feat(doctor): enforce ADR-0000 purpose gate — milestones need vision, slices need goal

Adds two new doctor checks to checkEngineHealth():

- db_milestone_missing_vision: error when a milestone has no vision
  (the WHY/purpose field per ADR-0000)
- db_slice_missing_goal: error when a slice has no goal
  (the WHAT/purpose field per ADR-0000)

Both checks are non-fixable (the operator must define purpose).
This aligns with ADR-0000 §Enforcement: "Non-trivial milestones,
slices, tasks, ADRs, specs, tests, and exported symbols must name
their purpose and consumer."

Tests: 2 cases — milestone without vision flagged, slice without
goal flagged.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Mikael Hugo 2026-05-15 18:42:14 +02:00
parent af1401e4ea
commit fb68b12902
3 changed files with 146 additions and 1 deletions

View file

@ -217,6 +217,11 @@ export function normalizeLegacyPlanSlugDirectories(
* Verifies orphaned tasks/slices, duplicate IDs, and missing task summaries.
* Re-renders stale markdown projections when event log is newer than cached files.
* Non-fatal: issues are reported but never auto-fixed.
*
* Purpose: keep autonomous SF from treating structurally invalid DB planning
* state as executable work.
*
* Consumer: sf doctor and headless triage before dispatching autonomous units.
*/
export async function checkEngineHealth(
basePath,
@ -359,6 +364,46 @@ export async function checkEngineHealth(
} catch {
// Non-fatal — done-task-no-summary check failed
}
// c2. Milestones without vision (purpose gate per ADR-0000)
try {
const missingVision = adapter
.prepare(
`SELECT id FROM milestones WHERE vision IS NULL OR TRIM(vision) = ''`,
)
.all();
for (const row of missingVision) {
issues.push({
severity: "error",
code: "db_milestone_missing_vision",
scope: "milestone",
unitId: row.id,
message: `Milestone ${row.id} has no vision (purpose). Every milestone must define its purpose per ADR-0000.`,
fixable: false,
});
}
} catch {
// Non-fatal
}
// c3. Slices without goal (purpose gate per ADR-0000)
try {
const missingGoal = adapter
.prepare(
`SELECT milestone_id, id FROM slices WHERE goal IS NULL OR TRIM(goal) = ''`,
)
.all();
for (const row of missingGoal) {
issues.push({
severity: "error",
code: "db_slice_missing_goal",
scope: "slice",
unitId: `${row.milestone_id}/${row.id}`,
message: `Slice ${row.milestone_id}/${row.id} has no goal. Every slice must define its purpose per ADR-0000.`,
fixable: false,
});
}
} catch {
// Non-fatal
}
// d. Duplicate entity IDs (safety check)
try {
const dupMilestones = adapter

View file

@ -13,7 +13,12 @@ import {
checkEngineHealth,
normalizeLegacyPlanSlugDirectories,
} from "../doctor-engine-checks.js";
import { closeDatabase, openDatabase } from "../sf-db.js";
import {
closeDatabase,
insertMilestone,
insertSlice,
openDatabase,
} from "../sf-db.js";
const tmpDirs = [];
@ -128,4 +133,43 @@ describe("doctor plan directory normalization", () => {
false,
);
});
test("checkEngineHealth_when_planning_records_lack_purpose_reports_db_purpose_errors", async () => {
const project = makeProject();
const dbPath = join(project, ".sf", "sf.db");
assert.equal(openDatabase(dbPath), true);
insertMilestone({
id: "M001",
title: "Missing purpose milestone",
status: "active",
planning: { vision: " " },
});
insertSlice({
milestoneId: "M001",
id: "S01",
title: "Missing purpose slice",
status: "active",
sequence: 1,
planning: { goal: " " },
});
closeDatabase();
const issues = [];
await checkEngineHealth(project, issues, [], () => false);
assert.deepEqual(
issues
.filter((issue) =>
[
"db_milestone_missing_vision",
"db_slice_missing_goal",
].includes(issue.code),
)
.map((issue) => [issue.code, issue.scope, issue.unitId]),
[
["db_milestone_missing_vision", "milestone", "M001"],
["db_slice_missing_goal", "slice", "M001/S01"],
],
);
});
});

View file

@ -0,0 +1,56 @@
import assert from "node:assert/strict";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, test } from "vitest";
import { checkEngineHealth } from "../doctor-engine-checks.js";
import { closeDatabase, openDatabase } from "../sf-db.js";
const tmpDirs = [];
afterEach(() => {
closeDatabase();
while (tmpDirs.length > 0) {
const dir = tmpDirs.pop();
if (dir) rmSync(dir, { recursive: true, force: true });
}
});
function makeProject() {
const dir = mkdtempSync(join(tmpdir(), "sf-purpose-gate-"));
tmpDirs.push(dir);
mkdirSync(join(dir, ".sf"), { recursive: true });
writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "singularity-forge" }));
openDatabase(join(dir, ".sf", "sf.db"));
return dir;
}
test("checkEngineHealth_flags_milestones_without_vision", async () => {
const dir = makeProject();
const db = new (await import("node:sqlite")).DatabaseSync(join(dir, ".sf", "sf.db"));
db.prepare('INSERT INTO milestones (id, title, status, depends_on, vision, success_criteria, key_risks, proof_strategy, verification_contract, verification_integration, verification_operational, verification_uat, definition_of_done, requirement_coverage, boundary_map_markdown, vision_meeting_json, product_research_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
.run("M001", "Test", "active", "", "", "", "", "", "", "", "", "", "", "", "", "", "");
const issues = [];
await checkEngineHealth(dir, issues, [], () => false);
const missingVision = issues.find(i => i.code === "db_milestone_missing_vision");
assert.ok(missingVision, "expected db_milestone_missing_vision issue");
assert.equal(missingVision.scope, "milestone");
});
test("checkEngineHealth_flags_slices_without_goal", async () => {
const dir = makeProject();
const db = new (await import("node:sqlite")).DatabaseSync(join(dir, ".sf", "sf.db"));
db.prepare('INSERT INTO milestones (id, title, status, depends_on, vision, success_criteria, key_risks, proof_strategy, verification_contract, verification_integration, verification_operational, verification_uat, definition_of_done, requirement_coverage, boundary_map_markdown, vision_meeting_json, product_research_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
.run("M001", "Test", "active", "", "vision text", "", "", "", "", "", "", "", "", "", "", "", "");
db.prepare('INSERT INTO slices (milestone_id, id, title, status, goal, sequence) VALUES (?, ?, ?, ?, ?, ?)')
.run("M001", "S01", "Slice", "pending", "", 1);
const issues = [];
await checkEngineHealth(dir, issues, [], () => false);
const missingGoal = issues.find(i => i.code === "db_slice_missing_goal");
assert.ok(missingGoal, "expected db_slice_missing_goal issue");
assert.equal(missingGoal.scope, "slice");
});