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
659 lines (658 loc) • 34.1 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/.
/**
* Knowledge-command helpers extracted from `src/cli.ts`.
*
* Covers the shared pipeline for reading, naming, and writing markdown assets
* (knowledge and memory) from the CLI. Extracted to keep the CLI entry point
* focused on command definitions and routing.
*/
import fs from "node:fs";
import path from "node:path";
import { parse as yamlParse } from "yaml";
import { detectAdapterId } from "../../core/adapter/detect-adapter.js";
import { assertFlatAssetName, combineCreatePath, normalizeCreateSubPath } from "../../core/asset/asset-create.js";
import { assetPathForName, stashDirFor } from "../../core/asset/asset-placement.js";
import { assembleAsset } from "../../core/asset/asset-serialize.js";
import { parseFrontmatter } from "../../core/asset/frontmatter.js";
import { conceptIdFromTypeName, parseRefInput } from "../../core/asset/resolve-ref.js";
import { isHttpUrl, isWithin, resolveStashDir, tryReadStdinText } from "../../core/common.js";
import { loadConfig } from "../../core/config/config.js";
import { UsageError } from "../../core/errors.js";
import { resolveBundleWriteTarget, resolveMutationTarget } from "../../core/mutation-target.js";
import { resolveStashStandards } from "../../core/standards/resolve-stash-standards.js";
import { warn } from "../../core/warn.js";
import { commitWriteTargetBoundary, formatRefForMessage, recordWriteTargetPath, resolveWriteTarget, writeAssetToSource, } from "../../core/write-source.js";
import { indexWrittenAssets } from "../../indexer/index-written-assets.js";
import { deriveInstallations, slugForPath } from "../../indexer/installations.js";
import { resolveSourceEntries } from "../../indexer/search/search-source.js";
import { storeSecretResolver } from "../../sources/snapshot-fetchers/secret-seam.js";
import { fetchWebsiteMarkdownSnapshot, shouldAllowPrivateWebsiteUrlForTests, } from "../../sources/snapshot-fetchers/website-ingest.js";
import { writeSupersededEdge } from "../improve/memory/memory-belief.js";
import { refToRelPath, resolveRefPathInStash } from "../lint/base-linter.js";
const MAX_CAPTURED_ASSET_SLUG_LENGTH = 64;
// ── Asset-name normalisation ─────────────────────────────────────────────────
/**
* Validate and normalise a markdown asset name supplied by the user.
*
* Strips the `.md` extension, rejects empty names, and guards against path
* traversal (`..` segments). The `fallback` is used when `name` is undefined.
*/
export function normalizeMarkdownAssetName(name, fallback) {
const trimmed = (name ?? fallback)
.trim()
.replace(/\\/g, "/")
.replace(/^\/+|\/+$/g, "")
.replace(/\.md$/i, "");
if (!trimmed)
throw new UsageError("Asset name cannot be empty.");
const segments = trimmed.split("/");
if (segments.some((segment) => !segment || segment === "." || segment === "..")) {
throw new UsageError("Asset name must be a relative path without '.' or '..' segments.");
}
return trimmed;
}
// `--path`/`--name` create semantics are shared across all asset-creating
// commands; re-exported here so existing `./knowledge` importers keep working.
export { assertFlatAssetName, combineCreatePath, normalizeCreateSubPath };
function slugifyAssetName(value, fallbackPrefix) {
const slug = value
.toLowerCase()
.replace(/^[#>\-\s]+/, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, MAX_CAPTURED_ASSET_SLUG_LENGTH);
return slug || `${fallbackPrefix}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
}
/**
* Derive a slug-style asset name from `content` and an optional `preferred`
* hint (e.g. a URL-derived page title or the source filename stem).
*/
export function inferAssetName(content, fallbackPrefix, preferred) {
const firstNonEmptyLine = content
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0);
const basis = preferred?.trim() || firstNonEmptyLine || fallbackPrefix;
return slugifyAssetName(basis, fallbackPrefix);
}
// ── Content reading ──────────────────────────────────────────────────────────
/**
* Read knowledge content from a local file path or stdin (`"-"`).
*
* Returns the raw text and an optional `preferredName` derived from the
* source filename stem (used as a slug fallback when no `--name` flag was
* supplied).
*/
export function readKnowledgeContent(source) {
if (source === "-") {
const content = tryReadStdinText();
if (!content?.trim()) {
throw new UsageError("No stdin content received. Pipe a document into stdin or pass a file path.");
}
return { content };
}
const resolvedSource = path.resolve(source);
let stat;
try {
stat = fs.statSync(resolvedSource);
}
catch {
throw new UsageError(`Knowledge source not found: "${source}". Pass a readable file path or "-" for stdin.`);
}
if (!stat.isFile()) {
throw new UsageError(`Knowledge source must be a file: "${source}".`);
}
return {
content: fs.readFileSync(resolvedSource, "utf8"),
preferredName: path.basename(resolvedSource, path.extname(resolvedSource)),
};
}
/**
* Read knowledge content from a local path, stdin (`"-"`), or a remote URL.
*
* URLs are fetched via `fetchWebsiteMarkdownSnapshot`; local sources delegate
* to `readKnowledgeContent`.
*/
export async function readKnowledgeInput(source, options) {
if (!isHttpUrl(source))
return readKnowledgeContent(source);
const snapshot = await fetchWebsiteMarkdownSnapshot(source, {
stashDir: options?.stashDir,
resolveSecret: storeSecretResolver.resolveSecret,
allowPrivateHosts: options?.allowPrivateHosts ?? shouldAllowPrivateWebsiteUrlForTests(source),
});
return { content: snapshot.content, preferredName: snapshot.preferredName };
}
/**
* Parse a `--xref` / `--supersedes` value through the new-grammar input parser
* (`parseRefInput`, the `[bundle//]conceptId` grammar) so malformed values get a
* structured error instead of a misleading "did not resolve". Bundle qualifiers
* are retained for exact source membership checks.
*/
function parseWriteRef(raw, flag) {
let parsed;
try {
parsed = parseRefInput(raw);
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new UsageError(`${flag} "${raw}" is not a valid asset ref: ${message}`, "INVALID_FLAG_VALUE", `Refs use the conceptId form, e.g. ${flag} knowledge/auth-flow.`);
}
// Bundle qualifiers remain explicit so duplicate names resolve in the named
// source.
const conceptId = conceptIdFromTypeName(parsed.type, parsed.name);
const origin = parsed.origin;
return {
ref: origin ? `${origin}//${conceptId}` : conceptId,
type: parsed.type,
name: parsed.name,
...(origin ? { origin } : {}),
};
}
/**
* Trim, parse, and dedupe `--xref` / `--supersedes` flag values, in argv
* order. Parsing comes before deduplication so normalized spellings collapse
* into one canonical entry.
*/
function parseWriteRefs(rawRefs, flag) {
const parsedRefs = [];
for (const raw of rawRefs) {
const trimmed = raw.trim();
if (!trimmed)
continue;
const parsed = parseWriteRef(trimmed, flag);
if (!parsedRefs.some((p) => p.ref === parsed.ref))
parsedRefs.push(parsed);
}
return parsedRefs;
}
function resolveWriteRefRoots(target, targetFlag) {
const cfg = loadConfig();
let writeTarget;
try {
writeTarget = resolveWriteTarget(cfg, target, { flag: targetFlag });
}
catch (error) {
if (!target)
throw error;
try {
writeTarget = resolveBundleWriteTarget(cfg, target);
}
catch {
throw error;
}
}
const stashRoot = writeTarget.source.path;
let workingStash;
try {
workingStash = resolveStashDir();
}
catch {
// No working stash configured — the write target alone.
}
const mutableRoots = [stashRoot];
if (workingStash && path.resolve(workingStash) !== path.resolve(stashRoot))
mutableRoots.push(workingStash);
const namedSources = resolveSourceEntries(undefined, cfg);
const installations = deriveInstallations(namedSources);
const bundleByPath = new Map(namedSources.flatMap((source, index) => {
const bundleId = installations[index]?.id;
return bundleId ? [[path.resolve(source.path), bundleId]] : [];
}));
const otherSources = namedSources.filter((s) => !mutableRoots.some((m) => path.resolve(m) === path.resolve(s.path)) && fs.existsSync(s.path));
const roots = [
...mutableRoots
.filter((p) => fs.existsSync(p))
.map((p) => {
const source = namedSources.find((candidate) => path.resolve(candidate.path) === path.resolve(p));
const mutable = path.resolve(p) === path.resolve(stashRoot)
? writeTarget.source.adapterId === "akm"
: source?.writable === true && (source.adapterId ?? detectAdapterId(p)) === "akm";
return {
path: p,
source,
bundleId: bundleByPath.get(path.resolve(p)) ?? slugForPath(p),
mutable,
};
}),
...otherSources.map((source) => ({
path: source.path,
source,
bundleId: bundleByPath.get(path.resolve(source.path)) ?? slugForPath(source.path),
mutable: false,
})),
];
return { roots };
}
function rootsForWriteRef(parsed, roots) {
if (!parsed.origin)
return roots;
return roots.filter((root) => root.bundleId === parsed.origin);
}
function canonicalWriteRef(parsed, bundleId) {
return `${bundleId}//${conceptIdFromTypeName(parsed.type, parsed.name)}`;
}
/**
* True when write-time validation must FAIL OPEN for this ref type — exactly
* lint's `checkMissingRefs` policy (`if (relPath === null) continue`,
* base-linter.ts): a type the slug resolver cannot map to a path (script refs
* are contract-pinned to return null) is accepted without an existence check
* rather than being unwinnable. Workflow refs never fail open — they resolve
* stash-rooted via {@link locateWriteRefInRoot}.
*/
function isFailOpenRefType(type, name) {
return type !== "workflow" && refToRelPath(type, name) === null;
}
/**
* Resolve a write-time ref to its primary on-disk file within a single stash
* root. Wraps lint's `resolveRefPathInStash` with one addition: workflow
* refs are probed against the ROOT's workflows/ dir first (every recognized
* workflow extension), because `workflowSpec.toAssetPath` inside
* `refToRelPath` probes the CWD — and write validation must not depend on the
* caller's cwd.
*/
function locateWriteRefInRoot(type, name, root) {
if (type === "workflow") {
const typeRoot = path.join(root, stashDirFor("workflow") ?? "workflows");
const candidate = assetPathForName("workflow", typeRoot, name);
if (fs.existsSync(candidate))
return candidate;
}
const relPath = refToRelPath(type, name);
if (relPath === null)
return null;
return resolveRefPathInStash(relPath, type, name, root);
}
/** Build the shared exit-2 error for refs that resolved in no root. */
function unresolvedRefsError(flag, unresolved) {
const firstName = unresolved[0]?.name ?? "asset";
const firstType = unresolved[0]?.type ?? "knowledge";
return new UsageError(`${flag} ref${unresolved.length > 1 ? "s" : ""} did not resolve in the write target or any configured source: ${unresolved.map((u) => u.ref).join(", ")}`, "INVALID_FLAG_VALUE", `Find the intended asset with \`akm search "${firstName}" --type ${firstType}\`. Refs use the form [bundle//]conceptId (e.g. knowledge/guide.md).`);
}
// ── Cross-references (--xref) ────────────────────────────────────────────────
/**
* Soft cap on xrefs per asset, from the back-linking conventions' "~5" rule.
* Exceeding it warns on stderr but never blocks the write (the cap is a
* heuristic, not a validator bound).
*/
export const XREF_SOFT_CAP = 5;
/**
* Validate `--xref` flag values before ANY write happens.
*
* Each `[bundle//]conceptId` must resolve to a real asset in the write-ref root
* set (write target + working stash + configured sources — see
* {@link resolveWriteRefRoots}; cross-stash provenance refs into read-only
* sources are accepted). An unresolvable ref is input validation of an
* explicitly passed flag — it throws {@link UsageError} (exit 2) naming every
* bad ref, and the caller must invoke this before writing so a failed
* validation leaves the stash untouched. Resolution reuses the lint
* ref-resolver helpers (`refToRelPath` / `resolveRefPathInStash`) — do not
* fork a second resolver — and mirrors lint's fail-open policy: a type the
* resolver cannot map to a path (for example a script concept) is accepted
* without an existence check.
*
* Returns fully-qualified durable refs, deduplicated in argv order. More than
* {@link XREF_SOFT_CAP} refs emits a stderr warning (soft cap) but still
* returns them all.
*/
export function resolveXrefsForWrite(rawXrefs, target, targetFlag) {
const parsedRefs = parseWriteRefs(rawXrefs, "--xref");
if (parsedRefs.length === 0)
return [];
const { roots } = resolveWriteRefRoots(target, targetFlag);
const unresolved = [];
const xrefs = [];
for (const parsed of parsedRefs) {
const candidates = rootsForWriteRef(parsed, roots);
const resolvedRoot = isFailOpenRefType(parsed.type, parsed.name)
? candidates[0]
: candidates.find((root) => locateWriteRefInRoot(parsed.type, parsed.name, root.path) !== null);
if (!resolvedRoot) {
unresolved.push(parsed);
continue;
}
const ref = canonicalWriteRef(parsed, resolvedRoot.bundleId);
if (!xrefs.includes(ref))
xrefs.push(ref);
}
if (unresolved.length > 0) {
throw unresolvedRefsError("--xref", unresolved);
}
if (xrefs.length > XREF_SOFT_CAP) {
warn(`Warning: ${xrefs.length} xrefs exceeds the ~${XREF_SOFT_CAP} soft cap from the back-linking conventions. ` +
"Each xref folds into this asset's search hints, so extras blur its ranking signal. Writing anyway.");
}
return xrefs;
}
/**
* Merge validated xrefs into a markdown document's frontmatter `xrefs:` list.
*
* A document without frontmatter gains a single block; a document WITH
* frontmatter keeps every existing key and gets the refs dedupe-appended to
* its `xrefs:` list — never a nested second block. Runs BEFORE the asset is
* written so write-path indexing sees the final content. Returns `content`
* unchanged when `xrefs` is empty.
*
* The merge round-trips the frontmatter through the YAML parser, so it is
* only safe when the existing block parses as a YAML mapping. Malformed YAML
* would silently fall back to `parseFrontmatter`'s lenient scalar-only
* scanner and re-serializing that lossy result would destroy list/nested
* values (`tags: [a, b]` → `tags: ""`). Rather than corrupt data the caller
* asked to preserve, a block that is not a parseable YAML mapping throws
* {@link UsageError} (exit 2, before any write). The AKM write boundary also
* rejects malformed frontmatter without `--xref` because it cannot safely add
* required type metadata. Known
* cosmetic limitation: YAML comments and anchors in a VALID block do not
* survive the round-trip (values are preserved).
*/
export function mergeXrefsIntoContent(content, xrefs) {
if (xrefs.length === 0)
return content;
const parsed = parseFrontmatter(content);
if (parsed.frontmatter?.trim()) {
if (!isParseableYamlMapping(parsed.frontmatter)) {
throw new UsageError("--xref cannot merge into this document: its frontmatter is not a parseable YAML mapping, and rewriting it would drop the values the parser could not read.", "INVALID_FLAG_VALUE", "Fix the document's frontmatter (e.g. an unterminated quote) and retry.");
}
}
const existingValue = parsed.data.xrefs;
const existing = Array.isArray(existingValue)
? existingValue.filter((v) => typeof v === "string" && v.trim().length > 0)
: typeof existingValue === "string" && existingValue.trim()
? [existingValue.trim()]
: [];
const merged = [...existing];
for (const ref of xrefs) {
if (!merged.includes(ref))
merged.push(ref);
}
return assembleAsset({ ...parsed.data, xrefs: merged }, parsed.content);
}
/**
* True when a raw frontmatter block (the text between the `---` fences)
* parses as a YAML mapping — the precondition for any round-trip rewrite.
* Malformed YAML falls back to `parseFrontmatter`'s lossy lenient scanner
* (scalars only), and re-serializing that result destroys list/nested values
* (`tags: [a, b]` → `tags: ""`), so writers must refuse instead of rewriting.
*/
function isParseableYamlMapping(frontmatter) {
try {
const fmValue = yamlParse(frontmatter);
return fmValue !== null && typeof fmValue === "object" && !Array.isArray(fmValue);
}
catch {
return false;
}
}
/** Resolve any qualified supersedes ref as the mutation target for remember/import. */
export function resolveSupersedesWriteTarget(rawRefs, target, targetFlag) {
const config = loadConfig();
let effectiveTarget = target;
for (const parsed of parseWriteRefs(rawRefs, "--supersedes")) {
if (!parsed.origin)
continue;
const resolved = resolveMutationTarget(config, { type: parsed.type, name: parsed.name, origin: parsed.origin }, effectiveTarget, { flag: targetFlag }).target;
effectiveTarget = resolved.selector ?? resolved.source.name;
}
return effectiveTarget;
}
/**
* Asset types `--supersedes` must refuse to demote: the demotion writes a YAML
* frontmatter block onto the target file, and these types are RAW files whose
* bytes are the value (a secret's entire content is the credential; a task is
* pure YAML that a prepended second document breaks; scripts have arbitrary
* syntax). Prepending frontmatter corrupts them. `akm mv` excludes the same
* types as "not markdown assets" (plus `script`, unresolvable by design).
*/
const SUPERSEDE_REJECTED_TYPES = new Set(["secret", "env", "task", "script"]);
/**
* Validate `--supersedes` flag values before ANY write happens.
*
* The conventions' corrections pattern needs TWO writes: the new correction
* asset (with an xref to what it corrects) and a metadata edit demoting the
* old asset (`beliefState: superseded` + `supersededBy: [<new ref>]`). This
* helper performs the validation half: each ref must resolve to a real asset
* (same resolver + root set as {@link resolveXrefsForWrite}); an unresolvable
* ref throws {@link UsageError} (exit 2) naming every bad ref, so a failed
* validation leaves the stash untouched — no partial correction.
*
* Because the demotion PREPENDS a YAML frontmatter block when the target file
* has none, only markdown assets may be demoted: refs of a raw asset type
* ({@link SUPERSEDE_REJECTED_TYPES}) and refs resolving to any non-`.md` file
* (for example a task YAML file) are rejected with {@link UsageError} BEFORE
* any write — never silently corrupted.
*
* Demotion targets must live under the resolved write target's source path or
* the working stash — honoring the "only operate on writable sources"
* constraint (and never dirtying a non-target source outside its boundary
* commit). A ref that resolves only in another configured source — read-only
* OR writable-but-not-the-target — is returned with `writable: false` and a
* reason (naming the target-flag remedy when the source is writable); the
* caller writes the correction anyway and reports the demotion as not
* applied.
*
* Returns the deduplicated plan in argv order; empty input returns [].
*/
export function resolveSupersedesForWrite(rawRefs, target, targetFlag) {
const parsedRefs = parseWriteRefs(rawRefs, "--supersedes");
if (parsedRefs.length === 0)
return [];
const { roots } = resolveWriteRefRoots(target, targetFlag);
const plan = [];
const unresolved = [];
for (const parsed of parsedRefs) {
const orderedRoots = rootsForWriteRef(parsed, roots);
// Data-corruption gate (SPEC-5): demotion is a frontmatter write; a raw
// asset type must be rejected up front — resolving it and mutating the
// file would prepend a YAML block over its raw bytes.
if (SUPERSEDE_REJECTED_TYPES.has(parsed.type)) {
throw new UsageError(`--supersedes cannot demote ${parsed.ref}: asset type "${parsed.type}" uses raw files, and the demotion writes YAML frontmatter that would corrupt them.`, "INVALID_FLAG_VALUE", "Only markdown assets (e.g. memories/note, knowledge/guide, facts/team/tool-stack) can carry the beliefState/supersededBy demotion. Replace or delete the raw asset instead.");
}
let located = null;
for (const root of orderedRoots) {
const filePath = locateWriteRefInRoot(parsed.type, parsed.name, root.path);
if (filePath !== null) {
located = {
root: root.path,
source: root.source,
bundleId: root.bundleId,
mutable: root.mutable,
filePath,
};
break;
}
}
if (located === null) {
unresolved.push(parsed);
continue;
}
// Belt-and-suspenders for the same corruption class: whatever the type,
// the demotion may only touch a markdown file. Rejects, for example, a task
// YAML file reached through a malformed or stale ref.
if (!located.filePath.toLowerCase().endsWith(".md")) {
throw new UsageError(`--supersedes ${parsed.ref} resolves to a non-markdown file (${located.filePath}) — the demotion writes YAML frontmatter and would corrupt it.`, "INVALID_FLAG_VALUE", "Only markdown assets can carry the beliefState/supersededBy demotion. Replace or delete the file instead.");
}
const { root, source, bundleId, mutable: writable, filePath } = located;
// The eligibility rule is write-target-or-working-stash, NOT source
// writability: mutating a non-target writable source would leave it dirty
// outside any boundary commit. Name the remedy when one exists.
const namedWritableSource = source?.writable === true ? source.registryId : undefined;
const canonicalRef = canonicalWriteRef(parsed, bundleId);
if (plan.some((item) => item.ref === canonicalRef))
continue;
plan.push({
ref: canonicalRef,
filePath,
stashRoot: root,
writable,
...(writable
? {}
: {
reason: namedWritableSource
? `resolves outside the write target and the working stash, in writable source "${namedWritableSource}" at ${root}; ` +
`re-run with ${targetFlag ?? "--target"} ${namedWritableSource} to demote it there`
: `resolves outside the write target and the working stash, in a read-only source at ${root}; ` +
"demotion only applies to assets in the write target or the working stash",
}),
});
}
if (unresolved.length > 0) {
throw unresolvedRefsError("--supersedes", unresolved);
}
return plan;
}
// ── Asset writing ────────────────────────────────────────────────────────────
/**
* Write a markdown asset (knowledge or memory) to the resolved write target.
*
* Resolves the write target via the v1 precedence chain (`--target` →
* `defaultWriteTarget` → working stash), validates the path is within the
* type root, enforces `--force` semantics, and delegates the actual write
* to `writeAssetToSource`.
*/
export async function writeMarkdownAsset(options) {
const cfg = loadConfig();
// `--name` is the flat asset name; `--path` is the subdirectory under the
// type root. Combine them into the nested name the path resolver expects.
const subPath = normalizeCreateSubPath(options.path);
const baseName = normalizeMarkdownAssetName(options.name, inferAssetName(options.content, options.fallbackPrefix, options.preferredName));
const normalizedName = combineCreatePath(subPath, baseName);
const resolved = resolveMutationTarget(cfg, { type: options.type, name: normalizedName }, options.target, {
flag: options.targetFlag,
});
const { target } = resolved;
const { source, config } = target;
const typeRoot = path.join(source.path, options.type === "knowledge" ? "knowledge" : "memories");
// Pre-flight: existence + force semantics. The helper itself overwrites
// unconditionally; the CLI surfaces a friendlier UsageError before any
// disk activity when --force is absent.
const assetPath = assetPathForName(options.type, typeRoot, normalizedName);
if (!isWithin(assetPath, typeRoot)) {
throw new UsageError(`Resolved ${options.type} path escapes the stash: "${normalizedName}"`);
}
if (fs.existsSync(assetPath) && !options.force) {
throw new UsageError(`${options.type === "knowledge" ? "Knowledge" : "Memory"} "${normalizedName}" already exists. Re-run with --force to overwrite it.`, "RESOURCE_ALREADY_EXISTS");
}
// A correction cannot supersede ITSELF. Under `--force` the ref resolves to
// the very file this command is about to overwrite, and the demotion would
// immediately mark the fresh correction superseded (plus a self-xref) —
// silently hiding the fix from `--belief current` and capping its rank.
// Input validation: exit 2, before any write, nothing demoted.
for (const item of options.supersedes ?? []) {
if (path.resolve(item.filePath) === path.resolve(assetPath)) {
throw new UsageError(`--supersedes ${item.ref} resolves to the asset being written ("${resolved.displayRef}") — a correction cannot supersede itself.`, "INVALID_FLAG_VALUE", "Write the correction under a different --name, or drop --supersedes when overwriting an asset in place with --force.");
}
}
const result = await writeAssetToSource(source, config, resolved.ref, options.content);
const durableRef = result.ref;
result.ref = resolved.displayRef;
// SPEC-5 (--supersedes): demote each superseded asset by mutating its
// frontmatter (`beliefState: superseded` + sorted-set-append `supersededBy`;
// every other key and the body are preserved). Ordered BEFORE
// commitWriteTargetBoundary so a git target batches the correction and the
// demoted incumbent into the single boundary commit instead of leaving the
// metadata edit as dirty residue after it.
const superseded = [];
const demotedByRoot = new Map();
for (const item of options.supersedes ?? []) {
if (!item.writable) {
const reason = item.reason ?? "target is not writable";
warn(`Warning: superseded asset ${item.ref} was NOT demoted (${reason}). ` +
"The correction was written and cites it in xrefs; demote the old asset where it is writable.");
superseded.push({ ref: item.ref, applied: false, reason });
continue;
}
// The demotion round-trips the old file's frontmatter through the YAML
// parser. A malformed block would silently fall back to the lossy lenient
// scanner and re-serializing that result destroys list/nested values —
// skip instead of rewriting, mirroring mergeXrefsIntoContent's
// abort-on-malformed policy (the correction itself still writes).
let oldFrontmatter = null;
try {
oldFrontmatter = parseFrontmatter(fs.readFileSync(item.filePath, "utf8")).frontmatter;
}
catch {
// Unreadable file — let writeSupersededEdge surface the real fs error.
}
if (oldFrontmatter?.trim() && !isParseableYamlMapping(oldFrontmatter)) {
const reason = "its existing frontmatter is not a parseable YAML mapping — rewriting it would drop the values the parser could not read";
warn(`Warning: superseded asset ${item.ref} was NOT demoted (${reason}). ` +
"The correction was written and cites it in xrefs; fix the old asset's frontmatter and re-run the correction with --force.");
superseded.push({ ref: item.ref, applied: false, reason });
continue;
}
// A demotion failure (fs error, concurrent delete, malformed YAML the
// pre-check missed) must NOT abort the correction: the new asset is
// already on disk, and bailing out here would skip the boundary commit and
// the write-path indexing below — leaving the correction unindexed and,
// on a git target, uncommitted (and a re-run hits RESOURCE_ALREADY_EXISTS).
// Degrade to the same applied:false report the non-writable path uses.
try {
const sameSource = path.resolve(item.stashRoot) === path.resolve(source.path);
writeSupersededEdge(item.filePath, durableRef);
if (sameSource) {
recordWriteTargetPath(source, item.filePath);
}
}
catch (error) {
const reason = `demotion failed: ${error instanceof Error ? error.message : String(error)}`;
warn(`Warning: superseded asset ${item.ref} was NOT demoted (${reason}). ` +
"The correction was written and cites it in xrefs; demote the old asset manually or re-run the correction with --force.");
superseded.push({ ref: item.ref, applied: false, reason });
continue;
}
superseded.push({ ref: item.ref, applied: true });
const files = demotedByRoot.get(item.stashRoot) ?? [];
files.push(item.filePath);
demotedByRoot.set(item.stashRoot, files);
}
// 0.9.0 (issue #507): single batch commit at the write boundary for git
// targets. No-op for filesystem/primary-stash targets.
commitWriteTargetBoundary(target, `Update ${formatRefForMessage(resolved.ref)}`);
// Write-path indexing: the asset is searchable immediately. Fail-open; reads
// no longer trigger reindexes, so keeping the index current is the writer's
// job. Demoted files reindex under their own containing root (usually the
// write target itself; the working stash when writing to a --target) so
// `--belief current` filtering takes effect without waiting for the next
// full index. (alpha.4 removed belief weights from ranking; `--belief` is
// the only reader of `beliefState` today.)
const demotedInTargetRoot = demotedByRoot.get(source.path) ?? [];
demotedByRoot.delete(source.path);
await indexWrittenAssets(source.path, [result.path, ...demotedInTargetRoot], { bundleId: resolved.ref.origin });
const sourceEntries = resolveSourceEntries(undefined, cfg);
const sourceInstallations = deriveInstallations(sourceEntries);
for (const [root, files] of demotedByRoot) {
const sourceIndex = sourceEntries.findIndex((entry) => path.resolve(entry.path) === path.resolve(root));
await indexWrittenAssets(root, files, { bundleId: sourceInstallations[sourceIndex]?.id });
}
// Placement hint (stash-organization conventions): CLI writers never receive
// the resolveStashStandards prompt injection LLM flows get, so a type-root
// write into a stash that carries convention/meta facts points the writer at
// the placement conventions. Additive output key, advisory only — parallel
// to search's `tip`. Fail-open: the hint must never break a completed write.
let hint;
if (!subPath && !normalizedName.includes("/")) {
try {
if (resolveStashStandards(source.path).trim().length > 0) {
// Only point at the canonical organization fact when it actually
// exists — resolveStashStandards fires for ANY convention/meta fact,
// and a dead `akm show` pointer is worse than generic wording.
const orgFactPath = path.join(source.path, "facts", "conventions", "organization.md");
hint = fs.existsSync(orgFactPath)
? `Wrote to the ${options.type} root. This stash has placement conventions — see \`akm show facts/conventions/organization\`.`
: `Wrote to the ${options.type} root. This stash has placement conventions — see the convention facts under its facts/ directory.`;
}
}
catch {
// Advisory only.
}
}
return {
ref: result.ref,
path: result.path,
stashDir: source.path,
...(hint ? { hint } : {}),
...(superseded.length > 0 ? { superseded } : {}),
};
}