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

273 lines (272 loc) • 12.3 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 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), }; }