cclaw-cli 0.46.15 → 0.48.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.
Files changed (37) hide show
  1. package/README.md +3 -1
  2. package/dist/artifact-linter.d.ts +7 -0
  3. package/dist/artifact-linter.js +169 -8
  4. package/dist/config.d.ts +6 -6
  5. package/dist/config.js +22 -0
  6. package/dist/constants.d.ts +10 -1
  7. package/dist/constants.js +19 -10
  8. package/dist/content/contracts.d.ts +1 -1
  9. package/dist/content/contracts.js +1 -1
  10. package/dist/content/{harnesses-doc.js → harness-doc.js} +32 -1
  11. package/dist/content/harness-playbooks.js +4 -4
  12. package/dist/content/ideate-command.js +19 -19
  13. package/dist/content/skills.js +2 -2
  14. package/dist/content/stage-schema.js +54 -15
  15. package/dist/content/stages/design.js +2 -2
  16. package/dist/content/stages/review.js +1 -1
  17. package/dist/content/stages/ship.js +2 -0
  18. package/dist/content/stages/tdd.js +8 -4
  19. package/dist/content/templates.js +4 -3
  20. package/dist/delegation.js +107 -26
  21. package/dist/doctor.js +77 -9
  22. package/dist/flow-state.d.ts +8 -0
  23. package/dist/flow-state.js +11 -8
  24. package/dist/gate-evidence.js +26 -2
  25. package/dist/harness-adapters.d.ts +2 -2
  26. package/dist/harness-adapters.js +2 -2
  27. package/dist/install.js +28 -6
  28. package/dist/internal/advance-stage.js +53 -16
  29. package/dist/internal/detect-public-api-changes.d.ts +5 -0
  30. package/dist/internal/detect-public-api-changes.js +45 -0
  31. package/dist/policy.js +3 -2
  32. package/dist/retro-gate.js +30 -3
  33. package/dist/run-persistence.js +16 -5
  34. package/dist/tdd-cycle.js +19 -1
  35. package/dist/types.d.ts +6 -1
  36. package/package.json +4 -1
  37. /package/dist/content/{harnesses-doc.d.ts → harness-doc.d.ts} +0 -0
@@ -1,9 +1,11 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { checkReviewVerdictConsistency, extractMarkdownSectionBody, lintArtifact, validateReviewArmy } from "./artifact-linter.js";
3
+ import { checkReviewSecurityNoChangeAttestation, checkReviewVerdictConsistency, extractMarkdownSectionBody, lintArtifact, validateReviewArmy } from "./artifact-linter.js";
4
4
  import { RUNTIME_ROOT } from "./constants.js";
5
5
  import { stageSchema } from "./content/stage-schema.js";
6
+ import { readDelegationLedger } from "./delegation.js";
6
7
  import { ensureDir, exists, writeFileSafe } from "./fs-utils.js";
8
+ import { detectPublicApiChanges } from "./internal/detect-public-api-changes.js";
7
9
  import { readFlowState, writeFlowState } from "./runs.js";
8
10
  import { buildTraceMatrix } from "./trace-matrix.js";
9
11
  import { FLOW_STAGES } from "./types.js";
