agentopology 0.2.0 → 0.2.3

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 (78) hide show
  1. package/README.md +7 -5
  2. package/dist/analyzer/__tests__/observability-gap.test.d.ts +8 -0
  3. package/dist/analyzer/__tests__/observability-gap.test.d.ts.map +1 -0
  4. package/dist/analyzer/__tests__/observability-gap.test.js +132 -0
  5. package/dist/analyzer/__tests__/observability-gap.test.js.map +1 -0
  6. package/dist/analyzer/index.d.ts +1 -1
  7. package/dist/analyzer/index.d.ts.map +1 -1
  8. package/dist/analyzer/index.js +26 -0
  9. package/dist/analyzer/index.js.map +1 -1
  10. package/dist/bindings/__tests__/bindings.test.js +288 -11
  11. package/dist/bindings/__tests__/bindings.test.js.map +1 -1
  12. package/dist/bindings/__tests__/observability.test.d.ts +9 -0
  13. package/dist/bindings/__tests__/observability.test.d.ts.map +1 -0
  14. package/dist/bindings/__tests__/observability.test.js +136 -0
  15. package/dist/bindings/__tests__/observability.test.js.map +1 -0
  16. package/dist/bindings/__tests__/stub-generation.test.d.ts +16 -0
  17. package/dist/bindings/__tests__/stub-generation.test.d.ts.map +1 -0
  18. package/dist/bindings/__tests__/stub-generation.test.js +117 -0
  19. package/dist/bindings/__tests__/stub-generation.test.js.map +1 -0
  20. package/dist/bindings/claude-code.d.ts.map +1 -1
  21. package/dist/bindings/claude-code.js +159 -110
  22. package/dist/bindings/claude-code.js.map +1 -1
  23. package/dist/bindings/codex.d.ts.map +1 -1
  24. package/dist/bindings/codex.js +1 -12
  25. package/dist/bindings/codex.js.map +1 -1
  26. package/dist/bindings/cursor.d.ts.map +1 -1
  27. package/dist/bindings/cursor.js +0 -12
  28. package/dist/bindings/cursor.js.map +1 -1
  29. package/dist/bindings/gemini-cli.d.ts.map +1 -1
  30. package/dist/bindings/gemini-cli.js +1 -12
  31. package/dist/bindings/gemini-cli.js.map +1 -1
  32. package/dist/bindings/kiro.d.ts.map +1 -1
  33. package/dist/bindings/kiro.js +1 -12
  34. package/dist/bindings/kiro.js.map +1 -1
  35. package/dist/bindings/lib/__tests__/stub.test.d.ts +8 -0
  36. package/dist/bindings/lib/__tests__/stub.test.d.ts.map +1 -0
  37. package/dist/bindings/lib/__tests__/stub.test.js +66 -0
  38. package/dist/bindings/lib/__tests__/stub.test.js.map +1 -0
  39. package/dist/bindings/lib/stub.d.ts +56 -0
  40. package/dist/bindings/lib/stub.d.ts.map +1 -0
  41. package/dist/bindings/lib/stub.js +69 -0
  42. package/dist/bindings/lib/stub.js.map +1 -0
  43. package/dist/bindings/openclaw.d.ts.map +1 -1
  44. package/dist/bindings/openclaw.js +1 -12
  45. package/dist/bindings/openclaw.js.map +1 -1
  46. package/dist/bindings/types.d.ts +5 -0
  47. package/dist/bindings/types.d.ts.map +1 -1
  48. package/dist/bindings/types.js.map +1 -1
  49. package/dist/cli/index.js +89 -4
  50. package/dist/cli/index.js.map +1 -1
  51. package/dist/docs/content.d.ts.map +1 -1
  52. package/dist/docs/content.js +9 -5
  53. package/dist/docs/content.js.map +1 -1
  54. package/dist/import/__tests__/import.test.js +2 -2
  55. package/dist/import/__tests__/import.test.js.map +1 -1
  56. package/dist/parser/__tests__/parser.test.js +31 -3
  57. package/dist/parser/__tests__/parser.test.js.map +1 -1
  58. package/dist/parser/validator.d.ts.map +1 -1
  59. package/dist/parser/validator.js +28 -8
  60. package/dist/parser/validator.js.map +1 -1
  61. package/dist/scaffold/__tests__/scaffold.test.js +20 -0
  62. package/dist/scaffold/__tests__/scaffold.test.js.map +1 -1
  63. package/dist/scaffold/__tests__/settings-merge.test.d.ts +15 -0
  64. package/dist/scaffold/__tests__/settings-merge.test.d.ts.map +1 -0
  65. package/dist/scaffold/__tests__/settings-merge.test.js +127 -0
  66. package/dist/scaffold/__tests__/settings-merge.test.js.map +1 -0
  67. package/dist/scaffold/incremental.d.ts.map +1 -1
  68. package/dist/scaffold/incremental.js +11 -2
  69. package/dist/scaffold/incremental.js.map +1 -1
  70. package/dist/scaffold/merge.d.ts +16 -0
  71. package/dist/scaffold/merge.d.ts.map +1 -1
  72. package/dist/scaffold/merge.js +77 -0
  73. package/dist/scaffold/merge.js.map +1 -1
  74. package/dist/scaffold/types.d.ts +2 -0
  75. package/dist/scaffold/types.d.ts.map +1 -1
  76. package/examples/simple-pipeline-topology.html +5700 -0
  77. package/package.json +2 -1
  78. package/skill/SKILL.md +7 -7
