akm-cli 0.9.0-beta.5 → 0.9.0-beta.51

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 (221) hide show
  1. package/CHANGELOG.md +711 -0
  2. package/README.md +12 -4
  3. package/dist/akm +38 -0
  4. package/dist/akm-migrate-storage +38 -0
  5. package/dist/assets/profiles/default.json +9 -4
  6. package/dist/assets/profiles/frequent.json +1 -1
  7. package/dist/assets/profiles/memory-focus.json +1 -1
  8. package/dist/assets/profiles/quick.json +1 -1
  9. package/dist/assets/profiles/synthesize.json +15 -0
  10. package/dist/assets/profiles/thorough.json +1 -1
  11. package/dist/assets/prompts/consolidate-system.md +23 -0
  12. package/dist/assets/prompts/contradiction-judge.md +33 -0
  13. package/dist/assets/prompts/distill-knowledge-system.md +22 -0
  14. package/dist/assets/prompts/distill-lesson-system.md +36 -0
  15. package/dist/assets/prompts/extract-session.md +6 -2
  16. package/dist/assets/prompts/graph-extract-system.md +1 -0
  17. package/dist/assets/prompts/graph-extract-user-prompt.md +1 -1
  18. package/dist/assets/prompts/memory-infer-system.md +1 -0
  19. package/dist/assets/prompts/memory-infer-user.md +5 -0
  20. package/dist/assets/prompts/metadata-enhance-system.md +1 -0
  21. package/dist/assets/prompts/procedural-system.md +44 -0
  22. package/dist/assets/prompts/recombine-system.md +40 -0
  23. package/dist/assets/prompts/staleness-detect-system.md +6 -0
  24. package/dist/assets/prompts/validate-summary-judge.md +1 -0
  25. package/dist/assets/stash-skeleton/facts/conventions/assets/agent.md +38 -0
  26. package/dist/assets/stash-skeleton/facts/conventions/assets/command.md +38 -0
  27. package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +39 -0
  28. package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +40 -0
  29. package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +43 -0
  30. package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +38 -0
  31. package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +43 -0
  32. package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +40 -0
  33. package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +43 -0
  34. package/dist/assets/templates/html/health.html +281 -111
  35. package/dist/assets/wiki/ingest-workflow-template.md +38 -10
  36. package/dist/cli/parse-args.js +46 -1
  37. package/dist/cli/shared.js +28 -0
  38. package/dist/cli.js +27 -11
  39. package/dist/commands/agent/agent-dispatch.js +2 -2
  40. package/dist/commands/agent/agent-support.js +0 -7
  41. package/dist/commands/agent/contribute-cli.js +17 -4
  42. package/dist/commands/config-cli.js +18 -2
  43. package/dist/commands/env/child-env.js +47 -0
  44. package/dist/commands/env/env-cli.js +33 -26
  45. package/dist/commands/env/secret-cli.js +36 -22
  46. package/dist/commands/feedback-cli.js +15 -6
  47. package/dist/commands/graph/graph-cli.js +5 -13
  48. package/dist/commands/graph/graph.js +76 -72
  49. package/dist/commands/health/checks.js +49 -1
  50. package/dist/commands/health/html-report.js +422 -80
  51. package/dist/commands/health.js +386 -9
  52. package/dist/commands/improve/calibration.js +161 -0
  53. package/dist/commands/improve/consolidate/chunking.js +141 -0
  54. package/dist/commands/improve/consolidate/eligibility.js +81 -0
  55. package/dist/commands/improve/consolidate/merge.js +145 -0
  56. package/dist/commands/improve/consolidate/sanitize.js +231 -0
  57. package/dist/commands/{lint.js → improve/consolidate/types.js} +1 -1
  58. package/dist/commands/improve/consolidate.js +635 -660
  59. package/dist/commands/improve/dedup.js +482 -0
  60. package/dist/commands/improve/distill.js +159 -69
  61. package/dist/commands/improve/eligibility.js +434 -0
  62. package/dist/commands/improve/encoding-salience.js +205 -0
  63. package/dist/commands/improve/extract-cli.js +124 -2
  64. package/dist/commands/improve/extract-prompt.js +39 -2
  65. package/dist/commands/improve/extract-watch.js +140 -0
  66. package/dist/commands/improve/extract.js +389 -40
  67. package/dist/commands/improve/feedback-valence.js +54 -0
  68. package/dist/commands/improve/homeostatic.js +467 -0
  69. package/dist/commands/improve/improve-auto-accept.js +138 -7
  70. package/dist/commands/improve/improve-cli.js +36 -61
  71. package/dist/commands/improve/improve-profiles.js +14 -0
  72. package/dist/commands/improve/improve-result-file.js +14 -25
  73. package/dist/commands/improve/improve-session.js +58 -0
  74. package/dist/commands/improve/improve.js +485 -2498
  75. package/dist/commands/improve/locks.js +154 -0
  76. package/dist/commands/improve/loop-stages.js +1083 -0
  77. package/dist/commands/improve/memory/memory-contradiction-detect.js +23 -28
  78. package/dist/commands/improve/outcome-loop.js +256 -0
  79. package/dist/commands/improve/preparation.js +1966 -0
  80. package/dist/commands/improve/proactive-maintenance.js +115 -0
  81. package/dist/commands/improve/procedural.js +418 -0
  82. package/dist/commands/improve/recombine.js +850 -0
  83. package/dist/commands/improve/reflect-noise.js +0 -0
  84. package/dist/commands/improve/reflect.js +183 -40
  85. package/dist/commands/improve/salience.js +438 -0
  86. package/dist/commands/improve/triage.js +93 -0
  87. package/dist/commands/lint/agent-linter.js +19 -24
  88. package/dist/commands/lint/base-linter.js +173 -60
  89. package/dist/commands/lint/command-linter.js +19 -24
  90. package/dist/commands/lint/env-key-rules.js +38 -1
  91. package/dist/commands/lint/fact-linter.js +39 -0
  92. package/dist/commands/lint/index.js +31 -13
  93. package/dist/commands/lint/memory-linter.js +1 -1
  94. package/dist/commands/lint/registry.js +7 -2
  95. package/dist/commands/lint/task-linter.js +3 -3
  96. package/dist/commands/lint/workflow-linter.js +26 -1
  97. package/dist/commands/proposal/drain-policies.js +5 -0
  98. package/dist/commands/proposal/drain.js +43 -50
  99. package/dist/commands/proposal/proposal-cli.js +21 -31
  100. package/dist/commands/proposal/proposal.js +5 -0
  101. package/dist/commands/proposal/propose.js +7 -2
  102. package/dist/commands/proposal/validators/proposal-quality-validators.js +9 -8
  103. package/dist/commands/proposal/validators/proposals.js +189 -63
  104. package/dist/commands/read/curate.js +414 -94
  105. package/dist/commands/read/knowledge.js +6 -3
  106. package/dist/commands/read/search-cli.js +9 -4
  107. package/dist/commands/read/search.js +10 -6
  108. package/dist/commands/read/show.js +86 -7
  109. package/dist/commands/sources/init.js +49 -17
  110. package/dist/commands/sources/installed-stashes.js +11 -3
  111. package/dist/commands/sources/schema-repair.js +43 -45
  112. package/dist/commands/sources/self-update.js +2 -2
  113. package/dist/commands/sources/source-add.js +7 -3
  114. package/dist/commands/sources/stash-cli.js +28 -40
  115. package/dist/commands/sources/stash-skeleton.js +23 -8
  116. package/dist/commands/tasks/tasks-cli.js +19 -27
  117. package/dist/commands/tasks/tasks.js +39 -11
  118. package/dist/commands/wiki-cli.js +21 -35
  119. package/dist/core/asset/asset-registry.js +3 -1
  120. package/dist/core/asset/asset-spec.js +18 -2
  121. package/dist/core/asset/frontmatter.js +166 -167
  122. package/dist/core/asset/markdown.js +8 -0
  123. package/dist/core/authoring-rules.js +92 -0
  124. package/dist/core/common.js +0 -5
  125. package/dist/core/config/config-migration.js +12 -11
  126. package/dist/core/config/config-schema.js +340 -56
  127. package/dist/core/config/config-types.js +3 -3
  128. package/dist/core/config/config.js +28 -7
  129. package/dist/core/events.js +3 -7
  130. package/dist/core/improve-types.js +11 -8
  131. package/dist/core/logs-db.js +10 -66
  132. package/dist/core/parse.js +36 -16
  133. package/dist/core/paths.js +3 -0
  134. package/dist/core/standards/resolve-standards-context.js +87 -0
  135. package/dist/core/standards/resolve-stash-standards.js +99 -0
  136. package/dist/core/standards/resolve-type-conventions.js +66 -0
  137. package/dist/core/state/migrations.js +714 -0
  138. package/dist/core/state-db.js +525 -474
  139. package/dist/indexer/db/db.js +439 -247
  140. package/dist/indexer/db/graph-db.js +129 -86
  141. package/dist/indexer/ensure-index.js +152 -17
  142. package/dist/indexer/graph/graph-boost.js +51 -41
  143. package/dist/indexer/graph/graph-extraction.js +218 -4
  144. package/dist/indexer/index-writer-lock.js +99 -0
  145. package/dist/indexer/indexer.js +123 -221
  146. package/dist/indexer/passes/dir-staleness.js +114 -0
  147. package/dist/indexer/passes/memory-inference.js +13 -5
  148. package/dist/indexer/passes/staleness-detect.js +2 -5
  149. package/dist/indexer/search/db-search.js +19 -6
  150. package/dist/indexer/search/ranking-contributors.js +22 -0
  151. package/dist/indexer/search/ranking.js +4 -0
  152. package/dist/indexer/search/search-source.js +17 -18
  153. package/dist/indexer/search/semantic-status.js +4 -0
  154. package/dist/indexer/walk/matchers.js +9 -0
  155. package/dist/integrations/agent/config.js +6 -53
  156. package/dist/integrations/agent/index.js +2 -18
  157. package/dist/integrations/agent/prompts.js +75 -9
  158. package/dist/integrations/agent/runner-dispatch.js +59 -0
  159. package/dist/integrations/harnesses/claude/session-log.js +11 -1
  160. package/dist/integrations/harnesses/index.js +2 -3
  161. package/dist/integrations/harnesses/opencode/session-log.js +173 -3
  162. package/dist/integrations/harnesses/opencode-sdk/index.js +2 -2
  163. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +0 -2
  164. package/dist/integrations/session-logs/index.js +16 -0
  165. package/dist/llm/client.js +45 -15
  166. package/dist/llm/embedder.js +42 -3
  167. package/dist/llm/embedders/deterministic.js +66 -0
  168. package/dist/llm/embedders/local.js +66 -2
  169. package/dist/llm/feature-gate.js +8 -4
  170. package/dist/llm/graph-extract.js +67 -44
  171. package/dist/llm/memory-infer-impl.js +138 -0
  172. package/dist/llm/memory-infer.js +1 -127
  173. package/dist/llm/metadata-enhance.js +44 -31
  174. package/dist/llm/structured-call.js +49 -0
  175. package/dist/migrate-storage-node.mjs +8 -0
  176. package/dist/output/context.js +5 -5
  177. package/dist/output/renderers.js +74 -2
  178. package/dist/output/shapes/curate.js +14 -2
  179. package/dist/output/shapes/passthrough.js +0 -1
  180. package/dist/output/text/helpers.js +16 -1
  181. package/dist/registry/providers/skills-sh.js +21 -147
  182. package/dist/registry/providers/static-index.js +15 -157
  183. package/dist/registry/resolve.js +22 -9
  184. package/dist/runtime.js +25 -1
  185. package/dist/scripts/migrate-storage.js +2617 -1961
  186. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +759 -510
  187. package/dist/setup/setup.js +29 -8
  188. package/dist/sources/include.js +6 -2
  189. package/dist/sources/providers/filesystem.js +0 -1
  190. package/dist/sources/providers/git-install.js +210 -0
  191. package/dist/sources/providers/git-provider.js +234 -0
  192. package/dist/sources/providers/git-stash.js +248 -0
  193. package/dist/sources/providers/git.js +10 -661
  194. package/dist/sources/providers/npm.js +2 -6
  195. package/dist/sources/providers/provider-utils.js +13 -7
  196. package/dist/sources/providers/sync-from-ref.js +9 -1
  197. package/dist/sources/providers/tar-utils.js +16 -8
  198. package/dist/sources/providers/website.js +9 -5
  199. package/dist/sources/website-ingest.js +187 -29
  200. package/dist/sources/wiki-fetchers/registry.js +53 -0
  201. package/dist/sources/wiki-fetchers/youtube.js +239 -0
  202. package/dist/storage/database.js +45 -10
  203. package/dist/storage/managed-db.js +82 -0
  204. package/dist/storage/repositories/registry-cache.js +92 -0
  205. package/dist/storage/sqlite-pragmas.js +146 -0
  206. package/dist/tasks/backends/cron.js +1 -1
  207. package/dist/tasks/backends/launchd.js +1 -1
  208. package/dist/tasks/backends/schtasks.js +1 -1
  209. package/dist/tasks/{resolveAkmBin.js → resolve-akm-bin.js} +2 -2
  210. package/dist/tasks/runner.js +5 -13
  211. package/dist/text-import-hook.mjs +0 -0
  212. package/dist/wiki/wiki.js +37 -0
  213. package/dist/workflows/db.js +3 -4
  214. package/dist/workflows/runtime/runs.js +1 -117
  215. package/dist/workflows/runtime/workflow-asset-loader.js +125 -0
  216. package/dist/workflows/validate-summary.js +2 -7
  217. package/docs/data-and-telemetry.md +3 -2
  218. package/docs/migration/release-notes/0.9.0.md +39 -0
  219. package/package.json +13 -11
  220. package/dist/commands/db-cli.js +0 -23
  221. package/dist/indexer/db/db-backup.js +0 -376
