@webpieces/ai-hook-rules 0.4.574 → 0.4.576

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.
@@ -1 +1 @@
1
- {"version":3,"file":"l0-matrix.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/l0-matrix.ts"],"names":[],"mappings":";;;AA0NA,oDA8BC;AAmED,kDASC;AAID,gDAGC;AA3UD,0DAAyE;AAEzE,sCAGqB;AACrB,yCAAqC;AAErC,8EAA8E;AAC9E,6CAA6C;AAC7C,EAAE;AACF,uFAAuF;AACvF,qGAAqG;AACrG,kEAAkE;AAClE,EAAE;AACF,gFAAgF;AAChF,EAAE;AACF,gGAAgG;AAChG,qGAAqG;AACrG,+FAA+F;AAC/F,8EAA8E;AAE9E;;;;;;;;;GASG;AACU,QAAA,gBAAgB,GAAG,2BAA2B,CAAC;AAE5D;;;;;;;GAOG;AACH,MAAa,MAAM;IAGF;IACA;IAKA;IAKA;IAbb,yDAAyD;IACzD,YACa,OAAe,EACf,IAAY;IACrB;;;OAGG;IACM,SAAkB;IAC3B;;;OAGG;IACM,aAAqB;QAXrB,YAAO,GAAP,OAAO,CAAQ;QACf,SAAI,GAAJ,IAAI,CAAQ;QAKZ,cAAS,GAAT,SAAS,CAAS;QAKlB,kBAAa,GAAb,aAAa,CAAQ;IAC/B,CAAC;IAEJ,qGAAqG;IACrG,SAAS;QACL,OAAO,IAAI,CAAC,IAAI,CAAC,QAAQ,KAAK,MAAM,CAAC;IACzC,CAAC;CACJ;AArBD,wBAqBC;AAED,wDAAwD;AACxD,MAAa,OAAO;IAEH;IACA;IACA;IACA;IACA;IAMA;IAXb,YACa,IAAY,EACZ,IAAY,EACZ,UAAkB,EAClB,UAAkB,EAClB,KAAwB;IACjC;;;;OAIG;IACM,QAAgB;QAVhB,SAAI,GAAJ,IAAI,CAAQ;QACZ,SAAI,GAAJ,IAAI,CAAQ;QACZ,eAAU,GAAV,UAAU,CAAQ;QAClB,eAAU,GAAV,UAAU,CAAQ;QAClB,UAAK,GAAL,KAAK,CAAmB;QAMxB,aAAQ,GAAR,QAAQ,CAAQ;IAC1B,CAAC;CACP;AAdD,0BAcC;AAED,qGAAqG;AACrG,4FAA4F;AAC5F,EAAE;AACF,oGAAoG;AACpG,sGAAsG;AACtG,uGAAuG;AACvG,sGAAsG;AACtG,uFAAuF;AACvF,EAAE;AACF,sGAAsG;AACtG,kGAAkG;AAClG,wGAAwG;AACxG,yGAAyG;AACzG,6EAA6E;AAChE,QAAA,qBAAqB,GAC9B,GAAG,8BAAe,iGAAiG;IACnH,qGAAqG;IACrG,+EAA+E,8BAAe,IAAI;IAClG,mGAAmG;IACnG,oGAAoG;IACpG,6FAA6F;IAC7F,mGAAmG;IACnG,oGAAoG;IACpG,yFAAyF;IACzF,iGAAiG,CAAC;AAEtG,sGAAsG;AACtG,+EAA+E;AAClE,QAAA,yBAAyB,GAClC,GAAG,8BAAe,0EAA0E,8BAAe,GAAG,CAAC;AAEnH,mGAAmG;AACnG,wGAAwG;AACxG,wGAAwG;AACxG,kGAAkG;AAClG,MAAM,iBAAiB,GAAG,IAAI,MAAM,CAChC,8BAAe,EAAE,IAAI,aAAM,CAAC,MAAM,EAAE,EAAE,EAAE,SAAS,8BAAe,EAAE,CAAC,EAAE,IAAI,EACzE,gGAAgG,CACnG,CAAC;AAEF,qGAAqG;AACrG,0CAA0C;AAC1C,8HAA8H;AAC9H,SAAS,QAAQ,CAAC,OAAe,EAAE,SAAkB,EAAE,aAAqB;IACxE,OAAO,IAAI,MAAM,CAAC,OAAO,EAAE,IAAI,aAAM,CAAC,MAAM,EAAE,OAAO,EAAE,EAAE,CAAC,EAAE,SAAS,EAAE,aAAa,CAAC,CAAC;AAC1F,CAAC;AAED;;;;GAIG;AACU,QAAA,SAAS,GAAuB;IACzC,IAAI,OAAO,CAAC,GAAG,EAAE,4DAA4D,EACzE,yBAAyB,EAAE,IAAI,EAC/B;QACI,4FAA4F;QAC5F,6FAA6F;QAC7F,kDAAkD;QAClD,QAAQ,CAAC,cAAc,EAAE,IAAI,EACzB,mFAAmF;cACjF,4DAA4D,CAAC;QACnE,QAAQ,CAAC,UAAU,EAAE,KAAK,EACtB,yFAAyF;cACvF,mEAAmE,CAAC;KAC7E,EAAE,IAAA,iBAAU,GAAE,CAAC;IACpB,IAAI,OAAO,CAAC,GAAG,EAAE,kEAAkE,EAC/E,yBAAyB,EAAE,IAAI,EAC/B,CAAC,QAAQ,CAAC,cAAc,EAAE,IAAI,EAC1B,sFAAsF;cACpF,wBAAwB,CAAC,CAAC,EAChC,IAAA,iBAAU,GAAE,CAAC;IACjB,IAAI,OAAO,CAAC,GAAG,EAAE,6EAA6E,EAC1F,yBAAyB,EAAE,IAAI,EAC/B,CAAC,QAAQ,CAAC,mBAAY,EAAE,IAAI,EACxB,6FAA6F;cAC3F,wFAAwF,CAAC,CAAC,EAChG,IAAA,iBAAU,GAAE,CAAC;IACjB,IAAI,OAAO,CAAC,GAAG,EAAE,wDAAwD,EACrE,eAAe,EAAE,IAAI,EACrB;QACI,mFAAmF;QACnF,wFAAwF;QACxF,2FAA2F;QAC3F,0FAA0F;QAC1F,2FAA2F;QAC3F,QAAQ,CAAC,uBAAgB,EAAE,IAAI,EAC3B,oFAAoF;cAClF,2EAA2E,CAAC;QAClF,gFAAgF;QAChF,QAAQ,CAAC,uBAAgB,EAAE,KAAK,EAC5B,wFAAwF;cACtF,iFAAiF;cACjF,0DAA0D,CAAC;KACpE,EACD,IAAA,0BAAmB,EAAC,EAAE,CAAC,CAAC;IAC5B,IAAI,OAAO,CAAC,GAAG,EAAE,GAAG,8BAAe,UAAU,EACzC,eAAe,EAAE,IAAI,EACrB;QACI,iBAAiB;QACjB,iFAAiF;QACjF,QAAQ,CAAC,wBAAiB,EAAE,KAAK,EAC7B,+EAA+E,CAAC;KACvF,EAAE,6BAAqB,CAAC;IAC7B,IAAI,OAAO,CAAC,GAAG,EAAE,wBAAwB,8BAAe,MAAM,EAC1D,eAAe,EAAE,IAAI,EACrB,CAAC,iBAAiB,CAAC,EAAE,iCAAyB,CAAC;CACtD,CAAC;AAEF;;;;;;;GAOG;AACH,gIAAgI;AAChI,SAAS,gBAAgB,CAAC,KAAc;IACpC,MAAM,OAAO,GAAG,KAAK,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,IAAY,EAAE,CAAS,EAAU,EAAE;QAChE,mGAAmG;QACnG,MAAM,OAAO,GAAG,IAAI,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC,KAAK,IAAI,CAAC,IAAI,CAAC,OAAO,IAAI,CAAC,CAAC,CAAC,UAAU,IAAI,CAAC,OAAO,aAAa,CAAC;QACpG,MAAM,KAAK,GAAG,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,UAAU,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC,CAAC,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC;QACjF,OAAO,OAAO,KAAK,OAAO,OAAO,sBAAsB,IAAI,CAAC,aAAa,EAAE,CAAC;IAChF,CAAC,CAAC,CAAC;IACH,OAAO,CAAC,SAAS,KAAK,CAAC,IAAI,QAAQ,KAAK,CAAC,IAAI,EAAE,EAAE,EAAE,EAAE,GAAG,OAAO,EAAE,EAAE,CAAC,CAAC;AACzE,CAAC;AAED;;;;;;GAMG;AACH,8HAA8H;AAC9H,SAAgB,oBAAoB;IAChC,OAAO;QACH,mDAAmD;QACnD,EAAE;QACF,+FAA+F;QAC/F,+FAA+F;QAC/F,iEAAiE;QACjE,EAAE;QACF,8FAA8F;QAC9F,+FAA+F;QAC/F,sEAAsE;QACtE,EAAE;QACF,eAAe;QACf,EAAE;QACF,8CAA8C;QAC9C,mBAAmB;QACnB,GAAG,iBAAS,CAAC,GAAG,CAAC,CAAC,CAAU,EAAU,EAAE,CAAC,OAAO,CAAC,CAAC,IAAI,QAAQ,CAAC,CAAC,IAAI,MAAM,CAAC,CAAC,UAAU,MAAM,CAAC,CAAC,UAAU,IAAI,CAAC;QAC7G,EAAE;QACF,+FAA+F;QAC/F,6FAA6F;QAC7F,EAAE;QACF,uBAAuB;QACvB,EAAE;QACF,iGAAiG;QACjG,iGAAiG;QACjG,2EAA2E;QAC3E,EAAE;QACF,GAAG,iBAAS,CAAC,OAAO,CAAC,gBAAgB,CAAC;QACtC,GAAG,wBAAwB,EAAE;KAChC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACjB,CAAC;AAED;;;;GAIG;AACH,wHAAwH;AACxH,SAAS,wBAAwB;IAC7B,OAAO;QACH,eAAe;QACf,EAAE;QACF,2EAA2E;QAC3E,EAAE;QACF,6CAA6C;QAC7C,mBAAmB;QACnB,sDAAsD;QACtD,mDAAmD;QACnD,iEAAiE;QACjE,EAAE;QACF,yFAAyF;QACzF,EAAE;QACF,kBAAkB;QAClB,EAAE;QACF,+FAA+F;QAC/F,8FAA8F;QAC9F,+FAA+F;QAC/F,0FAA0F;QAC1F,EAAE;QACF,2BAA2B;QAC3B,eAAe;QACf,GAAG,mBAAY,CAAC,GAAG,CAAC,CAAC,CAAe,EAAE,CAAS,EAAU,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,KAAK,MAAM,CAAC,CAAC,IAAI,CAAC,WAAW,EAAE,IAAI,CAAC;QAClH,EAAE;QACF,mGAAmG;QACnG,6FAA6F;QAC7F,oCAAoC;QACpC,EAAE;QACF,+FAA+F;QAC/F,kGAAkG;QAClG,6FAA6F;QAC7F,EAAE;QACF,kGAAkG;QAClG,mFAAmF;QACnF,EAAE;QACF,oBAAoB;QACpB,EAAE;QACF,kGAAkG;QAClG,mGAAmG;QACnG,yFAAyF;QACzF,EAAE;QACF,gBAAgB;QAChB,EAAE;QACF,mGAAmG;QACnG,gGAAgG;QAChG,EAAE;KACL,CAAC;AACN,CAAC;AAED;;;;;;;GAOG;AACH,gGAAgG;AAChG,SAAgB,mBAAmB,CAAC,aAAqB;IACrD,8DAA8D;IAC9D,IAAI,CAAC;QACD,OAAO,IAAA,4BAAa,EAAC,aAAa,EAAE,wBAAgB,CAAC,CAAC;IAC1D,CAAC;IAAC,OAAO,GAAY,EAAE,CAAC;QACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;QAC3B,KAAK,KAAK,CAAC,CAAC,0DAA0D;QACtE,OAAO,EAAE,CAAC;IACd,CAAC;AACL,CAAC;AAED,iGAAiG;AACjG,+FAA+F;AAC/F,SAAgB,kBAAkB,CAAC,OAAe;IAC9C,IAAI,OAAO,KAAK,EAAE;QAAE,OAAO,EAAE,CAAC;IAC9B,OAAO,6FAA6F,OAAO,wDAAwD,CAAC;AACxK,CAAC","sourcesContent":["import { CONFIG_FILENAME, writeTemplate } from '@webpieces/rules-config';\n\nimport {\n INSTALL_HOOKS_CMD, L0AllowEntry, L0Call, L0_ALLOWLIST, RECOVERY_CMD,\n RESTORE_SHIM_CMD, UPGRADE_SHIM_CMD, renderShim, shimStaleDenyReason,\n} from '../bin/shim';\nimport { toError } from './to-error';\n\n// ---------------------------------------------------------------------------\n// L0 — the TOOLING-INTEGRITY layer, as data.\n//\n// L0 is the outermost guard: it blocks work while node_modules, the committed shim, or\n// webpieces.config.json are in a state that makes every OTHER guard untrustworthy. It has SIX faults\n// and — drawn as a decision matrix — NO genuine second dimension:\n//\n// fault present AND call not on the allowlist -> BLOCK(messageFor(fault))\n//\n// so the only thing that varies per fault is the MESSAGE. This module holds the fault table and\n// renders it, together with L0_ALLOWLIST (../bin/shim), into webpieces.guard-matrix.md — the doc the\n// deny messages point the AI at. Doc and code come from the SAME arrays, so they cannot drift.\n// ---------------------------------------------------------------------------\n\n/**\n * The doc L0's deny messages point at. Lives in @webpieces/rules-config/templates alongside the others,\n * and is written to <root>/.webpieces/instruct-ai/ lazily, only on an L0 BLOCK.\n *\n * That generated doc is the AUTHORITY for the fault table and the allowlist (same arrays, cannot\n * drift). guards/L0-tooling.md is the hand-written companion: it adds L0's evaluation\n * ORDER, the use cases and the known gaps, and it documents L1, none of which are rendered from code.\n * Change L0_FAULTS or L0_ALLOWLIST and the generated doc follows automatically — guards/L0-tooling.md does\n * not, so update it in the same PR.\n */\nexport const GUARD_MATRIX_DOC = 'webpieces.guard-matrix.md';\n\n/**\n * One CURE for a fault: the exact call, plus the `mention` that must appear in that fault's deny text.\n *\n * Both halves are asserted (l0-matrix.spec.ts): the call must be accepted by isAllowed(), and the deny\n * message must actually name it. That pairing is the anti-deadlock invariant — a message that\n * prescribes a command the allowlist rejects is exactly the shape of the three deadlocks CLAUDE.md\n * records, and it is how the dead `wp-setup-ai-hooks` bin in the config-missing text was caught.\n */\nexport class L0Cure {\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(\n readonly mention: string,\n readonly call: L0Call,\n /**\n * The one to reach for first when a fault has several. Exactly one cure per fault carries it,\n * so the rendered Fix section never asks the reader to choose between equals.\n */\n readonly preferred: boolean,\n /**\n * WHEN to pick this cure over its siblings — the sentence that makes a list of commands\n * actionable instead of a menu (\"when the PIN is the stale side\", not \"an alternative\").\n */\n readonly discriminator: string,\n ) {}\n\n /** A Bash cure renders as a literal command; a tool-shaped one renders as the edit it stands for. */\n isCommand(): boolean {\n return this.call.toolName === 'Bash';\n }\n}\n\n/** One L0 fault. Data-only → a class, per CLAUDE.md. */\nexport class L0Fault {\n constructor(\n readonly code: string,\n readonly name: string,\n readonly detectedBy: string,\n readonly enforcedIn: string,\n readonly cures: readonly L0Cure[],\n /**\n * The artifact carrying this fault's deny text. For S/C/Y that is the deny string itself; for\n * D/X/K the text is built in POSIX sh inside the rendered shim, so it is the rendered shim —\n * the same bytes the consumer runs, which is what the mention assertion needs to search.\n */\n readonly denyText: string,\n ) {}\n}\n\n// The deny for fault C, and the ONLY message L0 has for a repo with no webpieces.config.json at all.\n// Moved here from runner.ts so the fault table and the runner cannot state different cures.\n//\n// It used to name `./node_modules/.bin/wp-setup-ai-hooks` — a bin that HAS NOT EXISTED since it was\n// renamed to wp-install-ai-hooks. So the one command this deny prescribed was (a) not installable and\n// (b) not on the L0 allowlist in that spelling, i.e. the AI was handed a cure it could neither run nor\n// get past the guard. Now it names the installer that actually seeds the config AND is entry 8 of the\n// allowlist, and it says out loud that writing the config yourself is allowed through.\n//\n// ORDERING (2026-08-02): writing the file yourself now LEADS. The bare installer used to be OPTION 1,\n// but it seeds the config and then PROMPTS twice for a hook target, which hangs a non-interactive\n// agent. Writing the file is the one cure that always works, and it is the same cure every other config\n// problem has (see the config-validation invariant in guards/L0-tooling.md): the validator reports every\n// error at once, so the write/validate loop converges in a couple of passes.\nexport const CONFIG_MISSING_REPORT =\n `${CONFIG_FILENAME} not found — the webpieces guards cannot run without it, so every other tool call is blocked.\\n` +\n 'THIS IS NOT A DEADLOCK: both options below are explicitly allowed through while this guard is up.\\n' +\n `OPTION 1 (preferred — it needs no other tool and it never prompts) - create ${CONFIG_FILENAME}\\n` +\n 'yourself: any Read, and any Write/Edit whose target is that file, is always allowed through, so\\n' +\n 'you can inspect the repo and write it. The validator reports EVERY missing/invalid entry at once\\n' +\n '(each with the snippet to paste), so a minimal first draft converges in about two passes.\\n' +\n 'OPTION 2 (pick this ONLY at an interactive terminal where you can answer its two prompts) - run\\n' +\n 'EXACTLY this command to seed the config: `pnpm exec wp-install-ai-hooks`. It goes on to wire the\\n' +\n 'Claude Code hooks and asks for a target twice, which hangs a non-interactive session.\\n' +\n 'Do not append anything to the option you pick — the allowlist is anchored to the whole command.';\n\n// The first line of the fault-Y deny (built out in runner.checkConfigSync, which appends the per-rule\n// detail). Kept here so the fault table quotes the same text the runner emits.\nexport const CONFIG_OUT_OF_SYNC_HEADER =\n `${CONFIG_FILENAME} is out of sync — new built-in rules are present that have no entry in ${CONFIG_FILENAME}.`;\n\n// Writing/repairing the file yourself. PREFERRED for both config faults, per the config-validation\n// invariant in guards/L0-tooling.md: every config problem cures to \"make the file right\", the validator\n// reports all errors at once so the loop converges in a couple of passes, and allowlist entry 2 permits\n// this edit unconditionally. (That section is the authority — do not restate its reasoning here.)\nconst CONFIG_WRITE_CURE = new L0Cure(\n CONFIG_FILENAME, new L0Call('Edit', '', `/repo/${CONFIG_FILENAME}`), true,\n 'this fault fires at all — it is the only cure that needs no other tool, and it is never denied',\n);\n\n// Cure calls are spelled exactly as the deny messages spell them, so the mention assertion is a real\n// string search rather than a paraphrase.\n// webpieces-disable no-function-outside-class -- pure constructor helper for the L0_FAULTS literal below, in this data module\nfunction bashCure(command: string, preferred: boolean, discriminator: string): L0Cure {\n return new L0Cure(command, new L0Call('Bash', command, ''), preferred, discriminator);\n}\n\n/**\n * THE six L0 faults, in first-match-wins order. D/X/K are decided in POSIX sh BEFORE the bin runs (a\n * stale, missing or broken validator cannot be trusted to validate itself); S/C/Y are decided inside\n * the bin, in JS. One model, two enforcement points.\n */\nexport const L0_FAULTS: readonly L0Fault[] = [\n new L0Fault('D', 'version drift — root package.json pin != installed version',\n 'sh, before the bin runs', 'sh',\n [\n // `pnpm install` clears D in BOTH directions — it makes installed == pin by definition — so\n // it is always the preferred cure. The direction only decides whether the PIN is the version\n // you WANT, which is what the second cure is for.\n bashCure('pnpm install', true,\n 'node_modules is OLDER than the pin, OR you are on a feature branch and want YOUR '\n + 'branch pin (usually the case) — it always clears the drift'),\n bashCure('git pull', false,\n 'node_modules is NEWER than the pin AND you are on main — the PIN is the stale side, so '\n + 'pull first and install second; a bare install would downgrade you'),\n ], renderShim()),\n new L0Fault('X', 'guard bin missing (fresh clone / new worktree / package removed)',\n 'sh, before the bin runs', 'sh',\n [bashCure('pnpm install', true,\n 'this fault fires at all — nothing is installed in THIS tree, and a new git worktree '\n + 'copies no node_modules')],\n renderShim()),\n new L0Fault('K', 'guard bin present but CRASHED (exit code not 0 or 2 — corrupt node_modules)',\n 'sh, before the bin runs', 'sh',\n [bashCure(RECOVERY_CMD, true,\n 'this fault fires at all — a BARE pnpm install SKIPS the corrupt package, because pnpm sees '\n + 'the right version on disk and considers it installed; only the delete forces a rewrite')],\n renderShim()),\n new L0Fault('S', 'committed .claude/webpieces/ai-hook.sh != renderShim()',\n 'the guard bin', 'JS',\n [\n // wp-upgrade-shim is the SURGICAL tool and therefore leads: upgrade-shim.ts writes\n // renderShim() to .claude/webpieces/ai-hook.sh and touches nothing else — no config, no\n // settings.json — and it imports only fs/path, so it runs on a tree too broken to load the\n // rule engine. The INSTALLER is deliberately NOT a cure here: it also migrates the config\n // and wires BOTH hooks, prompting for a target twice, which hangs a non-interactive agent.\n bashCure(UPGRADE_SHIM_CMD, true,\n 'this fault fires at all — it regenerates the shim and NOTHING else (no config, no '\n + 'settings.json); needs installed @webpieces/ai-hook-rules 0.4.408 or newer'),\n // 2026-07-21: the version gap below caused a real \"command not found\" deadlock.\n bashCure(RESTORE_SHIM_CMD, false,\n 'the installed @webpieces/ai-hook-rules is OLDER than 0.4.408, so wp-upgrade-shim does '\n + 'not exist yet — this works on every release, though Claude Code may ask you to '\n + 'confirm the overwrite, and that prompt is NOT this guard'),\n ],\n shimStaleDenyReason('')),\n new L0Fault('C', `${CONFIG_FILENAME} missing`,\n 'the guard bin', 'JS',\n [\n CONFIG_WRITE_CURE,\n // Kept, but demoted: it seeds the file and then PROMPTS twice for a hook target.\n bashCure(INSTALL_HOOKS_CMD, false,\n 'you are at an INTERACTIVE terminal and can answer its two hook-target prompts'),\n ], CONFIG_MISSING_REPORT),\n new L0Fault('Y', `a loaded rule has no ${CONFIG_FILENAME} key`,\n 'the guard bin', 'JS',\n [CONFIG_WRITE_CURE], CONFIG_OUT_OF_SYNC_HEADER),\n];\n\n/**\n * One fault's FIX section, rendered from its `cures` array — literal commands only, never prose.\n *\n * This is the half that used to live in hand-written docs and drift. The three fields of L0Cure map\n * onto the three things a blocked reader needs and nothing else: WHAT to type (the call), WHETHER it is\n * the default (preferred), and WHEN to pick a sibling instead (discriminator). A cure with no\n * discriminator would render as a menu of equals, which is how an agent picks the wrong one.\n */\n// webpieces-disable no-function-outside-class -- pure string builder for renderGuardMatrixDoc below, beside the arrays it reads\nfunction renderFixSection(fault: L0Fault): string[] {\n const options = fault.cures.map((cure: L0Cure, i: number): string => {\n // A Bash cure is the command verbatim; a tool-shaped one is the file it edits (allowlist entry 2).\n const literal = cure.isCommand() ? `\\`${cure.call.command}\\`` : `edit \\`${cure.mention}\\` yourself`;\n const label = cure.preferred ? `Option ${i + 1} (preferred)` : `Option ${i + 1}`;\n return `- **${label}**: ${literal} ← pick this when ${cure.discriminator}`;\n });\n return [`### \\`${fault.code}\\` — ${fault.name}`, '', ...options, ''];\n}\n\n/**\n * Render webpieces.guard-matrix.md from L0_FAULTS + L0_ALLOWLIST.\n *\n * A unit test locks the committed template byte-identical to this output, the same way\n * templates/ai-hook.sh is locked to renderShim(). That is what makes the doc assertable instead of\n * aspirational: the table in the doc IS the array the guard consults.\n */\n// webpieces-disable no-function-outside-class -- pure string builder over the two exported arrays, beside them in this module\nexport function renderGuardMatrixDoc(): string {\n return [\n '# webpieces guard matrix — L0 (tooling integrity)',\n '',\n 'GENERATED from `L0_FAULTS` + `L0_ALLOWLIST` in `@webpieces/ai-hook-rules`. Do not hand-edit —',\n 'a unit test locks this file byte-identical to `renderGuardMatrixDoc()`, so the table below is',\n 'the array the guard actually consults, not a description of it.',\n '',\n 'L0 is the OUTERMOST guard layer. It blocks work while `node_modules`, the committed shim, or',\n '`webpieces.config.json` are in a state that makes every other guard untrustworthy. If you are',\n 'reading this, one of the six faults below fired and named this file.',\n '',\n '## The faults',\n '',\n '| code | fault | detected by | enforced in |',\n '|---|---|---|---|',\n ...L0_FAULTS.map((f: L0Fault): string => `| \\`${f.code}\\` | ${f.name} | ${f.detectedBy} | ${f.enforcedIn} |`),\n '',\n 'First match wins. `D`/`X`/`K` are decided in POSIX `sh` inside the committed shim, BEFORE the',\n 'guard bin runs — a stale, missing or broken validator cannot be trusted to validate itself.',\n '',\n '## The fix, per fault',\n '',\n 'Every command below is rendered from that fault\\'s `cures` array and is asserted, by unit test,',\n 'to be accepted by `isAllowed()` — so nothing here can be a command the guard then rejects. Type',\n 'the option you pick EXACTLY as written and run nothing else on that line.',\n '',\n ...L0_FAULTS.flatMap(renderFixSection),\n ...renderMatrixAndAllowlist(),\n ].join('\\n');\n}\n\n/**\n * The second half of the doc: the three-row matrix and the ONE allowlist. Split out of\n * renderGuardMatrixDoc solely to keep it inside the method-line budget — the join order is what makes\n * the two halves one file, so keep them adjacent and keep the byte-lock test as the arbiter.\n */\n// webpieces-disable no-function-outside-class -- second half of renderGuardMatrixDoc's string, beside it in this module\nfunction renderMatrixAndAllowlist(): string[] {\n return [\n '## The matrix',\n '',\n 'L0 has NO genuine second dimension. Every branch reduces to one question:',\n '',\n '| # | fault | on the allowlist? | outcome |',\n '|---|---|---|---|',\n '| 1 | none | — | hand down to the next guard layer |',\n '| 2 | any | yes | PASS or ALLOW (see the entry) |',\n '| 3 | any | no | BLOCK — **only the message varies by fault** |',\n '',\n 'The tool is not a dimension either: \"any Read\" is an allowlist ENTRY, not a tool check.',\n '',\n '## The allowlist',\n '',\n 'ONE list, consulted identically by all six faults. A cure that cannot help a given fault also',\n 'cannot hurt it, and gating each entry on a fault is what produced four real defects (a stale',\n 'shim that denied `pnpm install` and `git pull`; faults that denied every Read; a config fault',\n 'that denied `rm -rf node_modules && pnpm install` while allowing a bare `pnpm install`).',\n '',\n '| # | allowed | outcome |',\n '|---|---|---|',\n ...L0_ALLOWLIST.map((e: L0AllowEntry, i: number): string => `| ${i + 1} | ${e.label} | ${e.kind.toUpperCase()} |`),\n '',\n '- **PASS** — L0 has no objection; the call falls THROUGH so the downstream guards still judge it.',\n '- **ALLOW** — terminal; bypasses everything, because a cure must stay reachable even when a',\n ' downstream guard would block it.',\n '',\n 'Every Bash entry is anchored to the WHOLE command. A leading `cd <dir> &&`, a trailing `2>&1`',\n 'and a pipe into `tail`/`head` are tolerated; nothing else is. Appending `&& git status` makes it',\n 'a DIFFERENT command and it is rejected again — that is not the guard refusing its own cure.',\n '',\n '`git merge` is deliberately NOT on this list. Main is merged ONLY through the 3-point fork merge',\n '(`pnpm wp-start-update`, or `pnpm wp-start-upsert-pr` when a PR is already open).',\n '',\n '## Known asymmetry',\n '',\n 'Under `S`/`C`/`Y` the guard bin IS running, so a PASS really does fall through to the downstream',\n 'guards. Under `D`/`X`/`K` the bin is never executed, so there is nothing to fall through to and a',\n 'PASS degenerates into a terminal allow — reads are unguarded during those three faults.',\n '',\n '## Widening L0',\n '',\n 'Add an entry to `L0_ALLOWLIST` in `packages/tooling/ai-hook-rules/src/bin/shim.ts`. That array is',\n 'the single source for the JS allowlist, the `grep -E` inside the rendered shim, and this file.',\n '',\n ];\n}\n\n/**\n * Drop the matrix doc where the AI can read it, and return its absolute path ('' if it could not be\n * written). Called from the L0 BLOCK path so the deny can say `READ <path>`.\n *\n * Best-effort by design: this runs while the tree is already known-broken, and a missing template (an\n * @webpieces/rules-config older than this package) must degrade the deny message, never replace it\n * with a crash.\n */\n// webpieces-disable no-function-outside-class -- sibling of renderGuardMatrixDoc in this module\nexport function writeGuardMatrixDoc(workspaceRoot: string): string {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return writeTemplate(workspaceRoot, GUARD_MATRIX_DOC);\n } catch (err: unknown) {\n const error = toError(err);\n void error; // best-effort: no doc → the deny simply omits the pointer\n return '';\n }\n}\n\n/** The `READ <path>` pointer appended to an L0 deny, or '' when the doc could not be written. */\n// webpieces-disable no-function-outside-class -- sibling of writeGuardMatrixDoc in this module\nexport function guardMatrixPointer(docPath: string): string {\n if (docPath === '') return '';\n return ` The full L0 guard matrix - all six faults and everything that is allowed through - is at ${docPath}; READ it if you are unsure why this call was blocked.`;\n}\n"]}
1
+ {"version":3,"file":"l0-matrix.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/l0-matrix.ts"],"names":[],"mappings":"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","sourcesContent":["import { CONFIG_FILENAME, writeTemplate } from '@webpieces/rules-config';\n\nimport {\n ADD_HOOK_PKG_CMD, HOOK_PKG, INSTALL_HOOKS_CMD, L0AllowEntry, L0Call, L0_ALLOWLIST, RECOVERY_CMD,\n RESTORE_SHIM_CMD, UPGRADE_SHIM_CMD, renderShim, shimStaleDenyReason,\n} from '../bin/shim';\nimport { toError } from './to-error';\n\n// ---------------------------------------------------------------------------\n// L0 — the TOOLING-INTEGRITY layer, as data.\n//\n// L0 is the outermost guard: it blocks work while node_modules, the committed shim, or\n// webpieces.config.json are in a state that makes every OTHER guard untrustworthy. Its faults are\n// enumerated in L0_FAULTS below and — drawn as a decision matrix — have NO genuine second dimension:\n//\n// fault present AND call not on the allowlist -> BLOCK(messageFor(fault))\n//\n// so the only thing that varies per fault is the MESSAGE. This module holds the fault table and\n// renders it, together with L0_ALLOWLIST (../bin/shim), into webpieces.guard-matrix.md — the doc the\n// deny messages point the AI at. Doc and code come from the SAME arrays, so they cannot drift.\n// ---------------------------------------------------------------------------\n\n/**\n * The doc L0's deny messages point at. Lives in @webpieces/rules-config/templates alongside the others,\n * and is written to <root>/.webpieces/instruct-ai/ lazily, only on an L0 BLOCK.\n *\n * That generated doc is the AUTHORITY for the fault table and the allowlist (same arrays, cannot\n * drift). guards/L0-tooling.md is the hand-written companion: it adds L0's evaluation\n * ORDER, the use cases and the known gaps, and it documents L1, none of which are rendered from code.\n * Change L0_FAULTS or L0_ALLOWLIST and the generated doc follows automatically — guards/L0-tooling.md does\n * not, so update it in the same PR.\n */\nexport const GUARD_MATRIX_DOC = 'webpieces.guard-matrix.md';\n\n/**\n * One CURE for a fault: the exact call, plus the `mention` that must appear in that fault's deny text.\n *\n * Both halves are asserted (l0-matrix.spec.ts): the call must be accepted by isAllowed(), and the deny\n * message must actually name it. That pairing is the anti-deadlock invariant — a message that\n * prescribes a command the allowlist rejects is exactly the shape of the three deadlocks CLAUDE.md\n * records, and it is how the dead `wp-setup-ai-hooks` bin in the config-missing text was caught.\n */\nexport class L0Cure {\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(\n readonly mention: string,\n readonly call: L0Call,\n /**\n * The one to reach for first when a fault has several. Exactly one cure per fault carries it,\n * so the rendered Fix section never asks the reader to choose between equals.\n */\n readonly preferred: boolean,\n /**\n * WHEN to pick this cure over its siblings — the sentence that makes a list of commands\n * actionable instead of a menu (\"when the PIN is the stale side\", not \"an alternative\").\n */\n readonly discriminator: string,\n ) {}\n\n /** A Bash cure renders as a literal command; a tool-shaped one renders as the edit it stands for. */\n isCommand(): boolean {\n return this.call.toolName === 'Bash';\n }\n}\n\n/** One L0 fault. Data-only → a class, per CLAUDE.md. */\nexport class L0Fault {\n constructor(\n readonly code: string,\n readonly name: string,\n readonly detectedBy: string,\n readonly enforcedIn: string,\n readonly cures: readonly L0Cure[],\n /**\n * The artifact carrying this fault's deny text. For S/C/Y that is the deny string itself; for\n * D/X/U/K the text is built in POSIX sh inside the rendered shim, so it is the rendered shim —\n * the same bytes the consumer runs, which is what the mention assertion needs to search.\n */\n readonly denyText: string,\n ) {}\n}\n\n// The deny for fault C, and the ONLY message L0 has for a repo with no webpieces.config.json at all.\n// Moved here from runner.ts so the fault table and the runner cannot state different cures.\n//\n// It used to name `./node_modules/.bin/wp-setup-ai-hooks` — a bin that HAS NOT EXISTED since it was\n// renamed to wp-install-ai-hooks. So the one command this deny prescribed was (a) not installable and\n// (b) not on the L0 allowlist in that spelling, i.e. the AI was handed a cure it could neither run nor\n// get past the guard. Now it names the installer that actually seeds the config AND is entry 8 of the\n// allowlist, and it says out loud that writing the config yourself is allowed through.\n//\n// ORDERING (2026-08-02): writing the file yourself now LEADS. The bare installer used to be OPTION 1,\n// but it seeds the config and then PROMPTS twice for a hook target, which hangs a non-interactive\n// agent. Writing the file is the one cure that always works, and it is the same cure every other config\n// problem has (see the config-validation invariant in guards/L0-tooling.md): the validator reports every\n// error at once, so the write/validate loop converges in a couple of passes.\nexport const CONFIG_MISSING_REPORT =\n `${CONFIG_FILENAME} not found — the webpieces guards cannot run without it, so every other tool call is blocked.\\n` +\n 'THIS IS NOT A DEADLOCK: both options below are explicitly allowed through while this guard is up.\\n' +\n `OPTION 1 (preferred — it needs no other tool and it never prompts) - create ${CONFIG_FILENAME}\\n` +\n 'yourself: any Read, and any Write/Edit whose target is that file, is always allowed through, so\\n' +\n 'you can inspect the repo and write it. The validator reports EVERY missing/invalid entry at once\\n' +\n '(each with the snippet to paste), so a minimal first draft converges in about two passes.\\n' +\n 'OPTION 2 (pick this ONLY at an interactive terminal where you can answer its two prompts) - run\\n' +\n 'EXACTLY this command to seed the config: `pnpm exec wp-install-ai-hooks`. It goes on to wire the\\n' +\n 'Claude Code hooks and asks for a target twice, which hangs a non-interactive session.\\n' +\n 'Do not append anything to the option you pick — the allowlist is anchored to the whole command.';\n\n// The first line of the fault-Y deny (built out in runner.checkConfigSync, which appends the per-rule\n// detail). Kept here so the fault table quotes the same text the runner emits.\nexport const CONFIG_OUT_OF_SYNC_HEADER =\n `${CONFIG_FILENAME} is out of sync — new built-in rules are present that have no entry in ${CONFIG_FILENAME}.`;\n\n// Writing/repairing the file yourself. PREFERRED for both config faults, per the config-validation\n// invariant in guards/L0-tooling.md: every config problem cures to \"make the file right\", the validator\n// reports all errors at once so the loop converges in a couple of passes, and allowlist entry 2 permits\n// this edit unconditionally. (That section is the authority — do not restate its reasoning here.)\nconst CONFIG_WRITE_CURE = new L0Cure(\n CONFIG_FILENAME, new L0Call('Edit', '', `/repo/${CONFIG_FILENAME}`), true,\n 'this fault fires at all — it is the only cure that needs no other tool, and it is never denied',\n);\n\n// Cure calls are spelled exactly as the deny messages spell them, so the mention assertion is a real\n// string search rather than a paraphrase.\n// webpieces-disable no-function-outside-class -- pure constructor helper for the L0_FAULTS literal below, in this data module\nfunction bashCure(command: string, preferred: boolean, discriminator: string): L0Cure {\n return new L0Cure(command, new L0Call('Bash', command, ''), preferred, discriminator);\n}\n\n/**\n * THE L0 faults, in first-match-wins order. D/X/U/K are decided in POSIX sh BEFORE the bin runs (a\n * stale, missing or broken validator cannot be trusted to validate itself); S/C/Y are decided inside\n * the bin, in JS. One model, two enforcement points.\n */\nexport const L0_FAULTS: readonly L0Fault[] = [\n new L0Fault('D', 'version drift — root package.json pin != installed version',\n 'sh, before the bin runs', 'sh',\n [\n // `pnpm install` clears D in BOTH directions — it makes installed == pin by definition — so\n // it is always the preferred cure. The direction only decides whether the PIN is the version\n // you WANT, which is what the second cure is for.\n bashCure('pnpm install', true,\n 'node_modules is OLDER than the pin, OR you are on a feature branch and want YOUR '\n + 'branch pin (usually the case) — it always clears the drift'),\n bashCure('git pull', false,\n 'node_modules is NEWER than the pin AND you are on main — the PIN is the stale side, so '\n + 'pull first and install second; a bare install would downgrade you'),\n ], renderShim()),\n new L0Fault('X', 'guard bin missing (fresh clone / new worktree / package removed)',\n 'sh, before the bin runs', 'sh',\n [bashCure('pnpm install', true,\n 'this fault fires at all — nothing is installed in THIS tree, and a new git worktree '\n + 'copies no node_modules')],\n renderShim()),\n // U is X with the ONE input that inverts X's cure, which is why it is a separate fault and not a\n // sentence inside X's message: when nothing declares the package, `pnpm install` is not a weaker fix,\n // it is a PROVABLE no-op, and an agent that trusts the X text will run it until it gives up. See the\n // ADD_HOOK_PKG entry in l0-allowlist.ts for the incident.\n new L0Fault('U', `guard bin missing AND ${HOOK_PKG} is not declared in package.json`,\n 'sh, before the bin runs', 'sh',\n [bashCure(ADD_HOOK_PKG_CMD, true,\n 'this fault fires at all — package.json asks for nothing, so pnpm install reports '\n + '\"Lockfile is up to date\" and leaves the tree exactly as broken as it found it')],\n renderShim()),\n new L0Fault('K', 'guard bin present but CRASHED (exit code not 0 or 2 — corrupt node_modules)',\n 'sh, before the bin runs', 'sh',\n [bashCure(RECOVERY_CMD, true,\n 'this fault fires at all — a BARE pnpm install SKIPS the corrupt package, because pnpm sees '\n + 'the right version on disk and considers it installed; only the delete forces a rewrite')],\n renderShim()),\n new L0Fault('S', 'committed .claude/webpieces/ai-hook.sh != renderShim()',\n 'the guard bin', 'JS',\n [\n // wp-upgrade-shim is the SURGICAL tool and therefore leads: upgrade-shim.ts writes\n // renderShim() to .claude/webpieces/ai-hook.sh and touches nothing else — no config, no\n // settings.json — and it imports only fs/path, so it runs on a tree too broken to load the\n // rule engine. The INSTALLER is deliberately NOT a cure here: it also migrates the config\n // and wires BOTH hooks, prompting for a target twice, which hangs a non-interactive agent.\n bashCure(UPGRADE_SHIM_CMD, true,\n 'this fault fires at all — it regenerates the shim and NOTHING else (no config, no '\n + 'settings.json); needs installed @webpieces/ai-hook-rules 0.4.408 or newer'),\n // 2026-07-21: the version gap below caused a real \"command not found\" deadlock.\n bashCure(RESTORE_SHIM_CMD, false,\n 'the installed @webpieces/ai-hook-rules is OLDER than 0.4.408, so wp-upgrade-shim does '\n + 'not exist yet — this works on every release, though Claude Code may ask you to '\n + 'confirm the overwrite, and that prompt is NOT this guard'),\n ],\n shimStaleDenyReason('')),\n new L0Fault('C', `${CONFIG_FILENAME} missing`,\n 'the guard bin', 'JS',\n [\n CONFIG_WRITE_CURE,\n // Kept, but demoted: it seeds the file and then PROMPTS twice for a hook target.\n bashCure(INSTALL_HOOKS_CMD, false,\n 'you are at an INTERACTIVE terminal and can answer its two hook-target prompts'),\n ], CONFIG_MISSING_REPORT),\n new L0Fault('Y', `a loaded rule has no ${CONFIG_FILENAME} key`,\n 'the guard bin', 'JS',\n [CONFIG_WRITE_CURE], CONFIG_OUT_OF_SYNC_HEADER),\n];\n\n/**\n * One fault's FIX section, rendered from its `cures` array — literal commands only, never prose.\n *\n * This is the half that used to live in hand-written docs and drift. The three fields of L0Cure map\n * onto the three things a blocked reader needs and nothing else: WHAT to type (the call), WHETHER it is\n * the default (preferred), and WHEN to pick a sibling instead (discriminator). A cure with no\n * discriminator would render as a menu of equals, which is how an agent picks the wrong one.\n */\n// webpieces-disable no-function-outside-class -- pure string builder for renderGuardMatrixDoc below, beside the arrays it reads\nfunction renderFixSection(fault: L0Fault): string[] {\n const options = fault.cures.map((cure: L0Cure, i: number): string => {\n // A Bash cure is the command verbatim; a tool-shaped one is the file it edits (allowlist entry 2).\n const literal = cure.isCommand() ? `\\`${cure.call.command}\\`` : `edit \\`${cure.mention}\\` yourself`;\n const label = cure.preferred ? `Option ${i + 1} (preferred)` : `Option ${i + 1}`;\n return `- **${label}**: ${literal} ← pick this when ${cure.discriminator}`;\n });\n return [`### \\`${fault.code}\\` — ${fault.name}`, '', ...options, ''];\n}\n\n/**\n * Render webpieces.guard-matrix.md from L0_FAULTS + L0_ALLOWLIST.\n *\n * A unit test locks the committed template byte-identical to this output, the same way\n * templates/ai-hook.sh is locked to renderShim(). That is what makes the doc assertable instead of\n * aspirational: the table in the doc IS the array the guard consults.\n */\n// webpieces-disable no-function-outside-class -- pure string builder over the two exported arrays, beside them in this module\nexport function renderGuardMatrixDoc(): string {\n return [\n '# webpieces guard matrix — L0 (tooling integrity)',\n '',\n 'GENERATED from `L0_FAULTS` + `L0_ALLOWLIST` in `@webpieces/ai-hook-rules`. Do not hand-edit —',\n 'a unit test locks this file byte-identical to `renderGuardMatrixDoc()`, so the table below is',\n 'the array the guard actually consults, not a description of it.',\n '',\n 'L0 is the OUTERMOST guard layer. It blocks work while `node_modules`, the committed shim, or',\n '`webpieces.config.json` are in a state that makes every other guard untrustworthy. If you are',\n 'reading this, one of the faults below fired and named this file.',\n '',\n '## The faults',\n '',\n '| code | fault | detected by | enforced in |',\n '|---|---|---|---|',\n ...L0_FAULTS.map((f: L0Fault): string => `| \\`${f.code}\\` | ${f.name} | ${f.detectedBy} | ${f.enforcedIn} |`),\n '',\n 'First match wins. `D`/`X`/`U`/`K` are decided in POSIX `sh` inside the committed shim, BEFORE the',\n 'guard bin runs — a stale, missing or broken validator cannot be trusted to validate itself.',\n '',\n '## The fix, per fault',\n '',\n 'Every command below is rendered from that fault\\'s `cures` array and is asserted, by unit test,',\n 'to be accepted by `isAllowed()` — so nothing here can be a command the guard then rejects. Type',\n 'the option you pick EXACTLY as written and run nothing else on that line.',\n '',\n ...L0_FAULTS.flatMap(renderFixSection),\n ...renderMatrixAndAllowlist(),\n ].join('\\n');\n}\n\n/**\n * The second half of the doc: the three-row matrix and the ONE allowlist. Split out of\n * renderGuardMatrixDoc solely to keep it inside the method-line budget — the join order is what makes\n * the two halves one file, so keep them adjacent and keep the byte-lock test as the arbiter.\n */\n// webpieces-disable no-function-outside-class -- second half of renderGuardMatrixDoc's string, beside it in this module\nfunction renderMatrixAndAllowlist(): string[] {\n return [\n '## The matrix',\n '',\n 'L0 has NO genuine second dimension. Every branch reduces to one question:',\n '',\n '| # | fault | on the allowlist? | outcome |',\n '|---|---|---|---|',\n '| 1 | none | — | hand down to the next guard layer |',\n '| 2 | any | yes | PASS or ALLOW (see the entry) |',\n '| 3 | any | no | BLOCK — **only the message varies by fault** |',\n '',\n 'The tool is not a dimension either: \"any Read\" is an allowlist ENTRY, not a tool check.',\n '',\n '## The allowlist',\n '',\n 'ONE list, consulted identically by every fault. A cure that cannot help a given fault also',\n 'cannot hurt it, and gating each entry on a fault is what produced four real defects (a stale',\n 'shim that denied `pnpm install` and `git pull`; faults that denied every Read; a config fault',\n 'that denied `rm -rf node_modules && pnpm install` while allowing a bare `pnpm install`).',\n '',\n '| # | allowed | outcome |',\n '|---|---|---|',\n ...L0_ALLOWLIST.map((e: L0AllowEntry, i: number): string => `| ${i + 1} | ${e.label} | ${e.kind.toUpperCase()} |`),\n '',\n '- **PASS** — L0 has no objection; the call falls THROUGH so the downstream guards still judge it.',\n '- **ALLOW** — terminal; bypasses everything, because a cure must stay reachable even when a',\n ' downstream guard would block it.',\n '',\n 'Every Bash entry is anchored to the WHOLE command. A leading `cd <dir> &&`, a trailing `2>&1`',\n 'and a pipe into `tail`/`head` are tolerated; nothing else is. Appending `&& git status` makes it',\n 'a DIFFERENT command and it is rejected again — that is not the guard refusing its own cure.',\n '',\n '`git merge` is deliberately NOT on this list. Main is merged ONLY through the 3-point fork merge',\n '(`pnpm wp-start-update`, or `pnpm wp-start-upsert-pr` when a PR is already open).',\n '',\n '## Known asymmetry',\n '',\n 'Under `S`/`C`/`Y` the guard bin IS running, so a PASS really does fall through to the downstream',\n 'guards. Under `D`/`X`/`U`/`K` the bin is never executed, so there is nothing to fall through to and a',\n 'PASS degenerates into a terminal allow — reads are unguarded during those three faults.',\n '',\n '## Widening L0',\n '',\n 'Add an entry to `L0_ALLOWLIST` in `packages/tooling/ai-hook-rules/src/bin/shim.ts`. That array is',\n 'the single source for the JS allowlist, the `grep -E` inside the rendered shim, and this file.',\n '',\n ];\n}\n\n/**\n * Drop the matrix doc where the AI can read it, and return its absolute path ('' if it could not be\n * written). Called from the L0 BLOCK path so the deny can say `READ <path>`.\n *\n * Best-effort by design: this runs while the tree is already known-broken, and a missing template (an\n * @webpieces/rules-config older than this package) must degrade the deny message, never replace it\n * with a crash.\n */\n// webpieces-disable no-function-outside-class -- sibling of renderGuardMatrixDoc in this module\nexport function writeGuardMatrixDoc(workspaceRoot: string): string {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return writeTemplate(workspaceRoot, GUARD_MATRIX_DOC);\n } catch (err: unknown) {\n const error = toError(err);\n void error; // best-effort: no doc → the deny simply omits the pointer\n return '';\n }\n}\n\n/** The `READ <path>` pointer appended to an L0 deny, or '' when the doc could not be written. */\n// webpieces-disable no-function-outside-class -- sibling of writeGuardMatrixDoc in this module\nexport function guardMatrixPointer(docPath: string): string {\n if (docPath === '') return '';\n return ` The full L0 guard matrix - every fault and everything that is allowed through - is at ${docPath}; READ it if you are unsure why this call was blocked.`;\n}\n"]}
@@ -28,9 +28,18 @@ BIN="$ROOT/node_modules/.bin/$BIN_NAME"
28
28
  # so the old scraper matched nothing and the guard was BLIND to it — DRIFT_PKG stayed empty and the
