diff --git a/src/gemstack/ai/bootstrap.py b/src/gemstack/ai/bootstrap.py index aad9b5a..85bfee4 100644 --- a/src/gemstack/ai/bootstrap.py +++ b/src/gemstack/ai/bootstrap.py @@ -402,12 +402,20 @@ def _parse_response(self, response: object) -> BootstrapResult: for marker, attr in file_markers.items(): pattern = ( - rf"(?:^|\n)#\s*(?:\.agent/)?\s*{marker}\.md\s*\n" - rf"(.*?)(?=\n#\s*(?:\.agent/)?\s*\w+\.md|\Z)" + rf"(?:^|\n)#{{1,6}}\s*(?:\.agent/)?\s*{marker}\.md\s*\n" + rf"(.*?)(?=\n#{{1,6}}\s*(?:\.agent/)?\s*\w+\.md|\Z)" ) match = re.search(pattern, text, re.DOTALL | re.IGNORECASE) if match: - setattr(result, attr, match.group(1).strip()) + content = match.group(1).strip() + # Clean up markdown code block wrappers if present + if content.startswith("```markdown"): + content = content[len("```markdown"):].strip() + elif content.startswith("```"): + content = content[3:].strip() + if content.endswith("```"): + content = content[:-3].strip() + setattr(result, attr, content) # If no sections were parsed, treat the whole response as architecture if not any([result.architecture, result.style, result.testing]): diff --git a/src/gemstack/data/context/context_prompt.md b/src/gemstack/data/context/context_prompt.md index e6c4a67..2a2d101 100644 --- a/src/gemstack/data/context/context_prompt.md +++ b/src/gemstack/data/context/context_prompt.md @@ -2,7 +2,7 @@ You are an expert Software Architect and Tech Lead. Your objective is to bootstrap the `.agent/` context directory for this repository. -The `.agent/` directory acts as the permanent memory and rules engine for all future AI agents operating in this codebase. By establishing strict architectural boundaries, style rules, and testing strategies, you ensure that agents do not drift, hallucinate styles, or break conventions as the project scales. +The `.agent/` directory acts as the permanent memory and rules engine for all future AI agents operating in this codebase. By establishing strict architectural boundaries, style rules, and testing strategies, you ensure that agents do not drift, hallucinate styles, or break conventions as the project scales. This directory also orchestrates the 5-Step Autonomous Workflow (`/step1-spec` → `/step2-trap` → `/step3-build` → `/step4-audit` → `/step5-ship`). Your output must perfectly support these phases. ## Your Task @@ -142,7 +142,7 @@ Before writing any files, you MUST use your search and read tools to investigate Now, read the template files currently located in the `.agent/` directory. Use their exact structure and headers as your baseline, but **replace the placeholder text with the concrete facts you discovered during your analysis (Phases 0 through 1).** Write the finalized content back to the same files, overwriting the templates. ### 1. `.agent/ARCHITECTURE.md` -- **Goal**: The definitive anchor for system design. +- **Goal**: The definitive anchor for system design and the target for strict execution contracts locked in during `/step1-spec`. - **Instructions**: At the top of the file, set the `## 0. Project Topology` section with the detected topology attributes. Detail the exact tech stack with pinned versions. Map out the data flow (e.g., "Client Components → React Query → Express → Drizzle → SQLite" or "CLI → asyncio.TaskGroup → bounded queues → GPU worker" or "HTTP → go-chi middleware → handler → service → sqlc → PostgreSQL"). Document all core database entities and their relational rules (cascading deletes, virtual tables, hypertables, manual cleanup requirements). Define API contracts (methods, paths, request/response shapes) for primary endpoints. For SDK libraries, document the exported public API surface and versioning guarantees. Note any AI providers, external APIs, or complex integrations. If the ML/AI topology is active, populate the Model Ledger in Section 5.1 with all models identified during analysis. Document the concurrency/threading model (goroutines, asyncio, Node.js event loop). List all environment variables from `.env.example`. Include exact local development commands. Document invariants and safety rules found in the codebase. ### 2. `.agent/STYLE.md` @@ -151,7 +151,7 @@ Now, read the template files currently located in the `.agent/` directory. Use t - **CRITICAL**: Formulate explicit "Anti-Patterns (FORBIDDEN)" based on what the codebase avoids (e.g., "NEVER use `useEffect` for data fetching", "NEVER use `border-gray-200` for sectioning", "NEVER write temp files to disk", "NEVER use `Any`/`interface{}` types", "NEVER write raw SQL outside sqlc queries"). ### 3. `.agent/TESTING.md` -- **Goal**: Track test methods, execution evidence, and local dev setup. +- **Goal**: Track test methods, execution evidence, and local dev setup. This powers the failing test suite trap set in `/step2-trap` and execution looping in `/step3-build`. - **Instructions**: Document the exact steps to get the app running locally (prerequisites, install, start, seed, database, code generation). Document the exact CLI commands to run tests, type checking, and linting (including `go test -race`, `golangci-lint`, `shellcheck` where applicable). Keep the "Execution Evidence Rules" from the template intact. Set up the empty scenario tables ready for the first feature. Initialize the empty Regression Scenarios table. Based on the active topologies, retain or mark as "N/A" the conditional sections: Backend Route Coverage Matrix, Frontend Component State Matrix, and ML/AI Evaluation Thresholds. Populate headers and set up empty tables ready for the first feature. ### 4. `.agent/PHILOSOPHY.md` @@ -159,8 +159,21 @@ Now, read the template files currently located in the `.agent/` directory. Use t - **Instructions**: Read the project's `README.md` and any existing context files. Infer the core pain point the project solves. Define the target user persona. Synthesize 3-5 core beliefs that drive technical and product decisions. Document design/UX principles (applies to CLI, web, SDK, and headless projects). Define explicit anti-goals (What This Is NOT). *If the product purpose is completely ambiguous, stop and ask the user for a 1-paragraph description before writing this file.* ### 5. `.agent/STATUS.md` -- **Goal**: The single source of truth for progress. -- **Instructions**: Initialize the tracking state. Set "Current Focus" to "Project Bootstrapped". Leave the lifecycle checkboxes unchecked. Leave "Relevant Files" empty. Leave "Review Results" empty. Set "Active Worktrees" to "(none — sequential execution)". Based on the active topologies, retain or mark as "N/A" the conditional sections: Stub Audit Tracker (for projects with both frontend and backend), and Prompt Versioning Changelog (for ML/AI projects). +- **Goal**: The single source of truth for tracking progress through the 5-Step workflow. +- **Instructions**: + - Add `**Phase:** Ideation` near the top to track the current workflow phase. + - Set "Current Focus" to "Project Bootstrapped". + - **CRITICAL**: You MUST preserve the exact 5-Step Feature Lifecycle checkboxes exactly as follows: + `- [ ] Step 1: Spec` + `- [ ] Step 2: Trap` + `- [ ] Step 3: Build` + `- [ ] Step 4: Audit` + `- [ ] Step 5: Ship` + Do NOT invent generic SDLC phases (like "Requirements Gathered"). + - Leave "Relevant Files" empty. + - Leave "Review Results" empty. + - Set "Active Worktrees" to "(none — sequential execution)". + - Based on the active topologies, retain or mark as "N/A" the conditional sections: Stub Audit Tracker (for projects with both frontend and backend), and Prompt Versioning Changelog (for ML/AI projects). --- @@ -198,7 +211,7 @@ Some projects don't fit the traditional "application" mold. Adapt the templates ## Phase 3: Docs Scaffolding Verification -Ensure the following directory structure exists in the project root to support the roles/phases workflow. Create them with `.gitkeep` files if they are missing: +Ensure the following directory structure exists in the project root to support the `/step2-trap` (plans) and `/step5-ship` (archive) workflows. Create them with `.gitkeep` files if they are missing: - `docs/explorations/.gitkeep` - `docs/designs/.gitkeep` - `docs/plans/.gitkeep` @@ -209,10 +222,11 @@ If you moved pre-existing docs to `docs/archive/pre-gemstack/` in Phase 0.5, ens --- ## Execution Rules for the LLM +- **Output Format**: Your final response MUST be structured as a sequence of markdown sections. Each section MUST begin with exactly an H1 markdown header for the filename, e.g., `# .agent/ARCHITECTURE.md`, followed by the raw content for that file. Do NOT wrap the file content in markdown code blocks. - **No Hallucinations**: If a project does not have a database, simply write "N/A — No database utilized" in the database section. Do not invent details. - **Extreme Specificity**: Do not write generic statements like "Uses Tailwind for styling." Write "Uses Tailwind CSS v4 with a strict tokenized surface hierarchy (`bg-surface`, `bg-surface-container`). 1px borders are FORBIDDEN for sectioning." - **Absorb Legacy Rules**: If `.cursorrules`, `GEMINI.md`, or similar files exist, their rules MUST be reflected in the appropriate `.agent/` file. Do not ignore them. - **Clean Up After Absorbing**: After absorbing existing `.agent/` files (Phase 0.5), delete the old files. The new standardized files replace them entirely. - **Do not delete template sections**: If a section from the template is not applicable, keep the header and write "N/A — [Reason]". - **Respect auto-generated content**: Do NOT move or modify auto-generated files in `docs/` (Swagger, godoc, images). Only move human-written documentation to the archive. -- **Completion**: Once all 5 files are successfully overwritten, the `docs/` folders are verified, and any pre-existing content is properly archived, inform the user that the project is bootstrapped and ready for the `/ideate` phase. \ No newline at end of file +- **Completion**: Once all 5 files are successfully overwritten, the `docs/` folders are verified, and any pre-existing content is properly archived, inform the user that the project is bootstrapped and ready for `/step1-spec` (or `/ideate`). \ No newline at end of file diff --git a/src/gemstack/orchestration/router.py b/src/gemstack/orchestration/router.py index fc85f8d..4739b1b 100644 --- a/src/gemstack/orchestration/router.py +++ b/src/gemstack/orchestration/router.py @@ -52,14 +52,10 @@ class PhaseRouter: """Deterministic state machine for workflow routing. Rules (evaluated in order): - 1. If AUDIT_FINDINGS.md exists and has content → REROUTE_TO_BUILD - 2. If state is INITIALIZED → CONTINUE to /step1-spec - 3. If state is IN_PROGRESS → CONTINUE to current step - 4. If state is READY_FOR_BUILD → CONTINUE to /step3-build - 5. If state is READY_FOR_AUDIT → CONTINUE to /step4-audit - 6. If state is READY_FOR_SHIP and no findings → READY_TO_SHIP - 7. If state is SHIPPED → BLOCKED (start a new feature) - 8. Default → BLOCKED with "unable to determine state" + 1. If AUDIT_FINDINGS.md has active findings → REROUTE_TO_BUILD + 2. If AUDIT_FINDINGS.md has "ALL ISSUES RESOLVED" → REROUTE_TO_AUDIT + 3. If lifecycle is fully complete and audit is PASS → READY_TO_SHIP + 4. Otherwise, infer next sequential step from Lifecycle checkboxes. """ def route(self, project_root: Path) -> RoutingDecision: @@ -76,14 +72,13 @@ def route(self, project_root: Path) -> RoutingDecision: blockers=["Missing .agent/ directory"], ) - state = self._parse_state(status_path) lifecycle = self._parse_lifecycle(status_path) - has_findings = audit_path.exists() and audit_path.stat().st_size > 0 + audit_state = self._parse_audit_state(audit_path) - logger.debug(f"Router state: {state}, has_findings={has_findings}, lifecycle={lifecycle}") + logger.debug(f"Router audit_state={audit_state}, lifecycle={lifecycle}") - # Rule 1: Audit findings exist → reroute to build - if has_findings: + # Rule 1: Active audit findings → reroute to build to fix them + if audit_state == "FINDINGS": return RoutingDecision( action=RoutingAction.REROUTE_TO_BUILD, next_command="/step3-build", @@ -94,92 +89,119 @@ def route(self, project_root: Path) -> RoutingDecision: context_files=[str(audit_path)], ) - # Rule 2: Initialized → start with spec - if state == "INITIALIZED": + # Rule 2: Builder fixed findings → reroute to audit to verify + if audit_state == "RESOLVED": return RoutingDecision( action=RoutingAction.CONTINUE, - next_command="/step1-spec", + next_command="/step4-audit", reason=( - "Project is initialized but no feature has been started. " - "Begin with Step 1: Spec to define the feature." + "Builder has marked issues as resolved. " + "Proceeding to Step 4: Audit for re-verification." ), + context_files=[str(audit_path)], + ) + + # Rule 3: Sequential step routing + next_cmd, _ = self._infer_current_step(lifecycle) + + if next_cmd == "/step1-spec": + return RoutingDecision( + action=RoutingAction.CONTINUE, + next_command="/step1-spec", + reason="Begin with Step 1: Spec to define the feature and architecture contracts.", context_files=[str(status_path)], ) - # Rule 3: In progress → determine current step from lifecycle - if state == "IN_PROGRESS": - next_cmd = self._infer_current_step(lifecycle) + if next_cmd == "/step2-trap": return RoutingDecision( action=RoutingAction.CONTINUE, - next_command=next_cmd, - reason=f"Feature is in progress. Continue with {next_cmd}.", + next_command="/step2-trap", + reason="Spec complete. Proceed to Step 2: Trap to write plan & failing tests.", context_files=[str(status_path)], ) - # Rule 4: Ready for build - if state == "READY_FOR_BUILD": + if next_cmd == "/step3-build": return RoutingDecision( action=RoutingAction.CONTINUE, next_command="/step3-build", - reason=( - "Spec and plan are complete. Proceeding to Step 3: Build. " - "Read the plan and contracts, then implement." - ), + reason="Trap is set. Proceed to Step 3: Build to implement the code.", context_files=[str(status_path)], ) - # Rule 5: Ready for audit - if state == "READY_FOR_AUDIT": + if next_cmd == "/step4-audit": + # Edge case: If Audit checkbox is missed but Audit explicitly passed + if audit_state == "PASS": + return RoutingDecision( + action=RoutingAction.READY_TO_SHIP, + next_command="/step5-ship", + reason="Audit passed with no active findings. Proceed to Step 5: Ship.", + context_files=[str(status_path)], + ) + return RoutingDecision( action=RoutingAction.CONTINUE, next_command="/step4-audit", - reason=( - "Build is complete and tests are passing. " - "Proceeding to Step 4: Audit for security and logic review." - ), + reason="Build complete. Proceed to Step 4: Audit for security and logic review.", context_files=[str(status_path)], ) - # Rule 6: Ready to ship (no findings) - if state == "READY_FOR_SHIP": + if next_cmd == "/step5-ship": + if audit_state == "PASS" or audit_state == "EMPTY": + return RoutingDecision( + action=RoutingAction.READY_TO_SHIP, + next_command="/step5-ship", + reason="Audit passed. Proceed to Step 5: Ship — integrate, merge, and deploy.", + context_files=[str(status_path)], + ) + # Should not reach here if FINDINGS/RESOLVED due to earlier rules return RoutingDecision( - action=RoutingAction.READY_TO_SHIP, - next_command="/step5-ship", - reason=( - "Audit passed with no findings. " - "Proceeding to Step 5: Ship — integrate, merge, and deploy." - ), + action=RoutingAction.CONTINUE, + next_command="/step4-audit", + reason="Audit has not explicitly passed. Proceed to Step 4: Audit.", context_files=[str(status_path)], ) - # Rule 7: Already shipped - if state == "SHIPPED": + if next_cmd == "DONE": return RoutingDecision( action=RoutingAction.BLOCKED, next_command="gemstack start", reason=( - "Current feature has been shipped. " + "Current feature lifecycle is fully complete. " "Run `gemstack start ` to begin a new feature." ), blockers=["Feature already shipped"], ) - # Rule 8: Unknown state return RoutingDecision( action=RoutingAction.BLOCKED, - reason=f"Unable to determine next action from state '{state}'.", - blockers=[f"Unrecognized state: {state}"], + reason="Unable to determine next action from lifecycle state.", + blockers=["Unrecognized lifecycle state"], ) - def _parse_state(self, status_path: Path) -> str: - """Extract [STATE: ...] enum from STATUS.md.""" + def _parse_audit_state(self, audit_path: Path) -> str: + """Parse the state of AUDIT_FINDINGS.md. + + Returns: + "EMPTY" if file doesn't exist or is empty + "PASS" if auditor passed it + "RESOLVED" if builder fixed issues but pending audit + "FINDINGS" if issues exist + """ + if not audit_path.exists() or audit_path.stat().st_size == 0: + return "EMPTY" + try: - content = status_path.read_text(encoding="utf-8") + content = audit_path.read_text(encoding="utf-8").upper() except (OSError, UnicodeDecodeError): - return "UNKNOWN" - - match = re.search(r"\[STATE:\s*(\w+)\]", content) - return match.group(1) if match else "UNKNOWN" + return "EMPTY" + + if "PASS" in content and "BLOCKS_RELEASE" not in content and "DEGRADED" not in content: + return "PASS" + + if "ALL ISSUES RESOLVED" in content: + return "RESOLVED" + + return "FINDINGS" def _parse_lifecycle(self, status_path: Path) -> dict[str, bool]: """Extract feature lifecycle checkbox state from STATUS.md. @@ -192,18 +214,18 @@ def _parse_lifecycle(self, status_path: Path) -> dict[str, bool]: return {} lifecycle: dict[str, bool] = {} - # Match lines like: - [x] Spec, - [ ] Build - for match in re.finditer(r"-\s*\[([ xX])\]\s*(\w+)", content): + # Match lines like: - [x] Step 1: Spec, - [ ] Step 3: Build + for match in re.finditer(r"-\s*\[([ xX])\]\s*(?:Step\s*\d+:\s*)?(\w+)", content): completed = match.group(1).lower() == "x" phase_name = match.group(2) lifecycle[phase_name] = completed return lifecycle - def _infer_current_step(self, lifecycle: dict[str, bool]) -> str: + def _infer_current_step(self, lifecycle: dict[str, bool]) -> tuple[str, str]: """Infer the current step from lifecycle checkboxes. - Returns the next uncompleted step command. + Returns (next_command, phase_name). """ step_mapping = [ ("Spec", "/step1-spec"), @@ -213,9 +235,12 @@ def _infer_current_step(self, lifecycle: dict[str, bool]) -> str: ("Ship", "/step5-ship"), ] + if not lifecycle: + return "/step1-spec", "Spec" + for phase_name, command in step_mapping: if not lifecycle.get(phase_name, False): - return command + return command, phase_name # All phases complete → ready to ship - return "/step5-ship" + return "DONE", "Done" diff --git a/tests/test_router.py b/tests/test_router.py index 478237f..53c9065 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -6,7 +6,7 @@ class TestRouterRules: - """Test all 8 routing rules from the spec.""" + """Test routing rules.""" def test_missing_status_returns_blocked(self, tmp_path: Path) -> None: router = PhaseRouter() @@ -16,13 +16,21 @@ def test_missing_status_returns_blocked(self, tmp_path: Path) -> None: assert "gemstack init" in decision.next_command assert "Missing .agent/ directory" in decision.blockers - def test_audit_findings_reroute_to_build(self, bootstrapped_project: Path) -> None: - # Create AUDIT_FINDINGS.md with content - audit_path = bootstrapped_project / ".agent" / "AUDIT_FINDINGS.md" + def test_audit_findings_reroute_to_build(self, tmp_path: Path) -> None: + agent_dir = tmp_path / ".agent" + agent_dir.mkdir() + (agent_dir / "STATUS.md").write_text( + "- [x] Step 1: Spec\n" + "- [x] Step 2: Trap\n" + "- [x] Step 3: Build\n" + "- [ ] Step 4: Audit\n" + ) + + audit_path = agent_dir / "AUDIT_FINDINGS.md" audit_path.write_text("## Findings\n- SQL injection in login route\n") router = PhaseRouter() - decision = router.route(bootstrapped_project) + decision = router.route(tmp_path) assert decision.action == RoutingAction.REROUTE_TO_BUILD assert decision.next_command == "/step3-build" @@ -32,7 +40,14 @@ def test_initialized_state_continues_to_spec(self, tmp_path: Path) -> None: agent_dir = tmp_path / ".agent" agent_dir.mkdir() (agent_dir / "STATUS.md").write_text( - "# Status\n\n[STATE: INITIALIZED]\n\n## Feature Lifecycle\n- [ ] Spec\n- [ ] Build\n" + + "## 5. Lifecycle Tracker\n" + "- [ ] Step 1: Spec\n" + "- [ ] Step 2: Trap\n" + "- [ ] Step 3: Build\n" + "- [ ] Step 4: Audit\n" + "- [ ] Step 5: Ship\n" + ) router = PhaseRouter() @@ -44,7 +59,13 @@ def test_initialized_state_continues_to_spec(self, tmp_path: Path) -> None: def test_ready_for_build_continues(self, tmp_path: Path) -> None: agent_dir = tmp_path / ".agent" agent_dir.mkdir() - (agent_dir / "STATUS.md").write_text("[STATE: READY_FOR_BUILD]\n") + (agent_dir / "STATUS.md").write_text( + + "- [x] Step 1: Spec\n" + "- [x] Step 2: Trap\n" + "- [ ] Step 3: Build\n" + + ) router = PhaseRouter() decision = router.route(tmp_path) @@ -55,7 +76,14 @@ def test_ready_for_build_continues(self, tmp_path: Path) -> None: def test_ready_for_audit_continues(self, tmp_path: Path) -> None: agent_dir = tmp_path / ".agent" agent_dir.mkdir() - (agent_dir / "STATUS.md").write_text("[STATE: READY_FOR_AUDIT]\n") + (agent_dir / "STATUS.md").write_text( + + "- [x] Step 1: Spec\n" + "- [x] Step 2: Trap\n" + "- [x] Step 3: Build\n" + "- [ ] Step 4: Audit\n" + + ) router = PhaseRouter() decision = router.route(tmp_path) @@ -66,7 +94,15 @@ def test_ready_for_audit_continues(self, tmp_path: Path) -> None: def test_ready_for_ship_no_findings(self, tmp_path: Path) -> None: agent_dir = tmp_path / ".agent" agent_dir.mkdir() - (agent_dir / "STATUS.md").write_text("[STATE: READY_FOR_SHIP]\n") + (agent_dir / "STATUS.md").write_text( + + "- [x] Step 1: Spec\n" + "- [x] Step 2: Trap\n" + "- [x] Step 3: Build\n" + "- [x] Step 4: Audit\n" + "- [ ] Step 5: Ship\n" + + ) router = PhaseRouter() decision = router.route(tmp_path) @@ -77,7 +113,15 @@ def test_ready_for_ship_no_findings(self, tmp_path: Path) -> None: def test_shipped_state_blocked(self, tmp_path: Path) -> None: agent_dir = tmp_path / ".agent" agent_dir.mkdir() - (agent_dir / "STATUS.md").write_text("[STATE: SHIPPED]\n") + (agent_dir / "STATUS.md").write_text( + + "- [x] Step 1: Spec\n" + "- [x] Step 2: Trap\n" + "- [x] Step 3: Build\n" + "- [x] Step 4: Audit\n" + "- [x] Step 5: Ship\n" + + ) router = PhaseRouter() decision = router.route(tmp_path) @@ -85,88 +129,70 @@ def test_shipped_state_blocked(self, tmp_path: Path) -> None: assert decision.action == RoutingAction.BLOCKED assert "gemstack start" in decision.next_command - def test_unknown_state_blocked(self, tmp_path: Path) -> None: - agent_dir = tmp_path / ".agent" - agent_dir.mkdir() - (agent_dir / "STATUS.md").write_text("[STATE: SOMETHING_WEIRD]\n") - - router = PhaseRouter() - decision = router.route(tmp_path) - - assert decision.action == RoutingAction.BLOCKED - -class TestInProgressRouting: - """Test IN_PROGRESS state with lifecycle inference.""" +class TestAuditStateParsing: + """Test parsing of AUDIT_FINDINGS.md""" - def test_in_progress_spec_incomplete(self, tmp_path: Path) -> None: + def test_empty_audit_file(self, tmp_path: Path) -> None: agent_dir = tmp_path / ".agent" agent_dir.mkdir() (agent_dir / "STATUS.md").write_text( - "[STATE: IN_PROGRESS]\n\n## Feature Lifecycle\n" - "- [ ] Spec\n- [ ] Trap\n- [ ] Build\n- [ ] Audit\n- [ ] Ship\n" + "- [x] Step 1: Spec\n" + "- [x] Step 2: Trap\n" + "- [x] Step 3: Build\n" + "- [ ] Step 4: Audit\n" ) - + (agent_dir / "AUDIT_FINDINGS.md").write_text("") + router = PhaseRouter() decision = router.route(tmp_path) - + + # Should continue to audit assert decision.action == RoutingAction.CONTINUE - assert decision.next_command == "/step1-spec" + assert decision.next_command == "/step4-audit" - def test_in_progress_spec_done_trap_next(self, tmp_path: Path) -> None: + def test_resolved_audit_file(self, tmp_path: Path) -> None: agent_dir = tmp_path / ".agent" agent_dir.mkdir() (agent_dir / "STATUS.md").write_text( - "[STATE: IN_PROGRESS]\n\n## Feature Lifecycle\n- [x] Spec\n- [ ] Trap\n- [ ] Build\n" + "- [x] Step 1: Spec\n" + "- [x] Step 2: Trap\n" + "- [x] Step 3: Build\n" + "- [ ] Step 4: Audit\n" ) - + (agent_dir / "AUDIT_FINDINGS.md").write_text("ALL ISSUES RESOLVED") + router = PhaseRouter() decision = router.route(tmp_path) - + + # Should continue to audit for re-verification assert decision.action == RoutingAction.CONTINUE - assert decision.next_command == "/step2-trap" - - def test_in_progress_build_next(self, tmp_path: Path) -> None: + assert decision.next_command == "/step4-audit" + + def test_pass_audit_file(self, tmp_path: Path) -> None: agent_dir = tmp_path / ".agent" agent_dir.mkdir() + # Even if Audit checkbox isn't checked, if file says PASS it skips to ship (agent_dir / "STATUS.md").write_text( - "[STATE: IN_PROGRESS]\n\n## Feature Lifecycle\n" - "- [x] Spec\n- [x] Trap\n- [ ] Build\n- [ ] Audit\n- [ ] Ship\n" + "- [x] Step 1: Spec\n" + "- [x] Step 2: Trap\n" + "- [x] Step 3: Build\n" + "- [ ] Step 4: Audit\n" + "- [ ] Step 5: Ship\n" ) - + (agent_dir / "AUDIT_FINDINGS.md").write_text("PASS") + router = PhaseRouter() decision = router.route(tmp_path) + + assert decision.action == RoutingAction.READY_TO_SHIP + assert decision.next_command == "/step5-ship" - assert decision.action == RoutingAction.CONTINUE - assert decision.next_command == "/step3-build" - - -class TestStateParsing: - """Test internal state parsing helpers.""" - - def test_parse_state_extracts_enum(self, tmp_path: Path) -> None: - path = tmp_path / "STATUS.md" - path.write_text("[STATE: READY_FOR_BUILD]") - - router = PhaseRouter() - assert router._parse_state(path) == "READY_FOR_BUILD" - - def test_parse_state_handles_whitespace(self, tmp_path: Path) -> None: - path = tmp_path / "STATUS.md" - path.write_text("[STATE: IN_PROGRESS]") - - router = PhaseRouter() - state = router._parse_state(path) - assert state == "IN_PROGRESS" - - def test_parse_state_missing_returns_unknown(self, tmp_path: Path) -> None: - path = tmp_path / "STATUS.md" - path.write_text("No state here") - router = PhaseRouter() - assert router._parse_state(path) == "UNKNOWN" +class TestLifecycleParsing: + """Test parsing of STATUS.md checkboxes.""" - def test_parse_lifecycle(self, tmp_path: Path) -> None: + def test_parse_lifecycle_old_format(self, tmp_path: Path) -> None: path = tmp_path / "STATUS.md" path.write_text("- [x] Spec\n- [x] Trap\n- [ ] Build\n- [ ] Audit\n- [ ] Ship\n") @@ -178,18 +204,20 @@ def test_parse_lifecycle(self, tmp_path: Path) -> None: assert lifecycle["Build"] is False assert lifecycle["Ship"] is False - -class TestEmptyAuditFindings: - """Test that empty AUDIT_FINDINGS.md behaves correctly.""" - - def test_empty_file_not_treated_as_findings(self, tmp_path: Path) -> None: - agent_dir = tmp_path / ".agent" - agent_dir.mkdir() - (agent_dir / "STATUS.md").write_text("[STATE: READY_FOR_SHIP]\n") - (agent_dir / "AUDIT_FINDINGS.md").write_text("") # Empty + def test_parse_lifecycle_new_format(self, tmp_path: Path) -> None: + path = tmp_path / "STATUS.md" + path.write_text( + "- [x] Step 1: Spec\n" + "- [x] Step 2: Trap\n" + "- [ ] Step 3: Build\n" + "- [ ] Step 4: Audit\n" + "- [ ] Step 5: Ship\n" + ) router = PhaseRouter() - decision = router.route(tmp_path) + lifecycle = router._parse_lifecycle(path) - # Empty file should NOT trigger reroute - assert decision.action == RoutingAction.READY_TO_SHIP + assert lifecycle["Spec"] is True + assert lifecycle["Trap"] is True + assert lifecycle["Build"] is False + assert lifecycle["Ship"] is False