From aa8333ccdaeea87bfea73fe33f89faec42354e66 Mon Sep 17 00:00:00 2001 From: Parfii-bot Date: Thu, 23 Apr 2026 02:51:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(agent-substrate/phase-4):=20hook=20wiring?= =?UTF-8?q?=20=E2=80=94=203-line=20glue=20for=20kei-capability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PreToolUse hooks route through kei-capability check when orchestrator registers a capability via KEI_CAPABILITY_NAME env var on agent spawn. hooks/agent-capability-check.sh (22 LOC): - Pass-through (exit 0) when KEI_CAPABILITY_NAME unset — no-op by default - Fail-open (exit 0) when kei-capability binary missing — kit convention - Sources _lib/gate.sh for KEI_DISABLED_HOOKS / KEI_HOOK_PROFILE respect - exec kei-capability check "$CAP_NAME" when active hooks/agent-capability-verify.sh (24 LOC): - Orchestrator-driven, NOT a Claude Code native hook - Carries env: AGENT_ID, TASK_TOML, WORKTREE_PATH, MAIN_REPO, RUN_MODE - exec kei-capability verify "$CAP_NAME" Registered in hooks/hooks.json + settings-snippet.json under both PreToolUse:Bash and PreToolUse:Edit|Write matchers. Internal NotApplicable returns exit 0 so non-matching tool calls cost nothing. install.sh unchanged — hooks/*.sh glob picks up both new files. tests/hook_wiring_integration.sh (64 LOC) — 3 contract assertions: (1) pass-through on unset KEI_CAPABILITY_NAME (2) deny+exit 2 on git-op pattern (3) allow+exit 0 on cargo-check pattern Multi-capability routing (for phase 5): KEI_CAPABILITY_NAME currently holds ONE name. When a role requires N capabilities, orchestrator will either iterate or kei-capability gains a compose subcommand. Design note left for phase 5. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/AGENT-SUBSTRATE-SCHEMA.md | 8 ++-- hooks/agent-capability-check.sh | 27 +++++++++++++ hooks/agent-capability-verify.sh | 29 ++++++++++++++ hooks/hooks.json | 10 +++++ settings-snippet.json | 10 +++++ tests/hook_wiring_integration.sh | 66 ++++++++++++++++++++++++++++++++ 6 files changed, 146 insertions(+), 4 deletions(-) create mode 100755 hooks/agent-capability-check.sh create mode 100755 hooks/agent-capability-verify.sh create mode 100755 tests/hook_wiring_integration.sh diff --git a/docs/AGENT-SUBSTRATE-SCHEMA.md b/docs/AGENT-SUBSTRATE-SCHEMA.md index 7509122..959accd 100644 --- a/docs/AGENT-SUBSTRATE-SCHEMA.md +++ b/docs/AGENT-SUBSTRATE-SCHEMA.md @@ -92,9 +92,9 @@ _primitives/_rust/kei-capability/ — BINARY (phase 3) kei-capability check (stdin JSON, exit 0|2) kei-capability verify (env-driven, exit 0 or fail) -hooks/ — 3-line shell glue (phase 4) -├── agent-capability-check.sh — `exec kei-capability check "$CAP_NAME" "$@"` -└── agent-capability-verify.sh — called by orchestrator post-agent +hooks/ — 3-line shell glue (phase 4 ✓ shipped) +├── agent-capability-check.sh — `exec kei-capability check "$KEI_CAPABILITY_NAME"` — PreToolUse:Bash|Edit|Write, no-op when env unset, fail-open on missing binary +└── agent-capability-verify.sh — orchestrator-driven post-agent: `exec kei-capability verify "$KEI_CAPABILITY_NAME"` with AGENT_ID/TASK_TOML/WORKTREE_PATH/MAIN_REPO/RUN_MODE env tasks/ — ephemeral, gitignored └── /{task.toml, prompt.md} @@ -495,7 +495,7 @@ Execution flow: | 1 | Capability library — 10 × (`capability.toml` + `text.md`) = **20 declarative files** | phase 0 | 1 code-implementer | 1-2 days | | 2 | Role matrix — 5 `_roles/*.toml` + auto-gen `docs/AGENT-ROLES.md` | phase 0 | 1 code-implementer | 0.5 day | | 3 | `kei-agent-runtime` + `kei-capability` binaries — compose/spawn/verify CLI + 6 gate modules + 8 verify modules + registry + simulated-merge executor | phase 0 | 1 code-implementer | 5-6 days | -| 4 | Hook wiring — `agent-capability-check.sh` + `agent-capability-verify.sh` 3-line glue + settings.json registration | phases 1+3 | 1 code-implementer | 0.5 day | +| 4 ✓ | Hook wiring — `agent-capability-check.sh` + `agent-capability-verify.sh` 3-line glue + settings.json registration | phases 1+3 | 1 code-implementer | 0.5 day (shipped) | | 5 | Migration — 5 custom agents (code-implementer / critic / architect / security-auditor / validator) adopt role+task-spec invocation | phases 1+2+3+4 | 1 code-implementer | 1 day | **Phases 1, 2, 3 start in parallel immediately after lock** (different dirs, zero file overlap). diff --git a/hooks/agent-capability-check.sh b/hooks/agent-capability-check.sh new file mode 100755 index 0000000..520c312 --- /dev/null +++ b/hooks/agent-capability-check.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# agent-capability-check.sh — 3-line hook glue (Agent Substrate v1, phase 4). +# +# Claude-Code hook adapter that routes a PreToolUse event (Bash|Edit|Write) +# to `kei-capability check `. The capability name is set +# per-agent by the orchestrator via env $KEI_CAPABILITY_NAME at Agent spawn +# time; Claude Code's hook protocol has no per-spawn scoping, so this script +# NO-OPs (exit 0, pass-through) when the env var is unset. +# +# Fail-open convention (RULE 0.13, kit-wide): a missing kei-capability +# binary MUST NOT block all tool use — it exits 0 with a stderr note. +# Block semantics come from the gate logic itself (exit 2 on Deny), never +# from adapter absence. +# +# See docs/AGENT-SUBSTRATE-SCHEMA.md §File layout / §Verify execution. +set -eu + +_KEI_LIB="$(dirname "$0")/_lib/gate.sh" +if [ -r "$_KEI_LIB" ]; then . "$_KEI_LIB"; kei_hook_gate "agent-capability-check" || exit 0; fi + +CAP="${KEI_CAPABILITY_NAME:-}" +[ -z "$CAP" ] && exit 0 +command -v kei-capability >/dev/null 2>&1 || { + echo "[agent-capability-check] kei-capability binary not in PATH — fail-open pass-through" >&2 + exit 0 +} +exec kei-capability check "$CAP" diff --git a/hooks/agent-capability-verify.sh b/hooks/agent-capability-verify.sh new file mode 100755 index 0000000..c083b33 --- /dev/null +++ b/hooks/agent-capability-verify.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# agent-capability-verify.sh — orchestrator-driven verify glue (phase 4). +# +# Called by the orchestrator after agent return (NOT by Claude Code's +# hook protocol directly). The orchestrator sets the full context in env: +# KEI_CAPABILITY_NAME — e.g. "quality::cargo-check-green" +# AGENT_ID — ledger agent id +# TASK_TOML — path to task.toml (parameterizes scope/output caps) +# WORKTREE_PATH — agent's worktree +# MAIN_REPO — orchestrator's main repo root +# RUN_MODE — worktree | simulated-merge +# +# Passes through stdin, stdout, exit code from kei-capability verify. +# Fail-open on missing binary (exit 0 + stderr note) — same convention as +# the check side; absence of the adapter must not crash the merge ceremony. +# +# See docs/AGENT-SUBSTRATE-SCHEMA.md §Verify execution. +set -eu + +CAP="${KEI_CAPABILITY_NAME:-}" +if [ -z "$CAP" ]; then + echo "[agent-capability-verify] KEI_CAPABILITY_NAME unset — nothing to verify" >&2 + exit 0 +fi +command -v kei-capability >/dev/null 2>&1 || { + echo "[agent-capability-verify] kei-capability binary not in PATH — fail-open pass-through" >&2 + exit 0 +} +exec kei-capability verify "$CAP" diff --git a/hooks/hooks.json b/hooks/hooks.json index b2dc64f..73c9e88 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -43,6 +43,11 @@ { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/assemble-validate.sh" + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/agent-capability-check.sh", + "statusMessage": "agent-capability-check (Agent Substrate v1, phase 4)..." } ] }, @@ -52,6 +57,11 @@ { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/no-hand-edit-agents.sh" + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/agent-capability-check.sh", + "statusMessage": "agent-capability-check (Agent Substrate v1, phase 4)..." } ] }, diff --git a/settings-snippet.json b/settings-snippet.json index 4a9c1d0..3f89bee 100644 --- a/settings-snippet.json +++ b/settings-snippet.json @@ -44,6 +44,11 @@ { "type": "command", "command": "~/.claude/hooks/assemble-validate.sh" + }, + { + "type": "command", + "command": "~/.claude/hooks/agent-capability-check.sh", + "statusMessage": "agent-capability-check (Agent Substrate v1, phase 4)..." } ] }, @@ -53,6 +58,11 @@ { "type": "command", "command": "~/.claude/hooks/no-hand-edit-agents.sh" + }, + { + "type": "command", + "command": "~/.claude/hooks/agent-capability-check.sh", + "statusMessage": "agent-capability-check (Agent Substrate v1, phase 4)..." } ] }, diff --git a/tests/hook_wiring_integration.sh b/tests/hook_wiring_integration.sh new file mode 100755 index 0000000..95df060 --- /dev/null +++ b/tests/hook_wiring_integration.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# hook_wiring_integration.sh — phase-4 smoke test for Agent Substrate v1. +# +# Asserts the three contract behaviours of hooks/agent-capability-check.sh: +# 1. KEI_CAPABILITY_NAME unset → exit 0 (pass-through) +# 2. Bash "git push" + policy::no-git-ops → exit 2 (deny) +# 3. Bash "cargo check" + policy::no-git-ops → exit 0 (allow) +# +# Build step: `cargo build --release -p kei-capability` from _primitives/_rust. +# PATH is shimmed to include the freshly-built binary; no sudo, no install. +# +# Exit 0 = all 3 assertions pass +# Exit 1 = any assertion failed — stderr names the offending case + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +HOOK="$ROOT/hooks/agent-capability-check.sh" + +fail() { echo "HOOK-WIRING FAIL: $*" >&2; exit 1; } + +[ -x "$HOOK" ] || chmod +x "$HOOK" 2>/dev/null || fail "hook script not executable: $HOOK" + +echo "==> Building kei-capability release binary…" +cd "$ROOT/_primitives/_rust" +cargo build --release -p kei-capability >/dev/null 2>&1 \ + || fail "cargo build -p kei-capability failed" +BIN_DIR="$(pwd)/target/release" +cd "$ROOT" + +[ -x "$BIN_DIR/kei-capability" ] || fail "kei-capability binary missing at $BIN_DIR" + +export PATH="$BIN_DIR:$PATH" + +# ---- Assertion 1: pass-through when KEI_CAPABILITY_NAME unset ----------- +echo "==> Assertion 1: env unset → pass-through (exit 0)…" +set +e +( unset KEI_CAPABILITY_NAME + echo '{"tool_name":"Bash","tool_input":{"command":"git push"}}' | "$HOOK" >/dev/null 2>&1 +) ; RC=$? +set -e +[ "$RC" -eq 0 ] || fail "unset env must pass-through, got exit $RC" + +# ---- Assertion 2: deny git push under policy::no-git-ops ---------------- +echo "==> Assertion 2: Bash 'git push' under policy::no-git-ops → deny (exit 2)…" +set +e +OUT=$(KEI_CAPABILITY_NAME=policy::no-git-ops \ + echo '{"tool_name":"Bash","tool_input":{"command":"git push"}}' \ + | KEI_CAPABILITY_NAME=policy::no-git-ops "$HOOK" 2>&1) +RC=$? +set -e +[ "$RC" -eq 2 ] || fail "expected exit 2 on git-op deny, got $RC (output: $OUT)" +echo "$OUT" | grep -q "policy::no-git-ops\|RULE 0.13\|git operation blocked" \ + || fail "deny output missing expected marker (output: $OUT)" + +# ---- Assertion 3: allow cargo check under policy::no-git-ops ----------- +echo "==> Assertion 3: Bash 'cargo check' under policy::no-git-ops → allow (exit 0)…" +set +e +OUT=$(echo '{"tool_name":"Bash","tool_input":{"command":"cargo check"}}' \ + | KEI_CAPABILITY_NAME=policy::no-git-ops "$HOOK" 2>&1) +RC=$? +set -e +[ "$RC" -eq 0 ] || fail "cargo check must be allowed by policy::no-git-ops, got exit $RC (output: $OUT)" + +echo "" +echo "✓ HOOK-WIRING PASS — 3/3 assertions (pass-through / deny / allow)"