akm-cli 0.9.17 → 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 (57) hide show
  1. package/CHANGELOG.md +157 -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/health/checks.js +6 -6
  8. package/dist/commands/health.js +3 -3
  9. package/dist/commands/improve/consolidate/coverage.js +132 -0
  10. package/dist/commands/improve/consolidate/pair-pass.js +31 -21
  11. package/dist/commands/improve/consolidate.js +46 -13
  12. package/dist/commands/improve/distill.js +8 -4
  13. package/dist/commands/improve/eligibility.js +39 -9
  14. package/dist/commands/improve/improve-cli.js +9 -6
  15. package/dist/commands/improve/improve.js +13 -4
  16. package/dist/commands/improve/ledger.js +2 -2
  17. package/dist/commands/improve/loop-stages.js +2 -0
  18. package/dist/commands/improve/preparation.js +3 -1
  19. package/dist/commands/improve/reflect.js +2 -2
  20. package/dist/commands/improve/stage.js +43 -12
  21. package/dist/commands/proposal/diff-format.js +21 -0
  22. package/dist/commands/proposal/proposal-cli.js +48 -10
  23. package/dist/commands/proposal/proposal-types.js +11 -0
  24. package/dist/commands/proposal/proposal.js +60 -5
  25. package/dist/commands/proposal/repository.js +250 -18
  26. package/dist/commands/read/knowledge.js +13 -11
  27. package/dist/commands/read/remember-cli.js +7 -3
  28. package/dist/commands/sources/source-clone.js +1 -1
  29. package/dist/commands/tasks/tasks-cli.js +1 -1
  30. package/dist/commands/tasks/tasks.js +10 -3
  31. package/dist/core/mutation-target.js +8 -3
  32. package/dist/core/write-source.js +3 -2
  33. package/dist/indexer/usage/usage-events.js +2 -1
  34. package/dist/output/shapes/helpers.js +7 -0
  35. package/dist/output/shapes/passthrough.js +1 -0
  36. package/dist/output/shapes/proposal/reopen.js +14 -0
  37. package/dist/output/shapes.js +2 -0
  38. package/dist/output/text/helpers.js +1 -1
  39. package/dist/output/text/proposal/proposal.js +3 -1
  40. package/dist/output/text/proposal-format.js +87 -32
  41. package/dist/scripts/akm-migrate-node.js +102 -25
  42. package/dist/scripts/akm-migrate.js +102 -25
  43. package/dist/storage/repositories/improve-ledger-repository.js +65 -6
  44. package/dist/storage/repositories/index-vec-repository.js +13 -8
  45. package/dist/storage/repositories/proposals-repository.js +23 -0
  46. package/dist/storage/sqlite-read-snapshot.js +46 -2
  47. package/dist/storage/state-db-integrity.js +12 -9
  48. package/dist/tasks/run/load-task.js +5 -1
  49. package/docs/migration/README.md +1 -0
  50. package/docs/migration/release-notes/0.9.19.md +134 -0
  51. package/docs/migration/release-notes/README.md +5 -0
  52. package/docs/migration/v0.7-to-v0.8.md +2 -2
  53. package/docs/migration/v0.8-to-v0.9.md +5 -1
  54. package/docs/reference/cli.md +189 -28
  55. package/docs/reference/configuration.md +9 -8
  56. package/docs/reference/data-and-telemetry.md +24 -16
  57. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -6,6 +6,163 @@ 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
