singularity-forge/src/headless-feedback.ts
Mikael Hugo 725affd126 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>
2026-05-15 18:51:52 +02:00

314 lines
9.8 KiB
TypeScript

/**
* Headless commands for the self-feedback subsystem:
*
* sf headless feedback add [flags]
* sf headless feedback list [--unresolved] [--severity <s>] [--json]
* sf headless feedback resolve <id> --reason "..." [--evidence-kind <k>]
*
* Why these exist: today the only writers to the self_feedback table
* are SF's autonomous runtime and a handful of internal detectors.
* Operators (and Claude Code instances running outside the autonomous
* loop) have no path to file feedback without dropping to SQL.
*
* Same pattern as headless-mark-state.ts: bypass the RPC child, use
* the established sf-db primitives, idempotent on conflict.
*
* resolve uses the same evidence shape the autonomous triage flow
* already accepts (`{kind: "human-clear" | "agent-fix" | ...}`), so
* resolution semantics are consistent across operator and agent paths.
*/
import { existsSync } from "node:fs";
import { join } from "node:path";
import { createJiti } from "@mariozechner/jiti";
import { resolveBundledSourceResource } from "./bundled-resource-path.js";
import { getSfEnv } from "./env.js";
export interface FeedbackResult {
exitCode: number;
}
interface FeedbackOptions {
subcommand: "add" | "list" | "resolve";
args: string[];
json: boolean;
}
const jiti = createJiti(import.meta.filename, {
interopDefault: true,
debug: false,
});
const agentExtensionsDir = join(getSfEnv().agentDir, "extensions", "sf");
const useAgentDir = existsSync(join(agentExtensionsDir, "state.js"));
function sfExtensionPath(moduleName: string): string {
if (useAgentDir) return join(agentExtensionsDir, `${moduleName}.js`);
const tsPath = resolveBundledSourceResource(
import.meta.url,
"extensions",
"sf",
`${moduleName}.ts`,
);
if (existsSync(tsPath)) return tsPath;
return resolveBundledSourceResource(
import.meta.url,
"extensions",
"sf",
`${moduleName}.js`,
);
}
// Match self-feedback.js's newId() so ids look uniform across writers.
function newId(): string {
const ts = Date.now().toString(36);
const rnd = Math.random().toString(36).slice(2, 8);
return `sf-${ts}-${rnd}`;
}
function readFlag(args: string[], name: string): string | undefined {
const i = args.indexOf(name);
if (i < 0 || i + 1 >= args.length) return undefined;
return args[i + 1];
}
function readBoolFlag(args: string[], name: string): boolean {
return args.includes(name);
}
function parseIntOrUndefined(s: string | undefined): number | undefined {
if (s === undefined) return undefined;
const n = Number.parseInt(s, 10);
return Number.isFinite(n) ? n : undefined;
}
const VALID_SEVERITIES = new Set(["low", "medium", "high", "critical"]);
const DEFAULT_KIND_DOMAIN = "improvement-idea";
function emit(json: boolean, payload: Record<string, unknown>, human: string): void {
if (json) {
process.stdout.write(`${JSON.stringify(payload)}\n`);
} else {
process.stdout.write(`${human}\n`);
}
}
async function loadDb(basePath: string): Promise<void> {
const autoStart = (await jiti.import(sfExtensionPath("auto-start"), {})) as {
openProjectDbIfPresent: (basePath: string) => Promise<void>;
};
await autoStart.openProjectDbIfPresent(basePath);
}
async function handleAdd(basePath: string, options: FeedbackOptions): Promise<FeedbackResult> {
const summary = readFlag(options.args, "--summary");
if (!summary || summary.trim() === "") {
process.stderr.write("[headless] Error: feedback add requires --summary <text>\n");
return { exitCode: 2 };
}
const severity = readFlag(options.args, "--severity") ?? "medium";
if (!VALID_SEVERITIES.has(severity)) {
process.stderr.write(
`[headless] Error: --severity must be one of: low, medium, high, critical (got '${severity}')\n`,
);
return { exitCode: 2 };
}
const kind = readFlag(options.args, "--kind") ?? DEFAULT_KIND_DOMAIN;
const evidence = readFlag(options.args, "--evidence") ?? "";
const suggestedFix = readFlag(options.args, "--suggested-fix") ?? "";
const milestone = readFlag(options.args, "--milestone");
const slice = readFlag(options.args, "--slice");
const task = readFlag(options.args, "--task");
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";
const id = newId();
const ts = new Date().toISOString();
const entry = {
id,
ts,
kind,
severity,
blocking,
repoIdentity: "external" as const,
sfVersion: "",
basePath,
occurredIn: {
unitType: unitType ?? null,
milestone: milestone ?? null,
slice: slice ?? null,
task: task ?? null,
},
summary: summary.trim(),
evidence,
suggestedFix,
impactScore,
effortEstimate,
purposeAnchor,
source: "headless-cli",
};
await loadDb(basePath);
const sfDb = (await jiti.import(sfExtensionPath("sf-db/sf-db-self-feedback"), {})) as {
insertSelfFeedbackEntry: (e: typeof entry) => void;
};
sfDb.insertSelfFeedbackEntry(entry);
emit(options.json, {
ok: true,
id,
ts,
kind,
severity,
blocking,
impact_score: impactScore,
purpose_anchor: purposeAnchor ?? null,
summary: entry.summary,
}, `${id} ${severity.padEnd(8)} ${kind} ${entry.summary}`);
return { exitCode: 0 };
}
async function handleList(basePath: string, options: FeedbackOptions): Promise<FeedbackResult> {
const wantUnresolved = readBoolFlag(options.args, "--unresolved");
const severityFilter = readFlag(options.args, "--severity");
if (severityFilter && !VALID_SEVERITIES.has(severityFilter)) {
process.stderr.write(
`[headless] Error: --severity must be one of: low, medium, high, critical (got '${severityFilter}')\n`,
);
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: (options?: { purpose?: string }) => Array<{
id: string;
ts: string;
kind: string;
severity: string;
blocking: boolean;
summary: string;
resolvedAt?: string | null;
impactScore?: number | null;
purposeAnchor?: string | null;
}>;
};
let entries = sfDb.listSelfFeedbackEntries(
purposeFragment ? { purpose: purposeFragment } : undefined,
);
if (wantUnresolved) {
entries = entries.filter((e) => !e.resolvedAt);
}
if (severityFilter) {
entries = entries.filter((e) => e.severity === severityFilter);
}
if (options.json) {
process.stdout.write(
`${JSON.stringify({ ok: true, count: entries.length, entries })}\n`,
);
return { exitCode: 0 };
}
if (entries.length === 0) {
process.stdout.write("(no self-feedback entries match)\n");
return { exitCode: 0 };
}
for (const e of entries) {
const state = e.resolvedAt ? "RESOLVED" : " ";
process.stdout.write(
`${e.id} ${state} ${e.severity.padEnd(8)} ${e.kind.padEnd(24)} ${e.summary}\n`,
);
}
return { exitCode: 0 };
}
async function handleResolve(basePath: string, options: FeedbackOptions): Promise<FeedbackResult> {
const positional = options.args.filter(
(a, i, all) => !a.startsWith("--") && (i === 0 || !all[i - 1].startsWith("--")),
);
const id = positional[0];
if (!id) {
process.stderr.write(
"[headless] Error: feedback resolve requires an id positional (e.g. sf-mp4xxx-yyy)\n",
);
return { exitCode: 2 };
}
const reason = readFlag(options.args, "--reason") ?? "";
const evidenceKind = readFlag(options.args, "--evidence-kind") ?? "human-clear";
await loadDb(basePath);
const sfDb = (await jiti.import(sfExtensionPath("sf-db/sf-db-self-feedback"), {})) as {
resolveSelfFeedbackEntry: (
id: string,
resolution: {
reason: string;
evidence: { kind: string };
resolvedBySfVersion?: string;
resolvedAt?: string;
},
) => boolean;
};
const ok = sfDb.resolveSelfFeedbackEntry(id, {
reason,
evidence: { kind: evidenceKind },
resolvedBySfVersion: "",
});
if (!ok) {
// Either id not found OR already resolved. The DB primitive returns
// false for both — surface a 1-line note rather than failing hard,
// since "already resolved" is the idempotent path.
emit(options.json, {
ok: false,
idempotent: true,
id,
note: "no row updated (already resolved, or id not found)",
}, `${id}: nothing to resolve (already resolved, or id not found)`);
return { exitCode: 0 };
}
emit(options.json, {
ok: true,
id,
resolved_at: new Date().toISOString(),
evidence_kind: evidenceKind,
reason,
}, `${id}: resolved (${evidenceKind})`);
return { exitCode: 0 };
}
export async function handleFeedback(
basePath: string,
options: FeedbackOptions,
): Promise<FeedbackResult> {
switch (options.subcommand) {
case "add":
return handleAdd(basePath, options);
case "list":
return handleList(basePath, options);
case "resolve":
return handleResolve(basePath, options);
default:
process.stderr.write(
`[headless] Error: feedback subcommand must be add|list|resolve (got '${options.subcommand}')\n`,
);
return { exitCode: 2 };
}
}