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
273 lines (272 loc) • 12.3 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 new <type> <name> --task ...` — proposal-producing command
* (#226).
*
* Mirrors {@link akmReflect} but for fresh authoring. The engine, of any kind,
* receives a task description plus per-asset-type schema hints and returns a
* brand-new asset payload as JSON on stdout. The output lands ONLY in the
* proposal queue.
*
* Failures use the same {@link AgentFailureReason} discriminants as
* `akm reflect`. `propose_invoked` is emitted at command entry.
*/
import { placementTypes, stashDirFor } from "../../core/asset/asset-placement.js";
import { parseRefInput } from "../../core/asset/resolve-ref.js";
import { resolveStashDir } from "../../core/common.js";
import { generatedContentRejection } from "../../core/content-safety.js";
import { UsageError } from "../../core/errors.js";
import { appendEvent } from "../../core/events.js";
import { redactSensitiveText } from "../../core/redaction.js";
import { resolveStandardsContext } from "../../core/standards/resolve-standards-context.js";
import { runStructured } from "../../core/structured.js";
import { warn } from "../../core/warn.js";
import { deriveEntryProvenance } from "../../indexer/installations.js";
import { fallbackAnnouncement } from "../../integrations/agent/engine-fallback.js";
import { buildExecution, resolveExecution } from "../../integrations/agent/execution.js";
import { buildProposePrompt, PROPOSAL_JSON_SCHEMA, validateProposalPayload, } from "../../integrations/agent/prompts.js";
import { assertRunnerCredentials, collectDispatchSensitiveValues, runExecution, unwrapHarnessReply, } from "../../integrations/agent/runner-dispatch.js";
import { baseFailureFields, enoentHintMessage, isEnoentFailure } from "../agent/agent-support.js";
import { createProposal, resolveProposalQueueTarget, } from "./repository.js";
function failureEnvelope(result, type, name, engine, notices, fallbackReason = "non_zero_exit") {
return {
...baseFailureFields(result, fallbackReason),
schemaVersion: 2,
type,
name,
engine,
...(notices.length > 0 ? { notices } : {}),
};
}
function noticeFields(notices) {
return notices.length > 0 ? { notices } : {};
}
const DISPATCH_FAILED = Symbol("proposal-dispatch-failed");
/**
* Resolve, lower, and dispatch the already-rendered proposal prompt with the
* proposal's JSON Schema as its output schema, and capture the reply. A reply
* that is not a proposal gets one corrective retry.
*/
async function dispatchProposalPrompt(prompt, config, options, onDispatchReady) {
const current = {
...(options.engine !== undefined ? { engine: options.engine } : {}),
...(options.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}),
outputSchema: PROPOSAL_JSON_SCHEMA,
};
const lower = (content) => {
const prepared = resolveExecution({ content, config, current });
return { prepared, lowered: buildExecution(prepared.request, prepared.runner) };
};
const { prepared, lowered } = lower(prompt);
const engineName = prepared.request.engine.name;
const announcement = fallbackAnnouncement(prepared.fallbackEngineName, engineName);
if (announcement)
warn(announcement);
// Validate every required symbolic credential before the entry event opens
// durable state. Provider/runtime failures still occur after the event,
// preserving the command-attempt observability contract. The dispatch reads
// the same caller environment.
const envSource = options.runAgentOptions?.envSource;
assertRunnerCredentials(lowered.runner, envSource);
onDispatchReady();
options.onDispatchReady?.();
// runStructured dispatches at least once, so a returned or DISPATCH_FAILED exchange has a result.
const results = [];
let reply;
try {
reply = await runStructured({
dispatch: async (feedback) => {
const execution = feedback ? lower(`${prompt}\n\n${feedback}`).lowered : lowered;
const result = await runExecution(execution, { runOptions: options.runAgentOptions ?? {} });
results.push(result);
if (!result.ok)
throw DISPATCH_FAILED;
return unwrapHarnessReply(execution.runner, result).text;
},
validate: validateProposalPayload,
});
}
catch (err) {
if (err !== DISPATCH_FAILED)
throw err;
}
return {
result: results.at(-1),
...(reply ? { reply } : {}),
durationMs: results.reduce((total, result) => total + result.durationMs, 0),
engineName,
...(lowered.runner.kind === "llm" ? {} : { engineBin: lowered.runner.profile.bin }),
notices: lowered.notices,
sensitiveValues: collectDispatchSensitiveValues(lowered.runner, {}, envSource),
};
}
/**
* WI-8.5a — the fully-qualified `bundle//conceptId` item_ref for a proposal
* target in `stashDir`. The conceptId is BUILT from the D-R2 static table
* (`deriveEntryProvenance`), never looked up, so a propose target that does not
* yet exist on disk still keys onto its final spelling; the bundle is the
* write-target stash's installation id (same derivation the index write path
* uses). Matches `createProposal`'s durable `proposals.ref` mint, so the entry
* event, the fallback ref, and the stored proposal all carry one spelling.
*/
function proposeItemRef(bundleId, type, name) {
return deriveEntryProvenance({ bundleId, componentId: bundleId, adapterId: "akm" }, type, name).itemRef;
}
/**
* The command-entry `propose_invoked` event. WI-8.5b: the ref carries the same
* fully-qualified item_ref the durable proposal is minted under
* (`proposeItemRef`), so the entry event and the stored proposal agree.
*/
function emitProposeInvoked(bundleId, options) {
appendEvent({
eventType: "propose_invoked",
ref: proposeItemRef(bundleId, options.type, options.name),
metadata: {
type: options.type,
name: options.name,
task: options.task,
...(options.engine ? { engine: options.engine } : {}),
},
});
}
export async function akmPropose(options) {
if (!options.type?.trim()) {
throw new UsageError("propose: <type> is required.", "MISSING_REQUIRED_ARGUMENT");
}
if (!options.name?.trim()) {
throw new UsageError("propose: <name> is required.", "MISSING_REQUIRED_ARGUMENT");
}
if (!options.task?.trim()) {
throw new UsageError("propose: --task is required.", "MISSING_REQUIRED_ARGUMENT");
}
if (!stashDirFor(options.type)) {
throw new UsageError(`propose: unknown asset type "${options.type}". Known types: ${[...placementTypes()].sort().join(", ")}.`, "INVALID_FLAG_VALUE");
}
const stash = options.stashDir ?? resolveStashDir();
// 1. Resolve the write target. Engine/model/inference resolution happens
// exactly once below through the shared execution cascade.
const config = options.agentConfig ?? (await import("../../core/config/config.js")).loadConfig();
const target = resolveProposalQueueTarget(stash, config);
// 2. Build terminal user content.
// Standards "rulebook" for this target — wiki schema (wiki page) or stash
// convention/meta facts (non-wiki asset); empty when neither fires.
const standardsContext = resolveStandardsContext(`${options.type}:${options.name}`, stash);
const prompt = buildProposePrompt({
type: options.type,
name: options.name,
task: options.task,
...(standardsContext.trim() ? { standardsContext } : {}),
});
// 3. Preserve the fully-authored prompt as the terminal user content; the
// proposal's JSON Schema crosses the shared resolved/lowered boundary as the
// request's output schema, with no synthetic persona, conversation turn, or
// tool selection.
const dispatch = await dispatchProposalPrompt(prompt, config, options, () => emitProposeInvoked(target.source, options));
const { result, reply, engineName, notices, sensitiveValues } = dispatch;
if (!reply) {
// B3: ENOENT / not-found gives an actionable hint.
if (isEnoentFailure(result)) {
return {
...failureEnvelope(result, options.type, options.name, engineName, notices),
error: enoentHintMessage(dispatch.engineBin ?? engineName),
};
}
return failureEnvelope(result, options.type, options.name, engineName, notices);
}
// 5. The proposal the engine returned on stdout, validated.
if (!reply.ok) {
return {
schemaVersion: 2,
ok: false,
reason: "parse_error",
error: `Engine "${engineName}" reply was not valid proposal JSON after ${reply.attempts} attempts: ${reply.errors.join("; ")}`,
type: options.type,
name: options.name,
engine: engineName,
exitCode: result.exitCode,
stdout: result.stdout,
...(result.stderr ? { stderr: result.stderr } : {}),
...noticeFields(notices),
};
}
const payload = reply.value;
const unsafeContent = generatedContentRejection(payload.content, redactSensitiveText(payload.content, sensitiveValues));
if (unsafeContent) {
return {
schemaVersion: 2,
ok: false,
reason: "parse_error",
error: unsafeContent,
type: options.type,
name: options.name,
engine: engineName,
exitCode: result.exitCode,
...noticeFields(notices),
};
}
// 6. Insert the proposal. Note: we allow the agent's `ref` to normalise the
// asset name (e.g. path-cleanup), but only after validating that the ref is
// well-formed and the type still matches the requested type.
const expectedRef = proposeItemRef(target.source, options.type, options.name);
let ref = expectedRef;
if (payload.ref) {
let parsedRef;
try {
parsedRef = parseRefInput(payload.ref);
}
catch (err) {
return {
schemaVersion: 2,
ok: false,
reason: "parse_error",
error: err instanceof Error ? err.message : String(err),
type: options.type,
name: options.name,
engine: engineName,
exitCode: result.exitCode,
stdout: result.stdout,
...(result.stderr ? { stderr: result.stderr } : {}),
...noticeFields(notices),
};
}
if (parsedRef.type !== options.type) {
return {
schemaVersion: 2,
ok: false,
reason: "parse_error",
error: `Agent returned ref type ${parsedRef.type} but expected ${options.type}`,
type: options.type,
name: options.name,
engine: engineName,
exitCode: result.exitCode,
stdout: result.stdout,
...(result.stderr ? { stderr: result.stderr } : {}),
...noticeFields(notices),
};
}
ref = proposeItemRef(target.source, parsedRef.type, parsedRef.name);
}
const createInput = {
ref,
source: "propose",
sourceRun: `propose-${Date.now()}`,
target,
payload: {
content: payload.content,
...(payload.frontmatter ? { frontmatter: payload.frontmatter } : {}),
},
...(payload.confidence !== undefined ? { confidence: payload.confidence } : {}),
};
const proposal = createProposal(stash, createInput, options.ctx);
return {
schemaVersion: 2,
ok: true,
proposal,
ref: proposal.ref,
engine: engineName,
durationMs: dispatch.durationMs,
...noticeFields(notices),
};
}