akm-cli 0.9.25 → 0.9.26-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,45 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.9.26-alpha.2] - 2026-10-05
10
+
11
+ ### Added
12
+
13
+ - **`akm feedback --negative` can carry an exact fix of the asset's text:**
14
+ `--replace "<exact current text>" --with "<corrected text>" --source "<URL,
15
+ command or file>"`, repeatable for several edits. akm checks at once that each
16
+ `--replace` text appears exactly once and that the frontmatter still parses,
17
+ records nothing if a check fails (so the caller can correct it and retry), and
18
+ queues the edit as a `feedback` proposal that shows the reason and source to
19
+ the reviewer. Improve has edited only an asset's frontmatter since 0.9.25, so
20
+ this is how a wrong fact in a note's text gets corrected: by the agent that
21
+ found it, with its evidence, instead of by a nightly rewrite.
22
+
23
+ ### Changed
24
+
25
+ - **`akm feedback` help, the shipped hints and the docs no longer promise that
26
+ improve proposes a fix of the text from a negative reason;** they say it may
27
+ repair the frontmatter, and point to the exact-fix flags.
28
+
29
+ ## [0.9.26-alpha.1] - 2026-10-05
30
+
31
+ ### Changed
32
+
33
+ - **Consolidate's pair judge lists what each note alone holds before it
34
+ classifies the pair, and akm never retires a note the judge listed anything
35
+ for.** The judge also reads 12,000 characters of each note instead of 2,500.
36
+ On 650 reviewed pairs from the owner's library, 64% of the old judge's
37
+ retirements lost nothing; with the lists, 92% do, and it picks the wrong
38
+ note to keep far less often.
39
+ - **A confirmed duplicate retires unattended.** When the judge finds nothing
40
+ unique on either side, one more call asks only what the retired note holds
41
+ that the kept one lacks; an empty answer stages the proposal, and `triage`
42
+ `applyMode: "promote"` accepts it like a judged revision (it counts against
43
+ `maxAcceptsPerRun`, and a continuity risk keeps it for review). 109 of the
44
+ 111 duplicates that passed in the reviewed pairs were safe to retire, and an
45
+ accepted retirement can be undone with `akm proposal revert`. Every other
46
+ retirement still waits for `akm proposal accept`.
47
+
9
48
  ## [0.9.25] - 2026-10-04
10
49
 
11
50
  The stable release of the 0.9.25 line: 0.9.25-alpha.1 to alpha.4, unchanged. Their
@@ -95,7 +95,8 @@ akm workflow create ship-release # Create a workflow asset in the
95
95
  akm lint --type workflows # Parse and compile every .md/.yml workflow source; list every error
96
96
  akm workflow run workflows/ship-release # Start or resume and execute the workflow
97
97
  akm feedback skills/code-review --positive # Record that an asset helped (ranks it higher; no rewrite)
98
- akm feedback agents/reviewer --negative --reason "wrong framework" # Flag it for review: improve proposes a fix from the reason
98
+ akm feedback agents/reviewer --negative --reason "wrong framework" # Flag it: lowers its ranking; improve may repair its frontmatter
99
+ akm feedback knowledge/opencode-server --negative --reason "the default port is 4096, not 8000" --replace "port 8000" --with "port 4096" --source "https://opencode.ai/docs/server/" # Queue an exact fix
99
100
  akm feedback memories/deployment-notes --positive # Works for memories too
100
101
  akm feedback env/prod --positive # Records env feedback without surfacing values
101
102
  ```
@@ -103,8 +104,11 @@ akm feedback env/prod --positive # Records env feedback without su
103
104
  Use `akm feedback` whenever an asset's content materially helps, or proves wrong,
104
105
  stale or unhelpful, so future search ranking can learn from actual usage.
105
106
  `akm feedback <ref> --negative --reason "<what is wrong and what should change>"`
106
- flags the asset for review: the next improve run proposes a fix based on your
107
- reason, so be specific. `--positive` records that an asset helped (it raises its
107
+ flags the asset: it ranks lower right away, and the next improve run may repair
108
+ its description, title or `when_to_use` from your reason. Improve does not
109
+ rewrite an asset's text: to correct a wrong fact there, attach the exact fix
110
+ with `--replace "<exact current text>" --with "<corrected text>" --source "<URL,
111
+ command or file>"`, which akm checks and queues as a proposal. `--positive` records that an asset helped (it raises its
108
112
  ranking) and does not trigger a rewrite; improve no longer rewrites assets from
109
113
  positive signals or on a proactive cadence. An akm command that fails says
110
114
  nothing about the asset; don't record it as feedback.
@@ -8,7 +8,7 @@ For any task, follow this loop:
8
8
  1. `akm curate "<task>"` — find the best matching asset
9
9
  2. `akm show <ref>` — read the schema (field names and structure)
10
10
  3. Edit the workspace file using schema field names + task-specific values from your README
11
- 4. `akm feedback <ref> --positive` — record that the asset helped (it raises its ranking and does not trigger a rewrite); when its content was wrong, stale or unhelpful, `akm feedback <ref> --negative --reason "<what is wrong and what should change>"` flags it for review: the next improve run proposes a fix based on your reason, so be specific. A failed akm command (e.g. `akm show` erroring) is not feedback on the asset — don't record it.
11
+ 4. `akm feedback <ref> --positive` — record that the asset helped (it raises its ranking and does not trigger a rewrite); when its content was wrong, stale or unhelpful, `akm feedback <ref> --negative --reason "<what is wrong and what should change>"` flags it: the next improve run may repair its description, title or `when_to_use` from your reason, but not its text. To correct a wrong fact in the text, add the exact fix: `--replace "<exact current text>" --with "<corrected text>" --source "<URL, command or file>"`. A failed akm command (e.g. `akm show` erroring) is not feedback on the asset — don't record it.
12
12
 
13
13
  For workflow tasks:
14
14
  1. `akm show workflows/<name>` — inspect the procedure before executing it
@@ -40,7 +40,8 @@ akm proposal diff skills/akm-dream # Diff proposal by ref, UUID, or 8
40
40
  akm proposal accept 7c115132 # Accept by UUID prefix
41
41
  akm proposal reject skills/my-skill --reason "..." # Reject by ref
42
42
  akm feedback <ref> --positive # Record that an asset helped (ranks it higher; no rewrite)
43
- akm feedback <ref> --negative --reason "..." # Flag it for review: the next improve run proposes a fix from your reason
43
+ akm feedback <ref> --negative --reason "..." # Flag it: lowers its ranking; improve may repair its frontmatter
44
+ akm feedback <ref> --negative --reason "..." --replace "<exact text>" --with "<fix>" --source "<url|cmd|file>" # Queue an exact fix of its text
44
45
  akm bundle add <ref> # Add a source (npm, GitHub, git, local dir)
