@webpieces/ai-hook-rules 0.4.622 → 0.4.624

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.
Files changed (60) hide show
  1. package/README.md +49 -19
  2. package/package.json +2 -2
  3. package/src/adapters/hook-core.js +13 -14
  4. package/src/adapters/hook-core.js.map +1 -1
  5. package/src/bin/hook-registration.d.ts +160 -50
  6. package/src/bin/hook-registration.js +227 -96
  7. package/src/bin/hook-registration.js.map +1 -1
  8. package/src/bin/managed-env.d.ts +46 -0
  9. package/src/bin/managed-env.js +50 -0
  10. package/src/bin/managed-env.js.map +1 -0
  11. package/src/bin/setup.d.ts +1 -2
  12. package/src/bin/setup.js +38 -46
  13. package/src/bin/setup.js.map +1 -1
  14. package/src/bin/shim-audit-log.js +12 -1
  15. package/src/bin/shim-audit-log.js.map +1 -1
  16. package/src/bin/shim-deny-reason.d.ts +6 -0
  17. package/src/bin/shim-deny-reason.js +82 -0
  18. package/src/bin/shim-deny-reason.js.map +1 -0
  19. package/src/bin/shim.d.ts +0 -1
  20. package/src/bin/shim.js +3 -57
  21. package/src/bin/shim.js.map +1 -1
  22. package/src/bin/upgrade-shim.js +140 -30
  23. package/src/bin/upgrade-shim.js.map +1 -1
  24. package/src/core/decision-log.d.ts +3 -3
  25. package/src/core/decision-log.js +8 -8
  26. package/src/core/decision-log.js.map +1 -1
  27. package/src/core/effective-tree.d.ts +5 -2
  28. package/src/core/effective-tree.js +1 -1
  29. package/src/core/effective-tree.js.map +1 -1
  30. package/src/core/l0-matrix.js +15 -13
  31. package/src/core/l0-matrix.js.map +1 -1
  32. package/src/core/l1-doc.js +29 -68
  33. package/src/core/l1-doc.js.map +1 -1
  34. package/src/core/l1-rows.d.ts +17 -9
  35. package/src/core/l1-rows.js +18 -13
  36. package/src/core/l1-rows.js.map +1 -1
  37. package/src/core/log-stream.d.ts +6 -4
  38. package/src/core/log-stream.js +6 -4
  39. package/src/core/log-stream.js.map +1 -1
  40. package/src/core/log-streams.d.ts +13 -3
  41. package/src/core/log-streams.js +15 -5
  42. package/src/core/log-streams.js.map +1 -1
  43. package/src/core/runner.d.ts +1 -2
  44. package/src/core/runner.js +29 -24
  45. package/src/core/runner.js.map +1 -1
  46. package/src/core/version-sync.d.ts +67 -0
  47. package/src/core/version-sync.js +148 -0
  48. package/src/core/version-sync.js.map +1 -0
  49. package/src/core/webpieces-versions.d.ts +83 -0
  50. package/src/core/webpieces-versions.js +169 -0
  51. package/src/core/webpieces-versions.js.map +1 -0
  52. package/templates/ai-hook.sh +15 -4
  53. package/templates/claude-settings-hook.json +6 -3
  54. package/src/bin/guarantee-root.d.ts +0 -95
  55. package/src/bin/guarantee-root.js +0 -297
  56. package/src/bin/guarantee-root.js.map +0 -1
  57. package/src/core/coordinator-worktree.d.ts +0 -61
  58. package/src/core/coordinator-worktree.js +0 -94
  59. package/src/core/coordinator-worktree.js.map +0 -1
  60. package/templates/guarantee-root.sh +0 -113
