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.
- package/README.md +17 -19
- package/dist/__tests__/e2e/action-guard.test.d.ts +13 -0
- package/dist/__tests__/e2e/action-guard.test.d.ts.map +1 -0
- package/dist/__tests__/e2e/action-guard.test.js +299 -0
- package/dist/__tests__/e2e/action-guard.test.js.map +1 -0
- package/dist/__tests__/e2e/conformance.test.d.ts +13 -0
- package/dist/__tests__/e2e/conformance.test.d.ts.map +1 -0
- package/dist/__tests__/e2e/conformance.test.js +109 -0
- package/dist/__tests__/e2e/conformance.test.js.map +1 -0
- package/dist/__tests__/e2e/contract-drift.test.d.ts +11 -0
- package/dist/__tests__/e2e/contract-drift.test.d.ts.map +1 -0
- package/dist/__tests__/e2e/contract-drift.test.js +142 -0
- package/dist/__tests__/e2e/contract-drift.test.js.map +1 -0
- package/dist/__tests__/e2e/guard-check.test.d.ts +9 -0
- package/dist/__tests__/e2e/guard-check.test.d.ts.map +1 -0
- package/dist/__tests__/e2e/guard-check.test.js +316 -0
- package/dist/__tests__/e2e/guard-check.test.js.map +1 -0
- package/dist/__tests__/e2e/guard-target.test.d.ts +11 -0
- package/dist/__tests__/e2e/guard-target.test.d.ts.map +1 -0
- package/dist/__tests__/e2e/guard-target.test.js +198 -0
- package/dist/__tests__/e2e/guard-target.test.js.map +1 -0
- package/dist/__tests__/e2e/inventory.test.d.ts +18 -0
- package/dist/__tests__/e2e/inventory.test.d.ts.map +1 -0
- package/dist/__tests__/e2e/inventory.test.js +187 -0
- package/dist/__tests__/e2e/inventory.test.js.map +1 -0
- package/dist/__tests__/e2e/matcher.test.d.ts +14 -0
- package/dist/__tests__/e2e/matcher.test.d.ts.map +1 -0
- package/dist/__tests__/e2e/matcher.test.js +145 -0
- package/dist/__tests__/e2e/matcher.test.js.map +1 -0
- package/dist/__tests__/e2e/step-check.test.d.ts +9 -0
- package/dist/__tests__/e2e/step-check.test.d.ts.map +1 -0
- package/dist/__tests__/e2e/step-check.test.js +158 -0
- package/dist/__tests__/e2e/step-check.test.js.map +1 -0
- package/dist/__tests__/e2e/strength.test.d.ts +17 -0
- package/dist/__tests__/e2e/strength.test.d.ts.map +1 -0
- package/dist/__tests__/e2e/strength.test.js +130 -0
- package/dist/__tests__/e2e/strength.test.js.map +1 -0
- package/dist/__tests__/e2e/verified-on.test.d.ts +2 -0
- package/dist/__tests__/e2e/verified-on.test.d.ts.map +1 -0
- package/dist/__tests__/e2e/verified-on.test.js +57 -0
- package/dist/__tests__/e2e/verified-on.test.js.map +1 -0
- package/dist/__tests__/open-findings.test.js +1 -0
- package/dist/__tests__/open-findings.test.js.map +1 -1
- package/dist/bindings/__tests__/bindings.test.js +6 -686
- package/dist/bindings/__tests__/bindings.test.js.map +1 -1
- package/dist/bindings/__tests__/defaults-agreement.test.js +0 -12
- package/dist/bindings/__tests__/defaults-agreement.test.js.map +1 -1
- package/dist/bindings/__tests__/hook-command.test.js +2 -2
- package/dist/bindings/__tests__/hook-command.test.js.map +1 -1
- package/dist/bindings/__tests__/mod-plugin.test.js +2 -2
- package/dist/bindings/__tests__/mod-plugin.test.js.map +1 -1
- package/dist/bindings/claude-code.d.ts +45 -1
- package/dist/bindings/claude-code.d.ts.map +1 -1
- package/dist/bindings/claude-code.js +181 -15
- package/dist/bindings/claude-code.js.map +1 -1
- package/dist/bindings/index.d.ts +5 -5
- package/dist/bindings/index.d.ts.map +1 -1
- package/dist/bindings/index.js +9 -16
- package/dist/bindings/index.js.map +1 -1
- package/dist/bindings/types.d.ts +6 -1
- package/dist/bindings/types.d.ts.map +1 -1
- package/dist/cli/__tests__/cli.test.js +25 -0
- package/dist/cli/__tests__/cli.test.js.map +1 -1
- package/dist/cli/index.js +222 -8
- package/dist/cli/index.js.map +1 -1
- package/dist/conformance/claude-code.d.ts +99 -0
- package/dist/conformance/claude-code.d.ts.map +1 -0
- package/dist/conformance/claude-code.js +242 -0
- package/dist/conformance/claude-code.js.map +1 -0
- package/dist/conformance/extract.d.ts +70 -0
- package/dist/conformance/extract.d.ts.map +1 -0
- package/dist/conformance/extract.js +185 -0
- package/dist/conformance/extract.js.map +1 -0
- package/dist/docs/content.d.ts.map +1 -1
- package/dist/docs/content.js +57 -32
- package/dist/docs/content.js.map +1 -1
- package/dist/guard/check.d.ts +53 -0
- package/dist/guard/check.d.ts.map +1 -0
- package/dist/guard/check.js +366 -0
- package/dist/guard/check.js.map +1 -0
- package/dist/guard/target.d.ts +45 -0
- package/dist/guard/target.d.ts.map +1 -0
- package/dist/guard/target.js +168 -0
- package/dist/guard/target.js.map +1 -0
- package/dist/import/__tests__/import.test.js +0 -554
- package/dist/import/__tests__/import.test.js.map +1 -1
- package/dist/import/index.d.ts +0 -1
- package/dist/import/index.d.ts.map +1 -1
- package/dist/import/index.js +1 -7
- package/dist/import/index.js.map +1 -1
- package/dist/import/serializer.d.ts.map +1 -1
- package/dist/import/serializer.js +41 -0
- package/dist/import/serializer.js.map +1 -1
- package/dist/index.d.ts +0 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -4
- package/dist/index.js.map +1 -1
- package/dist/inventory/index.d.ts +65 -0
- package/dist/inventory/index.d.ts.map +1 -0
- package/dist/inventory/index.js +272 -0
- package/dist/inventory/index.js.map +1 -0
- package/dist/parser/ast.d.ts +46 -0
- 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 +66 -4
- package/dist/parser/index.js.map +1 -1
- package/dist/parser/known-fields.d.ts +3 -0
- package/dist/parser/known-fields.d.ts.map +1 -1
- package/dist/parser/known-fields.js +5 -3
- package/dist/parser/known-fields.js.map +1 -1
- package/dist/parser/ledger.d.ts +36 -0
- package/dist/parser/ledger.d.ts.map +1 -0
- package/dist/parser/ledger.js +72 -0
- package/dist/parser/ledger.js.map +1 -0
- package/dist/parser/run.d.ts +6 -0
- package/dist/parser/run.d.ts.map +1 -1
- package/dist/parser/run.js +10 -0
- package/dist/parser/run.js.map +1 -1
- package/dist/parser/validator.d.ts.map +1 -1
- package/dist/parser/validator.js +255 -1
- package/dist/parser/validator.js.map +1 -1
- package/dist/scaffold/manifest.d.ts.map +1 -1
- package/dist/scaffold/manifest.js +0 -8
- package/dist/scaffold/manifest.js.map +1 -1
- package/dist/scaffold/merge.d.ts.map +1 -1
- package/dist/scaffold/merge.js +1 -3
- package/dist/scaffold/merge.js.map +1 -1
- package/dist/step/check.d.ts +56 -0
- package/dist/step/check.d.ts.map +1 -0
- package/dist/step/check.js +125 -0
- package/dist/step/check.js.map +1 -0
- package/dist/sync/index.d.ts +0 -2
- package/dist/sync/index.d.ts.map +1 -1
- package/dist/sync/index.js +0 -109
- package/dist/sync/index.js.map +1 -1
- package/package.json +3 -1
- package/spec/claude-code/README.md +19 -0
- package/spec/claude-code/hooks-contract.2.1.295.json +143 -0
- package/spec/claude-code/hooks-contract.json +142 -0
- package/spec/grammar.md +45 -7
- package/spec/testing.md +15 -0
- package/spec/validation.md +11 -2
- package/dist/__tests__/live/zz-debug.test.d.ts +0 -2
- package/dist/__tests__/live/zz-debug.test.d.ts.map +0 -1
- package/dist/__tests__/live/zz-debug.test.js +0 -4
- package/dist/__tests__/live/zz-debug.test.js.map +0 -1
- package/dist/bindings/copilot-cli.d.ts +0 -20
- package/dist/bindings/copilot-cli.d.ts.map +0 -1
- package/dist/bindings/copilot-cli.js +0 -1516
- package/dist/bindings/copilot-cli.js.map +0 -1
- package/dist/bindings/gemini-cli.d.ts +0 -18
- package/dist/bindings/gemini-cli.d.ts.map +0 -1
- package/dist/bindings/gemini-cli.js +0 -1318
- package/dist/bindings/gemini-cli.js.map +0 -1
- package/dist/bindings/kiro.d.ts +0 -17
- package/dist/bindings/kiro.d.ts.map +0 -1
- package/dist/bindings/kiro.js +0 -1615
- package/dist/bindings/kiro.js.map +0 -1
- package/dist/bindings/openclaw.d.ts +0 -18
- package/dist/bindings/openclaw.d.ts.map +0 -1
- package/dist/bindings/openclaw.js +0 -1947
- package/dist/bindings/openclaw.js.map +0 -1
- package/dist/import/openclaw.d.ts +0 -70
- package/dist/import/openclaw.d.ts.map +0 -1
- package/dist/import/openclaw.js +0 -1060
- 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 {
|
|
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
|
|
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
|
|
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 (
|
|
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",
|
|
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
|
|
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 ${
|
|
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)
|