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

162 lines (161 loc) • 7.04 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/. import { parseFrontmatter } from "../../../core/asset/frontmatter.js"; import { parseRefInput } from "../../../core/asset/resolve-ref.js"; import { proposalContent } from "../../../core/file-change.js"; import { lintLessonContent } from "../../../core/lesson-lint.js"; import { parseTaskSource } from "../../../tasks/source/parse-task-source.js"; import { compileWorkflowSource } from "../../../workflows/compile.js"; import { defaultProposalQualityValidators } from "./proposal-quality-validators.js"; const genericProposalValidator = { name: "generic-proposal-validator", appliesTo: () => true, validate(proposal, ctx) { const findings = []; if (!proposal.payload || typeof proposal.payload.content !== "string" || proposal.payload.content.trim() === "") { findings.push({ kind: "empty-content", message: `Proposal ${proposal.id} has empty content.` }); } try { ctx.parsedRef = parseRefInput(proposal.ref); } catch (err) { findings.push({ kind: "invalid-ref", message: `Proposal ${proposal.id} has invalid ref "${proposal.ref}": ${err.message}`, }); ctx.stop = true; return findings; } if (proposalContent(proposal).startsWith("---")) { try { parseFrontmatter(proposalContent(proposal)); } catch (err) { findings.push({ kind: "invalid-frontmatter", message: `Proposal ${proposal.id} frontmatter could not be parsed: ${err.message}`, }); } } return findings; }, }; const canonicalProposalValidators = { lesson(proposal) { return lintLessonContent(proposalContent(proposal), `proposal:${proposal.id}`).findings.map((finding) => ({ kind: finding.kind, message: finding.message, })); }, task(proposal, ctx) { const name = ctx.parsedRef?.name; if (!name) return []; // Version-routing seam (spec docs/plans/specs/p2a-task-source-v4.md // §3.6): a proposal body is validated by parsing alone — neither arm's // parsed document is inspected further, so routing through the union is // a pure swap; any parse failure (either version) is turned into an // `invalid-task-structure` finding by the try/catch this call sits in. parseTaskSource({ yaml: proposalContent(proposal), filePath: proposal.changes[0]?.path || proposal.ref, }); return []; }, workflow(proposal) { const content = proposalContent(proposal); if (!content.trim()) return []; // #859: proposedTarget is absent on legacy archived rows, but this // validator only ever runs on a proposal about to be minted or promoted // (both always carry proposedTarget — see the Proposal.proposedTarget // doc comment) — so hitting this is a genuine defect, not a legacy gap. if (!proposal.proposedTarget) { return [ { kind: "invalid-workflow-structure", message: `Workflow proposal ${proposal.id} (${proposal.ref}) is missing proposedTarget and cannot be validated.`, }, ]; } const sourcePath = proposal.changes[0]?.path || proposal.ref; const result = compileWorkflowSource(content, { path: sourcePath, workspaceRoot: proposal.proposedTarget.root, }); if (result.ok) return []; return result.errors.map((error) => ({ kind: "invalid-workflow-structure", message: `Workflow proposal ${proposal.id} (${proposal.ref}) is invalid [${error.code}] at ${error.path}:${error.line}: ${error.message}`, })); }, }; export function hasCanonicalProposalValidator(type) { return Object.hasOwn(canonicalProposalValidators, type); } const canonicalProposalValidator = { name: "canonical-asset-proposal-validator", appliesTo(_proposal, ctx) { return ctx.parsedRef !== undefined && hasCanonicalProposalValidator(ctx.parsedRef.type); }, validate(proposal, ctx) { const type = ctx.parsedRef?.type; const validator = type ? canonicalProposalValidators[type] : undefined; if (!validator) return []; try { return validator(proposal, ctx); } catch (error) { return [ { kind: `invalid-${type}-structure`, message: `${type} proposal ${proposal.id} (${proposal.ref}) is invalid: ${error instanceof Error ? error.message : String(error)}`, }, ]; } }, }; export const defaultProposalValidators = [ genericProposalValidator, canonicalProposalValidator, ...defaultProposalQualityValidators, ]; /** * Structural-only subset used by {@link createProposal}'s mint-time * canonical-structure gate (repository.ts, `hasCanonicalProposalValidator`). * That gate exists to reject a lesson/task/workflow proposal whose body is * not parseable as its type — it predates {@link defaultProposalQualityValidators} * and was previously safe to run in full there because every quality * validator was advisory (`advisory()` downgrades findings to `severity: * "warn"`, which {@link runProposalValidators}'s `ok` never treats as * failing). Blocking durable-content validators (the #952 truncation marker * and #962 redaction marker) deliberately * remain outside this subset, so running the full * {@link defaultProposalValidators} list at mint time could throw * `invalid_canonical_structure` for any lesson/task/workflow reflect * proposal whose body carries the truncation marker — instead of minting it * for a person to review. Quality validators (prose * shape, reflect size ratio, and durable-content guards) belong at * `proposal accept` / drain-promotion time, which already calls * {@link validateProposal} (the full list) via `preflightProposalPromotion` * / `promoteProposalWithLease`. */ export const canonicalOnlyProposalValidators = [ genericProposalValidator, canonicalProposalValidator, ]; export function runProposalValidators(proposal, validators = defaultProposalValidators, initialContext = {}) { const findings = []; const ctx = { ...initialContext }; for (const validator of validators) { if (!validator.appliesTo(proposal, ctx)) continue; findings.push(...validator.validate(proposal, ctx)); if (ctx.stop) break; } return { ok: findings.every((f) => f.severity === "warn"), findings }; }