Skip to content
Closed
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
2 changes: 1 addition & 1 deletion LifeOS/install/LIFEOS/ALGORITHM/v6.24.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ curl -s -X POST http://localhost:31337/notify -H "Content-Type: application/json

Entry: `"Entering the Algorithm"` (before OBSERVE). Transitions: `"Entering the PHASE_NAME phase."` (first action each phase).

**Phase-tracking layering (one source of truth, three writers):** ISA frontmatter `phase:` is ground truth; `ISASync.hook.ts` syncs ISA edits to `work.json`; `bun ~/.claude/LIFEOS/TOOLS/AlgoPhase.ts <phase>` is the operational write that drives the real-time dashboard at each transition; the chat phase header is decorative. Invoke order at every transition: **phase header → AlgoPhase CLI → voice curl → ISA edit.** Pulse SSE (`GET /api/algorithm/stream`, 100ms `work.json` poll) renders end-to-end under 250ms p95. TheRouter pre-writes `currentMode: 'algorithm'` + `phase: 'starting'` on classification to kill the wrong-display window. If the dashboard is stuck, check ISA frontmatter first.
**Phase-tracking layering (one source of truth, three writers):** ISA frontmatter `phase:` is ground truth; `ISASync.hook.ts` syncs ISA edits to `work.json`; `bun ~/.claude/LIFEOS/TOOLS/AlgoPhase.ts <phase> --slug <slug>` (always pass the session slug — with multiple live sessions a bare call now fails closed instead of guessing) is the operational write that drives the real-time dashboard at each transition; the chat phase header is decorative. Invoke order at every transition: **phase header → AlgoPhase CLI → voice curl → ISA edit.** Pulse SSE (`GET /api/algorithm/stream`, 100ms `work.json` poll) renders end-to-end under 250ms p95. TheRouter pre-writes `currentMode: 'algorithm'` + `phase: 'starting'` on classification to kill the wrong-display window. If the dashboard is stuck, check ISA frontmatter first.

---

Expand Down
172 changes: 172 additions & 0 deletions LifeOS/install/LIFEOS/TOOLS/AlgoPhase.resolution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/**
* AlgoPhase.resolution.test.ts — session-resolution contract for AlgoPhase.ts
*
* Drives the CLI as a subprocess against a throwaway registry (LIFEOS_DIR
* points at a per-test temp tree, which hooks/lib/paths.ts honors), so the
* real work.json is never touched. Run with: bun test AlgoPhase.resolution.test.ts
*/

import { test, expect } from "bun:test";
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";

const TOOL = join(import.meta.dir, "AlgoPhase.ts");

const MIN = 60 * 1000;
const iso = (agoMs: number) => new Date(Date.now() - agoMs).toISOString();

const UUID_A = "aaaaaaaa-0000-0000-0000-000000000001";
const UUID_B = "aaaaaaaa-0000-0000-0000-000000000002";

function algoRow(overrides: Record<string, unknown> = {}) {
return {
mode: "interactive",
currentMode: "algorithm",
phase: "build",
updatedAt: iso(1 * MIN),
started: iso(30 * MIN),
effort: "E3",
...overrides,
};
}

/** Write a fixture registry into a fresh LIFEOS_DIR temp tree. */
function fixture(sessions: Record<string, unknown>): string {
const dir = mkdtempSync(join(tmpdir(), "algophase-test-"));
mkdirSync(join(dir, "MEMORY", "STATE"), { recursive: true });
writeFileSync(join(dir, "MEMORY", "STATE", "work.json"), JSON.stringify({ sessions }, null, 2));
return dir;
}

function run(lifeosDir: string, args: string[], extraEnv: Record<string, string> = {}) {
const proc = Bun.spawnSync(["bun", TOOL, ...args], {
env: {
PATH: process.env.PATH ?? "",
HOME: process.env.HOME ?? "",
LIFEOS_DIR: lifeosDir,
// CLAUDE_SESSION_ID and CLAUDE_PLUGIN_ROOT deliberately absent unless a
// test injects them — the env is fully replaced, not inherited.
...extraEnv,
},
});
return {
exitCode: proc.exitCode,
stdout: proc.stdout.toString(),
stderr: proc.stderr.toString(),
};
}

