agentopology 0.6.0 → 0.8.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 (73) 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 +287 -0
  5. package/dist/__tests__/e2e/compile.test.js.map +1 -0
  6. package/dist/__tests__/e2e/roundtrip.test.js +2 -4
  7. package/dist/__tests__/e2e/roundtrip.test.js.map +1 -1
  8. package/dist/__tests__/e2e/scaffold-owns.test.js +12 -3
  9. package/dist/__tests__/e2e/scaffold-owns.test.js.map +1 -1
  10. package/dist/__tests__/e2e/sync-cycle.test.d.ts +10 -0
  11. package/dist/__tests__/e2e/sync-cycle.test.d.ts.map +1 -0
  12. package/dist/__tests__/e2e/sync-cycle.test.js +49 -0
  13. package/dist/__tests__/e2e/sync-cycle.test.js.map +1 -0
  14. package/dist/__tests__/live/claude-code.live.test.js +73 -4
  15. package/dist/__tests__/live/claude-code.live.test.js.map +1 -1
  16. package/dist/__tests__/open-findings.test.js +4 -25
  17. package/dist/__tests__/open-findings.test.js.map +1 -1
  18. package/dist/bindings/__tests__/bindings.test.js +19 -19
  19. package/dist/bindings/__tests__/bindings.test.js.map +1 -1
  20. package/dist/bindings/__tests__/claude-code-hybrid.test.js +5 -5
  21. package/dist/bindings/__tests__/claude-code-hybrid.test.js.map +1 -1
  22. package/dist/bindings/__tests__/hybrid-e2e.test.js +1 -1
  23. package/dist/bindings/__tests__/hybrid-e2e.test.js.map +1 -1
  24. package/dist/bindings/claude-code.d.ts +6 -8
  25. package/dist/bindings/claude-code.d.ts.map +1 -1
  26. package/dist/bindings/claude-code.js +492 -196
  27. package/dist/bindings/claude-code.js.map +1 -1
  28. package/dist/bindings/lib/prompt-fence.d.ts +22 -0
  29. package/dist/bindings/lib/prompt-fence.d.ts.map +1 -0
  30. package/dist/bindings/lib/prompt-fence.js +29 -0
  31. package/dist/bindings/lib/prompt-fence.js.map +1 -0
  32. package/dist/cli/index.js +9 -1
  33. package/dist/cli/index.js.map +1 -1
  34. package/dist/docs/teach.d.ts.map +1 -1
  35. package/dist/docs/teach.js +10 -0
  36. package/dist/docs/teach.js.map +1 -1
  37. package/dist/import/claude-code.d.ts.map +1 -1
  38. package/dist/import/claude-code.js +21 -5
  39. package/dist/import/claude-code.js.map +1 -1
  40. package/dist/import/serializer.d.ts.map +1 -1
  41. package/dist/import/serializer.js +44 -3
  42. package/dist/import/serializer.js.map +1 -1
  43. package/dist/parser/__tests__/mod-hooks.test.js +1 -1
  44. package/dist/parser/__tests__/mod-hooks.test.js.map +1 -1
  45. package/dist/parser/ast.d.ts +46 -3
  46. package/dist/parser/ast.d.ts.map +1 -1
  47. package/dist/parser/ast.js.map +1 -1
  48. package/dist/parser/index.d.ts.map +1 -1
  49. package/dist/parser/index.js +60 -3
  50. package/dist/parser/index.js.map +1 -1
  51. package/dist/parser/run.d.ts +23 -2
  52. package/dist/parser/run.d.ts.map +1 -1
  53. package/dist/parser/run.js +39 -3
  54. package/dist/parser/run.js.map +1 -1
  55. package/dist/parser/validator.d.ts +1 -1
  56. package/dist/parser/validator.d.ts.map +1 -1
  57. package/dist/parser/validator.js +131 -3
  58. package/dist/parser/validator.js.map +1 -1
  59. package/dist/scaffold/diff.d.ts +17 -0
  60. package/dist/scaffold/diff.d.ts.map +1 -0
  61. package/dist/scaffold/diff.js +66 -0
  62. package/dist/scaffold/diff.js.map +1 -0
  63. package/dist/scaffold/merge.d.ts.map +1 -1
  64. package/dist/scaffold/merge.js +14 -0
  65. package/dist/scaffold/merge.js.map +1 -1
  66. package/dist/sync/index.d.ts +1 -0
  67. package/dist/sync/index.d.ts.map +1 -1
  68. package/dist/sync/index.js +40 -2
  69. package/dist/sync/index.js.map +1 -1
  70. package/package.json +1 -1
  71. package/spec/grammar.md +28 -2
  72. package/spec/testing.md +35 -25
  73. package/spec/validation.md +5 -1
@@ -8,10 +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";
14
- import { proseInRun, scriptNameFromRun } from "../parser/run.js";
15
+ import { isProjectPath, proseInRun, runParts, scriptNameFromRun } from "../parser/run.js";
15
16
  // ---------------------------------------------------------------------------
16
17
  // Helpers
17
18
  // ---------------------------------------------------------------------------
