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
@@ -7,7 +7,10 @@
7
7
  * promoted. Promotion emits a reviewable proposal and never touches the
8
8
  * memory directly; accepting it later retires the source memory (O1, in
9
9
  * `proposal/repository.ts`). Memories the improve ledger judged recently and
10
- * that have not changed since are not judged again.
10
+ * that have not changed since are not judged again, and a memory whose
11
+ * promotion was accepted or rejected waits until its body changes. A memory
12
+ * that a neighbouring knowledge doc already covers is not promoted
13
+ * (`consolidate/coverage.ts`).
11
14
  *
12
15
  * Accounting invariant (the promote pass only): `processed == promoted +
13
16
  * judgedNoAction + Σ(skipReasons) + failedChunkMemories`.
@@ -41,11 +44,12 @@ import { getAllEntries } from "../../storage/repositories/index-entries-reposito
41
44
  import { listProposals, listProposalsReadOnly, proposalContent } from "../proposal/repository.js";
42
45
  import { hasHotCaptureMode, hasSupersededStatus, validateProposalFrontmatter, } from "../proposal/validators/proposal-quality-validators.js";
43
46
  import { buildChunkPrompt, computeSafeChunkSize, DEFAULT_CONTEXT_LENGTH_TOKENS } from "./consolidate/chunking.js";
47
+ import { openKnowledgeCoverage } from "./consolidate/coverage.js";
44
48
  import { runConsolidatePairPass } from "./consolidate/pair-pass.js";
45
49
  import { sanitizeMergedContent } from "./consolidate/sanitize.js";
46
50
  import { contentHash } from "./content-hash.js";
47
51
  import { resolveImproveStrategy, resolveProcessEnabled } from "./improve-strategies.js";
48
- import { isLedgerBlocked, ledgerKey, loadLedgerSnapshot, recordLedgerAttempt } from "./ledger.js";
52
+ import { isContentDrivenRow, isLedgerBlocked, ledgerKey, loadLedgerSnapshot, recordLedgerAttempt } from "./ledger.js";
49
53
  import { isInRetrievalScope, loadRetrievalScope } from "./retrieval-scope.js";
50
54
  import { callStage, mintProposal, noticeSet, stageRunner } from "./stage.js";
51
55
  /** A plan op worth acting on. Retired advisory ops (merge/delete/contradict) are dropped, never thrown on. */
@@ -222,10 +226,10 @@ function injectRandomClusterMembers(memories, profile, warnings) {
222
226
  return out;
223
227
  }
224
228
  /** Body hashes of pending consolidate proposals, so the prompt can mark memories already queued. */
