agentopology 0.9.1 → 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 (178) 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/compile.test.js +1 -1
  7. package/dist/__tests__/e2e/compile.test.js.map +1 -1
  8. package/dist/__tests__/e2e/conformance.test.d.ts +13 -0
  9. package/dist/__tests__/e2e/conformance.test.d.ts.map +1 -0
  10. package/dist/__tests__/e2e/conformance.test.js +109 -0
  11. package/dist/__tests__/e2e/conformance.test.js.map +1 -0
  12. package/dist/__tests__/e2e/contract-drift.test.d.ts +11 -0
  13. package/dist/__tests__/e2e/contract-drift.test.d.ts.map +1 -0
  14. package/dist/__tests__/e2e/contract-drift.test.js +142 -0
  15. package/dist/__tests__/e2e/contract-drift.test.js.map +1 -0
  16. package/dist/__tests__/e2e/fail-closed.test.d.ts +11 -0
  17. package/dist/__tests__/e2e/fail-closed.test.d.ts.map +1 -0
  18. package/dist/__tests__/e2e/fail-closed.test.js +111 -0
  19. package/dist/__tests__/e2e/fail-closed.test.js.map +1 -0
  20. package/dist/__tests__/e2e/guard-check.test.d.ts +9 -0
  21. package/dist/__tests__/e2e/guard-check.test.d.ts.map +1 -0
  22. package/dist/__tests__/e2e/guard-check.test.js +316 -0
  23. package/dist/__tests__/e2e/guard-check.test.js.map +1 -0
  24. package/dist/__tests__/e2e/guard-target.test.d.ts +11 -0
  25. package/dist/__tests__/e2e/guard-target.test.d.ts.map +1 -0
  26. package/dist/__tests__/e2e/guard-target.test.js +198 -0
  27. package/dist/__tests__/e2e/guard-target.test.js.map +1 -0
  28. package/dist/__tests__/e2e/inventory.test.d.ts +18 -0
  29. package/dist/__tests__/e2e/inventory.test.d.ts.map +1 -0
  30. package/dist/__tests__/e2e/inventory.test.js +187 -0
  31. package/dist/__tests__/e2e/inventory.test.js.map +1 -0
  32. package/dist/__tests__/e2e/matcher.test.d.ts +14 -0
  33. package/dist/__tests__/e2e/matcher.test.d.ts.map +1 -0
  34. package/dist/__tests__/e2e/matcher.test.js +145 -0
  35. package/dist/__tests__/e2e/matcher.test.js.map +1 -0
  36. package/dist/__tests__/e2e/step-check.test.d.ts +9 -0
  37. package/dist/__tests__/e2e/step-check.test.d.ts.map +1 -0
  38. package/dist/__tests__/e2e/step-check.test.js +158 -0
  39. package/dist/__tests__/e2e/step-check.test.js.map +1 -0
  40. package/dist/__tests__/e2e/strength.test.d.ts +17 -0
  41. package/dist/__tests__/e2e/strength.test.d.ts.map +1 -0
  42. package/dist/__tests__/e2e/strength.test.js +130 -0
  43. package/dist/__tests__/e2e/strength.test.js.map +1 -0
  44. package/dist/__tests__/e2e/verified-on.test.d.ts +2 -0
  45. package/dist/__tests__/e2e/verified-on.test.d.ts.map +1 -0
  46. package/dist/__tests__/e2e/verified-on.test.js +57 -0
  47. package/dist/__tests__/e2e/verified-on.test.js.map +1 -0
  48. package/dist/__tests__/open-findings.test.js +1 -0
  49. package/dist/__tests__/open-findings.test.js.map +1 -1
  50. package/dist/bindings/__tests__/bindings.test.js +6 -686
  51. package/dist/bindings/__tests__/bindings.test.js.map +1 -1
  52. package/dist/bindings/__tests__/defaults-agreement.test.js +0 -12
  53. package/dist/bindings/__tests__/defaults-agreement.test.js.map +1 -1
  54. package/dist/bindings/__tests__/hook-command.test.js +2 -2
  55. package/dist/bindings/__tests__/hook-command.test.js.map +1 -1
  56. package/dist/bindings/__tests__/mod-plugin.test.js +2 -2
  57. package/dist/bindings/__tests__/mod-plugin.test.js.map +1 -1
  58. package/dist/bindings/claude-code.d.ts +45 -1
  59. package/dist/bindings/claude-code.d.ts.map +1 -1
  60. package/dist/bindings/claude-code.js +191 -19
  61. package/dist/bindings/claude-code.js.map +1 -1
  62. package/dist/bindings/index.d.ts +5 -5
  63. package/dist/bindings/index.d.ts.map +1 -1
  64. package/dist/bindings/index.js +9 -16
  65. package/dist/bindings/index.js.map +1 -1
  66. package/dist/bindings/lib/stub.d.ts +15 -1
  67. package/dist/bindings/lib/stub.d.ts.map +1 -1
  68. package/dist/bindings/lib/stub.js +27 -2
  69. package/dist/bindings/lib/stub.js.map +1 -1
  70. package/dist/bindings/types.d.ts +6 -1
  71. package/dist/bindings/types.d.ts.map +1 -1
  72. package/dist/cli/__tests__/cli.test.js +25 -0
  73. package/dist/cli/__tests__/cli.test.js.map +1 -1
  74. package/dist/cli/index.js +222 -8
  75. package/dist/cli/index.js.map +1 -1
  76. package/dist/conformance/claude-code.d.ts +99 -0
  77. package/dist/conformance/claude-code.d.ts.map +1 -0
  78. package/dist/conformance/claude-code.js +242 -0
  79. package/dist/conformance/claude-code.js.map +1 -0
  80. package/dist/conformance/extract.d.ts +70 -0
  81. package/dist/conformance/extract.d.ts.map +1 -0
  82. package/dist/conformance/extract.js +185 -0
  83. package/dist/conformance/extract.js.map +1 -0
  84. package/dist/docs/content.d.ts.map +1 -1
  85. package/dist/docs/content.js +57 -32
  86. package/dist/docs/content.js.map +1 -1
  87. package/dist/guard/check.d.ts +53 -0
  88. package/dist/guard/check.d.ts.map +1 -0
  89. package/dist/guard/check.js +366 -0
  90. package/dist/guard/check.js.map +1 -0
  91. package/dist/guard/target.d.ts +45 -0
  92. package/dist/guard/target.d.ts.map +1 -0
  93. package/dist/guard/target.js +168 -0
  94. package/dist/guard/target.js.map +1 -0
  95. package/dist/import/__tests__/import.test.js +0 -554
  96. package/dist/import/__tests__/import.test.js.map +1 -1
  97. package/dist/import/index.d.ts +0 -1
  98. package/dist/import/index.d.ts.map +1 -1
  99. package/dist/import/index.js +1 -7
  100. package/dist/import/index.js.map +1 -1
  101. package/dist/import/serializer.d.ts.map +1 -1
  102. package/dist/import/serializer.js +41 -0
  103. package/dist/import/serializer.js.map +1 -1
  104. package/dist/index.d.ts +0 -4
  105. package/dist/index.d.ts.map +1 -1
  106. package/dist/index.js +0 -4
  107. package/dist/index.js.map +1 -1
  108. package/dist/inventory/index.d.ts +65 -0
  109. package/dist/inventory/index.d.ts.map +1 -0
  110. package/dist/inventory/index.js +272 -0
  111. package/dist/inventory/index.js.map +1 -0
  112. package/dist/parser/ast.d.ts +46 -0
  113. package/dist/parser/ast.d.ts.map +1 -1
  114. package/dist/parser/ast.js.map +1 -1
  115. package/dist/parser/index.d.ts +6 -1
  116. package/dist/parser/index.d.ts.map +1 -1
  117. package/dist/parser/index.js +66 -4
  118. package/dist/parser/index.js.map +1 -1
  119. package/dist/parser/known-fields.d.ts +3 -0
  120. package/dist/parser/known-fields.d.ts.map +1 -1
  121. package/dist/parser/known-fields.js +5 -3
  122. package/dist/parser/known-fields.js.map +1 -1
  123. package/dist/parser/ledger.d.ts +36 -0
  124. package/dist/parser/ledger.d.ts.map +1 -0
  125. package/dist/parser/ledger.js +72 -0
  126. package/dist/parser/ledger.js.map +1 -0
  127. package/dist/parser/run.d.ts +6 -0
  128. package/dist/parser/run.d.ts.map +1 -1
  129. package/dist/parser/run.js +10 -0
  130. package/dist/parser/run.js.map +1 -1
  131. package/dist/parser/validator.d.ts.map +1 -1
  132. package/dist/parser/validator.js +255 -1
  133. package/dist/parser/validator.js.map +1 -1
  134. package/dist/scaffold/manifest.d.ts.map +1 -1
  135. package/dist/scaffold/manifest.js +0 -8
  136. package/dist/scaffold/manifest.js.map +1 -1
  137. package/dist/scaffold/merge.d.ts.map +1 -1
  138. package/dist/scaffold/merge.js +1 -3
  139. package/dist/scaffold/merge.js.map +1 -1
  140. package/dist/step/check.d.ts +56 -0
  141. package/dist/step/check.d.ts.map +1 -0
  142. package/dist/step/check.js +125 -0
  143. package/dist/step/check.js.map +1 -0
  144. package/dist/sync/index.d.ts +0 -2
  145. package/dist/sync/index.d.ts.map +1 -1
  146. package/dist/sync/index.js +0 -109
  147. package/dist/sync/index.js.map +1 -1
  148. package/package.json +3 -1
  149. package/spec/claude-code/README.md +19 -0
  150. package/spec/claude-code/hooks-contract.2.1.295.json +143 -0
  151. package/spec/claude-code/hooks-contract.json +142 -0
  152. package/spec/grammar.md +45 -7
  153. package/spec/testing.md +16 -0
  154. package/spec/validation.md +11 -2
  155. package/dist/__tests__/live/zz-debug.test.d.ts +0 -2
  156. package/dist/__tests__/live/zz-debug.test.d.ts.map +0 -1
  157. package/dist/__tests__/live/zz-debug.test.js +0 -4
  158. package/dist/__tests__/live/zz-debug.test.js.map +0 -1
  159. package/dist/bindings/copilot-cli.d.ts +0 -20
  160. package/dist/bindings/copilot-cli.d.ts.map +0 -1
  161. package/dist/bindings/copilot-cli.js +0 -1516
  162. package/dist/bindings/copilot-cli.js.map +0 -1
  163. package/dist/bindings/gemini-cli.d.ts +0 -18
  164. package/dist/bindings/gemini-cli.d.ts.map +0 -1
  165. package/dist/bindings/gemini-cli.js +0 -1318
  166. package/dist/bindings/gemini-cli.js.map +0 -1
  167. package/dist/bindings/kiro.d.ts +0 -17
  168. package/dist/bindings/kiro.d.ts.map +0 -1
  169. package/dist/bindings/kiro.js +0 -1615
  170. package/dist/bindings/kiro.js.map +0 -1
  171. package/dist/bindings/openclaw.d.ts +0 -18
  172. package/dist/bindings/openclaw.d.ts.map +0 -1
  173. package/dist/bindings/openclaw.js +0 -1947
  174. package/dist/bindings/openclaw.js.map +0 -1
  175. package/dist/import/openclaw.d.ts +0 -70
  176. package/dist/import/openclaw.d.ts.map +0 -1
  177. package/dist/import/openclaw.js +0 -1060
  178. package/dist/import/openclaw.js.map +0 -1
