akm-cli
Version:
akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cur
323 lines (322 loc) • 15.8 kB
JavaScript
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
/**
* Plain-text renderers for `akm proposal *` commands.
*
* Split out of `helpers.ts` (formerly 1418 lines / 59 fns) as its own
* sibling module: proposal listing/show share the gate-decision summary
* helpers. (`akm distill` has no registered top-level command — its former
* plain-text renderer, `formatDistillPlain`, was dead code; removed per
* R-063/B5.)
*/
function appendLoweringNotices(lines, result) {
const notices = Array.isArray(result.notices) ? result.notices : [];
for (const notice of notices) {
const severity = notice.severity === "info" ? "info" : "warning";
const code = typeof notice.code === "string" ? notice.code : "lowering-notice";
const adapter = typeof notice.adapter === "string" ? ` adapter=${notice.adapter}` : "";
const field = typeof notice.field === "string" ? ` field=${notice.field}` : "";
const message = typeof notice.message === "string" ? `: ${notice.message}` : "";
lines.push(` notice[${severity}] ${code}${adapter}${field}${message}`);
}
}
export function formatProposalProducerPlain(command, r) {
if (r.ok === false) {
const reason = String(r.reason);
const error = typeof r.error === "string" ? r.error : "";
const lines = [`${command}: failed (${reason})`];
if (error)
lines.push(` error: ${error}`);
if (r.ref)
lines.push(` ref: ${String(r.ref)}`);
if (r.exitCode !== undefined && r.exitCode !== null) {
lines.push(` exitCode: ${String(r.exitCode)}`);
}
appendLoweringNotices(lines, r);
return lines.join("\n");
}
const proposal = r.proposal;
const id = String(proposal.id);
const ref = String(r.ref);
const status = String(proposal.status);
const lines = [`${command}: queued proposal ${id} (${ref}) [${status}]`];
appendLoweringNotices(lines, r);
return lines.join("\n");
}
/**
* Render a one-line gate-decision summary for the proposal list / show surfaces
* (#577), e.g. `gate=deferred:max-diff-lines (210 > 200)`. Returns the empty
* string when no well-formed decision is present.
*/
export function formatGateDecisionSummary(raw) {
if (typeof raw !== "object" || raw === null)
return "";
const d = raw;
const outcome = typeof d.outcome === "string" ? d.outcome : undefined;
if (!outcome)
return "";
const reason = typeof d.reason === "string" && d.reason.length > 0 ? `:${d.reason}` : "";
const cmp = formatGateThresholdComparison(d);
return `gate=${outcome}${reason}${cmp ? ` (${cmp})` : ""}`;
}
/**
* Reconstruct the drain threshold comparison when both operands are present.
* Returns the empty string when the decision lacks either operand.
*/
function formatGateThresholdComparison(d) {
const thresholds = (typeof d.thresholds === "object" && d.thresholds !== null ? d.thresholds : {});
const measured = typeof d.measured === "number" ? d.measured : undefined;
if (measured === undefined)
return "";
if (typeof thresholds.maxDiffLines === "number") {
return `${measured} > ${thresholds.maxDiffLines}`;
}
if (typeof thresholds.minContentLines === "number") {
return `${measured} < ${thresholds.minContentLines}`;
}
return "";
}
export function formatProposalListPlain(r) {
const proposals = r.proposals;
const total = r.totalCount;
if (proposals.length === 0) {
// NEW-1: `akm reflect` and `akm distill` are internal improve-pipeline
// process names (src/commands/improve/{reflect,distill}.ts), not
// registered top-level commands (see the subCommands map in src/cli.ts) —
// an agent following this hint would run a command that does not exist.
// The real ways to queue a proposal are `akm improve <ref>` (which runs
// the reflect/distill/consolidate pipeline internally) and
// `akm proposal new`.
return `${total} proposal(s).\nNo proposals.\nGenerate one with \`akm improve <ref>\` or \`akm proposal new <type> <name> --task ...\`.`;
}
const lines = [`${total} proposal(s)`, ""];
for (const p of proposals) {
const id = String(p.id);
const ref = String(p.ref);
const status = String(p.status);
const source = String(p.source);
const created = String(p.createdAt);
// #577: surface the gate verdict inline so the queue explains itself.
const gate = formatGateDecisionSummary(p.gateDecision);
const gateSuffix = gate ? ` ${gate}` : "";
// A retire proposal's continuityRisk excludes it from bulk accept —
// visible in the default list, not just `proposal show`, since the owner
// reviews the backlog list-first. `list`'s default (brief) shape
// projects a `continuityRisk: true` marker instead of the full
// `retirement` object (only present from `--detail normal` up), so both
// shapes are checked here.
const retirement = p.retirement;
const flagged = p.continuityRisk === true || Boolean(retirement?.continuityRisk);
const continuitySuffix = flagged ? " ⚠ continuity-risk" : "";
lines.push(`${id} [${status}] ${ref} source=${source} ${created}${gateSuffix}${continuitySuffix}`);
}
return lines.join("\n").trimEnd();
}
/**
* A retire proposal's verdict lines — the pair judge's label and reason, and
* the continuity check's failing queries — as `proposal show` and `proposal
* diff` both print them. `retirement` is the stored block, which `proposal
* diff` reports under the same keys.
*/
function retireVerdictLines(retirement) {
const lines = [
`retire.label: ${String(retirement.judgeLabel)} (cosine=${String(retirement.cosine)})`,
`retire.reason: ${String(retirement.judgeReason)}`,
];
// Item 1 (continuity check): flagged, but still minted — never swept by a
// bulk accept, only acceptable by id, so a reviewer must see it.
const continuityRisk = retirement.continuityRisk;
if (continuityRisk) {
const failingQueries = typeof continuityRisk.failingQueries === "number" ? continuityRisk.failingQueries : 0;
const unverifiedQueries = typeof continuityRisk.unverifiedQueries === "number" ? continuityRisk.unverifiedQueries : 0;
const summary = [];
if (failingQueries > 0) {
summary.push(`${failingQueries} of the retired asset's own quer${failingQueries === 1 ? "y" : "ies"} would not have found the successor top 10`);
}
// S2: a query the search call never ran, or that fell back to
// keyword-only ranking, is never silently trusted OR silently
// dropped — it excludes the proposal from bulk accept on its own.
if (unverifiedQueries > 0) {
summary.push(`${unverifiedQueries} quer${unverifiedQueries === 1 ? "y" : "ies"} unverified (search failed or used the keyword-only fallback)`);
}
lines.push(`retire.continuityRisk: ${summary.join("; ")} — excluded from bulk accept`);
// N3 / S4: the actual failing query text, not just the count — a
// reviewer deciding whether to accept by id needs to see what would
// stop resolving, not just how many queries.
const ranks = Array.isArray(continuityRisk.ranks) ? continuityRisk.ranks : [];
for (const rank of ranks) {
const successorRank = rank.successorRank === null || rank.successorRank === undefined
? "absent from top 10"
: `#${String(rank.successorRank)}`;
lines.push(` - "${String(rank.query)}": retired #${String(rank.retiredRank)}, successor ${successorRank}`);
}
}
return lines;
}
export function formatProposalShowPlain(r) {
const p = r.proposal;
const lines = [];
lines.push(`# proposal ${String(p.id)}`);
lines.push(`ref: ${String(p.ref)}`);
lines.push(`status: ${String(p.status)}`);
lines.push(`source: ${String(p.source)}`);
if (p.sourceRun)
lines.push(`sourceRun: ${String(p.sourceRun)}`);
if (p.createdAt)
lines.push(`createdAt: ${String(p.createdAt)}`);
if (p.updatedAt)
lines.push(`updatedAt: ${String(p.updatedAt)}`);
if (typeof p.confidence === "number")
lines.push(`confidence: ${p.confidence.toFixed(2)}`);
// #577: gate decision (auto-accepted / deferred / auto-rejected + reason +
// thresholds), when this proposal has passed through a gate.
const gate = p.gateDecision;
if (gate && typeof gate.outcome === "string") {
lines.push(`gate.decision: ${String(gate.outcome)}`);
lines.push(`gate.reason: ${String(gate.reason)}`);
const cmp = formatGateThresholdComparison(gate);
if (cmp)
lines.push(`gate.thresholds: ${cmp}`);
if (gate.gate)
lines.push(`gate.by: ${String(gate.gate)}`);
if (gate.decidedAt)
lines.push(`gate.decidedAt: ${String(gate.decidedAt)}`);
}
const review = p.review;
if (review) {
lines.push(`review.outcome: ${String(review.outcome ?? "?")}`);
if (review.reason)
lines.push(`review.reason: ${String(review.reason)}`);
if (review.decidedAt)
lines.push(`review.decidedAt: ${String(review.decidedAt)}`);
}
// `akm proposal reopen` (#997): the rejection each reopen undid, so a
// pending proposal that was once rejected says so.
const history = Array.isArray(p.reviewHistory) ? p.reviewHistory : [];
for (const entry of history) {
const undone = entry.review;
const was = undone
? `${String(undone.outcome ?? "?")}${undone.reason ? `: ${String(undone.reason)}` : ""} (${String(undone.decidedAt ?? "?")})`
: "an unrecorded review";
const why = entry.reopenReason ? ` (${String(entry.reopenReason)})` : "";
lines.push(`reopened: ${String(entry.reopenedAt)}${why}, undoing ${was}`);
}
// alpha.9: a consolidate retire proposal writes no content (`payload.content`
// is empty by design) — this is the reason a reviewer needs instead. `diff`
// shows the body being retired.
const retirement = p.retirement;
if (retirement) {
lines.push(`retire: ${String(retirement.retiredRef)} -> ${String(retirement.successorRef)}`);
lines.push(...retireVerdictLines(retirement));
}
const validation = r.validation;
if (validation) {
const findings = Array.isArray(validation.findings) ? validation.findings : [];
// Partition findings by severity. `severity: "warn"` findings are
// non-blocking (the validator reports `ok: true` for a warn-only proposal),
// so they must read as advisory — a distinct icon/label from blocking errors.
const warnings = findings.filter((f) => f.severity === "warn");
const errors = findings.filter((f) => f.severity !== "warn");
lines.push("");
if (errors.length > 0) {
const warnSuffix = warnings.length > 0 ? `, ${warnings.length} warning(s)` : "";
lines.push(`✗ invalid (${errors.length} error(s)${warnSuffix})`);
}
else if (warnings.length > 0) {
lines.push(`✓ valid (${warnings.length} warning(s))`);
}
else {
lines.push("✓ valid");
}
// Errors first (blocking), then warnings (advisory, non-blocking).
for (const f of errors) {
lines.push(` ✗ error [${String(f.kind)}] ${String(f.message)}`);
}
for (const f of warnings) {
lines.push(` ⚠ warning [${String(f.kind)}] ${String(f.message)} (non-blocking)`);
}
}
const payload = p.payload;
// A retire proposal's payload is empty by design (it archives a file, it
// writes none): a bare `payload:` heading reads as "replaced by nothing" —
// the misreading #997 is about — so the retirement lines above stand alone.
if (!retirement && payload && typeof payload.content === "string") {
lines.push("");
lines.push("payload:");
lines.push(payload.content);
}
return lines.join("\n").trimEnd();
}
export function formatProposalAcceptPlain(r) {
return `Accepted proposal ${String(r.id)} → ${String(r.ref)} at ${String(r.assetPath)}`;
}
export function formatProposalRejectPlain(r) {
const reason = r.reason ? ` (${String(r.reason)})` : "";
return `Rejected proposal ${String(r.id)} (${String(r.ref)})${reason}`;
}
export function formatProposalReopenPlain(r) {
const reason = r.reason ? ` (${String(r.reason)})` : "";
return `Reopened proposal ${String(r.id)} (${String(r.ref)}) [pending]${reason}`;
}
export function formatProposalReopenBatchPlain(r) {
const results = Array.isArray(r.results) ? r.results : [];
const reason = results[0]?.reason ? ` (${String(results[0].reason)})` : "";
return [
`Reopened ${results.length} proposal(s) [pending]${reason}`,
...results.map((result) => ` ${String(result.id)} ${String(result.ref)}`),
].join("\n");
}
export function formatProposalDrainPlain(r) {
const applyMode = String(r.applyMode ?? "queue");
const promoted = Array.isArray(r.promoted) ? r.promoted : [];
const rejected = Array.isArray(r.rejected) ? r.rejected : [];
const deferred = Array.isArray(r.deferred) ? r.deferred : [];
const skippedByCap = Array.isArray(r.skippedByCap) ? r.skippedByCap : [];
const staged = Array.isArray(r.staged) ? r.staged : [];
const failed = Array.isArray(r.failed) ? r.failed : [];
const prefix = r.dryRun === true ? "[dry-run] " : "";
const lines = [
`${prefix}Drained proposal queue (strategy=${String(r.strategy ?? "?")}, applyMode=${applyMode})`,
` promoted: ${promoted.length}`,
` rejected: ${rejected.length}`,
` deferred: ${deferred.length}`,
` skippedByCap: ${skippedByCap.length}`,
` staged: ${staged.length}`,
` failed: ${failed.length}`,
];
for (const d of deferred) {
lines.push(` - ${String(d.id ?? "?")} (${String(d.reason ?? "?")})`);
}
for (const f of failed) {
lines.push(` ! ${String(f.id ?? "?")} (${String(f.reason ?? "?")}): ${String(f.detail ?? "?")}`);
}
appendLoweringNotices(lines, r);
return lines.join("\n").trimEnd();
}
export function formatProposalDiffPlain(r) {
const unified = typeof r.unified === "string" ? r.unified : "";
if (r.op === "delete") {
// #997: a retire proposal archives its target — it does not "update" it —
// so the header says so, and the pair verdict and the accept/revert note
// sit above the file that is leaving.
const retirement = r.retirement;
const subject = retirement
? `${String(retirement.retiredRef)} -> ${String(retirement.successorRef)}`
: String(r.ref);
const lines = [`# proposal ${String(r.id)} (retire: ${subject})`];
if (retirement)
lines.push(...retireVerdictLines(retirement));
if (typeof r.note === "string")
lines.push(`note: ${r.note}`);
if (unified)
lines.push(unified);
return lines.join("\n");
}
const header = r.isNew
? `# proposal ${String(r.id)} (new asset: ${String(r.ref)})`
: `# proposal ${String(r.id)} (update: ${String(r.ref)})`;
if (!unified)
return `${header}\n(no changes)`;
return `${header}\n${unified}`;
}