akm-cli 0.10.261008-beta → 0.10.261010-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 (82) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/STABILITY.md +25 -8
  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/assets/prompts/memory-infer-system.md +1 -1
  10. package/dist/assets/prompts/memory-infer-user.md +3 -2
  11. package/dist/assets/ui/app.js +59 -2
  12. package/dist/assets/ui/asset.js +71 -6
  13. package/dist/assets/ui/bundles.js +80 -3
  14. package/dist/assets/ui/diff-view.js +1 -1
  15. package/dist/assets/ui/editor.js +103 -0
  16. package/dist/assets/ui/index.html +2 -0
  17. package/dist/assets/ui/lib.js +51 -1
  18. package/dist/assets/ui/markdown.js +86 -19
  19. package/dist/assets/ui/search.js +25 -5
  20. package/dist/assets/ui/styles.css +70 -0
  21. package/dist/commands/health/improve-metrics.js +11 -1
  22. package/dist/commands/health/md-report.js +3 -3
  23. package/dist/commands/health.js +1 -2
  24. package/dist/commands/improve/consolidate/pair-pass.js +59 -31
  25. package/dist/commands/improve/extract.js +18 -12
  26. package/dist/commands/improve/loop-stages.js +2 -2
  27. package/dist/commands/improve/memory/memory-belief.js +2 -2
  28. package/dist/commands/proposal/repository.js +44 -13
  29. package/dist/commands/read/curate.js +78 -19
  30. package/dist/commands/read/knowledge.js +109 -77
  31. package/dist/commands/read/search-cli.js +25 -11
  32. package/dist/commands/read/search.js +3 -3
  33. package/dist/commands/read/show.js +47 -1
  34. package/dist/commands/sources/bundle-cli.js +4 -6
  35. package/dist/commands/sources/installed-stashes.js +10 -19
  36. package/dist/commands/ui/api.js +95 -5
  37. package/dist/commands/ui/document-source.js +51 -0
  38. package/dist/commands/ui/git-read.js +18 -2
  39. package/dist/commands/ui/proposal-actions.js +5 -3
  40. package/dist/commands/ui/server.js +25 -13
  41. package/dist/commands/ui/ui-cli.js +3 -3
  42. package/dist/commands/ui/workspace-actions.js +264 -0
  43. package/dist/commands/workflow-cli.js +3 -1
  44. package/dist/core/adapter/adapters/akm-adapter.js +1 -0
  45. package/dist/core/adapter/adapters/llm-wiki-adapter.js +3 -5
  46. package/dist/core/adapter/adapters/okf-adapter.js +3 -16
  47. package/dist/core/asset/akm-markdown.js +19 -3
  48. package/dist/core/asset/markdown-link-targets.js +43 -0
  49. package/dist/core/asset/markdown.js +14 -6
  50. package/dist/core/config/config.js +6 -4
  51. package/dist/core/non-task-input.js +67 -3
  52. package/dist/core/write-source.js +2 -2
  53. package/dist/indexer/index-writer-lock.js +5 -1
  54. package/dist/indexer/passes/memory-inference.js +28 -6
  55. package/dist/indexer/passes/metadata.js +17 -0
  56. package/dist/indexer/scan/doc-to-entry.js +1 -0
  57. package/dist/indexer/search/db-search.js +16 -7
  58. package/dist/indexer/search/search-hit-enrichers.js +3 -3
  59. package/dist/llm/memory-infer.js +23 -14
  60. package/dist/output/shapes/curate.js +3 -1
  61. package/dist/output/shapes/helpers.js +6 -0
  62. package/dist/output/text/show-format.js +7 -0
  63. package/dist/scripts/akm-migrate-node.js +1820 -329
  64. package/dist/scripts/akm-migrate.js +1820 -329
  65. package/dist/storage/repositories/index-entries-repository.js +16 -0
  66. package/dist/storage/repositories/index-fts-repository.js +28 -6
  67. package/dist/storage/repositories/index-vec-repository.js +19 -3
  68. package/dist/workflows/exec/environment.js +4 -8
  69. package/dist/workflows/parser.js +13 -4
  70. package/docs/architecture/0.10-process-rules.md +9 -7
  71. package/docs/architecture/architecture.md +4 -0
  72. package/docs/architecture/internals/storage-locations.md +1 -1
  73. package/docs/architecture/persisted-data-compat.md +1 -1
  74. package/docs/guides/author-workflows.md +78 -28
  75. package/docs/guides/bundles.md +16 -2
  76. package/docs/reference/api.md +19 -1
  77. package/docs/reference/cli.md +90 -18
  78. package/docs/reference/env-and-secrets.md +11 -0
  79. package/docs/reference/memory.md +7 -6
  80. package/docs/reference/ui-api.md +84 -8
  81. package/docs/reference/workflow-schema.md +63 -32
  82. package/package.json +2 -1
