merge: wave 1 order 3 (plan and design) into main

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-06 20:36:31 +02:00
commit 885d515c3b
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
24 changed files with 3115 additions and 61 deletions

View file

@ -28,7 +28,7 @@ Voyage — a contract-driven Claude Code pipeline: brief, research, plan, execut
|---------|-------------|--------------------|
| `/trekbrief` | Brief — interactive interview produces a task brief with explicit research plan; ends with the operator approving `## Intent` + `## Goal` (`--approve <dir>` does only that, then writes the trekbrief record); optionally orchestrates the pipeline | session |
| `/trekresearch` | Research — deep local + external research, produces structured research brief. Opt-in `--engine {swarm\|deep-research}` delegates the external phase to Claude Code's built-in `/deep-research` workflow (swarm default) | session |
| `/trekplan` | Plan — brief-reviewer, explore, plan, review. Requires `--brief` or `--project`; halts unless the brief's intent is approved (hash-bound, stale on edit). Auto-discovers `architecture/overview.md` if present | session |
| `/trekplan` | Plan — brief-reviewer, explore, plan, review. Requires `--brief` or `--project`; halts unless the brief's intent is approved (hash-bound, stale on edit). Auto-discovers `architecture/overview.md` if present. Plan 1.8: `Test first:` per step + one `End-to-end:` step; a design part for `ceremony: architectural` (absent = architectural); critic loop of max 3 rounds, recorded in the plan, and `plan-gate.mjs` (0 go / 1 stop / 2 could not run) decides | session |
| `/trekexecute` | Execute — disciplined plan/session-spec executor with failure recovery | session |
| `/trekreview` | Review — a gate: Claude Code's `/code-review` + `/security-review` as engine, then Voyage's brief control (which stops); writes `review.md` with a required verdict that `lib/review/verdict-gate.mjs` decides on (0 ALLOW/WARN · 1 BLOCK · 2 never green); proposes recurring finding classes as project memory, line by line (Handover 6) | session |
| `/trekcontinue` | Continue — resumes the next session of a multi-session voyage project. Reads `.session-state.local.json` (Handover 7) and immediately begins executing | session |
@ -78,7 +78,7 @@ Full flag reference for each command (modes, `--gates`, `--profile`, breaking ch
| brief-conformance-reviewer | opus | Brief conformance review (SC + Non-Goal traceability) |
| code-correctness-reviewer | opus | Code correctness review (7 dimensions) |
| review-coordinator | opus | Judge Agent — dedup + reasonableness filter + verdict |
| plan-critic | opus | Adversarial plan review (10 dimensions) |
| plan-critic | opus | Adversarial plan review (12 dimensions) |
| scope-guardian | opus | Scope alignment (creep + gaps) |
| session-decomposer | opus | Splits plans into headless sessions with dependency graph |
| synthesis-agent | opus | Distills Phase-5/7 exploration outputs into a findings digest (NW3 PoC — **dormant**, not wired; delegating Phase 7 alone yields Δ main-context ≈ 0, see `docs/T1-synthesis-poc-results.md`) |

View file

@ -243,7 +243,7 @@ Research uses up to 5 local agents (architecture-mapper, dependency-tracer, task
Produces an implementation plan detailed enough for autonomous execution. **v2.0 breaking change:** requires `--brief` or `--project`. There is no longer an interview inside `/trekplan` — use `/trekbrief` first.
After `brief-reviewer` validates the input brief, 6–8 exploration agents analyze the codebase in parallel and merge findings into a synthesis. Optional research briefs (`--research`, or auto-discovered in `{project_dir}/research/`) enrich the plan; `architecture/overview.md` priors are loaded if an opt-in upstream architect plugin (not bundled) produced one. Opus then writes the plan with per-step YAML manifests, which `plan-critic` (10 dimensions) and `scope-guardian` adversarially review before handoff.
After `brief-reviewer` validates the input brief, 6–8 exploration agents analyze the codebase in parallel and merge findings into a synthesis. Optional research briefs (`--research`, or auto-discovered in `{project_dir}/research/`) enrich the plan; `architecture/overview.md` priors are loaded if an opt-in upstream architect plugin (not bundled) produced one. Opus then writes the plan with per-step YAML manifests, which `plan-critic` (12 dimensions) and `scope-guardian` adversarially review in a bounded loop of at most 3 rounds before handoff.
Output:
- With `--project <dir>`: `{dir}/plan.md`
@ -271,6 +271,8 @@ Output:
Every plan includes:
- **Context** — derived from brief `## Intent` + `## Goal`
- **Global Constraints** — the brief's constraints that bind several steps, quoted verbatim
- **Design** — only when the brief's `ceremony` is `architectural` (absent = architectural): architecture, data flow, interfaces, data models, error handling, a module map with no cycles, architecture rules that each name their enforcer, and a requirements check asked as keep / change questions
- **Architecture Diagram** — Mermaid C4-style component diagram
- **Codebase Analysis** — tech stack, patterns, relevant files, reusable code
- **Research Sources** — findings from research briefs (when present)
@ -281,14 +283,25 @@ Every plan includes:
- **Risks and Mitigations** — from risk-assessor findings
- **Verification** — testable end-to-end criteria
- **Execution Strategy** — session grouping and parallel waves (plans with > 5 steps)
- **Plan Quality Score** — quantitative grade (A-D) across 6 weighted dimensions
- **Plan Quality Score** — quantitative grade (A-D) across 7 weighted dimensions
- **Critic Verdict** — the last critic round's machine-readable verdict, written by `plan-gate.mjs --record`
Every implementation step includes:
- **Test first:** — the test the step makes pass (required from `plan_version 1.8`)
- **Interface in:** / **Interface out:** / **Review focus:** — optional: what the step consumes and produces, and the untested inputs it pins to a test
- **On failure:** — what to do when verification fails (revert / retry / skip / escalate)
- **Checkpoint:** — git commit after success
- **Manifest:** — the objective completion predicate (Hard Rule 17)
Exploration uses 6–8 agents in parallel (architecture-mapper, dependency-tracer, task-finder, test-strategist, git-historian, risk-assessor, plus convention-scanner on medium+ codebases and research-scout when unfamiliar tech is detected). Adversarial review then runs `brief-reviewer`, `plan-critic` (10 dimensions, no-placeholder enforcement, manifest audit), and `scope-guardian` (creep + gap detection). Per-agent details in [`agents/`](agents/).
One step per plan is the "run the thing" step (`End-to-end:`): the product run the way a user runs it. Without it a result is "not verified end to end", never green.
Exploration uses 6–8 agents in parallel (architecture-mapper, dependency-tracer, task-finder, test-strategist, git-historian, risk-assessor, plus convention-scanner on medium+ codebases and research-scout when unfamiliar tech is detected). Adversarial review then runs `brief-reviewer`, `plan-critic` (12 dimensions, no-placeholder enforcement, manifest audit, interfaces and slices, design and architecture rules), and `scope-guardian` (creep + gap detection, ceremony match). Per-agent details in [`agents/`](agents/).
**The plan gate.** Each critic round is written into the plan by `lib/validators/plan-gate.mjs --record`; `plan-gate.mjs <plan.md>` then exits 0 (go on), 1 (stop: invalid plan, no verdict, a verdict the plan has changed since, or an open BLOCKER) or 2 (could not run). After 3 rounds with a BLOCKER open, `/trekplan` stops and asks; it never continues silently, and "execute" is offered only when the gate exits 0. In Claude Code's plan mode the plan is written to Claude Code's plan file and approved through its own approval prompt (that `/trekplan` runs cleanly inside plan mode is not verified yet).
**Checks the plan hands on** (0 / 1 / 2, and 2 = "not measured", never green): `lib/verification/arch-rules.mjs --repo <dir>` runs the target repo's own dependency-cruiser or import-linter — Voyage installs nothing — and `--prove` shows a rule pass, fail on a planted violation, and pass again; `lib/verification/plan-scope-sweep.mjs --plan <plan.md> --base <ref>` lists changed files that no step names.
Borrowed, with credit: per-step interfaces, Global Constraints and Review Focus from [obra/superpowers](https://github.com/obra/superpowers) (MIT); the design sections and the bounded review loop from Kiro's documentation; the module map and "use the repo's own tool" from [addyosmani/agent-skills](https://github.com/addyosmani/agent-skills) (MIT); vertical slices, deep modules, the ADR bar and pass → fail → pass from [mattpocock/skills](https://github.com/mattpocock/skills) (MIT); rules that name their enforcer, adapted from [GitHub Spec Kit](https://github.com/github/spec-kit)'s constitution (MIT); the sweep for files outside the plan from [bashebr/ai-native-sdlc](https://github.com/bashebr/ai-native-sdlc) (MIT); the independent plan review and the run step from Boris Cherny's public Claude Code threads.
---

View file

@ -134,6 +134,12 @@ questions, which defeats the purpose.
- Are there assertions about behavior that have no corresponding test?
- Do the verification steps cover error paths, not just happy paths?
- Are the verification commands correct and runnable?
- `plan_version: 1.8`: does every step's `Test first:` name a test that would
FAIL before the step and pass after it (not a test that already passes)?
- Is there a "run the thing" step (`End-to-end:`), and does its command run
the product the way a user does — not a unit test renamed? A plan whose only
end-to-end check is a unit test is a **blocker**: its result could only be
"not verified end to end".
### 9. Headless readiness
@ -185,6 +191,59 @@ a single advisory note ("Plan is v1.6 legacy format — manifests will be
synthesized by trekexecute with reduced audit precision") and skip this
dimension's scoring.
### 11. Interfaces and slices
- Does every value a step consumes (`Interface in:`) come from an earlier
step's `Interface out:` or from existing code — same name, same type? A step
that consumes something nothing produces is a **blocker**.
- Do two steps produce conflicting versions of one interface?
- Is each step a vertical slice — a narrow but complete path through the layers
it touches, verifiable on its own? "All the tests, then all the code" or
"all the models, then all the handlers" is a **major** finding.
- Does preparatory refactoring come first, as its own steps? Does a wide change
go expand → migrate → contract?
- Are the brief's Constraints and NFRs that bind several steps quoted
**verbatim** in `## Global Constraints`, and quoted again in the
`Review focus:` of each step that could break one? A paraphrased constraint
whose exact value matters is a **major** finding.
- Does each `Review focus:` input name the test that pins it?
(Borrowed: obra/superpowers@8ca22db writing-plans — interfaces, Global
Constraints, Review Focus (MIT, © 2025 Jesse Vincent); mattpocock/skills@c55ee46
to-tickets — vertical slices (MIT, © 2026 Matt Pocock); GitHub Spec Kit —
constraints quoted into the task (MIT).)
### 12. Design and architecture rules
Applies when the plan's `ceremony` is `architectural` (or absent — absent
means architectural). For `small` and `bounded` plans, check only that the
ceremony matches the brief's.
- Does `## Design` answer, for each changed module: what does it do, how is it
used, what does it depend on — and can its internals change without breaking
its users? Would the deletion test keep it (deleting it would scatter real
complexity), or is it a shallow pass-through?
- Is the module map consistent with the steps — no step adds a dependency the
map does not show, and no edge points against the map's direction?
- Does every architecture rule name what enforces it ("checked by `<command>`"
the target repo actually has, or "advisory — `.claude/rules/<file>.md`"), and
does every checked rule's proving step really show pass → fail on a planted
violation → pass? A checked rule with no such proof is a **blocker**.
- Does any step break a rule the target repo already declares (its
`.claude/rules/`, CLAUDE.md, or the plan's own Architecture Rules) without a
written justification in `### Gotchas` or `### Decisions (ADR)`? Unjustified
is a **blocker**.
- Is each ADR earned (hard to reverse, surprising without context, a real
trade-off)? An ADR for an ordinary choice is a **minor** finding.
- Does the Requirements Check list the conflicts and gaps you can see between
the brief's requirements, each with a recorded keep / change answer?
(Borrowed: Kiro's design.md sections and requirements analysis, docs only;
obra/superpowers@8ca22db brainstorming design questions; mattpocock/skills@c55ee46
deep modules, the deletion test and the ADR bar; GitHub Spec Kit's
constitution check, changed so each rule names its enforcer;
addyosmani/agent-skills@9d0c60d module map (MIT, © 2025 Addy Osmani).)
## Rating system
Rate each finding:
@ -192,6 +251,15 @@ Rate each finding:
- **Major** — high risk of bugs, rework, or failure
- **Minor** — worth fixing but won't derail the implementation
Your rating decides what happens next: `/trekplan` records your findings with
`plan-gate.mjs --record`, and the plan gate stops the plan while any BLOCKER
is open. A finding with no valid severity counts as a blocker. You may be run
up to 3 times on successive revisions of the same plan; judge the plan in
front of you, and do not repeat a finding the revision has fixed.
Dimensions 11 and 12 are scored inside the existing weights: 11 under Step
quality, 12 under Coverage completeness and Specification quality.
## Plan scoring
After reviewing all findings, produce a quantitative score:
@ -274,7 +342,8 @@ this block inline *is* the hand-off; do not attempt to write a file.
One object per finding. `file`/`line` point at the plan section (use the plan
path + the step's line) or the source the finding concerns; `rule_key` is a
short, stable id for the finding class (used for exact-match dedup). Emit
short, stable id for the finding class (used for exact-match dedup).
`severity` is exactly one of `blocker`, `major`, `minor`. Emit
`"findings": []` when the plan is clean.
Be specific. Reference exact plan sections, step numbers, and file paths.

View file

@ -215,10 +215,14 @@ Write a comprehensive implementation plan including:
criterion must be an executable command or observable condition
- **Estimated Scope** — file counts and complexity
**Plan-version header:** Include `plan_version: 1.7` in the metadata line below
the title. This signals to trekexecute that the plan includes per-step
verification manifests and enables strict audit mode. Plans without this
marker are treated as legacy v1.6 with synthesized minimal manifests.
**Plan-version header:** Include `plan_version: 1.8` and the brief's
`ceremony:` in the metadata line below the title. This signals to trekexecute
that the plan includes per-step verification manifests and enables strict
audit mode. Plans without this marker are treated as legacy v1.6 with
synthesized minimal manifests. 1.8 adds `Test first:` on every step, one
`End-to-end:` step, the optional `Interface in:` / `Interface out:` /
`Review focus:` fields and, for an architectural plan, `## Design`
(`commands/trekplan.md` Phase 8 is the full text). 1.7 plans stay valid.
### Mandatory step format — copy this exactly
@ -331,7 +335,9 @@ Internally the validator enforces (same checks as before, now in one place):
- Step count == manifest count
- Zero forbidden narrative headings (`### Fase N`, `### Phase N`, `### Stage N`,
`### Steg N`)
- `plan_version: 1.7` declared (warning only if older / missing)
- `plan_version` 1.7 or 1.8 declared (warning only if older / missing)
- From 1.8: `Test first:` per step, an `End-to-end:` step, non-empty optional
fields, a valid `ceremony`, and the design part for an architectural plan
Each error has a `code` field — read these to localize the fix. Common codes:
- `PLAN_FORBIDDEN_HEADING` — narrative drift; rewrite the section using the
@ -425,6 +431,14 @@ merge. The merged array attributes each finding to `[plan-critic, scope-guardian
if both reviewers raised it. Revise the plan once for the merged set, not
twice for the duplicates. Source: research/05 R1 + R2.
The dedup output is piped on to `lib/validators/plan-gate.mjs --record
<plan> --reviewed <hash>` (the hash `--hash` printed before the reviewers read
the plan), which writes the round's verdict into the plan's `## Critic Verdict`.
This is a loop of at most 3 rounds: revise, then review again; after round 3
with a BLOCKER open, stop and ask the operator. `plan-gate.mjs <plan>` (K3)
exits 0 only for a valid plan whose current text carries a verdict with no
open BLOCKER (`commands/trekplan.md` Phase 9 is the full text).
After both complete:
- Address all blockers and major issues by revising the plan
- **Manifest quality is a hard gate:** any manifest-related `major` finding

View file

@ -72,6 +72,23 @@ Evaluate:
- A critical migration should not have only 3 steps
- Does the estimated scope (file count, complexity) match the actual plan?
### 6. Ceremony and design scope
- Does the plan's `ceremony:` (header line) equal the brief's `ceremony:`
frontmatter? An absent field means `architectural` on both sides. A plan made
lighter than its brief (e.g. `small` for an `architectural` brief, so no
design part) is a **blocker**.
- For an architectural plan: does `## Design` stay inside the brief — no module,
interface or rule that serves no requirement (creep), and no requirement the
design leaves out (gap)?
- Does the Requirements Check record a "change" answer that alters the brief's
Intent, Goal or Success Criteria? That cannot be settled in the plan: it is a
**blocker** until the brief is re-run through `/trekbrief`.
Your findings go through `plan-gate.mjs --record` with plan-critic's; an open
blocker stops the plan. `severity` is exactly `blocker`, `major` or `minor`;
an unrated finding counts as a blocker.
## Output format
```

View file

@ -580,6 +580,8 @@ Do NOT write this synthesis to disk. It is internal working context only.
> - **Files:** `src/middleware/jwt.ts`
> - **Changes:** Create new middleware function `verifyJWT(req, res, next)` that reads `Authorization: Bearer <token>` header, verifies signature with `process.env.JWT_SECRET`, attaches decoded payload to `req.user`, and returns 401 on invalid/missing token. (new file)
> - **Reuses:** `jsonwebtoken.verify()` (already in package.json), pattern from `src/middleware/cors.ts`
> - **Interface out:** `verifyJWT(req, res, next)`; sets `req.user = {id, roles}` — read by Step 2
> - **Review focus:** an expired token and a token signed with another key — pinned by `src/middleware/jwt.test.ts`
> - **Test first:**
> - File: `src/middleware/jwt.test.ts` (new)
> - Verifies: valid token attaches user; invalid token returns 401; missing header returns 401
@ -604,6 +606,14 @@ Do NOT write this synthesis to disk. It is internal working context only.
> ```
> ````
>
> **plan_version 1.8 additions** (the validator enforces them; 1.7 plans stay
> valid): `Test first:` with `File:` and `Verifies:` on EVERY step; exactly the
> optional fields `Interface in:`, `Interface out:`, `Review focus:` (never
> empty when present); at least one step with `End-to-end:` and a backticked
> command — the "run the thing" step; the header line carries
> `` `plan_version: 1.8` · `ceremony: <class>` ``; an architectural plan carries
> `## Design` (see "Design part" below).
>
> **Validator self-check (mandatory after writing `plan.md`):** run
> `node ${CLAUDE_PLUGIN_ROOT}/lib/validators/plan-validator.mjs --strict --json {plan_path}`
> and re-revise the plan if it fails. The validator is the source of truth for
@ -614,12 +624,20 @@ Do NOT write this synthesis to disk. It is internal working context only.
Read the brief file (from `--brief` or `--project`).
Read the plan template: @${CLAUDE_PLUGIN_ROOT}/templates/plan-template.md
**Ceremony.** Read `ceremony:` from the brief's frontmatter: `small`,
`bounded` or `architectural`. Absent means `architectural` (heaviest on doubt).
Write it into the plan's header line. Only `architectural` gets the design part.
Write the plan following the template structure. The plan MUST include:
### Required sections
1. **Context** — Why this change is needed. Use the brief's **Intent** verbatim
or tightly paraphrased. The plan's motivation must trace directly to the brief.
**Global Constraints** follows it: the brief's Constraints and NFRs that bind
more than one step, quoted verbatim (exact values, never paraphrased).
**Design** follows that, for `ceremony: architectural` only — see "Design
part" below.
2. **Codebase Analysis** — Tech stack, patterns, relevant files, reusable code,
external tech researched. Every file path must be real (verified during exploration).
3. **Research Sources** — If any research briefs or research-scout was used: table
@ -630,8 +648,27 @@ Write the plan following the template structure. The plan MUST include:
- Which existing code to reuse
- Dependencies on other steps
- Whether the step is based on codebase analysis or external research
- **Test first:** — the test file and what it verifies, on EVERY step
- **Interface in:** / **Interface out:** — what the step consumes (and which
earlier step or existing code produces it) and what it produces for later
steps. Write them whenever a step hands something to another step; a
step's `Interface in:` must match an earlier `Interface out:`.
- **Review focus:** — inputs the brief implies but no test exercises yet,
each pinned to a test; and any Global Constraint the step could break,
quoted verbatim.
- **On failure:** — recovery action (revert/retry/skip/escalate)
- **Checkpoint:** — git commit command after success
Steps are vertical slices: each cuts a narrow but complete path through the
layers it touches and is verifiable on its own. Never "all the tests, then
all the code". Preparatory refactoring comes first as its own steps; a wide
change goes expand → migrate → contract.
**The "run the thing" step.** At least one step carries
`End-to-end: \`<command>\` → expected: <what the user sees>`: the product run
the way a user runs it (the CLI, the server and a request, the app; for iOS,
the simulator). Without it the result is "not verified end to end", never
green.
10. **Execution Strategy** — For plans with > 5 steps: group steps into sessions
(3–5 steps each), organize sessions into waves (parallel where independent),
specify scope fences per session. Omit for plans with ≤ 5 steps.
@ -680,6 +717,51 @@ Write the plan following the template structure. The plan MUST include:
- Research-based decisions must cite their source
- Every implementation decision must be traceable to a brief section (Intent,
Goal, Constraint, Preference, NFR, or Success Criterion)
- Interfaces are consistent across steps: nothing is consumed before a step or
existing code produces it, under the same name and type
Borrowed, with credit: the per-step interfaces, Global Constraints quoted
verbatim and Review Focus from obra/superpowers@8ca22db writing-plans (MIT,
© 2025 Jesse Vincent); quoting constraints into the step from GitHub Spec Kit
(MIT); vertical slices and prefactoring first from mattpocock/skills@c55ee46
to-tickets (MIT, © 2026 Matt Pocock); "plan the verification too" — the run
step — from Boris Cherny's public Claude Code threads; test-first as a rule
from Spec Kit, made a validator check here instead of a rule the model ticks.
### Design part (ceremony: architectural)
Read the design template: @${CLAUDE_PLUGIN_ROOT}/templates/design-template.md
Write `## Design` between `## Global Constraints` and `## Implementation Plan`
with the template's subsections: Architecture, Data Flow, Interfaces, Data
Models, Error Handling, Module Map, Architecture Rules, Requirements Check
(Decisions (ADR) and Gotchas only when they apply). The test strategy is the
plan's own `## Test Strategy`.
- **Derived from:** the brief path and the first 12 hex of its sha256
(`shasum -a 256 "{brief_path}"`).
- **Module map:** a mermaid graph; dependencies point one way, no cycle.
- **Architecture rules:** first see what the target repo can check, read-only:
```bash
node ${CLAUDE_PLUGIN_ROOT}/lib/verification/arch-rules.mjs --repo .
```
Exit 0 or 1: the repo has dependency-cruiser or import-linter configured with
a local binary, so a rule can be "checked by" it. Exit 2: nothing to check
with — a rule is then "advisory — `.claude/rules/<file>.md`" (a plan step
writes that file), or "checked by" a test or linter the repo already has.
Voyage installs nothing; adding a tool is a plan step the operator approves.
Every checked rule names the step (Proof column) that shows it pass, fail on
a planted violation and pass again. For the two supported tools that step's
Verify is
`node ${CLAUDE_PLUGIN_ROOT}/lib/verification/arch-rules.mjs --repo . --prove --rule <rule name> --plant <violation file>=<dest>`
(exit 0 = proven: the planted run tripped THAT rule; the plant is removed
afterwards, and is never written over an existing path or outside the repo).
- **Requirements Check:** compare the brief's requirements with each other.
For each conflict or gap ask the operator ONE two-choice question with
AskUserQuestion, recommendation first: "keep" (as written) or "change like
this" (a concrete proposal). Record the answers in the table. If an answer
changes the brief's Intent, Goal or Success Criteria, stop: the brief is
re-run through `/trekbrief` — the planner never edits it.
### Write the plan
@ -689,9 +771,30 @@ Use the plan destination computed in Phase 3:
Create the parent directory if it does not exist.
## Phase 9 — Adversarial review
**In plan mode** (Claude Code has told this session it is in plan mode and
named its plan file): edits outside that plan file are blocked until the
operator approves, so write the plan to Claude Code's plan file instead and run
Phases 9 and 10 against it. Phase 10 copies it to the destination after the
approval.
Launch two review agents **in parallel — emit both Agent tool calls in a
## Phase 9 — Adversarial review (bounded critic loop)
Phase 9 is a loop of at most **3 critic rounds**. Each round reviews the plan
as it stands, and a command — not this session's judgment — records the
verdict and decides whether the plan may go on. The loop ends on a round with
no BLOCKER; after round 3 with a BLOCKER still open it stops and asks the
operator. It never goes on silently.
**One round:**
First take the hash of the plan the reviewers are about to read, and keep the
printed value for this round:
```bash
node ${CLAUDE_PLUGIN_ROOT}/lib/validators/plan-gate.mjs --hash "{plan_path}"
```
Then launch two review agents **in parallel — emit both Agent tool calls in a
single assistant message turn** (same pattern as Phase 5 exploration). They
have zero data dependencies; serializing them wastes 30–60 seconds per run.
@ -717,31 +820,77 @@ tool — return the block inline, do not write a file."
After both complete, **extract each reviewer's trailing `json` findings block**
and pipe both into the dedup helper via **stdin** — the reviewers are read-only
(`Read/Glob/Grep`, no `Write`), so they cannot persist temp files; the
orchestrator does the persistence by piping their inline blocks:
orchestrator does the persistence by piping their inline blocks. The dedup
output goes straight on to the plan gate, which writes this round's verdict
into the plan's `## Critic Verdict` section:
```bash
node ${CLAUDE_PLUGIN_ROOT}/lib/review/plan-review-dedup.mjs --stdin <<'JSON'
node ${CLAUDE_PLUGIN_ROOT}/lib/review/plan-review-dedup.mjs --stdin <<'JSON' | node ${CLAUDE_PLUGIN_ROOT}/lib/validators/plan-gate.mjs --record "{plan_path}" --reviewed {the hash from --hash}
{ "plan_critic": <plan-critic's json block>, "scope_guardian": <scope-guardian's json block> }
JSON
```
`plan-gate.mjs --record` prints one JSON line: `round`, `verdict`
(`BLOCK` = a blocker is open, `REVISE` = majors only, `PASS`) and `counts`.
Exit 1 means the round cap is spent (see "After round 3"). Exit 2 means the
round cannot be recorded: broken input, a reviewer missing from it (an absent
review is not a clean one), or a plan that changed after `--hash` — the
verdict would bless text nobody reviewed. Show the message and rerun the round
on the plan as it is; never record around it.
The helper **exits non-zero on malformed stdin** — a broken hand-off surfaces
loudly instead of collapsing into a silently-empty merge (the historical Phase-9
defect, where the read-only reviewers never wrote the files and the dedup ran on
nothing). The merged array attributes each finding to `[plan-critic, scope-guardian]`
when both reviewers raised the same issue (exact match on `file:line:rule_key`,
or Jaccard ≥ 0.7 on text tokens). Revise the plan once for the merged set, not
twice for the duplicates. Source: research/05 R1 + R2.
or Jaccard ≥ 0.7 on text tokens), and keeps the more severe of the two
severities. Revise the plan once for the merged set, not twice for the
duplicates. Source: research/05 R1 + R2.
After both complete:
- If **blockers** are found: revise the plan to address them. Add a "Revisions"
note at the bottom of the plan listing what changed and why.
- If only **major** issues: revise to address them. Add revisions note.
- If only **minor** issues or clean: proceed without changes. Note the
review result in the plan.
**After each round**, by the recorded verdict:
- **BLOCK** or **REVISE**, rounds left: revise the plan for the merged
findings, add rows to `## Revisions` (what changed and why), and run the
next round. Any edit outside the review log — a step, the design, the
verification — makes the recorded verdict stale, so a revised plan is always
re-reviewed.
- **PASS** (minors only, or clean): fill in the quality header and
`## Plan Quality Score` from plan-critic's score. These and `## Revisions`
are the review log and do not stale the verdict. Go to Phase 10.
- **REVISE after the last round**: do not edit the plan again (that would
stale the verdict); carry the open majors into the Phase 10 summary as known.
**After round 3 with a BLOCKER open**, `plan-gate.mjs` reports
`PLAN_GATE_ROUNDS_EXHAUSTED` and `--record` refuses a fourth round. Stop and ask
the operator with AskUserQuestion — the open blockers listed in plain words,
recommendation first:
- **One more round** — revise for the listed blockers, then record with
`--extra-round` (only after this yes; one yes = one round; the gate refuses
the flag before the cap is spent).
- **Stop here** — the plan is not ready; it stays saved with its verdict, and
`/trekexecute` is not offered.
Then check the plan with the gate (K3), which every later step relies on:
```bash
node ${CLAUDE_PLUGIN_ROOT}/lib/validators/plan-gate.mjs --json "{plan_path}"
```
Exit 0 = the plan validates, the verdict matches it, and no BLOCKER is open.
Exit 1 = stop; its `reasons` say why. Exit 2 = it could not read the plan.
Borrowed, with credit: the bounded review loop that ends only on a recorded
verdict, never on a claim in prose, from Kiro's `design-loop` workflow pattern
(kiro.dev, docs only); the independent staff-engineer review and "re-plan
instead of pushing on" from Boris Cherny's public Claude Code threads.
## Phase 10 — Present and refine
Run the gate (K3) on the plan first; its exit code decides what may be offered:
```bash
node ${CLAUDE_PLUGIN_ROOT}/lib/validators/plan-gate.mjs --json "{plan_path}"
```
Present a summary to the user:
```
@ -768,20 +917,60 @@ Present a summary to the user:
{Summary of external research + attached research briefs, or "No external research used."}
### Adversarial review
**Critic rounds:** {N} of {max} — last verdict {BLOCK | REVISE | PASS}
**Plan critic:** {Summary — blockers/majors/minors found, how addressed}
**Scope guardian:** {Summary — creep/gaps found, how addressed}
**Open majors:** {list, or "none"}
**Plan gate (K3):** {PASS | STOP — the reasons}
You can:
- Ask questions or request changes to refine the plan
- Say **"execute"** to start implementing
- Say **"execute"** to start implementing ← only when the gate exited 0
- Say **"save"** to keep the plan for later
```
When the gate exited 1, replace the "execute" line with
`Execution is blocked: {the gate's reasons}` and do not start implementing,
whatever is said next, until the gate exits 0.
**What counts as approval.** "Execute", "go" or "start" (or the plan-mode
approval below) approves the plan. A hedged reply — "sounds good", "looks
reasonable", "do what you think", "I guess" — is not approval: ask once,
plainly, whether to execute. (Borrowed: addyosmani/agent-skills@9d0c60d build
command, MIT, © 2025 Addy Osmani.)
If the user asks questions or requests changes:
- Update the plan file in-place
- Show what changed
- A change outside the review log makes the verdict stale: run one Phase 9
round on the changed plan (it counts toward the 3), then re-run the gate
- Re-present the summary
### In plan mode
When this session is in Claude Code's plan mode, the plan sits in Claude
Code's plan file (Phase 8), and approval goes through Claude Code's own
plan-approval prompt:
1. Run the gate on the plan file. Exit 1: do not present the plan for approval;
fix it (Phase 9) or, after round 3, ask the operator as Phase 9 says.
2. Exit 0: present the plan with `ExitPlanMode`. Claude Code shows the operator
the plan file and asks how to proceed. A hook on that approval (when one is
installed) runs the same gate on the plan file's text and can send a bad
plan back.
3. **Approved:** the session leaves plan mode. Copy the plan file to the plan
destination from Phase 3, then run the gate on the destination — it must
exit 0 again, which also proves the copy is the reviewed text. Then
continue with the operator's choice (Phase 11).
4. **"No, keep planning":** revise per the operator's words, run one Phase 9
round, and present again from step 1.
Without plan mode, all of the above happens in this chat, as described first.
The summary and the annotation HTML below are produced either way. **Not
verified:** that `/trekplan` runs end to end inside plan mode (exploration
agents, the gate's Bash calls and `--record` writing to the plan file all under
plan mode's restrictions). Until an operator has run it, both paths stay.
### Build the operator-annotation HTML and print the link
After the plan summary, run `scripts/annotate.mjs` to produce a
@ -871,7 +1060,8 @@ JSON
```
`slug` is the brief's `slug`, so this record pairs with the brief's
`brief-approved` and `trekbrief` records. The writer prints
`brief-approved` and `trekbrief` records. `critic_verdict` is the `verdict` of
the last round `plan-gate.mjs --record` wrote into the plan. The writer prints
`{"written": true, ...}`. On `"written": false` (exit 1), print its `reason` in
one line and continue. A tracking failure never blocks the main workflow, but
it is reported, not skipped silently.
@ -974,7 +1164,10 @@ Standard and low effort: do NOT emit the section at all.
brief (Intent, Goal, Constraint, Preference, NFR, Success Criterion). If a
step has no brief basis, it is scope creep — flag it or remove it.
- **No interview**: Never ask the user requirements questions. If the brief is
inadequate, stop and ask the user to run `/trekbrief` again.
inadequate, stop and ask the user to run `/trekbrief` again. The one
exception is the design part's Requirements Check: one two-choice question
("keep" / "change like this") per conflict or gap found between the brief's
own requirements.
- **Scope**: Only explore the current working directory and its subdirectories.
Never read files outside the repo (no ~/.env, no credentials, no other repos).
- **Cost**: Model resolution at Agent-spawn sites is a three-layer fallback:
@ -985,7 +1178,12 @@ Standard and low effort: do NOT emit the section at all.
- **Privacy**: Never log, store, or repeat file contents that look like
secrets, tokens, or credentials. Never log prompt text.
- **No premature execution**: Do not modify any project files until the user
explicitly approves the plan.
explicitly approves the plan, and never while `plan-gate.mjs` exits non-zero
on it.
- **Bounded critic loop**: at most 3 critic rounds (Phase 9). After the third
with a BLOCKER open, stop and ask the operator; never continue silently, and
never write or edit the `## Critic Verdict` block by hand — only
`plan-gate.mjs --record` writes it.
- **Plan stands alone**: The plan file must be understandable without access
to the exploration results. Include all necessary context.
- **Honesty**: If exploration reveals the task is trivial (single file, obvious

View file

@ -12,7 +12,7 @@ Each artifact carries an explicit version field. Schema bumps are coordinated:
|---|---|---|
| `brief.md` | `brief_version` (frontmatter) | `2.2` |
| `research/*.md` | (implicit; tracked via `type: trekresearch-brief`) | unversioned |
| `plan.md` | `plan_version` (plan header line) | `1.7` |
| `plan.md` | `plan_version` (plan header line) | `1.8` (1.7 still valid) |
| `progress.json` | `schema_version` (top-level) | `"1"` |
| `review.md` | `review_version` (frontmatter) | `1.1` |
| `.session-state.local.json` | `schema_version` (top-level) | `1` (number) |
@ -204,6 +204,8 @@ The validator (`lib/validators/architecture-discovery.mjs`) is intentionally min
**Failure modes:** none. Discovery always succeeds (returns `found: false` if absent). The handover is additive.
**Not the design part.** A plan's own design (`## Design`, plan_version 1.8, architectural plans only) is part of Handover 4, written by `/trekplan` itself. It does not read or write this external slot, and this slot stays drift-WARN.
---
## Handover 4 — `plan.md` → execute
@ -224,7 +226,8 @@ Handover 6. Brief-generated plans carry no frontmatter at all.
| Field | Type | Required | Location | Allowed |
|---|---|---|---|---|
| `plan_version` | string | yes | plan header line, not frontmatter | `"1.7"` (current) |
| `plan_version` | string | yes | plan header line, not frontmatter | `"1.8"` (current), `"1.7"` (valid) |
| `ceremony` | string | no | plan header line, after `plan_version` | `small` \| `bounded` \| `architectural`; absent = `architectural` |
`plan_version` is emitted in the plan's "Generated by" header metadata line
(`> Generated by … — `plan_version: 1.7``), parsed by `extractPlanVersion`
@ -246,6 +249,12 @@ prose form (relaxed in S26 so the template's prose emission parses).
- Indented fenced YAML: ` ```yaml\n manifest:\n ...\n ``` `
- Required keys: `expected_paths` (list), `min_file_count` (number), `commit_message_pattern` (string compilable to RegExp), `bash_syntax_check` (list), `forbidden_paths` (list), `must_contain` (list of `{path, pattern}` dicts or empty list)
4. Step count == manifest count
5. **plan_version 1.8 only** (1.7 plans are not held to these):
- every step has `Test first:` with `File:` and `Verifies:` sub-bullets;
- at least one step has `End-to-end:` with a backticked command — the "run the thing" step;
- the optional step fields `Interface in:`, `Interface out:`, `Review focus:` are never empty when present (consumers tolerate their absence);
- `ceremony` is one of the three classes; an `architectural` plan has `## Design` with the subsections Architecture, Data Flow, Interfaces, Data Models, Error Handling, Module Map, Architecture Rules, Requirements Check, plus `## Test Strategy`; the module map (mermaid) has no cycle; every architecture rule says "checked by `<command>`" (with a Proof column naming an existing step) or "advisory — `.claude/rules/<file>.md`".
6. **`## Critic Verdict`** (written by `lib/validators/plan-gate.mjs --record`, never by hand): one fenced `json` block, schema `voyage.plan-verdict/1` — `round`, `max_rounds` (3), `extra_rounds`, `plan_sha256`, `verdict` (`BLOCK` | `REVISE` | `PASS`), `counts`, `findings` (deduplicated, each with `severity`). `plan_sha256` hashes the plan minus its review log (the `> **Plan quality:` line and the sections Critic Verdict, Revisions, Plan Quality Score, Adversarial Pass 2), with CRLF and line-end whitespace normalized. The verdict sits inside the plan because Claude Code's plan approval hands a hook the plan file's text and nothing beside it.
**Validation strategy:**
@ -260,9 +269,23 @@ The strongest validation in the entire pipeline. Phase 5.5 (planning-orchestrato
| `MANIFEST_MISSING` | Specific step has no manifest YAML | Add Manifest block |
| `MANIFEST_MISSING_KEY` | Manifest is missing a required key | Add the key |
| `MANIFEST_PATTERN_INVALID` | `commit_message_pattern` does not compile | Check escaping (`\\(` not `\(` in YAML double-quoted strings) |
| `PLAN_VERSION_MISMATCH` | Older `plan_version` | Warning only; planner should bump |
| `PLAN_VERSION_MISMATCH` | `plan_version` other than 1.7 / 1.8 | Warning only; planner should bump |
| `PLAN_NO_VERSION` | No `plan_version` found | Warning only; add the header line |
| `PLAN_NOT_FOUND` / `PLAN_READ_ERROR` | The plan file is missing or unreadable | Check the path |
| `PLAN_STEP_NO_TEST_FIRST` | 1.8: a step lacks `Test first:` with File + Verifies | Add the test the step makes pass |
| `PLAN_NO_END_TO_END` | 1.8: no step carries `End-to-end:` with a command | Add the "run the thing" step |
| `PLAN_STEP_FIELD_EMPTY` | 1.8: `Interface in/out:` or `Review focus:` present but empty | Fill or remove it |
| `PLAN_CEREMONY_INVALID` | 1.8: `ceremony` not small / bounded / architectural | Copy the brief's value |
| `PLAN_DESIGN_MISSING` / `PLAN_DESIGN_SECTION_MISSING` | 1.8 architectural: no `## Design`, or a required subsection missing | Write it from `templates/design-template.md` |
| `PLAN_MODULE_MAP_EMPTY` / `PLAN_MODULE_CYCLE` | 1.8 architectural: no mermaid module map, or a cycle in it (all `mermaid` blocks of `### Module Map` form one map) | Draw it; break the cycle |
| `PLAN_MODULE_MAP_UNPARSED` | 1.8 architectural: a module-map line that looks like a link but gives no edge | Write it as `a --> b` |
| `PLAN_ARCH_RULE_NO_ENFORCER` / `PLAN_ARCH_RULE_NO_PROOF` | 1.8 architectural: a rule without an enforcer, or a checked rule without an existing proving step | Name the enforcer; name the step |
**Versioning:** v1.7 has been stable since v1.8.0 of the plugin (when literal-template + Phase 5.5 self-check were added to fix Opus 4.7 schema drift). v1.6 → v1.7 added the Manifest block (mandatory). Before bumping to v1.8, write the new validator branch + fixtures first.
**The plan gate (K3).** `node lib/validators/plan-gate.mjs <plan.md> [--json]` decides whether a plan may go on: **0** = it validates strict, its `## Critic Verdict` matches the current text, and no BLOCKER is open (open majors are warnings); **1** = stop, with `reasons` (`PLAN_GATE_INVALID_PLAN`, `PLAN_GATE_VERSION` — below plan_version 1.8, `PLAN_GATE_NO_VERDICT`, `PLAN_GATE_VERDICT_MALFORMED`, `PLAN_GATE_VERDICT_STALE`, `PLAN_GATE_OPEN_BLOCKER`, `PLAN_GATE_ROUNDS_EXHAUSTED`) and `warnings` (`PLAN_GATE_OPEN_MAJOR`); **2** = could not read the plan. `--hash <plan.md>` prints the operative hash; Phase 9 takes it before the reviewers read the plan. `--record <plan.md> --reviewed <hash>` reads plan-review-dedup output on stdin (which lists the reviewers that delivered, `sources`) and writes the next round; exit 2 when a reviewer is missing or the plan changed since `--hash`; it refuses round 4 (exit 1, stop and ask) unless `--extra-round` follows the operator's yes, and refuses that flag before the cap is spent. Unrated findings count as blockers. The review log is excluded from the hash only up to its first other heading, so a step placed under it is hashed. The verdict is a trace of the review, not a signature.
**Verification CLIs that read a plan** (0/1/2, JSON on stdout, 2 is never green): `lib/verification/arch-rules.mjs --repo <dir>` runs the target repo's own dependency-cruiser or import-linter (installs nothing; `--prove --rule <name> --plant <file>=<dest>` shows pass → fail on that rule → pass, refusing a destination that exists or resolves outside the repo); `lib/verification/plan-scope-sweep.mjs --plan <plan.md> --base <ref>` lists changed files no step names (`Files:`, `Test first:` File, manifest `expected_paths`) and no process path covers.
**Versioning:** v1.7 has been stable since v1.8.0 of the plugin (when literal-template + Phase 5.5 self-check were added to fix Opus 4.7 schema drift). v1.6 → v1.7 added the Manifest block (mandatory). v1.7 → v1.8 makes `Test first:` and the "run the thing" step required, adds the optional step fields and the design part, and was written validator-first with tests for both shapes (`tests/validators/plan-validator-v18.test.mjs`); 1.7 plans validate unchanged, so readers need no change (the executor already treats `plan_version ≥ 1.7` as strict).
**Failure modes:** strict mode is the default for both producer and consumer. There is no soft mode here — a malformed plan is a hard failure for execute.

View file

@ -1,13 +1,17 @@
// lib/parsers/plan-schema.mjs
// Plan v1.7 schema parser — heading shape detection.
// Plan schema parser — heading shape, per-step fields, ceremony, design part.
//
// The canonical step heading is `### Step N: <title>` (literal colon-space).
// Forbidden narrative drift formats (introduced in v1.8.0 to defend against
// Opus 4.7 schema-drift): `## Fase N`, `### Phase N`, `### Stage N`, `### Steg N`.
//
// This module extracts step boundaries; per-step body parsing lives elsewhere.
// plan_version 1.8 adds per-step field parsing (`parseStepFields`), the
// ceremony class the plan was made under (`small | bounded | architectural`,
// absent = architectural) and the design-part helpers (`splitSections`,
// `findModuleCycle`). The rules that use them live in plan-validator.mjs.
import { ok, fail, issue } from '../util/result.mjs';
import { parseDocument } from '../util/frontmatter.mjs';
export const STEP_HEADING_REGEX = /^### Step (\d+):\s+(.+?)\s*$/m;
export const STEP_HEADING_GLOBAL = /^### Step (\d+):\s+(.+?)\s*$/gm;
@ -127,3 +131,308 @@ export function validatePlanHeadings(text, opts = {}) {
return { valid: errors.length === 0, errors, warnings, parsed: { steps, forbidden } };
}
// ---- plan_version 1.8 ------------------------------------------------------
export const PLAN_VERSION_CURRENT = '1.8';
// 1.7 plans stay valid: the 1.8 rules apply only from 1.8 on.
export const PLAN_VERSIONS_SUPPORTED = Object.freeze(['1.7', '1.8']);
/** True when dotted version `v` is >= `min` (numeric per part; null → false). */
export function versionAtLeast(v, min) {
if (typeof v !== 'string' || !/^\d+(\.\d+)*$/.test(v)) return false;
const a = v.split('.').map(Number);
const b = min.split('.').map(Number);
for (let i = 0; i < Math.max(a.length, b.length); i++) {
const x = a[i] ?? 0;
const y = b[i] ?? 0;
if (x !== y) return x > y;
}
return true;
}
// ---- ceremony (the brief's size class) -------------------------------------
export const CEREMONY_VALUES = Object.freeze(['small', 'bounded', 'architectural']);
// Heaviest on doubt: a plan or brief that does not say is architectural.
export const CEREMONY_DEFAULT = 'architectural';
// A plan states its ceremony in exactly two places: frontmatter, or the
// "Generated by" header line (`> Generated by … · `ceremony: architectural``).
// Prose elsewhere ("the brief sets `ceremony: small`") never counts.
const HEADER_CEREMONY = /^> Generated by .*`ceremony:\s*([^`\s]+)\s*`/m;
/** @returns {{value: string, raw: string|null, defaulted: boolean, valid: boolean}} */
export function resolveCeremony(raw) {
if (raw === null || raw === undefined || raw === '') {
return { value: CEREMONY_DEFAULT, raw: null, defaulted: true, valid: true };
}
const s = String(raw).trim();
return { value: s, raw: s, defaulted: false, valid: CEREMONY_VALUES.includes(s) };
}
function frontmatterCeremony(text) {
const doc = typeof text === 'string' ? parseDocument(text) : null;
const fm = doc?.parsed?.frontmatter;
return fm && typeof fm === 'object' && fm.ceremony !== undefined ? fm.ceremony : null;
}
export function extractPlanCeremony(text) {
if (typeof text !== 'string') return resolveCeremony(null);
const fm = frontmatterCeremony(text);
if (fm !== null) return resolveCeremony(fm);
const m = text.match(HEADER_CEREMONY);
return resolveCeremony(m ? m[1] : null);
}
/** Read `ceremony:` from a brief's frontmatter. Absent (or no frontmatter) → architectural. */
export function ceremonyFromBrief(briefText) {
return resolveCeremony(frontmatterCeremony(briefText));
}
// ---- fences ------------------------------------------------------------------
/**
* Tracks fenced code blocks the CommonMark way: a fence closes only on the same
* character (` or ~), at least as long as the opener, with nothing after it.
* `feed(line)` returns true when the line is inside a fence or is a fence line.
*/
export function fenceTracker() {
let open = null; // { ch, len }
return {
feed(line) {
const m = line.match(/^\s{0,3}(`{3,}|~{3,})(.*)$/);
if (!open) {
if (m && !(m[1][0] === '`' && m[2].includes('`'))) {
open = { ch: m[1][0], len: m[1].length };
return true;
}
return false;
}
if (m && m[1][0] === open.ch && m[1].length >= open.len && m[2].trim() === '') open = null;
return true;
},
get inside() { return open !== null; },
};
}
// ---- per-step fields -------------------------------------------------------
// A top-level field bullet: `- **Name:** rest`, `- **Name**: rest` or `- Name: rest`.
const FIELD_LINE = /^- (?:\*\*)?([A-Z][A-Za-z -]*?)(?::\*\*|\*\*:|:)(?:\s+(.*))?$/;
/**
* Parse a step body into its top-level `- **Field:**` bullets. A field's block
* runs until the next top-level field bullet; indented lines (sub-bullets, the
* Manifest YAML fence) belong to the field above them. Parsing stops at the
* first heading after the step's own, outside fenced code: sliceSteps gives the
* last step the rest of the plan, and a later section must not lend it fields.
* @returns {Map<string, {name: string, inline: string, text: string}>}
* `inline` = text on the bullet line; `text` = inline + the indented block.
*/
export function parseStepFields(body) {
const fields = new Map();
if (typeof body !== 'string') return fields;
const fence = fenceTracker();
let current = null;
let first = true;
for (const line of body.split(/\r?\n/)) {
const inFence = fence.feed(line);
if (!inFence && /^#{1,6} /.test(line)) {
if (!first) break;
first = false;
continue;
}
first = false;
if (inFence) {
if (current) current.lines.push(line);
continue;
}
const m = line.match(FIELD_LINE);
if (m) {
current = { name: m[1].trim(), inline: (m[2] ?? '').trim(), lines: [] };
if (!fields.has(current.name)) fields.set(current.name, current);
continue;
}
if (current && /^\s/.test(line)) current.lines.push(line);
else if (current && line.trim() !== '') current = null;
}
for (const f of fields.values()) {
f.text = [f.inline, ...f.lines].join('\n').trim();
delete f.lines;
}
return fields;
}
// ---- sections (fence-aware) ------------------------------------------------
/**
* Split text at headings of exactly `level` (#-count), ignoring headings inside
* fenced code blocks. Text before the first heading has title null.
* @returns {Array<{title: string|null, start: number, end: number, text: string}>}
* start/end are line indexes (end exclusive).
*/
export function splitSections(text, level = 2) {
const lines = typeof text === 'string' ? text.split('\n') : [];
const prefix = '#'.repeat(level) + ' ';
const fence = fenceTracker();
const out = [];
let cur = { title: null, start: 0 };
for (let i = 0; i < lines.length; i++) {
const l = lines[i];
if (fence.feed(l)) continue;
if (l.startsWith(prefix)) {
out.push({ ...cur, end: i });
cur = { title: l.slice(prefix.length).trim(), start: i };
}
}
out.push({ ...cur, end: lines.length });
return out
.filter(s => s.end > s.start || s.title !== null)
.map(s => ({ ...s, text: lines.slice(s.start, s.end).join('\n') }));
}
/** First section of `level` whose title equals `title` (case-insensitive), or null. */
export function findSection(text, title, level = 2) {
const want = title.toLowerCase();
return splitSections(text, level).find(s => s.title && s.title.toLowerCase() === want) ?? null;
}
/** Every fenced block whose info string is `lang`, in order. */
export function fencedBlocks(text, lang) {
const out = [];
const lines = typeof text === 'string' ? text.split('\n') : [];
const fence = fenceTracker();
let cur = null;
for (const l of lines) {
const wasInside = fence.inside;
fence.feed(l);
if (!wasInside && fence.inside) {
cur = new RegExp(`^\\s{0,3}(\`{3,}|~{3,})\\s*${lang}\\s*$`).test(l) ? [] : null;
} else if (wasInside && !fence.inside) {
if (cur) out.push(cur.join('\n'));
cur = null;
} else if (cur) {
cur.push(l);
}
}
return out;
}
// ---- module map ------------------------------------------------------------
// Directives that are not node statements: the keyword must stand alone or be
// followed by whitespace, so a node id like `end-user` or `graph.mjs` is kept.
const DIRECTIVE = /^(graph|flowchart|subgraph|end|classDef|class|style|linkStyle|direction|click|accTitle|accDescr)(\s|$)/;
// Directed and undirected links, after labels are stripped:
// --> ---> ==> ===> -.-> -..-> with an optional leading < (both ways);
// --- === -.- ~~~ (no direction: not a dependency).
const LINK = /\s*(<?-{2,}>|<?={2,}>|<?-\.+->|-{3,}|={3,}|-\.+-|~{3,})\s*/;
const NODE_ID = /^[A-Za-z0-9_][\w.\-/]*$/;
const ARROWISH = /-->|==>|-\.+->|<--|<==/;
function stripLabels(line) {
return line
.replace(/"[^"]*"/g, '')
.replace(/:::[\w-]+/g, '')
.replace(/\[\[[^\]]*\]\]|\[\([^\]]*\)\]|\[\/[^\]]*\/\]|\[\\[^\]]*\\\]|\[[^\]]*\]/g, '')
.replace(/\(\(\([^)]*\)\)\)|\(\([^)]*\)\)|\(\[[^\]]*\]\)|\([^)]*\)/g, '')
.replace(/\{\{[^}]*\}\}|\{[^}]*\}/g, '')
.replace(/>[^\]\s][^\]]*\]/g, '')
.replace(/\|[^|]*\|/g, '')
// link text: `-- text -->`, `-. text .->`, `== text ==>`
.replace(/--\s+[^-<>=]+?\s+(-{2,}>)/g, '$1')
.replace(/-\.\s+[^.<>]+?\s+\.(-+>)/g, '-.$1')
.replace(/==\s+[^=<>]+?\s+(={2,}>)/g, '$1');
}
/**
* Parse a mermaid flowchart into directed edges.
* Handles chains (a --> b --> c), `&` groups, `;`-separated statements,
* `<-->` (one edge each way), long arrows, labels and `:::class`.
* @returns {{edges: Array<[string, string]>, unparsed: string[]}}
* unparsed = statements that look like links but yielded no edge.
*/
export function parseMermaid(mermaid) {
const edges = [];
const unparsed = [];
if (typeof mermaid !== 'string') return { edges, unparsed };
for (const raw of mermaid.split(/\r?\n/)) {
for (const stmt0 of raw.split(';')) {
const stmt = stmt0.trim();
if (!stmt || stmt.startsWith('%%') || DIRECTIVE.test(stmt)) continue;
const parts = stripLabels(stmt).split(LINK).map(p => p.trim());
if (parts.length < 3) {
if (ARROWISH.test(stmt)) unparsed.push(stmt);
continue;
}
let ok = true;
const groups = [];
for (let i = 0; i < parts.length; i += 2) {
const ids = parts[i].split('&').map(s => s.trim());
if (ids.length === 0 || ids.some(id => !NODE_ID.test(id))) { ok = false; break; }
groups.push(ids);
}
if (!ok) { unparsed.push(stmt); continue; }
for (let i = 1, g = 0; i < parts.length; i += 2, g++) {
const link = parts[i];
const forward = link.endsWith('>');
const backward = link.startsWith('<');
if (!forward) continue; // undirected: not a dependency
for (const a of groups[g]) {
for (const b of groups[g + 1]) {
edges.push([a, b]);
if (backward) edges.push([b, a]);
}
}
}
}
}
return { edges, unparsed };
}
/** Directed edges [from, to] from mermaid flowchart text (see parseMermaid). */
export function parseMermaidEdges(mermaid) {
return parseMermaid(mermaid).edges;
}
/**
* Find one directed cycle in a module map (mermaid text or an edge list).
* Iterative depth-first search: a deep chain cannot overflow the stack.
* @returns {string[]|null} the cycle as a closed path (first === last), or null.
*/
export function findModuleCycle(input) {
const edges = Array.isArray(input) ? input : parseMermaidEdges(input);
const adj = new Map();
for (const [a, b] of edges) {
if (!adj.has(a)) adj.set(a, []);
if (!adj.has(b)) adj.set(b, []);
adj.get(a).push(b);
}
const state = new Map(); // unset = unvisited, 1 = on the path, 2 = done
for (const root of adj.keys()) {
if (state.has(root)) continue;
const path = [root];
const iters = [0];
state.set(root, 1);
while (path.length > 0) {
const n = path[path.length - 1];
const next = adj.get(n);
const i = iters[iters.length - 1];
if (i >= next.length) {
state.set(n, 2);
path.pop();
iters.pop();
continue;
}
iters[iters.length - 1] = i + 1;
const nx = next[i];
if (state.get(nx) === 1) return [...path.slice(path.indexOf(nx)), nx];
if (!state.has(nx)) {
state.set(nx, 1);
path.push(nx);
iters.push(0);
}
}
}
return null;
}