45
46
  akm clone <ref> # Copy an asset to the working bundle (optional --dest arg to clone to specific location)
46
47
  akm sync # Commit (and push if writable remote) changes in the primary bundle (--no-push to commit only)
@@ -0,0 +1,5 @@
1
+ You check whether deleting note X from one person's agent-memory library would lose anything. Note Y stays. List every durable claim of X that Y does not state.
2
+
3
+ A durable claim is a fact, decision, value, command, flag, path, file name, URL, number, version, name, error message, condition or rule someone would act on. Ignore wording, headings, dates of writing, tags and boilerplate. A claim is stated when Y says it in any wording or format, or replaces it with a newer value. It is not stated when Y is vaguer (a config file instead of the specific path) or drops a number, command, step or condition. Read X's description as part of X. Write each item as a short phrase that quotes its identifier. Use [] when Y states everything.
4
+
5
+ Answer ONLY with JSON: {"missing": ["..."]}
@@ -1,20 +1,19 @@
1
- You compare two assets from one person's agent-memory library (memories and knowledge notes an AI coding agent reads). Asset A is the OLDER one and asset B the NEWER one, by the dates shown.
1
+ You compare two assets from one person's agent-memory library (memories and knowledge notes an AI coding agent reads). Asset A is the OLDER one and asset B the NEWER one, by the dates shown. akm deletes one of them only when the other keeps every claim a reader would need, so your two lists decide what is safe to delete.
2
2
 
3
- Classify the relation between them as exactly one of:
3
+ First list what each asset has that the other lacks:
4
+ - "onlyInA": every durable claim of A that B neither states nor updates.
5
+ - "onlyInB": every durable claim of B that A neither states nor updates.
4
6
 
5
- - "duplicate": they state the same durable facts. Wording, title or formatting may differ, but neither adds a claim a reader would need that the other lacks.
6
- - "subsumed": one of them contains every durable claim of the other, plus more. The smaller one is redundant.
7
- - "supersedes": B updates, corrects, reverses or replaces a claim in A (a newer version, a changed decision, a fixed bug, a new value). A is now stale or wrong on that point.
7
+ A durable claim is a fact, decision, value, command, flag, path, file name, URL, number, version, name, error message, condition or rule someone would act on. Ignore wording, headings, dates of writing, tags and boilerplate. A claim is stated when the other asset says it in any wording or format. It is not stated when the other asset is vaguer (a config file instead of the specific path) or drops a number, command, step or condition. Write each item as a short phrase that quotes its identifier. Use [] when there is nothing.
8
+
9
+ Then classify the relation as exactly one of:
10
+ - "duplicate": both lists are empty.
11
+ - "subsumed": exactly one list is empty. The asset with the empty list is redundant.
12
+ - "supersedes": B updates, corrects, reverses or replaces a claim in A (a newer version, a changed decision, a fixed bug, a new value), and onlyInA is empty.
8
13
  - "contradicts": they make logically exclusive claims about the same thing and nothing shows which one is current.
9
- - "overlap": same subject, but each has durable claims the other lacks. Keeping both loses nothing; merging them would keep both sets of claims.
14
+ - "overlap": same subject, and both lists have items.
10
15
  - "unrelated": different subjects or different facts that happen to share words.
11
16
 
12
- Rules:
13
- - A durable claim is a fact, decision, value, command, path, number or rule someone would act on. Ignore dates, headings, tags and phrasing.
14
- - Prefer "overlap" over "duplicate" when either asset has a specific detail (a number, command, file, version or condition) that the other lacks.
15
- - "supersedes" needs a specific claim in A that B changes. Being newer or longer is not enough.
16
- - "contradicts" needs two claims that cannot both be true. Different scope, project or time is not a contradiction.
17
-
18
- Set "redundant" to "A" or "B" when that asset could be removed with no loss (only for "duplicate" or "subsumed"; for "duplicate" name the less complete or older one), else null. Set "stale" to "A" when the relation is "supersedes", else null.
17
+ Set "redundant" to the asset that could be deleted with no loss: "A" for "duplicate", the asset with the empty list for "subsumed", "A" for "supersedes"; else null. Set "stale" to "A" when the relation is "supersedes", else null.
19
18
 
20
- Answer ONLY with JSON: {"relation": "...", "redundant": "A"|"B"|null, "stale": "A"|null, "confidence": 0.0-1.0, "reason": "<at most 25 words>"}
19
+ Answer ONLY with JSON: {"onlyInA": ["..."], "onlyInB": ["..."], "relation": "...", "redundant": "A"|"B"|null, "stale": "A"|null, "confidence": 0.0-1.0, "reason": "<at most 25 words>"}
@@ -80,10 +80,13 @@ akm search "<query>" --type skill
80
80
  # Mark an asset as helpful (raises its ranking; does not trigger a rewrite)
81
81
  akm feedback <ref> --positive
82
82
 
83
- # Flag an asset for review: the next improve run proposes a fix based on your
84
- # reason, so be specific about what is wrong and what should change
83
+ # Flag an asset: it ranks lower, and the next improve run may repair its
84
+ # description, title or when_to_use from your reason
85
85
  akm feedback <ref> --negative --reason "<what is wrong and what should change>"
86
86
 
87
+ # Correct a wrong fact in an asset's text: the exact fix is checked and queued for review
88
+ akm feedback <ref> --negative --reason "<what is wrong>" --replace "<exact current text>" --with "<corrected text>" --source "<URL, command or file>"
89
+
87
90
  # Capture a durable lesson or memory from the current session
88
91
  akm remember "<fact or lesson>"