@@ -0,0 +1,1083 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ import path from "node:path";
5
+ import { parseAssetRef } from "../../core/asset/asset-ref.js";
6
+ import { daysToMs } from "../../core/common.js";
7
+ import { loadConfig } from "../../core/config/config.js";
8
+ import { UsageError } from "../../core/errors.js";
9
+ import { appendEvent } from "../../core/events.js";
10
+ import { openLogsDatabase, purgeOldTaskLogs } from "../../core/logs-db.js";
11
+ import { getDbPath } from "../../core/paths.js";
12
+ import { purgeOldEvents, purgeOldImproveRuns, withStateDb } from "../../core/state-db.js";
13
+ import { info, warn } from "../../core/warn.js";
14
+ import { closeDatabase, openIndexDatabase } from "../../indexer/db/db.js";
15
+ import { runGraphExtractionPass } from "../../indexer/graph/graph-extraction.js";
16
+ import { withIndexWriterLease } from "../../indexer/index-writer-lock.js";
17
+ import { collectPendingMemories, runMemoryInferencePass, } from "../../indexer/passes/memory-inference.js";
18
+ import { runStalenessDetectionPass } from "../../indexer/passes/staleness-detect.js";
19
+ import { getWritableStashDirs, resolveSourceEntries } from "../../indexer/search/search-source.js";
20
+ import { resolveImproveProcessRunnerFromProfile } from "../../integrations/agent/runner.js";
21
+ import { isProcessEnabled } from "../../llm/feature-gate.js";
22
+ import { withLlmStage } from "../../llm/usage-telemetry.js";
23
+ import { createProposal, expireStaleProposals, isProposalSkipped, listProposals, purgeOrphanProposals, } from "../proposal/validators/proposals.js";
24
+ import { checkDeadUrls } from "../url-checker.js";
25
+ import { deriveLessonRef } from "./distill.js";
26
+ import { deriveKnowledgeRef } from "./distill-promotion-policy.js";
27
+ // Eligibility / candidate-selection predicates live in ./eligibility.
28
+ import { findAssetFilePath, isDistillCandidateRef } from "./eligibility.js";
29
+ import { writeEvalCase } from "./eval-cases.js";
30
+ import { makeGateConfig, runAutoAcceptGate } from "./improve-auto-accept.js";
31
+ import { resolveProcessEnabled, shouldSkipRef } from "./improve-profiles.js";
32
+ // The pre-loop preparation pipeline lives in ./preparation.
33
+ import { maybeAutoTuneThreshold } from "./preparation.js";
34
+ import { akmProcedural } from "./procedural.js";
35
+ import { akmRecombine } from "./recombine.js";
36
+ import { recordNoOp, resetConsecutiveNoOps } from "./salience.js";
37
+ // ── improve loop / post-loop / maintenance stages ───────────────────
38
+ // The cycle stages run by akmImprove, extracted from improve.ts.
39
+ export async function runImproveLoopStage(args) {
40
+ const { scope, options, primaryStashDir, reflectFn, distillFn, loopRefs, actions, signalBearingSet, distillCooledRefs, distillOnlyRefs, recentErrors, rejectedProposalsByRef, utilityMap, startMs, budgetMs, eventsCtx, improveProfile, } = args;
41
+ // O-1 (#364): compute remaining budget at call time so each sub-call
42
+ // receives only its fair share of the wall-clock budget.
43
+ const remainingBudgetMs = () => Math.max(0, budgetMs - (Date.now() - startMs));
44
+ const RECENT_ERRORS_CAP = 3;
45
+ // requirePlannedRefs guard: when the distill profile sets this flag, skip
46
+ // distill for distill-only refs if the reflect phase produced no planned refs.
47
+ // Prevents the distill loop from generating hundreds of distill-skipped events
48
+ // on quiet passes (all refs on reflect cooldown, no new signal to distill).
49
+ const requirePlannedRefs = improveProfile?.processes?.distill?.requirePlannedRefs === true;
50
+ const _distillOnlyRefNames = new Set(distillOnlyRefs.map((r) => r.ref));
51
+ const hasReflectEligibleRefs = loopRefs.some((r) => !_distillOnlyRefNames.has(r.ref));
52
+ const skipDistillDueToRequirePlannedRefs = requirePlannedRefs && !hasReflectEligibleRefs;
53
+ // R-2 / #389: Self-Consistency multi-sample voting helpers.
54
+ // Wang et al. arXiv:2203.11171 — N=3 samples beat single-shot on reasoning tasks.
55
+ const SC_THRESHOLD = options.selfConsistencyThreshold ?? 0.7;
56
+ const SC_N = Math.min(Math.max(2, options.selfConsistencyN ?? 3), 5);
57
+ /**
58
+ * Compute Jaccard token overlap between two strings.
59
+ * Tokenizes by whitespace; returns 0 when both are empty.
60
+ */
61
+ function jaccardSimilarity(a, b) {
62
+ const tokensA = new Set(a.split(/\s+/).filter(Boolean));
63
+ const tokensB = new Set(b.split(/\s+/).filter(Boolean));
64
+ if (tokensA.size === 0 && tokensB.size === 0)
65
+ return 1;
66
+ let intersection = 0;
67
+ for (const t of tokensA) {
68
+ if (tokensB.has(t))
69
+ intersection++;
70
+ }
71
+ const union = tokensA.size + tokensB.size - intersection;
72
+ return union > 0 ? intersection / union : 0;
73
+ }
74
+ /**
75
+ * Given N reflect results, return the one with the highest average Jaccard
76
+ * similarity to all other successful results (majority-vote winner).
77
+ * Falls back to the first successful result when N < 2.
78
+ */
79
+ function pickMajorityVote(results) {
80
+ const successful = results.filter((r) => r.ok);
81
+ if (successful.length === 0)
82
+ return (results[0] ?? {
83
+ schemaVersion: 1,
84
+ ok: false,
85
+ reason: "non_zero_exit",
86
+ error: "all samples failed",
87
+ exitCode: null,
88
+ });
89
+ if (successful.length === 1)
90
+ return successful[0];
91
+ let bestIdx = 0;
92
+ let bestScore = -1;
93
+ for (let i = 0; i < successful.length; i++) {
94
+ let totalSim = 0;
95
+ for (let j = 0; j < successful.length; j++) {
96
+ if (i === j)
97
+ continue;
98
+ totalSim += jaccardSimilarity(successful[i].proposal.payload.content ?? "", successful[j].proposal.payload.content ?? "");
99
+ }
100
+ const avgSim = totalSim / (successful.length - 1);
101
+ if (avgSim > bestScore) {
102
+ bestScore = avgSim;
103
+ bestIdx = i;
104
+ }
105
+ }
106
+ return successful[bestIdx] ?? successful[0];
107
+ }
108
+ // O-5 / #378: helper to push per-originator errors into the rolling window.
109
+ function pushRecentError(originator, msg) {
110
+ if (!recentErrors[originator])
111
+ recentErrors[originator] = [];
112
+ recentErrors[originator].push(msg);
113
+ if (recentErrors[originator].length > RECENT_ERRORS_CAP)
114
+ recentErrors[originator].shift();
115
+ }
116
+ // Build a Set for O(1) membership test — these refs skip the reflect call (Bug D2).
117
+ const distillOnlyRefSet = new Set(distillOnlyRefs.map((r) => r.ref));
118
+ let completedCount = 0;
119
+ let reflectsWithErrorContext = 0;
120
+ const memoryRefsForInference = new Set();
121
+ // Pre-load all pending proposals once instead of querying per asset in the loop.
122
+ const dedupeStashDirForProposals = primaryStashDir ?? options.stashDir;
123
+ const pendingProposalRefSet = new Set(dedupeStashDirForProposals
124
+ ? listProposals(dedupeStashDirForProposals, { status: "pending" }).map((p) => p.ref)
125
+ : []);
126
+ let gateAutoAcceptedCount = 0;
127
+ let gateAutoAcceptFailedCount = 0;
128
+ const reflectGateCfg = makeGateConfig("reflect", {
129
+ globalThreshold: options.autoAccept,
130
+ dryRun: options.dryRun ?? false,
131
+ stashDir: primaryStashDir,
132
+ config: options.config ?? loadConfig(),
133
+ eventsCtx,
134
+ stateDbPath: eventsCtx?.dbPath,
135
+ // candidateCount drives the exploration budget. loopRefs is the per-phase
136
+ // set for reflect/distill; pass it so exploration budget is proportional.
137
+ candidateCount: loopRefs.length,
138
+ });
139
+ const distillGateCfg = makeGateConfig("distill", {
140
+ globalThreshold: options.autoAccept,
141
+ dryRun: options.dryRun ?? false,
142
+ stashDir: primaryStashDir,
143
+ config: options.config ?? loadConfig(),
144
+ eventsCtx,
145
+ stateDbPath: eventsCtx?.dbPath,
146
+ candidateCount: loopRefs.length,
147
+ });
148
+ for (const planned of loopRefs) {
149
+ if (Date.now() - startMs >= budgetMs) {
150
+ const remaining = loopRefs.length - completedCount;
151
+ info(`[improve] budget exhausted after ${Math.round((Date.now() - startMs) / 60000)}min — ${remaining} assets skipped`);
152
+ appendEvent({
153
+ eventType: "improve_skipped",
154
+ ref: planned.ref,
155
+ metadata: {
156
+ reason: "budget_exhausted",
157
+ remaining,
158
+ },
159
+ }, eventsCtx);
160
+ // B11: Emit improve_skipped for all remaining assets that will not be processed.
161
+ for (const remainingRef of loopRefs.slice(completedCount + 1)) {
162
+ appendEvent({
163
+ eventType: "improve_skipped",
164
+ ref: remainingRef.ref,
165
+ metadata: { reason: "budget_exhausted_batch", remaining: loopRefs.length - completedCount - 1 },
166
+ }, eventsCtx);
167
+ }
168
+ actions.push({
169
+ ref: planned.ref,
170
+ mode: "error",
171
+ result: { ok: false, error: "timeout: improve wall-clock budget exhausted" },
172
+ });
173
+ break;
174
+ }
175
+ try {
176
+ // Bug D2: distillOnlyRefs skip the reflect call but still run the distill path.
177
+ // Bug D1: in-loop distill-cooldown check removed — distill-cooled candidates
178
+ // have their synthetic actions emitted in runImprovePreparationStage.
179
+ const isDistillOnly = distillOnlyRefSet.has(planned.ref);
180
+ const parsedPlannedRef = parseAssetRef(planned.ref);
181
+ // B6: derived memories are machine-generated; skip reflect to avoid noisy proposals.
182
+ // shouldDistillMemoryRef already returns false for .derived refs, so the distill
183
+ // path is also a no-op for them — we just avoid unnecessary agent spawns.
184
+ // D2: distillOnlyRefs also skip the reflect call (reflect-cooled, distill path only).
185
+ if (!isDistillOnly && !planned.ref.endsWith(".derived")) {
186
+ // Type guard: skip reflect for unsupported types (script, env, task, etc.)
187
+ // and raw wiki directories, driven by the active improve profile.
188
+ const reflectSkip = shouldSkipRef(planned.ref, "reflect", improveProfile);
189
+ if (reflectSkip.skip) {
190
+ actions.push({
191
+ ref: planned.ref,
192
+ mode: "reflect-skipped",
193
+ result: { ok: true, reason: reflectSkip.reason },
194
+ });
195
+ }
196
+ else {
197
+ // O-5 / #378: only inject reflect-originator errors into the reflect call.
198
+ // Cross-task errors (e.g. schema-repair) must NOT contaminate reflect prompts.
199
+ const reflectErrors = recentErrors.reflect ?? [];
200
+ if (reflectErrors.length > 0)
201
+ reflectsWithErrorContext++;
202
+ // O-1 (#364): pass remaining budget as timeoutMs so the agent spawn is
203
+ // bounded by the wall-clock deadline rather than the default per-profile timeout.
204
+ const reflectBudgetMs = remainingBudgetMs();
205
+ // Wire profile.processes.reflect.{mode, profile, timeoutMs} into the reflect
206
+ // dispatch when present. Falls back to akmReflect's own config-based resolution
207
+ // (profiles.improve.<name>.processes.reflect → defaults.llm) when the profile
208
+ // does not specify.
209
+ const reflectProfileRunner = resolveImproveProcessRunnerFromProfile(improveProfile.processes?.reflect, options.config ?? loadConfig());
210
+ const reflectCallArgs = {
211
+ ref: planned.ref,
212
+ task: options.task,
213
+ ...(options.stashDir ? { stashDir: options.stashDir } : {}),
214
+ ...(reflectErrors.length > 0 ? { avoidPatterns: [...reflectErrors] } : {}),
215
+ agentProcess: options.agentProcess ?? "reflect",
216
+ eventSource: "improve",
217
+ // #639 — resolve the low-value filter from the ACTIVE improve profile
218
+ // (default off when unset), so the running profile decides instead of
219
+ // a hardcoded profiles.improve.default path.
220
+ lowValueFilter: improveProfile.processes?.reflect?.lowValueFilter?.enabled === true,
221
+ ...(reflectBudgetMs > 0 ? { timeoutMs: reflectBudgetMs } : {}),
222
+ ...(reflectProfileRunner ? { runner: reflectProfileRunner } : {}),
223
+ // Attribution: carry the eligibility lane so reflect stamps it on
224
+ // the reflect_invoked event and the persisted proposal.
225
+ ...(planned.eligibilitySource ? { eligibilitySource: planned.eligibilitySource } : {}),
226
+ };
227
+ // R-2 / #389: Self-consistency multi-sample voting for high-utility refs.
228
+ // Self-Consistency arXiv:2203.11171 — N=3 samples beat single-shot quality.
229
+ const refUtility = utilityMap.get(planned.ref) ?? 0;
230
+ const useConsistency = refUtility >= SC_THRESHOLD && SC_N >= 2;
231
+ let reflectResult;
232
+ if (useConsistency) {
233
+ const samples = [];
234
+ for (let s = 0; s < SC_N; s++) {
235
+ if (remainingBudgetMs() <= 0)
236
+ break;
237
+ // draftMode: skip DB write so each sample doesn't create a proposal.
238
+ samples.push(await withLlmStage("reflect", () => reflectFn({ ...reflectCallArgs, draftMode: true })));
239
+ }
240
+ const winner = pickMajorityVote(samples.length > 0
241
+ ? samples
242
+ : [await withLlmStage("reflect", () => reflectFn({ ...reflectCallArgs, draftMode: true }))]);
243
+ // Persist only the majority-vote winner as a single real proposal.
244
+ if (winner.ok && primaryStashDir) {
245
+ const persistResult = createProposal(primaryStashDir, {
246
+ ref: winner.proposal.ref,
247
+ source: "reflect",
248
+ sourceRun: `reflect-sc-${Date.now()}`,
249
+ payload: winner.proposal.payload,
250
+ // Attribution: the self-consistency path persists the winner here
251
+ // (draftMode skips reflect's own createProposal), so stamp the lane.
252
+ ...(planned.eligibilitySource ? { eligibilitySource: planned.eligibilitySource } : {}),
253
+ });
254
+ reflectResult = isProposalSkipped(persistResult)
255
+ ? {
256
+ schemaVersion: 1,
257
+ ok: false,
258
+ reason: "cooldown",
259
+ error: `SC proposal skipped: ${persistResult.message}`,
260
+ ref: winner.ref,
261
+ exitCode: null,
262
+ }
263
+ : { ...winner, proposal: persistResult };
264
+ }
265
+ else {
266
+ reflectResult = winner;
267
+ }
268
+ }
269
+ else {
270
+ reflectResult = await withLlmStage("reflect", () => reflectFn(reflectCallArgs));
271
+ }
272
+ const isCooldown = !reflectResult.ok && reflectResult.reason === "cooldown";
273
+ // Content-policy guard hits (reflect size-rail rejections) are NOT
274
+ // LLM faults — the agent responded fine, the downstream guard
275
+ // blocked the output. Route them to a distinct `reflect-guard-rejected`
276
+ // mode so health metrics can split deterministic guard hits out of
277
+ // true LLM failures. See
278
+ // `/tmp/akm-health-investigations/metrics-taxonomy-review.md` §1a.
279
+ const isGuardReject = !reflectResult.ok && reflectResult.reason === "content_policy_reject";
280
+ // Type-guard rejection (reflect refused a script/env/task ref) is
281
+ // also NOT an LLM failure — the LLM is never invoked. Route to the
282
+ // existing `reflect-skipped` bucket so it does not inflate the
283
+ // failure-rate numerator. ~9% of `reflect-failed` events in the
284
+ // user's stack were this case; see review §1a row "Reflect refused
285
+ // asset type".
286
+ const isTypeRefused = !reflectResult.ok && reflectResult.reason === "unsupported_type";
287
+ // Noise-gate suppression (#580): the candidate edit was an empty
288
+ // diff or a cosmetic-only reformat of the current asset. Like
289
+ // `unsupported_type`, this is a deterministic skip — not an LLM
290
+ // fault — so it routes to the `reflect-skipped` bucket and stays
291
+ // out of recentErrors/avoidPatterns.
292
+ const isNoChange = !reflectResult.ok && reflectResult.reason === "no_change";
293
+ actions.push({
294
+ ref: planned.ref,
295
+ mode: reflectResult.ok
296
+ ? "reflect"
297
+ : isCooldown
298
+ ? "reflect-cooldown"
299
+ : isGuardReject
300
+ ? "reflect-guard-rejected"
301
+ : isTypeRefused || isNoChange
302
+ ? "reflect-skipped"
303
+ : "reflect-failed",
304
+ result: reflectResult,
305
+ });
306
+ // Cooldown skips, guard rejects, type-refused skips, and noise-gate
307
+ // skips are not failures — do not pollute recentErrors with them
308
+ // (those get injected as `avoidPatterns` into the next reflect
309
+ // prompt). Guard rejects ARE worth showing the LLM as a learn-signal
310
+ // so the next iteration sees "your last expansion was too large";
311
+ // type-refused and no-change are deterministic and add no learning
312
+ // signal.
313
+ if (!reflectResult.ok && !isCooldown && !isTypeRefused && !isNoChange) {
314
+ const errMsg = reflectResult.error ?? reflectResult.reason ?? "unknown reflect error";
315
+ pushRecentError("reflect", errMsg);
316
+ }
317
+ // improve_reflect_outcome — per-asset metric for tuning the reflect path.
318
+ appendEvent({
319
+ eventType: "improve_reflect_outcome",
320
+ ref: planned.ref,
321
+ metadata: {
322
+ ok: reflectResult.ok,
323
+ durationMs: reflectResult.ok ? reflectResult.durationMs : undefined,
324
+ agentProfile: reflectResult.ok ? reflectResult.agentProfile : undefined,
325
+ reason: reflectResult.ok ? undefined : reflectResult.reason,
326
+ },
327
+ }, eventsCtx);
328
+ // Plasticity counter (plan §WS-1 step 8): record no-ops so the
329
+ // WS-1 selection comparator (effectiveScore, ~line 3073) can dampen
330
+ // repeatedly-silent assets during consolidation-selection.
331
+ // A no_change reflect means the LLM was invoked but found nothing to
332
+ // improve — the asset is stable. Track it. A successful reflect means
333
+ // the asset changed; reset the counter so the dampener lifts.
334
+ if (isNoChange && eventsCtx?.db) {
335
+ try {
336
+ recordNoOp(eventsCtx.db, planned.ref);
337
+ }
338
+ catch {
339
+ // best-effort: plasticity counter failure never blocks the run
340
+ }
341
+ }
342
+ else if (reflectResult.ok && eventsCtx?.db) {
343
+ try {
344
+ resetConsecutiveNoOps(eventsCtx.db, planned.ref);
345
+ }
346
+ catch {
347
+ // best-effort
348
+ }
349
+ }
350
+ if (reflectResult.ok) {
351
+ const reflectGr = await runAutoAcceptGate([{ proposalId: reflectResult.proposal.id, confidence: reflectResult.proposal.confidence }], reflectGateCfg);
352
+ gateAutoAcceptedCount += reflectGr.promoted.length;
353
+ gateAutoAcceptFailedCount += reflectGr.failed.length;
354
+ }
355
+ } // end else (reflect type/profile check)
356
+ }
357
+ else if (!isDistillOnly && planned.ref.endsWith(".derived")) {
358
+ // B6: .derived refs skip reflect; record synthetic skip action.
359
+ actions.push({
360
+ ref: planned.ref,
361
+ mode: "distill-skipped",
362
+ result: { ok: true, reason: "derived-memory-reflect-skipped" },
363
+ });
364
+ appendEvent({
365
+ eventType: "improve_skipped",
366
+ ref: planned.ref,
367
+ metadata: { reason: "derived_memory_reflect_skipped" },
368
+ }, eventsCtx);
369
+ }
370
+ // isDistillOnly refs: no reflect action emitted — proceed directly to distill path below.
371
+ const hasRecentFeedbackSignal = signalBearingSet.has(planned.ref);
372
+ const explicitRefScope = scope.mode === "ref";
373
+ // Profile gate: apply the full type-filter / raw-wiki / disabled rules to
374
+ // distill so callers who configure `profile.processes.distill.allowedTypes`
375
+ // or land on raw-wiki refs get a recorded skip action instead of silently
376
+ // proceeding.
377
+ const distillSkip = shouldSkipRef(planned.ref, "distill", improveProfile);
378
+ if (distillSkip.skip) {
379
+ actions.push({
380
+ ref: planned.ref,
381
+ mode: "distill-skipped",
382
+ result: { ok: true, reason: distillSkip.reason },
383
+ });
384
+ completedCount++;
385
+ info(`[improve] ${completedCount}/${loopRefs.length} ${planned.ref}`);
386
+ continue;
387
+ }
388
+ // requirePlannedRefs guard: skip distill for distill-only refs when no
389
+ // reflect-eligible refs were planned this run, preventing mass skip events.
390
+ if (skipDistillDueToRequirePlannedRefs && isDistillOnly) {
391
+ actions.push({
392
+ ref: planned.ref,
393
+ mode: "distill-skipped",
394
+ result: { ok: true, reason: "require_planned_refs" },
395
+ });
396
+ completedCount++;
397
+ info(`[improve] ${completedCount}/${loopRefs.length} ${planned.ref}`);
398
+ continue;
399
+ }
400
+ // See `isDistillCandidateRef` — excludes `lesson:*` (and anything else in
401
+ // DISTILL_REFUSED_INPUT_TYPES) so distill never gets queued for an input
402
+ // it will refuse.
403
+ const shouldAttemptDistill = isDistillCandidateRef(planned.ref, options.stashDir);
404
+ const skipMemoryDistillForWeakSignal = !isDistillOnly && parsedPlannedRef.type === "memory" && !hasRecentFeedbackSignal && !explicitRefScope;
405
+ // distillCooledRefs guard: pre-filter emitted synthetic actions for distill-candidate
406
+ // refs; non-candidate refs in the set are blocked here.
407
+ // O-2 (#365): bypass the distill cooldown when the user explicitly targeted
408
+ // this ref via --scope — their intent overrides unattended-run policies.
409
+ if (shouldAttemptDistill &&
410
+ !skipMemoryDistillForWeakSignal &&
411
+ (!distillCooledRefs.has(planned.ref) || explicitRefScope)) {
412
+ // TODO(refactor): single call site needs both lesson+knowledge refs for proposal dedup. If a third target ref type is added, extract deriveAllTargetRefs(inputRef): string[].
413
+ const lessonRef = deriveLessonRef(planned.ref);
414
+ const knowledgeRef = deriveKnowledgeRef(planned.ref);
415
+ const dedupeStashDir = primaryStashDir ?? options.stashDir;
416
+ if (dedupeStashDir) {
417
+ // B2: check both lesson ref and knowledge ref since auto-promoted memories
418
+ // create knowledge: proposals, not lesson: proposals.
419
+ const hasExistingPending = pendingProposalRefSet.has(lessonRef) || pendingProposalRefSet.has(knowledgeRef);
420
+ if (hasExistingPending) {
421
+ actions.push({
422
+ ref: planned.ref,
423
+ mode: "distill-skipped",
424
+ result: { ok: true, reason: "pending proposal exists" },
425
+ });
426
+ appendEvent({
427
+ eventType: "improve_skipped",
428
+ ref: planned.ref,
429
+ metadata: { reason: "pending_proposal_exists" },
430
+ }, eventsCtx);
431
+ completedCount++;
432
+ info(`[improve] ${completedCount}/${loopRefs.length} ${planned.ref}`);
433
+ continue;
434
+ }
435
+ // D-2 (#370): reject-aware cooldown for distill. When the reviewer
436
+ // recently rejected a distilled lesson or knowledge proposal for this
437
+ // asset, skip re-distillation for a 1-day grace window. Prevents the
438
+ // same rejected proposal from being regenerated immediately. The
439
+ // window is fixed (the 0.8.0 redesign moved per-ref cooldowns to
440
+ // signal-delta gates and dropped --distill-cooldown-days; a short
441
+ // reject grace is preserved here so a fresh rejection isn't
442
+ // overridden by the same run).
443
+ // References: ExpeL arXiv:2308.10144, STaR arXiv:2203.14465.
444
+ const DISTILL_REJECT_COOLDOWN_MS = daysToMs(1);
445
+ const recentlyRejectedLesson = !explicitRefScope && // O-2: bypass when --scope <ref> is explicit
446
+ (rejectedProposalsByRef.has(lessonRef) || rejectedProposalsByRef.has(knowledgeRef));
447
+ if (recentlyRejectedLesson) {
448
+ const rejectedEntry = rejectedProposalsByRef.get(lessonRef) ?? rejectedProposalsByRef.get(knowledgeRef);
449
+ const rejectedAgeMs = rejectedEntry ? Date.now() - new Date(rejectedEntry.ts).getTime() : 0;
450
+ if (rejectedAgeMs < DISTILL_REJECT_COOLDOWN_MS) {
451
+ actions.push({
452
+ ref: planned.ref,
453
+ mode: "distill-skipped",
454
+ result: { ok: true, reason: "distill reject grace window" },
455
+ });
456
+ appendEvent({
457
+ eventType: "improve_skipped",
458
+ ref: planned.ref,
459
+ metadata: {
460
+ reason: "distill_reject_grace_window",
461
+ },
462
+ }, eventsCtx);
463
+ completedCount++;
464
+ info(`[improve] ${completedCount}/${loopRefs.length} ${planned.ref}`);
465
+ continue;
466
+ }
467
+ }
468
+ }
469
+ const distillResult = await withLlmStage("distill", () => distillFn({
470
+ ref: planned.ref,
471
+ ...(parsedPlannedRef.type === "memory" ? { proposalKind: "auto" } : {}),
472
+ ...(options.stashDir ? { stashDir: options.stashDir } : {}),
473
+ // Attribution: carry the eligibility lane so distill stamps it on the
474
+ // distill_invoked event and the persisted proposal.
475
+ ...(planned.eligibilitySource ? { eligibilitySource: planned.eligibilitySource } : {}),
476
+ }));
477
+ actions.push({ ref: planned.ref, mode: "distill", result: distillResult });
478
+ if (distillResult.outcome === "queued" && distillResult.proposal) {
479
+ const distillGr = await runAutoAcceptGate([{ proposalId: distillResult.proposal.id, confidence: distillResult.proposal.confidence }], distillGateCfg);
480
+ gateAutoAcceptedCount += distillGr.promoted.length;
481
+ gateAutoAcceptFailedCount += distillGr.failed.length;
482
+ }
483
+ if (parsedPlannedRef.type === "memory") {
484
+ const promotedToKnowledge = distillResult.outcome === "queued" && distillResult.proposalKind === "knowledge";
485
+ if (!promotedToKnowledge)
486
+ memoryRefsForInference.add(planned.ref);
487
+ }
488
+ // Plasticity counter (plan §WS-1 step 8) for the distill path.
489
+ // quality_rejected: the LLM ran but produced output that didn't pass the
490
+ // quality gate — the asset is not yielding useful distill output.
491
+ // queued: a proposal was produced; reset the no-op counter.
492
+ if (eventsCtx?.db) {
493
+ try {
494
+ if (distillResult.outcome === "quality_rejected" || distillResult.outcome === "skipped") {
495
+ recordNoOp(eventsCtx.db, planned.ref);
496
+ }
497
+ else if (distillResult.outcome === "queued") {
498
+ resetConsecutiveNoOps(eventsCtx.db, planned.ref);
499
+ }
500
+ }
501
+ catch {
502
+ // best-effort: plasticity counter failure never blocks the run
503
+ }
504
+ }
505
+ if (distillResult.outcome === "quality_rejected" && primaryStashDir) {
506
+ const slug = planned.ref
507
+ .replace(/[^a-z0-9]/gi, "-")
508
+ .toLowerCase()
509
+ .slice(0, 60);
510
+ writeEvalCase(primaryStashDir, {
511
+ ref: planned.ref,
512
+ failureReason: distillResult.reason ?? "quality gate rejected",
513
+ assetType: parseAssetRef(planned.ref).type ?? "unknown",
514
+ rejectedAt: Date.now(),
515
+ source: "distill_quality_rejected",
516
+ slug: `${slug}-${Date.now()}`,
517
+ });
518
+ }
519
+ // D6: use pre-loaded map instead of per-iteration DB query
520
+ const rejectedProposalEvent = rejectedProposalsByRef.get(planned.ref);
521
+ if (rejectedProposalEvent && primaryStashDir) {
522
+ const slug = planned.ref
523
+ .replace(/[^a-z0-9]/gi, "-")
524
+ .toLowerCase()
525
+ .slice(0, 60);
526
+ writeEvalCase(primaryStashDir, {
527
+ ref: planned.ref,
528
+ failureReason: rejectedProposalEvent.metadata?.reason ?? "proposal rejected",
529
+ assetType: parseAssetRef(planned.ref).type ?? "unknown",
530
+ rejectedAt: new Date(rejectedProposalEvent.ts).getTime(),
531
+ source: "proposal_rejected",
532
+ slug: `${slug}-rejected`,
533
+ });
534
+ }
535
+ }
536
+ else if (skipMemoryDistillForWeakSignal) {
537
+ actions.push({
538
+ ref: planned.ref,
539
+ mode: "distill-skipped",
540
+ result: { ok: true, reason: "memory requires recent feedback signal" },
541
+ });
542
+ appendEvent({
543
+ eventType: "improve_skipped",
544
+ ref: planned.ref,
545
+ metadata: { reason: "memory_distill_requires_feedback" },
546
+ }, eventsCtx);
547
+ }
548
+ }
549
+ catch (err) {
550
+ // B7: UsageError thrown by akmDistill on validation_failed should be recorded
551
+ // as mode:"distill" with outcome:"validation_failed", NOT as a generic error.
552
+ // The distill_invoked event was already emitted inside akmDistill before the throw.
553
+ if (err instanceof UsageError) {
554
+ actions.push({
555
+ ref: planned.ref,
556
+ mode: "distill",
557
+ result: { ok: false, outcome: "validation_failed", error: err.message },
558
+ });
559
+ }
560
+ else {
561
+ actions.push({
562
+ ref: planned.ref,
563
+ mode: "error",
564
+ result: { ok: false, error: err instanceof Error ? err.message : String(err) },
565
+ });
566
+ }
567
+ }
568
+ completedCount++;
569
+ info(`[improve] ${completedCount}/${loopRefs.length} ${planned.ref}`);
570
+ }
571
+ // WS-4: Per-phase threshold auto-tune — runs AFTER the loop so the gate
572
+ // has processed all candidates for this run. Persists each phase's tuned
573
+ // threshold to state.db for the NEXT run's makeGateConfig to read.
574
+ // Best-effort: a tune failure must never fail the improve run.
575
+ const stateDbPathForTune = eventsCtx?.dbPath;
576
+ if (options.autoAccept !== undefined && stateDbPathForTune) {
577
+ const phaseGateCfgMap = {
578
+ reflect: reflectGateCfg,
579
+ distill: distillGateCfg,
580
+ };
581
+ for (const phase of ["reflect", "distill"]) {
582
+ const phaseCfg = phaseGateCfgMap[phase];
583
+ try {
584
+ maybeAutoTuneThreshold(phaseCfg.phaseThreshold ?? options.autoAccept, options.config ?? loadConfig(), stateDbPathForTune, undefined, phase);
585
+ }
586
+ catch (err) {
587
+ warn(`[improve] calibration auto-tune (${phase}) skipped: ${err instanceof Error ? err.message : String(err)}`);
588
+ }
589
+ }
590
+ }
591
+ return { reflectsWithErrorContext, memoryRefsForInference, gateAutoAcceptedCount, gateAutoAcceptFailedCount };
592
+ }
593
+ export async function runImprovePostLoopStage(args) {
594
+ const { scope, options, primaryStashDir, actionableRefs, appliedCleanup, cleanupWarnings, memoryRefsForInference, reindexFn, eventsCtx, budgetSignal, improveProfile, consolidationRan, } = args;
595
+ const allWarnings = [...cleanupWarnings, ...(appliedCleanup?.warnings ?? [])];
596
+ info("[improve] post-loop maintenance starting");
597
+ const maintenanceResult = await runImproveMaintenancePasses({
598
+ options,
599
+ primaryStashDir,
600
+ actionableRefs,
601
+ memoryRefsForInference,
602
+ allWarnings,
603
+ reindexFn,
604
+ consolidationRan,
605
+ // O-1 (#364): forward the budget signal to memory inference + graph extraction.
606
+ budgetSignal,
607
+ eventsCtx,
608
+ improveProfile,
609
+ });
610
+ let deadUrls;
611
+ if (scope.mode === "all" && primaryStashDir && actionableRefs.length > 0) {
612
+ try {
613
+ const knowledgeEntries = actionableRefs
614
+ .filter((r) => {
615
+ try {
616
+ return parseAssetRef(r.ref).type === "knowledge";
617
+ }
618
+ catch {
619
+ return false;
620
+ }
621
+ })
622
+ .slice(0, 10)
623
+ .map((r) => ({ ref: r.ref, body: "" }));
624
+ if (knowledgeEntries.length > 0) {
625
+ info(`[improve] checking URLs in ${knowledgeEntries.length} knowledge refs`);
626
+ deadUrls = await checkDeadUrls(primaryStashDir, knowledgeEntries);
627
+ info(`[improve] URL check complete (${deadUrls.length} dead/timeout URLs)`);
628
+ }
629
+ }
630
+ catch {
631
+ // best-effort
632
+ }
633
+ }
634
+ // #609 — recombine / synthesize pass. Whole-corpus cross-episodic
635
+ // generalization. Runs in the post-loop stage under consolidate.lock (it
636
+ // reads the consolidated corpus and writes proposals). Opt-in: gated on the
637
+ // `recombine` process being enabled, whole-stash / type scope (never `ref`),
638
+ // and not a dry run. Mirrors the proactiveMaintenance opt-in wiring.
639
+ let recombination;
640
+ if (primaryStashDir &&
641
+ improveProfile &&
642
+ resolveProcessEnabled("recombine", improveProfile) &&
643
+ scope.mode !== "ref" &&
644
+ !options.dryRun) {
645
+ const recombineFn = options.recombineFn ?? akmRecombine;
646
+ try {
647
+ recombination = await recombineFn({
648
+ stashDir: primaryStashDir,
649
+ config: options.config ?? loadConfig(),
650
+ ...(options.runId ? { sourceRun: options.runId } : {}),
651
+ ...(budgetSignal ? { signal: budgetSignal } : {}),
652
+ ...(options.autoAccept !== undefined ? { autoAccept: options.autoAccept } : {}),
653
+ eligibilitySource: "recombine",
654
+ ...(eventsCtx ? { ctx: eventsCtx } : {}),
655
+ minClusterSize: improveProfile.processes?.recombine?.minClusterSize,
656
+ maxClustersPerRun: improveProfile.processes?.recombine?.maxClustersPerRun,
657
+ relatednessSource: improveProfile.processes?.recombine?.relatednessSource,
658
+ confirmThreshold: improveProfile.processes?.recombine?.confirmThreshold,
659
+ // #632 — clustering-tuning knobs. UNSET = pre-#632 behaviour.
660
+ maxClusterSize: improveProfile.processes?.recombine?.maxClusterSize,
661
+ excludeTags: improveProfile.processes?.recombine?.excludeTags,
662
+ excludeEntities: improveProfile.processes?.recombine?.excludeEntities,
663
+ });
664
+ }
665
+ catch (e) {
666
+ allWarnings.push(`recombine: ${String(e)}`);
667
+ }
668
+ }
669
+ // #615 — procedural-compilation pass. Detects recurring successful ordered
670
+ // action sequences and compiles them into workflow proposals. Opt-in: gated
671
+ // on the `procedural` process being enabled, whole-stash / type scope (never
672
+ // `ref`), and not a dry run. Mirrors the recombine opt-in wiring.
673
+ let proceduralCompilation;
674
+ if (primaryStashDir &&
675
+ improveProfile &&
676
+ resolveProcessEnabled("procedural", improveProfile) &&
677
+ scope.mode !== "ref" &&
678
+ !options.dryRun) {
679
+ const proceduralFn = options.proceduralFn ?? akmProcedural;
680
+ try {
681
+ proceduralCompilation = await proceduralFn({
682
+ stashDir: primaryStashDir,
683
+ config: options.config ?? loadConfig(),
684
+ ...(options.runId ? { sourceRun: options.runId } : {}),
685
+ ...(budgetSignal ? { signal: budgetSignal } : {}),
686
+ ...(options.autoAccept !== undefined ? { autoAccept: options.autoAccept } : {}),
687
+ eligibilitySource: "procedural",
688
+ ...(eventsCtx ? { ctx: eventsCtx } : {}),
689
+ minRecurrence: improveProfile.processes?.procedural?.minRecurrence,
690
+ maxProposalsPerRun: improveProfile.processes?.procedural?.maxProposalsPerRun,
691
+ });
692
+ }
693
+ catch (e) {
694
+ allWarnings.push(`procedural: ${String(e)}`);
695
+ }
696
+ }
697
+ return {
698
+ allWarnings,
699
+ deadUrls,
700
+ ...(recombination ? { recombination } : {}),
701
+ ...(proceduralCompilation ? { proceduralCompilation } : {}),
702
+ ...(maintenanceResult.memoryInference ? { memoryInference: maintenanceResult.memoryInference } : {}),
703
+ ...(maintenanceResult.graphExtraction ? { graphExtraction: maintenanceResult.graphExtraction } : {}),
704
+ ...(maintenanceResult.stalenessDetection ? { stalenessDetection: maintenanceResult.stalenessDetection } : {}),
705
+ ...(maintenanceResult.actions && maintenanceResult.actions.length > 0
706
+ ? { maintenanceActions: maintenanceResult.actions }
707
+ : {}),
708
+ memoryInferenceDurationMs: maintenanceResult.memoryInferenceDurationMs,
709
+ graphExtractionDurationMs: maintenanceResult.graphExtractionDurationMs,
710
+ orphansPurged: maintenanceResult.orphansPurged,
711
+ proposalsExpired: maintenanceResult.proposalsExpired,
712
+ // Consolidation's auto-accept gate counts now accrue in the preparation
713
+ // stage (#551); post-loop no longer runs an auto-accept gate of its own.
714
+ gateAutoAcceptedCount: 0,
715
+ gateAutoAcceptFailedCount: 0,
716
+ };
717
+ }
718
+ // TODO(refactor): mutates the passed-in `allWarnings` array as a hidden side channel. Return warnings in ImproveMaintenanceResult and merge in caller — invasive signature change deferred to next refactor pass.
719
+ // Exported for tests (#584/#585 DB-locking regression coverage); production
720
+ // callers reach it only through akmImprove → runImprovePostLoopStage.
721
+ // TODO(refactor): mutates the passed-in `allWarnings` array as a hidden side channel. Return warnings in ImproveMaintenanceResult and merge in caller — invasive signature change deferred to next refactor pass.
722
+ // Exported for tests (#584/#585 DB-locking regression coverage); production
723
+ // callers reach it only through akmImprove → runImprovePostLoopStage.
724
+ export async function runImproveMaintenancePasses(args) {
725
+ const { options, primaryStashDir, memoryRefsForInference, allWarnings, reindexFn, consolidationRan, budgetSignal, eventsCtx, improveProfile, } = args;
726
+ if (!primaryStashDir)
727
+ return { memoryInferenceDurationMs: 0, graphExtractionDurationMs: 0 };
728
+ const config = options.config ?? loadConfig();
729
+ const sources = resolveSourceEntries(options.stashDir, config);
730
+ const memoryInferenceFn = options.memoryInferenceFn ?? runMemoryInferencePass;
731
+ const graphExtractionFn = options.graphExtractionFn ?? runGraphExtractionPass;
732
+ const stalenessDetectionFn = options.stalenessDetectionFn ?? runStalenessDetectionPass;
733
+ let db;
734
+ let memoryInference;
735
+ let graphExtraction;
736
+ let stalenessDetection;
737
+ let reindexedAfterInference = false;
738
+ const actions = [];
739
+ let memoryInferenceDurationMs = 0;
740
+ let graphExtractionDurationMs = 0;
741
+ let orphansPurged = 0;
742
+ let proposalsExpired = 0;
743
+ const openIndexDb = () => openIndexDatabase(getDbPath(), config.embedding?.dimension ? { embeddingDim: config.embedding.dimension } : undefined);
744
+ // #584: reindexFn opens its own write handle on the same index.db WAL file.
745
+ // Holding our handle across that call produced SQLITE_BUSY / "database is
746
+ // locked" failures in production, so the handle is closed BEFORE every
747
+ // reindex and reopened after — the fresh handle also sees the post-reindex
748
+ // state that graph extraction and staleness detection below rely on. The
749
+ // reopen runs in `finally` so a failed reindex still leaves a usable handle.
750
+ const reindexWithIndexDbReleased = async (stashDir) => {
751
+ if (db) {
752
+ closeDatabase(db);
753
+ db = undefined;
754
+ }
755
+ try {
756
+ await reindexFn({ stashDir });
757
+ }
758
+ finally {
759
+ db = openIndexDb();
760
+ }
761
+ };
762
+ await withIndexWriterLease({ purpose: "improve-maintenance", signal: budgetSignal }, async () => {
763
+ try {
764
+ db = openIndexDb();
765
+ // Memory inference candidate-discovery (post-Item 9 fix from
766
+ // memory:akm-improve-critical-review-2026-05-20). Previously this pass
767
+ // was gated on memoryRefsForInference.size > 0 AND passed those refs as a
768
+ // candidateRefs filter. But memoryRefsForInference is populated from refs
769
+ // distilled THIS RUN — by the time that happens, those parents are
770
+ // already split (`inferenceProcessed: true`) and `isPendingMemory` excludes
771
+ // them. The genuinely-pending parents in the stash never entered the
772
+ // filter. Result: 0/0/0 for 25 consecutive runs.
773
+ //
774
+ // Fix: always run the pass when the feature is enabled; let the pass's
775
+ // own `collectPendingMemories` + `isPendingMemory` predicate find
776
+ // candidates from the filesystem-of-truth. The this-run set is still
777
+ // logged as a hint but no longer used as a filter.
778
+ const memoryInferenceDisabledByProfile = improveProfile?.processes?.memoryInference?.enabled === false;
779
+ const minPendingCount = improveProfile?.processes?.memoryInference?.minPendingCount;
780
+ const pendingBelowMinCount = (() => {
781
+ if (!primaryStashDir || minPendingCount === undefined || minPendingCount <= 0)
782
+ return false;
783
+ const pending = collectPendingMemories(primaryStashDir).length;
784
+ if (pending < minPendingCount) {
785
+ info(`[improve] memory inference skipped (${pending} pending < minPendingCount ${minPendingCount})`);
786
+ return true;
787
+ }
788
+ return false;
789
+ })();
790
+ if (memoryInferenceDisabledByProfile) {
791
+ info("[improve] memory inference skipped (disabled by improve profile)");
792
+ }
793
+ else if (pendingBelowMinCount) {
794
+ // skipped — message already emitted above
795
+ }
796
+ else {
797
+ const hintRefs = memoryRefsForInference.size;
798
+ info(hintRefs > 0
799
+ ? `[improve] memory inference starting (${hintRefs} hint refs touched this run; pass discovers all pending)`
800
+ : "[improve] memory inference starting (discovering pending parents)");
801
+ const inferenceStart = Date.now();
802
+ try {
803
+ // O-1 (#364): pass budget signal so a hung inference call is cancelled.
804
+ memoryInference = await withLlmStage("memory-inference", () => memoryInferenceFn({
805
+ config,
806
+ sources,
807
+ signal: budgetSignal,
808
+ db,
809
+ reEnrich: false,
810
+ onProgress: (event) => {
811
+ const current = event.currentRef ? ` ${event.currentRef}` : "";
812
+ info(`[improve] memory inference ${event.processed}/${event.total}${current} (written ${event.writtenFacts}, skipped ${event.skippedNoFacts})`);
813
+ },
814
+ }));
815
+ memoryInferenceDurationMs = Date.now() - inferenceStart;
816
+ actions.push({ ref: "memory:_inference", mode: "memory-inference", result: memoryInference });
817
+ info(`[improve] memory inference complete (${memoryInference.writtenFacts} facts written from ${memoryInference.splitParents} parents)`);
818
+ }
819
+ catch (err) {
820
+ memoryInferenceDurationMs = Date.now() - inferenceStart;
821
+ allWarnings.push(`memory inference failed: ${err instanceof Error ? err.message : String(err)}`);
822
+ }
823
+ }
824
+ if (memoryInference && (memoryInference.splitParents > 0 || memoryInference.writtenFacts > 0)) {
825
+ info("[improve] reindexing after memory inference writes");
826
+ try {
827
+ await reindexWithIndexDbReleased(primaryStashDir);
828
+ reindexedAfterInference = true;
829
+ info("[improve] reindex after memory inference complete");
830
+ }
831
+ catch (err) {
832
+ allWarnings.push(`reindex after memory inference failed: ${err instanceof Error ? err.message : String(err)}`);
833
+ }
834
+ }
835
+ const graphEnabled = isProcessEnabled("index", "graph_extraction", config);
836
+ const graphExtractionDisabledByProfile = improveProfile?.processes?.graphExtraction?.enabled === false;
837
+ const graphExtractionFullScan = improveProfile?.processes?.graphExtraction?.fullScan === true;
838
+ // #624 P2: optional incremental high-signal-first cap. Unset = process all
839
+ // eligible (byte-identical to today; no ranking/slice).
840
+ const graphExtractionTopN = improveProfile?.processes?.graphExtraction?.topN;
841
+ // Build the set of refs actually touched this run.
842
+ const touchedRefs = new Set();
843
+ for (const r of args.actionableRefs)
844
+ touchedRefs.add(r.ref);
845
+ for (const r of memoryRefsForInference)
846
+ touchedRefs.add(r);
847
+ // INVARIANT: graph extraction normally runs only on files touched by
848
+ // actionable refs (candidatePaths). Full-corpus scans are opt-in via
849
+ // profile.processes.graphExtraction.fullScan = true (used by the
850
+ // `graph-refresh` built-in profile and its weekly scheduled task).
851
+ // The empty-Set fallback is intentional when no refs were touched —
852
+ // the extractor's filter rejects every file and returns empty, keeping
853
+ // the pass invoked so the action is recorded and tests stay exercised.
854
+ if (graphExtractionDisabledByProfile) {
855
+ info("[improve] graph extraction skipped (disabled by improve profile)");
856
+ }
857
+ else if (sources.length > 0 && graphEnabled) {
858
+ info(`[improve] graph extraction starting${graphExtractionFullScan ? " (full-corpus scan)" : ""}`);
859
+ const extractionStart = Date.now();
860
+ try {
861
+ // D9: if consolidation ran but memory inference did not reindex, force a reindex
862
+ // so graph extraction sees current DB state after consolidation writes.
863
+ if (consolidationRan && !reindexedAfterInference) {
864
+ info("[improve] reindexing after consolidation (graph extraction needs current state)");
865
+ try {
866
+ await reindexWithIndexDbReleased(primaryStashDir);
867
+ reindexedAfterInference = true;
868
+ info("[improve] reindex after consolidation complete");
869
+ }
870
+ catch (err) {
871
+ allWarnings.push(`reindex after consolidation failed: ${err instanceof Error ? err.message : String(err)}`);
872
+ }
873
+ }
874
+ // #584: no close/reopen needed here — reindexWithIndexDbReleased
875
+ // already swapped in a fresh post-reindex handle.
876
+ // Resolve touched refs to absolute file paths. Skipped for fullScan
877
+ // (candidatePaths stays undefined → extractor processes all files).
878
+ let candidatePaths;
879
+ if (!graphExtractionFullScan) {
880
+ candidatePaths = new Set();
881
+ if (primaryStashDir && touchedRefs.size > 0) {
882
+ const writableDirSet = new Set(getWritableStashDirs(primaryStashDir).map((d) => path.resolve(d)));
883
+ const resolved = await Promise.all([...touchedRefs].map((ref) => findAssetFilePath(ref, primaryStashDir, writableDirSet).catch(() => null)));
884
+ for (const p of resolved) {
885
+ if (typeof p === "string" && p.length > 0)
886
+ candidatePaths.add(p);
887
+ }
888
+ }
889
+ }
890
+ const progressHandler = (event) => {
891
+ const current = event.currentPath ? ` ${path.basename(event.currentPath)}` : "";
892
+ info(`[improve] graph extraction ${event.processed}/${event.total}${current} (extracted ${event.extracted}, entities ${event.totalEntities}, relations ${event.totalRelations})`);
893
+ };
894
+ // O-1 (#364): pass budget signal so a hung graph extraction call is cancelled.
895
+ graphExtraction = await withLlmStage("graph-extraction", () => graphExtractionFn({
896
+ config,
897
+ sources,
898
+ signal: budgetSignal,
899
+ db,
900
+ reEnrich: false,
901
+ onProgress: progressHandler,
902
+ options: { candidatePaths, ...(graphExtractionTopN != null ? { topN: graphExtractionTopN } : {}) },
903
+ }));
904
+ graphExtractionDurationMs = Date.now() - extractionStart;
905
+ actions.push({ ref: "graph:_artifact", mode: "graph-extraction", result: graphExtraction });
906
+ info(`[improve] graph extraction complete (${graphExtraction.quality.extractedFiles} files, ${graphExtraction.quality.entityCount} entities, ${graphExtraction.quality.relationCount} relations)`);
907
+ }
908
+ catch (err) {
909
+ graphExtractionDurationMs = Date.now() - extractionStart;
910
+ allWarnings.push(`graph extraction failed: ${err instanceof Error ? err.message : String(err)}`);
911
+ }
912
+ }
913
+ else if (sources.length > 0 && !graphEnabled) {
914
+ info("[improve] graph extraction skipped (features.index.graph_extraction is disabled)");
915
+ }
916
+ // Orphan proposal purge — reject pending reflect proposals whose target
917
+ // asset no longer exists on disk. Runs after graph extraction so newly
918
+ // promoted assets from accept flows during this run are already present.
919
+ if (primaryStashDir) {
920
+ try {
921
+ const purgeResult = purgeOrphanProposals(primaryStashDir, sources.map((s) => s.path));
922
+ orphansPurged = purgeResult.rejected;
923
+ if (purgeResult.rejected > 0) {
924
+ info(`[improve] orphan purge: ${purgeResult.rejected}/${purgeResult.checked} orphaned proposals rejected (${purgeResult.durationMs}ms)`);
925
+ }
926
+ appendEvent({
927
+ eventType: "proposal_orphan_purge",
928
+ ref: "proposals:_orphan-purge",
929
+ metadata: {
930
+ checked: purgeResult.checked,
931
+ rejected: purgeResult.rejected,
932
+ durationMs: purgeResult.durationMs,
933
+ byType: purgeResult.byType,
934
+ orphans: purgeResult.orphans.map((o) => o.ref),
935
+ },
936
+ }, eventsCtx);
937
+ }
938
+ catch (err) {
939
+ allWarnings.push(`orphan purge failed: ${err instanceof Error ? err.message : String(err)}`);
940
+ }
941
+ // Phase 6B (Advantage D6b): expire pending proposals that have aged past
942
+ // the retention window. Runs AFTER orphan purge so we never double-archive
943
+ // a proposal that orphan-purge already moved. `expireStaleProposals` emits
944
+ // its own per-proposal `proposal_expired` events; we additionally emit a
945
+ // single roll-up event here for parity with the orphan-purge surface.
946
+ try {
947
+ const expireResult = expireStaleProposals(primaryStashDir, config);
948
+ proposalsExpired = expireResult.expired;
949
+ if (expireResult.expired > 0) {
950
+ info(`[improve] expiration: ${expireResult.expired}/${expireResult.checked} pending proposals expired ` +
951
+ `(retention=${expireResult.retentionDays}d, ${expireResult.durationMs}ms)`);
952
+ }
953
+ appendEvent({
954
+ eventType: "proposal_expiration_pass",
955
+ ref: "proposals:_expiration",
956
+ metadata: {
957
+ checked: expireResult.checked,
958
+ expired: expireResult.expired,
959
+ durationMs: expireResult.durationMs,
960
+ retentionDays: expireResult.retentionDays,
961
+ expiredProposals: expireResult.expiredProposals,
962
+ },
963
+ }, eventsCtx);
964
+ }
965
+ catch (err) {
966
+ allWarnings.push(`proposal expiration failed: ${err instanceof Error ? err.message : String(err)}`);
967
+ }
968
+ }
969
+ // Fix #2 (observability 0.8.0): trim the events table in state.db so it
970
+ // doesn't grow unbounded. `akm health` writes a `health_probe` row on every
971
+ // invocation, and every command surface emits at least one event besides —
972
+ // without this trim, state.db is a permanent append-only log. Config key
973
+ // `improve.eventRetentionDays` (default 90, set 0 to disable) controls the
974
+ // window. The purge runs against state.db (a different SQLite file from
975
+ // the index `db` above).
976
+ {
977
+ const retentionDays = typeof config.improve?.eventRetentionDays === "number" ? config.improve.eventRetentionDays : 90;
978
+ if (retentionDays > 0) {
979
+ // #585: reuse the long-lived eventsCtx.db connection when akmImprove
980
+ // opened one — opening a second state.db write connection while
981
+ // eventsDb is still live made two simultaneous writers contend on the
982
+ // same WAL file ("database is locked"). Only the eventsCtx.dbPath
983
+ // fallback path (state.db failed to open up-front) opens — and then
984
+ // owns and closes — its own handle. C2 still holds: the fallback uses
985
+ // the boundary-pinned path, never a live `process.env` re-read.
986
+ try {
987
+ withStateDb((stateDb) => {
988
+ const purgedCount = purgeOldEvents(stateDb, retentionDays);
989
+ if (purgedCount > 0) {
990
+ info(`[improve] events purge: ${purgedCount} event(s) older than ${retentionDays}d removed from state.db`);
991
+ }
992
+ appendEvent({
993
+ eventType: "events_purged",
994
+ ref: "events:_purge",
995
+ metadata: { purgedCount, retentionDays },
996
+ }, eventsCtx);
997
+ // improve_runs uses the same retention window as events — both are
998
+ // observability/audit data, both grow append-only, both have a
999
+ // dedicated purge helper. Mirroring the events purge here means a
1000
+ // single retention knob (improve.eventRetentionDays) governs both.
1001
+ const improveRunsPurged = purgeOldImproveRuns(stateDb, retentionDays);
1002
+ if (improveRunsPurged > 0) {
1003
+ info(`[improve] improve_runs purge: ${improveRunsPurged} run(s) older than ${retentionDays}d removed from state.db`);
1004
+ }
1005
+ appendEvent({
1006
+ eventType: "improve_runs_purged",
1007
+ ref: "improve_runs:_purge",
1008
+ metadata: { purgedCount: improveRunsPurged, retentionDays },
1009
+ }, eventsCtx);
1010
+ }, { path: eventsCtx?.dbPath, borrowed: eventsCtx?.db });
1011
+ }
1012
+ catch (err) {
1013
+ allWarnings.push(`events purge failed: ${err instanceof Error ? err.message : String(err)}`);
1014
+ }
1015
+ // task_logs in logs.db (#579) shares the same retention window as
1016
+ // events/improve_runs — all three are observability data governed by
1017
+ // the single improve.eventRetentionDays knob. Separate try/finally
1018
+ // because logs.db is a different file: a locked/missing logs.db must
1019
+ // not block the state.db purges above.
1020
+ let logsDb;
1021
+ try {
1022
+ logsDb = openLogsDatabase();
1023
+ const taskLogsPurged = purgeOldTaskLogs(logsDb, retentionDays);
1024
+ if (taskLogsPurged > 0) {
1025
+ info(`[improve] task_logs purge: ${taskLogsPurged} log line(s) older than ${retentionDays}d removed from logs.db`);
1026
+ }
1027
+ appendEvent({
1028
+ eventType: "task_logs_purged",
1029
+ ref: "task_logs:_purge",
1030
+ metadata: { purgedCount: taskLogsPurged, retentionDays },
1031
+ }, eventsCtx);
1032
+ }
1033
+ catch (err) {
1034
+ allWarnings.push(`task_logs purge failed: ${err instanceof Error ? err.message : String(err)}`);
1035
+ }
1036
+ finally {
1037
+ if (logsDb) {
1038
+ try {
1039
+ logsDb.close();
1040
+ }
1041
+ catch {
1042
+ // best-effort
1043
+ }
1044
+ }
1045
+ }
1046
+ }
1047
+ }
1048
+ // Phase 4A (staleness detection). Activates the `deprecated` belief-state
1049
+ // machinery shipped in Phase 1A. Default OFF — gated by
1050
+ // `features.index.staleness_detection.enabled`. Runs after orphan purge
1051
+ // and before the URL check (which lives in the outer caller).
1052
+ if (sources.length > 0) {
1053
+ try {
1054
+ stalenessDetection = await withLlmStage("staleness-detection", () => stalenessDetectionFn({ config, sources, signal: budgetSignal, db }));
1055
+ if (stalenessDetection.considered > 0) {
1056
+ info(`[improve] staleness detection complete (considered ${stalenessDetection.considered}, ` +
1057
+ `deprecated ${stalenessDetection.deprecated}, confirmed ${stalenessDetection.confirmed}, ` +
1058
+ `skipped ${stalenessDetection.skipped}, ${stalenessDetection.durationMs}ms)`);
1059
+ }
1060
+ for (const w of stalenessDetection.warnings)
1061
+ allWarnings.push(`[improve] staleness detection: ${w}`);
1062
+ }
1063
+ catch (err) {
1064
+ allWarnings.push(`staleness detection failed: ${err instanceof Error ? err.message : String(err)}`);
1065
+ }
1066
+ }
1067
+ }
1068
+ finally {
1069
+ if (db)
1070
+ closeDatabase(db);
1071
+ }
1072
+ });
1073
+ return {
1074
+ ...(memoryInference ? { memoryInference } : {}),
1075
+ ...(graphExtraction ? { graphExtraction } : {}),
1076
+ ...(stalenessDetection ? { stalenessDetection } : {}),
1077
+ ...(actions.length > 0 ? { actions } : {}),
1078
+ memoryInferenceDurationMs,
1079
+ graphExtractionDurationMs,
1080
+ orphansPurged,
1081
+ proposalsExpired,
1082
+ };
1083
+ }