@@ -1 +1 @@
1
- {"version":3,"file":"upgrade-shim.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/bin/upgrade-shim.ts"],"names":[],"mappings":"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","sourcesContent":["#!/usr/bin/env node\nimport * as fs from 'fs';\n\nimport { renderShim, shimPath, findShimRoot } from './shim';\nimport { guaranteeRootPath, writeGuaranteeRoot } from './guarantee-root';\nimport { repairRegistrationAt, managedSurfaceDrift } from './hook-registration';\nimport { toError } from '../core/to-error';\n\n// ---------------------------------------------------------------------------\n// The `wp-upgrade-shim` entry point — the CURE for the managed-hook-surface self-guard (L0 fault S).\n//\n// WHAT IT REPAIRS, and why all three (2026-08-07). This used to write EXACTLY ONE FILE, ai-hook.sh,\n// and touch nothing else. That was correct while the installed surface WAS one file. It is now three:\n//\n// 1. .claude/webpieces/ai-hook.sh the guard shim, registered RELATIVE so each git tree runs\n// its own release, its own binary and its own pin\n// 2. .claude/webpieces/guarantee-root.sh the L-1 hook, registered ABSOLUTE, which refuses any `cd`\n// that would park the shell where the RELATIVE hooks cannot\n// launch — an unresolvable hook exits 127, and per the hooks\n// reference that is a NON-BLOCKING error, i.e. a SILENT\n// UNGUARDED ALLOW\n// 3. the .claude/settings.json registration itself\n//\n// Leaving (2) and (3) out would have made the upgrade path silently useless: an upgrading consumer\n// would take the new shim, KEEP the old two-absolute-hook registration, never receive guarantee-root.sh\n// at all, and L-1 would never activate — with the drift check reporting nothing, because nothing\n// validated settings.json. A cure that fixes one of three is worse than no cure, because it reports\n// success. This bin is already the sanctioned cure named in fault S's message and already on the L0\n// allowlist, so extending it keeps the existing self-healing path working end to end.\n//\n// Deliberately imports only ./shim, ./guarantee-root and ./hook-registration (fs + path) + toError,\n// exactly like install-entry: the whole job is to rewrite webpieces-managed files, which never needed\n// the rule engine, and it must stay runnable on a tree too broken to load it.\n// ---------------------------------------------------------------------------\nconst RED = '[31;1m';\nconst RESET = '[0m';\n\n// Returns the process exit code (0 = ok). Kept as a function (not top-level code) so it is unit-testable\n// without spawning node.\n// webpieces-disable no-function-outside-class -- bin entry point: this module MUST load with only fs+path (see header), mirroring install-entry.ts. A DI-managed class would pull the container in and reintroduce the require-time crash this dependency-free path exists to survive.\nexport function runUpgradeShim(cwd: string): number {\n const root = findShimRoot(cwd);\n if (root === null) {\n console.error(`${RED}🛑 @webpieces: no committed .claude/webpieces/ai-hook.sh found to regenerate.${RESET}`);\n console.error(' Run this from a repo that installs @webpieces/ai-hook-rules, or run the installer (pnpm wp-install-ai-hooks).');\n return 1;\n }\n const target = shimPath(root);\n // webpieces-disable no-unmanaged-exceptions -- bin entry chokepoint: turn an fs error into an actionable line + non-zero exit rather than a raw node trace; there is no caller above a bin to handle it.\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n fs.writeFileSync(target, renderShim(), { mode: 0o755 });\n // writeFileSync's mode only applies on create; force it on overwrite too (matches writeShim).\n fs.chmodSync(target, 0o755);\n writeGuaranteeRoot(root);\n const rewired = repairRegistrationAt(root);\n reportRepairs(target, guaranteeRootPath(root), rewired);\n return verifyRepaired(root);\n } catch (err: unknown) {\n const error = toError(err);\n console.error(`${RED}🛑 @webpieces: could not write under ${root}: ${error.message}${RESET}`);\n return 1;\n }\n}\n\n/**\n * Say what was actually done, per managed thing. The old single line (\"regenerated the managed shim\")\n * would now be a lie by omission on the two most important repairs — and an agent reading a cure's\n * output is how it decides whether the cure worked.\n */\n// webpieces-disable no-function-outside-class -- sibling of runUpgradeShim in this deliberately dependency-free bin module\nfunction reportRepairs(shimFile: string, guaranteeFile: string, rewired: readonly string[]): void {\n console.log(`✅ @webpieces: regenerated the managed shim at ${shimFile} — tool calls are re-armed.`);\n console.log(`✅ @webpieces: regenerated the L-1 hook at ${guaranteeFile}.`);\n if (rewired.length === 0) {\n console.log(' .claude/settings.json hook registration already matches this release — no change.');\n } else {\n for (const file of rewired) {\n console.log(`✅ @webpieces: rewrote the hook registration in ${file} to the three-hook form`);\n console.log(' (L-1 absolute + the two guard hooks RELATIVE, so each git tree runs its own release).');\n }\n }\n console.log(' These files are generated + committed by webpieces; do not revert or hand-edit them.');\n}\n\n/**\n * DID THE CURE ACTUALLY CURE IT — asked of the same predicate the guard asks, not of our own writes.\n *\n * A cure that cannot fail loudly is worse than no cure. This bin used to print three ✅ lines and\n * return 0 the moment `writeFileSync` did not throw, which asserts only \"the bytes we chose were\n * written\", never \"the surface the guard measures now agrees\". Fault S blocks EVERY tool call, so the\n * one thing a blocked agent must be able to trust is whether the block will lift — and a success line\n * that is not backed by the guard's own check is exactly the false certainty that leaves it retrying a\n * cure that cannot work. So re-run `managedSurfaceDrift()`, the very function `enforceCommittedShim()`\n * calls, and return NON-ZERO naming whatever still differs.\n *\n * Measured against `root` (the tree we just repaired), not `governingShimRoot()` (the tree the running\n * binary came from). Those differ when the cure is run across trees, and the honest claim here is about\n * the files this invocation wrote.\n */\n// webpieces-disable no-function-outside-class -- sibling of runUpgradeShim in this deliberately dependency-free bin module\nfunction verifyRepaired(root: string): number {\n const stillDrifted = managedSurfaceDrift(root);\n if (stillDrifted.length === 0) return 0;\n console.error(`${RED}🛑 @webpieces: the repair ran but ${stillDrifted.length} managed surface(s) STILL differ: ${stillDrifted.join(', ')}.${RESET}`);\n console.error(` The guard will keep blocking. This is a webpieces bug or an unwritable tree under ${root} - do not retry this command in a loop; report it with the list above.`);\n return 1;\n}\n\n/**\n * THE PROCESS ENTRY POINT — the thing whose absence made this whole bin a lie.\n *\n * Up to and including 0.4.588 this module ENDED at the closing brace above. `pnpm exec wp-upgrade-shim`\n * loaded it, defined two functions, and exited 0 having printed nothing and changed no file. Fault S\n * names this command as OPTION 1, the only option that repairs all three managed surfaces, so the\n * guard's own \"THIS IS NOT A DEADLOCK\" promise was false: OPTION 2 repairs one of three, and OPTION 1\n * did nothing at all. Twenty-one unit tests missed it because every one of them called\n * `runUpgradeShim()` as a FUNCTION — the defect lived entirely in what the module does when SPAWNED.\n *\n * `runMain` from @webpieces/rules-config is the repo-wide wrapper and is deliberately NOT used here:\n * this bin must load with fs+path only (see the header) so it still runs on the broken tree it exists\n * to repair. `main()` is the sanctioned exit site instead, and `bin-process-entry.spec.ts` spawns\n * this file as a process so a future refactor cannot silently drop the launcher again.\n */\n// webpieces-disable no-function-outside-class -- bin entry point in this deliberately dependency-free module; see header\nexport function main(): void {\n process.exit(runUpgradeShim(process.cwd()));\n}\n\nif (require.main === module) {\n main();\n}\n"]}
1
+ {"version":3,"file":"upgrade-shim.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/bin/upgrade-shim.ts"],"names":[],"mappings":"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","sourcesContent":["#!/usr/bin/env node\nimport * as fs from 'fs';\nimport * as path from 'path';\n\nimport { renderShim, shimPath, findShimRoot } from './shim';\nimport { repairRegistrationAt, managedSurfaceDrift, SettingsRepair, LEGACY_GUARANTEE_ROOT_MARKER } from './hook-registration';\nimport { BASH_CWD_ENV_KEY, BASH_CWD_ENV_VALUE } from './managed-env';\nimport { toError } from '../core/to-error';\n\n// ---------------------------------------------------------------------------\n// The `wp-upgrade-shim` entry point — the CURE for the managed-hook-surface self-guard (L0 fault S).\n//\n// WHAT IT REPAIRS, and why all three (2026-08-07, extended). This used to write EXACTLY ONE FILE,\n// ai-hook.sh, and touch nothing else. That was correct while the installed surface WAS one file. It is\n// now three (the name `wp-upgrade-shim` is older than the job and is NOT renamed — a rename with no\n// functional change is a cost with no payer; the prose is what gets corrected):\n//\n// 1. .claude/webpieces/ai-hook.sh the guard shim, registered ABSOLUTE via $CLAUDE_PROJECT_DIR\n// so the MAIN tree governs every tree\n// 2. the .claude/settings.json registration itself\n// 3. the .claude/settings.json `env` entry CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR=1, which pins the\n// Bash cwd to the project root — and, because settings `env` is inherited, pins it identically for\n// every subagent, so a verdict never depends on where an earlier `cd` left the shell\n//\n// It also DELETES the retired `.claude/webpieces/guarantee-root.sh` and any settings entry still naming\n// it. That file was the L-1 hook, which existed only to guarantee the once-RELATIVE shim path resolved;\n// an absolute path resolves from any cwd, so it has no job left. Removing the file without removing the\n// entry (or vice versa) is the worst possible half-state — a registered hook pointing at a missing file\n// exits 127, which per the hooks reference is a NON-BLOCKING error, i.e. a SILENT UNGUARDED ALLOW — so\n// both happen here, file first.\n//\n// A cure that fixes some of three is worse than no cure, because it reports success. This bin is the\n// sanctioned cure named in fault S's message and is on the L0 allowlist, so it must repair everything.\n//\n// Deliberately imports only ./shim and ./hook-registration (fs + path) + toError,\n// exactly like install-entry: the whole job is to rewrite webpieces-managed files, which never needed\n// the rule engine, and it must stay runnable on a tree too broken to load it.\n// ---------------------------------------------------------------------------\nconst RED = '[31;1m';\nconst RESET = '[0m';\n\n// Returns the process exit code (0 = ok). Kept as a function (not top-level code) so it is unit-testable\n// without spawning node.\n// webpieces-disable no-function-outside-class -- bin entry point: this module MUST load with only fs+path (see header), mirroring install-entry.ts. A DI-managed class would pull the container in and reintroduce the require-time crash this dependency-free path exists to survive.\nexport function runUpgradeShim(cwd: string): number {\n const root = findShimRoot(cwd);\n if (root === null) {\n console.error(`${RED}🛑 @webpieces: no committed .claude/webpieces/ai-hook.sh found to regenerate.${RESET}`);\n console.error(' Run this from a repo that installs @webpieces/ai-hook-rules, or run the installer (pnpm wp-install-ai-hooks).');\n return 1;\n }\n const target = shimPath(root);\n // webpieces-disable no-unmanaged-exceptions -- bin entry chokepoint: turn an fs error into an actionable line + non-zero exit rather than a raw node trace; there is no caller above a bin to handle it.\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n fs.writeFileSync(target, renderShim(), { mode: 0o755 });\n // writeFileSync's mode only applies on create; force it on overwrite too (matches writeShim).\n fs.chmodSync(target, 0o755);\n // ORDER MATTERS, and it is registration-FIRST. The reverse leaves a window in which a settings\n // entry still names a file that is gone — exit 127, which the hooks reference defines as a\n // NON-BLOCKING error, i.e. a SILENT UNGUARDED ALLOW. This way round the transient state is an\n // ORPHANED FILE that nothing references, which is inert. (An earlier draft of this function\n // argued file-first was safer; it is not. repairRegistration() also early-returns when the file\n // registers no guard bins, and the retired H1 command contains neither bin name, so a\n // guarantee-root-ONLY settings file would have lost the file and kept the entry.)\n const repairs = repairRegistrationAt(root);\n const removedLegacy = removeRetiredGuaranteeRoot(root);\n reportRepairs(target, removedLegacy, repairs);\n // ADVISORY ONLY, and deliberately after the ✅ lines: it never touches the exit code (see\n // reportTreeDivergence).\n reportTreeDivergence(root);\n return verifyRepaired(root);\n } catch (err: unknown) {\n const error = toError(err);\n console.error(`${RED}🛑 @webpieces: could not write under ${root}: ${error.message}${RESET}`);\n return 1;\n }\n}\n\n/**\n * Say what was actually done, per managed thing. The old single line (\"regenerated the managed shim\")\n * would now be a lie by omission on the three most important repairs — and an agent reading a cure's\n * output is how it decides whether the cure worked.\n *\n * Each settings file reports the repairs IT needed, from the flags recorded before the rewrite. Printing\n * \"rewrote the hook registration\" for a file whose hooks were already current and whose `env` entry was\n * the only thing missing would be the same class of dishonesty one level down.\n */\n// webpieces-disable no-function-outside-class -- sibling of runUpgradeShim in this deliberately dependency-free bin module\n/**\n * Delete the RETIRED L-1 hook file, returning whether there was one. Removal-only, one way, no writer.\n *\n * This runs AFTER repairRegistrationAt(), which strips the stale H1 ENTRY from settings.json. Removing\n * the entry first means the file is merely orphaned in between — inert, because nothing references it.\n * File-first would instead leave a registered hook pointing at a missing file, and per the hooks\n * reference a non-2 non-zero exit is a NON-BLOCKING error: every `cd` would go unjudged.\n */\n// webpieces-disable no-function-outside-class -- module-scope like every other helper in this bin, which must load on a tree too broken to build a DI container\nfunction removeRetiredGuaranteeRoot(root: string): boolean {\n // The path comes from LEGACY_GUARANTEE_ROOT_MARKER, never re-spelled here: a second literal is a\n // second spelling, and when the expiry in hook-registration.ts fires the documented removal would\n // miss this copy and the dead name would survive in a file nobody thought to grep.\n const legacy = path.join(root, LEGACY_GUARANTEE_ROOT_MARKER);\n if (!fs.existsSync(legacy)) return false;\n fs.rmSync(legacy, { force: true });\n return true;\n}\n\n// webpieces-disable no-function-outside-class -- module-scope like every other helper in this bin, which must load on a tree too broken to build a DI container\nfunction reportRepairs(shimFile: string, removedLegacy: boolean, repairs: readonly SettingsRepair[]): void {\n console.log(`✅ @webpieces: regenerated the managed shim at ${shimFile} — tool calls are re-armed.`);\n if (removedLegacy) {\n console.log('✅ @webpieces: deleted the RETIRED L-1 hook .claude/webpieces/guarantee-root.sh.');\n console.log(' The guard hooks are ABSOLUTE now, so the launch guarantee it provided is structural.');\n }\n if (repairs.length === 0) {\n console.log(' .claude/settings.json (hook registration + managed env) already matches this release — no change.');\n }\n for (const repair of repairs) {\n if (repair.registration) {\n console.log(`✅ @webpieces: rewrote the hook registration in ${repair.settingsPath} to the two-hook form`);\n console.log(' (both guard hooks ABSOLUTE via $CLAUDE_PROJECT_DIR, so the MAIN tree governs every tree;');\n console.log(' any retired guarantee-root.sh entry was removed).');\n }\n if (repair.env) {\n console.log(`✅ @webpieces: set env.${BASH_CWD_ENV_KEY}=${BASH_CWD_ENV_VALUE} in ${repair.settingsPath}`);\n console.log(' (pins the Bash cwd to the project root, so the guard hooks resolve identically for every subagent — for');\n console.log(' this session and, because settings env is inherited, for every subagent it spawns).');\n }\n }\n console.log(' These files are generated + committed by webpieces; do not revert or hand-edit them.');\n}\n\n/**\n * WAS THE REPAIRED TREE THE TREE THE HOOKS LAUNCH FROM — the second way this cure can report success\n * while changing nothing the session is actually governed by.\n *\n * H1 is registered ABSOLUTE, `sh \"$CLAUDE_PROJECT_DIR/…\"`, and `$CLAUDE_PROJECT_DIR` never moves off the\n * PRIMARY clone (the two-tree straddle recorded in shim.ts, and the whole reason H2/H3 are relative\n * while H1 is not). So repairing a LINKED WORKTREE leaves the running session still loading the\n * PRIMARY's files, the PRIMARY's binary and the PRIMARY's pin. Four green lines, and the block does not\n * lift. Nothing printed above is false — but the question the reader has (\"will the block lift?\") went\n * unanswered, which is the same failure this file's header exists to prevent, one level out.\n *\n * THE PREDICATE IS TREE DIVERGENCE, NOT \"am I a subagent\". There is no runtime subagent marker in the\n * hook environment to read, and divergence is the more accurate question anyway: a MAIN agent in a\n * linked worktree HAS this problem (a subagent test would miss it), and a SUBAGENT in the same tree does\n * NOT (a subagent test would cry wolf). Both paths are realpath'd before comparing — a worktree path can\n * arrive symlinked, and /tmp vs /private/tmp on darwin is a live case in this repo's own specs.\n *\n * SILENT when `$CLAUDE_PROJECT_DIR` is unset: a plain CLI run outside Claude Code has no second tree to\n * talk about. And ADVISORY always — it must never turn a verified repair into a failure, so it returns\n * nothing and `verifyRepaired()`'s contract (non-zero only when a surface in THIS tree still differs) is\n * untouched.\n */\n// webpieces-disable no-function-outside-class -- sibling of runUpgradeShim in this deliberately dependency-free bin module\nfunction reportTreeDivergence(root: string): void {\n const projectDir = process.env['CLAUDE_PROJECT_DIR'];\n if (projectDir === undefined || projectDir === '') return;\n if (sameTree(root, projectDir)) return;\n console.log('');\n console.log('⚠️ @webpieces: the tree just repaired is NOT the tree the hooks launch from.');\n console.log(` repaired: ${root}`);\n console.log(` CLAUDE_PROJECT_DIR: ${projectDir}`);\n console.log(' The hooks governing this session resolve through CLAUDE_PROJECT_DIR (H1 is registered');\n console.log(' absolute), so this repair has not changed what is currently enforcing — it made THIS');\n console.log(' tree correct for when its own branch is the one being judged, which is not wasted work.');\n console.log(' To change what is enforcing NOW, run the same repair in the primary tree, and install');\n console.log(' there too — the hooks execute the INSTALLED release, not this tree\\'s source:');\n console.log(` cd ${projectDir} && pnpm install && pnpm exec wp-upgrade-shim`);\n console.log(' Repaired in BOTH trees is the aligned end state, and running it twice is safe.');\n}\n\n/**\n * Do two paths name the same tree? realpath'd (symlinked worktrees, /tmp vs /private/tmp) and stripped\n * of a trailing separator before comparing. A path that cannot be realpath'd falls back to `resolve`,\n * so an absent CLAUDE_PROJECT_DIR directory reads as \"different\" rather than throwing inside a cure.\n */\n// webpieces-disable no-function-outside-class -- sibling of runUpgradeShim in this deliberately dependency-free bin module\nfunction sameTree(a: string, b: string): boolean {\n return canonicalTree(a) === canonicalTree(b);\n}\n\n// webpieces-disable no-function-outside-class -- sibling of runUpgradeShim in this deliberately dependency-free bin module\nfunction canonicalTree(dir: string): string {\n // webpieces-disable no-unmanaged-exceptions -- realpath throws on a path that does not exist; the fallback IS the handling, and an advisory notice must never crash the cure it annotates.\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return path.resolve(fs.realpathSync(dir));\n } catch (err: unknown) {\n const error = toError(err);\n void error; // best-effort: an unresolvable path simply compares as itself\n return path.resolve(dir);\n }\n}\n\n/**\n * DID THE CURE ACTUALLY CURE IT — asked of the same predicate the guard asks, not of our own writes.\n *\n * A cure that cannot fail loudly is worse than no cure. This bin used to print three ✅ lines and\n * return 0 the moment `writeFileSync` did not throw, which asserts only \"the bytes we chose were\n * written\", never \"the surface the guard measures now agrees\". Fault S blocks EVERY tool call, so the\n * one thing a blocked agent must be able to trust is whether the block will lift — and a success line\n * that is not backed by the guard's own check is exactly the false certainty that leaves it retrying a\n * cure that cannot work. So re-run `managedSurfaceDrift()`, the very function `enforceCommittedShim()`\n * calls, and return NON-ZERO naming whatever still differs.\n *\n * Measured against `root` (the tree we just repaired), not `governingShimRoot()` (the tree the running\n * binary came from). Those differ when the cure is run across trees, and the honest claim here is about\n * the files this invocation wrote.\n */\n// webpieces-disable no-function-outside-class -- sibling of runUpgradeShim in this deliberately dependency-free bin module\nfunction verifyRepaired(root: string): number {\n const stillDrifted = managedSurfaceDrift(root);\n if (stillDrifted.length === 0) return 0;\n console.error(`${RED}🛑 @webpieces: the repair ran but ${stillDrifted.length} managed surface(s) STILL differ: ${stillDrifted.join(', ')}.${RESET}`);\n console.error(` The guard will keep blocking. This is a webpieces bug or an unwritable tree under ${root} - do not retry this command in a loop; report it with the list above.`);\n return 1;\n}\n\n/**\n * THE PROCESS ENTRY POINT — the thing whose absence made this whole bin a lie.\n *\n * Up to and including 0.4.588 this module ENDED at the closing brace above. `pnpm exec wp-upgrade-shim`\n * loaded it, defined two functions, and exited 0 having printed nothing and changed no file. Fault S\n * names this command as OPTION 1, the only option that repairs all three managed surfaces, so the\n * guard's own \"THIS IS NOT A DEADLOCK\" promise was false: OPTION 2 repairs one of three, and OPTION 1\n * did nothing at all. Twenty-one unit tests missed it because every one of them called\n * `runUpgradeShim()` as a FUNCTION — the defect lived entirely in what the module does when SPAWNED.\n *\n * `runMain` from @webpieces/rules-config is the repo-wide wrapper and is deliberately NOT used here:\n * this bin must load with fs+path only (see the header) so it still runs on the broken tree it exists\n * to repair. `main()` is the sanctioned exit site instead, and `bin-process-entry.spec.ts` spawns\n * this file as a process so a future refactor cannot silently drop the launcher again.\n */\n// webpieces-disable no-function-outside-class -- bin entry point in this deliberately dependency-free module; see header\nexport function main(): void {\n process.exit(runUpgradeShim(process.cwd()));\n}\n\nif (require.main === module) {\n main();\n}\n"]}
@@ -5,7 +5,7 @@
5
5
  * The three distinctions this exists to make, none of which `'ALLOW' | 'BLOCK'` could:
6
6
  *
7
7
  * ALLOW no objection — the call was HANDED DOWN to the next layer. A layer saying ALLOW
8
- * is NOT saying the call ran: the layer below it, or the PARALLEL L-1 hook, may
8
+ * is NOT saying the call ran: the layer below it, or the OTHER parallel hook, may
9
9
  * still deny. This is L1's `ACT_DOWN`.
10
10
  * ALLOW_EXEMPT out of scope by construction — allowed, and evaluation STOPS here. L1's
11
11
  * `ACT_EXEMPT`.
