Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,8 @@ jobs:
cache-dependency-path: tools/plugin-manifests/package-lock.json
- run: npm ci
- run: npm run typecheck
# Fails when any plugin's .claude-plugin and .codex-plugin versions differ.
# Fails when any plugin's .claude-plugin and .codex-plugin versions differ,
# or when any plugin registers more than one hook command for an event.
- run: npm test

lint:
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ This repository is a plugin monorepo under `plugins/`.
- Test files use `test_<name>.py`; functions use `snake_case`.
- Plugin artifacts are explicit and frontmatter-driven (`agents`, `commands`, `skills` definitions in Markdown).
- Use existing conventions in each plugin; avoid changing naming style in adjacent files (for example, slash command names and agent IDs remain kebab-case, e.g. `code-review`).
- A plugin registers one hook script per event, routing to its guards inside that script; `tools/plugin-manifests/src/hook-registrations.ts` fails CI on a second command for any event in any plugin's hook config.

## Testing Guidelines
- Framework: `pytest`.
Expand Down
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,29 @@ All notable changes to the claude-plugins project will be documented in this fil

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Entries are listed newest-first; each plugin section is treated as released when merged to `main`.

### code v2.0.2

PreToolUse and SubagentStop each run one registered script, which runs that event's handler scripts and merges their results.

#### Changed
- `hooks.json` registers `hooks/pre-tool-use.sh` for PreToolUse and `hooks/subagent-stop.sh` for SubagentStop, in place of two commands each. `pre-tool-use.sh` runs `pretooluse-hook.sh` for Read, Bash, Write and Edit (its old matcher) and `pre-tool-use-hook.sh` for every tool; `subagent-stop.sh` runs `loop-stop-hook.sh` and then `subagent-stop-hook.sh`.
- The new `hooks/dispatch.sh` passes a single handler's output through unchanged. Several JSON outputs merge: the strongest permission decision (deny, defer, ask, allow) or a block wins with every reason joined, and every context and system message is kept. Plain text is printed when no handler produced JSON. A handler exiting 2 still blocks; one exiting with another code contributes nothing, and its exit code is kept when no other handler produced output.
- Each handler reads the hook input from a pipe `dispatch.sh` writes, not from the stdin Claude Code opened for the event.
- SubagentStop handlers now run in sequence, `loop-stop-hook.sh` first, so it always reads the `.agent-types` record before `subagent-stop-hook.sh` removes it. They previously ran in parallel.

### vibe v1.0.82

Each hook event now runs one vibe script, which routes to every guard in a single process.

#### Changed
- `hooks.json` registers one PreToolUse command, `hooks/pre-tool-use.mjs` (matcher `Bash|Agent|SendMessage`), and one UserPromptSubmit command, `hooks/user-prompt-submit.mjs`, in place of seven per-guard commands. The guards are modules the two scripts route to by tool name.
- Several guard results for one call merge into one output: the strongest decision wins (deny, then defer, ask, allow), carrying every reason given for it in guard order, and every guard's context is kept. A single guard's output is unchanged byte for byte.
- A guard that throws is still reported on stderr under its own name and contributes nothing, so the other guards still decide; unreadable hook input fails open. The scripts always exit 0, as each guard did.
- `kill-guard.mjs` remains the `own <pid>` recorder; run without `own` it now prints its usage and exits 1.

#### Added
- A repository check in `tools/plugin-manifests` fails CI when any plugin's hook configuration (each `hooks.json` in the plugin tree or a `hooks` field in either manifest) registers more than one handler for an event. It has no exceptions.

### vibe v1.0.81

The 2026-10-23 and 2026-10-24 fleet lessons now have hooks, launcher behavior and evidence gates instead of prose alone.
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ The orchestrator prompt at `plugins/code/prompts/prompt.md` coordinates 8 workfl

### Hooks

