cclaw-cli 0.51.21 → 0.51.23

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 (48) hide show
  1. package/README.md +14 -13
  2. package/dist/config.d.ts +8 -1
  3. package/dist/config.js +9 -6
  4. package/dist/content/examples.js +2 -2
  5. package/dist/content/hook-manifest.d.ts +2 -4
  6. package/dist/content/hook-manifest.js +5 -7
  7. package/dist/content/learnings.js +5 -2
  8. package/dist/content/meta-skill.d.ts +1 -0
  9. package/dist/content/meta-skill.js +16 -9
  10. package/dist/content/next-command.js +2 -2
  11. package/dist/content/node-hooks.js +14 -4
  12. package/dist/content/review-loop.js +15 -5
  13. package/dist/content/review-prompts.js +1 -1
  14. package/dist/content/skills.js +16 -11
  15. package/dist/content/stage-command.d.ts +2 -0
  16. package/dist/content/stage-command.js +17 -0
  17. package/dist/content/stage-schema.js +1 -0
  18. package/dist/content/stages/brainstorm.js +3 -3
  19. package/dist/content/stages/design.js +18 -17
  20. package/dist/content/stages/plan.js +2 -1
  21. package/dist/content/stages/review.js +15 -15
  22. package/dist/content/stages/scope.js +14 -14
  23. package/dist/content/stages/spec.js +7 -5
  24. package/dist/content/stages/tdd.js +11 -4
  25. package/dist/content/start-command.d.ts +4 -3
  26. package/dist/content/start-command.js +21 -17
  27. package/dist/content/subagents.js +14 -4
  28. package/dist/content/templates.d.ts +1 -1
  29. package/dist/content/templates.js +49 -29
  30. package/dist/content/track-render-context.js +7 -0
  31. package/dist/content/view-command.js +3 -1
  32. package/dist/delegation.d.ts +2 -2
  33. package/dist/delegation.js +40 -13
  34. package/dist/doctor-registry.js +1 -1
  35. package/dist/doctor.js +222 -34
  36. package/dist/gate-evidence.js +19 -7
  37. package/dist/harness-adapters.d.ts +14 -11
  38. package/dist/harness-adapters.js +154 -22
  39. package/dist/install.js +116 -28
  40. package/dist/internal/advance-stage.js +90 -11
  41. package/dist/knowledge-store.d.ts +4 -1
  42. package/dist/knowledge-store.js +24 -14
  43. package/dist/retro-gate.d.ts +1 -0
  44. package/dist/retro-gate.js +9 -9
  45. package/dist/run-archive.js +19 -1
  46. package/dist/run-persistence.js +6 -2
  47. package/dist/tdd-cycle.js +6 -3
  48. package/package.json +1 -1
@@ -1,10 +1,11 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { RUNTIME_ROOT } from "./constants.js";
3
+ import { RUNTIME_ROOT, STAGE_TO_SKILL_FOLDER } from "./constants.js";
4
4
  import { conversationLanguagePolicyMarkdown } from "./content/language-policy.js";
5
5
  import { CCLAW_AGENTS, agentMarkdown } from "./content/core-agents.js";
6
6
  import { ironLawsAgentsMdBlock } from "./content/iron-laws.js";
7
7
  import { ensureDir, exists, writeFileSafe } from "./fs-utils.js";
8
+ import { FLOW_STAGES } from "./types.js";
8
9
  export const CCLAW_MARKER_START = "<!-- cclaw-start -->";
9
10
  export const CCLAW_MARKER_END = "<!-- cclaw-end -->";
