akm-cli 0.10.26101001-alpha → 0.10.26101101-alpha

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.
Files changed (58) hide show
  1. package/CHANGELOG.md +114 -0
  2. package/STABILITY.md +1 -1
  3. package/dist/assets/hints/cli-hints-full.md +2 -2
  4. package/dist/assets/hints/cli-hints-short.md +1 -1
  5. package/dist/assets/improve-strategies/default.json +1 -1
  6. package/dist/assets/stash-skeleton/README.md +1 -1
  7. package/dist/assets/tasks/improve/akm-improve-consolidate.yml +1 -1
  8. package/dist/commands/feedback-cli.js +75 -49
  9. package/dist/commands/improve/consolidate/chunking.js +3 -10
  10. package/dist/commands/improve/consolidate/pair-pass.js +22 -9
  11. package/dist/commands/improve/consolidate.js +4 -30
  12. package/dist/commands/improve/distill.js +8 -1
  13. package/dist/commands/improve/eligibility.js +14 -1
  14. package/dist/commands/improve/extract-prompt.js +17 -7
  15. package/dist/commands/improve/extract.js +5 -7
  16. package/dist/commands/improve/improve-cli.js +5 -4
  17. package/dist/commands/improve/improve.js +134 -52
  18. package/dist/commands/improve/loop-stages.js +18 -4
  19. package/dist/commands/improve/reflect.js +35 -4
  20. package/dist/commands/improve/retrieval-gate.js +4 -2
  21. package/dist/commands/improve/session-asset.js +4 -1
  22. package/dist/commands/improve/stage.js +9 -3
  23. package/dist/commands/proposal/drain.js +4 -0
  24. package/dist/commands/proposal/proposal-cli.js +1 -1
  25. package/dist/commands/proposal/repository.js +17 -11
  26. package/dist/commands/proposal/validators/proposal-quality-validators.js +0 -3
  27. package/dist/commands/sources/stash-cli.js +2 -2
  28. package/dist/core/config/schema/improve-processes.js +2 -2
  29. package/dist/core/file-lock.js +2 -6
  30. package/dist/core/improve-result.js +2 -0
  31. package/dist/core/paths.js +4 -0
  32. package/dist/core/run-lock.js +1 -1
  33. package/dist/indexer/indexer.js +15 -6
  34. package/dist/indexer/passes/memory-inference.js +15 -6
  35. package/dist/indexer/search/db-search.js +45 -17
  36. package/dist/indexer/search/ranking.js +29 -1
  37. package/dist/indexer/usage/usage-events.js +21 -16
  38. package/dist/scripts/akm-migrate-node.js +3 -5
  39. package/dist/scripts/akm-migrate.js +3 -5
  40. package/dist/sources/providers/git-stash.js +94 -13
  41. package/dist/storage/repositories/index-utility-repository.js +2 -32
  42. package/dist/storage/repositories/index-vec-repository.js +4 -0
  43. package/dist/tasks/run/run-command-task.js +9 -1
  44. package/dist/tasks/run/run-workflow-task.js +4 -3
  45. package/dist/tasks/run/task-log.js +38 -1
  46. package/docs/architecture/0.10-process-rules.md +834 -0
  47. package/docs/architecture/improvement.md +71 -39
  48. package/docs/architecture/internals/improve-workflow.md +7 -7
  49. package/docs/architecture/internals/storage-locations.md +1 -1
  50. package/docs/architecture/testing/manual-testing-checklist.md +1 -1
  51. package/docs/guides/bundles.md +3 -1
  52. package/docs/guides/improve-the-library.md +3 -2
  53. package/docs/guides/scheduling.md +9 -1
  54. package/docs/reference/cli.md +35 -18
  55. package/docs/reference/configuration.md +2 -2
  56. package/docs/reference/data-and-telemetry.md +1 -1
  57. package/package.json +1 -1
  58. package/dist/indexer/feedback/utility-policy.js +0 -75