@@ -26,7 +26,7 @@ export type Verdict = 'ALLOW' | 'ALLOW_EXEMPT' | 'ALLOW_FAIL_OPEN' | 'BLOCK_AI_C
26
26
  /**
27
27
  * WHICH ROW of WHICH layer's decision table produced this line. Data-only → a class, per CLAUDE.md.
28
28
  *
29
- * `row` is the row NUMBER from the layer's row array (`L1_ROWS[i].num`, `LMINUS1_ROWS[i].num`) — the
29
+ * `row` is the row NUMBER from the layer's row array (`L1_ROWS[i].num`) — the
30
30
  * same number the generated doc prints, because the doc is rendered from that same array. So a log
31
31
  * line joins to its matrix row BY NUMBER, and checking observed behaviour against the documented use
32
32
  * cases becomes a lookup rather than an investigation. `'-'` for a layer with no row array yet (L2).
@@ -81,7 +81,7 @@ export declare function logGuardDecision(root: string, decision: GuardDecision):
81
81
  * The L1 stream — `.webpieces/logs/L1-location/<writer>.log`.
82
82
  *
83
83
  * L1 had NO stream. Its three blocking paths wrote into L2's file under an implementation name
84
- * (`force-to-root`, `coordinator-in-worktree`, `cd-must-be-first`), and its NON-blocking outcomes —
84
+ * (`force-to-root`, `trinary-version-skew`, `cd-must-be-first`), and its NON-blocking outcomes —
85
85
  * the exempt row and the three hand-down rows — wrote nothing at all. So "L1 had no objection" was
86
86
  * unobservable, and "show me every L1 decision" had no answer: L1 existed in the trail only as the
87
87
  * `root=` / `projectDir=` / `tree=` columns stapled onto somebody else's line.
@@ -24,7 +24,7 @@ const MAX_TARGET_LEN = 160;
24
24
  /**
25
25
  * WHICH ROW of WHICH layer's decision table produced this line. Data-only → a class, per CLAUDE.md.
26
26
  *
27
- * `row` is the row NUMBER from the layer's row array (`L1_ROWS[i].num`, `LMINUS1_ROWS[i].num`) — the
27
+ * `row` is the row NUMBER from the layer's row array (`L1_ROWS[i].num`) — the
28
28
  * same number the generated doc prints, because the doc is rendered from that same array. So a log
29
29
  * line joins to its matrix row BY NUMBER, and checking observed behaviour against the documented use
30
30
  * cases becomes a lookup rather than an investigation. `'-'` for a layer with no row array yet (L2).
@@ -102,7 +102,7 @@ function logGuardDecision(root, decision) {
102
102
  * The L1 stream — `.webpieces/logs/L1-location/<writer>.log`.
103
103
  *
104
104
  * L1 had NO stream. Its three blocking paths wrote into L2's file under an implementation name
105
- * (`force-to-root`, `coordinator-in-worktree`, `cd-must-be-first`), and its NON-blocking outcomes —
105
+ * (`force-to-root`, `trinary-version-skew`, `cd-must-be-first`), and its NON-blocking outcomes —
106
106
  * the exempt row and the three hand-down rows — wrote nothing at all. So "L1 had no objection" was
107
107
  * unobservable, and "show me every L1 decision" had no answer: L1 existed in the trail only as the
108
108
  * `root=` / `projectDir=` / `tree=` columns stapled onto somebody else's line.
@@ -255,12 +255,12 @@ class InvocationLog {
255
255
  `branch=${invocation.branch}`,
256
256
  invocation.sync,
257
257
  // `guards=`, NOT `verdict=`. This hook can only report on ITSELF. Claude Code runs all
258
- // three PreToolUse hooks IN PARALLEL, so the L-1 `guarantee-root.sh` process may deny a
259
- // call this one had no objection to, and neither can see the other's answer. Measured:
260
- // `cd <repo>/packages && ls` was DENIED by L-1 and recorded here three times as
261
- // `verdict=ALLOW`. The old field name promised an outcome it structurally cannot know;
262
- // the TRUE final action is the JOIN of this stream with `L-1-cd/`, keyed by the
263
- // identical writer name which is what docs/tooling-logs.md now states.
258
+ // its PreToolUse hooks IN PARALLEL, so another hook process may deny a call this one
259
+ // had no objection to, and neither can see the other's answer. Measured under the
260
+ // RETIRED three-hook form: `cd <repo>/packages && ls` was DENIED by L-1 and recorded
261
+ // here three times as `verdict=ALLOW`. The old field name promised an outcome it
262
+ // structurally cannot know, so the name stayed even though L-1 is gone: the two
263
+ // surviving hooks still run in parallel and still cannot see each other.
264
264
  `guards=${verdict}`,
265
265
  `rule=${oneLine(rule) || '-'}`,
266
266
  // The tree the guard ACTED in, next to what Claude Code said the project was. Both, on
@@ -1 +1 @@
1
- {"version":3,"file":"decision-log.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/decision-log.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync } from 'child_process';\nimport * as fs from 'fs';\nimport * as path from 'path';\n\nimport { dotWebpieces, readMainSyncStatus, MainSyncStatus, RepoRootFinder, claudeEnv } from '@webpieces/rules-config';\nimport { L1_LOCATION_STREAM, L2_DECISIONS_STREAM, CALLS_STREAM } from './log-streams';\n\nimport { L0_FAULT_NONE } from './l0-fault-codes';\nimport { toError } from './to-error';\nimport { logStream } from './log-stream';\n\n// The SYNC decision log — what the synchronous hook DID on each invocation and WHY. Its companion is\n// the ASYNC log (the `async-refresh/` stream, written by the detached refresher in main-sync-log.ts). This\n// one records EVERY guard decision — allow, block, config-bypass, and the fail-open cases — and CITES\n// the async-written cache snapshot (`cache` field) that drove the decision, so a wrong allow/block is\n// traceable to a stale or missing async write. Writes to `.webpieces/logs/L2-decisions/<writer>.log` —\n// the LAYER is the directory, the WRITER is the file (see log-streams.ts and LogStream).\nconst MAX_LOG_BYTES = 512 * 1024; // 512 KB — rotate when exceeded (mirrors rejection-log)\nconst MAX_TARGET_LEN = 160;\n\n/**\n * THE ACTION CODEBOOK, as a type. These are the five actions GUARD_MATRIX.md numbers 1-5, and they\n * are the vocabulary EVERY layer reports in — so one grep spans L-1, L0, L1 and L2.\n *\n * The three distinctions this exists to make, none of which `'ALLOW' | 'BLOCK'` could:\n *\n * ALLOW no objection — the call was HANDED DOWN to the next layer. A layer saying ALLOW\n * is NOT saying the call ran: the layer below it, or the PARALLEL L-1 hook, may\n * still deny. This is L1's `ACT_DOWN`.\n * ALLOW_EXEMPT out of scope by construction — allowed, and evaluation STOPS here. L1's\n * `ACT_EXEMPT`.\n * ALLOW_FAIL_OPEN state could not be established, so nothing was judged. Keeping this distinct\n * from ALLOW is the entire point of the type: a fail-open allow and a real allow\n * that look identical make it impossible to tell whether the guards are protecting\n * anything or quietly abstaining. It used to be a `' (fail-open)'` SUBSTRING on the\n * reason field, which is exactly why the abstentions were never countable.\n * BLOCK_AI_CURE blocked, and the printed cure is a command the AI can run itself.\n * BLOCK_HUMAN blocked, and it needs a human decision — or a delegation (spawn a subagent) that\n * the blocked agent cannot perform for itself.\n *\n * Hard cut, per CLAUDE.md: `'BLOCK'` is GONE rather than aliased, so every construction site fails to\n * compile and has to say which kind of block it is. Before, that question had exactly one wrong\n * answer available — silence.\n */\nexport type Verdict = 'ALLOW' | 'ALLOW_EXEMPT' | 'ALLOW_FAIL_OPEN' | 'BLOCK_AI_CURE' | 'BLOCK_HUMAN';\n\n/**\n * WHICH ROW of WHICH layer's decision table produced this line. Data-only → a class, per CLAUDE.md.\n *\n * `row` is the row NUMBER from the layer's row array (`L1_ROWS[i].num`, `LMINUS1_ROWS[i].num`) — the\n * same number the generated doc prints, because the doc is rendered from that same array. So a log\n * line joins to its matrix row BY NUMBER, and checking observed behaviour against the documented use\n * cases becomes a lookup rather than an investigation. `'-'` for a layer with no row array yet (L2).\n */\nexport class MatrixRef {\n constructor(readonly layer: string, readonly row: string) {}\n}\n\n/**\n * The layer tokens. `row` is `'-'` for a layer with no row array YET (L2 is the un-converted one, and\n * L0's faults are a table of letters rather than numbered rows) — but the LAYER is always named, so\n * `grep layer=L2` works today and the row fills in when L2 converts.\n *\n * These are REQUIRED at the constructor, not defaulted: a defaulted `MatrixRef` would make the\n * uncited case reachable by doing nothing and impossible to grep — the same defect this file's own\n * docblock argues against for `'BLOCK'`, where silence was the one wrong answer available.\n */\nexport const MATRIX_L0 = new MatrixRef('L0', '-');\nexport const MATRIX_L2 = new MatrixRef('L2', '-');\n\n// Data-only record of one guard decision (per CLAUDE.md: classes for data, not object literals).\n// `cache` summarizes the async-written main-sync-status.json that drove a feature-branch-guard\n// decision (branch/merged/conflict/fork + the cache timestamp), or '-' when no cache was consulted\n// (bash guards, on-main, config-bypass).\nexport class GuardDecision {\n rule: string;\n tool: string;\n target: string; // file path (file guards) or the bash command (bash guards)\n branch: string;\n verdict: Verdict;\n reason: string;\n cache: string;\n /**\n * The L0 fault this decision IS, in the codebook's letter (core/l0-fault-codes.ts), or `-` for an\n * ordinary rule decision. The `sh` shim has always stamped `fault=` on its own stream; the three\n * JS-side faults (S/C/Y) reached this one with no label at all, so `grep fault=S` found nothing\n * even while an S storm was blocking every call.\n */\n fault: string;\n /** Which layer + row decided this. See MatrixRef — it is what joins a log line to the doc. */\n matrix: MatrixRef;\n\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(rule: string, tool: string, target: string, branch: string, verdict: Verdict, reason: string, cache: string = '-', fault: string, matrix: MatrixRef) {\n this.rule = rule;\n this.tool = tool;\n this.target = target;\n this.branch = branch;\n this.verdict = verdict;\n this.reason = reason;\n this.cache = cache;\n this.fault = fault;\n this.matrix = matrix;\n }\n}\n\n/**\n * Append one tab-separated line per L2 decision to `.webpieces/logs/L2-decisions/<writer>.log`, where\n * <writer> is LogStream's `<sessionId>-<agentId|coordinator>-<hook>` key (a caller that never\n * identified renders as `unknown-coordinator-hook` — there is no un-keyed name).\n * `root` is\n * the repo/workspace root that holds `.webpieces` (callers pass workspaceRoot, or a\n * RepoRootFinder-resolved root at the pre-load config-bypass site — never a raw cwd, so a bypass\n * logged from a subdir never scatters a stray `.webpieces`). Swallows all errors — logging must never\n * block or fail a hook.\n */\n// webpieces-disable no-function-outside-class -- the module-scope writer this log has always been, beside branchForLog/oneLine/rotateLogFile; it must stay callable from a tree too broken to build a DI container\nexport function logGuardDecision(root: string, decision: GuardDecision): void {\n appendDecision(root, L2_DECISIONS_STREAM, decision);\n}\n\n/**\n * The L1 stream — `.webpieces/logs/L1-location/<writer>.log`.\n *\n * L1 had NO stream. Its three blocking paths wrote into L2's file under an implementation name\n * (`force-to-root`, `coordinator-in-worktree`, `cd-must-be-first`), and its NON-blocking outcomes —\n * the exempt row and the three hand-down rows — wrote nothing at all. So \"L1 had no objection\" was\n * unobservable, and \"show me every L1 decision\" had no answer: L1 existed in the trail only as the\n * `root=` / `projectDir=` / `tree=` columns stapled onto somebody else's line.\n *\n * A SIBLING rather than a `base` parameter on logGuardDecision, deliberately: that signature is what\n * the process-wide `logStream` singleton exists to keep unchanged (see LogStream's docblock), and\n * `INVOCATION_LOG_FILE` already establishes the pattern of a second stream owning its own name in\n * this same module.\n */\n// webpieces-disable no-function-outside-class -- sibling of logGuardDecision, same module-scope writer shape and same reason\nexport function logL1Decision(root: string, decision: GuardDecision): void {\n appendDecision(root, L1_LOCATION_STREAM, decision);\n}\n\n// The one appender both streams share. `streamDir` is the LAYER; the writer key inside it is\n// logStream's session/agent/hook, which is what keeps one writer per file.\n// webpieces-disable no-function-outside-class -- the shared body of the two module-scope writers above\nfunction appendDecision(root: string, streamDir: string, decision: GuardDecision): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const timestamp = new Date().toISOString();\n // LOCAL scope: a guard decision belongs to the tree it judged. WHO made the call is answered\n // by the filename, which logStream names with session/agent/hook.\n const logsDir = dotWebpieces.logsFile(root, streamDir);\n fs.mkdirSync(logsDir, { recursive: true });\n\n const logPath = path.join(logsDir, logStream.writerFile('.log'));\n rotateLogFile(logPath, path.join(logsDir, logStream.writerFile('.1.log')));\n\n const line = [\n `[${timestamp}]`,\n decision.verdict,\n decision.tool,\n oneLine(decision.target),\n decision.branch,\n decision.rule,\n oneLine(decision.reason),\n oneLine(decision.cache),\n // WHICH ROW of WHICH table decided this. The directory already carries the layer, but a\n // line quoted out of its file must still say what judged it — and `row=` is the join key\n // to the generated doc, which is the point of the whole exercise.\n `layer=${decision.matrix.layer}`,\n `row=${decision.matrix.row}`,\n // The tree this decision was actually made against, and what Claude Code told the hook the\n // project was. Appended (never reordered) for the same reason as on the invocation line —\n // see ClaudeEnv: when these two disagree, that disagreement is the bug.\n `root=${root}`,\n `projectDir=${claudeEnv.projectDirForLog()}`,\n // git's name for that tree — `primary`, else the worktree name. Same literal and same\n // derivation as the L0 shim log's `tree=` (shim-audit-log.ts), so one grep spans both\n // streams: L0 carries tree without projectDir, L1 now carries both.\n `tree=${dotWebpieces.worktreeName(root) || 'primary'}`,\n // APPEND-ONLY, same spelling as the invocation line and the L0 shim log: which L0 fault\n // this was, or `-`.\n `fault=${decision.fault}`,\n ].join('\\t') + '\\n';\n fs.appendFileSync(logPath, line);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n }\n}\n\n/**\n * What the guard SAW on one invocation, captured up front and held until the outcome is known.\n * Data-only (per CLAUDE.md: classes for data, explicit construction).\n */\nexport class GuardInvocation {\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(\n public readonly root: string,\n public readonly timestamp: string,\n public readonly tool: string,\n public readonly target: string,\n public readonly branch: string,\n public readonly sync: string,\n public readonly projectDir: string,\n ) {}\n}\n\n/**\n * The per-INVOCATION stream — `.webpieces/logs/calls/<writer>.log` (see LogStream for the writer\n * key), one line for EVERY guards-hook\n * call (allow or block, bash or file), unlike `L2-decisions/` which records only the calls a\n * rule actually judged. It captures the tool, the command/file, the live git branch, the async-written\n * main-sync-status.json snapshot (branch / merged / fork-point / conflict), and — since this class\n * replaced a bare log-and-forget function — HOW THE CALL ENDED.\n *\n * WHY IT IS TWO CALLS. The line used to be written the moment the hook started, so it could not carry\n * a verdict: the decision had not been made yet. Answering \"what happened to this call?\" therefore\n * meant joining this file against the L2 decision stream BY TIMESTAMP, which is exactly the kind of\n * reconstruction a log exists to make unnecessary. So {@link begin} now only CAPTURES (including the\n * git/cache reads, which must still happen while the hook is running), and {@link finish} — called\n * from the hook's single terminal boundary, emitAllow/emitDeny — writes the whole line once the\n * outcome is known. The two streams stay distinct in purpose: this one is \"every call and how it\n * ended\", the decision log remains \"every judgement and why\".\n *\n * Every error is swallowed: logging must never block or fail a hook.\n */\nexport class InvocationLog {\n private pending: GuardInvocation | null = null;\n\n /**\n * Capture the context of one invocation. `cwd` is the AI's working dir; the repo root that owns\n * `.webpieces` is resolved from it. Writes NOTHING — {@link finish} does that.\n */\n begin(cwd: string, tool: string, target: string): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const root = new RepoRootFinder().resolveRepoRoot(cwd);\n const branch = branchForLog(root);\n // The cache is branch-keyed, so the entry to log is the one for the branch we are standing\n // on. 'unknown' (branchForLog's failure value) simply misses and logs 'sync=none'.\n const sync = summarizeSyncStatus(readMainSyncStatus(root, branch));\n this.pending = new GuardInvocation(root, new Date().toISOString(), tool, oneLine(target), branch, sync, claudeEnv.projectDirForLog());\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n }\n }\n\n /**\n * Write the captured line, now stamped with the outcome. A no-op when nothing was captured (the\n * 'rules' hook, or a terminal boundary reached before begin()), and it clears the pending entry so\n * a second emit cannot double-log.\n *\n * `rule` is the rule that blocked, or '-' when there is none; `fault` is the L0 fault code when this\n * call ended on one (S/C/Y — the JS-side faults), else '-'. FIELD ORDER IS APPEND-ONLY: the five\n * original fields keep their positions (cleanup automation mines this file), and `guards=` /\n * `rule=` / … / `fault=` are added at the end.\n */\n finish(verdict: Verdict, rule: string, fault: string = L0_FAULT_NONE): void {\n const invocation = this.pending;\n this.pending = null;\n if (invocation === null) return;\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const logsDir = dotWebpieces.logsFile(invocation.root, CALLS_STREAM);\n fs.mkdirSync(logsDir, { recursive: true });\n const logPath = path.join(logsDir, logStream.writerFile('.log'));\n rotateLogFile(logPath, path.join(logsDir, logStream.writerFile('.1.log')));\n\n const line = [\n `[${invocation.timestamp}]`,\n invocation.tool,\n invocation.target,\n `branch=${invocation.branch}`,\n invocation.sync,\n // `guards=`, NOT `verdict=`. This hook can only report on ITSELF. Claude Code runs all\n // three PreToolUse hooks IN PARALLEL, so the L-1 `guarantee-root.sh` process may deny a\n // call this one had no objection to, and neither can see the other's answer. Measured:\n // `cd <repo>/packages && ls` was DENIED by L-1 and recorded here three times as\n // `verdict=ALLOW`. The old field name promised an outcome it structurally cannot know;\n // the TRUE final action is the JOIN of this stream with `L-1-cd/`, keyed by the\n // identical writer name — which is what docs/tooling-logs.md now states.\n `guards=${verdict}`,\n `rule=${oneLine(rule) || '-'}`,\n // The tree the guard ACTED in, next to what Claude Code said the project was. Both, on\n // every line, because the diagnostic value is entirely in comparing them — see\n // ClaudeEnv for the open question this field exists to settle empirically.\n `root=${invocation.root}`,\n `projectDir=${invocation.projectDir}`,\n // See logGuardDecision: the short tree label, so `tree=primary` with a matching\n // projectDir reads as healthy at a glance and `tree=<worktree>` beside a projectDir\n // pointing at the primary is the straddle, without diffing two absolute paths.\n `tree=${dotWebpieces.worktreeName(invocation.root) || 'primary'}`,\n // WHICH L0 fault ended this call, in the same letters and the same field name the L0 sh\n // shim uses (the `L0-shim/` stream) — so ONE grep spans the whole trail.\n `fault=${fault}`,\n ].join('\\t') + '\\n';\n fs.appendFileSync(logPath, line);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n }\n }\n}\n\n// Process-wide instance: one hook process handles exactly one tool call, so a single pending entry is\n// the whole state there is. Module-scope (rather than DI) because the terminal boundary that flushes\n// it — emitAllow/emitDeny — is itself module-scope protocol code with no container in reach.\nexport const invocationLog = new InvocationLog();\n\n// One-field summary of main-sync-status.json for the invocation log: the branch the cache is FOR,\n// whether it is already merged (and its PR), fork-point presence, and conflict state — the signals a\n// cleanup step keys off. 'sync=none' when the cache has not been written yet (first call of a session).\nfunction summarizeSyncStatus(status: MainSyncStatus | null): string {\n if (status === null) return 'sync=none';\n const merged = status.branchAlreadyMerged ? `PR#${status.mergedPr !== '' ? status.mergedPr : '?'}` : 'no';\n return `sync=${status.branch} merged=${merged} fork=${String(status.hasForkPoint)} conflict=${String(status.conflict)} ts=${status.timestamp}`;\n}\n\n// Best-effort current branch for the log line. Returns 'unknown' on any failure (e.g. not a git\n// repo) — this is for display only, never for a control decision.\nexport function branchForLog(root: string): string {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return execSync('git rev-parse --abbrev-ref HEAD', {\n cwd: root,\n encoding: 'utf8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim() || 'unknown';\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return 'unknown';\n }\n}\n\n// Collapse newlines/tabs and cap length so one decision is always one log line.\nfunction oneLine(value: string): string {\n const flat = value.replace(/[\\t\\r\\n]+/g, ' ').trim();\n return flat.length <= MAX_TARGET_LEN ? flat : flat.slice(0, MAX_TARGET_LEN) + '…';\n}\n\nfunction rotateLogFile(logPath: string, prevPath: string): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const stat = fs.statSync(logPath);\n if (stat.size > MAX_LOG_BYTES) {\n if (fs.existsSync(prevPath)) fs.unlinkSync(prevPath);\n fs.renameSync(logPath, prevPath);\n }\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n }\n}\n"]}
1
+ {"version":3,"file":"decision-log.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/decision-log.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync } from 'child_process';\nimport * as fs from 'fs';\nimport * as path from 'path';\n\nimport { dotWebpieces, readMainSyncStatus, MainSyncStatus, RepoRootFinder, claudeEnv } from '@webpieces/rules-config';\nimport { L1_LOCATION_STREAM, L2_DECISIONS_STREAM, CALLS_STREAM } from './log-streams';\n\nimport { L0_FAULT_NONE } from './l0-fault-codes';\nimport { toError } from './to-error';\nimport { logStream } from './log-stream';\n\n// The SYNC decision log — what the synchronous hook DID on each invocation and WHY. Its companion is\n// the ASYNC log (the `async-refresh/` stream, written by the detached refresher in main-sync-log.ts). This\n// one records EVERY guard decision — allow, block, config-bypass, and the fail-open cases — and CITES\n// the async-written cache snapshot (`cache` field) that drove the decision, so a wrong allow/block is\n// traceable to a stale or missing async write. Writes to `.webpieces/logs/L2-decisions/<writer>.log` —\n// the LAYER is the directory, the WRITER is the file (see log-streams.ts and LogStream).\nconst MAX_LOG_BYTES = 512 * 1024; // 512 KB — rotate when exceeded (mirrors rejection-log)\nconst MAX_TARGET_LEN = 160;\n\n/**\n * THE ACTION CODEBOOK, as a type. These are the five actions GUARD_MATRIX.md numbers 1-5, and they\n * are the vocabulary EVERY layer reports in — so one grep spans L-1, L0, L1 and L2.\n *\n * The three distinctions this exists to make, none of which `'ALLOW' | 'BLOCK'` could:\n *\n * ALLOW no objection — the call was HANDED DOWN to the next layer. A layer saying ALLOW\n * is NOT saying the call ran: the layer below it, or the OTHER parallel hook, may\n * still deny. This is L1's `ACT_DOWN`.\n * ALLOW_EXEMPT out of scope by construction — allowed, and evaluation STOPS here. L1's\n * `ACT_EXEMPT`.\n * ALLOW_FAIL_OPEN state could not be established, so nothing was judged. Keeping this distinct\n * from ALLOW is the entire point of the type: a fail-open allow and a real allow\n * that look identical make it impossible to tell whether the guards are protecting\n * anything or quietly abstaining. It used to be a `' (fail-open)'` SUBSTRING on the\n * reason field, which is exactly why the abstentions were never countable.\n * BLOCK_AI_CURE blocked, and the printed cure is a command the AI can run itself.\n * BLOCK_HUMAN blocked, and it needs a human decision — or a delegation (spawn a subagent) that\n * the blocked agent cannot perform for itself.\n *\n * Hard cut, per CLAUDE.md: `'BLOCK'` is GONE rather than aliased, so every construction site fails to\n * compile and has to say which kind of block it is. Before, that question had exactly one wrong\n * answer available — silence.\n */\nexport type Verdict = 'ALLOW' | 'ALLOW_EXEMPT' | 'ALLOW_FAIL_OPEN' | 'BLOCK_AI_CURE' | 'BLOCK_HUMAN';\n\n/**\n * WHICH ROW of WHICH layer's decision table produced this line. Data-only → a class, per CLAUDE.md.\n *\n * `row` is the row NUMBER from the layer's row array (`L1_ROWS[i].num`) — the\n * same number the generated doc prints, because the doc is rendered from that same array. So a log\n * line joins to its matrix row BY NUMBER, and checking observed behaviour against the documented use\n * cases becomes a lookup rather than an investigation. `'-'` for a layer with no row array yet (L2).\n */\nexport class MatrixRef {\n constructor(readonly layer: string, readonly row: string) {}\n}\n\n/**\n * The layer tokens. `row` is `'-'` for a layer with no row array YET (L2 is the un-converted one, and\n * L0's faults are a table of letters rather than numbered rows) — but the LAYER is always named, so\n * `grep layer=L2` works today and the row fills in when L2 converts.\n *\n * These are REQUIRED at the constructor, not defaulted: a defaulted `MatrixRef` would make the\n * uncited case reachable by doing nothing and impossible to grep — the same defect this file's own\n * docblock argues against for `'BLOCK'`, where silence was the one wrong answer available.\n */\nexport const MATRIX_L0 = new MatrixRef('L0', '-');\nexport const MATRIX_L2 = new MatrixRef('L2', '-');\n\n// Data-only record of one guard decision (per CLAUDE.md: classes for data, not object literals).\n// `cache` summarizes the async-written main-sync-status.json that drove a feature-branch-guard\n// decision (branch/merged/conflict/fork + the cache timestamp), or '-' when no cache was consulted\n// (bash guards, on-main, config-bypass).\nexport class GuardDecision {\n rule: string;\n tool: string;\n target: string; // file path (file guards) or the bash command (bash guards)\n branch: string;\n verdict: Verdict;\n reason: string;\n cache: string;\n /**\n * The L0 fault this decision IS, in the codebook's letter (core/l0-fault-codes.ts), or `-` for an\n * ordinary rule decision. The `sh` shim has always stamped `fault=` on its own stream; the three\n * JS-side faults (S/C/Y) reached this one with no label at all, so `grep fault=S` found nothing\n * even while an S storm was blocking every call.\n */\n fault: string;\n /** Which layer + row decided this. See MatrixRef — it is what joins a log line to the doc. */\n matrix: MatrixRef;\n\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(rule: string, tool: string, target: string, branch: string, verdict: Verdict, reason: string, cache: string = '-', fault: string, matrix: MatrixRef) {\n this.rule = rule;\n this.tool = tool;\n this.target = target;\n this.branch = branch;\n this.verdict = verdict;\n this.reason = reason;\n this.cache = cache;\n this.fault = fault;\n this.matrix = matrix;\n }\n}\n\n/**\n * Append one tab-separated line per L2 decision to `.webpieces/logs/L2-decisions/<writer>.log`, where\n * <writer> is LogStream's `<sessionId>-<agentId|coordinator>-<hook>` key (a caller that never\n * identified renders as `unknown-coordinator-hook` — there is no un-keyed name).\n * `root` is\n * the repo/workspace root that holds `.webpieces` (callers pass workspaceRoot, or a\n * RepoRootFinder-resolved root at the pre-load config-bypass site — never a raw cwd, so a bypass\n * logged from a subdir never scatters a stray `.webpieces`). Swallows all errors — logging must never\n * block or fail a hook.\n */\n// webpieces-disable no-function-outside-class -- the module-scope writer this log has always been, beside branchForLog/oneLine/rotateLogFile; it must stay callable from a tree too broken to build a DI container\nexport function logGuardDecision(root: string, decision: GuardDecision): void {\n appendDecision(root, L2_DECISIONS_STREAM, decision);\n}\n\n/**\n * The L1 stream — `.webpieces/logs/L1-location/<writer>.log`.\n *\n * L1 had NO stream. Its three blocking paths wrote into L2's file under an implementation name\n * (`force-to-root`, `trinary-version-skew`, `cd-must-be-first`), and its NON-blocking outcomes —\n * the exempt row and the three hand-down rows — wrote nothing at all. So \"L1 had no objection\" was\n * unobservable, and \"show me every L1 decision\" had no answer: L1 existed in the trail only as the\n * `root=` / `projectDir=` / `tree=` columns stapled onto somebody else's line.\n *\n * A SIBLING rather than a `base` parameter on logGuardDecision, deliberately: that signature is what\n * the process-wide `logStream` singleton exists to keep unchanged (see LogStream's docblock), and\n * `INVOCATION_LOG_FILE` already establishes the pattern of a second stream owning its own name in\n * this same module.\n */\n// webpieces-disable no-function-outside-class -- sibling of logGuardDecision, same module-scope writer shape and same reason\nexport function logL1Decision(root: string, decision: GuardDecision): void {\n appendDecision(root, L1_LOCATION_STREAM, decision);\n}\n\n// The one appender both streams share. `streamDir` is the LAYER; the writer key inside it is\n// logStream's session/agent/hook, which is what keeps one writer per file.\n// webpieces-disable no-function-outside-class -- the shared body of the two module-scope writers above\nfunction appendDecision(root: string, streamDir: string, decision: GuardDecision): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const timestamp = new Date().toISOString();\n // LOCAL scope: a guard decision belongs to the tree it judged. WHO made the call is answered\n // by the filename, which logStream names with session/agent/hook.\n const logsDir = dotWebpieces.logsFile(root, streamDir);\n fs.mkdirSync(logsDir, { recursive: true });\n\n const logPath = path.join(logsDir, logStream.writerFile('.log'));\n rotateLogFile(logPath, path.join(logsDir, logStream.writerFile('.1.log')));\n\n const line = [\n `[${timestamp}]`,\n decision.verdict,\n decision.tool,\n oneLine(decision.target),\n decision.branch,\n decision.rule,\n oneLine(decision.reason),\n oneLine(decision.cache),\n // WHICH ROW of WHICH table decided this. The directory already carries the layer, but a\n // line quoted out of its file must still say what judged it — and `row=` is the join key\n // to the generated doc, which is the point of the whole exercise.\n `layer=${decision.matrix.layer}`,\n `row=${decision.matrix.row}`,\n // The tree this decision was actually made against, and what Claude Code told the hook the\n // project was. Appended (never reordered) for the same reason as on the invocation line —\n // see ClaudeEnv: when these two disagree, that disagreement is the bug.\n `root=${root}`,\n `projectDir=${claudeEnv.projectDirForLog()}`,\n // git's name for that tree — `primary`, else the worktree name. Same literal and same\n // derivation as the L0 shim log's `tree=` (shim-audit-log.ts), so one grep spans both\n // streams: L0 carries tree without projectDir, L1 now carries both.\n `tree=${dotWebpieces.worktreeName(root) || 'primary'}`,\n // APPEND-ONLY, same spelling as the invocation line and the L0 shim log: which L0 fault\n // this was, or `-`.\n `fault=${decision.fault}`,\n ].join('\\t') + '\\n';\n fs.appendFileSync(logPath, line);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n }\n}\n\n/**\n * What the guard SAW on one invocation, captured up front and held until the outcome is known.\n * Data-only (per CLAUDE.md: classes for data, explicit construction).\n */\nexport class GuardInvocation {\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(\n public readonly root: string,\n public readonly timestamp: string,\n public readonly tool: string,\n public readonly target: string,\n public readonly branch: string,\n public readonly sync: string,\n public readonly projectDir: string,\n ) {}\n}\n\n/**\n * The per-INVOCATION stream — `.webpieces/logs/calls/<writer>.log` (see LogStream for the writer\n * key), one line for EVERY guards-hook\n * call (allow or block, bash or file), unlike `L2-decisions/` which records only the calls a\n * rule actually judged. It captures the tool, the command/file, the live git branch, the async-written\n * main-sync-status.json snapshot (branch / merged / fork-point / conflict), and — since this class\n * replaced a bare log-and-forget function — HOW THE CALL ENDED.\n *\n * WHY IT IS TWO CALLS. The line used to be written the moment the hook started, so it could not carry\n * a verdict: the decision had not been made yet. Answering \"what happened to this call?\" therefore\n * meant joining this file against the L2 decision stream BY TIMESTAMP, which is exactly the kind of\n * reconstruction a log exists to make unnecessary. So {@link begin} now only CAPTURES (including the\n * git/cache reads, which must still happen while the hook is running), and {@link finish} — called\n * from the hook's single terminal boundary, emitAllow/emitDeny — writes the whole line once the\n * outcome is known. The two streams stay distinct in purpose: this one is \"every call and how it\n * ended\", the decision log remains \"every judgement and why\".\n *\n * Every error is swallowed: logging must never block or fail a hook.\n */\nexport class InvocationLog {\n private pending: GuardInvocation | null = null;\n\n /**\n * Capture the context of one invocation. `cwd` is the AI's working dir; the repo root that owns\n * `.webpieces` is resolved from it. Writes NOTHING — {@link finish} does that.\n */\n begin(cwd: string, tool: string, target: string): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const root = new RepoRootFinder().resolveRepoRoot(cwd);\n const branch = branchForLog(root);\n // The cache is branch-keyed, so the entry to log is the one for the branch we are standing\n // on. 'unknown' (branchForLog's failure value) simply misses and logs 'sync=none'.\n const sync = summarizeSyncStatus(readMainSyncStatus(root, branch));\n this.pending = new GuardInvocation(root, new Date().toISOString(), tool, oneLine(target), branch, sync, claudeEnv.projectDirForLog());\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n }\n }\n\n /**\n * Write the captured line, now stamped with the outcome. A no-op when nothing was captured (the\n * 'rules' hook, or a terminal boundary reached before begin()), and it clears the pending entry so\n * a second emit cannot double-log.\n *\n * `rule` is the rule that blocked, or '-' when there is none; `fault` is the L0 fault code when this\n * call ended on one (S/C/Y — the JS-side faults), else '-'. FIELD ORDER IS APPEND-ONLY: the five\n * original fields keep their positions (cleanup automation mines this file), and `guards=` /\n * `rule=` / … / `fault=` are added at the end.\n */\n finish(verdict: Verdict, rule: string, fault: string = L0_FAULT_NONE): void {\n const invocation = this.pending;\n this.pending = null;\n if (invocation === null) return;\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const logsDir = dotWebpieces.logsFile(invocation.root, CALLS_STREAM);\n fs.mkdirSync(logsDir, { recursive: true });\n const logPath = path.join(logsDir, logStream.writerFile('.log'));\n rotateLogFile(logPath, path.join(logsDir, logStream.writerFile('.1.log')));\n\n const line = [\n `[${invocation.timestamp}]`,\n invocation.tool,\n invocation.target,\n `branch=${invocation.branch}`,\n invocation.sync,\n // `guards=`, NOT `verdict=`. This hook can only report on ITSELF. Claude Code runs all\n // its PreToolUse hooks IN PARALLEL, so another hook process may deny a call this one\n // had no objection to, and neither can see the other's answer. Measured under the\n // RETIRED three-hook form: `cd <repo>/packages && ls` was DENIED by L-1 and recorded\n // here three times as `verdict=ALLOW`. The old field name promised an outcome it\n // structurally cannot know, so the name stayed even though L-1 is gone: the two\n // surviving hooks still run in parallel and still cannot see each other.\n `guards=${verdict}`,\n `rule=${oneLine(rule) || '-'}`,\n // The tree the guard ACTED in, next to what Claude Code said the project was. Both, on\n // every line, because the diagnostic value is entirely in comparing them — see\n // ClaudeEnv for the open question this field exists to settle empirically.\n `root=${invocation.root}`,\n `projectDir=${invocation.projectDir}`,\n // See logGuardDecision: the short tree label, so `tree=primary` with a matching\n // projectDir reads as healthy at a glance and `tree=<worktree>` beside a projectDir\n // pointing at the primary is the straddle, without diffing two absolute paths.\n `tree=${dotWebpieces.worktreeName(invocation.root) || 'primary'}`,\n // WHICH L0 fault ended this call, in the same letters and the same field name the L0 sh\n // shim uses (the `L0-shim/` stream) — so ONE grep spans the whole trail.\n `fault=${fault}`,\n ].join('\\t') + '\\n';\n fs.appendFileSync(logPath, line);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n }\n }\n}\n\n// Process-wide instance: one hook process handles exactly one tool call, so a single pending entry is\n// the whole state there is. Module-scope (rather than DI) because the terminal boundary that flushes\n// it — emitAllow/emitDeny — is itself module-scope protocol code with no container in reach.\nexport const invocationLog = new InvocationLog();\n\n// One-field summary of main-sync-status.json for the invocation log: the branch the cache is FOR,\n// whether it is already merged (and its PR), fork-point presence, and conflict state — the signals a\n// cleanup step keys off. 'sync=none' when the cache has not been written yet (first call of a session).\nfunction summarizeSyncStatus(status: MainSyncStatus | null): string {\n if (status === null) return 'sync=none';\n const merged = status.branchAlreadyMerged ? `PR#${status.mergedPr !== '' ? status.mergedPr : '?'}` : 'no';\n return `sync=${status.branch} merged=${merged} fork=${String(status.hasForkPoint)} conflict=${String(status.conflict)} ts=${status.timestamp}`;\n}\n\n// Best-effort current branch for the log line. Returns 'unknown' on any failure (e.g. not a git\n// repo) — this is for display only, never for a control decision.\nexport function branchForLog(root: string): string {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return execSync('git rev-parse --abbrev-ref HEAD', {\n cwd: root,\n encoding: 'utf8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim() || 'unknown';\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return 'unknown';\n }\n}\n\n// Collapse newlines/tabs and cap length so one decision is always one log line.\nfunction oneLine(value: string): string {\n const flat = value.replace(/[\\t\\r\\n]+/g, ' ').trim();\n return flat.length <= MAX_TARGET_LEN ? flat : flat.slice(0, MAX_TARGET_LEN) + '…';\n}\n\nfunction rotateLogFile(logPath: string, prevPath: string): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const stat = fs.statSync(logPath);\n if (stat.size > MAX_LOG_BYTES) {\n if (fs.existsSync(prevPath)) fs.unlinkSync(prevPath);\n fs.renameSync(logPath, prevPath);\n }\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n }\n}\n"]}
@@ -6,7 +6,10 @@ import { CommandScanner } from './command-scan';
6
6
  * WHY it has to exist at all: the shell cwd a PreToolUse hook is handed does not tell you which tree
