cclaw-cli 0.51.21 → 0.51.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/config.d.ts +8 -1
  2. package/dist/config.js +9 -6
  3. package/dist/content/hook-manifest.d.ts +2 -4
  4. package/dist/content/hook-manifest.js +4 -3
  5. package/dist/content/meta-skill.js +7 -9
  6. package/dist/content/next-command.js +2 -2
  7. package/dist/content/node-hooks.js +13 -3
  8. package/dist/content/review-loop.js +15 -5
  9. package/dist/content/review-prompts.js +1 -1
  10. package/dist/content/skills.js +3 -2
  11. package/dist/content/stage-schema.js +1 -0
  12. package/dist/content/stages/brainstorm.js +3 -3
  13. package/dist/content/stages/design.js +18 -17
  14. package/dist/content/stages/plan.js +2 -1
  15. package/dist/content/stages/review.js +10 -10
  16. package/dist/content/stages/scope.js +13 -13
  17. package/dist/content/stages/spec.js +7 -5
  18. package/dist/content/stages/tdd.js +2 -2
  19. package/dist/content/start-command.d.ts +4 -3
  20. package/dist/content/start-command.js +21 -17
  21. package/dist/content/templates.d.ts +1 -1
  22. package/dist/content/templates.js +48 -28
  23. package/dist/content/view-command.js +3 -1
  24. package/dist/delegation.js +28 -8
  25. package/dist/doctor.js +147 -21
  26. package/dist/gate-evidence.js +19 -7
  27. package/dist/harness-adapters.js +1 -5
  28. package/dist/install.js +87 -24
  29. package/dist/internal/advance-stage.js +90 -11
  30. package/dist/knowledge-store.d.ts +4 -1
  31. package/dist/knowledge-store.js +24 -14
  32. package/dist/retro-gate.d.ts +1 -0
  33. package/dist/retro-gate.js +9 -9
  34. package/dist/run-archive.js +19 -1
  35. package/dist/run-persistence.js +6 -2
  36. package/dist/tdd-cycle.js +6 -3
  37. package/package.json +1 -1
@@ -103,6 +103,9 @@ async function detectReviewTriggers(projectRoot) {
103
103
  return empty;
104
104
  }
105
105
  }
