cclaw-cli 0.48.1 → 0.48.3

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 (41) hide show
  1. package/README.md +10 -3
  2. package/dist/artifact-linter.js +2 -8
  3. package/dist/cli.js +8 -1
  4. package/dist/config.d.ts +3 -0
  5. package/dist/config.js +13 -3
  6. package/dist/constants.d.ts +6 -0
  7. package/dist/constants.js +11 -0
  8. package/dist/content/contracts.d.ts +2 -2
  9. package/dist/content/contracts.js +2 -2
  10. package/dist/content/core-agents.d.ts +1 -1
  11. package/dist/content/core-agents.js +1 -1
  12. package/dist/content/hooks.js +16 -15
  13. package/dist/content/next-command.js +4 -2
  14. package/dist/content/observe.d.ts +2 -2
  15. package/dist/content/observe.js +83 -13
  16. package/dist/content/opencode-plugin.js +227 -45
  17. package/dist/content/stage-schema.js +1 -1
  18. package/dist/content/stages/ship.js +2 -5
  19. package/dist/content/templates.js +3 -6
  20. package/dist/delegation.d.ts +5 -1
  21. package/dist/delegation.js +12 -8
  22. package/dist/doctor.js +132 -15
  23. package/dist/eval/runner.js +36 -4
  24. package/dist/feature-system.d.ts +11 -4
  25. package/dist/feature-system.js +54 -10
  26. package/dist/flow-state.d.ts +2 -0
  27. package/dist/flow-state.js +19 -2
  28. package/dist/fs-utils.d.ts +4 -1
  29. package/dist/fs-utils.js +20 -4
  30. package/dist/gate-evidence.d.ts +2 -0
  31. package/dist/gate-evidence.js +13 -4
  32. package/dist/install.js +25 -23
  33. package/dist/internal/advance-stage.js +49 -10
  34. package/dist/knowledge-store.d.ts +8 -0
  35. package/dist/knowledge-store.js +113 -33
  36. package/dist/retro-gate.js +33 -23
  37. package/dist/run-archive.js +166 -128
  38. package/dist/run-persistence.d.ts +8 -1
  39. package/dist/run-persistence.js +7 -6
  40. package/dist/trace-matrix.js +7 -7
  41. package/package.json +1 -1
package/dist/doctor.js CHANGED
@@ -5,16 +5,16 @@ import { pathToFileURL } from "node:url";
5
5
  import { promisify } from "node:util";
6
6
  import { REQUIRED_DIRS, RUNTIME_ROOT, UTILITY_COMMANDS } from "./constants.js";
7
7
  import { CCLAW_AGENTS } from "./content/core-agents.js";
8
- import { detectAdvancedKeys, readConfig } from "./config.js";
8
+ import { detectAdvancedKeys, InvalidConfigError, readConfig } from "./config.js";
9
9
  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,34 @@ 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
+ }
212
+ async function readPermissionBits(filePath) {
213
+ try {
214
+ const stat = await fs.stat(filePath);
215
+ return stat.mode & 0o777;
216
+ }
217
+ catch {
218
+ return null;
219
+ }
220
+ }
193
221
  function normalizeOpenCodePluginEntry(entry) {
194
222
  if (typeof entry === "string" && entry.trim().length > 0)
195
223
  return entry.trim();
@@ -212,12 +240,16 @@ async function opencodeRegistrationCheck(projectRoot) {
212
240
  path.join(projectRoot, ".opencode/opencode.json"),
213
241
  path.join(projectRoot, ".opencode/opencode.jsonc")
214
242
  ];
243
+ const mismatches = [];
244
+ let foundAnyConfig = false;
215
245
  for (const configPath of candidates) {
216
246
  if (!(await exists(configPath))) {
217
247
  continue;
218
248
  }
249
+ foundAnyConfig = true;
219
250
  const parsed = await readHookDocument(configPath);
220
251
  if (!parsed) {
252
+ mismatches.push(`${path.relative(projectRoot, configPath)} is unreadable or invalid JSON`);
221
253
  continue;
222
254
  }
223
255
  const plugins = Array.isArray(parsed.plugin) ? parsed.plugin : [];
@@ -225,7 +257,10 @@ async function opencodeRegistrationCheck(projectRoot) {
225
257
  if (registered) {
226
258
  return { ok: true, details: `${path.relative(projectRoot, configPath)} registers ${expected}` };
227
259
  }
228
- return { ok: false, details: `${path.relative(projectRoot, configPath)} missing plugin ${expected}` };
260
+ mismatches.push(`${path.relative(projectRoot, configPath)} missing plugin ${expected}`);
261
+ }
262
+ if (foundAnyConfig) {
263
+ return { ok: false, details: mismatches.join(" | ") };
229
264
  }
230
265
  return { ok: false, details: `No opencode.json/opencode.jsonc found with plugin ${expected}` };
231
266
  }
@@ -244,6 +279,12 @@ async function opencodePluginRuntimeShapeCheck(projectRoot) {
244
279
  };
245
280
  }