7
7
  * the command acts on. `cd` behaves TWO different ways, and both break a cwd-based guard:
8
8
  *
9
- * - A `cd` that stays INSIDE the session's working directory PERSISTS to later calls. So the cwd
9
+ * - IN THE MAIN SESSION ONLY, a `cd` that stays INSIDE the session's working directory PERSISTS to
10
+ * later calls. Claude Code documents this as a main-session property and states that "subagent
11
+ * sessions never carry over working directory changes" — measured true here, and the reason this
12
+ * rule must not be stated unconditionally to a reader who may be a subagent. So the cwd
10
13
  * can be a subdirectory of the governed root, left there by an unrelated command several turns
11
14
  * earlier — a relative path then resolves somewhere other than the root while still being in the
12
15
  * governed tree.
@@ -50,7 +53,7 @@ import { CommandScanner } from './command-scan';
50
53
  * else — so an agent worktree, which Claude Code checks out INSIDE the repo at
51
54
  * `<repo>/.claude/worktrees/agent-XXXX`, took that path, disagreed with `--show-toplevel`, and read as
52
55
  * FOREIGN. `foreign` is ALLOW_EXEMPT in runner.ts: every bash guard silently off, and
53
- * CoordinatorWorktreeGuard (which requires `kind === 'worktree'`) dead code, for exactly the worktrees
56
+ * the worktree guard (which requires `kind === 'worktree'`) dead code, for exactly the worktrees
54
57
  * the harness creates. A common-dir comparison answers the same for both placements, so there is no
