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
4 changes: 4 additions & 0 deletions plugins/omo/components/start-work-continuation/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## 0.1.1 - unreleased

- Documented how Stop-hook continuation consumes minimized `ulw-loop` resume snapshots and how that differs from `codex resume`.

## 0.1.0 - 2026-05-28

- Initial release: Stop and SubagentStop continuation injection.
10 changes: 10 additions & 0 deletions plugins/omo/components/start-work-continuation/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@ The `reason` is loaded from `directive.md` on every invocation and filled with c

This pairs with the `start-work` skill at `plugin/skills/start-work/SKILL.md`. That skill writes `.omo/boulder.json` with Codex session ids prefixed as `codex:` so the hook can continue only its own active Codex session.

## ULW Loop resume snapshots

When an active `ulw-loop` run has a resume snapshot, the continuation hook may read `.omo/ulw-loop/<session-id>/snapshots/latest.md` under the active workspace and include its next action in the continuation directive. This gives a new Codex turn a minimal handoff after transcript context has been discarded.

Snapshot lookup is deliberately limited. The hook only accepts a bounded markdown snapshot with the expected sections, a matching session id when present, and metadata paths that stay inside the workspace. Missing, malformed, oversized, out-of-workspace, or unsafe snapshots are ignored.

Resume snapshots are data-minimized. They are intended to contain status summaries, short redacted evidence excerpts, changed-file summaries, and one next action. They must not be treated as raw transcripts or as storage for headers, cookies, API keys, patches, diffs, or captured evidence payloads.

This is separate from `codex resume`. `codex resume` restores Codex conversation history; the snapshot bridge only gives the Stop hook enough local state to point at the next `ulw-loop` action when conversation history is not available.

## Counted plan checkboxes

Only column-0 checkboxes under these sections are counted:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ You are mid-flight on a Prometheus work plan; this turn is an automatic continua
{{WORKTREE_BLOCK}}
- Ledger: `{{LEDGER_PATH}}`
- Your session id in boulder.json: `codex:{{SESSION_ID}}`
{{ULW_SNAPSHOT_BLOCK}}

# What to do this turn

Expand Down
22 changes: 20 additions & 2 deletions plugins/omo/components/start-work-continuation/src/codex-hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,42 @@ import type { ContinuationState } from "./boulder-reader.js";
import { readContinuationState } from "./boulder-reader.js";
import { START_WORK_CONTINUATION_DIRECTIVE } from "./directive.js";
import type { ReadonlyFileSystem, StopHookEventName, StopHookOutput, StopInput } from "./types.js";
import { readUlwSnapshotSummary } from "./ulw-snapshot-reader.js";

export function runStopHook(input: unknown, fs: ReadonlyFileSystem): string {
if (!isStopInput(input)) return "";
if (input.stop_hook_active) return "";
if (transcriptHasContextPressureMarker(input.transcript_path, fs)) return "";
const state = readContinuationState(input.cwd, input.session_id);
if (state === null) return "";
const snapshot = readUlwSnapshotSummary(input.cwd, input.session_id, state.worktreePath, fs);
return JSON.stringify({
decision: "block",
reason: renderDirective(state, input.session_id),
reason: renderDirective(state, input.session_id, snapshot),
} satisfies StopHookOutput);
}

