agentopology 0.6.0 → 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 (59) 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/scaffold-owns.test.js +12 -3
  7. package/dist/__tests__/e2e/scaffold-owns.test.js.map +1 -1
  8. package/dist/__tests__/e2e/sync-cycle.test.d.ts +10 -0
  9. package/dist/__tests__/e2e/sync-cycle.test.d.ts.map +1 -0
  10. package/dist/__tests__/e2e/sync-cycle.test.js +49 -0
  11. package/dist/__tests__/e2e/sync-cycle.test.js.map +1 -0
  12. package/dist/__tests__/live/claude-code.live.test.js +66 -4
  13. package/dist/__tests__/live/claude-code.live.test.js.map +1 -1
  14. package/dist/__tests__/open-findings.test.js +0 -22
  15. package/dist/__tests__/open-findings.test.js.map +1 -1
  16. package/dist/bindings/__tests__/bindings.test.js +19 -19
  17. package/dist/bindings/__tests__/bindings.test.js.map +1 -1
  18. package/dist/bindings/__tests__/claude-code-hybrid.test.js +5 -5
  19. package/dist/bindings/__tests__/claude-code-hybrid.test.js.map +1 -1
  20. package/dist/bindings/__tests__/hybrid-e2e.test.js +1 -1
  21. package/dist/bindings/__tests__/hybrid-e2e.test.js.map +1 -1
  22. package/dist/bindings/claude-code.d.ts +6 -8
  23. package/dist/bindings/claude-code.d.ts.map +1 -1
  24. package/dist/bindings/claude-code.js +361 -194
  25. package/dist/bindings/claude-code.js.map +1 -1
  26. package/dist/bindings/lib/prompt-fence.d.ts +22 -0
  27. package/dist/bindings/lib/prompt-fence.d.ts.map +1 -0
  28. package/dist/bindings/lib/prompt-fence.js +29 -0
  29. package/dist/bindings/lib/prompt-fence.js.map +1 -0
  30. package/dist/cli/index.js +9 -1
  31. package/dist/cli/index.js.map +1 -1
  32. package/dist/docs/teach.d.ts.map +1 -1
  33. package/dist/docs/teach.js +10 -0
  34. package/dist/docs/teach.js.map +1 -1
  35. package/dist/parser/index.d.ts.map +1 -1
  36. package/dist/parser/index.js +26 -3
  37. package/dist/parser/index.js.map +1 -1
  38. package/dist/parser/run.d.ts +23 -2
  39. package/dist/parser/run.d.ts.map +1 -1
  40. package/dist/parser/run.js +39 -3
  41. package/dist/parser/run.js.map +1 -1
  42. package/dist/parser/validator.d.ts.map +1 -1
  43. package/dist/parser/validator.js +52 -2
  44. package/dist/parser/validator.js.map +1 -1
  45. package/dist/scaffold/diff.d.ts +17 -0
  46. package/dist/scaffold/diff.d.ts.map +1 -0
  47. package/dist/scaffold/diff.js +66 -0
  48. package/dist/scaffold/diff.js.map +1 -0
  49. package/dist/scaffold/merge.d.ts.map +1 -1
  50. package/dist/scaffold/merge.js +14 -0
  51. package/dist/scaffold/merge.js.map +1 -1
  52. package/dist/sync/index.d.ts +1 -0
  53. package/dist/sync/index.d.ts.map +1 -1
  54. package/dist/sync/index.js +40 -2
  55. package/dist/sync/index.js.map +1 -1
  56. package/package.json +1 -1
  57. package/spec/grammar.md +4 -1
  58. package/spec/testing.md +26 -22
  59. package/spec/validation.md +3 -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,43 @@ 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
+ /** 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 } : {}) };
30
61
  }
31
- return tool;
62
+ return { type: "command", command: command(), ...(hook.timeout ? { timeout: hook.timeout } : {}) };
32
63
  }
33
64
  /**
34
65
  * Downshift all markdown headings by one level so they nest properly
@@ -55,6 +86,8 @@ function toTitle(id) {
55
86
  */