106
+ function hasValidWaiverReason(value) {
107
+ return typeof value === "string" && value.trim().length > 0;
108
+ }
106
109
  function isDelegationTokenUsage(value) {
107
110
  if (!value || typeof value !== "object" || Array.isArray(value))
108
111
  return false;
@@ -130,6 +133,7 @@ function isDelegationEntry(value) {
130
133
  Number.isFinite(o.retryCount) &&
131
134
  Number.isInteger(o.retryCount) &&
132
135
  o.retryCount >= 0);
136
+ const waiverOk = o.status !== "waived" || hasValidWaiverReason(o.waiverReason);
133
137
  return (typeof o.stage === "string" &&
134
138
  typeof o.agent === "string" &&
135
139
  modeOk &&
@@ -141,6 +145,7 @@ function isDelegationEntry(value) {
141
145
  (o.endTs === undefined || typeof o.endTs === "string") &&
142
146
  (o.taskId === undefined || typeof o.taskId === "string") &&
143
147
  (o.waiverReason === undefined || typeof o.waiverReason === "string") &&
148
+ waiverOk &&
144
149
  (o.runId === undefined || typeof o.runId === "string") &&
145
150
  (o.fulfillmentMode === undefined ||
146
151
  o.fulfillmentMode === "isolated" ||
@@ -165,8 +170,10 @@ function parseLedger(raw, runId) {
165
170
  for (const item of entriesRaw) {
166
171
  if (isDelegationEntry(item)) {
167
172
  const ts = item.startTs ?? item.ts ?? new Date().toISOString();
168
- const inferredFulfillmentMode = item.fulfillmentMode
169
- ?? (item.status === "completed" ? "isolated" : undefined);
173
+ const isLegacyCompletion = item.fulfillmentMode === undefined &&
174
+ item.schemaVersion === undefined &&
175
+ item.status === "completed";
176
+ const inferredFulfillmentMode = item.fulfillmentMode ?? (isLegacyCompletion ? "isolated" : undefined);
170
177
  entries.push({
171
178
  ...item,
172
179
  spanId: item.spanId ?? createSpanId(),
@@ -205,6 +212,9 @@ export async function appendDelegation(projectRoot, entry) {
205
212
  const filePath = delegationLogPath(projectRoot);
206
213
  const prior = await readDelegationLedger(projectRoot);
207
214
  const startTs = entry.startTs ?? entry.ts ?? new Date().toISOString();
215
+ if (entry.status === "waived" && !hasValidWaiverReason(entry.waiverReason)) {
216
+ throw new Error("waived delegation entries require a non-empty waiverReason");
217
+ }
208
218
  const stamped = { ...entry, runId: entry.runId ?? activeRunId };
209
219
  stamped.spanId = entry.spanId ?? createSpanId();
210
220
  stamped.startTs = startTs;
@@ -219,10 +229,19 @@ export async function appendDelegation(projectRoot, entry) {
219
229
  stamped.evidenceRefs = [];
220
230
  }
221
231
  if (stamped.status === "completed" && stamped.fulfillmentMode === undefined) {
222
- const config = await readConfig(projectRoot).catch(() => null);
223
- const harnesses = config?.harnesses ?? [];
224
- const fallbacks = harnesses.map((h) => HARNESS_ADAPTERS[h].capabilities.subagentFallback);
225
- stamped.fulfillmentMode = expectedFulfillmentMode(fallbacks);
232
+ const activeFallback = process.env.CCLAW_ACTIVE_HARNESS
233
+ ? HARNESS_ADAPTERS[process.env.CCLAW_ACTIVE_HARNESS]
234
+ ?.capabilities.subagentFallback
235
+ : undefined;
236
+ if (activeFallback) {
237
+ stamped.fulfillmentMode = expectedFulfillmentMode([activeFallback]);
238
+ }
239
+ else {
240
+ const config = await readConfig(projectRoot).catch(() => null);
241
+ const harnesses = config?.harnesses ?? [];
242
+ const fallbacks = harnesses.map((h) => HARNESS_ADAPTERS[h].capabilities.subagentFallback);
243
+ stamped.fulfillmentMode = expectedFulfillmentMode(fallbacks);
244
+ }
226
245
  }
227
246
  // Idempotency: if a caller (or a retried hook) tries to append a row
228
247
  // with a spanId that already exists in the ledger, treat it as a no-op
@@ -256,10 +275,11 @@ export function expectedFulfillmentMode(fallbacks) {
256
275
  return "harness-waiver";
257
276
  }
258
277
  export async function checkMandatoryDelegations(projectRoot, stage, options = {}) {
259
- const mandatory = stageSchema(stage).mandatoryDelegations;
260
- const { activeRunId } = await readFlowState(projectRoot, {
278
+ const flowState = await readFlowState(projectRoot, {
261
279
  repairFeatureSystem: options.repairFeatureSystem
262
280
  });
281
+ const mandatory = stageSchema(stage, flowState.track).mandatoryDelegations;
282
+ const { activeRunId } = flowState;
263
283
  const ledger = await readDelegationLedger(projectRoot);
264
284
  const forStage = ledger.entries.filter((e) => e.stage === stage);
265
285
  const forRun = forStage.filter((e) => e.runId === activeRunId);
package/dist/doctor.js CHANGED
@@ -25,6 +25,7 @@ import { LANGUAGE_RULE_PACK_DIR, LANGUAGE_RULE_PACK_FILES, LEGACY_LANGUAGE_RULE_
25
25
  import { validateHookDocument } from "./hook-schema.js";
26
26
  import { validateKnowledgeEntry } from "./knowledge-store.js";
27
27
  import { readSeedShelf } from "./content/seed-shelf.js";
28
+ import { evaluateRetroGate } from "./retro-gate.js";
28
29
  const execFileAsync = promisify(execFile);
29
30
  async function isGitRepo(projectRoot) {
30
31
  try {
@@ -149,18 +150,30 @@ function knowledgeRoutingSurfaceIsDiscoverable(content) {
149
150
  return ["trigger", "action", "origin_run"].every((term) => normalized.includes(term));
150
151
  }
151
152
  async function commandAvailable(command) {
153
+ const version = await commandVersion(command);
154
+ return version.available;
155
+ }
156
+ async function commandVersion(command, args = ["--version"]) {
152
157
  try {
153
158
  if (process.platform === "win32") {
154
159
  await execFileAsync("where", [command]);
155
- return true;
156
160
  }
157
- await execFileAsync(command, ["--version"]);
158
- return true;
161
+ const { stdout, stderr } = await execFileAsync(command, args);
162
+ return { available: true, output: `${stdout}${stderr}`.trim() };
159
163
  }
160
164
  catch {
161
- return false;
165
+ return { available: false, output: "" };
162
166
  }
163
167
  }
168
+ function parseNodeMajor(versionOutput) {
169
+ const match = /v?(\d+)\./u.exec(versionOutput);
170
+ if (!match)
171
+ return null;
172
+ return Number(match[1]);
173
+ }
174
+ function gitVersionLooksUsable(versionOutput) {
175
+ return /git version \d+\.\d+/iu.test(versionOutput);
176
+ }
164
177
  function stripJsonCommentsOutsideStrings(input) {
165
178
  let out = "";
166
179
  let i = 0;
@@ -308,6 +321,87 @@ async function opencodeRegistrationCheck(projectRoot) {
308
321
  }
309
322
  return { ok: false, details: `No opencode.json/opencode.jsonc found with plugin ${expected}` };
310
323
  }
324
+ async function initRecoveryCheck(projectRoot) {
325
+ const sentinelPath = path.join(projectRoot, RUNTIME_ROOT, "state", ".init-in-progress");
326
+ if (!(await exists(sentinelPath))) {
327
+ return { ok: true, details: "no partial init/sync sentinel found" };
328
+ }
329
+ let summary = `${RUNTIME_ROOT}/state/.init-in-progress sentinel present`;
330
+ try {
331
+ const parsed = JSON.parse(await fs.readFile(sentinelPath, "utf8"));
332
+ const operation = typeof parsed.operation === "string" ? parsed.operation : "unknown";
333
+ const startedAt = typeof parsed.startedAt === "string" ? parsed.startedAt : "unknown";
334
+ summary = `${summary} (operation=${operation}, startedAt=${startedAt})`;
335
+ }
336
+ catch {
337
+ summary = `${summary} (unreadable sentinel payload)`;
338
+ }
339
+ return {
340
+ ok: false,
341
+ details: `${summary}. Fix: inspect generated runtime files, then rerun cclaw sync or remove the sentinel only after confirming the runtime is complete.`
342
+ };
343
+ }
344
+ async function archiveIntegrityCheck(projectRoot) {
345
+ const runsDir = path.join(projectRoot, RUNTIME_ROOT, "runs");
346
+ if (!(await exists(runsDir))) {
347
+ return { ok: true, details: `${RUNTIME_ROOT}/runs is absent; no archives to inspect yet` };
348
+ }
349
+ let entries;
350
+ try {
351
+ entries = await fs.readdir(runsDir, { withFileTypes: true });
352
+ }
353
+ catch (error) {
354
+ const reason = error instanceof Error ? error.message : String(error);
355
+ return { ok: false, details: `unable to inspect ${RUNTIME_ROOT}/runs (${reason})` };
356
+ }
357
+ const problems = [];
358
+ for (const entry of entries) {
359
+ if (!entry.isDirectory())
360
+ continue;
361
+ const runId = entry.name;
362
+ const runPath = path.join(runsDir, runId);
363
+ const relRunPath = `${RUNTIME_ROOT}/runs/${runId}`;
364
+ if (await exists(path.join(runPath, ".archive-in-progress"))) {
365
+ problems.push(`${relRunPath}/.archive-in-progress sentinel present`);
366
+ }
367
+ const manifestPath = path.join(runPath, "archive-manifest.json");
368
+ if (!(await exists(manifestPath))) {
369
+ problems.push(`${relRunPath} missing archive-manifest.json`);
370
+ continue;
371
+ }
372
+ let manifest;
373
+ try {
374
+ manifest = JSON.parse(await fs.readFile(manifestPath, "utf8"));
375
+ }
376
+ catch (error) {
377
+ const reason = error instanceof Error ? error.message : String(error);
378
+ problems.push(`${relRunPath}/archive-manifest.json unreadable (${reason})`);
379
+ continue;
380
+ }
381
+ const stateFiles = Array.isArray(manifest.snapshottedStateFiles)
382
+ ? manifest.snapshottedStateFiles.filter((value) => typeof value === "string")
383
+ : [];
384
+ const stateDir = path.join(runPath, "state");
385
+ if (stateFiles.length > 0 && !(await exists(stateDir))) {
386
+ problems.push(`${relRunPath} manifest lists state snapshot files but state/ is missing`);
387
+ continue;
388
+ }
389
+ for (const stateFile of stateFiles) {
390
+ if (stateFile.endsWith("/"))
391
+ continue;
392
+ if (!(await exists(path.join(stateDir, stateFile)))) {
393
+ problems.push(`${relRunPath}/state missing ${stateFile} listed in manifest`);
394
+ }
395
+ }
396
+ }
397
+ if (problems.length === 0) {
398
+ return { ok: true, details: "no partial archive sentinels or incomplete archive snapshots found" };
399
+ }
400
+ return {
401
+ ok: false,
402
+ details: `${problems.join("; ")}. Fix: inspect the archive directory, retry archive if the active run was restored, or recover/rollback artifacts and state from the snapshot before removing the sentinel.`
403
+ };
404
+ }
311
405
  async function opencodePluginRuntimeShapeCheck(projectRoot) {
312
406
  const pluginPath = path.join(projectRoot, ".opencode/plugins/cclaw-plugin.mjs");
313
407
  if (!(await exists(pluginPath))) {
@@ -981,11 +1075,36 @@ export async function doctorChecks(projectRoot, options = {}) {
981
1075
  details: registration.details
982
1076
  });
983
1077
  }
984
- const hasNode = await commandAvailable("node");
1078
+ const nodeVersion = await commandVersion("node");
1079
+ const nodeMajor = parseNodeMajor(nodeVersion.output);
985
1080
  checks.push({
986
1081
  name: "capability:required:node",
987
- ok: hasNode,
988
- details: "node is required for cclaw runtime scripts and CLI wiring"
1082
+ ok: nodeVersion.available,
1083
+ details: nodeVersion.available
1084
+ ? `node binary available (${nodeVersion.output || "version unknown"})`
1085
+ : "node is required for cclaw runtime scripts and CLI wiring"
1086
+ });
1087
+ checks.push({
1088
+ name: "capability:required:node_version",
1089
+ ok: nodeVersion.available && nodeMajor !== null && nodeMajor >= 20,
1090
+ details: nodeVersion.available
1091
+ ? `node >=20 required; detected ${nodeVersion.output || "unknown version"}`
1092
+ : "node version check skipped because node binary is unavailable"
1093
+ });
1094
+ const gitVersion = await commandVersion("git");
1095
+ checks.push({
1096
+ name: "capability:required:git",
1097
+ ok: gitVersion.available,
1098
+ details: gitVersion.available
1099
+ ? `git binary available (${gitVersion.output || "version unknown"})`
1100
+ : "git is required for repository detection, hook setup, and doctor checks"
1101
+ });
1102
+ checks.push({
1103
+ name: "capability:required:git_version",
1104
+ ok: gitVersion.available && gitVersionLooksUsable(gitVersion.output),
1105
+ details: gitVersion.available
1106
+ ? `git version output: ${gitVersion.output || "unknown version"}`
1107
+ : "git version check skipped because git binary is unavailable"
989
1108
  });
990
1109
  const windowsHookConfigCandidates = [
991
1110
  path.join(projectRoot, ".claude/hooks/hooks.json"),
@@ -1083,12 +1202,7 @@ export async function doctorChecks(projectRoot, options = {}) {
1083
1202
  const key = `${trigger} => ${action}`;
1084
1203
  triggerActionCounts.set(key, (triggerActionCounts.get(key) ?? 0) + 1);
1085
1204
  }
1086
- const missing = requiredV2Fields.some((field) => {
1087
- if (field === "origin_run" && Object.prototype.hasOwnProperty.call(parsed, "origin_feature")) {
1088
- return false;
1089
- }
1090
- return !Object.prototype.hasOwnProperty.call(parsed, field);
1091
- });
1205
+ const missing = requiredV2Fields.some((field) => !Object.prototype.hasOwnProperty.call(parsed, field));
1092
1206
  if (missing) {
1093
1207
  missingSchemaV2Fields += 1;
1094
1208
  }
@@ -1388,17 +1502,17 @@ export async function doctorChecks(projectRoot, options = {}) {
1388
1502
  ? "no stale stages pending acknowledgement"
1389
1503
  : `stale stages pending acknowledgement: ${staleStages.join(", ")}`
1390
1504
  });
1391
- const retroRequired = flowState.completedStages.includes("ship");
1392
- const retroComplete = !retroRequired ||
1393
- (typeof flowState.retro.completedAt === "string" && flowState.retro.compoundEntries > 0);
1505
+ const retroGateStatus = await evaluateRetroGate(projectRoot, flowState);
1394
1506
  checks.push({
1395
1507
  name: "state:retro_gate",
1396
- ok: retroComplete,
1397
- details: retroComplete
1398
- ? retroRequired
1399
- ? `retro gate complete (${flowState.retro.compoundEntries} compound entries)`
1508
+ ok: retroGateStatus.completed,
1509
+ details: retroGateStatus.completed
1510
+ ? retroGateStatus.required
1511
+ ? retroGateStatus.skipped
1512
+ ? "retro gate complete (retro skipped with recorded closeout decision)"
1513
+ : `retro gate complete (${retroGateStatus.compoundEntries} compound entries)`
1400
1514
  : "retro gate not required yet (ship not completed)"
1401
- : "retro gate incomplete: ship flow requires recorded retrospective evidence."
1515
+ : "retro gate incomplete: ship flow requires recorded retrospective evidence or an explicit retro skip."
1402
1516
  });
1403
1517
  const tddLogPath = path.join(projectRoot, RUNTIME_ROOT, "state", "tdd-cycle-log.jsonl");
1404
1518
  const tddLogExists = await exists(tddLogPath);
@@ -1444,6 +1558,18 @@ export async function doctorChecks(projectRoot, options = {}) {
1444
1558
  ok: await exists(path.join(projectRoot, RUNTIME_ROOT, "runs")),
1445
1559
  details: `${RUNTIME_ROOT}/runs must exist for archived run snapshots`
1446
1560
  });
1561
+ const initRecovery = await initRecoveryCheck(projectRoot);
1562
+ checks.push({
1563
+ name: "state:init_recovery",
1564
+ ok: initRecovery.ok,
1565
+ details: initRecovery.details
1566
+ });
1567
+ const archiveIntegrity = await archiveIntegrityCheck(projectRoot);
1568
+ checks.push({
1569
+ name: "runs:archive_integrity",
1570
+ ok: archiveIntegrity.ok,
1571
+ details: archiveIntegrity.details
1572
+ });
1447
1573
  const delegation = await checkMandatoryDelegations(projectRoot, flowState.currentStage, {
1448
1574
  repairFeatureSystem: false
1449
1575
  });
@@ -336,11 +336,27 @@ export async function verifyCurrentStageGateEvidence(projectRoot, flowState) {
336
336
  if (stage === "design") {
337
337
  const researchGateRequired = schema.requiredGates.some((gate) => gate.id === "design_research_complete" && gate.tier === "required");
338
338
  if (researchGateRequired) {
339
+ const designMarkdown = await readArtifactMarkdown(projectRoot, "03-design.md");
340
+ const inlineResearchBody = designMarkdown
341
+ ? extractMarkdownSectionBody(designMarkdown, "Research Fleet Synthesis")
342
+ : null;
343
+ const inlineResearchLines = inlineResearchBody
344
+ ? inlineResearchBody
345
+ .split(/\r?\n/gu)
346
+ .map((line) => line.trim())
347
+ .filter((line) => line.length > 0)
348
+ .filter((line) => !/^\|?(?:[-:\s|])+$/u.test(line))
349
+ .filter((line) => !/\b(?:TODO|TBD|FIXME|pending)\b/iu.test(line) &&
350
+ !/<fill-in>/iu.test(line) &&
351
+ !/^>\s*Default path:/iu.test(line) &&
352
+ !/^\|\s*compact inline synthesis\s*\|\s*\|\s*\|\s*\|?\s*$/iu.test(line))
353
+ : [];
354
+ const inlineResearchComplete = inlineResearchLines.length > 0;
339
355
  const researchMarkdown = await readArtifactMarkdown(projectRoot, "02a-research.md");
340
- if (!researchMarkdown) {
341
- issues.push("design research gate blocked (design_research_complete): missing `.cclaw/artifacts/02a-research.md`.");
356
+ if (!inlineResearchComplete && !researchMarkdown) {
357
+ issues.push("design research gate blocked (design_research_complete): fill `Research Fleet Synthesis` in `.cclaw/artifacts/03-design.md`, or write `.cclaw/artifacts/02a-research.md` for deep/high-risk research.");
342
358
  }
343
- else {
359
+ else if (researchMarkdown) {
344
360
  const missingSections = [];
345
361
  for (const section of DESIGN_RESEARCH_REQUIRED_SECTIONS) {
346
362
  const body = extractMarkdownSectionBody(researchMarkdown, section);
@@ -353,10 +369,6 @@ export async function verifyCurrentStageGateEvidence(projectRoot, flowState) {
353
369
  .map((line) => line.trim())
354
370
  .filter((line) => line.length > 0)
355
371
  .filter((line) => !/^\|?(?:[-:\s|])+$/u.test(line));
356
- // `<fill-in>` needs its own check because `\b` does not match
357
- // around `<`/`>` (non-word characters), so the previous combined
358
- // pattern `\b(?:...|<fill-in>)\b` silently never matched placeholder
359
- // templates that used angle-bracket form.
360
372
  const nonPlaceholder = meaningfulLines.filter((line) => !/\b(?:TODO|TBD|FIXME|pending)\b/iu.test(line) &&
361
373
  !/<fill-in>/iu.test(line));
362
374
  if (nonPlaceholder.length === 0) {
@@ -134,11 +134,7 @@ export function harnessTier(harnessId) {
134
134
  capabilities.hookSurface === "full") {
135
135
  return "tier1";
136
136
  }
137
- if (capabilities.hookSurface === "full" ||
138
- capabilities.hookSurface === "plugin" ||
139
- capabilities.hookSurface === "limited" ||
140
- capabilities.nativeSubagentDispatch === "generic" ||
141
- capabilities.nativeSubagentDispatch === "partial") {
137
+ if (capabilities.hookSurface !== "none" || capabilities.nativeSubagentDispatch !== "none") {
142
138
  return "tier2";
143
139
  }
144
140
  return "tier3";
package/dist/install.js CHANGED
@@ -34,10 +34,17 @@ const OPENCODE_PLUGIN_REL_PATH = ".opencode/plugins/cclaw-plugin.mjs";
34
34
  const CURSOR_RULE_REL_PATH = ".cursor/rules/cclaw-workflow.mdc";
35
35
  const GIT_HOOK_MANAGED_MARKER = "cclaw-managed-git-hook";
36
36
  const GIT_HOOK_RUNTIME_REL_DIR = `${RUNTIME_ROOT}/hooks/git`;
37
+ const INIT_SENTINEL_FILE = ".init-in-progress";
37
38
  const execFileAsync = promisify(execFile);
38
39
  function runtimePath(projectRoot, ...segments) {
39
40
  return path.join(projectRoot, RUNTIME_ROOT, ...segments);
40
41
  }
42
+ async function writeInitSentinel(projectRoot, operation) {
43
+ const sentinelPath = runtimePath(projectRoot, "state", INIT_SENTINEL_FILE);
44
+ await ensureDir(path.dirname(sentinelPath));
45
+ await writeFileSafe(sentinelPath, `${JSON.stringify({ operation, startedAt: new Date().toISOString() }, null, 2)}\n`);
46
+ return sentinelPath;
47
+ }
41
48
  async function removeBestEffort(targetPath, recursive = false) {
42
49
  try {
43
50
  await fs.rm(targetPath, { recursive, force: true });
@@ -653,6 +660,54 @@ async function backupHookFile(projectRoot, hookFilePath, rawContent) {
653
660
  await pruneOldHookBackups(backupsDir);
654
661
  return backupPath;
655
662
  }
663
+ function normalizeHookCommandForDedupe(command) {
664
+ return command.trim().replace(/\s+/gu, " ").replace(/\\/gu, "/");
665
+ }
666
+ function dedupeHookEntryByCommand(entry, seenCommands) {
667
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
668
+ return entry;
669
+ }
670
+ const obj = entry;
671
+ let changed = false;
672
+ if (typeof obj.command === "string") {
673
+ const normalized = normalizeHookCommandForDedupe(obj.command);
674
+ if (seenCommands.has(normalized)) {
675
+ return undefined;
676
+ }
677
+ seenCommands.add(normalized);
678
+ }
679
+ if (Array.isArray(obj.hooks)) {
680
+ const hooks = [];
681
+ for (const nested of obj.hooks) {
682
+ const deduped = dedupeHookEntryByCommand(nested, seenCommands);
683
+ if (deduped !== undefined) {
684
+ hooks.push(deduped);
685
+ }
686
+ else {
687
+ changed = true;
688
+ }
689
+ }
690
+ if (hooks.length !== obj.hooks.length) {
691
+ changed = true;
692
+ }
693
+ if (hooks.length === 0 && typeof obj.command !== "string") {
694
+ return undefined;
695
+ }
696
+ return changed ? { ...obj, hooks } : entry;
697
+ }
698
+ return entry;
699
+ }
700
+ function dedupeHookEntriesByCommand(entries) {
701
+ const seenCommands = new Set();
702
+ const deduped = [];
703
+ for (const entry of entries) {
704
+ const next = dedupeHookEntryByCommand(entry, seenCommands);
705
+ if (next !== undefined) {
706
+ deduped.push(next);
707
+ }
708
+ }
709
+ return deduped;
710
+ }
656
711
  function mergeHookDocuments(existingDoc, generatedDoc) {
657
712
  const generatedRoot = toObject(generatedDoc) ?? {};
658
713
  const generatedHooks = toObject(generatedRoot.hooks) ?? {};
@@ -664,7 +719,7 @@ function mergeHookDocuments(existingDoc, generatedDoc) {
664
719
  const existingEntries = existingHooks[eventName];
665
720
  if (Array.isArray(generatedEntries)) {
666
721
  const preservedEntries = Array.isArray(existingEntries) ? existingEntries : [];
667
- mergedHooks[eventName] = [...generatedEntries, ...preservedEntries];
722
+ mergedHooks[eventName] = dedupeHookEntriesByCommand([...generatedEntries, ...preservedEntries]);
668
723
  continue;
669
724
  }
670
725
  // Defensive: malformed generated event payload must not wipe user hooks.
@@ -944,26 +999,34 @@ async function cleanStaleFiles(projectRoot) {
944
999
  // Keep user-owned custom assets under .cclaw/agents and .cclaw/skills.
945
1000
  // Legacy managed removals happen in cleanLegacyArtifacts() with explicit paths.
946
1001
  }
947
- async function materializeRuntime(projectRoot, config, forceStateReset) {
948
- const harnesses = config.harnesses;
949
- await ensureStructure(projectRoot);
950
- await cleanLegacyArtifacts(projectRoot);
951
- await cleanStaleFiles(projectRoot);
952
- await Promise.all([
953
- writeEntryCommands(projectRoot),
954
- writeSkills(projectRoot, config),
955
- writeArtifactTemplates(projectRoot),
956
- writeRulebook(projectRoot)
957
- ]);
958
- await writeState(projectRoot, config, forceStateReset);
959
- await ensureRunSystem(projectRoot, { createIfMissing: false });
960
- await ensureKnowledgeStore(projectRoot);
961
- await writeHooks(projectRoot, config);
962
- await syncDisabledHarnessArtifacts(projectRoot, harnesses);
963
- await syncManagedGitHooks(projectRoot, config);
964
- await syncHarnessShims(projectRoot, harnesses);
965
- await writeCursorWorkflowRule(projectRoot, harnesses);
966
- await ensureGitignore(projectRoot);
1002
+ async function materializeRuntime(projectRoot, config, forceStateReset, operation = "sync") {
1003
+ const sentinelPath = await writeInitSentinel(projectRoot, operation);
1004
+ try {
1005
+ const harnesses = config.harnesses;
1006
+ await ensureStructure(projectRoot);
1007
+ await cleanLegacyArtifacts(projectRoot);
1008
+ await cleanStaleFiles(projectRoot);
1009
+ await Promise.all([
1010
+ writeEntryCommands(projectRoot),
1011
+ writeSkills(projectRoot, config),
1012
+ writeArtifactTemplates(projectRoot),
1013
+ writeRulebook(projectRoot)
1014
+ ]);
1015
+ await writeState(projectRoot, config, forceStateReset);
1016
+ await ensureRunSystem(projectRoot, { createIfMissing: false });
1017
+ await ensureKnowledgeStore(projectRoot);
1018
+ await writeHooks(projectRoot, config);
1019
+ await syncDisabledHarnessArtifacts(projectRoot, harnesses);
1020
+ await syncManagedGitHooks(projectRoot, config);
1021
+ await syncHarnessShims(projectRoot, harnesses);
1022
+ await writeCursorWorkflowRule(projectRoot, harnesses);
1023
+ await ensureGitignore(projectRoot);
1024
+ await fs.unlink(sentinelPath).catch(() => undefined);
1025
+ }
1026
+ catch (error) {
1027
+ // Leave the sentinel in place so doctor can surface the interrupted run.
1028
+ throw error;
1029
+ }
967
1030
  }
968
1031
  export async function initCclaw(options) {
969
1032
  const baseConfig = createDefaultConfig(options.harnesses, options.track);
@@ -978,7 +1041,7 @@ export async function initCclaw(options) {
978
1041
  // and only appear in the on-disk file when the user sets them explicitly
979
1042
  // or a non-default value was detected (e.g. languageRulePacks).
980
1043
  await writeConfig(options.projectRoot, config, { mode: "minimal" });
981
- await materializeRuntime(options.projectRoot, config, true);
1044
+ await materializeRuntime(options.projectRoot, config, true, "init");
982
1045
  }
983
1046
  export async function syncCclaw(projectRoot) {
984
1047
  const configExists = await exists(configPath(projectRoot));
@@ -996,7 +1059,7 @@ export async function syncCclaw(projectRoot) {
996
1059
  await writeConfig(projectRoot, defaultConfig);
997
1060
  config = defaultConfig;
998
1061
  }
999
- await materializeRuntime(projectRoot, config, false);
1062
+ await materializeRuntime(projectRoot, config, false, "sync");
1000
1063
  }
1001
1064
  /**
1002
1065
  * Refresh generated files in `.cclaw/` without touching user-authored
@@ -1020,7 +1083,7 @@ export async function upgradeCclaw(projectRoot) {
1020
1083
  mode: "minimal",
1021
1084
  advancedKeysPresent
1022
1085
  });
1023
- await materializeRuntime(projectRoot, upgraded, false);
1086
+ await materializeRuntime(projectRoot, upgraded, false, "upgrade");
1024
1087
  }
1025
1088
  function stripManagedHookCommands(value) {
1026
1089
  if (!value || typeof value !== "object" || Array.isArray(value)) {