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
50 changes: 37 additions & 13 deletions LifeOS/install/hooks/handlers/DocCrossRefIntegrity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,16 @@
* The deterministic layer detects WHAT changed. The inference layer understands
* HOW docs need updating — generating surgical edit pairs, never full rewrites.
*
* TRIGGER: Stop hook (via DocIntegrity.hook.ts)
* TRIGGER: SessionEnd (via DocIntegrity.hook.ts)
*
* TEARDOWN BUDGET (2026-07-19): the harness kills the whole hook at 30s and
* reports only "Hook cancelled" — the hook cannot self-report, because its
* fatal path exits 0 and every handler call is individually try/caught.
* Measured worst case before this change: up to 15s inference (a spawned
* `claude` subprocess) + a deliberate 3s sleep + a 3s voice fetch = ~21s,
* BEFORE RebuildArchSummary's then-unbounded generator subprocess even
* started. The inference layer is therefore OFF on the SessionEnd path.
* Set DOCINTEGRITY_INFERENCE=1 to opt back in for a manual run.
*
* PATTERN TYPES CHECKED (deterministic):
* 1. Hook file references (*.hook.ts) - diff against disk
Expand Down Expand Up @@ -797,17 +806,27 @@ export async function handleDocCrossRefIntegrity(
}
}

