cclaw-cli 6.13.0 → 6.14.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.
@@ -639,4 +639,19 @@ export interface StageLintContext {
639
639
  * v6.13.0 — effective worktree execution mode for TDD linters.
640
640
  */
641
641
  worktreeExecutionMode: "single-tree" | "worktree-first";
642
+ /**
643
+ * v6.14.0 — effective TDD checkpoint mode. `per-slice` enforces
644
+ * RED-before-GREEN per slice (the default for new projects);
645
+ * `global-red` keeps the v6.12/v6.13 wave-batch barrier (auto-applied
646
+ * for `legacyContinuation: true` projects on `cclaw-cli sync`).
647
+ */
648
+ tddCheckpointMode: "per-slice" | "global-red";
649
+ /**
650
+ * v6.14.0 — effective integration-overseer dispatch mode.
651
+ * `conditional` runs the overseer only when
652
+ * `integrationCheckRequired()` returns `required: true`; `always`
653
+ * preserves the v6.13 behavior of running it on every multi-slice
654
+ * wave.
655
+ */
656
+ integrationOverseerMode: "conditional" | "always";
642
657
  }
@@ -1,4 +1,4 @@
1
- import type { DelegationEntry } from "../delegation.js";
1
+ import type { DelegationEntry, DelegationEvent } from "../delegation.js";
2
2
  import { type LintFinding, type StageLintContext } from "./shared.js";
3
3
  /**
4
4
  * v6.11.0 — TDD stage linter.
@@ -53,19 +53,62 @@ interface RedCheckpointResult {
53
53
  details: string;
54
54
  }
55
55
  /**
56
- * v6.12.0 Phase W RED checkpoint enforcement. The wave protocol
57
- * requires ALL Phase A REDs to land before ANY Phase B GREEN starts.
58
- * The rule is enforced on a per-wave basis, where a wave is defined by
59
- * `<artifacts-dir>/wave-plans/wave-NN.md` files (when present) listing
60
- * slice ids. When no wave manifest exists, the linter falls back to a
61
- * conservative implicit detection: a wave is a contiguous run of
62
- * `phase=red` events with no other-phase events between them; the rule
63
- * fires only when the implicit wave has 2+ members.
56
+ * v6.13.1 detect single-slice dispatch when the merged wave plan
57
+ * requires parallel ready slice-implementer fan-out.
58
+ */
59
+ export declare function evaluateWavePlanDispatchIgnored(params: {
60
+ artifactsDir: string;
61
+ planMarkdown: string;
62
+ runEvents: DelegationEvent[];
63
+ runId: string;
64
+ slices: Map<string, DelegationEntry[]>;
65
+ legacyContinuation: boolean;
66
+ }): Promise<LintFinding | null>;
67
+ /**
68
+ * v6.12.0 Phase W (legacy `global-red` mode) — RED checkpoint enforcement.
69
+ * The wave protocol requires ALL Phase A REDs to land before ANY Phase B
70
+ * GREEN starts. The rule is enforced on a per-wave basis, where a wave is
71
+ * defined by the managed `## Parallel Execution Plan` block in
72
+ * `05-plan.md` and/or `<artifacts-dir>/wave-plans/wave-NN.md` files. When
73
+ * no wave manifest exists, the linter falls back to a conservative
74
+ * implicit detection: a wave is a contiguous run of `phase=red` events
75
+ * with no other-phase events between them; the rule fires only when the
76
+ * implicit wave has 2+ members.
77
+ *
78
+ * v6.14.0: this function powers the `global-red` checkpoint mode. New
79
+ * projects default to `per-slice` mode (see
80
+ * `evaluatePerSliceRedBeforeGreen`); `legacyContinuation: true` projects
81
+ * auto-keep this behavior. Exported under both `evaluateGlobalRedCheckpoint`
82
+ * (canonical name) and `evaluateRedCheckpoint` (back-compat alias for
83
+ * existing tests/consumers).
64
84
  *
65
85
  * @param waveMembers Optional explicit wave manifest. Map key is wave
66
86
  * name (e.g. `"W-01"`); value is the set of slice ids in that wave.
67
87
  */
