From 71e4e2a6d0c088197d89c1de1b6d7a3cd507c423 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 29 May 2026 13:27:22 +0900 Subject: [PATCH] feat(omo-claude): port ultragoal to per-session file-based goals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Goal content under ./.omo/ultragoal/sessions/claude-/ keyed by Claude Code session_id (claude: prefix); UltragoalScope struct threaded; plan version 2 + index.json registry with read-only v1 forward-migration (never deletes v1); create_goal/get_goal/update_goal dependency dropped (file/steering-based); the PreToolUse create_goal budget guard kept compiled+unit-tested but its hooks.json registration removed (inert in CC). 26 tests pass. ultragoal removed from sync-components HANDLED set — it is a hand-fork, not patch-synced. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/ultragoal/hooks/hooks.json | 16 + .../plugin/components/ultragoal/package.json | 55 +++ .../ultragoal/skills/ultragoal/.gitkeep | 0 .../ultragoal/skills/ultragoal/SKILL.md | 198 +++++++++++ .../skills/ultragoal/agents/openai.yaml | 6 + .../plugin/components/ultragoal/src/.gitkeep | 0 .../components/ultragoal/src/checkpoint.ts | 161 +++++++++ .../components/ultragoal/src/claude-hook.ts | 186 ++++++++++ .../ultragoal/src/cli-arg-parser.ts | 95 +++++ .../components/ultragoal/src/cli-commands.ts | 177 ++++++++++ .../components/ultragoal/src/cli-output.ts | 61 ++++ .../components/ultragoal/src/cli-steering.ts | 94 +++++ .../plugin/components/ultragoal/src/cli.ts | 40 +++ .../components/ultragoal/src/evidence.ts | 122 +++++++ .../ultragoal/src/goal-instruction.ts | 110 ++++++ .../components/ultragoal/src/goal-snapshot.ts | 134 +++++++ .../components/ultragoal/src/goal-status.ts | 84 +++++ .../plugin/components/ultragoal/src/paths.ts | 74 ++++ .../components/ultragoal/src/plan-crud.ts | 115 ++++++ .../components/ultragoal/src/plan-io.ts | 240 +++++++++++++ .../components/ultragoal/src/quality-gate.ts | 102 ++++++ .../ultragoal/src/review-blockers.ts | 83 +++++ .../components/ultragoal/src/session-scope.ts | 65 ++++ .../components/ultragoal/src/steering.ts | 271 +++++++++++++++ .../plugin/components/ultragoal/src/types.ts | 327 ++++++++++++++++++ .../ultragoal/test/claude-hook.test.ts | 109 ++++++ .../test/cli-scope-resolution.test.ts | 60 ++++ .../ultragoal/test/lifecycle.test.ts | 87 +++++ .../ultragoal/test/session-isolation.test.ts | 108 ++++++ .../ultragoal/test/session-scope.test.ts | 59 ++++ .../components/ultragoal/tsconfig.build.json | 12 + .../plugin/components/ultragoal/tsconfig.json | 27 ++ .../plugin/scripts/sync-components.mjs | 5 +- 33 files changed, 3282 insertions(+), 1 deletion(-) create mode 100644 packages/omo-claude/plugin/components/ultragoal/hooks/hooks.json create mode 100644 packages/omo-claude/plugin/components/ultragoal/package.json create mode 100644 packages/omo-claude/plugin/components/ultragoal/skills/ultragoal/.gitkeep create mode 100644 packages/omo-claude/plugin/components/ultragoal/skills/ultragoal/SKILL.md create mode 100644 packages/omo-claude/plugin/components/ultragoal/skills/ultragoal/agents/openai.yaml create mode 100644 packages/omo-claude/plugin/components/ultragoal/src/.gitkeep create mode 100644 packages/omo-claude/plugin/components/ultragoal/src/checkpoint.ts create mode 100644 packages/omo-claude/plugin/components/ultragoal/src/claude-hook.ts create mode 100644 packages/omo-claude/plugin/components/ultragoal/src/cli-arg-parser.ts create mode 100644 packages/omo-claude/plugin/components/ultragoal/src/cli-commands.ts create mode 100644 packages/omo-claude/plugin/components/ultragoal/src/cli-output.ts create mode 100644 packages/omo-claude/plugin/components/ultragoal/src/cli-steering.ts create mode 100644 packages/omo-claude/plugin/components/ultragoal/src/cli.ts create mode 100644 packages/omo-claude/plugin/components/ultragoal/src/evidence.ts create mode 100644 packages/omo-claude/plugin/components/ultragoal/src/goal-instruction.ts create mode 100644 packages/omo-claude/plugin/components/ultragoal/src/goal-snapshot.ts create mode 100644 packages/omo-claude/plugin/components/ultragoal/src/goal-status.ts create mode 100644 packages/omo-claude/plugin/components/ultragoal/src/paths.ts create mode 100644 packages/omo-claude/plugin/components/ultragoal/src/plan-crud.ts create mode 100644 packages/omo-claude/plugin/components/ultragoal/src/plan-io.ts create mode 100644 packages/omo-claude/plugin/components/ultragoal/src/quality-gate.ts create mode 100644 packages/omo-claude/plugin/components/ultragoal/src/review-blockers.ts create mode 100644 packages/omo-claude/plugin/components/ultragoal/src/session-scope.ts create mode 100644 packages/omo-claude/plugin/components/ultragoal/src/steering.ts create mode 100644 packages/omo-claude/plugin/components/ultragoal/src/types.ts create mode 100644 packages/omo-claude/plugin/components/ultragoal/test/claude-hook.test.ts create mode 100644 packages/omo-claude/plugin/components/ultragoal/test/cli-scope-resolution.test.ts create mode 100644 packages/omo-claude/plugin/components/ultragoal/test/lifecycle.test.ts create mode 100644 packages/omo-claude/plugin/components/ultragoal/test/session-isolation.test.ts create mode 100644 packages/omo-claude/plugin/components/ultragoal/test/session-scope.test.ts create mode 100644 packages/omo-claude/plugin/components/ultragoal/tsconfig.build.json create mode 100644 packages/omo-claude/plugin/components/ultragoal/tsconfig.json diff --git a/packages/omo-claude/plugin/components/ultragoal/hooks/hooks.json b/packages/omo-claude/plugin/components/ultragoal/hooks/hooks.json new file mode 100644 index 000000000..72b6ad67c --- /dev/null +++ b/packages/omo-claude/plugin/components/ultragoal/hooks/hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/cli.js\" hook user-prompt-submit", + "timeout": 10, + "statusMessage": "checking ultragoal steering" + } + ] + } + ] + } +} diff --git a/packages/omo-claude/plugin/components/ultragoal/package.json b/packages/omo-claude/plugin/components/ultragoal/package.json new file mode 100644 index 000000000..77782d91c --- /dev/null +++ b/packages/omo-claude/plugin/components/ultragoal/package.json @@ -0,0 +1,55 @@ +{ + "name": "@code-yeongyu/codex-ultragoal", + "version": "0.1.0", + "description": "Codex plugin: durable repo-native multi-goal orchestration with embedded success criteria and observable evidence audit.", + "type": "module", + "packageManager": "npm@11.12.1", + "license": "MIT", + "homepage": "https://github.com/code-yeongyu/codex-ultragoal", + "repository": { + "type": "git", + "url": "git+https://github.com/code-yeongyu/codex-ultragoal.git" + }, + "bugs": { + "url": "https://github.com/code-yeongyu/codex-ultragoal/issues" + }, + "keywords": [ + "codex", + "codex-plugin", + "ultragoal", + "goal-mode", + "orchestration", + "evidence", + "typescript" + ], + "bin": { + "omo": "./dist/cli.js" + }, + "files": [ + "dist", + "hooks", + "skills", + "LICENSE", + "NOTICE", + "README.md", + "CHANGELOG.md" + ], + "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" + }, + "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/ultragoal/skills/ultragoal/.gitkeep b/packages/omo-claude/plugin/components/ultragoal/skills/ultragoal/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/omo-claude/plugin/components/ultragoal/skills/ultragoal/SKILL.md b/packages/omo-claude/plugin/components/ultragoal/skills/ultragoal/SKILL.md new file mode 100644 index 000000000..86918797d --- /dev/null +++ b/packages/omo-claude/plugin/components/ultragoal/skills/ultragoal/SKILL.md @@ -0,0 +1,198 @@ +--- +name: ulw-loop +description: Goal-like loop that uses ultrawork mode to decompose work into systematic, evidence-bound steps. +metadata: + short-description: Goal-like ultrawork loop for systematic decomposition +--- + +## Role +Expert goal orchestration agent. Plan multi-goal work that survives across turns and sessions. +Use GPT-5.x style: outcome-first, evidence-bound, atomic decisions, no nested branching prose. + +## Goal +Deliver every goal in `.omo/ultragoal/goals.json` end-to-end. +Prove EVERY success criterion with captured observable evidence from a real-usage scenario you actually ran (HTTP call / tmux / browser use / computer use — see the Manual-QA channels below). +TESTS ALONE NEVER PROVE DONE. A green test suite is supporting evidence, not completion proof. +Audit each pass, fail, block, steering change, and checkpoint in `.omo/ultragoal/ledger.jsonl`. + +## Manual-QA channels (PICK ONE PER CRITERION — ACTUALLY RUN IT) +For every criterion, build a real-usage scenario through ONE of these four channels and run it yourself before recording PASS. The full test suite being green is NEVER verification on its own. + +1. **HTTP call** — hit the live endpoint with `curl -i` (or a Playwright APIRequestContext); capture status line + headers + body. +2. **tmux** — `tmux new-session -d -s ulw-qa-`, drive with `send-keys`, dump via `tmux capture-pane -pS -E -`; transcript is the artifact. +3. **Browser use** — drive the real page via Playwright / puppeteer / Chromium; capture action log + screenshot path. +4. **Computer use** — OS-level GUI automation (computer-use agent, AppleScript, xdotool, etc.) against the running app; capture action log + screenshot. + +Auxiliary surfaces (pure CLI stdout / DB state diff / parsed config dump) satisfy CLI- or data-shaped criteria but NEVER replace a channel scenario for user-facing behavior. `--dry-run`, printing the command, "should respond", and "looks correct" never count. + +## Artifacts +- `.omo/ultragoal/brief.md`: original brief and durable constraints. +- `.omo/ultragoal/goals.json`: goals with embedded `successCriteria` per goal. +- `.omo/ultragoal/ledger.jsonl`: append-only audit trail. +- Read artifacts before resuming, steering, or checkpointing. +- Never invent state outside `.omo/ultragoal` artifacts or `omo ultragoal status --json`. + +## Bootstrap +Do all three steps before execution. No edits, goal tools, or checkpointing before bootstrap completes. + +### 1. Create goals from the brief +Resolve the CLI before the first command. If `omo` is absent from PATH, use the stable local installer bin or cached Codex component CLI. This is the same ultragoal CLI, so PATH absence is not a blocker. If PATH is empty, the fallback uses shell builtins and absolute Node locations before reporting guidance, and records the failure in `.omo/ultragoal/bootstrap-notepad.md`. +```sh +if command -v omo >/dev/null 2>&1; then + ULTRAGOAL_CLI=omo +else + CODEX_HOME="${CODEX_HOME:-$HOME/.codex}" + ULTRAGOAL_CLI= + if [ -f "$CODEX_HOME/bin/omo" ] || [ -x "$CODEX_HOME/bin/omo" ]; then + ULTRAGOAL_CLI="$CODEX_HOME/bin/omo" + else + for candidate in "$CODEX_HOME"/plugins/cache/sisyphuslabs/omo/*/components/ultragoal/dist/cli.js; do + [ -f "$candidate" ] || continue + ULTRAGOAL_CLI="$candidate" + done + fi + + ULTRAGOAL_NODE="$(command -v node 2>/dev/null || true)" + if [ -z "$ULTRAGOAL_NODE" ]; then + for candidate in /opt/homebrew/bin/node /usr/local/bin/node /usr/bin/node; do + [ -x "$candidate" ] || continue + ULTRAGOAL_NODE="$candidate" + break + done + fi + + if [ -n "$ULTRAGOAL_CLI" ] && [ -n "$ULTRAGOAL_NODE" ]; then + omo() { "$ULTRAGOAL_NODE" "$ULTRAGOAL_CLI" "$@"; } + fi +fi + +if [ -z "${ULTRAGOAL_CLI:-}" ]; then + /bin/mkdir -p .omo/ultragoal 2>/dev/null || mkdir -p .omo/ultragoal 2>/dev/null || true + NOTE="${NOTE:-.omo/ultragoal/bootstrap-notepad.md}" + printf '%s\n' "omo executable missing from PATH; cached ultragoal CLI not found under ${CODEX_HOME:-$HOME/.codex}." >> "$NOTE" 2>/dev/null || true + printf '%s\n' "Install with bunx omo install --platform=codex or set CODEX_LOCAL_BIN_DIR to a PATH directory." >&2 +fi +``` +If `ULTRAGOAL_CLI` is empty, open the durable notepad first, record the missing CLI evidence, then surface the installer issue. + +Run one form: +```sh +omo ultragoal create-goals --brief "" --json +omo ultragoal create-goals --brief-file --json +cat | omo ultragoal create-goals --from-stdin --json +``` +Write state through the CLI path. Do not hand-edit state files. + +### 2. Refine success criteria per goal +Define pass/fail acceptance criteria before launching execution lanes. Include the command, artifact, or manual check that will prove success. +Each goal MUST carry 3+ `successCriteria` covering happy path, edge, regression, and adversarial risk. +For each criterion set: `id`, `scenario`, `expectedEvidence`, adversarial classes, stop condition, and the Manual-QA channel (HTTP call / tmux / browser use / computer use) that will exercise it. +Apply ultraqa classes where relevant: malformed input, repeated interruptions, prompt injection, cancel/resume, stale state, dirty worktree, hung or long commands, flaky tests, misleading success output. +Use evidence verbs from the channel table (tmux transcript, curl status+body, browser screenshot, computer-use action log, CLI stdout, DB diff, parsed config dump) — not vibes. +"Tests pass" is supporting signal, NEVER completion proof. Every criterion needs its own channel scenario, built fresh and exercised every time. +Record manual QA notes when behavior is user-visible. +Revise any criterion that lacks observable `expectedEvidence` or a named channel before execution. + +### 3. Inspect state +Run `omo ultragoal status --json`. +Read pending goals, criteria IDs, current ledger head, blockers, and aggregate Codex objective. + +## Execution Loop +Loop per goal. Cap at 5 cycles per goal. Cap identical same-criterion failures at 3. + +### Acquire Next Goal +1. Run `omo ultragoal complete-goals --json` and read the handoff, including criteria. +2. Call `get_goal` and inspect active Codex state. +3. Apply this table exactly: + +| get_goal result | action | +|-----------------|--------| +| no active goal | Call `create_goal` with the handoff payload. | +| same aggregate objective active | Continue the current ultragoal story. | +| different goal active | STOP. Checkpoint blocked and surface the conflict. | +4. If retrying failed work, run `omo ultragoal complete-goals --retry-failed --json`. +5. Never create a second Codex goal for the same aggregate objective. + +### Per-Criterion Cycle +1. PLAN: read `criterion.scenario`, `criterion.expectedEvidence`, prior ledger entries, and safety bounds. +2. Register atomic todos: `path: for - verify by `. +3. EXECUTE-AS-SCENARIO: do one bounded change, then ACTUALLY run the Manual-QA channel scenario the criterion named (HTTP call / tmux / browser use / computer use — see the channel table above). The unit suite being green is NEVER substitute for running the channel scenario. +4. CAPTURE: collect the observable artifact path: transcript, stdout, screenshot, assertion, status+body, diff, or parsed dump. +5. CLEAN (PAIRED, NEVER SKIP): tear down every runtime artifact step 3 spawned BEFORE recording — server PIDs (`kill`, verify `kill -0` fails), `tmux` sessions (`tmux kill-session -t ulw-qa-`; confirm `tmux ls`), browser / Playwright contexts (`.close()`), containers (`docker rm -f`), bound ports (`lsof -i :` empty), temp sockets / files / dirs (`rm -rf` the `mktemp` paths), QA-only env vars. Embed a one-line cleanup receipt in the evidence string, e.g. `cleanup: killed 12345; tmux kill-session ulw-qa-foo; rm -rf /tmp/ulw.aB12cD`. Missing receipt → record BLOCKED, not PASS. +6. RECORD exactly one result: + - PASS: `omo ultragoal record-evidence --goal-id --criterion-id --status pass --evidence " | " --json` + - FAIL: `omo ultragoal record-evidence --goal-id --criterion-id --status fail --evidence " | " --notes "" --json` + - BLOCKED: `omo ultragoal record-evidence --goal-id --criterion-id --status blocked --evidence "" --notes "" --json` +7. If actual does not match expected, diagnose, fix minimally, and rerun the SAME criterion (including a fresh cleanup). +8. After 3 same-criterion failures, exit the goal with diagnosis. +9. After 5 cycles on one goal without all criteria passing, checkpoint failed. +10. Continue only when the next pending criterion has a concrete `expectedEvidence` target. + +### Goal Completion +1. Confirm every criterion is `pass` with `omo ultragoal criteria --goal-id --json`. +2. Call `get_goal` for a fresh snapshot. +3. Run `omo ultragoal checkpoint --goal-id --status complete --evidence "" --codex-goal-json --json`. +4. If blocked or failed, checkpoint with `--status blocked` or `--status failed` and include diagnosis evidence. +5. If this is the final goal, run the final quality gate first and pass `--quality-gate-json`. + +## Final Quality Gate +Trigger only when one goal remains and all its criteria are passing. +1. Run targeted verification for changed behavior. +2. Run `ai-slop-cleaner` on changed files. If no relevant edits exist, record a passed no-op cleaner report. +3. Rerun verification after cleanup. +4. Run `$code-review`. +5. Clean review means `codeReview.recommendation == "APPROVE"` and `codeReview.architectStatus == "CLEAR"`. +6. If review is non-clean, run `omo ultragoal record-review-blockers --goal-id --title "<...>" --objective "<...>" --evidence "" --codex-goal-json --json`. +7. If clean, checkpoint final completion: +```sh +omo ultragoal checkpoint --goal-id --status complete --evidence "" --codex-goal-json --quality-gate-json --json +``` +`--quality-gate-json` shape: +```json +{ + "aiSlopCleaner": { "status": "passed", "evidence": "cleaner report" }, + "verification": { "status": "passed", "commands": ["npm test"], "evidence": "post-cleaner verification" }, + "codeReview": { "recommendation": "APPROVE", "architectStatus": "CLEAR", "evidence": "review synthesis" }, + "criteriaCoverage": { "totalCriteria": N, "passCount": N, "adversarialClassesCovered": ["malformed_input", "..."] } +} +``` + +## Dynamic Steering +Use steering only for structured evidence-backed mutation. Reject natural-language steering requests. + +| Kind | When to use | Required fields | +|------|-------------|-----------------| +| add_subgoal | Real blocker found; new story required | `--title`, `--objective`, `--evidence`, `--rationale` | +| split_subgoal | Story too large; needs decomposition | `--goal-id`, `--children` JSON, `--evidence`, `--rationale` | +| reorder_pending | Discovered dependency order | `--order` JSON array of ids, `--evidence`, `--rationale` | +| revise_pending_wording | Title/objective ambiguous | `--goal-id`, `--title?`, `--objective?`, `--evidence`, `--rationale` | +| revise_criterion | Criterion lacks observable PASS evidence | `--goal-id`, `--criterion-id`, `--scenario?`, `--expected-evidence?`, `--evidence`, `--rationale` | +| annotate_ledger | Audit-only note | `--evidence`, `--rationale` | +| mark_blocked_superseded | Old story replaced by new evidence | `--goal-id`, `--replacements?`, `--evidence`, `--rationale` | + +Command form: `omo ultragoal steer --kind [] --evidence "<...>" --rationale "<...>" --json`. +Structured prompt directives accepted: `OMO_ULTRAGOAL_STEER: { ... }`, `omo.ultragoal.steer: {...}`, `omo ultragoal steer: {...}`. + +## Constraints +1. NEVER call `update_goal` mid-aggregate; only on final story after the quality gate passes. +2. NEVER call `create_goal` when `get_goal` shows a different active goal. +3. NEVER mark `criterion.status == "pass"` without captured observable evidence in `record-evidence`. +4. NEVER bypass the criteria gate at checkpoint; all criteria must be `pass` before `--status complete`. +5. Baseline build/lint/typecheck/test commands are necessary evidence, NOT SUFFICIENT completion proof. Criteria coverage with observable evidence is the gate. +6. Treat `.omo/ultragoal/ledger.jsonl` as the durable audit trail; checkpoint after every success or failure. +7. Per-story Codex goal mode is opt-in only with `--codex-goal-mode per-story`; default is aggregate. +8. Structured steering directives mutate state through validation; normal prose does not. +9. Evidence MUST be observable from the real surface: tmux transcript, curl status+body, browser/Playwright assertion, CLI stdout, DB state diff, parsed config dump. +10. Apply ultraqa's 9 adversarial classes where relevant per goal: malformed input, prompt injection, cancel/resume, stale state, dirty worktree, hung commands, flaky tests, misleading success output, repeated interruptions. +11. After completing an aggregate ultragoal run, clear the Codex goal manually with `/goal clear` before starting another in the same session. +12. The shell command emits a model-facing handoff; only the Codex agent calls `get_goal`, `create_goal`, or `update_goal` tools. +13. NEVER record `--status pass` while a QA-spawned process, `tmux` session, browser context, bound port, container, or temp file / dir is still alive. The evidence string MUST include the cleanup receipt. Leftover runtime state = BLOCKED, not PASS. + +## Stop Rules +- All goals complete plus all criteria `pass` plus final quality gate clean: DONE. +- 3x same criterion failure: checkpoint failed, surface diagnosis. +- 5 cycles on one goal without all-pass: checkpoint failed, surface. +- Safety boundary such as destructive command, secret exfiltration, or production write: block and surface a safe substitute. +- Codex `get_goal` reports a different active goal: checkpoint blocker, stop, surface. +- Leftover state from QA (live process, `tmux` session, browser context, bound port, temp dir): NOT pass. Clean up, append the receipt, then continue. +- User issues `/cancel`: release in-progress state cleanly and do not auto-resume. diff --git a/packages/omo-claude/plugin/components/ultragoal/skills/ultragoal/agents/openai.yaml b/packages/omo-claude/plugin/components/ultragoal/skills/ultragoal/agents/openai.yaml new file mode 100644 index 000000000..f6855ddbb --- /dev/null +++ b/packages/omo-claude/plugin/components/ultragoal/skills/ultragoal/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "ulw loop" + short_description: "Goal-like ultrawork loop for systematic decomposition" + search_terms: + - "ultragoal" + default_prompt: "Use $ulw-loop to break this work into a systematic ultrawork loop with evidence-backed checkpoints." diff --git a/packages/omo-claude/plugin/components/ultragoal/src/.gitkeep b/packages/omo-claude/plugin/components/ultragoal/src/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/omo-claude/plugin/components/ultragoal/src/checkpoint.ts b/packages/omo-claude/plugin/components/ultragoal/src/checkpoint.ts new file mode 100644 index 000000000..28c57456c --- /dev/null +++ b/packages/omo-claude/plugin/components/ultragoal/src/checkpoint.ts @@ -0,0 +1,161 @@ +// biome-ignore-all format: keep checkpoint orchestration below the pure LOC budget. +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +import { formatGoalReconciliation, readGoalSnapshotInput, reconcileGoalSnapshot } from "./goal-snapshot.js"; +import { requireAllCriteriaPass } from "./evidence.js"; +import { compatibleObjectives, expectedObjective, goalMode, isFinalRunCompletionCandidate } from "./goal-status.js"; +import { ultragoalBriefPath } from "./paths.js"; +import { appendLedger, readUltragoalPlan, withUltragoalMutationLock, writePlan } from "./plan-io.js"; +import { classifyExternalAuthorizationBlocker, clearGoalBlockerFields, sameBlockerOccurrences, validateQualityGate } from "./quality-gate.js"; +import type { UltragoalScope } from "./session-scope.js"; +import type { UltragoalAggregateCompletion, UltragoalItem, UltragoalLedgerEntry, UltragoalPlan, UltragoalQualityGate } from "./types.js"; +import { iso, ULTRAGOAL_DIR, ULTRAGOAL_GOALS, ULTRAGOAL_LEDGER, UltragoalError } from "./types.js"; + +export interface CheckpointUltragoalArgs { readonly goalId: string; readonly status: "complete" | "failed" | "blocked"; readonly evidence: string; readonly goalSnapshotJson?: string; readonly qualityGateJson?: string } +export interface CheckpointUltragoalResult { readonly plan: UltragoalPlan; readonly goal: UltragoalItem; readonly ledgerEntry: UltragoalLedgerEntry; readonly aggregateCompletion?: UltragoalAggregateCompletion } + +function ultragoalFail(message: string, code: string): never { throw new UltragoalError(message, code); } +function normalizeObjective(value: string): string { return value.replace(/\s+/g, " ").trim(); } +function nonEmptyEvidence(value: string): string { const trimmed = value.trim(); return trimmed || ultragoalFail("Evidence must be a non-empty string.", "ultragoal_evidence_required"); } +function findGoal(plan: UltragoalPlan, goalId: string): UltragoalItem { const goal = plan.goals.find((candidate) => candidate.id === goalId); return goal ?? ultragoalFail(`Unknown ultragoal id: ${goalId}.`, "ultragoal_goal_not_found"); } + +function textMentionsUltragoalPlanArtifact(value: string | undefined): boolean { + const normalized = (value ?? "").toLowerCase(); + return normalized.includes(ULTRAGOAL_DIR.toLowerCase()) || normalized.includes(ULTRAGOAL_GOALS.toLowerCase()) || normalized.includes(ULTRAGOAL_LEDGER.toLowerCase()); +} +function textMentionsGoalId(value: string | undefined, goalId: string): boolean { return (value ?? "").toLowerCase().includes(goalId.toLowerCase()); } +function textHasCompletionValidationEvidence(value: string | undefined): boolean { + const normalized = (value ?? "").toLowerCase(); + const done = /\b(?:planned work|implementation|deliverables?|scope|task|work)\b/.test(normalized) && /\b(?:done|complete|completed|finished|shipped)\b/.test(normalized); + const verified = /\b(?:validation|verification|tests?|build|lint|review|quality gate|code-review)\b/.test(normalized) && /\b(?:passed|complete|completed|clean|green|approve|approved|clear)\b/.test(normalized); + return done && verified; +} + +async function snapshotObjectiveMapsToUltragoalPlan(scope: UltragoalScope, snapshotObjective: string): Promise { + const actual = normalizeObjective(snapshotObjective).toLowerCase(); + if (textMentionsUltragoalPlanArtifact(actual)) return true; + if (actual.length < 24 || !existsSync(ultragoalBriefPath(scope))) return false; + try { + const brief = normalizeObjective(await readFile(ultragoalBriefPath(scope), "utf8")).toLowerCase(); + return brief.length >= 24 && (brief.includes(actual) || actual.includes(brief)); + } catch (error) { + if (error instanceof Error) return false; + throw error; + } +} + +async function canReconcileCompletedTaskScopedAggregateSnapshot(scope: UltragoalScope, plan: UltragoalPlan, goal: UltragoalItem, snapshotObjective: string, evidence: string): Promise { + if (goalMode(plan) !== "aggregate") return false; + if (goal.status !== "in_progress" || plan.activeGoalId !== goal.id) return false; + if (isFinalRunCompletionCandidate(plan, goal)) return snapshotObjectiveMapsToUltragoalPlan(scope, snapshotObjective); + if (!textMentionsUltragoalPlanArtifact(evidence) || !textMentionsGoalId(evidence, goal.id)) return false; + if (!textHasCompletionValidationEvidence(evidence)) return false; + return snapshotObjectiveMapsToUltragoalPlan(scope, snapshotObjective); +} + +function buildCompletedLegacyGoalRemediation(goal: UltragoalItem): string { + return [ + "If a provided goal snapshot reports a different completed legacy/thread objective, do not repeat --status complete in this thread.", + `Record a non-terminal blocker with: omo ultragoal checkpoint --goal-id ${goal.id} --status blocked --evidence "".`, + "Then continue only from a context with no active/completed conflicting goal, in the same repo/worktree.", + ].join(" "); +} + +function buildTaskScopedAggregateReconciliationHint(goal: UltragoalItem, final: boolean): string { + if (final) { + return ` Final task-scoped aggregate reconciliation requires the checkpoint goal to be the active in-progress final OMO goal and the completed snapshot objective to map to the ultragoal brief or artifact. ${buildCompletedLegacyGoalRemediation(goal)}`; + } + return ` Completed task-scoped aggregate reconciliation requires the checkpoint goal to be the active in-progress OMO goal, evidence that names that active OMO goal id, names .omo/ultragoal/goals.json or ledger.jsonl, includes completed implementation plus validation/review evidence, and a snapshot objective that maps to the ultragoal brief/artifact. ${buildCompletedLegacyGoalRemediation(goal)}`; +} + +async function readJsonInput(raw: string | undefined, repoRoot: string): Promise { + if (raw === undefined || raw.trim() === "") return undefined; + const trimmed = raw.trim(); + try { return JSON.parse(trimmed); } catch (error) { if (!(error instanceof SyntaxError)) throw error; } + const path = resolve(repoRoot, trimmed); + if (!existsSync(path)) return ultragoalFail("Quality gate JSON is neither valid JSON nor a readable path.", "ultragoal_json_input_invalid"); + try { return JSON.parse(await readFile(path, "utf8")); } catch (error) { return ultragoalFail(`Quality gate path does not contain valid JSON${error instanceof Error ? `: ${error.message}` : "."}`, "ultragoal_json_input_invalid"); } +} + +function makeAggregateCompletion(now: string, evidence: string, goalSnapshot: unknown): UltragoalAggregateCompletion { + return { status: "complete", completedAt: now, evidence, ...(goalSnapshot === undefined ? {} : { goalSnapshot }) }; +} + +function applyBlockedOrFailed(goal: UltragoalItem, plan: UltragoalPlan, status: "failed" | "blocked", evidence: string, now: string): void { + const signature = classifyExternalAuthorizationBlocker(evidence); + const occurrences = signature === null ? 0 : sameBlockerOccurrences(plan, signature) + 1; + const needsDecision = signature !== null && occurrences >= 3; + goal.status = needsDecision ? "needs_user_decision" : status; + goal.updatedAt = now; + if (status === "failed" || needsDecision) { goal.failedAt = now; goal.failureReason = evidence; } + if (status === "blocked" || needsDecision) goal.blockedReason = evidence; + if (signature !== null) { goal.blockerSignature = signature; goal.blockerOccurrenceCount = occurrences; goal.requiredExternalDecision = `Resolve external authorization: ${signature}`; } + if (needsDecision) goal.nonRetriable = true; + if (plan.activeGoalId === goal.id) delete plan.activeGoalId; +} + +function ledgerKind(status: CheckpointUltragoalArgs["status"], goal: UltragoalItem, aggregateCompletion: UltragoalAggregateCompletion | undefined): UltragoalLedgerEntry["kind"] { + if (aggregateCompletion !== undefined) return "aggregate_completed"; + if (status === "complete") return "goal_completed"; + if (goal.status === "needs_user_decision") return "goal_needs_user_decision"; + return status === "blocked" ? "goal_blocked" : "goal_failed"; +} + +function buildLedger(now: string, args: CheckpointUltragoalArgs, goal: UltragoalItem, qualityGate: UltragoalQualityGate | undefined, goalSnapshot: unknown, aggregateCompletion: UltragoalAggregateCompletion | undefined): UltragoalLedgerEntry { + const entry: UltragoalLedgerEntry = { at: now, kind: ledgerKind(args.status, goal, aggregateCompletion), goalId: goal.id, status: goal.status, evidence: args.evidence }; + if (goalSnapshot !== undefined) entry.goalSnapshot = goalSnapshot; + if (qualityGate !== undefined) entry.qualityGate = qualityGate; + if (goal.blockerSignature !== undefined) entry.blockerSignature = goal.blockerSignature; + if (goal.blockerOccurrenceCount !== undefined) entry.blockerOccurrenceCount = goal.blockerOccurrenceCount; + if (goal.requiredExternalDecision !== undefined) entry.requiredExternalDecision = goal.requiredExternalDecision; + return entry; +} + +export async function checkpointUltragoal(scope: UltragoalScope, args: CheckpointUltragoalArgs): Promise { + return withUltragoalMutationLock(scope, async () => { + const repoRoot = scope.repoRoot; + const plan = await readUltragoalPlan(scope); + const goal = findGoal(plan, args.goalId); + if (args.status === "complete") requireAllCriteriaPass(goal); + const evidence = nonEmptyEvidence(args.evidence); + const now = iso(); + let aggregateCompletion: UltragoalAggregateCompletion | undefined; + let qualityGate: UltragoalQualityGate | undefined; + let goalSnapshot: unknown; + if (args.status === "complete") { + const aggregate = goalMode(plan) === "aggregate"; + const final = isFinalRunCompletionCandidate(plan, goal); + // Goal snapshot is OPTIONAL under the file/steering model (no create_goal/get_goal dependency). + // When a snapshot is supplied it is reconciled as before; when absent, reconciliation is skipped. + if (args.goalSnapshotJson !== undefined) { + const snapshot = await readGoalSnapshotInput(args.goalSnapshotJson, repoRoot); + const reconciliation = reconcileGoalSnapshot(snapshot, { expectedObjective: expectedObjective(plan, goal), ...(aggregate ? { acceptedObjectives: compatibleObjectives(plan) } : {}), allowedStatuses: aggregate ? (final ? ["complete"] : ["active"]) : ["complete"], requireSnapshot: true, requireComplete: !aggregate || final }); + goalSnapshot = reconciliation.snapshot.raw; + if (!reconciliation.ok) { + const objective = snapshot?.objective; + const taskScoped = snapshot?.available === true && snapshot.status === "complete" && objective !== undefined && normalizeObjective(objective) !== normalizeObjective(expectedObjective(plan, goal)) && await canReconcileCompletedTaskScopedAggregateSnapshot(scope, plan, goal, objective, evidence); + if (!taskScoped) throw new UltragoalError(`${formatGoalReconciliation(reconciliation)}${aggregate && snapshot?.status === "complete" && objective !== undefined ? buildTaskScopedAggregateReconciliationHint(goal, final) : ""}`, "ultragoal_goal_snapshot_mismatch"); + aggregateCompletion = makeAggregateCompletion(now, evidence, goalSnapshot); + } + } + if (final) aggregateCompletion = makeAggregateCompletion(now, evidence, goalSnapshot); + if (final || aggregateCompletion !== undefined) qualityGate = validateQualityGate(await readJsonInput(args.qualityGateJson, repoRoot)); + goal.status = "complete"; + goal.completedAt = now; + goal.evidence = evidence; + delete goal.failedAt; + delete goal.failureReason; + clearGoalBlockerFields(goal); + if (plan.activeGoalId === goal.id) delete plan.activeGoalId; + } else applyBlockedOrFailed(goal, plan, args.status, evidence, now); + goal.updatedAt = now; + if (aggregateCompletion !== undefined) plan.aggregateCompletion = aggregateCompletion; + plan.updatedAt = now; + await writePlan(scope, plan); + const ledgerEntry = buildLedger(now, args, goal, qualityGate, goalSnapshot, aggregateCompletion); + await appendLedger(scope, ledgerEntry); + return aggregateCompletion === undefined ? { plan, goal, ledgerEntry } : { plan, goal, ledgerEntry, aggregateCompletion }; + }); +} diff --git a/packages/omo-claude/plugin/components/ultragoal/src/claude-hook.ts b/packages/omo-claude/plugin/components/ultragoal/src/claude-hook.ts new file mode 100644 index 000000000..f2bb4770b --- /dev/null +++ b/packages/omo-claude/plugin/components/ultragoal/src/claude-hook.ts @@ -0,0 +1,186 @@ +import { makeUltragoalScope } from "./session-scope.js"; +import { parseUltragoalSteeringDirective, steerUltragoal } from "./steering.js"; + +export interface UserPromptSubmitPayload { + readonly cwd: string; + readonly hook_event_name: "UserPromptSubmit"; + readonly model?: string; + readonly permission_mode?: string; + readonly prompt: string; + readonly session_id: string; + readonly transcript_path?: string; + readonly turn_id?: string; +} + +export interface PreToolUsePayload { + readonly cwd: string; + readonly hook_event_name: "PreToolUse"; + readonly model?: string; + readonly permission_mode?: string; + readonly session_id: string; + readonly tool_input: unknown; + readonly tool_name: string; + readonly tool_use_id: string; + readonly transcript_path?: string | null; + readonly turn_id?: string; +} + +interface PreToolUseHookOutput { + readonly hookSpecificOutput: { + readonly hookEventName: "PreToolUse"; + readonly permissionDecision: "deny"; + readonly permissionDecisionReason: string; + readonly additionalContext: string; + }; +} + +const CREATE_GOAL_TOOL_NAME = "create_goal"; +const GOAL_BUDGET_WARNING = + "Do not set a budget on create_goal. Omit the budget field so the goal stays unlimited; ultrawork and ultragoal runs must always use unlimited goals."; + +export function parseUserPromptSubmitPayload(raw: string): UserPromptSubmitPayload | null { + if (raw.trim().length === 0) return null; + try { + const parsed: unknown = JSON.parse(raw); + return isUserPromptSubmitPayload(parsed) ? parsed : null; + } catch (error) { + if (error instanceof SyntaxError) return null; + return null; + } +} + +export function parsePreToolUsePayload(raw: string): PreToolUsePayload | null { + if (raw.trim().length === 0) return null; + try { + const parsed: unknown = JSON.parse(raw); + return isPreToolUsePayload(parsed) ? parsed : null; + } catch (error) { + if (error instanceof SyntaxError) return null; + return null; + } +} + +export async function applyUserPromptUltragoalSteering(payload: UserPromptSubmitPayload): Promise { + try { + if (payload.hook_event_name !== "UserPromptSubmit") return ""; + const proposal = parseUltragoalSteeringDirective(payload.prompt); + if (proposal === null) return ""; + const scope = makeUltragoalScope(payload.cwd, payload.session_id); + const result = await steerUltragoal(scope, proposal); + if (!result.accepted) return ""; + return JSON.stringify({ + status: "accepted", + kind: result.audit.kind, + source: result.audit.source, + deduped: result.deduped, + }); + } catch (error) { + if (error instanceof Error) return ""; + return ""; + } +} + +/** + * Inert under Claude Code: the `create_goal` PreToolUse block is intentionally + * NOT registered in hooks.json (D4) because Claude Code never emits a + * `create_goal` tool call. The guard CODE is retained and unit-testable so the + * behavior is documented and exercised; it short-circuits to "" for every tool + * Claude Code actually emits. + */ +export function applyPreToolUseGoalBudgetGuard(payload: PreToolUsePayload): string { + if (payload.hook_event_name !== "PreToolUse") return ""; + if (payload.tool_name !== CREATE_GOAL_TOOL_NAME) return ""; + if (!hasGoalBudgetInput(payload.tool_input)) return ""; + const output: PreToolUseHookOutput = { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: GOAL_BUDGET_WARNING, + additionalContext: GOAL_BUDGET_WARNING, + }, + }; + return `${JSON.stringify(output)}\n`; +} + +export async function runUltragoalHookCli(stdin: NodeJS.ReadableStream, stdout: NodeJS.WritableStream): Promise { + try { + const payload = parseUserPromptSubmitPayload(await readAll(stdin)); + if (payload === null) return; + const output = await applyUserPromptUltragoalSteering(payload); + if (output.length > 0) stdout.write(output); + } catch (error) { + if (error instanceof Error) return; + return; + } +} + +export async function runPreToolUseGoalBudgetGuardCli( + stdin: NodeJS.ReadableStream, + stdout: NodeJS.WritableStream, +): Promise { + try { + const payload = parsePreToolUsePayload(await readAll(stdin)); + if (payload === null) return; + const output = applyPreToolUseGoalBudgetGuard(payload); + if (output.length > 0) stdout.write(output); + } catch (error) { + if (error instanceof Error) return; + return; + } +} + +function isUserPromptSubmitPayload(value: unknown): value is UserPromptSubmitPayload { + if (!isRecord(value)) return false; + return ( + value["hook_event_name"] === "UserPromptSubmit" && + typeof value["cwd"] === "string" && + typeof value["prompt"] === "string" && + typeof value["session_id"] === "string" && + ["model", "permission_mode", "transcript_path", "turn_id"].every((key) => optionalString(value[key])) + ); +} + +/** + * Relaxed PreToolUse validator (D4): `model` and `turn_id` are optional because + * Claude Code never sends `turn_id` and omits `model` on non-SessionStart + * events. Requiring them would make the guard untestable against CC payloads. + */ +function isPreToolUsePayload(value: unknown): value is PreToolUsePayload { + if (!isRecord(value)) return false; + return ( + value["hook_event_name"] === "PreToolUse" && + typeof value["cwd"] === "string" && + typeof value["session_id"] === "string" && + typeof value["tool_name"] === "string" && + typeof value["tool_use_id"] === "string" && + (value["transcript_path"] === null || optionalString(value["transcript_path"])) && + optionalString(value["model"]) && + optionalString(value["permission_mode"]) && + optionalString(value["turn_id"]) && + Object.hasOwn(value, "tool_input") + ); +} + +function hasGoalBudgetInput(value: unknown): boolean { + return isRecord(value) && (Object.hasOwn(value, "token_budget") || Object.hasOwn(value, "tokenBudget")); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function optionalString(value: unknown): boolean { + return value === undefined || typeof value === "string"; +} + +function readAll(stdin: NodeJS.ReadableStream): Promise { + return new Promise((resolve, reject) => { + let data = ""; + stdin.setEncoding("utf8"); + stdin.on("data", (chunk: unknown) => { + data += chunk instanceof Buffer ? chunk.toString() : String(chunk); + }); + stdin.once("error", reject); + stdin.once("end", () => resolve(data)); + }); +} diff --git a/packages/omo-claude/plugin/components/ultragoal/src/cli-arg-parser.ts b/packages/omo-claude/plugin/components/ultragoal/src/cli-arg-parser.ts new file mode 100644 index 000000000..67416eab9 --- /dev/null +++ b/packages/omo-claude/plugin/components/ultragoal/src/cli-arg-parser.ts @@ -0,0 +1,95 @@ +// biome-ignore-all format: keep this module under the mandated pure LOC budget. +import { readFile } from "node:fs/promises"; + +import { UltragoalError } from "./types.js"; + +type RecordEvidenceCliArgs = { readonly goalId: string; readonly criterionId: string; readonly status: "pass" | "fail" | "blocked"; readonly evidence: string; readonly notes?: string }; + +const VALUE_FLAGS = new Set("--session-id --brief --brief-file --goal-mode --codex-goal-mode --goal --goal-id --criterion-id --status --evidence --notes --goal-snapshot-json --codex-goal-json --quality-gate-json --kind --rationale --title --objective --target-goal-id --source --after-json --directive-json --directive-file --idempotency-key".split(" ")); +const SUBCOMMANDS = new Set("create-goals status complete-goals criteria record-evidence checkpoint steer add-goal record-review-blockers".split(" ")); + +export function hasFlag(argv: readonly string[], flag: string): boolean { return argv.includes(flag); } + +export function readValue(argv: readonly string[], flag: string): string | undefined { + const index = argv.indexOf(flag); + if (index >= 0) { + const next = argv[index + 1]; + return next === undefined || next.startsWith("--") ? undefined : next; + } + const prefix = `${flag}=`; + return argv.find((arg) => arg.startsWith(prefix))?.slice(prefix.length); +} + +export function readRepeated(argv: readonly string[], flag: string): string[] { + const values: string[] = []; + const prefix = `${flag}=`; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + const next = argv[index + 1]; + if (arg === flag && next !== undefined && !next.startsWith("--")) { values.push(next); index += 1; } + else if (arg?.startsWith(prefix)) values.push(arg.slice(prefix.length)); + } + return values; +} + +export function parseGoalArg(argv: readonly string[]): string | undefined { return readValue(argv, "--goal-id") ?? readValue(argv, "--goal"); } + +export async function readStdin(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + return Buffer.concat(chunks).toString("utf8"); +} + +export function positionalText(argv: readonly string[]): string { + const words: string[] = []; + for (let index = SUBCOMMANDS.has(argv[0] ?? "") ? 1 : 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === undefined) continue; + if (VALUE_FLAGS.has(arg)) { index += 1; continue; } + if (arg.startsWith("--")) continue; + words.push(arg); + } + return words.join(" ").trim(); +} + +function looksLikeJson(value: string): boolean { const trimmed = value.trim(); return trimmed.startsWith("{") || trimmed.startsWith("["); } + +export async function readJsonInput(value: string | undefined): Promise { + if (value === undefined) return undefined; + try { return JSON.parse(looksLikeJson(value) ? value : await readFile(value, "utf8")); } + catch (error) { + const message = error instanceof Error ? error.message : "unknown error"; + throw new UltragoalError(`Invalid JSON input: ${message}`, "ULTRAGOAL_JSON_INPUT_INVALID", { cause: error }); + } +} + +export async function parseGoalSnapshotJson(value: string | undefined): Promise { + if (value === undefined) return undefined; + const raw = looksLikeJson(value) ? value : await readFile(value, "utf8"); + try { JSON.parse(raw); return raw; } + catch (error) { + const message = error instanceof Error ? error.message : "unknown error"; + throw new UltragoalError(`Invalid --goal-snapshot-json: ${message}`, "ULTRAGOAL_GOAL_SNAPSHOT_JSON_INVALID", { cause: error }); + } +} + +function required(argv: readonly string[], flag: string, code: string): string { + const value = readValue(argv, flag)?.trim(); + if (value) return value; + throw new UltragoalError(`Missing ${flag}.`, code, { details: { flag } }); +} + +function evidenceStatus(value: string): RecordEvidenceCliArgs["status"] { + switch (value) { + case "pass": return "pass"; + case "fail": return "fail"; + case "blocked": return "blocked"; + default: throw new UltragoalError("Invalid --status; expected pass, fail, or blocked.", "ULTRAGOAL_EVIDENCE_STATUS_INVALID", { details: { status: value } }); + } +} + +export function parseRecordEvidenceArgs(argv: readonly string[]): RecordEvidenceCliArgs { + const result = { goalId: required(argv, "--goal-id", "ULTRAGOAL_GOAL_ID_REQUIRED"), criterionId: required(argv, "--criterion-id", "ULTRAGOAL_CRITERION_ID_REQUIRED"), status: evidenceStatus(required(argv, "--status", "ULTRAGOAL_EVIDENCE_STATUS_REQUIRED")), evidence: required(argv, "--evidence", "ULTRAGOAL_EVIDENCE_REQUIRED") }; + const notes = readValue(argv, "--notes")?.trim(); + return notes ? { ...result, notes } : result; +} diff --git a/packages/omo-claude/plugin/components/ultragoal/src/cli-commands.ts b/packages/omo-claude/plugin/components/ultragoal/src/cli-commands.ts new file mode 100644 index 000000000..a4bb2f9c4 --- /dev/null +++ b/packages/omo-claude/plugin/components/ultragoal/src/cli-commands.ts @@ -0,0 +1,177 @@ +// biome-ignore-all format: keep cli-commands dispatcher under the 200 pure LOC budget. +import { readFile } from "node:fs/promises"; +import { checkpointUltragoal } from "./checkpoint.js"; +import { hasFlag, parseGoalSnapshotJson, parseRecordEvidenceArgs, positionalText, readStdin, readValue } from "./cli-arg-parser.js"; +import { blockedDecisionHandoff, normalizeGoalMode, printJson, printStatus, ULTRAGOAL_HELP } from "./cli-output.js"; +import { parseSteeringProposal, printSteerResult } from "./cli-steering.js"; +import { buildGoalInstruction } from "./goal-instruction.js"; +import { recordEvidence } from "./evidence.js"; +import { addUltragoalGoal, createUltragoalPlan, startNextUltragoal, summarizeUltragoalPlan } from "./plan-crud.js"; +import { readUltragoalIndex, readUltragoalPlan } from "./plan-io.js"; +import { recordFinalReviewBlockers } from "./review-blockers.js"; +import { makeUltragoalScope, type UltragoalScope } from "./session-scope.js"; +import { steerUltragoal } from "./steering.js"; +import type { UltragoalItem } from "./types.js"; +import { UltragoalError } from "./types.js"; + +type CheckpointStatus = "complete" | "failed" | "blocked"; + +/** + * Resolve the active session scope for a CLI subcommand. CLI subcommands run + * WITHOUT a hook payload, so the session is resolved in precedence order: + * 1. `--session-id ` flag + * 2. `$CLAUDE_SESSION_ID` + * 3. newest-active session in `./.omo/ultragoal/index.json` + * 4. otherwise error `ULTRAGOAL_SESSION_REQUIRED` + */ +export async function resolveUltragoalScope(repoRoot: string, argv: readonly string[]): Promise { + const flag = readValue(argv, "--session-id")?.trim(); + if (flag) return makeUltragoalScope(repoRoot, flag); + const env = process.env["CLAUDE_SESSION_ID"]?.trim(); + if (env) return makeUltragoalScope(repoRoot, env); + const index = await readUltragoalIndex(repoRoot); + const newest = [...index.sessions].sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt))[0]; + if (newest !== undefined) return makeUltragoalScope(repoRoot, newest.sessionId); + throw new UltragoalError("No ultragoal session resolved. Pass --session-id, set $CLAUDE_SESSION_ID, or run from a Claude Code session.", "ULTRAGOAL_SESSION_REQUIRED"); +} + +export async function ultragoalCommand(argv: readonly string[]): Promise { + const command = argv[0] ?? "help"; + const rest = argv.slice(1); + const repoRoot = process.cwd(); + const json = hasFlag(rest, "--json"); + try { + switch (command) { + case "help": case "--help": case "-h": process.stdout.write(`${ULTRAGOAL_HELP}\n`); return 0; + case "create-goals": return await createGoals(await resolveScopeForCreate(repoRoot, rest), rest, json); + case "status": return await status(await resolveUltragoalScope(repoRoot, rest), json); + case "complete-goals": return await completeGoals(await resolveUltragoalScope(repoRoot, rest), rest, json); + case "checkpoint": return await checkpoint(await resolveUltragoalScope(repoRoot, rest), rest, json); + case "steer": return await steer(await resolveUltragoalScope(repoRoot, rest), rest, json); + case "add-goal": return await addGoal(await resolveUltragoalScope(repoRoot, rest), rest, json); + case "criteria": return await criteria(await resolveUltragoalScope(repoRoot, rest), rest, json); + case "record-evidence": return await captureEvidence(await resolveUltragoalScope(repoRoot, rest), rest, json); + case "record-review-blockers": return await reviewBlockers(await resolveUltragoalScope(repoRoot, rest), rest, json); + default: process.stdout.write(`${ULTRAGOAL_HELP}\n`); return 1; + } + } catch (error) { + if (error instanceof UltragoalError) process.stderr.write(`[ultragoal] ${error.message}\n`); + else if (error instanceof Error) process.stderr.write(`[ultragoal] unexpected: ${error.message}\n`); + else process.stderr.write("[ultragoal] unknown error\n"); + return 1; + } +} + +/** + * create-goals can bootstrap a brand-new session: if --session-id / env are + * absent and no session exists in the index, it still needs a scope. We require + * an explicit session for create so two parallel sessions never collide; fall + * back to the index only when one already exists. + */ +async function resolveScopeForCreate(repoRoot: string, argv: readonly string[]): Promise { + const flag = readValue(argv, "--session-id")?.trim(); + if (flag) return makeUltragoalScope(repoRoot, flag); + const env = process.env["CLAUDE_SESSION_ID"]?.trim(); + if (env) return makeUltragoalScope(repoRoot, env); + throw new UltragoalError("create-goals requires a session. Pass --session-id or set $CLAUDE_SESSION_ID.", "ULTRAGOAL_SESSION_REQUIRED"); +} + +async function createGoals(scope: UltragoalScope, argv: readonly string[], json: boolean): Promise { + const briefFile = readValue(argv, "--brief-file"); + const brief = readValue(argv, "--brief") ?? (briefFile === undefined ? undefined : await readFile(briefFile, "utf8")) ?? (hasFlag(argv, "--from-stdin") ? await readStdin() : undefined) ?? positionalText(argv); + if (!brief.trim()) throw new UltragoalError("Missing brief text. Pass --brief, --brief-file, --from-stdin, or positional text.", "ULTRAGOAL_BRIEF_REQUIRED"); + const plan = await createUltragoalPlan(scope, { brief, goalMode: normalizeGoalMode(readValue(argv, "--goal-mode") ?? readValue(argv, "--codex-goal-mode")), force: hasFlag(argv, "--force") }); + if (json) printJson({ ok: true, plan, summary: summarizeUltragoalPlan(plan) }); + else process.stdout.write(`ultragoal plan created: ${plan.goals.length} goal(s)\nsession: ${plan.sessionId}\nbrief: ${plan.briefPath}\ngoals: ${plan.goalsPath}\nledger: ${plan.ledgerPath}\n`); + return 0; +} + +async function status(scope: UltragoalScope, json: boolean): Promise { + const plan = await readUltragoalPlan(scope); + if (json) printJson({ ok: true, plan, summary: summarizeUltragoalPlan(plan) }); + else printStatus(plan); + return 0; +} + +async function completeGoals(scope: UltragoalScope, argv: readonly string[], json: boolean): Promise { + const result = await startNextUltragoal(scope, { retryFailed: hasFlag(argv, "--retry-failed") }); + if ("done" in result) { + const handoff = blockedDecisionHandoff(result.plan); + if (json) printJson({ ok: true, done: true, blocked: handoff.length > 0, handoff, summary: summarizeUltragoalPlan(result.plan), plan: result.plan }); + else process.stdout.write(`${handoff || "ultragoal: all goals complete"}\n`); + return 0; + } + const instruction = buildGoalInstruction({ plan: result.plan, goal: result.goal }); + if (json) printJson({ ok: true, resumed: result.resumed, goal: result.goal, instruction, plan: result.plan }); + else process.stdout.write(`${instruction.text}\n`); + return 0; +} + +async function checkpoint(scope: UltragoalScope, argv: readonly string[], json: boolean): Promise { + const goalId = required(argv, "--goal-id"); + const statusValue = checkpointStatus(required(argv, "--status")); + const evidence = required(argv, "--evidence"); + const goalSnapshotJson = await parseGoalSnapshotJson(readValue(argv, "--goal-snapshot-json") ?? readValue(argv, "--codex-goal-json")); + const qualityGateJson = readValue(argv, "--quality-gate-json"); + const base = { goalId, status: statusValue, evidence }; + const withSnapshot = goalSnapshotJson === undefined ? base : { ...base, goalSnapshotJson }; + const result = await checkpointUltragoal(scope, qualityGateJson === undefined ? withSnapshot : { ...withSnapshot, qualityGateJson }); + if (json) printJson({ ok: true, ...result, summary: summarizeUltragoalPlan(result.plan) }); + else process.stdout.write(`ultragoal checkpoint: ${result.goal.id} -> ${result.goal.status}\n`); + return 0; +} + +async function steer(scope: UltragoalScope, argv: readonly string[], json: boolean): Promise { + const proposal = await parseSteeringProposal(argv); + const result = await steerUltragoal(scope, proposal); + printSteerResult(result, json); + return result.accepted ? 0 : 1; +} + +async function addGoal(scope: UltragoalScope, argv: readonly string[], json: boolean): Promise { + const result = await addUltragoalGoal(scope, { title: required(argv, "--title"), objective: required(argv, "--objective") }); + if (json) printJson({ ok: true, plan: result.plan, goal: result.goal, summary: summarizeUltragoalPlan(result.plan) }); + else { process.stdout.write(`ultragoal added goal: ${result.goal.id}\n`); printStatus(result.plan); } + return 0; +} + +async function criteria(scope: UltragoalScope, argv: readonly string[], json: boolean): Promise { + const goalId = required(argv, "--goal-id"); + const goal = findGoal(await readUltragoalPlan(scope), goalId); + if (json) printJson({ ok: true, goalId: goal.id, criteria: goal.successCriteria }); + else process.stdout.write(`criteria for ${goal.id}:\n${goal.successCriteria.map((c) => `- ${c.id} [${c.status}] (${c.userModel}) ${c.scenario} evidence: ${c.capturedEvidence ?? "pending"}`).join("\n")}\n`); + return 0; +} + +async function captureEvidence(scope: UltragoalScope, argv: readonly string[], json: boolean): Promise { + const result = await recordEvidence(scope, parseRecordEvidenceArgs(argv)); + if (json) printJson({ ok: true, ...result, summary: summarizeUltragoalPlan(result.plan) }); + else process.stdout.write(`ultragoal evidence recorded: ${result.goal.id}/${result.criterion.id} -> ${result.criterion.status}\n`); + return 0; +} + +async function reviewBlockers(scope: UltragoalScope, argv: readonly string[], json: boolean): Promise { + const goalSnapshotJson = await parseGoalSnapshotJson(readValue(argv, "--goal-snapshot-json") ?? readValue(argv, "--codex-goal-json")); + const base = { goalId: required(argv, "--goal-id"), title: required(argv, "--title"), objective: required(argv, "--objective"), evidence: required(argv, "--evidence") }; + const result = await recordFinalReviewBlockers(scope, goalSnapshotJson === undefined ? base : { ...base, goalSnapshotJson }); + if (json) printJson({ ok: true, plan: result.plan, blockedGoal: result.blockedGoal, goal: result.newGoal, ledgerEntries: result.ledgerEntries, summary: summarizeUltragoalPlan(result.plan) }); + else process.stdout.write(`ultragoal final review blockers recorded: ${result.blockedGoal.id} -> review_blocked; added ${result.newGoal.id}\n`); + return 0; +} + +function required(argv: readonly string[], flag: string): string { + const value = readValue(argv, flag)?.trim(); + if (value) return value; + throw new UltragoalError(`Missing ${flag}.`, "ULTRAGOAL_ARGUMENT_MISSING", { details: { flag } }); +} + +function checkpointStatus(value: string): CheckpointStatus { + if (value === "complete" || value === "failed" || value === "blocked") return value; + throw new UltragoalError("Missing or invalid --status; expected complete, failed, or blocked.", "ULTRAGOAL_STATUS_INVALID", { details: { status: value } }); +} + +function findGoal(plan: { readonly goals: readonly UltragoalItem[] }, goalId: string): UltragoalItem { + const goal = plan.goals.find((candidate) => candidate.id === goalId); + if (goal !== undefined) return goal; + throw new UltragoalError(`Unknown ultragoal id: ${goalId}.`, "ULTRAGOAL_GOAL_NOT_FOUND", { details: { goalId } }); +} diff --git a/packages/omo-claude/plugin/components/ultragoal/src/cli-output.ts b/packages/omo-claude/plugin/components/ultragoal/src/cli-output.ts new file mode 100644 index 000000000..f585843a3 --- /dev/null +++ b/packages/omo-claude/plugin/components/ultragoal/src/cli-output.ts @@ -0,0 +1,61 @@ +import type { UltragoalGoalMode, UltragoalItem, UltragoalPlan } from "./types.js"; +import { UltragoalError } from "./types.js"; + +export const ULTRAGOAL_HELP = `Usage (all subcommands resolve a session via --session-id, $CLAUDE_SESSION_ID, or the newest active session): + omo ultragoal create-goals --brief "..." [--session-id ] [--brief-file ] [--from-stdin] [--goal-mode aggregate|per_story] [--force] [--json] + omo ultragoal status [--session-id ] [--json] + omo ultragoal complete-goals [--session-id ] [--retry-failed] [--json] + omo ultragoal criteria --goal-id [--session-id ] [--json] + omo ultragoal record-evidence --goal-id --criterion-id --status pass|fail|blocked --evidence "..." [--notes "..."] [--session-id ] [--json] + omo ultragoal checkpoint --goal-id --status complete|failed|blocked --evidence "..." [--goal-snapshot-json <...>] [--quality-gate-json <...>] [--session-id ] [--json] + omo ultragoal steer --kind ... --evidence "..." --rationale "..." [--session-id ] [--json] + omo ultragoal add-goal --title "..." --objective "..." [--session-id ] [--json] + omo ultragoal record-review-blockers --goal-id --title "..." --objective "..." --evidence "..." [--goal-snapshot-json <...>] [--session-id ] [--json]`; + +type CriteriaCounts = { readonly pass: number; readonly total: number }; + +export function printJson(value: unknown): void { + process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); +} + +function criteriaCounts(goal: UltragoalItem): CriteriaCounts { + let pass = 0; + for (const criterion of goal.successCriteria) if (criterion.status === "pass") pass += 1; + return { pass, total: goal.successCriteria.length }; +} + +export function printStatus(plan: UltragoalPlan): void { + let totalCriteria = 0; + let passCriteria = 0; + const lines = ["ultragoal status", "", "goals:"]; + for (const goal of plan.goals) { + const counts = criteriaCounts(goal); + totalCriteria += counts.total; + passCriteria += counts.pass; + const marker = goal.id === plan.activeGoalId ? "*" : "-"; + lines.push(`${marker} ${goal.id} [${goal.status}] ${goal.title} (criteria: ${counts.pass}/${counts.total})`); + } + lines.push("", "summary:", `total goals: ${plan.goals.length}`, `criteria: ${passCriteria}/${totalCriteria} pass`); + process.stdout.write(`${lines.join("\n")}\n`); +} + +export function blockedDecisionHandoff(plan: UltragoalPlan): string { + const blocked = plan.goals.find((goal) => goal.status === "needs_user_decision" && goal.nonRetriable); + if (blocked === undefined) return ""; + return [ + "ultragoal: blocked on repeated external authorization; no retryable failed goals remain.", + `Goal: ${blocked.id} - ${blocked.title}`, + `Required external decision: ${blocked.requiredExternalDecision ?? "provide the missing authorization or choose a different unblock path"}.`, + "Do not run complete-goals --retry-failed again until external state changes or the user authorizes an unblock path.", + ].join("\n"); +} + +export function normalizeGoalMode(value: string | undefined): UltragoalGoalMode { + if (value === undefined) return "aggregate"; + if (value === "aggregate" || value === "per_story") return value; + throw new UltragoalError( + "Invalid --goal-mode; expected aggregate or per_story.", + "ULTRAGOAL_GOAL_MODE_INVALID", + { details: { value } }, + ); +} diff --git a/packages/omo-claude/plugin/components/ultragoal/src/cli-steering.ts b/packages/omo-claude/plugin/components/ultragoal/src/cli-steering.ts new file mode 100644 index 000000000..d0794da81 --- /dev/null +++ b/packages/omo-claude/plugin/components/ultragoal/src/cli-steering.ts @@ -0,0 +1,94 @@ +// biome-ignore-all format: keep this module under the mandated pure LOC budget. +import { parseGoalArg, readJsonInput, readValue } from "./cli-arg-parser.js"; +import { printJson, printStatus } from "./cli-output.js"; +import type { SteerUltragoalResult, UltragoalSteeringChildGoal, UltragoalSteeringMutationKind, UltragoalSteeringProposal, UltragoalSteeringSource, UltragoalSuccessCriterionUserModel } from "./types.js"; +import { ULTRAGOAL_STEERING_MUTATION_KINDS, ULTRAGOAL_SUCCESS_CRITERION_USER_MODELS, UltragoalError } from "./types.js"; + +const SOURCES = ["user_prompt_submit", "finding", "cli"] as const satisfies readonly UltragoalSteeringSource[]; + +export type CliSteeringProposal = UltragoalSteeringProposal & { readonly goalId?: string; readonly scenario?: string; readonly expectedEvidence?: string; readonly userModel?: UltragoalSuccessCriterionUserModel }; + +function isKind(value: string | undefined): value is UltragoalSteeringMutationKind { return value !== undefined && ULTRAGOAL_STEERING_MUTATION_KINDS.some((kind) => kind === value); } +function isSource(value: string | undefined): value is UltragoalSteeringSource { return value !== undefined && SOURCES.some((source) => source === value); } +function isModel(value: string): value is UltragoalSuccessCriterionUserModel { return ULTRAGOAL_SUCCESS_CRITERION_USER_MODELS.some((model) => model === value); } +function fail(message: string, code: string, details: Record): never { throw new UltragoalError(message, code, { details }); } +function text(value: string | undefined, field: string): string | undefined { if (value === undefined) return undefined; const trimmed = value.trim(); if (trimmed.length > 0) return trimmed; return fail(`Empty ${field}.`, "ULTRAGOAL_STEERING_FIELD_EMPTY", { field }); } +function required(argv: readonly string[], flag: string): string { const value = text(readValue(argv, flag), flag); return value ?? fail(`Missing ${flag}.`, "ULTRAGOAL_STEERING_FIELD_REQUIRED", { flag }); } +function requiredGoal(argv: readonly string[]): string { const value = text(parseGoalArg(argv), "--goal-id"); return value ?? fail("Missing --goal-id.", "ULTRAGOAL_GOAL_ID_REQUIRED", { flag: "--goal-id" }); } +function readObject(value: object, key: string): unknown { return Object.entries(value).find(([name]) => name === key)?.[1]; } +function isPlain(value: unknown): value is object { return typeof value === "object" && value !== null && !Array.isArray(value); } +function objectText(value: object, key: string): string | undefined { const candidate = readObject(value, key); return typeof candidate === "string" ? candidate : undefined; } + +export function parseSteeringKind(argv: readonly string[]): UltragoalSteeringMutationKind { + const value = readValue(argv, "--kind"); + if (isKind(value)) return value; + return value === undefined ? fail("Missing --kind.", "ULTRAGOAL_STEERING_KIND_REQUIRED", { flag: "--kind" }) : fail(`Invalid --kind: ${value}.`, "ULTRAGOAL_STEERING_KIND_INVALID", { value, expected: ULTRAGOAL_STEERING_MUTATION_KINDS }); +} + +export function parseSteeringSource(argv: readonly string[]): UltragoalSteeringSource { + const value = readValue(argv, "--source"); + if (value === undefined) return "cli"; + return isSource(value) ? value : fail(`Invalid --source: ${value}.`, "ULTRAGOAL_STEERING_SOURCE_INVALID", { value, expected: SOURCES }); +} + +function child(value: unknown): UltragoalSteeringChildGoal | null { + if (!isPlain(value)) return null; + const title = text(objectText(value, "title"), "title"); const objective = text(objectText(value, "objective"), "objective"); + if (title === undefined || objective === undefined) return null; + return { title, objective }; +} + +async function children(argv: readonly string[], flag: string, needed: boolean): Promise { + const input = needed ? required(argv, flag) : text(readValue(argv, flag), flag); + if (input === undefined) return []; + const raw = await readJsonInput(input); + if (!Array.isArray(raw)) return fail(`${flag} must be a JSON array.`, "ULTRAGOAL_STEERING_JSON_ARRAY_REQUIRED", { flag }); + const parsed: UltragoalSteeringChildGoal[] = []; + for (const item of raw) { const next = child(item); if (next === null) return fail(`${flag} entries require title/objective.`, "ULTRAGOAL_STEERING_CHILD_INVALID", { flag }); parsed.push(next); } + return parsed; +} + +async function stringArray(argv: readonly string[], flag: string): Promise { + const raw = await readJsonInput(required(argv, flag)); + if (!Array.isArray(raw)) return fail(`${flag} must be a JSON array.`, "ULTRAGOAL_STEERING_JSON_ARRAY_REQUIRED", { flag }); + const values: string[] = []; + for (const item of raw) { if (typeof item !== "string") return fail(`${flag} entries must be strings.`, "ULTRAGOAL_STEERING_STRING_ARRAY_REQUIRED", { flag }); values.push(text(item, flag) ?? ""); } + return values; +} + +function model(value: string | undefined): UltragoalSuccessCriterionUserModel | undefined { const trimmed = text(value, "--user-model"); if (trimmed === undefined) return undefined; return isModel(trimmed) ? trimmed : fail(`Invalid --user-model: ${trimmed}.`, "ULTRAGOAL_STEERING_USER_MODEL_INVALID", { value: trimmed, expected: ULTRAGOAL_SUCCESS_CRITERION_USER_MODELS }); } +function neverKind(kind: never): never { return fail(`Unsupported steering kind: ${String(kind)}.`, "ULTRAGOAL_STEERING_KIND_UNSUPPORTED", { kind }); } + +export async function parseSteeringProposal(argv: readonly string[]): Promise { + const kind = parseSteeringKind(argv); const source = parseSteeringSource(argv); const base = { kind, source, evidence: required(argv, "--evidence"), rationale: required(argv, "--rationale") }; + switch (kind) { + case "add_subgoal": return normalizeSteeringProposal({ ...base, title: required(argv, "--title"), objective: required(argv, "--objective") }); + case "split_subgoal": { const goalId = requiredGoal(argv); return normalizeSteeringProposal({ ...base, goalId, targetGoalId: goalId, childGoals: await children(argv, "--children", true) }); } + case "reorder_pending": return normalizeSteeringProposal({ ...base, pendingOrder: await stringArray(argv, "--order") }); + case "revise_pending_wording": { const goalId = requiredGoal(argv); const revisedTitle = readValue(argv, "--title"); const revisedObjective = readValue(argv, "--objective"); if (revisedTitle === undefined && revisedObjective === undefined) return fail("revise_pending_wording requires --title or --objective.", "ULTRAGOAL_STEERING_UPDATE_REQUIRED", { kind }); return normalizeSteeringProposal({ ...base, goalId, targetGoalId: goalId, ...(revisedTitle === undefined ? {} : { revisedTitle }), ...(revisedObjective === undefined ? {} : { revisedObjective }) }); } + case "revise_criterion": { const goalId = requiredGoal(argv); const criterionId = required(argv, "--criterion-id"); const scenario = readValue(argv, "--scenario"); const expectedEvidence = readValue(argv, "--expected-evidence"); const userModel = model(readValue(argv, "--user-model")); if (scenario === undefined && expectedEvidence === undefined && userModel === undefined) return fail("revise_criterion requires scenario, expected-evidence, or user-model.", "ULTRAGOAL_STEERING_UPDATE_REQUIRED", { kind }); return normalizeSteeringProposal({ ...base, goalId, targetGoalId: goalId, criterionId, ...(scenario === undefined ? {} : { scenario }), ...(expectedEvidence === undefined ? {} : { expectedEvidence }), ...(userModel === undefined ? {} : { userModel }) }); } + case "annotate_ledger": return normalizeSteeringProposal(base); + case "mark_blocked_superseded": { const goalId = requiredGoal(argv); const childGoals = await children(argv, "--replacements", false); return normalizeSteeringProposal({ ...base, goalId, targetGoalId: goalId, ...(childGoals.length === 0 ? {} : { childGoals }) }); } + default: return neverKind(kind); + } +} + +function normalizedChildren(values: readonly UltragoalSteeringChildGoal[] | undefined): UltragoalSteeringChildGoal[] | undefined { if (values === undefined) return undefined; return values.map((item) => ({ title: text(item.title, "child.title") ?? "", objective: text(item.objective, "child.objective") ?? "" })); } +function normalizedStrings(values: readonly string[] | undefined, field: string): string[] | undefined { if (values === undefined) return undefined; return values.map((value) => text(value, field) ?? ""); } + +export function normalizeSteeringProposal(proposal: CliSteeringProposal): CliSteeringProposal { + const evidence = text(proposal.evidence, "evidence") ?? ""; const rationale = text(proposal.rationale, "rationale") ?? ""; const goalId = text(proposal.goalId, "goalId"); const targetGoalId = text(proposal.targetGoalId, "targetGoalId"); const targetGoalIds = normalizedStrings(proposal.targetGoalIds, "targetGoalIds"); + const criterionId = text(proposal.criterionId, "criterionId"); const title = text(proposal.title, "title"); const objective = text(proposal.objective, "objective"); const revisedTitle = text(proposal.revisedTitle, "revisedTitle"); const revisedObjective = text(proposal.revisedObjective, "revisedObjective"); + const blockedReason = text(proposal.blockedReason, "blockedReason"); const directiveText = text(proposal.directiveText, "directiveText"); const promptSignature = text(proposal.promptSignature, "promptSignature"); const idempotencyKey = text(proposal.idempotencyKey, "idempotencyKey"); + const scenario = text(proposal.scenario, "scenario"); const expectedEvidence = text(proposal.expectedEvidence, "expectedEvidence"); const childGoals = normalizedChildren(proposal.childGoals); const pendingOrder = normalizedStrings(proposal.pendingOrder, "pendingOrder"); + return { kind: proposal.kind, source: proposal.source, evidence, rationale, ...(goalId === undefined ? {} : { goalId }), ...(targetGoalId === undefined ? {} : { targetGoalId }), ...(targetGoalIds === undefined ? {} : { targetGoalIds }), ...(criterionId === undefined ? {} : { criterionId }), ...(title === undefined ? {} : { title }), ...(objective === undefined ? {} : { objective }), ...(childGoals === undefined ? {} : { childGoals }), ...(revisedTitle === undefined ? {} : { revisedTitle }), ...(revisedObjective === undefined ? {} : { revisedObjective }), ...(pendingOrder === undefined ? {} : { pendingOrder }), ...(blockedReason === undefined ? {} : { blockedReason }), ...(proposal.after === undefined ? {} : { after: proposal.after }), ...(directiveText === undefined ? {} : { directiveText }), ...(promptSignature === undefined ? {} : { promptSignature }), ...(idempotencyKey === undefined ? {} : { idempotencyKey }), ...(proposal.now === undefined ? {} : { now: proposal.now }), ...(scenario === undefined ? {} : { scenario }), ...(expectedEvidence === undefined ? {} : { expectedEvidence }), ...(proposal.userModel === undefined ? {} : { userModel: proposal.userModel }) }; +} + +export function printSteerResult(result: SteerUltragoalResult, json: boolean): void { + if (json) { printJson({ ok: result.accepted, accepted: result.accepted, rejectedReasons: result.rejectedReasons, deduped: result.deduped, audit: result.audit, plan: result.plan }); return; } + const outcome = result.deduped ? "deduped" : result.accepted ? "accepted" : "rejected"; + process.stdout.write(`ultragoal steer: ${outcome} ${result.audit.kind}\n`); + if (result.rejectedReasons.length > 0) process.stdout.write(`rejected: ${result.rejectedReasons.join("; ")}\n`); + if (result.audit.idempotencyKey !== undefined) process.stdout.write(`idempotency-key: ${result.audit.idempotencyKey}\n`); + printStatus(result.plan); +} diff --git a/packages/omo-claude/plugin/components/ultragoal/src/cli.ts b/packages/omo-claude/plugin/components/ultragoal/src/cli.ts new file mode 100644 index 000000000..3411210e3 --- /dev/null +++ b/packages/omo-claude/plugin/components/ultragoal/src/cli.ts @@ -0,0 +1,40 @@ +#!/usr/bin/env node +import { ultragoalCommand } from "./cli-commands.js"; +import { runPreToolUseGoalBudgetGuardCli, runUltragoalHookCli } from "./claude-hook.js"; + +const TOP_LEVEL_HELP = + "Usage:\n omo ultragoal [args]\n omo hook user-prompt-submit (Claude Code UserPromptSubmit hook)\n omo help | --help | -h (this message)\n\nRun `omo ultragoal help` for ultragoal subcommands.\n"; + +async function main(): Promise { + const argv = process.argv.slice(2); + const command = argv[0]; + if (command === undefined || command === "help" || command === "--help" || command === "-h") { + process.stdout.write(TOP_LEVEL_HELP); + return 0; + } + if (command === "ultragoal") return ultragoalCommand(argv.slice(1)); + if (command === "hook") { + const sub = argv[1]; + if (sub === "user-prompt-submit") { + await runUltragoalHookCli(process.stdin, process.stdout); + return 0; + } + if (sub === "pre-tool-use") { + await runPreToolUseGoalBudgetGuardCli(process.stdin, process.stdout); + return 0; + } + process.stderr.write(`[omo] unknown hook subcommand: ${sub ?? "(none)"}\n`); + return 1; + } + process.stderr.write(`[omo] unknown command: ${command}\n${TOP_LEVEL_HELP}`); + return 1; +} + +main() + .then((code) => { + process.exit(code); + }) + .catch((error: unknown) => { + process.stderr.write(`[omo] ${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); + }); diff --git a/packages/omo-claude/plugin/components/ultragoal/src/evidence.ts b/packages/omo-claude/plugin/components/ultragoal/src/evidence.ts new file mode 100644 index 000000000..29eb71d99 --- /dev/null +++ b/packages/omo-claude/plugin/components/ultragoal/src/evidence.ts @@ -0,0 +1,122 @@ +// biome-ignore-all format: keep this module under the mandated pure LOC budget. +import { hasAllCriteriaPass } from "./goal-status.js"; +import { appendLedger, readUltragoalPlan, withUltragoalMutationLock, writePlan } from "./plan-io.js"; +import type { UltragoalScope } from "./session-scope.js"; +import type { UltragoalItem, UltragoalLedgerEntry, UltragoalPlan, UltragoalSuccessCriterion } from "./types.js"; +import { iso, UltragoalError } from "./types.js"; + +type EvidenceStatus = "pass" | "fail" | "blocked"; +type RecordEvidenceArgs = { readonly goalId: string; readonly criterionId: string; readonly status: EvidenceStatus; readonly evidence: string; readonly notes?: string }; + +function ultragoalFail(message: string, code: string, details: Record): never { throw new UltragoalError(message, code, { details }); } + +function ledgerKind(status: EvidenceStatus): UltragoalLedgerEntry["kind"] { + switch (status) { + case "pass": + return "evidence_captured"; + case "fail": + return "criterion_failed"; + case "blocked": + return "criterion_blocked"; + default: + return ultragoalFail("Invalid criterion status.", "ULTRAGOAL_CRITERION_STATUS_INVALID", { status }); + } +} + +function findGoal(plan: UltragoalPlan, goalId: string): UltragoalItem { + const goal = plan.goals.find((candidate) => candidate.id === goalId); + return goal ?? ultragoalFail(`Ultragoal goal not found: ${goalId}.`, "ULTRAGOAL_GOAL_NOT_FOUND", { goalId }); +} + +function findCriterion(goal: UltragoalItem, criterionId: string): UltragoalSuccessCriterion { + const criterion = goal.successCriteria.find((candidate) => candidate.id === criterionId); + return criterion ?? ultragoalFail(`Success criterion not found: ${criterionId}.`, "ULTRAGOAL_CRITERION_NOT_FOUND", { goalId: goal.id, criterionId }); +} + +function nonEmptyEvidence(evidence: string): string { const trimmed = evidence.trim(); return trimmed || ultragoalFail("Evidence must be a non-empty string.", "ULTRAGOAL_EVIDENCE_REQUIRED", {}); } + +export async function recordEvidence(scope: UltragoalScope, args: RecordEvidenceArgs): Promise<{ plan: UltragoalPlan; goal: UltragoalItem; criterion: UltragoalSuccessCriterion; ledgerEntry: UltragoalLedgerEntry }> { + return withUltragoalMutationLock(scope, async () => { + const plan = await readUltragoalPlan(scope); + const goal = findGoal(plan, args.goalId); + const criterion = findCriterion(goal, args.criterionId); + const evidence = nonEmptyEvidence(args.evidence); + const kind = ledgerKind(args.status); + const prevStatus = criterion.status; + const capturedAt = iso(); + criterion.status = args.status; + criterion.capturedEvidence = evidence; + criterion.capturedAt = capturedAt; + if (args.notes !== undefined) criterion.notes = args.notes; + goal.updatedAt = capturedAt; + plan.updatedAt = capturedAt; + await writePlan(scope, plan); + const ledgerEntry: UltragoalLedgerEntry = { + at: capturedAt, + kind, + goalId: goal.id, + criterionId: criterion.id, + criterionStatus: args.status, + evidence, + capturedEvidence: evidence, + before: { status: prevStatus }, + after: { goalId: goal.id, criterionId: criterion.id, status: args.status, evidence, capturedAt, prevStatus }, + }; + await appendLedger(scope, ledgerEntry); + return { plan, goal, criterion, ledgerEntry }; + }); +} + +export async function markCriteriaPendingResetForGoal(scope: UltragoalScope, goalId: string): Promise<{ plan: UltragoalPlan; resetCount: number }> { + return withUltragoalMutationLock(scope, async () => { + const plan = await readUltragoalPlan(scope); + const goal = findGoal(plan, goalId); + const now = iso(); + const before = goal.successCriteria.map((criterion) => ({ id: criterion.id, status: criterion.status, capturedEvidence: criterion.capturedEvidence, capturedAt: criterion.capturedAt ?? null })); + for (const criterion of goal.successCriteria) { + criterion.status = "pending"; + criterion.capturedEvidence = null; + delete criterion.capturedAt; + delete criterion.notes; + } + goal.updatedAt = now; + plan.updatedAt = now; + await writePlan(scope, plan); + await appendLedger(scope, { at: now, kind: "criteria_revised", goalId, message: `Reset ${goal.successCriteria.length} criteria to pending.`, before, after: { resetCount: goal.successCriteria.length } }); + return { plan, resetCount: goal.successCriteria.length }; + }); +} + +export function criteriaSummary(plan: UltragoalPlan): { totalCriteria: number; passCount: number; pendingCount: number; failCount: number; blockedCount: number; goalsWithUnresolvedCriteria: string[] } { + let totalCriteria = 0; + let passCount = 0; + let pendingCount = 0; + let failCount = 0; + let blockedCount = 0; + const goalsWithUnresolvedCriteria: string[] = []; + for (const goal of plan.goals) { + let unresolved = false; + for (const criterion of goal.successCriteria) { + totalCriteria += 1; + if (criterion.status !== "pass") unresolved = true; + switch (criterion.status) { + case "pass": passCount += 1; break; + case "pending": pendingCount += 1; break; + case "fail": failCount += 1; break; + case "blocked": blockedCount += 1; break; + default: ultragoalFail("Invalid criterion status.", "ULTRAGOAL_CRITERION_STATUS_INVALID", { status: criterion.status }); + } + } + if (unresolved) goalsWithUnresolvedCriteria.push(goal.id); + } + return { totalCriteria, passCount, pendingCount, failCount, blockedCount, goalsWithUnresolvedCriteria }; +} + +export function unresolvedCriteriaOf(goal: UltragoalItem): UltragoalSuccessCriterion[] { return goal.successCriteria.filter((criterion) => criterion.status !== "pass"); } + +export function requireAllCriteriaPass(goal: UltragoalItem): void { + if (hasAllCriteriaPass(goal)) return; + throw new UltragoalError(`Goal ${goal.id} has unresolved success criteria.`, "ultragoal_criteria_not_all_pass", { + details: { goalId: goal.id, unresolved: unresolvedCriteriaOf(goal).map((criterion) => ({ id: criterion.id, status: criterion.status })) }, + }); +} diff --git a/packages/omo-claude/plugin/components/ultragoal/src/goal-instruction.ts b/packages/omo-claude/plugin/components/ultragoal/src/goal-instruction.ts new file mode 100644 index 000000000..28816607c --- /dev/null +++ b/packages/omo-claude/plugin/components/ultragoal/src/goal-instruction.ts @@ -0,0 +1,110 @@ +import { expectedObjective, goalMode, isFinalRunCompletionCandidate } from "./goal-status.js"; +import type { UltragoalGoalMode, UltragoalItem, UltragoalPlan, UltragoalSuccessCriterion } from "./types.js"; + +export interface UltragoalGoalInstruction { + readonly text: string; + readonly objective: string; +} + +export function buildGoalInstruction(args: { + readonly plan: UltragoalPlan; + readonly goal: UltragoalItem; + readonly isFinal?: boolean; +}): UltragoalGoalInstruction { + const mode = goalMode(args.plan); + const objective = expectedObjective(args.plan, args.goal); + const isFinal = args.isFinal ?? isFinalRunCompletionCandidate(args.plan, args.goal); + return { text: buildText(mode, args.plan, args.goal, objective, isFinal), objective }; +} + +function buildText( + mode: UltragoalGoalMode, + plan: UltragoalPlan, + goal: UltragoalItem, + objective: string, + isFinal: boolean, +): string { + return joinLines([ + mode === "aggregate" ? "Ultragoal aggregate-goal handoff" : "Ultragoal active-goal handoff", + `Mode: ${mode}`, + `Plan: ${plan.goalsPath}`, + `Ledger: ${plan.ledgerPath}`, + `Session: ${plan.sessionId}`, + `Goal: ${goal.id} — ${goal.title}`, + "", + ...activeGoalLines(goal), + "", + ...successCriteriaLines(goal.successCriteria), + "", + "Ultragoal tracking constraints (file/steering based — no goal tool required):", + `- The durable objective is tracked in ${plan.goalsPath}; treat it as the source of truth.`, + "- Goals are unlimited. Do not impose numeric token/work limits.", + ...modeConstraintLines(mode, isFinal), + finalSection(goal, isFinal, mode === "aggregate"), + ...checkpointLines(mode), + "", + "Active objective:", + objective, + ]); +} + +function modeConstraintLines(mode: UltragoalGoalMode, isFinal: boolean): readonly string[] { + if (mode === "per_story") { + return [ + "- Work only this goal until its completion audit passes, then checkpoint it.", + "- Record success-criteria evidence with `omo ultragoal record-evidence` as you go.", + ]; + } + return [ + "- The aggregate objective spans the whole ultragoal run; OMO G001/G002/etc. are ledger stories.", + "- Continue the current OMO story; do not start a competing objective.", + isFinal + ? "- This is the final story; complete it only after the mandatory quality gate passes." + : "- This is not the final story: keep the aggregate objective active while later OMO stories remain.", + ]; +} + +function checkpointLines(mode: UltragoalGoalMode): readonly string[] { + const failureLine = + "- If blocked or failed, checkpoint with --status failed and the failure evidence; rerun complete-goals --retry-failed to resume."; + if (mode === "per_story") return [failureLine]; + return [ + "- Checkpoint this OMO story once its success criteria pass under the aggregate objective.", + failureLine, + ]; +} + +function activeGoalLines(goal: UltragoalItem): readonly string[] { + return ["Active goal:", `- id: ${goal.id}`, `- title: ${goal.title}`, `- objective: ${goal.objective}`]; +} + +function successCriteriaLines(criteria: readonly UltragoalSuccessCriterion[]): readonly string[] { + if (criteria.length === 0) return ["Success criteria:", "- No success criteria recorded for this goal."]; + return ["Success criteria:", ...criteria.map(formatCriterionLine)]; +} + +function formatCriterionLine(criterion: UltragoalSuccessCriterion): string { + const remainingWork = criterion.status === "pending" ? " remaining work:" : ""; + return `-${remainingWork} [${criterion.id}] (${criterion.userModel}) ${criterion.scenario} — expect: ${criterion.expectedEvidence} — status: ${criterion.status}`; +} + +function finalSection(goal: UltragoalItem, isFinal: boolean, aggregate: boolean): string { + if (!isFinal) + return "- This is not the final ultragoal story; do not run the final ai-slop-cleaner/$code-review gate yet."; + const blockerCommand = `omo ultragoal record-review-blockers --goal-id ${goal.id} --title "Resolve final code-review blockers" --objective "" --evidence ""`; + const checkpointCommand = `omo ultragoal checkpoint --goal-id ${goal.id} --status complete --evidence "" --quality-gate-json ""`; + return joinLines([ + "Final story — run the mandatory quality gate before completing:", + "- Run ai-slop-cleaner on changed files even when it is a no-op, rerun verification, then run $code-review.", + "- If final $code-review is not APPROVE with architect status CLEAR, do not complete. Record blocker work first:", + ` ${blockerCommand}`, + aggregate + ? "- If final $code-review is clean, checkpoint the aggregate story:" + : "- If final $code-review is clean, checkpoint this story:", + ` ${checkpointCommand}`, + ]); +} + +function joinLines(lines: readonly string[]): string { + return lines.join("\n"); +} diff --git a/packages/omo-claude/plugin/components/ultragoal/src/goal-snapshot.ts b/packages/omo-claude/plugin/components/ultragoal/src/goal-snapshot.ts new file mode 100644 index 000000000..aadeb0e3b --- /dev/null +++ b/packages/omo-claude/plugin/components/ultragoal/src/goal-snapshot.ts @@ -0,0 +1,134 @@ +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +export type GoalSnapshotStatus = "active" | "complete" | "cancelled" | "failed" | "unknown"; + +export interface GoalSnapshot { + available: boolean; + objective?: string; + status?: GoalSnapshotStatus; + raw: unknown; +} + +export interface GoalReconciliation { + ok: boolean; + snapshot: GoalSnapshot; + warnings: string[]; + errors: string[]; +} + +export interface ReconcileGoalOptions { + expectedObjective: string; + acceptedObjectives?: readonly string[]; + allowedStatuses?: readonly GoalSnapshotStatus[]; + requireSnapshot?: boolean; + requireComplete?: boolean; +} + +export class GoalSnapshotError extends Error {} + +function safeObject(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; +} + +function safeString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function normalizeStatus(value: unknown): GoalSnapshotStatus { + const status = safeString(value).toLowerCase(); + if (status === "complete" || status === "completed" || status === "done") return "complete"; + if (status === "cancelled" || status === "canceled") return "cancelled"; + if (status === "failed" || status === "failure") return "failed"; + if (status === "active" || status === "in_progress" || status === "pending" || status === "running") return "active"; + return "unknown"; +} + +function normalizeObjective(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} + +export function parseGoalSnapshot(value: unknown): GoalSnapshot { + const root = safeObject(value); + const goalValue = Object.hasOwn(root, "goal") ? root["goal"] : value; + if (goalValue === null || goalValue === undefined || goalValue === false) { + return { available: false, raw: value }; + } + + const goal = safeObject(goalValue); + const objective = safeString(goal["objective"] ?? goal["goal"] ?? goal["description"] ?? root["objective"]); + const status = normalizeStatus(goal["status"] ?? root["status"]); + + return { + available: Boolean(objective || status !== "unknown"), + ...(objective ? { objective } : {}), + status, + raw: value, + }; +} + +export async function readGoalSnapshotInput(raw: string | undefined, cwd = process.cwd()): Promise { + if (!raw?.trim()) return null; + const trimmed = raw.trim(); + try { + return parseGoalSnapshot(JSON.parse(trimmed)); + } catch { + const path = resolve(cwd, trimmed); + if (!existsSync(path)) { + throw new GoalSnapshotError(`Goal snapshot is neither valid JSON nor a readable path: ${trimmed}`); + } + try { + return parseGoalSnapshot(JSON.parse(await readFile(path, "utf-8"))); + } catch (error) { + throw new GoalSnapshotError( + `Goal snapshot path does not contain valid JSON: ${trimmed}${error instanceof Error ? ` (${error.message})` : ""}`, + ); + } + } +} + +export function reconcileGoalSnapshot( + snapshot: GoalSnapshot | null | undefined, + options: ReconcileGoalOptions, +): GoalReconciliation { + const effectiveSnapshot = snapshot ?? { available: false, raw: null }; + const errors: string[] = []; + const warnings: string[] = []; + + if (!effectiveSnapshot.available) { + const message = "Goal snapshot is absent or reports no active goal."; + if (options.requireSnapshot) errors.push(message); + else warnings.push(message); + return { ok: errors.length === 0, snapshot: effectiveSnapshot, warnings, errors }; + } + + const expected = normalizeObjective(options.expectedObjective); + const accepted = new Set( + [expected, ...(options.acceptedObjectives ?? []).map((objective) => normalizeObjective(objective))].filter( + Boolean, + ), + ); + const actual = normalizeObjective(effectiveSnapshot.objective ?? ""); + if (!actual) { + errors.push("Goal snapshot is missing objective text."); + } else if (!accepted.has(actual)) { + errors.push(`Goal objective mismatch: expected "${expected}", got "${actual}".`); + } + + const allowed = options.allowedStatuses ?? (options.requireComplete ? ["complete"] : ["active", "complete"]); + const actualStatus = effectiveSnapshot.status ?? "unknown"; + if (!allowed.includes(actualStatus)) { + errors.push(`Goal status mismatch: expected ${allowed.join(" or ")}, got ${actualStatus}.`); + } + if (options.requireComplete && actualStatus !== "complete") { + errors.push("Goal is not complete; finish the work and pass a complete snapshot."); + } + + return { ok: errors.length === 0, snapshot: effectiveSnapshot, warnings, errors }; +} + +export function formatGoalReconciliation(reconciliation: GoalReconciliation): string { + const parts = [...reconciliation.errors, ...reconciliation.warnings]; + return parts.join(" "); +} diff --git a/packages/omo-claude/plugin/components/ultragoal/src/goal-status.ts b/packages/omo-claude/plugin/components/ultragoal/src/goal-status.ts new file mode 100644 index 000000000..55567538b --- /dev/null +++ b/packages/omo-claude/plugin/components/ultragoal/src/goal-status.ts @@ -0,0 +1,84 @@ +import type { + UltragoalGoalMode, + UltragoalItem, + UltragoalPlan, + UltragoalStatus, + UltragoalSuccessCriterion, +} from "./types.js"; + +export const ULTRAGOAL_AGGREGATE_OBJECTIVE: string = + "Complete the durable ultragoal plan in .omo/ultragoal/goals.json, including later accepted/appended stories, under the original brief constraints; use .omo/ultragoal/ledger.jsonl as the audit trail."; + +export function goalMode(plan: UltragoalPlan): UltragoalGoalMode { + return plan.goalMode ?? "per_story"; +} + +function isResolvedStatus(status: UltragoalStatus): boolean { + return status === "complete"; +} + +function isSupersededResolved(goal: UltragoalItem, plan: UltragoalPlan): boolean { + if (goal.steeringStatus !== "superseded") return false; + const replacements = goal.supersededBy ?? []; + if (replacements.length === 0) return false; + return replacements.every((id) => { + const replacement = plan.goals.find((candidate) => candidate.id === id); + return replacement !== undefined && isResolvedStatus(replacement.status); + }); +} + +function isCompletionBlocking(goal: UltragoalItem, plan: UltragoalPlan): boolean { + if (goal.steeringStatus === "superseded") return !isSupersededResolved(goal, plan); + if (goal.steeringStatus === "blocked") return true; + return !isResolvedStatus(goal.status); +} + +function isCompletionBlockingForFinalCandidate( + candidate: UltragoalItem, + finalCandidate: UltragoalItem, + plan: UltragoalPlan, +): boolean { + if (candidate.id === finalCandidate.id) return false; + if (candidate.steeringStatus === "superseded") { + const replacements = candidate.supersededBy ?? []; + if (replacements.length === 0) return true; + return !replacements.every((id) => { + if (id === finalCandidate.id) return true; + const replacement = plan.goals.find((goal) => goal.id === id); + return replacement !== undefined && isResolvedStatus(replacement.status); + }); + } + return isCompletionBlocking(candidate, plan); +} + +export function isUltragoalDone(plan: UltragoalPlan): boolean { + if (plan.aggregateCompletion?.status === "complete") return true; + return plan.goals.every((goal) => !isCompletionBlocking(goal, plan)); +} + +export function isFinalRunCompletionCandidate(plan: UltragoalPlan, goal: UltragoalItem): boolean { + return ( + isCompletionBlocking(goal, plan) && + plan.goals.every((candidate) => !isCompletionBlockingForFinalCandidate(candidate, goal, plan)) + ); +} + +export function aggregateObjective(plan: UltragoalPlan): string { + return plan.objective ?? ULTRAGOAL_AGGREGATE_OBJECTIVE; +} + +export function expectedObjective(plan: UltragoalPlan, goal: UltragoalItem): string { + return goalMode(plan) === "aggregate" ? aggregateObjective(plan) : goal.objective; +} + +export function compatibleObjectives(plan: UltragoalPlan): readonly string[] { + return [aggregateObjective(plan), ...(plan.objectiveAliases ?? [])]; +} + +export function hasAllCriteriaPass(goal: UltragoalItem): boolean { + return goal.successCriteria.length > 0 && goal.successCriteria.every((criterion) => criterion.status === "pass"); +} + +export function firstUnresolvedCriterion(goal: UltragoalItem): UltragoalSuccessCriterion | undefined { + return goal.successCriteria.find((criterion) => criterion.status !== "pass"); +} diff --git a/packages/omo-claude/plugin/components/ultragoal/src/paths.ts b/packages/omo-claude/plugin/components/ultragoal/src/paths.ts new file mode 100644 index 000000000..ae1bf8afd --- /dev/null +++ b/packages/omo-claude/plugin/components/ultragoal/src/paths.ts @@ -0,0 +1,74 @@ +import { join } from "node:path"; + +import type { UltragoalScope } from "./session-scope.js"; +import { + ULTRAGOAL_BRIEF, + ULTRAGOAL_DIR, + ULTRAGOAL_GOALS, + ULTRAGOAL_INDEX, + ULTRAGOAL_LEDGER, + ULTRAGOAL_SESSIONS, +} from "./types.js"; + +/** Root `./.omo/ultragoal` directory for a repo. */ +export function ultragoalRootDir(repoRoot: string): string { + return join(repoRoot, ULTRAGOAL_DIR); +} + +/** `./.omo/ultragoal/sessions` directory holding all per-session scopes. */ +export function ultragoalSessionsRoot(repoRoot: string): string { + return join(ultragoalRootDir(repoRoot), ULTRAGOAL_SESSIONS); +} + +/** Per-repo session registry path `./.omo/ultragoal/index.json`. */ +export function ultragoalIndexPath(repoRoot: string): string { + return join(ultragoalRootDir(repoRoot), ULTRAGOAL_INDEX); +} + +/** Per-session content directory `./.omo/ultragoal/sessions/`. */ +export function ultragoalSessionDir(scope: UltragoalScope): string { + return join(ultragoalSessionsRoot(scope.repoRoot), scope.sessionScope); +} + +/** Backwards-compatible alias used by the rest of the codebase. */ +export function ultragoalDir(scope: UltragoalScope): string { + return ultragoalSessionDir(scope); +} + +export function ultragoalBriefPath(scope: UltragoalScope): string { + return join(ultragoalSessionDir(scope), ULTRAGOAL_BRIEF); +} + +export function ultragoalGoalsPath(scope: UltragoalScope): string { + return join(ultragoalSessionDir(scope), ULTRAGOAL_GOALS); +} + +export function ultragoalLedgerPath(scope: UltragoalScope): string { + return join(ultragoalSessionDir(scope), ULTRAGOAL_LEDGER); +} + +// --- Legacy repo-level getters (read-only, for v1 -> v2 migration) --- + +/** Legacy v1 goals path `./.omo/ultragoal/goals.json` (repo-level, no session). */ +export function legacyUltragoalGoalsPath(repoRoot: string): string { + return join(ultragoalRootDir(repoRoot), ULTRAGOAL_GOALS); +} + +/** Legacy v1 brief path `./.omo/ultragoal/brief.md`. */ +export function legacyUltragoalBriefPath(repoRoot: string): string { + return join(ultragoalRootDir(repoRoot), ULTRAGOAL_BRIEF); +} + +/** Legacy v1 ledger path `./.omo/ultragoal/ledger.jsonl`. */ +export function legacyUltragoalLedgerPath(repoRoot: string): string { + return join(ultragoalRootDir(repoRoot), ULTRAGOAL_LEDGER); +} + +export function repoRelative(absolutePath: string, repoRoot: string): string { + const slashPrefix = `${repoRoot}/`; + const backslashPrefix = `${repoRoot}\\`; + if (absolutePath.startsWith(slashPrefix)) return absolutePath.slice(slashPrefix.length).split("\\").join("/"); + if (absolutePath.startsWith(backslashPrefix)) + return absolutePath.slice(backslashPrefix.length).split("\\").join("/"); + return absolutePath.split("\\").join("/"); +} diff --git a/packages/omo-claude/plugin/components/ultragoal/src/plan-crud.ts b/packages/omo-claude/plugin/components/ultragoal/src/plan-crud.ts new file mode 100644 index 000000000..02f3983eb --- /dev/null +++ b/packages/omo-claude/plugin/components/ultragoal/src/plan-crud.ts @@ -0,0 +1,115 @@ +// biome-ignore-all format: keep this port under the mandated pure LOC budget. +import { existsSync } from "node:fs"; +import { mkdir, writeFile } from "node:fs/promises"; + +import { ULTRAGOAL_AGGREGATE_OBJECTIVE } from "./goal-status.js"; +import { ultragoalBriefPath, ultragoalGoalsPath, ultragoalLedgerPath, ultragoalSessionDir } from "./paths.js"; +import { appendLedger, readUltragoalPlan, withUltragoalMutationLock, writePlan } from "./plan-io.js"; +import type { UltragoalScope } from "./session-scope.js"; +import type { UltragoalGoalMode, UltragoalItem, UltragoalPlan, UltragoalSuccessCriterion } from "./types.js"; +import { iso, ULTRAGOAL_DIR, ULTRAGOAL_GOALS, ULTRAGOAL_LEDGER, ULTRAGOAL_PLATFORM, UltragoalError } from "./types.js"; + +export type UltragoalPlanSummary = { readonly total: number; readonly pending: number; readonly in_progress: number; readonly complete: number; readonly failed: number; readonly blocked: number; readonly review_blocked: number; readonly needs_user_decision: number; readonly superseded: number; readonly criteria: { readonly total: number; readonly pass: number; readonly pending: number; readonly fail: number; readonly blocked: number } }; + +function cleanLine(line: string): string { return line.replace(/^\s*(?:[-*+]\s+|\d+[.)]\s+)/, "").trim(); } +function normalizeObjective(value: string): string { return value.replace(/\s+/g, " ").trim(); } +function titleFromObjective(objective: string, fallback: string): string { const firstLine = objective.split(/\r?\n/).map((line) => line.trim()).find(Boolean) ?? fallback; return firstLine.length > 72 ? `${firstLine.slice(0, 69).trimEnd()}...` : firstLine; } +function normalizeGoalId(title: string, index: number): string { const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 36).replace(/-+$/g, ""); return `G${String(index + 1).padStart(3, "0")}${slug ? `-${slug}` : ""}`; } +function assertNonEmpty(value: string | undefined, label: string): string { const trimmed = value?.trim(); if (!trimmed) throw new UltragoalError(`Missing ${label}.`, "ULTRAGOAL_ARGUMENT_MISSING"); return trimmed; } +function truncateObjective(objective: string): string { return objective.length > 80 ? `${objective.slice(0, 77).trimEnd()}...` : objective; } + +export function seedDefaultSuccessCriteria(goalIndex: number, objective: string): UltragoalSuccessCriterion[] { + const subject = truncateObjective(normalizeObjective(objective) || `Goal ${goalIndex + 1}`); + const rows = [ + ["C001", "happy", `happy path for: ${subject}`, `Replace via revise_criterion with observable happy-path proof for goal ${goalIndex + 1}.`], + ["C002", "edge", "edge case (boundary/empty/malformed)", `Replace via revise_criterion with boundary or malformed-input proof for: ${subject}.`], + ["C003", "regression", "regression: adjacent surface still works", `Replace via revise_criterion with regression proof for neighboring behavior after: ${subject}.`], + ] as const; + return rows.map(([id, userModel, scenario, expectedEvidence]) => ({ id, scenario, userModel, expectedEvidence, capturedEvidence: null, status: "pending" })); +} + +export function deriveGoalCandidates(brief: string): Array<{ title: string; objective: string }> { + const bulletGoals = brief.split(/\r?\n/).map((line) => ({ original: line, cleaned: normalizeObjective(cleanLine(line)) })).filter(({ cleaned }) => cleaned.length > 0 && cleaned.length <= 1200).filter(({ original, cleaned }, index, all) => /^\s*(?:[-*+]\s+|\d+[.)]\s+)/.test(original) && all.findIndex((candidate) => candidate.cleaned === cleaned) === index).map(({ cleaned }) => cleaned); + const paragraphs = brief.split(/\n\s*\n/).map(normalizeObjective).filter((paragraph) => paragraph.length > 0 && !paragraph.startsWith("#")); + const selected = (bulletGoals.length > 0 ? bulletGoals : paragraphs).length > 0 ? (bulletGoals.length > 0 ? bulletGoals : paragraphs) : ["Complete the requested project objective."]; + return selected.map((objective, index) => ({ title: titleFromObjective(objective, `Goal ${index + 1}`), objective })); +} + +function makeGoal(title: string, objective: string, index: number, now: string): UltragoalItem { + const cleanTitle = assertNonEmpty(title, "title"); + const cleanObjective = assertNonEmpty(objective, "objective"); + return { id: normalizeGoalId(cleanTitle, index), title: cleanTitle, objective: cleanObjective, status: "pending", successCriteria: seedDefaultSuccessCriteria(index, cleanObjective), attempt: 0, createdAt: now, updatedAt: now }; +} + +function appendGoalToPlan(plan: UltragoalPlan, title: string, objective: string, now: string): UltragoalItem { + const goal = makeGoal(title, objective, plan.goals.length, now); + plan.goals.push(goal); + plan.updatedAt = now; + return goal; +} + +function isScheduleEligible(goal: UltragoalItem): boolean { return goal.steeringStatus !== "superseded" && goal.steeringStatus !== "blocked"; } + +function clearGoalBlockerFields(goal: UltragoalItem): void { + for (const key of ["blockedReason", "blockerSignature", "blockerOccurrenceCount", "requiredExternalDecision", "nonRetriable", "failedAt", "failureReason"] as const) delete goal[key]; +} + +export async function createUltragoalPlan(scope: UltragoalScope, args: { brief: string; goalMode?: UltragoalGoalMode; force?: boolean }): Promise { + return withUltragoalMutationLock(scope, async () => { + if (!args.force && existsSync(ultragoalGoalsPath(scope))) throw new UltragoalError(`Refusing to overwrite existing ${ULTRAGOAL_DIR}/sessions/${scope.sessionScope}/${ULTRAGOAL_GOALS}; pass --force to recreate it.`, "ULTRAGOAL_PLAN_EXISTS"); + const now = iso(); + const goals = deriveGoalCandidates(args.brief).map((goal, index) => makeGoal(goal.title, goal.objective, index, now)); + const sessionPrefix = `${ULTRAGOAL_DIR}/sessions/${scope.sessionScope}`; + const plan: UltragoalPlan = { version: 2, platform: ULTRAGOAL_PLATFORM, sessionId: scope.sessionId, sessionScope: scope.sessionScope, createdAt: now, updatedAt: now, briefPath: `${sessionPrefix}/brief.md`, goalsPath: `${sessionPrefix}/${ULTRAGOAL_GOALS}`, ledgerPath: `${sessionPrefix}/${ULTRAGOAL_LEDGER}`, goalMode: args.goalMode ?? "aggregate", goals }; + if (plan.goalMode === "aggregate") plan.objective = ULTRAGOAL_AGGREGATE_OBJECTIVE; + await mkdir(ultragoalSessionDir(scope), { recursive: true }); + await writeFile(ultragoalBriefPath(scope), args.brief.endsWith("\n") ? args.brief : `${args.brief}\n`, "utf8"); + await writePlan(scope, plan); + await writeFile(ultragoalLedgerPath(scope), "", "utf8"); + await appendLedger(scope, { at: now, kind: "plan_created", message: `${goals.length} goal(s) created` }); + return plan; + }); +} + +export async function addUltragoalGoal(scope: UltragoalScope, args: { title: string; objective: string }): Promise<{ plan: UltragoalPlan; goal: UltragoalItem }> { + return withUltragoalMutationLock(scope, async () => { + const plan = await readUltragoalPlan(scope); + const now = iso(); + const goal = appendGoalToPlan(plan, args.title, args.objective, now); + await writePlan(scope, plan); + await appendLedger(scope, { at: now, kind: "goal_added", goalId: goal.id, status: goal.status, message: goal.title }); + return { plan, goal }; + }); +} + +export async function startNextUltragoal(scope: UltragoalScope, args: { retryFailed?: boolean } = {}): Promise<{ plan: UltragoalPlan; goal: UltragoalItem; resumed: boolean } | { done: true; plan: UltragoalPlan }> { + return withUltragoalMutationLock(scope, async () => { + const plan = await readUltragoalPlan(scope); + const now = iso(); + if (plan.aggregateCompletion?.status === "complete") return { done: true, plan }; + const existing = plan.goals.find((goal) => goal.status === "in_progress" && isScheduleEligible(goal)); + if (existing) { await appendLedger(scope, { at: now, kind: "goal_resumed", goalId: existing.id, status: existing.status, message: "Resuming active ultragoal" }); return { plan, goal: existing, resumed: true }; } + let next = plan.goals.find((goal) => goal.status === "pending" && isScheduleEligible(goal)); + if (!next && args.retryFailed) { + next = plan.goals.find((goal) => goal.status === "failed" && !goal.nonRetriable && isScheduleEligible(goal)); + if (next) await appendLedger(scope, { at: now, kind: "goal_retried", goalId: next.id, status: "pending", ...(next.failureReason ? { message: next.failureReason } : {}) }); + } + if (!next) return { done: true, plan }; + next.status = "in_progress"; + next.attempt += 1; + next.startedAt = now; + clearGoalBlockerFields(next); + next.updatedAt = now; + plan.activeGoalId = next.id; + plan.updatedAt = now; + await writePlan(scope, plan); + await appendLedger(scope, { at: now, kind: "goal_started", goalId: next.id, status: next.status, message: `Attempt ${next.attempt}` }); + return { plan, goal: next, resumed: false }; + }); +} + +export function summarizeUltragoalPlan(plan: UltragoalPlan): UltragoalPlanSummary { + const countStatus = (status: UltragoalItem["status"]): number => plan.goals.filter((goal) => goal.status === status).length; + const countCriteria = (status: UltragoalSuccessCriterion["status"]): number => plan.goals.reduce((sum, goal) => sum + goal.successCriteria.filter((criterion) => criterion.status === status).length, 0); + return { total: plan.goals.length, pending: countStatus("pending"), in_progress: countStatus("in_progress"), complete: countStatus("complete"), failed: countStatus("failed"), blocked: countStatus("blocked"), review_blocked: countStatus("review_blocked"), needs_user_decision: countStatus("needs_user_decision"), superseded: plan.goals.filter((goal) => goal.steeringStatus === "superseded").length, criteria: { total: plan.goals.reduce((sum, goal) => sum + goal.successCriteria.length, 0), pass: countCriteria("pass"), pending: countCriteria("pending"), fail: countCriteria("fail"), blocked: countCriteria("blocked") } }; +} diff --git a/packages/omo-claude/plugin/components/ultragoal/src/plan-io.ts b/packages/omo-claude/plugin/components/ultragoal/src/plan-io.ts new file mode 100644 index 000000000..914a75abf --- /dev/null +++ b/packages/omo-claude/plugin/components/ultragoal/src/plan-io.ts @@ -0,0 +1,240 @@ +import { appendFile, mkdir, readFile, rename, writeFile } from "node:fs/promises"; + +import { + legacyUltragoalGoalsPath, + repoRelative, + ultragoalGoalsPath, + ultragoalIndexPath, + ultragoalLedgerPath, + ultragoalRootDir, + ultragoalSessionDir, +} from "./paths.js"; +import type { UltragoalScope } from "./session-scope.js"; +import type { + UltragoalIndex, + UltragoalIndexEntry, + UltragoalLedgerEntry, + UltragoalLegacyPlanV1, + UltragoalPlan, +} from "./types.js"; +import { + iso, + ULTRAGOAL_DIR, + ULTRAGOAL_GOALS, + ULTRAGOAL_LEDGER, + ULTRAGOAL_PLATFORM, + UltragoalError, +} from "./types.js"; + +const AGGREGATE_OBJECTIVE = `Complete the durable ultragoal plan in ${ULTRAGOAL_DIR}/${ULTRAGOAL_GOALS}, including later accepted/appended stories, under the original brief constraints; use ${ULTRAGOAL_DIR}/${ULTRAGOAL_LEDGER} as the audit trail.`; +const LEGACY_OBJECTIVE_PREFIX = `Complete all ultragoal stories in ${ULTRAGOAL_DIR}/${ULTRAGOAL_GOALS}: `; +const LEGACY_OBJECTIVE = `Complete all ultragoal stories listed in ${ULTRAGOAL_DIR}/${ULTRAGOAL_GOALS}. Use ${ULTRAGOAL_DIR}/${ULTRAGOAL_LEDGER} as the durable audit trail.`; +const locks = new Map>(); + +function lockKey(scope: UltragoalScope): string { + return `${scope.repoRoot}::${scope.sessionScope}`; +} + +function hasCode(error: unknown, code: string): boolean { + return error instanceof Error && "code" in error && error.code === code; +} + +function isLegacyEnumeratedAggregateObjective(objective: string | undefined): objective is string { + return objective === LEGACY_OBJECTIVE || Boolean(objective?.startsWith(LEGACY_OBJECTIVE_PREFIX)); +} + +function isSteeringKind(value: unknown): value is UltragoalLedgerEntry["kind"] { + return value === "steering_accepted" || value === "steering_rejected" || value === "criteria_revised"; +} + +export async function withUltragoalMutationLock(scope: UltragoalScope, fn: () => Promise): Promise { + const key = lockKey(scope); + const prior = locks.get(key) ?? Promise.resolve(); + const run = prior.then(fn, fn); + locks.set( + key, + run.catch(() => undefined), + ); + return run; +} + +function assertValidV2Plan(plan: UltragoalPlan, scope: UltragoalScope, path: string): void { + if (plan.version !== 2 || !Array.isArray(plan.goals)) { + throw new UltragoalError( + `Invalid ultragoal plan at ${repoRelative(path, scope.repoRoot)}.`, + "ULTRAGOAL_PLAN_INVALID", + ); + } +} + +/** + * Read the plan for a session scope. If no session-scoped plan exists but a + * legacy v1 repo-level plan is present, the v1 plan is migrated forward into + * this session scope (D3) and written there; the original v1 file is left in + * place (never deleted). + */ +export async function readUltragoalPlan(scope: UltragoalScope): Promise { + const path = ultragoalGoalsPath(scope); + let raw: string; + try { + raw = await readFile(path, "utf8"); + } catch (error) { + if (!hasCode(error, "ENOENT")) throw error; + const migrated = await tryMigrateLegacyV1(scope); + if (migrated !== null) return migrated; + throw new UltragoalError( + `No ultragoal plan found at ${repoRelative(path, scope.repoRoot)}. Run \`omo ultragoal create-goals ...\` first.`, + "ULTRAGOAL_PLAN_MISSING", + { cause: error }, + ); + } + const parsed: UltragoalPlan = JSON.parse(raw); + assertValidV2Plan(parsed, scope, path); + return await maybeMigrateAggregateObjective(scope, parsed); +} + +async function maybeMigrateAggregateObjective(scope: UltragoalScope, plan: UltragoalPlan): Promise { + const previousObjective = plan.objective; + if ((plan.goalMode ?? "per_story") === "aggregate" && isLegacyEnumeratedAggregateObjective(previousObjective)) { + const now = iso(); + plan.objective = AGGREGATE_OBJECTIVE; + plan.objectiveAliases = [...new Set([...(plan.objectiveAliases ?? []), previousObjective])]; + plan.updatedAt = now; + await writePlan(scope, plan); + await appendLedger(scope, { + at: now, + kind: "aggregate_objective_migrated", + message: "Migrated legacy enumerated aggregate objective to the stable pointer objective.", + before: { objective: previousObjective }, + after: { objective: plan.objective }, + }); + } + return plan; +} + +/** + * Read a legacy repo-level v1 plan (if present) and migrate it into the given + * session scope. Returns the migrated v2 plan, or null when no v1 file exists. + * The legacy v1 file is read-only and never deleted. + */ +async function tryMigrateLegacyV1(scope: UltragoalScope): Promise { + const legacyPath = legacyUltragoalGoalsPath(scope.repoRoot); + let legacyRaw: string; + try { + legacyRaw = await readFile(legacyPath, "utf8"); + } catch (error) { + if (hasCode(error, "ENOENT")) return null; + throw error; + } + const legacy: UltragoalLegacyPlanV1 = JSON.parse(legacyRaw); + if (legacy.version !== 1 || !Array.isArray(legacy.goals)) return null; + const now = iso(); + const plan = migrateV1ToV2(legacy, scope); + await writePlan(scope, plan); + await appendLedger(scope, { + at: now, + kind: "plan_migrated_to_session", + message: `Migrated legacy v1 plan into session ${scope.sessionId} (original v1 file left intact).`, + before: { version: 1, goalsPath: legacy.goalsPath }, + after: { version: 2, sessionScope: scope.sessionScope, goalsPath: plan.goalsPath }, + }); + return plan; +} + +function migrateV1ToV2(legacy: UltragoalLegacyPlanV1, scope: UltragoalScope): UltragoalPlan { + const sessionPrefix = `${ULTRAGOAL_DIR}/sessions/${scope.sessionScope}`; + const plan: UltragoalPlan = { + version: 2, + platform: ULTRAGOAL_PLATFORM, + sessionId: scope.sessionId, + sessionScope: scope.sessionScope, + createdAt: legacy.createdAt, + updatedAt: legacy.updatedAt, + briefPath: `${sessionPrefix}/brief.md`, + goalsPath: `${sessionPrefix}/${ULTRAGOAL_GOALS}`, + ledgerPath: `${sessionPrefix}/${ULTRAGOAL_LEDGER}`, + goals: legacy.goals, + }; + if (legacy.codexGoalMode !== undefined) plan.goalMode = legacy.codexGoalMode; + if (legacy.codexObjective !== undefined) plan.objective = legacy.codexObjective; + if (legacy.codexObjectiveAliases !== undefined) plan.objectiveAliases = legacy.codexObjectiveAliases; + if (legacy.activeGoalId !== undefined) plan.activeGoalId = legacy.activeGoalId; + if (legacy.aggregateCompletion !== undefined) { + const completion = legacy.aggregateCompletion; + plan.aggregateCompletion = { + status: completion.status, + completedAt: completion.completedAt, + evidence: completion.evidence, + ...(completion.codexGoal === undefined ? {} : { goalSnapshot: completion.codexGoal }), + }; + } + return plan; +} + +export async function writePlan(scope: UltragoalScope, plan: UltragoalPlan): Promise { + await mkdir(ultragoalSessionDir(scope), { recursive: true }); + const path = ultragoalGoalsPath(scope); + const tmpPath = `${path}.${process.pid}.${Date.now()}.tmp`; + await writeFile(tmpPath, `${JSON.stringify(plan, null, 2)}\n`, "utf8"); + await rename(tmpPath, path); + await upsertIndexEntry(scope, plan); +} + +export async function appendLedger(scope: UltragoalScope, entry: UltragoalLedgerEntry): Promise { + await mkdir(ultragoalSessionDir(scope), { recursive: true }); + await appendFile(ultragoalLedgerPath(scope), `${JSON.stringify(entry)}\n`, "utf8"); +} + +export async function readSteeringLedgerEntries(scope: UltragoalScope): Promise { + let raw: string; + try { + raw = await readFile(ultragoalLedgerPath(scope), "utf8"); + } catch (error) { + if (hasCode(error, "ENOENT")) return []; + throw error; + } + const entries: UltragoalLedgerEntry[] = []; + for (const line of raw.split(/\r?\n/).filter(Boolean)) { + const entry: UltragoalLedgerEntry = JSON.parse(line); + if (isSteeringKind(entry.kind)) entries.push(entry); + } + return entries; +} + +// --- index.json registry --- + +export async function readUltragoalIndex(repoRoot: string): Promise { + try { + const raw = await readFile(ultragoalIndexPath(repoRoot), "utf8"); + const parsed: UltragoalIndex = JSON.parse(raw); + if (parsed.version === 2 && Array.isArray(parsed.sessions)) return parsed; + return { version: 2, sessions: [] }; + } catch (error) { + if (hasCode(error, "ENOENT")) return { version: 2, sessions: [] }; + throw error; + } +} + +async function writeUltragoalIndex(repoRoot: string, index: UltragoalIndex): Promise { + await mkdir(ultragoalRootDir(repoRoot), { recursive: true }); + const path = ultragoalIndexPath(repoRoot); + const tmpPath = `${path}.${process.pid}.${Date.now()}.tmp`; + await writeFile(tmpPath, `${JSON.stringify(index, null, 2)}\n`, "utf8"); + await rename(tmpPath, path); +} + +/** Atomically upsert this session's entry in the per-repo registry. */ +async function upsertIndexEntry(scope: UltragoalScope, plan: UltragoalPlan): Promise { + const index = await readUltragoalIndex(scope.repoRoot); + const entry: UltragoalIndexEntry = { + sessionId: scope.sessionId, + sessionScope: scope.sessionScope, + platform: ULTRAGOAL_PLATFORM, + createdAt: plan.createdAt, + updatedAt: plan.updatedAt, + goalsPath: plan.goalsPath, + }; + const next = index.sessions.filter((session) => session.sessionScope !== scope.sessionScope); + next.push(entry); + await writeUltragoalIndex(scope.repoRoot, { version: 2, sessions: next }); +} diff --git a/packages/omo-claude/plugin/components/ultragoal/src/quality-gate.ts b/packages/omo-claude/plugin/components/ultragoal/src/quality-gate.ts new file mode 100644 index 000000000..b5e08d41f --- /dev/null +++ b/packages/omo-claude/plugin/components/ultragoal/src/quality-gate.ts @@ -0,0 +1,102 @@ +import type { UltragoalItem, UltragoalPlan, UltragoalQualityGate } from "./types.js"; +import { UltragoalError } from "./types.js"; + +const BLOCKER_FIELD_KEYS = "blocker blockerSignature blockerEvidence blockerOccurrences blockedAt".split(" "); +const URL_PATTERN = /https?:\/\/\S+/g; +const PUNCTUATION_PATTERN = /[`"'()[\]{}:,;]/g; +const WHITESPACE_PATTERN = /\s+/g; +const AUTH_PATTERN = /\b(auth\w*|credential\w*|token|permission\w*|scope\w*|access|unauthorized|forbidden|401|403)\b/; +const MISSING_PATTERN = + /\b(unset|missing|required|requires|without|omit\w*|not set|not available|no read packages|read packages)\b/; +const GHCR_PATTERN = + /\b(ghcr|github container registry|read packages|imagepullsecret|package api|anonymous|container image)\b/; +const GHCR_401_PATTERN = /\b(401|unauthorized|anonymous pull|authentication required)\b/; +const GHCR_403_PATTERN = /\b(403|forbidden|read packages|package api)\b/; + +function invalid(message: string, field: string): never { + throw new UltragoalError(message, "ULTRAGOAL_QUALITY_GATE_INVALID", { details: { field } }); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function section(value: unknown, field: string): Record { + return isRecord(value) ? value : invalid(`Final quality gate is missing ${field} evidence.`, field); +} + +function nonEmptyString(value: unknown, field: string): string { + return typeof value === "string" && value.trim() !== "" + ? value + : invalid(`Final quality gate requires non-empty ${field}.`, field); +} + +function numberField(value: unknown, field: string): number { + return typeof value === "number" && Number.isFinite(value) + ? value + : invalid(`Final quality gate requires numeric ${field}.`, field); +} + +function stringArray(value: unknown, field: string): string[] { + if (!Array.isArray(value) || value.length === 0) return invalid(`Final quality gate requires ${field}.`, field); + return value.map((item) => nonEmptyString(item, field)); +} + +export function validateQualityGate(input: unknown): UltragoalQualityGate { + const gate = section(input, "qualityGate"); + const cleaner = section(gate["aiSlopCleaner"], "aiSlopCleaner"); + const verification = section(gate["verification"], "verification"); + const review = section(gate["codeReview"], "codeReview"); + const coverage = section(gate["criteriaCoverage"], "criteriaCoverage"); + if (cleaner["status"] !== "passed") invalid("aiSlopCleaner.status must be passed.", "aiSlopCleaner.status"); + if (verification["status"] !== "passed") invalid("verification.status must be passed.", "verification.status"); + if (review["recommendation"] !== "APPROVE") invalid("recommendation must be APPROVE.", "codeReview.recommendation"); + if (review["architectStatus"] !== "CLEAR") invalid("architectStatus must be CLEAR.", "codeReview.architectStatus"); + const totalCriteria = numberField(coverage["totalCriteria"], "criteriaCoverage.totalCriteria"); + const passCount = numberField(coverage["passCount"], "criteriaCoverage.passCount"); + if (passCount < totalCriteria) + invalid("criteriaCoverage.passCount must cover totalCriteria.", "criteriaCoverage.passCount"); + const commands = stringArray(verification["commands"], "verification.commands"); + const covered = stringArray(coverage["adversarialClassesCovered"], "criteriaCoverage.adversarialClassesCovered"); + const cleanerEvidence = nonEmptyString(cleaner["evidence"], "aiSlopCleaner.evidence"); + const verificationEvidence = nonEmptyString(verification["evidence"], "verification.evidence"); + const reviewEvidence = nonEmptyString(review["evidence"], "codeReview.evidence"); + const result: UltragoalQualityGate = { + aiSlopCleaner: { status: "passed", evidence: cleanerEvidence }, + verification: { status: "passed", commands, evidence: verificationEvidence }, + codeReview: { recommendation: "APPROVE", architectStatus: "CLEAR", evidence: reviewEvidence }, + }; + Object.assign(result, { criteriaCoverage: { totalCriteria, passCount, adversarialClassesCovered: covered } }); + return result; +} + +export function normalizeBlockerEvidence(evidence: string): string { + const withoutUrls = evidence.toLowerCase().replace(URL_PATTERN, " "); + const withoutPunctuation = withoutUrls.replace(PUNCTUATION_PATTERN, " "); + return withoutPunctuation.replace(WHITESPACE_PATTERN, " ").trim(); +} + +export function classifyExternalAuthorizationBlocker(evidence: string): string | null { + const normalized = normalizeBlockerEvidence(evidence); + if (!normalized || !AUTH_PATTERN.test(normalized) || !MISSING_PATTERN.test(normalized)) return null; + if (!GHCR_PATTERN.test(normalized)) return "EXTERNAL_AUTHORIZATION_REQUIRED"; + const status401 = GHCR_401_PATTERN.test(normalized) ? "HTTP_401_ANONYMOUS" : null; + const status403 = GHCR_403_PATTERN.test(normalized) ? "HTTP_403_NO_READ_PACKAGES" : null; + const status = [status401, status403].filter((part): part is string => part !== null).join("+"); + return `GHCR_PULL_ACCESS:${status || "AUTHORIZATION_REQUIRED"}:GHCR_VISIBILITY_OR_CREDENTIAL_REQUIRED`; +} + +function nestedBlockerSignature(goal: UltragoalItem): string | null { + const blocker = Reflect.get(goal, "blocker"); + const signature = isRecord(blocker) ? blocker["signature"] : null; + return typeof signature === "string" ? signature : null; +} + +export function sameBlockerOccurrences(plan: UltragoalPlan, signature: string): number { + return plan.goals.filter((goal) => goal.blockerSignature === signature || nestedBlockerSignature(goal) === signature) + .length; +} + +export function clearGoalBlockerFields(goal: UltragoalItem): void { + for (const key of BLOCKER_FIELD_KEYS) Reflect.deleteProperty(goal, key); +} diff --git a/packages/omo-claude/plugin/components/ultragoal/src/review-blockers.ts b/packages/omo-claude/plugin/components/ultragoal/src/review-blockers.ts new file mode 100644 index 000000000..265f7c43d --- /dev/null +++ b/packages/omo-claude/plugin/components/ultragoal/src/review-blockers.ts @@ -0,0 +1,83 @@ +// biome-ignore-all format: compact port must stay within the requested pure LOC budget. + +import { readGoalSnapshotInput, reconcileGoalSnapshot } from "./goal-snapshot.js"; +import { compatibleObjectives, expectedObjective, goalMode, isFinalRunCompletionCandidate } from "./goal-status.js"; +import { seedDefaultSuccessCriteria } from "./plan-crud.js"; +import { appendLedger, readUltragoalPlan, withUltragoalMutationLock, writePlan } from "./plan-io.js"; +import type { UltragoalScope } from "./session-scope.js"; +import type { UltragoalItem, UltragoalLedgerEntry, UltragoalPlan } from "./types.js"; +import { iso, UltragoalError } from "./types.js"; + +export interface RecordFinalReviewBlockersArgs { readonly goalId: string; readonly title: string; readonly objective: string; readonly evidence: string; readonly goalSnapshotJson?: string } +export interface RecordFinalReviewBlockersResult { readonly plan: UltragoalPlan; readonly blockedGoal: UltragoalItem; readonly newGoal: UltragoalItem; readonly ledgerEntries: UltragoalLedgerEntry[] } + +const BLOCKER_FIELDS = "blockedReason blockerSignature blockerOccurrenceCount requiredExternalDecision nonRetriable failedAt failureReason completedAt blocker blockerEvidence blockerOccurrences blockedAt".split(" "); + +function ultragoalError(message: string, code: string): never { + throw new UltragoalError(message, code); +} + +function nextGoalId(plan: UltragoalPlan): string { + const max = plan.goals.reduce((current, goal) => { + const digits = /^G(\d+)/u.exec(goal.id)?.[1]; + return digits === undefined ? current : Math.max(current, Number(digits)); + }, 0); + return `G${String(max + 1).padStart(3, "0")}`; +} + +function appendBlockerGoal(plan: UltragoalPlan, args: RecordFinalReviewBlockersArgs, now: string): UltragoalItem { + const index = plan.goals.length; + const goal: UltragoalItem = { + id: nextGoalId(plan), + title: args.title, + objective: args.objective, + status: "pending", + successCriteria: seedDefaultSuccessCriteria(index, args.objective), + attempt: 0, + createdAt: now, + updatedAt: now, + }; + plan.goals.push(goal); + return goal; +} + +export async function recordFinalReviewBlockers( + scope: UltragoalScope, + args: RecordFinalReviewBlockersArgs, +): Promise { + return withUltragoalMutationLock(scope, async () => { + const plan = await readUltragoalPlan(scope); + const goal = plan.goals.find((candidate) => candidate.id === args.goalId); + if (goal === undefined) ultragoalError(`Unknown ultragoal id: ${args.goalId}`, "ultragoal_goal_not_found"); + if (goal.status !== "in_progress") ultragoalError(`${goal.id} is ${goal.status}.`, "ultragoal_goal_not_in_progress"); + if (!isFinalRunCompletionCandidate(plan, goal)) ultragoalError(`${goal.id} is not final.`, "ultragoal_not_final_story"); + + // Goal snapshot is optional under the file/steering model (no create_goal/get_goal dependency). + const snapshot = await readGoalSnapshotInput(args.goalSnapshotJson, scope.repoRoot); + const aggregate = goalMode(plan) === "aggregate"; + if (args.goalSnapshotJson !== undefined) { + const reconciliation = reconcileGoalSnapshot(snapshot, { expectedObjective: expectedObjective(plan, goal), ...(aggregate ? { acceptedObjectives: compatibleObjectives(plan) } : {}), allowedStatuses: ["active"], requireSnapshot: true, requireComplete: false }); + if (!reconciliation.ok) ultragoalError(reconciliation.errors.join(" "), "ultragoal_goal_snapshot_mismatch"); + } + + const now = iso(); + for (const field of BLOCKER_FIELDS) Reflect.deleteProperty(goal, field); + goal.status = "review_blocked"; + goal.reviewBlockedAt = now; + goal.evidence = args.evidence; + goal.updatedAt = now; + if (plan.activeGoalId === goal.id) delete plan.activeGoalId; + const newGoal = appendBlockerGoal(plan, args, now); + plan.updatedAt = now; + + const goalSnapshot = snapshot?.raw; + const blockedEntry: UltragoalLedgerEntry = { at: now, kind: "goal_review_blocked", goalId: goal.id, status: goal.status, evidence: args.evidence, ...(goalSnapshot === undefined ? {} : { goalSnapshot }) }; + const addedEntry: UltragoalLedgerEntry = { at: now, kind: "goal_added", goalId: newGoal.id, status: newGoal.status, evidence: args.evidence, message: newGoal.title }; + const summaryEntry: UltragoalLedgerEntry = { at: now, kind: "goal_review_blocked", goalId: goal.id, status: goal.status, evidence: args.evidence, ...(goalSnapshot === undefined ? {} : { goalSnapshot }), message: `Review blockers recorded; appended ${newGoal.id}.` }; + Reflect.set(summaryEntry, "kind", "blocker_recorded"); + const ledgerEntries = [blockedEntry, addedEntry, summaryEntry]; + await writePlan(scope, plan); + for (const entry of ledgerEntries) await appendLedger(scope, entry); + return { plan, blockedGoal: goal, newGoal, ledgerEntries }; + }); +} diff --git a/packages/omo-claude/plugin/components/ultragoal/src/session-scope.ts b/packages/omo-claude/plugin/components/ultragoal/src/session-scope.ts new file mode 100644 index 000000000..e4a21ba2c --- /dev/null +++ b/packages/omo-claude/plugin/components/ultragoal/src/session-scope.ts @@ -0,0 +1,65 @@ +/** + * Per-session scope resolution for ultragoal goal content. + * + * Goal content always lives under `./.omo/ultragoal/` and is tracked per + * Claude Code session keyed by `session_id` with the `claude:` prefix. The + * `UltragoalScope` struct carries everything callers need to resolve a plan; + * it is threaded everywhere instead of a bare session string (D2) so future + * fields can be added without another N-file refactor. + */ + +/** + * Recognized session-id prefixes. `claude` is the native Claude Code platform + * prefix; `codex`/`opencode` are accepted so a session id that already carries + * a sibling-platform prefix is left untouched (mirrors + * `packages/boulder-state/src/storage/shared.ts` `normalizeSessionId`). + */ +export const PREFIX_RE = /^(claude|codex|opencode):/; + +export const CLAUDE_SESSION_PREFIX = "claude:"; + +export interface UltragoalScope { + /** Absolute repo root that anchors `./.omo/ultragoal/`. */ + readonly repoRoot: string; + /** Prefixed session key, e.g. `claude:abc-123`. */ + readonly sessionId: string; + /** Filesystem-safe directory segment derived from `sessionId`. */ + readonly sessionScope: string; +} + +/** + * Normalize a raw Claude Code `session_id` into a prefixed session key. If the + * value already carries a recognized platform prefix it is returned verbatim; + * otherwise the `claude:` prefix is applied. + */ +export function normalizeClaudeSessionId(sessionId: string): string { + const trimmed = sessionId.trim(); + if (trimmed.length === 0) { + throw new Error("session_id must be a non-empty string"); + } + if (PREFIX_RE.test(trimmed)) return trimmed; + return `${CLAUDE_SESSION_PREFIX}${trimmed}`; +} + +/** + * Derive the filesystem directory segment for a (possibly prefixed) session id. + * The colon separating prefix and id is replaced with a dash and any remaining + * path-hostile characters are sanitized so the segment is always a single safe + * directory name (e.g. `claude:abc/def` -> `claude-abc-def`). + */ +export function sessionScopeDir(sessionId: string): string { + const normalized = normalizeClaudeSessionId(sessionId); + const sanitized = normalized.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, ""); + if (sanitized.length === 0) { + throw new Error(`session_id "${sessionId}" sanitizes to an empty scope directory`); + } + return sanitized; +} + +/** + * Build an `UltragoalScope` from a repo root and a raw or prefixed session id. + */ +export function makeUltragoalScope(repoRoot: string, sessionId: string): UltragoalScope { + const normalized = normalizeClaudeSessionId(sessionId); + return { repoRoot, sessionId: normalized, sessionScope: sessionScopeDir(normalized) }; +} diff --git a/packages/omo-claude/plugin/components/ultragoal/src/steering.ts b/packages/omo-claude/plugin/components/ultragoal/src/steering.ts new file mode 100644 index 000000000..8b413e29e --- /dev/null +++ b/packages/omo-claude/plugin/components/ultragoal/src/steering.ts @@ -0,0 +1,271 @@ +// biome-ignore-all format: compact steering module must stay below the 240 pure-LOC budget +import { isUltragoalDone } from "./goal-status.js"; +import { appendLedger, readSteeringLedgerEntries, readUltragoalPlan, withUltragoalMutationLock, writePlan } from "./plan-io.js"; +import type { UltragoalScope } from "./session-scope.js"; +import type { + SteerUltragoalResult, + UltragoalItem, + UltragoalLedgerEntry, + UltragoalPlan, + UltragoalSteeringAudit, + UltragoalSteeringChildGoal, + UltragoalSteeringMutationKind, + UltragoalSteeringProposal, + UltragoalSteeringSource, + UltragoalSuccessCriterionUserModel, +} from "./types.js"; +import { iso, ULTRAGOAL_STEERING_MUTATION_KINDS, ULTRAGOAL_SUCCESS_CRITERION_USER_MODELS } from "./types.js"; + +const SOURCES = ["user_prompt_submit", "finding", "cli"] as const satisfies readonly UltragoalSteeringSource[]; +// NOTE: the plan's aggregate objective field was renamed `codexObjective` -> `objective`. +// We intentionally do NOT list bare `objective`/`objectiveAliases` here because steering +// proposals legitimately carry an `objective` field (add_subgoal/split_subgoal children). +// The aggregate objective remains protected via `aggregateCompletion`, the `status`/ +// `complete*` guards, and the `weakens()` invariant. +const PROTECTED = new Set(["aggregateCompletion", "originalConstraints", "qualityGate", "status", "completedAt", "completionStatus"]); +const isObject = (value: unknown): value is object => typeof value === "object" && value !== null; const isPlain = (value: unknown): value is object => isObject(value) && !Array.isArray(value); +const read = (value: object, key: string): unknown => Object.entries(value).find(([name]) => name === key)?.[1]; +const isText = (value: unknown): value is string => typeof value === "string" && value.trim().length > 0; +const text = (value: object, key: string): string | undefined => { + const candidate = read(value, key); + return isText(candidate) ? candidate.trim() : undefined; +}; +const isKind = (value: unknown): value is UltragoalSteeringMutationKind => typeof value === "string" && ULTRAGOAL_STEERING_MUTATION_KINDS.some((kind) => kind === value); +const isSource = (value: unknown): value is UltragoalSteeringSource => typeof value === "string" && SOURCES.some((source) => source === value); +const isModel = (value: unknown): value is UltragoalSuccessCriterionUserModel => typeof value === "string" && ULTRAGOAL_SUCCESS_CRITERION_USER_MODELS.some((model) => model === value); +const texts = (value: object, key: string): string[] => { + const candidate = read(value, key); + return Array.isArray(candidate) && candidate.every((item) => typeof item === "string") ? candidate : []; +}; + +function targets(proposal: object): string[] { + const many = texts(proposal, "targetGoalIds"); + const one = text(proposal, "targetGoalId") ?? text(proposal, "goalId"); + return many.length > 0 ? many : one === undefined ? [] : [one]; +} + +const after = (proposal: object): object | undefined => { + const candidate = read(proposal, "after"); + return isPlain(candidate) ? candidate : undefined; +}; +const revised = (proposal: object, direct: string, nested: string): string | undefined => text(proposal, direct) ?? text(after(proposal) ?? proposal, nested); + +function child(value: unknown): UltragoalSteeringChildGoal | null { + if (!isPlain(value)) return null; + const title = text(value, "title"); + const objective = text(value, "objective"); + if (title === undefined || objective === undefined) return null; + return { title, objective }; +} + +function childValues(proposal: object): unknown[] { + const direct = read(proposal, "childGoals"); + if (Array.isArray(direct) && direct.length > 0) return direct; + const nested = after(proposal); + const fromAfter = nested === undefined ? undefined : read(nested, "children"); + return Array.isArray(fromAfter) ? fromAfter : []; +} + +const children = (proposal: object): UltragoalSteeringChildGoal[] => childValues(proposal).map(child).filter((item): item is UltragoalSteeringChildGoal => item !== null); +const pendingOrder = (proposal: object): string[] => { + const direct = texts(proposal, "pendingOrder"); + return direct.length > 0 ? direct : texts(after(proposal) ?? proposal, "pendingGoalIds"); +}; + +function hasProtected(value: unknown): boolean { + if (!isObject(value)) return false; + for (const [key, childValue] of Object.entries(value)) if (PROTECTED.has(key) || key.toLowerCase().includes("complete") || hasProtected(childValue)) return true; + return false; +} + +function allText(value: unknown): string { + if (typeof value === "string") return value; + return isObject(value) ? Object.values(value).map(allText).filter(Boolean).join("\n") : ""; +} + +function weakens(value: unknown): boolean { + const valueText = allText(value).toLowerCase(); + return /\b(skip|bypass|weaken|remove|omit|auto[-\s]?complete|mark complete|complete faster)\b/.test(valueText) && /\b(test|tests|verification|review|quality gate|complete|completion)\b/.test(valueText); +} + +function auditFor(proposal: unknown, reasons: string[]): UltragoalSteeringAudit { + const object = isPlain(proposal) ? proposal : undefined; + const kindRaw = object === undefined ? undefined : read(object, "kind"); + const sourceRaw = object === undefined ? undefined : read(object, "source"); + const evidence = object === undefined ? "" : (text(object, "evidence") ?? ""); + const rationale = object === undefined ? "" : (text(object, "rationale") ?? ""); + const audit: UltragoalSteeringAudit = { kind: isKind(kindRaw) ? kindRaw : "annotate_ledger", source: isSource(sourceRaw) ? sourceRaw : "cli", targetGoalIds: object === undefined ? [] : targets(object), evidence, rationale, invariant: { accepted: reasons.length === 0, structuralInvariantAccepted: reasons.length === 0, evidenceBackedNecessity: evidence.length > 0 && rationale.length > 0, noEasierCompletion: !weakens(proposal), rejectedReasons: reasons, reasons } }; + if (object === undefined) return audit; + const criterionId = text(object, "criterionId"); + const directiveText = text(object, "directiveText"); + const promptSignature = text(object, "promptSignature"); + const idempotencyKey = text(object, "idempotencyKey"); + if (criterionId !== undefined) audit.criterionId = criterionId; + if (directiveText !== undefined) audit.directiveText = directiveText; + if (promptSignature !== undefined) audit.promptSignature = promptSignature; + if (idempotencyKey !== undefined) audit.idempotencyKey = idempotencyKey; + return audit; +} + +export function validateUltragoalSteeringProposal(plan: UltragoalPlan, proposal: unknown): UltragoalSteeringAudit { + const reasons: string[] = []; + if (!isPlain(proposal)) reasons.push("proposal must be an object"); + const object = isPlain(proposal) ? proposal : {}; + const kind = read(object, "kind"); + if (!isKind(kind)) reasons.push(`invalid kind: ${String(kind)}`); + if (!isSource(read(object, "source"))) reasons.push(`invalid source: ${String(read(object, "source"))}`); + if (text(object, "evidence") === undefined) reasons.push("missing evidence"); + if (text(object, "rationale") === undefined) reasons.push("missing rationale"); + if (hasProtected(proposal)) reasons.push("protected payload"); + if (weakens(proposal)) reasons.push("weakened completion"); + if (isUltragoalDone(plan)) reasons.push("plan already complete"); + if (isKind(kind)) validateKind(plan, object, kind, reasons); + return auditFor(proposal, reasons); +} + +function goal(plan: UltragoalPlan, id: string | undefined): UltragoalItem | undefined { + return id === undefined ? undefined : plan.goals.find((item) => item.id === id); +} + +function validateKind(plan: UltragoalPlan, proposal: object, kind: UltragoalSteeringMutationKind, reasons: string[]): void { + const target = goal(plan, targets(proposal)[0]); + if (kind === "add_subgoal" && (text(proposal, "title") === undefined || text(proposal, "objective") === undefined)) reasons.push("add_subgoal requires title/objective"); + if ((kind === "split_subgoal" || kind === "revise_pending_wording" || kind === "mark_blocked_superseded") && target === undefined) reasons.push(`${kind} requires target`); + if ((kind === "split_subgoal" || kind === "revise_pending_wording") && target !== undefined && target.status !== "pending") reasons.push(`${kind} requires pending target`); + const rawChildren = childValues(proposal); + if (kind === "split_subgoal" && rawChildren.length === 0) reasons.push("split_subgoal requires children"); + if ((kind === "split_subgoal" || kind === "mark_blocked_superseded") && rawChildren.some((item) => child(item) === null)) reasons.push(`${kind} children require title/objective`); + if (kind === "reorder_pending") validateOrder(plan, proposal, reasons); + if (kind === "revise_pending_wording" && revised(proposal, "revisedTitle", "title") === undefined && revised(proposal, "revisedObjective", "objective") === undefined) reasons.push("revise_pending_wording requires update"); + if (kind === "revise_criterion") validateCriterion(plan, proposal, reasons); +} + +function validateOrder(plan: UltragoalPlan, proposal: object, reasons: string[]): void { + const requested = pendingOrder(proposal); + const pending = plan.goals.filter((item) => item.status === "pending" && item.steeringStatus === undefined).map((item) => item.id); + if (requested.length === 0) reasons.push("reorder_pending requires ids"); + if (new Set(requested).size !== requested.length) reasons.push("duplicate pending id"); + if (requested.some((id) => !pending.includes(id))) reasons.push("unknown pending id"); +} + +function validateCriterion(plan: UltragoalPlan, proposal: object, reasons: string[]): void { + const target = goal(plan, targets(proposal)[0]); + const criterionId = text(proposal, "criterionId"); + if (target === undefined) reasons.push("revise_criterion requires goalId"); + else if (criterionId === undefined || target.successCriteria.every((item) => item.id !== criterionId)) reasons.push("revise_criterion requires criterionId"); + const model = read(proposal, "userModel"); + if (read(proposal, "scenario") === undefined && read(proposal, "expectedEvidence") === undefined && model === undefined) reasons.push("revise_criterion requires update"); + if (model !== undefined && !isModel(model)) reasons.push("invalid userModel"); +} + +function nextId(plan: UltragoalPlan, offset: number): string { + const max = plan.goals.reduce((current, item) => { + const digits = /^G(\d+)$/u.exec(item.id)?.[1]; + return digits === undefined ? current : Math.max(current, Number(digits)); + }, 0); + return `G${String(max + offset).padStart(3, "0")}`; +} + +function makeGoal(plan: UltragoalPlan, childGoal: UltragoalSteeringChildGoal, evidence: string, now: string, offset: number): UltragoalItem { + return { id: nextId(plan, offset), title: childGoal.title, objective: childGoal.objective, status: "pending", successCriteria: [], attempt: 0, createdAt: now, updatedAt: now, evidence }; +} + +export function applySteeringMutation(plan: UltragoalPlan, proposal: UltragoalSteeringProposal, audit: UltragoalSteeringAudit): UltragoalPlan { + const next = structuredClone(plan); + if (!audit.invariant.accepted) return next; + const now = proposal.now?.toISOString() ?? iso(); + if (proposal.kind === "add_subgoal") next.goals.push(makeGoal(next, { title: proposal.title ?? "", objective: proposal.objective ?? "" }, proposal.evidence, now, 1)); + if (proposal.kind === "reorder_pending") { + const order = pendingOrder(proposal); + next.goals = [...order.map((id) => goal(next, id)).filter((item): item is UltragoalItem => item !== undefined), ...next.goals.filter((item) => !order.includes(item.id))]; + } + if (proposal.kind === "revise_pending_wording") reviseWording(next, proposal, now); + if (proposal.kind === "split_subgoal" || proposal.kind === "mark_blocked_superseded") splitOrBlock(next, proposal, now); + if (proposal.kind === "revise_criterion") reviseCriterion(next, proposal, now); + if (proposal.kind !== "annotate_ledger") next.updatedAt = now; + return next; +} + +function reviseWording(plan: UltragoalPlan, proposal: UltragoalSteeringProposal, now: string): void { + const target = goal(plan, targets(proposal)[0]); + if (target === undefined) return; + target.title = revised(proposal, "revisedTitle", "title") ?? target.title; + target.objective = revised(proposal, "revisedObjective", "objective") ?? target.objective; + target.steeringEvidence = proposal.evidence; + target.steeringRationale = proposal.rationale; + target.updatedAt = now; +} + +function splitOrBlock(plan: UltragoalPlan, proposal: UltragoalSteeringProposal, now: string): void { + const target = goal(plan, targets(proposal)[0]); + if (target === undefined) return; + const replacements = children(proposal).map((item, index) => makeGoal(plan, item, proposal.evidence, now, index + 1)); + target.steeringEvidence = proposal.evidence; + target.steeringRationale = proposal.rationale; + target.updatedAt = now; + if (replacements.length === 0) { + target.status = "blocked"; + target.steeringStatus = "blocked"; + target.blockedReason = proposal.blockedReason ?? proposal.rationale; + } else { + target.steeringStatus = "superseded"; + target.supersededBy = replacements.map((item) => item.id); + for (const item of replacements) item.supersedes = [target.id]; + plan.goals.splice(plan.goals.indexOf(target) + 1, 0, ...replacements); + } + if (plan.activeGoalId === target.id) delete plan.activeGoalId; +} + +function reviseCriterion(plan: UltragoalPlan, proposal: UltragoalSteeringProposal, now: string): void { + const target = goal(plan, targets(proposal)[0]); + const index = target?.successCriteria.findIndex((item) => item.id === proposal.criterionId) ?? -1; + const current = target?.successCriteria[index]; + if (target === undefined || current === undefined) return; + const model = read(proposal, "userModel"); + target.successCriteria[index] = { ...current, scenario: text(proposal, "scenario") ?? current.scenario, expectedEvidence: text(proposal, "expectedEvidence") ?? current.expectedEvidence, userModel: isModel(model) ? model : current.userModel }; + target.updatedAt = now; +} + +function isProposal(value: unknown): value is UltragoalSteeringProposal { + return isPlain(value) && isKind(read(value, "kind")) && isSource(read(value, "source")) && isText(read(value, "evidence")) && isText(read(value, "rationale")); +} + +export function parseUltragoalSteeringDirective(text: string): UltragoalSteeringProposal | null { + const match = /(?:^|\s)(?:OMO_ULTRAGOAL_STEER|omo\.ultragoal\.steer|omo ultragoal steer):\s*([\s\S]+)$/u.exec(text); + if (match?.[1] === undefined) return null; + try { + const parsed: unknown = JSON.parse(match[1].trim()); + return isProposal(parsed) ? parsed : null; + } catch (error) { + if (error instanceof SyntaxError) return null; + throw error; + } +} + +export async function steerUltragoal(scope: UltragoalScope, proposal: UltragoalSteeringProposal): Promise { + return withUltragoalMutationLock(scope, async () => { + const plan = await readUltragoalPlan(scope); + const key = proposal.idempotencyKey ?? proposal.promptSignature; + const prior = key === undefined ? undefined : (await readSteeringLedgerEntries(scope)).find((entry) => entry.steering?.invariant.accepted === true && (entry.idempotencyKey === key || entry.steering.idempotencyKey === key || entry.steering.promptSignature === key)); + if (prior?.steering !== undefined) return { plan, accepted: true, audit: { ...prior.steering, deduped: true }, rejectedReasons: [], deduped: true }; + const audit = validateUltragoalSteeringProposal(plan, proposal); + const accepted = audit.invariant.accepted; + const next = accepted ? applySteeringMutation(plan, proposal, audit) : plan; + const finalAudit: UltragoalSteeringAudit = { ...audit, before: plan }; + if (accepted) finalAudit.after = next; + if (accepted) await writePlan(scope, next); + await appendLedger(scope, ledgerEntry(proposal, finalAudit, proposal.now?.toISOString() ?? iso())); + return { plan: next, accepted, audit: finalAudit, rejectedReasons: audit.invariant.rejectedReasons, deduped: false }; + }); +} + +function ledgerEntry(proposal: UltragoalSteeringProposal, audit: UltragoalSteeringAudit, at: string): UltragoalLedgerEntry { + const entry: UltragoalLedgerEntry = { at, kind: audit.invariant.accepted ? (proposal.kind === "revise_criterion" ? "criteria_revised" : "steering_accepted") : "steering_rejected", evidence: proposal.evidence, message: proposal.rationale, steering: audit, mutationKind: proposal.kind }; + const goalId = audit.targetGoalIds[0]; + if (goalId !== undefined) entry.goalId = goalId; + if (proposal.criterionId !== undefined) entry.criterionId = proposal.criterionId; + if (proposal.idempotencyKey !== undefined) entry.idempotencyKey = proposal.idempotencyKey; + if (audit.before !== undefined) entry.before = audit.before; + if (audit.after !== undefined) entry.after = audit.after; + return entry; +} diff --git a/packages/omo-claude/plugin/components/ultragoal/src/types.ts b/packages/omo-claude/plugin/components/ultragoal/src/types.ts new file mode 100644 index 000000000..8f0491844 --- /dev/null +++ b/packages/omo-claude/plugin/components/ultragoal/src/types.ts @@ -0,0 +1,327 @@ +export const ULTRAGOAL_DIR = ".omo/ultragoal"; +export const ULTRAGOAL_SESSIONS = "sessions"; +export const ULTRAGOAL_INDEX = "index.json"; +export const ULTRAGOAL_BRIEF = "brief.md"; +export const ULTRAGOAL_GOALS = "goals.json"; +export const ULTRAGOAL_LEDGER = "ledger.jsonl"; + +export const ULTRAGOAL_PLATFORM = "claude" as const; +export type UltragoalPlatform = typeof ULTRAGOAL_PLATFORM; + +export type UltragoalStatus = + | "pending" + | "in_progress" + | "complete" + | "failed" + | "blocked" + | "review_blocked" + | "needs_user_decision"; + +export type UltragoalGoalMode = "aggregate" | "per_story"; +/** @deprecated retained as an alias for back-compat; use {@link UltragoalGoalMode}. */ +export type UltragoalCodexGoalMode = UltragoalGoalMode; + +export type UltragoalSteeringStatus = "superseded" | "blocked"; + +export const ULTRAGOAL_STEERING_MUTATION_KINDS = [ + "add_subgoal", + "split_subgoal", + "reorder_pending", + "revise_pending_wording", + "revise_criterion", + "annotate_ledger", + "mark_blocked_superseded", +] as const satisfies readonly string[]; +export type UltragoalSteeringMutationKind = (typeof ULTRAGOAL_STEERING_MUTATION_KINDS)[number]; + +export type UltragoalSteeringSource = "user_prompt_submit" | "finding" | "cli"; + +export const ULTRAGOAL_SUCCESS_CRITERION_USER_MODELS = [ + "happy", + "edge", + "regression", + "adversarial", +] as const satisfies readonly string[]; +export type UltragoalSuccessCriterionUserModel = (typeof ULTRAGOAL_SUCCESS_CRITERION_USER_MODELS)[number]; + +export const ULTRAGOAL_CRITERION_STATUSES = ["pending", "pass", "fail", "blocked"] as const satisfies readonly string[]; +export type UltragoalCriterionStatus = (typeof ULTRAGOAL_CRITERION_STATUSES)[number]; + +export const ULTRAGOAL_LEDGER_EVENT_KINDS = [ + "plan_created", + "goal_started", + "goal_resumed", + "goal_completed", + "goal_blocked", + "goal_failed", + "goal_needs_user_decision", + "goal_retried", + "aggregate_completed", + "aggregate_objective_migrated", + "plan_migrated_to_session", + "goal_added", + "steering_accepted", + "steering_rejected", + "final_review_failed", + "goal_review_blocked", + "evidence_captured", + "criterion_failed", + "criterion_blocked", + "criteria_revised", +] as const satisfies readonly string[]; +export type UltragoalLedgerEventKind = (typeof ULTRAGOAL_LEDGER_EVENT_KINDS)[number]; + +export interface UltragoalSuccessCriterion { + readonly id: string; + readonly scenario: string; + readonly userModel: UltragoalSuccessCriterionUserModel; + readonly expectedEvidence: string; + capturedEvidence: string | null; + status: UltragoalCriterionStatus; + capturedAt?: string; + notes?: string; +} + +export interface UltragoalSteeringInvariantResult { + accepted: boolean; + structuralInvariantAccepted: boolean; + evidenceBackedNecessity: boolean; + noEasierCompletion: boolean; + rejectedReasons: string[]; + reasons?: string[]; +} + +export interface UltragoalSteeringChildGoal { + title: string; + objective: string; +} + +export interface UltragoalSteeringAfterPayload { + title?: string; + objective?: string; + pendingGoalIds?: string[]; + children?: UltragoalSteeringChildGoal[]; +} + +export interface UltragoalSteeringProposal { + kind: UltragoalSteeringMutationKind; + source: UltragoalSteeringSource; + targetGoalId?: string; + targetGoalIds?: string[]; + criterionId?: string; + evidence: string; + rationale: string; + title?: string; + objective?: string; + childGoals?: UltragoalSteeringChildGoal[]; + revisedTitle?: string; + revisedObjective?: string; + pendingOrder?: string[]; + blockedReason?: string; + after?: UltragoalSteeringAfterPayload; + directiveText?: string; + promptSignature?: string; + idempotencyKey?: string; + now?: Date; +} + +export interface UltragoalSteeringAudit { + kind: UltragoalSteeringMutationKind; + source: UltragoalSteeringSource; + targetGoalIds: string[]; + criterionId?: string; + before?: unknown; + after?: unknown; + evidence: string; + rationale: string; + invariant: UltragoalSteeringInvariantResult; + directiveText?: string; + promptSignature?: string; + idempotencyKey?: string; + deduped?: boolean; +} + +export interface SteerUltragoalResult { + plan: UltragoalPlan; + accepted: boolean; + audit: UltragoalSteeringAudit; + rejectedReasons: string[]; + deduped: boolean; +} + +export interface UltragoalItem { + id: string; + title: string; + objective: string; + status: UltragoalStatus; + successCriteria: UltragoalSuccessCriterion[]; + attempt: number; + createdAt: string; + updatedAt: string; + startedAt?: string; + completedAt?: string; + failedAt?: string; + reviewBlockedAt?: string; + evidence?: string; + failureReason?: string; + steeringStatus?: UltragoalSteeringStatus; + supersededBy?: string[]; + supersedes?: string[]; + blockedReason?: string; + blockerSignature?: string; + blockerOccurrenceCount?: number; + requiredExternalDecision?: string; + nonRetriable?: boolean; + steeringEvidence?: string; + steeringRationale?: string; +} + +export interface UltragoalAggregateCompletion { + status: "complete"; + completedAt: string; + evidence: string; + goalSnapshot?: unknown; +} + +/** Current durable plan schema version. */ +export const ULTRAGOAL_PLAN_VERSION = 2 as const; + +export interface UltragoalPlan { + version: 2; + platform: UltragoalPlatform; + sessionId: string; + sessionScope: string; + createdAt: string; + updatedAt: string; + briefPath: string; + goalsPath: string; + ledgerPath: string; + goalMode?: UltragoalGoalMode; + objective?: string; + objectiveAliases?: string[]; + aggregateCompletion?: UltragoalAggregateCompletion; + activeGoalId?: string; + goals: UltragoalItem[]; +} + +/** A single registered session inside the per-repo {@link UltragoalIndex}. */ +export interface UltragoalIndexEntry { + sessionId: string; + sessionScope: string; + platform: UltragoalPlatform; + createdAt: string; + updatedAt: string; + goalsPath: string; +} + +/** Per-repo registry of every session that owns ultragoal content. */ +export interface UltragoalIndex { + version: 2; + sessions: UltragoalIndexEntry[]; +} + +/** + * Legacy repo-level v1 plan, read-only for forward migration (D3). The v1 file + * is never written or deleted; it is read once and migrated into a session + * scope. + */ +export interface UltragoalLegacyPlanV1 { + version: 1; + createdAt: string; + updatedAt: string; + briefPath: string; + goalsPath: string; + ledgerPath: string; + codexGoalMode?: UltragoalGoalMode; + codexObjective?: string; + codexObjectiveAliases?: string[]; + aggregateCompletion?: { status: "complete"; completedAt: string; evidence: string; codexGoal?: unknown }; + activeGoalId?: string; + goals: UltragoalItem[]; +} + +export interface UltragoalLedgerEntry { + at: string; + kind: UltragoalLedgerEventKind; + goalId?: string; + criterionId?: string; + status?: UltragoalStatus; + criterionStatus?: UltragoalCriterionStatus; + message?: string; + goalSnapshot?: unknown; + evidence?: string; + capturedEvidence?: string; + qualityGate?: UltragoalQualityGate; + steering?: UltragoalSteeringAudit; + before?: unknown; + after?: unknown; + mutationKind?: UltragoalSteeringMutationKind; + idempotencyKey?: string; + blockerSignature?: string; + blockerOccurrenceCount?: number; + requiredExternalDecision?: string; +} + +export interface CreateUltragoalOptions { + brief: string; + goals?: Array<{ title?: string; objective: string }>; + goalMode?: UltragoalGoalMode; + now?: Date; + force?: boolean; +} + +export interface StartNextOptions { + now?: Date; + retryFailed?: boolean; +} + +export interface CheckpointOptions { + goalId: string; + status: Extract | "blocked"; + evidence?: string; + goalSnapshot?: unknown; + qualityGate?: unknown; + allowActiveFinalGoalSnapshot?: boolean; + now?: Date; +} + +export interface AddUltragoalGoalOptions { + title: string; + objective: string; + evidence?: string; + now?: Date; +} + +export interface RecordFinalReviewBlockersOptions extends AddUltragoalGoalOptions { + goalId: string; + goalSnapshot?: unknown; +} + +export interface UltragoalQualityGate { + aiSlopCleaner: { status: "passed"; evidence: string }; + verification: { status: "passed"; commands: string[]; evidence: string }; + codeReview: { recommendation: "APPROVE"; architectStatus: "CLEAR"; evidence: string }; +} + +export interface UltragoalErrorOptions { + readonly cause?: unknown; + readonly details?: Record; +} + +export class UltragoalError extends Error { + readonly code: string; + readonly details?: Record; + + constructor(message: string, code: string, opts?: UltragoalErrorOptions) { + super(message, opts?.cause === undefined ? undefined : { cause: opts.cause }); + this.name = "UltragoalError"; + this.code = code; + if (opts?.details !== undefined) { + this.details = opts.details; + } + } +} + +export function iso(): string { + return new Date().toISOString(); +} diff --git a/packages/omo-claude/plugin/components/ultragoal/test/claude-hook.test.ts b/packages/omo-claude/plugin/components/ultragoal/test/claude-hook.test.ts new file mode 100644 index 000000000..1c7472483 --- /dev/null +++ b/packages/omo-claude/plugin/components/ultragoal/test/claude-hook.test.ts @@ -0,0 +1,109 @@ +import { mkdtemp, readFile, readdir } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Readable, Writable } from "node:stream"; +import { describe, expect, it } from "bun:test"; + +import { + applyPreToolUseGoalBudgetGuard, + type PreToolUsePayload, + parsePreToolUsePayload, + runUltragoalHookCli, + type UserPromptSubmitPayload, +} from "../src/claude-hook.js"; +import { makeUltragoalScope } from "../src/session-scope.js"; +import { createUltragoalPlan } from "../src/plan-crud.js"; + +function captureStdout(): { readonly stdout: Writable; readonly read: () => string } { + let captured = ""; + const stdout = new Writable({ + write(chunk: unknown, _encoding: BufferEncoding, callback: (error?: Error | null) => void): void { + captured += chunk instanceof Buffer ? chunk.toString() : String(chunk); + callback(); + }, + }); + return { stdout, read: () => captured }; +} + +function upsPayload(prompt: string, cwd: string, sessionId: string): UserPromptSubmitPayload { + return { cwd, hook_event_name: "UserPromptSubmit", prompt, session_id: sessionId }; +} + +const STEER = + 'OMO_ULTRAGOAL_STEER: {"kind":"annotate_ledger","source":"user_prompt_submit","evidence":"x","rationale":"y"}'; + +describe("UserPromptSubmit hook derives scope from session_id", () => { + it("two UserPromptSubmit payloads with distinct session_ids create two sessions/claude-* dirs", async () => { + const repoRoot = await mkdtemp(join(tmpdir(), "ug-hookscope-")); + // Seed plans for two distinct sessions so steering has something to mutate. + await createUltragoalPlan(makeUltragoalScope(repoRoot, "hook-a"), { + brief: "- objective alpha for hook test\n", + }); + await createUltragoalPlan(makeUltragoalScope(repoRoot, "hook-b"), { + brief: "- objective beta for hook test\n", + }); + + for (const sid of ["hook-a", "hook-b"]) { + const stdin = Readable.from([JSON.stringify(upsPayload(STEER, repoRoot, sid))]); + const cap = captureStdout(); + await runUltragoalHookCli(stdin, cap.stdout); + } + + const sessionsRoot = join(repoRoot, ".omo", "ultragoal", "sessions"); + expect(existsSync(sessionsRoot)).toBe(true); + const dirs = (await readdir(sessionsRoot)).filter((d) => d.startsWith("claude-")); + expect(dirs.length).toBe(2); + }); + + it("steering is a no-op (returns empty) when no plan exists for the session", async () => { + const repoRoot = await mkdtemp(join(tmpdir(), "ug-hooknoplan-")); + const stdin = Readable.from([JSON.stringify(upsPayload(STEER, repoRoot, "no-plan-session"))]); + const cap = captureStdout(); + await runUltragoalHookCli(stdin, cap.stdout); + expect(cap.read()).toBe(""); + }); +}); + +function preToolPayload(toolName: string, toolInput: unknown): PreToolUsePayload { + return { + cwd: "/repo", + hook_event_name: "PreToolUse", + session_id: "s1", + tool_input: toolInput, + tool_name: toolName, + tool_use_id: "call-1", + transcript_path: null, + }; +} + +describe("inert create_goal guard (D4: code kept, registration removed)", () => { + it("parses a PreToolUse payload WITHOUT model/turn_id (relaxed validator)", () => { + const raw = JSON.stringify(preToolPayload("create_goal", { objective: "Ship", token_budget: 5 })); + const parsed = parsePreToolUsePayload(raw); + expect(parsed).not.toBeNull(); + expect(parsed?.tool_name).toBe("create_goal"); + }); + + it("guard still blocks a budgeted create_goal when invoked directly (code remains unit-testable)", () => { + const out = applyPreToolUseGoalBudgetGuard( + preToolPayload("create_goal", { objective: "Ship", token_budget: 5 }), + ); + const parsed = JSON.parse(out); + expect(parsed.hookSpecificOutput.permissionDecision).toBe("deny"); + }); + + it("guard returns empty for create_goal without a budget", () => { + expect(applyPreToolUseGoalBudgetGuard(preToolPayload("create_goal", { objective: "Ship" }))).toBe(""); + }); + + it("hooks.json does NOT register a PreToolUse create_goal block", async () => { + const hooks = JSON.parse(await readFile(join(import.meta.dir, "..", "hooks", "hooks.json"), "utf8")); + expect(hooks.hooks.PreToolUse).toBeUndefined(); + // Only UserPromptSubmit is registered. + expect(Object.keys(hooks.hooks)).toEqual(["UserPromptSubmit"]); + const raw = await readFile(join(import.meta.dir, "..", "hooks", "hooks.json"), "utf8"); + expect(raw).not.toContain("create_goal"); + expect(raw).not.toContain("pre-tool-use"); + }); +}); diff --git a/packages/omo-claude/plugin/components/ultragoal/test/cli-scope-resolution.test.ts b/packages/omo-claude/plugin/components/ultragoal/test/cli-scope-resolution.test.ts new file mode 100644 index 000000000..1b789f05b --- /dev/null +++ b/packages/omo-claude/plugin/components/ultragoal/test/cli-scope-resolution.test.ts @@ -0,0 +1,60 @@ +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; + +import { resolveUltragoalScope } from "../src/cli-commands.js"; +import { makeUltragoalScope } from "../src/session-scope.js"; +import { createUltragoalPlan } from "../src/plan-crud.js"; +import { UltragoalError } from "../src/types.js"; + +const BRIEF = "- a goal objective for scope resolution\n"; + +let savedEnv: string | undefined; + +beforeEach(() => { + savedEnv = process.env["CLAUDE_SESSION_ID"]; + delete process.env["CLAUDE_SESSION_ID"]; +}); + +afterEach(() => { + if (savedEnv === undefined) delete process.env["CLAUDE_SESSION_ID"]; + else process.env["CLAUDE_SESSION_ID"] = savedEnv; +}); + +describe("resolveUltragoalScope precedence", () => { + it("1. --session-id flag wins", async () => { + const repoRoot = await mkdtemp(join(tmpdir(), "ug-cli-flag-")); + process.env["CLAUDE_SESSION_ID"] = "env-session"; + const scope = await resolveUltragoalScope(repoRoot, ["--session-id", "flag-session"]); + expect(scope.sessionId).toBe("claude:flag-session"); + }); + + it("2. $CLAUDE_SESSION_ID is used when no flag", async () => { + const repoRoot = await mkdtemp(join(tmpdir(), "ug-cli-env-")); + process.env["CLAUDE_SESSION_ID"] = "env-session"; + const scope = await resolveUltragoalScope(repoRoot, []); + expect(scope.sessionId).toBe("claude:env-session"); + }); + + it("3. newest-active session in index.json when no flag/env", async () => { + const repoRoot = await mkdtemp(join(tmpdir(), "ug-cli-idx-")); + await createUltragoalPlan(makeUltragoalScope(repoRoot, "first"), { brief: BRIEF }); + await createUltragoalPlan(makeUltragoalScope(repoRoot, "second"), { brief: BRIEF }); + const scope = await resolveUltragoalScope(repoRoot, []); + // "second" was created last -> newest active. + expect(scope.sessionId).toBe("claude:second"); + }); + + it("4. errors with ULTRAGOAL_SESSION_REQUIRED when nothing resolves", async () => { + const repoRoot = await mkdtemp(join(tmpdir(), "ug-cli-none-")); + let caught: unknown; + try { + await resolveUltragoalScope(repoRoot, []); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(UltragoalError); + expect((caught as UltragoalError).code).toBe("ULTRAGOAL_SESSION_REQUIRED"); + }); +}); diff --git a/packages/omo-claude/plugin/components/ultragoal/test/lifecycle.test.ts b/packages/omo-claude/plugin/components/ultragoal/test/lifecycle.test.ts new file mode 100644 index 000000000..2599d46ae --- /dev/null +++ b/packages/omo-claude/plugin/components/ultragoal/test/lifecycle.test.ts @@ -0,0 +1,87 @@ +import { mkdtemp, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "bun:test"; + +import { makeUltragoalScope } from "../src/session-scope.js"; +import { createUltragoalPlan, startNextUltragoal } from "../src/plan-crud.js"; +import { checkpointUltragoal } from "../src/checkpoint.js"; +import { recordEvidence } from "../src/evidence.js"; +import { steerUltragoal } from "../src/steering.js"; +import { buildGoalInstruction } from "../src/goal-instruction.js"; +import { readUltragoalPlan } from "../src/plan-io.js"; +import { ultragoalLedgerPath } from "../src/paths.js"; + +const BRIEF = "- Implement the alpha feature\n- Implement the beta feature\n"; + +async function tmpRepo(): Promise { + return mkdtemp(join(tmpdir(), "ug-life-")); +} + +describe("scope-threaded lifecycle still works end-to-end", () => { + it("create -> complete-goals handoff -> record-evidence -> checkpoint (per_story)", async () => { + const repoRoot = await tmpRepo(); + const scope = makeUltragoalScope(repoRoot, "life-1"); + const plan = await createUltragoalPlan(scope, { brief: BRIEF, goalMode: "per_story" }); + expect(plan.goals.length).toBe(2); + + const started = await startNextUltragoal(scope, {}); + expect("goal" in started).toBe(true); + if (!("goal" in started)) throw new Error("expected a goal"); + const goal = started.goal; + + // handoff text is file/steering based, no create_goal/get_goal language. + const instruction = buildGoalInstruction({ plan: started.plan, goal }); + expect(instruction.text).not.toContain("create_goal"); + expect(instruction.text).not.toContain("get_goal"); + expect(instruction.text).toContain(started.plan.goalsPath); + + // pass all seeded criteria + for (const c of goal.successCriteria) { + await recordEvidence(scope, { goalId: goal.id, criterionId: c.id, status: "pass", evidence: "verified ok" }); + } + // checkpoint complete WITHOUT a goal snapshot (snapshot is optional now) + const result = await checkpointUltragoal(scope, { + goalId: goal.id, + status: "complete", + evidence: "alpha done; tests pass; review clean", + }); + expect(result.goal.status).toBe("complete"); + + const reread = await readUltragoalPlan(scope); + expect(reread.goals.find((g) => g.id === goal.id)?.status).toBe("complete"); + }); + + it("steering adds a subgoal and is written under the session scope", async () => { + const repoRoot = await tmpRepo(); + const scope = makeUltragoalScope(repoRoot, "life-2"); + await createUltragoalPlan(scope, { brief: BRIEF }); + const result = await steerUltragoal(scope, { + kind: "add_subgoal", + source: "cli", + title: "Gamma feature", + objective: "Implement the gamma feature with care", + evidence: "user asked for gamma", + rationale: "newly discovered requirement", + }); + expect(result.accepted).toBe(true); + const reread = await readUltragoalPlan(scope); + expect(reread.goals.some((g) => g.title === "Gamma feature")).toBe(true); + // ledger lives in the session scope + const ledger = await readFile(ultragoalLedgerPath(scope), "utf8"); + expect(ledger).toContain("steering_accepted"); + }); + + it("steering is a no-op when the plan is missing for the scope", async () => { + const repoRoot = await tmpRepo(); + const scope = makeUltragoalScope(repoRoot, "life-noplan"); + await expect( + steerUltragoal(scope, { + kind: "annotate_ledger", + source: "cli", + evidence: "x", + rationale: "y", + }), + ).rejects.toThrow(); + }); +}); diff --git a/packages/omo-claude/plugin/components/ultragoal/test/session-isolation.test.ts b/packages/omo-claude/plugin/components/ultragoal/test/session-isolation.test.ts new file mode 100644 index 000000000..4e2ca8404 --- /dev/null +++ b/packages/omo-claude/plugin/components/ultragoal/test/session-isolation.test.ts @@ -0,0 +1,108 @@ +import { mkdtemp, readFile, writeFile, mkdir } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "bun:test"; + +import { makeUltragoalScope } from "../src/session-scope.js"; +import { createUltragoalPlan } from "../src/plan-crud.js"; +import { readUltragoalPlan, writePlan, readUltragoalIndex } from "../src/plan-io.js"; +import { ultragoalGoalsPath, ultragoalSessionDir, ultragoalIndexPath, legacyUltragoalGoalsPath } from "../src/paths.js"; + +const BRIEF = "- First goal objective for testing\n- Second goal objective for testing\n"; + +async function tmpRepo(): Promise { + return mkdtemp(join(tmpdir(), "ug-session-")); +} + +describe("per-session isolation", () => { + it("two distinct session ids produce two isolated plans under sessions/claude-*/", async () => { + const repoRoot = await tmpRepo(); + const scopeA = makeUltragoalScope(repoRoot, "session-aaa"); + const scopeB = makeUltragoalScope(repoRoot, "session-bbb"); + + await createUltragoalPlan(scopeA, { brief: BRIEF }); + await createUltragoalPlan(scopeB, { brief: BRIEF }); + + expect(existsSync(ultragoalGoalsPath(scopeA))).toBe(true); + expect(existsSync(ultragoalGoalsPath(scopeB))).toBe(true); + expect(ultragoalSessionDir(scopeA)).toContain("claude-session-aaa"); + expect(ultragoalSessionDir(scopeB)).toContain("claude-session-bbb"); + expect(ultragoalGoalsPath(scopeA)).not.toBe(ultragoalGoalsPath(scopeB)); + + // goal content lives under ./.omo/ultragoal/sessions/ + expect(ultragoalSessionDir(scopeA)).toContain(join(".omo", "ultragoal", "sessions")); + }); + + it("registers both sessions in the index.json registry", async () => { + const repoRoot = await tmpRepo(); + const scopeA = makeUltragoalScope(repoRoot, "idx-aaa"); + const scopeB = makeUltragoalScope(repoRoot, "idx-bbb"); + await createUltragoalPlan(scopeA, { brief: BRIEF }); + await createUltragoalPlan(scopeB, { brief: BRIEF }); + + expect(existsSync(ultragoalIndexPath(repoRoot))).toBe(true); + const index = await readUltragoalIndex(repoRoot); + const ids = index.sessions.map((s) => s.sessionId); + expect(ids).toContain("claude:idx-aaa"); + expect(ids).toContain("claude:idx-bbb"); + }); + + it("writes version:2 plans with platform/sessionId/sessionScope", async () => { + const repoRoot = await tmpRepo(); + const scope = makeUltragoalScope(repoRoot, "v2-check"); + const plan = await createUltragoalPlan(scope, { brief: BRIEF }); + expect(plan.version).toBe(2); + expect(plan.platform).toBe("claude"); + expect(plan.sessionId).toBe("claude:v2-check"); + const reread = await readUltragoalPlan(scope); + expect(reread.version).toBe(2); + expect(reread.goals.length).toBe(2); + }); +}); + +describe("v1 -> v2 migration", () => { + it("reads a legacy v1 plan, migrates it forward, and does NOT delete the v1 file", async () => { + const repoRoot = await tmpRepo(); + const scope = makeUltragoalScope(repoRoot, "legacy-1"); + + // Author a legacy v1 plan at the OLD repo-level path. + const legacyPath = legacyUltragoalGoalsPath(repoRoot); + await mkdir(join(repoRoot, ".omo", "ultragoal"), { recursive: true }); + const v1Plan = { + version: 1, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + briefPath: ".omo/ultragoal/brief.md", + goalsPath: ".omo/ultragoal/goals.json", + ledgerPath: ".omo/ultragoal/ledger.jsonl", + codexGoalMode: "aggregate", + goals: [ + { + id: "G001", + title: "Legacy goal", + objective: "Do the legacy thing.", + status: "pending", + successCriteria: [], + attempt: 0, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + ], + }; + await writeFile(legacyPath, `${JSON.stringify(v1Plan, null, 2)}\n`, "utf8"); + + const migrated = await readUltragoalPlan(scope); + expect(migrated.version).toBe(2); + expect(migrated.goals[0]?.id).toBe("G001"); + expect(migrated.goalMode).toBe("aggregate"); + + // The legacy v1 file must still exist (read-only migration). + expect(existsSync(legacyPath)).toBe(true); + const stillV1 = JSON.parse(await readFile(legacyPath, "utf8")); + expect(stillV1.version).toBe(1); + + // And the migrated copy lives in the session scope dir. + expect(existsSync(ultragoalGoalsPath(scope))).toBe(true); + }); +}); diff --git a/packages/omo-claude/plugin/components/ultragoal/test/session-scope.test.ts b/packages/omo-claude/plugin/components/ultragoal/test/session-scope.test.ts new file mode 100644 index 000000000..6e746770b --- /dev/null +++ b/packages/omo-claude/plugin/components/ultragoal/test/session-scope.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "bun:test"; + +import { + CLAUDE_SESSION_PREFIX, + makeUltragoalScope, + normalizeClaudeSessionId, + PREFIX_RE, + sessionScopeDir, +} from "../src/session-scope.js"; + +describe("normalizeClaudeSessionId", () => { + it("applies the claude: prefix to a bare session id", () => { + expect(normalizeClaudeSessionId("abc-123")).toBe("claude:abc-123"); + }); + + it("leaves an already-claude-prefixed id untouched", () => { + expect(normalizeClaudeSessionId("claude:abc-123")).toBe("claude:abc-123"); + }); + + it("leaves a sibling-platform prefix untouched", () => { + expect(normalizeClaudeSessionId("codex:xyz")).toBe("codex:xyz"); + expect(normalizeClaudeSessionId("opencode:xyz")).toBe("opencode:xyz"); + }); + + it("throws on an empty session id", () => { + expect(() => normalizeClaudeSessionId(" ")).toThrow(); + }); +}); + +describe("sessionScopeDir", () => { + it("converts the prefix colon to a dash", () => { + expect(sessionScopeDir("abc-123")).toBe("claude-abc-123"); + }); + + it("sanitizes path-hostile characters", () => { + expect(sessionScopeDir("claude:abc/def")).toBe("claude-abc-def"); + }); + + it("is deterministic for the same id", () => { + expect(sessionScopeDir("s1")).toBe(sessionScopeDir("claude:s1")); + }); +}); + +describe("makeUltragoalScope", () => { + it("builds a struct with repoRoot, prefixed sessionId, and scope dir", () => { + const scope = makeUltragoalScope("/repo", "s1"); + expect(scope.repoRoot).toBe("/repo"); + expect(scope.sessionId).toBe("claude:s1"); + expect(scope.sessionScope).toBe("claude-s1"); + }); +}); + +describe("PREFIX_RE / constants", () => { + it("recognizes the claude prefix", () => { + expect(PREFIX_RE.test("claude:x")).toBe(true); + expect(PREFIX_RE.test("nope:x")).toBe(false); + expect(CLAUDE_SESSION_PREFIX).toBe("claude:"); + }); +}); diff --git a/packages/omo-claude/plugin/components/ultragoal/tsconfig.build.json b/packages/omo-claude/plugin/components/ultragoal/tsconfig.build.json new file mode 100644 index 000000000..5b5bbcafd --- /dev/null +++ b/packages/omo-claude/plugin/components/ultragoal/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/ultragoal/tsconfig.json b/packages/omo-claude/plugin/components/ultragoal/tsconfig.json new file mode 100644 index 000000000..73e5001dd --- /dev/null +++ b/packages/omo-claude/plugin/components/ultragoal/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "lib": ["ES2022"], + "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"] +} diff --git a/packages/omo-claude/plugin/scripts/sync-components.mjs b/packages/omo-claude/plugin/scripts/sync-components.mjs index 54fea1df2..d93d06c90 100644 --- a/packages/omo-claude/plugin/scripts/sync-components.mjs +++ b/packages/omo-claude/plugin/scripts/sync-components.mjs @@ -39,9 +39,12 @@ export const HANDLED_COMPONENTS = [ "comment-checker", "lsp", "ultrawork", - "ultragoal", "start-work-continuation", ]; +// NOTE: `ultragoal` is intentionally NOT sync-managed. It is a deep hand-fork +// (per-session `.omo/ultragoal` refactor, `claude:` session scoping, inert +// create_goal guard) that diverges from the omo-codex source, so it is +// maintained directly in this tree rather than regenerated from a patch manifest. // Per-component copy surface. Directories are copied recursively; files are // copied verbatim. Entries that do not exist in the source are skipped.