feat(omo-codex): add start-work continuation component
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -0,0 +1,13 @@
|
|||||||
|
# Normalize line endings: store LF in git, check out LF on every platform.
|
||||||
|
# Required so biome's --check passes on Windows (default core.autocrlf=true).
|
||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
# Explicit binary types
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.gif binary
|
||||||
|
*.ico binary
|
||||||
|
*.zip binary
|
||||||
|
*.tgz binary
|
||||||
|
*.gz binary
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
dist/
|
||||||
|
node_modules/
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Repository Conventions
|
||||||
|
|
||||||
|
Conventions for human contributors and AI agents working on this repository.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- Node >=20 runtime.
|
||||||
|
- npm package manager.
|
||||||
|
- TypeScript 6 strict mode.
|
||||||
|
- Biome 2 linting and formatting.
|
||||||
|
- Vitest 4 test runner.
|
||||||
|
|
||||||
|
## Forbidden
|
||||||
|
|
||||||
|
- No `as any` or `as unknown`.
|
||||||
|
- No `@ts-ignore` or `@ts-expect-error`.
|
||||||
|
- No enums.
|
||||||
|
- No non-null assertions.
|
||||||
|
- No default exports. `vitest.config.ts` is exempt because the framework requires that shape.
|
||||||
|
|
||||||
|
## File Ceiling
|
||||||
|
|
||||||
|
- Keep each `src/` TypeScript file under 250 pure LOC.
|
||||||
|
- Split by responsibility before a file reaches the ceiling.
|
||||||
|
|
||||||
|
## Test Discipline
|
||||||
|
|
||||||
|
- Use Vitest with nested `describe` names in `#given`, `#when`, and `#then` form, or inline `// given`, `// when`, and `// then` comments.
|
||||||
|
- Never use Arrange-Act-Assert comments.
|
||||||
|
- Keep fixtures in `test/fixtures/`.
|
||||||
|
|
||||||
|
## Build and Hooks
|
||||||
|
|
||||||
|
- Build output goes to `dist/`.
|
||||||
|
- `hooks/hooks.json` registers Codex `Stop` and `SubagentStop` hooks.
|
||||||
|
- Hook commands run `node ${PLUGIN_ROOT}/components/start-work-continuation/dist/cli.js hook stop` and `node ${PLUGIN_ROOT}/components/start-work-continuation/dist/cli.js hook subagent-stop`.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Never let the hook block a Codex turn because of malformed input.
|
||||||
|
- Never make a network call from the hook.
|
||||||
|
- Keep the directive in `directive.md`. Do not inline it into TypeScript files.
|
||||||
|
- The hook only continues sessions listed in `.omo/boulder.json` as `codex:<session_id>`.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 0.1.0 - 2026-05-28
|
||||||
|
|
||||||
|
- Initial release: Stop and SubagentStop continuation injection.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Yeongyu Kim
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
codex-start-work-continuation
|
||||||
|
Copyright (c) 2026 Yeongyu Kim
|
||||||
|
|
||||||
|
This product includes software released under the MIT License.
|
||||||
|
See LICENSE for the full text.
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# codex-start-work-continuation
|
||||||
|
|
||||||
|
Codex Stop-hook continuation injector for the omo-codex `start-work` skill.
|
||||||
|
|
||||||
|
It reads `.omo/boulder.json` in the hook payload `cwd`, resolves the active work, inspects the active plan for incomplete top-level checkboxes, and emits Codex Stop-hook JSON when the plan still has work:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"decision":"block","reason":"<directive>"}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `reason` is loaded from `directive.md` on every invocation and filled with current plan state. The hook returns no output when `stop_hook_active` is `true`, when no active Boulder work exists, when the work is completed, when the active work is not tied to `codex:<session_id>`, or when all top-level plan checkboxes are complete.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Counted plan checkboxes
|
||||||
|
|
||||||
|
Only column-0 checkboxes under these sections are counted:
|
||||||
|
|
||||||
|
- `## TODOs`
|
||||||
|
- `## Final Verification Wave`
|
||||||
|
|
||||||
|
Nested checkboxes under `### Acceptance Criteria`, `### Evidence`, and `### Definition of Done` are ignored.
|
||||||
|
|
||||||
|
## Smoke test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TMP=$(mktemp -d)
|
||||||
|
mkdir -p "$TMP/.omo/plans"
|
||||||
|
cat > "$TMP/.omo/plans/test.md" <<EOF
|
||||||
|
## TODOs
|
||||||
|
- [ ] Task one
|
||||||
|
- [ ] Task two
|
||||||
|
EOF
|
||||||
|
cat > "$TMP/.omo/boulder.json" <<EOF
|
||||||
|
{"schema_version":2,"active_work_id":"w1","works":{"w1":{"work_id":"w1","active_plan":".omo/plans/test.md","plan_name":"test","session_ids":["codex:smoke-session"],"status":"active"}}}
|
||||||
|
EOF
|
||||||
|
PAYLOAD='{"session_id":"smoke-session","turn_id":"t1","transcript_path":"","cwd":"'"$TMP"'","hook_event_name":"Stop","model":"gpt-5.5","permission_mode":"default","stop_hook_active":false}'
|
||||||
|
npm run build
|
||||||
|
echo "$PAYLOAD" | node dist/cli.js hook stop
|
||||||
|
|
||||||
|
PAYLOAD_LOOP='{"session_id":"smoke-session","turn_id":"t1","transcript_path":"","cwd":"'"$TMP"'","hook_event_name":"Stop","model":"gpt-5.5","permission_mode":"default","stop_hook_active":true}'
|
||||||
|
echo "$PAYLOAD_LOOP" | node dist/cli.js hook stop
|
||||||
|
|
||||||
|
rm -rf "$TMP"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expect the first command to print JSON containing `"decision":"block"`; expect the anti-loop command to print nothing.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT. See `LICENSE`.
|
||||||
|
|
||||||
|
## Privacy
|
||||||
|
|
||||||
|
This plugin only reads local hook payloads, `.omo/boulder.json`, the active plan, and the bundled directive. It makes no network calls and stores no telemetry.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"recommended": true,
|
||||||
|
"style": {
|
||||||
|
"noDefaultExport": "error",
|
||||||
|
"noEnum": "error",
|
||||||
|
"noNonNullAssertion": "error",
|
||||||
|
"useImportType": "error",
|
||||||
|
"useConst": "error",
|
||||||
|
"useNodejsImportProtocol": "off"
|
||||||
|
},
|
||||||
|
"complexity": {
|
||||||
|
"useLiteralKeys": "off"
|
||||||
|
},
|
||||||
|
"suspicious": {
|
||||||
|
"noExplicitAny": "error",
|
||||||
|
"noTsIgnore": "error",
|
||||||
|
"noControlCharactersInRegex": "off",
|
||||||
|
"noEmptyInterface": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"formatWithErrors": false,
|
||||||
|
"indentStyle": "tab",
|
||||||
|
"indentWidth": 3,
|
||||||
|
"lineWidth": 120
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"includes": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts", "!**/node_modules/**/*", "!**/dist/**/*"]
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"includes": ["vitest.config.ts"],
|
||||||
|
"linter": {
|
||||||
|
"rules": {
|
||||||
|
"style": {
|
||||||
|
"noDefaultExport": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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 \"${PLUGIN_ROOT}/components/start-work-continuation/dist/cli.js\" hook stop",
|
||||||
|
"timeout": 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"SubagentStop": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"${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, `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: 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" &&
|
||||||
|
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;
|
||||||
|
};
|
||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { parsePlanChecklist } from "../src/boulder-reader.js";
|
||||||
|
|
||||||
|
describe("start-work plan checklist parser", () => {
|
||||||
|
it("#given top-level completed and incomplete checkboxes #when parsed #then counts remaining and total", () => {
|
||||||
|
// given
|
||||||
|
const markdown = ["# Plan", "", "## TODOs", "- [ ] First", "- [x] Done", "- [X] Also done", "- [ ] Second"].join(
|
||||||
|
"\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
// when
|
||||||
|
const checklist = parsePlanChecklist(markdown);
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(checklist).toEqual({ remaining: 2, total: 4, nextTaskLabel: "First" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("#given nested checkboxes #when parsed #then ignores non-column-zero items", () => {
|
||||||
|
// given
|
||||||
|
const markdown = ["## TODOs", "- [ ] Top-level", " - [ ] Nested", "\t- [ ] Tab nested", "- [x] Complete"].join(
|
||||||
|
"\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
// when
|
||||||
|
const checklist = parsePlanChecklist(markdown);
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(checklist).toEqual({ remaining: 1, total: 2, nextTaskLabel: "Top-level" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("#given checkboxes outside counted sections #when parsed #then ignores unrelated top-level tasks", () => {
|
||||||
|
// given
|
||||||
|
const markdown = [
|
||||||
|
"# Plan",
|
||||||
|
"- [ ] Preamble task",
|
||||||
|
"## TODOs",
|
||||||
|
"- [ ] Build hook",
|
||||||
|
"## Acceptance Criteria",
|
||||||
|
"- [ ] Acceptance item",
|
||||||
|
"## Final Verification Wave",
|
||||||
|
"- [x] Run tests",
|
||||||
|
"- [ ] Run smoke",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
// when
|
||||||
|
const checklist = parsePlanChecklist(markdown);
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(checklist).toEqual({ remaining: 2, total: 3, nextTaskLabel: "Build hook" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("#given all top-level tasks complete #when parsed #then next task is null", () => {
|
||||||
|
// given
|
||||||
|
const markdown = ["## TODOs", "- [x] First", "- [X] Second"].join("\n");
|
||||||
|
|
||||||
|
// when
|
||||||
|
const checklist = parsePlanChecklist(markdown);
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(checklist).toEqual({ remaining: 0, total: 2, nextTaskLabel: null });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { execPath } from "node:process";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const cleanupRoots: string[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const root of cleanupRoots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("start-work continuation CLI", () => {
|
||||||
|
it("#given valid Stop stdin #when CLI runs #then stdout contains block JSON", () => {
|
||||||
|
// given
|
||||||
|
const cwd = createWorkspace(["codex:s1"]);
|
||||||
|
const payload = JSON.stringify(makePayload(cwd, false, "Stop"));
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = runCli("stop", payload);
|
||||||
|
|
||||||
|
// then
|
||||||
|
if (result.error !== undefined) throw result.error;
|
||||||
|
expect(result.status).toBe(0);
|
||||||
|
expect(result.stdout).toContain('"decision":"block"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("#given valid SubagentStop stdin #when CLI runs #then stdout contains block JSON", () => {
|
||||||
|
// given
|
||||||
|
const cwd = createWorkspace(["codex:s1"]);
|
||||||
|
const payload = JSON.stringify(makePayload(cwd, false, "SubagentStop"));
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = runCli("subagent-stop", payload);
|
||||||
|
|
||||||
|
// then
|
||||||
|
if (result.error !== undefined) throw result.error;
|
||||||
|
expect(result.status).toBe(0);
|
||||||
|
expect(result.stdout).toContain('"decision":"block"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("#given active stop hook stdin #when CLI runs #then stdout is empty and exit is zero", () => {
|
||||||
|
// given
|
||||||
|
const cwd = createWorkspace(["codex:s1"]);
|
||||||
|
const payload = JSON.stringify(makePayload(cwd, true, "Stop"));
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = runCli("stop", payload);
|
||||||
|
|
||||||
|
// then
|
||||||
|
if (result.error !== undefined) throw result.error;
|
||||||
|
expect(result.status).toBe(0);
|
||||||
|
expect(result.stdout).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("#given unrelated session stdin #when CLI runs #then stdout is empty and exit is zero", () => {
|
||||||
|
// given
|
||||||
|
const cwd = createWorkspace(["codex:other"]);
|
||||||
|
const payload = JSON.stringify(makePayload(cwd, false, "Stop"));
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = runCli("stop", payload);
|
||||||
|
|
||||||
|
// then
|
||||||
|
if (result.error !== undefined) throw result.error;
|
||||||
|
expect(result.status).toBe(0);
|
||||||
|
expect(result.stdout).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("#given malformed stdin #when CLI runs #then stdout is empty and exit is zero", () => {
|
||||||
|
// given
|
||||||
|
const payload = "{not-json";
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = runCli("stop", payload);
|
||||||
|
|
||||||
|
// then
|
||||||
|
if (result.error !== undefined) throw result.error;
|
||||||
|
expect(result.status).toBe(0);
|
||||||
|
expect(result.stdout).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function runCli(subcommand: "stop" | "subagent-stop", input: string) {
|
||||||
|
return spawnSync(execPath, [join(process.cwd(), "dist", "cli.js"), "hook", subcommand], { input, encoding: "utf8" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWorkspace(sessionIds: readonly string[]): string {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "codex-continuation-cli-"));
|
||||||
|
cleanupRoots.push(root);
|
||||||
|
mkdirSync(join(root, ".omo", "plans"), { recursive: true });
|
||||||
|
writeFileSync(join(root, ".omo", "plans", "plan.md"), "## TODOs\n\n- [ ] Task one\n");
|
||||||
|
const work = {
|
||||||
|
work_id: "w1",
|
||||||
|
active_plan: ".omo/plans/plan.md",
|
||||||
|
plan_name: "cli plan",
|
||||||
|
session_ids: sessionIds,
|
||||||
|
status: "active",
|
||||||
|
};
|
||||||
|
writeFileSync(
|
||||||
|
join(root, ".omo", "boulder.json"),
|
||||||
|
`${JSON.stringify({ schema_version: 2, active_work_id: "w1", works: { w1: work } })}\n`,
|
||||||
|
);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makePayload(
|
||||||
|
cwd: string,
|
||||||
|
stopHookActive: boolean,
|
||||||
|
eventName: "Stop" | "SubagentStop",
|
||||||
|
): Record<string, string | boolean> {
|
||||||
|
return {
|
||||||
|
session_id: "s1",
|
||||||
|
turn_id: "t1",
|
||||||
|
transcript_path: "",
|
||||||
|
cwd,
|
||||||
|
hook_event_name: eventName,
|
||||||
|
model: "gpt-5.5",
|
||||||
|
permission_mode: "default",
|
||||||
|
stop_hook_active: stopHookActive,
|
||||||
|
last_assistant_message: "done",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { join } from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { runStopHook } from "../src/codex-hook.js";
|
||||||
|
import type { ReadonlyFileSystem, StopInput } from "../src/types.js";
|
||||||
|
|
||||||
|
const WORKSPACE = "/repo";
|
||||||
|
const BOULDER_PATH = join(WORKSPACE, ".omo", "boulder.json");
|
||||||
|
const PLAN_PATH = join(WORKSPACE, ".omo", "plans", "plan.md");
|
||||||
|
const LEDGER_PATH = join(WORKSPACE, ".omo", "start-work", "ledger.jsonl");
|
||||||
|
|
||||||
|
describe("start-work Stop hook", () => {
|
||||||
|
it("#given stop hook is already active #when hook runs #then returns empty output", () => {
|
||||||
|
// given
|
||||||
|
const fs = createMemoryFs();
|
||||||
|
const input = { ...createStopInput(), stop_hook_active: true };
|
||||||
|
|
||||||
|
// when
|
||||||
|
const output = runStopHook(input, fs);
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(output).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("#given active codex work with remaining top-level tasks #when hook runs #then returns block JSON", () => {
|
||||||
|
// given
|
||||||
|
const fs = createMemoryFs({
|
||||||
|
[BOULDER_PATH]: createBoulderJson({
|
||||||
|
sessionIds: ["codex:sess_abc"],
|
||||||
|
status: "active",
|
||||||
|
worktreePath: "/tmp/worktree",
|
||||||
|
}),
|
||||||
|
[PLAN_PATH]: ["# Plan", "", "## TODOs", "- [ ] First", "- [x] Done", "- [ ] Second"].join("\n"),
|
||||||
|
});
|
||||||
|
|
||||||
|
// when
|
||||||
|
const output = runStopHook(createStopInput(), fs);
|
||||||
|
|
||||||
|
// then
|
||||||
|
const parsed = parseBlockOutput(output);
|
||||||
|
expect(parsed.decision).toBe("block");
|
||||||
|
expect(parsed.reason).toContain("- Plan: `launch-plan`");
|
||||||
|
expect(parsed.reason).toContain(`- Plan file: \`${PLAN_PATH}\``);
|
||||||
|
expect(parsed.reason).toContain(`- Boulder state: \`${BOULDER_PATH}\``);
|
||||||
|
expect(parsed.reason).toContain("- Remaining top-level checkboxes: `2` of `3`");
|
||||||
|
expect(parsed.reason).toContain("- Next incomplete task: `First`");
|
||||||
|
expect(parsed.reason).toContain("- Worktree: `/tmp/worktree`");
|
||||||
|
expect(parsed.reason).toContain(`- Ledger: \`${LEDGER_PATH}\``);
|
||||||
|
expect(parsed.reason).toContain("- Your session id in boulder.json: `codex:sess_abc`");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("#given active work belongs to another harness #when hook runs #then returns empty output", () => {
|
||||||
|
// given
|
||||||
|
const fs = createMemoryFs({
|
||||||
|
[BOULDER_PATH]: createBoulderJson({ sessionIds: ["opencode:sess_abc"], status: "active" }),
|
||||||
|
[PLAN_PATH]: "- [ ] First",
|
||||||
|
});
|
||||||
|
|
||||||
|
// when
|
||||||
|
const output = runStopHook(createStopInput(), fs);
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(output).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("#given bare legacy session id #when hook runs #then returns empty output", () => {
|
||||||
|
// given
|
||||||
|
const fs = createMemoryFs({
|
||||||
|
[BOULDER_PATH]: createBoulderJson({ sessionIds: ["sess_abc"], status: "active" }),
|
||||||
|
[PLAN_PATH]: "- [ ] First",
|
||||||
|
});
|
||||||
|
|
||||||
|
// when
|
||||||
|
const output = runStopHook(createStopInput(), fs);
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(output).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("#given completed boulder work #when hook runs #then returns empty output", () => {
|
||||||
|
// given
|
||||||
|
const fs = createMemoryFs({
|
||||||
|
[BOULDER_PATH]: createBoulderJson({ sessionIds: ["codex:sess_abc"], status: "completed" }),
|
||||||
|
[PLAN_PATH]: "- [ ] First",
|
||||||
|
});
|
||||||
|
|
||||||
|
// when
|
||||||
|
const output = runStopHook(createStopInput(), fs);
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(output).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("#given malformed input #when hook runs #then returns empty output", () => {
|
||||||
|
// given
|
||||||
|
const fs = createMemoryFs();
|
||||||
|
|
||||||
|
// when
|
||||||
|
const output = runStopHook({ hook_event_name: "Stop", session_id: 123 }, fs);
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(output).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
type BoulderInput = {
|
||||||
|
readonly sessionIds: readonly string[];
|
||||||
|
readonly status: "active" | "completed" | "paused" | "abandoned";
|
||||||
|
readonly worktreePath?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createStopInput(): StopInput {
|
||||||
|
return {
|
||||||
|
hook_event_name: "Stop",
|
||||||
|
session_id: "sess_abc",
|
||||||
|
turn_id: "turn_1",
|
||||||
|
transcript_path: "",
|
||||||
|
cwd: WORKSPACE,
|
||||||
|
model: "gpt-5.5",
|
||||||
|
permission_mode: "default",
|
||||||
|
stop_hook_active: false,
|
||||||
|
last_assistant_message: "done",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBoulderJson(input: BoulderInput): string {
|
||||||
|
const work = {
|
||||||
|
work_id: "work_1",
|
||||||
|
active_plan: ".omo/plans/plan.md",
|
||||||
|
plan_name: "launch-plan",
|
||||||
|
status: input.status,
|
||||||
|
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 } });
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
Vendored
+19
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"schema_version": 2,
|
||||||
|
"active_work_id": "work_1",
|
||||||
|
"works": {
|
||||||
|
"work_1": {
|
||||||
|
"work_id": "work_1",
|
||||||
|
"active_plan": "/repo/.omo/plans/plan-with-unchecked.md",
|
||||||
|
"plan_name": "completed-plan",
|
||||||
|
"status": "completed",
|
||||||
|
"started_at": "2026-05-28T00:00:00Z",
|
||||||
|
"session_ids": ["codex:sess_abc"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"active_plan": "/repo/.omo/plans/plan-with-unchecked.md",
|
||||||
|
"plan_name": "completed-plan",
|
||||||
|
"started_at": "2026-05-28T00:00:00Z",
|
||||||
|
"status": "completed",
|
||||||
|
"session_ids": ["codex:sess_abc"]
|
||||||
|
}
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"schema_version": 2,
|
||||||
|
"active_work_id": "codex_work",
|
||||||
|
"works": {
|
||||||
|
"opencode_work": {
|
||||||
|
"work_id": "opencode_work",
|
||||||
|
"active_plan": "/repo/.omo/plans/opencode-plan.md",
|
||||||
|
"plan_name": "opencode-plan",
|
||||||
|
"status": "active",
|
||||||
|
"started_at": "2026-05-28T00:00:00Z",
|
||||||
|
"session_ids": ["opencode:sess_abc"]
|
||||||
|
},
|
||||||
|
"codex_work": {
|
||||||
|
"work_id": "codex_work",
|
||||||
|
"active_plan": "/repo/.omo/plans/plan-with-unchecked.md",
|
||||||
|
"plan_name": "codex-plan",
|
||||||
|
"status": "paused",
|
||||||
|
"started_at": "2026-05-28T00:00:00Z",
|
||||||
|
"session_ids": ["codex:def"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"active_plan": "/repo/.omo/plans/opencode-plan.md",
|
||||||
|
"plan_name": "legacy-opencode-plan",
|
||||||
|
"started_at": "2026-05-28T00:00:00Z",
|
||||||
|
"status": "active",
|
||||||
|
"session_ids": ["opencode:sess_abc"]
|
||||||
|
}
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"schema_version": 2,
|
||||||
|
"active_work_id": "work_1",
|
||||||
|
"works": {
|
||||||
|
"work_1": {
|
||||||
|
"work_id": "work_1",
|
||||||
|
"active_plan": "/repo/.omo/plans/plan-with-unchecked.md",
|
||||||
|
"plan_name": "launch-plan",
|
||||||
|
"status": "active",
|
||||||
|
"started_at": "2026-05-28T00:00:00Z",
|
||||||
|
"session_ids": ["codex:sess_abc"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"active_plan": "/repo/.omo/plans/plan-with-unchecked.md",
|
||||||
|
"plan_name": "legacy-launch-plan",
|
||||||
|
"started_at": "2026-05-28T00:00:00Z",
|
||||||
|
"status": "active",
|
||||||
|
"session_ids": ["codex:sess_legacy"]
|
||||||
|
}
|
||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
# Launch Plan
|
||||||
|
|
||||||
|
## TODOs
|
||||||
|
- [x] First
|
||||||
|
- [x] Second
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
# Launch Plan
|
||||||
|
|
||||||
|
## TODOs
|
||||||
|
- [ ] Top-level
|
||||||
|
|
||||||
|
### Acceptance Criteria
|
||||||
|
- [ ] Nested under acceptance criteria
|
||||||
|
- [x] Nested done
|
||||||
|
|
||||||
|
## Final Checklist
|
||||||
|
- [ ] Nested under final checklist
|
||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
# Launch Plan
|
||||||
|
|
||||||
|
## TODOs
|
||||||
|
- [ ] First
|
||||||
|
- [x] Done already
|
||||||
|
- [ ] Second
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ["test/**/*.test.ts"],
|
||||||
|
environment: "node",
|
||||||
|
pool: "threads",
|
||||||
|
isolate: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
"components/rules",
|
"components/rules",
|
||||||
"components/lsp",
|
"components/lsp",
|
||||||
"components/telemetry",
|
"components/telemetry",
|
||||||
|
"components/start-work-continuation",
|
||||||
"components/ultragoal",
|
"components/ultragoal",
|
||||||
"components/ultrawork"
|
"components/ultrawork"
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user