68
- export declare function evaluateRedCheckpoint(slices: Map<string, DelegationEntry[]>, waveMembers?: Map<string, Set<string>> | null): RedCheckpointResult;
88
+ export declare function evaluateGlobalRedCheckpoint(slices: Map<string, DelegationEntry[]>, waveMembers?: Map<string, Set<string>> | null): RedCheckpointResult;
89
+ /**
90
+ * Back-compat alias for `evaluateGlobalRedCheckpoint` (v6.12.0 Phase W
91
+ * behavior). Existing tests/consumers can keep importing
92
+ * `evaluateRedCheckpoint`. The v6.14.0 stream-style mode uses
93
+ * `evaluatePerSliceRedBeforeGreen` instead.
94
+ */
95
+ export declare const evaluateRedCheckpoint: typeof evaluateGlobalRedCheckpoint;
96
+ /**
97
+ * v6.14.0 — per-slice RED-before-GREEN enforcement (default mode).
98
+ *
99
+ * For each slice with both phase=red and phase=green completed events,
100
+ * fail if any green completedTs precedes the slice's last red completedTs.
101
+ * No global wave barrier — different slices may freely interleave their
102
+ * RED/GREEN/REFACTOR phases.
103
+ *
104
+ * Note: this is intentionally weaker than `evaluateGlobalRedCheckpoint`
105
+ * because the W-02 measurement on hox showed ~6 minutes of barrier
106
+ * overhead when slices were already disjoint (file-overlap scheduler did
107
+ * the parallelism job). The per-slice rule retains the only invariant
108
+ * that mattered for correctness: no slice goes GREEN before its own
109
+ * RED is observed failing.
110
+ */
111
+ export declare function evaluatePerSliceRedBeforeGreen(slices: Map<string, DelegationEntry[]>): RedCheckpointResult;
69
112
  export declare function parseVerticalSliceCycle(body: string): ParsedSliceCycleResult;
70
113
  interface VerificationLadderResult {
71
114
  ok: boolean;
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { readDelegationLedger, readDelegationEvents } from "../delegation.js";
3
+ import { integrationCheckRequired, loadTddReadySlicePool, readDelegationLedger, readDelegationEvents, selectReadySlices } from "../delegation.js";
4
+ import { mergeParallelWaveDefinitions, parseParallelExecutionPlanWaves, parseWavePlanDirectory } from "../internal/plan-split-waves.js";
4
5
  import { evaluateInvestigationTrace, sectionBodyByName } from "./shared.js";
5
6
  const SLICE_SUMMARY_START = "<!-- auto-start: tdd-slice-summary -->";
6
7
  const SLICE_SUMMARY_END = "<!-- auto-end: tdd-slice-summary -->";
@@ -26,8 +27,17 @@ const SLICES_INDEX_END = "<!-- auto-end: slices-index -->";
26
27
  * via `## Slices Index`.
27
28
  */
28
29
  export async function lintTddStage(ctx) {
29
- const { projectRoot, discoveryMode, raw, absFile, sections, findings, parsedFrontmatter, worktreeExecutionMode } = ctx;
30
+ const { projectRoot, discoveryMode, raw, absFile, sections, findings, parsedFrontmatter, worktreeExecutionMode, legacyContinuation, tddCheckpointMode, integrationOverseerMode } = ctx;
30
31
  void parsedFrontmatter;
32
+ const artifactsDir = path.dirname(absFile);
33
+ const planPath = path.join(artifactsDir, "05-plan.md");
34
+ let planRaw = "";
35
+ try {
36
+ planRaw = await fs.readFile(planPath, "utf8");
37
+ }
38
+ catch {
39
+ planRaw = "";
40
+ }
31
41
  evaluateInvestigationTrace(ctx, "Watched-RED Proof");
32
42
  const delegationLedger = await readDelegationLedger(ctx.projectRoot);
33
43
  const activeRunEntries = delegationLedger.entries.filter((entry) => entry.stage === "tdd" && entry.runId === delegationLedger.runId);
@@ -134,21 +144,27 @@ export async function lintTddStage(ctx) {
134
144
  });
135
145
  }
136
146
  }
137
- // v6.12.0 Phase R — slice-documenter coverage is mandatory on every
138
- // TDD run regardless of discoveryMode. `discoveryMode` is now strictly
139
- // an early-stage knob (brainstorm/scope/design); TDD parallelism must
140
- // be uniform across lean/guided/deep so the controller cannot quietly
141
- // skip per-slice prose by picking a non-deep mode.
142
- void discoveryMode;
147
+ // v6.14.0 Phase 4 — slice-documenter coverage is mandatory only on
148
+ // `discoveryMode === "deep"` runs. lean/guided still emit the finding
149
+ // but as advisory (`required: false`) so the controller can choose to
150
+ // run a tighter inline-doc pass instead. The DOC role still exists;
151
+ // the linter just stops blocking the gate on lean/guided. Reference
152
+ // research report Section 4: "soften slice-documenter mandate".
143
153
  if (eventsActive) {
144
154
  const docResult = evaluateSliceDocumenterCoverage(slicesByEvents);
145
155
  if (docResult.missing.length > 0) {
156
+ const required = discoveryMode === "deep";
146
157
  findings.push({
147
158
  section: "tdd_slice_documenter_missing",
148
- required: true,
149
- rule: "Every TDD slice with a phase=green event must also carry a slice-documenter `phase=doc` event whose evidenceRefs reference `<artifacts-dir>/tdd-slices/S-<id>.md`. The requirement is independent of discoveryMode (v6.12.0 Phase R).",
159
+ required,
160
+ rule: required
161
+ ? "deep mode: every TDD slice with a phase=green event must also carry a slice-documenter `phase=doc` event whose evidenceRefs reference `<artifacts-dir>/tdd-slices/S-<id>.md`."
162
+ : "lean/guided modes (v6.14.0): the slice-documenter `phase=doc` event is advisory; controllers may use slice-implementer --finalize-doc inline instead. Required only for deep mode.",
150
163
  found: false,
151
- details: `Slices missing slice-documenter coverage: ${docResult.missing.join(", ")}. Dispatch slice-documenter --slice <id> --phase doc in parallel with slice-implementer --phase green for each slice.`
164
+ details: `Slices missing slice-documenter coverage: ${docResult.missing.join(", ")}. ` +
165
+ (required
166
+ ? "Dispatch slice-documenter --slice <id> --phase doc in parallel with slice-implementer --phase green for each slice."
167
+ : "Either dispatch slice-documenter --phase doc or call slice-implementer --finalize-doc inline at GREEN-completion.")
152
168
  });
153
169
  }
154
170
  }
