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
536 lines (535 loc) • 25.1 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 { NEIGHBOUR_EXCERPT_CHARS, NEIGHBOUR_NOTE_COUNT, nearestKnowledgeNotes, shingleContainment, wordShingles, } from "../improve/consolidate/coverage.js";
import { errMessage, noticeSet } from "../improve/stage.js";
import { akmProposalAccept, akmProposalReject } from "./proposal.js";
import { isRetireProposal, PAIR_PASS_GATE, 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, judgeReason) {
const gateDecision = {
outcome: "auto-accepted",
reason,
gate: DRAIN_GATE,
...(judgeReason ? { judgeReason } : {}),
};
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, judgeReason) {
if (opts.dryRun)
return undefined;
try {
await rejectFn({
stashDir: opts.stashDir,
id,
reason,
gateDecision: {
outcome: "auto-rejected",
reason: gateReason,
gate: DRAIN_GATE,
...(judgeReason ? { judgeReason } : {}),
},
});
return undefined;
}
catch (err) {
return errMessage(err);
}
}
/**
* `text` as a fenced block whose fence is longer than any backtick run inside
* it (the CommonMark rule), so a note holding its own code block is not cut
* short at the first inner fence.
*/
export function fencedBlock(text) {
let longest = 2;
for (const run of text.match(/`+/g) ?? [])
if (run.length > longest)
longest = run.length;
const fence = "`".repeat(longest + 1);
return [fence, text, fence];
}
/** The judgment prompt: the proposal, the live asset it would overwrite, same-ref siblings, and for a promotion the nearest knowledge notes. */
export function buildJudgmentPrompt(proposal, reason, ctx) {
// A promotion (a memory proposed as a new knowledge note) must also be durable.
const promotion = proposal.source === "consolidate" && ctx.liveAsset === undefined;
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",
...fencedBlock(proposalContent(proposal)),
];
if (ctx.liveAsset !== undefined) {
sections.push("", "## Current live asset (would be overwritten on accept)", ...fencedBlock(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})`, ...fencedBlock(proposalContent(sib)));
}
}
if (ctx.neighbours && ctx.neighbours.length > 0) {
sections.push("", "## Existing knowledge notes nearest to this promotion's source memory");
for (const note of ctx.neighbours) {
sections.push("", `### ${note.ref}`, note.description, ...fencedBlock(note.excerpt));
}
sections.push("", "Reject the promotion if these notes already cover what it says, even in other words.");
}
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.", promotion
? '- reject: the proposal is wrong, a duplicate, contradicts the live asset, or is not durable: it reports the state of something that changes (a status, rollout, branch, commit, version, test count, "as of <date>"), is a plan not yet carried out, or describes something already retired, replaced or superseded. A lesson drawn from an incident is durable.'
: "- 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, deferNotes) {
const byId = new Map(pending.map((p) => [p.id, p]));
const notices = noticeSet();
const stillDeferred = [];
const acceptedPromotions = [];
const cappedBefore = result.skippedByCap.length;
for (const item of result.deferred) {
const proposal = byId.get(item.id);
if (!proposal) {
stillDeferred.push(item);
continue;
}
const liveAsset = readLiveAssetContent(opts.stashDir, proposal.ref);
const prompt = buildJudgmentPrompt(proposal, item.reason, {
liveAsset,
siblings: pending.filter((p) => p.ref === proposal.ref && p.id !== proposal.id),
// A create the model otherwise judges blind: the model never sees knowledge/.
...(liveAsset === undefined
? { neighbours: promotionNeighbours(opts.stashDir, proposal, acceptedPromotions) }
: {}),
});
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) {
deferNotes.set(item.id, { reason: dispatch.error ? "judgment-error" : "judgment-parse-failure" });
stillDeferred.push(item);
continue;
}
if (verdict.decision === "defer") {
deferNotes.set(item.id, {
reason: "judgment-deferred",
...(verdict.reason ? { judgeReason: verdict.reason } : {}),
});
stillDeferred.push(item);
continue;
}
if (verdict.decision === "reject") {
const failure = await rejectProposal(opts, item.id, verdict.reason || "judgment: reject", "judgment-reject", rejectFn, verdict.reason);
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,
...(verdict.reason ? { judgeReason: verdict.reason } : {}),
});
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, verdict.reason);
if (outcome === "promoted") {
result.promoted.push(item.id);
if (proposal.source === "consolidate" && proposal.promotionSource !== undefined)
acceptedPromotions.push(proposal);
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 knowledge notes nearest to a consolidate promotion's source memory; none for any other proposal. */
function promotionNeighbours(stashDir, proposal, acceptedPromotions) {
if (proposal.source !== "consolidate" || proposal.promotionSource === undefined)
return [];
try {
const parsed = parseRefInput(proposal.promotionSource);
const typeDir = stashDirFor(parsed.type);
if (!typeDir)
return [];
const memoryPath = assetPathForName(parsed.type, path.join(stashDir, typeDir), parsed.name);
const existing = nearestKnowledgeNotes(memoryPath);
const source = fs.readFileSync(memoryPath, "utf8");
const shingles = wordShingles(source);
// Promotions accepted earlier in this drain are not in the index yet; the
// ones sharing text with this memory join its neighbours (#1085).
const inDrain = acceptedPromotions
.map((accepted) => {
const parsedContent = parseFrontmatter(proposalContent(accepted));
const body = parsedContent.content;
return {
ref: accepted.ref,
description: typeof parsedContent.data.description === "string" ? parsedContent.data.description : "",
excerpt: body.length > NEIGHBOUR_EXCERPT_CHARS ? `${body.slice(0, NEIGHBOUR_EXCERPT_CHARS)}...` : body,
containment: shingleContainment(shingles, body),
};
})
.filter((note) => note.containment > 0)
.sort((a, b) => b.containment - a.containment)
.map(({ containment: _, ...note }) => note);
return [...inDrain, ...existing].slice(0, NEIGHBOUR_NOTE_COUNT);
}
catch {
return [];
}
}
/** 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 auto-accepted only when the
// pair pass staged it: nothing unique on either side, confirmed by a
// second look, no continuity risk; every other one waits for a direct
// `akm proposal accept` (spec §25.6). Checked before isEmptyDiff, which
// has nothing meaningful to read on a delete-primary change.
if (isRetireProposal(proposal)) {
const staged = proposal.gateDecision;
if (staged?.outcome === "staged" &&
staged.gate === PAIR_PASS_GATE &&
staged.contentHash === proposalContentHash(proposal) &&
!proposal.retirement?.continuityRisk) {
accepts.push({ id: proposal.id, reason: staged.reason });
}
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}`);
}
}
}
const deferNotes = new Map();
if (opts.judgment && result.deferred.length > 0) {
await runJudgmentTier({ ...opts, judgment: opts.judgment }, result, pending, cap - promotedHere, promoteFn, rejectFn, judgmentSeams, deferNotes);
}
// #577: whatever stays undecided is left for review (`review_needed` in the ledger).
if (!opts.dryRun) {
for (const item of result.deferred) {
const note = deferNotes.get(item.id);
const reviewReason = note?.reason ?? (opts.judgment ? "judgment-deferred" : "no-judge-configured");
try {
recordGateDecision(opts.stashDir, item.id, {
outcome: "deferred",
reason: reviewReason,
gate: DRAIN_GATE,
...(note?.judgeReason ? { judgeReason: note.judgeReason } : {}),
});
}
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 ?? {});
}
}