import { expandToPlanMarkdown } from "../plan/expand.ts"; export interface PreverifyInput { planMarkdown: string; evidenceIndexMarkdown?: string; fileExists: (path: string) => boolean; /** Optional reader for JSON path assertions (`json:path#a.b=value`). */ readText?: (path: string) => string | null; } export interface PreverifyResult { ok: boolean; gaps: string[]; } /** Artifact form: `json:#=` */ const JSON_ASSERT_RE = /^json:(.+?)#([^=]+)=(.*)$/i; /** * Deterministic pre-verify gates before spending skeptic tokens. * * - Parses numbered / listed acceptance criteria from the plan. * - If an evidence index is provided, requires a row covering each criterion * (by number or by substring match on criterion text). * - If no evidence index is provided, returns ok:true with a soft advisory gap. * - Optional artifact paths that look like file paths are checked via fileExists. * - `json:path#a.b.c=value` artifacts assert a JSON path/value even when the file exists. * - Draft-equivalent plans (structural expand skeleton) produce a hard gap. */ export function runPreverify(input: PreverifyInput): PreverifyResult { const gaps: string[] = []; if (isDraftEquivalentPlan(input.planMarkdown)) { gaps.push("plan not expanded beyond draft"); } const criteria = extractCriteria(input.planMarkdown); if (!input.evidenceIndexMarkdown?.trim()) { // Soft: do not hard-fail when evidence index is not ready yet — except draft. if (gaps.length > 0) { return { ok: false, gaps }; } return { ok: true, gaps: criteria.length > 0 ? ["no evidence index yet"] : [], }; } const rows = parseEvidenceRows(input.evidenceIndexMarkdown); if (criteria.length === 0) { return { ok: gaps.length === 0, gaps }; } for (let i = 0; i < criteria.length; i++) { const n = i + 1; const criterion = criteria[i]!; const row = rows.find( (r) => r.criterion === String(n) || r.criterion === `${n}.` || r.criterion.toLowerCase().includes(criterion.toLowerCase().slice(0, 40)) || criterion.toLowerCase().includes(r.criterion.toLowerCase().slice(0, 40)), ); if (!row) { gaps.push(`missing evidence row for criterion ${n}: ${truncate(criterion, 80)}`); continue; } const artifact = row.artifact.trim(); if (!artifact || artifact === "—" || artifact === "-") { gaps.push(`empty artifact for criterion ${n}`); continue; } // Check path-like artifacts and json: assertions. for (const part of artifact.split(/\s+\+\s+|\s*,\s*/)) { const p = part.trim(); if (!p) continue; const jsonAssert = parseJsonAssertion(p); if (jsonAssert) { gaps.push( ...checkJsonAssertion(jsonAssert, n, input.fileExists, input.readText), ); continue; } if (looksLikePath(p) && !input.fileExists(p)) { gaps.push(`artifact missing on disk for criterion ${n}: ${p}`); } } } return { ok: gaps.length === 0, gaps }; } export interface JsonAssertion { filePath: string; jsonPath: string; expected: string; raw: string; } /** Parse `json:/abs/path#a.b.c=value` or `json:rel/path#key=value`. */ export function parseJsonAssertion(artifact: string): JsonAssertion | null { const m = JSON_ASSERT_RE.exec(artifact.trim()); if (!m) return null; return { filePath: m[1]!.trim(), jsonPath: m[2]!.trim(), expected: m[3]!, raw: artifact.trim(), }; } function checkJsonAssertion( assert: JsonAssertion, criterionN: number, fileExists: (path: string) => boolean, readText?: (path: string) => string | null, ): string[] { const { filePath, jsonPath, expected, raw } = assert; if (!fileExists(filePath)) { return [`artifact missing on disk for criterion ${criterionN}: ${filePath} (${raw})`]; } if (!readText) { return [ `json assertion failed for criterion ${criterionN}: no file reader available (${raw})`, ]; } let text: string | null; try { text = readText(filePath); } catch { text = null; } if (text == null) { return [`json assertion failed for criterion ${criterionN}: cannot read ${filePath}`]; } let data: unknown; try { data = JSON.parse(text); } catch { return [`json assertion failed for criterion ${criterionN}: invalid JSON in ${filePath}`]; } const actual = getByDottedPath(data, jsonPath); if (actual === undefined) { return [ `json assertion failed for criterion ${criterionN}: path ${jsonPath} missing in ${filePath}`, ]; } if (!valuesMatch(actual, expected)) { return [ `json assertion failed for criterion ${criterionN}: ${jsonPath} expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`, ]; } return []; } /** Resolve `a.b.c` against a JSON value. */ export function getByDottedPath(data: unknown, path: string): unknown { const parts = path.split(".").filter(Boolean); let cur: unknown = data; for (const part of parts) { if (cur == null || typeof cur !== "object") return undefined; cur = (cur as Record)[part]; } return cur; } function valuesMatch(actual: unknown, expectedRaw: string): boolean { // Try JSON-parse expected so numbers/bools/null/objects work. let expected: unknown = expectedRaw; try { expected = JSON.parse(expectedRaw); } catch { // keep as string } if (typeof actual === "string" && typeof expected === "string") { return actual === expected; } if (typeof actual === "number" || typeof actual === "boolean" || actual === null) { return actual === expected || String(actual) === expectedRaw; } try { return JSON.stringify(actual) === JSON.stringify(expected); } catch { return String(actual) === expectedRaw; } } /** * True when plan.md is still the structural expand skeleton (or equals it). * Used so completed:true cannot pass before a real plan rewrite. */ export function isDraftEquivalentPlan(planMarkdown: string, objective?: string): boolean { const md = normalizePlan(planMarkdown); if (!md) return true; if (objective?.trim()) { const skeleton = normalizePlan(expandToPlanMarkdown(objective.trim())); if (md === skeleton) return true; } // Headline → try expand equality when objective not supplied. const headline = md.match(/^#\s+Plan:\s*(.+)$/m)?.[1]?.trim(); if (headline) { try { const skeleton = normalizePlan(expandToPlanMarkdown(headline)); if (md === skeleton) return true; } catch { /* empty objective edge */ } } // Marker-based detection for lightly edited copies of the structural template. return hasStructuralDraftMarkers(md); } /** Phrases unique to expandToPlan / expandGoal defaults. */ const STRUCTURAL_MARKERS = [ 'The work described by "', "Changes are committed or staged with a clear summary of what changed.", "No known regressions introduced by the change (relevant checks pass).", "Clarify scope and constraints for:", "Inspect the relevant codebase / environment", "Implement the smallest change that satisfies the objective", "Verify with tests or a concrete manual check", "Summarize results and remaining risks", "Unrelated refactors or drive-by cleanups", "Scope beyond the stated objective without an explicit plan deviation", "Work is limited to what is needed to achieve:", "Existing project conventions and tooling are preferred over new stacks", "Confirm or edit the objective and acceptance criteria, then start with subgoal 1.", "Resolve: What does done look like in one sentence?", "Run relevant automated tests or listed manual checks; all must pass.", "Evidence index lists an artifact row per acceptance criterion.", "No open blocking risks remain for the stated objective.", "Incomplete verification that ", ] as const; /** * Draft if enough structural-template markers remain (not a real rewrite). * Threshold avoids false positives on plans that reuse a single stock phrase. */ function hasStructuralDraftMarkers(md: string): boolean { let hits = 0; for (const marker of STRUCTURAL_MARKERS) { if (md.includes(marker)) hits += 1; } // Full skeleton hits many; a real rewrite should drop almost all. return hits >= 5; } function normalizePlan(md: string): string { return md.replace(/\r\n/g, "\n").trim(); } function extractCriteria(planMarkdown: string): string[] { const md = planMarkdown.replace(/\r\n/g, "\n"); const re = /^##\s+Acceptance criteria\s*$/im; const m = re.exec(md); if (!m || m.index === undefined) return []; const start = m.index + m[0].length; const rest = md.slice(start); const next = /^##\s+/im.exec(rest); const body = next ? rest.slice(0, next.index) : rest; return body .split("\n") .map((l) => l.trim()) .filter((l) => l.startsWith("- ")) .map((l) => l.slice(2).replace(/^\[[ xX]\]\s*/, "").trim()) .filter((t) => t && t !== "(none)"); } function parseEvidenceRows( md: string, ): Array<{ criterion: string; artifact: string; notes?: string }> { const rows: Array<{ criterion: string; artifact: string; notes?: string }> = []; for (const line of md.split("\n")) { const trimmed = line.trim(); if (!trimmed.startsWith("|")) continue; if (/^\|\s*-+/.test(trimmed)) continue; const parts = trimmed .replace(/^\|/, "") .replace(/\|$/, "") .split("|") .map((c) => c.trim()); if (parts.length < 2) continue; const head = parts[0]!.toLowerCase(); if (head === "criterion" || head.startsWith("---")) continue; rows.push({ criterion: parts[0]!, artifact: parts[1]!, notes: parts[2], }); } return rows; } function looksLikePath(p: string): boolean { if (!p || p.includes(" ")) return false; if (p.startsWith("http://") || p.startsWith("https://")) return false; if (p.toLowerCase().startsWith("json:")) return false; return ( p.startsWith("./") || p.startsWith("../") || p.startsWith(".pi/") || p.startsWith("src/") || p.startsWith("tests/") || p.startsWith("scratch/") || /\.[a-zA-Z0-9]{1,8}$/.test(p) ); } function truncate(s: string, n: number): string { if (s.length <= n) return s; return `${s.slice(0, n - 1)}…`; }