function renderDirective(state: ContinuationState, sessionId: string): string {
function renderDirective(
state: ContinuationState,
sessionId: string,
snapshot: { readonly path: string; readonly nextAction: string } | null,
): string {
const lineBreak = String.fromCharCode(10);
const worktreeBlock =
state.worktreePath === null
? ""
: `${lineBreak}- Worktree: \`${state.worktreePath}\` (all edits, tests, and commands run inside this directory)`;
const snapshotBlock =
snapshot === null
? ""
: [
"",
"# Repo-native ULW snapshot",
"",
`- Snapshot path: \`${snapshot.path}\``,
`- Next action: \`${snapshot.nextAction}\``,
"",
].join(lineBreak);
const replacements = {
PLAN_NAME: state.planName,
PLAN_PATH: state.planPath,
Expand All @@ -31,6 +48,7 @@ function renderDirective(state: ContinuationState, sessionId: string): string {
WORKTREE_BLOCK: worktreeBlock,
LEDGER_PATH: state.ledgerPath,
SESSION_ID: sessionId,
ULW_SNAPSHOT_BLOCK: snapshotBlock,
} as const;
let rendered = START_WORK_CONTINUATION_DIRECTIVE;
for (const [placeholder, value] of Object.entries(replacements)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { isAbsolute, join, relative, resolve, sep } from "node:path";

import type { ReadonlyFileSystem } from "./types.js";

export type UlwSnapshotSummary = {
readonly path: string;
readonly nextAction: string;
};

type SnapshotCandidate = {
readonly path: string;
readonly expectedSessionId: string | null;
};

const SNAPSHOT_HEADING = "# ULW Loop Resume Snapshot";
const REQUIRED_SECTIONS = [
"Metadata",
"Current State",
"Criteria",
"Evidence Summary",
"Changed Files",
"Next Action",
"Safety Notes",
] as const;
const SNAPSHOT_MAX_BYTES = 32 * 1024;
const SECRET_FIXTURE_PATTERNS = [
/\bAuthorization:\s*(?:Bearer|Basic)\s+[^\r\n]+/i,
/\b(?:Cookie|Set-Cookie):[^\r\n]+/i,
/\b(?:[A-Z][A-Z0-9_]*_)?API[-_]?KEY\s*[:=]\s*[^\s\r\n]+/i,
/\b(?:[A-Z][A-Z0-9_]*_)?TOKEN\s*[:=]\s*[^\s\r\n]+/i,
/\b(?:[A-Z][A-Z0-9_]*_)?(?:SECRET|PASSWORD|PASSWD|PWD)\s*[:=]\s*[^\s\r\n]+/i,
/\bsk-[A-Za-z0-9_-]{6,}\b/,
/\bgh[pousr]_[A-Za-z0-9_]{6,}\b/i,
/\bgithub_pat_[A-Za-z0-9_]{6,}\b/i,
/\bxox[abprs]-[A-Za-z0-9-]{6,}\b/i,
/\bhttps?:\/\/[^\s/:@]+:[^\s@/]+@[^\s)]+/i,
/BEGIN TRANSCRIPT[\s\S]*?(?:END TRANSCRIPT|$)/,
] as const;
const INSTRUCTION_INJECTION_PATTERNS = [
/\bignore\s+(?:all\s+)?(?:previous\s+)?instructions\b/i,
/\b(?:system|developer|assistant|user)\s*:/i,
/\bsystem\s+override\b/i,
/\b(?:tool|function)\s+(?:call|command)\b/i,
/\bexecute\s+(?:shell\s+)?command\b/i,
/\bprint\s+CANARY(?:[-_][A-Za-z0-9]+)?\b/i,
/\b(?:BEGIN|END)\s+PROMPT\b/i,
/<\/?\s*(?:system|developer|assistant|user)\b[^>]*>/i,
] as const;

export function readUlwSnapshotSummary(
cwd: string,
sessionId: string,
worktreePath: string | null,
fs: ReadonlyFileSystem,
): UlwSnapshotSummary | null {
const activeRoot = resolveActiveRoot(cwd, worktreePath);
for (const candidate of snapshotCandidates(activeRoot, sessionId)) {
const summary = readSnapshotCandidate(candidate, activeRoot, fs);
if (summary !== null) return summary;
if (candidate.expectedSessionId !== null) return null;
}
return null;
}

function snapshotCandidates(cwd: string, sessionId: string): readonly SnapshotCandidate[] {
const scopedSessionId = stripCodexPrefix(sessionId);
if (scopedSessionId.length > 0) {
return [
{
path: join(cwd, ".omo", "ulw-loop", scopedSessionId, "snapshots", "latest.md"),
expectedSessionId: scopedSessionId,
},
];
}
return [{ path: join(cwd, ".omo", "ulw-loop", "snapshots", "latest.md"), expectedSessionId: null }];
}

function readSnapshotCandidate(
candidate: SnapshotCandidate,
activeRoot: string,
fs: ReadonlyFileSystem,
): UlwSnapshotSummary | null {
if (!isInside(candidate.path, activeRoot)) return null;
const markdown = readBoundedText(candidate.path, fs);
if (markdown === null) return null;
if (!hasRequiredShape(markdown)) return null;
if (hasUnsafeText(markdown)) return null;

const metadata = parseMetadata(markdown);
if (!metadataMatchesSession(metadata, candidate.expectedSessionId)) return null;
if (!metadataPointsInsideWorkspace(metadata, activeRoot)) return null;

const nextAction = parseNextAction(markdown);
if (nextAction === null || hasUnsafeText(nextAction)) return null;
return { path: candidate.path, nextAction };
}

function readBoundedText(path: string, fs: ReadonlyFileSystem): string | null {
try {
const text = fs.readFileSync(path, "utf8");
return Buffer.byteLength(text, "utf8") <= SNAPSHOT_MAX_BYTES ? text : null;
} catch (error) {
if (error instanceof Error) return null;
throw error;
}
}

function hasRequiredShape(markdown: string): boolean {
if (!markdown.startsWith(SNAPSHOT_HEADING)) return false;
return REQUIRED_SECTIONS.every((section) => markdown.includes(`\n## ${section}\n`));
}

function hasUnsafeText(text: string): boolean {
if (INSTRUCTION_INJECTION_PATTERNS.some((pattern) => pattern.test(text))) return true;
return SECRET_FIXTURE_PATTERNS.some((pattern) => pattern.test(text));
}

function parseMetadata(markdown: string): ReadonlyMap<string, string> {
const lines = sectionLines(markdown, "Metadata");
const entries: [string, string][] = [];
for (const line of lines) {
const match = /^-\s*([^:]+):\s*(.+)$/.exec(line);
if (match === null) continue;
const [, key, value] = match;
if (key === undefined || value === undefined) continue;
entries.push([normalizeMetadataKey(key), value.trim()]);
}
return new Map(entries);
}

function metadataMatchesSession(metadata: ReadonlyMap<string, string>, expectedSessionId: string | null): boolean {
if (expectedSessionId === null) return !metadata.has("sessionid");
const metadataSessionId = metadata.get("sessionid");
if (metadataSessionId === undefined) return true;
return metadataSessionId === expectedSessionId || metadataSessionId === `codex:${expectedSessionId}`;
}

function metadataPointsInsideWorkspace(metadata: ReadonlyMap<string, string>, activeRoot: string): boolean {
const pathValue = metadata.get("goalspath") ?? metadata.get("planpath");
if (pathValue === undefined) return false;
const resolvedPath = resolve(activeRoot, pathValue);
return isInside(resolvedPath, activeRoot);
}

function parseNextAction(markdown: string): string | null {
for (const line of sectionLines(markdown, "Next Action")) {
const trimmed = line.trim();
if (trimmed.startsWith("- ")) return trimmed.slice("- ".length).trim();
if (trimmed.length > 0) return trimmed;
}
return null;
}

function sectionLines(markdown: string, section: string): readonly string[] {
const start = markdown.indexOf(`\n## ${section}\n`);
if (start === -1) return [];
const contentStart = start + `\n## ${section}\n`.length;
const nextSection = markdown.indexOf("\n## ", contentStart);
const contentEnd = nextSection === -1 ? markdown.length : nextSection;
return markdown.slice(contentStart, contentEnd).split(/\r?\n/);
}

function normalizeMetadataKey(key: string): string {
return key.replaceAll(/\s+/g, "").toLowerCase();
}

function stripCodexPrefix(sessionId: string): string {
return sessionId.startsWith("codex:") ? sessionId.slice("codex:".length) : sessionId;
}

function resolveActiveRoot(cwd: string, worktreePath: string | null): string {
const trimmed = worktreePath?.trim();
return trimmed === undefined || trimmed.length === 0 ? resolve(cwd) : resolve(cwd, trimmed);
}

function isInside(path: string, root: string): boolean {
const relativePath = relative(resolve(root), resolve(path));
return (
relativePath.length === 0 ||
(!relativePath.startsWith(`..${sep}`) && relativePath !== ".." && !isAbsolute(relativePath))
);
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";

import { runStopHook } from "../src/codex-hook.js";
import type { ReadonlyFileSystem, StopInput } from "../src/types.js";

const DEFAULT_WORKSPACE = "/repo";
const cleanupRoots: string[] = [];
import {
cleanupTestRoots,
createBoulderJson,
createMemoryFs,
createStopInput,
createWorkspace,
parseBlockOutput,
} from "./fixtures/hook-test-utils.js";

afterEach(() => {
for (const root of cleanupRoots.splice(0)) rmSync(root, { recursive: true, force: true });
cleanupTestRoots();
});

describe("start-work Stop hook", () => {
Expand Down Expand Up @@ -135,7 +138,7 @@ describe("start-work Stop hook", () => {
expect(parsed.reason).toMatch(/When unsure[^.]{0,30}HEAVY/);
expect(parsed.reason).toMatch(/mirrors its implementation/);
expect((parsed.reason.match(/malformed input, prompt injection/g) ?? []).length).toBe(1);
expect(parsed.reason.split(/\s+/).filter(Boolean).length).toBeLessThanOrEqual(1100);
expect(parsed.reason.split(/\s+/).filter(Boolean).length).toBeLessThanOrEqual(1150);
});

it("#given stop hook source #when inspected #then it remains Boulder-only without planning bootstrap logic", () => {
Expand Down Expand Up @@ -222,83 +225,3 @@ describe("start-work Stop hook", () => {
expect(output).toBe("");
});
});

type BoulderInput = {
readonly sessionIds: readonly string[];
readonly status: "active" | "completed" | "paused" | "abandoned";
readonly worktreePath?: string;
};

type WorkspaceInput = {
readonly boulderJson: string;
readonly planMarkdown: string;
};

function createStopInput(cwd = DEFAULT_WORKSPACE): StopInput {
return {
hook_event_name: "Stop",
session_id: "sess_abc",
turn_id: "turn_1",
transcript_path: "",
cwd,
model: "gpt-5.5",
permission_mode: "default",
stop_hook_active: false,
last_assistant_message: "done",
};
}

function createWorkspace(input: WorkspaceInput): string {
const root = mkdtempSync(join(tmpdir(), "codex-continuation-hook-"));
cleanupRoots.push(root);
mkdirSync(join(root, ".omo", "plans"), { recursive: true });
writeFileSync(join(root, ".omo", "plans", "plan.md"), input.planMarkdown);
writeFileSync(join(root, ".omo", "boulder.json"), input.boulderJson);
return root;
}

function createBoulderJson(input: BoulderInput): string {
const work = {
work_id: "work_1",
active_plan: ".omo/plans/plan.md",
plan_name: "launch-plan",
status: input.status,
started_at: "2026-06-13T00:00:00.000Z",
session_ids: input.sessionIds,
...(input.worktreePath === undefined ? {} : { worktree_path: input.worktreePath }),
};
return JSON.stringify({
schema_version: 2,
active_work_id: "work_1",
works: { work_1: work },
active_plan: ".omo/plans/plan.md",
plan_name: "legacy-launch-plan",
started_at: "2026-06-13T00:00:00.000Z",
status: input.status,
session_ids: input.sessionIds,
});
}

function createMemoryFs(files: Record<string, string> = {}): ReadonlyFileSystem {
return {
readFileSync(path, encoding) {
expect(encoding).toBe("utf8");
const value = files[path];
if (value === undefined) throw new Error(`Missing fixture: ${path}`);
return value;
},
};
}

function parseBlockOutput(output: string): { readonly decision: "block"; readonly reason: string } {
const parsed: unknown = JSON.parse(output);
if (!isRecord(parsed)) throw new Error("Expected object output");
if (parsed["decision"] !== "block") throw new Error("Expected block decision");
const reason = parsed["reason"];
if (typeof reason !== "string") throw new Error("Expected string reason");
return { decision: "block", reason };
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
Loading