v1.py
file-level
1
files
1
commits
0
hotspots
0
🧊 dead
0
💥 blast risk
| 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()) |