// Step 6: Inference-powered semantic analysis
// Run inference to catch what grep can't: semantic drift in descriptions.
// Always runs when system files are modified — deterministic checks only catch
// broken refs/counts, not semantic drift (e.g., "this hook does X" when it now does Y).
console.error(`${TAG} === Running inference analysis ===`);
const inferenceEdits = await runInferenceAnalysis(modifiedFiles, docsToCheck);
if (inferenceEdits.length > 0) {
const inferenceApplied = applyInferenceEdits(inferenceEdits);
updatesApplied.push(...inferenceApplied);
// Step 6: Inference-powered semantic analysis — NOT on teardown.
// Catches what grep can't (a doc saying "this hook does X" when it now does Y),
// but it spawns a `claude` subprocess capped at 15s, which is half the harness's
// 30s kill window for the whole hook. On SessionEnd the session is already
// ending, so a semantic correction bought at the cost of losing the entire
// deterministic pass — which is the part that actually fixes broken refs and
// counts — is a bad trade. Opt in explicitly for a manual run.
const inferenceEnabled =
hookInput.hook_event_name !== 'SessionEnd' || process.env.DOCINTEGRITY_INFERENCE === '1';

if (!inferenceEnabled) {
console.error(`${TAG} [INFERENCE] Skipped on SessionEnd (teardown budget). Set DOCINTEGRITY_INFERENCE=1 to force.`);
} else {
console.error(`${TAG} [INFERENCE] No semantic corrections needed`);
console.error(`${TAG} === Running inference analysis ===`);
const inferenceEdits = await runInferenceAnalysis(modifiedFiles, docsToCheck);
if (inferenceEdits.length > 0) {
const inferenceApplied = applyInferenceEdits(inferenceEdits);
updatesApplied.push(...inferenceApplied);
} else {
console.error(`${TAG} [INFERENCE] No semantic corrections needed`);
}
}

// Step 7: Summary
Expand All @@ -827,8 +846,13 @@ export async function handleDocCrossRefIntegrity(
// Step 10: Voice notification — ONLY when actual documentation edits were applied
// No voice for "queued for review" or "in sync" — that's noise
if (updatesApplied.length > 0) {
// Delay 3s so the main 🗣️ {{DA_NAME}} voice line plays first
await new Promise(resolve => setTimeout(resolve, 3000));
// The 3s delay below exists to let the main 🗣️ voice line play first. On
// SessionEnd there is no main voice line to sequence against — the session
// is ending — so it was 3 seconds of a 30s teardown budget spent ordering
// audio nobody is waiting for. Sequence only where sequencing is real.
if (hookInput.hook_event_name !== 'SessionEnd') {
await new Promise(resolve => setTimeout(resolve, 3000));
}

const affectedDocs = new Set<string>();
for (const update of updatesApplied) {
Expand Down
26 changes: 23 additions & 3 deletions LifeOS/install/hooks/handlers/RebuildArchSummary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,25 +94,45 @@ export async function handleRebuildArchSummary(): Promise<void> {
}
}

/**
* Wall-clock ceiling for the generator subprocess.
*
* This runs LAST on the SessionEnd path, after the cross-ref handler. The
* harness kills the whole hook at 30s and reports only "Hook cancelled" — the
* hook cannot self-report, so an unbounded child here surfaced as an
* unexplained cancellation rather than as a slow generator. Bounded so the
* failure is legible and the remaining budget is never consumed silently.
*/
const REBUILD_TIMEOUT_MS = 8000;

async function rebuild(generator: string, cwd: string): Promise<void> {
return new Promise((resolve) => {
const proc = spawn("bun", [generator, "generate"], { cwd, stdio: "pipe" });

let stderr = "";
let settled = false;
const finish = () => { if (!settled) { settled = true; clearTimeout(timer); resolve(); } };

const timer = setTimeout(() => {
console.error(`[RebuildArchSummary] Generator exceeded ${REBUILD_TIMEOUT_MS}ms — killed. Summary left unchanged.`);
try { proc.kill("SIGKILL"); } catch { /* already gone */ }
finish();
}, REBUILD_TIMEOUT_MS);

proc.stderr?.on("data", (chunk) => { stderr += chunk.toString(); });

proc.on("close", (code) => {
if (code === 0) {
console.error("[RebuildArchSummary] Regenerated DOCUMENTATION/ARCHITECTURE_SUMMARY.md");
} else {
} else if (!settled) {
console.error(`[RebuildArchSummary] Regeneration failed (exit ${code}): ${stderr.trim()}`);
}
resolve();
finish();
});

proc.on("error", (err) => {
console.error("[RebuildArchSummary] Spawn error:", err);
resolve();
finish();
});
});
}
53 changes: 53 additions & 0 deletions LifeOS/install/skills/Interceptor/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,59 @@ Agent(subagent_type="general-purpose", prompt="

- **Auto-launch recovery (`EnsureTestProfile.sh`) — safety is the re-verification loop, not the profile arg (2026-07-07).** When the test profile window isn't open, `EnsureTestProfile.sh` launches the configured `--profile-directory` and polls `PreflightIsolation.sh` until the PINNED context connects, then proceeds. It can auto-launch the *wrong* profile with zero risk, because it only ever exits 0 after preflight whole-field-matches `INTERCEPTOR_TEST_CONTEXT_ID` and hard-denies Default — a wrong launch just keeps failing preflight, so the agent never drives it. Two real limits: (1) macOS `open -a "Google Chrome" --args --profile-directory=…` is a **no-op for the args when Chrome is already running** (it just activates) — `LaunchTestProfile.sh` sidesteps this via its direct-`CHROME_BIN` branch, which opens a new profile window even against a running Chrome; if a launch "succeeds" but no new window appears, that's the `open`-already-running case and the direct-binary branch is what actually works. (2) If the launch connects a NEW context UUID (extension was reloaded → UUID rot), preflight won't match the pin and `EnsureTestProfile` STOPS with the durable fix (name the context `interceptor-test` in the popup) — it never rebinds to an unidentified UUID. Only exit 5/6 auto-launch; exit 7 (Default/working target) and 8 (unset config) never do.

### Linux + Flatpak browser — the four-hour class (2026-07-19, Fedora)

All four surfaced in one session on Fedora with Chrome installed as a Flatpak
(`com.google.Chrome`, reached through a `~/.local/bin/google-chrome` shim that
adds `--filesystem=/tmp --filesystem=home --die-with-parent`). Read this block
before debugging a Linux install; the first symptom looks like a dead extension
and is not.

- **A daemon socket created INSIDE a Flatpak sandbox is not connectable from the
host CLI, even though the socket file is plainly visible.** `/tmp` is genuinely
shared both directions (a host-written marker reads inside the sandbox and vice
versa), so `ls -la /tmp/interceptor.sock` shows the file — and `interceptor
status` reports `daemon: not running` in the same second. The endpoint lives in
the sandbox's namespace; the visible file is a shadow. **The fix is a
non-sandboxed browser**, not a Flatpak permission tweak. On this machine that
meant native Vivaldi (`/opt/vivaldi-snapshot/`), which Interceptor supports;
its relay's socket is host-accessible and the CLI connects immediately. Copy
the NMH manifest to that browser's own dir first
(`~/.config/vivaldi/NativeMessagingHosts/`).
- **A windowless orphaned Flatpak instance can hold port 19222 invisibly, and
`pkill` on the PID you can see does not clear it.** The operator sees no Chrome
window and reasonably reports "no Chrome is open" while
`bwrap --args N -- chrome --profile-directory=X` is still alive with its daemon
owning the port, starving every relay that starts afterwards — including a
different browser's. `kill <pid>` on the visible child does not propagate
through `bwrap`. **`flatpak kill com.google.Chrome` is the reliable clear.**
Check for this whenever a relay reports the port held but no browser is
visibly running.
- **The relay only attaches to a singleton it spawned itself; a losing duplicate
unlinks the winner's socket on exit.** The log signature is
`spawning detached standalone daemon` → `ws port 19222 already held ... exiting
this duplicate` → `detached standalone daemon did not become ready before
timeout` → `falling back to in-process singleton` → `exiting so the existing
daemon serves everyone`. It never falls back to connecting as a plain ws
client. Consequence: **CLI calls are self-destructive while another daemon owns
the port** — each one auto-spawns a duplicate that deletes the live socket, so
diagnosing the problem re-causes it. If the socket keeps vanishing, stop
running CLI commands and fix ownership first. (Upstream-worthy: a daemon should
never unlink a socket it did not create.)
- **`--load-extension` is dead (Chrome 137+; confirmed ignored on 150).** The
profile comes up carrying only the five default component extensions. The
`chrome://extensions` → Load unpacked picker is the only route — and in a
Flatpak browser it goes through the XDG Document Portal, so the chosen path
appears as `/run/flatpak/doc/<id>/...` in any error. That prefix is normal and
is not the fault. **`~/.claude` is a dotfolder and GTK pickers hide dotfiles:
press Ctrl+H in the picker** or the operator cannot navigate to the extension
at all.

**Agent-side footgun from the same session:** `pkill -f interceptor-daemon` run
from Bash matches the invoking shell's own command line and kills it — the
command dies at exit 144 with truncated output, which reads like a wedge. Split
the literal (`PAT="intercep""tor-daemon"; pkill -f "$PAT"`) or kill by PID.

## Stealth Verification

Passes all major bot detection:
Expand Down