agentopology 0.5.2 → 0.6.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 (115) hide show
  1. package/README.md +5 -5
  2. package/dist/__tests__/e2e/roundtrip.test.d.ts +16 -0
  3. package/dist/__tests__/e2e/roundtrip.test.d.ts.map +1 -0
  4. package/dist/__tests__/e2e/roundtrip.test.js +178 -0
  5. package/dist/__tests__/e2e/roundtrip.test.js.map +1 -0
  6. package/dist/__tests__/e2e/scaffold-owns.test.d.ts +10 -0
  7. package/dist/__tests__/e2e/scaffold-owns.test.d.ts.map +1 -0
  8. package/dist/__tests__/e2e/scaffold-owns.test.js +154 -0
  9. package/dist/__tests__/e2e/scaffold-owns.test.js.map +1 -0
  10. package/dist/__tests__/global-setup.d.ts +7 -0
  11. package/dist/__tests__/global-setup.d.ts.map +1 -0
  12. package/dist/__tests__/global-setup.js +16 -0
  13. package/dist/__tests__/global-setup.js.map +1 -0
  14. package/dist/__tests__/live/claude-code.live.test.d.ts +20 -0
  15. package/dist/__tests__/live/claude-code.live.test.d.ts.map +1 -0
  16. package/dist/__tests__/live/claude-code.live.test.js +136 -0
  17. package/dist/__tests__/live/claude-code.live.test.js.map +1 -0
  18. package/dist/__tests__/live/zz-debug.test.d.ts +2 -0
  19. package/dist/__tests__/live/zz-debug.test.d.ts.map +1 -0
  20. package/dist/__tests__/live/zz-debug.test.js +4 -0
  21. package/dist/__tests__/live/zz-debug.test.js.map +1 -0
  22. package/dist/__tests__/open-findings.test.d.ts +10 -0
  23. package/dist/__tests__/open-findings.test.d.ts.map +1 -0
  24. package/dist/__tests__/open-findings.test.js +37 -0
  25. package/dist/__tests__/open-findings.test.js.map +1 -0
  26. package/dist/__tests__/spec-testing-sync.test.d.ts +10 -0
  27. package/dist/__tests__/spec-testing-sync.test.d.ts.map +1 -0
  28. package/dist/__tests__/spec-testing-sync.test.js +57 -0
  29. package/dist/__tests__/spec-testing-sync.test.js.map +1 -0
  30. package/dist/bindings/__tests__/hook-command.test.d.ts +9 -0
  31. package/dist/bindings/__tests__/hook-command.test.d.ts.map +1 -0
  32. package/dist/bindings/__tests__/hook-command.test.js +88 -0
  33. package/dist/bindings/__tests__/hook-command.test.js.map +1 -0
  34. package/dist/bindings/__tests__/mod-plugin.test.d.ts +9 -0
  35. package/dist/bindings/__tests__/mod-plugin.test.d.ts.map +1 -0
  36. package/dist/bindings/__tests__/mod-plugin.test.js +164 -0
  37. package/dist/bindings/__tests__/mod-plugin.test.js.map +1 -0
  38. package/dist/bindings/claude-code.d.ts +52 -1
  39. package/dist/bindings/claude-code.d.ts.map +1 -1
  40. package/dist/bindings/claude-code.js +306 -68
  41. package/dist/bindings/claude-code.js.map +1 -1
  42. package/dist/bindings/index.d.ts +7 -0
  43. package/dist/bindings/index.d.ts.map +1 -1
  44. package/dist/bindings/index.js +26 -7
  45. package/dist/bindings/index.js.map +1 -1
  46. package/dist/bindings/types.d.ts +6 -0
  47. package/dist/bindings/types.d.ts.map +1 -1
  48. package/dist/bindings/types.js.map +1 -1
  49. package/dist/cli/__tests__/cli.test.d.ts +1 -1
  50. package/dist/cli/__tests__/cli.test.js +1 -1
  51. package/dist/cli/index.js +118 -78
  52. package/dist/cli/index.js.map +1 -1
  53. package/dist/docs/content.d.ts.map +1 -1
  54. package/dist/docs/content.js +39 -3
  55. package/dist/docs/content.js.map +1 -1
  56. package/dist/docs/teach.d.ts.map +1 -1
  57. package/dist/docs/teach.js +36 -0
  58. package/dist/docs/teach.js.map +1 -1
  59. package/dist/import/__tests__/import.test.js +21 -9
  60. package/dist/import/__tests__/import.test.js.map +1 -1
  61. package/dist/import/claude-code.d.ts.map +1 -1
  62. package/dist/import/claude-code.js +128 -44
  63. package/dist/import/claude-code.js.map +1 -1
  64. package/dist/import/serializer.d.ts.map +1 -1
  65. package/dist/import/serializer.js +10 -0
  66. package/dist/import/serializer.js.map +1 -1
  67. package/dist/parser/__tests__/mod-hooks.test.d.ts +10 -0
  68. package/dist/parser/__tests__/mod-hooks.test.d.ts.map +1 -0
  69. package/dist/parser/__tests__/mod-hooks.test.js +136 -0
  70. package/dist/parser/__tests__/mod-hooks.test.js.map +1 -0
  71. package/dist/parser/ast.d.ts +29 -2
  72. package/dist/parser/ast.d.ts.map +1 -1
  73. package/dist/parser/ast.js.map +1 -1
  74. package/dist/parser/index.d.ts +6 -1
  75. package/dist/parser/index.d.ts.map +1 -1
  76. package/dist/parser/index.js +76 -22
  77. package/dist/parser/index.js.map +1 -1
  78. package/dist/parser/paths.d.ts +15 -0
  79. package/dist/parser/paths.d.ts.map +1 -0
  80. package/dist/parser/paths.js +34 -0
  81. package/dist/parser/paths.js.map +1 -0
  82. package/dist/parser/run.d.ts +24 -0
  83. package/dist/parser/run.d.ts.map +1 -0
  84. package/dist/parser/run.js +55 -0
  85. package/dist/parser/run.js.map +1 -0
  86. package/dist/parser/validator.d.ts +12 -0
  87. package/dist/parser/validator.d.ts.map +1 -1
  88. package/dist/parser/validator.js +218 -2
  89. package/dist/parser/validator.js.map +1 -1
  90. package/dist/scaffold/__tests__/safety.test.d.ts +11 -0
  91. package/dist/scaffold/__tests__/safety.test.d.ts.map +1 -0
  92. package/dist/scaffold/__tests__/safety.test.js +184 -0
  93. package/dist/scaffold/__tests__/safety.test.js.map +1 -0
  94. package/dist/scaffold/__tests__/scaffold.test.js +2 -1
  95. package/dist/scaffold/__tests__/scaffold.test.js.map +1 -1
  96. package/dist/scaffold/__tests__/settings-merge.test.js +40 -9
  97. package/dist/scaffold/__tests__/settings-merge.test.js.map +1 -1
  98. package/dist/scaffold/incremental.d.ts +49 -13
  99. package/dist/scaffold/incremental.d.ts.map +1 -1
  100. package/dist/scaffold/incremental.js +235 -95
  101. package/dist/scaffold/incremental.js.map +1 -1
  102. package/dist/scaffold/manifest.d.ts +8 -1
  103. package/dist/scaffold/manifest.d.ts.map +1 -1
  104. package/dist/scaffold/manifest.js +32 -8
  105. package/dist/scaffold/manifest.js.map +1 -1
  106. package/dist/scaffold/merge.d.ts +15 -4
  107. package/dist/scaffold/merge.d.ts.map +1 -1
  108. package/dist/scaffold/merge.js +109 -18
  109. package/dist/scaffold/merge.js.map +1 -1
  110. package/dist/scaffold/types.d.ts +17 -0
  111. package/dist/scaffold/types.d.ts.map +1 -1
  112. package/package.json +1 -1
  113. package/spec/grammar.md +20 -4
  114. package/spec/testing.md +81 -0
  115. package/spec/validation.md +6 -2
