feat(omo-codex): batch 68 (6 files)

This commit is contained in:
YeonGyu-Kim
2026-05-30 19:12:11 +09:00
parent a2c8b3e31f
commit f31348c228
6 changed files with 319 additions and 0 deletions
@@ -0,0 +1,167 @@
import { isAbsolute, join, resolve } from "node:path";
import type { ReadonlyFileSystem } from "./types.js";
const CHECKBOX_PATTERN = /^- \[[ xX]\] /;
const UNCHECKED_PATTERN = /^- \[ \] /;
const TODO_HEADING = "TODOs";
const FINAL_VERIFICATION_HEADING = "Final Verification Wave";
type WorkStatus = "active" | "completed" | "paused" | "abandoned";
type BoulderWork = {
readonly activePlan: string;
readonly planName: string;
readonly status: WorkStatus;
readonly sessionIds: readonly string[];
readonly worktreePath: string | null;
};
export type PlanChecklist = {
readonly remaining: number;
readonly total: number;
readonly nextTaskLabel: string | null;
};
export type ContinuationState = {
readonly planName: string;
readonly planPath: string;
readonly boulderPath: string;
readonly ledgerPath: string;
readonly worktreePath: string | null;
readonly checklist: PlanChecklist;
};
export function parsePlanChecklist(markdown: string): PlanChecklist {
const lines = markdown.split(/\r?\n/);
const hasCountedSections = lines.some(hasCountedSectionHeading);
let remaining = 0;
let total = 0;
let nextTaskLabel: string | null = null;
let isCountedSection = !hasCountedSections;
for (const line of lines) {
const heading = parseLevelTwoHeading(line);
if (heading !== null) isCountedSection = isCountedHeading(heading);
if (!isCountedSection) continue;
if (!CHECKBOX_PATTERN.test(line)) continue;
total += 1;
if (!UNCHECKED_PATTERN.test(line)) continue;
remaining += 1;
if (nextTaskLabel === null) nextTaskLabel = line.slice("- [ ] ".length);
}
return { remaining, total, nextTaskLabel };
}
function hasCountedSectionHeading(line: string): boolean {
const heading = parseLevelTwoHeading(line);
return heading !== null && isCountedHeading(heading);
}
export function readContinuationState(
cwd: string,
sessionId: string,
fs: ReadonlyFileSystem,
): ContinuationState | null {
const boulderPath = join(cwd, ".omo", "boulder.json");
const boulderText = readTextFile(fs, boulderPath);
if (boulderText === null) return null;
const parsed = parseJsonObject(boulderText);
if (parsed === null) return null;
const work = findMatchingWork(parsed, `codex:${sessionId}`);
if (work === null) return null;
const planPath = resolvePlanPath(cwd, work.activePlan);
const planText = readTextFile(fs, planPath);
if (planText === null) return null;
const checklist = parsePlanChecklist(planText);
if (checklist.remaining === 0) return null;
return {
planName: work.planName,
planPath,
boulderPath,
ledgerPath: join(cwd, ".omo", "start-work", "ledger.jsonl"),
worktreePath: work.worktreePath,
checklist,
};
}
function findMatchingWork(state: Record<string, unknown>, prefixedSessionId: string): BoulderWork | null {
const worksValue = state["works"];
const candidates = isRecord(worksValue) ? Object.values(worksValue) : [state];
for (const candidate of candidates) {
const work = parseBoulderWork(candidate);
if (work === null) continue;
if (!isContinuableStatus(work.status)) continue;
if (work.sessionIds.includes(prefixedSessionId)) return work;
}
return null;
}
function parseBoulderWork(value: unknown): BoulderWork | null {
if (!isRecord(value)) return null;
const activePlan = value["active_plan"];
const planName = value["plan_name"];
const status = parseWorkStatus(value["status"]);
const sessionIds = value["session_ids"];
const worktreePath = value["worktree_path"];
if (typeof activePlan !== "string") return null;
if (typeof planName !== "string") return null;
if (status === null) return null;
if (!isStringArray(sessionIds)) return null;
return {
activePlan,
planName,
status,
sessionIds,
worktreePath: typeof worktreePath === "string" ? worktreePath : null,
};
}
function parseWorkStatus(value: unknown): WorkStatus | null {
if (value === "active" || value === "completed" || value === "paused" || value === "abandoned") return value;
return null;
}
function isContinuableStatus(status: WorkStatus): boolean {
return status === "active" || status === "paused";
}
function parseLevelTwoHeading(line: string): string | null {
if (!line.startsWith("## ")) return null;
if (line.startsWith("### ")) return null;
return line.slice("## ".length).trim();
}
function isCountedHeading(heading: string): boolean {
return heading === TODO_HEADING || heading === FINAL_VERIFICATION_HEADING;
}
function resolvePlanPath(cwd: string, activePlan: string): string {
return isAbsolute(activePlan) ? activePlan : resolve(cwd, activePlan);
}
function readTextFile(fs: ReadonlyFileSystem, path: string): string | null {
try {
return fs.readFileSync(path, "utf8");
} catch (error) {
if (error instanceof Error) return null;
throw error;
}
}
function parseJsonObject(json: string): Record<string, unknown> | null {
try {
const parsed: unknown = JSON.parse(json);
return isRecord(parsed) ? parsed : null;
} catch (error) {
if (error instanceof SyntaxError) return null;
throw error;
}
}
function isStringArray(value: unknown): value is readonly string[] {
return Array.isArray(value) && value.every((item) => typeof item === "string");
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -0,0 +1,52 @@
#!/usr/bin/env node
import { readFileSync } from "node:fs";
import { stdin as processStdin, stdout as processStdout } from "node:process";
import { runStopHook } from "./codex-hook.js";
import type { ReadonlyFileSystem } from "./types.js";
const nodeFileSystem: ReadonlyFileSystem = {
readFileSync(path, encoding) {
return readFileSync(path, encoding);
},
};
const command = process.argv[2];
const subcommand = process.argv[3];
if (command === "hook" && (subcommand === "stop" || subcommand === "subagent-stop")) {
await runHookCli();
} else {
process.stderr.write("Usage: omo-start-work-continuation hook <stop|subagent-stop>\n");
process.exitCode = 1;
}
async function runHookCli(): Promise<void> {
const raw = await readStdin();
if (raw.trim().length === 0) return;
const parsed = parseHookInput(raw);
const output = runStopHook(parsed, nodeFileSystem);
if (output.length > 0) processStdout.write(output);
}
function parseHookInput(raw: string): unknown | undefined {
try {
const parsed: unknown = JSON.parse(raw);
return parsed;
} catch (error) {
if (error instanceof SyntaxError) return undefined;
throw error;
}
}
function readStdin(): Promise<string> {
return new Promise((resolve) => {
let data = "";
processStdin.setEncoding("utf8");
processStdin.on("data", (chunk: string) => {
data += chunk;
});
processStdin.once("error", () => resolve(data));
processStdin.once("end", () => resolve(data));
});
}
@@ -0,0 +1,66 @@
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";
export function runStopHook(input: unknown, fs: ReadonlyFileSystem): string {
if (!isStopInput(input)) return "";
if (input.stop_hook_active) return "";
const state = readContinuationState(input.cwd, input.session_id, fs);
if (state === null) return "";
return JSON.stringify({
decision: "block",
reason: renderDirective(state, input.session_id),
} satisfies StopHookOutput);
}
function renderDirective(state: ContinuationState, sessionId: string): 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 replacements = {
PLAN_NAME: state.planName,
PLAN_PATH: state.planPath,
BOULDER_PATH: state.boulderPath,
REMAINING_COUNT: String(state.checklist.remaining),
TOTAL_COUNT: String(state.checklist.total),
NEXT_TASK_LABEL: state.checklist.nextTaskLabel ?? "",
WORKTREE_BLOCK: worktreeBlock,
LEDGER_PATH: state.ledgerPath,
SESSION_ID: sessionId,
} as const;
let rendered = START_WORK_CONTINUATION_DIRECTIVE;
for (const [placeholder, value] of Object.entries(replacements)) {
rendered = rendered.replaceAll(`{{${placeholder}}}`, value);
}
return rendered;
}
function isStopInput(value: unknown): value is StopInput {
return (
isRecord(value) &&
isStopHookEventName(value["hook_event_name"]) &&
typeof value["session_id"] === "string" &&
typeof value["turn_id"] === "string" &&
typeof value["transcript_path"] === "string" &&
typeof value["cwd"] === "string" &&
typeof value["model"] === "string" &&
typeof value["permission_mode"] === "string" &&
typeof value["stop_hook_active"] === "boolean" &&
optionalString(value["last_assistant_message"])
);
}
function isStopHookEventName(value: unknown): value is StopHookEventName {
return value === "Stop" || value === "SubagentStop";
}
function optionalString(value: unknown): boolean {
return value === undefined || typeof value === "string";
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -0,0 +1,6 @@
import { readFileSync } from "node:fs";
export const START_WORK_CONTINUATION_DIRECTIVE: string = readFileSync(
new URL("../directive.md", import.meta.url),
"utf8",
);
@@ -0,0 +1,5 @@
export type { ContinuationState, PlanChecklist } from "./boulder-reader.js";
export { parsePlanChecklist, readContinuationState } from "./boulder-reader.js";
export { runStopHook } from "./codex-hook.js";
export { START_WORK_CONTINUATION_DIRECTIVE } from "./directive.js";
export type { ReadonlyFileSystem, StopHookEventName, StopHookOutput, StopInput } from "./types.js";
@@ -0,0 +1,23 @@
export const STOP_HOOK_EVENTS = ["Stop", "SubagentStop"] as const;
export type StopHookEventName = (typeof STOP_HOOK_EVENTS)[number];
export type StopInput = {
readonly hook_event_name: StopHookEventName;
readonly session_id: string;
readonly turn_id: string;
readonly transcript_path: string;
readonly cwd: string;
readonly model: string;
readonly permission_mode: string;
readonly stop_hook_active: boolean;
readonly last_assistant_message?: string;
};
export type StopHookOutput = {
readonly decision: "block";
readonly reason: string;
};
export type ReadonlyFileSystem = {
readFileSync(path: string, encoding: "utf8"): string;
};