@webpieces/ai-hook-rules 0.4.661 → 0.4.663

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webpieces/ai-hook-rules",
3
- "version": "0.4.661",
3
+ "version": "0.4.663",
4
4
  "description": "Pluggable write-time validation framework for AI coding agents (@webpieces/ai-hook-rules). Claude Code PreToolUse + openclaw before_tool_call adapters share one rule engine.",
5
5
  "type": "commonjs",
6
6
  "main": "./src/index.js",
@@ -25,7 +25,7 @@
25
25
  "directory": "packages/tooling/ai-hook-rules"
26
26
  },
27
27
  "dependencies": {
28
- "@webpieces/rules-config": "0.4.661"
28
+ "@webpieces/rules-config": "0.4.663"
29
29
  },
30
30
  "publishConfig": {
31
31
  "access": "public"
package/src/bin/shim.js CHANGED
@@ -220,7 +220,7 @@ WP_INSTALL_CMD="pnpm install"
220
220
  WP_BORROW_NOTE=""
221
221
  if [ "$BIN_ROOT" != "$ROOT" ]; then
222
222
  WP_INSTALL_CMD="cd $ROOT && pnpm install"
223
- WP_BORROW_NOTE="\${NL} NOTE: this tree ($ROOT) has NO node_modules of its own, so the guard binary was inherited from $BIN_ROOT by walking up. TWO cures are real and they fix DIFFERENT things: A makes THIS tree work now, B stops the two trees disagreeing. A - run the command above HERE. That is legitimate and it does work; a worktree NEEDS its own node_modules anyway (nx, vitest and the eslint plugin all execute in this tree and load from it). B - get $BIN_ROOT onto the same @webpieces version: put both trees on the same git hash (the pin is tracked) and run ONE 'pnpm install' there. If you are a SUBAGENT you cannot reach that tree, so ESCALATE - ask the coordinator to run 'pnpm install' in the main tree so both trees are on the same @webpieces version. The rule is NOT no-install-here: it is that this tree's @webpieces must EQUAL $BIN_ROOT's, because doing only A leaves two trees on two releases (the trinary-version-skew guard then BLOCKS rather than letting it pass unnoticed). Adding an ordinary third-party dependency here changes none of that - only a differing @webpieces version does. If this tree genuinely needs a DIFFERENT version, use a separate clone rather than a worktree."
223
+ WP_BORROW_NOTE="\${NL} NOTE: this tree ($ROOT) has NO node_modules of its own, so the guard binary was inherited from $BIN_ROOT by walking up. TWO cures are real and they fix DIFFERENT things: A makes THIS tree work now, B stops the two trees disagreeing. A - run the command above HERE. That is legitimate and it does work; a worktree NEEDS its own node_modules anyway (nx, vitest and the eslint plugin all execute in this tree and load from it). B - get $BIN_ROOT onto the same @webpieces version: put both trees on the same git hash (the pin is tracked) and run ONE 'pnpm install' there. If you are a SUBAGENT, B is the half you cannot do: it needs both trees on the same git hash, and CROSS-TREE GIT is refused to you here (a local 'cd' + install does run - it is git -C another tree that is blocked). So ESCALATE B: TELL THE MAIN AGENT in the MAIN git worktree ($BIN_ROOT) to run 'git pull && pnpm install' there so both trees are on the same @webpieces version, and to tell you when it is complete so you can continue. If you escalate instead of doing A, that forwarding IS the end of your turn: STOP WORKING NOW, make NO further tool calls and do NOT retry - RETRYING IS THE BUG, because every retry re-fires this identical deny and buries the ask. WAIT for that confirmation, then resume. The rule is NOT no-install-here: it is that this tree's @webpieces must EQUAL $BIN_ROOT's, because doing only A leaves two trees on two releases (the trinary-version-skew guard then BLOCKS rather than letting it pass unnoticed). Adding an ordinary third-party dependency here changes none of that - only a differing @webpieces version does. If this tree genuinely needs a DIFFERENT version, use a separate clone rather than a worktree."
224
224
  fi`;
225
225
  // Shell fragment: run the installed guard bin and INSPECT its outcome, instead of exec'ing it.
226
226
  //
@@ -1 +1 @@
1
- {"version":3,"file":"shim.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/bin/shim.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\n\nimport { CONFIG_FILENAME, claudeEnv } from '@webpieces/rules-config';\n\nimport {\n L0_FAULT_BIN_BROKEN, L0_FAULT_BIN_MISSING, L0_FAULT_DRIFT, L0_FAULT_UNDECLARED,\n l0GuardHeader, l0MatrixCitation,\n} from '../core/l0-fault-codes';\nimport { toError } from '../core/to-error';\nimport {\n L0_ALLOW_ERE_SH, RECOVERY_CMD, INSTALL_HOOKS_CMD, UPGRADE_SHIM_CMD, RESTORE_SHIM_CMD,\n INSTALL_HOOKS_ALLOW_JS, UPGRADE_SHIM_ALLOW_JS, RESTORE_SHIM_ALLOW_JS,\n ADD_HOOK_PKG_CMD, HOOK_PKG,\n} from './l0-allowlist';\nimport { WP_LOG_SH } from './shim-audit-log';\nimport { DRIFT_INVERSE_FIX_SH } from './shim-drift-fix';\nimport { BASH_CWD_ENV_KEY, BASH_CWD_ENV_VALUE } from './managed-env';\n\n// The allowlist moved to ./l0-allowlist (this module was over the file-size limit); re-exported here so\n// every existing `from './shim'` import keeps working and there is still ONE name to import L0 by.\nexport * from './l0-allowlist';\n// Same treatment for the audit-log fragment: one name to import the whole rendered shim by.\nexport * from './shim-audit-log';\n\n// ---------------------------------------------------------------------------\n// The single checked-in shim (.claude/webpieces/ai-hook.sh). Both project hooks point at it, passing\n// their bin name as the first arg. settings.json points here (not at the bare bin) so a missing bin\n// (fresh clone, package removed) yields a friendly message instead of the raw `sh: No such file or\n// directory` on every Write/Edit/Bash tool call. `.claude` is committed, so the shim survives even\n// when node_modules does not.\n//\n// This module is the SINGLE SOURCE OF TRUTH for the shim body + the installer allowlist. The\n// installer (setup.ts) renders it on install; the running guards binary re-renders and self-heals it\n// (healShim) so the committed .sh can never go stale — no human ever hand-edits it.\n// ---------------------------------------------------------------------------\nexport const SHIM_MARKER = '.claude/webpieces/ai-hook.sh';\n\n// NO VERSION STAMP (removed 2026-07-24). The shim used to carry a per-release `# webpieces shim\n// version: <v> (<sha>)` on line 2, rewritten by scripts/set-version.sh at publish. It was a pure\n// human-eyeball diagnostic — nothing reads it (the deny's version note comes from the installed\n// package.json) — but it made the committed shim go byte-different on EVERY release even when the\n// logic was identical, so the committed-shim self-guard tripped on every upgrade over a comment (the\n// DENY-SHIM-STALE churn). It also carried its own hazard: stamp one of the two lockstep artifacts and\n// not the other and every consumer fail-closes forever on a phantom edit. Deleting it makes the shim\n// byte-STABLE across releases, so the self-guard (now in the binary) fires only on a genuine logic\n// change or a real tamper — which is what lets `pnpm install` be the fix for almost everything.\n\nexport function shimPath(projectRoot: string): string {\n return path.join(projectRoot, '.claude', 'webpieces', 'ai-hook.sh');\n}\n\n\n// ---------------------------------------------------------------------------\n// HOW EVERY DENY MUST SPELL ITS CURE (added 2026-07-23, from a live audit-log post-mortem).\n//\n// The guards were right, the message was right, and the assistant STILL handed the block back to the\n// human — because of one appended clause. From .webpieces/logs/L0-shim/<writer>.log in a consumer repo:\n//\n// DENY-SHIM-STALE cp node_modules/@webpieces/ai-hook-rules/templates/ai-hook.sh .claude/webpieces/ai-hook.sh && git status --short\n//\n// That is the prescribed cure, verbatim, plus `&& git status --short`. Every allowlist here is anchored\n// to `$`, so the trailing `&&` made it a different command and it was denied — and the assistant read\n// its own denial as proof that \"the guard blocks the very command that fixes it\" and stopped.\n//\n// Widening the allowlist to accept `&& <anything>` is NOT the fix: these are fail-CLOSED escape hatches\n// whose entire security property is that no shell operator can ride along (`cp … && rm -rf /`). The fix\n// is to stop the assistant appending in the first place — so every deny that prescribes a command now\n// (a) numbers its cures as OPTIONs, (b) quotes each one so the exact bytes are unambiguous, and\n// (c) carries this rule, which says in plain words that adding `&&` gets it rejected again.\n//\n// CONSTRAINT on every string that reaches a deny REASON: no double quotes and no backslashes. The\n// reason is interpolated into a `REASON=\"…\"` shell assignment and then printf'd into a JSON string, so\n// a `\"` would break BOTH. Hence single quotes around the commands here — do not \"improve\" them.\n// ---------------------------------------------------------------------------\n// LENGTH IS PART OF THE FIX (2026-08-03). This block used to run ~153 words and was repeated verbatim\n// in D, X and K — in X it was ~85% of the whole message, which buries the one sentence that matters.\n// It now states the rule and the three tolerated additions, and nothing else: the tolerated set is\n// exactly what CD_PREFIX_ERE + CAPTURE_TAIL_ERE accept (the single-quoted path branch of CD_PREFIX_ERE\n// is why \"single-quote a path containing spaces\" is named), so this text cannot promise more or less\n// than the allowlist grants.\nexport const NO_CHAINING_RULE =\n 'Run it EXACTLY as written - the allowlist matches the whole command, so appending anything ' +\n '(even && git status) makes it a different command and it is rejected; that is not the guard ' +\n 'blocking its own cure. Only these may be added: a leading cd <dir> && (single-quote a path ' +\n 'containing spaces), a trailing 2>&1, and | tail -N.';\n\n// Shell fragment: resolve the guard BIN by WALKING UP from ROOT, and remember WHERE it came from.\n//\n// THE BUG THIS CLOSES (it would have landed the day the hooks went relative). `ai-hook.sh` used to set\n// `BIN=\"$ROOT/node_modules/.bin/$BIN_NAME\"` — a LITERAL path with no upward walk, while Node's own\n// resolver walks up. That was correct only while the hooks were registered ABSOLUTE, because then ROOT\n// was always the primary clone and the bin was always there. The moment H2/H3 became relative, ROOT\n// became the tree the call is in — and a nested worktree at `<primary>/.claude/worktrees/<name>` has NO\n// node_modules of its own. Every subagent would have hard-blocked on fault X at its first tool call,\n// fleet-wide, on the day of the flip. Walking up finds the primary's install, exactly as a `require()`\n// from the same directory would; a SIBLING worktree finds nothing and correctly still faults X.\n//\n// BIN_ROOT is not a curiosity: walking up ALONE re-creates the version straddle documented above\n// committedShimStale(), where the shim of one tree is paired with the binary of another and the cure\n// can never converge. So the walk is paired with a check — DECLARED comes from `$ROOT/package.json`\n// (the tree being judged) and INSTALLED comes from `$BIN_ROOT/node_modules` (the binary actually\n// running). Equal → no fault, keep reusing the inherited bin, which is the common case and stays free.\n// Different → fault D, cured by an install in THIS tree, which materialises its own node_modules.\nconst RESOLVE_BIN_SH = `BIN_ROOT=\"\\$ROOT\"\nBIN=\"\\$ROOT/node_modules/.bin/\\$BIN_NAME\"\nWP_WALK=\"\\$ROOT\"\nwhile [ ! -x \"\\$WP_WALK/node_modules/.bin/\\$BIN_NAME\" ]; do\n WP_UP=\"\\$(dirname -- \"\\$WP_WALK\")\"\n [ \"\\$WP_UP\" != \"\\$WP_WALK\" ] || break\n WP_WALK=\"\\$WP_UP\"\ndone\nif [ -x \"\\$WP_WALK/node_modules/.bin/\\$BIN_NAME\" ]; then\n BIN_ROOT=\"\\$WP_WALK\"\n BIN=\"\\$WP_WALK/node_modules/.bin/\\$BIN_NAME\"\nfi`;\n\n// Normal template literal (not String.raw): it carries #235's shell escapes verbatim (\\${BIN_NAME},\n// \\$REASON, \\\\n for the deny JSON) AND my sed backslashes (doubled: \\\\(, \\\\), \\\\1, [^\"\\\\\\\\]). The\n// grep pattern is interpolated from INSTALLER_ALLOW_ERE (its value has no backslashes).\n// Shell fragment: the two JSON escapes the deny text is built from, plus the one shared \"what is still\n// allowed\" block. Hoisted to the TOP of the shim (it used to sit inside DENY_EMIT_SH's Bash branch,\n// i.e. AFTER every REASON was already assembled) because the deny text now needs the newline escape\n// while it is being BUILT, not only while it is being printed.\n//\n// THE MECHANISM IS THE ONE THE COLOUR ALREADY USED. `REASON` is interpolated into a `REASON=\"…\"` shell\n// assignment and then printf'd into a JSON string literal, so it may contain no RAW double-quote and no\n// RAW backslash — and a RAW newline would be invalid JSON. That constraint is not \"no newlines\": it is\n// \"no raw backslash\", and `${BS}` produces the backslash at RUNTIME, so `${ESC}` (six chars: \\ u 0 0 1 b)\n// and `${NL}` (two chars: \\ n) both travel as legal JSON escapes that Claude Code's parser turns back\n// into a real ESC and a real newline. Verified end to end through /bin/sh in setup.spec.ts: the payload\n// still parses as JSON, the systemMessage still carries 31;1m, and the reason renders as many lines.\n//\n// WHY THE STRUCTURE MATTERS: every L1/L2 deny is rendered by formatReport() into a scannable shape —\n// header, `[guard-name] (N violations)`, indented offenders each with a one-line `→ why`, then numbered\n// `Fix Option N:` lines. L0 was the ONLY layer answering in one unbroken paragraph. It now uses the same\n// skeleton, which is what WP_STILL_ALLOWED exists for: one definition of that section for all four\n// sh-side faults, so they cannot drift into four different answers to the same question.\nconst ESCAPES_SH = `BS='\\\\' # one literal backslash, so no \\\\u001b / \\\\n escape sits in this source\nESC=\"\\${BS}u001b\" # the 6 chars: backslash u 0 0 1 b — Claude Code parses \\\\u001b → ESC\nNL=\"\\${BS}n\" # the 2 chars: backslash n — parsed as a real newline inside the JSON string\nWP_STILL_ALLOWED=\"Still allowed while this block is up:\\${NL} - any Read\\${NL} - any Write/Edit whose target is ${CONFIG_FILENAME}\\${NL} - every command on the L0 allowlist, including the Fix Options below\\${NL} THIS IS NOT A DEADLOCK - run one YOURSELF now; do not hand it back to the human.\"`;\n\n// Shell fragment: the version-drift guard (see its own block comment). Extracted to a module const so\n// renderShim() stays within the method-line budget; it is spliced back in verbatim, byte-for-byte.\nconst VERSION_DRIFT_GUARD_SH = `# --- webpieces version-drift guard (pure sh — runs even when the installed guard bin is stale) -----\n# The committed shim is version-agnostic, so it keeps working right after a git pull, BEFORE the\n# matching pnpm install. That is exactly when node_modules can be STALE: an OLDER @webpieces than\n# package.json now pins, whose outdated validator rejects the NEWER webpieces.config.json with baffling\n# \"unknown rule\" errors. Detect that drift HERE (before exec'ing the possibly-stale bin): compare every\n# EXACT-pinned @webpieces/* version in the root package.json against the version actually installed in\n# node_modules; the first mismatch wins. Range specs (^ ~ workspace:*) are skipped, so they never\n# false-positive; best-effort — a version we cannot read is skipped. On drift we fall through to the\n# SAME fail-closed path as a missing bin (allow only pnpm install, deny the rest).\n#\n# pnpm CATALOGS: a dep pinned via \"catalog:\" / \"catalog:<name>\" carries NO digit-version in package.json,\n# so the old scraper matched nothing and the guard was BLIND to it — DRIFT_PKG stayed empty and the\n# stale bin ran (the 2026-07 \"0.3.369 vs 0.4.405\" incident). Resolve those specs through the top-level\n# \\`catalogs:\\` block of pnpm-lock.yaml (catalog -> pkg -> resolved version) before comparing.\n#\n# THE SAME PASS ANSWERS FAULT U (2026-08-05). Scraping root package.json is also the only way to learn\n# whether @webpieces/ai-hook-rules is DECLARED at all, and that is the difference between \"not installed\n# yet\" (X, cured by pnpm install) and \"nothing asks for it\" (U, where pnpm install is a guaranteed\n# no-op). WP_PIN carries the first EXACT @webpieces pin found, so U's deny can prescribe the version the\n# rest of the repo is already on rather than an unpinned add. Both are set BEFORE the range/catalog\n# \\`continue\\`s, so a repo pinning the package by range still counts as having declared it.\nDRIFT_PKG=\"\"\nDRIFT_DECLARED=\"\"\nDRIFT_INSTALLED=\"\"\nWP_HOOK_PKG_DECLARED=\"\"\nWP_PIN=\"\"\nif [ -f \"$ROOT/package.json\" ]; then\n # Only when a @webpieces dep actually uses a \"catalog:\" spec do we scan the (possibly huge) lockfile —\n # a cheap grep keeps the common, catalog-free repo from paying that cost on every tool call. One awk\n # pass over pnpm-lock.yaml emits \"<catalog> <@webpieces/pkg> <version>\" lines for the sh lookup below;\n # \\\\047 is a single quote (so this awk program carries none and stays safely single-quotable in sh).\n WP_CATALOGS=\"\"\n if grep -Eq '\"@webpieces/[^\"]*\"[[:space:]]*:[[:space:]]*\"catalog:' \"$ROOT/package.json\" 2>/dev/null && [ -f \"$ROOT/pnpm-lock.yaml\" ]; then\n WP_CATALOGS=\"$(awk '\n { n=0; while (substr($0,n+1,1)==\" \") n++; c=substr($0,n+1) }\n c==\"\" { next }\n n==0 { incat=(c ~ /^catalogs: *$/)?1:0; cat=\"\"; pkg=\"\"; next }\n incat==0 { next }\n n==2 { cat=c; sub(/:.*/,\"\",cat); pkg=\"\"; next }\n n==4 { pkg=c; sub(/: *$/,\"\",pkg); gsub(/[\"\\\\047]/,\"\",pkg); next }\n n==6 && substr(pkg,1,11)==\"@webpieces/\" && c ~ /^version:/ {\n v=c; sub(/^version: */,\"\",v); gsub(/[\"\\\\047 ]/,\"\",v);\n if (cat!=\"\" && v!=\"\") print cat \" \" pkg \" \" v\n }\n ' \"$ROOT/pnpm-lock.yaml\" 2>/dev/null)\"\n fi\n while IFS=' ' read -r WP_NAME WP_DECL; do\n [ -n \"$WP_NAME\" ] || continue\n # Fault U's input: the package is DECLARED (in any spec shape, in any dependency block of the root\n # manifest). Recorded before every \\`continue\\` below, so a range or catalog spec still counts.\n [ \"$WP_NAME\" = \"ai-hook-rules\" ] && WP_HOOK_PKG_DECLARED=1\n # Resolve the declared spec to an EXACT version, or skip it: ranges (^ ~ workspace:*) never drift,\n # and a catalog spec we cannot resolve is best-effort skipped rather than guessed.\n case \"$WP_DECL\" in\n catalog:*)\n WP_CAT=\"\\${WP_DECL#catalog:}\"; [ -n \"$WP_CAT\" ] || WP_CAT=\"default\"\n WP_DECL=\"$(printf '%s\\\\n' \"$WP_CATALOGS\" | awk -v c=\"$WP_CAT\" -v p=\"@webpieces/$WP_NAME\" '$1==c && $2==p {print $3; exit}')\"\n [ -n \"$WP_DECL\" ] || continue ;;\n [0-9]*) : ;;\n *) continue ;;\n esac\n # The release the rest of this repo is on — what fault U's cure should pin to.\n [ -n \"$WP_PIN\" ] || WP_PIN=\"$WP_DECL\"\n WP_MANIFEST=\"$BIN_ROOT/node_modules/@webpieces/$WP_NAME/package.json\"\n [ -f \"$WP_MANIFEST\" ] || continue\n WP_INST=\"$(sed -n 's/.*\"version\"[[:space:]]*:[[:space:]]*\"\\\\([^\"]*\\\\)\".*/\\\\1/p' \"$WP_MANIFEST\" | head -n1)\"\n [ -n \"$WP_INST\" ] || continue\n if [ \"$WP_DECL\" != \"$WP_INST\" ]; then\n DRIFT_PKG=\"@webpieces/$WP_NAME\"\n DRIFT_DECLARED=\"$WP_DECL\"\n DRIFT_INSTALLED=\"$WP_INST\"\n break\n fi\n done <<WPEOF\n$(sed -n 's/.*\"@webpieces\\\\/\\\\([A-Za-z0-9._-]*\\\\)\"[[:space:]]*:[[:space:]]*\"\\\\([^\"]*\\\\)\".*/\\\\1 \\\\2/p' \"$ROOT/package.json\")\nWPEOF\nfi\n# THE CURE FOR A BORROWED node_modules RUNS IN THIS TREE, NOT WHEREVER THE BIN CAME FROM. A bare\n# 'pnpm install' typed while the shell sits in the primary clone installs into the primary, changes\n# nothing in the worktree being judged, and re-fires the identical fault — the four-cure straddle\n# recorded above committedShimStale(). When the bin was inherited, prescribe the cd and say why.\nWP_INSTALL_CMD=\"pnpm install\"\nWP_BORROW_NOTE=\"\"\nif [ \"$BIN_ROOT\" != \"$ROOT\" ]; then\n WP_INSTALL_CMD=\"cd $ROOT && pnpm install\"\n WP_BORROW_NOTE=\"\\${NL} NOTE: this tree ($ROOT) has NO node_modules of its own, so the guard binary was inherited from $BIN_ROOT by walking up. TWO cures are real and they fix DIFFERENT things: A makes THIS tree work now, B stops the two trees disagreeing. A - run the command above HERE. That is legitimate and it does work; a worktree NEEDS its own node_modules anyway (nx, vitest and the eslint plugin all execute in this tree and load from it). B - get $BIN_ROOT onto the same @webpieces version: put both trees on the same git hash (the pin is tracked) and run ONE 'pnpm install' there. If you are a SUBAGENT you cannot reach that tree, so ESCALATE - ask the coordinator to run 'pnpm install' in the main tree so both trees are on the same @webpieces version. The rule is NOT no-install-here: it is that this tree's @webpieces must EQUAL $BIN_ROOT's, because doing only A leaves two trees on two releases (the trinary-version-skew guard then BLOCKS rather than letting it pass unnoticed). Adding an ordinary third-party dependency here changes none of that - only a differing @webpieces version does. If this tree genuinely needs a DIFFERENT version, use a separate clone rather than a worktree.\"\nfi`;\n\n// Shell fragment: run the installed guard bin and INSPECT its outcome, instead of exec'ing it.\n//\n// THE BUG THIS FIXES (guards silently fail-OPEN): the shim used to `exec \"$BIN\"`. exec REPLACES this\n// shim process, so once the bin was executable the shim was GONE and could no longer make a decision.\n// That is fine when the bin runs — but the bin can be INSTALLED YET BROKEN: a corrupt/partially-written\n// node_modules makes node die at require() time with MODULE_NOT_FOUND, exiting 1. And in the PreToolUse\n// protocol ONLY exit 2 blocks: any other non-zero is a NON-BLOCKING error, so Claude Code prints\n// \"Failed with non-blocking status code\" and RUNS THE TOOL CALL ANYWAY — the guard is silently skipped.\n// Result: every Write/Edit/Bash went UNGUARDED, for as long as node_modules stayed corrupt. The shim\n// handled \"bin missing\" and \"bin stale\", but never \"bin present and CRASHES\" — the third failure mode.\n//\n// So: do not exec. Run the bin with the payload on stdin and branch on its exit code.\n// rc 0 | 2 → a REAL decision (allow / block). Relay stdout, stderr and the code byte-faithfully.\n// anything else → the guard CRASHED. Fall through to the fail-CLOSED path (BROKEN_BIN=1).\n// stdout/stderr go through temp FILES, not $(command substitution), so the bin's bytes reach Claude\n// Code exactly as written — command substitution strips trailing newlines and would corrupt the\n// decision JSON. Reading the payload up-front ($PAYLOAD) is what replaces exec's stdin passthrough.\nconst RUN_BIN_SH = `if [ -x \"\\$BIN\" ] && [ -z \"\\$DRIFT_PKG\" ]; then\n OUT_FILE=\"\\${TMPDIR:-/tmp}/wp-ai-hook-out.\\$\\$\"\n ERR_FILE=\"\\${TMPDIR:-/tmp}/wp-ai-hook-err.\\$\\$\"\n printf '%s' \"\\$PAYLOAD\" | \"\\$BIN\" \"\\$@\" >\"\\$OUT_FILE\" 2>\"\\$ERR_FILE\"\n RC=\\$?\n if [ \"\\$RC\" = 0 ] || [ \"\\$RC\" = 2 ]; then\n cat \"\\$OUT_FILE\" # the guard's real decision — verbatim\n cat \"\\$ERR_FILE\" >&2\n rm -f \"\\$OUT_FILE\" \"\\$ERR_FILE\" 2>/dev/null\n # THE HEALTHY CALL, which this log used to be silent about (see WP_LOG_SH's header). No sh-side\n # fault, the bin ran, and its own exit code says how it ended — 0 allow, 2 block. Logged AFTER the\n # decision bytes are already on stdout, so the audit trail never sits between the guard and Claude\n # Code. Without this line, \"no entry\" meant either healthy or never-ran, and those are the two\n # answers a reader most needs to tell apart.\n WP_VERDICT=PASS-BIN-ALLOW\n [ \"\\$RC\" = 2 ] && WP_VERDICT=PASS-BIN-BLOCK\n wp_log - \"\\$WP_VERDICT\"\n exit \"\\$RC\"\n fi\n # Crashed. Keep the most useful stderr line for the human. Strip \" and backslash so the text stays a\n # valid JSON string, and cap the length so a giant node stack cannot blow up the deny payload.\n CRASH_MSG=\"\\$(grep -m1 'Cannot find module' \"\\$ERR_FILE\" 2>/dev/null | tr -d '\"\\\\\\\\' | cut -c1-120)\"\n [ -n \"\\$CRASH_MSG\" ] || CRASH_MSG=\"\\$(head -n1 \"\\$ERR_FILE\" 2>/dev/null | tr -d '\"\\\\\\\\' | cut -c1-120)\"\n [ -n \"\\$CRASH_MSG\" ] || CRASH_MSG=\"exit code \\$RC, no stderr\"\n rm -f \"\\$OUT_FILE\" \"\\$ERR_FILE\" 2>/dev/null\n BROKEN_BIN=1\nfi`;\n\n// Shell fragment: pull the four fields the shim itself reasons about out of the tool payload.\n//\n// MOVED AHEAD OF THE BIN (it used to sit inside TRIAGE_SH, i.e. only on the fail-closed path) because\n// the audit log now covers the HEALTHY call too, and a log line needs the tool and the command whether\n// or not anything went wrong.\n//\n// `cwd` is Claude Code's documented \"current working directory when the hook is invoked\". It is used\n// for ONE thing: deciding which tree's log directory this line belongs in (see RESOLVE_LOG_DIR_SH).\n// It deliberately does NOT change what the drift guard MEASURES — that stays anchored to $ROOT, the\n// tree the shim FILE lives in. Where a call is logged and what a call is judged against are separate\n// questions and are kept separate here.\n// TWO command variables, and the split is a SECURITY boundary — do not collapse them.\n//\n// $CMD is the DECISION input (the L0 allowlist greps it). Its pattern requires the CLOSING quote, so a\n// JSON payload that escapes an embedded quote as \\\\\" yields the EMPTY STRING for the whole command.\n// That looks like a bug and is in fact the safe direction: an empty command matches no allowlist entry,\n// so a quoted command falls through to the deny. FAIL CLOSED. Keep it that way.\n//\n// $CMD_LOG is the AUDIT input and must never reach a decision. It drops the closing quote from the\n// pattern so it captures the command PREFIX instead of nothing.\n//\n// WHY THEY CANNOT BE ONE VARIABLE: every L0 allowlist ERE is anchored `^…[[:space:]]*$`, and trailing\n// whitespace is tolerated — so `pnpm install \"; rm -rf /\"` would prefix-capture to `pnpm install `,\n// which MATCHES, and the injection after the quote would ride through allowlisted. Measured 2026-08-06:\n// 3,908 of 4,917 shim audit lines (79.5%) recorded an empty command, i.e. four out of five audit\n// entries were blind. Fixing the LOG is worth doing; fixing the DECISION the same way is a hole.\nconst PARSE_PAYLOAD_SH = `CMD=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"command\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\nCMD_LOG=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"command\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\).*/\\\\1/p')\"\n[ -n \"\\$CMD_LOG\" ] || CMD_LOG=\"\\$CMD\"\nTOOL=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"tool_name\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\nWP_SID=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"session_id\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\nWP_AID=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"agent_id\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\nFILE=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"file_path\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\nWP_CWD=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"cwd\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\n[ -n \"\\$WP_CWD\" ] || WP_CWD=\"\\$ROOT\" # no cwd in the payload (older client, or a hand-run) → the shim's own tree`;\n\n// Shell fragment: the guards are DOWN (missing | stale | crashed). Classify the fault, then let THE L0\n// ALLOWLIST through — everything else falls to the deny below.\n//\n// This asks the identical question isAllowed() asks in JS, in the same order: Read, then the\n// webpieces.config.json target, then the one command union (L0_ALLOW_ERE). The sh and JS halves exist\n// because D/X/K are decided BEFORE the bin runs (a stale/missing/broken validator cannot validate\n// itself) while S/C/Y are decided inside it — one model, two enforcement points.\n//\n// NOTE the documented asymmetry: here the bin is never executed, so an allowed Read is TERMINAL and\n// read-stale-guard does not run. In JS the same entry falls through and it does. See isAllowed().\nconst TRIAGE_SH = `# WHICH of the guards/L0-tooling.md faults fired, in the doc's own letters. Only the four sh-side\n# codes can be decided here; S/C/Y live in the binary, which never got to run on this path.\nWP_FAULT=${L0_FAULT_BIN_MISSING} # X — bin missing (fresh clone, new worktree)\n[ -z \"\\$WP_HOOK_PKG_DECLARED\" ] && WP_FAULT=${L0_FAULT_UNDECLARED} # U — X, but nothing declares the package: install is a no-op\n[ -n \"\\$DRIFT_PKG\" ] && WP_FAULT=${L0_FAULT_DRIFT} # D — version drift; D and K are mutually exclusive\n[ -n \"\\$BROKEN_BIN\" ] && WP_FAULT=${L0_FAULT_BIN_BROKEN} # K — bin present but CRASHED (corrupt node_modules)\nDENY_LABEL=\"DENY\"\n[ -z \"\\$WP_HOOK_PKG_DECLARED\" ] && DENY_LABEL=\"DENY-UNDECLARED\" # nothing in package.json asks for the package\n[ -n \"\\$DRIFT_PKG\" ] && DENY_LABEL=\"DENY-STALE\" # version drift, not a missing bin\n[ -n \"\\$BROKEN_BIN\" ] && DENY_LABEL=\"DENY-BROKEN\" # bin present but CRASHED (corrupt node_modules)\n# THE L0 ALLOWLIST, entry order identical to isAllowed(). No fault is consulted: a cure that cannot\n# help a given fault also cannot hurt it, and gating each entry on a fault is what produced the four\n# defects recorded above L0_ALLOW_ERE.\nif [ \"\\$TOOL\" = \"Read\" ]; then\n wp_log \"\\$WP_FAULT\" ALLOW-READ # you must be able to read to work out how to fix this\n exit 0\nfi\ncase \"\\$FILE\" in\n */${CONFIG_FILENAME}|${CONFIG_FILENAME})\n wp_log \"\\$WP_FAULT\" ALLOW-CONFIG # the always-allowed recovery target — every guard is configured from it\n exit 0 ;;\nesac\nif printf '%s' \"\\$CMD\" | grep -Eq '${L0_ALLOW_ERE_SH}'; then\n wp_log \"\\$WP_FAULT\" ALLOW-CURE # record the self-heal we let through (re-enables the guards)\n exit 0 # allow the cure so the assistant can break the deadlock\nfi\nwp_log \"\\$WP_FAULT\" \"\\$DENY_LABEL\" # every fail-closed block, with the fault that caused it`;\n\n// Shell fragment: emit the deny. FAIL CLOSED via Claude Code's PreToolUse JSON protocol\n// (permissionDecision \"deny\" on stdout, then exit 0) rather than a bare \"exit 2\". BOTH block the call,\n// but the reason must be made VISIBLE, and HOW depends on the tool (verified by live tests; the docs\n// are wrong here):\n// - Bash deny: permissionDecisionReason is NOT shown to the human — ONLY a top-level systemMessage\n// is, and it honors ANSI. So for Bash we emit systemMessage wrapped in ANSI red so the\n// recovery command is visible (without it, on Bash, it is invisible).\n// - Write/Edit/MultiEdit deny: permissionDecisionReason renders as a RED \"Error:\" block natively —\n// no systemMessage needed (a second line would be redundant).\n// - NEVER exit 2 (stdout JSON ignored; stderr not reliably shown on a blocked Bash call).\n// The ESC and the newline escape are both built in ESCAPES_SH at the TOP of the shim (see its header):\n// ${ESC} is the literal 6-char JSON escape \\\\u001b and ${NL} the 2-char \\\\n, so no raw ESC byte, no raw\n// newline and no \\\\uXXXX sits in this source, and Claude Code's JSON parser turns both back. The reason\n// is a single JSON string with no RAW double-quotes/backslashes, so it stays valid JSON after the subs.\n//\n// ONLY THE HEADLINE IS RED, and that is deliberate — the same call redSystemMessage() makes on the JS\n// side. The reason is MULTI-LINE now, and a whole page in bold red is harder to read than the paragraph\n// it replaced: the indentation carrying the structure stops registering when every line shouts. So\n// \\$WP_HEAD (each branch's own first line, kept in its own variable for exactly this) is wrapped in\n// [31;1m … [0m, and \\${REASON#\"\\$WP_HEAD\"} — POSIX prefix removal with a QUOTED pattern, so the\n// headline is matched literally and not as a glob — supplies the plain body after it.\nconst DENY_EMIT_SH = `if [ \"\\$TOOL\" = \"Bash\" ]; then\n printf '{\"systemMessage\":\"%s🛑 %s%s%s\",\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"%s\"}}\\\\n' \"\\${ESC}[31;1m\" \"\\$WP_HEAD\" \"\\${ESC}[0m\" \"\\${REASON#\"\\$WP_HEAD\"}\" \"\\$REASON\"\nelse\n printf '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"%s\"}}\\\\n' \"\\$REASON\"\nfi\nexit 0 # decision is carried by permissionDecision \"deny\", not the exit code`;\n\n// Shell fragment: pick the fail-closed deny REASON — a crashed-bin message (corrupt node_modules) vs a\n// version-drift message (bin present but stale) vs the missing-bin message. Extracted alongside\n// VERSION_DRIFT_GUARD_SH / RUN_BIN_SH to keep renderShim() within the method-line budget.\nconst DENY_REASON_SH = `if [ -n \"\\$BROKEN_BIN\" ]; then\n # Report (do NOT auto-clean) the orphaned pnpm staging dirs — a package pnpm was mid-way through\n # writing is left behind as <name>_<pid>_<hash>. Their presence is the fingerprint of an install that\n # was killed, which is what corrupts node_modules in the first place. Best-effort; never fatal.\n STAGING_N=\"\\$(ls \"\\$BIN_ROOT/node_modules\" 2>/dev/null | grep -Ec '_[0-9a-f]+_[0-9a-f]+\\$' || true)\"\n STAGING_NOTE=\"\"\n if [ \"\\${STAGING_N:-0}\" -gt 0 ] 2>/dev/null; then\n STAGING_NOTE=\"\\${NL} → also found \\$STAGING_N orphaned pnpm staging dirs (name_pid_hash) under node_modules - the fingerprint of an install that was killed mid-write.\" # only when N > 0\n fi\n # The HEADLINE, kept in its own variable so DENY_EMIT_SH can paint ONLY it red (see there).\n WP_HEAD=\"❌ webpieces ai-hooks blocked this call: the webpieces guards are DOWN.\"\n REASON=\"\\$WP_HEAD\\${NL}\\${NL}${l0GuardHeader(L0_FAULT_BIN_BROKEN, '1 violation')}\\${NL} \\${BIN_NAME} (\\$CRASH_MSG)\\${NL} → it is installed but CRASHED, so your node_modules is corrupt or partially written; the guards cannot run and they must not be silently skipped. Every OTHER tool call is BLOCKED until they can.\\${STAGING_NOTE}\\${NL} → ${l0MatrixCitation(L0_FAULT_BIN_BROKEN)}\\${NL}\\${NL}\\${WP_STILL_ALLOWED}\\${NL}\\${NL} Fix Option 1: (preferred) the only cure. A bare 'pnpm install' will NOT fix this, because pnpm sees the correct version on disk and skips the broken package\\${NL} run EXACTLY: '${RECOVERY_CMD}'\\${NL}\\${NL}${NO_CHAINING_RULE}\"\nelif [ -n \"\\$DRIFT_PKG\" ]; then\n # DECIDE THE DIRECTION, do not make the reader do it (2026-08-03). The detection is a plain !=, so it\n # fires BOTH ways, and the message used to carry OPTION 1/2/3 covering every direction at once — 3343\n # chars of which only about a third was the decision. A reader on the wrong branch of that menu was\n # one misread away from a downgrade. So compare the two versions HERE and emit only the relevant half.\n #\n # WITH AWK, not \\`sort -V\\`: -V is a GNU extension (absent/different on BSD sort), while the shim\n # already runs an awk pass to resolve catalog: specs, so awk adds no dependency. The program compares\n # the numeric cores component-by-component; a pre-release/build suffix (-rc.1, +sha) is stripped from\n # the core, and when the cores are EQUAL the side carrying a PRE-RELEASE suffix is the older one\n # (semver precedence). Build metadata (+sha) carries NO precedence, so two versions differing only\n # there come back undecidable rather than ordered. Anything it cannot parse prints NOTHING, and an\n # empty answer falls through to the ambiguous wording below rather than guessing a direction.\n #\n # WHAT WAS DELETED, so it does not creep back: the \"how to get main itself current\" paragraph and the\n # \"do NOT reach for git merge --ff-only / reset --hard / checkout -B main\" paragraph both belong to\n # redirect-how-to-merge-main, which fires on its own with its own message; and the sentence that named\n # wp-start-update / wp-start-upsert-pr ONLY to forbid them while the block is up — naming a command\n # purely to forbid it is pure cost, and the install that clears this fault comes first regardless.\n DRIFT_DIR=\"\\$(awk -v i=\"\\$DRIFT_INSTALLED\" -v d=\"\\$DRIFT_DECLARED\" 'BEGIN {\n iv = i; sub(/\\\\+.*/, \"\", iv); ic = iv; sub(/-.*/, \"\", ic); ip = substr(iv, length(ic) + 1)\n dv = d; sub(/\\\\+.*/, \"\", dv); dc = dv; sub(/-.*/, \"\", dc); dp = substr(dv, length(dc) + 1)\n if (ic !~ /^[0-9]+(\\\\.[0-9]+)*\\$/ || dc !~ /^[0-9]+(\\\\.[0-9]+)*\\$/) exit\n n = split(ic, ia, \".\"); m = split(dc, da, \".\"); k = (n > m) ? n : m\n for (x = 1; x <= k; x++) {\n av = (x <= n) ? ia[x] + 0 : 0; bv = (x <= m) ? da[x] + 0 : 0\n if (av < bv) { print \"older\"; exit }\n if (av > bv) { print \"newer\"; exit }\n }\n if (ip == dp) exit\n if (ip != \"\" && dp == \"\") print \"older\"\n if (ip == \"\" && dp != \"\") print \"newer\"\n }' 2>/dev/null)\"\n if [ \"\\$DRIFT_DIR\" = older ]; then\n # The HEADLINE, kept in its own variable so DENY_EMIT_SH can paint ONLY it red (see there).\n WP_HEAD=\"❌ webpieces ai-hooks blocked this call: webpieces version drift.\"\n REASON=\"\\$WP_HEAD\\${NL}\\${NL}${l0GuardHeader(L0_FAULT_DRIFT, '1 violation')}\\${NL} package.json pins \\$DRIFT_PKG@\\$DRIFT_DECLARED but node_modules has \\$DRIFT_INSTALLED\\${NL} → node_modules is OLDER, so the pin is what you want. Every OTHER tool call is BLOCKED until the two agree.\\${NL} → ${l0MatrixCitation(L0_FAULT_DRIFT)}\\${NL}\\${NL}\\${WP_STILL_ALLOWED}\\${NL}\\${NL} Fix Option 1: (preferred) the only cure - it makes node_modules match the pin\\${NL} run EXACTLY: '\\$WP_INSTALL_CMD'\\${WP_BORROW_NOTE}\\${NL}\\${NL}${NO_CHAINING_RULE}\"\n else\n # NEWER, or undecidable — the same choices apply either way, so the only thing the ambiguous\n # case changes is the claim about which side is stale.\n DRIFT_NOTE=\"node_modules is NEWER, so the PIN is the stale side and a bare 'pnpm install' DOWNGRADES you to \\$DRIFT_DECLARED\"\n [ \"\\$DRIFT_DIR\" = newer ] || DRIFT_NOTE=\"these two versions could not be ordered automatically - compare them yourself: if node_modules is the NEWER side then the PIN is the stale side and a bare 'pnpm install' DOWNGRADES you to \\$DRIFT_DECLARED\"\n ${DRIFT_INVERSE_FIX_SH}\n # The HEADLINE, kept in its own variable so DENY_EMIT_SH can paint ONLY it red (see there).\n WP_HEAD=\"❌ webpieces ai-hooks blocked this call: webpieces version drift.\"\n REASON=\"\\$WP_HEAD\\${NL}\\${NL}${l0GuardHeader(L0_FAULT_DRIFT, '1 violation')}\\${NL} package.json pins \\$DRIFT_PKG@\\$DRIFT_DECLARED but node_modules has \\$DRIFT_INSTALLED\\${NL} → \\$DRIFT_NOTE. That may be exactly what you want. Every OTHER tool call is BLOCKED until the two agree.\\${NL} → ${l0MatrixCitation(L0_FAULT_DRIFT)}\\${NL}\\${NL}\\${WP_STILL_ALLOWED}\\${NL}\\${NL}\\${WP_FIX}\\${WP_BORROW_NOTE}\\${NL}\\${NL}${NO_CHAINING_RULE}\"\n fi\nelse\n # A LINKED WORKTREE is the overwhelmingly common way to land here with a perfectly healthy repo:\n # git gives the new worktree a .git FILE (the primary clone has a .git directory) and copies no\n # node_modules, so the very first tool call in a brand-new worktree fail-closes on a missing bin.\n # Naming that explicitly turns a baffling \"not installed\" into a one-command fix, and the HERE is\n # load-bearing: installing in the primary clone does nothing for this tree.\n WORKTREE_NOTE=\"\"\n if [ -f \"\\$ROOT/.git\" ]; then\n WORKTREE_NOTE=\"\\${NL} → \\$ROOT is a LINKED WORKTREE - git does not copy node_modules into a new worktree, so this is expected on a fresh one. Run the Fix Option HERE, in this worktree, not in the primary clone.\"\n fi\n if [ -z \"\\$WP_HOOK_PKG_DECLARED\" ]; then\n # FAULT U — the one shape where the X message is not merely unhelpful but actively WRONG. It asserted\n # \"declared in package.json\" without ever checking, and prescribed the one command that provably\n # cannot help: with nothing asking for the package, \\`pnpm install\\` reports \"Lockfile is up to date\"\n # and converges to the identical broken tree, forever. So say what is actually true, say out loud\n # that the install is a no-op (an agent that has already run it needs to be told to STOP), and\n # prescribe the add — which is allowlist entry ADD_HOOK_PKG, so it is reachable while this block is up.\n WP_ADD_CMD=\"${ADD_HOOK_PKG_CMD}\"\n [ -n \"\\$WP_PIN\" ] && WP_ADD_CMD=\"\\${WP_ADD_CMD}@\\$WP_PIN\"\n # The HEADLINE, kept in its own variable so DENY_EMIT_SH can paint ONLY it red (see there).\n WP_HEAD=\"❌ webpieces ai-hooks blocked this call: the guard package is not declared anywhere.\"\n REASON=\"\\$WP_HEAD\\${NL}\\${NL}${l0GuardHeader(L0_FAULT_UNDECLARED, '1 violation')}\\${NL} ${HOOK_PKG} is NOT declared in package.json anywhere, and is not installed (\\${BIN_NAME} not found)\\${NL} → .claude/settings.json still runs its hooks, so every OTHER tool call is BLOCKED. Do NOT run 'pnpm install': nothing asks for this package, so it is a NO-OP and repeating it converges to this same state.\\${NL} → ${l0MatrixCitation(L0_FAULT_UNDECLARED)}\\${NL}\\${NL}\\${WP_STILL_ALLOWED}\\${NL}\\${NL} Fix Option 1: (preferred) declare it directly, to unblock yourself right now\\${NL} run EXACTLY: '\\$WP_ADD_CMD'\\${NL} Fix Option 2: the durable fix - ${HOOK_PKG} normally arrives with @webpieces/nx-webpieces-rules, the umbrella that bundles the whole toolchain, so upgrade that once you are unblocked.\\${NL} NOT an option: if you removed ${HOOK_PKG} on purpose, delete its hooks from .claude/settings.json instead.\\${NL}\\${NL}${NO_CHAINING_RULE}\"\n else\n # The HEADLINE, kept in its own variable so DENY_EMIT_SH can paint ONLY it red (see there).\n WP_HEAD=\"❌ webpieces ai-hooks blocked this call: the webpieces guard bin is not installed.\"\n REASON=\"\\$WP_HEAD\\${NL}\\${NL}${l0GuardHeader(L0_FAULT_BIN_MISSING, '1 violation')}\\${NL} ${HOOK_PKG} is declared in package.json but is not installed (\\${BIN_NAME} not found)\\${NL} → the guards cannot run, so every OTHER tool call is BLOCKED until they can.\\${WORKTREE_NOTE}\\${NL} → ${l0MatrixCitation(L0_FAULT_BIN_MISSING)}\\${NL}\\${NL}\\${WP_STILL_ALLOWED}\\${NL}\\${NL} Fix Option 1: (preferred) the only cure - it materializes what package.json already asks for\\${NL} run EXACTLY: 'pnpm install'\\${NL} NOT an option: if you removed ${HOOK_PKG} on purpose, delete its hooks from .claude/settings.json instead.\\${NL}\\${NL}${NO_CHAINING_RULE}\"\n fi\nfi`;\n\nexport function renderShim(): string {\n return `#!/bin/sh\n# Managed by @webpieces/ai-hook-rules (wp-install-ai-hooks) — do not edit. This file is GENERATED from\n# renderShim() and is intentionally VERSION-AGNOSTIC and byte-STABLE across releases: it carries no\n# version stamp, so it only changes when its own logic changes. The installed guards binary is what\n# checks that this committed copy still matches renderShim() (the committed-shim self-guard); if you\n# revert or hand-edit this file the binary fails closed and names the cure. Checked in on purpose so\n# the hook has a stable entry point even when node_modules is absent. Safe to delete along with the\n# matching .claude/settings.json entries if you remove @webpieces/ai-hook-rules.\n#\n# Usage (wired into .claude/settings.json, ABSOLUTE so the MAIN tree governs every tree):\n# sh \"$CLAUDE_PROJECT_DIR/.claude/webpieces/ai-hook.sh\" <bin-name>\nBIN_NAME=\"$1\"\nshift\n# Resolve the tree relative to THIS script (…/<root>/.claude/webpieces/ai-hook.sh → <root>), not the\n# caller's cwd — the hook can be invoked from any directory (a subdir, or a nested clone).\nROOT=\"$(CDPATH= cd -- \"$(dirname -- \"$0\")/../..\" && pwd)\"\n# The JSON escapes every deny message below is assembled from (ANSI red, and the newlines that give the\n# deny the same scannable shape formatReport() gives every L1/L2 deny). See ESCAPES_SH's header.\n${ESCAPES_SH}\n# The BIN is resolved by walking UP from ROOT (as Node does), and BIN_ROOT records which tree supplied\n# it — the version-drift guard below compares THIS tree's pin against THAT tree's installed version.\n${RESOLVE_BIN_SH}\n${VERSION_DRIFT_GUARD_SH}\n# Read the tool payload ONCE, up front. The shim no longer exec's the bin (see RUN_BIN_SH), so it must\n# forward stdin to the bin itself — and it needs the payload again on the fail-closed path below.\nPAYLOAD=\"$(cat)\"\n${PARSE_PAYLOAD_SH}\n# Best-effort AUDIT TRAIL of what L0 did with this call — every call, not just the broken ones. One\n# tab-separated line per invocation into this TREE's own\n# logs/L0-shim/<session>-<agent|coordinator>-<binName>.log (gitignored), so the\n# observed behaviour can be diffed against the matrix in guards/L0-tooling.md. NEVER breaks or blocks the\n# hook: every write is swallowed, and nothing ever goes to stdout (stdout is the PreToolUse decision\n# channel — a stray byte there would corrupt allow/deny).\n${WP_LOG_SH}\nBROKEN_BIN=\"\"\nCRASH_MSG=\"\"\n${RUN_BIN_SH}\n# Bin missing (fresh clone before install) OR a version drift (stale node_modules) OR the bin is\n# installed but CRASHED (corrupt node_modules). The webpieces guards CANNOT safely run.\n# Before failing closed, peek at the tool payload and let ONLY package-manager install/recovery commands\n# through: the assistant's own Bash tool routes through this hook too, so blocking everything would\n# deadlock the very commands (pnpm install / rm -rf node_modules && pnpm install) that re-enable the\n# guards. A silent exit 0 = \"allow\" in the PreToolUse protocol; the guards resume once the tree is sane.\n${TRIAGE_SH}\n${DENY_REASON_SH}\n${DENY_EMIT_SH}\n`;\n}\n\n// Find the repo root that owns the committed shim to heal: walk up from `cwd` (the invocation's\n// actual dir) to the nearest ancestor holding a shim, falling back to $CLAUDE_PROJECT_DIR (which\n// Claude Code exports to hooks) only if the walk finds nothing. cwd-first keeps this correct for a\n// nested clone and testable (a temp root is honoured over the ambient project env). Returns null when\n// no committed shim exists (e.g. a global / absolute install, which has none to heal).\n//\n// Exported for install-entry.ts: on a CORRUPT node_modules, healShim is the only installer step that\n// can still run, so the installer must be able to tell the human whether a committed shim was actually\n// there to re-arm. Pure existsSync walk — never throws, so it needs no try/catch of its own.\n// webpieces-disable no-function-outside-class -- pure fs+path helper in the dependency-free shim module; it must not depend on DI (install-entry.ts relies on this loading on a corrupt tree).\nexport function findShimRoot(cwd: string): string | null {\n let dir = cwd;\n for (;;) {\n if (fs.existsSync(shimPath(dir))) return dir;\n const parent = path.dirname(dir);\n if (parent === dir) break;\n dir = parent;\n }\n const env = process.env['CLAUDE_PROJECT_DIR'];\n if (env && fs.existsSync(shimPath(env))) return env;\n return null;\n}\n\n// Best-effort: keep the committed shim identical to renderShim() so the fail-closed escape hatch and\n// allowlist never drift. Only rewrites an EXISTING shim (never creates one) so global installs are\n// untouched. NEVER throws — a self-heal must never block or crash a tool call.\n//\n// The overwrite itself is correct and deliberate — shim and binary are two halves of one L0 and MUST\n// come from the same release (see shimStaleRecoveryDecision's header in ../adapters/hook-core).\n//\n// It needs no backup and no notice: the shim is a TRACKED file, so whatever it replaced is already in\n// git — `git diff` shows the rewrite, and a tamper is a working-tree modification git surfaces on its\n// own. In a consistent repo this is a no-op (committed shim already equals renderShim()); it earns its\n// keep on the upgrade path, where bumping the pin and installing leaves the committed shim behind and\n// this quietly brings it forward to be committed.\nexport function healShim(cwd: string): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const root = findShimRoot(cwd);\n if (!root) return;\n const target = shimPath(root);\n const desired = renderShim();\n if (fs.readFileSync(target, 'utf8') === desired) return;\n fs.writeFileSync(target, desired, { mode: 0o755 });\n fs.chmodSync(target, 0o755);\n } catch (err: unknown) {\n //const error = toError(err);\n // Ignore: healing is a convenience, not part of the guard decision.\n }\n}\n\n// ---------------------------------------------------------------------------\n// COMMITTED-SHIM SELF-GUARD — now enforced by the guards BINARY, not the shim (moved 2026-07-24).\n//\n// It used to live in the rendered shim (`cmp -s \"$0\" \"$WP_TEMPLATE\"` → fail closed). That was a\n// double-edged fix trap: the shim-matching logic lived IN the committed shim, so a bug in it could\n// only be fixed by regenerating the committed shim — which required passing the buggy shim's own gate\n// (via wp-upgrade-shim). The fix was locked behind the gate it needed to open.\n//\n// The drift guard MUST stay pre-binary (a stale validator can't be trusted to guard itself), but this\n// check's rationale — \"don't run possibly-stale shim logic\" — evaporates once the check is in the\n// binary: at that point the deciding code is the CURRENT binary from node_modules, not the reverted\n// shim. So the shim now only checks drift + bin-presence and always hands off; the binary (hook-core)\n// calls committedShimStale() and, on a mismatch, fails closed with shimStaleDenyReason() — the SAME\n// OPTION 1/2/3 message — while isShimCureCommand() lets the three cures through so the AI self-heals.\n// We deny + tell the AI; we do NOT silently rewrite the file under it. With the version stamp gone the\n// shim is byte-stable across releases, so this fires only on a genuine logic change or a real tamper.\n// ---------------------------------------------------------------------------\n\n// The root whose committed shim this BINARY governs — resolved from the RUNNING MODULE's own location,\n// never from process.cwd() and never from $CLAUDE_PROJECT_DIR. Same premise as installedShimRulesVersion()\n// below: the binary IS this package, so it can point at its OWN install.\n//\n// WHY IT MUST BE THE MODULE AND NOT THE CWD (the two-tree straddle, fixed 2026-08-03).\n// committedShimStale used to resolve its root by walking up from the invocation cwd, then compare that\n// tree's shim FILE against renderShim() — which is compiled into whichever binary is actually running.\n//\n// Which tree supplies the binary? settings.json runs $CLAUDE_PROJECT_DIR/.claude/webpieces/ai-hook.sh,\n// and that shim derives ROOT (hence BIN) from its own $0 — so the SESSION ROOT's tree supplies BOTH the\n// shim and the binary, and that pair is self-consistent by construction. A session rooted in a linked\n// worktree runs the worktree's shim and the worktree's binary; that is fine and is NOT the bug.\n//\n// The straddle appears when an agent's SESSION ROOT and its CWD are different trees — CLAUDE_PROJECT_DIR\n// is fixed at session start, so an agent that `cd`s into another checkout keeps running the session-root\n// tree's binary while findShimRoot(cwd) walks up into the OTHER tree. Each tree carries its own\n// node_modules at its own @webpieces version (seen in the wild: 0.4.545, 0.4.560 and 0.4.526 side by\n// side, every tree internally consistent). The comparison then straddles the two and can NEVER converge:\n// curing in the cwd tree renders with THAT tree's renderShim(), which the running binary's renderShim()\n// still rejects, so the cure re-fires the deny forever (observed: an agent gave up after four cures).\n//\n// Anchoring on __dirname makes the straddle UNCONSTRUCTIBLE rather than merely discouraged. It does not\n// pick a tree and privileges none: whichever tree the running binary came from is the tree whose shim it\n// compares, so the two halves of the comparison provably come from the same install either way.\n//\n// OUTERMOST node_modules wins, not innermost: under pnpm's linked layout __dirname realpaths to\n// <root>/node_modules/.pnpm/@webpieces+ai-hook-rules@X/node_modules/@webpieces/ai-hook-rules/src/bin —\n// the outermost segment lands on <root>, an innermost/first-ancestor rule lands inside the store.\n// With no node_modules segment at all we are running from a SOURCE checkout (vitest via tsconfig paths),\n// so walk up to the nearest ancestor that owns a shim. null = no committed shim to guard.\n// webpieces-disable no-function-outside-class -- pure fs+path helper in the dependency-free shim module, beside findShimRoot/healShim.\nexport function governingShimRoot(moduleDir: string = __dirname): string | null {\n const segments = moduleDir.split(path.sep);\n const outermost = segments.indexOf('node_modules');\n if (outermost > 0) {\n const root = segments.slice(0, outermost).join(path.sep);\n return fs.existsSync(shimPath(root)) ? root : null;\n }\n // A moduleDir that STARTS with node_modules is relative, so the root before it would be '' — i.e.\n // cwd-relative, the exact input this function exists to refuse. Nothing to govern.\n if (outermost === 0) return null;\n let dir = moduleDir;\n for (;;) {\n if (fs.existsSync(shimPath(dir))) return dir;\n const parent = path.dirname(dir);\n if (parent === dir) return null;\n dir = parent;\n }\n}\n\n// True when a committed shim EXISTS but no longer equals renderShim() (reverted, hand-edited, or a shim\n// whose LOGIC predates the installed binary). Missing shim → false: a fresh clone / global install has\n// nothing to guard, matching the old shim's `[ -f \"$WP_TEMPLATE\" ]` skip. Same comparison healShim\n// makes; never throws (an unreadable tree is treated as \"not stale\" so it can't wedge a tool call).\n//\n// The root defaults to governingShimRoot() — the decision's input is the MODULE's tree, never the cwd\n// (see governingShimRoot for the straddle this closes). The parameter exists ONLY so unit tests can\n// stage a temp root; nothing in production should pass one.\n// webpieces-disable no-function-outside-class -- pure fs+path helper in the shim module, beside healShim/renderShim.\nexport function committedShimStale(root: string | null = governingShimRoot()): boolean {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n if (root === null) return false;\n return fs.readFileSync(shimPath(root), 'utf8') !== renderShim();\n } catch (err: unknown) {\n const error = toError(err);\n void error; // best-effort: an unreadable tree counts as \"not stale\" so this never wedges a tool call\n return false;\n }\n}\n\n// True when `command` re-arms the committed shim — the two prescribed cures plus the installer, which\n// also heals the shim as its first step. These are the only commands allowed through while a stale\n// committed shim blocks everything else, so the AI can re-arm it. Each JS twin already tolerates\n// a trailing `2>&1 | tail -N` and rejects any `&&`-chained tail (see CAPTURE_TAIL_JS_SRC).\n// webpieces-disable no-function-outside-class -- pure predicate over the exported allowlist twins; belongs beside them in the shim module.\nexport function isShimCureCommand(command: string): boolean {\n const cmd = command.trim();\n return INSTALL_HOOKS_ALLOW_JS.test(cmd) || UPGRADE_SHIM_ALLOW_JS.test(cmd) || RESTORE_SHIM_ALLOW_JS.test(cmd);\n}\n\n// The shape of the fields we read out of this package's package.json.\ninterface ShimPackageManifest {\n readonly version?: string;\n}\n\n// The installed @webpieces/ai-hook-rules version, for shimStaleDenyReason's note. The binary IS this\n// package, so it reads its OWN package.json (two dirs up from src/bin). Best-effort: '' on any failure,\n// which shimStaleDenyReason renders as no note rather than a broken one.\n// webpieces-disable no-function-outside-class -- pure fs helper beside the shim module's other version plumbing.\nexport function installedShimRulesVersion(): string {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8')) as ShimPackageManifest;\n return pkg.version ?? '';\n } catch (err: unknown) {\n const error = toError(err);\n void error; // best-effort: no readable version → shimStaleDenyReason prints no note\n return '';\n }\n}\n"]}
1
+ {"version":3,"file":"shim.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/bin/shim.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\n\nimport { CONFIG_FILENAME, claudeEnv } from '@webpieces/rules-config';\n\nimport {\n L0_FAULT_BIN_BROKEN, L0_FAULT_BIN_MISSING, L0_FAULT_DRIFT, L0_FAULT_UNDECLARED,\n l0GuardHeader, l0MatrixCitation,\n} from '../core/l0-fault-codes';\nimport { toError } from '../core/to-error';\nimport {\n L0_ALLOW_ERE_SH, RECOVERY_CMD, INSTALL_HOOKS_CMD, UPGRADE_SHIM_CMD, RESTORE_SHIM_CMD,\n INSTALL_HOOKS_ALLOW_JS, UPGRADE_SHIM_ALLOW_JS, RESTORE_SHIM_ALLOW_JS,\n ADD_HOOK_PKG_CMD, HOOK_PKG,\n} from './l0-allowlist';\nimport { WP_LOG_SH } from './shim-audit-log';\nimport { DRIFT_INVERSE_FIX_SH } from './shim-drift-fix';\nimport { BASH_CWD_ENV_KEY, BASH_CWD_ENV_VALUE } from './managed-env';\n\n// The allowlist moved to ./l0-allowlist (this module was over the file-size limit); re-exported here so\n// every existing `from './shim'` import keeps working and there is still ONE name to import L0 by.\nexport * from './l0-allowlist';\n// Same treatment for the audit-log fragment: one name to import the whole rendered shim by.\nexport * from './shim-audit-log';\n\n// ---------------------------------------------------------------------------\n// The single checked-in shim (.claude/webpieces/ai-hook.sh). Both project hooks point at it, passing\n// their bin name as the first arg. settings.json points here (not at the bare bin) so a missing bin\n// (fresh clone, package removed) yields a friendly message instead of the raw `sh: No such file or\n// directory` on every Write/Edit/Bash tool call. `.claude` is committed, so the shim survives even\n// when node_modules does not.\n//\n// This module is the SINGLE SOURCE OF TRUTH for the shim body + the installer allowlist. The\n// installer (setup.ts) renders it on install; the running guards binary re-renders and self-heals it\n// (healShim) so the committed .sh can never go stale — no human ever hand-edits it.\n// ---------------------------------------------------------------------------\nexport const SHIM_MARKER = '.claude/webpieces/ai-hook.sh';\n\n// NO VERSION STAMP (removed 2026-07-24). The shim used to carry a per-release `# webpieces shim\n// version: <v> (<sha>)` on line 2, rewritten by scripts/set-version.sh at publish. It was a pure\n// human-eyeball diagnostic — nothing reads it (the deny's version note comes from the installed\n// package.json) — but it made the committed shim go byte-different on EVERY release even when the\n// logic was identical, so the committed-shim self-guard tripped on every upgrade over a comment (the\n// DENY-SHIM-STALE churn). It also carried its own hazard: stamp one of the two lockstep artifacts and\n// not the other and every consumer fail-closes forever on a phantom edit. Deleting it makes the shim\n// byte-STABLE across releases, so the self-guard (now in the binary) fires only on a genuine logic\n// change or a real tamper — which is what lets `pnpm install` be the fix for almost everything.\n\nexport function shimPath(projectRoot: string): string {\n return path.join(projectRoot, '.claude', 'webpieces', 'ai-hook.sh');\n}\n\n\n// ---------------------------------------------------------------------------\n// HOW EVERY DENY MUST SPELL ITS CURE (added 2026-07-23, from a live audit-log post-mortem).\n//\n// The guards were right, the message was right, and the assistant STILL handed the block back to the\n// human — because of one appended clause. From .webpieces/logs/L0-shim/<writer>.log in a consumer repo:\n//\n// DENY-SHIM-STALE cp node_modules/@webpieces/ai-hook-rules/templates/ai-hook.sh .claude/webpieces/ai-hook.sh && git status --short\n//\n// That is the prescribed cure, verbatim, plus `&& git status --short`. Every allowlist here is anchored\n// to `$`, so the trailing `&&` made it a different command and it was denied — and the assistant read\n// its own denial as proof that \"the guard blocks the very command that fixes it\" and stopped.\n//\n// Widening the allowlist to accept `&& <anything>` is NOT the fix: these are fail-CLOSED escape hatches\n// whose entire security property is that no shell operator can ride along (`cp … && rm -rf /`). The fix\n// is to stop the assistant appending in the first place — so every deny that prescribes a command now\n// (a) numbers its cures as OPTIONs, (b) quotes each one so the exact bytes are unambiguous, and\n// (c) carries this rule, which says in plain words that adding `&&` gets it rejected again.\n//\n// CONSTRAINT on every string that reaches a deny REASON: no double quotes and no backslashes. The\n// reason is interpolated into a `REASON=\"…\"` shell assignment and then printf'd into a JSON string, so\n// a `\"` would break BOTH. Hence single quotes around the commands here — do not \"improve\" them.\n// ---------------------------------------------------------------------------\n// LENGTH IS PART OF THE FIX (2026-08-03). This block used to run ~153 words and was repeated verbatim\n// in D, X and K — in X it was ~85% of the whole message, which buries the one sentence that matters.\n// It now states the rule and the three tolerated additions, and nothing else: the tolerated set is\n// exactly what CD_PREFIX_ERE + CAPTURE_TAIL_ERE accept (the single-quoted path branch of CD_PREFIX_ERE\n// is why \"single-quote a path containing spaces\" is named), so this text cannot promise more or less\n// than the allowlist grants.\nexport const NO_CHAINING_RULE =\n 'Run it EXACTLY as written - the allowlist matches the whole command, so appending anything ' +\n '(even && git status) makes it a different command and it is rejected; that is not the guard ' +\n 'blocking its own cure. Only these may be added: a leading cd <dir> && (single-quote a path ' +\n 'containing spaces), a trailing 2>&1, and | tail -N.';\n\n// Shell fragment: resolve the guard BIN by WALKING UP from ROOT, and remember WHERE it came from.\n//\n// THE BUG THIS CLOSES (it would have landed the day the hooks went relative). `ai-hook.sh` used to set\n// `BIN=\"$ROOT/node_modules/.bin/$BIN_NAME\"` — a LITERAL path with no upward walk, while Node's own\n// resolver walks up. That was correct only while the hooks were registered ABSOLUTE, because then ROOT\n// was always the primary clone and the bin was always there. The moment H2/H3 became relative, ROOT\n// became the tree the call is in — and a nested worktree at `<primary>/.claude/worktrees/<name>` has NO\n// node_modules of its own. Every subagent would have hard-blocked on fault X at its first tool call,\n// fleet-wide, on the day of the flip. Walking up finds the primary's install, exactly as a `require()`\n// from the same directory would; a SIBLING worktree finds nothing and correctly still faults X.\n//\n// BIN_ROOT is not a curiosity: walking up ALONE re-creates the version straddle documented above\n// committedShimStale(), where the shim of one tree is paired with the binary of another and the cure\n// can never converge. So the walk is paired with a check — DECLARED comes from `$ROOT/package.json`\n// (the tree being judged) and INSTALLED comes from `$BIN_ROOT/node_modules` (the binary actually\n// running). Equal → no fault, keep reusing the inherited bin, which is the common case and stays free.\n// Different → fault D, cured by an install in THIS tree, which materialises its own node_modules.\nconst RESOLVE_BIN_SH = `BIN_ROOT=\"\\$ROOT\"\nBIN=\"\\$ROOT/node_modules/.bin/\\$BIN_NAME\"\nWP_WALK=\"\\$ROOT\"\nwhile [ ! -x \"\\$WP_WALK/node_modules/.bin/\\$BIN_NAME\" ]; do\n WP_UP=\"\\$(dirname -- \"\\$WP_WALK\")\"\n [ \"\\$WP_UP\" != \"\\$WP_WALK\" ] || break\n WP_WALK=\"\\$WP_UP\"\ndone\nif [ -x \"\\$WP_WALK/node_modules/.bin/\\$BIN_NAME\" ]; then\n BIN_ROOT=\"\\$WP_WALK\"\n BIN=\"\\$WP_WALK/node_modules/.bin/\\$BIN_NAME\"\nfi`;\n\n// Normal template literal (not String.raw): it carries #235's shell escapes verbatim (\\${BIN_NAME},\n// \\$REASON, \\\\n for the deny JSON) AND my sed backslashes (doubled: \\\\(, \\\\), \\\\1, [^\"\\\\\\\\]). The\n// grep pattern is interpolated from INSTALLER_ALLOW_ERE (its value has no backslashes).\n// Shell fragment: the two JSON escapes the deny text is built from, plus the one shared \"what is still\n// allowed\" block. Hoisted to the TOP of the shim (it used to sit inside DENY_EMIT_SH's Bash branch,\n// i.e. AFTER every REASON was already assembled) because the deny text now needs the newline escape\n// while it is being BUILT, not only while it is being printed.\n//\n// THE MECHANISM IS THE ONE THE COLOUR ALREADY USED. `REASON` is interpolated into a `REASON=\"…\"` shell\n// assignment and then printf'd into a JSON string literal, so it may contain no RAW double-quote and no\n// RAW backslash — and a RAW newline would be invalid JSON. That constraint is not \"no newlines\": it is\n// \"no raw backslash\", and `${BS}` produces the backslash at RUNTIME, so `${ESC}` (six chars: \\ u 0 0 1 b)\n// and `${NL}` (two chars: \\ n) both travel as legal JSON escapes that Claude Code's parser turns back\n// into a real ESC and a real newline. Verified end to end through /bin/sh in setup.spec.ts: the payload\n// still parses as JSON, the systemMessage still carries 31;1m, and the reason renders as many lines.\n//\n// WHY THE STRUCTURE MATTERS: every L1/L2 deny is rendered by formatReport() into a scannable shape —\n// header, `[guard-name] (N violations)`, indented offenders each with a one-line `→ why`, then numbered\n// `Fix Option N:` lines. L0 was the ONLY layer answering in one unbroken paragraph. It now uses the same\n// skeleton, which is what WP_STILL_ALLOWED exists for: one definition of that section for all four\n// sh-side faults, so they cannot drift into four different answers to the same question.\nconst ESCAPES_SH = `BS='\\\\' # one literal backslash, so no \\\\u001b / \\\\n escape sits in this source\nESC=\"\\${BS}u001b\" # the 6 chars: backslash u 0 0 1 b — Claude Code parses \\\\u001b → ESC\nNL=\"\\${BS}n\" # the 2 chars: backslash n — parsed as a real newline inside the JSON string\nWP_STILL_ALLOWED=\"Still allowed while this block is up:\\${NL} - any Read\\${NL} - any Write/Edit whose target is ${CONFIG_FILENAME}\\${NL} - every command on the L0 allowlist, including the Fix Options below\\${NL} THIS IS NOT A DEADLOCK - run one YOURSELF now; do not hand it back to the human.\"`;\n\n// Shell fragment: the version-drift guard (see its own block comment). Extracted to a module const so\n// renderShim() stays within the method-line budget; it is spliced back in verbatim, byte-for-byte.\nconst VERSION_DRIFT_GUARD_SH = `# --- webpieces version-drift guard (pure sh — runs even when the installed guard bin is stale) -----\n# The committed shim is version-agnostic, so it keeps working right after a git pull, BEFORE the\n# matching pnpm install. That is exactly when node_modules can be STALE: an OLDER @webpieces than\n# package.json now pins, whose outdated validator rejects the NEWER webpieces.config.json with baffling\n# \"unknown rule\" errors. Detect that drift HERE (before exec'ing the possibly-stale bin): compare every\n# EXACT-pinned @webpieces/* version in the root package.json against the version actually installed in\n# node_modules; the first mismatch wins. Range specs (^ ~ workspace:*) are skipped, so they never\n# false-positive; best-effort — a version we cannot read is skipped. On drift we fall through to the\n# SAME fail-closed path as a missing bin (allow only pnpm install, deny the rest).\n#\n# pnpm CATALOGS: a dep pinned via \"catalog:\" / \"catalog:<name>\" carries NO digit-version in package.json,\n# so the old scraper matched nothing and the guard was BLIND to it — DRIFT_PKG stayed empty and the\n# stale bin ran (the 2026-07 \"0.3.369 vs 0.4.405\" incident). Resolve those specs through the top-level\n# \\`catalogs:\\` block of pnpm-lock.yaml (catalog -> pkg -> resolved version) before comparing.\n#\n# THE SAME PASS ANSWERS FAULT U (2026-08-05). Scraping root package.json is also the only way to learn\n# whether @webpieces/ai-hook-rules is DECLARED at all, and that is the difference between \"not installed\n# yet\" (X, cured by pnpm install) and \"nothing asks for it\" (U, where pnpm install is a guaranteed\n# no-op). WP_PIN carries the first EXACT @webpieces pin found, so U's deny can prescribe the version the\n# rest of the repo is already on rather than an unpinned add. Both are set BEFORE the range/catalog\n# \\`continue\\`s, so a repo pinning the package by range still counts as having declared it.\nDRIFT_PKG=\"\"\nDRIFT_DECLARED=\"\"\nDRIFT_INSTALLED=\"\"\nWP_HOOK_PKG_DECLARED=\"\"\nWP_PIN=\"\"\nif [ -f \"$ROOT/package.json\" ]; then\n # Only when a @webpieces dep actually uses a \"catalog:\" spec do we scan the (possibly huge) lockfile —\n # a cheap grep keeps the common, catalog-free repo from paying that cost on every tool call. One awk\n # pass over pnpm-lock.yaml emits \"<catalog> <@webpieces/pkg> <version>\" lines for the sh lookup below;\n # \\\\047 is a single quote (so this awk program carries none and stays safely single-quotable in sh).\n WP_CATALOGS=\"\"\n if grep -Eq '\"@webpieces/[^\"]*\"[[:space:]]*:[[:space:]]*\"catalog:' \"$ROOT/package.json\" 2>/dev/null && [ -f \"$ROOT/pnpm-lock.yaml\" ]; then\n WP_CATALOGS=\"$(awk '\n { n=0; while (substr($0,n+1,1)==\" \") n++; c=substr($0,n+1) }\n c==\"\" { next }\n n==0 { incat=(c ~ /^catalogs: *$/)?1:0; cat=\"\"; pkg=\"\"; next }\n incat==0 { next }\n n==2 { cat=c; sub(/:.*/,\"\",cat); pkg=\"\"; next }\n n==4 { pkg=c; sub(/: *$/,\"\",pkg); gsub(/[\"\\\\047]/,\"\",pkg); next }\n n==6 && substr(pkg,1,11)==\"@webpieces/\" && c ~ /^version:/ {\n v=c; sub(/^version: */,\"\",v); gsub(/[\"\\\\047 ]/,\"\",v);\n if (cat!=\"\" && v!=\"\") print cat \" \" pkg \" \" v\n }\n ' \"$ROOT/pnpm-lock.yaml\" 2>/dev/null)\"\n fi\n while IFS=' ' read -r WP_NAME WP_DECL; do\n [ -n \"$WP_NAME\" ] || continue\n # Fault U's input: the package is DECLARED (in any spec shape, in any dependency block of the root\n # manifest). Recorded before every \\`continue\\` below, so a range or catalog spec still counts.\n [ \"$WP_NAME\" = \"ai-hook-rules\" ] && WP_HOOK_PKG_DECLARED=1\n # Resolve the declared spec to an EXACT version, or skip it: ranges (^ ~ workspace:*) never drift,\n # and a catalog spec we cannot resolve is best-effort skipped rather than guessed.\n case \"$WP_DECL\" in\n catalog:*)\n WP_CAT=\"\\${WP_DECL#catalog:}\"; [ -n \"$WP_CAT\" ] || WP_CAT=\"default\"\n WP_DECL=\"$(printf '%s\\\\n' \"$WP_CATALOGS\" | awk -v c=\"$WP_CAT\" -v p=\"@webpieces/$WP_NAME\" '$1==c && $2==p {print $3; exit}')\"\n [ -n \"$WP_DECL\" ] || continue ;;\n [0-9]*) : ;;\n *) continue ;;\n esac\n # The release the rest of this repo is on — what fault U's cure should pin to.\n [ -n \"$WP_PIN\" ] || WP_PIN=\"$WP_DECL\"\n WP_MANIFEST=\"$BIN_ROOT/node_modules/@webpieces/$WP_NAME/package.json\"\n [ -f \"$WP_MANIFEST\" ] || continue\n WP_INST=\"$(sed -n 's/.*\"version\"[[:space:]]*:[[:space:]]*\"\\\\([^\"]*\\\\)\".*/\\\\1/p' \"$WP_MANIFEST\" | head -n1)\"\n [ -n \"$WP_INST\" ] || continue\n if [ \"$WP_DECL\" != \"$WP_INST\" ]; then\n DRIFT_PKG=\"@webpieces/$WP_NAME\"\n DRIFT_DECLARED=\"$WP_DECL\"\n DRIFT_INSTALLED=\"$WP_INST\"\n break\n fi\n done <<WPEOF\n$(sed -n 's/.*\"@webpieces\\\\/\\\\([A-Za-z0-9._-]*\\\\)\"[[:space:]]*:[[:space:]]*\"\\\\([^\"]*\\\\)\".*/\\\\1 \\\\2/p' \"$ROOT/package.json\")\nWPEOF\nfi\n# THE CURE FOR A BORROWED node_modules RUNS IN THIS TREE, NOT WHEREVER THE BIN CAME FROM. A bare\n# 'pnpm install' typed while the shell sits in the primary clone installs into the primary, changes\n# nothing in the worktree being judged, and re-fires the identical fault — the four-cure straddle\n# recorded above committedShimStale(). When the bin was inherited, prescribe the cd and say why.\nWP_INSTALL_CMD=\"pnpm install\"\nWP_BORROW_NOTE=\"\"\nif [ \"$BIN_ROOT\" != \"$ROOT\" ]; then\n WP_INSTALL_CMD=\"cd $ROOT && pnpm install\"\n WP_BORROW_NOTE=\"\\${NL} NOTE: this tree ($ROOT) has NO node_modules of its own, so the guard binary was inherited from $BIN_ROOT by walking up. TWO cures are real and they fix DIFFERENT things: A makes THIS tree work now, B stops the two trees disagreeing. A - run the command above HERE. That is legitimate and it does work; a worktree NEEDS its own node_modules anyway (nx, vitest and the eslint plugin all execute in this tree and load from it). B - get $BIN_ROOT onto the same @webpieces version: put both trees on the same git hash (the pin is tracked) and run ONE 'pnpm install' there. If you are a SUBAGENT, B is the half you cannot do: it needs both trees on the same git hash, and CROSS-TREE GIT is refused to you here (a local 'cd' + install does run - it is git -C another tree that is blocked). So ESCALATE B: TELL THE MAIN AGENT in the MAIN git worktree ($BIN_ROOT) to run 'git pull && pnpm install' there so both trees are on the same @webpieces version, and to tell you when it is complete so you can continue. If you escalate instead of doing A, that forwarding IS the end of your turn: STOP WORKING NOW, make NO further tool calls and do NOT retry - RETRYING IS THE BUG, because every retry re-fires this identical deny and buries the ask. WAIT for that confirmation, then resume. The rule is NOT no-install-here: it is that this tree's @webpieces must EQUAL $BIN_ROOT's, because doing only A leaves two trees on two releases (the trinary-version-skew guard then BLOCKS rather than letting it pass unnoticed). Adding an ordinary third-party dependency here changes none of that - only a differing @webpieces version does. If this tree genuinely needs a DIFFERENT version, use a separate clone rather than a worktree.\"\nfi`;\n\n// Shell fragment: run the installed guard bin and INSPECT its outcome, instead of exec'ing it.\n//\n// THE BUG THIS FIXES (guards silently fail-OPEN): the shim used to `exec \"$BIN\"`. exec REPLACES this\n// shim process, so once the bin was executable the shim was GONE and could no longer make a decision.\n// That is fine when the bin runs — but the bin can be INSTALLED YET BROKEN: a corrupt/partially-written\n// node_modules makes node die at require() time with MODULE_NOT_FOUND, exiting 1. And in the PreToolUse\n// protocol ONLY exit 2 blocks: any other non-zero is a NON-BLOCKING error, so Claude Code prints\n// \"Failed with non-blocking status code\" and RUNS THE TOOL CALL ANYWAY — the guard is silently skipped.\n// Result: every Write/Edit/Bash went UNGUARDED, for as long as node_modules stayed corrupt. The shim\n// handled \"bin missing\" and \"bin stale\", but never \"bin present and CRASHES\" — the third failure mode.\n//\n// So: do not exec. Run the bin with the payload on stdin and branch on its exit code.\n// rc 0 | 2 → a REAL decision (allow / block). Relay stdout, stderr and the code byte-faithfully.\n// anything else → the guard CRASHED. Fall through to the fail-CLOSED path (BROKEN_BIN=1).\n// stdout/stderr go through temp FILES, not $(command substitution), so the bin's bytes reach Claude\n// Code exactly as written — command substitution strips trailing newlines and would corrupt the\n// decision JSON. Reading the payload up-front ($PAYLOAD) is what replaces exec's stdin passthrough.\nconst RUN_BIN_SH = `if [ -x \"\\$BIN\" ] && [ -z \"\\$DRIFT_PKG\" ]; then\n OUT_FILE=\"\\${TMPDIR:-/tmp}/wp-ai-hook-out.\\$\\$\"\n ERR_FILE=\"\\${TMPDIR:-/tmp}/wp-ai-hook-err.\\$\\$\"\n printf '%s' \"\\$PAYLOAD\" | \"\\$BIN\" \"\\$@\" >\"\\$OUT_FILE\" 2>\"\\$ERR_FILE\"\n RC=\\$?\n if [ \"\\$RC\" = 0 ] || [ \"\\$RC\" = 2 ]; then\n cat \"\\$OUT_FILE\" # the guard's real decision — verbatim\n cat \"\\$ERR_FILE\" >&2\n rm -f \"\\$OUT_FILE\" \"\\$ERR_FILE\" 2>/dev/null\n # THE HEALTHY CALL, which this log used to be silent about (see WP_LOG_SH's header). No sh-side\n # fault, the bin ran, and its own exit code says how it ended — 0 allow, 2 block. Logged AFTER the\n # decision bytes are already on stdout, so the audit trail never sits between the guard and Claude\n # Code. Without this line, \"no entry\" meant either healthy or never-ran, and those are the two\n # answers a reader most needs to tell apart.\n WP_VERDICT=PASS-BIN-ALLOW\n [ \"\\$RC\" = 2 ] && WP_VERDICT=PASS-BIN-BLOCK\n wp_log - \"\\$WP_VERDICT\"\n exit \"\\$RC\"\n fi\n # Crashed. Keep the most useful stderr line for the human. Strip \" and backslash so the text stays a\n # valid JSON string, and cap the length so a giant node stack cannot blow up the deny payload.\n CRASH_MSG=\"\\$(grep -m1 'Cannot find module' \"\\$ERR_FILE\" 2>/dev/null | tr -d '\"\\\\\\\\' | cut -c1-120)\"\n [ -n \"\\$CRASH_MSG\" ] || CRASH_MSG=\"\\$(head -n1 \"\\$ERR_FILE\" 2>/dev/null | tr -d '\"\\\\\\\\' | cut -c1-120)\"\n [ -n \"\\$CRASH_MSG\" ] || CRASH_MSG=\"exit code \\$RC, no stderr\"\n rm -f \"\\$OUT_FILE\" \"\\$ERR_FILE\" 2>/dev/null\n BROKEN_BIN=1\nfi`;\n\n// Shell fragment: pull the four fields the shim itself reasons about out of the tool payload.\n//\n// MOVED AHEAD OF THE BIN (it used to sit inside TRIAGE_SH, i.e. only on the fail-closed path) because\n// the audit log now covers the HEALTHY call too, and a log line needs the tool and the command whether\n// or not anything went wrong.\n//\n// `cwd` is Claude Code's documented \"current working directory when the hook is invoked\". It is used\n// for ONE thing: deciding which tree's log directory this line belongs in (see RESOLVE_LOG_DIR_SH).\n// It deliberately does NOT change what the drift guard MEASURES — that stays anchored to $ROOT, the\n// tree the shim FILE lives in. Where a call is logged and what a call is judged against are separate\n// questions and are kept separate here.\n// TWO command variables, and the split is a SECURITY boundary — do not collapse them.\n//\n// $CMD is the DECISION input (the L0 allowlist greps it). Its pattern requires the CLOSING quote, so a\n// JSON payload that escapes an embedded quote as \\\\\" yields the EMPTY STRING for the whole command.\n// That looks like a bug and is in fact the safe direction: an empty command matches no allowlist entry,\n// so a quoted command falls through to the deny. FAIL CLOSED. Keep it that way.\n//\n// $CMD_LOG is the AUDIT input and must never reach a decision. It drops the closing quote from the\n// pattern so it captures the command PREFIX instead of nothing.\n//\n// WHY THEY CANNOT BE ONE VARIABLE: every L0 allowlist ERE is anchored `^…[[:space:]]*$`, and trailing\n// whitespace is tolerated — so `pnpm install \"; rm -rf /\"` would prefix-capture to `pnpm install `,\n// which MATCHES, and the injection after the quote would ride through allowlisted. Measured 2026-08-06:\n// 3,908 of 4,917 shim audit lines (79.5%) recorded an empty command, i.e. four out of five audit\n// entries were blind. Fixing the LOG is worth doing; fixing the DECISION the same way is a hole.\nconst PARSE_PAYLOAD_SH = `CMD=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"command\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\nCMD_LOG=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"command\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\).*/\\\\1/p')\"\n[ -n \"\\$CMD_LOG\" ] || CMD_LOG=\"\\$CMD\"\nTOOL=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"tool_name\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\nWP_SID=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"session_id\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\nWP_AID=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"agent_id\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\nFILE=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"file_path\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\nWP_CWD=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"cwd\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\n[ -n \"\\$WP_CWD\" ] || WP_CWD=\"\\$ROOT\" # no cwd in the payload (older client, or a hand-run) → the shim's own tree`;\n\n// Shell fragment: the guards are DOWN (missing | stale | crashed). Classify the fault, then let THE L0\n// ALLOWLIST through — everything else falls to the deny below.\n//\n// This asks the identical question isAllowed() asks in JS, in the same order: Read, then the\n// webpieces.config.json target, then the one command union (L0_ALLOW_ERE). The sh and JS halves exist\n// because D/X/K are decided BEFORE the bin runs (a stale/missing/broken validator cannot validate\n// itself) while S/C/Y are decided inside it — one model, two enforcement points.\n//\n// NOTE the documented asymmetry: here the bin is never executed, so an allowed Read is TERMINAL and\n// read-stale-guard does not run. In JS the same entry falls through and it does. See isAllowed().\nconst TRIAGE_SH = `# WHICH of the guards/L0-tooling.md faults fired, in the doc's own letters. Only the four sh-side\n# codes can be decided here; S/C/Y live in the binary, which never got to run on this path.\nWP_FAULT=${L0_FAULT_BIN_MISSING} # X — bin missing (fresh clone, new worktree)\n[ -z \"\\$WP_HOOK_PKG_DECLARED\" ] && WP_FAULT=${L0_FAULT_UNDECLARED} # U — X, but nothing declares the package: install is a no-op\n[ -n \"\\$DRIFT_PKG\" ] && WP_FAULT=${L0_FAULT_DRIFT} # D — version drift; D and K are mutually exclusive\n[ -n \"\\$BROKEN_BIN\" ] && WP_FAULT=${L0_FAULT_BIN_BROKEN} # K — bin present but CRASHED (corrupt node_modules)\nDENY_LABEL=\"DENY\"\n[ -z \"\\$WP_HOOK_PKG_DECLARED\" ] && DENY_LABEL=\"DENY-UNDECLARED\" # nothing in package.json asks for the package\n[ -n \"\\$DRIFT_PKG\" ] && DENY_LABEL=\"DENY-STALE\" # version drift, not a missing bin\n[ -n \"\\$BROKEN_BIN\" ] && DENY_LABEL=\"DENY-BROKEN\" # bin present but CRASHED (corrupt node_modules)\n# THE L0 ALLOWLIST, entry order identical to isAllowed(). No fault is consulted: a cure that cannot\n# help a given fault also cannot hurt it, and gating each entry on a fault is what produced the four\n# defects recorded above L0_ALLOW_ERE.\nif [ \"\\$TOOL\" = \"Read\" ]; then\n wp_log \"\\$WP_FAULT\" ALLOW-READ # you must be able to read to work out how to fix this\n exit 0\nfi\ncase \"\\$FILE\" in\n */${CONFIG_FILENAME}|${CONFIG_FILENAME})\n wp_log \"\\$WP_FAULT\" ALLOW-CONFIG # the always-allowed recovery target — every guard is configured from it\n exit 0 ;;\nesac\nif printf '%s' \"\\$CMD\" | grep -Eq '${L0_ALLOW_ERE_SH}'; then\n wp_log \"\\$WP_FAULT\" ALLOW-CURE # record the self-heal we let through (re-enables the guards)\n exit 0 # allow the cure so the assistant can break the deadlock\nfi\nwp_log \"\\$WP_FAULT\" \"\\$DENY_LABEL\" # every fail-closed block, with the fault that caused it`;\n\n// Shell fragment: emit the deny. FAIL CLOSED via Claude Code's PreToolUse JSON protocol\n// (permissionDecision \"deny\" on stdout, then exit 0) rather than a bare \"exit 2\". BOTH block the call,\n// but the reason must be made VISIBLE, and HOW depends on the tool (verified by live tests; the docs\n// are wrong here):\n// - Bash deny: permissionDecisionReason is NOT shown to the human — ONLY a top-level systemMessage\n// is, and it honors ANSI. So for Bash we emit systemMessage wrapped in ANSI red so the\n// recovery command is visible (without it, on Bash, it is invisible).\n// - Write/Edit/MultiEdit deny: permissionDecisionReason renders as a RED \"Error:\" block natively —\n// no systemMessage needed (a second line would be redundant).\n// - NEVER exit 2 (stdout JSON ignored; stderr not reliably shown on a blocked Bash call).\n// The ESC and the newline escape are both built in ESCAPES_SH at the TOP of the shim (see its header):\n// ${ESC} is the literal 6-char JSON escape \\\\u001b and ${NL} the 2-char \\\\n, so no raw ESC byte, no raw\n// newline and no \\\\uXXXX sits in this source, and Claude Code's JSON parser turns both back. The reason\n// is a single JSON string with no RAW double-quotes/backslashes, so it stays valid JSON after the subs.\n//\n// ONLY THE HEADLINE IS RED, and that is deliberate — the same call redSystemMessage() makes on the JS\n// side. The reason is MULTI-LINE now, and a whole page in bold red is harder to read than the paragraph\n// it replaced: the indentation carrying the structure stops registering when every line shouts. So\n// \\$WP_HEAD (each branch's own first line, kept in its own variable for exactly this) is wrapped in\n// [31;1m … [0m, and \\${REASON#\"\\$WP_HEAD\"} — POSIX prefix removal with a QUOTED pattern, so the\n// headline is matched literally and not as a glob — supplies the plain body after it.\nconst DENY_EMIT_SH = `if [ \"\\$TOOL\" = \"Bash\" ]; then\n printf '{\"systemMessage\":\"%s🛑 %s%s%s\",\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"%s\"}}\\\\n' \"\\${ESC}[31;1m\" \"\\$WP_HEAD\" \"\\${ESC}[0m\" \"\\${REASON#\"\\$WP_HEAD\"}\" \"\\$REASON\"\nelse\n printf '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"%s\"}}\\\\n' \"\\$REASON\"\nfi\nexit 0 # decision is carried by permissionDecision \"deny\", not the exit code`;\n\n// Shell fragment: pick the fail-closed deny REASON — a crashed-bin message (corrupt node_modules) vs a\n// version-drift message (bin present but stale) vs the missing-bin message. Extracted alongside\n// VERSION_DRIFT_GUARD_SH / RUN_BIN_SH to keep renderShim() within the method-line budget.\nconst DENY_REASON_SH = `if [ -n \"\\$BROKEN_BIN\" ]; then\n # Report (do NOT auto-clean) the orphaned pnpm staging dirs — a package pnpm was mid-way through\n # writing is left behind as <name>_<pid>_<hash>. Their presence is the fingerprint of an install that\n # was killed, which is what corrupts node_modules in the first place. Best-effort; never fatal.\n STAGING_N=\"\\$(ls \"\\$BIN_ROOT/node_modules\" 2>/dev/null | grep -Ec '_[0-9a-f]+_[0-9a-f]+\\$' || true)\"\n STAGING_NOTE=\"\"\n if [ \"\\${STAGING_N:-0}\" -gt 0 ] 2>/dev/null; then\n STAGING_NOTE=\"\\${NL} → also found \\$STAGING_N orphaned pnpm staging dirs (name_pid_hash) under node_modules - the fingerprint of an install that was killed mid-write.\" # only when N > 0\n fi\n # The HEADLINE, kept in its own variable so DENY_EMIT_SH can paint ONLY it red (see there).\n WP_HEAD=\"❌ webpieces ai-hooks blocked this call: the webpieces guards are DOWN.\"\n REASON=\"\\$WP_HEAD\\${NL}\\${NL}${l0GuardHeader(L0_FAULT_BIN_BROKEN, '1 violation')}\\${NL} \\${BIN_NAME} (\\$CRASH_MSG)\\${NL} → it is installed but CRASHED, so your node_modules is corrupt or partially written; the guards cannot run and they must not be silently skipped. Every OTHER tool call is BLOCKED until they can.\\${STAGING_NOTE}\\${NL} → ${l0MatrixCitation(L0_FAULT_BIN_BROKEN)}\\${NL}\\${NL}\\${WP_STILL_ALLOWED}\\${NL}\\${NL} Fix Option 1: (preferred) the only cure. A bare 'pnpm install' will NOT fix this, because pnpm sees the correct version on disk and skips the broken package\\${NL} run EXACTLY: '${RECOVERY_CMD}'\\${NL}\\${NL}${NO_CHAINING_RULE}\"\nelif [ -n \"\\$DRIFT_PKG\" ]; then\n # DECIDE THE DIRECTION, do not make the reader do it (2026-08-03). The detection is a plain !=, so it\n # fires BOTH ways, and the message used to carry OPTION 1/2/3 covering every direction at once — 3343\n # chars of which only about a third was the decision. A reader on the wrong branch of that menu was\n # one misread away from a downgrade. So compare the two versions HERE and emit only the relevant half.\n #\n # WITH AWK, not \\`sort -V\\`: -V is a GNU extension (absent/different on BSD sort), while the shim\n # already runs an awk pass to resolve catalog: specs, so awk adds no dependency. The program compares\n # the numeric cores component-by-component; a pre-release/build suffix (-rc.1, +sha) is stripped from\n # the core, and when the cores are EQUAL the side carrying a PRE-RELEASE suffix is the older one\n # (semver precedence). Build metadata (+sha) carries NO precedence, so two versions differing only\n # there come back undecidable rather than ordered. Anything it cannot parse prints NOTHING, and an\n # empty answer falls through to the ambiguous wording below rather than guessing a direction.\n #\n # WHAT WAS DELETED, so it does not creep back: the \"how to get main itself current\" paragraph and the\n # \"do NOT reach for git merge --ff-only / reset --hard / checkout -B main\" paragraph both belong to\n # redirect-how-to-merge-main, which fires on its own with its own message; and the sentence that named\n # wp-start-update / wp-start-upsert-pr ONLY to forbid them while the block is up — naming a command\n # purely to forbid it is pure cost, and the install that clears this fault comes first regardless.\n DRIFT_DIR=\"\\$(awk -v i=\"\\$DRIFT_INSTALLED\" -v d=\"\\$DRIFT_DECLARED\" 'BEGIN {\n iv = i; sub(/\\\\+.*/, \"\", iv); ic = iv; sub(/-.*/, \"\", ic); ip = substr(iv, length(ic) + 1)\n dv = d; sub(/\\\\+.*/, \"\", dv); dc = dv; sub(/-.*/, \"\", dc); dp = substr(dv, length(dc) + 1)\n if (ic !~ /^[0-9]+(\\\\.[0-9]+)*\\$/ || dc !~ /^[0-9]+(\\\\.[0-9]+)*\\$/) exit\n n = split(ic, ia, \".\"); m = split(dc, da, \".\"); k = (n > m) ? n : m\n for (x = 1; x <= k; x++) {\n av = (x <= n) ? ia[x] + 0 : 0; bv = (x <= m) ? da[x] + 0 : 0\n if (av < bv) { print \"older\"; exit }\n if (av > bv) { print \"newer\"; exit }\n }\n if (ip == dp) exit\n if (ip != \"\" && dp == \"\") print \"older\"\n if (ip == \"\" && dp != \"\") print \"newer\"\n }' 2>/dev/null)\"\n if [ \"\\$DRIFT_DIR\" = older ]; then\n # The HEADLINE, kept in its own variable so DENY_EMIT_SH can paint ONLY it red (see there).\n WP_HEAD=\"❌ webpieces ai-hooks blocked this call: webpieces version drift.\"\n REASON=\"\\$WP_HEAD\\${NL}\\${NL}${l0GuardHeader(L0_FAULT_DRIFT, '1 violation')}\\${NL} package.json pins \\$DRIFT_PKG@\\$DRIFT_DECLARED but node_modules has \\$DRIFT_INSTALLED\\${NL} → node_modules is OLDER, so the pin is what you want. Every OTHER tool call is BLOCKED until the two agree.\\${NL} → ${l0MatrixCitation(L0_FAULT_DRIFT)}\\${NL}\\${NL}\\${WP_STILL_ALLOWED}\\${NL}\\${NL} Fix Option 1: (preferred) the only cure - it makes node_modules match the pin\\${NL} run EXACTLY: '\\$WP_INSTALL_CMD'\\${WP_BORROW_NOTE}\\${NL}\\${NL}${NO_CHAINING_RULE}\"\n else\n # NEWER, or undecidable — the same choices apply either way, so the only thing the ambiguous\n # case changes is the claim about which side is stale.\n DRIFT_NOTE=\"node_modules is NEWER, so the PIN is the stale side and a bare 'pnpm install' DOWNGRADES you to \\$DRIFT_DECLARED\"\n [ \"\\$DRIFT_DIR\" = newer ] || DRIFT_NOTE=\"these two versions could not be ordered automatically - compare them yourself: if node_modules is the NEWER side then the PIN is the stale side and a bare 'pnpm install' DOWNGRADES you to \\$DRIFT_DECLARED\"\n ${DRIFT_INVERSE_FIX_SH}\n # The HEADLINE, kept in its own variable so DENY_EMIT_SH can paint ONLY it red (see there).\n WP_HEAD=\"❌ webpieces ai-hooks blocked this call: webpieces version drift.\"\n REASON=\"\\$WP_HEAD\\${NL}\\${NL}${l0GuardHeader(L0_FAULT_DRIFT, '1 violation')}\\${NL} package.json pins \\$DRIFT_PKG@\\$DRIFT_DECLARED but node_modules has \\$DRIFT_INSTALLED\\${NL} → \\$DRIFT_NOTE. That may be exactly what you want. Every OTHER tool call is BLOCKED until the two agree.\\${NL} → ${l0MatrixCitation(L0_FAULT_DRIFT)}\\${NL}\\${NL}\\${WP_STILL_ALLOWED}\\${NL}\\${NL}\\${WP_FIX}\\${WP_BORROW_NOTE}\\${NL}\\${NL}${NO_CHAINING_RULE}\"\n fi\nelse\n # A LINKED WORKTREE is the overwhelmingly common way to land here with a perfectly healthy repo:\n # git gives the new worktree a .git FILE (the primary clone has a .git directory) and copies no\n # node_modules, so the very first tool call in a brand-new worktree fail-closes on a missing bin.\n # Naming that explicitly turns a baffling \"not installed\" into a one-command fix, and the HERE is\n # load-bearing: installing in the primary clone does nothing for this tree.\n WORKTREE_NOTE=\"\"\n if [ -f \"\\$ROOT/.git\" ]; then\n WORKTREE_NOTE=\"\\${NL} → \\$ROOT is a LINKED WORKTREE - git does not copy node_modules into a new worktree, so this is expected on a fresh one. Run the Fix Option HERE, in this worktree, not in the primary clone.\"\n fi\n if [ -z \"\\$WP_HOOK_PKG_DECLARED\" ]; then\n # FAULT U — the one shape where the X message is not merely unhelpful but actively WRONG. It asserted\n # \"declared in package.json\" without ever checking, and prescribed the one command that provably\n # cannot help: with nothing asking for the package, \\`pnpm install\\` reports \"Lockfile is up to date\"\n # and converges to the identical broken tree, forever. So say what is actually true, say out loud\n # that the install is a no-op (an agent that has already run it needs to be told to STOP), and\n # prescribe the add — which is allowlist entry ADD_HOOK_PKG, so it is reachable while this block is up.\n WP_ADD_CMD=\"${ADD_HOOK_PKG_CMD}\"\n [ -n \"\\$WP_PIN\" ] && WP_ADD_CMD=\"\\${WP_ADD_CMD}@\\$WP_PIN\"\n # The HEADLINE, kept in its own variable so DENY_EMIT_SH can paint ONLY it red (see there).\n WP_HEAD=\"❌ webpieces ai-hooks blocked this call: the guard package is not declared anywhere.\"\n REASON=\"\\$WP_HEAD\\${NL}\\${NL}${l0GuardHeader(L0_FAULT_UNDECLARED, '1 violation')}\\${NL} ${HOOK_PKG} is NOT declared in package.json anywhere, and is not installed (\\${BIN_NAME} not found)\\${NL} → .claude/settings.json still runs its hooks, so every OTHER tool call is BLOCKED. Do NOT run 'pnpm install': nothing asks for this package, so it is a NO-OP and repeating it converges to this same state.\\${NL} → ${l0MatrixCitation(L0_FAULT_UNDECLARED)}\\${NL}\\${NL}\\${WP_STILL_ALLOWED}\\${NL}\\${NL} Fix Option 1: (preferred) declare it directly, to unblock yourself right now\\${NL} run EXACTLY: '\\$WP_ADD_CMD'\\${NL} Fix Option 2: the durable fix - ${HOOK_PKG} normally arrives with @webpieces/nx-webpieces-rules, the umbrella that bundles the whole toolchain, so upgrade that once you are unblocked.\\${NL} NOT an option: if you removed ${HOOK_PKG} on purpose, delete its hooks from .claude/settings.json instead.\\${NL}\\${NL}${NO_CHAINING_RULE}\"\n else\n # The HEADLINE, kept in its own variable so DENY_EMIT_SH can paint ONLY it red (see there).\n WP_HEAD=\"❌ webpieces ai-hooks blocked this call: the webpieces guard bin is not installed.\"\n REASON=\"\\$WP_HEAD\\${NL}\\${NL}${l0GuardHeader(L0_FAULT_BIN_MISSING, '1 violation')}\\${NL} ${HOOK_PKG} is declared in package.json but is not installed (\\${BIN_NAME} not found)\\${NL} → the guards cannot run, so every OTHER tool call is BLOCKED until they can.\\${WORKTREE_NOTE}\\${NL} → ${l0MatrixCitation(L0_FAULT_BIN_MISSING)}\\${NL}\\${NL}\\${WP_STILL_ALLOWED}\\${NL}\\${NL} Fix Option 1: (preferred) the only cure - it materializes what package.json already asks for\\${NL} run EXACTLY: 'pnpm install'\\${NL} NOT an option: if you removed ${HOOK_PKG} on purpose, delete its hooks from .claude/settings.json instead.\\${NL}\\${NL}${NO_CHAINING_RULE}\"\n fi\nfi`;\n\nexport function renderShim(): string {\n return `#!/bin/sh\n# Managed by @webpieces/ai-hook-rules (wp-install-ai-hooks) — do not edit. This file is GENERATED from\n# renderShim() and is intentionally VERSION-AGNOSTIC and byte-STABLE across releases: it carries no\n# version stamp, so it only changes when its own logic changes. The installed guards binary is what\n# checks that this committed copy still matches renderShim() (the committed-shim self-guard); if you\n# revert or hand-edit this file the binary fails closed and names the cure. Checked in on purpose so\n# the hook has a stable entry point even when node_modules is absent. Safe to delete along with the\n# matching .claude/settings.json entries if you remove @webpieces/ai-hook-rules.\n#\n# Usage (wired into .claude/settings.json, ABSOLUTE so the MAIN tree governs every tree):\n# sh \"$CLAUDE_PROJECT_DIR/.claude/webpieces/ai-hook.sh\" <bin-name>\nBIN_NAME=\"$1\"\nshift\n# Resolve the tree relative to THIS script (…/<root>/.claude/webpieces/ai-hook.sh → <root>), not the\n# caller's cwd — the hook can be invoked from any directory (a subdir, or a nested clone).\nROOT=\"$(CDPATH= cd -- \"$(dirname -- \"$0\")/../..\" && pwd)\"\n# The JSON escapes every deny message below is assembled from (ANSI red, and the newlines that give the\n# deny the same scannable shape formatReport() gives every L1/L2 deny). See ESCAPES_SH's header.\n${ESCAPES_SH}\n# The BIN is resolved by walking UP from ROOT (as Node does), and BIN_ROOT records which tree supplied\n# it — the version-drift guard below compares THIS tree's pin against THAT tree's installed version.\n${RESOLVE_BIN_SH}\n${VERSION_DRIFT_GUARD_SH}\n# Read the tool payload ONCE, up front. The shim no longer exec's the bin (see RUN_BIN_SH), so it must\n# forward stdin to the bin itself — and it needs the payload again on the fail-closed path below.\nPAYLOAD=\"$(cat)\"\n${PARSE_PAYLOAD_SH}\n# Best-effort AUDIT TRAIL of what L0 did with this call — every call, not just the broken ones. One\n# tab-separated line per invocation into this TREE's own\n# logs/L0-shim/<session>-<agent|coordinator>-<binName>.log (gitignored), so the\n# observed behaviour can be diffed against the matrix in guards/L0-tooling.md. NEVER breaks or blocks the\n# hook: every write is swallowed, and nothing ever goes to stdout (stdout is the PreToolUse decision\n# channel — a stray byte there would corrupt allow/deny).\n${WP_LOG_SH}\nBROKEN_BIN=\"\"\nCRASH_MSG=\"\"\n${RUN_BIN_SH}\n# Bin missing (fresh clone before install) OR a version drift (stale node_modules) OR the bin is\n# installed but CRASHED (corrupt node_modules). The webpieces guards CANNOT safely run.\n# Before failing closed, peek at the tool payload and let ONLY package-manager install/recovery commands\n# through: the assistant's own Bash tool routes through this hook too, so blocking everything would\n# deadlock the very commands (pnpm install / rm -rf node_modules && pnpm install) that re-enable the\n# guards. A silent exit 0 = \"allow\" in the PreToolUse protocol; the guards resume once the tree is sane.\n${TRIAGE_SH}\n${DENY_REASON_SH}\n${DENY_EMIT_SH}\n`;\n}\n\n// Find the repo root that owns the committed shim to heal: walk up from `cwd` (the invocation's\n// actual dir) to the nearest ancestor holding a shim, falling back to $CLAUDE_PROJECT_DIR (which\n// Claude Code exports to hooks) only if the walk finds nothing. cwd-first keeps this correct for a\n// nested clone and testable (a temp root is honoured over the ambient project env). Returns null when\n// no committed shim exists (e.g. a global / absolute install, which has none to heal).\n//\n// Exported for install-entry.ts: on a CORRUPT node_modules, healShim is the only installer step that\n// can still run, so the installer must be able to tell the human whether a committed shim was actually\n// there to re-arm. Pure existsSync walk — never throws, so it needs no try/catch of its own.\n// webpieces-disable no-function-outside-class -- pure fs+path helper in the dependency-free shim module; it must not depend on DI (install-entry.ts relies on this loading on a corrupt tree).\nexport function findShimRoot(cwd: string): string | null {\n let dir = cwd;\n for (;;) {\n if (fs.existsSync(shimPath(dir))) return dir;\n const parent = path.dirname(dir);\n if (parent === dir) break;\n dir = parent;\n }\n const env = process.env['CLAUDE_PROJECT_DIR'];\n if (env && fs.existsSync(shimPath(env))) return env;\n return null;\n}\n\n// Best-effort: keep the committed shim identical to renderShim() so the fail-closed escape hatch and\n// allowlist never drift. Only rewrites an EXISTING shim (never creates one) so global installs are\n// untouched. NEVER throws — a self-heal must never block or crash a tool call.\n//\n// The overwrite itself is correct and deliberate — shim and binary are two halves of one L0 and MUST\n// come from the same release (see shimStaleRecoveryDecision's header in ../adapters/hook-core).\n//\n// It needs no backup and no notice: the shim is a TRACKED file, so whatever it replaced is already in\n// git — `git diff` shows the rewrite, and a tamper is a working-tree modification git surfaces on its\n// own. In a consistent repo this is a no-op (committed shim already equals renderShim()); it earns its\n// keep on the upgrade path, where bumping the pin and installing leaves the committed shim behind and\n// this quietly brings it forward to be committed.\nexport function healShim(cwd: string): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const root = findShimRoot(cwd);\n if (!root) return;\n const target = shimPath(root);\n const desired = renderShim();\n if (fs.readFileSync(target, 'utf8') === desired) return;\n fs.writeFileSync(target, desired, { mode: 0o755 });\n fs.chmodSync(target, 0o755);\n } catch (err: unknown) {\n //const error = toError(err);\n // Ignore: healing is a convenience, not part of the guard decision.\n }\n}\n\n// ---------------------------------------------------------------------------\n// COMMITTED-SHIM SELF-GUARD — now enforced by the guards BINARY, not the shim (moved 2026-07-24).\n//\n// It used to live in the rendered shim (`cmp -s \"$0\" \"$WP_TEMPLATE\"` → fail closed). That was a\n// double-edged fix trap: the shim-matching logic lived IN the committed shim, so a bug in it could\n// only be fixed by regenerating the committed shim — which required passing the buggy shim's own gate\n// (via wp-upgrade-shim). The fix was locked behind the gate it needed to open.\n//\n// The drift guard MUST stay pre-binary (a stale validator can't be trusted to guard itself), but this\n// check's rationale — \"don't run possibly-stale shim logic\" — evaporates once the check is in the\n// binary: at that point the deciding code is the CURRENT binary from node_modules, not the reverted\n// shim. So the shim now only checks drift + bin-presence and always hands off; the binary (hook-core)\n// calls committedShimStale() and, on a mismatch, fails closed with shimStaleDenyReason() — the SAME\n// OPTION 1/2/3 message — while isShimCureCommand() lets the three cures through so the AI self-heals.\n// We deny + tell the AI; we do NOT silently rewrite the file under it. With the version stamp gone the\n// shim is byte-stable across releases, so this fires only on a genuine logic change or a real tamper.\n// ---------------------------------------------------------------------------\n\n// The root whose committed shim this BINARY governs — resolved from the RUNNING MODULE's own location,\n// never from process.cwd() and never from $CLAUDE_PROJECT_DIR. Same premise as installedShimRulesVersion()\n// below: the binary IS this package, so it can point at its OWN install.\n//\n// WHY IT MUST BE THE MODULE AND NOT THE CWD (the two-tree straddle, fixed 2026-08-03).\n// committedShimStale used to resolve its root by walking up from the invocation cwd, then compare that\n// tree's shim FILE against renderShim() — which is compiled into whichever binary is actually running.\n//\n// Which tree supplies the binary? settings.json runs $CLAUDE_PROJECT_DIR/.claude/webpieces/ai-hook.sh,\n// and that shim derives ROOT (hence BIN) from its own $0 — so the SESSION ROOT's tree supplies BOTH the\n// shim and the binary, and that pair is self-consistent by construction. A session rooted in a linked\n// worktree runs the worktree's shim and the worktree's binary; that is fine and is NOT the bug.\n//\n// The straddle appears when an agent's SESSION ROOT and its CWD are different trees — CLAUDE_PROJECT_DIR\n// is fixed at session start, so an agent that `cd`s into another checkout keeps running the session-root\n// tree's binary while findShimRoot(cwd) walks up into the OTHER tree. Each tree carries its own\n// node_modules at its own @webpieces version (seen in the wild: 0.4.545, 0.4.560 and 0.4.526 side by\n// side, every tree internally consistent). The comparison then straddles the two and can NEVER converge:\n// curing in the cwd tree renders with THAT tree's renderShim(), which the running binary's renderShim()\n// still rejects, so the cure re-fires the deny forever (observed: an agent gave up after four cures).\n//\n// Anchoring on __dirname makes the straddle UNCONSTRUCTIBLE rather than merely discouraged. It does not\n// pick a tree and privileges none: whichever tree the running binary came from is the tree whose shim it\n// compares, so the two halves of the comparison provably come from the same install either way.\n//\n// OUTERMOST node_modules wins, not innermost: under pnpm's linked layout __dirname realpaths to\n// <root>/node_modules/.pnpm/@webpieces+ai-hook-rules@X/node_modules/@webpieces/ai-hook-rules/src/bin —\n// the outermost segment lands on <root>, an innermost/first-ancestor rule lands inside the store.\n// With no node_modules segment at all we are running from a SOURCE checkout (vitest via tsconfig paths),\n// so walk up to the nearest ancestor that owns a shim. null = no committed shim to guard.\n// webpieces-disable no-function-outside-class -- pure fs+path helper in the dependency-free shim module, beside findShimRoot/healShim.\nexport function governingShimRoot(moduleDir: string = __dirname): string | null {\n const segments = moduleDir.split(path.sep);\n const outermost = segments.indexOf('node_modules');\n if (outermost > 0) {\n const root = segments.slice(0, outermost).join(path.sep);\n return fs.existsSync(shimPath(root)) ? root : null;\n }\n // A moduleDir that STARTS with node_modules is relative, so the root before it would be '' — i.e.\n // cwd-relative, the exact input this function exists to refuse. Nothing to govern.\n if (outermost === 0) return null;\n let dir = moduleDir;\n for (;;) {\n if (fs.existsSync(shimPath(dir))) return dir;\n const parent = path.dirname(dir);\n if (parent === dir) return null;\n dir = parent;\n }\n}\n\n// True when a committed shim EXISTS but no longer equals renderShim() (reverted, hand-edited, or a shim\n// whose LOGIC predates the installed binary). Missing shim → false: a fresh clone / global install has\n// nothing to guard, matching the old shim's `[ -f \"$WP_TEMPLATE\" ]` skip. Same comparison healShim\n// makes; never throws (an unreadable tree is treated as \"not stale\" so it can't wedge a tool call).\n//\n// The root defaults to governingShimRoot() — the decision's input is the MODULE's tree, never the cwd\n// (see governingShimRoot for the straddle this closes). The parameter exists ONLY so unit tests can\n// stage a temp root; nothing in production should pass one.\n// webpieces-disable no-function-outside-class -- pure fs+path helper in the shim module, beside healShim/renderShim.\nexport function committedShimStale(root: string | null = governingShimRoot()): boolean {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n if (root === null) return false;\n return fs.readFileSync(shimPath(root), 'utf8') !== renderShim();\n } catch (err: unknown) {\n const error = toError(err);\n void error; // best-effort: an unreadable tree counts as \"not stale\" so this never wedges a tool call\n return false;\n }\n}\n\n// True when `command` re-arms the committed shim — the two prescribed cures plus the installer, which\n// also heals the shim as its first step. These are the only commands allowed through while a stale\n// committed shim blocks everything else, so the AI can re-arm it. Each JS twin already tolerates\n// a trailing `2>&1 | tail -N` and rejects any `&&`-chained tail (see CAPTURE_TAIL_JS_SRC).\n// webpieces-disable no-function-outside-class -- pure predicate over the exported allowlist twins; belongs beside them in the shim module.\nexport function isShimCureCommand(command: string): boolean {\n const cmd = command.trim();\n return INSTALL_HOOKS_ALLOW_JS.test(cmd) || UPGRADE_SHIM_ALLOW_JS.test(cmd) || RESTORE_SHIM_ALLOW_JS.test(cmd);\n}\n\n// The shape of the fields we read out of this package's package.json.\ninterface ShimPackageManifest {\n readonly version?: string;\n}\n\n// The installed @webpieces/ai-hook-rules version, for shimStaleDenyReason's note. The binary IS this\n// package, so it reads its OWN package.json (two dirs up from src/bin). Best-effort: '' on any failure,\n// which shimStaleDenyReason renders as no note rather than a broken one.\n// webpieces-disable no-function-outside-class -- pure fs helper beside the shim module's other version plumbing.\nexport function installedShimRulesVersion(): string {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8')) as ShimPackageManifest;\n return pkg.version ?? '';\n } catch (err: unknown) {\n const error = toError(err);\n void error; // best-effort: no readable version → shimStaleDenyReason prints no note\n return '';\n }\n}\n"]}
@@ -39,14 +39,17 @@ import { FixHint } from '../fix-hint';
39
39
  * they had never asked for. An experimental feature is opted into from a machine-local file whose absent
40
40
  * state is byte-for-byte the old behaviour, or it is not experimental.
41
41
  *
42
- * Three states, and only three:
42
+ * Four states, and only four:
43
43
  * - the file does not exist (essentially every consumer) → this guard is INERT. It blocks nothing and
44
44
  * logs nothing, for every command, including the ones it would otherwise refuse.
45
45
  * - the file exists and defines the key → the key's value decides.
46
- * - the file exists and is unparseable, or does not define the key → HARD FAILURE naming the edit. A
47
- * file someone deliberately created must be correct, and `HomeConfigService` requires that key
48
- * precisely because guessing either way is wrong (see readRequiredBoolean). Editing that file is an
49
- * unconditional PASS in the guards, so the block is always self-curable.
46
+ * - the file exists but does NOT define the key → the guard is OFF, exactly as if there were no file.
47
+ * Every key in that file is optional, because it is MACHINE-GLOBAL and the repos on one machine pin
48
+ * different webpieces releases a required key there is unsatisfiable (see home-config.ts). The
49
+ * default can only fail towards "never opted in", which is the safe direction.
50
+ * - the file exists and is unparseable, or a key it DOES define has the wrong type → HARD FAILURE
51
+ * naming the edit. That is a file somebody wrote wrongly, not one written for another release.
52
+ * Editing that file is an unconditional PASS in the guards, so the block is always self-curable.
50
53
  *
51
54
  * ─── Two messages, chosen by a DIFFERENT key ───────────────────────────────────────────────────────
52
55
  * `experimental.buildGateLogCapture` is a separate feature (the pr-gate captures its build's full output
@@ -49,14 +49,17 @@ const whole_repo_build_scan_1 = require("./whole-repo-build-scan");
49
49
  * they had never asked for. An experimental feature is opted into from a machine-local file whose absent
50
50
  * state is byte-for-byte the old behaviour, or it is not experimental.
51
51
  *
52
- * Three states, and only three:
52
+ * Four states, and only four:
53
53
  * - the file does not exist (essentially every consumer) → this guard is INERT. It blocks nothing and
54
54
  * logs nothing, for every command, including the ones it would otherwise refuse.
55
55
  * - the file exists and defines the key → the key's value decides.
56
- * - the file exists and is unparseable, or does not define the key → HARD FAILURE naming the edit. A
57
- * file someone deliberately created must be correct, and `HomeConfigService` requires that key
58
- * precisely because guessing either way is wrong (see readRequiredBoolean). Editing that file is an
59
- * unconditional PASS in the guards, so the block is always self-curable.
56
+ * - the file exists but does NOT define the key → the guard is OFF, exactly as if there were no file.
57
+ * Every key in that file is optional, because it is MACHINE-GLOBAL and the repos on one machine pin
58
+ * different webpieces releases a required key there is unsatisfiable (see home-config.ts). The
59
+ * default can only fail towards "never opted in", which is the safe direction.
60
+ * - the file exists and is unparseable, or a key it DOES define has the wrong type → HARD FAILURE
61
+ * naming the edit. That is a file somebody wrote wrongly, not one written for another release.
62
+ * Editing that file is an unconditional PASS in the guards, so the block is always self-curable.
60
63
  *
61
64
  * ─── Two messages, chosen by a DIFFERENT key ───────────────────────────────────────────────────────
62
65
  * `experimental.buildGateLogCapture` is a separate feature (the pr-gate captures its build's full output
@@ -1 +1 @@
1
- {"version":3,"file":"whole-repo-build-guard.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/whole-repo-build-guard.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync } from 'child_process';\n\nimport { DEFAULT_BUILD_COMMAND, HomeConfig, HomeConfigService, InformAiError } from '@webpieces/rules-config';\n\nimport type { BashContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { BashRuleBase, EmptyRuleConfig } from '../rule-base';\nimport { FixHint } from '../fix-hint';\nimport { toError } from '../to-error';\nimport { L0_FAULT_NONE } from '../l0-fault-codes';\nimport { logGuardDecision, GuardDecision, Verdict, MATRIX_L2_UNROWED } from '../decision-log';\nimport { CommandScanner } from '../command-scan';\nimport { WholeRepoBuildScan, WholeRepoBuildHit } from './whole-repo-build-scan';\n\n/**\n * Blocks a Bash command that would build or test the WHOLE monorepo, and hands back the narrow\n * command to run instead. Which shapes count is `WholeRepoBuildScan`'s job; this rule owns the\n * decision, the log line and the two refusal messages.\n *\n * ─── Why: SCOPE and AGREEMENT, not a speed claim ───────────────────────────────────────────────────\n * `nx affected --target=ci --base=<fork point>` is the command the PR gate itself runs\n * (`commands.pr-gate.buildCommand`), so a green local result is evidence about the gate. A whole-repo\n * build is a different, wider command whose green says nothing extra — it just also compiles projects\n * the change cannot reach.\n *\n * It is NOT automatically faster, and this guard deliberately does not claim it is. Measured on a\n * `core-util` change, `affected` selected the IDENTICAL 20 projects / 104 tasks as the whole-repo\n * build: a package at the BASE of the dependency graph prunes nothing. The pruning win is real for\n * LEAF projects and absent for base ones. The long builds people blamed on scope were caused by a cold\n * nx cache and by CPU contention between agents running full sweeps at once (measured: ~3.2x total\n * test time under contention) — neither of which a narrower target list fixes on its own.\n *\n * So what this guard buys is the scope being right by default. Building the world is never the\n * correct inner-loop move in a monorepo; the correct one has existed all along, and nothing stopped\n * the wide one.\n *\n * ─── The message is READ FROM CONFIG, and it is RESOLVED ───────────────────────────────────────────\n * The replacement command comes from `commands.pr-gate.buildCommand` (injected into this guard's\n * config by load-config), so the refusal follows the project when the gate command changes. The `$(…)`\n * in it is EXPANDED before printing: handing an agent `--base=$(git merge-base origin/main HEAD)` is\n * handing it a template, and a template pasted where no shell expands it produces a confusing failure\n * that reads like the guard's advice was wrong. Only `$(git …)` is expanded, and only read-only git;\n * anything else is left verbatim.\n *\n * ─── EXPERIMENTAL: the ONLY switch is ~/.webpieces/config.json ─────────────────────────────────────\n * `experimental.whole-repo-build-guard` (a boolean) decides whether this guard blocks anything at all.\n * There is NO webpieces.config.json entry — deliberately, and the reason is a live incident: this guard\n * first shipped as an ordinary validated guard with `mode: 'ON'` by default AND a required entry under\n * `hookGuards`, so every consumer that upgraded hit fault Y — EVERY Bash call blocked — for a feature\n * they had never asked for. An experimental feature is opted into from a machine-local file whose absent\n * state is byte-for-byte the old behaviour, or it is not experimental.\n *\n * Three states, and only three:\n * - the file does not exist (essentially every consumer) → this guard is INERT. It blocks nothing and\n * logs nothing, for every command, including the ones it would otherwise refuse.\n * - the file exists and defines the key → the key's value decides.\n * - the file exists and is unparseable, or does not define the key → HARD FAILURE naming the edit. A\n * file someone deliberately created must be correct, and `HomeConfigService` requires that key\n * precisely because guessing either way is wrong (see readRequiredBoolean). Editing that file is an\n * unconditional PASS in the guards, so the block is always self-curable.\n *\n * ─── Two messages, chosen by a DIFFERENT key ───────────────────────────────────────────────────────\n * `experimental.buildGateLogCapture` is a separate feature (the pr-gate captures its build's full output\n * to a log file and hands the agent that path instead of a rebuild instruction) and it is NOT this\n * guard's switch. It only picks WHICH refusal is printed once the guard is on: with capture ON the right\n * advice is not \"build smaller\", it is \"do not build; stage ② already builds and you can READ the\n * result\". `HomeConfigService` is the one reader of both keys.\n *\n * ─── Humans are not affected, by construction ──────────────────────────────────────────────────────\n * This is a PreToolUse hook. It sees the AI's Bash tool calls and nothing else — a human typing\n * `pnpm run build-all` in their own terminal never reaches a hook, and the `build-all` script itself is\n * deliberately left in package.json for exactly that reason. The guard is about what the AI does in a\n * loop, not about the command being wrong for a person who chooses to run it once.\n */\nexport class WholeRepoBuildGuardRule extends BashRuleBase<EmptyRuleConfig> {\n /**\n * `affectedBuildCommand` is the project's gate command (`commands.pr-gate.buildCommand`), handed in\n * by the runner off the loaded config. It is a CONSTRUCTOR ARGUMENT rather than a config field\n * because this guard has no config entry to read one from — and that is the point.\n */\n constructor(private readonly affectedBuildCommand: string) {\n // configKey === name and is DELIBERATELY not a real key: this guard has no\n // webpieces.config.json entry at all (see RETIRED_CONFIG_KEYS), and it is loaded outside the\n // config-driven set so the fault-Y sync check never sees it. Naming it here keeps the\n // AbstractRule contract honest without putting the string in HOOK_GUARD_NAMES.\n super(new EmptyRuleConfig(), 'whole-repo-build-guard', 'whole-repo-build-guard');\n }\n\n private readonly scanner = new CommandScanner();\n private readonly homeConfig = new HomeConfigService();\n\n /** Set by check() when the home config is unreadable, so the fix hint names the same repair. */\n private homeConfigError = '';\n\n readonly description =\n 'Block a whole-monorepo build (build-all, an unnarrowed nx run-many, nx affected with no ' +\n '--base, a bare vitest run) and name the affected-scoped command to run instead.';\n\n /**\n * The command this rule last printed, so the fix hint and the violation message are one string and\n * cannot disagree. Empty until check() runs (fixHint is also read without it), at which point the\n * getter falls back to the configured TEMPLATE — never to a second literal, which is exactly the\n * drift this guard's own docstring says a duplicated command string causes.\n */\n private resolvedCommand = '';\n\n get fixHint(): FixHint {\n if (this.homeConfigError !== '') {\n return new FixHint(\n '~/.webpieces/config.json exists but cannot be read.',\n 'Edit ~/.webpieces/config.json (editing it is always permitted, including right now), or ' +\n 'delete it outright — with no such file every webpieces command behaves exactly as it ' +\n 'does by default.',\n );\n }\n const command = this.resolvedCommand !== '' ? this.resolvedCommand : this.buildCommandTemplate();\n return new FixHint(\n 'That command builds the WHOLE monorepo. Build only what your change affects.',\n 'Build the affected projects, or one project, or one spec file — never the workspace:\\n' +\n ` ${command} # the gate's own build\\n` +\n ' pnpm nx run <project>:ci # one project\\n' +\n ' pnpm exec vitest run <path> # one suite\\n' +\n 'Turn this guard off for this machine by setting \"experimental\": ' +\n '{ \"whole-repo-build-guard\": false } in ~/.webpieces/config.json (there is no ' +\n 'webpieces.config.json entry for it).',\n );\n }\n\n check(ctx: BashContext): readonly Violation[] {\n const home = this.loadHome();\n // A file someone deliberately created must be correct — see the class docstring. Reported for\n // whatever command happens to be running, because a broken opt-in file is not a build question.\n if (home instanceof Error) return this.blockOnBrokenHomeConfig(ctx, home);\n\n // THE EXPERIMENTAL GATE, and the state of essentially every consumer. Silent on purpose: no\n // block, no log, no file touched — \"no ~/.webpieces/config.json\" must be indistinguishable from\n // \"this guard does not exist\".\n this.homeConfigError = '';\n if (!home.wholeRepoBuildGuard) return [];\n\n // Blocklist-shaped, so match on commandCode: stripping heredocs and quoted prose can only ever\n // block LESS, and this repo's own commit messages are full of the command names above.\n const hit = new WholeRepoBuildScan(this.scanner, ctx.effectiveCwd === ctx.workspaceRoot)\n .firstHit(ctx.commandCode);\n if (hit === null) return this.allow(ctx, 'not-a-whole-repo-build');\n\n // Resolved ONCE, here, and read by both the violation message and the fix hint.\n this.resolvedCommand = this.resolvedBuildCommand(ctx.workspaceRoot);\n return this.block(ctx, hit, this.message(home));\n }\n\n /**\n * The home config, or the Error explaining why it is unusable. NOT a boolean: \"absent\" is already\n * folded into the returned HomeConfig (all-defaults, guard off), so the only thing left to\n * distinguish is \"present and wrong\", which must be reported rather than swallowed.\n */\n private loadHome(): HomeConfig | Error {\n // webpieces-disable no-unmanaged-exceptions -- chokepoint: a rejected home config is converted\n // into this guard's own block, which carries the loader's fix instruction verbatim\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return this.homeConfig.load();\n } catch (err: unknown) {\n const error = toError(err);\n return error;\n }\n }\n\n private blockOnBrokenHomeConfig(ctx: BashContext, error: Error): readonly Violation[] {\n this.homeConfigError = error.message;\n this.logDecision(ctx, 'BLOCK_AI_CURE', 'home-config-invalid');\n return [new V(1, this.truncate(ctx.command), this.brokenHomeConfigMessage(error))];\n }\n\n private brokenHomeConfigMessage(error: Error): string {\n const detail = error instanceof InformAiError\n ? error.message\n : `~/.webpieces/config.json could not be read: ${error.message}`;\n return `Blocked: ~/.webpieces/config.json is present but unusable.\\n\\n${detail}`;\n }\n\n // The whole refusal. Short on purpose: it is read mid-task by an agent that needs the ONE command\n // to run next, and a guard message long enough to skim is a guard message that gets skimmed.\n private message(home: HomeConfig): string {\n if (home.buildGateLogCapture) {\n return 'Blocked: that builds the WHOLE monorepo — and you should not be building at all.\\n'\n + '`pnpm wp-review-upsert-pr` (stage ②) runs the build for you, captures the full output to a\\n'\n + 'log file, and names that file if it fails. Read the file; do not rebuild.\\n'\n + 'Need a check before then: pnpm exec vitest run <path>.';\n }\n return 'Blocked: that builds the WHOLE monorepo. Build only what your change affects:\\n\\n'\n + ` ${this.resolvedCommand}\\n\\n`\n + 'Narrower still: pnpm nx run <project>:ci, or pnpm exec vitest run <path>.';\n }\n\n /**\n * The project's build command, unexpanded. ONE source: `commands.pr-gate.buildCommand`, handed to\n * the constructor by the runner, falling back to the same DEFAULT_BUILD_COMMAND the gate itself\n * falls back to. This guard never spells a build command of its own — a second copy is how a refusal\n * starts teaching a command the gate does not run.\n */\n private buildCommandTemplate(): string {\n return this.affectedBuildCommand.trim() === '' ? DEFAULT_BUILD_COMMAND : this.affectedBuildCommand;\n }\n\n /**\n * The configured build command with its `$(git …)` substitutions expanded, so what is printed is\n * runnable as-is. Expansion is limited to git, and any failure leaves the template untouched — a\n * guard may degrade its own message, never fail the tool call it is judging.\n */\n private resolvedBuildCommand(workspaceRoot: string): string {\n const template = this.buildCommandTemplate();\n return template.replace(/\\$\\(([^()]*)\\)/g, (match: string, inner: string): string => {\n const trimmed = inner.trim();\n if (!trimmed.startsWith('git ')) return match;\n const output = this.capture(trimmed, workspaceRoot);\n return output === null ? match : output;\n });\n }\n\n private capture(command: string, workspaceRoot: string): string | null {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return execSync(command, {\n cwd: workspaceRoot, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return null;\n }\n }\n\n private allow(ctx: BashContext, reason: string): readonly Violation[] {\n this.logDecision(ctx, 'ALLOW', reason);\n return [];\n }\n\n private block(ctx: BashContext, hit: WholeRepoBuildHit, message: string): readonly Violation[] {\n // BLOCK_AI_CURE, not BLOCK_HUMAN: the cure is one command the agent runs itself, and the\n // refusal hands it over already resolved.\n this.logDecision(ctx, 'BLOCK_AI_CURE', hit.shape);\n return [new V(1, this.truncate(ctx.command), message)];\n }\n\n private truncate(s: string): string {\n const MAX = 120;\n return s.length <= MAX ? s : s.slice(0, MAX) + '…';\n }\n\n private logDecision(ctx: BashContext, verdict: Verdict, reason: string): void {\n logGuardDecision(\n ctx.workspaceRoot,\n new GuardDecision('whole-repo-build-guard', 'Bash', ctx.command, '-', verdict, reason, '-', L0_FAULT_NONE, MATRIX_L2_UNROWED),\n );\n }\n}\n"]}
1
+ {"version":3,"file":"whole-repo-build-guard.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/whole-repo-build-guard.ts"],"names":[],"mappings":";;;AAAA,iDAAyC;AAEzC,0DAA8G;AAG9G,oCAA0C;AAC1C,4CAA6D;AAC7D,0CAAsC;AACtC,0CAAsC;AACtC,sDAAkD;AAClD,kDAA8F;AAC9F,kDAAiD;AACjD,mEAAgF;AAEhF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8DG;AACH,MAAa,uBAAwB,SAAQ,wBAA6B;IAMzC;IAL7B;;;;OAIG;IACH,YAA6B,oBAA4B;QACrD,2EAA2E;QAC3E,6FAA6F;QAC7F,sFAAsF;QACtF,+EAA+E;QAC/E,KAAK,CAAC,IAAI,2BAAe,EAAE,EAAE,wBAAwB,EAAE,wBAAwB,CAAC,CAAC;QALxD,yBAAoB,GAApB,oBAAoB,CAAQ;IAMzD,CAAC;IAEgB,OAAO,GAAG,IAAI,6BAAc,EAAE,CAAC;IAC/B,UAAU,GAAG,IAAI,gCAAiB,EAAE,CAAC;IAEtD,gGAAgG;IACxF,eAAe,GAAG,EAAE,CAAC;IAEpB,WAAW,GAChB,0FAA0F;QAC1F,iFAAiF,CAAC;IAEtF;;;;;OAKG;IACK,eAAe,GAAG,EAAE,CAAC;IAE7B,IAAI,OAAO;QACP,IAAI,IAAI,CAAC,eAAe,KAAK,EAAE,EAAE,CAAC;YAC9B,OAAO,IAAI,kBAAO,CACd,qDAAqD,EACrD,0FAA0F;gBAC1F,uFAAuF;gBACvF,kBAAkB,CACrB,CAAC;QACN,CAAC;QACD,MAAM,OAAO,GAAG,IAAI,CAAC,eAAe,KAAK,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,eAAe,CAAC,CAAC,CAAC,IAAI,CAAC,oBAAoB,EAAE,CAAC;QACjG,OAAO,IAAI,kBAAO,CACd,8EAA8E,EAC9E,wFAAwF;YACxF,KAAK,OAAO,6BAA6B;YACzC,8CAA8C;YAC9C,+CAA+C;YAC/C,kEAAkE;YAClE,+EAA+E;YAC/E,sCAAsC,CACzC,CAAC;IACN,CAAC;IAED,KAAK,CAAC,GAAgB;QAClB,MAAM,IAAI,GAAG,IAAI,CAAC,QAAQ,EAAE,CAAC;QAC7B,8FAA8F;QAC9F,gGAAgG;QAChG,IAAI,IAAI,YAAY,KAAK;YAAE,OAAO,IAAI,CAAC,uBAAuB,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;QAE1E,4FAA4F;QAC5F,gGAAgG;QAChG,+BAA+B;QAC/B,IAAI,CAAC,eAAe,GAAG,EAAE,CAAC;QAC1B,IAAI,CAAC,IAAI,CAAC,mBAAmB;YAAE,OAAO,EAAE,CAAC;QAEzC,+FAA+F;QAC/F,uFAAuF;QACvF,MAAM,GAAG,GAAG,IAAI,0CAAkB,CAAC,IAAI,CAAC,OAAO,EAAE,GAAG,CAAC,YAAY,KAAK,GAAG,CAAC,aAAa,CAAC;aACnF,QAAQ,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;QAC/B,IAAI,GAAG,KAAK,IAAI;YAAE,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,EAAE,wBAAwB,CAAC,CAAC;QAEnE,gFAAgF;QAChF,IAAI,CAAC,eAAe,GAAG,IAAI,CAAC,oBAAoB,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC;QACpE,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,EAAE,GAAG,EAAE,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC;IACpD,CAAC;IAED;;;;OAIG;IACK,QAAQ;QACZ,+FAA+F;QAC/F,mFAAmF;QACnF,8DAA8D;QAC9D,IAAI,CAAC;YACD,OAAO,IAAI,CAAC,UAAU,CAAC,IAAI,EAAE,CAAC;QAClC,CAAC;QAAC,OAAO,GAAY,EAAE,CAAC;YACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;YAC3B,OAAO,KAAK,CAAC;QACjB,CAAC;IACL,CAAC;IAEO,uBAAuB,CAAC,GAAgB,EAAE,KAAY;QAC1D,IAAI,CAAC,eAAe,GAAG,KAAK,CAAC,OAAO,CAAC;QACrC,IAAI,CAAC,WAAW,CAAC,GAAG,EAAE,eAAe,EAAE,qBAAqB,CAAC,CAAC;QAC9D,OAAO,CAAC,IAAI,iBAAC,CAAC,CAAC,EAAE,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,IAAI,CAAC,uBAAuB,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IACvF,CAAC;IAEO,uBAAuB,CAAC,KAAY;QACxC,MAAM,MAAM,GAAG,KAAK,YAAY,4BAAa;YACzC,CAAC,CAAC,KAAK,CAAC,OAAO;YACf,CAAC,CAAC,+CAA+C,KAAK,CAAC,OAAO,EAAE,CAAC;QACrE,OAAO,iEAAiE,MAAM,EAAE,CAAC;IACrF,CAAC;IAED,kGAAkG;IAClG,6FAA6F;IACrF,OAAO,CAAC,IAAgB;QAC5B,IAAI,IAAI,CAAC,mBAAmB,EAAE,CAAC;YAC3B,OAAO,oFAAoF;kBACrF,8FAA8F;kBAC9F,6EAA6E;kBAC7E,wDAAwD,CAAC;QACnE,CAAC;QACD,OAAO,mFAAmF;cACpF,OAAO,IAAI,CAAC,eAAe,MAAM;cACjC,2EAA2E,CAAC;IACtF,CAAC;IAED;;;;;OAKG;IACK,oBAAoB;QACxB,OAAO,IAAI,CAAC,oBAAoB,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,oCAAqB,CAAC,CAAC,CAAC,IAAI,CAAC,oBAAoB,CAAC;IACvG,CAAC;IAED;;;;OAIG;IACK,oBAAoB,CAAC,aAAqB;QAC9C,MAAM,QAAQ,GAAG,IAAI,CAAC,oBAAoB,EAAE,CAAC;QAC7C,OAAO,QAAQ,CAAC,OAAO,CAAC,iBAAiB,EAAE,CAAC,KAAa,EAAE,KAAa,EAAU,EAAE;YAChF,MAAM,OAAO,GAAG,KAAK,CAAC,IAAI,EAAE,CAAC;YAC7B,IAAI,CAAC,OAAO,CAAC,UAAU,CAAC,MAAM,CAAC;gBAAE,OAAO,KAAK,CAAC;YAC9C,MAAM,MAAM,GAAG,IAAI,CAAC,OAAO,CAAC,OAAO,EAAE,aAAa,CAAC,CAAC;YACpD,OAAO,MAAM,KAAK,IAAI,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC;QAC5C,CAAC,CAAC,CAAC;IACP,CAAC;IAEO,OAAO,CAAC,OAAe,EAAE,aAAqB;QAClD,8DAA8D;QAC9D,IAAI,CAAC;YACD,OAAO,IAAA,wBAAQ,EAAC,OAAO,EAAE;gBACrB,GAAG,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC;aACxE,CAAC,CAAC,IAAI,EAAE,CAAC;QACd,CAAC;QAAC,OAAO,GAAY,EAAE,CAAC;YACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;YAC3B,KAAK,KAAK,CAAC;YACX,OAAO,IAAI,CAAC;QAChB,CAAC;IACL,CAAC;IAEO,KAAK,CAAC,GAAgB,EAAE,MAAc;QAC1C,IAAI,CAAC,WAAW,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,CAAC,CAAC;QACvC,OAAO,EAAE,CAAC;IACd,CAAC;IAEO,KAAK,CAAC,GAAgB,EAAE,GAAsB,EAAE,OAAe;QACnE,yFAAyF;QACzF,0CAA0C;QAC1C,IAAI,CAAC,WAAW,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,CAAC,KAAK,CAAC,CAAC;QAClD,OAAO,CAAC,IAAI,iBAAC,CAAC,CAAC,EAAE,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC;IAC3D,CAAC;IAEO,QAAQ,CAAC,CAAS;QACtB,MAAM,GAAG,GAAG,GAAG,CAAC;QAChB,OAAO,CAAC,CAAC,MAAM,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,GAAG,GAAG,CAAC;IACvD,CAAC;IAEO,WAAW,CAAC,GAAgB,EAAE,OAAgB,EAAE,MAAc;QAClE,IAAA,+BAAgB,EACZ,GAAG,CAAC,aAAa,EACjB,IAAI,4BAAa,CAAC,wBAAwB,EAAE,MAAM,EAAE,GAAG,CAAC,OAAO,EAAE,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,8BAAa,EAAE,gCAAiB,CAAC,CAChI,CAAC;IACN,CAAC;CACJ;AAtLD,0DAsLC","sourcesContent":["import { execSync } from 'child_process';\n\nimport { DEFAULT_BUILD_COMMAND, HomeConfig, HomeConfigService, InformAiError } from '@webpieces/rules-config';\n\nimport type { BashContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { BashRuleBase, EmptyRuleConfig } from '../rule-base';\nimport { FixHint } from '../fix-hint';\nimport { toError } from '../to-error';\nimport { L0_FAULT_NONE } from '../l0-fault-codes';\nimport { logGuardDecision, GuardDecision, Verdict, MATRIX_L2_UNROWED } from '../decision-log';\nimport { CommandScanner } from '../command-scan';\nimport { WholeRepoBuildScan, WholeRepoBuildHit } from './whole-repo-build-scan';\n\n/**\n * Blocks a Bash command that would build or test the WHOLE monorepo, and hands back the narrow\n * command to run instead. Which shapes count is `WholeRepoBuildScan`'s job; this rule owns the\n * decision, the log line and the two refusal messages.\n *\n * ─── Why: SCOPE and AGREEMENT, not a speed claim ───────────────────────────────────────────────────\n * `nx affected --target=ci --base=<fork point>` is the command the PR gate itself runs\n * (`commands.pr-gate.buildCommand`), so a green local result is evidence about the gate. A whole-repo\n * build is a different, wider command whose green says nothing extra — it just also compiles projects\n * the change cannot reach.\n *\n * It is NOT automatically faster, and this guard deliberately does not claim it is. Measured on a\n * `core-util` change, `affected` selected the IDENTICAL 20 projects / 104 tasks as the whole-repo\n * build: a package at the BASE of the dependency graph prunes nothing. The pruning win is real for\n * LEAF projects and absent for base ones. The long builds people blamed on scope were caused by a cold\n * nx cache and by CPU contention between agents running full sweeps at once (measured: ~3.2x total\n * test time under contention) — neither of which a narrower target list fixes on its own.\n *\n * So what this guard buys is the scope being right by default. Building the world is never the\n * correct inner-loop move in a monorepo; the correct one has existed all along, and nothing stopped\n * the wide one.\n *\n * ─── The message is READ FROM CONFIG, and it is RESOLVED ───────────────────────────────────────────\n * The replacement command comes from `commands.pr-gate.buildCommand` (injected into this guard's\n * config by load-config), so the refusal follows the project when the gate command changes. The `$(…)`\n * in it is EXPANDED before printing: handing an agent `--base=$(git merge-base origin/main HEAD)` is\n * handing it a template, and a template pasted where no shell expands it produces a confusing failure\n * that reads like the guard's advice was wrong. Only `$(git …)` is expanded, and only read-only git;\n * anything else is left verbatim.\n *\n * ─── EXPERIMENTAL: the ONLY switch is ~/.webpieces/config.json ─────────────────────────────────────\n * `experimental.whole-repo-build-guard` (a boolean) decides whether this guard blocks anything at all.\n * There is NO webpieces.config.json entry — deliberately, and the reason is a live incident: this guard\n * first shipped as an ordinary validated guard with `mode: 'ON'` by default AND a required entry under\n * `hookGuards`, so every consumer that upgraded hit fault Y — EVERY Bash call blocked — for a feature\n * they had never asked for. An experimental feature is opted into from a machine-local file whose absent\n * state is byte-for-byte the old behaviour, or it is not experimental.\n *\n * Four states, and only four:\n * - the file does not exist (essentially every consumer) → this guard is INERT. It blocks nothing and\n * logs nothing, for every command, including the ones it would otherwise refuse.\n * - the file exists and defines the key → the key's value decides.\n * - the file exists but does NOT define the key → the guard is OFF, exactly as if there were no file.\n * Every key in that file is optional, because it is MACHINE-GLOBAL and the repos on one machine pin\n * different webpieces releases — a required key there is unsatisfiable (see home-config.ts). The\n * default can only fail towards \"never opted in\", which is the safe direction.\n * - the file exists and is unparseable, or a key it DOES define has the wrong type → HARD FAILURE\n * naming the edit. That is a file somebody wrote wrongly, not one written for another release.\n * Editing that file is an unconditional PASS in the guards, so the block is always self-curable.\n *\n * ─── Two messages, chosen by a DIFFERENT key ───────────────────────────────────────────────────────\n * `experimental.buildGateLogCapture` is a separate feature (the pr-gate captures its build's full output\n * to a log file and hands the agent that path instead of a rebuild instruction) and it is NOT this\n * guard's switch. It only picks WHICH refusal is printed once the guard is on: with capture ON the right\n * advice is not \"build smaller\", it is \"do not build; stage ② already builds and you can READ the\n * result\". `HomeConfigService` is the one reader of both keys.\n *\n * ─── Humans are not affected, by construction ──────────────────────────────────────────────────────\n * This is a PreToolUse hook. It sees the AI's Bash tool calls and nothing else — a human typing\n * `pnpm run build-all` in their own terminal never reaches a hook, and the `build-all` script itself is\n * deliberately left in package.json for exactly that reason. The guard is about what the AI does in a\n * loop, not about the command being wrong for a person who chooses to run it once.\n */\nexport class WholeRepoBuildGuardRule extends BashRuleBase<EmptyRuleConfig> {\n /**\n * `affectedBuildCommand` is the project's gate command (`commands.pr-gate.buildCommand`), handed in\n * by the runner off the loaded config. It is a CONSTRUCTOR ARGUMENT rather than a config field\n * because this guard has no config entry to read one from — and that is the point.\n */\n constructor(private readonly affectedBuildCommand: string) {\n // configKey === name and is DELIBERATELY not a real key: this guard has no\n // webpieces.config.json entry at all (see RETIRED_CONFIG_KEYS), and it is loaded outside the\n // config-driven set so the fault-Y sync check never sees it. Naming it here keeps the\n // AbstractRule contract honest without putting the string in HOOK_GUARD_NAMES.\n super(new EmptyRuleConfig(), 'whole-repo-build-guard', 'whole-repo-build-guard');\n }\n\n private readonly scanner = new CommandScanner();\n private readonly homeConfig = new HomeConfigService();\n\n /** Set by check() when the home config is unreadable, so the fix hint names the same repair. */\n private homeConfigError = '';\n\n readonly description =\n 'Block a whole-monorepo build (build-all, an unnarrowed nx run-many, nx affected with no ' +\n '--base, a bare vitest run) and name the affected-scoped command to run instead.';\n\n /**\n * The command this rule last printed, so the fix hint and the violation message are one string and\n * cannot disagree. Empty until check() runs (fixHint is also read without it), at which point the\n * getter falls back to the configured TEMPLATE — never to a second literal, which is exactly the\n * drift this guard's own docstring says a duplicated command string causes.\n */\n private resolvedCommand = '';\n\n get fixHint(): FixHint {\n if (this.homeConfigError !== '') {\n return new FixHint(\n '~/.webpieces/config.json exists but cannot be read.',\n 'Edit ~/.webpieces/config.json (editing it is always permitted, including right now), or ' +\n 'delete it outright — with no such file every webpieces command behaves exactly as it ' +\n 'does by default.',\n );\n }\n const command = this.resolvedCommand !== '' ? this.resolvedCommand : this.buildCommandTemplate();\n return new FixHint(\n 'That command builds the WHOLE monorepo. Build only what your change affects.',\n 'Build the affected projects, or one project, or one spec file — never the workspace:\\n' +\n ` ${command} # the gate's own build\\n` +\n ' pnpm nx run <project>:ci # one project\\n' +\n ' pnpm exec vitest run <path> # one suite\\n' +\n 'Turn this guard off for this machine by setting \"experimental\": ' +\n '{ \"whole-repo-build-guard\": false } in ~/.webpieces/config.json (there is no ' +\n 'webpieces.config.json entry for it).',\n );\n }\n\n check(ctx: BashContext): readonly Violation[] {\n const home = this.loadHome();\n // A file someone deliberately created must be correct — see the class docstring. Reported for\n // whatever command happens to be running, because a broken opt-in file is not a build question.\n if (home instanceof Error) return this.blockOnBrokenHomeConfig(ctx, home);\n\n // THE EXPERIMENTAL GATE, and the state of essentially every consumer. Silent on purpose: no\n // block, no log, no file touched — \"no ~/.webpieces/config.json\" must be indistinguishable from\n // \"this guard does not exist\".\n this.homeConfigError = '';\n if (!home.wholeRepoBuildGuard) return [];\n\n // Blocklist-shaped, so match on commandCode: stripping heredocs and quoted prose can only ever\n // block LESS, and this repo's own commit messages are full of the command names above.\n const hit = new WholeRepoBuildScan(this.scanner, ctx.effectiveCwd === ctx.workspaceRoot)\n .firstHit(ctx.commandCode);\n if (hit === null) return this.allow(ctx, 'not-a-whole-repo-build');\n\n // Resolved ONCE, here, and read by both the violation message and the fix hint.\n this.resolvedCommand = this.resolvedBuildCommand(ctx.workspaceRoot);\n return this.block(ctx, hit, this.message(home));\n }\n\n /**\n * The home config, or the Error explaining why it is unusable. NOT a boolean: \"absent\" is already\n * folded into the returned HomeConfig (all-defaults, guard off), so the only thing left to\n * distinguish is \"present and wrong\", which must be reported rather than swallowed.\n */\n private loadHome(): HomeConfig | Error {\n // webpieces-disable no-unmanaged-exceptions -- chokepoint: a rejected home config is converted\n // into this guard's own block, which carries the loader's fix instruction verbatim\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return this.homeConfig.load();\n } catch (err: unknown) {\n const error = toError(err);\n return error;\n }\n }\n\n private blockOnBrokenHomeConfig(ctx: BashContext, error: Error): readonly Violation[] {\n this.homeConfigError = error.message;\n this.logDecision(ctx, 'BLOCK_AI_CURE', 'home-config-invalid');\n return [new V(1, this.truncate(ctx.command), this.brokenHomeConfigMessage(error))];\n }\n\n private brokenHomeConfigMessage(error: Error): string {\n const detail = error instanceof InformAiError\n ? error.message\n : `~/.webpieces/config.json could not be read: ${error.message}`;\n return `Blocked: ~/.webpieces/config.json is present but unusable.\\n\\n${detail}`;\n }\n\n // The whole refusal. Short on purpose: it is read mid-task by an agent that needs the ONE command\n // to run next, and a guard message long enough to skim is a guard message that gets skimmed.\n private message(home: HomeConfig): string {\n if (home.buildGateLogCapture) {\n return 'Blocked: that builds the WHOLE monorepo — and you should not be building at all.\\n'\n + '`pnpm wp-review-upsert-pr` (stage ②) runs the build for you, captures the full output to a\\n'\n + 'log file, and names that file if it fails. Read the file; do not rebuild.\\n'\n + 'Need a check before then: pnpm exec vitest run <path>.';\n }\n return 'Blocked: that builds the WHOLE monorepo. Build only what your change affects:\\n\\n'\n + ` ${this.resolvedCommand}\\n\\n`\n + 'Narrower still: pnpm nx run <project>:ci, or pnpm exec vitest run <path>.';\n }\n\n /**\n * The project's build command, unexpanded. ONE source: `commands.pr-gate.buildCommand`, handed to\n * the constructor by the runner, falling back to the same DEFAULT_BUILD_COMMAND the gate itself\n * falls back to. This guard never spells a build command of its own — a second copy is how a refusal\n * starts teaching a command the gate does not run.\n */\n private buildCommandTemplate(): string {\n return this.affectedBuildCommand.trim() === '' ? DEFAULT_BUILD_COMMAND : this.affectedBuildCommand;\n }\n\n /**\n * The configured build command with its `$(git …)` substitutions expanded, so what is printed is\n * runnable as-is. Expansion is limited to git, and any failure leaves the template untouched — a\n * guard may degrade its own message, never fail the tool call it is judging.\n */\n private resolvedBuildCommand(workspaceRoot: string): string {\n const template = this.buildCommandTemplate();\n return template.replace(/\\$\\(([^()]*)\\)/g, (match: string, inner: string): string => {\n const trimmed = inner.trim();\n if (!trimmed.startsWith('git ')) return match;\n const output = this.capture(trimmed, workspaceRoot);\n return output === null ? match : output;\n });\n }\n\n private capture(command: string, workspaceRoot: string): string | null {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return execSync(command, {\n cwd: workspaceRoot, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return null;\n }\n }\n\n private allow(ctx: BashContext, reason: string): readonly Violation[] {\n this.logDecision(ctx, 'ALLOW', reason);\n return [];\n }\n\n private block(ctx: BashContext, hit: WholeRepoBuildHit, message: string): readonly Violation[] {\n // BLOCK_AI_CURE, not BLOCK_HUMAN: the cure is one command the agent runs itself, and the\n // refusal hands it over already resolved.\n this.logDecision(ctx, 'BLOCK_AI_CURE', hit.shape);\n return [new V(1, this.truncate(ctx.command), message)];\n }\n\n private truncate(s: string): string {\n const MAX = 120;\n return s.length <= MAX ? s : s.slice(0, MAX) + '…';\n }\n\n private logDecision(ctx: BashContext, verdict: Verdict, reason: string): void {\n logGuardDecision(\n ctx.workspaceRoot,\n new GuardDecision('whole-repo-build-guard', 'Bash', ctx.command, '-', verdict, reason, '-', L0_FAULT_NONE, MATRIX_L2_UNROWED),\n );\n }\n}\n"]}
@@ -60,6 +60,27 @@ export declare class VersionSyncGuard {
60
60
  *
61
61
  * So the escalation is rendered as literal text to forward, with the versions and the direction
62
62
  * already filled in. A human cannot sit with every agent; the deny has to carry the whole ask.
63
+ *
64
+ * The ask is ROUTED THROUGH THE MAIN AGENT rather than phrased as a command, and that distinction is
65
+ * the whole point of this block. `git -C <mainRoot> pull` reads like something you run from wherever
66
+ * you are standing — so a subagent reads it, tries it, and only then discovers that CROSS-TREE GIT IS
67
+ * REFUSED to a worktree-isolated agent (same measurement shim-deny-reason.ts records: the harness
68
+ * blocks `git -C <other tree>`, and it is git specifically). This skew needs a git pull in main, so
69
+ * the one printed cure was the one thing this session cannot perform. Be precise about that and do
70
+ * NOT overstate it into "you cannot reach that tree at all" — a local `cd <main> && pnpm install`
71
+ * measurably DOES run, it simply cannot move main onto a different commit, which is what a skew
72
+ * requires. The actor who can is the MAIN AGENT running in the MAIN git worktree, so the forwarded
73
+ * text asks for exactly that — and asks to be TOLD WHEN IT IS DONE, because "the work happened" is
74
+ * the event that unblocks this subagent and it has no way to observe it otherwise.
75
+ *
76
+ * And it has to say STOP, in caps, because forwarding is only half of what the subagent must do. One
77
+ * measured transcript re-fired this identical deny 35 TIMES: the subagent read it, escalated exactly
78
+ * as asked — and then kept making tool calls, because nothing here said that forwarding ENDS the
79
+ * turn. Every retry cost a round trip and pushed the one message that mattered further up the
80
+ * scrollback. The block is not transient and no command from this tree slips past it, so retrying is
81
+ * never a strategy; it is the bug. The caps are spent ONLY on that beat (STOP WORKING NOW / NO
82
+ * further tool calls / RETRYING IS THE BUG / WAIT) — shouting the whole report would just restore
83
+ * the wall of text the L0 message diet exists to prevent.
63
84
  */
64
85
  private escalationLines;
65
86
  /**
@@ -184,20 +184,51 @@ class VersionSyncGuard {
184
184
  *
185
185
  * So the escalation is rendered as literal text to forward, with the versions and the direction
186
186
  * already filled in. A human cannot sit with every agent; the deny has to carry the whole ask.
187
+ *
188
+ * The ask is ROUTED THROUGH THE MAIN AGENT rather than phrased as a command, and that distinction is
189
+ * the whole point of this block. `git -C <mainRoot> pull` reads like something you run from wherever
190
+ * you are standing — so a subagent reads it, tries it, and only then discovers that CROSS-TREE GIT IS
191
+ * REFUSED to a worktree-isolated agent (same measurement shim-deny-reason.ts records: the harness
192
+ * blocks `git -C <other tree>`, and it is git specifically). This skew needs a git pull in main, so
193
+ * the one printed cure was the one thing this session cannot perform. Be precise about that and do
194
+ * NOT overstate it into "you cannot reach that tree at all" — a local `cd <main> && pnpm install`
195
+ * measurably DOES run, it simply cannot move main onto a different commit, which is what a skew
196
+ * requires. The actor who can is the MAIN AGENT running in the MAIN git worktree, so the forwarded
197
+ * text asks for exactly that — and asks to be TOLD WHEN IT IS DONE, because "the work happened" is
198
+ * the event that unblocks this subagent and it has no way to observe it otherwise.
199
+ *
200
+ * And it has to say STOP, in caps, because forwarding is only half of what the subagent must do. One
201
+ * measured transcript re-fired this identical deny 35 TIMES: the subagent read it, escalated exactly
202
+ * as asked — and then kept making tool calls, because nothing here said that forwarding ENDS the
203
+ * turn. Every retry cost a round trip and pushed the one message that mattered further up the
204
+ * scrollback. The block is not transient and no command from this tree slips past it, so retrying is
205
+ * never a strategy; it is the bug. The caps are spent ONLY on that beat (STOP WORKING NOW / NO
206
+ * further tool calls / RETRYING IS THE BUG / WAIT) — shouting the whole report would just restore
207
+ * the wall of text the L0 message diet exists to prevent.
187
208
  */
188
209
  escalationLines(tree, quartet, bump) {
189
210
  const ask = bump
190
211
  ? [
191
212
  ` > A \`pnpm install\` in main will NOT fix this — main's PIN has to move. Pick one:`,
192
213
  ` > (a) I redo this task in the MAIN tree (a version bump cannot be done in a worktree), or`,
193
- ` > (b) you raise main's catalog pin to ${this.show(quartet.worktree.pinned).trim()} and \`pnpm install\` there, and I continue here.`,
214
+ ` > (b) you TELL THE MAIN AGENT in the MAIN git worktree ${tree.mainRoot} to`,
215
+ ` > raise main's catalog pin to ${this.show(quartet.worktree.pinned).trim()} and \`pnpm install\` there, and to tell me when it is complete`,
216
+ ` > so I can continue here. I cannot reach that tree from here.`,
194
217
  ]
195
- : [` > Please \`git -C ${tree.mainRoot} pull\` then \`pnpm install\` there, so both trees are on`, ` > the same release. I cannot reach that tree from here.`];
218
+ : [
219
+ ` > Please TELL THE MAIN AGENT in the MAIN git worktree ${tree.mainRoot} to run`,
220
+ ` > \`git pull && pnpm install\` there, so both trees are on the same release, and to tell me`,
221
+ ` > when it is complete so I can continue working. I cannot reach that tree from here.`,
222
+ ];
196
223
  return [
197
224
  ` SUBAGENT? You cannot fix the main tree from here. Forward this to your coordinator verbatim:`,
198
225
  ` > My worktree ${tree.root} is on @webpieces ${this.show(quartet.worktree.pinned).trim()};`,
199
226
  ` > the main tree ${tree.mainRoot} is on ${this.show(quartet.main.pinned).trim()}.`,
200
227
  ...ask,
228
+ ` THEN STOP WORKING NOW. Forwarding that message IS the end of your turn: make NO further tool`,
229
+ ` calls and do NOT retry this one — RETRYING IS THE BUG. Every retry re-fires this identical`,
230
+ ` deny and buries the ask above; one transcript did it 35 times. Nothing you can do from this`,
231
+ ` tree clears it. WAIT for the main agent to confirm it is done, then resume — nothing between.`,
201
232
  ];
202
233
  }
203
234
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"version-sync.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/version-sync.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawnSync } from 'child_process';\nimport * as path from 'path';\n\nimport { EffectiveTree } from './effective-tree';\nimport { ReadOnlyInspectionScan } from './read-only-inspection';\nimport { UMBRELLA_PACKAGE, VersionQuartet, WebpiecesVersions } from './webpieces-versions';\n\n/**\n * L1 row 8 — a tree may not be worked in while its `@webpieces` version disagrees with the MAIN tree's.\n *\n * ─── WHY THIS EXISTS, and what it replaces ─────────────────────────────────────────────────────────\n * The guard hooks are registered ABSOLUTE (`$CLAUDE_PROJECT_DIR/...`), so the MAIN tree governs every\n * tree. That is not a new imposition — it is what was always happening, because a linked worktree has no\n * `node_modules` and ai-hook.sh's upward walk already executed the main tree's binary. The design now\n * says so out loud, which makes ONE case newly important: a worktree whose branch pins a DIFFERENT\n * release is being linted, validated and built by a release it never asked for.\n *\n * This guard makes that case LOUD instead of silent. It replaces `CoordinatorWorktreeGuard`, and the\n * replacement is strictly better on the axis that matters: the old guard keyed off WHO was asking\n * (coordinator vs subagent), and agent identity was measured untrustworthy — a worktree-isolated agent\n * whose tree is auto-reaped at a turn boundary silently resumes with its cwd on the primary clone\n * (reproduced twice, 2026-08-10). This guard keys off the PATH the command acts on, which cannot lie.\n *\n * ─── IT EXISTS TO STOP A LOOP, not to enforce tidiness ─────────────────────────────────────────────\n * A main/worktree manifest mismatch is exactly the shape that produced the founding incident: an agent\n * is shown a fault measured against one tree, runs the prescribed cure in another, the cure succeeds,\n * nothing the guard measures changes, and the guard re-denies. Five identical no-op `pnpm install`s and\n * a fabricated theory about the harness later, a human had to untangle it. Firing EARLY, with a message\n * that names all the versions and all their files, is the whole point. Any future proposal to soften\n * this to a warning must answer: what stops the five-install loop instead?\n *\n * ─── Never a deadlock ──────────────────────────────────────────────────────────────────────────────\n * Two structurally independent escapes, and neither depends on an allowlist regex staying in step:\n * 1. WORK IN THE MAIN TREE — a main-tree-targeted command cannot classify as `worktree`, so it never\n * reaches this guard at all. No allowlist entry can break it because none is involved.\n * 2. EDIT THE MANIFESTS — `pnpm-workspace.yaml` / `package.json` edits are carved out in the runner\n * the same way `webpieces.config.json` already is, so the cure is typable from inside the block.\n * Reads and read-only inspection are never blocked either, so an agent can always look before it fixes.\n *\n * ─── The MAIN tree is `tree.mainRoot`, never `tree.governedRoot` ───────────────────────────────────\n * The two differ for exactly the reader this guard is for. `governedRoot` is walked up from the payload\n * cwd to the nearest `webpieces.config.json`, and that file is TRACKED — a linked worktree has its own.\n * So for an agent resident in a worktree `governedRoot` IS the worktree, and comparing it against\n * `tree.root` compared the tree with ITSELF: trivially in sync, guard silent. `mainRoot` is git's\n * `<git-common-dir>/..`, i.e. the clone whose `node_modules` actually supplies the judging binary, and\n * it is the same answer from every checkout. Measured 2026-08-10: a worktree on 0.4.624 with its own\n * install, a main clone on 0.4.616, and not one word from this guard.\n */\n/** The one file a pin lives in — named here so the \"did this branch bump it\" check cannot drift from it. */\nconst WORKSPACE_MANIFEST = 'pnpm-workspace.yaml';\n\nexport class VersionSyncGuard {\n private readonly inspection = new ReadOnlyInspectionScan();\n private readonly versions = new WebpiecesVersions();\n\n /**\n * True when this tree is a linked worktree whose webpieces version disagrees with the main tree's.\n * This is the `V` dimension of the L1 matrix; the runner asks it for EVERY Bash call so the answer\n * lands in the audit log even when nothing blocks.\n */\n skewed(tree: EffectiveTree): boolean {\n if (!this.applies(tree)) return false;\n return !this.quartetFor(tree).inSync;\n }\n\n /** The deny report, or null to allow. */\n block(command: string, tree: EffectiveTree): string | null {\n if (!this.applies(tree)) return null;\n if (this.inspection.isReadOnlyInspection(command)) return null;\n if (this.isCureOrLook(command)) return null;\n const quartet = this.quartetFor(tree);\n if (quartet.inSync) return null;\n return this.report(tree, quartet);\n }\n\n /**\n * Commands that must pass EVEN WHILE THIS GUARD IS BLOCKING, because they are how you get unblocked\n * — or how you look at the tree first.\n *\n * `ReadOnlyInspectionScan` deliberately excludes git and gh OUTRIGHT (\"the guards exist to police\n * git, and read-only git is not a line worth drawing while flying blind\"), which is right for the\n * guards that police git but WRONG here: this guard's own prescribed cure is `git pull` in both\n * trees. Without this carve-out the guard would deny the exact command it tells the reader to run —\n * the single failure shape this repo has been burned by most often, and the reason the deny text is\n * allowed to promise \"STILL ALLOWED HERE: ... pnpm install, git pull/fetch\".\n *\n * Deliberately NARROW: fetching, pulling and installing cannot make a skew worse, and every one of\n * them moves the tree toward agreement. Anything that BUILDS, TESTS or COMMITS is still blocked,\n * because those are the operations that would be judged by the wrong release.\n */\n private isCureOrLook(command: string): boolean {\n const words = command.trim().split(/\\s+/);\n const head = words[0] ?? '';\n const sub = words[1] ?? '';\n if (head === 'git' || head === 'gh') {\n // `git -C <dir> <sub>` names its own directory; take the first non-flag word after it.\n const subcommand = sub === '-C' ? (words[3] ?? '') : sub;\n return ['pull', 'fetch', 'status', 'log', 'diff', 'show', 'branch', 'rev-parse', 'worktree'].includes(subcommand);\n }\n return (head === 'pnpm' || head === 'npm') && (sub === 'install' || sub === 'i');\n }\n\n /**\n * Is there a cross-tree comparison to make at all? TWO cheap conditions, no file read behind either:\n *\n * • K is `worktree` — git's `--git-dir ≠ --git-common-dir`, so a repo with no linked worktrees can\n * never reach the manifests. (In the primary clone this is also structural escape #1: \"do the\n * work in the main tree\" needs no allowlist entry to keep working.)\n * • the two roots are DIFFERENT directories — a tree compared with itself is not a skew, it is the\n * single-tree pin-vs-install question the L0 drift guard already owns.\n */\n private applies(tree: EffectiveTree): boolean {\n return tree.kind === 'worktree'\n && path.resolve(tree.mainRoot) !== path.resolve(tree.root);\n }\n\n /** Public so the runner can log all four versions on ALLOW as well as on BLOCK (audit, not just deny). */\n quartetFor(tree: EffectiveTree): VersionQuartet {\n return this.versions.quartet(tree.mainRoot, tree.root);\n }\n\n // Short on purpose — L0 ran a deliberate message diet and these blocks regress into a wall of text\n // if each one argues its case. State the skew, show every version WITH its file, give the git cure\n // first, then the two structural escapes, then what is still allowed.\n private report(tree: EffectiveTree, quartet: VersionQuartet): string {\n const bump = this.isDeliberateBump(tree, quartet);\n return [\n `❌ @webpieces version SKEW — this worktree and the main tree disagree, so work here is blocked.`,\n '',\n ...this.versionLines(tree, quartet),\n '',\n ` Whichever tree's hooks are live, one of these two releases lints, validates and builds`,\n ` this worktree — and it may be the one this manifest does not ask for.`,\n '',\n ...this.fixLines(tree, quartet, bump),\n '',\n ...this.escalationLines(tree, quartet, bump),\n '',\n ` STILL ALLOWED HERE: every Read, read-only inspection, \\`pnpm install\\`, \\`git pull\\`/\\`fetch\\`,`,\n ` and edits to pnpm-workspace.yaml / package.json / webpieces.config.json.`,\n ` Do NOT lower the MAIN tree's pin to match — that downgrades every tree, including this`,\n ` session's own governor.`,\n ].join('\\n');\n }\n\n /**\n * The cure list, which is NOT the same list in both directions.\n *\n * The ordinary skew is two trees sitting on different commits of main, and there `git pull` both +\n * `pnpm install` genuinely converges them — the pin is tracked, so the same hash gives the same\n * version. That cure is WRONG, and worse than useless, when the branch bumped the pin ON PURPOSE:\n * pulling would revert the deliverable, and an install cannot move a pin in either tree. Printing\n * the git cure first in that case is what sent a real upgrade agent round the loop below.\n */\n private fixLines(tree: EffectiveTree, quartet: VersionQuartet, bump: boolean): readonly string[] {\n if (bump) {\n return [\n ` THIS BRANCH BUMPED THE PIN ON PURPOSE (${this.show(quartet.main.pinned).trim()} → ${this.show(quartet.worktree.pinned).trim()}), so the usual cures do NOT apply:`,\n ` • \\`pnpm install\\` cannot help in EITHER tree — an install materializes a pin, never moves one.`,\n ` • \\`git pull\\` here would revert the bump, which is the whole deliverable.`,\n ` • Wiping this tree's node_modules does NOT help — the two PINS still disagree, and the`,\n ` L0 drift guard blocks in this guard's place.`,\n ` Two ways out, and BOTH need the main tree:`,\n ` 1. Redo this task in the MAIN tree — a version bump cannot be done in a worktree at all.`,\n ` 2. Or raise the MAIN tree's pin to ${this.show(quartet.worktree.pinned).trim()} and \\`pnpm install\\` there, then continue here.`,\n ];\n }\n return [\n ` FIX (usually just git — the pin is TRACKED, so the same commit gives the same version):`,\n ` 1. \\`git -C ${tree.mainRoot} pull\\` and \\`git -C ${tree.root} pull\\` onto the same main,`,\n ` then \\`pnpm install\\` in each tree that has a node_modules. A worktree MAY have its`,\n ` own; what it may not have is a DIFFERENT @webpieces version from the main tree.`,\n ` 2. Or work in the MAIN tree instead — it is never blocked by this guard.`,\n ` 3. Or, if this tree genuinely needs a DIFFERENT version, use a separate CLONE, not a`,\n ` worktree: a clone gets its own governance. (This is the answer to \"I need a different`,\n ` version\", never to \"I need to install here\" — installing here is fine.)`,\n ];\n }\n\n /**\n * THE SUBAGENT CANNOT REACH THE MAIN TREE, so the message it is handed has to be the message it\n * FORWARDS. This used to be one sentence — \"report to your coordinator that one of you must move to\n * the other's version\" — with no command, no direction and nothing pasteable, and the result was a\n * subagent that correctly diagnosed the block, correctly escalated, and handed its coordinator a\n * request too vague to act on. Worse, the obvious guess (\"ask the coordinator to run `pnpm install`\n * in main\") is a NO-OP on a bump: it reinstalls main's own pin and nothing moves.\n *\n * So the escalation is rendered as literal text to forward, with the versions and the direction\n * already filled in. A human cannot sit with every agent; the deny has to carry the whole ask.\n */\n private escalationLines(tree: EffectiveTree, quartet: VersionQuartet, bump: boolean): readonly string[] {\n const ask = bump\n ? [\n ` > A \\`pnpm install\\` in main will NOT fix this — main's PIN has to move. Pick one:`,\n ` > (a) I redo this task in the MAIN tree (a version bump cannot be done in a worktree), or`,\n ` > (b) you raise main's catalog pin to ${this.show(quartet.worktree.pinned).trim()} and \\`pnpm install\\` there, and I continue here.`,\n ]\n : [` > Please \\`git -C ${tree.mainRoot} pull\\` then \\`pnpm install\\` there, so both trees are on`, ` > the same release. I cannot reach that tree from here.`];\n return [\n ` SUBAGENT? You cannot fix the main tree from here. Forward this to your coordinator verbatim:`,\n ` > My worktree ${tree.root} is on @webpieces ${this.show(quartet.worktree.pinned).trim()};`,\n ` > the main tree ${tree.mainRoot} is on ${this.show(quartet.main.pinned).trim()}.`,\n ...ask,\n ];\n }\n\n /**\n * Did THIS BRANCH change the pin, as opposed to the two trees having drifted onto different commits?\n *\n * Only answerable now that both pin legs actually resolve — before the catalog reader followed YAML\n * anchors they both read null on the repos that pin via an anchor, so every skew looked alike and the\n * report could only ever print the one generic cure.\n *\n * Two git spawns worst case, on the BLOCK path only (this is never reached on an allow), and\n * best-effort: a git failure answers \"not a deliberate bump\", which falls back to the generic cure\n * that was the only text this report had before.\n */\n private isDeliberateBump(tree: EffectiveTree, quartet: VersionQuartet): boolean {\n if (quartet.main.pinned === null || quartet.worktree.pinned === null) return false;\n if (quartet.main.pinned === quartet.worktree.pinned) return false;\n return this.touchesWorkspaceFile(tree.root, ['status', '--porcelain', '--', WORKSPACE_MANIFEST])\n || this.touchesWorkspaceFile(tree.root, ['diff', '--name-only', 'origin/main...HEAD', '--', WORKSPACE_MANIFEST]);\n }\n\n private touchesWorkspaceFile(root: string, args: readonly string[]): boolean {\n const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });\n return result.status === 0 && (result.stdout ?? '').trim() !== '';\n }\n\n // Every version WITH the file it came from. An agent that is told \"they disagree\" without being told\n // WHICH FILE to edit re-derives it by grepping, which is exactly the turn-burning this guard exists\n // to prevent. Unreadable legs are printed as `-` rather than omitted, so the reader can tell\n // \"this one is absent\" from \"I forgot to look\".\n private versionLines(tree: EffectiveTree, quartet: VersionQuartet): readonly string[] {\n const lines = [\n ` main pin ${this.show(quartet.main.pinned)} ${tree.mainRoot}/pnpm-workspace.yaml`,\n ` main installed ${this.show(quartet.main.installed)} ${tree.mainRoot}/node_modules/${UMBRELLA_PACKAGE}`,\n ` ^ the binary judging this very call`,\n ` this worktree ${this.show(quartet.worktree.pinned)} ${tree.root}/pnpm-workspace.yaml`,\n ];\n if (quartet.worktree.installed !== null) {\n lines.push(` its installed ${this.show(quartet.worktree.installed)} ${tree.root}/node_modules/${UMBRELLA_PACKAGE}`);\n lines.push(' ^ what nx, vitest and eslint load IN this tree');\n }\n const others = this.versions.otherWorktrees(tree.mainRoot, tree.root);\n if (others.length > 0) {\n lines.push(` NOTE ${others.length} other worktree(s) exist and are governed the same way — if they are`);\n lines.push(' skewed too, their agents are already mis-governed. Consider clones, or');\n lines.push(' serializing the work in the main tree.');\n }\n return lines;\n }\n\n private show(version: string | null): string {\n return (version ?? '-').padEnd(10);\n }\n}\n"]}
1
+ {"version":3,"file":"version-sync.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/version-sync.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawnSync } from 'child_process';\nimport * as path from 'path';\n\nimport { EffectiveTree } from './effective-tree';\nimport { ReadOnlyInspectionScan } from './read-only-inspection';\nimport { UMBRELLA_PACKAGE, VersionQuartet, WebpiecesVersions } from './webpieces-versions';\n\n/**\n * L1 row 8 — a tree may not be worked in while its `@webpieces` version disagrees with the MAIN tree's.\n *\n * ─── WHY THIS EXISTS, and what it replaces ─────────────────────────────────────────────────────────\n * The guard hooks are registered ABSOLUTE (`$CLAUDE_PROJECT_DIR/...`), so the MAIN tree governs every\n * tree. That is not a new imposition — it is what was always happening, because a linked worktree has no\n * `node_modules` and ai-hook.sh's upward walk already executed the main tree's binary. The design now\n * says so out loud, which makes ONE case newly important: a worktree whose branch pins a DIFFERENT\n * release is being linted, validated and built by a release it never asked for.\n *\n * This guard makes that case LOUD instead of silent. It replaces `CoordinatorWorktreeGuard`, and the\n * replacement is strictly better on the axis that matters: the old guard keyed off WHO was asking\n * (coordinator vs subagent), and agent identity was measured untrustworthy — a worktree-isolated agent\n * whose tree is auto-reaped at a turn boundary silently resumes with its cwd on the primary clone\n * (reproduced twice, 2026-08-10). This guard keys off the PATH the command acts on, which cannot lie.\n *\n * ─── IT EXISTS TO STOP A LOOP, not to enforce tidiness ─────────────────────────────────────────────\n * A main/worktree manifest mismatch is exactly the shape that produced the founding incident: an agent\n * is shown a fault measured against one tree, runs the prescribed cure in another, the cure succeeds,\n * nothing the guard measures changes, and the guard re-denies. Five identical no-op `pnpm install`s and\n * a fabricated theory about the harness later, a human had to untangle it. Firing EARLY, with a message\n * that names all the versions and all their files, is the whole point. Any future proposal to soften\n * this to a warning must answer: what stops the five-install loop instead?\n *\n * ─── Never a deadlock ──────────────────────────────────────────────────────────────────────────────\n * Two structurally independent escapes, and neither depends on an allowlist regex staying in step:\n * 1. WORK IN THE MAIN TREE — a main-tree-targeted command cannot classify as `worktree`, so it never\n * reaches this guard at all. No allowlist entry can break it because none is involved.\n * 2. EDIT THE MANIFESTS — `pnpm-workspace.yaml` / `package.json` edits are carved out in the runner\n * the same way `webpieces.config.json` already is, so the cure is typable from inside the block.\n * Reads and read-only inspection are never blocked either, so an agent can always look before it fixes.\n *\n * ─── The MAIN tree is `tree.mainRoot`, never `tree.governedRoot` ───────────────────────────────────\n * The two differ for exactly the reader this guard is for. `governedRoot` is walked up from the payload\n * cwd to the nearest `webpieces.config.json`, and that file is TRACKED — a linked worktree has its own.\n * So for an agent resident in a worktree `governedRoot` IS the worktree, and comparing it against\n * `tree.root` compared the tree with ITSELF: trivially in sync, guard silent. `mainRoot` is git's\n * `<git-common-dir>/..`, i.e. the clone whose `node_modules` actually supplies the judging binary, and\n * it is the same answer from every checkout. Measured 2026-08-10: a worktree on 0.4.624 with its own\n * install, a main clone on 0.4.616, and not one word from this guard.\n */\n/** The one file a pin lives in — named here so the \"did this branch bump it\" check cannot drift from it. */\nconst WORKSPACE_MANIFEST = 'pnpm-workspace.yaml';\n\nexport class VersionSyncGuard {\n private readonly inspection = new ReadOnlyInspectionScan();\n private readonly versions = new WebpiecesVersions();\n\n /**\n * True when this tree is a linked worktree whose webpieces version disagrees with the main tree's.\n * This is the `V` dimension of the L1 matrix; the runner asks it for EVERY Bash call so the answer\n * lands in the audit log even when nothing blocks.\n */\n skewed(tree: EffectiveTree): boolean {\n if (!this.applies(tree)) return false;\n return !this.quartetFor(tree).inSync;\n }\n\n /** The deny report, or null to allow. */\n block(command: string, tree: EffectiveTree): string | null {\n if (!this.applies(tree)) return null;\n if (this.inspection.isReadOnlyInspection(command)) return null;\n if (this.isCureOrLook(command)) return null;\n const quartet = this.quartetFor(tree);\n if (quartet.inSync) return null;\n return this.report(tree, quartet);\n }\n\n /**\n * Commands that must pass EVEN WHILE THIS GUARD IS BLOCKING, because they are how you get unblocked\n * — or how you look at the tree first.\n *\n * `ReadOnlyInspectionScan` deliberately excludes git and gh OUTRIGHT (\"the guards exist to police\n * git, and read-only git is not a line worth drawing while flying blind\"), which is right for the\n * guards that police git but WRONG here: this guard's own prescribed cure is `git pull` in both\n * trees. Without this carve-out the guard would deny the exact command it tells the reader to run —\n * the single failure shape this repo has been burned by most often, and the reason the deny text is\n * allowed to promise \"STILL ALLOWED HERE: ... pnpm install, git pull/fetch\".\n *\n * Deliberately NARROW: fetching, pulling and installing cannot make a skew worse, and every one of\n * them moves the tree toward agreement. Anything that BUILDS, TESTS or COMMITS is still blocked,\n * because those are the operations that would be judged by the wrong release.\n */\n private isCureOrLook(command: string): boolean {\n const words = command.trim().split(/\\s+/);\n const head = words[0] ?? '';\n const sub = words[1] ?? '';\n if (head === 'git' || head === 'gh') {\n // `git -C <dir> <sub>` names its own directory; take the first non-flag word after it.\n const subcommand = sub === '-C' ? (words[3] ?? '') : sub;\n return ['pull', 'fetch', 'status', 'log', 'diff', 'show', 'branch', 'rev-parse', 'worktree'].includes(subcommand);\n }\n return (head === 'pnpm' || head === 'npm') && (sub === 'install' || sub === 'i');\n }\n\n /**\n * Is there a cross-tree comparison to make at all? TWO cheap conditions, no file read behind either:\n *\n * • K is `worktree` — git's `--git-dir ≠ --git-common-dir`, so a repo with no linked worktrees can\n * never reach the manifests. (In the primary clone this is also structural escape #1: \"do the\n * work in the main tree\" needs no allowlist entry to keep working.)\n * • the two roots are DIFFERENT directories — a tree compared with itself is not a skew, it is the\n * single-tree pin-vs-install question the L0 drift guard already owns.\n */\n private applies(tree: EffectiveTree): boolean {\n return tree.kind === 'worktree'\n && path.resolve(tree.mainRoot) !== path.resolve(tree.root);\n }\n\n /** Public so the runner can log all four versions on ALLOW as well as on BLOCK (audit, not just deny). */\n quartetFor(tree: EffectiveTree): VersionQuartet {\n return this.versions.quartet(tree.mainRoot, tree.root);\n }\n\n // Short on purpose — L0 ran a deliberate message diet and these blocks regress into a wall of text\n // if each one argues its case. State the skew, show every version WITH its file, give the git cure\n // first, then the two structural escapes, then what is still allowed.\n private report(tree: EffectiveTree, quartet: VersionQuartet): string {\n const bump = this.isDeliberateBump(tree, quartet);\n return [\n `❌ @webpieces version SKEW — this worktree and the main tree disagree, so work here is blocked.`,\n '',\n ...this.versionLines(tree, quartet),\n '',\n ` Whichever tree's hooks are live, one of these two releases lints, validates and builds`,\n ` this worktree — and it may be the one this manifest does not ask for.`,\n '',\n ...this.fixLines(tree, quartet, bump),\n '',\n ...this.escalationLines(tree, quartet, bump),\n '',\n ` STILL ALLOWED HERE: every Read, read-only inspection, \\`pnpm install\\`, \\`git pull\\`/\\`fetch\\`,`,\n ` and edits to pnpm-workspace.yaml / package.json / webpieces.config.json.`,\n ` Do NOT lower the MAIN tree's pin to match — that downgrades every tree, including this`,\n ` session's own governor.`,\n ].join('\\n');\n }\n\n /**\n * The cure list, which is NOT the same list in both directions.\n *\n * The ordinary skew is two trees sitting on different commits of main, and there `git pull` both +\n * `pnpm install` genuinely converges them — the pin is tracked, so the same hash gives the same\n * version. That cure is WRONG, and worse than useless, when the branch bumped the pin ON PURPOSE:\n * pulling would revert the deliverable, and an install cannot move a pin in either tree. Printing\n * the git cure first in that case is what sent a real upgrade agent round the loop below.\n */\n private fixLines(tree: EffectiveTree, quartet: VersionQuartet, bump: boolean): readonly string[] {\n if (bump) {\n return [\n ` THIS BRANCH BUMPED THE PIN ON PURPOSE (${this.show(quartet.main.pinned).trim()} → ${this.show(quartet.worktree.pinned).trim()}), so the usual cures do NOT apply:`,\n ` • \\`pnpm install\\` cannot help in EITHER tree — an install materializes a pin, never moves one.`,\n ` • \\`git pull\\` here would revert the bump, which is the whole deliverable.`,\n ` • Wiping this tree's node_modules does NOT help — the two PINS still disagree, and the`,\n ` L0 drift guard blocks in this guard's place.`,\n ` Two ways out, and BOTH need the main tree:`,\n ` 1. Redo this task in the MAIN tree — a version bump cannot be done in a worktree at all.`,\n ` 2. Or raise the MAIN tree's pin to ${this.show(quartet.worktree.pinned).trim()} and \\`pnpm install\\` there, then continue here.`,\n ];\n }\n return [\n ` FIX (usually just git — the pin is TRACKED, so the same commit gives the same version):`,\n ` 1. \\`git -C ${tree.mainRoot} pull\\` and \\`git -C ${tree.root} pull\\` onto the same main,`,\n ` then \\`pnpm install\\` in each tree that has a node_modules. A worktree MAY have its`,\n ` own; what it may not have is a DIFFERENT @webpieces version from the main tree.`,\n ` 2. Or work in the MAIN tree instead — it is never blocked by this guard.`,\n ` 3. Or, if this tree genuinely needs a DIFFERENT version, use a separate CLONE, not a`,\n ` worktree: a clone gets its own governance. (This is the answer to \"I need a different`,\n ` version\", never to \"I need to install here\" — installing here is fine.)`,\n ];\n }\n\n /**\n * THE SUBAGENT CANNOT REACH THE MAIN TREE, so the message it is handed has to be the message it\n * FORWARDS. This used to be one sentence — \"report to your coordinator that one of you must move to\n * the other's version\" — with no command, no direction and nothing pasteable, and the result was a\n * subagent that correctly diagnosed the block, correctly escalated, and handed its coordinator a\n * request too vague to act on. Worse, the obvious guess (\"ask the coordinator to run `pnpm install`\n * in main\") is a NO-OP on a bump: it reinstalls main's own pin and nothing moves.\n *\n * So the escalation is rendered as literal text to forward, with the versions and the direction\n * already filled in. A human cannot sit with every agent; the deny has to carry the whole ask.\n *\n * The ask is ROUTED THROUGH THE MAIN AGENT rather than phrased as a command, and that distinction is\n * the whole point of this block. `git -C <mainRoot> pull` reads like something you run from wherever\n * you are standing — so a subagent reads it, tries it, and only then discovers that CROSS-TREE GIT IS\n * REFUSED to a worktree-isolated agent (same measurement shim-deny-reason.ts records: the harness\n * blocks `git -C <other tree>`, and it is git specifically). This skew needs a git pull in main, so\n * the one printed cure was the one thing this session cannot perform. Be precise about that and do\n * NOT overstate it into \"you cannot reach that tree at all\" — a local `cd <main> && pnpm install`\n * measurably DOES run, it simply cannot move main onto a different commit, which is what a skew\n * requires. The actor who can is the MAIN AGENT running in the MAIN git worktree, so the forwarded\n * text asks for exactly that — and asks to be TOLD WHEN IT IS DONE, because \"the work happened\" is\n * the event that unblocks this subagent and it has no way to observe it otherwise.\n *\n * And it has to say STOP, in caps, because forwarding is only half of what the subagent must do. One\n * measured transcript re-fired this identical deny 35 TIMES: the subagent read it, escalated exactly\n * as asked — and then kept making tool calls, because nothing here said that forwarding ENDS the\n * turn. Every retry cost a round trip and pushed the one message that mattered further up the\n * scrollback. The block is not transient and no command from this tree slips past it, so retrying is\n * never a strategy; it is the bug. The caps are spent ONLY on that beat (STOP WORKING NOW / NO\n * further tool calls / RETRYING IS THE BUG / WAIT) — shouting the whole report would just restore\n * the wall of text the L0 message diet exists to prevent.\n */\n private escalationLines(tree: EffectiveTree, quartet: VersionQuartet, bump: boolean): readonly string[] {\n const ask = bump\n ? [\n ` > A \\`pnpm install\\` in main will NOT fix this — main's PIN has to move. Pick one:`,\n ` > (a) I redo this task in the MAIN tree (a version bump cannot be done in a worktree), or`,\n ` > (b) you TELL THE MAIN AGENT in the MAIN git worktree ${tree.mainRoot} to`,\n ` > raise main's catalog pin to ${this.show(quartet.worktree.pinned).trim()} and \\`pnpm install\\` there, and to tell me when it is complete`,\n ` > so I can continue here. I cannot reach that tree from here.`,\n ]\n : [\n ` > Please TELL THE MAIN AGENT in the MAIN git worktree ${tree.mainRoot} to run`,\n ` > \\`git pull && pnpm install\\` there, so both trees are on the same release, and to tell me`,\n ` > when it is complete so I can continue working. I cannot reach that tree from here.`,\n ];\n return [\n ` SUBAGENT? You cannot fix the main tree from here. Forward this to your coordinator verbatim:`,\n ` > My worktree ${tree.root} is on @webpieces ${this.show(quartet.worktree.pinned).trim()};`,\n ` > the main tree ${tree.mainRoot} is on ${this.show(quartet.main.pinned).trim()}.`,\n ...ask,\n ` THEN STOP WORKING NOW. Forwarding that message IS the end of your turn: make NO further tool`,\n ` calls and do NOT retry this one — RETRYING IS THE BUG. Every retry re-fires this identical`,\n ` deny and buries the ask above; one transcript did it 35 times. Nothing you can do from this`,\n ` tree clears it. WAIT for the main agent to confirm it is done, then resume — nothing between.`,\n ];\n }\n\n /**\n * Did THIS BRANCH change the pin, as opposed to the two trees having drifted onto different commits?\n *\n * Only answerable now that both pin legs actually resolve — before the catalog reader followed YAML\n * anchors they both read null on the repos that pin via an anchor, so every skew looked alike and the\n * report could only ever print the one generic cure.\n *\n * Two git spawns worst case, on the BLOCK path only (this is never reached on an allow), and\n * best-effort: a git failure answers \"not a deliberate bump\", which falls back to the generic cure\n * that was the only text this report had before.\n */\n private isDeliberateBump(tree: EffectiveTree, quartet: VersionQuartet): boolean {\n if (quartet.main.pinned === null || quartet.worktree.pinned === null) return false;\n if (quartet.main.pinned === quartet.worktree.pinned) return false;\n return this.touchesWorkspaceFile(tree.root, ['status', '--porcelain', '--', WORKSPACE_MANIFEST])\n || this.touchesWorkspaceFile(tree.root, ['diff', '--name-only', 'origin/main...HEAD', '--', WORKSPACE_MANIFEST]);\n }\n\n private touchesWorkspaceFile(root: string, args: readonly string[]): boolean {\n const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });\n return result.status === 0 && (result.stdout ?? '').trim() !== '';\n }\n\n // Every version WITH the file it came from. An agent that is told \"they disagree\" without being told\n // WHICH FILE to edit re-derives it by grepping, which is exactly the turn-burning this guard exists\n // to prevent. Unreadable legs are printed as `-` rather than omitted, so the reader can tell\n // \"this one is absent\" from \"I forgot to look\".\n private versionLines(tree: EffectiveTree, quartet: VersionQuartet): readonly string[] {\n const lines = [\n ` main pin ${this.show(quartet.main.pinned)} ${tree.mainRoot}/pnpm-workspace.yaml`,\n ` main installed ${this.show(quartet.main.installed)} ${tree.mainRoot}/node_modules/${UMBRELLA_PACKAGE}`,\n ` ^ the binary judging this very call`,\n ` this worktree ${this.show(quartet.worktree.pinned)} ${tree.root}/pnpm-workspace.yaml`,\n ];\n if (quartet.worktree.installed !== null) {\n lines.push(` its installed ${this.show(quartet.worktree.installed)} ${tree.root}/node_modules/${UMBRELLA_PACKAGE}`);\n lines.push(' ^ what nx, vitest and eslint load IN this tree');\n }\n const others = this.versions.otherWorktrees(tree.mainRoot, tree.root);\n if (others.length > 0) {\n lines.push(` NOTE ${others.length} other worktree(s) exist and are governed the same way — if they are`);\n lines.push(' skewed too, their agents are already mis-governed. Consider clones, or');\n lines.push(' serializing the work in the main tree.');\n }\n return lines;\n }\n\n private show(version: string | null): string {\n return (version ?? '-').padEnd(10);\n }\n}\n"]}
@@ -119,7 +119,7 @@ WP_INSTALL_CMD="pnpm install"
119
119
  WP_BORROW_NOTE=""