@@ -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);
@@ -473,13 +473,13 @@ export async function runMemoryInferenceMaintenancePass(ctx, dbCell, memoryRefsF
473
473
  reEnrich: false,
474
474
  onProgress: (event) => {
475
475
  const current = event.currentRef ? ` ${event.currentRef}` : "";
476
- info(`[improve] memory inference ${event.processed}/${event.total}${current} (written ${event.writtenFacts}, skipped ${event.skippedNoFacts})`);
476
+ info(`[improve] memory inference ${event.processed}/${event.total}${current} (written ${event.writtenFacts}, no change ${event.noChange}, skipped ${event.skippedNoFacts})`);
477
477
  },
478
478
  })));
479
479
  const memoryInference = pass.result;
480
480
  if (!memoryInference)
481
481
  return { durationMs: pass.durationMs, warnings: pass.warnings };
482
- info(`[improve] memory inference complete (${memoryInference.writtenFacts} facts written from ${memoryInference.splitParents} parents)`);
482
+ info(`[improve] memory inference complete (${memoryInference.writtenFacts} facts written from ${memoryInference.splitParents} parents, ${memoryInference.noChange} no change)`);
483
483
  return {
484
484
  memoryInference,
485
485
  durationMs: pass.durationMs,
@@ -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));
@@ -24,7 +24,7 @@ import { appendEvent } from "../../core/events.js";
24
24
  import { nonTaskInput } from "../../core/non-task-input.js";
25
25
  import { redactCredentialPatterns } from "../../core/redaction.js";
26
26
  import { withStateDbTelemetry } from "../../core/state-db.js";
27
- import { searchHitContent } from "../../indexer/search/db-search.js";
27
+ import { matchBeliefFilter, searchHitContent } from "../../indexer/search/db-search.js";
28
28
  import { copySearchHitAttribution, getSearchHitAttribution, usageEventAttributionMetadata, } from "../../indexer/search/search-attribution.js";
29
29
  import { insertUsageEvent } from "../../indexer/usage/usage-events.js";
30
30
  import { estimateTokenCount } from "../../llm/embedders/remote.js";
@@ -32,9 +32,11 @@ import { isLlmFeatureEnabled, tryLlmFeature } from "../../llm/feature-gate.js";
32
32
  import { rerankDocuments } from "../../llm/rerank-client.js";
33
33
  import { truncateDescription } from "../../output/shapes/helpers.js";
34
34
  import { TELEMETRY_BUSY_TIMEOUT_MS, withIndexDb } from "../../storage/repositories/index-db.js";
35
- import { findEntryIdByRef, getItemRefById } from "../../storage/repositories/index-entries-repository.js";
36
- import { akmSearch, parseSearchSource } from "./search.js";
35
+ import { findEntryIdByRef, getEntryIdByFilePath, getItemRefById, } from "../../storage/repositories/index-entries-repository.js";
36
+ import { akmSearch, normalizeScopeFilters, parseBeliefFilterMode, parseSearchSource } from "./search.js";
37
37
  import { akmShowUnified } from "./show.js";
38
+ // Keep the effective policy with the result through packing without changing JSON.
39
+ const recallPolicies = new WeakMap();
38
40
  const DEFAULT_CURATE_LIMIT = 4;
39
41
  const MAX_CURATE_SUPPORT_REFS = 2;
40
42
  /** Fused candidates the reranker reorders when `search.curateRerank.topN` is unset. */
@@ -103,13 +105,19 @@ export async function akmCurate(options) {
103
105
  if (!trimmedQuery) {
104
106
  throw new UsageError('A curation query is required. Usage: akm curate "<task or prompt>" [--type <type>] [--limit <n>]', "MISSING_REQUIRED_ARGUMENT");
105
107
  }
108
+ const recall = {
109
+ type: options.type,
110
+ source: options.source ?? "local",
111
+ filters: normalizeScopeFilters(options.filters),
112
+ belief: parseBeliefFilterMode(options.belief ?? "current"),
113
+ };
106
114
  const nonTask = nonTaskInput(trimmedQuery);
107
115
  if (nonTask) {
108
116
  const abstained = {
109
117
  query: options.query,
110
118
  summary: `Curate abstained: the input is ${nonTask}, not a task.`,
111
119
  items: [],
112
- tip: 'Nothing was selected on purpose. To curate for it, pass the task itself: akm curate "<what you are trying to do>".',
120
+ tip: 'Nothing was selected on purpose. To curate for it, pass the task itself: akm curate "<what you are trying to do>". For a literal asset or word lookup, use akm search "<literal words>".',
113
121
  };
114
122
  if (!options.skipLogging) {
115
123
  logCurateEvent(options.query, abstained, options.eventSource, options.attributionProjection);
@@ -125,15 +133,18 @@ export async function akmCurate(options) {
125
133
  // An enabled reranker reorders the top fused candidates, not just the final `limit`.
126
134
  limit: Math.max(limit, rerankTopN(loadConfig())),
127
135
  source,
136
+ filters: recall.filters,
137
+ belief: recall.belief,
128
138
  skipLogging: true,
129
139
  }));
130
- const result = await curateSearchResults(options.query, searchResponse, limit, options.type, options.eventSource);
140
+ const result = await curateSearchResults(options.query, searchResponse, limit, options.type, options.eventSource, options.searchResponse ? undefined : recall);
141
+ recallPolicies.set(result, recall);
131
142
  if (!options.skipLogging) {
132
143
  logCurateEvent(options.query, result, options.eventSource, options.attributionProjection);
133
144
  }
134
145
  return result;
135
146
  }
136
- export async function curateSearchResults(query, result, limit, selectedType, eventSource) {
147
+ export async function curateSearchResults(query, result, limit, selectedType, eventSource, recall) {
137
148
  const allStashHits = result.hits.filter((hit) => hit.type !== "registry");
138
149
  const registryHits = result.registryHits ?? [];
139
150
  // F3/R-018: `--type` NARROWS the candidate pool. The caller's search
@@ -145,9 +156,18 @@ export async function curateSearchResults(query, result, limit, selectedType, ev
145
156
  // F4/R-019: respect `--limit` for registry fill instead of hard-capping it
146
157
  // at a bare literal 2 — the remaining slots after stash hits ARE the cap.
147
158
  const selectedRegistryHits = selectedStashHits.length >= limit ? [] : registryHits.slice(0, limit - selectedStashHits.length);
148
- const selectedRefs = new Set(selectedStashHits.map((hit) => hit.ref));
159
+ const selectedRefs = new Set(selectedStashHits.flatMap((hit) => {
160
+ if (!recall)
161
+ return [hit.ref];
162
+ try {
163
+ return [hit.ref, evidenceRef(hit)];
164
+ }
165
+ catch {
166
+ return [hit.ref]; // no longer indexed: enrichCuratedStashHit drops it
167
+ }
168
+ }));
149
169
  const items = [
150
- ...(await Promise.all(selectedStashHits.map((hit) => enrichCuratedStashHit(query, hit, selectedRefs, eventSource)))),
170
+ ...(await Promise.all(selectedStashHits.map((hit) => enrichCuratedStashHit(query, hit, selectedRefs, eventSource, recall)))).filter((item) => item !== undefined),
151
171
  ...selectedRegistryHits.map((hit) => buildCuratedRegistryItem(query, hit)),
152
172
  ].slice(0, limit);
153
173
  return {
@@ -181,15 +201,24 @@ export async function packCuratedHits(result, budgetTokens) {
181
201
  for (const item of stashItems) {
182
202
  let shown;
183
203
  try {
184
- shown = await akmShowUnified({ ref: item.ref, skipLogging: true });
204
+ shown = await akmShowUnified({
205
+ ref: evidenceRef(item),
206
+ skipLogging: true,
207
+ recall: recallPolicies.get(result) ?? { belief: "current" },
208
+ });
185
209
  }
186
210
  catch {
187
211
  continue;
188
212
  }
213
+ const historical = matchBeliefFilter(shown.beliefState, "historical");
214
+ const context = {
215
+ ...(shown.beliefState ? { beliefState: shown.beliefState } : {}),
216
+ ...(historical ? { reason: "Historical context; not current supporting authority." } : {}),
217
+ };
189
218
  const content = shown.content ?? shown.template ?? shown.prompt ?? "";
190
219
  const tokens = estimateTokenCount(content);
191
220
  if (used + tokens <= budgetTokens) {
192
- packed.push({ ref: item.ref, tokens, content });
221
+ packed.push({ ref: item.ref, tokens, content, ...context });
193
222
  used += tokens;
194
223
  continue;
195
224
  }
@@ -197,7 +226,7 @@ export async function packCuratedHits(result, budgetTokens) {
197
226
  const remaining = budgetTokens - used;
198
227
  if (remaining > 0) {
199
228
  const truncated = content.slice(0, remaining * 4);
200
- packed.push({ ref: item.ref, tokens: estimateTokenCount(truncated), content: truncated });
229
+ packed.push({ ref: item.ref, tokens: estimateTokenCount(truncated), content: truncated, ...context });
201
230
  used += estimateTokenCount(truncated);
202
231
  }
203
232
  }
@@ -205,22 +234,38 @@ export async function packCuratedHits(result, budgetTokens) {
205
234
  }
206
235
  return { query: result.query, budget: budgetTokens, tokens: used, items: packed };
207
236
  }
208
- async function enrichCuratedStashHit(query, hit, selectedRefs, eventSource) {
237
+ function evidenceRef(item) {
238
+ return withIndexDb((db) => {
239
+ const id = getEntryIdByFilePath(db, item.path);
240
+ const ref = id === undefined ? undefined : getItemRefById(db, id);
241
+ if (!ref) {
242
+ if (item.path)
243
+ throw new UsageError(`Indexed asset no longer available: ${item.ref}`);
244
+ return item.ref;
245
+ }
246
+ const fragment = parseBundleRef(item.ref).fragment;
247
+ return fragment ? `${ref}#${fragment}` : ref;
248
+ });
249
+ }
250
+ async function enrichCuratedStashHit(query, hit, selectedRefs, eventSource, recall) {
209
251
  let shown;
210
252
  try {
211
- shown = await akmShowUnified({ ref: hit.ref, eventSource, skipLogging: true });
253
+ shown = await akmShowUnified({ ref: recall ? evidenceRef(hit) : hit.ref, eventSource, skipLogging: true, recall });
212
254
  }
213
255
  catch {
256
+ if (recall)
257
+ return undefined;
214
258
  shown = undefined;
215
259
  }
260
+ const ref = shown?.selectedRef ?? shown?.ref ?? hit.ref;
216
261
  const description = shown?.description ?? hit.description;
217
262
  const preview = buildCuratedPreview(shown, hit);
218
- const supportRefs = buildCurateSupportRefs(shown?.links, selectedRefs, hit.ref);
263
+ const supportRefs = await buildCurateSupportRefs(shown?.links, selectedRefs, ref, recall);
219
264
  const item = {
220
265
  source: "local",
221
266
  type: shown?.type ?? hit.type,
222
267
  name: shown?.name ?? hit.name,
223
- ref: hit.ref,
268
+ ref,
224
269
  path: shown?.path ?? hit.path,
225
270
  editable: shown?.editable ?? hit.editable ?? false,
226
271
  ...((shown?.editable ?? hit.editable ?? false) === false
@@ -232,8 +277,9 @@ async function enrichCuratedStashHit(query, hit, selectedRefs, eventSource) {
232
277
  ...(shown?.parameters?.length ? { parameters: shown.parameters } : {}),
233
278
  ...(shown?.run ? { run: shown.run } : {}),
234
279
  ...(supportRefs.length > 0 ? { supportRefs } : {}),
235
- followUp: `akm show ${hit.ref}`,
236
- reason: buildCuratedReason(query, shown?.type ?? hit.type),
280
+ followUp: `akm show ${ref}`,
281
+ ...(shown?.beliefState ? { beliefState: shown.beliefState } : {}),
282
+ reason: `${matchBeliefFilter(shown?.beliefState, "historical") ? "Historical context. " : ""}${buildCuratedReason(query, shown?.type ?? hit.type)}`,
237
283
  ...(hit.score !== undefined ? { score: hit.score } : {}),
238
284
  };
239
285
  copySearchHitAttribution(hit, item, item.description);
@@ -333,7 +379,7 @@ function rerankDocumentTexts(hits) {
333
379
  * it, in the order `akm show` lists them. The LLM entity graph's `related`
334
380
  * list no longer feeds them.
335
381
  */
336
- function buildCurateSupportRefs(links, selectedRefs, ownerRef) {
382
+ async function buildCurateSupportRefs(links, selectedRefs, ownerRef, recall) {
337
383
  const supportRefs = [];
338
384
  for (const [part, direction] of [
339
385
  ["outgoing", "from"],
@@ -343,11 +389,24 @@ function buildCurateSupportRefs(links, selectedRefs, ownerRef) {
343
389
  for (const ref of group.refs) {
344
390
  if (ref === ownerRef || selectedRefs.has(ref) || supportRefs.some((existing) => existing.ref === ref))
345
391
  continue;
392
+ let shown;
393
+ if (recall) {
394
+ try {
395
+ // A support ref is a link, not a hit: `--type` narrows the hits only.
396
+ const { type: _type, ...linkRecall } = recall;
397
+ shown = await akmShowUnified({ ref, recall: linkRecall, skipLogging: true });
398
+ }
399
+ catch {
400
+ continue;
401
+ }
402
+ }
403
+ const historical = matchBeliefFilter(shown?.beliefState, "historical");
346
404
  const type = supportRefType(ref);
347
405
  supportRefs.push({
348
406
  ref,
349
407
  ...(type ? { type } : {}),
350
- reason: `Declared link (${kind}) ${direction} this asset.`,
408
+ ...(shown?.beliefState ? { beliefState: shown.beliefState } : {}),
409
+ reason: `${historical ? "Historical context; " : ""}Declared link (${kind}) ${direction} this asset.`,
351
410
  });
352
411
  if (supportRefs.length >= MAX_CURATE_SUPPORT_REFS)
353
412
  return supportRefs;