29
29
  # stale bin ran (the 2026-07 "0.3.369 vs 0.4.405" incident). Resolve those specs through the top-level
30
30
  # `catalogs:` block of pnpm-lock.yaml (catalog -> pkg -> resolved version) before comparing.
31
+ #
32
+ # THE SAME PASS ANSWERS FAULT U (2026-08-05). Scraping root package.json is also the only way to learn
33
+ # whether @webpieces/ai-hook-rules is DECLARED at all, and that is the difference between "not installed
34
+ # yet" (X, cured by pnpm install) and "nothing asks for it" (U, where pnpm install is a guaranteed
35
+ # no-op). WP_PIN carries the first EXACT @webpieces pin found, so U's deny can prescribe the version the
36
+ # rest of the repo is already on rather than an unpinned add. Both are set BEFORE the range/catalog
37
+ # `continue`s, so a repo pinning the package by range still counts as having declared it.
31
38
  DRIFT_PKG=""
32
39
  DRIFT_DECLARED=""
33
40
  DRIFT_INSTALLED=""
41
+ WP_HOOK_PKG_DECLARED=""
42
+ WP_PIN=""
34
43
  if [ -f "$ROOT/package.json" ]; then
35
44
  # Only when a @webpieces dep actually uses a "catalog:" spec do we scan the (possibly huge) lockfile —