View file

@ -8,6 +8,10 @@
// 2. Jaccard ≥ 0.7 on text-token sets → merge (catches near-duplicates).
//
// Provenance is preserved on the surviving finding's `raised_by` array.
// Severity is kept (normalized to blocker | major | minor, else null) and a
// merge keeps the more severe of the two: the plan gate (K3,
// lib/validators/plan-gate.mjs) stops on a BLOCKER, so a merge must never
// downgrade one.
//
// CLI shim — two input modes:
// file mode: node lib/review/plan-review-dedup.mjs \
@ -22,11 +26,29 @@
// blocks here. Malformed stdin exits NON-ZERO — a broken hand-off must surface
// loudly, never collapse into a silent empty merge.
import { readFileSync } from 'node:fs';
import { readFileSync, realpathSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { jaccardSimilarity, meetsThreshold } from '../parsers/jaccard.mjs';
import { computeFindingId } from '../parsers/finding-id.mjs';
export const DEFAULT_THRESHOLD = 0.7;
export const SEVERITIES = Object.freeze(['blocker', 'major', 'minor']);
/** 'Blocker' / 'MAJOR' → canonical lowercase; anything else → null (unrated). */
export function normalizeSeverity(s) {
const v = typeof s === 'string' ? s.trim().toLowerCase() : '';
return SEVERITIES.includes(v) ? v : null;
}
// Lower rank = more severe; unrated ranks with blocker so a merge never hides it.
const rank = s => (s === null ? 0 : SEVERITIES.indexOf(s));
function mergeInto(survivor, other) {
for (const a of other.raised_by) {
if (!survivor.raised_by.includes(a)) survivor.raised_by.push(a);
}
if (rank(other.severity) < rank(survivor.severity)) survivor.severity = other.severity;
}
/**
* Tokenize a finding's text for Jaccard comparison: lowercase, split on
@ -61,6 +83,7 @@ function annotate(finding, agent) {
file: finding.file ?? null,
line: finding.line ?? null,
rule_key: finding.rule_key ?? null,
severity: normalizeSeverity(finding.severity),
text: typeof finding.text === 'string' ? finding.text : '',
raised_by: [agent],
};
@ -73,6 +96,7 @@ function annotate(finding, agent) {
* @param {{ threshold?: number }} [opts]
* @returns {{
* findings: Array<object>,
* sources: Array<{agent: string, present: boolean}>,
* dedup_stats: { total_in: number, total_out: number,
* exact_id_dups: number, jaccard_dups: number }
* }}
@ -95,9 +119,7 @@ export function dedupFindings(sources, opts = {}) {
for (const f of incoming) {
const existing = byId.get(f.id);
if (existing) {
for (const a of f.raised_by) {
if (!existing.raised_by.includes(a)) existing.raised_by.push(a);
}
mergeInto(existing, f);
exact_id_dups += 1;
} else {
byId.set(f.id, f);
@ -113,9 +135,7 @@ export function dedupFindings(sources, opts = {}) {
for (const s of survivors) {
const sim = jaccardSimilarity(tokens, tokenize(s.text));
if (meetsThreshold(sim, threshold)) {
for (const a of f.raised_by) {
if (!s.raised_by.includes(a)) s.raised_by.push(a);
}
mergeInto(s, f);
jaccard_dups += 1;
merged = true;
break;
@ -126,6 +146,13 @@ export function dedupFindings(sources, opts = {}) {
return {
findings: survivors,
// Which reviewers actually delivered a findings block. `findings: []` from
// a reviewer that was absent (null payload, misspelled key, no `findings`)
// is not a clean review; plan-gate --record refuses it.
sources: sources.map(s => ({
agent: s.agent,
present: Boolean(s.payload && typeof s.payload === 'object' && Array.isArray(s.payload.findings)),
})),
dedup_stats: {
total_in,
total_out: survivors.length,
@ -190,7 +217,17 @@ function readStdinSourcesOrExit() {
];
}
if (import.meta.url === `file://${process.argv[1]}`) {
// realpath on both sides: `file://${argv[1]}` silently no-ops under a symlink
// or a path with a space.
function isMain() {
try {
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
}
if (isMain()) {
const args = parseArgs(process.argv.slice(2));
const sources = args.stdin
? readStdinSourcesOrExit()

View file

@ -0,0 +1,352 @@
// lib/validators/plan-gate.mjs
// K3 — the plan gate: may this plan go on to execution?
//
// node plan-gate.mjs <plan.md> [--json]
// 0 = the plan may go on
// 1 = stop: invalid plan (plan-validator --strict, plan_version >= 1.8),
// no critic verdict, a malformed verdict, a verdict the plan has
// moved away from (stale), or an open BLOCKER
// 2 = could not run (missing/unreadable plan, bad arguments)
//
// node plan-gate.mjs --hash <plan.md>
// Prints the sha256 of the plan's operative text. Phase 9 takes it BEFORE
// the reviewers read the plan and hands it to --record.
//
// node plan-gate.mjs --record <plan.md> --reviewed <sha256> [--extra-round]
// stdin = plan-review-dedup.mjs output. Writes this round's verdict into
// the plan. 0 = written; 1 = refused: the round cap is spent (stop and ask
// the operator; `--extra-round` only after their yes, and only then);
// 2 = could not run: bad stdin, a reviewer missing from the dedup input,
// the plan changed since --hash (the verdict would bless unreviewed text),
// an unreadable plan or a failed write. Nothing is written on 1 or 2.
//
// Where the verdict lives: INSIDE the plan, as the `## Critic Verdict` section
// with one fenced json block. Claude Code's plan approval hands a hook the
// plan file's text (`tool_input.plan`, `planFilePath`) and nothing next to
// it, so a verdict in a sibling file would be invisible there.
//
// What the verdict is bound to: sha256 of the plan's operative text — the plan
// minus its review log, with CRLF and line-end whitespace normalized. The
// review log is the `> **Plan quality:` header line and the level-2 sections
// titled exactly Critic Verdict, Revisions, Plan Quality Score and
// "Adversarial Pass 2 (v5.1.1 high-effort)" — each only up to its first
// other heading, so a step or section placed under a log heading is hashed.
// Edit a step, the design or the verification after the critic ran, and the
// verdict is stale until the next round.
//
// The loop is bounded (Kiro's bounded review loop): MAX_ROUNDS critic rounds,
// then stop and ask; never silently on. Unrated findings count as blockers.
// The verdict is a trace of a process, not a signature: anything that can
// write the plan can write the block.
import { readFileSync, existsSync, realpathSync, writeFileSync, renameSync, rmSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { validatePlanContent } from './plan-validator.mjs';
import { splitSections, fenceTracker, extractPlanVersion, versionAtLeast } from '../parsers/plan-schema.mjs';
import { normalizeSeverity } from '../review/plan-review-dedup.mjs';
export const MAX_ROUNDS = 3;
export const VERDICT_SCHEMA = 'voyage.plan-verdict/1';
export const VERDICT_HEADING = '## Critic Verdict';
export const REQUIRED_REVIEWERS = Object.freeze(['plan-critic', 'scope-guardian']);
// Level-2 sections that are review log, not plan: excluded from the hash.
export const REVIEW_LOG_SECTIONS = Object.freeze([
'Critic Verdict', 'Revisions', 'Plan Quality Score', 'Adversarial Pass 2 (v5.1.1 high-effort)',
]);
// The quality line alone; a line that also carries `plan_version`/`ceremony` stays hashed.
const QUALITY_LINE = /^> \*\*Plan quality:[^`]*$/;
const isLogTitle = t => t !== null && REVIEW_LOG_SECTIONS.includes(t);
const norm = t => String(t).replace(/\r\n?/g, '\n');
/** A log section counts as log only up to its first other heading (outside code). */
function logPart(sectionText) {
const lines = sectionText.split('\n');
const fence = fenceTracker();
for (let i = 1; i < lines.length; i++) {
if (fence.feed(lines[i])) continue;
if (/^#{1,6} /.test(lines[i])) return { log: lines.slice(0, i).join('\n'), rest: lines.slice(i).join('\n') };
}
return { log: sectionText, rest: '' };
}
/** The plan minus its review log, normalized (LF, no line-end whitespace, one trailing newline). */
export function operativeText(text) {
const kept = splitSections(norm(text), 2)
.map(s => (isLogTitle(s.title) ? logPart(s.text).rest : s.text))
.join('\n');
return kept
.split('\n')
.filter(l => !QUALITY_LINE.test(l))
.map(l => l.replace(/[ \t]+$/, ''))
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim() + '\n';
}
export function planHash(text) {
return createHash('sha256').update(operativeText(text)).digest('hex');
}
/**
* @returns {{found: boolean, placeholder: boolean, verdict: object|null, error: string|null}}
* placeholder = a `## Critic Verdict` heading with no fenced block at all
* (copied prose, not a verdict): --record replaces it, the gate stops on it.
*/
export function extractVerdict(text) {
const secs = splitSections(norm(text), 2).filter(s => s.title === 'Critic Verdict');
const none = { found: false, placeholder: false, verdict: null };
if (secs.length === 0) return { ...none, error: null };
if (secs.length > 1) return { ...none, found: true, error: `${secs.length} "## Critic Verdict" sections; expected one` };
const log = logPart(secs[0].text).log;
const m = log.match(/```json[ \t]*\n([\s\S]*?)\n[ \t]*```/);
if (!m) {
const placeholder = !/^\s{0,3}(```|~~~)/m.test(log);
return { ...none, found: true, placeholder, error: 'no ```json block in ## Critic Verdict' };
}
let v;
try { v = JSON.parse(m[1]); } catch (e) { return { ...none, found: true, error: `verdict JSON does not parse: ${e.message}` }; }
const okShape = v && typeof v === 'object' && v.schema === VERDICT_SCHEMA && Number.isInteger(v.round) && v.round >= 1 &&
Number.isInteger(v.max_rounds) && v.max_rounds >= 1 && v.max_rounds <= MAX_ROUNDS &&
Number.isInteger(v.extra_rounds ?? 0) && (v.extra_rounds ?? 0) >= 0 &&
typeof v.plan_sha256 === 'string' && Array.isArray(v.findings) &&
v.findings.every(f => f && typeof f === 'object' && typeof f.severity === 'string');
if (!okShape) {
return { ...none, found: true, error: `verdict block is not ${VERDICT_SCHEMA} (schema, round, max_rounds <= ${MAX_ROUNDS}, extra_rounds, plan_sha256, findings with severity)` };
}
return { found: true, placeholder: false, verdict: v, error: null };
}
function verdictFor(counts) {
if (counts.blocker > 0) return 'BLOCK';
if (counts.major > 0) return 'REVISE';
return 'PASS';
}
function renderSection(v) {
return [
VERDICT_HEADING,
'',
`<!-- Written by lib/validators/plan-gate.mjs --record (round ${v.round} of ${v.max_rounds}` +
`${v.extra_rounds ? ` + ${v.extra_rounds} extra` : ''}). Do not edit by hand: the gate re-hashes the plan and reads this block. -->`,
'',
'```json',
JSON.stringify(v, null, 2),
'```',
'',
].join('\n');
}
/** Replace (or append) the `## Critic Verdict` section; anything after its log part is kept. */
function placeSection(text, section) {
const secs = splitSections(norm(text), 2);
const idx = secs.findIndex(s => s.title === 'Critic Verdict');
if (idx === -1) return norm(text).replace(/\s*$/, '\n\n') + section;
const parts = secs.map(s => s.text);
const { rest } = logPart(parts[idx]);
parts[idx] = section.replace(/\n$/, '') + (rest ? `\n\n${rest}` : '');
return parts.join('\n').replace(/\s*$/, '\n');
}
/** Both reviewers must have been present in the dedup input; an absent one is not a clean review. */
function missingReviewers(dedup) {
const present = new Set(
(Array.isArray(dedup.sources) ? dedup.sources : []).filter(s => s && s.present === true).map(s => s.agent),
);
return REQUIRED_REVIEWERS.filter(r => !present.has(r));
}
/**
* Record one critic round into the plan text.
* @param {string} text - the plan
* @param {{findings: Array<object>, sources: Array<{agent: string, present: boolean}>}} dedup
* plan-review-dedup.mjs output
* @param {{reviewed?: string, extraRound?: boolean, maxRounds?: number, now?: string}} [opts]
* reviewed = planHash of the text the reviewers read (from --hash)
* @returns {{code: 0|1|2, text?: string, verdict?: object, message: string}}
*/
export function recordVerdict(text, dedup, opts = {}) {
if (!dedup || typeof dedup !== 'object' || !Array.isArray(dedup.findings)) {
return { code: 2, message: 'record input must be plan-review-dedup output: {"findings": [...], "sources": [...]}' };
}
if (dedup.findings.some(f => !f || typeof f !== 'object')) {
return { code: 2, message: 'record input has a finding that is not an object' };
}
const missing = missingReviewers(dedup);
if (missing.length > 0) {
return { code: 2, message: `reviewer(s) missing from the dedup input: ${missing.join(', ')} — an absent review is not a clean one; rerun the round` };
}
const current = planHash(text);
if (typeof opts.reviewed !== 'string' || !/^[0-9a-f]{64}$/.test(opts.reviewed)) {
return { code: 2, message: '--reviewed <sha256> is required: take it with --hash before the reviewers read the plan' };
}
if (opts.reviewed !== current) {
return { code: 2, message: 'the plan changed after the reviewers read it (--reviewed differs from the plan now); rerun the round on the current plan' };
}
const prev = extractVerdict(text);
if (prev.error && !prev.placeholder) return { code: 2, message: `existing verdict unreadable: ${prev.error}` };
const maxRounds = Math.min(prev.verdict?.max_rounds ?? opts.maxRounds ?? MAX_ROUNDS, MAX_ROUNDS);
const prevExtra = prev.verdict?.extra_rounds ?? 0;
const round = (prev.verdict?.round ?? 0) + 1;
const capped = round > maxRounds + prevExtra;
if (opts.extraRound && !capped) {
return { code: 2, message: `--extra-round refused: round ${round} is within the cap; it is only for a round after the cap, after the operator's yes` };
}
if (capped && !opts.extraRound) {
return {
code: 1,
message: `round ${round} refused: ${maxRounds} critic rounds${prevExtra ? ` (+${prevExtra} extra)` : ''} are spent. ` +
'Stop and ask the operator: one more round (rerun with --extra-round after their yes), or stop here.',
};
}
const extra = prevExtra + (capped ? 1 : 0);
const findings = dedup.findings.map((f) => {
const sev = normalizeSeverity(f.severity);
const out = {
id: f.id ?? null,
severity: sev ?? 'blocker',
rule_key: f.rule_key ?? null,
file: f.file ?? null,
line: f.line ?? null,
text: typeof f.text === 'string' ? f.text : '',
raised_by: Array.isArray(f.raised_by) ? f.raised_by : [],
};
if (sev === null) out.severity_defaulted = true;
return out;
});
const counts = { blocker: 0, major: 0, minor: 0 };
for (const f of findings) counts[f.severity] += 1;
const verdict = {
schema: VERDICT_SCHEMA,
round,
max_rounds: maxRounds,
extra_rounds: extra,
recorded_at: opts.now ?? new Date().toISOString(),
plan_sha256: current,
verdict: verdictFor(counts),
counts,
findings,
};
return { code: 0, text: placeSection(text, renderSection(verdict)), verdict, message: `round ${round} recorded: ${verdict.verdict}` };
}
/**
* K3 — decide whether the plan may go on.
* @returns {{code: 0|1, status: 'pass'|'stop', reasons: object[], warnings: object[], verdict: object|null, validation: object}}
*/
export function gatePlan(rawText) {
const text = norm(rawText);
const reasons = [];
const warnings = [];
const validation = validatePlanContent(text, { strict: true });
if (!validation.valid) {
reasons.push({ code: 'PLAN_GATE_INVALID_PLAN', message: `plan-validator --strict: ${validation.errors.map(e => e.code).join(', ')}`, errors: validation.errors });
}
const version = extractPlanVersion(text);
if (!versionAtLeast(version, '1.8')) {
reasons.push({ code: 'PLAN_GATE_VERSION', message: `plan_version ${version ?? '(none)'}: the gate passes only plans written to plan_version 1.8 or later` });
}
const ex = extractVerdict(text);
const v = ex.verdict;
if (!ex.found) {
reasons.push({ code: 'PLAN_GATE_NO_VERDICT', message: 'No "## Critic Verdict": the plan has not been through the critic (run Phase 9).' });
} else if (ex.error) {
reasons.push({ code: 'PLAN_GATE_VERDICT_MALFORMED', message: ex.error });
} else {
if (v.plan_sha256 !== planHash(text)) {
reasons.push({ code: 'PLAN_GATE_VERDICT_STALE', message: `The plan changed after critic round ${v.round}; run the next round.` });
}
const sev = f => normalizeSeverity(f.severity);
const blockers = v.findings.filter(f => sev(f) !== 'major' && sev(f) !== 'minor');
const majors = v.findings.filter(f => sev(f) === 'major');
if (blockers.length > 0) {
reasons.push({ code: 'PLAN_GATE_OPEN_BLOCKER', message: `${blockers.length} open BLOCKER(s) in critic round ${v.round}: ${blockers.map(b => b.text).join(' | ')}` });
if (v.round >= v.max_rounds + (v.extra_rounds ?? 0)) {
reasons.push({ code: 'PLAN_GATE_ROUNDS_EXHAUSTED', message: `All ${v.round} critic rounds are spent with a BLOCKER open: stop and ask the operator.` });
}
}
if (majors.length > 0) {
warnings.push({ code: 'PLAN_GATE_OPEN_MAJOR', message: `${majors.length} MAJOR finding(s) left open in round ${v.round}; show them to the operator.` });
}
}
return { code: reasons.length === 0 ? 0 : 1, status: reasons.length === 0 ? 'pass' : 'stop', reasons, warnings, verdict: v ?? null, validation };
}
// ---- CLI --------------------------------------------------------------------
function isMain() {
try {
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
}
function out(obj) {
process.stdout.write(JSON.stringify(obj) + '\n');
}
if (isMain()) {
const args = process.argv.slice(2);
const usage = 'Usage: plan-gate.mjs <plan.md> [--json] | --hash <plan.md> | --record <plan.md> --reviewed <sha256> [--extra-round] < dedup.json';
const known = new Set(['--json', '--hash', '--record', '--reviewed', '--extra-round']);
const rIdx = args.indexOf('--reviewed');
const reviewed = rIdx >= 0 ? args[rIdx + 1] : undefined;
const positional = args.filter((a, i) => !a.startsWith('--') && !(rIdx >= 0 && i === rIdx + 1));
const unknown = args.filter(a => a.startsWith('--') && !known.has(a));
const planPath = positional[0];
if (!planPath || positional.length > 1 || unknown.length > 0 || (rIdx >= 0 && (!reviewed || reviewed.startsWith('--')))) {
process.stderr.write(`${usage}\n`);
out({ code: 2, status: 'error', message: unknown.length ? `unknown argument: ${unknown.join(' ')}` : 'bad arguments' });
process.exit(2);
}
let text;
try {
if (!existsSync(planPath)) throw new Error('file not found');
text = readFileSync(planPath, 'utf-8');
} catch (e) {
out({ code: 2, status: 'error', plan: planPath, message: `cannot read plan: ${e.message}` });
process.exit(2);
}
if (args.includes('--hash')) {
process.stdout.write(`${planHash(text)}\n`);
process.exit(0);
}
if (args.includes('--record')) {
let dedup;
try { dedup = JSON.parse(readFileSync(0, 'utf-8')); } catch (e) {
out({ code: 2, recorded: false, message: `stdin is not JSON: ${e.message}` });
process.exit(2);
}
const r = recordVerdict(text, dedup, { extraRound: args.includes('--extra-round'), reviewed });
if (r.code === 0) {
const tmp = `${planPath}.plan-gate.tmp`;
try {
writeFileSync(tmp, r.text);
renameSync(tmp, planPath);
} catch (e) {
rmSync(tmp, { force: true });
out({ code: 2, recorded: false, message: `cannot write the plan: ${e.message}` });
process.exit(2);
}
}
out({
code: r.code, recorded: r.code === 0, message: r.message,
round: r.verdict?.round ?? null, max_rounds: r.verdict?.max_rounds ?? null,
verdict: r.verdict?.verdict ?? null, counts: r.verdict?.counts ?? null,
});
process.exit(r.code);
}
const g = gatePlan(text);
if (args.includes('--json')) {
process.stdout.write(JSON.stringify({ code: g.code, status: g.status, plan: planPath, reasons: g.reasons, warnings: g.warnings, verdict: g.verdict }, null, 2) + '\n');
} else {
process.stdout.write(
`plan-gate: ${g.status.toUpperCase()} ${planPath}\n` +
g.reasons.map(r => ` STOP [${r.code}] ${r.message}\n`).join('') +
g.warnings.map(w => ` WARN [${w.code}] ${w.message}\n`).join(''),
);
}
process.exit(g.code);
}

View file

@ -6,15 +6,150 @@
// tolerated silently. Strict-key checks are intentionally avoided so new
// optional fields (jf. the source_findings precedent) can be added without
// a plan_version bump.
//
// plan_version 1.8 rules (apply only to plans declaring >= 1.8; 1.7 plans
// validate exactly as before):
// - every step has `Test first:` naming a File and what it Verifies;
// - the plan has a "run the thing" step: an `End-to-end:` field with a
// backticked command (without it a result is "not verified end to end");
// - the optional step fields `Interface in:`, `Interface out:`,
// `Review focus:` are never empty when present;
// - `ceremony:` is small | bounded | architectural (absent = architectural);
// an architectural plan carries `## Design` (see checkDesign).
// Strict mode reports these as errors; --soft downgrades them to warnings.
import { readFileSync, existsSync } from 'node:fs';
import { sliceSteps, validatePlanHeadings, extractPlanVersion } from '../parsers/plan-schema.mjs';
import { readFileSync, existsSync, realpathSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import {
sliceSteps, validatePlanHeadings, extractPlanVersion, extractPlanCeremony,
parseStepFields, findSection, splitSections, findModuleCycle, parseMermaid, fencedBlocks,
versionAtLeast, PLAN_VERSION_CURRENT, PLAN_VERSIONS_SUPPORTED, CEREMONY_VALUES,
} from '../parsers/plan-schema.mjs';
import { validateAllManifests } from '../parsers/manifest-yaml.mjs';
import { parseDocument } from '../util/frontmatter.mjs';
import { issue, fail } from '../util/result.mjs';
export function validatePlanContent(text, opts = {}) {
export const OPTIONAL_STEP_FIELDS = Object.freeze(['Interface in', 'Interface out', 'Review focus']);
export const DESIGN_SUBSECTIONS = Object.freeze([
'Architecture', 'Data Flow', 'Interfaces', 'Data Models', 'Error Handling',
'Module Map', 'Architecture Rules', 'Requirements Check',
]);
const BACKTICKED = /`[^`\n]+`/;
function checkSteps(sections) {
const out = [];
let endToEnd = 0;
for (const s of sections) {
const f = parseStepFields(s.body);
const tf = f.get('Test first');
const names = tf && /\bFile:\**[ \t]*`[^`\n]+`/i.test(tf.text) && /\bVerifies:\**[ \t]*\S/i.test(tf.text);
if (!names) {
out.push(issue(
'PLAN_STEP_NO_TEST_FIRST',
`Step ${s.n}: ${tf ? '`Test first:` does not name a File (backticked path) and what it Verifies' : 'no `Test first:` field'}`,
'plan_version 1.8 requires `Test first:` on every step, with `File:` and `Verifies:` sub-bullets.',
));
}
for (const name of OPTIONAL_STEP_FIELDS) {
if (f.has(name) && f.get(name).text === '') {
out.push(issue('PLAN_STEP_FIELD_EMPTY', `Step ${s.n}: \`${name}:\` is present but empty`,
'Fill the field or remove it; it is optional.'));
}
}
if (f.has('End-to-end') && BACKTICKED.test(f.get('End-to-end').text)) endToEnd += 1;
}
if (sections.length > 0 && endToEnd === 0) {
out.push(issue(
'PLAN_NO_END_TO_END',
'No "run the thing" step: no step carries `End-to-end:` with a backticked command',
'Add a step that runs the product the way a user does (`End-to-end: `<command>` → expected: …`). ' +
'Without it the result is "not verified end to end", never green.',
));
}
return out;
}
/** Table body rows (skips the header and the |---| separator). */
function tableRows(text) {
const rows = text.split('\n').filter(l => l.trim().startsWith('|'));
return rows.slice(2).map(r => r.trim().replace(/^\||\|$/g, '').split('|').map(c => c.trim()));
}
function checkDesign(text, stepNumbers) {
const out = [];
const design = findSection(text, 'Design', 2);
if (!design) {
return [issue('PLAN_DESIGN_MISSING', 'Architectural plan without a `## Design` section',
'Add the design part (templates/design-template.md), or plan under `ceremony: small|bounded` if the brief says so.')];
}
const subs = splitSections(design.text, 3);
const titles = new Set(subs.filter(s => s.title).map(s => s.title.toLowerCase()));
const missing = DESIGN_SUBSECTIONS.filter(t => !titles.has(t.toLowerCase()));
if (missing.length > 0) {
out.push(issue('PLAN_DESIGN_SECTION_MISSING', `## Design lacks: ${missing.join(', ')}`,
`Required subsections: ${DESIGN_SUBSECTIONS.join(', ')}.`));
}
if (!findSection(text, 'Test Strategy', 2)) {
out.push(issue('PLAN_DESIGN_SECTION_MISSING', 'Architectural plan lacks `## Test Strategy`',
'The design part\'s test strategy lives in the plan\'s `## Test Strategy` section.'));
}
const sub = t => subs.find(s => s.title && s.title.toLowerCase() === t.toLowerCase());
const map = sub('Module Map');
if (map) {
const blocks = fencedBlocks(map.text, 'mermaid');
if (blocks.length === 0) {
out.push(issue('PLAN_MODULE_MAP_EMPTY', '### Module Map has no ```mermaid block',
'Draw the modules and their dependency direction as a mermaid graph.'));
}
// All blocks form one map: a cycle may run across two of them.
const parsed = blocks.map(b => parseMermaid(b));
const unparsed = parsed.flatMap(p => p.unparsed);
if (unparsed.length > 0) {
out.push(issue('PLAN_MODULE_MAP_UNPARSED', `Module map lines that look like links but give no edge: ${unparsed.join(' | ')}`,
'Write links as `a --> b` (ids of letters, digits, _ . - /); the validator cannot check what it cannot read.'));
}
const cycle = findModuleCycle(parsed.flatMap(p => p.edges));
if (cycle) {
out.push(issue('PLAN_MODULE_CYCLE', `Module map has a dependency cycle: ${cycle.join(' -> ')}`,
'Dependencies point one way. Break the cycle (extract the shared part, or invert one edge).'));
}
}
const rules = sub('Architecture Rules');
if (rules) {
const rows = tableRows(rules.text);
if (rows.length === 0) {
out.push(issue('PLAN_ARCH_RULE_NO_ENFORCER', '### Architecture Rules lists no rule',
'List at least one rule as a table row: | Rule | Enforced by | Proof |.'));
}
for (const row of rows) {
const line = row.join(' | ');
const checked = /checked by\s+`[^`]+`/i.test(line);
const advisory = /\badvisory\b/i.test(line) && /\.claude\/rules\/[^\s`|]+\.md/.test(line);
if (!checked && !advisory) {
out.push(issue('PLAN_ARCH_RULE_NO_ENFORCER', `Rule without an enforcer: ${row[0] || line}`,
'Each rule says "checked by `<command>`" or "advisory — `.claude/rules/<file>.md`".'));
continue;
}
if (checked) {
const proof = (row[2] ?? '').match(/Step (\d+)/);
if (!proof || !stepNumbers.includes(Number(proof[1]))) {
out.push(issue('PLAN_ARCH_RULE_NO_PROOF',
`Checked rule without an existing proving step: ${row[0] || line}`,
'Name the step (Proof column, "Step N") that shows the check pass, fail on a planted violation, and pass again.'));
}
}
}
}
return out;
}
export function validatePlanContent(rawText, opts = {}) {
const strict = opts.strict !== false;
// CRLF plans (an editor on Windows) used to fail every manifest with
// FM_SYNTAX: the YAML line parser does not expect a trailing \r.
const text = typeof rawText === 'string' ? rawText.replace(/\r\n?/g, '\n') : rawText;
const headRes = validatePlanHeadings(text, { strict });
const errors = [...headRes.errors];
const warnings = [...headRes.warnings];
@ -34,9 +169,22 @@ export function validatePlanContent(text, opts = {}) {
const planVersion = extractPlanVersion(text);
if (planVersion === null) {
warnings.push(issue('PLAN_NO_VERSION', 'No plan_version detected; current target is 1.7'));
} else if (planVersion !== '1.7') {
warnings.push(issue('PLAN_VERSION_MISMATCH', `plan_version=${planVersion}, current target is 1.7`));
warnings.push(issue('PLAN_NO_VERSION', `No plan_version detected; current target is ${PLAN_VERSION_CURRENT}`));
} else if (!PLAN_VERSIONS_SUPPORTED.includes(planVersion)) {
warnings.push(issue('PLAN_VERSION_MISMATCH', `plan_version=${planVersion}, current target is ${PLAN_VERSION_CURRENT}`));
}
const ceremony = extractPlanCeremony(text);
if (versionAtLeast(planVersion, '1.8')) {
const v18 = [...checkSteps(sections)];
if (!ceremony.valid) {
v18.push(issue('PLAN_CEREMONY_INVALID', `ceremony=${ceremony.raw}`,
`Allowed: ${CEREMONY_VALUES.join(' | ')} (absent = architectural).`));
} else if (ceremony.value === 'architectural') {
v18.push(...checkDesign(text, steps.map(s => s.n)));
}
if (strict) errors.push(...v18);
else warnings.push(...v18);
}
// v4.1 SC #20 — MANIFEST_PROFILE_DRIFT detection. Strict-mode only.
@ -66,7 +214,7 @@ export function validatePlanContent(text, opts = {}) {
valid: errors.length === 0,
errors,
warnings,
parsed: { steps, manifests: manRes.parsed, planVersion },
parsed: { steps, manifests: manRes.parsed, planVersion, ceremony: ceremony.value },
};
}
@ -79,7 +227,17 @@ export function validatePlan(filePath, opts = {}) {
return { ...r, parsed: { ...r.parsed, filePath } };
}
if (import.meta.url === `file://${process.argv[1]}`) {
// realpath on both sides: `file://${argv[1]}` silently no-ops under a symlink
// or a path with a space (the CLI then exits 0 having checked nothing).
function isMain() {
try {
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
}
if (isMain()) {
const args = process.argv.slice(2);
const strict = !args.includes('--soft');
const filePath = args.find(a => !a.startsWith('--'));
@ -95,6 +253,7 @@ if (import.meta.url === `file://${process.argv[1]}`) {
warnings: r.warnings,
steps: r.parsed?.steps?.length ?? 0,
planVersion: r.parsed?.planVersion ?? null,
ceremony: r.parsed?.ceremony ?? null,
}, null, 2) + '\n');
} else {
process.stdout.write(`plan-validator: ${r.valid ? 'READY' : 'FAIL'} ${filePath} (${r.parsed?.steps?.length ?? 0} steps)\n`);

View file

@ -0,0 +1,303 @@
// lib/verification/arch-rules.mjs
// K5 — the architecture-rule runner. Runs the target repo's OWN architecture
// tool and reports one verdict. It installs nothing and never reaches for a
// package runner that could (no npx): a tool counts only when its config AND
// a local binary are both present.
//
// node arch-rules.mjs --repo <dir> [--target <path>]...
// 0 = every detected tool passed
// 1 = a tool reported a violation
// 2 = not measured: no tool, a tool that could not run, or a tool that
// checked nothing. Never green.
// JSON on stdout: {code, status, repo, tools: [{tool, status, targets?, reason?, violations?}]}
//
// node arch-rules.mjs --repo <dir> --prove --rule <name> --plant <file>=<repo-relative-dest> [...]
// A rule is accepted only once it has been seen to pass, fail on a planted
// violation, and pass again. Runs the tools, copies each plant in (never
// over an existing path, never outside the repo, symlinks resolved), runs
// them again, removes the plants (and the directories made for them), runs
// a third time. The planted run must report a violation of THIS rule
// (`--rule`: a dependency-cruiser rule name or an import-linter contract
// name); another rule tripping proves nothing.
// 0 = proven (0 → 1 with the rule → 0), 1 = not proven, 2 = not measured
// or the plant could not be placed.
//
// Adapters (output shapes measured against the real tools on 2026-10-06):
// dependency-cruiser 18.5.0 — config .dependency-cruiser.{cjs,js,mjs,json},
// binary node_modules/.bin/depcruise, `--output-type json`. It exits 0
// even with violations; the verdict is `summary.error` (> 0 = violation),
// `summary.totalCruised` 0 = checked nothing. Default targets: every one of
// src/ lib/ app/ packages/ source/ that exists, else the repo root.
// import-linter 2.15 — config .importlinter, setup.cfg [importlinter] or
// pyproject.toml [tool.importlinter]; binary .venv/bin, venv/bin or PATH.
// Exit 0 + "Contracts: N kept, 0 broken." (N > 0) = pass; exit 1 + "M
// broken" (M > 0) = violation, one per "<contract> BROKEN" line; anything
// else (0 contracts included) = not measured. The repo root is put first
// on PYTHONPATH so an uninstalled root package resolves.
// Not used: madge 8.0.0 reported no cycle for a planted .mjs cycle without
// extra configuration (measured) — a false green, so it is not an adapter.
//
// Borrowed: "use the de-facto tool" and the module map from agent-skills
// (MIT); pass → fail → pass from Pocock's skills (MIT).
import {
existsSync, readFileSync, statSync, lstatSync, realpathSync, mkdirSync, copyFileSync,
rmSync, rmdirSync, readdirSync, constants as fsc,
} from 'node:fs';
import { spawnSync } from 'node:child_process';
import { join, resolve, dirname, basename, relative, isAbsolute, delimiter, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
const TIMEOUT_MS = 180_000;
const DEPCRUISE_CONFIGS = ['.dependency-cruiser.cjs', '.dependency-cruiser.js', '.dependency-cruiser.mjs', '.dependency-cruiser.json'];
const DEFAULT_TARGETS = ['src', 'lib', 'app', 'packages', 'source'];
function isFile(p) {
try { return statSync(p).isFile(); } catch { return false; }
}
function isDir(p) {
try { return statSync(p).isDirectory(); } catch { return false; }
}
/** True when anything sits at p — a file, a directory, or a symlink, dangling or not. */
function occupied(p) {
try { lstatSync(p); return true; } catch { return false; }
}
function fileHas(p, re) {
try { return re.test(readFileSync(p, 'utf-8')); } catch { return false; }
}
function onPath(bin) {
for (const d of (process.env.PATH ?? '').split(delimiter)) {
if (d && isFile(join(d, bin))) return join(d, bin);
}
return null;
}
/** Which architecture tools does this repo configure? (configured ≠ runnable) */
export function detectTools(repo) {
const tools = [];
const dcConfig = DEPCRUISE_CONFIGS.find(c => isFile(join(repo, c)));
if (dcConfig) {
const bin = join(repo, 'node_modules', '.bin', 'depcruise');
tools.push({ tool: 'dependency-cruiser', config: dcConfig, bin: isFile(bin) ? bin : null, wanted: 'node_modules/.bin/depcruise' });
}
const ilConfig = isFile(join(repo, '.importlinter')) ? '.importlinter'
: fileHas(join(repo, 'setup.cfg'), /^\[importlinter\]/m) ? 'setup.cfg'
: fileHas(join(repo, 'pyproject.toml'), /^\[tool\.importlinter\]/m) ? 'pyproject.toml'
: null;
if (ilConfig) {
const local = ['.venv', 'venv'].map(v => join(repo, v, 'bin', 'lint-imports')).find(isFile);
tools.push({ tool: 'import-linter', config: ilConfig, bin: local ?? onPath('lint-imports'), wanted: '.venv/bin/lint-imports (or lint-imports on PATH)' });
}
return tools;
}
function exec(bin, args, repo, env = {}) {
return spawnSync(bin, args, {
cwd: repo, encoding: 'utf-8', timeout: TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024,
env: { ...process.env, ...env },
});
}
function runDepcruise(t, repo, targets) {
const tg = targets.length > 0 ? targets : DEFAULT_TARGETS.filter(d => isDir(join(repo, d)));
const use = tg.length > 0 ? tg : ['.'];
const r = exec(t.bin, ['--config', t.config, '--output-type', 'json', ...use], repo);
let j = null;
try { j = JSON.parse(r.stdout); } catch { /* handled below */ }
const s = j?.summary;
if (!s || typeof s.error !== 'number') {
const why = r.error ? r.error.message : (r.stderr || '').trim().split('\n')[0] || `exit ${r.status}, no JSON summary`;
return { status: 'not-measured', targets: use, reason: `dependency-cruiser could not run: ${why}` };
}
if (!s.totalCruised) return { status: 'not-measured', targets: use, reason: 'dependency-cruiser checked 0 modules' };
const violations = (s.violations ?? [])
.filter(v => v?.rule?.severity === 'error')
.map(v => ({ rule: v.rule.name, text: `${v.type ?? 'violation'}: ${v.from} -> ${v.to}` }));
if (s.error > 0) return { status: 'violation', targets: use, violations };
return { status: 'passed', targets: use, checked: s.totalCruised, warn: s.warn ?? 0 };
}
function runImportLinter(t, repo) {
const py = process.env.PYTHONPATH ? `${repo}${delimiter}${process.env.PYTHONPATH}` : repo;
const r = exec(t.bin, ['--no-cache'], repo, { PYTHONPATH: py });
const out = `${r.stdout ?? ''}${r.stderr ?? ''}`;
const m = out.match(/Contracts: (\d+) kept, (\d+) broken\./);
if (!m) {
const why = r.error ? r.error.message : out.trim().split('\n').pop() || `exit ${r.status}`;
return { status: 'not-measured', reason: `import-linter could not run: ${why}` };
}
const kept = Number(m[1]);
const broken = Number(m[2]);
if (kept + broken === 0) return { status: 'not-measured', reason: 'import-linter checked 0 contracts' };
if (r.status === 0 && broken === 0) return { status: 'passed', checked: kept };
if (r.status === 1 && broken > 0) {
const names = [...out.matchAll(/^(.+?) BROKEN$/gm)].map(x => x[1].trim());
const detail = out.split('\n').filter(l => /not allowed to import|^- .+ -> /.test(l)).slice(0, 50).map(l => l.trim());
const violations = names.length > 0
? names.map(n => ({ rule: n, text: `${n} BROKEN${detail.length ? `: ${detail.join(' ')}` : ''}` }))
: [{ rule: 'import-linter', text: detail.join(' ') || 'a contract is broken' }];
return { status: 'violation', violations };
}
return { status: 'not-measured', reason: `import-linter: exit ${r.status} with "${m[0]}"` };
}
/** Run every detected tool once. */
export function runArchRules({ repo, targets = [] }) {
if (!repo || !isDir(repo)) {
return { code: 2, status: 'not-measured', repo, tools: [], reason: `repo dir not found: ${repo}` };
}
const tools = detectTools(repo).map((t) => {
if (!t.bin) return { tool: t.tool, config: t.config, status: 'not-measured', reason: `${t.config} found but no ${t.wanted}; this runner installs nothing` };
const res = t.tool === 'dependency-cruiser' ? runDepcruise(t, repo, targets) : runImportLinter(t, repo);
return { tool: t.tool, config: t.config, ...res };
});
if (tools.length === 0) {
return { code: 2, status: 'not-measured', repo, tools, reason: 'no architecture tool configured in this repo (dependency-cruiser, import-linter)' };
}
if (tools.some(t => t.status === 'violation')) return { code: 1, status: 'violation', repo, tools };
if (tools.some(t => t.status === 'not-measured')) return { code: 2, status: 'not-measured', repo, tools };
return { code: 0, status: 'passed', repo, tools };
}
/**
* Is dest (repo-relative) inside the repo once symlinks are resolved?
* Resolves the deepest existing ancestor, so `link/x` through a symlink that
* points outside the repo is refused.
*/
function insideRepo(repo, dest) {
if (isAbsolute(dest)) return false;
let root;
try { root = realpathSync(repo); } catch { return false; }
let probe = resolve(repo, dest);
const tail = [];
while (!existsSync(probe)) {
tail.unshift(basename(probe));
const up = dirname(probe);
if (up === probe) return false;
probe = up;
}
let real;
try { real = realpathSync(probe); } catch { return false; }
const full = join(real, ...tail);
const rel = relative(root, full);
return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
}
const rulesIn = run => (run.tools ?? []).flatMap(t => (t.violations ?? []).map(v => v.rule));
/** pass → fail on the planted violation of `rule` → pass. */
export function proveRule({ repo, targets = [], plants, rule }) {
const phases = [];
const base = { mode: 'prove', repo, rule: rule ?? null, phases };
const notMeasured = reason => ({ ...base, code: 2, proven: false, reason });
if (typeof rule !== 'string' || rule === '') return notMeasured('--prove needs --rule <name>: the rule the plant must trip');
if (!Array.isArray(plants) || plants.length === 0) return notMeasured('--prove needs at least one --plant <file>=<dest>');
for (const p of plants) {
if (!isFile(p.src)) return notMeasured(`plant source not found: ${p.src}`);
if (!insideRepo(repo, p.dest)) return notMeasured(`plant destination is outside the repo (after resolving symlinks): ${p.dest}`);
if (occupied(join(repo, p.dest))) return notMeasured(`refusing to overwrite an existing path: ${p.dest}`);
}
const before = runArchRules({ repo, targets });
phases.push({ phase: 'before', code: before.code, status: before.status });
if (before.code !== 0) {
return { ...base, code: before.code === 2 ? 2 : 1, proven: false, reason: 'the base does not pass, so a failure after planting would prove nothing', detail: before };
}
const madeDirs = [];
const planted = [];
const cleanup = () => {
for (const f of planted.splice(0)) rmSync(f, { force: true });
for (const d of madeDirs.splice(0).reverse()) {
try { if (readdirSync(d).length === 0) rmdirSync(d); } catch { /* not empty: left in place, never force-removed */ }
}
};
// A Ctrl-C during the planted run must not leave the plants in the repo.
const onSignal = (sig) => { cleanup(); process.exit(sig === 'SIGINT' ? 130 : 143); };
process.once('SIGINT', onSignal);
process.once('SIGTERM', onSignal);
let during;
try {
for (const p of plants) {
const destAbs = join(repo, p.dest);
let d = dirname(destAbs);
const chain = [];
while (!existsSync(d)) { chain.push(d); d = dirname(d); }
for (const c of chain.reverse()) { mkdirSync(c); madeDirs.push(c); }
// EXCL: if something appeared at dest since the check, fail rather than overwrite it.
copyFileSync(p.src, destAbs, fsc.COPYFILE_EXCL);
planted.push(destAbs);
}
during = runArchRules({ repo, targets });
} catch (e) {
cleanup();
process.removeListener('SIGINT', onSignal);
process.removeListener('SIGTERM', onSignal);
return notMeasured(`could not place the plant: ${e.message}`);
}
cleanup();
process.removeListener('SIGINT', onSignal);
process.removeListener('SIGTERM', onSignal);
phases.push({ phase: 'planted', code: during.code, status: during.status, rules: rulesIn(during) });
const after = runArchRules({ repo, targets });
phases.push({ phase: 'after', code: after.code, status: after.status });
if (during.code === 2 || after.code === 2) return { ...base, code: 2, proven: false, reason: 'a run was not measured', detail: { during, after } };
if (during.code !== 1) return { ...base, code: 1, proven: false, reason: 'the tool did not catch the planted violation: the rule does not bite', detail: during };
if (!rulesIn(during).includes(rule)) {
return { ...base, code: 1, proven: false, reason: `the plant tripped ${rulesIn(during).join(', ')}, not ${rule}: this proves nothing about ${rule}`, detail: during };
}
if (after.code !== 0) return { ...base, code: 1, proven: false, reason: 'the check still fails after the plant was removed', detail: after };
return { ...base, code: 0, proven: true, caught: during.tools };
}
// ---- CLI --------------------------------------------------------------------
function isMain() {
try {
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
}
function parseArgs(argv) {
const o = { targets: [], plants: [], prove: false, repo: null, rule: null, bad: null };
const value = (i, flag) => {
const v = argv[i + 1];
if (v === undefined || v.startsWith('--')) { o.bad = o.bad ?? `${flag} needs a value`; return null; }
return v;
};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--repo') { o.repo = value(i, a); i++; }
else if (a === '--target') { const v = value(i, a); if (v) o.targets.push(v); i++; }
else if (a === '--rule') { o.rule = value(i, a); i++; }
else if (a === '--prove') o.prove = true;
else if (a === '--plant') {
const v = value(i, a) ?? '';
i++;
const at = v.lastIndexOf('=');
if (at <= 0 || at === v.length - 1) o.bad = o.bad ?? `--plant expects <file>=<dest>, got "${v}"`;
else o.plants.push({ src: resolve(v.slice(0, at)), dest: v.slice(at + 1).split('/').join(sep) });
} else o.bad = o.bad ?? `unknown argument: ${a}`;
}
return o;
}
if (isMain()) {
const o = parseArgs(process.argv.slice(2));
let result;
if (o.bad || !o.repo) {
result = { code: 2, status: 'not-measured', reason: o.bad ?? 'usage: arch-rules.mjs --repo <dir> [--target <path>]... [--prove --rule <name> --plant <file>=<dest>...]' };
} else {
const repo = resolve(o.repo);
result = o.prove
? proveRule({ repo, targets: o.targets, plants: o.plants, rule: o.rule })
: runArchRules({ repo, targets: o.targets });
}
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
process.exit(result.code);
}

View file

@ -0,0 +1,202 @@
// lib/verification/plan-scope-sweep.mjs
// K6 — the sweep for files the plan does not name.
//
// node plan-scope-sweep.mjs --plan <plan.md> --base <ref> [--repo <dir>] [--process <path|glob>]...
// 0 = every changed file is named by the plan or is a process file
// 1 = deviation: changed files outside the plan (listed in `outside`)
// 2 = could not run (not a git repo, unknown ref, plan missing or without steps)
// JSON on stdout: {code, status, base, merge_base, changed, outside, planned, process}
//
// changed = `git diff --name-only --no-renames <merge-base>` (committed AND
// uncommitted tracked changes) + untracked, non-ignored files
// planned = every step's `Files:` paths, its `Test first:` File, and its
// manifest `expected_paths`
// process = PROCESS_FILES (the plan itself, its project directory, Voyage's
// own planning areas) + any `--process` entries
// outside = changed − planned − process. It must be empty.
//
// A deviation is allowed only when the plan names the file in the same
// commit: the sweep reads the plan from the working tree, so adding the path
// to the step's `Files:` is what clears it.
//
// `--repo` defaults to the current directory. Paths in the plan are relative
// to the repository root (git top level), as git reports changed files.
// Paths in a plan may end in `/` (a directory) or use `*` / `**` globs; a glob
// must start with a fixed directory (`src/**` is fine, `**/*` would plan the
// whole repo and is ignored and reported in `ignored`). git output is read
// NUL-separated (-z), so non-ASCII names and spaces arrive unquoted.
//
// Borrowed: the sweep for files outside the plan from ai-native-sdlc (MIT).
// Voyage before this checked only each step's expected and forbidden paths.
import { readFileSync, existsSync, realpathSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { resolve, relative, dirname, isAbsolute, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { sliceSteps, parseStepFields } from '../parsers/plan-schema.mjs';
import { parseManifest } from '../parsers/manifest-yaml.mjs';
// Files Voyage's own process writes while planning and executing.
export const PROCESS_FILES = Object.freeze([
'.claude/projects/**',
'.claude/plans/**',
'.claude/trekplan-sessions/**',
]);
const BACKTICK_SPAN = /`([^`\n]+)`/g;
// Backticked: spaces allowed (`docs/my file.md`); bare: no whitespace.
const looksLikeTickedPath = s => /[./]/.test(s) && !/^https?:/.test(s) && !s.startsWith('-') && s.trim() === s;
const looksLikePath = s => !/\s/.test(s) && looksLikeTickedPath(s);
function pathsIn(text) {
const out = [];
for (const m of text.matchAll(BACKTICK_SPAN)) if (looksLikeTickedPath(m[1])) out.push(m[1]);
return out;
}
const norm = p => p.replace(/\\/g, '/').replace(/^\.\//, '');
/** Every path the plan names: Files, Test first File, manifest expected_paths. */
export function plannedPaths(planText) {
const out = new Set();
for (const s of sliceSteps(planText)) {
const f = parseStepFields(s.body);
const files = f.get('Files');
if (files) {
const ticked = pathsIn(files.text);
// Older plans write bare paths: `- Files: a.ts, b.ts`.
const bare = ticked.length ? [] : files.text.split(/[,\s]+/).filter(looksLikePath);
for (const p of [...ticked, ...bare]) out.add(norm(p));
}
const tf = f.get('Test first');
if (tf) {
for (const line of tf.text.split('\n')) {
if (/\bFile:/i.test(line)) for (const p of pathsIn(line)) out.add(norm(p));
}
}
const man = parseManifest(s.body);
for (const p of man?.parsed?.expected_paths ?? []) {
if (typeof p === 'string') out.add(norm(p));
}
}
return [...out].sort();
}
function globToRegExp(glob) {
let re = '';
for (let i = 0; i < glob.length; i++) {
const c = glob[i];
if (c === '*' && glob[i + 1] === '*') { re += '.*'; i++; if (glob[i + 1] === '/') i++; }
else if (c === '*') re += '[^/]*';
else if (c === '?') re += '[^/]';
else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
}
return new RegExp(`^${re}$`);
}
// A glob with no fixed directory in front (`*`, `**/*`, `*.js`) names no place.
export const unboundedGlob = p => /[*?]/.test(p) && /[*?]/.test(norm(p).split('/')[0]);
function matcher(patterns) {
const exact = new Set();
const dirs = [];
const globs = [];
for (const p of patterns.map(norm)) {
if (p.endsWith('/')) dirs.push(p);
else if (/[*?]/.test(p)) globs.push(globToRegExp(p));
else exact.add(p);
}
return f => exact.has(f) || dirs.some(d => f.startsWith(d)) || globs.some(g => g.test(f));
}
function git(repo, args) {
const r = spawnSync('git', ['-C', repo, ...args], { encoding: 'utf-8', maxBuffer: 64 * 1024 * 1024 });
if (r.status !== 0) throw new Error(`git ${args[0]}: ${(r.stderr || r.error?.message || '').trim().split('\n')[0]}`);
return r.stdout;
}
const nulList = out => out.split('\0').filter(Boolean);
/**
* @returns {{code: 0|1|2, status: string, outside?: string[], changed?: string[], planned?: string[], reason?: string}}
*/
export function sweep({ plan, base, repo = process.cwd(), process: extra = [] }) {
const repoAbs = resolve(repo);
const planAbs = resolve(repoAbs, plan);
if (!existsSync(planAbs)) return { code: 2, status: 'error', reason: `plan not found: ${plan}` };
let top;
let mergeBase;
let changed;
let planText;
try {
planText = readFileSync(planAbs, 'utf-8');
top = git(repoAbs, ['rev-parse', '--show-toplevel']).trim();
mergeBase = git(repoAbs, ['merge-base', base, 'HEAD']).trim();
const tracked = nulList(git(top, ['diff', '--name-only', '--no-renames', '-z', mergeBase]));
const untracked = nulList(git(top, ['ls-files', '--others', '--exclude-standard', '-z']));
changed = [...new Set([...tracked, ...untracked])].sort();
} catch (e) {
return { code: 2, status: 'error', reason: e.message };
}
if (sliceSteps(planText).length === 0) return { code: 2, status: 'error', reason: `plan has no "### Step N:" steps: ${plan}` };
const all = plannedPaths(planText);
const ignored = all.filter(unboundedGlob);
const planned = all.filter(p => !unboundedGlob(p));
const planRel = norm(relative(realpathSync(top), realpathSync(planAbs)).split(sep).join('/'));
const processList = [...PROCESS_FILES, planRel, ...extra];
const planDir = dirname(planRel);
if (planDir !== '.' && planDir.startsWith('.claude/')) processList.push(`${planDir}/`);
const isPlanned = matcher(planned);
const isProcess = matcher(processList);
const outside = changed.filter(f => !isPlanned(f) && !isProcess(f));
return {
code: outside.length === 0 ? 0 : 1,
status: outside.length === 0 ? 'clean' : 'deviation',
base,
merge_base: mergeBase,
changed,
outside,
planned,
ignored,
process: processList,
};
}
// ---- CLI --------------------------------------------------------------------
function isMain() {
try {
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
}
if (isMain()) {
const argv = process.argv.slice(2);
const o = { process: [] };
let bad = null;
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
const v = argv[i + 1];
if (['--plan', '--base', '--repo', '--process'].includes(a)) {
if (v === undefined || v.startsWith('--')) { bad = bad ?? `${a} needs a value`; continue; }
i++;
if (a === '--plan') o.plan = v;
else if (a === '--base') o.base = v;
else if (a === '--repo') o.repo = v;
else o.process.push(v);
} else bad = bad ?? `unknown argument: ${a}`;
}
let result;
if (bad || !o.plan || !o.base) {
result = { code: 2, status: 'error', reason: bad ?? 'usage: plan-scope-sweep.mjs --plan <plan.md> --base <ref> [--repo <dir>] [--process <path|glob>]...' };
} else {
result = sweep(o);
}
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
if (result.code === 1) process.stderr.write(`plan-scope-sweep: ${result.outside.length} file(s) outside the plan:\n${result.outside.map(f => ` ${f}`).join('\n')}\n`);
process.exit(result.code);
}

View file

@ -0,0 +1,114 @@
<!--
The design part of a plan (plan_version 1.8). /trekplan Phase 8 writes it as
the plan's `## Design` section, between `## Context` and
`## Implementation Plan`, ONLY when the brief's `ceremony:` is
`architectural` (an absent field means architectural: heaviest on doubt).
`small` and `bounded` plans omit it.
The design lives in the PLAN, never in the brief: the brief format is a public
contract (Handover 1), the plan contract is Voyage's own (Handover 4).
`plan-validator --strict` checks, for an architectural 1.8 plan: the eight
`###` subsections below exist, the module map is a mermaid graph with no
cycle, every architecture rule names its enforcer, a checked rule names the
step that proves it, and an advisory rule names its `.claude/rules/` file.
`### Decisions (ADR)` and `### Gotchas` are optional.
Borrowed, with credit:
- Section list: Kiro's design.md (architecture, data flow, interfaces, data
models, error handling, test strategy), and its requirements analysis asked
as two-choice questions. Kiro's SMT engine is not borrowed.
- Design questions: obra/superpowers@8ca22db brainstorming (MIT, © 2025 Jesse
Vincent).
- Module map with one dependency direction and no cycles, and "use the
de-facto tool" (e.g. dependency-cruiser): addyosmani/agent-skills@9d0c60d
(MIT, © 2025 Addy Osmani).
- Vocabulary (deep module, seam, deletion test), the three-condition ADR bar,
and pass → fail → pass: mattpocock/skills@c55ee46 (MIT, © 2026 Matt Pocock).
- Rules that must pass: GitHub Spec Kit's constitution (MIT), changed so each
rule names what enforces it; in Spec Kit the model ticks the boxes itself.
- Gotchas and "Derived from <hash>": bashebr/ai-native-sdlc@11fb8d7 (MIT).
-->
## Design
> Derived from: `{brief path}` @ sha256 `{first 12 hex of the brief's sha256}` · ceremony: architectural
### Architecture
The modules this change adds or alters, and what each one owns. Judge each by
the deletion test: if deleting the module would only move its complexity into
its callers, it earns its place; a deep module hides much behind a small
interface. Name the seams where behaviour can be tested or swapped.
### Data Flow
How data enters, moves through and leaves the changed modules — one path per
use case in the brief, in order.
### Interfaces
Every boundary this plan creates or changes: signature, inputs, outputs,
errors. Steps cite these in `Interface in:` / `Interface out:`.
### Data Models
Types, schemas, files and their fields. Mark what is new and what changes shape.
### Error Handling
Each failure mode, what the system does about it, and what the user sees.
Unreadable input, a missing dependency and a partial failure are always listed.
### Module Map
Dependencies point one way. The validator rejects a cycle.
```mermaid
graph TD
cli[src/cli] --> core[src/core]
core --> store[src/store]
```
### Architecture Rules
Each rule names what enforces it. **checked by `<command>`** — a command the
target repo already has (its own dependency-cruiser, import-linter, linter or a
test); `lib/verification/arch-rules.mjs` runs dependency-cruiser and
import-linter when the repo configures them, and installs nothing. **advisory —
`.claude/rules/<file>.md`** — guidance Claude Code loads in the target repo; a
plan step writes that file. A checked rule is accepted only once it has been
seen to pass, fail on a planted violation, and pass again: the Proof column
names the step that shows it (`arch-rules.mjs --prove --rule <name> --plant
<file>=<dest>` does it in one command for the two supported tools; the planted
run must trip that rule).
| Rule | Enforced by | Proof |
|------|-------------|-------|
| {core never imports cli} | checked by `{node_modules/.bin/depcruise --config .dependency-cruiser.cjs src}` | Step {N} |
| {prefer pure functions in core} | advisory — `.claude/rules/{core}.md` | — |
### Requirements Check
Conflicts and gaps between the brief's requirements, each asked once as a
two-choice question with the recommendation first: **keep** (the requirement as
written) or **change like this** (a concrete proposal). Record the answer here.
An answer that changes the brief's Intent, Goal or Success Criteria stops the
plan: the brief is re-run through `/trekbrief`, never edited by the planner.
| # | Finding | Question | Answer |
|---|---------|----------|--------|
| 1 | {two requirements that cannot both hold / a case no requirement covers} | Keep, or change like this: {proposal}? | {keep / change} |
*If there is nothing to ask, write "No conflicts or gaps found" and say what was compared.*
### Decisions (ADR)
*Optional.* Only for a decision that is hard to reverse, surprising without
context, AND the result of a real trade-off. One paragraph each: context,
decision, alternatives rejected and why.
### Gotchas
*Optional.* Each place two constraints collide, the risky call made there, and
who decided it.

View file

@ -18,13 +18,24 @@ source_findings:
> **Plan quality: {grade}** ({score}/100) — {APPROVE | APPROVE_WITH_NOTES | REVISE | REPLAN}
>
> Generated by trekplan v{version} on {YYYY-MM-DD} — `plan_version: 1.7`
> Generated by trekplan v{version} on {YYYY-MM-DD} — `plan_version: 1.8` · `ceremony: {small | bounded | architectural}`
## Context
Why this change is needed. The problem or need it addresses, what prompted it,
and the intended outcome. Reference the spec file if one was used.
## Global Constraints
The brief's Constraints and Non-Functional Requirements that bind more than one
step, **quoted verbatim** — exact values, never paraphrased. A step that could
break one quotes it again in its `Review focus:`.
- > "{constraint, verbatim from the brief}" — brief § Constraints
<!-- ceremony: architectural (or absent) → insert the design part here, from
templates/design-template.md (## Design). small | bounded → omit it. -->
## Architecture Diagram
```mermaid
@ -57,14 +68,25 @@ their relationships, and the data flow between them.*
## Implementation Plan
Each step targets 1–2 files and one focused change. Steps follow TDD structure
when the project has tests.
Each step targets 1–2 files and one focused change, and is a vertical slice:
a narrow but complete path through every layer it touches, verifiable on its
own. Preparatory refactoring comes first, as its own steps. Every step has
`Test first:` (plan_version 1.8 requires it), and one step runs the thing
end to end (`End-to-end:`).
Optional per-step fields: `Interface in:` (what the step consumes, and which
earlier step or existing code produces it), `Interface out:` (what it produces
for later steps), `Review focus:` (inputs the brief implies but no test
exercises yet, each pinned to a test; and any Global Constraint the step could
break, quoted verbatim). A field that is present is never empty.
### Step 1: {description}
- **Files:** `path/to/file.ts`
- **Changes:** {exactly what to modify — no placeholders, no "update as needed"}
- **Reuses:** {existing function/pattern from codebase, with file path}
- **Interface out:** `{name(args) → result}` — read by Step 2
- **Review focus:** {untested input the brief implies} — pinned by `path/to/test.ts`
- **Test first:**
- File: `path/to/test.ts` *(existing | new)*
- Verifies: {what the test checks}
@ -84,15 +106,17 @@ when the project has tests.
must_contain: []
```
### Step 2: {description}
### Step 2: {description — the "run the thing" step}
- **Files:** `path/to/file.ts`
- **Changes:** {exactly what to modify}
- **Reuses:** {existing function/pattern}
- **Interface in:** `{name(args) → result}` from Step 1
- **Test first:**
- File: `path/to/test.ts` *(existing | new)*
- Verifies: {what the test checks}
- Pattern: `path/to/existing-test.ts`
- **End-to-end:** `{the command that runs the product the way a user does — the app, the CLI, the simulator}` → expected: {what the user sees}
- **Verify:** `{exact command}` → expected: `{output}`
- **On failure:** {revert | retry | skip | escalate} — {specific instructions}
- **Checkpoint:** `git commit -m "{conventional commit message}"`
@ -110,8 +134,10 @@ when the project has tests.
pattern: "expected content marker"
```
*For projects without tests: omit "Test first" and keep "Verify" with a
concrete command (e.g., run the app, check output, curl an endpoint).*
*A project without tests still gets `Test first:` on every step: the first step
adds the test runner (or a script under `tests/`) the later steps use. A plan
without an `End-to-end:` step is invalid at plan_version 1.8 — without it the
result can only be "not verified end to end", never green.*
### Manifest — objective completion predicate
@ -257,3 +283,10 @@ later waves depend on earlier waves completing first.*
| # | Finding | Severity | Resolution |
|---|---------|----------|------------|
| 1 | {what was wrong} | {blocker/major/minor} | {how it was fixed} |
<!-- ## Critic Verdict — do NOT write this section. `lib/validators/plan-gate.mjs
--record` appends it after the first critic round (Phase 9) and replaces it on
each later round: one fenced json block with round, max_rounds, plan_sha256
(the plan minus its review log), verdict BLOCK | REVISE | PASS, and the
deduplicated findings. `plan-gate.mjs <plan>` exits 0 only when the plan
validates, the block matches the current plan, and no BLOCKER is open. -->

View file

@ -0,0 +1,129 @@
// tests/helpers/plan-v18-builder.mjs
// Builds plan_version 1.8 plans for the plan-contract tests (K3 gate, K4 step
// fields, the design part, the scope sweep). One builder, many variants, so a
// test states only the difference it is about.
function manifest(paths) {
return [
'- **Manifest:**',
' ```yaml',
' manifest:',
' expected_paths:',
...paths.map(p => ` - ${p}`),
` min_file_count: ${paths.length}`,
' commit_message_pattern: "^feat\\\\(x\\\\):"',
' bash_syntax_check: []',
' forbidden_paths: []',
' must_contain: []',
' ```',
].join('\n');
}
export function step(n, opts = {}) {
const file = opts.file ?? `src/mod${n}.mjs`;
const test = opts.test ?? `tests/mod${n}.test.mjs`;
const lines = [
`### Step ${n}: ${opts.title ?? `Build module ${n}`}`,
'',
`- **Files:** \`${file}\``,
`- **Changes:** Add \`run${n}()\` to \`${file}\`. (new file)`,
];
if (opts.interfaceIn !== undefined) lines.push(`- **Interface in:** ${opts.interfaceIn}`);
if (opts.interfaceOut !== undefined) lines.push(`- **Interface out:** ${opts.interfaceOut}`);
if (opts.reviewFocus !== undefined) lines.push(`- **Review focus:** ${opts.reviewFocus}`);
if (opts.testFirst !== false) {
lines.push(
'- **Test first:**',
` - File: \`${test}\` (new)`,
` - Verifies: run${n}() returns the value the next step reads`,
' - Pattern: `tests/lib/plan-schema.test.mjs`',
);
}
if (opts.endToEnd) lines.push(`- **End-to-end:** \`${opts.endToEnd}\` → expected: exit 0 and the CLI prints a result`);
lines.push(
`- **Verify:** \`node --test ${test}\` → expected: pass`,
`- **On failure:** revert — \`git checkout -- ${file}\``,
`- **Checkpoint:** \`git commit -m "feat(x): step ${n}"\``,
manifest(opts.manifestPaths ?? [file, test]),
);
return lines.join('\n');
}
export const DESIGN = [
'## Design',
'',
'### Architecture',
'Two modules: a parser and a CLI on top of it.',
'',
'### Data Flow',
'CLI argv → parser → result JSON on stdout.',
'',
'### Interfaces',
'`parse(text) → {ok, items}`',
'',
'### Data Models',
'`Item = {name: string, n: number}`',
'',
'### Error Handling',
'Unreadable input → exit 2; invalid input → exit 1.',
'',
'### Module Map',
'```mermaid',
'graph TD',
' cli[src/cli.mjs] --> parser[src/parser.mjs]',
' parser --> util[src/util.mjs]',
'```',
'',
'### Architecture Rules',
'| Rule | Enforced by | Proof |',
'|------|-------------|-------|',
'| The parser never imports the CLI | checked by `node_modules/.bin/depcruise --config .dependency-cruiser.cjs src` | Step 1 |',
'| Prefer pure functions in the parser | advisory — `.claude/rules/parser.md` | — |',
'',
'### Requirements Check',
'No conflicts or gaps found between the brief\'s requirements.',
].join('\n');
/**
* Build a plan.
* @param {object} o
* @param {string} [o.version='1.8']
* @param {string|null} [o.ceremony] - header value; null = omit (→ architectural)
* @param {boolean} [o.design=true]
* @param {string} [o.designText]
* @param {Array<object>} [o.steps] - step() option objects
*/
export function buildPlan(o = {}) {
const version = o.version ?? '1.8';
const ceremony = o.ceremony === undefined ? 'architectural' : o.ceremony;
const header = ceremony === null
? `> Generated by trekplan v6.1.1 on 2026-10-06 — \`plan_version: ${version}\``
: `> Generated by trekplan v6.1.1 on 2026-10-06 — \`plan_version: ${version}\` · \`ceremony: ${ceremony}\``;
const stepOpts = o.steps ?? [{}, { endToEnd: 'node src/cli.mjs --demo' }];
const parts = [
'# Test plan',
'',
'> **Plan quality: A** (92/100) — APPROVE',
'>',
header,
'',
'## Context',
'',
'Why: the brief asks for a parser CLI.',
'',
];
if (o.design !== false) parts.push(o.designText ?? DESIGN, '');
parts.push('## Implementation Plan', '');
stepOpts.forEach((s, i) => parts.push(step(i + 1, s), ''));
parts.push(
'## Test Strategy',
'',
'- **Framework:** node:test',
'',
'## Verification',
'',
'- [ ] `npm test` → expected: exit 0',
'',
);
return parts.join('\n');
}

View file

@ -0,0 +1,265 @@
// tests/lib/arch-rules.test.mjs
// K5 — the architecture-rule runner. `arch-rules.mjs --repo <dir>`: 0 = passed,
// 1 = violation, 2 = not measured (never green). JSON on stdout. It runs the
// target repo's OWN tool and installs nothing: no local tool → 2.
//
// The tools are test doubles that print the shapes measured from the real tools
// on 2026-10-06: dependency-cruiser 18.5.0 `--output-type json` (exit 0 even
// with violations; the verdict is `summary.error`, the violation carries
// `type: "cycle"`, `rule.name`, `from`, `to`) and import-linter 2.15 (exit 0
// kept, exit 1 broken, summary line `Contracts: N kept, M broken.`). The
// dependency-cruiser double really walks relative imports, so the planted
// cycle below is a real cycle in the fixture source.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, chmodSync, existsSync, symlinkSync, lstatSync, readlinkSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const RUNNER = join(HERE, '..', '..', 'lib', 'verification', 'arch-rules.mjs');
const DEPCRUISE_DOUBLE = `#!/usr/bin/env node
// Test double for dependency-cruiser: walks relative ESM imports and reports
// cycles in the JSON shape dependency-cruiser 18.5.0 prints.
const fs = require('node:fs'); const path = require('node:path');
const args = process.argv.slice(2);
if (process.env.DOUBLE_CRASH) { process.stderr.write('ERROR: boom'); process.exit(1); }
const targets = []; for (let i = 0; i < args.length; i++) { if (args[i].startsWith('--')) { i++; continue; } targets.push(args[i]); }
const files = [];
const walk = d => { for (const e of fs.readdirSync(d, { withFileTypes: true })) { const p = path.join(d, e.name); if (e.isDirectory()) walk(p); else if (p.endsWith('.mjs')) files.push(p); } };
for (const t of targets) { if (!fs.existsSync(t)) { process.stderr.write("ERROR: Can't open '" + t + "' for reading. Does it exist?"); process.exit(1); } walk(t); }
const edges = {};
for (const f of files) { edges[f] = []; for (const m of fs.readFileSync(f, 'utf8').matchAll(/from '(\\.[^']+)'/g)) edges[f].push(path.join(path.dirname(f), m[1])); }
const violations = []; const seen = new Set();
for (const a of files) for (const b of edges[a]) if ((edges[b] || []).includes(a) && !seen.has([a, b].sort().join())) {
seen.add([a, b].sort().join());
violations.push({ type: 'cycle', from: a, to: b, rule: { severity: 'error', name: 'no-circular' }, cycle: [{ name: b }, { name: a }] });
}
process.stdout.write(JSON.stringify({ modules: files.map(f => ({ source: f })), summary: { violations, error: violations.length, warn: 0, info: 0, ignore: 0, advisedExitCode: violations.length ? 1 : 0, totalCruised: process.env.DOUBLE_EMPTY ? 0 : files.length } }));
`;
const LINT_IMPORTS_DOUBLE = `#!/usr/bin/env node
const mode = require('node:fs').readFileSync(process.cwd() + '/linter-mode', 'utf8').trim();
if (mode === 'kept') { console.log('ui above domain KEPT\\n\\nContracts: 1 kept, 0 broken.'); process.exit(0); }
if (mode === 'broken') { console.log('ui above domain BROKEN\\n\\nContracts: 0 kept, 1 broken.\\n\\napp.domain is not allowed to import app.ui:\\n\\n- app.domain.core -> app.ui.view (l.1)'); process.exit(1); }
if (mode === 'zero') { console.log('Contracts: 0 kept, 0 broken.'); process.exit(0); }
console.log('Could not read any configuration.'); process.exit(1);
`;
function exe(path, body) {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, body);
chmodSync(path, 0o755);
}
function jsRepo({ cycle = false, binary = true } = {}) {
const dir = mkdtempSync(join(tmpdir(), 'arch-rules-'));
mkdirSync(join(dir, 'src'));
writeFileSync(join(dir, 'src', 'a.mjs'), "import { b } from './b.mjs';\nexport const a = 1;\n");
writeFileSync(join(dir, 'src', 'b.mjs'), cycle ? "import { a } from './a.mjs';\nexport const b = 2;\n" : 'export const b = 2;\n');
writeFileSync(join(dir, '.dependency-cruiser.cjs'), "module.exports = { forbidden: [{ name: 'no-circular', severity: 'error', from: {}, to: { circular: true } }] };\n");
if (binary) exe(join(dir, 'node_modules', '.bin', 'depcruise'), DEPCRUISE_DOUBLE);
return dir;
}
function run(args, env = {}) {
const r = spawnSync('node', [RUNNER, ...args], { encoding: 'utf-8', env: { ...process.env, ...env } });
let json = null;
try { json = JSON.parse(r.stdout); } catch { /* asserted by caller */ }
return { code: r.status, json, out: r.stdout, err: r.stderr };
}
const cleanup = d => rmSync(d, { recursive: true, force: true });
// ---- the tests that failed first ----
test('K5: a planted cycle under the repo\'s own dependency-cruiser → 1, violation listed', () => {
const dir = jsRepo({ cycle: true });
try {
const r = run(['--repo', dir]);
assert.equal(r.code, 1, r.out + r.err);
assert.equal(r.json.status, 'violation');
const t = r.json.tools.find(x => x.tool === 'dependency-cruiser');
assert.equal(t.status, 'violation');
assert.equal(t.violations[0].rule, 'no-circular');
assert.match(t.violations[0].text, /src\/a\.mjs/);
} finally { cleanup(dir); }
});
test('K5: a repo with no architecture tool → 2 "not measured", never green', () => {
const dir = mkdtempSync(join(tmpdir(), 'arch-rules-none-'));
try {
writeFileSync(join(dir, 'index.mjs'), 'export {};\n');
const r = run(['--repo', dir]);
assert.equal(r.code, 2, r.out + r.err);
assert.equal(r.json.status, 'not-measured');
assert.deepEqual(r.json.tools, []);
} finally { cleanup(dir); }
});
test('K5: the same repo without the cycle → 0', () => {
const dir = jsRepo();
try {
const r = run(['--repo', dir]);
assert.equal(r.code, 0, r.out + r.err);
assert.equal(r.json.status, 'passed');
} finally { cleanup(dir); }
});
test('K5: config without a local binary → 2 (never npx, never installs)', () => {
const dir = jsRepo({ binary: false });
try {
const r = run(['--repo', dir]);
assert.equal(r.code, 2);
assert.match(r.json.tools[0].reason, /node_modules\/\.bin\/depcruise/);
assert.ok(!existsSync(join(dir, 'node_modules')), 'the runner must not create node_modules');
} finally { cleanup(dir); }
});
test('K5: a tool that crashes or cruises nothing → 2', () => {
const dir = jsRepo({ cycle: true });
try {
assert.equal(run(['--repo', dir], { DOUBLE_CRASH: '1' }).code, 2);
assert.equal(run(['--repo', dir], { DOUBLE_EMPTY: '1' }).code, 2);
assert.equal(run(['--repo', dir, '--target', 'nosuchdir']).code, 2);
} finally { cleanup(dir); }
});
test('K5: import-linter — kept → 0, broken → 1, no summary line → 2', () => {
const dir = mkdtempSync(join(tmpdir(), 'arch-rules-py-'));
try {
writeFileSync(join(dir, '.importlinter'), '[importlinter]\nroot_package = app\n');
exe(join(dir, '.venv', 'bin', 'lint-imports'), LINT_IMPORTS_DOUBLE);
writeFileSync(join(dir, 'linter-mode'), 'kept');
assert.equal(run(['--repo', dir]).code, 0);
writeFileSync(join(dir, 'linter-mode'), 'broken');
const broken = run(['--repo', dir]);
assert.equal(broken.code, 1);
assert.equal(broken.json.tools[0].violations[0].rule, 'ui above domain', 'the broken contract is the rule');
assert.match(broken.json.tools[0].violations[0].text, /not allowed to import/);
writeFileSync(join(dir, 'linter-mode'), 'noconfig');
assert.equal(run(['--repo', dir]).code, 2);
writeFileSync(join(dir, 'linter-mode'), 'zero');
assert.equal(run(['--repo', dir]).code, 2, '0 contracts checked is not measured, not passed');
} finally { cleanup(dir); }
});
test('K5: a missing --repo dir → 2', () => {
assert.equal(run(['--repo', '/nonexistent/repo']).code, 2);
});
// ---- pass → fail on a planted violation → pass ----
test('--prove: pass → fail on the planted cycle → pass, and the plant is removed', () => {
const dir = jsRepo();
const plantDir = mkdtempSync(join(tmpdir(), 'arch-plant-'));
try {
writeFileSync(join(plantDir, 'c.mjs'), "import { d } from './d.mjs';\nexport const c = 1;\n");
writeFileSync(join(plantDir, 'd.mjs'), "import { c } from './c.mjs';\nexport const d = 1;\n");
const r = run(['--repo', dir, '--prove', '--rule', 'no-circular',
'--plant', `${join(plantDir, 'c.mjs')}=src/planted/c.mjs`,
'--plant', `${join(plantDir, 'd.mjs')}=src/planted/d.mjs`]);
assert.equal(r.code, 0, r.out + r.err);
assert.equal(r.json.proven, true);
assert.deepEqual(r.json.phases.map(p => p.code), [0, 1, 0]);
assert.ok(!existsSync(join(dir, 'src', 'planted')), 'planted files and the dirs made for them are removed');
} finally { cleanup(dir); cleanup(plantDir); }
});
test('--prove: a plant the tool does not catch → 1 (the rule is not proven)', () => {
const dir = jsRepo();
const plantDir = mkdtempSync(join(tmpdir(), 'arch-plant-'));
try {
writeFileSync(join(plantDir, 'e.mjs'), 'export const e = 1;\n');
const r = run(['--repo', dir, '--prove', '--rule', 'no-circular', '--plant', `${join(plantDir, 'e.mjs')}=src/e.mjs`]);
assert.equal(r.code, 1, r.out);
assert.equal(r.json.proven, false);
assert.ok(!existsSync(join(dir, 'src', 'e.mjs')));
} finally { cleanup(dir); cleanup(plantDir); }
});
test('--prove refuses to overwrite an existing file → 2, file untouched', () => {
const dir = jsRepo();
const plantDir = mkdtempSync(join(tmpdir(), 'arch-plant-'));
try {
writeFileSync(join(plantDir, 'x.mjs'), 'export const x = 1;\n');
const r = run(['--repo', dir, '--prove', '--rule', 'no-circular', '--plant', `${join(plantDir, 'x.mjs')}=src/a.mjs`]);
assert.equal(r.code, 2, r.out);
assert.ok(existsSync(join(dir, 'src', 'a.mjs')));
} finally { cleanup(dir); cleanup(plantDir); }
});
// ---- review findings (independent code review, 06.10) ----
function cyclePlants() {
const plantDir = mkdtempSync(join(tmpdir(), 'arch-plant-'));
writeFileSync(join(plantDir, 'c.mjs'), "import { d } from './d.mjs';\nexport const c = 1;\n");
writeFileSync(join(plantDir, 'd.mjs'), "import { c } from './c.mjs';\nexport const d = 1;\n");
return plantDir;
}
test('--prove refuses a plant destination outside the repo: ../ and through a symlink → 2, nothing written', () => {
const dir = jsRepo();
const outside = mkdtempSync(join(tmpdir(), 'arch-outside-'));
const plantDir = cyclePlants();
try {
symlinkSync(outside, join(dir, 'src', 'link'));
for (const dest of ['../escaped.mjs', 'src/link/escaped.mjs']) {
const r = run(['--repo', dir, '--prove', '--rule', 'no-circular', '--plant', `${join(plantDir, 'c.mjs')}=${dest}`]);
assert.equal(r.code, 2, `${dest}: ${r.out}`);
assert.match(r.json.reason, /outside the repo/);
}
assert.ok(!existsSync(join(outside, 'escaped.mjs')));
assert.ok(!existsSync(join(dir, '..', 'escaped.mjs')));
} finally { cleanup(dir); cleanup(outside); cleanup(plantDir); }
});
test('--prove refuses a dangling symlink at the destination and leaves it in place', () => {
const dir = jsRepo();
const plantDir = cyclePlants();
try {
symlinkSync(join(dir, 'nowhere.mjs'), join(dir, 'src', 'dangling.mjs'));
const r = run(['--repo', dir, '--prove', '--rule', 'no-circular', '--plant', `${join(plantDir, 'c.mjs')}=src/dangling.mjs`]);
assert.equal(r.code, 2, r.out);
assert.ok(lstatSync(join(dir, 'src', 'dangling.mjs')).isSymbolicLink());
assert.equal(readlinkSync(join(dir, 'src', 'dangling.mjs')), join(dir, 'nowhere.mjs'));
assert.ok(!existsSync(join(dir, 'nowhere.mjs')));
} finally { cleanup(dir); cleanup(plantDir); }
});
test('--prove: a plant that trips another rule proves nothing about the named one → 1', () => {
const dir = jsRepo();
const plantDir = cyclePlants();
try {
const r = run(['--repo', dir, '--prove', '--rule', 'no-orphans',
'--plant', `${join(plantDir, 'c.mjs')}=src/p/c.mjs`, '--plant', `${join(plantDir, 'd.mjs')}=src/p/d.mjs`]);
assert.equal(r.code, 1, r.out);
assert.match(r.json.reason, /not no-orphans/);
assert.ok(!existsSync(join(dir, 'src', 'p')));
assert.equal(run(['--repo', dir, '--prove', '--plant', `${join(plantDir, 'c.mjs')}=src/p/c.mjs`]).code, 2, '--rule is required');
} finally { cleanup(dir); cleanup(plantDir); }
});
test('K5: with src/ and lib/ both present, a cycle in lib/ is caught (every default dir is cruised)', () => {
const dir = jsRepo();
try {
mkdirSync(join(dir, 'lib'));
writeFileSync(join(dir, 'lib', 'x.mjs'), "import { y } from './y.mjs';\nexport const x = 1;\n");
writeFileSync(join(dir, 'lib', 'y.mjs'), "import { x } from './x.mjs';\nexport const y = 1;\n");
const r = run(['--repo', dir]);
assert.equal(r.code, 1, r.out);
assert.deepEqual(r.json.tools[0].targets, ['src', 'lib']);
} finally { cleanup(dir); }
});
test('K5: a flag without its value → 2, never a crash', () => {
const dir = jsRepo();
try {
for (const args of [['--repo', dir, '--target'], ['--repo'], ['--repo', dir, '--prove', '--rule']]) {
const r = run(args);
assert.equal(r.code, 2, `${args.join(' ')}: ${r.err}`);
assert.ok(r.json, 'JSON on stdout');
}
} finally { cleanup(dir); }
});

View file

@ -139,9 +139,9 @@ test('every command frontmatter name matches its filename', () => {
}
});
test('templates/plan-template.md declares plan_version: 1.7', () => {
test('templates/plan-template.md declares plan_version: 1.8', () => {
const tpl = read('templates/plan-template.md');
assert.match(tpl, /plan_version:\s*['"]?1\.7['"]?/);
assert.match(tpl, /plan_version:\s*['"]?1\.8['"]?/);
});
test('HANDOVER-CONTRACTS.md locates plan_version in the plan header line, not frontmatter (S28)', () => {
@ -162,8 +162,8 @@ test('HANDOVER-CONTRACTS.md locates plan_version in the plan header line, not fr
// "commands/trekexecute.md still parses v1.7 plan schema" was an OR-chain
// (`includes('plan_version') || includes('Step N:') || includes('### Step ')`)
// that is ~always true and asserts almost nothing. The real v1.7 coverage lives
// in the structural pin "templates/plan-template.md declares plan_version: 1.7"
// (above) + the plan-validator / plan-schema behavior tests.
// in the structural pin "templates/plan-template.md declares plan_version: 1.8"
// (above; 1.7 plans stay valid) + the plan-validator / plan-schema behavior tests.
test('settings.json has only known top-level scopes after Spor 0 cleanup', () => {
const cfg = JSON.parse(read('settings.json'));

View file

@ -0,0 +1,129 @@
// tests/lib/plan-prose-pins.test.mjs
// Prose pins for the plan process (wave 1, order 3): /trekplan Phases 8-10, the
// plan and design templates, plan-critic and the Handover 4 contract must keep
// saying what the plan gate, the validator and the verification CLIs do.
// Counted as doc pins by lib/util/test-census.mjs (PIN_FILE_RE).
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { readFileSync, existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { MAX_ROUNDS } from '../../lib/validators/plan-gate.mjs';
import { DESIGN_SUBSECTIONS, OPTIONAL_STEP_FIELDS } from '../../lib/validators/plan-validator.mjs';
import { findSteps, PLAN_VERSION_CURRENT } from '../../lib/parsers/plan-schema.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const read = rel => readFileSync(join(ROOT, rel), 'utf-8');
function phase(text, n) {
const start = text.indexOf(`\n## Phase ${n} `);
assert.ok(start >= 0, `## Phase ${n} missing`);
const end = text.indexOf('\n## Phase ', start + 1);
return text.slice(start, end < 0 ? undefined : end);
}
const PLAN_CMD = read('commands/trekplan.md');
test('Phase 9 is a bounded loop: --record after the dedup, the cap equals MAX_ROUNDS, stop and ask after it', () => {
const p9 = phase(PLAN_CMD, 9);
assert.match(p9, /plan-review-dedup\.mjs --stdin <<'JSON' \| node \$\{CLAUDE_PLUGIN_ROOT\}\/lib\/validators\/plan-gate\.mjs --record/);
assert.ok(p9.includes(`at most **${MAX_ROUNDS} critic rounds**`), `Phase 9 must state the cap of ${MAX_ROUNDS}`);
assert.match(p9, /After round 3 with a BLOCKER open/);
assert.match(p9, /never goes on silently/);
assert.match(p9, /--extra-round/);
assert.ok(p9.indexOf('plan-gate.mjs --hash') < p9.indexOf('Then launch two review agents'), 'the hash is taken before the reviewers read the plan');
assert.match(p9, /--record "\{plan_path\}" --reviewed \{the hash from --hash\}/);
assert.match(p9, /Kiro/, 'the bounded loop is borrowed and credited');
});
test('Phase 10 runs the plan gate before offering "execute", and covers plan mode', () => {
const p10 = phase(PLAN_CMD, 10);
assert.ok(p10.indexOf('plan-gate.mjs --json') < p10.indexOf('Say **"execute"**'), 'the gate runs before the offer');
assert.match(p10, /only when the gate exited 0/);
assert.match(p10, /ExitPlanMode/);
assert.match(p10, /\*\*Not\s+verified:\*\* that `\/trekplan` runs end to end inside plan mode/);
assert.match(p10, /hedged reply/i);
});
test('Phase 8 reads ceremony (absent = architectural), the design template and the arch-rules runner', () => {
const p8 = phase(PLAN_CMD, 8);
assert.match(p8, /Absent means `architectural`/);
assert.ok(p8.includes('templates/design-template.md'));
assert.ok(p8.includes('lib/verification/arch-rules.mjs --repo .'));
assert.ok(p8.includes('--prove --rule <rule name> --plant'), 'the proof names the rule the plant must trip');
assert.match(p8, /End-to-end:/);
for (const f of OPTIONAL_STEP_FIELDS) assert.ok(p8.includes(`${f}:`), `Phase 8 names the optional field ${f}:`);
for (const credit of ['obra/superpowers', 'Spec Kit', 'mattpocock/skills', 'Cherny']) {
assert.ok(p8.includes(credit), `Phase 8 credits ${credit}`);
}
});
test('the CLIs and templates /trekplan points at exist', () => {
for (const rel of [
'lib/validators/plan-gate.mjs', 'lib/verification/arch-rules.mjs',
'lib/verification/plan-scope-sweep.mjs', 'templates/design-template.md',
]) assert.ok(existsSync(join(ROOT, rel)), `${rel} missing`);
});
test('the hard rules carry the bounded critic loop and the gate', () => {
const rules = PLAN_CMD.slice(PLAN_CMD.indexOf('\n## Hard rules'));
assert.match(rules, /\*\*Bounded critic loop\*\*: at most 3 critic rounds/);
assert.match(rules, /never while `plan-gate\.mjs` exits non-zero/);
});
test('design template: every subsection the validator requires is a ### heading, and the borrowings are credited', () => {
const t = read('templates/design-template.md');
for (const s of DESIGN_SUBSECTIONS) assert.match(t, new RegExp(`^### ${s}$`, 'm'), `design template lacks ### ${s}`);
for (const credit of ['Kiro', 'obra/superpowers', 'addyosmani/agent-skills', 'mattpocock/skills', 'Spec Kit', 'ai-native-sdlc']) {
assert.ok(t.includes(credit), `design template credits ${credit}`);
}
});
test(`plan template: plan_version ${PLAN_VERSION_CURRENT} with ceremony, Test first on every example step, one End-to-end step, no hand-written Critic Verdict`, () => {
const t = read('templates/plan-template.md');
assert.ok(t.includes(`\`plan_version: ${PLAN_VERSION_CURRENT}\` · \`ceremony:`));
const steps = findSteps(t);
assert.ok(steps.length >= 2);
const bodies = steps.map((s, i) => t.slice(s.offset, i + 1 < steps.length ? steps[i + 1].offset : undefined));
for (const [i, b] of bodies.entries()) assert.match(b, /- \*\*Test first:\*\*/, `example Step ${i + 1} lacks Test first`);
assert.equal(bodies.filter(b => /- \*\*End-to-end:\*\* `/.test(b)).length, 1);
assert.doesNotMatch(t, /^## Critic Verdict$/m, 'the planner must not copy a verdict heading: only plan-gate --record writes it');
assert.match(t, /plan-gate\.mjs\s+--record` appends it/);
assert.match(t, /^## Global Constraints$/m);
assert.doesNotMatch(t, /omit "Test first"/, 'a 1.8 plan never omits Test first');
});
test('plan-critic carries dimensions 11 (interfaces and slices) and 12 (design and architecture rules)', () => {
const c = read('agents/plan-critic.md');
assert.match(c, /^### 11\. Interfaces and slices$/m);
assert.match(c, /^### 12\. Design and architecture rules$/m);
assert.match(c, /`severity` is exactly one of `blocker`, `major`, `minor`/);
});
test('Handover 4 documents every PLAN_* code the validator and the gate emit', () => {
const contract = read('docs/HANDOVER-CONTRACTS.md');
const h4 = contract.slice(contract.indexOf('## Handover 4'), contract.indexOf('## Handover 5'));
const codes = new Set();
for (const rel of ['lib/validators/plan-validator.mjs', 'lib/validators/plan-gate.mjs']) {
for (const m of read(rel).matchAll(/'(PLAN_[A-Z_]+)'/g)) codes.add(m[1]);
}
assert.ok(codes.size >= 15, `expected the 1.8 and gate codes, found ${codes.size}`);
const missing = [...codes].filter(c => !h4.includes(c));
assert.deepEqual(missing, [], `Handover 4 does not document: ${missing.join(', ')}`);
});
test('Handover 4 states 1.8 as current and 1.7 as still valid', () => {
const contract = read('docs/HANDOVER-CONTRACTS.md');
assert.match(contract, /\| `plan\.md` \| `plan_version` \(plan header line\) \| `1\.8` \(1\.7 still valid\) \|/);
assert.match(contract, /`"1\.8"` \(current\), `"1\.7"` \(valid\)/);
});
test('the verification CLIs credit what they borrow and say they install nothing', () => {
const arch = read('lib/verification/arch-rules.mjs');
assert.match(arch, /installs nothing/);
assert.match(arch, /agent-skills/);
assert.match(arch, /Pocock/);
assert.match(read('lib/verification/plan-scope-sweep.mjs'), /ai-native-sdlc \(MIT\)/);
assert.match(read('lib/validators/plan-gate.mjs'), /Kiro/);
});

View file

@ -164,6 +164,31 @@ test('CLI --stdin tolerates a single present payload (other key absent)', () =>
assert.deepEqual(parsed.findings[0].raised_by, ['plan-critic']);
});
test('severity survives the dedup, and a merge keeps the more severe of the two (the plan gate reads it)', () => {
const sources = [
{ agent: 'plan-critic', payload: { findings: [{ file: 'plan.md', line: 42, rule_key: 'PC1', severity: 'Minor', text: 'Step 4 lacks verifiable acceptance criteria' }] } },
{ agent: 'scope-guardian', payload: { findings: [{ file: 'plan.md', line: 42, rule_key: 'PC1', severity: 'blocker', text: 'Step 4 lacks verifiable acceptance criteria' }] } },
];
const r = dedupFindings(sources);
assert.equal(r.findings.length, 1);
assert.equal(r.findings[0].severity, 'blocker');
const lone = dedupFindings([{ agent: 'plan-critic', payload: { findings: [{ file: 'p', line: 1, rule_key: 'k', severity: 'MAJOR', text: 't' }] } }]);
assert.equal(lone.findings[0].severity, 'major');
const unrated = dedupFindings([{ agent: 'plan-critic', payload: { findings: [{ file: 'p', line: 1, rule_key: 'k', text: 't' }] } }]);
assert.equal(unrated.findings[0].severity, null, 'an unrated finding stays unrated here; the gate decides what that means');
});
test('sources says which reviewers delivered a findings block (null, misnamed or findings-less = absent)', () => {
const stdin = JSON.stringify({ plan_critic: { agent: 'plan-critic', findings: [] }, scopeGuardian: { findings: [] } });
const parsed = JSON.parse(execFileSync(process.execPath, [SHIM, '--stdin'], { input: stdin, encoding: 'utf-8' }));
assert.deepEqual(parsed.sources, [
{ agent: 'plan-critic', present: true },
{ agent: 'scope-guardian', present: false },
]);
const noFindings = dedupFindings([{ agent: 'plan-critic', payload: { agent: 'plan-critic' } }]);
assert.deepEqual(noFindings.sources, [{ agent: 'plan-critic', present: false }]);
});
test('CLI --stdin exits non-zero on malformed input (loud, not a silent empty merge)', () => {
assert.throws(
() => execFileSync(process.execPath, [SHIM, '--stdin'], { input: 'not json{', encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }),

View file

@ -150,5 +150,5 @@ test('extractPlanVersion — recognizes the canonical plan template (regression
// The real generated artifact: no test exercised the parser against the
// actual template, which is why defect S26 went unnoticed.
const tpl = readFileSync(new URL('../../templates/plan-template.md', import.meta.url), 'utf-8');
assert.equal(extractPlanVersion(tpl), '1.7');
assert.equal(extractPlanVersion(tpl), '1.8');
});

View file

@ -0,0 +1,177 @@
// tests/lib/plan-scope-sweep.test.mjs
// K6 — the sweep for files the plan does not name.
// `plan-scope-sweep.mjs --plan <plan.md> --base <ref>`: changed files since the
// merge base, minus every step's `Files:` / `Test first:` file / manifest
// `expected_paths`, minus a named process-file list, must be empty.
// 0 = nothing outside the plan, 1 = deviation (listed), 2 = could not run.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawnSync, execFileSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { plannedPaths, PROCESS_FILES } from '../../lib/verification/plan-scope-sweep.mjs';
import { buildPlan } from '../helpers/plan-v18-builder.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const SWEEP = join(HERE, '..', '..', 'lib', 'verification', 'plan-scope-sweep.mjs');
const git = (dir, ...args) => execFileSync('git', ['-C', dir, ...args], { encoding: 'utf-8', stdio: 'pipe' });
function repo() {
const dir = mkdtempSync(join(tmpdir(), 'scope-sweep-'));
git(dir, 'init', '-q', '-b', 'main');
git(dir, 'config', 'user.email', 't@example.invalid');
git(dir, 'config', 'user.name', 't');
git(dir, 'config', 'commit.gpgsign', 'false');
writeFileSync(join(dir, 'README.md'), '# r\n');
git(dir, 'add', 'README.md');
git(dir, 'commit', '-q', '-m', 'base');
mkdirSync(join(dir, '.claude', 'projects', 'p1'), { recursive: true });
writeFileSync(join(dir, '.claude', 'projects', 'p1', 'plan.md'), buildPlan());
mkdirSync(join(dir, 'src'));
mkdirSync(join(dir, 'tests'));
return dir;
}
function put(dir, rel, body = 'x\n') {
mkdirSync(dirname(join(dir, rel)), { recursive: true });
writeFileSync(join(dir, rel), body);
}
function run(dir, extra = []) {
const r = spawnSync('node', [SWEEP, '--plan', '.claude/projects/p1/plan.md', '--base', 'main', ...extra],
{ cwd: dir, encoding: 'utf-8' });
let json = null;
try { json = JSON.parse(r.stdout); } catch { /* asserted by caller */ }
return { code: r.status, json, out: r.stdout, err: r.stderr };
}
// ---- the test that failed first ----
test('K6: one changed file outside the plan → 1, and it is listed', () => {
const dir = repo();
try {
git(dir, 'checkout', '-q', '-b', 'work');
put(dir, 'src/mod1.mjs');
put(dir, 'tests/mod1.test.mjs');
put(dir, 'src/unplanned.mjs');
git(dir, 'add', 'src', 'tests', '.claude');
git(dir, 'commit', '-q', '-m', 'work');
const r = run(dir);
assert.equal(r.code, 1, r.out + r.err);
assert.deepEqual(r.json.outside, ['src/unplanned.mjs']);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('K6: only planned files and process files changed → 0', () => {
const dir = repo();
try {
git(dir, 'checkout', '-q', '-b', 'work');
put(dir, 'src/mod1.mjs');
put(dir, 'tests/mod2.test.mjs');
put(dir, '.claude/projects/p1/progress.json', '{}');
git(dir, 'add', 'src', 'tests', '.claude');
git(dir, 'commit', '-q', '-m', 'work');
const r = run(dir);
assert.equal(r.code, 0, r.out + r.err);
assert.deepEqual(r.json.outside, []);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('K6: uncommitted and untracked changes count too', () => {
const dir = repo();
try {
git(dir, 'add', '.claude');
git(dir, 'commit', '-q', '-m', 'plan');
put(dir, 'README.md', '# changed\n');
put(dir, 'stray.txt');
const r = run(dir);
assert.equal(r.code, 1);
assert.deepEqual(r.json.outside, ['README.md', 'stray.txt']);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('K6: --process names extra process files; a deviation is allowed once the plan names the file', () => {
const dir = repo();
try {
put(dir, 'CHANGELOG.md');
assert.equal(run(dir).code, 1);
assert.equal(run(dir, ['--process', 'CHANGELOG.md']).code, 0);
const plan = buildPlan().replace('- **Files:** `src/mod1.mjs`', '- **Files:** `src/mod1.mjs`, `CHANGELOG.md`');
writeFileSync(join(dir, '.claude', 'projects', 'p1', 'plan.md'), plan);
assert.equal(run(dir).code, 0);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('K6: a bad ref, a missing plan, or a plan without steps → 2', () => {
const dir = repo();
try {
assert.equal(run(dir, ['--base', 'no-such-ref']).code, 2);
const r = spawnSync('node', [SWEEP, '--plan', 'missing.md', '--base', 'main'], { cwd: dir, encoding: 'utf-8' });
assert.equal(r.status, 2);
writeFileSync(join(dir, '.claude', 'projects', 'p1', 'plan.md'), '# no steps\n');
assert.equal(run(dir).code, 2);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('plannedPaths collects Files, Test first files and manifest expected_paths', () => {
const p = plannedPaths(buildPlan());
for (const f of ['src/mod1.mjs', 'tests/mod1.test.mjs', 'src/mod2.mjs', 'tests/mod2.test.mjs']) {
assert.ok(p.includes(f), `${f} missing from ${p.join(',')}`);
}
assert.ok(PROCESS_FILES.length > 0);
});
// ---- review findings (independent code review, 06.10) ----
test('K6: renames list both paths, deletions count, non-ASCII and spaced names match when planned', () => {
const dir = repo();
try {
put(dir, 'src/old name.mjs');
put(dir, 'src/gone.mjs');
git(dir, 'add', 'src');
git(dir, 'commit', '-q', '-m', 'more');
git(dir, 'checkout', '-q', '-b', 'work');
git(dir, 'mv', 'src/old name.mjs', 'src/prøve fil.mjs');
git(dir, 'rm', '-q', 'src/gone.mjs');
git(dir, 'commit', '-q', '-m', 'work');
const r = run(dir);
assert.equal(r.code, 1);
assert.deepEqual(r.json.outside, ['src/gone.mjs', 'src/old name.mjs', 'src/prøve fil.mjs']);
const plan = buildPlan().replace('- **Files:** `src/mod1.mjs`',
'- **Files:** `src/mod1.mjs`, `src/old name.mjs`, `src/prøve fil.mjs`, `src/gone.mjs`');
writeFileSync(join(dir, '.claude', 'projects', 'p1', 'plan.md'), plan);
const ok = run(dir);
assert.equal(ok.code, 0, ok.out);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('K6: a glob with no fixed directory (`**/*`) plans nothing and is reported as ignored', () => {
const dir = repo();
try {
put(dir, 'src/anything.mjs');
const plan = buildPlan().replace('- **Files:** `src/mod1.mjs`', '- **Files:** `src/mod1.mjs`, `**/*`');
writeFileSync(join(dir, '.claude', 'projects', 'p1', 'plan.md'), plan);
const r = run(dir);
assert.equal(r.code, 1);
assert.deepEqual(r.json.ignored, ['**/*']);
assert.ok(r.json.outside.includes('src/anything.mjs'));
const scoped = buildPlan().replace('- **Files:** `src/mod1.mjs`', '- **Files:** `src/**`');
writeFileSync(join(dir, '.claude', 'projects', 'p1', 'plan.md'), scoped);
assert.equal(run(dir).code, 0, 'a glob under a fixed directory still plans');
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('K6: a flag without its value, or a directory as the plan → 2, never a crash', () => {
const dir = repo();
try {
for (const extra of [['--process'], ['--repo']]) {
const r = run(dir, extra);
assert.equal(r.code, 2, `${extra.join(' ')}: ${r.err}`);
assert.ok(r.json);
}
const r = spawnSync('node', [SWEEP, '--plan', 'src', '--base', 'main'], { cwd: dir, encoding: 'utf-8' });
assert.equal(r.status, 2, r.stderr);
} finally { rmSync(dir, { recursive: true, force: true }); }
});

View file

@ -0,0 +1,234 @@
// tests/validators/plan-gate.test.mjs
// K3 — the plan gate. `plan-gate.mjs <plan.md>`: 0 = the plan may go on,
// 1 = stop (invalid plan, plan_version < 1.8, no critic verdict, a verdict the
// plan has moved away from, or an open BLOCKER), 2 = could not run. The
// critic's verdict lives machine-readably INSIDE the plan (`## Critic Verdict`),
// because Claude Code's plan approval hands a hook the plan file's text and
// nothing next to it. `--record` writes the verdict from plan-review-dedup
// output, bound to the hash taken (`--hash`) before the reviewers read the
// plan; at most 3 rounds, then stop and ask — never silently on.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import {
gatePlan, recordVerdict, planHash, operativeText, extractVerdict, MAX_ROUNDS,
} from '../../lib/validators/plan-gate.mjs';
import { buildPlan, step } from '../helpers/plan-v18-builder.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const GATE = join(HERE, '..', '..', 'lib', 'validators', 'plan-gate.mjs');
const BOTH = [{ agent: 'plan-critic', present: true }, { agent: 'scope-guardian', present: true }];
const finding = (severity, text, rule_key = 'step-quality') =>
({ id: `${severity}-${rule_key}`, file: 'plan.md', line: 10, rule_key, severity, text, raised_by: ['plan-critic'] });
const dedupOut = (...findings) => ({ findings, sources: BOTH, dedup_stats: { total_in: findings.length, total_out: findings.length } });
// One round as Phase 9 runs it: hash before the review, record after it.
const rec = (text, dedup, opts = {}) => recordVerdict(text, dedup, { reviewed: planHash(text), ...opts });
function run(args, input) {
const r = spawnSync('node', [GATE, ...args], { input, encoding: 'utf-8' });
return { code: r.status, out: r.stdout, err: r.stderr };
}
function withPlan(text, fn) {
const dir = mkdtempSync(join(tmpdir(), 'plan-gate-'));
const p = join(dir, 'plan.md');
writeFileSync(p, text);
try { return fn(p, dir); } finally { rmSync(dir, { recursive: true, force: true }); }
}
// ---- the test that failed first ----
test('K3: a plan with an open critic BLOCKER exits 1', () => {
const r0 = rec(buildPlan(), dedupOut(finding('blocker', 'Step 2 reads a value no step writes')));
assert.equal(r0.code, 0, r0.message);
withPlan(r0.text, (p) => {
const r = run([p, '--json']);
assert.equal(r.code, 1, r.out + r.err);
const j = JSON.parse(r.out);
assert.equal(j.status, 'stop');
assert.ok(j.reasons.some(x => x.code === 'PLAN_GATE_OPEN_BLOCKER'), r.out);
});
});
test('K3: a valid plan whose latest verdict has no blocker exits 0 (majors are warnings)', () => {
const r0 = rec(buildPlan(), dedupOut(finding('major', 'Step 1 lacks an error path'), finding('minor', 'typo')));
withPlan(r0.text, (p) => {
const r = run([p, '--json']);
assert.equal(r.code, 0, r.out + r.err);
const j = JSON.parse(r.out);
assert.equal(j.verdict.verdict, 'REVISE');
assert.ok(j.warnings.some(w => w.code === 'PLAN_GATE_OPEN_MAJOR'));
});
});
test('K3: no critic verdict → 1 (the plan has not been through the critic)', () => {
const g = gatePlan(buildPlan());
assert.equal(g.code, 1);
assert.ok(g.reasons.some(x => x.code === 'PLAN_GATE_NO_VERDICT'));
});
test('K3: an invalid plan → 1 even with a clean verdict', () => {
const bad = buildPlan({ steps: [{ testFirst: false }, { endToEnd: 'node x' }] });
const g = gatePlan(rec(bad, dedupOut()).text);
assert.equal(g.code, 1);
assert.ok(g.reasons.some(x => x.code === 'PLAN_GATE_INVALID_PLAN'));
});
test('K3: a plan below plan_version 1.8 → 1, even with a clean verdict', () => {
const old = buildPlan({ version: '1.7', ceremony: null, design: false });
const g = gatePlan(rec(old, dedupOut()).text);
assert.equal(g.code, 1);
assert.ok(g.reasons.some(x => x.code === 'PLAN_GATE_VERSION'), JSON.stringify(g.reasons));
});
test('K3: editing a step after the verdict makes it stale → 1', () => {
const r0 = rec(buildPlan(), dedupOut());
assert.equal(gatePlan(r0.text).code, 0);
const edited = r0.text.replace('Add `run1()`', 'Add `run1()` and `cleanup1()`');
const g = gatePlan(edited);
assert.equal(g.code, 1);
assert.ok(g.reasons.some(x => x.code === 'PLAN_GATE_VERDICT_STALE'));
});
test('K3: a step appended after the verdict (inside a review-log section) stales it', () => {
const r0 = rec(buildPlan(), dedupOut());
const appended = `${r0.text}\n${step(3, { title: 'Sneaked in' })}\n`;
assert.notEqual(planHash(appended), planHash(r0.text));
const g = gatePlan(appended);
assert.equal(g.code, 1);
assert.ok(g.reasons.some(x => x.code === 'PLAN_GATE_VERDICT_STALE'), JSON.stringify(g.reasons));
});
test('K3: review-log sections (quality header, score, revisions, verdict) do not stale the verdict', () => {
const r0 = rec(buildPlan(), dedupOut(finding('minor', 'nit')));
const logged = r0.text
.replace('> **Plan quality: A** (92/100) — APPROVE', '> **Plan quality: B** (80/100) — APPROVE_WITH_NOTES')
.replace('## Verification', '## Revisions\n\n| # | Finding | Severity | Resolution |\n|---|---|---|---|\n| 1 | nit | minor | left |\n\n## Verification');
assert.equal(planHash(logged), planHash(r0.text));
assert.equal(gatePlan(logged).code, 0);
});
test('K3: only exact review-log titles are excluded ("Revisions to the API" is plan)', () => {
const plan = buildPlan().replace('## Verification', '## Revisions to the API\n\nThe endpoint moves.\n\n## Verification');
const changed = plan.replace('The endpoint moves.', 'The endpoint is deleted.');
assert.notEqual(planHash(changed), planHash(plan));
});
test('K3: whitespace at line ends and CRLF do not stale the verdict', () => {
const r0 = rec(buildPlan(), dedupOut());
assert.equal(gatePlan(r0.text.replace(/\n/g, ' \r\n')).code, 0);
});
test('K3: a malformed verdict block → 1 (bad JSON, a null finding, max_rounds raised); a missing file → 2', () => {
const r0 = rec(buildPlan(), dedupOut(finding('minor', 'n')));
for (const broken of [
r0.text.replace('"schema"', '"schema" oops'),
r0.text.replace(/"findings": \[\n\s*\{/, '"findings": [\n null,\n {'),
r0.text.replace('"max_rounds": 3', '"max_rounds": 99'),
]) {
const g = gatePlan(broken);
assert.equal(g.code, 1);
assert.ok(g.reasons.some(x => x.code === 'PLAN_GATE_VERDICT_MALFORMED'), JSON.stringify(g.reasons));
}
assert.equal(run(['/nonexistent/plan.md', '--json']).code, 2);
});
// ---- what --record refuses ----
test('--record refuses a dedup input where a reviewer is absent (an absent review is not a clean one)', () => {
const plan = buildPlan();
const oneMissing = { findings: [], sources: [{ agent: 'plan-critic', present: true }, { agent: 'scope-guardian', present: false }] };
const r1 = rec(plan, oneMissing);
assert.equal(r1.code, 2);
assert.match(r1.message, /scope-guardian/);
assert.equal(rec(plan, { findings: [] }).code, 2, 'no sources at all');
});
test('--record binds the verdict to the text the reviewers read: no hash or a changed plan → 2', () => {
const plan = buildPlan();
assert.equal(recordVerdict(plan, dedupOut()).code, 2, '--reviewed is required');
const before = planHash(plan);
const revised = plan.replace('Add `run1()`', 'Add `run1()` after review');
const r = recordVerdict(revised, dedupOut(), { reviewed: before });
assert.equal(r.code, 2);
assert.match(r.message, /changed after the reviewers read it/);
});
test(`--record counts rounds; round ${MAX_ROUNDS + 1} needs the operator's extra round, which is only for then`, () => {
assert.equal(MAX_ROUNDS, 3);
let text = buildPlan();
assert.equal(rec(text, dedupOut(), { extraRound: true }).code, 2, '--extra-round within the cap is refused, not banked');
for (let i = 1; i <= 3; i++) {
const r = rec(text, dedupOut(finding('blocker', `still broken ${i}`)));
assert.equal(r.code, 0, r.message);
assert.equal(r.verdict.round, i);
text = r.text;
}
const fourth = rec(text, dedupOut());
assert.equal(fourth.code, 1);
assert.match(fourth.message, /ask the operator/i);
const g = gatePlan(text);
assert.equal(g.code, 1);
assert.ok(g.reasons.some(x => x.code === 'PLAN_GATE_ROUNDS_EXHAUSTED'), JSON.stringify(g.reasons));
const extra = rec(text, dedupOut(), { extraRound: true });
assert.equal(extra.code, 0);
assert.equal(extra.verdict.round, 4);
assert.equal(extra.verdict.extra_rounds, 1);
assert.equal(gatePlan(extra.text).code, 0);
assert.equal(rec(extra.text, dedupOut()).code, 1, 'one yes buys one round');
});
test('--record clamps a raised round cap to MAX_ROUNDS', () => {
const r = rec(buildPlan(), dedupOut(), { maxRounds: 99 });
assert.equal(r.verdict.max_rounds, MAX_ROUNDS);
});
test('--record replaces the verdict section instead of stacking a second one, and replaces a copied placeholder', () => {
const once = rec(buildPlan(), dedupOut()).text;
const twice = rec(once, dedupOut()).text;
assert.equal((twice.match(/^## Critic Verdict$/gm) || []).length, 1);
assert.equal(extractVerdict(twice).verdict.round, 2);
const withPlaceholder = `${buildPlan()}\n## Critic Verdict\n\n*Written by plan-gate --record.*\n`;
const r = rec(withPlaceholder, dedupOut());
assert.equal(r.code, 0, r.message);
assert.equal(r.verdict.round, 1);
assert.doesNotMatch(r.text, /\*Written by plan-gate --record\.\*/);
});
test('--record: a finding without a valid severity counts as a blocker', () => {
const r = rec(buildPlan(), dedupOut({ file: 'plan.md', line: 1, rule_key: 'x', text: 'unrated' }));
assert.equal(r.verdict.verdict, 'BLOCK');
assert.equal(r.verdict.findings[0].severity, 'blocker');
assert.equal(r.verdict.findings[0].severity_defaulted, true);
});
// ---- the CLI as Phase 9 calls it ----
test('CLI: --hash, then --record --reviewed writes in place; malformed stdin or unknown flags → 2, nothing written', () => {
withPlan(buildPlan(), (p, dir) => {
const before = readFileSync(p, 'utf-8');
const hash = run(['--hash', p]).out.trim();
assert.match(hash, /^[0-9a-f]{64}$/);
assert.equal(run(['--record', p, '--reviewed', hash], 'not json').code, 2);
assert.equal(run(['--record', p, '--reviewed', hash, '--max-rounds', '9'], JSON.stringify(dedupOut())).code, 2);
assert.equal(run(['--record', p], JSON.stringify(dedupOut())).code, 2, '--reviewed is required');
assert.equal(readFileSync(p, 'utf-8'), before);
const ok = run(['--record', p, '--reviewed', hash], JSON.stringify(dedupOut(finding('major', 'm'))));
assert.equal(ok.code, 0, ok.out + ok.err);
assert.equal(JSON.parse(ok.out).verdict, 'REVISE');
assert.equal(run([p]).code, 0);
assert.ok(!existsSync(join(dir, 'plan.md.plan-gate.tmp')));
});
});
test('operativeText drops only the review-log sections', () => {
const r0 = rec(buildPlan(), dedupOut());
const op = operativeText(r0.text);
assert.ok(!op.includes('## Critic Verdict'));
assert.ok(!op.includes('Plan quality:'));
assert.ok(op.includes('### Step 2:'));
assert.ok(op.includes('## Design'));
});

View file

@ -0,0 +1,252 @@
// tests/validators/plan-validator-v18.test.mjs
// plan_version 1.8 (wave 1, order 3): `Test first:` is required on every step,
// every plan carries one "run the thing" step (`End-to-end:`), the K4 step
// fields (`Interface in:`, `Interface out:`, `Review focus:`) are optional but
// never empty, and an architectural plan carries the design part. 1.7 plans
// keep validating exactly as before.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, writeFileSync, rmSync, symlinkSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { validatePlanContent } from '../../lib/validators/plan-validator.mjs';
import {
parseStepFields, ceremonyFromBrief, resolveCeremony, findModuleCycle, splitSections,
} from '../../lib/parsers/plan-schema.mjs';
import { buildPlan, DESIGN } from '../helpers/plan-v18-builder.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const VALIDATOR = join(HERE, '..', '..', 'lib', 'validators', 'plan-validator.mjs');
const codes = r => r.errors.map(e => e.code);
test('a complete 1.8 architectural plan validates strict', () => {
const r = validatePlanContent(buildPlan());
assert.deepEqual(r.errors, [], JSON.stringify(r.errors, null, 2));
assert.equal(r.valid, true);
assert.equal(r.parsed.planVersion, '1.8');
assert.equal(r.parsed.ceremony, 'architectural');
});
test('1.8: a step without `Test first:` is rejected', () => {
const r = validatePlanContent(buildPlan({ steps: [{ testFirst: false }, { endToEnd: 'node src/cli.mjs' }] }));
assert.equal(r.valid, false);
assert.ok(codes(r).includes('PLAN_STEP_NO_TEST_FIRST'), codes(r).join(','));
assert.match(r.errors.find(e => e.code === 'PLAN_STEP_NO_TEST_FIRST').message, /Step 1/);
});
test('1.8: `Test first:` must name a File and what it Verifies', () => {
const plan = buildPlan().replace(/ - Verifies: run1\(\)[^\n]*\n/, '');
const r = validatePlanContent(plan);
assert.ok(codes(r).includes('PLAN_STEP_NO_TEST_FIRST'), codes(r).join(','));
});
test('1.8: a plan without a "run the thing" step is rejected', () => {
const r = validatePlanContent(buildPlan({ steps: [{}, {}] }));
assert.equal(r.valid, false);
assert.ok(codes(r).includes('PLAN_NO_END_TO_END'), codes(r).join(','));
});
test('1.8: `End-to-end:` without a backticked command does not count', () => {
const plan = buildPlan().replace(/- \*\*End-to-end:\*\* `[^`]+`/, '- **End-to-end:** run it and look');
assert.ok(codes(validatePlanContent(plan)).includes('PLAN_NO_END_TO_END'));
});
test('1.8: K4 fields are optional, but an empty one is rejected', () => {
const withFields = buildPlan({ steps: [
{ interfaceOut: '`run1(): number` read by Step 2', reviewFocus: 'empty input — pinned by `tests/mod1.test.mjs`' },
{ interfaceIn: '`run1()` from Step 1', endToEnd: 'node src/cli.mjs' },
] });
assert.deepEqual(validatePlanContent(withFields).errors, []);
const empty = buildPlan({ steps: [{ interfaceIn: '' }, { endToEnd: 'node src/cli.mjs' }] });
assert.ok(codes(validatePlanContent(empty)).includes('PLAN_STEP_FIELD_EMPTY'));
});
test('1.7 plans stay valid: no Test first, no End-to-end, no design required', () => {
const plan = buildPlan({ version: '1.7', ceremony: null, design: false, steps: [{ testFirst: false }] });
const r = validatePlanContent(plan);
assert.deepEqual(r.errors, [], JSON.stringify(r.errors));
assert.equal(r.warnings.find(w => w.code === 'PLAN_VERSION_MISMATCH'), undefined);
});
test('a CRLF plan validates like its LF twin (the manifest parser used to fail on \\r)', () => {
const r = validatePlanContent(buildPlan().replace(/\n/g, '\r\n'));
assert.deepEqual(r.errors, [], JSON.stringify(r.errors));
});
test('1.8: --soft downgrades the 1.8 rules to warnings', () => {
const r = validatePlanContent(buildPlan({ steps: [{ testFirst: false }, {}] }), { strict: false });
assert.equal(r.valid, true);
const w = r.warnings.map(x => x.code);
assert.ok(w.includes('PLAN_STEP_NO_TEST_FIRST') && w.includes('PLAN_NO_END_TO_END'), w.join(','));
});
// --- design part (P2) gated on ceremony (K1) ---
test('ceremony: absent means architectural (heaviest on doubt), so the design part is required', () => {
const r = validatePlanContent(buildPlan({ ceremony: null, design: false }));
assert.ok(codes(r).includes('PLAN_DESIGN_MISSING'), codes(r).join(','));
assert.equal(r.parsed.ceremony, 'architectural');
});
test('ceremony: small and bounded plans need no design part', () => {
for (const c of ['small', 'bounded']) {
const r = validatePlanContent(buildPlan({ ceremony: c, design: false }));
assert.deepEqual(r.errors, [], `${c}: ${JSON.stringify(r.errors)}`);
}
});
test('ceremony: an unknown class is rejected', () => {
assert.ok(codes(validatePlanContent(buildPlan({ ceremony: 'huge' }))).includes('PLAN_CEREMONY_INVALID'));
});
test('design part: every required subsection must be present', () => {
const r = validatePlanContent(buildPlan({ designText: DESIGN.replace('### Data Flow', '### Flow of data') }));
const e = r.errors.find(x => x.code === 'PLAN_DESIGN_SECTION_MISSING');
assert.ok(e, codes(r).join(','));
assert.match(e.message, /Data Flow/);
});
test('design part: a cycle in the module map is rejected', () => {
const cyclic = DESIGN.replace(' parser --> util[src/util.mjs]', ' parser --> util[src/util.mjs]\n util --> cli');
const r = validatePlanContent(buildPlan({ designText: cyclic }));
const e = r.errors.find(x => x.code === 'PLAN_MODULE_CYCLE');
assert.ok(e, codes(r).join(','));
assert.match(e.message, /cli -> parser -> util -> cli/);
});
test('design part: every architecture rule names its enforcer', () => {
const noEnforcer = DESIGN.replace('| Prefer pure functions in the parser | advisory — `.claude/rules/parser.md` | — |',
'| Prefer pure functions in the parser | we will try | — |');
assert.ok(codes(validatePlanContent(buildPlan({ designText: noEnforcer }))).includes('PLAN_ARCH_RULE_NO_ENFORCER'));
});
test('design part: a checked rule names the step that proves it, and the step exists', () => {
const noProof = DESIGN.replace('src` | Step 1 |', 'src` | — |');
assert.ok(codes(validatePlanContent(buildPlan({ designText: noProof }))).includes('PLAN_ARCH_RULE_NO_PROOF'));
const ghost = DESIGN.replace('src` | Step 1 |', 'src` | Step 9 |');
assert.ok(codes(validatePlanContent(buildPlan({ designText: ghost }))).includes('PLAN_ARCH_RULE_NO_PROOF'));
});
test('design part: an advisory rule says which .claude/rules/ file carries it', () => {
const nowhere = DESIGN.replace('advisory — `.claude/rules/parser.md`', 'advisory');
assert.ok(codes(validatePlanContent(buildPlan({ designText: nowhere }))).includes('PLAN_ARCH_RULE_NO_ENFORCER'));
});
// --- parser units ---
test('parseStepFields reads bold and plain field bullets with their blocks', () => {
const f = parseStepFields('### Step 1: x\n\n- **Files:** `a.ts`\n- Test first:\n - File: `t.ts`\n - Verifies: y\n- **Interface in:** `f()`\n');
assert.equal(f.get('Files').inline, '`a.ts`');
assert.match(f.get('Test first').text, /File: `t\.ts`/);
assert.equal(f.get('Interface in').inline, '`f()`');
});
test('parseStepFields stops at the next heading: a later section cannot lend the last step a field', () => {
const f = parseStepFields('### Step 2: x\n\n- **Files:** `a.ts`\n\n## Verification\n\n- **End-to-end:** `npm start`\n');
assert.equal(f.has('End-to-end'), false);
const plan = buildPlan({ steps: [{}, {}] }).replace('## Verification\n', '## Verification\n\n- **End-to-end:** `npm start` → expected: up\n');
assert.ok(codes(validatePlanContent(plan)).includes('PLAN_NO_END_TO_END'), 'only a step can be the run step');
});
test('findModuleCycle finds a cycle through labelled and chained edges', () => {
assert.equal(findModuleCycle('graph TD\n a --> b\n b -->|uses| c\n'), null);
assert.deepEqual(findModuleCycle('graph LR\n a --> b --> c\n c -.-> a\n'), ['a', 'b', 'c', 'a']);
});
// --- review findings (independent code review, 06.10) ---
test('ceremony is read only from frontmatter or the header line, never from prose', () => {
const plan = buildPlan({ ceremony: null, design: false })
.replace('Why: the brief asks for a parser CLI.', 'Why: the brief once said `ceremony: small`.\nceremony: small');
const r = validatePlanContent(plan);
assert.equal(r.parsed.ceremony, 'architectural');
assert.ok(codes(r).includes('PLAN_DESIGN_MISSING'));
});
test('module map: the edge forms mermaid allows are all read, so their cycles are caught', () => {
const cyclic = [
'a-->b\nb-->a',
'a <--> b',
'a ---> b\nb ===> a',
'a -. uses .-> b\nb == calls ==> a',
'a -- calls --> b\nb --> a',
'a & c --> b\nb --> a',
'a --> b; b --> a',
'a:::hot --> b\nb --> a',
'a{{hex}} --> b[[sub]]\nb --> a',
'end-user --> api\napi --> end-user',
'graph.mjs --> x\nx --> graph.mjs',
];
for (const body of cyclic) {
assert.ok(findModuleCycle(`graph TD\n${body}\n`), `cycle not found in: ${JSON.stringify(body)}`);
}
assert.equal(findModuleCycle('graph TD\n a --- b\n b --> a\n'), null, 'an undirected link is not a dependency');
});
test('module map: a link line the parser cannot read is an error, not silently dropped', () => {
const odd = DESIGN.replace(' parser --> util[src/util.mjs]', ' parser --> util[src/util.mjs]\n util --> @weird id');
assert.ok(codes(validatePlanContent(buildPlan({ designText: odd }))).includes('PLAN_MODULE_MAP_UNPARSED'));
});
test('module map: a cycle across two mermaid blocks is caught', () => {
const two = DESIGN.replace(' parser --> util[src/util.mjs]\n```',
' parser --> util[src/util.mjs]\n```\n\n```mermaid\ngraph TD\n util --> cli\n```');
assert.ok(codes(validatePlanContent(buildPlan({ designText: two }))).includes('PLAN_MODULE_CYCLE'));
});
test('findModuleCycle survives a 20 000-node chain (iterative, no stack overflow)', () => {
const edges = [];
for (let i = 0; i < 20000; i++) edges.push([`n${i}`, `n${i + 1}`]);
assert.equal(findModuleCycle(edges), null);
edges.push(['n20000', 'n0']);
assert.equal(findModuleCycle(edges).length, 20002);
});
test('Test first with an empty File: or Verifies: does not count', () => {
const noPath = buildPlan().replace(' - File: `tests/mod1.test.mjs` (new)', ' - File:');
assert.ok(codes(validatePlanContent(noPath)).includes('PLAN_STEP_NO_TEST_FIRST'));
const noWhat = buildPlan().replace(/ - Verifies: run1\(\)[^\n]*/, ' - Verifies:');
assert.ok(codes(validatePlanContent(noWhat)).includes('PLAN_STEP_NO_TEST_FIRST'));
});
test('a column-0 # comment inside a fenced block does not end the step fields', () => {
const f = parseStepFields('### Step 1: x\n\n- **Changes:** run\n ```bash\n# a comment\nnpm test\n ```\n- **Test first:**\n - File: `t.mjs`\n - Verifies: y\n');
assert.ok(f.has('Test first'));
});
test('a ~~~ line inside a ``` block does not end the fence (headings in code stay code)', () => {
const t = '## A\n\n```md\n~~~\n## Not a heading\n~~~\n```\n\n## B\n';
assert.deepEqual(splitSections(t, 2).filter(s => s.title).map(s => s.title), ['A', 'B']);
});
test('K1 stub: ceremonyFromBrief reads the brief frontmatter; absent → architectural', () => {
assert.equal(ceremonyFromBrief('---\ntype: brief\nceremony: small\n---\n# B\n').value, 'small');
const none = ceremonyFromBrief('---\ntype: brief\n---\n# B\n');
assert.equal(none.value, 'architectural');
assert.equal(none.defaulted, true);
assert.equal(resolveCeremony('huge').valid, false);
});
// --- CLI: strict is the default, and the main-module guard survives a symlink ---
test('CLI --strict exits 1 on a 1.8 plan missing Test first; exits 0 through a symlinked path with a space', () => {
const dir = mkdtempSync(join(tmpdir(), 'plan v18 '));
try {
const bad = join(dir, 'bad.md');
writeFileSync(bad, buildPlan({ steps: [{ testFirst: false }, { endToEnd: 'node x' }] }));
let code = 0;
try { execFileSync('node', [VALIDATOR, '--strict', '--json', bad], { stdio: 'pipe' }); } catch (e) { code = e.status; }
assert.equal(code, 1);
const good = join(dir, 'good.md');
writeFileSync(good, buildPlan());
const link = join(dir, 'linked validator.mjs');
symlinkSync(VALIDATOR, link);
const out = execFileSync('node', [link, '--json', good], { encoding: 'utf-8' });
assert.equal(JSON.parse(out).valid, true, 'symlinked CLI must run, not silently no-op');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});