feat(omo-claude): port ultragoal to per-session file-based goals
Goal content under ./.omo/ultragoal/sessions/claude-<id>/ 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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-<criterion>`, 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 "<brief>" --json
|
||||
omo ultragoal create-goals --brief-file <path> --json
|
||||
cat <brief> | 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: <action> for <criterion> - verify by <check>`.
|
||||
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-<criterion>`; confirm `tmux ls`), browser / Playwright contexts (`.close()`), containers (`docker rm -f`), bound ports (`lsof -i :<port>` 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 <id> --criterion-id <id> --status pass --evidence "<observable> | <cleanup receipt>" --json`
|
||||
- FAIL: `omo ultragoal record-evidence --goal-id <id> --criterion-id <id> --status fail --evidence "<observable> | <cleanup receipt>" --notes "<diagnosis>" --json`
|
||||
- BLOCKED: `omo ultragoal record-evidence --goal-id <id> --criterion-id <id> --status blocked --evidence "<observable>" --notes "<safety/blocker/leftover-state>" --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 <id> --json`.
|
||||
2. Call `get_goal` for a fresh snapshot.
|
||||
3. Run `omo ultragoal checkpoint --goal-id <id> --status complete --evidence "<criteria evidence summary>" --codex-goal-json <snapshot> --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 <id> --title "<...>" --objective "<...>" --evidence "<review findings>" --codex-goal-json <snapshot> --json`.
|
||||
7. If clean, checkpoint final completion:
|
||||
```sh
|
||||
omo ultragoal checkpoint --goal-id <id> --status complete --evidence "<e2e evidence + manual QA notes>" --codex-goal-json <snapshot> --quality-gate-json <json-or-path> --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 <kind> [<kind-specific-fields>] --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.
|
||||
@@ -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."
|
||||
@@ -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<boolean> {
|
||||
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<boolean> {
|
||||
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 "<completed legacy goal blocks completion in this thread>".`,
|
||||
"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<unknown> {
|
||||
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<CheckpointUltragoalResult> {
|
||||
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 };
|
||||
});
|
||||
}
|
||||
@@ -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<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string, unknown> {
|
||||
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<string> {
|
||||
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));
|
||||
});
|
||||
}
|
||||
@@ -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<string> {
|
||||
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<unknown | undefined> {
|
||||
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<string | undefined> {
|
||||
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;
|
||||
}
|
||||
@@ -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 <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<UltragoalScope> {
|
||||
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<number> {
|
||||
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<UltragoalScope> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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 } });
|
||||
}
|
||||
@@ -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 <id>] [--brief-file <path>] [--from-stdin] [--goal-mode aggregate|per_story] [--force] [--json]
|
||||
omo ultragoal status [--session-id <id>] [--json]
|
||||
omo ultragoal complete-goals [--session-id <id>] [--retry-failed] [--json]
|
||||
omo ultragoal criteria --goal-id <id> [--session-id <id>] [--json]
|
||||
omo ultragoal record-evidence --goal-id <id> --criterion-id <id> --status pass|fail|blocked --evidence "..." [--notes "..."] [--session-id <id>] [--json]
|
||||
omo ultragoal checkpoint --goal-id <id> --status complete|failed|blocked --evidence "..." [--goal-snapshot-json <...>] [--quality-gate-json <...>] [--session-id <id>] [--json]
|
||||
omo ultragoal steer --kind <kind> ... --evidence "..." --rationale "..." [--session-id <id>] [--json]
|
||||
omo ultragoal add-goal --title "..." --objective "..." [--session-id <id>] [--json]
|
||||
omo ultragoal record-review-blockers --goal-id <id> --title "..." --objective "..." --evidence "..." [--goal-snapshot-json <...>] [--session-id <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 } },
|
||||
);
|
||||
}
|
||||
@@ -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<string, unknown>): 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<UltragoalSteeringChildGoal[]> {
|
||||
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<string[]> {
|
||||
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<CliSteeringProposal> {
|
||||
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);
|
||||
}
|
||||
@@ -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 <subcommand> [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<number> {
|
||||
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);
|
||||
});
|
||||
@@ -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<string, unknown>): 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 })) },
|
||||
});
|
||||
}
|
||||
@@ -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 "<blocker-resolution objective>" --evidence "<review findings>"`;
|
||||
const checkpointCommand = `omo ultragoal checkpoint --goal-id ${goal.id} --status complete --evidence "<tests/files/PR evidence>" --quality-gate-json "<quality gate JSON or path>"`;
|
||||
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");
|
||||
}
|
||||
@@ -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<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
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<GoalSnapshot | null> {
|
||||
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(" ");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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/<scope>`. */
|
||||
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("/");
|
||||
}
|
||||
@@ -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<UltragoalPlan> {
|
||||
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") } };
|
||||
}
|
||||
@@ -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<string, Promise<unknown>>();
|
||||
|
||||
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<T>(scope: UltragoalScope, fn: () => Promise<T>): Promise<T> {
|
||||
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<UltragoalPlan> {
|
||||
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<UltragoalPlan> {
|
||||
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<UltragoalPlan | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await mkdir(ultragoalSessionDir(scope), { recursive: true });
|
||||
await appendFile(ultragoalLedgerPath(scope), `${JSON.stringify(entry)}\n`, "utf8");
|
||||
}
|
||||
|
||||
export async function readSteeringLedgerEntries(scope: UltragoalScope): Promise<UltragoalLedgerEntry[]> {
|
||||
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<UltragoalIndex> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 });
|
||||
}
|
||||
@@ -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<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function section(value: unknown, field: string): Record<string, unknown> {
|
||||
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);
|
||||
}
|
||||
@@ -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<RecordFinalReviewBlockersResult> {
|
||||
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 };
|
||||
});
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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<SteerUltragoalResult> {
|
||||
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;
|
||||
}
|
||||
@@ -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<UltragoalStatus, "complete" | "failed"> | "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<string, unknown>;
|
||||
}
|
||||
|
||||
export class UltragoalError extends Error {
|
||||
readonly code: string;
|
||||
readonly details?: Record<string, unknown>;
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<string> {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<string> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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:");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowImportingTsExtensions": false,
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"noEmit": false
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["test/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "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"]
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user