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.
- package/README.md +5 -5
- package/dist/__tests__/e2e/roundtrip.test.d.ts +16 -0
- package/dist/__tests__/e2e/roundtrip.test.d.ts.map +1 -0
- package/dist/__tests__/e2e/roundtrip.test.js +178 -0
- package/dist/__tests__/e2e/roundtrip.test.js.map +1 -0
- package/dist/__tests__/e2e/scaffold-owns.test.d.ts +10 -0
- package/dist/__tests__/e2e/scaffold-owns.test.d.ts.map +1 -0
- package/dist/__tests__/e2e/scaffold-owns.test.js +154 -0
- package/dist/__tests__/e2e/scaffold-owns.test.js.map +1 -0
- package/dist/__tests__/global-setup.d.ts +7 -0
- package/dist/__tests__/global-setup.d.ts.map +1 -0
- package/dist/__tests__/global-setup.js +16 -0
- package/dist/__tests__/global-setup.js.map +1 -0
- package/dist/__tests__/live/claude-code.live.test.d.ts +20 -0
- package/dist/__tests__/live/claude-code.live.test.d.ts.map +1 -0
- package/dist/__tests__/live/claude-code.live.test.js +136 -0
- package/dist/__tests__/live/claude-code.live.test.js.map +1 -0
- package/dist/__tests__/live/zz-debug.test.d.ts +2 -0
- package/dist/__tests__/live/zz-debug.test.d.ts.map +1 -0
- package/dist/__tests__/live/zz-debug.test.js +4 -0
- package/dist/__tests__/live/zz-debug.test.js.map +1 -0
- package/dist/__tests__/open-findings.test.d.ts +10 -0
- package/dist/__tests__/open-findings.test.d.ts.map +1 -0
- package/dist/__tests__/open-findings.test.js +37 -0
- package/dist/__tests__/open-findings.test.js.map +1 -0
- package/dist/__tests__/spec-testing-sync.test.d.ts +10 -0
- package/dist/__tests__/spec-testing-sync.test.d.ts.map +1 -0
- package/dist/__tests__/spec-testing-sync.test.js +57 -0
- package/dist/__tests__/spec-testing-sync.test.js.map +1 -0
- package/dist/bindings/__tests__/hook-command.test.d.ts +9 -0
- package/dist/bindings/__tests__/hook-command.test.d.ts.map +1 -0
- package/dist/bindings/__tests__/hook-command.test.js +88 -0
- package/dist/bindings/__tests__/hook-command.test.js.map +1 -0
- package/dist/bindings/__tests__/mod-plugin.test.d.ts +9 -0
- package/dist/bindings/__tests__/mod-plugin.test.d.ts.map +1 -0
- package/dist/bindings/__tests__/mod-plugin.test.js +164 -0
- package/dist/bindings/__tests__/mod-plugin.test.js.map +1 -0
- package/dist/bindings/claude-code.d.ts +52 -1
- package/dist/bindings/claude-code.d.ts.map +1 -1
- package/dist/bindings/claude-code.js +306 -68
- package/dist/bindings/claude-code.js.map +1 -1
- package/dist/bindings/index.d.ts +7 -0
- package/dist/bindings/index.d.ts.map +1 -1
- package/dist/bindings/index.js +26 -7
- package/dist/bindings/index.js.map +1 -1
- package/dist/bindings/types.d.ts +6 -0
- package/dist/bindings/types.d.ts.map +1 -1
- package/dist/bindings/types.js.map +1 -1
- package/dist/cli/__tests__/cli.test.d.ts +1 -1
- package/dist/cli/__tests__/cli.test.js +1 -1
- package/dist/cli/index.js +118 -78
- package/dist/cli/index.js.map +1 -1
- package/dist/docs/content.d.ts.map +1 -1
- package/dist/docs/content.js +39 -3
- package/dist/docs/content.js.map +1 -1
- package/dist/docs/teach.d.ts.map +1 -1
- package/dist/docs/teach.js +36 -0
- package/dist/docs/teach.js.map +1 -1
- package/dist/import/__tests__/import.test.js +21 -9
- package/dist/import/__tests__/import.test.js.map +1 -1
- package/dist/import/claude-code.d.ts.map +1 -1
- package/dist/import/claude-code.js +128 -44
- package/dist/import/claude-code.js.map +1 -1
- package/dist/import/serializer.d.ts.map +1 -1
- package/dist/import/serializer.js +10 -0
- package/dist/import/serializer.js.map +1 -1
- package/dist/parser/__tests__/mod-hooks.test.d.ts +10 -0
- package/dist/parser/__tests__/mod-hooks.test.d.ts.map +1 -0
- package/dist/parser/__tests__/mod-hooks.test.js +136 -0
- package/dist/parser/__tests__/mod-hooks.test.js.map +1 -0
- package/dist/parser/ast.d.ts +29 -2
- package/dist/parser/ast.d.ts.map +1 -1
- package/dist/parser/ast.js.map +1 -1
- package/dist/parser/index.d.ts +6 -1
- package/dist/parser/index.d.ts.map +1 -1
- package/dist/parser/index.js +76 -22
- package/dist/parser/index.js.map +1 -1
- package/dist/parser/paths.d.ts +15 -0
- package/dist/parser/paths.d.ts.map +1 -0
- package/dist/parser/paths.js +34 -0
- package/dist/parser/paths.js.map +1 -0
- package/dist/parser/run.d.ts +24 -0
- package/dist/parser/run.d.ts.map +1 -0
- package/dist/parser/run.js +55 -0
- package/dist/parser/run.js.map +1 -0
- package/dist/parser/validator.d.ts +12 -0
- package/dist/parser/validator.d.ts.map +1 -1
- package/dist/parser/validator.js +218 -2
- package/dist/parser/validator.js.map +1 -1
- package/dist/scaffold/__tests__/safety.test.d.ts +11 -0
- package/dist/scaffold/__tests__/safety.test.d.ts.map +1 -0
- package/dist/scaffold/__tests__/safety.test.js +184 -0
- package/dist/scaffold/__tests__/safety.test.js.map +1 -0
- package/dist/scaffold/__tests__/scaffold.test.js +2 -1
- package/dist/scaffold/__tests__/scaffold.test.js.map +1 -1
- package/dist/scaffold/__tests__/settings-merge.test.js +40 -9
- package/dist/scaffold/__tests__/settings-merge.test.js.map +1 -1
- package/dist/scaffold/incremental.d.ts +49 -13
- package/dist/scaffold/incremental.d.ts.map +1 -1
- package/dist/scaffold/incremental.js +235 -95
- package/dist/scaffold/incremental.js.map +1 -1
- package/dist/scaffold/manifest.d.ts +8 -1
- package/dist/scaffold/manifest.d.ts.map +1 -1
- package/dist/scaffold/manifest.js +32 -8
- package/dist/scaffold/manifest.js.map +1 -1
- package/dist/scaffold/merge.d.ts +15 -4
- package/dist/scaffold/merge.d.ts.map +1 -1
- package/dist/scaffold/merge.js +109 -18
- package/dist/scaffold/merge.js.map +1 -1
- package/dist/scaffold/types.d.ts +17 -0
- package/dist/scaffold/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/spec/grammar.md +20 -4
- package/spec/testing.md +81 -0
- 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
|
-
/**
|
|
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;
|
|
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
|
|
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
|
-
|
|
1645
|
-
|
|
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
|
|
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
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
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
|
|
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
|
-
*
|
|
1999
|
+
* The shell command for a command hook's `run:`.
|
|
1849
2000
|
*
|
|
1850
|
-
*
|
|
1851
|
-
*
|
|
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
|
|
1854
|
-
const
|
|
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
|
-
*
|
|
2742
|
-
*
|
|
2743
|
-
*
|
|
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
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
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
|