89
92
  ```
@@ -2,12 +2,13 @@
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  import fs from "node:fs";
5
+ import { parse as yamlParse } from "yaml";
5
6
  import { defineJsonCommand, output, parseAllFlagValues } from "../cli/shared.js";
6
7
  import { makeBundleRef, parseBundleRef } from "../core/asset/asset-ref.js";
7
8
  import { assembleAsset } from "../core/asset/asset-serialize.js";
8
9
  import { parseFrontmatter, parseFrontmatterBlock } from "../core/asset/frontmatter.js";
9
10
  import { conceptIdFromTypeName, parseRefInput } from "../core/asset/resolve-ref.js";
10
- import { isWithin, writeFileAtomic } from "../core/common.js";
11
+ import { isWithin, resolveStashDir, writeFileAtomic } from "../core/common.js";
11
12
  import { FEEDBACK_FAILURE_MODES, loadConfig } from "../core/config/config.js";
12
13
  import { NotFoundError, UsageError } from "../core/errors.js";
13
14
  import { appendEvent } from "../core/events.js";
@@ -23,6 +24,7 @@ import { resolveSourcesForOrigin } from "../registry/origin-resolve.js";
23
24
  import { closeDatabase, openExistingDatabase } from "../storage/repositories/index-connection.js";
24
25
  import { findEntryIdByRef, getEntryFilePathById, getItemRefById, } from "../storage/repositories/index-entries-repository.js";
25
26
  import { applyFeedbackToUtilityScore } from "../storage/repositories/index-utility-repository.js";
27
+ import { createProposal } from "./proposal/repository.js";
26
28
  // ── Tag validation ────────────────────────────────────────────────────────────
27
29
  const TAG_KEY_RE = /^[a-z_][a-z0-9_]*$/;
28
30
  function validateFeedbackTags(raw) {
@@ -185,15 +187,74 @@ function recordFeedbackUsage(indexDb, entryId, durableEntryRef, signal, metadata
185
187
  });
186
188
  return { utilityResult, rankingUpdateApplied, rankingUpdateSkippedReason };
187
189
  }
190
+ // ── Exact fixes ──────────────────────────────────────────────────────────────
191
+ /** 1-based numbers of the lines on which `needle` starts. */
192
+ function startLines(text, needle) {
193
+ const lines = [];
194
+ for (let at = text.indexOf(needle); at !== -1; at = text.indexOf(needle, at + 1)) {
195
+ lines.push(text.slice(0, at).split("\n").length);
196
+ }
197
+ return lines;
198
+ }
199
+ /**
200
+ * Apply `--replace`/`--with` pairs in order. Each `--replace` text must appear
201
+ * exactly once in the text as it stands at that point, so the edit can only
202
+ * land where the caller meant it.
203
+ */
204
+ export function applyExactReplacements(text, pairs, file) {
205
+ let out = text;
206
+ pairs.forEach((pair, i) => {
207
+ const label = `--replace #${i + 1}`;
208
+ if (!pair.old)
209
+ throw new UsageError(`${label} is empty.`, "INVALID_FLAG_VALUE");
210
+ const lines = startLines(out, pair.old);
211
+ if (lines.length === 0) {
212
+ throw new UsageError(`${label} was not found in ${file}.`, "INVALID_FLAG_VALUE", "Copy the text verbatim from the file, including whitespace and punctuation.");
213
+ }
214
+ if (lines.length > 1) {
215
+ 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.");
216
+ }
217
+ const at = out.indexOf(pair.old);
218
+ out = out.slice(0, at) + pair.new + out.slice(at + pair.old.length);
219
+ });
220
+ return out;
221
+ }
222
+ /** A fix may change the frontmatter's values, but it must still parse as YAML. */
223
+ function assertFrontmatterStillParses(before, after, file) {
224
+ if (!parseFrontmatterBlock(before))
225
+ return;
226
+ const block = parseFrontmatterBlock(after);
227
+ let problem;
228
+ if (!block) {
229
+ problem = "the frontmatter block is gone";
230
+ }
231
+ else if (block.frontmatter.trim()) {
232
+ try {
233
+ const data = yamlParse(block.frontmatter);
234
+ if (typeof data !== "object" || data === null || Array.isArray(data))
235
+ problem = "it is no longer a mapping";
236
+ }
237
+ catch (err) {
238
+ problem = (err instanceof Error ? err.message : String(err)).split("\n")[0];
239
+ }
240
+ }
241
+ if (problem) {
242
+ throw new UsageError(`The fix breaks the frontmatter of ${file}: ${problem}.`, "INVALID_FLAG_VALUE", 'Quote a value that contains ": ", or leave the frontmatter alone.');
243
+ }
244
+ }
188
245
  // ── Command definition ────────────────────────────────────────────────────────
