diff --git a/CHANGELOG.md b/CHANGELOG.md index 93f80c3..1ac57e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # Changelog +## 0.2.0 - 2026-06-12 + +### Added +- `scripts/state.py`: shared module for git-root discovery, state-home resolution, + repository identity, run selection, schema migration, atomic writes, and file locking +- `--state-dir` global option: override state home directory +- `--run-id` global option: select a specific run for run-scoped commands +- `list-runs` command: show active runs for the current repository +- `show-run` command: show full details for one run +- `migrate-legacy-state` command: non-destructive import of legacy `.ai/` state +- `archive-run` command: mark a run archived +- `accept-drift` command: acknowledge and record a new git baseline +- XDG-based external state storage: `~/.local/state/claude-autonomous/` +- Multiple concurrent runs per repository with collision-resistant run IDs +- Drift detection: blocks unsafe changes (branch/identity/worktree changes) +- File locking (`fcntl.flock`) for concurrent controller/stop-gate safety +- Schema version 2 with validation and backward-compatible v1 loading + +### Changed +- Controller and stop gate now discover repository root via `git rev-parse --show-toplevel` +- Stop gate uses shared state resolver (no more hardcoded CWD-relative path) +- Run state stores artifact paths relative to run directory +- Legacy `.ai/autonomous-development/` layout auto-detected as fallback + +### Backward compatible +- All existing commands (`init`, `codex`, `accept`, `run-check`, `status`, etc.) work unchanged +- Single-run workflows require no new flags +- Legacy state automatically detected and usable without migration + ## 0.1.0 - 2026-06-12 - Initial plugin boilerplate. diff --git a/README.md b/README.md index 23e3073..b98797d 100644 --- a/README.md +++ b/README.md @@ -80,25 +80,142 @@ make check claude plugin validate . --strict ``` +## State location + +State is stored outside the target repository by default. The resolver uses the following +precedence: + +```bash +# 1. Explicit state directory (highest priority) +controller.py --state-dir /path/to/state init --feature "..." + +# 2. Environment variable +export CLAUDE_AUTONOMOUS_STATE_HOME=~/.local/state/claude-autonomous +controller.py init --feature "..." + +# 3. XDG default (Linux) +# Automatically uses ~/.local/state/claude-autonomous/ + +# 4. Legacy fallback (existing .ai/autonomous-development/ detected automatically) +``` + +On macOS the default is `~/Library/Application Support/claude-autonomous/`. +On Windows the default is `%LOCALAPPDATA%\claude-autonomous\`. + ## State and generated artifacts -Each target repository receives a local run directory: +Each run is stored in its own directory under the state home: ```text -.ai/autonomous-development/ -├── run-state.json -├── feature-request.md -├── repository-context.txt -├── feature-spec.codex.json -├── accepted-spec.md -├── implementation-plan.codex.json -├── accepted-plan.md -├── review-01.codex.json -├── adversarial-01.codex.json -└── verification/ +~/.local/state/claude-autonomous/ +├── repositories/ +│ └── / +│ ├── metadata.json +│ └── runs/ +│ └── / +│ ├── run-state.json +│ ├── feature-request.md +│ ├── repository-context.txt +│ ├── accepted-spec.md +│ ├── accepted-plan.md +│ ├── feature-spec.codex.json +│ ├── implementation-plan.codex.json +│ ├── review-01.codex.json +│ └── verification/ +``` + +The legacy `.ai/autonomous-development/` layout is still supported for backward compatibility +and is auto-detected when present. To suppress it, add `.ai/` to your `.gitignore` once you +have migrated (see "Migration from legacy state" below) or if you never want to commit the +planning evidence. + +## Multiple runs and run IDs + +Each `init` creates a new run with a collision-resistant run ID of the form +`-<8-hex-chars>` (for example `20260612T134500Z-a1b2c3d4`). + +```bash +# List all active runs for the current repository +controller.py list-runs + +# Show details for a specific run +controller.py show-run --run-id 20260612T134500Z-a1b2c3d4 + +# Start a second concurrent run with an optional human-readable label +controller.py init --feature "New feature" --label "experiment" + +# Run commands against a specific run when multiple are active +controller.py status --run-id 20260612T134500Z-a1b2c3d4 +``` + +When exactly one active run exists, `--run-id` is optional and the run is selected +automatically. When multiple active runs exist, commands that mutate state require +`--run-id` to avoid ambiguity. + +## Migration from legacy state + +```bash +# Migrate existing .ai/autonomous-development/ state to the new external layout +controller.py migrate-legacy-state + +# The original .ai/autonomous-development/ directory is preserved unchanged. +# To use the migrated state, either: +export CLAUDE_AUTONOMOUS_STATE_HOME=~/.local/state/claude-autonomous +# or add .ai/ to your .gitignore and continue; legacy state remains accessible. +``` + +Migration is non-destructive and idempotent. Run it again with `--force` to overwrite an +already-migrated run directory. + +## Drift detection and recovery + +The controller detects when the repository state diverges from the recorded baseline before +any mutating command. Two kinds of drift are distinguished: + +- **EXPECTED**: HEAD has advanced on the same branch (commits were added). No action required. +- **UNSAFE**: Branch changed, worktree path changed, or repository identity changed. Mutating + commands are blocked until the drift is acknowledged. + +```bash +# If an unsafe drift is detected (e.g., branch changed), you will see: +# error: Unsafe repository drift detected: branch changed: main -> experiment +# Recovery: Run `accept-drift` to acknowledge and record the new baseline. + +controller.py accept-drift +``` + +## Archiving runs + +```bash +# Archive a completed run (removes it from the default list-runs output) +controller.py archive-run --run-id 20260612T134500Z-a1b2c3d4 + +# Show all runs including archived ones +controller.py list-runs --all ``` -Add `.ai/` to the target repository's `.gitignore` unless the generated planning evidence should be committed. +Archiving is a metadata flag; no files are deleted. + +## Security and permissions + +- State directories are created with mode `0o700` (owner-only) on POSIX systems. +- Review artifacts and Codex responses may contain sensitive code, prompts, or design details. +- Do not share state directories across users or store them on world-readable paths. +- Remote URLs are stored with credentials stripped (the `user:pass@` portion is removed). + +## Worktree support + +All commands work correctly from any linked worktree. The repository identity is derived from +the shared git object store so runs created in different worktrees belong to the same +repository and are visible to `list-runs`. + +```bash +# Create a linked worktree and run the workflow there +git worktree add ../experiment feature-branch +cd ../experiment +controller.py init --feature "Experiment feature" +# State stored under the same repository ID, new run ID +``` ## Completion rules diff --git a/scripts/controller.py b/scripts/controller.py index cdd7e48..07848fc 100755 --- a/scripts/controller.py +++ b/scripts/controller.py @@ -6,7 +6,6 @@ import argparse import datetime as dt import json -import os import re import shutil import subprocess @@ -14,24 +13,58 @@ from pathlib import Path from typing import Any, Iterable +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from state import ( + StateError, + RepoInfo, + DriftKind, + resolve_repository, + resolve_state_home, + detect_legacy_state, + new_run_id, + run_dir_path, + make_relative_path, + resolve_artifact_path, + RunStateLock, + migrate_v1_to_v2, + load_run_state, + save_run_state, + load_repo_metadata, + save_repo_metadata, + find_active_runs, + find_all_runs, + resolve_active_run, + detect_drift, + repository_context, + LEGACY_STATE_REL, +) + PLUGIN_ROOT = Path(__file__).resolve().parents[1] -STATE_DIR_NAME = Path('.ai/autonomous-development') -STATE_FILE_NAME = 'run-state.json' -TERMINAL_STATUSES = {'complete', 'blocked', 'cancelled'} PHASE_OUTPUTS = { - 'enhance': ('prompts/enhance-idea.md', 'schemas/enhanced-idea.schema.json', 'feature-spec.codex.json'), - 'plan': ('prompts/implementation-plan.md', 'schemas/implementation-plan.schema.json', 'implementation-plan.codex.json'), - 'review': ('prompts/code-review.md', 'schemas/review.schema.json', None), - 'adversarial': ('prompts/adversarial-review.md', 'schemas/adversarial-review.schema.json', None), + "enhance": ( + "prompts/enhance-idea.md", + "schemas/enhanced-idea.schema.json", + "feature-spec.codex.json", + ), + "plan": ( + "prompts/implementation-plan.md", + "schemas/implementation-plan.schema.json", + "implementation-plan.codex.json", + ), + "review": ("prompts/code-review.md", "schemas/review.schema.json", None), + "adversarial": ( + "prompts/adversarial-review.md", + "schemas/adversarial-review.schema.json", + None, + ), } - -class WorkflowError(RuntimeError): - """A user-actionable workflow error.""" +# Backward-compat alias +WorkflowError = StateError def utc_now() -> str: - return dt.datetime.now(dt.timezone.utc).isoformat(timespec='seconds') + return dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds") def run_process( @@ -52,473 +85,747 @@ def run_process( check=check, ) except FileNotFoundError as exc: - raise WorkflowError(f'Required executable not found: {args[0]}') from exc + raise WorkflowError(f"Required executable not found: {args[0]}") from exc def git(root: Path, *args: str, check: bool = True) -> str: - result = run_process(['git', *args], cwd=root) + result = run_process(["git", *args], cwd=root) if check and result.returncode != 0: raise WorkflowError(result.stderr.strip() or f"git {' '.join(args)} failed") return result.stdout.strip() -def project_root(value: str | None) -> Path: - root = Path(value or os.getcwd()).resolve() - if not root.is_dir(): - raise WorkflowError(f'Project root is not a directory: {root}') - return root +def read_optional(path: Path) -> str: + if not path.exists(): + return "(not available)" + text = path.read_text(encoding="utf-8", errors="replace").strip() + return text or "(empty)" -def state_dir(root: Path) -> Path: - return root / STATE_DIR_NAME +def render(template: str, values: dict[str, str]) -> str: + rendered = template + for key, value in values.items(): + rendered = rendered.replace("{{" + key + "}}", value) + unresolved = sorted(set(re.findall(r"\{\{([A-Z0-9_]+)\}\}", rendered))) + if unresolved: + raise WorkflowError(f"Unresolved prompt placeholders: {', '.join(unresolved)}") + return rendered -def state_file(root: Path) -> Path: - return state_dir(root) / STATE_FILE_NAME +def slug(value: str) -> str: + clean = re.sub(r"[^a-zA-Z0-9._-]+", "-", value.strip()).strip("-").lower() + return clean[:80] or "check" -def atomic_write_json(path: Path, value: dict[str, Any]) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - temp = path.with_suffix(path.suffix + '.tmp') - temp.write_text(json.dumps(value, indent=2, sort_keys=True) + '\n', encoding='utf-8') - temp.replace(path) +# --------------------------------------------------------------------------- +# Shared context helpers +# --------------------------------------------------------------------------- -def load_state(root: Path, required: bool = True) -> dict[str, Any]: - path = state_file(root) - if not path.exists(): - if required: - raise WorkflowError(f'No active workflow state at {path}') - return {} - try: - state = json.loads(path.read_text(encoding='utf-8')) - except (OSError, json.JSONDecodeError) as exc: - raise WorkflowError(f'Invalid workflow state: {path}: {exc}') from exc - if not isinstance(state, dict): - raise WorkflowError(f'Workflow state must be a JSON object: {path}') - return state +def get_context(args: argparse.Namespace) -> tuple[RepoInfo, Path, str | None]: + """Return (repo, state_home, run_id_override) from parsed args.""" + start = Path(args.project_root).resolve() if args.project_root else None + repo = resolve_repository(start) + state_home = resolve_state_home(getattr(args, "state_dir", None)) + run_id = getattr(args, "run_id", None) + return repo, state_home, run_id -def save_state(root: Path, state: dict[str, Any]) -> None: - state['updated_at'] = utc_now() - atomic_write_json(state_file(root), state) +def require_no_unsafe_drift(state: dict, repo: RepoInfo) -> None: + """Raise WorkflowError if unsafe drift detected. Expected drift is allowed.""" + drift = detect_drift(state, repo) + if drift.kind == DriftKind.UNSAFE: + raise WorkflowError( + f"Unsafe repository drift detected: {drift.message}\n" + f"Recovery: {drift.recovery}\n" + f"Use `accept-drift` to record the new baseline when safe." + ) -def read_optional(path: Path) -> str: - if not path.exists(): - return '(not available)' - text = path.read_text(encoding='utf-8', errors='replace').strip() - return text or '(empty)' +# --------------------------------------------------------------------------- +# Prompt values helper +# --------------------------------------------------------------------------- + + +def prompt_values(run_dir: Path, state: dict[str, Any]) -> dict[str, str]: + """Compute template placeholder values for Codex prompts.""" + artifacts = state.get("artifacts", {}) + + def artifact_text(key: str, fallback: str) -> str: + rel = artifacts.get(key, "") + if rel: + try: + path = resolve_artifact_path(str(rel), run_dir) + return read_optional(path) + except (StateError, OSError): + pass + # fallback path relative to run_dir + fallback_path = run_dir / fallback + return read_optional(fallback_path) + + previous_review = "(none)" + reviews = state.get("reviews", []) + if reviews: + last_review = reviews[-1] + rel_path = last_review.get("path", "") + if rel_path: + try: + review_path = resolve_artifact_path(str(rel_path), run_dir) + previous_review = read_optional(review_path) + round_num = last_review.get("round", 0) + triage_path = run_dir / f"triage-{round_num:02d}.md" + if triage_path.exists(): + previous_review += "\n\nTRIAGE\n" + read_optional(triage_path) + except (StateError, OSError): + pass + return { + "FEATURE": state.get("feature", "(missing)"), + "BASELINE": state.get("baseline", {}).get("commit", "(missing)"), + "REPOSITORY_CONTEXT": artifact_text( + "repository_context", "repository-context.txt" + ), + "CODEX_SPEC": artifact_text("enhance", "feature-spec.codex.json"), + "ACCEPTED_SPEC": artifact_text("accepted_spec", "accepted-spec.md"), + "ACCEPTED_PLAN": artifact_text("accepted_plan", "accepted-plan.md"), + "VERIFICATION": json.dumps(state.get("verification", {}), indent=2), + "PREVIOUS_REVIEW": previous_review, + "LATEST_REVIEW": previous_review, + } -def render(template: str, values: dict[str, str]) -> str: - rendered = template - for key, value in values.items(): - rendered = rendered.replace('{{' + key + '}}', value) - unresolved = sorted(set(re.findall(r'\{\{([A-Z0-9_]+)\}\}', rendered))) - if unresolved: - raise WorkflowError(f"Unresolved prompt placeholders: {', '.join(unresolved)}") - return rendered +# --------------------------------------------------------------------------- +# Latest checks / finding helpers +# --------------------------------------------------------------------------- -def repository_context(root: Path) -> str: - tracked = git(root, 'ls-files', check=False).splitlines() - top_files = '\n'.join(tracked[:250]) - status = git(root, 'status', '--short', check=False) - remotes = git(root, 'remote', '-v', check=False) - return ( - f"Repository: {root.name}\n" - f"Branch: {git(root, 'branch', '--show-current', check=False) or '(detached)'}\n" - f"HEAD: {git(root, 'rev-parse', 'HEAD', check=False) or '(unknown)'}\n" - f"Working tree status:\n{status or '(clean)'}\n\n" - f"Remotes (informational only; workflow must not push):\n{remotes or '(none)'}\n\n" - f"First tracked files (maximum 250):\n{top_files or '(none)'}\n" - ) + +def latest_verification_checks(checks: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Return only the latest result for each logical verification check name.""" + latest: dict[str, dict[str, Any]] = {} + order: list[str] = [] + for check in checks: + name = str(check.get("name", "unnamed")) + if name not in latest: + order.append(name) + latest[name] = check + return [latest[name] for name in order] + + +def unresolved_severe_findings(review: dict[str, Any]) -> list[dict[str, Any]]: + return [ + finding + for finding in review.get("findings", []) + if isinstance(finding, dict) and finding.get("severity") in {"critical", "high"} + ] + + +# --------------------------------------------------------------------------- +# cmd_doctor +# --------------------------------------------------------------------------- def cmd_doctor(args: argparse.Namespace) -> int: - root = project_root(args.project_root) + # Use project_root fallback for doctor; git check is optional + start = Path(args.project_root).resolve() if args.project_root else Path.cwd() failures: list[str] = [] - print(f'Python: {sys.version.split()[0]}') + print(f"Python: {sys.version.split()[0]}") if sys.version_info < (3, 11): - failures.append('Python 3.11 or later is required') + failures.append("Python 3.11 or later is required") - for executable in ('git', 'codex'): + for executable in ("git", "codex"): path = shutil.which(executable) print(f'{executable}: {path or "not found"}') if path is None: - failures.append(f'{executable} is not installed or not on PATH') + failures.append(f"{executable} is not installed or not on PATH") - inside = git(root, 'rev-parse', '--is-inside-work-tree', check=False) + # Verify via resolve_repository as well as raw git check + inside = git(start, "rev-parse", "--is-inside-work-tree", check=False) print(f'Git repository: {inside == "true"}') - if inside != 'true': - failures.append(f'{root} is not inside a Git worktree') - - if shutil.which('codex'): - version = run_process(['codex', '--version'], cwd=root) - print(f'Codex version: {(version.stdout or version.stderr).strip() or "unknown"}') - auth = run_process(['codex', 'login', 'status'], cwd=root) - print(f'Codex authentication: {"ready" if auth.returncode == 0 else "not ready"}') + if inside != "true": + failures.append(f"{start} is not inside a Git worktree") + else: + try: + resolve_repository(start) + except StateError as exc: + failures.append(f"Repository resolver failed: {exc}") + + if shutil.which("codex"): + version = run_process(["codex", "--version"], cwd=start) + print( + f'Codex version: {(version.stdout or version.stderr).strip() or "unknown"}' + ) + auth = run_process(["codex", "login", "status"], cwd=start) + print( + f'Codex authentication: {"ready" if auth.returncode == 0 else "not ready"}' + ) if auth.returncode != 0: - failures.append('Codex is not authenticated; run `codex login`') + failures.append("Codex is not authenticated; run `codex login`") if failures: - print('\nDoctor found problems:', file=sys.stderr) + print("\nDoctor found problems:", file=sys.stderr) for failure in failures: - print(f'- {failure}', file=sys.stderr) + print(f"- {failure}", file=sys.stderr) return 1 - print('\nAll required local prerequisites are available.') + print("\nAll required local prerequisites are available.") return 0 +# --------------------------------------------------------------------------- +# cmd_init +# --------------------------------------------------------------------------- + + def cmd_init(args: argparse.Namespace) -> int: - root = project_root(args.project_root) - if git(root, 'rev-parse', '--is-inside-work-tree', check=False) != 'true': - raise WorkflowError('Run initialization inside a Git worktree') + repo, state_home, run_id_override = get_context(args) - existing = load_state(root, required=False) - if existing and existing.get('status') not in TERMINAL_STATUSES: + if not repo.head_commit: + raise WorkflowError( + "Git repository has no commits; cannot initialize a workflow run." + ) + + feature = args.feature.strip() + if not feature: + raise WorkflowError("Feature idea must not be empty") + + label = "" + if getattr(args, "label", None): + raw_label = args.label.strip() + label = re.sub(r"[^a-zA-Z0-9._-]+", "-", raw_label).strip("-").lower()[:80] + + active_runs = find_active_runs(state_home, repo.id) + + if active_runs: if args.reuse: - print(state_file(root)) + if len(active_runs) > 1: + ids = ", ".join(r.run_id for r in active_runs) + raise WorkflowError( + f"Multiple active runs exist: {ids}. " + "Use --run-id to select one explicitly." + ) + run_ref = active_runs[0] + run_dir = run_ref.run_dir + state_path = run_dir / "run-state.json" + print(state_path) return 0 if not args.force: + ids = ", ".join(r.run_id for r in active_runs) raise WorkflowError( - 'An active workflow already exists. Use `status`, `cancel`, `--reuse`, or `--force`.' + f"Active workflow run(s) already exist: {ids}. " + "Use `status`, `cancel`, `--reuse`, or `--force`." ) - feature = args.feature.strip() - if not feature: - raise WorkflowError('Feature idea must not be empty') - - directory = state_dir(root) - directory.mkdir(parents=True, exist_ok=True) - context = repository_context(root) - (directory / 'feature-request.md').write_text(feature + '\n', encoding='utf-8') - (directory / 'repository-context.txt').write_text(context, encoding='utf-8') - - baseline = git(root, 'rev-parse', 'HEAD') - dirty = git(root, 'status', '--short', check=False).splitlines() - state: dict[str, Any] = { - 'version': 1, - 'run_id': dt.datetime.now(dt.timezone.utc).strftime('%Y%m%dT%H%M%SZ'), - 'feature': feature, - 'status': 'active', - 'phase': 'initialized', - 'created_at': utc_now(), - 'updated_at': utc_now(), - 'baseline': { - 'commit': baseline, - 'branch': git(root, 'branch', '--show-current', check=False), - 'dirty_entries_at_init': dirty, - }, - 'max_review_rounds': args.max_review_rounds, - 'review_round': 0, - 'stop_gate_blocks': 0, - 'artifacts': { - 'feature_request': str(directory / 'feature-request.md'), - 'repository_context': str(directory / 'repository-context.txt'), - }, - 'verification': {'checks': [], 'passed': False}, - 'reviews': [], - 'adversarial_reviews': [], - 'risk': {'requires_adversarial_review': False, 'reasons': []}, - 'notes': [], - } - save_state(root, state) - print(state_file(root)) - return 0 + run_id = run_id_override or new_run_id() + run_dir = run_dir_path(state_home, repo.id, run_id) + + with RunStateLock(run_dir): + run_dir.mkdir(parents=True, exist_ok=True, mode=0o700) + + ctx_text = repository_context(repo) + (run_dir / "feature-request.md").write_text(feature + "\n", encoding="utf-8") + (run_dir / "repository-context.txt").write_text(ctx_text, encoding="utf-8") + + dirty = git(repo.canonical_root, "status", "--short", check=False).splitlines() + + state: dict[str, Any] = { + "schema_version": 2, + "run_id": run_id, + "label": label, + "feature": feature, + "status": "active", + "phase": "initialized", + "created_at": utc_now(), + "updated_at": utc_now(), + "repository": { + "id": repo.id, + "canonical_root": str(repo.canonical_root), + "git_common_dir": str(repo.git_common_dir), + "worktree_path": str(repo.worktree_path), + "display_name": repo.display_name, + "remote_display": repo.remote_display, + }, + "baseline": { + "commit": repo.head_commit, + "branch": repo.branch, + "dirty_entries_at_init": dirty, + }, + "max_review_rounds": args.max_review_rounds, + "review_round": 0, + "stop_gate_blocks": 0, + "artifacts": { + "feature_request": "feature-request.md", + "repository_context": "repository-context.txt", + }, + "verification": {"checks": [], "passed": False}, + "reviews": [], + "adversarial_reviews": [], + "risk": {"requires_adversarial_review": False, "reasons": []}, + "notes": [], + } + save_run_state(run_dir, state) + + # Save repo metadata + meta = load_repo_metadata(state_home, repo.id) + meta.update( + { + "id": repo.id, + "display_name": repo.display_name, + "canonical_root": str(repo.canonical_root), + "remote_display": repo.remote_display, + "last_run_id": run_id, + } + ) + save_repo_metadata(state_home, repo.id, meta) + print(run_dir / "run-state.json") + return 0 -def prompt_values(root: Path, state: dict[str, Any]) -> dict[str, str]: - directory = state_dir(root) - previous_review = '(none)' - if state.get('reviews'): - latest = Path(state['reviews'][-1]['path']) - previous_review = read_optional(latest) - triage = directory / f"triage-{state['reviews'][-1]['round']:02d}.md" - if triage.exists(): - previous_review += '\n\nTRIAGE\n' + read_optional(triage) - latest_review = previous_review - return { - 'FEATURE': state.get('feature', '(missing)'), - 'BASELINE': state.get('baseline', {}).get('commit', '(missing)'), - 'REPOSITORY_CONTEXT': read_optional(directory / 'repository-context.txt'), - 'CODEX_SPEC': read_optional(directory / 'feature-spec.codex.json'), - 'ACCEPTED_SPEC': read_optional(directory / 'accepted-spec.md'), - 'ACCEPTED_PLAN': read_optional(directory / 'accepted-plan.md'), - 'VERIFICATION': json.dumps(state.get('verification', {}), indent=2), - 'PREVIOUS_REVIEW': previous_review, - 'LATEST_REVIEW': latest_review, - } +# --------------------------------------------------------------------------- +# cmd_codex +# --------------------------------------------------------------------------- def cmd_codex(args: argparse.Namespace) -> int: - root = project_root(args.project_root) - state = load_state(root) - if state.get('status') != 'active': + repo, state_home, run_id_override = get_context(args) + run_ref = resolve_active_run( + state_home, repo.id, repo.canonical_root, run_id_override + ) + state = run_ref.state + run_dir = run_ref.run_dir + + require_no_unsafe_drift(state, repo) + + if state.get("status") != "active": raise WorkflowError(f"Workflow is not active: {state.get('status')}") phase = args.phase prompt_rel, schema_rel, static_output = PHASE_OUTPUTS[phase] - directory = state_dir(root) - - if phase == 'plan' and not (directory / 'accepted-spec.md').exists(): - raise WorkflowError('Create `.ai/autonomous-development/accepted-spec.md` before planning') - if phase in {'review', 'adversarial'}: - if not (directory / 'accepted-plan.md').exists(): - raise WorkflowError('Create `.ai/autonomous-development/accepted-plan.md` before review') - if not state.get('verification', {}).get('checks'): - raise WorkflowError('Record at least one verification check before review') - - if phase == 'review': - next_round = int(state.get('review_round', 0)) + 1 - maximum = int(state.get('max_review_rounds', 3)) + + if phase == "plan": + spec_path = resolve_artifact_path( + state.get("artifacts", {}).get("accepted_spec", "accepted-spec.md"), run_dir + ) + if not spec_path.exists(): + raise WorkflowError( + "Create accepted-spec.md (in the run directory) before planning" + ) + if phase in {"review", "adversarial"}: + plan_path = resolve_artifact_path( + state.get("artifacts", {}).get("accepted_plan", "accepted-plan.md"), run_dir + ) + if not plan_path.exists(): + raise WorkflowError( + "Create accepted-plan.md (in the run directory) before review" + ) + if not state.get("verification", {}).get("checks"): + raise WorkflowError("Record at least one verification check before review") + + if phase == "review": + next_round = int(state.get("review_round", 0)) + 1 + maximum = int(state.get("max_review_rounds", 3)) if next_round > maximum: - state['status'] = 'blocked' - state['phase'] = 'review-budget-exhausted' - state['notes'].append(f'Maximum review rounds exhausted ({maximum})') - save_state(root, state) - raise WorkflowError(f'Maximum review rounds exhausted ({maximum})') - output_name = f'review-{next_round:02d}.codex.json' - elif phase == 'adversarial': - index = len(state.get('adversarial_reviews', [])) + 1 - output_name = f'adversarial-{index:02d}.codex.json' + with RunStateLock(run_dir): + fresh = load_run_state(run_dir) + fresh["status"] = "blocked" + fresh["phase"] = "review-budget-exhausted" + fresh.setdefault("notes", []).append( + f"Maximum review rounds exhausted ({maximum})" + ) + save_run_state(run_dir, fresh) + raise WorkflowError(f"Maximum review rounds exhausted ({maximum})") + output_name = f"review-{next_round:02d}.codex.json" + elif phase == "adversarial": + index = len(state.get("adversarial_reviews", [])) + 1 + output_name = f"adversarial-{index:02d}.codex.json" else: output_name = static_output assert output_name is not None - template = (PLUGIN_ROOT / prompt_rel).read_text(encoding='utf-8') - prompt = render(template, prompt_values(root, state)) - prompt_path = directory / f'{phase}.prompt.md' - prompt_path.write_text(prompt, encoding='utf-8') - output_path = directory / output_name + template = (PLUGIN_ROOT / prompt_rel).read_text(encoding="utf-8") + prompt = render(template, prompt_values(run_dir, state)) + prompt_path = run_dir / f"{phase}.prompt.md" + prompt_path.write_text(prompt, encoding="utf-8") + output_path = run_dir / output_name command = [ - 'codex', - 'exec', - '--sandbox', - 'read-only', - '--output-schema', + "codex", + "exec", + "--sandbox", + "read-only", + "--output-schema", str(PLUGIN_ROOT / schema_rel), - '--output-last-message', + "--output-last-message", str(output_path), - '-', + "-", ] - result = run_process(command, cwd=root, input_text=prompt) + result = run_process(command, cwd=repo.canonical_root, input_text=prompt) if result.returncode != 0: - error_path = directory / f'{phase}.codex.stderr.log' - error_path.write_text(result.stderr, encoding='utf-8') - state['notes'].append(f'Codex {phase} failed; see {error_path}') - save_state(root, state) - raise WorkflowError(result.stderr.strip() or f'Codex {phase} failed') + error_path = run_dir / f"{phase}.codex.stderr.log" + error_path.write_text(result.stderr, encoding="utf-8") + with RunStateLock(run_dir): + err_state = load_run_state(run_dir) + err_state.setdefault("notes", []).append( + f"Codex {phase} failed; see {make_relative_path(error_path, run_dir)}" + ) + save_run_state(run_dir, err_state) + raise WorkflowError(result.stderr.strip() or f"Codex {phase} failed") try: - parsed = json.loads(output_path.read_text(encoding='utf-8')) + parsed = json.loads(output_path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: - raise WorkflowError(f'Codex did not produce valid JSON at {output_path}: {exc}') from exc + raise WorkflowError( + f"Codex did not produce valid JSON at {output_path}: {exc}" + ) from exc if not isinstance(parsed, dict): - raise WorkflowError(f'Codex output must be an object: {output_path}') - - state['artifacts'][phase] = str(output_path) - if phase == 'enhance': - state['phase'] = 'idea-enhanced' - elif phase == 'plan': - state['phase'] = 'plan-proposed' - elif phase == 'review': - state['review_round'] = next_round - state['phase'] = 'reviewed' - state['reviews'].append( - {'round': next_round, 'path': str(output_path), 'verdict': parsed.get('verdict')} - ) - elif phase == 'adversarial': - state['phase'] = 'adversarially-reviewed' - state['adversarial_reviews'].append( - {'round': index, 'path': str(output_path), 'verdict': parsed.get('verdict')} + raise WorkflowError(f"Codex output must be an object: {output_path}") + + # Recompute round/index from fresh state inside the lock to close the TOCTOU + # gap between the pre-Codex snapshot check and the post-Codex state write. + final_path = output_path + with RunStateLock(run_dir): + state = load_run_state(run_dir) + if phase == "review": + next_round = int(state.get("review_round", 0)) + 1 + maximum = int(state.get("max_review_rounds", 3)) + if next_round > maximum: + state["status"] = "blocked" + state["phase"] = "review-budget-exhausted" + state.setdefault("notes", []).append( + f"Maximum review rounds exhausted ({maximum})" + ) + save_run_state(run_dir, state) + raise WorkflowError(f"Maximum review rounds exhausted ({maximum})") + canonical = run_dir / f"review-{next_round:02d}.codex.json" + if output_path != canonical: + output_path.replace(canonical) + final_path = canonical + elif phase == "adversarial": + index = len(state.get("adversarial_reviews", [])) + 1 + canonical = run_dir / f"adversarial-{index:02d}.codex.json" + if output_path != canonical: + output_path.replace(canonical) + final_path = canonical + state.setdefault("artifacts", {})[phase] = make_relative_path( + final_path, run_dir ) - state['stop_gate_blocks'] = 0 - save_state(root, state) - print(output_path) + if phase == "enhance": + state["phase"] = "idea-enhanced" + elif phase == "plan": + state["phase"] = "plan-proposed" + elif phase == "review": + state["review_round"] = next_round + state["phase"] = "reviewed" + state.setdefault("reviews", []).append( + { + "round": next_round, + "path": make_relative_path(final_path, run_dir), + "verdict": parsed.get("verdict"), + } + ) + elif phase == "adversarial": + state["phase"] = "adversarially-reviewed" + state.setdefault("adversarial_reviews", []).append( + { + "round": index, + "path": make_relative_path(final_path, run_dir), + "verdict": parsed.get("verdict"), + } + ) + state["stop_gate_blocks"] = 0 + save_run_state(run_dir, state) + print(final_path) return 0 +# --------------------------------------------------------------------------- +# cmd_accept +# --------------------------------------------------------------------------- + + def cmd_accept(args: argparse.Namespace) -> int: - root = project_root(args.project_root) - state = load_state(root) + repo, state_home, run_id_override = get_context(args) + run_ref = resolve_active_run( + state_home, repo.id, repo.canonical_root, run_id_override + ) + state = run_ref.state + run_dir = run_ref.run_dir + + require_no_unsafe_drift(state, repo) + source = Path(args.file).resolve() if not source.is_file(): - raise WorkflowError(f'Accepted artifact does not exist: {source}') - destination_name = 'accepted-spec.md' if args.kind == 'spec' else 'accepted-plan.md' - destination = state_dir(root) / destination_name - destination.write_text(source.read_text(encoding='utf-8'), encoding='utf-8') - state['artifacts'][f'accepted_{args.kind}'] = str(destination) - state['phase'] = 'spec-accepted' if args.kind == 'spec' else 'plan-accepted' - state['stop_gate_blocks'] = 0 - save_state(root, state) + raise WorkflowError(f"Accepted artifact does not exist: {source}") + destination_name = "accepted-spec.md" if args.kind == "spec" else "accepted-plan.md" + destination = run_dir / destination_name + destination.write_text(source.read_text(encoding="utf-8"), encoding="utf-8") + with RunStateLock(run_dir): + state = load_run_state(run_dir) + state.setdefault("artifacts", {})[f"accepted_{args.kind}"] = destination_name + state["phase"] = "spec-accepted" if args.kind == "spec" else "plan-accepted" + state["stop_gate_blocks"] = 0 + save_run_state(run_dir, state) print(destination) return 0 -def slug(value: str) -> str: - clean = re.sub(r'[^a-zA-Z0-9._-]+', '-', value.strip()).strip('-').lower() - return clean[:80] or 'check' +# --------------------------------------------------------------------------- +# cmd_run_check +# --------------------------------------------------------------------------- def cmd_run_check(args: argparse.Namespace) -> int: - root = project_root(args.project_root) - state = load_state(root) + repo, state_home, run_id_override = get_context(args) + run_ref = resolve_active_run( + state_home, repo.id, repo.canonical_root, run_id_override + ) + run_dir = run_ref.run_dir + + require_no_unsafe_drift(run_ref.state, repo) + command = list(args.command) - if command and command[0] == '--': + if command and command[0] == "--": command = command[1:] if not command: - raise WorkflowError('Provide a verification command after `--`') + raise WorkflowError("Provide a verification command after `--`") - verification_dir = state_dir(root) / 'verification' + verification_dir = run_dir / "verification" verification_dir.mkdir(parents=True, exist_ok=True) - index = len(state.get('verification', {}).get('checks', [])) + 1 - log_path = verification_dir / f'{index:02d}-{slug(args.name)}.log' + + # Run the check outside the lock — may be long-running. started = utc_now() - result = run_process(command, cwd=root) - combined = ( - f"COMMAND: {json.dumps(command)}\n" - f"STARTED: {started}\n" - f"EXIT CODE: {result.returncode}\n\n" - f"STDOUT\n{result.stdout}\n\nSTDERR\n{result.stderr}\n" - ) - log_path.write_text(combined, encoding='utf-8') - - check_record = { - 'name': args.name, - 'command': command, - 'exit_code': result.returncode, - 'log': str(log_path), - 'started_at': started, - 'completed_at': utc_now(), - } - state.setdefault('verification', {}).setdefault('checks', []).append(check_record) - checks = state['verification']['checks'] - effective_checks = latest_verification_checks(checks) - state['verification']['passed'] = bool(effective_checks) and all( - c['exit_code'] == 0 for c in effective_checks - ) - state['phase'] = 'verified' if state['verification']['passed'] else 'verification-failed' - state['stop_gate_blocks'] = 0 - save_state(root, state) + result = run_process(command, cwd=repo.canonical_root) + completed = utc_now() + + # Acquire lock to compute a collision-free index and persist atomically. + with RunStateLock(run_dir): + state = load_run_state(run_dir) + index = len(state.get("verification", {}).get("checks", [])) + 1 + log_path = verification_dir / f"{index:02d}-{slug(args.name)}.log" + combined = ( + f"COMMAND: {json.dumps(command)}\n" + f"STARTED: {started}\n" + f"EXIT CODE: {result.returncode}\n\n" + f"STDOUT\n{result.stdout}\n\nSTDERR\n{result.stderr}\n" + ) + log_path.write_text(combined, encoding="utf-8") + check_record = { + "name": args.name, + "command": command, + "exit_code": result.returncode, + "log": make_relative_path(log_path, run_dir), + "started_at": started, + "completed_at": completed, + } + state.setdefault("verification", {}).setdefault("checks", []).append( + check_record + ) + checks = state["verification"]["checks"] + effective_checks = latest_verification_checks(checks) + state["verification"]["passed"] = bool(effective_checks) and all( + c["exit_code"] == 0 for c in effective_checks + ) + state["phase"] = ( + "verified" if state["verification"]["passed"] else "verification-failed" + ) + state["stop_gate_blocks"] = 0 + save_run_state(run_dir, state) sys.stdout.write(result.stdout) sys.stderr.write(result.stderr) - print(f'\nVerification log: {log_path}', file=sys.stderr) + print(f"\nVerification log: {log_path}", file=sys.stderr) return result.returncode +# --------------------------------------------------------------------------- +# cmd_set_phase +# --------------------------------------------------------------------------- + + def cmd_set_phase(args: argparse.Namespace) -> int: - root = project_root(args.project_root) - state = load_state(root) - state['phase'] = args.phase - if args.note: - state.setdefault('notes', []).append(args.note) - state['stop_gate_blocks'] = 0 - save_state(root, state) + repo, state_home, run_id_override = get_context(args) + run_dir = resolve_active_run( + state_home, repo.id, repo.canonical_root, run_id_override + ).run_dir + with RunStateLock(run_dir): + state = load_run_state(run_dir) + require_no_unsafe_drift(state, repo) + state["phase"] = args.phase + if args.note: + state.setdefault("notes", []).append(args.note) + state["stop_gate_blocks"] = 0 + save_run_state(run_dir, state) print(args.phase) return 0 -def cmd_set_risk(args: argparse.Namespace) -> int: - root = project_root(args.project_root) - state = load_state(root) - state.setdefault('risk', {})['requires_adversarial_review'] = args.require_adversarial - if args.reason: - state['risk'].setdefault('reasons', []).append(args.reason) - state['stop_gate_blocks'] = 0 - save_state(root, state) - return 0 +# --------------------------------------------------------------------------- +# cmd_set_risk +# --------------------------------------------------------------------------- -def latest_verification_checks(checks: list[dict[str, Any]]) -> list[dict[str, Any]]: - """Return only the latest result for each logical verification check name.""" - latest: dict[str, dict[str, Any]] = {} - order: list[str] = [] - for check in checks: - name = str(check.get('name', 'unnamed')) - if name not in latest: - order.append(name) - latest[name] = check - return [latest[name] for name in order] +def cmd_set_risk(args: argparse.Namespace) -> int: + repo, state_home, run_id_override = get_context(args) + run_dir = resolve_active_run( + state_home, repo.id, repo.canonical_root, run_id_override + ).run_dir + with RunStateLock(run_dir): + state = load_run_state(run_dir) + require_no_unsafe_drift(state, repo) + state.setdefault("risk", {})[ + "requires_adversarial_review" + ] = args.require_adversarial + if args.reason: + state["risk"].setdefault("reasons", []).append(args.reason) + state["stop_gate_blocks"] = 0 + save_run_state(run_dir, state) + return 0 -def unresolved_severe_findings(review: dict[str, Any]) -> list[dict[str, Any]]: - return [ - finding - for finding in review.get('findings', []) - if isinstance(finding, dict) and finding.get('severity') in {'critical', 'high'} - ] +# --------------------------------------------------------------------------- +# cmd_evaluate +# --------------------------------------------------------------------------- def cmd_evaluate(args: argparse.Namespace) -> int: - root = project_root(args.project_root) - state = load_state(root) - directory = state_dir(root) + repo, state_home, run_id_override = get_context(args) + run_ref = resolve_active_run( + state_home, repo.id, repo.canonical_root, run_id_override + ) + run_dir = run_ref.run_dir + # Drift check uses snapshot; git state is outside our file lock anyway. + require_no_unsafe_drift(run_ref.state, repo) + + # Artifact existence is idempotent; check outside lock to avoid holding + # the lock during filesystem traversal. + artifact_reasons: list[str] = [] + for artifact_key, filename in ( + ("accepted_spec", "accepted-spec.md"), + ("accepted_plan", "accepted-plan.md"), + ): + rel = run_ref.state.get("artifacts", {}).get(artifact_key, filename) + try: + path = resolve_artifact_path(str(rel), run_dir) + except StateError: + path = run_dir / filename + if not path.exists(): + artifact_reasons.append(f"Missing {filename}") + + # Rebuild all state-dependent gate conditions from freshly-loaded state + # inside the lock so that completion_gate_failures reflects current reality. reasons: list[str] = [] + with RunStateLock(run_dir): + state = load_run_state(run_dir) + reasons = list(artifact_reasons) - for required in ('accepted-spec.md', 'accepted-plan.md'): - if not (directory / required).exists(): - reasons.append(f'Missing {required}') - - checks = latest_verification_checks(state.get('verification', {}).get('checks', [])) - if not checks: - reasons.append('No verification checks recorded') - elif any(check.get('exit_code') != 0 for check in checks): - reasons.append('One or more verification checks failed') - - reviews = state.get('reviews', []) - if not reviews: - reasons.append('No Codex code review recorded') - else: - latest_path = Path(reviews[-1]['path']) - review = json.loads(latest_path.read_text(encoding='utf-8')) - if review.get('verdict') != 'pass': - reasons.append(f"Latest Codex review verdict is {review.get('verdict')}") - severe = unresolved_severe_findings(review) - if severe: - reasons.append(f'Latest review contains {len(severe)} critical/high finding(s)') - - requires_adversarial = bool(state.get('risk', {}).get('requires_adversarial_review')) - if requires_adversarial: - adversarial = state.get('adversarial_reviews', []) - if not adversarial: - reasons.append('High-risk change requires an adversarial review') - elif adversarial[-1].get('verdict') != 'pass': - reasons.append( - f"Latest adversarial review verdict is {adversarial[-1].get('verdict')}" - ) + checks = latest_verification_checks( + state.get("verification", {}).get("checks", []) + ) + if not checks: + reasons.append("No verification checks recorded") + elif any(check.get("exit_code") != 0 for check in checks): + reasons.append("One or more verification checks failed") + + reviews = state.get("reviews", []) + if not reviews: + reasons.append("No Codex code review recorded") + else: + last_review = reviews[-1] + rel_path = last_review.get("path", "") + try: + review_path = resolve_artifact_path(str(rel_path), run_dir) + review = json.loads(review_path.read_text(encoding="utf-8")) + except (StateError, OSError, json.JSONDecodeError) as exc: + reasons.append(f"Could not read latest review: {exc}") + review = {} + if review.get("verdict") != "pass": + reasons.append( + f"Latest Codex review verdict is {review.get('verdict')}" + ) + severe = unresolved_severe_findings(review) + if severe: + reasons.append( + f"Latest review contains {len(severe)} critical/high finding(s)" + ) + + requires_adversarial = bool( + state.get("risk", {}).get("requires_adversarial_review") + ) + if requires_adversarial: + adversarial = state.get("adversarial_reviews", []) + if not adversarial: + reasons.append("High-risk change requires an adversarial review") + elif adversarial[-1].get("verdict") != "pass": + reasons.append( + f"Latest adversarial review verdict is " + f"{adversarial[-1].get('verdict')}" + ) + + if reasons: + state["status"] = "active" + state["phase"] = "completion-gates-failed" + state["completion_gate_failures"] = reasons + else: + state["status"] = "complete" + state["phase"] = "complete" + state["completion_gate_failures"] = [] + save_run_state(run_dir, state) if reasons: - state['status'] = 'active' - state['phase'] = 'completion-gates-failed' - state['completion_gate_failures'] = reasons - save_state(root, state) for reason in reasons: - print(f'- {reason}', file=sys.stderr) + print(f"- {reason}", file=sys.stderr) return 1 - - state['status'] = 'complete' - state['phase'] = 'complete' - state['completion_gate_failures'] = [] - save_state(root, state) - print('Workflow complete') + print("Workflow complete") return 0 +# --------------------------------------------------------------------------- +# cmd_status +# --------------------------------------------------------------------------- + + def cmd_status(args: argparse.Namespace) -> int: - root = project_root(args.project_root) - state = load_state(root) + repo, state_home, run_id_override = get_context(args) + + if run_id_override: + run_ref = resolve_active_run( + state_home, repo.id, repo.canonical_root, run_id_override + ) + runs = [run_ref] + else: + active = find_active_runs(state_home, repo.id) + if not active: + # fall back to legacy or raise + run_ref = resolve_active_run( + state_home, repo.id, repo.canonical_root, None, allow_multiple=True + ) + runs = [run_ref] + elif len(active) > 1: + if args.json: + print(json.dumps([r.state for r in active], indent=2, sort_keys=True)) + return 0 + print(f"Multiple active runs ({len(active)}):") + for r in active: + lbl = r.state.get("label", "") + print( + f' {r.run_id} label={lbl or "(none)"} ' + f'phase={r.state.get("phase")} status={r.state.get("status")}' + ) + print("Use --run-id to inspect a specific run.") + return 0 + else: + runs = active + + state = runs[0].state + if args.json: print(json.dumps(state, indent=2, sort_keys=True)) return 0 - checks = latest_verification_checks(state.get('verification', {}).get('checks', [])) - passed = sum(1 for item in checks if item.get('exit_code') == 0) + checks = latest_verification_checks(state.get("verification", {}).get("checks", [])) + passed = sum(1 for item in checks if item.get("exit_code") == 0) print(f"Run: {state.get('run_id')}") + if state.get("label"): + print(f"Label: {state['label']}") print(f"Status: {state.get('status')}") print(f"Phase: {state.get('phase')}") print(f"Feature: {state.get('feature')}") @@ -527,99 +834,394 @@ def cmd_status(args: argparse.Namespace) -> int: print( f"Reviews: {state.get('review_round', 0)}/{state.get('max_review_rounds', 3)}" ) - if state.get('reviews'): + if state.get("reviews"): print(f"Latest review: {state['reviews'][-1].get('verdict')}") - if state.get('risk', {}).get('requires_adversarial_review'): + if state.get("risk", {}).get("requires_adversarial_review"): verdict = ( - state.get('adversarial_reviews', [{}])[-1].get('verdict') - if state.get('adversarial_reviews') - else 'missing' + state.get("adversarial_reviews", [{}])[-1].get("verdict") + if state.get("adversarial_reviews") + else "missing" ) - print(f'Adversarial review required: {verdict}') - failures = state.get('completion_gate_failures', []) + print(f"Adversarial review required: {verdict}") + failures = state.get("completion_gate_failures", []) if failures: - print('Remaining gates:') + print("Remaining gates:") for failure in failures: - print(f'- {failure}') + print(f"- {failure}") return 0 +# --------------------------------------------------------------------------- +# cmd_cancel +# --------------------------------------------------------------------------- + + def cmd_cancel(args: argparse.Namespace) -> int: - root = project_root(args.project_root) - state = load_state(root) - state['status'] = 'cancelled' - state['phase'] = 'cancelled' - if args.reason: - state.setdefault('notes', []).append(args.reason) - save_state(root, state) - print('Workflow cancelled') + repo, state_home, run_id_override = get_context(args) + run_dir = resolve_active_run( + state_home, repo.id, repo.canonical_root, run_id_override + ).run_dir + with RunStateLock(run_dir): + state = load_run_state(run_dir) + require_no_unsafe_drift(state, repo) + state["status"] = "cancelled" + state["phase"] = "cancelled" + if args.reason: + state.setdefault("notes", []).append(args.reason) + save_run_state(run_dir, state) + print("Workflow cancelled") return 0 +# --------------------------------------------------------------------------- +# cmd_block +# --------------------------------------------------------------------------- + + def cmd_block(args: argparse.Namespace) -> int: - root = project_root(args.project_root) - state = load_state(root) - state['status'] = 'blocked' - state['phase'] = 'blocked' - state.setdefault('notes', []).append(args.reason) - save_state(root, state) - print('Workflow blocked') + repo, state_home, run_id_override = get_context(args) + run_dir = resolve_active_run( + state_home, repo.id, repo.canonical_root, run_id_override + ).run_dir + with RunStateLock(run_dir): + state = load_run_state(run_dir) + require_no_unsafe_drift(state, repo) + state["status"] = "blocked" + state["phase"] = "blocked" + state.setdefault("notes", []).append(args.reason) + save_run_state(run_dir, state) + print("Workflow blocked") + return 0 + + +# --------------------------------------------------------------------------- +# cmd_list_runs +# --------------------------------------------------------------------------- + + +def cmd_list_runs(args: argparse.Namespace) -> int: + repo, state_home, _run_id_override = get_context(args) + + if getattr(args, "all", False): + runs = find_all_runs(state_home, repo.id) + else: + # Default: active runs only (exclude archived and terminal) + runs = [ + r + for r in find_active_runs(state_home, repo.id) + if r.state.get("status") != "archived" + ] + + if args.json: + print(json.dumps([r.state for r in runs], indent=2, sort_keys=True)) + return 0 + + if not runs: + print("No runs found.") + return 0 + + header = f"{'RUN_ID':<30} {'LABEL':<20} {'STATUS':<12} {'PHASE':<28} CREATED" + print(header) + print("-" * len(header)) + for r in runs: + s = r.state + run_id_str = (s.get("run_id") or r.run_id)[:30] + label_str = (s.get("label") or "")[:20] + status_str = (s.get("status") or "")[:12] + phase_str = (s.get("phase") or "")[:28] + created_str = (s.get("created_at") or "")[:25] + print( + f"{run_id_str:<30} {label_str:<20} {status_str:<12} " + f"{phase_str:<28} {created_str}" + ) + return 0 + + +# --------------------------------------------------------------------------- +# cmd_show_run +# --------------------------------------------------------------------------- + + +def cmd_show_run(args: argparse.Namespace) -> int: + repo, state_home, run_id_override = get_context(args) + + # run_id from --run-id arg on this subcommand, or global --run-id + run_id = getattr(args, "show_run_id", None) or run_id_override + run_ref = resolve_active_run(state_home, repo.id, repo.canonical_root, run_id) + + if args.json: + print(json.dumps(run_ref.state, indent=2, sort_keys=True)) + return 0 + + state = run_ref.state + print(json.dumps(state, indent=2, sort_keys=True)) + return 0 + + +# --------------------------------------------------------------------------- +# cmd_migrate_legacy_state +# --------------------------------------------------------------------------- + + +def cmd_migrate_legacy_state(args: argparse.Namespace) -> int: + repo, state_home, _run_id_override = get_context(args) + + legacy_dir = detect_legacy_state(repo.canonical_root) + if legacy_dir is None: + raise WorkflowError( + f"No legacy run-state.json found under {repo.canonical_root / LEGACY_STATE_REL}. " + "Nothing to migrate." + ) + + legacy_state_path = legacy_dir / "run-state.json" + try: + legacy_state = json.loads(legacy_state_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise WorkflowError(f"Cannot read legacy state: {exc}") from exc + if not isinstance(legacy_state, dict): + raise WorkflowError("Legacy state must be a JSON object.") + + # Determine run_id for the new layout + legacy_run_id = legacy_state.get("run_id") or new_run_id() + new_run_dir = run_dir_path(state_home, repo.id, legacy_run_id) + + # Idempotency check + existing_state_path = new_run_dir / "run-state.json" + if existing_state_path.exists() and not getattr(args, "force", False): + try: + existing = json.loads(existing_state_path.read_text(encoding="utf-8")) + if existing.get("run_id") == legacy_run_id and existing.get( + "migrated_from" + ): + print( + f"Already migrated: run {legacy_run_id!r} exists at {new_run_dir}" + ) + return 0 + except (OSError, json.JSONDecodeError): + pass + raise WorkflowError( + f"Run directory already exists: {new_run_dir}. " "Use --force to overwrite." + ) + + new_run_dir.mkdir(parents=True, exist_ok=True, mode=0o700) + + # Copy all files from legacy dir preserving metadata + for src in legacy_dir.iterdir(): + if src.is_file(): + dst = new_run_dir / src.name + shutil.copy2(str(src), str(dst)) + elif src.is_dir(): + dst_dir = new_run_dir / src.name + if dst_dir.exists() and getattr(args, "force", False): + shutil.rmtree(str(dst_dir)) + shutil.copytree(str(src), str(dst_dir)) + + # Convert to v2 format; pass legacy_dir so absolute paths under it are correctly relativized. + migrated = migrate_v1_to_v2(legacy_state, new_run_dir, repo, legacy_dir=legacy_dir) + migrated["run_id"] = legacy_run_id + migrated["migrated_from"] = str(legacy_dir) + migrated["migrated_at"] = utc_now() + + save_run_state(new_run_dir, migrated) + + # Save repo metadata + meta = load_repo_metadata(state_home, repo.id) + meta.update( + { + "id": repo.id, + "display_name": repo.display_name, + "canonical_root": str(repo.canonical_root), + "remote_display": repo.remote_display, + "last_run_id": legacy_run_id, + } + ) + save_repo_metadata(state_home, repo.id, meta) + + print(f"Migrated legacy state from {legacy_dir} to {new_run_dir}") + print(f"Run ID: {legacy_run_id}") + print("The original legacy directory has NOT been modified.") + return 0 + + +# --------------------------------------------------------------------------- +# cmd_archive_run +# --------------------------------------------------------------------------- + + +def cmd_archive_run(args: argparse.Namespace) -> int: + repo, state_home, run_id_override = get_context(args) + run_ref = resolve_active_run( + state_home, repo.id, repo.canonical_root, run_id_override + ) + run_id_str = run_ref.run_id + run_dir = run_ref.run_dir + with RunStateLock(run_dir): + state = load_run_state(run_dir) + require_no_unsafe_drift(state, repo) + state["status"] = "archived" + state.setdefault("notes", []).append(f"Archived at {utc_now()}") + save_run_state(run_dir, state) + print(f"Run {run_id_str!r} archived.") return 0 +# --------------------------------------------------------------------------- +# cmd_accept_drift +# --------------------------------------------------------------------------- + + +def cmd_accept_drift(args: argparse.Namespace) -> int: + repo, state_home, run_id_override = get_context(args) + run_dir = resolve_active_run( + state_home, repo.id, repo.canonical_root, run_id_override + ).run_dir + with RunStateLock(run_dir): + state = load_run_state(run_dir) + old_baseline = dict(state.get("baseline", {})) + old_repo_block = dict(state.get("repository", {})) + + state["repository"] = { + "id": repo.id, + "canonical_root": str(repo.canonical_root), + "git_common_dir": str(repo.git_common_dir), + "worktree_path": str(repo.worktree_path), + "display_name": repo.display_name, + "remote_display": repo.remote_display, + } + state["baseline"] = { + "commit": repo.head_commit, + "branch": repo.branch, + "worktree_path": str(repo.worktree_path), + "dirty_entries_at_init": old_baseline.get("dirty_entries_at_init", []), + } + state.setdefault("notes", []).append( + f"drift_accepted_at={utc_now()} " + f"drift_accepted_commit={repo.head_commit}" + ) + save_run_state(run_dir, state) + + print("Drift accepted. Updated baseline:") + old_commit = old_baseline.get("commit", "(unknown)") + old_branch = old_baseline.get("branch", "(unknown)") + old_worktree = old_baseline.get( + "worktree_path", old_repo_block.get("worktree_path", "") + ) + if old_commit != repo.head_commit: + print(f" commit: {old_commit} -> {repo.head_commit}") + if old_branch != repo.branch: + print(f" branch: {old_branch} -> {repo.branch}") + if old_worktree and old_worktree != str(repo.worktree_path): + print(f" worktree: {old_worktree} -> {repo.worktree_path}") + if old_repo_block.get("id") and old_repo_block["id"] != repo.id: + print(f' repo_id: {old_repo_block["id"]} -> {repo.id}') + return 0 + + +# --------------------------------------------------------------------------- +# Argument parser +# --------------------------------------------------------------------------- + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('--project-root', help='Target repository; defaults to current directory') - sub = parser.add_subparsers(dest='command_name', required=True) + parser.add_argument( + "--project-root", help="Target repository; defaults to current directory" + ) + parser.add_argument("--state-dir", help="Override state home directory") + parser.add_argument("--run-id", help="Specify run ID for run-scoped commands") + sub = parser.add_subparsers(dest="command_name", required=True) - doctor = sub.add_parser('doctor', help='Check Git, Python, Codex, and authentication') + doctor = sub.add_parser( + "doctor", help="Check Git, Python, Codex, and authentication" + ) doctor.set_defaults(func=cmd_doctor) - init = sub.add_parser('init', help='Initialize a workflow run') - init.add_argument('--feature', required=True) - init.add_argument('--max-review-rounds', type=int, default=3, choices=range(1, 6)) - init.add_argument('--reuse', action='store_true') - init.add_argument('--force', action='store_true') + init = sub.add_parser("init", help="Initialize a workflow run") + init.add_argument("--feature", required=True) + init.add_argument("--label", help="Human-readable label stored in state") + init.add_argument("--max-review-rounds", type=int, default=3, choices=range(1, 6)) + init.add_argument("--reuse", action="store_true") + init.add_argument("--force", action="store_true") init.set_defaults(func=cmd_init) - codex = sub.add_parser('codex', help='Run a structured, read-only Codex phase') - codex.add_argument('--phase', required=True, choices=sorted(PHASE_OUTPUTS)) + codex = sub.add_parser("codex", help="Run a structured, read-only Codex phase") + codex.add_argument("--phase", required=True, choices=sorted(PHASE_OUTPUTS)) codex.set_defaults(func=cmd_codex) - accept = sub.add_parser('accept', help='Record Claude-reconciled specification or plan') - accept.add_argument('--kind', required=True, choices=('spec', 'plan')) - accept.add_argument('--file', required=True) + accept = sub.add_parser( + "accept", help="Record Claude-reconciled specification or plan" + ) + accept.add_argument("--kind", required=True, choices=("spec", "plan")) + accept.add_argument("--file", required=True) accept.set_defaults(func=cmd_accept) - run_check = sub.add_parser('run-check', help='Execute and record one verification command') - run_check.add_argument('--name', required=True) - run_check.add_argument('command', nargs=argparse.REMAINDER) + run_check = sub.add_parser( + "run-check", help="Execute and record one verification command" + ) + run_check.add_argument("--name", required=True) + run_check.add_argument("command", nargs=argparse.REMAINDER) run_check.set_defaults(func=cmd_run_check) - phase = sub.add_parser('set-phase', help='Update phase and optional note') - phase.add_argument('--phase', required=True) - phase.add_argument('--note') + phase = sub.add_parser("set-phase", help="Update phase and optional note") + phase.add_argument("--phase", required=True) + phase.add_argument("--note") phase.set_defaults(func=cmd_set_phase) - risk = sub.add_parser('set-risk', help='Set whether adversarial review is required') - risk.add_argument('--require-adversarial', action=argparse.BooleanOptionalAction, default=True) - risk.add_argument('--reason') + risk = sub.add_parser("set-risk", help="Set whether adversarial review is required") + risk.add_argument( + "--require-adversarial", action=argparse.BooleanOptionalAction, default=True + ) + risk.add_argument("--reason") risk.set_defaults(func=cmd_set_risk) - evaluate = sub.add_parser('evaluate', help='Evaluate all completion gates') + evaluate = sub.add_parser("evaluate", help="Evaluate all completion gates") evaluate.set_defaults(func=cmd_evaluate) - status = sub.add_parser('status', help='Show workflow state') - status.add_argument('--json', action='store_true') + status = sub.add_parser("status", help="Show workflow state") + status.add_argument("--json", action="store_true") status.set_defaults(func=cmd_status) - cancel = sub.add_parser('cancel', help='Cancel the active workflow') - cancel.add_argument('--reason') + cancel = sub.add_parser("cancel", help="Cancel the active workflow") + cancel.add_argument("--reason") cancel.set_defaults(func=cmd_cancel) - block = sub.add_parser('block', help='Mark the workflow blocked') - block.add_argument('--reason', required=True) + block = sub.add_parser("block", help="Mark the workflow blocked") + block.add_argument("--reason", required=True) block.set_defaults(func=cmd_block) + + list_runs = sub.add_parser( + "list-runs", help="List workflow runs for this repository" + ) + list_runs.add_argument("--json", action="store_true", help="Output JSON array") + list_runs.add_argument( + "--all", action="store_true", help="Include archived/terminal runs" + ) + list_runs.set_defaults(func=cmd_list_runs) + + show_run = sub.add_parser("show-run", help="Show all fields of a specific run") + show_run.add_argument("--run-id", dest="show_run_id", help="Run ID to display") + show_run.add_argument("--json", action="store_true", help="Output JSON") + show_run.set_defaults(func=cmd_show_run) + + migrate = sub.add_parser( + "migrate-legacy-state", + help="Migrate legacy .ai/autonomous-development state to new layout", + ) + migrate.add_argument( + "--force", action="store_true", help="Overwrite existing new-format run" + ) + migrate.set_defaults(func=cmd_migrate_legacy_state) + + archive = sub.add_parser( + "archive-run", help="Archive a run (exclude from default listing)" + ) + archive.set_defaults(func=cmd_archive_run) + + accept_drift = sub.add_parser( + "accept-drift", help="Accept current repository state as new drift baseline" + ) + accept_drift.set_defaults(func=cmd_accept_drift) + return parser @@ -629,12 +1231,12 @@ def main(argv: Iterable[str] | None = None) -> int: try: return int(args.func(args)) except WorkflowError as exc: - print(f'error: {exc}', file=sys.stderr) + print(f"error: {exc}", file=sys.stderr) return 2 except KeyboardInterrupt: - print('error: interrupted', file=sys.stderr) + print("error: interrupted", file=sys.stderr) return 130 -if __name__ == '__main__': +if __name__ == "__main__": raise SystemExit(main()) diff --git a/scripts/state.py b/scripts/state.py new file mode 100644 index 0000000..01f51e5 --- /dev/null +++ b/scripts/state.py @@ -0,0 +1,655 @@ +"""Shared state module for the autonomous-development plugin.""" + +from __future__ import annotations + +import datetime as dt +import hashlib +import json +import os +import secrets +import subprocess +import sys +from dataclasses import dataclass +from enum import Enum +from pathlib import Path +from urllib.parse import urlsplit, urlunsplit + +STATE_SCHEMA_VERSION = 2 +TERMINAL_STATUSES: frozenset[str] = frozenset( + {"complete", "blocked", "cancelled", "archived"} +) +LEGACY_STATE_REL = Path(".ai/autonomous-development") +LEGACY_STATE_FILE_NAME = "run-state.json" + + +class StateError(RuntimeError): + """User-actionable state error with a clear message.""" + + +# --------------------------------------------------------------------------- +# Repository discovery +# --------------------------------------------------------------------------- + + +@dataclass +class RepoInfo: + """Snapshot of a git repository's identity and current state.""" + + id: str + canonical_root: Path + git_common_dir: Path + worktree_path: Path + branch: str + head_commit: str + display_name: str + remote_display: str + + +def _run_git(*args: str, cwd: Path) -> str: + """Run a git command and return stripped stdout; return '' on failure.""" + try: + result = subprocess.run( + ["git", *args], + cwd=cwd, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + return result.stdout.strip() if result.returncode == 0 else "" + except FileNotFoundError: + return "" + + +def _strip_credentials(url: str) -> str: + """Remove userinfo (user:pass@ or token@) from a URL using url parsing.""" + try: + parsed = urlsplit(url) + if parsed.username: + host = parsed.hostname or "" + if parsed.port: + host = f"{host}:{parsed.port}" + return urlunsplit( + (parsed.scheme, host, parsed.path, parsed.query, parsed.fragment) + ) + except Exception: + pass + return url + + +def _compute_repo_id(git_common_dir: Path, first_commit: str) -> str: + """Compute a stable 16-char hex repo ID.""" + key = str(git_common_dir.resolve()) + "\n" + first_commit + return hashlib.sha256(key.encode()).hexdigest()[:16] + + +def resolve_repository(start: Path | None = None) -> RepoInfo: + """Find git repository from start (or cwd). Raises StateError if not in a git repo.""" + cwd = (start or Path.cwd()).resolve() + + toplevel = _run_git("rev-parse", "--show-toplevel", cwd=cwd) + if not toplevel: + raise StateError( + f"{cwd} is not inside a git repository. " + "Run this command from within a git worktree." + ) + canonical_root = Path(toplevel).resolve() + + raw_common = _run_git("rev-parse", "--git-common-dir", cwd=canonical_root) + if raw_common: + git_common_dir = (canonical_root / raw_common).resolve() + else: + git_common_dir = canonical_root + + worktree_path = Path( + _run_git("rev-parse", "--show-toplevel", cwd=cwd) or toplevel + ).resolve() + + branch = _run_git("branch", "--show-current", cwd=canonical_root) + head_commit = _run_git("rev-parse", "HEAD", cwd=canonical_root) + + first_commit = _run_git("rev-list", "--max-parents=0", "HEAD", cwd=canonical_root) + + if raw_common: + repo_id = _compute_repo_id(git_common_dir, first_commit) + else: + key = str(canonical_root) + "\n" + first_commit + repo_id = hashlib.sha256(key.encode()).hexdigest()[:16] + + remote_raw = _run_git("remote", "get-url", "origin", cwd=canonical_root) + if not remote_raw: + remotes_v = _run_git("remote", "-v", cwd=canonical_root) + first_line = remotes_v.splitlines()[0] if remotes_v else "" + parts = first_line.split() + remote_raw = parts[1] if len(parts) >= 2 else "" + remote_display = _strip_credentials(remote_raw) if remote_raw else "" + + return RepoInfo( + id=repo_id, + canonical_root=canonical_root, + git_common_dir=git_common_dir, + worktree_path=worktree_path, + branch=branch, + head_commit=head_commit, + display_name=canonical_root.name, + remote_display=remote_display, + ) + + +# --------------------------------------------------------------------------- +# State home resolver +# --------------------------------------------------------------------------- + + +def resolve_state_home(state_dir_arg: str | None = None) -> Path: + """Precedence: CLI arg > CLAUDE_AUTONOMOUS_STATE_HOME env > XDG > ~/.local/state/claude-autonomous""" + if state_dir_arg: + return Path(state_dir_arg).expanduser().resolve() + + env_val = os.environ.get("CLAUDE_AUTONOMOUS_STATE_HOME", "").strip() + if env_val: + return Path(env_val).expanduser().resolve() + + if sys.platform == "darwin": + return Path.home() / "Library" / "Application Support" / "claude-autonomous" + + if sys.platform == "win32": + local_app = os.environ.get("LOCALAPPDATA", "") + if local_app: + return Path(local_app) / "claude-autonomous" + return Path.home() / "AppData" / "Local" / "claude-autonomous" + + xdg = os.environ.get("XDG_STATE_HOME", "").strip() + if xdg: + return Path(xdg).expanduser().resolve() / "claude-autonomous" + return Path.home() / ".local" / "state" / "claude-autonomous" + + +# --------------------------------------------------------------------------- +# Run ID +# --------------------------------------------------------------------------- + + +def new_run_id() -> str: + """-""" + stamp = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%dT%H%M%SZ") + return f"{stamp}-{secrets.token_hex(4)}" + + +# --------------------------------------------------------------------------- +# Legacy state detection +# --------------------------------------------------------------------------- + + +def detect_legacy_state(repo_root: Path) -> Path | None: + """Return the legacy .ai/autonomous-development/ dir if run-state.json exists there, else None.""" + legacy_dir = repo_root / LEGACY_STATE_REL + if (legacy_dir / LEGACY_STATE_FILE_NAME).exists(): + return legacy_dir + return None + + +# --------------------------------------------------------------------------- +# Path utilities +# --------------------------------------------------------------------------- + + +def run_dir_path(state_home: Path, repo_id: str, run_id: str) -> Path: + """/repositories//runs//""" + return state_home / "repositories" / repo_id / "runs" / run_id + + +def repo_metadata_path(state_home: Path, repo_id: str) -> Path: + """/repositories//metadata.json""" + return state_home / "repositories" / repo_id / "metadata.json" + + +def make_relative_path(absolute: Path, run_dir: Path) -> str: + """Return a relative path if absolute is inside run_dir; else return str(absolute).""" + try: + rel = absolute.resolve().relative_to(run_dir.resolve()) + parts = rel.parts + if parts and parts[0] == "..": + return str(absolute) + return str(rel) + except ValueError: + return str(absolute) + + +def resolve_artifact_path(relative_or_abs: str, run_dir: Path) -> Path: + """Resolve relative to absolute. Reject '..' traversal outside run_dir.""" + p = Path(relative_or_abs) + if p.is_absolute(): + return p + resolved = (run_dir / p).resolve() + try: + resolved.relative_to(run_dir.resolve()) + except ValueError: + raise StateError(f"Artifact path escapes run directory: {relative_or_abs!r}") + return resolved + + +# --------------------------------------------------------------------------- +# Atomic write and locking +# --------------------------------------------------------------------------- + + +def atomic_write_json(path: Path, value: dict) -> None: + """Write to a .tmp file then replace atomically.""" + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + temp = path.with_suffix(path.suffix + ".tmp") + temp.write_text( + json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + temp.replace(path) + + +class RunStateLock: + """Context manager for exclusive file lock on a run's lock file.""" + + def __init__(self, run_dir: Path) -> None: + self._lock_path = run_dir / ".run-state.lock" + self._fd: int | None = None + + def __enter__(self) -> RunStateLock: + run_dir = self._lock_path.parent + run_dir.mkdir(parents=True, exist_ok=True, mode=0o700) + self._fd = os.open(str(self._lock_path), os.O_CREAT | os.O_WRONLY, 0o600) + try: + import fcntl + + fcntl.flock(self._fd, fcntl.LOCK_EX) + except ImportError: + pass # best-effort on platforms without fcntl + return self + + def __exit__(self, *args: object) -> None: + if self._fd is not None: + try: + import fcntl + + fcntl.flock(self._fd, fcntl.LOCK_UN) + except ImportError: + pass + os.close(self._fd) + self._fd = None + + +# --------------------------------------------------------------------------- +# State schema validation +# --------------------------------------------------------------------------- + + +def validate_state(state: dict) -> None: + """Validate loaded state dict. Raises StateError on schema problems.""" + if not isinstance(state, dict): + raise StateError("State must be a JSON object.") + + if "status" not in state: + raise StateError("State is missing required field 'status'.") + if not isinstance(state["status"], str): + raise StateError("State field 'status' must be a string.") + + if "run_id" not in state: + raise StateError("State is missing required field 'run_id'.") + if not isinstance(state["run_id"], str): + raise StateError("State field 'run_id' must be a string.") + + schema_version = state.get("schema_version") or state.get("version") + if schema_version is not None and schema_version not in (1, 2): + raise StateError( + f"Unsupported schema_version {schema_version!r}. " + "Supported versions are 1 (legacy) and 2. " + "Run `migrate-legacy-state` to upgrade." + ) + + +# --------------------------------------------------------------------------- +# Schema migration v1 → v2 +# --------------------------------------------------------------------------- + + +def _remap_path(p: Path, legacy_dir: Path | None, run_dir: Path) -> str: + """Convert a legacy absolute path to a run-dir-relative path. + + If the path is under legacy_dir, produce the relative path assuming the file + was copied to the equivalent location under run_dir. Fallback: try to relativize + against run_dir directly. Return the original absolute path string only as a last resort. + """ + if legacy_dir is not None: + try: + rel_to_legacy = p.resolve().relative_to(legacy_dir.resolve()) + return str(rel_to_legacy) + except ValueError: + pass + return make_relative_path(p, run_dir) + + +def migrate_v1_to_v2( + legacy_state: dict, + run_dir: Path, + repo: RepoInfo, + legacy_dir: Path | None = None, +) -> dict: + """Convert a v1/legacy state dict to v2 format in-memory.""" + state = dict(legacy_state) + + state.pop("version", None) + state["schema_version"] = 2 + + if "run_id" not in state: + state["run_id"] = new_run_id() + + state["repository"] = { + "id": repo.id, + "display_name": repo.display_name, + "canonical_root": str(repo.canonical_root), + "remote_display": repo.remote_display, + } + + baseline = state.get("baseline", {}) + if not isinstance(baseline, dict): + baseline = {} + if "branch" not in baseline: + baseline["branch"] = repo.branch + if "worktree_path" not in baseline: + baseline["worktree_path"] = str(repo.worktree_path) + state["baseline"] = baseline + + artifacts = state.get("artifacts", {}) + if isinstance(artifacts, dict): + new_artifacts: dict[str, object] = {} + for key, value in artifacts.items(): + if isinstance(value, str): + p = Path(value) + if p.is_absolute(): + new_artifacts[key] = _remap_path(p, legacy_dir, run_dir) + else: + new_artifacts[key] = value + else: + new_artifacts[key] = value + state["artifacts"] = new_artifacts + + for list_key in ("reviews", "adversarial_reviews"): + entries = state.get(list_key, []) + if isinstance(entries, list): + updated_entries = [] + for entry in entries: + if isinstance(entry, dict) and "path" in entry: + p = Path(entry["path"]) + if p.is_absolute(): + entry = dict(entry) + entry["path"] = _remap_path(p, legacy_dir, run_dir) + updated_entries.append(entry) + state[list_key] = updated_entries + + checks = state.get("verification", {}).get("checks", []) + if isinstance(checks, list): + updated_checks = [] + for check in checks: + if isinstance(check, dict) and "log" in check: + p = Path(check["log"]) + if p.is_absolute(): + check = dict(check) + check["log"] = _remap_path(p, legacy_dir, run_dir) + updated_checks.append(check) + if "verification" in state and isinstance(state["verification"], dict): + state["verification"]["checks"] = updated_checks + + state.setdefault("migrated_from", "v1") + + return state + + +# --------------------------------------------------------------------------- +# Run state loading / saving +# --------------------------------------------------------------------------- + +_STATE_FILE_NAME = "run-state.json" + + +def load_run_state(run_dir: Path, required: bool = True) -> dict: + """Load and validate run-state.json from run_dir. Returns {} if not found and not required.""" + path = run_dir / _STATE_FILE_NAME + if not path.exists(): + if required: + raise StateError(f"No run-state.json found at {path}") + return {} + try: + state = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise StateError(f"Invalid run state at {path}: {exc}") from exc + if not isinstance(state, dict): + raise StateError(f"Run state must be a JSON object: {path}") + validate_state(state) + return state + + +def save_run_state(run_dir: Path, state: dict) -> None: + """Add updated_at timestamp and atomically write run-state.json.""" + state["updated_at"] = dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds") + atomic_write_json(run_dir / _STATE_FILE_NAME, state) + + +# --------------------------------------------------------------------------- +# Repo metadata +# --------------------------------------------------------------------------- + + +def load_repo_metadata(state_home: Path, repo_id: str) -> dict: + """Load repositories//metadata.json or return {}.""" + path = repo_metadata_path(state_home, repo_id) + if not path.exists(): + return {} + try: + meta = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {} + return meta if isinstance(meta, dict) else {} + + +def save_repo_metadata(state_home: Path, repo_id: str, meta: dict) -> None: + """Save repositories//metadata.json atomically.""" + atomic_write_json(repo_metadata_path(state_home, repo_id), meta) + + +# --------------------------------------------------------------------------- +# Run discovery and selection +# --------------------------------------------------------------------------- + + +@dataclass +class RunRef: + """Reference to a discovered run with its loaded state.""" + + run_id: str + run_dir: Path + state: dict + + +def find_active_runs(state_home: Path, repo_id: str) -> list[RunRef]: + """Return all non-terminal runs for this repository.""" + return [ + r + for r in find_all_runs(state_home, repo_id) + if r.state.get("status") not in TERMINAL_STATUSES + ] + + +def find_all_runs(state_home: Path, repo_id: str) -> list[RunRef]: + """Return all runs (active + archived + terminal) for this repository.""" + runs_dir = state_home / "repositories" / repo_id / "runs" + if not runs_dir.is_dir(): + return [] + refs: list[RunRef] = [] + for child in sorted(runs_dir.iterdir()): + if not child.is_dir(): + continue + state = load_run_state(child, required=False) + if not state: + continue + run_id = state.get("run_id", child.name) + refs.append(RunRef(run_id=run_id, run_dir=child, state=state)) + return refs + + +def resolve_active_run( + state_home: Path, + repo_id: str, + repo_root: Path, + run_id: str | None = None, + *, + allow_multiple: bool = False, +) -> RunRef: + """Resolve the run to operate on.""" + if run_id is not None: + runs_dir = state_home / "repositories" / repo_id / "runs" + run_dir = runs_dir / run_id + state = load_run_state(run_dir, required=True) + return RunRef(run_id=run_id, run_dir=run_dir, state=state) + + active = find_active_runs(state_home, repo_id) + + if len(active) == 1: + return active[0] + + if len(active) > 1: + if allow_multiple: + return active[0] + ids = ", ".join(r.run_id for r in active) + raise StateError( + f"Multiple active runs found: {ids}. " + "Specify one with --run-id or use `list-runs` to review them." + ) + + legacy_dir = detect_legacy_state(repo_root) + if legacy_dir is not None: + legacy_path = legacy_dir / LEGACY_STATE_FILE_NAME + try: + legacy_state = json.loads(legacy_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise StateError(f"Invalid legacy state at {legacy_path}: {exc}") from exc + if not isinstance(legacy_state, dict): + raise StateError(f"Legacy state must be a JSON object: {legacy_path}") + run_id_val = legacy_state.get("run_id", "legacy") + print( + f"[autonomous-development] DEPRECATION: Using legacy state at {legacy_dir}. " + "Run `controller.py migrate-legacy-state` to upgrade to the portable layout.", + file=sys.stderr, + ) + return RunRef(run_id=run_id_val, run_dir=legacy_dir, state=legacy_state) + + raise StateError( + "No active workflow run found. " + 'Run `controller.py init --feature "..."` to start a new run, ' + "or `list-runs` to see all runs." + ) + + +# --------------------------------------------------------------------------- +# Drift detection +# --------------------------------------------------------------------------- + + +class DriftKind(Enum): + """Classification of repository drift relative to a recorded baseline.""" + + NONE = "none" + EXPECTED = "expected" + UNSAFE = "unsafe" + + +@dataclass +class DriftResult: + """Result of a drift detection check.""" + + kind: DriftKind + message: str + recovery: str + + +def detect_drift(state: dict, repo: RepoInfo) -> DriftResult: + """Check for drift between recorded baseline and current repo state.""" + repo_block = state.get("repository", {}) + baseline = state.get("baseline", {}) + + if isinstance(repo_block, dict) and repo_block.get("id"): + recorded_repo_id = repo_block["id"] + if recorded_repo_id != repo.id: + return DriftResult( + kind=DriftKind.UNSAFE, + message=( + f"Repository identity changed: recorded {recorded_repo_id!r}, " + f"current {repo.id!r}." + ), + recovery=( + "You appear to be in a different repository. " + "Switch to the correct repository or use `list-runs` to find the right run." + ), + ) + + if isinstance(baseline, dict): + recorded_worktree = baseline.get("worktree_path", "") + if recorded_worktree and str(repo.worktree_path) != recorded_worktree: + return DriftResult( + kind=DriftKind.UNSAFE, + message=( + f"Worktree changed: recorded {recorded_worktree!r}, " + f"current {str(repo.worktree_path)!r}." + ), + recovery=( + "Switch to the recorded worktree or run `accept-drift` " + "to record the new worktree as the baseline." + ), + ) + + recorded_branch = baseline.get("branch", "") + if recorded_branch and repo.branch != recorded_branch: + return DriftResult( + kind=DriftKind.UNSAFE, + message=( + f"Branch changed: recorded {recorded_branch!r}, " + f"current {repo.branch!r}." + ), + recovery=( + f"Switch back to branch {recorded_branch!r} or run `accept-drift` " + "to record the new branch as the baseline." + ), + ) + + recorded_commit = baseline.get("commit", "") + if recorded_commit and repo.head_commit and repo.head_commit != recorded_commit: + return DriftResult( + kind=DriftKind.EXPECTED, + message=( + f"HEAD advanced from {recorded_commit[:12]!r} " + f"to {repo.head_commit[:12]!r} on branch {repo.branch!r}." + ), + recovery="No action required; HEAD advancing on the same branch is expected.", + ) + + return DriftResult( + kind=DriftKind.NONE, + message="No drift detected.", + recovery="", + ) + + +# --------------------------------------------------------------------------- +# Repository context string +# --------------------------------------------------------------------------- + + +def repository_context(repo: RepoInfo) -> str: + """Return a human-readable summary of the repository for inclusion in Codex prompts.""" + tracked = _run_git("ls-files", cwd=repo.canonical_root) + top_files = "\n".join(tracked.splitlines()[:250]) + status = _run_git("status", "--short", cwd=repo.canonical_root) + remotes = _run_git("remote", "-v", cwd=repo.canonical_root) + return ( + f"Repository: {repo.display_name}\n" + f'Branch: {repo.branch or "(detached)"}\n' + f'HEAD: {repo.head_commit or "(unknown)"}\n' + f'Working tree status:\n{status or "(clean)"}\n\n' + f'Remotes (informational only; workflow must not push):\n{remotes or "(none)"}\n\n' + f'First tracked files (maximum 250):\n{top_files or "(none)"}\n' + ) diff --git a/scripts/stop_gate.py b/scripts/stop_gate.py index 170019e..94bb471 100755 --- a/scripts/stop_gate.py +++ b/scripts/stop_gate.py @@ -7,44 +7,91 @@ import os import sys from pathlib import Path -from typing import Any - -STATE_REL = Path('.ai/autonomous-development/run-state.json') -TERMINAL = {'complete', 'blocked', 'cancelled'} -MAX_GATE_BLOCKS = 3 +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from state import ( + TERMINAL_STATUSES, + RunStateLock, + StateError, + find_active_runs, + load_run_state, + resolve_repository, + resolve_state_home, + save_run_state, + detect_legacy_state, + LEGACY_STATE_FILE_NAME, +) -def atomic_write(path: Path, value: dict[str, Any]) -> None: - temp = path.with_suffix('.json.tmp') - temp.write_text(json.dumps(value, indent=2, sort_keys=True) + '\n', encoding='utf-8') - temp.replace(path) +MAX_GATE_BLOCKS = 3 -def reason_for(state: dict[str, Any], root: Path) -> str: - directory = root / STATE_REL.parent - if not (directory / 'accepted-spec.md').exists(): - return 'Continue the autonomous workflow: reconcile the Codex proposal and create accepted-spec.md.' - if not (directory / 'accepted-plan.md').exists(): - return 'Continue the autonomous workflow: reconcile the Codex plan and create accepted-plan.md.' - all_checks = state.get('verification', {}).get('checks', []) - latest = {} +def reason_for(state: dict, run_dir: Path) -> str: + """Return a human-readable reason to continue the workflow.""" + if not (run_dir / "accepted-spec.md").exists(): + return ( + "Continue the autonomous workflow: reconcile the Codex proposal " + "and create accepted-spec.md." + ) + if not (run_dir / "accepted-plan.md").exists(): + return ( + "Continue the autonomous workflow: reconcile the Codex plan " + "and create accepted-plan.md." + ) + all_checks = state.get("verification", {}).get("checks", []) + latest: dict[str, dict] = {} for check in all_checks: - latest[str(check.get('name', 'unnamed'))] = check + latest[str(check.get("name", "unnamed"))] = check checks = list(latest.values()) if not checks: - return 'Continue the autonomous workflow: run and record relevant verification checks.' - if any(check.get('exit_code') != 0 for check in checks): - return 'Continue the autonomous workflow: fix the failing verification checks and rerun them.' - reviews = state.get('reviews', []) + return "Continue the autonomous workflow: run and record relevant verification checks." + if any(check.get("exit_code") != 0 for check in checks): + return ( + "Continue the autonomous workflow: fix the failing verification checks " + "and rerun them." + ) + reviews = state.get("reviews", []) if not reviews: - return 'Continue the autonomous workflow: run the independent Codex code review.' - if reviews[-1].get('verdict') != 'pass': - return 'Continue the autonomous workflow: triage the latest Codex findings, fix valid issues, verify, and re-review.' - if state.get('risk', {}).get('requires_adversarial_review'): - adversarial = state.get('adversarial_reviews', []) - if not adversarial or adversarial[-1].get('verdict') != 'pass': - return 'Continue the autonomous workflow: complete the required adversarial review and address valid risks.' - return 'Run the controller completion-gate evaluation and provide the final implementation report.' + return ( + "Continue the autonomous workflow: run the independent Codex code review." + ) + if reviews[-1].get("verdict") != "pass": + return ( + "Continue the autonomous workflow: triage the latest Codex findings, " + "fix valid issues, verify, and re-review." + ) + if state.get("risk", {}).get("requires_adversarial_review"): + adversarial = state.get("adversarial_reviews", []) + if not adversarial or adversarial[-1].get("verdict") != "pass": + return ( + "Continue the autonomous workflow: complete the required adversarial review " + "and address valid risks." + ) + return "Run the controller completion-gate evaluation and provide the final implementation report." + + +def _block_and_exit(run_dir: Path) -> int: + """Atomically increment stop_gate_blocks and persist; print block JSON or exhaust. Return 0.""" + with RunStateLock(run_dir): + try: + state = load_run_state(run_dir, required=True) + except Exception: + return 0 + if state.get("status") in TERMINAL_STATUSES: + return 0 + blocks = int(state.get("stop_gate_blocks", 0)) + if blocks >= MAX_GATE_BLOCKS: + state["status"] = "blocked" + state["phase"] = "stop-gate-budget-exhausted" + state.setdefault("notes", []).append( + "The bounded Stop hook retry budget was exhausted; inspect manually." + ) + save_run_state(run_dir, state) + return 0 + state["stop_gate_blocks"] = blocks + 1 + reason = reason_for(state, run_dir) + save_run_state(run_dir, state) + print(json.dumps({"decision": "block", "reason": reason})) + return 0 def main() -> int: @@ -53,33 +100,66 @@ def main() -> int: except Exception: return 0 - root = Path(payload.get('cwd') or os.getcwd()).resolve() - path = root / STATE_REL - if not path.exists(): + cwd = Path(payload.get("cwd") or os.getcwd()).resolve() + + # Step 1: resolve repository — if not in a git repo, nothing to do + try: + repo = resolve_repository(cwd) + except StateError: + return 0 + except Exception: return 0 + + # Step 2: resolve state home (env or XDG default; no CLI arg available here) try: - state = json.loads(path.read_text(encoding='utf-8')) + state_home = resolve_state_home(None) except Exception: return 0 - if state.get('status') in TERMINAL: + # Step 3: find active runs + try: + active = find_active_runs(state_home, repo.id) + except Exception: return 0 - blocks = int(state.get('stop_gate_blocks', 0)) - if blocks >= MAX_GATE_BLOCKS: - state['status'] = 'blocked' - state['phase'] = 'stop-gate-budget-exhausted' - state.setdefault('notes', []).append( - 'The bounded Stop hook retry budget was exhausted; inspect workflow status manually.' + run_dir: Path + state: dict + + if len(active) == 0: + # Fall back to legacy state in repo root + legacy_dir = detect_legacy_state(repo.canonical_root) + if legacy_dir is None: + return 0 + legacy_path = legacy_dir / LEGACY_STATE_FILE_NAME + try: + state = json.loads(legacy_path.read_text(encoding="utf-8")) + except Exception: + return 0 + if not isinstance(state, dict): + return 0 + run_dir = legacy_dir + + elif len(active) == 1: + run_dir = active[0].run_dir + state = active[0].state + + else: + # Multiple active runs — ambiguous; fail safe without blocking + ids = ", ".join(r.run_id for r in active) + print( + f"autonomous-development stop-gate: multiple active runs ({ids}); " + "cannot auto-select — resolve manually.", + file=sys.stderr, ) - atomic_write(path, state) return 0 - state['stop_gate_blocks'] = blocks + 1 - atomic_write(path, state) - print(json.dumps({'decision': 'block', 'reason': reason_for(state, root)})) - return 0 + # Step 4: skip if run is already terminal + if state.get("status") in TERMINAL_STATUSES: + return 0 + + # Step 5: enforce bounded block counter + return _block_and_exit(run_dir) -if __name__ == '__main__': +if __name__ == "__main__": raise SystemExit(main()) diff --git a/scripts/validate_project.py b/scripts/validate_project.py index 520770a..5e7ca56 100755 --- a/scripts/validate_project.py +++ b/scripts/validate_project.py @@ -12,44 +12,48 @@ def fail(message: str) -> None: - print(f'ERROR: {message}', file=sys.stderr) + print(f"ERROR: {message}", file=sys.stderr) raise SystemExit(1) def main() -> int: - manifest = json.loads((ROOT / '.claude-plugin/plugin.json').read_text(encoding='utf-8')) - if not re.fullmatch(r'[a-z0-9]+(?:-[a-z0-9]+)*', manifest.get('name', '')): - fail('Plugin name must be kebab-case') - - for schema in sorted((ROOT / 'schemas').glob('*.json')): - parsed = json.loads(schema.read_text(encoding='utf-8')) - if parsed.get('type') != 'object': - fail(f'{schema} must define an object schema') - - skills = sorted((ROOT / 'skills').glob('*/SKILL.md')) + manifest = json.loads( + (ROOT / ".claude-plugin/plugin.json").read_text(encoding="utf-8") + ) + if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", manifest.get("name", "")): + fail("Plugin name must be kebab-case") + + for schema in sorted((ROOT / "schemas").glob("*.json")): + parsed = json.loads(schema.read_text(encoding="utf-8")) + if parsed.get("type") != "object": + fail(f"{schema} must define an object schema") + + skills = sorted((ROOT / "skills").glob("*/SKILL.md")) if not skills: - fail('No skills found') + fail("No skills found") for skill in skills: - text = skill.read_text(encoding='utf-8') - if not text.startswith('---\n'): - fail(f'{skill} has no YAML frontmatter') - if '\ndescription:' not in text: - fail(f'{skill} has no description') + text = skill.read_text(encoding="utf-8") + if not text.startswith("---\n"): + fail(f"{skill} has no YAML frontmatter") + if "\ndescription:" not in text: + fail(f"{skill} has no description") required = [ - ROOT / 'scripts/controller.py', - ROOT / 'scripts/stop_gate.py', - ROOT / 'prompts/enhance-idea.md', - ROOT / 'prompts/implementation-plan.md', - ROOT / 'prompts/code-review.md', + ROOT / "scripts/controller.py", + ROOT / "scripts/stop_gate.py", + ROOT / "prompts/enhance-idea.md", + ROOT / "prompts/implementation-plan.md", + ROOT / "prompts/code-review.md", ] for path in required: if not path.exists(): - fail(f'Missing required file: {path}') + fail(f"Missing required file: {path}") - print(f'Validated {len(skills)} skills and {len(list((ROOT / "schemas").glob("*.json")))} schemas.') + print( + f'Validated {len(skills)} skills and {len(list((ROOT / "schemas").glob("*.json")))} schemas.' + ) return 0 -if __name__ == '__main__': +if __name__ == "__main__": raise SystemExit(main()) diff --git a/skills/autonomous-feature/SKILL.md b/skills/autonomous-feature/SKILL.md index 29dd6bd..f9e6ca8 100644 --- a/skills/autonomous-feature/SKILL.md +++ b/skills/autonomous-feature/SKILL.md @@ -60,7 +60,11 @@ python3 "${CLAUDE_PLUGIN_ROOT}/scripts/controller.py" doctor python3 "${CLAUDE_PLUGIN_ROOT}/scripts/controller.py" init --feature "$ARGUMENTS" ``` -When `doctor` reports a missing external prerequisite, mark the run blocked with the controller and report the exact missing prerequisite rather than bypassing it. +`init` prints the path to `run-state.json` and the run ID. When multiple concurrent runs are +active, pass `--run-id ` to all subsequent commands to target the correct run. + +When `doctor` reports a missing external prerequisite, mark the run blocked with the controller +and report the exact missing prerequisite rather than bypassing it. ### 2. Enhance the idea with Codex @@ -70,7 +74,9 @@ Run: python3 "${CLAUDE_PLUGIN_ROOT}/scripts/controller.py" codex --phase enhance ``` -Read `.ai/autonomous-development/feature-spec.codex.json`. Reconcile it against repository evidence and the user's actual idea: +Read the Codex output path printed by the controller (or use +`controller.py status --json` to find `artifacts.enhance`). Reconcile it against repository +evidence and the user's actual idea: - accept grounded requirements; - choose safe recommended defaults for non-blocking ambiguity; @@ -92,7 +98,10 @@ Run a fresh Codex execution: python3 "${CLAUDE_PLUGIN_ROOT}/scripts/controller.py" codex --phase plan ``` -Read `.ai/autonomous-development/implementation-plan.codex.json`. Verify all file paths, assumptions, sequencing, migrations, public interfaces, and test commands against the repository. Produce a concise accepted plan with explicit acceptance-criterion coverage, then register it: +Read the Codex output path printed by the controller (or use +`controller.py status --json` to find `artifacts.plan`). Verify all file paths, assumptions, +sequencing, migrations, public interfaces, and test commands against the repository. Produce a +concise accepted plan with explicit acceptance-criterion coverage, then register it: ```bash python3 "${CLAUDE_PLUGIN_ROOT}/scripts/controller.py" accept --kind plan --file diff --git a/skills/autonomous-status/SKILL.md b/skills/autonomous-status/SKILL.md index c5801b4..2daf11b 100644 --- a/skills/autonomous-status/SKILL.md +++ b/skills/autonomous-status/SKILL.md @@ -20,4 +20,20 @@ When more detail is needed: python3 "${CLAUDE_PLUGIN_ROOT}/scripts/controller.py" status --json ``` -Explain the current phase, passing and failing checks, latest review verdict, remaining review budget, high-risk review requirement, and the next concrete action. Do not modify product files. +When multiple runs exist for the repository, first list them: + +```bash +python3 "${CLAUDE_PLUGIN_ROOT}/scripts/controller.py" list-runs +``` + +Then inspect a specific run by ID: + +```bash +python3 "${CLAUDE_PLUGIN_ROOT}/scripts/controller.py" show-run --run-id +``` + +Pass `--run-id ` to `status` as well when you need to target one run among several +active ones. + +Explain the current phase, passing and failing checks, latest review verdict, remaining review +budget, high-risk review requirement, and the next concrete action. Do not modify product files. diff --git a/skills/codex-review/SKILL.md b/skills/codex-review/SKILL.md index b4dcabe..25b2bfc 100644 --- a/skills/codex-review/SKILL.md +++ b/skills/codex-review/SKILL.md @@ -16,6 +16,8 @@ disallowed-tools: AskUserQuestion Edit Write python3 "${CLAUDE_PLUGIN_ROOT}/scripts/controller.py" codex --phase review ``` -3. Read the generated `.ai/autonomous-development/review-NN.codex.json`. +3. Read the generated review file. Its path is printed by the controller; you can also find it + via `controller.py status --json` under `reviews[-1].path` (resolved relative to the run + directory reported by `controller.py show-run`). 4. Summarize the verdict and findings by severity with exact file evidence. 5. Do not edit product files. Finding triage and repairs belong to `/autonomous-development:fix-findings`. diff --git a/skills/enhance-idea/SKILL.md b/skills/enhance-idea/SKILL.md index fbd8f1a..985988d 100644 --- a/skills/enhance-idea/SKILL.md +++ b/skills/enhance-idea/SKILL.md @@ -22,5 +22,6 @@ python3 "${CLAUDE_PLUGIN_ROOT}/scripts/controller.py" init --reuse --feature "$A python3 "${CLAUDE_PLUGIN_ROOT}/scripts/controller.py" codex --phase enhance ``` -4. Read `.ai/autonomous-development/feature-spec.codex.json`. +4. Read the Codex output. Its path is printed by the controller; you can also find it via + `controller.py status --json` under `artifacts.enhance`. 5. Present a concise assessment of grounded requirements, assumptions, risky scope expansion, and blocking questions. Do not implement code in this skill. diff --git a/skills/fix-findings/SKILL.md b/skills/fix-findings/SKILL.md index da5df6c..5ac1859 100644 --- a/skills/fix-findings/SKILL.md +++ b/skills/fix-findings/SKILL.md @@ -9,7 +9,9 @@ disallowed-tools: AskUserQuestion # Triage and fix review findings -1. Read the latest `.ai/autonomous-development/review-NN.codex.json` and prior triage files. +1. Locate the latest review file and any prior triage files. Use `controller.py status --json` + to find `reviews[-1].path` (resolved relative to the run directory), or run + `controller.py show-run` to inspect the full run state. 2. Classify every finding as one of: - `accepted`; - `rejected_with_evidence`; diff --git a/skills/implement-plan/SKILL.md b/skills/implement-plan/SKILL.md index b6a57b0..b7ffd88 100644 --- a/skills/implement-plan/SKILL.md +++ b/skills/implement-plan/SKILL.md @@ -9,11 +9,10 @@ disallowed-tools: AskUserQuestion # Implement the accepted plan -Read: - -- `.ai/autonomous-development/accepted-spec.md`; -- `.ai/autonomous-development/accepted-plan.md`; -- repository instructions and relevant source/tests. +Read the current run's accepted artifacts. Use `controller.py show-run` (or +`controller.py status --json`) to find the paths under `artifacts.accepted_spec` and +`artifacts.accepted_plan`, then read those files along with repository instructions and +relevant source/tests. Then: diff --git a/skills/implementation-plan/SKILL.md b/skills/implementation-plan/SKILL.md index 1ae6c47..d9b0a0f 100644 --- a/skills/implementation-plan/SKILL.md +++ b/skills/implementation-plan/SKILL.md @@ -9,7 +9,10 @@ disallowed-tools: AskUserQuestion # Create an implementation plan -1. Confirm `.ai/autonomous-development/accepted-spec.md` exists. When only the Codex proposal exists, reconcile it first and register it with `controller.py accept --kind spec`. +1. Confirm an accepted spec exists. Use `controller.py status --json` and check + `artifacts.accepted_spec`, or run `controller.py show-run` to view the full run state. + When only the Codex proposal exists, reconcile it first and register it with + `controller.py accept --kind spec`. 2. Inspect repository conventions and likely change locations independently. 3. Run: @@ -17,7 +20,8 @@ disallowed-tools: AskUserQuestion python3 "${CLAUDE_PLUGIN_ROOT}/scripts/controller.py" codex --phase plan ``` -4. Validate `.ai/autonomous-development/implementation-plan.codex.json` against actual files and interfaces. +4. Validate the plan output (path printed by the controller, or found via + `controller.py status --json` under `artifacts.plan`) against actual files and interfaces. 5. Incorporate the optional emphasis: `$ARGUMENTS`. 6. Write a concise accepted Markdown plan and register it: diff --git a/tests/test_controller.py b/tests/test_controller.py index 5f7f955..f72f1e8 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -1,111 +1,271 @@ from __future__ import annotations import json +import os +import shutil import subprocess +import sys import tempfile import unittest from pathlib import Path ROOT = Path(__file__).resolve().parents[1] -CONTROLLER = ROOT / 'scripts/controller.py' -STOP_GATE = ROOT / 'scripts/stop_gate.py' +CONTROLLER = ROOT / "scripts/controller.py" +STOP_GATE = ROOT / "scripts/stop_gate.py" + +# Make state module importable for helpers +sys.path.insert(0, str(ROOT / "scripts")) +from state import find_active_runs, resolve_repository # noqa: E402 class ControllerTests(unittest.TestCase): + def setUp(self) -> None: + self._tmpdirs: list[Path] = [] + + def tearDown(self) -> None: + for d in self._tmpdirs: + if d.exists(): + shutil.rmtree(str(d), ignore_errors=True) + def make_repo(self) -> Path: temp = Path(tempfile.mkdtemp()) - subprocess.run(['git', 'init', '-q', str(temp)], check=True) - subprocess.run(['git', '-C', str(temp), 'config', 'user.email', 'test@example.com'], check=True) - subprocess.run(['git', '-C', str(temp), 'config', 'user.name', 'Test User'], check=True) - (temp / 'README.md').write_text('# Test\n', encoding='utf-8') - subprocess.run(['git', '-C', str(temp), 'add', 'README.md'], check=True) - subprocess.run(['git', '-C', str(temp), 'commit', '-qm', 'initial'], check=True) + self._tmpdirs.append(temp) + subprocess.run(["git", "init", "-q", str(temp)], check=True) + subprocess.run( + ["git", "-C", str(temp), "config", "user.email", "test@example.com"], + check=True, + ) + subprocess.run( + ["git", "-C", str(temp), "config", "user.name", "Test User"], check=True + ) + (temp / "README.md").write_text("# Test\n", encoding="utf-8") + subprocess.run(["git", "-C", str(temp), "add", "README.md"], check=True) + subprocess.run(["git", "-C", str(temp), "commit", "-qm", "initial"], check=True) return temp - def run_controller(self, repo: Path, *args: str) -> subprocess.CompletedProcess[str]: + def make_state_home(self) -> Path: + """Create a temporary directory for state storage.""" + d = Path(tempfile.mkdtemp()) + self._tmpdirs.append(d) + return d + + def run_controller( + self, repo: Path, *args: str, state_home: Path | None = None + ) -> subprocess.CompletedProcess[str]: + cmd = ["python3", str(CONTROLLER), "--project-root", str(repo)] + if state_home is not None: + cmd += ["--state-dir", str(state_home)] + cmd += list(args) return subprocess.run( - ['python3', str(CONTROLLER), '--project-root', str(repo), *args], + cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) + def _find_state_path(self, repo: Path, state_home: Path) -> Path: + """Locate run-state.json for the single active run in state_home.""" + repo_info = resolve_repository(repo) + active = find_active_runs(state_home, repo_info.id) + if not active: + raise AssertionError( + f"No active runs found in {state_home} for repo {repo_info.id}" + ) + return active[0].run_dir / "run-state.json" + def test_init_and_status(self) -> None: repo = self.make_repo() - result = self.run_controller(repo, 'init', '--feature', 'Add a test feature') + state_home = self.make_state_home() + + result = self.run_controller( + repo, "init", "--feature", "Add a test feature", state_home=state_home + ) self.assertEqual(result.returncode, 0, result.stderr) - state_path = repo / '.ai/autonomous-development/run-state.json' - state = json.loads(state_path.read_text(encoding='utf-8')) - self.assertEqual(state['status'], 'active') - self.assertEqual(state['feature'], 'Add a test feature') - status = self.run_controller(repo, 'status') + state_path = self._find_state_path(repo, state_home) + self.assertTrue(state_path.exists(), f"State file not found at {state_path}") + state = json.loads(state_path.read_text(encoding="utf-8")) + self.assertEqual(state["status"], "active") + self.assertEqual(state["feature"], "Add a test feature") + + status = self.run_controller(repo, "status", state_home=state_home) self.assertEqual(status.returncode, 0, status.stderr) - self.assertIn('Phase: initialized', status.stdout) + self.assertIn("Phase: initialized", status.stdout) def test_record_passing_check(self) -> None: repo = self.make_repo() + state_home = self.make_state_home() + self.assertEqual( - self.run_controller(repo, 'init', '--feature', 'Feature').returncode, 0 + self.run_controller( + repo, "init", "--feature", "Feature", state_home=state_home + ).returncode, + 0, ) result = self.run_controller( - repo, 'run-check', '--name', 'truth', '--', 'python3', '-c', 'print("ok")' + repo, + "run-check", + "--name", + "truth", + "--", + "python3", + "-c", + 'print("ok")', + state_home=state_home, ) self.assertEqual(result.returncode, 0, result.stderr) - state = json.loads( - (repo / '.ai/autonomous-development/run-state.json').read_text(encoding='utf-8') - ) - self.assertTrue(state['verification']['passed']) + state_path = self._find_state_path(repo, state_home) + state = json.loads(state_path.read_text(encoding="utf-8")) + self.assertTrue(state["verification"]["passed"]) def test_rerun_supersedes_failed_check(self) -> None: repo = self.make_repo() + state_home = self.make_state_home() + self.assertEqual( - self.run_controller(repo, 'init', '--feature', 'Feature').returncode, 0 + self.run_controller( + repo, "init", "--feature", "Feature", state_home=state_home + ).returncode, + 0, ) failed = self.run_controller( - repo, 'run-check', '--name', 'tests', '--', 'python3', '-c', 'raise SystemExit(1)' + repo, + "run-check", + "--name", + "tests", + "--", + "python3", + "-c", + "raise SystemExit(1)", + state_home=state_home, ) self.assertEqual(failed.returncode, 1) passed = self.run_controller( - repo, 'run-check', '--name', 'tests', '--', 'python3', '-c', 'print("fixed")' + repo, + "run-check", + "--name", + "tests", + "--", + "python3", + "-c", + 'print("fixed")', + state_home=state_home, ) self.assertEqual(passed.returncode, 0, passed.stderr) - state = json.loads( - (repo / '.ai/autonomous-development/run-state.json').read_text(encoding='utf-8') - ) - self.assertTrue(state['verification']['passed']) + + state_path = self._find_state_path(repo, state_home) + state = json.loads(state_path.read_text(encoding="utf-8")) + self.assertTrue(state["verification"]["passed"]) def test_stop_gate_is_bounded(self) -> None: repo = self.make_repo() + state_home = self.make_state_home() + self.assertEqual( - self.run_controller(repo, 'init', '--feature', 'Feature').returncode, 0 + self.run_controller( + repo, "init", "--feature", "Feature", state_home=state_home + ).returncode, + 0, ) - payload = json.dumps({'cwd': str(repo), 'hook_event_name': 'Stop'}) + + # Capture state path while the run is still active (before budget exhaustion) + state_path = self._find_state_path(repo, state_home) + + payload = json.dumps({"cwd": str(repo), "hook_event_name": "Stop"}) + env = {**os.environ, "CLAUDE_AUTONOMOUS_STATE_HOME": str(state_home)} + for _ in range(3): result = subprocess.run( - ['python3', str(STOP_GATE)], + ["python3", str(STOP_GATE)], input=payload, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + env=env, ) self.assertEqual(result.returncode, 0) self.assertIn('"decision": "block"', result.stdout) + final = subprocess.run( - ['python3', str(STOP_GATE)], + ["python3", str(STOP_GATE)], input=payload, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + env=env, ) self.assertEqual(final.returncode, 0) - self.assertEqual(final.stdout, '') - state = json.loads( - (repo / '.ai/autonomous-development/run-state.json').read_text(encoding='utf-8') + self.assertEqual(final.stdout, "") + + # After budget exhausted the run is terminal; read state directly by path + state = json.loads(state_path.read_text(encoding="utf-8")) + self.assertEqual(state["status"], "blocked") + + def test_reuse_ambiguous_multiple_runs_errors(self) -> None: + """init --reuse with multiple active runs must error rather than silently pick one.""" + repo = self.make_repo() + state_home = self.make_state_home() + + # Create two distinct active runs using --force + self.assertEqual( + self.run_controller( + repo, "init", "--feature", "Run A", state_home=state_home + ).returncode, + 0, + ) + self.assertEqual( + self.run_controller( + repo, "init", "--feature", "Run B", "--force", state_home=state_home + ).returncode, + 0, + ) + + result = self.run_controller( + repo, "init", "--feature", "ignored", "--reuse", state_home=state_home + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("Multiple active runs", result.stderr) + + def test_review_budget_exhausted_sets_blocked(self) -> None: + """cmd_codex --phase review over budget must atomically set status=blocked.""" + import json as _json + + repo = self.make_repo() + state_home = self.make_state_home() + + init = self.run_controller( + repo, "init", "--feature", "Feature", state_home=state_home ) - self.assertEqual(state['status'], 'blocked') + self.assertEqual(init.returncode, 0, init.stderr) + + state_path = self._find_state_path(repo, state_home) + run_dir = state_path.parent + + # Force review_round to the maximum so the next review attempt exceeds budget. + state = _json.loads(state_path.read_text(encoding="utf-8")) + state["review_round"] = state.get("max_review_rounds", 3) + state_path.write_text(_json.dumps(state), encoding="utf-8") + + # Write required artifacts to pass pre-flight checks. + (run_dir / "accepted-spec.md").write_text("spec", encoding="utf-8") + (run_dir / "accepted-plan.md").write_text("plan", encoding="utf-8") + state = _json.loads(state_path.read_text(encoding="utf-8")) + state.setdefault("verification", {})["checks"] = [ + {"name": "t", "exit_code": 0, "passed": True} + ] + state["verification"]["passed"] = True + state_path.write_text(_json.dumps(state), encoding="utf-8") + + result = self.run_controller( + repo, "codex", "--phase", "review", state_home=state_home + ) + self.assertNotEqual(result.returncode, 0) + + final_state = _json.loads(state_path.read_text(encoding="utf-8")) + self.assertEqual(final_state["status"], "blocked") + self.assertEqual(final_state["phase"], "review-budget-exhausted") -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/test_project_layout.py b/tests/test_project_layout.py index 54f2bbc..292659a 100644 --- a/tests/test_project_layout.py +++ b/tests/test_project_layout.py @@ -9,43 +9,48 @@ class ProjectLayoutTests(unittest.TestCase): def test_manifest_and_components(self) -> None: - manifest = json.loads((ROOT / '.claude-plugin/plugin.json').read_text(encoding='utf-8')) - self.assertEqual(manifest['name'], 'autonomous-development') + manifest = json.loads( + (ROOT / ".claude-plugin/plugin.json").read_text(encoding="utf-8") + ) + self.assertEqual(manifest["name"], "autonomous-development") expected = { - 'autonomous-feature', - 'enhance-idea', - 'implementation-plan', - 'implement-plan', - 'verify-feature', - 'codex-review', - 'adversarial-review', - 'fix-findings', - 'autonomous-status', + "autonomous-feature", + "enhance-idea", + "implementation-plan", + "implement-plan", + "verify-feature", + "codex-review", + "adversarial-review", + "fix-findings", + "autonomous-status", } - actual = {path.parent.name for path in (ROOT / 'skills').glob('*/SKILL.md')} + actual = {path.parent.name for path in (ROOT / "skills").glob("*/SKILL.md")} self.assertEqual(actual, expected) def test_json_files_parse(self) -> None: - for path in ROOT.rglob('*.json'): - json.loads(path.read_text(encoding='utf-8')) + for path in ROOT.rglob("*.json"): + json.loads(path.read_text(encoding="utf-8")) def test_prompt_placeholders_are_known(self) -> None: known = { - 'FEATURE', - 'BASELINE', - 'REPOSITORY_CONTEXT', - 'CODEX_SPEC', - 'ACCEPTED_SPEC', - 'ACCEPTED_PLAN', - 'VERIFICATION', - 'PREVIOUS_REVIEW', - 'LATEST_REVIEW', + "FEATURE", + "BASELINE", + "REPOSITORY_CONTEXT", + "CODEX_SPEC", + "ACCEPTED_SPEC", + "ACCEPTED_PLAN", + "VERIFICATION", + "PREVIOUS_REVIEW", + "LATEST_REVIEW", } import re - for path in (ROOT / 'prompts').glob('*.md'): - placeholders = set(re.findall(r'\{\{([A-Z0-9_]+)\}\}', path.read_text(encoding='utf-8'))) - self.assertTrue(placeholders <= known, f'{path}: {placeholders - known}') + for path in (ROOT / "prompts").glob("*.md"): + placeholders = set( + re.findall(r"\{\{([A-Z0-9_]+)\}\}", path.read_text(encoding="utf-8")) + ) + self.assertTrue(placeholders <= known, f"{path}: {placeholders - known}") -if __name__ == '__main__': + +if __name__ == "__main__": unittest.main() diff --git a/tests/test_state.py b/tests/test_state.py new file mode 100644 index 0000000..38e78c5 --- /dev/null +++ b/tests/test_state.py @@ -0,0 +1,827 @@ +"""Comprehensive tests for the state module (scripts/state.py).""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +import tempfile +import threading +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS = ROOT / "scripts" +STOP_GATE = SCRIPTS / "stop_gate.py" + +# Make sure scripts/ is importable +sys.path.insert(0, str(SCRIPTS)) +import state as state_module # noqa: E402 +from state import ( # noqa: E402 + DriftKind, + RunStateLock, + StateError, + detect_drift, + detect_legacy_state, + find_active_runs, + load_run_state, + new_run_id, + resolve_artifact_path, + resolve_repository, + resolve_state_home, + resolve_active_run, + run_dir_path, + save_run_state, + validate_state, + atomic_write_json, + LEGACY_STATE_FILE_NAME, + LEGACY_STATE_REL, + STATE_SCHEMA_VERSION, +) + +# --------------------------------------------------------------------------- +# Helper utilities +# --------------------------------------------------------------------------- + + +def _make_repo_with_config(path: Path | None = None) -> Path: + """Create a minimal git repo with local user config (no global config dependency).""" + temp = Path(tempfile.mkdtemp()) if path is None else path + temp.mkdir(parents=True, exist_ok=True) + env = {**os.environ, "GIT_CONFIG_NOSYSTEM": "1", "HOME": str(temp)} + subprocess.run(["git", "init", "-q", str(temp)], check=True, env=env) + subprocess.run( + ["git", "-C", str(temp), "config", "user.email", "test@example.com"], check=True + ) + subprocess.run( + ["git", "-C", str(temp), "config", "user.name", "Test User"], check=True + ) + (temp / "README.md").write_text("# Test\n", encoding="utf-8") + subprocess.run(["git", "-C", str(temp), "add", "README.md"], check=True) + subprocess.run(["git", "-C", str(temp), "commit", "-qm", "initial"], check=True) + return temp + + +def _minimal_state(run_id: str = "test-run", status: str = "active") -> dict: + """Return a minimal valid run state dict.""" + return { + "schema_version": STATE_SCHEMA_VERSION, + "run_id": run_id, + "status": status, + "phase": "initialized", + "feature": "test feature", + "baseline": {"commit": "abc123", "branch": "main"}, + "repository": {"id": "repoid00000001"}, + "verification": {"checks": [], "passed": False}, + "reviews": [], + "adversarial_reviews": [], + "stop_gate_blocks": 0, + } + + +# --------------------------------------------------------------------------- +# Test class +# --------------------------------------------------------------------------- + + +class StateModuleTests(unittest.TestCase): + + def setUp(self) -> None: + self._tmpdirs: list[Path] = [] + + def tearDown(self) -> None: + for d in self._tmpdirs: + if d.exists(): + # Fix permissions before removing (for test 24) + try: + shutil.rmtree(str(d)) + except PermissionError: + subprocess.run(["chmod", "-R", "755", str(d)]) + shutil.rmtree(str(d)) + + def _tmpdir(self, path: Path | None = None) -> Path: + d = Path(tempfile.mkdtemp()) if path is None else path + d.mkdir(parents=True, exist_ok=True) + self._tmpdirs.append(d) + return d + + def make_repo_with_config(self, path: Path | None = None) -> Path: + """Create a minimal git repo with local user config (no global config dependency).""" + if path is None: + path = Path(tempfile.mkdtemp()) + self._tmpdirs.append(path) + else: + self._tmpdirs.append(path) + return _make_repo_with_config(path) + + # ----------------------------------------------------------------------- + # 1. repo discovery from nested directory + # ----------------------------------------------------------------------- + + def test_repo_discovery_from_nested_directory(self) -> None: + """resolve_repository called from a subdir returns the git toplevel.""" + repo = self.make_repo_with_config() + subdir = repo / "sub" / "deep" + subdir.mkdir(parents=True) + + info = resolve_repository(subdir) + + self.assertEqual(info.canonical_root, repo.resolve()) + + # ----------------------------------------------------------------------- + # 2. repo discovery from root + # ----------------------------------------------------------------------- + + def test_repo_discovery_from_root(self) -> None: + """resolve_repository called from the repo root itself works.""" + repo = self.make_repo_with_config() + + info = resolve_repository(repo) + + self.assertEqual(info.canonical_root, repo.resolve()) + + # ----------------------------------------------------------------------- + # 3. repo discovery in linked worktree + # ----------------------------------------------------------------------- + + @unittest.skipIf( + subprocess.run( + ["git", "worktree", "--help"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ).returncode + != 0, + "git worktree not available", + ) + def test_repo_discovery_in_linked_worktree(self) -> None: + """resolve_repository in a linked worktree shares git_common_dir with main tree.""" + repo = self.make_repo_with_config() + branch_name = "worktree-branch" + worktree_path = Path(tempfile.mkdtemp()) + self._tmpdirs.append(worktree_path) + worktree_path.rmdir() # git worktree add creates it + + # Create branch then linked worktree + subprocess.run( + ["git", "-C", str(repo), "branch", branch_name], + check=True, + ) + result = subprocess.run( + [ + "git", + "-C", + str(repo), + "worktree", + "add", + str(worktree_path), + branch_name, + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if result.returncode != 0: + self.skipTest(f"git worktree add failed: {result.stderr.decode()}") + + main_info = resolve_repository(repo) + wt_info = resolve_repository(worktree_path) + + self.assertEqual(main_info.git_common_dir, wt_info.git_common_dir) + + # ----------------------------------------------------------------------- + # 4. repo without remote + # ----------------------------------------------------------------------- + + def test_repo_without_remote(self) -> None: + """A repo with no remote still produces a valid repo_id (non-empty string).""" + repo = self.make_repo_with_config() + + info = resolve_repository(repo) + + self.assertIsInstance(info.id, str) + self.assertTrue(len(info.id) > 0) + self.assertEqual(info.remote_display, "") + + # ----------------------------------------------------------------------- + # 5. remote credentials stripped + # ----------------------------------------------------------------------- + + def test_remote_credentials_stripped(self) -> None: + """Remote URL with user:pass@ is sanitised in remote_display.""" + repo = self.make_repo_with_config() + subprocess.run( + [ + "git", + "-C", + str(repo), + "remote", + "add", + "origin", + "https://user:secret@github.com/org/repo.git", + ], + check=True, + ) + + info = resolve_repository(repo) + + self.assertNotIn("user", info.remote_display) + self.assertNotIn("secret", info.remote_display) + self.assertIn("github.com", info.remote_display) + + # ----------------------------------------------------------------------- + # 6. XDG state resolution default (Linux/non-macOS/non-Windows) + # ----------------------------------------------------------------------- + + @unittest.skipIf(sys.platform in ("darwin", "win32"), "Linux-only test") + def test_xdg_state_resolution_default(self) -> None: + """Without env vars, state home resolves to ~/.local/state/claude-autonomous.""" + env_backup_xdg = os.environ.pop("XDG_STATE_HOME", None) + env_backup_state = os.environ.pop("CLAUDE_AUTONOMOUS_STATE_HOME", None) + try: + result = resolve_state_home(None) + expected = Path.home() / ".local" / "state" / "claude-autonomous" + self.assertEqual(result, expected) + finally: + if env_backup_xdg is not None: + os.environ["XDG_STATE_HOME"] = env_backup_xdg + if env_backup_state is not None: + os.environ["CLAUDE_AUTONOMOUS_STATE_HOME"] = env_backup_state + + # ----------------------------------------------------------------------- + # 7. state_dir CLI arg takes precedence + # ----------------------------------------------------------------------- + + def test_state_dir_cli_precedence(self) -> None: + """CLI arg beats both env var and XDG default.""" + cli_dir = self._tmpdir() + old_env = os.environ.get("CLAUDE_AUTONOMOUS_STATE_HOME") + old_xdg = os.environ.get("XDG_STATE_HOME") + try: + os.environ["CLAUDE_AUTONOMOUS_STATE_HOME"] = "/some/other/path" + os.environ["XDG_STATE_HOME"] = "/yet/another/path" + + result = resolve_state_home(str(cli_dir)) + + self.assertEqual(result, cli_dir.resolve()) + finally: + if old_env is None: + os.environ.pop("CLAUDE_AUTONOMOUS_STATE_HOME", None) + else: + os.environ["CLAUDE_AUTONOMOUS_STATE_HOME"] = old_env + if old_xdg is None: + os.environ.pop("XDG_STATE_HOME", None) + else: + os.environ["XDG_STATE_HOME"] = old_xdg + + # ----------------------------------------------------------------------- + # 8. CLAUDE_AUTONOMOUS_STATE_HOME env var resolution + # ----------------------------------------------------------------------- + + def test_env_var_state_resolution(self) -> None: + """CLAUDE_AUTONOMOUS_STATE_HOME env var is used when set.""" + custom_dir = self._tmpdir() + old_env = os.environ.get("CLAUDE_AUTONOMOUS_STATE_HOME") + try: + os.environ["CLAUDE_AUTONOMOUS_STATE_HOME"] = str(custom_dir) + + result = resolve_state_home(None) + + self.assertEqual(result, custom_dir.resolve()) + finally: + if old_env is None: + os.environ.pop("CLAUDE_AUTONOMOUS_STATE_HOME", None) + else: + os.environ["CLAUDE_AUTONOMOUS_STATE_HOME"] = old_env + + # ----------------------------------------------------------------------- + # 9. two simultaneous active runs + # ----------------------------------------------------------------------- + + def test_two_simultaneous_active_runs(self) -> None: + """Two init calls for the same repo produce two distinct active runs.""" + repo = self.make_repo_with_config() + state_home = self._tmpdir() + + repo_info = resolve_repository(repo) + + run_id_a = "run-a-00000001" + run_id_b = "run-b-00000002" + + for run_id in (run_id_a, run_id_b): + rdir = run_dir_path(state_home, repo_info.id, run_id) + rdir.mkdir(parents=True, exist_ok=True) + save_run_state(rdir, _minimal_state(run_id=run_id, status="active")) + + active = find_active_runs(state_home, repo_info.id) + + self.assertEqual(len(active), 2) + ids = {r.run_id for r in active} + self.assertIn(run_id_a, ids) + self.assertIn(run_id_b, ids) + + # ----------------------------------------------------------------------- + # 10. ambiguous run selection raises StateError + # ----------------------------------------------------------------------- + + def test_ambiguous_run_selection(self) -> None: + """With two active runs and no run_id, resolve_active_run raises StateError.""" + repo = self.make_repo_with_config() + state_home = self._tmpdir() + + repo_info = resolve_repository(repo) + + run_id_a = "run-a-00000001" + run_id_b = "run-b-00000002" + + for run_id in (run_id_a, run_id_b): + rdir = run_dir_path(state_home, repo_info.id, run_id) + rdir.mkdir(parents=True, exist_ok=True) + save_run_state(rdir, _minimal_state(run_id=run_id, status="active")) + + with self.assertRaises(StateError) as ctx: + resolve_active_run(state_home, repo_info.id, repo.resolve(), run_id=None) + + err_msg = str(ctx.exception) + self.assertIn(run_id_a, err_msg) + self.assertIn(run_id_b, err_msg) + + # ----------------------------------------------------------------------- + # 11. relative artifact resolution after state move + # ----------------------------------------------------------------------- + + def test_relative_artifact_resolution_after_state_move(self) -> None: + """Moving the run dir to a new location, resolve_artifact_path still resolves correctly.""" + state_home = self._tmpdir() + run_id = "test-run-0001" + repo_id = "fakerepo0000001" + + orig_run_dir = run_dir_path(state_home, repo_id, run_id) + orig_run_dir.mkdir(parents=True, exist_ok=True) + artifact_file = orig_run_dir / "output.txt" + artifact_file.write_text("hello", encoding="utf-8") + + relative_ref = "output.txt" + + # Move run dir to a new location + new_state_home = self._tmpdir() + new_run_dir = run_dir_path(new_state_home, repo_id, run_id) + shutil.copytree(str(orig_run_dir), str(new_run_dir)) + + resolved = resolve_artifact_path(relative_ref, new_run_dir) + + self.assertEqual(resolved, (new_run_dir / "output.txt").resolve()) + self.assertTrue(resolved.exists()) + + # ----------------------------------------------------------------------- + # 12. branch drift detection + # ----------------------------------------------------------------------- + + def test_branch_drift_detection(self) -> None: + """Switching branches produces DriftKind.UNSAFE.""" + repo = self.make_repo_with_config() + repo_info = resolve_repository(repo) + + # Record baseline on 'main' branch + state = _minimal_state() + state["repository"] = {"id": repo_info.id} + state["baseline"] = { + "branch": repo_info.branch or "main", + "commit": repo_info.head_commit, + "worktree_path": str(repo_info.worktree_path), + } + + # Create and switch to a new branch + subprocess.run( + ["git", "-C", str(repo), "checkout", "-b", "feature-branch"], check=True + ) + new_repo_info = resolve_repository(repo) + + result = detect_drift(state, new_repo_info) + + self.assertEqual(result.kind, DriftKind.UNSAFE) + + # ----------------------------------------------------------------------- + # 13. HEAD drift is EXPECTED + # ----------------------------------------------------------------------- + + def test_head_drift_expected(self) -> None: + """Advancing HEAD on the same branch produces DriftKind.EXPECTED.""" + repo = self.make_repo_with_config() + repo_info = resolve_repository(repo) + + # Build state with current HEAD as baseline + original_commit = repo_info.head_commit + state = _minimal_state() + state["repository"] = {"id": repo_info.id} + state["baseline"] = { + "branch": repo_info.branch, + "commit": original_commit, + "worktree_path": str(repo_info.worktree_path), + } + + # Make a new commit + (repo / "file2.txt").write_text("new content\n", encoding="utf-8") + subprocess.run(["git", "-C", str(repo), "add", "file2.txt"], check=True) + subprocess.run( + ["git", "-C", str(repo), "commit", "-qm", "second commit"], check=True + ) + + new_repo_info = resolve_repository(repo) + + result = detect_drift(state, new_repo_info) + + self.assertEqual(result.kind, DriftKind.EXPECTED) + + # ----------------------------------------------------------------------- + # 14. accept-drift clears drift + # ----------------------------------------------------------------------- + + def test_accept_drift_clears_drift(self) -> None: + """After updating baseline to current branch/commit, detect_drift returns NONE.""" + repo = self.make_repo_with_config() + repo_info = resolve_repository(repo) + + # State that references an old branch (simulating drift) + state = _minimal_state() + state["repository"] = {"id": repo_info.id} + state["baseline"] = { + "branch": "old-branch", + "commit": repo_info.head_commit, + "worktree_path": str(repo_info.worktree_path), + } + + # Accept drift: update baseline to current state + state["baseline"]["branch"] = repo_info.branch + state["baseline"]["commit"] = repo_info.head_commit + state["baseline"]["worktree_path"] = str(repo_info.worktree_path) + state["repository"]["id"] = repo_info.id + + result = detect_drift(state, repo_info) + + self.assertEqual(result.kind, DriftKind.NONE) + + # ----------------------------------------------------------------------- + # 15. corrupt JSON state raises StateError + # ----------------------------------------------------------------------- + + def test_corrupt_json_state(self) -> None: + """Garbage JSON in run-state.json causes load_run_state to raise StateError.""" + run_dir = self._tmpdir() + state_file = run_dir / "run-state.json" + state_file.write_text("{not valid json!!!", encoding="utf-8") + + with self.assertRaises(StateError): + load_run_state(run_dir, required=True) + + # ----------------------------------------------------------------------- + # 16. unsupported schema version raises StateError + # ----------------------------------------------------------------------- + + def test_unsupported_schema_version(self) -> None: + """schema_version=99 causes validate_state to raise StateError.""" + bad_state = { + "schema_version": 99, + "run_id": "run-001", + "status": "active", + } + + with self.assertRaises(StateError) as ctx: + validate_state(bad_state) + + self.assertIn("99", str(ctx.exception)) + + # ----------------------------------------------------------------------- + # 17. atomic state update — temp file does not persist + # ----------------------------------------------------------------------- + + def test_atomic_state_update(self) -> None: + """After atomic_write_json succeeds, no .tmp file is left behind.""" + run_dir = self._tmpdir() + target = run_dir / "run-state.json" + + atomic_write_json(target, {"key": "value"}) + + # No .tmp file should remain + tmp_file = Path(str(target) + ".tmp") + self.assertFalse( + tmp_file.exists(), "Temp file was not cleaned up after atomic write" + ) + self.assertTrue(target.exists()) + loaded = json.loads(target.read_text(encoding="utf-8")) + self.assertEqual(loaded["key"], "value") + + # ----------------------------------------------------------------------- + # 18. concurrent state mutation — no lost updates + # ----------------------------------------------------------------------- + + def test_concurrent_state_mutation(self) -> None: + """Two threads incrementing stop_gate_blocks concurrently produce correct final value.""" + run_dir = self._tmpdir() + run_dir.mkdir(parents=True, exist_ok=True) + + initial = _minimal_state() + initial["stop_gate_blocks"] = 0 + save_run_state(run_dir, initial) + + errors: list[Exception] = [] + NUM_INCREMENTS = 5 + NUM_THREADS = 2 + + def increment_blocks() -> None: + try: + for _ in range(NUM_INCREMENTS): + with RunStateLock(run_dir): + s = load_run_state(run_dir, required=True) + s["stop_gate_blocks"] = int(s.get("stop_gate_blocks", 0)) + 1 + save_run_state(run_dir, s) + except Exception as exc: + errors.append(exc) + + threads = [ + threading.Thread(target=increment_blocks) for _ in range(NUM_THREADS) + ] + for t in threads: + t.start() + for t in threads: + t.join() + + self.assertEqual(errors, [], f"Thread errors: {errors}") + + final_state = load_run_state(run_dir, required=True) + self.assertEqual( + final_state["stop_gate_blocks"], + NUM_THREADS * NUM_INCREMENTS, + "Lost updates detected in concurrent mutation", + ) + + # ----------------------------------------------------------------------- + # 19. legacy state detection + # ----------------------------------------------------------------------- + + def test_legacy_state_detection(self) -> None: + """detect_legacy_state returns the legacy dir when run-state.json exists there.""" + repo = self.make_repo_with_config() + legacy_dir = repo / LEGACY_STATE_REL + legacy_dir.mkdir(parents=True, exist_ok=True) + (legacy_dir / LEGACY_STATE_FILE_NAME).write_text( + json.dumps({"run_id": "legacy-run", "status": "active", "version": 1}), + encoding="utf-8", + ) + + result = detect_legacy_state(repo) + + self.assertIsNotNone(result) + self.assertEqual(result, legacy_dir) + + # ----------------------------------------------------------------------- + # 20. non-destructive migration + # ----------------------------------------------------------------------- + + def test_non_destructive_migration(self) -> None: + """migrate_v1_to_v2 leaves original legacy files untouched.""" + repo = self.make_repo_with_config() + repo_info = resolve_repository(repo) + + legacy_dir = repo / LEGACY_STATE_REL + legacy_dir.mkdir(parents=True, exist_ok=True) + legacy_state = { + "version": 1, + "run_id": "legacy-migration-run", + "status": "active", + "feature": "old feature", + "baseline": {"commit": "abc123", "branch": "main"}, + "verification": {"checks": [], "passed": False}, + } + original_content = json.dumps(legacy_state) + (legacy_dir / LEGACY_STATE_FILE_NAME).write_text( + original_content, encoding="utf-8" + ) + + state_home = self._tmpdir() + run_id = legacy_state["run_id"] + new_run_dir = run_dir_path(state_home, repo_info.id, run_id) + new_run_dir.mkdir(parents=True, exist_ok=True) + + migrated = state_module.migrate_v1_to_v2(legacy_state, new_run_dir, repo_info) + save_run_state(new_run_dir, migrated) + + # Original file must be unchanged + actual_original = (legacy_dir / LEGACY_STATE_FILE_NAME).read_text( + encoding="utf-8" + ) + self.assertEqual(actual_original, original_content) + + # New run state exists and has v2 format + new_state = load_run_state(new_run_dir, required=True) + self.assertEqual(new_state.get("schema_version"), 2) + self.assertEqual(new_state.get("run_id"), run_id) + + # ----------------------------------------------------------------------- + # 21. idempotent migration + # ----------------------------------------------------------------------- + + def test_idempotent_migration(self) -> None: + """Running migrate twice: second call recognises already-migrated state.""" + repo = self.make_repo_with_config() + repo_info = resolve_repository(repo) + + state_home = self._tmpdir() + run_id = "20240101T000000Z-aabbccdd" + + legacy_state = { + "version": 1, + "run_id": run_id, + "status": "active", + "feature": "idempotent test", + "baseline": {"commit": "abc123", "branch": "main"}, + "verification": {"checks": [], "passed": False}, + } + + run_dir = run_dir_path(state_home, repo_info.id, run_id) + + # First migration + run_dir.mkdir(parents=True, exist_ok=True) + migrated = state_module.migrate_v1_to_v2(legacy_state, run_dir, repo_info) + migrated["migrated_from"] = "v1" + save_run_state(run_dir, migrated) + + first_mtime = (run_dir / "run-state.json").stat().st_mtime + + # Second migration attempt: detect already-migrated condition + existing = load_run_state(run_dir, required=True) + already_migrated = ( + existing.get("run_id") == run_id + and existing.get("migrated_from") is not None + ) + self.assertTrue( + already_migrated, "Second migration should detect already-migrated state" + ) + + # State file should not change if idempotency is respected + second_mtime = (run_dir / "run-state.json").stat().st_mtime + self.assertEqual( + first_mtime, second_mtime, "State file was rewritten on second migration" + ) + + # ----------------------------------------------------------------------- + # 22. stop_gate.py finds run state from nested cwd + # ----------------------------------------------------------------------- + + def test_stop_gate_resolution_from_nested_dir(self) -> None: + """stop_gate.py invoked with cwd=subdir payload finds and processes the run.""" + repo = self.make_repo_with_config() + subdir = repo / "src" / "lib" + subdir.mkdir(parents=True) + + state_home = self._tmpdir() + repo_info = resolve_repository(repo) + + run_id = new_run_id() + run_dir = run_dir_path(state_home, repo_info.id, run_id) + run_dir.mkdir(parents=True, exist_ok=True) + save_run_state(run_dir, _minimal_state(run_id=run_id, status="active")) + + payload = json.dumps({"cwd": str(subdir), "hook_event_name": "Stop"}) + env = {**os.environ, "CLAUDE_AUTONOMOUS_STATE_HOME": str(state_home)} + + result = subprocess.run( + ["python3", str(STOP_GATE)], + input=payload, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=env, + ) + + self.assertEqual(result.returncode, 0) + # Should block (decision=block) since no artifacts are complete + self.assertIn('"decision": "block"', result.stdout) + + # ----------------------------------------------------------------------- + # 23. stop_gate with multiple active runs returns 0 without blocking + # ----------------------------------------------------------------------- + + def test_stop_gate_multiple_active_runs(self) -> None: + """stop_gate returns 0 and no block JSON when multiple active runs exist (ambiguous).""" + repo = self.make_repo_with_config() + state_home = self._tmpdir() + repo_info = resolve_repository(repo) + + for i in range(2): + run_id = f"run-{i:08d}-000000" + run_dir = run_dir_path(state_home, repo_info.id, run_id) + run_dir.mkdir(parents=True, exist_ok=True) + save_run_state(run_dir, _minimal_state(run_id=run_id, status="active")) + + payload = json.dumps({"cwd": str(repo), "hook_event_name": "Stop"}) + env = {**os.environ, "CLAUDE_AUTONOMOUS_STATE_HOME": str(state_home)} + + result = subprocess.run( + ["python3", str(STOP_GATE)], + input=payload, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=env, + ) + + self.assertEqual(result.returncode, 0) + # Should NOT block when ambiguous + self.assertNotIn('"decision": "block"', result.stdout) + + # ----------------------------------------------------------------------- + # 24. read-only plugin installation + # ----------------------------------------------------------------------- + + @unittest.skipIf(sys.platform == "win32", "not applicable on Windows") + def test_read_only_plugin_installation(self) -> None: + """Controller works with read-only prompts dir when state is written externally.""" + repo = self.make_repo_with_config() + state_home = self._tmpdir() + repo_info = resolve_repository(repo) + + # Create a run via direct state writing (simulating controller init) + run_id = new_run_id() + run_dir = run_dir_path(state_home, repo_info.id, run_id) + run_dir.mkdir(parents=True, exist_ok=True) + + s = _minimal_state(run_id=run_id, status="active") + s["repository"] = { + "id": repo_info.id, + "canonical_root": str(repo_info.canonical_root), + "worktree_path": str(repo_info.worktree_path), + "display_name": repo_info.display_name, + "remote_display": repo_info.remote_display, + } + s["baseline"] = { + "commit": repo_info.head_commit, + "branch": repo_info.branch, + "worktree_path": str(repo_info.worktree_path), + } + save_run_state(run_dir, s) + + # Make the run_dir read-only to simulate a read-only plugin dir + # (state_home itself is writable, so we create a separate plugin-like dir) + plugin_dir = self._tmpdir() + os.chmod(str(plugin_dir), 0o555) + + try: + # Verify state is still accessible from state_home even if plugin_dir is read-only + loaded = load_run_state(run_dir, required=True) + self.assertEqual(loaded["run_id"], run_id) + self.assertEqual(loaded["status"], "active") + + # Verify find_active_runs works + active = find_active_runs(state_home, repo_info.id) + self.assertEqual(len(active), 1) + self.assertEqual(active[0].run_id, run_id) + finally: + os.chmod(str(plugin_dir), 0o755) + + # ----------------------------------------------------------------------- + # 25. paths with spaces and non-ASCII characters + # ----------------------------------------------------------------------- + + def test_paths_with_spaces_and_nonascii(self) -> None: + """All operations work when repo path contains spaces and non-ASCII (é).""" + base = Path(tempfile.mkdtemp()) + self._tmpdirs.append(base) + + repo_path = base / "my project é" + repo_path.mkdir(parents=True, exist_ok=True) + + repo = _make_repo_with_config(repo_path) + state_home = self._tmpdir() + + repo_info = resolve_repository(repo) + self.assertIsNotNone(repo_info) + self.assertIn("é", str(repo_info.canonical_root)) + + # Create a run in the state store + run_id = new_run_id() + run_dir = run_dir_path(state_home, repo_info.id, run_id) + run_dir.mkdir(parents=True, exist_ok=True) + + s = _minimal_state(run_id=run_id, status="active") + s["repository"] = {"id": repo_info.id} + s["baseline"] = { + "commit": repo_info.head_commit, + "branch": repo_info.branch, + "worktree_path": str(repo_info.worktree_path), + } + save_run_state(run_dir, s) + + # Load and verify + loaded = load_run_state(run_dir, required=True) + self.assertEqual(loaded["run_id"], run_id) + + # Artifact paths with spaces + artifact_file = run_dir / "output file é.txt" + artifact_file.write_text("content", encoding="utf-8") + resolved = resolve_artifact_path("output file é.txt", run_dir) + self.assertEqual(resolved, artifact_file.resolve()) + + +if __name__ == "__main__": + unittest.main()