cclaw-cli 0.48.1 → 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.
@@ -1,6 +1,6 @@
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 { exists } from "./fs-utils.js";
5
5
  import { stageSchema } from "./content/stage-schema.js";
6
6
  import { FLOW_STAGES } from "./types.js";
@@ -137,13 +137,7 @@ function tokensFromRule(rule) {
137
137
  return [...new Set(allCaps)];
138
138
  }
139
139
  if (/finalization enum token/iu.test(rule)) {
140
- return [
141
- "FINALIZE_MERGE_LOCAL",
142
- "FINALIZE_OPEN_PR",
143
- "FINALIZE_KEEP_BRANCH",
144
- "FINALIZE_DISCARD_BRANCH",
145
- "FINALIZE_NO_VCS"
146
- ];
140
+ return [...SHIP_FINALIZATION_MODES];
147
141
  }
148
142
  if (/final verdict/iu.test(rule)) {
149
143
  return ["APPROVED", "APPROVED_WITH_CONCERNS", "BLOCKED"];
@@ -3,6 +3,12 @@ import type { FlowStage, HarnessId } from "./types.js";
3
3
  export declare const RUNTIME_ROOT = ".cclaw";
4
4
  export declare const CCLAW_VERSION: string;
5
5
  export declare const FLOW_VERSION = "1.0.0";
6
+ /**
7
+ * Canonical ship finalization enums used across stage schema, linting, and
8
+ * runtime gate evidence checks.
9
+ */
10
+ export declare const SHIP_FINALIZATION_MODES: readonly ["FINALIZE_MERGE_LOCAL", "FINALIZE_OPEN_PR", "FINALIZE_KEEP_BRANCH", "FINALIZE_DISCARD_BRANCH", "FINALIZE_NO_VCS"];
11
+ export type ShipFinalizationMode = (typeof SHIP_FINALIZATION_MODES)[number];
6
12
  export declare const DEFAULT_HARNESSES: HarnessId[];
7
13
  /**
8
14
  * Evals subtree. Scaffolds the directory layout and a default config.yaml; the
package/dist/constants.js CHANGED
@@ -35,6 +35,17 @@ function readPackageVersion() {
35
35
  }
36
36
  export const CCLAW_VERSION = readPackageVersion();
37
37
  export const FLOW_VERSION = "1.0.0";
38
+ /**
39
+ * Canonical ship finalization enums used across stage schema, linting, and
40
+ * runtime gate evidence checks.
41
+ */
42
+ export const SHIP_FINALIZATION_MODES = [
43
+ "FINALIZE_MERGE_LOCAL",
44
+ "FINALIZE_OPEN_PR",
45
+ "FINALIZE_KEEP_BRANCH",
46
+ "FINALIZE_DISCARD_BRANCH",
47
+ "FINALIZE_NO_VCS"
48
+ ];
38
49
  export const DEFAULT_HARNESSES = [
39
50
  "claude",
40
51
  "cursor",
@@ -1,3 +1,4 @@
1
+ import { SHIP_FINALIZATION_MODES } from "../../constants.js";
1
2
  // ---------------------------------------------------------------------------
2
3
  // SHIP — reference: superpowers finishing-a-development-branch + gstack /ship
3
4
  // ---------------------------------------------------------------------------
@@ -93,11 +94,7 @@ export const SHIP = {
93
94
  "Pre-Ship Checks",
94
95
  "Release Notes",
95
96
  "Rollback Plan",
96
- "FINALIZE_MERGE_LOCAL",
97
- "FINALIZE_OPEN_PR",
98
- "FINALIZE_KEEP_BRANCH",
99
- "FINALIZE_DISCARD_BRANCH",
100
- "FINALIZE_NO_VCS"
97
+ ...SHIP_FINALIZATION_MODES
101
98
  ],
102
99
  artifactFile: "08-ship.md",
103
100
  // `done` exits the stage pipeline. Archive semantics are handled by the
@@ -1,6 +1,7 @@
1
- import { CCLAW_VERSION } from "../constants.js";
1
+ import { CCLAW_VERSION, SHIP_FINALIZATION_MODES } from "../constants.js";
2
2
  import { orderedStageSchemas } from "./stage-schema.js";
3
3
  import { FLOW_STAGES } from "../types.js";
4
+ const SHIP_FINALIZATION_ENUM_LINES = SHIP_FINALIZATION_MODES.map((mode) => ` - ${mode}`).join("\n");
4
5
  export const ARTIFACT_TEMPLATES = {
5
6
  "01-brainstorm.md": `---
6
7
  stage: brainstorm
@@ -645,11 +646,7 @@ inputs_hash: sha256:pending
645
646
 
646
647
  ## Finalization
647
648
  - Selected enum (exactly one):
648
- - FINALIZE_MERGE_LOCAL
649
- - FINALIZE_OPEN_PR
650
- - FINALIZE_KEEP_BRANCH
651
- - FINALIZE_DISCARD_BRANCH
652
- - FINALIZE_NO_VCS
649
+ ${SHIP_FINALIZATION_ENUM_LINES}
653
650
  - Selected label (A/B/C/D/E):
654
651
  - Execution result:
655
652
  - PR URL / merge commit / kept branch / discard confirmation:
@@ -53,6 +53,8 @@ export type DelegationEntry = {
53
53
  retryCount?: number;
54
54
  /** Optional references to evidence anchors in artifacts. */
55
55
  evidenceRefs?: string[];
56
+ /** Optional skill marker used for role-specific mandatory checks. */
57
+ skill?: string;
56
58
  /**
57
59
  * Fulfillment mode this entry was executed under. Omitted on legacy rows
58
60
  * (treated as `"isolated"` for Claude, otherwise inferred from the active
@@ -85,7 +87,9 @@ export declare function appendDelegation(projectRoot: string, entry: DelegationE
85
87
  * strongest guarantee.
86
88
  */
87
89
  export declare function expectedFulfillmentMode(fallbacks: SubagentFallback[]): DelegationFulfillmentMode;
88
- export declare function checkMandatoryDelegations(projectRoot: string, stage: FlowStage): Promise<{
90
+ export declare function checkMandatoryDelegations(projectRoot: string, stage: FlowStage, options?: {
91
+ repairFeatureSystem?: boolean;
92
+ }): Promise<{
89
93
  satisfied: boolean;
90
94
  missing: string[];
91
95
  waived: string[];
@@ -154,6 +154,7 @@ function isDelegationEntry(value) {
154
154
  (o.tokens === undefined || isDelegationTokenUsage(o.tokens)) &&
155
155
  retryOk &&
156
156
  (o.evidenceRefs === undefined || (Array.isArray(o.evidenceRefs) && o.evidenceRefs.every((item) => typeof item === "string"))) &&
157
+ (o.skill === undefined || typeof o.skill === "string") &&
157
158
  (o.schemaVersion === undefined || o.schemaVersion === 1));
158
159
  }
159
160
  function parseLedger(raw, runId) {
@@ -257,9 +258,11 @@ export function expectedFulfillmentMode(fallbacks) {
257
258
  return "role-switch";
258
259
  return "harness-waiver";
259
260
  }
260
- export async function checkMandatoryDelegations(projectRoot, stage) {
261
+ export async function checkMandatoryDelegations(projectRoot, stage, options = {}) {
261
262
  const mandatory = stageSchema(stage).mandatoryDelegations;
262
- const { activeRunId } = await readFlowState(projectRoot);
263
+ const { activeRunId } = await readFlowState(projectRoot, {
264
+ repairFeatureSystem: options.repairFeatureSystem
265
+ });
263
266
  const ledger = await readDelegationLedger(projectRoot);
264
267
  const forStage = ledger.entries.filter((e) => e.stage === stage);
265
268
  const forRun = forStage.filter((e) => e.runId === activeRunId);
@@ -279,14 +282,15 @@ export async function checkMandatoryDelegations(projectRoot, stage) {
279
282
  const rows = forRun.filter((e) => e.agent === agent);
280
283
  const completedRows = rows.filter((e) => e.status === "completed");
281
284
  const waivedRows = rows.filter((e) => e.status === "waived");
282
- const requiredCompletedCount = stage === "review" &&
285
+ const adversarialReviewerRequired = stage === "review" &&
283
286
  agent === "reviewer" &&
284
- reviewTriggers?.requireAdversarialReviewer
285
- ? 2
286
- : 1;
287
+ reviewTriggers?.requireAdversarialReviewer === true;
288
+ const requiredCompletedCount = adversarialReviewerRequired ? 2 : 1;
287
289
  const hasCompleted = completedRows.length >= requiredCompletedCount;
288
290
  const hasWaived = waivedRows.length > 0;
289
- const ok = hasCompleted || hasWaived;
291
+ const hasAdversarialSkill = !adversarialReviewerRequired ||
292
+ completedRows.some((row) => row.skill === "adversarial-review");
293
+ const ok = hasWaived || (hasCompleted && hasAdversarialSkill);
290
294
  if (!ok) {
291
295
  missing.push(agent);
292
296
  continue;
package/dist/doctor.js CHANGED
@@ -10,11 +10,11 @@ import { exists } from "./fs-utils.js";
10
10
  import { gitignoreHasRequiredPatterns } from "./gitignore.js";
11
11
  import { HARNESS_ADAPTERS, CCLAW_MARKER_START, CCLAW_MARKER_END, harnessShimFileNames, harnessShimSkillNames } from "./harness-adapters.js";
12
12
  import { policyChecks } from "./policy.js";
13
- import { readFlowState } from "./runs.js";
14
- import { skippedStagesForTrack } from "./flow-state.js";
13
+ import { CorruptFlowStateError, readFlowState } from "./runs.js";
14
+ import { createInitialFlowState, skippedStagesForTrack } from "./flow-state.js";
15
15
  import { FLOW_STAGES, TRACK_STAGES } from "./types.js";
16
16
  import { checkMandatoryDelegations } from "./delegation.js";
17
- import { ensureFeatureSystem, listFeatures, readActiveFeature, readFeatureWorktreeRegistry, resolveFeatureWorkspacePath, worktreeRegistryPath } from "./feature-system.js";
17
+ import { activeFeatureMetaPath, ensureFeatureSystem, listFeatures, readActiveFeature, readFeatureWorktreeRegistry, resolveFeatureWorkspacePath, worktreeRegistryPath } from "./feature-system.js";
18
18
  import { buildTraceMatrix } from "./trace-matrix.js";
19
19
  import { classifyReconciliationNotices, reconcileAndWriteCurrentStageGateCatalog, readReconciliationNotices, RECONCILIATION_NOTICES_REL_PATH, verifyCompletedStagesGateClosure, verifyCurrentStageGateEvidence } from "./gate-evidence.js";
20
20
  import { parseTddCycleLog, validateTddCycleOrder } from "./tdd-cycle.js";
@@ -190,6 +190,25 @@ async function readHookDocument(filePath) {
190
190
  return null;
191
191
  }
192
192
  }
193
+ async function readJsonObjectStatus(filePath) {
194
+ if (!(await exists(filePath))) {
195
+ return { exists: false, ok: false, error: "file is missing" };
196
+ }
197
+ try {
198
+ const parsed = JSON.parse(await fs.readFile(filePath, "utf8"));
199
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
200
+ return { exists: true, ok: false, error: "JSON root must be an object" };
201
+ }
202
+ return { exists: true, ok: true };
203
+ }
204
+ catch (error) {
205
+ return {
206
+ exists: true,
207
+ ok: false,
208
+ error: error instanceof Error ? error.message : String(error)
209
+ };
210
+ }
211
+ }
193
212
  function normalizeOpenCodePluginEntry(entry) {
194
213
  if (typeof entry === "string" && entry.trim().length > 0)
195
214
  return entry.trim();
@@ -212,12 +231,16 @@ async function opencodeRegistrationCheck(projectRoot) {
212
231
  path.join(projectRoot, ".opencode/opencode.json"),
213
232
  path.join(projectRoot, ".opencode/opencode.jsonc")
214
233
  ];
234
+ const mismatches = [];
235
+ let foundAnyConfig = false;
215
236
  for (const configPath of candidates) {
216
237
  if (!(await exists(configPath))) {
217
238
  continue;
218
239
  }
240
+ foundAnyConfig = true;
219
241
  const parsed = await readHookDocument(configPath);
220
242
  if (!parsed) {
243
+ mismatches.push(`${path.relative(projectRoot, configPath)} is unreadable or invalid JSON`);
221
244
  continue;
222
245
  }
223
246
  const plugins = Array.isArray(parsed.plugin) ? parsed.plugin : [];
@@ -225,7 +248,10 @@ async function opencodeRegistrationCheck(projectRoot) {
225
248
  if (registered) {
226
249
  return { ok: true, details: `${path.relative(projectRoot, configPath)} registers ${expected}` };
227
250
  }
228
- return { ok: false, details: `${path.relative(projectRoot, configPath)} missing plugin ${expected}` };
251
+ mismatches.push(`${path.relative(projectRoot, configPath)} missing plugin ${expected}`);
252
+ }
253
+ if (foundAnyConfig) {
254
+ return { ok: false, details: mismatches.join(" | ") };
229
255
  }
230
256
  return { ok: false, details: `No opencode.json/opencode.jsonc found with plugin ${expected}` };
231
257
  }
@@ -244,6 +270,12 @@ async function opencodePluginRuntimeShapeCheck(projectRoot) {
244
270
  };
245
271
  }
246
272
  const plugin = imported.default({ directory: projectRoot });
273
+ if (!plugin || typeof plugin !== "object" || Array.isArray(plugin)) {
274
+ return {
275
+ ok: false,
276
+ details: `${path.relative(projectRoot, pluginPath)} factory must return a plugin object`
277
+ };
278
+ }
247
279
  const requiredHandlers = [
248
280
  "event",
249
281
  "tool.execute.before",
@@ -257,7 +289,6 @@ async function opencodePluginRuntimeShapeCheck(projectRoot) {
257
289
  details: `${path.relative(projectRoot, pluginPath)} missing runtime handlers: ${missing.join(", ")}`
258
290
  };
259
291
  }
260
- await plugin.event({ event: { type: "session.updated", data: {} } });
261
292
  return {
262
293
  ok: true,
263
294
  details: `${path.relative(projectRoot, pluginPath)} exports compatible runtime handler shape`
@@ -1289,10 +1320,28 @@ export async function doctorChecks(projectRoot, options = {}) {
1289
1320
  details: modePath
1290
1321
  });
1291
1322
  }
1292
- await ensureFeatureSystem(projectRoot);
1293
- const activeFeature = await readActiveFeature(projectRoot);
1294
- let flowState = await readFlowState(projectRoot);
1295
- if (options.reconcileCurrentStageGates === true) {
1323
+ await ensureFeatureSystem(projectRoot, { repair: false });
1324
+ const activeFeature = await readActiveFeature(projectRoot, { repair: false });
1325
+ let flowState = createInitialFlowState();
1326
+ let flowStateCorruptError = null;
1327
+ try {
1328
+ flowState = await readFlowState(projectRoot, { repairFeatureSystem: false });
1329
+ }
1330
+ catch (error) {
1331
+ if (error instanceof CorruptFlowStateError) {
1332
+ flowStateCorruptError = error;
1333
+ checks.push({
1334
+ name: "flow_state:readable",
1335
+ ok: false,
1336
+ severity: "error",
1337
+ details: error.message
1338
+ });
1339
+ }
1340
+ else {
1341
+ throw error;
1342
+ }
1343
+ }
1344
+ if (options.reconcileCurrentStageGates === true && !flowStateCorruptError) {
1296
1345
  const reconciliation = await reconcileAndWriteCurrentStageGateCatalog(projectRoot);
1297
1346
  if (reconciliation.wrote) {
1298
1347
  flowState = {
@@ -1311,6 +1360,13 @@ export async function doctorChecks(projectRoot, options = {}) {
1311
1360
  : `no gate reconciliation changes needed for stage "${reconciliation.stage}"`
1312
1361
  });
1313
1362
  }
1363
+ else if (options.reconcileCurrentStageGates === true && flowStateCorruptError) {
1364
+ checks.push({
1365
+ name: "gates:reconcile:writeback",
1366
+ ok: false,
1367
+ details: "skipped gate reconciliation because flow-state.json is corrupt"
1368
+ });
1369
+ }
1314
1370
  const activeRunId = typeof flowState.activeRunId === "string" ? flowState.activeRunId.trim() : "";
1315
1371
  checks.push({
1316
1372
  name: "flow_state:active_run_id",
@@ -1318,6 +1374,15 @@ export async function doctorChecks(projectRoot, options = {}) {
1318
1374
  details: `${RUNTIME_ROOT}/state/flow-state.json must include activeRunId`
1319
1375
  });
1320
1376
  const reconciliationNotices = await readReconciliationNotices(projectRoot);
1377
+ checks.push({
1378
+ name: "state:reconciliation_notices_parse",
1379
+ ok: reconciliationNotices.parseOk && reconciliationNotices.schemaOk,
1380
+ details: !reconciliationNotices.parseOk
1381
+ ? `unable to parse ${RECONCILIATION_NOTICES_REL_PATH}; reset with \`cclaw sync\` or repair JSON by hand`
1382
+ : !reconciliationNotices.schemaOk
1383
+ ? `${RECONCILIATION_NOTICES_REL_PATH} schemaVersion mismatch; expected ${reconciliationNotices.schemaVersion}`
1384
+ : `${RECONCILIATION_NOTICES_REL_PATH} parsed successfully`
1385
+ });
1321
1386
  const noticeBuckets = classifyReconciliationNotices(flowState, reconciliationNotices.notices);
1322
1387
  const formatNoticeList = (items) => items
1323
1388
  .slice(0, 8)
@@ -1425,22 +1490,38 @@ export async function doctorChecks(projectRoot, options = {}) {
1425
1490
  ? "no TODO/TBD/FIXME placeholder markers found in active artifacts"
1426
1491
  : `warning: placeholder markers detected in active artifacts (${artifactPlaceholderHits.join(", ")}). Clear before marking completion.`
1427
1492
  });