@@ -7,12 +7,13 @@
7
7
  * @module
8
8
  */
9
9
  import { deduplicateFiles } from "./types.js";
10
- import { shellStub, STUB_MARKER } from "./lib/stub.js";
10
+ import { shellStub, STUB_MARKER, FAIL_CLOSED, FAIL_CLOSED_DONE } from "./lib/stub.js";
11
11
  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
@@ -223,6 +226,7 @@ function generateBeforeAgentScript(ast) {
223
226
  "# PreToolUse hook on the Agent tool. Exit 2 blocks the launch and shows the",
224
227
  "# reason to Claude; an \"ask\" decision makes Claude Code ask the user first.",
225
228
  "set -euo pipefail",
229
+ ...FAIL_CLOSED,
226
230
  'cd "$CLAUDE_PROJECT_DIR"',
227
231
  `DIR=".claude/skills/${name}/scripts"`,
228
232
  'AGENT=$(jq -r \'.tool_input.subagent_type // ""\')',
@@ -236,7 +240,7 @@ function generateBeforeAgentScript(ast) {
236
240
  for (const [agent, body] of steps) {
237
241
  lines.push(` ${agent})`, ...body, " ;;");
238
242
  }
239
- lines.push("esac", "exit 0", "");
243
+ lines.push("esac", FAIL_CLOSED_DONE, "exit 0", "");
240
244
  return {
241
245
  path: `.claude/skills/${name}/scripts/before-agent.sh`,
242
246
  content: lines.join("\n"),
@@ -244,6 +248,137 @@ function generateBeforeAgentScript(ast) {
244
248
  executable: true,
245
249
  };
246
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
+ }
247
382
  /** Single-quote a string for bash. */
248
383
  function shellQuote(text) {
249
384
  return `'${text.replace(/'/g, `'\\''`)}'`;
@@ -611,7 +746,7 @@ function generateAgents(ast) {
611
746
  const scriptName = scriptNameFromRun(hook.run);
612
747
  if (isCommandHook(hook) && !scriptName)
613
748
  continue; // V96: prose in run names no script
614
- 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}`)}`);
615
750
  // JSON strings are valid YAML double-quoted scalars, so a regex
616
751
  // matcher or a prompt with quotes or colons can never break the file.
617
752
  hookLines.push(hook.matcher ? ` - matcher: ${JSON.stringify(hook.matcher)}` : ` -`);
@@ -1900,7 +2035,7 @@ function generateGateScripts(ast) {
1900
2035
  const project = isProjectPath(script);
1901
2036
  files.push({
1902
2037
  path: project ? script.replace(/^\.\//, "") : `.claude/skills/${name}/scripts/${scriptName}`,
1903
- content: shellStub(`Gate: ${gate.id} — ${gate.run}`),
2038
+ content: shellStub(`Gate: ${gate.id} — ${gate.run}`, { fail: true }),
1904
2039
  category: "script",
1905
2040
  ...(project ? { createOnly: true } : {}),
1906
2041
  });
@@ -1930,7 +2065,11 @@ function generateGateWrapperScripts(ast) {
1930
2065
  const wiredAsHook = wiring.kind === "subagent-stop";
1931
2066
  let enforcementNote;
1932
2067
  let enforcementNote2;
1933
- 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") {
1934
2073
  enforcementNote = `# Enforcement: PreToolUse hook (before-agent.sh) before Claude Code launches "${wiring.agents.join('", "')}".`;
1935
2074
  enforcementNote2 = `# Exit 2 blocks the launch and surfaces stderr to Claude.`;
1936
2075
  }
