v1.py python
603 lines 28.8 KB
Raw
sha256:c87cf2c2f03da507f66f93922cb339a2cbebb33ad6372a6039032fb09a20da12 Reconcile reviewed Overseer recovery d47291d5d9030de5ebef71… Human minor ⚠ breaking 9 days ago
1 """Repository-bound v1 commands. No registry, receipt, transaction, or network path."""
2 from __future__ import annotations
3
4 import argparse
5 import fcntl
6 import json
7 import os
8 from pathlib import Path
9 import re
10 import shlex
11 import stat
12 import subprocess
13 import sys
14 import uuid
15
16 import yaml
17
18 from cli.v1_io import LIMIT, Refusal, atomic, confined, digest, read
19 from cli.v1_policy import ignore_assets, local_path
20 from cli.v1_revision import discover, git, git_branch as branch, git_head as head, muse_read, revision
21
22 VERSION = "1.0.0"
23 KIT = Path(__file__).resolve().parent.parent
24 CONFIG = ".overseer/config.yaml"
25 NEXT = "docs/NEXT.md"
26 KINDS = ("plan", "implement", "review", "maintain", "stop")
27 TOKEN = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}\Z")
28 RUNTIME_FILES = ("cli/__init__.py", "cli/bootstrap.py", "cli/main.py", "cli/ok",
29 "cli/overseer", "cli/v1.py", "cli/v1_io.py", "cli/v1_revision.py",
30 "cli/v1_muse_reader.py", "cli/v1_policy.py", "cli/v1_mirror.py",
31 "cli/v1_mirror_reader.py", "scripts/muse-bridge-deploy.sh",
32 "templates/scripts/muse-bridge-deploy.sh.template", "cli/digest.py", "VERSION")
33
34
35 class UniqueLoader(yaml.SafeLoader):
36 pass
37
38
39 def unique_mapping(loader, node):
40 result = {}
41 for key_node, value_node in node.value:
42 key = loader.construct_object(key_node)
43 if not isinstance(key, str) or key in result:
44 raise Refusal("duplicate_or_invalid_key")
45 result[key] = loader.construct_object(value_node)
46 return result
47
48
49 UniqueLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, unique_mapping)
50
51
52 def decode(data: bytes):
53 try:
54 return yaml.load(data.decode("utf-8"), Loader=UniqueLoader)
55 except (UnicodeError, yaml.YAMLError, ValueError, TypeError) as exc:
56 raise Refusal("malformed_document") from exc
57
58
59 def encode(value) -> bytes:
60 return yaml.safe_dump(value, sort_keys=False, allow_unicode=True).encode()
61
62
63 def keys(value, required):
64 if not isinstance(value, dict) or set(value) != set(required):
65 raise Refusal("schema_keys_invalid")
66
67
68 def label(value):
69 if not isinstance(value, str) or not value.strip() or len(value) > 200 or any(ord(c) < 32 for c in value):
70 raise Refusal("label_invalid")
71 return value
72
73
74 def token(value):
75 if not isinstance(value, str) or not TOKEN.fullmatch(value):
76 raise Refusal("identifier_invalid")
77 return value
78
79
80 def root_for(args) -> Path:
81 cwd = Path.cwd().resolve()
82 requested = Path(args.repo).expanduser().resolve(strict=True) if args.repo else cwd
83 root = discover(requested)
84 if args.repo and requested != root:
85 raise Refusal("explicit_repo_must_be_root")
86 if args.bound_root and root != Path(args.bound_root):
87 raise Refusal("launcher_repository_mismatch")
88 if args.binding_path:
89 path = Path(os.path.abspath(args.binding_path))
90 if path.is_symlink() or path != root / ".overseer/bin/ok":
91 raise Refusal("launcher_location_mismatch")
92 if bool(args.bound_root) != bool(args.bound_id) or bool(args.bound_root) != bool(args.binding_path):
93 raise Refusal("launcher_binding_incomplete")
94 if args.config:
95 path = Path(args.config)
96 if not path.is_absolute():
97 path = root / path
98 if path != root / CONFIG:
99 raise Refusal("only_canonical_config_supported")
100 return root
101
102
103 def runtime():
104 if (KIT / "VERSION").read_text().strip() != VERSION:
105 raise Refusal("runtime_version_mismatch")
106 data = b"".join(p.encode() + b"\0" + (KIT / p).read_bytes() + b"\0" for p in RUNTIME_FILES)
107 return {"root": str(KIT), "version": VERSION, "sha256": digest(data)}
108
109
110 def config_for(root, args, *, syncing=False, data=None):
111 data = read(root, CONFIG) if data is None else data
112 config = decode(data)
113 if not isinstance(config, dict):
114 raise Refusal("schema_keys_invalid")
115 muse = config.get("vcs") == "muse"
116 keys(config, ("schema", "repo", "context", "runtime", "vcs", *(("muse",) if muse else ())))
117 if (type(config["schema"]) is not int or config["schema"] not in (1, 2)
118 or config["vcs"] not in ("git", "muse") or (muse and config["schema"] != 2)):
119 raise Refusal("config_version_or_vcs_unsupported")
120 if muse:
121 keys(config["muse"], ("repo_id", "store", "runtime"))
122 keys(config["muse"]["runtime"], ("python", "version", "sha256"))
123 for value in (*config["muse"]["runtime"].values(), config["muse"]["repo_id"], config["muse"]["store"]):
124 label(value)
125 keys(config["repo"], ("name", "id", "root"))
126 keys(config["context"], ("lane", "model"))
127 keys(config["runtime"], ("root", "version", "sha256"))
128 repo = config["repo"]
129 label(repo["name"])
130 try:
131 if str(uuid.UUID(repo["id"])) != repo["id"]:
132 raise ValueError()
133 except (ValueError, TypeError, AttributeError):
134 raise Refusal("repo_identity_invalid") from None
135 if repo["root"] != str(root):
136 raise Refusal("config_repository_mismatch")
137 if args.bound_id and repo["id"] != args.bound_id:
138 raise Refusal("launcher_identity_mismatch")
139 token(config["context"]["lane"])
140 label(config["context"]["model"])
141 if config["runtime"]["root"] != str(KIT):
142 raise Refusal("runtime_installation_mismatch")
143 if not syncing and config["runtime"] != runtime():
144 raise Refusal("runtime_changed: run this installation's explicit sync")
145 return config, data
146
147
148 def check_expectations(meta, args):
149 for field in ("repo_id", "branch", "lane", "model", "action_id", "action_kind", "vcs", "base_head"):
150 expected = getattr(args, field, None)
151 actual = meta.get(field, "git" if field == "vcs" else None)
152 if expected is not None and actual != expected:
153 raise Refusal("expected_" + field + "_mismatch")
154
155
156 def parse_next(data):
157 try:
158 text = data.decode("utf-8")
159 except UnicodeError:
160 raise Refusal("next_encoding_invalid") from None
161 if not text.startswith("---\n") or "\r" in text:
162 raise Refusal("next_frontmatter_invalid")
163 parts = text[4:].split("\n---\n", 1)
164 if len(parts) != 2:
165 raise Refusal("next_frontmatter_invalid")
166 meta = decode(parts[0].encode())
167 prompt = parts[1]
168 if not isinstance(meta, dict):
169 raise Refusal("schema_keys_invalid")
170 extra = ("vcs", "source_id") if meta.get("schema") == 2 else ()
171 keys(meta, ("schema", "repo_id", "repo_root", "branch", "base_head", "lane",
172 "model", "action_id", "action_kind", "prompt_sha256", *extra))
173 if type(meta["schema"]) is not int or meta["schema"] not in (1, 2):
174 raise Refusal("next_version_invalid")
175 if extra and (meta["vcs"] not in ("git", "muse") or
176 (meta["vcs"] == "git" and meta["source_id"] is not None)):
177 raise Refusal("next_revision_kind_invalid")
178 for field in ("repo_id", "repo_root", "branch", "base_head", "model"):
179 label(meta[field])
180 token(meta["lane"])
181 token(meta["action_id"])
182 if meta["action_kind"] not in KINDS:
183 raise Refusal("action_kind_invalid")
184 validate_prompt(prompt)
185 if meta["prompt_sha256"] != digest(prompt.encode()):
186 raise Refusal("prompt_digest_mismatch")
187 return meta, prompt
188
189
190 def validate_prompt(prompt):
191 if not prompt.strip() or len(prompt.encode()) > 64000 or any(
192 (ord(c) < 32 and c not in "\n\t") or ord(c) == 127 for c in prompt
193 ):
194 raise Refusal("prompt_invalid")
195 # Binding lines are generated from metadata, never trusted from prose.
196 if re.search(r"(?im)^\s*(repo(?:sitory)?(?: id| root)?|branch|lane|model|action id|action kind|step|revision(?: kind)?|muse repository id)\s*:", prompt):
197 raise Refusal("prompt_contains_binding_fields")
198
199
200 def validate_next(root, config, data, args, *, freshness=True, state=None):
201 meta, prompt = parse_next(data)
202 state = state or revision(root, config)
203 if meta.get("vcs", "git") != state.kind or meta.get("source_id") != state.source_id:
204 raise Refusal("next_revision_source_mismatch")
205 for field, expected in {"repo_id": config["repo"]["id"], "repo_root": str(root),
206 "branch": state.branch, **config["context"]}.items():
207 if meta[field] != expected:
208 raise Refusal("next_" + field + "_mismatch")
209 check_expectations(meta, args)
210 if getattr(args, "expect_next", None) and args.expect_next != digest(data):
211 raise Refusal("next_digest_mismatch")
212 current = state.head
213 if freshness and current != meta["base_head"]:
214 if state.kind != "git":
215 raise Refusal("next_stale_head")
216 # Permit the one closing commit that publishes these exact NEXT bytes.
217 # Any subsequent commit needs an explicit refresh through next-write.
218 parents = git(root, "rev-list", "--parents", "-n", "1", "HEAD").split()[1:]
219 expected_parents = [] if meta["base_head"] == "unborn" else [meta["base_head"]]
220 committed = git(root, "show", "HEAD:" + NEXT, optional=True)
221 if parents != expected_parents or committed != data.decode().strip():
222 raise Refusal("next_stale_head")
223 return meta, prompt
224
225
226 def rendered(meta, prompt):
227 binding = (f"Repository: {meta['repo_root']}\nRepository ID: {meta['repo_id']}\n"
228 f"Branch: {meta['branch']}\nLane: {meta['lane']}\nModel: {meta['model']}\n"
229 f"Action ID: {meta['action_id']}\nAction kind: {meta['action_kind']}\n")
230 if meta["schema"] == 2:
231 binding += f"Revision kind: {meta['vcs']}\nRevision: {meta['base_head']}\n"
232 if meta["vcs"] == "muse":
233 binding += f"Muse repository ID: {meta['source_id']}\n"
234 body = binding + "\n" + prompt.rstrip() + "\n"
235 fence = "`" * max(3, 1 + max((len(x) for x in re.findall(r"`+", body)), default=0))
236 return "## CURRENT NEXT — paste this\n\n" + fence + "text\n" + body + fence + "\n"
237
238
239 def write_next(root, config, args, prompt, *, expected):
240 state = revision(root, config)
241 if state.kind == "muse" and (getattr(args, "vcs", None) != "muse" or getattr(args, "base_head", None) is None):
242 raise Refusal("muse_writer_requires_vcs_and_base_head")
243 meta = {"schema": config["schema"], "repo_id": config["repo"]["id"], "repo_root": str(root),
244 "branch": state.branch, "base_head": state.head, **config["context"],
245 "action_id": args.action_id, "action_kind": args.action_kind,
246 "prompt_sha256": digest(prompt.encode())}
247 if meta["schema"] == 2:
248 meta.update(vcs=state.kind, source_id=state.source_id)
249 check_expectations(meta, args)
250 payload = b"---\n" + encode(meta) + b"---\n" + prompt.encode()
251 validate_next(root, config, payload, args, freshness=False, state=state)
252 path = confined(root, NEXT, parents=True)
253 # Advisory directory lock avoids ordinary concurrent lost updates without
254 # persistent lock state or a recovery protocol.
255 fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
256 try:
257 fcntl.flock(fd, fcntl.LOCK_EX)
258 if decode(read(root, CONFIG)) != config or revision(root, config) != state:
259 raise Refusal("context_changed_before_write")
260 atomic(root, NEXT, payload, expected=expected)
261 finally:
262 os.close(fd)
263 return payload
264
265
266 def launcher(root, identity):
267 q = shlex.quote
268 return ("#!/bin/sh\nset -eu\nPATH=/usr/bin:/bin\nexport PATH\n"
269 "unset PYTHONPATH PYTHONHOME VIRTUAL_ENV\nexec "
270 + q(str(KIT / ".venv/bin/python3")) + " -I -B " + q(str(KIT / "cli/bootstrap.py"))
271 + " --bound-root " + q(str(root)) + " --bound-id " + q(identity)
272 + ' --binding-path "$0" "$@"\n').encode()
273
274
275 def hook_script(root):
276 return ("#!/bin/sh\nset -eu\nexec " + shlex.quote(str(root / ".overseer/bin/ok"))
277 + ' hook --hook-path "$0"\n').encode()
278
279
280 def assets(root, config, hooks=False):
281 result = {".overseer/bin/ok": launcher(root, config["repo"]["id"])}
282 if hooks:
283 for name in ("session-start-next.sh", "session-end-closeout.sh"):
284 result[".cursor/hooks/" + name] = hook_script(root)
285 result[".cursor/hooks.json"] = (json.dumps({"version": 1, "hooks": {
286 "sessionStart": [{"command": str(root / ".cursor/hooks/session-start-next.sh")}],
287 "stop": [{"command": str(root / ".cursor/hooks/session-end-closeout.sh")}],
288 }}, indent=2) + "\n").encode()
289 return result
290
291
292 def apply_assets(root, mapping, *, dry_run=False, expected=None):
293 updates = []
294 # Validate all destinations before the first replacement.
295 for path, data in mapping.items():
296 target = confined(root, path, parents=not dry_run, missing_parents=dry_run)
297 old = read(root, path, missing=True)
298 prior = "absent" if old is None else digest(old)
299 if expected is not None and path in expected and expected[path] != prior:
300 raise Refusal("stale_write: " + path)
301 mode = 0o755 if path.endswith(".sh") or path.endswith("/ok") else 0o644
302 if old != data or (old is not None and stat.S_IMODE(target.stat().st_mode) != mode):
303 updates.append((path, data, prior, mode))
304 if not dry_run:
305 for path, data, expected, mode in updates:
306 atomic(root, path, data, expected=expected, mode=mode)
307 return [p for p, _, _, _ in updates]
308
309
310 def selected_vcs(root, args, existing=None):
311 selected = getattr(args, "vcs", None)
312 regime = getattr(args, "regime", None)
313 if regime:
314 mapped = {"git-only": "git", "muse-only": "muse", "muse+git-mirror": "muse"}[regime]
315 if selected and selected != mapped:
316 raise Refusal("conflicting_vcs_selection")
317 selected = mapped
318 if selected is None:
319 if existing:
320 return existing["vcs"]
321 if os.path.lexists(root / ".muse"):
322 raise Refusal("explicit_vcs_selection_required")
323 selected = "git"
324 return selected
325
326
327 def bind_muse(root, config, python):
328 if not python:
329 raise Refusal("muse_python_required")
330 state = muse_read(root, python)
331 return dict(config, schema=2, vcs="muse", muse={
332 "repo_id": state["repo_id"], "store": state["store"], "runtime": state["runtime"]})
333
334
335 def tracked_bindings(root, config):
336 result = {}
337 if os.path.lexists(root / ".git"):
338 result["git"] = sorted(p for p in git(root, "ls-files", "-z").split("\0") if local_path(p))
339 if config["vcs"] == "muse":
340 result["muse"] = list(revision(root, config).tracked_local)
341 # Git-only use deliberately does not inspect or load a Muse installation.
342 return result
343
344
345 def adopt(root, config, original, args):
346 if digest(original) != args.expect_config:
347 raise Refusal("stale_write: " + CONFIG)
348 for field, actual in {"repo_id": config["repo"]["id"], **config["context"]}.items():
349 if getattr(args, field) != actual:
350 raise Refusal("expected_" + field + "_mismatch")
351 expected = {CONFIG: digest(original)}
352 if args.restore_config:
353 if args.muse_python:
354 raise Refusal("rollback_uses_preserved_muse_binding")
355 if not args.restore_config.startswith(".overseer/local/"):
356 raise Refusal("rollback_requires_local_config_backup")
357 payload = read(root, args.restore_config)
358 updated, _ = config_for(root, args, syncing=True, data=payload)
359 if updated["repo"] != config["repo"] or updated["context"] != config["context"]:
360 raise Refusal("rollback_identity_or_context_mismatch")
361 mapping = {}
362 else:
363 updated = dict(config, schema=2, vcs=args.vcs, runtime=runtime())
364 updated.pop("muse", None)
365 if args.vcs == "muse":
366 python = args.muse_python or config.get("muse", {}).get("runtime", {}).get("python")
367 updated = bind_muse(root, updated, python)
368 elif args.muse_python:
369 raise Refusal("git_does_not_use_muse_python")
370 payload = encode(updated)
371 mapping = ignore_assets(root, expected=expected)
372 target = revision(root, updated)
373 if target.branch != args.branch or target.head != args.base_head:
374 raise Refusal("adoption_target_revision_mismatch")
375 tracked = tracked_bindings(root, updated)
376 backup = ".overseer/local/config-before-" + digest(original) + ".yaml"
377 old_backup = read(root, backup, missing=True)
378 if old_backup is not None and old_backup != original:
379 raise Refusal("adoption_backup_mismatch")
380 expected[backup] = "absent" if old_backup is None else digest(old_backup)
381 mapping[backup] = original
382 mapping[CONFIG] = payload
383 changes = apply_assets(root, mapping, dry_run=True, expected=expected)
384 result = {"ok": not any(tracked.values()), "repository": config["repo"],
385 "vcs": updated["vcs"], "branch": target.branch, "head": target.head,
386 "changed": changes, "backup": backup, "tracked_local": tracked,
387 "config_sha256": digest(payload), "dry_run": args.dry_run,
388 "next": "preserved; publish explicitly with next-write after authority change",
389 "ignore_rollback": "additive exclusions retained; existing rules and edits preserved"}
390 if not args.dry_run:
391 if not result["ok"]:
392 raise Refusal("tracked_local_bindings: review dry-run; no automatic untracking")
393 if read(root, CONFIG) != original or revision(root, updated) != target:
394 raise Refusal("context_changed_before_adoption")
395 # Config is replaced last. A failure beforehand leaves the old binding
396 # usable and additive ignores/backup safe to retain and retry.
397 apply_assets(root, mapping, expected=expected)
398 return result
399
400
401 def initialize(root, args):
402 confined(root, CONFIG, parents=True)
403 existing = read(root, CONFIG, missing=True)
404 if existing is not None:
405 config, _ = config_for(root, args)
406 if selected_vcs(root, args, config) != config["vcs"] or args.muse_python:
407 raise Refusal("use_explicit_adopt_for_config_changes")
408 else:
409 config = {"schema": 2, "repo": {"name": label(args.repo_name or root.name),
410 "id": str(uuid.uuid4()), "root": str(root)}, "context": {
411 "lane": token(args.lane), "model": label(args.model)},
412 "runtime": runtime(), "vcs": selected_vcs(root, args)}
413 if config["vcs"] == "muse":
414 config = bind_muse(root, config, args.muse_python)
415 elif args.muse_python:
416 raise Refusal("git_does_not_use_muse_python")
417 state = revision(root, config)
418 confined(root, NEXT, parents=True)
419 current = read(root, NEXT, missing=True)
420 if current is not None:
421 validate_next(root, config, current, argparse.Namespace(), freshness=False)
422 # Refuse preexisting unmanaged launchers rather than silently taking ownership.
423 mapping = assets(root, config, args.hooks)
424 ignores = ignore_assets(root)
425 tracked = tracked_bindings(root, config)
426 if existing is None and any(tracked.values()):
427 raise Refusal("tracked_local_bindings: review and explicitly untrack before init")
428 for path in ("docs/ROADMAP.md", "docs/OVERSEER-HANDOVER.md"):
429 confined(root, path)
430 for path in mapping:
431 confined(root, path, parents=True)
432 old = read(root, path, missing=True)
433 if existing is None and old is not None:
434 raise Refusal("init_asset_exists: " + path)
435 if existing is None:
436 atomic(root, CONFIG, encode(config), expected="absent")
437 for path, body in {"docs/ROADMAP.md": "# Roadmap\n\nCurrent action: docs/NEXT.md.\n",
438 "docs/OVERSEER-HANDOVER.md": "# Handover\n\nRead current action with `ok next`.\n"}.items():
439 if read(root, path, missing=True) is None:
440 atomic(root, path, body.encode(), expected="absent")
441 if current is None:
442 initial = argparse.Namespace(action_id="SETUP", action_kind="plan", vcs=state.kind, base_head=state.head)
443 write_next(root, config, initial, "Choose the first bounded task for this repository.\n", expected="absent")
444 apply_assets(root, mapping)
445 apply_assets(root, ignores)
446 return {"ok": True, "repository": config["repo"], "initialized": True}
447
448
449 def parser():
450 p = argparse.ArgumentParser(prog="ok", description="Bounded repository and NEXT utility",
451 allow_abbrev=False)
452 def globals_to(target):
453 for names, opts in [(("-C", "--repo"), {}), (("--config",), {}),
454 (("--json",), {"action": "store_true"}),
455 (("--bound-root",), {"help": argparse.SUPPRESS}),
456 (("--bound-id",), {"help": argparse.SUPPRESS}),
457 (("--binding-path",), {"help": argparse.SUPPRESS})]:
458 target.add_argument(*names, default=argparse.SUPPRESS, **opts)
459 globals_to(p)
460 p.set_defaults(repo=None, config=None, json=False, bound_root=None, bound_id=None, binding_path=None)
461 p.add_argument("--version", action="version", version=VERSION)
462 sub = p.add_subparsers(dest="command", required=True)
463 for name in ("status", "init", "sync", "adopt", "mirror", "next", "next-write", "hook"):
464 cmd = sub.add_parser(name, allow_abbrev=False)
465 globals_to(cmd)
466 if name == "mirror":
467 cmd.add_argument("operation", choices=("prepare", "verify", "deliver"))
468 cmd.add_argument("--plan-file", required=True, help="Confined reviewed JSON plan")
469 cmd.add_argument("--expect-plan", required=True, help="SHA-256 of the exact approved plan bytes")
470 cmd.add_argument("--gh", help="Absolute gh executable, required only for explicit delivery")
471 if name == "init":
472 cmd.add_argument("--regime", choices=("git-only", "muse-only", "muse+git-mirror"))
473 cmd.add_argument("--vcs", choices=("git", "muse"))
474 cmd.add_argument("--muse-python", help="Absolute Python path in the separate Muse venv")
475 cmd.add_argument("--repo-name")
476 cmd.add_argument("--lane", default="product")
477 cmd.add_argument("--model", default="GPT-6 Astra")
478 cmd.add_argument("--non-interactive", action="store_true")
479 if name in ("init", "sync"):
480 cmd.add_argument("--hooks", action="store_true", help="Install bound hooks (fixtures only in this milestone)")
481 if name == "sync":
482 cmd.add_argument("--dry-run", action="store_true")
483 if name == "adopt":
484 selection = cmd.add_mutually_exclusive_group(required=True)
485 selection.add_argument("--vcs", choices=("git", "muse"))
486 selection.add_argument("--restore-config", help="Confined .overseer/local config backup")
487 cmd.add_argument("--muse-python")
488 cmd.add_argument("--expect-config", required=True)
489 for field in ("repo-id", "branch", "lane", "model", "base-head"):
490 cmd.add_argument("--" + field, required=True)
491 cmd.add_argument("--dry-run", action="store_true")
492 if name in ("next", "next-write", "status"):
493 for field in ("repo-id", "branch", "lane", "model", "action-id", "action-kind"):
494 cmd.add_argument("--" + field, required=name == "next-write",
495 choices=KINDS if field == "action-kind" else None)
496 cmd.add_argument("--expect-next", required=name == "next-write")
497 cmd.add_argument("--vcs", choices=("git", "muse"))
498 cmd.add_argument("--base-head", help="Expected authoritative revision; required for Muse writes")
499 if name == "next-write":
500 cmd.add_argument("--prompt-file", required=True, help="Confined repository-relative UTF-8 file")
501 if name == "hook":
502 cmd.add_argument("--hook-path", required=True)
503 return p
504
505
506 def main(argv=None):
507 argv = list(sys.argv[1:] if argv is None else argv)
508 lock = None
509 try:
510 # Argparse's last-wins behavior must not override a bound launcher's root.
511 for aliases in (("-C", "--repo"), ("--config",), ("--bound-root",), ("--bound-id",), ("--binding-path",)):
512 if sum(a.split("=", 1)[0] in aliases or (a.startswith("-C") and a != "-C" and "-C" in aliases) for a in argv) > 1:
513 raise Refusal("duplicate_binding_option")
514 args = parser().parse_args(argv)
515 root = root_for(args)
516 if args.command in ("init", "sync", "adopt", "next-write"):
517 lock = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
518 fcntl.flock(lock, fcntl.LOCK_EX)
519 if args.command == "init":
520 result = initialize(root, args)
521 else:
522 config, original = config_for(root, args, syncing=args.command in ("sync", "adopt"))
523 if args.command == "mirror":
524 from cli.v1_mirror import command
525 result = command(root, config, original, args)
526 elif args.command == "adopt":
527 result = adopt(root, config, original, args)
528 elif args.command == "sync":
529 confined(root, NEXT)
530 revision(root, config)
531 updated = dict(config, runtime=runtime())
532 mapping = assets(root, updated, args.hooks)
533 mapping[CONFIG] = encode(updated)
534 changed = apply_assets(root, mapping, dry_run=args.dry_run)
535 result = {"ok": True, "repository": config["repo"], "changed": changed, "dry_run": args.dry_run}
536 elif args.command == "next-write":
537 prompt = read(root, args.prompt_file).decode("utf-8")
538 if not prompt.endswith("\n"):
539 prompt += "\n"
540 # The expected digest applies to the old NEXT, not the new candidate.
541 expected = args.expect_next
542 args.expect_next = None
543 result = {"ok": True, "next_sha256": digest(write_next(root, config, args, prompt, expected=expected))}
544 else:
545 if args.command == "hook":
546 path = Path(os.path.abspath(args.hook_path))
547 if not args.bound_id or path.is_symlink() or path not in (
548 root / ".cursor/hooks/session-start-next.sh", root / ".cursor/hooks/session-end-closeout.sh"):
549 raise Refusal("hook_location_mismatch")
550 if not sys.stdin.isatty():
551 raw = sys.stdin.buffer.read(LIMIT + 1)
552 if len(raw) > LIMIT:
553 raise Refusal("hook_input_too_large")
554 if raw.strip():
555 event = json.loads(raw)
556 if not isinstance(event, dict):
557 raise Refusal("hook_input_invalid")
558 roots = event.get("workspace_roots", [])
559 if roots and (not isinstance(roots, list) or len(roots) != 1 or not isinstance(roots[0], str) or Path(roots[0]).resolve() != root):
560 raise Refusal("hook_workspace_mismatch")
561 state = revision(root, config, dirty=args.command == "status")
562 result = {"ok": True, "repository": config["repo"], "branch": state.branch,
563 "head": state.head, "vcs": state.kind, "source_id": state.source_id,
564 "runtime": runtime(), "config_sha256": digest(original),
565 "publication": "not_checked_offline"}
566 try:
567 data = read(root, NEXT)
568 meta, prompt = validate_next(root, config, data, args, state=state)
569 result.update(next=meta, next_sha256=digest(data))
570 except Refusal as exc:
571 if args.command != "status":
572 raise
573 result.update(ok=False, next_error=str(exc))
574 if args.command in ("next", "hook"):
575 output = rendered(meta, prompt)
576 if args.command == "hook":
577 print(json.dumps({"additional_context": output, "followup_message": output}))
578 elif args.json:
579 print(json.dumps(dict(result, prompt=prompt, rendered=output)))
580 else:
581 print(output, end="")
582 return 0
583 result["dirty"] = state.dirty
584 if state.kind == "muse":
585 result["dirty_basis"] = "working_files_vs_snapshot_and_shared_stage"
586 result["tracked_local"] = list(state.tracked_local)
587 if args.json:
588 print(json.dumps(result))
589 else:
590 print(json.dumps(result, indent=2))
591 return 0 if result["ok"] else 1
592 except (Refusal, OSError, ValueError, UnicodeError, subprocess.SubprocessError) as exc:
593 # No partial prompt and no traceback/config bytes on refusal.
594 message = str(exc) if isinstance(exc, Refusal) else type(exc).__name__
595 print("refused: " + message[:300], file=sys.stderr)
596 return 2
597 finally:
598 if lock is not None:
599 os.close(lock)
600
601
602 if __name__ == "__main__":
603 raise SystemExit(main())
File History 1 commit
sha256:c87cf2c2f03da507f66f93922cb339a2cbebb33ad6372a6039032fb09a20da12 Reconcile reviewed Overseer recovery d47291d5d9030de5ebef71… Human minor ⚠ 9 days ago