Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 154 additions & 19 deletions LifeOS/install/hooks/HookHealer.hook.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,28 @@
#!/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
* direct-exec command ("$HOME/.claude/hooks/X.hook.ts") then fails every
* 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.
Expand All @@ -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 <path>" 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'];
Expand Down Expand Up @@ -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<string> {
const paths = new Set<string>();
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;
Expand All @@ -111,26 +129,142 @@ function directExecPaths(): Set<string> {
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<string> {
const paths = new Set<string>();
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<string>;
declared: Map<string, string[]>;
} {
const imported = new Set<string>();
const declared = new Map<string, string[]>();
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<string, Set<string>>();
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<string>).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' });
Expand All @@ -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[] = [];
Expand Down
22 changes: 20 additions & 2 deletions LifeOS/install/hooks/ModelRungGuard.hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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 || "";
Expand Down Expand Up @@ -144,6 +145,23 @@ function log(event: Record<string, unknown>): 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 },
Expand All @@ -165,7 +183,7 @@ async function main(): Promise<void> {
`'${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 });
Expand Down
6 changes: 3 additions & 3 deletions LifeOS/install/hooks/PromptProcessing.hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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]".
Expand All @@ -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"
Expand Down
Loading