36
45
  # a cheap grep keeps the common, catalog-free repo from paying that cost on every tool call. One awk
@@ -53,6 +62,9 @@ if [ -f "$ROOT/package.json" ]; then
53
62
  fi
54
63
  while IFS=' ' read -r WP_NAME WP_DECL; do
55
64
  [ -n "$WP_NAME" ] || continue
65
+ # Fault U's input: the package is DECLARED (in any spec shape, in any dependency block of the root
66
+ # manifest). Recorded before every `continue` below, so a range or catalog spec still counts.
67
+ [ "$WP_NAME" = "ai-hook-rules" ] && WP_HOOK_PKG_DECLARED=1
56
68
  # Resolve the declared spec to an EXACT version, or skip it: ranges (^ ~ workspace:*) never drift,
57
69
  # and a catalog spec we cannot resolve is best-effort skipped rather than guessed.
58
70
  case "$WP_DECL" in
@@ -63,6 +75,8 @@ if [ -f "$ROOT/package.json" ]; then
63
75
  [0-9]*) : ;;
64
76
  *) continue ;;
65
77
  esac
78
+ # The release the rest of this repo is on — what fault U's cure should pin to.
79
+ [ -n "$WP_PIN" ] || WP_PIN="$WP_DECL"
66
80
  WP_MANIFEST="$ROOT/node_modules/@webpieces/$WP_NAME/package.json"