120
120
  if [ "$BIN_ROOT" != "$ROOT" ]; then
121
121
  WP_INSTALL_CMD="cd $ROOT && pnpm install"
122
- WP_BORROW_NOTE="${NL} NOTE: this tree ($ROOT) has NO node_modules of its own, so the guard binary was inherited from $BIN_ROOT by walking up. TWO cures are real and they fix DIFFERENT things: A makes THIS tree work now, B stops the two trees disagreeing. A - run the command above HERE. That is legitimate and it does work; a worktree NEEDS its own node_modules anyway (nx, vitest and the eslint plugin all execute in this tree and load from it). B - get $BIN_ROOT onto the same @webpieces version: put both trees on the same git hash (the pin is tracked) and run ONE 'pnpm install' there. If you are a SUBAGENT you cannot reach that tree, so ESCALATE - ask the coordinator to run 'pnpm install' in the main tree so both trees are on the same @webpieces version. The rule is NOT no-install-here: it is that this tree's @webpieces must EQUAL $BIN_ROOT's, because doing only A leaves two trees on two releases (the trinary-version-skew guard then BLOCKS rather than letting it pass unnoticed). Adding an ordinary third-party dependency here changes none of that - only a differing @webpieces version does. If this tree genuinely needs a DIFFERENT version, use a separate clone rather than a worktree."
122
+ WP_BORROW_NOTE="${NL} NOTE: this tree ($ROOT) has NO node_modules of its own, so the guard binary was inherited from $BIN_ROOT by walking up. TWO cures are real and they fix DIFFERENT things: A makes THIS tree work now, B stops the two trees disagreeing. A - run the command above HERE. That is legitimate and it does work; a worktree NEEDS its own node_modules anyway (nx, vitest and the eslint plugin all execute in this tree and load from it). B - get $BIN_ROOT onto the same @webpieces version: put both trees on the same git hash (the pin is tracked) and run ONE 'pnpm install' there. If you are a SUBAGENT, B is the half you cannot do: it needs both trees on the same git hash, and CROSS-TREE GIT is refused to you here (a local 'cd' + install does run - it is git -C another tree that is blocked). So ESCALATE B: TELL THE MAIN AGENT in the MAIN git worktree ($BIN_ROOT) to run 'git pull && pnpm install' there so both trees are on the same @webpieces version, and to tell you when it is complete so you can continue. If you escalate instead of doing A, that forwarding IS the end of your turn: STOP WORKING NOW, make NO further tool calls and do NOT retry - RETRYING IS THE BUG, because every retry re-fires this identical deny and buries the ask. WAIT for that confirmation, then resume. The rule is NOT no-install-here: it is that this tree's @webpieces must EQUAL $BIN_ROOT's, because doing only A leaves two trees on two releases (the trinary-version-skew guard then BLOCKS rather than letting it pass unnoticed). Adding an ordinary third-party dependency here changes none of that - only a differing @webpieces version does. If this tree genuinely needs a DIFFERENT version, use a separate clone rather than a worktree."
123
123
  fi
124
124
  # Read the tool payload ONCE, up front. The shim no longer exec's the bin (see RUN_BIN_SH), so it must
125
125
  # forward stdin to the bin itself — and it needs the payload again on the fail-closed path below.