@@ -228,6 +230,10 @@ export async function verifyCurrentStageGateEvidence(projectRoot, flowState) {
228
230
  if (!verdictConsistency.ok) {
229
231
  issues.push(`review verdict inconsistency: ${verdictConsistency.errors.join("; ")}`);
230
232
  }
233
+ const securityAttestation = await checkReviewSecurityNoChangeAttestation(projectRoot);
234
+ if (!securityAttestation.ok) {
235
+ issues.push(`review security attestation failed: ${securityAttestation.errors.join("; ")}`);
236
+ }
231
237
  const traceGateRequired = schema.requiredGates.some((gate) => gate.id === "review_trace_matrix_clean" && gate.tier === "required");
232
238
  if (traceGateRequired) {
233
239
  const trace = await buildTraceMatrix(projectRoot);
@@ -266,7 +272,12 @@ export async function verifyCurrentStageGateEvidence(projectRoot, flowState) {
266
272
  .map((line) => line.trim())
267
273
  .filter((line) => line.length > 0)
268
274
  .filter((line) => !/^\|?(?:[-:\s|])+$/u.test(line));
269
- const nonPlaceholder = meaningfulLines.filter((line) => !/\b(?:TODO|TBD|FIXME|pending|<fill-in>)\b/iu.test(line));
275
+ // `<fill-in>` needs its own check because `\b` does not match
276
+ // around `<`/`>` (non-word characters), so the previous combined
277
+ // pattern `\b(?:...|<fill-in>)\b` silently never matched placeholder
278
+ // templates that used angle-bracket form.
279
+ const nonPlaceholder = meaningfulLines.filter((line) => !/\b(?:TODO|TBD|FIXME|pending)\b/iu.test(line) &&
280
+ !/<fill-in>/iu.test(line));
270
281
  if (nonPlaceholder.length === 0) {
271
282
  missingSections.push(`${section} (empty or placeholder)`);
272
283
  }
@@ -277,6 +288,19 @@ export async function verifyCurrentStageGateEvidence(projectRoot, flowState) {
277
288
  }
278
289
  }
279
290
  }
291
+ if (stage === "tdd") {
292
+ const docsDriftDetection = await detectPublicApiChanges(projectRoot);
293
+ if (docsDriftDetection.triggered) {
294
+ const ledger = await readDelegationLedger(projectRoot);
295
+ const hasDocUpdaterCompletion = ledger.entries.some((entry) => entry.runId === flowState.activeRunId &&
296
+ entry.stage === "tdd" &&
297
+ entry.agent === "doc-updater" &&
298
+ entry.status === "completed");
299
+ if (!hasDocUpdaterCompletion) {
300
+ issues.push(`tdd docs drift gate blocked (tdd_docs_drift_check): public surface changes detected (${docsDriftDetection.changedFiles.join(", ")}) but no completed doc-updater delegation exists for the active run.`);
301
+ }
302
+ }
303
+ }
280
304
  }
281
305
  const passedSet = new Set(catalog.passed);
282
306
  const missingRequired = required.filter((gateId) => !passedSet.has(gateId));
@@ -17,8 +17,8 @@ export type SubagentFallback =
17
17
  */
18
18
  | "role-switch"
19
19
  /**
20
- * No meaningful fallback mandatory delegations can only be waived
21
- * under `waiverReason: "harness_limitation"`.
20
+ * Reserved escape hatch for future harnesses with no parity path.
21
+ * Current shipped harnesses do not use this fallback.
22
22
  */
23
23
  | "waiver";
