agentopology 0.2.1 → 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 (73) 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 +277 -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 +113 -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 +9 -5
  52. package/dist/docs/content.js.map +1 -1
  53. package/dist/parser/__tests__/parser.test.js +31 -3
  54. package/dist/parser/__tests__/parser.test.js.map +1 -1
  55. package/dist/parser/validator.d.ts.map +1 -1
  56. package/dist/parser/validator.js +28 -8
  57. package/dist/parser/validator.js.map +1 -1
  58. package/dist/scaffold/__tests__/scaffold.test.js +20 -0
  59. package/dist/scaffold/__tests__/scaffold.test.js.map +1 -1
  60. package/dist/scaffold/__tests__/settings-merge.test.d.ts +15 -0
  61. package/dist/scaffold/__tests__/settings-merge.test.d.ts.map +1 -0
  62. package/dist/scaffold/__tests__/settings-merge.test.js +127 -0
  63. package/dist/scaffold/__tests__/settings-merge.test.js.map +1 -0
  64. package/dist/scaffold/incremental.d.ts.map +1 -1
  65. package/dist/scaffold/incremental.js +11 -2
  66. package/dist/scaffold/incremental.js.map +1 -1
  67. package/dist/scaffold/merge.d.ts +16 -0
  68. package/dist/scaffold/merge.d.ts.map +1 -1
  69. package/dist/scaffold/merge.js +77 -0
  70. package/dist/scaffold/merge.js.map +1 -1
  71. package/dist/scaffold/types.d.ts +2 -0
  72. package/dist/scaffold/types.d.ts.map +1 -1
  73. package/package.json +1 -1
@@ -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;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
  // ---------------------------------------------------------------------------
@@ -76,17 +77,18 @@ 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);
90
92
  }
91
93
  /** Generate an empty directory marker (a .gitkeep file). */
92
94
  function gitkeep(dirPath) {
@@ -1457,11 +1459,19 @@ function generateGateWrapperScripts(ast) {
1457
1459
  const onFailExit = gate.onFail === "halt"
1458
1460
  ? `echo "Gate '${gate.id}' validation failed. Fix issues before proceeding." >&2\n exit 2`
1459
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.`;
1460
1469
  const contentLines = [
1461
1470
  "#!/usr/bin/env bash",
1462
1471
  `# Gate wrapper: ${gate.id}`,
1463
- `# Enforces gate "${gate.id}" on SubagentStop (after: ${gate.after || "any"}).`,
1464
1472
  `# Auto-generated by agentopology scaffold — edit as needed.`,
1473
+ enforcementNote,
1474
+ enforcementNote2,
1465
1475
  "set -euo pipefail",
1466
1476
  "",
1467
1477
  "# Gate runs after: " + (gate.after || "any"),
@@ -1517,27 +1527,66 @@ function generateGateWrapperScripts(ast) {
1517
1527
  }
1518
1528
  return files;
1519
1529
  }
1520
- /** 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). */
1521
1572
  function generateHookScripts(ast) {
1522
1573
  const files = [];
1523
1574
  const name = ast.topology.name;
1524
1575
  for (const hook of ast.hooks) {
1525
1576
  if (!hook.run)
1526
1577
  continue;
1527
- // Extract script filename from the run command
1528
- 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.*$/, "")}`;
1529
1585
  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"),
1586
+ path: filePath,
1587
+ content: observabilityStub(hook.name, hook.on, hook.matcher ?? ""),
1540
1588
  category: "script",
1589
+ executable: true,
1541
1590
  });
1542
1591
  }
1543
1592
  return files;
@@ -1589,9 +1638,16 @@ function generateSettings(ast) {
1589
1638
  if (!hooksByEvent[eventName]) {
1590
1639
  hooksByEvent[eventName] = [];
1591
1640
  }
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}`;
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.*$/, "")}`;
1595
1651
  const hookEntry = {
1596
1652
  hooks: [
1597
1653
  {
@@ -1610,22 +1666,47 @@ function generateSettings(ast) {
1610
1666
  // Per-agent hooks are now rendered in AGENT.md frontmatter (not in settings.json).
1611
1667
  // This is the Claude Code native approach: hooks in the agent file are scoped
1612
1668
  // 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.
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.
1617
1687
  const gates = ast.nodes.filter((n) => n.type === "gate");
1618
1688
  for (const gate of gates) {
1619
1689
  if (!gate.run)
1620
1690
  continue;
1621
1691
  if (gate.behavior === "advisory")
1622
1692
  continue; // advisory gates stay in markdown only
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
+ }
1623
1703
  const eventName = "SubagentStop";
1624
1704
  if (!hooksByEvent[eventName])
1625
1705
  hooksByEvent[eventName] = [];
1626
1706
  const scriptName = `gate-${gate.id}.sh`;
1627
1707
  const command = `bash .claude/skills/${topologyName}/scripts/${scriptName}`;
1628
1708
  const hookEntry = {
1709
+ matcher: gate.after,
1629
1710
  hooks: [
1630
1711
  {
1631
1712
  type: "command",
@@ -1633,11 +1714,6 @@ function generateSettings(ast) {
1633
1714
  },
1634
1715
  ],
1635
1716
  };
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
1717
  hooksByEvent[eventName].push(hookEntry);
1642
1718
  }
1643
1719
  // Write hooksByEvent to settings if any hooks or gates were registered