agentopology 0.5.2 → 0.7.0

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 (141) hide show
  1. package/README.md +5 -5
  2. package/dist/__tests__/e2e/compile.test.d.ts +12 -0
  3. package/dist/__tests__/e2e/compile.test.d.ts.map +1 -0
  4. package/dist/__tests__/e2e/compile.test.js +219 -0
  5. package/dist/__tests__/e2e/compile.test.js.map +1 -0
  6. package/dist/__tests__/e2e/roundtrip.test.d.ts +16 -0
  7. package/dist/__tests__/e2e/roundtrip.test.d.ts.map +1 -0
  8. package/dist/__tests__/e2e/roundtrip.test.js +178 -0
  9. package/dist/__tests__/e2e/roundtrip.test.js.map +1 -0
  10. package/dist/__tests__/e2e/scaffold-owns.test.d.ts +10 -0
  11. package/dist/__tests__/e2e/scaffold-owns.test.d.ts.map +1 -0
  12. package/dist/__tests__/e2e/scaffold-owns.test.js +163 -0
  13. package/dist/__tests__/e2e/scaffold-owns.test.js.map +1 -0
  14. package/dist/__tests__/e2e/sync-cycle.test.d.ts +10 -0
  15. package/dist/__tests__/e2e/sync-cycle.test.d.ts.map +1 -0
  16. package/dist/__tests__/e2e/sync-cycle.test.js +49 -0
  17. package/dist/__tests__/e2e/sync-cycle.test.js.map +1 -0
  18. package/dist/__tests__/global-setup.d.ts +7 -0
  19. package/dist/__tests__/global-setup.d.ts.map +1 -0
  20. package/dist/__tests__/global-setup.js +16 -0
  21. package/dist/__tests__/global-setup.js.map +1 -0
  22. package/dist/__tests__/live/claude-code.live.test.d.ts +20 -0
  23. package/dist/__tests__/live/claude-code.live.test.d.ts.map +1 -0
  24. package/dist/__tests__/live/claude-code.live.test.js +198 -0
  25. package/dist/__tests__/live/claude-code.live.test.js.map +1 -0
  26. package/dist/__tests__/live/zz-debug.test.d.ts +2 -0
  27. package/dist/__tests__/live/zz-debug.test.d.ts.map +1 -0
  28. package/dist/__tests__/live/zz-debug.test.js +4 -0
  29. package/dist/__tests__/live/zz-debug.test.js.map +1 -0
  30. package/dist/__tests__/open-findings.test.d.ts +10 -0
  31. package/dist/__tests__/open-findings.test.d.ts.map +1 -0
  32. package/dist/__tests__/open-findings.test.js +15 -0
  33. package/dist/__tests__/open-findings.test.js.map +1 -0
  34. package/dist/__tests__/spec-testing-sync.test.d.ts +10 -0
  35. package/dist/__tests__/spec-testing-sync.test.d.ts.map +1 -0
  36. package/dist/__tests__/spec-testing-sync.test.js +57 -0
  37. package/dist/__tests__/spec-testing-sync.test.js.map +1 -0
  38. package/dist/bindings/__tests__/bindings.test.js +19 -19
  39. package/dist/bindings/__tests__/bindings.test.js.map +1 -1
  40. package/dist/bindings/__tests__/claude-code-hybrid.test.js +5 -5
  41. package/dist/bindings/__tests__/claude-code-hybrid.test.js.map +1 -1
  42. package/dist/bindings/__tests__/hook-command.test.d.ts +9 -0
  43. package/dist/bindings/__tests__/hook-command.test.d.ts.map +1 -0
  44. package/dist/bindings/__tests__/hook-command.test.js +88 -0
  45. package/dist/bindings/__tests__/hook-command.test.js.map +1 -0
  46. package/dist/bindings/__tests__/hybrid-e2e.test.js +1 -1
  47. package/dist/bindings/__tests__/hybrid-e2e.test.js.map +1 -1
  48. package/dist/bindings/__tests__/mod-plugin.test.d.ts +9 -0
  49. package/dist/bindings/__tests__/mod-plugin.test.d.ts.map +1 -0
  50. package/dist/bindings/__tests__/mod-plugin.test.js +164 -0
  51. package/dist/bindings/__tests__/mod-plugin.test.js.map +1 -0
  52. package/dist/bindings/claude-code.d.ts +50 -1
  53. package/dist/bindings/claude-code.d.ts.map +1 -1
  54. package/dist/bindings/claude-code.js +636 -231
  55. package/dist/bindings/claude-code.js.map +1 -1
  56. package/dist/bindings/index.d.ts +7 -0
  57. package/dist/bindings/index.d.ts.map +1 -1
  58. package/dist/bindings/index.js +26 -7
  59. package/dist/bindings/index.js.map +1 -1
  60. package/dist/bindings/lib/prompt-fence.d.ts +22 -0
  61. package/dist/bindings/lib/prompt-fence.d.ts.map +1 -0
  62. package/dist/bindings/lib/prompt-fence.js +29 -0
  63. package/dist/bindings/lib/prompt-fence.js.map +1 -0
  64. package/dist/bindings/types.d.ts +6 -0
  65. package/dist/bindings/types.d.ts.map +1 -1
  66. package/dist/bindings/types.js.map +1 -1
  67. package/dist/cli/__tests__/cli.test.d.ts +1 -1
  68. package/dist/cli/__tests__/cli.test.js +1 -1
  69. package/dist/cli/index.js +124 -76
  70. package/dist/cli/index.js.map +1 -1
  71. package/dist/docs/content.d.ts.map +1 -1
  72. package/dist/docs/content.js +39 -3
  73. package/dist/docs/content.js.map +1 -1
  74. package/dist/docs/teach.d.ts.map +1 -1
  75. package/dist/docs/teach.js +46 -0
  76. package/dist/docs/teach.js.map +1 -1
  77. package/dist/import/__tests__/import.test.js +21 -9
  78. package/dist/import/__tests__/import.test.js.map +1 -1
  79. package/dist/import/claude-code.d.ts.map +1 -1
  80. package/dist/import/claude-code.js +128 -44
  81. package/dist/import/claude-code.js.map +1 -1
  82. package/dist/import/serializer.d.ts.map +1 -1
  83. package/dist/import/serializer.js +10 -0
  84. package/dist/import/serializer.js.map +1 -1
  85. package/dist/parser/__tests__/mod-hooks.test.d.ts +10 -0
  86. package/dist/parser/__tests__/mod-hooks.test.d.ts.map +1 -0
  87. package/dist/parser/__tests__/mod-hooks.test.js +136 -0
  88. package/dist/parser/__tests__/mod-hooks.test.js.map +1 -0
  89. package/dist/parser/ast.d.ts +29 -2
  90. package/dist/parser/ast.d.ts.map +1 -1
  91. package/dist/parser/ast.js.map +1 -1
  92. package/dist/parser/index.d.ts +6 -1
  93. package/dist/parser/index.d.ts.map +1 -1
  94. package/dist/parser/index.js +102 -25
  95. package/dist/parser/index.js.map +1 -1
  96. package/dist/parser/paths.d.ts +15 -0
  97. package/dist/parser/paths.d.ts.map +1 -0
  98. package/dist/parser/paths.js +34 -0
  99. package/dist/parser/paths.js.map +1 -0
  100. package/dist/parser/run.d.ts +45 -0
  101. package/dist/parser/run.d.ts.map +1 -0
  102. package/dist/parser/run.js +91 -0
  103. package/dist/parser/run.js.map +1 -0
  104. package/dist/parser/validator.d.ts +12 -0
  105. package/dist/parser/validator.d.ts.map +1 -1
  106. package/dist/parser/validator.js +268 -2
  107. package/dist/parser/validator.js.map +1 -1
  108. package/dist/scaffold/__tests__/safety.test.d.ts +11 -0
  109. package/dist/scaffold/__tests__/safety.test.d.ts.map +1 -0
  110. package/dist/scaffold/__tests__/safety.test.js +184 -0
  111. package/dist/scaffold/__tests__/safety.test.js.map +1 -0
  112. package/dist/scaffold/__tests__/scaffold.test.js +2 -1
  113. package/dist/scaffold/__tests__/scaffold.test.js.map +1 -1
  114. package/dist/scaffold/__tests__/settings-merge.test.js +40 -9
  115. package/dist/scaffold/__tests__/settings-merge.test.js.map +1 -1
  116. package/dist/scaffold/diff.d.ts +17 -0
  117. package/dist/scaffold/diff.d.ts.map +1 -0
  118. package/dist/scaffold/diff.js +66 -0
  119. package/dist/scaffold/diff.js.map +1 -0
  120. package/dist/scaffold/incremental.d.ts +49 -13
  121. package/dist/scaffold/incremental.d.ts.map +1 -1
  122. package/dist/scaffold/incremental.js +235 -95
  123. package/dist/scaffold/incremental.js.map +1 -1
  124. package/dist/scaffold/manifest.d.ts +8 -1
  125. package/dist/scaffold/manifest.d.ts.map +1 -1
  126. package/dist/scaffold/manifest.js +32 -8
  127. package/dist/scaffold/manifest.js.map +1 -1
  128. package/dist/scaffold/merge.d.ts +15 -4
  129. package/dist/scaffold/merge.d.ts.map +1 -1
  130. package/dist/scaffold/merge.js +123 -18
  131. package/dist/scaffold/merge.js.map +1 -1
  132. package/dist/scaffold/types.d.ts +17 -0
  133. package/dist/scaffold/types.d.ts.map +1 -1
  134. package/dist/sync/index.d.ts +1 -0
  135. package/dist/sync/index.d.ts.map +1 -1
  136. package/dist/sync/index.js +40 -2
  137. package/dist/sync/index.js.map +1 -1
  138. package/package.json +1 -1
  139. package/spec/grammar.md +24 -5
  140. package/spec/testing.md +85 -0
  141. package/spec/validation.md +8 -2
