/** * Plan-phase Review Gate focus coverage (spec | wbs | schedule | quality). */ import { constants } from "node:fs"; import { access, readdir, readFile } from "node:fs/promises"; import { join } from "node:path"; import { parse as parseYaml } from "yaml"; export const PLAN_FOCUS_AREAS = ["spec", "wbs", "schedule", "quality"] as const; export type PlanDebateFocus = (typeof PLAN_FOCUS_AREAS)[number]; export type PlanDebateRoundFocus = PlanDebateFocus | "all"; export interface PlanFocusCoverage { covered: PlanDebateFocus[]; missing: PlanDebateFocus[]; rounds_by_focus: Partial>; focus_by_round: Partial>; last_review_gate_ready: boolean; last_round_index: number; } export interface PlanFocusCoverageOptions { requiredFocuses?: readonly PlanDebateFocus[]; } async function fileExists(path: string): Promise { try { await access(path, constants.R_OK); return true; } catch { return false; } } function focusFromDraft( draft: Record, ): PlanDebateRoundFocus | null { const focus = String(draft.debate_round_focus ?? "").trim(); if (focus === "all") return "all"; if ((PLAN_FOCUS_AREAS as readonly string[]).includes(focus)) { return focus as PlanDebateFocus; } return null; } /** * Scan submitted review-round artifacts for focus coverage and last gate flag. */ export async function getPlanFocusCoverage( runDir: string, opts?: PlanFocusCoverageOptions, ): Promise { const required = opts?.requiredFocuses && opts.requiredFocuses.length > 0 ? opts.requiredFocuses : PLAN_FOCUS_AREAS; const artifactsDir = join(runDir, "artifacts"); const covered = new Set(); const rounds_by_focus: Partial> = {}; const focus_by_round: Partial> = {}; let last_review_gate_ready = false; let last_round_index = 0; let files: string[] = []; try { files = (await readdir(artifactsDir)).filter((f) => /^review-round(?:-r\d+|-consolidated|-parallel-probes)\.yaml$/i.test(f), ); } catch { return { covered: [], missing: [...required], rounds_by_focus: {}, focus_by_round: {}, last_review_gate_ready: false, last_round_index: 0, }; } for (const name of files.sort()) { const consolidated = /^review-round-consolidated\.yaml$/i.test(name); const parallelProbes = /^review-round-parallel-probes\.yaml$/i.test(name); const m = consolidated ? ["review-round-consolidated.yaml", "1"] : parallelProbes ? ["review-round-parallel-probes.yaml", "1"] : /^review-round-r(\d+)\.yaml$/i.exec(name); if (!m) continue; const roundIndex = consolidated || parallelProbes ? 1 : Number(m[1]); if (roundIndex > last_round_index) last_round_index = roundIndex; const raw = await readFile(join(artifactsDir, name), "utf-8"); let draft: Record; try { draft = parseYaml(raw) as Record; } catch { continue; } const focus = focusFromDraft(draft); if (focus) { if (focus === "all") { for (const requiredFocus of required) { covered.add(requiredFocus); rounds_by_focus[requiredFocus] = roundIndex; } } else { covered.add(focus); rounds_by_focus[focus] = roundIndex; } focus_by_round[roundIndex] = focus; } if (roundIndex === last_round_index) { last_review_gate_ready = draft.review_gate_ready === true; } } const coveredList = required.filter((f) => covered.has(f)); const missing = required.filter((f) => !covered.has(f)); return { covered: coveredList, missing, rounds_by_focus, focus_by_round, last_review_gate_ready, last_round_index, }; } export interface PlanDebateOutcomeOptions { requiredFocuses?: readonly PlanDebateFocus[]; minRoundIndex?: number; } export function planDebateOutcomeComplete( coverage: PlanFocusCoverage, opts?: PlanDebateOutcomeOptions, ): boolean { const required = opts?.requiredFocuses && opts.requiredFocuses.length > 0 ? opts.requiredFocuses : PLAN_FOCUS_AREAS; const minRounds = opts?.minRoundIndex ?? required.length; const missing = required.filter((f) => !coverage.covered.includes(f)); return ( missing.length === 0 && coverage.last_review_gate_ready === true && coverage.last_round_index >= minRounds ); } /** Read debate_round_focus from an existing review-round artifact. */ export async function readDebateRoundFocus( runDir: string, roundIndex: number, ): Promise { const candidates = roundIndex === 1 ? [ "review-round-parallel-probes.yaml", "review-round-consolidated.yaml", `review-round-r${roundIndex}.yaml`, ] : [`review-round-r${roundIndex}.yaml`]; for (const name of candidates) { const path = join(runDir, "artifacts", name); if (!(await fileExists(path))) continue; try { const raw = await readFile(path, "utf-8"); const draft = parseYaml(raw) as Record; return focusFromDraft(draft); } catch {} } return null; }