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>
314 lines
9.8 KiB
TypeScript
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 };
|
|
}
|
|
}
|