/** * THE INSTALLED HOOK SURFACE — two hooks, both ABSOLUTE, and the ONE place their spelling is defined. * * ─── ONE GOVERNOR: the MAIN tree judges every tree ───────────────────────────────────────────────── * * H1 sh "$CLAUDE_PROJECT_DIR/.claude/webpieces/ai-hook.sh" wp-ai-guards-hook Write|…|Bash|Read * H2 sh "$CLAUDE_PROJECT_DIR/.claude/webpieces/ai-hook.sh" wp-ai-rules-hook Write|Edit|MultiEdit * * This REPLACES a three-hook form in which these two were RELATIVE (`sh ".claude/webpieces/ai-hook.sh"`) * and a third hook, L-1 `guarantee-root.sh`, existed solely to guarantee that relative path resolved. * * ─── Why the relative experiment was reversed ────────────────────────────────────────────────────── * Relative registration was adopted so each git tree would be governed by "its own release, binary and * pin". MEASURED 2026-08-10: it never delivered that. A linked worktree has NO `node_modules`, so * ai-hook.sh's upward walk executes the PRIMARY's binary — `readlink -f` resolved a worktree agent's bin * to `/node_modules/@webpieces/ai-hook-rules`. A worktree ran its own SCRIPT and its own * CONFIG; it never ran its own release. Governance was already the primary's, in every tree, the whole * time. * * The price of that fiction was an entire guard layer. A relative path only resolves at a tree root, and * a hook that cannot resolve exits 127 — per the hooks reference a NON-BLOCKING error, i.e. a SILENT * UNGUARDED ALLOW. So L-1 had to deny every `cd` into a project subdirectory. That denial produced the * force-to-root bug class, the reaped-worktree `cd` prescription, and a measured hard deadlock: L-1 told * a worktree-isolated agent to `cd` to the primary clone, which the harness refuses for an isolated * agent, leaving it unable to stay or to follow the cure. * * An absolute path resolves from ANY cwd. So the launch guarantee is structural, L-1 has no job left and * is deleted, and `cd` into a subdirectory is simply allowed. What used to be true only by policing the * shell is now true by construction. * * ─── What replaces the property that was lost ────────────────────────────────────────────────────── * Nothing is lost that was ever delivered — but the case relative registration WANTED to handle (a tree * that genuinely needs a different @webpieces) is now DETECTED instead of silently mis-governed: * `VersionSyncGuard` (L1 row 8) blocks when a worktree's pin disagrees with the main tree's, and * prescribes either aligning the pins (same git hash → same tracked pin → install in each tree that has * a node_modules) or using a separate CLONE, which — unlike a worktree — gets its own GOVERNANCE. Note * what that does NOT say: a worktree may perfectly well have its own node_modules, and usually does the * moment anything installs in it. What it may not have is a DIFFERENT @webpieces version. * * ─── Why the registration is a DRIFT SURFACE, not just an install step ───────────────────────────── * Nothing used to validate `.claude/settings.json` at all, so a settings file left on a superseded form * silently changed who governs. The installed surface is THREE things (ai-hook.sh, the registration, and * the managed `env` entry — see managed-env.ts), all three are compared against this release, and * `wp-upgrade-shim` regenerates all three. A cure that fixes two of three is worse than no cure, because * it reports success. */ /** * The RETIRED L-1 hook's committed path. Named here, in the one module that must still recognise it, and * matched ONLY by isManagedCommand() so repairRegistration() can delete the stale entry. There is no * guarantee-root.ts any more — this literal is all that remains of it, deliberately. * * It is a one-way RECOGNISER, not a shim: nothing emits it, `expectedEntries()` never returns it, and a * settings file carrying it is reported STALE. Its only job is to make the retired entry findable so it * can be REMOVED. */ export declare const LEGACY_GUARANTEE_ROOT_MARKER = ".claude/webpieces/guarantee-root.sh"; /** * When this recogniser may be deleted, as a value rather than a comment nobody re-reads. * * A removal-only migration is still dead weight once no consumer can be carrying the old shape. The * hazard of deleting it EARLY is severe and silent — repair would stop stripping the retired entry, and * a hook registered against a deleted file exits 127, which the Claude Code hooks reference defines as a * NON-BLOCKING error, i.e. every `cd` unjudged while the cure reports success. So it gets a stated date * and a test that fails once the date passes, instead of an intention. * * `legacy-marker-expiry.spec.ts` reads this and fails after it, which is the reminder. */ export declare const LEGACY_MARKER_REMOVE_AFTER = "2026-12-01"; /** One PreToolUse hook entry as webpieces registers it. Data-only → a class, per CLAUDE.md. */ export declare class HookRegistrationEntry { readonly matcher: string; readonly command: string; constructor(matcher: string, command: string); sameAs(other: HookRegistrationEntry): boolean; } export interface HookCommand { type: string; command: string; } export interface HookEntry { matcher: string; hooks: HookCommand[]; } export interface ClaudeSettings { hooks?: { PreToolUse?: HookEntry[]; }; env?: Record; [key: string]: unknown; } export declare const RULES_BIN = "wp-ai-rules-hook"; export declare const GUARDS_BIN = "wp-ai-guards-hook"; export declare const RULES_MATCHER = "Write|Edit|MultiEdit"; export declare const GUARDS_MATCHER = "Write|Edit|MultiEdit|Bash|Read"; /** * The guard-hook command — `sh "$CLAUDE_PROJECT_DIR/.claude/webpieces/ai-hook.sh" `. * * ABSOLUTE. This replaced a RELATIVE spelling, and the reversal is the whole point of this release. * * The relative form existed to give each git tree its own release, binary and pin. **It never delivered * that.** Measured 2026-08-10: a linked worktree has no `node_modules`, so ai-hook.sh's upward walk * executes the PRIMARY's binary — `readlink -f` resolved a worktree agent's bin to * `/node_modules/@webpieces/ai-hook-rules`. A worktree ran its own SCRIPT and its own CONFIG, * never its own release. The property was fiction, and paying for it cost an entire guard layer: a * relative path only resolves at a tree root, so L-1 (guarantee-root.sh) had to deny every `cd` into a * project subdirectory, which produced the force-to-root bug class, the reaped-worktree `cd` * prescription, and a measured hard deadlock where L-1 told a worktree-isolated agent to `cd` to the * primary clone — which the harness refuses for an isolated agent. * * Absolute resolves from ANY cwd, so L-1 has no job left and is deleted. One governor: the MAIN tree * judges every tree, which is what was already happening via the borrowed binary — the design now says * so out loud, and `VersionSyncGuard` blocks the case where that is the wrong answer. * * Invoked via `sh ` rather than executed directly so a missing executable bit on the checked-in * shim (fresh clone, a filesystem that drops the bit, git core.fileMode quirks) can never break the hook * with a raw `Permission denied` on every tool call. Quoted to survive spaces in the path. */ export declare function shimCommand(bin: string): string; /** The registration entry for one guard bin. */ export declare function guardHookEntry(bin: string): HookRegistrationEntry; /** * True when this PreToolUse command is one webpieces owns — in ANY spelling it has ever shipped. * * THE SINGLE MOST IMPORTANT LINE IN THIS RELEASE. `LEGACY_GUARANTEE_ROOT_MARKER` is matched here and * NOWHERE else: it is how `repairRegistration()` finds and REMOVES the retired H1 entry from a settings * file written by an older release. Drop it and the repair silently leaves a live L-1 hook registered * against a file this release deletes — exit 127, which the Claude Code hooks reference defines as a * NON-BLOCKING error, so every `cd` goes unjudged while `wp-upgrade-shim` reports success and no drift * check can name it. Removal-only, never emitted: it appears in no `expectedEntries()` result, so it is * a one-way migration and not a second accepted spelling. */ export declare function isManagedCommand(command: string): boolean; /** Every webpieces-managed entry of one settings file, flattened to matcher + command pairs. */ export declare function managedEntries(settings: ClaudeSettings): readonly HookRegistrationEntry[]; /** * Which guard bins this settings file registers, in installer order. A file registering NEITHER is not * a project (relative) install and is therefore never judged — a global/absolute install names the bin * path directly and carries no shim marker at all. */ export declare function registeredBins(settings: ClaudeSettings): readonly string[]; /** * The exact set of entries THIS RELEASE expects in a settings file that registers `bins`. * * ONE entry per registered bin, and nothing else. There used to be a third, `guarantee-root.sh`, added * whenever the GUARDS bin was present; it is retired, and a settings file still carrying it is STALE — * `repairRegistration()` removes it via isManagedCommand()'s legacy marker. */ export declare function expectedEntries(bins: readonly string[]): readonly HookRegistrationEntry[]; /** * True when a settings file registers webpieces hooks in a shape this release does not expect: a * RELATIVE shim command, a leftover guarantee-root entry, a stray duplicate, or a wrong matcher. * * Compared as a SET, not a sequence: Claude Code runs all matching hooks in parallel, so array order * carries no meaning and reordering must not read as drift. */ export declare function registrationStale(settings: ClaudeSettings): boolean; /** Drop every webpieces-managed PreToolUse command; returns true if anything was removed. */ export declare function dropManagedEntries(settings: ClaudeSettings): boolean; /** Append one PreToolUse entry. */ export declare function addHookEntry(settings: ClaudeSettings, entry: HookRegistrationEntry): void; /** * True when a settings file that registers webpieces hooks is missing the managed `env` entry, or * carries any value other than the one required one. * * Gated on `registeredBins()` for the SAME reason `registrationStale()` is: a settings file that * registers no webpieces hooks is not a project install and is never judged — a global install names * the bin path directly and carries no shim marker at all, so there is nothing here to keep in step. * * NOTE what this entry is for NOW. It was originally justified by keeping the then-RELATIVE hook path * resolvable; that job is retired, because both hooks are absolute and resolve from any cwd. It is kept * for VERDICT STABILITY — a guard's answer must depend on the command, not on where an earlier `cd` left * the shell — and because settings `env` is inherited, every subagent shares that cwd and therefore that * verdict. See managed-env.ts, which states this at length. */ export declare function envStale(settings: ClaudeSettings): boolean; /** * Set the managed `env` entry IN PLACE, whatever was there before. Returns true when it changed. * * A user-set `"0"` is BROUGHT TO `"1"`, not honoured: this is a managed surface, and "webpieces sets it * unless you disagreed" would be a second, invisible spelling of the decision. Turning it off means * uninstalling the hooks, exactly as it does for every other managed thing. * * UNGATED on purpose — the installer calls it for the file it is writing hooks into, and the gate lives * at the caller (repairRegistration / applyHook), so a file with no webpieces hooks is never touched. */ export declare function applyManagedEnv(settings: ClaudeSettings): boolean; /** * Bring one settings object to the two-hook ABSOLUTE form AND the managed `env` entry IN PLACE. Returns * true when it changed anything. * * REMOVE-then-ADD for the hooks, never add-beside: two spellings of one registration is exactly the * compatibility shim the backwards-compat reviewer rejects, and leaving a RELATIVE entry beside the * absolute one would run two shims per call — the * straddle this whole change exists to delete. * * THE TWO REPAIRS ARE EVALUATED INDEPENDENTLY, and that is load-bearing rather than tidy: this used to * early-return on `!registrationStale(settings)`, so a repo whose hooks are already current but whose * `env` entry is missing — the state EVERY existing consumer is in the moment this release lands — would * have been reported as drifted by fault S and then left unrepaired by its own prescribed cure. A cure * that skips half the surface is the failure mode `upgrade-shim.ts`'s header exists to prevent. */ export declare function repairRegistration(settings: ClaudeSettings): boolean; /** The two project settings files the installer can write. */ export declare function projectSettingsPaths(projectRoot: string): readonly string[]; export declare function readSettings(settingsPath: string): ClaudeSettings; export declare function writeSettings(settingsPath: string, settings: ClaudeSettings): void; /** True when either project settings file under `root` carries a stale registration. */ export declare function registrationStaleAt(root: string | null): boolean; /** True when either project settings file under `root` is missing the managed `env` entry. */ export declare function envStaleAt(root: string | null): boolean; /** * WHAT was rewritten in ONE settings file. Data-only → a class, per CLAUDE.md. * * Two independent flags rather than one path, because the cure has to be able to SAY which repair it * made: "rewrote the hook registration" printed for a file whose registration was already current and * whose `env` entry was the only thing missing is a cure lying about its own work. */ export declare class SettingsRepair { readonly settingsPath: string; readonly registration: boolean; readonly env: boolean; constructor(settingsPath: string, registration: boolean, env: boolean); } /** Rewrite every stale project settings file under `root`; returns what changed, per file. */ export declare function repairRegistrationAt(root: string): readonly SettingsRepair[]; /** * The THREE names the drift check reports, so a deny can say WHICH of them moved. * * There were four. `GUARANTEE_ROOT_SURFACE` is gone with L-1 itself: an absolutely-registered shim * resolves from any cwd, so there is no launch guarantee left to police and no second .sh file to keep * byte-locked. A settings file still carrying the retired H1 entry is not its own surface any more — * it is ordinary REGISTRATION drift, which `registrationStaleAt()` already reports and * `repairRegistration()` already fixes by removing it. */ export declare const SHIM_SURFACE = ".claude/webpieces/ai-hook.sh"; export declare const REGISTRATION_SURFACE = ".claude/settings.json hook registration"; export declare const ENV_SURFACE = ".claude/settings.json env.CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR"; /** * WHICH of the three managed surfaces disagree with this release — the input to fault S. * * All three are checked against the SAME root, resolved from the RUNNING MODULE (governingShimRoot), * never from cwd and never from `$CLAUDE_PROJECT_DIR`: the files we compare and the renderers we * compare them TO must come from one install, or the check straddles two trees and can never converge. */ export declare function managedSurfaceDrift(root: string | null): readonly string[];