function readSessions(lifeosDir: string): Record<string, any> {
return JSON.parse(readFileSync(join(lifeosDir, "MEMORY", "STATE", "work.json"), "utf-8")).sessions;
}

test("two fresh live rows, bare call: fails closed, lists candidates, writes nothing", () => {
const dir = fixture({
"session-a": algoRow({ sessionUUID: UUID_A, phase: "build" }),
"session-b": algoRow({ sessionUUID: UUID_B, phase: "build", updatedAt: iso(2 * MIN) }),
});
const before = readFileSync(join(dir, "MEMORY", "STATE", "work.json"), "utf-8");
const r = run(dir, ["think"]);
expect(r.exitCode).toBe(1);
expect(r.stderr).toContain("ERR:");
expect(r.stderr).toContain("ambiguous");
expect(r.stderr).toContain("session-a");
expect(r.stderr).toContain("session-b");
expect(readFileSync(join(dir, "MEMORY", "STATE", "work.json"), "utf-8")).toBe(before);
});

test("single fresh live row, bare call: convenience path writes the phase", () => {
const dir = fixture({ "session-a": algoRow({ sessionUUID: UUID_A }) });
const r = run(dir, ["think"]);
expect(r.exitCode).toBe(0);
expect(r.stdout).toContain("OK: session-a build→think");
expect(readSessions(dir)["session-a"].phase).toBe("think");
});

test("stale live rows are not guessable: 2h-idle row is skipped, fresh row wins", () => {
const dir = fixture({
"session-stale": algoRow({ sessionUUID: UUID_B, updatedAt: iso(120 * MIN) }),
"session-a": algoRow({ sessionUUID: UUID_A }),
});
const r = run(dir, ["think"]);
expect(r.exitCode).toBe(0);
expect(r.stdout).toContain("OK: session-a");
expect(readSessions(dir)["session-stale"].phase).toBe("build");
});

test("only stale rows: bare call errors instead of resurrecting a ghost", () => {
const dir = fixture({ "session-stale": algoRow({ sessionUUID: UUID_B, updatedAt: iso(120 * MIN) }) });
const r = run(dir, ["think"]);
expect(r.exitCode).toBe(1);
expect(r.stderr).toContain("ERR:");
});

test("CLAUDE_SESSION_ID resolves the matching row, not the most recent", () => {
const dir = fixture({
"session-a": algoRow({ sessionUUID: UUID_A, updatedAt: iso(5 * MIN) }),
"session-b": algoRow({ sessionUUID: UUID_B, updatedAt: iso(1 * MIN) }),
});
const r = run(dir, ["verify"], { CLAUDE_SESSION_ID: UUID_A });
expect(r.exitCode).toBe(0);
expect(r.stdout).toContain("OK: session-a");
expect(readSessions(dir)["session-b"].phase).toBe("build");
});

test("CLAUDE_SESSION_ID set but unmatched: hard error, no fall-through guess", () => {
const dir = fixture({ "session-a": algoRow({ sessionUUID: UUID_A }) });
const r = run(dir, ["think"], { CLAUDE_SESSION_ID: "aaaaaaaa-0000-0000-0000-00000000dead" });
expect(r.exitCode).toBe(1);
expect(r.stderr).toContain("ERR:");
expect(readSessions(dir)["session-a"].phase).toBe("build");
});

test("explicit --uuid outranks CLAUDE_SESSION_ID env", () => {
const dir = fixture({
"session-a": algoRow({ sessionUUID: UUID_A }),
"session-b": algoRow({ sessionUUID: UUID_B }),
});
const r = run(dir, ["plan", "--uuid", UUID_B], { CLAUDE_SESSION_ID: UUID_A });
expect(r.exitCode).toBe(0);
expect(r.stdout).toContain("OK: session-b");
expect(readSessions(dir)["session-a"].phase).toBe("build");
});