24
24
  /**
@@ -222,7 +222,7 @@ When in doubt, prefer **non-trivial** — the quick track is opt-in and only saf
222
222
  |---|---|
223
223
  | \`/cc\` | **Entry point.** No args = resume current stage. With prompt = classify task and start the right flow. |
224
224
  | \`/cc-next\` | **Progression.** Advances to the next stage when current is complete. |
225
- | \`/cc-ideate\` | **Discovery mode.** Generates a ranked repo-improvement backlog before implementation. |
225
+ | \`/cc-ideate\` | **Ideate mode.** Generates a ranked repo-improvement backlog before implementation. |
226
226
  | \`/cc-view\` | **Read-only router.** Unified entry for status/tree/diff views. |
227
227
  | \`/cc-ops\` | **Operations router.** Unified entry for feature/tdd-log/retro/compound/archive/rewind actions. |
228
228
 
@@ -356,7 +356,7 @@ function codexSkillDescription(command) {
356
356
  case "next":
357
357
  return `Advance the cclaw flow to the next stage. Use when the user types \`/cc-next\` or asks to "move to the next stage", "continue the flow", "advance cclaw", "progress the workflow", or when the current stage skill reports completion and gates have passed.`;
358
358
  case "ideate":
359
- return `Read-only repo-improvement discovery for cclaw. Use when the user types \`/cc-ideate\` or asks to "ideate", "brainstorm improvements", "scan the repo for TODOs/tech debt", "generate a backlog", or wants a ranked list of candidate ideas before committing to a single flow. Does not mutate \`.cclaw/state/flow-state.json\`.`;
359
+ return `Read-only repo-improvement ideate mode for cclaw. Use when the user types \`/cc-ideate\` or asks to "ideate", "scan the repo for TODOs/tech debt", "generate a backlog", or wants a ranked list of candidate ideas before committing to a single flow. Does not mutate \`.cclaw/state/flow-state.json\`.`;
360
360
  case "view":
361
361
  return `Read-only router for cclaw flow views. Use when the user types \`/cc-view\`, \`/cc-view status\`, \`/cc-view tree\`, \`/cc-view diff\`, or asks to "show cclaw status", "show the flow tree", "diff flow state", or wants a snapshot without mutation.`;
362
362
  case "ops":
package/dist/install.js CHANGED
@@ -2,9 +2,9 @@ import { execFile } from "node:child_process";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { promisify } from "node:util";
5
- import { CCLAW_VERSION, COMMAND_FILE_ORDER, FLOW_VERSION, REQUIRED_DIRS, RUNTIME_ROOT } from "./constants.js";
5
+ import { CCLAW_VERSION, FLOW_VERSION, REQUIRED_DIRS, RUNTIME_ROOT } from "./constants.js";
6
6
  import { writeConfig, createDefaultConfig, readConfig, configPath, detectLanguageRulePacks, detectAdvancedKeys } from "./config.js";
7
- import { commandContract } from "./content/contracts.js";
7
+ import { stageCommandContract } from "./content/contracts.js";
8
8
  import { contextModeFiles, createInitialContextModeState } from "./content/contexts.js";
9
9
  import { learnSkillMarkdown, learnCommandContract } from "./content/learnings.js";
10
10
  import { nextCommandContract, nextCommandSkillMarkdown } from "./content/next-command.js";
@@ -36,7 +36,7 @@ import { LANGUAGE_RULE_PACK_DIR, LANGUAGE_RULE_PACK_FILES, LANGUAGE_RULE_PACK_GE
36
36
  import { RESEARCH_PLAYBOOKS } from "./content/research-playbooks.js";
37
37
  import { HARNESS_TOOL_REFS_DIR, HARNESS_TOOL_REFS_INDEX_MD, harnessToolRefMarkdown } from "./content/harness-tool-refs.js";
38
38
  import { DOCTOR_REFERENCE_MARKDOWN } from "./content/doctor-references.js";
39
- import { harnessDocsOverviewMarkdown, harnessIntegrationDocMarkdown } from "./content/harnesses-doc.js";
39
+ import { harnessDocsOverviewMarkdown, harnessIntegrationDocMarkdown } from "./content/harness-doc.js";
40
40
  import { HARNESS_PLAYBOOKS_DIR, harnessPlaybookFileName, harnessPlaybookMarkdown, harnessPlaybooksIndexMarkdown } from "./content/harness-playbooks.js";
41
41
  import { HOOK_EVENTS_BY_HARNESS, HOOK_SEMANTIC_EVENTS } from "./content/hook-events.js";
42
42
  import { createInitialFlowState } from "./flow-state.js";
@@ -45,6 +45,7 @@ import { ensureGitignore, removeGitignorePatterns } from "./gitignore.js";
45
45
  import { HARNESS_ADAPTERS, harnessShimFileNames, harnessTier, syncHarnessShims, removeCclawFromAgentsMd } from "./harness-adapters.js";
46
46
  import { validateHookDocument } from "./hook-schema.js";
47
47
  import { ensureRunSystem, readFlowState } from "./runs.js";
48
+ import { FLOW_STAGES } from "./types.js";
48
49
  const OPENCODE_PLUGIN_REL_PATH = ".opencode/plugins/cclaw-plugin.mjs";
49
50
  const CURSOR_RULE_REL_PATH = ".cursor/rules/cclaw-workflow.mdc";
50
51
  const GIT_HOOK_MANAGED_MARKER = "cclaw-managed-git-hook";
@@ -177,8 +178,8 @@ async function ensureStructure(projectRoot) {
177
178
  }
178
179
  }
179
180
  async function writeCommandContracts(projectRoot) {
180
- for (const stage of COMMAND_FILE_ORDER) {
181
- await writeFileSafe(runtimePath(projectRoot, "commands", `${stage}.md`), commandContract(stage));
181
+ for (const stage of FLOW_STAGES) {
182
+ await writeFileSafe(runtimePath(projectRoot, "commands", `${stage}.md`), stageCommandContract(stage));
182
183
  }
183
184
  }
184
185
  async function writeArtifactTemplates(projectRoot) {
@@ -214,7 +215,7 @@ async function writeEvalScaffold(projectRoot) {
214
215
  }
215
216
  async function writeSkills(projectRoot, config) {
216
217
  const skillTrack = config?.defaultTrack ?? "standard";
217
- for (const stage of COMMAND_FILE_ORDER) {
218
+ for (const stage of FLOW_STAGES) {
218
219
  const folder = stageSkillFolder(stage);
219
220
  await writeFileSafe(runtimePath(projectRoot, "skills", folder, "SKILL.md"), stageSkillMarkdown(stage, skillTrack));
220
221
  // Progressive disclosure (A.2#8): materialize the full example artifact as
@@ -1114,6 +1115,27 @@ async function cleanLegacyArtifacts(projectRoot) {
1114
1115
  // best-effort cleanup
1115
1116
  }
1116
1117
  }
1118
+ // D-4 terminology migration: rename historical ideation artifacts to the
1119
+ // canonical ideate-* naming without deleting user-authored content.
1120
+ const artifactsDir = runtimePath(projectRoot, "artifacts");
1121
+ try {
1122
+ const entries = await fs.readdir(artifactsDir);
1123
+ for (const entry of entries) {
1124
+ const match = /^ideation-(.+\.md)$/u.exec(entry);
1125
+ if (!match)
1126
+ continue;
1127
+ const nextName = `ideate-${match[1]}`;
1128
+ const from = path.join(artifactsDir, entry);
1129
+ const to = path.join(artifactsDir, nextName);
1130
+ if (await exists(to)) {
1131
+ continue;
1132
+ }
1133
+ await fs.rename(from, to);
1134
+ }
1135
+ }
1136
+ catch {
1137
+ // no artifacts directory yet (fresh init) or read-only FS
1138
+ }
1117
1139
  }
1118
1140
  async function cleanStaleFiles(projectRoot) {
1119
1141
  const expectedShimFiles = new Set(harnessShimFileNames());
@@ -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
  }
@@ -0,0 +1,5 @@
1
+ export interface PublicApiChangeDetection {
2
+ triggered: boolean;
3
+ changedFiles: string[];
4
+ }
5
+ export declare function detectPublicApiChanges(projectRoot: string): Promise<PublicApiChangeDetection>;
@@ -0,0 +1,45 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ const execFileAsync = promisify(execFile);
4
+ const PUBLIC_SURFACE_PATH_PATTERNS = [
5
+ /(^|\/)(cli|types?|config)\.[cm]?[jt]s$/iu,
6
+ /(^|\/)(openapi|swagger|schema)(\/|[-_.])/iu,
7
+ /(^|\/)(api|commands?|flags?)(\/|[-_.])/iu,
8
+ /(^|\/)(package|tsconfig)\.json$/iu
9
+ ];
10
+ async function resolveDiffBase(projectRoot) {
11
+ try {
12
+ const { stdout } = await execFileAsync("git", ["rev-parse", "HEAD~1"], {
13
+ cwd: projectRoot
14
+ });
15
+ const base = stdout.trim();
16
+ return base.length > 0 ? base : null;
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
22
+ export async function detectPublicApiChanges(projectRoot) {
23
+ const base = await resolveDiffBase(projectRoot);
24
+ if (!base) {
25
+ return { triggered: false, changedFiles: [] };
26
+ }
27
+ try {
28
+ const range = `${base}..HEAD`;
29
+ const { stdout } = await execFileAsync("git", ["diff", "--name-only", range], {
30
+ cwd: projectRoot
31
+ });
32
+ const changedFiles = stdout
33
+ .split(/\r?\n/gu)
34
+ .map((line) => line.trim())
35
+ .filter((line) => line.length > 0)
36
+ .filter((filePath) => PUBLIC_SURFACE_PATH_PATTERNS.some((pattern) => pattern.test(filePath)));
37
+ return {
38
+ triggered: changedFiles.length > 0,
39
+ changedFiles
40
+ };
41
+ }
42
+ catch {
43
+ return { triggered: false, changedFiles: [] };
44
+ }
45
+ }
package/dist/policy.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { COMMAND_FILE_ORDER, RUNTIME_ROOT } from "./constants.js";
3
+ import { RUNTIME_ROOT } from "./constants.js";
4
+ import { FLOW_STAGES } from "./types.js";
4
5
  import { stageSchema, stagePolicyNeedles } from "./content/stage-schema.js";
5
6
  import { stageSkillFolder } from "./content/skills.js";
6
7
  import { exists } from "./fs-utils.js";
@@ -10,7 +11,7 @@ export async function policyChecks(projectRoot, options = {}) {
10
11
  const checks = [];
11
12
  const rules = [...POLICY_RULES];
12
13
  const activeHarnesses = new Set(options.harnesses && options.harnesses.length > 0 ? options.harnesses : ALL_HARNESSES);
13
- for (const stage of COMMAND_FILE_ORDER) {
14
+ for (const stage of FLOW_STAGES) {
14
15
  const folder = stageSkillFolder(stage);
15
16
  const schema = stageSchema(stage);
16
17
  const commandFile = `${RUNTIME_ROOT}/commands/${stage}.md`;
@@ -8,6 +8,7 @@ function activeArtifactsPath(projectRoot) {
8
8
  function retroArtifactPath(projectRoot) {
9
9
  return path.join(activeArtifactsPath(projectRoot), "09-retro.md");
10
10
  }
11
+ const RETRO_ARTIFACT_MTIME_FALLBACK_WINDOW_MS = 24 * 60 * 60 * 1000;
11
12
  function parseIsoTimestamp(value) {
12
13
  if (!value || value.trim().length === 0)
13
14
  return null;
@@ -35,8 +36,24 @@ export async function evaluateRetroGate(projectRoot, state) {
35
36
  }
36
37
  }
37
38
  let compoundEntries = state.retro.compoundEntries;
38
- const windowStartMs = parseIsoTimestamp(state.closeout.retroDraftedAt);
39
- const windowEndMs = parseIsoTimestamp(state.closeout.retroAcceptedAt) ?? parseIsoTimestamp(state.retro.completedAt);
39
+ let windowStartMs = parseIsoTimestamp(state.closeout.retroDraftedAt);
40
+ let windowEndMs = parseIsoTimestamp(state.closeout.retroAcceptedAt) ?? parseIsoTimestamp(state.retro.completedAt);
41
+ if (compoundEntries <= 0 &&
42
+ hasRetroArtifact &&
43
+ windowStartMs === null &&
44
+ windowEndMs === null) {
45
+ try {
46
+ const stats = await fs.stat(artifactFile);
47
+ const anchor = stats.mtimeMs;
48
+ if (Number.isFinite(anchor) && anchor > 0) {
49
+ windowStartMs = anchor - RETRO_ARTIFACT_MTIME_FALLBACK_WINDOW_MS;
50
+ windowEndMs = anchor + RETRO_ARTIFACT_MTIME_FALLBACK_WINDOW_MS;
51
+ }
52
+ }
53
+ catch {
54
+ // fallback scan remains disabled when mtime cannot be read
55
+ }
56
+ }
40
57
  const shouldFallbackScan = compoundEntries <= 0 && (windowStartMs !== null || windowEndMs !== null);
41
58
  const knowledgeFile = path.join(projectRoot, RUNTIME_ROOT, "knowledge.jsonl");
42
59
  if (shouldFallbackScan && (await exists(knowledgeFile))) {
@@ -73,7 +90,17 @@ export async function evaluateRetroGate(projectRoot, state) {
73
90
  compoundEntries = 0;
74
91
  }
75
92
  }
76
- const completed = required ? (hasRetroArtifact && compoundEntries > 0) : true;
93
+ // A retro is considered complete when either:
94
+ // - at least one compound learning was promoted during the retro window, or
95
+ // - the operator explicitly skipped retro or compound (`retroSkipped` /
96
+ // `compoundSkipped` recorded in the closeout substate) after reviewing
97
+ // the draft. Previously the gate required `compoundEntries > 0`
98
+ // unconditionally, which dead-locked ship closeout whenever the retro
99
+ // yielded no new patterns worth promoting.
100
+ const explicitSkip = Boolean(state.closeout.retroSkipped || state.closeout.compoundSkipped);
101
+ const completed = required
102
+ ? hasRetroArtifact && (compoundEntries > 0 || explicitSkip)
103
+ : true;
77
104
  return {
78
105
  required,
79
106
  completed,
@@ -1,9 +1,10 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { COMMAND_FILE_ORDER, RUNTIME_ROOT } from "./constants.js";
3
+ import { RUNTIME_ROOT } from "./constants.js";
4
4
  import { canTransition, createInitialCloseoutState, createInitialFlowState, isFlowTrack, skippedStagesForTrack, SHIP_SUBSTATES } from "./flow-state.js";
5
5
  import { ensureFeatureSystem, syncActiveFeatureSnapshot } from "./feature-system.js";
6
6
  import { ensureDir, exists, withDirectoryLock, writeFileSafe } from "./fs-utils.js";
7
+ import { FLOW_STAGES } from "./types.js";
7
8
  export class InvalidStageTransitionError extends Error {
8
9
  from;
9
10
  to;
@@ -17,7 +18,7 @@ export class InvalidStageTransitionError extends Error {
17
18
  const FLOW_STATE_REL_PATH = `${RUNTIME_ROOT}/state/flow-state.json`;
18
19
  const RUNS_DIR_REL_PATH = `${RUNTIME_ROOT}/runs`;
19
20
  const ACTIVE_ARTIFACTS_REL_PATH = `${RUNTIME_ROOT}/artifacts`;
20
- const FLOW_STAGE_SET = new Set(COMMAND_FILE_ORDER);
21
+ const FLOW_STAGE_SET = new Set(FLOW_STAGES);
21
22
  function validateFlowTransition(prev, next) {
22
23
  if (prev.activeRunId !== next.activeRunId) {
23
24
  // New run — only reset paths may change the runId, but those set allowReset.
@@ -85,7 +86,7 @@ function sanitizeGuardEvidence(value) {
85
86
  function sanitizeStageGateCatalog(value, fallback) {
86
87
  const uniqueStrings = (items) => [...new Set(items)];
87
88
  const next = {};
88
- for (const stage of COMMAND_FILE_ORDER) {
89
+ for (const stage of FLOW_STAGES) {
89
90
  const base = fallback[stage];
90
91
  next[stage] = {
91
92
  required: [...base.required],
@@ -100,7 +101,7 @@ function sanitizeStageGateCatalog(value, fallback) {
100
101
  return next;
101
102
  }
102
103
  const rawCatalog = value;
103
- for (const stage of COMMAND_FILE_ORDER) {
104
+ for (const stage of FLOW_STAGES) {
104
105
  const rawStage = rawCatalog[stage];
105
106
  if (!rawStage || typeof rawStage !== "object" || Array.isArray(rawStage)) {
106
107
  continue;
@@ -235,7 +236,7 @@ function sanitizeCloseoutState(value) {
235
236
  return fallback;
236
237
  }
237
238
  const typed = value;
238
- const shipSubstate = isShipSubstate(typed.shipSubstate) ? typed.shipSubstate : fallback.shipSubstate;
239
+ let shipSubstate = isShipSubstate(typed.shipSubstate) ? typed.shipSubstate : fallback.shipSubstate;
239
240
  const retroDraftedAt = typeof typed.retroDraftedAt === "string" ? typed.retroDraftedAt : undefined;
240
241
  const retroAcceptedAt = typeof typed.retroAcceptedAt === "string" ? typed.retroAcceptedAt : undefined;
241
242
  const retroSkipped = typeof typed.retroSkipped === "boolean" ? typed.retroSkipped : undefined;
@@ -246,6 +247,16 @@ function sanitizeCloseoutState(value) {
246
247
  const compoundPromoted = typeof promotedRaw === "number" && Number.isFinite(promotedRaw) && promotedRaw >= 0
247
248
  ? Math.floor(promotedRaw)
248
249
  : 0;
250
+ // Demote shipSubstate when its retro invariant is violated on disk. A
251
+ // hand-edited flow-state could claim `ready_to_archive` or `compound_review`
252
+ // without ever going through the retro step, which would let `archive`
253
+ // proceed and skip the gate. Compound completion is not independently
254
+ // tracked in all flows (some runs rely on knowledge.jsonl + the retro
255
+ // window), so we only demote when the retro leg is missing outright.
256
+ const retroDone = retroAcceptedAt !== undefined || retroSkipped === true;
257
+ if (!retroDone && (shipSubstate === "ready_to_archive" || shipSubstate === "compound_review")) {
258
+ shipSubstate = "retro_review";
259
+ }
249
260
  return {
250
261
  shipSubstate,
251
262
  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/dist/types.d.ts CHANGED
@@ -109,7 +109,7 @@ export interface TddPathConfig {
109
109
  export interface CompoundConfig {
110
110
  recurrenceThreshold?: number;
111
111
  }
112
- export interface VibyConfig {
112
+ export interface CclawConfig {
113
113
  version: string;
114
114
  flowVersion: string;
115
115
  harnesses: HarnessId[];
@@ -141,6 +141,7 @@ export interface VibyConfig {
141
141
  /**
142
142
  * Legacy alias for test-side path detection in workflow-guard.
143
143
  * Prefer `tdd.testPathPatterns` in new configs.
144
+ * @deprecated Use `tdd.testPathPatterns` instead.
144
145
  */