67
81
  [ -f "$WP_MANIFEST" ] || continue
68
82
  WP_INST="$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$WP_MANIFEST" | head -n1)"
@@ -167,12 +181,14 @@ fi
167
181
  # through: the assistant's own Bash tool routes through this hook too, so blocking everything would
168
182
  # deadlock the very commands (pnpm install / rm -rf node_modules && pnpm install) that re-enable the
169
183
  # guards. A silent exit 0 = "allow" in the PreToolUse protocol; the guards resume once the tree is sane.
170
- # WHICH of the six guards/L0-tooling.md faults fired, in the doc's own letters. Only the three sh-side
184
+ # WHICH of the guards/L0-tooling.md faults fired, in the doc's own letters. Only the four sh-side
171
185
  # codes can be decided here; S/C/Y live in the binary, which never got to run on this path.
172
186
  WP_FAULT=X # X — bin missing (fresh clone, new worktree)
187
+ [ -z "$WP_HOOK_PKG_DECLARED" ] && WP_FAULT=U # U — X, but nothing declares the package: install is a no-op
173
188
  [ -n "$DRIFT_PKG" ] && WP_FAULT=D # D — version drift; D and K are mutually exclusive
174
189
  [ -n "$BROKEN_BIN" ] && WP_FAULT=K # K — bin present but CRASHED (corrupt node_modules)
