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
759 lines (758 loc) • 41.5 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/.
import fs from "node:fs";
import path from "node:path";
import { isDeepStrictEqual } from "node:util";
import { parse as yamlParse, stringify as yamlStringify } from "yaml";
import { defineJsonCommand, output, parseAllFlagValues } from "../cli/shared.js";
import { assetPathForName, stashDirFor } from "../core/asset/asset-placement.js";
import { makeBundleRef, parseBundleRef } from "../core/asset/asset-ref.js";
import { assembleAsset, serializeFrontmatter } from "../core/asset/asset-serialize.js";
import { parseFrontmatter, parseFrontmatterBlock, spliceFrontmatterLine } from "../core/asset/frontmatter.js";
import { conceptIdFromTypeName, parseRefInput, typeNameFromConceptId } from "../core/asset/resolve-ref.js";
import { isWithin, resolveStashDir, safeRealpath, writeFileAtomic } from "../core/common.js";
import { loadConfig } from "../core/config/config.js";
import { ConfigError, NotFoundError, UsageError } from "../core/errors.js";
import { appendEvent } from "../core/events.js";
import { resolveMutationTarget } from "../core/mutation-target.js";
import { isPathAbsent } from "../core/path-access.js";
import { getDbPath } from "../core/paths.js";
import { getStateDbPath, withStateDb } from "../core/state-db.js";
import { warn } from "../core/warn.js";
import { withWriteTargetMutation } from "../core/write-source.js";
import { resolveSourceEntries } from "../indexer/search/search-source.js";
import { countFeedbackSignals, insertUsageEventStrict, resolveUsageEventSource } from "../indexer/usage/usage-events.js";
import { resolveSourcesForOrigin } from "../registry/origin-resolve.js";
import { closeDatabase, openExistingDatabase } from "../storage/repositories/index-connection.js";
import { findEntryIdByRef, getEntryFilePathById, getItemRefById, } from "../storage/repositories/index-entries-repository.js";
import { applyFeedbackToUtilityScore } from "../storage/repositories/index-utility-repository.js";
import { contentHash } from "./improve/content-hash.js";
import { readEdgeList } from "./improve/memory/memory-belief.js";
import { createProposal } from "./proposal/repository.js";
// ── Tag validation ────────────────────────────────────────────────────────────
const TAG_KEY_RE = /^[a-z_][a-z0-9_]*$/;
function validateFeedbackTags(raw) {
const seen = new Set();
const out = [];
for (const tag of raw) {
const parts = tag.split(":");
if (parts.length < 2 || parts[0] === "" || parts.slice(1).join("") === "") {
throw new UsageError(`Invalid tag "${tag}". Tags must be in key:value format where key matches [a-z_][a-z0-9_]* and value is non-empty.`, "INVALID_FLAG_VALUE");
}
const key = parts[0];
if (!TAG_KEY_RE.test(key)) {
throw new UsageError(`Invalid tag key "${key}" in "${tag}". Key must match [a-z_][a-z0-9_]*.`, "INVALID_FLAG_VALUE");
}
if (seen.has(tag))
continue;
seen.add(tag);
out.push(tag);
}
return out;
}
// ── Lesson strength helper ────────────────────────────────────────────────────
/**
* Phase 7A: append a feedback ref to a lesson's `lessonStrength[]`
* frontmatter array. Returns `{ strength }` (post-update count) on success,
* or `null` when the lesson cannot be located. Idempotent: if the ref is
* already credited, no write occurs.
*
* The function looks up the lesson's file via the indexer DB so the write
* targets the canonical on-disk location. Frontmatter is rewritten in
* place (no asset-spec round-trip) because we're modifying a single key on
* an existing asset — the same pattern memory-inference uses for
* `inferenceProcessed`.
*/
function appendLessonStrength(refInput, feedbackRef) {
// Canonical conceptId (`lessons/<name>`, D-R3): `findEntryIdByRef` keys on
// the stored `item_ref`.
const conceptId = conceptIdFromTypeName(refInput.type, refInput.name);
const config = loadConfig();
let filePath;
let bundleId;
const db = openExistingDatabase();
try {
const entryId = findEntryIdByRef(db, makeBundleRef(refInput.origin, conceptId));
if (entryId !== undefined) {
const itemRef = getItemRefById(db, entryId);
const parsedItemRef = itemRef ? parseBundleRef(itemRef) : undefined;
filePath = getEntryFilePathById(db, entryId) ?? undefined;
bundleId = parsedItemRef?.bundle;
}
}
finally {
closeDatabase(db);
}
const requestedRef = makeBundleRef(refInput.origin, conceptId);
if (!filePath || !bundleId || !fs.existsSync(filePath)) {
warn(`[feedback] --applied-to: lesson ${requestedRef} is not in the index or is missing on disk.`);
return null;
}
const resolved = resolveMutationTarget(config, { ...refInput, origin: bundleId });
if (!isWithin(filePath, resolved.target.source.path)) {
throw new UsageError(`Resolved lesson ${requestedRef} is outside bundle "${bundleId}".`);
}
fs.lstatSync(filePath);
const initialUpdate = buildLessonStrengthUpdate(fs.readFileSync(filePath), feedbackRef);
if (!initialUpdate.nextBytes) {
return { ref: makeBundleRef(bundleId, conceptId), strength: initialUpdate.strength };
}
let strength = initialUpdate.strength;
let mutationStarted = false;
try {
withWriteTargetMutation(resolved.target, [filePath], {
purpose: "feedback-lesson-credit",
message: `Update ${makeBundleRef(bundleId, conceptId)}`,
}, () => {
const stat = fs.lstatSync(filePath);
const update = buildLessonStrengthUpdate(fs.readFileSync(filePath), feedbackRef);
strength = update.strength;
if (!update.nextBytes)
return;
mutationStarted = true;
// Preserve the existing file's permission bits (markdown assets are typically 0o644).
writeFileAtomic(filePath, update.nextBytes, stat.mode & 0o777);
});
}
catch (err) {
if (mutationStarted)
throw err;
warn(`[feedback] --applied-to: failed to write ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
return null;
}
return { ref: makeBundleRef(bundleId, conceptId), strength };
}
function buildLessonStrengthUpdate(rawBytes, feedbackRef) {
const raw = rawBytes.toString("utf8");
const parsed = parseFrontmatter(raw);
const data = { ...parsed.data };
const existing = data.lessonStrength;
const strengthList = Array.isArray(existing)
? existing.filter((x) => typeof x === "string" && x.trim().length > 0).map((x) => x.trim())
: typeof existing === "string" && existing.trim().length > 0
? [existing.trim()]
: [];
if (strengthList.includes(feedbackRef)) {
return { strength: strengthList.length };
}
strengthList.push(feedbackRef);
data.lessonStrength = strengthList;
const block = parseFrontmatterBlock(raw);
const body = block?.content ?? raw;
const next = assembleAsset(data, body);
const nextBytes = Buffer.from(next);
return rawBytes.equals(nextBytes) ? { strength: strengthList.length } : { strength: strengthList.length, nextBytes };
}
/**
* Persist the feedback usage-event (state.db) and immediately fold it into the
* entry's MemRL utility score (index.db). Chunk-8 WI-8.3: usage_events lives in
* state.db; entries + utility_scores stay in `indexDb`. BOTH positive and
* negative signals apply the EMA utility update unconditionally and
* immediately when the source is user-attributed — no `akm index` run is
* required for either signal to affect search ranking. Uses the bounded-step
* EMA policy (F-5 / #386, arXiv:2601.03192).
*
* The update is intentionally SKIPPED for non-`user` event sources (`improve`,
* `task`, `audit`, `unknown`) — an anti-self-reinforcement guard that stops an
* agent or automated pipeline from boosting its own picks. This is by design
* and must not be removed; R-034 only asks that the skip be surfaced to the
* caller rather than passing silently. A failed usage-event write fails the
* command (DATA_DIR_NOT_WRITABLE); a utility-update failure never fails the
* feedback record.
*/
function recordFeedbackUsage(indexDb, entryId, durableEntryRef, signal, metadataStr) {
let utilityResult;
let rankingUpdateApplied = false;
let rankingUpdateSkippedReason;
const eventSource = resolveUsageEventSource();
withStateDb((stateDb) => {
// The usage event IS the feedback record, so a failed write fails the command
// instead of reporting ok while nothing was stored.
try {
insertUsageEventStrict(stateDb, {
event_type: "feedback",
entry_ref: durableEntryRef,
entry_id: entryId,
signal,
metadata: metadataStr,
source: eventSource,
});
}
catch (err) {
throw new ConfigError(`Feedback was not recorded: could not write ${getStateDbPath()} (${err instanceof Error ? err.message : String(err)}).`, "DATA_DIR_NOT_WRITABLE");
}
if (eventSource !== "user") {
rankingUpdateSkippedReason =
`feedback source is "${eventSource}", not "user" — ranking updates only apply to user-attributed ` +
"feedback (anti-self-reinforcement guard; set AKM_EVENT_SOURCE=user to record as user demand).";
return;
}
try {
const { pos, neg } = countFeedbackSignals(stateDb, entryId);
utilityResult = applyFeedbackToUtilityScore(indexDb, entryId, pos, neg);
rankingUpdateApplied = true;
}
catch (err) {
// best-effort — feedback recording succeeds even if utility update fails
rankingUpdateSkippedReason = `utility update failed: ${err instanceof Error ? err.message : String(err)}`;
}
});
return { utilityResult, rankingUpdateApplied, rankingUpdateSkippedReason };
}
// ── Judged text ──────────────────────────────────────────────────────────────
/**
* The body hash of the text a piece of feedback judges: the asset's file as it
* stands when the feedback is given. `undefined` when the file cannot be read.
* Reflect compares it with the asset's current body to tell feedback given on
* an earlier version of the text. An env or secret file is never read.
*/
function judgedTextHash(itemRef, filePath) {
try {
const type = typeNameFromConceptId(parseBundleRef(itemRef).conceptId)?.type;
if (!filePath || type === "env" || type === "secret")
return undefined;
return contentHash(fs.readFileSync(filePath, "utf8"), "body");
}
catch {
return undefined;
}
}
// ── Exact fixes ──────────────────────────────────────────────────────────────
/** 1-based numbers of the lines on which `needle` starts. */
function startLines(text, needle) {
const lines = [];
for (let at = text.indexOf(needle); at !== -1; at = text.indexOf(needle, at + 1)) {
lines.push(text.slice(0, at).split("\n").length);
}
return lines;
}
/**
* Apply `--replace`/`--with` pairs in order. Each `--replace` text must appear
* exactly once in the text as it stands at that point, so the edit can only
* land where the caller meant it.
*/
export function applyExactReplacements(text, pairs, file) {
let out = text;
pairs.forEach((pair, i) => {
const label = `--replace #${i + 1}`;
if (!pair.old)
throw new UsageError(`${label} is empty.`, "INVALID_FLAG_VALUE");
const lines = startLines(out, pair.old);
if (lines.length === 0) {
throw new UsageError(`${label} was not found in ${file}.`, "INVALID_FLAG_VALUE", "Copy the text verbatim from the file, including whitespace and punctuation.");
}
if (lines.length > 1) {
throw new UsageError(`${label} appears ${lines.length} times in ${file} (lines ${lines.join(", ")}).`, "INVALID_FLAG_VALUE", "Include more of the surrounding text so it appears once.");
}
const at = out.indexOf(pair.old);
out = out.slice(0, at) + pair.new + out.slice(at + pair.old.length);
});
return out;
}
/** A fix may change the frontmatter's values, but it must still parse as YAML. */
function assertFrontmatterStillParses(before, after, file) {
if (!parseFrontmatterBlock(before))
return;
const block = parseFrontmatterBlock(after);
let problem;
if (!block) {
problem = "the frontmatter block is gone";
}
else if (block.frontmatter.trim()) {
try {
const data = yamlParse(block.frontmatter);
if (typeof data !== "object" || data === null || Array.isArray(data))
problem = "it is no longer a mapping";
}
catch (err) {
problem = (err instanceof Error ? err.message : String(err)).split("\n")[0];
}
}
if (problem) {
throw new UsageError(`The fix breaks the frontmatter of ${file}: ${problem}.`, "INVALID_FLAG_VALUE", 'Quote a value that contains ": ", or leave the frontmatter alone.');
}
}
/**
* Refuse a fix that a proposal could not apply to the asset's file. A proposal
* writes the file `createProposal` computes from the ref's type and name under
* the bundle's root. For an asset indexed anywhere else (a git bundle's
* `tasks/README.md` is `knowledge/tasks/README`) nothing is there: accepting
* the proposal would create a second file and leave the real one unfixed.
*/
export function assertProposalWritesFile(itemRef, root, filePath) {
const { type, name } = parseRefInput(itemRef);
const typeDir = stashDirFor(type);
const writes = typeDir === undefined ? undefined : assetPathForName(type, path.join(path.resolve(root), typeDir), name);
if (writes !== undefined && safeRealpath(writes) === safeRealpath(filePath))
return;
throw new UsageError(`akm cannot queue a fix for ${itemRef}: its file is ${filePath}, but a proposal would write ${writes ?? `no file (akm has no directory for "${type}" assets)`}. Edit the file directly.`, "INVALID_PROPOSAL");
}
/**
* The durable ref of the asset `--superseded-by` names. It must be indexed and
* must not be the asset itself, or nothing is recorded.
*/
function resolveSuccessorRef(db, input, itemRef) {
const parsed = parseBundleRef(input);
const id = findEntryIdByRef(db, makeBundleRef(parsed.bundle, parsed.conceptId), parsed.bundle);
const successor = id === undefined ? null : getItemRefById(db, id);
if (!successor) {
throw new UsageError(`--superseded-by ${input} is not in the index.`, "INVALID_FLAG_VALUE", "Run 'akm search' to find the asset that replaces it, then 'akm index' if it was recently added.");
}
if (successor === itemRef) {
throw new UsageError(`--superseded-by ${input} is the asset itself.`, "INVALID_FLAG_VALUE");
}
return successor;
}
/** The frontmatter of `text` as a YAML mapping: `{}` when it has none (or only comments), `undefined` when it is not valid YAML or not a mapping. */
function frontmatterMapping(text) {
const block = parseFrontmatterBlock(text);
if (!block?.frontmatter.trim())
return {};
try {
const data = (yamlParse(block.frontmatter) ?? {});
return typeof data === "object" && data !== null && !Array.isArray(data)
? data
: undefined;
}
catch {
return undefined;
}
}
/**
* What `mark` sets on frontmatter `data`, `{}` when it already says so. The rules
* of `writeSupersededEdge`: a ref already listed is not added again,
* `contradicted` and `archived` stay, and a scalar `supersededBy` joins the list.
* `--outdated` also leaves `superseded`, which says more than `deprecated`.
*/
function historyMarkChanges(data, mark) {
const state = data.beliefState;
if ("outdated" in mark) {
return state === "deprecated" || state === "superseded" || state === "contradicted" || state === "archived"
? {}
: { beliefState: "deprecated" };
}
const existing = readEdgeList(data.supersededBy);
return {
...(state === "superseded" || state === "contradicted" || state === "archived"
? {}
: { beliefState: "superseded" }),
...(existing.includes(mark.supersededBy) ? {} : { supersededBy: [...existing, mark.supersededBy] }),
};
}
/** Index of the top-level `key:` line inside the frontmatter block of `lines`, or -1. */
function topLevelKeyLine(lines, key) {
const close = lines.findIndex((line, i) => i > 0 && line.trim() === "---");
if (lines[0]?.trim() !== "---" || close === -1)
return -1;
return lines.findIndex((line, i) => i > 0 && i < close && line.startsWith(`${key}:`));
}
/** Put a one-line `beliefState` in the frontmatter of `text`: in place of its line, or just before the closing fence. */
function setBeliefState(text, state) {
const lines = text.split("\n");
const at = topLevelKeyLine(lines, "beliefState");
if (at === -1)
return spliceFrontmatterLine(text, `beliefState: ${state}`) ?? text;
lines[at] = `beliefState: ${state}`;
return lines.join("\n");
}
/** Put the `supersededBy` list `list`, whose last item is `ref`, in the frontmatter of `text`. */
function setSupersededBy(text, list, ref) {
const item = (value) => `- ${yamlStringify(value, { lineWidth: 0 }).trimEnd()}`;
const whole = ["supersededBy:", ...list.map((value) => ` ${item(value)}`)];
const lines = text.split("\n");
const at = topLevelKeyLine(lines, "supersededBy");
if (at === -1)
return whole.reduce((out, line) => spliceFrontmatterLine(out, line) ?? out, text);
// The item lines of a block list, which may have blank lines and comments between them.
const items = [];
for (let i = at + 1; i < lines.length && /^\s*(-(\s|$)|#|$)/.test(lines[i]); i++) {
if (/^\s*-(\s|$)/.test(lines[i]))
items.push(i);
}
if (items.length > 0 && /^supersededBy:\s*(#.*)?$/.test(lines[at])) {
// A block list: the new item follows the last one, indented like the first.
const indent = /^\s*/.exec(lines[items[0]])?.[0] ?? "";
lines.splice(items[items.length - 1] + 1, 0, `${indent}${item(ref)}`);
}
else {
// A flow list, a scalar or no value: this one key is written out as a block list.
lines.splice(at, 1, ...whole);
}
return lines.join("\n");
}
/**
* Mark an asset's history in the frontmatter of its text, as a proposal's content:
* `{ supersededBy }` sets `beliefState: superseded` and adds the ref to the
* `supersededBy` list, `{ outdated }` sets `beliefState: deprecated`. Only those
* two keys change, by line edits that keep every other byte (comments, quoting,
* key order, line endings, the body). When a line edit does not give exactly the
* intended frontmatter (an unusual spelling of a key), the frontmatter is
* written out again instead. Returns `raw` when it already says what the mark sets.
*/
export function applyHistoryMark(raw, mark, file) {
const crlf = raw.includes("\r\n");
const text = crlf ? raw.replace(/\r\n/g, "\n") : raw;
const data = frontmatterMapping(text);
if (!data) {
throw new UsageError(`The frontmatter of ${file} is not a valid YAML mapping, so akm cannot mark it.`, "INVALID_FLAG_VALUE", "Fix the frontmatter first.");
}
const changes = historyMarkChanges(data, mark);
if (Object.keys(changes).length === 0)
return raw;
let out = text;
if (changes.beliefState !== undefined)
out = setBeliefState(out, changes.beliefState);
if (changes.supersededBy !== undefined && "supersededBy" in mark) {
out = setSupersededBy(out, changes.supersededBy, mark.supersededBy);
}
const intended = { ...data, ...changes };
if (!isDeepStrictEqual(frontmatterMapping(out), intended)) {
const block = parseFrontmatterBlock(text);
out = `---\n${serializeFrontmatter(intended)}\n---\n${block ? block.content : text}`;
}
return crlf ? out.replace(/\n/g, "\r\n") : out;
}
// ── Command definition ────────────────────────────────────────────────────────
export const feedbackCommand = defineJsonCommand({
meta: {
name: "feedback",
description: "Record positive or negative feedback for any indexed bundle asset.\n\n" +
'`akm feedback <ref> --negative --reason "<what is wrong and what should change>"` flags\n' +
"the asset: the next improve run may repair its description, title or when_to_use from\n" +
"your reason, but it does not rewrite the text. To correct a wrong fact in the text, attach\n" +
'the exact fix: --replace "<exact current text>" --with "<corrected text>" --source "<URL,\n' +
'command or file that shows it>" (repeat --replace/--with for several edits; use --with=...\n' +
"for a value that starts with -). akm checks that each --replace text appears exactly once\n" +
"and queues the edit as a proposal for review. To mark the asset's history instead, add\n" +
"--superseded-by <ref> (the asset that replaces it) or --outdated (it describes a past\n" +
"state and nothing replaces it), with --reason and --source: the same proposal sets its\n" +
"beliefState and, for --superseded-by, adds the ref to supersededBy. `--positive` records\n" +
"that an asset helped (it raises its ranking) and does not trigger a rewrite.\n\n" +
"Both signals adjust the asset's usefulness score right away, in the same\n" +
"process: positive feedback raises it, negative lowers it, and recent\n" +
"feedback counts for more than old feedback. No reindex is needed — the new\n" +
"score affects ranking starting with the very next `akm search`.",
},
args: {
// Optional in citty so run() is invoked even when omitted; we re-validate
// and throw a structured UsageError below so exit code is 2 (USAGE) rather
// than citty's default 0 (help banner).
ref: { type: "positional", description: "Asset ref ([bundle//]conceptId, e.g. lessons/deploy)", required: false },
positive: {
type: "boolean",
description: "Record that the asset helped (raises its ranking immediately; does not trigger a rewrite)",
default: false,
},
negative: {
type: "boolean",
description: "Flag the asset: lowers its ranking immediately (no reindex needed), and the next improve run may repair its frontmatter from --reason. Attach --replace/--with/--source to correct its text, or --superseded-by/--outdated to mark its history.",
default: false,
},
reason: {
type: "string",
description: "What is wrong with the asset's content and what should change, specifically (required for negative feedback by default). Not for akm command errors.",
},
replace: {
type: "string",
description: "Exact text to correct, copied verbatim from the asset file; it must appear exactly once (repeatable, each paired with a --with in order). Negative feedback only.",
},
with: {
type: "string",
description: "Corrected text for the matching --replace (repeatable, in the same order).",
},
source: {
type: "string",
description: "Where the correct fact comes from: a URL, command or file. Required with --replace, --superseded-by and --outdated.",
},
"superseded-by": {
type: "string",
description: "Ref of the asset that replaces this one. The fix sets beliefState: superseded and adds the ref to supersededBy (contradicted and archived stay). The ref must be indexed and not this asset. Negative feedback on a markdown asset only; may be combined with --replace/--with.",
},
outdated: {
type: "boolean",
description: "The asset describes a past state and no single asset replaces it. The fix sets beliefState: deprecated (superseded, contradicted and archived stay). Negative feedback on a markdown asset only; may be combined with --replace/--with, not with --superseded-by.",
default: false,
},
tag: {
type: "string",
description: "Tag to attach to the feedback (repeatable, e.g. --tag slice:train --tag team:platform)",
},
"applied-to": {
type: "string",
description: "Credit a lesson that helped resolve this task. Accepts a `lessons/<name>` ref. " +
"When combined with --positive, appends this feedback ref to the target lesson's " +
"`lessonStrength[]` frontmatter array (dedup, idempotent). A non-lesson target or a " +
"missing --positive produces a warning rather than silently doing nothing.",
},
},
async run({ args }) {
const ref = (args.ref ?? "").trim();
if (!ref) {
throw new UsageError("Asset ref is required. Usage: akm feedback <ref> --positive|--negative", "MISSING_REQUIRED_ARGUMENT", "Pass a ref like `skills/deploy` and either --positive or --negative.");
}
const parsedRef = parseBundleRef(ref);
if (args.positive && args.negative) {
throw new UsageError("Specify either --positive or --negative, not both.");
}
if (!args.positive && !args.negative) {
throw new UsageError("Specify --positive or --negative.");
}
const signal = args.positive ? "positive" : "negative";
const reason = args.reason;
// A fix: an exact edit of the asset's text (each --replace pairs with a --with, in order),
// a mark of its history (--superseded-by or --outdated), or both.
const replaces = parseAllFlagValues("--replace");
const withs = parseAllFlagValues("--with");
const fixSource = args.source?.trim() || undefined;
const fixPairs = replaces.map((old, i) => ({ old, new: withs[i] ?? "" }));
const supersededBy = args["superseded-by"];
const outdated = args.outdated === true;
const hasMark = supersededBy !== undefined || outdated;
if (replaces.length > 0 || withs.length > 0 || fixSource !== undefined || hasMark) {
if (!args.negative) {
throw new UsageError("--replace, --with, --source, --superseded-by and --outdated are only for negative feedback.", "INVALID_FLAG_VALUE");
}
if (supersededBy !== undefined && outdated) {
throw new UsageError("--superseded-by names the asset that replaces this one and --outdated says none does; use one.", "INVALID_FLAG_VALUE");
}
if (parseAllFlagValues("--superseded-by").length > 1) {
throw new UsageError("--superseded-by takes one ref.", "INVALID_FLAG_VALUE");
}
if (supersededBy !== undefined && !supersededBy.trim()) {
throw new UsageError("--superseded-by needs the ref of the asset that replaces this one.", "MISSING_REQUIRED_ARGUMENT");
}
if (replaces.length !== withs.length || (replaces.length === 0 && !hasMark)) {
throw new UsageError(`Each --replace needs one --with (got ${replaces.length} --replace and ${withs.length} --with).`, "INVALID_FLAG_VALUE");
}
if (!fixSource) {
throw new UsageError("A fix needs --source: the URL, command or file that shows the correct fact.", "MISSING_REQUIRED_ARGUMENT");
}
if (!reason?.trim()) {
throw new UsageError("A fix needs --reason: say what is wrong.", "MISSING_REQUIRED_ARGUMENT");
}
}
if (args.negative === true && !reason?.trim()) {
// F-3 / #384: Default requireReason is now true. Load config to allow
// operators to opt out via feedback.requireReason: false in akm.json.
const cfg = loadConfig();
const requireReason = cfg.feedback?.requireReason ?? true; // Default: true (F-3 / #384)
if (requireReason) {
throw new UsageError("Negative feedback requires --reason: say what is wrong and what should change. " +
"Set feedback.requireReason: false in akm.json to downgrade to a warning.", "MISSING_REQUIRED_ARGUMENT", `Hint: akm feedback ${ref} --negative --reason "<what is wrong and what should change>"`);
}
else {
warn("Warning: negative feedback without --reason says nothing about what is wrong.");
}
}
const rawTags = parseAllFlagValues("--tag");
const validatedTags = validateFeedbackTags(rawTags);
const metadataObj = {
signal,
...(reason?.trim() ? { reason: reason.trim() } : {}),
...(validatedTags.length > 0 ? { tags: validatedTags } : {}),
};
// Feedback only needs the index to exist, not to be current. A stale index
// is fine — the ref lookup works against any populated DB. We do NOT call
// ensureIndex here: it either blocks (3+ min inline reindex) or spawns a
// background process that holds the writer lock, causing the feedback write
// to spin-wait for the full reindex duration. If the DB is absent we give a
// clear error below rather than silently triggering a rebuild.
// "Run 'akm index' first" is only true advice for an index that was never
// built. Told to someone whose index exists but is unreadable it is a lie
// that sends them to rebuild a file they may not have permission to touch
// (#791), so that case falls through to `openExistingDatabase` below, which
// names the path, errno, mode/owner and uid instead.
if (isPathAbsent(getDbPath())) {
throw new UsageError("Index not found. Run 'akm index' first to build the index before recording feedback.", "MISSING_REQUIRED_ARGUMENT", "akm index");
}
// Feedback writes exactly 2 rows (usage_events + utility_score). SQLite
// WAL mode + busy_timeout=30s handles concurrent access with an ongoing
// `akm improve` run without needing the application-level writer lock.
// The lock was originally needed to prevent feedback from racing a
// background reindex it spawned — now that ensureIndex is removed, holding
// the lock only causes feedback to block for the full improve run duration.
let utilityResult;
let rankingUpdateApplied = false;
let rankingUpdateSkippedReason;
let durableRef = ref;
let fix;
const db = openExistingDatabase();
try {
const config = loadConfig();
const sources = resolveSourceEntries(undefined, config);
const requestedSource = parsedRef.bundle ? resolveSourcesForOrigin(parsedRef.bundle, sources)[0] : undefined;
if (parsedRef.bundle && !requestedSource) {
throw new UsageError(`Source "${parsedRef.bundle}" is not configured.`, "INVALID_FLAG_VALUE");
}
const lookupRef = makeBundleRef(parsedRef.bundle, parsedRef.conceptId);
const entryId = findEntryIdByRef(db, lookupRef, parsedRef.bundle);
if (entryId === undefined) {
// NotFoundError (exit 1), not UsageError (exit 2): the flags parsed
// fine, the asset just isn't there. The documented exit-code table
// reserves 1 for "requested resource missing", and scripts branch on it.
throw new NotFoundError(`Ref "${ref}" is not in the index. ` +
"Run 'akm search' to verify the asset exists, then 'akm index' if it was recently added.", "ASSET_NOT_FOUND");
}
// Persist the feedback signal into usage_events. Both positive and
// negative signals apply the EMA utility update immediately — no
// `akm index` run is required for either signal to affect ranking in
// search results (see recordFeedbackUsage / applyFeedbackToUtilityScore).
// WI-8.5b: the `feedback` / `improve_review_needed` events key on the
// resolved entry's fully-qualified item_ref — the SAME durable key the
// usage_events row carries and the SAME spelling the signal-delta
// correlation reads (buildLatestFeedbackTsMap, collapsed to [item_ref]).
const itemRef = getItemRefById(db, entryId);
if (!itemRef)
throw new UsageError(`Indexed ref "${ref}" has no durable item ref.`, "INVALID_PROPOSAL");
durableRef = itemRef;
const filePath = getEntryFilePathById(db, entryId);
const textHash = judgedTextHash(itemRef, filePath);
if (textHash)
metadataObj.contentHash = textHash;
if (fixPairs.length > 0 || hasMark) {
// Checked before anything is recorded, so a fix that does not apply leaves no trace.
if (!filePath || !fs.existsSync(filePath)) {
throw new NotFoundError(`The file for ${itemRef} is missing on disk.`, "ASSET_NOT_FOUND");
}
const assetRef = parseRefInput(itemRef);
const resolved = resolveMutationTarget(config, assetRef, undefined, { requireWritable: true });
if (!isWithin(filePath, resolved.target.source.path)) {
throw new UsageError(`${itemRef} is outside bundle "${resolved.target.source.name}".`);
}
assertProposalWritesFile(itemRef, resolved.target.source.path, filePath);
let mark;
if (hasMark) {
if (assetRef.type === "env" || assetRef.type === "secret" || !filePath.toLowerCase().endsWith(".md")) {
throw new UsageError(`--superseded-by and --outdated mark the frontmatter of a markdown asset, and ${itemRef} is not one.`, "INVALID_FLAG_VALUE");
}
mark =
supersededBy !== undefined
? { supersededBy: resolveSuccessorRef(db, supersededBy.trim(), itemRef) }
: { outdated: true };
}
const before = fs.readFileSync(filePath, "utf8");
let after = applyExactReplacements(before, fixPairs, filePath);
if (mark)
after = applyHistoryMark(after, mark, filePath);
if (after === before)
throw new UsageError("The fix changes nothing.", "INVALID_FLAG_VALUE");
assertFrontmatterStillParses(before, after, filePath);
// What the proposal leaves the asset saying, for the output and the event.
const marked = mark && {
beliefState: String(parseFrontmatter(after).data.beliefState),
...("supersededBy" in mark ? { supersededBy: mark.supersededBy } : {}),
};
fix = {
content: after,
target: { source: resolved.target.source.name, root: resolved.target.source.path },
...(marked ? { marked } : {}),
};
metadataObj.fix = { source: fixSource, replacements: fixPairs.length, ...marked };
}
const metadataStr = Object.keys(metadataObj).length > 1 ? JSON.stringify(metadataObj) : undefined;
const recordResult = recordFeedbackUsage(db, entryId, itemRef, signal, metadataStr);
utilityResult = recordResult.utilityResult;
rankingUpdateApplied = recordResult.rankingUpdateApplied;
rankingUpdateSkippedReason = recordResult.rankingUpdateSkippedReason;
}
finally {
closeDatabase(db);
}
appendEvent({
eventType: "feedback",
ref: durableRef,
metadata: metadataObj,
});
const fixProposal = fix && reason?.trim() && fixSource
? createProposal(resolveStashDir(), {
ref: durableRef,
itemRef: durableRef,
target: fix.target,
source: "feedback",
payload: { content: fix.content },
feedback: { reason: reason.trim(), source: fixSource },
})
: undefined;
// F-5 / #386: When a high-utility asset crosses below the review threshold,
// auto-create a review-needed escalation proposal so a human can confirm
// whether the negative feedback is valid before the asset falls out of
// the improve loop. Best-effort — failure is logged but does not fail the
// feedback command.
// Emit a structured event rather than a proposal so the review-needed
// signal doesn't risk an accidental asset overwrite if the proposal is
// accepted.
if (utilityResult?.crossedReviewThreshold) {
try {
appendEvent({
eventType: "improve_review_needed",
ref: durableRef,
metadata: {
previousUtility: utilityResult.previousUtility,
nextUtility: utilityResult.nextUtility,
reason: reason?.trim() ?? null,
},
});
}
catch (escalationErr) {
warn(`[feedback] Could not emit review-needed event for ${ref}: ${escalationErr instanceof Error ? escalationErr.message : String(escalationErr)}`);
}
}
// Phase 7A / Advantage D4b: --applied-to credits a lesson. When the
// target is a `lessons/<name>` ref and the signal is positive, append
// the feedback ref to the target lesson's `lessonStrength[]`
// frontmatter array (dedup, idempotent). Non-lesson targets are REJECTED
// with a loud warning (R-033b) rather than silently doing nothing.
// Failures here are warnings — feedback recording is the primary
// contract and must not regress on lesson-write errors.
const appliedToRaw = args["applied-to"]?.trim();
let appliedToResult = null;
if (appliedToRaw && signal === "positive") {
let parsedApplied;
try {
parsedApplied = parseRefInput(appliedToRaw);
}
catch (err) {
warn(`[feedback] --applied-to failed for ${appliedToRaw}: ${err instanceof Error ? err.message : String(err)}`);
}
if (parsedApplied) {
if (parsedApplied.type === "lesson") {
const updated = appendLessonStrength(parsedApplied, durableRef);
if (updated) {
appliedToResult = { lessonRef: updated.ref, strength: updated.strength };
}
}
else {
warn(`[feedback] --applied-to ${appliedToRaw} was ignored: it resolves to a "${parsedApplied.type}" asset, ` +
"not a lesson. Only `lessons/<name>` refs can be credited via --applied-to.");
}
}
}
else if (appliedToRaw && signal !== "positive") {
warn("[feedback] --applied-to is ignored without --positive; lesson credit is only recorded on positive signals.");
}
output("feedback", {
ok: true,
ref,
signal,
reason: reason?.trim() ?? null,
tags: validatedTags,
rankingUpdate: rankingUpdateApplied
? { applied: true }
: { applied: false, reason: rankingUpdateSkippedReason ?? "unknown" },
...(fixProposal
? {
fix: {
proposalId: fixProposal.id,
replacements: fixPairs.length,
source: fixSource,
...fix?.marked,
},
}
: {}),
...(appliedToResult
? { appliedTo: { ref: appliedToResult.lessonRef, lessonStrength: appliedToResult.strength } }
: {}),
});
},
});