246
281
  const plugin = imported.default({ directory: projectRoot });
282
+ if (!plugin || typeof plugin !== "object" || Array.isArray(plugin)) {
283
+ return {
284
+ ok: false,
285
+ details: `${path.relative(projectRoot, pluginPath)} factory must return a plugin object`
286
+ };
287
+ }
247
288
  const requiredHandlers = [
248
289
  "event",
249
290
  "tool.execute.before",
@@ -257,7 +298,6 @@ async function opencodePluginRuntimeShapeCheck(projectRoot) {
257
298
  details: `${path.relative(projectRoot, pluginPath)} missing runtime handlers: ${missing.join(", ")}`
258
299
  };
259
300
  }
260
- await plugin.event({ event: { type: "session.updated", data: {} } });
261
301
  return {
262
302
  ok: true,
263
303
  details: `${path.relative(projectRoot, pluginPath)} exports compatible runtime handler shape`
@@ -426,6 +466,7 @@ export async function doctorChecks(projectRoot, options = {}) {
426
466
  checks.push({
427
467
  name: "config:valid",
428
468
  ok: false,
469
+ severity: error instanceof InvalidConfigError ? "error" : "warning",
429
470
  details: error instanceof Error ? error.message : "Invalid config"
430
471
  });
431
472
  }
@@ -1289,10 +1330,28 @@ export async function doctorChecks(projectRoot, options = {}) {
1289
1330
  details: modePath
1290
1331
  });
1291
1332
  }
