feat(omo-claude): vendor start-work-continuation with claude session prefix
codex:->claude: session prefix; model/turn_id optional in Stop validator. QA: no-op without boulder.json, no crash. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
<start-work-continuation>
|
||||
|
||||
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: <exact error>` + `Diagnosis: <observation>` + `Fix: <instruction>`.
|
||||
|
||||
# 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.
|
||||
|
||||
</start-work-continuation>
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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<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: codex-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" || 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<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;
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowImportingTsExtensions": false,
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"noEmit": false
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["test/**/*"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user