1428
- const features = await listFeatures(projectRoot);
1429
- const worktreeRegistry = await readFeatureWorktreeRegistry(projectRoot);
1493
+ const activeMetaStatus = await readJsonObjectStatus(activeFeatureMetaPath(projectRoot));
1494
+ const worktreeRegistryStatus = await readJsonObjectStatus(worktreeRegistryPath(projectRoot));
1495
+ const features = await listFeatures(projectRoot, { repair: false });
1496
+ const worktreeRegistry = await readFeatureWorktreeRegistry(projectRoot, { repair: false });
1430
1497
  const activeFeatureEntry = worktreeRegistry.entries.find((entry) => entry.featureId === activeFeature);
1431
1498
  const activeFeatureWorkspacePath = activeFeatureEntry
1432
1499
  ? resolveFeatureWorkspacePath(projectRoot, activeFeatureEntry)
1433
1500
  : "";
1434
1501
  checks.push({
1435
1502
  name: "state:active_feature_meta",
1436
- ok: await exists(path.join(projectRoot, RUNTIME_ROOT, "state", "active-feature.json")),
1503
+ ok: activeMetaStatus.exists,
1437
1504
  details: `${RUNTIME_ROOT}/state/active-feature.json must exist`
1438
1505
  });
1506
+ checks.push({
1507
+ name: "state:active_feature_meta_valid_json",
1508
+ ok: activeMetaStatus.ok,
1509
+ details: activeMetaStatus.ok
1510
+ ? `${RUNTIME_ROOT}/state/active-feature.json parsed successfully`
1511
+ : `${RUNTIME_ROOT}/state/active-feature.json is invalid: ${activeMetaStatus.error ?? "unknown error"}`
1512
+ });
1439
1513
  checks.push({
1440
1514
  name: "state:worktree_registry_exists",
1441
- ok: await exists(worktreeRegistryPath(projectRoot)),
1515
+ ok: worktreeRegistryStatus.exists,
1442
1516
  details: `${RUNTIME_ROOT}/state/worktrees.json must exist and track feature->worktree mapping`
1443
1517
  });
