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

289 lines (288 loc) • 14 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/. /** * `akm proposal {list,show,accept,reject,reopen,diff}` — review surface for the * proposal substrate (#225). * * Each function returns a plain JSON envelope; the CLI dispatcher in * `src/cli.ts` flows the result through the standard * `shapeForCommand` + `formatPlain` pipeline. There is no `JSON.stringify` * fallback in the output layer — every shape is registered explicitly in * `src/output/shapes.ts` and `src/output/text.ts`. */ import { resolveStashDir } from "../../core/common.js"; import { loadConfig } from "../../core/config/config.js"; import { NotFoundError } from "../../core/errors.js"; import { resolveWriteTarget } from "../../core/write-source.js"; import { withAssetMutationLease } from "../../indexer/index-writer-lock.js"; import { isRetireProposal, proposalWaitingSince } from "./proposal-types.js"; import { diffProposal, listProposals, preflightProposalPromotion, promoteProposal, proposalContent, rejectProposalDurably, reopenProposals, resolveProposalId, revertProposal, } from "./repository.js"; import { validateProposal } from "./validators/proposals.js"; // ── Shared helpers ────────────────────────────────────────────────────────── function resolveStash(stashDir) { if (stashDir) return stashDir; return resolveStashDir(); } function resolveProposalQueue(stashDir, queue, config) { if (stashDir) return { stashDir }; if (!queue) return { stashDir: resolveStash() }; const target = resolveWriteTarget(config ?? loadConfig(), queue, { flag: "--queue" }); return { stashDir: target.source.path, target }; } /** * Thin in-process read of the pending proposal queue, used by the health HTML * report builder (#582) so it never shells out to `akm proposal list`. * * Deliberately narrow (one optional arg, returns the storage-layer rows) so * the parallel proposal-storage-to-SQLite consolidation only has to swap this * one function's body. */ export function listPendingProposals(stashDir) { return listProposals(resolveStash(stashDir), { status: "pending" }); } export function akmProposalList(options = {}) { const { stashDir: stash } = resolveProposalQueue(options.stashDir, options.queue, options.config); // `--status accepted|rejected|reverted` implies archive-inclusion since the // live queue only ever contains pending entries. const includeArchive = options.includeArchive === true || options.status === "accepted" || options.status === "rejected" || options.status === "reverted"; const proposals = listProposals(stash, { includeArchive, status: options.status, ref: options.ref, type: options.type, }).filter((p) => options.generator === undefined || p.source === options.generator); return { schemaVersion: 1, totalCount: proposals.length, proposals }; } export function akmProposalShow(options) { const queue = resolveProposalQueue(options.stashDir, options.queue, options.config); const stash = queue.stashDir; const proposal = resolveProposalId(stash, options.id); let validation = validateProposal(proposal); // An explicit stashDir without config is an in-process storage test seam; it // has no authenticated write-target context to preflight against. A retire // proposal writes no content — preflightProposalPromotion's stamp/lint // machinery is create/update-shaped and has nothing meaningful to check // here; `diff` already shows the body being retired. if (validation.ok && proposal.status === "pending" && !isRetireProposal(proposal) && (options.config !== undefined || options.stashDir === undefined)) { try { preflightProposalPromotion(options.config ?? loadConfig(), proposal, { queueTarget: queue.target }); } catch (error) { validation = { ok: false, findings: [{ kind: "promotion", message: error instanceof Error ? error.message : String(error) }], }; } } return { schemaVersion: 1, proposal, validation, }; } export async function akmProposalAccept(options) { const config = options.config ?? loadConfig(); const queue = resolveProposalQueue(options.stashDir, options.queue, config); const stash = queue.stashDir; const resolvedId = resolveProposalId(stash, options.id).id; const result = await promoteProposal(stash, config, resolvedId, { target: options.target, queueTarget: queue.target, gateDecision: options.gateDecision }, options.ctx); return { schemaVersion: 1, ok: true, id: result.proposal.id, ref: result.ref, assetPath: result.assetPath, proposal: result.proposal, }; } export async function akmProposalReject(options) { return withAssetMutationLease("proposal-reject", async () => { const config = options.config ?? loadConfig(); const { stashDir: stash } = resolveProposalQueue(options.stashDir, options.queue, config); const proposalId = resolveProposalId(stash, options.id, options.ctx).id; const updated = rejectProposalDurably(stash, proposalId, options.reason, options.ctx, options.gateDecision); return { schemaVersion: 1, ok: true, id: updated.id, ref: updated.ref, ...(options.reason !== undefined ? { reason: options.reason } : {}), proposal: updated, }; }); } /** * Put rejected proposals back in the queue as `pending` (#997) — the undo the * proposal queue lacked. All-or-nothing: see {@link reopenProposals}. An * archived proposal is found by its full id (a prefix only matches the pending * queue), the same as `revert`. */ export async function akmProposalReopen(options) { return withAssetMutationLease("proposal-reopen", async () => { const config = options.config ?? loadConfig(); const queue = resolveProposalQueue(options.stashDir, options.queue, config); const ids = options.ids.map((id) => { try { return resolveProposalId(queue.stashDir, id, options.ctx).id; } catch (error) { if (!(error instanceof NotFoundError)) throw error; throw new NotFoundError(error.message, error.code, "A rejected proposal is addressed by its full id (a prefix only matches pending proposals): `akm proposal list --status rejected` lists them."); } }); const reopened = reopenProposals(queue.stashDir, config, ids, { queueTarget: queue.target, ...(options.reason !== undefined ? { reason: options.reason } : {}) }, options.ctx); return reopened.map((proposal) => ({ schemaVersion: 1, ok: true, id: proposal.id, ref: proposal.ref, ...(options.reason !== undefined ? { reason: options.reason } : {}), proposal, })); }); } /** What a reviewer of a retire proposal needs to know that its diff (a file leaving) does not say. */ const RETIRE_DIFF_NOTE = "Accepting archives the retired file under .akm/memory-cleanup/archive/ (nothing is deleted); " + "`akm proposal revert` restores it byte-exactly."; /** The fields a retire proposal's diff result adds to an ordinary one (#997). */ function retireDiffFields(retirement) { return { op: "delete", ...(retirement ? { retirement: { retiredRef: retirement.retiredRef, successorRef: retirement.successorRef, judgeLabel: retirement.judgeLabel, judgeReason: retirement.judgeReason, cosine: retirement.cosine, ...(retirement.continuityRisk ? { continuityRisk: retirement.continuityRisk } : {}), }, } : {}), note: RETIRE_DIFF_NOTE, }; } export function akmProposalDiff(options) { const config = options.config ?? loadConfig(); const queue = resolveProposalQueue(options.stashDir, options.queue, config); const stash = queue.stashDir; const proposal = resolveProposalId(stash, options.id); const diff = diffProposal(stash, config, proposal.id, { target: options.target, queueTarget: queue.target }); return { schemaVersion: 1, id: proposal.id, ref: proposal.ref, isNew: diff.isNew, unified: diff.unified, ...(diff.targetPath ? { targetPath: diff.targetPath } : {}), ...(isRetireProposal(proposal) ? retireDiffFields(proposal.retirement) : {}), }; } /** * Restore an accepted proposal's prior content from the backup captured at * promotion time (Advantage D6c / Phase 6C). * * Failure modes (all surface as typed errors so the CLI can map exit codes): * - Proposal id does not resolve → `NotFoundError("FILE_NOT_FOUND")` * (raised by `resolveProposalId` / `getProposal`). * - Proposal is not `status === "accepted"` → `UsageError("INVALID_FLAG_VALUE")` * with message `"only accepted proposals can be reverted ..."`. * - No backup content on the record (new-asset proposals capture none) → * `UsageError` with message `"no backup available for this proposal ..."`. * * On success, emits a `proposal_reverted` event for observability, mirroring * how `akmProposalAccept` emits `promoted` and `akmProposalReject` emits * `rejected`. */ export async function akmProposalRevert(options) { const config = options.config ?? loadConfig(); const queue = resolveProposalQueue(options.stashDir, options.queue, config); const stash = queue.stashDir; const resolvedId = resolveProposalId(stash, options.id).id; const result = await revertProposal(stash, config, resolvedId, { target: options.target, queueTarget: queue.target }, options.ctx); return { schemaVersion: 1, ok: true, id: result.proposal.id, ref: result.ref, assetPath: result.assetPath, proposal: result.proposal, }; } /** The retired file's current line count for `--max-diff-lines` (S6); unresolvable is never size-filtered here — the real accept/reject attempt fails cleanly on a genuinely stale target. */ function retiredTargetLineCount(stashDir, config, proposal, queueTarget) { try { const existing = diffProposal(stashDir, config, proposal.id, { queueTarget }).existing; return existing === null ? 0 : existing.split("\n").length; } catch { return 0; } } /** * Bulk accept/reject every pending proposal from one generator, applying the * shared `--max-diff-lines` / `--older-than` filters. Consolidates the two * near-identical loops that lived in `proposal-cli.ts` (Chunk 6 WI-6.6); * behavior is verbatim — same filter order, same per-item envelopes, same * dry-run record shape. The destructive-confirmation prompt stays CLI-side. */ export async function bulkAdjudicateProposals(options) { const config = options.config ?? loadConfig(); const { stashDir, target: queueTarget } = resolveProposalQueue(options.stashDir, options.queue, config); // Every filter EXCEPT the continuityRisk exclusion below — matched against // separately so its own count (S4) can be reported apart from an ordinary // --max-diff-lines/--older-than miss. const matched = listProposals(stashDir, { status: "pending" }).filter((p) => { if (p.source !== options.generator) return false; if (options.maxDiffLines !== undefined) { // S6: a retire proposal's own payload is empty (it deletes its // target) — proposalContent() would always read as 1 line, so // --max-diff-lines could never filter one out. Count the retired // file's own current line count instead. const lines = isRetireProposal(p) ? retiredTargetLineCount(stashDir, config, p, queueTarget) : proposalContent(p).split("\n").length; if (lines > options.maxDiffLines) return false; } if (options.olderThanMs !== undefined) { const age = Date.now() - new Date(proposalWaitingSince(p)).getTime(); if (age < options.olderThanMs) return false; } return true; }); // Item 1 (continuity check, alpha.9 plan §5.4, rule R3): a retire proposal // the check flagged is never bulk-accepted, by generator or any other // sweep — only a person accepting it by id can. Bulk reject is unaffected: // declining a risky proposal is never the unsafe direction. const isContinuityExcluded = (p) => options.action === "accept" && Boolean(p.retirement?.continuityRisk); const skippedForContinuityRisk = matched.filter(isContinuityExcluded).length; const pending = matched.filter((p) => !isContinuityExcluded(p)); const results = []; for (const proposal of pending) { if (options.dryRun) { results.push({ id: proposal.id, ref: proposal.ref, source: proposal.source, dryRun: true }); } else if (options.action === "accept") { results.push(await akmProposalAccept({ stashDir, id: proposal.id, queue: options.queue, target: options.target, config })); } else { results.push(await akmProposalReject({ stashDir, id: proposal.id, queue: options.queue, reason: options.reason, config })); } } return { count: results.length, results, skippedForContinuityRisk }; }