test("--uuid with no live match: error, not a fall-through", () => {
const dir = fixture({ "session-a": algoRow({ sessionUUID: UUID_A }) });
const r = run(dir, ["think", "--uuid", "aaaaaaaa-0000-0000-0000-00000000dead"]);
expect(r.exitCode).toBe(1);
expect(r.stderr).toContain("ERR:");
});

test("--slug works on a phase:complete row (resume-after-complete is sanctioned)", () => {
const dir = fixture({ "session-done": algoRow({ sessionUUID: UUID_A, phase: "complete" }) });
const r = run(dir, ["observe", "--slug", "session-done"]);
expect(r.exitCode).toBe(0);
expect(r.stdout).toContain("OK: session-done complete→observe");
});

test("--slug and --uuid that disagree: error, nothing written", () => {
const dir = fixture({ "session-a": algoRow({ sessionUUID: UUID_A }) });
const r = run(dir, ["think", "--slug", "session-a", "--uuid", UUID_B]);
expect(r.exitCode).toBe(1);
expect(r.stderr).toContain("disagree");
expect(readSessions(dir)["session-a"].phase).toBe("build");
});

test("prototype key as --slug does not false-match (Object.hasOwn guard)", () => {
const dir = fixture({ "session-a": algoRow({ sessionUUID: UUID_A }) });
const r = run(dir, ["think", "--slug", "constructor"]);
expect(r.exitCode).toBe(1);
expect(r.stderr).toContain("not found");
});