@@ -1953,6 +2092,7 @@ function generateGateWrapperScripts(ast) {
1953
2092
  enforcementNote,
1954
2093
  enforcementNote2,
1955
2094
  "set -euo pipefail",
2095
+ ...FAIL_CLOSED,
1956
2096
  "",
1957
2097
  "# Gate runs after: " + (gateAnchors(gate, "after").join(", ") || "any"),
1958
2098
  "# Gate runs before: " + (gateAnchors(gate, "before").join(", ") || "any"),
@@ -1997,6 +2137,7 @@ function generateGateWrapperScripts(ast) {
1997
2137
  contentLines.push(` echo "Gate '${gate.id}' FAILED (exit code $GATE_RESULT)"`);
1998
2138
  contentLines.push(` ${onFailExit}`);
1999
2139
  contentLines.push("fi");
2140
+ contentLines.push(FAIL_CLOSED_DONE);
2000
2141
  contentLines.push("");
2001
2142
  const content = contentLines.join("\n");
2002
2143
  files.push({
@@ -2176,6 +2317,8 @@ function generateHookScripts(ast) {
2176
2317
  // bash stub. generateModPlugin imports it instead.
2177
2318
  if (!isCommandHook(hook))
2178
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
2179
2322
  const runPath = hook.run;
2180
2323
  // If the run path starts with '.' or '/' it is already project-relative —
2181
2324
  // generate the stub at exactly that path (observability pattern).
@@ -2219,6 +2362,8 @@ function generateAgentHookScripts(ast) {
2219
2362
  for (const hook of agent.hooks) {
2220
2363
  if (!hook.run || !isCommandHook(hook))
2221
2364
  continue;
2365
+ if (isShellCommand(hook.run))
2366
+ continue; // run as written: no script to stub
2222
2367
  const scriptName = scriptNameFromRun(hook.run);
2223
2368
  if (!scriptName)
2224
2369
  continue; // V96: prose in run names no script
@@ -2239,6 +2384,15 @@ function generateAgentHookScripts(ast) {
2239
2384
  }
2240
2385
  return files;
2241
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
+ }
2242
2396
  /**
2243
2397
  * The shell command for a command hook's or gate's `run:`.
2244
2398
  *
@@ -2251,10 +2405,12 @@ function generateAgentHookScripts(ast) {
2251
2405
  * Any other `run:` is a script this topology's skill scaffolds, by basename.
2252
2406
  */
2253
2407
  export function hookCommand(run, topologyName) {
2408
+ if (isShellCommand(run))
2409
+ return run.trim();
2254
2410
  const { interpreter: named, script: first, args: rest } = runParts(run);
2255
2411
  if (!isProjectPath(first)) {
2256
2412
  const base = first.replace(/^.*\//, "");
2257
- return [named ?? "bash", `.claude/skills/${topologyName}/scripts/${base}`, ...rest].join(" ");
2413
+ return [named ?? "bash", skillScriptPath(topologyName, base), ...rest].join(" ");
2258
2414
  }
2259
2415
  const interpreter = named ?? (first.endsWith(".py") ? "python3" : "bash");
2260
2416
  const where = first.startsWith("/")
@@ -2366,6 +2522,8 @@ function generateSettings(ast) {
2366
2522
  const wiring = gateWiring(ast, gate);
2367
2523
  if (wiring.kind === "before-agent")
2368
2524
  continue;
2525
+ if (checkableActionGate(ast, gate))
2526
+ continue; // before-action.sh runs it
2369
2527
  if (wiring.kind !== "subagent-stop") {
2370
2528
  const bad = anchors.filter((a) => !isRegisteredSubagentType(ast, a));
2371
2529
  const targetDesc = anchors.length
@@ -2380,7 +2538,7 @@ function generateSettings(ast) {
2380
2538
  if (!hooksByEvent[eventName])
2381
2539
  hooksByEvent[eventName] = [];
2382
2540
  const scriptName = `gate-${gate.id}.sh`;
2383
- const command = `bash .claude/skills/${topologyName}/scripts/${scriptName}`;
2541
+ const command = `bash ${skillScriptPath(topologyName, scriptName)}`;
2384
2542
  // ONE ENTRY PER ANCHOR. A SubagentStop matcher is a STRING — an array would
2385
2543
  // land in settings.json, type-check clean through Record<string, unknown>,
2386
2544
  // and the hook would simply never fire. Silent, and the gate would look
@@ -2411,7 +2569,18 @@ function generateSettings(ast) {
2411
2569
  hooksByEvent.PreToolUse.push({
2412
2570
  // Agent is the subagent tool; Task is its earlier name.
2413
2571
  matcher: "Agent|Task",
2414
- hooks: [{ type: "command", command: beforeAgentCommand(ast) }],
2572
+ // onFailure "block" (Claude Code 2.1.295+): a script that cannot start,
2573
+ // times out or exits other than 0/2 blocks the launch instead of
2574
+ // letting it through. The script's own EXIT trap covers the rest.
2575
+ hooks: [{ type: "command", command: beforeAgentCommand(ast), onFailure: "block" }],
2576
+ });
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" }],
2415
2584
  });
2416
2585
  }
2417
2586
  const observeHook = buildHybridObserveHook(ast, topologyName);
@@ -3175,7 +3344,7 @@ function buildHybridObserveHook(ast, topologyName) {
3175
3344
  hooks: [
3176
3345
  {
3177
3346
  type: "command",
3178
- command: `bash ${scriptPath}`,
3347
+ command: `bash ${skillScriptPath(topologyName, "observe-blackboard.sh")}`,
3179
3348
  },
3180
3349
  ],
3181
3350
  },
@@ -3376,6 +3545,9 @@ export const claudeCodeBinding = {
3376
3545
  const beforeAgent = generateBeforeAgentScript(ast);
3377
3546
  if (beforeAgent)
3378
3547
  files.push(beforeAgent);
3548
+ const beforeAction = generateBeforeActionScript(ast);
3549
+ if (beforeAction)
3550
+ files.push(beforeAction);
3379
3551
  // Hook scripts (global)
3380
3552
  files.push(...generateHookScripts(ast));
3381
3553
  // Hook scripts (per-agent)