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"