akm-cli 0.9.16 → 0.9.17-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.
Files changed (50) hide show
  1. package/CHANGELOG.md +504 -0
  2. package/dist/assets/prompts/consolidate-system.md +4 -11
  3. package/dist/assets/prompts/graph-extract-user-prompt.md +5 -5
  4. package/dist/commands/health/accept-rate.js +6 -0
  5. package/dist/commands/health/checks.js +54 -0
  6. package/dist/commands/health/improve-metrics.js +1 -5
  7. package/dist/commands/health/report-view-model.js +0 -1
  8. package/dist/commands/health.js +10 -0
  9. package/dist/commands/improve/consolidate/chunking.js +19 -35
  10. package/dist/commands/improve/consolidate/merge.js +6 -9
  11. package/dist/commands/improve/consolidate.js +104 -91
  12. package/dist/commands/improve/distill/promote-memory.js +40 -2
  13. package/dist/commands/improve/distill/quality-gate.js +186 -23
  14. package/dist/commands/improve/distill.js +42 -8
  15. package/dist/commands/improve/eligibility.js +13 -3
  16. package/dist/commands/improve/improve-cli.js +32 -9
  17. package/dist/commands/improve/improve-strategies.js +23 -1
  18. package/dist/commands/improve/improve.js +121 -84
  19. package/dist/commands/improve/loop-stages.js +241 -108
  20. package/dist/commands/improve/preparation.js +50 -17
  21. package/dist/commands/improve/reflect.js +16 -5
  22. package/dist/commands/improve/shared.js +0 -10
  23. package/dist/commands/proposal/drain.js +79 -10
  24. package/dist/commands/proposal/proposal-types.js +21 -0
  25. package/dist/commands/proposal/repository.js +108 -29
  26. package/dist/commands/tasks/tasks.js +19 -2
  27. package/dist/core/asset/frontmatter.js +106 -1
  28. package/dist/core/config/config.js +5 -2
  29. package/dist/core/config/retired-experimental-keys-shim.js +62 -0
  30. package/dist/core/config/schema/improve-processes.js +29 -2
  31. package/dist/core/improve-result.js +9 -0
  32. package/dist/core/paths.js +7 -0
  33. package/dist/core/write-source.js +10 -2
  34. package/dist/indexer/ensure-index.js +52 -7
  35. package/dist/indexer/graph/graph-extraction.js +82 -8
  36. package/dist/indexer/passes/memory-inference.js +16 -1
  37. package/dist/llm/client.js +16 -2
  38. package/dist/llm/graph-extract.js +162 -18
  39. package/dist/scripts/akm-migrate-node.js +97 -36
  40. package/dist/scripts/akm-migrate.js +97 -36
  41. package/dist/storage/repositories/index-entries-repository.js +43 -0
  42. package/dist/storage/repositories/proposals-repository.js +4 -1
  43. package/dist/storage/state-db-integrity.js +123 -0
  44. package/dist/workflows/program/schema.js +1 -0
  45. package/docs/reference/cli.md +17 -7
  46. package/docs/reference/data-and-telemetry.md +1 -0
  47. package/package.json +1 -1
  48. package/schemas/akm-config.json +44 -0
  49. package/schemas/akm-workflow.json +1 -0
  50. package/dist/commands/improve/eval-cases.js +0 -52