175
190
  DENY_LABEL="DENY"
191
+ [ -z "$WP_HOOK_PKG_DECLARED" ] && DENY_LABEL="DENY-UNDECLARED" # nothing in package.json asks for the package
176
192
  [ -n "$DRIFT_PKG" ] && DENY_LABEL="DENY-STALE" # version drift, not a missing bin
177
193
  [ -n "$BROKEN_BIN" ] && DENY_LABEL="DENY-BROKEN" # bin present but CRASHED (corrupt node_modules)
178
194
  # THE L0 ALLOWLIST, entry order identical to isAllowed(). No fault is consulted: a cure that cannot
@@ -187,7 +203,7 @@ case "$FILE" in
187
203
  wp_log "$WP_FAULT" ALLOW-CONFIG # the always-allowed recovery target — every guard is configured from it
188
204
  exit 0 ;;
189
205
  esac
190
- if printf '%s' "$CMD" | grep -Eq '^(cd[[:space:]]+([A-Za-z0-9._/@~+-]+|'\''[^'\'']+'\'')[[:space:]]*&&[[:space:]]*)?((pnpm|npm)[[:space:]]+(install|i)([[:space:]]+--[A-Za-z][A-Za-z0-9=._/@:-]*)*|rm[[:space:]]+-rf[[:space:]]+(\./)?node_modules/?([[:space:]]*&&[[:space:]]*(pnpm|npm)[[:space:]]+(install|i)([[:space:]]+--[A-Za-z][A-Za-z0-9=._/@:-]*)*)?|git[[:space:]]+(pull|fetch)([[:space:]]+(--)?[A-Za-z0-9][A-Za-z0-9=._/@:-]*)*|(pnpm|npm|npx)([[:space:]]+(exec|run))?[[:space:]]+wp-upgrade-shim|cp[[:space:]]+(\./)?node_modules/@webpieces/ai-hook-rules/templates/ai-hook\.sh[[:space:]]+(\./)?\.claude/webpieces/ai-hook\.sh|(pnpm|npm|npx)([[:space:]]+(exec|run))?[[:space:]]+wp-install-ai-hooks([[:space:]]+--[A-Za-z][A-Za-z0-9=._/@:-]*)*|(pwd|git[[:space:]]+(status|log|diff|show|branch|rev-parse)|git[[:space:]]+worktree[[:space:]]+list)([[:space:]]+(--)?[A-Za-z0-9][A-Za-z0-9=._/@:-]*)*)([[:space:]]+2>(&1|/dev/null))?([[:space:]]*\|[[:space:]]*(tail|head)([[:space:]]+-(n[[:space:]]+)?[0-9]+)?)?[[:space:]]*$'; then
206
+ if printf '%s' "$CMD" | grep -Eq '^(cd[[:space:]]+([A-Za-z0-9._/@~+-]+|'\''[^'\'']+'\'')[[:space:]]*&&[[:space:]]*)?((pnpm|npm)[[:space:]]+(install|i)([[:space:]]+--[A-Za-z][A-Za-z0-9=._/@:-]*)*|rm[[:space:]]+-rf[[:space:]]+(\./)?node_modules/?([[:space:]]*&&[[:space:]]*(pnpm|npm)[[:space:]]+(install|i)([[:space:]]+--[A-Za-z][A-Za-z0-9=._/@:-]*)*)?|git[[:space:]]+(pull|fetch)([[:space:]]+(--)?[A-Za-z0-9][A-Za-z0-9=._/@:-]*)*|(pnpm|npm|npx)([[:space:]]+(exec|run))?[[:space:]]+wp-upgrade-shim|cp[[:space:]]+(\./)?node_modules/@webpieces/ai-hook-rules/templates/ai-hook\.sh[[:space:]]+(\./)?\.claude/webpieces/ai-hook\.sh|(pnpm|npm|npx)([[:space:]]+(exec|run))?[[:space:]]+wp-install-ai-hooks([[:space:]]+--[A-Za-z][A-Za-z0-9=._/@:-]*)*|(pnpm|npm)[[:space:]]+add([[:space:]]+(-[A-Za-z]|--[A-Za-z][A-Za-z0-9=._/@:-]*))*[[:space:]]+@webpieces/ai-hook-rules(@[A-Za-z0-9._+-]+)?([[:space:]]+(-[A-Za-z]|--[A-Za-z][A-Za-z0-9=._/@:-]*))*|(pwd|git[[:space:]]+(status|log|diff|show|branch|rev-parse)|git[[:space:]]+worktree[[:space:]]+list)([[:space:]]+(--)?[A-Za-z0-9][A-Za-z0-9=._/@:-]*)*)([[:space:]]+2>(&1|/dev/null))?([[:space:]]*\|[[:space:]]*(tail|head)([[:space:]]+-(n[[:space:]]+)?[0-9]+)?)?[[:space:]]*$'; then
191
207
  wp_log "$WP_FAULT" ALLOW-CURE # record the self-heal we let through (re-enables the guards)
