From f31348c22860dc0f120955ac5e5c67334fae7db0 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 30 May 2026 19:12:11 +0900 Subject: [PATCH] feat(omo-codex): batch 68 (6 files) --- .../src/boulder-reader.ts | 167 ++++++++++++++++++ .../start-work-continuation/src/cli.ts | 52 ++++++ .../start-work-continuation/src/codex-hook.ts | 66 +++++++ .../start-work-continuation/src/directive.ts | 6 + .../start-work-continuation/src/index.ts | 5 + .../start-work-continuation/src/types.ts | 23 +++ 6 files changed, 319 insertions(+) create mode 100644 packages/omo-codex/plugin/components/start-work-continuation/src/boulder-reader.ts create mode 100644 packages/omo-codex/plugin/components/start-work-continuation/src/cli.ts create mode 100644 packages/omo-codex/plugin/components/start-work-continuation/src/codex-hook.ts create mode 100644 packages/omo-codex/plugin/components/start-work-continuation/src/directive.ts create mode 100644 packages/omo-codex/plugin/components/start-work-continuation/src/index.ts create mode 100644 packages/omo-codex/plugin/components/start-work-continuation/src/types.ts diff --git a/packages/omo-codex/plugin/components/start-work-continuation/src/boulder-reader.ts b/packages/omo-codex/plugin/components/start-work-continuation/src/boulder-reader.ts new file mode 100644 index 000000000..6e371f0fa --- /dev/null +++ b/packages/omo-codex/plugin/components/start-work-continuation/src/boulder-reader.ts @@ -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, 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 | 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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/omo-codex/plugin/components/start-work-continuation/src/cli.ts b/packages/omo-codex/plugin/components/start-work-continuation/src/cli.ts new file mode 100644 index 000000000..b0548fe92 --- /dev/null +++ b/packages/omo-codex/plugin/components/start-work-continuation/src/cli.ts @@ -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 \n"); + process.exitCode = 1; +} + +async function runHookCli(): Promise { + 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 { + 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)); + }); +} diff --git a/packages/omo-codex/plugin/components/start-work-continuation/src/codex-hook.ts b/packages/omo-codex/plugin/components/start-work-continuation/src/codex-hook.ts new file mode 100644 index 000000000..7717809fe --- /dev/null +++ b/packages/omo-codex/plugin/components/start-work-continuation/src/codex-hook.ts @@ -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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/omo-codex/plugin/components/start-work-continuation/src/directive.ts b/packages/omo-codex/plugin/components/start-work-continuation/src/directive.ts new file mode 100644 index 000000000..12fe3af22 --- /dev/null +++ b/packages/omo-codex/plugin/components/start-work-continuation/src/directive.ts @@ -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", +); diff --git a/packages/omo-codex/plugin/components/start-work-continuation/src/index.ts b/packages/omo-codex/plugin/components/start-work-continuation/src/index.ts new file mode 100644 index 000000000..fd51acd0e --- /dev/null +++ b/packages/omo-codex/plugin/components/start-work-continuation/src/index.ts @@ -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"; diff --git a/packages/omo-codex/plugin/components/start-work-continuation/src/types.ts b/packages/omo-codex/plugin/components/start-work-continuation/src/types.ts new file mode 100644 index 000000000..01f74b59b --- /dev/null +++ b/packages/omo-codex/plugin/components/start-work-continuation/src/types.ts @@ -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; +};