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/doctor.js CHANGED
@@ -3,18 +3,18 @@ import path from "node:path";
3
3
  import { execFile } from "node:child_process";
4
4
  import { pathToFileURL } from "node:url";
5
5
  import { promisify } from "node:util";
6
- import { REQUIRED_DIRS, RUNTIME_ROOT } from "./constants.js";
6
+ import { REQUIRED_DIRS, RUNTIME_ROOT, UTILITY_COMMANDS } from "./constants.js";
7
7
  import { CCLAW_AGENTS } from "./content/core-agents.js";
8
8
  import { detectAdvancedKeys, 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,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`
@@ -547,8 +578,8 @@ export async function doctorChecks(projectRoot, options = {}) {
547
578
  ok: agentsBlockOk,
548
579
  details: `${agentsFile} must contain the managed cclaw marker block with routing, verification, and minimal detail pointer`
549
580
  });
550
- // Utility commands
551
- for (const cmd of ["learn", "next", "ideate", "status", "tree", "diff", "feature", "tdd-log", "retro", "compound", "rewind"]) {
581
+ // Utility commands — keep in sync with UTILITY_COMMANDS (src/constants.ts)
582
+ for (const cmd of UTILITY_COMMANDS) {
552
583
  const cmdPath = path.join(projectRoot, RUNTIME_ROOT, "commands", `${cmd}.md`);
553
584
  checks.push({
554
585
  name: `utility_command:${cmd}`,
@@ -815,7 +846,7 @@ export async function doctorChecks(projectRoot, options = {}) {
815
846
  });
816
847
  checks.push({
817
848
  name: `shim:codex:${skillName}:frontmatter`,
818
- ok,
849
+ ok: frontmatterOk,
819
850
  details: frontmatterOk
820
851
  ? `${skillPath} has \`name: ${skillName}\` frontmatter`
821
852
  : ok
@@ -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;
97
- export declare function getTransitionGuards(from: FlowStage, to: FlowStage): string[];
98
+ export declare function getAvailableTransitions(from: FlowStage, track?: FlowTrack): TransitionRule[];
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,7 +100,35 @@ 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
  }
100
- export function getTransitionGuards(from, to) {
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
+ }
117
+ export function getTransitionGuards(from, to, track = "standard") {
118
+ // Natural forward edge on this track: derive guards fresh from the
119
+ // track-specific gate schema. `TRANSITION_RULES` collapses shared edges
120
+ // across tracks (first-registered wins), so reading guards directly
121
+ // from the track-aware schema avoids silently dropping gates that only
122
+ // the current track requires (e.g. `tdd_traceable_to_plan` on standard
123
+ // gets lost if quick was registered first).
124
+ const ordered = TRACK_STAGES[track];
125
+ const fromIdx = ordered.indexOf(from);
126
+ if (fromIdx >= 0 && ordered[fromIdx + 1] === to) {
127
+ return stageGateIds(from, track);
128
+ }
129
+ // Non-neighbour edges (e.g. `review -> tdd` with `review_verdict_blocked`)
130
+ // carry special guards not derivable from a stage's gate catalog; fall
131
+ // back to the pre-computed rule table.
101
132
  const match = TRANSITION_RULES.find((rule) => rule.from === from && rule.to === to);
102
133
  return match ? [...match.guards] : [];
103
134
  }
@@ -1,4 +1,13 @@
1
1
  export declare function ensureDir(dirPath: string): Promise<void>;
