feat(self-feedback): purpose_anchor on entries (ADR-0000 restoration, v71)

SF is a purpose-to-software compiler — every self_feedback row must name
the milestone vision or slice goal it's filed against, so triage can
prioritize against purpose rather than treating each row as floating.

  - Schema v71 ALTERs self_feedback ADD COLUMN purpose_anchor TEXT.
    NULL allowed for legacy rows; fresh-DB CREATE includes the column.
  - sf-db-self-feedback.js: insertSelfFeedbackEntry accepts purposeAnchor
    (camelCase), stored as :purpose_anchor; listSelfFeedbackEntries({purpose})
    pushes a LIKE %fragment% filter into the DB layer so triage doesn't
    have to pull the full table.
  - rowToSelfFeedback exposes purposeAnchor, falling back to the JSON
    projection for legacy rows where the column is NULL.
  - headless-feedback CLI: `feedback add --purpose <fragment>` persists
    the anchor; `feedback list --purpose <fragment>` filters by it.
    Omission stays valid — restoration is additive, not breaking.
  - help-text + migration test updated; new vitest covers add/list
    round-trip, NULL-on-omit legacy compat, substring match, and the
    help-text documentation contract.

Restores the doctrine in docs/adr/0000-purpose-to-software-compiler.md:
"non-trivial artifacts must name their purpose and consumer."

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-05-15 18:40:27 +02:00
parent 3c416162b2
commit 725affd126
7 changed files with 306 additions and 14 deletions

View file

@ -122,6 +122,15 @@ async function handleAdd(basePath: string, options: FeedbackOptions): Promise<Fe
const unitType = readFlag(options.args, "--unit-type");
const impactScore = parseIntOrUndefined(readFlag(options.args, "--impact-score"));
const effortEstimate = parseIntOrUndefined(readFlag(options.args, "--effort-estimate"));
// purpose_anchor (sf-db v71, ADR-0000): free-text fragment of the milestone
// vision or slice goal sentence this feedback is filed against. Optional —
// the CLI accepts omission so legacy callers keep working, but triage can
// only prioritize against purpose for rows that supply it.
const purposeAnchorRaw = readFlag(options.args, "--purpose");
const purposeAnchor =
typeof purposeAnchorRaw === "string" && purposeAnchorRaw.trim() !== ""
? purposeAnchorRaw.trim()
: undefined;
const blocking =
readBoolFlag(options.args, "--blocking") || severity === "high" || severity === "critical";
@ -147,6 +156,7 @@ async function handleAdd(basePath: string, options: FeedbackOptions): Promise<Fe
suggestedFix,
impactScore,
effortEstimate,
purposeAnchor,
source: "headless-cli",
};
@ -164,6 +174,7 @@ async function handleAdd(basePath: string, options: FeedbackOptions): Promise<Fe
severity,
blocking,
impact_score: impactScore,
purpose_anchor: purposeAnchor ?? null,
summary: entry.summary,
}, `${id} ${severity.padEnd(8)} ${kind} ${entry.summary}`);
return { exitCode: 0 };
@ -178,10 +189,18 @@ async function handleList(basePath: string, options: FeedbackOptions): Promise<F
);
return { exitCode: 2 };
}
// --purpose <fragment>: 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<F
summary: string;
resolvedAt?: string | null;
impactScore?: number | null;
purposeAnchor?: string | null;
}>;
};
let entries = sfDb.listSelfFeedbackEntries();
let entries = sfDb.listSelfFeedbackEntries(
purposeFragment ? { purpose: purposeFragment } : undefined,
);
if (wantUnresolved) {
entries = entries.filter((e) => !e.resolvedAt);
}

View file

@ -259,8 +259,8 @@ const SUBCOMMAND_HELP: Record<string, string> = {
" complete-slice Mark a slice complete out-of-band: complete-slice <M>/<S> [--reason <txt>]",
" skip-slice Mark a slice skipped out-of-band (placeholder/migration cleanup)",
" complete-milestone Mark a milestone complete out-of-band: complete-milestone <M> [--reason <txt>]",
" feedback add File a self_feedback entry (--summary <txt> --severity low|medium|high|critical [--kind <k>] [--evidence <t>] [--suggested-fix <t>] [--milestone M --slice S --task T] [--impact-score N] [--effort-estimate N] [--blocking])",
" feedback list List self_feedback entries [--unresolved] [--severity <s>] [--json]",
" feedback add File a self_feedback entry (--summary <txt> --severity low|medium|high|critical [--kind <k>] [--evidence <t>] [--suggested-fix <t>] [--milestone M --slice S --task T] [--impact-score N] [--effort-estimate N] [--blocking] [--purpose <fragment>])",
" feedback list List self_feedback entries [--unresolved] [--severity <s>] [--purpose <fragment>] [--json]",
" feedback resolve Resolve an entry: feedback resolve <id> --reason <txt> [--evidence-kind human-clear|agent-fix|...]",
"",
"new-milestone flags:",
@ -303,7 +303,9 @@ const SUBCOMMAND_HELP: Record<string, string> = {
" 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",

View file

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

View file

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

View file

@ -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();

View file

@ -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();

View file

@ -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 <fragment>` persists the anchor to SQLite.
* 2. `feedback list --purpose <fragment>` 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<string, unknown>): Record<string, unknown> {
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 <fragment>",
);
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 <fragment>/s,
"help: feedback add must list --purpose <fragment>",
);
assert.match(
helpSrc,
/feedback list\s+List self_feedback entries.*--purpose <fragment>/s,
"help: feedback list must list --purpose <fragment>",
);
});
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)",
);
});