1518
+ checks.push({
1519
+ name: "state:worktree_registry_valid_json",
1520
+ ok: worktreeRegistryStatus.ok,
1521
+ details: worktreeRegistryStatus.ok
1522
+ ? `${RUNTIME_ROOT}/state/worktrees.json parsed successfully`
1523
+ : `${RUNTIME_ROOT}/state/worktrees.json is invalid: ${worktreeRegistryStatus.error ?? "unknown error"}`
1524
+ });
1444
1525
  checks.push({
1445
1526
  name: "state:active_feature_exists",
1446
1527
  ok: features.includes(activeFeature),
@@ -1590,7 +1671,9 @@ export async function doctorChecks(projectRoot, options = {}) {
1590
1671
  ok: await exists(path.join(projectRoot, RUNTIME_ROOT, "runs")),
1591
1672
  details: `${RUNTIME_ROOT}/runs must exist for archived feature snapshots`
1592
1673
  });
1593
- const delegation = await checkMandatoryDelegations(projectRoot, flowState.currentStage);
1674
+ const delegation = await checkMandatoryDelegations(projectRoot, flowState.currentStage, {
1675
+ repairFeatureSystem: false
1676
+ });
1594
1677
  const missingEvidenceNote = delegation.missingEvidence && delegation.missingEvidence.length > 0
1595
1678
  ? ` (role-switch rows without evidenceRefs: ${delegation.missingEvidence.join(", ")})`
1596
1679
  : "";
@@ -19,16 +19,23 @@ export interface CreateFeatureOptions {
19
19
  cloneActive?: boolean;
20
20
  switchTo?: boolean;
21
21
  }
22
+ export interface FeatureSystemAccessOptions {
23
+ /**
24
+ * When false, read metadata without auto-repair writes. Useful for pure
25
+ * diagnostics (doctor) that should not mutate state as a side effect.
26
+ */
27
+ repair?: boolean;
28
+ }
22
29
  export declare function activeFeatureMetaPath(projectRoot: string): string;
23
30
  export declare function worktreeRegistryPath(projectRoot: string): string;
24
31
  export declare function featureRootPath(projectRoot: string, featureId: string): string;
25
32
  export declare function featureArtifactsPath(projectRoot: string, featureId: string): string;
26
33
  export declare function featureStatePath(projectRoot: string, featureId: string): string;
27
34
  export declare function resolveFeatureWorkspacePath(projectRoot: string, entry: FeatureWorkspaceEntry): string;
28
- export declare function ensureFeatureSystem(projectRoot: string): Promise<ActiveFeatureMeta>;
29
- export declare function readFeatureWorktreeRegistry(projectRoot: string): Promise<FeatureWorktreeRegistry>;
30
- export declare function readActiveFeature(projectRoot: string): Promise<string>;
31
- export declare function listFeatures(projectRoot: string): Promise<string[]>;
35
+ export declare function ensureFeatureSystem(projectRoot: string, options?: FeatureSystemAccessOptions): Promise<ActiveFeatureMeta>;
36
+ export declare function readFeatureWorktreeRegistry(projectRoot: string, options?: FeatureSystemAccessOptions): Promise<FeatureWorktreeRegistry>;
37
+ export declare function readActiveFeature(projectRoot: string, options?: FeatureSystemAccessOptions): Promise<string>;
38
+ export declare function listFeatures(projectRoot: string, options?: FeatureSystemAccessOptions): Promise<string[]>;
32
39
  export declare function syncActiveFeatureSnapshot(projectRoot: string): Promise<void>;
33
40
  export declare function switchActiveFeature(projectRoot: string, featureId: string): Promise<ActiveFeatureMeta>;
34
41
  export declare function createFeature(projectRoot: string, rawFeatureId: string, options?: CreateFeatureOptions): Promise<string>;
@@ -278,20 +278,64 @@ async function ensureRegistryState(projectRoot) {
278
278
  await writeActiveFeatureMeta(projectRoot, activeMeta);
279
279
  return { registry, activeMeta };
280
280
  }
281
- export async function ensureFeatureSystem(projectRoot) {
282
- const { activeMeta } = await ensureRegistryState(projectRoot);
281
+ async function readRegistryStateReadonly(projectRoot) {
282
+ const currentRegistry = await readRegistry(projectRoot);
283
+ const entries = [...currentRegistry.entries];
284
+ const gitRepo = await isGitRepository(projectRoot);
285
+ const source = gitRepo ? "git-worktree" : "workspace";
286
+ const branch = gitRepo ? await currentBranch(projectRoot) : "workspace/default";
287
+ if (!entries.some((entry) => entry.featureId === DEFAULT_FEATURE_ID)) {
288
+ entries.push(buildDefaultEntry(source, branch));
289
+ }
290
+ const legacyFeatureIds = await listLegacySnapshotIds(projectRoot);
291
+ for (const legacyId of legacyFeatureIds) {
292
+ if (entries.some((entry) => entry.featureId === legacyId)) {
293
+ continue;
294
+ }
295
+ entries.push({
296
+ featureId: legacyId,
297
+ branch: `legacy/${legacyId}`,
298
+ path: `${LEGACY_FEATURES_DIR_REL_PATH}/${legacyId}`,
299
+ source: "legacy-snapshot",
300
+ createdAt: new Date().toISOString()
301
+ });
302
+ }
303
+ const registry = {
304
+ schemaVersion: WORKTREE_REGISTRY_SCHEMA_VERSION,
305
+ updatedAt: currentRegistry.updatedAt,
306
+ entries: dedupeEntries(entries)
307
+ };
308
+ const active = await readActiveFeatureMetaInternal(projectRoot);
309
+ return {
310
+ registry,
311
+ activeMeta: {
312
+ activeFeature: registryHasFeature(registry, active.activeFeature)
313
+ ? active.activeFeature
314
+ : DEFAULT_FEATURE_ID,
315
+ updatedAt: active.updatedAt
316
+ }
317
+ };
318
+ }
319
+ async function resolveFeatureSystemState(projectRoot, options = {}) {
320
+ if (options.repair === false) {
321
+ return readRegistryStateReadonly(projectRoot);
322
+ }
323
+ return ensureRegistryState(projectRoot);
324
+ }
325
+ export async function ensureFeatureSystem(projectRoot, options = {}) {
326
+ const { activeMeta } = await resolveFeatureSystemState(projectRoot, options);
283
327
  return activeMeta;
284
328
  }
285
- export async function readFeatureWorktreeRegistry(projectRoot) {
286
- const { registry } = await ensureRegistryState(projectRoot);
329
+ export async function readFeatureWorktreeRegistry(projectRoot, options = {}) {
330
+ const { registry } = await resolveFeatureSystemState(projectRoot, options);
287
331
  return registry;
288
332
  }
289
- export async function readActiveFeature(projectRoot) {
290
- const meta = await ensureFeatureSystem(projectRoot);
333
+ export async function readActiveFeature(projectRoot, options = {}) {
334
+ const meta = await ensureFeatureSystem(projectRoot, options);
291
335
  return normalizedFeatureId(meta.activeFeature);
292
336
  }
293
- export async function listFeatures(projectRoot) {
294
- const registry = await readFeatureWorktreeRegistry(projectRoot);
337
+ export async function listFeatures(projectRoot, options = {}) {
338
+ const registry = await readFeatureWorktreeRegistry(projectRoot, options);
295
339
  return registry.entries.map((entry) => entry.featureId).sort((a, b) => a.localeCompare(b));
296
340
  }
297
341
  export async function syncActiveFeatureSnapshot(projectRoot) {
@@ -92,8 +92,10 @@ export declare function isFlowTrack(value: unknown): value is FlowTrack;
92
92
  export declare function trackStages(track: FlowTrack): FlowStage[];
93
93
  export declare function skippedStagesForTrack(track: FlowTrack): FlowStage[];
94
94
  export declare function firstStageForTrack(track: FlowTrack): FlowStage;
95
+ export declare function createRunId(date?: Date): string;
95
96
  export declare function createInitialFlowState(activeRunIdOrOptions?: string | InitialFlowStateOptions, maybeTrack?: FlowTrack): FlowState;
96
97
  export declare function canTransition(from: FlowStage, to: FlowStage): boolean;
98
+ export declare function getAvailableTransitions(from: FlowStage, track?: FlowTrack): TransitionRule[];
97
99
  export declare function getTransitionGuards(from: FlowStage, to: FlowStage, track?: FlowTrack): string[];
98
100
  export declare function nextStage(stage: FlowStage, track?: FlowTrack): FlowStage | null;
99
101
  export declare function previousStage(stage: FlowStage, track?: FlowTrack): FlowStage | null;
@@ -58,11 +58,14 @@ export function firstStageForTrack(track) {
58
58
  const stages = TRACK_STAGES[track];
59
59
  return stages[0] ?? "brainstorm";
60
60
  }
61
- export function createInitialFlowState(activeRunIdOrOptions = "active", maybeTrack) {
61
+ export function createRunId(date = new Date()) {
62
+ return `run-${date.getTime().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
63
+ }
64
+ export function createInitialFlowState(activeRunIdOrOptions = {}, maybeTrack) {
62
65
  const options = typeof activeRunIdOrOptions === "string"
63
66
  ? { activeRunId: activeRunIdOrOptions, track: maybeTrack }
64
67
  : activeRunIdOrOptions;
65
- const activeRunId = options.activeRunId ?? "active";
68
+ const activeRunId = options.activeRunId ?? createRunId();
66
69
  const track = options.track ?? "standard";
67
70
  const skippedStages = skippedStagesForTrack(track);
68
71
  const stageGateCatalog = {};
@@ -97,6 +100,20 @@ export function createInitialFlowState(activeRunIdOrOptions = "active", maybeTra
97
100
  export function canTransition(from, to) {
98
101
  return TRANSITION_RULES.some((rule) => rule.from === from && rule.to === to);
99
102
  }
103
+ export function getAvailableTransitions(from, track = "standard") {
104
+ const natural = nextStage(from, track);
105
+ const fromRules = TRANSITION_RULES.filter((rule) => rule.from === from);
106
+ if (!natural) {
107
+ return fromRules;
108
+ }
109
+ return fromRules.sort((a, b) => {
110
+ if (a.to === natural && b.to !== natural)
111
+ return -1;
112
+ if (b.to === natural && a.to !== natural)
113
+ return 1;
114
+ return a.to.localeCompare(b.to);
115
+ });
116
+ }
100
117
  export function getTransitionGuards(from, to, track = "standard") {
101
118
  // Natural forward edge on this track: derive guards fresh from the
102
119
  // track-specific gate schema. `TRANSITION_RULES` collapses shared edges
package/dist/fs-utils.js CHANGED
@@ -27,6 +27,7 @@ export async function withDirectoryLock(lockPath, fn, options = {}) {
27
27
  const staleAfterMs = options.staleAfterMs ?? 60_000;
28
28
  await ensureDir(path.dirname(lockPath));
29
29
  let acquired = false;
30
+ let lastError = null;
30
31
  for (let attempt = 0; attempt < retries; attempt += 1) {
31
32
  try {
32
33
  await fs.mkdir(lockPath);
@@ -34,6 +35,7 @@ export async function withDirectoryLock(lockPath, fn, options = {}) {
34
35
  break;
35
36
  }
36
37
  catch (error) {
38
+ lastError = error;
37
39
  const code = error?.code;
38
40
  if (code !== "EEXIST") {
39
41
  throw error;
@@ -52,13 +54,20 @@ export async function withDirectoryLock(lockPath, fn, options = {}) {
52
54
  }
53
55
  }
54
56
  if (!acquired) {
55
- throw new Error(`Failed to acquire lock: ${lockPath}`);
57
+ const details = lastError instanceof Error ? lastError.message : String(lastError);
58
+ throw new Error(`Failed to acquire lock: ${lockPath} (attempts=${retries}, retryDelayMs=${retryDelayMs}, staleAfterMs=${staleAfterMs}, lastError=${details})`);
56
59
  }
57
60
  try {
58
61
  return await fn();
59
62
  }
60
63
  finally {
61
- await fs.rm(lockPath, { recursive: true, force: true }).catch(() => { });
64
+ await fs.rm(lockPath, { recursive: true, force: true }).catch((cleanupError) => {
65
+ // Lock cleanup failure should not shadow the original operation result,
66
+ // but keep a diagnostic breadcrumb for flaky FS environments.
67
+ const details = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
68
+ // eslint-disable-next-line no-console
69
+ console.warn(`cclaw lock cleanup failed for ${lockPath}: ${details}`);
70
+ });
62
71
  }
63
72
  }
64
73
  export async function writeFileSafe(filePath, content) {
@@ -41,6 +41,8 @@ export interface ReconciliationNotice {
41
41
  export interface ReconciliationNoticesPayload {
42
42
  schemaVersion: number;
43
43
  notices: ReconciliationNotice[];
44
+ parseOk: boolean;
45
+ schemaOk: boolean;
44
46
  }
45
47
  export interface ReconciliationNoticeBuckets {
46
48
  activeBlocked: ReconciliationNotice[];
@@ -72,7 +72,9 @@ function reconciliationNoticesPath(projectRoot) {
72
72
  function defaultReconciliationNoticesPayload() {
73
73
  return {
74
74
  schemaVersion: RECONCILIATION_NOTICES_SCHEMA_VERSION,
75
- notices: []
75
+ notices: [],
76
+ parseOk: true,
77
+ schemaOk: true
76
78
  };
77
79
  }
78
80
  function sanitizeReconciliationNotice(raw) {
@@ -104,6 +106,7 @@ export async function readReconciliationNotices(projectRoot) {
104
106
  }
105
107
  try {
106
108
  const raw = JSON.parse(await fs.readFile(filePath, "utf8"));
109
+ const schemaOk = raw.schemaVersion === RECONCILIATION_NOTICES_SCHEMA_VERSION;
107
110
  const notices = Array.isArray(raw.notices)
108
111
  ? raw.notices
109
112
  .map((value) => sanitizeReconciliationNotice(value))
@@ -111,11 +114,17 @@ export async function readReconciliationNotices(projectRoot) {
111
114
  : [];
112
115
  return {
113
116
  schemaVersion: RECONCILIATION_NOTICES_SCHEMA_VERSION,
114
- notices
117
+ notices,
118
+ parseOk: true,
119
+ schemaOk
115
120
  };
116
121
  }
117
122
  catch {
118
- return defaultReconciliationNoticesPayload();
123
+ return {
124
+ ...defaultReconciliationNoticesPayload(),
125
+ parseOk: false,
126
+ schemaOk: false
127
+ };
119
128
  }
120
129
  }
121
130
  async function writeReconciliationNotices(projectRoot, payload) {
package/dist/install.js CHANGED
@@ -977,7 +977,7 @@ async function writeState(projectRoot, config, forceReset = false) {
977
977
  if (!forceReset && (await exists(statePath))) {
978
978
  return;
979
979
  }
980
- const state = createInitialFlowState("active", config.defaultTrack ?? "standard");
980
+ const state = createInitialFlowState({ track: config.defaultTrack ?? "standard" });
981
981
  await writeFileSafe(statePath, `${JSON.stringify(state, null, 2)}\n`);
982
982
  }
983
983
  async function writeAdapterManifest(projectRoot, harnesses) {
@@ -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];
@@ -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,127 +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
- }
192
- };
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
- // Drop an `.archive-in-progress` sentinel immediately so that a crash
208
- // between the artifact rename and the final manifest write leaves a
209
- // recoverable marker (doctor surfaces these; re-running archive on an
210
- // orphan attempts to complete or roll back). The sentinel is removed
211
- // only after the manifest lands successfully.
212
- const sentinelPath = path.join(archivePath, ".archive-in-progress");
213
- const archivedAt = new Date().toISOString();
214
- await writeFileSafe(sentinelPath, `${JSON.stringify({ archiveId, startedAt: archivedAt, sourceRunId: sourceState.activeRunId }, null, 2)}\n`);
215
- let artifactsMoved = false;
216
- try {
217
- await fs.rename(artifactsDir, archiveArtifactsPath);
218
- artifactsMoved = true;
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);
219
168
  await ensureDir(artifactsDir);
220
- const archiveStatePath = path.join(archivePath, "state");
221
- const snapshottedStateFiles = await snapshotStateDirectory(projectRoot, archiveStatePath);
222
- const resetState = createInitialFlowState();
223
- await writeFlowState(projectRoot, resetState, { allowReset: true });
224
- await resetCarryoverStateFiles(projectRoot, resetState.activeRunId);
225
- const manifest = {
226
- version: 1,
227
- archiveId,
228
- archivedAt,
229
- featureName: feature,
230
- activeFeature,
231
- sourceRunId: sourceState.activeRunId,
232
- sourceCurrentStage: sourceState.currentStage,
233
- sourceCompletedStages: sourceState.completedStages,
234
- snapshottedStateFiles,
235
- retro: retroSummary
236
- };
237
- await writeFileSafe(path.join(archivePath, "archive-manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
238
- // Manifest landed sentinel is no longer needed.
239
- await fs.unlink(sentinelPath).catch(() => undefined);
240
- const knowledgeStats = await readKnowledgeStats(projectRoot);
241
- await syncActiveFeatureSnapshot(projectRoot);
242
- return {
243
- archiveId,
244
- archivePath,
245
- archivedAt,
246
- featureName: feature,
247
- activeFeature,
248
- resetState,
249
- snapshottedStateFiles,
250
- knowledge: knowledgeStats,
251
- retro: retroSummary
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
252
228
  };
253
- }
254
- catch (err) {
255
- // Best-effort rollback: if artifacts were moved but the subsequent
256
- // steps failed, put artifacts back so the user is not left without
257
- // a working run. The sentinel is intentionally left behind for
258
- // inspection; doctor surfaces it.
259
- if (artifactsMoved) {
260
- try {
261
- await fs.rm(artifactsDir, { recursive: true, force: true });
262
- await fs.rename(archiveArtifactsPath, artifactsDir);
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
+ }
263
294
  }
264
- catch {
265
- // Rollback failed — sentinel + orphaned archive dir will be
266
- // surfaced by doctor and can be reconciled manually.
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
+ }
267
304
  }
305
+ throw err;
268
306
  }
269
- throw err;
270
- }
307
+ }, {
308
+ retries: 400,
309
+ retryDelayMs: 25,
310
+ staleAfterMs: 120_000
311
+ });
271
312
  }
272
313
  const KNOWLEDGE_SOFT_THRESHOLD = 50;
273
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;
@@ -277,7 +277,7 @@ function sanitizeCloseoutState(value) {
277
277
  }
278
278
  function coerceFlowState(parsed) {
279
279
  const track = coerceTrack(parsed.track);
280
- const next = createInitialFlowState("active", track);
280
+ const next = createInitialFlowState({ track });
281
281
  const activeRunIdRaw = parsed.activeRunId;
282
282
  const activeRunId = typeof activeRunIdRaw === "string" && activeRunIdRaw.trim().length > 0
283
283
  ? activeRunIdRaw.trim()
@@ -333,8 +333,10 @@ async function quarantineCorruptState(statePath, cause) {
333
333
  }
334
334
  throw new CorruptFlowStateError(statePath, quarantinedPath, cause);
335
335
  }
336
- export async function readFlowState(projectRoot) {
337
- await ensureFeatureSystem(projectRoot);
336
+ export async function readFlowState(projectRoot, options = {}) {
337
+ if (options.repairFeatureSystem !== false) {
338
+ await ensureFeatureSystem(projectRoot);
339
+ }
338
340
  const statePath = flowStatePath(projectRoot);
339
341
  if (!(await exists(statePath))) {
340
342
  return createInitialFlowState();
@@ -375,8 +377,7 @@ export async function writeFlowState(projectRoot, state, options = {}) {
375
377
  if (err instanceof InvalidStageTransitionError) {
376
378
  throw err;
377
379
  }
378
- // A corrupt prior file is surfaced by readFlowState elsewhere; don't
379
- // 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.`);
380
381
  }
381
382
  }
382
383
  const safe = coerceFlowState({ ...state });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cclaw-cli",
3
- "version": "0.48.1",
3
+ "version": "0.48.2",
4
4
  "description": "Installer-first flow toolkit for coding agents",
5
5
  "type": "module",
6
6
  "bin": {