@@ -169,23 +185,42 @@ export async function lintTddStage(ctx) {
169
185
  });
170
186
  }
171
187
  }
172
- // v6.12.0 Phase W — RED checkpoint enforcement. The wave protocol
173
- // requires ALL Phase A REDs to land before ANY Phase B GREEN starts.
174
- // Enforced per-wave: explicit `wave-plans/wave-NN.md` manifest if
175
- // present, otherwise implicit detection via contiguous red blocks
176
- // (size >= 2). Sequential per-slice runs (redgreen→refactor in a
177
- // tight loop) form size-1 implicit waves and are unaffected.
188
+ // v6.14.0 Phase 1 — RED checkpoint enforcement. The mode is selected
189
+ // by `flow-state.json::tddCheckpointMode`:
190
+ //
191
+ // - `per-slice` (default for new projects): enforce RED-before-GREEN
192
+ // per slice only. No global wave barrier; lanes run REDGREEN as
193
+ // soon as their dependsOn closes. Rule id:
194
+ // `tdd_slice_red_completed_before_green`.
195
+ // - `global-red` (auto-applied for legacyContinuation): enforce the
196
+ // v6.12 wave-batch barrier — every slice in a wave must complete
197
+ // phase=red before any slice in the same wave starts phase=green.
198
+ // Rule id: `tdd_red_checkpoint_violation` (legacy).
178
199
  if (eventsActive) {
179
- const waveManifest = await readWaveManifest(path.dirname(absFile));
180
- const checkpointResult = evaluateRedCheckpoint(slicesByEvents, waveManifest);
181
- if (!checkpointResult.ok) {
182
- findings.push({
183
- section: "tdd_red_checkpoint_violation",
184
- required: true,
185
- rule: "Wave Batch Mode (v6.12.0 Phase W): every slice in a wave must complete phase=red before any slice in the same wave starts phase=green. Detected: a phase=green completedTs precedes the last phase=red completedTs of the same wave.",
186
- found: false,
187
- details: checkpointResult.details
188
- });
200
+ if (tddCheckpointMode === "global-red") {
201
+ const waveManifest = await readMergedWaveManifestForCheckpoint(artifactsDir, planRaw);
202
+ const checkpointResult = evaluateGlobalRedCheckpoint(slicesByEvents, waveManifest);
203
+ if (!checkpointResult.ok) {
204
+ findings.push({
205
+ section: "tdd_red_checkpoint_violation",
206
+ required: true,
207
+ rule: "Wave Batch Mode (legacy global-red mode, v6.12.0 Phase W): every slice in a wave must complete phase=red before any slice in the same wave starts phase=green. Detected: a phase=green completedTs precedes the last phase=red completedTs of the same wave.",
208
+ found: false,
209
+ details: checkpointResult.details
210
+ });
211
+ }
212
+ }
213
+ else {
214
+ const perSliceResult = evaluatePerSliceRedBeforeGreen(slicesByEvents);
215
+ if (!perSliceResult.ok) {
216
+ findings.push({
217
+ section: "tdd_slice_red_completed_before_green",
218
+ required: true,
219
+ rule: "Stream-style TDD (v6.14.0): each slice's phase=green completedTs must be >= the same slice's last phase=red completedTs. No global wave barrier — lanes run independently.",
220
+ found: false,
221
+ details: perSliceResult.details
222
+ });
223
+ }
189
224
  }
190
225
  }
191
226
  // v6.12.0 Phase L — advisory backslide detection. When a cutover is
@@ -203,52 +238,69 @@ export async function lintTddStage(ctx) {
203
238
  }
204
239
  const { events: jsonlEvents, fanInAudits } = await readDelegationEvents(projectRoot);
205
240
  const runEvents = jsonlEvents.filter((e) => e.runId === delegationLedger.runId);
241
+ if (eventsActive && planRaw.length > 0) {
242
+ const ignoredWave = await evaluateWavePlanDispatchIgnored({
243
+ artifactsDir,
244
+ planMarkdown: planRaw,
245
+ runEvents,
246
+ runId: delegationLedger.runId,
247
+ slices: slicesByEvents,
248
+ legacyContinuation
249
+ });
250
+ if (ignoredWave) {
251
+ findings.push(ignoredWave);
252
+ }
253
+ }
206
254
  if (eventsActive && worktreeExecutionMode === "worktree-first") {
207
255
  const terminalPhases = new Set([
208
- "green",
209
256
  "refactor",
210
257
  "refactor-deferred",
211
258
  "resolve-conflict"
212
259
  ]);
213
- const missingClaim = new Set();
260
+ const missingGreenMeta = new Set();
214
261
  for (const ev of runEvents) {
215
262
  if (ev.stage !== "tdd" || ev.agent !== "slice-implementer")
216
263
  continue;
217
- if (ev.status !== "completed" && ev.status !== "failed")
264
+ if (ev.status !== "completed" || ev.phase !== "green")
218
265
  continue;
219
- if (!ev.phase || !terminalPhases.has(ev.phase))
266
+ if (typeof ev.sliceId !== "string")
220
267
  continue;
221
268
  const tok = ev.claimToken?.trim() ?? "";
222
- if (tok.length === 0 && typeof ev.sliceId === "string") {
223
- missingClaim.add(ev.sliceId);
269
+ const lane = ev.ownerLaneId?.trim() ?? "";
270
+ const lease = ev.leasedUntil?.trim() ?? "";
271
+ if (tok.length === 0 || lane.length === 0 || lease.length === 0) {
272
+ missingGreenMeta.add(ev.sliceId);
224
273
  }
225
274
  }
226
- if (missingClaim.size > 0) {
275
+ if (missingGreenMeta.size > 0) {
227
276
  findings.push({
228
- section: "tdd_slice_claim_token_missing",
277
+ section: "tdd_slice_lane_metadata_missing",
229
278
  required: true,
230
- rule: "Worktree-first: terminal slice-implementer rows must echo --claim-token. Remediation: pass the same --claim-token used on the scheduled row for every completed/failed terminal phase.",
279
+ rule: "Worktree-first: every completed slice-implementer phase=green row must record claimToken, ownerLaneId (--lane-id), and leasedUntil (--lease-until).",
231
280
  found: false,
232
- details: `Slices missing claim token on terminal rows: ${[...missingClaim].join(", ")}.`
281
+ details: `Slices missing one or more lane fields on GREEN: ${[...missingGreenMeta].sort().join(", ")}. Remediation: include --claim-token, --lane-id, and --lease-until on every slice-implementer --phase green delegation-record write (schedule through completion); the hook fails fast with dispatch_lane_metadata_missing when they are omitted.`
233
282
  });
234
283
  }
235
- const missingLane = new Set();
284
+ const missingClaim = new Set();
236
285
  for (const ev of runEvents) {
237
286
  if (ev.stage !== "tdd" || ev.agent !== "slice-implementer")
238
287
  continue;
239
- if (ev.status !== "completed" || ev.phase !== "green")
288
+ if (ev.status !== "completed" && ev.status !== "failed")
240
289
  continue;
241
- if (!ev.ownerLaneId?.trim() && typeof ev.sliceId === "string") {
242
- missingLane.add(ev.sliceId);
290
+ if (!ev.phase || !terminalPhases.has(ev.phase))
291
+ continue;
292
+ const tok = ev.claimToken?.trim() ?? "";
293
+ if (tok.length === 0 && typeof ev.sliceId === "string") {
294
+ missingClaim.add(ev.sliceId);
243
295
  }
244
296
  }
245
- if (missingLane.size > 0) {
297
+ if (missingClaim.size > 0) {
246
298
  findings.push({
247
- section: "tdd_slice_worktree_metadata_missing",
299
+ section: "tdd_slice_claim_token_missing",
248
300
  required: true,
249
- rule: "Worktree-first: completed GREEN rows must record --lane-id (ownerLaneId) for the lane worktree.",
301
+ rule: "Worktree-first: terminal slice-implementer rows (refactor / refactor-deferred / resolve-conflict) must echo --claim-token. Remediation: pass the same --claim-token used on the scheduled row for every completed/failed terminal phase.",
250
302
  found: false,
251
- details: `Slices missing ownerLaneId on GREEN completion: ${[...missingLane].join(", ")}.`
303
+ details: `Slices missing claim token on non-GREEN terminal rows: ${[...missingClaim].join(", ")}.`
252
304
  });
253
305
  }
254
306
  const conflictSlices = [
@@ -338,7 +390,6 @@ export async function lintTddStage(ctx) {
338
390
  const completedSliceImplementers = activeRunEntries.filter((entry) => entry.agent === "slice-implementer" && entry.status === "completed");
339
391
  const fanOutDetected = completedSliceImplementers.length > 1;
340
392
  if (fanOutDetected) {
341
- const artifactsDir = path.dirname(absFile);
342
393
  const cohesionContractMarkdownPath = path.join(artifactsDir, "cohesion-contract.md");
343
394
  const cohesionContractJsonPath = path.join(artifactsDir, "cohesion-contract.json");
344
395
  let cohesionContractFound = true;
@@ -392,15 +443,41 @@ export async function lintTddStage(ctx) {
392
443
  const overseerStatusInArtifact = /\bintegration-overseer\b[\s\S]{0,200}\b(?:PASS_WITH_GAPS|PASS)\b/iu.test(raw);
393
444
  const integrationOverseerFound = completedOverseerRows.length > 0 &&
394
445
  (overseerStatusInEvidence || overseerStatusInArtifact);
446
+ // v6.14.0 Phase 3 — conditional integration-overseer dispatch. When
447
+ // `integrationOverseerMode === "conditional"` and
448
+ // `integrationCheckRequired()` returns required=false, the gate is
449
+ // soft (advisory) and an audit-only finding is emitted so the
450
+ // controller can record the deliberate skip in artifacts.
451
+ let overseerVerdict = null;
452
+ let overseerRequired = true;
453
+ if (integrationOverseerMode === "conditional") {
454
+ const eventsForVerdict = runEvents.length > 0 ? runEvents : [];
455
+ const auditsForVerdict = fanInAudits.filter((a) => a.runId === delegationLedger.runId);
456
+ overseerVerdict = integrationCheckRequired(eventsForVerdict, auditsForVerdict);
457
+ overseerRequired = overseerVerdict.required;
458
+ if (!overseerVerdict.required) {
459
+ findings.push({
460
+ section: "tdd_integration_overseer_skipped_by_disjoint_paths",
461
+ required: false,
462
+ rule: "v6.14.0 conditional integration-overseer mode: the heuristic returned `required: false` (disjoint claimedPaths, no high-risk slices, no fan-in conflicts). The controller may skip dispatching `integration-overseer` and emit a `cclaw_integration_overseer_skipped` audit row instead.",
463
+ found: true,
464
+ details: `integrationCheckRequired() reasons: ${overseerVerdict.reasons.join(", ")}. Skip is safe — record an audit row via delegation events for traceability.`
465
+ });
466
+ }
467
+ }
395
468
  findings.push({
396
469
  section: "tdd.integration_overseer_missing",
397
- required: true,
398
- rule: "When fan-out is detected, require completed `integration-overseer` evidence with PASS or PASS_WITH_GAPS.",
470
+ required: overseerRequired,
471
+ rule: overseerRequired
472
+ ? "When fan-out is detected, require completed `integration-overseer` evidence with PASS or PASS_WITH_GAPS."
473
+ : "v6.14.0 conditional integration-overseer mode: integration-overseer dispatch is advisory because `integrationCheckRequired()` returned required=false. Run it anyway if the run touches new boundaries.",
399
474
  found: integrationOverseerFound,
400
475
  details: integrationOverseerFound
401
476
  ? "integration-overseer completion recorded with PASS/PASS_WITH_GAPS evidence."
402
477
  : completedOverseerRows.length === 0
403
- ? "Fan-out detected but no completed integration-overseer delegation row exists for active run."
478
+ ? overseerRequired
479
+ ? "Fan-out detected but no completed integration-overseer delegation row exists for active run."
480
+ : "Fan-out detected; integration-overseer not dispatched (conditional mode skipped on disjoint paths). Audit-only."
404
481
  : "integration-overseer completion exists, but PASS/PASS_WITH_GAPS evidence is missing in delegation evidenceRefs and artifact text."
405
482
  });
406
483
  }
@@ -418,7 +495,6 @@ export async function lintTddStage(ctx) {
418
495
  // Phase S — sharded slice files. Validate per-slice file presence
419
496
  // and required headings. `tdd-slices/` is optional; missing folder
420
497
  // simply means main-only mode (legacy fallback).
421
- const artifactsDir = path.dirname(absFile);
422
498
  const slicesDir = path.join(artifactsDir, "tdd-slices");
423
499
  const sliceFiles = await listSliceFiles(slicesDir);
424
500
  for (const sliceFile of sliceFiles) {
@@ -607,19 +683,43 @@ export function evaluateEventsSliceCycle(slices) {
607
683
  });
608
684
  continue;
609
685
  }
610
- if (refactors.length === 0) {
686
+ // v6.14.0 refactorOutcome on phase=green satisfies REFACTOR coverage
687
+ // without a separate phase=refactor / phase=refactor-deferred row.
688
+ // - mode: "inline" → REFACTOR ran inline as part of GREEN.
689
+ // - mode: "deferred" → rationale required (carried in evidenceRefs[0]
690
+ // by the hook helper so legacy linters keep working).
691
+ const greenWithOutcome = greens.find((entry) => entry.refactorOutcome &&
692
+ (entry.refactorOutcome.mode === "inline" || entry.refactorOutcome.mode === "deferred"));
693
+ if (refactors.length === 0 && !greenWithOutcome) {
611
694
  errors.push(`${sliceId}: phase=refactor or phase=refactor-deferred event missing.`);
612
695
  findings.push({
613
696
  section: `tdd_slice_refactor_missing:${sliceId}`,
614
697
  required: true,
615
- rule: "Each TDD slice must close with a `phase=refactor` event or a `phase=refactor-deferred` event whose evidenceRefs / refactorRationale captures why refactor was deferred.",
698
+ rule: "Each TDD slice must close with a `phase=refactor` event, a `phase=refactor-deferred` event whose evidenceRefs / refactorRationale captures why refactor was deferred, OR a `phase=green` event carrying `refactorOutcome` (v6.14.0).",
699
+ found: false,
700
+ details: `${sliceId}: no phase=refactor / phase=refactor-deferred event and no refactorOutcome on phase=green.`
701
+ });
702
+ continue;
703
+ }
704
+ if (greenWithOutcome &&
705
+ greenWithOutcome.refactorOutcome?.mode === "deferred" &&
706
+ !greenWithOutcome.refactorOutcome.rationale &&
707
+ !(Array.isArray(greenWithOutcome.evidenceRefs) &&
708
+ greenWithOutcome.evidenceRefs.some((ref) => typeof ref === "string" && ref.trim().length > 0))) {
709
+ errors.push(`${sliceId}: phase=green refactorOutcome=deferred missing rationale.`);
710
+ findings.push({
711
+ section: `tdd_slice_refactor_missing:${sliceId}`,
712
+ required: true,
713
+ rule: "phase=green refactorOutcome=deferred requires a rationale (via --refactor-rationale or --evidence-ref).",
616
714
  found: false,
617
- details: `${sliceId}: no phase=refactor or phase=refactor-deferred event.`
715
+ details: `${sliceId}: phase=green refactorOutcome.mode=deferred recorded without rationale.`
618
716
  });
619
717
  continue;
620
718
  }
621
719
  const deferred = refactors.find((entry) => entry.phase === "refactor-deferred");
622
- if (deferred && refactors.every((entry) => entry.phase === "refactor-deferred")) {
720
+ if (refactors.length > 0 &&
721
+ deferred &&
722
+ refactors.every((entry) => entry.phase === "refactor-deferred")) {
623
723
  const refs = Array.isArray(deferred.evidenceRefs) ? deferred.evidenceRefs : [];
624
724
  const hasRationale = refs.some((ref) => typeof ref === "string" && ref.trim().length > 0);
625
725
  if (!hasRationale) {
@@ -694,20 +794,122 @@ export function evaluateSliceImplementerCoverage(slices) {
694
794
  }
695
795
  return { missing };
696
796
  }
797
+ async function readMergedWaveManifestForCheckpoint(artifactsDir, planMarkdown) {
798
+ try {
799
+ const merged = mergeParallelWaveDefinitions(parseParallelExecutionPlanWaves(planMarkdown), await parseWavePlanDirectory(artifactsDir));
800
+ if (merged.length === 0)
801
+ return null;
802
+ const map = new Map();
803
+ for (const w of merged) {
804
+ map.set(w.waveId, new Set(w.members.map((m) => m.sliceId)));
805
+ }
806
+ return map.size > 0 ? map : null;
807
+ }
808
+ catch {
809
+ return null;
810
+ }
811
+ }
812
+ function sliceRefactorTerminal(sliceId, slices) {
813
+ const rows = slices.get(sliceId);
814
+ if (!rows)
815
+ return false;
816
+ return rows.some((e) => e.agent === "slice-implementer" &&
817
+ (e.phase === "refactor" || e.phase === "refactor-deferred") &&
818
+ (e.status === "completed" || e.status === "failed"));
819
+ }
820
+ /**
821
+ * v6.13.1 — detect single-slice dispatch when the merged wave plan
822
+ * requires parallel ready slice-implementer fan-out.
823
+ */
824
+ export async function evaluateWavePlanDispatchIgnored(params) {
825
+ let merged;
826
+ try {
827
+ merged = mergeParallelWaveDefinitions(parseParallelExecutionPlanWaves(params.planMarkdown), await parseWavePlanDirectory(params.artifactsDir));
828
+ }
829
+ catch {
830
+ return null;
831
+ }
832
+ if (merged.length === 0)
833
+ return null;
834
+ let pool;
835
+ try {
836
+ pool = await loadTddReadySlicePool(params.planMarkdown, params.artifactsDir, {
837
+ legacyParallelDefaultSerial: params.legacyContinuation
838
+ });
839
+ }
840
+ catch {
841
+ return null;
842
+ }
843
+ if (pool.length === 0)
844
+ return null;
845
+ const completedUnitIds = new Set();
846
+ for (const u of pool) {
847
+ if (sliceRefactorTerminal(u.sliceId, params.slices)) {
848
+ completedUnitIds.add(u.unitId);
849
+ }
850
+ }
851
+ const scoped = params.runEvents.filter((e) => e.runId === params.runId);
852
+ const tail = scoped.slice(-20);
853
+ const implInTail = new Set();
854
+ for (const e of tail) {
855
+ if (e.agent === "slice-implementer" && typeof e.sliceId === "string" && e.sliceId.length > 0) {
856
+ implInTail.add(e.sliceId);
857
+ }
858
+ }
859
+ if (implInTail.size !== 1)
860
+ return null;
861
+ for (const wave of merged) {
862
+ const waveSliceSet = new Set(wave.members.map((m) => m.sliceId));
863
+ const wavePool = pool.filter((u) => waveSliceSet.has(u.sliceId));
864
+ if (wavePool.length < 2)
865
+ continue;
866
+ const waveIncomplete = wave.members.some((m) => !sliceRefactorTerminal(m.sliceId, params.slices));
867
+ if (!waveIncomplete)
868
+ continue;
869
+ const ready = selectReadySlices(wavePool, {
870
+ cap: Math.max(32, wavePool.length),
871
+ completedUnitIds,
872
+ activePathHolders: [],
873
+ legacyContinuation: params.legacyContinuation
874
+ });
875
+ if (ready.length < 2)
876
+ continue;
877
+ const only = [...implInTail][0];
878
+ const missed = ready.map((r) => r.sliceId).filter((s) => s !== only);
879
+ if (missed.length === 0)
880
+ continue;
881
+ return {
882
+ section: "tdd_wave_plan_ignored",
883
+ required: true,
884
+ rule: "When the Parallel Execution Plan (or wave-plans/) defines an open wave with two or more ready parallelizable slices, the controller must fan out slice-implementer work for each ready slice instead of serializing to one slice only.",
885
+ found: false,
886
+ details: `Wave ${wave.waveId}: scheduler-ready members ${ready.map((r) => r.sliceId).join(", ")}; last 20 delegation events show slice-implementer only for ${only}. Missed parallel dispatch: ${missed.join(", ")}. Remediation: load \`05-plan.md\` (Parallel Execution Plan) and \`wave-plans/\` before routing, launch the wave (AskQuestion only when waveCount>=2 and single-slice is a real alternative), then dispatch GREEN+DOC for every ready slice with mandatory worktree-first flags on GREEN.`
887
+ };
888
+ }
889
+ return null;
890
+ }
697
891
  /**
698
- * v6.12.0 Phase W — RED checkpoint enforcement. The wave protocol
699
- * requires ALL Phase A REDs to land before ANY Phase B GREEN starts.
700
- * The rule is enforced on a per-wave basis, where a wave is defined by
701
- * `<artifacts-dir>/wave-plans/wave-NN.md` files (when present) listing
702
- * slice ids. When no wave manifest exists, the linter falls back to a
703
- * conservative implicit detection: a wave is a contiguous run of
704
- * `phase=red` events with no other-phase events between them; the rule
705
- * fires only when the implicit wave has 2+ members.
892
+ * v6.12.0 Phase W (legacy `global-red` mode) — RED checkpoint enforcement.
893
+ * The wave protocol requires ALL Phase A REDs to land before ANY Phase B
894
+ * GREEN starts. The rule is enforced on a per-wave basis, where a wave is
895
+ * defined by the managed `## Parallel Execution Plan` block in
896
+ * `05-plan.md` and/or `<artifacts-dir>/wave-plans/wave-NN.md` files. When
897
+ * no wave manifest exists, the linter falls back to a conservative
898
+ * implicit detection: a wave is a contiguous run of `phase=red` events
899
+ * with no other-phase events between them; the rule fires only when the
900
+ * implicit wave has 2+ members.
901
+ *
902
+ * v6.14.0: this function powers the `global-red` checkpoint mode. New
903
+ * projects default to `per-slice` mode (see
904
+ * `evaluatePerSliceRedBeforeGreen`); `legacyContinuation: true` projects
905
+ * auto-keep this behavior. Exported under both `evaluateGlobalRedCheckpoint`
906
+ * (canonical name) and `evaluateRedCheckpoint` (back-compat alias for
907
+ * existing tests/consumers).
706
908
  *
707
909
  * @param waveMembers Optional explicit wave manifest. Map key is wave
708
910
  * name (e.g. `"W-01"`); value is the set of slice ids in that wave.
709
911
  */
710
- export function evaluateRedCheckpoint(slices, waveMembers = null) {
912
+ export function evaluateGlobalRedCheckpoint(slices, waveMembers = null) {
711
913
  const events = [];
712
914
  for (const [sliceId, rows] of slices.entries()) {
713
915
  for (const entry of rows) {
@@ -784,40 +986,61 @@ export function evaluateRedCheckpoint(slices, waveMembers = null) {
784
986
  };
785
987
  }
786
988
  /**
787
- * Read explicit wave manifest from `<artifacts-dir>/wave-plans/wave-NN.md`
788
- * files. Returns a map from wave name to the set of slice ids it
789
- * contains. Slice ids are extracted via `S-<digits>` regex matches in
790
- * each wave file. Returns null when no wave files exist or all are
791
- * empty/unparseable.
989
+ * Back-compat alias for `evaluateGlobalRedCheckpoint` (v6.12.0 Phase W
990
+ * behavior). Existing tests/consumers can keep importing
991
+ * `evaluateRedCheckpoint`. The v6.14.0 stream-style mode uses
992
+ * `evaluatePerSliceRedBeforeGreen` instead.
792
993
  */
793
- async function readWaveManifest(artifactsDir) {
794
- const wavePlansDir = path.join(artifactsDir, "wave-plans");
795
- let entries = [];
796
- try {
797
- entries = await fs.readdir(wavePlansDir);
798
- }
799
- catch {
800
- return null;
801
- }
802
- const waves = new Map();
803
- for (const name of entries) {
804
- const match = /^wave-(\d+)\.md$/u.exec(name);
805
- if (!match)
994
+ export const evaluateRedCheckpoint = evaluateGlobalRedCheckpoint;
995
+ /**
996
+ * v6.14.0 — per-slice RED-before-GREEN enforcement (default mode).
997
+ *
998
+ * For each slice with both phase=red and phase=green completed events,
999
+ * fail if any green completedTs precedes the slice's last red completedTs.
1000
+ * No global wave barrier — different slices may freely interleave their
1001
+ * RED/GREEN/REFACTOR phases.
1002
+ *
1003
+ * Note: this is intentionally weaker than `evaluateGlobalRedCheckpoint`
1004
+ * because the W-02 measurement on hox showed ~6 minutes of barrier
1005
+ * overhead when slices were already disjoint (file-overlap scheduler did
1006
+ * the parallelism job). The per-slice rule retains the only invariant
1007
+ * that mattered for correctness: no slice goes GREEN before its own
1008
+ * RED is observed failing.
1009
+ */
1010
+ export function evaluatePerSliceRedBeforeGreen(slices) {
1011
+ const violations = [];
1012
+ for (const [sliceId, rows] of slices.entries()) {
1013
+ const reds = rows.filter((entry) => entry.phase === "red");
1014
+ const greens = rows.filter((entry) => entry.phase === "green");
1015
+ if (reds.length === 0 || greens.length === 0)
806
1016
  continue;
807
- const wavePath = path.join(wavePlansDir, name);
808
- let body = "";
809
- try {
810
- body = await fs.readFile(wavePath, "utf8");
811
- }
812
- catch {
1017
+ const redTs = reds
1018
+ .map((entry) => entry.completedTs ?? entry.endTs ?? entry.ts ?? "")
1019
+ .filter((ts) => ts.length > 0)
1020
+ .sort();
1021
+ const greenTs = greens
1022
+ .map((entry) => entry.completedTs ?? entry.endTs ?? entry.ts ?? "")
1023
+ .filter((ts) => ts.length > 0)
1024
+ .sort();
1025
+ if (redTs.length === 0 || greenTs.length === 0)
813
1026
  continue;
1027
+ const lastRed = redTs[redTs.length - 1];
1028
+ const earliestGreen = greenTs[0];
1029
+ if (earliestGreen < lastRed) {
1030
+ violations.push(`${sliceId}: phase=green completedTs (${earliestGreen}) precedes the slice's last phase=red completedTs (${lastRed})`);
814
1031
  }
815
- const ids = extractSliceIdsFromBody(body);
816
- if (ids.length === 0)
817
- continue;
818
- waves.set(`W-${match[1]}`, new Set(ids));
819
1032
  }
820
- return waves.size > 0 ? waves : null;
1033
+ if (violations.length === 0) {
1034
+ return {
1035
+ ok: true,
1036
+ details: `Per-slice RED-before-GREEN holds: ${slices.size} slice(s) checked.`
1037
+ };
1038
+ }
1039
+ return {
1040
+ ok: false,
1041
+ details: `Per-slice RED-before-GREEN violation: ${violations.join("; ")}. ` +
1042
+ "Stream-style TDD requires each slice's RED to land before its own GREEN, but cross-lane interleaving is allowed."
1043
+ };
821
1044
  }
822
1045
  const LEGACY_PER_SLICE_SECTIONS = [
823
1046
  "Test Discovery",