@@ -6,7 +6,58 @@
6
6
  *
7
7
  * @module
8
8
  */
9
+ import type { TopologyAST } from "../parser/ast.js";
9
10
  import type { BindingTarget } from "./types.js";
10
- /** Claude Code binding — the reference binding implementation. */
11
+ /**
12
+ * `type: mod` hooks → a Claude Code plugin (function hooks, early access).
13
+ *
14
+ * A Mod is a TypeScript module loaded INSIDE the engine; settings.json cannot
15
+ * carry it. The engine loads ONE module per plugin (hooks.json `modules`,
16
+ * measured on 2.1.283), so the plugin's single entry is a generated
17
+ * `hooks/at-register.ts` that imports each declared Mod's `register(on)` and
18
+ * calls it. A `flag:` is the Mod's OWN switch: the Mod reads it per event with
19
+ * `$.env.get`, and the launch script sets it. The entry cannot gate it: a
20
+ * module has no `process`, and the engine refuses an `on` that is wrapped or
21
+ * kept (measured 2.1.283: "on is always on(\"<event>\", hook)"). The launch script
22
+ * sets CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 (without it the module never
23
+ * loads), each flag, and `--plugin-dir`.
24
+ *
25
+ * The plugin root is the folder that HOLDS the Mods' modules, because the
26
+ * engine refuses a hooks module that imports outside its plugin folder
27
+ * (measured 2.1.283: "cannot import … it is outside the plugin's folder").
28
+ * The entry has its own name so it never overwrites a hand-written
29
+ * `hooks/register.ts`. The Mods' own modules are the user's code: nothing is
30
+ * written at a `run:` path. A topology without Mods gets no plugin.
31
+ */
32
+ export declare const MOD_PLUGIN_ENV = "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS";
33
+ export declare const MOD_ENTRY = "at-register.ts";
34
+ /**
35
+ * The plugin root: the deepest folder shared by every Mod's `run:` path, less
36
+ * a trailing `hooks` (the plugin's own hooks/ folder). "" is the project root.
37
+ * null when a `run:` is absolute: it cannot be placed inside the project.
38
+ */
39
+ export declare function modPluginDir(ast: TopologyAST): string | null;
40
+ /**
41
+ * The shell command for a command hook's `run:`.
42
+ *
43
+ * A `run:` whose script (its first word, or its second when the first names
44
+ * the interpreter, e.g. `/opt/homebrew/bin/python3 ./x/y.py`) starts with "."
45
+ * or "/" names the user's own script. A project-relative one runs through
46
+ * `$CLAUDE_PROJECT_DIR` (a hook's cwd is the session's, which moves); an
47
+ * absolute one is kept as written. It runs by the interpreter the `run:`
48
+ * names, else the one its extension names (`.py` → python3, anything else →
49
+ * bash), with its arguments kept.
50
+ *
51
+ * Any other `run:` is a script this topology's skill scaffolds, by basename.
52
+ */
53
+ export declare function hookCommand(run: string, topologyName: string): string;
54
+ /**
55
+ * The settings.json `hooks` entries of the topology's own `hooks {}` block
56
+ * (command hooks only; a Mod is a plugin, see generateModPlugin), grouped by
57
+ * event. Gate wiring is added by generateSettings, not here: a caller that
58
+ * wants the reflexes alone (e.g. a drift check against a live settings file)
59
+ * reads this.
60
+ */
61
+ export declare function settingsHooksOf(ast: TopologyAST): Record<string, unknown[]>;
11
62
  export declare const claudeCodeBinding: BindingTarget;
