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