189
246
  export const feedbackCommand = defineJsonCommand({
190
247
  meta: {
191
248
  name: "feedback",
192
249
  description: "Record positive or negative feedback for any indexed bundle asset.\n\n" +
193
250
  '`akm feedback <ref> --negative --reason "<what is wrong and what should change>"` flags\n' +
194
- "the asset for review: the next improve run proposes a fix based on your reason, so be\n" +
195
- "specific. `--positive` records that an asset helped (it raises its ranking) and does not\n" +
196
- "trigger a rewrite.\n\n" +
251
+ "the asset: the next improve run may repair its description, title or when_to_use from\n" +
252
+ "your reason, but it does not rewrite the text. To correct a wrong fact in the text, attach\n" +
253
+ 'the exact fix: --replace "<exact current text>" --with "<corrected text>" --source "<URL,\n' +
254
+ 'command or file that shows it>" (repeat --replace/--with for several edits; use --with=...\n' +
255
+ "for a value that starts with -). akm checks that each --replace text appears exactly once\n" +
256
+ "and queues the edit as a proposal for review. `--positive` records that an asset helped\n" +
257
+ "(it raises its ranking) and does not trigger a rewrite.\n\n" +
197
258
  "Both signals adjust the asset's usefulness score right away, in the same\n" +
198
259
  "process: positive feedback raises it, negative lowers it, and recent\n" +
199
260
  "feedback counts for more than old feedback. No reindex is needed — the new\n" +
@@ -211,12 +272,24 @@ export const feedbackCommand = defineJsonCommand({
211
272
  },
212
273
  negative: {
213
274
  type: "boolean",
214
- description: "Flag the asset for review: the next improve run proposes a fix from --reason (also lowers its ranking immediately, no reindex needed).",
275
+ 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.",
215
276
  default: false,
216
277
  },
217
278
  reason: {
218
279
  type: "string",
219
- description: "What is wrong with the asset's content and what should change; the next improve run proposes a fix from it, so be specific (required for negative feedback by default). Not for akm command errors.",
280
+ 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.",
281
+ },
282
+ replace: {
283
+ type: "string",
284
+ 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.",
285
+ },
286
+ with: {
287
+ type: "string",
288
+ description: "Corrected text for the matching --replace (repeatable, in the same order).",
289
+ },
290
+ source: {
291
+ type: "string",
292
+ description: "Where the correct fact comes from: a URL, command or file. Required with --replace.",
220
293
  },
221
294
  "failure-mode": {
222
295
  type: "string",
@@ -262,18 +335,37 @@ export const feedbackCommand = defineJsonCommand({
262
335
  throw new UsageError(`Invalid --failure-mode "${failureMode}". Accepted values: ${allowedModes.join(", ")}.`, "INVALID_FLAG_VALUE", `Use one of: ${allowedModes.join(", ")}`);
263
336
  }
264
337
  }
338
+ // An exact fix for the asset's text: each --replace pairs with a --with, in order.
339
+ const replaces = parseAllFlagValues("--replace");
340
+ const withs = parseAllFlagValues("--with");
341
+ const fixSource = args.source?.trim() || undefined;
342
+ const fixPairs = replaces.map((old, i) => ({ old, new: withs[i] ?? "" }));
343
+ if (replaces.length > 0 || withs.length > 0 || fixSource !== undefined) {
344
+ if (!args.negative) {
345
+ throw new UsageError("--replace, --with and --source are only for negative feedback.", "INVALID_FLAG_VALUE");
346
+ }
347
+ if (replaces.length === 0 || replaces.length !== withs.length) {
348
+ throw new UsageError(`Each --replace needs one --with (got ${replaces.length} --replace and ${withs.length} --with).`, "INVALID_FLAG_VALUE");
349
+ }
350
+ if (!fixSource) {
351
+ throw new UsageError("A fix needs --source: the URL, command or file that shows the correct fact.", "MISSING_REQUIRED_ARGUMENT");
352
+ }
353
+ if (!reason?.trim()) {
354
+ throw new UsageError("A fix needs --reason: say what is wrong.", "MISSING_REQUIRED_ARGUMENT");
355
+ }
356
+ }
265
357
  if (args.negative === true && !reason?.trim()) {
266
358
  // F-3 / #384: Default requireReason is now true. Load config to allow
267
359
  // operators to opt out via feedback.requireReason: false in akm.json.
268
360
  const cfg = loadConfig();
269
361
  const requireReason = cfg.feedback?.requireReason ?? true; // Default: true (F-3 / #384)
270
362
  if (requireReason) {
271
- throw new UsageError("Negative feedback requires --reason: the next improve run proposes a fix from it, so say what is wrong and what should change. " +
363
+ throw new UsageError("Negative feedback requires --reason: say what is wrong and what should change. " +
272
364
  "Use --failure-mode for a curated taxonomy or --reason for free text. " +
273
365
  "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>" [--failure-mode incorrect|outdated|dangerous|incomplete|redundant]`);
274
366
  }
275
367
  else {
276
- warn("Warning: negative feedback without --reason gives the next improve run nothing to base a fix on.");
368
+ warn("Warning: negative feedback without --reason says nothing about what is wrong.");
277
369
  }
278
370
  }
279
371
  const rawTags = parseAllFlagValues("--tag");
@@ -283,6 +375,7 @@ export const feedbackCommand = defineJsonCommand({
283
375
  ...(reason?.trim() ? { reason: reason.trim() } : {}),
284
376
  ...(failureMode ? { failureMode } : {}),
285
377
  ...(validatedTags.length > 0 ? { tags: validatedTags } : {}),
378
+ ...(fixPairs.length > 0 ? { fix: { source: fixSource, replacements: fixPairs.length } } : {}),
286
379
  };
287
380
  const metadataStr = Object.keys(metadataObj).length > 1 ? JSON.stringify(metadataObj) : undefined;
288
381
  // Feedback only needs the index to exist, not to be current. A stale index
@@ -309,6 +402,7 @@ export const feedbackCommand = defineJsonCommand({
309
402
  let rankingUpdateApplied = false;
310
403
  let rankingUpdateSkippedReason;
311
404
  let durableRef = ref;
405
+ let fix;
312
406
  const db = openExistingDatabase();
313
407
  try {
314
408
  const config = loadConfig();
@@ -338,6 +432,23 @@ export const feedbackCommand = defineJsonCommand({
338
432
  if (!itemRef)
339
433
  throw new UsageError(`Indexed ref "${ref}" has no durable item ref.`, "INVALID_PROPOSAL");
340
434
  durableRef = itemRef;
435
+ if (fixPairs.length > 0) {
436
+ // Checked before anything is recorded, so a fix that does not apply leaves no trace.
437
+ const filePath = getEntryFilePathById(db, entryId);
438
+ if (!filePath || !fs.existsSync(filePath)) {
439
+ throw new NotFoundError(`The file for ${itemRef} is missing on disk.`, "ASSET_NOT_FOUND");
440
+ }
441
+ const resolved = resolveMutationTarget(config, parseRefInput(itemRef), undefined, { requireWritable: true });
442
+ if (!isWithin(filePath, resolved.target.source.path)) {
443
+ throw new UsageError(`${itemRef} is outside bundle "${resolved.target.source.name}".`);
444
+ }
445
+ const before = fs.readFileSync(filePath, "utf8");
446
+ const after = applyExactReplacements(before, fixPairs, filePath);
447
+ if (after === before)
448
+ throw new UsageError("The fix changes nothing.", "INVALID_FLAG_VALUE");
449
+ assertFrontmatterStillParses(before, after, filePath);
450
+ fix = { content: after, target: { source: resolved.target.source.name, root: resolved.target.source.path } };
451
+ }
341
452
  const recordResult = recordFeedbackUsage(db, entryId, itemRef, signal, metadataStr);
342
453
  utilityResult = recordResult.utilityResult;
343
454
  rankingUpdateApplied = recordResult.rankingUpdateApplied;
@@ -351,6 +462,16 @@ export const feedbackCommand = defineJsonCommand({
351
462
  ref: durableRef,
352
463
  metadata: metadataObj,
353
464
  });
465
+ const fixProposal = fix && reason?.trim() && fixSource
466
+ ? createProposal(resolveStashDir(), {
467
+ ref: durableRef,
468
+ itemRef: durableRef,
469
+ target: fix.target,
470
+ source: "feedback",
471
+ payload: { content: fix.content },
472
+ feedback: { reason: reason.trim(), source: fixSource },
473
+ })
474
+ : undefined;
354
475
  // F-5 / #386: When a high-utility asset crosses below the review threshold,
355
476
  // auto-create a review-needed escalation proposal so a human can confirm
356
477
  // whether the negative feedback is valid before the asset falls out of
@@ -419,6 +540,7 @@ export const feedbackCommand = defineJsonCommand({
419
540
  rankingUpdate: rankingUpdateApplied
420
541
  ? { applied: true }
421
542
  : { applied: false, reason: rankingUpdateSkippedReason ?? "unknown" },
543
+ ...(fixProposal ? { fix: { proposalId: fixProposal.id, replacements: fixPairs.length, source: fixSource } } : {}),
422
544
  ...(appliedToResult
423
545
  ? { appliedTo: { ref: appliedToResult.lessonRef, lessonStrength: appliedToResult.strength } }
424
546
  : {}),
@@ -29,6 +29,7 @@
29
29
  import fs from "node:fs";
30
30
  import path from "node:path";
31
31
  import consolidatePairPrompt from "../../../assets/prompts/consolidate-pair.md" with { type: "text" };
32
+ import consolidatePairCheckPrompt from "../../../assets/prompts/consolidate-pair-check.md" with { type: "text" };
32
33
  import { parseFrontmatter } from "../../../core/asset/frontmatter.js";
33
34
  import { conceptIdFromTypeName } from "../../../core/asset/resolve-ref.js";
34
35
  import { asNonEmptyString } from "../../../core/common.js";
@@ -42,8 +43,8 @@ import { runGit } from "../../../sources/providers/git-install.js";
42
43
  import { closeDatabase, openExistingDatabase, openReadonlyExistingDatabase, } from "../../../storage/repositories/index-connection.js";
43
44
  import { getAllEntries, getEntryById } from "../../../storage/repositories/index-entries-repository.js";
44
45
  import { getNeighborsByEntryId } from "../../../storage/repositories/index-vec-repository.js";
45
- import { isRetireProposal } from "../../proposal/proposal-types.js";
46
- import { createRetireProposal, listProposalsReadOnly } from "../../proposal/repository.js";
46
+ import { isRetireProposal, PAIR_PASS_GATE, } from "../../proposal/proposal-types.js";
47
+ import { createRetireProposal, listProposalsReadOnly, proposalContentHash, recordGateDecision, } from "../../proposal/repository.js";
47
48
  import { isHotCapturedMemory } from "../consolidate.js";
48
49
  import { contentHash, stripFrontmatterBody } from "../content-hash.js";
49
50
  import { loadLedgerSnapshot, PAIR_PASS_LEDGER_SOURCE, recordLedgerAttempt, stripBundle } from "../ledger.js";
@@ -67,8 +68,8 @@ export const BACKFILL_FLOOR = 0.95;
67
68
  export const NEW_MATERIAL_DAYS = 7;
68
69
  /** Pairs judged per run, highest cosine first (plan §7's nightly cost budget). */
69
70
  export const MAX_PAIRS_PER_RUN = 300;
70
- /** Body characters sent to the judge per side (plan §4.3: bodies were truncated at this length for calibration). */
71
- const PAIR_BODY_TRUNCATE_CHARS = 2500;
71
+ /** Body characters sent to the judge per side: enough for nearly every note, so a retirement is judged on the whole text. */
72
+ const PAIR_BODY_TRUNCATE_CHARS = 12_000;
72
73
  const MS_PER_DAY = 86_400_000;
73
74
  const RELATION_LABELS = ["duplicate", "subsumed", "supersedes", "contradicts", "overlap", "unrelated"];
74
75
  const RETIRE_LABELS = new Set(["duplicate", "subsumed", "supersedes"]);
@@ -76,11 +77,14 @@ const RETIRE_LABELS = new Set(["duplicate", "subsumed", "supersedes"]);
76
77
  function isRetireLabel(label) {
77
78
  return RETIRE_LABELS.has(label);
78
79
  }
79
- const PAIR_JUDGE_JSON_SCHEMA = {
80
+ /** Exported, with {@link buildPairUserPrompt}, so a replay can drive the exact judge call. */
81
+ export const PAIR_JUDGE_JSON_SCHEMA = {
80
82
  type: "object",
81
- required: ["relation", "redundant", "stale", "confidence", "reason"],
83
+ required: ["onlyInA", "onlyInB", "relation", "redundant", "stale", "confidence", "reason"],
82
84
  additionalProperties: false,
83
85
  properties: {
86
+ onlyInA: { type: "array", items: { type: "string", maxLength: 200 }, maxItems: 20 },
87
+ onlyInB: { type: "array", items: { type: "string", maxLength: 200 }, maxItems: 20 },
84
88
  relation: { type: "string", enum: [...RELATION_LABELS] },
85
89
  redundant: { type: ["string", "null"], enum: ["A", "B", null] },
86
90
  stale: { type: ["string", "null"], enum: ["A", null] },
@@ -88,6 +92,12 @@ const PAIR_JUDGE_JSON_SCHEMA = {
88
92
  reason: { type: "string", maxLength: 400 },
89
93
  },
90
94
  };
95
+ const PAIR_CHECK_JSON_SCHEMA = {
96
+ type: "object",
97
+ required: ["missing"],
98
+ additionalProperties: false,
99
+ properties: { missing: { type: "array", items: { type: "string", maxLength: 200 }, maxItems: 20 } },
100
+ };
91
101
  /** Hand-validates the judge's JSON, independent of whatever the provider's own schema enforcement did. */
92
102
  export function parsePairJudgeResponse(raw) {
93
103
  const parsed = parseEmbeddedJsonResponse(raw);
@@ -103,7 +113,16 @@ export function parsePairJudgeResponse(raw) {
103
113
  return undefined;
104
114
  const confidence = Math.max(0, Math.min(1, parsed.confidence));
105
115
  const reason = typeof parsed.reason === "string" ? parsed.reason : "";
106
- return { relation: parsed.relation, redundant, confidence, reason };
116
+ const onlyInA = claimList(parsed.onlyInA);
117
+ const onlyInB = claimList(parsed.onlyInB);
118
+ if (!onlyInA || !onlyInB)
119
+ return undefined;
120
+ return { onlyInA, onlyInB, relation: parsed.relation, redundant, confidence, reason };
121
+ }
122
+ function claimList(value) {
123
+ if (!Array.isArray(value) || !value.every((v) => typeof v === "string"))
124
+ return undefined;
125
+ return value.map((v) => v.trim()).filter(Boolean);
107
126
  }
108
127
  function isFlatName(name) {
109
128
  return !name.includes("/");
@@ -445,7 +464,7 @@ function orderByAge(x, y) {
445
464
  return xIsOlder ? { older: x, newer: y } : { older: y, newer: x };
446
465
  }
447
466
  /** The user message: dates decide "A (older)" / "B (newer)" (plan Appendix A), matching the calibration sample's own ordering. */
448
- function buildPairUserPrompt(older, newer) {
467
+ export function buildPairUserPrompt(older, newer) {
449
468
  return [...sideSection("A (older)", older), ...sideSection("B (newer)", newer)].join("\n");
450
469
  }
451
470
  /** The tombstone-vocabulary reason a judge label maps to (`supersedes` -> `superseded`; the rest unchanged). */
@@ -456,19 +475,59 @@ export function tombstoneReason(label) {
456
475
  * The calibrated outcome table (owner grades, replacing plan §5.2's
457
476
  * "shorter body" rule): `duplicate`/`supersedes` keep the newer copy;
458
477
  * `subsumed` keeps the side the judge did NOT name `redundant` (no proposal
459
- * if that pointer is missing or invalid).
478
+ * if that pointer is missing or invalid). A side is retired only when the
479
+ * judge listed nothing that it alone holds.
460
480
  */
461
- export function decideRetirement(label, redundant, older, newer) {
481
+ export function decideRetirement(label, redundant, older, newer, only) {
482
+ let decision;
462
483
  if (label === "duplicate" || label === "supersedes")
463
- return { retired: older, successor: newer };
464
- if (label === "subsumed") {
465
- if (redundant === "A")
466
- return { retired: older, successor: newer };
467
- if (redundant === "B")
468
- return { retired: newer, successor: older };
469
- return undefined; // the judge's pointer is missing or invalid — no proposal
470
- }
471
- return undefined;
484
+ decision = { retired: older, successor: newer };
485
+ else if (label === "subsumed" && redundant === "A")
486
+ decision = { retired: older, successor: newer };
487
+ else if (label === "subsumed" && redundant === "B")
488
+ decision = { retired: newer, successor: older };
489
+ if (!decision)
490
+ return undefined;
491
+ return (decision.retired === older ? only.onlyInA : only.onlyInB).length === 0 ? decision : undefined;
492
+ }
493
+ function checkSection(label, side) {
494
+ return [
495
+ `Note ${label}:`,
496
+ `Ref: ${side.asset.ref}`,
497
+ `Description: ${asNonEmptyString(side.frontmatter.description) ?? "(none)"}`,
498
+ "Content:",
499
+ "```",
500
+ stripFrontmatterBody(side.raw).slice(0, PAIR_BODY_TRUNCATE_CHARS),
501
+ "```",
502
+ "",
503
+ ].join("\n");
504
+ }
505
+ /**
506
+ * The second look a duplicate gets before it may retire unattended: one call
507
+ * that asks only what the retired note holds that the kept one lacks. True
508
+ * only on a clean, empty answer (it caught 2 of 4 duplicates the judge got
509
+ * wrong, and held back none of 109 right ones).
510
+ */
511
+ async function confirmNothingLost(ctx, retired, successor) {
512
+ const outcome = await callStage({
513
+ feature: "memory_consolidation",
514
+ runner: ctx.llmRunner,
515
+ system: consolidatePairCheckPrompt,
516
+ prompt: `${checkSection("X (to delete)", retired)}\n${checkSection("Y (kept)", successor)}`,
517
+ gate: { config: ctx.config, enabled: true },
518
+ request: {
519
+ responseSchema: PAIR_CHECK_JSON_SCHEMA,
520
+ enableThinking: false,
521
+ ...(Object.hasOwn(ctx.llmRunner, "timeoutMs") ? { timeoutMs: ctx.llmRunner.timeoutMs } : {}),
522
+ signal: ctx.opts.signal,
523
+ ...(ctx.chat ? { chat: ctx.chat } : {}),
524
+ },
525
+ parse: (raw) => claimList(parseEmbeddedJsonResponse(raw)?.missing),
526
+ ...(ctx.opts.onNotices ? { onNotices: ctx.opts.onNotices } : {}),
527
+ });
528
+ if (!outcome.ok)
529
+ return false;
530
+ return claimList(parseEmbeddedJsonResponse(outcome.raw)?.missing)?.length === 0;
472
531
  }
473
532
  /**
474
533
  * One pair: judge it, then (for a retire class) apply the guards and mint
@@ -519,7 +578,7 @@ async function judgeOne(ctx, candidate) {
519
578
  return { failed: false }; // counted; stays human — no proposal, no belief write
520
579
  if (!isRetireLabel(verdict.relation))
521
580
  return { failed: false }; // overlap / unrelated: judged_no_action
522
- const decision = decideRetirement(verdict.relation, verdict.redundant, older, newer);
581
+ const decision = decideRetirement(verdict.relation, verdict.redundant, older, newer, verdict);
523
582
  if (!decision)
524
583
  return { failed: false };
525
584
  const { retired, successor } = decision;
@@ -599,6 +658,21 @@ async function judgeOne(ctx, candidate) {
599
658
  }, ctx.opts.proposalsCtx);
600
659
  ctx.retired.push(proposal.id);
601
660
  ctx.perInitiatorProposed.add(candidate.initiator.ref);
661
+ // A duplicate with nothing unique on either side, confirmed by a second
662
+ // look, is the one class that retires unattended (109 of 111 safe on the
663
+ // owner's reviewed pairs, 2026-10-04): the triage drain accepts it under
664
+ // its usual applyMode. Every other retirement waits for a person.
665
+ if (verdict.relation === "duplicate" &&
666
+ verdict.onlyInA.length + verdict.onlyInB.length === 0 &&
667
+ !continuityRisk &&
668
+ (await confirmNothingLost(ctx, retired, successor))) {
669
+ recordGateDecision(ctx.stashDir, proposal.id, {
670
+ outcome: "staged",
671
+ reason: "duplicate",
672
+ gate: PAIR_PASS_GATE,
673
+ contentHash: proposalContentHash(proposal),
674
+ }, ctx.opts.proposalsCtx);
675
+ }
602
676
  return { failed: false };
603
677
  }
604
678
  catch (error) {
@@ -30,7 +30,7 @@ import { buildExecution, resolveExecution } from "../../integrations/agent/execu
30
30
  import { assertRunnerCredentials, runExecution, } from "../../integrations/agent/runner-dispatch.js";
31
31
  import { errMessage, noticeSet } from "../improve/stage.js";
32
32
  import { akmProposalAccept, akmProposalReject } from "./proposal.js";
33
- import { isRetireProposal, STALE_TARGET_GATE_REASON } from "./proposal-types.js";
33
+ import { isRetireProposal, PAIR_PASS_GATE, STALE_TARGET_GATE_REASON } from "./proposal-types.js";
34
34
  import { listProposals, listProposalsReadOnly, preflightProposalPromotion, proposalContent, proposalContentHash, readFreshProposalTarget, recordGateDecision, } from "./repository.js";
35
35
  /** The gate label on every decision the drain records. */
36
36
  const DRAIN_GATE = "triage";
@@ -313,13 +313,21 @@ export async function drainProposals(opts, promoteFn = akmProposalAccept, reject
313
313
  const accepts = [];
314
314
  const empties = [];
315
315
  for (const proposal of pending) {
316
- // A consolidate pair-pass `retire` proposal is never auto-decided here,
317
- // whatever `applyMode` says (alpha.9 brief §A "Review"; spec §25.6):
318
- // untouched, still pending, waiting for a direct `akm proposal accept`.
319
- // Checked before isEmptyDiff, which reads proposalContent() and has
320
- // nothing meaningful to read on a delete-primary change anyway.
321
- if (isRetireProposal(proposal))
316
+ // A consolidate pair-pass `retire` proposal is auto-accepted only when the
317
+ // pair judge staged it as a duplicate with nothing unique on either side
318
+ // (spec §25.9, equivalent content); every other one waits for a direct
319
+ // `akm proposal accept` (spec §25.6). Checked before isEmptyDiff, which
320
+ // has nothing meaningful to read on a delete-primary change.
321
+ if (isRetireProposal(proposal)) {
322
+ const staged = proposal.gateDecision;
323
+ if (staged?.outcome === "staged" &&
324
+ staged.gate === PAIR_PASS_GATE &&
325
+ staged.contentHash === proposalContentHash(proposal) &&
326
+ !proposal.retirement?.continuityRisk) {
327
+ accepts.push({ id: proposal.id, reason: "duplicate" });
328
+ }
322
329
  continue;
330
+ }
323
331
  const decision = proposal.gateDecision;
324
332
  // Another gate's rejection stands, and another gate's deferral is a
325
333
  // generating stage's hand-off to a person: it is left for that person.
@@ -53,6 +53,8 @@ export function isRetireProposal(proposal) {
53
53
  }
54
54
  /** A promote refused because the target changed after mint (STALE, R20) — not a merit judgement. */
55
55
  export const STALE_TARGET_GATE_REASON = "stale-target";
56
+ /** The gate on a retire proposal the triage drain may accept unattended: a pair-judged duplicate. */
57
+ export const PAIR_PASS_GATE = "consolidate-pair";
56
58
  export const EXPIRED_GATE_REASON = "expired";
57
59
  export const ASSET_MISSING_GATE_REASON = "asset-missing";
58
60
  const PROCEDURAL_GATE_REASONS = new Set([
@@ -287,6 +287,7 @@ export function createProposal(stashDir, input, ctx) {
287
287
  ...(input.eligibilitySource !== undefined ? { eligibilitySource: input.eligibilitySource } : {}),
288
288
  ...(input.promotionSource !== undefined ? { promotionSource: input.promotionSource } : {}),
289
289
  ...(input.promotionSourceHash !== undefined ? { promotionSourceHash: input.promotionSourceHash } : {}),
290
+ ...(input.feedback !== undefined ? { feedback: input.feedback } : {}),
290
291
  };
291
292
  upsertProposal(db, proposal, stashDir);
292
293
  for (const ref of input.attemptedRefs ?? [normalizedRef]) {
@@ -85,6 +85,7 @@ export function shapeProposalEntry(entry, detail) {
85
85
  "updatedAt",
86
86
  "confidence",
87
87
  "gateDecision",
88
+ "feedback",
88
89
  "review",
89
90
  "reviewHistory",
90
91
  "retirement",
@@ -101,6 +102,7 @@ export function shapeProposalEntry(entry, detail) {
101
102
  "updatedAt",
102
103
  "confidence",
103
104
  "gateDecision",
105
+ "feedback",
104
106
  "payload",
105
107
  "review",
106
108
  "reviewHistory",
@@ -183,6 +183,11 @@ export function formatProposalShowPlain(r) {
183
183
  if (gate.decidedAt)
184
184
  lines.push(`gate.decidedAt: ${String(gate.decidedAt)}`);
185
185
  }
186
+ const fix = p.feedback;
187
+ if (fix) {
188
+ lines.push(`feedback.reason: ${String(fix.reason)}`);
189
+ lines.push(`feedback.source: ${String(fix.source)}`);
190
+ }
186
191
  const review = p.review;
187
192
  if (review) {
188
193
  lines.push(`review.outcome: ${String(review.outcome ?? "?")}`);
@@ -28539,6 +28539,7 @@ function shapeProposalEntry(entry, detail) {
28539
28539
  "updatedAt",
28540
28540
  "confidence",
28541
28541
  "gateDecision",
28542
+ "feedback",
28542
28543
  "review",
28543
28544
  "reviewHistory",
28544
28545
  "retirement"
@@ -28554,6 +28555,7 @@ function shapeProposalEntry(entry, detail) {
28554
28555
  "updatedAt",
28555
28556
  "confidence",
28556
28557
  "gateDecision",
28558
+ "feedback",
28557
28559
  "payload",
28558
28560
  "review",
28559
28561
  "reviewHistory",
@@ -30491,6 +30493,11 @@ function formatProposalShowPlain(r) {
30491
30493
  if (gate.decidedAt)
30492
30494
  lines.push(`gate.decidedAt: ${String(gate.decidedAt)}`);
30493
30495
  }
30496
+ const fix = p.feedback;
30497
+ if (fix) {
30498
+ lines.push(`feedback.reason: ${String(fix.reason)}`);
30499
+ lines.push(`feedback.source: ${String(fix.source)}`);
30500
+ }
30494
30501
  const review = p.review;
30495
30502
  if (review) {
30496
30503
  lines.push(`review.outcome: ${String(review.outcome ?? "?")}`);
@@ -27867,6 +27867,7 @@ function shapeProposalEntry(entry, detail) {
27867
27867
  "updatedAt",
27868
27868
  "confidence",
27869
27869
  "gateDecision",
27870
+ "feedback",
27870
27871
  "review",
27871
27872
  "reviewHistory",
27872
27873
  "retirement"
@@ -27882,6 +27883,7 @@ function shapeProposalEntry(entry, detail) {
27882
27883
  "updatedAt",
27883
27884
  "confidence",
27884
27885
  "gateDecision",
27886
+ "feedback",
27885
27887
  "payload",
27886
27888
  "review",
27887
27889
  "reviewHistory",
@@ -29819,6 +29821,11 @@ function formatProposalShowPlain(r) {
29819
29821
  if (gate.decidedAt)
29820
29822
  lines.push(`gate.decidedAt: ${String(gate.decidedAt)}`);
29821
29823
  }
29824
+ const fix = p.feedback;
29825
+ if (fix) {
29826
+ lines.push(`feedback.reason: ${String(fix.reason)}`);
29827
+ lines.push(`feedback.source: ${String(fix.source)}`);
29828
+ }
29822
29829
  const review = p.review;
29823
29830
  if (review) {
29824
29831
  lines.push(`review.outcome: ${String(review.outcome ?? "?")}`);
@@ -171,6 +171,12 @@ function validatePresentMetadata(meta) {
171
171
  }
172
172
  }
173
173
  }
174
+ if (Object.hasOwn(meta, "feedback")) {
175
+ const fix = meta.feedback;
176
+ if (!isRecord(fix) || typeof fix.reason !== "string" || typeof fix.source !== "string") {
177
+ invalidPresentField("feedback");
178
+ }
179
+ }
174
180
  if (Object.hasOwn(meta, "acceptedTarget")) {
175
181
  const target = meta.acceptedTarget;
176
182
  if (typeof target !== "object" ||
@@ -293,6 +299,7 @@ export function proposalRowToProposal(row) {
293
299
  ...(meta.reviewHistory !== undefined ? { reviewHistory: meta.reviewHistory } : {}),
294
300
  ...(typeof meta.confidence === "number" ? { confidence: meta.confidence } : {}),
295
301
  ...(meta.gateDecision !== undefined ? { gateDecision: meta.gateDecision } : {}),
302
+ ...(meta.feedback !== undefined ? { feedback: meta.feedback } : {}),
296
303
  ...(typeof meta.backupContent === "string" ? { backupContent: meta.backupContent } : {}),
297
304
  ...(meta.acceptedTarget !== undefined ? { acceptedTarget: meta.acceptedTarget } : {}),
298
305
  ...(typeof meta.eligibilitySource === "string"
@@ -359,6 +366,8 @@ export function proposalToRowValues(proposal, stashDir) {
359
366
  metaObj.confidence = proposal.confidence;
360
367
  if (proposal.gateDecision !== undefined)
361
368
  metaObj.gateDecision = proposal.gateDecision;
369
+ if (proposal.feedback !== undefined)
370
+ metaObj.feedback = proposal.feedback;
362
371
  if (proposal.backupContent !== undefined)
363
372
  metaObj.backupContent = proposal.backupContent;
364
373
  if (proposal.acceptedTarget !== undefined)
@@ -1589,9 +1589,14 @@ preserves it byte-for-byte.
1589
1589
 
1590
1590
  Record positive or negative feedback for any indexed bundle asset.
1591
1591
  `akm feedback <ref> --negative --reason "<what is wrong and what should change>"`
1592
- flags the asset for review: the next improve run proposes a fix based on your
1593
- reason, so be specific. `--positive` records that an asset helped (it raises
1594
- its ranking) and does not trigger a rewrite. Both signals update the asset's
1592
+ flags the asset: it ranks lower right away, and the next improve run may repair
1593
+ its description, title or `when_to_use` from your reason. Improve does not
1594
+ rewrite an asset's text. To correct a wrong fact there, attach the exact fix
1595
+ with `--replace`, `--with` and `--source`: akm checks that each `--replace`
1596
+ text appears exactly once and that the frontmatter still parses, records nothing
1597
+ if either check fails, and queues the edit as a `feedback` proposal for review.
1598
+ `--positive` records that an asset helped (it raises its ranking) and does not
1599
+ trigger a rewrite. Both signals update the asset's
1595
1600
  utility score right away, so highly-rated assets rank higher in search results.
1596
1601
 
1597
1602
  ```sh
@@ -1602,13 +1607,17 @@ akm feedback env/prod --positive
1602
1607
  akm feedback skills/code-review --positive --reason "Worked perfectly for PR reviews"
1603
1608
  akm feedback skills/code-review --negative --failure-mode outdated --reason "references a removed flag"
1604
1609
  akm feedback skills/code-review --negative --reason "flaky" --tag slice:train --tag team:platform
1610
+ akm feedback knowledge/opencode-server --negative --reason "the default port is 4096, not 8000" --replace "port 8000" --with "port 4096" --source "https://opencode.ai/docs/server/"
1605
1611
  ```
1606
1612
 
1607
1613
  | Flag | Description |
1608
1614
  | --- | --- |
1609
1615
  | `--positive` | Record that an asset helped: it raises its ranking and does not trigger a rewrite |
1610
- | `--negative` | Flag the asset for review: the next improve run proposes a fix based on `--reason`, so be specific |
1611
- | `--reason` | What is wrong with the asset's content and what should change; not for `akm` command errors. Attached to the feedback event and read by the next improve run's fix proposal (required for negative feedback by default) |
1616
+ | `--negative` | Flag the asset: it ranks lower right away, and the next improve run may repair its frontmatter from `--reason` |
1617
+ | `--reason` | What is wrong with the asset's content and what should change; not for `akm` command errors. Attached to the feedback event (required for negative feedback by default, and always with `--replace`) |
1618
+ | `--replace <text>` | Exact text to correct, copied verbatim from the asset file; it must appear exactly once. Repeatable, each paired in order with a `--with`. Negative feedback only |
1619
+ | `--with <text>` | The corrected text for the matching `--replace`. Use `--with=<text>` for a value that starts with `-` |
1620
+ | `--source <where>` | The URL, command or file that shows the correct fact. Required with `--replace`; shown to the reviewer with the proposal |
1612
1621
  | `--failure-mode` | Structured failure-mode taxonomy for negative feedback: `incorrect`, `outdated`, `dangerous`, `incomplete`, `redundant`. Stored alongside `--reason` in event metadata for the distill pipeline. |
1613
1622
  | `--tag` | Tag to attach to the feedback (repeatable, e.g. `--tag slice:train --tag team:platform`) |
1614
1623
  | `--applied-to <ref>` | Credit a `lessons/<name>` lesson that helped resolve this task. 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. |
@@ -3097,8 +3106,8 @@ akm proposal drain --strategy default --promote -y # Read the triage block from
3097
3106
 
3098
3107
  `akm feedback` accepts an optional `--reason <text>` flag whose value is
3099
3108
  forwarded into feedback metadata and consumed by improve/distill proposal
3100
- prompts. Negative feedback requires a reason by default: the next improve run
3101
- proposes a fix from it, so say what is wrong and what should change.
3109
+ prompts. Negative feedback requires a reason by default: say what is wrong and
3110
+ what should change.
3102
3111
 
3103
3112
  Write the reason about the asset's content. Reflect treats it as an unverified
3104
3113
  report to investigate, not a fact to insert, and is told to leave the section
@@ -159,7 +159,7 @@ the set of types the code actually emits at HEAD (verified against every
159
159
  | `curate` | `akm curate <prompt>` | `query`, `itemCount`, `itemRefs` |
160
160
  | `show` | `akm show <ref>` | `ref`, `type`, `name` |
161
161
  | `select` | `akm show` after a search returning the same ref | `ref`, `query`, `searchTs`, `rankPosition` |
162
- | `feedback` | `akm feedback <ref>` | `signal` (positive/negative), `reason`, `failureMode`, `tags` |
162
+ | `feedback` | `akm feedback <ref>` | `signal` (positive/negative), `reason`, `failureMode`, `tags`, `fix` (`source` and the number of replacements, when an exact fix was attached) |
163
163
  | `sync` | `akm sync` | `name`, `message`, `ok` |
164
164
  | `index_db_vacuumed` | `akm index` VACUUMed index.db, after an index layout migration or because more than half its pages were free | `pagesBefore`, `pagesAfter`, `freelistRatioBefore` |
165
165
  | `stash_synced` | `akm improve`'s internal auto-sync pass (the `sync.push` feature), **distinct from** the `akm sync` command above | `committed`, `pushed`, `skipped`, `reason`, `attributed` (paths the run wrote and staged), `unattributed` (in-scope paths that went dirty during the run without the run writing them — left for their author) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akm-cli",
3
- "version": "0.9.25",
3
+ "version": "0.9.26-alpha.2",
4
4
  "type": "module",
5
5
  "description": "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 Cursor.",
6
6
  "keywords": [