10
11
  function escapeRegExp(value) {
@@ -46,12 +47,26 @@ const LEGACY_CODEX_SKILL_PREFIX = "cclaw-cc";
46
47
  * harness command directories so `/cc-learn` etc. do not linger.
47
48
  */
48
49
  const LEGACY_HARNESS_SHIMS = ["cc-learn.md"];
50
+ function stageShimFileName(stage) {
51
+ return `cc-${stage}.md`;
52
+ }
53
+ function stageShimSkillName(stage) {
54
+ return `cc-${stage}`;
55
+ }
49
56
  export function harnessShimFileNames() {
50
- return ["cc.md", ...UTILITY_SHIMS.map((shim) => shim.fileName)];
57
+ return [
58
+ "cc.md",
59
+ ...UTILITY_SHIMS.map((shim) => shim.fileName),
60
+ ...FLOW_STAGES.map((stage) => stageShimFileName(stage))
61
+ ];
51
62
  }
52
63
  /** Skill folder names cclaw writes under `<commandDir>` for skill-kind harnesses. */
53
64
  export function harnessShimSkillNames() {
54
- return [ENTRY_SHIM_SKILL_NAME, ...UTILITY_SHIMS.map((shim) => shim.skillName)];
65
+ return [
66
+ ENTRY_SHIM_SKILL_NAME,
67
+ ...UTILITY_SHIMS.map((shim) => shim.skillName),
68
+ ...FLOW_STAGES.map((stage) => stageShimSkillName(stage))
69
+ ];
55
70
  }
56
71
  export const HARNESS_ADAPTERS = {
57
72
  claude: {
@@ -85,7 +100,11 @@ export const HARNESS_ADAPTERS = {
85
100
  commandDir: ".opencode/commands",
86
101
  shimKind: "command",
87
102
  capabilities: {
88
- nativeSubagentDispatch: "partial",
103
+ // OpenCode supports project-local markdown subagents under
104
+ // `.opencode/agents/`; primary agents can invoke them via the Task
105
+ // tool or explicit `@agent` mention. cclaw materializes its core
106
+ // roster there, so mandatory delegations are real isolated subagents.
107
+ nativeSubagentDispatch: "full",
89
108
  hookSurface: "plugin",
90
109
  // OpenCode exposes a native `question` tool (header + options +
91
110
  // custom-answer fallback, multi-question navigation). It is
@@ -95,7 +114,7 @@ export const HARNESS_ADAPTERS = {
95
114
  // in generated harness guidance; skills fall back to the shared
96
115
  // plain-text lettered list when the tool is denied or unavailable.
97
116
  structuredAsk: "question",
98
- subagentFallback: "role-switch"
117
+ subagentFallback: "native"
99
118
  }
100
119
  },
101
120
  codex: {
@@ -103,8 +122,10 @@ export const HARNESS_ADAPTERS = {
103
122
  // Codex CLI reads skills from the universal `.agents/skills/` path
104
123
  // (OpenAI Codex 0.89, Jan 2026). It does NOT have a native
105
124
  // `.codex/commands/*` slash-command discovery — cclaw installs
106
- // its entry points as skills here. Since v0.114 (Mar 2026) Codex
107
- // also exposes lifecycle hooks via `.codex/hooks.json`, behind
125
+ // its entry points as skills here. Current Codex releases also support
126
+ // native parallel subagents and project-local `.codex/agents/*.toml`
127
+ // custom agents; cclaw materializes its core roster there. Since v0.114
128
+ // (Mar 2026) Codex also exposes lifecycle hooks via `.codex/hooks.json`, behind
108
129
  // the `[features] codex_hooks = true` feature flag in
109
130
  // `~/.codex/config.toml`. cclaw writes that file on sync and
110
131
  // `hookSurface: "limited"` records the reality: SessionStart /
@@ -113,7 +134,7 @@ export const HARNESS_ADAPTERS = {
113
134
  commandDir: ".agents/skills",
114
135
  shimKind: "skill",
115
136
  capabilities: {
116
- nativeSubagentDispatch: "none",
137
+ nativeSubagentDispatch: "full",
117
138
  hookSurface: "limited",
118
139
  // Codex CLI exposes `request_user_input` — an experimental tool
119
140
  // that asks 1-3 short questions and returns the user's answers.
@@ -123,10 +144,29 @@ export const HARNESS_ADAPTERS = {
123
144
  // it into generated harness guidance. The shared plain-text
124
145
  // lettered list is the documented fallback when the tool is unavailable.
125
146
  structuredAsk: "request_user_input",
126
- subagentFallback: "role-switch"
147
+ subagentFallback: "native"
127
148
  }
128
149
  }
129
150
  };
151
+ export function harnessDispatchSurface(harnessId) {
152
+ switch (harnessId) {
153
+ case "claude":
154
+ return "Use Claude Code Task with the cclaw agent name as subagent_type; record fulfillmentMode: \"isolated\".";
155
+ case "cursor":
156
+ return "Use Cursor Subagent/Task with a generic subagent_type (explore for read-only mapping, generalPurpose for broader work, shell/browser-use when specifically needed) and paste the cclaw role prompt; record fulfillmentMode: \"generic-dispatch\" with evidenceRefs.";
157
+ case "opencode":
158
+ return "Use OpenCode subagents: invoke the generated .opencode/agents/<agent>.md agent via Task or @<agent>, run independent agents in parallel when safe, then record fulfillmentMode: \"isolated\".";
159
+ case "codex":
160
+ return "Use Codex native subagents: ask Codex to spawn the generated .codex/agents/<agent>.toml agent(s) by name, wait for all results, then record fulfillmentMode: \"isolated\".";
161
+ }
162
+ }
163
+ export function harnessDispatchFallback(harnessId) {
164
+ const adapter = HARNESS_ADAPTERS[harnessId];
165
+ if (adapter.capabilities.subagentFallback !== "role-switch") {
166
+ return "Role-switch is only a degradation path if the active runtime cannot expose the declared dispatch surface; include non-empty evidenceRefs when used.";
167
+ }
168
+ return "Use a visible role-switch pass with non-empty evidenceRefs because this harness has no true dispatch surface.";
169
+ }
130
170
  export function harnessTier(harnessId) {
131
171
  const capabilities = HARNESS_ADAPTERS[harnessId].capabilities;
132
172
  if (capabilities.nativeSubagentDispatch === "full" &&
@@ -134,11 +174,7 @@ export function harnessTier(harnessId) {
134
174
  capabilities.hookSurface === "full") {
135
175
  return "tier1";
136
176
  }
137
- if (capabilities.hookSurface === "full" ||
138
- capabilities.hookSurface === "plugin" ||
139
- capabilities.hookSurface === "limited" ||
140
- capabilities.nativeSubagentDispatch === "generic" ||
141
- capabilities.nativeSubagentDispatch === "partial") {
177
+ if (capabilities.hookSurface !== "none" || capabilities.nativeSubagentDispatch !== "none") {
142
178
  return "tier2";
143
179
  }
144
180
  return "tier3";
@@ -227,7 +263,7 @@ If the same approach fails three times in a row (same command, same finding, sam
227
263
  ### Detail Level
228
264
 
229
265
  - This managed AGENTS block is intentionally minimal for cross-project use.
230
- - Harness coverage is tiered: Tier1 (claude), Tier2 (cursor/opencode/codex codex has Bash-only tool hooks), Tier3 (fallback/manual-only).
266
+ - Subagent dispatch coverage: Claude/OpenCode/Codex support native isolated workers; Cursor uses generic Task dispatch. Codex still has Bash-only tool hooks.
231
267
  - Detailed operating procedures live in \`.cclaw/skills/using-cclaw/SKILL.md\`.
232
268
  - Keep preambles brief; re-announce role/stage only when either changes.
233
269
  - Subagent orchestration patterns: \`.cclaw/skills/subagent-dev/SKILL.md\` and \`.cclaw/skills/parallel-dispatch/SKILL.md\`.
@@ -340,6 +376,50 @@ Load and execute:
340
376
  ${utilityShimBehavior(command)}
341
377
  `;
342
378
  }
379
+ function stageShimContent(harness, stage) {
380
+ const shimName = stageShimSkillName(stage);
381
+ const skillPath = `${RUNTIME_ROOT}/skills/${STAGE_TO_SKILL_FOLDER[stage]}/SKILL.md`;
382
+ return `---
383
+ name: ${shimName}
384
+ description: Generated shim for ${harness}. Flow stage pointer; normal advancement uses /cc-next.
385
+ source: generated-by-cclaw
386
+ ---
387
+
388
+ # cclaw ${stage}
389
+
390
+ This is a thin compatibility shim for the \`${stage}\` flow stage.
391
+
392
+ Load and follow the authoritative stage skill:
393
+
394
+ - \`${skillPath}\`
395
+
396
+ Normal stage resume and advancement uses \`/cc-next\`. Use \`/cc-next\` to read
397
+ \`.cclaw/state/flow-state.json\`, select the active stage, and advance only after
398
+ that stage's gates pass. Do not duplicate the stage protocol here.
399
+ `;
400
+ }
401
+ function codexStageSkillMarkdown(stage) {
402
+ const skillName = stageShimSkillName(stage);
403
+ const skillPath = `${RUNTIME_ROOT}/skills/${STAGE_TO_SKILL_FOLDER[stage]}/SKILL.md`;
404
+ return `---
405
+ name: ${skillName}
406
+ description: Thin cclaw stage shim for /cc-${stage}. Load ${skillPath}; normal stage resume and advancement uses /cc-next.
407
+ source: generated-by-cclaw
408
+ ---
409
+
410
+ # cclaw /cc-${stage} (Codex adapter)
411
+
412
+ This is a thin compatibility shim for the \`${stage}\` flow stage.
413
+
414
+ Load and follow the authoritative stage skill:
415
+
416
+ - \`${skillPath}\`
417
+
418
+ Normal stage resume and advancement uses \`/cc-next\`. Use \`/cc-next\` to read
419
+ \`.cclaw/state/flow-state.json\`, select the active stage, and advance only after
420
+ that stage's gates pass. Do not duplicate the stage protocol here.
421
+ `;
422
+ }
343
423
  /**
344
424
  * Frontmatter `description` that triggers the skill when the user types any
345
425
  * of the classic cclaw slash-tokens. Codex's skill matcher runs on the skill
@@ -402,11 +482,12 @@ for the current hook surface and limitations.
402
482
 
403
483
  ## Honest caveats
404
484
 
405
- - Codex has no subagent dispatch primitive. Mandatory delegations
406
- fall back to **role-switch** announce the role, act in-session,
407
- append a completed row with \`evidenceRefs\` to
408
- \`.cclaw/state/delegation-log.json\`. Silent auto-waiver is disabled
409
- (v0.33+).
485
+ - Codex has native parallel subagents. cclaw writes project custom agents
486
+ under \`.codex/agents/*.toml\`; ask Codex to spawn the relevant cclaw
487
+ agent(s) by name, wait for their results, write evidence into the active
488
+ artifact, then append completed delegation rows with \`fulfillmentMode:
489
+ "isolated"\`. Use role-switch only if this Codex build has subagents
490
+ unavailable or disabled, and then include non-empty \`evidenceRefs\`.
410
491
  - Codex's \`PreToolUse\` / \`PostToolUse\` hooks currently only intercept
411
492
  the \`Bash\` tool. \`Write\`, \`Edit\`, \`WebSearch\`, and MCP tool calls
412
493
  are **not** gated by hooks — use \`cclaw doctor --explain\` for what cclaw
@@ -436,6 +517,9 @@ async function writeCommandKindShims(commandDir, harness) {
436
517
  for (const shim of UTILITY_SHIMS) {
437
518
  await writeFileSafe(path.join(commandDir, shim.fileName), utilityShimContent(harness, shim.command, shim.skillFolder, shim.commandFile));
438
519
  }
520
+ for (const stage of FLOW_STAGES) {
521
+ await writeFileSafe(path.join(commandDir, stageShimFileName(stage)), stageShimContent(harness, stage));
522
+ }
439
523
  for (const legacy of LEGACY_HARNESS_SHIMS) {
440
524
  const legacyPath = path.join(commandDir, legacy);
441
525
  try {
@@ -452,6 +536,9 @@ async function writeSkillKindShims(commandDir) {
452
536
  for (const shim of UTILITY_SHIMS) {
453
537
  await writeFileSafe(path.join(commandDir, shim.skillName, "SKILL.md"), codexSkillMarkdown(shim.command, shim.skillName, shim.skillFolder, shim.commandFile));
454
538
  }
539
+ for (const stage of FLOW_STAGES) {
540
+ await writeFileSafe(path.join(commandDir, stageShimSkillName(stage), "SKILL.md"), codexStageSkillMarkdown(stage));
541
+ }
455
542
  }
456
543
  /**
457
544
  * Legacy codex surfaces cclaw wrote before v0.39.0 that Codex CLI never
@@ -509,12 +596,57 @@ async function cleanupLegacyCodexSurfaces(projectRoot) {
509
596
  // directory absent or non-empty
510
597
  }
511
598
  }
512
- async function syncAgentFiles(projectRoot) {
599
+ function codexAgentToml(agent) {
600
+ const instructions = `${agent.body}\n\n${enhancedAgentInstruction(agent.name)}`.trim();
601
+ const sandboxMode = agent.tools.some((tool) => ["Write", "Edit", "Bash"].includes(tool))
602
+ ? "workspace-write"
603
+ : "read-only";
604
+ return [
605
+ `name = ${JSON.stringify(agent.name)}`,
606
+ `description = ${JSON.stringify(agent.description)}`,
607
+ `sandbox_mode = ${JSON.stringify(sandboxMode)}`,
608
+ 'developer_instructions = """',
609
+ instructions.replace(/"""/gu, '\"\"\"'),
610
+ '"""',
611
+ ""
612
+ ].join("\n");
613
+ }
614
+ function opencodeAgentMarkdown(agent) {
615
+ const editPermission = agent.tools.some((tool) => ["Write", "Edit"].includes(tool)) ? "ask" : "deny";
616
+ const bashPermission = agent.tools.includes("Bash") ? "ask" : "deny";
617
+ return `---
618
+ description: ${JSON.stringify(agent.description)}
619
+ mode: subagent
620
+ permission:
621
+ edit: ${editPermission}
622
+ bash: ${bashPermission}
623
+ ---
624
+
625
+ ${agentMarkdown(agent)}`;
626
+ }
627
+ function enhancedAgentInstruction(agentName) {
628
+ return `You are the cclaw ${agentName} subagent. Follow the parent prompt as the task boundary, produce evidence suitable for .cclaw/state/delegation-log.json, and do not recursively orchestrate other agents unless the parent explicitly asks.`;
629
+ }
630
+ async function syncAgentFiles(projectRoot, harnesses) {
513
631
  const agentsDir = path.join(projectRoot, RUNTIME_ROOT, "agents");
514
632
  await ensureDir(agentsDir);
515
633
  for (const agent of CCLAW_AGENTS) {
516
634
  await writeFileSafe(path.join(agentsDir, `${agent.name}.md`), agentMarkdown(agent));
517
635
  }
636
+ if (harnesses.includes("opencode")) {
637
+ const opencodeAgentsDir = path.join(projectRoot, ".opencode/agents");
638
+ await ensureDir(opencodeAgentsDir);
639
+ for (const agent of CCLAW_AGENTS) {
640
+ await writeFileSafe(path.join(opencodeAgentsDir, `${agent.name}.md`), opencodeAgentMarkdown(agent));
641
+ }
642
+ }
643
+ if (harnesses.includes("codex")) {
644
+ const codexAgentsDir = path.join(projectRoot, ".codex/agents");
645
+ await ensureDir(codexAgentsDir);
646
+ for (const agent of CCLAW_AGENTS) {
647
+ await writeFileSafe(path.join(codexAgentsDir, `${agent.name}.toml`), codexAgentToml(agent));
648
+ }
649
+ }
518
650
  }
519
651
  export async function syncHarnessShims(projectRoot, harnesses) {
520
652
  // Legacy codex cleanup is unconditional — even installs that never enabled
@@ -533,6 +665,6 @@ export async function syncHarnessShims(projectRoot, harnesses) {
533
665
  await writeCommandKindShims(commandDir, harness);
534
666
  }
535
667
  }
536
- await syncAgentFiles(projectRoot);
668
+ await syncAgentFiles(projectRoot, harnesses);
537
669
  await syncAgentsMd(projectRoot, harnesses);
538
670
  }
package/dist/install.js CHANGED
@@ -6,6 +6,7 @@ import { CCLAW_VERSION, FLOW_VERSION, REQUIRED_DIRS, RUNTIME_ROOT } from "./cons
6
6
  import { writeConfig, createDefaultConfig, readConfig, configPath, detectLanguageRulePacks, detectAdvancedKeys } from "./config.js";
7
7
  import { learnSkillMarkdown } from "./content/learnings.js";
8
8
  import { nextCommandContract, nextCommandSkillMarkdown } from "./content/next-command.js";
9
+ import { stageCommandShimMarkdown } from "./content/stage-command.js";
9
10
  import { ideateCommandContract, ideateCommandSkillMarkdown } from "./content/ideate-command.js";
10
11
  import { startCommandContract, startCommandSkillMarkdown } from "./content/start-command.js";
11
12
  import { viewCommandContract, viewCommandSkillMarkdown } from "./content/view-command.js";
@@ -34,10 +35,17 @@ const OPENCODE_PLUGIN_REL_PATH = ".opencode/plugins/cclaw-plugin.mjs";
34
35
  const CURSOR_RULE_REL_PATH = ".cursor/rules/cclaw-workflow.mdc";
35
36
  const GIT_HOOK_MANAGED_MARKER = "cclaw-managed-git-hook";
36
37
  const GIT_HOOK_RUNTIME_REL_DIR = `${RUNTIME_ROOT}/hooks/git`;
38
+ const INIT_SENTINEL_FILE = ".init-in-progress";
37
39
  const execFileAsync = promisify(execFile);
38
40
  function runtimePath(projectRoot, ...segments) {
39
41
  return path.join(projectRoot, RUNTIME_ROOT, ...segments);
40
42
  }
43
+ async function writeInitSentinel(projectRoot, operation) {
44
+ const sentinelPath = runtimePath(projectRoot, "state", INIT_SENTINEL_FILE);
45
+ await ensureDir(path.dirname(sentinelPath));
46
+ await writeFileSafe(sentinelPath, `${JSON.stringify({ operation, startedAt: new Date().toISOString() }, null, 2)}\n`);
47
+ return sentinelPath;
48
+ }
41
49
  async function removeBestEffort(targetPath, recursive = false) {
42
50
  try {
43
51
  await fs.rm(targetPath, { recursive, force: true });
@@ -436,6 +444,9 @@ async function writeEntryCommands(projectRoot) {
436
444
  await writeFileSafe(runtimePath(projectRoot, "commands", "next.md"), nextCommandContract());
437
445
  await writeFileSafe(runtimePath(projectRoot, "commands", "ideate.md"), ideateCommandContract());
438
446
  await writeFileSafe(runtimePath(projectRoot, "commands", "view.md"), viewCommandContract());
447
+ for (const stage of FLOW_STAGES) {
448
+ await writeFileSafe(runtimePath(projectRoot, "commands", `${stage}.md`), stageCommandShimMarkdown(stage));
449
+ }
439
450
  }
440
451
  function toObject(value) {
441
452
  if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -540,11 +551,20 @@ function mergeOpenCodePluginConfig(existingDoc, pluginRelPath) {
540
551
  if (!normalized.has(pluginRelPath)) {
541
552
  pluginsRaw.push(pluginRelPath);
542
553
  }
543
- const changed = !normalized.has(pluginRelPath) || !Array.isArray(root.plugin);
554
+ const permission = toObject(root.permission) ?? {};
555
+ const permissionChanged = permission.question !== "allow";
556
+ const changed = !normalized.has(pluginRelPath) ||
557
+ !Array.isArray(root.plugin) ||
558
+ permissionChanged ||
559
+ !toObject(root.permission);
544
560
  return {
545
561
  merged: {
546
562
  ...root,
547
- plugin: pluginsRaw
563
+ plugin: pluginsRaw,
564
+ permission: {
565
+ ...permission,
566
+ question: "allow"
567
+ }
548
568
  },
549
569
  changed
550
570
  };
@@ -653,6 +673,54 @@ async function backupHookFile(projectRoot, hookFilePath, rawContent) {
653
673
  await pruneOldHookBackups(backupsDir);
654
674
  return backupPath;
655
675
  }
676
+ function normalizeHookCommandForDedupe(command) {
677
+ return command.trim().replace(/\s+/gu, " ").replace(/\\/gu, "/");
678
+ }
679
+ function dedupeHookEntryByCommand(entry, seenCommands) {
680
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
681
+ return entry;
682
+ }
683
+ const obj = entry;
684
+ let changed = false;
685
+ if (typeof obj.command === "string") {
686
+ const normalized = normalizeHookCommandForDedupe(obj.command);
687
+ if (seenCommands.has(normalized)) {
688
+ return undefined;
689
+ }
690
+ seenCommands.add(normalized);
691
+ }
692
+ if (Array.isArray(obj.hooks)) {
693
+ const hooks = [];
694
+ for (const nested of obj.hooks) {
695
+ const deduped = dedupeHookEntryByCommand(nested, seenCommands);
696
+ if (deduped !== undefined) {
697
+ hooks.push(deduped);
698
+ }
699
+ else {
700
+ changed = true;
701
+ }
702
+ }
703
+ if (hooks.length !== obj.hooks.length) {
704
+ changed = true;
705
+ }
706
+ if (hooks.length === 0 && typeof obj.command !== "string") {
707
+ return undefined;
708
+ }
709
+ return changed ? { ...obj, hooks } : entry;
710
+ }
711
+ return entry;
712
+ }
713
+ function dedupeHookEntriesByCommand(entries) {
714
+ const seenCommands = new Set();
715
+ const deduped = [];
716
+ for (const entry of entries) {
717
+ const next = dedupeHookEntryByCommand(entry, seenCommands);
718
+ if (next !== undefined) {
719
+ deduped.push(next);
720
+ }
721
+ }
722
+ return deduped;
723
+ }
656
724
  function mergeHookDocuments(existingDoc, generatedDoc) {
657
725
  const generatedRoot = toObject(generatedDoc) ?? {};
658
726
  const generatedHooks = toObject(generatedRoot.hooks) ?? {};
@@ -664,7 +732,7 @@ function mergeHookDocuments(existingDoc, generatedDoc) {
664
732
  const existingEntries = existingHooks[eventName];
665
733
  if (Array.isArray(generatedEntries)) {
666
734
  const preservedEntries = Array.isArray(existingEntries) ? existingEntries : [];
667
- mergedHooks[eventName] = [...generatedEntries, ...preservedEntries];
735
+ mergedHooks[eventName] = dedupeHookEntriesByCommand([...generatedEntries, ...preservedEntries]);
668
736
  continue;
669
737
  }
670
738
  // Defensive: malformed generated event payload must not wipe user hooks.
@@ -878,7 +946,6 @@ async function cleanLegacyArtifacts(projectRoot) {
878
946
  await removeBestEffort(legacyPlugin);
879
947
  }
880
948
  for (const legacyRuntimeFile of [
881
- ...FLOW_STAGES.map((stage) => runtimePath(projectRoot, "commands", `${stage}.md`)),
882
949
  ...DEPRECATED_COMMAND_FILES.map((file) => runtimePath(projectRoot, "commands", file)),
883
950
  ...DEPRECATED_SKILL_FILES.map((segments) => runtimePath(projectRoot, "skills", ...segments)),
884
951
  ...DEPRECATED_STATE_FILES.map((file) => runtimePath(projectRoot, "state", file)),
@@ -944,26 +1011,34 @@ async function cleanStaleFiles(projectRoot) {
944
1011
  // Keep user-owned custom assets under .cclaw/agents and .cclaw/skills.
945
1012
  // Legacy managed removals happen in cleanLegacyArtifacts() with explicit paths.
946
1013
  }
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);
1014
+ async function materializeRuntime(projectRoot, config, forceStateReset, operation = "sync") {
1015
+ const sentinelPath = await writeInitSentinel(projectRoot, operation);
1016
+ try {
1017
+ const harnesses = config.harnesses;
1018
+ await ensureStructure(projectRoot);
1019
+ await cleanLegacyArtifacts(projectRoot);
1020
+ await cleanStaleFiles(projectRoot);
1021
+ await Promise.all([
1022
+ writeEntryCommands(projectRoot),
1023
+ writeSkills(projectRoot, config),
1024
+ writeArtifactTemplates(projectRoot),
1025
+ writeRulebook(projectRoot)
1026
+ ]);
1027
+ await writeState(projectRoot, config, forceStateReset);
1028
+ await ensureRunSystem(projectRoot, { createIfMissing: false });
1029
+ await ensureKnowledgeStore(projectRoot);
1030
+ await writeHooks(projectRoot, config);
1031
+ await syncDisabledHarnessArtifacts(projectRoot, harnesses);
1032
+ await syncManagedGitHooks(projectRoot, config);
1033
+ await syncHarnessShims(projectRoot, harnesses);
1034
+ await writeCursorWorkflowRule(projectRoot, harnesses);
1035
+ await ensureGitignore(projectRoot);
1036
+ await fs.unlink(sentinelPath).catch(() => undefined);
1037
+ }
1038
+ catch (error) {
1039
+ // Leave the sentinel in place so doctor can surface the interrupted run.
1040
+ throw error;
1041
+ }
967
1042
  }
968
1043
  export async function initCclaw(options) {
969
1044
  const baseConfig = createDefaultConfig(options.harnesses, options.track);
@@ -978,7 +1053,7 @@ export async function initCclaw(options) {
978
1053
  // and only appear in the on-disk file when the user sets them explicitly
979
1054
  // or a non-default value was detected (e.g. languageRulePacks).
980
1055
  await writeConfig(options.projectRoot, config, { mode: "minimal" });
981
- await materializeRuntime(options.projectRoot, config, true);
1056
+ await materializeRuntime(options.projectRoot, config, true, "init");
982
1057
  }
983
1058
  export async function syncCclaw(projectRoot) {
984
1059
  const configExists = await exists(configPath(projectRoot));
@@ -996,7 +1071,7 @@ export async function syncCclaw(projectRoot) {
996
1071
  await writeConfig(projectRoot, defaultConfig);
997
1072
  config = defaultConfig;
998
1073
  }
999
- await materializeRuntime(projectRoot, config, false);
1074
+ await materializeRuntime(projectRoot, config, false, "sync");
1000
1075
  }
1001
1076
  /**
1002
1077
  * Refresh generated files in `.cclaw/` without touching user-authored
@@ -1020,7 +1095,7 @@ export async function upgradeCclaw(projectRoot) {
1020
1095
  mode: "minimal",
1021
1096
  advancedKeysPresent
1022
1097
  });
1023
- await materializeRuntime(projectRoot, upgraded, false);
1098
+ await materializeRuntime(projectRoot, upgraded, false, "upgrade");
1024
1099
  }
1025
1100
  function stripManagedHookCommands(value) {
1026
1101
  if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -1185,7 +1260,7 @@ export async function uninstallCclaw(projectRoot) {
1185
1260
  try {
1186
1261
  const entries = await fs.readdir(codexSkillsRoot);
1187
1262
  for (const entry of entries) {
1188
- if (/^(?:cclaw-)?cc(?:-(?:next|view|ops|ideate))?$/u.test(entry)) {
1263
+ if (/^(?:cclaw-)?cc(?:-(?:next|view|ops|ideate|brainstorm|scope|design|spec|plan|tdd|review|ship))?$/u.test(entry)) {
1189
1264
  await fs.rm(path.join(codexSkillsRoot, entry), { recursive: true, force: true });
1190
1265
  }
1191
1266
  }
@@ -1195,6 +1270,17 @@ export async function uninstallCclaw(projectRoot) {
1195
1270
  }
1196
1271
  await removeIfEmpty(codexSkillsRoot);
1197
1272
  await removeIfEmpty(path.join(projectRoot, ".agents"));
1273
+ const managedAgentNames = [
1274
+ "planner",
1275
+ "reviewer",
1276
+ "security-reviewer",
1277
+ "test-author",
1278
+ "doc-updater"
1279
+ ];
1280
+ for (const agentName of managedAgentNames) {
1281
+ await removeBestEffort(path.join(projectRoot, ".opencode/agents", `${agentName}.md`));
1282
+ await removeBestEffort(path.join(projectRoot, ".codex/agents", `${agentName}.toml`));
1283
+ }
1198
1284
  for (const pluginPath of [
1199
1285
  path.join(projectRoot, ".opencode/plugins/viby-plugin.mjs"),
1200
1286
  path.join(projectRoot, ".opencode/plugins/opencode-plugin.mjs"),
@@ -1221,8 +1307,10 @@ export async function uninstallCclaw(projectRoot) {
1221
1307
  ".cursor/rules",
1222
1308
  ".cursor/commands",
1223
1309
  ".cursor",
1310
+ ".codex/agents",
1224
1311
  ".codex/commands",
1225
1312
  ".codex",
1313
+ ".opencode/agents",
1226
1314
  ".opencode/plugins",
1227
1315
  ".opencode/commands",
1228
1316
  ".opencode"