diff --git a/packages/omo-claude/plugin/components/start-work-continuation/directive.md b/packages/omo-claude/plugin/components/start-work-continuation/directive.md new file mode 100644 index 000000000..959019d67 --- /dev/null +++ b/packages/omo-claude/plugin/components/start-work-continuation/directive.md @@ -0,0 +1,50 @@ + + +You are mid-flight on a Prometheus work plan. The turn just ended without finishing the plan. This is an automatic continuation — keep going. Do NOT ask the user whether to continue; the contract is auto-continue until every top-level checkbox is `- [x]`. + +# State + +- Plan: `{{PLAN_NAME}}` +- Plan file: `{{PLAN_PATH}}` +- Boulder state: `{{BOULDER_PATH}}` +- Remaining top-level checkboxes: `{{REMAINING_COUNT}}` of `{{TOTAL_COUNT}}` +- Next incomplete task: `{{NEXT_TASK_LABEL}}` +{{WORKTREE_BLOCK}} +- Ledger: `{{LEDGER_PATH}}` +- Your session id in boulder.json: `codex:{{SESSION_ID}}` + +# What to do this turn + +1. Read `{{PLAN_PATH}}` AND `{{LEDGER_PATH}}` first — ground truth for what remains and what evidence has already been recorded. The plan checkbox and the ledger are the only sources of truth; do not trust your own memory of prior turns. +2. Pick the FIRST unchecked top-level checkbox in `## TODOs` or `## Final Verification Wave`. Ignore nested checkboxes under Acceptance Criteria / Evidence / Definition of Done. +3. Follow the `start-work` skill in full. The skill is already loaded from your earlier turn — re-read its file at `packages/omo-codex/plugin/skills/start-work/SKILL.md` if you have lost context. +4. Decompose the checkbox into atomic sub-tasks. Dispatch them in PARALLEL via `spawn_agent` calls in this same response unless a sub-task has a NAMED blocking dependency (input from another sub-task or shared file). +5. Every sub-task message MUST include all 6 sections and name one Manual-QA channel (HTTP call / tmux / browser use / computer use) with a captured artifact + a cleanup receipt. Tests are the floor; the channel artifact is the ceiling. Both are required. +6. After verification of ALL sub-tasks under this checkbox: `apply_patch` the plan to change `- [ ]` → `- [x]`, re-read the plan to confirm the count decreased, append a `task-completed` line to the ledger, then continue. +7. Do not start fresh on a sub-agent failure. Re-dispatch the same `task_name` with a fix-message: `FAILED: ` + `Diagnosis: ` + `Fix: `. + +# Hard constraints + +- No production code before a failing test exists. RED → GREEN → SURFACE. +- No `--dry-run` as evidence. No "should work". No "tests pass" as completion proof. +- No `as any` / `@ts-ignore` / `@ts-expect-error`. No deleting failing tests. +- Cleanup receipt is mandatory. Leftover PIDs / `tmux` sessions / browser contexts / bound ports / containers / temp dirs = BLOCKED, not PASS. +- The worktree path (if set in boulder.json) governs every file edit and command. Do not stray into the main repo. +- session_ids you write to boulder.json MUST be prefixed `codex:`. Bare ids on read are legacy `opencode:`. + +# Stop conditions for THIS turn + +- A top-level checkbox flipped to `- [x]` after the 4-phase QA gate (Phase 1 read, Phase 2 automated, Phase 3 channel scenario, Phase 4 gate decision). Then the Stop hook will re-evaluate; if more checkboxes remain you will be continued again. +- 3 same-failure cycles on one sub-task → escalate via `spawn_agent(agent_type="codex-ultrawork-reviewer", ...)` and stop dispatch. +- Safety boundary (destructive command, secret exfiltration, production write) → stop and surface a safe substitute. +- All top-level checkboxes `- [x]` AND (if gate triggered) `codex-ultrawork-reviewer` approved unconditionally → print the ORCHESTRATION COMPLETE block and end. + +# Output discipline + +- Surface only state changes: sub-agent dispatched, channel scenario PASS/FAIL with artifact path, checkbox marked, evidence appended to ledger. +- Do NOT print "Should I continue?" — the Stop hook handles continuation. +- Do NOT restate the full plan. Do NOT recap prior turns. The ledger and the plan file are the durable record. + +Begin now. Pick the next checkbox, dispatch the parallel sub-agents, verify, mark, continue. + + diff --git a/packages/omo-claude/plugin/components/start-work-continuation/hooks/hooks.json b/packages/omo-claude/plugin/components/start-work-continuation/hooks/hooks.json new file mode 100644 index 000000000..1aaed5725 --- /dev/null +++ b/packages/omo-claude/plugin/components/start-work-continuation/hooks/hooks.json @@ -0,0 +1,26 @@ +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/components/start-work-continuation/dist/cli.js\" hook stop", + "timeout": 10 + } + ] + } + ], + "SubagentStop": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/components/start-work-continuation/dist/cli.js\" hook subagent-stop", + "timeout": 10 + } + ] + } + ] + } +} diff --git a/packages/omo-claude/plugin/components/start-work-continuation/package.json b/packages/omo-claude/plugin/components/start-work-continuation/package.json new file mode 100644 index 000000000..3ea0f40a9 --- /dev/null +++ b/packages/omo-claude/plugin/components/start-work-continuation/package.json @@ -0,0 +1,53 @@ +{ + "name": "@code-yeongyu/codex-start-work-continuation", + "version": "0.1.0", + "description": "Codex Stop hook continuation injector for omo-codex start-work plans.", + "type": "module", + "packageManager": "npm@11.12.1", + "license": "MIT", + "homepage": "https://github.com/code-yeongyu/codex-start-work-continuation", + "repository": { + "type": "git", + "url": "git+https://github.com/code-yeongyu/codex-start-work-continuation.git" + }, + "bugs": { + "url": "https://github.com/code-yeongyu/codex-start-work-continuation/issues" + }, + "keywords": [ + "codex", + "codex-plugin", + "start-work", + "continuation", + "hooks", + "boulder" + ], + "bin": { + "codex-start-work-continuation": "./dist/cli.js" + }, + "scripts": { + "build": "tsc -p tsconfig.build.json", + "test": "vitest --run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit", + "lint": "biome check .", + "lint:fix": "biome check --write .", + "check": "tsc --noEmit && biome check . && npm run build" + }, + "files": [ + "dist", + "directive.md", + "hooks", + "README.md", + "LICENSE", + "NOTICE" + ], + "devDependencies": { + "@biomejs/biome": "2.4.15", + "@types/node": "^25.7.0", + "typescript": "^6.0.3", + "vitest": "^4.1.5" + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/packages/omo-claude/plugin/components/start-work-continuation/src/boulder-reader.ts b/packages/omo-claude/plugin/components/start-work-continuation/src/boulder-reader.ts new file mode 100644 index 000000000..d94e1c808 --- /dev/null +++ b/packages/omo-claude/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, `claude:${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-claude/plugin/components/start-work-continuation/src/cli.ts b/packages/omo-claude/plugin/components/start-work-continuation/src/cli.ts new file mode 100644 index 000000000..09cfbaa0e --- /dev/null +++ b/packages/omo-claude/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: codex-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-claude/plugin/components/start-work-continuation/src/codex-hook.ts b/packages/omo-claude/plugin/components/start-work-continuation/src/codex-hook.ts new file mode 100644 index 000000000..3db4c31c1 --- /dev/null +++ b/packages/omo-claude/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" || value["turn_id"] === undefined) && + typeof value["transcript_path"] === "string" && + typeof value["cwd"] === "string" && + (typeof value["model"] === "string" || value["model"] === undefined) && + 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-claude/plugin/components/start-work-continuation/src/directive.ts b/packages/omo-claude/plugin/components/start-work-continuation/src/directive.ts new file mode 100644 index 000000000..12fe3af22 --- /dev/null +++ b/packages/omo-claude/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-claude/plugin/components/start-work-continuation/src/index.ts b/packages/omo-claude/plugin/components/start-work-continuation/src/index.ts new file mode 100644 index 000000000..fd51acd0e --- /dev/null +++ b/packages/omo-claude/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-claude/plugin/components/start-work-continuation/src/types.ts b/packages/omo-claude/plugin/components/start-work-continuation/src/types.ts new file mode 100644 index 000000000..01f74b59b --- /dev/null +++ b/packages/omo-claude/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; +}; diff --git a/packages/omo-claude/plugin/components/start-work-continuation/tsconfig.build.json b/packages/omo-claude/plugin/components/start-work-continuation/tsconfig.build.json new file mode 100644 index 000000000..5b5bbcafd --- /dev/null +++ b/packages/omo-claude/plugin/components/start-work-continuation/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "allowImportingTsExtensions": false, + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "noEmit": false + }, + "include": ["src/**/*"], + "exclude": ["test/**/*"] +} diff --git a/packages/omo-claude/plugin/components/start-work-continuation/tsconfig.json b/packages/omo-claude/plugin/components/start-work-continuation/tsconfig.json new file mode 100644 index 000000000..3ac7ab0e2 --- /dev/null +++ b/packages/omo-claude/plugin/components/start-work-continuation/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ESNext"], + "strict": true, + "exactOptionalPropertyTypes": true, + "noUncheckedIndexedAccess": true, + "noPropertyAccessFromIndexSignature": true, + "verbatimModuleSyntax": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "esModuleInterop": true, + "allowImportingTsExtensions": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "useDefineForClassFields": false, + "types": ["node"], + "noEmit": true + }, + "include": ["src/**/*", "test/**/*", "vitest.config.ts"] +}