feat(db): DB-only UAT verdicts — backfill on open, write on ASSESSMENT save, no file fallbacks

- 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>
This commit is contained in:
Mikael Hugo 2026-05-10 08:49:45 +02:00
parent 6c113be473
commit e58e138457
4 changed files with 123 additions and 22 deletions

View file

@ -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;
}

View file

@ -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

View file

@ -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.

View file

@ -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 };
}