cclaw-cli 6.14.1 → 6.14.2

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.
@@ -654,4 +654,19 @@ export interface StageLintContext {
654
654
  * wave.
655
655
  */
656
656
  integrationOverseerMode: "conditional" | "always";
657
+ /**
658
+ * v6.14.2 — historical cutover marker (`flow-state.json::tddCutoverSliceId`).
659
+ * Empty string when not set. Used by the `tdd_cutover_misread_warning`
660
+ * advisory rule to detect controllers that mistake the historical
661
+ * marker for an active-slice pointer.
662
+ */
663
+ tddCutoverSliceId: string;
664
+ /**
665
+ * v6.14.2 — worktree-first boundary
666
+ * (`flow-state.json::tddWorktreeCutoverSliceId`). Empty string when
667
+ * not set. Linters that fire on closed worktree slices use this
668
+ * boundary (with a fallback to `tddCutoverSliceId`) to exempt
669
+ * pre-flip closed slices on `legacyContinuation: true` projects.
670
+ */
671
+ tddWorktreeCutoverSliceId: string;
657
672
  }
@@ -27,8 +27,17 @@ const SLICES_INDEX_END = "<!-- auto-end: slices-index -->";
27
27
  * via `## Slices Index`.
28
28
  */
29
29
  export async function lintTddStage(ctx) {
30
- const { projectRoot, discoveryMode, raw, absFile, sections, findings, parsedFrontmatter, worktreeExecutionMode, legacyContinuation, tddCheckpointMode, integrationOverseerMode } = ctx;
30
+ const { projectRoot, discoveryMode, raw, absFile, sections, findings, parsedFrontmatter, worktreeExecutionMode, legacyContinuation, tddCheckpointMode, integrationOverseerMode, tddCutoverSliceId, tddWorktreeCutoverSliceId } = ctx;
31
31
  void parsedFrontmatter;
32
+ // v6.14.2 — boundary slice for the "any-metadata" exemption applied
33
+ // to worktree-first findings. Falls back to the v6.12 cutover marker
34
+ // when the boundary is absent (sync hasn't run, or `cclaw-cli sync`
35
+ // detected no auto-detectable boundary). The exemption only kicks in
36
+ // for `legacyContinuation: true` projects — fresh worktree-first
37
+ // projects continue to enforce all three rules globally.
38
+ const worktreeCutoverBoundary = legacyContinuation
39
+ ? parseSliceNumber(tddWorktreeCutoverSliceId || tddCutoverSliceId || "")
40
+ : null;
32
41
  const artifactsDir = path.dirname(absFile);
33
42
  const planPath = path.join(artifactsDir, "05-plan.md");
34
43
  let planRaw = "";
@@ -236,6 +245,25 @@ export async function lintTddStage(ctx) {
236
245
  if (cutoverFinding) {
237
246
  findings.push(cutoverFinding);
238
247
  }
248
+ // v6.14.2 Fix 2 — advisory cutover-misread detection. Fires when the
249
+ // active run scheduled NEW work for the slice id stored in
250
+ // `tddCutoverSliceId` AND that slice has already closed (terminal
251
+ // refactor* row recorded for the same id, possibly under a prior
252
+ // run). This is the "controller mistook the historical marker for an
253
+ // active-slice pointer" pattern observed in hox W-03/S-17. Advisory
254
+ // only — clears as soon as the controller pivots to a different
255
+ // slice, and never blocks stage-complete.
256
+ if (tddCutoverSliceId) {
257
+ const misreadFinding = evaluateCutoverMisread({
258
+ projectRoot,
259
+ tddCutoverSliceId,
260
+ activeRunEntries,
261
+ ledgerEntries: delegationLedger.entries
262
+ });
263
+ if (misreadFinding) {
264
+ findings.push(misreadFinding);
265
+ }
266
+ }
239
267
  const { events: jsonlEvents, fanInAudits } = await readDelegationEvents(projectRoot);
240
268
  const runEvents = jsonlEvents.filter((e) => e.runId === delegationLedger.runId);
241
269
  if (eventsActive && planRaw.length > 0) {
@@ -257,7 +285,32 @@ export async function lintTddStage(ctx) {
257
285
  "refactor-deferred",
258
286
  "resolve-conflict"
259
287
  ]);
288
+ // v6.14.2 — under `legacyContinuation: true` AND a stamped
289
+ // boundary, exempt closed slices that NEVER recorded ANY of the
290
+ // three worktree-first metadata fields. This is the "all-or-
291
+ // nothing legacy" rule from v6.14.2 Fix 3: partial-metadata
292
+ // slices stay flagged (a real bug), but slices that pre-date
293
+ // the worktree-first flip get amnesty.
294
+ const sliceWorktreeMetaState = computeSliceWorktreeMetaState(runEvents);
295
+ const isExemptLegacySlice = (sliceId) => {
296
+ if (!legacyContinuation)
297
+ return false;
298
+ if (worktreeCutoverBoundary === null)
299
+ return false;
300
+ const n = parseSliceNumber(sliceId);
301
+ if (n === null)
302
+ return false;
303
+ if (n > worktreeCutoverBoundary)
304
+ return false;
305
+ const meta = sliceWorktreeMetaState.get(sliceId);
306
+ if (!meta)
307
+ return true; // no slice-implementer rows at all → fully legacy
308
+ // Exempt only when the slice carries ZERO worktree fields across
309
+ // all rows. Partial metadata stays flagged.
310
+ return !meta.anyMeta;
311
+ };
260
312
  const missingGreenMeta = new Set();
313
+ const exemptedGreenMeta = new Set();
261
314
  for (const ev of runEvents) {
262
315
  if (ev.stage !== "tdd" || ev.agent !== "slice-implementer")
263
316
  continue;
@@ -269,7 +322,12 @@ export async function lintTddStage(ctx) {
269
322
  const lane = ev.ownerLaneId?.trim() ?? "";
270
323
  const lease = ev.leasedUntil?.trim() ?? "";
271
324
  if (tok.length === 0 || lane.length === 0 || lease.length === 0) {
272
- missingGreenMeta.add(ev.sliceId);
325
+ if (isExemptLegacySlice(ev.sliceId)) {
326
+ exemptedGreenMeta.add(ev.sliceId);
327
+ }
328
+ else {
329
+ missingGreenMeta.add(ev.sliceId);
330
+ }
273
331
  }
274
332
  }
275
333
  if (missingGreenMeta.size > 0) {
@@ -281,7 +339,17 @@ export async function lintTddStage(ctx) {
281
339
  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.`
282
340
  });
283
341
  }
342
+ else if (exemptedGreenMeta.size > 0) {
343
+ findings.push({
344
+ section: "tdd_slice_lane_metadata_legacy_exempt",
345
+ required: false,
346
+ rule: "v6.14.2 legacyContinuation amnesty: closed slices ≤ tddWorktreeCutoverSliceId whose slice-implementer rows lack ALL worktree-first metadata are exempt from `tdd_slice_lane_metadata_missing`.",
347
+ found: true,
348
+ details: `Legacy-exempt slices (no claimToken/ownerLaneId/leasedUntil recorded; all closed before worktree-first flip): ${[...exemptedGreenMeta].sort().join(", ")}.`
349
+ });
350
+ }
284
351
  const missingClaim = new Set();
352
+ const exemptedClaim = new Set();
285
353
  for (const ev of runEvents) {
286
354
  if (ev.stage !== "tdd" || ev.agent !== "slice-implementer")
287
355
  continue;
@@ -291,7 +359,12 @@ export async function lintTddStage(ctx) {
291
359
  continue;
292
360
  const tok = ev.claimToken?.trim() ?? "";
293
361
  if (tok.length === 0 && typeof ev.sliceId === "string") {
294
- missingClaim.add(ev.sliceId);
362
+ if (isExemptLegacySlice(ev.sliceId)) {
363
+ exemptedClaim.add(ev.sliceId);
364
+ }
365
+ else {
366
+ missingClaim.add(ev.sliceId);
367
+ }
295
368
  }
296
369
  }
297
370
  if (missingClaim.size > 0) {
@@ -303,6 +376,15 @@ export async function lintTddStage(ctx) {
303
376
  details: `Slices missing claim token on non-GREEN terminal rows: ${[...missingClaim].join(", ")}.`
304
377
  });
305
378
  }
379
+ else if (exemptedClaim.size > 0) {
380
+ findings.push({
381
+ section: "tdd_slice_claim_token_legacy_exempt",
382
+ required: false,
383
+ rule: "v6.14.2 legacyContinuation amnesty: closed pre-cutover slices without claim tokens on terminal rows are exempt from `tdd_slice_claim_token_missing`.",
384
+ found: true,
385
+ details: `Legacy-exempt slices: ${[...exemptedClaim].sort().join(", ")}.`
386
+ });
387
+ }
306
388
  const conflictSlices = [
307
389
  ...new Set([
308
390
  ...runEvents
@@ -327,6 +409,12 @@ export async function lintTddStage(ctx) {
327
409
  }
328
410
  const now = Date.now();
329
411
  const leaseStale = new Set();
412
+ const leaseStaleExempted = new Set();
413
+ // v6.14.2 — also exempt slices whose lease has expired but the
414
+ // slice was already closed (terminal row recorded) before the
415
+ // expiry. The reclaim audit row was just never written —
416
+ // bookkeeping advisory, not a blocker.
417
+ const closedBeforeLeaseExpiry = computeClosedBeforeLeaseExpiry(runEvents);
330
418
  for (const ev of runEvents) {
331
419
  if (typeof ev.leasedUntil !== "string")
332
420
  continue;
@@ -335,8 +423,18 @@ export async function lintTddStage(ctx) {
335
423
  continue;
336
424
  if (ev.leaseState === "reclaimed" || ev.leaseState === "released")
337
425
  continue;
338
- if (typeof ev.sliceId === "string")
339
- leaseStale.add(ev.sliceId);
426
+ if (typeof ev.sliceId !== "string")
427
+ continue;
428
+ const sliceId = ev.sliceId;
429
+ if (isExemptLegacySlice(sliceId)) {
430
+ leaseStaleExempted.add(sliceId);
431
+ continue;
432
+ }
433
+ if (closedBeforeLeaseExpiry.has(sliceId)) {
434
+ leaseStaleExempted.add(sliceId);
435
+ continue;
436
+ }
437
+ leaseStale.add(sliceId);
340
438
  }
341
439
  if (leaseStale.size > 0) {
342
440
  findings.push({
@@ -347,6 +445,15 @@ export async function lintTddStage(ctx) {
347
445
  details: `Expired leases not reclaimed for slice(s): ${[...leaseStale].join(", ")}.`
348
446
  });
349
447
  }
448
+ else if (leaseStaleExempted.size > 0) {
449
+ findings.push({
450
+ section: "tdd_lease_expired_legacy_exempt",
451
+ required: false,
452
+ rule: "v6.14.2 amnesty: expired leases are exempt when the slice closed before the expiry timestamp (reclaim audit just never recorded) OR when the slice predates the worktree-first cutover under legacyContinuation.",
453
+ found: true,
454
+ details: `Lease-expiry-exempt slices: ${[...leaseStaleExempted].sort().join(", ")}.`
455
+ });
456
+ }
350
457
  }
351
458
  const assertionBody = sectionBodyByName(sections, "Assertion Correctness Notes");
352
459
  if (assertionBody !== null) {
@@ -426,14 +533,27 @@ export async function lintTddStage(ctx) {
426
533
  cohesionContractFound = false;
427
534
  cohesionErrors.push("cohesion-contract.json is missing or invalid JSON.");
428
535
  }
536
+ // v6.14.2 — soften cohesion-contract under `legacyContinuation: true`.
537
+ // Pre-flip projects (hox) carry many closed implementer rows but
538
+ // never recorded cross-slice cohesion data because that schema
539
+ // didn't exist when the slices closed. Flag advisory + suggest the
540
+ // auto-stub helper instead of blocking the gate.
541
+ const cohesionRequired = legacyContinuation === true ? false : true;
542
+ const advisoryNote = cohesionRequired
543
+ ? cohesionErrors.join(" ")
544
+ : `${cohesionErrors.join(" ")} ` +
545
+ "Cohesion contract is advisory under legacyContinuation: true — emit a stub via " +
546
+ "`cclaw-cli internal cohesion-contract --stub` to silence this finding.";
429
547
  findings.push({
430
548
  section: "tdd.cohesion_contract_missing",
431
- required: true,
432
- rule: "When delegation ledger has >1 completed slice-implementer rows for active TDD run, require `.cclaw/artifacts/cohesion-contract.md` and parseable `.cclaw/artifacts/cohesion-contract.json` sidecar.",
549
+ required: cohesionRequired,
550
+ rule: cohesionRequired
551
+ ? "When delegation ledger has >1 completed slice-implementer rows for active TDD run, require `.cclaw/artifacts/cohesion-contract.md` and parseable `.cclaw/artifacts/cohesion-contract.json` sidecar."
552
+ : "v6.14.2 advisory under legacyContinuation: cohesion contract is recommended, not required. Use `cclaw-cli internal cohesion-contract --stub` to write a baseline.",
433
553
  found: cohesionContractFound,
434
554
  details: cohesionContractFound
435
555
  ? `Fan-out detected (${completedSliceImplementers.length} completed slice-implementer rows); cohesion contract markdown+JSON sidecar are present and parseable.`
436
- : cohesionErrors.join(" ")
556
+ : advisoryNote
437
557
  });
438
558
  const completedOverseerRows = activeRunEntries.filter((entry) => entry.agent === "integration-overseer" && entry.status === "completed");
439
559
  const overseerStatusInEvidence = completedOverseerRows.some((entry) => {
@@ -1213,6 +1333,134 @@ function pickEventTs(rows) {
1213
1333
  }
1214
1334
  return undefined;
1215
1335
  }
1336
+ /**
1337
+ * v6.14.2 — for each slice id appearing in `slice-implementer` rows of
1338
+ * the active run, record whether ANY row carried at least one of the
1339
+ * three worktree-first metadata fields (`claimToken`, `ownerLaneId`,
1340
+ * `leasedUntil`). Used by `isExemptLegacySlice` to enforce the "all-or-
1341
+ * nothing legacy" rule: only slices with NO worktree fields anywhere
1342
+ * in their rows qualify for the legacyContinuation amnesty.
1343
+ */
1344
+ function computeSliceWorktreeMetaState(events) {
1345
+ const out = new Map();
1346
+ for (const ev of events) {
1347
+ if (ev.stage !== "tdd" || ev.agent !== "slice-implementer")
1348
+ continue;
1349
+ if (typeof ev.sliceId !== "string")
1350
+ continue;
1351
+ const tok = ev.claimToken?.trim() ?? "";
1352
+ const lane = ev.ownerLaneId?.trim() ?? "";
1353
+ const lease = ev.leasedUntil?.trim() ?? "";
1354
+ const anyHere = tok.length > 0 || lane.length > 0 || lease.length > 0;
1355
+ const prev = out.get(ev.sliceId) ?? { anyMeta: false };
1356
+ out.set(ev.sliceId, { anyMeta: prev.anyMeta || anyHere });
1357
+ }
1358
+ return out;
1359
+ }
1360
+ /**
1361
+ * v6.14.2 — slices whose terminal `refactor` / `refactor-deferred` /
1362
+ * `resolve-conflict` row recorded a `completedTs` that PRECEDES the
1363
+ * latest `leasedUntil` for the same slice. The lease was never
1364
+ * reclaimed but the wave closed in time; the missing audit row is
1365
+ * advisory bookkeeping, not a correctness failure.
1366
+ */
1367
+ function computeClosedBeforeLeaseExpiry(events) {
1368
+ const terminalPhases = new Set([
1369
+ "refactor",
1370
+ "refactor-deferred",
1371
+ "resolve-conflict"
1372
+ ]);
1373
+ const lastLease = new Map();
1374
+ const earliestTerminal = new Map();
1375
+ for (const ev of events) {
1376
+ if (ev.stage !== "tdd" || ev.agent !== "slice-implementer")
1377
+ continue;
1378
+ if (typeof ev.sliceId !== "string")
1379
+ continue;
1380
+ if (typeof ev.leasedUntil === "string") {
1381
+ const until = Date.parse(ev.leasedUntil);
1382
+ if (Number.isFinite(until)) {
1383
+ const prev = lastLease.get(ev.sliceId);
1384
+ if (prev === undefined || until > prev) {
1385
+ lastLease.set(ev.sliceId, until);
1386
+ }
1387
+ }
1388
+ }
1389
+ if (ev.status === "completed" &&
1390
+ typeof ev.phase === "string" &&
1391
+ terminalPhases.has(ev.phase) &&
1392
+ typeof ev.completedTs === "string") {
1393
+ const ts = Date.parse(ev.completedTs);
1394
+ if (Number.isFinite(ts)) {
1395
+ const prev = earliestTerminal.get(ev.sliceId);
1396
+ if (prev === undefined || ts < prev) {
1397
+ earliestTerminal.set(ev.sliceId, ts);
1398
+ }
1399
+ }
1400
+ }
1401
+ }
1402
+ const out = new Set();
1403
+ for (const [sliceId, terminalTs] of earliestTerminal.entries()) {
1404
+ const leaseTs = lastLease.get(sliceId);
1405
+ if (leaseTs === undefined)
1406
+ continue;
1407
+ if (terminalTs < leaseTs) {
1408
+ out.add(sliceId);
1409
+ }
1410
+ }
1411
+ return out;
1412
+ }
1413
+ /**
1414
+ * v6.14.2 Fix 2 — advisory linter rule.
1415
+ *
1416
+ * Fires when:
1417
+ * (a) `tddCutoverSliceId` is set on the active flow state, AND
1418
+ * (b) the active run has a `scheduled` row whose `sliceId === tddCutoverSliceId`
1419
+ * AND `phase ∈ {red, green, doc}`, AND
1420
+ * (c) that slice already has a terminal `refactor` / `refactor-deferred` /
1421
+ * `resolve-conflict` event recorded for it (under any run) — i.e.
1422
+ * it's already closed.
1423
+ *
1424
+ * This is the diagnostic hox surfaced on S-17/W-03: the controller
1425
+ * read `tddCutoverSliceId: "S-11"` and treated it as the active slice
1426
+ * pointer, then dispatched new work for S-11 (already closed under
1427
+ * v6.12 markdown). Advisory — never blocks stage-complete.
1428
+ */
1429
+ function evaluateCutoverMisread(input) {
1430
+ const { tddCutoverSliceId, activeRunEntries, ledgerEntries } = input;
1431
+ const cutoverPhases = new Set(["red", "green", "doc"]);
1432
+ const newWork = activeRunEntries.find((entry) => entry.sliceId === tddCutoverSliceId &&
1433
+ typeof entry.phase === "string" &&
1434
+ cutoverPhases.has(entry.phase) &&
1435
+ // any schedule/launch/ack/completed for the cutover slice in this run
1436
+ (entry.status === "scheduled" ||
1437
+ entry.status === "launched" ||
1438
+ entry.status === "acknowledged" ||
1439
+ entry.status === "completed"));
1440
+ if (!newWork)
1441
+ return null;
1442
+ const terminalPhases = new Set([
1443
+ "refactor",
1444
+ "refactor-deferred",
1445
+ "resolve-conflict"
1446
+ ]);
1447
+ const closure = ledgerEntries.find((entry) => entry.sliceId === tddCutoverSliceId &&
1448
+ entry.status === "completed" &&
1449
+ typeof entry.phase === "string" &&
1450
+ terminalPhases.has(entry.phase));
1451
+ if (!closure)
1452
+ return null;
1453
+ const closedTs = closure.completedTs ?? closure.endTs ?? closure.ts ?? "(unknown)";
1454
+ const closedRunId = closure.runId ?? "(unknown-run)";
1455
+ return {
1456
+ section: "tdd_cutover_misread_warning",
1457
+ required: false,
1458
+ rule: "v6.14.2 Fix 2 advisory: `tddCutoverSliceId` is a HISTORICAL boundary set by sync, NOT a pointer to the active slice. The controller appears to have scheduled new work on the cutover slice id while that slice already closed.",
1459
+ found: false,
1460
+ details: `Active run scheduled new ${newWork.phase} work for slice ${tddCutoverSliceId} but that slice closed at ${closedTs} (run ${closedRunId}) — confirm this is intentional re-work, not a misread of tddCutoverSliceId. ` +
1461
+ "Use `cclaw-cli internal wave-status --json` to find the next ready slice."
1462
+ };
1463
+ }
1216
1464
  export function parseVerticalSliceCycle(body) {
1217
1465
  const tableLines = body.split("\n").filter((line) => /^\|/u.test(line));
1218
1466
  if (tableLines.length < 3) {
@@ -126,6 +126,8 @@ export async function lintArtifact(projectRoot, stage, track = "standard", optio
126
126
  let worktreeExecutionMode = "single-tree";
127
127
  let tddCheckpointMode = "per-slice";
128
128
  let integrationOverseerMode = "always";
129
+ let tddCutoverSliceId = "";
130
+ let tddWorktreeCutoverSliceId = "";
129
131
  try {
130
132
  const flowState = await readFlowState(projectRoot);
131
133
  const hint = flowState.interactionHints?.[stage];
@@ -140,6 +142,8 @@ export async function lintArtifact(projectRoot, stage, track = "standard", optio
140
142
  worktreeExecutionMode = effectiveWorktreeExecutionMode(flowState);
141
143
  tddCheckpointMode = effectiveTddCheckpointMode(flowState);
142
144
  integrationOverseerMode = effectiveIntegrationOverseerMode(flowState);
145
+ tddCutoverSliceId = flowState.tddCutoverSliceId ?? "";
146
+ tddWorktreeCutoverSliceId = flowState.tddWorktreeCutoverSliceId ?? "";
143
147
  }
144
148
  catch {
145
149
  activeStageFlags = [];
@@ -152,6 +156,8 @@ export async function lintArtifact(projectRoot, stage, track = "standard", optio
152
156
  worktreeExecutionMode = "single-tree";
153
157
  tddCheckpointMode = "per-slice";
154
158
  integrationOverseerMode = "always";
159
+ tddCutoverSliceId = "";
160
+ tddWorktreeCutoverSliceId = "";
155
161
  }
156
162
  for (const extra of options.extraStageFlags ?? []) {
157
163
  if (typeof extra === "string" && extra.length > 0 && !activeStageFlags.includes(extra)) {
@@ -291,7 +297,9 @@ export async function lintArtifact(projectRoot, stage, track = "standard", optio
291
297
  legacyContinuation,
292
298
  worktreeExecutionMode,
293
299
  tddCheckpointMode,
294
- integrationOverseerMode
300
+ integrationOverseerMode,
301
+ tddCutoverSliceId,
302
+ tddWorktreeCutoverSliceId
295
303
  };
296
304
  switch (stage) {
297
305
  case "brainstorm":
@@ -68,7 +68,7 @@ function tddWorkerSelfRecordContract(agentName) {
68
68
  const laneFlags = isImplementer
69
69
  ? " [--claim-token=<t>] [--lane-id=<lane>] [--lease-until=<iso>]"
70
70
  : "";
71
- return `## TDD Worker Self-Record Contract (v6.14.1)
71
+ return `## TDD Worker Self-Record Contract (v6.14.2)
72
72
 
73
73
  You are a TDD worker dispatched via \`Task\`. The parent already wrote your \`scheduled\` and \`launched\` ledger rows BEFORE invoking you. **Your responsibility is to self-record \`acknowledged\` on entry and \`completed\` on exit** by invoking \`.cclaw/hooks/delegation-record.mjs\` directly. Do NOT skip these — the controller depends on them, the linter validates them, and back-fill via \`--repair\` is reserved for recovery only.
74
74
 
@@ -100,7 +100,7 @@ node .cclaw/hooks/delegation-record.mjs \\
100
100
  --json
101
101
  \`\`\`
102
102
 
103
- Reuse the same \`<spanId>\` and \`<dispatchId>\` across both rows. \`--ack-ts\` and \`--completed-ts\` must be monotonic on the span (\`startTs ≤ launchedTs ≤ ackTs ≤ completedTs\`); the helper rejects out-of-order writes with \`delegation_timestamp_non_monotonic\`. If the helper rejects with \`dispatch_active_span_collision\` against a stale span, surface the conflicting \`spanId\` to the parent — do NOT silently retry with \`--allow-parallel\`.`;
103
+ Reuse the same \`<spanId>\` and \`<dispatchId>\` across both rows. **v6.14.2 evidence-freshness contract** (slice-implementer GREEN only): the FIRST \`--evidence-ref\` MUST (1) reference the same test the matching \`phase=red\` row cited (basename/stem substring; reject \`green_evidence_red_test_mismatch\`), (2) include a recognized passing-runner line such as \`=> N passed; 0 failed\`, \`N passed in 0.42s\`, or \`ok pkg 0.12s\` (reject \`green_evidence_passing_assertion_missing\`), AND (3) be captured AFTER \`ackTs\` of this span — \`completedTs - ackTs\` must be ≥ \`flow-state.json::tddGreenMinElapsedMs\` (default 4000ms; reject \`green_evidence_too_fresh\`). Escape clause for legitimate observational GREEN: pass BOTH \`--allow-fast-green --green-mode=observational\`. \`--ack-ts\` and \`--completed-ts\` must be monotonic on the span (\`startTs ≤ launchedTs ≤ ackTs ≤ completedTs\`); the helper rejects out-of-order writes with \`delegation_timestamp_non_monotonic\`. If the helper rejects with \`dispatch_active_span_collision\` against a stale span, surface the conflicting \`spanId\` to the parent — do NOT silently retry with \`--allow-parallel\`.`;
104
104
  }
105
105
  function formatReturnSchema(schema) {
106
106
  const lines = [
@@ -307,6 +307,65 @@ async function readWorktreeExecutionModeInline(root) {
307
307
  }
308
308
  }
309
309
 
310
+ // v6.14.2 — read \`tddGreenMinElapsedMs\` from flow-state.json. Defaults to
311
+ // 4000ms when missing or invalid. Operators set 0 to disable the freshness
312
+ // floor while keeping RED-test-name and passing-assertion checks active.
313
+ async function readTddGreenMinElapsedMsInline(root) {
314
+ try {
315
+ const raw = await fs.readFile(path.join(root, RUNTIME_ROOT, "state", "flow-state.json"), "utf8");
316
+ const parsed = JSON.parse(raw);
317
+ if (parsed && typeof parsed.tddGreenMinElapsedMs === "number" && parsed.tddGreenMinElapsedMs >= 0) {
318
+ return Math.floor(parsed.tddGreenMinElapsedMs);
319
+ }
320
+ return 4000;
321
+ } catch {
322
+ return 4000;
323
+ }
324
+ }
325
+
326
+ // v6.14.2 Fix 4 — match the RED test name into the GREEN evidenceRef.
327
+ // Returns the basename or stem (without extension) of the most-specific
328
+ // path token in the RED row's first evidenceRef. We deliberately use a
329
+ // substring match, not equality, so callers can include richer text
330
+ // like "REGRESSION: cargo test --test foo => 8 passed; 0 failed".
331
+ function extractRedTestNameInline(redEvidenceRef) {
332
+ if (typeof redEvidenceRef !== "string") return null;
333
+ const trimmed = redEvidenceRef.trim();
334
+ if (trimmed.length === 0) return null;
335
+ // Path-shaped token (foo/bar/baz_test.rs or src/foo.test.ts).
336
+ const pathMatch = /[A-Za-z0-9_./-]+/u.exec(trimmed);
337
+ if (pathMatch) {
338
+ const token = pathMatch[0];
339
+ const slashIdx = token.lastIndexOf("/");
340
+ const base = slashIdx >= 0 ? token.slice(slashIdx + 1) : token;
341
+ const dotIdx = base.indexOf(".");
342
+ const stem = dotIdx > 0 ? base.slice(0, dotIdx) : base;
343
+ if (stem.length >= 4) return stem;
344
+ return base;
345
+ }
346
+ return trimmed;
347
+ }
348
+
349
+ // Match canonical runner pass lines:
350
+ // cargo: "test result: ok. N passed; 0 failed"
351
+ // pytest: "===== N passed in 0.42s ====="
352
+ // go test: "ok pkg 0.123s"
353
+ // npm/jest/vitest: "Tests: N passed"
354
+ // We accept a generic shape: "=> N passed; 0 failed" (the example in
355
+ // the v6.14.2 worker contract) plus four runner-specific patterns.
356
+ const GREEN_PASS_PATTERNS = [
357
+ /=>\\s*\\d+\\s+passed/iu,
358
+ /\\b\\d+\\s+passed[;,]\\s*0\\s+failed\\b/iu,
359
+ /\\btest\\s+result:\\s*ok\\b/iu,
360
+ /\\b\\d+\\s+passed\\s+in\\s+\\d+(?:\\.\\d+)?\\s*s\\b/iu,
361
+ /^ok\\s+\\S+\\s+\\d+(?:\\.\\d+)?s\\b/imu
362
+ ];
363
+
364
+ function matchesPassingAssertionInline(value) {
365
+ if (typeof value !== "string") return false;
366
+ return GREEN_PASS_PATTERNS.some((re) => re.test(value));
367
+ }
368
+
310
369
  async function readDelegationEvents(root) {
311
370
  try {
312
371
  const raw = await fs.readFile(path.join(root, RUNTIME_ROOT, "state", "delegation-events.jsonl"), "utf8");
@@ -1435,6 +1494,137 @@ async function main() {
1435
1494
  }
1436
1495
  }
1437
1496
 
1497
+ // v6.14.2 Fix 4 — GREEN evidence freshness contract for
1498
+ // \`slice-implementer --phase green --status=completed\`. Three checks:
1499
+ // 1. green_evidence_red_test_mismatch — evidenceRefs[0] must contain
1500
+ // the basename/stem of the RED span's first evidenceRef.
1501
+ // 2. green_evidence_passing_assertion_missing — evidenceRefs[0]
1502
+ // must carry a recognized passing-assertion line ("=> N passed;
1503
+ // 0 failed" or runner-specific equivalents).
1504
+ // 3. green_evidence_too_fresh — completedTs minus ackTs must be
1505
+ // >= flow-state.json::tddGreenMinElapsedMs (default 4000ms).
1506
+ // Escape hatch for legitimate observational GREENs (cross-slice
1507
+ // handoff, no-op verification): pair --allow-fast-green with
1508
+ // --green-mode=observational. Both flags are required.
1509
+ if (
1510
+ clean.stage === "tdd" &&
1511
+ clean.agent === "slice-implementer" &&
1512
+ clean.phase === "green" &&
1513
+ clean.status === "completed"
1514
+ ) {
1515
+ const isObservational =
1516
+ typeof args["green-mode"] === "string" &&
1517
+ args["green-mode"].trim().toLowerCase() === "observational";
1518
+ const allowFastGreen = args["allow-fast-green"] === true;
1519
+ const greenEvidenceFirst =
1520
+ Array.isArray(clean.evidenceRefs) && clean.evidenceRefs.length > 0
1521
+ ? String(clean.evidenceRefs[0])
1522
+ : "";
1523
+
1524
+ // Locate the matching RED row's first evidenceRef in the events log.
1525
+ const priorEvents = await readDelegationEvents(root);
1526
+ let redEvidenceRef = null;
1527
+ for (let i = priorEvents.length - 1; i >= 0; i -= 1) {
1528
+ const ev = priorEvents[i];
1529
+ if (!ev) continue;
1530
+ if (ev.runId !== runId) continue;
1531
+ if (ev.stage !== "tdd") continue;
1532
+ if (ev.sliceId !== clean.sliceId) continue;
1533
+ if (ev.phase !== "red") continue;
1534
+ if (Array.isArray(ev.evidenceRefs) && ev.evidenceRefs.length > 0) {
1535
+ redEvidenceRef = String(ev.evidenceRefs[0] || "");
1536
+ break;
1537
+ }
1538
+ }
1539
+
1540
+ // The freshness contract only fires when there's a matching RED row
1541
+ // for this slice in the active run. Without RED context we have
1542
+ // nothing to verify GREEN against (legacy ledger imports, RED
1543
+ // happened outside cclaw harness, or test fixtures that bypass
1544
+ // RED). Once a RED row is present, the contract becomes
1545
+ // mandatory unless explicitly waived via --allow-fast-green
1546
+ // --green-mode=observational.
1547
+ const hasRedContext = redEvidenceRef !== null;
1548
+ const escapeFastGreen = allowFastGreen && isObservational;
1549
+
1550
+ if (hasRedContext && !escapeFastGreen) {
1551
+ // Check 1: RED test name match.
1552
+ const stem = extractRedTestNameInline(redEvidenceRef);
1553
+ if (stem && greenEvidenceFirst.length > 0 && !greenEvidenceFirst.toLowerCase().includes(stem.toLowerCase())) {
1554
+ emitErrorJson(
1555
+ "green_evidence_red_test_mismatch",
1556
+ {
1557
+ sliceId: clean.sliceId,
1558
+ redEvidenceFirst: redEvidenceRef,
1559
+ greenEvidenceFirst,
1560
+ expectedSubstring: stem,
1561
+ remediation:
1562
+ "evidenceRefs[0] on the GREEN row must reference the same test the RED row cited. Re-run the matching RED test, capture its passing output, and pass it as --evidence-ref."
1563
+ },
1564
+ json
1565
+ );
1566
+ return;
1567
+ }
1568
+
1569
+ // Check 2: passing-assertion line.
1570
+ if (greenEvidenceFirst.length > 0 && !matchesPassingAssertionInline(greenEvidenceFirst)) {
1571
+ emitErrorJson(
1572
+ "green_evidence_passing_assertion_missing",
1573
+ {
1574
+ sliceId: clean.sliceId,
1575
+ greenEvidenceFirst,
1576
+ remediation:
1577
+ "evidenceRefs[0] on the GREEN row must contain a passing-assertion line such as \\"=> N passed; 0 failed\\" (cargo/jest/vitest), \\"N passed in 0.42s\\" (pytest), \\"ok pkg 0.12s\\" (go test), or equivalent runner output. Re-run the test and paste a fresh runner line."
1578
+ },
1579
+ json
1580
+ );
1581
+ return;
1582
+ }
1583
+
1584
+ // Check 3: fast-green floor. ackTs is required upstream; we use
1585
+ // the persisted ackTs from prior events when not provided on this
1586
+ // row.
1587
+ const minMs = await readTddGreenMinElapsedMsInline(root);
1588
+ if (minMs > 0 && clean.completedTs) {
1589
+ let ackTs = clean.ackTs;
1590
+ if (!ackTs) {
1591
+ for (let i = priorEvents.length - 1; i >= 0; i -= 1) {
1592
+ const ev = priorEvents[i];
1593
+ if (!ev) continue;
1594
+ if (ev.spanId !== clean.spanId) continue;
1595
+ if (typeof ev.ackTs === "string" && ev.ackTs.length > 0) {
1596
+ ackTs = ev.ackTs;
1597
+ break;
1598
+ }
1599
+ }
1600
+ }
1601
+ if (ackTs) {
1602
+ const completedMs = Date.parse(clean.completedTs);
1603
+ const ackMs = Date.parse(ackTs);
1604
+ if (Number.isFinite(completedMs) && Number.isFinite(ackMs)) {
1605
+ const elapsed = completedMs - ackMs;
1606
+ if (elapsed < minMs) {
1607
+ emitErrorJson(
1608
+ "green_evidence_too_fresh",
1609
+ {
1610
+ sliceId: clean.sliceId,
1611
+ ackTs,
1612
+ completedTs: clean.completedTs,
1613
+ elapsedMs: elapsed,
1614
+ minMs,
1615
+ remediation:
1616
+ "GREEN completedTs - ackTs is below the freshness floor. Either run the verification test for real and re-record, or pass --allow-fast-green --green-mode=observational for legitimate no-op verification spans."
1617
+ },
1618
+ json
1619
+ );
1620
+ return;
1621
+ }
1622
+ }
1623
+ }
1624
+ }
1625
+ }
1626
+ }
1627
+
1438
1628
  if (
1439
1629
  clean.stage === "tdd" &&
1440
1630
  clean.agent === "slice-implementer" &&