agentopology 0.2.1 → 0.2.4

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 (79) hide show
  1. package/dist/analyzer/__tests__/observability-gap.test.d.ts +8 -0
  2. package/dist/analyzer/__tests__/observability-gap.test.d.ts.map +1 -0
  3. package/dist/analyzer/__tests__/observability-gap.test.js +132 -0
  4. package/dist/analyzer/__tests__/observability-gap.test.js.map +1 -0
  5. package/dist/analyzer/index.d.ts +1 -1
  6. package/dist/analyzer/index.d.ts.map +1 -1
  7. package/dist/analyzer/index.js +26 -0
  8. package/dist/analyzer/index.js.map +1 -1
  9. package/dist/bindings/__tests__/bindings.test.js +423 -0
  10. package/dist/bindings/__tests__/bindings.test.js.map +1 -1
  11. package/dist/bindings/__tests__/observability.test.d.ts +9 -0
  12. package/dist/bindings/__tests__/observability.test.d.ts.map +1 -0
  13. package/dist/bindings/__tests__/observability.test.js +136 -0
  14. package/dist/bindings/__tests__/observability.test.js.map +1 -0
  15. package/dist/bindings/__tests__/stub-generation.test.d.ts +16 -0
  16. package/dist/bindings/__tests__/stub-generation.test.d.ts.map +1 -0
  17. package/dist/bindings/__tests__/stub-generation.test.js +117 -0
  18. package/dist/bindings/__tests__/stub-generation.test.js.map +1 -0
  19. package/dist/bindings/claude-code.d.ts.map +1 -1
  20. package/dist/bindings/claude-code.js +188 -37
  21. package/dist/bindings/claude-code.js.map +1 -1
  22. package/dist/bindings/codex.d.ts.map +1 -1
  23. package/dist/bindings/codex.js +1 -12
  24. package/dist/bindings/codex.js.map +1 -1
  25. package/dist/bindings/cursor.d.ts.map +1 -1
  26. package/dist/bindings/cursor.js +0 -12
  27. package/dist/bindings/cursor.js.map +1 -1
  28. package/dist/bindings/gemini-cli.d.ts.map +1 -1
  29. package/dist/bindings/gemini-cli.js +1 -12
  30. package/dist/bindings/gemini-cli.js.map +1 -1
  31. package/dist/bindings/kiro.d.ts.map +1 -1
  32. package/dist/bindings/kiro.js +1 -12
  33. package/dist/bindings/kiro.js.map +1 -1
  34. package/dist/bindings/lib/__tests__/stub.test.d.ts +8 -0
  35. package/dist/bindings/lib/__tests__/stub.test.d.ts.map +1 -0
  36. package/dist/bindings/lib/__tests__/stub.test.js +66 -0
  37. package/dist/bindings/lib/__tests__/stub.test.js.map +1 -0
  38. package/dist/bindings/lib/stub.d.ts +56 -0
  39. package/dist/bindings/lib/stub.d.ts.map +1 -0
  40. package/dist/bindings/lib/stub.js +69 -0
  41. package/dist/bindings/lib/stub.js.map +1 -0
  42. package/dist/bindings/openclaw.d.ts.map +1 -1
  43. package/dist/bindings/openclaw.js +1 -12
  44. package/dist/bindings/openclaw.js.map +1 -1
  45. package/dist/bindings/types.d.ts +5 -0
  46. package/dist/bindings/types.d.ts.map +1 -1
  47. package/dist/bindings/types.js.map +1 -1
  48. package/dist/cli/index.js +89 -4
  49. package/dist/cli/index.js.map +1 -1
  50. package/dist/docs/content.d.ts.map +1 -1
  51. package/dist/docs/content.js +19 -5
  52. package/dist/docs/content.js.map +1 -1
  53. package/dist/parser/__tests__/parser.test.js +88 -3
  54. package/dist/parser/__tests__/parser.test.js.map +1 -1
  55. package/dist/parser/ast.d.ts +15 -0
  56. package/dist/parser/ast.d.ts.map +1 -1
  57. package/dist/parser/index.d.ts.map +1 -1
  58. package/dist/parser/index.js +12 -0
  59. package/dist/parser/index.js.map +1 -1
  60. package/dist/parser/validator.d.ts.map +1 -1
  61. package/dist/parser/validator.js +65 -9
  62. package/dist/parser/validator.js.map +1 -1
  63. package/dist/scaffold/__tests__/scaffold.test.js +20 -0
  64. package/dist/scaffold/__tests__/scaffold.test.js.map +1 -1
  65. package/dist/scaffold/__tests__/settings-merge.test.d.ts +15 -0
  66. package/dist/scaffold/__tests__/settings-merge.test.d.ts.map +1 -0
  67. package/dist/scaffold/__tests__/settings-merge.test.js +127 -0
  68. package/dist/scaffold/__tests__/settings-merge.test.js.map +1 -0
  69. package/dist/scaffold/incremental.d.ts.map +1 -1
  70. package/dist/scaffold/incremental.js +11 -2
  71. package/dist/scaffold/incremental.js.map +1 -1
  72. package/dist/scaffold/merge.d.ts +16 -0
  73. package/dist/scaffold/merge.d.ts.map +1 -1
  74. package/dist/scaffold/merge.js +77 -0
  75. package/dist/scaffold/merge.js.map +1 -1
  76. package/dist/scaffold/types.d.ts +2 -0
  77. package/dist/scaffold/types.d.ts.map +1 -1
  78. package/package.json +1 -1
  79. package/spec/grammar.md +9 -0
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Tests for Change 4 + 5: Agent-scoped env var injection + stub script generation.
3
+ *
4
+ * Change 4: When scaffold generates a hook stub, it must include logic to
5
+ * extract ACTIVE_AGENT from the Claude Code stdin JSON payload (agent_type field).
6
+ *
7
+ * Change 5: When a hook's run: path does not exist on disk, scaffold emits a stub
8
+ * at that path with:
9
+ * - #!/usr/bin/env bash shebang
10
+ * - jq-based AGENT_TYPE extraction from stdin
11
+ * - A metrics.jsonl append
12
+ * - chmod +x (executable bit)
13
+ * - Auto-generated marker comment
14
+ */
15
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
16
+ import * as fs from "node:fs";
17
+ import * as path from "node:path";
18
+ import * as os from "node:os";
19
+ import { parse } from "../../parser/index.js";
20
+ import { bindings } from "../index.js";
21
+ let tmpDir;
22
+ beforeEach(() => {
23
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "stub-gen-test-"));
24
+ });
25
+ afterEach(() => {
26
+ fs.rmSync(tmpDir, { recursive: true, force: true });
27
+ });
28
+ // ---------------------------------------------------------------------------
29
+ // Topology with a hook pointing at a non-existent script
30
+ // ---------------------------------------------------------------------------
31
+ const STUB_TOPOLOGY = `
32
+ topology stub-test : [supervisor] {
33
+ meta { version: "1.0.0" description: "Stub generation test" }
34
+ orchestrator { model: opus handles: [start] }
35
+ action start { kind: inline }
36
+ agent tracker { model: sonnet phase: 1 prompt { You are tracker. } }
37
+ flow { start -> tracker }
38
+ hooks {
39
+ hook log-finish {
40
+ on: SubagentStop
41
+ matcher: "tracker"
42
+ run: ".claude/scripts/log-finish.sh"
43
+ type: command
44
+ timeout: 5000
45
+ }
46
+ }
47
+ }
48
+ `;
49
+ // ---------------------------------------------------------------------------
50
+ // Change 5 — stub generation for non-existent hook scripts
51
+ // ---------------------------------------------------------------------------
52
+ describe("Change 5 — hook stub generation", () => {
53
+ const ast = parse(STUB_TOPOLOGY);
54
+ const binding = bindings["claude-code"];
55
+ it("generates a stub script file for hook with non-existent run path", () => {
56
+ const files = binding.scaffold(ast);
57
+ // The stub should be at the path declared in hook.run
58
+ const stub = files.find((f) => f.path === ".claude/scripts/log-finish.sh");
59
+ expect(stub).toBeDefined();
60
+ });
61
+ it("stub file has category 'script'", () => {
62
+ const files = binding.scaffold(ast);
63
+ const stub = files.find((f) => f.path === ".claude/scripts/log-finish.sh");
64
+ expect(stub.category).toBe("script");
65
+ });
66
+ it("stub contains shebang line", () => {
67
+ const files = binding.scaffold(ast);
68
+ const stub = files.find((f) => f.path === ".claude/scripts/log-finish.sh");
69
+ expect(stub.content).toContain("#!/usr/bin/env bash");
70
+ });
71
+ it("stub contains auto-generated marker", () => {
72
+ const files = binding.scaffold(ast);
73
+ const stub = files.find((f) => f.path === ".claude/scripts/log-finish.sh");
74
+ expect(stub.content).toContain("Auto-generated by agentopology scaffold");
75
+ });
76
+ it("stub extracts AGENT_TYPE from stdin JSON (Change 4 — env var injection)", () => {
77
+ const files = binding.scaffold(ast);
78
+ const stub = files.find((f) => f.path === ".claude/scripts/log-finish.sh");
79
+ // Stub must extract agent_type from the Claude Code stdin payload
80
+ expect(stub.content).toContain("agent_type");
81
+ expect(stub.content).toContain("AGENT_TYPE");
82
+ });
83
+ it("stub appends to metrics.jsonl", () => {
84
+ const files = binding.scaffold(ast);
85
+ const stub = files.find((f) => f.path === ".claude/scripts/log-finish.sh");
86
+ expect(stub.content).toContain("metrics.jsonl");
87
+ });
88
+ it("stub includes hook name in header comment", () => {
89
+ const files = binding.scaffold(ast);
90
+ const stub = files.find((f) => f.path === ".claude/scripts/log-finish.sh");
91
+ expect(stub.content).toContain("log-finish");
92
+ });
93
+ it("stub includes event name in header comment", () => {
94
+ const files = binding.scaffold(ast);
95
+ const stub = files.find((f) => f.path === ".claude/scripts/log-finish.sh");
96
+ expect(stub.content).toContain("SubagentStop");
97
+ });
98
+ it("stub is marked executable (via a generated chmod hint or content)", () => {
99
+ // Since scaffold returns file content (not real fs writes), we check the
100
+ // category is 'script' — the executeActions function sets chmod on creation.
101
+ // The actual chmod happens in executeActions. We verify it in an integration
102
+ // test by writing the file and checking the mode.
103
+ const files = binding.scaffold(ast);
104
+ const stub = files.find((f) => f.path === ".claude/scripts/log-finish.sh");
105
+ expect(stub).toBeDefined();
106
+ expect(stub.category).toBe("script");
107
+ expect(stub.executable).toBe(true);
108
+ });
109
+ });
110
+ // ---------------------------------------------------------------------------
111
+ // Change 5 — NO stub if the script already exists on disk (not scaffold's job)
112
+ // ---------------------------------------------------------------------------
113
+ // Note: scaffold() generates file lists; the incremental engine decides
114
+ // what to write. The stub is always in the generated list — the incremental
115
+ // engine's "script" category + shouldOverwriteScript() prevents overwriting
116
+ // user-edited stubs. This is correct behaviour, already tested in scaffold.test.ts.
117
+ //# sourceMappingURL=stub-generation.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stub-generation.test.js","sourceRoot":"","sources":["../../../src/bindings/__tests__/stub-generation.test.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"claude-code.d.ts","sourceRoot":"","sources":["../../src/bindings/claude-code.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAuBH,OAAO,KAAK,EAAE,aAAa,EAAiB,MAAM,YAAY,CAAC;AA+xE/D,kEAAkE;AAClE,eAAO,MAAM,iBAAiB,EAAE,aA0F/B,CAAC"}
1
+ {"version":3,"file":"claude-code.d.ts","sourceRoot":"","sources":["../../src/bindings/claude-code.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAuBH,OAAO,KAAK,EAAE,aAAa,EAAiB,MAAM,YAAY,CAAC;AAs8E/D,kEAAkE;AAClE,eAAO,MAAM,iBAAiB,EAAE,aA0F/B,CAAC"}
@@ -7,6 +7,7 @@
7
7
  * @module
8
8
  */
9
9
  import { deduplicateFiles } from "./types.js";
10
+ import { shellStub } from "./lib/stub.js";
10
11
  // ---------------------------------------------------------------------------
11
12
  // Helpers
12
13
  // ---------------------------------------------------------------------------
@@ -76,17 +77,30 @@ function frontmatter(fields) {
76
77
  lines.push("---");
77
78
  return lines.join("\n");
78
79
  }
79
- /** Generate a shell script stub with a header comment. */
80
- function shellStub(description) {
81
- return [
82
- "#!/usr/bin/env bash",
83
- `# ${description}`,
84
- "# Auto-generated by agentopology scaffold — edit as needed.",
85
- 'set -euo pipefail',
86
- "",
87
- 'echo "TODO: implement this script"',
88
- "",
89
- ].join("\n");
80
+ /**
81
+ * Check whether `name` refers to a node registered as a Claude Code subagent_type.
82
+ *
83
+ * Both `agent` and `group` nodes scaffold a `.claude/agents/<name>/AGENT.md`
84
+ * file, which is what Claude Code registers as a subagent_type. Action, gate,
85
+ * human, and orchestrator nodes are NOT registered and therefore CANNOT be
86
+ * used as SubagentStop matchers.
87
+ */
88
+ function isRegisteredSubagentType(ast, name) {
89
+ if (!name)
90
+ return false;
91
+ return ast.nodes.some((n) => (n.type === "agent" || n.type === "group") && n.id === name);
92
+ }
93
+ /**
94
+ * Resolve the topology's orchestration delegation mode.
95
+ *
96
+ * Returns `"inline"` if the orchestrator block declares `delegation: inline`.
97
+ * Otherwise returns `"subagent"` (the default). Inline-orchestrator topologies
98
+ * drive every agent in the main session's context; no Task-tool subagent is
99
+ * ever spawned, so any `SubagentStop` hook would silently never fire.
100
+ */
101
+ function getDelegationMode(ast) {
102
+ const orch = ast.nodes.find((n) => n.type === "orchestrator");
103
+ return orch?.delegation === "inline" ? "inline" : "subagent";
90
104
  }
91
105
  /** Generate an empty directory marker (a .gitkeep file). */
92
106
  function gitkeep(dirPath) {
@@ -1457,11 +1471,28 @@ function generateGateWrapperScripts(ast) {
1457
1471
  const onFailExit = gate.onFail === "halt"
1458
1472
  ? `echo "Gate '${gate.id}' validation failed. Fix issues before proceeding." >&2\n exit 2`
1459
1473
  : `echo "Gate '${gate.id}' failed — bounce back and fix the issues." >&2\n exit 2`;
1474
+ const inlineMode = getDelegationMode(ast) === "inline";
1475
+ const wiredAsHook = !inlineMode && isRegisteredSubagentType(ast, gate.after);
1476
+ let enforcementNote;
1477
+ let enforcementNote2;
1478
+ if (inlineMode) {
1479
+ enforcementNote = `# Enforcement: NOT wired as a SubagentStop hook (orchestrator.delegation is "inline").`;
1480
+ enforcementNote2 = `# Invoke this script from your /${ast.topology.name} playbook at the right step.`;
1481
+ }
1482
+ else if (wiredAsHook) {
1483
+ enforcementNote = `# Enforcement: SubagentStop hook on agent "${gate.after}". Exit 2 blocks the subagent`;
1484
+ enforcementNote2 = `# from stopping (Claude Code prevents stop and surfaces stderr to Claude).`;
1485
+ }
1486
+ else {
1487
+ enforcementNote = `# Enforcement: NOT wired as a SubagentStop hook (after: ${gate.after || "<unset>"} is not an agent).`;
1488
+ enforcementNote2 = `# Invoke this script from your orchestrator playbook or a /command at the right step.`;
1489
+ }
1460
1490
  const contentLines = [
1461
1491
  "#!/usr/bin/env bash",
1462
1492
  `# Gate wrapper: ${gate.id}`,
1463
- `# Enforces gate "${gate.id}" on SubagentStop (after: ${gate.after || "any"}).`,
1464
1493
  `# Auto-generated by agentopology scaffold — edit as needed.`,
1494
+ enforcementNote,
1495
+ enforcementNote2,
1465
1496
  "set -euo pipefail",
1466
1497
  "",
1467
1498
  "# Gate runs after: " + (gate.after || "any"),
@@ -1517,27 +1548,66 @@ function generateGateWrapperScripts(ast) {
1517
1548
  }
1518
1549
  return files;
1519
1550
  }
1520
- /** Generate hook script stubs for every hook's `run` field. */
1551
+ /**
1552
+ * Generate an observability-aware hook stub (Changes 4+5).
1553
+ *
1554
+ * For SubagentStop/Stop hooks: extract agent_type from the Claude Code stdin
1555
+ * JSON payload and export as AGENT_TYPE, then append a metrics.jsonl entry.
1556
+ * For other events: emit a simple TODO stub.
1557
+ */
1558
+ function observabilityStub(hookName, eventName, matcherHint) {
1559
+ const isFinishEvent = eventName === "SubagentStop" || eventName === "Stop";
1560
+ if (isFinishEvent) {
1561
+ return [
1562
+ "#!/usr/bin/env bash",
1563
+ `# Auto-generated by agentopology scaffold on ${new Date().toISOString().slice(0, 10)}`,
1564
+ `# Hook: ${hookName} · Event: ${eventName} · Matcher: ${matcherHint || "*"}`,
1565
+ "# Replace this stub with your implementation.",
1566
+ "# Stdin JSON payload from Claude Code is available on FD 0.",
1567
+ "set -euo pipefail",
1568
+ "",
1569
+ "PAYLOAD=$(cat)",
1570
+ 'AGENT_TYPE=$(echo "$PAYLOAD" | jq -r \'.agent_type // "unknown"\')',
1571
+ 'SESSION_ID=$(echo "$PAYLOAD" | jq -r \'.session_id // ""\')',
1572
+ "",
1573
+ `echo "{\\\"ts\\\":\\\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\\\",\\\"event\\\":\\\"${eventName}\\\",\\\"agent\\\":\\\"$AGENT_TYPE\\\",\\\"session\\\":\\\"$SESSION_ID\\\",\\\"hook\\\":\\\"${hookName}\\\"}" >> .claude/memory/metrics.jsonl`,
1574
+ "",
1575
+ ].join("\n");
1576
+ }
1577
+ return [
1578
+ "#!/usr/bin/env bash",
1579
+ `# Auto-generated by agentopology scaffold on ${new Date().toISOString().slice(0, 10)}`,
1580
+ `# Hook: ${hookName} · Event: ${eventName} · Matcher: ${matcherHint || "*"}`,
1581
+ "# Replace this stub with your implementation.",
1582
+ "# Stdin JSON payload from Claude Code is available on FD 0.",
1583
+ "set -euo pipefail",
1584
+ "",
1585
+ 'PAYLOAD=$(cat)',
1586
+ 'AGENT_TYPE=$(echo "$PAYLOAD" | jq -r \'.agent_type // "unknown"\')',
1587
+ "",
1588
+ `echo "TODO: implement ${hookName} hook (event: ${eventName}, agent: $AGENT_TYPE)"`,
1589
+ "",
1590
+ ].join("\n");
1591
+ }
1592
+ /** Generate hook script stubs for every hook's `run` field (Changes 1, 4, 5). */
1521
1593
  function generateHookScripts(ast) {
1522
1594
  const files = [];
1523
1595
  const name = ast.topology.name;
1524
1596
  for (const hook of ast.hooks) {
1525
1597
  if (!hook.run)
1526
1598
  continue;
1527
- // Extract script filename from the run command
1528
- const scriptName = hook.run.replace(/^.*\//, "").replace(/\s.*$/, "");
1599
+ const runPath = hook.run;
1600
+ // If the run path starts with '.' or '/' it is already project-relative —
1601
+ // generate the stub at exactly that path (observability pattern).
1602
+ // Otherwise scope the stub into this topology's skill scripts directory.
1603
+ const filePath = runPath.startsWith(".") || runPath.startsWith("/")
1604
+ ? runPath
1605
+ : `.claude/skills/${name}/scripts/${runPath.replace(/^.*\//, "").replace(/\s.*$/, "")}`;
1529
1606
  files.push({
1530
- path: `.claude/skills/${name}/scripts/${scriptName}`,
1531
- content: [
1532
- "#!/usr/bin/env bash",
1533
- `# Hook: ${hook.name} — fires on ${hook.on}`,
1534
- "# Auto-generated by agentopology scaffold — edit as needed.",
1535
- 'set -euo pipefail',
1536
- "",
1537
- `echo "TODO: implement ${hook.name} hook"`,
1538
- "",
1539
- ].join("\n"),
1607
+ path: filePath,
1608
+ content: observabilityStub(hook.name, hook.on, hook.matcher ?? ""),
1540
1609
  category: "script",
1610
+ executable: true,
1541
1611
  });
1542
1612
  }
1543
1613
  return files;
@@ -1582,16 +1652,34 @@ function generateAgentHookScripts(ast) {
1582
1652
  function generateSettings(ast) {
1583
1653
  const settings = {};
1584
1654
  const topologyName = ast.topology.name;
1655
+ const isInline = getDelegationMode(ast) === "inline";
1585
1656
  // Hooks section — grouped by event name
1586
1657
  const hooksByEvent = {};
1587
1658
  for (const hook of ast.hooks) {
1588
1659
  const eventName = hook.on;
1660
+ // Inline orchestrator mode never spawns subagents, so SubagentStop /
1661
+ // SubagentStart hooks would never fire. Warn the user and skip emission
1662
+ // — keeping them in settings.json would be a maintenance trap.
1663
+ if (isInline && (eventName === "SubagentStop" || eventName === "SubagentStart")) {
1664
+ console.warn(`[claude-code] Hook "${hook.name}" targets ${eventName} but ` +
1665
+ `orchestrator.delegation is "inline" — no subagent ever runs, so the hook would ` +
1666
+ `never fire. Skipped. Move the work into your orchestrator playbook or change ` +
1667
+ `delegation to "subagent".`);
1668
+ continue;
1669
+ }
1589
1670
  if (!hooksByEvent[eventName]) {
1590
1671
  hooksByEvent[eventName] = [];
1591
1672
  }
1592
- // Build the script command with proper path prefix
1593
- const scriptName = hook.run.replace(/^.*\//, "").replace(/\s.*$/, "");
1594
- const command = `bash .claude/skills/${topologyName}/scripts/${scriptName}`;
1673
+ // Build the script command.
1674
+ // If hook.run starts with '.' or '/' it is already a project-relative or
1675
+ // absolute path — use it verbatim so observability hooks like
1676
+ // ".claude/scripts/log-subagent.sh" land at the correct location.
1677
+ // Otherwise treat it as a bare filename scoped to this topology's skill
1678
+ // scripts directory.
1679
+ const runPath = hook.run;
1680
+ const command = runPath.startsWith(".") || runPath.startsWith("/")
1681
+ ? `bash ${runPath}`
1682
+ : `bash .claude/skills/${topologyName}/scripts/${runPath.replace(/^.*\//, "").replace(/\s.*$/, "")}`;
1595
1683
  const hookEntry = {
1596
1684
  hooks: [
1597
1685
  {
@@ -1610,22 +1698,62 @@ function generateSettings(ast) {
1610
1698
  // Per-agent hooks are now rendered in AGENT.md frontmatter (not in settings.json).
1611
1699
  // This is the Claude Code native approach: hooks in the agent file are scoped
1612
1700
  // to that agent automatically.
1613
- // Gate enforcement — compile gates with run: to SubagentStop hooks
1614
- // Gates fire AFTER an agent finishes (SubagentStop), not before a tool runs.
1615
- // The matcher is the gate's `after:` agent name so the hook fires when that
1616
- // specific agent completes.
1701
+ // Gate enforcement — compile gates with run: to SubagentStop hooks.
1702
+ //
1703
+ // SubagentStop fires when a subagent finishes. Its `matcher` field is matched
1704
+ // against `agent_type` (the registered subagent name from .claude/agents/),
1705
+ // NOT against arbitrary node ids. So a SubagentStop hook only fires when its
1706
+ // matcher names a real agent node — human, action, and orchestrator nodes are
1707
+ // NOT registered as subagent_types and their matchers would silently never
1708
+ // fire.
1709
+ //
1710
+ // SubagentStop with exit code 2 (or `decision: "block"`) DOES block: per the
1711
+ // Claude Code hooks reference, exit 2 "prevents subagent from stopping" and
1712
+ // surfaces stderr to Claude. That is the strongest enforcement available for
1713
+ // a gate that runs after an agent completes.
1714
+ //
1715
+ // For gates whose `after` target is not an agent (e.g. a human checkpoint, an
1716
+ // action, or "any"), no SubagentStop hook fires reliably. We skip emitting
1717
+ // dead config and warn the user — the topology's orchestrator (or a slash
1718
+ // command playbook) must invoke the gate script at the right step.
1719
+ //
1720
+ // INLINE-ORCHESTRATOR MODE: when the topology declares
1721
+ // `orchestrator { delegation: inline }`, no subagent is ever spawned even
1722
+ // when the matcher names a real agent — the orchestrator drives every step
1723
+ // in its own session context. SubagentStop hooks would be dead config in
1724
+ // this mode, so we skip emitting them entirely (still generate the wrapper
1725
+ // script). The gate must be invoked from the playbook at the right step.
1726
+ const delegationMode = getDelegationMode(ast);
1617
1727
  const gates = ast.nodes.filter((n) => n.type === "gate");
1618
1728
  for (const gate of gates) {
1619
1729
  if (!gate.run)
1620
1730
  continue;
1621
1731
  if (gate.behavior === "advisory")
1622
1732
  continue; // advisory gates stay in markdown only
1733
+ if (delegationMode === "inline") {
1734
+ console.warn(`[claude-code] Gate "${gate.id}" — orchestrator.delegation is "inline", ` +
1735
+ `so no subagent ever runs. SubagentStop hook not emitted; invoke ` +
1736
+ `.claude/skills/${topologyName}/scripts/gate-${gate.id}.sh from your ` +
1737
+ `orchestrator playbook at the right step.`);
1738
+ continue;
1739
+ }
1740
+ // SubagentStop matcher must be a registered subagent_type. If the gate
1741
+ // doesn't target an agent node, we cannot wire it as a hook — warn and
1742
+ // skip so we don't ship dead config that never fires.
1743
+ if (!isRegisteredSubagentType(ast, gate.after)) {
1744
+ const targetDesc = gate.after ? `non-agent target "${gate.after}"` : "no after: target";
1745
+ console.warn(`[claude-code] Gate "${gate.id}" has ${targetDesc} — SubagentStop hook not emitted ` +
1746
+ `(matcher must be an agent node id). The gate wrapper script is still generated; ` +
1747
+ `invoke it from your orchestrator playbook or a slash command at the right step.`);
1748
+ continue;
1749
+ }
1623
1750
  const eventName = "SubagentStop";
1624
1751
  if (!hooksByEvent[eventName])
1625
1752
  hooksByEvent[eventName] = [];
1626
1753
  const scriptName = `gate-${gate.id}.sh`;
1627
1754
  const command = `bash .claude/skills/${topologyName}/scripts/${scriptName}`;
1628
1755
  const hookEntry = {
1756
+ matcher: gate.after,
1629
1757
  hooks: [
1630
1758
  {
1631
1759
  type: "command",
@@ -1633,11 +1761,6 @@ function generateSettings(ast) {
1633
1761
  },
1634
1762
  ],
1635
1763
  };
1636
- // Use the gate's after: agent as matcher so the hook only fires
1637
- // when that specific agent completes. Without after:, match all agents.
1638
- if (gate.after) {
1639
- hookEntry.matcher = gate.after;
1640
- }
1641
1764
  hooksByEvent[eventName].push(hookEntry);
1642
1765
  }
1643
1766
  // Write hooksByEvent to settings if any hooks or gates were registered
@@ -1986,6 +2109,34 @@ function generateCommandFiles(ast) {
1986
2109
  }
1987
2110
  sections.push("");
1988
2111
  }
2112
+ // Inline-orchestrator topologies cannot wire gates via SubagentStop hooks
2113
+ // (no subagent is ever spawned). The orchestrator playbook must invoke
2114
+ // each non-advisory gate at the right step. We emit a copy-pasteable
2115
+ // cheat sheet so the user knows exactly which command to drop in and
2116
+ // where in the flow it belongs.
2117
+ if (getDelegationMode(ast) === "inline") {
2118
+ const blockingGates = ast.nodes.filter((n) => n.type === "gate")
2119
+ .filter((g) => g.run && g.behavior !== "advisory");
2120
+ if (blockingGates.length > 0) {
2121
+ sections.push("## Gates to invoke (inline-orchestrator)");
2122
+ sections.push("");
2123
+ sections.push("Because `orchestrator.delegation: inline` is declared, no `SubagentStop` " +
2124
+ "hooks fire. Invoke each gate from this playbook at the step indicated " +
2125
+ "by its `after:` field. Exit code 0 = pass, non-zero = fail.");
2126
+ sections.push("");
2127
+ for (const gate of blockingGates) {
2128
+ const after = gate.after ?? "(any)";
2129
+ const before = gate.before ?? "(any)";
2130
+ const onFail = gate.onFail ?? "halt";
2131
+ sections.push(`### gate \`${gate.id}\` — after \`${after}\`, before \`${before}\`, on-fail \`${onFail}\``);
2132
+ sections.push("");
2133
+ sections.push("```bash");
2134
+ sections.push(`bash .claude/skills/${ast.topology.name}/scripts/gate-${gate.id}.sh`);
2135
+ sections.push("```");
2136
+ sections.push("");
2137
+ }
2138
+ }
2139
+ }
1989
2140
  files.push({
1990
2141
  path: `.claude/commands/${trigger.name}.md`,
1991
2142
  content: sections.join("\n") + "\n",