From e58e138457d73a16c3c12c1ef58ae43e6d6f55c7 Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Sun, 10 May 2026 08:49:45 +0200 Subject: [PATCH] =?UTF-8?q?feat(db):=20DB-only=20UAT=20verdicts=20?= =?UTF-8?q?=E2=80=94=20backfill=20on=20open,=20write=20on=20ASSESSMENT=20s?= =?UTF-8?q?ave,=20no=20file=20fallbacks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sf-db.js: add backfillUatVerdicts(basePath) that scans ASSESSMENT/UAT_RESULT files for slices with no uat_verdict in DB and populates them on open - dynamic-tools.js: call backfillUatVerdicts after openDatabase succeeds so all 3 repos with existing verdict files are covered on next launch - workflow-tool-executors.js: call setSliceUatVerdict when saving ASSESSMENT at slice scope so future verdicts are written directly to DB - workflow-helpers.js: remove all file fallbacks from checkNeedsRunUat; verdict check is DB-only (backfill guarantees DB is populated on open) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../extensions/sf/bootstrap/dynamic-tools.js | 6 +- src/resources/extensions/sf/sf-db.js | 95 +++++++++++++++++++ .../sf/tools/workflow-tool-executors.js | 18 ++++ .../extensions/sf/workflow-helpers.js | 26 +---- 4 files changed, 123 insertions(+), 22 deletions(-) diff --git a/src/resources/extensions/sf/bootstrap/dynamic-tools.js b/src/resources/extensions/sf/bootstrap/dynamic-tools.js index be0231edb..c5052934b 100644 --- a/src/resources/extensions/sf/bootstrap/dynamic-tools.js +++ b/src/resources/extensions/sf/bootstrap/dynamic-tools.js @@ -81,7 +81,10 @@ export async function ensureDbOpen(basePath = process.cwd()) { // Open existing DB file (may be at project root for worktrees) if (existsSync(dbPath)) { const opened = db.openDatabase(dbPath); - if (opened) setLogBasePath(projectRoot); + if (opened) { + setLogBasePath(projectRoot); + try { db.backfillUatVerdicts(projectRoot); } catch { /* non-fatal */ } + } return opened; } // No DB file — create + migrate from Markdown if .sf/ has content @@ -102,6 +105,7 @@ export async function ensureDbOpen(basePath = process.cwd()) { `ensureDbOpen auto-migration failed: ${err.message}`, ); } + try { db.backfillUatVerdicts(projectRoot); } catch { /* non-fatal */ } } return opened; } diff --git a/src/resources/extensions/sf/sf-db.js b/src/resources/extensions/sf/sf-db.js index 3badc9bb0..37d80ddf0 100644 --- a/src/resources/extensions/sf/sf-db.js +++ b/src/resources/extensions/sf/sf-db.js @@ -2114,6 +2114,12 @@ function migrateSchema(db) { "observability_impact", `ALTER TABLE slices ADD COLUMN observability_impact TEXT NOT NULL DEFAULT ''`, ); + ensureColumn( + db, + "slices", + "uat_verdict", + `ALTER TABLE slices ADD COLUMN uat_verdict TEXT DEFAULT NULL`, + ); ensureColumn( db, "tasks", @@ -4550,6 +4556,95 @@ export function updateSliceStatus(milestoneId, sliceId, status, completedAt) { ":id": sliceId, }); } +/** + * Store the UAT verdict for a slice. Called when an ASSESSMENT or UAT_RESULT + * file is written so the DB is the canonical source for verdict checks. + */ +export function setSliceUatVerdict(milestoneId, sliceId, verdict) { + if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open"); + currentDb + .prepare( + `UPDATE slices SET uat_verdict = :verdict WHERE milestone_id = :mid AND id = :sid`, + ) + .run({ ":mid": milestoneId, ":sid": sliceId, ":verdict": verdict }); +} +/** + * Returns the stored UAT verdict for a slice, or null if not yet recorded. + */ +export function getSliceUatVerdict(milestoneId, sliceId) { + if (!currentDb) return null; + const row = currentDb + .prepare( + `SELECT uat_verdict FROM slices WHERE milestone_id = :mid AND id = :sid`, + ) + .get({ ":mid": milestoneId, ":sid": sliceId }); + return row?.uat_verdict ?? null; +} +/** + * Scan existing ASSESSMENT/UAT_RESULT files on disk and populate uat_verdict + * for slices that have no verdict recorded in the DB yet. + * + * Purpose: one-time migration path so that repos with pre-existing verdict + * files work without file fallbacks in checkNeedsRunUat — the DB becomes the + * sole source of truth immediately after open. + * + * Consumer: ensureDbOpen (dynamic-tools.js) after openDatabase succeeds. + */ +export function backfillUatVerdicts(basePath) { + if (!currentDb) return; + // Find all slices that have no verdict yet + const rows = currentDb + .prepare( + `SELECT milestone_id, id FROM slices WHERE uat_verdict IS NULL`, + ) + .all(); + if (!rows.length) return; + // Extract verdict from content — inline to avoid cross-module import at db layer + function parseVerdictFromContent(content) { + const fmMatch = content.match(/^---\n([\s\S]*?)\n---/); + if (fmMatch) { + const m = fmMatch[1].match(/verdict:\s*([\w-]+)/i); + if (m) { + let v = m[1].toLowerCase(); + if (v === "passed") v = "pass"; + return v; + } + return null; + } + const bodyMatch = content.match(/\*\*Verdict:?\*\*\s*(?:✅\s*)?(\w[\w-]*)/i); + if (bodyMatch) { + let v = bodyMatch[1].toLowerCase(); + if (v === "passed") v = "pass"; + return v; + } + return null; + } + const stmt = currentDb.prepare( + `UPDATE slices SET uat_verdict = :verdict WHERE milestone_id = :mid AND id = :sid`, + ); + for (const row of rows) { + const mid = row["milestone_id"]; + const sid = row["id"]; + const sliceDir = join(basePath, ".sf", "milestones", mid, "slices", sid); + const candidates = [ + join(sliceDir, `${sid}-ASSESSMENT.md`), + join(sliceDir, `${sid}-UAT_RESULT.md`), + ]; + for (const candidatePath of candidates) { + if (!existsSync(candidatePath)) continue; + try { + const content = readFileSync(candidatePath, "utf8"); + const verdict = parseVerdictFromContent(content); + if (verdict) { + stmt.run({ ":mid": mid, ":sid": sid, ":verdict": verdict }); + break; + } + } catch { + // Skip unreadable files + } + } + } +} export function setTaskSummaryMd(milestoneId, sliceId, taskId, md) { if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open"); currentDb diff --git a/src/resources/extensions/sf/tools/workflow-tool-executors.js b/src/resources/extensions/sf/tools/workflow-tool-executors.js index bac13c526..706475abf 100644 --- a/src/resources/extensions/sf/tools/workflow-tool-executors.js +++ b/src/resources/extensions/sf/tools/workflow-tool-executors.js @@ -13,9 +13,11 @@ import { getSliceTaskCounts, readTransaction, saveGateResult, + setSliceUatVerdict, } from "../sf-db.js"; import { invalidateStateCache } from "../state.js"; import { logError, logWarning } from "../workflow-logger.js"; +import { extractVerdict } from "../verdict-parser.js"; import { handleCompleteMilestone } from "./complete-milestone.js"; import { handleCompleteSlice } from "./complete-slice.js"; import { handleCompleteTask } from "./complete-task.js"; @@ -128,6 +130,22 @@ export async function executeSummarySave(params, basePath = process.cwd()) { }, basePath, ); + // Persist UAT verdict to DB when an ASSESSMENT is saved at slice scope. + // This makes checkNeedsRunUat DB-only — no file fallback needed. + if ( + params.artifact_type === "ASSESSMENT" && + params.slice_id && + !params.task_id + ) { + try { + const verdict = extractVerdict(params.content); + if (verdict) { + setSliceUatVerdict(params.milestone_id, params.slice_id, verdict); + } + } catch { + // Non-fatal — verdict check will still fall through to backfill + } + } // Terminal transition for research units: After successful RESEARCH artifact save, // research units must terminate or become unable to call planning/milestone-generation tools. // This prevents the dr-repo M008/S01 issue where research continued into planning. diff --git a/src/resources/extensions/sf/workflow-helpers.js b/src/resources/extensions/sf/workflow-helpers.js index 77b8d948a..ca0429f3f 100644 --- a/src/resources/extensions/sf/workflow-helpers.js +++ b/src/resources/extensions/sf/workflow-helpers.js @@ -13,7 +13,6 @@ import { join } from "node:path"; import { loadFile, parseContinue, parseSummary } from "./files.js"; import { resolveSliceFile } from "./paths.js"; import { isDbAvailable } from "./sf-db.js"; -import { hasVerdict } from "./verdict-parser.js"; /** * Escape regex special characters for safe use in RegExp. @@ -95,14 +94,14 @@ export async function checkNeedsReassessment(base, mid, _state, _prefs) { * - All slices are done (milestone complete path) * - uat_dispatch preference is not enabled * - No UAT file exists for the slice - * - UAT result file already exists (idempotent) + * - uat_verdict already recorded in DB (backfilled on open from any existing ASSESSMENT/UAT_RESULT) */ export async function checkNeedsRunUat(base, mid, _state, prefs) { // Check if UAT dispatch is enabled if (!prefs?.uat_dispatch) return null; try { - const { getMilestoneSlices } = await import("./sf-db.js"); + const { getMilestoneSlices, getSliceUatVerdict } = await import("./sf-db.js"); if (isDbAvailable()) { const slices = getMilestoneSlices(mid); if (slices.length > 0) { @@ -112,25 +111,10 @@ export async function checkNeedsRunUat(base, mid, _state, prefs) { const lastCompleted = completedSlices[completedSlices.length - 1]; const uatFile = resolveSliceFile(base, mid, lastCompleted.id, "UAT"); if (!uatFile || !existsSync(uatFile)) return null; - const resultFile = resolveSliceFile( - base, - mid, - lastCompleted.id, - "UAT_RESULT", - ); - if (resultFile && existsSync(resultFile)) return null; - // Also treat an ASSESSMENT file with a verdict as a completed UAT result. - const assessmentFile = resolveSliceFile( - base, - mid, - lastCompleted.id, - "ASSESSMENT", - ); - if (assessmentFile && existsSync(assessmentFile)) { - const assessContent = await loadFile(assessmentFile); - if (assessContent && hasVerdict(assessContent)) return null; - } + // DB-only: verdict backfilled on open from any existing ASSESSMENT/UAT_RESULT file + if (getSliceUatVerdict(mid, lastCompleted.id)) return null; const uatContent = await loadFile(uatFile); + const { hasVerdict } = await import("./verdict-parser.js"); const uatType = hasVerdict(uatContent) ? "verdict" : "narrative"; return { sliceId: lastCompleted.id, uatType }; }