agentopology 0.9.2 → 0.10.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 (168) hide show
  1. package/README.md +17 -19
  2. package/dist/__tests__/e2e/action-guard.test.d.ts +13 -0
  3. package/dist/__tests__/e2e/action-guard.test.d.ts.map +1 -0
  4. package/dist/__tests__/e2e/action-guard.test.js +299 -0
  5. package/dist/__tests__/e2e/action-guard.test.js.map +1 -0
  6. package/dist/__tests__/e2e/conformance.test.d.ts +13 -0
  7. package/dist/__tests__/e2e/conformance.test.d.ts.map +1 -0
  8. package/dist/__tests__/e2e/conformance.test.js +109 -0
  9. package/dist/__tests__/e2e/conformance.test.js.map +1 -0
  10. package/dist/__tests__/e2e/contract-drift.test.d.ts +11 -0
  11. package/dist/__tests__/e2e/contract-drift.test.d.ts.map +1 -0
  12. package/dist/__tests__/e2e/contract-drift.test.js +142 -0
  13. package/dist/__tests__/e2e/contract-drift.test.js.map +1 -0
  14. package/dist/__tests__/e2e/guard-check.test.d.ts +9 -0
  15. package/dist/__tests__/e2e/guard-check.test.d.ts.map +1 -0
  16. package/dist/__tests__/e2e/guard-check.test.js +316 -0
  17. package/dist/__tests__/e2e/guard-check.test.js.map +1 -0
  18. package/dist/__tests__/e2e/guard-target.test.d.ts +11 -0
  19. package/dist/__tests__/e2e/guard-target.test.d.ts.map +1 -0
  20. package/dist/__tests__/e2e/guard-target.test.js +198 -0
  21. package/dist/__tests__/e2e/guard-target.test.js.map +1 -0
  22. package/dist/__tests__/e2e/inventory.test.d.ts +18 -0
  23. package/dist/__tests__/e2e/inventory.test.d.ts.map +1 -0
  24. package/dist/__tests__/e2e/inventory.test.js +187 -0
  25. package/dist/__tests__/e2e/inventory.test.js.map +1 -0
  26. package/dist/__tests__/e2e/matcher.test.d.ts +14 -0
  27. package/dist/__tests__/e2e/matcher.test.d.ts.map +1 -0
  28. package/dist/__tests__/e2e/matcher.test.js +145 -0
  29. package/dist/__tests__/e2e/matcher.test.js.map +1 -0
  30. package/dist/__tests__/e2e/step-check.test.d.ts +9 -0
  31. package/dist/__tests__/e2e/step-check.test.d.ts.map +1 -0
  32. package/dist/__tests__/e2e/step-check.test.js +158 -0
  33. package/dist/__tests__/e2e/step-check.test.js.map +1 -0
  34. package/dist/__tests__/e2e/strength.test.d.ts +17 -0
  35. package/dist/__tests__/e2e/strength.test.d.ts.map +1 -0
  36. package/dist/__tests__/e2e/strength.test.js +130 -0
  37. package/dist/__tests__/e2e/strength.test.js.map +1 -0
  38. package/dist/__tests__/e2e/verified-on.test.d.ts +2 -0
  39. package/dist/__tests__/e2e/verified-on.test.d.ts.map +1 -0
  40. package/dist/__tests__/e2e/verified-on.test.js +57 -0
  41. package/dist/__tests__/e2e/verified-on.test.js.map +1 -0
  42. package/dist/__tests__/open-findings.test.js +1 -0
  43. package/dist/__tests__/open-findings.test.js.map +1 -1
  44. package/dist/bindings/__tests__/bindings.test.js +6 -686
  45. package/dist/bindings/__tests__/bindings.test.js.map +1 -1
  46. package/dist/bindings/__tests__/defaults-agreement.test.js +0 -12
  47. package/dist/bindings/__tests__/defaults-agreement.test.js.map +1 -1
  48. package/dist/bindings/__tests__/hook-command.test.js +2 -2
  49. package/dist/bindings/__tests__/hook-command.test.js.map +1 -1
  50. package/dist/bindings/__tests__/mod-plugin.test.js +2 -2
  51. package/dist/bindings/__tests__/mod-plugin.test.js.map +1 -1
  52. package/dist/bindings/claude-code.d.ts +45 -1
  53. package/dist/bindings/claude-code.d.ts.map +1 -1
  54. package/dist/bindings/claude-code.js +181 -15
  55. package/dist/bindings/claude-code.js.map +1 -1
  56. package/dist/bindings/index.d.ts +5 -5
  57. package/dist/bindings/index.d.ts.map +1 -1
  58. package/dist/bindings/index.js +9 -16
  59. package/dist/bindings/index.js.map +1 -1
  60. package/dist/bindings/types.d.ts +6 -1
  61. package/dist/bindings/types.d.ts.map +1 -1
  62. package/dist/cli/__tests__/cli.test.js +25 -0
  63. package/dist/cli/__tests__/cli.test.js.map +1 -1
  64. package/dist/cli/index.js +222 -8
  65. package/dist/cli/index.js.map +1 -1
  66. package/dist/conformance/claude-code.d.ts +99 -0
  67. package/dist/conformance/claude-code.d.ts.map +1 -0
  68. package/dist/conformance/claude-code.js +242 -0
  69. package/dist/conformance/claude-code.js.map +1 -0
  70. package/dist/conformance/extract.d.ts +70 -0
  71. package/dist/conformance/extract.d.ts.map +1 -0
  72. package/dist/conformance/extract.js +185 -0
  73. package/dist/conformance/extract.js.map +1 -0
  74. package/dist/docs/content.d.ts.map +1 -1
  75. package/dist/docs/content.js +57 -32
  76. package/dist/docs/content.js.map +1 -1
  77. package/dist/guard/check.d.ts +53 -0
  78. package/dist/guard/check.d.ts.map +1 -0
  79. package/dist/guard/check.js +366 -0
  80. package/dist/guard/check.js.map +1 -0
  81. package/dist/guard/target.d.ts +45 -0
  82. package/dist/guard/target.d.ts.map +1 -0
  83. package/dist/guard/target.js +168 -0
  84. package/dist/guard/target.js.map +1 -0
  85. package/dist/import/__tests__/import.test.js +0 -554
  86. package/dist/import/__tests__/import.test.js.map +1 -1
  87. package/dist/import/index.d.ts +0 -1
  88. package/dist/import/index.d.ts.map +1 -1
  89. package/dist/import/index.js +1 -7
  90. package/dist/import/index.js.map +1 -1
  91. package/dist/import/serializer.d.ts.map +1 -1
  92. package/dist/import/serializer.js +41 -0
  93. package/dist/import/serializer.js.map +1 -1
  94. package/dist/index.d.ts +0 -4
  95. package/dist/index.d.ts.map +1 -1
  96. package/dist/index.js +0 -4
  97. package/dist/index.js.map +1 -1
  98. package/dist/inventory/index.d.ts +65 -0
  99. package/dist/inventory/index.d.ts.map +1 -0
  100. package/dist/inventory/index.js +272 -0
  101. package/dist/inventory/index.js.map +1 -0
  102. package/dist/parser/ast.d.ts +46 -0
  103. package/dist/parser/ast.d.ts.map +1 -1
  104. package/dist/parser/ast.js.map +1 -1
  105. package/dist/parser/index.d.ts +6 -1
  106. package/dist/parser/index.d.ts.map +1 -1
  107. package/dist/parser/index.js +66 -4
  108. package/dist/parser/index.js.map +1 -1
  109. package/dist/parser/known-fields.d.ts +3 -0
  110. package/dist/parser/known-fields.d.ts.map +1 -1
  111. package/dist/parser/known-fields.js +5 -3
  112. package/dist/parser/known-fields.js.map +1 -1
  113. package/dist/parser/ledger.d.ts +36 -0
  114. package/dist/parser/ledger.d.ts.map +1 -0
  115. package/dist/parser/ledger.js +72 -0
  116. package/dist/parser/ledger.js.map +1 -0
  117. package/dist/parser/run.d.ts +6 -0
  118. package/dist/parser/run.d.ts.map +1 -1
  119. package/dist/parser/run.js +10 -0
  120. package/dist/parser/run.js.map +1 -1
  121. package/dist/parser/validator.d.ts.map +1 -1
  122. package/dist/parser/validator.js +255 -1
  123. package/dist/parser/validator.js.map +1 -1
  124. package/dist/scaffold/manifest.d.ts.map +1 -1
  125. package/dist/scaffold/manifest.js +0 -8
  126. package/dist/scaffold/manifest.js.map +1 -1
  127. package/dist/scaffold/merge.d.ts.map +1 -1
  128. package/dist/scaffold/merge.js +1 -3
  129. package/dist/scaffold/merge.js.map +1 -1
  130. package/dist/step/check.d.ts +56 -0
  131. package/dist/step/check.d.ts.map +1 -0
  132. package/dist/step/check.js +125 -0
  133. package/dist/step/check.js.map +1 -0
  134. package/dist/sync/index.d.ts +0 -2
  135. package/dist/sync/index.d.ts.map +1 -1
  136. package/dist/sync/index.js +0 -109
  137. package/dist/sync/index.js.map +1 -1
  138. package/package.json +3 -1
  139. package/spec/claude-code/README.md +19 -0
  140. package/spec/claude-code/hooks-contract.2.1.295.json +143 -0
  141. package/spec/claude-code/hooks-contract.json +142 -0
  142. package/spec/grammar.md +45 -7
  143. package/spec/testing.md +15 -0
  144. package/spec/validation.md +11 -2
  145. package/dist/__tests__/live/zz-debug.test.d.ts +0 -2
  146. package/dist/__tests__/live/zz-debug.test.d.ts.map +0 -1
  147. package/dist/__tests__/live/zz-debug.test.js +0 -4
  148. package/dist/__tests__/live/zz-debug.test.js.map +0 -1
  149. package/dist/bindings/copilot-cli.d.ts +0 -20
  150. package/dist/bindings/copilot-cli.d.ts.map +0 -1
  151. package/dist/bindings/copilot-cli.js +0 -1516
  152. package/dist/bindings/copilot-cli.js.map +0 -1
  153. package/dist/bindings/gemini-cli.d.ts +0 -18
  154. package/dist/bindings/gemini-cli.d.ts.map +0 -1
  155. package/dist/bindings/gemini-cli.js +0 -1318
  156. package/dist/bindings/gemini-cli.js.map +0 -1
  157. package/dist/bindings/kiro.d.ts +0 -17
  158. package/dist/bindings/kiro.d.ts.map +0 -1
  159. package/dist/bindings/kiro.js +0 -1615
  160. package/dist/bindings/kiro.js.map +0 -1
  161. package/dist/bindings/openclaw.d.ts +0 -18
  162. package/dist/bindings/openclaw.d.ts.map +0 -1
  163. package/dist/bindings/openclaw.js +0 -1947
  164. package/dist/bindings/openclaw.js.map +0 -1
  165. package/dist/import/openclaw.d.ts +0 -70
  166. package/dist/import/openclaw.d.ts.map +0 -1
  167. package/dist/import/openclaw.js +0 -1060
  168. package/dist/import/openclaw.js.map +0 -1
