diff --git a/.ai/allowlist.json b/.ai/allowlist.json index 7e1b046..e0f9d10 100644 --- a/.ai/allowlist.json +++ b/.ai/allowlist.json @@ -28,6 +28,11 @@ "code-reviewer-b": "gpt-5.4", "fixer": "gpt-5.4" }, + "quest_startup": { + "branch_mode": "branch", + "branch_prefix": "quest/", + "worktree_root": ".worktrees/quest" + }, "review_mode": "full", "fast_review_thresholds": { "max_files": 5, diff --git a/.ai/schemas/allowlist.schema.json b/.ai/schemas/allowlist.schema.json index 5ae4a44..a0d9637 100644 --- a/.ai/schemas/allowlist.schema.json +++ b/.ai/schemas/allowlist.schema.json @@ -37,6 +37,17 @@ }, "additionalProperties": { "type": "string" } }, + "quest_startup": { + "type": "object", + "properties": { + "branch_mode": { + "type": "string", + "enum": ["branch", "worktree", "none"] + }, + "branch_prefix": { "type": "string" }, + "worktree_root": { "type": "string" } + } + }, "review_mode": { "type": "string", "enum": ["auto", "fast", "full"] }, "fast_review_thresholds": { "type": "object", diff --git a/.gitignore b/.gitignore index 9e37cae..a7cdd9c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Quest ephemeral state .quest/ +.worktrees/ # Workspace scratch .ws/ diff --git a/.quest-checksums b/.quest-checksums index 8830e6b..878dcf9 100644 --- a/.quest-checksums +++ b/.quest-checksums @@ -6,7 +6,7 @@ 8501266cd7b1f0004361aec200d85f02f35b1de2bc57648fe03625867b1c46e3 .agents/skills/README.md c5cbf7daca607003d8904c1d97fc45fc4a0f3eb9f089c43457d33a1d9aac3f98 .ai/quest.md a17dbe2d7bfb6cdddde81b7b4fdec27083b9ee81db32c45a9644d4f9ebd2ca15 .ai/roles/quest_agent.md -fe83cda3c43c2562ed5a5ebc1bf7c227caca7f041987e5b1d3d5eb4cc0f9943f .ai/schemas/allowlist.schema.json +cbcc84c5e116dc0f9bc6764d427ee291e399e4b9c6ec284d5c9479728e31b75b .ai/schemas/allowlist.schema.json 49eb6396d1c1cbdf232ed1a14aedbb7ef2ee99534c52dbc6ca4acd6e63da3770 .ai/schemas/handoff.schema.json ba648315fd083c7cfec66b66fc2fe97fbe53dbe95904789ab371ea667159a89b .ai/templates/plan.md 0c2681a3d0ae6ae88ae3361dd6e8ad0ab8ea3360f6984d5a982790660b8f13ce .ai/templates/pr_description.md @@ -52,8 +52,8 @@ c1ece37ca71c80ba1880631ac2762a79999b3812be243dd9a249a10065e3aab7 .skills/quest/ e1cef5bb261421226a8007f88aab5cac0295b16f4044735856305c155a952376 .skills/quest/agents/README.md 5e838b78b482e1c4933593dd57d7702ab03f5d3f2d10a062db5489ef184c9c88 .skills/quest/delegation/questioner.md c1138ae5c661af4333e465910dd864aa7d1c3a253f8696bfbcd2e679f5cf5801 .skills/quest/delegation/router.md -5d32a6474a635fc0e9ba14dd92ca3852c0b6fd050494b624546d27c1675fedf0 .skills/quest/delegation/workflow.md -75a7d855a0d5cad4f2ef0f0b7675ee6de550b8b53104e3968db7e66d477fef83 .skills/quest/SKILL.md +a84c771b6837f2112652c4754aa410e647b1fcc4e33875c1a93539c06d2bd06a .skills/quest/delegation/workflow.md +75a1c7edec4aaaf443c2e8c75435a1ce65481d9035fbdc2a162c6fac6775b63c .skills/quest/SKILL.md 05689231eed6eae8df6fc429b909690218806222af2181c7689ee7bcd6184fbd .skills/README.md 70a8d9bb6e2de003e2a47e51028ec7d3c1ffb3fa76a36aa1fb5cff7835c47118 .skills/SKILLS.md 63cd28a52d17218959af1e81707aa289b58b1ba8bfc09b0f009aa24cb33d23df scripts/quest_celebrate/__init__.py @@ -66,6 +66,7 @@ c594d592326fbe99f38e9dbd784b1926f7067c00784cdaede50f57f6c8dbb1e5 scripts/quest_ 2882f437fcb5cae46d598360d78b5bb096299f0aab66fc75d7ce514feada834e scripts/quest_celebrate/terminal.py f662acec6363dd22b02f504e349e0d53c57293ffb422f371540b3e5b7620f9a7 scripts/quest_installer.sh dff199c72aea3b7157492c60eaced28848b012bd2d21ff3222f935a4dfaf8b85 scripts/validate-handoff-contracts.sh -c609bc6014f0d1980a8a203dc05b6ab2db4f7a3e5104d2c2a0191da78229a697 scripts/validate-manifest.sh -85274507a2928a68c2f2a14c4c1a6ff7f2b17b5f9910e620d753113b73c855ab scripts/validate-quest-config.sh +c73acc52afed8510a3c5d1d4338b0c5c3315b335395c727a721404ce22a404cb scripts/validate-manifest.sh +d2b32057859a23cfb90a4bf6e801f6d95354c09d8c1fe8235666bebfab9fcc8c scripts/validate-quest-config.sh +96190d1c933df39145c6246abacc328619fb99e699d54f97288e191703174d0b scripts/quest_startup_branch.py fbd4a93c3cacfb2feae00fc7a8d1e4bacb05992eb81545626bbf7a4c8057850c scripts/validate-quest-state.sh diff --git a/.quest-manifest b/.quest-manifest index ab09766..9fb33c1 100644 --- a/.quest-manifest +++ b/.quest-manifest @@ -71,6 +71,7 @@ scripts/validate-handoff-contracts.sh scripts/claude_cli_bridge.py scripts/quest_claude_probe.py scripts/quest_claude_runner.py +scripts/quest_startup_branch.py scripts/quest_checks/__init__.py scripts/quest_checks/cli.py scripts/quest_runtime/__init__.py diff --git a/.skills/quest/SKILL.md b/.skills/quest/SKILL.md index 37b1fdd..f316dfc 100644 --- a/.skills/quest/SKILL.md +++ b/.skills/quest/SKILL.md @@ -160,14 +160,27 @@ Before creating the quest folder, present the routing classification to the user ### Quest Folder Creation 1. Generate a slug (lowercase, hyphenated, 2-5 words) and inform the user -2. Create `.quest/_YYYY-MM-DD__HHMM/` with subfolders: +2. Run quest startup branch preparation before creating the quest folder: + - Execute: `python3 scripts/quest_startup_branch.py --slug ` + - Parse the JSON result + - If `status` is `"blocked"`: show the returned `message`, do NOT create the quest folder yet, and stop for the user to resolve the git state or config + - If `status` is `"created"` or `"skipped"`: continue and surface the returned `message` to the user + - Record these fields for `state.json` initialization: + - `branch` + - `branch_mode` + - `worktree_path` (if present) + - Behavior rules: + - Default mode comes from `.ai/allowlist.json` → `quest_startup.branch_mode` and defaults to `"branch"` + - When starting on the repo default branch, Quest creates either a feature branch or a worktree-backed branch from that default branch + - When already on a non-default branch, Quest does not create another branch/worktree and records the existing branch for the run +3. Create `.quest/_YYYY-MM-DD__HHMM/` with subfolders: `phase_01_plan/`, `phase_02_implementation/`, `phase_03_review/`, `logs/` -3. Write quest brief to `.quest//quest_brief.md` including: +4. Write quest brief to `.quest//quest_brief.md` including: - User input (original prompt) - Questioner summary (if questioning occurred) - **Router classification JSON** (the final routing decision that sent the quest to workflow). This is the classification produced by the most recent router evaluation — if the router ran twice (once before questioning, once after), record the second (final) classification. -4. Copy `.ai/allowlist.json` to `.quest//logs/allowlist_snapshot.json` -5. Initialize `state.json`: +5. Copy `.ai/allowlist.json` to `.quest//logs/allowlist_snapshot.json` +6. Initialize `state.json`: ```json { "quest_id": "", @@ -175,6 +188,9 @@ Before creating the quest folder, present the routing classification to the user "phase": "plan", "status": "pending", "quest_mode": "workflow", + "branch": "quest/ or current branch", + "branch_mode": "branch | worktree | none", + "worktree_path": "/absolute/path/to/worktree (worktree mode only)", "plan_iteration": 0, "fix_iteration": 0, "created_at": "", @@ -182,3 +198,4 @@ Before creating the quest folder, present the routing classification to the user } ``` Set `quest_mode` to the user's final selection: `"workflow"` (default) or `"solo"`. This field is read by `workflow.md` to determine agent dispatch and by `validate-quest-state.sh` for artifact checks. + `branch_mode` records the actual startup mode used for this quest run after no-op handling. If Quest starts on an existing feature branch, set `branch_mode` to `"none"` and record that branch in `branch`. diff --git a/.skills/quest/delegation/workflow.md b/.skills/quest/delegation/workflow.md index 7d643f7..216825c 100644 --- a/.skills/quest/delegation/workflow.md +++ b/.skills/quest/delegation/workflow.md @@ -258,7 +258,16 @@ This workflow expects to be invoked with a quest brief already prepared. 1. Verify `.quest//quest_brief.md` exists 2. If it does not exist, STOP and report error: "Quest brief not found. The routing layer should have created it before invoking workflow." -3. If it exists, proceed to Step 2 +3. Read `.quest//state.json` and determine the source workspace root for code-bearing phases: + - If `worktree_path` exists and the directory is present, set `source_workspace_root = worktree_path` + - Otherwise set `source_workspace_root = ` + - All source edits plus `git status`, `git diff`, and `git log` commands in Steps 4-7 MUST run from `source_workspace_root` + - Quest artifacts always remain under `.quest//` in the original repo root; when `source_workspace_root != `, prefer absolute quest artifact paths when invoking builder, reviewers, and fixer +4. Verify branch context: + - If `branch` exists in state.json and `branch_mode == "branch"`, compare it to `git branch --show-current` in `source_workspace_root` + - If `branch_mode == "worktree"`, verify the directory at `worktree_path` still exists + - If verification fails, warn the user but do not block automatically; they may have switched context intentionally +5. If the checks pass, proceed to Step 2 ### Step 2: Route Intent @@ -598,6 +607,7 @@ After plan approval, present the plan interactively before proceeding to build. - Read `models.builder` from allowlist. - If builder model is Codex, invoke via `mcp__codex__codex` with `sandbox_permissions: "workspace-write"`. - If builder model is Claude, invoke through Claude runtime (native `Task(...)` when available, bridge in Codex-led sessions). + - Run the builder from `source_workspace_root`. If this quest uses a separate worktree, source changes happen there while `.quest//...` artifacts still point at the original repo root. - **Artifact preparation** (per Handoff File Polling §5): Resolve and prepare `pr_description.md`, `builder_feedback_discussion.md`, and `handoff.json` in `.quest//phase_02_implementation/`. - Prompt: Reference file paths only, do not embed content: - Approved plan: `.quest//phase_01_plan/plan.md` @@ -642,7 +652,7 @@ After plan approval, present the plan interactively before proceeding to build. 3. **Build a change summary for Codex:** - - Compute from git (the canonical source for what changed): + - Compute from git in `source_workspace_root` (the canonical source for what changed): - File list: `git diff --name-only` - Diff stats: `git diff --stat` - LOC totals: `git diff --numstat` and sum added + deleted. @@ -842,6 +852,7 @@ After plan approval, present the plan interactively before proceeding to build. - Read `models.fixer` from allowlist. - If fixer model is Codex, invoke via `mcp__codex__codex` with `sandbox_permissions: "workspace-write"`. - If fixer model is Claude, invoke through Claude runtime (native `Task(...)` when available, bridge in Codex-led sessions). + - Run the fixer from `source_workspace_root`. If this quest uses a separate worktree, source fixes happen there while `.quest//...` artifacts remain in the original repo root. - Prompt: Reference file paths only, do not embed content: - Code review A: `.quest//phase_03_review/review_code-reviewer-a.md` - Code review B: `.quest//phase_03_review/review_code-reviewer-b.md` @@ -911,11 +922,12 @@ After plan approval, present the plan interactively before proceeding to build. 4. **Show summary** (before archiving — quest directory still exists): - Quest ID - - Files changed (from `git diff --name-only` and `state.json` artifact paths) + - Files changed (from `git diff --name-only` in `source_workspace_root` and `state.json` artifact paths) - Total iterations (plan + fix, from `state.json`) - Parallel execution stats (read from `.quest//logs/parallelism.log` if it exists — show each line) - Location of artifacts (will be archived to `.quest/archive//`) - Location of journal entry (will be created next) + - If `branch_mode == "worktree"` and `worktree_path` exists, remind the user that the implementation branch lives in that worktree and cleanup is manual via `git worktree remove ` 6. **Context health report:** If `.quest//logs/context_health.log` exists, display it in full: @@ -1096,6 +1108,9 @@ If a Claude role returns `STATUS: needs_human`: "slug": "feature-x", "phase": "plan | plan_reviewed | presenting | presentation_complete | building | reviewing | fixing | complete", "status": "pending | in_progress | complete | blocked", + "branch": "quest/feature-x", + "branch_mode": "branch | worktree | none", + "worktree_path": "/absolute/path/to/worktree or null", "plan_iteration": 2, "fix_iteration": 0, "last_role": "arbiter_agent", diff --git a/scripts/README.md b/scripts/README.md index 48bce91..8e959ca 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -13,6 +13,7 @@ Build and utility scripts for the Quest repository. | `quest_preflight.sh` | Checks second-model readiness before quest routing. Codex-led Claude probes now retain a recent successful host probe under `.quest/cache/` so later quest starts can reuse it. | | `quest_claude_probe.py` | Probes the Claude bridge by requiring a real artifact write and `handoff.json` under the quest logs directory. | | `quest_state.py` | Updates `.quest//state.json` consistently and refreshes `updated_at`. | +| `quest_startup_branch.py` | Creates the startup branch or worktree for a new quest from `.ai/allowlist.json` and returns machine-readable branch context JSON. | | `quest_claude_runner.py` | Runs Claude-designated Quest roles through the additive Codex-host Claude adapter, using `scripts/claude_cli_bridge.py` as transport plus `bypassPermissions`, explicit `--add-dir` access, handoff polling, and `context_health.log` updates. Native Claude-led Quest behavior stays on `Task(...)`. | | `quest_installer.sh` | Installs and updates Quest in any repository. Handles fresh installs, updates, and checksum-based change detection. | | `validate-quest-config.sh` | Validates quest configuration files (allowlist JSON schema, role markdown completeness). Used by pre-commit hooks and CI. | @@ -28,6 +29,9 @@ python3 scripts/quest_dashboard/build_quest_dashboard.py # Update quest state without hand-editing JSON python3 scripts/quest_state.py --quest-dir .quest/ --phase plan_reviewed --status complete +# Prepare startup branch/worktree context for a new quest +python3 scripts/quest_startup_branch.py --slug feature-x + # Run a Claude-designated role via the local bridge with file polling python3 scripts/quest_claude_runner.py --quest-dir .quest/ --phase plan_review --agent plan-reviewer-a --iter 1 --prompt-file .quest//phase_01_plan/reviewer_a_prompt.txt --handoff-file .quest//phase_01_plan/handoff_plan-reviewer-a.json diff --git a/scripts/quest_startup_branch.py b/scripts/quest_startup_branch.py new file mode 100644 index 0000000..f19a040 --- /dev/null +++ b/scripts/quest_startup_branch.py @@ -0,0 +1,312 @@ +#!/usr/bin/env python3 +"""Prepare branch/worktree context for a new quest run.""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path +from typing import Any + +DEFAULT_BRANCH_MODE = "branch" +DEFAULT_BRANCH_PREFIX = "quest/" +DEFAULT_WORKTREE_ROOT = ".worktrees/quest" +VALID_BRANCH_MODES = {"branch", "worktree", "none"} + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Create quest startup branch or worktree context." + ) + parser.add_argument("--slug", required=True) + parser.add_argument("--repo-root", default=".") + parser.add_argument("--allowlist", default=".ai/allowlist.json") + return parser.parse_args() + + +def run_git(repo_root: Path, *args: str, check: bool = True) -> str: + result = subprocess.run( + ["git", *args], + cwd=repo_root, + capture_output=True, + text=True, + ) + if check and result.returncode != 0: + stderr = result.stderr.strip() or result.stdout.strip() + raise RuntimeError(f"git {' '.join(args)} failed: {stderr}") + return result.stdout.strip() + + +def git_success(repo_root: Path, *args: str) -> bool: + result = subprocess.run( + ["git", *args], + cwd=repo_root, + capture_output=True, + text=True, + ) + return result.returncode == 0 + + +def load_allowlist(path: Path) -> dict[str, Any]: + if not path.is_file(): + return {} + return json.loads(path.read_text(encoding="utf-8")) + + +def detect_default_branch(repo_root: Path, current_branch: str) -> str: + remote_head = run_git( + repo_root, + "symbolic-ref", + "refs/remotes/origin/HEAD", + check=False, + ) + if remote_head.startswith("refs/remotes/origin/"): + return remote_head.rsplit("/", 1)[-1] + + for candidate in ("main", "master"): + if git_success(repo_root, "show-ref", "--verify", "--quiet", f"refs/heads/{candidate}"): + return candidate + + if current_branch in {"main", "master"}: + return current_branch + + return "main" + + +def branch_exists(repo_root: Path, branch_name: str) -> bool: + return git_success(repo_root, "show-ref", "--verify", "--quiet", f"refs/heads/{branch_name}") + + +def repo_dirty(repo_root: Path) -> bool: + return bool( + run_git( + repo_root, + "status", + "--porcelain", + "--untracked-files=no", + check=False, + ).strip() + ) + + +def build_result( + *, + status: str, + branch: str | None, + branch_mode: str, + requested_branch_mode: str, + current_branch: str | None, + default_branch: str | None, + branch_created: bool, + worktree_path: Path | None, + message: str, +) -> dict[str, Any]: + return { + "status": status, + "branch": branch, + "branch_mode": branch_mode, + "requested_branch_mode": requested_branch_mode, + "current_branch": current_branch, + "default_branch": default_branch, + "branch_created": branch_created, + "worktree_path": str(worktree_path) if worktree_path else None, + "message": message, + } + + +def main() -> int: + args = parse_args() + repo_root = Path(args.repo_root).resolve() + allowlist_path = Path(args.allowlist) + if not allowlist_path.is_absolute(): + allowlist_path = (repo_root / allowlist_path).resolve() + requested_branch_mode = DEFAULT_BRANCH_MODE + + try: + allowlist = load_allowlist(allowlist_path) + startup = allowlist.get("quest_startup") or {} + requested_branch_mode = startup.get("branch_mode", DEFAULT_BRANCH_MODE) + branch_prefix = startup.get("branch_prefix", DEFAULT_BRANCH_PREFIX) + worktree_root = startup.get("worktree_root", DEFAULT_WORKTREE_ROOT) + + if requested_branch_mode not in VALID_BRANCH_MODES: + payload = build_result( + status="blocked", + branch=None, + branch_mode="none", + requested_branch_mode=str(requested_branch_mode), + current_branch=None, + default_branch=None, + branch_created=False, + worktree_path=None, + message=( + "Invalid quest_startup.branch_mode in allowlist.json. " + "Expected one of: branch, worktree, none." + ), + ) + print(json.dumps(payload, indent=2)) + return 0 + + current_branch = run_git(repo_root, "branch", "--show-current", check=False) + if not current_branch: + payload = build_result( + status="blocked", + branch=None, + branch_mode="none", + requested_branch_mode=requested_branch_mode, + current_branch=None, + default_branch=None, + branch_created=False, + worktree_path=None, + message="Detached HEAD detected. Quest startup branch setup requires a named branch checkout.", + ) + print(json.dumps(payload, indent=2)) + return 0 + + default_branch = detect_default_branch(repo_root, current_branch) + branch_name = f"{branch_prefix}{args.slug}" + + if current_branch != default_branch: + payload = build_result( + status="skipped", + branch=current_branch, + branch_mode="none", + requested_branch_mode=requested_branch_mode, + current_branch=current_branch, + default_branch=default_branch, + branch_created=False, + worktree_path=None, + message=f"Already on branch {current_branch} — skipping quest startup branch creation.", + ) + print(json.dumps(payload, indent=2)) + return 0 + + if requested_branch_mode == "none": + payload = build_result( + status="skipped", + branch=current_branch, + branch_mode="none", + requested_branch_mode=requested_branch_mode, + current_branch=current_branch, + default_branch=default_branch, + branch_created=False, + worktree_path=None, + message="Quest startup branch mode disabled — staying on the current branch.", + ) + print(json.dumps(payload, indent=2)) + return 0 + + if branch_exists(repo_root, branch_name): + payload = build_result( + status="blocked", + branch=branch_name, + branch_mode="none", + requested_branch_mode=requested_branch_mode, + current_branch=current_branch, + default_branch=default_branch, + branch_created=False, + worktree_path=None, + message=( + f"Branch {branch_name} already exists. " + "Choose a different quest slug or clean up the existing branch first." + ), + ) + print(json.dumps(payload, indent=2)) + return 0 + + if requested_branch_mode == "branch": + if repo_dirty(repo_root): + payload = build_result( + status="blocked", + branch=current_branch, + branch_mode="none", + requested_branch_mode=requested_branch_mode, + current_branch=current_branch, + default_branch=default_branch, + branch_created=False, + worktree_path=None, + message=( + "Working tree is dirty on the default branch. " + "Commit or stash changes before Quest creates a startup branch." + ), + ) + print(json.dumps(payload, indent=2)) + return 0 + + run_git(repo_root, "checkout", "-b", branch_name) + payload = build_result( + status="created", + branch=branch_name, + branch_mode="branch", + requested_branch_mode=requested_branch_mode, + current_branch=current_branch, + default_branch=default_branch, + branch_created=True, + worktree_path=None, + message=f"Created and checked out quest branch {branch_name}.", + ) + print(json.dumps(payload, indent=2)) + return 0 + + worktree_path = (repo_root / worktree_root / args.slug).resolve() + if worktree_path.exists(): + payload = build_result( + status="blocked", + branch=branch_name, + branch_mode="none", + requested_branch_mode=requested_branch_mode, + current_branch=current_branch, + default_branch=default_branch, + branch_created=False, + worktree_path=worktree_path, + message=( + f"Worktree path already exists: {worktree_path}. " + "Remove it or choose a different quest slug first." + ), + ) + print(json.dumps(payload, indent=2)) + return 0 + + worktree_path.parent.mkdir(parents=True, exist_ok=True) + run_git( + repo_root, + "worktree", + "add", + str(worktree_path), + "-b", + branch_name, + default_branch, + ) + payload = build_result( + status="created", + branch=branch_name, + branch_mode="worktree", + requested_branch_mode=requested_branch_mode, + current_branch=current_branch, + default_branch=default_branch, + branch_created=True, + worktree_path=worktree_path, + message=f"Created quest worktree {worktree_path} on branch {branch_name}.", + ) + print(json.dumps(payload, indent=2)) + return 0 + except Exception as exc: + payload = build_result( + status="blocked", + branch=None, + branch_mode="none", + requested_branch_mode=requested_branch_mode, + current_branch=None, + default_branch=None, + branch_created=False, + worktree_path=None, + message=f"Quest startup branch preparation failed: {exc}", + ) + print(json.dumps(payload, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validate-manifest.sh b/scripts/validate-manifest.sh index 7b8e1e6..9be4039 100755 --- a/scripts/validate-manifest.sh +++ b/scripts/validate-manifest.sh @@ -51,6 +51,7 @@ EXPECTED_PATTERNS=( "scripts/claude_cli_bridge.py" "scripts/quest_claude_probe.py" "scripts/quest_claude_runner.py" + "scripts/quest_startup_branch.py" "scripts/quest_checks/*.py" "scripts/quest_runtime/*.py" "scripts/validate-quest-config.sh" diff --git a/scripts/validate-quest-config.sh b/scripts/validate-quest-config.sh index ea14e33..00cda5e 100755 --- a/scripts/validate-quest-config.sh +++ b/scripts/validate-quest-config.sh @@ -22,6 +22,7 @@ Options: When run without options, validates: - .quest/ is in .gitignore + - .worktrees/ is in .gitignore - .ai/allowlist.json is valid JSON - .ai/allowlist.json matches schema (if ajv installed) - .skills/quest/agents/*.md and .ai/roles/quest_agent.md have required sections @@ -101,7 +102,7 @@ fi pass() { echo -e "${GREEN}[PASS]${NC} $1"; } fail() { echo -e "${RED}[FAIL]${NC} $1"; ERRORS=$((ERRORS + 1)); } -# Check .quest/ is in .gitignore +# Check .quest/ and .worktrees/ are in .gitignore check_gitignore() { if grep -q "^\.quest/" "$REPO_ROOT/.gitignore" 2>/dev/null || \ grep -q "^\.quest$" "$REPO_ROOT/.gitignore" 2>/dev/null; then @@ -109,6 +110,13 @@ check_gitignore() { else fail ".quest/ is NOT in .gitignore - add '.quest/' to prevent committing ephemeral state" fi + + if grep -q "^\.worktrees/" "$REPO_ROOT/.gitignore" 2>/dev/null || \ + grep -q "^\.worktrees$" "$REPO_ROOT/.gitignore" 2>/dev/null; then + pass ".worktrees/ is in .gitignore" + else + fail ".worktrees/ is NOT in .gitignore - add '.worktrees/' to prevent committing worktree checkouts" + fi } # Validate JSON syntax (pure bash fallback, prefers jq) diff --git a/tests/test-quest-runtime.sh b/tests/test-quest-runtime.sh index 594c75c..1c728da 100644 --- a/tests/test-quest-runtime.sh +++ b/tests/test-quest-runtime.sh @@ -4,6 +4,7 @@ REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" STATE_SCRIPT="$REPO_ROOT/scripts/quest_state.py" +STARTUP_BRANCH_SCRIPT="$REPO_ROOT/scripts/quest_startup_branch.py" CLAUDE_RUNNER="$REPO_ROOT/scripts/quest_claude_runner.py" CLAUDE_PROBE="$REPO_ROOT/scripts/quest_claude_probe.py" @@ -23,6 +24,34 @@ run_test() { fi } +init_git_repo() { + local dir="$1" + git init -b main "$dir" >/dev/null 2>&1 || { + git init "$dir" >/dev/null 2>&1 || return 1 + git -C "$dir" checkout -b main >/dev/null 2>&1 || return 1 + } + git -C "$dir" config user.name "Quest Test" >/dev/null 2>&1 || return 1 + git -C "$dir" config user.email "quest-test@example.com" >/dev/null 2>&1 || return 1 + printf 'seed\n' > "$dir/README.md" + git -C "$dir" add README.md >/dev/null 2>&1 || return 1 + git -C "$dir" commit -m "init" >/dev/null 2>&1 || return 1 +} + +write_allowlist() { + local dir="$1" + local branch_mode="$2" + mkdir -p "$dir/.ai" + cat > "$dir/.ai/allowlist.json" < "$tmpdir/.ai/allowlist.json" + + local output rc branch status branch_mode requested_mode + output=$(python3 "$STARTUP_BRANCH_SCRIPT" --repo-root "$tmpdir" --allowlist "$tmpdir/.ai/allowlist.json" --slug startup-branch 2>&1) + rc=$? + branch=$(git -C "$tmpdir" branch --show-current) + status=$(printf '%s' "$output" | jq -r '.status') + branch_mode=$(printf '%s' "$output" | jq -r '.branch_mode') + requested_mode=$(printf '%s' "$output" | jq -r '.requested_branch_mode') + rm -rf "$tmpdir" + + [ "$rc" -eq 0 ] && + [ "$branch" = "quest/startup-branch" ] && + [ "$status" = "created" ] && + [ "$branch_mode" = "branch" ] && + [ "$requested_mode" = "branch" ] +} + +test_quest_startup_branch_skips_when_already_on_feature_branch() { + local tmpdir + tmpdir=$(mktemp -d) + init_git_repo "$tmpdir" || return 1 + write_allowlist "$tmpdir" "branch" + git -C "$tmpdir" checkout -b feature/existing >/dev/null 2>&1 || return 1 + + local output rc branch status branch_mode + output=$(python3 "$STARTUP_BRANCH_SCRIPT" --repo-root "$tmpdir" --allowlist "$tmpdir/.ai/allowlist.json" --slug startup-branch 2>&1) + rc=$? + branch=$(git -C "$tmpdir" branch --show-current) + status=$(printf '%s' "$output" | jq -r '.status') + branch_mode=$(printf '%s' "$output" | jq -r '.branch_mode') + rm -rf "$tmpdir" + + [ "$rc" -eq 0 ] && + [ "$branch" = "feature/existing" ] && + [ "$status" = "skipped" ] && + [ "$branch_mode" = "none" ] +} + +test_quest_startup_branch_blocks_dirty_default_branch_checkout() { + local tmpdir + tmpdir=$(mktemp -d) + init_git_repo "$tmpdir" || return 1 + write_allowlist "$tmpdir" "branch" + printf 'dirty\n' >> "$tmpdir/README.md" + + local output rc branch status message + output=$(python3 "$STARTUP_BRANCH_SCRIPT" --repo-root "$tmpdir" --allowlist "$tmpdir/.ai/allowlist.json" --slug startup-branch 2>&1) + rc=$? + branch=$(git -C "$tmpdir" branch --show-current) + status=$(printf '%s' "$output" | jq -r '.status') + message=$(printf '%s' "$output" | jq -r '.message') + rm -rf "$tmpdir" + + [ "$rc" -eq 0 ] && + [ "$branch" = "main" ] && + [ "$status" = "blocked" ] && + echo "$message" | grep -qi "dirty" +} + +test_quest_startup_branch_creates_worktree() { + local tmpdir + tmpdir=$(mktemp -d) + init_git_repo "$tmpdir" || return 1 + write_allowlist "$tmpdir" "worktree" + + local output rc main_branch status branch_mode worktree_path worktree_branch + output=$(python3 "$STARTUP_BRANCH_SCRIPT" --repo-root "$tmpdir" --allowlist "$tmpdir/.ai/allowlist.json" --slug startup-worktree 2>&1) + rc=$? + main_branch=$(git -C "$tmpdir" branch --show-current) + status=$(printf '%s' "$output" | jq -r '.status') + branch_mode=$(printf '%s' "$output" | jq -r '.branch_mode') + worktree_path=$(printf '%s' "$output" | jq -r '.worktree_path') + worktree_branch=$(git -C "$worktree_path" branch --show-current 2>/dev/null) + git -C "$tmpdir" worktree remove "$worktree_path" --force >/dev/null 2>&1 || true + rm -rf "$tmpdir" + + [ "$rc" -eq 0 ] && + [ "$main_branch" = "main" ] && + [ "$status" = "created" ] && + [ "$branch_mode" = "worktree" ] && + [ "$worktree_branch" = "quest/startup-worktree" ] +} + +test_quest_startup_branch_none_mode_leaves_main_checked_out() { + local tmpdir + tmpdir=$(mktemp -d) + init_git_repo "$tmpdir" || return 1 + write_allowlist "$tmpdir" "none" + + local output rc branch status branch_mode requested_mode + output=$(python3 "$STARTUP_BRANCH_SCRIPT" --repo-root "$tmpdir" --allowlist "$tmpdir/.ai/allowlist.json" --slug startup-none 2>&1) + rc=$? + branch=$(git -C "$tmpdir" branch --show-current) + status=$(printf '%s' "$output" | jq -r '.status') + branch_mode=$(printf '%s' "$output" | jq -r '.branch_mode') + requested_mode=$(printf '%s' "$output" | jq -r '.requested_branch_mode') + rm -rf "$tmpdir" + + [ "$rc" -eq 0 ] && + [ "$branch" = "main" ] && + [ "$status" = "skipped" ] && + [ "$branch_mode" = "none" ] && + [ "$requested_mode" = "none" ] +} + +test_quest_startup_branch_invalid_allowlist_returns_blocked_contract() { + local tmpdir + tmpdir=$(mktemp -d) + init_git_repo "$tmpdir" || return 1 + mkdir -p "$tmpdir/.ai" + printf '{ invalid json\n' > "$tmpdir/.ai/allowlist.json" + + local output rc status branch_mode requested_mode message + output=$(python3 "$STARTUP_BRANCH_SCRIPT" --repo-root "$tmpdir" --allowlist "$tmpdir/.ai/allowlist.json" --slug startup-bad 2>&1) + rc=$? + status=$(printf '%s' "$output" | jq -r '.status') + branch_mode=$(printf '%s' "$output" | jq -r '.branch_mode') + requested_mode=$(printf '%s' "$output" | jq -r '.requested_branch_mode') + message=$(printf '%s' "$output" | jq -r '.message') + rm -rf "$tmpdir" + + [ "$rc" -eq 0 ] && + [ "$status" = "blocked" ] && + [ "$branch_mode" = "none" ] && + [ "$requested_mode" = "branch" ] && + echo "$message" | grep -qi "failed" +} + run_test test_quest_state_updates_phase_and_timestamp run_test test_quest_state_transition_valid run_test test_quest_state_transition_invalid_leaves_state_unchanged run_test test_quest_state_transition_rejects_plan_reviewed_to_building +run_test test_quest_startup_branch_defaults_to_branch_checkout +run_test test_quest_startup_branch_skips_when_already_on_feature_branch +run_test test_quest_startup_branch_blocks_dirty_default_branch_checkout +run_test test_quest_startup_branch_creates_worktree +run_test test_quest_startup_branch_none_mode_leaves_main_checked_out +run_test test_quest_startup_branch_invalid_allowlist_returns_blocked_contract run_test test_quest_claude_runner_polls_handoff_and_logs_runtime run_test test_quest_claude_probe_requires_real_artifacts