@@ -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+yE/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;AAk3E/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
  // ---------------------------------------------------------------------------
@@ -48,6 +49,8 @@ function toTitle(id) {
48
49
  * - item1
49
50
  * - item2
50
51
  */
52
+ /** Characters that require quoting in YAML scalar values. */
53
+ const YAML_SPECIAL = /[:#\[\]{}&*!|>'"%@`]/;
51
54
  function frontmatter(fields) {
52
55
  const lines = ["---"];
53
56
  for (const [key, value] of Object.entries(fields)) {
@@ -65,23 +68,27 @@ function frontmatter(fields) {
65
68
  lines.push(`${key}: ${value}`);
66
69
  }
67
70
  else {
68
- lines.push(`${key}: ${value}`);
71
+ // Auto-quote strings containing YAML-special characters
72
+ const str = String(value);
73
+ const needsQuotes = YAML_SPECIAL.test(str) || str.trim() !== str;
74
+ lines.push(`${key}: ${needsQuotes ? `"${str.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` : str}`);
69
75
  }
70
76
  }
71
77
  lines.push("---");
72
78
  return lines.join("\n");
73
79
  }
74
- /** Generate a shell script stub with a header comment. */
75
- function shellStub(description) {
76
- return [
77
- "#!/usr/bin/env bash",
78
- `# ${description}`,
79
- "# Auto-generated by agentopology scaffold — edit as needed.",
80
- 'set -euo pipefail',
81
- "",
82
- 'echo "TODO: implement this script"',
83
- "",
84
- ].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);
85
92
  }
86
93
  /** Generate an empty directory marker (a .gitkeep file). */
87
94
  function gitkeep(dirPath) {
@@ -93,10 +100,10 @@ function gitkeep(dirPath) {
93
100
  */
94
101
  function mapPermissionMode(perm) {
95
102
  // Claude Code native values — pass through
96
- const nativeValues = ["plan", "auto", "confirm", "bypassPermissions"];
103
+ const nativeValues = ["plan", "default", "acceptEdits", "dontAsk", "bypassPermissions"];
97
104
  if (nativeValues.includes(perm)) {
98
- // "auto" is the default, omit it
99
- if (perm === "auto")
105
+ // "default" is the default, omit it
106
+ if (perm === "default")
100
107
  return undefined;
101
108
  return perm;
102
109
  }
@@ -106,8 +113,10 @@ function mapPermissionMode(perm) {
106
113
  return "plan";
107
114
  case "autonomous":
108
115
  return undefined; // default, omit
116
+ case "auto":
117
+ return undefined; // default, omit
109
118
  case "interactive":
110
- return "default";
119
+ return "acceptEdits";
111
120
  case "unrestricted":
112
121
  return "bypassPermissions";
113
122
  default:
@@ -229,7 +238,7 @@ function generateAgents(ast) {
229
238
  if (agent.invocation !== "manual") {
230
239
  const desc = agent.description ?? ast.roles[agent.role ?? ""] ?? ast.roles[agent.id] ?? agent.role;
231
240
  if (desc) {
232
- fm.description = `"${desc}"`;
241
+ fm.description = desc;
233
242
  }
234
243
  }
235
244
  if (agent.model)
@@ -237,9 +246,9 @@ function generateAgents(ast) {
237
246
  if (agent.maxTurns != null)
238
247
  fm.maxTurns = String(agent.maxTurns);
239
248
  if (agent.tools && agent.tools.length > 0)
240
- fm.tools = agent.tools.map(convertMcpToolName);
249
+ fm.tools = agent.tools.map(convertMcpToolName).join(", ");
241
250
  if (agent.disallowedTools && agent.disallowedTools.length > 0)
242
- fm.disallowedTools = agent.disallowedTools.map(convertMcpToolName);
251
+ fm.disallowedTools = agent.disallowedTools.map(convertMcpToolName).join(", ");
243
252
  if (agent.mcpServers && agent.mcpServers.length > 0)
244
253
  fm.mcpServers = agent.mcpServers;
245
254
  if (agent.background === true)
@@ -836,7 +845,7 @@ function generateGroupNodes(ast) {
836
845
  const skillFm = {};
837
846
  skillFm.name = group.id;
838
847
  if (group.description)
839
- skillFm.description = `"${group.description}"`;
848
+ skillFm.description = group.description;
840
849
  skillFm["user-invocable"] = true;
841
850
  const skillSections = [];
842
851
  skillSections.push(frontmatter(skillFm));
@@ -853,7 +862,7 @@ function generateGroupNodes(ast) {
853
862
  const agentFm = {};
854
863
  agentFm.name = group.id;
855
864
  if (group.description)
856
- agentFm.description = `"${group.description}"`;
865
+ agentFm.description = group.description;
857
866
  const agentSections = [];
858
867
  agentSections.push(frontmatter(agentFm));
859
868
  agentSections.push("");
@@ -879,9 +888,9 @@ function generateTopologySkill(ast) {
879
888
  const fm = {};
880
889
  fm.name = name;
881
890
  if (ast.topology.description) {
882
- fm.description = `"${ast.topology.description}"`;
891
+ fm.description = ast.topology.description;
883
892
  }
884
- fm.version = `"${ast.topology.version}"`;
893
+ fm.version = ast.topology.version;
885
894
  fm.topology = name;
886
895
  if (ast.topology.patterns.length > 0) {
887
896
  fm.patterns = ast.topology.patterns;
@@ -905,7 +914,7 @@ function generateTopologySkill(ast) {
905
914
  if (mainSkill.agent)
906
915
  fm.agent = mainSkill.agent;
907
916
  if (mainSkill.allowedTools && mainSkill.allowedTools.length > 0) {
908
- fm["allowed-tools"] = mainSkill.allowedTools.map(convertMcpToolName);
917
+ fm["allowed-tools"] = mainSkill.allowedTools.map(convertMcpToolName).join(", ");
909
918
  }
910
919
  }
911
920
  const sections = [];
@@ -1222,7 +1231,7 @@ function generateSkills(ast) {
1222
1231
  const sfm = {};
1223
1232
  sfm.name = skill.id;
1224
1233
  if (skill.description)
1225
- sfm.description = `"${skill.description}"`;
1234
+ sfm.description = skill.description;
1226
1235
  if (skill.disableModelInvocation != null) {
1227
1236
  sfm["disable-model-invocation"] = skill.disableModelInvocation;
1228
1237
  }
@@ -1234,7 +1243,7 @@ function generateSkills(ast) {
1234
1243
  if (skill.agent)
1235
1244
  sfm.agent = skill.agent;
1236
1245
  if (skill.allowedTools && skill.allowedTools.length > 0) {
1237
- sfm["allowed-tools"] = skill.allowedTools.map(convertMcpToolName);
1246
+ sfm["allowed-tools"] = skill.allowedTools.map(convertMcpToolName).join(", ");
1238
1247
  }
1239
1248
  // Only emit frontmatter if there are fields beyond just the name
1240
1249
  if (Object.keys(sfm).length > 1) {
@@ -1434,7 +1443,7 @@ function generateGateScripts(ast) {
1434
1443
  }
1435
1444
  return files;
1436
1445
  }
1437
- /** Generate gate wrapper scripts that filter Task calls to the correct agent. */
1446
+ /** Generate gate wrapper scripts that run validation when agents complete. */
1438
1447
  function generateGateWrapperScripts(ast) {
1439
1448
  const files = [];
1440
1449
  const name = ast.topology.name;
@@ -1447,12 +1456,22 @@ function generateGateWrapperScripts(ast) {
1447
1456
  if (gate.behavior === "advisory")
1448
1457
  continue;
1449
1458
  const gateScriptName = gate.run.replace(/^.*\//, "").replace(/\s.*$/, "");
1450
- const onFailExit = gate.onFail === "halt" ? 'exit 1' : 'echo "Gate failed — bounce-back requested (advisory)"';
1459
+ const onFailExit = gate.onFail === "halt"
1460
+ ? `echo "Gate '${gate.id}' validation failed. Fix issues before proceeding." >&2\n exit 2`
1461
+ : `echo "Gate '${gate.id}' failed — bounce back and fix the issues." >&2\n exit 2`;
1462
+ const wiredAsHook = isRegisteredSubagentType(ast, gate.after);
1463
+ const enforcementNote = wiredAsHook
1464
+ ? `# Enforcement: SubagentStop hook on agent "${gate.after}". Exit 2 blocks the subagent`
1465
+ : `# Enforcement: NOT wired as a SubagentStop hook (after: ${gate.after || "<unset>"} is not an agent).`;
1466
+ const enforcementNote2 = wiredAsHook
1467
+ ? `# from stopping (Claude Code prevents stop and surfaces stderr to Claude).`
1468
+ : `# Invoke this script from your orchestrator playbook or a /command at the right step.`;
1451
1469
  const contentLines = [
1452
1470
  "#!/usr/bin/env bash",
1453
1471
  `# Gate wrapper: ${gate.id}`,
1454
- `# Enforces gate "${gate.id}" on Task tool calls.`,
1455
1472
  `# Auto-generated by agentopology scaffold — edit as needed.`,
1473
+ enforcementNote,
1474
+ enforcementNote2,
1456
1475
  "set -euo pipefail",
1457
1476
  "",
1458
1477
  "# Gate runs after: " + (gate.after || "any"),
@@ -1508,27 +1527,66 @@ function generateGateWrapperScripts(ast) {
1508
1527
  }
1509
1528
  return files;
1510
1529
  }
1511
- /** Generate hook script stubs for every hook's `run` field. */
1530
+ /**
1531
+ * Generate an observability-aware hook stub (Changes 4+5).
1532
+ *
1533
+ * For SubagentStop/Stop hooks: extract agent_type from the Claude Code stdin
1534
+ * JSON payload and export as AGENT_TYPE, then append a metrics.jsonl entry.
1535
+ * For other events: emit a simple TODO stub.
1536
+ */
1537
+ function observabilityStub(hookName, eventName, matcherHint) {
1538
+ const isFinishEvent = eventName === "SubagentStop" || eventName === "Stop";
1539
+ if (isFinishEvent) {
1540
+ return [
1541
+ "#!/usr/bin/env bash",
1542
+ `# Auto-generated by agentopology scaffold on ${new Date().toISOString().slice(0, 10)}`,
1543
+ `# Hook: ${hookName} · Event: ${eventName} · Matcher: ${matcherHint || "*"}`,
1544
+ "# Replace this stub with your implementation.",
1545
+ "# Stdin JSON payload from Claude Code is available on FD 0.",
1546
+ "set -euo pipefail",
1547
+ "",
1548
+ "PAYLOAD=$(cat)",
1549
+ 'AGENT_TYPE=$(echo "$PAYLOAD" | jq -r \'.agent_type // "unknown"\')',
1550
+ 'SESSION_ID=$(echo "$PAYLOAD" | jq -r \'.session_id // ""\')',
1551
+ "",
1552
+ `echo "{\\\"ts\\\":\\\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\\\",\\\"event\\\":\\\"${eventName}\\\",\\\"agent\\\":\\\"$AGENT_TYPE\\\",\\\"session\\\":\\\"$SESSION_ID\\\",\\\"hook\\\":\\\"${hookName}\\\"}" >> .claude/memory/metrics.jsonl`,
1553
+ "",
1554
+ ].join("\n");
1555
+ }
1556
+ return [
1557
+ "#!/usr/bin/env bash",
1558
+ `# Auto-generated by agentopology scaffold on ${new Date().toISOString().slice(0, 10)}`,
1559
+ `# Hook: ${hookName} · Event: ${eventName} · Matcher: ${matcherHint || "*"}`,
1560
+ "# Replace this stub with your implementation.",
1561
+ "# Stdin JSON payload from Claude Code is available on FD 0.",
1562
+ "set -euo pipefail",
1563
+ "",
1564
+ 'PAYLOAD=$(cat)',
1565
+ 'AGENT_TYPE=$(echo "$PAYLOAD" | jq -r \'.agent_type // "unknown"\')',
1566
+ "",
1567
+ `echo "TODO: implement ${hookName} hook (event: ${eventName}, agent: $AGENT_TYPE)"`,
1568
+ "",
1569
+ ].join("\n");
1570
+ }
1571
+ /** Generate hook script stubs for every hook's `run` field (Changes 1, 4, 5). */
1512
1572
  function generateHookScripts(ast) {
1513
1573
  const files = [];
1514
1574
  const name = ast.topology.name;
1515
1575
  for (const hook of ast.hooks) {
1516
1576
  if (!hook.run)
1517
1577
  continue;
1518
- // Extract script filename from the run command
1519
- const scriptName = hook.run.replace(/^.*\//, "").replace(/\s.*$/, "");
1578
+ const runPath = hook.run;
1579
+ // If the run path starts with '.' or '/' it is already project-relative —
1580
+ // generate the stub at exactly that path (observability pattern).
1581
+ // Otherwise scope the stub into this topology's skill scripts directory.
1582
+ const filePath = runPath.startsWith(".") || runPath.startsWith("/")
1583
+ ? runPath
1584
+ : `.claude/skills/${name}/scripts/${runPath.replace(/^.*\//, "").replace(/\s.*$/, "")}`;
1520
1585
  files.push({
1521
- path: `.claude/skills/${name}/scripts/${scriptName}`,
1522
- content: [
1523
- "#!/usr/bin/env bash",
1524
- `# Hook: ${hook.name} — fires on ${hook.on}`,
1525
- "# Auto-generated by agentopology scaffold — edit as needed.",
1526
- 'set -euo pipefail',
1527
- "",
1528
- `echo "TODO: implement ${hook.name} hook"`,
1529
- "",
1530
- ].join("\n"),
1586
+ path: filePath,
1587
+ content: observabilityStub(hook.name, hook.on, hook.matcher ?? ""),
1531
1588
  category: "script",
1589
+ executable: true,
1532
1590
  });
1533
1591
  }
1534
1592
  return files;
@@ -1580,9 +1638,16 @@ function generateSettings(ast) {
1580
1638
  if (!hooksByEvent[eventName]) {
1581
1639
  hooksByEvent[eventName] = [];
1582
1640
  }
1583
- // Build the script command with proper path prefix
1584
- const scriptName = hook.run.replace(/^.*\//, "").replace(/\s.*$/, "");
1585
- const command = `bash .claude/skills/${topologyName}/scripts/${scriptName}`;
1641
+ // Build the script command.
1642
+ // If hook.run starts with '.' or '/' it is already a project-relative or
1643
+ // absolute path — use it verbatim so observability hooks like
1644
+ // ".claude/scripts/log-subagent.sh" land at the correct location.
1645
+ // Otherwise treat it as a bare filename scoped to this topology's skill
1646
+ // scripts directory.
1647
+ const runPath = hook.run;
1648
+ const command = runPath.startsWith(".") || runPath.startsWith("/")
1649
+ ? `bash ${runPath}`
1650
+ : `bash .claude/skills/${topologyName}/scripts/${runPath.replace(/^.*\//, "").replace(/\s.*$/, "")}`;
1586
1651
  const hookEntry = {
1587
1652
  hooks: [
1588
1653
  {
@@ -1601,26 +1666,53 @@ function generateSettings(ast) {
1601
1666
  // Per-agent hooks are now rendered in AGENT.md frontmatter (not in settings.json).
1602
1667
  // This is the Claude Code native approach: hooks in the agent file are scoped
1603
1668
  // to that agent automatically.
1604
- // Gate enforcement — compile gates with run: to PreToolUse hooks
1669
+ // Gate enforcement — compile gates with run: to SubagentStop hooks.
1670
+ //
1671
+ // SubagentStop fires when a subagent finishes. Its `matcher` field is matched
1672
+ // against `agent_type` (the registered subagent name from .claude/agents/),
1673
+ // NOT against arbitrary node ids. So a SubagentStop hook only fires when its
1674
+ // matcher names a real agent node — human, action, and orchestrator nodes are
1675
+ // NOT registered as subagent_types and their matchers would silently never
1676
+ // fire.
1677
+ //
1678
+ // SubagentStop with exit code 2 (or `decision: "block"`) DOES block: per the
1679
+ // Claude Code hooks reference, exit 2 "prevents subagent from stopping" and
1680
+ // surfaces stderr to Claude. That is the strongest enforcement available for
1681
+ // a gate that runs after an agent completes.
1682
+ //
1683
+ // For gates whose `after` target is not an agent (e.g. a human checkpoint, an
1684
+ // action, or "any"), no SubagentStop hook fires reliably. We skip emitting
1685
+ // dead config and warn the user — the topology's orchestrator (or a slash
1686
+ // command playbook) must invoke the gate script at the right step.
1605
1687
  const gates = ast.nodes.filter((n) => n.type === "gate");
1606
1688
  for (const gate of gates) {
1607
1689
  if (!gate.run)
1608
1690
  continue;
1609
1691
  if (gate.behavior === "advisory")
1610
1692
  continue; // advisory gates stay in markdown only
1611
- const eventName = "PreToolUse";
1693
+ // SubagentStop matcher must be a registered subagent_type. If the gate
1694
+ // doesn't target an agent node, we cannot wire it as a hook — warn and
1695
+ // skip so we don't ship dead config that never fires.
1696
+ if (!isRegisteredSubagentType(ast, gate.after)) {
1697
+ const targetDesc = gate.after ? `non-agent target "${gate.after}"` : "no after: target";
1698
+ console.warn(`[claude-code] Gate "${gate.id}" has ${targetDesc} — SubagentStop hook not emitted ` +
1699
+ `(matcher must be an agent node id). The gate wrapper script is still generated; ` +
1700
+ `invoke it from your orchestrator playbook or a slash command at the right step.`);
1701
+ continue;
1702
+ }
1703
+ const eventName = "SubagentStop";
1612
1704
  if (!hooksByEvent[eventName])
1613
1705
  hooksByEvent[eventName] = [];
1614
1706
  const scriptName = `gate-${gate.id}.sh`;
1615
1707
  const command = `bash .claude/skills/${topologyName}/scripts/${scriptName}`;
1616
1708
  const hookEntry = {
1709
+ matcher: gate.after,
1617
1710
  hooks: [
1618
1711
  {
1619
1712
  type: "command",
1620
1713
  command,
1621
1714
  },
1622
1715
  ],
1623
- matcher: "Task", // Gates fire on Task tool (agent spawning)
1624
1716
  };
1625
1717
  hooksByEvent[eventName].push(hookEntry);
1626
1718
  }
@@ -1633,66 +1725,21 @@ function generateSettings(ast) {
1633
1725
  if (envSettings) {
1634
1726
  settings.env = envSettings;
1635
1727
  }
1636
- // Permissions section from settings block
1728
+ // Permissions section from settings block — nested under "permissions" key
1637
1729
  const perms = ast.settings;
1638
1730
  if (perms) {
1639
1731
  const allow = perms.allow;
1640
1732
  const deny = perms.deny;
1641
- const ask = perms.ask;
1733
+ const permObj = {};
1642
1734
  if (allow && allow.length > 0)
1643
- settings.allow = allow;
1735
+ permObj.allow = allow;
1644
1736
  if (deny && deny.length > 0)
1645
- settings.deny = deny;
1646
- if (ask && ask.length > 0)
1647
- settings.ask = ask;
1648
- }
1649
- // Defaults documentation
1650
- if (ast.defaults) {
1651
- const d = ast.defaults;
1652
- const defaultsDoc = {};
1653
- if (d.temperature != null)
1654
- defaultsDoc.temperature = d.temperature;
1655
- if (d.maxTokens != null)
1656
- defaultsDoc.maxTokens = d.maxTokens;
1657
- if (d.topP != null)
1658
- defaultsDoc.topP = d.topP;
1659
- if (d.topK != null)
1660
- defaultsDoc.topK = d.topK;
1661
- if (d.stop)
1662
- defaultsDoc.stop = d.stop;
1663
- if (d.seed != null)
1664
- defaultsDoc.seed = d.seed;
1665
- if (d.thinking)
1666
- defaultsDoc.thinking = d.thinking;
1667
- if (d.thinkingBudget != null)
1668
- defaultsDoc.thinkingBudget = d.thinkingBudget;
1669
- if (d.outputFormat)
1670
- defaultsDoc.outputFormat = d.outputFormat;
1671
- if (d.timeout)
1672
- defaultsDoc.timeout = d.timeout;
1673
- if (d.logLevel)
1674
- defaultsDoc.logLevel = d.logLevel;
1675
- if (Object.keys(defaultsDoc).length > 0) {
1676
- settings._defaults = defaultsDoc;
1677
- }
1678
- }
1679
- // Observability documentation
1680
- if (ast.observability) {
1681
- const o = ast.observability;
1682
- const obsDoc = {
1683
- enabled: o.enabled,
1684
- level: o.level,
1685
- exporter: o.exporter,
1686
- sampleRate: o.sampleRate,
1687
- };
1688
- if (o.endpoint)
1689
- obsDoc.endpoint = o.endpoint;
1690
- if (o.service)
1691
- obsDoc.service = o.service;
1692
- obsDoc.capture = o.capture;
1693
- obsDoc.spans = o.spans;
1694
- settings._observability = obsDoc;
1737
+ permObj.deny = deny;
1738
+ if (Object.keys(permObj).length > 0)
1739
+ settings.permissions = permObj;
1695
1740
  }
1741
+ // Defaults and observability are documented in the CLAUDE.md context file,
1742
+ // not in settings.json (Claude Code doesn't recognize these keys).
1696
1743
  // Top-level extensions for claude-code
1697
1744
  if (ast.extensions?.["claude-code"]) {
1698
1745
  for (const [k, v] of Object.entries(ast.extensions["claude-code"])) {
@@ -1955,21 +2002,23 @@ function generateCommandFiles(ast) {
1955
2002
  // Frontmatter
1956
2003
  const fm = {};
1957
2004
  if (ast.topology.description) {
1958
- fm.description = `"${ast.topology.description}"`;
2005
+ fm.description = ast.topology.description;
2006
+ }
2007
+ if (trigger.argument) {
2008
+ fm["argument-hint"] = `[${trigger.argument}]`;
1959
2009
  }
1960
2010
  sections.push(frontmatter(fm));
1961
2011
  sections.push("");
1962
- // Header
2012
+ // Header — use $ARGUMENTS for Claude Code command argument substitution
1963
2013
  sections.push(`# /${trigger.name}`);
1964
2014
  sections.push("");
1965
- sections.push(trigger.pattern);
1966
- sections.push("");
1967
- // Arguments
1968
2015
  if (trigger.argument) {
1969
- sections.push("## Arguments");
1970
- sections.push(`- ${trigger.argument}: extracted from the command pattern`);
1971
- sections.push("");
2016
+ sections.push(`/${trigger.name} $ARGUMENTS`);
1972
2017
  }
2018
+ else {
2019
+ sections.push(trigger.pattern);
2020
+ }
2021
+ sections.push("");
1973
2022
  // Pipeline (from edges, enhanced)
1974
2023
  if (ast.edges.length > 0) {
1975
2024
  sections.push("## Pipeline");
@@ -2076,7 +2125,7 @@ function generateWorkspaceProtocol(ast) {
2076
2125
  * destroy their content.
2077
2126
  */
2078
2127
  function generateContextFile(ast) {
2079
- const DEFAULT_PATH = ".claude/CONTEXT.md";
2128
+ const DEFAULT_PATH = ".claude/CLAUDE.md";
2080
2129
  let fileName = ast.context.file ?? DEFAULT_PATH;
2081
2130
  // Safety: never overwrite the user's root-level CLAUDE.md
2082
2131
  if (fileName === "CLAUDE.md" || fileName === "./CLAUDE.md") {