225
- function loadPendingConsolidateProposalHashes(stashDir) {
229
+ function loadPendingConsolidateProposalHashes(stashDir, proposalsCtx) {
226
230
  const hashes = new Set();
227
231
  try {
228
- for (const p of listProposalsReadOnly(stashDir, { status: "pending" })) {
232
+ for (const p of listProposalsReadOnly(stashDir, { status: "pending" }, proposalsCtx)) {
229
233
  if (p.source !== "consolidate")
230
234
  continue;
231
235
  try {
@@ -420,12 +424,26 @@ export function inspectConsolidationPool(opts, stashDir, warnings, existingKnowl
420
424
  }
421
425
  memories = memories.filter((memory) => fs.existsSync(memory.filePath));
422
426
  const poolSize = memories.length;
423
- // A memory judged within its revisit window comes back once it is edited.
427
+ // A memory judged within its revisit window comes back once it is edited. A
428
+ // promotion that was accepted or rejected holds its memory until the body
429
+ // changes, however long that takes (#998).
424
430
  const ledger = loadLedgerSnapshot({ proposalsCtx: opts.proposalsCtx, readOnly }, stashDir, ["consolidate"]);
425
431
  if (ledger.size > 0) {
426
432
  const nowIso = new Date().toISOString();
427
433
  memories = memories.filter((memory) => {
428
434
  const row = ledger.get(ledgerKey("consolidate", conceptIdFromTypeName("memory", memory.name)));
435
+ if (!row)
436
+ return true;
437
+ if (isContentDrivenRow(row)) {
438
+ let bodyHash;
439
+ try {
440
+ bodyHash = contentHash(fs.readFileSync(memory.filePath, "utf8"), "body");
441
+ }
442
+ catch {
443
+ bodyHash = undefined;
444
+ }
445
+ return row.contentHash !== bodyHash;
446
+ }
429
447
  let changedAt;
430
448
  try {
431
449
  changedAt = fs.statSync(memory.filePath).mtime.toISOString();
@@ -433,7 +451,7 @@ export function inspectConsolidationPool(opts, stashDir, warnings, existingKnowl
433
451
  catch {
434
452
  changedAt = undefined;
435
453
  }
436
- return !row || !isLedgerBlocked(row, nowIso, changedAt);
454
+ return !isLedgerBlocked(row, nowIso, changedAt);
437
455
  });
438
456
  }
439
457
  const judgedUnchanged = poolSize - memories.length;
@@ -622,7 +640,7 @@ async function planConsolidation(opts, config, stashDir, memories, warnings, sta
622
640
  assertRunnerCredentials(llmRunner);
623
641
  const { ordered, embedTelemetry } = await clusterMemoriesBySimilarity(budgeted, config, stateDb, opts.signal);
624
642
  const chunks = slice(injectRandomClusterMembers(ordered, opts.improveProfile, warnings));
625
- const pendingProposalBodyHashes = loadPendingConsolidateProposalHashes(stashDir);
643
+ const pendingProposalBodyHashes = loadPendingConsolidateProposalHashes(stashDir, opts.proposalsCtx);
626
644
  warn(`[consolidate] ${budgeted.length} memories / ${chunks.length} chunk(s) / chunk_size=${chunkSize}` +
627
645
  ` / pending-proposal hashes: ${pendingProposalBodyHashes.size}`);
628
646
  const planned = await judgeConsolidationChunks({
@@ -651,7 +669,7 @@ async function consolidate(opts, config, stashDir, startMs, stateDb) {
651
669
  const { memories, prefilteredAlreadyPromoted } = pool;
652
670
  const plural = (n) => `memor${n === 1 ? "y" : "ies"}`;
653
671
  if (pool.judgedUnchanged > 0) {
654
- warnings.push(`Consolidation: skipped ${pool.judgedUnchanged} ${plural(pool.judgedUnchanged)} judged within the revisit window and unchanged since.`);
672
+ warnings.push(`Consolidation: skipped ${pool.judgedUnchanged} ${plural(pool.judgedUnchanged)} judged within the revisit window, or already promoted or rejected, and unchanged since.`);
655
673
  }
656
674
  if (pool.outsideRetrievalScope > 0) {
657
675
  warnings.push(`Consolidation: skipped ${pool.outsideRetrievalScope} ${plural(pool.outsideRetrievalScope)} already judged that retrieval has not returned in the last ${USAGE_EVENT_RETENTION_DAYS} days.`);
@@ -664,8 +682,8 @@ async function consolidate(opts, config, stashDir, startMs, stateDb) {
664
682
  // sees .derived memories, flat knowledge and lessons, not just the
665
683
  // promote pool above), so it runs regardless of whether the promote pool
666
684
  // is empty — every return path below carries its result.
667
- const pairPassBundleId = resolveConsolidationSourceOwner(opts, stashDir)?.bundleId;
668
- const pairPass = await runConsolidatePairPass(opts, config, stashDir, pairPassBundleId, warnings);
685
+ const bundleId = resolveConsolidationSourceOwner(opts, stashDir)?.bundleId;
686
+ const pairPass = await runConsolidatePairPass(opts, config, stashDir, bundleId, warnings);
669
687
  if (memories.length === 0) {
670
688
  return makeConsolidateResult({
671
689
  dryRun: opts.dryRun ?? false,
@@ -719,8 +737,16 @@ async function consolidate(opts, config, stashDir, startMs, stateDb) {
719
737
  warnings,
720
738
  pushSkipReason: (op, ref, reason) => pushSkipReason(acc, op, ref, reason),
721
739
  };
722
- for (const op of plan.allOps)
723
- await emitPromotionProposal(op, ctx);
740
+ const coverage = plan.allOps.length > 0 ? openKnowledgeCoverage(bundleId) : undefined;
741
+ if (coverage)
742
+ ctx.coveringKnowledge = coverage.find;
743
+ try {
744
+ for (const op of plan.allOps)
745
+ await emitPromotionProposal(op, ctx);
746
+ }
747
+ finally {
748
+ coverage?.close();
749
+ }
724
750
  // Every other judged memory waits out its revisit window (or its next edit);
725
751
  // a promotion that failed to persist is retried next run.
726
752
  recordLedgerAttempt({ proposalsCtx: opts.proposalsCtx }, [...acc.judgedRefs]
@@ -765,7 +791,8 @@ const PROMOTE_BODY_MIN_CHARS = 100;
765
791
  /**
766
792
  * Queue one promotion as a proposal. Refused (with a skip reason) when the
767
793
  * memory is unknown, already promoted this run, already pending or present
768
- * as knowledge (by concept, body hash or slug variant), unreadable, fails
794
+ * as knowledge (by concept, body hash or slug variant), already covered by a
795
+ * neighbouring knowledge doc (`coverage.ts`), unreadable, fails
769
796
  * sanitization, is superseded, has a body too small to be knowledge, or has
770
797
  * no valid description.
771
798
  * @internal Exported for promotion-path integration tests.
@@ -842,6 +869,12 @@ export async function emitPromotionProposal(op, ctx) {
842
869
  if (sameBody) {
843
870
  return skip("dedup_pending_proposal", `Skipping promote: identical body already pending as proposal ${sameBody.id} (ref: ${sameBody.ref}); skipping duplicate for ${op.ref} → ${knowledgeRef}`);
844
871
  }
872
+ // #998: the copies the two exact checks above cannot see — an earlier
873
+ // promotion that was edited or re-slugged, a doc that quotes the memory.
874
+ const covering = ctx.coveringKnowledge?.(entry.filePath, sourceBody);
875
+ if (covering) {
876
+ return skip("dedup_covered_by_knowledge", `Skipping promote: ${op.ref} → ${knowledgeRef} is already covered by ${covering.ref} (${Math.round(covering.containment * 100)}% of its text appears there).`);
877
+ }
845
878
  try {
846
879
  const description = (typeof op.description === "string" && op.description.trim()
847
880
  ? op.description.trim()
@@ -20,7 +20,7 @@ import { parseFrontmatter, writeSalienceToFrontmatter } from "../../core/asset/f
20
20
  import { stripMarkdownFences } from "../../core/asset/markdown.js";
21
21
  import { conceptIdFromTypeName, parseRefInput } from "../../core/asset/resolve-ref.js";
22
22
  import { authoringRulesForType } from "../../core/authoring-rules.js";
23
- import { resolveStashDir } from "../../core/common.js";
23
+ import { isWithin, resolveStashDir } from "../../core/common.js";
24
24
  import { getImproveProcessConfig, loadConfig } from "../../core/config/config.js";
25
25
  import { UsageError } from "../../core/errors.js";
26
26
  import { appendEvent, readEvents } from "../../core/events.js";
@@ -436,7 +436,9 @@ async function judgeAndQueue(run, out) {
436
436
  let confidence;
437
437
  if (qualityGateEnabled(run)) {
438
438
  const similarLessons = await run.similar(content.slice(0, 500), 3);
439
- const verdict = await runLessonQualityJudge(run.config, content, out.source ?? "", run.options.chat, {
439
+ // The judge reads what the generator read: the source body, without its frontmatter (buildDistillPrompt).
440
+ const source = out.source ? parseFrontmatter(out.source).content.trim() : "";
441
+ const verdict = await runLessonQualityJudge(run.config, content, source, run.options.chat, {
440
442
  ...(similarLessons.length > 0 ? { similarLessons } : {}),
441
443
  ...(run.runner ? { llmRunner: run.runner } : {}),
442
444
  ...(run.options.signal ? { signal: run.options.signal } : {}),
@@ -599,8 +601,10 @@ async function planPromotion(run, feedbackEvents) {
599
601
  const existingPath = await run.lookup(assessment.knowledgeRef);
600
602
  let existing = null;
601
603
  try {
602
- if (existingPath && fs.existsSync(existingPath))
604
+ // The promotion is filed in run.stash, so only that bundle's doc is its destination (#1000).
605
+ if (existingPath && fs.existsSync(existingPath) && isWithin(existingPath, run.stash)) {
603
606
  existing = fs.readFileSync(existingPath, "utf8");
607
+ }
604
608
  }
605
609
  catch {
606
610
  existing = null;
@@ -799,7 +803,7 @@ function readDistillFeedback(run) {
799
803
  }
800
804
  /** System + user prompt: rejected-proposal context, optional CLS neighbours, stash standards. */
801
805
  async function buildDistillMessages(run, feedback, kind, outputRef) {
802
- const rejectedProposals = rejectedProposalContext(run.stash, run.inputRef, run.options.ctx);
806
+ const rejectedProposals = rejectedProposalContext(run.stash, run.inputRef, run.options.ctx, run.options.eventsCtx);
803
807
  // CLS interleaving (default off): show related lessons so the model does not overwrite them.
804
808
  const cls = getImproveProcessConfig("distill", run.profile)?.cls ?? {};
805
809
  let clsContext = "";
@@ -4,6 +4,7 @@
4
4
  /** The refs an improve run may consider, read from the index, and the small predicates over them. */
5
5
  import fs from "node:fs";
6
6
  import path from "node:path";
7
+ import { parseBundleRef } from "../../core/asset/asset-ref.js";
7
8
  import { parseFrontmatter } from "../../core/asset/frontmatter.js";
8
9
  import { conceptIdFromTypeName, parseRefInput, resolveRef, typeNameFromConceptId } from "../../core/asset/resolve-ref.js";
9
10
  import { loadConfig } from "../../core/config/config.js";
@@ -109,6 +110,18 @@ export function dedupeRefs(refs) {
109
110
  return true;
110
111
  });
111
112
  }
113
+ /**
114
+ * The bundle an improve run reads candidates from and files proposals into: its
115
+ * write target, the source rooted at `stashDir` (else the working bundle).
116
+ * Reflect reads a candidate from its owning bundle but the proposal lands in
117
+ * the write target, so a candidate owned by any other bundle would be read from
118
+ * one bundle and filed in another (#1000).
119
+ */
120
+ function runBundleIdOf(sources, installations, stashDir) {
121
+ const root = stashDir === undefined ? undefined : path.resolve(stashDir);
122
+ const index = sources.findIndex((source) => root === undefined ? source.isDefault === true : path.resolve(source.path) === root);
123
+ return installations[index]?.id;
124
+ }
112
125
  export async function collectEligibleRefs(scope, stashDir, improveProfile, config) {
113
126
  return collectEligibleRefsFromIndex(scope, stashDir, improveProfile, false, config);
114
127
  }
@@ -135,6 +148,9 @@ async function collectEligibleRefsFromIndex(scope, stashDir, improveProfile, rea
135
148
  return empty();
136
149
  const installations = deriveInstallations(sources);
137
150
  const writableBundleIds = deriveWritableBundleIds(sources);
151
+ const runBundleId = runBundleIdOf(sources, installations, stashDir);
152
+ // Candidates come from the write target alone, and only while it is writable.
153
+ const isCandidateBundle = (bundleId) => bundleId !== undefined && bundleId === runBundleId && writableBundleIds.has(bundleId);
138
154
  const ready = {
139
155
  status: "ready",
140
156
  reason: readOnly ? "loaded a non-mutating point-in-time copy of the existing index" : "loaded the prepared index",
@@ -152,20 +168,34 @@ async function collectEligibleRefsFromIndex(scope, stashDir, improveProfile, rea
152
168
  return empty(missing);
153
169
  if (scope.mode === "ref" && scope.value) {
154
170
  const entriesByItemRef = new Map(getAllEntries(db).map((entry) => [entry.itemRef, entry]));
155
- const resolved = resolveRef(scope.value, {
156
- defaultBundle: config.defaultBundle,
157
- bundles: installations.map((installation) => ({
158
- id: installation.id,
159
- hasConcept: (conceptId) => entriesByItemRef.has(`${installation.id}//${conceptId}`),
160
- })),
161
- });
171
+ let resolved;
172
+ try {
173
+ resolved = resolveRef(scope.value, {
174
+ defaultBundle: config.defaultBundle,
175
+ bundles: installations.map((installation) => ({
176
+ id: installation.id,
177
+ hasConcept: (conceptId) => entriesByItemRef.has(`${installation.id}//${conceptId}`),
178
+ })),
179
+ ...(runBundleId ? { only: runBundleId } : {}),
180
+ });
181
+ }
182
+ catch (error) {
183
+ if (!(error instanceof NotFoundError) || !runBundleId)
184
+ throw error;
185
+ // Name the bundle that owns the ref; a typo or an unindexed ref keeps the default advice.
186
+ const { conceptId } = parseBundleRef(scope.value);
187
+ const owner = installations.find((installation) => installation.id !== runBundleId && entriesByItemRef.has(`${installation.id}//${conceptId}`))?.id;
188
+ if (!owner)
189
+ throw error;
190
+ throw new NotFoundError(error.message, error.code, `"${conceptId}" is in bundle "${owner}"; this run improves bundle "${runBundleId}" only. Run \`akm improve ${owner}//${conceptId}\` (or pass \`--bundle ${owner}\`).`);
191
+ }
162
192
  const indexed = entriesByItemRef.get(`${resolved.bundle}//${resolved.conceptId}`);
163
193
  if (!indexed?.bundleId || !indexed.conceptId || !fs.existsSync(indexed.filePath)) {
164
194
  if (await findAssetFilePath(scope.value, stashDir))
165
195
  return empty(ready);
166
196
  throw new NotFoundError(`Asset not found in the selected writable source: ${scope.value}`, "ASSET_NOT_FOUND");
167
197
  }
168
- if (!writableBundleIds.has(indexed.bundleId))
198
+ if (!isCandidateBundle(indexed.bundleId))
169
199
  return empty(ready);
170
200
  const isMemory = indexed.entry.type === "memory";
171
201
  return {
@@ -180,7 +210,7 @@ async function collectEligibleRefsFromIndex(scope, stashDir, improveProfile, rea
180
210
  indexSnapshot: ready,
181
211
  };
182
212
  }
183
- const entries = getAllEntries(db, scope.mode === "type" ? scope.value : undefined).filter((indexed) => writableBundleIds.has(indexed.bundleId));
213
+ const entries = getAllEntries(db, scope.mode === "type" ? scope.value : undefined).filter((indexed) => isCandidateBundle(indexed.bundleId));
184
214
  const planned = new Map();
185
215
  const strategyFiltered = new Map();
186
216
  let memoryEligible = 0;
@@ -19,7 +19,7 @@ import { collectEngineCredentialValues } from "../../integrations/agent/engine-r
19
19
  import { probeLlmReachable } from "../../llm/client.js";
20
20
  import { getOutputMode } from "../../output/context.js";
21
21
  import { deliverRendered } from "../../output/html-render.js";
22
- import { akmImprove, resolveImproveReadSource } from "./improve.js";
22
+ import { akmImprove, IMPROVE_TARGET_FLAG, resolveImproveReadSource } from "./improve.js";
23
23
  import { runImproveReportQuery } from "./improve-report.js";
24
24
  import { buildImproveRunId, recordImproveRunResult, recordTerminatedImproveRun, } from "./improve-result-file.js";
25
25
  import { runImproveSession } from "./improve-session.js";
@@ -209,8 +209,11 @@ export const improveCommand = defineCommand({
209
209
  description: "Alias for --dry-run (#947). Sets the exact same internal flag; use it when previewing resolved process -> engine -> model routing (plan.processes) rather than checking what would write.",
210
210
  default: false,
211
211
  },
212
- bundle: { type: "string", description: "Override the write target for accepted proposals" },
213
- limit: { type: "string", description: "Maximum number of assets to process (highest utility first)" },
212
+ bundle: {
213
+ type: "string",
214
+ description: "Bundle to improve and write proposals to (default: defaultWriteTarget, else the working bundle); only its assets are planned",
215
+ },
216
+ limit: { type: "string", description: "Maximum number of assets to process (highest salience first)" },
214
217
  "timeout-ms": {
215
218
  type: "string",
216
219
  description: "Wall-clock budget for the entire run in milliseconds (default: 7200000 = 2 hours)",
@@ -227,7 +230,7 @@ export const improveCommand = defineCommand({
227
230
  },
228
231
  "skip-if-locked": {
229
232
  type: "boolean",
230
- description: "If another improve run already holds the lock, skip gracefully (exit 0) instead of failing with 'already running' (exit 78). Use for high-frequency scheduled runs so they don't pile up failures while a longer run is in progress.",
233
+ description: "If another improve run already holds the lock, skip gracefully (exit 0) instead of failing with 'already running' (exit 75). Use for high-frequency scheduled runs so they don't pile up failures while a longer run is in progress.",
231
234
  default: false,
232
235
  },
233
236
  "require-engines": {
@@ -286,8 +289,8 @@ export const improveCommand = defineCommand({
286
289
  const writeTarget = dryRun
287
290
  ? undefined
288
291
  : scopeRef
289
- ? resolveMutationTarget(effectiveConfig, scopeRef, targetArg).target
290
- : resolveWriteTarget(effectiveConfig, targetArg);
292
+ ? resolveMutationTarget(effectiveConfig, scopeRef, targetArg, { flag: IMPROVE_TARGET_FLAG }).target
293
+ : resolveWriteTarget(effectiveConfig, targetArg, { flag: IMPROVE_TARGET_FLAG });
291
294
  // Every model-backed process resolves before any side effect; a dry run
292
295
  // never dispatches, so it tolerates every process being disabled.
293
296
  const resolvedPlan = resolveImprovePlan(strategyArg, effectiveConfig, { allowAllDisabled: Boolean(dryRun) });
@@ -19,7 +19,7 @@ import { redactSensitiveText } from "../../core/redaction.js";
19
19
  import { openStateDatabase } from "../../core/state-db.js";
20
20
  import { info, warn, warnVerbose } from "../../core/warn.js";
21
21
  import { beginWriteProvenance, relativeWrittenPath } from "../../core/write-provenance.js";
22
- import { resolveWritable, resolveWriteTarget } from "../../core/write-source.js";
22
+ import { resolveWorkingStashTarget, resolveWritable, resolveWriteTarget } from "../../core/write-source.js";
23
23
  import { ensureIndex } from "../../indexer/ensure-index.js";
24
24
  import { indexWrittenAssets } from "../../indexer/index-written-assets.js";
25
25
  import { akmIndex } from "../../indexer/indexer.js";
@@ -268,17 +268,24 @@ function describeRunWrittenPaths(setup, writtenPaths) {
268
268
  }
269
269
  return [...described].sort();
270
270
  }
271
+ /** How `akm improve` spells its destination flag: the shared target resolvers name it in their errors. */
272
+ export const IMPROVE_TARGET_FLAG = "--bundle";
271
273
  /**
272
274
  * The source a dry run (or `--show-prompt`) inspects, without adapting it into
273
275
  * a write target.
274
276
  */
275
277
  export function resolveImproveReadSource(config, scopedRef, explicitTarget, fallbackStashDir) {
276
278
  if (scopedRef?.origin && explicitTarget && scopedRef.origin !== explicitTarget) {
277
- throw new UsageError(`Qualified ref bundle "${scopedRef.origin}" conflicts with --target "${explicitTarget}".`, "INVALID_FLAG_VALUE", `Drop --target or use --target ${scopedRef.origin}.`);
279
+ throw new UsageError(`Qualified ref bundle "${scopedRef.origin}" conflicts with ${IMPROVE_TARGET_FLAG} "${explicitTarget}".`, "INVALID_FLAG_VALUE", `Drop ${IMPROVE_TARGET_FLAG} or use ${IMPROVE_TARGET_FLAG} ${scopedRef.origin}.`);
278
280
  }
279
281
  const selector = scopedRef?.origin ?? explicitTarget ?? config.defaultWriteTarget;
280
282
  if (!selector && fallbackStashDir)
281
283
  return { source: { name: "stash", path: fallbackStashDir } };
284
+ if (!selector && process.env.AKM_BUNDLE_DIR?.trim()) {
285
+ // A live run's working bundle starts from AKM_BUNDLE_DIR (`resolveWorkingStashTarget`), so its preview does too.
286
+ const { source } = resolveWorkingStashTarget(config, { requireWritable: false });
287
+ return { source: { name: source.name, path: source.path } };
288
+ }
282
289
  const configuredSelector = selector ?? config.defaultBundle;
283
290
  if (configuredSelector) {
284
291
  const entry = bundlesToSourceEntries(config)?.find((source) => source.name === configuredSelector);
@@ -339,10 +346,12 @@ function resolveImproveRunSetup(options) {
339
346
  const writeTarget = options.dryRun
340
347
  ? undefined
341
348
  : scopedRef?.origin
342
- ? resolveMutationTarget(config, scopedRef, options.writeTarget?.source.name ?? options.target).target
349
+ ? resolveMutationTarget(config, scopedRef, options.writeTarget?.source.name ?? options.target, {
350
+ flag: IMPROVE_TARGET_FLAG,
351
+ }).target
343
352
  : (options.writeTarget ??
344
353
  (options.target || config.defaultWriteTarget || !options.stashDir
345
- ? resolveWriteTarget(config, options.target)
354
+ ? resolveWriteTarget(config, options.target, { flag: IMPROVE_TARGET_FLAG })
346
355
  : {
347
356
  source: { kind: "filesystem", name: "stash", path: options.stashDir },
348
357
  config: { type: "filesystem", name: "stash", path: options.stashDir, writable: true },
@@ -9,9 +9,9 @@
9
9
  import fs from "node:fs";
10
10
  import { getStateDbPath, withImmediateTransaction, withStateDb } from "../../core/state-db.js";
11
11
  import { warn } from "../../core/warn.js";
12
- import { isLedgerBlocked, listImproveLedgerRows, PAIR_PASS_LEDGER_SOURCE, recordImproveLedger, } from "../../storage/repositories/improve-ledger-repository.js";
12
+ import { isContentDrivenRow, isLedgerBlocked, listImproveLedgerRows, PAIR_PASS_LEDGER_SOURCE, recordImproveLedger, } from "../../storage/repositories/improve-ledger-repository.js";
13
13
  import { openSqliteReadSnapshot } from "../../storage/sqlite-read-snapshot.js";
14
- export { isLedgerBlocked, PAIR_PASS_LEDGER_SOURCE };
14
+ export { isContentDrivenRow, isLedgerBlocked, PAIR_PASS_LEDGER_SOURCE };
15
15
  /** An improve candidate's durable state key: its index item_ref, else its conceptId. */
16
16
  export function stateKey(ref, itemRef) {
17
17
  return itemRef ?? ref;
@@ -136,6 +136,8 @@ async function runLoopReflectPass(planned, env, tally) {
136
136
  if (reflectErrors.length > 0)
137
137
  tally.reflectsWithErrorContext++;
138
138
  const budgetMs = env.remainingBudgetMs();
139
+ // `planned` was selected from the run's write target alone (eligibility.ts), so the
140
+ // `target` below is the bundle `planned.itemRef` is read from and the proposal is filed in.
139
141
  const reflectArgs = {
140
142
  ref: planned.ref,
141
143
  ...(planned.itemRef ? { itemRef: planned.itemRef } : {}),
@@ -209,6 +209,8 @@ async function runConsolidationPass(args) {
209
209
  maxChunkSize: processConfig?.maxChunkSize,
210
210
  signal: args.budgetSignal,
211
211
  p90ChunkSecondsDefault: processConfig?.p90ChunkSecondsDefault,
212
+ // Its read-only proposal lookups go through the run's own state.db handle.
213
+ ...(eventsCtx?.db ? { proposalsCtx: { db: eventsCtx.db } } : {}),
212
214
  }));
213
215
  }
214
216
  return { consolidation, plan: planned.plan };
@@ -791,7 +793,7 @@ function fetchRetrievalSignals(options, signalFiltered, noFeedbackCandidates, ev
791
793
  // usage_events live in state.db, entries in index.db.
792
794
  withRunState(eventsCtx, persist, (stateDb) => {
793
795
  if (countUsageEventsByType(stateDb, "show") === 0) {
794
- warn("Warning: show events not yet in usage_events — zero-feedback fallback will match only search-retrieved assets.");
796
+ warn("Warning: show events not yet in usage_events — the retrieval scope will match only search-retrieved assets.");
795
797
  }
796
798
  const refs = [...new Set([...signalFiltered, ...noFeedbackCandidates].map((r) => r.ref))];
797
799
  out.retrievalCounts = getRetrievalCounts(indexDb, stateDb, refs, { sourceName: options.sourceName });
@@ -761,7 +761,7 @@ async function gatherReflectPromptSources(options, stash, parsedRef, assetConten
761
761
  relatedLessons: options.ref && parsedRef
762
762
  ? await readRelatedLessons(stash, options.ref, parsedRef, options.itemRef, options.eventsCtx)
763
763
  : [],
764
- rejectedProposals: rejectedProposalContext(stash, options.ref, options.ctx),
764
+ rejectedProposals: rejectedProposalContext(stash, options.ref, options.ctx, options.eventsCtx),
765
765
  standardsContext: resolveStandardsContext(options.ref, stash),
766
766
  };
767
767
  }
@@ -1036,7 +1036,7 @@ async function finalizeReflectProposal(args) {
1036
1036
  payload: { content: payload.content, ...(Object.keys(frontmatter).length > 0 ? { frontmatter } : {}) },
1037
1037
  ...(typeof payload.confidence === "number" ? { confidence: payload.confidence } : {}),
1038
1038
  ...(options.eligibilitySource ? { eligibilitySource: options.eligibilitySource } : {}),
1039
- ...(options.itemRef ? { attemptedRefs: [options.itemRef] } : {}),
1039
+ ...(options.itemRef ? { itemRef: options.itemRef, attemptedRefs: [options.itemRef] } : {}),
1040
1040
  }, reviewReasons.length > 0
1041
1041
  ? {
1042
1042
  review: {
@@ -100,12 +100,14 @@ export const MAX_REJECTED_PROPOSALS = 3;
100
100
  /**
101
101
  * Reflexion context: the newest reviewer rejections for `ref`. Procedural
102
102
  * refusals (expiry, stale target, missing asset) are not judgements on the
103
- * content and are left out. Reads never create state.db.
103
+ * content and are left out. Reads never create state.db, and an improve run's
104
+ * live connection (`eventsCtx.db`) is read through, not copied.
104
105
  */
105
- export function rejectedProposalContext(stash, ref, ctx) {
106
+ export function rejectedProposalContext(stash, ref, ctx, eventsCtx) {
106
107
  if (!ref)
107
108
  return [];
108
- return listProposalsReadOnly(stash, { ref, status: "rejected", includeArchive: true }, ctx)
109
+ const proposalsCtx = eventsCtx?.db ? { ...ctx, db: eventsCtx.db } : ctx;
110
+ return listProposalsReadOnly(stash, { ref, status: "rejected", includeArchive: true }, proposalsCtx)
109
111
  .filter((p) => !isProceduralRejection(p))
110
112
  .sort((a, b) => new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime())
111
113
  .slice(0, MAX_REJECTED_PROPOSALS)
@@ -156,18 +158,20 @@ export function buildJudgePrompt(lessonContent, sourceContent, similarLessons) {
156
158
  "Score this lesson on each criterion from 1 (poor) to 5 (excellent):",
157
159
  "1. NOVELTY: Does the lesson add information not already present in the source asset?",
158
160
  "2. NON-REDUNDANCY: Is this lesson meaningfully different from what the source already says?",
161
+ "3. GROUNDING: Is the lesson about what the source asset is about? Score 1-2 only if it is about a different subject than the source; 3 if it is on the source's subject but goes beyond or corrects what the source says (it may draw on feedback you are not shown); 4-5 if the source supports it. A lesson may generalize the source's point.",
159
162
  "",
160
163
  "Source asset content:",
161
164
  "```",
162
- sourceContent.slice(0, 2000),
165
+ // The window distill generates from (buildDistillPrompt): grounding can reject, so the judge reads all of it.
166
+ sourceContent.slice(0, 3000),
163
167
  "```",
164
168
  ];
165
169
  if (similarLessons && similarLessons.length > 0) {
166
- lines.push("", "Existing similar lessons (top-3 by similarity). Rate lower if the proposed lesson is substantially similar to any of these:");
170
+ lines.push("", "Existing similar lessons (top-3 by similarity). Rate NOVELTY and NON-REDUNDANCY lower if the proposed lesson is substantially similar to any of these:");
167
171
  for (const sl of similarLessons)
168
172
  lines.push(`\nExisting lesson ref: ${sl.ref}`, "```", sl.content.slice(0, 500), "```");
169
173
  }
170
- lines.push("", "Proposed lesson content:", "```", lessonContent.slice(0, 1000), "```", "", 'Return ONLY valid JSON, no prose: {"scores": {"novelty": <1-5 integer>, "nonRedundancy": <1-5 integer>}, "reason": "<one sentence>"}');
174
+ lines.push("", "Proposed lesson content:", "```", lessonContent.slice(0, 1000), "```", "", 'Return ONLY valid JSON, no prose: {"scores": {"novelty": <1-5 integer>, "nonRedundancy": <1-5 integer>, "grounding": <1-5 integer>}, "reason": "<one sentence>"}');
171
175
  return lines.join("\n");
172
176
  }
173
177
  function boundedDocument(content, maxChars = 6000) {
@@ -227,12 +231,26 @@ export function buildReflectJudgePrompt(candidateContent, sourceContent, feedbac
227
231
  'Return ONLY valid JSON, no prose: {"scores": {"feedbackAlignment": <1-5 integer>, "preservation": <1-5 integer>, "quality": <1-5 integer>}, "reason": "<one sentence>"}',
228
232
  ].join("\n");
229
233
  }
230
- const LESSON_JUDGE_CRITERIA = ["novelty", "nonRedundancy"];
234
+ /**
235
+ * `grounding` is scored with the other lesson criteria but left out of their
236
+ * mean: a lesson about a different subject than its source reads as novel and
237
+ * non-redundant, so the mean would pass it (or, in the review band, mint it as
238
+ * a pending proposal). A score of {@link UNGROUNDED_MAX_SCORE} or less is a
239
+ * rejection whatever the mean says (#999). Only a different subject scores that
240
+ * low. A lesson that goes beyond or corrects its source is on its subject:
241
+ * distill folds feedback into the lesson, and the judge is never shown it. A
242
+ * contradiction of the source is the optional fidelity check's to send to a
243
+ * human (`judgeAndQueue` in distill.ts), so the rubric must not pre-empt it.
244
+ */
245
+ const GROUNDING_CRITERION = "grounding";
246
+ const UNGROUNDED_MAX_SCORE = 2;
247
+ const LESSON_JUDGE_CRITERIA = ["novelty", "nonRedundancy", GROUNDING_CRITERION];
231
248
  const REFLECT_JUDGE_CRITERIA = ["feedbackAlignment", "preservation", "quality"];
232
249
  /**
233
- * Read a judge response: the per-criterion shape (averaged here) or the older
234
- * `{"score"}` shape. Only the expected criteria are read; any missing or
235
- * out-of-range (1..5) value is a parse failure, extra keys are ignored.
250
+ * Read a judge response: the per-criterion shape (averaged here, `grounding`
251
+ * aside) or the older `{"score"}` shape. Only the expected criteria are read;
252
+ * any missing or out-of-range (1..5) value is a parse failure, extra keys are
253
+ * ignored.
236
254
  */
237
255
  function parseJudgeResponse(raw, keys) {
238
256
  const parsed = parseEmbeddedJsonResponse(raw);
@@ -251,7 +269,10 @@ function parseJudgeResponse(raw, keys) {
251
269
  return undefined;
252
270
  criteria[key] = value;
253
271
  }
254
- return { score: Object.values(criteria).reduce((a, b) => a + b, 0) / keys.length, reason, criteria };
272
+ const averaged = Object.entries(criteria)
273
+ .filter(([key]) => key !== GROUNDING_CRITERION)
274
+ .map(([, value]) => value);
275
+ return { score: averaged.reduce((a, b) => a + b, 0) / averaged.length, reason, criteria };
255
276
  }
256
277
  return inRange(parsed.score) ? { score: parsed.score, reason } : undefined;
257
278
  }
@@ -274,7 +295,8 @@ function judgeResponseSchema(keys) {
274
295
  /**
275
296
  * The quality judge. Fails closed: no runner, an unparseable verdict or a
276
297
  * provider failure never passes content. Bands: >= 3.5 pass, 2.5-3.5 review,
277
- * < 2.5 reject. Temperature is pinned to 0 so verdicts do not flip.
298
+ * < 2.5 reject; a `grounding` score of {@link UNGROUNDED_MAX_SCORE} or less
299
+ * rejects whatever the mean is. Temperature is pinned to 0 so verdicts do not flip.
278
300
  */
279
301
  async function runQualityJudge(feature, config, prompt, keys, chat, options) {
280
302
  const resolved = !options.runnerSelectionFrozen && !options.llmRunner
@@ -307,6 +329,15 @@ async function runQualityJudge(feature, config, prompt, keys, chat, options) {
307
329
  if (!parsed)
308
330
  return { pass: false, score: -1, reason: "judge parse failed — routed to review", reviewNeeded: true };
309
331
  const { score, reason, criteria } = parsed;
332
+ const grounding = criteria?.[GROUNDING_CRITERION];
333
+ if (criteria && grounding !== undefined && grounding <= UNGROUNDED_MAX_SCORE) {
334
+ return {
335
+ pass: false,
336
+ score,
337
+ reason: `Off-subject for its source (grounding ${grounding}/5): ${reason}`,
338
+ criteria,
339
+ };
340
+ }
310
341
  const verdict = score >= 3.5 ? { pass: true } : score >= 2.5 ? { pass: false, reviewNeeded: true } : { pass: false };
311
342
  return { ...verdict, score, reason, ...(criteria ? { criteria } : {}) };
312
343
  }
@@ -48,3 +48,24 @@ export function formatNewAssetDiff(ref, content) {
48
48
  }
49
49
  return lines.join("\n");
50
50
  }
51
+ /**
52
+ * Render the all-removals diff for a retire proposal (#997): the file its
53
+ * accept archives, line by line, and nothing added. Padding the proposed side
54
+ * with an empty string, as {@link formatUnifiedDiff} does, reads as "the whole
55
+ * file replaced by one blank line" — a retirement reviewed as data loss.
56
+ * `existing` is `null` when the retired file is already gone.
57
+ */
58
+ export function formatRetireDiff(ref, existing, successorRef) {
59
+ const destination = `+++ /dev/null (retired: archived${successorRef ? `; successor ${successorRef}` : ""})`;
60
+ if (existing === null)
61
+ return [`--- ${ref} (missing)`, destination].join("\n");
62
+ const removed = existing.split("\n");
63
+ if (removed[removed.length - 1] === "")
64
+ removed.pop(); // the newline ending the file is not a line of its own
65
+ return [
66
+ `--- ${ref} (existing)`,
67
+ destination,
68
+ `@@ 1,${removed.length} 0,0 @@`,
69
+ ...removed.map((line) => `-${line}`),
70
+ ].join("\n");
71
+ }