From 083136a50caa1c41bb2472ffaa00ef204cb81088 Mon Sep 17 00:00:00 2001 From: firas lamouchi Date: Tue, 11 Aug 2026 14:56:33 +0300 Subject: [PATCH] fix: hook up 6 orphan hooks and stop raw {{PRINCIPAL_NAME}} leaking to the model (#1817) Turns out six of our hooks (ModelRungGuard, AtlasEventCapture, KnowledgeWriteGuard, SpendAuditor, TimeContext, and VersionDrift) were just sitting on disk doing absolutely nothing. They weren't registered in the manifest and weren't imported anywhere, so they ran zero times on every install despite what the docs claimed. Wired them up using the exact events, positions, and timeouts specified in the docs. Also fixed a sneaky issue where raw {{PRINCIPAL_NAME}} tokens were reaching the model. Since template substitution only runs at install time, runtime strings in PromptProcessing (lines 708, 721) and ModelRungGuard (line 168) were sending unrendered placeholders directly into live system prompts. PromptProcessing now uses the constant already in scope, and ModelRungGuard pulls from getPrincipalName() with a generic fallback so it never fails a prompt. Finally, updated HookHealer (1.1.0) with an orphan check lane so we don't hit this again. It warns if a hook isn't registered per event, ignores the 14 dispatcher hooks on purpose, and reports zero issues against this fix. --- LifeOS/install/hooks/HookHealer.hook.ts | 173 ++++++++++++++++-- LifeOS/install/hooks/ModelRungGuard.hook.ts | 22 ++- LifeOS/install/hooks/PromptProcessing.hook.ts | 6 +- LifeOS/install/hooks/hooks.json | 74 ++++++++ 4 files changed, 251 insertions(+), 24 deletions(-) diff --git a/LifeOS/install/hooks/HookHealer.hook.ts b/LifeOS/install/hooks/HookHealer.hook.ts index 10c6367a00..b4fd0e53c2 100755 --- a/LifeOS/install/hooks/HookHealer.hook.ts +++ b/LifeOS/install/hooks/HookHealer.hook.ts @@ -1,7 +1,8 @@ #!/usr/bin/env bun /** - * @version 1.0.3 - * HookHealer.hook.ts - Self-healing for the registered-script exec-bit class + * @version 1.1.0 + * HookHealer.hook.ts - Self-healing for the registered-script exec-bit class, + * plus the orphaned-hook lane (a script on disk that no event ever reaches). * * PURPOSE: * The Write tool creates files mode 0644. A hook registered in settings as a @@ -9,11 +10,19 @@ * invocation with "/bin/sh: Permission denied" until someone notices. * This hook detects and repairs that class automatically. * + * The orphan lane (1.1.0) catches the opposite problem: a hook that ships, gets + * documented as if it were running, and is wired to nothing. Six of them were + * sitting in here like that (public issue #1817) โ€” the README table and the + * wiring diagram both described them as live. Nobody had ever compared the + * files on disk against what's actually registered, so it went unnoticed until + * someone audited the manifest by hand. + * * MODES: * - (default) SessionStart sweep: every script directly executed by a * settings hook command (first token of each command segment) * must exist and be executable. Missing exec bit -> chmod +x. * Missing file / missing shebang -> surfaced warning only. + * Then the orphan lane over ~/.claude/hooks (warning only). * - --posttool PostToolUse(Write|Edit) ingestion guard: a written file under * ~/.claude whose content starts with "#!" gets its exec bit * immediately - heals at the ingestion point. @@ -22,22 +31,28 @@ * - chmod containment: only ever touches paths under ~/.claude * - non-blocking: exits 0 on every path, including internal errors * - registered via "bun " so it is immune to losing its own exec bit + * - the orphan lane never fixes anything, it just tells you. Deciding when a + * hook should fire is a human call, and auto-registering something would be + * a worse bug than the one we're reporting. * * OUTPUTS: - * - MEMORY/OBSERVABILITY/hook-healer.jsonl (heal/warning events) + * - MEMORY/OBSERVABILITY/hook-healer.jsonl (heal/warning/orphan events) * - stdout "๐Ÿฉน HookHealer: ..." line when something was healed or needs attention * - * PERFORMANCE: <50ms (two JSON reads + stat per registered path) + * PERFORMANCE: <50ms for the exec-bit sweep (two JSON reads + stat per + * registered path). The orphan lane adds one directory listing plus a read of + * each hook file (~570KB across the shipped tree) for the import scan. */ import { existsSync, readFileSync, chmodSync, statSync, appendFileSync, - mkdirSync, openSync, readSync, closeSync, realpathSync, + mkdirSync, openSync, readSync, closeSync, realpathSync, readdirSync, } from 'fs'; import { join } from 'path'; import { homedir } from 'os'; const CLAUDE_DIR = join(homedir(), '.claude'); +const HOOKS_DIR = join(CLAUDE_DIR, 'hooks'); const OBS_DIR = join(CLAUDE_DIR, 'LIFEOS', 'MEMORY', 'OBSERVABILITY'); const LOG_FILE = join(OBS_DIR, 'hook-healer.jsonl'); const SETTINGS_FILES = ['settings.json', 'settings.local.json']; @@ -95,12 +110,15 @@ function expandHome(token: string): string { } /** - * Collect scripts that settings hook commands execute DIRECTLY (first token - * of each command segment). Scripts passed as arguments to bun/sh are - * deliberately excluded - their exec bit is irrelevant. + * Every (event, command) pair from the settings files. Read once and used by + * both lanes below. + * + * We keep the event name rather than just the command because a hook can be + * registered on one event while its header says it runs on three โ€” and if you + * only compare script names, that looks perfectly wired. */ -function directExecPaths(): Set { - const paths = new Set(); +function registeredCommands(): Array<{ event: string; command: string }> { + const out: Array<{ event: string; command: string }> = []; for (const name of SETTINGS_FILES) { const file = join(CLAUDE_DIR, name); if (!existsSync(file)) continue; @@ -111,26 +129,142 @@ function directExecPaths(): Set { log({ event: 'settings-parse-failed', file }); continue; } - for (const groups of Object.values(parsed.hooks ?? {})) { - for (const group of groups) { + for (const [event, groups] of Object.entries(parsed.hooks ?? {})) { + for (const group of groups ?? []) { for (const hook of group?.hooks ?? []) { - const cmd = hook?.command ?? ''; - for (const segment of cmd.split(/;|&&|\|\|/)) { - const first = segment.trim().split(/\s+/)[0] ?? ''; - const p = expandHome(first); - if (/\.(ts|js|sh)$/.test(p) && p.startsWith(CLAUDE_DIR + '/')) paths.add(p); - } + if (typeof hook?.command === 'string') out.push({ event, command: hook.command }); } } } } + return out; +} + +/** + * Collect scripts that settings hook commands execute DIRECTLY (first token + * of each command segment). Scripts passed as arguments to bun/sh are + * deliberately excluded - their exec bit is irrelevant. + */ +function directExecPaths(registered: Array<{ command: string }>): Set { + const paths = new Set(); + for (const { command } of registered) { + for (const segment of command.split(/;|&&|\|\|/)) { + const first = segment.trim().split(/\s+/)[0] ?? ''; + const p = expandHome(first); + if (/\.(ts|js|sh)$/.test(p) && p.startsWith(CLAUDE_DIR + '/')) paths.add(p); + } + } return paths; } +/** The event names a header might mention. Longest first, so "PostToolUse" + * doesn't match inside "PostToolUseFailure". */ +const HOOK_EVENTS = [ + 'PostToolUseFailure', 'UserPromptSubmit', 'PermissionRequest', 'SubagentStop', + 'SessionStart', 'SessionEnd', 'PostToolUse', 'PreToolUse', 'ConfigChange', + 'TaskCreated', 'StopFailure', 'Notification', 'PreCompact', 'Stop', +] as const; + +/** Every hook script sitting in the hooks dir. */ +function hookFilesOnDisk(): string[] { + try { + return readdirSync(HOOKS_DIR) + .filter((f: string) => /\.hook\.(ts|sh)$/.test(f)) + .sort(); + } catch { + return []; + } +} + +/** + * One pass over the hook files, pulling out two things: which hooks get + * imported by another hook, and which events each header says it runs on. + * + * The import part matters most. Plenty of hooks here are deliberately + * registered nowhere because a dispatcher calls them directly (FormatGate is + * called by StopGates, LoadMemory by MemoryTurnStart, and so on). Those are + * fine, and flagging them would just train everyone to ignore the warning. + */ +function scanHookSources(files: string[]): { + imported: Set; + declared: Map; +} { + const imported = new Set(); + const declared = new Map(); + for (const file of files) { + const base = file.replace(/\.hook\.(ts|sh)$/, ''); + let src: string; + try { + src = readFileSync(join(HOOKS_DIR, file), 'utf-8'); + } catch { + continue; + } + for (const m of src.matchAll(/from\s+['"]\.\/([A-Za-z0-9_-]+)\.hook['"]/g)) { + if (m[1] && m[1] !== base) imported.add(m[1]); + } + // Only an explicit TRIGGER: line counts. Headers mention event names in + // passing all the time, and treating that as a claim about wiring made + // this too noisy to be worth reading. + const trigger = src.match(/^[ \t]*\*?[ \t]*TRIGGERS?:[ \t]*(.*)$/m)?.[1] ?? ''; + if (trigger) { + const events = HOOK_EVENTS.filter((e) => new RegExp(`\\b${e}\\b`).test(trigger)); + if (events.length > 0) declared.set(base, events); + } + } + return { imported, declared }; +} + +/** + * Two things to report: hooks nothing ever fires, and hooks that fire on some + * of the events their header claims but not all of them. Warnings only โ€” we + * never register anything automatically, because picking when a hook runs is a + * judgement call and guessing wrong is worse than the gap we found. + */ +function orphanLane(registered: Array<{ event: string; command: string }>): string[] { + const files = hookFilesOnDisk(); + if (files.length === 0) return []; + + // hook name -> the events it's actually wired to + const eventsFor = new Map>(); + for (const { event, command } of registered) { + for (const m of command.matchAll(/([A-Za-z0-9_-]+)\.hook\.(?:ts|sh)\b/g)) { + const base = m[1] as string; + if (!eventsFor.has(base)) eventsFor.set(base, new Set()); + (eventsFor.get(base) as Set).add(event); + } + } + + const { imported, declared } = scanHookSources(files); + const warnings: string[] = []; + + for (const file of files) { + const base = file.replace(/\.hook\.(ts|sh)$/, ''); + const live = eventsFor.get(base); + + if (!live || live.size === 0) { + if (imported.has(base)) continue; // a dispatcher calls it, so it's fine + warnings.push(`orphan (registered on no event): ${file}`); + log({ event: 'orphan', hook: file, declared: declared.get(base) ?? [], source: 'sweep' }); + continue; + } + + // Same deal here: if a dispatcher calls it, it can run on events it has no + // registration for, so comparing against the header would be wrong. + if (imported.has(base)) continue; + const missing = (declared.get(base) ?? []).filter((e) => !live.has(e)); + if (missing.length > 0) { + warnings.push(`declares ${missing.join('+')} but is not registered there: ${file}`); + log({ event: 'event-drift', hook: file, missing, registered: [...live], source: 'sweep' }); + } + } + return warnings; +} + function sweep(): void { const healed: string[] = []; const warnings: string[] = []; - for (const p of [...directExecPaths()].sort()) { + const registered = registeredCommands(); + for (const p of [...directExecPaths(registered)].sort()) { if (!existsSync(p)) { warnings.push(`missing: ${p}`); log({ event: 'missing', path: p, source: 'sweep' }); @@ -142,6 +276,7 @@ function sweep(): void { } if (heal(p, 'sweep')) healed.push(p); } + warnings.push(...orphanLane(registered)); if (healed.length > 0 || warnings.length > 0) { const short = (s: string) => s.replace(CLAUDE_DIR + '/', ''); const parts: string[] = []; diff --git a/LifeOS/install/hooks/ModelRungGuard.hook.ts b/LifeOS/install/hooks/ModelRungGuard.hook.ts index 2e0b1c75e9..b409f39a3c 100755 --- a/LifeOS/install/hooks/ModelRungGuard.hook.ts +++ b/LifeOS/install/hooks/ModelRungGuard.hook.ts @@ -7,7 +7,7 @@ for (const __k of ["LIFEOS_DIR", "LIFEOS_CONFIG_DIR", "PROJECTS_DIR"]) { } /** - * @version 1.0.0 + * @version 1.0.1 * TRIGGER: UserPromptSubmit * ModelRungGuard โ€” detect a session running BELOW the pinned model rung. * @@ -37,6 +37,7 @@ for (const __k of ["LIFEOS_DIR", "LIFEOS_CONFIG_DIR", "PROJECTS_DIR"]) { import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs"; import { join } from "node:path"; +import { getPrincipalName } from "./lib/identity"; const STDIN_TIMEOUT_MS = 300; const HOME = process.env.HOME || ""; @@ -144,6 +145,23 @@ function log(event: Record): void { } catch { /* observability is never worth failing a prompt over */ } } +/** + * Look up the principal's name while the hook is running. + * + * This used to be a `{{PRINCIPAL_NAME}}` token inside the advisory string, + * which doesn't work: placeholder substitution happens once at install time + * over the files on disk, and it never sees a string we build at runtime. So + * the model was getting the raw token handed to it. Falls back to a generic + * word if identity can't be read โ€” nothing here is worth failing a prompt over. + */ +function principalName(): string { + try { + return getPrincipalName() || "the principal"; + } catch { + return "the principal"; + } +} + function emit(line: string): void { console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: line }, @@ -165,7 +183,7 @@ async function main(): Promise { `'${pin}' pin in settings.json. Per OPERATIONAL_RULES ยง Model selection, MAX-class work ` + `(judgment, design, architecture, scoping, synthesis, meta work on LifeOS) does NOT run here: ` + `dispatch it now with the '${pin}' tier alias. Do not ask which rung to use, and do not ask ` + - `{{PRINCIPAL_NAME}} to change /model.`, + `${principalName()} to change /model.`, ); } else { log({ event: "on-pin", pin, live, model }); diff --git a/LifeOS/install/hooks/PromptProcessing.hook.ts b/LifeOS/install/hooks/PromptProcessing.hook.ts index 6639bebe6b..802698089a 100755 --- a/LifeOS/install/hooks/PromptProcessing.hook.ts +++ b/LifeOS/install/hooks/PromptProcessing.hook.ts @@ -7,7 +7,7 @@ for (const __k of ["LIFEOS_DIR", "LIFEOS_CONFIG_DIR", "PROJECTS_DIR"]) { } /** - * @version 1.4.45 + * @version 1.4.46 * PromptProcessing.hook.ts - Tab Title + Session Naming (Haiku) * * PURPOSE: @@ -705,7 +705,7 @@ THINK FIRST: What is ${PRINCIPAL_NAME} actually trying to ACCOMPLISH? Not what w - Words appearing in the prompt are EVIDENCE, not the answer. The goal lives in ${PRINCIPAL_NAME}'s actual question or instruction. - Ignore HOW they asked (pull up, show me, continue with, look at, hey, thanks) โ€” those are interaction tokens, not work. - Focus on the GOAL (what outcome is being pursued: a decision, a fix, a build, a piece of research, an evaluation). -- The name should be a complete imperative phrase. Read it aloud โ€” it should sound like "{{PRINCIPAL_NAME}} needs to ___" filled in coherently. +- The name should be a complete imperative phrase. Read it aloud โ€” it should sound like "${PRINCIPAL_NAME} needs to ___" filled in coherently. - **Pasted content rule:** If the user pastes an email, letter, message, quote, document, or any block of text that someone ELSE wrote (signs like "Hey [Name],", a closing like "Thanks,/Best,/Cheers,/Regards,", quoted reviews, copied tweets, forwarded messages), the GOAL is ${PRINCIPAL_NAME}'s question or instruction WRAPPED AROUND that content โ€” NOT words from the pasted content itself. Words like "Thanks", "Hey", "Dear", "Regards", "Agenda", "Accurate", recipient names, sender names, subject lines, and other email/letter tokens are NEVER subjects of work. Find ${PRINCIPAL_NAME}'s actual question ("research...", "is X fair?", "what should I do about...", "help me decide...", "evaluate...") and name the session from THAT. - **Decision rule:** If the prompt is "Should I X or Y?" or "Is 20% fair?" the goal is a DECISION. Name it: "Decide [Subject] [Aspect]" or "Evaluate [Subject] [Aspect]". - **Question rule:** If the prompt is "What is X?" / "How does X work?" the goal is RESEARCH. Name it: "Research [Subject] [Aspect]". @@ -718,7 +718,7 @@ Rules: - Start with a base-form action verb (Fix, Build, Debug, Refactor, Migrate, Research, Analyze โ€” NOT Fixing, Building). - Preserve acronyms in ALL CAPS (LifeOS, TUI, API, UL, CLI, ISC, ISA, BPE). - Every word must carry meaning. No filler adverbs (seriously, really, properly), no lone conjunctions, no fragment scraps. -- Reads as a grammatical phrase: imagine "{{PRINCIPAL_NAME}} needs to ___" โ€” the name fills the blank as a coherent action. +- Reads as a grammatical phrase: imagine "${PRINCIPAL_NAME} needs to ___" โ€” the name fills the blank as a coherent action. Examples of separating instruction from subject: - "Pull up the LifeOS TUI work and continue" โ†’ subject is LifeOS TUI โ†’ "Build LifeOS TUI Dashboard Interface" diff --git a/LifeOS/install/hooks/hooks.json b/LifeOS/install/hooks/hooks.json index e8960c7288..1a89614800 100644 --- a/LifeOS/install/hooks/hooks.json +++ b/LifeOS/install/hooks/hooks.json @@ -105,6 +105,16 @@ { "type": "command", "command": "$HOME/.claude/hooks/ConfigEvalFire.hook.ts" + }, + { + "type": "command", + "command": "$HOME/.claude/hooks/AtlasEventCapture.hook.ts", + "timeout": 5 + }, + { + "type": "command", + "command": "$HOME/.claude/hooks/KnowledgeWriteGuard.hook.ts", + "timeout": 5 } ] }, @@ -123,6 +133,16 @@ { "type": "command", "command": "$HOME/.claude/hooks/ConfigEvalFire.hook.ts" + }, + { + "type": "command", + "command": "$HOME/.claude/hooks/AtlasEventCapture.hook.ts", + "timeout": 5 + }, + { + "type": "command", + "command": "$HOME/.claude/hooks/KnowledgeWriteGuard.hook.ts", + "timeout": 5 } ] }, @@ -141,6 +161,26 @@ { "type": "command", "command": "$HOME/.claude/hooks/ConfigEvalFire.hook.ts" + }, + { + "type": "command", + "command": "$HOME/.claude/hooks/AtlasEventCapture.hook.ts", + "timeout": 5 + }, + { + "type": "command", + "command": "$HOME/.claude/hooks/KnowledgeWriteGuard.hook.ts", + "timeout": 5 + } + ] + }, + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "$HOME/.claude/hooks/AtlasEventCapture.hook.ts", + "timeout": 5 } ] }, @@ -225,6 +265,15 @@ } ] }, + { + "hooks": [ + { + "type": "command", + "command": "$HOME/.claude/hooks/VersionDrift.hook.ts", + "timeout": 10 + } + ] + }, { "hooks": [ { @@ -251,6 +300,26 @@ "command": "$HOME/.claude/hooks/AlgorithmNudge.hook.ts" } ] + }, + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "$HOME/.claude/hooks/ModelRungGuard.hook.ts", + "timeout": 5 + } + ] + }, + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "$HOME/.claude/hooks/TimeContext.hook.ts", + "timeout": 5 + } + ] } ], "PostToolUseFailure": [ @@ -342,6 +411,11 @@ "type": "command", "command": "$HOME/.claude/hooks/ISARenderOnStop.hook.ts" }, + { + "type": "command", + "command": "$HOME/.claude/hooks/SpendAuditor.hook.ts", + "timeout": 5 + }, { "type": "command", "command": "$HOME/.claude/hooks/StopGates.hook.ts"