55
58
  * inside/outside case left to get wrong.
56
59
  *
@@ -175,7 +175,7 @@ class EffectiveTreeResolver {
175
175
  return new TreeClassification('foreign', treeRoot);
176
176
  }
177
177
  // Home is `primary` whether the session was started in the clone or in a worktree — the split
178
- // CoordinatorWorktreeGuard exists to catch is standing in a tree OTHER than the one that governs
178
+ // VersionSyncGuard exists to catch is acting on a tree OTHER than the one that governs
179
179
  // you, and there is no split when they are the same directory.
180
180
  if (!dirs.isLinkedWorktree || sameDir(treeRoot, governedRoot)) {
181
181
  return new TreeClassification('primary', treeRoot);
@@ -1 +1 @@
1
- {"version":3,"file":"effective-tree.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/effective-tree.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\n\nimport { atRoot, dotWebpieces } from '@webpieces/rules-config';\n\nimport { CommandScanner } from './command-scan';\nimport { ShellSegmentScan } from './rules/shell-segment-scan';\n\n/**\n * WHICH TREE does a Bash command actually act on? The ONE resolver every bash guard and the\n * force-to-root check share.\n *\n * WHY it has to exist at all: the shell cwd a PreToolUse hook is handed does not tell you which tree\n * the command acts on. `cd` behaves TWO different ways, and both break a cwd-based guard:\n *\n * - A `cd` that stays INSIDE the session's working directory PERSISTS to later calls. So the cwd\n * can be a subdirectory of the governed root, left there by an unrelated command several turns\n * earlier — a relative path then resolves somewhere other than the root while still being in the\n * governed tree.\n * - A `cd` that LEAVES it is reset by the harness, which says so (`Shell cwd was reset to <root>`).\n * So an agent working in a linked worktree is back in the primary clone by the next call and must\n * write self-contained `cd <worktree> && …` commands — and the cwd the hook sees is the primary\n * clone, not the worktree the command targets.\n *\n * (Measured on 2026-08-02: `cd backlog && pwd` → `…/backlog`, then a bare `pwd` in a FRESH call →\n * still `…/backlog`. But `cd ../<linked-worktree> && pwd` → the worktree, then a bare `pwd` → back at\n * the primary clone. An earlier version of this comment asserted `cd` never persists; that was the\n * worktree case generalized. The conclusion below is unchanged — only the reason was wrong.)\n *\n * Either way a guard that reasons from the raw cwd judges the wrong tree. Three field sightings in\n * one session:\n * an `ls` of a path outside every repo blocked as \"this branch is merged\"; a version-drift cure\n * (`pnpm install`) that could not be typed from the directory that needed it; and a command aimed at\n * `/private/tmp` blocked because the PRIMARY clone's main was behind — with a remedy (`git pull` in\n * the primary clone) the agent had been explicitly forbidden to run.\n *\n * Two separate copies of the cwd logic used to exist (the runner's foreign-repo/excludePaths check\n * and force-to-root). They are both this class now: two resolvers WILL disagree about which tree you\n * are in, and a guard that disagrees with the guard beside it is worse than either being wrong.\n *\n * Resolution, in order:\n * 1. `effectiveCwd` — the leading run of `cd`/`pushd` in the command itself, resolved left to right.\n * 2. SAME REPO? `git rev-parse --git-common-dir` is identical for every checkout of one repo, so\n * comparing it against the governed root's answer is the whole test. Different → FOREIGN (a\n * nested clone under `repositories/**`), out of scope, hands-off. Not a git repo at all\n * (`cd /tmp && …`) → OUTSIDE: the guards still run — an absolute path back into the repo must\n * still be judged — but nothing the command names relative to `/tmp` is workspace content, which\n * is what ContentReadScan uses `effectiveCwd` for.\n * 3. WHICH CHECKOUT? `--show-toplevel` from `effectiveCwd`. A LINKED WORKTREE of the governed repo is\n * MANAGED — it is the same project, just another checkout — so guards run, keyed on THAT tree's\n * branch and its own state.\n * 4. PRIMARY OR LINKED? `gitDir !== commonDir`, git's own canonical test. The governed root itself is\n * always `primary`: it is home, whether the session was started in the clone or in a worktree.\n *\n * PLACEMENT IS NOT IDENTITY, and assuming it was is the bug this shape exists to prevent. classify()\n * used to short-circuit on \"is `effectiveCwd` inside the governed root?\" and never ask git anything\n * else — so an agent worktree, which Claude Code checks out INSIDE the repo at\n * `<repo>/.claude/worktrees/agent-XXXX`, took that path, disagreed with `--show-toplevel`, and read as\n * FOREIGN. `foreign` is ALLOW_EXEMPT in runner.ts: every bash guard silently off, and\n * CoordinatorWorktreeGuard (which requires `kind === 'worktree'`) dead code, for exactly the worktrees\n * the harness creates. A common-dir comparison answers the same for both placements, so there is no\n * inside/outside case left to get wrong.\n *\n * WHY THE GIT DIRS AND NOT ONE OF THE OTHER RESOLVERS — state-dir.ts's own header makes this argument\n * in full (\"Why `--git-dir` / `--git-common-dir`, and not one of the existing services\"); the short\n * version is that `WorktreeService` is a repo-wide ENUMERATION that fails SOFT to `[]` (i.e. fails open\n * into `foreign`, this bug) and `webpieces.config.json` walk-up is GOVERNANCE, not identity — it\n * deliberately climbs past a nested clone's `.git` to the outer config (repo-root.spec.ts pins that),\n * so identity built on it would hand the guards someone else's repo. This class asks DotWebpieces,\n * whose `rev-parse` pair is memoized per directory per process — it is on the hook's blocking path.\n */\n// L1's K dimension. 'primary' and 'worktree' are the same PROJECT, so every rule-scoped guard treats\n// them alike — guards/L1-location.md writes them as one value, `pw`. Exactly ONE guard separates them,\n// and on a dimension that is not the tree at all: CoordinatorWorktreeGuard blocks the COORDINATOR\n// (never a subagent) from working inside a linked worktree, because the coordinator's governance is\n// anchored at session start and does not follow a `cd`.\n//\n// 'outside' is produced below (git has no answer for the directory) and consumed NOWHERE, so a command in no git repo is\n// judged against governedRoot — a repo it is not in. guards/L1-location.md's \"Not done\" section explains why\n// exempting it must ship together with target-based jurisdiction, never alone.\n//\n// 'missing' is the directory that is NOT THERE — the worktree reaped out from under a live shell. It is\n// separate from 'outside' because the two used to be one `null` from git, and conflating them produced\n// the worst message this layer has emitted: \"you are in a subdirectory\", with a remedy that `cd`s back\n// into the deleted path. See MissingDirectoryGuard.\nexport type TreeKind = 'primary' | 'worktree' | 'foreign' | 'outside' | 'missing';\n\n/** Data-only (per CLAUDE.md, classes for data). */\nexport class EffectiveTree {\n /** The pre-`cd` cwd the hook was handed. For an agent in a worktree this is the primary clone. */\n readonly shellCwd: string;\n /** The directory the command really runs in, after its own leading `cd`/`pushd` run. */\n readonly effectiveCwd: string;\n /** The tree root to JUDGE: the owning worktree root, the foreign repo root, or the governed root. */\n readonly root: string;\n /** The root that owns webpieces.config.json — where config and excludePaths come from. */\n readonly governedRoot: string;\n readonly kind: TreeKind;\n /** The command acts on a tree other than the shell's own — messages must steer with `cd <root> &&`. */\n readonly redirected: boolean;\n\n constructor(shellCwd: string, effectiveCwd: string, root: string, governedRoot: string, kind: TreeKind) {\n this.shellCwd = shellCwd;\n this.effectiveCwd = effectiveCwd;\n this.root = root;\n this.governedRoot = governedRoot;\n this.kind = kind;\n this.redirected = path.resolve(root) !== path.resolve(shellCwd);\n }\n}\n\nexport class EffectiveTreeResolver {\n private readonly shell: ShellSegmentScan;\n\n constructor(private readonly scanner: CommandScanner = new CommandScanner()) {\n this.shell = new ShellSegmentScan(scanner);\n }\n\n resolve(command: string, shellCwd: string, governedRoot: string): EffectiveTree {\n const effectiveCwd = this.effectiveCwd(command, shellCwd);\n const kindAndRoot = this.classify(effectiveCwd, governedRoot);\n return new EffectiveTree(shellCwd, effectiveCwd, kindAndRoot.root, governedRoot, kindAndRoot.kind);\n }\n\n /**\n * The cwd a command actually runs from, resolving a LEADING run of `cd`/`pushd` in the command.\n *\n * ONLY a leading run counts. Once a non-cd command appears it has ALREADY run in the current dir,\n * so a later `cd` must not retroactively pull it out of scope — otherwise a trailing\n * `… && cd <exempt-tree>` would exempt the WHOLE line, smuggling a root-level `git push` past the\n * guards. `cd a && cd b && git …` resolves left to right, matching the shell.\n *\n * Segmentation is ShellSegmentScan's (over CommandScanner), so quoting is handled exactly as the\n * guards handle it: `echo \"cd sub && git push\"` is ONE opaque segment whose first word is `echo`,\n * so the quoted `cd` is never picked up and cannot be weaponised into a scope escape.\n */\n effectiveCwd(command: string, shellCwd: string): string {\n let effective = shellCwd;\n for (const segment of this.scanner.commandSegments(command)) {\n const words = this.shell.effectiveWords(segment);\n if (words[0] !== 'cd' && words[0] !== 'pushd') break;\n if (words[1] !== undefined) effective = path.resolve(effective, words[1]);\n }\n return effective;\n }\n\n /**\n * Is this command's location UNAMBIGUOUS — or should it be rejected outright? Returns the reason a\n * `cd` in it cannot be resolved, or null when there is nothing wrong.\n *\n * `cd <literal path> && <work>` is the ONE shape that moves where a command is judged, because it\n * is the one shape effectiveCwd() can resolve. Everything else fell back to the shell cwd, which\n * is SAFE (fails closed, nothing smuggled) but silent, and silent is what cost the time:\n *\n * `cd \"$DIR\" && git push` — `path.resolve(cwd, '$DIR')` is a directory that does not\n * exist, not the tree that was meant.\n * `D=/x; cd \"$D\"; git push` — a bare `VAR=value` segment tokenizes to NO words, so the\n * leading run ends before the `cd` is reached.\n * `git fetch && cd /x && git push` — bash really does run the push in /x; the guard judged it\n * from the shell cwd and blocked it.\n * `git push && cd /x` — the push already ran at the root, whatever the trailing\n * `cd` says.\n *\n * Naming these in the block MESSAGE (the previous two PRs) helped, but left the agent to notice a\n * paragraph on an otherwise-normal block. Rejecting is the simpler contract and the one the human\n * asked for: ONE legal shape, everything else refused with the fix spelled out. No verdict changes\n * — every command this rejects was already being judged from the shell cwd — so this trades a\n * silent misdirect for a loud one-line rule, and deletes the near-miss taxonomy entirely.\n *\n * Expanding `$VAR` here is still the fix NOT taken. The resolver decides ONE location for a whole\n * line, so a `cd` that counts retroactively is exactly the `… && cd <exempt-tree>` scope escape.\n *\n * A command containing a HEREDOC (`<<`) is exempt: CommandScanner does not model heredoc bodies,\n * so a commit message or doc that merely CONTAINS `cd /x && …` tokenizes as if it were code, and\n * rejecting that would block ordinary writing. Skipping the rejection cannot open a hole — the\n * location still falls back to the shell cwd exactly as before.\n */\n misplacedCd(command: string): string | null {\n if (HEREDOC.test(command)) return null;\n\n const segments = this.scanner.commandSegments(command).map(\n (segment: string): readonly string[] => this.shell.effectiveWords(segment));\n\n // The leading run effectiveCwd() actually consumed — a `cd` at or after this index did not count.\n let consumed = 0;\n while (consumed < segments.length && isCd(segments[consumed])) consumed++;\n\n for (let i = 0; i < segments.length; i++) {\n if (!isCd(segments[i])) continue;\n if (i >= consumed) {\n return segments.slice(0, i).some(isRealCommand)\n ? 'it comes after another command — a `cd` only counts at the FRONT of the line'\n : 'a `VAR=…` assignment precedes it, which ends the scan';\n }\n const target = segments[i][1];\n if (target !== undefined && VARIABLE_TARGET.test(target)) {\n return 'its target is not a literal path (a `$VAR`, `~` or `$(…)` the guard cannot expand)';\n }\n }\n return null;\n }\n\n /**\n * The remedy for a command judged in the wrong directory — `cd '<root>' && <the work>`, with the\n * command's OWN leading `cd` run REPLACED rather than prefixed.\n *\n * A block's remedy must not leave the block's condition true. `atRoot()` alone prefixes, and\n * `effectiveCwd()` resolves the leading run of `cd`s LEFT TO RIGHT — so prefixing `cd '<root>' &&`\n * onto `cd <elsewhere> && git status` still lands in `<elsewhere>`, the identical block fires on\n * the remedy, and the retry prints it with the prefix doubled, then tripled. Structurally\n * non-convergent, and observed in the field against an agent worktree.\n *\n * Only the LEADING run is dropped, because only the leading run moved where the command was judged.\n * A mid-line `cd` is part of the work and is carried through untouched (it is separately rejected by\n * `misplacedCd`).\n */\n remedyAtRoot(root: string, command: string): string {\n return atRoot(root, this.withoutLeadingCds(command));\n }\n\n private withoutLeadingCds(command: string): string {\n let rest = command;\n for (const segment of this.scanner.commandSegments(command)) {\n const words = this.shell.effectiveWords(segment);\n if (words[0] !== 'cd' && words[0] !== 'pushd') break;\n const at = rest.indexOf(segment);\n if (at < 0) break;\n rest = rest.slice(at + segment.length).replace(LEADING_SEPARATOR, '');\n }\n // A line that is NOTHING but `cd`s has no work to steer; hand it back whole rather than emit an\n // empty remedy.\n return rest.trim() === '' ? command : rest.trim();\n }\n\n private classify(effectiveCwd: string, governedRoot: string): TreeClassification {\n // GONE, not merely un-gitted. git answers `null` for both \"not a repo\" and \"no such directory\",\n // and collapsing the two is what made a reaped worktree read as an ordinary subdirectory of the\n // governed root — with a remedy that `cd`s straight back into the deleted path. One statSync,\n // ahead of the git calls, keeps them apart. The root is the GOVERNED root because that is the\n // only tree left to steer anyone to.\n if (!fs.existsSync(effectiveCwd)) return new TreeClassification('missing', governedRoot);\n\n const dirs = dotWebpieces.gitDirs(effectiveCwd);\n // Not a git repo at all. Inside the governed tree that can only be a directory git declined to\n // answer for, so it stays `primary` exactly as before; outside it is the `cd /tmp && …` case.\n if (dirs === null) {\n return this.isInside(effectiveCwd, governedRoot)\n ? new TreeClassification('primary', governedRoot)\n : new TreeClassification('outside', governedRoot);\n }\n\n const treeRoot = dotWebpieces.treeRoot(effectiveCwd) ?? governedRoot;\n const ours = dotWebpieces.gitDirs(governedRoot);\n // ONE test for \"is this our repo\": the shared git dir. It is identical for every checkout of one\n // repo and different for a nested clone, wherever either happens to sit on disk.\n if (ours === null || !sameDir(dirs.commonDir, ours.commonDir)) {\n return new TreeClassification('foreign', treeRoot);\n }\n\n // Home is `primary` whether the session was started in the clone or in a worktree — the split\n // CoordinatorWorktreeGuard exists to catch is standing in a tree OTHER than the one that governs\n // you, and there is no split when they are the same directory.\n if (!dirs.isLinkedWorktree || sameDir(treeRoot, governedRoot)) {\n return new TreeClassification('primary', treeRoot);\n }\n return new TreeClassification('worktree', treeRoot);\n }\n\n /** Is `dir` the directory `root` itself, or somewhere beneath it? Pure path math, no filesystem. */\n private isInside(dir: string, root: string): boolean {\n const relative = path.relative(path.resolve(root), path.resolve(dir));\n return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));\n }\n}\n\n// The two segment shapes unresolvedCd() sorts by. A segment with NO words at all is a bare `VAR=value`\n// assignment (CommandScanner strips assignments as command prefixes), which is neither.\n// webpieces-disable no-function-outside-class -- pure predicates over one segment's words, siblings of the module-scope helpers below\nfunction isCd(words: readonly string[]): boolean {\n return words[0] === 'cd' || words[0] === 'pushd';\n}\n\n// webpieces-disable no-function-outside-class -- sibling of isCd()\nfunction isRealCommand(words: readonly string[]): boolean {\n return words.length > 0 && !isCd(words);\n}\n\n// `<<EOF` / `<<'EOF'` / `<<-EOF`. NOT `<` or `<<<` alone — a herestring has no multi-line body, so it\n// cannot carry prose that tokenizes as commands.\nconst HEREDOC = /<<-?\\s*['\"]?[A-Za-z_]/;\n\n// A `cd` target that is not a literal path: `$DIR`, `${DIR}`, `~`, or a `$(…)`/backtick substitution.\n// `~` is here because path.resolve() does not expand it either — the shell does, and the hook never\n// sees a shell.\nconst VARIABLE_TARGET = /[$`]|^~/;\n\n// The separator joining a leading `cd` to what follows it, stripped when withoutLeadingCds() drops the\n// `cd`. `&&`, `||`, `;` and a bare newline are the shapes CommandScanner splits a leading run on.\nconst LEADING_SEPARATOR = /^\\s*(?:&&|\\|\\||;|\\n)\\s*/;\n\n/** Data-only carrier for the two values classify() decides together. */\nclass TreeClassification {\n readonly kind: TreeKind;\n readonly root: string;\n\n constructor(kind: TreeKind, root: string) {\n this.kind = kind;\n this.root = root;\n }\n}\n\n/**\n * The steering prefix every remedy needs, re-exported from @webpieces/rules-config so the guards, the\n * message builders and pr-gate's worktree notices all emit the IDENTICAL string — including the single\n * quotes that keep it runnable when the repo path contains a space. See atRoot's own header for why the\n * quotes are single and never double.\n */\nexport { atRoot } from '@webpieces/rules-config';\n\n// Two absolute paths naming the same directory. There is no filesystem access here — both sides are\n// already git's own answers or a resolved root.\n// webpieces-disable no-function-outside-class -- sibling of atRoot(); this module is the resolver plus its pure helpers\nfunction sameDir(a: string, b: string): boolean {\n return path.resolve(a) === path.resolve(b);\n}\n"]}
1
+ {"version":3,"file":"effective-tree.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/effective-tree.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\n\nimport { atRoot, dotWebpieces } from '@webpieces/rules-config';\n\nimport { CommandScanner } from './command-scan';\nimport { ShellSegmentScan } from './rules/shell-segment-scan';\n\n/**\n * WHICH TREE does a Bash command actually act on? The ONE resolver every bash guard and the\n * force-to-root check share.\n *\n * WHY it has to exist at all: the shell cwd a PreToolUse hook is handed does not tell you which tree\n * the command acts on. `cd` behaves TWO different ways, and both break a cwd-based guard:\n *\n * - IN THE MAIN SESSION ONLY, a `cd` that stays INSIDE the session's working directory PERSISTS to\n * later calls. Claude Code documents this as a main-session property and states that \"subagent\n * sessions never carry over working directory changes\" — measured true here, and the reason this\n * rule must not be stated unconditionally to a reader who may be a subagent. So the cwd\n * can be a subdirectory of the governed root, left there by an unrelated command several turns\n * earlier — a relative path then resolves somewhere other than the root while still being in the\n * governed tree.\n * - A `cd` that LEAVES it is reset by the harness, which says so (`Shell cwd was reset to <root>`).\n * So an agent working in a linked worktree is back in the primary clone by the next call and must\n * write self-contained `cd <worktree> && …` commands — and the cwd the hook sees is the primary\n * clone, not the worktree the command targets.\n *\n * (Measured on 2026-08-02: `cd backlog && pwd` → `…/backlog`, then a bare `pwd` in a FRESH call →\n * still `…/backlog`. But `cd ../<linked-worktree> && pwd` → the worktree, then a bare `pwd` → back at\n * the primary clone. An earlier version of this comment asserted `cd` never persists; that was the\n * worktree case generalized. The conclusion below is unchanged — only the reason was wrong.)\n *\n * Either way a guard that reasons from the raw cwd judges the wrong tree. Three field sightings in\n * one session:\n * an `ls` of a path outside every repo blocked as \"this branch is merged\"; a version-drift cure\n * (`pnpm install`) that could not be typed from the directory that needed it; and a command aimed at\n * `/private/tmp` blocked because the PRIMARY clone's main was behind — with a remedy (`git pull` in\n * the primary clone) the agent had been explicitly forbidden to run.\n *\n * Two separate copies of the cwd logic used to exist (the runner's foreign-repo/excludePaths check\n * and force-to-root). They are both this class now: two resolvers WILL disagree about which tree you\n * are in, and a guard that disagrees with the guard beside it is worse than either being wrong.\n *\n * Resolution, in order:\n * 1. `effectiveCwd` — the leading run of `cd`/`pushd` in the command itself, resolved left to right.\n * 2. SAME REPO? `git rev-parse --git-common-dir` is identical for every checkout of one repo, so\n * comparing it against the governed root's answer is the whole test. Different → FOREIGN (a\n * nested clone under `repositories/**`), out of scope, hands-off. Not a git repo at all\n * (`cd /tmp && …`) → OUTSIDE: the guards still run — an absolute path back into the repo must\n * still be judged — but nothing the command names relative to `/tmp` is workspace content, which\n * is what ContentReadScan uses `effectiveCwd` for.\n * 3. WHICH CHECKOUT? `--show-toplevel` from `effectiveCwd`. A LINKED WORKTREE of the governed repo is\n * MANAGED — it is the same project, just another checkout — so guards run, keyed on THAT tree's\n * branch and its own state.\n * 4. PRIMARY OR LINKED? `gitDir !== commonDir`, git's own canonical test. The governed root itself is\n * always `primary`: it is home, whether the session was started in the clone or in a worktree.\n *\n * PLACEMENT IS NOT IDENTITY, and assuming it was is the bug this shape exists to prevent. classify()\n * used to short-circuit on \"is `effectiveCwd` inside the governed root?\" and never ask git anything\n * else — so an agent worktree, which Claude Code checks out INSIDE the repo at\n * `<repo>/.claude/worktrees/agent-XXXX`, took that path, disagreed with `--show-toplevel`, and read as\n * FOREIGN. `foreign` is ALLOW_EXEMPT in runner.ts: every bash guard silently off, and\n * the worktree guard (which requires `kind === 'worktree'`) dead code, for exactly the worktrees\n * the harness creates. A common-dir comparison answers the same for both placements, so there is no\n * inside/outside case left to get wrong.\n *\n * WHY THE GIT DIRS AND NOT ONE OF THE OTHER RESOLVERS — state-dir.ts's own header makes this argument\n * in full (\"Why `--git-dir` / `--git-common-dir`, and not one of the existing services\"); the short\n * version is that `WorktreeService` is a repo-wide ENUMERATION that fails SOFT to `[]` (i.e. fails open\n * into `foreign`, this bug) and `webpieces.config.json` walk-up is GOVERNANCE, not identity — it\n * deliberately climbs past a nested clone's `.git` to the outer config (repo-root.spec.ts pins that),\n * so identity built on it would hand the guards someone else's repo. This class asks DotWebpieces,\n * whose `rev-parse` pair is memoized per directory per process — it is on the hook's blocking path.\n */\n// L1's K dimension. 'primary' and 'worktree' are the same PROJECT, so every rule-scoped guard treats\n// them alike — guards/L1-location.md writes them as one value, `pw`. Exactly ONE guard separates them,\n// and on a dimension read OFF the tree itself: VersionSyncGuard blocks work in a linked worktree whose\n// @webpieces pin disagrees with the MAIN tree's, because the main tree's binary is what judges it.\n//\n// 'outside' is produced below (git has no answer for the directory) and consumed NOWHERE, so a command in no git repo is\n// judged against governedRoot — a repo it is not in. guards/L1-location.md's \"Not done\" section explains why\n// exempting it must ship together with target-based jurisdiction, never alone.\n//\n// 'missing' is the directory that is NOT THERE — the worktree reaped out from under a live shell. It is\n// separate from 'outside' because the two used to be one `null` from git, and conflating them produced\n// the worst message this layer has emitted: \"you are in a subdirectory\", with a remedy that `cd`s back\n// into the deleted path. See MissingDirectoryGuard.\nexport type TreeKind = 'primary' | 'worktree' | 'foreign' | 'outside' | 'missing';\n\n/** Data-only (per CLAUDE.md, classes for data). */\nexport class EffectiveTree {\n /** The pre-`cd` cwd the hook was handed. For an agent in a worktree this is the primary clone. */\n readonly shellCwd: string;\n /** The directory the command really runs in, after its own leading `cd`/`pushd` run. */\n readonly effectiveCwd: string;\n /** The tree root to JUDGE: the owning worktree root, the foreign repo root, or the governed root. */\n readonly root: string;\n /** The root that owns webpieces.config.json — where config and excludePaths come from. */\n readonly governedRoot: string;\n readonly kind: TreeKind;\n /** The command acts on a tree other than the shell's own — messages must steer with `cd <root> &&`. */\n readonly redirected: boolean;\n\n constructor(shellCwd: string, effectiveCwd: string, root: string, governedRoot: string, kind: TreeKind) {\n this.shellCwd = shellCwd;\n this.effectiveCwd = effectiveCwd;\n this.root = root;\n this.governedRoot = governedRoot;\n this.kind = kind;\n this.redirected = path.resolve(root) !== path.resolve(shellCwd);\n }\n}\n\nexport class EffectiveTreeResolver {\n private readonly shell: ShellSegmentScan;\n\n constructor(private readonly scanner: CommandScanner = new CommandScanner()) {\n this.shell = new ShellSegmentScan(scanner);\n }\n\n resolve(command: string, shellCwd: string, governedRoot: string): EffectiveTree {\n const effectiveCwd = this.effectiveCwd(command, shellCwd);\n const kindAndRoot = this.classify(effectiveCwd, governedRoot);\n return new EffectiveTree(shellCwd, effectiveCwd, kindAndRoot.root, governedRoot, kindAndRoot.kind);\n }\n\n /**\n * The cwd a command actually runs from, resolving a LEADING run of `cd`/`pushd` in the command.\n *\n * ONLY a leading run counts. Once a non-cd command appears it has ALREADY run in the current dir,\n * so a later `cd` must not retroactively pull it out of scope — otherwise a trailing\n * `… && cd <exempt-tree>` would exempt the WHOLE line, smuggling a root-level `git push` past the\n * guards. `cd a && cd b && git …` resolves left to right, matching the shell.\n *\n * Segmentation is ShellSegmentScan's (over CommandScanner), so quoting is handled exactly as the\n * guards handle it: `echo \"cd sub && git push\"` is ONE opaque segment whose first word is `echo`,\n * so the quoted `cd` is never picked up and cannot be weaponised into a scope escape.\n */\n effectiveCwd(command: string, shellCwd: string): string {\n let effective = shellCwd;\n for (const segment of this.scanner.commandSegments(command)) {\n const words = this.shell.effectiveWords(segment);\n if (words[0] !== 'cd' && words[0] !== 'pushd') break;\n if (words[1] !== undefined) effective = path.resolve(effective, words[1]);\n }\n return effective;\n }\n\n /**\n * Is this command's location UNAMBIGUOUS — or should it be rejected outright? Returns the reason a\n * `cd` in it cannot be resolved, or null when there is nothing wrong.\n *\n * `cd <literal path> && <work>` is the ONE shape that moves where a command is judged, because it\n * is the one shape effectiveCwd() can resolve. Everything else fell back to the shell cwd, which\n * is SAFE (fails closed, nothing smuggled) but silent, and silent is what cost the time:\n *\n * `cd \"$DIR\" && git push` — `path.resolve(cwd, '$DIR')` is a directory that does not\n * exist, not the tree that was meant.\n * `D=/x; cd \"$D\"; git push` — a bare `VAR=value` segment tokenizes to NO words, so the\n * leading run ends before the `cd` is reached.\n * `git fetch && cd /x && git push` — bash really does run the push in /x; the guard judged it\n * from the shell cwd and blocked it.\n * `git push && cd /x` — the push already ran at the root, whatever the trailing\n * `cd` says.\n *\n * Naming these in the block MESSAGE (the previous two PRs) helped, but left the agent to notice a\n * paragraph on an otherwise-normal block. Rejecting is the simpler contract and the one the human\n * asked for: ONE legal shape, everything else refused with the fix spelled out. No verdict changes\n * — every command this rejects was already being judged from the shell cwd — so this trades a\n * silent misdirect for a loud one-line rule, and deletes the near-miss taxonomy entirely.\n *\n * Expanding `$VAR` here is still the fix NOT taken. The resolver decides ONE location for a whole\n * line, so a `cd` that counts retroactively is exactly the `… && cd <exempt-tree>` scope escape.\n *\n * A command containing a HEREDOC (`<<`) is exempt: CommandScanner does not model heredoc bodies,\n * so a commit message or doc that merely CONTAINS `cd /x && …` tokenizes as if it were code, and\n * rejecting that would block ordinary writing. Skipping the rejection cannot open a hole — the\n * location still falls back to the shell cwd exactly as before.\n */\n misplacedCd(command: string): string | null {\n if (HEREDOC.test(command)) return null;\n\n const segments = this.scanner.commandSegments(command).map(\n (segment: string): readonly string[] => this.shell.effectiveWords(segment));\n\n // The leading run effectiveCwd() actually consumed — a `cd` at or after this index did not count.\n let consumed = 0;\n while (consumed < segments.length && isCd(segments[consumed])) consumed++;\n\n for (let i = 0; i < segments.length; i++) {\n if (!isCd(segments[i])) continue;\n if (i >= consumed) {\n return segments.slice(0, i).some(isRealCommand)\n ? 'it comes after another command — a `cd` only counts at the FRONT of the line'\n : 'a `VAR=…` assignment precedes it, which ends the scan';\n }\n const target = segments[i][1];\n if (target !== undefined && VARIABLE_TARGET.test(target)) {\n return 'its target is not a literal path (a `$VAR`, `~` or `$(…)` the guard cannot expand)';\n }\n }\n return null;\n }\n\n /**\n * The remedy for a command judged in the wrong directory — `cd '<root>' && <the work>`, with the\n * command's OWN leading `cd` run REPLACED rather than prefixed.\n *\n * A block's remedy must not leave the block's condition true. `atRoot()` alone prefixes, and\n * `effectiveCwd()` resolves the leading run of `cd`s LEFT TO RIGHT — so prefixing `cd '<root>' &&`\n * onto `cd <elsewhere> && git status` still lands in `<elsewhere>`, the identical block fires on\n * the remedy, and the retry prints it with the prefix doubled, then tripled. Structurally\n * non-convergent, and observed in the field against an agent worktree.\n *\n * Only the LEADING run is dropped, because only the leading run moved where the command was judged.\n * A mid-line `cd` is part of the work and is carried through untouched (it is separately rejected by\n * `misplacedCd`).\n */\n remedyAtRoot(root: string, command: string): string {\n return atRoot(root, this.withoutLeadingCds(command));\n }\n\n private withoutLeadingCds(command: string): string {\n let rest = command;\n for (const segment of this.scanner.commandSegments(command)) {\n const words = this.shell.effectiveWords(segment);\n if (words[0] !== 'cd' && words[0] !== 'pushd') break;\n const at = rest.indexOf(segment);\n if (at < 0) break;\n rest = rest.slice(at + segment.length).replace(LEADING_SEPARATOR, '');\n }\n // A line that is NOTHING but `cd`s has no work to steer; hand it back whole rather than emit an\n // empty remedy.\n return rest.trim() === '' ? command : rest.trim();\n }\n\n private classify(effectiveCwd: string, governedRoot: string): TreeClassification {\n // GONE, not merely un-gitted. git answers `null` for both \"not a repo\" and \"no such directory\",\n // and collapsing the two is what made a reaped worktree read as an ordinary subdirectory of the\n // governed root — with a remedy that `cd`s straight back into the deleted path. One statSync,\n // ahead of the git calls, keeps them apart. The root is the GOVERNED root because that is the\n // only tree left to steer anyone to.\n if (!fs.existsSync(effectiveCwd)) return new TreeClassification('missing', governedRoot);\n\n const dirs = dotWebpieces.gitDirs(effectiveCwd);\n // Not a git repo at all. Inside the governed tree that can only be a directory git declined to\n // answer for, so it stays `primary` exactly as before; outside it is the `cd /tmp && …` case.\n if (dirs === null) {\n return this.isInside(effectiveCwd, governedRoot)\n ? new TreeClassification('primary', governedRoot)\n : new TreeClassification('outside', governedRoot);\n }\n\n const treeRoot = dotWebpieces.treeRoot(effectiveCwd) ?? governedRoot;\n const ours = dotWebpieces.gitDirs(governedRoot);\n // ONE test for \"is this our repo\": the shared git dir. It is identical for every checkout of one\n // repo and different for a nested clone, wherever either happens to sit on disk.\n if (ours === null || !sameDir(dirs.commonDir, ours.commonDir)) {\n return new TreeClassification('foreign', treeRoot);\n }\n\n // Home is `primary` whether the session was started in the clone or in a worktree — the split\n // VersionSyncGuard exists to catch is acting on a tree OTHER than the one that governs\n // you, and there is no split when they are the same directory.\n if (!dirs.isLinkedWorktree || sameDir(treeRoot, governedRoot)) {\n return new TreeClassification('primary', treeRoot);\n }\n return new TreeClassification('worktree', treeRoot);\n }\n\n /** Is `dir` the directory `root` itself, or somewhere beneath it? Pure path math, no filesystem. */\n private isInside(dir: string, root: string): boolean {\n const relative = path.relative(path.resolve(root), path.resolve(dir));\n return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));\n }\n}\n\n// The two segment shapes unresolvedCd() sorts by. A segment with NO words at all is a bare `VAR=value`\n// assignment (CommandScanner strips assignments as command prefixes), which is neither.\n// webpieces-disable no-function-outside-class -- pure predicates over one segment's words, siblings of the module-scope helpers below\nfunction isCd(words: readonly string[]): boolean {\n return words[0] === 'cd' || words[0] === 'pushd';\n}\n\n// webpieces-disable no-function-outside-class -- sibling of isCd()\nfunction isRealCommand(words: readonly string[]): boolean {\n return words.length > 0 && !isCd(words);\n}\n\n// `<<EOF` / `<<'EOF'` / `<<-EOF`. NOT `<` or `<<<` alone — a herestring has no multi-line body, so it\n// cannot carry prose that tokenizes as commands.\nconst HEREDOC = /<<-?\\s*['\"]?[A-Za-z_]/;\n\n// A `cd` target that is not a literal path: `$DIR`, `${DIR}`, `~`, or a `$(…)`/backtick substitution.\n// `~` is here because path.resolve() does not expand it either — the shell does, and the hook never\n// sees a shell.\nconst VARIABLE_TARGET = /[$`]|^~/;\n\n// The separator joining a leading `cd` to what follows it, stripped when withoutLeadingCds() drops the\n// `cd`. `&&`, `||`, `;` and a bare newline are the shapes CommandScanner splits a leading run on.\nconst LEADING_SEPARATOR = /^\\s*(?:&&|\\|\\||;|\\n)\\s*/;\n\n/** Data-only carrier for the two values classify() decides together. */\nclass TreeClassification {\n readonly kind: TreeKind;\n readonly root: string;\n\n constructor(kind: TreeKind, root: string) {\n this.kind = kind;\n this.root = root;\n }\n}\n\n/**\n * The steering prefix every remedy needs, re-exported from @webpieces/rules-config so the guards, the\n * message builders and pr-gate's worktree notices all emit the IDENTICAL string — including the single\n * quotes that keep it runnable when the repo path contains a space. See atRoot's own header for why the\n * quotes are single and never double.\n */\nexport { atRoot } from '@webpieces/rules-config';\n\n// Two absolute paths naming the same directory. There is no filesystem access here — both sides are\n// already git's own answers or a resolved root.\n// webpieces-disable no-function-outside-class -- sibling of atRoot(); this module is the resolver plus its pure helpers\nfunction sameDir(a: string, b: string): boolean {\n return path.resolve(a) === path.resolve(b);\n}\n"]}
@@ -6,8 +6,8 @@ exports.writeGuardMatrixDoc = writeGuardMatrixDoc;
6
6
  exports.guardMatrixPointer = guardMatrixPointer;
