cclaw-cli 0.48.0 → 0.48.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.
package/dist/install.js CHANGED
@@ -44,7 +44,8 @@ import { ensureDir, exists, writeFileSafe } from "./fs-utils.js";
44
44
  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
- import { ensureRunSystem, readFlowState } from "./runs.js";
47
+ import { detectHarnesses } from "./init-detect.js";
48
+ import { CorruptFlowStateError, ensureRunSystem, readFlowState } from "./runs.js";
48
49
  import { FLOW_STAGES } from "./types.js";
49
50
  const OPENCODE_PLUGIN_REL_PATH = ".opencode/plugins/cclaw-plugin.mjs";
50
51
  const CURSOR_RULE_REL_PATH = ".cursor/rules/cclaw-workflow.mdc";
@@ -853,7 +854,24 @@ Drop this section if no hard rule applies. Keep it crisp:
853
854
  async function ensureSessionStateFiles(projectRoot) {
854
855
  const stateDir = runtimePath(projectRoot, "state");
855
856
  await ensureDir(stateDir);
856
- const flow = await readFlowState(projectRoot);
857
+ // If flow-state.json is corrupt, `readFlowState` quarantines the bad
858
+ // file and throws. During install we'd rather continue than abort:
859
+ // the user just asked to set up cclaw, and the corrupt file is already
860
+ // preserved next to the original path. Fall back to a fresh initial
861
+ // state so the rest of install completes and the user can inspect the
862
+ // `.corrupt-<timestamp>.json` quarantine afterwards.
863
+ let flow;
864
+ try {
865
+ flow = await readFlowState(projectRoot);
866
+ }
867
+ catch (err) {
868
+ if (err instanceof CorruptFlowStateError) {
869
+ flow = createInitialFlowState();
870
+ }
871
+ else {
872
+ throw err;
873
+ }
874
+ }
857
875
  const activityPath = path.join(stateDir, "stage-activity.jsonl");
858
876
  if (!(await exists(activityPath))) {
859
877
  await writeFileSafe(activityPath, "");
@@ -959,7 +977,7 @@ async function writeState(projectRoot, config, forceReset = false) {
959
977
  if (!forceReset && (await exists(statePath))) {
960
978
  return;
961
979
  }
962
- const state = createInitialFlowState("active", config.defaultTrack ?? "standard");
980
+ const state = createInitialFlowState({ track: config.defaultTrack ?? "standard" });
963
981
  await writeFileSafe(statePath, `${JSON.stringify(state, null, 2)}\n`);
964
982
  }
965
983
  async function writeAdapterManifest(projectRoot, harnesses) {
@@ -1021,6 +1039,14 @@ async function writeHarnessGapsState(projectRoot, harnesses) {
1021
1039
  break;
1022
1040
  }
1023
1041
  for (const event of missingHookEvents) {
1042
+ if (harness === "codex" && event === "precompact_digest") {
1043
+ // Codex CLI has no PreCompact event. Generic "schedule the script
1044
+ // manually" copy doesn't help; instead, point the agent at the
1045
+ // in-thread substitute that already exists in cclaw content
1046
+ // (`/cc-ops retro` reads the same digest the hook would emit).
1047
+ remediation.push("hook event precompact_digest → Codex has no PreCompact event; run `/cc-ops retro` in-thread before compaction instead of relying on a hook");
1048
+ continue;
1049
+ }
1024
1050
  remediation.push(`hook event ${event} → schedule the corresponding script manually or accept reduced observability`);
1025
1051
  }
1026
1052
  return {
@@ -1210,9 +1236,19 @@ export async function initCclaw(options) {
1210
1236
  }
1211
1237
  export async function syncCclaw(projectRoot) {
1212
1238
  const configExists = await exists(configPath(projectRoot));
1213
- const config = await readConfig(projectRoot);
1239
+ let config = await readConfig(projectRoot);
1214
1240
  if (!configExists) {
1215
- await writeConfig(projectRoot, createDefaultConfig(config.harnesses));
1241
+ // Prefer detected harness markers over the hardcoded default list.
1242
+ // Without this, a user running `cclaw sync` in a `.claude`-only
1243
+ // project ends up with a config that also enables cursor/opencode/
1244
+ // codex, which then fails doctor checks for missing shim folders.
1245
+ // Fall back to the previous default (config.harnesses) if no markers
1246
+ // are found so brand-new projects still bootstrap cleanly.
1247
+ const detected = await detectHarnesses(projectRoot);
1248
+ const harnesses = detected.length > 0 ? detected : config.harnesses;
1249
+ const defaultConfig = createDefaultConfig(harnesses);
1250
+ await writeConfig(projectRoot, defaultConfig);
1251
+ config = defaultConfig;
1216
1252
  }
1217
1253
  await materializeRuntime(projectRoot, config, false);
1218
1254
  }
@@ -1,22 +1,51 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { RUNTIME_ROOT } from "../constants.js";
3
+ import { RUNTIME_ROOT, SHIP_FINALIZATION_MODES } from "../constants.js";
4
4
  import { stageSchema } from "../content/stage-schema.js";
5
5
  import { appendDelegation, checkMandatoryDelegations } from "../delegation.js";
6
6
  import { readActiveFeature } from "../feature-system.js";
7
7
  import { verifyCompletedStagesGateClosure, verifyCurrentStageGateEvidence } from "../gate-evidence.js";
8
8
  import { extractMarkdownSectionBody, parseLearningsSection } from "../artifact-linter.js";
9
- import { isFlowTrack, nextStage } from "../flow-state.js";
9
+ import { getAvailableTransitions, getTransitionGuards, isFlowTrack } from "../flow-state.js";
10
10
  import { appendKnowledge } from "../knowledge-store.js";
11
11
  import { readFlowState, writeFlowState } from "../runs.js";
12
12
  import { FLOW_STAGES } from "../types.js";
13
13
  function unique(values) {
14
14
  return [...new Set(values)];
15
15
  }
16
+ function resolveSuccessorTransition(stage, track, transitionTargets, satisfiedGuards, selectedTransitionGuards) {
17
+ const natural = transitionTargets[0] ?? null;
18
+ const specialTargets = transitionTargets.filter((target) => target !== natural);
19
+ for (const target of specialTargets) {
20
+ const guards = getTransitionGuards(stage, target, track);
21
+ if (guards.length === 0)
22
+ continue;
23
+ const selectedSpecial = guards.some((guard) => selectedTransitionGuards.has(guard));
24
+ if (!selectedSpecial)
25
+ continue;
26
+ if (guards.every((guard) => satisfiedGuards.has(guard))) {
27
+ return target;
28
+ }
29
+ }
30
+ if (natural) {
31
+ const guards = getTransitionGuards(stage, natural, track);
32
+ if (guards.every((guard) => satisfiedGuards.has(guard))) {
33
+ return natural;
34
+ }
35
+ }
36
+ for (const target of specialTargets) {
37
+ const guards = getTransitionGuards(stage, target, track);
38
+ if (guards.every((guard) => satisfiedGuards.has(guard))) {
39
+ return target;
40
+ }
41
+ }
42
+ return natural;
43
+ }
16
44
  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
45
  const SHA_WITH_LABEL_PATTERN = /\b(?:sha|commit)(?:\s*[:=]|\s+)\s*[0-9a-f]{7,40}\b/iu;
18
46
  const PASS_STATUS_PATTERN = /\b(?:pass|passed|green|ok)\b/iu;
19
- const SHIP_FINALIZATION_MODE_PATTERN = /\bFINALIZE_(?:MERGE_LOCAL|OPEN_PR|QUEUE|HANDOFF|SKIP)\b/u;
47
+ const SHIP_FINALIZATION_MODE_PATTERN = new RegExp(`\\b(?:${SHIP_FINALIZATION_MODES.join("|")})\\b`, "u");
48
+ const SHIP_FINALIZATION_MODE_HINT = SHIP_FINALIZATION_MODES.join(", ");
20
49
  // Per-gate validators keyed by `${stage}:${gateId}`. Returning a non-null
21
50
  // string surfaces the reason as an `advance-stage` failure so evidence is
22
51
  // guaranteed to carry the structural breadcrumbs downstream tooling
@@ -36,7 +65,7 @@ const GATE_EVIDENCE_VALIDATORS = {
36
65
  },
37
66
  "ship:ship_finalization_executed": (evidence) => {
38
67
  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`).";
68
+ return `must name the finalization mode that ran (for example ${SHIP_FINALIZATION_MODE_HINT}).`;
40
69
  }
41
70
  return null;
42
71
  }
@@ -395,10 +424,16 @@ async function runAdvanceStage(projectRoot, args, io) {
395
424
  const requiredGateIds = schema.requiredGates
396
425
  .filter((gate) => gate.tier === "required")
397
426
  .map((gate) => gate.id);
427
+ const transitionTargets = getAvailableTransitions(args.stage, flowState.track).map((rule) => rule.to);
398
428
  const allowedGateIds = new Set(schema.requiredGates.map((gate) => gate.id));
429
+ const transitionGuardIds = new Set(transitionTargets
430
+ .flatMap((target) => getTransitionGuards(args.stage, target, flowState.track))
431
+ .filter((guardId) => !allowedGateIds.has(guardId)));
432
+ const selectableGateIds = new Set([...allowedGateIds, ...transitionGuardIds]);
399
433
  const selectedGateIds = args.passedGateIds.length > 0
400
- ? args.passedGateIds.filter((gateId) => allowedGateIds.has(gateId))
434
+ ? args.passedGateIds.filter((gateId) => selectableGateIds.has(gateId))
401
435
  : requiredGateIds;
436
+ const selectedTransitionGuards = selectedGateIds.filter((gateId) => transitionGuardIds.has(gateId));
402
437
  const missingRequired = requiredGateIds.filter((gateId) => !selectedGateIds.includes(gateId));
403
438
  if (missingRequired.length > 0) {
404
439
  io.stderr.write(`cclaw internal advance-stage: required gates not selected as passed: ${missingRequired.join(", ")}.\n`);
@@ -436,7 +471,8 @@ async function runAdvanceStage(projectRoot, args, io) {
436
471
  ...nextPassed.filter((gateId) => conditional.has(gateId)),
437
472
  ...nextBlocked.filter((gateId) => conditional.has(gateId))
438
473
  ]);
439
- const missingGuardEvidence = nextPassed.filter((gateId) => {
474
+ const guardEvidenceGateIds = unique([...nextPassed, ...selectedTransitionGuards]);
475
+ const missingGuardEvidence = guardEvidenceGateIds.filter((gateId) => {
440
476
  const existing = flowState.guardEvidence[gateId];
441
477
  if (typeof existing === "string" && existing.trim().length > 0) {
442
478
  return false;
@@ -464,7 +500,7 @@ async function runAdvanceStage(projectRoot, args, io) {
464
500
  return 1;
465
501
  }
466
502
  const nextGuardEvidence = { ...flowState.guardEvidence };
467
- for (const gateId of nextPassed) {
503
+ for (const gateId of guardEvidenceGateIds) {
468
504
  const provided = args.evidenceByGate[gateId];
469
505
  if (typeof provided === "string" && provided.trim().length > 0) {
470
506
  nextGuardEvidence[gateId] = provided.trim();
@@ -508,7 +544,8 @@ async function runAdvanceStage(projectRoot, args, io) {
508
544
  io.stderr.write(`cclaw internal advance-stage: learnings harvest failed for "${schema.artifactFile}". ${learningsHarvest.details}\n`);
509
545
  return 1;
510
546
  }
511
- const successor = nextStage(args.stage, flowState.track);
547
+ const satisfiedGuards = new Set([...nextPassed, ...selectedTransitionGuards]);
548
+ const successor = resolveSuccessorTransition(args.stage, flowState.track, transitionTargets, satisfiedGuards, new Set(selectedTransitionGuards));
512
549
  const completedStages = flowState.completedStages.includes(args.stage)
513
550
  ? [...flowState.completedStages]
514
551
  : [...flowState.completedStages, args.stage];
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { RUNTIME_ROOT } from "./constants.js";
4
- import { withDirectoryLock } from "./fs-utils.js";
4
+ import { stripBom, withDirectoryLock } from "./fs-utils.js";
5
5
  import { FLOW_STAGES } from "./types.js";
6
6
  const KNOWLEDGE_TYPE_SET = new Set(["rule", "pattern", "lesson", "compound"]);
7
7
  const KNOWLEDGE_CONFIDENCE_SET = new Set(["high", "medium", "low"]);
@@ -179,7 +179,7 @@ export function materializeKnowledgeEntry(seed, defaults = {}) {
179
179
  async function readExistingKnowledgeKeys(filePath) {
180
180
  const keys = new Set();
181
181
  try {
182
- const raw = await fs.readFile(filePath, "utf8");
182
+ const raw = stripBom(await fs.readFile(filePath, "utf8"));
183
183
  const lines = raw.split(/\r?\n/u).map((line) => line.trim()).filter((line) => line.length > 0);
184
184
  for (const line of lines) {
185
185
  try {
@@ -1,14 +1,20 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { RUNTIME_ROOT } from "./constants.js";
4
- import { exists } from "./fs-utils.js";
4
+ import { exists, stripBom } from "./fs-utils.js";
5
5
  function activeArtifactsPath(projectRoot) {
6
6
  return path.join(projectRoot, RUNTIME_ROOT, "artifacts");
7
7
  }
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
+ // Fallback window for compound-entry scanning when `retroDraftedAt` /
12
+ // `retroAcceptedAt` are not set (legacy runs or imports): use the retro
13
+ // artifact's mtime ± 7 days. 24h was too narrow for long-running retros
14
+ // that are edited over several days or runs imported from another
15
+ // machine with slightly different clocks; 7 days is still tight enough
16
+ // that entries from an unrelated future run are excluded.
17
+ const RETRO_ARTIFACT_MTIME_FALLBACK_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
12
18
  function parseIsoTimestamp(value) {
13
19
  if (!value || value.trim().length === 0)
14
20
  return null;
@@ -58,7 +64,7 @@ export async function evaluateRetroGate(projectRoot, state) {
58
64
  const knowledgeFile = path.join(projectRoot, RUNTIME_ROOT, "knowledge.jsonl");
59
65
  if (shouldFallbackScan && (await exists(knowledgeFile))) {
60
66
  try {
61
- const raw = await fs.readFile(knowledgeFile, "utf8");
67
+ const raw = stripBom(await fs.readFile(knowledgeFile, "utf8"));
62
68
  compoundEntries = 0;
63
69
  for (const line of raw.split(/\r?\n/)) {
64
70
  const trimmed = line.trim();
@@ -90,17 +96,20 @@ export async function evaluateRetroGate(projectRoot, state) {
90
96
  compoundEntries = 0;
91
97
  }
92
98
  }
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;
99
+ // A retro is considered complete when any of:
100
+ // - the retro artifact exists AND (at least one compound learning was
101
+ // promoted during the retro window OR compound was explicitly skipped
102
+ // after reviewing the draft), or
103
+ // - the operator explicitly skipped the retro step itself
104
+ // (`retroSkipped === true` with a reason). `retroSkipped` is an
105
+ // operator-level override of the artifact requirement, so it must
106
+ // bypass `hasRetroArtifact` otherwise a run that legitimately had
107
+ // nothing worth retro-ing dead-locks at closeout waiting for a
108
+ // file that will never exist.
109
+ const retroSkipped = state.closeout.retroSkipped === true;
110
+ const compoundSkipped = state.closeout.compoundSkipped === true;
111
+ const artifactPathComplete = hasRetroArtifact && (compoundEntries > 0 || compoundSkipped);
112
+ const completed = required ? retroSkipped || artifactPathComplete : true;
104
113
  return {
105
114
  required,
106
115
  completed,
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
  import { RUNTIME_ROOT } from "./constants.js";
4
4
  import { createInitialFlowState } from "./flow-state.js";
5
5
  import { readActiveFeature, syncActiveFeatureSnapshot } from "./feature-system.js";
6
- import { ensureDir, exists, writeFileSafe } from "./fs-utils.js";
6
+ import { ensureDir, exists, withDirectoryLock, writeFileSafe } from "./fs-utils.js";
7
7
  import { evaluateRetroGate } from "./retro-gate.js";
8
8
  import { ensureRunSystem, readFlowState, writeFlowState } from "./run-persistence.js";
9
9
  const RUNS_DIR_REL_PATH = `${RUNTIME_ROOT}/runs`;
@@ -17,6 +17,12 @@ const STATE_SNAPSHOT_EXCLUDE = new Set([
17
17
  const DELEGATION_LOG_FILE = "delegation-log.json";
18
18
  const TDD_CYCLE_LOG_FILE = "tdd-cycle-log.jsonl";
19
19
  const RECONCILIATION_NOTICES_FILE = "reconciliation-notices.json";
20
+ const CRITICAL_STATE_SNAPSHOT_FILES = new Set([
21
+ "flow-state.json",
22
+ DELEGATION_LOG_FILE,
23
+ TDD_CYCLE_LOG_FILE,
24
+ RECONCILIATION_NOTICES_FILE
25
+ ]);
20
26
  function runsRoot(projectRoot) {
21
27
  return path.join(projectRoot, RUNS_DIR_REL_PATH);
22
28
  }
@@ -26,6 +32,9 @@ function activeArtifactsPath(projectRoot) {
26
32
  function stateDirPath(projectRoot) {
27
33
  return path.join(projectRoot, STATE_DIR_REL_PATH);
28
34
  }
35
+ function archiveLockPath(projectRoot) {
36
+ return path.join(projectRoot, RUNTIME_ROOT, "state", ".archive.lock");
37
+ }
29
38
  async function snapshotStateDirectory(projectRoot, destinationRoot) {
30
39
  const sourceDir = stateDirPath(projectRoot);
31
40
  if (!(await exists(sourceDir))) {
@@ -57,8 +66,12 @@ async function snapshotStateDirectory(projectRoot, destinationRoot) {
57
66
  copied.push(entry.name);
58
67
  }
59
68
  }
60
- catch {
61
- // best-effort snapshot; continue on individual failures
69
+ catch (error) {
70
+ if (CRITICAL_STATE_SNAPSHOT_FILES.has(entry.name)) {
71
+ const details = error instanceof Error ? error.message : String(error);
72
+ throw new Error(`Archive snapshot failed for critical state file "${entry.name}" (${details}).`);
73
+ }
74
+ // Non-critical snapshot files are best-effort and may be skipped.
62
75
  }
63
76
  }
64
77
  return copied.sort((a, b) => a.localeCompare(b));
@@ -147,97 +160,155 @@ export async function listRuns(projectRoot) {
147
160
  }
148
161
  export async function archiveRun(projectRoot, featureName, options = {}) {
149
162
  await ensureRunSystem(projectRoot);
150
- const activeFeature = await readActiveFeature(projectRoot);
151
- const artifactsDir = activeArtifactsPath(projectRoot);
152
- const runsDir = runsRoot(projectRoot);
153
- await ensureDir(runsDir);
154
- await ensureDir(artifactsDir);
155
- const feature = (featureName?.trim() && featureName.trim().length > 0)
156
- ? featureName.trim()
157
- : await inferFeatureNameFromArtifacts(projectRoot);
158
- const archiveBaseId = `${toArchiveDate()}-${slugifyFeatureName(feature)}`;
159
- const archiveId = await uniqueArchiveId(projectRoot, archiveBaseId);
160
- const archivePath = path.join(runsDir, archiveId);
161
- const archiveArtifactsPath = path.join(archivePath, "artifacts");
162
- let sourceState = await readFlowState(projectRoot);
163
- const retroGate = await evaluateRetroGate(projectRoot, sourceState);
164
- const shipCompleted = sourceState.completedStages.includes("ship");
165
- const skipRetro = options.skipRetro === true;
166
- const skipRetroReason = options.skipRetroReason?.trim();
167
- if (skipRetro && (!skipRetroReason || skipRetroReason.length === 0)) {
168
- throw new Error("archive --skip-retro requires --retro-reason=<text>.");
169
- }
170
- const retroSkippedInCloseout = sourceState.closeout.retroSkipped === true &&
171
- typeof sourceState.closeout.retroSkipReason === "string" &&
172
- sourceState.closeout.retroSkipReason.trim().length > 0;
173
- const readyForArchive = sourceState.closeout.shipSubstate === "ready_to_archive";
174
- if (shipCompleted && !readyForArchive && !skipRetro) {
175
- throw new Error("Archive blocked: closeout is not ready_to_archive. " +
176
- "Resume /cc-next until closeout reaches ready_to_archive, " +
177
- "or run `cclaw archive --skip-retro --retro-reason=<text>` for CLI-only flows.");
178
- }
179
- if (retroGate.required && !retroGate.completed && !skipRetro && !retroSkippedInCloseout) {
180
- throw new Error("Archive blocked: retro gate is required after ship completion. " +
181
- "Run /cc-next (auto-runs retro) or, for CLI-only flows, re-run `cclaw archive --skip-retro --retro-reason=<text>`.");
182
- }
183
- if (retroGate.completed) {
184
- const completedAt = sourceState.retro.completedAt ?? new Date().toISOString();
185
- sourceState = {
186
- ...sourceState,
187
- retro: {
188
- required: retroGate.required,
189
- completedAt,
190
- compoundEntries: retroGate.compoundEntries
191
- }
163
+ return withDirectoryLock(archiveLockPath(projectRoot), async () => {
164
+ const activeFeature = await readActiveFeature(projectRoot);
165
+ const artifactsDir = activeArtifactsPath(projectRoot);
166
+ const runsDir = runsRoot(projectRoot);
167
+ await ensureDir(runsDir);
168
+ await ensureDir(artifactsDir);
169
+ const feature = (featureName?.trim() && featureName.trim().length > 0)
170
+ ? featureName.trim()
171
+ : await inferFeatureNameFromArtifacts(projectRoot);
172
+ const archiveBaseId = `${toArchiveDate()}-${slugifyFeatureName(feature)}`;
173
+ const archiveId = await uniqueArchiveId(projectRoot, archiveBaseId);
174
+ const archivePath = path.join(runsDir, archiveId);
175
+ const archiveArtifactsPath = path.join(archivePath, "artifacts");
176
+ let sourceState = await readFlowState(projectRoot);
177
+ const retroGate = await evaluateRetroGate(projectRoot, sourceState);
178
+ const shipCompleted = sourceState.completedStages.includes("ship");
179
+ const skipRetro = options.skipRetro === true;
180
+ const skipRetroReason = options.skipRetroReason?.trim();
181
+ if (skipRetro && (!skipRetroReason || skipRetroReason.length === 0)) {
182
+ throw new Error("archive --skip-retro requires --retro-reason=<text>.");
183
+ }
184
+ const retroSkippedInCloseout = sourceState.closeout.retroSkipped === true &&
185
+ typeof sourceState.closeout.retroSkipReason === "string" &&
186
+ sourceState.closeout.retroSkipReason.trim().length > 0;
187
+ const readyForArchive = sourceState.closeout.shipSubstate === "ready_to_archive";
188
+ const inShipCloseout = sourceState.currentStage === "ship";
189
+ if (inShipCloseout && skipRetro) {
190
+ throw new Error("Archive blocked: --skip-retro is not allowed while current stage is ship. " +
191
+ "Complete closeout to ready_to_archive via /cc-next.");
192
+ }
193
+ if (inShipCloseout && !readyForArchive) {
194
+ throw new Error("Archive blocked: closeout is not ready_to_archive. " +
195
+ "Resume /cc-next until closeout reaches ready_to_archive.");
196
+ }
197
+ if (shipCompleted && !readyForArchive && !skipRetro) {
198
+ throw new Error("Archive blocked: closeout is not ready_to_archive. " +
199
+ "Resume /cc-next until closeout reaches ready_to_archive, " +
200
+ "or run `cclaw archive --skip-retro --retro-reason=<text>` for CLI-only flows.");
201
+ }
202
+ if (retroGate.required && !retroGate.completed && !skipRetro && !retroSkippedInCloseout) {
203
+ throw new Error("Archive blocked: retro gate is required after ship completion. " +
204
+ "Run /cc-next (auto-runs retro) or, for CLI-only flows, re-run `cclaw archive --skip-retro --retro-reason=<text>`.");
205
+ }
206
+ if (retroGate.completed) {
207
+ const completedAt = sourceState.retro.completedAt ?? new Date().toISOString();
208
+ sourceState = {
209
+ ...sourceState,
210
+ retro: {
211
+ required: retroGate.required,
212
+ completedAt,
213
+ compoundEntries: retroGate.compoundEntries
214
+ }
215
+ };
216
+ await writeFlowState(projectRoot, sourceState, { allowReset: true });
217
+ }
218
+ const retroSummary = {
219
+ required: retroGate.required,
220
+ completed: retroGate.completed,
221
+ skipped: skipRetro || retroSkippedInCloseout,
222
+ skipReason: skipRetro
223
+ ? skipRetroReason
224
+ : retroSkippedInCloseout
225
+ ? sourceState.closeout.retroSkipReason
226
+ : undefined,
227
+ compoundEntries: retroGate.compoundEntries
192
228
  };
193
- await writeFlowState(projectRoot, sourceState, { allowReset: true });
194
- }
195
- const retroSummary = {
196
- required: retroGate.required,
197
- completed: retroGate.completed,
198
- skipped: skipRetro || retroSkippedInCloseout,
199
- skipReason: skipRetro
200
- ? skipRetroReason
201
- : retroSkippedInCloseout
202
- ? sourceState.closeout.retroSkipReason
203
- : undefined,
204
- compoundEntries: retroGate.compoundEntries
205
- };
206
- await ensureDir(archivePath);
207
- await fs.rename(artifactsDir, archiveArtifactsPath);
208
- await ensureDir(artifactsDir);
209
- const archiveStatePath = path.join(archivePath, "state");
210
- const snapshottedStateFiles = await snapshotStateDirectory(projectRoot, archiveStatePath);
211
- const resetState = createInitialFlowState();
212
- await writeFlowState(projectRoot, resetState, { allowReset: true });
213
- await resetCarryoverStateFiles(projectRoot, resetState.activeRunId);
214
- const archivedAt = new Date().toISOString();
215
- const manifest = {
216
- version: 1,
217
- archiveId,
218
- archivedAt,
219
- featureName: feature,
220
- activeFeature,
221
- sourceRunId: sourceState.activeRunId,
222
- sourceCurrentStage: sourceState.currentStage,
223
- sourceCompletedStages: sourceState.completedStages,
224
- snapshottedStateFiles,
225
- retro: retroSummary
226
- };
227
- await writeFileSafe(path.join(archivePath, "archive-manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
228
- const knowledgeStats = await readKnowledgeStats(projectRoot);
229
- await syncActiveFeatureSnapshot(projectRoot);
230
- return {
231
- archiveId,
232
- archivePath,
233
- archivedAt,
234
- featureName: feature,
235
- activeFeature,
236
- resetState,
237
- snapshottedStateFiles,
238
- knowledge: knowledgeStats,
239
- retro: retroSummary
240
- };
229
+ await ensureDir(archivePath);
230
+ // Drop an `.archive-in-progress` sentinel immediately so that a crash
231
+ // between the artifact rename and the final manifest write leaves a
232
+ // recoverable marker (doctor surfaces these; re-running archive on an
233
+ // orphan attempts to complete or roll back). The sentinel is removed
234
+ // only after the manifest lands successfully.
235
+ const sentinelPath = path.join(archivePath, ".archive-in-progress");
236
+ const archivedAt = new Date().toISOString();
237
+ await writeFileSafe(sentinelPath, `${JSON.stringify({ archiveId, startedAt: archivedAt, sourceRunId: sourceState.activeRunId }, null, 2)}\n`);
238
+ const stateBeforeReset = sourceState;
239
+ let artifactsMoved = false;
240
+ let stateReset = false;
241
+ try {
242
+ await fs.rename(artifactsDir, archiveArtifactsPath);
243
+ artifactsMoved = true;
244
+ await ensureDir(artifactsDir);
245
+ const archiveStatePath = path.join(archivePath, "state");
246
+ const snapshottedStateFiles = await snapshotStateDirectory(projectRoot, archiveStatePath);
247
+ const resetState = createInitialFlowState();
248
+ await writeFlowState(projectRoot, resetState, { allowReset: true });
249
+ stateReset = true;
250
+ await resetCarryoverStateFiles(projectRoot, resetState.activeRunId);
251
+ const manifest = {
252
+ version: 1,
253
+ archiveId,
254
+ archivedAt,
255
+ featureName: feature,
256
+ activeFeature,
257
+ sourceRunId: sourceState.activeRunId,
258
+ sourceCurrentStage: sourceState.currentStage,
259
+ sourceCompletedStages: sourceState.completedStages,
260
+ snapshottedStateFiles,
261
+ retro: retroSummary
262
+ };
263
+ await writeFileSafe(path.join(archivePath, "archive-manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
264
+ // Manifest landed sentinel is no longer needed.
265
+ await fs.unlink(sentinelPath).catch(() => undefined);
266
+ const knowledgeStats = await readKnowledgeStats(projectRoot);
267
+ await syncActiveFeatureSnapshot(projectRoot);
268
+ return {
269
+ archiveId,
270
+ archivePath,
271
+ archivedAt,
272
+ featureName: feature,
273
+ activeFeature,
274
+ resetState,
275
+ snapshottedStateFiles,
276
+ knowledge: knowledgeStats,
277
+ retro: retroSummary
278
+ };
279
+ }
280
+ catch (err) {
281
+ // Best-effort rollback: if artifacts were moved but the subsequent
282
+ // steps failed, put artifacts back so the user is not left without
283
+ // a working run. The sentinel is intentionally left behind for
284
+ // inspection; doctor surfaces it.
285
+ if (artifactsMoved) {
286
+ try {
287
+ await fs.rm(artifactsDir, { recursive: true, force: true });
288
+ await fs.rename(archiveArtifactsPath, artifactsDir);
289
+ }
290
+ catch {
291
+ // Rollback failed — sentinel + orphaned archive dir will be
292
+ // surfaced by doctor and can be reconciled manually.
293
+ }
294
+ }
295
+ if (stateReset) {
296
+ try {
297
+ await writeFlowState(projectRoot, stateBeforeReset, { allowReset: true });
298
+ await resetCarryoverStateFiles(projectRoot, stateBeforeReset.activeRunId);
299
+ }
300
+ catch {
301
+ // If rollback of state fails, keep sentinel + archive remnants for
302
+ // manual reconciliation.
303
+ }
304
+ }
305
+ throw err;
306
+ }
307
+ }, {
308
+ retries: 400,
309
+ retryDelayMs: 25,
310
+ staleAfterMs: 120_000
311
+ });
241
312
  }
242
313
  const KNOWLEDGE_SOFT_THRESHOLD = 50;
243
314
  async function readKnowledgeStats(projectRoot) {
@@ -12,12 +12,19 @@ export interface WriteFlowStateOptions {
12
12
  */
13
13
  allowReset?: boolean;
14
14
  }
15
+ export interface ReadFlowStateOptions {
16
+ /**
17
+ * When false, skip feature-system auto-repair writes and read flow-state in
18
+ * pure diagnostic mode.
19
+ */
20
+ repairFeatureSystem?: boolean;
21
+ }
15
22
  export declare class CorruptFlowStateError extends Error {
16
23
  readonly statePath: string;
17
24
  readonly quarantinedPath: string;
18
25
  constructor(statePath: string, quarantinedPath: string, cause: unknown);
19
26
  }
20
- export declare function readFlowState(projectRoot: string): Promise<FlowState>;
27
+ export declare function readFlowState(projectRoot: string, options?: ReadFlowStateOptions): Promise<FlowState>;
21
28
  export declare function writeFlowState(projectRoot: string, state: FlowState, options?: WriteFlowStateOptions): Promise<void>;
22
29
  interface EnsureRunSystemOptions {
23
30
  createIfMissing?: boolean;
@@ -24,6 +24,13 @@ function validateFlowTransition(prev, next) {
24
24
  // New run — only reset paths may change the runId, but those set allowReset.
25
25
  throw new InvalidStageTransitionError(prev.currentStage, next.currentStage, `cannot change activeRunId from "${prev.activeRunId}" to "${next.activeRunId}" without allowReset.`);
26
26
  }
27
+ // Track is immutable within a single run: stage schemas, gate sets, and
28
+ // cross-stage reads all branch on track. Silently flipping the track
29
+ // mid-run would let completed stages satisfy one gate tier and the
30
+ // current stage re-read the catalog under a different tier.
31
+ if (prev.track !== next.track) {
32
+ throw new InvalidStageTransitionError(prev.currentStage, next.currentStage, `cannot change track from "${prev.track}" to "${next.track}" mid-run (activeRunId="${prev.activeRunId}"). Archive the run and start a new one to switch tracks.`);
33
+ }
27
34
  for (const completed of prev.completedStages) {
28
35
  if (!next.completedStages.includes(completed)) {
29
36
  throw new InvalidStageTransitionError(prev.currentStage, next.currentStage, `completedStages must be monotonic: stage "${completed}" was previously completed but is missing from the new state.`);
@@ -270,7 +277,7 @@ function sanitizeCloseoutState(value) {
270
277
  }
271
278
  function coerceFlowState(parsed) {
272
279
  const track = coerceTrack(parsed.track);
273
- const next = createInitialFlowState("active", track);
280
+ const next = createInitialFlowState({ track });
274
281
  const activeRunIdRaw = parsed.activeRunId;
275
282
  const activeRunId = typeof activeRunIdRaw === "string" && activeRunIdRaw.trim().length > 0
276
283
  ? activeRunIdRaw.trim()
@@ -326,8 +333,10 @@ async function quarantineCorruptState(statePath, cause) {
326
333
  }
327
334
  throw new CorruptFlowStateError(statePath, quarantinedPath, cause);
328
335
  }
329
- export async function readFlowState(projectRoot) {
330
- await ensureFeatureSystem(projectRoot);
336
+ export async function readFlowState(projectRoot, options = {}) {
337
+ if (options.repairFeatureSystem !== false) {
338
+ await ensureFeatureSystem(projectRoot);
339
+ }
331
340
  const statePath = flowStatePath(projectRoot);
332
341
  if (!(await exists(statePath))) {
333
342
  return createInitialFlowState();
@@ -368,8 +377,7 @@ export async function writeFlowState(projectRoot, state, options = {}) {
368
377
  if (err instanceof InvalidStageTransitionError) {
369
378
  throw err;
370
379
  }
371
- // A corrupt prior file is surfaced by readFlowState elsewhere; don't
372
- // block a legitimate write attempt on parse errors here.
380
+ throw new Error(`cannot validate flow-state transition because ${FLOW_STATE_REL_PATH} is unreadable or corrupt (${err instanceof Error ? err.message : String(err)}). Run \`cclaw doctor\` and reconcile the state before retrying.`);
373
381
  }
374
382
  }
375
383
  const safe = coerceFlowState({ ...state });