pi-gauntlet 7.20.1 → 7.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## v7.22.0 - 2026-10-20
4
+
5
+ ### Added
6
+
7
+ - **Plans carry base-commit test evidence.** The `plan-writer` persona gains `bash`, runs every task's `Tests:` command on the base commit, and records a `- baseline: <sha> pass | pass via <base command> | skipped - <paths> created by this task` bullet; `plan_check` gains the `baseline-line` and `open-questions` findings and refuses to stamp without that evidence or while `## Open Questions` holds a substantive row. A failing base reaches the human once, with the command and output, through the fix-first / narrow / amend menu. A boundary the spec leaves open is settled from the caller or harness contract as a `Decision:` line in the task. `/skill:gauntlet-resume` re-baselines pre-release plans without replacing task statuses. (#72)
8
+ - **Brainstorming traces implicit dependencies.** The scout reports every unnamed file, binary, extension, or fixture the design relies on; a missing one lands in the premise note and the spec as a corrected fact instead of a stop. (#72)
9
+ - **Lookup before asking in the implement loop.** A worker's `NEEDS_CONTEXT`/`BLOCKED` question is classified as a fact ask or a decision; fact asks are looked up in the documented spec, plan, and repo locations before any stop, and the stop note carries `Searched: <where> - <result>` lines. (#72)
10
+
11
+ ### Changed
12
+
13
+ - **Eval runner accepts unplanned improvements.** An unlisted `fails -> holds` fact is labeled `unplanned improvement` and counts as `improved`; only same-fact regressions are confirmed; cached cells are reclassified locally with zero model calls and the report regenerates even on a zero-call run. (#72)
14
+
15
+ ## v7.21.0 - 2026-10-19
16
+
17
+ ### Changed
18
+
19
+ - **Removed sequential mode; every plan task runs through the wave loop.** Singleton waves use the same implementer/spec-review chain, integration, review, and accepted-wave commit as larger waves. Re-review fixes use a single tree-to-tree fix delta captured through a scratch index, so committed, staged, and unstaged repairs produce the same repair-only patch. Each accepted wave folds repair commits into one wave commit before tracker completion. (#71)
20
+
3
21
  ## v7.20.1 - 2026-10-19
4
22
 
5
23
  ### Changed
package/README.md CHANGED
@@ -79,7 +79,7 @@ pi-gauntlet ships three kinds of pieces, layered on top of pi-cohort's dispatch:
79
79
 
80
80
  - **20 skills** - the workflow logic. Fifteen activate automatically when pi sees the matching kind of task, and the workflow skills chain through their gates: `shape-ticket` (creates or repairs one tracker issue per run against a Context/Problem/Idea/Acceptance-Criteria template, gated by an AC integrity check, a cheap council roast, and a single human-confirmed write; model-invocable so other skills and commands can route "file a ticket" to it, the gate still needs your yes), `gatekeep-pr` (model-invocable on an explicit ask that names the PR and the verb; the gates still need your selection - is consent-gated pre-merge verification of a PR against the scope contract at its head - the approved spec's acceptance-criteria dispositions and Design when the PR carries one, else the PR's stated intent; the ticket is cross-checked, never judged here (`check-delivery` owns it) - read-only gathering, verification evidence resolved CI-first (green checks on the exact assessed head count as evidence; a pending commit status that GitHub does not report as blocking the gating actor (`mergeStateStatus` not `BLOCKED`) is not waited for - external-CI commit statuses included - while a running Actions check, an unknown merge state, and every other merge precondition still withhold; the project's verification command runs only as fallback), a rubric-based review, a compact bottom-up report (what the PR delivers, including rows deferred or deviating per spec, PR comments, nits, blockers, then a `mergeable` / `fixable` verdict) and a verb menu whose rows derive from the actor's permissions on the PR; nothing mutates (fixes, pushes, reviews, merges, tracker comments) until you pick a row, except the opt-in `--rebase [base]` sync that rebases, reviews, and lease-pushes the head before verification, or holds the rebased head locally when a conflict stop's scoped tests fail, so the normal `fix` row resolves it before the lease push; after your own merge two opt-in rows post the `AC coverage` block to the ticket or the PR; every terminal menu offers a recommended `cleanup` row that tears down the gate's worktree and branch and runs the overrides file's `## cleanup` steps - run it with `/skill:gatekeep-pr <pr> [--rebase [base]]`), `check-delivery` (model-invocable on an explicit ask that names the ticket and the verb; the gates still need your selection - is a post-merge detective control: proves an issue actually shipped (default-branch landing, delivery target, per-AC evidence) before its tracker status advances; it never writes a terminal status - run it with `/skill:check-delivery <ref>`), `writing-plans`, `roasting-the-spec`, `test-driven-development`, `subagent-driven-development`, `dispatching-parallel-agents`, `verification-before-completion`, `requesting-code-review`, `receiving-code-review`, `using-git-worktrees`, `finishing-a-development-branch`, `forge-skill`, `linear` (reads/searches/comments on/manages Linear tickets via the `linearis` CLI; owns all linearis mechanics and the `## Issue tracker` overrides schema; tracker-facing skills route to it). Five more require explicit entry (`disable-model-invocation: true`): `brainstorming` starts the gated workflow on explicit consent - use `/skill:brainstorming` for reliable discovery, or opt in through reachable explicit prose or a human-selected handoff; after spec approval, downstream planning and implementation still chain automatically. `chase-bug` is human-only bug triage: read-only root-cause discovery to an evidenced verdict menu (real bug -> ticket/brainstorm/hotfix/respond; five negative verdicts), then a gated response to the reporter for addressable origins (GitHub issue / tracker ticket) and a rendered verdict summary otherwise - it never fixes during triage; the hotfix row hands off to `skills/chase-bug/hotfix.md` after the menu - run it with `/skill:chase-bug`. `gauntlet-performance` reads the committed run telemetry (current repo by default, `--dir <path>` adds others) through the parse-only `gauntlet-performance` CLI and answers with one example-led recommendation, 3-5 cornerstone numbers, a council assessment when available, and a menu of at most three actions (render a report file, open the recommendation as a ticket via `shape-ticket`, drill into one run); it writes nothing unless you pick render - run it with `/skill:gauntlet-performance [--dir <path>]... [--since <version>]`. `gauntlet-handoff` ends a session whose flow a fresh session will continue: it invokes pi-cohort's `handoff` skill (`--out <path>` or `--key <name>`, default key = the run worktree's branch with `/` flattened to `-`, default file `<tmpdir>/pi-handoff/<key>.md`), then appends the gauntlet process-state section (phase/plan tracker status) per the shared contract `skills/gauntlet-resume/reference/brief-contract.md` - run it with `/skill:gauntlet-handoff [--out <path> | --key <name>]`. `gauntlet-resume` is the only way back into an interrupted flow from a fresh session: it takes a `gauntlet-handoff` brief (a file path, pasted text, or - with no arguments - a pick from the briefs under `<tmpdir>/pi-handoff/`), a bare worktree that already holds a spec, or a spec tracked on main under a configured spec dir (a spec seed: it creates or reuses the worktree through `using-git-worktrees` and pins the spec), restores phase/plan tracker state through the legal arming sequence (`start brainstorm`, `skip` with `resume:` reasons, `plan_check` before implement-or-later), and never infers approval from artifacts - run it with `/skill:gauntlet-resume [<brief-file> | <spec>.md | <worktree-name-or-path>]`.
81
81
  - **9 subagent personas** - the specialized child agents the skills dispatch via pi-cohort: `implementer`, `code-reviewer`, `spec-reviewer`, `conformance-reviewer`, `spec-summarizer`, `plan-writer`, `spec-council-member`, `spec-council-synthesizer`, `spec-council-applier`. See [doc/personas.md](./doc/personas.md) for what each one does and why its permissions are scoped the way they are.
82
- - **4 runtime extensions** - the enforcement layer. `plan-tracker` and `phase-tracker` are tools skills call to track progress (with a TUI widget); `verify-before-ship` is a hook that warns if you push or open a PR without a passing test run since your last edit; a phase-tracker flow guard reminds on implement-phase commits missing spec/code review. In a brainstorming-entered flow, phase-tracker rejects `implement` or `verify` completion while tracker tasks remain pending or in progress; see [its configuration reference](./doc/configuration.md#phase-tracker). phase-tracker also registers `plan_check`, which verifies a plan against its spec and against the grammar in [skills/writing-plans/reference/plan-contract.md](./skills/writing-plans/reference/plan-contract.md), including that each task's `Tests:` commands are selective and never the full suite; a pass stamps the plan for implementation. `telemetry` records one YAML record per gauntlet run (phase timing, models, personas, gate/fix rounds, diff at ship) that ships in the squash beside the spec - it is written only inside a brainstorming-entered flow, kept out of history until `finishing-a-development-branch` seals it once with `gauntlet-telemetry-seal` (a `telemetry:` commit under git; the working-copy snapshot in a plain jj workspace) - and blocks a brainstorm `write` into an already-shipped spec; see [its configuration reference](./doc/configuration.md#telemetry). See [doc/configuration.md](./doc/configuration.md) for the settings each one reads.
82
+ - **4 runtime extensions** - the enforcement layer. `plan-tracker` and `phase-tracker` are tools skills call to track progress (with a TUI widget); `verify-before-ship` is a hook that warns if you push or open a PR without a passing test run since your last edit; a phase-tracker flow guard reminds on implement-phase commits missing spec/code review. In a brainstorming-entered flow, phase-tracker rejects `implement` or `verify` completion while tracker tasks remain pending or in progress; see [its configuration reference](./doc/configuration.md#phase-tracker). phase-tracker also registers `plan_check`, which verifies a plan against its spec and against the grammar in [skills/writing-plans/reference/plan-contract.md](./skills/writing-plans/reference/plan-contract.md), including that each task's `Tests:` commands are selective and never the full suite, and that every test command carries a passing base-commit result; a pass stamps the plan for implementation. `telemetry` records one YAML record per gauntlet run (phase timing, models, personas, gate/fix rounds, diff at ship) that ships in the squash beside the spec - it is written only inside a brainstorming-entered flow, kept out of history until `finishing-a-development-branch` seals it once with `gauntlet-telemetry-seal` (a `telemetry:` commit under git; the working-copy snapshot in a plain jj workspace) - and blocks a brainstorm `write` into an already-shipped spec; see [its configuration reference](./doc/configuration.md#telemetry). See [doc/configuration.md](./doc/configuration.md) for the settings each one reads.
83
83
 
84
84
  pi-gauntlet is **opinionated**: it recommends this pipeline for non-trivial changes, but starts `brainstorming` only after explicit user consent. Enforcement is opt-in by entry, not ambient: once brainstorming starts a flow, the phase-tracker extension mechanically blocks a phase from closing before its gate runs, and warns once if the main loop writes code during implement (subagents own implement-phase edits). A change made *without* entering the flow (a typo, a formatting run, a dependency bump - see "When to use / when NOT to use") is not gated; the discipline of routing real work through the pipeline is a convention the tooling supports, not a trap it springs on every edit.
85
85
 
@@ -110,7 +110,7 @@ Run by a human against pi-cohort >= 7.1.0, before a pi-gauntlet release claims t
110
110
  | Gate | A machine-enforced checkpoint between phases (e.g. within a brainstorming-entered flow, `complete verify` is blocked until conformance review has run). Not a suggestion. |
111
111
  | Spec council | Multi-model critique of the spec before you see it (`roasting-the-spec`); members and chair return saved-file references, and a fresh applier inherits the main loop's model and thinking unless overridden, edits only the spec, and returns the audit. The main loop checks structure without ingesting member/chair findings, re-reads the spec, and hands it to the unchanged user gate to ratify or revert. Members flag unrelated extras or sufficient in-scope simplifications (`over-spec`); every member closes with a `lean:` line - "nothing to cut" is a first-class answer - and the chair tallies them (`lean: <k> of <n> members found nothing to cut`). Falls back to a single-model critique if no council is configured. |
112
112
  | Conformance gate | The closing check: does the delivered code + docs match your *original prompt*, not the derived plan? Compatible executable recommendations auto-fix first; anything still open renders as a numbered decision brief - one item per decision, the problem in plain words first, lettered options each with a one-line consequence, and `Recommended: <letter> - <reason>` or `No recommendation - pick one`. Reply with one line per item (`1. B`), or bare `1` to take every recommendation (no-recommendation items are re-asked by number); a current `CONFORMS` / no-concerns handoff goes straight to branch options with no extra sign-off. |
113
- | Wave | A batch of plan tasks that don't touch the same files, dispatched to implementers in parallel. |
113
+ | Wave | A batch of N >= 1 plan tasks with disjoint files, run through the one wave loop; N > 1 dispatches concurrently |
114
114
  | Overrides file | `.pi/gauntlet-overrides.md` - where you put project-specific detail the generic skills don't know (CI command, worktree wrapper, routing rules). |
115
115
 
116
116
  ## When to use / when NOT to use
@@ -122,7 +122,7 @@ Run by a human against pi-cohort >= 7.1.0, before a pi-gauntlet release claims t
122
122
  ## Requirements
123
123
 
124
124
  - [pi-coding-agent](https://github.com/earendil-works/pi) ≥ 0.85.1 - tested minimum for sequential `phase_tracker` tool execution, which persists a tracker update before a later tool call in the same model message.
125
- - [pi-cohort](https://github.com/jjuraszek/pi-cohort) >= 7.4.1 - required peer package, including binary-safe worktree patches. Skills that dispatch agents (`requesting-code-review`, `subagent-driven-development`, `dispatching-parallel-agents`, `writing-plans`, `forge-skill`, `shape-ticket`, `roasting-the-spec`) call `subagent({})`, which pi-cohort provides. pi-gauntlet does not vendor the dispatch tool; without pi-cohort those skills have nothing to call. Gauntlet execution dispatches explicitly use `async: false`; do not enable pi-cohort's incompatible `forceTopLevelAsync` setting. See pi-cohort's [dispatch configuration](https://github.com/jjuraszek/pi-cohort/blob/main/doc/configuration.md) for that setting's owner and semantics. In Parallel-Wave Mode a wave's implementers and first spec reviews are one chain call.
125
+ - [pi-cohort](https://github.com/jjuraszek/pi-cohort) >= 7.4.1 - required peer package, including binary-safe worktree patches. Skills that dispatch agents (`requesting-code-review`, `subagent-driven-development`, `dispatching-parallel-agents`, `writing-plans`, `forge-skill`, `shape-ticket`, `roasting-the-spec`) call `subagent({})`, which pi-cohort provides. pi-gauntlet does not vendor the dispatch tool; without pi-cohort those skills have nothing to call. Gauntlet execution dispatches explicitly use `async: false`; do not enable pi-cohort's incompatible `forceTopLevelAsync` setting. See pi-cohort's [dispatch configuration](https://github.com/jjuraszek/pi-cohort/blob/main/doc/configuration.md) for that setting's owner and semantics. A wave of N >= 1 tasks dispatches its implementers and first spec reviews as one chain call.
126
126
 
127
127
  Both packages must be listed in your `.pi/settings.json#packages` array (pi adds them automatically when you `pi install`). pi-gauntlet and pi-cohort are versioned independently but release together whenever dispatch semantics change - pin compatible versions of both.
128
128
 
@@ -77,8 +77,6 @@ with one partition line over the `Fn` IDs assigned above; when a task requires
77
77
  a trailing `TRAJECTORY:` verdict (re-review), that verdict comes after `Behaviour-change:` as the
78
78
  true final line:
79
79
 
80
- <!-- writing-plans' plan-time Parallel-safe: line is a deliberately different free-text form; do not unify -->
81
-
82
80
  ```
83
81
  Parallel-safe: <group>[; <group>]*
84
82
  <group> = <comma-separated finding-id list> " disjoint"
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: plan-writer
3
3
  description: Writes, checks, and repairs the gauntlet implementation plan for one approved spec in a fresh context; the plan file is its only output. Dispatched only by the writing-plans skill; not for direct dispatch.
4
- tools: read, grep, find, ls, write, plan_check
4
+ tools: read, grep, find, ls, bash, write, plan_check
5
5
  inheritProjectContext: false
6
6
  inheritSkills: false
7
7
  completionGuard: true
@@ -23,7 +23,7 @@ No plan content, no summary, no commentary.
23
23
 
24
24
  The task names five absolute paths: the worktree, the approved spec, the plan path, the plan skill file, and the plan contract file. It may also carry one block after them:
25
25
 
26
- - `Findings:` - the verbatim output of the orchestrator's own `plan_check` on the plan at the plan path, or a human's answers to the plan's open questions. Read the existing plan first and keep everything the block does not touch. Treat each answer as the resolution of the matching open question; remove the `## Open Questions` section when every question is answered.
26
+ - `Findings:` - the verbatim output of the orchestrator's own `plan_check` on the plan at the plan path, or a human's answers to the plan's open questions. Read the existing plan first and keep everything the block does not touch, except baseline rows, which Baseline owns and regenerates. Treat each answer as the resolution of the matching contract question; remove the section when no questions remain. `Findings: rerun baseline` reruns every task command.
27
27
  - `Amend:` - the amended spec sections and the rule for the edit. Read the existing plan first; preserve every task title and wave position the amendment does not touch; edit only the affected anchors and tasks.
28
28
 
29
29
  ## Procedure
@@ -32,12 +32,13 @@ The task names five absolute paths: the worktree, the approved spec, the plan pa
32
32
  2. Read the plan skill file (`SKILL.md`) from its `## File Structure` heading through the end of its `## Self-Review` section, and the whole plan contract file (`plan-contract.md`). That text is the plan contract; follow it as written.
33
33
  3. Read the spec in full. Recon the code the spec touches by reading it directly: exact paths to create, modify, or delete; call sites and tests with line ranges; the scoped command that runs one test file; scoped and repo-wide lint and format commands; the full-suite verification entrypoint and whether it bundles style checks. Dispatch nothing.
34
34
  4. Write the plan at the plan path with `write` as a full replacement. The plan path is the only file you write; never create or edit any other file.
35
- 5. Run `plan_check({ planPath })` on the plan path. A result whose first line starts with `PASS` is a pass; `PASS (no flow to stamp)` is the expected text here. On `FAIL`, fix every finding in the plan and run it again.
36
- 6. Apply the judgment checks the skill's `## Self-Review` section lists: code-vs-anchor sanity, type and API consistency, test contract, runtime-resource disjointness, solo-reason validity, waiver authorization, verification-ownership authorization, documentation-impact mapping. Any edit returns you to step 5.
37
- 7. Stop when `plan_check` passes and the judgment checks caused no edit. When the same finding has survived 3 fix rounds, write it under an `## Open Questions` section at the top of the plan and stop; rows in a `Findings:` block continue that finding's count, so one 3-round budget per finding covers every dispatch. Then reply per the output contract.
35
+ 5. Run Baseline from the skill's Self-Review before `plan_check({ planPath })`. On Findings/Amend re-dispatch, read provenance once for the current base; retain evidence only for unchanged commands on that same base, rerun missing, invalid, changed-command or changed-SHA evidence, and rerun every command for `Findings: rerun baseline`. Never rewrite SHAs without execution. Baseline owns and regenerates its rows; preserve unresolved contract rows.
36
+ 6. Run the deterministic checker and judgment checks in Self-Review, including Contract decisions. Repair `baseline-line` and substantive baseline `open-questions` findings only by Baseline execution, never by authoring evidence or deleting/rewording a row. Normalize malformed Open Questions into rows without changing substantive text. Fix every other finding; with substantive rows remaining, reply `OPEN QUESTIONS <abs plan path>` immediately, without a passing check or repair rounds for those rows. Any ordinary edit returns to step 5.
37
+ 7. Stop when `plan_check` passes and judgment checks cause no edit. Apply the 3-round budget only to ordinary findings: convert an exhausted finding to an Open Questions row and stop. Findings blocks continue that finding's count across dispatches. Omit the section when no rows remain and reply per the output contract.
38
38
 
39
39
  ## Rules
40
40
 
41
- - Never leave a placeholder in the plan; a decision you cannot make is a named open question.
41
+ - Use `bash` only for Baseline provenance (`git rev-parse`, `git status --porcelain`, or the contract's jj equivalents) and the plan's own `Tests:` commands in the worktree. Write only the plan. Never edit tracked source, commit, run `stash`, `checkout`, `restore`, `clean`, or mutate jj's working copy or revisions (`new`, `commit`, `abandon`, `restore`, `squash`).
42
+ - Never leave a placeholder in authored plan prose; retain verbatim failure evidence in row continuation fences. Derive decisions from source per Contract decisions; name only an unsettled decision as an open question.
42
43
  - Never call `plan_tracker`, `phase_tracker`, or `subagent`; the orchestrator owns them.
43
44
  - Never reply with more than the one line.
@@ -42,6 +42,7 @@ const VALID_PLAN = `# Fixture Plan
42
42
 
43
43
  **Tests:**
44
44
  - \`node --test extensions/lib/fixture-task1.test.ts\`
45
+ - baseline: abc1234 pass
45
46
  - via: \`helperFn()\`
46
47
 
47
48
  This task implements helperFn() for parsing.
@@ -57,13 +58,12 @@ This task implements helperFn() for parsing.
57
58
 
58
59
  **Tests:**
59
60
  - \`node --test extensions/lib/fixture-task2.test.ts\`
61
+ - baseline: abc1234 pass
60
62
 
61
63
  This task handles naming details.
62
64
 
63
65
  ## Wave 2 — Solo task
64
66
 
65
- Solo: lone remaining task
66
-
67
67
  ### Task 3: Document banned token handling
68
68
 
69
69
  **Spec:** doc/specs/fixture-spec.md § "Testing" L9
@@ -74,6 +74,7 @@ Solo: lone remaining task
74
74
 
75
75
  **Tests:**
76
76
  - \`node --test extensions/lib/fixture-task3.test.ts\`
77
+ - baseline: abc1234 pass
77
78
 
78
79
  The literal TODO is intentionally documented here per spec quote-integrity requirement.
79
80
 
@@ -520,8 +521,6 @@ const ENTRYPOINT_PLAN = `# Fixture Plan
520
521
 
521
522
  ## Wave 1 — Solo
522
523
 
523
- Solo: lone remaining task
524
-
525
524
  ### Task 1: Scoped test
526
525
 
527
526
  **Spec:** doc/specs/fixture-spec.md § "Testing" L4
@@ -532,6 +531,7 @@ Solo: lone remaining task
532
531
 
533
532
  **Tests:**
534
533
  - \`node --test extensions/lib/plan-check.test.ts\`
534
+ - baseline: abc1234 pass
535
535
 
536
536
  Run node --test extensions/lib/plan-check.test.ts and confirm green.
537
537
 
@@ -550,7 +550,7 @@ test("quote-integrity: header entrypoint literal on an anchored line is satisfie
550
550
 
551
551
  test("quote-integrity: scoped command on the same anchored line is still required in the task body", () => {
552
552
  const mutated = ENTRYPOINT_PLAN.replace(
553
- "- Test: extensions/lib/plan-check.test.ts\n\n**Tests:**\n- `node --test extensions/lib/plan-check.test.ts`\n\nRun node --test extensions/lib/plan-check.test.ts and confirm green.",
553
+ "- Test: extensions/lib/plan-check.test.ts\n\n**Tests:**\n- `node --test extensions/lib/plan-check.test.ts`\n- baseline: abc1234 pass\n\nRun node --test extensions/lib/plan-check.test.ts and confirm green.",
554
554
  "- Test: extensions/lib/other.test.ts\n\n**Tests:**\n- `node --test extensions/lib/other.test.ts`\n\nRun the scoped test and confirm green.",
555
555
  );
556
556
  const qi = findingsFor(checkPlan(mutated, ENTRYPOINT_SPEC, alwaysTruePort()), "quote-integrity");
@@ -649,7 +649,7 @@ test("check 4 fail-closed: invalid glob (port throws)", () => {
649
649
 
650
650
  test("check 4 fail-closed: task missing **Files:** block", () => {
651
651
  const mutated = VALID_PLAN.replace(
652
- "**Files:**\n- Modify: extensions/lib/fixture-task3.ts\n- Test: extensions/lib/fixture-task3.test.ts\n\n**Tests:**\n- `node --test extensions/lib/fixture-task3.test.ts`\n\n",
652
+ "**Files:**\n- Modify: extensions/lib/fixture-task3.ts\n- Test: extensions/lib/fixture-task3.test.ts\n\n**Tests:**\n- `node --test extensions/lib/fixture-task3.test.ts`\n- baseline: abc1234 pass\n\n",
653
653
  "",
654
654
  );
655
655
  const findings = checkPlan(mutated, SPEC_TEXT, alwaysTruePort());
@@ -698,13 +698,14 @@ test("check 6 wave-file-disjointness: two tasks in the same wave declare the sam
698
698
  );
699
699
  });
700
700
 
701
- test("check 7 solo-line: single-task wave missing its Solo: line", () => {
702
- const mutated = VALID_PLAN.replace("Solo: lone remaining task\n\n", "");
703
- const findings = checkPlan(mutated, SPEC_TEXT, alwaysTruePort());
704
- const sl = findingsFor(findings, "solo-line");
705
- assert.equal(sl.length, 1);
706
- assert.match(sl[0].reason, /missing a 'Solo: <reason>' line/);
707
- assert.equal(sl[0].line, lineOf(mutated, "## Wave 2 — Solo task"));
701
+ test("every-wave-singleton plan without Solo: lines passes; legacy Solo: lines are accepted silently", () => {
702
+ const plan = VALID_PLAN.replace(
703
+ "### Task 2: Implement naming",
704
+ "## Wave 2 - Naming\n\n### Task 2: Implement naming",
705
+ ).replace("## Wave 2 — Solo task", "## Wave 3 - Documentation");
706
+ assert.deepEqual(checkPlan(plan, SPEC_TEXT, alwaysTruePort()), []);
707
+ const legacy = plan.replace("## Wave 2 - Naming", "## Wave 2 - Naming\n\nSolo: Task 2 depends on Wave 1");
708
+ assert.deepEqual(checkPlan(legacy, SPEC_TEXT, alwaysTruePort()), []);
708
709
  });
709
710
 
710
711
  test("check 8 header-entrypoint: verification entrypoint string reused outside the header", () => {
@@ -746,8 +747,6 @@ const EXEMPTION_PLAN = `# Exemption Plan
746
747
 
747
748
  ## Wave 1 — Solo task
748
749
 
749
- Solo: only task in this wave
750
-
751
750
  ### Task 1: Document banned token handling
752
751
 
753
752
  **Spec:** doc/specs/exemption-spec.md § "Notes" L4
@@ -758,6 +757,7 @@ Solo: only task in this wave
758
757
 
759
758
  **Tests:**
760
759
  - \`node --test extensions/lib/exemption-task1.test.ts\`
760
+ - baseline: abc1234 pass
761
761
 
762
762
  The literal TODO is intentionally documented here per spec quote-integrity requirement.
763
763
 
@@ -799,9 +799,8 @@ test("check 2/5 exemption negative: without the task body carrying the literal,
799
799
  );
800
800
  });
801
801
 
802
- test("aggregate: independent mutations across three checks are all reported together", () => {
802
+ test("aggregate: independent mutations across two checks are all reported together", () => {
803
803
  let mutated = VALID_PLAN;
804
- mutated = mutated.replace("Solo: lone remaining task\n\n", "");
805
804
  mutated = mutated.replace(
806
805
  "- via: `helperFn()`\n\nThis task implements helperFn() for parsing.",
807
806
  "- via: `parserSeam()`\n\nThis task implements the helper for parsing.",
@@ -811,7 +810,6 @@ test("aggregate: independent mutations across three checks are all reported toge
811
810
  "- Modify: extensions/lib/fixture-shared.ts",
812
811
  );
813
812
  const findings = checkPlan(mutated, SPEC_TEXT, alwaysTruePort());
814
- assert.ok(findingsFor(findings, "solo-line").length > 0, "expected a solo-line finding");
815
813
  assert.ok(findingsFor(findings, "quote-integrity").length > 0, "expected a quote-integrity finding");
816
814
  assert.ok(findingsFor(findings, "wave-file-disjointness").length > 0, "expected a wave-file-disjointness finding");
817
815
  });
@@ -1050,6 +1048,104 @@ test("tests-block: no path normalization - ./x and x differ, as in Files:", () =
1050
1048
  assert.ok(tb(mutated).some((x) => x.reason.includes("not anchored")));
1051
1049
  });
1052
1050
 
1051
+ const BASELINE = "- baseline: abc1234 pass";
1052
+ const T1_COMMAND = "node --test extensions/lib/fixture-task1.test.ts";
1053
+ const T1_PATH = "extensions/lib/fixture-task1.test.ts";
1054
+ const baselineFindings = (plan: string) => findingsFor(checkPlan(plan, SPEC_TEXT, alwaysTruePort()), "baseline-line");
1055
+
1056
+ for (const [name, evidence] of [
1057
+ ["pass", BASELINE],
1058
+ ["pass artifacts", BASELINE + " - artifacts: coverage.json, report.txt"],
1059
+ ["via", BASELINE + " via `" + T1_COMMAND + "`"],
1060
+ ["via artifacts", BASELINE + " via `" + T1_COMMAND + "` - artifacts: coverage.json"],
1061
+ ] ) {
1062
+ test(`baseline-line accepts ${name}`, () => {
1063
+ assert.deepEqual(baselineFindings(VALID_PLAN.replace(BASELINE, evidence)), []);
1064
+ });
1065
+ }
1066
+
1067
+ for (const [name, evidence, reason] of [
1068
+ ["missing", "", `Task 1: command \`${T1_COMMAND}\` has no baseline: bullet`],
1069
+ ["bad sha", "- baseline: ABC1234 pass", "Task 1: baseline: bullet malformed:"],
1070
+ ["short sha", "- baseline: abc123 pass", "Task 1: baseline: bullet malformed:"],
1071
+ ["long sha", "- baseline: " + "a".repeat(41) + " pass", "Task 1: baseline: bullet malformed:"],
1072
+ ["bad result", "- baseline: abc1234 fail", "Task 1: baseline: bullet malformed:"],
1073
+ ["empty base command", BASELINE + " via ` `", "Task 1: baseline: bullet malformed:"],
1074
+ ["separator-only base command", BASELINE + " via ` ; `", "Task 1: baseline: bullet malformed:"],
1075
+ ["misplaced", "- via: seam\n" + BASELINE, "Task 1: baseline: not directly after its command"],
1076
+ ["duplicate", BASELINE + "\n" + BASELINE, "Task 1: baseline: not directly after its command"],
1077
+ ["sha mismatch", "- baseline: def5678 pass", "baseline SHAs differ: def5678, abc1234"],
1078
+ ["skip existing", `- baseline: abc1234 skipped - ${T1_PATH} created by this task`, `Task 1: baseline: skipped but \`${T1_PATH}\` is not a Create: path`],
1079
+ ["via unanchored", BASELINE + " via `echo hi`", `Task 1: baseline: pass via \`echo hi\` segment \`echo hi\` anchors no existing Test: path`],
1080
+ ["via unanchored compound", BASELINE + " via `" + T1_COMMAND + " && echo hi`", "anchors no existing Test: path"],
1081
+ ]) {
1082
+ test(`baseline-line rejects ${name}`, () => {
1083
+ const findings = baselineFindings(VALID_PLAN.replace(BASELINE, evidence));
1084
+ assert.ok(findings.some(f => f.reason.includes(reason)), JSON.stringify(findings));
1085
+ });
1086
+ }
1087
+
1088
+ test("baseline-line accepts 40-character SHAs and introduced-selector base commands", () => {
1089
+ assert.deepEqual(baselineFindings(VALID_PLAN.replaceAll("abc1234", "a".repeat(40))), []);
1090
+ const plan = VALID_PLAN.replace(`- \`${T1_COMMAND}\``, `- \`${T1_COMMAND}::new_case\``)
1091
+ .replace(BASELINE, BASELINE + " via `" + T1_COMMAND + "`");
1092
+ assert.deepEqual(baselineFindings(plan), []);
1093
+ });
1094
+
1095
+ test("baseline-line pass via rejects created tokens outside this task's Test paths", () => {
1096
+ const plan = VALID_PLAN.replace(BASELINE, BASELINE + " via `" + T1_COMMAND + " extensions/lib/fixture-task2.ts`");
1097
+ assert.ok(baselineFindings(plan).some(f => f.reason.includes("anchors Create: path extensions/lib/fixture-task2.ts")));
1098
+ });
1099
+
1100
+ test("baseline-line validates skipped anchor sets and derived surviving segments", () => {
1101
+ const created = VALID_PLAN.replace("- Create: extensions/lib/fixture-task1.ts", `- Create: ${T1_PATH}`);
1102
+ const skipped = `- baseline: abc1234 skipped - ${T1_PATH} created by this task`;
1103
+ assert.deepEqual(baselineFindings(created.replace(BASELINE, skipped)), []);
1104
+ assert.ok(baselineFindings(created.replace(BASELINE, BASELINE + " via `" + T1_COMMAND + "`" )).some(f => f.reason.includes("anchors Create: path " + T1_PATH)));
1105
+ for (const list of ["other.test.ts", `${T1_PATH}, other.test.ts`]) {
1106
+ assert.ok(baselineFindings(created.replace(BASELINE, `- baseline: abc1234 skipped - ${list} created by this task`)).length);
1107
+ }
1108
+ const compound = created.replace(`- \`${T1_COMMAND}\``, `- \`${T1_COMMAND} && node --test extensions/lib/fixture-task2.test.ts\``)
1109
+ .replace(`- Test: ${T1_PATH}`, `- Test: ${T1_PATH}\n- Test: extensions/lib/fixture-task2.test.ts`);
1110
+ assert.deepEqual(baselineFindings(compound.replace(BASELINE, BASELINE + " via `node --test extensions/lib/fixture-task2.test.ts`")), []);
1111
+ assert.ok(baselineFindings(compound.replace(BASELINE, skipped)).length);
1112
+ const allCreated = compound.replace("- Create: extensions/lib/fixture-task1.ts", "- Create: extensions/lib/fixture-task2.test.ts")
1113
+ .replace("- Create: extensions/lib/fixture-task2.ts", "- Create: extensions/lib/fixture-task2.test.ts");
1114
+ const bothSkipped = `- baseline: abc1234 skipped - ${T1_PATH}, extensions/lib/fixture-task2.test.ts created by this task`;
1115
+ assert.deepEqual(baselineFindings(allCreated.replace(BASELINE, bothSkipped)), []);
1116
+ assert.ok(baselineFindings(allCreated.replace(BASELINE, skipped)).length);
1117
+ const none = VALID_PLAN.replace(`- \`${T1_COMMAND}\``, "- none: docs");
1118
+ assert.ok(baselineFindings(none).some(f => f.reason === "Task 1: baseline: under a none: task"));
1119
+ });
1120
+
1121
+ const FAILURE_ROW = `- Task 1: \`${T1_COMMAND}\` fails on abc1234 (exit 2): assertion failed`;
1122
+ for (const [name, body, count, malformed] of [
1123
+ ["absent", "", 0, false],
1124
+ ["sentinel", "- none", 0, false],
1125
+ ["failure", FAILURE_ROW, 1, false],
1126
+ ["global then failure", "- Baseline: worktree dirty on abc1234: source.ts\n" + FAILURE_ROW, 2, false],
1127
+ ["contract", "- Task 1: unresolved caller contract", 1, false],
1128
+ ["prose", "Not resolved", 1, true],
1129
+ ["table", "| question | owner |", 1, true],
1130
+ ["continuations", FAILURE_ROW + "\n ran: `node --test original.ts`\n blocked: Task 2 `node --test next.ts`\n ```text\n TODO etc.\n ```", 1, false],
1131
+ ] as const) {
1132
+ test(`open-questions ${name}`, () => {
1133
+ const plan = VALID_PLAN + (body ? "\n## Open Questions\n\n" + body + "\n" : "");
1134
+ const findings = checkPlan(plan, SPEC_TEXT, alwaysTruePort());
1135
+ const questions = findingsFor(findings, "open-questions");
1136
+ assert.equal(questions.length, count);
1137
+ if (malformed) assert.equal(questions[0].reason, "## Open Questions malformed: expected `- ` rows or `- none`");
1138
+ if (body.includes(FAILURE_ROW)) assert.ok(questions.some(f => f.reason === "plan has open questions: " + FAILURE_ROW));
1139
+ assert.deepEqual(findingsFor(findings, "placeholder-scan"), []);
1140
+ });
1141
+ }
1142
+
1143
+ test("placeholder-scan masks only valid question output, not authored fences or continuations", () => {
1144
+ for (const text of ["```\nTODO\n```", "## Open Questions\n ```\n TODO\n ```", "## Open Questions\n- TODO unresolved", "## Open Questions\n" + FAILURE_ROW + "\n ```\n TODO", "## Open Questions\n" + FAILURE_ROW + "\n ```\nTODO\n ```", "## Open Questions\n" + FAILURE_ROW + "\n ran: `TODO`", "## Open Questions\n" + FAILURE_ROW + "\n blocked: TODO"]) {
1145
+ assert.ok(findingsFor(checkPlan(VALID_PLAN + "\n" + text, SPEC_TEXT, alwaysTruePort()), "placeholder-scan").length);
1146
+ }
1147
+ });
1148
+
1053
1149
  test("tests-block: fenced **Tests:** lines are ignored", () => {
1054
1150
  const mutated = VALID_PLAN.replace(
1055
1151
  "This task handles naming details.",
@@ -58,6 +58,7 @@ interface Task {
58
58
  testsMisplacedLines: number[];
59
59
  tests: TestsBullet[];
60
60
  testsVia: TestsBullet[];
61
+ testsBaseline: TestsBullet[];
61
62
  testsNone: TestsBullet[];
62
63
  testsMalformed: TestsBullet[];
63
64
  }
@@ -67,8 +68,6 @@ interface Wave {
67
68
  line: number;
68
69
  text: string;
69
70
  label: string;
70
- soloLine: number | undefined;
71
- soloReason: string | undefined;
72
71
  }
73
72
 
74
73
  interface CoverageRow {
@@ -103,6 +102,7 @@ interface ParsedPlan {
103
102
  tasks: Task[];
104
103
  coverageTableFound: boolean;
105
104
  coverageRows: CoverageRow[];
105
+ openQuestions: { findings: PlanCheckFinding[]; output: Set<number> };
106
106
  }
107
107
 
108
108
  interface SpecHeading {
@@ -188,18 +188,7 @@ function parsePlan(planText: string): ParsedPlan {
188
188
  if (mask[i]) continue;
189
189
  const m = waveRe.exec(lines[i]);
190
190
  if (!m) continue;
191
- let soloLine: number | undefined;
192
- let soloReason: string | undefined;
193
- let j = i + 1;
194
- while (j < lines.length && lines[j].trim() === "") j++;
195
- if (j < lines.length) {
196
- const sm = /^Solo: (.+)$/.exec(lines[j]);
197
- if (sm) {
198
- soloLine = j + 1;
199
- soloReason = sm[1];
200
- }
201
- }
202
- waves.push({ number: Number(m[1]), line: i + 1, text: lines[i], label: m[3], soloLine, soloReason });
191
+ waves.push({ number: Number(m[1]), line: i + 1, text: lines[i], label: m[3] });
203
192
  }
204
193
 
205
194
  const taskHeaderIdxs: number[] = [];
@@ -263,6 +252,7 @@ function parsePlan(planText: string): ParsedPlan {
263
252
  const testsMisplacedLines: number[] = [];
264
253
  const tests: TestsBullet[] = [];
265
254
  const testsVia: TestsBullet[] = [];
255
+ const testsBaseline: TestsBullet[] = [];
266
256
  const testsNone: TestsBullet[] = [];
267
257
  const testsMalformed: TestsBullet[] = [];
268
258
 
@@ -295,10 +285,12 @@ function parsePlan(planText: string): ParsedPlan {
295
285
  const cmd = /^- `([^`]+)`$/.exec(l);
296
286
  const via = /^- via: (\S.*)$/.exec(l);
297
287
  const none = /^- none: (\S.*)$/.exec(l);
298
- const bullet = { line: p + 1, text: l, value: (cmd ?? via ?? none)?.[1] ?? "" };
288
+ const baseline = /^- baseline: (\S.*)$/.exec(l);
289
+ const bullet = { line: p + 1, text: l, value: (cmd ?? via ?? none ?? baseline)?.[1] ?? "" };
299
290
  if (cmd) tests.push(bullet);
300
291
  else if (via) testsVia.push(bullet);
301
292
  else if (none) testsNone.push(bullet);
293
+ else if (baseline) testsBaseline.push(bullet);
302
294
  else testsMalformed.push(bullet);
303
295
  }
304
296
  }
@@ -319,6 +311,7 @@ function parsePlan(planText: string): ParsedPlan {
319
311
  testsMisplacedLines,
320
312
  tests,
321
313
  testsVia,
314
+ testsBaseline,
322
315
  testsNone,
323
316
  testsMalformed,
324
317
  });
@@ -380,8 +373,59 @@ function parsePlan(planText: string): ParsedPlan {
380
373
  break;
381
374
  }
382
375
 
376
+ const questionFindings: PlanCheckFinding[] = [];
377
+ const questionOutput = new Set<number>();
378
+ let inSection = false;
379
+ let row = false;
380
+ let inOutput = false;
381
+ let sentinel: number | undefined;
382
+ let contentCount = 0;
383
+ const malformed = (idx: number) => questionFindings.push({ check: "open-questions", line: idx + 1, text: lines[idx], reason: "## Open Questions malformed: expected `- ` rows or `- none`" });
384
+ for (let idx = 0; idx < lines.length; idx++) {
385
+ const line = lines[idx];
386
+ if (!mask[idx] && /^## /.test(line)) {
387
+ if (inSection && sentinel !== undefined && contentCount !== 1) malformed(sentinel);
388
+ inSection = line === "## Open Questions";
389
+ row = false;
390
+ inOutput = false;
391
+ sentinel = undefined;
392
+ contentCount = 0;
393
+ continue;
394
+ }
395
+ if (!inSection) continue;
396
+ if (inOutput) {
397
+ questionOutput.add(idx);
398
+ if (/^\s+```/.test(line)) inOutput = false;
399
+ continue;
400
+ }
401
+ if (!line.trim()) continue;
402
+ contentCount++;
403
+ if (/^- \S/.test(line)) {
404
+ row = line !== "- none";
405
+ if (row) questionFindings.push({ check: "open-questions", line: idx + 1, text: line, reason: `plan has open questions: ${line}` });
406
+ else sentinel = idx;
407
+ } else if (row && /^\s+\S/.test(line)) {
408
+ if (/^\s+```/.test(line)) {
409
+ let end = idx + 1;
410
+ while (end < lines.length && !/^\s+```/.test(lines[end])) {
411
+ if (lines[end].trim() && !/^\s+/.test(lines[end])) break;
412
+ end++;
413
+ }
414
+ if (end < lines.length && /^\s+```/.test(lines[end])) {
415
+ inOutput = true;
416
+ questionOutput.add(idx);
417
+ }
418
+ }
419
+ } else {
420
+ malformed(idx);
421
+ row = false;
422
+ }
423
+ }
424
+ if (inSection && sentinel !== undefined && contentCount !== 1) malformed(sentinel);
425
+
383
426
  return {
384
427
  lines,
428
+ openQuestions: { findings: questionFindings, output: questionOutput },
385
429
  header: { specPathLine, specPath, verificationLine, verificationText, happyPathLine, happyPathText, happyPathCommand, separatorLine },
386
430
  waves,
387
431
  tasks,
@@ -487,7 +531,7 @@ function checkTestsBlock(parsed: ParsedPlan, fs: FsPort): PlanCheckFinding[] {
487
531
 
488
532
  const headingText = parsed.lines[task.testsHeadingLine - 1];
489
533
  if (task.tests.length === 0 && task.testsNone.length === 0) push(task, task.testsHeadingLine, headingText, "block empty: no command bullet and no `none:`");
490
- for (const b of task.testsMalformed) push(task, b.line, b.text, "malformed bullet: expected `- \\`command\\``, `- via: <seam>`, or `- none: <category>`");
534
+ for (const b of task.testsMalformed) push(task, b.line, b.text, "malformed bullet: expected `- \\`command\\``, `- baseline: <evidence>`, `- via: <seam>`, or `- none: <category>`");
491
535
  if (task.testsNone.length > 1 || (task.testsNone.length > 0 && (task.tests.length > 0 || task.testsVia.length > 0))) {
492
536
  push(task, task.testsNone[0].line, task.testsNone[0].text, "contradictory block: `none:` with a command or `via:`, or more than one `none:`");
493
537
  }
@@ -515,6 +559,55 @@ function checkTestsBlock(parsed: ParsedPlan, fs: FsPort): PlanCheckFinding[] {
515
559
  return findings;
516
560
  }
517
561
 
562
+ function checkBaselineLines(parsed: ParsedPlan, fs: FsPort): PlanCheckFinding[] {
563
+ const findings: PlanCheckFinding[] = [];
564
+ const shas = new Set<string>();
565
+ const creates = new Set(parsed.tasks.flatMap(t => t.files.filter(f => f.kind === "create").map(f => stripLineSuffix(f.path))));
566
+ for (const task of parsed.tasks) {
567
+ const push = (bullet: TestsBullet, reason: string) => findings.push({ check: "baseline-line", line: bullet.line, text: bullet.text, reason: `Task ${task.number}: ${reason}` });
568
+ const paths = task.files.filter(f => f.kind === "test").map(f => stripLineSuffix(f.path)).filter(p => !isBroadening(p));
569
+ const anchors = (command: string) => commandSegments(command).flatMap(segment => segment.split(" ").flatMap(token => paths.filter(p => token === p || token.startsWith(p + "::") || token.startsWith(p + "#") || token.startsWith(p + ":"))));
570
+ for (const command of task.tests) {
571
+ if (!task.testsBaseline.some(b => b.line === command.line + 1)) push(command, `command \`${command.value}\` has no baseline: bullet`);
572
+ }
573
+ for (const bullet of task.testsBaseline) {
574
+ const command = task.tests.find(c => c.line + 1 === bullet.line);
575
+ if (task.testsNone.length) push(bullet, "baseline: under a none: task");
576
+ else if (!command) push(bullet, "baseline: not directly after its command");
577
+ const match = /^([a-f0-9]{7,40}) (?:pass(?: via `([^`]+)`)?(?: - artifacts: (\S.*))?|skipped - (\S.*) created by this task)$/.exec(bullet.value);
578
+ if (!match || (match[2] !== undefined && !match[2].trim()) || (match[3] !== undefined && !match[3].split(", ").every(p => p.trim().length > 0))) {
579
+ push(bullet, `baseline: bullet malformed: ${bullet.text}`);
580
+ continue;
581
+ }
582
+ shas.add(match[1]);
583
+ if (match[4] !== undefined && command) {
584
+ const listed = match[4].split(", ");
585
+ const expected = new Set(anchors(command.value));
586
+ for (const path of new Set([...expected, ...listed])) {
587
+ if (!creates.has(path)) push(bullet, `baseline: skipped but \`${path}\` is not a Create: path`);
588
+ else if (!expected.has(path) || !listed.includes(path)) push(bullet, `baseline: bullet malformed: ${bullet.text}`);
589
+ }
590
+ if (!expected.size || listed.length !== new Set(listed).size) push(bullet, `baseline: bullet malformed: ${bullet.text}`);
591
+ }
592
+ if (match[2] !== undefined) {
593
+ const segments = commandSegments(match[2]);
594
+ if (!segments.length) {
595
+ push(bullet, `baseline: bullet malformed: ${bullet.text}`);
596
+ continue;
597
+ }
598
+ for (const segment of segments) {
599
+ const found = anchors(segment);
600
+ const created = [...creates].filter(p => segment.split(" ").some(token => token === p || token.startsWith(p + "::") || token.startsWith(p + "#") || token.startsWith(p + ":")));
601
+ for (const path of created) push(bullet, `baseline: pass via \`${match[2]}\` segment \`${segment}\` anchors Create: path ${path}`);
602
+ if (!found.some(p => !creates.has(p) && fs.exists(p))) push(bullet, `baseline: pass via \`${match[2]}\` segment \`${segment}\` anchors no existing Test: path`);
603
+ }
604
+ }
605
+ }
606
+ }
607
+ if (shas.size > 1) findings.push({ check: "baseline-line", line: 0, text: "", reason: `baseline SHAs differ: ${[...shas].join(", ")}` });
608
+ return findings;
609
+ }
610
+
518
611
  function taskBodyText(task: Task, lines: string[]): string {
519
612
  return lines.slice(task.bodyStartLine - 1, task.bodyEndLine).join("\n");
520
613
  }
@@ -822,6 +915,7 @@ function checkPlaceholderScan(parsed: ParsedPlan, requiredLiterals: Map<number,
822
915
  for (let ln = t.bodyStartLine; ln <= t.bodyEndLine; ln++) lineTask.set(ln, t);
823
916
  }
824
917
  parsed.lines.forEach((line, idx) => {
918
+ if (parsed.openQuestions.output.has(idx)) return;
825
919
  const lineNo = idx + 1;
826
920
  const lower = line.toLowerCase();
827
921
  const task = lineTask.get(lineNo);
@@ -944,23 +1038,6 @@ function globErrFinding(wave: Wave, pattern: string, err: Error): PlanCheckFindi
944
1038
  };
945
1039
  }
946
1040
 
947
- function checkSoloLine(parsed: ParsedPlan): PlanCheckFinding[] {
948
- const findings: PlanCheckFinding[] = [];
949
- for (const wave of parsed.waves) {
950
- const count = parsed.tasks.filter((t) => t.waveNumber === wave.number).length;
951
- if (count !== 1) continue;
952
- if (wave.soloLine === undefined || !wave.soloReason || wave.soloReason.trim().length === 0) {
953
- findings.push({
954
- check: "solo-line",
955
- line: wave.line,
956
- text: wave.text,
957
- reason: `single-task Wave ${wave.number} is missing a 'Solo: <reason>' line directly under its header`,
958
- });
959
- }
960
- }
961
- return findings;
962
- }
963
-
964
1041
  function checkHappyPathLine(parsed: ParsedPlan): PlanCheckFinding[] {
965
1042
  const { happyPathLine, happyPathText, happyPathCommand } = parsed.header;
966
1043
  if (happyPathLine === undefined || happyPathCommand !== undefined) return [];
@@ -992,7 +1069,7 @@ function checkHeaderEntrypoint(parsed: ParsedPlan): PlanCheckFinding[] {
992
1069
  const entrypoints = [entrypoint, parsed.header.happyPathCommand].filter((e): e is string => Boolean(e));
993
1070
  const executable = new Set<number>();
994
1071
  for (const task of parsed.tasks) {
995
- for (const bullet of [...task.tests, ...task.testsVia, ...task.testsNone, ...task.testsMalformed]) {
1072
+ for (const bullet of [...task.tests, ...task.testsVia, ...task.testsBaseline, ...task.testsNone, ...task.testsMalformed]) {
996
1073
  executable.add(bullet.line);
997
1074
  }
998
1075
  }
@@ -1087,10 +1164,11 @@ export function checkPlan(planText: string, specText: string, fs: FsPort): PlanC
1087
1164
  findings.push(...checkAnchorResolution(parsed, specLines));
1088
1165
  findings.push(...checkPathsExist(parsed, fs));
1089
1166
  findings.push(...checkTestsBlock(parsed, fs));
1167
+ findings.push(...checkBaselineLines(parsed, fs));
1168
+ findings.push(...parsed.openQuestions.findings);
1090
1169
  const requiredLiterals = computeRequiredLiteralsPerTask(parsed, specLines);
1091
1170
  findings.push(...checkPlaceholderScan(parsed, requiredLiterals));
1092
1171
  findings.push(...checkWaveFileDisjointness(parsed, fs));
1093
- findings.push(...checkSoloLine(parsed));
1094
1172
  findings.push(...checkHeaderEntrypoint(parsed));
1095
1173
  findings.push(...checkHappyPathLine(parsed));
1096
1174
  findings.push(...checkWaiverLiteral(parsed));