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.
- 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/compile.test.js +1 -1
- package/dist/__tests__/e2e/compile.test.js.map +1 -1
- 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/fail-closed.test.d.ts +11 -0
- package/dist/__tests__/e2e/fail-closed.test.d.ts.map +1 -0
- package/dist/__tests__/e2e/fail-closed.test.js +111 -0
- package/dist/__tests__/e2e/fail-closed.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 +191 -19
- 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/lib/stub.d.ts +15 -1
- package/dist/bindings/lib/stub.d.ts.map +1 -1
- package/dist/bindings/lib/stub.js +27 -2
- package/dist/bindings/lib/stub.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 +16 -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
|
@@ -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 {
|
|
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
|
|
@@ -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
|
|
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 (
|
|
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",
|
|
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
|
|
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
|
-
|
|
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 ${
|
|
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)
|