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
13 changes: 10 additions & 3 deletions LifeOS/install/LIFEOS/DOCUMENTATION/Hooks/HookSystem.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ Claude Code supports the following hook events:
- Capture prompts for analysis
- Detect ratings and sentiment

**Current Hooks (fire order per settings.json — 9 hooks):**
**Current Hooks (fire order per settings.json — 10 hooks):**
```json
{
"UserPromptSubmit": [
Expand All @@ -146,7 +146,8 @@ Claude Code supports the following hook events:
{ "hooks": [ { "type": "command", "command": "$HOME/.claude/hooks/MemoryTurnStart.hook.ts", "timeout": 8 } ] },
{ "hooks": [ { "type": "command", "command": "$HOME/.claude/hooks/AlgorithmNudge.hook.ts", "timeout": 5, "async": true } ] },
{ "hooks": [ { "type": "command", "command": "$HOME/.claude/hooks/TimeContext.hook.ts", "timeout": 5, "async": true } ] },
{ "hooks": [ { "type": "command", "command": "$HOME/.claude/hooks/ModelRungGuard.hook.ts", "timeout": 5, "async": true } ] }
{ "hooks": [ { "type": "command", "command": "$HOME/.claude/hooks/ModelRungGuard.hook.ts", "timeout": 5, "async": true } ] },
{ "hooks": [ { "type": "command", "command": "$HOME/.claude/hooks/ContinuationArm.hook.ts", "timeout": 5 } ] }
]
}
```
Expand Down Expand Up @@ -200,6 +201,11 @@ Claude Code supports the following hook events:
- Compares the `model` pin in `settings.json` against the model on the last assistant message in the transcript, and reports when the session is running below the pinned rung
- Reports only. A hook cannot set the main loop's carrier, so it names the sanctioned move from OPERATIONAL_RULES § Model selection (dispatch MAX-class work up with a tier alias) rather than asking for a `/model` change. Reads a tail of the transcript; no LLM calls; any error exits 0

**ContinuationArm.hook.ts** — the spoken front door for ContinuationGate (timeout 5s, sync)
- Deterministic directive parser (`lib/continuation-directive.ts`): saying **"auto-continue for 2 hours"** (or "until done", "on", the overnight family — "auto-continue overnight / all night / until morning / while I sleep" = an 8h window — with an optional "cap N") grants THIS session a time-boxed licence the Stop-side ContinuationGate honours; **"auto-continue off"** revokes it. The keyword must appear with an explicit cue — a question about auto-continue, a code review mentioning it, or a deliberative "should we auto-continue…" arms nothing; a polite spoken request with a duration ("could you auto-continue for 2h?") does
- Writes a session-scoped `grant` (merge, 0600, read-back-verified) into `MEMORY/STATE/continuation-cap.json`; clamps: 12h window, 50-continue cap. Expiry is the safety property — nothing renews a grant implicitly, and an expired one is indistinguishable from none. No model call anywhere on this path; failures are silent-open (the gate just keeps its standing budget)
- Division of labour: this hook = arming by utterance; `LIFEOS/TOOLS/ContinuationDoctor.ts` = standing config + wiring checks + verdict history; `ContinuationGate` (Stop) = enforcement, reading the grant fail-closed