56
87
  /** Characters that require quoting in YAML scalar values. */
57
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)$/;
58
91
  function frontmatter(fields) {
59
92
  const lines = ["---"];
60
93
  for (const [key, value] of Object.entries(fields)) {
@@ -68,13 +101,13 @@ function frontmatter(fields) {
68
101
  lines.push(` - ${item}`);
69
102
  }
70
103
  }
71
- else if (typeof value === "boolean") {
104
+ else if (typeof value === "boolean" || typeof value === "number") {
72
105
  lines.push(`${key}: ${value}`);
73
106
  }
74
107
  else {
75
108
  // Auto-quote strings containing YAML-special characters
76
109
  const str = String(value);
77
- 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);
78
111
  lines.push(`${key}: ${needsQuotes ? `"${str.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` : str}`);
79
112
  }
80
113
  }
@@ -106,7 +139,96 @@ function getDelegationMode(ast) {
106
139
  const orch = ast.nodes.find((n) => n.type === "orchestrator");
107
140
  return orch?.delegation === "inline" ? "inline" : "subagent";
108
141
  }
109
- /** 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
+ }
110
232
  function gitkeep(dirPath) {
111
233
  return { path: `${dirPath}/.gitkeep`, content: "", category: "machine" };
112
234
  }
@@ -252,7 +374,7 @@ function workflowRunLabel(ast, run) {
252
374
  */
253
375
  function mapPermissionMode(perm) {
254
376
  // Claude Code native values — pass through
255
- const nativeValues = ["plan", "default", "acceptEdits", "dontAsk", "bypassPermissions"];
377
+ const nativeValues = ["plan", "default", "acceptEdits", "auto", "dontAsk", "bypassPermissions"];
256
378
  if (nativeValues.includes(perm)) {
257
379
  // "default" is the default, omit it
258
380
  if (perm === "default")
@@ -265,14 +387,16 @@ function mapPermissionMode(perm) {
265
387
  return "plan";
266
388
  case "autonomous":
267
389
  return undefined; // default, omit
268
- case "auto":
269
- return undefined; // default, omit
390
+ case "confirm":
391
+ return undefined; // asks before each action: Claude Code's default
270
392
  case "interactive":
271
393
  return "acceptEdits";
272
394
  case "unrestricted":
395
+ case "bypass":
273
396
  return "bypassPermissions";
274
397
  default:
275
- return perm; // unknown — pass through as-is
398
+ // Claude Code rejects an unknown permissionMode; V27 already warned.
399
+ return undefined;
276
400
  }
277
401
  }
278
402
  /** Format a SchemaType to a human-readable string. Optionally resolves refs. */
@@ -326,45 +450,24 @@ function generateActionNodes(ast) {
326
450
  if (action.join) {
327
451
  lines.push(`# Join semantics: ${action.join}`);
328
452
  }
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;
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");
368
471
  }
369
472
  lines.push("");
370
473
  files.push({
@@ -393,17 +496,24 @@ function generateAgents(ast) {
393
496
  const fm = {};
394
497
  fm.name = agent.id;
395
498
  // 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;
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.`);
401
513
  }
402
514
  }
403
- if (agent.model)
404
- fm.model = agent.model;
405
515
  if (agent.maxTurns != null)
406
- fm.maxTurns = String(agent.maxTurns);
516
+ fm.maxTurns = agent.maxTurns;
407
517
  if (agent.tools && agent.tools.length > 0)
408
518
  fm.tools = agent.tools.map(convertMcpToolName).join(", ");
409
519
  if (agent.disallowedTools && agent.disallowedTools.length > 0)
@@ -427,8 +537,6 @@ function generateAgents(ast) {
427
537
  if (agent.fallbackChain && agent.fallbackChain.length > 0) {
428
538
  fm["fallback-chain"] = agent.fallbackChain.join(", ");
429
539
  }
430
- // invocation: manual — Claude Code doesn't parse this field. Instead, omit
431
- // description to prevent auto-delegation for manual agents.
432
540
  if (agent.skills && agent.skills.length > 0)
433
541
  fm.skills = agent.skills;
434
542
  // scale is NOT a Claude Code frontmatter field — rendered only in body as prompt text
@@ -452,30 +560,28 @@ function generateAgents(ast) {
452
560
  for (const hook of agent.hooks) {
453
561
  if (hook.type === "mod")
454
562
  continue; // engine-wide only (V94)
455
- if (!hooksByEvent[hook.on])
456
- hooksByEvent[hook.on] = [];
457
- hooksByEvent[hook.on].push(hook);
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);
458
568
  }
459
569
  const hookLines = ["hooks:"];
460
570
  for (const [eventName, hooks] of Object.entries(hooksByEvent)) {
461
571
  hookLines.push(` ${eventName}:`);
462
572
  for (const hook of hooks) {
463
573
  const scriptName = scriptNameFromRun(hook.run);
464
- if (!scriptName)
574
+ if (hook.type !== "prompt" && !scriptName)
465
575
  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
- }
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)}` : ` -`);
473
580
  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
- }
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)}`);
479
585
  }
480
586
  }
481
587
  // Insert hooks before the closing ---
@@ -493,6 +599,9 @@ function generateAgents(ast) {
493
599
  }
494
600
  sections.push("## Instructions");
495
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);
496
605
  if (agent.prompt) {
497
606
  sections.push(downshiftHeadings(agent.prompt));
498
607
  }
@@ -507,6 +616,7 @@ function generateAgents(ast) {
507
616
  else {
508
617
  sections.push("Complete the assigned task.");
509
618
  }
619
+ sections.push(PROMPT_FENCE_CLOSE);
510
620
  sections.push("");
511
621
  // Reads section
512
622
  if (agent.reads && agent.reads.length > 0) {
@@ -814,35 +924,6 @@ function generateAgents(ast) {
814
924
  }
815
925
  return files;
816
926
  }
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
927
  /**
847
928
  * Generate group chat artifacts:
848
929
  * - Orchestrator AGENT.md — spawns members sequentially
@@ -1259,6 +1340,29 @@ function generateTopologySkill(ast) {
1259
1340
  sections.push("");
1260
1341
  }
1261
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
+ }
1262
1366
  // Schedules section
1263
1367
  if (ast.schedules.length > 0) {
1264
1368
  sections.push("## Schedules");
@@ -1453,9 +1557,19 @@ function generateSkills(ast) {
1453
1557
  }
1454
1558
  sections.push("");
1455
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.
1456
1563
  if (skill.prompt) {
1457
- sections.push("## Prompt");
1458
- 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
+ }
1459
1573
  sections.push("");
1460
1574
  }
1461
1575
  files.push({
@@ -1647,13 +1761,21 @@ function generateGateScripts(ast) {
1647
1761
  continue;
1648
1762
  const gate = node;
1649
1763
  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
- }
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
+ });
1657
1779
  }
1658
1780
  return files;
1659
1781
  }
@@ -1669,20 +1791,22 @@ function generateGateWrapperScripts(ast) {
1669
1791
  continue;
1670
1792
  if (gate.behavior === "advisory")
1671
1793
  continue;
1672
- const gateScriptName = scriptNameFromRun(gate.run);
1673
- if (!gateScriptName)
1794
+ if (!scriptNameFromRun(gate.run))
1674
1795
  continue; // V96: prose in run names no script
1796
+ const gateCommand = hookCommand(gate.run, name);
1675
1797
  const onFailExit = gate.onFail === "halt"
1676
1798
  ? `echo "Gate '${gate.id}' validation failed. Fix issues before proceeding." >&2\n exit 2`
1677
1799
  : `echo "Gate '${gate.id}' failed — bounce back and fix the issues." >&2\n exit 2`;
1678
1800
  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));
1801
+ const wiring = gateWiring(ast, gate);
1802
+ const wiredAsHook = wiring.kind === "subagent-stop";
1683
1803
  let enforcementNote;
1684
1804
  let enforcementNote2;
1685
- 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) {
1686
1810
  enforcementNote = `# Enforcement: NOT wired as a SubagentStop hook (orchestrator.delegation is "inline").`;
1687
1811
  enforcementNote2 = `# Invoke this script from your /${ast.topology.name} playbook at the right step.`;
1688
1812
  }
