akm-cli 0.9.18 → 0.9.19-alpha.1

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 (52) hide show
  1. package/CHANGELOG.md +135 -0
  2. package/STABILITY.md +2 -1
  3. package/dist/assets/hints/cli-hints-full.md +4 -2
  4. package/dist/assets/hints/cli-hints-short.md +5 -3
  5. package/dist/assets/prompts/reflect-feedback-framing.md +1 -1
  6. package/dist/commands/feedback-cli.js +1 -1
  7. package/dist/commands/improve/consolidate/coverage.js +132 -0
  8. package/dist/commands/improve/consolidate/pair-pass.js +30 -20
  9. package/dist/commands/improve/consolidate.js +43 -10
  10. package/dist/commands/improve/distill.js +7 -3
  11. package/dist/commands/improve/eligibility.js +39 -9
  12. package/dist/commands/improve/improve-cli.js +9 -6
  13. package/dist/commands/improve/improve.js +13 -4
  14. package/dist/commands/improve/ledger.js +2 -2
  15. package/dist/commands/improve/loop-stages.js +2 -0
  16. package/dist/commands/improve/preparation.js +1 -1
  17. package/dist/commands/improve/reflect.js +1 -1
  18. package/dist/commands/improve/stage.js +38 -9
  19. package/dist/commands/proposal/diff-format.js +21 -0
  20. package/dist/commands/proposal/proposal-cli.js +48 -10
  21. package/dist/commands/proposal/proposal-types.js +11 -0
  22. package/dist/commands/proposal/proposal.js +60 -5
  23. package/dist/commands/proposal/repository.js +245 -17
  24. package/dist/commands/read/knowledge.js +13 -11
  25. package/dist/commands/read/remember-cli.js +7 -3
  26. package/dist/commands/sources/source-clone.js +1 -1
  27. package/dist/commands/tasks/tasks-cli.js +1 -1
  28. package/dist/commands/tasks/tasks.js +10 -3
  29. package/dist/core/mutation-target.js +8 -3
  30. package/dist/core/write-source.js +3 -2
  31. package/dist/indexer/usage/usage-events.js +2 -1
  32. package/dist/output/shapes/helpers.js +7 -0
  33. package/dist/output/shapes/passthrough.js +1 -0
  34. package/dist/output/shapes/proposal/reopen.js +14 -0
  35. package/dist/output/shapes.js +2 -0
  36. package/dist/output/text/helpers.js +1 -1
  37. package/dist/output/text/proposal/proposal.js +3 -1
  38. package/dist/output/text/proposal-format.js +87 -32
  39. package/dist/scripts/akm-migrate-node.js +79 -23
  40. package/dist/scripts/akm-migrate.js +79 -23
  41. package/dist/storage/repositories/improve-ledger-repository.js +65 -6
  42. package/dist/storage/repositories/index-vec-repository.js +13 -8
  43. package/dist/storage/repositories/proposals-repository.js +23 -0
  44. package/dist/tasks/run/load-task.js +5 -1
  45. package/docs/migration/README.md +1 -0
  46. package/docs/migration/release-notes/0.9.19.md +134 -0
  47. package/docs/migration/release-notes/README.md +5 -0
  48. package/docs/migration/v0.8-to-v0.9.md +5 -1
  49. package/docs/reference/cli.md +189 -28
  50. package/docs/reference/configuration.md +9 -8
  51. package/docs/reference/data-and-telemetry.md +19 -14
  52. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -6,6 +6,141 @@ 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.19-alpha.1] - 2026-09-30