+
144
+ ## [0.9.18] - 2026-09-29
145
+
146
+ ### Fixed
147
+
148
+ - **A running `akm improve` no longer loses its SQLite file locks, which let
149
+ an older `sqlite3` delete `state.db`'s WAL.** Copying a database file
150
+ inside a process that also holds it open drops every POSIX lock the process
151
+ holds on it, and `akm improve` did exactly that (its read snapshots copy
152
+ `state.db`, hundreds of times a run). A peer on SQLite older than 3.51 (the
153
+ system `sqlite3` command, Python's `sqlite3` module) that then opened
154
+ `state.db` read-write, even just for `.backup`, saw no reader and deleted
155
+ `state.db-wal`/`-shm` at close, leaving colliding rowids, stale index
156
+ entries and lost rows. Affected: every release since 0.9.2 through the
157
+ snapshot, and 0.9.2 through 0.9.16 also on every `openStateDatabase` (fixed
158
+ in 0.9.17-alpha.4). The snapshot now copies in a child `cp` (Windows keeps
159
+ the in-process copy; its locks belong to the handle), and `improve` reads
160
+ proposals through its own connection instead of snapshotting per asset.
161
+ `akm health`'s `state-db-integrity` check now runs `PRAGMA integrity_check`,
162
+ since `quick_check` does not compare indexes with their tables and reported
163
+ this damage as `ok`. On earlier releases, open a live database only with
164
+ `sqlite3 -readonly`.
165
+
9
166
  ## [0.9.17] - 2026-09-29
10
167
 
11
168
  ### Added
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",
@@ -888,7 +888,7 @@ export const HEALTH_CHECKS = [
888
888
  // R0: nothing looked at state.db's own SQLite-level integrity before
889
889
  // this — the round-trip probe above only proves one row can be appended
890
890
  // and read back, which stays true on a database that fails
891
- // `PRAGMA quick_check` elsewhere (corrupt indexes, out-of-order rowids).
891
+ // `PRAGMA integrity_check` elsewhere (corrupt indexes, out-of-order rowids).
892
892
  // Also reports the freelist ratio (fraction of pages VACUUM could
893
893
  // reclaim) so a bloated-but-uncorrupted file is visible as a warning
894
894
  // rather than silence.
@@ -904,8 +904,8 @@ export const HEALTH_CHECKS = [
904
904
  kind: "deterministic",
905
905
  status: "fail",
906
906
  confidence: "high",
907
- message: `state.db failed PRAGMA quick_check: ${detail}. Repair: back up state.db, then run ` +
908
- `sqlite3 state.db ".recover" | sqlite3 state.new.db, verify state.new.db passes quick_check, stop ` +
907
+ message: `state.db failed PRAGMA integrity_check: ${detail}. Repair: back up state.db, then run ` +
908
+ `sqlite3 -readonly state.db ".recover" | sqlite3 state.new.db, verify state.new.db passes integrity_check, stop ` +
909
909
  "every akm process, then delete state.db-wal and state.db-shm before swapping state.new.db in as " +
910
910
  "state.db — a leftover WAL from the OLD database is replayed onto the new one and corrupts it.",
911
911
  evidence: { path: ctx.stateDbPath, lines, freelistRatio },
@@ -917,7 +917,7 @@ export const HEALTH_CHECKS = [
917
917
  kind: "deterministic",
918
918
  status: "fail",
919
919
  confidence: "high",
920
- message: `state.db passed PRAGMA quick_check, but reading its freelist/page-count failed: ${freelistError}.`,
920
+ message: `state.db passed PRAGMA integrity_check, but reading its freelist/page-count failed: ${freelistError}.`,
921
921
  evidence: { path: ctx.stateDbPath, lines, freelistError },
922
922
  };
923
923
  }
@@ -928,8 +928,8 @@ export const HEALTH_CHECKS = [
928
928
  status: freelistWarn ? "warn" : "pass",
929
929
  confidence: "high",
930
930
  message: freelistWarn
931
- ? `state.db passed PRAGMA quick_check, but ${(freelistRatio * 100).toFixed(1)}% of its pages are free (reclaimable by VACUUM).`
932
- : "state.db passed PRAGMA quick_check.",
931
+ ? `state.db passed PRAGMA integrity_check, but ${(freelistRatio * 100).toFixed(1)}% of its pages are free (reclaimable by VACUUM).`
932
+ : "state.db passed PRAGMA integrity_check.",
933
933
  evidence: {
934
934
  path: ctx.stateDbPath,
935
935
  freelistCount: ctx.stateDbFreelist.freelistCount,
@@ -17,7 +17,7 @@ import { countImproveRunsSince } from "../storage/repositories/improve-runs-repo
17
17
  import { closeDatabase, openReadonlyExistingDatabase } from "../storage/repositories/index-connection.js";
18
18
  import { getAllEntries } from "../storage/repositories/index-entries-repository.js";
19
19
  import { queryTaskHistory } from "../storage/repositories/task-history-repository.js";
20
- import { getStateDbFreelistInfo, runStateDbQuickCheck } from "../storage/state-db-integrity.js";
20
+ import { getStateDbFreelistInfo, runStateDbIntegrityCheck } from "../storage/state-db-integrity.js";
21
21
  import { pkgVersion } from "../version.js";
22
22
  import { collectArchiveUsageAdvisory } from "./health/archive-usage.js";
23
23
  import { HEALTH_CHECKS, probeActiveImproveStrategy, runHealthEngineProbes, runPendingStateMigrationsCheck, SESSION_EXTRACTION_LEDGER_WINDOW_DAYS, } from "./health/checks.js";
@@ -182,10 +182,10 @@ function gatherTaskHistoryPhase(db, since, stateDbPath, now) {
182
182
  const requiredTables = ["events", "proposals", "schema_migrations", "task_history"];
183
183
  const missingTables = requiredTables.filter((name) => !tableNames.includes(name));
184
184
  const probe = probeStateDbRoundTrip(stateDbPath);
185
- // R0: read-only, independent of the round-trip probe above — quick_check
185
+ // R0: read-only, independent of the round-trip probe above — integrity_check
186
186
  // catches corruption a successful append/read cannot (out-of-order rowids,
187
187
  // bad index entry counts), and the freelist reading is purely informational.
188
- const stateDbIntegrity = runStateDbQuickCheck(stateDbPath);
188
+ const stateDbIntegrity = runStateDbIntegrityCheck(stateDbPath);
189
189
  const stateDbFreelist = getStateDbFreelistInfo(stateDbPath);
190
190
  // D8 (spec §5.3): a marked "command" row or a legacy (unmarked) "prompt"
191
191
  // row is the agent/LLM arm; an unmarked "command" row is the legacy
@@ -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
@@ -635,7 +664,7 @@ seams = {}) {
635
664
  // also be judged as part of another until that decision resolves.
636
665
  const pendingRetireRefs = new Set();
637
666
  try {
638
- for (const p of listProposalsReadOnly(stashDir, { status: "pending" })) {
667
+ for (const p of listProposalsReadOnly(stashDir, { status: "pending" }, opts.proposalsCtx)) {
639
668
  if (!isRetireProposal(p))
640
669
  continue;
641
670
  pendingRetireRefs.add(stripBundle(p.ref));
@@ -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 })) {
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