7
7
  const rules_config_1 = require("@webpieces/rules-config");
8
8
  const shim_1 = require("../bin/shim");
9
- const guarantee_root_1 = require("../bin/guarantee-root");
10
9
  const hook_registration_1 = require("../bin/hook-registration");
10
+ const shim_deny_reason_1 = require("../bin/shim-deny-reason");
11
11
  const l0_fault_codes_1 = require("./l0-fault-codes");
12
12
  const to_error_1 = require("./to-error");
13
13
  // ---------------------------------------------------------------------------
@@ -157,24 +157,26 @@ exports.L0_FAULTS = [
157
157
  + '"Lockfile is up to date" and leaves the tree exactly as broken as it found it')], (0, shim_1.renderShim)()),
158
158
  new L0Fault(l0_fault_codes_1.L0_FAULT_BIN_BROKEN, 'guard bin present but CRASHED (exit code not 0 or 2 — corrupt node_modules)', 'sh, before the bin runs', 'sh', [bashCure(shim_1.RECOVERY_CMD, true, 'this fault fires at all — a BARE pnpm install SKIPS the corrupt package, because pnpm sees '
159
159
  + 'the right version on disk and considers it installed; only the delete forces a rewrite')], (0, shim_1.renderShim)()),
160
- // S covers the WHOLE managed hook surface, not just the shim: the two committed .sh files AND the
161
- // .claude/settings.json entries that register them. They only work as a set a settings file left
162
- // on the old two-absolute-hook form disables the L-1 hook and re-pins every worktree to the
160
+ // S covers the WHOLE managed hook surface, not just the shim: the committed ai-hook.sh, the
161
+ // .claude/settings.json entries that register them AND the managed `env` entry that pins the Bash
162
+ // cwd so the hooks resolve identically for every subagent. They only work as a set — a settings file left
163
+ // on a superseded form silently changes who governs, re-pinning every tree to the
163
164
  // primary's release — and nothing validated the registration at all before it joined this fault.