10
+
11
+ ### Added
12
+
13
+ - **`akm proposal reopen <id...> [--reason <text>]` (#997).** A rejection was
14
+ final: nothing undid it, and a rejected `consolidate-pair` retire proposal
15
+ also kept the pair pass from ever proposing that retirement again while both
16
+ documents were unchanged. Reopen moves rejected proposals back to `pending`,
17
+ keeping the rejection (and the gate verdict that came with it) in the
18
+ proposal's new `reviewHistory`, which `proposal show` prints; the verdict
19
+ itself is cleared so the drain sees the proposal as undecided, except a
20
+ `deferred` one (the quality gate's hand-off to a person), which stays. It is
21
+ refused unless the proposal is `rejected` and `accept` would not refuse it as
22
+ stale (an update's target unchanged; a create's target still absent; a retire
23
+ proposal's successor present and both documents' body hashes as recorded),
24
+ and a retire proposal is refused while another pending retire proposal
25
+ involves either of its documents. Several ids are all-or-nothing. The pair pass
26
+ follows the status: a reopened proposal is no longer a settled pair and,
27
+ pending, is not minted twice. Its `improve_ledger` row is reset (a retire
28
+ proposal's rejection row is dropped, any other goes back to `proposed`), the
29
+ age that retention expiry and `--older-than` (bulk accept/reject, `drain`)
30
+ see restarts at the reopen, so a scheduled sweep does not take a proposal a
31
+ person just put back, and a `proposal_reopened` event is appended.
32
+ `akm proposal reject`'s confirmation prompt no longer says a rejection
33
+ cannot be undone.
34
+
35
+ ### Fixed
36
+
37
+ - **A tool failure recorded with `akm feedback` no longer becomes a lesson
38
+ about the error or a `TODO` placeholder in a memory (#999).** Agents
39
+ recorded `akm show` failing on a memory with a `.derived.md` child (fixed in
40
+ 0.9.17) as negative feedback, and distill and reflect read it as evidence
41
+ about the memory's content. On one bundle, 9 such events on 8 memories
42
+ produced 4 distill lessons about "duplicate physical owners" for memories on
43
+ unrelated subjects (one auto-accepted and live), and a reflect proposal,
44
+ also auto-accepted, that added a `TODO: verify physical owner` section to a
45
+ memory, on which a fifth lesson was then built. The shipped hints had told
46
+ agents to record `--negative` "when it fails"; they, and the help for
47
+ `akm feedback --reason`, now say a failed akm command is not feedback on the
48
+ asset. Reflect's feedback caveat no longer offers a `TODO: verify …`
49
+ placeholder: when feedback asks for information the asset lacks, it says
50
+ only to leave the section unchanged. The distill quality judge now also
51
+ scores **grounding**, whether the lesson is about what its source is about
52
+ (1–2 only for a different subject; a lesson that corrects its source from
53
+ feedback is not off-subject), and a grounding score of 2 or less is
54
+ `quality_rejected` (an `improve_ledger` row and a `distill_invoked` event, no
55
+ proposal) whatever the mean of novelty and non-redundancy is. Such a lesson
56
+ reads as novel and non-redundant, so it used to pass or, in the review band,
57
+ be minted as a pending `review_needed` proposal. The judge also reads the
58
+ same slice of the source the lesson was generated from (its body without
59
+ frontmatter, first 3000 characters) instead of the raw file's first 2000. A
60
+ lesson that contradicts its source still reaches a human through the
61
+ optional fidelity check (`processes.distill.fidelityCheck.enabled`, off by
62
+ default), and every other `review_needed` reason is unchanged. `TODO:`
63
+ lines already in a memory are not removed.
64
+ - **Consolidation stops re-proposing memories that `knowledge/` already
65
+ covers (#998).** The promote pass copied a memory into a new `knowledge/`
66
+ proposal with no notion of what `knowledge/` already held: the model never
67
+ sees it, the mint-time checks only caught the same slug or a byte-identical
68
+ body, and an accepted promotion's memory was eligible again at once (a
69
+ rejected one after 7 days). On one bundle 88% of a run's proposals came from
70
+ memories promoted before, one of them 14 times, and 215 of 224 rejections
71
+ read "covered by an existing knowledge doc". Two changes, no new setting:
72
+ before queuing a promotion, consolidate now compares the memory with the 20
73
+ `knowledge/` docs in its bundle nearest to it by stored vector (the lookup
74
+ the pair pass uses) and skips it, with skip reason
75
+ `dedup_covered_by_knowledge`, when one of them holds at least half of the
76
+ memory's distinct 5-word shingles (measured against every knowledge doc,
77
+ that share was at least 0.5 for 122 of the 224 rejected proposals and for
78
+ none of the 103 accepted ones; a covering doc past the 20 nearest goes
79
+ unseen); and a memory whose promotion was accepted or rejected is offered
80
+ again only when its body changes, the same content-driven rule the pair pass
81
+ uses, instead of at once or after 7 days. A promotion decided by an older
82
+ release recorded no body hash and keeps its old windows. With no stored
83
+ vector (semantic search off) the coverage check does nothing.
84
+ - **`akm improve` no longer files one bundle's assets into another (#1000).**
85
+ Candidate selection admitted assets from every writable bundle, but every
86
+ proposal is filed in the run's write target and reflect reads each asset
87
+ from the bundle that owns it. An asset owned by another bundle therefore
88
+ came back as a `create` fork in the write target, or as an `update` of the
89
+ write target's own copy built from the other bundle's copy. A run now plans
90
+ only the bundle it writes to (`--bundle`, else `defaultWriteTarget`, else
91
+ the working bundle), and a bare ref scope (`akm improve skills/x`) resolves
92
+ inside that bundle. Distill's memory-to-knowledge promotion likewise merges
93
+ only with a doc that already exists in the write target. As a second line of
94
+ defence, `createProposal` refuses a rewrite whose `itemRef` names an asset
95
+ owned by a different configured bundle than the queue's. **Narrowed
96
+ behaviour:** a run no longer picks up assets from your other writable
97
+ bundles (it used to read them and queue the result in its own write target),
98
+ so a scheduled `akm improve` now covers only its write target: add one
99
+ `akm improve --bundle <name>` run per other bundle you want improved.
100
+ `akm improve <ref>` for an asset that lives only in another bundle now fails
101
+ with a not-found error whose hint names the remedy (`--bundle team`, or
102
+ `akm improve team//skills/x`). Proposals the old behaviour already queued
103
+ stay in the queue; review them with `akm proposal list`. `--bundle`'s help
104
+ text now says it selects the bundle a run improves and writes to.
105
+ - **`akm improve --dry-run`/`--plan` previews the bundle a live run improves.**
106
+ With no `--bundle` and no `defaultWriteTarget`, a live run starts from
107
+ `AKM_BUNDLE_DIR` before `defaultBundle`, but a dry run read `defaultBundle`
108
+ only, so the two could plan different bundles. The preview now resolves the
109
+ working bundle the same way.
110
+ - **`akm proposal diff` shows a retire proposal as a retirement (#997).** It
111
+ rendered the retired file as replaced by one blank line (`----`, a lone `+`,
112
+ then every other line as a removal, under an `(update: <ref>)` header) and
113
+ said nothing about the retirement, so one reviewer rejected all 65 of a
114
+ bundle's `consolidate-pair` proposals as "would destroy content". The diff
115
+ now lists only the removed lines, under a `(retire: <retired> -> <successor>)`
116
+ header and `+++ /dev/null (retired: archived; successor <ref>)`, and its JSON
117
+ result gains `op: "delete"`, a `retirement` block under the keys `proposal
118
+ show` uses (`retiredRef`, `successorRef`, `judgeLabel`, `judgeReason`,
119
+ `cosine`, and `continuityRisk` when the pair was flagged, which the text
120
+ output prints as well) and a `note` that accepting archives the file under
121
+ `.akm/memory-cleanup/archive/` and `akm proposal revert` restores it
122
+ byte-exactly. The new fields are additive and appear on retire proposals
123
+ only. `akm proposal show --detail full` also stops ending a retire proposal
124
+ with a bare `payload:` heading over nothing.
125
+ - **Errors name the flag the command takes, not the retired `--target`
126
+ (`improve`, `remember`, `clone`, `task`, `proposal --queue`).** A `--bundle`
127
+ that names no configured bundle, names a read-only one, or differs from a
128
+ bundle-qualified ref (`akm improve team//skills/x --bundle stash`) failed with
129
+ "--target must reference a source name", "or pass --target to a different
130
+ source" or "conflicts with --target". `akm improve`, `remember`, `clone` and
131
+ every `task` verb reject `--target` (renamed `--bundle` in 0.9), and
132
+ `proposal --queue` takes `--queue`, so each message sent the user to a flag
133
+ that does not work. They now name the flag the command takes, and the
134
+ remedy in `akm remember --supersedes` says "re-run with --bundle" instead of
135
+ "--target". A bundle that came from a ref inside a task (`ghost//workflows/x`)
136
+ is described as such rather than blamed on a flag. The commands that really
137
+ take `--target` (`import`, `env`, `secret`, `proposal accept`) are unchanged.
138
+ Separately, the help for `akm improve --skip-if-locked` said a lock collision
139
+ without the flag exits 78. It exits 75 (`IMPROVE_LOCK_HELD`), as the CLI
140
+ reference says. The `--limit` help says "highest salience first" (it said
141
+ utility), and the `task add --command` help example uses
142
+ `--strategy reflect-distill` (the `frequent` strategy no longer exists).
143
+
9
144
  ## [0.9.18] - 2026-09-29
10
145
 
11
146
  ### Fixed
package/STABILITY.md CHANGED
@@ -89,6 +89,7 @@ enumeration of the whole `proposal` noun group.
89
89
  | `akm proposal diff` | Evolving | |
90
90
  | `akm proposal accept` | Evolving | |
91
91
  | `akm proposal reject` | Evolving | |
92
+ | `akm proposal reopen` | Evolving | New in 0.9.19; undoes a rejection. |
92
93
  | `akm proposal revert` | Evolving | |
93
94
  | `akm proposal drain` | Evolving | |
94
95
  | `akm proposal extract` | Evolving | Former top-level `akm extract`. |
@@ -247,7 +248,7 @@ proposal-queue shape may shift. Breaking changes will be flagged in the
247
248
  CHANGELOG with a migration note.
248
249
 
249
250
  - **Improvement loop** — `akm improve` and the proposal noun group
250
- `akm proposal {extract,new,list,show,diff,accept,reject,revert,drain}`
251
+ `akm proposal {extract,new,list,show,diff,accept,reject,reopen,revert,drain}`
251
252
  (`extract` and `new` are the former top-level `akm extract`/`akm propose`,
252
253
  moved under `proposal` in 0.9.0). Output JSON keys
253
254
  are stable; CLI flags (`--strategy`, `--task`, `--generator`) may add
@@ -100,8 +100,9 @@ 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
  ```
102
102
 
103
- Use `akm feedback` whenever an asset materially helps or fails so future search
104
- ranking can learn from actual usage.
103
+ Use `akm feedback` whenever an asset's content materially helps, or proves wrong,
104
+ stale or unhelpful, so future search ranking can learn from actual usage. An akm
105
+ command that fails says nothing about the asset; don't record it as feedback.
105
106
 
106
107
  ## LLM Wiki bundles
107
108
 
@@ -340,6 +341,7 @@ akm proposal accept 7c115132 # Accept by UUID prefix
340
341
  akm proposal accept <id> --target team-bundle # Accept to a named writable bundle source
341
342
  akm proposal reject skills/my-skill --reason "not ready" # Reject by asset ref
342
343
  akm proposal reject <id> --reason "..." # Archive with a reason
344
+ akm proposal reopen <id> --reason "..." # Undo a rejection: back to pending (refused if the target changed)
343
345
  akm proposal revert <id> # Restore the pre-promotion content
344
346
  akm proposal new <type> <name> --task "..." # Agent-author a NEW asset as a proposal
345
347
  akm proposal extract --auto # Mine native session files into proposals
@@ -8,7 +8,7 @@ For any task, follow this loop:
8
8
  1. `akm curate "<task>"` — find the best matching asset
9
9
  2. `akm show <ref>` — read the schema (field names and structure)
10
10
  3. Edit the workspace file using schema field names + task-specific values from your README
11
- 4. `akm feedback <ref> --positive` — record success; use `--negative --reason "..."` when it fails
11
+ 4. `akm feedback <ref> --positive` — record that the asset helped; use `--negative --reason "..."` when its content was wrong, stale or unhelpful. A failed akm command (e.g. `akm show` erroring) is not feedback on the asset — don't record it.
12
12
 
13
13
  For workflow tasks:
14
14
  1. `akm show workflows/<name>` — inspect the procedure before executing it
@@ -63,8 +63,10 @@ akm search "<query>" --from registry # Search all registries (registry
63
63
  | secret | A single sensitive value for AUTHENTICATION (token, key, cert); name only. Inject with `akm secret run <ref> <VAR> -- <cmd>`. |
64
64
  | lesson | A distilled feedback lesson: `content` plus `action` (rendered from the `when_to_use` frontmatter). Read both before applying a related skill. Generated by the improve pipeline and promoted through the proposal queue. |
65
65
 
66
- When an asset meaningfully helps or fails, record that with `akm feedback` so
67
- future search ranking can learn from real usage.
66
+ When an asset's content meaningfully helps, or proves wrong, stale or unhelpful,
67
+ record that with `akm feedback` so future search ranking can learn from real
68
+ usage. An akm command that fails says nothing about the asset; don't record it
69
+ as feedback.
68
70
 
69
71
  ## Error Shapes and Exit Codes
70
72
 
@@ -1 +1 @@
1
- Feedback describes what a reader found missing or wrong. It is a signal to investigate, not a fact to insert. Do not add claims, numbers, dates, paths, ports, or incidents that are not already present in the asset content. If feedback asks for information the asset lacks, add a clearly marked `TODO: verify …` placeholder or leave the section unchanged.
1
+ Feedback describes what a reader found missing or wrong. It is a signal to investigate, not a fact to insert. Do not add claims, numbers, dates, paths, ports, or incidents that are not already present in the asset content. If feedback asks for information the asset lacks, leave the section unchanged.
@@ -208,7 +208,7 @@ export const feedbackCommand = defineJsonCommand({
208
208
  },
209
209
  reason: {
210
210
  type: "string",
211
- description: "Reason for the feedback (required for negative feedback by default; used by distillation)",
211
+ description: "What was wrong with (or right about) the asset's content (required for negative feedback by default; used by distillation). Not for akm command errors.",
212
212
  },
213
213
  "failure-mode": {
214
214
  type: "string",
@@ -0,0 +1,132 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * The promote pass's coverage gate (#998): before a memory becomes a
6
+ * `knowledge/` proposal, ask whether `knowledge/` already says it.
7
+ *
8
+ * The rule: a memory is covered when at least {@link COVERAGE_MIN_CONTAINMENT}
9
+ * (half) of its distinct {@link COVERAGE_SHINGLE_WORDS}-word shingles appear in
10
+ * one of the knowledge docs nearest to it. It is a containment of the MEMORY in
11
+ * the doc, not a similarity: a long guide that quotes the memory covers it; a
12
+ * memory that quotes a short doc and adds claims of its own does not.
13
+ *
14
+ * Why this rule and this cut. The model never sees `knowledge/`, and the
15
+ * mint-time checks before this one only catch the same slug or the same whole
16
+ * body, so one edit or paraphrase defeats them. The evidence is #998's, from
17
+ * the owner's bundle (run `consolidate-1790666282516`: 327 decided proposals,
18
+ * 103 accepted, 224 rejected, 215 of those as "duplicate of / covered by /
19
+ * overlaps" an existing knowledge doc): the share of a proposal's distinct
20
+ * 5-word shingles found in the best OTHER knowledge doc was >= 0.5 for 122 of
21
+ * the 224 rejected proposals and for none of the 103 accepted ones. 0.5 is the
22
+ * cut that sample supports: no accepted promotion would have been skipped. That
23
+ * sample compared each proposal with EVERY other knowledge doc, so 122 of 224
24
+ * (up to about 54%) is what the rule can take out of review at most, not what
25
+ * this gate achieves: it reads only the {@link PAIR_NEIGHBOR_FETCH_K} nearest
26
+ * knowledge docs (below), and a covering doc that ranks lower goes unseen. The
27
+ * recall over that candidate set is unmeasured. The rest of the rejected
28
+ * proposals (paraphrases, partial overlaps) still reach review: the next cut
29
+ * measured, 0.2 (169 of 224 rejected), would also have skipped 2 of the 103
30
+ * accepted ones, and no cosine cut was measured at all. A wrong skip is a
31
+ * promotion nobody gets to review.
32
+ *
33
+ * Candidates are the memory's {@link PAIR_NEIGHBOR_FETCH_K} nearest knowledge
34
+ * docs in its bundle by stored vector (`getNeighborsByEntryId`, the lookup the
35
+ * pair pass runs, scoped to the bundle's knowledge entries), never a scan of
36
+ * `knowledge/`. There is no similarity floor, only a rank: a guide that quotes
37
+ * the memory is found however far it sits from it by vector, as long as fewer
38
+ * than that many other knowledge docs in the bundle are nearer. With no stored
39
+ * vector (semantic search off, the memory not indexed yet, no index at all)
40
+ * there are no candidates and the gate does nothing: the exact-slug and
41
+ * whole-body checks still run, and nothing throws.
42
+ */
43
+ import fs from "node:fs";
44
+ import { closeDatabase, openExistingDatabase } from "../../../storage/repositories/index-connection.js";
45
+ import { getEntryById, getEntryIdByFilePath } from "../../../storage/repositories/index-entries-repository.js";
46
+ import { getNeighborsByEntryId } from "../../../storage/repositories/index-vec-repository.js";
47
+ import { stripFrontmatterBody } from "../content-hash.js";
48
+ import { PAIR_NEIGHBOR_FETCH_K } from "./pair-pass.js";
49
+ /** Words per shingle. */
50
+ export const COVERAGE_SHINGLE_WORDS = 5;
51
+ /** Share of a memory's distinct shingles one knowledge doc must hold for the memory to count as covered. */
52
+ export const COVERAGE_MIN_CONTAINMENT = 0.5;
53
+ const WORD = /[\p{L}\p{N}]+/gu;
54
+ /** The distinct lower-cased word n-grams of `text`; empty when it has fewer than {@link COVERAGE_SHINGLE_WORDS} words. */
55
+ export function wordShingles(text) {
56
+ const words = text.toLowerCase().match(WORD) ?? [];
57
+ const shingles = new Set();
58
+ for (let i = 0; i + COVERAGE_SHINGLE_WORDS <= words.length; i++) {
59
+ shingles.add(words.slice(i, i + COVERAGE_SHINGLE_WORDS).join(" "));
60
+ }
61
+ return shingles;
62
+ }
63
+ /** The share (0..1) of `memory`'s shingles that also occur in `doc`; 0 when the memory has none. */
64
+ export function shingleContainment(memory, doc) {
65
+ if (memory.size === 0)
66
+ return 0;
67
+ const docShingles = wordShingles(doc);
68
+ let shared = 0;
69
+ for (const shingle of memory)
70
+ if (docShingles.has(shingle))
71
+ shared++;
72
+ return shared / memory.size;
73
+ }
74
+ /**
75
+ * The best-covering knowledge doc among the {@link PAIR_NEIGHBOR_FETCH_K}
76
+ * knowledge docs in `bundleId` nearest to the memory. `filePath` is the
77
+ * memory's indexed file; a memory the index does not know has no stored vector
78
+ * and so no candidates.
79
+ */
80
+ export function findCoveringKnowledge(db, bundleId, filePath, body) {
81
+ const shingles = wordShingles(body);
82
+ if (shingles.size === 0)
83
+ return undefined;
84
+ const entryId = getEntryIdByFilePath(db, filePath);
85
+ if (entryId === undefined)
86
+ return undefined;
87
+ let best;
88
+ for (const hit of getNeighborsByEntryId(db, entryId, PAIR_NEIGHBOR_FETCH_K, { type: "knowledge", bundleId })) {
89
+ const neighbour = getEntryById(db, hit.id);
90
+ if (!neighbour)
91
+ continue;
92
+ let raw;
93
+ try {
94
+ raw = fs.readFileSync(neighbour.filePath, "utf8");
95
+ }
96
+ catch {
97
+ continue; // the index outlived the file
98
+ }
99
+ const containment = shingleContainment(shingles, stripFrontmatterBody(raw));
100
+ if (containment >= COVERAGE_MIN_CONTAINMENT && (best === undefined || containment > best.containment)) {
101
+ best = { ref: neighbour.conceptId, containment };
102
+ }
103
+ }
104
+ return best;
105
+ }
106
+ /**
107
+ * The gate for one run, holding its own read handle on `index.db` for the
108
+ * promotions that run emits; `undefined` when there is no bundle or no index
109
+ * to look in. A lookup that throws counts as "not known to be covered".
110
+ */
111
+ export function openKnowledgeCoverage(bundleId) {
112
+ if (!bundleId)
113
+ return undefined;
114
+ let db;
115
+ try {
116
+ db = openExistingDatabase();
117
+ }
118
+ catch {
119
+ return undefined;
120
+ }
121
+ return {
122
+ find: (filePath, body) => {
123
+ try {
124
+ return findCoveringKnowledge(db, bundleId, filePath, body);
125
+ }
126
+ catch {
127
+ return undefined;
128
+ }
129
+ },
130
+ close: () => closeDatabase(db),
131
+ };
132
+ }
@@ -273,6 +273,35 @@ function pairKey(a, b) {
273
273
  function rejectedPairKey(retiredRef, successorRef, retiredHash, successorHash) {
274
274
  return [retiredRef, successorRef, retiredHash, successorHash].join("\u0000");
275
275
  }
276
+ /**
277
+ * Item 0 / S1: every rejected OR reverted consolidate-pair retirement on
278
+ * record, as {@link rejectedPairKey}s — read once per run, the same shape as
279
+ * `pendingRetireRefs` in {@link runConsolidatePairPass}. `reverted` is
280
+ * included alongside `rejected`: a person undoing an accept via `akm proposal
281
+ * revert` is the same "no, not this" signal as a reject — without it, the next
282
+ * run would re-mint the identical retirement, and a bulk accept could
283
+ * re-apply a decision the person just undid. The keys follow each proposal's
284
+ * CURRENT status, so one `akm proposal reopen` put back to `pending` (#997) is
285
+ * no longer among them — and, pending, blocks its pair from a second mint.
286
+ *
287
+ * @internal exported for unit tests.
288
+ */
289
+ export function loadRejectedPairKeys(stashDir, proposalsCtx) {
290
+ const keys = new Set();
291
+ try {
292
+ for (const status of ["rejected", "reverted"]) {
293
+ for (const p of listProposalsReadOnly(stashDir, { status, includeArchive: true }, proposalsCtx)) {
294
+ if (!isRetireProposal(p) || !p.retirement)
295
+ continue;
296
+ keys.add(rejectedPairKey(p.retirement.retiredRef, p.retirement.successorRef, p.retirement.retiredContentHash, p.retirement.successorContentHash));
297
+ }
298
+ }
299
+ }
300
+ catch {
301
+ // Best-effort de-dup only; a failed read never blocks judging.
302
+ }
303
+ return keys;
304
+ }
276
305
  /**
277
306
  * Initiators (plan §5.2 step 1, S1 post-review): the pool, in the retrieval
278
307
  * scope, and content-eligible — no prior `consolidate-pair` ledger row, or a
@@ -647,26 +676,7 @@ seams = {}) {
647
676
  // Best-effort de-dup only; a failed read never blocks judging.
648
677
  }
649
678
  const isPendingBlocked = (c) => pendingRetireRefs.has(stripBundle(c.initiator.ref)) || pendingRetireRefs.has(stripBundle(c.other.ref));
650
- // Item 0 / S1: every rejected OR reverted consolidate-pair retirement on
651
- // record, keyed by its exact ref pair and both content hashes — read once
652
- // per run, the same shape as pendingRetireRefs above. `reverted` is
653
- // included alongside `rejected`: a person undoing an accept via `akm
654
- // proposal revert` is the same "no, not this" signal as a reject — without
655
- // it, the next run would re-mint the identical retirement, and a bulk
656
- // accept could re-apply a decision the person just undid.
657
- const rejectedPairKeys = new Set();
658
- try {
659
- for (const status of ["rejected", "reverted"]) {
660
- for (const p of listProposalsReadOnly(stashDir, { status, includeArchive: true }, opts.proposalsCtx)) {
661
- if (!isRetireProposal(p) || !p.retirement)
662
- continue;
663
- rejectedPairKeys.add(rejectedPairKey(p.retirement.retiredRef, p.retirement.successorRef, p.retirement.retiredContentHash, p.retirement.successorContentHash));
664
- }
665
- }
666
- }
667
- catch {
668
- // Best-effort de-dup only; a failed read never blocks judging.
669
- }
679
+ const rejectedPairKeys = loadRejectedPairKeys(stashDir, opts.proposalsCtx);
670
680
  // Blocker 2: admit WHOLE initiators under MAX_PAIRS_PER_RUN, never
671
681
  // individual pairs — the old flat "top 300 candidates by cosine" cap let a
672
682
  // pending-blocked pair spend a budget slot doing nothing, and left
@@ -7,7 +7,10 @@
7
7
  * promoted. Promotion emits a reviewable proposal and never touches the
8
8
  * memory directly; accepting it later retires the source memory (O1, in
9
9
  * `proposal/repository.ts`). Memories the improve ledger judged recently and
10
- * that have not changed since are not judged again.
10
+ * that have not changed since are not judged again, and a memory whose
11
+ * promotion was accepted or rejected waits until its body changes. A memory
12
+ * that a neighbouring knowledge doc already covers is not promoted
13
+ * (`consolidate/coverage.ts`).
11
14
  *
12
15
  * Accounting invariant (the promote pass only): `processed == promoted +
13
16
  * judgedNoAction + Σ(skipReasons) + failedChunkMemories`.
@@ -41,11 +44,12 @@ import { getAllEntries } from "../../storage/repositories/index-entries-reposito
41
44
  import { listProposals, listProposalsReadOnly, proposalContent } from "../proposal/repository.js";
42
45
  import { hasHotCaptureMode, hasSupersededStatus, validateProposalFrontmatter, } from "../proposal/validators/proposal-quality-validators.js";
43
46
  import { buildChunkPrompt, computeSafeChunkSize, DEFAULT_CONTEXT_LENGTH_TOKENS } from "./consolidate/chunking.js";
47
+ import { openKnowledgeCoverage } from "./consolidate/coverage.js";
44
48
  import { runConsolidatePairPass } from "./consolidate/pair-pass.js";
45
49
  import { sanitizeMergedContent } from "./consolidate/sanitize.js";
46
50
  import { contentHash } from "./content-hash.js";
47
51
  import { resolveImproveStrategy, resolveProcessEnabled } from "./improve-strategies.js";
48
- import { isLedgerBlocked, ledgerKey, loadLedgerSnapshot, recordLedgerAttempt } from "./ledger.js";
52
+ import { isContentDrivenRow, isLedgerBlocked, ledgerKey, loadLedgerSnapshot, recordLedgerAttempt } from "./ledger.js";
49
53
  import { isInRetrievalScope, loadRetrievalScope } from "./retrieval-scope.js";
50
54
  import { callStage, mintProposal, noticeSet, stageRunner } from "./stage.js";
51
55
  /** A plan op worth acting on. Retired advisory ops (merge/delete/contradict) are dropped, never thrown on. */
@@ -420,12 +424,26 @@ export function inspectConsolidationPool(opts, stashDir, warnings, existingKnowl
420
424
  }
421
425
  memories = memories.filter((memory) => fs.existsSync(memory.filePath));
422
426
  const poolSize = memories.length;
423
- // A memory judged within its revisit window comes back once it is edited.
427
+ // A memory judged within its revisit window comes back once it is edited. A
428
+ // promotion that was accepted or rejected holds its memory until the body
429
+ // changes, however long that takes (#998).
424
430
  const ledger = loadLedgerSnapshot({ proposalsCtx: opts.proposalsCtx, readOnly }, stashDir, ["consolidate"]);
425
431
  if (ledger.size > 0) {
426
432
  const nowIso = new Date().toISOString();
427
433
  memories = memories.filter((memory) => {
428
434
  const row = ledger.get(ledgerKey("consolidate", conceptIdFromTypeName("memory", memory.name)));
435
+ if (!row)
436
+ return true;
437
+ if (isContentDrivenRow(row)) {
438
+ let bodyHash;
439
+ try {
440
+ bodyHash = contentHash(fs.readFileSync(memory.filePath, "utf8"), "body");
441
+ }
442
+ catch {
443
+ bodyHash = undefined;
444
+ }
445
+ return row.contentHash !== bodyHash;
446
+ }
429
447
  let changedAt;
430
448
  try {
431
449
  changedAt = fs.statSync(memory.filePath).mtime.toISOString();
@@ -433,7 +451,7 @@ export function inspectConsolidationPool(opts, stashDir, warnings, existingKnowl
433
451
  catch {
434
452
  changedAt = undefined;
435
453
  }
436
- return !row || !isLedgerBlocked(row, nowIso, changedAt);
454
+ return !isLedgerBlocked(row, nowIso, changedAt);
437
455
  });
438
456
  }
439
457
  const judgedUnchanged = poolSize - memories.length;
@@ -651,7 +669,7 @@ async function consolidate(opts, config, stashDir, startMs, stateDb) {
651
669
  const { memories, prefilteredAlreadyPromoted } = pool;
652
670
  const plural = (n) => `memor${n === 1 ? "y" : "ies"}`;
653
671
  if (pool.judgedUnchanged > 0) {
654
- warnings.push(`Consolidation: skipped ${pool.judgedUnchanged} ${plural(pool.judgedUnchanged)} judged within the revisit window and unchanged since.`);
672
+ warnings.push(`Consolidation: skipped ${pool.judgedUnchanged} ${plural(pool.judgedUnchanged)} judged within the revisit window, or already promoted or rejected, and unchanged since.`);
655
673
  }
656
674
  if (pool.outsideRetrievalScope > 0) {
657
675
  warnings.push(`Consolidation: skipped ${pool.outsideRetrievalScope} ${plural(pool.outsideRetrievalScope)} already judged that retrieval has not returned in the last ${USAGE_EVENT_RETENTION_DAYS} days.`);
@@ -664,8 +682,8 @@ async function consolidate(opts, config, stashDir, startMs, stateDb) {
664
682
  // sees .derived memories, flat knowledge and lessons, not just the
665
683
  // promote pool above), so it runs regardless of whether the promote pool
666
684
  // is empty — every return path below carries its result.
667
- const pairPassBundleId = resolveConsolidationSourceOwner(opts, stashDir)?.bundleId;
668
- const pairPass = await runConsolidatePairPass(opts, config, stashDir, pairPassBundleId, warnings);
685
+ const bundleId = resolveConsolidationSourceOwner(opts, stashDir)?.bundleId;
686
+ const pairPass = await runConsolidatePairPass(opts, config, stashDir, bundleId, warnings);
669
687
  if (memories.length === 0) {
670
688
  return makeConsolidateResult({
671
689
  dryRun: opts.dryRun ?? false,
@@ -719,8 +737,16 @@ async function consolidate(opts, config, stashDir, startMs, stateDb) {
719
737
  warnings,
720
738
  pushSkipReason: (op, ref, reason) => pushSkipReason(acc, op, ref, reason),
721
739
  };
722
- for (const op of plan.allOps)
723
- await emitPromotionProposal(op, ctx);
740
+ const coverage = plan.allOps.length > 0 ? openKnowledgeCoverage(bundleId) : undefined;
741
+ if (coverage)
742
+ ctx.coveringKnowledge = coverage.find;
743
+ try {
744
+ for (const op of plan.allOps)
745
+ await emitPromotionProposal(op, ctx);
746
+ }
747
+ finally {
748
+ coverage?.close();
749
+ }
724
750
  // Every other judged memory waits out its revisit window (or its next edit);
725
751
  // a promotion that failed to persist is retried next run.
726
752
  recordLedgerAttempt({ proposalsCtx: opts.proposalsCtx }, [...acc.judgedRefs]
@@ -765,7 +791,8 @@ const PROMOTE_BODY_MIN_CHARS = 100;
765
791
  /**
766
792
  * Queue one promotion as a proposal. Refused (with a skip reason) when the
767
793
  * memory is unknown, already promoted this run, already pending or present
768
- * as knowledge (by concept, body hash or slug variant), unreadable, fails
794
+ * as knowledge (by concept, body hash or slug variant), already covered by a
795
+ * neighbouring knowledge doc (`coverage.ts`), unreadable, fails
769
796
  * sanitization, is superseded, has a body too small to be knowledge, or has
770
797
  * no valid description.
771
798
  * @internal Exported for promotion-path integration tests.
@@ -842,6 +869,12 @@ export async function emitPromotionProposal(op, ctx) {
842
869
  if (sameBody) {
843
870
  return skip("dedup_pending_proposal", `Skipping promote: identical body already pending as proposal ${sameBody.id} (ref: ${sameBody.ref}); skipping duplicate for ${op.ref} → ${knowledgeRef}`);
844
871
  }
872
+ // #998: the copies the two exact checks above cannot see — an earlier
873
+ // promotion that was edited or re-slugged, a doc that quotes the memory.
874
+ const covering = ctx.coveringKnowledge?.(entry.filePath, sourceBody);
875
+ if (covering) {
876
+ return skip("dedup_covered_by_knowledge", `Skipping promote: ${op.ref} → ${knowledgeRef} is already covered by ${covering.ref} (${Math.round(covering.containment * 100)}% of its text appears there).`);
877
+ }
845
878
  try {
846
879
  const description = (typeof op.description === "string" && op.description.trim()
847
880
  ? op.description.trim()
@@ -20,7 +20,7 @@ import { parseFrontmatter, writeSalienceToFrontmatter } from "../../core/asset/f
20
20
  import { stripMarkdownFences } from "../../core/asset/markdown.js";
21
21
  import { conceptIdFromTypeName, parseRefInput } from "../../core/asset/resolve-ref.js";
22
22
  import { authoringRulesForType } from "../../core/authoring-rules.js";
23
- import { resolveStashDir } from "../../core/common.js";
23
+ import { isWithin, resolveStashDir } from "../../core/common.js";
24
24
  import { getImproveProcessConfig, loadConfig } from "../../core/config/config.js";
25
25
  import { UsageError } from "../../core/errors.js";
26
26
  import { appendEvent, readEvents } from "../../core/events.js";
@@ -436,7 +436,9 @@ async function judgeAndQueue(run, out) {
436
436
  let confidence;
437
437
  if (qualityGateEnabled(run)) {
438
438
  const similarLessons = await run.similar(content.slice(0, 500), 3);
439
- const verdict = await runLessonQualityJudge(run.config, content, out.source ?? "", run.options.chat, {
439
+ // The judge reads what the generator read: the source body, without its frontmatter (buildDistillPrompt).
440
+ const source = out.source ? parseFrontmatter(out.source).content.trim() : "";
441
+ const verdict = await runLessonQualityJudge(run.config, content, source, run.options.chat, {
440
442
  ...(similarLessons.length > 0 ? { similarLessons } : {}),
441
443
  ...(run.runner ? { llmRunner: run.runner } : {}),
442
444
  ...(run.options.signal ? { signal: run.options.signal } : {}),
@@ -599,8 +601,10 @@ async function planPromotion(run, feedbackEvents) {
599
601
  const existingPath = await run.lookup(assessment.knowledgeRef);
600
602
  let existing = null;
601
603
  try {
602
- if (existingPath && fs.existsSync(existingPath))
604
+ // The promotion is filed in run.stash, so only that bundle's doc is its destination (#1000).
605
+ if (existingPath && fs.existsSync(existingPath) && isWithin(existingPath, run.stash)) {
603
606
  existing = fs.readFileSync(existingPath, "utf8");
607
+ }
604
608
  }
605
609
  catch {
606
610
  existing = null;