12
63
  //# sourceMappingURL=claude-code.d.ts.map
@@ -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;AAo5F/D,kEAAkE;AAClE,eAAO,MAAM,iBAAiB,EAAE,aAkG/B,CAAC"}
1
+ {"version":3,"file":"claude-code.d.ts","sourceRoot":"","sources":["../../src/bindings/claude-code.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EACV,WAAW,EAkBZ,MAAM,kBAAkB,CAAC;AAE1B,OAAO,KAAK,EAAE,aAAa,EAAiB,MAAM,YAAY,CAAC;AA01D/D;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,cAAc,sCAAsC,CAAC;AAClE,eAAO,MAAM,SAAS,mBAAmB,CAAC;AAE1C;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,WAAW,GAAG,MAAM,GAAG,IAAI,CAQ5D;AA8KD;;;;;;;;;;;;GAYG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,MAAM,CAUrE;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,WAAW,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,EAAE,CAAC,CA6C3E;AAqgCD,eAAO,MAAM,iBAAiB,EAAE,aAqG/B,CAAC"}
@@ -11,6 +11,7 @@ import { shellStub, STUB_MARKER } from "./lib/stub.js";
11
11
  import { isWorkflowSeamAgent, seamFiles } from "./lib/seam.js";
12
12
  import { phaseOf as sharedPhaseOf } from "../resolve/phase.js";
13
13
  import { gateAnchors } from "../parser/ast.js";
14
+ import { proseInRun, scriptNameFromRun } from "../parser/run.js";
14
15
  // ---------------------------------------------------------------------------
15
16
  // Helpers
16
17
  // ---------------------------------------------------------------------------
@@ -449,6 +450,8 @@ function generateAgents(ast) {
449
450
  const topologyName = ast.topology.name;
450
451
  const hooksByEvent = {};
451
452
  for (const hook of agent.hooks) {
453
+ if (hook.type === "mod")
454
+ continue; // engine-wide only (V94)
452
455
  if (!hooksByEvent[hook.on])
453
456
  hooksByEvent[hook.on] = [];
454
457
  hooksByEvent[hook.on].push(hook);
@@ -457,7 +460,9 @@ function generateAgents(ast) {
457
460
  for (const [eventName, hooks] of Object.entries(hooksByEvent)) {
458
461
  hookLines.push(` ${eventName}:`);
459
462
  for (const hook of hooks) {
460
- const scriptName = hook.run.replace(/^.*\//, "").replace(/\s.*$/, "");
463
+ const scriptName = scriptNameFromRun(hook.run);
464
+ if (!scriptName)
465
+ continue; // V96: prose in run names no script
461
466
  const command = `bash .claude/skills/${topologyName}/scripts/${agent.id}-${scriptName}`;
462
467
  if (hook.matcher) {
463
468
  hookLines.push(` - matcher: "${hook.matcher}"`);
@@ -1641,9 +1646,8 @@ function generateGateScripts(ast) {
1641
1646
  if (node.type !== "gate")
1642
1647
  continue;
1643
1648
  const gate = node;
1644
- if (gate.run) {
1645
- // Extract script name from the run command
1646
- const scriptName = gate.run.replace(/^.*\//, "").replace(/\s.*$/, "");
1649
+ const scriptName = scriptNameFromRun(gate.run);
1650
+ if (scriptName) {
1647
1651
  files.push({
1648
1652
  path: `.claude/skills/${name}/scripts/${scriptName}`,
1649
1653
  content: shellStub(`Gate: ${gate.id} — ${gate.run}`),
@@ -1665,7 +1669,9 @@ function generateGateWrapperScripts(ast) {
1665
1669
  continue;
1666
1670
  if (gate.behavior === "advisory")
1667
1671
  continue;
1668
- const gateScriptName = gate.run.replace(/^.*\//, "").replace(/\s.*$/, "");
1672
+ const gateScriptName = scriptNameFromRun(gate.run);
1673
+ if (!gateScriptName)
1674
+ continue; // V96: prose in run names no script
1669
1675
  const onFailExit = gate.onFail === "halt"
1670
1676
  ? `echo "Gate '${gate.id}' validation failed. Fix issues before proceeding." >&2\n exit 2`
1671
1677
  : `echo "Gate '${gate.id}' failed — bounce back and fix the issues." >&2\n exit 2`;
@@ -1790,6 +1796,123 @@ function observabilityStub(hookName, eventName, matcherHint) {
1790
1796
  "",
1791
1797
  ].join("\n");
1792
1798
  }
1799
+ /**
1800
+ * `type: mod` hooks → a Claude Code plugin (function hooks, early access).
1801
+ *
1802
+ * A Mod is a TypeScript module loaded INSIDE the engine; settings.json cannot
1803
+ * carry it. The engine loads ONE module per plugin (hooks.json `modules`,
1804
+ * measured on 2.1.283), so the plugin's single entry is a generated
1805
+ * `hooks/at-register.ts` that imports each declared Mod's `register(on)` and
1806
+ * calls it. A `flag:` is the Mod's OWN switch: the Mod reads it per event with
1807
+ * `$.env.get`, and the launch script sets it. The entry cannot gate it: a
1808
+ * module has no `process`, and the engine refuses an `on` that is wrapped or
1809
+ * kept (measured 2.1.283: "on is always on(\"<event>\", hook)"). The launch script
1810
+ * sets CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 (without it the module never
1811
+ * loads), each flag, and `--plugin-dir`.
1812
+ *
1813
+ * The plugin root is the folder that HOLDS the Mods' modules, because the
1814
+ * engine refuses a hooks module that imports outside its plugin folder
1815
+ * (measured 2.1.283: "cannot import … it is outside the plugin's folder").
1816
+ * The entry has its own name so it never overwrites a hand-written
1817
+ * `hooks/register.ts`. The Mods' own modules are the user's code: nothing is
1818
+ * written at a `run:` path. A topology without Mods gets no plugin.
1819
+ */
1820
+ export const MOD_PLUGIN_ENV = "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS";
1821
+ export const MOD_ENTRY = "at-register.ts";
1822
+ /**
1823
+ * The plugin root: the deepest folder shared by every Mod's `run:` path, less
1824
+ * a trailing `hooks` (the plugin's own hooks/ folder). "" is the project root.
1825
+ * null when a `run:` is absolute: it cannot be placed inside the project.
1826
+ */
1827
+ export function modPluginDir(ast) {
1828
+ const runs = ast.hooks.filter((h) => h.type === "mod" && h.run).map((h) => h.run);
1829
+ if (runs.length === 0 || runs.some((r) => r.startsWith("/")))
1830
+ return null;
1831
+ const dirs = runs.map((r) => r.split("/").filter((p) => p && p !== ".").slice(0, -1));
1832
+ const shared = [];
1833
+ for (let i = 0; dirs.every((d) => i < d.length && d[i] === dirs[0][i]); i++)
1834
+ shared.push(dirs[0][i]);
1835
+ if (shared[shared.length - 1] === "hooks")
1836
+ shared.pop();
1837
+ return shared.join("/");
1838
+ }
1839
+ function generateModPlugin(ast) {
1840
+ const mods = ast.hooks.filter((h) => h.type === "mod" && h.run);
1841
+ if (mods.length === 0)
1842
+ return [];
1843
+ const name = ast.topology.name;
1844
+ const dir = modPluginDir(ast);
1845
+ if (dir === null) {
1846
+ console.warn(`[claude-code] Mods ${mods.map((m) => m.name).join(", ")}: a run: path is absolute, so no plugin folder ` +
1847
+ `can hold the modules (the engine refuses imports outside it). Use project-relative run: paths. Skipped.`);
1848
+ return [];
1849
+ }
1850
+ const at = (p) => (dir ? `${dir}/${p}` : p);
1851
+ const hooksDir = at("hooks");
1852
+ // `run:` is project-relative (or absolute); the import is relative to hooks/.
1853
+ // Bindings stay free of node: imports, so the relative path is computed here.
1854
+ const importPath = (run) => {
1855
+ const target = run.replace(/\.(ts|js|mjs)$/, "");
1856
+ if (target.startsWith("/"))
1857
+ return target;
1858
+ const to = target.split("/").filter((p) => p && p !== ".");
1859
+ const from = hooksDir.split("/");
1860
+ let common = 0;
1861
+ while (common < from.length && common < to.length - 1 && from[common] === to[common])
1862
+ common++;
1863
+ const rel = [...from.slice(common).map(() => ".."), ...to.slice(common)].join("/");
1864
+ return rel.startsWith(".") ? rel : `./${rel}`;
1865
+ };
1866
+ const ident = (hookName) => "mod_" + hookName.replace(/[^A-Za-z0-9_]/g, "_");
1867
+ const summary = mods
1868
+ .map((m) => `${m.name} (${m.on}${m.flag ? `, ${m.flag}` : ""})`)
1869
+ .join("; ");
1870
+ const pluginJson = {
1871
+ name: `${name}-mods`,
1872
+ version: ast.topology.version || "0.0.0",
1873
+ description: `Mods of the ${name} topology, generated by agentopology: ${summary}.`,
1874
+ };
1875
+ const hooksJson = {
1876
+ description: `The engine loads ONE module per plugin: ${MOD_ENTRY} registers every Mod ` +
1877
+ `declared in ${name}.at. Off unless ${MOD_PLUGIN_ENV}=1.`,
1878
+ modules: [`./${MOD_ENTRY}`],
1879
+ };
1880
+ const register = [
1881
+ "// Generated by agentopology scaffold from the topology's `type: mod` hooks.",
1882
+ "// The engine loads ONE module per plugin: this one registers every Mod.",
1883
+ "// Edit the .at file, not this file.",
1884
+ "import type { On } from 'claude-code'",
1885
+ "",
1886
+ ...mods.map((m) => `import { register as ${ident(m.name)} } from '${importPath(m.run)}'`),
1887
+ "",
1888
+ "export function register(on: On) {",
1889
+ ...mods.map((m) => ` // ${m.name} · on ${m.on}${m.flag ? ` · the Mod reads its own switch, ${m.flag}` : ""}\n ${ident(m.name)}(on)`),
1890
+ "}",
1891
+ "",
1892
+ ].join("\n");
1893
+ const flags = [...new Set(mods.map((m) => m.flag).filter((f) => !!f))];
1894
+ const launch = [
1895
+ "#!/usr/bin/env bash",
1896
+ `# Launch Claude Code with the Mods of ${name} (generated by agentopology).`,
1897
+ "# Mods run inside the engine (function hooks, early access).",
1898
+ "# Each flag defaults to on here; set it to 0 before launching to turn a Mod off.",
1899
+ "set -euo pipefail",
1900
+ 'PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"',
1901
+ `export ${MOD_PLUGIN_ENV}=1`,
1902
+ ...flags.map((f) => {
1903
+ const users = mods.filter((m) => m.flag === f).map((m) => `${m.name} · ${m.on}`).join(", ");
1904
+ return `export ${f}="\${${f}:-1}" # ${users}`;
1905
+ }),
1906
+ 'exec claude --plugin-dir "$PLUGIN_DIR" "$@"',
1907
+ "",
1908
+ ].join("\n");
1909
+ return [
1910
+ { path: at(".claude-plugin/plugin.json"), content: JSON.stringify(pluginJson, null, 2) + "\n" },
1911
+ { path: `${hooksDir}/hooks.json`, content: JSON.stringify(hooksJson, null, 2) + "\n" },
1912
+ { path: `${hooksDir}/${MOD_ENTRY}`, content: register },
1913
+ { path: at("launch-mods.sh"), content: launch, executable: true },
1914
+ ];
1915
+ }
1793
1916
  /** Generate hook script stubs for every hook's `run` field (Changes 1, 4, 5). */
1794
1917
  function generateHookScripts(ast) {
1795
1918
  const files = [];
@@ -1797,18 +1920,36 @@ function generateHookScripts(ast) {
1797
1920
  for (const hook of ast.hooks) {
1798
1921
  if (!hook.run)
1799
1922
  continue;
1923
+ // A Mod's run: is the user's TypeScript module — never overwrite it with a
1924
+ // bash stub. generateModPlugin imports it instead.
1925
+ if (hook.type === "mod")
1926
+ continue;
1800
1927
  const runPath = hook.run;
1801
1928
  // If the run path starts with '.' or '/' it is already project-relative —
1802
1929
  // generate the stub at exactly that path (observability pattern).
1803
1930
  // Otherwise scope the stub into this topology's skill scripts directory.
1804
- const filePath = runPath.startsWith(".") || runPath.startsWith("/")
1805
- ? runPath
1806
- : `.claude/skills/${name}/scripts/${runPath.replace(/^.*\//, "").replace(/\s.*$/, "")}`;
1931
+ if (proseInRun(runPath))
1932
+ continue; // V96: prose in run names no script
1933
+ const { script } = hookRun(runPath);
1934
+ // An absolute script is the user's own, outside the project: never stubbed.
1935
+ if (script.startsWith("/"))
1936
+ continue;
1937
+ const scoped = scriptNameFromRun(script);
1938
+ const filePath = isHookPath(script)
1939
+ ? script
1940
+ : scoped
1941
+ ? `.claude/skills/${name}/scripts/${scoped}`
1942
+ : null;
1943
+ if (!filePath)
1944
+ continue;
1807
1945
  files.push({
1808
1946
  path: filePath,
1809
1947
  content: observabilityStub(hook.name, hook.on, hook.matcher ?? ""),
1810
1948
  category: "script",
1811
1949
  executable: true,
1950
+ // A run that names its own path names the user's script: a stub only
1951
+ // fills the gap until it exists. --force used to overwrite it.
1952
+ ...(isHookPath(script) ? { createOnly: true } : {}),
1812
1953
  });
1813
1954
  }
1814
1955
  return files;
@@ -1824,9 +1965,11 @@ function generateAgentHookScripts(ast) {
1824
1965
  if (!agent.hooks || agent.hooks.length === 0)
1825
1966
  continue;
1826
1967
  for (const hook of agent.hooks) {
1827
- if (!hook.run)
1968
+ if (!hook.run || hook.type === "mod")
1828
1969
  continue;
1829
- const scriptName = hook.run.replace(/^.*\//, "").replace(/\s.*$/, "");
1970
+ const scriptName = scriptNameFromRun(hook.run);
1971
+ if (!scriptName)
1972
+ continue; // V96: prose in run names no script
1830
1973
  files.push({
1831
1974
  path: `.claude/skills/${name}/scripts/${agent.id}-${scriptName}`,
1832
1975
  content: [
@@ -1844,19 +1987,54 @@ function generateAgentHookScripts(ast) {
1844
1987
  }
1845
1988
  return files;
1846
1989
  }
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
+ }
1847
1998
  /**
1848
- * Generate hook configuration in .claude/settings.json.
1999
+ * The shell command for a command hook's `run:`.
1849
2000
  *
1850
- * Hooks are grouped by event name as an object:
1851
- * { "hooks": { "PostToolUse": [ { hooks: [...], matcher? } ], ... } }
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.
2008
+ *
2009
+ * Any other `run:` is a script this topology's skill scaffolds, by basename.
1852
2010
  */
1853
- function generateSettings(ast) {
1854
- const settings = {};
2011
+ 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(/^.*\//, "")}`;
2015
+ }
2016
+ const interpreter = named ?? (first.endsWith(".py") ? "python3" : "bash");
2017
+ const where = first.startsWith("/")
2018
+ ? first
2019
+ : `"$CLAUDE_PROJECT_DIR/${first.replace(/^\.\//, "")}"`;
2020
+ return [interpreter, where, ...rest].join(" ");
2021
+ }
2022
+ /**
2023
+ * The settings.json `hooks` entries of the topology's own `hooks {}` block
2024
+ * (command hooks only; a Mod is a plugin, see generateModPlugin), grouped by
2025
+ * event. Gate wiring is added by generateSettings, not here: a caller that
2026
+ * wants the reflexes alone (e.g. a drift check against a live settings file)
2027
+ * reads this.
2028
+ */
2029
+ export function settingsHooksOf(ast) {
1855
2030
  const topologyName = ast.topology.name;
1856
2031
  const isInline = getDelegationMode(ast) === "inline";
1857
- // Hooks section — grouped by event name
1858
2032
  const hooksByEvent = {};
1859
2033
  for (const hook of ast.hooks) {
2034
+ // A Mod is a module inside the engine, not a settings.json command: it is
2035
+ // emitted as a plugin (generateModPlugin).
2036
+ if (hook.type === "mod")
2037
+ continue;
1860
2038
  const eventName = hook.on;
1861
2039
  // Inline orchestrator mode never spawns subagents, so SubagentStop /
1862
2040
  // SubagentStart hooks would never fire. Warn the user and skip emission
@@ -1868,24 +2046,16 @@ function generateSettings(ast) {
1868
2046
  `delegation to "subagent".`);
1869
2047
  continue;
1870
2048
  }
2049
+ if (proseInRun(hook.run))
2050
+ continue; // V96: prose in run names no script
1871
2051
  if (!hooksByEvent[eventName]) {
1872
2052
  hooksByEvent[eventName] = [];
1873
2053
  }
1874
- // Build the script command.
1875
- // If hook.run starts with '.' or '/' it is already a project-relative or
1876
- // absolute path — use it verbatim so observability hooks like
1877
- // ".claude/scripts/log-subagent.sh" land at the correct location.
1878
- // Otherwise treat it as a bare filename scoped to this topology's skill
1879
- // scripts directory.
1880
- const runPath = hook.run;
1881
- const command = runPath.startsWith(".") || runPath.startsWith("/")
1882
- ? `bash ${runPath}`
1883
- : `bash .claude/skills/${topologyName}/scripts/${runPath.replace(/^.*\//, "").replace(/\s.*$/, "")}`;
1884
2054
  const hookEntry = {
1885
2055
  hooks: [
1886
2056
  {
1887
2057
  type: hook.type ?? "command",
1888
- command,
2058
+ command: hookCommand(hook.run, topologyName),
1889
2059
  ...(hook.timeout ? { timeout: hook.timeout } : {}),
1890
2060
  },
1891
2061
  ],
@@ -1896,6 +2066,19 @@ function generateSettings(ast) {
1896
2066
  }
1897
2067
  hooksByEvent[eventName].push(hookEntry);
1898
2068
  }
2069
+ return hooksByEvent;
2070
+ }
2071
+ /**
2072
+ * Generate hook configuration in .claude/settings.json.
2073
+ *
2074
+ * Hooks are grouped by event name as an object:
2075
+ * { "hooks": { "PostToolUse": [ { hooks: [...], matcher? } ], ... } }
2076
+ */
2077
+ function generateSettings(ast) {
2078
+ const settings = {};
2079
+ const topologyName = ast.topology.name;
2080
+ // Hooks section — grouped by event name
2081
+ const hooksByEvent = settingsHooksOf(ast);
1899
2082
  // Per-agent hooks are now rendered in AGENT.md frontmatter (not in settings.json).
1900
2083
  // This is the Claude Code native approach: hooks in the agent file are scoped
1901
2084
  // to that agent automatically.
@@ -1927,8 +2110,8 @@ function generateSettings(ast) {
1927
2110
  const delegationMode = getDelegationMode(ast);
1928
2111
  const gates = ast.nodes.filter((n) => n.type === "gate");
1929
2112
  for (const gate of gates) {
1930
- if (!gate.run)
1931
- continue;
2113
+ if (!scriptNameFromRun(gate.run))
2114
+ continue; // no run, or V96 prose
1932
2115
  if (gate.behavior === "advisory")
1933
2116
  continue; // advisory gates stay in markdown only
1934
2117
  if (delegationMode === "inline") {
@@ -2738,49 +2921,28 @@ function generateObserveBlackboardScript(ast) {
2738
2921
  /**
2739
2922
  * Merge `ast.env` into the settings.json output under an `env` key.
2740
2923
  *
2741
- * When `ast.providers` includes an "anthropic" provider with an api-key,
2742
- * that env var is included automatically (enabling API-key mode instead
2743
- * of subscription mode).
2924
+ * Claude Code applies settings `env` values exactly as written — no shell
2925
+ * expands them — and they override the variable exported in the shell. So a
2926
+ * reference such as `${ANTHROPIC_API_KEY}` must never be emitted: it replaced
2927
+ * the real key with that literal text, and every session in the project failed
2928
+ * with "Invalid API key" (6 of the 10 examples did this). Only literal values
2929
+ * are written; references, secrets and encrypted values stay with the shell.
2930
+ * The anthropic provider's api-key is the same kind of reference and is not
2931
+ * emitted either.
2744
2932
  *
2745
2933
  * Returns the env record if non-empty, otherwise null.
2746
2934
  */
2747
2935
  function generateEnvToSettings(ast) {
2748
2936
  const env = {};
2749
2937
  for (const [key, val] of Object.entries(ast.env)) {
2750
- if (typeof val === "string") {
2751
- env[key] = val;
2752
- }
2753
- else {
2754
- const sv = val;
2755
- if (sv.secretRef) {
2756
- // Secret reference — emit placeholder with comment-style URI
2757
- env[key] = `\${${key}}`; // placeholder
2758
- env[`# ${key}_SECRET`] = sv.secretRef.uri;
2759
- }
2760
- else if (sv.sensitive) {
2761
- // Sensitive value — never emit the actual value
2762
- env[key] = `\${${key}}`;
2763
- }
2764
- else if (sv.encrypted) {
2765
- // Encrypted value — emit placeholder with warning
2766
- env[key] = `\${${key}}`;
2767
- env[`# ${key}_ENCRYPTED`] = "WARNING: value is SOPS-encrypted, decrypt before use";
2768
- }
2769
- else {
2770
- env[key] = sv.value;
2771
- }
2772
- }
2773
- }
2774
- // If an anthropic provider exists, scaffold the API key env var
2775
- if (ast.providers && ast.providers.length > 0) {
2776
- const anthropicProvider = ast.providers.find((p) => p.name === "anthropic");
2777
- if (anthropicProvider?.apiKey) {
2778
- // Extract env var name from ${ENV_VAR} format
2779
- const envVarMatch = anthropicProvider.apiKey.match(/^\$\{(.+)\}$/);
2780
- if (envVarMatch) {
2781
- env[envVarMatch[1]] = anthropicProvider.apiKey;
2782
- }
2783
- }
2938
+ const literal = typeof val === "string"
2939
+ ? val
2940
+ : val.secretRef || val.sensitive || val.encrypted
2941
+ ? undefined
2942
+ : val.value;
2943
+ if (literal === undefined || literal.includes("${"))
2944
+ continue;
2945
+ env[key] = literal;
2784
2946
  }
2785
2947
  if (Object.keys(env).length === 0)
2786
2948
  return null;
@@ -2804,6 +2966,80 @@ function generateTriggers(ast) {
2804
2966
  // Binding export
2805
2967
  // ---------------------------------------------------------------------------
2806
2968
  /** Claude Code binding — the reference binding implementation. */
2969
+ /**
2970
+ * Drop every generated file that belongs to a hand-written node (`file:`).
2971
+ *
2972
+ * An authored node keeps its file (a group also its skill and runtime
2973
+ * folder); an authored skill keeps its whole folder
2974
+ * (SKILL.md, scripts, references); an authored command keeps its file. The
2975
+ * topology still describes them — gates still wire to an authored agent by
2976
+ * name — but the binding never writes inside them. Without this, importing a
2977
+ * hand-built .claude and scaffolding it back replaced 37 of 72 files and cut
2978
+ * the rest down (a 404-line SKILL.md came back as 9 lines).
2979
+ */
2980
+ function withoutAuthored(ast, files) {
2981
+ const exact = new Set();
2982
+ const folders = [];
2983
+ for (const node of ast.nodes) {
2984
+ if (!node.file)
2985
+ continue;
2986
+ // Everything the binding would write for this node: its agent file, and
2987
+ // for a group its skill and its runtime folder.
2988
+ exact.add(`.claude/agents/${node.id}/AGENT.md`);
2989
+ exact.add(`.claude/agents/${node.id}.md`);
2990
+ exact.add(normalizeRel(node.file));
2991
+ if (node.type === "group") {
2992
+ folders.push(`.claude/skills/${node.id}/`, `.claude/groups/${node.id}/`);
2993
+ }
2994
+ }
2995
+ for (const skill of ast.skills ?? []) {
2996
+ if (!skill.file)
2997
+ continue;
2998
+ folders.push(`.claude/skills/${skill.id}/`);
2999
+ exact.add(normalizeRel(skill.file));
3000
+ }
3001
+ for (const trigger of ast.triggers ?? []) {
3002
+ if (!trigger.file)
3003
+ continue;
3004
+ exact.add(`.claude/commands/${trigger.name}.md`);
3005
+ exact.add(normalizeRel(trigger.file));
3006
+ }
3007
+ if (exact.size === 0 && folders.length === 0)
3008
+ return files;
3009
+ const isAuthored = (path) => {
3010
+ const p = normalizeRel(path);
3011
+ return exact.has(p) || folders.some((dir) => p.startsWith(dir));
3012
+ };
3013
+ const dropped = files.filter((f) => isAuthored(f.path)).map((f) => normalizeRel(f.path));
3014
+ return files
3015
+ .filter((f) => !isAuthored(f.path))
3016
+ .map((f) => (/(^|\/)\.claude\/settings\.json$/.test(f.path) ? withoutHooksRunning(f, dropped) : f));
3017
+ }
3018
+ /**
3019
+ * settings.json without the hook groups that run a script this scaffold no
3020
+ * longer writes. A gate whose wrapper sits in an authored skill folder would
3021
+ * otherwise be wired to a file that does not exist, and fire on every stop.
3022
+ */
3023
+ function withoutHooksRunning(file, dropped) {
3024
+ if (dropped.length === 0)
3025
+ return file;
3026
+ const settings = JSON.parse(file.content);
3027
+ if (!settings.hooks)
3028
+ return file;
3029
+ for (const [event, groups] of Object.entries(settings.hooks)) {
3030
+ const kept = groups.filter((g) => !(g.hooks ?? []).some((h) => dropped.some((p) => (h.command ?? "").includes(p))));
3031
+ if (kept.length > 0)
3032
+ settings.hooks[event] = kept;
3033
+ else
3034
+ delete settings.hooks[event];
3035
+ }
3036
+ if (Object.keys(settings.hooks).length === 0)
3037
+ delete settings.hooks;
3038
+ return { ...file, content: JSON.stringify(settings, null, 2) + "\n" };
3039
+ }
3040
+ function normalizeRel(p) {
3041
+ return p.replace(/^\.\//, "");
3042
+ }
2807
3043
  export const claudeCodeBinding = {
2808
3044
  name: "claude-code",
2809
3045
  description: "Anthropic Claude Code CLI — generates .claude/ directory structure, .mcp.json, and scripts.",
@@ -2815,7 +3051,7 @@ export const claudeCodeBinding = {
2815
3051
  files.push(...generateTopologySkill(ast));
2816
3052
  files.push(...generateAgents(ast));
2817
3053
  files.push(...generateSchemas(ast));
2818
- return deduplicateFiles(files);
3054
+ return withoutAuthored(ast, deduplicateFiles(files));
2819
3055
  }
2820
3056
  const files = [];
2821
3057
  // Action nodes
@@ -2848,6 +3084,8 @@ export const claudeCodeBinding = {
2848
3084
  files.push(...generateHookScripts(ast));
2849
3085
  // Hook scripts (per-agent)
2850
3086
  files.push(...generateAgentHookScripts(ast));
3087
+ // Mods (type: mod) → a plugin directory; never settings.json
3088
+ files.push(...generateModPlugin(ast));
2851
3089
  // HYBRID: host-side launch directives for the embedded workflow rung(s).
2852
3090
  files.push(...generateWorkflowLaunchArtifacts(ast));
2853
3091
  // HYBRID: host-layer concurrent Blackboard observer script (paired with the
@@ -2879,7 +3117,7 @@ export const claudeCodeBinding = {
2879
3117
  files.push(meteringFile);
2880
3118
  // Context file (.claude/CONTEXT.md or custom — never root CLAUDE.md)
2881
3119
  files.push(generateContextFile(ast));
2882
- return deduplicateFiles(files);
3120
+ return withoutAuthored(ast, deduplicateFiles(files));
2883
3121
  },
2884
3122
  };
2885
3123
  //# sourceMappingURL=claude-code.js.map