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
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/.
/**
* `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 };
}