feat(v0.17.1): orchestrator-dirty-check hook — prevent uncommitted-output compounding

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) <noreply@anthropic.com>
This commit is contained in:
Parfii-bot 2026-04-22 15:42:11 +08:00
parent a6853134cc
commit 249733c164
6 changed files with 127 additions and 5 deletions

View file

@ -145,7 +145,7 @@ Interactive wizard: run `/hooks-control` — click-only picker that shows curren
|---|---:|---|
| Behavioral blocks | <!-- count:BLOCKS -->79<!-- /count:BLOCKS --> | `baseline`, `evidence-grading`, `rule-math-first`, `stack-rust-axum`, `stack-react-vite`, `stack-sveltekit`, `stack-astro`, `deploy-modal`, `api-fal-ai`, ... |
| Generic agents (manifests) | <!-- count:AGENTS -->12<!-- /count:AGENTS --> | `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) | <!-- count:HOOKS -->9<!-- /count:HOOKS --> | `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) | <!-- count:HOOKS -->9<!-- /count:HOOKS --> | `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 | <!-- count:SKILLS -->39<!-- /count:SKILLS --> | `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) | <!-- count:RUST_CRATES -->24<!-- /count:RUST_CRATES --> | `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) | <!-- count:SHELL_PRIMITIVES -->13<!-- /count:SHELL_PRIMITIVES --> | `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 `<!-- GENERATED -->` 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.

View file

@ -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 <porcelain-output>
write_git_shim() {
cat > "$_SHIM_DIR/git" <<SHIM
#!/bin/sh
case "\$*" in
*"rev-parse --show-toplevel"*) printf '%s\n' "$_SHIM_DIR"; exit 0 ;;
*"status --porcelain"*) printf '%s' "$1"; [ -n "$1" ] && printf '\n'; exit 0 ;;
*) exit 0 ;;
esac
SHIM
chmod +x "$_SHIM_DIR/git"
}
_pass=0; _total=0
run_case() {
_total=$((_total+1))
_name="$1"; _expect_err="$2"; shift 2
_err=$(PATH="$_SHIM_DIR:$PATH" "$@" sh "$_HOOK" </dev/null 2>&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

View file

@ -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)..."
}
]
}

View file

@ -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

View file

@ -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)..."
}
]
}

View file

@ -45,10 +45,10 @@ Active kit-shipped hooks: <list of 9 minus disabled set>
### 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