package/CHANGELOG.md CHANGED
@@ -6,6 +6,120 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.10.26101101] - 2026-10-11
10
+
11
+ ### Changed
12
+
13
+ - **A manual fix to an asset applies at once.** `akm feedback --negative ... --replace/--with/--source`, `--superseded-by` and `--outdated` used to queue a `feedback` proposal that waited for `akm proposal accept` and expired after 90 days. The command now files the proposal and accepts it in the same run, through the ordinary accept (a git-backed bundle gets the usual commit; `akm proposal revert` undoes it), with gate decision `manual-fix` and no judge. If the accept refuses, the proposal is rejected, the file is untouched and the command fails. The edited asset stays eligible for every improve process.
14
+ - **`akm improve` syncs before it runs, not only after (#1091).** A live run on a git-backed bundle now does the same
15
+ fetch / fast-forward-or-merge / commit / push as `akm sync` right after taking its lock and before the index and plan
16
+ stages, so it plans from what other hosts pushed. It honours `--no-sync` and `--no-push`. A conflict or fetch failure
17
+ warns and the run continues on the local state; the outcome is `preRunSync` on the result and a `stash_synced` event
18
+ with `phase: "pre-run"`. Pending hand edits in the managed paths are committed by it, as `akm sync` would.
19
+ - **Reflect no longer locks an asset for 14 days over an uncertain verdict; it asks a person.** A reflect revision the
20
+ quality judge does not pass but scores 3.5 or more (the judge's own review label begins at 2.5) is now queued as a
21
+ pending proposal for a person (`judge-uncertain`, with the per-criterion scores and the judge's reason) and records
22
+ no `quality_rejected` window; a lower score is refused as before. Likewise a retrieval check that cannot grade a
23
+ revision (transport error, timeout, unreadable reply) queues it for a person (`retrieval-ungraded`) instead of
24
+ refusing it as a regression, and a distill lesson or promotion whose judge is missing at judge time is queued for
25
+ review instead of recording a 30-day `quality_rejected` window. Triage never accepts any of these. `akm proposal
26
+ list` shows the reason as `gate=deferred:<reason>`.
27
+ - **Feedback now changes `akm search` and `akm curate` ranking, as `akm feedback` always said it did.** The fused
28
+ score is scaled by `1 + 0.03 x (ln(1 + positives) - ln(1 + negatives))`, counting the user's recorded feedback
29
+ events for the asset (improve, task and audit feedback does not count). The rule is the same for every asset, rises
30
+ with each positive and falls with each negative, and gains less per vote as votes pile up: about +2% / +3% / +5% /
31
+ +9% for 1 / 2 / 5 / 20 positives, so an asset with 5 positives outranks one with 2, and the effect reorders close
32
+ matches without overriding relevance. The counts are read from `state.db` in one query per search. `utility_scores`
33
+ is still written and read by improve and metrics, but search does not use it.
34
+ - **`akm feedback` reports the real ranking effect.** `rankingUpdate.applied` in its output used to mean the utility
35
+ EMA write succeeded, which no longer has anything to do with ranking. It is now `true` exactly when the recorded
36
+ event is user-attributed (the events search counts) and `false` with a reason for improve, task, audit or unknown
37
+ attribution; a utility-write failure only warns and no longer flips it.
38
+ - **Utility has one formula and one implementation.** `akm feedback` used to move utility with its own bounded step
39
+ toward the cumulative positive share while `akm index` recomputed the same table with a different formula, so the
40
+ stored value depended on which ran last. `akm feedback` now calls the indexer's `recomputeUtilityScores` for the one
41
+ entry (user-attributed events only), so the value it stores is the one `akm index` would compute at that moment; the
42
+ bounded-step code (`utility-policy.ts`) is removed. A first positive on an entry with no utility row now gives 0.3
43
+ (the old start was 0.5). Search still does not read utility.
44
+
45
+ ### Fixed
46
+
47
+ - **The 02:00 nightly `akm improve` can no longer lose its night to the consolidate task's lock.** The shipped
48
+ `akm-improve-consolidate` task (`20 */4 * * *`) kept the default 2-hour budget, so a 00:20 run could still hold
49
+ `improve.lock` at 02:00 and the nightly (deliberately without `--skip-if-locked`) failed with `IMPROVE_LOCK_HELD`.
50
+ The consolidate task now passes `--timeout-ms 3600000`; the nightly is unchanged.
51
+ - **Scheduled command tasks (`uses: commands/…`) get the 6-hour unattended default timeout.** Only workflow and native
52
+ shell/script tasks had it, so a wedged engine call could hold a command task forever. An explicit task `timeout:`,
53
+ an engine timeout, or `timeout: null` still wins.
54
+ - **`akm search` no longer warns "no embedding provider is configured" on a default install.** With
55
+ `semanticSearchMode: auto` and no embedding settings, search uses the built-in local model, so the warning was
56
+ false on every query. It now fires only for a remote `embedding.endpoint` set without `embedding.model` (the one
57
+ config that cannot work), and the remedy names that field.
58
+ - **`akm sync` fetches the remote and brings other hosts' changes in before it pushes.** It used to check the stale
59
+ local tracking ref, so once another host had pushed, the lease push failed and `akm sync` exited 70 instead of
60
+ committing, and a two-host filesystem bundle never converged. A writable bundle with a remote now fetches, then
61
+ fast-forwards (or merges with git's default merge) the upstream, then pushes; a host with nothing to commit still
62
+ receives the upstream. A merge that conflicts is aborted, your commit stays, the push is skipped, and
63
+ `reason` reads `not pushed: merging upstream conflicts in <files> ...`. A failed fetch likewise only skips the push.
64
+ `--no-push` and read-only bundles never touch the network.
65
+ - **The end-of-run improve sync reports `committed: true` when only the push failed.** The commit had landed, but the
66
+ result and the `stash_synced` event said `committed: false`.
67
+ - **The extract per-session lock no longer reclaims a live holder.** It was reclaimed once its file was five minutes
68
+ old, but one session can hold it across two extraction attempts and the summary call, so a session-end hook racing
69
+ the improve run processed the session twice. Only a lock whose holder process is dead is reclaimed, as with the
70
+ improve run lock.
71
+ - **Memory inference writes to the default bundle, not the first bundle in the list.** The pass took `sources[0]`, so
72
+ it relied on list order and did nothing, silently, when that entry was not an akm bundle. It now selects the source
73
+ flagged as the default bundle and warns when there is none or when it is not an akm bundle.
74
+ - **Memory inference no longer reverts a parent memory's frontmatter edit.** The `inferenceProcessed` stamp rewrote
75
+ frontmatter parsed before the model call, so an edit made to the memory in the meantime (accepted proposal,
76
+ feedback hash) was lost. The stamp now adds its key to the file's current frontmatter.
77
+ - **The session summary prompt fences the transcript as untrusted.** Extract's summary call sent the raw, unfiltered
78
+ event stream with no boundary, so a crafted session could steer the text written into the session asset. It now
79
+ uses the same pre-filter and the same `UNTRUSTED SESSION TRANSCRIPT` fence, with forged fence markers neutralised,
80
+ as the extraction call.
81
+ - **`akm proposal revert` of a retirement still works after the 30-day archive purge.** When the graced purge has
82
+ deleted the archived copy, the primary is restored byte for byte from the proposal's recorded `backupContent`
83
+ instead of the revert refusing. A file that now occupies the path is still never overwritten.
84
+ - **Retire proposals can no longer become permanent.** They now expire by age like every other proposal (`akm proposal
85
+ reopen` brings one back, so the old "never expires" rationale is gone), a staged one whose file or successor is gone
86
+ or changed is auto-rejected once as `stale-target` instead of failing every drain run, and an expired or stale-target
87
+ retire proposal no longer counts as a person's rejection that settles its pair.
88
+ - **The consolidate pair pass no longer marks assets judged when they have no stored embedding.** With
89
+ `semanticSearchMode: off` (or before an asset is embedded) the neighbour query could not run, yet the pass wrote a
90
+ `judged_no_action` row that held the asset until its body changed. It now writes no row for such an asset, so
91
+ enabling embeddings reconsiders it, and warns once per run that the pair pass needs stored embeddings.
92
+ - **A transient reflect failure no longer consumes the feedback that planned it.** A transport error, parse error,
93
+ timeout, budget abort or crash wrote a `failed` ledger row that advanced the signal cursor, so the ref was not
94
+ retried until new feedback arrived. Like distill, these now write no row; guard rejections and quality rejections
95
+ are unchanged.
96
+ - **The per-run `akm improve` and `akm index` log files are now purged by the retention pass.**
97
+ `$CACHE/logs/improve/<ISO>.log` and `$CACHE/logs/index/<ISO>.log` were outside the purge and grew without bound;
98
+ they now follow `improve.eventRetentionDays` like the task log files.
99
+ - **A run hard-killed by the budget watchdog now leaves a record and banks its writes.** The forced `process.exit(0)`
100
+ (exit code unchanged) skipped every finally block, so the run left no `improve_runs` row and the files it had
101
+ written were never committed. Before exiting, the watchdog now records the run as terminated
102
+ (`budget_exhausted`) and makes the same provenance-scoped auto-sync commit, without pushing.
103
+
104
+ ### Changed
105
+
106
+ - **`captureMode: hot` memories are consolidated like any other memory.** The pair pass may now retire one, the promote
107
+ pass may promote one (accepting archives the source, as for any memory), and a chunk of only hot memories goes to the
108
+ model instead of being skipped as judged no-action. The hot-specific retire guard, chunk skip and promote-prompt
109
+ annotation are gone; `akm remember` still writes `captureMode: hot`.
110
+
111
+ ## [0.10.26101002] - 2026-10-10
112
+
113
+ ### Changed
114
+
115
+ - **Triage accepts up to 200 proposals per run by default, up from 25.** The consolidate pair pass stages about 90
116
+ pairs a night on a real stash, so a lower cap let the review queue grow every night. `maxAcceptsPerRun` still bounds
117
+ a run, and `akm proposal drain` uses the same default.
118
+ - **`akm improve <scope>` warns when the scope matches no asset.** A type scope that matches nothing in the stash
119
+ (`akm improve typo`, `improve skills`, `improve memorys`) prints one warning on stderr naming the scope and, for a
120
+ near-miss of a real type, the likely intended one. The exit code and the empty plan are unchanged, and a valid scope
121
+ with nothing eligible does not warn.
122
+
9
123
  ## [0.10.26101001] - 2026-10-10
10
124
 
11
125
  ### Added
package/STABILITY.md CHANGED
@@ -446,7 +446,7 @@ review-first config correctly shows `queue`.
446
446
 
447
447
  A deterministic-only `promote` (triage with judgment off) is **not** a gated
448
448
  lane (#1143): the `default` strategy runs it without the opt-in, bounded by
449
- `maxAcceptsPerRun` (25), through the same deterministic gates as
449
+ `maxAcceptsPerRun` (200), through the same deterministic gates as
450
450
  `akm proposal drain --promote`. Only a promote that turns the judgment tier on
451
451
  needs `experimental.improveAutonomy`.
452
452
 
@@ -95,7 +95,7 @@ akm lint --type workflows # Parse and compile every workflo
95
95
  akm workflow run workflows/ship-release # Start or resume and execute the workflow
96
96
  akm feedback skills/code-review --positive # Record that an asset helped (ranks it higher; no rewrite)
97
97
  akm feedback agents/reviewer --negative --reason "says to run jest; the suite runs on vitest" # Content wrong or stale: lowers its ranking; improve may repair its frontmatter
98
- 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
98
+ 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/" # Apply an exact fix
99
99
  akm feedback memories/deployment-notes --positive # Works for memories too
100
100
  akm feedback env/prod --positive # Records env feedback without surfacing values
101
101
  ```
@@ -110,7 +110,7 @@ its description, title or `when_to_use` from your reason. A note that simply did
110
110
  not fit your task is not negative feedback: record nothing for it. Improve does
111
111
  not rewrite an asset's text: once you have verified the correct fact, attach the
112
112
  exact fix with `--replace "<exact current text>" --with "<corrected text>"
113
- --source "<URL, command or file>"`, which akm checks and queues as a proposal.
113
+ --source "<URL, command or file>"`, which akm checks and applies at once.
114
114
  A note that is outdated can be marked with `--outdated`, or
115
115
  `--superseded-by <ref>` when another note replaces it.
116
116
  `--positive` records that an asset helped (it raises its ranking) and does not
@@ -41,7 +41,7 @@ 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
43
  akm feedback <ref> --negative --reason "..." # Content wrong or stale: 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
+ akm feedback <ref> --negative --reason "..." --replace "<exact text>" --with "<fix>" --source "<url|cmd|file>" # Apply an exact fix to its text
45
45
  akm bundle add <ref> # Add a source (npm, GitHub, git, local dir)
46
46
  akm clone <ref> # Copy an asset to the working bundle (optional --dest arg to clone to specific location)
47
47
  akm sync # Commit (and push if writable remote) changes in the primary bundle (--no-push to commit only)
@@ -12,7 +12,7 @@
12
12
  "extract": { "enabled": false, "triage": { "enabled": true, "minScore": 2 } },
13
13
  "validation": { "enabled": true },
14
14
  "proactiveMaintenance": { "enabled": true, "dueDays": 30, "maxPerRun": 15 },
15
- "triage": { "enabled": true, "applyMode": "promote", "maxAcceptsPerRun": 25 }
15
+ "triage": { "enabled": true, "applyMode": "promote", "maxAcceptsPerRun": 200 }
16
16
  },
17
17
  "sync": { "enabled": true, "push": true }
18
18
  }
@@ -85,7 +85,7 @@ akm feedback <ref> --positive
85
85
  # A note that simply did not fit your task gets no negative feedback.
86
86
  akm feedback <ref> --negative --reason "<what is wrong and what should change>"
87
87
 
88
- # Once you have verified the correct fact, attach the exact fix: it is checked and queued for review
88
+ # Once you have verified the correct fact, attach the exact fix: it is checked and applied at once
89
89
  akm feedback <ref> --negative --reason "<what is wrong>" --replace "<exact current text>" --with "<corrected text>" --source "<URL, command or file>"
90
90
 
91
91
  # Capture a durable lesson or memory from the current session
@@ -1,4 +1,4 @@
1
1
  version: 4
2
- run: akm improve --strategy consolidate --skip-if-locked --require-engines
2
+ run: akm improve --strategy consolidate --timeout-ms 3600000 --skip-if-locked --require-engines
3
3
  description: Consolidation-only pass (every 4h at :20)
4
4
  schedule: "20 */4 * * *"
@@ -21,15 +21,17 @@ import { getDbPath } from "../core/paths.js";
21
21
  import { withStateDb } from "../core/state-db.js";
22
22
  import { warn } from "../core/warn.js";
23
23
  import { withWriteTargetMutation } from "../core/write-source.js";
24
+ import { recomputeUtilityScores } from "../indexer/indexer.js";
24
25
  import { resolveSourceEntries } from "../indexer/search/search-source.js";
25
- import { countFeedbackSignals, insertUsageEvent, resolveUsageEventSource } from "../indexer/usage/usage-events.js";
26
+ import { insertUsageEvent, resolveUsageEventSource } from "../indexer/usage/usage-events.js";
26
27
  import { resolveSourcesForOrigin } from "../registry/origin-resolve.js";
27
28
  import { closeDatabase, openExistingDatabase } from "../storage/repositories/index-connection.js";
28
29
  import { findEntryIdByRef, getEntryFilePathById, getItemRefById, } from "../storage/repositories/index-entries-repository.js";
29
- import { applyFeedbackToUtilityScore } from "../storage/repositories/index-utility-repository.js";
30
+ import { getUtilityScore } from "../storage/repositories/index-utility-repository.js";
30
31
  import { contentHash } from "./improve/content-hash.js";
31
32
  import { readEdgeList } from "./improve/memory/memory-belief.js";
32
- import { createProposal } from "./proposal/repository.js";
33
+ import { akmProposalAccept } from "./proposal/proposal.js";
34
+ import { createProposal, recordGateDecision, rejectProposalDurably } from "./proposal/repository.js";
33
35
  // ── Tag validation ────────────────────────────────────────────────────────────
34
36
  const TAG_KEY_RE = /^[a-z_][a-z0-9_]*$/;
35
37
  function validateFeedbackTags(raw) {
@@ -144,26 +146,25 @@ function buildLessonStrengthUpdate(rawBytes, feedbackRef) {
144
146
  const nextBytes = Buffer.from(next);
145
147
  return rawBytes.equals(nextBytes) ? { strength: strengthList.length } : { strength: strengthList.length, nextBytes };
146
148
  }
149
+ /** Utility at or above this is "high"; falling below it after feedback emits `improve_review_needed`. */
150
+ const UTILITY_REVIEW_THRESHOLD = 0.5;
147
151
  /**
148
- * Persist the feedback usage-event (state.db) and immediately fold it into the
149
- * entry's MemRL utility score (index.db). Chunk-8 WI-8.3: usage_events lives in
150
- * state.db; entries + utility_scores stay in `indexDb`. BOTH positive and
151
- * negative signals apply the EMA utility update unconditionally and
152
- * immediately when the source is user-attributed — no `akm index` run is
153
- * required for either signal to affect search ranking. Uses the bounded-step
154
- * EMA policy (F-5 / #386, arXiv:2601.03192).
155
- *
156
- * The update is intentionally SKIPPED for non-`user` event sources (`improve`,
157
- * `task`, `audit`, `unknown`) — an anti-self-reinforcement guard that stops an
158
- * agent or automated pipeline from boosting its own picks. This is by design
159
- * and must not be removed; R-034 only asks that the skip be surfaced to the
160
- * caller rather than passing silently. Best-effort: a utility-update failure
161
- * never fails the feedback record.
152
+ * Persist the feedback usage-event (state.db) and, for a user-attributed event,
153
+ * recompute the entry's utility score (index.db) with the same function the
154
+ * `akm index` finalize pass uses, scoped to this one entry, so there is one
155
+ * formula and the stored value is what the next index would compute. Chunk-8
156
+ * WI-8.3: usage_events lives in state.db; entries + utility_scores stay in `indexDb`.
157
+ * Search ranking does not read the utility score: it counts the recorded
158
+ * `source='user'` feedback events itself, so a user-attributed event affects
159
+ * ranking at once and an `improve`/`task`/`audit`/`unknown` one never does — an
160
+ * anti-self-reinforcement guard that stops an agent or automated pipeline from
161
+ * boosting its own picks. This is by design and must not be removed. The
162
+ * utility score is read by improve and metrics only; its update is best-effort
163
+ * and its failure never fails the feedback record or changes the ranking effect.
162
164
  */
163
165
  function recordFeedbackUsage(indexDb, entryId, durableEntryRef, signal, metadataStr) {
164
166
  let utilityResult;
165
- let rankingUpdateApplied = false;
166
- let rankingUpdateSkippedReason;
167
+ let rankingSkippedReason;
167
168
  const eventSource = resolveUsageEventSource();
168
169
  withStateDb((stateDb) => {
169
170
  insertUsageEvent(stateDb, {
@@ -175,22 +176,22 @@ function recordFeedbackUsage(indexDb, entryId, durableEntryRef, signal, metadata
175
176
  source: eventSource,
176
177
  });
177
178
  if (eventSource !== "user") {
178
- rankingUpdateSkippedReason =
179
- `feedback source is "${eventSource}", not "user" — ranking updates only apply to user-attributed ` +
180
- "feedback (anti-self-reinforcement guard; set AKM_EVENT_SOURCE=user to record as user demand).";
179
+ rankingSkippedReason =
180
+ `feedback source is "${eventSource}", not "user" — it does not count toward search ranking, which counts ` +
181
+ "user-attributed feedback only (anti-self-reinforcement guard; set AKM_EVENT_SOURCE=user to record as user demand).";
181
182
  return;
182
183
  }
183
184
  try {
184
- const { pos, neg } = countFeedbackSignals(stateDb, entryId);
185
- utilityResult = applyFeedbackToUtilityScore(indexDb, entryId, pos, neg);
186
- rankingUpdateApplied = true;
185
+ const previousUtility = getUtilityScore(indexDb, entryId)?.utility ?? 0;
186
+ recomputeUtilityScores(indexDb, stateDb, entryId);
187
+ utilityResult = { previousUtility, nextUtility: getUtilityScore(indexDb, entryId)?.utility ?? previousUtility };
187
188
  }
188
189
  catch (err) {
189
- // best-effort — feedback recording succeeds even if utility update fails
190
- rankingUpdateSkippedReason = `utility update failed: ${err instanceof Error ? err.message : String(err)}`;
190
+ // best-effort — the event is recorded (and ranked) even if the utility update fails
191
+ warn(`[feedback] utility update failed: ${err instanceof Error ? err.message : String(err)}`);
191
192
  }
192
193
  });
193
- return { utilityResult, rankingUpdateApplied, rankingUpdateSkippedReason };
194
+ return { utilityResult, countsTowardRanking: eventSource === "user", rankingSkippedReason };
194
195
  }
195
196
  // ── Judged text ──────────────────────────────────────────────────────────────
196
197
  /**
@@ -416,15 +417,20 @@ export const feedbackCommand = defineJsonCommand({
416
417
  'the exact fix: --replace "<exact current text>" --with "<corrected text>" --source "<URL,\n' +
417
418
  'command or file that shows it>" (repeat --replace/--with for several edits; use --with=...\n' +
418
419
  "for a value that starts with -). akm checks that each --replace text appears exactly once\n" +
419
- "and queues the edit as a proposal for review. To mark the asset's history instead, add\n" +
420
+ "and applies the edit at once, with no review (`akm proposal revert` undoes it). To mark\n" +
421
+ "the asset's history instead, add\n" +
420
422
  "--superseded-by <ref> (the asset that replaces it) or --outdated (it describes a past\n" +
421
- "state and nothing replaces it), with --reason and --source: the same proposal sets its\n" +
423
+ "state and nothing replaces it), with --reason and --source: the same edit sets its\n" +
422
424
  "beliefState and, for --superseded-by, adds the ref to supersededBy. `--positive` records\n" +
423
425
  "that an asset helped (it raises its ranking) and does not trigger a rewrite.\n\n" +
424
- "Both signals adjust the asset's usefulness score right away, in the same\n" +
425
- "process: positive feedback raises it, negative lowers it, and recent\n" +
426
- "feedback counts for more than old feedback. No reindex is needed — the new\n" +
427
- "score affects ranking starting with the very next `akm search`.",
426
+ "Feedback changes `akm search` and `akm curate` ranking starting with the very\n" +
427
+ "next search; no reindex is needed. Each recorded positive raises the asset's\n" +
428
+ "score and each negative lowers it, the same way for every asset: the more\n" +
429
+ "positives, the higher it ranks (5 outrank 2), and each further vote counts\n" +
430
+ "for a little less. The effect is small, so it reorders close matches and\n" +
431
+ "does not override relevance. Only feedback recorded as the user counts, not\n" +
432
+ "feedback from improve, task or audit runs. Feedback also moves the utility\n" +
433
+ "score that improve and `akm metrics` read.",
428
434
  },
429
435
  args: {
430
436
  // Optional in citty so run() is invoked even when omitted; we re-validate
@@ -565,8 +571,8 @@ export const feedbackCommand = defineJsonCommand({
565
571
  // background reindex it spawned — now that ensureIndex is removed, holding
566
572
  // the lock only causes feedback to block for the full improve run duration.
567
573
  let utilityResult;
568
- let rankingUpdateApplied = false;
569
- let rankingUpdateSkippedReason;
574
+ let countsTowardRanking = false;
575
+ let rankingSkippedReason;
570
576
  let durableRef = ref;
571
577
  let fix;
572
578
  const db = openExistingDatabase();
@@ -586,10 +592,10 @@ export const feedbackCommand = defineJsonCommand({
586
592
  throw new NotFoundError(`Ref "${ref}" is not in the index. ` +
587
593
  "Run 'akm search' to verify the asset exists, then 'akm index' if it was recently added.", "ASSET_NOT_FOUND");
588
594
  }
589
- // Persist the feedback signal into usage_events. Both positive and
590
- // negative signals apply the EMA utility update immediately — no
591
- // `akm index` run is required for either signal to affect ranking in
592
- // search results (see recordFeedbackUsage / applyFeedbackToUtilityScore).
595
+ // Persist the feedback signal into usage_events. A user-attributed signal
596
+ // also recomputes the entry's utility at once (see recordFeedbackUsage).
597
+ // Search ranking reads the recorded events directly, so no `akm index`
598
+ // run is needed.
593
599
  // WI-8.5b: the `feedback` / `improve_review_needed` events key on the
594
600
  // resolved entry's fully-qualified item_ref — the SAME durable key the
595
601
  // usage_events row carries and the SAME spelling the signal-delta
@@ -645,8 +651,8 @@ export const feedbackCommand = defineJsonCommand({
645
651
  const metadataStr = Object.keys(metadataObj).length > 1 ? JSON.stringify(metadataObj) : undefined;
646
652
  const recordResult = recordFeedbackUsage(db, entryId, itemRef, signal, metadataStr);
647
653
  utilityResult = recordResult.utilityResult;
648
- rankingUpdateApplied = recordResult.rankingUpdateApplied;
649
- rankingUpdateSkippedReason = recordResult.rankingUpdateSkippedReason;
654
+ countsTowardRanking = recordResult.countsTowardRanking;
655
+ rankingSkippedReason = recordResult.rankingSkippedReason;
650
656
  }
651
657
  finally {
652
658
  closeDatabase(db);
@@ -656,16 +662,34 @@ export const feedbackCommand = defineJsonCommand({
656
662
  ref: durableRef,
657
663
  metadata: metadataObj,
658
664
  });
659
- const fixProposal = fix && reason?.trim() && fixSource
660
- ? createProposal(resolveStashDir(), {
665
+ // A person typed the fix, so it is applied now: the proposal is minted for the audit trail, `akm proposal
666
+ // revert` and reflect's "already acted on" rule, then accepted through the ordinary accept path (the same
667
+ // checks and git commit). If that refuses, the proposal is rejected and the command fails.
668
+ let fixProposal;
669
+ if (fix && reason?.trim() && fixSource) {
670
+ const stashDir = resolveStashDir();
671
+ const minted = createProposal(stashDir, {
661
672
  ref: durableRef,
662
673
  itemRef: durableRef,
663
674
  target: fix.target,
664
675
  source: "feedback",
665
676
  payload: { content: fix.content },
666
677
  feedback: { reason: reason.trim(), source: fixSource },
667
- })
668
- : undefined;
678
+ });
679
+ try {
680
+ recordGateDecision(stashDir, minted.id, { outcome: "auto-accepted", reason: "manual-fix", gate: "feedback" });
681
+ fixProposal = (await akmProposalAccept({ stashDir, id: minted.id })).proposal;
682
+ }
683
+ catch (err) {
684
+ try {
685
+ rejectProposalDurably(stashDir, minted.id, `manual fix not applied: ${err instanceof Error ? err.message : String(err)}`);
686
+ }
687
+ catch (rejectErr) {
688
+ warn(`[feedback] could not reject proposal ${minted.id}: ${rejectErr instanceof Error ? rejectErr.message : String(rejectErr)}`);
689
+ }
690
+ throw err;
691
+ }
692
+ }
669
693
  // F-5 / #386: When a high-utility asset crosses below the review threshold,
670
694
  // auto-create a review-needed escalation proposal so a human can confirm
671
695
  // whether the negative feedback is valid before the asset falls out of
@@ -674,7 +698,9 @@ export const feedbackCommand = defineJsonCommand({
674
698
  // Emit a structured event rather than a proposal so the review-needed
675
699
  // signal doesn't risk an accidental asset overwrite if the proposal is
676
700
  // accepted.
677
- if (utilityResult?.crossedReviewThreshold) {
701
+ if (utilityResult &&
702
+ utilityResult.previousUtility >= UTILITY_REVIEW_THRESHOLD &&
703
+ utilityResult.nextUtility < UTILITY_REVIEW_THRESHOLD) {
678
704
  try {
679
705
  appendEvent({
680
706
  eventType: "improve_review_needed",
@@ -729,9 +755,9 @@ export const feedbackCommand = defineJsonCommand({
729
755
  signal,
730
756
  reason: reason?.trim() ?? null,
731
757
  tags: validatedTags,
732
- rankingUpdate: rankingUpdateApplied
758
+ rankingUpdate: countsTowardRanking
733
759
  ? { applied: true }
734
- : { applied: false, reason: rankingUpdateSkippedReason ?? "unknown" },
760
+ : { applied: false, reason: rankingSkippedReason ?? "unknown" },
735
761
  ...(fixProposal
736
762
  ? {
737
763
  fix: {
@@ -3,7 +3,6 @@
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  /** Consolidate's chunk sizing and per-chunk prompt (pure token math and text). */
5
5
  import fs from "node:fs";
6
- import { parseFrontmatter } from "../../../core/asset/frontmatter.js";
7
6
  import { contentHash, stripFrontmatterBody } from "../content-hash.js";
8
7
  /** A conservative chars-per-token estimate, shared by every context-length budget. */
9
8
  export const CHARS_PER_TOKEN = 3;
@@ -24,9 +23,8 @@ export function computeSafeChunkSize(contextLength, bodyTruncation, maxChunkSize
24
23
  /**
25
24
  * One chunk's user prompt. Each memory shows its body only (frontmatter can
26
25
  * outrun the truncation and leave the model judging metadata), marked
27
- * `captureMode: hot` when the user captured it explicitly and `already queued`
28
- * when a pending consolidate proposal carries its body hash — the system
29
- * prompt forbids promoting those.
26
+ * `already queued` when a pending consolidate proposal carries its body hash —
27
+ * the system prompt forbids promoting those.
30
28
  */
31
29
  export function buildChunkPrompt(sourceName, memories, chunkIndex, totalChunks, bodyTruncation, pendingProposalBodyHashes = new Set()) {
32
30
  const lines = [
@@ -42,12 +40,7 @@ export function buildChunkPrompt(sourceName, memories, chunkIndex, totalChunks,
42
40
  catch {
43
41
  body = "(unreadable)";
44
42
  }
45
- const annotations = [];
46
- if (parseFrontmatter(body).data.captureMode === "hot")
47
- annotations.push("captureMode: hot");
48
- if (pendingProposalBodyHashes.has(contentHash(body, "body")))
49
- annotations.push("already queued");
50
- const annotationSuffix = annotations.length > 0 ? ` (${annotations.join("; ")})` : "";
43
+ const annotationSuffix = pendingProposalBodyHashes.has(contentHash(body, "body")) ? " (already queued)" : "";
51
44
  lines.push(`[${i + 1}] memories/${m.name}${annotationSuffix}`, `Description: ${m.description || "(none)"}`, `Tags: ${m.tags.length > 0 ? m.tags.join(", ") : "(none)"}`, "---", stripFrontmatterBody(body).slice(0, bodyTruncation), "");
52
45
  });
53
46
  return lines.join("\n");
@@ -42,10 +42,9 @@ import { assertRunnerCredentials } from "../../../integrations/agent/runner-disp
42
42
  import { runGit } from "../../../sources/providers/git-install.js";
43
43
  import { closeDatabase, openExistingDatabase, openReadonlyExistingDatabase, } from "../../../storage/repositories/index-connection.js";
44
44
  import { getAllEntries, getEntryById } from "../../../storage/repositories/index-entries-repository.js";
45
- import { getNeighborsByEntryId } from "../../../storage/repositories/index-vec-repository.js";
46
- import { isRetireProposal, PAIR_PASS_GATE, } from "../../proposal/proposal-types.js";
45
+ import { getNeighborsByEntryId, hasStoredEmbedding } from "../../../storage/repositories/index-vec-repository.js";
46
+ import { EXPIRED_GATE_REASON, isRetireProposal, PAIR_PASS_GATE, STALE_TARGET_GATE_REASON, } from "../../proposal/proposal-types.js";
47
47
  import { createRetireProposal, listProposalsReadOnly, proposalContentHash, recordGateDecision, } from "../../proposal/repository.js";
48
- import { isHotCapturedMemory } from "../consolidate.js";
49
48
  import { contentHash, stripFrontmatterBody } from "../content-hash.js";
50
49
  import { loadLedgerSnapshot, PAIR_PASS_LEDGER_SOURCE, recordLedgerAttempt, stripBundle } from "../ledger.js";
51
50
  import { isInRetrievalScope, loadRetrievalScope } from "../retrieval-scope.js";
@@ -313,6 +312,10 @@ export function loadRejectedPairKeys(stashDir, proposalsCtx) {
313
312
  for (const p of listProposalsReadOnly(stashDir, { status, includeArchive: true }, proposalsCtx)) {
314
313
  if (!isRetireProposal(p) || !p.retirement)
315
314
  continue;
315
+ // Expiry and a stale target are procedural, not a person saying no: the pair may be judged again.
316
+ const autoReason = p.gateDecision?.outcome === "auto-rejected" ? p.gateDecision.reason : undefined;
317
+ if (autoReason === EXPIRED_GATE_REASON || autoReason === STALE_TARGET_GATE_REASON)
318
+ continue;
316
319
  keys.add(rejectedPairKey(p.retirement.retiredRef, p.retirement.successorRef, p.retirement.retiredContentHash, p.retirement.successorContentHash));
317
320
  }
318
321
  }
@@ -364,12 +367,18 @@ export function selectInitiators(pool, opts, stashDir, gitFirstAdded) {
364
367
  * apart from a fully-judged one). S2: fetches `PAIR_NEIGHBOR_FETCH_K` (20)
365
368
  * raw neighbours and keeps the first `PAIR_NEIGHBOR_K` (5) that clear every
366
369
  * filter, so a few self/twin/bundle/tier misses in the raw top-5 no longer
367
- * starve an initiator down to zero real candidates.
370
+ * starve an initiator down to zero real candidates. An initiator with no
371
+ * stored vector (embeddings off, or not embedded yet) cannot be queried at
372
+ * all; its ref is added to `unvectored` so the caller does not settle it.
368
373
  */
369
- export function selectCandidates(db, initiators, bundleId) {
374
+ export function selectCandidates(db, initiators, bundleId, unvectored) {
370
375
  const candidates = [];
371
376
  const seenPairs = new Set();
372
377
  for (const initiator of initiators) {
378
+ if (!hasStoredEmbedding(db, initiator.entryId)) {
379
+ unvectored?.add(initiator.ref);
380
+ continue;
381
+ }
373
382
  // S1: a changed-content or new-material initiator judges at T_PAIR; the
374
383
  // rest of the backlog (no row, not recently git-added) needs the higher
375
384
  // BACKFILL_FLOOR.
@@ -597,9 +606,6 @@ async function judgeOne(ctx, candidate) {
597
606
  [retired, successor] = [successor, retired];
598
607
  }
599
608
  // Guards (plan §5.2 step 4 / brief §A "Guards").
600
- if (retired.asset.type === "memory" && isHotCapturedMemory(retired.asset.filePath)) {
601
- return { failed: false }; // never propose retiring a captureMode: hot memory — leave the pair alone
602
- }
603
609
  if (retired.asset.type === "memory" && retired.asset.name.endsWith(DERIVED_SUFFIX)) {
604
610
  // S3: derive the parent path from the FULL file path, not from name +
605
611
  // dirname — for a subfolder memory (e.g. memories/sub/foo.derived) the
@@ -736,6 +742,7 @@ seams = {}) {
736
742
  let candidates;
737
743
  let gitFirstAdded;
738
744
  let db;
745
+ const unvectored = new Set();
739
746
  try {
740
747
  db = opts.dryRun ? openReadonlyExistingDatabase(undefined, { isolatedSnapshot: true }) : openExistingDatabase();
741
748
  if (!db)
@@ -743,7 +750,7 @@ seams = {}) {
743
750
  gitFirstAdded = loadGitFirstAddedMap(stashDir);
744
751
  const pool = loadPairPassPool(db, bundleId);
745
752
  initiators = selectInitiators(pool, opts, stashDir, gitFirstAdded).initiators;
746
- candidates = selectCandidates(db, initiators, bundleId);
753
+ candidates = selectCandidates(db, initiators, bundleId, unvectored);
747
754
  }
748
755
  catch (error) {
749
756
  warnings.push(`Pair pass: index unavailable — skipped (${error instanceof Error ? error.message : String(error)}).`);
@@ -754,6 +761,9 @@ seams = {}) {
754
761
  closeDatabase(db);
755
762
  }
756
763
  const initiatorsBacklog = initiators.filter((i) => i.backlog).length;
764
+ if (unvectored.size > 0) {
765
+ warnings.push(`Pair pass: ${unvectored.size} asset(s) have no stored embedding, so their neighbours cannot be found and they were not judged. The pair pass needs stored embeddings (set semanticSearchMode to "auto" in the config, then run akm index).`);
766
+ }
757
767
  // Never judge a pair when either side already has a pending retire
758
768
  // proposal, as the retired ref OR its successor (B2 widens this from the
759
769
  // retired ref alone): an asset spoken for by one pending decision cannot
@@ -867,6 +877,9 @@ seams = {}) {
867
877
  const admittedRefs = new Set(judgeable.map((c) => c.initiator.ref));
868
878
  const ledgerInputs = initiators
869
879
  .filter((i) => {
880
+ // No stored vector: the neighbour query never ran, so nothing was judged. Leave it unsettled.
881
+ if (unvectored.has(i.ref))
882
+ return false;
870
883
  const hasCandidates = byInitiator.has(i.ref);
871
884
  if (!hasCandidates)
872
885
  return true;
@@ -43,7 +43,7 @@ import { getBodyEmbeddings, upsertBodyEmbeddings } from "../../storage/repositor
43
43
  import { closeDatabase, openExistingDatabase, openReadonlyExistingDatabase, } from "../../storage/repositories/index-connection.js";
44
44
  import { getAllEntries } from "../../storage/repositories/index-entries-repository.js";
45
45
  import { listProposals, listProposalsReadOnly, proposalContent } from "../proposal/repository.js";
46
- import { hasHotCaptureMode, hasSupersededStatus, validateProposalFrontmatter, } from "../proposal/validators/proposal-quality-validators.js";
46
+ import { hasSupersededStatus, validateProposalFrontmatter } from "../proposal/validators/proposal-quality-validators.js";
47
47
  import { buildChunkPrompt, computeSafeChunkSize, DEFAULT_CONTEXT_LENGTH_TOKENS } from "./consolidate/chunking.js";
48
48
  import { openKnowledgeCoverage } from "./consolidate/coverage.js";
49
49
  import { runConsolidatePairPass } from "./consolidate/pair-pass.js";
@@ -75,21 +75,6 @@ export function mergePlans(chunks) {
75
75
  export function isConsolidationEligibleMemoryName(name) {
76
76
  return !name.endsWith(".derived");
77
77
  }
78
- /**
79
- * A `captureMode: hot` memory (written deliberately with `akm remember`). A
80
- * missing file is not hot; an unreadable one is treated as hot — the check is
81
- * a protection and must not fail open.
82
- */
83
- export function isHotCapturedMemory(filePath) {
84
- if (!fs.existsSync(filePath))
85
- return false;
86
- try {
87
- return hasHotCaptureMode(parseFrontmatter(fs.readFileSync(filePath, "utf8")).data);
88
- }
89
- catch {
90
- return true;
91
- }
92
- }
93
78
  /**
94
79
  * Structured-output schema for a plan. Promote-only: merge/delete/contradict
95
80
  * were removed in 0.9.17-alpha.1 (`e82eec811`) after running in production —
@@ -531,9 +516,8 @@ const ABORT_MIN_CHUNKS = 4;
531
516
  const ABORT_FAILURE_RATE = 0.5;
532
517
  /**
533
518
  * The chunk loop: stop cleanly on the budget signal, abort once ≥50% of at
534
- * least 4 chunks failed (the model is likely down), skip an all-hot chunk
535
- * without a call (the only thing the model could do with it is refused), and
536
- * count every memory into exactly one accounting bucket.
519
+ * least 4 chunks failed (the model is likely down), and count every memory
520
+ * into exactly one accounting bucket.
537
521
  */
538
522
  async function judgeConsolidationChunks(args) {
539
523
  const { chunks, opts, config, warnings, acc } = args;
@@ -571,15 +555,6 @@ async function judgeConsolidationChunks(args) {
571
555
  }
572
556
  }
573
557
  const chunk = chunks[chunkIdx];
574
- if (chunk.length > 0 && chunk.every((m) => isHotCapturedMemory(m.filePath))) {
575
- for (const m of chunk) {
576
- acc.judgedNoActionRefs.add(memRef(m));
577
- acc.judgedRefs.add(memRef(m));
578
- }
579
- acc.judgedNoAction += chunk.length;
580
- warn(`[consolidate] chunk ${chunkIdx + 1}/${chunks.length}: all ${chunk.length} memories are captureMode: hot — skipping LLM (judged no-action).`);
581
- continue;
582
- }
583
558
  warn(`[consolidate] chunk ${chunkIdx + 1}/${chunks.length} (${chunk.length} memories) …`);
584
559
  processed++;
585
560
  if (!llmRunner) {
@@ -667,8 +642,7 @@ async function planConsolidation(opts, config, stashDir, memories, warnings, sta
667
642
  }
668
643
  }
669
644
  const slice = (list) => Array.from({ length: Math.ceil(list.length / chunkSize) }, (_, i) => list.slice(i * chunkSize, (i + 1) * chunkSize));
670
- const willDispatch = slice(budgeted).some((chunk) => chunk.length > 0 && !chunk.every((memory) => isHotCapturedMemory(memory.filePath)));
671
- if (llmRunner && willDispatch)
645
+ if (llmRunner && budgeted.length > 0)
672
646
  assertRunnerCredentials(llmRunner);
673
647
  const { ordered, embedTelemetry } = await clusterMemoriesBySimilarity(budgeted, config, stateDb, opts.signal);
674
648
  const chunks = slice(ordered);