test("__pulse_strip is never addressable, even explicitly", () => {
const dir = fixture({
__pulse_strip: { anything: true },
"session-a": algoRow({ sessionUUID: UUID_A }),
});
const r = run(dir, ["think", "--slug", "__pulse_strip"]);
expect(r.exitCode).toBe(1);
expect(r.stderr).toContain("not found");
});
151 changes: 112 additions & 39 deletions LifeOS/install/LIFEOS/TOOLS/AlgoPhase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,17 @@
* Phases (case-insensitive):
* observe | think | plan | build | execute | verify | learn | complete | starting
*
* Slug resolution priority:
* 1. --slug X (explicit)
* 2. row whose sessionUUID === CLAUDE_SESSION_ID env var
* 3. row whose sessionUUID === --uuid X
* 4. most-recent (by updatedAt) algorithm-mode row in work.json
* Slug resolution priority (deterministic, fail-closed):
* 1. --slug X (explicit; unknown slug is an error; complete rows allowed —
* resume-after-complete is a sanctioned flow)
* 2. --uuid X (explicit flags outrank ambient env; no live match is an error)
* 3. row whose sessionUUID === CLAUDE_SESSION_ID env var (set-but-unmatched
* is an error, never a fall-through to a guess)
* 4. the SINGLE fresh live algorithm-mode row in work.json (updated within
* the last 60 minutes) — if zero or more than one qualify, the tool
* refuses to guess: it errors out listing the candidates and performs
* NO write. (Previously it warned and wrote to the most-recent row,
* which could flip the OTHER session's phase.)
*
* Output:
* On success — prints `OK: <slug> <prev>→<phase>` to stdout, exits 0.
Expand Down Expand Up @@ -76,11 +82,13 @@ Usage:

Phases: observe | think | plan | build | execute | verify | learn | complete | starting

Slug resolution priority:
1. --slug X
2. row with sessionUUID === \$CLAUDE_SESSION_ID
3. row with sessionUUID === --uuid X
4. most-recent algorithm-mode row in work.json
Slug resolution priority (deterministic, fail-closed):
1. --slug X (unknown slug errors; complete rows allowed for resume)
2. row with sessionUUID === --uuid X (explicit flag outranks env; no live match errors)
3. row with sessionUUID === \$CLAUDE_SESSION_ID (set-but-unmatched errors)
4. the SINGLE live algorithm-mode row updated in the last 60 minutes —
zero or multiple candidates error out (listed, nothing written)
instead of guessing

Examples:
bun ~/.claude/LIFEOS/TOOLS/AlgoPhase.ts think
Expand All @@ -102,53 +110,117 @@ interface Session {
modeHistory?: Array<{ mode: string; startedAt: number; endedAt?: number }>;
}

function pickAlgorithmModeRow(sessions: Record<string, Session>): string | null {
let best: { slug: string; t: number } | null = null;
// Candidate-freshness horizon for the no-flag fallback. Crashed or abandoned
// sessions leave non-complete rows behind (the registry's stale sweep only
// prunes them after ~7 days, and SessionCleanup fires only on a clean
// SessionEnd), so "not complete" alone is far too weak a liveness signal —
// fail-closed against week-old ghosts would hard-fail every bare call. A
// session actively running the Algorithm touches its row on every phase emit
// and tool-activity hook, so an hour of silence means the row is not driving
// anything right now. Stale rows are only excluded from GUESSING — they stay
// addressable explicitly via --slug or --uuid.
const CANDIDATE_FRESH_MS = 60 * 60 * 1000;

/** Millisecond timestamp of a row's last update (0 when unparseable). */
function rowUpdatedMs(s: Session): number {
const t = new Date(s.updatedAt || s.started || 0).getTime();
return Number.isFinite(t) ? t : 0;
}

/** Fresh live algorithm-mode rows, most recently updated first. */
function listAlgorithmModeRows(sessions: Record<string, Session>, nowMs: number): string[] {
const rows: Array<{ slug: string; t: number }> = [];
for (const [slug, s] of Object.entries(sessions)) {
if (slug === '__pulse_strip') continue;
if (s.phase === 'complete') continue;
const isAlgo = s.currentMode === 'algorithm'
|| s.mode === 'starting'
|| (s.mode === 'interactive' && s.phase && s.phase !== 'native');
if (!isAlgo) continue;
const t = new Date(s.updatedAt || s.started || 0).getTime();
const t = rowUpdatedMs(s);
if (nowMs - t > CANDIDATE_FRESH_MS) continue;
rows.push({ slug, t });
}
rows.sort((a, b) => b.t - a.t);
return rows.map((r) => r.slug);
}

/** Most recently updated live (non-complete) row matching a session UUID. */
function findLiveByUUID(sessions: Record<string, Session>, uuid: string): string | null {
let best: { slug: string; t: number } | null = null;
for (const [slug, s] of Object.entries(sessions)) {
if (slug === '__pulse_strip') continue;
if (s.sessionUUID !== uuid || s.phase === 'complete') continue;
const t = rowUpdatedMs(s);
if (!best || t > best.t) best = { slug, t };
}
return best ? best.slug : null;
}

function resolveSlug(args: Args, sessions: Record<string, Session>): string | null {
// 1. explicit --slug
if (args.slug) return args.slug in sessions ? args.slug : null;

// 2. CLAUDE_SESSION_ID env
const envUUID = process.env.CLAUDE_SESSION_ID;
if (envUUID) {
for (const [slug, s] of Object.entries(sessions)) {
if (s.sessionUUID === envUUID && s.phase !== 'complete') return slug;
type SlugResolution = { ok: true; slug: string } | { ok: false; reason: string };

function resolveSlug(args: Args, sessions: Record<string, Session>): SlugResolution {
// 1. explicit --slug — trusted verbatim, INCLUDING phase:complete rows:
// resume-after-complete is a sanctioned flow, and an explicit slug is the
// caller stating exactly which row it owns. Object.hasOwn (not `in`) so
// prototype keys like "constructor" can't false-match; the __pulse_strip
// metadata row is never addressable from any path.
if (args.slug) {
if (args.slug === '__pulse_strip' || !Object.hasOwn(sessions, args.slug)) {
return { ok: false, reason: `slug "${args.slug}" not found in work.json` };
}
if (args.uuid && sessions[args.slug].sessionUUID !== args.uuid) {
return {
ok: false,
reason: `--slug "${args.slug}" and --uuid "${args.uuid}" disagree — ` +
`that row has sessionUUID "${sessions[args.slug].sessionUUID ?? 'none'}"`,
};
}
return { ok: true, slug: args.slug };
}

// 3. --uuid
// 2. explicit --uuid — outranks ambient CLAUDE_SESSION_ID (an explicit flag
// must never be silently overridden by environment state); unmatched is an
// error, never a fall-through guess.
if (args.uuid) {
for (const [slug, s] of Object.entries(sessions)) {
if (s.sessionUUID === args.uuid && s.phase !== 'complete') return slug;
}
const slug = findLiveByUUID(sessions, args.uuid);
return slug
? { ok: true, slug }
: { ok: false, reason: `no live session with sessionUUID "${args.uuid}" in work.json` };
}

// 4. most-recent algorithm-mode row — multi-tab hazard.
// If two Algorithm sessions are alive concurrently and the caller passed
// neither --slug, --uuid, nor CLAUDE_SESSION_ID, we'd write to the
// most-recent one — possibly the WRONG one. Emit a stderr warning so this
// ambiguity is visible. Cato 2026-05-24 self-review flagged this.
const fallback = pickAlgorithmModeRow(sessions);
if (fallback) {
// 3. CLAUDE_SESSION_ID env — forward-looking defense-in-depth (Claude Code
// does not export it to tool subprocesses today) and local-trust only, NOT
// an auth boundary. Set-but-unmatched is a hard error: falling through to
// a guess would reintroduce the ambiguity this resolver exists to close.
const envUUID = process.env.CLAUDE_SESSION_ID;
if (envUUID) {
const slug = findLiveByUUID(sessions, envUUID);
return slug
? { ok: true, slug }
: { ok: false, reason: `CLAUDE_SESSION_ID "${envUUID}" matches no live session in work.json — pass --slug` };
}

// 4. Fail-closed disambiguation. The old behavior warned and then WROTE to
// the most-recent algorithm-mode row anyway — with two Algorithm sessions
// alive, a bare `AlgoPhase think` from one session could flip the OTHER
// session's phase. Exactly one fresh live row is unambiguous and stays the
// convenience path; anything else refuses and performs no write.
const candidates = listAlgorithmModeRows(sessions, Date.now());
if (candidates.length === 1) {
process.stderr.write(
`WARN: AlgoPhase falling back to most-recent algorithm-mode row "${fallback}". ` +
`Pass --slug or --uuid (or set CLAUDE_SESSION_ID) when multiple Algorithm sessions are alive.\n`
`NOTE: AlgoPhase resolved the single live algorithm-mode row "${candidates[0]}" (no --slug/--uuid/CLAUDE_SESSION_ID given).\n`
);
return { ok: true, slug: candidates[0] };
}
if (candidates.length === 0) {
return { ok: false, reason: 'no live algorithm-mode session found in work.json (rows idle >60min are not guessable) — pass --slug or --uuid' };
}
return fallback;
return {
ok: false,
reason: `${candidates.length} live algorithm-mode sessions are ambiguous — refusing to guess (no write performed). ` +
`Candidates: ${candidates.join(', ')}. Pass --slug or --uuid (or set CLAUDE_SESSION_ID).`,
};
}

function main(): number {
Expand All @@ -161,11 +233,12 @@ function main(): number {
const registry = readRegistry();
const sessions = registry.sessions as Record<string, Session>;

const slug = resolveSlug(args, sessions);
if (!slug) {
process.stderr.write('ERR: no algorithm-mode session found in work.json — pass --slug or --uuid\n');
const resolved = resolveSlug(args, sessions);
if (!resolved.ok) {
process.stderr.write(`ERR: ${resolved.reason}\n`);
return 1;
}
const slug = resolved.slug;

const session = sessions[slug];
if (!session) {
Expand Down
Loading