UNPKG

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
// 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 ?? {}); } }