> **Historical — retired 2026-07-11 (hooks-BPE pass):**
> - **`TheRouter.hook.ts` retired entirely** (commit `4dd0fbe19`). It owned per-prompt Mode + Tier classification (emitting `MODE: MINIMAL|NATIVE|ALGORITHM | TIER: E1-E5`); that whole scheme was abolished. There is no successor classifier — the model discovers difficulty from the work, and model rungs now live in `LIFEOS/TOOLS/models.ts` + `AgentInvocation.hook.ts`. Its deterministic router libs (`router-deterministic`, `router-classifier`, `RouterShadow`, `ai-speak-patterns`) were deleted with it.
> - **`MemoryReviewTrigger.hook.ts` retired** (commit `4dd0fbe19`) — its per-prompt cadence tick was absorbed by `MemoryReviewFire` v2 at Stop.
Expand Down Expand Up @@ -273,7 +279,8 @@ Each Stop hook is a self-contained `.hook.ts` file that reads stdin via shared `
4. `ISAFoldGate.run()` — D-50 enforcement (added 2026-07-29): prod mutated this turn + active run + ISA untouched + the reply silent on ISA state → block. Phrase-independent, so it sees the gap `ISACloseGate`'s completion regex cannot ("rigged and armed" isn't "done")
5. `ISAGate.run()` — blocks a close (`phase: complete` written this turn) on structural ISA violations (non-M/N progress, fog-at-complete, missing anchors_to); scoped to ISAs touched this turn — the structural tooth complementing ISACloseGate's staleness tooth
6. `WritingGate.run()` — blocks publication prose without a real Pangram run (strong signals)
- The FIRST gate returning `decision:"block"` wins; the recovery turn re-runs all gates. Fails open per-gate so one gate's crash never silences the others
7. `ContinuationGate.run()` — the throughput gate, and the ONLY reason to KEEP GOING: when the turn asked the principal nothing, produced clean tool evidence, and the declared work is provably unfinished (open ISC criteria on the bound run, or a strict `finished:false` from the `lib/continuation-judge.ts` haiku-tier judge on no-ISA sessions), it hands the run one more turn instead of handing back. **Registered LAST on purpose — every stop-reason outranks it.** Ships in SHADOW (cap 0: verdicts logged to `MEMORY/OBSERVABILITY/continuation-gate.jsonl`, never acted on, no model called on the unarmed no-ISA path); arm/disarm/inspect live with `bun LIFEOS/TOOLS/ContinuationDoctor.ts --arm N | --off | --arm-isa N`, per-ISA via `autocontinue: N` frontmatter, or by SAYING **"auto-continue for 2 hours"** in a prompt (session-scoped expiring grant via `ContinuationArm.hook.ts` — see Section 3). Loop safety: per-run consecutive-continue counter (written and READ BACK before any continuation; reset when the principal speaks), 45-min wall-clock ceiling (`LIFEOS_AUTOCONTINUE_MAX_MS`), hard cap 8, kill switch `CONTINUATIONGATE_OFF=1`
- Arbitration lives in `lib/gate-chain.ts`: the FIRST gate returning `decision:"block"` wins and short-circuits, and a block from ANY gate outranks a non-block object (e.g. a `systemMessage`) from an earlier one — the old inline reducer kept the first object outright, which silently swallowed later blocks. The recovery turn re-runs all gates. Fails open per-gate so one gate's crash never silences the others
- `OutputFormatGate.run()` was dropped from the chain 2026-07-11 (it was telemetry-only and policed the retired mode-banner system; voice/format drift is now `DriftReminder`'s job)

**`MemoryReviewFire.hook.ts`** (v2) — owns the WHOLE memory-review cadence (consolidated 2026-07-11)
Expand Down
176 changes: 176 additions & 0 deletions LifeOS/install/LIFEOS/TOOLS/ContinuationDoctor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
#!/usr/bin/env bun
/**
* ContinuationDoctor — "is the auto-continue actually working?" in one command.
*
* The ContinuationGate's integration points fail SILENTLY — every unit test stays
* green while the feature sits disconnected — so this tool answers the only useful
* question: what is provably live right now.
*
* Also the arm/disarm control: `--arm N` writes a cap FILE the hook re-reads every
* Stop, so it reaches sessions already running with no restart. That is also why
* the file outranks env: `settings.json` env is read once at session start and
* strands every open window.
*
* Usage:
* bun ContinuationDoctor.ts # human report
* bun ContinuationDoctor.ts --json # machine readable
* bun ContinuationDoctor.ts --arm 3 # arm the no-ISA path, live, no restart
* bun ContinuationDoctor.ts --off # disarm it
* bun ContinuationDoctor.ts --arm-isa 3 # arm the ISA path's standing cap, live
* bun ContinuationDoctor.ts --off-isa # back to shadow
*
* Exit codes: 0 = wiring intact, 1 = a wiring check failed (the upgrade tripwire).
*/

import { readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
import { join, dirname } from "node:path";
import { homedir } from "node:os";

const CLAUDE = join(homedir(), ".claude");
const HOOKS = join(CLAUDE, "hooks");
const LIFEOS = process.env.LIFEOS_DIR || join(CLAUDE, "LIFEOS");
const VERDICTS = join(LIFEOS, "MEMORY", "OBSERVABILITY", "continuation-gate.jsonl");
const CAP_PATH = join(LIFEOS, "MEMORY", "STATE", "continuation-cap.json");

const read = (p: string): string => { try { return readFileSync(p, "utf-8"); } catch { return ""; } };

function currentCap(): number {
try {
const o = JSON.parse(read(CAP_PATH));
if (o && typeof o.all === "number") return o.all;
} catch { /* fall through */ }
return Number(process.env.LIFEOS_AUTOCONTINUE_ALL ?? "0") || 0;
}

/** Standing cap for ISA-bound sessions. Mirrors the gate's file-then-env order. */
function currentIsaCap(): number {
try {
const o = JSON.parse(read(CAP_PATH));
if (o && typeof o.isa === "number") return o.isa;
} catch { /* fall through */ }
return Number(process.env.LIFEOS_AUTOCONTINUE_MAX ?? "0") || 0;
}

/**
* Arm or disarm one path. MERGES rather than overwrites: the file also carries the
* other path's cap, and a whole-object write would silently revoke it.
*/
function setCap(n: number, key: "all" | "isa" = "all"): void {
mkdirSync(dirname(CAP_PATH), { recursive: true });
let existing: Record<string, unknown> = {};
try { existing = JSON.parse(read(CAP_PATH)) ?? {}; } catch { /* start fresh */ }
existing[key] = n;
// 0600: arming is a privilege decision — on a shared-group install a group-writable
// cap file would let another account raise autonomy without touching hook code.
// The explicit chmod matters because fs mode options only apply on CREATE; a
// pre-existing looser file would otherwise keep its old permissions forever.
writeFileSync(CAP_PATH, JSON.stringify(existing, null, 2), { mode: 0o600 });
chmodSync(CAP_PATH, 0o600);
const label = key === "all" ? "no-ISA path" : "ISA path standing cap";
console.log(n > 0
? `✅ Armed the ${label}: up to ${n} auto-continue${n === 1 ? "" : "s"} per run. Live in every open session on its next turn.`
: `⭕ ${label} back to ${key === "all" ? "off" : "shadow"}. Live in every open session on its next turn.`);
}

const armIsaIdx = process.argv.indexOf("--arm-isa");
if (armIsaIdx > -1) {
const n = Number(process.argv[armIsaIdx + 1]);
if (!Number.isFinite(n) || n < 0) { console.error("usage: --arm-isa <0-8>"); process.exit(2); }
setCap(Math.min(Math.floor(n), 8), "isa");
process.exit(0);
}
if (process.argv.includes("--off-isa")) { setCap(0, "isa"); process.exit(0); }

const armIdx = process.argv.indexOf("--arm");
if (armIdx > -1) {
const n = Number(process.argv[armIdx + 1]);
if (!Number.isFinite(n) || n < 0) { console.error("usage: --arm <0-8>"); process.exit(2); }
setCap(Math.min(Math.floor(n), 8));
process.exit(0);
}
if (process.argv.includes("--off")) { setCap(0); process.exit(0); }

interface Check { name: string; ok: boolean; detail: string }

/** The same integration points ContinuationWiring.integration.test.ts guards. Checked
* here too so a human can ask the question without running a test suite. */
function wiringChecks(): Check[] {
const stop = read(join(HOOKS, "StopGates.hook.ts"));
const gate = read(join(HOOKS, "ContinuationGate.hook.ts"));
const entries = [...stop.matchAll(/\["([A-Za-z]+)",\s*[a-zA-Z]+\]/g)].map((m) => m[1]);

return [
{ name: "ContinuationGate exists", ok: !!gate, detail: gate ? "present" : "MISSING" },
{ name: "registered in StopGates", ok: stop.includes('["ContinuationGate", continuationGate]'), detail: "chain entry" },
{ name: "registered LAST in chain", ok: entries.at(-1) === "ContinuationGate", detail: `order: ${entries.join(" → ") || "none"}` },
{ name: "chain arbitration extracted", ok: stop.includes("decide(GATES, input)"), detail: "lib/gate-chain.ts (a block outranks earlier messages)" },
{ name: "no-ISA sessions routed to the judge", ok: gate.includes("if (!active) return await runJudgePath("), detail: "most sessions depend on this line" },
{ name: "cap read from file, not just env", ok: gate.includes("readFile(CAP_PATH)"), detail: "so arming reaches sessions already running" },
];
}

function verdictHistogram(): { total: number; byWhy: Record<string, number>; lastTs: string } {
const raw = read(VERDICTS);
const byWhy: Record<string, number> = {};
let total = 0, lastTs = "never";
for (const line of raw.split("\n")) {
if (!line.trim()) continue;
try {
const r = JSON.parse(line);
byWhy[r.why ?? "?"] = (byWhy[r.why ?? "?"] ?? 0) + 1;
total++;
if (r.ts) lastTs = r.ts;
} catch { /* skip */ }
}
return { total, byWhy, lastTs };
}

const wiring = wiringChecks();
const verdicts = verdictHistogram();
const wiringOk = wiring.every((c) => c.ok);

if (process.argv.includes("--json")) {
console.log(JSON.stringify({ wiringOk, wiring, verdicts }, null, 2));
process.exit(wiringOk ? 0 : 1);
}

const mark = (ok: boolean) => (ok ? "✅" : "❌");
console.log("\n═══ ContinuationGate — auto-continue ═══\n");

console.log("WIRING (fails loudly if an upgrade dropped the registration)");
for (const c of wiring) console.log(` ${mark(c.ok)} ${c.name.padEnd(38)} ${c.detail}`);

console.log("\nVERDICTS");
console.log(` logged: ${verdicts.total} (last: ${verdicts.lastTs})`);
for (const [why, n] of Object.entries(verdicts.byWhy).sort((a, b) => b[1] - a[1])) {
console.log(` ${String(n).padStart(5)} ${why}`);
}

const isaCap = currentIsaCap();
console.log("\nISA PATH — needs an active run; open ISC criteria answer \"finished?\"");
console.log(` ${isaCap > 0 ? "✅ ARMED" : "⭕ shadow"} standing cap=${isaCap}${isaCap > 0 ? " (an ISA's own `autocontinue:` overrides it)" : " → arm to act on verdicts"}`);
console.log(" arm/disarm live, no restart: bun ContinuationDoctor.ts --arm-isa 3 | --off-isa");
const wouldHave = verdicts.byWhy["shadow-would-continue"] ?? 0;
console.log(wouldHave > 0
? ` → ${wouldHave} turn(s) it would have continued while in shadow.`
: " → no would-have-continued turns yet; every verdict so far was a hand-back.");

// Live grant, if any — the "auto-continue for 2 hours" utterance surface.
try {
const g = JSON.parse(read(CAP_PATH))?.grant;
if (g && typeof g.untilMs === "number") {
const live = Date.now() < g.untilMs;
console.log(`\nGRANT — spoken licence ("auto-continue for 2h" in a prompt; ContinuationArm hook)`);
console.log(` ${live ? "✅ LIVE " : "⭕ expired"} session=${String(g.session).slice(0, 12)}… cap=${g.cap} until=${new Date(g.untilMs).toISOString()}`);
console.log(` revoke by saying "auto-continue off" in that session`);
}
} catch { /* no grant to show */ }

const allCap = currentCap();
console.log("\nNO-ISA PATH — a judge answers \"finished?\" for sessions without a run");
console.log(` ${allCap > 0 ? "✅ ARMED" : "⭕ off "} cap=${allCap}${allCap > 0 ? ` (${allCap} continues per session, reset when you speak)` : " → off: no verdicts computed, no model called"}`);
console.log(" judge: LIFEOS/TOOLS/Inference.ts --level low; only an explicit finished:false continues");
console.log(" arm/disarm live, no restart: bun ContinuationDoctor.ts --arm 3 | --off");
console.log(" hard kill: CONTINUATIONGATE_OFF=1");

process.exit(wiringOk ? 0 : 1);
104 changes: 104 additions & 0 deletions LifeOS/install/hooks/ContinuationArm.hook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
#!/usr/bin/env bun
/**
* ContinuationArm.hook.ts — the spoken front door for ContinuationGate.
*
* "auto-continue for 2 hours" said in a prompt grants THIS session a time-boxed
* licence to continue past turn boundaries; "auto-continue off" revokes it. The
* grammar is deterministic and strict (lib/continuation-directive.ts): the literal
* keyword plus an explicit cue, so a question ABOUT auto-continue never arms it,
* and no model sits between the user's words and the arming decision.
*
* Division of labour, on purpose:
* - THIS hook (UserPromptSubmit) — arming by utterance, session-scoped, expiring.
* - ContinuationDoctor (CLI) — standing config, wiring checks, verdict history.
* - ContinuationGate (Stop) — the enforcement; reads the grant fail-closed.
*
* The write is a MERGE into the cap file (the standing `isa`/`all` keys survive),
* 0600 (arming is a privilege decision), read back before confirming. Every failure
* is silent-open: this hook must never be why a prompt breaks, and an unwritten
* grant simply means the gate keeps its standing budget.
*
* TRIGGER: UserPromptSubmit (registered in hooks.json)
*/

import { readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
import { dirname, join } from "node:path";
import { parseAutoContinueDirective, type Directive } from "./lib/continuation-directive";

const LIFEOS = process.env.LIFEOS_DIR || join(process.env.HOME!, ".claude", "LIFEOS");
const CAP_PATH = join(LIFEOS, "MEMORY", "STATE", "continuation-cap.json");

/** Apply a parsed directive to the cap file. Exported for tests; the shim below
* owns stdin/stdout. Returns the user-facing confirmation, or null when nothing
* changed (including every failure — fail silent-open, never break a prompt). */
export function applyDirective(d: Directive, session: string, capPath: string = CAP_PATH): string | null {
try {
let existing: Record<string, unknown> = {};
try { existing = JSON.parse(readFileSync(capPath, "utf-8")) ?? {}; } catch { /* start fresh */ }

if (d.action === "off") {
if (!existing.grant) return "⏭️ auto-continue: no licence was active.";
delete existing.grant;
} else {
existing.grant = { session, cap: d.cap, untilMs: d.untilMs };
}

mkdirSync(dirname(capPath), { recursive: true });
writeFileSync(capPath, JSON.stringify(existing, null, 2), { mode: 0o600 });
chmodSync(capPath, 0o600); // fs mode only applies on create; enforce on the existing file too

// Read back: an unpersisted grant must not be confirmed as live.
const back = JSON.parse(readFileSync(capPath, "utf-8"));
if (d.action === "off") {
return back?.grant ? null : "⏭️ auto-continue: licence revoked. Standing budget applies from the next turn.";
}
const g = back?.grant;
if (!g || g.session !== session || g.untilMs !== d.untilMs) return null;
const until = new Date(d.untilMs);
const hh = String(until.getHours()).padStart(2, "0");
const mm = String(until.getMinutes()).padStart(2, "0");
return `⏭️ auto-continue armed for THIS session until ${hh}:${mm} (up to ${d.cap} continues). ` +
`Questions to you, tool errors, and no-work turns still hand back. Say "auto-continue off" to revoke.`;
} catch { return null; }
}

async function readStdin(): Promise<string> {
const timeout = new Promise<string>((r) => setTimeout(() => r(""), 2000));
const read = (async () => {
let s = "";
for await (const chunk of Bun.stdin.stream()) s += new TextDecoder().decode(chunk);
return s;
})();
return Promise.race([read, timeout]);
}

if (import.meta.main) {
(async () => {
const raw = await readStdin();
if (!raw.trim()) process.exit(0);
let input: { session_id?: string; prompt?: string };
try { input = JSON.parse(raw); } catch { process.exit(0); }
const session = input.session_id ?? "";
const prompt = input.prompt ?? "";
if (!session || !prompt) process.exit(0);

const d = parseAutoContinueDirective(prompt);
if (!d) process.exit(0);
const confirmation = applyDirective(d, session);
if (confirmation) {
console.log(JSON.stringify({
systemMessage: confirmation,
hookSpecificOutput: {
hookEventName: "UserPromptSubmit",
additionalContext: d.action === "arm"
? `The principal armed auto-continue for this session (until ${new Date(d.untilMs).toISOString()}). ` +
`The Stop gate will hand you continuation turns while work is demonstrably unfinished; you do not ` +
`need to ask permission to keep working, and you should not stop to ask anything you can decide ` +
`reversibly and note.`
: "The principal revoked auto-continue for this session; the standing budget applies again.",
},
}));
}
process.exit(0);
})().catch(() => process.exit(0));
}
Loading