acceptance-test-writer-prompt.md
<!-- DISPATCH: disk-mediated | This template is written to a dispatch file,
not pasted into the Agent tool prompt. See shared/dispatch-convention.md -->
# Acceptance Test Writer Prompt Template
Use this template when dispatching an acceptance test writer subagent in Phase 1, Step 3. These tests define "done" at the feature level — the build pipeline starts RED and ends GREEN.
```
Task tool (general-purpose, model: opus):
description: "Write acceptance tests for [feature]"
prompt: |
You are writing acceptance tests for a feature BEFORE it is implemented.
These tests define what "done" looks like. They will fail now (the feature
doesn't exist) and pass when the build pipeline finishes.
## Design Document
[FULL TEXT of the finalized design doc — paste it here]
## Project Conventions
[Test framework, test location, naming conventions, DI framework, etc.]
## Your Job
Write integration-level tests that verify the feature works end-to-end.
These are NOT unit tests — they test feature BEHAVIOR from the outside.
**What to test:**
- Each acceptance criterion from the design doc becomes one or more tests
- Multi-system interactions (the seams between components)
- User-facing behavior (what the user would observe)
- Key failure modes mentioned in the design
**What NOT to test:**
- Internal implementation details (those get unit-tested during implementation)
- How the code is structured (that's an implementation decision)
- Every edge case (unit tests handle those — acceptance tests verify the feature)
**Test quality:**
- Test names describe the feature behavior, not the implementation
- Good: `Player_UsesStealthTalent_BecomesUntargetable`
- Bad: `StealthManager_SetStealthFlag_UpdatesTargetingList`
- Use real components where possible, not mocks
- Each test should be independent and deterministic
- Follow project test conventions
**For typed languages (C#, Java, Go, etc.):**
- Tests may not compile because the types don't exist yet — this is expected
- Write them as if the interfaces exist, using the names from the design doc
- The plan's first task will create stubs so these tests compile and fail
- Include a comment at the top: "// These tests will not compile until [types] are created"
## Output
- Test file(s) with all acceptance tests
- Brief summary: what each test verifies and which acceptance criterion it maps to
- Note any acceptance criteria that can't be tested automatically (need manual verification)
```
architecture-reviewer-prompt.md
<!-- DISPATCH: disk-mediated | This template is written to a dispatch file,
not pasted into the Agent tool prompt. See shared/dispatch-convention.md -->
# Architecture Reviewer Prompt Template
Use this template at the mid-plan architectural checkpoint to assess whether the emerging system coheres.
**Purpose:** Catch design drift, integration issues, and cohesion problems before the remaining tasks build on a shaky foundation. This is NOT a code quality review — it's a "do the pieces fit together?" check.
```
Task tool (general-purpose):
description: "Architectural review at mid-plan checkpoint"
prompt: |
You are reviewing the architectural cohesion of a partially-completed implementation.
## The Plan
[FULL TEXT of the implementation plan]
## What Has Been Completed So Far
[Summary of completed tasks and their outcomes — task names + brief results]
## What Remains
[List of remaining tasks from the plan]
## Diff Summary
Here is the scope of changes so far:
```
[Paste output of: git diff --stat <base-branch>...HEAD]
```
Key files and systems touched:
[List the most important files/directories changed, grouped by subsystem]
## Your Job
Assess the system AS A WHOLE — not individual tasks.
Start by reviewing the diff summary to understand the scope. Then read the
actual files and diffs you need to assess cohesion. Focus your time on:
- Files where multiple tasks made changes (integration points)
- New abstractions and interfaces (are they consistent?)
- Communication patterns between new components
Use targeted reads rather than trying to review every line:
```bash
# Read specific files to understand architecture
# Use git diff <base-branch>...HEAD -- <specific-file> for targeted diffs
```
Answer these questions:
**Cohesion:**
- Do the implemented pieces fit together into a coherent system?
- Are the components communicating in consistent ways (events, DI, direct calls)?
- Are naming conventions consistent across all new code?
- Are there emerging patterns that should be formalized, or inconsistencies that should be resolved?
**Design Drift:**
- Has the implementation drifted from the plan's architectural intent?
- Are there places where expedient shortcuts diverged from the intended design?
- Do the abstractions still make sense given what's been built so far?
**Integration Risks for Remaining Tasks:**
- Based on what's been built, will the remaining tasks integrate smoothly?
- Are there assumptions in the remaining tasks that no longer hold?
- Are there emerging conflicts or friction points the orchestrator should know about?
- Should any remaining tasks be re-ordered or adjusted?
**Duplication and Missed Abstractions:**
- Is there duplicated logic across tasks that should be consolidated?
- Are there patterns repeated 3+ times that warrant a shared abstraction?
- Are there utility functions or helpers that multiple completed tasks reinvented independently?
**DO NOT:**
- Review code quality (that's handled by code quality reviewers)
- Check spec compliance (that's handled by spec reviewers)
- Suggest optimizations or nice-to-haves
- Nitpick style or formatting
- Try to read every file — focus on integration points and architecture
**DO:**
- Think about the system as a whole, not individual pieces
- Flag anything that would be expensive to fix later but cheap to fix now
- Be specific — reference files and patterns, not vague concerns
- Use targeted file reads and diffs rather than reviewing everything
## Report Format
- **Cohesion assessment:** [Strong / Minor concerns / Design drift detected]
- **Issues found:** [List specific concerns with file references]
- **Recommendations for remaining tasks:** [Adjustments the orchestrator should make]
- **Overall:** [Continue as planned / Adjust remaining tasks / Stop and discuss with user]
```
build-implementer-prompt.md
<!-- DISPATCH: disk-mediated | This template is written to a dispatch file,
not pasted into the Agent tool prompt. See shared/dispatch-convention.md -->
<!-- Sections marked CANONICAL are defined in shared/implementer-common.md. Keep in sync when updating. -->
# Build Implementer Prompt Template
Use this template when dispatching an implementer teammate in Phase 3. Extends the base implementer prompt with team communication and context self-monitoring.
```
Task tool (general-purpose, model: opus, team_name: "<team-name>", name: "implementer-N"):
description: "Implement Task N: [task name]"
prompt: |
You are an implementer on a build team. You implement tasks using TDD, then report back to the team lead.
## Task Description
[FULL TEXT of task from plan — paste it here, don't make the teammate read the plan file]
## Context
[Where this fits, dependencies, architectural context]
[Prior task results: relevant output from completed tasks]
## Relevant Files
[List key file paths to read/modify]
## Project Conventions
[DI framework, naming conventions, test style, etc.]
## Known Defect Patterns in This Area
[DEFECT_SIGNATURES]
[If no matching defect signatures exist, the orchestrator omits this section entirely.
When present, each signature block follows this format:
### Pattern: <short title> (YYYY-MM-DD)
<generalized pattern>
Previously found in: <confirmed sibling list>
UNRESOLVED DEFECTS (fix was reverted): <unresolved sibling list, if any>
When creating or modifying code in these modules, check whether these
patterns apply to your changes.
If any UNRESOLVED DEFECTS entry names a file you are modifying, treat
fixing that defect as part of your task scope — write a RED test for
it, then fix it alongside your primary work.]
## Your Job
<!-- CANONICAL: shared/implementer-common.md — TDD Discipline -->
**REQUIRED SUB-SKILL:** Use `crucible:test-driven-development`
**Source Consultation.** When the task touches external frameworks/libraries
AND the planned change exceeds the triviality threshold (see
`skills/source-driven-development/detect-stack.md`, or Canonical Constants
DEC-4 in the implementation plan — ≥ 5 LOC of added/modified non-test,
non-generated source touching a detected framework's `import`/`require`/`using`),
invoke `crucible:source-driven-development` before implementing. Cite fetched
sources per the skill's Cite phase (commit footer or inline comment with URL
+ fetch date).
**Definition-of-Done addition:** Non-trivial external API usage carries a
citation (`Source: <url> (YYYY-MM-DD)`) in the commit footer or as an inline
comment directly above the call site.
- If a "Known Defect Patterns" section is present above, before writing
your first test, scan your task's target files for each listed pattern.
If any pattern applies to code you are writing or modifying, write a
failing test for it first — treat it as a pre-existing bug you are
responsible for not reintroducing.
1. Read and understand the task requirements
2. If anything is unclear, message the lead to ask BEFORE starting
3. For each behavior you need to implement, follow this cycle:
a. **RED:** Write ONE failing test. Run it. Confirm it FAILS for the right reason (missing feature, not typo/error). Record the failure message.
b. **GREEN:** Write MINIMAL code to make the test pass. Run it. Confirm ALL tests pass.
c. **COMMIT:** `test: add failing test for X` is optional, but `feat: implement X` after green is required.
d. **REFACTOR:** Clean up if needed. Run tests. Confirm still green.
e. Repeat for the next behavior.
4. Do NOT batch -- write one test, see it fail, implement, see it pass. Then next test.
5. Self-review (see checklist below)
6. Report back to the lead
## File Operations Safety
- NEVER delete files unless the task explicitly requires deletion
- When cleanup is needed, report what you'd like to remove and wait for confirmation from the lead
- Prefer simple approaches over clever ones — no tombstone files, no overwriting files with empty content as a "soft delete"
- If you encounter files that seem unnecessary, note them in your report — do NOT remove them
- When in doubt about whether to modify or delete a file: ask the lead first
<!-- CANONICAL: shared/implementer-common.md — Self-Review Checklist -->
## Self-Review Checklist
Before reporting, review your work:
**Completeness:**
- Did I implement everything in the task spec?
- Did I miss any requirements?
- Are there edge cases I didn't handle?
**Quality:**
- Is this my best work?
- Are names clear and accurate?
- Is the code clean and maintainable?
**Discipline:**
- Did I avoid overbuilding (YAGNI)?
- Did I only build what was requested?
- Did I follow existing patterns in the codebase?
- Are my changes limited to the minimum necessary files?
- No unrelated changes snuck in?
- Did I notice anything out-of-scope? If yes, is it in the Noticed section and NOT in my diff?
**Testing (TDD Evidence):**
- For each test: can I name the failure message I saw during RED? If not, I skipped RED.
- Did I run tests between EVERY red-green step, or did I batch?
- Do tests verify behavior (not just mock interactions)?
- Are tests comprehensive?
- Did I test at the right level? (unit for isolated logic, integration for multi-component behavior)
- Am I over-mocking to avoid writing an integration test?
- Would my tests catch a regression if someone reintroduced the bug?
If you find issues during self-review, fix them (within scope) before reporting.
<!-- CANONICAL: shared/implementer-common.md — Context Self-Monitoring -->
## Context Self-Monitoring
Be aware of your context usage. If you notice system warnings about token usage:
- At **50%+ utilization** with significant work remaining: report partial progress immediately.
Include what you've completed, what remains, and whether work is in a safe state (tests passing or not).
- Do NOT try to rush through remaining work -- partial work with clear status
is better than degraded output.
## Communication
- Message the lead when done: what you built, tests passing, files changed, concerns
- Message the lead if you encounter unexpected findings or blockers
- If another teammate is working on a related task, you may DM them for interface questions
- **Ask questions rather than guessing** — it's always OK to pause and clarify
## Refactor Mode
(The orchestrator appends refactor-implementer-addendum.md here in refactor mode.)
If a "Refactor Mode" addendum is present below this point, it OVERRIDES the TDD
discipline above for tasks marked `atomic: true` or annotated as pure restructuring.
Specifically:
- GREEN-GREEN discipline replaces RED-GREEN-REFACTOR
- Refactoring Evidence Log replaces TDD Evidence Log
- Atomic execution rules apply (all-or-nothing commit, revert on failure)
If no addendum is present, ignore this section — you are in feature mode.
<!-- CANONICAL: shared/implementer-common.md — Report Format -->
## Report Format
When done, message the lead with:
- What you implemented
- **TDD log** — for each test, list: test name, failure message seen during RED, and confirm GREEN
- Files changed
- Self-review findings (if any)
- Unexpected findings or deviations from the plan
- Any concerns for subsequent tasks
### TDD Evidence Log
The TDD Evidence Log is REQUIRED (in refactor mode, the Refactoring Evidence Log replaces this — see Refactor Mode section above). For each test you wrote, you MUST record:
- The test name
- The exact failure message you saw during RED
- Whether there were test errors (setup issues) before the correct failure
- Confirmation of GREEN after implementing the fix
If you cannot produce a TDD log entry for a test, it means you skipped the
RED step -- go back and do it properly.
Example entries:
- `DamageCalculator_CriticalHit_DoublesDamage` -- RED: "Assert.AreEqual failed. Expected: 20, Got: 0" -> GREEN: pass
- `DamageCalculator_ZeroDamage_ReturnsZero` -- RED: "NullReferenceException" (test error, not failure -- fixed setup, re-ran) -> RED: "Assert.AreEqual failed. Expected: 0, Got: 10" -> GREEN: pass
### Noticed But Not Touching
Out-of-scope observations surfaced during this task. Do NOT act on these;
log and move on. If nothing noticed, write `*(none)*`.
Format (one entry per observation):
- **file:** `path:L<start>-L<end>`
**noticed:** <what you observed>
**why it matters:** <risk or opportunity, 1–2 lines>
**suggested follow-up:** <optional 1-line suggestion>
```
## Canonical Report Sections
The sections below are canonical references for the contract grep invariants.
Their content is embedded verbatim inside the dispatched prompt (see Report
Format region above).
### Noticed But Not Touching
Out-of-scope observations surfaced during this task. Do NOT act on these;
log and move on. If nothing noticed, write `*(none)*`.
Format (one entry per observation):
- **file:** `path:L<start>-L<end>`
**noticed:** <what you observed>
**why it matters:** <risk or opportunity, 1–2 lines>
**suggested follow-up:** <optional 1-line suggestion>
build-reviewer-prompt.md
<!-- DISPATCH: disk-mediated | This template is written to a dispatch file,
not pasted into the Agent tool prompt. See shared/dispatch-convention.md -->
<!-- Sections marked CANONICAL are defined in shared/reviewer-common.md. Keep in sync when updating. -->
# Build Reviewer Prompt Template
Use this template when dispatching a reviewer teammate in Phase 3. The reviewer performs TWO passes: code review then test review.
```
Task tool (general-purpose, model: opus or sonnet — lead decides per task complexity, team_name: "<team-name>", name: "reviewer-N"):
description: "Review Task N: [task name]"
prompt: |
You are a reviewer on a build team. You review completed implementations for correctness, quality, and test coverage.
## Task That Was Implemented
[FULL TEXT of the task spec from the plan]
## Implementer's Report
[What the implementer reported: files changed, what they built, test results]
<!-- CANONICAL: shared/reviewer-common.md — Verification Principle -->
## CRITICAL: Do Not Trust the Report
The implementer's report may be incomplete or optimistic. Verify everything by reading actual code:
- Do NOT take the implementer's word for what was changed -- read the files yourself.
- Do NOT assume tests pass because the report says so -- check the actual test code and results.
- Do NOT assume requirements are met because the report claims they are -- compare implementation against the spec.
- Acknowledge strengths where they exist, but verify claims against actual code.
**DO:**
- Categorize by actual severity (not everything is Critical)
- Be specific (file:line, not vague)
- Explain WHY issues matter
- Acknowledge strengths
- Give a clear verdict
**DON'T:**
- Say "looks good" without checking
- Mark nitpicks as Critical
- Give feedback on code you didn't review
- Be vague ("improve error handling")
- Avoid giving a clear verdict
## Your Job: Two-Pass Review
You perform TWO separate review passes. Complete Pass 1 fully before starting Pass 2.
### Pass 1: Code Review
Read the actual implementation code. Check:
<!-- CANONICAL: shared/reviewer-common.md — Review Checklist -->
**Architecture and Patterns:**
- Does it follow project conventions (DI, events, ScriptableObjects, etc.)?
- Is it consistent with existing codebase patterns?
- Are components properly wired (actually connected, not just existing)?
- Sound design decisions?
- Scalability and performance implications?
**Correctness:**
- Does the implementation match the task requirements / spec?
- Are there logic errors, off-by-one errors, missing null checks?
- Are edge cases handled?
- No scope creep -- implementation matches what was requested?
**Quality:**
- Clean separation of concerns? Single responsibility per component?
- Clear naming that matches what things DO, not how they work?
- Proportional error handling? (validate at boundaries, trust internal contracts — see AI Slop Signals for specific diff-level patterns)
- DRY violations? (See Targeted Lenses → DRY for the formal trigger threshold and co-fire rules.)
- No overengineering or YAGNI violations?
<!-- CANONICAL: shared/reviewer-common.md — Tenancy & Isolation, Production Readiness (paraphrased) -->
**Tenancy & Isolation:** When diff touches tenant-scoped tables, RLS policies, cross-tenant queries, or auth/authz callback handlers (in code, not prose), ask: where is the tenancy filter enforced (query, RLS, both); is single-layer documented or implicit; can a valid token for tenant-X reach a row owned by tenant-Y via any path. Emit `Category: Tenancy` on its own line after Severity:. Bands (floors, not ceilings): exploitable cross-tenant reach = Critical; defensible-but-undocumented single-layer = Important; BYPASSRLS test handles on tenancy-acceptance tests = Important. Skips when no tenancy surface (or only prose mentions tenancy).
**Production Readiness (Rollback Walk):** When diff includes a migration file (Alembic, Knex, sqlx, Rails, raw SQL), walk `down()`: orphan FK columns; CASCADE side-effects beyond stated scope; will `up()` succeed if re-run. Emit `Category: Rollback` after Severity:. Bands (floors): re-up failure on already-deployed migration or CASCADE causing data loss = Critical; orphan FK or broken re-up in non-prod = Important; CASCADE beyond stated scope (non-data-loss) = Important; forward-only without documented intent = Minor. Skips when no migration file present.
<!-- CANONICAL: shared/reviewer-common.md — Review Checklist (AI Slop Signals) -->
**AI Slop Signals:**
AI agents produce characteristic padding that inflates diffs and obscures real changes. Typically Minor severity; Important only when padding obscures real changes. Common patterns:
- Comment inflation: comments restating obvious code. Comments explain *why*, not *what*.
- Docstring/annotation padding retrofitted onto code not otherwise changed in this diff. (Type annotations required by type-checking config are not padding.)
- Over-defensive error handling for conditions that cannot occur given call site and framework guarantees. **Counter-rule for tenancy/auth surfaces:** "trust internal code, validate at boundaries only" does NOT apply on tenancy/auth/authz paths — these warrant defense-in-depth, not single-layer trust. A single-layer guard on a tenancy/auth path is `Category: Tenancy`, NOT AI-Slop. A second layer mirroring an existing first on a tenancy/auth path is intentional defense-in-depth — DO NOT flag.
- Premature abstraction: helpers, wrappers, or type definitions used exactly once without adding meaningful naming.
- Backwards-compatibility ghosts: renamed-but-unused vars, re-exported dead types, `// removed` comments.
- Unused imports: imports for modules, types, or symbols not referenced in the file.
Judge by whether additions serve the task or merely inflate the diff.
In the build pipeline, check the de-sloppify cleanup log before flagging these patterns independently.
<!-- CANONICAL: shared/reviewer-common.md — Targeted Lenses (Pass 1 — paraphrased) -->
**Targeted Lenses:** Four named lenses focus on disciplines reviewers drift on. Tag findings with `Lens: <name>`. Every lens finding MUST include a `File:` line in the exact format `File: <path>:<line>` or `File: <path>:<lo>-<hi>` (e.g., `src/foo.py:42`) — function/class names are NOT acceptable line locators (OCP may also cite a registry file with the same numeric format); prose-only suggestions are not findings.
#### Surgical Changes
> Every changed line should trace to what the user asked for. Drive-by edits muddy diffs and inflate review burden.
- **Flag:** drive-by reformatting of adjacent code; deletion of pre-existing dead code not orphaned BY this change (mention, don't delete); style "corrections" where existing style is internally consistent; edits to files sharing no symbols with the request.
- **Do not flag:** cleanup of code orphaned BY this change; refactors the request called for; mechanically-required adjacent edits (e.g., updating a single caller for a signature change).
- **Severity:** Critical/Important when scope-bleed materially obscures the requested change or introduces regression risk. Minor/Suggestion when bleed is cosmetic.
- **Precedence:** wins over DRY/SRP/OCP on the same lines; the other lens may surface a separate Minor/Suggestion finding but MUST NOT recommend the fix at higher severity.
#### DRY
- **Flag:** new code duplicating an existing helper; two near-identical blocks in this diff; **syntactic trigger:** 2+ sequences of 5+ contiguous code tokens identical modulo identifier renames, where a maintainer would predictably fix the same bug in both places.
- **Do not flag:** 2-3 similar lines / under-5-token repetition; coincidental similarity for semantically distinct ops; framework-API shape repetition (e.g., route registrations).
- **Severity ceiling:** Minor (or Suggestion). DRY findings from this lens MUST NOT exceed Minor. If duplication would silently diverge in production, DROP the direct DRY finding entirely (no parallel Minor emit) and re-emit ONE finding under Correctness/Architecture at the appropriate severity, tagged `Lens: DRY (re-attributed)` on its own line. Direct and re-attributed are mutually exclusive.
#### SRP
- **Flag:** new function/class doing two clearly separable jobs (parse+emit, validate+persist, routing+business rules). Module-level mixing surfaces as **architectural observation only** and NEVER displaces DRY on co-fire.
- **Do not flag:** existing units (lens applies to NEW or substantially-rewritten units); deliberately-coupled convenience helpers (e.g., `parse_and_validate`).
- **Severity ceiling:** Minor (or Suggestion). Function- and class-level SRP findings are primary; module-level is architectural observation only.
#### OCP
- **Flag (ALL must hold):** a NEW `elif`/`case`/`match` arm added to a chain dispatching on a discriminator (string tag, enum, type name) when a registry/strategy table for the same discriminator exists elsewhere AND the OCP finding's `File:` lines explicitly include the registry file path (use a second `File:` line if needed). If the cited registry can't be located, DROP the finding.
- **Carve-out:** OCP is the ONLY lens permitted to cite a file outside the diff (the registry).
- **Do not flag:** chains with no existing registry; chains dispatching on non-discriminator values (e.g., `if x > threshold`). L/I/D are out of scope.
- **Severity ceiling:** Minor (or Suggestion).
**Co-fire precedence table:**
| Co-fire condition | Attribute to |
|---|---|
| Surgical Changes triggers + any other lens (same lines) | Surgical Changes |
| Function-SRP fully contains DRY block | SRP |
| Class-SRP fully contains DRY block | SRP |
| Module-SRP overlaps DRY (any extent) | DRY (module-SRP surfaced separately as architectural observation) |
| SRP and DRY apply, SRP unit does NOT contain DRY block | DRY |
| OCP and any other lens | Both fire independently (OCP carve-out is structural, not overlapping) |
Finding format addendum: add `Lens: Surgical | DRY | SRP | OCP` — required when finding originates from a Targeted Lens; omit otherwise. Re-attributed findings use `Lens: <name> (re-attributed)` on its own line immediately after Severity:.
**Wiring:**
- Is new code actually connected to the rest of the system?
- Are registrations, event subscriptions, and DI bindings in place?
- Would this actually work at runtime, or just compile?
Report Pass 1 findings before proceeding to Pass 2.
### Pass 2: Test Quality Review
Now review the TESTS for quality. Note: staleness checks (stale tests,
tests to update, dead tests) are handled by crucible:test-coverage after
this review. Focus on test QUALITY here:
**Missing Coverage:**
- Are there new code paths without test coverage?
- Are edge cases visible in the implementation but untested?
- Are error paths tested?
**Test Quality:**
- Tests actually test behavior (not just mock interactions)?
- Edge cases covered?
- Integration tests where needed? (Are complex mock setups masking the need for one?)
- All tests passing?
- Tests are independent and deterministic?
- Tests follow AAA pattern (Arrange, Act, Assert)?
**Test Level:**
- Are there multi-component behaviors tested only at the unit level?
- Should any of these have integration tests instead of (or in addition to) unit tests?
<!-- CANONICAL: shared/reviewer-common.md — Review Checklist (TDD Process Evidence) -->
**TDD Process Evidence:**
- Does the implementer's TDD log list a failure message for each test?
- Do the failure messages make sense (indicate missing feature, not typo/setup error)?
- Does the git history show test-then-implementation ordering?
- If the TDD log is missing or vague, flag it: "TDD log incomplete, cannot verify red-green process"
**Refactor Mode Evidence (when applicable):**
- If the task is marked `atomic: true` or annotated as pure restructuring, the implementer produces a **Refactoring Evidence Log** instead of a TDD Evidence Log. This is valid — do not flag it as "TDD log incomplete."
- The Refactoring Evidence Log must show:
- Pre-change test count and baseline commit SHA
- Description of structural changes made
- Post-change test count (same or higher — never lower)
- All blast-radius + direct consumer tests GREEN
- Verify that post-change test count >= pre-change test count
- If the task mixes restructuring with new abstractions, BOTH a Refactoring Evidence Log (for the restructuring) and TDD Evidence Log entries (for the new abstractions) should be present
- Do NOT flag the absence of a RED phase on GREEN-GREEN tasks
Report Pass 2 findings.
<!-- CANONICAL: shared/reviewer-common.md — Issue Classification -->
## Issue Classification
**Per-issue severity levels:**
- **Critical (Must Fix):** Bugs, security issues, data loss risks, broken functionality. The code cannot ship with these.
- **Important (Should Fix):** Architecture problems, missing error handling, test gaps, missing features from the spec. These materially affect quality or correctness.
- **Minor (Nice to Have):** Code style, optimization opportunities, documentation improvements. These improve polish but don't affect correctness.
- **Suggestion:** Not an issue per se -- ideas for future improvement, alternative approaches worth considering.
**Overall verdict levels:**
- **Clean:** No issues found. Code is ready to merge.
- **Issues Found:** Specific problems identified that need fixing before merge.
- **Architectural Concern:** Fundamental design issue that may require rethinking the approach. Escalate to lead immediately.
<!-- CANONICAL: shared/reviewer-common.md — Report Format -->
## Report Format
**For each issue found:**
- File:line reference, in the exact format `File: <path>:<line>` or `File: <path>:<lo>-<hi>` (numeric line refs from diff hunks — function/class names are NOT acceptable substitutes)
- What's wrong
- Why it matters
- Severity classification
- Lens: Surgical | DRY | SRP | OCP — required when finding originates from a Targeted Lens; omit otherwise. Re-attributed findings use 'Lens: <name> (re-attributed)' on its own line immediately after Severity:.
- Category: Tenancy | Rollback — required when finding originates from a Tenancy or Rollback discipline section; omit otherwise. Mutually exclusive with `Lens:` (do not emit both on the same finding).
- How to fix (if not obvious)
### Pass 1: Code Review
- **Verdict:** Clean | Issues found | Architectural concern
- **Issues:** [Specific findings with file:line references]
- **Architectural concerns:** [If any — immediate escalation]
### Pass 2: Test Quality Review
- **Verdict:** Clean | Issues found
- **TDD process:** Verified | Incomplete log | No evidence
- **Missing coverage:** [List with specific code paths]
- **Test quality issues:** [List — independence, determinism, mock overuse, wrong test level]
<!-- CONTRACT:preflight:START — check_canonical_drift.py: a Pre-flight prerequisite-walk block present in BOTH canonical + build-paraphrase; prose inside is free -->
### Pre-flight
Always emit. If this PR were deployed right now, what must be true for it to actually deliver its claimed feature? List prerequisites — config, env vars, schema/migrations, downstream services, feature flags — as dash bullets, verifying each against the diff or marking it **MISSING**. If self-contained, state: "No external prerequisites — change is self-contained."
<!-- CONTRACT:preflight:END -->
### Overall
- **Combined verdict:** Approved | Needs fixes (list them) | Escalate
### Recommendations
[Improvements for code quality, architecture, or process]
### Assessment
Ready to merge? [Yes / No / With fixes]
Reasoning: [Technical assessment in 1-2 sentences]
```
cleanup-prompt.md
<!-- DISPATCH: disk-mediated | This template is written to a dispatch file,
not pasted into the Agent tool prompt. See shared/dispatch-convention.md -->
# Cleanup Agent Prompt Template
Use this template when dispatching a de-sloppify cleanup agent in Phase 3.
```
Task tool (general-purpose, model: opus):
description: "De-sloppify cleanup for task N"
prompt: |
You are a cleanup agent. Your job is to review the implementer's changes and remove unnecessary code that adds complexity without value.
## Changes to Review
Review all changes committed by the implementer for this task.
Use `git diff <pre-task-sha>..HEAD` to see the full diff.
The pre-task commit SHA is: [PROVIDED BY ORCHESTRATOR]
## Removal Categories (Explicit Allowlist)
You may ONLY remove code that falls into these categories:
1. **Over-defensive error handling for impossible states** — Checks for conditions that cannot occur given the type system, control flow, or framework guarantees
2. **Tests that verify language/framework behavior** — Tests that assert how C#/Unity/the framework works rather than testing business logic
3. **Redundant type checks the type system already enforces** — Runtime checks that duplicate compile-time guarantees
4. **Commented-out code** — Dead code left in comments
5. **Debug logging** — Console.log, Debug.Log, print statements added during development
6. **Dead compatibility shims (refactor mode only)** — Adapter code, re-export aliases, or temporary compatibility layers introduced during refactoring that are no longer referenced. Detection scope: code added after the baseline commit SHA (provided by orchestrator) that re-exports, aliases, or wraps symbols under old names, AND where no code outside the refactoring's changed files references the old names. **String-based references:** If the refactoring target was registered by name in any configuration system (DI containers, serialization configs, URL routing tables, reflection lookups), flag the shim as UNCERTAIN and defer to the reviewer rather than removing it. The baseline commit SHA is: [PROVIDED BY ORCHESTRATOR — only present in refactor mode]
## Paired Removal Rule
You CAN remove test+code pairs together. This is critical — unnecessary code often has unnecessary tests guarding it. But you MUST:
- Justify each paired removal specifically in the removal log
- Explain why BOTH the code AND its test are unnecessary
- Categorize the removal into one of the 6 categories above
## When in Doubt
If a removal doesn't clearly fit one of the 6 categories, do NOT remove it. Instead, flag it in the removal log for the reviewer to decide:
```
FLAGGED: [file:line] — [what you'd remove] — [why you think it's unnecessary] — [why you're not sure]
```
## Process
1. Review the diff
2. Identify removals (must fit a category)
3. Remove code and/or test+code pairs
4. Run the full test suite after EACH removal
5. If tests fail: PUT IT BACK immediately
6. Commit all successful removals: `refactor: cleanup task N implementation`
## Report Format (Removal Log)
```
REMOVAL LOG
===========
Removed:
- [file:line] — [what was removed] — Category: [1-6] — [one-line justification]
- [file:line + test_file:line] — [paired removal] — Category: [1-6] — [justification for both]
Flagged for reviewer:
- [file:line] — [description] — [uncertainty reason]
Test suite: PASS (N tests, 0 failures)
Total removals: X code, Y tests, Z paired
```
## What You Must NOT Do
- Remove code that doesn't fit a category (even if you think it's ugly)
- Remove tests without removing the code they test (unless the test tests framework behavior)
- Skip running the test suite
- Make "improvements" or refactoring beyond removal
- Add new code
```
contract-test-writer-prompt.md
<!-- DISPATCH: disk-mediated | This template is written to a dispatch file,
not pasted into the Agent tool prompt. See shared/dispatch-convention.md -->
# Contract Test Writer Prompt Template
Use this template when dispatching a contract test writer subagent in refactor-mode Phase 1. These tests lock existing behavior before refactoring begins — the build pipeline starts GREEN and must stay GREEN throughout.
This is NOT a variant of `acceptance-test-writer-prompt.md`. It has different inputs (impact manifest + blast radius file list) and a different goal (lock current behavior, not define new behavior).
```
Task tool (general-purpose, model: opus):
description: "Write contract tests for [target] refactoring"
prompt: |
You are writing contract tests to lock existing behavior BEFORE a
refactoring begins. These tests capture what the code does NOW, so
that any refactoring step that breaks existing behavior is caught
immediately.
## Impact Manifest
[FULL TEXT of the impact manifest from blast radius analysis — paste it here]
## Blast Radius File List
[FULL LIST of files in the blast radius — paste file paths here]
## Project Conventions
[Test framework, test location, naming conventions, DI framework, etc.]
## Your Job
Perform three steps in order:
### Step 1: Map Existing Tests to Behavioral Seams
Read the test files that already exist for the target and its consumers.
For each behavioral seam in the blast radius, identify whether an existing
test already covers it. A "behavioral seam" is any point where the target
code's behavior is observable by a consumer — method calls, return values,
side effects, error conditions, event emissions, state transitions.
Output a mapping:
- Seam: [description] → Covered by: [test name] or UNCOVERED
### Step 2: Identify Untested Seams (Gaps)
From the mapping above, list every UNCOVERED seam. These are the gaps
where refactoring could silently break behavior.
For each gap, note:
- What behavior the seam exercises
- Which consumers depend on this behavior
- Why it matters for the refactoring (what could break)
### Proportionality Check
Before writing tests, count the gaps. If any of the following are true,
STOP and report to the orchestrator before writing tests:
- More than 15 contract tests would be needed
- You are approaching context limits
- Estimated total contract test LOC exceeds ~2x the estimated
refactoring scope LOC
The orchestrator will present the gap list to the user for prioritization.
### Step 3: Write Contract Tests for Gaps
For each identified gap, write a contract test that locks the CURRENT
behavior. Critical rules:
**Lock current behavior, not desired behavior:**
- If the code returns null on invalid input, your test asserts null — even
if that seems like a bug. You are locking what EXISTS, not what SHOULD exist.
- If the code silently swallows an exception, your test asserts no exception —
even if that seems wrong.
**Do not write tests for already-covered seams:**
- If Step 1 shows a seam is already tested, skip it. Do not duplicate coverage.
**Test naming convention:**
- Name tests to indicate they are contract tests:
`ContractTest_[Target]_[Seam]_[ExpectedBehavior]`
**Test quality:**
- Use real components where possible, not mocks
- Each test should be independent and deterministic
- Follow project test conventions
- Test at the behavioral boundary (consumer-facing), not internal implementation
### Running the Tests
After writing all contract tests:
1. Run them ALL
2. Every contract test MUST pass GREEN — you are locking existing behavior
3. If a contract test FAILS, investigate:
- **Test defect** (wrong assertion, bad setup, misunderstood seam):
Fix the test and re-run.
- **Latent codebase bug** (the existing code genuinely doesn't match
expected behavior): Report to the user with three options:
(a) Fix the bug first before proceeding with the refactoring
(b) Exclude this seam from contract test coverage and accept the risk
(c) Abort the refactoring entirely
- NEVER silently drop a failing contract test or adjust its assertion
to match unexpected behavior without reporting to the user.
## Output
- Seam mapping (Step 1 results)
- Gap list (Step 2 results)
- Contract test file(s) with all new tests
- Per-test run results (test name: PASS or FAIL with details)
- Summary: N seams found, M already covered, K contract tests written,
all GREEN / X failures reported
- Commit with message: `test: add contract tests for [target] refactoring (GREEN — locking existing behavior)`
```
evals/__init__.py
evals/conftest.py
"""Make the parent package importable when pytest is run from any cwd.
Also exclude fixtures/ from pytest collection — fixture seeds contain intentionally
buggy tests (e.g. b3-bugfix's seed/tests/test_tax.py is RED by design) that must
not be run as part of the harness's own self-tests.
"""
import sys
from pathlib import Path
_HERE = Path(__file__).resolve().parent
_REPO_ROOT = _HERE.parents[2] # skills/build/evals -> repo root
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
collect_ignore_glob = ["fixtures/*"]
evals/expectations.py
"""Expectation checkers for build-evals.
Each expectation is a dict with a 'type' key plus type-specific fields. Pluggable
dispatch via _CHECKERS. New types can be added in v0.2 without touching call sites.
"""
from __future__ import annotations
import ast
import json
import re
import subprocess
from dataclasses import dataclass
from pathlib import Path
@dataclass
class CheckContext:
"""Runtime context passed to every expectation checker."""
workdir: Path # the staged project root (where source files live)
manifest_path: Path | None # build's manifest.jsonl on disk, if reachable
gate_ledger_path: Path | None # build's build-gate-ledger.md, if reachable
git_repo: Path | None # repo root for git diff operations (usually == workdir)
baseline_sha_file: Path | None # <workdir>/.eval-baseline-sha (written by stage)
@dataclass
class CheckResult:
passed: bool
detail: str
def check(expectation: dict, ctx: CheckContext) -> CheckResult:
etype = expectation.get("type")
if etype is None:
return CheckResult(False, "expectation missing 'type' field")
fn = _CHECKERS.get(etype)
if fn is None:
return CheckResult(False, f"unknown expectation type: {etype!r}")
try:
return fn(expectation, ctx)
except Exception as e: # noqa: BLE001 — defensive: an expectation crash should not bring down score()
return CheckResult(False, f"expectation {etype!r} crashed: {e!r}")
# ---------------- individual checkers ----------------
def _file_exists(exp: dict, ctx: CheckContext) -> CheckResult:
p = ctx.workdir / exp["path"]
return CheckResult(p.exists(), f"{exp['path']} {'exists' if p.exists() else 'MISSING'}")
def _file_does_not_exist(exp: dict, ctx: CheckContext) -> CheckResult:
p = ctx.workdir / exp["path"]
return CheckResult(not p.exists(), f"{exp['path']} {'absent' if not p.exists() else 'UNEXPECTEDLY PRESENT'}")
def _file_contains(exp: dict, ctx: CheckContext) -> CheckResult:
p = ctx.workdir / exp["path"]
if not p.exists():
return CheckResult(False, f"{exp['path']} missing (cannot check pattern)")
body = p.read_text(errors="replace")
found = exp["pattern"] in body
return CheckResult(found, f"pattern {exp['pattern']!r} {'found' if found else 'NOT FOUND'} in {exp['path']}")
def _function_defined(exp: dict, ctx: CheckContext) -> CheckResult:
"""Match top-level FunctionDef, AsyncFunctionDef, ClassDef, or methods within any ClassDef.
Python-only for v0.1. Non-Python files raise SyntaxError which is caught upstream.
"""
p = ctx.workdir / exp["file"]
if not p.exists():
return CheckResult(False, f"{exp['file']} missing")
src = p.read_text(errors="replace")
try:
tree = ast.parse(src, filename=str(p))
except SyntaxError as e:
return CheckResult(False, f"{exp['file']} did not parse: {e}")
name = exp["name"]
found = False
for node in ast.walk(tree):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) and node.name == name:
found = True
break
return CheckResult(found, f"name {name!r} {'defined' if found else 'NOT defined'} in {exp['file']}")
def _read_manifest(ctx: CheckContext) -> list[dict]:
"""Read manifest.jsonl, skipping malformed lines."""
if ctx.manifest_path is None or not ctx.manifest_path.exists():
return []
out: list[dict] = []
for line in ctx.manifest_path.read_text().splitlines():
line = line.strip()
if not line:
continue
try:
out.append(json.loads(line))
except json.JSONDecodeError:
continue
return out
def _dispatch_matches_skill(entry: dict, skill: str) -> bool:
"""Match a manifest entry against a skill identifier.
Field naming in build's manifest.jsonl is not contractually fixed; check several
plausible fields (template, skill, dispatch_template, type) and substring-match
so 'plan-writer-prompt.md' satisfies 'plan-writer'.
"""
for key in ("template", "skill", "dispatch_template", "type"):
v = entry.get(key)
if isinstance(v, str) and skill in v:
return True
return False
def _manifest_contains_dispatch(exp: dict, ctx: CheckContext) -> CheckResult:
entries = _read_manifest(ctx)
n = sum(1 for e in entries if _dispatch_matches_skill(e, exp["skill"]))
cmin = int(exp.get("count_min", 1))
cmax = int(exp.get("count_max", 1_000_000))
ok = cmin <= n <= cmax
return CheckResult(ok, f"dispatch {exp['skill']!r} count={n} expected={cmin}..{cmax}")
def _manifest_does_not_contain(exp: dict, ctx: CheckContext) -> CheckResult:
entries = _read_manifest(ctx)
n = sum(1 for e in entries if _dispatch_matches_skill(e, exp["skill"]))
return CheckResult(n == 0, f"dispatch {exp['skill']!r} count={n} (expected 0)")
def _gate_ledger_phase_status(exp: dict, ctx: CheckContext) -> CheckResult:
if ctx.gate_ledger_path is None or not ctx.gate_ledger_path.exists():
return CheckResult(False, "gate ledger absent")
body = ctx.gate_ledger_path.read_text()
# Find the phase block, then look for a Status: line within it.
phase_header = re.compile(rf"^## Phase {re.escape(str(exp['phase']))}:", re.MULTILINE)
m = phase_header.search(body)
if not m:
return CheckResult(False, f"phase {exp['phase']} not found in ledger")
# Scan from match to next "## " or EOF
tail = body[m.end():]
nxt = re.search(r"^## ", tail, re.MULTILINE)
block = tail[: nxt.start()] if nxt else tail
sm = re.search(r"^Status:\s*(\S+)", block, re.MULTILINE)
if not sm:
return CheckResult(False, f"phase {exp['phase']} has no Status: line")
actual = sm.group(1).strip()
expected = exp["status"]
ok = actual == expected
return CheckResult(ok, f"Phase {exp['phase']} Status={actual} expected={expected}")
def _resolve_baseline_sha(exp: dict, ctx: CheckContext) -> str | None:
"""Resolve 'BASELINE' placeholder to the SHA written by stage()."""
sha = exp.get("baseline_sha")
if sha and sha != "BASELINE":
return sha
if ctx.baseline_sha_file and ctx.baseline_sha_file.exists():
return ctx.baseline_sha_file.read_text().strip() or None
return None
def _working_tree_unchanged_from(exp: dict, ctx: CheckContext) -> CheckResult:
sha = _resolve_baseline_sha(exp, ctx)
if sha is None:
return CheckResult(False, "no baseline SHA resolvable (placeholder unresolved)")
if ctx.git_repo is None:
return CheckResult(False, "no git_repo set on context")
try:
# Tracked content vs the baseline SHA. --quiet exits 0 when identical.
diff = subprocess.run(
["git", "diff", "--quiet", sha, "--"],
cwd=ctx.git_repo,
capture_output=True,
text=True,
timeout=30,
)
# Untracked files are invisible to `git diff` — list them separately.
# b4's halt signal is "no source modifications", and an over-eager build
# leaking a brand-new file is the most likely failure shape, so an
# untracked file must count as "changed". Eval-harness scaffolding that
# legitimately lives inside the workdir (the isolated HOME and the
# baseline-SHA marker stage() writes post-commit) is excluded so it does
# not masquerade as a build-leaked file.
untracked = subprocess.run(
[
"git", "ls-files", "--others", "--exclude-standard", "--",
":(exclude).home", ":(exclude).home/**",
":(exclude).eval-baseline-sha",
],
cwd=ctx.git_repo,
capture_output=True,
text=True,
timeout=30,
)
except FileNotFoundError:
return CheckResult(False, "git binary not found")
except subprocess.TimeoutExpired:
return CheckResult(False, "git diff timed out")
untracked_files = [ln for ln in untracked.stdout.splitlines() if ln.strip()]
tracked_unchanged = diff.returncode == 0
unchanged = tracked_unchanged and not untracked_files
if untracked_files:
detail = f"untracked files present: {', '.join(untracked_files[:5])}"
else:
detail = f"git diff vs {sha[:12]} exit={diff.returncode}"
return CheckResult(unchanged, detail)
_CHECKERS = {
"file_exists": _file_exists,
"file_does_not_exist": _file_does_not_exist,
"file_contains": _file_contains,
"function_defined": _function_defined,
"manifest_contains_dispatch": _manifest_contains_dispatch,
"manifest_does_not_contain": _manifest_does_not_contain,
"gate_ledger_phase_status": _gate_ledger_phase_status,
"working_tree_unchanged_from": _working_tree_unchanged_from,
}
evals/fixture_loader.py
"""Fixture loader for build-evals harness.
Loads a fixture directory (fixture.json + seed/ + mock-dispatch/ + optional mock-user-input/)
into a typed Fixture dataclass for the harness to stage and score.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
class FixtureSchemaError(Exception):
pass
@dataclass
class Fixture:
id: str
task: str
expectations: list[dict]
seed_dir: Path
mock_dispatch_dir: Path
mock_user_input_dir: Path | None
mode: str | None
no_mock: bool = False
_REQUIRED_KEYS = {"id", "task", "expectations"}
def load_fixture(fixture_dir: Path) -> Fixture:
"""Load a fixture from disk. Raises FixtureSchemaError on any structural problem."""
fixture_dir = Path(fixture_dir)
fjson = fixture_dir / "fixture.json"
if not fjson.exists():
raise FixtureSchemaError(f"missing fixture.json in {fixture_dir}")
try:
data = json.loads(fjson.read_text())
except json.JSONDecodeError as e:
raise FixtureSchemaError(f"invalid JSON in {fjson}: {e}") from e
if not isinstance(data, dict):
raise FixtureSchemaError(f"fixture.json must be an object, got {type(data).__name__}")
missing = _REQUIRED_KEYS - set(data)
if missing:
raise FixtureSchemaError(f"fixture.json missing required keys: {sorted(missing)}")
if not isinstance(data["expectations"], list):
raise FixtureSchemaError("fixture.json 'expectations' must be a list")
seed = fixture_dir / "seed"
if not seed.is_dir():
raise FixtureSchemaError(f"missing seed/ directory in {fixture_dir}")
mui = fixture_dir / "mock-user-input"
return Fixture(
id=data["id"],
task=data["task"],
expectations=data["expectations"],
seed_dir=seed,
mock_dispatch_dir=fixture_dir / "mock-dispatch",
mock_user_input_dir=mui if mui.is_dir() else None,
mode=data.get("mode"),
no_mock=bool(data.get("no_mock", False)),
)
evals/fixtures/b1-simple-feature/fixture.json
{
"id": "b1-simple-feature",
"task": "Add a function get_user_email(user_id) to src/users.py that returns the email string for a given user id.",
"expectations": [
{"type": "file_exists", "path": "src/users.py"},
{"type": "function_defined", "file": "src/users.py", "name": "get_user_email"},
{"type": "manifest_does_not_contain", "skill": "design"},
{"type": "manifest_contains_dispatch", "skill": "plan-writer", "count_min": 1, "count_max": 2}
],
"_notes": "Probes build's ability to NOT over-ceremonialize. A trivial single-function add should skip design (or design returns 'no design needed'). Expectations focus on the artifact (file + function present) and the orchestration shape (plan-writer ran, design did not). Mock dispatches cover plan-writer, build-implementer, build-reviewer, cleanup, and finish."
}
evals/fixtures/b1-simple-feature/mock-dispatch/build-implementer.md
# Receipt — build-implementer (mocked)
VERDICT: PASS
CLAIMS:
- files-touched: 2
- tests-passing: 1
- file: src/users.py — added get_user_email(user_id)
- file: src/test_users.py — added test_get_user_email_returns_string
WITNESS: kind=exec; ran=TRACE#3
TRACE:
1: wrote failing test (test_users.py)
2: ran pytest → FAIL (expected)
3: implemented get_user_email in src/users.py
4: ran pytest → PASS
EDIT: src/users.py:1-6:abc123
EDIT: src/test_users.py:1-5:def456
TRIPWIRE: claims-touch(src/users.py), wrote(src/users.py)
SUPERSEDES:
evals/fixtures/b1-simple-feature/mock-dispatch/build-reviewer.md
# Receipt — build-reviewer (mocked)
VERDICT: PASS
CLAIMS:
- findings-count: 0
WITNESS: kind=read; ran=TRACE#1
TRACE:
1: read src/users.py and src/test_users.py; no issues
TRIPWIRE: always
SUPERSEDES:
No issues. Implementation is minimal and correct for the task description.
evals/fixtures/b1-simple-feature/mock-dispatch/cleanup.md
# Receipt — cleanup (mocked)
VERDICT: PASS
CLAIMS:
- removed: 0
- recommendation: No cleanup needed
WITNESS: kind=read; ran=TRACE#1
TRACE: 1: read diff src/users.py + src/test_users.py; no unnecessary code
TRIPWIRE: always
SUPERSEDES:
evals/fixtures/b1-simple-feature/mock-dispatch/finish.md
# Receipt — finish (mocked)
VERDICT: PASS
CLAIMS:
- finish-mode: dry-run completed
- pr-created: false
- merge-attempted: false
WITNESS: kind=read; ran=TRACE#1
TRACE: 1: dry-run summary; finish skipped in eval-gate mode
TRIPWIRE: always
SUPERSEDES:
This is a dry-run completion (eval-gate mode). No PR was created; no merge was attempted.
evals/fixtures/b1-simple-feature/mock-dispatch/plan-writer.md
# Receipt — plan-writer (mocked)
VERDICT: PASS
CLAIMS:
- plan-task-count: 1
- plan-saved-at: docs/plans/2026-05-28-get-user-email-implementation-plan.md
WITNESS: kind=read; ran=TRACE#1
TRACE: 1: read design doc; 2: wrote implementation plan
TRIPWIRE: always
SUPERSEDES:
## Plan (summary)
### Task 1: Implement get_user_email
**Files:**
- Modify: `src/users.py` — add `get_user_email(user_id)`
- Create: `src/test_users.py` — test the new function
**Step 1.1: Write failing test**
```python
def test_get_user_email_returns_string():
assert isinstance(get_user_email(1), str)
```
**Step 1.2: Implement minimal**
```python
def get_user_email(user_id):
return f"user{user_id}@example.com"
```
**Step 1.3: Verify + commit**
evals/fixtures/b1-simple-feature/seed/src/__init__.py
evals/fixtures/b1-simple-feature/seed/src/users.py
"""User-related helpers.
Seed file for b1-simple-feature: build should add `get_user_email(user_id)` here.
"""
evals/fixtures/b2-multi-file/fixture.json
{
"id": "b2-multi-file",
"task": "Add a UserService class that depends on a new UserRepository. Both go in src/users/. Wire them together.",
"expectations": [
{"type": "file_exists", "path": "src/users/service.py"},
{"type": "file_exists", "path": "src/users/repository.py"},
{"type": "function_defined", "file": "src/users/service.py", "name": "UserService"},
{"type": "function_defined", "file": "src/users/repository.py", "name": "UserRepository"},
{"type": "file_contains", "path": "src/users/service.py", "pattern": "UserRepository"},
{"type": "manifest_contains_dispatch", "skill": "design", "count_min": 1, "count_max": 2},
{"type": "manifest_contains_dispatch", "skill": "build-implementer", "count_min": 1, "count_max": 4}
],
"_notes": "Probes plan dependency ordering (repository before service) and multi-file orchestration. Expects design to fire (non-trivial), then plan + implementer dispatches. file_contains check confirms wiring: service references the repository."
}
evals/fixtures/b2-multi-file/mock-dispatch/build-implementer.md
# Receipt — build-implementer (mocked, generic; reused across tasks)
VERDICT: PASS
CLAIMS:
- files-touched: 2
- tests-passing: 1
WITNESS: kind=exec; ran=TRACE#3
TRACE:
1: wrote test for the task's target class
2: ran pytest → expected RED
3: implemented class; pytest → PASS
EDIT: src/users/__init__.py:1-0:empty1
EDIT: src/users/repository.py:1-10:repoaa
EDIT: src/users/service.py:1-12:svcbbb
TRIPWIRE: claims-touch(src/users/**), wrote(src/users/**)
SUPERSEDES:
Per the task: implemented the target class with minimal behavior. For service.py, UserService takes a UserRepository in __init__ — wiring is established.
evals/fixtures/b2-multi-file/mock-dispatch/build-reviewer.md
# Receipt — build-reviewer (mocked, generic)
VERDICT: PASS
CLAIMS:
- findings-count: 0
WITNESS: kind=read; ran=TRACE#1
TRACE: 1: read implementer diff; dependency order respected (repository defined before service)
TRIPWIRE: always
SUPERSEDES:
evals/fixtures/b2-multi-file/mock-dispatch/cleanup.md
# Receipt — cleanup (mocked)
VERDICT: PASS
CLAIMS:
- removed: 0
- recommendation: No cleanup needed
WITNESS: kind=read; ran=TRACE#1
TRACE: 1: read diff src/users.py + src/test_users.py; no unnecessary code
TRIPWIRE: always
SUPERSEDES:
evals/fixtures/b2-multi-file/mock-dispatch/design.md
# Receipt — design (mocked)
VERDICT: PASS
CLAIMS:
- design-doc-path: docs/plans/2026-05-28-user-service-design.md
- dec-count: 2
WITNESS: kind=read; ran=TRACE#1
TRACE: 1: read user request; 2: composed design with UserService + UserRepository decomposition
TRIPWIRE: always
SUPERSEDES:
## Design (summary)
DEC-1: UserRepository owns persistence; UserService owns business logic.
DEC-2: Service depends on repository via constructor injection — repository must exist before service.
Acceptance criteria:
- `src/users/repository.py` defines `UserRepository` (data access)
- `src/users/service.py` defines `UserService` (takes a UserRepository in __init__)
evals/fixtures/b2-multi-file/mock-dispatch/finish.md
# Receipt — finish (mocked)
VERDICT: PASS
CLAIMS:
- finish-mode: dry-run completed
- pr-created: false
- merge-attempted: false
WITNESS: kind=read; ran=TRACE#1
TRACE: 1: dry-run summary; finish skipped in eval-gate mode
TRIPWIRE: always
SUPERSEDES:
This is a dry-run completion (eval-gate mode). No PR was created; no merge was attempted.
evals/fixtures/b2-multi-file/mock-dispatch/plan-writer.md
# Receipt — plan-writer (mocked)
VERDICT: PASS
CLAIMS:
- plan-task-count: 2
- plan-saved-at: docs/plans/2026-05-28-user-service-implementation-plan.md
- task-dependency: Task 2 (service) depends on Task 1 (repository)
WITNESS: kind=read; ran=TRACE#2
TRACE: 1: read design; 2: wrote plan with repository → service dependency order
TRIPWIRE: always
SUPERSEDES:
## Plan (summary)
### Task 1: Implement UserRepository (must land first)
- Create: `src/users/__init__.py`
- Create: `src/users/repository.py` — `class UserRepository`
### Task 2: Implement UserService (depends on Task 1)
- Create: `src/users/service.py` — `class UserService` accepting a `UserRepository` in `__init__`
evals/fixtures/b2-multi-file/seed/src/__init__.py
evals/fixtures/b3-bugfix/fixture.json
{
"id": "b3-bugfix",
"task": "Existing test test_compute_tax_with_discount fails because compute_tax ignores the discount kwarg. Fix it.",
"mode": "refactor",
"expectations": [
{"type": "manifest_contains_dispatch", "skill": "contract-test-writer", "count_min": 1, "count_max": 3},
{"type": "manifest_does_not_contain", "skill": "acceptance-test-writer"},
{"type": "file_contains", "path": "src/tax.py", "pattern": "discount"}
],
"_notes": "v0.1 does not assert 'no new files'. `working_tree_unchanged_from` with an except_paths arg is a v0.2 enhancement (filed at /finish). The manifest_does_not_contain assertion on acceptance-test-writer (paired with contract-test-writer being dispatched) proves build entered refactor mode (not feature mode), which is the targeted regression."
}
evals/fixtures/b3-bugfix/mock-dispatch/build-implementer.md
# Receipt — build-implementer (mocked, refactor mode)
VERDICT: PASS
CLAIMS:
- files-touched: 1
- tests-passing: 1 (tests/test_tax.py)
WITNESS: kind=exec; ran=TRACE#3
TRACE:
1: ran pytest tests/test_tax.py → RED (existing failure)
2: edited src/tax.py: return (amount - discount) * rate
3: ran pytest tests/test_tax.py → GREEN
EDIT: src/tax.py:1-12:taxfix
TRIPWIRE: claims-touch(src/tax.py), wrote(src/tax.py)
SUPERSEDES:
Fixed compute_tax to subtract discount before applying rate. Single-file targeted fix; no new files.
evals/fixtures/b3-bugfix/mock-dispatch/build-reviewer.md
# Receipt — build-reviewer (mocked)
VERDICT: PASS
CLAIMS:
- findings-count: 0
WITNESS: kind=read; ran=TRACE#1
TRACE:
1: read src/users.py and src/test_users.py; no issues
TRIPWIRE: always
SUPERSEDES:
No issues. Implementation is minimal and correct for the task description.
evals/fixtures/b3-bugfix/mock-dispatch/cleanup.md
# Receipt — cleanup (mocked)
VERDICT: PASS
CLAIMS:
- removed: 0
- recommendation: No cleanup needed
WITNESS: kind=read; ran=TRACE#1
TRACE: 1: read diff src/users.py + src/test_users.py; no unnecessary code
TRIPWIRE: always
SUPERSEDES:
evals/fixtures/b3-bugfix/mock-dispatch/contract-test-writer.md
# Receipt — contract-test-writer (mocked, refactor mode)
VERDICT: PASS
CLAIMS:
- contract-tests-existing: 1 (tests/test_tax.py)
- contract-tests-added: 0
- coverage-gaps: 0
WITNESS: kind=exec; ran=TRACE#2
TRACE:
1: identified existing test coverage for compute_tax
2: confirmed test exists and exercises the discount-kwarg seam
TRIPWIRE: always
SUPERSEDES:
The seam (compute_tax with discount kwarg) is already covered by tests/test_tax.py. Currently the test is RED because of the bug; that's by design for this fixture. After the implementer fix lands, this same test will be the GREEN contract.
evals/fixtures/b3-bugfix/mock-dispatch/finish.md
# Receipt — finish (mocked)
VERDICT: PASS
CLAIMS:
- finish-mode: dry-run completed
- pr-created: false
- merge-attempted: false
WITNESS: kind=read; ran=TRACE#1
TRACE: 1: dry-run summary; finish skipped in eval-gate mode
TRIPWIRE: always
SUPERSEDES:
This is a dry-run completion (eval-gate mode). No PR was created; no merge was attempted.
evals/fixtures/b3-bugfix/seed/src/__init__.py
evals/fixtures/b3-bugfix/seed/src/tax.py
"""Tax computation.
BUG (intentional, for b3 fixture): compute_tax ignores the discount kwarg.
The seed test in tests/test_tax.py asserts compute_tax(100, 0.1, discount=10) == 9
which fails until build's bugfix lands.
"""
def compute_tax(amount, rate, discount=0):
# FIXME: should subtract discount before applying rate
return amount * rate
evals/fixtures/b3-bugfix/seed/tests/__init__.py
evals/fixtures/b3-bugfix/seed/tests/test_tax.py
"""Failing test exercising the b3 bug. build should fix src/tax.py so this passes."""
from src.tax import compute_tax
def test_compute_tax_with_discount():
# (amount - discount) * rate == 90 * 0.1 == 9
assert compute_tax(100, 0.1, discount=10) == 9
evals/fixtures/b4-design-required/fixture.json
{
"id": "b4-design-required",
"task": "Add caching to the user lookup path.",
"mode": "feature",
"expectations": [
{"type": "manifest_contains_dispatch", "skill": "design", "count_min": 1, "count_max": 2},
{"type": "file_does_not_exist", "path": "handoff-1-to-2.md"},
{"type": "file_does_not_exist", "path": "src/users/cache.py"},
{"type": "working_tree_unchanged_from", "baseline_sha": "BASELINE"}
],
"_notes": "Deliberately ambiguous task. design dispatch returns NEEDS_CLARIFICATION; mock-user-input/ is empty so build's AskUserQuestion in Phase 1 raises MockUserInputMissing and halts before Phase 2. PASS signal: design dispatched + no phase-2 handoff + no source modifications. False-pass mode (pre-design crash) caught by the manifest_contains_dispatch expectation."
}
evals/fixtures/b4-design-required/mock-dispatch/design.md
# Receipt — design (mocked, b4)
VERDICT: NEEDS_CLARIFICATION
CLAIMS:
- ambiguity-count: 3
WITNESS: kind=read; ran=TRACE#1
TRACE: 1: read user request; insufficient information to choose a caching strategy
TRIPWIRE: always
SUPERSEDES:
The request "Add caching to the user lookup path" is ambiguous on three load-bearing
dimensions. Cannot proceed to plan without clarification:
1. **Cache implementation:** in-memory dict, `functools.lru_cache`, or external (Redis/Memcached)?
2. **TTL semantics:** is staleness acceptable? If so, what window? If not, what's the invalidation trigger?
3. **Scope:** per-process cache, per-request cache, or shared across processes/instances?
Recommend halting Phase 1 here. Ask the user to specify each of (1)-(3) before generating a plan.
Build orchestrator: this verdict means you should invoke AskUserQuestion. In eval-gate mode
the user-input directory is empty by design — that absence IS the test signal. Halt before
writing a phase-1-to-2 handoff manifest; do NOT proceed to Phase 2.
evals/fixtures/b4-design-required/mock-user-input/.gitkeep
# Intentionally empty: see b4-design-required/fixture.json _notes.
#
# When build's mock-mode AskUserQuestion call hits this directory and finds no
# turn-1.md, it raises MockUserInputMissing inside build's process — causing
# build to halt before Phase 2. That halt is the b4 PASS signal, detected by
# the harness via on-disk artifact absence (no handoff-1-to-2.md, no source
# modifications), not by catching the exception across the runtime boundary.
evals/fixtures/b4-design-required/seed/src/__init__.py
evals/fixtures/b4-design-required/seed/src/users.py
"""User-lookup module — no caching yet (intentional for b4)."""
def lookup_user(user_id):
# placeholder: real DB lookup in production
return {"id": user_id, "email": f"user{user_id}@example.com"}
evals/fixtures/smoke-no-mock/fixture.json
{
"id": "smoke-no-mock",
"task": "Add a function add(a, b) to src/math.py that returns a + b.",
"no_mock": true,
"expectations": [
{"type": "file_exists", "path": "src/math.py"},
{"type": "function_defined", "file": "src/math.py", "name": "add"}
],
"_notes": "Smoke fixture: verifies build's Mock Dispatch Mode is a TRUE no-op when CRUCIBLE_BUILD_EVAL_MOCK_DIR is unset. Runs build with real subagent dispatches against a trivial task. k=1 (not part of k=3 majority); excluded from run-all by default; opt-in via --include-smoke. Cost: ~50-100K tokens, ~10-30 minutes wall-clock. If this fixture FAILS, Task 2's SKILL.md edit broke production behavior — ROLLBACK Task 2 before continuing."
}
evals/fixtures/smoke-no-mock/README.md
# smoke-no-mock fixture
**Purpose:** verify the build skill's Mock Dispatch Mode (added in Task 2) is a true no-op when `CRUCIBLE_BUILD_EVAL_MOCK_DIR` is unset. This fixture genuinely runs build with real subagent dispatches, against a trivial single-file task. If it fails, Task 2's SKILL.md edit broke production behavior and must be rolled back before any other fixture work proceeds.
**Why it matters:** Task 2 is the highest-risk task in the #304 plan because it modifies the build orchestrator that runs every feature development session. Mocked fixtures (b1-b4) cannot prove that the *unmocked* path still works — by construction, they never exercise it. The smoke fixture is the only verification that production behavior is preserved.
**Usage:**
```sh
# Stage a workdir (no env vars in the returned dict)
python -m skills.build.evals.run_evals stage --fixture smoke-no-mock
# In a fresh shell with HOME set to the returned workdir/.home:
cd <workdir>
export HOME=<workdir>/.home
/build "Add a function add(a, b) to src/math.py that returns a + b."
# After build completes:
python -m skills.build.evals.run_evals score --fixture smoke-no-mock --build-output <workdir>
```
PASS requires both expectations: `src/math.py` exists and a callable `add` symbol is defined in it.
**Wall-clock cost:** ~10-30 minutes for the build run itself (full pipeline against a trivial task, real subagent dispatches). Run k=1 — replicates are not justified at this cost for a no-op-preservation check.
evals/fixtures/smoke-no-mock/seed/src/__init__.py
evals/minimalism-ladder/conftest.py
"""Pytest config for the minimalism-ladder eval.
The dir name is hyphenated (`minimalism-ladder`, the design's committed home),
which is not a valid dotted package path — so this conftest puts the harness dir
itself on sys.path. The harness modules (`loc`, `scorer`, `decision`, `tasks`)
are imported as top-level names. Fixture solution dirs are kept out of pytest
collection (they hold standalone solution.py / test_solution.py files that are
*inputs* to the scorer, not tests of this harness).
"""
import sys
from pathlib import Path
_HERE = Path(__file__).resolve().parent
if str(_HERE) not in sys.path:
sys.path.insert(0, str(_HERE))
collect_ignore_glob = ["fixtures/*"]
evals/minimalism-ladder/decision.py
"""Decision rule: turn paired WITH/WITHOUT trial results into a verdict.
`decide(with_results, without_results, *, band="iqr") -> {adopt|skip|reject|expand}`
The step ordering is load-bearing — reject precedes every skip, the 0-LOC floor
guard sits AFTER reject (so a degenerate arm that also fails the carve-out gate
still surfaces as reject), and the borderline check precedes the plain
band-overlap skip. Bands default to IQR (Q3 of WITH vs Q1 of WITHOUT); the
`minmax` alternative uses WITH's max vs WITHOUT's min. Bands are SEPARATED iff
`WITH_Q3 < WITHOUT_Q1`, and TOUCH/OVERLAP iff `WITH_Q3 >= WITHOUT_Q1`.
"""
from __future__ import annotations
import statistics
from typing import List
REDUCTION_THRESHOLD = 0.15
def _median_loc(results) -> float:
return statistics.median(r.non_test_source_loc for r in results)
def _mean_pass_rate(results) -> float:
return statistics.mean(r.assertion_pass_rate for r in results)
def _band_bounds(results, band: str):
"""Return (lower_bound, upper_bound) of the arm's LOC band."""
locs = [r.non_test_source_loc for r in results]
if band == "minmax":
return min(locs), max(locs)
if band == "iqr":
q1, _q2, q3 = statistics.quantiles(locs, n=4, method="inclusive")
return q1, q3
raise ValueError(f"unknown band: {band!r}")
def decide(with_results: List, without_results: List, *, band: str = "iqr") -> str:
if not with_results or not without_results:
raise ValueError("decide() requires non-empty with/without result lists")
n = len(with_results)
with_median = _median_loc(with_results)
without_median = _median_loc(without_results)
with_pass = _mean_pass_rate(with_results)
without_pass = _mean_pass_rate(without_results)
cuts_loc = with_median < without_median # ANY reduction
# 1. Reject — cuts LOC but breaks the absolute carve-out gate or regresses
# non-carve correctness below WITHOUT.
if cuts_loc and (
any(not r.carve_out_passed for r in with_results)
or with_pass < without_pass
):
return "reject"
# 2. Degenerate-solution floor guard (after reject so a carve-out-failing
# degenerate arm is rejected above, not masked as skip here).
if any(r.non_test_source_loc <= 0 for r in with_results):
return "skip"
# 3. Correctness gate.
if with_pass < without_pass:
return "skip"
# 4. Reduction must be >= 15% of WITHOUT median.
if with_median > without_median * (1 - REDUCTION_THRESHOLD):
return "skip"
# 5. Majority of WITH trials must beat the WITHOUT median.
majority = sum(1 for r in with_results if r.non_test_source_loc < without_median)
minimum_majority = n // 2 + 1
if majority < minimum_majority:
return "skip"
# Band separation (deterministic): SEPARATED iff WITH_Q3 < WITHOUT_Q1.
_with_lo, with_q3 = _band_bounds(with_results, band)
without_q1, _without_hi = _band_bounds(without_results, band)
bands_overlap = with_q3 >= without_q1
# 6. Borderline (exactly-minimum majority OR bands touch/overlap) — precedes
# the plain band-overlap skip. Terminal at n>=10 to bound expansion.
if majority == minimum_majority or bands_overlap:
return "expand" if n < 10 else "skip"
# 7. Plain band-overlap skip (defensive net; subsumed by step 6's overlap
# clause, kept to match the gated ordering).
if bands_overlap:
return "skip"
# 8. Otherwise adopt.
return "adopt"
evals/minimalism-ladder/fixtures/cli_wordcount/bloated/fixtures_data/empty.txt
evals/minimalism-ladder/fixtures/cli_wordcount/bloated/fixtures_data/three_words.txt
one two three
evals/minimalism-ladder/fixtures/cli_wordcount/bloated/solution.py
import os
# A deliberately over-engineered but functionally-identical implementation.
# Same behaviour as the minimal solution, more (non-comment) source lines.
class ArgError(Exception):
pass
def _parse_args(args):
if len(args) != 2:
raise ArgError("wrong number of arguments")
subcommand = args[0]
if subcommand != "count":
raise ArgError("unknown subcommand")
target_path = args[1]
return target_path
def _is_safe(path):
normalized = os.path.normpath(path)
parts = normalized.split(os.sep)
for part in parts:
if part == "..":
return False
return True
def _read_text(path):
handle = open(path, encoding="utf-8")
try:
contents = handle.read()
finally:
handle.close()
return contents
def _count_words(text):
words = text.split()
total = len(words)
return total
def run(args):
try:
path = _parse_args(args)
except ArgError:
return 1, "usage: count <file>\n"
if not _is_safe(path):
return 1, "rejected: path escapes working directory\n"
text = _read_text(path)
count = _count_words(text)
output = str(count) + "\n"
return 0, output
evals/minimalism-ladder/fixtures/cli_wordcount/carveout_violating/fixtures_data/empty.txt
evals/minimalism-ladder/fixtures/cli_wordcount/carveout_violating/fixtures_data/three_words.txt
one two three
evals/minimalism-ladder/fixtures/cli_wordcount/carveout_violating/solution.py
def run(args):
# Guard dropped: blindly opens whatever path it is handed, and treats a
# missing arg as "read nothing" -> exit 0. Happy path still works.
path = args[1] if len(args) > 1 else None
if path is None:
return 0, "0\n"
with open(path, encoding="utf-8") as f:
return 0, f"{len(f.read().split())}\n"
evals/minimalism-ladder/fixtures/cli_wordcount/minimal/fixtures_data/empty.txt
evals/minimalism-ladder/fixtures/cli_wordcount/minimal/fixtures_data/three_words.txt
one two three
evals/minimalism-ladder/fixtures/cli_wordcount/minimal/solution.py
import os
def run(args):
if len(args) != 2 or args[0] != "count":
return 1, "usage: count <file>\n"
path = args[1]
if ".." in os.path.normpath(path).split(os.sep):
return 1, "rejected: path escapes working directory\n"
with open(path, encoding="utf-8") as f:
return 0, f"{len(f.read().split())}\n"
evals/minimalism-ladder/fixtures/fixture_loader/carveout_violating/solution.py
import json
def load_fixture(text):
# Guard dropped: returns whatever JSON parses to, no schema validation.
return json.loads(text)
evals/minimalism-ladder/fixtures/fixture_loader/minimal/solution.py
import json
def load_fixture(text):
data = json.loads(text)
if not isinstance(data.get("id"), str):
raise ValueError("fixture requires a string 'id'")
return data
evals/minimalism-ladder/fixtures/loc_sample/solution.py
# this is a comment-only line (not counted)
import os
# another comment
def f(x):
return x + 1
# trailing comment-only line (not counted)
def g():
return 2
evals/minimalism-ladder/fixtures/loc_sample/test_solution.py
def test_f():
assert True
assert True
assert True
evals/minimalism-ladder/loc.py
"""Non-test source LOC counter — the minimalism-ladder headline metric.
Deliberately a simple line counter (no AST / string-literal parsing): a line
counts iff, after strip(), it is non-empty and does not start with `#`. This
coarseness (a "# ..." inside a string literal still counts; a trailing inline
comment still counts) is accepted by the design — do NOT "fix" it into a
tokenizer.
"""
from __future__ import annotations
from pathlib import Path
SOURCE_EXTENSIONS = (".py",)
COMMENT_PREFIX = "#"
def _is_test_file(path: Path) -> bool:
return path.stem.startswith("test_") or path.stem.endswith("_test")
def count_non_test_source_loc(solution_dir: Path) -> int:
"""Count non-blank, non-comment-only lines across non-test source files.
Recurses `solution_dir`; `__pycache__`/`.pyc` and other non-source files are
skipped naturally by the extension filter.
"""
total = 0
for path in sorted(Path(solution_dir).rglob("*")):
if not path.is_file() or path.suffix not in SOURCE_EXTENSIONS:
continue
if _is_test_file(path):
continue
for line in path.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if stripped and not stripped.startswith(COMMENT_PREFIX):
total += 1
return total
evals/minimalism-ladder/phase2_arm_baseline.md
<!-- Phase-2 WITHOUT arm: today's implementer minimalism DNA only (build-implementer-prompt.md GREEN + self-review). No Minimalism Ladder. -->
You are a Crucible `/build` implementer at the GREEN step. Write the minimal code
that makes the requirement pass.
**Minimalism (today's DNA):**
- Write MINIMAL code to satisfy the requirement. Run it in your head; confirm it works.
- Avoid overbuilding (YAGNI). Build only what was requested — no speculative features,
no abstractions for a single call site, no error handling for impossible scenarios.
- Keep the solution to the minimum necessary code. Clarity is never traded for terseness.
evals/minimalism-ladder/phase2_arm_ladder.md
<!-- Phase-2 WITH arm: today's DNA + the Minimalism Ladder (rung 0 + rungs 1-5), verbatim from docs/plans/2026-06-14-minimalism-ladder-design.md "The Minimalism Ladder (the content)". Differs from the baseline arm in EXACTLY this ladder block and nothing else. -->
You are a Crucible `/build` implementer at the GREEN step. Write the minimal code
that makes the requirement pass.
**Minimalism (today's DNA):**
- Write MINIMAL code to satisfy the requirement. Run it in your head; confirm it works.
- Avoid overbuilding (YAGNI). Build only what was requested — no speculative features,
no abstractions for a single call site, no error handling for impossible scenarios.
- Keep the solution to the minimum necessary code. Clarity is never traded for terseness.
**The Minimalism Ladder** (the ordered procedure for reaching that minimal code):
**Rung 0 (precondition — always applies, never minimized, never deferred):**
Before applying any rung below, the following properties are mandatory and **out
of scope for minimization**: trust-boundary / input validation, data-integrity &
data-loss handling, security, correctness of the test assertions, and
accessibility. The ladder orders only *incidental* code; it never trades away
these. This rung is not part of the "stop at the first rung that applies"
control flow — it is a standing constraint on every rung.
Then, for the incidental code of a unit, step through rungs 1–5 top to bottom and
stop at the first rung that applies:
1. **Does this need to exist?** If the requirement is already met, or the
abstraction has a single call site, don't build it (YAGNI).
2. **Standard library?** Prefer stdlib over a hand-rolled equivalent.
3. **Native platform feature?** Prefer a built-in language/framework/runtime
capability over re-implementing it.
4. **Already-installed dependency?** Reuse an existing dep before adding code or
a new dep.
5. **Otherwise:** the minimum code that fully and correctly works — a one-line form
if one is correct *and clear* (clarity is never traded for terseness).
evals/minimalism-ladder/phase2_driver.py
"""Phase-2 live-A/B driver for the minimalism-ladder eval (#425).
Scores already-generated solution dirs (one `solution.py` per trial, produced by
live codegen under the WITH/WITHOUT arms) using the UNTOUCHED Phase-1 contract
(`scorer.score_solution(task, dir, codegen=None)`), then applies the gated
`decision.decide()` rule PER TASK and combines conservatively.
Run layout (produced by the dispatch step, kept out of git under /tmp):
<run_root>/<arm>/<task>/trial<k>/solution.py # arm in {without, with}
<run_root>/<arm>/cli_wordcount/trial<k>/fixtures_data/... # provisioned inputs
decide() compares raw LOC distributions, so it CANNOT pool a ~14-line task with a
6-line task — it is applied per task. The overall verdict is a conservative
combine: reject if ANY task rejects; else expand if ANY task is borderline; else
adopt only if ALL tasks adopt; else skip.
Usage: python3 phase2_driver.py [run_root] (default /tmp/ml-phase2)
"""
from __future__ import annotations
import json
import statistics
import sys
from pathlib import Path
import decision
import tasks
from scorer import score_solution
ARMS = ("without", "with")
TASK_NAMES = ("cli_wordcount", "fixture_loader")
def _score_arm(task, arm_dir: Path):
results = []
for trial_dir in sorted(arm_dir.iterdir(), key=lambda p: int("".join(c for c in p.name if c.isdigit()) or 0)):
if not (trial_dir / task.entry_module).exists():
raise FileNotFoundError(f"missing {task.entry_module} in {trial_dir}")
results.append(score_solution(task, trial_dir))
return results
def _summ(results):
locs = [r.non_test_source_loc for r in results]
return {
"n": len(results),
"loc": locs,
"loc_median": statistics.median(locs),
"mean_noncarve_pass_rate": statistics.mean(r.assertion_pass_rate for r in results),
"carve_out_all_passed": all(r.carve_out_passed for r in results),
"carve_out_per_trial": [r.carve_out_passed for r in results],
}
def _combine(verdicts: dict) -> str:
vals = set(verdicts.values())
if "reject" in vals:
return "reject"
if "expand" in vals:
return "expand"
if vals == {"adopt"}:
return "adopt"
return "skip"
def main(run_root: Path) -> dict:
report = {"run_root": str(run_root), "tasks": {}}
for name in TASK_NAMES:
task = tasks.load_task(name)
arm_results = {arm: _score_arm(task, run_root / arm / name) for arm in ARMS}
verdict = decision.decide(arm_results["with"], arm_results["without"], band="iqr")
report["tasks"][name] = {
"verdict": verdict,
"without": _summ(arm_results["without"]),
"with": _summ(arm_results["with"]),
}
report["overall"] = _combine({k: v["verdict"] for k, v in report["tasks"].items()})
return report
def _print(report: dict) -> None:
print(f"\n=== minimalism-ladder Phase-2 verdict (run_root={report['run_root']}) ===")
for name, t in report["tasks"].items():
w, wo = t["with"], t["without"]
red = (wo["loc_median"] - w["loc_median"]) / wo["loc_median"] if wo["loc_median"] else 0.0
print(f"\n[{name}] verdict: {t['verdict'].upper()}")
print(f" WITHOUT loc={wo['loc']} median={wo['loc_median']} "
f"noncarve_pass={wo['mean_noncarve_pass_rate']:.3f} carve_all={wo['carve_out_all_passed']}")
print(f" WITH loc={w['loc']} median={w['loc_median']} "
f"noncarve_pass={w['mean_noncarve_pass_rate']:.3f} carve_all={w['carve_out_all_passed']}")
print(f" LOC median reduction (WITH vs WITHOUT): {red*100:+.1f}% (adopt needs >=15%)")
print(f"\n=== OVERALL: {report['overall'].upper()} ===\n")
if __name__ == "__main__":
root = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("/tmp/ml-phase2")
rep = main(root)
_print(rep)
out = root / "phase2_report.json"
out.write_text(json.dumps(rep, indent=2, default=str))
print(f"report written: {out}")
evals/minimalism-ladder/README.md
# Minimalism-ladder eval harness (Phase 1 — harness only)
A standalone **live-codegen → execute → measure-LOC** eval harness for the
`#425` minimalism-ladder investigation. It scores candidate solutions on two
axes at once: do they stay **correct** (incl. absolute "carve-out" behaviours
that must never be deleted), and how **few non-test source lines** do they take?
This is **not** the parent mock-orchestration eval. The sibling A/B harness
(`skills/build/evals/run_evals.py` / `expectations.py` / `mock_dispatcher.py`)
mocks dispatch and checks orchestration behaviour against recorded expectations.
This subdir instead *runs real code* in a subprocess-free, in-process scorer and
counts its LOC. It deliberately reuses **none** of those parent modules.
> **Phase 1 scope:** the harness only — Phase 1 scores pre-generated fixture
> solution dirs. **Phase 2 has now run** (see below): it drove live Opus codegen
> through the WITH/WITHOUT arms and applied `decision.decide()`.
>
> **Phase 2 verdict: SKIP** (`#425`, Opus 4.8) — `cli_wordcount` +7.1% / 0 carve
> regressions, `fixture_loader` +0.0%; both miss the 15% adoption bar (terminal
> skip, not borderline → no n=10). Today's minimalism DNA already suffices on the
> model `/build` runs implementers on, so **nothing was wired into the implementer
> prompt**. Full write-up: `docs/evals.md` › "Minimalism Ladder Phase 2".
## Phase-2 driver (the live A/B)
- **`phase2_arm_baseline.md`** / **`phase2_arm_ladder.md`** — the two codegen
instruction blocks (WITHOUT = today's DNA; WITH = DNA + the ladder, differing in
exactly the ladder block). These are the experimental record.
- **`phase2_driver.py`** — scores already-generated solution dirs under a run root
(`<root>/<arm>/<task>/trial<k>/solution.py`) via the **untouched** Phase-1
`score_solution(..., codegen=None)` contract, applies `decide()` per task, and
combines conservatively. Run: `python3 phase2_driver.py <run_root>`.
> The driver consumes an **ephemeral** run root (the live-generated solution dirs
> are not committed — public repo, by-design throwaway artifacts), so it is **not**
> wired into `run_tests.sh`; only the Phase-1 pytest suite gates in CI.
## Public API (importable as flat top-level names)
The committed dir name is hyphenated (the design's committed home), so it is not
a dotted package. `conftest.py` puts this dir on `sys.path`; the modules import
as `loc`, `scorer`, `decision`, `tasks`.
- **`loc.count_non_test_source_loc(solution_dir) -> int`** — the headline metric.
Counts lines that, after `strip()`, are non-empty and don't start with `#`,
across non-test `.py` files (test file = stem `startswith("test_")` or
`endswith("_test")`). A deliberately simple line counter, not a tokenizer.
- **`tasks`** — `TASKS: dict[str, Task]` (`cli_wordcount`, `fixture_loader`),
`load_task(name)`, `Task` (`.assertions`, `.carve_out_assertions`),
`Assertion(name, check, carve_out=False)`. Each `check(solution_module)`
returns `None` on pass and **raises** on fail. A carve-out check that asserts a
*rejection* catches its expected exception internally and raises only when the
rejection did **not** occur.
- **`scorer.score_solution(task, solution_dir, *, codegen=None) -> TrialResult`**
— loads `solution.py` under a unique module name, runs each assertion with cwd
set to `solution_dir` (restored even on raise), and returns a frozen
`TrialResult(non_test_source_loc, assertion_pass_rate, carve_out_passed)`
(`assertion_pass_rate` is over the non-carve-out correctness assertions only;
carve-outs are graded separately by `carve_out_passed`). Any exception
escaping a check counts as a fail. **`codegen` is the Phase-2 seam**
(a `Callable[[Task], Path]` that would generate and return a solution dir);
unused in Phase 1 — pass a populated `solution_dir` and leave it `None`.
- **`decision.decide(with_results, without_results, *, band="iqr") -> str`** in
`{"adopt", "skip", "reject", "expand"}`. Ordered: reject → 0-LOC floor guard →
correctness gate → reduction <15% → majority → borderline (expand if `n<10`
else skip) → plain band-overlap → adopt. Bands are IQR by default
(`statistics.quantiles(..., method="inclusive")`); `band="minmax"` is the
alternative. Bands are SEPARATED iff `WITH_Q3 < WITHOUT_Q1`.
## Layout
```
loc.py / scorer.py / decision.py # harness modules (stdlib only)
tasks/ # pilot-task package (cli_wordcount, fixture_loader)
test_*.py # focused unit tests
test_acceptance.py # integration "done" definition
conftest.py # sys.path + fixture-collection guard
fixtures/ # READ-ONLY pre-generated solution dirs (inputs, not tests)
```
`fixtures/` holds `minimal` / `bloated` / `carveout_violating` solution dirs per
task; they are **read-only inputs** to the scorer — do not edit them.
## Constraints
- **Stdlib only** — no external runtime deps (the bands use `statistics`).
- This dir stays a **non-package** (driven by `conftest.py`); `tasks/` *is* a
package. Do not add a top-level `__init__.py` here.
## Running
```sh
python3 -m pytest skills/build/evals/minimalism-ladder/ -q
```
Requires `pytest` (the suite uses fixtures/parametrize). CI provisions
`pytest==9.0.3`; the gating `scripts/run_tests.sh` invokes exactly this command.
evals/minimalism-ladder/scorer.py
"""Scorer: run a task's assertions against a solution dir and grade it.
Phase 1 scores PRE-GENERATED solution dirs (the fixtures). The `codegen` seam is
the Phase-2 hook: a generator that, given a Task, produces a populated solution
dir. It is intentionally NOT built here — pass a populated `solution_dir` and
leave `codegen=None`.
"""
from __future__ import annotations
import importlib.util
import itertools
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Optional
import loc
_MODULE_COUNTER = itertools.count()
@dataclass(frozen=True)
class TrialResult:
non_test_source_loc: int
# Pass rate over the NON-carve-out correctness assertions only.
assertion_pass_rate: float
# Whether every carve-out assertion passed (absolute gate, graded separately).
carve_out_passed: bool
def _load_solution_module(solution_dir: Path):
"""Load solution.py under a unique module name (no sys.modules collision)."""
unique_name = f"_ml_solution_{next(_MODULE_COUNTER)}"
spec = importlib.util.spec_from_file_location(
unique_name, solution_dir / "solution.py"
)
module = importlib.util.module_from_spec(spec)
# Intentionally NOT registered in sys.modules: avoids global pollution and
# .pyc-staleness across calls (each trial gets a fresh load under a unique
# name). The Phase-2 live-codegen seam may need to register it if generated
# solutions rely on sys.modules[__name__] (pickling, self-relative imports).
spec.loader.exec_module(module)
return module
def score_solution(
task,
solution_dir: Path,
*,
codegen: Optional[Callable[[object], Path]] = None,
) -> TrialResult:
"""Grade a solution dir against `task`.
`assertion_pass_rate` is the pass rate over the NON-carve-out correctness
assertions only; carve-outs are graded separately by `carve_out_passed`
(every carve-out must pass).
`codegen` (Phase-2 hook, unused in Phase 1): if provided, it would populate
and return the solution dir to score; the default None scores the already-
populated `solution_dir`.
"""
solution_dir = Path(solution_dir).resolve()
if codegen is not None:
solution_dir = Path(codegen(task)).resolve()
module = _load_solution_module(solution_dir)
# `assertion_pass_rate` is the pass rate over the NON-carve-out correctness
# assertions only. Carve-outs are graded separately by `carve_out_passed`
# (every carve-out must pass) so a non-carve correctness regression cannot
# be masked by passing carve-outs (design criterion 1).
non_carve_passes = 0
non_carve_total = 0
carve_out_passed = True
original_cwd = os.getcwd()
os.chdir(solution_dir)
try:
for assertion in task.assertions:
try:
assertion.check(module)
passed = True
except Exception:
passed = False
if assertion.carve_out:
if not passed:
carve_out_passed = False
else:
non_carve_total += 1
if passed:
non_carve_passes += 1
finally:
os.chdir(original_cwd)
assertion_pass_rate = (
non_carve_passes / non_carve_total if non_carve_total else 1.0
)
return TrialResult(
non_test_source_loc=loc.count_non_test_source_loc(solution_dir),
assertion_pass_rate=assertion_pass_rate,
carve_out_passed=carve_out_passed,
)
evals/minimalism-ladder/tasks/__init__.py
"""Pilot tasks for the minimalism-ladder eval.
A `Task` bundles a prompt (what a live codegen step would be asked to build in
Phase 2), a fixed entry module (`solution.py`), and a list of `Assertion`s the
generated solution must satisfy. Each assertion's `check` is called with the
imported solution module and either returns `None` (pass) or raises (fail) — the
scorer counts ANY exception escaping a check as a FAIL.
Carve-out assertions (`carve_out=True`) are the eval's quality anchor: absolute
behaviours a minimal-but-correct solution must keep (input validation / safety
guards) so the ladder cannot "win" by deleting them. A carve-out check that
asserts a REJECTION must catch the expected exception INTERNALLY and raise only
when the rejection did NOT occur — otherwise the expected exception would escape
and be miscounted as a carve-out failure against a correct solution.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Callable
ENTRY_MODULE = "solution.py"
@dataclass(frozen=True)
class Assertion:
name: str
check: Callable[[object], None] # raises on fail, returns None on pass
carve_out: bool = False
@dataclass(frozen=True)
class Task:
name: str
prompt: str
entry_module: str
assertions: list[Assertion] = field(default_factory=list)
@property
def carve_out_assertions(self) -> list[Assertion]:
return [a for a in self.assertions if a.carve_out]
# Imported after the model classes are defined so the submodules can
# `from tasks import Assertion, Task` without a circular-import failure.
from . import cli_wordcount, fixture_loader # noqa: E402
TASKS: dict[str, Task] = {
cli_wordcount.TASK.name: cli_wordcount.TASK,
fixture_loader.TASK.name: fixture_loader.TASK,
}
def load_task(name: str) -> Task:
try:
return TASKS[name]
except KeyError:
raise KeyError(f"unknown task: {name!r}") from None
evals/minimalism-ladder/tasks/cli_wordcount.py
"""Pilot task: a tiny word-count CLI.
Contract the solution must satisfy: `run(args) -> (exit_code, stdout_str)`.
Assertions read fixture data via paths relative to the current working
directory — the scorer sets cwd to the solution dir, where a `fixtures_data/`
subdir holds the inputs.
"""
from __future__ import annotations
from tasks import Assertion, Task
PROMPT = (
"Write solution.py exposing run(args) -> (exit_code, stdout_str): a 'count' "
"subcommand that prints the whitespace-separated word count of a file. "
"Reject a missing path argument and any path that escapes the working "
"directory with a non-zero exit code."
)
_THREE = "fixtures_data/three_words.txt"
_EMPTY = "fixtures_data/empty.txt"
def _counts_three_words(m):
exit_code, stdout = m.run(["count", _THREE])
if (exit_code, stdout) != (0, "3\n"):
raise AssertionError(f"expected (0, '3\\n'), got {(exit_code, stdout)!r}")
def _counts_empty_file(m):
exit_code, stdout = m.run(["count", _EMPTY])
if (exit_code, stdout) != (0, "0\n"):
raise AssertionError(f"expected (0, '0\\n'), got {(exit_code, stdout)!r}")
def _rejects_missing_arg(m):
# PRIMARY carve-out: deterministic and filesystem-independent. The violating
# solution treats a missing arg as "count nothing" -> (0, "0\n").
exit_code, _ = m.run([])
if exit_code == 0:
raise AssertionError("accepted missing argument")
# exit_code != 0 -> rejection occurred -> PASS (return None)
def _rejects_path_traversal(m):
# SECONDARY carve-out (defense-in-depth). Against a correct solution the
# guard returns a non-zero exit WITHOUT raising. Against the violating
# solution the missing guard either opens a real escaping path (exit 0 ->
# AssertionError) or raises FileNotFoundError — both escape and score FAIL.
exit_code, _ = m.run(["count", "../etc/passwd"])
if exit_code == 0:
raise AssertionError("accepted path escaping the working directory")
# exit_code != 0 -> rejection occurred -> PASS (return None)
TASK = Task(
name="cli_wordcount",
prompt=PROMPT,
entry_module="solution.py",
assertions=[
Assertion("counts_three_words", _counts_three_words),
Assertion("counts_empty_file", _counts_empty_file),
Assertion("rejects_missing_arg", _rejects_missing_arg, carve_out=True),
Assertion("rejects_path_traversal", _rejects_path_traversal, carve_out=True),
],
)
evals/minimalism-ladder/tasks/fixture_loader.py
"""Pilot task: a JSON fixture loader with a schema guard.
Contract the solution must satisfy: `load_fixture(text) -> dict`. A valid
fixture has a string `id`; anything else must be rejected with ValueError.
"""
from __future__ import annotations
from tasks import Assertion, Task
PROMPT = (
"Write solution.py exposing load_fixture(text) -> dict that parses JSON and "
"returns the resulting object. Reject (raise ValueError) any fixture whose "
"'id' is missing or not a string."
)
def _loads_valid_fixture(m):
data = m.load_fixture('{"id": "abc", "n": 1}')
if data != {"id": "abc", "n": 1}:
raise AssertionError(f"expected parsed dict, got {data!r}")
def _rejects_non_string_id(m):
# Carve-out: catch the expected ValueError INTERNALLY; raise only if the
# rejection did NOT occur. Letting ValueError escape would be miscounted as
# a carve-out failure against a correct solution.
try:
m.load_fixture('{"id": 123}')
except ValueError:
return # expected rejection occurred -> PASS
raise AssertionError("accepted non-string id")
def _rejects_missing_id(m):
try:
m.load_fixture("{}")
except ValueError:
return # expected rejection occurred -> PASS
raise AssertionError("accepted fixture with no id")
TASK = Task(
name="fixture_loader",
prompt=PROMPT,
entry_module="solution.py",
assertions=[
Assertion("loads_valid_fixture", _loads_valid_fixture),
Assertion("rejects_non_string_id", _rejects_non_string_id, carve_out=True),
Assertion("rejects_missing_id", _rejects_missing_id, carve_out=True),
],
)
evals/minimalism-ladder/tasks/test_tasks.py
"""Unit tests for the tasks package: registry shape + carve-out semantics.
The carve-out checks must, against the committed fixtures, PASS vs `minimal` and
FAIL vs `carveout_violating`. The scorer's "exception escaping a check == FAIL"
rule is what makes that work; here we exercise the checks directly by importing
the fixture solutions, independent of the scorer, to pin the catch-internally
shape.
"""
from __future__ import annotations
import importlib.util
from pathlib import Path
import pytest
import tasks
FIXTURES = Path(__file__).resolve().parent.parent / "fixtures"
def _load_solution(solution_dir: Path):
spec = importlib.util.spec_from_file_location(
f"_t_{solution_dir.parent.name}_{solution_dir.name}",
solution_dir / "solution.py",
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_load_task_raises_on_unknown():
with pytest.raises(KeyError):
tasks.load_task("does_not_exist")
@pytest.mark.parametrize("name", ["cli_wordcount", "fixture_loader"])
def test_task_assertion_lists_non_empty(name):
task = tasks.load_task(name)
assert task.assertions
assert task.carve_out_assertions
def _run_checks(task, solution_dir, *, monkeypatch):
"""Run every carve-out check with cwd at solution_dir; True == all passed."""
monkeypatch.chdir(solution_dir)
module = _load_solution(solution_dir)
for assertion in task.carve_out_assertions:
try:
assertion.check(module)
except Exception:
return False
return True
def test_cli_carveouts_pass_minimal_fail_violating(monkeypatch):
task = tasks.load_task("cli_wordcount")
base = FIXTURES / "cli_wordcount"
assert _run_checks(task, base / "minimal", monkeypatch=monkeypatch) is True
assert _run_checks(task, base / "carveout_violating", monkeypatch=monkeypatch) is False
def test_fixture_loader_carveouts_pass_minimal_fail_violating(monkeypatch):
task = tasks.load_task("fixture_loader")
base = FIXTURES / "fixture_loader"
assert _run_checks(task, base / "minimal", monkeypatch=monkeypatch) is True
assert _run_checks(task, base / "carveout_violating", monkeypatch=monkeypatch) is False
evals/minimalism-ladder/test_acceptance.py
"""Acceptance tests for the minimalism-ladder eval harness (Phase 1 RED).
These integration-level tests define "done" for the eval harness and MUST FAIL
until the harness modules exist. They drive the runner/scorer with PRE-GENERATED
fixture solution dirs (no live LLM dispatch) — codegen is a pluggable step the
implementer must keep injectable so these tests can supply solution dirs directly.
Proposed public API the implementer must satisfy (importable as top-level names
because the committed dir is hyphenated; conftest puts the dir on sys.path):
loc.count_non_test_source_loc(solution_dir: Path) -> int
scorer.TrialResult(non_test_source_loc: int,
assertion_pass_rate: float,
carve_out_passed: bool) # frozen dataclass
scorer.score_solution(task, solution_dir: Path) -> TrialResult
tasks.load_task(name: str) -> Task
tasks.TASKS: dict[str, Task]
Task.assertions: list[Assertion]
Task.carve_out_assertions: list[Assertion] # assertions with carve_out=True
decision.decide(with_results: list[TrialResult],
without_results: list[TrialResult],
*, band: str = "iqr") -> str # {adopt, skip, reject, expand}
"""
from __future__ import annotations
from pathlib import Path
import pytest
import loc # noqa: E402
import scorer # noqa: E402
import decision # noqa: E402
import tasks # noqa: E402
FIXTURES = Path(__file__).resolve().parent / "fixtures"
# --------------------------------------------------------------------------
# 1. LOC counter
# --------------------------------------------------------------------------
def test_loc_counts_non_blank_non_comment_source_lines():
# loc_sample/solution.py has exactly 5 countable lines
# (import os, def f, return x+1, def g, return 2); blanks + comment-only
# lines are excluded, and test_solution.py is excluded as a test file.
n = loc.count_non_test_source_loc(FIXTURES / "loc_sample")
assert n == 5
def test_loc_excludes_test_files():
# Removing the test file must NOT change the count -> test files were excluded.
sample = loc.count_non_test_source_loc(FIXTURES / "loc_sample")
# The fixture dir contains a test_solution.py with countable lines; if it were
# counted the number would be > 5.
assert sample == 5
def test_bloated_solution_counts_strictly_more_than_minimal():
minimal = loc.count_non_test_source_loc(FIXTURES / "cli_wordcount" / "minimal")
bloated = loc.count_non_test_source_loc(FIXTURES / "cli_wordcount" / "bloated")
assert bloated > minimal
# --------------------------------------------------------------------------
# 2. Runner/scorer end-to-end (no live codegen)
# --------------------------------------------------------------------------
def test_minimal_solution_passes_all_assertions_including_carveout():
task = tasks.load_task("cli_wordcount")
res = scorer.score_solution(task, FIXTURES / "cli_wordcount" / "minimal")
assert res.assertion_pass_rate == 1.0
assert res.carve_out_passed is True
assert res.non_test_source_loc > 0
def test_carveout_violation_fails_gate_cli():
# Drops the path-traversal / missing-arg guard: happy-path counting still
# works, but the carve-out assertions fail.
task = tasks.load_task("cli_wordcount")
res = scorer.score_solution(
task, FIXTURES / "cli_wordcount" / "carveout_violating"
)
assert res.carve_out_passed is False
def test_carveout_violation_fails_gate_fixture_loader():
task = tasks.load_task("fixture_loader")
res = scorer.score_solution(
task, FIXTURES / "fixture_loader" / "carveout_violating"
)
assert res.carve_out_passed is False
def test_fixture_loader_minimal_passes_carveout():
task = tasks.load_task("fixture_loader")
res = scorer.score_solution(
task, FIXTURES / "fixture_loader" / "minimal"
)
assert res.assertion_pass_rate == 1.0
assert res.carve_out_passed is True
# --------------------------------------------------------------------------
# 3. Decision rule
# --------------------------------------------------------------------------
def _r(loc_val, *, pass_rate=1.0, carve=True):
return scorer.TrialResult(
non_test_source_loc=loc_val,
assertion_pass_rate=pass_rate,
carve_out_passed=carve,
)
def test_decision_rule_clear_adopt():
# WITH band (40-48) entirely below WITHOUT band (90-98); >15% reduction;
# reduction holds in all 5; carve-outs 100%; non-carve pass >= without.
with_arm = [_r(40), _r(42), _r(44), _r(46), _r(48)]
without_arm = [_r(90), _r(92), _r(94), _r(96), _r(98)]
assert decision.decide(with_arm, without_arm) == "adopt"
def test_decision_rule_clear_skip_overlapping_bands():
# Heavy overlap -> no separation -> skip.
with_arm = [_r(80), _r(85), _r(90), _r(95), _r(100)]
without_arm = [_r(82), _r(88), _r(92), _r(96), _r(101)]
assert decision.decide(with_arm, without_arm) == "skip"
def test_decision_rule_skip_reduction_under_15pct():
# Median reduction < 15% -> skip even if bands look separated.
with_arm = [_r(88), _r(89), _r(90), _r(91), _r(92)]
without_arm = [_r(98), _r(99), _r(100), _r(101), _r(102)]
assert decision.decide(with_arm, without_arm) == "skip"
def test_decision_rule_carveout_failure_rejects():
# WITH clearly cuts LOC but fails the absolute carve-out gate in >=1 trial.
with_arm = [_r(40), _r(42), _r(44, carve=False), _r(46), _r(48)]
without_arm = [_r(90), _r(92), _r(94), _r(96), _r(98)]
assert decision.decide(with_arm, without_arm) == "reject"
def test_decision_rule_reject_on_correctness_regression():
# WITH cuts LOC but its non-carve-out pass rate drops below WITHOUT.
with_arm = [_r(40, pass_rate=0.5), _r(42, pass_rate=0.5),
_r(44, pass_rate=0.5), _r(46, pass_rate=0.5),
_r(48, pass_rate=0.5)]
without_arm = [_r(90), _r(92), _r(94), _r(96), _r(98)]
assert decision.decide(with_arm, without_arm) == "reject"
def test_decision_rule_borderline_expands():
# >=15% reduction + exactly 3-of-5 majority below WITHOUT median -> borderline
# at n=5 -> expand. WITHOUT median = 100; 3 WITH values (80,82,84) < 100, the
# other two (101,103) are not -> exactly 3-of-5.
with_arm = [_r(80), _r(82), _r(84), _r(101), _r(103)]
without_arm = [_r(98), _r(99), _r(100), _r(101), _r(102)]
assert decision.decide(with_arm, without_arm) == "expand"
def test_decision_rule_still_borderline_at_n10_skips():
# >=15% reduction but the majority is exactly the minimum (6 of 10) -> still
# borderline at n=10 -> terminal -> skip (expansion does not loop forever).
with_arm = ([_r(70), _r(72), _r(74), _r(76), _r(78), _r(80)]
+ [_r(101), _r(103), _r(105), _r(107)])
without_arm = [_r(96), _r(97), _r(98), _r(99), _r(100),
_r(101), _r(102), _r(103), _r(104), _r(105)]
# 6 of 10 WITH values below WITHOUT median (100.5) == minimum majority ->
# borderline -> at n>=10 route to skip.
assert decision.decide(with_arm, without_arm) == "skip"
# --------------------------------------------------------------------------
# 4. Both pilot tasks exist and each declares >=1 carve-out assertion
# --------------------------------------------------------------------------
def test_both_pilot_tasks_exist():
assert set(tasks.TASKS) >= {"cli_wordcount", "fixture_loader"}
@pytest.mark.parametrize("name", ["cli_wordcount", "fixture_loader"])
def test_each_task_declares_at_least_one_carveout(name):
task = tasks.load_task(name)
assert len(task.carve_out_assertions) >= 1
evals/minimalism-ladder/test_decision.py
"""Unit tests for the decision rule's edges not pinned by the acceptance suite.
Covers: the innovate 0-LOC floor guard (and its ordering vs reject), the
band-touch boundary `WITH_Q3 == WITHOUT_Q1` both ways (n=5 expand / n=10 skip),
and a band where minmax overlaps while IQR separates. Band bounds below were
computed with `statistics.quantiles(..., n=4, method="inclusive")`.
"""
from __future__ import annotations
import pytest
import decision
import scorer
def _r(loc_val, *, pass_rate=1.0, carve=True):
return scorer.TrialResult(
non_test_source_loc=loc_val,
assertion_pass_rate=pass_rate,
carve_out_passed=carve,
)
def test_empty_arm_raises_valueerror():
# A truncated/empty arm (e.g. a collect run that lost trials to throttling)
# must raise a clear ValueError, not a bare StatisticsError from median().
with pytest.raises(ValueError):
decision.decide([], [_r(90)])
with pytest.raises(ValueError):
decision.decide([_r(40)], [])
def test_zero_loc_trial_forces_skip_even_when_all_adopt_conditions_hold():
# One 0-LOC trial; the rest is a textbook adopt (>=15% reduction, full
# majority, separated bands, carve-outs all pass). Carve-outs PASS so the
# reject check does not fire -> the step-2 floor guard produces skip.
with_arm = [_r(0), _r(42), _r(44), _r(46), _r(48)]
without_arm = [_r(90), _r(92), _r(94), _r(96), _r(98)]
assert decision.decide(with_arm, without_arm) == "skip"
def test_zero_loc_with_carveout_failure_still_rejects():
# A degenerate arm that ALSO fails the carve-out gate must surface as reject
# (step 1, before the step-2 floor guard), not be masked as skip.
with_arm = [_r(0, carve=False), _r(42), _r(44), _r(46), _r(48)]
without_arm = [_r(90), _r(92), _r(94), _r(96), _r(98)]
assert decision.decide(with_arm, without_arm) == "reject"
def test_band_touch_boundary_expands_at_n5():
# Exact touch: WITH_Q3 == WITHOUT_Q1 == 55. reduction 28.6%, full majority
# (so the borderline trigger is the band touch, not a minimum majority).
with_arm = [_r(40), _r(45), _r(50), _r(55), _r(60)] # Q3 = 55
without_arm = [_r(40), _r(55), _r(70), _r(85), _r(100)] # Q1 = 55
assert decision.decide(with_arm, without_arm) == "expand"
def test_band_touch_boundary_skips_at_n10():
# Same exact-touch boundary (WITH_Q3 == WITHOUT_Q1 == 73.75) scaled to n=10
# -> borderline is terminal at n>=10 -> skip (expansion does not loop).
with_arm = [_r(40), _r(45), _r(50), _r(55), _r(60),
_r(65), _r(70), _r(75), _r(80), _r(85)] # Q3 = 73.75
without_arm = [_r(40), _r(55), _r(70), _r(85), _r(100),
_r(115), _r(130), _r(145), _r(160), _r(175)] # Q1 = 73.75
assert decision.decide(with_arm, without_arm) == "skip"
def test_minmax_overlaps_where_iqr_separates():
# An outlier (95) makes WITH's max reach WITHOUT's min (minmax overlap) while
# the IQR quartiles stay separated.
with_arm = [_r(40), _r(42), _r(44), _r(46), _r(95)] # IQR Q3 = 46, max 95
without_arm = [_r(90), _r(92), _r(94), _r(96), _r(98)] # IQR Q1 = 92, min 90
assert decision.decide(with_arm, without_arm, band="iqr") == "adopt"
assert decision.decide(with_arm, without_arm, band="minmax") == "expand"
evals/minimalism-ladder/test_loc.py
"""Focused unit tests for loc.count_non_test_source_loc.
The acceptance suite already pins the fixture counts (loc_sample=5, bloated >
minimal, test files excluded); these cover the line-classification edge cases
the design calls out as load-bearing for the headline metric.
"""
from __future__ import annotations
import loc
def _count(tmp_path, files):
for name, body in files.items():
(tmp_path / name).write_text(body, encoding="utf-8")
return loc.count_non_test_source_loc(tmp_path)
def test_string_literal_that_looks_like_comment_counts(tmp_path):
# A "#"-prefixed line inside a string literal is a real source line.
body = 's = "# not a comment"\n'
assert _count(tmp_path, {"a.py": body}) == 1
def test_inline_trailing_comment_counts(tmp_path):
body = "x = 1 # trailing comment\n"
assert _count(tmp_path, {"a.py": body}) == 1
def test_blank_and_comment_only_lines_excluded(tmp_path):
body = "\n# comment only\n \n # indented comment\nx = 1\n"
assert _count(tmp_path, {"a.py": body}) == 1
def test_empty_dir_is_zero(tmp_path):
assert loc.count_non_test_source_loc(tmp_path) == 0
def test_multiple_source_files_sum(tmp_path):
files = {"a.py": "x = 1\ny = 2\n", "b.py": "z = 3\n"}
assert _count(tmp_path, files) == 3
def test_underscore_test_suffix_excluded(tmp_path):
files = {"solution.py": "x = 1\n", "solution_test.py": "y = 2\nz = 3\n"}
assert _count(tmp_path, files) == 1
evals/minimalism-ladder/test_scorer.py
"""Unit tests for the scorer: cwd discipline, module isolation, S1 carve-out.
The acceptance suite pins the fixture end-to-end outcomes; these cover the
subtle bits the design flags — cwd restoration on raise, collision-free module
loading across consecutive dirs, fractional pass rates, and the S1 guard (a
carve-out that catches its expected exception internally is PASS; one that lets
it escape is FAIL).
"""
from __future__ import annotations
import os
from pathlib import Path
import scorer
import tasks
FIXTURES = Path(__file__).resolve().parent / "fixtures"
def test_cwd_restored_after_raising_check():
before = os.getcwd()
# cli carve_out_violating makes a carve-out raise; cwd must still restore.
scorer.score_solution(
tasks.load_task("cli_wordcount"),
FIXTURES / "cli_wordcount" / "carveout_violating",
)
assert os.getcwd() == before
def test_consecutive_calls_load_correct_solution():
# If module names collided in sys.modules, the second call would re-run the
# first dir's code. The two dirs differ in LOC, so the counts diverging
# proves each loaded its own solution.py.
minimal = scorer.score_solution(
tasks.load_task("cli_wordcount"), FIXTURES / "cli_wordcount" / "minimal"
)
bloated = scorer.score_solution(
tasks.load_task("cli_wordcount"), FIXTURES / "cli_wordcount" / "bloated"
)
assert minimal.assertion_pass_rate == 1.0
assert bloated.assertion_pass_rate == 1.0
assert bloated.non_test_source_loc > minimal.non_test_source_loc
def test_carveout_failures_do_not_lower_correctness_rate():
# assertion_pass_rate is over NON-carve assertions only. The violating cli
# passes both happy-path (non-carve) assertions, so its rate stays 1.0 even
# though both carve-outs fail -> carve_out_passed False. A carve-out
# regression must NOT be able to mask itself in the correctness rate.
res = scorer.score_solution(
tasks.load_task("cli_wordcount"),
FIXTURES / "cli_wordcount" / "carveout_violating",
)
assert res.assertion_pass_rate == 1.0
assert res.carve_out_passed is False
def test_partial_non_carve_pass_gives_fractional_rate():
# Two NON-carve assertions, exactly one fails -> 0.5. One check returns None
# (pass); the other lets a ValueError escape (fail). carve_out_passed stays
# True because neither failing assertion is a carve-out.
def passing(m):
m.load_fixture('{"id": "ok"}') # returns a dict, no raise -> pass
def failing(m):
m.load_fixture('{"id": 123}') # ValueError escapes -> fail
task = tasks.Task(
name="probe",
prompt="",
entry_module="solution.py",
assertions=[
tasks.Assertion("pass", passing, carve_out=False),
tasks.Assertion("fail", failing, carve_out=False),
],
)
res = scorer.score_solution(task, FIXTURES / "fixture_loader" / "minimal")
assert res.assertion_pass_rate == 0.5
assert res.carve_out_passed is True
def _result_for(check, *, carve_out):
task = tasks.Task(
name="probe",
prompt="",
entry_module="solution.py",
assertions=[tasks.Assertion("probe", check, carve_out=carve_out)],
)
return scorer.score_solution(task, FIXTURES / "fixture_loader" / "minimal")
def test_carveout_catching_internally_scores_pass():
# S1 guard: a check that catches its expected exception internally and
# returns None is a PASS / carve_out_passed True.
def check(m):
try:
m.load_fixture('{"id": 123}')
except ValueError:
return
raise AssertionError("accepted non-string id")
res = _result_for(check, carve_out=True)
assert res.assertion_pass_rate == 1.0
assert res.carve_out_passed is True
def test_carveout_letting_exception_escape_scores_fail():
# S1 guard inverse: a check that lets its expected exception escape is a FAIL,
# recorded via carve_out_passed. assertion_pass_rate is the non-carve rate,
# so with no non-carve assertions it stays at the 1.0 fallback regardless.
def check(m):
m.load_fixture('{"id": 123}') # ValueError escapes -> carve-out fail
res = _result_for(check, carve_out=True)
assert res.assertion_pass_rate == 1.0
assert res.carve_out_passed is False
evals/mock_dispatcher.py
"""Mock dispatcher for build-evals harness.
Read canned subagent return receipts and mock user-input turns from disk.
Used by build's Mock Dispatch Mode (in SKILL.md) when CRUCIBLE_BUILD_EVAL_MOCK_DIR
is set in the environment.
"""
from __future__ import annotations
from pathlib import Path
class MockNotFound(Exception):
pass
class MockUserInputMissing(Exception):
pass
def load(mock_dir: Path, seq: int, template_name: str) -> str:
"""Return the mock dispatch content for a given (seq, template_name) pair.
Lookup order:
1. <seq>-<template_name>.md
2. <template_name>.md (fallback when the orchestrator dispatched a different number of times)
Raises MockNotFound if neither file exists.
"""
mock_dir = Path(mock_dir)
primary = mock_dir / f"{seq}-{template_name}.md"
if primary.exists():
return primary.read_text()
fallback = mock_dir / f"{template_name}.md"
if fallback.exists():
return fallback.read_text()
raise MockNotFound(
f"no mock for seq={seq}, template={template_name!r} in {mock_dir} "
f"(tried {primary.name} and {fallback.name})"
)
def load_user_input(mock_user_input_dir: Path | None, turn_n: int) -> str:
"""Return the canned user input for turn N.
Raises MockUserInputMissing when the directory is None or turn-<N>.md is absent.
This exception, when raised inside build's process, causes build to halt for input —
which is the b4 fixture's PASS signal (detected by the harness via absent on-disk
artifacts, not by catching this exception across a process boundary).
"""
if mock_user_input_dir is None:
raise MockUserInputMissing(f"no mock user-input dir; cannot fetch turn-{turn_n}")
mock_user_input_dir = Path(mock_user_input_dir)
f = mock_user_input_dir / f"turn-{turn_n}.md"
if not f.exists():
raise MockUserInputMissing(f"missing turn-{turn_n}.md in {mock_user_input_dir}")
return f.read_text()
evals/README.md
# build-evals — eval gate for the build skill
`build` is the highest-leverage orchestrator in Crucible. Every "implement this" session routes through it. Without a gate, prompt edits to `skills/build/SKILL.md` ship blind. This harness gives the gate.
**v0.1 scope** (see `docs/plans/2026-05-28-304-build-eval-gate-design.md`):
- 4 mocked fixtures covering build's main orchestration paths (`b1` simple-feature, `b2` multi-file, `b3` bugfix/refactor, `b4` design-required halt)
- 1 smoke fixture verifying the eval-gate toggle is a no-op when env vars are unset
- k=3 majority threshold per fixture (manual replicates; no Python-driven orchestrator)
- No CI; no real-PR fixtures; no drift-delta calibration (v0.2)
## How the toggle works
Build's `## Mock Dispatch Mode (eval-gate)` section in SKILL.md is enabled when `CRUCIBLE_BUILD_EVAL_MOCK_DIR` is set. Three env vars together replace the parts of build's runtime that would otherwise dispatch real subagents or ask the user questions:
| Env var | Behavior when set | When unset |
|---|---|---|
| `CRUCIBLE_BUILD_EVAL_MOCK_DIR` | Each `Use crucible:<skill>` / Task tool invocation is replaced by reading `<seq>-<template-name>.md` (fallback `<template-name>.md`) from this dir and treating it as the subagent's return | Real dispatch (production behavior) |
| `CRUCIBLE_BUILD_EVAL_MODE` | Pre-set answer (`feature` or `refactor`) to build's Mode Detection question | Build asks the user normally |
| `CRUCIBLE_BUILD_EVAL_USER_INPUT_DIR` | Per-turn AskUserQuestion answers from `turn-<N>.md`; missing turn → halt | Build asks the user normally |
The dispatch file is still written normally (trace integrity); only the Task/Agent tool invocation is substituted. Missing mock → fast-fail with `MockNotFound` (no silent fallthrough).
## Running a fixture
```sh
# 1. Stage: prepares a tmpdir + env, returns JSON
python3 -m skills.build.evals.run_evals stage --fixture b1-simple-feature
# { "workdir": "...", "env": { "CRUCIBLE_BUILD_EVAL_MOCK_DIR": "...", "HOME": "...", ... } }
# 2. In a fresh shell, cd to the workdir and export env vars
WD=/tmp/build-evals-work/b1-simple-feature-<hash>
cd "$WD"
export HOME="$WD/.home"
export CRUCIBLE_BUILD_EVAL_MOCK_DIR=".../mock-dispatch"
/build "Add a function get_user_email(user_id) to src/users.py..."
# 3. Score after build exits
python3 -m skills.build.evals.run_evals score --fixture b1-simple-feature --build-output "$WD"
```
The script wrapper does the same thing:
```sh
bash scripts/build-evals.sh stage --fixture b1-simple-feature
```
## k=3 majority threshold
Build's orchestrator (the LLM driving it) is non-deterministic. With sub-skills mocked from disk, expectation evaluation is deterministic — but build's orchestration path can vary across runs on identical inputs. **k=3 majority** is the noise filter: each fixture is run 3 times, fixture PASSes iff ≥2/3 trials satisfy all expectations.
k=3 is a coarse filter, not a statistical test. It does not measure the false-pass rate. v0.2 (real-PR fixtures + drift-delta) will quantify the rate.
## Smoke fixture (`smoke-no-mock`)
The mocked fixtures cannot prove that the production (unmocked) path still works — by construction, they never exercise it. The smoke fixture genuinely runs `/build` with real subagent dispatches against a trivial task, with **all three** `CRUCIBLE_BUILD_EVAL_*` env vars deliberately omitted from `stage`'s returned env dict. If smoke FAILS, build's SKILL.md edit broke production — rollback the SKILL.md edit before continuing.
- k=1 (real dispatch is expensive — ~10-30 minutes)
- Excluded from `run-all` by default; opt-in via `--include-smoke` (when run-all lands; v0.1 is manual)
## Test isolation
`stage` creates a fresh tmpdir as the project root, copies the fixture's `seed/` into it, runs `git init && commit` to establish a baseline, writes the baseline SHA to `<workdir>/.eval-baseline-sha` (used by the `working_tree_unchanged_from` expectation's `BASELINE` placeholder), and sets `HOME=<workdir>/.home` so build's `~/.claude/projects/<hash>/memory/` writes land in the tmpdir.
In v0.1 tmpdirs are **always preserved** (no automatic cleanup) — remove `/tmp/build-evals-work/` manually when done. `score` does not delete the workdir on PASS, and `stage` does not create a per-run dispatch session; both are v0.2 follow-ups.
## Adding a new fixture
1. Create `fixtures/<id>/fixture.json` with `id`, `task`, `expectations`. Optional: `mode` (`feature` or `refactor`), `no_mock: true` (for smoke-style fixtures), `_notes` (string, soft documentation).
2. Create `fixtures/<id>/seed/` with the starting file tree.
3. Create `fixtures/<id>/mock-dispatch/` with one `<seq>-<template>.md` per expected dispatch (or `<template>.md` for templates that fire multiple times).
4. Optional: `fixtures/<id>/mock-user-input/` with `turn-<N>.md` files for AskUserQuestion replies (or leave empty to test halt-for-input, as b4 does).
5. Run `stage`; in a fresh shell, run `/build "<task>"`; run `score`. Iterate.
Mock files follow the `<seq>-<template-name>.md` schema (e.g. `1-plan-writer.md`). When the orchestrator dispatches a template multiple times with equivalent expected outputs, drop the seq prefix: `plan-writer.md` is the fallback for ANY seq.
If `MockNotFound` fires on a template you didn't anticipate, rename your file to match what build actually dispatched. Do NOT add silent fallthrough — fast-fail is the design.
## Expectation types
| Type | Fields | What it checks |
|---|---|---|
| `file_exists` | `path` | path exists under workdir |
| `file_does_not_exist` | `path` | path is absent under workdir |
| `file_contains` | `path`, `pattern` | substring match |
| `function_defined` | `file`, `name` | AST-parsed; matches FunctionDef, AsyncFunctionDef, ClassDef, or class methods |
| `manifest_contains_dispatch` | `skill`, `count_min`, `count_max` | substring match against template/skill fields in manifest.jsonl |
| `manifest_does_not_contain` | `skill` | count==0 |
| `gate_ledger_phase_status` | `phase`, `status` | parses `build-gate-ledger.md` for the phase block's Status line |
| `working_tree_unchanged_from` | `baseline_sha` (or `"BASELINE"` to resolve from `<workdir>/.eval-baseline-sha`) | `git diff --quiet <sha>` |
Python-only for `function_defined` in v0.1.
## Pre-push reminder hook
`scripts/hooks/pre-push-build-evals.sh` is an opt-in reminder (not a gate) that fires when a push includes changes to `skills/build/SKILL.md` or build dispatch prompt templates. It prints a reminder asking whether the fixtures were re-run; it does not block the push. Install with:
```sh
cp scripts/hooks/pre-push-build-evals.sh .git/hooks/pre-push
chmod +x .git/hooks/pre-push
```
Real enforcement (CI gate) is a v0.2 follow-up.
## Layout
```
skills/build/evals/
├── README.md (this file)
├── __init__.py
├── conftest.py (makes the harness importable in tests)
├── expectations.py (pluggable checkers)
├── fixture_loader.py (load fixture.json → Fixture dataclass)
├── mock_dispatcher.py (load(seq, template), load_user_input(turn))
├── run_evals.py (stage/score + CLI)
├── test_expectations.py
├── test_fixture_loader.py
├── test_mock_dispatcher.py
├── test_run_evals.py
└── fixtures/
├── b1-simple-feature/ (feature mode, design skipped)
├── b2-multi-file/ (feature mode, design fires, dependency order)
├── b3-bugfix/ (refactor mode, contract-test-writer dispatch)
├── b4-design-required/ (feature mode, design returns NEEDS_CLARIFICATION, build halts)
└── smoke-no-mock/ (real dispatch — verifies toggle is a no-op when unset)
```
Self-tests live alongside the modules (`skills/build/evals/test_*.py`) so the project-level `tests/` gitignore rule doesn't catch them. Run them with `pytest skills/build/evals/`.
evals/run_evals.py
"""build-evals harness entry point.
Stage / score / CLI for the build skill eval gate. The harness is split into:
stage(fixture_id, work_root) -> StageResult prepares a tmpdir + env for /build
score(fixture, workdir) -> FixtureResult evaluates expectations after /build ran
The harness does NOT drive /build itself — build runs in a separate shell. This keeps
the harness pure and avoids modeling the Crucible orchestrator runtime in Python.
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import subprocess
import sys
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from .expectations import CheckContext, check
from .fixture_loader import load_fixture
FIXTURES_ROOT = Path(__file__).resolve().parent / "fixtures"
@dataclass
class StageResult:
fixture_id: str
workdir: Path
baseline_sha: str
env: dict[str, str]
def to_dict(self) -> dict:
return {
"fixture_id": self.fixture_id,
"workdir": str(self.workdir),
"baseline_sha": self.baseline_sha,
"env": self.env,
}
@dataclass
class FixtureResult:
fixture_id: str
passed: bool
expectations: list[dict] = field(default_factory=list)
def to_dict(self) -> dict:
return {
"fixture_id": self.fixture_id,
"passed": self.passed,
"expectations": self.expectations,
}
# ---------------- stage ----------------
def _git(*args: str, cwd: Path) -> subprocess.CompletedProcess:
return subprocess.run(
["git", *args], cwd=cwd, capture_output=True, text=True, check=True
)
def stage(fixture_id: str, work_root: Path, fixtures_root: Path | None = None) -> StageResult:
"""Prepare a workdir for a fixture run.
Steps:
1. Resolve fixture directory under fixtures_root
2. Create tmpdir under work_root, copy seed/ in as the project root
3. git init + add + commit, capture baseline SHA
4. Write <workdir>/.eval-baseline-sha
5. Compose env dict (deps on fixture.no_mock and fixture.mode)
"""
fixtures_root = Path(fixtures_root or FIXTURES_ROOT)
fixture_dir = fixtures_root / fixture_id
fixture = load_fixture(fixture_dir)
work_root = Path(work_root)
work_root.mkdir(parents=True, exist_ok=True)
workdir = work_root / f"{fixture_id}-{uuid.uuid4().hex[:8]}"
workdir.mkdir()
# copy seed contents directly into workdir
_copytree_into(fixture.seed_dir, workdir)
# set up isolated HOME
home = workdir / ".home"
home.mkdir()
# git init + initial commit
_git("init", "-q", "-b", "main", cwd=workdir)
_git("config", "user.email", "build-evals@example.invalid", cwd=workdir)
_git("config", "user.name", "build-evals", cwd=workdir)
_git("add", "-A", cwd=workdir)
_git("commit", "-q", "-m", "seed", cwd=workdir)
sha = _git("rev-parse", "HEAD", cwd=workdir).stdout.strip()
(workdir / ".eval-baseline-sha").write_text(sha)
env: dict[str, str] = {"HOME": str(home)}
if not fixture.no_mock:
env["CRUCIBLE_BUILD_EVAL_MOCK_DIR"] = str(fixture.mock_dispatch_dir)
if fixture.mode is not None:
env["CRUCIBLE_BUILD_EVAL_MODE"] = fixture.mode
if fixture.mock_user_input_dir is not None:
# Present even when empty (b4's dir holds only .gitkeep): build's
# AskUserQuestion finds no turn-N reply and halts cleanly.
env["CRUCIBLE_BUILD_EVAL_USER_INPUT_DIR"] = str(fixture.mock_user_input_dir)
return StageResult(fixture_id=fixture_id, workdir=workdir, baseline_sha=sha, env=env)
def _copytree_into(src: Path, dst: Path) -> None:
for item in src.iterdir():
target = dst / item.name
if item.is_dir():
shutil.copytree(item, target)
else:
shutil.copy2(item, target)
# ---------------- score ----------------
def score(fixture_id: str, build_output_dir: Path, fixtures_root: Path | None = None) -> FixtureResult:
"""Evaluate a fixture's expectations against the on-disk artifacts left by /build."""
fixtures_root = Path(fixtures_root or FIXTURES_ROOT)
fixture = load_fixture(fixtures_root / fixture_id)
workdir = Path(build_output_dir)
# discover manifest + gate ledger if present
manifest_path = _find_first(workdir, ["manifest.jsonl"])
gate_ledger_path = _find_first(workdir, ["build-gate-ledger.md", ".claude/build-gate-ledger.md"])
baseline_sha_file = workdir / ".eval-baseline-sha"
ctx = CheckContext(
workdir=workdir,
manifest_path=manifest_path,
gate_ledger_path=gate_ledger_path,
git_repo=workdir if (workdir / ".git").exists() else None,
baseline_sha_file=baseline_sha_file if baseline_sha_file.exists() else None,
)
expectation_results: list[dict] = []
overall = True
for exp in fixture.expectations:
r = check(exp, ctx)
expectation_results.append(
{"type": exp.get("type"), "passed": r.passed, "detail": r.detail, "expectation": exp}
)
if not r.passed:
overall = False
return FixtureResult(fixture_id=fixture_id, passed=overall, expectations=expectation_results)
def _find_first(root: Path, rel_candidates: list[str]) -> Path | None:
"""Try several relative paths under root; return the first that exists."""
for rel in rel_candidates:
p = root / rel
if p.exists():
return p
# also try a recursive find for manifest.jsonl which build writes under a dispatch dir.
# Sort for deterministic selection when a multi-phase pipeline leaves several.
if rel_candidates and rel_candidates[0] == "manifest.jsonl":
hits = sorted(root.rglob("manifest.jsonl"))
if hits:
return hits[0]
return None
# ---------------- CLI ----------------
def _cmd_stage(args: argparse.Namespace) -> int:
result = stage(args.fixture, Path(args.work_root))
print(json.dumps(result.to_dict(), indent=2))
return 0
def _cmd_score(args: argparse.Namespace) -> int:
result = score(args.fixture, Path(args.build_output))
print(json.dumps(result.to_dict(), indent=2))
return 0 if result.passed else 1
def _cmd_run_all(args: argparse.Namespace) -> int:
print(
"run-all is not yet implemented as an in-process orchestration loop.\n"
"v0.1 design: invoke /build externally per fixture, run `score` for each.\n"
"See skills/build/evals/README.md for the manual k=3 procedure."
)
return 0
def _cmd_run(args: argparse.Namespace) -> int:
print(
"run is not yet implemented as an in-process orchestration loop.\n"
"Use `stage` to set up, invoke /build externally, then use `score`."
)
return 0
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(prog="run_evals", description="build skill eval-gate harness")
sub = p.add_subparsers(dest="cmd", required=True)
sp = sub.add_parser("stage", help="prepare workdir + env for a fixture")
sp.add_argument("--fixture", required=True)
sp.add_argument("--work-root", default="/tmp/build-evals-work")
sp.set_defaults(func=_cmd_stage)
sc = sub.add_parser("score", help="evaluate expectations against a build output dir")
sc.add_argument("--fixture", required=True)
sc.add_argument("--build-output", required=True)
sc.set_defaults(func=_cmd_score)
sub.add_parser("run-all", help="(stub) explains the manual k=3 procedure").set_defaults(func=_cmd_run_all)
rn = sub.add_parser("run", help="(stub) explains the per-fixture procedure")
rn.add_argument("--fixture", required=True)
rn.set_defaults(func=_cmd_run)
args = p.parse_args(argv)
return args.func(args)
if __name__ == "__main__":
sys.exit(main())
evals/test_expectations.py
import json
import subprocess
from pathlib import Path
import pytest
from skills.build.evals.expectations import CheckContext, check
def _ctx(workdir: Path, *, manifest: Path | None = None, ledger: Path | None = None,
baseline_file: Path | None = None) -> CheckContext:
return CheckContext(
workdir=workdir,
manifest_path=manifest,
gate_ledger_path=ledger,
git_repo=workdir if (workdir / ".git").exists() else None,
baseline_sha_file=baseline_file,
)
# ---- file_exists / file_does_not_exist ----
def test_file_exists_pass(tmp_path: Path) -> None:
(tmp_path / "a.py").write_text("x")
assert check({"type": "file_exists", "path": "a.py"}, _ctx(tmp_path)).passed
def test_file_exists_fail(tmp_path: Path) -> None:
assert not check({"type": "file_exists", "path": "missing.py"}, _ctx(tmp_path)).passed
def test_file_does_not_exist_pass(tmp_path: Path) -> None:
assert check({"type": "file_does_not_exist", "path": "nope.py"}, _ctx(tmp_path)).passed
def test_file_does_not_exist_fail(tmp_path: Path) -> None:
(tmp_path / "here.py").write_text("x")
assert not check({"type": "file_does_not_exist", "path": "here.py"}, _ctx(tmp_path)).passed
# ---- file_contains ----
def test_file_contains_pass(tmp_path: Path) -> None:
(tmp_path / "a.py").write_text("hello world")
assert check({"type": "file_contains", "path": "a.py", "pattern": "world"}, _ctx(tmp_path)).passed
def test_file_contains_missing_file_fails(tmp_path: Path) -> None:
assert not check({"type": "file_contains", "path": "x", "pattern": "y"}, _ctx(tmp_path)).passed
# ---- function_defined ----
def test_function_defined_top_level(tmp_path: Path) -> None:
(tmp_path / "m.py").write_text("def get_email(uid):\n return ''\n")
assert check({"type": "function_defined", "file": "m.py", "name": "get_email"}, _ctx(tmp_path)).passed
def test_function_defined_async(tmp_path: Path) -> None:
(tmp_path / "m.py").write_text("async def fetch():\n return None\n")
assert check({"type": "function_defined", "file": "m.py", "name": "fetch"}, _ctx(tmp_path)).passed
def test_function_defined_matches_class(tmp_path: Path) -> None:
(tmp_path / "m.py").write_text("class UserService:\n pass\n")
assert check({"type": "function_defined", "file": "m.py", "name": "UserService"}, _ctx(tmp_path)).passed
def test_function_defined_matches_method(tmp_path: Path) -> None:
(tmp_path / "m.py").write_text("class C:\n def do_thing(self): pass\n")
assert check({"type": "function_defined", "file": "m.py", "name": "do_thing"}, _ctx(tmp_path)).passed
def test_function_defined_no_match(tmp_path: Path) -> None:
(tmp_path / "m.py").write_text("x = 1\n")
assert not check({"type": "function_defined", "file": "m.py", "name": "missing"}, _ctx(tmp_path)).passed
def test_function_defined_syntax_error(tmp_path: Path) -> None:
(tmp_path / "m.py").write_text("def broken(:\n")
r = check({"type": "function_defined", "file": "m.py", "name": "x"}, _ctx(tmp_path))
assert not r.passed and "did not parse" in r.detail
# ---- manifest_contains_dispatch / does_not_contain ----
def _write_manifest(p: Path, entries: list[dict]) -> None:
p.write_text("\n".join(json.dumps(e) for e in entries) + "\n")
def test_manifest_contains_dispatch_pass(tmp_path: Path) -> None:
mf = tmp_path / "manifest.jsonl"
_write_manifest(mf, [{"template": "plan-writer-prompt.md"}, {"template": "implementer-prompt.md"}])
exp = {"type": "manifest_contains_dispatch", "skill": "plan-writer", "count_min": 1, "count_max": 1}
assert check(exp, _ctx(tmp_path, manifest=mf)).passed
def test_manifest_contains_dispatch_count_violation(tmp_path: Path) -> None:
mf = tmp_path / "manifest.jsonl"
_write_manifest(mf, [{"template": "implementer.md"}, {"template": "implementer.md"}, {"template": "implementer.md"}])
exp = {"type": "manifest_contains_dispatch", "skill": "implementer", "count_min": 1, "count_max": 2}
assert not check(exp, _ctx(tmp_path, manifest=mf)).passed
def test_manifest_does_not_contain_pass(tmp_path: Path) -> None:
mf = tmp_path / "manifest.jsonl"
_write_manifest(mf, [{"template": "plan-writer"}])
assert check({"type": "manifest_does_not_contain", "skill": "design"}, _ctx(tmp_path, manifest=mf)).passed
def test_manifest_does_not_contain_fail(tmp_path: Path) -> None:
mf = tmp_path / "manifest.jsonl"
_write_manifest(mf, [{"template": "design-prompt.md"}])
assert not check({"type": "manifest_does_not_contain", "skill": "design"}, _ctx(tmp_path, manifest=mf)).passed
def test_manifest_skips_malformed_lines(tmp_path: Path) -> None:
mf = tmp_path / "manifest.jsonl"
mf.write_text('{"template":"plan-writer"}\nNOT JSON\n{"template":"plan-writer"}\n')
exp = {"type": "manifest_contains_dispatch", "skill": "plan-writer", "count_min": 2, "count_max": 2}
assert check(exp, _ctx(tmp_path, manifest=mf)).passed
# ---- gate_ledger_phase_status ----
def test_gate_ledger_phase_status_pass(tmp_path: Path) -> None:
led = tmp_path / "ledger.md"
led.write_text(
"# Ledger\n## Phase 1: Design\nStatus: PASS\n\n## Phase 2: Plan\nStatus: NOT_STARTED\n"
)
exp = {"type": "gate_ledger_phase_status", "phase": "1", "status": "PASS"}
assert check(exp, _ctx(tmp_path, ledger=led)).passed
def test_gate_ledger_phase_status_mismatch(tmp_path: Path) -> None:
led = tmp_path / "ledger.md"
led.write_text("## Phase 4: Completion\nStatus: NOT_STARTED\n")
exp = {"type": "gate_ledger_phase_status", "phase": "4", "status": "PASS"}
assert not check(exp, _ctx(tmp_path, ledger=led)).passed
def test_gate_ledger_missing_phase(tmp_path: Path) -> None:
led = tmp_path / "ledger.md"
led.write_text("## Phase 1: Design\nStatus: PASS\n")
exp = {"type": "gate_ledger_phase_status", "phase": "2", "status": "PASS"}
assert not check(exp, _ctx(tmp_path, ledger=led)).passed
# ---- working_tree_unchanged_from ----
def _git(*args: str, cwd: Path) -> None:
subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True)
def test_working_tree_unchanged_pass(tmp_path: Path) -> None:
_git("init", "-q", "-b", "main", cwd=tmp_path)
_git("config", "user.email", "t@x", cwd=tmp_path)
_git("config", "user.name", "t", cwd=tmp_path)
(tmp_path / "a.txt").write_text("hi")
_git("add", "-A", cwd=tmp_path)
_git("commit", "-q", "-m", "seed", cwd=tmp_path)
sha = subprocess.run(["git", "rev-parse", "HEAD"], cwd=tmp_path, capture_output=True, text=True).stdout.strip()
(tmp_path / ".eval-baseline-sha").write_text(sha)
# Eval-harness scaffolding inside the workdir must not read as a build leak.
home = tmp_path / ".home" / ".claude"
home.mkdir(parents=True)
(home / "pipeline-status.md").write_text("build process artifact")
ctx = _ctx(tmp_path, baseline_file=tmp_path / ".eval-baseline-sha")
assert check({"type": "working_tree_unchanged_from", "baseline_sha": "BASELINE"}, ctx).passed
def test_working_tree_unchanged_detects_diff(tmp_path: Path) -> None:
_git("init", "-q", "-b", "main", cwd=tmp_path)
_git("config", "user.email", "t@x", cwd=tmp_path)
_git("config", "user.name", "t", cwd=tmp_path)
(tmp_path / "a.txt").write_text("hi")
_git("add", "-A", cwd=tmp_path)
_git("commit", "-q", "-m", "seed", cwd=tmp_path)
sha = subprocess.run(["git", "rev-parse", "HEAD"], cwd=tmp_path, capture_output=True, text=True).stdout.strip()
(tmp_path / ".eval-baseline-sha").write_text(sha)
(tmp_path / "a.txt").write_text("MODIFIED")
ctx = _ctx(tmp_path, baseline_file=tmp_path / ".eval-baseline-sha")
assert not check({"type": "working_tree_unchanged_from", "baseline_sha": "BASELINE"}, ctx).passed
def test_working_tree_unchanged_detects_untracked_file(tmp_path: Path) -> None:
# An untracked new file is the most likely b4-halt failure shape (build leaks
# a file it created). `git diff` is blind to it, so the check must catch it.
_git("init", "-q", "-b", "main", cwd=tmp_path)
_git("config", "user.email", "t@x", cwd=tmp_path)
_git("config", "user.name", "t", cwd=tmp_path)
(tmp_path / "a.txt").write_text("hi")
_git("add", "-A", cwd=tmp_path)
_git("commit", "-q", "-m", "seed", cwd=tmp_path)
sha = subprocess.run(["git", "rev-parse", "HEAD"], cwd=tmp_path, capture_output=True, text=True).stdout.strip()
(tmp_path / ".eval-baseline-sha").write_text(sha)
(tmp_path / "leaked.py").write_text("# build should not have created this")
ctx = _ctx(tmp_path, baseline_file=tmp_path / ".eval-baseline-sha")
r = check({"type": "working_tree_unchanged_from", "baseline_sha": "BASELINE"}, ctx)
assert not r.passed and "untracked" in r.detail
# ---- error paths ----
def test_unknown_expectation_type(tmp_path: Path) -> None:
r = check({"type": "nonsense"}, _ctx(tmp_path))
assert not r.passed and "unknown expectation type" in r.detail
def test_missing_type_field(tmp_path: Path) -> None:
r = check({}, _ctx(tmp_path))
assert not r.passed and "missing 'type'" in r.detail
evals/test_fixture_loader.py
import json
from pathlib import Path
import pytest
from skills.build.evals.fixture_loader import Fixture, FixtureSchemaError, load_fixture
def _write_fixture(root: Path, fixture_id: str, data: dict, *, seed: bool = True, mui: bool = False) -> Path:
fdir = root / fixture_id
fdir.mkdir()
(fdir / "fixture.json").write_text(json.dumps(data))
if seed:
(fdir / "seed").mkdir()
(fdir / "seed" / "marker.txt").write_text("hi")
if mui:
(fdir / "mock-user-input").mkdir()
return fdir
def test_load_minimal_fixture(tmp_path: Path) -> None:
d = _write_fixture(tmp_path, "f", {"id": "f", "task": "do thing", "expectations": []})
fx = load_fixture(d)
assert isinstance(fx, Fixture)
assert fx.id == "f"
assert fx.task == "do thing"
assert fx.expectations == []
assert fx.mode is None
assert fx.mock_user_input_dir is None
assert fx.no_mock is False
def test_load_with_mode_and_mock_user_input(tmp_path: Path) -> None:
d = _write_fixture(
tmp_path,
"f",
{"id": "f", "task": "x", "expectations": [], "mode": "refactor"},
mui=True,
)
fx = load_fixture(d)
assert fx.mode == "refactor"
assert fx.mock_user_input_dir == d / "mock-user-input"
def test_load_with_no_mock_flag(tmp_path: Path) -> None:
d = _write_fixture(tmp_path, "smoke", {"id": "smoke", "task": "x", "expectations": [], "no_mock": True})
assert load_fixture(d).no_mock is True
def test_missing_fixture_json_raises(tmp_path: Path) -> None:
(tmp_path / "f").mkdir()
(tmp_path / "f" / "seed").mkdir()
with pytest.raises(FixtureSchemaError, match="missing fixture.json"):
load_fixture(tmp_path / "f")
def test_malformed_json_raises(tmp_path: Path) -> None:
d = tmp_path / "f"
d.mkdir()
(d / "fixture.json").write_text("{not valid json")
(d / "seed").mkdir()
with pytest.raises(FixtureSchemaError, match="invalid JSON"):
load_fixture(d)
def test_missing_required_keys_raises(tmp_path: Path) -> None:
d = tmp_path / "f"
d.mkdir()
(d / "fixture.json").write_text(json.dumps({"id": "x"}))
(d / "seed").mkdir()
with pytest.raises(FixtureSchemaError, match="missing required keys"):
load_fixture(d)
def test_missing_seed_raises(tmp_path: Path) -> None:
d = tmp_path / "f"
d.mkdir()
(d / "fixture.json").write_text(json.dumps({"id": "f", "task": "x", "expectations": []}))
with pytest.raises(FixtureSchemaError, match="missing seed/"):
load_fixture(d)
def test_expectations_must_be_list(tmp_path: Path) -> None:
d = tmp_path / "f"
d.mkdir()
(d / "fixture.json").write_text(json.dumps({"id": "f", "task": "x", "expectations": "not a list"}))
(d / "seed").mkdir()
with pytest.raises(FixtureSchemaError, match="must be a list"):
load_fixture(d)
evals/test_mock_dispatcher.py
from pathlib import Path
import pytest
from skills.build.evals.mock_dispatcher import (
MockNotFound,
MockUserInputMissing,
load,
load_user_input,
)
def test_load_finds_seq_specific(tmp_path: Path) -> None:
(tmp_path / "1-plan-writer.md").write_text("PLAN OUT")
assert load(tmp_path, 1, "plan-writer") == "PLAN OUT"
def test_load_falls_back_to_template_name(tmp_path: Path) -> None:
(tmp_path / "implementer.md").write_text("IMPL OUT")
assert load(tmp_path, 17, "implementer") == "IMPL OUT"
def test_load_prefers_seq_over_fallback(tmp_path: Path) -> None:
(tmp_path / "2-x.md").write_text("SEQ")
(tmp_path / "x.md").write_text("FALLBACK")
assert load(tmp_path, 2, "x") == "SEQ"
def test_load_raises_when_neither_exists(tmp_path: Path) -> None:
with pytest.raises(MockNotFound, match="no mock"):
load(tmp_path, 1, "nope")
def test_load_user_input_reads_turn_file(tmp_path: Path) -> None:
(tmp_path / "turn-1.md").write_text("USER SAYS GO")
assert load_user_input(tmp_path, 1) == "USER SAYS GO"
def test_load_user_input_raises_when_dir_is_none() -> None:
with pytest.raises(MockUserInputMissing, match="no mock user-input dir"):
load_user_input(None, 1)
def test_load_user_input_raises_when_turn_missing(tmp_path: Path) -> None:
with pytest.raises(MockUserInputMissing, match="missing turn-3"):
load_user_input(tmp_path, 3)
evals/test_run_evals.py
import json
from pathlib import Path
from skills.build.evals.run_evals import score, stage
def _mk_fixture(root: Path, fixture_id: str, *, mode: str | None = None, no_mock: bool = False,
expectations: list[dict] | None = None, mui: bool = False) -> Path:
fdir = root / fixture_id
fdir.mkdir(parents=True)
body: dict = {"id": fixture_id, "task": "do thing", "expectations": expectations or []}
if mode is not None:
body["mode"] = mode
if no_mock:
body["no_mock"] = True
(fdir / "fixture.json").write_text(json.dumps(body))
seed = fdir / "seed"
seed.mkdir()
(seed / "src").mkdir()
(seed / "src" / "__init__.py").write_text("")
(fdir / "mock-dispatch").mkdir()
if mui:
(fdir / "mock-user-input").mkdir()
return fdir
# ---- stage ----
def test_stage_creates_workdir_and_git_init(tmp_path: Path) -> None:
fixtures = tmp_path / "fixtures"
_mk_fixture(fixtures, "f")
out = stage("f", tmp_path / "work", fixtures_root=fixtures)
assert out.workdir.is_dir()
assert (out.workdir / ".git").is_dir()
assert (out.workdir / "src" / "__init__.py").exists()
assert (out.workdir / ".eval-baseline-sha").read_text() == out.baseline_sha
assert len(out.baseline_sha) >= 7 # short SHA at minimum
def test_stage_env_for_mock_fixture(tmp_path: Path) -> None:
fixtures = tmp_path / "fixtures"
_mk_fixture(fixtures, "f", mode="feature")
out = stage("f", tmp_path / "work", fixtures_root=fixtures)
assert "CRUCIBLE_BUILD_EVAL_MOCK_DIR" in out.env
assert out.env["CRUCIBLE_BUILD_EVAL_MODE"] == "feature"
assert out.env["HOME"].endswith(".home")
def test_stage_env_for_no_mock_fixture_omits_mock_vars(tmp_path: Path) -> None:
fixtures = tmp_path / "fixtures"
_mk_fixture(fixtures, "smoke", no_mock=True)
out = stage("smoke", tmp_path / "work", fixtures_root=fixtures)
assert "CRUCIBLE_BUILD_EVAL_MOCK_DIR" not in out.env
assert "CRUCIBLE_BUILD_EVAL_MODE" not in out.env
assert "CRUCIBLE_BUILD_EVAL_USER_INPUT_DIR" not in out.env
assert out.env["HOME"].endswith(".home")
def test_stage_env_for_b4_with_empty_mock_user_input(tmp_path: Path) -> None:
fixtures = tmp_path / "fixtures"
_mk_fixture(fixtures, "b4", mode="feature", mui=True)
out = stage("b4", tmp_path / "work", fixtures_root=fixtures)
assert "CRUCIBLE_BUILD_EVAL_USER_INPUT_DIR" in out.env
# ---- score ----
def test_score_empty_build_output_fails_all(tmp_path: Path) -> None:
fixtures = tmp_path / "fixtures"
_mk_fixture(fixtures, "f", expectations=[
{"type": "file_exists", "path": "expected.py"},
{"type": "function_defined", "file": "expected.py", "name": "foo"},
])
empty = tmp_path / "empty"
empty.mkdir()
r = score("f", empty, fixtures_root=fixtures)
assert not r.passed
assert all(not exp["passed"] for exp in r.expectations)
def test_score_passes_when_all_expectations_pass(tmp_path: Path) -> None:
fixtures = tmp_path / "fixtures"
_mk_fixture(fixtures, "f", expectations=[{"type": "file_exists", "path": "src/__init__.py"}])
# use stage so we get a proper workdir with seed copied in
staged = stage("f", tmp_path / "work", fixtures_root=fixtures)
r = score("f", staged.workdir, fixtures_root=fixtures)
assert r.passed
assert all(exp["passed"] for exp in r.expectations)
def test_score_baseline_placeholder_uses_stage_file(tmp_path: Path) -> None:
"""`working_tree_unchanged_from` with baseline_sha=BASELINE should resolve from .eval-baseline-sha."""
fixtures = tmp_path / "fixtures"
_mk_fixture(fixtures, "f", expectations=[
{"type": "working_tree_unchanged_from", "baseline_sha": "BASELINE"},
])
staged = stage("f", tmp_path / "work", fixtures_root=fixtures)
r = score("f", staged.workdir, fixtures_root=fixtures)
assert r.passed, r.expectations
plan-reviewer-prompt.md
<!-- DISPATCH: disk-mediated | This template is written to a dispatch file,
not pasted into the Agent tool prompt. See shared/dispatch-convention.md -->
# Plan Reviewer Prompt Template
Use this template when dispatching a plan reviewer subagent in Phase 2.
```
Task tool (general-purpose, model: opus or sonnet — see build skill for decision heuristic):
description: "Review implementation plan for [feature]"
prompt: |
You are reviewing an implementation plan against its design document.
## Design Document
[FULL TEXT of the design doc]
## Implementation Plan
[FULL TEXT of the implementation plan]
## Your Job
Compare the plan against the design doc. Check:
**Completeness:**
- Does the plan cover ALL requirements from the design doc?
- Are there design requirements with no corresponding task?
- Are there tasks that don't trace back to a design requirement?
**Task Quality:**
- Does every task have metadata (Files, Complexity, Review-Tier, Dependencies)?
- Are file paths exact and correct?
- Is code complete (not placeholder or "add X here")?
- Are tasks sized appropriately (2-3 per subagent, ~10 files max)?
- Do tasks follow TDD (test before implementation)?
- Does every task have a Review-Tier (1, 2, or 3)?
- Are Review-Tier assignments consistent with the classification rules?
- No task with High complexity or 6+ files at Tier 1 or 2
- No task with cross-system dependencies at Tier 1
- No task introducing a new public API at Tier 1
- Tasks near cartographer landmines must be Tier 3
- Are any tasks under-tiered? (err toward higher tier when uncertain)
- **Refactor mode:** GREEN-GREEN tasks do NOT have a RED phase. Do not flag the absence of a failing-test-first step as a deficiency on tasks marked `atomic: true` or `restructuring-only: true`. The success criterion for these tasks is "existing tests stay green," not "new test passes." Verify instead that:
- Each task lists which existing tests must remain passing ("Tests to verify")
- Atomic tasks bundle the interface change with all consumer updates
- Tasks have refactor metadata (Atomic, Restructuring-only, Safe-partial, Rollback, Tests to verify)
- The bite-sized step exception is respected (atomic tasks are not split into multiple tasks)
**Dependencies:**
- Is the dependency graph correct? (No missing edges, no cycles)
- Are shared-file conflicts identified? (Tasks touching same file should be sequential)
- Is the ordering sensible? (Foundation before features, data before UI)
**Architectural Alignment:**
- Does the plan follow the architecture described in the design doc?
- Are the right patterns being used (DI, events, ScriptableObjects, etc.)?
- Are there architectural concerns that should be escalated to the user?
**IMPORTANT:** Architectural concerns are IMMEDIATE escalation — flag them clearly and separately from other findings.
## Report Format
- **Overall:** Approved | Needs revision | Architectural concern (escalate)
- **Missing requirements:** [List anything from design doc not covered]
- **Unnecessary tasks:** [List anything not in design doc]
- **Task quality issues:** [Specific findings with task numbers]
- **Tier classification issues:** [Tasks with incorrect or questionable tier assignments]
- **Dependency issues:** [Specific findings]
- **Architectural concerns:** [If any — these bypass revision loop]
- **Suggestions:** [Optional improvements, clearly marked as non-blocking]
```
plan-writer-prompt.md
<!-- DISPATCH: disk-mediated | This template is written to a dispatch file,
not pasted into the Agent tool prompt. See shared/dispatch-convention.md -->
# Plan Writer Prompt Template
Use this template when dispatching a plan writer subagent in Phase 2.
```
Task tool (general-purpose, model: opus):
description: "Write implementation plan for [feature]"
prompt: |
You are writing an implementation plan for a feature.
## Design Document
[FULL TEXT of the design doc — paste it here, don't make the subagent read the file]
## Acceptance Tests
[FULL TEXT or summary of acceptance tests generated from the design. These define "done" at the feature level. If tests couldn't compile (typed language), Task 1 of your plan should create the interfaces/stubs needed for them to compile and fail correctly.]
## Plan Format Requirements
**REQUIRED SUB-SKILL:** Use crucible:planning — follow its format exactly.
**OVERRIDE:** Skip planning's "Execution Handoff" section entirely. Your job is ONLY to write and save the plan. Do NOT ask the user about execution approach — the build pipeline handles execution automatically.
### Per-Task Metadata (REQUIRED)
Every task MUST include this metadata block:
### Task N: [Description]
- **Files:** file1.cs, file2.cs (N files)
- **Complexity:** Low | Medium | High
- **Review-Tier:** 1 | 2 | 3
- **Dependencies:** Task X, Task Y (or "None")
Complexity tiers:
- **Low:** 1-3 files, straightforward changes, no cross-system interaction
- **Medium:** 3-6 files, some inheritance or cross-system interaction
- **High:** 6+ files, refactoring, deep inheritance chains, cross-system wiring
**Review tier classification (compute for every task):**
- **Tier 1 (Light):** Low complexity AND 1-3 files AND no cross-system dependencies AND no cartographer landmine proximity. Pipeline: implementer -> cleanup -> single-pass code review.
- **Tier 2 (Standard):** Medium complexity OR 3-6 files OR single-system behavioral changes. Not eligible if any Tier 3 trigger applies. Pipeline: implementer -> cleanup -> iterative code review -> single-pass test review -> adversarial tester.
- **Tier 3 (Heavy):** High complexity OR 6+ files OR cross-system dependencies OR new public API surface OR cartographer landmine proximity. Pipeline: full current review flow.
**Escalation rules (tier can only increase, never decrease):**
- Cross-system dependencies -> minimum Tier 2
- New public API (interface, endpoint, event) -> minimum Tier 2
- Cartographer landmine proximity (any task file in a landmine directory) -> Tier 3
### Task Sizing
- Target **2-3 tasks per subagent context window** (~10 files max per task)
- Each step within a task is one action (2-5 minutes): write test, run test, implement, run test, commit
- Include exact file paths, complete code, exact commands with expected output
## Project Context
[Key architectural patterns, DI framework, naming conventions, test style]
## Relevant Files
[List key files the plan will need to reference]
## Your Job
1. Read the design document carefully
2. Identify all components, data changes, and test changes needed
3. Determine task dependencies and ordering
4. Write the implementation plan with TDD steps for each task
5. Include per-task metadata (Files, Complexity, Review-Tier, Dependencies)
6. Save to: docs/plans/YYYY-MM-DD-<topic>-implementation-plan.md
## Refactor Mode Context
(This section is appended by the orchestrator ONLY in refactor mode. Omit in feature mode.)
This is a REFACTORING build. The user is restructuring existing code, not adding new behavior.
Success means: existing behavior preserved + structural goals met.
### Input: Impact Manifest and Blast Radius
[FULL TEXT of the impact manifest from Phase 1 blast radius analysis]
### Planning Constraints for Refactor Mode
**Preserve-behavior constraint:**
- Every task must list which existing tests exercise the code being changed (in a "Tests to verify" field)
- The success criterion for each step is "all existing tests still pass" — not "new test passes"
- Tasks that change an interface must specify which tests will need updating and why
**Atomic step detection:**
- When a task modifies a public interface (method signature, class name, module export, type definition), trace all consumers from the impact manifest
- Bundle the interface change + all consumer updates into a single task marked `atomic: true`
- Independent consumers can be split into parallel atomic tasks
- Mark each task with `restructuring-only: true/false` based on whether it changes signatures or control flow
**Consumer migration fan-out:**
- When an interface changes and consumers are independent, create parallel tasks
- Explicitly declare dependencies between consumer migrations
- Consumers that depend on each other get sequential tasks
**Task metadata (required for every refactoring task):**
- **Atomic:** true | false — [reason]
- **Restructuring-only:** true | false
- **Safe-partial:** true | false
- **Rollback:** git revert to pre-task commit
- **Tests to verify:** [list of test files/suites]
**Bite-sized step exception:** Atomic tasks are NOT split into multiple bite-sized tasks. The coordinated change is one commit-unit. Internal steps are execution guidance, not separate commit points.
**Rollback annotations:** Every task must include a rollback annotation. Mark tasks as `safe-partial: true` if the codebase is in a valid, shippable state after that task completes.
## Before Reporting Back
Review your plan:
- Does every task have metadata (Files, Complexity, Review-Tier, Dependencies)?
- Does every task have a Review-Tier computed from the classification rules?
- Are Review-Tier assignments consistent with the tier criteria (no High-complexity task at Tier 1)?
- Are file paths exact (not "somewhere in src/")?
- Is code complete (not "add validation here")?
- Are tasks sized for 2-3 per subagent?
- Does the dependency graph make sense?
- Are there any circular dependencies?
- (Refactor mode) Does every task have refactor metadata (Atomic, Restructuring-only, Safe-partial, Rollback, Tests to verify)?
- (Refactor mode) Are interface-changing tasks marked atomic with all consumers bundled?
- (Refactor mode) Are independent consumer groups split into parallel tasks?
- (Refactor mode) Is the success criterion "existing tests green" (not "new test passes")?
```
refactor-implementer-addendum.md
<!-- DISPATCH: addendum | Appended to build-implementer dispatch file, not dispatched standalone.
See shared/dispatch-convention.md -->
# Refactor Implementer Addendum
Appended to the build implementer dispatch file when the build pipeline is running in **refactor mode**. The orchestrator includes this section after the standard implementer prompt. This addendum overrides TDD discipline with GREEN-GREEN discipline for refactoring tasks.
---
## Refactor Mode: You Are Restructuring, Not Adding Behavior
All existing tests must pass BEFORE and AFTER your changes. You are not adding new features — you are changing code structure while preserving behavior.
### Atomic Task Execution
When a task is marked `atomic: true`:
1. **Record the pre-task commit SHA** before making any changes
2. **Make ALL changes in the task together** — modify every listed file as a coordinated unit. Do NOT commit individual files or make partial changes.
3. **Run blast-radius + direct consumer tests** (the tests listed in the task's "Tests to verify" section) after ALL changes are made — not after each file
4. **If ALL GREEN:** Commit all files together in a single commit
5. **If ANY test FAILS:** Revert ALL files back to the pre-task commit SHA using `git checkout <pre-task-sha> -- .` and then `git clean -fd`. Report the failure to the lead with:
- Which tests failed
- The failure messages
- What changes you attempted
- Do NOT try to fix the failure yourself — revert and report
### GREEN-GREEN Discipline
You are NOT doing RED-GREEN-REFACTOR. There is no RED phase. Your discipline is:
- **GREEN (before):** All existing tests pass before you touch anything. Verify this.
- **CHANGE:** Make the structural changes specified in the task.
- **GREEN (after):** All existing tests still pass after your changes. Verify this.
If you need to write a NEW test, it is only because you are introducing a new internal abstraction (e.g., extracting a class that didn't exist before). In that case, the new test covers the new abstraction — but the existing tests remain your primary constraint.
### Rollback Awareness
- Every task has a rollback target: the pre-task commit SHA
- If your changes break tests, revert to the pre-task commit — do not attempt partial fixes on atomic steps
- For non-atomic tasks, you may attempt to fix failures, but if the fix touches files outside the task scope, revert and report instead
### Non-Atomic Refactoring Tasks
Tasks NOT marked `atomic: true` are structural changes that don't break intermediate states (e.g., extracting a private method, adding a new module that nothing imports yet). For these:
- If the task introduces a new internal abstraction: use standard TDD (write a test for the new abstraction, implement it, verify existing tests still pass)
- If the task is pure restructuring (no new abstractions): use GREEN-GREEN discipline
- Either way, existing tests must remain GREEN throughout
### Refactoring Evidence Log (Replaces TDD Evidence Log)
For GREEN-GREEN tasks, the standard TDD Evidence Log does not apply — there is no RED phase to record. Instead, produce a **Refactoring Evidence Log**:
```
### Refactoring Evidence Log — Task N
**Pre-change state:**
- Test count: N tests passing
- Baseline commit: <SHA>
**Changes made:**
- [description of structural change 1]
- [description of structural change 2]
**Post-change state:**
- Test count: N tests passing (same or higher — never lower)
- All blast-radius + direct consumer tests: GREEN
**No RED phase required** — this is a GREEN-GREEN restructuring task.
```
For tasks that mix restructuring with new internal abstractions, produce BOTH:
- TDD Evidence Log entries for the new abstraction tests (RED-GREEN cycle)
- Refactoring Evidence Log for the restructuring portion (GREEN-GREEN)
### Commit Messages
- Atomic restructuring commits: `refactor: [description of structural change]`
- Non-atomic restructuring commits: `refactor: [description]`
- New internal abstraction tests: `test: add tests for [new abstraction]`
- Do NOT use `feat:` prefix — you are not adding features
### Self-Review Additions for Refactor Mode
In addition to the standard self-review checklist, verify:
- Did I change ONLY what the task specified? (No opportunistic refactoring of nearby code)
- Are all blast-radius + direct consumer tests still passing? (The full suite runs at wave boundaries.)
- For atomic tasks: did I commit all files together in a single commit?
- Is the test count the same or higher after my changes? (Never lower)
- Did I produce a Refactoring Evidence Log (not a TDD Evidence Log) for GREEN-GREEN tasks?
SKILL.md
---
name: build
description: Use when starting any feature development, building new functionality, implementing a design, or going from idea to working code. Triggers on "build", "implement", "add feature", or any task requiring design-through-execution.
---
# Build
## Overview
<!-- CANONICAL: shared/dispatch-convention.md -->
All subagent dispatches use disk-mediated dispatch. See `shared/dispatch-convention.md` for the full protocol.
<!-- CANONICAL: shared/return-convention.md -->
All subagent returns use the Ledger Return Protocol. Every subagent returns exactly one Evidence Receipt per `shared/return-convention.md`; the orchestrator applies the two-tier receipt linter (Tier 1 structural + Tier 2 witness verification — full grammar in the shared convention) to every Task return before acting on the declared VERDICT. The linter is a deterministic runtime tool: orchestrators MUST run `python3 scripts/rcpt_verify.py --tier2 --strict --root <dispatch-root> --ledger <dispatch-root>/receipt-ledger.jsonl <receipt>` on every received receipt before acting on its VERDICT, and apply the shared convention's in-context pseudocode ONLY as the fallback when the tool is unavailable (`--root` is repeatable, but build passes exactly one — the dispatch root; a sha256 mismatch on any resolved artifact hard-FAILs with or without `--strict`, `--strict` additionally hard-FAILs a path-shaped name that neither the dispatch root nor its git toplevel holds, and under a single root the cross-root ambiguity hard-FAIL cannot arise at all — an unresolvable bare basename is UNVERIFIABLE, never a false FAIL). A lint failure is treated as structurally `BLOCKED`.
<!-- CANONICAL: shared/cairn-convention.md -->
The orchestrator maintains a per-run Invariant Cairn at `~/.claude/projects/<project-hash>/memory/cairn/cairn-<run-id>.md` per `shared/cairn-convention.md`. See the `## Cairn (Layer 3)` section below for build-specific phase definitions, terminal condition, and mandatory-invariant categories.
End-to-end development pipeline: interactive design, autonomous planning with adversarial review, team-based execution with per-task code and test review. One command, idea to completion.
**Announce at start:** "I'm using the build skill to run the full development pipeline."
**Session index event:** At startup, if session indexing is active (session index path discoverable via glob), emit a `skill_start` event to the outbox: `{"ts":"<now>","seq":0,"type":"skill_start","summary":"Starting /build for <user goal>","detail":{"skill":"build","goal":"<user goal>"}}`. See `skills/shared/session-index-convention.md` for the outbox pattern.
**Guiding principle:** Quality over velocity. This pipeline produces correct, well-integrated, maintainable output — even if slower. Parallel execution is available for independent work, but sequential with quality gates is the default.
<!-- Trust framework: see [skills/getting-started/trust-hierarchy.md](../getting-started/trust-hierarchy.md). -->
## Mock Dispatch Mode (eval-gate)
This mode exists for the `skills/build/evals/` eval-gate harness. It is enabled **iff** `CRUCIBLE_BUILD_EVAL_MOCK_DIR` is set in the environment. Production runs MUST leave this variable unset, in which case this mode is a no-op and the orchestrator behaves exactly as if this section were not present.
**Env-var contract.** Three variables, all consumed only when `CRUCIBLE_BUILD_EVAL_MOCK_DIR` is set:
- `CRUCIBLE_BUILD_EVAL_MOCK_DIR=<path>` — directory of canned subagent return receipts. Filenames follow `<seq>-<template-name>.md` with fallback `<template-name>.md` (e.g. `1-plan-writer.md`, then `plan-writer.md`). Missing mock → halt immediately with a clear error; no silent fallthrough.
- `CRUCIBLE_BUILD_EVAL_MODE=feature|refactor` — pre-set answer to the Mode Detection prompt. When present, the orchestrator skips the AskUserQuestion call in Mode Detection and uses this value.
- `CRUCIBLE_BUILD_EVAL_USER_INPUT_DIR=<path>` — directory of canned user-input turns, named `turn-<N>.md`. Each AskUserQuestion call (other than Mode Detection, which uses `CRUCIBLE_BUILD_EVAL_MODE`) consumes the next sequential turn. If the next turn-file is missing, halt before proceeding — this is the b4 fixture's design: build correctly stops when it needs input it does not have.
**Substitution rule (defined ONCE here; referenced from intercept sites below):** at every dispatch site, the dispatch file is STILL written to the normal dispatch dir (trace integrity preserved). Only the Task/Agent tool invocation is replaced — instead of invoking the tool, read `$CRUCIBLE_BUILD_EVAL_MOCK_DIR/<seq>-<template-name>.md` (or `<template-name>.md`) and treat its contents as the subagent's return receipt. Apply the normal receipt linter and manifest sweep as if the receipt had come from a live agent.
**Boundary behavior.** `MockNotFound` / `MockUserInputMissing` errors raised by the mock loader halt the build run with a clear stderr message. They do not silently fall through. The eval-gate harness detects these halts via on-disk artifacts (absent phase-handoff manifests, pipeline-active marker still at the original phase) — the harness does NOT catch these exceptions across the build-runtime boundary.
**Pointer reminders.** Sections that reference these env vars (Mode Detection, Phase 1 Step 2 / 3, Phase 2 Step 1 / 2, Phase 3 Step 3) contain short inline pointers back to this section. The substitution rule is defined here only; the pointers exist so a reader scanning a dispatch site doesn't need to re-derive the contract.
**See also:** `skills/build/evals/README.md` for harness usage; `skills/shared/dispatch-convention.md` for the dispatch-file protocol the substitution rule preserves.
## Cairn (Layer 3)
The orchestrator maintains an Invariant Cairn per `shared/cairn-convention.md`. Build-specific bindings:
- **Phase mapping.** The build pipeline's four phases (1 Design, 2 Plan, 3 Execute, 4 Completion) map 1:1 to cairn phases. Mid-phase sub-stages (e.g. Phase 3 Wave N, Phase 4 gate rounds) do NOT get their own cairn phase counter — they are internal to the owning phase and contribute a single LEDGER line when the owning phase completes.
- **Phase transitions.** At every 1→2, 2→3, 3→4 transition, the orchestrator: (a) writes any correctness-critical phase-N invariants, (b) appends the phase-N LEDGER line with `dispatches=/receipts=/verdict=`, (c) single atomic Write advancing PHASE to phase N+1. Uses the phase handoff manifest (`handoff-N-to-M.md`) as input evidence for the invariants.
- **Terminal phase.** Phase 4 sealing — after finish-skill completes and the pipeline-active marker is deleted. At terminal sealing, delete `active-run.md`; leave `cairn-<run-id>.md` in place.
- **Mandatory-invariant categories for build.** Each phase-exit MUST capture:
- **Design exit:** the one-sentence architectural commitment, plus any RED-flag constraint surfaced by red-team that later phases must preserve.
- **Plan exit:** the task list's load-bearing dependencies (e.g. "Task 3 unblocks Tasks 5-7; T2 review tier"); any non-obvious refactoring risk.
- **Execute exit:** every `noticed-not-touching` entry that is correctness-critical for a future task or for post-merge review; every test-gap finding that the run chose to leave uncovered.
- **Completion exit:** acceptance-test outcome; siege dispatch decision + outcome; any skipped gate with `Acknowledged: true`.
- **Reconciliation on phase entry.** Runs the full Reconciliation Pass (5 rules) against `receipt-ledger.jsonl` and the in-context Tripwire Manifest. Rule 1 local-repair is authorized for trailing-receipts-in-current-phase LEDGER under-count only.
- **Composition with Phase Handoff Manifest.** The cairn and the existing Phase Handoff Manifest overlap in intent but not in scope: the handoff manifest is a per-transition snapshot of inputs for the next phase; the cairn is the cumulative load-bearing state across the whole run. Both are maintained; neither replaces the other. On Recovery Protocol invocation, the orchestrator reads the cairn first (authoritative for load-bearing state) then the most recent handoff manifest (authoritative for current-phase inputs).
## Tripwire Manifest Sweep (Layer 2)
Starting with convention **v1.1**, every subagent returns a receipt carrying `TRIPWIRE:`, `SUPERSEDES:`, and (if the subagent dispatched children) `TRIPWIRE-CHILD:` lines. The full grammar and predicate vocabulary live in `shared/return-convention.md`. This section defines how the orchestrator uses them.
**Manifest:** After each Task return (post-lint), append one line to the in-context manifest:
```
<rcpt-sha256-prefix-12> <skill>/<dispatch-id> <verdict> TRIPWIRE: <predicates> [SUPERSEDED_BY=<prefix>] [keys=<skill>:<k>:<v>,…] [files=<path>:<h6>,…]
```
Extract `keys=` and `files=` discriminators at insertion time (severity-max, `*-count` CLAIM keys namespaced by skill; EDIT/WROTE paths with first 6 hex of post-edit hash). Truncate each list at 8; overflow becomes `more=<N>` and forces mandatory fire on `peer-dispatch-disagrees`.
**Sweep (the dispatch-loop clause):** The orchestrator MAY NOT dispatch the next subagent until it has:
1. Applied Layer 1 two-tier linter to the just-returned receipt. Lint failure → re-dispatch, DO NOT sweep.
2. Appended the manifest entry.
3. Processed `SUPERSEDES:` — marked each cited predecessor `SUPERSEDED_BY=<new-prefix>`.
4. Evaluated self-checks (verdict=FAIL, exec-exit!=0, suspicion>=N-self) on the new receipt — no Read needed.
5. Evaluated forward-checks against every active (not `SUPERSEDED_BY=*`) prior manifest entry, over the union of that entry's `TRIPWIRE` and `TRIPWIRE-CHILD` predicate sets:
- `claims-touch(glob)` / `wrote(glob)` / `read(glob)` — path-glob match against the new receipt's TRACE or CLAIMS citations.
- `suspicion>=N` — new receipt's SUSPICION ≥ N.
- `peer-dispatch-disagrees(<dim>)` — same-skill, same-target, discriminator mismatch (evaluated via manifest `keys=`/`files=`; `more=` overflow → mandatory fire).
- `always` — fires unconditionally.
6. For each firing predicate on manifest entry M, `Read` M's full receipt from disk and narrate the re-read: *"tripwire `<predicate>` on <M-prefix> fired from <new-prefix>; re-read M."*
7. Only then dispatch the next subagent.
**Supersession fix-flow.** A fix-agent dispatched after a FAIL receipt normally supersedes that FAIL. Its receipt MUST cite the FAIL's hash-prefix in `SUPERSEDES:` and in at least one CLAIM `from=<prefix>#…`, AND its WITNESS must be `kind ∈ {exec, grep}` with `ran=TRACE#N` (not SKIPPED/UNRUNNABLE). Tier-2 then verifies the witness — supersession only survives if the original failure no longer reproduces.
**Mandatory-work declarations for build's subagent types** (add to each dispatch template's `## Return Format` section):
- Implementer (feature): `run-tests`, `apply-edits`.
- Implementer (refactor, atomic): `run-blast-radius-tests`, `apply-edits`.
- Reviewer (code / test): `read-artifact`, `emit-findings`.
- Cleanup agent: `read-diff`, `emit-recommendation`.
- Plan writer / plan reviewer: `read-design`, `emit-artifact`.
- Acceptance-test writer / test-gap writer / adversarial tester: `run-tests`, `emit-tests`.
## Communication Requirement (Non-Negotiable)
**Between every agent dispatch and every agent completion, output a status update to the user.** This is NOT optional — the user cannot see agent activity without your narration.
Every status update must include:
1. **Current phase** — Which pipeline phase you're in
2. **What just completed** — What the last agent reported
3. **What's being dispatched next** — What you're about to do and why
4. **Task checklist** — Current status of all tasks (pending/in-progress/complete)
**After compaction:** If you just experienced context compaction, re-read the task list from disk and output current status before continuing. Do NOT proceed silently.
**Examples of GOOD narration:**
> "Phase 3, Task 4 complete. Reviewer found 2 Important issues — dispatching implementer to fix. Tasks: [1] ✓ [2] ✓ [3] ✓ [4] fixing [5-8] pending"
> "Phase 2 complete. Plan passed review with 0 issues on round 2. Dispatching innovate on the plan."
**This requirement exists because:** Long-running autonomous pipelines can run for hours. Without narration, the user sees nothing but a spinner. They can't assess progress, can't decide whether to intervene, and can't learn from the pipeline's decisions.
## Pipeline Discipline (Non-Negotiable)
NEVER skip quality gate steps. Every artifact must pass its quality gate before proceeding to the next phase. No exceptions, no shortcuts.
**BLOCK semantics:** Phase transitions are gated. You CANNOT proceed from Phase 1→2, 2→3, or 3→4 without the gate for that phase passing. If a gate fails, fix the issues and re-run the gate. Do not silently skip a gate because "it looks fine" or "we already reviewed it."
**If you find yourself about to skip a gate:** STOP. Re-read this section. The gate exists because skipping it has caused real production incidents and hours of wasted time. Run the gate.
## Anti-Rationalization Table — build
| Rationalization | Rebuttal | Rule |
|---|---|---|
| "This task is small/simple/trivial, the quality gate would just find nits." | Small changes have the same bug density per line as large ones. QG has never run on a Crucible artifact without finding at least one real issue. | Run the quality gate on every phase artifact, regardless of size. |
| "Phase N looks fine, I can skip the gate and move on." | Self-assessment of artifact quality is exactly the bias the gate exists to counter. "Looks fine" is the failure mode, not a pass criterion. | Phase transitions are BLOCKED without a verified PASS verdict marker for the prior phase. |
| "The fix agent addressed the findings, so the gate is done." | Fixing is not passing. Fix rounds routinely introduce new issues or incompletely resolve old ones. A clean verification round is required. | The gate is only complete after a fresh red-team round returns 0 Fatal, 0 Significant. |
| "The user said 'looks good' / 'move on' — that's approval to skip the gate." | General feedback is not skip approval. Only an unambiguous instruction that explicitly references the gate counts. | Require literal `SKIP GATE` (or equivalent explicit phrase) before recording `Status: SKIPPED`. |
| "I can fix this one finding myself instead of dispatching a fix agent." | Orchestrator-applied fixes conflate coordination with remediation and bypass the fix journal. Every fix — even trivial — goes through a fix agent. | Orchestrator never edits the artifact directly; always dispatch the fix agent. |
| "Innovate/red-team seem redundant on top of the quality gate, I'll skip them." | They are not redundant. Innovate is divergent; red-team is adversarial; QG is iterative remediation. Skipping any one of them is a documented regression (`feedback_never_skip_gates`). | Run innovate and red-team on every artifact, every time. |
| "I'll just finish the task list and narrate at the end." | Long-running autonomous pipelines are invisible without narration. Silent runs prevent the user from intervening or learning. | Narrate before every dispatch and after every completion — non-negotiable. |
## Gate Ledger Protocol
Tamper-evident audit trail for phase transitions and gate verdicts. This is defense-in-depth — it raises the cost of gate-skipping from zero to nonzero by requiring structured state to be maintained and verified. An external enforcement hook (`gate-ledger-guard.sh`) provides mechanical enforcement by blocking unauthorized PASS writes.
**File location:** `~/.claude/projects/<project-hash>/memory/build-gate-ledger.md`
**Relationship to pipeline-status.md:** pipeline-status.md is ambient user awareness (overwritten at every narration point). build-gate-ledger.md is the gate verdict audit trail (updated per phase as gates pass). Both are needed; neither replaces the other.
### PipelineID Generation
At pipeline start, generate a PipelineID via `date -u +build-%Y%m%d-%H%M%S`. This ID:
- Is persisted in the ledger header
- Is passed to quality-gate invocations as `pipeline_id`
- Is used by the enforcement hook to cross-check verdict markers
- Is unique per build run (timestamp-based)
### Ledger Format
```
# Build Gate Ledger
Run: <ISO-8601 timestamp>
PipelineID: <build-YYYYMMDD-HHMMSS>
Goal: <user request>
Mode: <feature | refactor>
## Phase 1: Design
Status: NOT_STARTED
## Phase 2: Plan
Status: NOT_STARTED
## Phase 3: Execute
Status: NOT_STARTED
## Phase 4: Completion
Status: NOT_STARTED
```
**Format constraints:**
- One key-value pair per line: `Key: value`
- Fixed key names: `Status`, `Gate`, `Artifact`, `Tasks`, `Reason`, `Acknowledged`, `PipelineID`
- Status values: `NOT_STARTED`, `IN_PROGRESS`, `PASS`, `COMPLETE`, `FAIL`, `SKIPPED`, `INFERRED`
- Phase headers are `## Phase N: Name` — always 4 phases, always in order
- No prose, no paragraphs, no nested structure
### Ledger Initialization
Runs during build startup, after mode detection but before Phase 1 begins:
1. Check for existing ledger at canonical path
2. If found: run Run Isolation checks (see below)
3. If not found (or user chose "start fresh"): write new ledger including `Run`, `PipelineID`, `Goal`, and `Mode` header fields, then all four phases with `Status: NOT_STARTED`
4. The ledger MUST exist before Phase 1 transitions to `IN_PROGRESS`
After writing any ledger (fresh or reconstructed), immediately re-read the ledger header to extract the PipelineID into the active in-memory state. This is a defensive consistency practice — ensures the in-memory value always matches the persisted value.
### Run Isolation
Stale detection prevents cross-run contamination:
1. **Compaction recovery (same run):** If pipeline-status.md `Started` timestamp matches the ledger's `Run` timestamp, this is the same build run recovering from compaction. Auto-resume without prompting.
2. **New session with existing ledger:** If the ledger exists but pipeline-status.md is missing or its `Started` timestamp doesn't match the ledger's `Run`, prompt: "Found existing ledger for '[goal]' (started [timestamp], Phase N [status]). Resume this run? [y/n]". On "no", archive the old ledger via Bash `mv` to `build-gate-ledger-<old-timestamp>.md`. If the target filename already exists, append a counter suffix (`-2`, `-3`, etc.).
3. **No existing ledger:** Create fresh.
### Orphan Cleanup
**Requires:** Active PipelineID established (from Ledger Initialization + Run Isolation). This step runs AFTER the resume/fresh decision is resolved.
Scan `~/.claude/projects/<project-hash>/memory/quality-gate/gate-verdict-*.md` for verdict markers. Delete any whose `PipelineID` does not match the active PipelineID. If resuming: use the resumed build's PipelineID (from the existing ledger). If starting fresh: use the newly generated PipelineID.
Note: If the session is recovering via INFERRED reconstruction (new PipelineID generated), markers from the old run will be cleaned up. This is intentional — the design requires a fresh QG run for INFERRED→PASS upgrade, not reuse of old markers.
### Timestamps and File Operations
- **Timestamps:** Obtained via Bash `date -u +%Y-%m-%dT%H:%M:%S` (Bash is allowed for `date` commands that don't reference `.claude/` paths)
- **Ledger archival (rename):** Uses Bash `mv` since Write/Read/Edit/Glob have no rename capability
- **All other ledger operations** (create, read, update): MUST use Write and Read tools, NOT Bash. This is a **tooling-discipline convention, not an enforced hook** — see `quality-gate/SKILL.md` › Round History and Compaction Recovery › *Stated cause, corrected (#486)*, which retracts the "safety hooks block Bash commands referencing `.claude/` paths" justification this line used to carry. Follow it regardless (a deployment may add such a hook), but do not cite the hook as the reason.
### Enforcement Rules
Before each phase transition, read `build-gate-ledger.md` and check the previous phase's status:
- **Gate check:** If the previous phase's Status is NOT in {`PASS`, `COMPLETE` (Phase 3 only), `SKIPPED` with `Acknowledged: true`}, output:
```
PHASE GATE BLOCKED: Cannot start Phase N — Phase N-1 gate has not passed.
Current state: [status]
Run the quality gate on Phase N-1's artifact before proceeding.
```
This means `INFERRED`, `IN_PROGRESS`, `FAIL`, and `NOT_STARTED` all trigger BLOCKED.
- **Phase 1 exception:** Phase 1 (Design) has no predecessor gate — it always starts.
- **Phase 3 exception:** Phase 3 transitions to `COMPLETE` (not `PASS`) when all tasks are done and per-task code reviews pass. `COMPLETE` satisfies the gate requirement for Phase 4. No verdict marker is required for Phase 3.
### Verdict Marker Verification
After quality-gate returns with a verdict, verify the verdict marker before writing to the ledger:
1. Glob for verdict markers: `~/.claude/projects/<project-hash>/memory/quality-gate/gate-verdict-*.md`
2. Filter by `PipelineID` match — only markers with the current build's PipelineID
3. Sort by the `Timestamp` field value inside the marker file (parsed as ISO-8601), take the most recent
4. Verify: marker exists, `Verdict` is `PASS`, `PipelineID` matches current build's PipelineID
5. If verification passes: write `PASS` to the ledger with `Gate` timestamp and `Artifact` path
6. If verification fails:
- **Normal flow** (marker missing/mismatched after a just-run gate): do NOT write PASS. Output warning and re-invoke warden on the same artifact (the full reviewer set — NOT bare `quality-gate`, else recovery downgrades to red-team-only and the I-W7 fail-open re-opens).
- **INFERRED recovery** (PipelineID mismatch or missing marker on an INFERRED phase): prompt the user for the artifact path, then offer to run the gate or type SKIP GATE.
7. After writing the ledger entry, delete the verdict marker (it has served its purpose). This applies to all verdict outcomes — PASS, FAIL, STAGNATION, and ESCALATED markers are all deleted after the corresponding ledger entry is written. [PLAN ADDITION — extends the design doc's PASS-only deletion to all verdict outcomes for cleanliness.]
### Skip Escape Hatch
If the user explicitly wants to bypass a gate:
**Example of a SKIPPED phase in the ledger:**
```
## Phase 2: Plan
Status: SKIPPED
Gate: 2026-04-13T15:00:00
Reason: User requested skip
Acknowledged: true
```
**Confirmation protocol:** [Default: option (a) — separate-turn required, matching the design doc's two-step flow. User may override to option (b) before implementation.]
1. The orchestrator outputs: "Gate skip requested. Type `SKIP GATE` to confirm. This will be logged."
2. The orchestrator halts execution and waits. The user's NEXT message must contain exactly `SKIP GATE`. A `SKIP GATE` token in the same message as the skip request does NOT satisfy the confirmation requirement.
3. The orchestrator writes `Status: SKIPPED` with `Reason` field to the ledger.
**Per-phase acknowledgment:** SKIPPED requires one acknowledgment per phase, not per boundary. Before starting Phase N, the orchestrator checks all prior phases. Any prior phase with `Status: SKIPPED` that has not yet been `Acknowledged: true` triggers the BLOCKED message. The user types `SKIP GATE` once per skipped phase, and the ledger records `Acknowledged: true`. Subsequent boundaries do not re-prompt for already-acknowledged skips.
**Missing artifact handling:** If a phase was SKIPPED because no artifact was produced, retroactive gating requires the user to supply the artifact path: "To run the gate on Phase N, provide the artifact path." If no artifact exists, retroactive gating is not possible — the phase remains SKIPPED.
**Recovery from SKIPPED:** If the user later wants to properly gate a skipped phase, they can ask to "run the gate on Phase N." The orchestrator transitions `SKIPPED → IN_PROGRESS`, runs the quality gate on the phase's artifact, and writes the result normally.
**Phase 4 completion warning:** If ANY prior phase has `Status: SKIPPED`, Phase 4 outputs a prominent warning listing all skipped gates before presenting finish options.
### State Machine
```
Phase 1: Design
NOT_STARTED → IN_PROGRESS (design skill starts)
IN_PROGRESS → PASS (quality gate verdict marker verified)
IN_PROGRESS → FAIL (quality gate escalates — stagnation/regression)
FAIL → IN_PROGRESS (user directs re-work)
* → SKIPPED (user types SKIP GATE — does NOT unlock next phase without acknowledgment)
SKIPPED → IN_PROGRESS (user asks to run the gate retroactively)
INFERRED → IN_PROGRESS (user runs gate after compaction recovery)
INFERRED → SKIPPED (user types SKIP GATE after compaction recovery)
Phase 2: Plan
NOT_STARTED → IN_PROGRESS (requires Phase 1 Status = PASS or SKIPPED+Acknowledged)
[same transitions as Phase 1]
Phase 3: Execute (no quality gate — uses COMPLETE instead of PASS)
NOT_STARTED → IN_PROGRESS (requires Phase 2 Status = PASS or SKIPPED+Acknowledged)
IN_PROGRESS → COMPLETE (all tasks done, per-task reviews passed, verification gates green)
IN_PROGRESS → FAIL (task failures, user escalation)
FAIL → IN_PROGRESS (user directs re-work)
* → SKIPPED (user types SKIP GATE)
SKIPPED → IN_PROGRESS (user asks to run retroactively)
Note: Phase 3 has no QG invocation. COMPLETE satisfies Phase 4's gate requirement.
Phase 4: Completion
NOT_STARTED → IN_PROGRESS (requires Phase 3 Status = COMPLETE or SKIPPED+Acknowledged. PASS is unreachable for Phase 3.)
IN_PROGRESS → PASS (quality gate verdict marker verified)
IN_PROGRESS → FAIL (quality gate escalates)
FAIL → IN_PROGRESS (user directs re-work)
* → SKIPPED (user types SKIP GATE)
SKIPPED → IN_PROGRESS (user asks to run retroactively)
IN_PROGRESS includes: emit skip warnings if any prior phase SKIPPED
```
### Compaction Recovery (Ledger)
build-gate-ledger.md is on disk and survives compaction. Recovery precedence when state is partial:
- **Ledger exists, handoff manifest missing:** Use ledger to determine which phase to resume from. Prompt: "Gate ledger shows Phase N passed, but the phase handoff context was lost. Confirm resume from Phase N+1?" If PASS but no handoff, also prompt for Phase N inputs (design doc path, plan path, etc.) before proceeding.
- **Handoff manifest exists, ledger missing:** Reconstruct ledger from manifests. Mark the current phase as `INFERRED` (not `PASS`). Mark predecessor phases as `PASS` (handoff existence proves the boundary was crossed). Generate a new PipelineID and write it to the reconstructed ledger header. After writing, re-read the ledger header to extract the PipelineID into active state. INFERRED phases trigger the gate-blocked check — the orchestrator must run a fresh quality gate (with matching PipelineID) or the user must type SKIP GATE.
- **Both missing:** Fresh start. Prompt user.
## Quality Gate Requirement (Non-Negotiable)
**Every quality gate in this pipeline MUST run to completion.** This is NOT optional — you may NOT self-assess whether a quality gate is "needed" based on task size, complexity, or scope.
Quality gates are unconditional at all three gate points:
1. **Phase 1, Step 2** — Design doc gate
2. **Phase 2, Step 3** — Plan gate
3. **Phase 4, Step 6** — Implementation gate
**Common rationalizations that are NEVER valid reasons to skip:**
- "This is a small change"
- "This is trivial / simple / straightforward"
- "This is just a config change / documentation update / one-liner"
- "The quality gate won't find anything on something this simple"
- "I fixed the findings, so the gate is done" — **fixing findings is NOT the same as passing the gate.** The iteration loop must complete with a clean verification round (0 Fatal, 0 Significant on a fresh review). Fix agents introduce new issues or incompletely resolve old ones — that is why fresh-eyes re-review exists.
**This requirement exists because:** Quality gates consistently find issues the pipeline misses regardless of task size. There is no category of task that is immune. In observed runs, tasks self-assessed as "trivial" had the same defect rate as complex tasks. The only way to skip a quality gate is with explicit user approval — an unambiguous instruction specifically referencing the gate, not general feedback like "looks good" or "move on."
## Pipeline Status
Write a status file to `~/.claude/projects/<hash>/memory/pipeline-status.md` at every narration point. This file is overwritten (not appended) and provides ambient awareness for the user in a second terminal.
### Write Triggers
Write the status file at every point where the Communication Requirement mandates narration: before dispatch, after completion, phase transitions, health changes, escalations, and after compaction recovery.
### Status File Format
The status file uses this structure (overwritten in full each time):
```
# Pipeline Status
**Updated:** <current timestamp>
**Started:** <timestamp from first write — persisted across compaction>
**Skill:** build
**Phase:** <current phase, e.g. "3 — Execute (Autonomous)">
**Health:** <GREEN|YELLOW|RED>
**Suggested Action:** <omit when GREEN; concrete one-sentence action when YELLOW/RED>
**Elapsed:** <computed from Started>
## Recent Events
- [HH:MM] <most recent event>
- [HH:MM] <previous event>
(last 5 events, newest first)
```
### Skill-Specific Body
Append after the shared header:
```
## Task Progress
| # | Task | Tier | Status | Duration |
|---|------|------|--------|----------|
| 1 | Auth middleware | T3 | DONE | 12m |
| 2 | Route handlers | T2 | IN REVIEW (code, pass 1) | 18m+ |
| 3 | Database layer | T1 | PENDING | — |
## Quality Gates
- Design: PASSED (2 rounds)
- Plan: PASSED (1 round)
- Task tiers: 1x T1, 1x T2, 1x T3
- Code: not yet reached
## Checkpoints
- Last checkpoint: pre-wave-3 (12:45:30)
- Total checkpoints: 7
- Shadow repo: healthy
## Compression State
Goal: [original user request]
Key Decisions:
- [accumulated decisions, max 10]
Active Constraints:
- [constraints affecting remaining work]
Next Steps:
1. [immediate next action]
2. [subsequent actions]
```
The Compression State section is a semantic subset of the full Compression State Block emitted into the conversation. It omits Files Modified (recoverable from git) and Scratch State (fixed per skill). It is the first section read during compaction recovery.
### Health State Machine
Health transitions are one-directional within a phase: GREEN -> YELLOW -> RED. Phase boundaries reset to GREEN.
- **Phase boundaries** (reset to GREEN): Phase 1->2, 2->3, 3->4
- **YELLOW:** review loop round 3+, quality gate round 5+, retry in progress
- **RED:** escalation pending, stagnation detected, test suite failure unresolved
When health is YELLOW or RED, include `**Suggested Action:**` with a concrete, context-specific sentence (e.g., "Code review looping on Task 4. Check recent events for recurring patterns.").
### Inline CLI Format
Output concise inline status alongside the status file write:
- **Minor transitions** (dispatch, completion): one-liner, e.g. `Phase 3 [4/8] Task 4 IN REVIEW (pass 1) | GREEN | 1h 12m`
- **Phase changes and escalations**: expanded block with `---` separators
- **Health transitions**: always expanded with old -> new health
### Compaction Recovery
After compaction, before re-writing the status file:
0. Read the `## Compression State` section from `pipeline-status.md` — recover Goal, Key Decisions, Active Constraints, and Next Steps. If the section is absent (pre-update pipeline), skip to step 1.
<!-- TRUST: dispatch manifest is L2 — produced by prior pipeline stage; prefer most recent if conflicting. -->
0.5. Check for handoff manifests (`handoff-*-to-*.md`) in the scratch directory. If the most recent manifest exists, use its Inputs, Decisions, and Constraints to reconstruct state for the current phase — this supersedes the Compression State section for phase-boundary recovery. If no manifest exists, continue with CSB-based recovery.
1. Read the rest of `pipeline-status.md` to recover `Started` timestamp and `Recent Events` buffer
2. Reconstruct phase, health, and skill-specific body from internal state files
3. If crucible:checkpoint was used: verify checkpoint availability by checking for the shadow repo at the computed path. Log available checkpoint count. Do not restore — just confirm checkpoints are recoverable.
4. Emit a Compression State Block into the conversation to seed the new context window with recovered state
4.5. **Read session index summary (supplementary):** If the CSB Scratch State contains a `Session Index:` path, or if globbing `~/.claude/projects/<hash>/memory/session-index/*/summary.md` finds a recent file, read `summary.md`. Include the Activity Timeline, Files Modified, and Key Decisions sections in the post-compaction narration. If no session index exists, skip silently — this step is purely additive. If `summary.md` lacks detail for a specific event type (e.g., errors, decisions, file changes), use `/recall` to query `events.jsonl` with filters for targeted recovery.
5. Write the updated status file
6. Output inline status to CLI
### Compression State Block
At checkpoint boundaries (see Checkpoint Timing below), emit the following structured block into the conversation. This block signals to the auto-compactor which state is critical to preserve. Also persist the semantic subset (Goal, Key Decisions, Active Constraints, Next Steps) to the `## Compression State` section of pipeline-status.md.
```
===COMPRESSION_STATE===
Goal: [original user request, one sentence]
Skill: [skill name]
Phase: [current phase identifier]
Health: [GREEN|YELLOW|RED]
Mode: [skill-specific mode if applicable, omit otherwise]
Progress:
- [completed milestone 1]
- [completed milestone 2]
- [current work in progress]
Key Decisions (this session):
- [DEC-1] [decision]: [reasoning, one line]
- [DEC-2] [decision]: [reasoning, one line]
Active Constraints:
- [constraint that affects remaining work]
- [constraint from prior phase that still applies]
Files Modified:
- [file path]: [what changed, one line]
Scratch State:
- Location: [scratch directory path]
- Session Index: [~/.claude/projects/<hash>/memory/session-index/<session-id>/ if active, omit if not]
- Recovery: [which files to read first, in order]
Next Steps:
1. [immediate next action]
2. [action after that]
3. [remaining work summary]
===END_COMPRESSION_STATE===
```
**Rules:**
- Key Decisions list is capped at 10. When adding an 11th, compress the oldest low-impact decision into a single-line Progress entry annotated "[compressed from decisions]".
- Each Compression State Block includes the FULL accumulated decision list, not just new decisions since the last block. Decisions accumulate across compressions.
- Progress entries are cumulative — include all completed milestones, not just since the last block.
- Files Modified lists only files changed since the last block emission. On first block of a session, list all files changed so far.
- Goal must be the original user request verbatim or a faithful one-sentence paraphrase. Do not let it drift across compressions.
### Checkpoint Timing
Emit a Compression State Block into the conversation AND update the `## Compression State` section in pipeline-status.md at these points:
- **Phase transitions:** 1→2, 2→3, 3→4 — emit a **Phase Handoff Manifest** (see below) instead of a Compression State Block at these points
- **Phase 3 progress:** After every 3 task completions
- **Quality gate entry/exit:** Before first quality gate round dispatch and after gate completes (pass or escalation)
- **Escalations:** Before any escalation to user
- **Health transitions:** On any GREEN->YELLOW or YELLOW->RED transition
These triggers are a superset of the existing pipeline-status.md write triggers. The Compression State Block is emitted alongside (not instead of) the normal narration and status file write.
### Phase Handoff Manifest
At phase boundaries (1→2, 2→3, 3→4), write a **handoff manifest** to the scratch directory instead of emitting a Compression State Block. The manifest defines exactly what the next phase needs — an allowlist, not a denylist. Everything not on the manifest is shed.
**Format:**
```markdown
# Phase Handoff: N → M
**Timestamp:** ISO-8601
**Goal:** [original user request, verbatim]
**Mode:** feature | refactor
## Inputs for Phase M
- **[Input name]:** [disk path or inline value]
## Decisions Carried Forward
- [DEC-N] [decision]: [reasoning, one line]
## Active Constraints
- [constraint affecting remaining work]
## Shed Receipt
- [what was shed] → [where it lives on disk]
```
**Rules:**
- After writing the manifest, emit an explicit **shed statement**: list what context is no longer needed, where it lives on disk, and that the orchestrator operates from manifest inputs only going forward.
- After writing the manifest, update the `## Compression State` section in pipeline-status.md with the manifest contents (Goal, Decisions, Constraints, and the Inputs as Next Steps). This ensures compaction recovery can reconstruct state even if the manifest is lost.
- CSBs continue at all non-boundary checkpoint triggers (intra-phase progress, quality gate entry/exit, escalations, health transitions).
- **Backward compatibility:** If a handoff manifest does not exist at a recovery point, fall back to CSB-based recovery (existing behavior).
## Mode Detection
Before dispatching the design skill, determine whether this build is:
- **Feature mode** (default) — adding new capability. Success = new acceptance tests pass.
- **Refactor mode** — restructuring existing code. Success = existing behavior preserved + structural goals met.
**Detection:** If the user's intent is ambiguous, ask directly before proceeding:
> "Is this adding new behavior, or restructuring existing code without changing what it does?"
The user's answer sets the mode for the entire pipeline. No special syntax needed.
> **Eval-gate pointer (Mock Dispatch Mode):** if `CRUCIBLE_BUILD_EVAL_MODE` is set, use its value (`feature` or `refactor`) as the mode-detection answer and skip the AskUserQuestion call. The substitution rule lives in the `## Mock Dispatch Mode (eval-gate)` section near the top of this file.
### Mode Propagation
Propagate refactor mode to subagents through:
1. **New refactor-specific prompt templates** — `contract-test-writer-prompt.md` and `refactor-implementer-addendum.md` are standalone files used only in refactor mode. Select these instead of (or in addition to) the feature-mode equivalents.
2. **Appended context blocks** — For existing prompts that serve both modes (`plan-writer-prompt.md`, `build-implementer-prompt.md`), append a "Refactor Mode Context" section when composing the dispatch file. The templates remain flat markdown — the orchestrator decides what to include.
3. **Scratch file for compaction recovery** — Persist the current mode in `/tmp/crucible-build-mode.md` containing `mode: refactor` or `mode: feature` plus the baseline commit SHA. Only one build runs per session, so a well-known filename is sufficient.
### Compaction Recovery
Build's existing compaction step must read the Compression State FIRST (step 0 from Pipeline Status Compaction Recovery), then the mode file, before re-reading the task list or any other state. On resumption after compaction:
0. **Read `## Compression State` from pipeline-status.md** — recover goal, decisions, constraints, next steps.
0.5. **Check for handoff manifests** (`handoff-*-to-*.md`) in the scratch directory. If the most recent manifest exists, use its Inputs and Mode to bootstrap recovery — this supersedes the mode file for phase-boundary state.
1. **Read `/tmp/crucible-build-mode.md`** — recover mode and baseline commit SHA.
2. **If file is missing:** Default to feature mode and warn.
3. **If mode is `refactor`:** Verify baseline commit SHA exists.
4. **Read `build-gate-ledger.md`** — if it exists, apply Gate Ledger Compaction Recovery (see Compaction Recovery subsection under Gate Ledger Protocol). Use the ledger's phase statuses to determine the resume point. If the ledger is missing but handoff manifests exist, reconstruct with INFERRED status.
5. **After mode and ledger are recovered:** Proceed with general state reconstruction (task list, phase, health).
## Phase 1: Design (Interactive)
### Step -1: Resume Detection and Pipeline-Active Marker
Before any design or dispatch work, check for a crashed prior pipeline:
1. **Check `<scratch>/.pipeline-active`** (where `<scratch>` is `~/.claude/projects/<hash>/memory/`)
2. **Not found:** Write the pipeline-active marker (JSON with `pipeline_id` set to current session ID, `skill` set to `"build"`, `phase` set to `"1"`, `start_time` set to current ISO-8601 timestamp, `scratch_dir` set to the scratch directory path, `dispatch_dir` set to the dispatch directory path, `branch` from `git branch --show-current`, `baseline_sha` from `git rev-parse HEAD`). Proceed to Step 0.
3. **Found, same `pipeline_id` as current session:** This is a compaction recovery scenario. Follow existing compaction recovery procedures. Do not re-write the marker.
4. **Found, different `pipeline_id`:**
a. **Branch guard:** Compare marker's `branch` field against current `git branch --show-current`. If they differ, warn: *"Previous build on branch [marker.branch] crashed at Phase [phase]. You are currently on [current-branch]. Switch to [marker.branch] before resuming? [switch+resume / start fresh / abort]"*. Do NOT offer resume on the wrong branch.
b. Read `manifest.jsonl` from the marker's `dispatch_dir` (or from the scratch directory copy if `/tmp` was lost)
c. Identify the last successful phase boundary by scanning manifest entries grouped by phase. A phase boundary is verified when all dispatches in that phase have `status: "completed"`.
d. Present resume option to the user:
> "Previous build on branch [marker.branch] crashed at Phase [N], [context]. Resume from [last good boundary] ([checkpoint reason], [estimated time preserved] of work preserved)? [yes / no / fresh]"
e. **User accepts:** Invoke `crucible:replay` in resume mode, passing the scratch directory path. The replay skill handles checkpoint restore, state reconstruction, and re-dispatch. The build pipeline does not continue -- replay takes over.
f. **User declines (fresh):** Delete the stale `.pipeline-active` marker. Write a fresh marker with the current session. Proceed to Step 0 as a new pipeline run.
**Marker updates during pipeline:** Update the `phase` field in `.pipeline-active` at each phase boundary (1->2, 2->3, 3->4) to track progress for crash detection.
**Marker cleanup:** Delete `.pipeline-active` at Phase 4 step 12 (after finish skill completes).
**Gate Ledger Initialization:** After the pipeline-active marker is written (or recovered) and mode detection is complete, run the Gate Ledger Protocol's Ledger Initialization and Orphan Cleanup steps. The ledger must exist before Phase 1 transitions to IN_PROGRESS.
**Compass Arc Emit (build orchestrator only — D14):**
<!-- CANONICAL: shared/compass-protocol.md -->
After Gate Ledger Initialization completes AND the resume decision at Step -1 has resolved, emit the current arc to `docs/compass.md` — but ONLY on a fresh-start or fresh-restart path. Skip this emit if the user accepted the resume path (Step -1e: replay took over), because the prior arc's `current_arc` is already correct. Do NOT place this emit inside crash-recovery branches (Step -1, items 3 or 4e), as those fire mid-resume-detection and can clobber `current_arc` before replay restores the prior arc.
`RESUME_DECISION` is set by Step -1 to one of `fresh` / `resume` / `fresh-restart`. Default `fresh` if unset.
```bash
if [ "${RESUME_DECISION:-fresh}" != "resume" ]; then
python scripts/compass.py update --field current_arc --value "#<ticket>: <user-goal-one-liner>" \
|| echo '[compass] emit failed at arc start; continuing build' >&2
fi
```
Replace `<ticket>` with the GitHub issue number (e.g. `273`) and `<user-goal-one-liner>` with a short, precise description of the task at hand (e.g. `Compass arc-state skill`). The leading `#` is required — `compass update` raises `ValueError` on values missing the `#NNN:` prefix.
**Error policy (best-effort):** Compass is an optimization, not a correctness layer. A failed emit MUST NOT fail the build pipeline — log to stderr and continue. Never tighten this error handling.
**D14 invariant:** Sub-agents spawned inside build do NOT emit compass updates. This emit fires from the build orchestrator only, exactly once per fresh pipeline start.
### Step 0: Pre-Existing Doc Detection
Before running interactive design, check whether `/spec` (or a prior `/build` run) already produced design artifacts for this ticket.
1. **Scan for pre-existing spec docs:** Search `docs/plans/` for design docs (`*-design.md`) with a matching `ticket` field in YAML frontmatter. Also check for corresponding `*-implementation-plan.md` and `*-contract.yaml` files with the same ticket field.
2. **Conflict detection:** If multiple design docs match the same `ticket` field, escalate to user: "Found multiple design docs for ticket #NNN: [list files]. Which should I use?" Do not proceed until the user resolves the conflict.
3. **Full match (design doc + implementation plan + contract all present):**
- Skip interactive design (the Phase 1 design sub-skill below) — design doc already exists
- **Security review check:** If the contract contains `security_review` field, note it in the Phase 1→2 handoff manifest under Active Constraints: "Contract requires security review (`security_review.status: [required|recommended]`) — siege will be evaluated in Phase 4 Step 5.5." This ensures the directive survives phase handoffs and compaction recovery.
- Quality-gate the existing design doc with staleness context: "This design doc is pre-existing from /spec and may be stale — verify against current codebase state before proceeding"
- **Staleness rejection:** If the quality gate finds that the design doc references files, interfaces, or modules that no longer exist in the codebase, reject the doc as fundamentally stale. Fall back to running Phase 1 interactively. Inform user: "Pre-existing design doc for #NNN is fundamentally stale (references [specific items] that no longer exist). Running interactive design instead."
- If quality gate passes: Run Phase 2 on the pre-existing implementation plan — skip Plan Writer (plan already exists), but run Plan Reviewer + innovate + quality-gate on the existing plan. This ensures the plan gets the same review rigor as a freshly written plan.
- If quality gate fails (non-staleness issues): fix or escalate
- Proceed to Phase 3 when the plan passes review
4. **Partial match (design doc present but implementation plan or contract missing):**
- Use the existing design doc (quality-gate it as above, including staleness rejection)
- Run the missing phases normally: if no implementation plan, run Plan Writer in Phase 2; if no contract, proceed without contract awareness for this ticket
- Inform user which artifacts were found and which are being generated fresh: "Found pre-existing design doc for #NNN. Implementation plan is missing — will generate in Phase 2." (or similar)
5. **Not found:** Proceed with normal Phase 1 (interactive design below).
---
- **Model:** Opus (creative/architectural work needs the best model)
- **Mode:** Interactive with the user
- **RECOMMENDED SUB-SKILL:** Use crucible:forge (feed-forward mode) — consult past lessons before starting
- **RECOMMENDED SUB-SKILL:** Use crucible:cartographer-skill (consult mode) — review codebase map for structural awareness
- **REQUIRED SUB-SKILL:** Use crucible:design
- Follow design skill for design refinement, section-by-section validation, and saving the design doc
- **OVERRIDE:** When design completes and the design doc is saved, do NOT follow design's "Implementation" section (do not chain into planning or worktree from there). Return control to this build skill — Phase 2 handles planning with its own subagent-based approach.
- Phase ends when user approves the design (says "go", "looks good", "proceed", etc.)
- **Everything after this point is autonomous** — tell the user: "Design approved. Starting autonomous pipeline — I'll only interrupt for escalations."
> **Eval-gate pointer (Mock Dispatch Mode):** when `CRUCIBLE_BUILD_EVAL_MOCK_DIR` is set, all `Use crucible:<skill>` and `Dispatch a <kind> subagent` invocations in Phase 1 (design, innovate, quality-gate, acceptance test writer, contract test writer) substitute a disk-read from the mock dir for the Task tool invocation. Each substitution follows the substitution rule in the `## Mock Dispatch Mode (eval-gate)` section. AskUserQuestion calls in Phase 1 use `CRUCIBLE_BUILD_EVAL_USER_INPUT_DIR` per that same section.
### Step 2: Innovate and Red-Team the Design
After the user approves the design and before starting Phase 2:
**RECOMMENDED SUB-SKILL:** Use crucible:checkpoint — create checkpoint with reason "pre-design-gate" before dispatching innovate and quality-gate on the design doc.
1. **Innovate:** Dispatch `crucible:innovate` on the design doc. Plan Writer incorporates the proposal.
2. **Write Phase 1 IN_PROGRESS** to the gate ledger (after ledger initialization).
3. **REQUIRED SUB-SKILL:** Use crucible:quality-gate on the (potentially updated) design doc with artifact type "design". Include in the dispatch context: `Phase: design` and `PipelineID: <current PipelineID>`. Iterates until clean or stagnation. **(Non-negotiable — see Quality Gate Requirement.)**
4. If the quality gate requires changes, the Plan Writer updates the design doc and re-commits.
5. **Verify verdict marker and write Phase 1 PASS** to the gate ledger (see Verdict Marker Verification). Delete the verdict marker after writing the ledger entry.
6. Design doc is now finalized — proceed to acceptance tests.
### Step 3: Generate Acceptance Tests (RED)
Before planning, define "done" with executable tests:
1. Dispatch an **Acceptance Test Writer** subagent (Opus) using `./acceptance-test-writer-prompt.md`
- Input: finalized design doc (especially acceptance criteria)
- Output: integration-level test file(s) that verify feature behavior end-to-end
2. Run the acceptance tests — verify they **FAIL** (the feature doesn't exist yet)
- If tests pass: something is wrong — investigate before proceeding
- If tests error (won't compile): this is expected in typed languages — note which tests exist and what they verify. They become the first implementation task.
3. Commit: `test: add acceptance tests for [feature] (RED)`
These tests define the feature-level RED-GREEN cycle that wraps the entire pipeline. The pipeline is done when these tests pass.
### Refactor Mode: Phase 1 Changes
When in refactor mode, Phase 1 shifts from "what should we build?" to "what are we changing and what could break?"
#### Blast Radius Analysis
After the user describes the refactoring intent, the design phase:
1. **Identify the target** — What code is being restructured? (module, interface, data representation, file organization, etc.)
2. **Trace the blast radius** using cartographer (if available) or fallback exploration:
- **Direct consumers** — code that imports/calls/references the target
- **Indirect dependents** — code that depends on consumers (transitive)
- **Test coverage** — which tests exercise the target behavior
- **Configuration/wiring** — DI registrations, config files, build scripts that reference the target
- **Fallback when cartographer is unavailable:** Use language-aware symbol search via agent exploration. Grep for symbol references (imports, type annotations, function calls) using language-specific patterns. The impact manifest's confidence field reflects reduced precision.
3. **Present an impact manifest** to the user:
```
### Impact Manifest
**Target:** [what's being restructured]
**Structural goal:** [what the code should look like after]
**Direct consumers:** N files
- path/to/consumer1.py (calls TargetClass.method)
- path/to/consumer2.py (imports TargetClass)
**Indirect dependents:** N files
- path/to/dependent.py (depends on consumer1)
**Test coverage:**
- N tests directly exercise target behavior
- N tests exercise consumers
- Gap: no tests cover [specific seam]
**Risk assessment:** [Low/Medium/High] based on consumer count and coverage gaps
**Confidence:** [High/Medium/Low] — High if cartographer used, Medium/Low if fallback
```
**When confidence is Low**, require explicit user confirmation before proceeding. The user must review the impact manifest and confirm the blast radius is complete.
4. **Design the structural goal** — what should the code look like after the refactoring? User validates the target state.
#### Acceptance Tests (Refactor Mode)
Instead of writing NEW acceptance tests (Step 3 above), the pipeline:
1. **Dispatch the contract test writer** using `./contract-test-writer-prompt.md` — a single agent handles gap identification AND gap filling. Input: impact manifest + blast radius file list. The agent maps existing tests to behavioral seams, identifies untested seams, and writes contract tests for each gap.
2. **Run all contract tests GREEN** — contract tests must pass before any refactoring begins.
3. **If a contract test FAILS:** The contract test writer investigates:
- **Test defect** (wrong assertion, bad setup) — fix the test and re-run
- **Latent codebase bug** — report to user with options: (a) fix the bug first, (b) exclude this seam and accept the risk, (c) abort the refactoring. Never silently drop a failing contract test.
4. **Commit:** `test: add contract tests for [target] refactoring (GREEN — locking existing behavior)`
#### Proportionality Escape Valve
Contract test writing must remain proportional to the refactoring scope. Trigger a scope check when **any** of these thresholds are hit:
- **Count threshold:** More than 15 contract tests needed
- **Effort threshold:** Contract test writer reports context pressure, or estimated total contract test LOC exceeds ~2x the estimated refactoring scope LOC
When triggered:
1. Present the full gap list to the user with estimated effort per gap
2. User selects which gaps to fill and which to accept as uncovered risk
3. Proceed with only user-selected contract tests
The impact manifest records which gaps the user chose to leave uncovered.
### Phase Handoff: 1 → 2
Before dispatching the Plan Writer, verify the gate ledger and write a handoff manifest:
0. **Gate ledger check:** Read `build-gate-ledger.md` and verify Phase 1 Status is `PASS`. If not, follow Enforcement Rules.
1. Write `handoff-1-to-2.md` with:
- **Goal:** original user request, verbatim
- **Mode:** feature or refactor
- **Inputs for Phase 2:** design doc path, acceptance test paths (or contract tests in refactor mode), conventions path (from cartographer, if loaded)
- **Decisions Carried Forward:** accumulated decisions from Phase 1
- **Active Constraints:** constraints affecting planning
- **Shed Receipt:** design iteration history, innovate proposals, quality gate round details → design doc on disk captures the outcome
2. Emit shed statement: "Phase 1 context shed. Design doc and acceptance tests are on disk. Design iteration history, innovate proposals, and gate round details are not carried forward."
3. Update `## Compression State` in pipeline-status.md with manifest contents.
4. Do NOT emit a Compression State Block (manifest replaces it at this boundary).
5. **Session index event:** Emit a `phase_change` event to the outbox: `{"ts":"<now>","seq":0,"type":"phase_change","summary":"Build: Phase 1 -> Phase 2 (Plan)","detail":{"skill":"build","from":"1","to":"2"}}`.
## Phase 2: Plan (Autonomous)
> **Eval-gate pointer (Mock Dispatch Mode):** when `CRUCIBLE_BUILD_EVAL_MOCK_DIR` is set, the Plan Writer, Plan Reviewer, innovate, and quality-gate dispatches in this phase use the mock-dir substitution rule defined in the `## Mock Dispatch Mode (eval-gate)` section. The substitution does not change Phase 2's structure or the gate-ledger writes.
### Step 1: Write the Plan
Dispatch a **Plan Writer** subagent (Opus):
- Read the design doc produced in Phase 1 and the acceptance tests from Step 3
- Write an implementation plan following the `crucible:planning` format
- If acceptance tests couldn't compile (typed language), Task 1 should create the interfaces/stubs needed for them to compile and fail correctly
- Include per-task metadata: Files (with count), Complexity (Low/Medium/High), Dependencies
- Save to `docs/plans/YYYY-MM-DD-<topic>-implementation-plan.md`
- Plan tasks should be scoped to 2-3 per subagent, ~10 files max (context budget awareness)
Use `./plan-writer-prompt.md` template for the dispatch prompt.
### Step 2: Review the Plan
Dispatch a **Plan Reviewer** subagent:
Reviewer model selection:
- Plan touches **4+ systems** or has **10+ tasks** → Opus
- Plan touches **1-3 systems** with **<10 tasks** → Sonnet
- When in doubt → Opus
Review protocol (iterative):
- Dispatch Plan Reviewer to check plan against design doc
- If issues found: record issue count, dispatch Plan Writer to revise
- Dispatch NEW fresh Plan Reviewer on revised plan (no anchoring)
- Compare issue count to prior round:
- Strictly fewer issues → progress, loop again
- Same or more issues → stagnation, **escalate to user** with findings from both rounds
- Loop until plan passes with no issues
- **Architectural concerns bypass the loop** — immediate escalation regardless of round
Use `./plan-reviewer-prompt.md` template for the dispatch prompt.
### Step 3: Innovate and Red-Team the Plan
**After the plan passes review:**
**RECOMMENDED SUB-SKILL:** Use crucible:checkpoint — create checkpoint with reason "pre-plan-gate" before dispatching innovate and quality-gate on the plan.
1. **Write Phase 2 IN_PROGRESS** to the gate ledger.
2. **Innovate:** Dispatch `crucible:innovate` on the approved plan. Plan Writer incorporates the proposal into the plan.
3. **REQUIRED SUB-SKILL:** Use crucible:quality-gate on the (potentially updated) plan with artifact type "plan". Include in the dispatch context: `Phase: plan` and `PipelineID: <current PipelineID>`. Provides the plan and design doc as context. **(Non-negotiable — see Quality Gate Requirement.)**
4. **Verify verdict marker and write Phase 2 PASS** to the gate ledger (see Verdict Marker Verification). Delete the verdict marker after writing the ledger entry.
The quality gate handles the iterative red-team loop — fresh review each round, weighted stagnation detection, 15-round safety limit, escalation. See `crucible:quality-gate` for details.
### Phase Handoff: 2 → 3
Before creating the team and task list, write a handoff manifest. Step 3.4 above already verified the verdict marker, wrote PASS to the ledger, and deleted the marker. The handoff manifest is written AFTER the ledger PASS — this sequencing ensures compaction recovery finds a consistent state (ledger shows PASS, handoff exists).
Write a handoff manifest:
1. Write `handoff-2-to-3.md` with:
- **Goal:** original user request, verbatim
- **Mode:** feature or refactor
- **Inputs for Phase 3:** plan path, design doc path, acceptance test paths (or contract tests), contract YAML path (if exists), baseline SHA (current HEAD), cartographer context paths (module files, conventions.md, landmines.md)
- **Decisions Carried Forward:** accumulated decisions from Phases 1-2
- **Active Constraints:** constraints affecting execution
- **Shed Receipt:** plan review iterations, innovate proposals, quality gate round history → plan on disk captures the outcome
2. Emit shed statement: "Phase 2 context shed. Plan, design doc, and acceptance tests are on disk. Plan review rounds, innovate proposals, and gate details are not carried forward."
3. Update `## Compression State` in pipeline-status.md with manifest contents.
4. Do NOT emit a Compression State Block.
5. **Session index event:** Emit a `phase_change` event to the outbox: `{"ts":"<now>","seq":0,"type":"phase_change","summary":"Build: Phase 2 -> Phase 3 (Execute)","detail":{"skill":"build","from":"2","to":"3"}}`.
## Phase 3: Execute (Autonomous, Team-Based)
> **Eval-gate pointer (Mock Dispatch Mode):** when `CRUCIBLE_BUILD_EVAL_MOCK_DIR` is set, all per-task dispatches in this phase (implementer, reviewer, cleanup, test-coverage, test-gap-writer, adversarial-tester, architecture-reviewer) use the mock-dir substitution rule defined in the `## Mock Dispatch Mode (eval-gate)` section. TeamCreate and TaskCreate calls run normally — only the Task/Agent tool invocations on teammates are substituted.
### Step 0: Load Module Context for Subagents
- **RECOMMENDED SUB-SKILL:** Use crucible:cartographer-skill (load mode) — when dispatching implementers and reviewers, include relevant module files, conventions.md, and landmines.md in their dispatch files
- **Defect signature loading (for implementers only):**
1. Glob `defect-signatures/*.md` (excluding `*.non-matches.md`) from the cartographer storage directory
2. For each signature, read its `Modules` field and match against the task's target modules:
- Read each cartographer module file's `Path:` field
- A task's file is in a module if the file path starts with the module's `Path:` value
- When a task spans multiple modules, load signatures for all matched modules
- **Directory prefix fallback:** When no cartographer modules exist, match if any target file path starts with any of the signature's `Modules` directory prefixes
3. For matching signatures, validate all file paths still exist on disk — drop stale entries silently
4. Inject into the `[DEFECT_SIGNATURES]` section of `build-implementer-prompt.md`:
- Generalized pattern (always)
- Confirmed siblings list (always)
- Unresolved siblings list (always — these are known live defects; produces a stronger warning)
- Non-match companion files are NOT loaded for implementers
5. **`Last loaded` update:** Loading is pure-read. After all implementer dispatches for the current phase complete, batch-update the `Last loaded` field to today on all signatures that were loaded. Do NOT update during dispatch — defer to after all subagents are dispatched.
- **Grudge pre-flight (regression-oracle, #271):** Before dispatching implementers, query the **Book of Grudges** for each task's in-scope files and inject any matches into that implementer's dispatch file as a hard **DO NOT REPEAT** constraint (sibling to defect-signature loading). Resolve the helper by absolute path from the plugin root — `plugin_root="$(realpath "<this-skill-base-dir>/../..")"` — and run `python3 "$plugin_root/scripts/grudge_query.py" <task files…>`; non-empty output lists past regressions held against those files. Best-effort: if the helper is unresolved, emit a one-line stderr warning and continue — a missing pre-flight must NEVER block the build. See `skills/grudge/SKILL.md`.
### Step 0.5: Gate Ledger — Phase 3 Start
**Write Phase 3 IN_PROGRESS** to the gate ledger (after Phase 2 PASS verification).
### Step 1: Create Team and Task List
Create a team using `TeamCreate`:
```
team_name: "build-<feature-name>"
description: "Building <feature description>"
```
Read the approved plan. Create tasks via `TaskCreate` for each plan task, including:
- Subject from plan task title
- Description with full plan task text (subagents should never read the plan file)
- Dependencies via `TaskUpdate` with `addBlockedBy`
#### Agent Teams Fallback
If `TeamCreate` fails (agent teams not available), output a clear one-time warning:
> ⚠️ Agent teams are not available. Recommended: set `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`
> Falling back to sequential subagent dispatch via Agent tool.
Then fall back to sequential subagent dispatch via the regular Task tool (without `team_name`). Everything still works — independent tasks run sequentially instead of in parallel via teammates.
**What changes in fallback mode:**
- Tasks are dispatched via `Agent` tool instead of as teammates
- Independent tasks that would run in parallel now run sequentially
- Task tracking still uses `TaskCreate`/`TaskUpdate` for state management
- All other pipeline behavior (TDD, review, de-sloppify, quality gates) is unchanged
### Step 2: Analyze Dependencies and Execution Order
Before dispatching:
1. Map the dependency graph from plan task metadata
2. Identify independent tasks (no shared files, no sequential dependencies)
3. Group into execution waves — independent tasks parallel, dependent tasks sequential
4. Assess complexity per task for reviewer model selection
### Step 3: Execute Tasks
For each task (or wave of parallel tasks):
**RECOMMENDED SUB-SKILL:** Before dispatching each execution wave, use crucible:checkpoint — create checkpoint with reason "pre-wave-N" (where N is the wave number). This captures the working directory state after the prior wave's verification gate passed.
1. Mark task `in_progress` via `TaskUpdate`
2. Spawn **Implementer** teammate (Opus) via Task tool with `team_name` and `subagent_type="general-purpose"`
- Use `./build-implementer-prompt.md` template
- Pass full task text, file paths, project conventions
- **Contract-aware dispatch (when a contract exists for this ticket):** Include the contract YAML alongside the design doc and task description. See "Contract-Aware Implementer Guidance" below.
- Implementer follows TDD, writes tests, runs tests, commits, self-reviews
3. When Implementer reports completion, run **De-Sloppify Cleanup** (see below)
4. After cleanup completes, spawn **Reviewer** teammate
- Use `./build-reviewer-prompt.md` template
5. **Tier-aware review routing:** Read the task's `Review-Tier` from plan metadata.
- **Tier 1:** Dispatch single-pass code reviewer (Sonnet). If Clean or Minor-only: task complete. If Critical/Important: dispatch implementer fix, then task complete. If Architectural Concern: escalate.
- **Tier 2:** Dispatch iterative code review (per existing loop). Then dispatch single-pass test reviewer. If test review surfaces Critical findings, escalate to Tier 3. Then dispatch adversarial tester (per existing logic). Task complete.
- **Tier 3:** Follow current full pipeline (no changes to existing flow).
#### Contract-Aware Implementer Guidance
When a contract YAML exists for the current ticket (detected during Step 0 or produced by `/spec`), the implementer receives the contract alongside the design doc and task description. The contract uses the schema defined in [`crucible:spec/contract-schema.md`](../spec/contract-schema.md) (version `1.0`). Implementers must treat contract elements as follows:
1. **`api_surface` declarations are binding.** The implementer must match the declared function signatures, class interfaces, endpoint shapes, parameter names, types, and return types exactly. Deviations from the contract's API surface are implementation errors.
2. **`checkable` invariants are binding.** The implementer must satisfy all declared constraints (e.g., "must not import X", "must be idempotent"). The `check_method` field (`grep`, `code-inspection`, `file-structure`) indicates how the quality gate will verify compliance — the implementer should self-check against these before committing.
3. **`testable` invariants require tagged tests.** For each `testable` invariant, the implementer must write a test tagged with the declared `test_tag` (pattern: `contract:<category>:<id>`) that validates the invariant. These tests are checked by the quality gate and reviewers — they must exist and pass.
4. **`integration_points` are informational.** These indicate which other components and contracts this ticket interacts with. The implementer should be aware of referenced components and ensure compatibility, but integration points are not binding constraints — they provide context for making good implementation decisions.
#### De-Sloppify Cleanup
<!-- TRUST: subagent report is L4 — cross-check file paths and claims against L3 source before acting. -->
After the implementer reports completion and before dispatching the reviewer:
**RECOMMENDED:** Use crucible:checkpoint — create checkpoint with reason "pre-cleanup-task-N" before dispatching the cleanup agent. If cleanup removes something needed, restore to this checkpoint.
1. Record the pre-cleanup commit SHA
2. Dispatch a fresh **Cleanup Agent** (Opus) using `./cleanup-prompt.md`
- Input: `git diff <pre-task-sha>..HEAD` (the implementer's committed changes)
- The orchestrator provides the pre-task commit SHA to the cleanup agent
3. Cleanup agent reviews changes, removes unnecessary code (see allowlist), runs tests
4. If cleanup made changes, commits separately: `refactor: cleanup task N implementation`
5. If cleanup found nothing to remove, reports "No cleanup needed" and proceeds
#### Reviewer Model Selection (Lead Decides Per-Task)
| Task Complexity | Reviewer Model |
|----------------|----------------|
| Low (1-3 files, straightforward) | Sonnet |
| Medium (3-6 files, some cross-system) | Lead decides (default Opus) |
| High (6+ files, refactoring, deep chains) | Opus |
| When in doubt | Opus |
#### Two-Pass Review Cycle
Each task gets TWO review passes before completion:
```dot
digraph review {
"Implementer builds + tests" -> "De-sloppify cleanup";
"De-sloppify cleanup" -> "Pass 1: Code Review";
"Pass 1: Code Review" -> "Implementer fixes code findings";
"Implementer fixes code findings" -> "Pass 2: Test Quality Review";
"Pass 2: Test Quality Review" -> "Implementer fixes test findings";
"Implementer fixes test findings" -> "Test Alignment Audit (crucible:test-coverage)";
"Test Alignment Audit (crucible:test-coverage)" -> "Test Gap Writer";
"Test Gap Writer" -> "Adversarial Tester";
"Adversarial Tester" -> "Task complete";
}
```
**Pass 1 — Code Review:** Architecture, patterns, correctness, wiring (actually connected, not just existing?)
**Pass 2 — Test Quality Review:** Test independence? Determinism? Edge cases? Integration tests where mocks are masking real behavior? AAA pattern? Correct test level? (Staleness and alignment checks are handled by the test-coverage dispatch below.)
#### Review Tier Routing
Each task's `Review-Tier` (from the plan) determines which review steps execute. Phase 4 full-implementation gates are NOT affected by per-task tiers.
| Step | Tier 1 | Tier 2 | Tier 3 |
|------|--------|--------|--------|
| Implementer | Yes | Yes | Yes |
| De-sloppify cleanup | Yes | Yes | Yes |
| Pass 1: Code review | Single pass | Iterative | Iterative |
| Implementer fixes (code) | If findings | If findings | If findings |
| Pass 2: Test quality review | SKIP | Single pass (non-iterative) | Iterative |
| Implementer fixes (test) | SKIP | If critical findings only | If findings |
| Test alignment audit | SKIP | SKIP | Yes |
| Test gap writer | SKIP | SKIP | Yes |
| Adversarial tester | SKIP | Yes | Yes |
**Tier 1 "single pass" code review:** Dispatch one reviewer. If findings are Clean, task is complete. If findings include Critical or Important issues, dispatch implementer to fix, then the task is complete (no re-review). If findings include an Architectural Concern, escalate as normal.
**Tier 2 "single pass" test review:** Dispatch one test quality reviewer. Report findings but do NOT enter the iterative review loop. If the single pass surfaces Critical findings, escalate the task to Tier 3 for full iterative treatment.
**Tier 2 "iterative" code review:** Same as current behavior -- fresh reviewer each round, track issue count, loop until clean or stagnation.
#### Runtime Tier Escalation
The orchestrator may escalate a task's review tier during execution. Escalation is one-directional (up only).
**Triggers:**
- Implementer reports unexpected complexity or cross-system interaction not anticipated in the plan
- Single-pass reviewer (Tier 1 code review or Tier 2 test review) reports Critical findings
- Implementer touches significantly more files than the plan specified
**Process:**
1. Log escalation to decision journal: `[timestamp] DECISION: review-tier | choice=escalate T1->T2 | reason=<trigger> | alternatives=none`
2. Execute the additional review steps for the new tier (from the point where the current tier's pipeline diverges)
3. Update the task status display to show the escalated tier
#### Contract-Aware Reviewer Guidance
When a contract YAML exists for the current ticket, reviewers receive the contract alongside the implementation and must add the following checks to both review passes:
1. **API surface compliance:** Do the implemented public interfaces match the `api_surface` declarations in the contract? Check function signatures, class interfaces, endpoint shapes, parameter names/types, and return types. Any deviation from the contract's declared API surface is a blocking finding.
2. **Checkable invariant satisfaction:** Are all `checkable` invariants satisfied per their declared `check_method`?
- `grep`: verify the pattern match (or absence) in production code
- `code-inspection`: read and reason about code to confirm the invariant holds
- `file-structure`: check file existence/organization matches the constraint
Any unsatisfied checkable invariant is a blocking finding.
3. **Testable invariant test existence:** Does a test exist for each `testable` invariant, tagged with the correct `test_tag` (pattern: `contract:<category>:<id>`)? A missing tagged test is a blocking finding.
4. **Test correctness:** Do the tagged tests actually validate the invariant they claim to cover? A test that exists but does not meaningfully exercise the invariant (e.g., a trivially passing assertion, a test that tests something unrelated despite having the right tag) is a blocking finding.
**Severity:** All contract-related review findings are classified as **blocking** — the same severity as contract violations in the quality gate. Contract findings must be resolved before the task is marked complete.
#### Test Alignment Audit
After the implementer addresses Pass 2 findings, invoke `crucible:test-coverage` against the task's changes:
- Code diff: `git diff <pre-task-sha>..HEAD`
- Affected test files: test files touched or related to the task
- Context: "Build task N: [task description]"
The test-coverage skill audits existing tests for staleness (wrong assertions, misleading descriptions, dead tests, coincidence tests) and handles its own fix dispatch and revert-on-failure logic. It returns a structured report. Note: the diff includes review fix commits — the audit agent should focus on behavioral changes to source files, not changes that only touch test files.
**Skip this step if** the task made no behavioral source changes (only `.md`, `.json`, config files).
#### Test Gap Writer
After test-coverage completes (or is skipped), dispatch a **Test Gap Writer** (Opus) using `./test-gap-writer-prompt.md`:
1. Input: Pass 2 test reviewer's missing coverage findings + implementer's changes + test-coverage audit report (if available)
2. The test gap writer writes tests ONLY for gaps the reviewer identified — no scope creep. Before writing a new test for a flagged gap, verify no existing test already covers this path (it may have been updated by the test-coverage audit).
3. Tests should pass immediately (the behavior already exists from implementation)
4. The test gap writer reports per-test PASS/FAIL results (see prompt template for report format)
5. Commits new tests: `test: fill coverage gaps for task N`
**If all tests PASS:** Continue to adversarial tester.
**If some tests FAIL** (gaps reveal genuinely missing implementation):
1. Dispatch a fresh implementer (Opus) with the failing test(s), their failure messages, and the gap descriptions from the reviewer
2. Implementer fixes the missing behavior, then re-runs ALL test gap writer tests (not just the failures — catches regressions from the fix)
3. If all tests pass after fix: commit (`fix: address test gap failures for task N`), continue to adversarial tester
4. If tests still fail after one fix attempt: **escalate to user** with:
- Which coverage gaps the reviewer identified
- Which tests the gap writer wrote (per-test PASS/FAIL)
- What the implementer attempted to fix
- Which tests still fail and their current failure messages
**Skip this step if** the Pass 2 test reviewer reported zero missing coverage gaps.
#### Adversarial Tester
After the test gap writer completes (or is skipped), dispatch an **Adversarial Tester** (Opus) using `skills/adversarial-tester/break-it-prompt.md`:
1. Input: Full diff of the task's changes (`git diff <pre-task-sha>..HEAD`), project test conventions, cartographer module context (if available)
2. The adversarial tester identifies the top 5 most likely failure modes, writes one test per mode, and runs them
3. Outcome handling:
- **All tests PASS:** Implementation is robust. Log results and proceed to task complete.
- **Some tests FAIL:** Real weaknesses found. Dispatch implementer to fix. Re-run all tests (including adversarial). If pass → task complete. If fail → one more fix attempt, then escalate to user.
- **Tests ERROR (won't compile):** Adversarial tester mistake. Discard broken tests, log, proceed to task complete.
4. Quality bypass prevention: If the implementer's fix touches more than 3 files, route through a lightweight code review before completing.
5. Commit adversarial tests: `test: adversarial tests for task N`
**Skip this step when:**
- The task diff contains no behavioral source files (only `.md`, `.json`, `.yaml`, `.uss`, `.uxml`)
- No tests were written during implementation (pure scaffolding)
#### Iterative Review Loop
Each review pass (code and test) uses the iterative loop:
- After fixes, dispatch a **NEW fresh Reviewer** (no anchoring to prior findings)
- Track issue count between rounds
- **Strictly fewer issues** → progress, loop again
- **Same or more issues** → stagnation, **escalate to user**
- Loop until clean
- Architectural concerns → **immediate escalation** regardless of round
#### Verification Gates
After each wave completes:
1. Run full test suite (not just current wave's tests)
2. Check compilation
3. Failures → identify which task caused regression before fixing
4. Clean → proceed to next wave
#### Refactor Mode: Phase 3 Changes
When in refactor mode, Phase 3 execution differs from feature mode in several ways.
##### Pre-Execution Coverage Check
Before the first task executes:
1. Run all contract tests from Phase 1 — confirm GREEN
2. Run the full test suite — confirm GREEN (pre-execution baseline)
3. Record the "baseline commit" SHA in `/tmp/crucible-build-mode.md` — this is the rollback target
##### Tiered Test Strategy
Running the full test suite after every atomic step is prohibitively expensive. Instead:
- **(a) After each atomic task:** Run blast-radius tests + direct consumer tests only (tests identified in the impact manifest)
- **(b) After each execution wave:** Run the full test suite (matches existing verification gate between waves)
- **(c) Full suite checkpoints:** Pre-execution baseline and Phase 4 final verification always run the full suite
##### Coordinated-Atomic Execution
When the executor encounters a task marked `atomic: true`:
1. Record pre-task commit SHA
2. Implementer makes ALL changes (multiple files) — dispatch with `./refactor-implementer-addendum.md` appended
3. Run blast-radius tests + direct consumer tests (per tiered strategy)
4. **If GREEN:** Commit all files together in a single commit
5. **If FAIL:** Revert ALL files to pre-task SHA. Dispatch one retry with a fresh implementer that receives the failure context and test output. If second attempt also fails, revert to pre-task SHA and escalate to user (see Rollback Policy below).
**Key difference from feature mode:** Feature mode does RED-GREEN-REFACTOR. Refactor mode for atomic steps does **GREEN-GREEN** — tests are green before, tests must be green after. No RED phase because no new behavior is being added.
After a successful atomic commit (step 4), the rest of the per-task pipeline continues as normal: de-sloppify cleanup, two-pass review cycle, test alignment audit, test gap writer, and adversarial tester (unless skipped per restructuring-only annotation below).
**Non-atomic refactoring tasks** follow normal execution — structural changes that don't break intermediate states (e.g., extracting a private method, adding a module nothing imports yet). These use standard TDD if they introduce new abstractions, or GREEN-GREEN if they are pure restructuring.
##### Phase 3 Adaptations for Existing Steps
- **Adversarial tester:** The planner annotates each task with `restructuring-only: true/false`. If `restructuring-only: true`, adversarial testing is skipped. Tasks with `restructuring-only: false` still get adversarial testing. When in doubt, default to `false`.
- `restructuring-only: true` examples: renames where all call sites are mechanically updated, file moves with updated paths, extract-method where the extracted method is private and preserves the original call signature
- `restructuring-only: false` examples: extract-class where callers must change call targets, splitting a module where consumers must update imports, any change where the consumer-facing API surface shifts
- **De-sloppify cleanup:** Gains a new removal category: **dead compatibility shims.** After a refactoring task, look for leftover adapter code, re-export aliases, or compatibility layers introduced during migration but no longer referenced. Detection scope: code added after the baseline commit SHA that re-exports, aliases, or wraps symbols under old names, AND where no code outside the refactoring's changed files references the old names. **String-based references:** When the target was registered by name in a configuration system, flag the shim as UNCERTAIN and defer to the reviewer rather than removing it.
##### Refactoring Rollback Policy
###### Baseline Commit
The orchestrator records the baseline commit SHA before the first refactoring task executes (during pre-execution coverage check). Persisted in `/tmp/crucible-build-mode.md`.
###### Per-Task Rollback
When a single task fails after the executor's retry attempt:
1. Revert that task's changes to the pre-task commit SHA
2. Escalate to user with failure context and test output
3. User chooses: **skip this task and continue** (orchestrator also skips all tasks that depend on the skipped task, and informs the user which tasks were transitively skipped), **retry with guidance**, or **revert all tasks to baseline**
###### Full Rollback to Baseline
When the user chooses full rollback (or cascading failures make forward progress impossible):
1. Perform `git reset --hard <baseline-SHA>` to restore pre-refactoring state
2. Re-run all contract tests to confirm known-good state
3. Report what was reverted and why
###### Safe Partial States
The planner annotates tasks with `safe-partial: true/false`. A task is `safe-partial: true` if the codebase is in a valid, shippable state after that task completes (all tests green, no dangling references). When a later task fails, the orchestrator can offer to keep changes through the last safe-partial task.
#### Architectural Checkpoint
For plans with 10+ tasks, at ~50% completion or after a major subsystem:
- Dispatch architecture reviewer using `./architecture-reviewer-prompt.md`
- Design drift → escalate to user
- Minor concerns → adjust prompts for remaining tasks
- All clear → continue
### Noticed Reconciliation
After all implementers in Phase 3 report back and before writing the Phase 3 COMPLETE ledger entry, aggregate their `### Noticed But Not Touching` sections into a single `docs/plans/<YYYY-MM-DD>-<ticket-slug>-noticed.md` artifact.
**Scope discipline:** Notice, do not act. If an implementer sees an out-of-scope issue during implementation, it must be logged under `### Noticed But Not Touching` in their report — NOT fixed in their diff. Acting on noticed items in the same task is a scope-discipline failure. The orchestrator enforces this via reconciliation: noticed entries are surfaced here and converted to follow-up tickets later (see `/finish`).
**7-step reconciliation process:**
1. Collect each implementer's `### Noticed But Not Touching` section from every Phase 3 implementer report.
2. Skip any section whose body is `*(none)*`.
3. Dedupe entries using the canonical dedupe key: `sha256( normalize(file_path) + "|" + line_range + "|" + noticed[:40] )`, where `normalize(file_path)` is the repo-relative POSIX path lowercased.
4. Sort the deduped entries by file path, then line range.
5. If any entries remain, write `docs/plans/<YYYY-MM-DD>-<ticket-slug>-noticed.md` matching the canonical filename regex `^docs/plans/\d{4}-\d{2}-\d{2}-[a-z0-9-]+-noticed\.md$`. Use the date embedded in the sibling plan filename (not wall-clock date) so all sibling artifacts share a date; slug matches the ticket being built. Frontmatter and body must follow the Canonical Constants template exactly:
```markdown
---
pipeline_id: "<build-YYYYMMDD-HHMMSS>"
date: "YYYY-MM-DD"
ticket: "#NNN"
---
# Noticed But Not Touching — <ticket-slug>
- **file:** `path:L<start>-L<end>`
**noticed:** <desc>
**why it matters:** <risk/opportunity>
**suggested follow-up:** <optional>
```
6. **Idempotent overwrite:** If the target `-noticed.md` already exists (same-ticket re-run on the same date), merge the existing entries with the newly collected entries, run the full dedupe (same key), sort, and overwrite the file in one write. No append-mode; the on-disk file is always the full deduped set for that date+ticket.
7. Stage the `-noticed.md` file so it lands in the PR commit.
Skip the write entirely if zero entries remain after dedupe — do not produce an empty `-noticed.md`.
### Gate Ledger — Phase 3 Complete
After the last task wave's verification gate passes and all tasks are marked complete — but BEFORE the Phase 3→4 handoff — write `Status: COMPLETE` and `Tasks: N/N complete` to the Phase 3 ledger entry. If any task is in a retry/re-dispatch loop, COMPLETE is NOT written until retries resolve.
### Phase Handoff: 3 → 4
Before running acceptance tests and code review, verify the gate ledger and write a handoff manifest:
0. **Gate ledger check:** Read `build-gate-ledger.md` and verify Phase 3 Status is `COMPLETE`. If not, follow Enforcement Rules.
Write the handoff manifest:
1. Write `handoff-3-to-4.md` with:
- **Goal:** original user request, verbatim
- **Mode:** feature or refactor
- **Inputs for Phase 4:** HEAD SHA (all tasks committed), design doc path, acceptance test paths (or contract tests), baseline SHA (for `git diff` scope), task summary (completed count, escalation outcomes)
- **Decisions Carried Forward:** accumulated decisions from Phases 1-3
- **Active Constraints:** constraints affecting completion review
- **Shed Receipt:** per-task review rounds, implementer context, wave verification details → task completion status in task list; per-task review details are shed
2. Emit shed statement: "Phase 3 context shed. Working code at HEAD, design doc, and acceptance tests on disk. Per-task implementation context, review rounds, and verification details are not carried forward."
3. Update `## Compression State` in pipeline-status.md with manifest contents.
4. Do NOT emit a Compression State Block.
5. **Session index event:** Emit a `phase_change` event to the outbox: `{"ts":"<now>","seq":0,"type":"phase_change","summary":"Build: Phase 3 -> Phase 4 (Completion)","detail":{"skill":"build","from":"3","to":"4"}}`.
## Phase 4: Completion
> **Eval-gate pointer (Mock Dispatch Mode):** when `CRUCIBLE_BUILD_EVAL_MOCK_DIR` is set, the temper, inquisitor, optional siege, quality-gate, forge, cartographer, and finish dispatches in this phase use the mock-dir substitution rule defined in the `## Mock Dispatch Mode (eval-gate)` section. Local test-suite execution (`pytest`, etc.) runs normally — substitution applies only to subagent dispatches.
After all tasks complete:
0. **Write Phase 4 IN_PROGRESS** to the gate ledger (after Phase 3 COMPLETE verification).
1. **Feature mode:** Run acceptance tests from Phase 1 Step 3 — verify they **PASS** (GREEN). **Refactor mode:** Run all contract tests from Phase 1 — verify they **PASS** (GREEN).
- If any fail: implementation is incomplete. Identify what's missing, dispatch implementer to fix, re-run.
- If all pass: feature is verifiably done. Proceed.
2. Run full test suite (unit + integration)
2.5. **Guarantee a fully-clean working tree before the review gate.** The Step-1/Step-2 test runs sit between the Phase-3 commit and the review gate and can leave residue — not only tracked modifications but also untracked artifacts. Run `git status --porcelain` and drive the tree to **fully empty** (tracked **and** untracked) by **producing** that clean state, not merely checking for one. Because `git status --porcelain` never lists `.gitignore`d paths, properly-ignored caches/coverage never appear, so the residual to classify is small and author-actionable:
- **Tracked modifications** (a passing test regenerated a golden file or updated a snapshot) → **commit** them: `git add -A && git commit -m "chore(build): commit test-regenerated artifacts before gate"`. This is normal output — committed, not flagged.
- **Non-ignored new untracked files** → classify each: a legitimately-generated file that belongs in the repo (a golden/fixture) → **commit** it (fold into the same commit); an incidental artifact that should never be tracked (coverage output, a cache) → **gitignore** it (a test-hygiene fix) so it stops appearing.
- There is **NO bare "assert-clean" branch.** An assert-clean would spuriously halt a *healthy* build whenever a passing test regenerates a golden file, and untracked test residue would make the review gate REFUSE inside a healthy build. build must **produce** the clean tree, not merely check for one.
- A tree that is **still** dirty or untracked *after* the commit/gitignore step is a **surfaced build/test defect** — surface it (error out) and stop; do NOT silently sweep it into the gate's first commit. End state: `git status --porcelain` empty (tracked **and** untracked), identical to the review gate's entry precondition.
3. **REQUIRED SUB-SKILL:** Use crucible:warden on the full implementation — the consolidated pre-push review gate. Pass `reviewer-set: full`, `Phase: code`, and `PipelineID: <current PipelineID>`. warden runs the full reviewer set (temper + delve + red-team[quality-gate leg] + siege[conditional, same security-signal trigger] + inquisitor[unconditional in full]) as one disjunction-of-native-gates over a fully-clean tree (Step 2.5 guaranteed it), drives and commits every leg's fixes (non-`fix:` subjects), and emits the single build-`PipelineID` aggregate verdict marker. On `BLOCKED`, halt the pipeline (fail-closed). Do NOT separately invoke temper / inquisitor / siege / quality-gate here — warden is the sole code-leg gate-driver (I-W4).
6b. **Verify warden's aggregate verdict marker and write Phase 4 PASS** to the gate ledger (see Verdict Marker Verification). The marker to verify is now **warden's** build-`PipelineID` aggregate verdict marker — warden owns it (it is no longer a temper or quality-gate marker). The READ mechanism (marker present + `Verdict: PASS` + PipelineID match) is unchanged; only the marker's owner/name changed. Delete the verdict marker after writing the ledger entry.
7. **RECOMMENDED SUB-SKILL:** Use crucible:forge (retrospective mode) — capture what happened vs what was planned
7.5. **Chronicle signal fallback:** If forge retrospective was skipped (user declined, session ending),
append a minimal chronicle signal directly:
- Read the metrics log at `/tmp/crucible-metrics-<session-id>.log` for duration and subagent counts
- Construct signal: `v=1`, `ts=now`, `skill="build"`, `outcome` from acceptance test results,
`duration_m` from metrics log, `branch` from git, `files_touched` from `git diff <base-sha>..HEAD --name-only`,
`metrics={mode, tasks count, tasks_passed count from task list, stagnation=false}`
- Append as a single JSON line to `~/.claude/projects/<hash>/memory/chronicle/signals.jsonl`
- If forge retrospective DID run, skip this step (forge Step 8.5 already emitted the signal)
8. **RECOMMENDED SUB-SKILL:** Use crucible:cartographer-skill (record mode) — persist any new codebase knowledge discovered during build
9. Compile summary: what was built, acceptance tests passing, review findings addressed, inquisitor findings, concerns
10. Report to user
10.5. **Session index event:** Emit a `skill_end` event to the outbox: `{"ts":"<now>","seq":0,"type":"skill_end","summary":"/build complete: <outcome summary>","detail":{"skill":"build","outcome":"success|failure|escalated"}}`.
11. **REQUIRED SUB-SKILL:** Use crucible:finish — **skip finish's warden call** (warden already ran in build Phase 4 at step 3 — this is the structural double-temper kill), **AND skip finish's Step 2.5 (test-coverage)** since test-coverage ran per-task in Phase 3. Tell finish to skip both.
12. **Delete pipeline-active marker:** Remove `<scratch>/.pipeline-active`. This signals that the pipeline completed successfully. If deletion fails (permissions, missing file), log a warning but do not fail the pipeline.
### Session Metrics
Throughout the pipeline, the orchestrator appends timestamped entries to `/tmp/crucible-metrics-<session-id>.log` on each subagent dispatch and completion.
**Dispatch measurement protocol:** On every subagent dispatch, the orchestrator follows the enriched manifest protocol from `shared/dispatch-convention.md`:
- **Before dispatching:** Measure the dispatch file size in characters. Record `input_chars` and `model_tier` in the manifest entry.
- **After dispatch returns:** Measure the subagent response length in characters. Record `output_chars` and `tool_calls` (if available) in the manifest completion entry.
At completion (before reporting to user, i.e. step 9), read the metrics log and manifest, then compute:
```
-- Pipeline Complete ----------------------------------------
Subagents dispatched: 23 (14 Opus, 7 Sonnet, 2 Haiku)
Active work time: 2h 47m
Wall clock time: 11h 13m
Quality gate rounds: 4 (design: 2, plan: 1, impl: 1)
Siege: dispatched (3 agents, 2 rounds, 0 Critical, 0 High) | skipped (0 signals) | skipped (1 signal: auth)
Task tiers: 3 Tier 1, 3 Tier 2, 2 Tier 3
Subagent savings: ~21 dispatches skipped vs all-Tier-3
Est. input tokens: ~32,100 (128,400 chars)
Est. output tokens: ~20,500 (82,000 chars)
Token estimate note: Based on dispatch file sizes (chars/4). Actual consumption may vary +/-30%.
-------------------------------------------------------------
```
**Metrics tracked:**
- Total subagents dispatched (by type and model tier: Opus/Sonnet/Haiku)
- Active work time (merge overlapping parallel intervals — NOT naive sum)
- Wall clock time (first dispatch to final completion)
- Quality gate rounds (per gate: design, plan, implementation)
- Siege status (dispatched with agent count, rounds, and final severity counts — or skipped with signal count and reason)
- Estimated input tokens (sum of `input_chars` from manifest / 4)
- Estimated output tokens (sum of `output_chars` from manifest / 4)
**Efficiency summary computation:** Read `manifest.jsonl` from the dispatch directory. Sum `input_chars` and `output_chars` across all completed entries (skip nulls). Divide each by 4 for token estimates. Count dispatches grouped by `model_tier`. Include these in the pipeline completion report alongside existing metrics.
**Gate tracking verification:** Before compiling the pipeline summary (Phase 4 Step 9), verify that all three gate categories (design, plan, implementation) show round count >= 1 with clean final rounds (0 Fatal, 0 Significant). If any gate was skipped with explicit user approval, record it as `USER_SKIP` in the metrics. A zero without user approval indicates a gate was dropped — report this in the summary.
### Pipeline Decision Journal
Alongside the metrics log, maintain a decision journal at `/tmp/crucible-decisions-<session-id>.log`. Append a structured entry for every non-trivial routing decision:
```
[timestamp] DECISION: <type> | choice=<what> | reason=<why> | alternatives=<rejected>
```
Decision types to capture:
- `reviewer-model` — why Opus vs Sonnet for this reviewer
- `review-tier` -- tier assignment read from plan, runtime escalation reason if applicable
- `gate-round` — issue count, severity shifts, progress/stagnation per round
- `escalation` — why the orchestrator escalated to user (and user's decision)
- `task-grouping` — parallelism decisions for wave execution
- `cleanup-removal` — what de-sloppify removed and accept/reject decision
## Escalation Triggers (Any Phase)
**STOP and ask the user when:**
- Architectural concerns in plan or code review
- Review loop stagnation (same or more issues after fixes — any phase)
- Test suite failures not obviously fixable
- Multiple teammates fail on different tasks
- Teammate reports context pressure at 50%+ with significant work remaining
- When escalating for regression or stagnation AND a checkpoint exists for the current phase boundary: include "A checkpoint from [reason] is available. Restore to pre-regression state?" in the escalation message.
**Minor issues:** Log, work around, include in final report.
## What the Lead Should NOT Do
- Implement code (dispatch implementers)
- Read large files (spawn Haiku researcher)
- Debug failing tests (dispatch implementer)
- Make architectural decisions (escalate to user)
## Context Management
- **One task per agent** — always spawn a fresh implementer for each task. Never send a second task to a running agent via SendMessage. Reusing agents accumulates context and causes exhaustion.
- "2-3 per subagent, ~10 files max" refers to **plan design** — group small steps into one task at planning time, not sequential dispatch to a running agent
- Lead stays thin — coordination only
- All important state on disk (plan files, task list)
- Teammates report at 50%+ context usage
- Lead compaction acceptable — task list is source of truth
- **Agent teams unavailable:** If agent teams are not enabled, the lead dispatches tasks sequentially via Agent tool. Task tracking still uses TaskCreate/TaskUpdate. The pipeline is slower but functionally identical.
## Prompt Templates
- `./acceptance-test-writer-prompt.md` — Phase 1 acceptance test generation
- `./plan-writer-prompt.md` — Phase 2 plan writer dispatch
- `./plan-reviewer-prompt.md` — Phase 2 plan reviewer dispatch
- `./build-implementer-prompt.md` — Phase 3 implementer dispatch
- `./build-reviewer-prompt.md` — Phase 3 reviewer dispatch
- `./cleanup-prompt.md` — Phase 3 de-sloppify cleanup dispatch
- `./test-gap-writer-prompt.md` — Phase 3 test gap writer dispatch
- `./architecture-reviewer-prompt.md` — Mid-plan checkpoint
- `./contract-test-writer-prompt.md` — Phase 1 refactor-mode contract test generation
- `./refactor-implementer-addendum.md` — Phase 3 refactor-mode implementer addendum (appended to build-implementer-prompt)
Red-team, innovate, adversarial tester, and inquisitor prompts live in their respective skills:
- `crucible:red-team` — `skills/red-team/red-team-prompt.md`
- `crucible:innovate` — `skills/innovate/innovate-prompt.md`
- `crucible:adversarial-tester` — `skills/adversarial-tester/break-it-prompt.md`
- `crucible:inquisitor` — `skills/inquisitor/inquisitor-prompt.md`
## Quality Gate Orchestration
Build is the outermost orchestrator. The **design and plan** gates route via `crucible:quality-gate`; the **code** gate routes via `crucible:warden` (which drives the quality-gate red-team leg exactly once). Do NOT invoke red-team (or temper/inquisitor/siege) separately at these points.
**Gate points in the pipeline:**
| Pipeline Stage | Artifact Type | Replaces |
|---------------|---------------|----------|
| Phase 1, Step 2 (after design) | design | Existing `crucible:red-team` on design |
| Phase 2, Step 3 (after plan review) | plan | Existing `crucible:red-team` on plan |
| Phase 4 (single warden gate) | code | `crucible:warden` — runs the red-team leg (+ temper/delve/inquisitor/siege) |
For the **code leg**, code review (`crucible:temper`), inquisitor (`crucible:inquisitor`), and the quality-gate red-team leg are now consolidated inside `crucible:warden` — temper does structured quality checks, inquisitor writes cross-component adversarial tests, and the red-team leg does adversarial artifact review (plus delve and a conditional siege run). The three purposes still stand, but warden drives them as one disjunction-of-native-gates rather than as separate steps.
### Contract-Aware Quality Gate
When a contract YAML exists for the current ticket, the quality gate adds contract verification to its checks. This applies at all gate points (design, plan, and code), though most contract checks are only meaningful at the code gate (Phase 4, Step 6).
1. **Version check:** Before processing a contract, verify the `version` field is `"1.0"`. If the version is missing or unrecognized, reject the contract with a clear error: "Contract version [X] is not supported. Expected version 1.0." Do not proceed with contract-aware checks — fall back to standard quality gate behavior without contract awareness.
2. **Checkable invariant verification:** For each `checkable` invariant in the contract, verify satisfaction using the declared `check_method`:
- `grep` — pattern match (or absence) in production code. Run the grep and confirm the result matches the invariant's `verification` description.
- `code-inspection` — read and reason about the relevant code to confirm the invariant holds (e.g., idempotency, no side effects).
- `file-structure` — check that file existence, location, or organization matches the constraint.
3. **Testable invariant verification:** For each `testable` invariant in the contract:
- Verify that a test tagged with the declared `test_tag` (pattern: `contract:<category>:<id>`) exists in the test suite.
- Verify that the tagged test passes when run.
- A missing or failing tagged test is a contract violation.
4. **Contract violations are blocking issues.** Contract violations are NOT warnings — they have the same severity as architectural concerns and must be resolved before the gate passes. The quality gate's iterative fix loop applies: dispatch fixes, re-check, track progress/stagnation as normal.
## Red Flags
- Skipping Compression State Block emission at checkpoint boundaries
- Emitting a Compression State Block at a phase boundary (1→2, 2→3, 3→4) instead of writing a handoff manifest
- Skipping the shed statement after a manifest write
- Emitting a Compression State Block with stale or missing Key Decisions (decisions must be cumulative across all prior blocks)
- Allowing the Goal field to drift across successive Compression State Blocks (must match original user request)
- Exceeding 10 entries in the Key Decisions list without overflow-compressing the oldest
- Skipping a REQUIRED quality gate because the task seems "small", "simple", or "trivial"
- Self-assessing that a quality gate is unnecessary based on perceived task complexity
- Rationalizing that quality-gate findings would be "minor" as justification to skip
- Declaring a quality gate "done" after fixing findings without a clean verification round (fixing is not passing)
- Short-circuiting the quality-gate iteration loop by assuming fixes are self-evidently correct
- Interpreting general user feedback as approval to skip a quality gate that has not yet run — once a gate has run and presented findings to the user, the user's decision to proceed is authoritative. Pre-gate skip approval must be an unambiguous instruction specifically referencing the gate.
- Treating session index summary as authoritative over CSB state (session index is supplementary narrative, CSB is authoritative state)
## Integration
**Required sub-skills:**
- **crucible:design** — Phase 1
- **crucible:finish** — Phase 4
- **crucible:quality-gate** — Iterative red-teaming at each quality gate point
- **crucible:red-team** — Adversarial review engine (invoked by quality-gate)
- **crucible:innovate** — Creative enhancement before quality gates
- **crucible:inquisitor** — Full-feature cross-component adversarial testing (Phase 4, inside the warden gate)
- **crucible:warden** — Consolidated pre-push code-review gate (Phase 4): temper + delve + quality-gate red-team leg + conditional siege + inquisitor as one disjunction-of-native-gates.
**Recommended sub-skills:**
- **crucible:forge** — Feed-forward at Phase 1 start, retrospective at Phase 4 completion
- **crucible:cartographer-skill** — Consult at Phase 1 start, load at Phase 3 dispatches, record at Phase 4
- **crucible:checkpoint** — Shadow git checkpoints at pipeline boundaries (pre-design-gate, pre-plan-gate, pre-wave-N, pre-cleanup-task-N)
**Recon/assay context:** Inherits recon/assay context through /design (Phase 1). No direct dispatch. When design integrates recon, build benefits automatically. See #147 for rationale.
**Phase 3 sub-skills (dispatched per-task):**
- **crucible:test-coverage** — Test alignment audit after each task's test quality review (staleness, dead tests, coincidence tests)
**Implementer sub-skills:**
- **crucible:test-driven-development** — TDD within each task
- **crucible:source-driven-development** — Detect → Fetch → Implement → Cite loop for non-trivial external API usage (≥ 5 LOC touching a detected framework); invoked by the implementer prompt's Source Consultation block. Recommended — skipped for pure internal refactors or trivial edits.
**Contract consumption:**
- **crucible:spec** — Consumes contract YAML files produced by `/spec` (schema version 1.0). Contracts are read from `docs/plans/*-contract.yaml` and feed into pre-existing doc detection (Phase 1 Step 0), implementer dispatch (Phase 3), reviewer checks (Phase 3), and quality gate verification (all gate points). See [`crucible:spec/contract-schema.md`](../spec/contract-schema.md) for field definitions.
test-gap-writer-prompt.md
<!-- DISPATCH: disk-mediated | This template is written to a dispatch file,
not pasted into the Agent tool prompt. See shared/dispatch-convention.md -->
# Test Gap Writer Prompt Template
Use this template when dispatching a test gap writer after Pass 2 (Test Review) identifies missing coverage.
```
Task tool (general-purpose, model: opus):
description: "Write tests for coverage gaps in task N"
prompt: |
You are a test writer. Your job is to write tests for behaviors that were discovered during implementation but aren't covered by the existing test suite.
## Test Review Findings
[PASTE: The Pass 2 test reviewer's report, specifically the "Missing coverage" and "Edge cases untested" sections]
## Implementation Context
[PASTE: The implementer's changes — git diff of the task's commits]
## Test Alignment Audit Report
[PASTE: The test-coverage audit report from crucible:test-coverage,
if available. This shows which existing tests were updated, deleted,
or flagged as coincidence tests. If no audit was run, write
"Not available — no test-coverage audit was run for this task."]
## Project Test Conventions
[PASTE: Project test conventions from CLAUDE.md or cartographer — naming patterns, test framework, AAA pattern, etc.]
## Your Job
For each coverage gap identified by the test reviewer, write a focused test that:
1. **Tests the behavior, not the implementation** — Assert on observable outcomes, not internal state
2. **Follows project conventions** — Match existing test naming, patterns, and framework usage
3. **Is independent** — Each test runs in isolation, no shared mutable state
4. **Documents the "why"** — If the behavior was discovered during implementation (not in the original spec), add a brief comment explaining what scenario this covers
## Process
1. Read the test reviewer's gap analysis
2. Check the test alignment audit report (if available). If the
test-coverage audit already updated an existing test to cover
a flagged gap, do NOT write a duplicate test for that path.
3. For each remaining gap:
a. Write the test (RED — should fail if the behavior didn't exist)
b. Run it — verify it PASSES (the behavior already exists from implementation)
c. If it fails: the gap is real but the behavior wasn't actually implemented. Flag this for the implementer.
3. Group tests logically — add to existing test files where appropriate, create new files only when necessary
4. Run the full test suite to ensure no regressions
## What You Must NOT Do
- Write tests for behaviors the test reviewer didn't flag (that's scope creep)
- Refactor existing tests (that's not your job)
- Modify implementation code (only write tests)
- Write tests that verify language/framework behavior (the de-sloppify agent would just remove them)
## Report Format
```
TEST GAP REPORT
===============
Tests written (per-test results):
- [test_file:test_name] — Covers: [gap description] — Result: PASS
- [test_file:test_name] — Covers: [gap description] — Result: FAIL
Failure: [assertion error or exception message]
Fix guidance: [what behavior is missing and where to implement it]
Summary:
- Total tests written: N
- Passing: N
- Failing: N
Test suite (full): PASS/FAIL (N total tests, M failures)
```
```
tests/test-noticed-reconciliation.sh
#!/usr/bin/env bash
# Mechanical contract test for /build Noticed reconciliation.
# Tag: contract:integration:inv-4
#
# Asserts the 7-step reconciliation in skills/build/tools/reconcile-noticed.py:
# 1. parses `### Noticed But Not Touching` sections from implementer reports
# 2. skips *(none)*
# 3. dedupes by canonical key (sha256 of normalized path + range + noticed[:40])
# 4. sorts by file path then line range
# 5. writes docs/plans/<date>-<slug>-noticed.md matching INV-6 regex
# 6. idempotent overwrite: re-running with same inputs is byte-identical
# 7. frontmatter contains pipeline_id, date, ticket
set -euo pipefail
CONTRACT_TAG="contract:integration:inv-4"
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)"
RECONCILER="$REPO_ROOT/skills/build/tools/reconcile-noticed.py"
if [[ ! -f "$RECONCILER" ]]; then
echo "FAIL [$CONTRACT_TAG]: reconciler not found at $RECONCILER" >&2
exit 1
fi
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
# Synthetic report A: one entry at out_of_scope.ts:L10-L20
cat >"$WORK/reportA.md" <<'EOF'
## Report
### TDD Evidence Log
- testFoo -- RED: "x" -> GREEN: pass
### Noticed But Not Touching
- **file:** `out_of_scope.ts:L10-L20`
**noticed:** Unvalidated input flows into a SQL query builder
**why it matters:** Potential injection, but outside current ticket scope
**suggested follow-up:** File a security-hygiene ticket
EOF
# Synthetic report B: duplicate of A + unique entry at other.ts:L5-L7
cat >"$WORK/reportB.md" <<'EOF'
## Report
### Noticed But Not Touching
- **file:** `out_of_scope.ts:L10-L20`
**noticed:** Unvalidated input flows into a SQL query builder
**why it matters:** duplicate should be collapsed
- **file:** `other.ts:L5-L7`
**noticed:** Dead import left over from a refactor
**why it matters:** Minor cleanup; noise in IDE warnings
EOF
OUT_DIR="$WORK/docs/plans"
mkdir -p "$OUT_DIR"
OUT="$OUT_DIR/2026-04-16-noticed-test-noticed.md"
python3 "$RECONCILER" \
--out "$OUT" \
--pipeline-id "build-20260416-120000" \
--date "2026-04-16" \
--ticket "#179" \
--slug "noticed-test" \
"$WORK/reportA.md" "$WORK/reportB.md" >/dev/null
# Assert 1: filename matches INV-6 regex
REL_OUT="docs/plans/2026-04-16-noticed-test-noticed.md"
if ! [[ "$REL_OUT" =~ ^docs/plans/[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9-]+-noticed\.md$ ]]; then
echo "FAIL [$CONTRACT_TAG]: output path does not match INV-6 filename regex" >&2
exit 1
fi
# Assert 2: exactly 2 entries
ENTRY_COUNT=$(grep -cE '^- \*\*file:\*\*' "$OUT")
if [[ "$ENTRY_COUNT" -ne 2 ]]; then
echo "FAIL [$CONTRACT_TAG]: expected 2 entries, got $ENTRY_COUNT" >&2
cat "$OUT" >&2
exit 1
fi
# Assert 3: entries sorted by file path then line range (other.ts < out_of_scope.ts)
FIRST=$(grep -E '^- \*\*file:\*\*' "$OUT" | sed -n '1p')
SECOND=$(grep -E '^- \*\*file:\*\*' "$OUT" | sed -n '2p')
if [[ "$FIRST" != *"other.ts"* ]] || [[ "$SECOND" != *"out_of_scope.ts"* ]]; then
echo "FAIL [$CONTRACT_TAG]: entries not sorted by file path" >&2
echo "first=$FIRST" >&2
echo "second=$SECOND" >&2
exit 1
fi
# Assert 4: frontmatter contains pipeline_id, date, ticket
for field in "pipeline_id" "date" "ticket"; do
if ! grep -qE "^${field}:" "$OUT"; then
echo "FAIL [$CONTRACT_TAG]: frontmatter missing $field" >&2
exit 1
fi
done
# Capture first-run bytes
FIRST_RUN_SHA=$(sha256sum "$OUT" | awk '{print $1}')
# Re-run with same inputs → idempotent overwrite
python3 "$RECONCILER" \
--out "$OUT" \
--pipeline-id "build-20260416-120000" \
--date "2026-04-16" \
--ticket "#179" \
--slug "noticed-test" \
"$WORK/reportA.md" "$WORK/reportB.md" >/dev/null
SECOND_RUN_SHA=$(sha256sum "$OUT" | awk '{print $1}')
if [[ "$FIRST_RUN_SHA" != "$SECOND_RUN_SHA" ]]; then
echo "FAIL [$CONTRACT_TAG]: non-idempotent overwrite (sha256 changed between runs)" >&2
echo "first=$FIRST_RUN_SHA" >&2
echo "second=$SECOND_RUN_SHA" >&2
exit 1
fi
# Assert *(none)* is skipped
cat >"$WORK/reportC.md" <<'EOF'
### Noticed But Not Touching
*(none)*
EOF
OUT_NONE="$OUT_DIR/2026-04-16-noticed-none-noticed.md"
python3 "$RECONCILER" \
--out "$OUT_NONE" \
--pipeline-id "build-20260416-120000" \
--date "2026-04-16" \
--ticket "#179" \
--slug "noticed-none" \
"$WORK/reportC.md" >/dev/null
if [[ -f "$OUT_NONE" ]]; then
echo "FAIL [$CONTRACT_TAG]: *(none)*-only reports should not produce a file" >&2
exit 1
fi
echo "PASS [$CONTRACT_TAG]: reconciliation parses, dedupes, sorts, writes, and is idempotent"
tools/reconcile-noticed.py
#!/usr/bin/env python3
"""
Reconcile `### Noticed But Not Touching` sections from multiple implementer
reports into a single docs/plans/<date>-<slug>-noticed.md artifact.
Implements the 7-step reconciliation process from
docs/plans/2026-04-16-noticed-but-not-touching-implementation-plan.md §T2.
Contract tag: contract:integration:inv-4
"""
from __future__ import annotations
import argparse
import hashlib
import os
import pathlib
import re
import sys
from typing import List, Tuple
NONE_MARKER = "*(none)*"
SECTION_HEADER_RE = re.compile(r"^###\s+Noticed But Not Touching\s*$", re.MULTILINE)
ENTRY_RE = re.compile(
r"-\s+\*\*file:\*\*\s+`(?P<path>.+?):(?P<range>L\d+-L\d+)`\s*\n"
r"\s+\*\*noticed:\*\*\s+(?P<noticed>[^\n]+)\n"
r"\s+\*\*why it matters:\*\*\s+(?P<why>[^\n]+)"
r"(?:\n\s+\*\*suggested follow-up:\*\*\s+(?P<follow>[^\n]+))?"
r"(?=\n\s*-\s+\*\*file:\*\*|\n\s*###|\n\s*\n|\Z)",
)
def extract_section(report_text: str) -> str | None:
"""Return the body of the ### Noticed But Not Touching section, or None."""
m = SECTION_HEADER_RE.search(report_text)
if not m:
return None
start = m.end()
# body ends at next ### heading OR end of string
rest = report_text[start:]
next_h = re.search(r"\n###\s+\S", rest)
body = rest[: next_h.start()] if next_h else rest
return body.strip()
def parse_entries(body: str) -> List[dict]:
"""Parse entries from a section body. Returns [] if body is *(none)* or empty."""
if not body or body.strip() == NONE_MARKER:
return []
entries = []
for m in ENTRY_RE.finditer(body):
entries.append(
{
"file_path": m.group("path").strip(),
"line_range": m.group("range").strip(),
"noticed": m.group("noticed").strip(),
"why": m.group("why").strip(),
"follow": (m.group("follow") or "").strip(),
}
)
return entries
def dedupe_key(entry: dict) -> str:
"""Canonical Constants dedupe key: sha256(normalize(path) + | + range + | + noticed[:40])."""
norm_path = entry["file_path"].replace("\\", "/").lower()
raw = f"{norm_path}|{entry['line_range']}|{entry['noticed'][:40]}"
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def line_range_sort_key(entry: dict) -> Tuple[str, int, int]:
m = re.match(r"L(\d+)-L(\d+)", entry["line_range"])
if not m:
return (entry["file_path"], 0, 0)
return (entry["file_path"], int(m.group(1)), int(m.group(2)))
def render_file(
entries: List[dict], pipeline_id: str, date: str, ticket: str, slug: str
) -> str:
lines = [
"---",
f'pipeline_id: "{pipeline_id}"',
f'date: "{date}"',
f'ticket: "{ticket}"',
"---",
"",
f"# Noticed But Not Touching — {slug}",
"",
]
for e in entries:
lines.append(f"- **file:** `{e['file_path']}:{e['line_range']}`")
lines.append(f" **noticed:** {e['noticed']}")
lines.append(f" **why it matters:** {e['why']}")
if e["follow"]:
lines.append(f" **suggested follow-up:** {e['follow']}")
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def parse_existing(path: pathlib.Path) -> List[dict]:
"""Read an existing -noticed.md and extract entries for idempotent merge."""
if not path.exists():
return []
text = path.read_text(encoding="utf-8")
# Strip frontmatter
if text.startswith("---"):
end = text.find("\n---", 3)
if end != -1:
text = text[end + 4 :]
return parse_entries(text)
def reconcile(
reports: List[str],
out_path: pathlib.Path,
pipeline_id: str,
date: str,
ticket: str,
slug: str,
) -> int:
all_entries: List[dict] = []
for r in reports:
body = extract_section(r)
if body is None:
continue
all_entries.extend(parse_entries(body))
# Step 6: idempotent overwrite — merge with on-disk
all_entries.extend(parse_existing(out_path))
seen = {}
for e in all_entries:
k = dedupe_key(e)
if k not in seen:
seen[k] = e
entries = sorted(seen.values(), key=line_range_sort_key)
if not entries:
return 0
content = render_file(entries, pipeline_id, date, ticket, slug)
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(content, encoding="utf-8")
return len(entries)
def main() -> int:
p = argparse.ArgumentParser()
p.add_argument("--out", required=True, help="output -noticed.md path")
p.add_argument("--pipeline-id", required=True)
p.add_argument("--date", required=True)
p.add_argument("--ticket", required=True)
p.add_argument("--slug", required=True)
p.add_argument("reports", nargs="*", help="implementer report files; stdin if empty")
args = p.parse_args()
if args.reports:
reports = [pathlib.Path(r).read_text(encoding="utf-8") for r in args.reports]
else:
reports = [sys.stdin.read()]
n = reconcile(
reports,
pathlib.Path(args.out),
args.pipeline_id,
args.date,
args.ticket,
args.slug,
)
print(f"reconciled {n} entries -> {args.out}")
return 0
if __name__ == "__main__":
sys.exit(main())