From 249733c1645581fb9d2e75feb56eb7015a0477ca Mon Sep 17 00:00:00 2001 From: Parfii-bot Date: Wed, 22 Apr 2026 15:42:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(v0.17.1):=20orchestrator-dirty-check=20hoo?= =?UTF-8?q?k=20=E2=80=94=20prevent=20uncommitted-output=20compounding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PreToolUse:Agent advisory — warns orchestrator if git status is dirty before spawning next agent. Closes the workflow gap that caused 28 uncommitted files across 5 bundles on main (2026-04-22 incident). hooks/orchestrator-dirty-check.sh (51 LOC, POSIX sh): - Sources _lib/gate.sh, respects KEI_DISABLED_HOOKS - Reads git status --porcelain at repo root - Emits stderr advisory with modified/untracked counts + sample - Exit 0 always (advisory, not blocking) - Bypass: ORCHESTRATOR_META=1 (existing RULE 0.13 flag) or ORCHESTRATOR_DIRTY_OK=1 (new, explicit) - Severity: warn — per RULE 0.10 ladder; upgrade to enforce only after 2nd recurrence hooks/_lib/test-orchestrator-dirty-check.sh (60 LOC): - 5 test cases with mocked git PATH shim - Clean / dirty-modified / dirty-untracked / env-bypass / gate-bypass - PASS 5/5 (existing gate.sh tests unchanged — 11/11) Wired into hooks/hooks.json (plugin format) and settings-snippet.json (classic install) at PreToolUse/Agent matcher. skills/hooks-control/SKILL.md — hook list 9 → 10. README.md — hook table gains 1 row; count marker left at 9 for scripts/regen-counts.sh to update post-merge. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 3 +- hooks/_lib/test-orchestrator-dirty-check.sh | 60 +++++++++++++++++++++ hooks/hooks.json | 5 ++ hooks/orchestrator-dirty-check.sh | 51 ++++++++++++++++++ settings-snippet.json | 5 ++ skills/hooks-control/SKILL.md | 8 +-- 6 files changed, 127 insertions(+), 5 deletions(-) create mode 100755 hooks/_lib/test-orchestrator-dirty-check.sh create mode 100755 hooks/orchestrator-dirty-check.sh diff --git a/README.md b/README.md index 9023321..44af8de 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ Interactive wizard: run `/hooks-control` — click-only picker that shows curren |---|---:|---| | Behavioral blocks | 79 | `baseline`, `evidence-grading`, `rule-math-first`, `stack-rust-axum`, `stack-react-vite`, `stack-sveltekit`, `stack-astro`, `deploy-modal`, `api-fal-ai`, ... | | Generic agents (manifests) | 12 | `kei-code-implementer`, `kei-critic`, `kei-validator`, `kei-security-auditor`, `kei-architect`, `kei-researcher`, `kei-ml-implementer`, `kei-cost-guardian`, `kei-modal-runner`, ... | -| Hooks (PreToolUse / PostToolUse) | 9 | `assemble-agents`, `assemble-validate`, `no-hand-edit-agents`, `tomd-preread`, `agent-fork-logger`, `site-wysiwyd-check`, `session-end-dump`, `milestone-commit-hook`, `error-spike-detector` | +| Hooks (PreToolUse / PostToolUse) | 9 | `assemble-agents`, `assemble-validate`, `no-hand-edit-agents`, `tomd-preread`, `agent-fork-logger`, `orchestrator-dirty-check`, `site-wysiwyd-check`, `session-end-dump`, `milestone-commit-hook`, `error-spike-detector` | | Portable skills | 39 | `compose-solution`, `new-agent`, `new-project`, `site-create`, `schema-design`, `observability-setup`, `auth-setup`, `api-design`, `ci-scaffold`, `test-matrix`, `docs-scaffold`, `vm-provision`, ... | | Primitives (Rust crates, opt-in) | 24 | `kei-ledger`, `kei-migrate`, `kei-changelog`, `ssh-check`, `firewall-diff`, `mock-render`, `visual-diff`, `tokens-sync`, `kei-memory`, `kei-conflict-scan`, `kei-refactor-engine`, `kei-graph-check`, `kei-store`, `kei-router`, `kei-sage`, `kei-task`, `kei-chat-store`, `kei-crossdomain`, `kei-search-core`, `kei-content-store`, `kei-social-store`, `kei-curator`, `kei-auth` | | Primitives (shell, opt-in via profile) | 13 | `tomd`, `design-scrape`, `live-preview`, `figma-tokens`, `frontend-inspect`, `screenshot-decode`, `metrics-scrape`, `log-ship`, `provision-hetzner`, `provision-vultr`, `harden-base`, `kei-ci-lint`, `kei-docs-scaffold` | @@ -341,6 +341,7 @@ Requires the new `kei-conflict-scan`, `kei-refactor-engine`, `kei-graph-check`, - **`no-hand-edit-agents`** (PreToolUse, Edit/Write) — refuses edits to any `.md` under `~/.claude/agents/` that starts with the `` marker, pointing you at the manifest instead. Override with `AGENT_MIGRATION=1` for emergencies only. - **`tomd-preread`** (PreToolUse, Read) — auto-converts opaque binary formats (`.docx`, `.doc`, `.xlsx`, `.pptx`, `.csv`) to markdown via the `tomd` primitive and redirects Claude to read the cached `.md` instead. - **`agent-fork-logger`** (PreToolUse, Agent) — RULE 0.12 advisory: logs every Agent subagent invocation to the `kei-ledger` SQLite DB so the orchestrator can validate the fork bundle. Never blocks; silent no-op if `kei-ledger` is absent. +- **`orchestrator-dirty-check`** (PreToolUse, Agent) — RULE 0.13 advisory: stderr-warns when `git status --porcelain` of the current repo is non-empty before spawning a sub-agent, so orchestrators don't compound uncommitted output across parallel agents. Never blocks; bypass with `ORCHESTRATOR_DIRTY_OK=1` (per-call) or `ORCHESTRATOR_META=1` (meta-orchestrator). - **`site-wysiwyd-check`** (PostToolUse, Edit/Write) — on frontend-source edits (`.tsx`, `.vue`, `.svelte`, `.astro`, `.css`, `.html`, `.jsx`, `.ts`) in a project with a live dev server (`.keisei/dev-server.pid`), takes a Playwright screenshot via `mock-render` and diffs against `.keisei/target.png` via `visual-diff`. Advisory-only — drift is reported to stderr, never blocks. - **`session-end-dump`** (Stop event) — RULE 0.14 self-audit: archives the session JSONL trace and ingests it into `kei-memory`. - **`milestone-commit-hook`** (PostToolUse, Bash) — RULE 0.14 self-audit: appends a one-line session summary to `~/.claude/memory/audit-backlog.md` on every `feat:`/`refactor:`/merge commit. diff --git a/hooks/_lib/test-orchestrator-dirty-check.sh b/hooks/_lib/test-orchestrator-dirty-check.sh new file mode 100755 index 0000000..6f730ed --- /dev/null +++ b/hooks/_lib/test-orchestrator-dirty-check.sh @@ -0,0 +1,60 @@ +#!/bin/sh +# hooks/_lib/test-orchestrator-dirty-check.sh — POSIX sh harness for +# orchestrator-dirty-check.sh. Run: sh hooks/_lib/test-orchestrator-dirty-check.sh +# +# Mocks `git` via a PATH-shim so tests are hermetic (no real repo needed). + +set -u + +_TEST_DIR=$(cd "$(dirname "$0")" 2>/dev/null && pwd) +_HOOK="$_TEST_DIR/../orchestrator-dirty-check.sh" +_SHIM_DIR=$(mktemp -d 2>/dev/null || mktemp -d -t 'odc') +trap 'rm -rf "$_SHIM_DIR"' EXIT + +# write_git_shim +write_git_shim() { + cat > "$_SHIM_DIR/git" <&1 >/dev/null) + _rc=$? + if [ "$_rc" != "0" ]; then + printf 'FAIL %s: rc=%s (expected 0)\n' "$_name" "$_rc" >&2; exit 1 + fi + case "$_expect_err" in + empty) [ -z "$_err" ] || { printf 'FAIL %s: expected no stderr, got:\n%s\n' "$_name" "$_err" >&2; exit 1; } ;; + *) printf '%s' "$_err" | grep -q "$_expect_err" || { printf 'FAIL %s: stderr missing %s:\n%s\n' "$_name" "$_expect_err" "$_err" >&2; exit 1; } ;; + esac + _pass=$((_pass+1)) +} + +# Case 1 — clean repo → exit 0, no stderr +write_git_shim '' +run_case clean_no_stderr empty env ORCHESTRATOR_META= ORCHESTRATOR_DIRTY_OK= KEI_DISABLED_HOOKS= + +# Case 2 — dirty repo (1 modified + 1 untracked) → exit 0, stderr has counts +write_git_shim ' M hooks/a.sh +?? hooks/b.sh' +run_case dirty_stderr '1 modified' env ORCHESTRATOR_META= ORCHESTRATOR_DIRTY_OK= KEI_DISABLED_HOOKS= +run_case dirty_stderr_untracked '1 untracked' env ORCHESTRATOR_META= ORCHESTRATOR_DIRTY_OK= KEI_DISABLED_HOOKS= + +# Case 3 — dirty + ORCHESTRATOR_DIRTY_OK=1 → bypass (no stderr) +run_case bypass_env empty env ORCHESTRATOR_DIRTY_OK=1 KEI_DISABLED_HOOKS= + +# Case 4 — dirty + KEI_DISABLED_HOOKS=orchestrator-dirty-check → gate skip +run_case gate_disable empty env ORCHESTRATOR_DIRTY_OK= KEI_DISABLED_HOOKS=orchestrator-dirty-check + +printf 'PASS %d/%d\n' "$_pass" "$_total" +exit 0 diff --git a/hooks/hooks.json b/hooks/hooks.json index f19286a..b2dc64f 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -72,6 +72,11 @@ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/agent-fork-logger.sh", "statusMessage": "agent-fork-logger (RULE 0.12)..." + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/orchestrator-dirty-check.sh", + "statusMessage": "orchestrator-dirty-check (RULE 0.13)..." } ] } diff --git a/hooks/orchestrator-dirty-check.sh b/hooks/orchestrator-dirty-check.sh new file mode 100755 index 0000000..a4a6314 --- /dev/null +++ b/hooks/orchestrator-dirty-check.sh @@ -0,0 +1,51 @@ +#!/bin/sh +# orchestrator-dirty-check.sh — PreToolUse:Agent advisory hook (RULE 0.13). +# Severity: warn — per RULE 0.10, upgrade to enforce only after 2nd recurrence. +# +# Prevents the "uncommitted-agent-output compounding" failure mode: +# orchestrator spawns a new agent while prior-agent output is still +# uncommitted in the main worktree, so N parallel bundles pile up on main +# and require a painful cascade split. +# +# Checks: if the current repo is dirty (`git status --porcelain` non-empty), +# emit a stderr advisory with counts + sample. Never blocks (exit 0 always). +# +# Bypass: set ORCHESTRATOR_META=1 (meta-orchestrator, existing RULE 0.13 +# flag) or ORCHESTRATOR_DIRTY_OK=1 (explicit per-call bypass). +# Gate: respects KEI_DISABLED_HOOKS / KEI_HOOK_PROFILE via _lib/gate.sh. + +_KEI_LIB="$(dirname "$0")/_lib/gate.sh" +if [ -r "$_KEI_LIB" ]; then . "$_KEI_LIB"; kei_hook_gate "orchestrator-dirty-check" || exit 0; fi + +# Env bypass — silent. +if [ "${ORCHESTRATOR_META:-0}" = "1" ] || [ "${ORCHESTRATOR_DIRTY_OK:-0}" = "1" ]; then + exit 0 +fi + +# Git not installed → silent no-op. +command -v git >/dev/null 2>&1 || exit 0 + +# Not in a git repo → silent no-op. +repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0 +[ -n "$repo_root" ] || exit 0 + +# Porcelain status of the repo root. +porcelain=$(git -C "$repo_root" status --porcelain 2>/dev/null) || exit 0 + +# Clean → silent. +[ -n "$porcelain" ] || exit 0 + +# Count modified ( M/A/D/R/C/U in either column, but NOT ?? ) vs untracked (??). +modified=$(printf '%s\n' "$porcelain" | grep -cv '^??' 2>/dev/null || echo 0) +untracked=$(printf '%s\n' "$porcelain" | grep -c '^??' 2>/dev/null || echo 0) +sample=$(printf '%s\n' "$porcelain" | head -n 5) + +{ + printf '[orchestrator-dirty-check] repo %s has uncommitted changes:\n' "$repo_root" + printf ' %s modified, %s untracked\n' "$modified" "$untracked" + printf ' sample (first 5 lines of git status --short):\n' + printf '%s\n' "$sample" | sed 's/^/ /' + printf ' commit or stash before spawning next agent (set ORCHESTRATOR_DIRTY_OK=1 to bypass).\n' +} >&2 + +exit 0 diff --git a/settings-snippet.json b/settings-snippet.json index 99ca59f..4a9c1d0 100644 --- a/settings-snippet.json +++ b/settings-snippet.json @@ -73,6 +73,11 @@ "type": "command", "command": "~/.claude/hooks/agent-fork-logger.sh", "statusMessage": "agent-fork-logger (RULE 0.12)..." + }, + { + "type": "command", + "command": "~/.claude/hooks/orchestrator-dirty-check.sh", + "statusMessage": "orchestrator-dirty-check (RULE 0.13)..." } ] } diff --git a/skills/hooks-control/SKILL.md b/skills/hooks-control/SKILL.md index 024bef9..2ebfe3e 100644 --- a/skills/hooks-control/SKILL.md +++ b/skills/hooks-control/SKILL.md @@ -45,10 +45,10 @@ Active kit-shipped hooks: ### Phase 2a — Hook multi-select (if picked 1) -`AskUserQuestion` multi-select over the 9 kit-shipped hook names: +`AskUserQuestion` multi-select over the 10 kit-shipped hook names: `assemble-agents`, `assemble-validate`, `no-hand-edit-agents`, `tomd-preread`, -`agent-fork-logger`, `site-wysiwyd-check`, `error-spike-detector`, -`milestone-commit-hook`, `session-end-dump`. +`agent-fork-logger`, `orchestrator-dirty-check`, `site-wysiwyd-check`, +`error-spike-detector`, `milestone-commit-hook`, `session-end-dump`. Emit: ```sh @@ -88,7 +88,7 @@ Stop after the state block. exit — the shell running hooks is a subshell. - **No rc edits.** If the user wants persistence, we say "paste into your shell rc". The skill MUST NOT modify `~/.zshrc` / `~/.bashrc`. -- **RULE 0.4 — no invented hook names.** Only the 9 names in Phase 2a +- **RULE 0.4 — no invented hook names.** Only the 10 names in Phase 2a are valid choices. Never suggest a name not in the kit. - **RULE -1 — NO DOWNGRADE.** If the user asks "can I silence all safety hooks?", present tradeoffs; point at `KEI_HOOK_PROFILE=off` with a