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:
parent
a6853134cc
commit
249733c164
6 changed files with 127 additions and 5 deletions
|
|
@ -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.
|
||||
|
|
|
|||
60
hooks/_lib/test-orchestrator-dirty-check.sh
Executable file
60
hooks/_lib/test-orchestrator-dirty-check.sh
Executable 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
|
||||
|
|
@ -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)..."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
51
hooks/orchestrator-dirty-check.sh
Executable file
51
hooks/orchestrator-dirty-check.sh
Executable 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
|
||||
|
|
@ -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)..."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue