akm-cli 0.10.261007-beta → 0.10.261009-beta

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 (43) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/STABILITY.md +10 -4
  3. package/dist/api.d.ts +6 -0
  4. package/dist/api.js +12 -1
  5. package/dist/assets/prompts/consolidate-pair-check.md +2 -0
  6. package/dist/assets/prompts/consolidate-pair-same-age.md +1 -0
  7. package/dist/assets/prompts/consolidate-pair-terms.md +6 -0
  8. package/dist/assets/prompts/consolidate-pair.md +4 -12
  9. package/dist/commands/health.js +1 -2
  10. package/dist/commands/improve/consolidate/pair-pass.js +59 -31
  11. package/dist/commands/improve/extract.js +18 -12
  12. package/dist/commands/improve/memory/memory-belief.js +2 -2
  13. package/dist/commands/proposal/repository.js +44 -13
  14. package/dist/commands/read/curate.js +77 -18
  15. package/dist/commands/read/search-cli.js +25 -11
  16. package/dist/commands/read/search.js +3 -3
  17. package/dist/commands/read/show.js +47 -1
  18. package/dist/core/adapter/adapters/akm-adapter.js +1 -0
  19. package/dist/core/adapter/adapters/llm-wiki-adapter.js +3 -5
  20. package/dist/core/adapter/adapters/okf-adapter.js +3 -16
  21. package/dist/core/asset/markdown-link-targets.js +43 -0
  22. package/dist/core/common.js +17 -2
  23. package/dist/core/config/config-io.js +14 -4
  24. package/dist/indexer/passes/metadata.js +17 -0
  25. package/dist/indexer/scan/doc-to-entry.js +1 -0
  26. package/dist/indexer/search/db-search.js +16 -7
  27. package/dist/indexer/search/search-hit-enrichers.js +3 -3
  28. package/dist/output/shapes/curate.js +3 -1
  29. package/dist/output/shapes/helpers.js +6 -0
  30. package/dist/output/text/show-format.js +7 -0
  31. package/dist/scripts/akm-migrate-node.js +1824 -324
  32. package/dist/scripts/akm-migrate.js +1824 -324
  33. package/dist/storage/repositories/index-entries-repository.js +16 -0
  34. package/dist/storage/repositories/index-fts-repository.js +28 -6
  35. package/dist/storage/repositories/index-vec-repository.js +19 -3
  36. package/docs/architecture/0.10-process-rules.md +4 -4
  37. package/docs/architecture/architecture.md +4 -0
  38. package/docs/architecture/persisted-data-compat.md +1 -1
  39. package/docs/reference/api.md +19 -1
  40. package/docs/reference/cli.md +56 -4
  41. package/docs/reference/data-and-telemetry.md +1 -1
  42. package/docs/reference/memory.md +7 -6
  43. package/package.json +2 -1
package/CHANGELOG.md CHANGED
@@ -6,6 +6,78 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.10.261009] - 2026-10-21
10
+
11
+ ### Changed
12
+
13
+ - Search and curate default to `--belief current`, retaining unflagged reference
14
+ knowledge and active/asserted assets while excluding historical beliefs (#1199).
15
+ Use `--belief all` for broad investigation or `--belief historical` for old
16
+ evidence. Curate accepts search's repeatable `--filter` scope controls, and the
17
+ public `akm-cli/api` options add `source`, normalized `filters`, and `belief`.
18
+ The same eligibility follows derived surfaces, linked support and packed
19
+ content; historical results are labelled. Belief labels do not establish factual
20
+ truth. No migration or reindex is required; direct show remains available. The default
21
+ also applies to `akm ui` search, `akm remember --show-similar` and the related assets
22
+ distill reads.
23
+
24
+ ### Fixed
25
+
26
+ - Search applies eligibility filters (scope, source, type, belief state, proposed quality)
27
+ before each channel's candidate limit instead of after it (#1198). An eligible asset ranked
28
+ below many ineligible ones in the keyword or semantic channel is no longer dropped before
29
+ filtering. The vector channel checks eligibility in similarity order, so unfiltered searches
30
+ load only the top candidates. No index migration or reindex is needed.
31
+
32
+ - Extraction proposals retain captured `evidence`, optional guidance and known session sources
33
+ in the Markdown actually published by acceptance (#1196). Reindexing and full/agent `show` preserve
34
+ the support; compact discovery continues to provide refs for follow-up. Earlier pending
35
+ proposals require explicit review/repair of their separate evidence payload; existing accepted
36
+ notes are not backfilled. Run `akm index --full` to project evidence already present in old files.
37
+ - Invalid health comparison windows are rejected before runtime probes can prepend an unrelated
38
+ installed-agent advisory to the JSON usage error.
39
+
40
+ - OKF and LLM Wiki indexing no longer records relationships from links inside code spans,
41
+ fenced or indented code, including code nested in lists and blockquotes (#1200). Links are
42
+ read with the `marked` tokenizer (new runtime dependency) instead of raw regex scans; the
43
+ indexed link syntax is otherwise unchanged. Run `akm index --full` once to drop false
44
+ relationships stored by earlier builds; incremental indexing keeps unchanged files' old links.
45
+
46
+ - Consolidation preserves the existing `scope_user`, `scope_agent`, `scope_run`, and
47
+ `scope_channel` tuple (#1197). Pairing requires exact normalized equality before
48
+ consuming an eligible neighbor slot. Retirement acceptance, including resume, checks
49
+ live scopes against the judged tuple; promotion refuses incompatible source/output
50
+ scopes and preserves differently scoped derived twins. Older retirements without a
51
+ recorded judged tuple remain readable but require rejection/reassessment before
52
+ acceptance. Staged stale decisions use the existing stale-target rejection/retry
53
+ path. No database migration is needed; review pending work before unattended drain.
54
+
55
+ - Consolidation assesses complete note bodies in both retirement checks instead of silently
56
+ cutting each at 12,000 characters. Requests exceeding the selected engine's context window fail conservatively;
57
+ failed second checks leave proposals pending and unstaged. Before unattended proposal drain,
58
+ review and reject/reassess pending or staged retirements produced by older builds when either
59
+ body exceeded the former cutoff. An upgrade does not revalidate those decisions. No storage
60
+ migration is needed; accepted retirements remain recoverable with `akm proposal revert <id>`.
61
+
62
+ ### Changed
63
+
64
+ - The consolidation pair judge reports claim differences; AKM derives `duplicate`, `subsumed`,
65
+ or `overlap` and the redundant side from those lists. It no longer asks the model to repeat
66
+ that decision in relation/pointer fields. Replacement retirements use `subsumed`; historical
67
+ queued labels remain readable. Lexical attention hints accompany complete bodies and never
68
+ substitute for assessing their meaning.
69
+
70
+ ## [0.10.261008] - 2026-10-20
71
+
72
+ ### Fixed
73
+
74
+ - **`akm remember`, config saves and other writes work on filesystems that refuse chmod.** On a CIFS/SMB mount
75
+ such as an Azure Files share, chmod fails with `EPERM`, and akm treated that as fatal: every atomic write
76
+ (`akm remember`, `akm setup`, `akm env`, `akm secret`) and every config backup failed. akm now keeps the mode the
77
+ file was created with when the filesystem refuses chmod (`EPERM`, `ENOTSUP`, `EINVAL`); the mount options decide
78
+ it there, so `env/`, `secrets/` and config backups are not guaranteed owner-only on such a share. Any other chmod
79
+ error still fails the write.
80
+
9
81
  ## [0.10.261007] - 2026-10-20
10
82
 
11
83
  ### Added
package/STABILITY.md CHANGED
@@ -471,10 +471,16 @@ lane (#1143): the `default` strategy runs it without the opt-in, bounded by
471
471
  `akm proposal drain --promote`. Only a promote that turns the judgment tier on
472
472
  needs `experimental.improveAutonomy`.
473
473
 
474
- Consolidation remains enabled with autonomy off: both its passes (promotion,
475
- and the pair pass's duplicate/subsumed/supersedes judging) only ever emit a
476
- reviewable proposal, and a pair-pass `retire` proposal is never auto-accepted
477
- by `triage` `applyMode: "promote"` regardless of this gate.
474
+ Consolidation remains enabled with autonomy off. Both passes emit proposals;
475
+ the pair pass stages a retirement when its claim checks pass and there is no
476
+ continuity risk. `triage` with `applyMode: "promote"` can accept that staged
477
+ retirement unattended; an unstaged retirement stays pending. Both retirement
478
+ checks receive complete note bodies with lexical attention hints, so the selected
479
+ engine must accommodate them. The pair judge supplies distinct-claim lists; AKM
480
+ derives duplicate/subsumed/overlap outcomes and retirement direction from those
481
+ lists. Historical queued retirement labels remain readable. Before unattended drain after an upgrade, review pending/staged
482
+ retirements produced by older builds from notes over 12,000 body characters:
483
+ upgrading does not revalidate their evidence (#1195).
478
484
 
479
485
  Because the gate is applied before the LLM preflight, a review-first workspace
480
486
  also needs fewer engines configured: a strategy whose only model-backed process
package/dist/api.d.ts CHANGED
@@ -10,6 +10,12 @@ export interface CurateOptions {
10
10
  limit?: number;
11
11
  /** Asset type filter, same as `--type`. */
12
12
  type?: string;
13
+ /** Named bundle or local|registry|all, same as --from. */
14
+ source?: string;
15
+ /** AND-joined exact scope values, same as repeated --filter. */
16
+ filters?: { user?: string; agent?: string; run?: string; channel?: string };
17
+ /** Default current; labels do not establish factual truth. */
18
+ belief?: "all" | "current" | "historical";
13
19
  /** Output format, same as `--format`. Default `"json"`, as in the CLI. */
14
20
  format?: "text" | "json";
15
21
  }
package/dist/api.js CHANGED
@@ -35,7 +35,18 @@ export async function curate(query, options = {}) {
35
35
  const mode = resolveOutputMode(["--detail", "agent", "--format", format], loadConfig().output ?? {});
36
36
  const disposeLlmUsageSink = installLlmUsagePersistenceIfAbsent();
37
37
  try {
38
- const curated = await runCurate({ query, type: options.type, limit: options.limit === undefined ? undefined : String(options.limit) }, mode);
38
+ const curated = await runCurate({
39
+ query,
40
+ type: options.type,
41
+ limit: options.limit === undefined ? undefined : String(options.limit),
42
+ from: options.source,
43
+ belief: options.belief,
44
+ filters: options.filters === undefined
45
+ ? undefined
46
+ : Object.entries(options.filters)
47
+ .filter(([, value]) => value !== undefined)
48
+ .map(([key, value]) => `${key}=${value}`),
49
+ }, mode);
39
50
  return stdoutText(renderOutput("curate", curated, { ...mode, format }));
40
51
  }
41
52
  finally {
@@ -2,4 +2,6 @@ You check whether deleting note X from one person's agent-memory library would l
2
2
 
3
3
  A durable claim is a fact, decision, value, command, flag, path, file name, URL, number, version, name, error message, condition or rule someone would act on. Ignore wording, headings, dates of writing, tags and boilerplate. A claim is stated when Y says it in any wording or format, or replaces it with a newer value. It is not stated when Y is vaguer (a config file instead of the specific path) or drops a number, command, step or condition. Read X's description as part of X. Write each item as a short phrase that quotes its identifier. Use [] when Y states everything.
4
4
 
5
+ Check the complete X, including its ending, one claim at a time against Y. A missing claim must actually be stated by X and absent from Y in meaning, not just in wording. Extra detail stated only by Y does not belong in missing. Repetition adds no claim. Before answering, remove any item that Y already states or replaces for the same named thing.
6
+
5
7
  Answer ONLY with JSON: {"missing": ["..."]}
@@ -0,0 +1 @@
1
+ The two notes have the same creation date: neither is newer, so the chronological replacement rule does not apply. Different values for the same named thing conflict unless the text explicitly says one replaces the other. Treat an unresolved conflicting value as a distinct claim. A note's position in this prompt does not establish which value is current.
@@ -0,0 +1,6 @@
1
+ Lexical attention hints from the complete notes above:
2
+ {{DIFFERENCES}}
3
+
4
+ Check these terms in their original context. They are not claim lists: different tokens can express the same claim or a replaced value, and equal token sets do not prove equal claims. Assess the complete notes, including claims that these hints do not distinguish.
5
+
6
+ Before leaving a side's claim list empty, check the source sentence of every exclusive term. Commands, flags, paths, URLs, numbers, versions and conditions must keep their specific meaning: a shared generic word does not preserve an omitted command or rule. Verify that every claim you list is absent from the other complete note in meaning.
@@ -1,6 +1,6 @@
1
- You compare two assets from one person's agent-memory library (memories and knowledge notes an AI coding agent reads). Asset A is the OLDER one and asset B the NEWER one, by the dates shown, unless both carry the same date and are labelled "same age". akm deletes one of them only when the other keeps every claim a reader would need, so your two lists decide what is safe to delete.
1
+ You compare the complete claims of two assets from one person's agent-memory library. Use the supplied dates: when both dates are equal, neither note is newer. A note can be deleted only when the other preserves every claim it states.
2
2
 
3
- First list what each asset has that the other lacks:
3
+ Compare the complete meaning of both assets before answering. Claims are equivalent when they give a reader the same facts, conditions and action, even with different wording. For example, "the build runs `make test`" and "`make test` is the build test command" are the same claim; `make test-fast` is a different command. First give a concise account of what distinguishes the claims of the two notes, including whether a value is explicitly replaced. There may be no safe deletion. Then list only claims that the other asset genuinely lacks:
4
4
  - "onlyInA": every durable claim of A that B does not state or replace.
5
5
  - "onlyInB": every durable claim of B that A does not state or replace.
6
6
 
@@ -8,14 +8,6 @@ A durable claim is a fact, decision, value, command, flag, path, file name, URL,
8
8
 
9
9
  B replaces a claim of A only when both are about the same named thing (the same service, integration, partner, component, region or file) and B gives a new value for it that is current: B says so (now, instead of, since, renamed, corrected, no longer) or B is created later than A. Notes about different named things never replace each other, whatever their dates: list each one's own name, value and path as its claims. A different value for the same thing with neither sign is a conflict, not a replacement: list A's value in "onlyInA" and B's value in "onlyInB".
10
10
 
11
- Then classify the relation as exactly one of:
12
- - "duplicate": both lists are empty.
13
- - "subsumed": exactly one list is empty. The asset with the empty list is redundant.
14
- - "supersedes": B replaces a claim in A as defined above (a newer version, a changed decision, a fixed bug, a new value), and onlyInA is empty.
15
- - "contradicts": they make logically exclusive claims about the same thing and nothing shows which one is current: B does not say it replaces the claim and was not created later.
16
- - "overlap": same subject, and both lists have items.
17
- - "unrelated": different subjects, or different named things written to one layout (two services, partners or regions), or different facts that happen to share words.
11
+ Before answering, check the final claim lists against the complete notes, including their endings. Every item in onlyInA must be stated by A and neither stated in other wording nor replaced by B; check onlyInB symmetrically. Remove equivalent or replaced claims. The reason must agree with the final lists.
18
12
 
19
- Set "redundant" to the asset that could be deleted with no loss: "A" for "duplicate", the asset with the empty list for "subsumed", "A" for "supersedes"; else null. Set "stale" to "A" when the relation is "supersedes", else null.
20
-
21
- Answer ONLY with JSON: {"onlyInA": ["..."], "onlyInB": ["..."], "relation": "...", "redundant": "A"|"B"|null, "stale": "A"|null, "confidence": 0.0-1.0, "reason": "<at most 25 words: name the asset that can be deleted and what the other asset still holds; for supersedes, quote the words of B that replace the claim or say B is created later>"}
13
+ Answer ONLY with JSON: {"reason": "<at most 25 words: the distinct claims of each side, or that their claims are equivalent; quote any explicit replacement>", "onlyInA": ["..."], "onlyInB": ["..."], "confidence": 0.0-1.0}
@@ -580,6 +580,7 @@ export async function akmHealth(options = {}) {
580
580
  }
581
581
  const { db } = opened;
582
582
  try {
583
+ const { windowResults, deltas } = resolveWindowComparePhase(options, db, stateDbPath, now);
583
584
  // Network probes overlap the local database phases below; awaited where consumed.
584
585
  const engineProbesPromise = runHealthEngineProbes({ probeReachable: options.probe ? probeLlmEndpoint : undefined });
585
586
  engineProbesPromise.catch(() => undefined);
@@ -666,8 +667,6 @@ export async function akmHealth(options = {}) {
666
667
  const hardFailure = hardChecks.some((check) => check.status === "fail");
667
668
  const deterministicWarnings = [...hardChecks, ...advisories].some((check) => check.status === "warn" && check.kind === "deterministic");
668
669
  const status = hardFailure ? "fail" : deterministicWarnings ? "warn" : "pass";
669
- // ── Window-compare mode (Phase 3) ─────────────────────────────────────
670
- const { windowResults, deltas } = resolveWindowComparePhase(options, db, stateDbPath, now);
671
670
  // ── Per-run mode (Phase 2) ────────────────────────────────────────────
672
671
  const runs = options.groupBy === "run" ? perRunSummaries : undefined;
673
672
  return {
@@ -5,15 +5,13 @@
5
5
  * The consolidate pair pass (0.9.17-alpha.9 plan §5.2, brief §A): runs
6
6
  * alongside the existing promote pass inside `akmConsolidate`, over new and
7
7
  * changed memory-tier assets. Judges each near-duplicate/superseding pair
8
- * with the calibrated relation prompt and mints a reviewed `retire` proposal
9
- * for the `duplicate` / `subsumed` / `supersedes` classes — never merges,
10
- * never writes belief edges for `contradicts`, and is never auto-accepted by
11
- * triage (`drain.ts`).
8
+ * with complete claim comparisons and mints a reviewed `retire` proposal
9
+ * for derived `duplicate` / `subsumed` outcomes — never merges,
10
+ * never writes belief edges. A staged retirement can be accepted unattended
11
+ * by triage (`drain.ts`); an unstaged one stays pending.
12
12
  *
13
- * Calibration (owner grades, 2026-09-28; see the plan's "Human calibration,
14
- * O5" section): the combined retire class (duplicate ∪ subsumed ∪
15
- * supersedes) is 20/22 = 0.91 precision against the owner. `T_PAIR` is 0.93,
16
- * not the initial 0.90, because the 0.90–0.93 band alone graded 3/4 (R1).
13
+ * Pair-selection calibration (owner grades, 2026-09-28): `T_PAIR` stays
14
+ * 0.93, not the initial 0.90, because the 0.90–0.93 band alone graded 3/4 (R1).
17
15
  *
18
16
  * Initiator eligibility and dating (post-review, alpha.9): "created" is the
19
17
  * asset's git first-add time (one `git log` per run, {@link loadGitFirstAddedMap}),
@@ -30,6 +28,8 @@ import fs from "node:fs";
30
28
  import path from "node:path";
31
29
  import consolidatePairPrompt from "../../../assets/prompts/consolidate-pair.md" with { type: "text" };
32
30
  import consolidatePairCheckPrompt from "../../../assets/prompts/consolidate-pair-check.md" with { type: "text" };
31
+ import consolidatePairSameAge from "../../../assets/prompts/consolidate-pair-same-age.md" with { type: "text" };
32
+ import consolidatePairTerms from "../../../assets/prompts/consolidate-pair-terms.md" with { type: "text" };
33
33
  import { parseFrontmatter } from "../../../core/asset/frontmatter.js";
34
34
  import { conceptIdFromTypeName } from "../../../core/asset/resolve-ref.js";
35
35
  import { asNonEmptyString } from "../../../core/common.js";
@@ -37,6 +37,7 @@ import { concurrentMap } from "../../../core/concurrent.js";
37
37
  import { parseEmbeddedJsonResponse } from "../../../core/parse.js";
38
38
  import { DERIVED_SUFFIX } from "../../../core/recognition-util.js";
39
39
  import { warnOnce } from "../../../core/warn.js";
40
+ import { sameScope, scopeFromFrontmatter } from "../../../indexer/passes/metadata.js";
40
41
  import { runnerLlmConnection } from "../../../integrations/agent/runner.js";
41
42
  import { assertRunnerCredentials } from "../../../integrations/agent/runner-dispatch.js";
42
43
  import { runGit } from "../../../sources/providers/git-install.js";
@@ -67,28 +68,22 @@ export const BACKFILL_FLOOR = 0.95;
67
68
  export const NEW_MATERIAL_DAYS = 7;
68
69
  /** Pairs judged per run, highest cosine first (plan §7's nightly cost budget). */
69
70
  export const MAX_PAIRS_PER_RUN = 300;
70
- /** Body characters sent to the judge per side: enough for nearly every note, so a retirement is judged on the whole text. */
71
- const PAIR_BODY_TRUNCATE_CHARS = 12_000;
72
71
  const MS_PER_DAY = 86_400_000;
73
- const RELATION_LABELS = ["duplicate", "subsumed", "supersedes", "contradicts", "overlap", "unrelated"];
74
72
  const RETIRE_LABELS = new Set(["duplicate", "subsumed", "supersedes"]);
75
- /** The classes that mint a `retire` proposal (the calibrated combined retire class, 20/22 precision). */
73
+ /** The labels represented by the existing retirement metadata. */
76
74
  function isRetireLabel(label) {
77
75
  return RETIRE_LABELS.has(label);
78
76
  }
79
77
  /** Exported, with {@link buildPairUserPrompt}, so a replay can drive the exact judge call. */
80
78
  export const PAIR_JUDGE_JSON_SCHEMA = {
81
79
  type: "object",
82
- required: ["onlyInA", "onlyInB", "relation", "redundant", "stale", "confidence", "reason"],
80
+ required: ["reason", "onlyInA", "onlyInB", "confidence"],
83
81
  additionalProperties: false,
84
82
  properties: {
83
+ reason: { type: "string", maxLength: 400 },
85
84
  onlyInA: { type: "array", items: { type: "string", maxLength: 200 }, maxItems: 20 },
86
85
  onlyInB: { type: "array", items: { type: "string", maxLength: 200 }, maxItems: 20 },
87
- relation: { type: "string", enum: [...RELATION_LABELS] },
88
- redundant: { type: ["string", "null"], enum: ["A", "B", null] },
89
- stale: { type: ["string", "null"], enum: ["A", null] },
90
86
  confidence: { type: "number", minimum: 0, maximum: 1 },
91
- reason: { type: "string", maxLength: 400 },
92
87
  },
93
88
  };
94
89
  export const PAIR_CHECK_JSON_SCHEMA = {
@@ -97,17 +92,11 @@ export const PAIR_CHECK_JSON_SCHEMA = {
97
92
  additionalProperties: false,
98
93
  properties: { missing: { type: "array", items: { type: "string", maxLength: 200 }, maxItems: 20 } },
99
94
  };
100
- /** Hand-validates the judge's JSON, independent of whatever the provider's own schema enforcement did. */
95
+ /** Validate both claim lists, then derive the outcome instead of asking the model to duplicate that decision. */
101
96
  export function parsePairJudgeResponse(raw) {
102
97
  const parsed = parseEmbeddedJsonResponse(raw);
103
98
  if (!parsed)
104
99
  return undefined;
105
- if (typeof parsed.relation !== "string" || !RELATION_LABELS.includes(parsed.relation)) {
106
- return undefined;
107
- }
108
- const redundant = parsed.redundant;
109
- if (redundant !== "A" && redundant !== "B" && redundant !== null)
110
- return undefined;
111
100
  if (typeof parsed.confidence !== "number" || !Number.isFinite(parsed.confidence))
112
101
  return undefined;
113
102
  const confidence = Math.max(0, Math.min(1, parsed.confidence));
@@ -116,7 +105,13 @@ export function parsePairJudgeResponse(raw) {
116
105
  const onlyInB = claimList(parsed.onlyInB);
117
106
  if (!onlyInA || !onlyInB)
118
107
  return undefined;
119
- return { onlyInA, onlyInB, relation: parsed.relation, redundant, confidence, reason };
108
+ const relation = onlyInA.length === 0 && onlyInB.length === 0
109
+ ? "duplicate"
110
+ : onlyInA.length === 0 || onlyInB.length === 0
111
+ ? "subsumed"
112
+ : "overlap";
113
+ const redundant = onlyInA.length === 0 ? "A" : onlyInB.length === 0 ? "B" : null;
114
+ return { onlyInA, onlyInB, relation, redundant, confidence, reason };
120
115
  }
121
116
  function claimList(value) {
122
117
  if (!Array.isArray(value) || !value.every((v) => typeof v === "string"))
@@ -383,6 +378,10 @@ export function selectCandidates(db, initiators, bundleId, unvectored) {
383
378
  // rest of the backlog (no row, not recently git-added) needs the higher
384
379
  // BACKFILL_FLOOR.
385
380
  const floor = initiator.backlog && !initiator.newMaterial ? BACKFILL_FLOOR : T_PAIR;
381
+ const initiatorSide = loadSide(initiator, undefined, "");
382
+ if (!initiatorSide)
383
+ continue;
384
+ const scope = scopeFromFrontmatter(initiatorSide.frontmatter);
386
385
  let kept = 0;
387
386
  for (const hit of getNeighborsByEntryId(db, initiator.entryId, PAIR_NEIGHBOR_FETCH_K)) {
388
387
  if (kept >= PAIR_NEIGHBOR_K)
@@ -411,6 +410,9 @@ export function selectCandidates(db, initiators, bundleId, unvectored) {
411
410
  const cosine = 1 - (hit.distance * hit.distance) / 2;
412
411
  if (cosine < floor)
413
412
  continue;
413
+ const otherSide = loadSide(other, undefined, "");
414
+ if (!otherSide || !sameScope(scope, scopeFromFrontmatter(otherSide.frontmatter)))
415
+ continue;
414
416
  const key = pairKey(initiator.ref, other.ref);
415
417
  if (seenPairs.has(key))
416
418
  continue;
@@ -460,11 +462,22 @@ function sideSection(label, side) {
460
462
  `Description: ${asNonEmptyString(side.frontmatter.description) ?? "(none)"}`,
461
463
  "Content:",
462
464
  "```",
463
- stripFrontmatterBody(side.raw).slice(0, PAIR_BODY_TRUNCATE_CHARS),
465
+ stripFrontmatterBody(side.raw),
464
466
  "```",
465
467
  "",
466
468
  ];
467
469
  }
470
+ /** Lexical attention hints only: equal token sets do not prove equal claims. */
471
+ function exclusiveTerms(side, other) {
472
+ const tokens = (note) => new Set(`${asNonEmptyString(note.frontmatter.description) ?? ""}\n${stripFrontmatterBody(note.raw)}`
473
+ .split(/\s+/)
474
+ .filter(Boolean));
475
+ const otherTerms = tokens(other);
476
+ return JSON.stringify([...tokens(side)].filter((term) => !otherTerms.has(term)));
477
+ }
478
+ function termHints(lines) {
479
+ return consolidatePairTerms.replace("{{DIFFERENCES}}", lines.join("\n"));
480
+ }
468
481
  /** Orders two loaded sides by date (older/newer) for the "A (older)"/"B (newer)" labelling the prompt requires. */
469
482
  function orderByAge(x, y) {
470
483
  const xMs = Date.parse(x.createdIso);
@@ -480,6 +493,11 @@ export function buildPairUserPrompt(older, newer) {
480
493
  return [
481
494
  ...sideSection(tied ? "A (same age as B)" : "A (older)", older),
482
495
  ...sideSection(tied ? "B (same age as A)" : "B (newer)", newer),
496
+ termHints([
497
+ `Tokens only in A: ${exclusiveTerms(older, newer)}`,
498
+ `Tokens only in B: ${exclusiveTerms(newer, older)}`,
499
+ ]),
500
+ ...(tied ? [consolidatePairSameAge] : []),
483
501
  ].join("\n");
484
502
  }
485
503
  /** The tombstone-vocabulary reason a judge label maps to (`supersedes` -> `superseded`; the rest unchanged). */
@@ -489,8 +507,7 @@ export function tombstoneReason(label) {
489
507
  /**
490
508
  * The calibrated outcome table (owner grades, replacing plan §5.2's
491
509
  * "shorter body" rule): `duplicate`/`supersedes` keep the newer copy;
492
- * `subsumed` keeps the side the judge did NOT name `redundant` (no proposal
493
- * if that pointer is missing or invalid). A side is retired only when the
510
+ * `subsumed` keeps the side opposite the derived `redundant` pointer. A side is retired only when the
494
511
  * judge listed nothing that it alone holds.
495
512
  */
496
513
  export function decideRetirement(label, redundant, older, newer, only) {
@@ -512,7 +529,7 @@ function checkSection(label, side) {
512
529
  `Description: ${asNonEmptyString(side.frontmatter.description) ?? "(none)"}`,
513
530
  "Content:",
514
531
  "```",
515
- stripFrontmatterBody(side.raw).slice(0, PAIR_BODY_TRUNCATE_CHARS),
532
+ stripFrontmatterBody(side.raw),
516
533
  "```",
517
534
  "",
518
535
  ].join("\n");
@@ -528,7 +545,7 @@ async function confirmNothingLost(ctx, retired, successor) {
528
545
  feature: "memory_consolidation",
529
546
  runner: ctx.llmRunner,
530
547
  system: consolidatePairCheckPrompt,
531
- prompt: `${checkSection("X (to delete)", retired)}\n${checkSection("Y (kept)", successor)}`,
548
+ prompt: `${checkSection("X (to delete)", retired)}\n${checkSection("Y (kept)", successor)}\n${termHints([`Tokens only in X: ${exclusiveTerms(retired, successor)}`])}${Date.parse(retired.createdIso) === Date.parse(successor.createdIso) ? `\n${consolidatePairSameAge}` : ""}`,
532
549
  gate: { config: ctx.config, enabled: true },
533
550
  request: {
534
551
  responseSchema: PAIR_CHECK_JSON_SCHEMA,
@@ -554,6 +571,13 @@ async function judgeOne(ctx, candidate) {
554
571
  const otherSide = loadSide(candidate.other, ctx.gitFirstAdded, ctx.stashDir);
555
572
  if (!initiatorSide || !otherSide)
556
573
  return { failed: false }; // unreadable since selection — skip, not a judge failure
574
+ const judgedScope = scopeFromFrontmatter(initiatorSide.frontmatter);
575
+ if (!sameScope(judgedScope, scopeFromFrontmatter(otherSide.frontmatter)))
576
+ return { failed: false };
577
+ const scopesStillFresh = () => [initiatorSide, otherSide].every((side) => {
578
+ const live = loadSide(side.asset, ctx.gitFirstAdded, ctx.stashDir);
579
+ return live !== undefined && sameScope(judgedScope, scopeFromFrontmatter(live.frontmatter));
580
+ });
557
581
  // Item 0 / S1: this exact pair (same two refs, same two content hashes,
558
582
  // EITHER orientation) was already judged and rejected OR reverted. Checked
559
583
  // HERE, before the judge call, not after — the judge (not yet run) is what
@@ -651,7 +675,10 @@ async function judgeOne(ctx, candidate) {
651
675
  ledgerAccess: { proposalsCtx: ctx.opts.proposalsCtx },
652
676
  search: ctx.continuitySearch,
653
677
  });
678
+ if (!scopesStillFresh())
679
+ return { failed: true };
654
680
  const retirement = {
681
+ judgedScope,
655
682
  retiredRef: retired.asset.ref,
656
683
  successorRef: successor.asset.ref,
657
684
  cosine: candidate.cosine,
@@ -691,7 +718,8 @@ async function judgeOne(ctx, candidate) {
691
718
  // reviewed pairs (2026-10-04). Anything else waits for a person.
692
719
  if ((verdict.relation !== "duplicate" || verdict.onlyInA.length + verdict.onlyInB.length === 0) &&
693
720
  !continuityRisk &&
694
- (await confirmNothingLost(ctx, retired, successor))) {
721
+ (await confirmNothingLost(ctx, retired, successor)) &&
722
+ scopesStillFresh()) {
695
723
  recordGateDecision(ctx.stashDir, proposal.id, {
696
724
  outcome: "staged",
697
725
  reason: verdict.relation,
@@ -189,17 +189,27 @@ export function parseSinceArg(value, now = Date.now()) {
189
189
  throw new UsageError(`--since value "${value}" could not be parsed (expected ISO timestamp or duration like 24h / 7d / 30m)`, "INVALID_FLAG_VALUE");
190
190
  }
191
191
  /**
192
- * A candidate's ref and content. `description` (and a lesson's `when_to_use`)
193
- * go into the body's frontmatter so accept-time validation sees them.
192
+ * Assemble candidate metadata once in the authoritative Markdown that acceptance
193
+ * publishes. The payload projection is for proposal readers, not another write path.
194
194
  */
195
195
  function buildCandidateProposal(candidate, sourceRef, sessionAssetRef) {
196
196
  const ref = deriveExtractCandidateRef(candidate, sourceRef);
197
197
  // Complete a description the model cut mid-sentence (no-op for valid ones).
198
198
  const description = repairTruncatedDescription(candidate.description, candidate.body);
199
- const fm = { description, ...(sessionAssetRef ? { xrefs: [sessionAssetRef] } : {}) };
200
- if (candidate.type === "lesson" && candidate.when_to_use)
199
+ const fm = {
200
+ description,
201
+ evidence: candidate.evidence,
202
+ ...(sessionAssetRef ? { xrefs: [sessionAssetRef], evidenceSources: [sessionAssetRef] } : {}),
203
+ };
204
+ // With no published session asset, keep the known native session identity in
205
+ // the existing structured provenance channel, never invent an AKM asset ref.
206
+ // The identity, not the log's absolute path, so published notes carry no local paths.
207
+ if (!sessionAssetRef && sourceRef.filePath) {
208
+ fm.provenance = { sources: [{ resource: `${sourceRef.harness}:${sourceRef.sessionId}` }] };
209
+ }
210
+ if (candidate.when_to_use)
201
211
  fm.when_to_use = candidate.when_to_use;
202
- return { ref, content: assembleAsset(fm, candidate.body), description };
212
+ return { ref, content: assembleAsset(fm, candidate.body), frontmatter: fm };
203
213
  }
204
214
  function canonicalSegment(value) {
205
215
  return value
@@ -496,13 +506,9 @@ async function processSession(run, sessionRef, gate) {
496
506
  attemptedRefs: [built.ref],
497
507
  payload: {
498
508
  content: built.content,
499
- frontmatter: {
500
- description: built.description,
501
- ...(candidate.when_to_use ? { when_to_use: candidate.when_to_use } : {}),
502
- confidence: candidate.confidence,
503
- ...(sessionAsset.sessionAssetRef ? { xrefs: [sessionAsset.sessionAssetRef] } : {}),
504
- evidence: candidate.evidence,
505
- },
509
+ // Model confidence stays on the proposal for reviewers; it is not published,
510
+ // where promotion scoring would read it as a signal.
511
+ frontmatter: { ...built.frontmatter, confidence: candidate.confidence },
506
512
  },
507
513
  });
508
514
  proposalIds.push(proposal.id);
@@ -7,8 +7,8 @@
7
7
  * present edge AND demotion is a no-op; an edge without its demotion is
8
8
  * repaired) and never weakens a stronger demotion — severity is
9
9
  * superseded > contradicted > archived. (alpha.4 removed belief weights from
10
- * ranking: search no longer reads `beliefState` at all, and `--belief
11
- * current|historical` is the only reader, opt-in.)
10
+ * ranking: beliefState never changes rank. Search/curate filter current
11
+ * by default; --belief all|historical explicitly includes older context.)
12
12
  * The SCC resolver in memory-improve.ts is a state-transition writer (it
13
13
  * replaces and clears edges) and deliberately does not use this (#885).
14
14
  */
@@ -33,9 +33,10 @@ import { assertAkmAssetWrite, commitAcceptedPaths, commitWriteTargetBoundary, pr
33
33
  import { withAssetMutationLease } from "../../indexer/index-writer-lock.js";
34
34
  import { indexWrittenAssets } from "../../indexer/index-written-assets.js";
35
35
  import { deriveInstallations } from "../../indexer/installations.js";
36
+ import { sameScope, scopeFromFrontmatter } from "../../indexer/passes/metadata.js";
36
37
  import { resolveSourceEntries } from "../../indexer/search/search-source.js";
37
38
  import { insertEventOnce } from "../../storage/repositories/events-repository.js";
38
- import { CONSOLIDATE_LEDGER_SOURCE, forgetImproveLedgerDecision, recordImproveLedger, recordImproveLedgerDecision, reopenImproveLedgerDecision, } from "../../storage/repositories/improve-ledger-repository.js";
39
+ import { CONSOLIDATE_LEDGER_SOURCE, forgetImproveLedgerDecision, PAIR_PASS_LEDGER_SOURCE, recordImproveLedger, recordImproveLedgerDecision, reopenImproveLedgerDecision, } from "../../storage/repositories/improve-ledger-repository.js";
39
40
  import { getStateProposal, listStateProposalIdsByPrefix, listStateProposals, upsertProposal, } from "../../storage/repositories/proposals-repository.js";
40
41
  import { openSqliteReadSnapshot } from "../../storage/sqlite-read-snapshot.js";
41
42
  import { pkgVersion } from "../../version.js";
@@ -528,6 +529,14 @@ export function archiveProposal(stashDir, id, status, reason, ctx, gateDecision)
528
529
  at: decidedAt,
529
530
  ...(reason !== undefined ? { detail: reason } : {}),
530
531
  });
532
+ // The pair pass settles initiators under bare refs, independently of the
533
+ // proposal's durable ref. A stale decision must release both inputs for
534
+ // reassessment through that same ledger, rather than hold an old body forever.
535
+ if (isRetireProposal(updated) && gateDecision?.reason === STALE_TARGET_GATE_REASON) {
536
+ for (const ref of retireHeldRefs(updated)) {
537
+ recordImproveLedger(db, { stashDir, ref, source: PAIR_PASS_LEDGER_SOURCE, outcome: "failed", at: decidedAt });
538
+ }
539
+ }
531
540
  return updated;
532
541
  }));
533
542
  }
@@ -1007,6 +1016,14 @@ export function preflightProposalPromotion(config, proposal, options = {}, ctx)
1007
1016
  // best-effort; the freshness check is the real staleness gate
1008
1017
  }
1009
1018
  }
1019
+ if (prepared.promotionSource) {
1020
+ const sourcePath = resolveAssetFilePathSafe(target.source, parseRefInput(prepared.promotionSource));
1021
+ if (sourcePath &&
1022
+ fs.existsSync(sourcePath) &&
1023
+ !sameScope(scopeFromFrontmatter(parseFrontmatter(fs.readFileSync(sourcePath, "utf8")).data), scopeFromFrontmatter(parseFrontmatter(stampedContent).data))) {
1024
+ throw new UsageError(`Promotion ${proposal.id} has incompatible source/output scope — reject and reassess.`, "INVALID_FLAG_VALUE");
1025
+ }
1026
+ }
1010
1027
  const lintBlockers = promotionLintBlockers(stampedContent, assetPath, target.source.path, ref.type, config);
1011
1028
  if (lintBlockers.length > 0) {
1012
1029
  const summary = lintBlockers.map((finding) => `[${finding.issue}] ${finding.detail}`).join("; ");
@@ -1127,7 +1144,12 @@ function retirePromotionSource(mutationTarget, accepted, paths) {
1127
1144
  warn(`[proposal] O1: ${accepted.id} has no recorded source hash (minted by an older release) — leaving its source ${accepted.promotionSource} unarchived.`);
1128
1145
  return;
1129
1146
  }
1130
- const currentHash = contentHash(fs.readFileSync(sourcePath, "utf8"), "body");
1147
+ const sourceContent = fs.readFileSync(sourcePath, "utf8");
1148
+ if (!sameScope(scopeFromFrontmatter(parseFrontmatter(sourceContent).data), scopeFromFrontmatter(parseFrontmatter(proposalContent(accepted)).data))) {
1149
+ warn(`[proposal] source scope changed for ${accepted.id} — leaving ${accepted.promotionSource} unarchived.`);
1150
+ return;
1151
+ }
1152
+ const currentHash = contentHash(sourceContent, "body");
1131
1153
  if (currentHash !== accepted.promotionSourceHash) {
1132
1154
  warn(`[proposal] O1: source ${accepted.promotionSource} for ${accepted.id} changed since the promotion was queued — leaving it unarchived.`);
1133
1155
  return;
@@ -1141,7 +1163,8 @@ function retirePromotionSource(mutationTarget, accepted, paths) {
1141
1163
  const record = archiveCleanupCandidate(mutationTarget.source.path, candidate, sourcePath);
1142
1164
  paths.push(sourcePath, path.join(mutationTarget.source.path, record.archivedPath), path.join(mutationTarget.source.path, record.auditPath));
1143
1165
  const twin = derivedTwinPath(sourcePath, sourceRef.type);
1144
- if (twin) {
1166
+ if (twin &&
1167
+ sameScope(scopeFromFrontmatter(parseFrontmatter(fs.readFileSync(twin, "utf8")).data), scopeFromFrontmatter(parseFrontmatter(sourceContent).data))) {
1145
1168
  const twinRecord = archiveCleanupCandidate(mutationTarget.source.path, candidate, twin);
1146
1169
  paths.push(twin, path.join(mutationTarget.source.path, twinRecord.archivedPath), path.join(mutationTarget.source.path, twinRecord.auditPath));
1147
1170
  }
@@ -1325,7 +1348,7 @@ function persistRetireAcceptance(stashDir, proposal, info, ctx) {
1325
1348
  }
1326
1349
  /**
1327
1350
  * B2: throws a stale-retire `UsageError` unless the successor still exists
1328
- * and both sides' recorded body hashes still match their current files — the
1351
+ * and both sides' recorded body hashes and judged scopes still match — the
1329
1352
  * durable half of the chain guard. Used for a fresh accept, and (4b, third
1330
1353
  * review round) to re-check a resumed accept whose intent was recorded but
1331
1354
  * nothing has moved yet: a separate proposal accepted in between (e.g. this
@@ -1335,6 +1358,15 @@ function persistRetireAcceptance(stashDir, proposal, info, ctx) {
1335
1358
  function assertRetirementStillFresh(proposalId, proposalRef, retirement, targetSource, retiredCurrentBytes) {
1336
1359
  const successorPath = resolveAssetFilePathSafe(targetSource, parseRefInput(retirement.successorRef));
1337
1360
  const successorBytes = successorPath && fs.existsSync(successorPath) ? fs.readFileSync(successorPath) : undefined;
1361
+ const judgedScope = retirement.judgedScope;
1362
+ if (!judgedScope || typeof judgedScope !== "object" || Array.isArray(judgedScope)) {
1363
+ throw new UsageError(`Retire proposal ${proposalId} is stale — judged scope cannot be established; reject it and reassess the pair before acceptance.`, "INVALID_FLAG_VALUE");
1364
+ }
1365
+ if (!sameScope(scopeFromFrontmatter(parseFrontmatter(retiredCurrentBytes.toString("utf8")).data), judgedScope) ||
1366
+ (successorBytes &&
1367
+ !sameScope(scopeFromFrontmatter(parseFrontmatter(successorBytes.toString("utf8")).data), judgedScope))) {
1368
+ throw new UsageError(`Retire proposal ${proposalId} is stale — scope changed since judging; reject and reassess.`, "INVALID_FLAG_VALUE");
1369
+ }
1338
1370
  const retiredFresh = contentHash(retiredCurrentBytes, "body") === retirement.retiredContentHash;
1339
1371
  const successorFresh = successorBytes !== undefined && contentHash(successorBytes, "body") === retirement.successorContentHash;
1340
1372
  if (!successorBytes || !retiredFresh || !successorFresh) {
@@ -1416,14 +1448,10 @@ async function retireProposalWithLease(stashDir, config, proposal, options, ctx)
1416
1448
  }
1417
1449
  else {
1418
1450
  assertAkmAssetWrite(target.source);
1419
- // 4b (third review round): intent was recorded but Phase 2 never moved
1420
- // anything yet — re-run the B2 freshness check before resuming. A
1421
- // separate proposal accepted in the meantime (this one's successor
1422
- // itself retired by a B->C accept) can make the decision stale even
1423
- // though nothing here changed. Once something has moved, it is too late
1424
- // to cleanly refuse — Phase 2 below already tolerates a partial move.
1425
- if (working.retirement && fs.existsSync(intent.assetPath)) {
1426
- assertRetirementStillFresh(proposal.id, proposal.ref, working.retirement, target.source, fs.readFileSync(intent.assetPath));
1451
+ // Revalidate the survivor even after a partial archive. The recorded original
1452
+ // is the judged retired side when its file has already moved.
1453
+ if (working.retirement) {
1454
+ assertRetirementStillFresh(proposal.id, proposal.ref, working.retirement, target.source, fs.existsSync(intent.assetPath) ? fs.readFileSync(intent.assetPath) : Buffer.from(intent.backupContent));
1427
1455
  }
1428
1456
  }
1429
1457
  // Phase 2: move, idempotently — a resumed call skips whichever file a
@@ -1449,7 +1477,10 @@ async function retireProposalWithLease(stashDir, config, proposal, options, ctx)
1449
1477
  // tombstone scan above (`alreadyDone`) already finds one archived earlier,
1450
1478
  // so storing it was redundant persisted state.
1451
1479
  const twinPath = derivedTwinPath(intent.assetPath, ref.type);
1452
- if (twinPath && !alreadyDone.has(path.resolve(twinPath)) && fs.existsSync(twinPath)) {
1480
+ if (twinPath &&
1481
+ !alreadyDone.has(path.resolve(twinPath)) &&
1482
+ fs.existsSync(twinPath) &&
1483
+ sameScope(scopeFromFrontmatter(parseFrontmatter(fs.readFileSync(twinPath, "utf8")).data), scopeFromFrontmatter(parseFrontmatter(intent.backupContent).data))) {
1453
1484
  const twinRecord = archiveCleanupCandidate(mutationTarget.source.path, candidate, twinPath);
1454
1485
  archiveDirs.push(path.dirname(twinRecord.auditPath));
1455
1486
  paths.push(twinPath, path.join(mutationTarget.source.path, twinRecord.archivedPath), path.join(mutationTarget.source.path, twinRecord.auditPath));