diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 33c37d0..37a374a 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -105,6 +105,13 @@ "description": "Hierarchical microstructure knowledge base for trading venues: exchange mechanics, order books, auctions, Reg NMS, Chinese futures (CTP), feed handlers, execution models", "source": "./venue-expert", "skills": ["./"] + }, + { + "name": "agentic-tdd", + "description": "Phase-based resumable agentic TDD loop (PLAN → LOOP → ASSESS → RELEASE) with a deterministic gate engine, protected test evidence, fresh-context falsification, and optional external challenges", + "source": "./agentic-tdd", + "skills": ["./"], + "commands": ["./commands/"] } ] } diff --git a/.gitignore b/.gitignore index d803c05..6ad0bf1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,6 @@ .claude/ .tmp/ +.DS_Store +.obsidian/ +/research/ +.tdd/ diff --git a/CLAUDE.md b/CLAUDE.md index d3a888b..f9c1008 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,6 +70,11 @@ Use the **venue-expert** skill for exchange-specific context. Invoke it when: - Debugging trading system issues (feed, order, session problems) - Needing venue mechanics: order types, matching rules, session times, data formats, regulatory quirks +## Research Integrity + +- **Approval ≠ truth**: user approval sets objectives, budget, risk tolerance. It never upgrades the confidence of a factual claim — evidence does. +- **Anti-ritual**: any "always X" default (Data Sentinel first, Double ML, fixed thresholds) may be skipped with a stated reason. Method follows the decision context: literature questions have no dataset; descriptive work needs no causal estimator. + ## Testing - Tests will be specified when needed - Don't assume - ask if testing is required diff --git a/README.md b/README.md index dd5cf1c..f208174 100644 --- a/README.md +++ b/README.md @@ -283,6 +283,31 @@ arxiv_search "protein folding" --max-papers 5 /plugin install arxiv-search@deevs-agent-system ``` +### agentic-tdd + +Phase-based, resumable agentic TDD workflow: `PLAN → LOOP → ASSESS → RELEASE`. + +**Core**: A zero-dependency gate engine (`core/scripts/tdd-engine.mjs`) validates every state transition, artifact hash, budget, and protected test file; the orchestrator spawns fresh-context role subagents (architect, logic-hunter, developer, tester-qa, release-reviewer, external-challenger) whose reports are schema-shaped, never prose-driven. Workflow state lives in the target repo under gitignored `.tdd//` and is resumable from files alone. + +**Quick start**: +```bash +/tdd plan my-feature # intake, contract, oracle, risks, frozen plan +/tdd loop my-feature # protected tests locked, then implementation +/tdd assess my-feature # fresh-context falsification on the exact candidate commit +/tdd release my-feature # mandatory fresh-context release reviewer +/tdd status my-feature +``` + +**Use when**: correctness-critical features that justify contract-first TDD with protected evidence — especially stateful, concurrent, numerical, or latency-sensitive changes (`cpp-hft` profile). + +**Dependencies**: node >= 22, git. Canonical `core/` source lives in `deevs-pi-kit/skills/agentic-tdd/core/`; this copy is a byte-identical mirror. + +**Details**: [agentic-tdd/SKILL.md](agentic-tdd/SKILL.md) + +```bash +/plugin install agentic-tdd@deevs-agent-system +``` + ## Agent Color Scheme Universal color scheme across all agent plugins: diff --git a/agentic-tdd/SKILL.md b/agentic-tdd/SKILL.md new file mode 100644 index 0000000..24b98b1 --- /dev/null +++ b/agentic-tdd/SKILL.md @@ -0,0 +1,32 @@ +--- +name: agentic-tdd +description: Phase-based resumable agentic TDD loop (PLAN → LOOP → ASSESS → RELEASE) with protected test evidence, deterministic engine gates, fresh-context falsification, and optional external challenges. Use when the user asks for a tdd loop, agentic tdd, /tdd, protected tests, or to plan/implement/assess/release a feature under the TDD workflow. +--- + +# Agentic TDD (Claude Code shim) + +The runtime-agnostic workflow lives in `core/`. Read `core/ORCHESTRATOR.md` first and +follow it; this shim only binds it to the Claude Code runtime. + +## Runtime bindings + +- **Engine**: `node /core/scripts/tdd-engine.mjs --root ` + via Bash (requires node >= 22 on PATH). Treat only its typed JSON output as + authoritative. +- **Roles**: launch packets with the Agent tool (general-purpose, or a matching + specialist agent when available). Critique, tester-qa acceptance authoring, assess, + release-review, and challenge runs always start as fresh agents — never reuse the + implementing context to judge its own work. Apply `options.models.` via the + Agent tool's model parameter. Launch independent perspectives in one message so they + run in parallel. +- **Recurrence**: the main session drives the state machine — after each phase completes, + read `engine status` and dispatch the next phase until `CLOSED`, a `WAITING_FOR_USER_*` + state, or `BUDGET_EXHAUSTED`. Resume in a fresh session from `engine status` alone. + +## Boundaries + +- Never bypass the engine to edit `manifest.json` or protection state by hand. +- Verdicts, transitions, and blockers come from engine enums and structured role + reports, never from prose. +- This directory's `core/` is a byte-identical mirror; canonical source is + `deevs-pi-kit/skills/agentic-tdd/core/`. Edit there and copy. diff --git a/agentic-tdd/commands/tdd.md b/agentic-tdd/commands/tdd.md new file mode 100644 index 0000000..1f5e612 --- /dev/null +++ b/agentic-tdd/commands/tdd.md @@ -0,0 +1,19 @@ +--- +description: Drive the agentic TDD workflow (plan | loop | assess | release | challenge | status) for a feature +argument-hint: [--resume | --from-finding ID | --amend ID] +--- + +Drive the agentic-tdd skill for: $ARGUMENTS + +1. Load the `agentic-tdd` skill and read its `core/ORCHESTRATOR.md`. +2. Run `node /core/scripts/tdd-engine.mjs status --root `. + If the feature is not initialized and the subcommand is `plan`, follow the + initialization protocol (ask the USER the batched init questions first). +3. Validate that the requested subcommand matches an allowed transition from the current + engine state; if not, report the typed state and the legal next steps instead of + forcing it. +4. Execute the matching phase file (`core/phases/`), honoring `--resume`, + `--from-finding`, and `--amend` by re-entering the owning phase with the referenced + finding attached. +5. For `status`: print the engine status plus open questions, budgets, and the next + required command. Never mutate state from a status call. diff --git a/agentic-tdd/core/ORCHESTRATOR.md b/agentic-tdd/core/ORCHESTRATOR.md new file mode 100644 index 0000000..853290a --- /dev/null +++ b/agentic-tdd/core/ORCHESTRATOR.md @@ -0,0 +1,137 @@ +# Agentic TDD Orchestrator + +You are the coordinator of a phase-based, resumable, artifact-driven TDD workflow: +`PLAN → LOOP → ASSESS → RELEASE`, with optional external challenges after PLAN/LOOP/ASSESS. +You preserve process integrity and advance the state machine. You never author the +protected evidence that judges implementation work. + +All state lives in `.tdd//` in the target repository (gitignored). Resume from +files, never from conversation memory. The engine is the sole authority for state, +transitions, hashes, budgets, and protection: + +```text +ENGINE = /core/scripts/tdd-engine.mjs +node "$ENGINE" [args] --root +``` + +| Command | Purpose | +|---|---| +| `init [--depth auto\|light\|full] [--domains cpp-hft] [--protection auto\|flags\|chmod\|none] [--challenges plan,loop,assess] [--test-command CMD] [--allow-dirty]` | scaffold `.tdd//`, bind baseline commit | +| `status ` | typed state, allowed transitions, budgets, open questions, required checks | +| `transition [--result FILE]` | validated state change; every phase-exit edge accepts only its bound result phase+status, and `verify` runs inside | +| `set-depth light\|full [--evidence NOTE]` | PLAN records the routing decision (PLANNING only) | +| `freeze --files a,b,c` | hash-freeze phase artifacts (allowed only in the owning phase state) | +| `protect ` / `unprotect ` | lock/release protected files; unprotect only in CLOSED / RELEASE_BLOCKED | +| `verify ` | hashes, protected files, candidate tree; exit 2 on drift | +| `run-check [--cwd DIR] -- ` | engine-executed evidence: exit code, output hashes, bound to the candidate tree | +| `assess-worktree [--remove]` | detached clean worktree at the exact candidate snapshot | +| `challenge [--response\|--file\|--reason]` | external-challenge lifecycle; gates block on pending challenges | +| `question ...` | typed user questions; blocking ones freeze checkpoints | +| `review ingest --report FILE` | release reviewer report, bound to the candidate tree | +| `waiver authorize --file FILE` | user-authorized waiver with future expiry | +| `budget ` / `grant --authorized-by WHO [--n N]` | consume / user-granted headroom | +| `run [run-id]` | transactional `.partial` run directories | +| `unlock [--stale\|--force]` | recover a crashed writer's lock | +| `event [--data JSON]` | append to the audit log | + +Every decision you make consumes typed engine output (states, codes, statuses) — never +infer success from prose or file existence. Transitions self-verify: a tampered or +drifted evidence chain fails the transition, not just the report. Phase artifacts and +result files belong under `.tdd//` (gitignored) — files written elsewhere become +part of the candidate tree and will register as drift. + +## Candidate identity + +Reaching `CANDIDATE_READY` snapshots the exact worktree (tracked + untracked, +`.gitignore` respected) into an engine-owned ref — no user-visible commit is created. +All downstream evidence binds to that `candidate_tree`; any later change to the worktree +fails `verify` until the state machine routes back through LOOP. Assessment SHOULD run +in `assess-worktree` so leftover developer state cannot leak into evidence. + +## Initialization protocol + +Before `init`, investigate the repository, then ask the USER once, batched: + +1. **Optional steps**: which external challenges to enable (plan/loop/assess), and — + for full depth — mutation testing, fuzzing, performance protocol where relevant. + Challenges can also be invoked ad hoc at any boundary later. +2. **Domains**: `--domains cpp-hft` loads `references/verification-matrix-cpp.md` and + adds sanitizer check obligations. +3. **Models**: preferred model per optional/verification step, if the runtime supports + per-run model selection. Record in `options.models` (e.g. `{"challenge": "...", "assess": "..."}`) + and apply when spawning; note a deviation if the runtime cannot honor it. +4. **Protection mode** if the default (`auto`) is unsuitable — immutable flags need root + on Linux; `chmod` is the portable fallback; hashes are always verified regardless. +5. **Dirty worktree / test command** only if detection failed or the tree is dirty. + +Do NOT ask for light/full — depth starts `auto` and PLAN's intake determines it with +evidence (`set-depth`), asking the USER only when routing needs an authority decision. + +## Phase dispatch + +Read the phase file for the current state, execute it, then transition: + +| State | File | +|---|---| +| NEW, PLANNING, PLAN_READY, WAITING_FOR_USER_PLAN | `phases/10-plan.md` | +| PLAN_APPROVED, LOOP_RUNNING, BLOCKED_*, CANDIDATE_READY | `phases/20-loop.md` | +| ASSESSING, CHANGES_REQUIRED, ASSESSMENT_READY, WAITING_FOR_USER_ASSESS | `phases/30-assess.md` | +| ASSESSMENT_ACCEPTED, RELEASING, WAITING_FOR_USER_RELEASE, RELEASE_BLOCKED, CLOSED | `phases/40-release.md` | +| any enabled challenge gate | `phases/90-external-challenge.md` | + +`BUDGET_EXHAUSTED` is an explicit stop: report which counter died and ask the USER for a +grant or a route; never quietly continue. + +## Subagent packets + +Roles run as fresh-context subagents. A packet contains, in this order: + +1. the role card from `roles/` (verbatim); +2. the frozen artifacts the role may read (paths, not transcripts); +3. the concrete task and required output template from `templates/`; +4. scope bounds: exact files/dirs, what not to inspect, bounded output. + +Rules: +- Implementation (developer role) never sees or edits protected files; `protect` runs + before LOOP starts, on QA's acceptance tests **and** test-runner configs. +- ASSESS and RELEASE roles start fresh and initially read-only; they receive frozen + artifacts and diffs, never the implementation transcript or confidence claims. +- Independent critiques launch in parallel **before** implementation spend (cheap + falsification first, expensive compute second). +- A candidate that survives repair is re-verified on the exact candidate commit — the + engine rejects stale assessments. + +## Evidence rules + +Priority: reproducible failing check > authoritative spec > frozen contract + validated +oracle > production trace > exact code-path argument > model judgment > model consensus. +Critical/major findings require a minimal counterexample or a discriminating check +(`templates/finding.json`); otherwise they are non-blocking hypotheses. + +Deterministic verification runs through `run-check`, never as self-reported prose: the +engine records exit codes and output hashes bound to the candidate tree, and the +`ASSESSMENT_READY` gate requires passing bound records for every profile-required check +id. Assessors interpret this evidence; they cannot manufacture it. Tests proposed during +ASSESS are staged under `.tdd//assess//proposed-tests/` and applied in the +next LOOP round — the candidate tree stays immutable while it is being judged. + +## User authority + +Investigate repository evidence before asking. Batch questions with options, +consequences, and a recommendation (`templates/question.json`), registered via +`question open` — blocking classes freeze every checkpoint transition until +`question answer` or `question waive` records the USER's decision. Budget grants and +waivers likewise exist only as engine records with an `authorized-by`. Never ask the +USER to resolve ordinary technical questions answerable from the repository. + +## Memory + +At PLAN start, read `.tdd/memory.jsonl` (if present) and surface relevant observations. +At CLOSED, append 1–2 tagged observations: defect classes found, which control caught +them, which control was dead weight. One JSON object per line: +`{"ts": "...", "feature": "...", "tag": "...", "observation": "..."}`. + +## Git policy + +Follow `references/git-policy.md`. Never commit or push without explicit USER approval; +commit only functional code changes — never `.tdd/`, plans, scratch files, or `git add -A`. diff --git a/agentic-tdd/core/phases/10-plan.md b/agentic-tdd/core/phases/10-plan.md new file mode 100644 index 0000000..c5f0754 --- /dev/null +++ b/agentic-tdd/core/phases/10-plan.md @@ -0,0 +1,63 @@ +# Phase: PLAN + +Define what correct behavior means, how the change fits the repository, how it will be +falsified, and whether LIGHT or FULL depth is justified. + +## Enter + +`transition PLANNING`. Read `.tdd/memory.jsonl` and prior `decisions/` if present. + +## Steps + +1. **Engineering intake** — localize affected components and downstream consumers; + identify state ownership, temporal assumptions, concurrency, and hot-path impact; + find precedents, existing tests, oracles, and benchmark infrastructure. Determine + depth with evidence and record it: `engine set-depth light|full --evidence "..."` + (any high-risk dimension routes full: new domain semantics, statefulness, concurrency, + missing oracle, irreversibility, hot path, uncertain blast radius, weak production + detection). Ask the USER only when routing needs an authority decision. Write + `plan/vNNN/intake.json`. +2. **Role briefs** — spawn fresh-context critiques in parallel (architect, logic-hunter, + tester-qa packets from `roles/`), full mode or where risk warrants. Architect: design, + seams, dependencies, rollout. Logic-hunter: contract (P, Q, invariants, violation + policy, tolerances), oracle candidates with provenance. Tester-qa: partitions, + adversarial cases, testability objections. +3. **User-question gate** — after evidence gathering, batch blocking domain/policy + questions (`templates/question.json`) and register each via + `engine question open --file q.json`. Record answers with + `engine question answer ""`. If any `BLOCKING_*` remains + unanswered: `transition WAITING_FOR_USER_PLAN` and stop — the engine refuses + `PLAN_READY` while blocking questions are open. +4. **Synthesis and critique** — developer critiques feasibility and test seams; + tester-qa checks falsifiability; logic-hunter checks semantic coverage and oracle + domain; architect checks composition and downstream effects. +5. **Gate** — `engine run begin plan` gives a transactional `vNNN.partial` + directory; write `plan.md`, `architecture.md`, `contract.json` (stable clause IDs), + `oracle.json` (provenance, independence, domain, exclusions), `risks.json`, + `test-plan.md`, `performance-plan.json` (hot path only), `result.json`; then + `engine run publish plan vNNN`. + +## Exit requirements + +Affected components enumerated with evidence; contract explicit (P/Q/invariants/violation +policy/tolerances); oracle provenance recorded — for high-risk logic, two independent +evidence sources; risks mapped to planned controls; QA has a path to discriminating +tests; developer confirms seams are implementable; no blocking question open. + +Then: + +```text +engine freeze plan --files .tdd//plan/vNNN/contract.json,.tdd//plan/vNNN/oracle.json,... +engine transition PLAN_READY --result .tdd//plan/vNNN/result.json +``` + +If `options.challenges.plan`: run `phases/90-external-challenge.md`, then +`transition PLAN_APPROVED`. Otherwise `transition PLAN_APPROVED` directly (record the +challenge as `not_invoked`). + +## Revision + +Amendments consume `budget planning_revisions` and produce a new +`plan/vNNN`; downstream evidence bound to older versions is invalid. Statuses: +`PLAN_READY | WAITING_FOR_USER | NEEDS_REPOSITORY_EVIDENCE | NEEDS_HUMAN_DOMAIN_INPUT | +ESCALATE_FULL | BUDGET_EXHAUSTED`. diff --git a/agentic-tdd/core/phases/20-loop.md b/agentic-tdd/core/phases/20-loop.md new file mode 100644 index 0000000..afdcd31 --- /dev/null +++ b/agentic-tdd/core/phases/20-loop.md @@ -0,0 +1,54 @@ +# Phase: LOOP + +Produce a candidate implementation against frozen planning artifacts. Mechanical +iteration is cheap and unrestricted; semantic iteration returns to PLAN. + +## Enter + +Preconditions (engine-checked): state `PLAN_APPROVED`, `verify` clean. Then: + +1. Tester-qa (fresh context) writes the protected acceptance tests from the frozen + contract — designed from the plan, not from any patch. Verify each red test fails for + the intended reason. +2. `engine protect ` — locks them + before implementation exists. +3. `engine transition LOOP_RUNNING`. + +## Implementation + +Spawn the developer packet (`roles/developer.md` + frozen plan/contract/oracle paths + +`loop/run-NNN/handoff.md` template). The developer: + +- edits production code; compiles and runs focused tests freely; adds developer-local + tests; red–green–refactor per atomic behavioral gap; +- records exact commands in `loop/run-NNN/commands.log`; +- records discoveries in `loop/run-NNN/discoveries.json`, classified: + `MECHANICAL | IMPLEMENTATION_DEFECT` → fix in LOOP; + `TESTABILITY_PROBLEM` → record, may stay in LOOP; + `SPECIFICATION_AMBIGUITY | ORACLE_CONFLICT | ARCHITECTURE_DRIFT` → stop, route PLAN; + `ENVIRONMENT_FAILURE` → stop, route environment; +- never edits protected files, tolerances, oracle semantics, benchmark thresholds, or + scope; never special-cases known test data; never invents domain policy to unblock. + +## Exit + +Candidate handoff (`loop/run-NNN/` via `run begin`/`run publish`): `handoff.md` (behavior +implemented, files/interfaces changed, known limitations, recommended assessment focus), +`changes.patch`, `tests-added.md`, `discoveries.json`, `result.json`. + +```text +engine transition CANDIDATE_READY --result .tdd//loop/run-NNN/result.json +``` + +The transition self-verifies protected surfaces and then snapshots the exact worktree — +committed or not — into an engine-owned candidate ref. From this point every piece of +evidence binds to that tree; touching any non-`.tdd` file invalidates it until the state +machine routes back through LOOP. + +Blockers transition to `BLOCKED_SPEC | BLOCKED_ORACLE | BLOCKED_ENVIRONMENT`, which route +to `PLANNING` (amend, consumes `semantic_replans`) or back to `LOOP_RUNNING` when the +environment is fixed. Repair rounds after assessment findings consume +`implementation_repairs` and re-enter via `CHANGES_REQUIRED → LOOP_RUNNING`. + +If `options.challenges.loop`: run `phases/90-external-challenge.md` against the frozen +candidate before assessment; the candidate commit stays frozen. diff --git a/agentic-tdd/core/phases/30-assess.md b/agentic-tdd/core/phases/30-assess.md new file mode 100644 index 0000000..0f7c06b --- /dev/null +++ b/agentic-tdd/core/phases/30-assess.md @@ -0,0 +1,75 @@ +# Phase: ASSESS + +Attempt to falsify that the candidate satisfies the frozen plan and contract. +Fresh context, initially read-only. Assessment never silently repairs code. + +## Enter + +`engine transition ASSESSING` — self-verifies; any drift from the candidate +tree fails the transition. Then create the clean evaluation environment: + +```text +engine assess-worktree # detached checkout of the exact candidate snapshot +``` + +All builds and checks run in that worktree, not in the developer's tree. + +Spawn assessment packets fresh: logic-hunter (semantic), tester-qa (verification), plus +language/systems review where the profile requires. Packets receive: feature brief, +frozen plan/contract/oracle, baseline commit + candidate tree, diff, protected test +manifest, loop handoff and discoveries, challenge focus items. They do NOT receive the +implementation transcript, developer confidence, or failed-approach narratives. + +## Layers + +A. **Artifact integrity** — `engine verify` plus: did the candidate weaken/skip tests, + change tolerances, replace reference outputs, alter benchmark inputs, disable checks, + expand scope beyond the plan? Any hit is a finding, not a discussion. +B. **Deterministic verification** — run the applicable subset per profile through the + engine, never self-reported: + + ```text + engine run-check acceptance-tests --cwd .tdd//assess/worktree -- + engine run-check build --cwd ... -- + ``` + + The engine records exit codes and output hashes bound to the candidate tree; the + `ASSESSMENT_READY` gate requires a passing bound record for every profile-required + check id (`engine status` lists them). Property/metamorphic, differential, replay, + fuzz, sanitizer, static-analysis, and performance runs use the same mechanism with + their own check ids. +C. **Semantic** — P/Q/invariant conformance, violation and recovery policy, numerical + and rounding semantics, temporal ordering, sequence/duplicate/gap/reset/idempotence, + oracle applicability and common-mode risk, lookahead/data leakage, downstream + compatibility. +D. **Systems** (per profile) — lifetime/ownership, UB, overflow/narrowing, exceptions, + atomics and memory ordering, races, allocations and layout on hot paths, build flags. + +## Findings + +Every blocking finding follows `templates/finding.json`: falsifiable claim, violated +clause or exact code path, evidence or minimal counterexample, discriminating check, +route. Routing: implementation defect / test hole → tester-qa stages the new failing +test under `assess/run-NNN/proposed-tests/` (the candidate tree stays immutable while +judged), then `CHANGES_REQUIRED → LOOP_RUNNING`, where the test is applied, protected, +and a new candidate is produced; contract/architecture defect → `PLANNING` (direct +route, `assess/PLAN_AMENDMENT_REQUIRED` result); oracle disagreement → `PLANNING` or +user authority; missing/noisy evidence → rerun ASSESS; waiver-class decisions → +`WAITING_FOR_USER_ASSESS`. + +The assessor never asks the USER to judge ordinary technical correctness. + +## Exit + +`assess/run-NNN/`: `assessment.md`, `findings.json`, `verdict.json`, machine results, +`result.json` with status `PASS | PASS_WITH_NONBLOCKING_FINDINGS | CHANGES_REQUIRED | +PLAN_AMENDMENT_REQUIRED | HUMAN_DECISION_REQUIRED | ASSESSMENT_INCONCLUSIVE | +BUDGET_EXHAUSTED`. + +```text +engine transition ASSESSMENT_READY --result .tdd//assess/run-NNN/result.json +``` + +If `options.challenges.assess`: run `phases/90-external-challenge.md` (it challenges the +assessment's coverage and verdict, not a free-form re-review), then +`transition ASSESSMENT_ACCEPTED`. Otherwise transition directly. diff --git a/agentic-tdd/core/phases/40-release.md b/agentic-tdd/core/phases/40-release.md new file mode 100644 index 0000000..dac4541 --- /dev/null +++ b/agentic-tdd/core/phases/40-release.md @@ -0,0 +1,58 @@ +# Phase: RELEASE + +Validate the complete evidence chain and operational readiness, then make the final +deterministic transition. The release reviewer is mandatory and internal. + +## Enter + +`engine transition RELEASING`, `engine verify ` clean. + +## Mandatory release reviewer + +Spawn `roles/release-reviewer.md` fresh-context, read-only. Packet: frozen artifacts, +summarized evidence index, manifest, challenge dispositions, waivers — never the +development transcript. The reviewer checks: + +- **Integrity** — assessed commit equals candidate commit; plan/contract/oracle/protected + versions match; nothing protected changed after assessment; every challenge finding has + a disposition; accepted critical/major findings verified closed. +- **Completeness** — required checks actually ran against the candidate commit; replay + and performance obligations met where required; no unresolved blocking question; + residual risks explicit. +- **Scope and readiness** — final diff matches approved scope; interface and migration + effects documented; rollout, monitoring, rollback/kill-switch paths exist where + required; waivers have owner, rationale, expiration, follow-up. + +Output: a report naming the exact `candidate_tree` it reviewed, with decision +`RELEASE_APPROVED | RETURN_TO_PLAN | RETURN_TO_LOOP | RETURN_TO_ASSESS | +USER_SIGNOFF_REQUIRED | RELEASE_BLOCKED | INSUFFICIENT_EVIDENCE` +(`templates/reviewer-report.json`). `INSUFFICIENT_EVIDENCE` is a legitimate verdict — +it routes to ASSESS rather than forcing a guess. Ingest it: + +```text +engine review ingest --report .tdd//release/report.json +``` + +The engine rejects a report bound to any other candidate tree, and `CLOSED` is +unreachable without an ingested `RELEASE_APPROVED`, dispositioned challenges, zero open +blocking questions, and unexpired waivers. The reviewer never edits artifacts and never +authors waivers; waivers exist only via `engine waiver authorize` with USER authority. + +## User sign-off + +Ask the USER (via `WAITING_FOR_USER_RELEASE`) only for: accepting a known +performance/risk deviation, unresolved compatibility impact, residual semantic +uncertainty, or a temporary waiver (record per `templates/release-decision.json`). + +## Exit + +- `RELEASE_APPROVED` → write `release/release-decision.json`, `rollout-plan.md`, + `rollback-plan.md`, `monitoring-plan.md`, `retro.md`; then + `engine transition CLOSED --result release/result.json`; + append observations to `.tdd/memory.jsonl`; `engine unprotect `. +- `RETURN_TO_*` → transition to the owning phase with the reviewer finding attached. +- `RELEASE_BLOCKED` → transition, report blockers to the USER, stop. + `engine unprotect` is also legal here if the USER abandons the feature. + +Retro records: phase/repair/challenge counts, escaped vs caught defect classes, controls +that paid off, controls that were dead weight, user decisions and waivers. diff --git a/agentic-tdd/core/phases/90-external-challenge.md b/agentic-tdd/core/phases/90-external-challenge.md new file mode 100644 index 0000000..66a5a04 --- /dev/null +++ b/agentic-tdd/core/phases/90-external-challenge.md @@ -0,0 +1,50 @@ +# Phase: External challenge (optional) + +Independent, read-only criticism of a frozen phase result by a fresh context — a +different model/CLI where available, else a fresh same-model context. Enabled per phase +at init (`options.challenges`) or invoked ad hoc at any boundary; the engine blocks the +boundary transition until an invoked challenge is dispositioned or skipped with a reason. + +## Lifecycle: PREPARE → REVIEW → INGEST → DISPOSITION + +1. **PREPARE** — `engine challenge prepare ` (allowed at the phase's + boundary state; consumes the `external_challenges` budget). Export a bounded, + self-contained packet into the returned directory as `request.md`: the phase's frozen + artifacts, the specific challenge questions below, and the required response shape + (`templates/challenge-response.json`). No author identity, no internal transcript, no + persuasive rationale. +2. **REVIEW** — run `roles/external-challenger.md` with the packet, using + `options.models.challenge` if set. Manual cross-CLI mode: hand `request.md` to the + other CLI and save its `response.json`. Provenance (provider, model id, prompt/packet + hashes) belongs in the response. +3. **INGEST** — `engine challenge ingest --response response.json`: + the engine validates verdict and finding shape (id, severity, claim) and records the + response hash. +4. **DISPOSITION** — `engine challenge dispose --file disposition.json` + with exactly one disposition per finding: + `ACCEPTED` (route to PLAN/LOOP/ASSESS) | `REJECTED_WITH_EVIDENCE` | + `MORE_EVIDENCE_REQUIRED` | `USER_DECISION_REQUIRED` | `PARKED_NONBLOCKING` | + `DUPLICATE`. The engine refuses to park a critical finding and refuses to reject one + without evidence references — critical findings cannot be dismissed by prose. + +Skipping an enabled challenge is explicit: `engine challenge skip --reason "..."`. + +## Phase-specific questions + +- **plan**: LIGHT/FULL routing sound? contract ambiguous? oracle independent and + domain-complete? missing failure modes, controls, or user questions? +- **loop**: drift from approved plan? scope expansion? concrete semantic/state/numerical + defect? weakened tests, tolerances, benchmarks? suspicious special-casing? which + scenarios should assessment prioritize? +- **assess**: every critical clause and risk control covered? claimed checks actually + executed against the candidate commit? findings reproducible and correctly classified? + high-impact hypothesis dismissed without evidence? verdict follows from evidence? + +## Rules + +- Critical/major findings require an executable check, trace, exact code-path argument, + authoritative source, or numerical counterexample — otherwise non-blocking hypothesis. +- One challenge, one disposition, at most one targeted clarification. No critic-of-critic + loops. The challenger never edits canonical artifacts; an unsupported hypothesis never + causes code churn. +- Model consensus is not evidence; no vote count overrides a reproducible check. diff --git a/agentic-tdd/core/profiles/cpp-hft.json b/agentic-tdd/core/profiles/cpp-hft.json new file mode 100644 index 0000000..4a9441b --- /dev/null +++ b/agentic-tdd/core/profiles/cpp-hft.json @@ -0,0 +1,13 @@ +{ + "domain": "cpp-hft", + "references": ["references/verification-matrix-cpp.md"], + "assessment": { + "systems_review": "required", + "required_check_ids": ["sanitizers"], + "performance_protocol": "required_for_hot_path", + "sanitizers": "asan_ubsan_required_tsan_when_concurrent" + }, + "release": { + "shadow_mode": "required_for_book_order_position_or_risk_state" + } +} diff --git a/agentic-tdd/core/profiles/full.json b/agentic-tdd/core/profiles/full.json new file mode 100644 index 0000000..11bb27a --- /dev/null +++ b/agentic-tdd/core/profiles/full.json @@ -0,0 +1,35 @@ +{ + "mode": "full", + "planning": { + "independent_role_briefs": "risk_based", + "maximum_revisions": 3, + "new_oracle_research": true, + "oracle_triangulation": "high_risk_logic" + }, + "loop": { + "mechanical_iterations": "unrestricted_within_budget", + "semantic_replans": 2, + "maximum_repair_rounds": 3, + "recursive_decomposition": "allowed_with_signed_subcontracts" + }, + "assessment": { + "protected_acceptance_tests": "required", + "required_check_ids": ["build", "acceptance-tests"], + "properties_and_metamorphic": "applicable", + "differential_testing": "applicable", + "deterministic_replay": "required_when_stateful", + "mutation_testing": "high_risk_core_only", + "fuzzing": "protocol_or_parser_changes", + "sanitizers": "applicable_subset", + "performance_protocol": "required_for_hot_path" + }, + "external_challenge": { + "maximum_per_phase_version": 1, + "invocation": "optional_tripwire_based" + }, + "release": { + "reviewer": "mandatory", + "shadow_mode": "required_for_critical_state_changes", + "rollout_and_rollback": "risk_based" + } +} diff --git a/agentic-tdd/core/profiles/light.json b/agentic-tdd/core/profiles/light.json new file mode 100644 index 0000000..4b1d334 --- /dev/null +++ b/agentic-tdd/core/profiles/light.json @@ -0,0 +1,36 @@ +{ + "mode": "light", + "routing": { + "requires_validated_oracle_covering_delta": true, + "semantic_novelty_allowed": false, + "stateful_or_concurrent_change_allowed": false, + "semantic_ambiguity_escalates_to": "full" + }, + "planning": { + "independent_role_briefs": "optional", + "maximum_revisions": 1, + "new_oracle_research": false + }, + "loop": { + "mechanical_iterations": "unrestricted_within_budget", + "semantic_replans": 1, + "maximum_repair_rounds": 1, + "recursive_semantic_decomposition": false + }, + "assessment": { + "protected_acceptance_tests": "required", + "required_check_ids": ["acceptance-tests"], + "mutation_testing": false, + "broad_fuzzing": false, + "replay": "when_stateful_or_protocol_related", + "sanitizers": "applicable_subset" + }, + "external_challenge": { + "maximum_total": 1, + "invocation": "optional_tripwire_based" + }, + "release": { + "reviewer": "mandatory", + "shadow_mode": "normally_not_required" + } +} diff --git a/agentic-tdd/core/references/git-policy.md b/agentic-tdd/core/references/git-policy.md new file mode 100644 index 0000000..07aceea --- /dev/null +++ b/agentic-tdd/core/references/git-policy.md @@ -0,0 +1,25 @@ +# Git policy + +The workflow only observes and records git state; it never rewrites history or publishes. + +## Ground rules + +- `.tdd/` is gitignored in the target repository. Add the ignore entry at init if missing. +- Commit only functional code changes. Never commit `.tdd/`, plans, scratch files, + editor droppings, or generated reports. Never `git add -A` / `git add .` — stage an + explicit allowlist of files the phase actually changed. +- Never push, amend, rebase, merge, reset, tag, or force-update anything. Committing at + all requires explicit USER approval per repository session. +- Clean tracked worktree required at init and at every phase gate (`engine verify` + reports `modified`). Untracked garbage is reported, not blocking — but ask the USER + once about untracked files that look like they belong in `.gitignore`. + +## Commit binding + +- `init` records `baseline_commit`; reaching `CANDIDATE_READY` records the exact + `candidate_commit`; entering `ASSESSING` fails on any other HEAD. +- All evidence (test runs, replay, benchmarks) is only valid for the commit recorded + with it. New commits invalidate downstream evidence — the engine enforces this; + re-run, do not argue. +- New assessment tests route back through LOOP and produce a new candidate commit before + reassessment. diff --git a/agentic-tdd/core/references/mission-adapter.md b/agentic-tdd/core/references/mission-adapter.md new file mode 100644 index 0000000..4655433 --- /dev/null +++ b/agentic-tdd/core/references/mission-adapter.md @@ -0,0 +1,39 @@ +# Mission adapter (Pi runtime only) + +A TDD Mission is a normal Mission whose objective names the feature and whose operating +procedure is this skill's state machine. Missions stay usable for ad-hoc (non-TDD) work; +nothing here changes Mission mechanics. Mission owns recurrence, controller identity, +takeover, limits, child settlement, and final review. The TDD engine owns the nested +state machine, artifacts, gates, and budgets. + +## Wiring + +- Create via `mission_create` only when the USER asks for a continuing TDD objective; + run `engine init` immediately after and record feature id + `.tdd//` path in + the first `mission_progress` entry. +- Record a `mission_progress` milestone at every checkpoint transition: `PLAN_READY`, + `PLAN_APPROVED`, `CANDIDATE_READY`, `ASSESSMENT_READY`, `ASSESSMENT_ACCEPTED`, + reviewer decision, `CLOSED`. +- `WAITING_FOR_USER_*` and `BUDGET_EXHAUSTED` are genuine typed blockers: record them + via `mission_progress` with the engine's state string and the open question ids, then + pause substantive work. +- Complete the Mission only when the engine state is `CLOSED` (or the USER explicitly + abandons at `RELEASE_BLOCKED`). Mission completion gates (requirement audit, + independent review, child settlement, Chain checkpoints) still apply on top — + engine `CLOSED` does not waive them. + +## Resume and takeover + +- On resume or takeover, authority comes from `engine status` and `engine verify` — + never from `mission.md`, `log.md`, or transcript memory. If `verify` reports drift, + that is the first blocker to record and resolve. +- Before takeover, settle or explicitly cancel known Jobs/Subagents in the old session; + after takeover, review state is invalid — fresh-context assessment/review runs are + required for any phase whose evidence predates the takeover generation. + +## Subagent mapping + +Launch role packets with `subagent` (fresh context for critique/assess/release/challenge; +one `subagent_wait` per group; parallel-first for independent perspectives). Apply +`options.models.` when the runtime supports per-run model selection; otherwise +record the deviation in the phase result provenance. diff --git a/agentic-tdd/core/references/permissions.md b/agentic-tdd/core/references/permissions.md new file mode 100644 index 0000000..cea9d8a --- /dev/null +++ b/agentic-tdd/core/references/permissions.md @@ -0,0 +1,35 @@ +# Permissions and protected surfaces + +Separation is useful only when it creates different information exposure, different +writable surfaces, different evidence obligations, independent falsification, or +deterministic gates outside the authoring agent's control. + +| Surface | Coordinator | Architect | Logic-hunter | Developer | Tester-qa | Release reviewer | External challenger | +|---|---|---|---|---|---|---|---| +| Production code | protect | read | read | **write in LOOP** | read | read | read | +| Architecture | version/freeze | **propose in PLAN** | comment | critique | critique | read | challenge | +| Contract | version/freeze | comment | **propose in PLAN** | critique | critique | read | challenge | +| Protected acceptance tests | protect via engine | read | propose semantic cases | read/run only | **write outside DEV control** | read | read/challenge | +| Developer-local tests | record | read | read | **write** | read | read | read | +| Oracle/reference | freeze | review | **propose/supervise** | review/run | test/challenge | read | challenge | +| Tolerances | freeze | comment | propose | no write | challenge | verify | challenge | +| Performance harness | protect | propose constraints | comment | run, never weaken | propose/validate | verify | inspect | +| Findings | version/dispose | submit | submit/classify | respond | submit/classify | review dispositions | submit | +| Waivers | record | no | no | no | no | verify | no | + +Authors may propose revisions to their own artifacts; a revision becomes authoritative +only after independent review and a coordinator version bump. The implementation agent +never controls the evidence that judges it. + +## Threat model + +Protected hashes, immutable flags/chmod, engine-validated transitions, and candidate +tree binding defend against **cooperative-agent drift and accidents** — an agent +forgetting the rules, silently weakening a test, or assessing the wrong code. They are +NOT isolation from a malicious or compromised same-user process, which could restore +permissions, rewrite `manifest.json`, or re-freeze hashes. The practical mitigations the +engine does provide: evidence executes through `run-check` (assessors interpret, they +cannot fabricate records), assessment runs in a clean detached worktree of the exact +candidate snapshot, and every mutation lands in the append-only event log for after-the- +fact audit. Stronger guarantees require a second user or containerized evaluator — +out of scope for this kit. diff --git a/agentic-tdd/core/references/verification-matrix-cpp.md b/agentic-tdd/core/references/verification-matrix-cpp.md new file mode 100644 index 0000000..f4cd26a --- /dev/null +++ b/agentic-tdd/core/references/verification-matrix-cpp.md @@ -0,0 +1,38 @@ +# C++/HFT verification matrix (loaded by the cpp-hft profile) + +Minimum controls by change class. "Applicable subset" is decided in PLAN and recorded in +`test-plan.md`; ASSESS verifies the recorded subset actually ran. + +| Change class | Minimum controls | +|---|---| +| Pure local deterministic function | unit tests, boundary tests, warnings/static checks | +| Numerical formula | analytic anchors, property/metamorphic tests, tolerance policy, differential oracle where possible | +| Parser/protocol change | malformed-input tests, fuzzing, replay, compatibility checks, sanitizer coverage | +| Stateful market-data logic | deterministic replay, sequence/duplicate/gap/reset cases, state invariants, snapshot reconciliation | +| Order/position/risk state | idempotence, partial fill/reject/cancel races, reconciliation invariants, fail-closed paths, shadow validation | +| Concurrent/lock-free code | TSan where applicable, controlled interleavings, memory-order review, false-sharing/layout review | +| Hot-path optimization | protected semantic oracle, allocation checks, repeated relative benchmark, assembly inspection where needed | +| Time-sensitive logic | monotonicity, clock-domain assumptions, reversal/staleness cases, deterministic timestamps in tests | +| Rollout-sensitive change | historical replay, shadow mode, monitoring, rollback/kill switch | + +Domain cases where relevant: duplicate/missing/out-of-order messages; sequence reset and +session transition; snapshot/incremental reconciliation; halts and instrument-definition +changes; tick/lot-size transitions; partial fills, rejects, cancel races, duplicated +executions; position/PnL/risk-limit reconciliation; burst load, backpressure, stale +signals; lookahead and data leakage; allocation, cache behavior, tail latency. + +## Performance protocol + +"p99 within budget" is insufficient without a reproducible protocol. Record in +`performance-plan.json`: machine profile, affinity, NUMA, governor/turbo, compiler and +flags, build type, warmup/measured iterations, repetitions, input dataset, pinned +baseline commit, metric deltas (p50/p99/p999 relative, throughput min, allocations per +event max), and the decision rule. Compare repeated measurements against the pinned +baseline; a single observation decides nothing. + +## Systems assessment additions (layer D) + +Lifetime/ownership, UB, overflow/narrowing/alignment/aliasing, exception and failure +paths, atomics and memory ordering, races and false sharing, hot-path allocations and +layout, compiler flags and floating-point environment (`fast-math` implications), +ABI/build-system impact. diff --git a/agentic-tdd/core/roles/architect.md b/agentic-tdd/core/roles/architect.md new file mode 100644 index 0000000..5072abf --- /dev/null +++ b/agentic-tdd/core/roles/architect.md @@ -0,0 +1,30 @@ +# Architect (ARCH) + +## Mission +Own system structure, repository impact, state ownership, interfaces, lifecycle, and operational integration. + +## Primary phase +PLAN. + +## Must +- localize affected code and downstream consumers; +- identify data flow, state ownership, concurrency, and hot-path impact; +- define interfaces, seams, migration, observability, rollout, and rollback; +- challenge hidden coupling and low-blast-radius claims; +- ensure the design is testable and supports independent verification; +- document non-goals and compatibility boundaries; +- deliver a brief with this evidence shape: affected components, downstream consumers, + state owners, call-site evidence, concurrency boundaries, compatibility boundaries, + rollback seam, explicit unknowns; +- stay at interface and lifecycle level — premature low-level implementation detail + cascades errors into downstream phases. + +## Must not +- decide market/business semantics without authority; +- write production implementation during PLAN; +- approve its own plan without critique; +- use file/module count as the main risk proxy; +- conceal uncertainty with polished prose. + +## Escalate when +Ownership, dependency surface, concurrency, lifecycle, or rollout safety remains uncertain. diff --git a/agentic-tdd/core/roles/developer.md b/agentic-tdd/core/roles/developer.md new file mode 100644 index 0000000..ad2e3df --- /dev/null +++ b/agentic-tdd/core/roles/developer.md @@ -0,0 +1,28 @@ +# Developer (DEV) + +## Mission +Produce the implementation witness satisfying frozen planning artifacts. + +## Primary phase +LOOP. + +## Must +- critique feasibility and test seams during PLAN; +- implement against exact frozen plan/contract/oracle versions; +- use frequent edit → build → focused-test feedback; +- make minimal behavioral changes, then refactor under green evidence; +- preserve the language/runtime constraints the contract names (e.g. ABI, lifetime, + ownership, exceptions, concurrency, allocation, performance); +- record material discoveries and stop on semantic ambiguity; +- rerun parent/integration evidence after decomposed work closes. + +## Must not +- edit protected acceptance tests, runner configs, tolerances, oracle semantics, or benchmark thresholds; +- special-case known test data merely to pass; +- move work outside measured regions; +- disable checks, swallow failures, or silently broaden scope; +- invent business/domain policy during implementation; +- avoid build/test feedback in the name of "one-shot" coding. + +## Escalate when +Minimal implementation violates an invariant, the seam is untestable, scope grows, or repeated failures indicate a plan-level problem. diff --git a/agentic-tdd/core/roles/external-challenger.md b/agentic-tdd/core/roles/external-challenger.md new file mode 100644 index 0000000..3acd801 --- /dev/null +++ b/agentic-tdd/core/roles/external-challenger.md @@ -0,0 +1,30 @@ +# External Challenger + +## Mission +Introduce an independent failure profile and produce evidence-bearing criticism against a frozen phase result. + +## Phases +Optional after PLAN, LOOP, or ASSESS. + +## Must +- use a bounded self-contained packet and fresh context; +- record provenance in the response: provider, model identifier, CLI/harness, and + packet/prompt hashes; +- remain read-only; +- make precise falsifiable claims; +- cite exact artifacts, clauses, code paths, traces, or sources; +- propose discriminating checks for critical and major findings; +- distinguish blockers from hypotheses; +- focus on the objective of the challenged phase; +- accept that the primary coordinator disposes findings. + +## Must not +- edit canonical artifacts; +- use confidence, eloquence, or model identity as evidence; +- create an endless critic-of-critic loop; +- block with generic unsupported concerns; +- inherit the full authoring transcript by default; +- act as final release authority. + +## Escalate when +The packet lacks key evidence or a user/domain decision is required. diff --git a/agentic-tdd/core/roles/logic-hunter.md b/agentic-tdd/core/roles/logic-hunter.md new file mode 100644 index 0000000..4e2d0a4 --- /dev/null +++ b/agentic-tdd/core/roles/logic-hunter.md @@ -0,0 +1,30 @@ +# Logic-Hunter / Semantic Assessor (LH) + +## Mission +Own correctness of meaning: contract, invariants, numerical semantics, market conventions, and oracle validity. + +## Primary phases +PLAN and ASSESS. + +## Must +- define P, Q, invariants, violation policy, tolerance, and semantic scope; +- identify authoritative domain sources; +- document oracle provenance, independence, domain, and exclusions; +- create or supervise properties, metamorphic relations, anchors, and reference models; +- search for counterexamples, limiting cases, temporal errors, and common-mode oracle failures; +- route ambiguity to evidence gathering or user/domain authority. + +## Must not +- write production implementation; +- rely solely on oracles it authored: for critical domain logic at least one oracle + input must be independent of this role — an authoritative external document, an + independently implemented reference, an independently selected historical trace, an + analytic anchor, or an external domain decision; +- declare a reference “obviously correct” without validation; +- resolve ambiguity by plausibility or model vote; +- widen tolerance to fit a candidate; +- approve its own contract/oracle without independent critique; +- overclaim differential testing outside the oracle domain. + +## Escalate when +Oracles disagree, sources conflict, contract silence affects behavior, or a legitimate semantic choice needs user authority. diff --git a/agentic-tdd/core/roles/release-reviewer.md b/agentic-tdd/core/roles/release-reviewer.md new file mode 100644 index 0000000..98730e0 --- /dev/null +++ b/agentic-tdd/core/roles/release-reviewer.md @@ -0,0 +1,28 @@ +# Internal Release Reviewer + +## Mission +Validate the final evidence chain, artifact integrity, scope, and operational readiness. + +## Primary phase +RELEASE only. + +## Must +- start in fresh context and remain read-only; +- verify candidate commit and all frozen artifact versions; +- confirm required evidence exists for the current candidate; +- inspect external challenge dispositions and waivers; +- verify scope, rollout, monitoring, rollback, and residual-risk handling; +- route problems to PLAN, LOOP, or ASSESS; +- produce a structured release recommendation naming the exact candidate tree reviewed; +- return INSUFFICIENT_EVIDENCE rather than guessing when the evidence chain cannot + support a verdict — a reviewer that always approves is ceremony, not a gate. + +## Must not +- edit code, tests, contract, oracle, or benchmarks; +- create or authorize waivers; +- approve because prior agents agree; +- overlook stale evidence or commit mismatch; +- repeat a full unrestricted assessment without a concrete trigger. + +## Escalate when +Evidence is incomplete, artifacts changed after assessment, residual risk needs authority, or operational safeguards are inadequate. diff --git a/agentic-tdd/core/roles/tester-qa.md b/agentic-tdd/core/roles/tester-qa.md new file mode 100644 index 0000000..b9da8e3 --- /dev/null +++ b/agentic-tdd/core/roles/tester-qa.md @@ -0,0 +1,31 @@ +# Tester / QA + +## Mission +Own independent falsification and protected verification evidence. + +## Primary phases +PLAN and ASSESS. + +## Must +- challenge ambiguity, partitions, malformed inputs, boundaries, and recovery paths; +- design acceptance tests independently of the final patch — patch-blind design is + mandatory at full depth and for high-risk changes, best-effort elsewhere; +- verify red tests fail for the intended reason; +- own protected acceptance, regression, adversarial, replay, and integrity tests; +- run applicable sanitizers, fuzzing, static analysis, replay, and performance protocol; +- inspect weak assertions, fixture leakage, and implementation-shaped expectations; +- produce reproducible findings with discriminating checks; +- separate pre-patch acceptance design from post-patch adversarial exploration. + +## Must not +- let DEV weaken protected evidence without independent review; +- create tests designed to pass current behavior; +- over-mock, validate only fixtures, mirror implementation structure in assertions, or + test only the happy-path representation DEV selected; +- silently patch production code during read-only ASSESS; +- finalize domain semantics without LH/user authority; +- block with unsupported speculation; +- equate coverage with assertion strength. + +## Escalate when +A finding may be specification-level, tests cannot distinguish interpretations, the oracle is common-mode, or evidence is inconclusive. diff --git a/agentic-tdd/core/scripts/tdd-engine.d.mts b/agentic-tdd/core/scripts/tdd-engine.d.mts new file mode 100644 index 0000000..908a760 --- /dev/null +++ b/agentic-tdd/core/scripts/tdd-engine.d.mts @@ -0,0 +1,103 @@ +export interface InitOptions { + depth?: string; + domains?: string[]; + title?: string; + protection?: string; + challenges?: string[]; + testCommand?: string; + allowDirty?: boolean; + models?: Record; +} + +export interface Manifest { + schema_version: number; + feature: { id: string; title: string; depth: string; domains: string[] }; + workflow: { state: string }; + repository: { + baseline_commit: string; + candidate_tree: string | null; + candidate_commit: string | null; + candidate_ref: string | null; + branch: string; + test_command: string | null; + untracked_at_init: string[]; + }; + current: { plan_version: string | null; loop_run: string | null; assessment_run: string | null }; + options: { + protection_mode: string; + challenges: { plan: boolean; loop: boolean; assess: boolean }; + models: Record; + }; + integrity: Record; + budgets: { used: Record; extra: Record }; + questions: { open: Record }; + challenges: Record>; + release: { reviewer_status: string; decision: string | null; candidate_tree: string | null; report_hash: string | null }; +} + +export interface VerifyReport { + ok: boolean; + state: string; + problems: Array<{ code: string; path?: string; phase?: string; expected?: string; actual?: string }>; + untracked: string[]; + modified: string[]; +} + +export interface Status { + feature: Manifest["feature"]; + state: string; + allowed_next: string[]; + required_next: string; + repository: Manifest["repository"]; + current: Manifest["current"]; + budgets: Record; + required_check_ids: string[]; + unresolved_blocking: number; + open_questions: Record; + options: Manifest["options"]; + challenges: Manifest["challenges"]; + release: Manifest["release"]; +} + +export interface CheckRecord { + check_id: string; + seq: number; + argv: string[]; + cwd: string; + candidate_tree: string; + bound_to_candidate: boolean; + environment: { platform: string; node: string }; + started_at: string; + duration_ms: number; + exit_code: number; + stdout_hash: string; + stderr_hash: string; + truncated: boolean; +} + +export declare const TRANSITIONS: Record; +export declare function init(root: string, feature: string, options?: InitOptions): Manifest; +export declare function setDepth(root: string, feature: string, depth: string, options?: { evidence?: string }): { depth: string; previous: string }; +export declare function transition(root: string, feature: string, to: string, options?: { resultFile?: string }): { from: string; to: string; state: string; candidate_tree: string | null }; +export declare function freeze(root: string, feature: string, phase: string, files: string[]): { commit: string; files: Record }; +export declare function protect(root: string, feature: string, files: string[]): Array<{ path: string; hash: string; method: string }>; +export declare function unprotect(root: string, feature: string): { released: number }; +export declare function verify(root: string, feature: string): VerifyReport; +export declare function consumeBudget(root: string, feature: string, counter: string): { counter: string; remaining: number }; +export declare function grantBudget(root: string, feature: string, counter: string, n: number, authorizedBy: string): { counter: string; remaining: number; authorized_by: string }; +export declare function questionOpen(root: string, feature: string, questionFile: string): { id: string; class: string; unresolved_blocking: number }; +export declare function questionAnswer(root: string, feature: string, id: string, answer: string): { id: string; unresolved_blocking: number }; +export declare function questionWaive(root: string, feature: string, id: string, authorizedBy: string): { id: string; unresolved_blocking: number }; +export declare function challengePrepare(root: string, feature: string, phase: string): { phase: string; state: string; packet_dir: string }; +export declare function challengeIngest(root: string, feature: string, phase: string, responseFile: string): { phase: string; state: string; findings: number }; +export declare function challengeDispose(root: string, feature: string, phase: string, dispositionFile: string): { phase: string; state: string }; +export declare function challengeSkip(root: string, feature: string, phase: string, reason: string): { phase: string; state: string }; +export declare function reviewIngest(root: string, feature: string, reportFile: string): { decision: string }; +export declare function waiverAuthorize(root: string, feature: string, waiverFile: string): { id: string; expires: string }; +export declare function runCheck(root: string, feature: string, checkId: string, argv: string[], options?: { cwd?: string; maxBytes?: number }): CheckRecord; +export declare function assessWorktree(root: string, feature: string, options?: { remove?: boolean }): Record; +export declare function runBegin(root: string, feature: string, phase: string): { phase: string; run_id: string; dir: string }; +export declare function runPublish(root: string, feature: string, phase: string, runId: string): { phase: string; run_id: string; dir: string }; +export declare function unlock(root: string, feature: string, options?: { stale?: boolean; force?: boolean }): { released: boolean; reason: string }; +export declare function status(root: string, feature: string): Status; +export declare function appendEvent(root: string, feature: string, type: string, data?: Record): Record; diff --git a/agentic-tdd/core/scripts/tdd-engine.mjs b/agentic-tdd/core/scripts/tdd-engine.mjs new file mode 100644 index 0000000..b7ddba6 --- /dev/null +++ b/agentic-tdd/core/scripts/tdd-engine.mjs @@ -0,0 +1,1065 @@ +#!/usr/bin/env node +// Deterministic gate engine for the agentic-tdd skill. Zero dependencies. +// All behavioral decisions consume typed fields; prose is display-only. +// Threat model: gates and hashes defend against cooperative-agent drift and +// accidents, not against a malicious same-user process (see references/permissions.md). + +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +export const TRANSITIONS = { + NEW: ["PLANNING"], + PLANNING: ["WAITING_FOR_USER_PLAN", "PLAN_READY", "BUDGET_EXHAUSTED"], + WAITING_FOR_USER_PLAN: ["PLANNING"], + PLAN_READY: ["PLAN_APPROVED", "PLANNING"], + PLAN_APPROVED: ["LOOP_RUNNING", "PLANNING"], + LOOP_RUNNING: ["BLOCKED_SPEC", "BLOCKED_ORACLE", "BLOCKED_ENVIRONMENT", "CANDIDATE_READY", "BUDGET_EXHAUSTED"], + BLOCKED_SPEC: ["PLANNING"], + BLOCKED_ORACLE: ["PLANNING"], + BLOCKED_ENVIRONMENT: ["LOOP_RUNNING"], + CANDIDATE_READY: ["ASSESSING", "LOOP_RUNNING"], + ASSESSING: ["WAITING_FOR_USER_ASSESS", "CHANGES_REQUIRED", "ASSESSMENT_READY", "PLANNING", "BUDGET_EXHAUSTED"], + WAITING_FOR_USER_ASSESS: ["ASSESSING"], + CHANGES_REQUIRED: ["LOOP_RUNNING"], + ASSESSMENT_READY: ["ASSESSMENT_ACCEPTED", "ASSESSING", "LOOP_RUNNING", "PLANNING"], + ASSESSMENT_ACCEPTED: ["RELEASING"], + RELEASING: ["WAITING_FOR_USER_RELEASE", "RELEASE_BLOCKED", "CLOSED", "PLANNING", "LOOP_RUNNING", "ASSESSING"], + WAITING_FOR_USER_RELEASE: ["RELEASING"], + RELEASE_BLOCKED: ["PLANNING"], + BUDGET_EXHAUSTED: ["PLANNING", "LOOP_RUNNING", "ASSESSING"], + CLOSED: [], +}; + +// Transitions that start or resume work; every other transition must carry a +// result whose (phase, status) pair is bound to that edge in RESULT_GATES. +const START_TRANSITIONS = new Set([ + "NEW>PLANNING", + "WAITING_FOR_USER_PLAN>PLANNING", + "BLOCKED_SPEC>PLANNING", + "BLOCKED_ORACLE>PLANNING", + "PLAN_APPROVED>LOOP_RUNNING", + "BLOCKED_ENVIRONMENT>LOOP_RUNNING", + "CANDIDATE_READY>ASSESSING", + "WAITING_FOR_USER_ASSESS>ASSESSING", + "CHANGES_REQUIRED>LOOP_RUNNING", + "ASSESSMENT_ACCEPTED>RELEASING", + "WAITING_FOR_USER_RELEASE>RELEASING", + "RELEASE_BLOCKED>PLANNING", + "BUDGET_EXHAUSTED>PLANNING", + "BUDGET_EXHAUSTED>LOOP_RUNNING", + "BUDGET_EXHAUSTED>ASSESSING", +]); + +// Edge-specific result binding: which result phase and statuses may drive each transition. +const RESULT_GATES = { + "PLANNING>WAITING_FOR_USER_PLAN": { phases: ["plan"], statuses: ["WAITING_FOR_USER", "NEEDS_HUMAN_DOMAIN_INPUT"] }, + "PLANNING>PLAN_READY": { phases: ["plan"], statuses: ["PLAN_READY"] }, + "PLANNING>BUDGET_EXHAUSTED": { phases: ["plan"], statuses: ["BUDGET_EXHAUSTED"] }, + "PLAN_READY>PLAN_APPROVED": { phases: ["plan", "challenge"], statuses: ["PLAN_READY", "NO_BLOCKERS"] }, + "PLAN_READY>PLANNING": { phases: ["plan", "challenge"], statuses: ["PLAN_REVISION_REQUIRED", "ESCALATE_LIGHT_TO_FULL", "NEEDS_REPOSITORY_EVIDENCE", "USER_DECISION_REQUIRED"] }, + "PLAN_APPROVED>PLANNING": { phases: ["plan", "loop"], statuses: ["PLAN_AMENDMENT_REQUIRED", "PLAN_REVISION_REQUIRED"] }, + "LOOP_RUNNING>BLOCKED_SPEC": { phases: ["loop"], statuses: ["BLOCKED_SPEC"] }, + "LOOP_RUNNING>BLOCKED_ORACLE": { phases: ["loop"], statuses: ["BLOCKED_ORACLE"] }, + "LOOP_RUNNING>BLOCKED_ENVIRONMENT": { phases: ["loop"], statuses: ["BLOCKED_ENVIRONMENT"] }, + "LOOP_RUNNING>CANDIDATE_READY": { phases: ["loop"], statuses: ["CANDIDATE_READY"] }, + "LOOP_RUNNING>BUDGET_EXHAUSTED": { phases: ["loop"], statuses: ["BUDGET_EXHAUSTED", "IMPLEMENTATION_FAILED"] }, + "CANDIDATE_READY>LOOP_RUNNING": { phases: ["challenge", "assess"], statuses: ["NEW_IMPLEMENTATION_FINDING", "CHANGES_REQUIRED"] }, + "ASSESSING>WAITING_FOR_USER_ASSESS": { phases: ["assess"], statuses: ["HUMAN_DECISION_REQUIRED", "WAITING_FOR_USER"] }, + "ASSESSING>CHANGES_REQUIRED": { phases: ["assess"], statuses: ["CHANGES_REQUIRED"] }, + "ASSESSING>ASSESSMENT_READY": { phases: ["assess"], statuses: ["PASS", "PASS_WITH_NONBLOCKING_FINDINGS"] }, + "ASSESSING>PLANNING": { phases: ["assess"], statuses: ["PLAN_AMENDMENT_REQUIRED"] }, + "ASSESSING>BUDGET_EXHAUSTED": { phases: ["assess"], statuses: ["BUDGET_EXHAUSTED", "ASSESSMENT_INCONCLUSIVE"] }, + "ASSESSMENT_READY>ASSESSMENT_ACCEPTED": { phases: ["assess", "challenge"], statuses: ["PASS", "PASS_WITH_NONBLOCKING_FINDINGS", "ASSESSMENT_ACCEPTED", "NO_BLOCKERS"] }, + "ASSESSMENT_READY>ASSESSING": { phases: ["challenge"], statuses: ["MORE_EVIDENCE_REQUIRED", "INCONCLUSIVE", "FINDING_RECLASSIFICATION_REQUIRED"] }, + "ASSESSMENT_READY>LOOP_RUNNING": { phases: ["challenge"], statuses: ["NEW_IMPLEMENTATION_FINDING"] }, + "ASSESSMENT_READY>PLANNING": { phases: ["challenge"], statuses: ["PLAN_AMBIGUITY_FOUND"] }, + "RELEASING>WAITING_FOR_USER_RELEASE": { phases: ["release"], statuses: ["USER_SIGNOFF_REQUIRED"] }, + "RELEASING>RELEASE_BLOCKED": { phases: ["release"], statuses: ["RELEASE_BLOCKED"] }, + "RELEASING>CLOSED": { phases: ["release"], statuses: ["RELEASE_APPROVED"] }, + "RELEASING>PLANNING": { phases: ["release"], statuses: ["RETURN_TO_PLAN"] }, + "RELEASING>LOOP_RUNNING": { phases: ["release"], statuses: ["RETURN_TO_LOOP"] }, + "RELEASING>ASSESSING": { phases: ["release"], statuses: ["RETURN_TO_ASSESS", "INSUFFICIENT_EVIDENCE"] }, +}; + +const RESULT_PHASES = new Set(["plan", "loop", "assess", "release", "challenge"]); +const QUESTION_CLASSES = new Set(["BLOCKING_DOMAIN", "BLOCKING_POLICY", "NONBLOCKING_ASSUMPTION", "OPTIONAL_PREFERENCE"]); +const CHALLENGE_PHASES = new Set(["plan", "loop", "assess"]); +const CHALLENGE_STATES = new Set(["not_invoked", "prepared", "ingested", "dispositioned", "skipped"]); +const CHALLENGE_VERDICTS = new Set([ + "NO_BLOCKERS", "PLAN_REVISION_REQUIRED", "USER_DECISION_REQUIRED", "ESCALATE_LIGHT_TO_FULL", + "INCONCLUSIVE", "NEW_IMPLEMENTATION_FINDING", "MORE_EVIDENCE_REQUIRED", "PLAN_AMBIGUITY_FOUND", + "FINDING_RECLASSIFICATION_REQUIRED", "ASSESSMENT_ACCEPTED", +]); +const DISPOSITIONS = new Set(["ACCEPTED", "REJECTED_WITH_EVIDENCE", "MORE_EVIDENCE_REQUIRED", "USER_DECISION_REQUIRED", "PARKED_NONBLOCKING", "DUPLICATE"]); +const SEVERITIES = new Set(["critical", "major", "minor"]); +const REVIEW_DECISIONS = new Set(["RELEASE_APPROVED", "RETURN_TO_PLAN", "RETURN_TO_LOOP", "RETURN_TO_ASSESS", "USER_SIGNOFF_REQUIRED", "RELEASE_BLOCKED", "INSUFFICIENT_EVIDENCE"]); +const PROTECTION_MODES = new Set(["auto", "flags", "chmod", "none"]); +const UNPROTECT_STATES = new Set(["CLOSED", "RELEASE_BLOCKED"]); +const DEPTHS = new Set(["auto", "light", "full"]); +const CHECKPOINT_STATES = new Set(["PLAN_READY", "PLAN_APPROVED", "CANDIDATE_READY", "ASSESSMENT_READY", "ASSESSMENT_ACCEPTED", "CLOSED"]); +const FREEZE_STATES = { plan: "PLANNING", loop: "LOOP_RUNNING", assess: "ASSESSING", release: "RELEASING" }; +const BUDGET_COUNTERS = new Set([ + "planning_revisions", + "semantic_replans", + "implementation_repairs", + "external_challenges", +]); +const BUILTIN_BUDGETS = { + light: { planning_revisions: 1, semantic_replans: 1, implementation_repairs: 1, external_challenges: 1 }, + full: { planning_revisions: 3, semantic_replans: 2, implementation_repairs: 3, external_challenges: 3 }, +}; +const STALE_LOCK_MS = 15 * 60 * 1000; +const DEFAULT_CHECK_OUTPUT_CAP = 1024 * 1024; + +class EngineError extends Error { + constructor(code, message) { + super(message); + this.code = code; + } +} + +const fail = (code, message) => { throw new EngineError(code, message); }; + +function git(root, args, env) { + return execFileSync("git", args, { cwd: root, encoding: "utf8", env: env ? { ...process.env, ...env } : undefined }).trim(); +} + +function canonicalJson(value) { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + if (value && typeof value === "object") { + return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(",")}}`; + } + return JSON.stringify(value); +} + +const sha256 = (data) => createHash("sha256").update(data).digest("hex"); +const hashFile = (file) => `sha256:${sha256(fs.readFileSync(file))}`; +const hashObject = (obj) => `sha256:${sha256(canonicalJson(obj))}`; + +function featureDir(root, feature) { + if (typeof feature !== "string" || !/^[a-z0-9][a-z0-9._-]*$/.test(feature) || feature.includes("..")) { + fail("INVALID_FEATURE_ID", `Feature id must be [a-z0-9._-], got: ${feature}`); + } + return path.join(root, ".tdd", feature); +} + +// Realpath-based containment: rejects escapes AND any symlinked path component, +// including symlinked ancestors that physically point outside the repository. +function resolveInsideRoot(root, file) { + const realRoot = fs.realpathSync(root); + const resolved = path.resolve(realRoot, file); + const rel = path.relative(realRoot, resolved); + if (rel.startsWith("..") || path.isAbsolute(rel)) fail("PATH_ESCAPE", `Path escapes repository root: ${file}`); + let probe = resolved; + while (!fs.existsSync(probe)) probe = path.dirname(probe); + if (fs.realpathSync(probe) !== probe) fail("SYMLINK_REJECTED", `Symlinked path component is not allowed: ${file}`); + if (fs.existsSync(resolved) && fs.lstatSync(resolved).isSymbolicLink()) fail("SYMLINK_REJECTED", `Symlinks are not allowed: ${file}`); + return resolved; +} + +function atomicWriteJson(file, value) { + const tmp = `${file}.${process.pid}.tmp`; + fs.writeFileSync(tmp, `${JSON.stringify(value, null, "\t")}\n`); + fs.renameSync(tmp, file); +} + +const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8")); +const manifestPath = (dir) => path.join(dir, "manifest.json"); +const protectedPath = (dir) => path.join(dir, "protected-manifest.json"); +const checksIndexPath = (dir) => path.join(dir, "checks", "index.jsonl"); + +function loadManifest(root, feature) { + const file = manifestPath(featureDir(root, feature)); + if (!fs.existsSync(file)) fail("NOT_INITIALIZED", `No manifest for feature '${feature}'. Run init first.`); + return readJson(file); +} + +function saveManifest(root, feature, manifest) { + atomicWriteJson(manifestPath(featureDir(root, feature)), manifest); +} + +function lockDir(root, feature) { + return path.join(featureDir(root, feature), ".lock"); +} + +function withLock(root, feature, fn) { + const lock = lockDir(root, feature); + try { + fs.mkdirSync(lock); + } catch (error) { + if (error.code === "EEXIST") fail("LOCKED", `Feature '${feature}' is locked by another writer (${lock}). Use 'unlock ${feature} --stale' if the holder crashed.`); + throw error; + } + try { + atomicWriteJson(path.join(lock, "meta.json"), { pid: process.pid, host: os.hostname(), started_at: new Date().toISOString() }); + return fn(); + } finally { + fs.rmSync(lock, { recursive: true, force: true }); + } +} + +export function unlock(root, feature, options = {}) { + const lock = lockDir(root, feature); + if (!fs.existsSync(lock)) return { released: false, reason: "not_locked" }; + if (options.force) { + fs.rmSync(lock, { recursive: true, force: true }); + return { released: true, reason: "forced" }; + } + if (!options.stale) fail("UNLOCK_REFUSED", "Refusing to unlock a live lock. Pass stale (dead holder) or force."); + const metaFile = path.join(lock, "meta.json"); + const meta = fs.existsSync(metaFile) ? readJson(metaFile) : null; + let dead = !meta; + if (meta && meta.host === os.hostname()) { + try { + process.kill(meta.pid, 0); + } catch { + dead = true; + } + } + const old = meta && Date.now() - Date.parse(meta.started_at) > STALE_LOCK_MS; + if (!dead && !old) fail("LOCK_NOT_STALE", `Lock holder pid=${meta.pid} on ${meta.host} appears alive and recent.`); + fs.rmSync(lock, { recursive: true, force: true }); + return { released: true, reason: dead ? "dead_holder" : "expired_lease" }; +} + +export function appendEvent(root, feature, type, data = {}) { + const line = { ts: new Date().toISOString(), type, ...data }; + fs.appendFileSync(path.join(featureDir(root, feature), "events.jsonl"), `${JSON.stringify(line)}\n`); + return line; +} + +// NUL-delimited porcelain parsing; rename/copy records carry a second path field. +function worktreeStatus(root) { + const raw = execFileSync("git", ["status", "--porcelain=v1", "-z"], { cwd: root, encoding: "utf8" }); + const fields = raw.split("\0").filter((f) => f.length > 0); + const modified = []; + const untracked = []; + for (let i = 0; i < fields.length; i++) { + const record = fields[i]; + const xy = record.slice(0, 2); + const file = record.slice(3); + if (xy === "??") untracked.push(file); + else modified.push(file); + if (xy[0] === "R" || xy[0] === "C") i++; + } + return { modified, untracked }; +} + +// Exact candidate identity, independent of user-visible commits: stage the whole +// worktree (tracked + untracked, .gitignore respected) into a temporary index. +function computeWorktree(root, feature) { + const idx = path.join(featureDir(root, feature), `.index-${process.pid}`); + try { + const env = { GIT_INDEX_FILE: idx }; + git(root, ["add", "-A"], env); + return git(root, ["write-tree"], env); + } finally { + fs.rmSync(idx, { force: true }); + } +} + +function snapshotCandidate(root, feature, runId) { + const tree = computeWorktree(root, feature); + const head = git(root, ["rev-parse", "HEAD"]); + const commit = git(root, [ + "-c", "user.name=agentic-tdd", "-c", "user.email=tdd@local", + "commit-tree", tree, "-p", head, "-m", `agentic-tdd candidate ${feature}/${runId}`, + ]); + const ref = `refs/agentic-tdd/${feature}/candidate/${runId}`; + git(root, ["update-ref", ref, commit]); + return { tree, commit, ref }; +} + +function detectTestCommand(root) { + const pkg = path.join(root, "package.json"); + if (fs.existsSync(pkg) && readJson(pkg).scripts?.test) return "npm test"; + if (fs.existsSync(path.join(root, "CMakeLists.txt"))) return "ctest --test-dir build"; + if (fs.existsSync(path.join(root, "pyproject.toml")) || fs.existsSync(path.join(root, "pytest.ini"))) return "pytest"; + if (fs.existsSync(path.join(root, "Cargo.toml"))) return "cargo test"; + return null; +} + +function loadProfile(depth, domains = []) { + const profileDir = path.join(path.dirname(new URL(import.meta.url).pathname), "..", "profiles"); + const effectiveDepth = depth === "auto" ? "light" : depth; + let profile = { budgets: { ...BUILTIN_BUDGETS[effectiveDepth] }, required_check_ids: [] }; + const merge = (file) => { + if (!fs.existsSync(file)) return; + const data = readJson(file); + profile.budgets = { + planning_revisions: data.planning?.maximum_revisions ?? profile.budgets.planning_revisions, + semantic_replans: data.loop?.semantic_replans ?? profile.budgets.semantic_replans, + implementation_repairs: data.loop?.maximum_repair_rounds ?? profile.budgets.implementation_repairs, + external_challenges: data.external_challenge?.maximum_total + ?? (data.external_challenge?.maximum_per_phase_version ? data.external_challenge.maximum_per_phase_version * 3 : profile.budgets.external_challenges), + }; + if (Array.isArray(data.assessment?.required_check_ids)) { + profile.required_check_ids = [...new Set([...profile.required_check_ids, ...data.assessment.required_check_ids])]; + } + }; + merge(path.join(profileDir, `${effectiveDepth}.json`)); + for (const domain of domains) merge(path.join(profileDir, `${domain}.json`)); + return profile; +} + +const budgetRemaining = (manifest, counter) => { + const profile = loadProfile(manifest.feature.depth, manifest.feature.domains); + return (profile.budgets[counter] ?? 0) + (manifest.budgets.extra[counter] ?? 0) - (manifest.budgets.used[counter] ?? 0); +}; + +export function init(root, feature, options = {}) { + const depth = options.depth ?? "auto"; + if (!DEPTHS.has(depth)) fail("INVALID_DEPTH", `Unknown depth: ${depth} (auto|light|full)`); + const domains = options.domains ?? []; + const protection = options.protection ?? "auto"; + if (!PROTECTION_MODES.has(protection)) fail("INVALID_PROTECTION", `Unknown protection_mode: ${protection}`); + + const dir = featureDir(root, feature); + if (fs.existsSync(manifestPath(dir))) fail("ALREADY_INITIALIZED", `Feature '${feature}' already exists.`); + + let baseline; + try { + baseline = git(root, ["rev-parse", "HEAD"]); + } catch { + fail("NOT_A_GIT_REPO", `Target root is not a git repository with commits: ${root}`); + } + const status = worktreeStatus(root); + if (status.modified.length && !options.allowDirty) { + fail("DIRTY_WORKTREE", `Tracked files modified: ${status.modified.join(", ")}. Ask the USER, then re-run with allowDirty.`); + } + + fs.mkdirSync(dir, { recursive: true }); + for (const sub of ["input", "decisions/questions", "decisions/answers", "decisions/waivers", "plan", "loop", "assess", "challenges", "release", "checks", "results"]) { + fs.mkdirSync(path.join(dir, sub), { recursive: true }); + } + + const manifest = { + schema_version: 2, + feature: { id: feature, title: options.title ?? feature, depth, domains }, + workflow: { state: "NEW" }, + repository: { + baseline_commit: baseline, + candidate_tree: null, + candidate_commit: null, + candidate_ref: null, + branch: git(root, ["rev-parse", "--abbrev-ref", "HEAD"]), + test_command: options.testCommand ?? detectTestCommand(root), + untracked_at_init: status.untracked, + }, + current: { plan_version: null, loop_run: null, assessment_run: null }, + options: { + protection_mode: protection, + challenges: { + plan: options.challenges?.includes("plan") ?? false, + loop: options.challenges?.includes("loop") ?? false, + assess: options.challenges?.includes("assess") ?? false, + }, + models: options.models ?? {}, + }, + integrity: {}, + budgets: { used: {}, extra: {} }, + questions: { open: {} }, + challenges: { + plan: { state: "not_invoked" }, + loop: { state: "not_invoked" }, + assess: { state: "not_invoked" }, + }, + release: { reviewer_status: "not_started", decision: null, candidate_tree: null, report_hash: null }, + }; + saveManifest(root, feature, manifest); + appendEvent(root, feature, "init", { depth, domains, baseline_commit: baseline }); + return manifest; +} + +export function setDepth(root, feature, depth, options = {}) { + if (!DEPTHS.has(depth) || depth === "auto") fail("INVALID_DEPTH", `set-depth requires light or full, got: ${depth}`); + return withLock(root, feature, () => { + const manifest = loadManifest(root, feature); + if (manifest.workflow.state !== "PLANNING") fail("WRONG_STATE", `set-depth allowed only in PLANNING; state is ${manifest.workflow.state}.`); + const previous = manifest.feature.depth; + manifest.feature.depth = depth; + saveManifest(root, feature, manifest); + appendEvent(root, feature, "set_depth", { from: previous, to: depth, evidence: options.evidence ?? null }); + return { depth, previous }; + }); +} + +const unresolvedBlocking = (manifest) => + Object.values(manifest.questions.open).filter((c) => c.startsWith("BLOCKING_")).length; + +function challengeSatisfied(manifest, phase) { + const state = manifest.challenges[phase].state; + if (state === "dispositioned" || state === "skipped") return true; + if (state === "not_invoked") return !manifest.options.challenges[phase]; + return false; +} + +function requiredChecksSatisfied(root, feature, manifest) { + const required = loadProfile(manifest.feature.depth, manifest.feature.domains).required_check_ids; + if (!required.length) return { ok: true, missing: [] }; + const indexFile = checksIndexPath(featureDir(root, feature)); + const records = fs.existsSync(indexFile) + ? fs.readFileSync(indexFile, "utf8").trim().split("\n").filter(Boolean).map((l) => JSON.parse(l)) + : []; + const missing = required.filter((id) => + !records.some((r) => r.check_id === id && r.exit_code === 0 && r.candidate_tree === manifest.repository.candidate_tree)); + return { ok: missing.length === 0, missing }; +} + +function validateResult(result, edge) { + for (const field of ["phase", "run_id", "status", "requested_transition"]) { + if (typeof result?.[field] !== "string" || !result[field]) fail("INVALID_RESULT", `Result missing required string field: ${field}`); + } + if (!RESULT_PHASES.has(result.phase)) fail("INVALID_RESULT", `Unknown result phase: ${result.phase}`); + const gate = RESULT_GATES[edge]; + if (!gate) fail("INVALID_RESULT", `No result gate defined for ${edge}.`); + if (!gate.phases.includes(result.phase)) { + fail("RESULT_PHASE_MISMATCH", `${edge} accepts result phases [${gate.phases.join(", ")}], got '${result.phase}'.`); + } + if (!gate.statuses.includes(result.status)) { + fail("RESULT_STATUS_MISMATCH", `${edge} accepts statuses [${gate.statuses.join(", ")}], got '${result.status}'.`); + } + const to = edge.split(">")[1]; + if (result.requested_transition !== to) { + fail("RESULT_MISMATCH", `Result requests ${result.requested_transition}, transition is to ${to}.`); + } +} + +function checkGates(root, feature, manifest, from, to) { + if (CHECKPOINT_STATES.has(to) && unresolvedBlocking(manifest) > 0) { + fail("BLOCKING_QUESTIONS_OPEN", `${unresolvedBlocking(manifest)} blocking user question(s) open; answer or waive them first.`); + } + if (from === "PLAN_READY" && to === "PLAN_APPROVED" && !challengeSatisfied(manifest, "plan")) { + fail("CHALLENGE_PENDING", `Plan challenge is ${manifest.challenges.plan.state}; disposition or skip it before approval.`); + } + if (from === "CANDIDATE_READY" && to === "ASSESSING" && !challengeSatisfied(manifest, "loop")) { + fail("CHALLENGE_PENDING", `Loop challenge is ${manifest.challenges.loop.state}; disposition or skip it before assessment.`); + } + if (from === "ASSESSMENT_READY" && to === "ASSESSMENT_ACCEPTED" && !challengeSatisfied(manifest, "assess")) { + fail("CHALLENGE_PENDING", `Assess challenge is ${manifest.challenges.assess.state}; disposition or skip it before acceptance.`); + } + if (from === "ASSESSING" && to === "ASSESSMENT_READY") { + const checks = requiredChecksSatisfied(root, feature, manifest); + if (!checks.ok) { + fail("REQUIRED_CHECKS_MISSING", `No passing engine-executed record bound to the candidate for: ${checks.missing.join(", ")}. Use run-check.`); + } + } + if (to === "CLOSED") { + if (manifest.release.decision !== "RELEASE_APPROVED") { + fail("REVIEW_MISSING", `Release reviewer decision is '${manifest.release.decision ?? "absent"}'; ingest a RELEASE_APPROVED report first.`); + } + if (manifest.release.candidate_tree !== manifest.repository.candidate_tree) { + fail("REVIEW_STALE", "Reviewer report is bound to a different candidate tree than the current one."); + } + for (const phase of CHALLENGE_PHASES) { + if (!challengeSatisfied(manifest, phase)) fail("CHALLENGE_PENDING", `Challenge '${phase}' is ${manifest.challenges[phase].state}.`); + } + const waiverDir = path.join(featureDir(root, feature), "decisions", "waivers"); + for (const file of fs.readdirSync(waiverDir)) { + const waiver = readJson(path.join(waiverDir, file)); + if (Date.parse(waiver.expires) < Date.now()) fail("WAIVER_EXPIRED", `Waiver ${waiver.id} expired ${waiver.expires}.`); + } + } +} + +export function transition(root, feature, to, options = {}) { + return withLock(root, feature, () => { + const manifest = loadManifest(root, feature); + const from = manifest.workflow.state; + const edge = `${from}>${to}`; + if (!(to in TRANSITIONS)) fail("UNKNOWN_STATE", `Unknown state: ${to}`); + if (!TRANSITIONS[from].includes(to)) fail("ILLEGAL_TRANSITION", `${from} → ${to} is not a legal transition.`); + + let result = null; + if (!START_TRANSITIONS.has(edge)) { + if (!options.resultFile) fail("RESULT_REQUIRED", `${edge.replace(">", " → ")} requires a phase result file.`); + result = readJson(resolveInsideRoot(root, options.resultFile)); + validateResult(result, edge); + } + + checkGates(root, feature, manifest, from, to); + + const report = verifyManifest(root, feature, manifest); + if (!report.ok) { + fail("VERIFY_FAILED", `Evidence chain is not intact: ${report.problems.map((p) => p.code).join(", ")}. Resolve before transitioning.`); + } + + if (to === "LOOP_RUNNING" && from !== "BLOCKED_ENVIRONMENT") { + manifest.repository.candidate_tree = null; + manifest.repository.candidate_commit = null; + manifest.repository.candidate_ref = null; + } + if (to === "CANDIDATE_READY") { + const snapshot = snapshotCandidate(root, feature, result?.run_id ?? "run"); + manifest.repository.candidate_tree = snapshot.tree; + manifest.repository.candidate_commit = snapshot.commit; + manifest.repository.candidate_ref = snapshot.ref; + } + + manifest.workflow.state = to; + saveManifest(root, feature, manifest); + appendEvent(root, feature, "transition", { from, to, run_id: result?.run_id ?? null, status: result?.status ?? null }); + return { from, to, state: to, candidate_tree: manifest.repository.candidate_tree }; + }); +} + +export function freeze(root, feature, phase, files) { + return withLock(root, feature, () => { + const manifest = loadManifest(root, feature); + const requiredState = FREEZE_STATES[phase]; + if (!requiredState) fail("INVALID_PHASE", `freeze phase must be one of: ${Object.keys(FREEZE_STATES).join(", ")}`); + if (manifest.workflow.state !== requiredState) { + fail("WRONG_STATE", `freeze '${phase}' allowed only in ${requiredState}; state is ${manifest.workflow.state}.`); + } + const hashes = {}; + for (const file of files) { + const resolved = resolveInsideRoot(root, file); + if (!fs.existsSync(resolved)) fail("MISSING_ARTIFACT", `Cannot freeze missing file: ${file}`); + hashes[path.relative(fs.realpathSync(root), resolved)] = hashFile(resolved); + } + manifest.integrity[phase] = { commit: git(root, ["rev-parse", "HEAD"]), files: hashes }; + saveManifest(root, feature, manifest); + appendEvent(root, feature, "freeze", { phase, files: Object.keys(hashes) }); + return manifest.integrity[phase]; + }); +} + +function applyProtection(file, mode) { + if (mode === "none") return "hash_only"; + const useFlags = mode === "flags" || (mode === "auto" && (process.platform === "darwin" || process.getuid?.() === 0)); + if (useFlags) { + try { + if (process.platform === "darwin") execFileSync("chflags", ["uchg", file]); + else execFileSync("chattr", ["+i", file]); + return process.platform === "darwin" ? "chflags" : "chattr"; + } catch { + if (mode === "flags") fail("PROTECTION_FAILED", `Immutable flag failed for ${file}; requires root on linux. Use protection_mode chmod or none.`); + } + } + fs.chmodSync(file, 0o444); + return "chmod"; +} + +function removeProtection(file, method) { + if (method === "chflags") execFileSync("chflags", ["nouchg", file]); + else if (method === "chattr") execFileSync("chattr", ["-i", file]); + else if (method === "chmod") fs.chmodSync(file, 0o644); +} + +export function protect(root, feature, files) { + return withLock(root, feature, () => { + const manifest = loadManifest(root, feature); + const file = protectedPath(featureDir(root, feature)); + const entries = fs.existsSync(file) ? readJson(file) : []; + const known = new Set(entries.map((e) => e.path)); + for (const raw of files) { + const resolved = resolveInsideRoot(root, raw); + if (!fs.existsSync(resolved)) fail("MISSING_ARTIFACT", `Cannot protect missing file: ${raw}`); + const rel = path.relative(fs.realpathSync(root), resolved); + if (known.has(rel)) continue; + const method = applyProtection(resolved, manifest.options.protection_mode); + entries.push({ path: rel, hash: hashFile(resolved), method }); + } + atomicWriteJson(file, entries); + manifest.integrity.protected_manifest_hash = hashObject(entries); + saveManifest(root, feature, manifest); + appendEvent(root, feature, "protect", { files: entries.map((e) => e.path) }); + return entries; + }); +} + +export function unprotect(root, feature) { + return withLock(root, feature, () => { + const manifest = loadManifest(root, feature); + if (!UNPROTECT_STATES.has(manifest.workflow.state)) { + fail("UNPROTECT_FORBIDDEN", `Unprotect allowed only in ${[...UNPROTECT_STATES].join("/")}; state is ${manifest.workflow.state}.`); + } + const file = protectedPath(featureDir(root, feature)); + const entries = fs.existsSync(file) ? readJson(file) : []; + for (const entry of entries) removeProtection(path.resolve(root, entry.path), entry.method); + atomicWriteJson(file, []); + manifest.integrity.protected_manifest_hash = hashObject([]); + saveManifest(root, feature, manifest); + appendEvent(root, feature, "unprotect", { files: entries.map((e) => e.path) }); + return { released: entries.length }; + }); +} + +function verifyManifest(root, feature, manifest) { + const problems = []; + + const file = protectedPath(featureDir(root, feature)); + const entries = fs.existsSync(file) ? readJson(file) : []; + if (manifest.integrity.protected_manifest_hash && manifest.integrity.protected_manifest_hash !== hashObject(entries)) { + problems.push({ code: "PROTECTED_MANIFEST_TAMPERED", path: path.relative(root, file) }); + } + for (const entry of entries) { + const target = path.resolve(root, entry.path); + if (!fs.existsSync(target)) problems.push({ code: "PROTECTED_FILE_DELETED", path: entry.path }); + else if (hashFile(target) !== entry.hash) problems.push({ code: "PROTECTED_FILE_MODIFIED", path: entry.path }); + } + + for (const [phase, record] of Object.entries(manifest.integrity)) { + if (typeof record !== "object" || !record?.files) continue; + for (const [rel, hash] of Object.entries(record.files)) { + const target = path.resolve(root, rel); + if (!fs.existsSync(target)) problems.push({ code: "FROZEN_ARTIFACT_DELETED", phase, path: rel }); + else if (hashFile(target) !== hash) problems.push({ code: "FROZEN_ARTIFACT_MODIFIED", phase, path: rel }); + } + } + + if (manifest.repository.candidate_tree) { + const currentTree = computeWorktree(root, feature); + if (currentTree !== manifest.repository.candidate_tree) { + problems.push({ code: "CANDIDATE_TREE_DRIFT", expected: manifest.repository.candidate_tree, actual: currentTree }); + } + } + + const status = worktreeStatus(root); + return { ok: problems.length === 0, state: manifest.workflow.state, problems, untracked: status.untracked, modified: status.modified }; +} + +export function verify(root, feature) { + return verifyManifest(root, feature, loadManifest(root, feature)); +} + +export function consumeBudget(root, feature, counter) { + if (!BUDGET_COUNTERS.has(counter)) fail("UNKNOWN_BUDGET", `Unknown budget counter: ${counter}`); + return withLock(root, feature, () => { + const manifest = loadManifest(root, feature); + if (budgetRemaining(manifest, counter) <= 0) { + fail("BUDGET_EXHAUSTED", `Budget exhausted: ${counter}. Transition to BUDGET_EXHAUSTED or ask the USER for a grant.`); + } + manifest.budgets.used[counter] = (manifest.budgets.used[counter] ?? 0) + 1; + saveManifest(root, feature, manifest); + const remaining = budgetRemaining(manifest, counter); + appendEvent(root, feature, "budget", { counter, remaining }); + return { counter, remaining }; + }); +} + +export function grantBudget(root, feature, counter, n, authorizedBy) { + if (!BUDGET_COUNTERS.has(counter)) fail("UNKNOWN_BUDGET", `Unknown budget counter: ${counter}`); + if (!authorizedBy) fail("AUTHORITY_REQUIRED", "grant requires authorized-by (the USER or domain owner)."); + const units = n ?? 1; + if (!Number.isInteger(units) || units < 1) fail("INVALID_GRANT", `Grant units must be a positive integer, got: ${n}`); + return withLock(root, feature, () => { + const manifest = loadManifest(root, feature); + manifest.budgets.extra[counter] = (manifest.budgets.extra[counter] ?? 0) + units; + saveManifest(root, feature, manifest); + const remaining = budgetRemaining(manifest, counter); + appendEvent(root, feature, "budget_grant", { counter, units, authorized_by: authorizedBy, remaining }); + return { counter, remaining, authorized_by: authorizedBy }; + }); +} + +export function questionOpen(root, feature, questionFile) { + return withLock(root, feature, () => { + const manifest = loadManifest(root, feature); + const question = readJson(resolveInsideRoot(root, questionFile)); + for (const field of ["id", "class", "question"]) { + if (typeof question?.[field] !== "string" || !question[field]) fail("INVALID_QUESTION", `Question missing required string field: ${field}`); + } + if (!QUESTION_CLASSES.has(question.class)) fail("INVALID_QUESTION", `Unknown question class: ${question.class}`); + if (manifest.questions.open[question.id]) fail("QUESTION_EXISTS", `Question ${question.id} is already open.`); + atomicWriteJson(path.join(featureDir(root, feature), "decisions", "questions", `${question.id}.json`), question); + manifest.questions.open[question.id] = question.class; + saveManifest(root, feature, manifest); + appendEvent(root, feature, "question_open", { id: question.id, class: question.class }); + return { id: question.id, class: question.class, unresolved_blocking: unresolvedBlocking(manifest) }; + }); +} + +function resolveQuestion(root, feature, id, record, eventType) { + return withLock(root, feature, () => { + const manifest = loadManifest(root, feature); + if (!manifest.questions.open[id]) fail("QUESTION_NOT_OPEN", `Question ${id} is not open.`); + atomicWriteJson(path.join(featureDir(root, feature), "decisions", "answers", `${id}.json`), { id, ts: new Date().toISOString(), ...record }); + delete manifest.questions.open[id]; + saveManifest(root, feature, manifest); + appendEvent(root, feature, eventType, { id }); + return { id, unresolved_blocking: unresolvedBlocking(manifest) }; + }); +} + +export function questionAnswer(root, feature, id, answer) { + if (!answer) fail("INVALID_ANSWER", "answer requires the user's decision text or option id."); + return resolveQuestion(root, feature, id, { answer }, "question_answer"); +} + +export function questionWaive(root, feature, id, authorizedBy) { + if (!authorizedBy) fail("AUTHORITY_REQUIRED", "waiving a question requires authorized-by."); + return resolveQuestion(root, feature, id, { waived_by: authorizedBy }, "question_waive"); +} + +const CHALLENGE_BOUNDARY = { plan: "PLAN_READY", loop: "CANDIDATE_READY", assess: "ASSESSMENT_READY" }; + +function challengeDir(root, feature, phase) { + return path.join(featureDir(root, feature), "challenges", phase); +} + +export function challengePrepare(root, feature, phase) { + if (!CHALLENGE_PHASES.has(phase)) fail("INVALID_PHASE", `Challenge phase must be plan|loop|assess, got: ${phase}`); + return withLock(root, feature, () => { + const manifest = loadManifest(root, feature); + if (manifest.workflow.state !== CHALLENGE_BOUNDARY[phase]) { + fail("WRONG_STATE", `Challenge '${phase}' prepares at ${CHALLENGE_BOUNDARY[phase]}; state is ${manifest.workflow.state}.`); + } + if (manifest.challenges[phase].state !== "not_invoked" && manifest.challenges[phase].state !== "skipped") { + fail("CHALLENGE_ACTIVE", `Challenge '${phase}' is already ${manifest.challenges[phase].state}.`); + } + if (budgetRemaining(manifest, "external_challenges") <= 0) { + fail("BUDGET_EXHAUSTED", "Budget exhausted: external_challenges. Ask the USER for a grant."); + } + manifest.budgets.used.external_challenges = (manifest.budgets.used.external_challenges ?? 0) + 1; + fs.mkdirSync(challengeDir(root, feature, phase), { recursive: true }); + manifest.challenges[phase] = { state: "prepared", candidate_tree: manifest.repository.candidate_tree }; + saveManifest(root, feature, manifest); + appendEvent(root, feature, "challenge_prepare", { phase }); + return { phase, state: "prepared", packet_dir: path.relative(root, challengeDir(root, feature, phase)) }; + }); +} + +export function challengeIngest(root, feature, phase, responseFile) { + return withLock(root, feature, () => { + const manifest = loadManifest(root, feature); + if (manifest.challenges[phase]?.state !== "prepared") { + fail("WRONG_STATE", `Challenge '${phase}' must be prepared before ingest; it is ${manifest.challenges[phase]?.state}.`); + } + const resolved = resolveInsideRoot(root, responseFile); + const response = readJson(resolved); + if (!CHALLENGE_VERDICTS.has(response?.verdict?.value)) fail("INVALID_RESPONSE", `Unknown challenge verdict: ${response?.verdict?.value}`); + if (!Array.isArray(response.findings)) fail("INVALID_RESPONSE", "Challenge response requires a findings array."); + for (const finding of response.findings) { + if (typeof finding?.id !== "string" || !finding.id) fail("INVALID_RESPONSE", "Every finding requires a string id."); + if (!SEVERITIES.has(finding.severity)) fail("INVALID_RESPONSE", `Finding ${finding.id}: severity must be critical|major|minor.`); + if (typeof finding.claim !== "string" || !finding.claim) fail("INVALID_RESPONSE", `Finding ${finding.id}: claim is required.`); + } + fs.copyFileSync(resolved, path.join(challengeDir(root, feature, phase), "response.json")); + manifest.challenges[phase] = { + ...manifest.challenges[phase], + state: "ingested", + response_hash: hashFile(resolved), + provenance: response.provenance ?? null, + finding_ids: response.findings.map((f) => f.id), + }; + saveManifest(root, feature, manifest); + appendEvent(root, feature, "challenge_ingest", { phase, findings: response.findings.length, verdict: response.verdict.value }); + return { phase, state: "ingested", findings: response.findings.length }; + }); +} + +export function challengeDispose(root, feature, phase, dispositionFile) { + return withLock(root, feature, () => { + const manifest = loadManifest(root, feature); + if (manifest.challenges[phase]?.state !== "ingested") { + fail("WRONG_STATE", `Challenge '${phase}' must be ingested before disposition; it is ${manifest.challenges[phase]?.state}.`); + } + const resolved = resolveInsideRoot(root, dispositionFile); + const body = readJson(resolved); + if (!Array.isArray(body?.dispositions)) fail("INVALID_DISPOSITION", "Disposition file requires a dispositions array."); + const response = readJson(path.join(challengeDir(root, feature, phase), "response.json")); + const bySeverity = new Map(response.findings.map((f) => [f.id, f])); + const seen = new Set(); + for (const item of body.dispositions) { + const finding = bySeverity.get(item?.finding); + if (!finding) fail("INVALID_DISPOSITION", `Disposition references unknown finding: ${item?.finding}`); + if (!DISPOSITIONS.has(item.disposition)) fail("INVALID_DISPOSITION", `Unknown disposition: ${item.disposition}`); + // Critical findings cannot be dismissed by prose: rejection demands evidence + // refs; parking is not available at critical severity. + if (finding.severity === "critical") { + if (item.disposition === "PARKED_NONBLOCKING") fail("CRITICAL_UNDISMISSABLE", `Critical finding ${finding.id} cannot be parked.`); + if (item.disposition === "REJECTED_WITH_EVIDENCE" && !(Array.isArray(item.evidence) && item.evidence.length)) { + fail("CRITICAL_UNDISMISSABLE", `Rejecting critical finding ${finding.id} requires evidence references.`); + } + } + seen.add(item.finding); + } + const missing = response.findings.filter((f) => !seen.has(f.id)).map((f) => f.id); + if (missing.length) fail("INVALID_DISPOSITION", `Every finding needs a disposition; missing: ${missing.join(", ")}`); + fs.copyFileSync(resolved, path.join(challengeDir(root, feature, phase), "disposition.json")); + manifest.challenges[phase] = { ...manifest.challenges[phase], state: "dispositioned", disposition_hash: hashFile(resolved) }; + saveManifest(root, feature, manifest); + appendEvent(root, feature, "challenge_dispose", { phase, dispositions: body.dispositions.length }); + return { phase, state: "dispositioned" }; + }); +} + +export function challengeSkip(root, feature, phase, reason) { + if (!CHALLENGE_PHASES.has(phase)) fail("INVALID_PHASE", `Challenge phase must be plan|loop|assess, got: ${phase}`); + if (typeof reason !== "string" || !reason) fail("REASON_REQUIRED", "Skipping a challenge requires a recorded reason."); + return withLock(root, feature, () => { + const manifest = loadManifest(root, feature); + const state = manifest.challenges[phase].state; + if (state !== "not_invoked" && state !== "prepared") fail("WRONG_STATE", `Cannot skip challenge '${phase}' in state ${state}.`); + manifest.challenges[phase] = { state: "skipped", reason }; + saveManifest(root, feature, manifest); + appendEvent(root, feature, "challenge_skip", { phase, reason }); + return { phase, state: "skipped" }; + }); +} + +export function reviewIngest(root, feature, reportFile) { + return withLock(root, feature, () => { + const manifest = loadManifest(root, feature); + if (manifest.workflow.state !== "RELEASING") fail("WRONG_STATE", `review ingest allowed only in RELEASING; state is ${manifest.workflow.state}.`); + const resolved = resolveInsideRoot(root, reportFile); + const report = readJson(resolved); + if (!REVIEW_DECISIONS.has(report?.decision)) fail("INVALID_REVIEW", `Unknown reviewer decision: ${report?.decision}`); + if (report.candidate_tree !== manifest.repository.candidate_tree) { + fail("REVIEW_STALE", "Reviewer report must name the exact current candidate_tree it reviewed."); + } + fs.copyFileSync(resolved, path.join(featureDir(root, feature), "release", "reviewer-report.json")); + manifest.release = { + reviewer_status: "ingested", + decision: report.decision, + candidate_tree: report.candidate_tree, + report_hash: hashFile(resolved), + }; + saveManifest(root, feature, manifest); + appendEvent(root, feature, "review_ingest", { decision: report.decision }); + return { decision: report.decision }; + }); +} + +export function waiverAuthorize(root, feature, waiverFile) { + return withLock(root, feature, () => { + loadManifest(root, feature); + const waiver = readJson(resolveInsideRoot(root, waiverFile)); + for (const field of ["id", "authorized_by", "rationale", "expires"]) { + if (typeof waiver?.[field] !== "string" || !waiver[field]) fail("INVALID_WAIVER", `Waiver missing required string field: ${field}`); + } + if (!Number.isFinite(Date.parse(waiver.expires)) || Date.parse(waiver.expires) < Date.now()) { + fail("INVALID_WAIVER", `Waiver expiry must be a future date, got: ${waiver.expires}`); + } + atomicWriteJson(path.join(featureDir(root, feature), "decisions", "waivers", `${waiver.id}.json`), waiver); + appendEvent(root, feature, "waiver_authorize", { id: waiver.id, authorized_by: waiver.authorized_by, expires: waiver.expires }); + return { id: waiver.id, expires: waiver.expires }; + }); +} + +// Engine-owned check execution: assessors interpret this evidence but cannot +// manufacture it. Records bind to the candidate tree computed at start time. +export function runCheck(root, feature, checkId, argv, options = {}) { + if (!/^[a-z0-9][a-z0-9._-]*$/.test(checkId ?? "")) fail("INVALID_CHECK_ID", `Check id must be [a-z0-9._-], got: ${checkId}`); + if (!Array.isArray(argv) || !argv.length) fail("INVALID_CHECK", "run-check requires argv after --"); + const manifest = loadManifest(root, feature); + const cwd = options.cwd ? resolveInsideRoot(root, options.cwd) : fs.realpathSync(root); + const cap = options.maxBytes ?? DEFAULT_CHECK_OUTPUT_CAP; + const treeAtStart = computeWorktree(root, feature); + const started = Date.now(); + let exitCode = 0; + let stdout = ""; + let stderr = ""; + try { + stdout = execFileSync(argv[0], argv.slice(1), { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); + } catch (error) { + if (error.status === undefined && !error.stdout && !error.stderr) fail("CHECK_SPAWN_FAILED", `Could not execute ${argv[0]}: ${error.message}`); + exitCode = error.status ?? 1; + stdout = error.stdout?.toString() ?? ""; + stderr = error.stderr?.toString() ?? ""; + } + const dir = path.join(featureDir(root, feature), "checks"); + const seq = fs.readdirSync(dir).filter((f) => f.startsWith(`${checkId}-`) && f.endsWith(".out")).length + 1; + const base = path.join(dir, `${checkId}-${String(seq).padStart(3, "0")}`); + fs.writeFileSync(`${base}.out`, stdout.slice(0, cap)); + fs.writeFileSync(`${base}.err`, stderr.slice(0, cap)); + const record = { + check_id: checkId, + seq, + argv, + cwd: path.relative(fs.realpathSync(root), cwd) || ".", + candidate_tree: treeAtStart, + bound_to_candidate: treeAtStart === manifest.repository.candidate_tree, + environment: { platform: process.platform, node: process.version }, + started_at: new Date(started).toISOString(), + duration_ms: Date.now() - started, + exit_code: exitCode, + stdout_hash: `sha256:${sha256(stdout)}`, + stderr_hash: `sha256:${sha256(stderr)}`, + truncated: stdout.length > cap || stderr.length > cap, + }; + fs.appendFileSync(checksIndexPath(featureDir(root, feature)), `${JSON.stringify(record)}\n`); + appendEvent(root, feature, "run_check", { check_id: checkId, exit_code: exitCode, bound_to_candidate: record.bound_to_candidate }); + return record; +} + +export function assessWorktree(root, feature, options = {}) { + const manifest = loadManifest(root, feature); + const dir = path.join(featureDir(root, feature), "assess", "worktree"); + if (options.remove) { + git(root, ["worktree", "remove", "--force", dir]); + appendEvent(root, feature, "assess_worktree_remove", {}); + return { removed: true }; + } + if (!manifest.repository.candidate_commit) fail("NO_CANDIDATE", "No candidate snapshot exists; reach CANDIDATE_READY first."); + if (fs.existsSync(dir)) fail("WORKTREE_EXISTS", `Assessment worktree already exists: ${dir}`); + git(root, ["worktree", "add", "--detach", dir, manifest.repository.candidate_commit]); + appendEvent(root, feature, "assess_worktree_add", { candidate_commit: manifest.repository.candidate_commit }); + return { path: dir, candidate_commit: manifest.repository.candidate_commit, candidate_tree: manifest.repository.candidate_tree }; +} + +const RUN_DIR_KEYS = { plan: "plan_version", loop: "loop_run", assessment: "assessment_run" }; + +export function runBegin(root, feature, phase) { + const group = phase === "plan" ? "plan" : phase === "loop" ? "loop" : phase === "assess" ? "assessment" : null; + if (!group) fail("INVALID_PHASE", `run begin phase must be plan|loop|assess, got: ${phase}`); + return withLock(root, feature, () => { + loadManifest(root, feature); + const parent = path.join(featureDir(root, feature), phase === "assess" ? "assess" : phase); + const existing = fs.readdirSync(parent).filter((f) => !f.startsWith(".")).length; + const runId = phase === "plan" ? `v${String(existing + 1).padStart(3, "0")}` : `run-${String(existing + 1).padStart(3, "0")}`; + fs.mkdirSync(path.join(parent, `${runId}.partial`), { recursive: true }); + appendEvent(root, feature, "run_begin", { phase, run_id: runId }); + return { phase, run_id: runId, dir: path.relative(root, path.join(parent, `${runId}.partial`)) }; + }); +} + +export function runPublish(root, feature, phase, runId) { + const key = phase === "plan" ? "plan_version" : phase === "loop" ? "loop_run" : phase === "assess" ? "assessment_run" : null; + if (!key) fail("INVALID_PHASE", `run publish phase must be plan|loop|assess, got: ${phase}`); + return withLock(root, feature, () => { + const manifest = loadManifest(root, feature); + const parent = path.join(featureDir(root, feature), phase === "assess" ? "assess" : phase); + const partial = path.join(parent, `${runId}.partial`); + const final = path.join(parent, runId); + if (!fs.existsSync(partial)) fail("MISSING_RUN", `No partial run directory: ${path.relative(root, partial)}`); + if (fs.existsSync(final)) fail("RUN_EXISTS", `Run already published: ${path.relative(root, final)}`); + fs.renameSync(partial, final); + manifest.current[key] = runId; + saveManifest(root, feature, manifest); + appendEvent(root, feature, "run_publish", { phase, run_id: runId }); + return { phase, run_id: runId, dir: path.relative(root, final) }; + }); +} + +const NEXT_COMMANDS = { + NEW: "transition PLANNING", + PLANNING: "produce plan artifacts, then transition PLAN_READY", + WAITING_FOR_USER_PLAN: "question answer/waive, then transition PLANNING", + PLAN_READY: "challenge plan or skip, then transition PLAN_APPROVED", + PLAN_APPROVED: "protect acceptance tests, then transition LOOP_RUNNING", + LOOP_RUNNING: "implement, then transition CANDIDATE_READY", + BLOCKED_SPEC: "transition PLANNING (amend)", + BLOCKED_ORACLE: "transition PLANNING (amend)", + BLOCKED_ENVIRONMENT: "fix environment, then transition LOOP_RUNNING", + CANDIDATE_READY: "challenge loop or skip, then transition ASSESSING", + ASSESSING: "run-check required checks, then transition ASSESSMENT_READY", + WAITING_FOR_USER_ASSESS: "question answer/waive, then transition ASSESSING", + CHANGES_REQUIRED: "transition LOOP_RUNNING (repair, consume budget)", + ASSESSMENT_READY: "challenge assess or skip, then transition ASSESSMENT_ACCEPTED", + ASSESSMENT_ACCEPTED: "transition RELEASING", + RELEASING: "review ingest, then transition CLOSED", + WAITING_FOR_USER_RELEASE: "question answer/waive, then transition RELEASING", + RELEASE_BLOCKED: "transition PLANNING or stop", + BUDGET_EXHAUSTED: "grant budget, then transition back to the owning phase", + CLOSED: "unprotect, append memory observations", +}; + +export function status(root, feature) { + const manifest = loadManifest(root, feature); + const profile = loadProfile(manifest.feature.depth, manifest.feature.domains); + const budgets = {}; + for (const counter of BUDGET_COUNTERS) budgets[counter] = budgetRemaining(manifest, counter); + return { + feature: manifest.feature, + state: manifest.workflow.state, + allowed_next: TRANSITIONS[manifest.workflow.state], + required_next: NEXT_COMMANDS[manifest.workflow.state], + repository: manifest.repository, + current: manifest.current, + budgets, + required_check_ids: profile.required_check_ids, + unresolved_blocking: unresolvedBlocking(manifest), + open_questions: manifest.questions.open, + options: manifest.options, + challenges: manifest.challenges, + release: manifest.release, + }; +} + +function parseArgs(argv) { + const positional = []; + const flags = {}; + let passthrough = null; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === "--") { + passthrough = argv.slice(i + 1); + break; + } + if (argv[i].startsWith("--")) { + const key = argv[i].slice(2); + flags[key] = argv[i + 1] !== undefined && !argv[i + 1].startsWith("--") ? argv[++i] : true; + } else positional.push(argv[i]); + } + return { positional, flags, passthrough }; +} + +function main() { + const { positional, flags, passthrough } = parseArgs(process.argv.slice(2)); + const [command, ...args] = positional; + const root = path.resolve(flags.root ?? process.cwd()); + + const run = () => { + switch (command) { + case "init": + return init(root, args[0], { + depth: flags.depth, title: flags.title, protection: flags.protection, + domains: flags.domains ? flags.domains.split(",") : [], + challenges: flags.challenges ? flags.challenges.split(",") : [], + testCommand: flags["test-command"], allowDirty: flags["allow-dirty"] === true, + models: flags.models ? JSON.parse(flags.models) : {}, + }); + case "status": return status(root, args[0]); + case "transition": return transition(root, args[0], args[1], { resultFile: flags.result }); + case "freeze": return freeze(root, args[0], args[1], (flags.files ?? "").split(",").filter(Boolean)); + case "protect": return protect(root, args[0], args.slice(1)); + case "unprotect": return unprotect(root, args[0]); + case "verify": return verify(root, args[0]); + case "budget": return consumeBudget(root, args[0], args[1]); + case "grant": return grantBudget(root, args[0], args[1], flags.n ? Number(flags.n) : 1, flags["authorized-by"]); + case "set-depth": return setDepth(root, args[0], args[1], { evidence: flags.evidence }); + case "unlock": return unlock(root, args[0], { stale: flags.stale === true, force: flags.force === true }); + case "event": return appendEvent(root, args[0], args[1], flags.data ? JSON.parse(flags.data) : {}); + case "run-check": return runCheck(root, args[0], args[1], passthrough ?? [], { cwd: flags.cwd, maxBytes: flags["max-bytes"] ? Number(flags["max-bytes"]) : undefined }); + case "assess-worktree": return assessWorktree(root, args[0], { remove: flags.remove === true }); + case "question": + if (args[0] === "open") return questionOpen(root, args[1], flags.file); + if (args[0] === "answer") return questionAnswer(root, args[1], args[2] ?? flags.answer); + if (args[0] === "waive") return questionWaive(root, args[1], args[2], flags["authorized-by"]); + return fail("USAGE", "question ..."); + case "challenge": + if (args[0] === "prepare") return challengePrepare(root, args[1], args[2]); + if (args[0] === "ingest") return challengeIngest(root, args[1], args[2], flags.response); + if (args[0] === "dispose") return challengeDispose(root, args[1], args[2], flags.file); + if (args[0] === "skip") return challengeSkip(root, args[1], args[2], flags.reason); + return fail("USAGE", "challenge ..."); + case "review": + if (args[0] === "ingest") return reviewIngest(root, args[1], flags.report); + return fail("USAGE", "review ingest --report FILE"); + case "waiver": + if (args[0] === "authorize") return waiverAuthorize(root, args[1], flags.file); + return fail("USAGE", "waiver authorize --file FILE"); + case "run": + if (args[0] === "begin") return runBegin(root, args[1], args[2]); + if (args[0] === "publish") return runPublish(root, args[1], args[2], args[3]); + return fail("USAGE", "run [run-id]"); + default: + return fail("USAGE", "Usage: tdd-engine.mjs ... [--root DIR]"); + } + }; + + try { + const output = run(); + process.stdout.write(`${JSON.stringify(output, null, 2)}\n`); + if (command === "verify" && !output.ok) process.exit(2); + } catch (error) { + process.stderr.write(`${JSON.stringify({ error: error.message, code: error.code ?? "ENGINE_ERROR" })}\n`); + process.exit(1); + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === new URL(import.meta.url).pathname) main(); diff --git a/agentic-tdd/core/templates/challenge-response.json b/agentic-tdd/core/templates/challenge-response.json new file mode 100644 index 0000000..14c9107 --- /dev/null +++ b/agentic-tdd/core/templates/challenge-response.json @@ -0,0 +1,26 @@ +{ + "id": "loop-run-001", + "phase": "loop", + "verdict": { + "value": "NO_BLOCKERS", + "confidence": null + }, + "findings": [ + { + "id": "F-001", + "severity": "major", + "claim": "Precise falsifiable statement.", + "evidence": [], + "discriminating_check": null + } + ], + "missing_evidence": [], + "questions_for_user": [], + "provenance": { + "provider": null, + "model": null, + "harness": null, + "packet_hash": null, + "prompt_hash": null + } +} diff --git a/agentic-tdd/core/templates/finding.json b/agentic-tdd/core/templates/finding.json new file mode 100644 index 0000000..fe549e2 --- /dev/null +++ b/agentic-tdd/core/templates/finding.json @@ -0,0 +1,25 @@ +{ + "id": "A-001", + "phase": "assess", + "finder_role": "tester-qa", + "severity": "major", + "class": "implementation", + "claim": "Precise falsifiable statement.", + "violated_requirement": { + "artifact": "contract.json", + "clause": "INV-001" + }, + "evidence": { + "repository_locations": [], + "commands": [], + "traces": [], + "sources": [] + }, + "minimal_counterexample": null, + "discriminating_check": { + "type": "replay", + "description": "Exact check that would confirm or reject the claim." + }, + "route": "LOOP", + "status": "OPEN" +} diff --git a/agentic-tdd/core/templates/phase-result.json b/agentic-tdd/core/templates/phase-result.json new file mode 100644 index 0000000..b683995 --- /dev/null +++ b/agentic-tdd/core/templates/phase-result.json @@ -0,0 +1,28 @@ +{ + "phase": "plan", + "run_id": "plan-v001", + "status": "PLAN_READY", + "requested_transition": "PLAN_READY", + "transition_reason": { + "class": "phase_complete", + "explanation": "Display-only prose; never drives decisions." + }, + "artifact_versions_read": {}, + "repository": { + "baseline_commit": null, + "candidate_commit": null + }, + "artifact_versions_written": [], + "evidence": { + "commands_run": [], + "summarized_results": [] + }, + "findings": [], + "user_questions": { + "unresolved_blocking": 0 + }, + "provenance": { + "model_or_agent": null, + "started_from_state": null + } +} diff --git a/agentic-tdd/core/templates/question.json b/agentic-tdd/core/templates/question.json new file mode 100644 index 0000000..dc29f0b --- /dev/null +++ b/agentic-tdd/core/templates/question.json @@ -0,0 +1,15 @@ +{ + "id": "UQ-P-001", + "phase": "plan", + "class": "BLOCKING_DOMAIN", + "question": "Precise decision requested from the user.", + "why_it_matters": "Effect on contract, implementation, tests, risk, or release.", + "options": [ + { "id": "option-a", "consequence": "Consequence A" }, + { "id": "option-b", "consequence": "Consequence B" } + ], + "recommended_option": "option-a", + "recommendation_basis": [], + "affected_artifacts": [], + "consequence_if_unanswered": "planning_stops" +} diff --git a/agentic-tdd/core/templates/release-decision.json b/agentic-tdd/core/templates/release-decision.json new file mode 100644 index 0000000..e39f36b --- /dev/null +++ b/agentic-tdd/core/templates/release-decision.json @@ -0,0 +1,24 @@ +{ + "feature": "my-feature", + "candidate_commit": null, + "assessment_run": null, + "reviewer_report": null, + "decision": "RELEASE_BLOCKED", + "blockers": [], + "waivers": [ + { + "id": "W-001", + "finding": null, + "authorized_by": "user-or-owner", + "rationale": null, + "scope": null, + "expires": "YYYY-MM-DD", + "monitoring": null, + "follow_up_issue": null + } + ], + "rollout_plan": null, + "rollback_plan": null, + "monitoring_plan": null, + "closed_at": null +} diff --git a/agentic-tdd/core/templates/reviewer-report.json b/agentic-tdd/core/templates/reviewer-report.json new file mode 100644 index 0000000..2931f5b --- /dev/null +++ b/agentic-tdd/core/templates/reviewer-report.json @@ -0,0 +1,14 @@ +{ + "decision": "INSUFFICIENT_EVIDENCE", + "candidate_tree": "exact tree hash from engine status", + "assessment_run": null, + "integrity_checks": [], + "evidence_gaps": [], + "scope_deviations": [], + "residual_risks": [], + "routed_findings": [], + "provenance": { + "model_or_agent": null, + "fresh_context": true + } +} diff --git a/mft-research-experts/agents/mft-strategist.md b/mft-research-experts/agents/mft-strategist.md index 24ec4de..af8f430 100644 --- a/mft-research-experts/agents/mft-strategist.md +++ b/mft-research-experts/agents/mft-strategist.md @@ -35,7 +35,14 @@ An idea without a mechanism is a lottery ticket. A mechanism without validation ## Alpha Squad Delegation -You don't just "deploy Alpha Squad." You orchestrate within it: +You don't just "deploy Alpha Squad." You orchestrate within it. Inline this packet into every spawned agent's task: + +``` +Question (verbatim): [do not redefine] +Your probe: [one assumption to test] +Report back: finding + type (observed/derived/estimated/speculation) + + contradicting evidence + UNRESOLVED allowed +``` **Phase 1: Parallel Brainstorm** — Deploy relevant squad members simultaneously based on hypothesis type: @@ -77,6 +84,7 @@ You don't just "deploy Alpha Squad." You orchestrate within it: 1. "What's the scope?" - MVP / Full build / Improve existing / Brainstorm 2. "What's the core hypothesis?" - edge, mechanism, data available 3. "What would make you abandon this?" +4. "What decision does this inform?" - no outcome changes it → don't research it **Only proceed after mode is clear.** @@ -99,11 +107,16 @@ You dig deep by default. You: 6. **Cross-Pollinate (Phase 2)** - route outputs for squad debate 7. **Internal Gate (Phase 3)** - causal-detective validates mechanism 8. **ASK USER** - "Squad produced [N] hypotheses. [X] passed causal gate, [Y] killed. Review before validation?" -9. **Deploy Factor Geometer** - exposure check, alpha-orthogonal decomposition -10. **Deploy Skeptic** - full causal + statistical gauntlet -11. **Synthesize** - SHIP / KILL / ITERATE with explicit reasoning -12. **Trigger Forensic Auditor** - on schedule and on anomaly -13. **Present** - to user with full analysis and recommendations +9. **Bird's-eye** - before digging deeper or adding agents; any flag → re-frame, don't patch: + - Same decision as framed? Hypothesis set changed? + - Evidence against current favorite: [empty list = red flag] + - Duplicate probes across agents: [count once] + - Next probe worth its cost? [info gain vs budget left] +10. **Deploy Factor Geometer** - exposure check, alpha-orthogonal decomposition +11. **Deploy Skeptic** - full causal + statistical gauntlet +12. **Synthesize** - SHIP / KILL / ITERATE / UNRESOLVED with explicit reasoning; send final report + original request to skeptic for acceptance pass before delivery +13. **Trigger Forensic Auditor** - on schedule and on anomaly +14. **Present** - to user with full analysis and recommendations ## Decision Points → USER @@ -167,6 +180,7 @@ flowchart TD ``` Strategic Assessment: [topic] Venue Context: [from /venue-expert] +Trial Ledger: [Σ tests attempted across all agents — deflation input] Research Question: [Refined after user dialogue] @@ -198,8 +212,9 @@ Synthesis: --- [Mathematics of models used] -VERDICT: SHIP / KILL / ITERATE +VERDICT: SHIP / KILL / ITERATE / UNRESOLVED Reasoning: [explicit, tied to mechanism + evidence] +UNRESOLVED stays UNRESOLVED — never silently converted to SHIP or KILL Bias disclosure: [what preference is influencing this] If ITERATE: diff --git a/mft-research-experts/agents/skeptic.md b/mft-research-experts/agents/skeptic.md index 11ef70e..24cdb35 100644 --- a/mft-research-experts/agents/skeptic.md +++ b/mft-research-experts/agents/skeptic.md @@ -46,6 +46,8 @@ Unified validation engine. You prove mechanisms (causal mode) AND test statistic | Transaction Cost Survival | Does net SR survive realistic tcosts? | Net SR < 0.3 | | Factor-Neutral Check | Does alpha-orthogonal SR hold up? | α⊥ SR < 0.4 × raw SR | +Deflated Sharpe N = strategist's Trial Ledger Σ M. No ledger → assume N large, haircut hard. + ## Unified Protocol 1. Receive hypothesis from Alpha Squad (with mechanism diagram) @@ -55,6 +57,7 @@ Unified validation engine. You prove mechanisms (causal mode) AND test statistic 5. If PASS statistical → **SHIP** with confidence intervals and caveats 6. If FAIL either → **KILL** with specific failure point and "what would change our mind" 7. If PARTIAL → **ITERATE** with required fixes +8. **Acceptance pass** (when strategist sends a final report): audit vs original request, requirement by requirement — met / partial / missing; list claims without locators ## Depth Preference