@@ -1718,10 +1842,10 @@ function generateGateWrapperScripts(ast) {
1718
1842
  contentLines.push("while [ $ATTEMPT -le $MAX_RETRIES ] && [ $GATE_RESULT -ne 0 ]; do");
1719
1843
  contentLines.push(" ATTEMPT=$((ATTEMPT + 1))");
1720
1844
  if (gate.timeout) {
1721
- 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=$?`);
1722
1846
  }
1723
1847
  else {
1724
- contentLines.push(` bash .claude/skills/${name}/scripts/${gateScriptName} && GATE_RESULT=0 || GATE_RESULT=$?`);
1848
+ contentLines.push(` ${gateCommand} && GATE_RESULT=0 || GATE_RESULT=$?`);
1725
1849
  }
1726
1850
  contentLines.push(' if [ $GATE_RESULT -ne 0 ] && [ $ATTEMPT -le $MAX_RETRIES ]; then');
1727
1851
  contentLines.push(` echo "Gate '${gate.id}' attempt $ATTEMPT failed, retrying..."`);
@@ -1734,10 +1858,10 @@ function generateGateWrapperScripts(ast) {
1734
1858
  contentLines.push("# Run the gate check script");
1735
1859
  contentLines.push(`GATE_RESULT=0`);
1736
1860
  if (gate.timeout) {
1737
- contentLines.push(`timeout ${gate.timeout} bash .claude/skills/${name}/scripts/${gateScriptName} || GATE_RESULT=$?`);
1861
+ contentLines.push(`timeout ${gate.timeout} ${gateCommand} || GATE_RESULT=$?`);
1738
1862
  }
1739
1863
  else {
1740
- contentLines.push(`bash .claude/skills/${name}/scripts/${gateScriptName} || GATE_RESULT=$?`);
1864
+ contentLines.push(`${gateCommand} || GATE_RESULT=$?`);
1741
1865
  }
1742
1866
  }
1743
1867
  contentLines.push("");
@@ -1767,7 +1891,7 @@ function observabilityStub(hookName, eventName, matcherHint) {
1767
1891
  if (isFinishEvent) {
1768
1892
  return [
1769
1893
  "#!/usr/bin/env bash",
1770
- `# Auto-generated by agentopology scaffold on ${new Date().toISOString().slice(0, 10)}`,
1894
+ `# Auto-generated by agentopology scaffold`,
1771
1895
  `# Hook: ${hookName} · Event: ${eventName} · Matcher: ${matcherHint || "*"}`,
1772
1896
  "# Replace this stub with your implementation.",
1773
1897
  "# Stdin JSON payload from Claude Code is available on FD 0.",
@@ -1783,7 +1907,7 @@ function observabilityStub(hookName, eventName, matcherHint) {
1783
1907
  }
1784
1908
  return [
1785
1909
  "#!/usr/bin/env bash",
1786
- `# Auto-generated by agentopology scaffold on ${new Date().toISOString().slice(0, 10)}`,
1910
+ `# Auto-generated by agentopology scaffold`,
1787
1911
  `# Hook: ${hookName} · Event: ${eventName} · Matcher: ${matcherHint || "*"}`,
1788
1912
  "# Replace this stub with your implementation.",
1789
1913
  "# Stdin JSON payload from Claude Code is available on FD 0.",
@@ -1922,7 +2046,7 @@ function generateHookScripts(ast) {
1922
2046
  continue;
1923
2047
  // A Mod's run: is the user's TypeScript module — never overwrite it with a
1924
2048
  // bash stub. generateModPlugin imports it instead.
1925
- if (hook.type === "mod")
2049
+ if (hook.type === "mod" || hook.type === "prompt")
1926
2050
  continue;
1927
2051
  const runPath = hook.run;
1928
2052
  // If the run path starts with '.' or '/' it is already project-relative —
@@ -1930,12 +2054,12 @@ function generateHookScripts(ast) {
1930
2054
  // Otherwise scope the stub into this topology's skill scripts directory.
1931
2055
  if (proseInRun(runPath))
1932
2056
  continue; // V96: prose in run names no script
1933
- const { script } = hookRun(runPath);
2057
+ const { script } = runParts(runPath);
1934
2058
  // An absolute script is the user's own, outside the project: never stubbed.
1935
2059
  if (script.startsWith("/"))
1936
2060
  continue;
1937
2061
  const scoped = scriptNameFromRun(script);
1938
- const filePath = isHookPath(script)
2062
+ const filePath = isProjectPath(script)
1939
2063
  ? script
1940
2064
  : scoped
1941
2065
  ? `.claude/skills/${name}/scripts/${scoped}`
@@ -1949,7 +2073,7 @@ function generateHookScripts(ast) {
1949
2073
  executable: true,
1950
2074
  // A run that names its own path names the user's script: a stub only
1951
2075
  // fills the gap until it exists. --force used to overwrite it.
1952
- ...(isHookPath(script) ? { createOnly: true } : {}),
2076
+ ...(isProjectPath(script) ? { createOnly: true } : {}),
1953
2077
  });
1954
2078
  }
1955
2079
  return files;
@@ -1965,7 +2089,7 @@ function generateAgentHookScripts(ast) {
1965
2089
  if (!agent.hooks || agent.hooks.length === 0)
1966
2090
  continue;
1967
2091
  for (const hook of agent.hooks) {
1968
- if (!hook.run || hook.type === "mod")
2092
+ if (!hook.run || hook.type === "mod" || hook.type === "prompt")
1969
2093
  continue;
1970
2094
  const scriptName = scriptNameFromRun(hook.run);
1971
2095
  if (!scriptName)
@@ -1987,31 +2111,22 @@ function generateAgentHookScripts(ast) {
1987
2111
  }
1988
2112
  return files;
1989
2113
  }
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
2114
  /**
1999
- * The shell command for a command hook's `run:`.
2115
+ * The shell command for a command hook's or gate's `run:`.
2000
2116
  *
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.
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.
2008
2122
  *
2009
2123
  * Any other `run:` is a script this topology's skill scaffolds, by basename.
2010
2124
  */
2011
2125
  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(/^.*\//, "")}`;
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(" ");
2015
2130
  }
2016
2131
  const interpreter = named ?? (first.endsWith(".py") ? "python3" : "bash");
2017
2132
  const where = first.startsWith("/")
@@ -2035,7 +2150,7 @@ export function settingsHooksOf(ast) {
2035
2150
  // emitted as a plugin (generateModPlugin).
2036
2151
  if (hook.type === "mod")
2037
2152
  continue;
2038
- const eventName = hook.on;
2153
+ const eventName = claudeEvent(hook.on);
2039
2154
  // Inline orchestrator mode never spawns subagents, so SubagentStop /
2040
2155
  // SubagentStart hooks would never fire. Warn the user and skip emission
2041
2156
  // — keeping them in settings.json would be a maintenance trap.
@@ -2046,19 +2161,13 @@ export function settingsHooksOf(ast) {
2046
2161
  `delegation to "subagent".`);
2047
2162
  continue;
2048
2163
  }
2049
- if (proseInRun(hook.run))
2164
+ if (hook.type !== "prompt" && proseInRun(hook.run))
2050
2165
  continue; // V96: prose in run names no script
2051
2166
  if (!hooksByEvent[eventName]) {
2052
2167
  hooksByEvent[eventName] = [];
2053
2168
  }
2054
2169
  const hookEntry = {
2055
- hooks: [
2056
- {
2057
- type: hook.type ?? "command",
2058
- command: hookCommand(hook.run, topologyName),
2059
- ...(hook.timeout ? { timeout: hook.timeout } : {}),
2060
- },
2061
- ],
2170
+ hooks: [hookHandler(hook, () => hookCommand(hook.run, topologyName))],
2062
2171
  };
2063
2172
  // Only add matcher when it has a value
2064
2173
  if (hook.matcher) {
@@ -2121,11 +2230,15 @@ function generateSettings(ast) {
2121
2230
  `orchestrator playbook at the right step.`);
2122
2231
  continue;
2123
2232
  }
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.
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.
2127
2237
  const anchors = gateAnchors(gate, "after");
2128
- 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") {
2129
2242
  const bad = anchors.filter((a) => !isRegisteredSubagentType(ast, a));
2130
2243
  const targetDesc = anchors.length
2131
2244
  ? `non-agent target ${bad.map((b) => `"${b}"`).join(", ")}`
@@ -2164,6 +2277,15 @@ function generateSettings(ast) {
2164
2277
  // Blackboard implies a host-side observer that watches the workflow rung's
2165
2278
  // Write tool-calls LIVE. This is the host's piece of the hybrid's concurrent
2166
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
+ }
2167
2289
  const observeHook = buildHybridObserveHook(ast, topologyName);
2168
2290
  if (observeHook) {
2169
2291
  if (!hooksByEvent[observeHook.event])
@@ -2182,13 +2304,12 @@ function generateSettings(ast) {
2182
2304
  // Permissions section from settings block — nested under "permissions" key
2183
2305
  const perms = ast.settings;
2184
2306
  if (perms) {
2185
- const allow = perms.allow;
2186
- const deny = perms.deny;
2187
2307
  const permObj = {};
2188
- if (allow && allow.length > 0)
2189
- permObj.allow = allow;
2190
- if (deny && deny.length > 0)
2191
- 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
+ }
2192
2313
  if (Object.keys(permObj).length > 0)
2193
2314
  settings.permissions = permObj;
2194
2315
  }
@@ -2242,9 +2363,12 @@ function generateMcpJson(ast) {
2242
2363
  for (const [name, config] of Object.entries(ast.mcpServers)) {
2243
2364
  const entry = {};
2244
2365
  for (const [key, value] of Object.entries(config)) {
2245
- if (key === "command" || key === "url") {
2366
+ if (key === "command" || key === "url" || key === "type") {
2246
2367
  entry[key] = value;
2247
2368
  }
2369
+ else if (key === "headers" && typeof value === "object" && value !== null) {
2370
+ entry.headers = value;
2371
+ }
2248
2372
  else if (key === "args" && Array.isArray(value)) {
2249
2373
  entry.args = value;
2250
2374
  }
@@ -2256,8 +2380,11 @@ function generateMcpJson(ast) {
2256
2380
  // Only include servers that have a command or url (filter parser artifacts)
2257
2381
  if (!("command" in entry) && !("url" in entry))
2258
2382
  continue;
2259
- // Always include env field for Claude Code .mcp.json
2260
- 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)) {
2261
2388
  entry.env = {};
2262
2389
  }
2263
2390
  servers[name] = entry;
@@ -2338,6 +2465,7 @@ function generateToolScripts(ast) {
2338
2465
  shebang,
2339
2466
  `# Tool: ${tool.id} — ${tool.description}`,
2340
2467
  "# Auto-generated by agentopology scaffold — edit as needed.",
2468
+ tool.lang === "node" ? `// ${STUB_MARKER}` : STUB_MARKER,
2341
2469
  "",
2342
2470
  ];
2343
2471
  if (tool.lang === "bash" || !tool.lang) {
@@ -2358,14 +2486,50 @@ function generateToolScripts(ast) {
2358
2486
  lines.push(`echo "TODO: implement ${tool.id}"`);
2359
2487
  }
2360
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(/^.*\//, "");
2361
2497
  files.push({
2362
- path: `.claude/skills/${name}/scripts/${tool.id}.${ext}`,
2498
+ path: project
2499
+ ? script.replace(/^\.\//, "")
2500
+ : `.claude/skills/${name}/scripts/${base || `${tool.id}.${ext}`}`,
2363
2501
  content: lines.join("\n"),
2364
2502
  category: "script",
2503
+ ...(project ? { createOnly: true } : {}),
2365
2504
  });
2366
2505
  }
2367
2506
  return files;
2368
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
+ }
2369
2533
  /** Generate schema documentation files. */
2370
2534
  function generateSchemas(ast) {
2371
2535
  const files = [];
@@ -2410,15 +2574,18 @@ function generateScheduleScripts(ast) {
2410
2574
  `# Schedule: ${job.id} — invokes ${target}`,
2411
2575
  "# Auto-generated by agentopology scaffold — edit as needed.",
2412
2576
  'set -euo pipefail',
2577
+ "# cron starts in $HOME: run from the project root (this script is in scripts/).",
2578
+ 'cd "$(dirname "$0")/.."',
2413
2579
  "",
2414
2580
  ];
2415
2581
  if (job.agent) {
2582
+ // Headless: cron has no terminal, so an interactive session never ran.
2416
2583
  lines.push(`# Invoke agent: ${job.agent}`);
2417
- lines.push(`claude --agent ${job.agent}`);
2584
+ lines.push(`claude -p "Run your scheduled task (${job.id})." --agent ${job.agent}`);
2418
2585
  }
2419
2586
  else if (job.action) {
2420
2587
  lines.push(`# Invoke action: ${job.action}`);
2421
- lines.push(`bash scripts/action-${job.action}.sh`);
2588
+ lines.push(`bash .claude/skills/${ast.topology.name}/scripts/action-${job.action}.sh`);
2422
2589
  }
2423
2590
  if (job.enabled === false) {
2424
2591
  lines.unshift("# DISABLED — this schedule is not enabled");
@@ -2428,18 +2595,12 @@ function generateScheduleScripts(ast) {
2428
2595
  // Add crontab entry
2429
2596
  let cronExpr = job.cron ?? "# every: " + (job.every ?? "unknown");
2430
2597
  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}`;
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}`;
2443
2604
  }
2444
2605
  const enabledPrefix = job.enabled === false ? "# DISABLED: " : "";
2445
2606
  cronLines.push(`${enabledPrefix}${cronExpr} bash ${scriptPath} # ${job.id}`);
@@ -2607,7 +2768,11 @@ function generateWorkspaceProtocol(ast) {
2607
2768
  * destroy their content.
2608
2769
  */
2609
2770
  function generateContextFile(ast) {
2610
- 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`;
2611
2776
  let fileName = ast.context.file ?? DEFAULT_PATH;
2612
2777
  // Safety: never overwrite the user's root-level CLAUDE.md
2613
2778
  if (fileName === "CLAUDE.md" || fileName === "./CLAUDE.md") {
@@ -2804,7 +2969,7 @@ function generateWorkflowLaunchArtifacts(ast) {
2804
2969
  }
2805
2970
  /**
2806
2971
  * 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
2972
+ * context file (.claude/rules/<topology>.md) and the topology SKILL.md. It states the
2808
2973
  * host's interleaving with the embedded workflow rung, in the SAME run-order
2809
2974
  * vocabulary the claude-workflow binding's SEAM.md uses, so the two bindings
2810
2975
  * stay in sync. Returns null when the topology is not a hybrid.
@@ -3061,7 +3226,6 @@ export const claudeCodeBinding = {
3061
3226
  // Agent files — may include group conversation protocol sections
3062
3227
  files.push(...generateAgents(ast));
3063
3228
  // Human nodes
3064
- files.push(...generateHumanNodes(ast));
3065
3229
  // Main topology skill (with frontmatter)
3066
3230
  const skillFiles = generateTopologySkill(ast);
3067
3231
  // Append trigger info to the main skill
@@ -3080,6 +3244,9 @@ export const claudeCodeBinding = {
3080
3244
  // Gate scripts
3081
3245
  files.push(...generateGateScripts(ast));
3082
3246
  files.push(...generateGateWrapperScripts(ast));
3247
+ const beforeAgent = generateBeforeAgentScript(ast);
3248
+ if (beforeAgent)
3249
+ files.push(beforeAgent);
3083
3250
  // Hook scripts (global)
3084
3251
  files.push(...generateHookScripts(ast));
3085
3252
  // Hook scripts (per-agent)