cclaw-cli 0.46.15 → 0.47.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.
@@ -12,25 +12,52 @@ async function resolveArtifactPath(projectRoot, fileName) {
12
12
  function normalizeHeadingTitle(title) {
13
13
  return title.trim().replace(/\s+/g, " ");
14
14
  }
15
- /** Collect H2 sections and body content (`## Section Name`). */
15
+ /**
16
+ * Collect H2 sections and body content (`## Section Name`).
17
+ *
18
+ * - Ignores lines that live inside fenced code blocks (``` / ~~~) so a
19
+ * commented `## Approaches` inside an example doesn't open a phantom
20
+ * section and swallow real content.
21
+ * - When the same heading appears more than once at the top level we
22
+ * concatenate the bodies rather than silently overwriting the earlier
23
+ * occurrence. This keeps lint rules honest when authors split a section
24
+ * into multiple passes.
25
+ */
16
26
  function extractH2Sections(markdown) {
17
27
  const sections = new Map();
18
28
  const lines = markdown.split(/\r?\n/);
19
29
  let currentHeading = null;
20
30
  let buffer = [];
31
+ let fenced = null;
21
32
  const flush = () => {
22
33
  if (currentHeading === null)
23
34
  return;
24
- sections.set(currentHeading, buffer.join("\n"));
35
+ const existing = sections.get(currentHeading);
36
+ const body = buffer.join("\n");
37
+ sections.set(currentHeading, existing === undefined ? body : `${existing}\n${body}`);
25
38
  };
26
39
  for (const line of lines) {
27
- const match = /^##\s+(.+)$/u.exec(line);
28
- if (match) {
29
- flush();
30
- currentHeading = normalizeHeadingTitle(match[1] ?? "");
31
- buffer = [];
40
+ const fenceMatch = /^(```|~~~)/u.exec(line);
41
+ if (fenceMatch) {
42
+ if (fenced === null) {
43
+ fenced = fenceMatch[1] ?? null;
44
+ }
45
+ else if (line.startsWith(fenced)) {
46
+ fenced = null;
47
+ }
48
+ if (currentHeading !== null)
49
+ buffer.push(line);
32
50
  continue;
33
51
  }
52
+ if (fenced === null) {
53
+ const match = /^##\s+(.+)$/u.exec(line);
54
+ if (match) {
55
+ flush();
56
+ currentHeading = normalizeHeadingTitle(match[1] ?? "");
57
+ buffer = [];
58
+ continue;
59
+ }
60
+ }
34
61
  if (currentHeading !== null) {
35
62
  buffer.push(line);
36
63
  }
@@ -869,6 +896,49 @@ export async function lintArtifact(projectRoot, stage) {
869
896
  details: learnings.details
870
897
  });
871
898
  }
899
+ if (stage === "brainstorm") {
900
+ // Brainstorm Iron Law: "NO ARTIFACT IS COMPLETE WITHOUT AN EXPLICITLY
901
+ // APPROVED DIRECTION — SILENCE IS NOT APPROVAL." Previously this was
902
+ // prose-only — nothing failed when the Selected Direction section
903
+ // omitted an approval marker, or when the Approaches table collapsed
904
+ // to a single row (defeating the "2-3 distinct approaches" gate).
905
+ const approachesBody = sectionBodyByName(sections, "Approaches");
906
+ if (approachesBody !== null) {
907
+ const tableRows = approachesBody
908
+ .split(/\r?\n/u)
909
+ .map((line) => line.trim())
910
+ .filter((line) => line.startsWith("|"))
911
+ .filter((line) => !/^\|\s*[-: |]+\|\s*$/u.test(line))
912
+ .filter((line) => !/^\|\s*approach\b/iu.test(line));
913
+ const bulletRows = approachesBody
914
+ .split(/\r?\n/u)
915
+ .map((line) => line.trim())
916
+ .filter((line) => /^(?:[-*]|\d+\.)\s+\S/u.test(line));
917
+ const rowCount = Math.max(tableRows.length, bulletRows.length);
918
+ findings.push({
919
+ section: "Distinct Approaches Enforcement",
920
+ required: true,
921
+ rule: "Approaches section must document at least 2 distinct approaches so the Iron Law comparison is meaningful.",
922
+ found: rowCount >= 2,
923
+ details: rowCount >= 2
924
+ ? `Detected ${rowCount} approach row(s).`
925
+ : `Detected ${rowCount} approach row(s); at least 2 required.`
926
+ });
927
+ }
928
+ const directionBody = sectionBodyByName(sections, "Selected Direction");
929
+ if (directionBody !== null) {
930
+ const approvalMarker = /\bapprov(?:ed|al)\b/iu.test(directionBody);
931
+ findings.push({
932
+ section: "Direction Approval Marker",
933
+ required: true,
934
+ rule: "Selected Direction section must state an explicit approval marker (for example `Approval: approved` or `Approved by: user`).",
935
+ found: approvalMarker,
936
+ details: approvalMarker
937
+ ? "Approval marker present in Selected Direction."
938
+ : "No explicit `approved`/`approval` marker found in Selected Direction."
939
+ });
940
+ }
941
+ }
872
942
  if (stage === "plan") {
873
943
  const strictPlanGuards = parsedFrontmatter.hasFrontmatter ||
874
944
  headingPresent(sections, "No-Placeholder Scan") ||
@@ -914,12 +984,13 @@ export async function lintArtifact(projectRoot, stage) {
914
984
  });
915
985
  }
916
986
  if (stage === "scope") {
987
+ const lockedDecisionsBody = sectionBodyByName(sections, "Locked Decisions (D-XX)") ?? "";
917
988
  const strictScopeGuards = parsedFrontmatter.hasFrontmatter ||
918
989
  headingPresent(sections, "Locked Decisions (D-XX)");
919
990
  const scopeSections = [
920
991
  sectionBodyByName(sections, "In Scope / Out of Scope") ?? "",
921
992
  sectionBodyByName(sections, "Scope Summary") ?? "",
922
- sectionBodyByName(sections, "Locked Decisions (D-XX)") ?? ""
993
+ lockedDecisionsBody
923
994
  ].join("\n");
924
995
  const reductionHits = collectPatternHits(scopeSections, SCOPE_REDUCTION_PATTERNS);
925
996
  findings.push({
@@ -931,6 +1002,45 @@ export async function lintArtifact(projectRoot, stage) {
931
1002
  ? "No scope-reduction phrases detected in scope boundary sections."
932
1003
  : `Detected scope-reduction phrase(s): ${reductionHits.join(", ")}.`
933
1004
  });
1005
+ // When the Locked Decisions section is present we must enforce the
1006
+ // D-XX ID contract at runtime (previously this was prose-only in the
1007
+ // artifactValidation rule). Empty body, missing IDs, and duplicate
1008
+ // IDs all fail the lint; absence of the section remains advisory so
1009
+ // scope stays optional for small/quick tracks.
1010
+ if (headingPresent(sections, "Locked Decisions (D-XX)")) {
1011
+ const decisionIds = extractDecisionIds(lockedDecisionsBody);
1012
+ const bulletLines = lockedDecisionsBody
1013
+ .split(/\r?\n/u)
1014
+ .map((line) => line.trim())
1015
+ .filter((line) => /^(?:[-*]|\|)\s+\S/u.test(line));
1016
+ const orphanBullets = bulletLines.filter((line) => !/\bD-\d+\b/u.test(line));
1017
+ const duplicateIds = (() => {
1018
+ const all = lockedDecisionsBody.match(/\bD-\d+\b/gu) ?? [];
1019
+ const counts = new Map();
1020
+ for (const id of all)
1021
+ counts.set(id, (counts.get(id) ?? 0) + 1);
1022
+ return [...counts.entries()].filter(([, n]) => n > 1).map(([id]) => id);
1023
+ })();
1024
+ const issues = [];
1025
+ if (decisionIds.length === 0 && bulletLines.length === 0) {
1026
+ issues.push("section is empty");
1027
+ }
1028
+ if (orphanBullets.length > 0) {
1029
+ issues.push(`${orphanBullets.length} bullet(s) missing a D-XX ID`);
1030
+ }
1031
+ if (duplicateIds.length > 0) {
1032
+ issues.push(`duplicate IDs: ${duplicateIds.join(", ")}`);
1033
+ }
1034
+ findings.push({
1035
+ section: "Locked Decisions ID Integrity",
1036
+ required: true,
1037
+ rule: "Locked Decisions section must list each decision with a unique stable D-XX ID.",
1038
+ found: issues.length === 0,
1039
+ details: issues.length === 0
1040
+ ? `${decisionIds.length} decision ID(s) recorded with no duplicates.`
1041
+ : issues.join("; ")
1042
+ });
1043
+ }
934
1044
  }
935
1045
  const passed = findings.every((f) => !f.required || f.found);
936
1046
  return { stage, file: relFile, passed, findings };
@@ -1198,6 +1308,14 @@ export async function checkReviewVerdictConsistency(projectRoot) {
1198
1308
  if (finalVerdict === "APPROVED" && (openCriticalCount > 0 || shipBlockerCount > 0)) {
1199
1309
  errors.push(`Final Verdict is APPROVED but review-army has ${openCriticalCount} open Critical finding(s) and ${shipBlockerCount} shipBlocker(s). Use BLOCKED or APPROVED_WITH_CONCERNS.`);
1200
1310
  }
1311
+ // APPROVED_WITH_CONCERNS is intended for Important/Suggestion findings
1312
+ // the author has accepted. An *open* Critical finding or an active
1313
+ // shipBlocker must route through BLOCKED (review_verdict_blocked gate)
1314
+ // rather than pass as a concession — previously this slipped through.
1315
+ if (finalVerdict === "APPROVED_WITH_CONCERNS" &&
1316
+ (openCriticalCount > 0 || shipBlockerCount > 0)) {
1317
+ errors.push(`Final Verdict is APPROVED_WITH_CONCERNS but review-army has ${openCriticalCount} open Critical finding(s) and ${shipBlockerCount} shipBlocker(s). Resolve them or use BLOCKED.`);
1318
+ }
1201
1319
  return {
1202
1320
  ok: errors.length === 0,
1203
1321
  errors,
@@ -1,4 +1,5 @@
1
1
  import { COMMAND_FILE_ORDER } from "../constants.js";
2
+ import { FLOW_TRACKS, TRACK_STAGES } from "../types.js";
2
3
  import { BRAINSTORM, SCOPE, DESIGN, SPEC, PLAN, TDD, REVIEW, SHIP } from "./stages/index.js";
3
4
  import { tddStageForTrack } from "./stages/tdd.js";
4
5
  const REQUIRED_GATE_IDS = {
@@ -266,15 +267,27 @@ export function nextCclawCommand(stage) {
266
267
  }
267
268
  export function buildTransitionRules() {
268
269
  const rules = [];
269
- for (const schema of orderedStageSchemas()) {
270
- if (schema.next === "done") {
271
- continue;
270
+ const seen = new Set();
271
+ // Derive transitions from every track so medium/quick (which skip stages)
272
+ // get their neighbour edges registered alongside the standard chain.
273
+ // Previously only the standard track produced rules, so `canTransition`
274
+ // returned false for legitimate medium/quick transitions (e.g. brainstorm
275
+ // -> spec on medium) even though `nextStage` correctly advanced them.
276
+ for (const track of FLOW_TRACKS) {
277
+ const ordered = TRACK_STAGES[track];
278
+ for (let i = 0; i < ordered.length - 1; i += 1) {
279
+ const from = ordered[i];
280
+ const to = ordered[i + 1];
281
+ const key = `${from}->${to}`;
282
+ if (seen.has(key))
283
+ continue;
284
+ seen.add(key);
285
+ rules.push({
286
+ from,
287
+ to,
288
+ guards: stageGateIds(from, track)
289
+ });
272
290
  }
273
- rules.push({
274
- from: schema.stage,
275
- to: schema.next,
276
- guards: stageGateIds(schema.stage)
277
- });
278
291
  }
279
292
  // Review can explicitly route back to TDD when the verdict is BLOCKED.
280
293
  rules.push({
@@ -126,6 +126,13 @@ export async function appendDelegation(projectRoot, entry) {
126
126
  if (!Array.isArray(stamped.evidenceRefs)) {
127
127
  stamped.evidenceRefs = [];
128
128
  }
129
+ // Idempotency: if a caller (or a retried hook) tries to append a row
130
+ // with a spanId that already exists in the ledger, treat it as a no-op
131
+ // instead of growing the log with duplicate entries that subsequent
132
+ // delegation checks would mis-count.
133
+ if (prior.entries.some((existing) => existing.spanId === stamped.spanId)) {
134
+ return;
135
+ }
129
136
  const ledger = {
130
137
  runId: activeRunId,
131
138
  entries: [...prior.entries, stamped]
@@ -201,11 +208,19 @@ export async function checkMandatoryDelegations(projectRoot, stage) {
201
208
  if (hasWaived) {
202
209
  waived.push(agent);
203
210
  }
204
- // Under role-switch fallback, a `completed` row is only credible if it
205
- // carries at least one evidenceRef otherwise the agent might have
206
- // claimed role-switch satisfaction without showing its work.
211
+ // Evidence gating for `completed` rows has two triggers:
212
+ // 1. The aggregate expected mode is role-switch (no isolated harness
213
+ // available), so every completion implicitly ran as role-switch.
214
+ // 2. Any completed row is explicitly stamped `fulfillmentMode:
215
+ // "role-switch"` — even in a mixed install. This closes the loop
216
+ // where a Codex session logs a role-switch completion inside a
217
+ // claude+codex project: the aggregate expectedMode is "isolated"
218
+ // (claude wins), so the role-switch row would previously sail
219
+ // through without evidenceRefs.
220
+ const hasExplicitRoleSwitchRow = completedRows.some((e) => e.fulfillmentMode === "role-switch");
221
+ const evidenceRequired = expectedMode === "role-switch" || hasExplicitRoleSwitchRow;
207
222
  if (hasCompleted &&
208
- expectedMode === "role-switch" &&
223
+ evidenceRequired &&
209
224
  !completedRows.some((e) => Array.isArray(e.evidenceRefs) && e.evidenceRefs.length > 0)) {
210
225
  missingEvidence.push(agent);
211
226
  }
@@ -266,7 +266,12 @@ export async function verifyCurrentStageGateEvidence(projectRoot, flowState) {
266
266
  .map((line) => line.trim())
267
267
  .filter((line) => line.length > 0)
268
268
  .filter((line) => !/^\|?(?:[-:\s|])+$/u.test(line));
269
- const nonPlaceholder = meaningfulLines.filter((line) => !/\b(?:TODO|TBD|FIXME|pending|<fill-in>)\b/iu.test(line));
269
+ // `<fill-in>` needs its own check because `\b` does not match
270
+ // around `<`/`>` (non-word characters), so the previous combined
271
+ // pattern `\b(?:...|<fill-in>)\b` silently never matched placeholder
272
+ // templates that used angle-bracket form.
273
+ const nonPlaceholder = meaningfulLines.filter((line) => !/\b(?:TODO|TBD|FIXME|pending)\b/iu.test(line) &&
274
+ !/<fill-in>/iu.test(line));
270
275
  if (nonPlaceholder.length === 0) {
271
276
  missingSections.push(`${section} (empty or placeholder)`);
272
277
  }
@@ -16,21 +16,36 @@ function unique(values) {
16
16
  const TEST_COMMAND_HINT_PATTERN = /\b(?:npm test|pnpm test|yarn test|bun test|vitest|jest|pytest|go test|cargo test|mvn test|gradle test|dotnet test)\b/iu;
17
17
  const SHA_WITH_LABEL_PATTERN = /\b(?:sha|commit)(?:\s*[:=]|\s+)\s*[0-9a-f]{7,40}\b/iu;
18
18
  const PASS_STATUS_PATTERN = /\b(?:pass|passed|green|ok)\b/iu;
19
- function validateGateEvidenceShape(stage, gateId, evidence) {
20
- if (stage !== "tdd" || gateId !== "tdd_verified_before_complete") {
19
+ const SHIP_FINALIZATION_MODE_PATTERN = /\bFINALIZE_(?:MERGE_LOCAL|OPEN_PR|QUEUE|HANDOFF|SKIP)\b/u;
20
+ // Per-gate validators keyed by `${stage}:${gateId}`. Returning a non-null
21
+ // string surfaces the reason as an `advance-stage` failure so evidence is
22
+ // guaranteed to carry the structural breadcrumbs downstream tooling
23
+ // expects. Previously only `tdd:tdd_verified_before_complete` was checked.
24
+ const GATE_EVIDENCE_VALIDATORS = {
25
+ "tdd:tdd_verified_before_complete": (evidence) => {
26
+ if (!TEST_COMMAND_HINT_PATTERN.test(evidence)) {
27
+ return "must include the fresh verification command that was run (for example `npm test`, `pytest`, `go test`, or equivalent).";
28
+ }
29
+ if (!SHA_WITH_LABEL_PATTERN.test(evidence)) {
30
+ return "must include a commit SHA token prefixed with `sha` or `commit` (for example `sha: abc1234`).";
31
+ }
32
+ if (!PASS_STATUS_PATTERN.test(evidence)) {
33
+ return "must include explicit success status (for example `PASS` or `GREEN`).";
34
+ }
35
+ return null;
36
+ },
37
+ "ship:ship_finalization_executed": (evidence) => {
38
+ if (!SHIP_FINALIZATION_MODE_PATTERN.test(evidence)) {
39
+ return "must name the finalization mode that ran (for example `FINALIZE_MERGE_LOCAL`, `FINALIZE_OPEN_PR`, `FINALIZE_HANDOFF`, `FINALIZE_QUEUE`, or `FINALIZE_SKIP`).";
40
+ }
21
41
  return null;
22
42
  }
23
- const trimmed = evidence.trim();
24
- if (!TEST_COMMAND_HINT_PATTERN.test(trimmed)) {
25
- return "must include the fresh verification command that was run (for example `npm test`, `pytest`, `go test`, or equivalent).";
26
- }
27
- if (!SHA_WITH_LABEL_PATTERN.test(trimmed)) {
28
- return "must include a commit SHA token prefixed with `sha` or `commit` (for example `sha: abc1234`).";
29
- }
30
- if (!PASS_STATUS_PATTERN.test(trimmed)) {
31
- return "must include explicit success status (for example `PASS` or `GREEN`).";
32
- }
33
- return null;
43
+ };
44
+ function validateGateEvidenceShape(stage, gateId, evidence) {
45
+ const validator = GATE_EVIDENCE_VALIDATORS[`${stage}:${gateId}`];
46
+ if (!validator)
47
+ return null;
48
+ return validator(evidence.trim());
34
49
  }
35
50
  function parseStringList(raw) {
36
51
  if (!Array.isArray(raw))
@@ -58,10 +73,23 @@ function parseGuardEvidence(value) {
58
73
  }
59
74
  return next;
60
75
  }
76
+ function emptyGateState() {
77
+ return {
78
+ required: [],
79
+ recommended: [],
80
+ conditional: [],
81
+ triggered: [],
82
+ passed: [],
83
+ blocked: []
84
+ };
85
+ }
61
86
  function parseCandidateGateCatalog(value, fallback) {
62
87
  const next = {};
63
88
  for (const stage of FLOW_STAGES) {
64
- const base = fallback[stage];
89
+ // Guard against stale on-disk flow-state files that persisted a partial
90
+ // stageGateCatalog (missing a stage key). Previously `fallback[stage]`
91
+ // could be undefined and the spread below would throw at runtime.
92
+ const base = fallback[stage] ?? emptyGateState();
65
93
  next[stage] = {
66
94
  required: [...base.required],
67
95
  recommended: [...base.recommended],
@@ -81,7 +109,7 @@ function parseCandidateGateCatalog(value, fallback) {
81
109
  continue;
82
110
  }
83
111
  const typed = rawStage;
84
- const base = fallback[stage];
112
+ const base = fallback[stage] ?? emptyGateState();
85
113
  const allowed = new Set([...base.required, ...base.recommended, ...base.conditional]);
86
114
  const conditional = new Set(base.conditional);
87
115
  const passed = unique(parseStringList(typed.passed)).filter((gateId) => allowed.has(gateId));
@@ -114,13 +142,22 @@ function coerceCandidateFlowState(raw, fallback) {
114
142
  const completedStages = unique(parseStringList(typed.completedStages).filter((stage) => isFlowStageValue(stage)));
115
143
  const skippedStagesRaw = parseStringList(typed.skippedStages).filter((stage) => isFlowStageValue(stage));
116
144
  const skippedStages = skippedStagesRaw.length > 0 ? skippedStagesRaw : fallback.skippedStages;
145
+ // When the candidate payload omits `guardEvidence` entirely we must keep
146
+ // the on-disk fallback — otherwise a partial update (e.g. a tooling call
147
+ // that only passes stage + passedGateIds) would silently wipe every
148
+ // previously recorded evidence string and fail the next
149
+ // `verifyCurrentStageGateEvidence` check.
150
+ const candidateEvidence = parseGuardEvidence(typed.guardEvidence);
151
+ const guardEvidence = typed.guardEvidence === undefined
152
+ ? { ...fallback.guardEvidence }
153
+ : candidateEvidence;
117
154
  return {
118
155
  ...fallback,
119
156
  currentStage,
120
157
  completedStages,
121
158
  track,
122
159
  skippedStages,
123
- guardEvidence: parseGuardEvidence(typed.guardEvidence),
160
+ guardEvidence,
124
161
  stageGateCatalog: parseCandidateGateCatalog(typed.stageGateCatalog, fallback.stageGateCatalog)
125
162
  };
126
163
  }
@@ -73,7 +73,17 @@ export async function evaluateRetroGate(projectRoot, state) {
73
73
  compoundEntries = 0;
74
74
  }
75
75
  }
76
- const completed = required ? (hasRetroArtifact && compoundEntries > 0) : true;
76
+ // A retro is considered complete when either:
77
+ // - at least one compound learning was promoted during the retro window, or
78
+ // - the operator explicitly skipped retro or compound (`retroSkipped` /
79
+ // `compoundSkipped` recorded in the closeout substate) after reviewing
80
+ // the draft. Previously the gate required `compoundEntries > 0`
81
+ // unconditionally, which dead-locked ship closeout whenever the retro
82
+ // yielded no new patterns worth promoting.
83
+ const explicitSkip = Boolean(state.closeout.retroSkipped || state.closeout.compoundSkipped);
84
+ const completed = required
85
+ ? hasRetroArtifact && (compoundEntries > 0 || explicitSkip)
86
+ : true;
77
87
  return {
78
88
  required,
79
89
  completed,
@@ -235,7 +235,7 @@ function sanitizeCloseoutState(value) {
235
235
  return fallback;
236
236
  }
237
237
  const typed = value;
238
- const shipSubstate = isShipSubstate(typed.shipSubstate) ? typed.shipSubstate : fallback.shipSubstate;
238
+ let shipSubstate = isShipSubstate(typed.shipSubstate) ? typed.shipSubstate : fallback.shipSubstate;
239
239
  const retroDraftedAt = typeof typed.retroDraftedAt === "string" ? typed.retroDraftedAt : undefined;
240
240
  const retroAcceptedAt = typeof typed.retroAcceptedAt === "string" ? typed.retroAcceptedAt : undefined;
241
241
  const retroSkipped = typeof typed.retroSkipped === "boolean" ? typed.retroSkipped : undefined;
@@ -246,6 +246,16 @@ function sanitizeCloseoutState(value) {
246
246
  const compoundPromoted = typeof promotedRaw === "number" && Number.isFinite(promotedRaw) && promotedRaw >= 0
247
247
  ? Math.floor(promotedRaw)
248
248
  : 0;
249
+ // Demote shipSubstate when its retro invariant is violated on disk. A
250
+ // hand-edited flow-state could claim `ready_to_archive` or `compound_review`
251
+ // without ever going through the retro step, which would let `archive`
252
+ // proceed and skip the gate. Compound completion is not independently
253
+ // tracked in all flows (some runs rely on knowledge.jsonl + the retro
254
+ // window), so we only demote when the retro leg is missing outright.
255
+ const retroDone = retroAcceptedAt !== undefined || retroSkipped === true;
256
+ if (!retroDone && (shipSubstate === "ready_to_archive" || shipSubstate === "compound_review")) {
257
+ shipSubstate = "retro_review";
258
+ }
249
259
  return {
250
260
  shipSubstate,
251
261
  retroDraftedAt,
package/dist/tdd-cycle.js CHANGED
@@ -31,6 +31,7 @@ export function parseTddCycleLog(text) {
31
31
  }
32
32
  return out;
33
33
  }
34
+ const SLICE_ID_PATTERN = /^S-\d+$/u;
34
35
  export function validateTddCycleOrder(entries, options = {}) {
35
36
  const targetRun = options.runId;
36
37
  const filtered = targetRun
@@ -44,6 +45,15 @@ export function validateTddCycleOrder(entries, options = {}) {
44
45
  }
45
46
  const issues = [];
46
47
  const openRedSlices = [];
48
+ // Reject slices whose ID does not match the stable `S-<number>` contract.
49
+ // Entries that drop the slice field entirely were previously coerced to
50
+ // `S-unknown` and silently bucketed together, which means multiple distinct
51
+ // cycles could appear to share a RED/GREEN pair.
52
+ for (const slice of bySlice.keys()) {
53
+ if (!SLICE_ID_PATTERN.test(slice)) {
54
+ issues.push(`slice "${slice}": id must match /^S-\\d+$/ (e.g. S-1)`);
55
+ }
56
+ }
47
57
  for (const [slice, sliceEntries] of bySlice.entries()) {
48
58
  let state = "need_red";
49
59
  for (const entry of sliceEntries) {
@@ -79,7 +89,15 @@ export function validateTddCycleOrder(entries, options = {}) {
79
89
  state = "green_done";
80
90
  continue;
81
91
  }
82
- // refactor
92
+ // refactor — must preserve the passing state established by green.
93
+ if (entry.exitCode === undefined) {
94
+ issues.push(`slice ${slice}: refactor entry must record exitCode 0`);
95
+ continue;
96
+ }
97
+ if (entry.exitCode !== 0) {
98
+ issues.push(`slice ${slice}: refactor entry exitCode must be 0 (tests must stay green)`);
99
+ continue;
100
+ }
83
101
  if (state !== "green_done") {
84
102
  issues.push(`slice ${slice}: refactor logged before green`);
85
103
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cclaw-cli",
3
- "version": "0.46.15",
3
+ "version": "0.47.0",
4
4
  "description": "Installer-first flow toolkit for coding agents",
5
5
  "type": "module",
6
6
  "bin": {