Registered in `plugins/code/hooks/hooks.json` across 5 lifecycle events: `SessionStart`, `SessionEnd`, `SubagentStart`, `SubagentStop`, `PreToolUse`. `SubagentStart` injects environment variables and relevant learning patterns. `SubagentStop` logs outcomes and telemetry. `PreToolUse` fires on `Read|Bash|Write|Edit` for just-in-time pattern injection.
Registered in `plugins/code/hooks/hooks.json`, one command per event (see AGENTS.md). `SubagentStart` injects environment variables and relevant learning patterns. `SubagentStop` (`subagent-stop.sh`) runs the loop and learning stop handlers and logs outcomes and telemetry. `PreToolUse` (`pre-tool-use.sh`) runs just-in-time pattern injection for `Read|Bash|Write|Edit` and the tool-call sentinel for every tool.

### TOON Format

Expand Down
2 changes: 1 addition & 1 deletion plugins/code/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "code",
"description": "Code and planning framework plugin",
"version": "2.0.1",
"version": "2.0.2",
"dependencies": ["closedloop-core"],
"author": {
"name": "ClosedLoop",
Expand Down
2 changes: 1 addition & 1 deletion plugins/code/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "code",
"version": "2.0.1",
"version": "2.0.2",
"description": "Code and planning framework plugin.",
"author": {
"name": "ClosedLoop",
Expand Down
16 changes: 10 additions & 6 deletions plugins/code/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,11 @@ Staged pipeline for inventorying a Claude Design export into reviewable findings

## Hooks

Hooks are shell scripts that fire at Claude Code lifecycle events. Registered in `hooks/hooks.json`.
Hooks are shell scripts that fire at Claude Code lifecycle events. Registered in `hooks/hooks.json`, one command per event. PreToolUse and SubagentStop each register one event script that runs that event's handler scripts below through `dispatch.sh`, which merges their results into one output.

### `pre-tool-use.sh` and `subagent-stop.sh` (PreToolUse, SubagentStop)

The registered commands for those two events. `pre-tool-use.sh` runs `pretooluse-hook.sh` for Read, Bash, Write and Edit, then `pre-tool-use-hook.sh` for every tool. `subagent-stop.sh` runs `loop-stop-hook.sh`, then `subagent-stop-hook.sh`. `dispatch.sh` passes a single handler's output through unchanged; when several produce JSON it keeps the strongest permission decision (deny, defer, ask, allow) or a block, with every reason, context and system message joined.

### `session-start-hook.sh` (SessionStart)

Expand All @@ -352,7 +356,7 @@ Runs when any subagent starts. Performs three tasks:
2. **Agent type tracking**: Writes the agent type, short name, and start timestamp to `.agent-types/{agent_id}` so the stop hook can track timing and type.
3. **Learning injection**: Reads `~/.closedloop-ai/learnings/org-patterns.toon`, filters patterns matching the agent's name, sorts by category priority (mistake > convention > pattern > insight) and confidence, and injects up to 15 patterns into the agent's context via `additionalContext`. Also injects environment variables (`CLOSEDLOOP_WORKDIR`, `CLAUDE_PLUGIN_ROOT`, etc.) into every agent's context.

### `subagent-stop-hook.sh` (SubagentStop)
### `subagent-stop-hook.sh` (SubagentStop handler)

Runs when any subagent exits. Performs:

Expand All @@ -361,7 +365,7 @@ Runs when any subagent exits. Performs:
3. **Outcome logging**: Records which injected patterns were applied or merely injected to `outcomes.log` for success rate computation.
4. **Performance instrumentation**: Computes agent wall-clock duration from start timestamp and appends a timing event to `perf.jsonl`.

### `loop-stop-hook.sh` (SubagentStop, runs before subagent-stop-hook)
### `loop-stop-hook.sh` (SubagentStop handler, runs before subagent-stop-hook)

Implements the validation loop for agents registered in `loop-agents.json`. When an agent exits:

Expand All @@ -371,13 +375,13 @@ Implements the validation loop for agents registered in `loop-agents.json`. When
4. If validation passes, allows the agent to exit (returns nothing)
5. If validation fails or the promise is absent, increments the iteration counter and blocks the agent with feedback to continue

### `pretooluse-hook.sh` (PreToolUse, matches Read/Bash/Write/Edit)
### `pretooluse-hook.sh` (PreToolUse handler, for Read/Bash/Write/Edit)

Injects tool-specific learnings just before tool execution. Filters `org-patterns.toon` by tool type (Bash patterns get build/test tags; Write/Edit patterns get language-specific tags based on file extension). Injects up to 10 matching patterns as `additionalContext`. Also auto-allows tool calls targeting `.closedloop-ai/` workspace paths without prompting.

### `pre-tool-use-hook.sh` (PreToolUse)
### `pre-tool-use-hook.sh` (PreToolUse handler, every tool)

Writes per-tool-call sentinel files to `{WORKDIR}/.tool-calls/` so post-tool handling can compute duration and attribution. After resolving the session workdir, it hydrates persisted native run metadata from `.closedloop-ai/config.env` so `tool` and `spawn` rows share the same run id, command, and iteration as the native command. Emits `spawn` perf events for `Agent` tool calls. Registered separately from `pretooluse-hook.sh` and designed to fail open.
Writes per-tool-call sentinel files to `{WORKDIR}/.tool-calls/` so post-tool handling can compute duration and attribution. After resolving the session workdir, it hydrates persisted native run metadata from `.closedloop-ai/config.env` so `tool` and `spawn` rows share the same run id, command, and iteration as the native command. Emits `spawn` perf events for `Agent` tool calls. Runs after `pretooluse-hook.sh` in `pre-tool-use.sh` and is designed to fail open.

### `post-tool-use-hook.sh` (PostToolUse)

Expand Down
144 changes: 144 additions & 0 deletions plugins/code/hooks/dispatch.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
#!/usr/bin/env bash
# Shared library (sourced, not executed) for the code plugin's per-event hook
# scripts. hooks.json registers one command per event; that script reads stdin
# once and calls `dispatch_hooks [--parallel] "$INPUT" handler...`, which runs
# each handler script with the same input, one at a time in the order given
# (or all at once with --parallel, for handlers that share no state, as
# separate hooks.json commands ran), merges in handler order, and prints one
# result (code.claude.com/docs/en/hooks: on exit 0 stdout is read as JSON only
# when it is one object with nothing around it but whitespace).
#
# Merging the handlers that exit 0:
# - An empty object `{}` contributes nothing. When it is all there is, `{}` is printed.
# - A single contribution is printed byte for byte, so one handler firing looks
# exactly as it did when it was its own hooks.json command.
# - Several JSON objects merge: hookSpecificOutput.permissionDecision takes the
# highest of deny > defer > ask > allow (the order Claude Code applies across
# PreToolUse hooks) with every reason given for it joined; a top-level
# decision "block" wins over none with every reason joined; additionalContext
# and systemMessage keep every value; any other field comes from the first
# handler that set it.
# - Plain text (stdout that is not one JSON object) is printed as is when no
# handler produced JSON, and moved to stderr otherwise, so the one JSON object
# still parses. Claude Code reads plain stdout on these events into the debug
# log only, as it reads stderr on exit 0.
#
# Failures keep each handler's own effect: a handler exiting 2 blocks, so the
# dispatcher prints that handler's stderr (the block reason) and exits 2. A
# handler exiting with any other nonzero code contributes nothing (its stdout
# was never parsed before either); the dispatcher exits with that code when no
# handler produced a decision, context, message or text, so the non-blocking
# error notice still appears. Otherwise every handler's stderr is passed
# through in handler order.

# shellcheck disable=SC2034
DISPATCH_MERGE_JQ='
def rank: {"deny": 4, "defer": 3, "ask": 2, "allow": 1}[.] // 0;
def joined($values; $separator): [$values[] | select(type == "string" and . != "")] | if length == 0 then null else join($separator) end;
. as $outs
| (reduce $outs[] as $out ({}; $out * .)) as $base
| ([$outs[] | .hookSpecificOutput.permissionDecision // empty] | max_by(rank)) as $decision
| (if $decision == null then $base
else $base
| .hookSpecificOutput.permissionDecision = $decision
| (joined([$outs[] | select(.hookSpecificOutput.permissionDecision == $decision) | .hookSpecificOutput.permissionDecisionReason]; "\n")) as $reason
| if $reason == null then del(.hookSpecificOutput.permissionDecisionReason) else .hookSpecificOutput.permissionDecisionReason = $reason end
end)
| (joined([$outs[] | .hookSpecificOutput.additionalContext]; "\n\n")) as $context
| (if $context == null then . else .hookSpecificOutput.additionalContext = $context end)
| (if any($outs[]; .decision == "block") then
.decision = "block"
| (joined([$outs[] | select(.decision == "block") | .reason]; "\n\n")) as $reason
| if $reason == null then del(.reason) else .reason = $reason end
else . end)
| (joined([$outs[] | .systemMessage]; "\n\n")) as $message
| (if $message == null then . else .systemMessage = $message end)
'

dispatch_hooks() {
local parallel=0
if [[ "${1:-}" == "--parallel" ]]; then
parallel=1
shift
fi
local input="$1"
shift
local work
work=$(mktemp -d "${TMPDIR:-/tmp}/closedloop-hook.XXXXXX") || return 0
local count=0 handler
for handler in "$@"; do
count=$((count + 1))
if [[ $parallel -eq 1 ]]; then
_dispatch_run "$handler" "$input" "$work/$count" &
else
_dispatch_run "$handler" "$input" "$work/$count"
fi
done
wait

local index status output first_failure=0
local json_files=() text_files=() blocking_errors=() all_errors=() meaningful=()
for ((index = 1; index <= count; index++)); do
read -r status < "$work/$index.status"
all_errors+=("$work/$index.err")
if [[ $status -eq 2 ]]; then
blocking_errors+=("$work/$index.err")
continue
elif [[ $status -ne 0 ]]; then
[[ $first_failure -ne 0 ]] || first_failure=$status
continue
fi
output=""
IFS= read -r -d '' output < "$work/$index.out" || true
output="${output//[[:space:]]/}"
if [[ -z "$output" ]]; then
continue
elif [[ "$output" == "{}" ]]; then
json_files+=("$work/$index.out")
elif jq -se 'length == 1 and (.[0] | type == "object")' "$work/$index.out" > /dev/null 2>&1; then
json_files+=("$work/$index.out")
meaningful+=("$work/$index.out")
else
text_files+=("$work/$index.out")
fi
done

if [[ ${#blocking_errors[@]} -gt 0 ]]; then
_dispatch_cat_to_stderr "${blocking_errors[@]}"
rm -rf "$work"
exit 2
fi
_dispatch_cat_to_stderr ${all_errors[@]+"${all_errors[@]}"}

local file
if [[ ${#meaningful[@]} -gt 0 ]]; then
_dispatch_cat_to_stderr ${text_files[@]+"${text_files[@]}"}
if [[ ${#meaningful[@]} -eq 1 ]]; then
cat "${meaningful[0]}"
else
jq -cs "$DISPATCH_MERGE_JQ" "${meaningful[@]}"
fi
elif [[ ${#text_files[@]} -gt 0 ]]; then
for file in "${text_files[@]}"; do cat "$file"; done
elif [[ $first_failure -ne 0 ]]; then
rm -rf "$work"
exit "$first_failure"
elif [[ ${#json_files[@]} -gt 0 ]]; then
cat "${json_files[0]}"
fi
rm -rf "$work"
exit 0
}

_dispatch_run() {
local status=0
printf '%s' "$2" | "$1" > "$3.out" 2> "$3.err" || status=$?
printf '%s' "$status" > "$3.status"
}

_dispatch_cat_to_stderr() {
local file
for file in "$@"; do
if [[ -s "$file" ]]; then cat "$file" >&2; fi
done
}
Loading
Loading