145
146
  tddTestGlobs?: string[];
146
147
  /** Path-pattern routing for TDD test/production write classification. */
@@ -173,6 +174,10 @@ export interface VibyConfig {
173
174
  */
174
175
  sliceReview?: SliceReviewConfig;
175
176
  }
177
+ /**
178
+ * @deprecated Use `CclawConfig` instead.
179
+ */
180
+ export type VibyConfig = CclawConfig;
176
181
  export interface TransitionRule {
177
182
  from: FlowStage;
178
183
  to: FlowStage;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cclaw-cli",
3
- "version": "0.46.15",
3
+ "version": "0.48.0",
4
4
  "description": "Installer-first flow toolkit for coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,6 +21,7 @@
21
21
  "test": "vitest run",
22
22
  "test:watch": "vitest",
23
23
  "test:coverage": "vitest run --coverage",
24
+ "test:mutation": "stryker run",
24
25
  "smoke:runtime": "npm run build && node scripts/smoke-init.mjs",
25
26
  "lint:hooks": "npm run build && node scripts/lint-generated-hooks.mjs",
26
27
  "build:harness-docs": "npm run build && node scripts/build-harness-docs.mjs",
@@ -44,6 +45,8 @@
44
45
  "yaml": "^2.8.1"
45
46
  },
46
47
  "devDependencies": {
48
+ "@stryker-mutator/core": "^9.6.1",
49
+ "@stryker-mutator/vitest-runner": "^9.6.1",
47
50
  "@types/node": "^24.7.2",
48
51
  "@vitest/coverage-v8": "^3.2.4",
49
52
  "typescript": "^5.9.3",