192
208
  exit 0 # allow the cure so the assistant can break the deadlock
193
209
  fi
@@ -254,7 +270,19 @@ else
254
270
  if [ -f "$ROOT/.git" ]; then
255
271
  WORKTREE_NOTE=" NOTE: $ROOT is a LINKED WORKTREE - git does not copy node_modules into a new worktree, so this is expected on a fresh one. Run it HERE, in this worktree, not in the primary clone."
256
272
  fi
257
- REASON="❌ @webpieces/ai-hook-rules is declared in package.json but is not installed (${BIN_NAME} not found). Run EXACTLY: 'pnpm install'.${WORKTREE_NOTE} Run it EXACTLY as written - the allowlist matches the whole command, so appending anything (even && git status) makes it a different command and it is rejected; that is not the guard blocking its own cure. Only these may be added: a leading cd <dir> && (single-quote a path containing spaces), a trailing 2>&1, and | tail -N. (If you removed @webpieces/ai-hook-rules on purpose, delete its hooks from .claude/settings.json.)"
273
+ if [ -z "$WP_HOOK_PKG_DECLARED" ]; then
274
+ # FAULT U — the one shape where the X message is not merely unhelpful but actively WRONG. It asserted
275
+ # "declared in package.json" without ever checking, and prescribed the one command that provably
276
+ # cannot help: with nothing asking for the package, `pnpm install` reports "Lockfile is up to date"
277
+ # and converges to the identical broken tree, forever. So say what is actually true, say out loud
278
+ # that the install is a no-op (an agent that has already run it needs to be told to STOP), and
279
+ # prescribe the add — which is allowlist entry ADD_HOOK_PKG, so it is reachable while this block is up.
280
+ WP_ADD_CMD="pnpm add -D @webpieces/ai-hook-rules"
281
+ [ -n "$WP_PIN" ] && WP_ADD_CMD="${WP_ADD_CMD}@$WP_PIN"
282
+ REASON="❌ @webpieces/ai-hook-rules is NOT declared in package.json anywhere, and is not installed (${BIN_NAME} not found) - yet .claude/settings.json still runs its hooks, so every 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. It normally arrives with @webpieces/nx-webpieces-rules, the umbrella that bundles the whole toolchain - so the durable fix is to upgrade that. To unblock yourself right now, declare it directly. Run EXACTLY: '$WP_ADD_CMD'. Run it EXACTLY as written - the allowlist matches the whole command, so appending anything (even && git status) makes it a different command and it is rejected; that is not the guard blocking its own cure. Only these may be added: a leading cd <dir> && (single-quote a path containing spaces), a trailing 2>&1, and | tail -N. (If you removed @webpieces/ai-hook-rules on purpose, delete its hooks from .claude/settings.json instead.)"
283
+ else
284
+ REASON="❌ @webpieces/ai-hook-rules is declared in package.json but is not installed (${BIN_NAME} not found). Run EXACTLY: 'pnpm install'.${WORKTREE_NOTE} Run it EXACTLY as written - the allowlist matches the whole command, so appending anything (even && git status) makes it a different command and it is rejected; that is not the guard blocking its own cure. Only these may be added: a leading cd <dir> && (single-quote a path containing spaces), a trailing 2>&1, and | tail -N. (If you removed @webpieces/ai-hook-rules on purpose, delete its hooks from .claude/settings.json.)"
285
+ fi
258
286
  fi
259
287
  if [ "$TOOL" = "Bash" ]; then
260
288
  BS='\' # one literal backslash, so the \u001b escape never sits in this source