@@ -12,7 +12,8 @@ import { PROMPT_FENCE_CLOSE, PROMPT_FENCE_OPEN } from "./lib/prompt-fence.js";
12
12
  import { isWorkflowSeamAgent, seamFiles } from "./lib/seam.js";
13
13
  import { phaseOf as sharedPhaseOf } from "../resolve/phase.js";
14
14
  import { gateAnchors } from "../parser/ast.js";
15
- import { isProjectPath, proseInRun, runParts, scriptNameFromRun } from "../parser/run.js";
15
+ import { gateLedger, guardedActions } from "../parser/ledger.js";
16
+ import { isProjectPath, isShellCommand, proseInRun, runParts, scriptNameFromRun } from "../parser/run.js";
16
17
  // ---------------------------------------------------------------------------
17
18
  // Helpers
18
19
  // ---------------------------------------------------------------------------
@@ -20,7 +21,7 @@ import { isProjectPath, proseInRun, runParts, scriptNameFromRun } from "../parse
20
21
  * Convert .at MCP tool notation (mcp.server.method) to Claude Code format (mcp__server__method).
21
22
  * Non-MCP tool names pass through unchanged.
22
23
  */
23
- function convertMcpToolName(tool) {
24
+ export function convertMcpToolName(tool) {
24
25
  if (!tool.startsWith("mcp."))
25
26
  return tool;
26
27
  // mcp.server → mcp__server (every tool on it); mcp.server.* → mcp__server__*;
@@ -50,7 +51,7 @@ const UNIVERSAL_EVENTS = {
50
51
  ToolUse: "PostToolUse",
51
52
  Error: "PostToolUseFailure",
52
53
  };
53
- function claudeEvent(on) {
54
+ export function claudeEvent(on) {
54
55
  return UNIVERSAL_EVENTS[on] ?? on;
55
56
  }
56
57
  /** Whether a hook runs a shell command (and so has a script). */
@@ -60,18 +61,20 @@ function isCommandHook(hook) {
60
61
  /** A settings.json hook handler, in the shape Claude Code reads for its type. */
61
62
  function hookHandler(hook, command) {
62
63
  const timeout = hook.timeout ? { timeout: hook.timeout } : {};
64
+ // `if` filters on the tool's arguments (permission-rule syntax); a common handler field.
65
+ const filter = hook.if ? { if: hook.if } : {};
63
66
  switch (hook.type ?? "command") {
64
67
  // A prompt or agent hook asks a model (or a subagent) to judge the event;
65
68
  // its run: is the prompt.
66
69
  case "prompt":
67
70
  case "agent":
68
- return { type: hook.type, prompt: hook.run, ...(hook.model ? { model: hook.model } : {}), ...timeout };
71
+ return { type: hook.type, prompt: hook.run, ...(hook.model ? { model: hook.model } : {}), ...timeout, ...filter };
69
72
  case "http":
70
- return { type: "http", url: hook.url, ...timeout };
73
+ return { type: "http", url: hook.url, ...timeout, ...filter };
71
74
  case "mcp_tool":
72
- return { type: "mcp_tool", server: hook.server, tool: hook.tool, ...timeout };
75
+ return { type: "mcp_tool", server: hook.server, tool: hook.tool, ...timeout, ...filter };
73
76
  default:
74
- return { type: "command", command: command(), ...(hook.async ? { async: true } : {}), ...timeout };
77
+ return { type: "command", command: command(), ...(hook.async ? { async: true } : {}), ...timeout, ...filter };
75
78
  }
76
79
  }
77
80
  /**
@@ -158,7 +161,7 @@ function getDelegationMode(ast) {
158
161
  const orch = ast.nodes.find((n) => n.type === "orchestrator");
159
162
  return orch?.delegation === "inline" ? "inline" : "subagent";
160
163
  }
161
- function gateWiring(ast, gate) {
164
+ export function gateWiring(ast, gate) {
162
165
  if (getDelegationMode(ast) === "inline")
163
166
  return { kind: "none" };
164
167
  const afters = gateAnchors(gate, "after");
@@ -169,7 +172,7 @@ function gateWiring(ast, gate) {
169
172
  return agents.length > 0 ? { kind: "before-agent", agents } : { kind: "none" };
170
173
  }
171
174
  /** Agents a human approval step stands before: its direct flow successors. */
172
- function approvalTargets(ast, human) {
175
+ export function approvalTargets(ast, human) {
173
176
  if (getDelegationMode(ast) === "inline")
174
177
  return [];
175
178
  const out = ast.edges.filter((e) => e.from === human.id && !e.isError).map((e) => e.to);
@@ -177,7 +180,7 @@ function approvalTargets(ast, human) {
177
180
  }
178
181
  /** The settings.json command for the topology's before-agent hook. */
179
182
  function beforeAgentCommand(ast) {
180
- return `bash "$CLAUDE_PROJECT_DIR/.claude/skills/${ast.topology.name}/scripts/before-agent.sh"`;
183
+ return `bash ${skillScriptPath(ast.topology.name, "before-agent.sh")}`;
181
184
  }
182
185
  /**
183
186
  * One PreToolUse hook on the Agent tool for the whole topology: before Claude
@@ -245,6 +248,137 @@ function generateBeforeAgentScript(ast) {
245
248
  executable: true,
246
249
  };
247
250
  }
251
+ /** The settings.json command for the topology's before-action hook. */
252
+ function beforeActionCommand(ast) {
253
+ return `bash ${skillScriptPath(ast.topology.name, "before-action.sh")}`;
254
+ }
255
+ /**
256
+ * A gate before a script step that the generated guard can check: it has a
257
+ * ledger to read, or a `run:` script to run. A gate with neither has nothing
258
+ * to check (V110) and gets no line.
259
+ */
260
+ export function checkableActionGate(ast, gate) {
261
+ if (gate.behavior === "advisory")
262
+ return false;
263
+ if (guardedActions(ast, gate).length === 0)
264
+ return false;
265
+ return !!gateLedger(ast, gate) || !!scriptNameFromRun(gate.run);
266
+ }
267
+ /** Every action a gate guards, with its gates, in declaration order. */
268
+ function actionGuards(ast) {
269
+ const gates = ast.nodes.filter((n) => n.type === "gate").filter((g) => checkableActionGate(ast, g));
270
+ const out = [];
271
+ for (const node of ast.nodes) {
272
+ if (node.type !== "action")
273
+ continue;
274
+ const mine = gates.filter((g) => guardedActions(ast, g).some((a) => a.id === node.id));
275
+ if (mine.length > 0)
276
+ out.push({ action: node, gates: mine });
277
+ }
278
+ return out;
279
+ }
280
+ /**
281
+ * One PreToolUse hook on Bash for the whole topology: before Claude Code runs
282
+ * a script step's command, the gates that stand before that step are checked.
283
+ * A gate with a ledger lets the command through only when the LAST ledger line
284
+ * for its `after:` step (and the command's unit) is ok; a gate with `run:` also
285
+ * runs its wrapper. Null when no gate guards a script step.
286
+ *
287
+ * Why a hook and not a note in the playbook: V25 used to say "invoke the gate
288
+ * from your playbook", and a session that skipped it ran the step anyway. A
289
+ * hook only covers Claude Code sessions; a script run from a terminal checks
290
+ * itself with `agentopology step check`.
291
+ */
292
+ function generateBeforeActionScript(ast) {
293
+ const guards = actionGuards(ast);
294
+ if (guards.length === 0)
295
+ return null;
296
+ const name = ast.topology.name;
297
+ const lines = [
298
+ "#!/usr/bin/env bash",
299
+ `# Before Claude Code runs a script step of topology "${name}".`,
300
+ "# Auto-generated by agentopology scaffold. Wired in .claude/settings.json as a",
301
+ "# PreToolUse hook on Bash. Exit 2 blocks the command and shows stderr to Claude.",
302
+ "# A ledger is JSON Lines: {\"step\": \"<id>\", \"unit\": \"<unit>\", \"ok\": true, \"at\": \"<ISO time>\"}.",
303
+ "# Covers Claude Code sessions only; a step run outside Claude calls",
304
+ "# `agentopology step check` itself.",
305
+ "set -euo pipefail",
306
+ ...FAIL_CLOSED,
307
+ 'command -v jq >/dev/null 2>&1 || { echo "agentopology: jq is required to check this step, so it is blocked" >&2; exit 2; }',
308
+ 'if [ -z "${CLAUDE_PROJECT_DIR:-}" ]; then echo "agentopology: CLAUDE_PROJECT_DIR is not set, so this step cannot be checked and is blocked" >&2; exit 2; fi',
309
+ 'cd "$CLAUDE_PROJECT_DIR"',
310
+ `DIR=".claude/skills/${name}/scripts"`,
311
+ 'CMD=$(jq -er \'.tool_input.command // ""\') || { echo "agentopology: the hook input is not valid JSON, so the command is blocked" >&2; exit 2; }',
312
+ "",
313
+ "deny() { echo \"agentopology: gate $1 refuses this command ($CMD): $2\" >&2; exit 2; }",
314
+ "",
315
+ "# check_ledger GATE STEP LEDGER-PATH UNIT: the last line for STEP (and UNIT) must be ok.",
316
+ "check_ledger() {",
317
+ " local gate=$1 step=$2 tmpl=$3 unit=$4 path ok",
318
+ ' if [[ "$tmpl" == *"{unit}"* ]]; then',
319
+ ' [ -n "$unit" ] || deny "$gate" "step \\"$step\\" must have finished ok, but no unit could be read from the command to find its ledger $tmpl"',
320
+ ' path="${tmpl//\\{unit\\}/$unit}"',
321
+ " else",
322
+ ' path="$tmpl"',
323
+ " fi",
324
+ ' [ -f "$path" ] || deny "$gate" "step \\"$step\\" has no ok record for unit \\"$unit\\": the ledger $path does not exist"',
325
+ " ok=$(jq -rs --arg step \"$step\" --arg unit \"$unit\" '[.[] | select(.step == $step and ($unit == \"\" or .unit == $unit))] | last | (.ok == true)' \"$path\") \\",
326
+ ' || deny "$gate" "step \\"$step\\" for unit \\"$unit\\": the ledger $path could not be read"',
327
+ ' [ "$ok" = true ] || deny "$gate" "step \\"$step\\" has no ok record for unit \\"$unit\\" in ledger $path (its last record must have ok: true)"',
328
+ "}",
329
+ "",
330
+ "# A Bash command is a list: `cd repo && ./tool publish ep1`, `a; b`, `a || b`, `a | b`, a",
331
+ "# backgrounded `a &`, or several lines. Each segment is checked on its own, because the step is",
332
+ "# the segment's command, not the whole line (matching only the start of the whole line let",
333
+ "# `cd repo && ./tool publish ep1` run the step with its gate unmet). Quoting is NOT parsed: a",
334
+ "# separator inside a quoted string also splits, which can only produce an extra segment that",
335
+ "# is checked too, so it over-blocks and never under-blocks (fail-safe). A command that runs the",
336
+ "# step indirectly (bash -c, eval, a script that calls it) is not seen.",
337
+ "SEGS=$CMD",
338
+ "for sep in '&' '|' ';'; do SEGS=${SEGS//\"$sep\"/$'\\n'}; done",
339
+ "# Leading VAR=value assignments and wrappers are not part of the command.",
340
+ "re_assign='^[[:space:]]*[A-Za-z_][A-Za-z0-9_]*=(\"[^\"]*\"|'\"'\"'[^'\"'\"']*'\"'\"'|[^[:space:]]*)([[:space:]]+|$)'",
341
+ "re_wrap='^(\\(+[[:space:]]*|\\{[[:space:]]+|(then|do|exec|time|nohup|env)([[:space:]]+|$))'",
342
+ "UNIT=''",
343
+ "",
344
+ "# Segments come in on fd 3 and children get stdin from /dev/null: a check that reads stdin must not swallow the later segments.",
345
+ "while IFS= read -r -u 3 C || [ -n \"$C\" ]; do",
346
+ " prev=''",
347
+ ' while [ "$C" != "$prev" ]; do',
348
+ ' prev=$C',
349
+ ' C="${C#"${C%%[![:space:]]*}"}"',
350
+ ' if [[ "$C" =~ $re_assign ]]; then C="${C:${#BASH_REMATCH[0]}}"; elif [[ "$C" =~ $re_wrap ]]; then C="${C:${#BASH_REMATCH[0]}}"; fi',
351
+ " done",
352
+ ' [ -n "$C" ] || continue',
353
+ ];
354
+ for (const { action, gates } of guards) {
355
+ const tests = (action.commands ?? []).map((c) => `[[ "$C" == ${shellQuote(c)} || "$C" == ${shellQuote(c + " ")}* ]]`);
356
+ lines.push(` # step ${action.id}`, ` if ${tests.join(" || ")}; then`, " UNIT=''");
357
+ if (action.unitFrom) {
358
+ lines.push(` UNIT=$(jq -nr --arg c "$C" --arg re ${shellQuote(action.unitFrom)} '$c | match($re) | .captures[0].string // empty') || deny ${shellQuote(gates[0].id)} "unit-from does not compile"`, ' if ! [[ "$UNIT" =~ ^[A-Za-z0-9_.@+-]*$ ]] || [[ "$UNIT" == *..* ]]; then', ` deny ${shellQuote(gates[0].id)} "the unit \\"$UNIT\\" read from the command is not a safe file name"`, " fi");
359
+ }
360
+ for (const gate of gates) {
361
+ lines.push(` # gate ${gate.id} (after: ${gateAnchors(gate, "after").join(", ") || "-"})`);
362
+ const ledger = gateLedger(ast, gate);
363
+ if (ledger) {
364
+ for (const step of gateAnchors(gate, "after")) {
365
+ lines.push(` check_ledger ${shellQuote(gate.id)} ${shellQuote(step)} ${shellQuote(ledger.path.replace(/^\.\//, ""))} "$UNIT"`);
366
+ }
367
+ }
368
+ if (scriptNameFromRun(gate.run)) {
369
+ lines.push(` bash "$DIR/gate-${gate.id}.sh" </dev/null >&2 || deny ${shellQuote(gate.id)} "its check script failed"`);
370
+ }
371
+ }
372
+ lines.push(" fi", "");
373
+ }
374
+ lines.push('done 3<<< "$SEGS"', "", FAIL_CLOSED_DONE, "exit 0", "");
375
+ return {
376
+ path: `.claude/skills/${name}/scripts/before-action.sh`,
377
+ content: lines.join("\n"),
378
+ category: "script",
379
+ executable: true,
380
+ };
381
+ }
248
382
  /** Single-quote a string for bash. */
249
383
  function shellQuote(text) {
250
384
  return `'${text.replace(/'/g, `'\\''`)}'`;
@@ -612,7 +746,7 @@ function generateAgents(ast) {
612
746
  const scriptName = scriptNameFromRun(hook.run);
613
747
  if (isCommandHook(hook) && !scriptName)
614
748
  continue; // V96: prose in run names no script
615
- const handler = hookHandler(hook, () => `bash .claude/skills/${topologyName}/scripts/${agent.id}-${scriptName}`);
749
+ const handler = hookHandler(hook, () => isShellCommand(hook.run) ? hook.run.trim() : `bash ${skillScriptPath(topologyName, `${agent.id}-${scriptName}`)}`);
616
750
  // JSON strings are valid YAML double-quoted scalars, so a regex
617
751
  // matcher or a prompt with quotes or colons can never break the file.
618
752
  hookLines.push(hook.matcher ? ` - matcher: ${JSON.stringify(hook.matcher)}` : ` -`);
@@ -1931,7 +2065,11 @@ function generateGateWrapperScripts(ast) {
1931
2065
  const wiredAsHook = wiring.kind === "subagent-stop";
1932
2066
  let enforcementNote;
1933
2067
  let enforcementNote2;
1934
- if (wiring.kind === "before-agent") {
2068
+ if (checkableActionGate(ast, gate)) {
2069
+ enforcementNote = `# Enforcement: PreToolUse hook on Bash (before-action.sh) before Claude Code runs a command of "${gateAnchors(gate, "before").join('", "')}".`;
2070
+ enforcementNote2 = `# Exit 2 blocks the command and surfaces stderr to Claude.`;
2071
+ }
2072
+ else if (wiring.kind === "before-agent") {
1935
2073
  enforcementNote = `# Enforcement: PreToolUse hook (before-agent.sh) before Claude Code launches "${wiring.agents.join('", "')}".`;
1936
2074
  enforcementNote2 = `# Exit 2 blocks the launch and surfaces stderr to Claude.`;
1937
2075
  }
@@ -2179,6 +2317,8 @@ function generateHookScripts(ast) {
2179
2317
  // bash stub. generateModPlugin imports it instead.
2180
2318
  if (!isCommandHook(hook))
2181
2319
  continue; // a mod's module, or no script at all
2320
+ if (isShellCommand(hook.run))
2321
+ continue; // run as written: no script to stub
2182
2322
  const runPath = hook.run;
2183
2323
  // If the run path starts with '.' or '/' it is already project-relative —
2184
2324
  // generate the stub at exactly that path (observability pattern).
@@ -2222,6 +2362,8 @@ function generateAgentHookScripts(ast) {
2222
2362
  for (const hook of agent.hooks) {
2223
2363
  if (!hook.run || !isCommandHook(hook))
2224
2364
  continue;
2365
+ if (isShellCommand(hook.run))
2366
+ continue; // run as written: no script to stub
2225
2367
  const scriptName = scriptNameFromRun(hook.run);
2226
2368
  if (!scriptName)
2227
2369
  continue; // V96: prose in run names no script
@@ -2242,6 +2384,15 @@ function generateAgentHookScripts(ast) {
2242
2384
  }
2243
2385
  return files;
2244
2386
  }
2387
+ /**
2388
+ * A script this topology's skill scaffolds, as a hook command names it. A
2389
+ * hook runs in the session's current directory, which moves when Claude
2390
+ * changes directory, so the path is anchored to the project: a relative one
2391
+ * is not found there, exits 127, and 127 does not block.
2392
+ */
2393
+ export function skillScriptPath(topologyName, file) {
2394
+ return `"$CLAUDE_PROJECT_DIR/.claude/skills/${topologyName}/scripts/${file}"`;
2395
+ }
2245
2396
  /**
2246
2397
  * The shell command for a command hook's or gate's `run:`.
2247
2398
  *
@@ -2254,10 +2405,12 @@ function generateAgentHookScripts(ast) {
2254
2405
  * Any other `run:` is a script this topology's skill scaffolds, by basename.
2255
2406
  */
2256
2407
  export function hookCommand(run, topologyName) {
2408
+ if (isShellCommand(run))
2409
+ return run.trim();
2257
2410
  const { interpreter: named, script: first, args: rest } = runParts(run);
2258
2411
  if (!isProjectPath(first)) {
2259
2412
  const base = first.replace(/^.*\//, "");
2260
- return [named ?? "bash", `.claude/skills/${topologyName}/scripts/${base}`, ...rest].join(" ");
2413
+ return [named ?? "bash", skillScriptPath(topologyName, base), ...rest].join(" ");
2261
2414
  }
2262
2415
  const interpreter = named ?? (first.endsWith(".py") ? "python3" : "bash");
2263
2416
  const where = first.startsWith("/")
@@ -2369,6 +2522,8 @@ function generateSettings(ast) {
2369
2522
  const wiring = gateWiring(ast, gate);
2370
2523
  if (wiring.kind === "before-agent")
2371
2524
  continue;
2525
+ if (checkableActionGate(ast, gate))
2526
+ continue; // before-action.sh runs it
2372
2527
  if (wiring.kind !== "subagent-stop") {
2373
2528
  const bad = anchors.filter((a) => !isRegisteredSubagentType(ast, a));
2374
2529
  const targetDesc = anchors.length
@@ -2383,7 +2538,7 @@ function generateSettings(ast) {
2383
2538
  if (!hooksByEvent[eventName])
2384
2539
  hooksByEvent[eventName] = [];
2385
2540
  const scriptName = `gate-${gate.id}.sh`;
2386
- const command = `bash .claude/skills/${topologyName}/scripts/${scriptName}`;
2541
+ const command = `bash ${skillScriptPath(topologyName, scriptName)}`;
2387
2542
  // ONE ENTRY PER ANCHOR. A SubagentStop matcher is a STRING — an array would
2388
2543
  // land in settings.json, type-check clean through Record<string, unknown>,
2389
2544
  // and the hook would simply never fire. Silent, and the gate would look
@@ -2420,6 +2575,14 @@ function generateSettings(ast) {
2420
2575
  hooks: [{ type: "command", command: beforeAgentCommand(ast), onFailure: "block" }],
2421
2576
  });
2422
2577
  }
2578
+ if (generateBeforeActionScript(ast)) {
2579
+ if (!hooksByEvent.PreToolUse)
2580
+ hooksByEvent.PreToolUse = [];
2581
+ hooksByEvent.PreToolUse.push({
2582
+ matcher: "Bash",
2583
+ hooks: [{ type: "command", command: beforeActionCommand(ast), onFailure: "block" }],
2584
+ });
2585
+ }
2423
2586
  const observeHook = buildHybridObserveHook(ast, topologyName);
2424
2587
  if (observeHook) {
2425
2588
  if (!hooksByEvent[observeHook.event])
@@ -3181,7 +3344,7 @@ function buildHybridObserveHook(ast, topologyName) {
3181
3344
  hooks: [
3182
3345
  {
3183
3346
  type: "command",
3184
- command: `bash ${scriptPath}`,
3347
+ command: `bash ${skillScriptPath(topologyName, "observe-blackboard.sh")}`,
3185
3348
  },
3186
3349
  ],
3187
3350
  },
@@ -3382,6 +3545,9 @@ export const claudeCodeBinding = {
3382
3545
  const beforeAgent = generateBeforeAgentScript(ast);
3383
3546
  if (beforeAgent)
3384
3547
  files.push(beforeAgent);
3548
+ const beforeAction = generateBeforeActionScript(ast);
3549
+ if (beforeAction)
3550
+ files.push(beforeAction);
3385
3551
  // Hook scripts (global)
3386
3552
  files.push(...generateHookScripts(ast));
3387
3553
  // Hook scripts (per-agent)