164
- new L0Fault(l0_fault_codes_1.L0_FAULT_SHIM_STALE, 'a webpieces-managed hook file or the .claude/settings.json registration does not match this release', 'the guard bin', 'JS', [
165
- // wp-upgrade-shim leads because it is now the ONLY cure that repairs all three, and it is
166
- // still surgical: it rewrites the two .sh files and the registration and touches no config,
167
- // and it imports only fs/path so it runs on a tree too broken to load the rule engine. The
168
- // INSTALLER is deliberately NOT a cure here: it also migrates the config and prompts for a
169
- // target twice, which hangs a non-interactive agent.
165
+ new L0Fault(l0_fault_codes_1.L0_FAULT_SHIM_STALE, 'a webpieces-managed hook file, the .claude/settings.json registration or its managed env entry does not match this release', 'the guard bin', 'JS', [
166
+ // wp-upgrade-shim leads because it is the ONLY cure that repairs all three, and it is
167
+ // still surgical: it rewrites ai-hook.sh, the registration and the managed env entry
168
+ // and touches no config, and it imports only fs/path so it runs on a tree too broken to load
169
+ // the rule engine. The INSTALLER is deliberately NOT a cure here: it also migrates the config
170
+ // and prompts for a target twice, which hangs a non-interactive agent.
170
171
  bashCure(shim_1.UPGRADE_SHIM_CMD, true, 'this fault fires at all — it is the only cure that repairs all three managed things '
171
- + '(both .sh files and the settings.json registration) and it touches no config; needs '
172
- + 'installed @webpieces/ai-hook-rules 0.4.408 or newer'),
172
+ + '(ai-hook.sh, the settings.json registration and its managed env entry), and it also '
173
+ + 'deletes the retired guarantee-root.sh and any entry still naming it, and it '
174
+ + 'touches no config; needs installed @webpieces/ai-hook-rules 0.4.408 or newer'),
173
175
  // 2026-07-21: the version gap below caused a real "command not found" deadlock.
174
176
  bashCure(shim_1.RESTORE_SHIM_CMD, false, 'the installed @webpieces/ai-hook-rules is OLDER than 0.4.408, so wp-upgrade-shim does '
175
177
  + 'not exist yet — it is PARTIAL (it repairs ai-hook.sh and NOTHING else), so upgrade '
176
178
  + '@webpieces afterwards and run Option 1 to finish'),
177
- ], (0, shim_1.shimStaleDenyReason)('', '', [shim_1.SHIM_MARKER, guarantee_root_1.GUARANTEE_ROOT_MARKER, hook_registration_1.REGISTRATION_SURFACE])),
179
+ ], (0, shim_deny_reason_1.shimStaleDenyReason)('', '', [shim_1.SHIM_MARKER, hook_registration_1.REGISTRATION_SURFACE, hook_registration_1.ENV_SURFACE], false)),
178
180
  new L0Fault(l0_fault_codes_1.L0_FAULT_CONFIG_MISSING, `${rules_config_1.CONFIG_FILENAME} missing`, 'the guard bin', 'JS', [
179
181
  CONFIG_WRITE_CURE,
180
182
  // Kept, but demoted: it seeds the file and then PROMPTS twice for a hook target.