Files
oh-my-opencode/packages/omo-codex/plugin/components/ultragoal/src/plan-io.ts
T

100 lines
4.2 KiB
TypeScript

import { appendFile, mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { repoRelative, ultragoalDir, ultragoalGoalsPath, ultragoalLedgerPath } from "./paths.js";
import type { UltragoalLedgerEntry, UltragoalPlan } from "./types.js";
import { iso, ULTRAGOAL_DIR, ULTRAGOAL_GOALS, ULTRAGOAL_LEDGER, UltragoalError } from "./types.js";
const AGGREGATE_CODEX_OBJECTIVE = `Complete the durable ultragoal plan in ${ULTRAGOAL_DIR}/${ULTRAGOAL_GOALS}, including later accepted/appended stories, under the original brief constraints; use ${ULTRAGOAL_DIR}/${ULTRAGOAL_LEDGER} as the audit trail.`;
const LEGACY_OBJECTIVE_PREFIX = `Complete all ultragoal stories in ${ULTRAGOAL_DIR}/${ULTRAGOAL_GOALS}: `;
const LEGACY_OBJECTIVE = `Complete all ultragoal stories listed in ${ULTRAGOAL_DIR}/${ULTRAGOAL_GOALS}. Use ${ULTRAGOAL_DIR}/${ULTRAGOAL_LEDGER} as the durable audit trail.`;
const locks = new Map<string, Promise<unknown>>();
function hasCode(error: unknown, code: string): boolean {
return error instanceof Error && "code" in error && error.code === code;
}
function isLegacyEnumeratedAggregateObjective(objective: string | undefined): objective is string {
return objective === LEGACY_OBJECTIVE || Boolean(objective?.startsWith(LEGACY_OBJECTIVE_PREFIX));
}
function isSteeringKind(value: unknown): value is UltragoalLedgerEntry["kind"] {
return value === "steering_accepted" || value === "steering_rejected" || value === "criteria_revised";
}
export async function withUltragoalMutationLock<T>(repoRoot: string, fn: () => Promise<T>): Promise<T> {
const prior = locks.get(repoRoot) ?? Promise.resolve();
const run = prior.then(fn, fn);
locks.set(
repoRoot,
run.catch(() => undefined),
);
return run;
}
export async function readUltragoalPlan(repoRoot: string): Promise<UltragoalPlan> {
const path = ultragoalGoalsPath(repoRoot);
let raw: string;
try {
raw = await readFile(path, "utf8");
} catch (error) {
if (!hasCode(error, "ENOENT")) throw error;
throw new UltragoalError(
`No ultragoal plan found at ${repoRelative(path, repoRoot)}. Run \`omo ultragoal create-goals ...\` first.`,
"ULTRAGOAL_PLAN_MISSING",
{ cause: error },
);
}
const parsed: UltragoalPlan = JSON.parse(raw);
if (parsed.version !== 1 || !Array.isArray(parsed.goals)) {
throw new UltragoalError(`Invalid ultragoal plan at ${repoRelative(path, repoRoot)}.`, "ULTRAGOAL_PLAN_INVALID");
}
const previousObjective = parsed.codexObjective;
if (
(parsed.codexGoalMode ?? "per_story") === "aggregate" &&
isLegacyEnumeratedAggregateObjective(previousObjective)
) {
const now = iso();
parsed.codexObjective = AGGREGATE_CODEX_OBJECTIVE;
parsed.codexObjectiveAliases = [...new Set([...(parsed.codexObjectiveAliases ?? []), previousObjective])];
parsed.updatedAt = now;
await writePlan(repoRoot, parsed);
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 },
});
}
return parsed;
}
export async function writePlan(repoRoot: string, plan: UltragoalPlan): Promise<void> {
await mkdir(ultragoalDir(repoRoot), { recursive: true });
const path = ultragoalGoalsPath(repoRoot);
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: UltragoalLedgerEntry): Promise<void> {
await mkdir(ultragoalDir(repoRoot), { recursive: true });
await appendFile(ultragoalLedgerPath(repoRoot), `${JSON.stringify(entry)}\n`, "utf8");
}
export async function readSteeringLedgerEntries(repoRoot: string): Promise<UltragoalLedgerEntry[]> {
let raw: string;
try {
raw = await readFile(ultragoalLedgerPath(repoRoot), "utf8");
} catch (error) {
if (hasCode(error, "ENOENT")) return [];
throw error;
}
const entries: UltragoalLedgerEntry[] = [];
for (const line of raw.split(/\r?\n/).filter(Boolean)) {
const entry: UltragoalLedgerEntry = JSON.parse(line);
if (isSteeringKind(entry.kind)) entries.push(entry);
}
return entries;
}