@@ -22,13 +23,56 @@ import { proseInRun, scriptNameFromRun } from "../parser/run.js";
22
23
  function convertMcpToolName(tool) {
23
24
  if (!tool.startsWith("mcp."))
24
25
  return tool;
25
- // mcp.server-name.method_name → mcp__server-name__method_name
26
- // Only replace the first two dots (mcp + server separator)
27
- const parts = tool.split(".");
28
- if (parts.length >= 3) {
29
- 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
+ /** Whether a hook runs a shell command (and so has a script). */
57
+ function isCommandHook(hook) {
58
+ return (hook.type ?? "command") === "command";
59
+ }
60
+ /** A settings.json hook handler, in the shape Claude Code reads for its type. */
61
+ function hookHandler(hook, command) {
62
+ const timeout = hook.timeout ? { timeout: hook.timeout } : {};
63
+ switch (hook.type ?? "command") {
64
+ // A prompt or agent hook asks a model (or a subagent) to judge the event;
65
+ // its run: is the prompt.
66
+ case "prompt":
67
+ case "agent":
68
+ return { type: hook.type, prompt: hook.run, ...(hook.model ? { model: hook.model } : {}), ...timeout };
69
+ case "http":
70
+ return { type: "http", url: hook.url, ...timeout };
71
+ case "mcp_tool":
72
+ return { type: "mcp_tool", server: hook.server, tool: hook.tool, ...timeout };
73
+ default:
74
+ return { type: "command", command: command(), ...(hook.async ? { async: true } : {}), ...timeout };
30
75
  }
31
- return tool;
32
76
  }
33
77
  /**
34
78
  * Downshift all markdown headings by one level so they nest properly
@@ -55,6 +99,8 @@ function toTitle(id) {
55
99
  */
56
100
  /** Characters that require quoting in YAML scalar values. */
57
101
  const YAML_SPECIAL = /[:#\[\]{}&*!|>'"%@`]/;
102
+ /** Plain scalars YAML reads as something other than a string (yes → true, 1.0 → a number). */
103
+ 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)$/;
58
104
  function frontmatter(fields) {
59
105
  const lines = ["---"];
60
106
  for (const [key, value] of Object.entries(fields)) {
@@ -68,13 +114,13 @@ function frontmatter(fields) {
68
114
  lines.push(` - ${item}`);
69
115
  }
70
116
  }
71
- else if (typeof value === "boolean") {
117
+ else if (typeof value === "boolean" || typeof value === "number") {
72
118
  lines.push(`${key}: ${value}`);
73
119
  }
74
120
  else {
75
121
  // Auto-quote strings containing YAML-special characters
76
122
  const str = String(value);
77
- const needsQuotes = YAML_SPECIAL.test(str) || str.trim() !== str;
123
+ const needsQuotes = YAML_SPECIAL.test(str) || str.trim() !== str || str === "" || YAML_NOT_STRING.test(str);
78
124
  lines.push(`${key}: ${needsQuotes ? `"${str.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` : str}`);
79
125
  }
80
126
  }
@@ -89,10 +135,16 @@ function frontmatter(fields) {
89
135
  * human, and orchestrator nodes are NOT registered and therefore CANNOT be
90
136
  * used as SubagentStop matchers.
91
137
  */
138
+ /** An agent with runs: inline or fork compiles to a skill, not a subagent. */
139
+ function runsAsSkill(agent) {
140
+ return agent.runs === "inline" || agent.runs === "fork";
141
+ }
142
+ /** Permission modes a subagent may ask for but cannot get beyond its session. */
143
+ const LOOSER_MODES = new Set(["acceptEdits", "auto", "dontAsk", "bypassPermissions"]);
92
144
  function isRegisteredSubagentType(ast, name) {
93
145
  if (!name)
94
146
  return false;
95
- return ast.nodes.some((n) => (n.type === "agent" || n.type === "group") && n.id === name);
147
+ return ast.nodes.some((n) => (n.type === "group" || (n.type === "agent" && !runsAsSkill(n))) && n.id === name);
96
148
  }
97
149
  /**
98
150
  * Resolve the topology's orchestration delegation mode.
@@ -106,7 +158,96 @@ function getDelegationMode(ast) {
106
158
  const orch = ast.nodes.find((n) => n.type === "orchestrator");
107
159
  return orch?.delegation === "inline" ? "inline" : "subagent";
108
160
  }
109
- /** Generate an empty directory marker (a .gitkeep file). */
161
+ function gateWiring(ast, gate) {
162
+ if (getDelegationMode(ast) === "inline")
163
+ return { kind: "none" };
164
+ const afters = gateAnchors(gate, "after");
165
+ if (afters.length > 0 && afters.every((a) => isRegisteredSubagentType(ast, a))) {
166
+ return { kind: "subagent-stop", anchors: afters };
167
+ }
168
+ const agents = gateAnchors(gate, "before").filter((b) => isRegisteredSubagentType(ast, b));
169
+ return agents.length > 0 ? { kind: "before-agent", agents } : { kind: "none" };
170
+ }
171
+ /** Agents a human approval step stands before: its direct flow successors. */
172
+ function approvalTargets(ast, human) {
173
+ if (getDelegationMode(ast) === "inline")
174
+ return [];
175
+ const out = ast.edges.filter((e) => e.from === human.id && !e.isError).map((e) => e.to);
176
+ return [...new Set(out)].filter((id) => isRegisteredSubagentType(ast, id));
177
+ }
178
+ /** The settings.json command for the topology's before-agent hook. */
179
+ function beforeAgentCommand(ast) {
180
+ return `bash "$CLAUDE_PROJECT_DIR/.claude/skills/${ast.topology.name}/scripts/before-agent.sh"`;
181
+ }
182
+ /**
183
+ * One PreToolUse hook on the Agent tool for the whole topology: before Claude
184
+ * Code launches a subagent it runs that agent's gates (from actions or
185
+ * humans), then asks the user to approve when a human step stands before it.
186
+ * Null when nothing needs it.
187
+ */
188
+ function generateBeforeAgentScript(ast) {
189
+ const name = ast.topology.name;
190
+ const steps = new Map();
191
+ const add = (agent, lines) => steps.set(agent, [...(steps.get(agent) ?? []), ...lines]);
192
+ for (const node of ast.nodes) {
193
+ if (node.type !== "gate")
194
+ continue;
195
+ const gate = node;
196
+ if (!gate.run || gate.behavior === "advisory" || !scriptNameFromRun(gate.run))
197
+ continue;
198
+ const wiring = gateWiring(ast, gate);
199
+ if (wiring.kind !== "before-agent")
200
+ continue;
201
+ for (const agent of wiring.agents) {
202
+ add(agent, [
203
+ ` # gate ${gate.id} (after: ${gateAnchors(gate, "after").join(", ") || "-"})`,
204
+ ` bash "$DIR/gate-${gate.id}.sh" >&2 || exit 2`,
205
+ ]);
206
+ }
207
+ }
208
+ for (const node of ast.nodes) {
209
+ if (node.type !== "human")
210
+ continue;
211
+ const human = node;
212
+ const reason = `Approval step "${human.id}"${human.description ? `: ${human.description}` : ""}`;
213
+ for (const agent of approvalTargets(ast, human)) {
214
+ add(agent, [` # approval step ${human.id}`, ` ask ${shellQuote(reason)}`]);
215
+ }
216
+ }
217
+ if (steps.size === 0)
218
+ return null;
219
+ const lines = [
220
+ "#!/usr/bin/env bash",
221
+ `# Before Claude Code launches a subagent of topology "${name}".`,
222
+ "# Auto-generated by agentopology scaffold. Wired in .claude/settings.json as a",
223
+ "# PreToolUse hook on the Agent tool. Exit 2 blocks the launch and shows the",
224
+ "# reason to Claude; an \"ask\" decision makes Claude Code ask the user first.",
225
+ "set -euo pipefail",
226
+ 'cd "$CLAUDE_PROJECT_DIR"',
227
+ `DIR=".claude/skills/${name}/scripts"`,
228
+ 'AGENT=$(jq -r \'.tool_input.subagent_type // ""\')',
229
+ "",
230
+ "ask() {",
231
+ " jq -n --arg r \"$1\" '{hookSpecificOutput: {hookEventName: \"PreToolUse\", permissionDecision: \"ask\", permissionDecisionReason: $r}}'",
232
+ "}",
233
+ "",
234
+ 'case "$AGENT" in',
235
+ ];
236
+ for (const [agent, body] of steps) {
237
+ lines.push(` ${agent})`, ...body, " ;;");
238
+ }
239
+ lines.push("esac", "exit 0", "");
240
+ return {
241
+ path: `.claude/skills/${name}/scripts/before-agent.sh`,
242
+ content: lines.join("\n"),
243
+ category: "script",
244
+ executable: true,
245
+ };
246
+ }
247
+ /** Single-quote a string for bash. */
248
+ function shellQuote(text) {
249
+ return `'${text.replace(/'/g, `'\\''`)}'`;
250
+ }
110
251
  function gitkeep(dirPath) {
111
252
  return { path: `${dirPath}/.gitkeep`, content: "", category: "machine" };
112
253
  }
@@ -252,7 +393,7 @@ function workflowRunLabel(ast, run) {
252
393
  */
253
394
  function mapPermissionMode(perm) {
254
395
  // Claude Code native values — pass through
255
- const nativeValues = ["plan", "default", "acceptEdits", "dontAsk", "bypassPermissions"];
396
+ const nativeValues = ["plan", "default", "acceptEdits", "auto", "dontAsk", "bypassPermissions"];
256
397
  if (nativeValues.includes(perm)) {
257
398
  // "default" is the default, omit it
258
399
  if (perm === "default")
@@ -265,14 +406,16 @@ function mapPermissionMode(perm) {
265
406
  return "plan";
266
407
  case "autonomous":
267
408
  return undefined; // default, omit
268
- case "auto":
269
- return undefined; // default, omit
409
+ case "confirm":
410
+ return undefined; // asks before each action: Claude Code's default
270
411
  case "interactive":
271
412
  return "acceptEdits";
272
413
  case "unrestricted":
414
+ case "bypass":
273
415
  return "bypassPermissions";
274
416
  default:
275
- return perm; // unknown — pass through as-is
417
+ // Claude Code rejects an unknown permissionMode; V27 already warned.
418
+ return undefined;
276
419
  }
277
420
  }
278
421
  /** Format a SchemaType to a human-readable string. Optionally resolves refs. */
@@ -326,45 +469,24 @@ function generateActionNodes(ast) {
326
469
  if (action.join) {
327
470
  lines.push(`# Join semantics: ${action.join}`);
328
471
  }
329
- switch (action.kind) {
330
- case "external":
331
- lines.push(`# External source: ${action.source ?? "unknown"}`);
332
- lines.push("");
333
- lines.push(`echo "TODO: implement external action — fetch from ${action.source ?? "source"}"`);
334
- break;
335
- case "inline":
336
- if (action.commands && action.commands.length > 0) {
337
- lines.push("");
338
- for (const cmd of action.commands) {
339
- lines.push(cmd);
340
- }
341
- }
342
- else {
343
- lines.push("");
344
- lines.push('echo "TODO: implement inline action"');
345
- }
346
- break;
347
- case "git":
348
- lines.push("");
349
- lines.push("# Git operation");
350
- if (action.commands && action.commands.length > 0) {
351
- for (const cmd of action.commands) {
352
- lines.push(cmd);
353
- }
354
- }
355
- else {
356
- lines.push('echo "TODO: implement git action"');
357
- }
358
- break;
359
- case "report":
360
- lines.push("");
361
- lines.push("# Report generation");
362
- lines.push('echo "TODO: implement report action"');
363
- break;
364
- default:
365
- lines.push("");
366
- lines.push(`echo "TODO: implement ${action.kind ?? "unknown"} action"`);
367
- break;
472
+ if (action.kind === "external")
473
+ lines.push(`# External source: ${action.source ?? "unknown"}`);
474
+ lines.push("");
475
+ // The action's own commands, whatever its kind: an external or report
476
+ // action used to drop them for a TODO line.
477
+ if (action.commands && action.commands.length > 0) {
478
+ lines.push(...action.commands);
479
+ }
480
+ else if (!action.kind || action.kind === "inline") {
481
+ // Done by the orchestrator in conversation (the playbook says how).
482
+ lines.push(`# Performed by the orchestrator in conversation; nothing to run.`);
483
+ }
484
+ else {
485
+ // A placeholder that reports itself as one (agentopology stubs), never a
486
+ // silent success.
487
+ lines.splice(3, 0, STUB_MARKER);
488
+ lines.push(`echo "TODO: implement ${action.kind ?? "this"} action ${action.id}" >&2`);
489
+ lines.push("exit 1");
368
490
  }
369
491
  lines.push("");
370
492
  files.push({
@@ -389,21 +511,31 @@ function generateAgents(ast) {
389
511
  // .claude/agents/<name>/AGENT.md for it. See generateWorkflowLaunchArtifacts.
390
512
  if (isWorkflowSeamAgent(agent))
391
513
  continue;
514
+ // runs: inline or fork compiles to a skill (generateAgentSkills).
515
+ if (runsAsSkill(agent))
516
+ continue;
392
517
  // Build frontmatter fields
393
518
  const fm = {};
394
519
  fm.name = agent.id;
395
520
  // Description: prefer agent.description, then role description from roles block.
396
- // For manual agents, omit description to prevent Claude Code auto-delegation.
397
- if (agent.invocation !== "manual") {
398
- const desc = agent.description ?? ast.roles[agent.role ?? ""] ?? ast.roles[agent.id] ?? agent.role;
399
- if (desc) {
400
- fm.description = desc;
521
+ // Claude Code skips an agent without one, so a manual agent keeps it and
522
+ // says it runs only when asked (there is no frontmatter field for that).
523
+ const desc = agent.description ?? ast.roles[agent.role ?? ""] ?? ast.roles[agent.id] ?? agent.role ?? toTitle(agent.id);
524
+ fm.description = agent.invocation === "manual"
525
+ ? `${desc.replace(/[.\s]*$/, "")}. Use only when the user asks for this agent by name; never delegate to it on your own.`
526
+ : desc;
527
+ if (agent.model) {
528
+ if (isClaudeCodeModel(agent.model)) {
529
+ fm.model = agent.model;
530
+ }
531
+ else {
532
+ console.warn(`[claude-code] Agent "${agent.id}" model "${agent.model}" is not a Claude Code model ` +
533
+ `(sonnet, opus, haiku, fable, inherit, or a claude-* id); omitted, so the agent ` +
534
+ `runs on the session's model.`);
401
535
  }
402
536
  }
403
- if (agent.model)
404
- fm.model = agent.model;
405
537
  if (agent.maxTurns != null)
406
- fm.maxTurns = String(agent.maxTurns);
538
+ fm.maxTurns = agent.maxTurns;
407
539
  if (agent.tools && agent.tools.length > 0)
408
540
  fm.tools = agent.tools.map(convertMcpToolName).join(", ");
409
541
  if (agent.disallowedTools && agent.disallowedTools.length > 0)
@@ -416,7 +548,23 @@ function generateAgents(ast) {
416
548
  const mapped = mapPermissionMode(agent.permissions);
417
549
  if (mapped)
418
550
  fm.permissionMode = mapped;
551
+ // B5: allow rules are session-wide; a subagent can narrow what its
552
+ // session may do, never widen it.
553
+ if (mapped && LOOSER_MODES.has(mapped)) {
554
+ console.warn(`[claude-code] Agent "${agent.id}" permissions: ${agent.permissions} compiles to permissionMode ${mapped}. ` +
555
+ `Claude Code ignores it when the session already runs in acceptEdits, auto or bypassPermissions, ` +
556
+ `and a subagent cannot get permissions its session lacks: grant them in settings { allow: [...] }.`);
557
+ }
419
558
  }
559
+ // thinking is the language's reasoning-effort field; Claude Code calls it effort.
560
+ if (agent.thinking && agent.thinking !== "off")
561
+ fm.effort = agent.thinking;
562
+ if (agent.color)
563
+ fm.color = agent.color;
564
+ if (agent.initialPrompt)
565
+ fm.initialPrompt = agent.initialPrompt;
566
+ if (agent.omitClaudeMd != null)
567
+ fm.omitClaudeMd = agent.omitClaudeMd;
420
568
  if (agent.isolation)
421
569
  fm.isolation = agent.isolation;
422
570
  // Note: sandbox and fallback-chain are not recognized by Claude Code frontmatter
@@ -427,8 +575,6 @@ function generateAgents(ast) {
427
575
  if (agent.fallbackChain && agent.fallbackChain.length > 0) {
428
576
  fm["fallback-chain"] = agent.fallbackChain.join(", ");
429
577
  }
430
- // invocation: manual — Claude Code doesn't parse this field. Instead, omit
431
- // description to prevent auto-delegation for manual agents.
432
578
  if (agent.skills && agent.skills.length > 0)
433
579
  fm.skills = agent.skills;
434
580
  // scale is NOT a Claude Code frontmatter field — rendered only in body as prompt text
@@ -452,30 +598,28 @@ function generateAgents(ast) {
452
598
  for (const hook of agent.hooks) {
453
599
  if (hook.type === "mod")
454
600
  continue; // engine-wide only (V94)
455
- if (!hooksByEvent[hook.on])
456
- hooksByEvent[hook.on] = [];
457
- hooksByEvent[hook.on].push(hook);
601
+ // In an agent's frontmatter Claude Code turns Stop into SubagentStop itself.
602
+ const event = hook.on === "AgentStop" ? "Stop" : claudeEvent(hook.on);
603
+ if (!hooksByEvent[event])
604
+ hooksByEvent[event] = [];
605
+ hooksByEvent[event].push(hook);
458
606
  }
459
607
  const hookLines = ["hooks:"];
460
608
  for (const [eventName, hooks] of Object.entries(hooksByEvent)) {
461
609
  hookLines.push(` ${eventName}:`);
462
610
  for (const hook of hooks) {
463
611
  const scriptName = scriptNameFromRun(hook.run);
464
- if (!scriptName)
612
+ if (isCommandHook(hook) && !scriptName)
465
613
  continue; // V96: prose in run names no script
466
- const command = `bash .claude/skills/${topologyName}/scripts/${agent.id}-${scriptName}`;
467
- if (hook.matcher) {
468
- hookLines.push(` - matcher: "${hook.matcher}"`);
469
- }
470
- else {
471
- hookLines.push(` -`);
472
- }
614
+ const handler = hookHandler(hook, () => `bash .claude/skills/${topologyName}/scripts/${agent.id}-${scriptName}`);
615
+ // JSON strings are valid YAML double-quoted scalars, so a regex
616
+ // matcher or a prompt with quotes or colons can never break the file.
617
+ hookLines.push(hook.matcher ? ` - matcher: ${JSON.stringify(hook.matcher)}` : ` -`);
473
618
  hookLines.push(` hooks:`);
474
- hookLines.push(` - type: ${hook.type ?? "command"}`);
475
- hookLines.push(` command: "${command}"`);
476
- if (hook.timeout) {
477
- hookLines.push(` timeout: ${hook.timeout}`);
478
- }
619
+ const [first, ...rest] = Object.entries(handler);
620
+ hookLines.push(` - ${first[0]}: ${JSON.stringify(first[1])}`);
621
+ for (const [k, v] of rest)
622
+ hookLines.push(` ${k}: ${JSON.stringify(v)}`);
479
623
  }
480
624
  }
481
625
  // Insert hooks before the closing ---
@@ -493,6 +637,9 @@ function generateAgents(ast) {
493
637
  }
494
638
  sections.push("## Instructions");
495
639
  sections.push("");
640
+ // Only the text between the markers is the prompt: sync and re-scaffold
641
+ // read it there, so generated sections below never leak into the .at.
642
+ sections.push(PROMPT_FENCE_OPEN);
496
643
  if (agent.prompt) {
497
644
  sections.push(downshiftHeadings(agent.prompt));
498
645
  }
@@ -507,7 +654,14 @@ function generateAgents(ast) {
507
654
  else {
508
655
  sections.push("Complete the assigned task.");
509
656
  }
657
+ sections.push(PROMPT_FENCE_CLOSE);
510
658
  sections.push("");
659
+ if (agent.independent) {
660
+ sections.push("## Independence");
661
+ sections.push("");
662
+ sections.push(INDEPENDENT_NOTE);
663
+ sections.push("");
664
+ }
511
665
  // Reads section
512
666
  if (agent.reads && agent.reads.length > 0) {
513
667
  sections.push("## Reads");
@@ -814,35 +968,6 @@ function generateAgents(ast) {
814
968
  }
815
969
  return files;
816
970
  }
817
- /** Generate AGENT.md files for each human node. */
818
- function generateHumanNodes(ast) {
819
- const files = [];
820
- for (const node of ast.nodes) {
821
- if (node.type !== "human")
822
- continue;
823
- const human = node;
824
- const sections = [];
825
- sections.push(`# ${toTitle(human.id)} (Human-in-the-Loop)`);
826
- sections.push("");
827
- if (human.description) {
828
- sections.push(human.description);
829
- sections.push("");
830
- }
831
- if (human.timeout) {
832
- sections.push(`Timeout: ${human.timeout}`);
833
- }
834
- if (human.onTimeout) {
835
- sections.push(`On timeout: ${human.onTimeout}`);
836
- }
837
- sections.push("");
838
- files.push({
839
- path: `.claude/agents/${human.id}/AGENT.md`,
840
- content: sections.join("\n") + "\n",
841
- category: "machine",
842
- });
843
- }
844
- return files;
845
- }
846
971
  /**
847
972
  * Generate group chat artifacts:
848
973
  * - Orchestrator AGENT.md — spawns members sequentially
@@ -1259,6 +1384,48 @@ function generateTopologySkill(ast) {
1259
1384
  sections.push("");
1260
1385
  }
1261
1386
  }
1387
+ // Approval steps: a human node is a point where the user decides. It used to
1388
+ // compile to an AGENT.md without frontmatter, which Claude Code ignores.
1389
+ const humans = ast.nodes.filter((n) => n.type === "human");
1390
+ if (humans.length > 0) {
1391
+ sections.push("## Approval steps");
1392
+ sections.push("");
1393
+ sections.push("At each step below, stop and ask the user (AskUserQuestion), then continue only on their approval.");
1394
+ sections.push("");
1395
+ for (const human of humans) {
1396
+ sections.push(`### ${toTitle(human.id)}`);
1397
+ if (human.description)
1398
+ sections.push(human.description);
1399
+ const before = approvalTargets(ast, human);
1400
+ if (before.length > 0) {
1401
+ sections.push(`Before: ${before.join(", ")} (Claude Code also asks the user before launching ${before.length > 1 ? "them" : "it"})`);
1402
+ }
1403
+ if (human.timeout)
1404
+ sections.push(`Timeout: ${human.timeout}`);
1405
+ if (human.onTimeout)
1406
+ sections.push(`On timeout: ${human.onTimeout}`);
1407
+ sections.push("");
1408
+ }
1409
+ }
1410
+ // How each node runs, when it is not a plain subagent: the choice is on the
1411
+ // node (runs:, independent), and the playbook says how to call it.
1412
+ const special = ast.nodes.filter((n) => n.type === "agent" && (runsAsSkill(n) || !!n.independent));
1413
+ if (special.length > 0) {
1414
+ sections.push("## How these nodes run");
1415
+ sections.push("");
1416
+ for (const agent of special) {
1417
+ const how = agent.runs === "inline"
1418
+ ? `invoke the \`${agent.id}\` skill yourself; it runs in your own context`
1419
+ : agent.runs === "fork"
1420
+ ? `invoke the \`${agent.id}\` skill; it runs in a fresh forked context`
1421
+ : `launch the \`${agent.id}\` subagent`;
1422
+ const indep = agent.independent
1423
+ ? "; it is independent: hand it only the artifacts it reads, never your reasoning or the author's"
1424
+ : "";
1425
+ sections.push(`- **${agent.id}**: ${how}${indep}.`);
1426
+ }
1427
+ sections.push("");
1428
+ }
1262
1429
  // Schedules section
1263
1430
  if (ast.schedules.length > 0) {
1264
1431
  sections.push("## Schedules");
@@ -1399,6 +1566,54 @@ function generateTopologySkill(ast) {
1399
1566
  return files;
1400
1567
  }
1401
1568
  /** Generate skill-level files from the skills block. */
1569
+ /** What an independent node is told, wherever it runs. */
1570
+ const INDEPENDENT_NOTE = "You are independent of the author: judge only the artifacts you are given to read. " +
1571
+ "If you are handed the author's reasoning, drafts notes or conversation, ignore them.";
1572
+ /**
1573
+ * An agent with runs: inline or fork compiles to a skill: inline runs in the
1574
+ * caller's own context, fork in a fresh forked one (context: fork). The node
1575
+ * keeps its id, description, model, tools and prompt.
1576
+ */
1577
+ function generateAgentSkills(ast) {
1578
+ const files = [];
1579
+ for (const node of ast.nodes) {
1580
+ if (node.type !== "agent")
1581
+ continue;
1582
+ const agent = node;
1583
+ if (!runsAsSkill(agent))
1584
+ continue;
1585
+ const fm = { name: agent.id };
1586
+ const desc = agent.description ?? ast.roles[agent.role ?? ""] ?? ast.roles[agent.id] ?? agent.role;
1587
+ if (desc)
1588
+ fm.description = desc;
1589
+ if (agent.runs === "fork")
1590
+ fm.context = "fork";
1591
+ if (agent.model && isClaudeCodeModel(agent.model))
1592
+ fm.model = agent.model;
1593
+ if (agent.thinking && agent.thinking !== "off")
1594
+ fm.effort = agent.thinking;
1595
+ if (agent.tools && agent.tools.length > 0)
1596
+ fm["allowed-tools"] = agent.tools.map(convertMcpToolName).join(", ");
1597
+ if (agent.disallowedTools && agent.disallowedTools.length > 0) {
1598
+ fm["disallowed-tools"] = agent.disallowedTools.map(convertMcpToolName).join(", ");
1599
+ }
1600
+ if (agent.invocation === "manual")
1601
+ fm["disable-model-invocation"] = true;
1602
+ const sections = [frontmatter(fm), "", `# ${agent.label || toTitle(agent.id)}`, ""];
1603
+ sections.push(agent.runs === "fork"
1604
+ ? "Runs as a skill in a fresh forked context: it sees none of the conversation that invoked it."
1605
+ : "Runs as a skill in the caller's own context: the same agent, no handoff.");
1606
+ sections.push("", "## Instructions", "", downshiftHeadings(agent.prompt ?? desc ?? "Complete the assigned task."), "");
1607
+ if (agent.reads && agent.reads.length > 0)
1608
+ sections.push("## Reads", ...agent.reads.map((r) => `- ${r}`), "");
1609
+ if (agent.writes && agent.writes.length > 0)
1610
+ sections.push("## Writes", ...agent.writes.map((w) => `- ${w}`), "");
1611
+ if (agent.independent)
1612
+ sections.push("## Independence", "", INDEPENDENT_NOTE, "");
1613
+ files.push({ path: `.claude/skills/${agent.id}/SKILL.md`, content: sections.join("\n"), category: "agent" });
1614
+ }
1615
+ return files;
1616
+ }
1402
1617
  function generateSkills(ast) {
1403
1618
  const files = [];
1404
1619
  for (const skill of ast.skills) {
@@ -1421,6 +1636,23 @@ function generateSkills(ast) {
1421
1636
  if (skill.allowedTools && skill.allowedTools.length > 0) {
1422
1637
  sfm["allowed-tools"] = skill.allowedTools.map(convertMcpToolName).join(", ");
1423
1638
  }
1639
+ if (skill.disallowedTools && skill.disallowedTools.length > 0) {
1640
+ sfm["disallowed-tools"] = skill.disallowedTools.map(convertMcpToolName).join(", ");
1641
+ }
1642
+ if (skill.whenToUse)
1643
+ sfm.when_to_use = skill.whenToUse;
1644
+ if (skill.argumentHint)
1645
+ sfm["argument-hint"] = skill.argumentHint;
1646
+ if (skill.arguments && skill.arguments.length > 0)
1647
+ sfm.arguments = skill.arguments;
1648
+ if (skill.paths && skill.paths.length > 0)
1649
+ sfm.paths = skill.paths;
1650
+ if (skill.shell)
1651
+ sfm.shell = skill.shell;
1652
+ if (skill.model)
1653
+ sfm.model = skill.model;
1654
+ if (skill.thinking && skill.thinking !== "off")
1655
+ sfm.effort = skill.thinking;
1424
1656
  // Only emit frontmatter if there are fields beyond just the name
1425
1657
  if (Object.keys(sfm).length > 1) {
1426
1658
  sections.push(frontmatter(sfm));
@@ -1453,9 +1685,19 @@ function generateSkills(ast) {
1453
1685
  }
1454
1686
  sections.push("");
1455
1687
  }
1688
+ // The instructions themselves, or a file that holds them. "See: path" was
1689
+ // written before: Claude Code loads a skill's other files only when the
1690
+ // skill tells it to, so the instructions never reached the model.
1456
1691
  if (skill.prompt) {
1457
- sections.push("## Prompt");
1458
- sections.push(`See: ${skill.prompt}`);
1692
+ sections.push("## Instructions");
1693
+ sections.push("");
1694
+ const path = skill.prompt.trim();
1695
+ if (!/\s/.test(path) && /\.(md|txt)$/.test(path)) {
1696
+ sections.push(`Read \`${path}\` before anything else and follow it: it holds this skill's instructions.`);
1697
+ }
1698
+ else {
1699
+ sections.push(downshiftHeadings(skill.prompt));
1700
+ }
1459
1701
  sections.push("");
1460
1702
  }
1461
1703
  files.push({
@@ -1647,13 +1889,21 @@ function generateGateScripts(ast) {
1647
1889
  continue;
1648
1890
  const gate = node;
1649
1891
  const scriptName = scriptNameFromRun(gate.run);
1650
- if (scriptName) {
1651
- files.push({
1652
- path: `.claude/skills/${name}/scripts/${scriptName}`,
1653
- content: shellStub(`Gate: ${gate.id} — ${gate.run}`),
1654
- category: "script",
1655
- });
1656
- }
1892
+ if (!scriptName)
1893
+ continue;
1894
+ const { script } = runParts(gate.run);
1895
+ // The user's own script, outside the project: never stubbed.
1896
+ if (script.startsWith("/"))
1897
+ continue;
1898
+ // A project path names the user's script: a stub only fills the gap until
1899
+ // it exists, and is never written over it.
1900
+ const project = isProjectPath(script);
1901
+ files.push({
1902
+ path: project ? script.replace(/^\.\//, "") : `.claude/skills/${name}/scripts/${scriptName}`,
1903
+ content: shellStub(`Gate: ${gate.id} — ${gate.run}`),
1904
+ category: "script",
1905
+ ...(project ? { createOnly: true } : {}),
1906
+ });
1657
1907
  }
1658
1908
  return files;
1659
1909
  }
@@ -1669,20 +1919,22 @@ function generateGateWrapperScripts(ast) {
1669
1919
  continue;
1670
1920
  if (gate.behavior === "advisory")
1671
1921
  continue;
1672
- const gateScriptName = scriptNameFromRun(gate.run);
1673
- if (!gateScriptName)
1922
+ if (!scriptNameFromRun(gate.run))
1674
1923
  continue; // V96: prose in run names no script
1924
+ const gateCommand = hookCommand(gate.run, name);
1675
1925
  const onFailExit = gate.onFail === "halt"
1676
1926
  ? `echo "Gate '${gate.id}' validation failed. Fix issues before proceeding." >&2\n exit 2`
1677
1927
  : `echo "Gate '${gate.id}' failed — bounce back and fix the issues." >&2\n exit 2`;
1678
1928
  const inlineMode = getDelegationMode(ast) === "inline";
1679
- const gateAfters = gateAnchors(gate, "after");
1680
- const wiredAsHook = !inlineMode &&
1681
- gateAfters.length > 0 &&
1682
- gateAfters.every((a) => isRegisteredSubagentType(ast, a));
1929
+ const wiring = gateWiring(ast, gate);
1930
+ const wiredAsHook = wiring.kind === "subagent-stop";
1683
1931
  let enforcementNote;
1684
1932
  let enforcementNote2;
1685
- if (inlineMode) {
1933
+ if (wiring.kind === "before-agent") {
1934
+ enforcementNote = `# Enforcement: PreToolUse hook (before-agent.sh) before Claude Code launches "${wiring.agents.join('", "')}".`;
1935
+ enforcementNote2 = `# Exit 2 blocks the launch and surfaces stderr to Claude.`;
1936
+ }
1937
+ else if (inlineMode) {
1686
1938
  enforcementNote = `# Enforcement: NOT wired as a SubagentStop hook (orchestrator.delegation is "inline").`;
1687
1939
  enforcementNote2 = `# Invoke this script from your /${ast.topology.name} playbook at the right step.`;
1688
1940
  }
@@ -1718,10 +1970,10 @@ function generateGateWrapperScripts(ast) {
1718
1970
  contentLines.push("while [ $ATTEMPT -le $MAX_RETRIES ] && [ $GATE_RESULT -ne 0 ]; do");
1719
1971
  contentLines.push(" ATTEMPT=$((ATTEMPT + 1))");
1720
1972
  if (gate.timeout) {
1721
- contentLines.push(` timeout ${gate.timeout} bash .claude/skills/${name}/scripts/${gateScriptName} && GATE_RESULT=0 || GATE_RESULT=$?`);
1973
+ contentLines.push(` timeout ${gate.timeout} ${gateCommand} && GATE_RESULT=0 || GATE_RESULT=$?`);
1722
1974
  }
1723
1975
  else {
1724
- contentLines.push(` bash .claude/skills/${name}/scripts/${gateScriptName} && GATE_RESULT=0 || GATE_RESULT=$?`);
1976
+ contentLines.push(` ${gateCommand} && GATE_RESULT=0 || GATE_RESULT=$?`);
1725
1977
  }
1726
1978
  contentLines.push(' if [ $GATE_RESULT -ne 0 ] && [ $ATTEMPT -le $MAX_RETRIES ]; then');
1727
1979
  contentLines.push(` echo "Gate '${gate.id}' attempt $ATTEMPT failed, retrying..."`);
@@ -1734,10 +1986,10 @@ function generateGateWrapperScripts(ast) {
1734
1986
  contentLines.push("# Run the gate check script");
1735
1987
  contentLines.push(`GATE_RESULT=0`);
1736
1988
  if (gate.timeout) {
1737
- contentLines.push(`timeout ${gate.timeout} bash .claude/skills/${name}/scripts/${gateScriptName} || GATE_RESULT=$?`);
1989
+ contentLines.push(`timeout ${gate.timeout} ${gateCommand} || GATE_RESULT=$?`);
1738
1990
  }
1739
1991
  else {
1740
- contentLines.push(`bash .claude/skills/${name}/scripts/${gateScriptName} || GATE_RESULT=$?`);
1992
+ contentLines.push(`${gateCommand} || GATE_RESULT=$?`);
1741
1993
  }
1742
1994
  }
1743
1995
  contentLines.push("");
@@ -1767,7 +2019,7 @@ function observabilityStub(hookName, eventName, matcherHint) {
1767
2019
  if (isFinishEvent) {
1768
2020
  return [
1769
2021
  "#!/usr/bin/env bash",
1770
- `# Auto-generated by agentopology scaffold on ${new Date().toISOString().slice(0, 10)}`,
2022
+ `# Auto-generated by agentopology scaffold`,
1771
2023
  `# Hook: ${hookName} · Event: ${eventName} · Matcher: ${matcherHint || "*"}`,
1772
2024
  "# Replace this stub with your implementation.",
1773
2025
  "# Stdin JSON payload from Claude Code is available on FD 0.",
@@ -1783,7 +2035,7 @@ function observabilityStub(hookName, eventName, matcherHint) {
1783
2035
  }
1784
2036
  return [
1785
2037
  "#!/usr/bin/env bash",
1786
- `# Auto-generated by agentopology scaffold on ${new Date().toISOString().slice(0, 10)}`,
2038
+ `# Auto-generated by agentopology scaffold`,
1787
2039
  `# Hook: ${hookName} · Event: ${eventName} · Matcher: ${matcherHint || "*"}`,
1788
2040
  "# Replace this stub with your implementation.",
1789
2041
  "# Stdin JSON payload from Claude Code is available on FD 0.",
@@ -1922,20 +2174,20 @@ function generateHookScripts(ast) {
1922
2174
  continue;
1923
2175
  // A Mod's run: is the user's TypeScript module — never overwrite it with a
1924
2176
  // bash stub. generateModPlugin imports it instead.
1925
- if (hook.type === "mod")
1926
- continue;
2177
+ if (!isCommandHook(hook))
2178
+ continue; // a mod's module, or no script at all
1927
2179
  const runPath = hook.run;
1928
2180
  // If the run path starts with '.' or '/' it is already project-relative —
1929
2181
  // generate the stub at exactly that path (observability pattern).
1930
2182
  // Otherwise scope the stub into this topology's skill scripts directory.
1931
2183
  if (proseInRun(runPath))
1932
2184
  continue; // V96: prose in run names no script
1933
- const { script } = hookRun(runPath);
2185
+ const { script } = runParts(runPath);
1934
2186
  // An absolute script is the user's own, outside the project: never stubbed.
1935
2187
  if (script.startsWith("/"))
1936
2188
  continue;
1937
2189
  const scoped = scriptNameFromRun(script);
1938
- const filePath = isHookPath(script)
2190
+ const filePath = isProjectPath(script)
1939
2191
  ? script
1940
2192
  : scoped
1941
2193
  ? `.claude/skills/${name}/scripts/${scoped}`
@@ -1949,7 +2201,7 @@ function generateHookScripts(ast) {
1949
2201
  executable: true,
1950
2202
  // A run that names its own path names the user's script: a stub only
1951
2203
  // fills the gap until it exists. --force used to overwrite it.
1952
- ...(isHookPath(script) ? { createOnly: true } : {}),
2204
+ ...(isProjectPath(script) ? { createOnly: true } : {}),
1953
2205
  });
1954
2206
  }
1955
2207
  return files;
@@ -1965,7 +2217,7 @@ function generateAgentHookScripts(ast) {
1965
2217
  if (!agent.hooks || agent.hooks.length === 0)
1966
2218
  continue;
1967
2219
  for (const hook of agent.hooks) {
1968
- if (!hook.run || hook.type === "mod")
2220
+ if (!hook.run || !isCommandHook(hook))
1969
2221
  continue;
1970
2222
  const scriptName = scriptNameFromRun(hook.run);
1971
2223
  if (!scriptName)
@@ -1987,31 +2239,22 @@ function generateAgentHookScripts(ast) {
1987
2239
  }
1988
2240
  return files;
1989
2241
  }
1990
- const isHookPath = (w = "") => w.startsWith(".") || w.startsWith("/");
1991
- /** A hook's `run:` split: the interpreter it names (if any), its script, its arguments. */
1992
- function hookRun(run) {
1993
- const words = run.trim().split(/\s+/);
1994
- const named = words.length > 1 && !/\.(py|sh|ts|mjs|js)$/.test(words[0]) && isHookPath(words[1]);
1995
- const [script = "", ...args] = named ? words.slice(1) : words;
1996
- return { ...(named ? { interpreter: words[0] } : {}), script, args };
1997
- }
1998
2242
  /**
1999
- * The shell command for a command hook's `run:`.
2243
+ * The shell command for a command hook's or gate's `run:`.
2000
2244
  *
2001
- * A `run:` whose script (its first word, or its second when the first names
2002
- * the interpreter, e.g. `/opt/homebrew/bin/python3 ./x/y.py`) starts with "."
2003
- * or "/" names the user's own script. A project-relative one runs through
2004
- * `$CLAUDE_PROJECT_DIR` (a hook's cwd is the session's, which moves); an
2005
- * absolute one is kept as written. It runs by the interpreter the `run:`
2006
- * names, else the one its extension names (`.py` → python3, anything else →
2007
- * bash), with its arguments kept.
2245
+ * A `run:` whose script (see runParts) is a project path names the user's own
2246
+ * script. A project-relative one runs through `$CLAUDE_PROJECT_DIR` (a hook's
2247
+ * cwd is the session's, which moves); an absolute one is kept as written. It
2248
+ * runs by the interpreter the `run:` names, else the one its extension names
2249
+ * (`.py` → python3, anything else → bash). Arguments are kept.
2008
2250
  *
2009
2251
  * Any other `run:` is a script this topology's skill scaffolds, by basename.
2010
2252
  */
2011
2253
  export function hookCommand(run, topologyName) {
2012
- const { interpreter: named, script: first, args: rest } = hookRun(run);
2013
- if (!isHookPath(first)) {
2014
- return `bash .claude/skills/${topologyName}/scripts/${first.replace(/^.*\//, "")}`;
2254
+ const { interpreter: named, script: first, args: rest } = runParts(run);
2255
+ if (!isProjectPath(first)) {
2256
+ const base = first.replace(/^.*\//, "");
2257
+ return [named ?? "bash", `.claude/skills/${topologyName}/scripts/${base}`, ...rest].join(" ");
2015
2258
  }
2016
2259
  const interpreter = named ?? (first.endsWith(".py") ? "python3" : "bash");
2017
2260
  const where = first.startsWith("/")
@@ -2035,7 +2278,7 @@ export function settingsHooksOf(ast) {
2035
2278
  // emitted as a plugin (generateModPlugin).
2036
2279
  if (hook.type === "mod")
2037
2280
  continue;
2038
- const eventName = hook.on;
2281
+ const eventName = claudeEvent(hook.on);
2039
2282
  // Inline orchestrator mode never spawns subagents, so SubagentStop /
2040
2283
  // SubagentStart hooks would never fire. Warn the user and skip emission
2041
2284
  // — keeping them in settings.json would be a maintenance trap.
@@ -2046,19 +2289,13 @@ export function settingsHooksOf(ast) {
2046
2289
  `delegation to "subagent".`);
2047
2290
  continue;
2048
2291
  }
2049
- if (proseInRun(hook.run))
2292
+ if (isCommandHook(hook) && proseInRun(hook.run))
2050
2293
  continue; // V96: prose in run names no script
2051
2294
  if (!hooksByEvent[eventName]) {
2052
2295
  hooksByEvent[eventName] = [];
2053
2296
  }
2054
2297
  const hookEntry = {
2055
- hooks: [
2056
- {
2057
- type: hook.type ?? "command",
2058
- command: hookCommand(hook.run, topologyName),
2059
- ...(hook.timeout ? { timeout: hook.timeout } : {}),
2060
- },
2061
- ],
2298
+ hooks: [hookHandler(hook, () => hookCommand(hook.run, topologyName))],
2062
2299
  };
2063
2300
  // Only add matcher when it has a value
2064
2301
  if (hook.matcher) {
@@ -2121,11 +2358,15 @@ function generateSettings(ast) {
2121
2358
  `orchestrator playbook at the right step.`);
2122
2359
  continue;
2123
2360
  }
2124
- // SubagentStop matcher must be a registered subagent_type. If the gate
2125
- // doesn't target an agent node, we cannot wire it as a hook — warn and
2126
- // skip so we don't ship dead config that never fires.
2361
+ // SubagentStop matcher must be a registered subagent_type. A gate after an
2362
+ // action or a human runs before its next agent instead (before-agent.sh,
2363
+ // wired below); with no agent on either side, warn and skip so we don't
2364
+ // ship dead config that never fires.
2127
2365
  const anchors = gateAnchors(gate, "after");
2128
- if (anchors.length === 0 || !anchors.every((a) => isRegisteredSubagentType(ast, a))) {
2366
+ const wiring = gateWiring(ast, gate);
2367
+ if (wiring.kind === "before-agent")
2368
+ continue;
2369
+ if (wiring.kind !== "subagent-stop") {
2129
2370
  const bad = anchors.filter((a) => !isRegisteredSubagentType(ast, a));
2130
2371
  const targetDesc = anchors.length
2131
2372
  ? `non-agent target ${bad.map((b) => `"${b}"`).join(", ")}`
@@ -2164,6 +2405,15 @@ function generateSettings(ast) {
2164
2405
  // Blackboard implies a host-side observer that watches the workflow rung's
2165
2406
  // Write tool-calls LIVE. This is the host's piece of the hybrid's concurrent
2166
2407
  // observability (the deterministic workflow rung cannot express it).
2408
+ if (generateBeforeAgentScript(ast)) {
2409
+ if (!hooksByEvent.PreToolUse)
2410
+ hooksByEvent.PreToolUse = [];
2411
+ hooksByEvent.PreToolUse.push({
2412
+ // Agent is the subagent tool; Task is its earlier name.
2413
+ matcher: "Agent|Task",
2414
+ hooks: [{ type: "command", command: beforeAgentCommand(ast) }],
2415
+ });
2416
+ }
2167
2417
  const observeHook = buildHybridObserveHook(ast, topologyName);
2168
2418
  if (observeHook) {
2169
2419
  if (!hooksByEvent[observeHook.event])
@@ -2182,13 +2432,12 @@ function generateSettings(ast) {
2182
2432
  // Permissions section from settings block — nested under "permissions" key
2183
2433
  const perms = ast.settings;
2184
2434
  if (perms) {
2185
- const allow = perms.allow;
2186
- const deny = perms.deny;
2187
2435
  const permObj = {};
2188
- if (allow && allow.length > 0)
2189
- permObj.allow = allow;
2190
- if (deny && deny.length > 0)
2191
- permObj.deny = deny;
2436
+ for (const key of ["allow", "ask", "deny"]) {
2437
+ const rules = perms[key];
2438
+ if (rules && rules.length > 0)
2439
+ permObj[key] = rules.map(convertMcpToolName);
2440
+ }
2192
2441
  if (Object.keys(permObj).length > 0)
2193
2442
  settings.permissions = permObj;
2194
2443
  }
@@ -2242,9 +2491,12 @@ function generateMcpJson(ast) {
2242
2491
  for (const [name, config] of Object.entries(ast.mcpServers)) {
2243
2492
  const entry = {};
2244
2493
  for (const [key, value] of Object.entries(config)) {
2245
- if (key === "command" || key === "url") {
2494
+ if (key === "command" || key === "url" || key === "type") {
2246
2495
  entry[key] = value;
2247
2496
  }
2497
+ else if (key === "headers" && typeof value === "object" && value !== null) {
2498
+ entry.headers = value;
2499
+ }
2248
2500
  else if (key === "args" && Array.isArray(value)) {
2249
2501
  entry.args = value;
2250
2502
  }
@@ -2256,8 +2508,11 @@ function generateMcpJson(ast) {
2256
2508
  // Only include servers that have a command or url (filter parser artifacts)
2257
2509
  if (!("command" in entry) && !("url" in entry))
2258
2510
  continue;
2259
- // Always include env field for Claude Code .mcp.json
2260
- if (!("env" in entry)) {
2511
+ // Claude Code reads a url entry without type as stdio and skips it.
2512
+ if ("url" in entry && !("type" in entry))
2513
+ entry.type = "http";
2514
+ // Always include env field for a stdio server in .mcp.json
2515
+ if (!("url" in entry) && !("env" in entry)) {
2261
2516
  entry.env = {};
2262
2517
  }
2263
2518
  servers[name] = entry;
@@ -2338,6 +2593,7 @@ function generateToolScripts(ast) {
2338
2593
  shebang,
2339
2594
  `# Tool: ${tool.id} — ${tool.description}`,
2340
2595
  "# Auto-generated by agentopology scaffold — edit as needed.",
2596
+ tool.lang === "node" ? `// ${STUB_MARKER}` : STUB_MARKER,
2341
2597
  "",
2342
2598
  ];
2343
2599
  if (tool.lang === "bash" || !tool.lang) {
@@ -2358,14 +2614,50 @@ function generateToolScripts(ast) {
2358
2614
  lines.push(`echo "TODO: implement ${tool.id}"`);
2359
2615
  }
2360
2616
  lines.push("");
2617
+ // The stub goes where the tool's script: says, not to a copy beside it: a
2618
+ // project path is the user's script (create-only); an absolute one is never
2619
+ // touched; a bare name lives in the skill's scripts folder.
2620
+ const script = tool.script ? runParts(tool.script).script : "";
2621
+ if (script.startsWith("/"))
2622
+ continue;
2623
+ const project = isProjectPath(script);
2624
+ const base = script.replace(/^.*\//, "");
2361
2625
  files.push({
2362
- path: `.claude/skills/${name}/scripts/${tool.id}.${ext}`,
2626
+ path: project
2627
+ ? script.replace(/^\.\//, "")
2628
+ : `.claude/skills/${name}/scripts/${base || `${tool.id}.${ext}`}`,
2363
2629
  content: lines.join("\n"),
2364
2630
  category: "script",
2631
+ ...(project ? { createOnly: true } : {}),
2365
2632
  });
2366
2633
  }
2367
2634
  return files;
2368
2635
  }
2636
+ /**
2637
+ * A cron expression for an `every:` duration, or null when cron cannot say
2638
+ * it. Only "1h", "daily" and a few words converted before; anything else
2639
+ * became a commented-out crontab line that never ran.
2640
+ */
2641
+ function everyToCron(every) {
2642
+ const text = every.trim().toLowerCase();
2643
+ const words = { hourly: "0 * * * *", daily: "0 0 * * *", weekly: "0 0 * * 0" };
2644
+ if (words[text])
2645
+ return words[text];
2646
+ const m = text.match(/^(\d+)\s*(m|min|mins|minutes?|h|hours?|d|days?|w|weeks?)$/);
2647
+ if (!m)
2648
+ return null;
2649
+ const n = Number(m[1]);
2650
+ const unit = m[2][0];
2651
+ if (n <= 0)
2652
+ return null;
2653
+ if (unit === "m")
2654
+ return n < 60 && 60 % n === 0 ? (n === 1 ? "* * * * *" : `*/${n} * * * *`) : null;
2655
+ if (unit === "h")
2656
+ return n < 24 && 24 % n === 0 ? (n === 1 ? "0 * * * *" : `0 */${n} * * *`) : null;
2657
+ if (unit === "d")
2658
+ return n === 1 ? "0 0 * * *" : null;
2659
+ return n === 1 ? "0 0 * * 0" : null;
2660
+ }
2369
2661
  /** Generate schema documentation files. */
2370
2662
  function generateSchemas(ast) {
2371
2663
  const files = [];
@@ -2410,15 +2702,18 @@ function generateScheduleScripts(ast) {
2410
2702
  `# Schedule: ${job.id} — invokes ${target}`,
2411
2703
  "# Auto-generated by agentopology scaffold — edit as needed.",
2412
2704
  'set -euo pipefail',
2705
+ "# cron starts in $HOME: run from the project root (this script is in scripts/).",
2706
+ 'cd "$(dirname "$0")/.."',
2413
2707
  "",
2414
2708
  ];
2415
2709
  if (job.agent) {
2710
+ // Headless: cron has no terminal, so an interactive session never ran.
2416
2711
  lines.push(`# Invoke agent: ${job.agent}`);
2417
- lines.push(`claude --agent ${job.agent}`);
2712
+ lines.push(`claude -p "Run your scheduled task (${job.id})." --agent ${job.agent}`);
2418
2713
  }
2419
2714
  else if (job.action) {
2420
2715
  lines.push(`# Invoke action: ${job.action}`);
2421
- lines.push(`bash scripts/action-${job.action}.sh`);
2716
+ lines.push(`bash .claude/skills/${ast.topology.name}/scripts/action-${job.action}.sh`);
2422
2717
  }
2423
2718
  if (job.enabled === false) {
2424
2719
  lines.unshift("# DISABLED — this schedule is not enabled");
@@ -2428,18 +2723,12 @@ function generateScheduleScripts(ast) {
2428
2723
  // Add crontab entry
2429
2724
  let cronExpr = job.cron ?? "# every: " + (job.every ?? "unknown");
2430
2725
  if (!job.cron && job.every) {
2431
- // Convert common "every" expressions to cron
2432
- const everyMap = {
2433
- "1h": "0 * * * *",
2434
- "1 hour": "0 * * * *",
2435
- "hourly": "0 * * * *",
2436
- "1d": "0 0 * * *",
2437
- "1 day": "0 0 * * *",
2438
- "daily": "0 0 * * *",
2439
- "1w": "0 0 * * 0",
2440
- "weekly": "0 0 * * 0",
2441
- };
2442
- cronExpr = everyMap[job.every.toLowerCase()] ?? `# every: ${job.every}`;
2726
+ const converted = everyToCron(job.every);
2727
+ if (!converted) {
2728
+ console.warn(`[claude-code] Schedule "${job.id}" every: ${job.every} has no cron form; ` +
2729
+ `its crontab line is commented out. Use cron: instead.`);
2730
+ }
2731
+ cronExpr = converted ?? `# every: ${job.every}`;
2443
2732
  }
2444
2733
  const enabledPrefix = job.enabled === false ? "# DISABLED: " : "";
2445
2734
  cronLines.push(`${enabledPrefix}${cronExpr} bash ${scriptPath} # ${job.id}`);
@@ -2607,7 +2896,11 @@ function generateWorkspaceProtocol(ast) {
2607
2896
  * destroy their content.
2608
2897
  */
2609
2898
  function generateContextFile(ast) {
2610
- const DEFAULT_PATH = ".claude/CLAUDE.md";
2899
+ // A rules file, one per topology: Claude Code loads .claude/rules/*.md like
2900
+ // CLAUDE.md, so the context still reaches every session, but it never
2901
+ // competes with the project's own CLAUDE.md or .claude/CLAUDE.md (it used to
2902
+ // be written to .claude/CLAUDE.md, a conflict in any project that had one).
2903
+ const DEFAULT_PATH = `.claude/rules/${ast.topology.name}.md`;
2611
2904
  let fileName = ast.context.file ?? DEFAULT_PATH;
2612
2905
  // Safety: never overwrite the user's root-level CLAUDE.md
2613
2906
  if (fileName === "CLAUDE.md" || fileName === "./CLAUDE.md") {
@@ -2804,7 +3097,7 @@ function generateWorkflowLaunchArtifacts(ast) {
2804
3097
  }
2805
3098
  /**
2806
3099
  * HYBRID: build the "Hybrid run order" markdown block appended to the generated
2807
- * context file (.claude/CLAUDE.md) and the topology SKILL.md. It states the
3100
+ * context file (.claude/rules/<topology>.md) and the topology SKILL.md. It states the
2808
3101
  * host's interleaving with the embedded workflow rung, in the SAME run-order
2809
3102
  * vocabulary the claude-workflow binding's SEAM.md uses, so the two bindings
2810
3103
  * stay in sync. Returns null when the topology is not a hybrid.
@@ -3061,7 +3354,6 @@ export const claudeCodeBinding = {
3061
3354
  // Agent files — may include group conversation protocol sections
3062
3355
  files.push(...generateAgents(ast));
3063
3356
  // Human nodes
3064
- files.push(...generateHumanNodes(ast));
3065
3357
  // Main topology skill (with frontmatter)
3066
3358
  const skillFiles = generateTopologySkill(ast);
3067
3359
  // Append trigger info to the main skill
@@ -3071,6 +3363,7 @@ export const claudeCodeBinding = {
3071
3363
  files.push(...skillFiles);
3072
3364
  // Named skills
3073
3365
  files.push(...generateSkills(ast));
3366
+ files.push(...generateAgentSkills(ast));
3074
3367
  // Schemas
3075
3368
  files.push(...generateSchemas(ast));
3076
3369
  // Memory directories
@@ -3080,6 +3373,9 @@ export const claudeCodeBinding = {
3080
3373
  // Gate scripts
3081
3374
  files.push(...generateGateScripts(ast));
3082
3375
  files.push(...generateGateWrapperScripts(ast));
3376
+ const beforeAgent = generateBeforeAgentScript(ast);
3377
+ if (beforeAgent)
3378
+ files.push(beforeAgent);
3083
3379
  // Hook scripts (global)
3084
3380
  files.push(...generateHookScripts(ast));
3085
3381
  // Hook scripts (per-agent)