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
435 lines (434 loc) • 19.9 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/.
/**
* The proposal drain behind `akm proposal drain` and improve's triage pre-pass.
* One rule decides the pending backlog:
* - an empty diff is rejected;
* - a proposal whose quality judge passed on this exact content (a `staged`
* gate decision carrying its content hash) is accepted, unless its target
* changed since mint — then it is auto-rejected as `stale-target`, never
* overwritten;
* - everything else needs a judge: the judgment tier decides it when a runner
* is configured, and whatever stays undecided is left for review
* (`review_needed` in the improve ledger).
* `maxAccepts` caps promotions across both tiers; `applyMode: "queue"` never
* promotes; `excludeIds` keeps this run's fresh proposals out; a proposal that
* a generating stage routed to a person is left for that person.
*/
import fs from "node:fs";
import path from "node:path";
import { assetPathForName, stashDirFor } from "../../core/asset/asset-placement.js";
import { parseFrontmatter } from "../../core/asset/frontmatter.js";
import { parseRefInput } from "../../core/asset/resolve-ref.js";
import { ConfigError } from "../../core/errors.js";
import { appendEvent } from "../../core/events.js";
import { escapeJsonStringControls, stripCodeFences, stripThinkBlocks } from "../../core/parse.js";
import { info, warn } from "../../core/warn.js";
import { DEFAULT_LLM_TIMEOUT_MS } from "../../integrations/agent/config.js";
import { buildExecution, resolveExecution } from "../../integrations/agent/execution.js";
import { assertRunnerCredentials, runExecution, } from "../../integrations/agent/runner-dispatch.js";
import { errMessage, noticeSet } from "../improve/stage.js";
import { akmProposalAccept, akmProposalReject } from "./proposal.js";
import { isRetireProposal, STALE_TARGET_GATE_REASON } from "./proposal-types.js";
import { listProposals, listProposalsReadOnly, preflightProposalPromotion, proposalContent, proposalContentHash, readFreshProposalTarget, recordGateDecision, } from "./repository.js";
/** The gate label on every decision the drain records. */
const DRAIN_GATE = "triage";
/** An empty diff: no non-blank body line outside the frontmatter. */
export function isEmptyDiff(proposal) {
const content = proposalContent(proposal);
if (content.trim().length === 0)
return true;
return !parseFrontmatter(content)
.content.split("\n")
.some((line) => line.trim().length > 0);
}
function categorizeDrainFailure(message, fallback) {
if (/target (?:changed after|was created after) proposal/.test(message))
return STALE_TARGET_GATE_REASON;
if (/failed validation:/.test(message))
return "validation";
return fallback;
}
/**
* The one accept path both tiers share. A dry run exercises the same stamped
* candidate, lint and freshness boundary a promotion would (tests that pass no
* config keep the classification-only seam). A stale target is not a merit
* rejection, so instead of failing identically every run it is auto-rejected
* once; the ledger records `failed`, keeping the ref re-proposable.
*/
async function acceptProposal(opts, proposal, id, reason, promoteFn, rejectFn) {
const gateDecision = { outcome: "auto-accepted", reason, gate: DRAIN_GATE };
try {
if (!opts.dryRun) {
await promoteFn({
stashDir: opts.stashDir,
id,
...(opts.target ? { target: opts.target } : {}),
...(opts.config ? { config: opts.config } : {}),
gateDecision,
});
}
else if (opts.config) {
if (!proposal)
throw new Error(`Proposal ${id} disappeared during drain preflight.`);
const preflight = preflightProposalPromotion(opts.config, proposal, {
...(opts.target ? { target: opts.target } : {}),
gateDecision,
});
readFreshProposalTarget(proposal, preflight.assetPath, preflight.stampedContent);
}
return "promoted";
}
catch (err) {
const message = errMessage(err);
if (categorizeDrainFailure(message, "") !== STALE_TARGET_GATE_REASON)
return { message };
if (opts.dryRun)
return "rejected";
try {
await rejectFn({
stashDir: opts.stashDir,
id,
reason: `stale-target: ${message}`,
gateDecision: { outcome: "auto-rejected", reason: STALE_TARGET_GATE_REASON, gate: DRAIN_GATE },
});
return "rejected";
}
catch (rejectErr) {
warn(`[triage] stale-target auto-reject failed for ${id}: ${errMessage(rejectErr)}`);
return { message };
}
}
}
/** Reject one proposal (nothing in a dry run); the error message on failure. */
async function rejectProposal(opts, id, reason, gateReason, rejectFn) {
if (opts.dryRun)
return undefined;
try {
await rejectFn({
stashDir: opts.stashDir,
id,
reason,
gateDecision: { outcome: "auto-rejected", reason: gateReason, gate: DRAIN_GATE },
});
return undefined;
}
catch (err) {
return errMessage(err);
}
}
/** The judgment prompt: the proposal, the live asset it would overwrite, and same-ref siblings. */
export function buildJudgmentPrompt(proposal, reason, ctx) {
const sections = [
"You are adjudicating a pending knowledge-base proposal no quality judge has",
"passed yet. Decide whether to accept, reject, or defer it.",
"",
`Asset ref: ${proposal.ref}`,
`Generator (source): ${proposal.source}`,
`Left for judgment because: ${reason === "needs-judgment" ? "no quality judge has passed this content yet" : reason}`,
"",
"## Proposed content",
"```",
proposalContent(proposal),
"```",
];
if (ctx.liveAsset !== undefined) {
sections.push("", "## Current live asset (would be overwritten on accept)", "```", ctx.liveAsset, "```");
}
else {
sections.push("", "## Current live asset", "(none — this proposal would create a new asset)");
}
if (ctx.siblings.length > 0) {
sections.push("", "## Other pending proposals for the same ref (dedup context)");
for (const sib of ctx.siblings) {
sections.push("", `### Sibling ${sib.id} (source: ${sib.source})`, "```", proposalContent(sib), "```");
}
}
sections.push("", "## Your task", 'Return ONLY a JSON object: {"decision": "accept" | "reject" | "defer", "reason": "<short reason>"}.', "- accept: the proposed content is a correct, valuable update worth committing.", "- reject: the proposal is wrong, a duplicate, or contradicts the live asset.", "- defer: you cannot decide from the provided context (leave it pending).", "Output the JSON object and nothing else.");
return sections.join("\n");
}
/** A verdict from raw runner output (the first JSON object), or null. */
export function parseJudgmentVerdict(raw) {
const cleaned = escapeJsonStringControls(stripCodeFences(stripThinkBlocks(raw))).trim();
const start = cleaned.indexOf("{");
const end = cleaned.lastIndexOf("}");
if (start === -1 || end <= start)
return null;
let obj;
try {
obj = JSON.parse(cleaned.slice(start, end + 1));
}
catch {
return null;
}
const { decision, reason } = (obj ?? {});
if (decision !== "accept" && decision !== "reject" && decision !== "defer")
return null;
return { decision, reason: typeof reason === "string" ? reason : "" };
}
/** Lower the judgment prompt through the frozen runner and dispatch it. */
async function dispatchJudgment(runner, prompt, seams) {
let notices = [];
try {
// Model work is bounded on every runner kind: one with no timeout of its own gets the default.
// The judgment runs under the model-work tool policy.
const prepared = resolveExecution({
content: prompt,
runner,
current: Object.hasOwn(runner, "timeoutMs") ? {} : { timeout: DEFAULT_LLM_TIMEOUT_MS },
modelWork: true,
});
const lowered = buildExecution(prepared.request, prepared.runner);
notices = lowered.notices;
const chat = seams.chat;
const llmRunner = lowered.runner.kind === "llm" ? lowered.runner : undefined;
const result = await runExecution(lowered, {
...(seams.runAgentFn ? { runAgent: seams.runAgentFn } : {}),
...(seams.runSdkFn ? { runSdk: seams.runSdkFn } : {}),
...(chat && llmRunner
? { chat: async (connection, messages) => chat({ ...llmRunner, connection }, messages) }
: {}),
});
if (!result.ok)
return { verdict: null, notices, error: result.error ?? result.reason ?? "unknown error" };
return { verdict: parseJudgmentVerdict(result.stdout), notices };
}
catch (error) {
if (error instanceof ConfigError)
throw error;
return { verdict: null, notices, error: errMessage(error) };
}
}
/**
* The judgment tier: the runner only judges; the drain performs the accept
* (under `applyMode` and the remaining accept budget) or the reject. A defer, an
* unparseable verdict or a runner error leaves the item undecided.
*/
async function runJudgmentTier(opts, result, pending, acceptBudget, promoteFn, rejectFn, seams) {
const byId = new Map(pending.map((p) => [p.id, p]));
const notices = noticeSet();
const stillDeferred = [];
const cappedBefore = result.skippedByCap.length;
for (const item of result.deferred) {
const proposal = byId.get(item.id);
if (!proposal) {
stillDeferred.push(item);
continue;
}
const prompt = buildJudgmentPrompt(proposal, item.reason, {
liveAsset: readLiveAssetContent(opts.stashDir, proposal.ref),
siblings: pending.filter((p) => p.ref === proposal.ref && p.id !== proposal.id),
});
const dispatch = await dispatchJudgment(opts.judgment, prompt, seams);
notices.add(dispatch.notices);
if (dispatch.error)
warn(`[triage] judgment dispatch failed for ${item.id}: ${dispatch.error}`);
const verdict = dispatch.error ? null : dispatch.verdict;
if (!verdict || verdict.decision === "defer") {
stillDeferred.push(item);
continue;
}
if (verdict.decision === "reject") {
const failure = await rejectProposal(opts, item.id, verdict.reason || "judgment: reject", "judgment-reject", rejectFn);
if (failure === undefined) {
result.rejected.push(item.id);
}
else {
warn(`[triage] judgment reject failed for ${item.id}: ${failure}`);
stillDeferred.push(item);
}
continue;
}
// Queue mode never writes the asset: the verdict is staged for a later promote run.
if (opts.applyMode !== "promote") {
if (opts.dryRun) {
result.staged.push(item.id);
continue;
}
try {
recordGateDecision(opts.stashDir, item.id, {
outcome: "staged",
reason: "judgment-accept",
contentHash: proposalContentHash(proposal),
gate: DRAIN_GATE,
});
result.staged.push(item.id);
}
catch (err) {
warn(`[triage] failed to stage judgment for ${item.id}: ${errMessage(err)}`);
stillDeferred.push(item);
}
continue;
}
if (acceptBudget <= 0) {
result.skippedByCap.push(item.id);
continue;
}
const outcome = await acceptProposal(opts, proposal, item.id, "judgment-accept", promoteFn, rejectFn);
if (outcome === "promoted") {
result.promoted.push(item.id);
acceptBudget -= 1;
}
else if (outcome === "rejected") {
result.rejected.push(item.id);
}
else {
warn(`[triage] judgment ${opts.dryRun ? "preflight" : "promote"} failed for ${item.id}: ${outcome.message}`);
stillDeferred.push(item);
}
}
const capped = result.skippedByCap.length - cappedBefore;
if (capped > 0) {
info(`[triage] accept ceiling reached in judgment tier: ${capped} judged-accept items skipped by cap (maxAccepts=${opts.maxAccepts})`);
}
if (notices.list().length > 0)
result.notices = notices.list();
result.deferred = stillDeferred;
}
/** The live asset a proposal would overwrite, if any. */
function readLiveAssetContent(stashDir, ref) {
try {
const parsed = parseRefInput(ref);
const typeDir = stashDirFor(parsed.type);
if (!typeDir)
return undefined;
const assetPath = assetPathForName(parsed.type, path.join(stashDir, typeDir), parsed.name);
return fs.existsSync(assetPath) ? fs.readFileSync(assetPath, "utf8") : undefined;
}
catch {
return undefined;
}
}
/**
* Drain the pending backlog. `promoteFn` / `rejectFn` / `judgmentSeams` are
* test seams.
*/
export async function drainProposals(opts, promoteFn = akmProposalAccept, rejectFn = akmProposalReject, judgmentSeams = {}) {
const exclude = opts.excludeIds ?? new Set();
// A judgment runner's credentials are validated before any live state
// connection, so its classification reads an isolated snapshot.
const pending = (opts.judgment ? listProposalsReadOnly : listProposals)(opts.stashDir, { status: "pending" }).filter((proposal) => !exclude.has(proposal.id));
const result = { promoted: [], rejected: [], deferred: [], skippedByCap: [], staged: [], failed: [] };
const accepts = [];
const empties = [];
for (const proposal of pending) {
// A consolidate pair-pass `retire` proposal is never auto-decided here,
// whatever `applyMode` says (alpha.9 brief §A "Review"; spec §25.6):
// untouched, still pending, waiting for a direct `akm proposal accept`.
// Checked before isEmptyDiff, which reads proposalContent() and has
// nothing meaningful to read on a delete-primary change anyway.
if (isRetireProposal(proposal))
continue;
const decision = proposal.gateDecision;
// Another gate's rejection stands, and another gate's deferral is a
// generating stage's hand-off to a person: it is left for that person.
if (decision?.outcome === "auto-rejected" && !decision.gate?.startsWith(DRAIN_GATE))
continue;
if (decision?.outcome === "deferred" && !decision.gate?.startsWith(DRAIN_GATE))
continue;
if (isEmptyDiff(proposal)) {
empties.push(proposal.id);
}
else if (decision?.outcome === "staged" && decision.contentHash === proposalContentHash(proposal)) {
accepts.push({ id: proposal.id, reason: decision.gate === "quality-gate" ? "judge-passed" : "judgment-accept" });
}
else {
result.deferred.push({ id: proposal.id, reason: "needs-judgment" });
}
}
if (opts.judgment && result.deferred.length > 0) {
// Symbolic credentials, and the runner's model-work tool policy, are checked before any gate, reject or promote.
const prepared = resolveExecution({
content: "Validate the selected proposal judgment runner before mutation.",
runner: opts.judgment,
modelWork: true,
});
assertRunnerCredentials(buildExecution(prepared.request, prepared.runner).runner);
}
for (const id of empties) {
const failure = await rejectProposal(opts, id, "empty diff", "empty-diff", rejectFn);
if (failure === undefined) {
result.rejected.push(id);
}
else {
result.failed.push({ id, reason: categorizeDrainFailure(failure, "reject-error"), detail: failure });
warn(`[triage] reject failed for ${id}: ${failure}`);
}
}
const cap = Math.max(0, opts.maxAccepts);
const withinCap = accepts.slice(0, cap);
result.skippedByCap = accepts.slice(cap).map((a) => a.id);
if (result.skippedByCap.length > 0) {
info(`[triage] accept ceiling reached: ${withinCap.length} promoted, ${result.skippedByCap.length} skipped by cap (maxAccepts=${opts.maxAccepts})`);
}
let promotedHere = 0;
if (opts.applyMode === "promote") {
if (!opts.dryRun)
info(`[triage] auto-promote active: ${withinCap.length} accepts allowed this run`);
const byId = new Map(pending.map((proposal) => [proposal.id, proposal]));
for (const { id, reason } of withinCap) {
const outcome = await acceptProposal(opts, byId.get(id), id, reason, promoteFn, rejectFn);
if (outcome === "promoted") {
result.promoted.push(id);
promotedHere += 1;
}
else if (outcome === "rejected") {
result.rejected.push(id);
}
else {
result.failed.push({
id,
reason: categorizeDrainFailure(outcome.message, "promote-error"),
detail: outcome.message,
});
warn(`[triage] ${opts.dryRun ? "preflight" : "promote"} failed for ${id}: ${outcome.message}`);
}
}
}
if (opts.judgment && result.deferred.length > 0) {
await runJudgmentTier({ ...opts, judgment: opts.judgment }, result, pending, cap - promotedHere, promoteFn, rejectFn, judgmentSeams);
}
// #577: whatever stays undecided is left for review (`review_needed` in the ledger).
if (!opts.dryRun) {
const reviewReason = opts.judgment ? "judgment-deferred" : "no-judge-configured";
for (const item of result.deferred) {
try {
recordGateDecision(opts.stashDir, item.id, { outcome: "deferred", reason: reviewReason, gate: DRAIN_GATE });
}
catch (err) {
warn(`[triage] failed to record gate decision for ${item.id}: ${errMessage(err)}`);
}
}
}
emitDrainEvents(opts, result);
return result;
}
function emitDrainEvents(opts, result) {
const deferredByReason = {};
for (const d of result.deferred)
deferredByReason[d.reason] = (deferredByReason[d.reason] ?? 0) + 1;
appendEvent({
eventType: "triage_drained",
metadata: {
promoted: result.promoted.length,
rejected: result.rejected.length,
deferredByReason,
skippedByCap: result.skippedByCap.length,
...(result.staged.length > 0 ? { staged: result.staged.length } : {}),
applyMode: opts.applyMode,
...(opts.dryRun ? { dryRun: true } : {}),
},
}, opts.eventsCtx ?? {});
// Undecided items must never look like full success. Staged accepts were decided.
if (result.deferred.length > 0) {
appendEvent({
eventType: "triage_deferred",
metadata: {
deferred: result.deferred.length,
deferredByReason,
reason: opts.judgment ? "judgment tier left items unresolved" : "no judgment runner configured",
},
}, opts.eventsCtx ?? {});
}
}