@@ -8,9 +8,11 @@
8
8
  */
9
9
  import { deduplicateFiles } from "./types.js";
10
10
  import { shellStub, STUB_MARKER } from "./lib/stub.js";
11
+ import { PROMPT_FENCE_CLOSE, PROMPT_FENCE_OPEN } from "./lib/prompt-fence.js";
11
12
  import { isWorkflowSeamAgent, seamFiles } from "./lib/seam.js";
12
13
  import { phaseOf as sharedPhaseOf } from "../resolve/phase.js";
13
14
  import { gateAnchors } from "../parser/ast.js";
15
+ import { isProjectPath, proseInRun, runParts, scriptNameFromRun } from "../parser/run.js";
14
16
  // ---------------------------------------------------------------------------
15
17
  // Helpers
16
18
  // ---------------------------------------------------------------------------
@@ -21,13 +23,43 @@ import { gateAnchors } from "../parser/ast.js";
21
23
  function convertMcpToolName(tool) {
22
24
  if (!tool.startsWith("mcp."))
23
25
  return tool;
24
- // mcp.server-name.method_name → mcp__server-name__method_name
25
- // Only replace the first two dots (mcp + server separator)
26
- const parts = tool.split(".");
27
- if (parts.length >= 3) {
28
- return `mcp__${parts[1]}__${parts.slice(2).join(".")}`;
26
+ // mcp.server → mcp__server (every tool on it); mcp.server.* → mcp__server__*;
27
+ // mcp.server.method_name → mcp__server__method_name. Only the first dot after
28
+ // the server separates; a dotted method name keeps its own dots.
29
+ const rest = tool.slice(4);
30
+ const dot = rest.indexOf(".");
31
+ if (dot === -1)
32
+ return `mcp__${rest}`;
33
+ return `mcp__${rest.slice(0, dot)}__${rest.slice(dot + 1)}`;
34
+ }
35
+ /**
36
+ * Whether Claude Code accepts `model` as written: an alias (optionally with a
37
+ * context suffix such as [1m]), inherit, or a full claude-* model id. Any other
38
+ * value (gpt-4o, gemini-2.5-pro) was written to the agent file unchecked.
39
+ */
40
+ function isClaudeCodeModel(model) {
41
+ return /^(?:sonnet|opus|haiku|fable)(?:\[[0-9a-z]+\])?$|^inherit$|^claude-[a-z0-9.-]+(?:\[[0-9a-z]+\])?$/.test(model);
42
+ }
43
+ /**
44
+ * Claude Code hook events for the language's universal names. A hook on a
45
+ * name Claude Code does not know never fires, so AgentStop used to be dead.
46
+ */
47
+ const UNIVERSAL_EVENTS = {
48
+ AgentStart: "SubagentStart",
49
+ AgentStop: "SubagentStop",
50
+ ToolUse: "PostToolUse",
51
+ Error: "PostToolUseFailure",
52
+ };
53
+ function claudeEvent(on) {
54
+ return UNIVERSAL_EVENTS[on] ?? on;
55
+ }
56
+ /** A settings.json hook handler for a command or prompt hook. */
57
+ function hookHandler(hook, command) {
58
+ // A prompt hook asks a model to judge the event; its run: is the prompt.
59
+ if (hook.type === "prompt") {
60
+ return { type: "prompt", prompt: hook.run, ...(hook.timeout ? { timeout: hook.timeout } : {}) };
29
61
  }
30
- return tool;
62
+ return { type: "command", command: command(), ...(hook.timeout ? { timeout: hook.timeout } : {}) };
31
63
  }
32
64
  /**
33
65
  * Downshift all markdown headings by one level so they nest properly
@@ -54,6 +86,8 @@ function toTitle(id) {
54
86
  */
55
87
  /** Characters that require quoting in YAML scalar values. */
56
88
  const YAML_SPECIAL = /[:#\[\]{}&*!|>'"%@`]/;
89
+ /** Plain scalars YAML reads as something other than a string (yes → true, 1.0 → a number). */
90
+ const YAML_NOT_STRING = /^(?:y|Y|yes|Yes|YES|n|N|no|No|NO|true|True|TRUE|false|False|FALSE|on|On|ON|off|Off|OFF|null|Null|NULL|~|[-+]?(?:\d[\d_]*)?\.?\d+(?:[eE][-+]?\d+)?|0x[0-9a-fA-F]+|0o[0-7]+|\.inf|\.nan)$/;
57
91
  function frontmatter(fields) {
58
92
  const lines = ["---"];
59
93
  for (const [key, value] of Object.entries(fields)) {
@@ -67,13 +101,13 @@ function frontmatter(fields) {
67
101
  lines.push(` - ${item}`);
68
102
  }
69
103
  }
70
- else if (typeof value === "boolean") {
104
+ else if (typeof value === "boolean" || typeof value === "number") {
71
105
  lines.push(`${key}: ${value}`);
72
106
  }
73
107
  else {
74
108
  // Auto-quote strings containing YAML-special characters
75
109
  const str = String(value);
76
- const needsQuotes = YAML_SPECIAL.test(str) || str.trim() !== str;
110
+ const needsQuotes = YAML_SPECIAL.test(str) || str.trim() !== str || str === "" || YAML_NOT_STRING.test(str);
77
111
  lines.push(`${key}: ${needsQuotes ? `"${str.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` : str}`);
78
112
  }
79
113
  }
@@ -105,7 +139,96 @@ function getDelegationMode(ast) {
105
139
  const orch = ast.nodes.find((n) => n.type === "orchestrator");
106
140
  return orch?.delegation === "inline" ? "inline" : "subagent";
107
141
  }
108
- /** Generate an empty directory marker (a .gitkeep file). */
142
+ function gateWiring(ast, gate) {
143
+ if (getDelegationMode(ast) === "inline")
144
+ return { kind: "none" };
145
+ const afters = gateAnchors(gate, "after");
146
+ if (afters.length > 0 && afters.every((a) => isRegisteredSubagentType(ast, a))) {
147
+ return { kind: "subagent-stop", anchors: afters };
148
+ }
149
+ const agents = gateAnchors(gate, "before").filter((b) => isRegisteredSubagentType(ast, b));
150
+ return agents.length > 0 ? { kind: "before-agent", agents } : { kind: "none" };
151
+ }
152
+ /** Agents a human approval step stands before: its direct flow successors. */
153
+ function approvalTargets(ast, human) {
154
+ if (getDelegationMode(ast) === "inline")
155
+ return [];
156
+ const out = ast.edges.filter((e) => e.from === human.id && !e.isError).map((e) => e.to);
157
+ return [...new Set(out)].filter((id) => isRegisteredSubagentType(ast, id));
158
+ }
159
+ /** The settings.json command for the topology's before-agent hook. */
160
+ function beforeAgentCommand(ast) {
161
+ return `bash "$CLAUDE_PROJECT_DIR/.claude/skills/${ast.topology.name}/scripts/before-agent.sh"`;
162
+ }
163
+ /**
164
+ * One PreToolUse hook on the Agent tool for the whole topology: before Claude
165
+ * Code launches a subagent it runs that agent's gates (from actions or
166
+ * humans), then asks the user to approve when a human step stands before it.
167
+ * Null when nothing needs it.
168
+ */
169
+ function generateBeforeAgentScript(ast) {
170
+ const name = ast.topology.name;
171
+ const steps = new Map();
172
+ const add = (agent, lines) => steps.set(agent, [...(steps.get(agent) ?? []), ...lines]);
173
+ for (const node of ast.nodes) {
174
+ if (node.type !== "gate")
175
+ continue;
176
+ const gate = node;
177
+ if (!gate.run || gate.behavior === "advisory" || !scriptNameFromRun(gate.run))
178
+ continue;
179
+ const wiring = gateWiring(ast, gate);
180
+ if (wiring.kind !== "before-agent")
181
+ continue;
182
+ for (const agent of wiring.agents) {
183
+ add(agent, [
184
+ ` # gate ${gate.id} (after: ${gateAnchors(gate, "after").join(", ") || "-"})`,
185
+ ` bash "$DIR/gate-${gate.id}.sh" >&2 || exit 2`,
186
+ ]);
187
+ }
188
+ }
189
+ for (const node of ast.nodes) {
190
+ if (node.type !== "human")
191
+ continue;
192
+ const human = node;
193
+ const reason = `Approval step "${human.id}"${human.description ? `: ${human.description}` : ""}`;
194
+ for (const agent of approvalTargets(ast, human)) {
195
+ add(agent, [` # approval step ${human.id}`, ` ask ${shellQuote(reason)}`]);
196
+ }
197
+ }
198
+ if (steps.size === 0)
199
+ return null;
200
+ const lines = [
201
+ "#!/usr/bin/env bash",
202
+ `# Before Claude Code launches a subagent of topology "${name}".`,
203
+ "# Auto-generated by agentopology scaffold. Wired in .claude/settings.json as a",
204
+ "# PreToolUse hook on the Agent tool. Exit 2 blocks the launch and shows the",
205
+ "# reason to Claude; an \"ask\" decision makes Claude Code ask the user first.",
206
+ "set -euo pipefail",
207
+ 'cd "$CLAUDE_PROJECT_DIR"',
208
+ `DIR=".claude/skills/${name}/scripts"`,
209
+ 'AGENT=$(jq -r \'.tool_input.subagent_type // ""\')',
210
+ "",
211
+ "ask() {",
212
+ " jq -n --arg r \"$1\" '{hookSpecificOutput: {hookEventName: \"PreToolUse\", permissionDecision: \"ask\", permissionDecisionReason: $r}}'",
213
+ "}",
214
+ "",
215
+ 'case "$AGENT" in',
216
+ ];
217
+ for (const [agent, body] of steps) {
218
+ lines.push(` ${agent})`, ...body, " ;;");
219
+ }
220
+ lines.push("esac", "exit 0", "");
221
+ return {
222
+ path: `.claude/skills/${name}/scripts/before-agent.sh`,
223
+ content: lines.join("\n"),
224
+ category: "script",
225
+ executable: true,
226
+ };
227
+ }
228
+ /** Single-quote a string for bash. */
229
+ function shellQuote(text) {
230
+ return `'${text.replace(/'/g, `'\\''`)}'`;
231
+ }
109
232
  function gitkeep(dirPath) {
110
233
  return { path: `${dirPath}/.gitkeep`, content: "", category: "machine" };
111
234
  }
@@ -251,7 +374,7 @@ function workflowRunLabel(ast, run) {
251
374
  */
252
375
  function mapPermissionMode(perm) {
253
376
  // Claude Code native values — pass through
254
- const nativeValues = ["plan", "default", "acceptEdits", "dontAsk", "bypassPermissions"];
377
+ const nativeValues = ["plan", "default", "acceptEdits", "auto", "dontAsk", "bypassPermissions"];
255
378
  if (nativeValues.includes(perm)) {
256
379
  // "default" is the default, omit it
257
380
  if (perm === "default")
@@ -264,14 +387,16 @@ function mapPermissionMode(perm) {
264
387
  return "plan";
265
388
  case "autonomous":
266
389
  return undefined; // default, omit
267
- case "auto":
268
- return undefined; // default, omit
390
+ case "confirm":
391
+ return undefined; // asks before each action: Claude Code's default
269
392
  case "interactive":
270
393
  return "acceptEdits";
271
394
  case "unrestricted":
395
+ case "bypass":
272
396
  return "bypassPermissions";
273
397
  default:
274
- return perm; // unknown — pass through as-is
398
+ // Claude Code rejects an unknown permissionMode; V27 already warned.
399
+ return undefined;
275
400
  }
276
401
  }
277
402
  /** Format a SchemaType to a human-readable string. Optionally resolves refs. */
@@ -325,45 +450,24 @@ function generateActionNodes(ast) {
325
450
  if (action.join) {
326
451
  lines.push(`# Join semantics: ${action.join}`);
327
452
  }
328
- switch (action.kind) {
329
- case "external":
330
- lines.push(`# External source: ${action.source ?? "unknown"}`);
331
- lines.push("");
332
- lines.push(`echo "TODO: implement external action — fetch from ${action.source ?? "source"}"`);
333
- break;
334
- case "inline":
335
- if (action.commands && action.commands.length > 0) {
336
- lines.push("");
337
- for (const cmd of action.commands) {
338
- lines.push(cmd);
339
- }
340
- }
341
- else {
342
- lines.push("");
343
- lines.push('echo "TODO: implement inline action"');
344
- }
345
- break;
346
- case "git":
347
- lines.push("");
348
- lines.push("# Git operation");
349
- if (action.commands && action.commands.length > 0) {
350
- for (const cmd of action.commands) {
351
- lines.push(cmd);
352
- }
353
- }
354
- else {
355
- lines.push('echo "TODO: implement git action"');
356
- }
357
- break;
358
- case "report":
359
- lines.push("");
360
- lines.push("# Report generation");
361
- lines.push('echo "TODO: implement report action"');
362
- break;
363
- default:
364
- lines.push("");
365
- lines.push(`echo "TODO: implement ${action.kind ?? "unknown"} action"`);
366
- break;
453
+ if (action.kind === "external")
454
+ lines.push(`# External source: ${action.source ?? "unknown"}`);
455
+ lines.push("");
456
+ // The action's own commands, whatever its kind: an external or report
457
+ // action used to drop them for a TODO line.
458
+ if (action.commands && action.commands.length > 0) {
459
+ lines.push(...action.commands);
460
+ }
461
+ else if (!action.kind || action.kind === "inline") {
462
+ // Done by the orchestrator in conversation (the playbook says how).
463
+ lines.push(`# Performed by the orchestrator in conversation; nothing to run.`);
464
+ }
465
+ else {
466
+ // A placeholder that reports itself as one (agentopology stubs), never a
467
+ // silent success.
468
+ lines.splice(3, 0, STUB_MARKER);
469
+ lines.push(`echo "TODO: implement ${action.kind ?? "this"} action ${action.id}" >&2`);
470
+ lines.push("exit 1");
367
471
  }
368
472
  lines.push("");
369
473
  files.push({
@@ -392,17 +496,24 @@ function generateAgents(ast) {
392
496
  const fm = {};
393
497
  fm.name = agent.id;
394
498
  // Description: prefer agent.description, then role description from roles block.
395
- // For manual agents, omit description to prevent Claude Code auto-delegation.
396
- if (agent.invocation !== "manual") {
397
- const desc = agent.description ?? ast.roles[agent.role ?? ""] ?? ast.roles[agent.id] ?? agent.role;
398
- if (desc) {
399
- fm.description = desc;
499
+ // Claude Code skips an agent without one, so a manual agent keeps it and
500
+ // says it runs only when asked (there is no frontmatter field for that).
501
+ const desc = agent.description ?? ast.roles[agent.role ?? ""] ?? ast.roles[agent.id] ?? agent.role ?? toTitle(agent.id);
502
+ fm.description = agent.invocation === "manual"
503
+ ? `${desc.replace(/[.\s]*$/, "")}. Use only when the user asks for this agent by name; never delegate to it on your own.`
504
+ : desc;
505
+ if (agent.model) {
506
+ if (isClaudeCodeModel(agent.model)) {
507
+ fm.model = agent.model;
508
+ }
509
+ else {
510
+ console.warn(`[claude-code] Agent "${agent.id}" model "${agent.model}" is not a Claude Code model ` +
511
+ `(sonnet, opus, haiku, fable, inherit, or a claude-* id); omitted, so the agent ` +
512
+ `runs on the session's model.`);
400
513
  }
401
514
  }
402
- if (agent.model)
403
- fm.model = agent.model;
404
515
  if (agent.maxTurns != null)
405
- fm.maxTurns = String(agent.maxTurns);
516
+ fm.maxTurns = agent.maxTurns;
406
517
  if (agent.tools && agent.tools.length > 0)
407
518
  fm.tools = agent.tools.map(convertMcpToolName).join(", ");
408
519
  if (agent.disallowedTools && agent.disallowedTools.length > 0)
@@ -426,8 +537,6 @@ function generateAgents(ast) {
426
537
  if (agent.fallbackChain && agent.fallbackChain.length > 0) {
427
538
  fm["fallback-chain"] = agent.fallbackChain.join(", ");
428
539
  }
429
- // invocation: manual — Claude Code doesn't parse this field. Instead, omit
430
- // description to prevent auto-delegation for manual agents.
431
540
  if (agent.skills && agent.skills.length > 0)
432
541
  fm.skills = agent.skills;
433
542
  // scale is NOT a Claude Code frontmatter field — rendered only in body as prompt text
@@ -449,28 +558,30 @@ function generateAgents(ast) {
449
558
  const topologyName = ast.topology.name;
450
559
  const hooksByEvent = {};
451
560
  for (const hook of agent.hooks) {
452
- if (!hooksByEvent[hook.on])
453
- hooksByEvent[hook.on] = [];
454
- hooksByEvent[hook.on].push(hook);
561
+ if (hook.type === "mod")
562
+ continue; // engine-wide only (V94)
563
+ // In an agent's frontmatter Claude Code turns Stop into SubagentStop itself.
564
+ const event = hook.on === "AgentStop" ? "Stop" : claudeEvent(hook.on);
565
+ if (!hooksByEvent[event])
566
+ hooksByEvent[event] = [];
567
+ hooksByEvent[event].push(hook);
455
568
  }
456
569
  const hookLines = ["hooks:"];
457
570
  for (const [eventName, hooks] of Object.entries(hooksByEvent)) {
458
571
  hookLines.push(` ${eventName}:`);
459
572
  for (const hook of hooks) {
460
- const scriptName = hook.run.replace(/^.*\//, "").replace(/\s.*$/, "");
461
- const command = `bash .claude/skills/${topologyName}/scripts/${agent.id}-${scriptName}`;
462
- if (hook.matcher) {
463
- hookLines.push(` - matcher: "${hook.matcher}"`);
464
- }
465
- else {
466
- hookLines.push(` -`);
467
- }
573
+ const scriptName = scriptNameFromRun(hook.run);
574
+ if (hook.type !== "prompt" && !scriptName)
575
+ continue; // V96: prose in run names no script
576
+ const handler = hookHandler(hook, () => `bash .claude/skills/${topologyName}/scripts/${agent.id}-${scriptName}`);
577
+ // JSON strings are valid YAML double-quoted scalars, so a regex
578
+ // matcher or a prompt with quotes or colons can never break the file.
579
+ hookLines.push(hook.matcher ? ` - matcher: ${JSON.stringify(hook.matcher)}` : ` -`);
468
580
  hookLines.push(` hooks:`);
469
- hookLines.push(` - type: ${hook.type ?? "command"}`);
470
- hookLines.push(` command: "${command}"`);
471
- if (hook.timeout) {
472
- hookLines.push(` timeout: ${hook.timeout}`);
473
- }
581
+ const [first, ...rest] = Object.entries(handler);
582
+ hookLines.push(` - ${first[0]}: ${JSON.stringify(first[1])}`);
583
+ for (const [k, v] of rest)
584
+ hookLines.push(` ${k}: ${JSON.stringify(v)}`);
474
585
  }
475
586
  }
476
587
  // Insert hooks before the closing ---
@@ -488,6 +599,9 @@ function generateAgents(ast) {
488
599
  }
489
600
  sections.push("## Instructions");
490
601
  sections.push("");
602
+ // Only the text between the markers is the prompt: sync and re-scaffold
603
+ // read it there, so generated sections below never leak into the .at.
604
+ sections.push(PROMPT_FENCE_OPEN);
491
605
  if (agent.prompt) {
492
606
  sections.push(downshiftHeadings(agent.prompt));
493
607
  }
@@ -502,6 +616,7 @@ function generateAgents(ast) {
502
616
  else {
503
617
  sections.push("Complete the assigned task.");
504
618
  }
619
+ sections.push(PROMPT_FENCE_CLOSE);
505
620
  sections.push("");
506
621
  // Reads section
507
622
  if (agent.reads && agent.reads.length > 0) {
@@ -809,35 +924,6 @@ function generateAgents(ast) {
809
924
  }
810
925
  return files;
811
926
  }
812
- /** Generate AGENT.md files for each human node. */
813
- function generateHumanNodes(ast) {
814
- const files = [];
815
- for (const node of ast.nodes) {
816
- if (node.type !== "human")
817
- continue;
818
- const human = node;
819
- const sections = [];
820
- sections.push(`# ${toTitle(human.id)} (Human-in-the-Loop)`);
821
- sections.push("");
822
- if (human.description) {
823
- sections.push(human.description);
824
- sections.push("");
825
- }
826
- if (human.timeout) {
827
- sections.push(`Timeout: ${human.timeout}`);
828
- }
829
- if (human.onTimeout) {
830
- sections.push(`On timeout: ${human.onTimeout}`);
831
- }
832
- sections.push("");
833
- files.push({
834
- path: `.claude/agents/${human.id}/AGENT.md`,
835
- content: sections.join("\n") + "\n",
836
- category: "machine",
837
- });
838
- }
839
- return files;
840
- }
841
927
  /**
842
928
  * Generate group chat artifacts:
843
929
  * - Orchestrator AGENT.md — spawns members sequentially
@@ -1254,6 +1340,29 @@ function generateTopologySkill(ast) {
1254
1340
  sections.push("");
1255
1341
  }
1256
1342
  }
1343
+ // Approval steps: a human node is a point where the user decides. It used to
1344
+ // compile to an AGENT.md without frontmatter, which Claude Code ignores.
1345
+ const humans = ast.nodes.filter((n) => n.type === "human");
1346
+ if (humans.length > 0) {
1347
+ sections.push("## Approval steps");
1348
+ sections.push("");
1349
+ sections.push("At each step below, stop and ask the user (AskUserQuestion), then continue only on their approval.");
1350
+ sections.push("");
1351
+ for (const human of humans) {
1352
+ sections.push(`### ${toTitle(human.id)}`);
1353
+ if (human.description)
1354
+ sections.push(human.description);
1355
+ const before = approvalTargets(ast, human);
1356
+ if (before.length > 0) {
1357
+ sections.push(`Before: ${before.join(", ")} (Claude Code also asks the user before launching ${before.length > 1 ? "them" : "it"})`);
1358
+ }
1359
+ if (human.timeout)
1360
+ sections.push(`Timeout: ${human.timeout}`);
1361
+ if (human.onTimeout)
1362
+ sections.push(`On timeout: ${human.onTimeout}`);
1363
+ sections.push("");
1364
+ }
1365
+ }
1257
1366
  // Schedules section
1258
1367
  if (ast.schedules.length > 0) {
1259
1368
  sections.push("## Schedules");
@@ -1448,9 +1557,19 @@ function generateSkills(ast) {
1448
1557
  }
1449
1558
  sections.push("");
1450
1559
  }
1560
+ // The instructions themselves, or a file that holds them. "See: path" was
1561
+ // written before: Claude Code loads a skill's other files only when the
1562
+ // skill tells it to, so the instructions never reached the model.
1451
1563
  if (skill.prompt) {
1452
- sections.push("## Prompt");
1453
- sections.push(`See: ${skill.prompt}`);
1564
+ sections.push("## Instructions");
1565
+ sections.push("");
1566
+ const path = skill.prompt.trim();
1567
+ if (!/\s/.test(path) && /\.(md|txt)$/.test(path)) {
1568
+ sections.push(`Read \`${path}\` before anything else and follow it: it holds this skill's instructions.`);
1569
+ }
1570
+ else {
1571
+ sections.push(downshiftHeadings(skill.prompt));
1572
+ }
1454
1573
  sections.push("");
1455
1574
  }
1456
1575
  files.push({
@@ -1641,15 +1760,22 @@ function generateGateScripts(ast) {
1641
1760
  if (node.type !== "gate")
1642
1761
  continue;
1643
1762
  const gate = node;
1644
- if (gate.run) {
1645
- // Extract script name from the run command
1646
- const scriptName = gate.run.replace(/^.*\//, "").replace(/\s.*$/, "");
1647
- files.push({
1648
- path: `.claude/skills/${name}/scripts/${scriptName}`,
1649
- content: shellStub(`Gate: ${gate.id} — ${gate.run}`),
1650
- category: "script",
1651
- });
1652
- }
1763
+ const scriptName = scriptNameFromRun(gate.run);
1764
+ if (!scriptName)
1765
+ continue;
1766
+ const { script } = runParts(gate.run);
1767
+ // The user's own script, outside the project: never stubbed.
1768
+ if (script.startsWith("/"))
1769
+ continue;
1770
+ // A project path names the user's script: a stub only fills the gap until
1771
+ // it exists, and is never written over it.
1772
+ const project = isProjectPath(script);
1773
+ files.push({
1774
+ path: project ? script.replace(/^\.\//, "") : `.claude/skills/${name}/scripts/${scriptName}`,
1775
+ content: shellStub(`Gate: ${gate.id} — ${gate.run}`),
1776
+ category: "script",
1777
+ ...(project ? { createOnly: true } : {}),
1778
+ });
1653
1779
  }
1654
1780
  return files;
1655
1781
  }
@@ -1665,18 +1791,22 @@ function generateGateWrapperScripts(ast) {
1665
1791
  continue;
1666
1792
  if (gate.behavior === "advisory")
1667
1793
  continue;
1668
- const gateScriptName = gate.run.replace(/^.*\//, "").replace(/\s.*$/, "");
1794
+ if (!scriptNameFromRun(gate.run))
1795
+ continue; // V96: prose in run names no script
1796
+ const gateCommand = hookCommand(gate.run, name);
1669
1797
  const onFailExit = gate.onFail === "halt"
1670
1798
  ? `echo "Gate '${gate.id}' validation failed. Fix issues before proceeding." >&2\n exit 2`
1671
1799
  : `echo "Gate '${gate.id}' failed — bounce back and fix the issues." >&2\n exit 2`;
1672
1800
  const inlineMode = getDelegationMode(ast) === "inline";
1673
- const gateAfters = gateAnchors(gate, "after");
1674
- const wiredAsHook = !inlineMode &&
1675
- gateAfters.length > 0 &&
1676
- gateAfters.every((a) => isRegisteredSubagentType(ast, a));
1801
+ const wiring = gateWiring(ast, gate);
1802
+ const wiredAsHook = wiring.kind === "subagent-stop";
1677
1803
  let enforcementNote;
1678
1804
  let enforcementNote2;
1679
- if (inlineMode) {
1805
+ if (wiring.kind === "before-agent") {
1806
+ enforcementNote = `# Enforcement: PreToolUse hook (before-agent.sh) before Claude Code launches "${wiring.agents.join('", "')}".`;
1807
+ enforcementNote2 = `# Exit 2 blocks the launch and surfaces stderr to Claude.`;
1808
+ }
1809
+ else if (inlineMode) {
1680
1810
  enforcementNote = `# Enforcement: NOT wired as a SubagentStop hook (orchestrator.delegation is "inline").`;
1681
1811
  enforcementNote2 = `# Invoke this script from your /${ast.topology.name} playbook at the right step.`;
1682
1812
  }
@@ -1712,10 +1842,10 @@ function generateGateWrapperScripts(ast) {
1712
1842
  contentLines.push("while [ $ATTEMPT -le $MAX_RETRIES ] && [ $GATE_RESULT -ne 0 ]; do");
1713
1843
  contentLines.push(" ATTEMPT=$((ATTEMPT + 1))");
1714
1844
  if (gate.timeout) {
1715
- contentLines.push(` timeout ${gate.timeout} bash .claude/skills/${name}/scripts/${gateScriptName} && GATE_RESULT=0 || GATE_RESULT=$?`);
1845
+ contentLines.push(` timeout ${gate.timeout} ${gateCommand} && GATE_RESULT=0 || GATE_RESULT=$?`);
1716
1846
  }
1717
1847
  else {
1718
- contentLines.push(` bash .claude/skills/${name}/scripts/${gateScriptName} && GATE_RESULT=0 || GATE_RESULT=$?`);
1848
+ contentLines.push(` ${gateCommand} && GATE_RESULT=0 || GATE_RESULT=$?`);
1719
1849
  }
1720
1850
  contentLines.push(' if [ $GATE_RESULT -ne 0 ] && [ $ATTEMPT -le $MAX_RETRIES ]; then');
1721
1851
  contentLines.push(` echo "Gate '${gate.id}' attempt $ATTEMPT failed, retrying..."`);
@@ -1728,10 +1858,10 @@ function generateGateWrapperScripts(ast) {
1728
1858
  contentLines.push("# Run the gate check script");
1729
1859
  contentLines.push(`GATE_RESULT=0`);
1730
1860
  if (gate.timeout) {
1731
- contentLines.push(`timeout ${gate.timeout} bash .claude/skills/${name}/scripts/${gateScriptName} || GATE_RESULT=$?`);
1861
+ contentLines.push(`timeout ${gate.timeout} ${gateCommand} || GATE_RESULT=$?`);
1732
1862
  }
1733
1863
  else {
1734
- contentLines.push(`bash .claude/skills/${name}/scripts/${gateScriptName} || GATE_RESULT=$?`);
1864
+ contentLines.push(`${gateCommand} || GATE_RESULT=$?`);
1735
1865
  }
1736
1866
  }
1737
1867
  contentLines.push("");
@@ -1761,7 +1891,7 @@ function observabilityStub(hookName, eventName, matcherHint) {
1761
1891
  if (isFinishEvent) {
1762
1892
  return [
1763
1893
  "#!/usr/bin/env bash",
1764
- `# Auto-generated by agentopology scaffold on ${new Date().toISOString().slice(0, 10)}`,
1894
+ `# Auto-generated by agentopology scaffold`,
1765
1895
  `# Hook: ${hookName} · Event: ${eventName} · Matcher: ${matcherHint || "*"}`,
1766
1896
  "# Replace this stub with your implementation.",
1767
1897
  "# Stdin JSON payload from Claude Code is available on FD 0.",
@@ -1777,7 +1907,7 @@ function observabilityStub(hookName, eventName, matcherHint) {
1777
1907
  }
1778
1908
  return [
1779
1909
  "#!/usr/bin/env bash",
1780
- `# Auto-generated by agentopology scaffold on ${new Date().toISOString().slice(0, 10)}`,
1910
+ `# Auto-generated by agentopology scaffold`,
1781
1911
  `# Hook: ${hookName} · Event: ${eventName} · Matcher: ${matcherHint || "*"}`,
1782
1912
  "# Replace this stub with your implementation.",
1783
1913
  "# Stdin JSON payload from Claude Code is available on FD 0.",
@@ -1790,6 +1920,123 @@ function observabilityStub(hookName, eventName, matcherHint) {
1790
1920
  "",
1791
1921
  ].join("\n");
1792
1922
  }
1923
+ /**
1924
+ * `type: mod` hooks → a Claude Code plugin (function hooks, early access).
1925
+ *
1926
+ * A Mod is a TypeScript module loaded INSIDE the engine; settings.json cannot
1927
+ * carry it. The engine loads ONE module per plugin (hooks.json `modules`,
1928
+ * measured on 2.1.283), so the plugin's single entry is a generated
1929
+ * `hooks/at-register.ts` that imports each declared Mod's `register(on)` and
1930
+ * calls it. A `flag:` is the Mod's OWN switch: the Mod reads it per event with
1931
+ * `$.env.get`, and the launch script sets it. The entry cannot gate it: a
1932
+ * module has no `process`, and the engine refuses an `on` that is wrapped or
1933
+ * kept (measured 2.1.283: "on is always on(\"<event>\", hook)"). The launch script
1934
+ * sets CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 (without it the module never
1935
+ * loads), each flag, and `--plugin-dir`.
1936
+ *
1937
+ * The plugin root is the folder that HOLDS the Mods' modules, because the
1938
+ * engine refuses a hooks module that imports outside its plugin folder
1939
+ * (measured 2.1.283: "cannot import … it is outside the plugin's folder").
1940
+ * The entry has its own name so it never overwrites a hand-written
1941
+ * `hooks/register.ts`. The Mods' own modules are the user's code: nothing is
1942
+ * written at a `run:` path. A topology without Mods gets no plugin.
1943
+ */
1944
+ export const MOD_PLUGIN_ENV = "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS";
1945
+ export const MOD_ENTRY = "at-register.ts";
1946
+ /**
1947
+ * The plugin root: the deepest folder shared by every Mod's `run:` path, less
1948
+ * a trailing `hooks` (the plugin's own hooks/ folder). "" is the project root.
1949
+ * null when a `run:` is absolute: it cannot be placed inside the project.
1950
+ */
1951
+ export function modPluginDir(ast) {
1952
+ const runs = ast.hooks.filter((h) => h.type === "mod" && h.run).map((h) => h.run);
1953
+ if (runs.length === 0 || runs.some((r) => r.startsWith("/")))
1954
+ return null;
1955
+ const dirs = runs.map((r) => r.split("/").filter((p) => p && p !== ".").slice(0, -1));
1956
+ const shared = [];
1957
+ for (let i = 0; dirs.every((d) => i < d.length && d[i] === dirs[0][i]); i++)
1958
+ shared.push(dirs[0][i]);
1959
+ if (shared[shared.length - 1] === "hooks")
1960
+ shared.pop();
1961
+ return shared.join("/");
1962
+ }
1963
+ function generateModPlugin(ast) {
1964
+ const mods = ast.hooks.filter((h) => h.type === "mod" && h.run);
1965
+ if (mods.length === 0)
1966
+ return [];
1967
+ const name = ast.topology.name;
1968
+ const dir = modPluginDir(ast);
1969
+ if (dir === null) {
1970
+ console.warn(`[claude-code] Mods ${mods.map((m) => m.name).join(", ")}: a run: path is absolute, so no plugin folder ` +
1971
+ `can hold the modules (the engine refuses imports outside it). Use project-relative run: paths. Skipped.`);
1972
+ return [];
1973
+ }
1974
+ const at = (p) => (dir ? `${dir}/${p}` : p);
1975
+ const hooksDir = at("hooks");
1976
+ // `run:` is project-relative (or absolute); the import is relative to hooks/.
1977
+ // Bindings stay free of node: imports, so the relative path is computed here.
1978
+ const importPath = (run) => {
1979
+ const target = run.replace(/\.(ts|js|mjs)$/, "");
1980
+ if (target.startsWith("/"))
1981
+ return target;
1982
+ const to = target.split("/").filter((p) => p && p !== ".");
1983
+ const from = hooksDir.split("/");
1984
+ let common = 0;
1985
+ while (common < from.length && common < to.length - 1 && from[common] === to[common])
1986
+ common++;
1987
+ const rel = [...from.slice(common).map(() => ".."), ...to.slice(common)].join("/");
1988
+ return rel.startsWith(".") ? rel : `./${rel}`;
1989
+ };
1990
+ const ident = (hookName) => "mod_" + hookName.replace(/[^A-Za-z0-9_]/g, "_");
1991
+ const summary = mods
1992
+ .map((m) => `${m.name} (${m.on}${m.flag ? `, ${m.flag}` : ""})`)
1993
+ .join("; ");
1994
+ const pluginJson = {
1995
+ name: `${name}-mods`,
1996
+ version: ast.topology.version || "0.0.0",
1997
+ description: `Mods of the ${name} topology, generated by agentopology: ${summary}.`,
1998
+ };
1999
+ const hooksJson = {
2000
+ description: `The engine loads ONE module per plugin: ${MOD_ENTRY} registers every Mod ` +
2001
+ `declared in ${name}.at. Off unless ${MOD_PLUGIN_ENV}=1.`,
2002
+ modules: [`./${MOD_ENTRY}`],
2003
+ };
2004
+ const register = [
2005
+ "// Generated by agentopology scaffold from the topology's `type: mod` hooks.",
2006
+ "// The engine loads ONE module per plugin: this one registers every Mod.",
2007
+ "// Edit the .at file, not this file.",
2008
+ "import type { On } from 'claude-code'",
2009
+ "",
2010
+ ...mods.map((m) => `import { register as ${ident(m.name)} } from '${importPath(m.run)}'`),
2011
+ "",
2012
+ "export function register(on: On) {",
2013
+ ...mods.map((m) => ` // ${m.name} · on ${m.on}${m.flag ? ` · the Mod reads its own switch, ${m.flag}` : ""}\n ${ident(m.name)}(on)`),
2014
+ "}",
2015
+ "",
2016
+ ].join("\n");
2017
+ const flags = [...new Set(mods.map((m) => m.flag).filter((f) => !!f))];
2018
+ const launch = [
2019
+ "#!/usr/bin/env bash",
2020
+ `# Launch Claude Code with the Mods of ${name} (generated by agentopology).`,
2021
+ "# Mods run inside the engine (function hooks, early access).",
2022
+ "# Each flag defaults to on here; set it to 0 before launching to turn a Mod off.",
2023
+ "set -euo pipefail",
2024
+ 'PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"',
2025
+ `export ${MOD_PLUGIN_ENV}=1`,
2026
+ ...flags.map((f) => {
2027
+ const users = mods.filter((m) => m.flag === f).map((m) => `${m.name} · ${m.on}`).join(", ");
2028
+ return `export ${f}="\${${f}:-1}" # ${users}`;
2029
+ }),
2030
+ 'exec claude --plugin-dir "$PLUGIN_DIR" "$@"',
2031
+ "",
2032
+ ].join("\n");
2033
+ return [
2034
+ { path: at(".claude-plugin/plugin.json"), content: JSON.stringify(pluginJson, null, 2) + "\n" },
2035
+ { path: `${hooksDir}/hooks.json`, content: JSON.stringify(hooksJson, null, 2) + "\n" },
2036
+ { path: `${hooksDir}/${MOD_ENTRY}`, content: register },
2037
+ { path: at("launch-mods.sh"), content: launch, executable: true },
2038
+ ];
2039
+ }
1793
2040
  /** Generate hook script stubs for every hook's `run` field (Changes 1, 4, 5). */
1794
2041
  function generateHookScripts(ast) {
1795
2042
  const files = [];
@@ -1797,18 +2044,36 @@ function generateHookScripts(ast) {
1797
2044
  for (const hook of ast.hooks) {
1798
2045
  if (!hook.run)
1799
2046
  continue;
2047
+ // A Mod's run: is the user's TypeScript module — never overwrite it with a
2048
+ // bash stub. generateModPlugin imports it instead.
2049
+ if (hook.type === "mod" || hook.type === "prompt")
2050
+ continue;
1800
2051
  const runPath = hook.run;
1801
2052
  // If the run path starts with '.' or '/' it is already project-relative —
1802
2053
  // generate the stub at exactly that path (observability pattern).
1803
2054
  // Otherwise scope the stub into this topology's skill scripts directory.
1804
- const filePath = runPath.startsWith(".") || runPath.startsWith("/")
1805
- ? runPath
1806
- : `.claude/skills/${name}/scripts/${runPath.replace(/^.*\//, "").replace(/\s.*$/, "")}`;
2055
+ if (proseInRun(runPath))
2056
+ continue; // V96: prose in run names no script
2057
+ const { script } = runParts(runPath);
2058
+ // An absolute script is the user's own, outside the project: never stubbed.
2059
+ if (script.startsWith("/"))
2060
+ continue;
2061
+ const scoped = scriptNameFromRun(script);
2062
+ const filePath = isProjectPath(script)
2063
+ ? script
2064
+ : scoped
2065
+ ? `.claude/skills/${name}/scripts/${scoped}`
2066
+ : null;
2067
+ if (!filePath)
2068
+ continue;
1807
2069
  files.push({
1808
2070
  path: filePath,
1809
2071
  content: observabilityStub(hook.name, hook.on, hook.matcher ?? ""),
1810
2072
  category: "script",
1811
2073
  executable: true,
2074
+ // A run that names its own path names the user's script: a stub only
2075
+ // fills the gap until it exists. --force used to overwrite it.
2076
+ ...(isProjectPath(script) ? { createOnly: true } : {}),
1812
2077
  });
1813
2078
  }
1814
2079
  return files;
@@ -1824,9 +2089,11 @@ function generateAgentHookScripts(ast) {
1824
2089
  if (!agent.hooks || agent.hooks.length === 0)
1825
2090
  continue;
1826
2091
  for (const hook of agent.hooks) {
1827
- if (!hook.run)
2092
+ if (!hook.run || hook.type === "mod" || hook.type === "prompt")
1828
2093
  continue;
1829
- const scriptName = hook.run.replace(/^.*\//, "").replace(/\s.*$/, "");
2094
+ const scriptName = scriptNameFromRun(hook.run);
2095
+ if (!scriptName)
2096
+ continue; // V96: prose in run names no script
1830
2097
  files.push({
1831
2098
  path: `.claude/skills/${name}/scripts/${agent.id}-${scriptName}`,
1832
2099
  content: [
@@ -1845,19 +2112,45 @@ function generateAgentHookScripts(ast) {
1845
2112
  return files;
1846
2113
  }
1847
2114
  /**
1848
- * Generate hook configuration in .claude/settings.json.
2115
+ * The shell command for a command hook's or gate's `run:`.
1849
2116
  *
1850
- * Hooks are grouped by event name as an object:
1851
- * { "hooks": { "PostToolUse": [ { hooks: [...], matcher? } ], ... } }
2117
+ * A `run:` whose script (see runParts) is a project path names the user's own
2118
+ * script. A project-relative one runs through `$CLAUDE_PROJECT_DIR` (a hook's
2119
+ * cwd is the session's, which moves); an absolute one is kept as written. It
2120
+ * runs by the interpreter the `run:` names, else the one its extension names
2121
+ * (`.py` → python3, anything else → bash). Arguments are kept.
2122
+ *
2123
+ * Any other `run:` is a script this topology's skill scaffolds, by basename.
1852
2124
  */
1853
- function generateSettings(ast) {
1854
- const settings = {};
2125
+ export function hookCommand(run, topologyName) {
2126
+ const { interpreter: named, script: first, args: rest } = runParts(run);
2127
+ if (!isProjectPath(first)) {
2128
+ const base = first.replace(/^.*\//, "");
2129
+ return [named ?? "bash", `.claude/skills/${topologyName}/scripts/${base}`, ...rest].join(" ");
2130
+ }
2131
+ const interpreter = named ?? (first.endsWith(".py") ? "python3" : "bash");
2132
+ const where = first.startsWith("/")
2133
+ ? first
2134
+ : `"$CLAUDE_PROJECT_DIR/${first.replace(/^\.\//, "")}"`;
2135
+ return [interpreter, where, ...rest].join(" ");
2136
+ }
2137
+ /**
2138
+ * The settings.json `hooks` entries of the topology's own `hooks {}` block
2139
+ * (command hooks only; a Mod is a plugin, see generateModPlugin), grouped by
2140
+ * event. Gate wiring is added by generateSettings, not here: a caller that
2141
+ * wants the reflexes alone (e.g. a drift check against a live settings file)
2142
+ * reads this.
2143
+ */
2144
+ export function settingsHooksOf(ast) {
1855
2145
  const topologyName = ast.topology.name;
1856
2146
  const isInline = getDelegationMode(ast) === "inline";
1857
- // Hooks section — grouped by event name
1858
2147
  const hooksByEvent = {};
1859
2148
  for (const hook of ast.hooks) {
1860
- const eventName = hook.on;
2149
+ // A Mod is a module inside the engine, not a settings.json command: it is
2150
+ // emitted as a plugin (generateModPlugin).
2151
+ if (hook.type === "mod")
2152
+ continue;
2153
+ const eventName = claudeEvent(hook.on);
1861
2154
  // Inline orchestrator mode never spawns subagents, so SubagentStop /
1862
2155
  // SubagentStart hooks would never fire. Warn the user and skip emission
1863
2156
  // — keeping them in settings.json would be a maintenance trap.
@@ -1868,27 +2161,13 @@ function generateSettings(ast) {
1868
2161
  `delegation to "subagent".`);
1869
2162
  continue;
1870
2163
  }
2164
+ if (hook.type !== "prompt" && proseInRun(hook.run))
2165
+ continue; // V96: prose in run names no script
1871
2166
  if (!hooksByEvent[eventName]) {
1872
2167
  hooksByEvent[eventName] = [];
1873
2168
  }
1874
- // Build the script command.
1875
- // If hook.run starts with '.' or '/' it is already a project-relative or
1876
- // absolute path — use it verbatim so observability hooks like
1877
- // ".claude/scripts/log-subagent.sh" land at the correct location.
1878
- // Otherwise treat it as a bare filename scoped to this topology's skill
1879
- // scripts directory.
1880
- const runPath = hook.run;
1881
- const command = runPath.startsWith(".") || runPath.startsWith("/")
1882
- ? `bash ${runPath}`
1883
- : `bash .claude/skills/${topologyName}/scripts/${runPath.replace(/^.*\//, "").replace(/\s.*$/, "")}`;
1884
2169
  const hookEntry = {
1885
- hooks: [
1886
- {
1887
- type: hook.type ?? "command",
1888
- command,
1889
- ...(hook.timeout ? { timeout: hook.timeout } : {}),
1890
- },
1891
- ],
2170
+ hooks: [hookHandler(hook, () => hookCommand(hook.run, topologyName))],
1892
2171
  };
1893
2172
  // Only add matcher when it has a value
1894
2173
  if (hook.matcher) {
@@ -1896,6 +2175,19 @@ function generateSettings(ast) {
1896
2175
  }
1897
2176
  hooksByEvent[eventName].push(hookEntry);
1898
2177
  }
2178
+ return hooksByEvent;
2179
+ }
2180
+ /**
2181
+ * Generate hook configuration in .claude/settings.json.
2182
+ *
2183
+ * Hooks are grouped by event name as an object:
2184
+ * { "hooks": { "PostToolUse": [ { hooks: [...], matcher? } ], ... } }
2185
+ */
2186
+ function generateSettings(ast) {
2187
+ const settings = {};
2188
+ const topologyName = ast.topology.name;
2189
+ // Hooks section — grouped by event name
2190
+ const hooksByEvent = settingsHooksOf(ast);
1899
2191
  // Per-agent hooks are now rendered in AGENT.md frontmatter (not in settings.json).
1900
2192
  // This is the Claude Code native approach: hooks in the agent file are scoped
1901
2193
  // to that agent automatically.
@@ -1927,8 +2219,8 @@ function generateSettings(ast) {
1927
2219
  const delegationMode = getDelegationMode(ast);
1928
2220
  const gates = ast.nodes.filter((n) => n.type === "gate");
1929
2221
  for (const gate of gates) {
1930
- if (!gate.run)
1931
- continue;
2222
+ if (!scriptNameFromRun(gate.run))
2223
+ continue; // no run, or V96 prose
1932
2224
  if (gate.behavior === "advisory")
1933
2225
  continue; // advisory gates stay in markdown only
1934
2226
  if (delegationMode === "inline") {
@@ -1938,11 +2230,15 @@ function generateSettings(ast) {
1938
2230
  `orchestrator playbook at the right step.`);
1939
2231
  continue;
1940
2232
  }
1941
- // SubagentStop matcher must be a registered subagent_type. If the gate
1942
- // doesn't target an agent node, we cannot wire it as a hook — warn and
1943
- // skip so we don't ship dead config that never fires.
2233
+ // SubagentStop matcher must be a registered subagent_type. A gate after an
2234
+ // action or a human runs before its next agent instead (before-agent.sh,
2235
+ // wired below); with no agent on either side, warn and skip so we don't
2236
+ // ship dead config that never fires.
1944
2237
  const anchors = gateAnchors(gate, "after");
1945
- if (anchors.length === 0 || !anchors.every((a) => isRegisteredSubagentType(ast, a))) {
2238
+ const wiring = gateWiring(ast, gate);
2239
+ if (wiring.kind === "before-agent")
2240
+ continue;
2241
+ if (wiring.kind !== "subagent-stop") {
1946
2242
  const bad = anchors.filter((a) => !isRegisteredSubagentType(ast, a));
1947
2243
  const targetDesc = anchors.length
1948
2244
  ? `non-agent target ${bad.map((b) => `"${b}"`).join(", ")}`
@@ -1981,6 +2277,15 @@ function generateSettings(ast) {
1981
2277
  // Blackboard implies a host-side observer that watches the workflow rung's
1982
2278
  // Write tool-calls LIVE. This is the host's piece of the hybrid's concurrent
1983
2279
  // observability (the deterministic workflow rung cannot express it).
2280
+ if (generateBeforeAgentScript(ast)) {
2281
+ if (!hooksByEvent.PreToolUse)
2282
+ hooksByEvent.PreToolUse = [];
2283
+ hooksByEvent.PreToolUse.push({
2284
+ // Agent is the subagent tool; Task is its earlier name.
2285
+ matcher: "Agent|Task",
2286
+ hooks: [{ type: "command", command: beforeAgentCommand(ast) }],
2287
+ });
2288
+ }
1984
2289
  const observeHook = buildHybridObserveHook(ast, topologyName);
1985
2290
  if (observeHook) {
1986
2291
  if (!hooksByEvent[observeHook.event])
@@ -1999,13 +2304,12 @@ function generateSettings(ast) {
1999
2304
  // Permissions section from settings block — nested under "permissions" key
2000
2305
  const perms = ast.settings;
2001
2306
  if (perms) {
2002
- const allow = perms.allow;
2003
- const deny = perms.deny;
2004
2307
  const permObj = {};
2005
- if (allow && allow.length > 0)
2006
- permObj.allow = allow;
2007
- if (deny && deny.length > 0)
2008
- permObj.deny = deny;
2308
+ for (const key of ["allow", "ask", "deny"]) {
2309
+ const rules = perms[key];
2310
+ if (rules && rules.length > 0)
2311
+ permObj[key] = rules.map(convertMcpToolName);
2312
+ }
2009
2313
  if (Object.keys(permObj).length > 0)
2010
2314
  settings.permissions = permObj;
2011
2315
  }
@@ -2059,9 +2363,12 @@ function generateMcpJson(ast) {
2059
2363
  for (const [name, config] of Object.entries(ast.mcpServers)) {
2060
2364
  const entry = {};
2061
2365
  for (const [key, value] of Object.entries(config)) {
2062
- if (key === "command" || key === "url") {
2366
+ if (key === "command" || key === "url" || key === "type") {
2063
2367
  entry[key] = value;
2064
2368
  }
2369
+ else if (key === "headers" && typeof value === "object" && value !== null) {
2370
+ entry.headers = value;
2371
+ }
2065
2372
  else if (key === "args" && Array.isArray(value)) {
2066
2373
  entry.args = value;
2067
2374
  }
@@ -2073,8 +2380,11 @@ function generateMcpJson(ast) {
2073
2380
  // Only include servers that have a command or url (filter parser artifacts)
2074
2381
  if (!("command" in entry) && !("url" in entry))
2075
2382
  continue;
2076
- // Always include env field for Claude Code .mcp.json
2077
- if (!("env" in entry)) {
2383
+ // Claude Code reads a url entry without type as stdio and skips it.
2384
+ if ("url" in entry && !("type" in entry))
2385
+ entry.type = "http";
2386
+ // Always include env field for a stdio server in .mcp.json
2387
+ if (!("url" in entry) && !("env" in entry)) {
2078
2388
  entry.env = {};
2079
2389
  }
2080
2390
  servers[name] = entry;
@@ -2155,6 +2465,7 @@ function generateToolScripts(ast) {
2155
2465
  shebang,
2156
2466
  `# Tool: ${tool.id} — ${tool.description}`,
2157
2467
  "# Auto-generated by agentopology scaffold — edit as needed.",
2468
+ tool.lang === "node" ? `// ${STUB_MARKER}` : STUB_MARKER,
2158
2469
  "",
2159
2470
  ];
2160
2471
  if (tool.lang === "bash" || !tool.lang) {
@@ -2175,14 +2486,50 @@ function generateToolScripts(ast) {
2175
2486
  lines.push(`echo "TODO: implement ${tool.id}"`);
2176
2487
  }
2177
2488
  lines.push("");
2489
+ // The stub goes where the tool's script: says, not to a copy beside it: a
2490
+ // project path is the user's script (create-only); an absolute one is never
2491
+ // touched; a bare name lives in the skill's scripts folder.
2492
+ const script = tool.script ? runParts(tool.script).script : "";
2493
+ if (script.startsWith("/"))
2494
+ continue;
2495
+ const project = isProjectPath(script);
2496
+ const base = script.replace(/^.*\//, "");
2178
2497
  files.push({
2179
- path: `.claude/skills/${name}/scripts/${tool.id}.${ext}`,
2498
+ path: project
2499
+ ? script.replace(/^\.\//, "")
2500
+ : `.claude/skills/${name}/scripts/${base || `${tool.id}.${ext}`}`,
2180
2501
  content: lines.join("\n"),
2181
2502
  category: "script",
2503
+ ...(project ? { createOnly: true } : {}),
2182
2504
  });
2183
2505
  }
2184
2506
  return files;
2185
2507
  }
2508
+ /**
2509
+ * A cron expression for an `every:` duration, or null when cron cannot say
2510
+ * it. Only "1h", "daily" and a few words converted before; anything else
2511
+ * became a commented-out crontab line that never ran.
2512
+ */
2513
+ function everyToCron(every) {
2514
+ const text = every.trim().toLowerCase();
2515
+ const words = { hourly: "0 * * * *", daily: "0 0 * * *", weekly: "0 0 * * 0" };
2516
+ if (words[text])
2517
+ return words[text];
2518
+ const m = text.match(/^(\d+)\s*(m|min|mins|minutes?|h|hours?|d|days?|w|weeks?)$/);
2519
+ if (!m)
2520
+ return null;
2521
+ const n = Number(m[1]);
2522
+ const unit = m[2][0];
2523
+ if (n <= 0)
2524
+ return null;
2525
+ if (unit === "m")
2526
+ return n < 60 && 60 % n === 0 ? (n === 1 ? "* * * * *" : `*/${n} * * * *`) : null;
2527
+ if (unit === "h")
2528
+ return n < 24 && 24 % n === 0 ? (n === 1 ? "0 * * * *" : `0 */${n} * * *`) : null;
2529
+ if (unit === "d")
2530
+ return n === 1 ? "0 0 * * *" : null;
2531
+ return n === 1 ? "0 0 * * 0" : null;
2532
+ }
2186
2533
  /** Generate schema documentation files. */
2187
2534
  function generateSchemas(ast) {
2188
2535
  const files = [];
@@ -2227,15 +2574,18 @@ function generateScheduleScripts(ast) {
2227
2574
  `# Schedule: ${job.id} — invokes ${target}`,
2228
2575
  "# Auto-generated by agentopology scaffold — edit as needed.",
2229
2576
  'set -euo pipefail',
2577
+ "# cron starts in $HOME: run from the project root (this script is in scripts/).",
2578
+ 'cd "$(dirname "$0")/.."',
2230
2579
  "",
2231
2580
  ];
2232
2581
  if (job.agent) {
2582
+ // Headless: cron has no terminal, so an interactive session never ran.
2233
2583
  lines.push(`# Invoke agent: ${job.agent}`);
2234
- lines.push(`claude --agent ${job.agent}`);
2584
+ lines.push(`claude -p "Run your scheduled task (${job.id})." --agent ${job.agent}`);
2235
2585
  }
2236
2586
  else if (job.action) {
2237
2587
  lines.push(`# Invoke action: ${job.action}`);
2238
- lines.push(`bash scripts/action-${job.action}.sh`);
2588
+ lines.push(`bash .claude/skills/${ast.topology.name}/scripts/action-${job.action}.sh`);
2239
2589
  }
2240
2590
  if (job.enabled === false) {
2241
2591
  lines.unshift("# DISABLED — this schedule is not enabled");
@@ -2245,18 +2595,12 @@ function generateScheduleScripts(ast) {
2245
2595
  // Add crontab entry
2246
2596
  let cronExpr = job.cron ?? "# every: " + (job.every ?? "unknown");
2247
2597
  if (!job.cron && job.every) {
2248
- // Convert common "every" expressions to cron
2249
- const everyMap = {
2250
- "1h": "0 * * * *",
2251
- "1 hour": "0 * * * *",
2252
- "hourly": "0 * * * *",
2253
- "1d": "0 0 * * *",
2254
- "1 day": "0 0 * * *",
2255
- "daily": "0 0 * * *",
2256
- "1w": "0 0 * * 0",
2257
- "weekly": "0 0 * * 0",
2258
- };
2259
- cronExpr = everyMap[job.every.toLowerCase()] ?? `# every: ${job.every}`;
2598
+ const converted = everyToCron(job.every);
2599
+ if (!converted) {
2600
+ console.warn(`[claude-code] Schedule "${job.id}" every: ${job.every} has no cron form; ` +
2601
+ `its crontab line is commented out. Use cron: instead.`);
2602
+ }
2603
+ cronExpr = converted ?? `# every: ${job.every}`;
2260
2604
  }
2261
2605
  const enabledPrefix = job.enabled === false ? "# DISABLED: " : "";
2262
2606
  cronLines.push(`${enabledPrefix}${cronExpr} bash ${scriptPath} # ${job.id}`);
@@ -2424,7 +2768,11 @@ function generateWorkspaceProtocol(ast) {
2424
2768
  * destroy their content.
2425
2769
  */
2426
2770
  function generateContextFile(ast) {
2427
- const DEFAULT_PATH = ".claude/CLAUDE.md";
2771
+ // A rules file, one per topology: Claude Code loads .claude/rules/*.md like
2772
+ // CLAUDE.md, so the context still reaches every session, but it never
2773
+ // competes with the project's own CLAUDE.md or .claude/CLAUDE.md (it used to
2774
+ // be written to .claude/CLAUDE.md, a conflict in any project that had one).
2775
+ const DEFAULT_PATH = `.claude/rules/${ast.topology.name}.md`;
2428
2776
  let fileName = ast.context.file ?? DEFAULT_PATH;
2429
2777
  // Safety: never overwrite the user's root-level CLAUDE.md
2430
2778
  if (fileName === "CLAUDE.md" || fileName === "./CLAUDE.md") {
@@ -2621,7 +2969,7 @@ function generateWorkflowLaunchArtifacts(ast) {
2621
2969
  }
2622
2970
  /**
2623
2971
  * HYBRID: build the "Hybrid run order" markdown block appended to the generated
2624
- * context file (.claude/CLAUDE.md) and the topology SKILL.md. It states the
2972
+ * context file (.claude/rules/<topology>.md) and the topology SKILL.md. It states the
2625
2973
  * host's interleaving with the embedded workflow rung, in the SAME run-order
2626
2974
  * vocabulary the claude-workflow binding's SEAM.md uses, so the two bindings
2627
2975
  * stay in sync. Returns null when the topology is not a hybrid.
@@ -2738,49 +3086,28 @@ function generateObserveBlackboardScript(ast) {
2738
3086
  /**
2739
3087
  * Merge `ast.env` into the settings.json output under an `env` key.
2740
3088
  *
2741
- * When `ast.providers` includes an "anthropic" provider with an api-key,
2742
- * that env var is included automatically (enabling API-key mode instead
2743
- * of subscription mode).
3089
+ * Claude Code applies settings `env` values exactly as written — no shell
3090
+ * expands them — and they override the variable exported in the shell. So a
3091
+ * reference such as `${ANTHROPIC_API_KEY}` must never be emitted: it replaced
3092
+ * the real key with that literal text, and every session in the project failed
3093
+ * with "Invalid API key" (6 of the 10 examples did this). Only literal values
3094
+ * are written; references, secrets and encrypted values stay with the shell.
3095
+ * The anthropic provider's api-key is the same kind of reference and is not
3096
+ * emitted either.
2744
3097
  *
2745
3098
  * Returns the env record if non-empty, otherwise null.
2746
3099
  */
2747
3100
  function generateEnvToSettings(ast) {
2748
3101
  const env = {};
2749
3102
  for (const [key, val] of Object.entries(ast.env)) {
2750
- if (typeof val === "string") {
2751
- env[key] = val;
2752
- }
2753
- else {
2754
- const sv = val;
2755
- if (sv.secretRef) {
2756
- // Secret reference — emit placeholder with comment-style URI
2757
- env[key] = `\${${key}}`; // placeholder
2758
- env[`# ${key}_SECRET`] = sv.secretRef.uri;
2759
- }
2760
- else if (sv.sensitive) {
2761
- // Sensitive value — never emit the actual value
2762
- env[key] = `\${${key}}`;
2763
- }
2764
- else if (sv.encrypted) {
2765
- // Encrypted value — emit placeholder with warning
2766
- env[key] = `\${${key}}`;
2767
- env[`# ${key}_ENCRYPTED`] = "WARNING: value is SOPS-encrypted, decrypt before use";
2768
- }
2769
- else {
2770
- env[key] = sv.value;
2771
- }
2772
- }
2773
- }
2774
- // If an anthropic provider exists, scaffold the API key env var
2775
- if (ast.providers && ast.providers.length > 0) {
2776
- const anthropicProvider = ast.providers.find((p) => p.name === "anthropic");
2777
- if (anthropicProvider?.apiKey) {
2778
- // Extract env var name from ${ENV_VAR} format
2779
- const envVarMatch = anthropicProvider.apiKey.match(/^\$\{(.+)\}$/);
2780
- if (envVarMatch) {
2781
- env[envVarMatch[1]] = anthropicProvider.apiKey;
2782
- }
2783
- }
3103
+ const literal = typeof val === "string"
3104
+ ? val
3105
+ : val.secretRef || val.sensitive || val.encrypted
3106
+ ? undefined
3107
+ : val.value;
3108
+ if (literal === undefined || literal.includes("${"))
3109
+ continue;
3110
+ env[key] = literal;
2784
3111
  }
2785
3112
  if (Object.keys(env).length === 0)
2786
3113
  return null;
@@ -2804,6 +3131,80 @@ function generateTriggers(ast) {
2804
3131
  // Binding export
2805
3132
  // ---------------------------------------------------------------------------
2806
3133
  /** Claude Code binding — the reference binding implementation. */
3134
+ /**
3135
+ * Drop every generated file that belongs to a hand-written node (`file:`).
3136
+ *
3137
+ * An authored node keeps its file (a group also its skill and runtime
3138
+ * folder); an authored skill keeps its whole folder
3139
+ * (SKILL.md, scripts, references); an authored command keeps its file. The
3140
+ * topology still describes them — gates still wire to an authored agent by
3141
+ * name — but the binding never writes inside them. Without this, importing a
3142
+ * hand-built .claude and scaffolding it back replaced 37 of 72 files and cut
3143
+ * the rest down (a 404-line SKILL.md came back as 9 lines).
3144
+ */
3145
+ function withoutAuthored(ast, files) {
3146
+ const exact = new Set();
3147
+ const folders = [];
3148
+ for (const node of ast.nodes) {
3149
+ if (!node.file)
3150
+ continue;
3151
+ // Everything the binding would write for this node: its agent file, and
3152
+ // for a group its skill and its runtime folder.
3153
+ exact.add(`.claude/agents/${node.id}/AGENT.md`);
3154
+ exact.add(`.claude/agents/${node.id}.md`);
3155
+ exact.add(normalizeRel(node.file));
3156
+ if (node.type === "group") {
3157
+ folders.push(`.claude/skills/${node.id}/`, `.claude/groups/${node.id}/`);
3158
+ }
3159
+ }
3160
+ for (const skill of ast.skills ?? []) {
3161
+ if (!skill.file)
3162
+ continue;
3163
+ folders.push(`.claude/skills/${skill.id}/`);
3164
+ exact.add(normalizeRel(skill.file));
3165
+ }
3166
+ for (const trigger of ast.triggers ?? []) {
3167
+ if (!trigger.file)
3168
+ continue;
3169
+ exact.add(`.claude/commands/${trigger.name}.md`);
3170
+ exact.add(normalizeRel(trigger.file));
3171
+ }
3172
+ if (exact.size === 0 && folders.length === 0)
3173
+ return files;
3174
+ const isAuthored = (path) => {
3175
+ const p = normalizeRel(path);
3176
+ return exact.has(p) || folders.some((dir) => p.startsWith(dir));
3177
+ };
3178
+ const dropped = files.filter((f) => isAuthored(f.path)).map((f) => normalizeRel(f.path));
3179
+ return files
3180
+ .filter((f) => !isAuthored(f.path))
3181
+ .map((f) => (/(^|\/)\.claude\/settings\.json$/.test(f.path) ? withoutHooksRunning(f, dropped) : f));
3182
+ }
3183
+ /**
3184
+ * settings.json without the hook groups that run a script this scaffold no
3185
+ * longer writes. A gate whose wrapper sits in an authored skill folder would
3186
+ * otherwise be wired to a file that does not exist, and fire on every stop.
3187
+ */
3188
+ function withoutHooksRunning(file, dropped) {
3189
+ if (dropped.length === 0)
3190
+ return file;
3191
+ const settings = JSON.parse(file.content);
3192
+ if (!settings.hooks)
3193
+ return file;
3194
+ for (const [event, groups] of Object.entries(settings.hooks)) {
3195
+ const kept = groups.filter((g) => !(g.hooks ?? []).some((h) => dropped.some((p) => (h.command ?? "").includes(p))));
3196
+ if (kept.length > 0)
3197
+ settings.hooks[event] = kept;
3198
+ else
3199
+ delete settings.hooks[event];
3200
+ }
3201
+ if (Object.keys(settings.hooks).length === 0)
3202
+ delete settings.hooks;
3203
+ return { ...file, content: JSON.stringify(settings, null, 2) + "\n" };
3204
+ }
3205
+ function normalizeRel(p) {
3206
+ return p.replace(/^\.\//, "");
3207
+ }
2807
3208
  export const claudeCodeBinding = {
2808
3209
  name: "claude-code",
2809
3210
  description: "Anthropic Claude Code CLI — generates .claude/ directory structure, .mcp.json, and scripts.",
@@ -2815,7 +3216,7 @@ export const claudeCodeBinding = {
2815
3216
  files.push(...generateTopologySkill(ast));
2816
3217
  files.push(...generateAgents(ast));
2817
3218
  files.push(...generateSchemas(ast));
2818
- return deduplicateFiles(files);
3219
+ return withoutAuthored(ast, deduplicateFiles(files));
2819
3220
  }
2820
3221
  const files = [];
2821
3222
  // Action nodes
@@ -2825,7 +3226,6 @@ export const claudeCodeBinding = {
2825
3226
  // Agent files — may include group conversation protocol sections
2826
3227
  files.push(...generateAgents(ast));
2827
3228
  // Human nodes
2828
- files.push(...generateHumanNodes(ast));
2829
3229
  // Main topology skill (with frontmatter)
2830
3230
  const skillFiles = generateTopologySkill(ast);
2831
3231
  // Append trigger info to the main skill
@@ -2844,10 +3244,15 @@ export const claudeCodeBinding = {
2844
3244
  // Gate scripts
2845
3245
  files.push(...generateGateScripts(ast));
2846
3246
  files.push(...generateGateWrapperScripts(ast));
3247
+ const beforeAgent = generateBeforeAgentScript(ast);
3248
+ if (beforeAgent)
3249
+ files.push(beforeAgent);
2847
3250
  // Hook scripts (global)
2848
3251
  files.push(...generateHookScripts(ast));
2849
3252
  // Hook scripts (per-agent)
2850
3253
  files.push(...generateAgentHookScripts(ast));
3254
+ // Mods (type: mod) → a plugin directory; never settings.json
3255
+ files.push(...generateModPlugin(ast));
2851
3256
  // HYBRID: host-side launch directives for the embedded workflow rung(s).
2852
3257
  files.push(...generateWorkflowLaunchArtifacts(ast));
2853
3258
  // HYBRID: host-layer concurrent Blackboard observer script (paired with the
@@ -2879,7 +3284,7 @@ export const claudeCodeBinding = {
2879
3284
  files.push(meteringFile);
2880
3285
  // Context file (.claude/CONTEXT.md or custom — never root CLAUDE.md)
2881
3286
  files.push(generateContextFile(ast));
2882
- return deduplicateFiles(files);
3287
+ return withoutAuthored(ast, deduplicateFiles(files));
2883
3288
  },
2884
3289
  };
2885
3290
  //# sourceMappingURL=claude-code.js.map