diff --git a/LifeOS/install/LIFEOS/DOCUMENTATION/OmpHooksBridge.md b/LifeOS/install/LIFEOS/DOCUMENTATION/OmpHooksBridge.md new file mode 100644 index 0000000000..644c6b5e44 --- /dev/null +++ b/LifeOS/install/LIFEOS/DOCUMENTATION/OmpHooksBridge.md @@ -0,0 +1,74 @@ +--- +version: 1.0.1 +--- + +# OmpHooksBridge — LifeOS hooks in omp (Oh My Pi) + +> The LifeOS hook layer runs on the Claude Code hook contract (`${LIFEOS_DIR}/settings.json`: JSON on stdin, JSON on stdout). omp does not execute that contract. `OmpHooksBridge.ts` is an omp extension that shims it onto the omp event bus, so LifeOS's enforcement layer — observability, memory capture, gates, permission guards — runs in every harness, not just Claude Code. This directly serves LifeOS's harness-agnostic design goal ("it's designed to run wherever your AI does"). + +## How it works + +| omp event | Claude Code event | Effect | +|---|---|---| +| `session_start` | `SessionStart` | startup maintenance hooks | +| `turn_start` | `UserPromptSubmit` | prompt-side hooks | +| `tool_call` | `PreToolUse` | `permissionDecision: deny` → tool blocked; `ask` → UI confirm (fail-closed headless) | +| `tool_result` | `PostToolUse` | result-side hooks (isError → `PostToolUseFailure`) | +| `turn_end` | `Stop` | per-turn stop hooks | +| `session_shutdown` | `SessionEnd` | end-of-session maintenance | + +Hooks are invoked exactly as Claude Code invokes them: `sh -c ` with the Claude Code JSON payload on stdin. The registry (event → matcher → command) is read live from `${LIFEOS_DIR}/settings.json`, so there is no duplicated configuration. + +**Context injection.** Any `additionalContext` a hook returns (e.g. ``, rule updates) is queued and injected as a system message before the next LLM call via the `context` event — the omp equivalent of Claude Code's implicit injection. + +**Voice.** The `VoiceCompletion` hook requires a Claude transcript, which omp doesn't produce, so the bridge adds an omp-native voice path: at `turn_end` it reads the turn's own final assistant message and extracts the final `🗣️ :` closer (angle brackets optional — both `🗣️ :` and `🗣️ ZEN:` are accepted), and speaks it through Pulse's `/notify` (ElevenLabs). Same semantics as Claude Code — no `🗣️` line means silence. Disable with `OMP_VOICE=0`; override the voice id with `OMP_VOICE_ID`. + +**Safety.** A hook returning `deny` blocks the tool. `ask` shows a UI confirm; headless runs fail closed (deny). Hook failures are logged, never crash the session. Per-hook timeout 30s. + +## Install + +```bash +mkdir -p ~/.omp/agent/extensions +cp OmpHooksBridge.ts ~/.omp/agent/extensions/ +``` + +Restart omp — the extension auto-loads in every new session (user-level extension discovery from `~/.omp/agent/extensions`). Alternatively pass it explicitly: `omp --hook /path/to/OmpHooksBridge.ts`. + +Configuration: + +- `LIFEOS_DIR` — LifeOS config root (default `~/.claude`) +- `OMP_BRIDGE_LOG` — audit log path (default `~/.omp/lifeos-bridge.log`) + +## Verification evidence + +Headless run (`omp -p --auto-approve "run: echo bridge-test"`, DeepSeek v4 flash via Ollama). Audit log (`${OMP_BRIDGE_LOG}`): + +``` +{"event":"bridge_init","hooks":76,"lifeosDir":"${LIFEOS_DIR}"} +{"hook":"HookHealer.hook.ts","cc":"SessionStart","ok":true,"ms":15} +{"hook":"LoadContext.hook.ts","cc":"SessionStart","ok":true,"ms":34} +{"hook":"PromptProcessing.hook.ts","cc":"UserPromptSubmit","ok":true,"ms":1593} +{"hook":"DriftReminder.hook.ts","cc":"UserPromptSubmit","ok":true,"context":true} +{"event":"tool_call","tool":"bash"} +{"hook":"ContextReduction.hook.sh","cc":"PreToolUse","tool":"bash","ok":true,"ms":5} +{"hook":"PreToolGuard.hook.ts","cc":"PreToolUse","tool":"bash","ok":true,"ms":23} +{"hook":"EventLogger.hook.ts","cc":"PostToolUse","tool":"bash","ok":true,"ms":32} +{"hook":"AtlasEventCapture.hook.ts","cc":"PostToolUse","tool":"bash","ok":true,"ms":13} +{"event":"context_inject","chars":118} +{"event":"session_shutdown"} +``` + +Observability proof — LifeOS `tool-activity.jsonl` gains a real entry written by the EventLogger hook from inside the omp session: + +```json +{"event":"tool_use","tool_name":"Bash","tool_input_preview":"{\"command\":\"echo bridge-test\"}","ground_truth":{"command":"echo bridge-test"}} +``` + +Per-event hook execution in the same run: SessionStart 5 · UserPromptSubmit 24 · PreToolUse 2 · PostToolUse 4 · Stop 16 · SessionEnd 6. Zero hook crashes. + +## Known limitations + +- `transcript_path` is empty — hooks that parse the Claude transcript (e.g. LastResponseCache, StopGates) degrade gracefully (they log, don't crash); the voice line is handled by the omp-native path instead of `VoiceCompletion`. +- UserPromptSubmit receives a placeholder prompt (the omp `input` event is not yet wired) — prompt-text analysis hooks run with reduced signal. +- Hooks run sequentially; heavy prompt-side hooks (inference) add ~1–2s per turn. +- Changes take effect from the next session start. diff --git a/LifeOS/install/LIFEOS/TOOLS/OmpHooksBridge.ts b/LifeOS/install/LIFEOS/TOOLS/OmpHooksBridge.ts new file mode 100644 index 0000000000..41b49bf87e --- /dev/null +++ b/LifeOS/install/LIFEOS/TOOLS/OmpHooksBridge.ts @@ -0,0 +1,454 @@ +/** + * lifeos-hooks-bridge.ts — runs LifeOS Claude-Code hooks inside omp (Oh My Pi). + * + * omp does not execute ${LIFEOS_DIR}/settings.json hooks (Claude Code contract: + * JSON on stdin, JSON on stdout). This extension shims that contract onto the + * omp event bus: + * + * omp event → Claude Code event + * --------------------- ------------------- + * session_start → SessionStart + * turn_start → UserPromptSubmit + * tool_call → PreToolUse (permissionDecision → block/allow) + * tool_result → PostToolUse (isError → PostToolUseFailure) + * turn_end → Stop + * session_shutdown → SessionEnd + * + * Hook output contract: + * { "hookSpecificOutput": { "permissionDecision": "allow|deny|ask", + * "permissionDecisionReason": "...", + * "shouldBlockFurtherMessages": bool, + * "additionalContext": "..." } } + * - deny → tool call blocked (fail-closed in headless for "ask") + * - additionalContext → queued and injected as a system message before the + * next LLM call via the `context` event (replaces Claude Code's implicit + * injection). This is what carries memory/rule deltas to the model. + * + * Config: + * - LIFEOS_DIR env overrides the LifeOS config root (default ~/.claude) + * - OMP_BRIDGE_LOG overrides the audit log path (default ~/.omp/lifeos-bridge.log) + * + * Every invocation is logged to the audit log. Hook failures are logged and + * never crash the session. Per-hook timeout 30s. + */ + +import { appendFileSync, readFileSync } from "fs"; +import { homedir } from "os"; +import { join } from "path"; +import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent"; + +const HOME = homedir(); +const LIFEOS_DIR = process.env.LIFEOS_DIR ?? join(HOME, ".claude"); +const SETTINGS_PATH = join(LIFEOS_DIR, "settings.json"); +const LOG_PATH = process.env.OMP_BRIDGE_LOG ?? join(HOME, ".omp", "lifeos-bridge.log"); +const PULSE_NOTIFY = "http://127.0.0.1:31337/notify"; +const VOICE_ID = process.env.OMP_VOICE_ID ?? "fTtv3eikoepIosk8dTZ5"; +const HOOK_TIMEOUT_MS = 30_000; + +interface HookReg { + event: string; + matcher: string; + command: string; +} + +interface HookResult { + ok: boolean; + out: string; + err: string; + ms: number; +} + +interface HookOutput { + decision?: string; + reason?: string; + context?: string; + block?: boolean; +} + +/** omp tool name → Claude Code tool name (known map; unknown pass through). */ +const TOOL_UP: Record = { + bash: "Bash", read: "Read", write: "Write", edit: "Edit", glob: "Glob", + grep: "Grep", task: "Task", todo: "Todo", ask: "AskUserQuestion", + web_search: "WebSearch", hub: "Hub", eval: "Eval", lsp: "Lsp", + debug: "Debug", browser: "Browser", inspect_image: "InspectImage", +}; + +function log(line: Record): void { + try { + appendFileSync(LOG_PATH, JSON.stringify({ ts: new Date().toISOString(), ...line }) + "\n"); + } catch { /* never crash on logging */ } +} + +function expand(s: string): string { + return s.replaceAll("$HOME", HOME).replaceAll("${HOME}", HOME); +} + +/** Claude Code matcher: `*` wildcard + `|` alternation (e.g. "Bash|Write|Edit|MultiEdit", "mcp__.*"). */ +function matcherToRegex(matcher: string): RegExp { + if (!matcher || matcher === "*") return /^.*$/; + const parts = matcher.split("|").map((p) => p.trim()).filter(Boolean); + const body = parts + .map((p) => p.split("*").map((seg) => seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*")) + .join("|"); + return new RegExp(`^(?:${body})$`); +} + +function hookName(command: string): string { + return command.split("/").pop() ?? command; +} + +/** Load the hook registry snapshot from ${LIFEOS_DIR}/settings.json. */ +function loadRegistry(): HookReg[] { + try { + const raw: unknown = JSON.parse(readFileSync(SETTINGS_PATH, "utf8")); + if (!raw || typeof raw !== "object" || !("hooks" in raw)) return []; + const hooksMap = raw.hooks; + if (!hooksMap || typeof hooksMap !== "object") return []; + const out: HookReg[] = []; + for (const [ev, entries] of Object.entries(hooksMap)) { + if (!Array.isArray(entries)) continue; + for (const entry of entries) { + if (!entry || typeof entry !== "object") continue; + const matcher = "matcher" in entry && typeof entry.matcher === "string" ? entry.matcher : "*"; + if (!("hooks" in entry) || !Array.isArray(entry.hooks)) continue; + for (const hk of entry.hooks) { + if (!hk || typeof hk !== "object") continue; + if (!("type" in hk) || hk.type !== "command") continue; + if (!("command" in hk) || typeof hk.command !== "string" || !hk.command) continue; + out.push({ event: ev, matcher, command: expand(hk.command) }); + } + } + } + return out; + } catch (e) { + log({ err: "registry_load_failed", detail: String(e) }); + return []; + } +} + +const registry: HookReg[] = loadRegistry(); + +/** Run one hook: `sh -c ` with the CC JSON payload on stdin. */ +async function runHook(reg: HookReg, payload: Record): Promise { + const t0 = Date.now(); + try { + const proc = Bun.spawn(["sh", "-c", reg.command], { + cwd: LIFEOS_DIR, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + proc.stdin.write(JSON.stringify(payload)); + proc.stdin.end(); + const outP = new Response(proc.stdout).text(); + const errP = new Response(proc.stderr).text(); + const exited = await Promise.race([proc.exited, Bun.sleep(HOOK_TIMEOUT_MS).then(() => "timeout" as const)]); + if (exited === "timeout") proc.kill(); + const [out, err] = await Promise.all([outP, errP]); + return { + ok: exited !== "timeout", + out, + err: exited === "timeout" ? "timeout" : err.slice(0, 300), + ms: Date.now() - t0, + }; + } catch (e) { + return { ok: false, out: "", err: String(e).slice(0, 300), ms: Date.now() - t0 }; + } +} + +/** Parse CC hook stdout → { permissionDecision, reason, additionalContext, block }. */ +function parseOutput(raw: string): HookOutput { + for (const line of raw.split("\n").reverse()) { + const t = line.trim(); + if (!t.startsWith("{")) continue; + try { + const parsed: unknown = JSON.parse(t); + if (!parsed || typeof parsed !== "object") continue; + const maybe = "hookSpecificOutput" in parsed ? parsed.hookSpecificOutput : parsed; + if (!maybe || typeof maybe !== "object") continue; + const out = maybe as Record; + const context = "additionalContext" in out && typeof out.additionalContext === "string" ? out.additionalContext : undefined; + return { + decision: "permissionDecision" in out && typeof out.permissionDecision === "string" ? out.permissionDecision : undefined, + reason: "permissionDecisionReason" in out && typeof out.permissionDecisionReason === "string" ? out.permissionDecisionReason : undefined, + context, + block: "shouldBlockFurtherMessages" in out && out.shouldBlockFurtherMessages === true, + }; + } catch { /* keep scanning */ } + } + return {}; +} + +interface ToolCallInfo { + name: string; + input: unknown; + toolCallId: string; +} + +function readToolCall(v: unknown): ToolCallInfo | null { + if (!v || typeof v !== "object") return null; + const name = "toolName" in v ? v.toolName : undefined; + if (typeof name !== "string") return null; + return { + name, + input: "input" in v ? v.input : undefined, + toolCallId: "toolCallId" in v && typeof v.toolCallId === "string" ? v.toolCallId : "", + }; +} + +interface ToolResultInfo { + name: string; + input: unknown; + toolCallId: string; + isError: boolean; + content: string; +} + +function readToolResult(v: unknown): ToolResultInfo | null { + if (!v || typeof v !== "object") return null; + const name = "tool_name" in v && typeof v.tool_name === "string" + ? v.tool_name + : "toolName" in v && typeof v.toolName === "string" ? v.toolName : ""; + const isErr = "isError" in v ? v.isError === true : false; + const contentRaw = "content" in v ? v.content : undefined; + let contentText: string; + if (Array.isArray(contentRaw)) { + contentText = contentRaw + .map((c: unknown) => { + if (c && typeof c === "object" && "text" in c && typeof c.text === "string") return c.text; + return JSON.stringify(c); + }) + .join("\n"); + } else if (typeof contentRaw === "string") { + contentText = contentRaw; + } else { + contentText = JSON.stringify(contentRaw ?? ""); + } + return { + name, + input: "input" in v ? v.input : undefined, + toolCallId: "toolCallId" in v && typeof v.toolCallId === "string" ? v.toolCallId : "", + isError: isErr, + content: contentText, + }; +} + +interface CtxWithUI { + ui?: { confirm?: (title: string, message: string) => Promise }; +} + +function readCtxUI(v: unknown): CtxWithUI | null { + return v && typeof v === "object" ? (v as CtxWithUI) : null; +} + +function forEvent(event: string): HookReg[] { + return registry.filter((r) => r.event === event && r.matcher === "*"); +} + +function forTool(event: string, ccName: string): HookReg[] { + return registry.filter((r) => r.event === event && matcherToRegex(r.matcher).test(ccName)); +} + +function toolCcName(name: string): string { + return TOOL_UP[name] ?? name; +} + +let pendingContext: string[] = []; + +/** omp-native voice line: the VoiceCompletion hook needs a Claude transcript, + * which omp doesn't have — extract the 🗣️ closer from the message that just + * completed the turn instead. */ +function extractVoiceLine(text: string): string | null { + const lines = text.split("\n"); + for (let i = lines.length - 1; i >= 0; i--) { + // Accept both `🗣️ :` and `🗣️ ZEN:` — the constitution template's + // `` is a placeholder, and TranscriptParser.extractVoiceCompletion + // accepts both. Angle-bracket-only matching silently dropped every closer + // written without brackets, so sessions spoke nothing. (2026-08-22) + const m = lines[i].match(/^🗣️\s*(?:<[^>]{1,32}>|[^:\n]{1,32})\s*:\s*(.+)$/); + if (m) return m[1].trim(); + } + return null; +} + +function assistantMessageText(msg: unknown): string { + if (!msg || typeof msg !== "object") return ""; + if (!("content" in msg)) return ""; + const content = msg.content; + if (Array.isArray(content)) { + return content + .filter((c: unknown): c is { text?: string } => !!c && typeof c === "object" && "text" in c) + .map((c) => c.text ?? "") + .join("\n"); + } + return typeof content === "string" ? content : ""; +} + +export default function lifeosBridge(pi: ExtensionAPI): void { + log({ event: "bridge_init", hooks: registry.length, lifeosDir: LIFEOS_DIR }); + + pi.on("session_start", async () => { + log({ event: "session_start" }); + for (const reg of forEvent("SessionStart")) { + const res = await runHook(reg, { + session_id: process.env.OMP_SESSION_ID ?? "omp", + source: "startup", + cwd: process.cwd(), + hook_event_name: "SessionStart", + }); + const parsed = parseOutput(res.out); + if (parsed.context) pendingContext.push(parsed.context); + log({ hook: hookName(reg.command), cc: "SessionStart", ok: res.ok, ms: res.ms, err: res.err || null, context: !!parsed.context }); + } + }); + + pi.on("turn_start", async () => { + log({ event: "turn_start" }); + for (const reg of forEvent("UserPromptSubmit")) { + const res = await runHook(reg, { + prompt: "(omp turn_start; input payload unavailable)", + session_id: "unknown", + cwd: process.cwd(), + hook_event_name: "UserPromptSubmit", + }); + const parsed = parseOutput(res.out); + if (parsed.context) pendingContext.push(parsed.context); + log({ hook: hookName(reg.command), cc: "UserPromptSubmit", ok: res.ok, ms: res.ms, err: res.err || null, context: !!parsed.context }); + } + }); + + pi.on("tool_call", async (event: unknown, ctx: unknown) => { + const info = readToolCall(event); + if (!info) return; + log({ event: "tool_call", tool: info.name }); + const ccName = toolCcName(info.name); + for (const reg of forTool("PreToolUse", ccName)) { + const res = await runHook(reg, { + tool_name: ccName, + tool_input: info.input ?? {}, + session_id: "unknown", + transcript_path: "", + cwd: process.cwd(), + hook_event_name: "PreToolUse", + permission_mode: "default", + source: "omp", + tool_use_id: info.toolCallId, + }); + const parsed = parseOutput(res.out); + if (parsed.context) pendingContext.push(parsed.context); + log({ hook: hookName(reg.command), cc: "PreToolUse", tool: info.name, ok: res.ok, ms: res.ms, err: res.err || null, decision: parsed.decision || null, context: !!parsed.context }); + if (parsed.decision === "deny") { + return { block: true, reason: parsed.reason || `blocked by LifeOS hook ${hookName(reg.command)}` }; + } + if (parsed.decision === "ask") { + const ui = readCtxUI(ctx); + if (ui?.ui?.confirm) { + const allow = await ui.ui.confirm("LifeOS hook", parsed.reason ?? "allow this tool call?"); + if (!allow) return { block: true, reason: parsed.reason ?? "denied by user" }; + } else { + // Claude Code headless behavior: ask → deny (fail closed) + log({ hook: hookName(reg.command), cc: "PreToolUse", tool: info.name, note: "ask→deny (headless)" }); + return { block: true, reason: parsed.reason ?? "ask denied (headless)" }; + } + } + } + }); + + pi.on("tool_result", async (event: unknown) => { + const info = readToolResult(event); + if (!info) return; + const ccEvent = info.isError ? "PostToolUseFailure" : "PostToolUse"; + log({ event: "tool_result", tool: info.name, isError: info.isError }); + for (const reg of forTool(ccEvent, toolCcName(info.name))) { + const res = await runHook(reg, info.isError + ? { + tool_name: toolCcName(info.name), + tool_input: info.input ?? {}, + tool_response_error: info.content, + session_id: "unknown", + cwd: process.cwd(), + hook_event_name: "PostToolUseFailure", + tool_use_id: info.toolCallId, + } + : { + tool_name: toolCcName(info.name), + tool_input: info.input ?? {}, + tool_response: info.content, + tool_response_error: null, + session_id: "unknown", + cwd: process.cwd(), + hook_event_name: "PostToolUse", + permission_mode: "default", + source: "omp", + tool_use_id: info.toolCallId, + }); + const parsed = parseOutput(res.out); + if (parsed.context) pendingContext.push(parsed.context); + log({ hook: hookName(reg.command), cc: ccEvent, tool: info.name, ok: res.ok, ms: res.ms, err: res.err || null, context: !!parsed.context }); + } + }); + + pi.on("turn_end", async (event: { message?: unknown }) => { + log({ event: "turn_end" }); + for (const reg of forEvent("Stop")) { + const res = await runHook(reg, { + stop_hook_active: true, + transcript_path: "", + cwd: process.cwd(), + hook_event_name: "Stop", + session_id: "unknown", + }); + const parsed = parseOutput(res.out); + if (parsed.context) pendingContext.push(parsed.context); + log({ hook: hookName(reg.command), cc: "Stop", ok: res.ok, ms: res.ms, err: res.err || null, context: !!parsed.context }); + } + // omp-native voice: VoiceCompletion.hook.ts needs a Claude transcript omp + // doesn't have. Speak the turn's OWN final message — the old path read the + // newest session log globally, so with parallel sessions it picked the + // wrong session's line (or none) and voice silently died. (2026-08-22) + if (process.env.OMP_VOICE === "0") return; + const line = extractVoiceLine(assistantMessageText(event?.message)); + if (!line) return; + const t0 = Date.now(); + try { + const res = await fetch(PULSE_NOTIFY, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: line, voice_id: VOICE_ID }), + signal: AbortSignal.timeout(10_000), + }); + log({ voice: res.ok ? "sent" : "failed", status: res.status, ms: Date.now() - t0, line: line.slice(0, 40) }); + } catch (e) { + log({ voice: "failed", err: String(e).slice(0, 120) }); + } + }); + + pi.on("session_shutdown", async () => { + log({ event: "session_shutdown" }); + for (const reg of forEvent("SessionEnd")) { + const res = await runHook(reg, { + session_id: "unknown", + transcript_path: "", + cwd: process.cwd(), + hook_event_name: "SessionEnd", + }); + const parsed = parseOutput(res.out); + if (parsed.context) pendingContext.push(parsed.context); + log({ hook: hookName(reg.command), cc: "SessionEnd", ok: res.ok, ms: res.ms, err: res.err || null, context: !!parsed.context }); + } + }); + + // Inject queued additionalContext (delta blocks, rules) as a system message + // before the next LLM call — replaces Claude Code's implicit injection. + pi.on("context", async (event: unknown) => { + if (pendingContext.length === 0) return; + const text = pendingContext.join("\n"); + pendingContext = []; + log({ event: "context_inject", chars: text.length }); + const messages = event && typeof event === "object" && "messages" in event && Array.isArray(event.messages) + ? [...event.messages] + : []; + messages.push({ role: "system", content: [{ type: "text", text }] }); + return { messages }; + }); +}