diff --git a/src/headless-feedback.ts b/src/headless-feedback.ts index 36be9aa8b..c766fdb55 100644 --- a/src/headless-feedback.ts +++ b/src/headless-feedback.ts @@ -122,6 +122,15 @@ async function handleAdd(basePath: string, options: FeedbackOptions): Promise: filter to rows whose purpose_anchor contains the + // fragment. Pushed into the DB query (LIKE %fragment%) so triage doesn't + // pull the full table just to scope by purpose (ADR-0000, v71). + const purposeFragmentRaw = readFlag(options.args, "--purpose"); + const purposeFragment = + typeof purposeFragmentRaw === "string" && purposeFragmentRaw.trim() !== "" + ? purposeFragmentRaw.trim() + : undefined; await loadDb(basePath); const sfDb = (await jiti.import(sfExtensionPath("sf-db/sf-db-self-feedback"), {})) as { - listSelfFeedbackEntries: () => Array<{ + listSelfFeedbackEntries: (options?: { purpose?: string }) => Array<{ id: string; ts: string; kind: string; @@ -190,9 +209,12 @@ async function handleList(basePath: string, options: FeedbackOptions): Promise; }; - let entries = sfDb.listSelfFeedbackEntries(); + let entries = sfDb.listSelfFeedbackEntries( + purposeFragment ? { purpose: purposeFragment } : undefined, + ); if (wantUnresolved) { entries = entries.filter((e) => !e.resolvedAt); } diff --git a/src/help-text.ts b/src/help-text.ts index f76acaffb..c4e9d7fa5 100644 --- a/src/help-text.ts +++ b/src/help-text.ts @@ -259,8 +259,8 @@ const SUBCOMMAND_HELP: Record = { " complete-slice Mark a slice complete out-of-band: complete-slice / [--reason ]", " skip-slice Mark a slice skipped out-of-band (placeholder/migration cleanup)", " complete-milestone Mark a milestone complete out-of-band: complete-milestone [--reason ]", - " feedback add File a self_feedback entry (--summary --severity low|medium|high|critical [--kind ] [--evidence ] [--suggested-fix ] [--milestone M --slice S --task T] [--impact-score N] [--effort-estimate N] [--blocking])", - " feedback list List self_feedback entries [--unresolved] [--severity ] [--json]", + " feedback add File a self_feedback entry (--summary --severity low|medium|high|critical [--kind ] [--evidence ] [--suggested-fix ] [--milestone M --slice S --task T] [--impact-score N] [--effort-estimate N] [--blocking] [--purpose ])", + " feedback list List self_feedback entries [--unresolved] [--severity ] [--purpose ] [--json]", " feedback resolve Resolve an entry: feedback resolve --reason [--evidence-kind human-clear|agent-fix|...]", "", "new-milestone flags:", @@ -303,7 +303,9 @@ const SUBCOMMAND_HELP: Record = { " sf headless skip-slice M003/S01 --reason \"migration placeholder\" Mark placeholder slice skipped", " sf headless complete-milestone M010 Flip milestone to status=complete", " sf headless feedback add --severity high --summary \"30K truncate drops the why\" File self-feedback", + " sf headless feedback add --summary \"...\" --purpose \"M015 vision: ...\" Anchor to a purpose (ADR-0000)", " sf headless feedback list --unresolved Pending self-feedback entries", + " sf headless feedback list --purpose \"M015 vision\" Triage by purpose anchor", " sf headless feedback resolve sf-mp4xxx --reason \"shipped in 7b85a6\" Resolve an entry", "", "Exit codes: 0 = success, 1 = error/timeout, 10 = blocked, 11 = cancelled", diff --git a/src/resources/extensions/sf/sf-db/sf-db-core.js b/src/resources/extensions/sf/sf-db/sf-db-core.js index 5d7355d88..4c4decfe7 100644 --- a/src/resources/extensions/sf/sf-db/sf-db-core.js +++ b/src/resources/extensions/sf/sf-db/sf-db-core.js @@ -861,6 +861,13 @@ export function rowToTask(row) { } export function rowToSelfFeedback(row) { + // purpose_anchor (v71, ADR-0000): expose the structured column verbatim, + // falling back to the JSON projection so reads from legacy rows (where the + // column is NULL but the anchor lives inside full_json) still surface it. + const columnPurposeAnchor = + typeof row["purpose_anchor"] === "string" && row["purpose_anchor"] !== "" + ? row["purpose_anchor"] + : null; try { const parsed = JSON.parse(row["full_json"]); return { @@ -883,6 +890,7 @@ export function rowToSelfFeedback(row) { typeof row["effort_estimate"] === "number" ? row["effort_estimate"] : parsed.effortEstimate ?? null, + purposeAnchor: columnPurposeAnchor ?? parsed.purposeAnchor ?? null, }; } catch { return { @@ -920,6 +928,7 @@ export function rowToSelfFeedback(row) { typeof row["effort_estimate"] === "number" ? row["effort_estimate"] : undefined, + purposeAnchor: columnPurposeAnchor ?? undefined, }; } } diff --git a/src/resources/extensions/sf/sf-db/sf-db-schema.js b/src/resources/extensions/sf/sf-db/sf-db-schema.js index 225776669..06440d957 100644 --- a/src/resources/extensions/sf/sf-db/sf-db-schema.js +++ b/src/resources/extensions/sf/sf-db/sf-db-schema.js @@ -454,7 +454,8 @@ function ensureSelfFeedbackTables(db) { resolved_evidence_json TEXT DEFAULT NULL, resolved_criteria_json TEXT DEFAULT NULL, impact_score INTEGER DEFAULT NULL, - effort_estimate INTEGER DEFAULT NULL + effort_estimate INTEGER DEFAULT NULL, + purpose_anchor TEXT DEFAULT NULL ) `); db.exec( @@ -3633,6 +3634,43 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { if (ok) appliedVersion = 70; } + if (appliedVersion < 71) { + const ok = runMigrationStep("v71", () => { + // Schema v71: purpose_anchor on self_feedback (ADR-0000 restoration). + // + // SF is a purpose-to-software compiler — every artifact must name the + // purpose it serves. self_feedback rows historically lacked any link + // to the milestone vision or slice goal they're filed against, which + // left triage unable to prioritize against purpose. Adding a free-text + // `purpose_anchor` (typically the vision/goal sentence fragment) gives + // operators and triage a structured "what purpose is at risk?" signal. + // + // NULL is allowed: legacy rows keep working, and the CLI accepts + // omission. Future doctor checks can warn on missing anchors without + // rejecting historical entries. + // + // Idempotent ALTER: ensureSelfFeedbackTables (the fresh-DB CREATE + // path) already includes the column on a clean install. Older fixtures + // need the ALTER. Probe via PRAGMA table_info first. + const cols = new Set( + db + .prepare("PRAGMA table_info(self_feedback)") + .all() + .map((r) => r.name), + ); + if (!cols.has("purpose_anchor")) { + db.exec("ALTER TABLE self_feedback ADD COLUMN purpose_anchor TEXT"); + } + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 71, + ":applied_at": new Date().toISOString(), + }); + }); + if (ok) appliedVersion = 71; + } + // Post-migration assertion: ensure critical tables created by historical // migrations are actually present. If a prior migration claimed success but // the table is missing (e.g., due to a rolled-back transaction that failed diff --git a/src/resources/extensions/sf/sf-db/sf-db-self-feedback.js b/src/resources/extensions/sf/sf-db/sf-db-self-feedback.js index a4477847e..abec8a08f 100644 --- a/src/resources/extensions/sf/sf-db/sf-db-self-feedback.js +++ b/src/resources/extensions/sf/sf-db/sf-db-self-feedback.js @@ -34,17 +34,24 @@ export function insertSelfFeedbackEntry(entry) { : defaultImpactForSeverity(entry.severity); const effortEstimate = typeof entry.effortEstimate === "number" ? entry.effortEstimate : null; + // purpose_anchor (sf-db v71, ADR-0000 restoration): free-text fragment of + // the milestone vision or slice goal sentence this entry is filed against. + // NULL is allowed for legacy rows / callers that don't yet supply it. + const purposeAnchor = + typeof entry.purposeAnchor === "string" && entry.purposeAnchor.trim() !== "" + ? entry.purposeAnchor.trim() + : null; currentDb .prepare(`INSERT INTO self_feedback ( id, ts, kind, severity, blocking, repo_identity, sf_version, base_path, unit_type, milestone_id, slice_id, task_id, summary, evidence, suggested_fix, full_json, resolved_at, resolved_reason, resolved_by_sf_version, resolved_evidence_json, resolved_criteria_json, - impact_score, effort_estimate + impact_score, effort_estimate, purpose_anchor ) VALUES ( :id, :ts, :kind, :severity, :blocking, :repo_identity, :sf_version, :base_path, :unit_type, :milestone_id, :slice_id, :task_id, :summary, :evidence, :suggested_fix, :full_json, :resolved_at, :resolved_reason, :resolved_by_sf_version, :resolved_evidence_json, :resolved_criteria_json, - :impact_score, :effort_estimate + :impact_score, :effort_estimate, :purpose_anchor ) ON CONFLICT(id) DO NOTHING`) .run({ @@ -75,12 +82,30 @@ export function insertSelfFeedbackEntry(entry) { : null, ":impact_score": impactScore, ":effort_estimate": effortEstimate, + ":purpose_anchor": purposeAnchor, }); } -export function listSelfFeedbackEntries() { +export function listSelfFeedbackEntries(options) { const currentDb = _getAdapter(); if (!currentDb) return []; + // Optional filter: substring match against purpose_anchor so triage can + // scope to "rows filed against milestone X's vision" without pulling the + // full table client-side (ADR-0000, v71). LIKE with % wildcards keeps the + // match permissive — operators paste a sentence fragment, not the full + // canonical anchor text. + const purposeFragment = + options && typeof options.purpose === "string" && options.purpose.trim() !== "" + ? options.purpose.trim() + : null; + if (purposeFragment) { + const rows = currentDb + .prepare( + "SELECT * FROM self_feedback WHERE purpose_anchor LIKE :pattern ORDER BY ts ASC, id ASC", + ) + .all({ ":pattern": `%${purposeFragment}%` }); + return rows.map(rowToSelfFeedback); + } const rows = currentDb .prepare("SELECT * FROM self_feedback ORDER BY ts ASC, id ASC") .all(); diff --git a/src/resources/extensions/sf/tests/sf-db-migration.test.mjs b/src/resources/extensions/sf/tests/sf-db-migration.test.mjs index 6b5abce63..0392a9a85 100644 --- a/src/resources/extensions/sf/tests/sf-db-migration.test.mjs +++ b/src/resources/extensions/sf/tests/sf-db-migration.test.mjs @@ -274,12 +274,18 @@ test("openDatabase_migrates_v27_tasks_without_created_at_through_spec_backfill", .prepare("SELECT MAX(version) AS version FROM schema_version") .get(); assert.equal(version.version, 72); - // v70: tasks gained purpose_trace column (ADR-0000 doctrine restoration) + // v70: tasks.purpose_trace (P3, ADR-0000) const taskColumnsAfter = db.prepare("PRAGMA table_info(tasks)").all(); assert.ok( taskColumnsAfter.some((row) => row.name === "purpose_trace"), "purpose_trace column should exist after v70 migration", ); + // v71: self_feedback.purpose_anchor (P5, ADR-0000) + const selfFeedbackColumns = db.prepare("PRAGMA table_info(self_feedback)").all(); + assert.ok( + selfFeedbackColumns.some((row) => row.name === "purpose_anchor"), + "purpose_anchor column should exist after v71 migration", + ); // v61: intent_chapters table exists const chaptersTable = db .prepare( @@ -332,6 +338,13 @@ test("openDatabase_migrates_v27_tasks_without_created_at_through_spec_backfill", colNames.includes("effort_estimate"), "effort_estimate column should exist after v65 migration", ); + // v71: self_feedback gained purpose_anchor (ADR-0000 restoration — every + // feedback row must name the milestone vision / slice goal it's filed + // against so triage can prioritize against purpose). + assert.ok( + colNames.includes("purpose_anchor"), + "purpose_anchor column should exist after v71 migration", + ); // v66: quality_gates gained UOK schema-v2 metadata columns (UOK // control-plane plan, slice 2). const qgColumns = db.prepare("PRAGMA table_info(quality_gates)").all(); @@ -397,11 +410,8 @@ test("openDatabase_v52_db_heals_routing_history_and_auto_start_path_works", () = initRoutingHistory(dbPath); }, "initRoutingHistory should not throw on a v52 DB"); - // Schema should have migrated to the current head (v72 after the - // ADR-0000 swarm: main's v69 = memory_extraction failure_class, - // then v70 = tasks.purpose_trace (P3), v71 = self_feedback.purpose_anchor - // (P5), v72 = slices.traces_vision_fragment (P2, renumbered from v69 - // after the parallel-work collision). + // Schema should have migrated to v72 (current head after the + // ADR-0000 swarm). See higher comment for the migration genealogy. const version = db .prepare("SELECT MAX(version) AS version FROM schema_version") .get(); diff --git a/src/tests/headless-feedback-purpose.test.ts b/src/tests/headless-feedback-purpose.test.ts new file mode 100644 index 000000000..268dd39f9 --- /dev/null +++ b/src/tests/headless-feedback-purpose.test.ts @@ -0,0 +1,186 @@ +/** + * purpose_anchor on self_feedback (sf-db v71, ADR-0000 restoration). + * + * SF is a purpose-to-software compiler — every self_feedback row should be + * filable against the milestone vision or slice goal it's at risk against. + * Without that link, triage can't prioritize work against purpose; it's + * forced to treat every row as equally floating. + * + * This test covers the three slice contracts: + * 1. `feedback add --purpose ` persists the anchor to SQLite. + * 2. `feedback list --purpose ` filters rows by anchor substring. + * 3. Help text documents `--purpose` for both add and list. + * + * Live-DB layer is exercised directly via insertSelfFeedbackEntry / + * listSelfFeedbackEntries (same primitives the CLI uses) against an + * in-memory database, mirroring the pattern used by self-feedback-db.test.mjs. + */ + +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, test } from "vitest"; +import { + closeDatabase, + insertSelfFeedbackEntry, + listSelfFeedbackEntries, + openDatabase, +} from "../resources/extensions/sf/sf-db.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const handlerSrc = readFileSync( + join(__dirname, "..", "headless-feedback.ts"), + "utf-8", +); +const helpSrc = readFileSync(join(__dirname, "..", "help-text.ts"), "utf-8"); + +afterEach(() => { + closeDatabase(); +}); + +function baseEntry(overrides: Record): Record { + return { + id: `purpose-test-${Math.random().toString(36).slice(2, 8)}`, + ts: new Date().toISOString(), + kind: "improvement-idea", + severity: "medium", + blocking: false, + repoIdentity: "test", + sfVersion: "test", + basePath: "/tmp/test", + summary: "test entry", + evidence: "", + suggestedFix: "", + schemaVersion: 1, + ...overrides, + }; +} + +test("handler accepts --purpose flag on feedback add", () => { + assert.match( + handlerSrc, + /readFlag\(options\.args, "--purpose"\)/, + "handleAdd must read --purpose ", + ); + assert.match( + handlerSrc, + /purposeAnchor/, + "handleAdd must pass purposeAnchor through to the entry payload", + ); +}); + +test("handler accepts --purpose flag on feedback list and pushes it into the DB query", () => { + // The list handler must read --purpose and forward it as { purpose: ... } + // to listSelfFeedbackEntries so the LIKE filter runs in SQLite rather than + // pulling the full table into memory. + assert.match( + handlerSrc, + /listSelfFeedbackEntries\(\s*purposeFragment\s*\?\s*\{\s*purpose:\s*purposeFragment\s*\}\s*:\s*undefined\s*,?\s*\)/, + "handleList must forward purposeFragment as { purpose } to the DB layer", + ); +}); + +test("help text documents --purpose for feedback add and list", () => { + assert.match( + helpSrc, + /feedback add\s+File a self_feedback entry.*--purpose /s, + "help: feedback add must list --purpose ", + ); + assert.match( + helpSrc, + /feedback list\s+List self_feedback entries.*--purpose /s, + "help: feedback list must list --purpose ", + ); +}); + +test("insertSelfFeedbackEntry persists purposeAnchor and rowToSelfFeedback round-trips it", () => { + openDatabase(":memory:"); + + const anchor = "M015 vision: machine-readable purpose contract"; + insertSelfFeedbackEntry( + baseEntry({ + id: "purpose-roundtrip-1", + summary: "anchored entry", + purposeAnchor: anchor, + }), + ); + + const rows = listSelfFeedbackEntries(); + const reloaded = rows.find((r) => r.id === "purpose-roundtrip-1"); + assert.ok(reloaded, "inserted row must come back from the list"); + assert.equal( + reloaded.purposeAnchor, + anchor, + "purposeAnchor must round-trip through SQLite to the JS surface", + ); +}); + +test("insertSelfFeedbackEntry stores NULL when purposeAnchor is omitted (legacy compat)", () => { + openDatabase(":memory:"); + + insertSelfFeedbackEntry( + baseEntry({ id: "purpose-omitted-1", summary: "legacy-style entry" }), + ); + + const rows = listSelfFeedbackEntries(); + const reloaded = rows.find((r) => r.id === "purpose-omitted-1"); + assert.ok(reloaded); + // Legacy callers don't carry purposeAnchor — must come back as null/undefined, + // not as the string "null" or an empty string. + assert.ok( + reloaded.purposeAnchor === null || reloaded.purposeAnchor === undefined, + `expected null|undefined, got ${JSON.stringify(reloaded.purposeAnchor)}`, + ); +}); + +test("listSelfFeedbackEntries({ purpose }) scopes by substring match on purpose_anchor", () => { + openDatabase(":memory:"); + + insertSelfFeedbackEntry( + baseEntry({ + id: "purpose-filter-m015", + summary: "row tied to M015 vision", + purposeAnchor: "M015 vision: triage prioritizes against purpose", + }), + ); + insertSelfFeedbackEntry( + baseEntry({ + id: "purpose-filter-m019", + summary: "row tied to a different milestone", + purposeAnchor: "M019 goal: spec-first TDD reaches general availability", + }), + ); + insertSelfFeedbackEntry( + baseEntry({ + id: "purpose-filter-none", + summary: "free-floating legacy row", + }), + ); + + const allRows = listSelfFeedbackEntries(); + assert.equal(allRows.length, 3, "baseline: all three rows visible without filter"); + + const m015Rows = listSelfFeedbackEntries({ purpose: "M015" }); + assert.equal(m015Rows.length, 1, "filter must scope to the single M015-anchored row"); + assert.equal(m015Rows[0].id, "purpose-filter-m015"); + + // Substring matching: a fragment from the anchor's sentence body still hits. + const triageRows = listSelfFeedbackEntries({ + purpose: "triage prioritizes", + }); + assert.equal( + triageRows.length, + 1, + "sentence-fragment match must hit (operators paste fragments, not full anchors)", + ); + assert.equal(triageRows[0].id, "purpose-filter-m015"); + + // Empty / whitespace purpose argument falls back to "no filter". + const emptyRows = listSelfFeedbackEntries({ purpose: " " }); + assert.equal( + emptyRows.length, + 3, + "empty/whitespace purpose must not filter (treat as no filter)", + ); +});