2
+ /**
3
+ * Strip a leading UTF-8 BOM (U+FEFF) if present. Many editors (VS Code on
4
+ * Windows, Notepad, some CI tools) silently prepend a BOM when saving
5
+ * UTF-8; when the file is then split on `\n` the first line keeps the
6
+ * invisible BOM and `JSON.parse` rejects it, which caused the first
7
+ * knowledge.jsonl entry to be silently dropped on load. Treat BOM as a
8
+ * no-op at read time so the rest of the pipeline sees clean UTF-8.
9
+ */
10
+ export declare function stripBom(text: string): string;
2
11
  export interface DirectoryLockOptions {
3
12
  retries?: number;
4
13
  retryDelayMs?: number;
package/dist/fs-utils.js CHANGED
@@ -3,6 +3,17 @@ import path from "node:path";
3
3
  export async function ensureDir(dirPath) {
4
4
  await fs.mkdir(dirPath, { recursive: true });
5
5
  }
6
+ /**
7
+ * Strip a leading UTF-8 BOM (U+FEFF) if present. Many editors (VS Code on
8
+ * Windows, Notepad, some CI tools) silently prepend a BOM when saving
9
+ * UTF-8; when the file is then split on `\n` the first line keeps the
10
+ * invisible BOM and `JSON.parse` rejects it, which caused the first
11
+ * knowledge.jsonl entry to be silently dropped on load. Treat BOM as a
12
+ * no-op at read time so the rest of the pipeline sees clean UTF-8.
13
+ */
14
+ export function stripBom(text) {
15
+ return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
16
+ }
6
17
  function sleep(ms) {
7
18
  return new Promise((resolve) => setTimeout(resolve, ms));
8
19
  }
@@ -16,6 +27,7 @@ export async function withDirectoryLock(lockPath, fn, options = {}) {
16
27
  const staleAfterMs = options.staleAfterMs ?? 60_000;
17
28
  await ensureDir(path.dirname(lockPath));
18
29
  let acquired = false;
30
+ let lastError = null;
19
31
  for (let attempt = 0; attempt < retries; attempt += 1) {
20
32
  try {
21
33
  await fs.mkdir(lockPath);
@@ -23,6 +35,7 @@ export async function withDirectoryLock(lockPath, fn, options = {}) {
23
35
  break;
24
36
  }
25
37
  catch (error) {
38
+ lastError = error;
26
39
  const code = error?.code;
27
40
  if (code !== "EEXIST") {
28
41
  throw error;
@@ -41,20 +54,50 @@ export async function withDirectoryLock(lockPath, fn, options = {}) {
41
54
  }
42
55
  }
43
56
  if (!acquired) {
44
- 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})`);
45
59
  }
46
60
  try {
47
61
  return await fn();
48
62
  }
49
63
  finally {
50
- 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
+ });
51
71
  }
52
72
  }
53
73
  export async function writeFileSafe(filePath, content) {
54
74
  await ensureDir(path.dirname(filePath));
55
75
  const tempPath = path.join(path.dirname(filePath), `.${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
56
76
  await fs.writeFile(tempPath, content, "utf8");
57
- await fs.rename(tempPath, filePath);
77
+ try {
78
+ await fs.rename(tempPath, filePath);
79
+ }
80
+ catch (error) {
81
+ const code = error?.code;
82
+ // `rename` fails with EXDEV when the temp file and target live on
83
+ // different filesystems (container bind mounts, tmpfs + rootfs,
84
+ // cross-volume setups). Fall back to copy + unlink so atomic writes
85
+ // still work — copyFile is not fully atomic but is the best we can
86
+ // do across devices, and we remove the temp even if copy fails.
87
+ if (code === "EXDEV") {
88
+ try {
89
+ await fs.copyFile(tempPath, filePath);
90
+ }
91
+ finally {
92
+ await fs.unlink(tempPath).catch(() => undefined);
93
+ }
94
+ return;
95
+ }
96
+ // Other errors: try to clean up the temp to avoid littering the
97
+ // directory with orphaned `.tmp-<pid>-*` files, then rethrow.
98
+ await fs.unlink(tempPath).catch(() => undefined);
99
+ throw error;
100
+ }
58
101
  }
59
102
  export async function exists(filePath) {
60
103
  try {
@@ -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) {
@@ -212,7 +221,7 @@ export async function verifyCurrentStageGateEvidence(projectRoot, flowState) {
212
221
  const artifactPresent = await currentStageArtifactExists(projectRoot, stage, flowState.track);
213
222
  const shouldValidateArtifact = artifactPresent || catalog.passed.length > 0 || flowState.completedStages.includes(stage);
214
223
  if (shouldValidateArtifact) {
215
- const lint = await lintArtifact(projectRoot, stage);
224
+ const lint = await lintArtifact(projectRoot, stage, flowState.track);
216
225
  if (!lint.passed) {
217
226
  const failedRequired = lint.findings
218
227
  .filter((finding) => finding.required && !finding.found)
package/dist/gitignore.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { REQUIRED_GITIGNORE_PATTERNS } from "./constants.js";
4
- import { exists } from "./fs-utils.js";
4
+ import { exists, writeFileSafe } from "./fs-utils.js";
5
5
  export async function ensureGitignore(projectRoot) {
6
6
  const gitignorePath = path.join(projectRoot, ".gitignore");
7
7
  const currentContent = (await exists(gitignorePath))
@@ -15,7 +15,10 @@ export async function ensureGitignore(projectRoot) {
15
15
  }
16
16
  const base = lines.join("\n").replace(/\s+$/u, "");
17
17
  const suffix = `${base.length > 0 ? "\n" : ""}${missing.join("\n")}\n`;
18
- await fs.writeFile(gitignorePath, `${base}${suffix}`, "utf8");
18
+ // `writeFileSafe` performs a tmp-file + rename so a crash mid-write
19
+ // cannot leave `.gitignore` in a half-written state; the previous
20
+ // direct `fs.writeFile` could truncate the file on SIGKILL.
21
+ await writeFileSafe(gitignorePath, `${base}${suffix}`);
19
22
  }
20
23
  export async function removeGitignorePatterns(projectRoot) {
21
24
  const gitignorePath = path.join(projectRoot, ".gitignore");
@@ -30,7 +33,7 @@ export async function removeGitignorePatterns(projectRoot) {
30
33
  await fs.rm(gitignorePath, { force: true });
31
34
  }
32
35
  else {
33
- await fs.writeFile(gitignorePath, `${result}\n`, "utf8");
36
+ await writeFileSafe(gitignorePath, `${result}\n`);
34
37
  }
35
38
  }
36
39
  export async function gitignoreHasRequiredPatterns(projectRoot) {
@@ -54,7 +54,12 @@ const LEGACY_CODEX_SKILL_NAMES = [
54
54
  "cclaw-cc-next",
55
55
  "cclaw-cc-view",
56
56
  "cclaw-cc-ops",
57
- "cclaw-cc-ideate"
57
+ "cclaw-cc-ideate",
58
+ // Pre-v0.40 installed `/cc-learn` as a top-level skill before it was
59
+ // folded into `/cc-ops`. Without this entry the orphan stays behind
60
+ // after upgrade and Codex lists both the new in-thread workflow and
61
+ // the legacy slash command.
62
+ "cclaw-cc-learn"
58
63
  ];
59
64
  /**
60
65
  * Shims that older cclaw versions installed as top-level slash commands but
@@ -417,6 +422,11 @@ what the hook surface does and does not cover.
417
422
  are **not** gated by hooks — read
418
423
  \`.cclaw/references/harnesses/codex-playbook.md\` for what cclaw
419
424
  substitutes with in-turn agent steps for those call classes.
425
+ - Codex's \`SessionStart\` matcher only supports \`startup|resume\`. Claude
426
+ and Cursor also fire on \`clear\` and \`compact\`, so mid-session
427
+ context resets there re-inject cclaw's bootstrap automatically. In
428
+ Codex you must re-announce the active stage yourself after any
429
+ \`/clear\` or compaction — the skill does not reload implicitly.
420
430
  `;
421
431
  }
422
432
  function codexSkillMarkdown(command, skillName, skillFolder, commandFile) {