package/CHANGELOG.md CHANGED
@@ -6,6 +6,510 @@ 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.17-alpha.2] - 2026-09-24
10
+
11
+ ### Fixed
12
+
13
+ - **A config carrying the retired `experimental.workflowEngine` key no longer
14
+ fails to load.** `ExperimentalConfigSchema` moved from `.passthrough()` to
15
+ `.strict()` in 0.9.16 (`cc6152e02`), after `workflowEngine` had already been
16
+ removed from it in `e0655d13c`; a real config a 0.9.15 install wrote (whose
17
+ passthrough still accepted the key) then failed every command with
18
+ `Invalid config: experimental: Unrecognized key(s) in object: 'workflowEngine'`.
19
+ The config loader now strips known-retired `experimental.*` keys in memory
20
+ before validation, warning once and naming `akm migrate apply`; a genuinely
21
+ unknown/misspelled key (e.g. `improveAutonomyy`) still fails closed.
22
+ `akm migrate apply` removes the retired key from `config.json` on disk
23
+ (with the usual backup), and `--dry-run` reports the pending removal.
24
+ - **Unscoped `akm task sync` no longer crashes when an enabled website or npm
25
+ bundle is configured.** The sync plan loop resolved every active source
26
+ through the write-target resolver, which rejects any kind other than
27
+ `filesystem`/`git` outright (writes, and therefore scheduler state, are
28
+ undefined for those kinds — the same rejection `akm task enable` already
29
+ hit). Unscoped sync now skips non-filesystem/git bundles when building
30
+ install operations — they never carried schedulable tasks — while
31
+ inactive-bundle removal/revocation still sees them. A scoped
32
+ `akm task sync --bundle <website-or-npm-bundle>` now fails with a clear
33
+ usage error instead of the write-target `ConfigError`.
34
+
35
+ ## [0.9.17-alpha.1] - 2026-09-24
36
+
37
+ ### Added
38
+
39
+ - **`akm improve --require-engines` now records its reachability probe on the
40
+ run result (R17).** `assertRequiredEnginesReachable` only ever reported a
41
+ failure (abort, exit 78); a probe that passed — including a slow or
42
+ flapping gateway that still answered in time — left no trace once the run
43
+ proceeded. It now returns one outcome per probed target (`process`,
44
+ `engine`, `endpoint`, `reachable`, `latencyMs`), threaded through a new
45
+ `AkmImproveOptions.engineProbe` and copied onto the persisted result as
46
+ `AkmImproveResult.engineProbe`. Omitted entirely when `--require-engines`
47
+ was not passed; a result persisted without it (every run before this
48
+ change) still decodes. `--require-engines --dry-run` results carry it too.
49
+ - **Reflect had no way to exclude raw wiki-ingest snapshots, which are the
50
+ longest generations in the ledger (89.5s/161.8s observed).** `wikis/articles/raw/*.md`
51
+ website snapshots index as `knowledge/wikis/articles/raw/<slug>`, and
52
+ reflect's `allowedTypes` filter is type-only, so it can't exclude a subset
53
+ of the `knowledge` type. `processes.reflect` now accepts an optional
54
+ `excludeRefPrefixes: string[]` — conceptId prefixes, matched after
55
+ stripping an optional `bundle//` from both the ref and each prefix.
56
+ `shouldSkipRef` skips a matching ref with reason `exclude-filter`, for
57
+ reflect only (distill and consolidate are memory-only and reject the key).
58
+ A trailing `/` on a prefix is ignored, so
59
+ `"knowledge/wikis/articles/raw/"` excludes the same refs as
60
+ `"knowledge/wikis/articles/raw"`.
61
+
62
+ - **`akm health` now checks state.db's own SQLite integrity.** A new hard
63
+ `state-db-integrity` check runs a read-only `PRAGMA quick_check` against
64
+ `state.db` and fails, naming the returned diagnostic lines and the repair
65
+ steps (back up, dump/restore via `sqlite3`, verify, swap in), when it
66
+ reports anything other than `ok`. The same check reports state.db's
67
+ freelist ratio (the fraction of pages `VACUUM` could reclaim) and warns
68
+ above 50%. Previously nothing in `akm health` looked past a successful
69
+ append/read round trip, which stays true on a database that is corrupt at
70
+ the SQLite level.
71
+ - **The retention purge (`akm improve`) now VACUUMs state.db when more than
72
+ half its pages are free**, immediately after the events/improve_runs/
73
+ cycle-metrics purge, recording a `state_db_vacuumed` event with pages
74
+ before/after. Opportunistic: a locked/busy database is skipped, not
75
+ raised, so it never fails the purge pass it follows.
76
+
77
+ ### Changed
78
+
79
+ - **The orphan-state GC pass no longer probes index.db once per pending
80
+ row.** `runOrphanStateGcPass` used to call `getEntryByRef` (up to two
81
+ statements each, via its bare-ref fallback) for every pending
82
+ `asset_salience` / `asset_outcome` row — 2,101 pending rows cost 83–100s
83
+ per run. It now builds one snapshot of every live `item_ref` in index.db up
84
+ front and matches every pending row against it in memory: O(1) index.db
85
+ queries per run instead of one probe per row, with the same live/orphan
86
+ resolution (including the bundle-qualified-exact and bare-conceptId-suffix
87
+ fallback) as before.
88
+ - **Memory inference no longer forces a full reindex for the file(s) it
89
+ writes.** The post-inference maintenance step used to call the full
90
+ `reindexFn` (42–220s per run, typically for one written derived fact)
91
+ whenever memory inference split a parent. `runMemoryInferencePass` now
92
+ reports the exact paths it wrote or rewrote (`writtenPaths`, sourced from
93
+ the run's write-provenance journal), and the maintenance pass indexes just
94
+ those files with `indexWrittenAssets` instead — closing and reopening the
95
+ shared index.db handle around the call with the same discipline the full
96
+ reindex used (#584). The separate post-consolidation full reindex is
97
+ removed outright rather than re-gated: it used to fire whenever
98
+ `consolidation.processed > 0` (memories the LLM judged), but
99
+ merge/delete/contradict ops are advisory and never auto-applied, and the
100
+ one op that does execute — promote — writes a proposal to state.db, not to
101
+ the stash. Consolidation therefore cannot change a file the index reads,
102
+ so the reindex had no precondition it could ever satisfy.
103
+ - **The improve loop's reflect dispatch now checks the proposal
104
+ fingerprint/rejection-backoff guard *before* calling reflect, not just
105
+ after.** `fingerprint_match` and `rejection_backoff` were evaluated only
106
+ inside `createProposal`, which runs after reflect's full generation and
107
+ quality-judge call — so a ref already guaranteed to be skipped still paid
108
+ the LLM cost (measured: 2–16% of reflect LLM seconds spent on refs the
109
+ guard then discarded). The guard's fingerprint is an input fingerprint
110
+ (target ref, source, before-hash, model id), computable before dispatch, so
111
+ `checkProposalGuard` (`src/commands/proposal/repository.ts`) exposes the
112
+ identical check `createProposal` runs post-generation — the two share one
113
+ implementation and can never disagree. `runLoopReflectPass`
114
+ (`src/commands/improve/loop-stages.ts`) now calls it first; a hit skips
115
+ `reflectFn` entirely and lands in the existing `reflect-cooldown` bucket
116
+ with the same `reflect_invoked` event the signal-delta cursor
117
+ (`buildLatestProposalTsMap`) reads, so cursor advancement and run-result
118
+ classification are unchanged. `createProposal`'s post-generation check
119
+ remains the authoritative gate.
120
+ - **Consolidate's plan schema and prompt are promote-only.** The apply loop
121
+ only ever executed `promote` — `merge`/`delete`/`contradict` were advisory
122
+ by design and never applied — but the schema still asked for all four ops
123
+ plus a free-text `warnings` array, and completion tokens rose from 7–8k to
124
+ 21–30k per run after the 35B-A3B model switch with no change in
125
+ promotions. `CONSOLIDATE_PLAN_JSON_SCHEMA` and `consolidate-system.md` now
126
+ request only `promote` (with `reason` capped at 200 chars), and `isValidOp`
127
+ rejects any other op shape — e.g. from a model that ignores the schema —
128
+ with the existing "skipping invalid operation" warning instead of treating
129
+ it as an actionable plan entry. `ConsolidateResult.merged` / `deleted` /
130
+ `contradicted` and the `planned` op breakdown are unchanged in shape and
131
+ stay zero.
132
+ - **`improve-maintenance-passes.test.ts` moved under `tests/integration/`.**
133
+ The suite opens a real `state.db` via `openStateDatabase`, which AGENTS.md's
134
+ ORG-03..06 rule places under `tests/integration/`, not `tests/`; no content
135
+ change. Also corrected
136
+ `docs/architecture/specs/improve-collapse-churn-detector-design.md` §2.5,
137
+ which described the post-loop collapse-detector gate as `consolidationRan
138
+ OR recombination.processed > 0` — no `recombination` value is plumbed into
139
+ `runImprovePostLoopStage` and no recombine pass exists in the codebase, so
140
+ the spec now matches the shipped `consolidationRan`-only gate and notes
141
+ that the recombine-triggered pass is not implemented.
142
+ - **Graph-extraction relations are now compact `[from, type, to]` triples
143
+ instead of `{"from","to","type"}` objects, and the batch graph-extraction
144
+ call now sends a `responseSchema`.** The object-keyed form cost 10+ tokens
145
+ per relation for no signal, and completion tokens cost far more than
146
+ prompt tokens; a compact triple form measured −51% / −18% completion
147
+ tokens on two chunks. `graph-extract.ts`'s single-asset and batch prompts
148
+ and JSON schemas now ask for `["from", "type", "to"]` (`type` may be `""`);
149
+ `parseGraphExtraction` accepts both the triple form and the legacy object
150
+ form (a relation-level `confidence` is still read from a legacy object,
151
+ though the schema no longer offers it — the prompt never asked for one).
152
+ Separately, production runs graph extraction batched
153
+ (`processes.graphExtraction.batchSize`), and `extractGraphFromBodies` sent
154
+ no `responseSchema` at all, so the R12b output-bounding schema only ever
155
+ reached the single-asset path. The batch call now sends the same
156
+ `maxItems`-bounded schema (scoped to the batch's asset count) through the
157
+ same `supportsJsonSchema`-gated `responseSchema` field the single-asset
158
+ call uses. `GRAPH_EXTRACT_PROMPT_VERSION` bumps `v2` → `v3`, so every file
159
+ re-extracts once on the next graph pass — entity semantics, caps, chunking
160
+ and batch sizing are unchanged.
161
+
162
+ ### Removed
163
+
164
+ - **The write-only distill/proposal eval-cases path.** `writeEvalCase`
165
+ (`src/commands/improve/eval-cases.ts`) wrote a Markdown file per rejection
166
+ under `$STATE/improve/eval-cases/<stash>/` that nothing ever read back, and
167
+ `countEvalCases` reported a cumulative on-disk file count as if it were a
168
+ per-run number (surfaced as `evalCasesWritten` on the improve result and in
169
+ `akm health`'s improve metrics). A rejected proposal row (see above) now
170
+ carries the same information through a path something actually reads.
171
+ Deleted `eval-cases.ts` and its two `loop-stages.ts` call sites, the
172
+ `evalCasesWritten` field from `AkmImproveResult` and every health-metrics
173
+ reader/aggregator, and the `improve_completed` event's `evalCasesWritten`
174
+ field. `decodeImproveResult` still accepts (and ignores) `evalCasesWritten`
175
+ on an envelope an older release wrote, and existing eval-case files on disk
176
+ are untouched — `getEvalCasesDir` (`core/paths.ts`) stays, since
177
+ `scripts/akm-migrate/migrate/writer-relocation.ts` still uses it to
178
+ relocate them from the legacy `$STASH/.akm/eval-cases/` path.
179
+
180
+ ### Fixed
181
+
182
+ - **The lesson quality judge's ACTIONABILITY criterion carried no signal, and
183
+ the judge's request/parser let a differently-spelled or extra key change
184
+ the verdict (R16).** Splinter measured ACTIONABILITY at AUC 0.46 against
185
+ accept/reject outcomes — no better than chance — and averaging it into the
186
+ score pulled every verdict toward its 3.0 mode, i.e. the review band.
187
+ `buildJudgePrompt` no longer asks for it;
188
+ `LESSON_JUDGE_CRITERIA_KEYS` is now `novelty`/`nonRedundancy` only.
189
+ Separately, `runQualityJudge`'s request sent no `responseSchema` while the
190
+ prompt text spelled criteria as NON-REDUNDANCY / FEEDBACK ALIGNMENT, so a
191
+ model that echoed a differently-cased or -spelled key turned the verdict
192
+ into a parse failure routed to review; and `parseJudgeResponse` averaged
193
+ over every key present in `scores`, so an unexpected extra key changed the
194
+ score. `runQualityJudge` now sends a strict `responseSchema` — built from
195
+ the judge's own expected criteria keys, `additionalProperties: false` at
196
+ both levels — through the same `supportsJsonSchema`-gated
197
+ `request.responseSchema` path `src/llm/graph-extract.ts` uses, a no-op for
198
+ providers that don't opt in; and `parseJudgeResponse` now reads, validates,
199
+ and averages only the expected keys, silently ignoring any other key
200
+ instead of averaging or validating it. A missing expected key is still a
201
+ parse failure, unchanged.
202
+ - **The reflect quality-gate's "no judge configured" warning named a config
203
+ key nothing reads.** It told users to set
204
+ `improve.strategies.<name>.processes.reflect.qualityGate.engine`, but
205
+ `qualityGate` is `{ enabled }` passthrough — `resolveReflectQualityJudgeRunner`
206
+ always uses the generation runner when it is an LLM, or falls back to
207
+ `defaults.llmEngine` via `resolveImproveLlmExecution` with no profile/process
208
+ layer, so that key was never read. The warning now names only
209
+ `defaults.llmEngine`.
210
+ - **The distill/reflect LLM-as-judge quality gate inherited the generation
211
+ runner's temperature, and its averaged score hid which criterion actually
212
+ failed.** `runQualityJudge`'s request only pinned `enableThinking: false`,
213
+ so the judge ran at whatever temperature generation used — measured at 0.3,
214
+ the verdict flipped on 10/16 identical inputs, vs. 0/16 at temperature 0.
215
+ The request now also pins `temperature: 0`, for both the distill and
216
+ reflect judges that share this function, independent of the runner's
217
+ configured temperature. Separately, both judge prompts asked for one
218
+ averaged float, so a criterion carrying no signal was invisible in
219
+ production. They now ask for per-criterion integer scores
220
+ (`buildJudgePrompt`: novelty/actionability/nonRedundancy;
221
+ `buildReflectJudgePrompt`: feedbackAlignment/preservation/quality), averaged
222
+ in code to the same `score` the unchanged 3.5/2.5 thresholds gate on. The
223
+ parser accepts this new `{"scores": {...}, "reason"}` shape and still
224
+ accepts the old `{"score": <float>, "reason"}` shape a model may return;
225
+ each criterion (or the bare score) must be a finite number in 1..5 or the
226
+ response routes to review exactly as a parse failure does today. The
227
+ per-criterion scores, when present, are now carried through
228
+ `QualityJudgeResult.criteria` into the `distill_invoked` event metadata and
229
+ rejection-envelope frontmatter `writeQualityRejection` writes, and into
230
+ reflect's `reflect_completed` rejection event as `qualityCriteria`.
231
+ - **The judge parser accepted a partial `scores` object and auto-passed it.**
232
+ `parseJudgeResponse` validated only that whatever criterion keys arrived
233
+ held finite 1-5 values, then averaged over those keys alone — so a
234
+ truncated judge response like `{"scores": {"novelty": 5}, "reason": "…"}`
235
+ parsed to `score: 5.0` and `pass: true`, promoting content the judge never
236
+ finished evaluating on its other criteria. `runQualityJudge` now passes the
237
+ criterion key set its prompt asked for (`buildJudgePrompt`:
238
+ novelty/actionability/nonRedundancy; `buildReflectJudgePrompt`:
239
+ feedbackAlignment/preservation/quality) down to `parseJudgeResponse`, which
240
+ returns a parse failure — routed to review, exactly as a malformed response
241
+ is today — when any expected key is missing from `scores`.
242
+ - **The reflect pre-generation proposal-guard skip (R9) emitted `reflect_invoked` with no paired `reflect_completed`.** `runLoopReflectPass`'s guard-skip branch in `loop-stages.ts` appended a synthetic `reflect_invoked` event to advance the signal-delta cursor, but never called `reflectFn`, so `reflect.ts`'s own `reflect_completed` emission never ran either — a new, permanent source of unpaired `reflect_invoked` rows for every fingerprint/backoff hit, violating the invoke/complete pairing invariant `buildReflectEventEmitters` documents. The branch now also appends a matching `reflect_completed` (`ok:false`, `reason:"cooldown"`, `subreason:"pre_generation_guard"`), mirroring `emitFailed`'s shape.
243
+ - **R9's pre-generation proposal guard covered reflect only — distill paid for a full generation + judge call before the same fingerprint/backoff guard could reject it.** `runLoopDistillPass` had no equivalent of `runLoopReflectPass`'s pre-check, even though `createProposal`'s post-generation guard (and every rejected row R10 now mints under `source: "distill"`) applies to distill just as much. `runLoopDistillPass` now calls `checkProposalGuard` against the derived lesson/knowledge ref (distill's real `createProposal` call never targets the input ref) before dispatching `distillFn`; a hit routes to the pass's existing `distill-skipped` bucket and emits `distill_invoked` with a `skipped` outcome so `buildLatestProposalTsMap`'s signal cursor still advances.
244
+ - **The distill pre-generation proposal guard could suppress a legitimate dispatch by checking a ref distill would never target.** For a memory input, distill's real `createProposal` call targets one of two refs decided at dispatch time inside `planMemoryKnowledgePromotion` — the derived knowledge ref when the deterministic promotion heuristic fires, the derived lesson ref otherwise — but `runLoopDistillPass`'s pre-check checked both candidate refs and skipped on the FIRST guard hit, so a stale fingerprint/backoff hit on the ref distill would NOT have targeted silently suppressed dispatch until that ref's fingerprint happened to change. The pre-check now resolves the SAME target `planMemoryKnowledgePromotion` would via `wouldPromoteMemoryToKnowledge` (`distill/promote-memory.ts`) — a thin wrapper that delegates to `planMemoryKnowledgePromotion` itself so the classification can never drift from the real dispatch decision, with no LLM call — and checks only that ref; content and the classification's `durableInputRef` are read via `planned.ref` alone, matching `akmDistill`'s real dispatch, while `planned.itemRef ?? planned.ref` feeds only the feedback-events query.
245
+ - **The `akm improve` triage pre-pass drain's judgment LLM calls were unattributed in the usage report.** `runTriagePrePass`'s `drainProposalsFn` call dispatched judgment calls with no `withLlmStage` wrapper, unlike the standalone `akm proposal drain` CLI path, so they landed in `byProcessEngineModel` as unattributed (5 calls, 24s per run) instead of under a `triage` stage. The pre-pass drain is now wrapped in `withLlmStage("triage", …, { engine, process: "triage.judgment" })`, mirroring the CLI path.
246
+ - **The batch graph-extraction provider-storm guard only recognized one error
247
+ code.** After a failed batch call, `extractGraphFromBodies` skipped the
248
+ per-asset fallback retry only for `LlmCallError`s coded `provider_error` —
249
+ but a dead endpoint more often raises `network_error` (a dropped
250
+ connection) or `provider_html_error` (a provider serving an HTML error
251
+ page), both of which still paid the full per-asset fallback storm the
252
+ guard exists to prevent. The predicate is now `isTransportFailure`
253
+ (`src/llm/client.ts`), shared with `chatCompletion`'s retry classifier so
254
+ the two cannot drift apart, and covers `provider_error`, `network_error`,
255
+ and `provider_html_error`.
256
+ - **`akm health`'s `state-db-integrity` check no longer crashes when the freelist/page-count read fails.** `getStateDbFreelistInfo` had a `finally` but no `catch` around its read-only open and pragma reads, unlike its sibling `runStateDbQuickCheck` — a throw there (e.g. an unopenable state.db) escaped `akm health` as an unclassified exit 70 on exactly the damaged database the check exists to report. It now returns a zeroed `StateDbFreelistInfo` with an `error` field, and the check renders that as a failed check instead of throwing.
257
+ - **The post-purge VACUUM's `state_db_vacuumed` event now honors the caller's `EventsContext`.** `vacuumStateDbIfReclaimable` appended its event with a direct `insertEvent` call, bypassing `EventsContext.readOnly` and the injectable clock its sibling purge events (`events_purged`, `improve_runs_purged`, `improve_cycle_metrics_purged`) use in the same `runRetentionPurgePass` callback. It now appends the event via `appendEvent` with the caller's `EventsContext` plumbed through.
258
+ - **Consolidate's per-chunk prompt excerpt truncated the raw file (frontmatter
259
+ + body) instead of the body.** `buildChunkPrompt` sliced `body.slice(0,
260
+ bodyTruncation)` off the unstripped file; a memory whose frontmatter alone
261
+ exceeded the excerpt length was judged on metadata only and never showed
262
+ its own body text. The excerpt now truncates `stripFrontmatterBody(body)`;
263
+ hot/queued detection is unchanged and still reads the raw body.
264
+ - **Consolidate's chunk prompt carried an unused ~14k-char standards block and
265
+ a header the model sometimes echoed back as a bogus `ref`.** Every chunk
266
+ prompt resolved and injected a "Standards to follow" section
267
+ (`resolveStandardsContext("memories/_consolidated", ...)`), but the chunk
268
+ output is a promote-only op list that never reads it. Separately, the
269
+ chunk header (`Chunk N of M, memories <first>–<last>:`) named the chunk's
270
+ boundary memories with an en dash between two `memories/<name>` refs; on
271
+ 2026-09-24 the judge model returned promote ops whose `ref` was exactly
272
+ that `memories/<first>–memories/<last>` range, naming a memory that does
273
+ not exist and losing the promotion. `buildChunkPrompt` no longer takes a
274
+ `standardsContext` and the header is now
275
+ `Chunk N of M (<count> memories):` — no refs in it.
276
+ - **Consolidate re-judged memories that were already promoted verbatim into
277
+ `knowledge/`.** That duplication was previously discovered only after the
278
+ LLM (`shouldSkipPromotionBodyDuplicate`), so a pool where the large
279
+ majority of memories were already-promoted duplicates still paid the full
280
+ chunk/LLM cost on all of them before being skipped.
281
+ `inspectConsolidationPool` now drops those memories before any chunking or
282
+ LLM work, sharing one `loadExistingKnowledgeBodyHashes` call and the same
283
+ `cacheHash` domain with the post-LLM check so the two cannot disagree. The
284
+ dropped count is reported as `prefilteredAlreadyPromoted` on the
285
+ consolidate result and in a warning line. The pre-filter also now runs
286
+ *before* the `consolidate.limit` cap (previously after), so a run with a
287
+ limit set selects its oldest-modified window from the pre-filtered pool
288
+ instead of re-selecting and re-dropping the same permanently-undeletable
289
+ duplicates every run while fresh memories past the cap went unreached; the
290
+ preview/eligibility path (`preparation.ts`) computes and passes the same
291
+ hash set so the reported candidate pool agrees with what the run will act
292
+ on. A live (non-preview) `akm improve` run reuses that same hash set for
293
+ the actual `akmConsolidate` call instead of recomputing it, so a run still
294
+ walks `knowledge/` only once.
295
+ - **`improve`'s start-of-run index rescan ran after triage dirtied the stash,
296
+ not before it.** Proposal triage promotes accepted proposals straight into
297
+ the flat `knowledge/` root, and the blocking `ensureIndex` call that is
298
+ supposed to give the run a current index ran only afterward (inside
299
+ `collectEligibleRefs`'s setup), so every triage promotion guaranteed the
300
+ very full rescan it should have preceded — up to ~27 minutes, holding the
301
+ index lock against co-scheduled writers. `ensureIndex` now runs before the
302
+ triage pre-pass, and triage's own writes are indexed incrementally
303
+ (`indexWrittenAssets`) so `collectEligibleRefs` still sees them without a
304
+ second full walk. Because `indexWrittenAssets` upserts a file's
305
+ `content_hash` without bumping `builtAt`, index staleness detection
306
+ (`ensure-index.ts`) is now per-file: a file newer than the last build is
307
+ only treated as stale when its current content actually differs from what
308
+ is indexed, so incrementally-reindexed content stops re-triggering the
309
+ same full rescan on every subsequent run. The implicit reindex's timing
310
+ breakdown (walk/llm/embed/finalize), previously discarded, is now logged
311
+ at verbose level and surfaced on the improve result as `ensureIndexDurationMs`.
312
+ - **Distill quality rejections vanished instead of persisting, so backoff and
313
+ Reflexion never saw them and the same ref was re-selected and re-rejected
314
+ on every run** (two refs were rejected 11× and 10×). `writeQualityRejection`
315
+ wrote only a `$STATE`-side file and an event, never a `proposals` row, so
316
+ `rejection_backoff`/`fingerprint_match` (proposal/repository.ts) and the
317
+ Reflexion "previously rejected" context had nothing to find; the distill
318
+ signal-delta cursor (`buildLatestProposalTsMap`) also only advanced for
319
+ `queued`/`skipped`/`validation_failed` outcomes, so a rejected ref stayed
320
+ eligible forever. `writeQualityRejection` now mints a real proposal through
321
+ the same `createProposal`/`archiveProposal` path every other proposal
322
+ source uses: a `quality_rejected` outcome is minted pending then archived
323
+ to `rejected` carrying the judge's reason; a `review_needed` outcome stays
324
+ `pending` in the normal queue, where triage — a human, or the drain's
325
+ judgment tier when one is configured — decides, the same path every other
326
+ pending distill proposal (including quality-gate passes) already takes.
327
+ The cursor now also advances on both outcomes (still excluding
328
+ `llm_failed`, where no real attempt produced anything). A retry for the
329
+ same target, source, and model is skipped by `fingerprint_match` (the
330
+ input fingerprint recorded at mint, retained `archiveRetentionDays`,
331
+ default 90 days); the 30-day `rejection_backoff` window only applies once
332
+ the target's before-hash or the model differs. Because these machine
333
+ rejections are now real `rejected` rows under `source: "distill"`, `akm
334
+ health`'s distill accept rate (`computeAcceptRateBySource`,
335
+ src/commands/health/accept-rate.ts) drops relative to earlier releases and
336
+ no longer measures reviewer acceptance alone. Nothing gates on that
337
+ metric.
338
+ - **`writeQualityRejection` could throw instead of returning a rejection
339
+ result.** Minting the proposal row above runs the mint-time canonical
340
+ validator (`createProposal` → `rejectProposal`,
341
+ `src/commands/proposal/repository.ts`), which throws `UsageError` for
342
+ structurally-invalid content — e.g. a `lessons/` ref whose body lacks
343
+ `description`/`when_to_use`. `writeQualityRejection` is the terminal,
344
+ non-throwing rejection path and none of its callers handled a throw. The
345
+ proposal row is bookkeeping for backoff/Reflexion, never the authoritative
346
+ record of the rejection, so a validator throw now degrades to "no row
347
+ minted" — the envelope file and `distill_invoked` event are still written,
348
+ matching the existing fingerprint/backoff skip behavior.
349
+ - **A `review_needed` quality-gate rejection could be auto-promoted by the
350
+ triage drain's judgment tier with no human ever seeing it.**
351
+ `writeQualityRejection` minted a `review_needed` outcome as an ordinary
352
+ pending proposal under `source: "distill"` (knowledge promotions from
353
+ `promote-memory.ts` take the same path); the `personal-stash` drain policy
354
+ defers `distill` proposals to the judgment tier, which can auto-accept
355
+ under `applyMode: promote` + `experimental.improveAutonomy` — so content
356
+ the quality judge explicitly refused to auto-queue (the 2.5–3.5
357
+ review-needed band) could be promoted without a human in the loop.
358
+ `writeQualityRejection` now stamps a `review_needed` mint with a
359
+ `{ outcome: "deferred", reason: "quality-review", gate: "quality-gate" }`
360
+ gate decision (best-effort: a stamp failure warns and continues, like the
361
+ existing mint/archive tolerance), and `classifyPendingProposals`
362
+ (`proposal/drain.ts`) skips any pending row carrying it — leaving it
363
+ pending and untouched, before the drain's own policy-deferred re-stamp
364
+ loop would otherwise overwrite the stamp.
365
+ - **Consolidate's post-LLM promote-dedup hash double-stripped frontmatter.**
366
+ `shouldSkipPromotionBodyDuplicate`'s `bodyHash` was computed as
367
+ `cacheHash(parseFrontmatter(memoryContent).content.trim())` — the body was
368
+ already frontmatter-stripped before being handed to `cacheHash`, which
369
+ strips it again internally — diverging from the single-strip
370
+ `cacheHash(raw)` domain `loadExistingKnowledgeBodyHashes` and the pre-filter
371
+ use for a source memory body that begins with its own `---` block. The
372
+ check now hashes `cacheHash(memoryContent)` directly, so the two sides of
373
+ the dedup comparison agree.
374
+ - **Consolidate's per-chunk prompt still warned against proposing `delete`
375
+ for `(captureMode: hot)` memories.** The consolidate op schema and system
376
+ prompt dropped `delete` (along with `merge`/`contradict`), leaving
377
+ `buildChunkPrompt`'s top-of-prompt hot-ref block as the only remaining
378
+ mention of `delete` anywhere in the prompt — a retired op name that
379
+ `isValidOp` now rejects if the model echoes it back, wasting tokens on
380
+ "skipping invalid operation" warnings. The block and the `hotRefs`
381
+ collection that fed it are removed; the inline `(captureMode: hot)`
382
+ annotation on each memory line is unchanged.
383
+ - **Graph extraction sent no `json_schema` and no per-asset chunk cap, so a
384
+ long file could pay for dozens of LLM calls whose output was then sliced
385
+ down to the same 32-entity/32-relation limit anyway** (one file spent 21 of
386
+ 27 calls and 12.9k completion tokens this way). The single-asset extraction
387
+ call (`extractGraphFromBody`) now sends a `responseSchema` (entities/
388
+ relations capped at 32 each, `additionalProperties: false` otherwise), via
389
+ the same `supportsJsonSchema`-gated request path memory-infer.ts uses — no
390
+ `maxTokens` is sent; cost is bounded by the schema's `maxItems` caps alone,
391
+ per AGENTS.md's "LLM Defaults" (a hardcoded cap risked silent truncation
392
+ with zero headroom for JSON punctuation or reasoning tokens). A body
393
+ chunked beyond the new
394
+ `processes.graphExtraction.maxChunksPerAsset` (default 8) now stops after
395
+ the first N chunks instead of processing every one; the skipped chunks are
396
+ reported as `truncatedChunks` in the run's graph-extraction telemetry so
397
+ the coverage loss is visible rather than silently absorbed. The `improve`
398
+ loop's dispatch (`loop-stages.ts`) now also forwards a configured
399
+ `maxChunksPerAsset` to the extraction call, mirroring the existing
400
+ `topN`/`batchSize` wiring — without this the config key had no effect in a
401
+ real `akm improve` run and the default of 8 always applied.
402
+ - **The graph-extraction `responseSchema` forbade the `confidence` field the
403
+ parser itself reads.** `additionalProperties: false` on both the root
404
+ object and each relation item made `confidence` impossible on a
405
+ `supportsJsonSchema` provider, even though `parseGraphExtraction` uses
406
+ `rel.confidence` to drop relations below `MIN_RELATION_CONFIDENCE` and
407
+ `item.confidence` to feed the merged extraction confidence — silently
408
+ turning the confidence filter into dead code on exactly the providers the
409
+ schema targets. `confidence: {"type": "number"}` is now allowed at both
410
+ levels; `additionalProperties: false` still forbids anything else.
411
+ - **A pending proposal went stale the moment akm's own bookkeeping touched
412
+ its target, and promote refused it forever (R20).** `resolveProposalTargetInfo`
413
+ captured the target's raw `beforeHash` at mint; the SAME nightly run's
414
+ `writeSalienceToFrontmatter` (distill) and memory inference's
415
+ `inferenceProcessed` stamp then rewrote the target's frontmatter before
416
+ promote ran, so `promoteProposalWithLease`'s guard (`repository.ts` ~L2406)
417
+ and `drain.ts`'s dry-run mirror (`assertProposalTargetFresh`) refused every
418
+ affected proposal with "target changed after proposal was created" — the
419
+ same 11+ reflect proposals, every day, on splinter. `resolveProposalTargetInfo`
420
+ now also captures `beforeHashNormalized` (`core/asset/frontmatter.ts`'s new
421
+ `computeNormalizedContentHash`, over the target with
422
+ `BOOKKEEPING_FRONTMATTER_KEYS` — `salience`/`salienceInputs`/`inferenceProcessed`
423
+ — stripped and the remaining frontmatter canonically re-serialized); the
424
+ promote guard and its dry-run mirror both prefer it over the raw
425
+ `beforeHash` when present, so a bookkeeping-only rewrite no longer stales a
426
+ proposal out while a real content change still refuses. Promotion also now
427
+ carries the live target's bookkeeping keys forward
428
+ (`carryForwardBookkeepingFrontmatter`) when the proposal's own frontmatter
429
+ doesn't set them, so accepting never drops `inferenceProcessed` and forces
430
+ memory inference to reprocess the memory. A legacy proposal minted before
431
+ this field existed keeps its exact original raw-hash check.
432
+ `computeNormalizedContentHash` also normalizes the body boundary the same
433
+ way `assembleAssetFromString` does (leading newlines stripped, exactly one
434
+ trailing newline) before hashing, and treats an empty frontmatter block as
435
+ `{}` instead of falling back to the raw hash — both `writeSalienceToFrontmatter`
436
+ and the memory-inference `assembleAsset` rewrite shift where the body starts,
437
+ which without this normalization still staled a proposal out unless the
438
+ target's frontmatter was already in that exact on-disk shape.
439
+ - **A stale-target promote failure was retried, and refused, identically
440
+ every drain run forever (R20).** The drain already categorized a
441
+ "target changed/was created after proposal" failure as `stale-target`
442
+ (`categorizeDrainFailure`), but left the row pending either way — so the
443
+ same proposals failed the same way on every subsequent `akm proposal
444
+ drain` / triage pass. Both promote-failure sites (`drainProposals`'s
445
+ deterministic loop and `runJudgmentTier`) now auto-reject a stale-target
446
+ failure once, stamping `gateDecision: { outcome: "auto-rejected", reason:
447
+ "stale-target" }` instead of leaving it to retry. This is not a merit
448
+ rejection, so `checkFingerprintAndBackoff`'s rejection-backoff window
449
+ (`repository.ts`) now excludes stale-target rows — the ref stays
450
+ re-proposable against its current content — and the Reflexion
451
+ "previously rejected" context (`reflect.ts`'s `readRejectedProposals`,
452
+ `distill.ts`'s `buildDistillMessages`) and the accept-rate health metric
453
+ (`health/accept-rate.ts`) now exclude stale-target rejections too, so a
454
+ procedural refusal doesn't misrepresent content quality. `--dry-run` now
455
+ predicts the same outcome: a stale-target promote failure it detects is
456
+ reported under `rejected`, matching what a real run does, instead of under
457
+ `failed`.
458
+
459
+ - **Reflect quality-gate rejections were mislabelled `parse_error` and fed
460
+ back into later prompts as learned "avoid" patterns.** When the reflect
461
+ quality judge rejected an otherwise well-parsed proposal, the result
462
+ carried `reason: "parse_error"` — a real parse failure and a judge
463
+ rejection were indistinguishable. The improve loop injects non-excluded
464
+ reflect failures into the next reflect prompt's "Avoid These Patterns"
465
+ block, so a single gate rejection could poison every subsequent candidate
466
+ in the run. Judge rejections now carry a distinct `quality_rejected`
467
+ reason, stay in the `reflect-failed` metrics bucket, and are excluded from
468
+ that avoid-patterns injection like the existing deterministic skips.
469
+ - **Legacy rejected proposals no longer throw before reflect/distill prompt
470
+ dispatch.** `readRejectedProposals` (reflect.ts) and the equivalent mapper
471
+ in distill.ts built their "previously rejected" context via
472
+ `proposalContent(p)`, which throws when a proposal's `changes[0]?.after` is
473
+ undefined — the shape `storedToChanges` deliberately returns for rows
474
+ archived before the `changes` field existed (the large majority of
475
+ real-world rejected-proposal history). The throw happened before the
476
+ signal cursor advanced, so a ref with any such legacy rejection errored on
477
+ every run instead of ever completing. Both call sites now read the preview
478
+ from `payload.content`, which is populated for every row regardless of
479
+ its `changes` shape.
480
+ - **Failed graph extractions are no longer cached as permanent hits.** A
481
+ provider outage upserted thousands of `{"entities":[],"status":"failed"}`
482
+ results into `llm_enrichment_cache` and the persisted graph, and both
483
+ cache-hit paths (the DB lookup and reuse from the previous graph) treated
484
+ them as valid hits forever after — the affected files never retried.
485
+ `status: "failed"` results are now treated as a miss and are never written
486
+ to the cache; existing rows are left on disk and are overwritten naturally
487
+ on the next successful extraction. `src/llm/graph-extract.ts` also no
488
+ longer falls back to a per-asset retry for every body in a batch after a
489
+ `provider_error` — the provider has already demonstrated it is failing, so
490
+ each asset in that batch is recorded as failed directly. Graph extraction
491
+ now aborts the rest of the run (returning the partial results already
492
+ extracted) once the failure rate crosses 50% over at least 4 attempted
493
+ extraction dispatches, mirroring consolidate's existing failure-rate guard.
494
+ The abort counts one attempt per `extractGraphFromBodies` dispatch, not per
495
+ file inside its batch — per-file counting let a single batched
496
+ `provider_error` trip the guard after one HTTP failure whenever
497
+ `graphExtractionBatchSize` was at its default of 4.
498
+ - **Memory consolidation's cooldown could never engage.** `consolidate_completed`
499
+ was only emitted when a run planned zero merge/delete/contradict operations —
500
+ advisory ops the model plans daily and that are never auto-applied — so the
501
+ event had, in practice, never fired and the pool-delta gate stayed
502
+ permanently in its bootstrap "run every time" state. The event now fires
503
+ whenever the LLM pass itself completes, recording the unapplied advisory op
504
+ count (`advisoryOpsUnapplied`) instead of withholding the event. Separately,
505
+ the memory-volume override (`memoryVolumeConsolidationThreshold`, forcing a
506
+ run when the eligible pool exceeds the threshold) is now bootstrap-only: once
507
+ a `consolidate_completed` event exists for the source, the pool-delta gate
508
+ governs on its own, even when the pool is large. `akm improve --plan`'s
509
+ `consolidation.gates.delta.reason` no longer reports "memory pool has work"
510
+ for both a real pool delta and the bootstrap case (no `consolidate_completed`
511
+ event yet, so no delta was evaluated) — bootstrap now reports its own reason.
512
+
9
513
  ## [0.9.16] - 2026-09-22
10
514
 
11
515
  ### Fixed
@@ -1,21 +1,14 @@
1
1
  You are the akm consolidate assistant analyzing memory assets.
2
2
 
3
3
  Rules:
4
- 1. MERGE: Two or more memories are substantially duplicated or closely related → propose merging. Return the primary ref to keep and secondary refs to delete. Do NOT include mergedContent — the merge will be executed in a separate step.
5
- 2. DELETE: Memory is clearly outdated, contradicted, or redundant → propose deletion. NEVER propose delete for memories annotated `(captureMode: hot)` — they are user-explicit and only the user can retire them. The downstream guard will refuse these regardless, so proposing them just wastes tokens.
6
- 3. PROMOTE: Memory expresses a stable, reusable fact suitable as a `knowledge/` asset → propose promotion. Do NOT delete the source memory. NEVER propose promote / merge / contradict for memories annotated `(already queued)` — they have a pending proposal whose body matches; a duplicate will be deterministically dropped, so proposing them just wastes tokens.
7
- 4. CONTRADICT: Two memories assert logically exclusive facts such that following BOTH simultaneously is impossible — not merely related or overlapping. You MUST cite the exact sentence from Memory A and the exact sentence from Memory B that are in direct conflict. If you cannot cite specific opposing sentences, use KEEP instead. Sharing a topic, tool, domain, or workflow stage is NOT sufficient. Only direct factual opposites qualify: opposing recommended commands, opposing boolean flags, opposing version numbers, or mutually exclusive instructions. Use confidence ≥ 0.92 only; omit the op entirely if below that threshold.
8
- 5. KEEP: Memory is unique and current → omit from output.
4
+ 1. PROMOTE: Memory expresses a stable, reusable fact suitable as a `knowledge/` asset → propose promotion. Do NOT delete the source memory. NEVER propose promote for memories annotated `(already queued)` — they have a pending proposal whose body matches; a duplicate will be deterministically dropped, so proposing them just wastes tokens.
5
+ 2. KEEP: Memory is unique and current → omit from output.
9
6
 
10
7
  Return ONLY JSON (no prose, no code fences):
11
8
  {
12
9
  "operations": [
13
- { "op": "merge", "primary": "memories/<name>", "secondaries": ["memories/<name>", ...], "mergeStrategy": "synthesize", "confidence": 0.95 },
14
- { "op": "delete", "ref": "memories/<name>", "reason": "<brief reason>", "confidence": 0.90 },
15
- { "op": "promote", "ref": "memories/<name>", "knowledgeRef": "knowledge/<suggested-slug>", "reason": "<brief reason>", "description": "<one sentence describing the new knowledge asset>", "confidence": 0.92 },
16
- { "op": "contradict", "ref": "memories/<name>", "contradictedByRef": "memories/<name>", "reason": "<brief reason>", "confidence": 0.88 }
17
- ],
18
- "warnings": ["<optional concerns>"]
10
+ { "op": "promote", "ref": "memories/<name>", "knowledgeRef": "knowledge/<suggested-slug>", "reason": "<brief reason>", "description": "<one sentence describing the new knowledge asset>", "confidence": 0.92 }
11
+ ]
19
12
  }
20
13
 
21
14
  For every operation, emit a `confidence` field in [0, 1] expressing your certainty that the operation is correct and safe. Use 0.95+ only when evidence is unambiguous. Omit the field rather than guessing if you are uncertain.
@@ -1,10 +1,10 @@
1
1
  Extract entities and relations from the asset body below.
2
2
 
3
3
  Rules:
4
- - Output ONLY a JSON object: {"entities": ["Entity One", ...], "relations": [{"from": "A", "to": "B", "type": "uses"}, ...]}.
4
+ - Output ONLY a JSON object: {"entities": ["Entity One", ...], "relations": [["A", "uses", "B"], ...]}.
5
5
  - Entities are short, canonical noun phrases (project names, services, tools, people, technical concepts). Do NOT emit file or directory paths (anything containing "/" or "\") — they are dropped downstream.
6
- - Relations connect two entities that both appear in the entities array.
7
- - "type" is a short verb phrase (e.g. "uses", "depends on", "owns", "documents"). Optional; omit when unsure.
6
+ - Each relation is a 3-element array: [from, type, to]. Relations connect two entities that both appear in the entities array.
7
+ - "type" is a short verb phrase (e.g. "uses", "depends on", "owns", "documents"). Use "" when unsure.
8
8
  - Drop pleasantries, meta-commentary, and timestamps.
9
9
  - Limit to at most {{MAX_ENTITIES}} entities and {{MAX_RELATIONS}} relations per asset.
10
10
  - Return {"entities": [], "relations": []} if the body has no extractable graph content.
@@ -19,7 +19,7 @@ for rate limiting. The terraform-provisioner deploys everything to the prod clus
19
19
  Owner: @alice.
20
20
 
21
21
  Output:
22
- {"entities":["auth-service","PostgreSQL","redis-cache","terraform-provisioner","prod cluster","@alice"],"relations":[{"from":"auth-service","to":"PostgreSQL","type":"uses"},{"from":"auth-service","to":"redis-cache","type":"depends on"},{"from":"terraform-provisioner","to":"prod cluster","type":"deploys"},{"from":"terraform-provisioner","to":"auth-service","type":"deploys"},{"from":"@alice","to":"auth-service","type":"owns"}]}
22
+ {"entities":["auth-service","PostgreSQL","redis-cache","terraform-provisioner","prod cluster","@alice"],"relations":[["auth-service","uses","PostgreSQL"],["auth-service","depends on","redis-cache"],["terraform-provisioner","deploys","prod cluster"],["terraform-provisioner","deploys","auth-service"],["@alice","owns","auth-service"]]}
23
23
 
24
24
  Input:
25
25
  ## Meeting: API Redesign
@@ -27,7 +27,7 @@ Discussed moving from REST to GraphQL. The frontend team will use Apollo Client.
27
27
  Backend needs to implement resolvers. Timeline: Q2.
28
28
 
29
29
  Output:
30
- {"entities":["REST","GraphQL","Apollo Client","frontend team","backend","resolvers","Q2"],"relations":[{"from":"frontend team","to":"Apollo Client","type":"uses"},{"from":"backend","to":"resolvers","type":"implements"},{"from":"frontend team","to":"GraphQL","type":"migrates to"}]}
30
+ {"entities":["REST","GraphQL","Apollo Client","frontend team","backend","resolvers","Q2"],"relations":[["frontend team","uses","Apollo Client"],["backend","implements","resolvers"],["frontend team","migrates to","GraphQL"]]}
31
31
 
32
32
  ===============
33
33
 
@@ -15,6 +15,7 @@
15
15
  * same read extended to the accepted/rejected archive.
16
16
  */
17
17
  import { resolveStashDir } from "../../core/common.js";
18
+ import { isStaleTargetRejection } from "../proposal/proposal-types.js";
18
19
  import { listProposals } from "../proposal/repository.js";
19
20
  /**
20
21
  * Compute accept-rate-per-source metrics from the proposal store. Defaults to
@@ -28,6 +29,11 @@ export function computeAcceptRateBySource(stashDir) {
28
29
  for (const status of statuses) {
29
30
  const proposals = listProposals(stash, { status, includeArchive });
30
31
  for (const p of proposals) {
32
+ // A stale-target auto-reject (STALE, R20) is procedural, not a
33
+ // judgement on the content — counting it would understate the
34
+ // source's real accept rate for content the drain will re-propose.
35
+ if (status === "rejected" && isStaleTargetRejection(p))
36
+ continue;
31
37
  const src = p.source || "(unknown)";
32
38
  const entry = bySource.get(src) ?? { accepted: 0, rejected: 0, pending: 0 };
33
39
  if (status === "accepted")