akm-cli 0.9.0-beta.9 → 0.9.0-rc.0

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