1292
- await ensureFeatureSystem(projectRoot);
1293
- const activeFeature = await readActiveFeature(projectRoot);
1294
- let flowState = await readFlowState(projectRoot);
1295
- if (options.reconcileCurrentStageGates === true) {
1333
+ await ensureFeatureSystem(projectRoot, { repair: false });
1334
+ const activeFeature = await readActiveFeature(projectRoot, { repair: false });
1335
+ let flowState = createInitialFlowState();
1336
+ let flowStateCorruptError = null;
1337
+ try {
1338
+ flowState = await readFlowState(projectRoot, { repairFeatureSystem: false });
1339
+ }
1340
+ catch (error) {
1341
+ if (error instanceof CorruptFlowStateError) {
1342
+ flowStateCorruptError = error;
1343
+ checks.push({
1344
+ name: "flow_state:readable",
1345
+ ok: false,
1346
+ severity: "error",
1347
+ details: error.message
1348
+ });
1349
+ }
1350
+ else {
1351
+ throw error;
1352
+ }
1353
+ }
1354
+ if (options.reconcileCurrentStageGates === true && !flowStateCorruptError) {
1296
1355
  const reconciliation = await reconcileAndWriteCurrentStageGateCatalog(projectRoot);
1297
1356
  if (reconciliation.wrote) {
1298
1357
  flowState = {
@@ -1311,13 +1370,53 @@ export async function doctorChecks(projectRoot, options = {}) {
1311
1370
  : `no gate reconciliation changes needed for stage "${reconciliation.stage}"`
1312
1371
  });
1313
1372
  }
1373
+ else if (options.reconcileCurrentStageGates === true && flowStateCorruptError) {
1374
+ checks.push({
1375
+ name: "gates:reconcile:writeback",
1376
+ ok: false,
1377
+ details: "skipped gate reconciliation because flow-state.json is corrupt"
1378
+ });
1379
+ }
1314
1380
  const activeRunId = typeof flowState.activeRunId === "string" ? flowState.activeRunId.trim() : "";
1315
1381
  checks.push({
1316
1382
  name: "flow_state:active_run_id",
1317
1383
  ok: activeRunId.length > 0,
1318
1384
  details: `${RUNTIME_ROOT}/state/flow-state.json must include activeRunId`
1319
1385
  });
1386
+ const sensitivePermissionTargets = [
1387
+ path.join(projectRoot, RUNTIME_ROOT, "state", "flow-state.json"),
1388
+ path.join(projectRoot, RUNTIME_ROOT, "state", "delegation-log.json"),
1389
+ path.join(projectRoot, RUNTIME_ROOT, "state", "reconciliation-notices.json"),
1390
+ path.join(projectRoot, RUNTIME_ROOT, "state", "worktrees.json"),
1391
+ path.join(projectRoot, RUNTIME_ROOT, "state", "active-feature.json"),
1392
+ path.join(projectRoot, RUNTIME_ROOT, "knowledge.jsonl")
1393
+ ];
1394
+ const permissiveStateFiles = [];
1395
+ for (const targetPath of sensitivePermissionTargets) {
1396
+ const bits = await readPermissionBits(targetPath);
1397
+ if (bits === null)
1398
+ continue;
1399
+ if (bits > 0o640) {
1400
+ permissiveStateFiles.push(`${path.relative(projectRoot, targetPath)}:${bits.toString(8)}`);
1401
+ }
1402
+ }
1403
+ checks.push({
1404
+ name: "warning:state:file_permissions",
1405
+ ok: true,
1406
+ details: permissiveStateFiles.length === 0
1407
+ ? "sensitive state files are <=0640 permissions"
1408
+ : `warning: sensitive state files are overly permissive (${permissiveStateFiles.join(", ")}). Run \`chmod 600 .cclaw/state/*.json .cclaw/state/*.jsonl .cclaw/knowledge.jsonl\` if this machine is multi-user.`
1409
+ });
1320
1410
  const reconciliationNotices = await readReconciliationNotices(projectRoot);
1411
+ checks.push({
1412
+ name: "state:reconciliation_notices_parse",
1413
+ ok: reconciliationNotices.parseOk && reconciliationNotices.schemaOk,
1414
+ details: !reconciliationNotices.parseOk
1415
+ ? `unable to parse ${RECONCILIATION_NOTICES_REL_PATH}; reset with \`cclaw sync\` or repair JSON by hand`
1416
+ : !reconciliationNotices.schemaOk
1417
+ ? `${RECONCILIATION_NOTICES_REL_PATH} schemaVersion mismatch; expected ${reconciliationNotices.schemaVersion}`
1418
+ : `${RECONCILIATION_NOTICES_REL_PATH} parsed successfully`
1419
+ });
1321
1420
  const noticeBuckets = classifyReconciliationNotices(flowState, reconciliationNotices.notices);
1322
1421
  const formatNoticeList = (items) => items
1323
1422
  .slice(0, 8)
@@ -1425,22 +1524,38 @@ export async function doctorChecks(projectRoot, options = {}) {
1425
1524
  ? "no TODO/TBD/FIXME placeholder markers found in active artifacts"
1426
1525
  : `warning: placeholder markers detected in active artifacts (${artifactPlaceholderHits.join(", ")}). Clear before marking completion.`
1427
1526
  });
1428
- const features = await listFeatures(projectRoot);
1429
- const worktreeRegistry = await readFeatureWorktreeRegistry(projectRoot);
1527
+ const activeMetaStatus = await readJsonObjectStatus(activeFeatureMetaPath(projectRoot));
1528
+ const worktreeRegistryStatus = await readJsonObjectStatus(worktreeRegistryPath(projectRoot));
1529
+ const features = await listFeatures(projectRoot, { repair: false });
1530
+ const worktreeRegistry = await readFeatureWorktreeRegistry(projectRoot, { repair: false });
1430
1531
  const activeFeatureEntry = worktreeRegistry.entries.find((entry) => entry.featureId === activeFeature);
1431
1532
  const activeFeatureWorkspacePath = activeFeatureEntry
1432
1533
  ? resolveFeatureWorkspacePath(projectRoot, activeFeatureEntry)
1433
1534
  : "";
1434
1535
  checks.push({
1435
1536
  name: "state:active_feature_meta",
1436
- ok: await exists(path.join(projectRoot, RUNTIME_ROOT, "state", "active-feature.json")),
1537
+ ok: activeMetaStatus.exists,
1437
1538
  details: `${RUNTIME_ROOT}/state/active-feature.json must exist`
1438
1539
  });
1540
+ checks.push({
1541
+ name: "state:active_feature_meta_valid_json",
1542
+ ok: activeMetaStatus.ok,
1543
+ details: activeMetaStatus.ok
1544
+ ? `${RUNTIME_ROOT}/state/active-feature.json parsed successfully`
1545
+ : `${RUNTIME_ROOT}/state/active-feature.json is invalid: ${activeMetaStatus.error ?? "unknown error"}`
1546
+ });
1439
1547
  checks.push({
1440
1548
  name: "state:worktree_registry_exists",
1441
- ok: await exists(worktreeRegistryPath(projectRoot)),
1549
+ ok: worktreeRegistryStatus.exists,
1442
1550
  details: `${RUNTIME_ROOT}/state/worktrees.json must exist and track feature->worktree mapping`
1443
1551
  });
1552
+ checks.push({
1553
+ name: "state:worktree_registry_valid_json",
1554
+ ok: worktreeRegistryStatus.ok,
1555
+ details: worktreeRegistryStatus.ok
1556
+ ? `${RUNTIME_ROOT}/state/worktrees.json parsed successfully`
1557
+ : `${RUNTIME_ROOT}/state/worktrees.json is invalid: ${worktreeRegistryStatus.error ?? "unknown error"}`
1558
+ });
1444
1559
  checks.push({
1445
1560
  name: "state:active_feature_exists",
1446
1561
  ok: features.includes(activeFeature),
@@ -1590,7 +1705,9 @@ export async function doctorChecks(projectRoot, options = {}) {
1590
1705
  ok: await exists(path.join(projectRoot, RUNTIME_ROOT, "runs")),
1591
1706
  details: `${RUNTIME_ROOT}/runs must exist for archived feature snapshots`
1592
1707
  });
1593
- const delegation = await checkMandatoryDelegations(projectRoot, flowState.currentStage);
1708
+ const delegation = await checkMandatoryDelegations(projectRoot, flowState.currentStage, {
1709
+ repairFeatureSystem: false
1710
+ });
1594
1711
  const missingEvidenceNote = delegation.missingEvidence && delegation.missingEvidence.length > 0
1595
1712
  ? ` (role-switch rows without evidenceRefs: ${delegation.missingEvidence.join(", ")})`
1596
1713
  : "";
@@ -524,6 +524,34 @@ function stagesInResults(caseResults) {
524
524
  set.add(c.stage);
525
525
  return FLOW_STAGES.filter((s) => set.has(s));
526
526
  }
527
+ const MAX_PARALLEL_CASES = 4;
528
+ async function runCasesWithBoundedConcurrency(items, concurrency, worker) {
529
+ if (items.length === 0) {
530
+ return [];
531
+ }
532
+ const limit = Math.max(1, Math.min(concurrency, items.length));
533
+ if (limit === 1) {
534
+ const results = [];
535
+ for (let i = 0; i < items.length; i += 1) {
536
+ results.push(await worker(items[i], i));
537
+ }
538
+ return results;
539
+ }
540
+ const results = new Array(items.length);
541
+ let cursor = 0;
542
+ const runners = Array.from({ length: limit }, async () => {
543
+ while (true) {
544
+ const index = cursor;
545
+ cursor += 1;
546
+ if (index >= items.length) {
547
+ return;
548
+ }
549
+ results[index] = await worker(items[index], index);
550
+ }
551
+ });
552
+ await Promise.all(runners);
553
+ return results;
554
+ }
527
555
  /**
528
556
  * Main eval runner. Dispatches between fixture-backed verification, the
529
557
  * single-stage agent-with-tools loop, and the multi-stage workflow
@@ -653,8 +681,11 @@ export async function runEval(options) {
653
681
  }
654
682
  }
655
683
  else {
656
- for (let i = 0; i < corpus.length; i += 1) {
657
- const item = corpus[i];
684
+ // Only parallelize fixture/rules verification passes that do not depend on
685
+ // LLM judge/agent loops. Those modes touch cost guards and retries where
686
+ // ordered execution is safer.
687
+ const caseConcurrency = flags.runJudge || flags.runAgent ? 1 : MAX_PARALLEL_CASES;
688
+ const results = await runCasesWithBoundedConcurrency(corpus, caseConcurrency, async (item, i) => {
658
689
  progress.emit({
659
690
  kind: "case-start",
660
691
  caseId: item.id,
@@ -682,8 +713,9 @@ export async function runEval(options) {
682
713
  durationMs: result.durationMs,
683
714
  ...(result.costUsd !== undefined ? { costUsd: result.costUsd } : {})
684
715
  });
685
- caseResults.push(result);
686
- }
716
+ return result;
717
+ });
718
+ caseResults.push(...results);
687
719
  }
688
720
  const stages = stagesInResults(caseResults);
689
721
  const baselines = await loadBaselinesByStage(options.projectRoot, stages);
@@ -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>;
@@ -178,7 +178,7 @@ async function writeRegistry(projectRoot, registry) {
178
178
  updatedAt: registry.updatedAt,
179
179
  entries: dedupeEntries(registry.entries)
180
180
  };
181
- await writeFileSafe(worktreeRegistryPath(projectRoot), `${JSON.stringify(normalized, null, 2)}\n`);
181
+ await writeFileSafe(worktreeRegistryPath(projectRoot), `${JSON.stringify(normalized, null, 2)}\n`, { mode: 0o600 });
182
182
  }
183
183
  async function readActiveFeatureMetaInternal(projectRoot) {
184
184
  const filePath = activeFeatureMetaPath(projectRoot);
@@ -213,7 +213,7 @@ async function writeActiveFeatureMeta(projectRoot, meta) {
213
213
  activeFeature: normalizedFeatureId(meta.activeFeature),
214
214
  updatedAt: meta.updatedAt
215
215
  };
216
- await writeFileSafe(activeFeatureMetaPath(projectRoot), `${JSON.stringify(normalized, null, 2)}\n`);
216
+ await writeFileSafe(activeFeatureMetaPath(projectRoot), `${JSON.stringify(normalized, null, 2)}\n`, { mode: 0o600 });
217
217
  }
218
218
  function registryHasFeature(registry, featureId) {
219
219
  return registry.entries.some((entry) => entry.featureId === featureId);
@@ -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
@@ -18,7 +18,10 @@ export interface DirectoryLockOptions {
18
18
  * The lock is removed in a finally block.
19
19
  */
20
20
  export declare function withDirectoryLock<T>(lockPath: string, fn: () => Promise<T>, options?: DirectoryLockOptions): Promise<T>;
21
- export declare function writeFileSafe(filePath: string, content: string): Promise<void>;
21
+ export interface WriteFileSafeOptions {
22
+ mode?: number;
23
+ }
24
+ export declare function writeFileSafe(filePath: string, content: string, options?: WriteFileSafeOptions): Promise<void>;
22
25
  export declare function exists(filePath: string): Promise<boolean>;
23
26
  export declare function removeIfExists(targetPath: string): Promise<void>;
24
27
  export declare function resolveProjectPath(cwd: string, relativePath: string): string;
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,21 +54,32 @@ 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
- export async function writeFileSafe(filePath, content) {
73
+ export async function writeFileSafe(filePath, content, options = {}) {
65
74
  await ensureDir(path.dirname(filePath));
66
75
  const tempPath = path.join(path.dirname(filePath), `.${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
67
- await fs.writeFile(tempPath, content, "utf8");
76
+ const targetMode = options.mode;
77
+ await fs.writeFile(tempPath, content, { encoding: "utf8", ...(targetMode !== undefined ? { mode: targetMode } : {}) });
68
78
  try {
69
79
  await fs.rename(tempPath, filePath);
80
+ if (targetMode !== undefined) {
81
+ await fs.chmod(filePath, targetMode).catch(() => undefined);
82
+ }
70
83
  }
71
84
  catch (error) {
72
85
  const code = error?.code;
@@ -78,6 +91,9 @@ export async function writeFileSafe(filePath, content) {
78
91
  if (code === "EXDEV") {
79
92
  try {
80
93
  await fs.copyFile(tempPath, filePath);
94
+ if (targetMode !== undefined) {
95
+ await fs.chmod(filePath, targetMode).catch(() => undefined);
96
+ }
81
97
  }
82
98
  finally {
83
99
  await fs.unlink(tempPath).catch(() => undefined);
@@ -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) {
@@ -124,7 +133,7 @@ async function writeReconciliationNotices(projectRoot, payload) {
124
133
  await writeFileSafe(filePath, `${JSON.stringify({
125
134
  schemaVersion: RECONCILIATION_NOTICES_SCHEMA_VERSION,
126
135
  notices: payload.notices
127
- }, null, 2)}\n`);
136
+ }, null, 2)}\n`, { mode: 0o600 });
128
137
  }
129
138
  export function classifyReconciliationNotices(flowState, notices) {
130
139
  const activeBlocked = [];