diff --git a/packages/omo-codex/plugin/components/ulw-loop/src/.gitkeep b/packages/omo-codex/plugin/components/ulw-loop/src/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/omo-codex/plugin/components/ulw-loop/src/checkpoint.ts b/packages/omo-codex/plugin/components/ulw-loop/src/checkpoint.ts new file mode 100644 index 000000000..989136297 --- /dev/null +++ b/packages/omo-codex/plugin/components/ulw-loop/src/checkpoint.ts @@ -0,0 +1,155 @@ +// 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 { formatCodexGoalReconciliation, readCodexGoalSnapshotInput, reconcileCodexGoalSnapshot } from "./codex-goal-snapshot.js"; +import { requireAllCriteriaPass } from "./evidence.js"; +import { codexGoalMode, compatibleCodexObjectives, expectedCodexObjective, isFinalRunCompletionCandidate } from "./goal-status.js"; +import { type UlwLoopScope, ulwLoopBriefPath } from "./paths.js"; +import { appendLedger, readUlwLoopPlan, withUlwLoopMutationLock, writePlan } from "./plan-io.js"; +import { classifyExternalAuthorizationBlocker, clearGoalBlockerFields, sameBlockerOccurrences, validateQualityGate } from "./quality-gate.js"; +import type { UlwLoopAggregateCompletion, UlwLoopItem, UlwLoopLedgerEntry, UlwLoopPlan, UlwLoopQualityGate } from "./types.js"; +import { iso, ULW_LOOP_DIR, ULW_LOOP_GOALS, ULW_LOOP_LEDGER, UlwLoopError } from "./types.js"; + +export interface CheckpointUlwLoopArgs { readonly goalId: string; readonly status: "complete" | "failed" | "blocked"; readonly evidence: string; readonly codexGoalJson?: string; readonly qualityGateJson?: string } +export interface CheckpointUlwLoopResult { readonly plan: UlwLoopPlan; readonly goal: UlwLoopItem; readonly ledgerEntry: UlwLoopLedgerEntry; readonly aggregateCompletion?: UlwLoopAggregateCompletion } + +function ulwLoopFail(message: string, code: string): never { throw new UlwLoopError(message, code); } +function normalizeObjective(value: string): string { return value.replace(/\s+/g, " ").trim(); } +function nonEmptyEvidence(value: string): string { const trimmed = value.trim(); return trimmed || ulwLoopFail("Evidence must be a non-empty string.", "ulw_loop_evidence_required"); } +function findGoal(plan: UlwLoopPlan, goalId: string): UlwLoopItem { const goal = plan.goals.find((candidate) => candidate.id === goalId); return goal ?? ulwLoopFail(`Unknown ulw-loop id: ${goalId}.`, "ulw_loop_goal_not_found"); } + +function textMentionsUlwLoopPlanArtifact(value: string | undefined): boolean { + const normalized = (value ?? "").toLowerCase(); + return normalized.includes(ULW_LOOP_DIR.toLowerCase()) || normalized.includes(ULW_LOOP_GOALS.toLowerCase()) || normalized.includes(ULW_LOOP_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 snapshotObjectiveMapsToUlwLoopPlan(repoRoot: string, snapshotObjective: string, scope?: UlwLoopScope): Promise { + const actual = normalizeObjective(snapshotObjective).toLowerCase(); + if (textMentionsUlwLoopPlanArtifact(actual)) return true; + if (actual.length < 24 || !existsSync(ulwLoopBriefPath(repoRoot, scope))) return false; + try { + const brief = normalizeObjective(await readFile(ulwLoopBriefPath(repoRoot, 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(repoRoot: string, plan: UlwLoopPlan, goal: UlwLoopItem, snapshotObjective: string, evidence: string, scope?: UlwLoopScope): Promise { + if (codexGoalMode(plan) !== "aggregate") return false; + if (goal.status !== "in_progress" || plan.activeGoalId !== goal.id) return false; + if (isFinalRunCompletionCandidate(plan, goal)) return snapshotObjectiveMapsToUlwLoopPlan(repoRoot, snapshotObjective, scope); + if (!textMentionsUlwLoopPlanArtifact(evidence) || !textMentionsGoalId(evidence, goal.id)) return false; + if (!textHasCompletionValidationEvidence(evidence)) return false; + return snapshotObjectiveMapsToUlwLoopPlan(repoRoot, snapshotObjective, scope); +} + +function buildCompletedLegacyGoalRemediation(goal: UlwLoopItem): string { + return [ + "If get_goal returns a different completed legacy/thread objective, do not repeat --status complete in this thread.", + `Record a non-terminal blocker with: omo ulw-loop checkpoint --goal-id ${goal.id} --status blocked --evidence "" --codex-goal-json "".`, + "Then continue only from a Codex goal context with no active/completed conflicting goal, in the same repo/worktree, and create the intended goal there.", + ].join(" "); +} + +function buildTaskScopedAggregateReconciliationHint(goal: UlwLoopItem, 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 get_goal objective to map to the ulw-loop 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/ulw-loop/goals.json or ledger.jsonl, includes completed implementation plus validation/review evidence, and a get_goal objective that maps to the ulw-loop brief/artifact. ${buildCompletedLegacyGoalRemediation(goal)}`; +} + +async function readJsonInput(raw: string | undefined, repoRoot: string): Promise { + if (raw === undefined || raw.trim() === "") return undefined; + const trimmed = raw.trim(); + try { return JSON.parse(trimmed); } catch (error) { if (!(error instanceof SyntaxError)) throw error; } + const path = resolve(repoRoot, trimmed); + if (!existsSync(path)) return ulwLoopFail("Quality gate JSON is neither valid JSON nor a readable path.", "ulw_loop_json_input_invalid"); + try { return JSON.parse(await readFile(path, "utf8")); } catch (error) { return ulwLoopFail(`Quality gate path does not contain valid JSON${error instanceof Error ? `: ${error.message}` : "."}`, "ulw_loop_json_input_invalid"); } +} + +function makeAggregateCompletion(now: string, evidence: string, codexGoal: unknown): UlwLoopAggregateCompletion { + return { status: "complete", completedAt: now, evidence, codexGoal }; +} + +function applyBlockedOrFailed(goal: UlwLoopItem, plan: UlwLoopPlan, 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: CheckpointUlwLoopArgs["status"], goal: UlwLoopItem, aggregateCompletion: UlwLoopAggregateCompletion | undefined): UlwLoopLedgerEntry["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: CheckpointUlwLoopArgs, goal: UlwLoopItem, qualityGate: UlwLoopQualityGate | undefined, codexGoal: unknown, aggregateCompletion: UlwLoopAggregateCompletion | undefined): UlwLoopLedgerEntry { + const entry: UlwLoopLedgerEntry = { at: now, kind: ledgerKind(args.status, goal, aggregateCompletion), goalId: goal.id, status: goal.status, evidence: args.evidence }; + if (codexGoal !== undefined) entry.codexGoal = codexGoal; + 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 checkpointUlwLoop(repoRoot: string, args: CheckpointUlwLoopArgs, scope?: UlwLoopScope): Promise { + return withUlwLoopMutationLock(repoRoot, scope, async () => { + const plan = await readUlwLoopPlan(repoRoot, scope); + const goal = findGoal(plan, args.goalId); + if (args.status === "complete") requireAllCriteriaPass(goal); + const evidence = nonEmptyEvidence(args.evidence); + const now = iso(); + let aggregateCompletion: UlwLoopAggregateCompletion | undefined; + let qualityGate: UlwLoopQualityGate | undefined; + let codexGoal: unknown; + if (args.status === "complete") { + const aggregate = codexGoalMode(plan) === "aggregate"; + const final = isFinalRunCompletionCandidate(plan, goal); + const snapshot = await readCodexGoalSnapshotInput(args.codexGoalJson, repoRoot); + const reconciliation = reconcileCodexGoalSnapshot(snapshot, { expectedObjective: expectedCodexObjective(plan, goal), ...(aggregate ? { acceptedObjectives: compatibleCodexObjectives(plan) } : {}), allowedStatuses: aggregate ? (final ? ["complete"] : ["active"]) : ["complete"], requireSnapshot: true, requireComplete: !aggregate || final }); + codexGoal = reconciliation.snapshot.raw; + if (!reconciliation.ok) { + const objective = snapshot?.objective; + const taskScoped = snapshot?.available === true && snapshot.status === "complete" && objective !== undefined && normalizeObjective(objective) !== normalizeObjective(expectedCodexObjective(plan, goal)) && await canReconcileCompletedTaskScopedAggregateSnapshot(repoRoot, plan, goal, objective, evidence, scope); + if (!taskScoped) throw new UlwLoopError(`${formatCodexGoalReconciliation(reconciliation)}${aggregate && snapshot?.status === "complete" && objective !== undefined ? buildTaskScopedAggregateReconciliationHint(goal, final) : ""}`, "ulw_loop_codex_snapshot_mismatch"); + aggregateCompletion = makeAggregateCompletion(now, evidence, codexGoal); + } + if (final) aggregateCompletion = makeAggregateCompletion(now, evidence, codexGoal); + 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(repoRoot, plan, scope); + const ledgerEntry = buildLedger(now, args, goal, qualityGate, codexGoal, aggregateCompletion); + await appendLedger(repoRoot, ledgerEntry, scope); + return aggregateCompletion === undefined ? { plan, goal, ledgerEntry } : { plan, goal, ledgerEntry, aggregateCompletion }; + }); +} diff --git a/packages/omo-codex/plugin/components/ulw-loop/src/cli-arg-parser.ts b/packages/omo-codex/plugin/components/ulw-loop/src/cli-arg-parser.ts new file mode 100644 index 000000000..ebd2a794a --- /dev/null +++ b/packages/omo-codex/plugin/components/ulw-loop/src/cli-arg-parser.ts @@ -0,0 +1,95 @@ +// biome-ignore-all format: keep this module under the mandated pure LOC budget. +import { readFile } from "node:fs/promises"; + +import { UlwLoopError } 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("--brief --brief-file --session-id --codex-goal-mode --goal --goal-id --criterion-id --status --evidence --notes --codex-goal-json --quality-gate-json --kind --rationale --title --objective --target-goal-id --source --after-json --directive-json --directive-file --idempotency-key".split(" ")); +const SUBCOMMANDS = new Set("create-goals status complete-goals criteria record-evidence checkpoint steer add-goal record-review-blockers".split(" ")); + +export function hasFlag(argv: readonly string[], flag: string): boolean { return argv.includes(flag); } + +export function readValue(argv: readonly string[], flag: string): string | undefined { + const index = argv.indexOf(flag); + if (index >= 0) { + const next = argv[index + 1]; + return next === undefined || next.startsWith("--") ? undefined : next; + } + const prefix = `${flag}=`; + return argv.find((arg) => arg.startsWith(prefix))?.slice(prefix.length); +} + +export function readRepeated(argv: readonly string[], flag: string): string[] { + const values: string[] = []; + const prefix = `${flag}=`; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + const next = argv[index + 1]; + if (arg === flag && next !== undefined && !next.startsWith("--")) { values.push(next); index += 1; } + else if (arg?.startsWith(prefix)) values.push(arg.slice(prefix.length)); + } + return values; +} + +export function parseGoalArg(argv: readonly string[]): string | undefined { return readValue(argv, "--goal-id") ?? readValue(argv, "--goal"); } + +export async function readStdin(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + return Buffer.concat(chunks).toString("utf8"); +} + +export function positionalText(argv: readonly string[]): string { + const words: string[] = []; + for (let index = SUBCOMMANDS.has(argv[0] ?? "") ? 1 : 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === undefined) continue; + if (VALUE_FLAGS.has(arg)) { index += 1; continue; } + if (arg.startsWith("--")) continue; + words.push(arg); + } + return words.join(" ").trim(); +} + +function looksLikeJson(value: string): boolean { const trimmed = value.trim(); return trimmed.startsWith("{") || trimmed.startsWith("["); } + +export async function readJsonInput(value: string | undefined): Promise { + if (value === undefined) return undefined; + try { return JSON.parse(looksLikeJson(value) ? value : await readFile(value, "utf8")); } + catch (error) { + const message = error instanceof Error ? error.message : "unknown error"; + throw new UlwLoopError(`Invalid JSON input: ${message}`, "ULW_LOOP_JSON_INPUT_INVALID", { cause: error }); + } +} + +export async function parseCodexGoalJson(value: string | undefined): Promise { + if (value === undefined) return undefined; + const raw = looksLikeJson(value) ? value : await readFile(value, "utf8"); + try { JSON.parse(raw); return raw; } + catch (error) { + const message = error instanceof Error ? error.message : "unknown error"; + throw new UlwLoopError(`Invalid --codex-goal-json: ${message}`, "ULW_LOOP_CODEX_GOAL_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 UlwLoopError(`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 UlwLoopError("Invalid --status; expected pass, fail, or blocked.", "ULW_LOOP_EVIDENCE_STATUS_INVALID", { details: { status: value } }); + } +} + +export function parseRecordEvidenceArgs(argv: readonly string[]): RecordEvidenceCliArgs { + const result = { goalId: required(argv, "--goal-id", "ULW_LOOP_GOAL_ID_REQUIRED"), criterionId: required(argv, "--criterion-id", "ULW_LOOP_CRITERION_ID_REQUIRED"), status: evidenceStatus(required(argv, "--status", "ULW_LOOP_EVIDENCE_STATUS_REQUIRED")), evidence: required(argv, "--evidence", "ULW_LOOP_EVIDENCE_REQUIRED") }; + const notes = readValue(argv, "--notes")?.trim(); + return notes ? { ...result, notes } : result; +} diff --git a/packages/omo-codex/plugin/components/ulw-loop/src/cli-commands.ts b/packages/omo-codex/plugin/components/ulw-loop/src/cli-commands.ts new file mode 100644 index 000000000..0b120f806 --- /dev/null +++ b/packages/omo-codex/plugin/components/ulw-loop/src/cli-commands.ts @@ -0,0 +1,156 @@ +// biome-ignore-all format: keep cli-commands dispatcher under the 200 pure LOC budget. +import { readFile } from "node:fs/promises"; +import { type CheckpointUlwLoopArgs, checkpointUlwLoop } from "./checkpoint.js"; +import { hasFlag, parseCodexGoalJson, parseRecordEvidenceArgs, positionalText, readStdin, readValue } from "./cli-arg-parser.js"; +import { blockedDecisionHandoff, normalizeCodexGoalMode, printJson, printStatus, ULW_LOOP_HELP } from "./cli-output.js"; +import { parseSteeringProposal, printSteerResult } from "./cli-steering.js"; +import { buildCodexGoalInstruction } from "./codex-goal-instruction.js"; +import { recordEvidence } from "./evidence.js"; +import { resolveUlwLoopSessionIdFromEnv, type UlwLoopScope } from "./paths.js"; +import { addUlwLoopGoal, createUlwLoopPlan, startNextUlwLoop, summarizeUlwLoopPlan } from "./plan-crud.js"; +import { readUlwLoopPlan } from "./plan-io.js"; +import { recordFinalReviewBlockers } from "./review-blockers.js"; +import { steerUlwLoop } from "./steering.js"; +import type { UlwLoopItem } from "./types.js"; +import { UlwLoopError } from "./types.js"; + +type CheckpointStatus = "complete" | "failed" | "blocked"; + +export async function ulwLoopCommand(argv: readonly string[]): Promise { + const command = argv[0] ?? "help"; + const rest = argv.slice(1); + const repoRoot = process.cwd(); + const json = hasFlag(rest, "--json"); + const scope = commandScope(rest); + try { + switch (command) { + case "help": case "--help": case "-h": process.stdout.write(`${ULW_LOOP_HELP}\n`); return 0; + case "create-goals": return await createGoals(repoRoot, rest, json, scope); + case "status": return await status(repoRoot, json, scope); + case "complete-goals": return await completeGoals(repoRoot, rest, json, scope); + case "checkpoint": return await checkpoint(repoRoot, rest, json, scope); + case "steer": return await steer(repoRoot, rest, json, scope); + case "add-goal": return await addGoal(repoRoot, rest, json, scope); + case "criteria": return await criteria(repoRoot, rest, json, scope); + case "record-evidence": return await captureEvidence(repoRoot, rest, json, scope); + case "record-review-blockers": return await reviewBlockers(repoRoot, rest, json, scope); + default: process.stdout.write(`${ULW_LOOP_HELP}\n`); return 1; + } + } catch (error) { + if (error instanceof UlwLoopError) process.stderr.write(`[ulw-loop] ${error.message}\n`); + else if (error instanceof Error) process.stderr.write(`[ulw-loop] unexpected: ${error.message}\n`); + else process.stderr.write("[ulw-loop] unknown error\n"); + return 1; + } +} + +function commandScope(argv: readonly string[]): UlwLoopScope | undefined { + const sessionId = readValue(argv, "--session-id") ?? resolveUlwLoopSessionIdFromEnv(); + return sessionId === null ? undefined : { sessionId }; +} + +async function createGoals(repoRoot: string, argv: readonly string[], json: boolean, scope?: UlwLoopScope): Promise { + const briefFile = readValue(argv, "--brief-file"); + const brief = readValue(argv, "--brief") ?? (briefFile === undefined ? undefined : await readFile(briefFile, "utf8")) ?? (hasFlag(argv, "--from-stdin") ? await readStdin() : undefined) ?? positionalText(argv); + if (!brief.trim()) throw new UlwLoopError("Missing brief text. Pass --brief, --brief-file, --from-stdin, or positional text.", "ULW_LOOP_BRIEF_REQUIRED"); + const plan = await createUlwLoopPlan(repoRoot, { brief, codexGoalMode: normalizeCodexGoalMode(readValue(argv, "--codex-goal-mode")), force: hasFlag(argv, "--force") }, scope); + if (json) printJson({ ok: true, plan, summary: summarizeUlwLoopPlan(plan) }); + else process.stdout.write(`ulw-loop plan created: ${plan.goals.length} goal(s)\nbrief: ${plan.briefPath}\ngoals: ${plan.goalsPath}\nledger: ${plan.ledgerPath}\n`); + return 0; +} + +async function status(repoRoot: string, json: boolean, scope?: UlwLoopScope): Promise { + const plan = await readUlwLoopPlan(repoRoot, scope); + if (json) printJson({ ok: true, plan, summary: summarizeUlwLoopPlan(plan) }); + else printStatus(plan); + return 0; +} + +async function completeGoals(repoRoot: string, argv: readonly string[], json: boolean, scope?: UlwLoopScope): Promise { + const result = await startNextUlwLoop(repoRoot, { retryFailed: hasFlag(argv, "--retry-failed") }, scope); + if ("done" in result) { + const handoff = blockedDecisionHandoff(result.plan); + if (json) printJson({ ok: true, done: true, blocked: handoff.length > 0, handoff, summary: summarizeUlwLoopPlan(result.plan), plan: result.plan }); + else process.stdout.write(`${handoff || "ulw-loop: all goals complete"}\n`); + return 0; + } + const instruction = buildCodexGoalInstruction({ 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(repoRoot: string, argv: readonly string[], json: boolean, scope?: UlwLoopScope): Promise { + const goalId = required(argv, "--goal-id"); + const statusValue = checkpointStatus(required(argv, "--status")); + const evidence = required(argv, "--evidence"); + const codexGoalJson = await parseCodexGoalJson(statusValue === "complete" ? required(argv, "--codex-goal-json") : readValue(argv, "--codex-goal-json")); + if (statusValue === "complete" && codexGoalJson === undefined) throw new UlwLoopError("Missing --codex-goal-json.", "ULW_LOOP_CODEX_GOAL_JSON_REQUIRED"); + const qualityGateJson = readValue(argv, "--quality-gate-json"); + const args: CheckpointUlwLoopArgs = { + goalId, + status: statusValue, + evidence, + ...(codexGoalJson === undefined ? {} : { codexGoalJson }), + ...(qualityGateJson === undefined ? {} : { qualityGateJson }), + }; + const result = await checkpointUlwLoop(repoRoot, args, scope); + if (json) printJson({ ok: true, ...result, summary: summarizeUlwLoopPlan(result.plan) }); + else process.stdout.write(`ulw-loop checkpoint: ${result.goal.id} -> ${result.goal.status}\n`); + return 0; +} + +async function steer(repoRoot: string, argv: readonly string[], json: boolean, scope?: UlwLoopScope): Promise { + const proposal = await parseSteeringProposal(argv); + const result = await steerUlwLoop(repoRoot, proposal, scope); + printSteerResult(result, json); + return result.accepted ? 0 : 1; +} + +async function addGoal(repoRoot: string, argv: readonly string[], json: boolean, scope?: UlwLoopScope): Promise { + const result = await addUlwLoopGoal(repoRoot, { title: required(argv, "--title"), objective: required(argv, "--objective") }, scope); + if (json) printJson({ ok: true, plan: result.plan, goal: result.goal, summary: summarizeUlwLoopPlan(result.plan) }); + else { process.stdout.write(`ulw-loop added goal: ${result.goal.id}\n`); printStatus(result.plan); } + return 0; +} + +async function criteria(repoRoot: string, argv: readonly string[], json: boolean, scope?: UlwLoopScope): Promise { + const goalId = required(argv, "--goal-id"); + const goal = findGoal(await readUlwLoopPlan(repoRoot, 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(repoRoot: string, argv: readonly string[], json: boolean, scope?: UlwLoopScope): Promise { + const result = await recordEvidence(repoRoot, parseRecordEvidenceArgs(argv), scope); + if (json) printJson({ ok: true, ...result, summary: summarizeUlwLoopPlan(result.plan) }); + else process.stdout.write(`ulw-loop evidence recorded: ${result.goal.id}/${result.criterion.id} -> ${result.criterion.status}\n`); + return 0; +} + +async function reviewBlockers(repoRoot: string, argv: readonly string[], json: boolean, scope?: UlwLoopScope): Promise { + const codexGoalJson = await parseCodexGoalJson(required(argv, "--codex-goal-json")); + if (codexGoalJson === undefined) throw new UlwLoopError("Missing --codex-goal-json.", "ULW_LOOP_CODEX_GOAL_JSON_REQUIRED"); + const result = await recordFinalReviewBlockers(repoRoot, { goalId: required(argv, "--goal-id"), title: required(argv, "--title"), objective: required(argv, "--objective"), evidence: required(argv, "--evidence"), codexGoalJson }, scope); + if (json) printJson({ ok: true, plan: result.plan, blockedGoal: result.blockedGoal, goal: result.newGoal, ledgerEntries: result.ledgerEntries, summary: summarizeUlwLoopPlan(result.plan) }); + else process.stdout.write(`ulw-loop 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 UlwLoopError(`Missing ${flag}.`, "ULW_LOOP_ARGUMENT_MISSING", { details: { flag } }); +} + +function checkpointStatus(value: string): CheckpointStatus { + if (value === "complete" || value === "failed" || value === "blocked") return value; + throw new UlwLoopError("Missing or invalid --status; expected complete, failed, or blocked.", "ULW_LOOP_STATUS_INVALID", { details: { status: value } }); +} + +function findGoal(plan: { readonly goals: readonly UlwLoopItem[] }, goalId: string): UlwLoopItem { + const goal = plan.goals.find((candidate) => candidate.id === goalId); + if (goal !== undefined) return goal; + throw new UlwLoopError(`Unknown ulw-loop id: ${goalId}.`, "ULW_LOOP_GOAL_NOT_FOUND", { details: { goalId } }); +} diff --git a/packages/omo-codex/plugin/components/ulw-loop/src/cli-output.ts b/packages/omo-codex/plugin/components/ulw-loop/src/cli-output.ts new file mode 100644 index 000000000..aeb70adc8 --- /dev/null +++ b/packages/omo-codex/plugin/components/ulw-loop/src/cli-output.ts @@ -0,0 +1,63 @@ +import type { UlwLoopCodexGoalMode, UlwLoopItem, UlwLoopPlan } from "./types.js"; +import { UlwLoopError } from "./types.js"; + +export const ULW_LOOP_HELP = `Usage: + omo ulw-loop create-goals --brief "..." [--brief-file ] [--from-stdin] [--codex-goal-mode aggregate|per_story] [--force] [--json] + omo ulw-loop status [--json] + omo ulw-loop complete-goals [--retry-failed] [--json] + omo ulw-loop criteria --goal-id [--json] + omo ulw-loop record-evidence --goal-id --criterion-id --status pass|fail|blocked --evidence "..." [--notes "..."] [--json] + omo ulw-loop checkpoint --goal-id --status complete|failed|blocked --evidence "..." --codex-goal-json <...> [--quality-gate-json <...>] [--json] + omo ulw-loop steer --kind ... --evidence "..." --rationale "..." [--json] + omo ulw-loop add-goal --title "..." --objective "..." [--json] + omo ulw-loop record-review-blockers --goal-id --title "..." --objective "..." --evidence "..." --codex-goal-json <...> [--json] + +All subcommands accept [--session-id ] to isolate state under .omo/ulw-loop//; without it, Codex session env is used when present.`; + +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: UlwLoopItem): 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: UlwLoopPlan): void { + let totalCriteria = 0; + let passCriteria = 0; + const lines = ["ulw-loop 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: UlwLoopPlan): string { + const blocked = plan.goals.find((goal) => goal.status === "needs_user_decision" && goal.nonRetriable); + if (blocked === undefined) return ""; + return [ + "ulw-loop: 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 normalizeCodexGoalMode(value: string | undefined): UlwLoopCodexGoalMode { + if (value === undefined) return "aggregate"; + if (value === "aggregate" || value === "per_story") return value; + throw new UlwLoopError( + "Invalid --codex-goal-mode; expected aggregate or per_story.", + "ULW_LOOP_CODEX_GOAL_MODE_INVALID", + { details: { value } }, + ); +} diff --git a/packages/omo-codex/plugin/components/ulw-loop/src/cli-steering.ts b/packages/omo-codex/plugin/components/ulw-loop/src/cli-steering.ts new file mode 100644 index 000000000..e7f6b8b99 --- /dev/null +++ b/packages/omo-codex/plugin/components/ulw-loop/src/cli-steering.ts @@ -0,0 +1,94 @@ +// biome-ignore-all format: keep this module under the mandated pure LOC budget. +import { parseGoalArg, readJsonInput, readValue } from "./cli-arg-parser.js"; +import { printJson, printStatus } from "./cli-output.js"; +import type { SteerUlwLoopResult, UlwLoopSteeringChildGoal, UlwLoopSteeringMutationKind, UlwLoopSteeringProposal, UlwLoopSteeringSource, UlwLoopSuccessCriterionUserModel } from "./types.js"; +import { ULW_LOOP_STEERING_MUTATION_KINDS, ULW_LOOP_SUCCESS_CRITERION_USER_MODELS, UlwLoopError } from "./types.js"; + +const SOURCES = ["user_prompt_submit", "finding", "cli"] as const satisfies readonly UlwLoopSteeringSource[]; + +export type CliSteeringProposal = UlwLoopSteeringProposal & { readonly goalId?: string; readonly scenario?: string; readonly expectedEvidence?: string; readonly userModel?: UlwLoopSuccessCriterionUserModel }; + +function isKind(value: string | undefined): value is UlwLoopSteeringMutationKind { return value !== undefined && ULW_LOOP_STEERING_MUTATION_KINDS.some((kind) => kind === value); } +function isSource(value: string | undefined): value is UlwLoopSteeringSource { return value !== undefined && SOURCES.some((source) => source === value); } +function isModel(value: string): value is UlwLoopSuccessCriterionUserModel { return ULW_LOOP_SUCCESS_CRITERION_USER_MODELS.some((model) => model === value); } +function fail(message: string, code: string, details: Record): never { throw new UlwLoopError(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}.`, "ULW_LOOP_STEERING_FIELD_EMPTY", { field }); } +function required(argv: readonly string[], flag: string): string { const value = text(readValue(argv, flag), flag); return value ?? fail(`Missing ${flag}.`, "ULW_LOOP_STEERING_FIELD_REQUIRED", { flag }); } +function requiredGoal(argv: readonly string[]): string { const value = text(parseGoalArg(argv), "--goal-id"); return value ?? fail("Missing --goal-id.", "ULW_LOOP_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[]): UlwLoopSteeringMutationKind { + const value = readValue(argv, "--kind"); + if (isKind(value)) return value; + return value === undefined ? fail("Missing --kind.", "ULW_LOOP_STEERING_KIND_REQUIRED", { flag: "--kind" }) : fail(`Invalid --kind: ${value}.`, "ULW_LOOP_STEERING_KIND_INVALID", { value, expected: ULW_LOOP_STEERING_MUTATION_KINDS }); +} + +export function parseSteeringSource(argv: readonly string[]): UlwLoopSteeringSource { + const value = readValue(argv, "--source"); + if (value === undefined) return "cli"; + return isSource(value) ? value : fail(`Invalid --source: ${value}.`, "ULW_LOOP_STEERING_SOURCE_INVALID", { value, expected: SOURCES }); +} + +function child(value: unknown): UlwLoopSteeringChildGoal | null { + if (!isPlain(value)) return null; + const title = text(objectText(value, "title"), "title"); const objective = text(objectText(value, "objective"), "objective"); + if (title === undefined || objective === undefined) return null; + return { title, objective }; +} + +async function children(argv: readonly string[], flag: string, needed: boolean): Promise { + const input = needed ? required(argv, flag) : text(readValue(argv, flag), flag); + if (input === undefined) return []; + const raw = await readJsonInput(input); + if (!Array.isArray(raw)) return fail(`${flag} must be a JSON array.`, "ULW_LOOP_STEERING_JSON_ARRAY_REQUIRED", { flag }); + const parsed: UlwLoopSteeringChildGoal[] = []; + for (const item of raw) { const next = child(item); if (next === null) return fail(`${flag} entries require title/objective.`, "ULW_LOOP_STEERING_CHILD_INVALID", { flag }); parsed.push(next); } + return parsed; +} + +async function stringArray(argv: readonly string[], flag: string): Promise { + const raw = await readJsonInput(required(argv, flag)); + if (!Array.isArray(raw)) return fail(`${flag} must be a JSON array.`, "ULW_LOOP_STEERING_JSON_ARRAY_REQUIRED", { flag }); + const values: string[] = []; + for (const item of raw) { if (typeof item !== "string") return fail(`${flag} entries must be strings.`, "ULW_LOOP_STEERING_STRING_ARRAY_REQUIRED", { flag }); values.push(text(item, flag) ?? ""); } + return values; +} + +function model(value: string | undefined): UlwLoopSuccessCriterionUserModel | undefined { const trimmed = text(value, "--user-model"); if (trimmed === undefined) return undefined; return isModel(trimmed) ? trimmed : fail(`Invalid --user-model: ${trimmed}.`, "ULW_LOOP_STEERING_USER_MODEL_INVALID", { value: trimmed, expected: ULW_LOOP_SUCCESS_CRITERION_USER_MODELS }); } +function neverKind(kind: never): never { return fail(`Unsupported steering kind: ${String(kind)}.`, "ULW_LOOP_STEERING_KIND_UNSUPPORTED", { kind }); } + +export async function parseSteeringProposal(argv: readonly string[]): Promise { + const kind = parseSteeringKind(argv); const source = parseSteeringSource(argv); const base = { kind, source, evidence: required(argv, "--evidence"), rationale: required(argv, "--rationale") }; + switch (kind) { + case "add_subgoal": return normalizeSteeringProposal({ ...base, title: required(argv, "--title"), objective: required(argv, "--objective") }); + case "split_subgoal": { const goalId = requiredGoal(argv); return normalizeSteeringProposal({ ...base, goalId, targetGoalId: goalId, childGoals: await children(argv, "--children", true) }); } + case "reorder_pending": return normalizeSteeringProposal({ ...base, pendingOrder: await stringArray(argv, "--order") }); + case "revise_pending_wording": { const goalId = requiredGoal(argv); const revisedTitle = readValue(argv, "--title"); const revisedObjective = readValue(argv, "--objective"); if (revisedTitle === undefined && revisedObjective === undefined) return fail("revise_pending_wording requires --title or --objective.", "ULW_LOOP_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.", "ULW_LOOP_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 UlwLoopSteeringChildGoal[] | undefined): UlwLoopSteeringChildGoal[] | 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: SteerUlwLoopResult, 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(`ulw-loop steer: ${outcome} ${result.audit.kind}\n`); + if (result.rejectedReasons.length > 0) process.stdout.write(`rejected: ${result.rejectedReasons.join("; ")}\n`); + if (result.audit.idempotencyKey !== undefined) process.stdout.write(`idempotency-key: ${result.audit.idempotencyKey}\n`); + printStatus(result.plan); +} diff --git a/packages/omo-codex/plugin/components/ulw-loop/src/cli.ts b/packages/omo-codex/plugin/components/ulw-loop/src/cli.ts new file mode 100644 index 000000000..ed9d5fd9a --- /dev/null +++ b/packages/omo-codex/plugin/components/ulw-loop/src/cli.ts @@ -0,0 +1,40 @@ +#!/usr/bin/env node +import { ulwLoopCommand } from "./cli-commands.js"; +import { runPreToolUseGoalBudgetGuardCli, runUlwLoopHookCli } from "./codex-hook.js"; + +const TOP_LEVEL_HELP = + "Usage:\n omo ulw-loop [args]\n omo hook user-prompt-submit (Codex UserPromptSubmit hook)\n omo help | --help | -h (this message)\n\nRun `omo ulw-loop help` for ulw-loop subcommands.\n"; + +async function main(): Promise { + const argv = process.argv.slice(2); + const command = argv[0]; + if (command === undefined || command === "help" || command === "--help" || command === "-h") { + process.stdout.write(TOP_LEVEL_HELP); + return 0; + } + if (command === "ulw-loop") return ulwLoopCommand(argv.slice(1)); + if (command === "hook") { + const sub = argv[1]; + if (sub === "user-prompt-submit") { + await runUlwLoopHookCli(process.stdin, process.stdout); + return 0; + } + if (sub === "pre-tool-use") { + await runPreToolUseGoalBudgetGuardCli(process.stdin, process.stdout); + return 0; + } + process.stderr.write(`[omo] unknown hook subcommand: ${sub ?? "(none)"}\n`); + return 1; + } + process.stderr.write(`[omo] unknown command: ${command}\n${TOP_LEVEL_HELP}`); + return 1; +} + +main() + .then((code) => { + process.exit(code); + }) + .catch((error: unknown) => { + process.stderr.write(`[omo] ${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); + }); diff --git a/packages/omo-codex/plugin/components/ulw-loop/src/codex-goal-instruction.ts b/packages/omo-codex/plugin/components/ulw-loop/src/codex-goal-instruction.ts new file mode 100644 index 000000000..482246722 --- /dev/null +++ b/packages/omo-codex/plugin/components/ulw-loop/src/codex-goal-instruction.ts @@ -0,0 +1,129 @@ +import { codexGoalMode, expectedCodexObjective, isFinalRunCompletionCandidate } from "./goal-status.js"; +import type { UlwLoopCodexGoalMode, UlwLoopItem, UlwLoopPlan, UlwLoopSuccessCriterion } from "./types.js"; + +export interface CodexCreateGoalPayload { + readonly objective: string; + readonly status: "active"; +} + +export interface UlwLoopGoalInstruction { + readonly text: string; + readonly json: CodexCreateGoalPayload; +} + +export function buildCodexGoalInstruction(args: { + readonly plan: UlwLoopPlan; + readonly goal: UlwLoopItem; + readonly isFinal?: boolean; +}): UlwLoopGoalInstruction { + const mode = codexGoalMode(args.plan); + const createGoal = buildCreateGoalPayload(args.plan, args.goal); + const isFinal = args.isFinal ?? isFinalRunCompletionCandidate(args.plan, args.goal); + return { text: buildText(mode, args.plan, args.goal, createGoal, isFinal), json: createGoal }; +} + +function buildCreateGoalPayload(plan: UlwLoopPlan, goal: UlwLoopItem): CodexCreateGoalPayload { + return { objective: expectedCodexObjective(plan, goal), status: "active" }; +} + +function buildText( + mode: UlwLoopCodexGoalMode, + plan: UlwLoopPlan, + goal: UlwLoopItem, + createGoal: CodexCreateGoalPayload, + isFinal: boolean, +): string { + return joinLines([ + mode === "aggregate" ? "UlwLoop aggregate-goal handoff" : "UlwLoop active-goal handoff", + `Mode: ${mode}`, + `Plan: ${plan.goalsPath}`, + `Ledger: ${plan.ledgerPath}`, + `Goal: ${goal.id} — ${goal.title}`, + "", + ...activeGoalLines(goal), + "", + ...successCriteriaLines(goal.successCriteria), + "", + "Codex goal integration constraints:", + "- Use the create_goal payload exactly as rendered: objective and status only.", + "- Goals are unlimited. Do not add numeric limits.", + ...modeConstraintLines(mode, isFinal), + finalSection(plan, goal, isFinal, mode === "aggregate"), + ...checkpointLines(plan, mode), + "", + "create_goal payload:", + JSON.stringify(createGoal, null, 2), + ]); +} + +function modeConstraintLines(mode: UlwLoopCodexGoalMode, isFinal: boolean): readonly string[] { + if (mode === "per_story") { + return [ + "- First call get_goal. If no active goal exists, call create_goal with the payload below.", + "- If a different active Codex goal exists, finish/checkpoint that goal before starting this ulw-loop.", + "- Work only this goal until its completion audit passes.", + ]; + } + return [ + "- Codex goal = the whole omo ulw-loop run; OMO G001/G002/etc. = ledger stories.", + "- First call get_goal. If no active goal exists, call create_goal with the aggregate payload below.", + "- If get_goal reports the same aggregate objective as active, continue this OMO story without creating a new Codex goal.", + "- If a different active or incomplete Codex goal exists, finish/checkpoint that goal before starting this ulw-loop.", + isFinal + ? "- This is the final story; update_goal is allowed only after the mandatory quality gate passes." + : "- This is not the final story: do not call update_goal yet; the aggregate Codex goal must remain active while later OMO stories remain.", + ]; +} + +function checkpointLines(plan: UlwLoopPlan, mode: UlwLoopCodexGoalMode): readonly string[] { + const failureLine = `- If blocked or failed, checkpoint with --status failed and the failure evidence; rerun complete-goals${sessionOption(plan)} --retry-failed to resume.`; + if (mode === "per_story") return [failureLine]; + return [ + "- Checkpoint this OMO story with a fresh get_goal snapshot whose objective matches the aggregate payload.", + failureLine, + ]; +} + +function activeGoalLines(goal: UlwLoopItem): readonly string[] { + return ["Active goal:", `- id: ${goal.id}`, `- title: ${goal.title}`, `- objective: ${goal.objective}`]; +} + +function successCriteriaLines(criteria: readonly UlwLoopSuccessCriterion[]): 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: UlwLoopSuccessCriterion): string { + const remainingWork = criterion.status === "pending" ? " remaining work:" : ""; + return `-${remainingWork} [${criterion.id}] (${criterion.userModel}) ${criterion.scenario} — expect: ${criterion.expectedEvidence} — status: ${criterion.status}`; +} + +function finalSection(plan: UlwLoopPlan, goal: UlwLoopItem, isFinal: boolean, aggregate: boolean): string { + if (!isFinal) + return "- This is not the final ulw-loop story; do not run the final ai-slop-cleaner/$code-review gate yet."; + const option = sessionOption(plan); + const blockerCommand = `omo ulw-loop record-review-blockers${option} --goal-id ${goal.id} --title "Resolve final code-review blockers" --objective "" --evidence "" --codex-goal-json ""`; + const checkpointCommand = `omo ulw-loop checkpoint${option} --goal-id ${goal.id} --status complete --evidence "" --codex-goal-json "" --quality-gate-json ""`; + return joinLines([ + "Final story — run mandatory quality gate before update_goal:", + "- 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 call update_goal. Record blocker work first:", + ` ${blockerCommand}`, + aggregate + ? '- If final $code-review is clean, call update_goal({status: "complete"}), call get_goal again, then checkpoint the aggregate story:' + : '- If final $code-review is clean, call update_goal({status: "complete"}), call get_goal again, then checkpoint:', + ` ${checkpointCommand}`, + ]); +} + +function sessionOption(plan: UlwLoopPlan): string { + const prefix = ".omo/ulw-loop/"; + const suffix = "/goals.json"; + if (!plan.goalsPath.startsWith(prefix) || !plan.goalsPath.endsWith(suffix)) return ""; + const sessionId = plan.goalsPath.slice(prefix.length, -suffix.length); + return sessionId.length === 0 ? "" : ` --session-id ${sessionId}`; +} + +function joinLines(lines: readonly string[]): string { + return lines.join("\n"); +} diff --git a/packages/omo-codex/plugin/components/ulw-loop/src/codex-goal-snapshot.ts b/packages/omo-codex/plugin/components/ulw-loop/src/codex-goal-snapshot.ts new file mode 100644 index 000000000..8361c3540 --- /dev/null +++ b/packages/omo-codex/plugin/components/ulw-loop/src/codex-goal-snapshot.ts @@ -0,0 +1,139 @@ +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +export type CodexGoalSnapshotStatus = "active" | "complete" | "cancelled" | "failed" | "unknown"; + +export interface CodexGoalSnapshot { + available: boolean; + objective?: string; + status?: CodexGoalSnapshotStatus; + raw: unknown; +} + +export interface CodexGoalReconciliation { + ok: boolean; + snapshot: CodexGoalSnapshot; + warnings: string[]; + errors: string[]; +} + +export interface ReconcileCodexGoalOptions { + expectedObjective: string; + acceptedObjectives?: readonly string[]; + allowedStatuses?: readonly CodexGoalSnapshotStatus[]; + requireSnapshot?: boolean; + requireComplete?: boolean; +} + +export class CodexGoalSnapshotError extends Error {} +function safeObject(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; +} + +function safeString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function normalizeStatus(value: unknown): CodexGoalSnapshotStatus { + 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 parseCodexGoalSnapshot(value: unknown): CodexGoalSnapshot { + 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 readCodexGoalSnapshotInput( + raw: string | undefined, + cwd = process.cwd(), +): Promise { + if (!raw?.trim()) return null; + const trimmed = raw.trim(); + try { + return parseCodexGoalSnapshot(JSON.parse(trimmed)); + } catch { + const path = resolve(cwd, trimmed); + if (!existsSync(path)) { + throw new CodexGoalSnapshotError(`Codex goal snapshot is neither valid JSON nor a readable path: ${trimmed}`); + } + try { + return parseCodexGoalSnapshot(JSON.parse(await readFile(path, "utf-8"))); + } catch (error) { + throw new CodexGoalSnapshotError( + `Codex goal snapshot path does not contain valid JSON: ${trimmed}${error instanceof Error ? ` (${error.message})` : ""}`, + ); + } + } +} + +export function reconcileCodexGoalSnapshot( + snapshot: CodexGoalSnapshot | null | undefined, + options: ReconcileCodexGoalOptions, +): CodexGoalReconciliation { + const effectiveSnapshot = snapshot ?? { available: false, raw: null }; + const errors: string[] = []; + const warnings: string[] = []; + + if (!effectiveSnapshot.available) { + const message = + "Codex goal snapshot is absent or reports no active goal; call get_goal and pass its JSON with --codex-goal-json."; + 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("Codex goal snapshot is missing objective text."); + } else if (!accepted.has(actual)) { + errors.push(`Codex 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(`Codex goal status mismatch: expected ${allowed.join(" or ")}, got ${actualStatus}.`); + } + if (options.requireComplete && actualStatus !== "complete") { + errors.push( + 'Codex goal is not complete; call update_goal({status: "complete"}) only after the objective is actually complete, then pass the fresh get_goal JSON.', + ); + } + + return { ok: errors.length === 0, snapshot: effectiveSnapshot, warnings, errors }; +} + +export function formatCodexGoalReconciliation(reconciliation: CodexGoalReconciliation): string { + const parts = [...reconciliation.errors, ...reconciliation.warnings]; + return parts.join(" "); +} diff --git a/packages/omo-codex/plugin/components/ulw-loop/src/codex-hook.ts b/packages/omo-codex/plugin/components/ulw-loop/src/codex-hook.ts new file mode 100644 index 000000000..9393ff5db --- /dev/null +++ b/packages/omo-codex/plugin/components/ulw-loop/src/codex-hook.ts @@ -0,0 +1,177 @@ +import type { UlwLoopScope } from "./paths.js"; +import { parseUlwLoopSteeringDirective, steerUlwLoop } 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 token_budget on create_goal. Omit the budget field so the goal stays unlimited; ultrawork and ulw-loop 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 applyUserPromptUlwLoopSteering(payload: UserPromptSubmitPayload): Promise { + try { + if (payload.hook_event_name !== "UserPromptSubmit") return ""; + const proposal = parseUlwLoopSteeringDirective(payload.prompt); + if (proposal === null) return ""; + const result = await steerUlwLoop(payload.cwd, proposal, payloadScope(payload)); + 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 ""; + } +} + +function payloadScope(payload: UserPromptSubmitPayload): UlwLoopScope { + return { sessionId: payload.session_id }; +} + +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 runUlwLoopHookCli(stdin: NodeJS.ReadableStream, stdout: NodeJS.WritableStream): Promise { + try { + const payload = parseUserPromptSubmitPayload(await readAll(stdin)); + if (payload === null) return; + const output = await applyUserPromptUlwLoopSteering(payload); + if (output.length > 0) stdout.write(output); + } catch (error) { + if (error instanceof Error) return; + return; + } +} + +export async function runPreToolUseGoalBudgetGuardCli( + stdin: NodeJS.ReadableStream, + stdout: NodeJS.WritableStream, +): Promise { + try { + const payload = parsePreToolUsePayload(await readAll(stdin)); + if (payload === null) return; + const output = applyPreToolUseGoalBudgetGuard(payload); + if (output.length > 0) stdout.write(output); + } catch (error) { + if (error instanceof Error) return; + return; + } +} + +function isUserPromptSubmitPayload(value: unknown): value is UserPromptSubmitPayload { + if (!isRecord(value)) return false; + return ( + value["hook_event_name"] === "UserPromptSubmit" && + typeof value["cwd"] === "string" && + typeof value["prompt"] === "string" && + typeof value["session_id"] === "string" && + ["model", "permission_mode", "transcript_path", "turn_id"].every((key) => optionalString(value[key])) + ); +} + +function isPreToolUsePayload(value: unknown): value is PreToolUsePayload { + if (!isRecord(value)) return false; + return ( + value["hook_event_name"] === "PreToolUse" && + typeof value["cwd"] === "string" && + typeof value["model"] === "string" && + typeof value["permission_mode"] === "string" && + typeof value["session_id"] === "string" && + typeof value["tool_name"] === "string" && + typeof value["tool_use_id"] === "string" && + (value["transcript_path"] === null || typeof value["transcript_path"] === "string") && + typeof value["turn_id"] === "string" && + Object.hasOwn(value, "tool_input") + ); +} + +function hasGoalBudgetInput(value: unknown): boolean { + return isRecord(value) && (Object.hasOwn(value, "token_budget") || Object.hasOwn(value, "tokenBudget")); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function optionalString(value: unknown): boolean { + return value === undefined || typeof value === "string"; +} + +function readAll(stdin: NodeJS.ReadableStream): Promise { + return new Promise((resolve, reject) => { + let data = ""; + stdin.setEncoding("utf8"); + stdin.on("data", (chunk: unknown) => { + data += chunk instanceof Buffer ? chunk.toString() : String(chunk); + }); + stdin.once("error", reject); + stdin.once("end", () => resolve(data)); + }); +} diff --git a/packages/omo-codex/plugin/components/ulw-loop/src/evidence.ts b/packages/omo-codex/plugin/components/ulw-loop/src/evidence.ts new file mode 100644 index 000000000..c955e5d6d --- /dev/null +++ b/packages/omo-codex/plugin/components/ulw-loop/src/evidence.ts @@ -0,0 +1,122 @@ +// biome-ignore-all format: keep this module under the mandated pure LOC budget. +import { hasAllCriteriaPass } from "./goal-status.js"; +import type { UlwLoopScope } from "./paths.js"; +import { appendLedger, readUlwLoopPlan, withUlwLoopMutationLock, writePlan } from "./plan-io.js"; +import type { UlwLoopItem, UlwLoopLedgerEntry, UlwLoopPlan, UlwLoopSuccessCriterion } from "./types.js"; +import { iso, UlwLoopError } 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 ulwLoopFail(message: string, code: string, details: Record): never { throw new UlwLoopError(message, code, { details }); } + +function ledgerKind(status: EvidenceStatus): UlwLoopLedgerEntry["kind"] { + switch (status) { + case "pass": + return "evidence_captured"; + case "fail": + return "criterion_failed"; + case "blocked": + return "criterion_blocked"; + default: + return ulwLoopFail("Invalid criterion status.", "ULW_LOOP_CRITERION_STATUS_INVALID", { status }); + } +} + +function findGoal(plan: UlwLoopPlan, goalId: string): UlwLoopItem { + const goal = plan.goals.find((candidate) => candidate.id === goalId); + return goal ?? ulwLoopFail(`UlwLoop goal not found: ${goalId}.`, "ULW_LOOP_GOAL_NOT_FOUND", { goalId }); +} + +function findCriterion(goal: UlwLoopItem, criterionId: string): UlwLoopSuccessCriterion { + const criterion = goal.successCriteria.find((candidate) => candidate.id === criterionId); + return criterion ?? ulwLoopFail(`Success criterion not found: ${criterionId}.`, "ULW_LOOP_CRITERION_NOT_FOUND", { goalId: goal.id, criterionId }); +} + +function nonEmptyEvidence(evidence: string): string { const trimmed = evidence.trim(); return trimmed || ulwLoopFail("Evidence must be a non-empty string.", "ULW_LOOP_EVIDENCE_REQUIRED", {}); } + +export async function recordEvidence(repoRoot: string, args: RecordEvidenceArgs, scope?: UlwLoopScope): Promise<{ plan: UlwLoopPlan; goal: UlwLoopItem; criterion: UlwLoopSuccessCriterion; ledgerEntry: UlwLoopLedgerEntry }> { + return withUlwLoopMutationLock(repoRoot, scope, async () => { + const plan = await readUlwLoopPlan(repoRoot, 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(repoRoot, plan, scope); + const ledgerEntry: UlwLoopLedgerEntry = { + 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(repoRoot, ledgerEntry, scope); + return { plan, goal, criterion, ledgerEntry }; + }); +} + +export async function markCriteriaPendingResetForGoal(repoRoot: string, goalId: string, scope?: UlwLoopScope): Promise<{ plan: UlwLoopPlan; resetCount: number }> { + return withUlwLoopMutationLock(repoRoot, scope, async () => { + const plan = await readUlwLoopPlan(repoRoot, 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(repoRoot, plan, scope); + await appendLedger(repoRoot, { at: now, kind: "criteria_revised", goalId, message: `Reset ${goal.successCriteria.length} criteria to pending.`, before, after: { resetCount: goal.successCriteria.length } }, scope); + return { plan, resetCount: goal.successCriteria.length }; + }); +} + +export function criteriaSummary(plan: UlwLoopPlan): { 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: ulwLoopFail("Invalid criterion status.", "ULW_LOOP_CRITERION_STATUS_INVALID", { status: criterion.status }); + } + } + if (unresolved) goalsWithUnresolvedCriteria.push(goal.id); + } + return { totalCriteria, passCount, pendingCount, failCount, blockedCount, goalsWithUnresolvedCriteria }; +} + +export function unresolvedCriteriaOf(goal: UlwLoopItem): UlwLoopSuccessCriterion[] { return goal.successCriteria.filter((criterion) => criterion.status !== "pass"); } + +export function requireAllCriteriaPass(goal: UlwLoopItem): void { + if (hasAllCriteriaPass(goal)) return; + throw new UlwLoopError(`Goal ${goal.id} has unresolved success criteria.`, "ulw_loop_criteria_not_all_pass", { + details: { goalId: goal.id, unresolved: unresolvedCriteriaOf(goal).map((criterion) => ({ id: criterion.id, status: criterion.status })) }, + }); +} diff --git a/packages/omo-codex/plugin/components/ulw-loop/src/goal-status.ts b/packages/omo-codex/plugin/components/ulw-loop/src/goal-status.ts new file mode 100644 index 000000000..f6d9d7312 --- /dev/null +++ b/packages/omo-codex/plugin/components/ulw-loop/src/goal-status.ts @@ -0,0 +1,88 @@ +import { type UlwLoopScope, ulwLoopGoalsRelativePath, ulwLoopLedgerRelativePath } from "./paths.js"; +import type { + UlwLoopCodexGoalMode, + UlwLoopItem, + UlwLoopPlan, + UlwLoopStatus, + UlwLoopSuccessCriterion, +} from "./types.js"; + +export const ULW_LOOP_AGGREGATE_CODEX_OBJECTIVE: string = aggregateCodexObjectiveForScope(); + +export function aggregateCodexObjectiveForScope(scope?: UlwLoopScope): string { + return `Complete the durable ulw-loop plan in ${ulwLoopGoalsRelativePath(scope)}, including later accepted/appended stories, under the original brief constraints; use ${ulwLoopLedgerRelativePath(scope)} as the audit trail.`; +} + +export function codexGoalMode(plan: UlwLoopPlan): UlwLoopCodexGoalMode { + return plan.codexGoalMode ?? "per_story"; +} + +function isResolvedStatus(status: UlwLoopStatus): boolean { + return status === "complete"; +} + +function isSupersededResolved(goal: UlwLoopItem, plan: UlwLoopPlan): 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: UlwLoopItem, plan: UlwLoopPlan): boolean { + if (goal.steeringStatus === "superseded") return !isSupersededResolved(goal, plan); + if (goal.steeringStatus === "blocked") return true; + return !isResolvedStatus(goal.status); +} + +function isCompletionBlockingForFinalCandidate( + candidate: UlwLoopItem, + finalCandidate: UlwLoopItem, + plan: UlwLoopPlan, +): 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 isUlwLoopDone(plan: UlwLoopPlan): boolean { + if (plan.aggregateCompletion?.status === "complete") return true; + return plan.goals.every((goal) => !isCompletionBlocking(goal, plan)); +} + +export function isFinalRunCompletionCandidate(plan: UlwLoopPlan, goal: UlwLoopItem): boolean { + return ( + isCompletionBlocking(goal, plan) && + plan.goals.every((candidate) => !isCompletionBlockingForFinalCandidate(candidate, goal, plan)) + ); +} + +export function aggregateCodexObjective(plan: UlwLoopPlan): string { + return plan.codexObjective ?? ULW_LOOP_AGGREGATE_CODEX_OBJECTIVE; +} + +export function expectedCodexObjective(plan: UlwLoopPlan, goal: UlwLoopItem): string { + return codexGoalMode(plan) === "aggregate" ? aggregateCodexObjective(plan) : goal.objective; +} + +export function compatibleCodexObjectives(plan: UlwLoopPlan): readonly string[] { + return [aggregateCodexObjective(plan), ...(plan.codexObjectiveAliases ?? [])]; +} + +export function hasAllCriteriaPass(goal: UlwLoopItem): boolean { + return goal.successCriteria.length > 0 && goal.successCriteria.every((criterion) => criterion.status === "pass"); +} + +export function firstUnresolvedCriterion(goal: UlwLoopItem): UlwLoopSuccessCriterion | undefined { + return goal.successCriteria.find((criterion) => criterion.status !== "pass"); +} diff --git a/packages/omo-codex/plugin/components/ulw-loop/src/paths.ts b/packages/omo-codex/plugin/components/ulw-loop/src/paths.ts new file mode 100644 index 000000000..a646469cf --- /dev/null +++ b/packages/omo-codex/plugin/components/ulw-loop/src/paths.ts @@ -0,0 +1,73 @@ +import { join } from "node:path"; +import { ULW_LOOP_BRIEF, ULW_LOOP_DIR, ULW_LOOP_GOALS, ULW_LOOP_LEDGER } from "./types.js"; + +export interface UlwLoopScope { + readonly sessionId?: string | null; +} + +const SESSION_ENV_KEYS = ["OMO_ULW_LOOP_SESSION_ID", "CODEX_SESSION_ID", "CODEX_THREAD_ID"] as const; +type EnvMap = Readonly>; + +export function normalizeUlwLoopSessionId(sessionId: string | null | undefined): string | null { + const trimmed = sessionId?.trim(); + if (!trimmed) return null; + const pathSegments = trimmed + .split(/[\\/]+/) + .filter((segment) => segment.length > 0 && segment !== "." && segment !== ".."); + const candidate = (pathSegments.length > 0 ? pathSegments.join("-") : trimmed) + .replace(/[^A-Za-z0-9._-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^\.+/, "") + .replace(/^[.-]+|[.-]+$/g, ""); + return candidate.length > 0 ? candidate : null; +} + +export function resolveUlwLoopSessionIdFromEnv(env: EnvMap = process.env): string | null { + for (const key of SESSION_ENV_KEYS) { + const normalized = normalizeUlwLoopSessionId(env[key]); + if (normalized !== null) return normalized; + } + return null; +} + +export function ulwLoopRelativeDir(scope?: UlwLoopScope): string { + const sessionId = normalizeUlwLoopSessionId(scope?.sessionId); + return sessionId === null ? ULW_LOOP_DIR : `${ULW_LOOP_DIR}/${sessionId}`; +} + +export function ulwLoopDir(repoRoot: string, scope?: UlwLoopScope): string { + return join(repoRoot, ulwLoopRelativeDir(scope)); +} + +export function ulwLoopBriefRelativePath(scope?: UlwLoopScope): string { + return `${ulwLoopRelativeDir(scope)}/${ULW_LOOP_BRIEF}`; +} + +export function ulwLoopGoalsRelativePath(scope?: UlwLoopScope): string { + return `${ulwLoopRelativeDir(scope)}/${ULW_LOOP_GOALS}`; +} + +export function ulwLoopLedgerRelativePath(scope?: UlwLoopScope): string { + return `${ulwLoopRelativeDir(scope)}/${ULW_LOOP_LEDGER}`; +} + +export function ulwLoopBriefPath(repoRoot: string, scope?: UlwLoopScope): string { + return join(ulwLoopDir(repoRoot, scope), ULW_LOOP_BRIEF); +} + +export function ulwLoopGoalsPath(repoRoot: string, scope?: UlwLoopScope): string { + return join(ulwLoopDir(repoRoot, scope), ULW_LOOP_GOALS); +} + +export function ulwLoopLedgerPath(repoRoot: string, scope?: UlwLoopScope): string { + return join(ulwLoopDir(repoRoot, scope), ULW_LOOP_LEDGER); +} + +export function repoRelative(absolutePath: string, repoRoot: string): string { + const slashPrefix = `${repoRoot}/`; + const backslashPrefix = `${repoRoot}\\`; + if (absolutePath.startsWith(slashPrefix)) return absolutePath.slice(slashPrefix.length).split("\\").join("/"); + if (absolutePath.startsWith(backslashPrefix)) + return absolutePath.slice(backslashPrefix.length).split("\\").join("/"); + return absolutePath.split("\\").join("/"); +} diff --git a/packages/omo-codex/plugin/components/ulw-loop/src/plan-crud.ts b/packages/omo-codex/plugin/components/ulw-loop/src/plan-crud.ts new file mode 100644 index 000000000..b2a61ca1d --- /dev/null +++ b/packages/omo-codex/plugin/components/ulw-loop/src/plan-crud.ts @@ -0,0 +1,113 @@ +// 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 { aggregateCodexObjectiveForScope } from "./goal-status.js"; +import { type UlwLoopScope, ulwLoopBriefPath, ulwLoopBriefRelativePath, ulwLoopDir, ulwLoopGoalsPath, ulwLoopGoalsRelativePath, ulwLoopLedgerPath, ulwLoopLedgerRelativePath } from "./paths.js"; +import { appendLedger, readUlwLoopPlan, withUlwLoopMutationLock, writePlan } from "./plan-io.js"; +import type { UlwLoopCodexGoalMode, UlwLoopItem, UlwLoopPlan, UlwLoopSuccessCriterion } from "./types.js"; +import { iso, UlwLoopError } from "./types.js"; + +export type UlwLoopPlanSummary = { 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 UlwLoopError(`Missing ${label}.`, "ULW_LOOP_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): UlwLoopSuccessCriterion[] { + 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): UlwLoopItem { + 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: UlwLoopPlan, title: string, objective: string, now: string): UlwLoopItem { + const goal = makeGoal(title, objective, plan.goals.length, now); + plan.goals.push(goal); + plan.updatedAt = now; + return goal; +} + +function isScheduleEligible(goal: UlwLoopItem): boolean { return goal.steeringStatus !== "superseded" && goal.steeringStatus !== "blocked"; } + +function clearGoalBlockerFields(goal: UlwLoopItem): void { + for (const key of ["blockedReason", "blockerSignature", "blockerOccurrenceCount", "requiredExternalDecision", "nonRetriable", "failedAt", "failureReason"] as const) delete goal[key]; +} + +export async function createUlwLoopPlan(repoRoot: string, args: { brief: string; codexGoalMode?: UlwLoopCodexGoalMode; force?: boolean }, scope?: UlwLoopScope): Promise { + return withUlwLoopMutationLock(repoRoot, scope, async () => { + if (!args.force && existsSync(ulwLoopGoalsPath(repoRoot, scope))) throw new UlwLoopError(`Refusing to overwrite existing ${ulwLoopGoalsRelativePath(scope)}; pass --force to recreate it.`, "ULW_LOOP_PLAN_EXISTS"); + const now = iso(); + const goals = deriveGoalCandidates(args.brief).map((goal, index) => makeGoal(goal.title, goal.objective, index, now)); + const plan: UlwLoopPlan = { version: 1, createdAt: now, updatedAt: now, briefPath: ulwLoopBriefRelativePath(scope), goalsPath: ulwLoopGoalsRelativePath(scope), ledgerPath: ulwLoopLedgerRelativePath(scope), codexGoalMode: args.codexGoalMode ?? "aggregate", goals }; + if (plan.codexGoalMode === "aggregate") plan.codexObjective = aggregateCodexObjectiveForScope(scope); + await mkdir(ulwLoopDir(repoRoot, scope), { recursive: true }); + await writeFile(ulwLoopBriefPath(repoRoot, scope), args.brief.endsWith("\n") ? args.brief : `${args.brief}\n`, "utf8"); + await writePlan(repoRoot, plan, scope); + await writeFile(ulwLoopLedgerPath(repoRoot, scope), "", "utf8"); + await appendLedger(repoRoot, { at: now, kind: "plan_created", message: `${goals.length} goal(s) created` }, scope); + return plan; + }); +} + +export async function addUlwLoopGoal(repoRoot: string, args: { title: string; objective: string }, scope?: UlwLoopScope): Promise<{ plan: UlwLoopPlan; goal: UlwLoopItem }> { + return withUlwLoopMutationLock(repoRoot, scope, async () => { + const plan = await readUlwLoopPlan(repoRoot, scope); + const now = iso(); + const goal = appendGoalToPlan(plan, args.title, args.objective, now); + await writePlan(repoRoot, plan, scope); + await appendLedger(repoRoot, { at: now, kind: "goal_added", goalId: goal.id, status: goal.status, message: goal.title }, scope); + return { plan, goal }; + }); +} + +export async function startNextUlwLoop(repoRoot: string, args: { retryFailed?: boolean } = {}, scope?: UlwLoopScope): Promise<{ plan: UlwLoopPlan; goal: UlwLoopItem; resumed: boolean } | { done: true; plan: UlwLoopPlan }> { + return withUlwLoopMutationLock(repoRoot, scope, async () => { + const plan = await readUlwLoopPlan(repoRoot, 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(repoRoot, { at: now, kind: "goal_resumed", goalId: existing.id, status: existing.status, message: "Resuming active ulw-loop" }, scope); 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(repoRoot, { at: now, kind: "goal_retried", goalId: next.id, status: "pending", ...(next.failureReason ? { message: next.failureReason } : {}) }, scope); + } + 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(repoRoot, plan, scope); + await appendLedger(repoRoot, { at: now, kind: "goal_started", goalId: next.id, status: next.status, message: `Attempt ${next.attempt}` }, scope); + return { plan, goal: next, resumed: false }; + }); +} + +export function summarizeUlwLoopPlan(plan: UlwLoopPlan): UlwLoopPlanSummary { + const countStatus = (status: UlwLoopItem["status"]): number => plan.goals.filter((goal) => goal.status === status).length; + const countCriteria = (status: UlwLoopSuccessCriterion["status"]): number => plan.goals.reduce((sum, goal) => sum + goal.successCriteria.filter((criterion) => criterion.status === status).length, 0); + return { total: plan.goals.length, pending: countStatus("pending"), in_progress: countStatus("in_progress"), complete: countStatus("complete"), failed: countStatus("failed"), blocked: countStatus("blocked"), review_blocked: countStatus("review_blocked"), needs_user_decision: countStatus("needs_user_decision"), superseded: plan.goals.filter((goal) => goal.steeringStatus === "superseded").length, criteria: { total: plan.goals.reduce((sum, goal) => sum + goal.successCriteria.length, 0), pass: countCriteria("pass"), pending: countCriteria("pending"), fail: countCriteria("fail"), blocked: countCriteria("blocked") } }; +} diff --git a/packages/omo-codex/plugin/components/ulw-loop/src/plan-io.ts b/packages/omo-codex/plugin/components/ulw-loop/src/plan-io.ts new file mode 100644 index 000000000..79f2044ea --- /dev/null +++ b/packages/omo-codex/plugin/components/ulw-loop/src/plan-io.ts @@ -0,0 +1,124 @@ +import { appendFile, mkdir, readFile, rename, writeFile } from "node:fs/promises"; + +import { aggregateCodexObjectiveForScope } from "./goal-status.js"; +import { + repoRelative, + type UlwLoopScope, + ulwLoopDir, + ulwLoopGoalsPath, + ulwLoopLedgerPath, + ulwLoopRelativeDir, +} from "./paths.js"; +import type { UlwLoopLedgerEntry, UlwLoopPlan } from "./types.js"; +import { iso, ULW_LOOP_DIR, ULW_LOOP_GOALS, ULW_LOOP_LEDGER, UlwLoopError } from "./types.js"; + +const LEGACY_OBJECTIVE_PREFIX = `Complete all ulw-loop stories in ${ULW_LOOP_DIR}/${ULW_LOOP_GOALS}: `; +const LEGACY_OBJECTIVE = `Complete all ulw-loop stories listed in ${ULW_LOOP_DIR}/${ULW_LOOP_GOALS}. Use ${ULW_LOOP_DIR}/${ULW_LOOP_LEDGER} as the durable audit trail.`; +const locks = new Map>(); + +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 UlwLoopLedgerEntry["kind"] { + return value === "steering_accepted" || value === "steering_rejected" || value === "criteria_revised"; +} + +export async function withUlwLoopMutationLock(repoRoot: string, fn: () => Promise): Promise; +export async function withUlwLoopMutationLock( + repoRoot: string, + scope: UlwLoopScope | undefined, + fn: () => Promise, +): Promise; +export async function withUlwLoopMutationLock( + repoRoot: string, + scopeOrFn: UlwLoopScope | (() => Promise) | undefined, + maybeFn?: () => Promise, +): Promise { + const scope = typeof scopeOrFn === "function" ? undefined : scopeOrFn; + const fn = typeof scopeOrFn === "function" ? scopeOrFn : maybeFn; + if (fn === undefined) throw new UlwLoopError("Missing ulw-loop mutation body.", "ULW_LOOP_LOCK_BODY_MISSING"); + const lockKey = `${repoRoot}\0${ulwLoopRelativeDir(scope)}`; + const prior = locks.get(lockKey) ?? Promise.resolve(); + const run = prior.then(fn, fn); + locks.set( + lockKey, + run.catch(() => undefined), + ); + return run; +} + +export async function readUlwLoopPlan(repoRoot: string, scope?: UlwLoopScope): Promise { + const path = ulwLoopGoalsPath(repoRoot, scope); + let raw: string; + try { + raw = await readFile(path, "utf8"); + } catch (error) { + if (!hasCode(error, "ENOENT")) throw error; + throw new UlwLoopError( + `No ulw-loop plan found at ${repoRelative(path, repoRoot)}. Run \`omo ulw-loop create-goals ...\` first.`, + "ULW_LOOP_PLAN_MISSING", + { cause: error }, + ); + } + const parsed: UlwLoopPlan = JSON.parse(raw); + if (parsed.version !== 1 || !Array.isArray(parsed.goals)) { + throw new UlwLoopError(`Invalid ulw-loop plan at ${repoRelative(path, repoRoot)}.`, "ULW_LOOP_PLAN_INVALID"); + } + const previousObjective = parsed.codexObjective; + if ( + (parsed.codexGoalMode ?? "per_story") === "aggregate" && + isLegacyEnumeratedAggregateObjective(previousObjective) + ) { + const now = iso(); + parsed.codexObjective = aggregateCodexObjectiveForScope(scope); + parsed.codexObjectiveAliases = [...new Set([...(parsed.codexObjectiveAliases ?? []), previousObjective])]; + parsed.updatedAt = now; + await writePlan(repoRoot, parsed, scope); + await appendLedger( + repoRoot, + { + at: now, + kind: "aggregate_objective_migrated", + message: "Migrated legacy enumerated aggregate Codex objective to the stable pointer objective.", + before: { codexObjective: previousObjective }, + after: { codexObjective: parsed.codexObjective }, + }, + scope, + ); + } + return parsed; +} + +export async function writePlan(repoRoot: string, plan: UlwLoopPlan, scope?: UlwLoopScope): Promise { + await mkdir(ulwLoopDir(repoRoot, scope), { recursive: true }); + const path = ulwLoopGoalsPath(repoRoot, scope); + const tmpPath = `${path}.${process.pid}.${Date.now()}.tmp`; + await writeFile(tmpPath, `${JSON.stringify(plan, null, 2)}\n`, "utf8"); + await rename(tmpPath, path); +} + +export async function appendLedger(repoRoot: string, entry: UlwLoopLedgerEntry, scope?: UlwLoopScope): Promise { + await mkdir(ulwLoopDir(repoRoot, scope), { recursive: true }); + await appendFile(ulwLoopLedgerPath(repoRoot, scope), `${JSON.stringify(entry)}\n`, "utf8"); +} + +export async function readSteeringLedgerEntries(repoRoot: string, scope?: UlwLoopScope): Promise { + let raw: string; + try { + raw = await readFile(ulwLoopLedgerPath(repoRoot, scope), "utf8"); + } catch (error) { + if (hasCode(error, "ENOENT")) return []; + throw error; + } + const entries: UlwLoopLedgerEntry[] = []; + for (const line of raw.split(/\r?\n/).filter(Boolean)) { + const entry: UlwLoopLedgerEntry = JSON.parse(line); + if (isSteeringKind(entry.kind)) entries.push(entry); + } + return entries; +} diff --git a/packages/omo-codex/plugin/components/ulw-loop/src/quality-gate.ts b/packages/omo-codex/plugin/components/ulw-loop/src/quality-gate.ts new file mode 100644 index 000000000..4303b99ae --- /dev/null +++ b/packages/omo-codex/plugin/components/ulw-loop/src/quality-gate.ts @@ -0,0 +1,102 @@ +import type { UlwLoopItem, UlwLoopPlan, UlwLoopQualityGate } from "./types.js"; +import { UlwLoopError } 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 UlwLoopError(message, "ULW_LOOP_QUALITY_GATE_INVALID", { details: { field } }); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function section(value: unknown, field: string): Record { + return isRecord(value) ? value : invalid(`Final quality gate is missing ${field} evidence.`, field); +} + +function nonEmptyString(value: unknown, field: string): string { + return typeof value === "string" && value.trim() !== "" + ? value + : invalid(`Final quality gate requires non-empty ${field}.`, field); +} + +function numberField(value: unknown, field: string): number { + return typeof value === "number" && Number.isFinite(value) + ? value + : invalid(`Final quality gate requires numeric ${field}.`, field); +} + +function stringArray(value: unknown, field: string): string[] { + if (!Array.isArray(value) || value.length === 0) return invalid(`Final quality gate requires ${field}.`, field); + return value.map((item) => nonEmptyString(item, field)); +} + +export function validateQualityGate(input: unknown): UlwLoopQualityGate { + 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: UlwLoopQualityGate = { + 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: UlwLoopItem): 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: UlwLoopPlan, signature: string): number { + return plan.goals.filter((goal) => goal.blockerSignature === signature || nestedBlockerSignature(goal) === signature) + .length; +} + +export function clearGoalBlockerFields(goal: UlwLoopItem): void { + for (const key of BLOCKER_FIELD_KEYS) Reflect.deleteProperty(goal, key); +} diff --git a/packages/omo-codex/plugin/components/ulw-loop/src/review-blockers.ts b/packages/omo-codex/plugin/components/ulw-loop/src/review-blockers.ts new file mode 100644 index 000000000..6ad3292fd --- /dev/null +++ b/packages/omo-codex/plugin/components/ulw-loop/src/review-blockers.ts @@ -0,0 +1,81 @@ +// biome-ignore-all format: compact port must stay within the requested pure LOC budget. + +import { readCodexGoalSnapshotInput, reconcileCodexGoalSnapshot } from "./codex-goal-snapshot.js"; +import { codexGoalMode, compatibleCodexObjectives, expectedCodexObjective, isFinalRunCompletionCandidate } from "./goal-status.js"; +import type { UlwLoopScope } from "./paths.js"; +import { seedDefaultSuccessCriteria } from "./plan-crud.js"; +import { appendLedger, readUlwLoopPlan, withUlwLoopMutationLock, writePlan } from "./plan-io.js"; +import type { UlwLoopItem, UlwLoopLedgerEntry, UlwLoopPlan } from "./types.js"; +import { iso, UlwLoopError } from "./types.js"; + +export interface RecordFinalReviewBlockersArgs { readonly goalId: string; readonly title: string; readonly objective: string; readonly evidence: string; readonly codexGoalJson: string } +export interface RecordFinalReviewBlockersResult { readonly plan: UlwLoopPlan; readonly blockedGoal: UlwLoopItem; readonly newGoal: UlwLoopItem; readonly ledgerEntries: UlwLoopLedgerEntry[] } + +const BLOCKER_FIELDS = "blockedReason blockerSignature blockerOccurrenceCount requiredExternalDecision nonRetriable failedAt failureReason completedAt blocker blockerEvidence blockerOccurrences blockedAt".split(" "); + +function ulwLoopError(message: string, code: string): never { + throw new UlwLoopError(message, code); +} + +function nextGoalId(plan: UlwLoopPlan): 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: UlwLoopPlan, args: RecordFinalReviewBlockersArgs, now: string): UlwLoopItem { + const index = plan.goals.length; + const goal: UlwLoopItem = { + 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( + repoRoot: string, + args: RecordFinalReviewBlockersArgs, + scope?: UlwLoopScope, +): Promise { + return withUlwLoopMutationLock(repoRoot, scope, async () => { + const plan = await readUlwLoopPlan(repoRoot, scope); + const goal = plan.goals.find((candidate) => candidate.id === args.goalId); + if (goal === undefined) ulwLoopError(`Unknown ulw-loop id: ${args.goalId}`, "ulw_loop_goal_not_found"); + if (goal.status !== "in_progress") ulwLoopError(`${goal.id} is ${goal.status}.`, "ulw_loop_goal_not_in_progress"); + if (!isFinalRunCompletionCandidate(plan, goal)) ulwLoopError(`${goal.id} is not final.`, "ulw_loop_not_final_story"); + + const snapshot = await readCodexGoalSnapshotInput(args.codexGoalJson, repoRoot); + const aggregate = codexGoalMode(plan) === "aggregate"; + const reconciliation = reconcileCodexGoalSnapshot(snapshot, { expectedObjective: expectedCodexObjective(plan, goal), ...(aggregate ? { acceptedObjectives: compatibleCodexObjectives(plan) } : {}), allowedStatuses: ["active"], requireSnapshot: true, requireComplete: false }); + if (!reconciliation.ok) ulwLoopError(reconciliation.errors.join(" "), "ulw_loop_codex_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 codexGoal = reconciliation.snapshot.raw; + const blockedEntry: UlwLoopLedgerEntry = { at: now, kind: "goal_review_blocked", goalId: goal.id, status: goal.status, evidence: args.evidence, codexGoal }; + const addedEntry: UlwLoopLedgerEntry = { at: now, kind: "goal_added", goalId: newGoal.id, status: newGoal.status, evidence: args.evidence, message: newGoal.title }; + const summaryEntry: UlwLoopLedgerEntry = { at: now, kind: "goal_review_blocked", goalId: goal.id, status: goal.status, evidence: args.evidence, codexGoal, message: `Review blockers recorded; appended ${newGoal.id}.` }; + Reflect.set(summaryEntry, "kind", "blocker_recorded"); + const ledgerEntries = [blockedEntry, addedEntry, summaryEntry]; + await writePlan(repoRoot, plan, scope); + for (const entry of ledgerEntries) await appendLedger(repoRoot, entry, scope); + return { plan, blockedGoal: goal, newGoal, ledgerEntries }; + }); +} diff --git a/packages/omo-codex/plugin/components/ulw-loop/src/steering.ts b/packages/omo-codex/plugin/components/ulw-loop/src/steering.ts new file mode 100644 index 000000000..76ae86dad --- /dev/null +++ b/packages/omo-codex/plugin/components/ulw-loop/src/steering.ts @@ -0,0 +1,270 @@ +// biome-ignore-all format: compact steering module must stay below the 240 pure-LOC budget +import { isUlwLoopDone } from "./goal-status.js"; +import type { UlwLoopScope } from "./paths.js"; +import { seedDefaultSuccessCriteria } from "./plan-crud.js"; +import { appendLedger, readSteeringLedgerEntries, readUlwLoopPlan, withUlwLoopMutationLock, writePlan } from "./plan-io.js"; +import type { + SteerUlwLoopResult, + UlwLoopItem, + UlwLoopLedgerEntry, + UlwLoopPlan, + UlwLoopSteeringAudit, + UlwLoopSteeringChildGoal, + UlwLoopSteeringMutationKind, + UlwLoopSteeringProposal, + UlwLoopSteeringSource, + UlwLoopSuccessCriterionUserModel, +} from "./types.js"; +import { iso, ULW_LOOP_STEERING_MUTATION_KINDS, ULW_LOOP_SUCCESS_CRITERION_USER_MODELS } from "./types.js"; + +const SOURCES = ["user_prompt_submit", "finding", "cli"] as const satisfies readonly UlwLoopSteeringSource[]; +const PROTECTED = new Set(["aggregateCompletion", "codexObjective", "codexObjectiveAliases", "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 UlwLoopSteeringMutationKind => typeof value === "string" && ULW_LOOP_STEERING_MUTATION_KINDS.some((kind) => kind === value); +const isSource = (value: unknown): value is UlwLoopSteeringSource => typeof value === "string" && SOURCES.some((source) => source === value); +const isModel = (value: unknown): value is UlwLoopSuccessCriterionUserModel => typeof value === "string" && ULW_LOOP_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): UlwLoopSteeringChildGoal | 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): UlwLoopSteeringChildGoal[] => childValues(proposal).map(child).filter((item): item is UlwLoopSteeringChildGoal => 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[]): UlwLoopSteeringAudit { + 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: UlwLoopSteeringAudit = { 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 validateUlwLoopSteeringProposal(plan: UlwLoopPlan, proposal: unknown): UlwLoopSteeringAudit { + 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 (isUlwLoopDone(plan)) reasons.push("plan already complete"); + if (isKind(kind)) validateKind(plan, object, kind, reasons); + return auditFor(proposal, reasons); +} + +function goal(plan: UlwLoopPlan, id: string | undefined): UlwLoopItem | undefined { + return id === undefined ? undefined : plan.goals.find((item) => item.id === id); +} + +function validateKind(plan: UlwLoopPlan, proposal: object, kind: UlwLoopSteeringMutationKind, 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: UlwLoopPlan, 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: UlwLoopPlan, 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: UlwLoopPlan, 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: UlwLoopPlan, childGoal: UlwLoopSteeringChildGoal, evidence: string, now: string, offset: number): UlwLoopItem { + const id = nextId(plan, offset); + const digits = /^G(\d+)/u.exec(id)?.[1]; + const goalIndex = digits === undefined ? plan.goals.length + offset - 1 : Number(digits) - 1; + return { id, title: childGoal.title, objective: childGoal.objective, status: "pending", successCriteria: seedDefaultSuccessCriteria(goalIndex, childGoal.objective), attempt: 0, createdAt: now, updatedAt: now, evidence }; +} + +export function applySteeringMutation(plan: UlwLoopPlan, proposal: UlwLoopSteeringProposal, audit: UlwLoopSteeringAudit): UlwLoopPlan { + 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 UlwLoopItem => 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: UlwLoopPlan, proposal: UlwLoopSteeringProposal, 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: UlwLoopPlan, proposal: UlwLoopSteeringProposal, 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: UlwLoopPlan, proposal: UlwLoopSteeringProposal, 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 UlwLoopSteeringProposal { + return isPlain(value) && isKind(read(value, "kind")) && isSource(read(value, "source")) && isText(read(value, "evidence")) && isText(read(value, "rationale")); +} + +export function parseUlwLoopSteeringDirective(text: string): UlwLoopSteeringProposal | null { + const match = /(?:^|\s)(?:OMO_ULW_LOOP_STEER|omo\.ulw-loop\.steer|omo ulw-loop 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 steerUlwLoop(repoRoot: string, proposal: UlwLoopSteeringProposal, scope?: UlwLoopScope): Promise { + return withUlwLoopMutationLock(repoRoot, scope, async () => { + const plan = await readUlwLoopPlan(repoRoot, scope); + const key = proposal.idempotencyKey ?? proposal.promptSignature; + const prior = key === undefined ? undefined : (await readSteeringLedgerEntries(repoRoot, 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 = validateUlwLoopSteeringProposal(plan, proposal); + const accepted = audit.invariant.accepted; + const next = accepted ? applySteeringMutation(plan, proposal, audit) : plan; + const finalAudit: UlwLoopSteeringAudit = { ...audit, before: plan }; + if (accepted) finalAudit.after = next; + if (accepted) await writePlan(repoRoot, next, scope); + await appendLedger(repoRoot, ledgerEntry(proposal, finalAudit, proposal.now?.toISOString() ?? iso()), scope); + return { plan: next, accepted, audit: finalAudit, rejectedReasons: audit.invariant.rejectedReasons, deduped: false }; + }); +} + +function ledgerEntry(proposal: UlwLoopSteeringProposal, audit: UlwLoopSteeringAudit, at: string): UlwLoopLedgerEntry { + const entry: UlwLoopLedgerEntry = { at, kind: audit.invariant.accepted ? (proposal.kind === "revise_criterion" ? "criteria_revised" : "steering_accepted") : "steering_rejected", evidence: proposal.evidence, message: proposal.rationale, steering: audit, mutationKind: proposal.kind }; + const goalId = audit.targetGoalIds[0]; + if (goalId !== undefined) entry.goalId = goalId; + if (proposal.criterionId !== undefined) entry.criterionId = proposal.criterionId; + if (proposal.idempotencyKey !== undefined) entry.idempotencyKey = proposal.idempotencyKey; + if (audit.before !== undefined) entry.before = audit.before; + if (audit.after !== undefined) entry.after = audit.after; + return entry; +} diff --git a/packages/omo-codex/plugin/components/ulw-loop/src/types.ts b/packages/omo-codex/plugin/components/ulw-loop/src/types.ts new file mode 100644 index 000000000..aa1f11ed2 --- /dev/null +++ b/packages/omo-codex/plugin/components/ulw-loop/src/types.ts @@ -0,0 +1,277 @@ +export const ULW_LOOP_DIR = ".omo/ulw-loop"; +export const ULW_LOOP_BRIEF = "brief.md"; +export const ULW_LOOP_GOALS = "goals.json"; +export const ULW_LOOP_LEDGER = "ledger.jsonl"; + +export type UlwLoopStatus = + | "pending" + | "in_progress" + | "complete" + | "failed" + | "blocked" + | "review_blocked" + | "needs_user_decision"; + +export type UlwLoopCodexGoalMode = "aggregate" | "per_story"; + +export type UlwLoopSteeringStatus = "superseded" | "blocked"; + +export const ULW_LOOP_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 UlwLoopSteeringMutationKind = (typeof ULW_LOOP_STEERING_MUTATION_KINDS)[number]; + +export type UlwLoopSteeringSource = "user_prompt_submit" | "finding" | "cli"; + +export const ULW_LOOP_SUCCESS_CRITERION_USER_MODELS = [ + "happy", + "edge", + "regression", + "adversarial", +] as const satisfies readonly string[]; +export type UlwLoopSuccessCriterionUserModel = (typeof ULW_LOOP_SUCCESS_CRITERION_USER_MODELS)[number]; + +export const ULW_LOOP_CRITERION_STATUSES = ["pending", "pass", "fail", "blocked"] as const satisfies readonly string[]; +export type UlwLoopCriterionStatus = (typeof ULW_LOOP_CRITERION_STATUSES)[number]; + +export const ULW_LOOP_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", + "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 UlwLoopLedgerEventKind = (typeof ULW_LOOP_LEDGER_EVENT_KINDS)[number]; + +export interface UlwLoopSuccessCriterion { + readonly id: string; + readonly scenario: string; + readonly userModel: UlwLoopSuccessCriterionUserModel; + readonly expectedEvidence: string; + capturedEvidence: string | null; + status: UlwLoopCriterionStatus; + capturedAt?: string; + notes?: string; +} + +export interface UlwLoopSteeringInvariantResult { + accepted: boolean; + structuralInvariantAccepted: boolean; + evidenceBackedNecessity: boolean; + noEasierCompletion: boolean; + rejectedReasons: string[]; + reasons?: string[]; +} + +export interface UlwLoopSteeringChildGoal { + title: string; + objective: string; +} + +export interface UlwLoopSteeringAfterPayload { + title?: string; + objective?: string; + pendingGoalIds?: string[]; + children?: UlwLoopSteeringChildGoal[]; +} + +export interface UlwLoopSteeringProposal { + kind: UlwLoopSteeringMutationKind; + source: UlwLoopSteeringSource; + targetGoalId?: string; + targetGoalIds?: string[]; + criterionId?: string; + evidence: string; + rationale: string; + title?: string; + objective?: string; + childGoals?: UlwLoopSteeringChildGoal[]; + revisedTitle?: string; + revisedObjective?: string; + pendingOrder?: string[]; + blockedReason?: string; + after?: UlwLoopSteeringAfterPayload; + directiveText?: string; + promptSignature?: string; + idempotencyKey?: string; + now?: Date; +} + +export interface UlwLoopSteeringAudit { + kind: UlwLoopSteeringMutationKind; + source: UlwLoopSteeringSource; + targetGoalIds: string[]; + criterionId?: string; + before?: unknown; + after?: unknown; + evidence: string; + rationale: string; + invariant: UlwLoopSteeringInvariantResult; + directiveText?: string; + promptSignature?: string; + idempotencyKey?: string; + deduped?: boolean; +} + +export interface SteerUlwLoopResult { + plan: UlwLoopPlan; + accepted: boolean; + audit: UlwLoopSteeringAudit; + rejectedReasons: string[]; + deduped: boolean; +} + +export interface UlwLoopItem { + id: string; + title: string; + objective: string; + status: UlwLoopStatus; + successCriteria: UlwLoopSuccessCriterion[]; + attempt: number; + createdAt: string; + updatedAt: string; + startedAt?: string; + completedAt?: string; + failedAt?: string; + reviewBlockedAt?: string; + evidence?: string; + failureReason?: string; + steeringStatus?: UlwLoopSteeringStatus; + supersededBy?: string[]; + supersedes?: string[]; + blockedReason?: string; + blockerSignature?: string; + blockerOccurrenceCount?: number; + requiredExternalDecision?: string; + nonRetriable?: boolean; + steeringEvidence?: string; + steeringRationale?: string; +} + +export interface UlwLoopAggregateCompletion { + status: "complete"; + completedAt: string; + evidence: string; + codexGoal?: unknown; +} + +export interface UlwLoopPlan { + version: 1; + createdAt: string; + updatedAt: string; + briefPath: string; + goalsPath: string; + ledgerPath: string; + codexGoalMode?: UlwLoopCodexGoalMode; + codexObjective?: string; + codexObjectiveAliases?: string[]; + aggregateCompletion?: UlwLoopAggregateCompletion; + activeGoalId?: string; + goals: UlwLoopItem[]; +} + +export interface UlwLoopLedgerEntry { + at: string; + kind: UlwLoopLedgerEventKind; + goalId?: string; + criterionId?: string; + status?: UlwLoopStatus; + criterionStatus?: UlwLoopCriterionStatus; + message?: string; + codexGoal?: unknown; + evidence?: string; + capturedEvidence?: string; + qualityGate?: UlwLoopQualityGate; + steering?: UlwLoopSteeringAudit; + before?: unknown; + after?: unknown; + mutationKind?: UlwLoopSteeringMutationKind; + idempotencyKey?: string; + blockerSignature?: string; + blockerOccurrenceCount?: number; + requiredExternalDecision?: string; +} + +export interface CreateUlwLoopOptions { + brief: string; + goals?: Array<{ title?: string; objective: string }>; + codexGoalMode?: UlwLoopCodexGoalMode; + now?: Date; + force?: boolean; +} + +export interface StartNextOptions { + now?: Date; + retryFailed?: boolean; +} + +export interface CheckpointOptions { + goalId: string; + status: Extract | "blocked"; + evidence?: string; + codexGoal?: unknown; + qualityGate?: unknown; + allowActiveFinalCodexGoal?: boolean; + now?: Date; +} + +export interface AddUlwLoopGoalOptions { + title: string; + objective: string; + evidence?: string; + now?: Date; +} + +export interface RecordFinalReviewBlockersOptions extends AddUlwLoopGoalOptions { + goalId: string; + codexGoal?: unknown; +} + +export interface UlwLoopQualityGate { + aiSlopCleaner: { status: "passed"; evidence: string }; + verification: { status: "passed"; commands: string[]; evidence: string }; + codeReview: { recommendation: "APPROVE"; architectStatus: "CLEAR"; evidence: string }; +} + +export interface UlwLoopErrorOptions { + readonly cause?: unknown; + readonly details?: Record; +} + +export class UlwLoopError extends Error { + readonly code: string; + readonly details?: Record; + + constructor(message: string, code: string, opts?: UlwLoopErrorOptions) { + super(message, opts?.cause === undefined ? undefined : { cause: opts.cause }); + this.name = "UlwLoopError"; + this.code = code; + if (opts?.details !== undefined) { + this.details = opts.details; + } + } +} + +export function iso(): string { + return new Date().toISOString(); +}