akm-cli 0.9.17-alpha.3 → 0.9.17-alpha.4

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 (343) hide show
  1. package/CHANGELOG.md +731 -0
  2. package/dist/akm +94 -196
  3. package/dist/cli/shared.js +6 -2
  4. package/dist/cli.js +22 -9
  5. package/dist/commands/agent/agent-dispatch.js +1 -1
  6. package/dist/commands/command/command-execution.js +24 -62
  7. package/dist/commands/feedback-cli.js +0 -1
  8. package/dist/commands/health/accept-rate.js +2 -2
  9. package/dist/commands/health/checks.js +30 -75
  10. package/dist/commands/health/config-skew.js +38 -0
  11. package/dist/commands/health/egress.js +54 -0
  12. package/dist/commands/health/html-report.js +0 -38
  13. package/dist/commands/health/improve-metrics.js +123 -562
  14. package/dist/commands/health/plugin-staleness.js +53 -3
  15. package/dist/commands/health/renderers.js +12 -4
  16. package/dist/commands/health/report-view-model.js +11 -106
  17. package/dist/commands/health/types-improve.js +4 -19
  18. package/dist/commands/health/windows.js +64 -73
  19. package/dist/commands/health.js +122 -143
  20. package/dist/commands/improve/consolidate/chunking.js +25 -100
  21. package/dist/commands/improve/consolidate/sanitize.js +54 -149
  22. package/dist/commands/improve/consolidate.js +538 -1075
  23. package/dist/commands/improve/content-hash.js +16 -24
  24. package/dist/commands/improve/distill/content-repair.js +18 -100
  25. package/dist/commands/improve/distill-guards.js +20 -81
  26. package/dist/commands/improve/distill-promotion-policy.js +23 -243
  27. package/dist/commands/improve/distill.js +608 -1075
  28. package/dist/commands/improve/eligibility.js +126 -400
  29. package/dist/commands/improve/execution.js +3 -5
  30. package/dist/commands/improve/extract.js +487 -1046
  31. package/dist/commands/improve/feedback-valence.js +0 -25
  32. package/dist/commands/improve/improve-cli.js +29 -166
  33. package/dist/commands/improve/improve-result-file.js +10 -66
  34. package/dist/commands/improve/improve-strategies.js +12 -7
  35. package/dist/commands/improve/improve-usage-report.js +18 -64
  36. package/dist/commands/improve/improve.js +443 -1063
  37. package/dist/commands/improve/ledger.js +114 -0
  38. package/dist/commands/improve/locks.js +2 -8
  39. package/dist/commands/improve/loop-stages.js +459 -1172
  40. package/dist/commands/improve/memory/derived-ref.js +12 -77
  41. package/dist/commands/improve/memory/memory-belief.js +14 -118
  42. package/dist/commands/improve/memory/memory-improve.js +4 -3
  43. package/dist/commands/improve/outcome-loop.js +28 -156
  44. package/dist/commands/improve/planner.js +5 -10
  45. package/dist/commands/improve/preparation.js +851 -2339
  46. package/dist/commands/improve/proactive-maintenance.js +34 -101
  47. package/dist/commands/improve/reflect-noise.js +104 -280
  48. package/dist/commands/improve/reflect.js +621 -1367
  49. package/dist/commands/improve/salience.js +46 -232
  50. package/dist/commands/improve/session-asset.js +19 -100
  51. package/dist/commands/improve/stage.js +323 -0
  52. package/dist/commands/proposal/drain.js +251 -644
  53. package/dist/commands/proposal/proposal-cli.js +3 -18
  54. package/dist/commands/proposal/proposal-types.js +20 -41
  55. package/dist/commands/proposal/proposal.js +1 -2
  56. package/dist/commands/proposal/propose.js +134 -160
  57. package/dist/commands/proposal/repository.js +502 -1487
  58. package/dist/commands/proposal/validators/proposal-quality-validators.js +71 -174
  59. package/dist/commands/proposal/validators/proposal-validators.js +1 -1
  60. package/dist/commands/proposal/validators/proposals.js +13 -89
  61. package/dist/commands/read/curate.js +63 -413
  62. package/dist/commands/read/search-cli.js +16 -33
  63. package/dist/commands/read/search.js +17 -23
  64. package/dist/commands/read/show.js +2 -13
  65. package/dist/commands/sources/bundle-cli.js +25 -2
  66. package/dist/commands/sources/bundle-config-ops.js +7 -0
  67. package/dist/commands/sources/dangerous-env-audit.js +1 -2
  68. package/dist/commands/sources/info.js +2 -11
  69. package/dist/commands/sources/installed-stashes.js +197 -746
  70. package/dist/commands/sources/schema-repair.js +98 -129
  71. package/dist/commands/sources/source-add.js +62 -12
  72. package/dist/commands/sources/stash-cli.js +1 -1
  73. package/dist/commands/tasks/explain.js +10 -13
  74. package/dist/commands/tasks/tasks-cli.js +9 -8
  75. package/dist/commands/tasks/tasks.js +326 -930
  76. package/dist/commands/tasks/validate.js +42 -21
  77. package/dist/commands/workflow/plan.js +22 -29
  78. package/dist/commands/workflow-cli.js +4 -4
  79. package/dist/core/adapter/adapters/akm-adapter.js +0 -1
  80. package/dist/core/adapter/adapters/akm-lint.js +2 -3
  81. package/dist/core/adapter/adapters/akm-metadata.js +11 -12
  82. package/dist/core/adapter/adapters/akm-workflow-adapter.js +1 -1
  83. package/dist/core/adapter/execution-source.js +17 -29
  84. package/dist/core/asset/resolve-ref.js +1 -1
  85. package/dist/core/bundle-id.js +42 -5
  86. package/dist/core/bundle-rename.js +291 -0
  87. package/dist/core/config/config-io.js +1 -2
  88. package/dist/core/config/config-schema.js +1 -33
  89. package/dist/core/config/config-walker.js +1 -1
  90. package/dist/core/config/config.js +163 -68
  91. package/dist/core/config/legacy-source-shape-shim.js +38 -9
  92. package/dist/core/config/schema/embedding.js +20 -5
  93. package/dist/core/config/schema/engines.js +5 -0
  94. package/dist/core/config/schema/execution.js +1 -1
  95. package/dist/core/config/schema/experimental.js +1 -1
  96. package/dist/core/config/schema/improve-processes.js +21 -95
  97. package/dist/core/config/schema/improve.js +4 -42
  98. package/dist/core/config/schema/scheduler.js +12 -12
  99. package/dist/core/config/schema/search.js +6 -22
  100. package/dist/core/env-secret-ref.js +0 -1
  101. package/dist/core/errors.js +8 -9
  102. package/dist/core/file-lock.js +76 -173
  103. package/dist/core/logs-db.js +2 -2
  104. package/dist/core/paths.js +0 -27
  105. package/dist/core/redaction.js +109 -2
  106. package/dist/core/run-lock.js +2 -5
  107. package/dist/core/spawn-env.js +1 -1
  108. package/dist/core/state/migrations.js +108 -61
  109. package/dist/core/state-db-scope.js +2 -4
  110. package/dist/core/state-db.js +126 -692
  111. package/dist/core/type-presentation.js +1 -9
  112. package/dist/core/write-source.js +293 -1012
  113. package/dist/execution/input-contract.js +1 -1
  114. package/dist/execution/resolved-request.js +135 -689
  115. package/dist/execution/source.js +63 -257
  116. package/dist/execution/target-ref.js +1 -1
  117. package/dist/indexer/bundle-identity-guard.js +2 -2
  118. package/dist/indexer/db/graph-db.js +106 -46
  119. package/dist/indexer/ensure-index.js +44 -85
  120. package/dist/indexer/graph/graph-extraction.js +340 -562
  121. package/dist/indexer/graph/graph-related.js +130 -0
  122. package/dist/indexer/index-rebuild-lock.js +3 -11
  123. package/dist/indexer/index-writer-lock.js +8 -17
  124. package/dist/indexer/index-written-assets.js +139 -151
  125. package/dist/indexer/indexer.js +524 -846
  126. package/dist/indexer/materialize-embeddings.js +60 -397
  127. package/dist/indexer/passes/memory-inference.js +81 -90
  128. package/dist/indexer/passes/metadata.js +132 -200
  129. package/dist/indexer/read-preflight.js +0 -7
  130. package/dist/indexer/scan/doc-to-entry.js +1 -3
  131. package/dist/indexer/scan/drain-dir.js +1 -1
  132. package/dist/indexer/search/db-search.js +181 -590
  133. package/dist/indexer/search/fts-query.js +30 -41
  134. package/dist/indexer/search/ranking.js +28 -154
  135. package/dist/indexer/search/search-attribution.js +12 -32
  136. package/dist/indexer/search/search-fields.js +11 -15
  137. package/dist/indexer/search/search-hit-enrichers.js +54 -85
  138. package/dist/indexer/search/search-source.js +1 -4
  139. package/dist/indexer/usage/usage-events.js +2 -7
  140. package/dist/integrations/agent/engine-fallback.js +23 -40
  141. package/dist/integrations/agent/engine-resolution.js +93 -183
  142. package/dist/integrations/agent/execution.js +507 -0
  143. package/dist/integrations/agent/model-map.js +28 -156
  144. package/dist/integrations/agent/request-lowering.js +66 -141
  145. package/dist/integrations/agent/runner-dispatch.js +143 -321
  146. package/dist/integrations/agent/runner.js +54 -14
  147. package/dist/integrations/lockfile.js +53 -101
  148. package/dist/llm/embedders/deterministic.js +2 -3
  149. package/dist/llm/embedders/profile.js +71 -0
  150. package/dist/llm/embedders/remote.js +10 -15
  151. package/dist/llm/graph-extract.js +3 -12
  152. package/dist/llm/index-passes.js +3 -5
  153. package/dist/llm/memory-infer.js +1 -2
  154. package/dist/llm/metadata-enhance.js +1 -2
  155. package/dist/llm/structured-call.js +5 -24
  156. package/dist/output/generic-render.js +23 -11
  157. package/dist/output/html-render.js +13 -10
  158. package/dist/output/render-registry.js +3 -32
  159. package/dist/output/shapes/helpers.js +2 -34
  160. package/dist/output/shapes/passthrough.js +1 -9
  161. package/dist/{indexer/search/ranking-types.js → output/text/bundle-rename.js} +4 -1
  162. package/dist/output/text/command-format.js +60 -23
  163. package/dist/output/text/helpers.js +1 -1
  164. package/dist/output/text/migrate.js +5 -14
  165. package/dist/output/text/proposal-format.js +1 -2
  166. package/dist/output/text/workflow-format.js +0 -32
  167. package/dist/output/text.js +2 -0
  168. package/dist/registry/factory.js +4 -19
  169. package/dist/registry/network.js +66 -220
  170. package/dist/registry/providers/index.js +0 -2
  171. package/dist/registry/providers/skills-sh.js +3 -14
  172. package/dist/registry/providers/static-index.js +24 -26
  173. package/dist/registry/resolve.js +55 -131
  174. package/dist/scripts/akm-migrate-node.js +43937 -93313
  175. package/dist/scripts/akm-migrate.js +43697 -93071
  176. package/dist/setup/registry-stash-loader.js +4 -13
  177. package/dist/setup/semantic-assets.js +3 -44
  178. package/dist/setup/setup.js +1 -1
  179. package/dist/setup/steps/tasks.js +25 -15
  180. package/dist/sources/provider-factory.js +17 -18
  181. package/dist/sources/providers/filesystem.js +2 -3
  182. package/dist/sources/providers/git-install.js +7 -1
  183. package/dist/sources/providers/git-provider.js +0 -3
  184. package/dist/sources/providers/git-stash.js +0 -17
  185. package/dist/sources/providers/npm.js +2 -4
  186. package/dist/sources/providers/provider-utils.js +5 -10
  187. package/dist/sources/providers/website.js +0 -2
  188. package/dist/sources/snapshot-fetchers/website-ingest.js +1 -1
  189. package/dist/sources/website-url.js +2 -2
  190. package/dist/storage/database.js +9 -35
  191. package/dist/storage/repositories/improve-ledger-repository.js +168 -0
  192. package/dist/storage/repositories/index-connection.js +34 -70
  193. package/dist/storage/repositories/index-entries-repository.js +69 -111
  194. package/dist/storage/repositories/index-entry-mapper.js +1 -2
  195. package/dist/storage/repositories/index-entry-schema.js +83 -269
  196. package/dist/storage/repositories/index-fts-repository.js +86 -256
  197. package/dist/storage/repositories/index-llm-cache-repository.js +17 -0
  198. package/dist/storage/repositories/index-meta-repository.js +6 -4
  199. package/dist/storage/repositories/index-schema.js +192 -220
  200. package/dist/storage/repositories/index-utility-repository.js +8 -29
  201. package/dist/storage/repositories/index-vec-repository.js +133 -414
  202. package/dist/storage/repositories/outcome-repository.js +2 -1
  203. package/dist/storage/repositories/proposals-repository.js +35 -0
  204. package/dist/storage/repositories/registry-index-cache-repository.js +100 -0
  205. package/dist/storage/repositories/task-history-repository.js +26 -4
  206. package/dist/storage/repositories/workflow-runs-repository.js +53 -244
  207. package/dist/storage/sqlite-migrations.js +136 -0
  208. package/dist/storage/sqlite-pragmas.js +11 -9
  209. package/dist/storage/sqlite-transaction.js +170 -0
  210. package/dist/storage/state-db-integrity.js +34 -27
  211. package/dist/tasks/activation-config.js +134 -62
  212. package/dist/tasks/backends/cron.js +129 -277
  213. package/dist/tasks/backends/exec-utils.js +2 -5
  214. package/dist/tasks/backends/launchd.js +125 -745
  215. package/dist/tasks/backends/schtasks.js +101 -620
  216. package/dist/tasks/prepare/prepare-support.js +5 -15
  217. package/dist/tasks/prepare/prepare.js +0 -2
  218. package/dist/tasks/resolve-akm-bin.js +20 -79
  219. package/dist/tasks/run/attempt-lifecycle.js +0 -1
  220. package/dist/tasks/scheduler-binding.js +18 -238
  221. package/dist/tasks/scheduler-invocation.js +52 -52
  222. package/dist/tasks/scheduler-lock.js +53 -0
  223. package/dist/tasks/scheduler-sync.js +361 -751
  224. package/dist/tasks/source/parse-task-source.js +160 -10
  225. package/dist/tasks/source/task-source-v3-frozen.js +3 -4
  226. package/dist/tasks/source/task-to-v4.js +2 -2
  227. package/dist/workflows/authoring/authoring.js +3 -12
  228. package/dist/workflows/compile.js +211 -0
  229. package/dist/workflows/concurrency-policy.js +13 -74
  230. package/dist/workflows/exec/child-invocation.js +3 -17
  231. package/dist/workflows/exec/child-workflow.js +32 -141
  232. package/dist/workflows/exec/dispatch-redaction.js +13 -53
  233. package/dist/workflows/exec/environment.js +98 -0
  234. package/dist/workflows/exec/exec-unit.js +33 -140
  235. package/dist/workflows/exec/frozen-judge.js +7 -59
  236. package/dist/workflows/exec/native-executor.js +82 -341
  237. package/dist/workflows/exec/param-secrets.js +29 -47
  238. package/dist/workflows/exec/run-workflow.js +154 -387
  239. package/dist/workflows/exec/scheduler.js +9 -36
  240. package/dist/workflows/exec/step-work.js +127 -430
  241. package/dist/workflows/exec/unit-dispatch.js +11 -63
  242. package/dist/workflows/exec/unit-writer.js +8 -52
  243. package/dist/workflows/exec/worktree.js +39 -273
  244. package/dist/workflows/freeze/child-output-references.js +4 -15
  245. package/dist/workflows/freeze/environment.js +99 -92
  246. package/dist/workflows/freeze/freeze.js +172 -0
  247. package/dist/workflows/freeze/step-values.js +19 -21
  248. package/dist/workflows/freeze/targets/child-workflow.js +23 -92
  249. package/dist/workflows/freeze/targets/command.js +10 -33
  250. package/dist/workflows/freeze/targets/script.js +5 -12
  251. package/dist/workflows/freeze/targets/shell.js +3 -6
  252. package/dist/workflows/freeze/targets/task.js +25 -80
  253. package/dist/workflows/freeze/task-bindings.js +20 -67
  254. package/dist/workflows/{source-ir/github-yaml.js → github-yaml.js} +88 -206
  255. package/dist/workflows/ir/params.js +6 -51
  256. package/dist/workflows/ir/plan-hash.js +2 -34
  257. package/dist/workflows/parser.js +140 -43
  258. package/dist/{commands/improve/consolidate/types.js → workflows/plan.js} +2 -1
  259. package/dist/workflows/renderer.js +36 -69
  260. package/dist/workflows/resource-limits.js +12 -120
  261. package/dist/workflows/runtime/agent-identity.js +8 -40
  262. package/dist/workflows/runtime/run-outputs.js +3 -6
  263. package/dist/workflows/runtime/run-plan.js +316 -0
  264. package/dist/workflows/runtime/runs.js +48 -200
  265. package/dist/workflows/runtime/workflow-asset-loader.js +24 -57
  266. package/dist/workflows/{source-ir/semantics.js → source-semantics.js} +16 -20
  267. package/dist/workflows/validate-summary.js +2 -7
  268. package/docs/integration/bundling-akm.md +49 -42
  269. package/docs/migration/README.md +1 -0
  270. package/docs/migration/release-notes/0.9.17.md +41 -0
  271. package/docs/migration/v0.9.1-to-v0.9.2.md +19 -7
  272. package/docs/reference/cli.md +182 -125
  273. package/docs/reference/configuration.md +49 -56
  274. package/docs/reference/data-and-telemetry.md +19 -20
  275. package/docs/reference/tasks.md +86 -38
  276. package/docs/reference/workflow-schema.md +14 -18
  277. package/docs/reference/workflows.md +6 -9
  278. package/package.json +1 -1
  279. package/schemas/akm-config.json +87 -406
  280. package/dist/commands/health/advisories.js +0 -150
  281. package/dist/commands/health/metrics.js +0 -329
  282. package/dist/commands/health/surfaces.js +0 -102
  283. package/dist/commands/improve/anti-collapse.js +0 -83
  284. package/dist/commands/improve/collapse-detector.js +0 -432
  285. package/dist/commands/improve/consolidate/eligibility.js +0 -48
  286. package/dist/commands/improve/consolidate/merge.js +0 -146
  287. package/dist/commands/improve/distill/promote-memory.js +0 -329
  288. package/dist/commands/improve/distill/quality-gate.js +0 -500
  289. package/dist/commands/improve/memory/memory-contradiction-detect.js +0 -291
  290. package/dist/commands/improve/proposal-envelope.js +0 -31
  291. package/dist/commands/improve/run-context.js +0 -123
  292. package/dist/commands/improve/shared.js +0 -21
  293. package/dist/commands/improve/source-identity.js +0 -28
  294. package/dist/commands/improve/triage.js +0 -96
  295. package/dist/commands/proposal/drain-policies.js +0 -151
  296. package/dist/commands/sources/update-transaction.js +0 -220
  297. package/dist/core/action-contributors.js +0 -28
  298. package/dist/core/config/config-version-shim.js +0 -101
  299. package/dist/core/config/retired-experimental-keys-shim.js +0 -62
  300. package/dist/core/fs-txn.js +0 -405
  301. package/dist/core/lexical-score.js +0 -25
  302. package/dist/core/maintenance-barrier.js +0 -167
  303. package/dist/execution/executable-identity.js +0 -105
  304. package/dist/execution/guarded-source.js +0 -441
  305. package/dist/indexer/graph/graph-boost.js +0 -427
  306. package/dist/indexer/graph/graph-dedup.js +0 -95
  307. package/dist/indexer/search/name-match.js +0 -35
  308. package/dist/indexer/search/ranking-contributors.js +0 -515
  309. package/dist/indexer/walk/project-context.js +0 -192
  310. package/dist/integrations/agent/execution-cascade.js +0 -566
  311. package/dist/integrations/agent/execution-definitions.js +0 -202
  312. package/dist/integrations/agent/execution-lowering.js +0 -841
  313. package/dist/integrations/agent/execution-preparation.js +0 -98
  314. package/dist/integrations/agent/inline-execution.js +0 -74
  315. package/dist/registry/create-provider-registry.js +0 -29
  316. package/dist/registry/pinned-request-helper.js +0 -247
  317. package/dist/registry/pinned-transport.js +0 -717
  318. package/dist/sources/providers/index.js +0 -14
  319. package/dist/storage/engines/sqlite-migrations.js +0 -271
  320. package/dist/storage/repositories/canaries-repository.js +0 -107
  321. package/dist/storage/repositories/embedding-salvage-repository.js +0 -184
  322. package/dist/storage/repositories/registry-cache.js +0 -113
  323. package/dist/tasks/scheduler-sync-preview.js +0 -52
  324. package/dist/workflows/freeze/resolve-steps.js +0 -86
  325. package/dist/workflows/freeze/source-freeze.js +0 -64
  326. package/dist/workflows/ir/compile.js +0 -321
  327. package/dist/workflows/ir/environment-v4.js +0 -330
  328. package/dist/workflows/ir/freeze-v4.js +0 -153
  329. package/dist/workflows/ir/schema-v4.js +0 -745
  330. package/dist/workflows/ir/schema.js +0 -354
  331. package/dist/workflows/program/schema.js +0 -78
  332. package/dist/workflows/runtime/checkin.js +0 -57
  333. package/dist/workflows/runtime/plan-classifier.js +0 -196
  334. package/dist/workflows/runtime/unit-checkin.js +0 -45
  335. package/dist/workflows/runtime/unit-phases.js +0 -20
  336. package/dist/workflows/schema.js +0 -4
  337. package/dist/workflows/source-ir/compile.js +0 -200
  338. package/dist/workflows/source-ir/program.js +0 -50
  339. package/dist/workflows/source-ir/result.js +0 -26
  340. package/dist/workflows/source-ir/schema.js +0 -786
  341. package/dist/workflows/source-ir/triggers.js +0 -79
  342. package/dist/workflows/source-ir/uses.js +0 -40
  343. package/dist/workflows/validator.js +0 -60
@@ -1,12 +1,15 @@
1
1
  // This Source Code Form is subject to the terms of the Mozilla Public
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * `akm improve`: under one run lock, bootstrap the index, drain the proposal
6
+ * backlog (triage), select candidates, run preparation → loop → post-loop, then
7
+ * commit what the run wrote (auto-sync). A dry run plans on read-only state.
8
+ */
4
9
  import fs from "node:fs";
5
10
  import path from "node:path";
6
- import { assertNever } from "../../core/assert.js";
7
11
  import { parseRefInput } from "../../core/asset/resolve-ref.js";
8
- import { daysToMs } from "../../core/common.js";
9
- import { bundlesToSourceEntries, loadConfig } from "../../core/config/config.js";
12
+ import { bundlesToSourceEntries, loadConfig, } from "../../core/config/config.js";
10
13
  import { ConfigError, rethrowIfTestIsolationError, UsageError } from "../../core/errors.js";
11
14
  import { appendEvent, readEvents } from "../../core/events.js";
12
15
  import { classifyImproveAction, foldDistillSkipped } from "../../core/improve-types.js";
@@ -31,25 +34,20 @@ import { getEntryCount } from "../../storage/repositories/index-entries-reposito
31
34
  import { openSqliteReadSnapshot, SqliteReadSnapshotUnavailableError } from "../../storage/sqlite-read-snapshot.js";
32
35
  import { summarizeLlmUsageCrossTab } from "../health/llm-usage.js";
33
36
  import { drainProposals } from "../proposal/drain.js";
34
- import { resolveDrainPolicy } from "../proposal/drain-policies.js";
35
37
  import { describeGatedLanes, isAutonomyLaneAllowed } from "./autonomy-gate.js";
36
38
  import { akmDistill } from "./distill.js";
37
- // Eligibility / candidate-selection predicates live in ./eligibility.
38
- import { buildLatestProposalTsMap, collectEligibleRefs, collectEligibleRefsReadOnly, memoryCleanupParentRef, resolveImproveScope, shouldAnalyzeMemoryCleanup, } from "./eligibility.js";
39
- import { projectResolvedProcessRouting, resolveImprovePlan, resolveImproveStrategy, shouldSkipRef, } from "./improve-strategies.js";
39
+ import { collectEligibleRefs, collectEligibleRefsReadOnly, memoryCleanupParentRef, resolveImproveScope, shouldAnalyzeMemoryCleanup, } from "./eligibility.js";
40
+ import { eligibleRefCount, projectResolvedProcessRouting, resolveImprovePlan, resolveImproveStrategy, } from "./improve-strategies.js";
40
41
  import { buildImproveUsageReport } from "./improve-usage-report.js";
42
+ import { lastAttemptByRef, loadLedgerSnapshot } from "./ledger.js";
41
43
  import { improveLockPath, releaseImproveLock, tryAcquireImproveLock } from "./locks.js";
42
- // The cycle loop / post-loop / maintenance stages live in ./loop-stages.
43
44
  import { runImproveLoopStage, runImprovePostLoopStage } from "./loop-stages.js";
44
45
  import { analyzeMemoryCleanup } from "./memory/memory-improve.js";
45
46
  import { buildImproveExecutionPlan } from "./planner.js";
46
- // The pre-loop preparation pipeline lives in ./preparation.
47
- import { runImprovePreparationStage } from "./preparation.js";
47
+ import { CONSOLIDATION_CONFIG_KEYS, pickDefined, recordImproveSkip, runImprovePreparationStage } from "./preparation.js";
48
48
  import { DEFAULT_DUE_DAYS, filterProactiveDue } from "./proactive-maintenance.js";
49
49
  import { akmReflect } from "./reflect.js";
50
- import { createRunContext } from "./run-context.js";
51
- import { errMessage } from "./shared.js";
52
- // Re-exported from ./loop-stages for test importers (improve-db-locking).
50
+ import { errMessage, noticeSet } from "./stage.js";
53
51
  export { runImproveMaintenancePasses } from "./loop-stages.js";
54
52
  export function renderSyncCommitMessage(template, result, nowMs) {
55
53
  const iso = new Date(nowMs).toISOString();
@@ -67,18 +65,16 @@ export function renderSyncCommitMessage(template, result, nowMs) {
67
65
  return template.replace(/\{(\w+)\}/g, (match, key) => tokens[key] ?? match);
68
66
  }
69
67
  /**
70
- * How long the improve loop waits for its FIRST engine response (success or
71
- * error — any terminal record proves the run is not silent) before printing
72
- * one default-level line. The timer is armed once the triage/index prepass
73
- * finishes and the loop is about to start dispatching engine requests — not
74
- * at run start — so it measures engine latency, not prepass time. Field
75
- * re-test (#957): an engine pointed at a dead endpoint produced zero output
76
- * for minutes, so a genuine hang looked identical to a normal-but-slow run.
77
- * A few seconds is short enough that an operator watching a scheduled run's
78
- * live log sees something promptly, long enough that an ordinary fast
79
- * response never prints it.
68
+ * How long the loop waits for its first engine response before printing one
69
+ * "still waiting" line (#957): a dead endpoint otherwise looks like a slow run.
70
+ * Armed when the loop starts, not at run start.
80
71
  */
81
72
  export const FIRST_ENGINE_RESPONSE_HEARTBEAT_MS = 5_000;
73
+ /**
74
+ * Abort the run at the budget; force-exit (0 — budget exhaustion is a normal
75
+ * scheduled-task outcome) only if the drain overruns the grace period.
76
+ * Returns an idempotent disposer.
77
+ */
82
78
  export function armBudgetWatchdog(budgetMs, controller, deps) {
83
79
  const setTimeoutFn = deps?.setTimeoutFn ?? setTimeout;
84
80
  const clearTimeoutFn = deps?.clearTimeoutFn ?? clearTimeout;
@@ -86,15 +82,10 @@ export function armBudgetWatchdog(budgetMs, controller, deps) {
86
82
  const hardKillGraceMs = deps?.hardKillGraceMs ?? 5_000;
87
83
  let hardKillTimer;
88
84
  const budgetTimer = setTimeoutFn(() => {
89
- // Cooperative cancellation first: let the run drain.
90
85
  controller.abort("improve budget exhausted");
91
- // Watchdog: only force-exit if the drain itself overruns the grace period.
92
- // Exit 0: budget exhaustion is a normal scheduled-task condition, not an error.
93
86
  hardKillTimer = setTimeoutFn(() => exitFn(0), hardKillGraceMs);
94
- // Never keep the event loop alive solely for the watchdog.
95
87
  hardKillTimer.unref?.();
96
88
  }, budgetMs);
97
- // RAII dispose: clears whichever timer is still pending. Idempotent.
98
89
  return () => {
99
90
  clearTimeoutFn(budgetTimer);
100
91
  if (hardKillTimer !== undefined) {
@@ -103,62 +94,30 @@ export function armBudgetWatchdog(budgetMs, controller, deps) {
103
94
  }
104
95
  };
105
96
  }
106
- /**
107
- * The run's write-provenance journal lifecycle as one named unit (#652).
108
- *
109
- * The journal is opened once the run owns its lock — so it spans exactly the
110
- * window in which this run, and only this run, is allowed to write — and closed
111
- * on every exit path including the crash-safety commit. Keeping the mutable
112
- * handle behind this factory rather than as a bare `let` in {@link akmImprove}
113
- * is also what keeps that function under the R31 size gate
114
- * (`tests/architecture/improve-fn-size-ratchet.test.ts`, absolute 220-line bar
115
- * with an empty baseline): lifecycle state belongs in a named unit, not in the
116
- * orchestrator's preamble.
117
- */
118
- function createRunWriteJournal() {
119
- let journal;
120
- return {
121
- open: () => {
122
- journal = beginWriteProvenance();
123
- },
124
- close: () => {
125
- journal?.end();
126
- journal = undefined;
127
- },
128
- current: () => journal,
129
- };
130
- }
131
97
  export async function akmImprove(options = {}) {
132
98
  const setup = resolveImproveRunSetup(options);
133
99
  options = setup.options;
134
- const { budgetMs, budgetAbortController, scope, selectedStrategy, syncRepoDir, resolvedStateDbPath, resolvedLockPath, } = setup;
100
+ const { budgetMs, budgetAbortController, scope, selectedStrategy, syncRepoDir, resolvedStateDbPath } = setup;
135
101
  let clearBudgetTimer = () => { };
136
102
  let clearFirstResponseHeartbeat = () => { };
137
- // #957: set by the usage sink's onRecord callback the moment any engine
138
- // call terminates (success or error), including one issued by the prepass
139
- // itself — makes arming the heartbeat below a no-op when the run is
140
- // already known not to be silent.
103
+ // Set by the usage sink when any engine call terminates, prepass included.
141
104
  let firstEngineResponseSeen = false;
142
105
  let initialGitPaths = new Set();
143
- const runJournal = createRunWriteJournal();
106
+ // The write-provenance journal (#652) spans exactly the window this run holds the lock.
107
+ let journal;
108
+ const closeJournal = () => {
109
+ journal?.end();
110
+ journal = undefined;
111
+ };
144
112
  const preEnsureCleanupWarnings = [];
145
- let plannedRefs = [];
146
- let memorySummary = { eligible: 0, derived: 0 };
147
- let strategyFilteredRefs = [];
148
- let indexSnapshot;
149
- let memoryCleanupPlan;
150
- let autonomyGatedDirectLanes = [];
151
- let guidance;
113
+ let collected;
152
114
  let triageDrain;
153
115
  let ensureIndexDurationMs;
154
116
  let improveLockOwnership;
155
117
  let exitBackstop;
156
- // I1: open a single state.db connection for the main improve run so all
157
- // appendEvent calls reuse one handle instead of open/migrate/close per call.
158
118
  let eventsDb;
159
- // Start boundary-pinned for prepass telemetry, then replace this binding with
160
- // the long-lived handle after the prepass. The usage sink resolves it per
161
- // append, so one owner and counter span both lifecycle phases.
119
+ // Boundary-pinned for the prepass; replaced by the long-lived handle after it.
120
+ // The usage sink resolves it per append.
162
121
  let eventsCtx = { dbPath: resolvedStateDbPath };
163
122
  let disposeLlmUsageSink = () => { };
164
123
  const releaseRunLock = () => {
@@ -170,15 +129,27 @@ export async function akmImprove(options = {}) {
170
129
  releaseImproveLock(ownership);
171
130
  }
172
131
  catch {
173
- // Best-effort cleanup. Exact ownership prevents deleting a successor.
132
+ // Best-effort; exact ownership never deletes a successor's lock.
174
133
  }
175
134
  };
135
+ const teardown = () => {
136
+ // The usage sink goes before eventsDb closes; the journal closes after the
137
+ // crash-safety commit that still needs it.
138
+ disposeLlmUsageSink();
139
+ clearFirstResponseHeartbeat();
140
+ clearBudgetTimer();
141
+ if (exitBackstop) {
142
+ // Only our own listener: removeAllListeners would drop the host's.
143
+ process.removeListener("exit", exitBackstop);
144
+ exitBackstop = undefined;
145
+ }
146
+ releaseRunLock();
147
+ closeJournal();
148
+ };
176
149
  const commitStashBatch = makeCommitStashBatch({
177
150
  run: setup,
178
151
  getInitialGitPaths: () => initialGitPaths,
179
- getWriteJournal: () => runJournal.current(),
180
- // Captured via getter: the live `eventsCtx` binding is reassigned after the
181
- // prepass, and the catch-path sync must write through the current context.
152
+ getWriteJournal: () => journal,
182
153
  getEventsCtx: () => eventsCtx,
183
154
  });
184
155
  if (!options.dryRun) {
@@ -187,8 +158,7 @@ export async function akmImprove(options = {}) {
187
158
  }
188
159
  try {
189
160
  if (!options.dryRun) {
190
- const acquisition = tryAcquireImproveLock(resolvedLockPath, options.skipIfLocked, {
191
- // R25: C2 boundary-pinned path — the long-lived handle doesn't exist yet.
161
+ const acquisition = tryAcquireImproveLock(setup.resolvedLockPath, options.skipIfLocked, {
192
162
  dbPath: resolvedStateDbPath,
193
163
  });
194
164
  if (acquisition.state === "skipped") {
@@ -204,86 +174,38 @@ export async function akmImprove(options = {}) {
204
174
  process.on("exit", exitBackstop);
205
175
  initialGitPaths =
206
176
  syncRepoDir && isGitBackedStash(syncRepoDir) ? new Set(listGitChangedPaths(syncRepoDir)) : new Set();
207
- // #652: open AFTER the lock, so the journal covers exactly the window in
208
- // which this run — and only this run — is allowed to write.
209
- runJournal.open();
210
- // R6: ensureIndex BEFORE triage, not after. Triage promotes proposals
211
- // straight into the flat `knowledge/` root; running the blocking
212
- // reindex only afterward (inside indexAndCollect, below) meant every
213
- // triage promotion dirtied the stash right before the rescan that is
214
- // supposed to precede it, so the "blocking" reindex always found fresh
215
- // work and paid for a full walk on the very run it was meant to avoid.
177
+ journal = beginWriteProvenance();
178
+ // The index is made current BEFORE triage (R6): triage promotes into the
179
+ // stash, and a reindex after it would always find fresh work.
216
180
  const bootstrap = await runIndexBootstrapPass(setup, budgetAbortController.signal);
217
181
  preEnsureCleanupWarnings.push(...bootstrap.warnings);
218
182
  ensureIndexDurationMs = bootstrap.ensureIndexDurationMs;
219
- // Drain the standing proposal backlog under the now-current index, so
220
- // fresh proposal generation sees promotions from this same serialized
221
- // run.
222
183
  triageDrain = await runTriagePrePass(setup);
223
- // R6: index triage's own writes incrementally (the R8
224
- // `indexWrittenAssets` pattern) instead of letting them sit newer than
225
- // `builtAt` until the next full reindex. `collectEligibleRefs` below
226
- // still needs to see them, and per-file staleness (`ensure-index.ts`)
227
- // needs their `content_hash` recorded so it does not re-trigger a full
228
- // rescan next run for content it already has.
229
- if (setup.primaryStashDir) {
230
- const triageWrittenPaths = runJournal.current()?.writtenPaths() ?? [];
231
- if (triageWrittenPaths.length > 0) {
232
- await indexWrittenAssets(setup.primaryStashDir, triageWrittenPaths);
233
- }
184
+ // Index triage's own writes incrementally so selection sees them.
185
+ const triageWrittenPaths = journal?.writtenPaths() ?? [];
186
+ if (setup.primaryStashDir && triageWrittenPaths.length > 0) {
187
+ await indexWrittenAssets(setup.primaryStashDir, triageWrittenPaths);
234
188
  }
235
189
  }
236
- const collected = await indexAndCollect({ run: setup });
237
- plannedRefs = collected.plannedRefs;
238
- memorySummary = collected.memorySummary;
239
- strategyFilteredRefs = collected.strategyFilteredRefs;
240
- indexSnapshot = collected.indexSnapshot;
241
- memoryCleanupPlan = collected.memoryCleanupPlan;
242
- autonomyGatedDirectLanes = collected.autonomyGatedDirectLanes;
243
- guidance = collected.guidance;
190
+ collected = await indexAndCollect(setup);
244
191
  if (options.dryRun) {
245
- const result = await runDryPlanningStage({
246
- run: setup,
247
- collected,
248
- resolvedStateDbPath,
249
- budgetMs,
250
- initialCleanupWarnings: preEnsureCleanupWarnings,
251
- signal: budgetAbortController.signal,
252
- });
192
+ const result = await runDryPlanningStage(setup, collected, preEnsureCleanupWarnings);
253
193
  clearBudgetTimer();
254
194
  return result;
255
195
  }
256
196
  }
257
197
  catch (err) {
258
- // If the live prepass fails, emit its summary and clear the owning sink
259
- // before any run teardown. The disposer is idempotent with the main finalizer.
260
- disposeLlmUsageSink();
261
- clearFirstResponseHeartbeat();
262
- clearBudgetTimer();
263
- if (exitBackstop) {
264
- process.removeListener("exit", exitBackstop);
265
- exitBackstop = undefined;
266
- }
267
- releaseRunLock();
268
- runJournal.close();
198
+ teardown();
269
199
  throw err;
270
200
  }
271
201
  try {
272
- const openedEvents = openImproveEventsContext(resolvedStateDbPath);
273
- eventsDb = openedEvents.db;
274
- eventsCtx = openedEvents.ctx;
275
- // WI-9.10: construct the run's RunContext here — the first point after
276
- // run-setup where config/stashDir/eventsCtx/proposalsCtx/sourceRun/dryRun
277
- // are all in hand. See buildImproveRunContext for exactly which
278
- // already-resolved values back each field.
279
- const ctx = buildImproveRunContext(setup, eventsCtx);
280
- // #957: arm the heartbeat here, immediately before the improve loop
281
- // starts dispatching engine requests — not at run start, where its timer
282
- // would measure the triage/index prepass instead of engine latency. A
283
- // no-op when the prepass already produced a terminal LLM record (the
284
- // onRecord callback above already saw it). Cleared the moment any call
285
- // terminates (success or error) — never rearmed, so this prints at most
286
- // once per run.
202
+ try {
203
+ eventsDb = openStateDatabase(resolvedStateDbPath);
204
+ eventsCtx = { db: eventsDb };
205
+ }
206
+ catch (err) {
207
+ rethrowIfTestIsolationError(err);
208
+ }
287
209
  if (!firstEngineResponseSeen) {
288
210
  const firstResponseTimer = setTimeout(() => {
289
211
  warn("[improve] Still waiting for the first engine response...");
@@ -291,46 +213,11 @@ export async function akmImprove(options = {}) {
291
213
  firstResponseTimer.unref?.();
292
214
  clearFirstResponseHeartbeat = () => clearTimeout(firstResponseTimer);
293
215
  }
294
- const seq = await runImproveStageSequence({
295
- run: setup,
296
- strategyFilteredRefs,
297
- plannedRefs,
298
- memoryCleanupPlan,
299
- autonomyGatedDirectLanes,
300
- memorySummary,
301
- preEnsureCleanupWarnings,
302
- eventsCtx,
303
- ctx,
304
- });
305
- const result = finalizeImproveResult({
306
- run: setup,
307
- seq,
308
- guidance,
309
- memorySummary,
310
- memoryCleanupPlan,
311
- strategyFilteredRefs,
312
- rawPlannedRefs: plannedRefs,
313
- indexSnapshot,
314
- triageDrain,
315
- ensureIndexDurationMs,
316
- eventsCtx,
317
- });
318
- // End-of-run BATCH auto-sync — the converged commit. Recognition is
319
- // decoupled from the per-write path (see write-source.ts case-3): the primary
320
- // stash writes as a filesystem source during the run, then is committed via
321
- // the same `saveGitStash` that `akm sync` calls. The gating (git-backed
322
- // primary stash, sync not disabled) and the NON-FATAL guarantee now live in
323
- // `commitStashBatch` (#662); the inter-cycle and catch-path calls reuse it.
324
- // dry-run already returned above, so this always runs on a completed live
325
- // run. `result.sync` reflects this final commit (for a one-cycle run it is
326
- // the only commit; for a multi-cycle run the earlier cycles were banked by
327
- // the inter-cycle calls and this records the last batch). `result` carries
328
- // the full `{accepted}`/`{refs}`/`{triage_*}` token data for the message.
329
- //
330
- // #652: surface the run's write provenance on the envelope BEFORE the sync
331
- // (the sync itself writes no assets), so `result.writtenPaths` describes
332
- // exactly the set the commit below was scoped to.
333
- const writtenPaths = describeRunWrittenPaths(setup, runJournal.current()?.writtenPaths() ?? []);
216
+ const seq = await runImproveStageSequence(setup, collected, preEnsureCleanupWarnings, eventsCtx);
217
+ const result = finalizeImproveResult({ run: setup, seq, collected, triageDrain, ensureIndexDurationMs, eventsCtx });
218
+ // The run's write provenance goes on the envelope before the sync, so
219
+ // `writtenPaths` is exactly the set the commit is scoped to.
220
+ const writtenPaths = describeRunWrittenPaths(setup, journal?.writtenPaths() ?? []);
334
221
  if (writtenPaths.length > 0)
335
222
  result.writtenPaths = writtenPaths;
336
223
  result.sync = commitStashBatch(result);
@@ -338,64 +225,24 @@ export async function akmImprove(options = {}) {
338
225
  }
339
226
  catch (err) {
340
227
  recordImproveFailure(err, setup, eventsCtx);
341
- // #662 crash/abort safety net: commit whatever this run already wrote to the
342
- // primary stash BEFORE rethrowing, so an interrupted run (mid-cycle crash or
343
- // a cooperative budget abort that surfaces as a throw) does not leave its
344
- // writes uncommitted until a later clean run sweeps them up. Best-effort —
345
- // `commitStashBatch` swallows its own errors and no-ops a clean tree, so this
346
- // never masks or supersedes the original failure being rethrown below.
347
- commitStashBatch({ scope, plannedRefs, runId: options.runId });
228
+ // Crash/abort safety net (#662): commit what this run already wrote.
229
+ // commitStashBatch never throws and no-ops a clean tree.
230
+ commitStashBatch({ scope, plannedRefs: collected.plannedRefs, runId: options.runId });
348
231
  throw err;
349
232
  }
350
233
  finally {
351
- // #576: clear the per-run LLM usage sink BEFORE closing `eventsDb` below, so
352
- // no late sink invocation can write through a closed handle.
353
- disposeLlmUsageSink();
354
- // #957: never leave the first-response heartbeat timer pending past the run.
355
- clearFirstResponseHeartbeat();
356
- // O-1 (#364): Clear the budget abort timer so it does not keep the event
357
- // loop alive after the run completes.
358
- clearBudgetTimer();
359
- // Drop ONLY our own process.exit backstop so it does not fire later (or
360
- // accumulate across repeated in-process calls). Must NOT use
361
- // removeAllListeners("exit") here: in the in-process model (tests and
362
- // programmatic callers import cli.ts) that would silently destroy exit
363
- // handlers owned by the host or other commands.
364
- if (exitBackstop) {
365
- process.removeListener("exit", exitBackstop);
366
- exitBackstop = undefined;
367
- }
368
- releaseRunLock();
369
- // #652: close the write-provenance journal LAST among the write-facing
370
- // teardown steps — the catch path's crash-safety commit above still needs
371
- // it open to know what this run wrote.
372
- runJournal.close();
373
- // I1: close the long-lived state.db connection opened at the top of the run.
234
+ teardown();
374
235
  try {
375
236
  eventsDb?.close();
376
237
  }
377
238
  catch {
378
- // ignore — DB may already be closed
239
+ // already closed
379
240
  }
380
241
  }
381
242
  }
382
- /** Open the run-owned state handle while preserving the boundary-pinned fallback. */
383
- function openImproveEventsContext(dbPath) {
384
- try {
385
- const db = openStateDatabase(dbPath);
386
- return { db, ctx: { db } };
387
- }
388
- catch (err) {
389
- rethrowIfTestIsolationError(err);
390
- return { ctx: { dbPath } };
391
- }
392
- }
393
243
  /**
394
- * Shape the run's journaled absolute paths for `result.writtenPaths` (#652):
395
- * POSIX-relative to the run's primary stash dir (the repo root for a git-backed
396
- * stash) when the write landed inside it, absolute otherwise — a run writing to
397
- * a `--target` bundle outside the stash still reports what it wrote. Deduped and
398
- * sorted so the field is stable across runs.
244
+ * The run's journaled paths for `result.writtenPaths`: POSIX-relative to the
245
+ * primary stash when inside it, absolute otherwise; deduped and sorted.
399
246
  */
400
247
  function describeRunWrittenPaths(setup, writtenPaths) {
401
248
  const root = setup.primaryStashDir ?? setup.syncRepoDir;
@@ -407,19 +254,16 @@ function describeRunWrittenPaths(setup, writtenPaths) {
407
254
  return [...described].sort();
408
255
  }
409
256
  /**
410
- * Resolve a dry-run inspection source without adapting it into a write target.
411
- * Exported so `improve-cli.ts`'s `--show-prompt` (#952) can resolve the same
412
- * read-only bundle a plain `--dry-run` would, without duplicating this
413
- * selector/target/fallback precedence.
257
+ * The source a dry run (or `--show-prompt`) inspects, without adapting it into
258
+ * a write target.
414
259
  */
415
260
  export function resolveImproveReadSource(config, scopedRef, explicitTarget, fallbackStashDir) {
416
261
  if (scopedRef?.origin && explicitTarget && scopedRef.origin !== explicitTarget) {
417
262
  throw new UsageError(`Qualified ref bundle "${scopedRef.origin}" conflicts with --target "${explicitTarget}".`, "INVALID_FLAG_VALUE", `Drop --target or use --target ${scopedRef.origin}.`);
418
263
  }
419
264
  const selector = scopedRef?.origin ?? explicitTarget ?? config.defaultWriteTarget;
420
- if (!selector && fallbackStashDir) {
265
+ if (!selector && fallbackStashDir)
421
266
  return { source: { name: "stash", path: fallbackStashDir } };
422
- }
423
267
  const configuredSelector = selector ?? config.defaultBundle;
424
268
  if (configuredSelector) {
425
269
  const entry = bundlesToSourceEntries(config)?.find((source) => source.name === configuredSelector);
@@ -433,17 +277,14 @@ export function resolveImproveReadSource(config, scopedRef, explicitTarget, fall
433
277
  return { selector: configuredSelector, source: { name: configuredSelector, path: sourcePath } };
434
278
  }
435
279
  const implicit = resolveSourceEntries(undefined, config)[0];
436
- if (!implicit) {
280
+ if (!implicit)
437
281
  throw new ConfigError("no source configured; run `akm bundle create`", "STASH_DIR_NOT_FOUND");
438
- }
439
282
  return { source: { name: implicit.registryId ?? "stash", path: implicit.path } };
440
283
  }
441
284
  /**
442
- * Run-setup: budget/watchdog plumbing, scope + seam resolution, the invocation
443
- * plan, write-target resolution, the profile-defaulted options rebuild, and
444
- * the boundary-pinned state.db/lock paths. Fully SYNCHRONOUS — the C2 boundary
445
- * snapshot (resolvedStateDbPath) must be taken before the first await of the
446
- * run.
285
+ * Run setup, fully synchronous: the budget signal, the invocation plan, the
286
+ * write target, the profile-defaulted options, and the state.db and lock paths
287
+ * pinned before the first await (C2: a later env change cannot redirect them).
447
288
  */
448
289
  function resolveImproveRunSetup(options) {
449
290
  const startMs = Date.now();
@@ -455,30 +296,15 @@ function resolveImproveRunSetup(options) {
455
296
  configurable: true,
456
297
  });
457
298
  const scope = resolveImproveScope(options.scope);
458
- const reflectFn = options.reflectFn ?? akmReflect;
459
- const distillFn = options.distillFn ?? akmDistill;
460
- const ensureIndexFn = options.ensureIndexFn ?? ensureIndex;
461
- const reindexFn = options.reindexFn ?? akmIndex;
462
- const drainProposalsFn = options.drainProposalsFn ?? drainProposals;
463
- // #616 multi-cycle test seams. Default to the real module-local fns.
464
- const collectEligibleRefsImpl = options.collectEligibleRefsFn ?? (options.dryRun ? collectEligibleRefsReadOnly : collectEligibleRefs);
465
- const runImprovePreparationStageImpl = options.runImprovePreparationStageFn ?? runImprovePreparationStage;
466
- const runImproveLoopStageImpl = options.runImproveLoopStageFn ?? runImproveLoopStage;
467
- const runImprovePostLoopStageImpl = options.runImprovePostLoopStageFn ?? runImprovePostLoopStage;
468
- // Resolve the improve profile for this run. Profile drives type filtering,
469
- // process gating, and the default limit value.
470
- const _earlyConfig = options.config ?? loadConfig();
471
- const configuredImproveProfile = resolveImproveStrategy(options.strategy, _earlyConfig).config;
299
+ const config = options.config ?? loadConfig();
300
+ const configuredImproveProfile = resolveImproveStrategy(options.strategy, config).config;
301
+ // A dry run never dispatches, so an all-disabled strategy must not abort it.
472
302
  const resolvedPlan = options.resolvedPlan ??
473
- // #800/#957 round 3 — same dry-run exemption as improve-cli.ts's own
474
- // resolveImprovePlan call: a dry run never dispatches, so a strategy left
475
- // fully disabled by an unreachable credential must not abort here either.
476
- resolveImprovePlan(options.strategy, _earlyConfig, {
303
+ resolveImprovePlan(options.strategy, config, {
477
304
  repairValidationFailures: options.repairValidationFailures,
478
305
  allowAllDisabled: options.dryRun,
479
306
  });
480
307
  const selectedStrategy = resolvedPlan.strategy;
481
- const improveSensitiveValues = collectEngineCredentialValues(_earlyConfig);
482
308
  const improveProfile = selectedStrategy.config;
483
309
  const configuredLimits = {
484
310
  ...(options.limit !== undefined ? { cli: options.limit } : {}),
@@ -487,52 +313,36 @@ function resolveImproveRunSetup(options) {
487
313
  ? { reflect: configuredImproveProfile.processes.reflect.limit }
488
314
  : {}),
489
315
  };
316
+ // --limit, then the reflect process limit, then the profile limit.
490
317
  const effectiveLimit = options.limit ?? improveProfile?.processes?.reflect?.limit ?? improveProfile.limit;
491
318
  const scopedRef = scope.mode === "ref" && scope.value ? parseRefInput(scope.value) : undefined;
492
319
  const readSource = options.dryRun
493
320
  ? options.writeTarget
494
321
  ? { selector: options.writeTarget.selector, source: options.writeTarget.source }
495
- : resolveImproveReadSource(_earlyConfig, scopedRef, options.target, options.stashDir)
322
+ : resolveImproveReadSource(config, scopedRef, options.target, options.stashDir)
496
323
  : undefined;
497
324
  const writeTarget = options.dryRun
498
325
  ? undefined
499
326
  : scopedRef?.origin
500
- ? resolveMutationTarget(_earlyConfig, scopedRef, options.writeTarget?.source.name ?? options.target).target
327
+ ? resolveMutationTarget(config, scopedRef, options.writeTarget?.source.name ?? options.target).target
501
328
  : (options.writeTarget ??
502
- (options.target || _earlyConfig.defaultWriteTarget || !options.stashDir
503
- ? resolveWriteTarget(_earlyConfig, options.target)
329
+ (options.target || config.defaultWriteTarget || !options.stashDir
330
+ ? resolveWriteTarget(config, options.target)
504
331
  : {
505
332
  source: { kind: "filesystem", name: "stash", path: options.stashDir },
506
- config: {
507
- type: "filesystem",
508
- name: "stash",
509
- path: options.stashDir,
510
- writable: true,
511
- },
333
+ config: { type: "filesystem", name: "stash", path: options.stashDir, writable: true },
512
334
  }));
513
335
  const selectedSource = writeTarget?.source ?? readSource?.source;
514
336
  if (!selectedSource)
515
337
  throw new ConfigError("improve could not resolve a source", "STASH_DIR_NOT_FOUND");
516
- const selectedSelector = writeTarget?.selector ?? readSource?.selector;
517
- // Apply profile defaults — CLI flags take precedence over profile defaults.
518
- // Rebuild options with effective values so all downstream stage functions
519
- // automatically pick up the profile-driven defaults.
338
+ // Every stage reads this one config snapshot; nothing reloads it mid-run.
520
339
  options = {
521
340
  ...options,
522
- // Pin nested calls and quality gates to the same config snapshot as the
523
- // invocation plan. They must never reload a changed config mid-run.
524
- config: _earlyConfig,
525
- target: selectedSelector,
341
+ config,
342
+ target: writeTarget?.selector ?? readSource?.selector,
526
343
  sourceName: selectedSource.name,
527
344
  ...(writeTarget ? { writeTarget } : {}),
528
345
  stashDir: selectedSource.path,
529
- consolidateOptions: {
530
- ...options.consolidateOptions,
531
- target: selectedSelector,
532
- ...(writeTarget ? { writeTarget } : {}),
533
- },
534
- // Profile-level limit, then process-level reflect.limit as fallback.
535
- // CLI --limit takes precedence over both.
536
346
  limit: effectiveLimit,
537
347
  };
538
348
  let primaryStashDir;
@@ -542,42 +352,24 @@ function resolveImproveRunSetup(options) {
542
352
  catch {
543
353
  primaryStashDir = undefined;
544
354
  }
545
- const syncRepoDir = writeTarget?.source.repoPath ?? primaryStashDir;
546
- // C2 (#553/#554/#499): resolve the state.db path ONCE, synchronously, at the
547
- // command boundary — before the first `await` below. Every state.db open in
548
- // this run (`openStateDatabase`, every default-path `appendEvent`) is pinned
549
- // to this snapshot via `eventsCtx.dbPath`, so a parallel test file mutating
550
- // `process.env.XDG_DATA_HOME` across an await boundary can never redirect this
551
- // run's DB opens to a wrong/just-deleted tmpdir mid-flight (the parallel-load
552
- // timeout root cause). Because beforeEach runs synchronously, env is still the
553
- // calling test's own at this point; we capture it before yielding the loop.
554
- const resolvedStateDbPath = getStateDbPathInDataDir();
555
- // One conservative run lock protects the complete mutation window, including
556
- // triage, indexing, proposal work, maintenance, and final stash sync. Moved
557
- // out of `$STASH/.akm` to `$STATE/locks/<stash>/` (itlackey/akm#890): a lock
558
- // file is machine-local coordination state, not content that must travel
559
- // with the bundle.
560
- const lockBaseDir = getStashLocksDir(primaryStashDir ?? options.stashDir ?? ".");
561
- const resolvedLockPath = improveLockPath(lockBaseDir);
562
- const effectiveSync = { ...improveProfile.sync, ...options.sync };
563
355
  return {
564
356
  startMs,
565
357
  budgetMs,
566
358
  budgetAbortController,
567
359
  scope,
568
- reflectFn,
569
- distillFn,
570
- ensureIndexFn,
571
- reindexFn,
572
- drainProposalsFn,
573
- collectEligibleRefsImpl,
574
- runImprovePreparationStageImpl,
575
- runImproveLoopStageImpl,
576
- runImprovePostLoopStageImpl,
577
- _earlyConfig,
360
+ reflectFn: options.reflectFn ?? akmReflect,
361
+ distillFn: options.distillFn ?? akmDistill,
362
+ ensureIndexFn: options.ensureIndexFn ?? ensureIndex,
363
+ reindexFn: options.reindexFn ?? akmIndex,
364
+ drainProposalsFn: options.drainProposalsFn ?? drainProposals,
365
+ collectEligibleRefsImpl: options.collectEligibleRefsFn ?? (options.dryRun ? collectEligibleRefsReadOnly : collectEligibleRefs),
366
+ runImprovePreparationStageImpl: options.runImprovePreparationStageFn ?? runImprovePreparationStage,
367
+ runImproveLoopStageImpl: options.runImproveLoopStageFn ?? runImproveLoopStage,
368
+ runImprovePostLoopStageImpl: options.runImprovePostLoopStageFn ?? runImprovePostLoopStage,
369
+ config,
578
370
  resolvedPlan,
579
371
  selectedStrategy,
580
- improveSensitiveValues,
372
+ improveSensitiveValues: collectEngineCredentialValues(config),
581
373
  improveProfile,
582
374
  configuredImproveProfile,
583
375
  configuredLimits,
@@ -585,108 +377,58 @@ function resolveImproveRunSetup(options) {
585
377
  writeTarget,
586
378
  options,
587
379
  primaryStashDir,
588
- syncRepoDir,
589
- resolvedStateDbPath,
590
- resolvedLockPath,
591
- effectiveSync,
380
+ syncRepoDir: writeTarget?.source.repoPath ?? primaryStashDir,
381
+ resolvedStateDbPath: getStateDbPathInDataDir(),
382
+ // The run lock is machine-local state, kept outside the bundle (#890).
383
+ resolvedLockPath: improveLockPath(getStashLocksDir(primaryStashDir ?? options.stashDir ?? ".")),
384
+ effectiveSync: { ...improveProfile.sync, ...options.sync },
592
385
  };
593
386
  }
594
- /**
595
- * WI-9.10: build the run's {@link RunContext} purely from values
596
- * `resolveImproveRunSetup` and the long-lived state.db open already
597
- * resolved — no second config load, no new db handle. Called once, right
598
- * after `eventsCtx` is resolved (the last of {config, stashDir, eventsCtx,
599
- * proposalsCtx, sourceRun, dryRun} to become available in `akmImprove`).
600
- */
601
- function buildImproveRunContext(run, eventsCtx) {
602
- return createRunContext({
603
- config: run._earlyConfig,
604
- // primaryStashDir can fail to resolve (rare); "." mirrors the existing
605
- // lockBaseDir fallback in resolveImproveRunSetup for the same case. This
606
- // is a BEST-EFFORT value for the required RunContext field only — the
607
- // loop stage reads `ImproveLoopState.primaryStashDir` (the honest
608
- // optional), so its `if (primaryStashDir)` guards still skip on the
609
- // unresolvable path; no RunContext consumer reads `ctx.stashDir` there.
610
- stashDir: run.primaryStashDir ?? run.options.stashDir ?? ".",
611
- eventsCtx,
612
- // ProposalsContext threads only a dbPath seam (D14: no db handle is
613
- // threaded in), mirroring eventsCtx's own dbPath-only fallback shape.
614
- // Not yet wired into any proposal call site this stage — verb-level
615
- // RunContext adoption (reflect/distill/extract/consolidate) is later.
616
- proposalsCtx: { dbPath: run.resolvedStateDbPath },
617
- // Representative symbolic runner for this run: reflect is the loop's
618
- // primary model-driving process. Credentials remain unresolved here.
619
- getLlmRunner: () => run.resolvedPlan.processes.reflect.runner,
620
- sourceRun: run.options.runId ?? `improve-${run.startMs}`,
621
- // Always false here: callers only reach this point past the dry-run
622
- // early return in akmImprove.
623
- dryRun: run.options.dryRun ?? false,
624
- // Same AbortSignal instance armBudgetWatchdog stamped a live
625
- // `remainingBudgetMs` getter onto (#616) — identity preserved so
626
- // `ctx.signal.remainingBudgetMs` resolves correctly for every consumer.
627
- signal: run.budgetAbortController.signal,
628
- });
629
- }
630
- /** The catch-path improve_failed audit event (D3), redacted. */
387
+ /** The redacted `improve_failed` event for a crashed run. */
631
388
  function recordImproveFailure(err, run, eventsCtx) {
632
- const { scope, selectedStrategy, improveSensitiveValues, startMs } = run;
633
- // D3: emit improve_failed on unexpected crash so dashboards can detect failures.
634
389
  appendEvent({
635
390
  eventType: "improve_failed",
636
- ref: scope.mode === "ref" ? scope.value : `improve:${scope.mode}:${scope.value ?? "all"}`,
391
+ ref: run.scope.mode === "ref" ? run.scope.value : `improve:${run.scope.mode}:${run.scope.value ?? "all"}`,
637
392
  metadata: {
638
- strategy: selectedStrategy.name,
639
- error: redactSensitiveText(errMessage(err), improveSensitiveValues),
640
- durationMs: Date.now() - startMs,
393
+ strategy: run.selectedStrategy.name,
394
+ error: redactSensitiveText(errMessage(err), run.improveSensitiveValues),
395
+ durationMs: Date.now() - run.startMs,
641
396
  },
642
397
  }, eventsCtx);
643
398
  }
644
- /**
645
- * R6: ensureIndex bootstrap pass, hoisted out of `indexAndCollect` (below) so
646
- * the caller can run it BEFORE the triage pre-pass instead of after it —
647
- * triage promotes proposals straight into `knowledge/`, so ensureIndex must
648
- * see a current index before triage dirties it, not after. Gated exactly as
649
- * this logic was gated inside `indexAndCollect`: only for a resolved
650
- * `primaryStashDir` on a non-dry-run.
651
- *
652
- * #339 fix carried over unchanged: ensureIndex MUST run before
653
- * collectEligibleRefs. The eligible-ref query reads the `entries` table; if a
654
- * DB version upgrade just dropped that table (or the index is otherwise
655
- * empty), skipping this would silently return plannedRefs=[] and the improve
656
- * loop would no-op.
657
- */
658
- async function runIndexBootstrapPass(run, signal) {
659
- const { primaryStashDir, options, ensureIndexFn } = run;
660
- const warnings = [];
661
- if (!primaryStashDir || options.dryRun)
662
- return { warnings };
663
- // Probe pre-ensureIndex entry count to drive the loud-fail warning below.
664
- // Best-effort: a missing DB / unreadable schema is the fresh-install case
665
- // and not a bug — we silently skip the probe.
666
- let preEnsureEntryCount;
399
+ function probeEntryCount() {
667
400
  try {
668
- const dbPath = getDbPath();
669
- if (fs.existsSync(dbPath)) {
670
- const probeDb = openExistingDatabase();
671
- try {
672
- preEnsureEntryCount = getEntryCount(probeDb);
673
- }
674
- finally {
675
- closeDatabase(probeDb);
676
- }
401
+ if (!fs.existsSync(getDbPath()))
402
+ return undefined;
403
+ const db = openExistingDatabase();
404
+ try {
405
+ return getEntryCount(db);
406
+ }
407
+ finally {
408
+ closeDatabase(db);
677
409
  }
678
410
  }
679
411
  catch (err) {
680
412
  rethrowIfTestIsolationError(err);
681
- // best-effort; leave preEnsureEntryCount undefined
413
+ return undefined;
682
414
  }
415
+ }
416
+ /**
417
+ * ensureIndex before selection (#339): the eligible-ref query reads `entries`,
418
+ * which a DB version upgrade may just have dropped. An index that was empty
419
+ * and is now populated means an upgrade rebuild happened; say so.
420
+ */
421
+ async function runIndexBootstrapPass(run, signal) {
422
+ const { primaryStashDir, options } = run;
423
+ const warnings = [];
424
+ if (!primaryStashDir || options.dryRun)
425
+ return { warnings };
426
+ const preEnsureEntryCount = probeEntryCount();
683
427
  let ensureIndexDurationMs;
684
428
  try {
685
- await ensureIndexFn(primaryStashDir, {
429
+ await run.ensureIndexFn(primaryStashDir, {
686
430
  mode: "blocking",
687
431
  signal,
688
- // R6: capture the implicit reindex's wall-clock cost (otherwise
689
- // discarded) so the caller can surface it on the improve result.
690
432
  onReindexTiming: ({ durationMs }) => {
691
433
  ensureIndexDurationMs = durationMs;
692
434
  },
@@ -697,72 +439,37 @@ async function runIndexBootstrapPass(run, signal) {
697
439
  throw err;
698
440
  warnings.push(`ensureIndex failed: ${errMessage(err)}`);
699
441
  }
700
- // #339 loud-fail: if the index was empty pre-ensureIndex but is now
701
- // populated, a version-upgrade-triggered rebuild just happened. Surface
702
- // that on stderr so the improve run is not silently masked by stale
703
- // index state. Zero-before AND zero-after is the empty-stash case and
704
- // is intentionally not warned (not a bug).
705
- if (preEnsureEntryCount === 0) {
706
- try {
707
- const probeDb = openExistingDatabase();
708
- let postCount = 0;
709
- try {
710
- postCount = getEntryCount(probeDb);
711
- }
712
- finally {
713
- closeDatabase(probeDb);
714
- }
715
- if (postCount > 0) {
716
- warn("[improve] index was empty after DB version upgrade — repopulating before continuing");
717
- }
718
- }
719
- catch (err) {
720
- rethrowIfTestIsolationError(err);
721
- // best-effort
722
- }
442
+ if (preEnsureEntryCount === 0 && (probeEntryCount() ?? 0) > 0) {
443
+ warn("[improve] index was empty after DB version upgrade — repopulating before continuing");
723
444
  }
724
445
  return { warnings, ensureIndexDurationMs };
725
446
  }
726
- /**
727
- * collectEligibleRefs + the contradiction pre-pass + the memory-cleanup
728
- * recompute. Formerly the runIndexAndCollect closure mutating outer `let`s;
729
- * now a pure pass returning its results. The ensureIndex bootstrap this
730
- * function used to open with now runs earlier, before triage — see
731
- * {@link runIndexBootstrapPass} and its call site in `akmImprove`.
732
- */
733
- async function indexAndCollect(args) {
734
- const { scope, options, primaryStashDir, improveProfile, _earlyConfig, collectEligibleRefsImpl } = args.run;
735
- const { plannedRefs, memorySummary, strategyFilteredRefs = [], indexSnapshot, } = await collectEligibleRefsImpl(scope, options.stashDir, improveProfile, _earlyConfig);
447
+ /** Candidate selection plus the memory-cleanup plan and the autonomy-gated direct lanes. */
448
+ async function indexAndCollect(run) {
449
+ const { scope, options, primaryStashDir, improveProfile, config } = run;
450
+ const { plannedRefs, memorySummary, strategyFilteredRefs, indexSnapshot } = await run.collectEligibleRefsImpl(scope, options.stashDir, improveProfile, config);
736
451
  const cleanupParentRef = memoryCleanupParentRef(scope, options.stashDir);
737
- // D8 — the two direct lanes share one eligibility predicate, which reads scope
738
- // and eligible-memory count and no strategy flag at all. Compute it once: it
739
- // decides whether each lane would have run, which is also what decides whether
740
- // a gated lane is worth REPORTING. A lane that was never eligible was not
741
- // suppressed by the gate, so claiming it was would be noise.
742
- const cleanupEligible = shouldAnalyzeMemoryCleanup(scope, memorySummary.eligible, primaryStashDir);
743
- // Cleanup remains independent of the disabled contradiction writer.
744
- const memoryCleanupPlan = cleanupEligible
452
+ const memoryCleanupPlan = shouldAnalyzeMemoryCleanup(scope, memorySummary.eligible, primaryStashDir)
745
453
  ? analyzeMemoryCleanup(primaryStashDir, cleanupParentRef ? { parentRef: cleanupParentRef } : undefined)
746
454
  : undefined;
455
+ // A lane that would not have run anyway was not suppressed by the gate.
747
456
  const cleanupWouldMutate = Boolean(memoryCleanupPlan &&
748
457
  (memoryCleanupPlan.pruneCandidates.length > 0 ||
749
458
  memoryCleanupPlan.beliefStateTransitions.length > 0 ||
750
459
  memoryCleanupPlan.relativeDateCandidates.length > 0));
751
- const autonomyGatedDirectLanes = cleanupWouldMutate && !isAutonomyLaneAllowed("memoryCleanup", _earlyConfig) ? ["memoryCleanup"] : [];
752
- const guidance = memorySummary.eligible > 0
753
- ? "Improve folds memory cleanup into the same proposal queue: speculative promotions still go through reflect/distill proposals, while high-confidence redundant derived memories are moved into a recoverable cleanup archive instead of being left active in the stash."
754
- : undefined;
755
460
  return {
756
461
  plannedRefs,
757
462
  memorySummary,
758
463
  strategyFilteredRefs: strategyFilteredRefs ?? [],
759
464
  indexSnapshot,
760
465
  memoryCleanupPlan,
761
- autonomyGatedDirectLanes,
762
- guidance,
466
+ autonomyGatedDirectLanes: cleanupWouldMutate && !isAutonomyLaneAllowed("memoryCleanup", config) ? ["memoryCleanup"] : [],
467
+ guidance: memorySummary.eligible > 0
468
+ ? "Improve folds memory cleanup into the same proposal queue: speculative promotions still go through reflect/distill proposals, while high-confidence redundant derived memories are moved into a recoverable cleanup archive instead of being left active in the stash."
469
+ : undefined,
763
470
  };
764
471
  }
765
- /** The P2 lock-skipped envelope (graceful `skipIfLocked` early return). Exported for unit tests. */
472
+ /** The envelope for a run skipped because another run holds the lock. */
766
473
  export function buildLockSkippedResult(strategyName, scope, runId) {
767
474
  return {
768
475
  schemaVersion: 2,
@@ -777,14 +484,13 @@ export function buildLockSkippedResult(strategyName, scope, runId) {
777
484
  ...(runId !== undefined ? { runId } : {}),
778
485
  };
779
486
  }
780
- /** Evaluate the real selectors on read-only state, stopping before every writer boundary. */
781
- async function runDryPlanningStage(args) {
782
- const { run, collected, resolvedStateDbPath, budgetMs, initialCleanupWarnings, signal } = args;
487
+ /** A dry run: the real selectors on a read-only state.db snapshot, stopping before every writer. */
488
+ async function runDryPlanningStage(run, collected, initialCleanupWarnings) {
783
489
  let stateDb;
784
490
  let stateSnapshotUnavailable = false;
785
491
  try {
786
492
  try {
787
- stateDb = openSqliteReadSnapshot(resolvedStateDbPath);
493
+ stateDb = openSqliteReadSnapshot(run.resolvedStateDbPath);
788
494
  stateSnapshotUnavailable = !stateDb;
789
495
  }
790
496
  catch (error) {
@@ -792,27 +498,13 @@ async function runDryPlanningStage(args) {
792
498
  throw error;
793
499
  stateSnapshotUnavailable = true;
794
500
  }
795
- const eventsCtx = {
796
- ...(stateDb ? { db: stateDb } : { dbPath: resolvedStateDbPath }),
797
- readOnly: true,
798
- ...(stateSnapshotUnavailable ? { readOnlySnapshotUnavailable: true } : {}),
799
- };
800
501
  const preparation = await run.runImprovePreparationStageImpl({
801
- scope: run.scope,
802
- options: run.options,
803
- plannedRefs: collected.plannedRefs,
804
- memoryCleanupPlan: collected.memoryCleanupPlan,
805
- primaryStashDir: run.primaryStashDir,
806
- memorySummary: collected.memorySummary,
807
- reindexFn: run.reindexFn,
808
- startMs: run.startMs,
809
- budgetMs,
810
- eventsCtx,
811
- initialCleanupWarnings,
812
- improveProfile: run.improveProfile,
813
- resolvedPlan: run.resolvedPlan,
814
- strategyName: run.selectedStrategy.name,
815
- budgetSignal: signal,
502
+ ...preparationArgs(run, collected, initialCleanupWarnings),
503
+ eventsCtx: {
504
+ ...(stateDb ? { db: stateDb } : { dbPath: run.resolvedStateDbPath }),
505
+ readOnly: true,
506
+ ...(stateSnapshotUnavailable ? { readOnlySnapshotUnavailable: true } : {}),
507
+ },
816
508
  planOnly: true,
817
509
  });
818
510
  return buildDryRunResult(run, collected, preparation);
@@ -821,114 +513,101 @@ async function runDryPlanningStage(args) {
821
513
  stateDb?.close();
822
514
  }
823
515
  }
824
- /** The P3 dry-run envelope (plan-only early return). Exported for unit tests. */
516
+ function preparationArgs(run, collected, initialCleanupWarnings) {
517
+ return {
518
+ scope: run.scope,
519
+ options: run.options,
520
+ plannedRefs: collected.plannedRefs,
521
+ memoryCleanupPlan: collected.memoryCleanupPlan,
522
+ primaryStashDir: run.primaryStashDir,
523
+ memorySummary: collected.memorySummary,
524
+ reindexFn: run.reindexFn,
525
+ startMs: run.startMs,
526
+ budgetMs: run.budgetMs,
527
+ initialCleanupWarnings,
528
+ improveProfile: run.improveProfile,
529
+ resolvedPlan: run.resolvedPlan,
530
+ strategyName: run.selectedStrategy.name,
531
+ budgetSignal: run.budgetAbortController.signal,
532
+ };
533
+ }
534
+ /** The plan-only envelope of a dry run. */
825
535
  export function buildDryRunResult(run, collected, preparation) {
826
- const { selectedStrategy, scope } = run;
827
536
  const { guidance, memorySummary, memoryCleanupPlan, plannedRefs, strategyFilteredRefs } = collected;
828
- const effectiveRefs = preparation?.loopRefs ?? plannedRefs;
829
- const notices = collectImproveNotices({ resolvedPlan: run.resolvedPlan });
537
+ const notices = collectImproveNotices(run.resolvedPlan, []);
830
538
  return {
831
539
  schemaVersion: 2,
832
540
  ok: true,
833
- strategy: selectedStrategy.name,
834
- scope,
541
+ strategy: run.selectedStrategy.name,
542
+ scope: run.scope,
835
543
  dryRun: true,
836
- ...(notices.length > 0 ? { notices } : {}),
544
+ ...notices.fields(),
837
545
  ...(guidance ? { guidance } : {}),
838
546
  memorySummary,
839
547
  ...(memoryCleanupPlan ? { memoryCleanup: shapeMemoryCleanup(memoryCleanupPlan) } : {}),
840
- plannedRefs: effectiveRefs,
841
- ...(preparation?.planning
842
- ? {
843
- plan: buildResultExecutionPlan(run, preparation, plannedRefs, strategyFilteredRefs, collected.indexSnapshot, true),
844
- }
845
- : {}),
548
+ plannedRefs: preparation?.loopRefs ?? plannedRefs,
549
+ ...(preparation?.planning ? { plan: buildResultExecutionPlan(run, preparation, collected, true) } : {}),
846
550
  ...(strategyFilteredRefs.length > 0 ? { strategyFilteredRefs } : {}),
847
551
  ...(run.resolvedPlan.engineUnavailable.length > 0 ? { skippedProcesses: run.resolvedPlan.engineUnavailable } : {}),
848
552
  ...(run.options.engineProbe !== undefined ? { engineProbe: run.options.engineProbe } : {}),
849
553
  ...(preparation?.proactiveMaintenance ? { proactiveMaintenance: preparation.proactiveMaintenance } : {}),
850
554
  };
851
555
  }
852
- /** One public plan projection for the dry and live result assemblers. */
853
- function buildResultExecutionPlan(run, preparation, rawProfileEligibleRefs, strategyFilteredRefs, indexSnapshot, dryRun) {
854
- const { improveProfile, configuredImproveProfile, resolvedPlan, configuredLimits, effectiveLimit, scope } = run;
855
- const triageConfig = improveProfile.processes?.triage;
556
+ /** The public execution-plan projection shared by the dry and live results. */
557
+ function buildResultExecutionPlan(run, preparation, collected, dryRun) {
558
+ const { improveProfile, configuredImproveProfile, resolvedPlan, scope } = run;
559
+ const { strategyFilteredRefs } = collected;
856
560
  const configuredTriage = configuredImproveProfile.processes?.triage;
857
- const configuredProactive = configuredImproveProfile.processes?.proactiveMaintenance;
858
- const configuredConsolidation = configuredImproveProfile.processes?.consolidate;
859
- const triageEnabled = scope.mode !== "ref" && resolvedPlan.processes.triage.enabled;
860
- const distillOnlySet = new Set(preparation.distillOnlyRefs.map((entry) => entry.ref));
861
561
  const inferenceMinPending = improveProfile.processes?.memoryInference?.minPendingCount;
862
562
  const pendingMemories = run.primaryStashDir && inferenceMinPending !== undefined && inferenceMinPending > 0
863
563
  ? collectPendingMemories(run.primaryStashDir).length
864
564
  : undefined;
865
- const memoryInferenceEnabled = resolvedPlan.processes.memoryInference.enabled &&
866
- !(pendingMemories !== undefined && inferenceMinPending !== undefined && pendingMemories < inferenceMinPending);
565
+ const belowMinPending = pendingMemories !== undefined && inferenceMinPending !== undefined && pendingMemories < inferenceMinPending;
566
+ const memoryInferenceEnabled = resolvedPlan.processes.memoryInference.enabled && !belowMinPending;
867
567
  const graphExtractionEnabled = resolvedPlan.processes.graphExtraction.enabled && run.primaryStashDir !== undefined;
868
- const profileGate = {
869
- name: "profile",
870
- removed: strategyFilteredRefs.length,
871
- reason: "all enabled per-ref processes refuse the asset type",
872
- };
873
- // #947 — per-process resolved engine/model/notices, plus how many of this
874
- // run's effective refs each ref-scoped process (reflect/distill/consolidate)
875
- // would act on. Counts only (not a per-ref matrix) to bound result_json size.
876
- const REF_SCOPED_PROCESSES = new Set(["reflect", "distill", "consolidate"]);
568
+ // Per-process routing, plus how many effective refs each ref-scoped process would act on (#947).
877
569
  const processes = projectResolvedProcessRouting(resolvedPlan).map((row) => {
878
- if (!REF_SCOPED_PROCESSES.has(row.process))
879
- return row;
880
- const eligibleRefs = preparation.loopRefs.filter((entry) => !shouldSkipRef(entry.ref, row.process, resolvedPlan.strategy.config)
881
- .skip).length;
882
- return { ...row, eligibleRefs };
570
+ const eligibleRefs = eligibleRefCount(preparation.loopRefs, row.process, resolvedPlan.strategy.config);
571
+ return eligibleRefs === undefined ? row : { ...row, eligibleRefs };
883
572
  });
884
573
  const proactive = preparation.planning.proactive
885
574
  ? {
886
575
  ...preparation.planning.proactive,
887
- configured: {
888
- ...(configuredProactive?.dueDays !== undefined ? { dueDays: configuredProactive.dueDays } : {}),
889
- ...(configuredProactive?.maxPerRun !== undefined ? { maxPerRun: configuredProactive.maxPerRun } : {}),
890
- ...(configuredProactive?.limit !== undefined ? { limit: configuredProactive.limit } : {}),
891
- },
576
+ configured: pickDefined(configuredImproveProfile.processes?.proactiveMaintenance, [
577
+ "dueDays",
578
+ "maxPerRun",
579
+ "limit",
580
+ ]),
892
581
  }
893
582
  : undefined;
894
- const consolidation = {
895
- ...preparation.planning.consolidation,
896
- configured: {
897
- ...(configuredConsolidation?.enabled !== undefined ? { enabled: configuredConsolidation.enabled } : {}),
898
- ...(configuredConsolidation?.minPoolSize !== undefined
899
- ? { minPoolSize: configuredConsolidation.minPoolSize }
900
- : {}),
901
- ...(configuredConsolidation?.limit !== undefined ? { limit: configuredConsolidation.limit } : {}),
902
- ...(configuredConsolidation?.maxChunkSize !== undefined
903
- ? { maxChunkSize: configuredConsolidation.maxChunkSize }
904
- : {}),
905
- ...(configuredConsolidation?.incrementalSince !== undefined
906
- ? { incrementalSince: configuredConsolidation.incrementalSince }
907
- : {}),
908
- },
909
- };
910
583
  return buildImproveExecutionPlan({
911
584
  dryRun,
912
- snapshot: indexSnapshot ?? {
585
+ snapshot: collected.indexSnapshot ?? {
913
586
  status: "unknown",
914
587
  reason: "the injected selector did not report an index snapshot status",
915
588
  },
916
- rawInScope: rawProfileEligibleRefs.length + strategyFilteredRefs.length,
589
+ rawInScope: collected.plannedRefs.length + strategyFilteredRefs.length,
917
590
  selectedRefs: preparation.actionableRefs,
918
591
  effectiveRefs: preparation.loopRefs,
919
- distillOnlyRefs: distillOnlySet,
920
- configuredLimits,
921
- effectiveLimit,
922
- replayBudget: preparation.planning.replayBudget,
923
- gates: [profileGate, ...preparation.planning.gates],
592
+ distillOnlyRefs: new Set(preparation.distillOnlyRefs.map((entry) => entry.ref)),
593
+ configuredLimits: run.configuredLimits,
594
+ effectiveLimit: run.effectiveLimit,
595
+ gates: [
596
+ {
597
+ name: "profile",
598
+ removed: strategyFilteredRefs.length,
599
+ reason: "all enabled per-ref processes refuse the asset type",
600
+ },
601
+ ...preparation.planning.gates,
602
+ ],
924
603
  processes,
925
604
  ...(proactive ? { proactive } : {}),
926
- consolidation,
605
+ consolidation: {
606
+ ...preparation.planning.consolidation,
607
+ configured: pickDefined(configuredImproveProfile.processes?.consolidate, CONSOLIDATION_CONFIG_KEYS),
608
+ },
927
609
  stageConfig: {
928
- extract: {
929
- enabled: preparation.planning.extract.wouldRun,
930
- reason: preparation.planning.extract.reason,
931
- },
610
+ extract: { enabled: preparation.planning.extract.wouldRun, reason: preparation.planning.extract.reason },
932
611
  graphExtraction: {
933
612
  enabled: graphExtractionEnabled,
934
613
  reason: !resolvedPlan.processes.graphExtraction.enabled
@@ -949,78 +628,51 @@ function buildResultExecutionPlan(run, preparation, rawProfileEligibleRefs, stra
949
628
  },
950
629
  },
951
630
  triage: {
952
- enabled: triageEnabled,
631
+ enabled: scope.mode !== "ref" && resolvedPlan.processes.triage.enabled,
953
632
  configuredMode: configuredTriage?.applyMode ?? "queue",
954
- mode: triageConfig?.applyMode ?? "queue",
633
+ mode: improveProfile.processes?.triage?.applyMode ?? "queue",
955
634
  maxAcceptsPerRun: configuredTriage?.maxAcceptsPerRun ?? 25,
956
- ...(configuredTriage?.maxDiffLines !== undefined ? { maxDiffLines: configuredTriage.maxDiffLines } : {}),
957
635
  },
958
636
  });
959
637
  }
960
- /** The triage drain pre-pass (non-fatal; single-ref scope never drains). */
638
+ /** Drain the proposal backlog before generating more (non-fatal; a single-ref scope never drains). */
961
639
  async function runTriagePrePass(run) {
962
- const { primaryStashDir, resolvedPlan, scope, options, improveProfile, drainProposalsFn } = run;
963
- let triageDrain;
964
- if (primaryStashDir && resolvedPlan.processes.triage.enabled) {
965
- if (scope.mode === "ref") {
966
- warn("[improve] triage pre-pass skipped (single-ref scope never drains the whole queue)");
967
- }
968
- else {
969
- try {
970
- const triageConfig = improveProfile.processes?.triage;
971
- const policy = resolveDrainPolicy(triageConfig?.policy);
972
- const applyMode = triageConfig?.applyMode ?? "queue";
973
- const maxAccepts = triageConfig?.maxAcceptsPerRun ?? 25;
974
- triageDrain = await withLlmStage("triage", () => drainProposalsFn({
975
- stashDir: primaryStashDir,
976
- ...(options.target ? { target: options.target } : {}),
977
- config: options.config,
978
- policy,
979
- applyMode,
980
- maxAccepts,
981
- dryRun: false,
982
- excludeIds: new Set(),
983
- ...(triageConfig?.maxDiffLines !== undefined ? { maxDiffLines: triageConfig.maxDiffLines } : {}),
984
- judgment: resolvedPlan.triageJudgment,
985
- }), { engine: resolvedPlan.triageJudgment?.engine, process: "triage.judgment" });
986
- }
987
- catch (err) {
988
- warn(`[improve] triage pre-pass failed (non-fatal): ${errMessage(err)}`);
989
- }
990
- }
640
+ const { primaryStashDir, resolvedPlan, scope, options, improveProfile } = run;
641
+ if (!primaryStashDir || !resolvedPlan.processes.triage.enabled)
642
+ return undefined;
643
+ if (scope.mode === "ref") {
644
+ warn("[improve] triage pre-pass skipped (single-ref scope never drains the whole queue)");
645
+ return undefined;
646
+ }
647
+ try {
648
+ const triageConfig = improveProfile.processes?.triage;
649
+ return await withLlmStage("triage", () => run.drainProposalsFn({
650
+ stashDir: primaryStashDir,
651
+ ...(options.target ? { target: options.target } : {}),
652
+ config: options.config,
653
+ applyMode: triageConfig?.applyMode ?? "queue",
654
+ maxAccepts: triageConfig?.maxAcceptsPerRun ?? 25,
655
+ dryRun: false,
656
+ excludeIds: new Set(),
657
+ judgment: resolvedPlan.triageJudgment,
658
+ }), { engine: resolvedPlan.triageJudgment?.engine, process: "triage.judgment" });
659
+ }
660
+ catch (err) {
661
+ warn(`[improve] triage pre-pass failed (non-fatal): ${errMessage(err)}`);
662
+ return undefined;
991
663
  }
992
- return triageDrain;
993
- }
994
- /** A path is never staged by auto-sync when it is (or looks like) a lock file. */
995
- function isSyncExcludedPath(relativePath) {
996
- return path.basename(relativePath).includes(".lock");
997
664
  }
998
665
  /**
999
- * Resolve the EXACT repo-relative path set the auto-sync commit stages (#652).
1000
- *
1001
- * Precedence:
1002
- *
1003
- * 1. **Run-scoped write provenance** (`writtenPaths`, absolute) — the paths
1004
- * this run actually wrote, created, or removed, intersected with the paths
1005
- * Git currently reports as changed. The intersection is what makes the set
1006
- * correct rather than merely plausible: it drops a journaled path whose
1007
- * final bytes match HEAD (nothing to commit), a journaled path that was
1008
- * created and then removed again (never existed for Git), and any journaled
1009
- * path Git ignores (which `saveGitStash` would otherwise reject outright).
1010
- * A journaled path that was ALREADY dirty when the run started stays IN —
1011
- * this run rewrote it, so this run owns it.
1012
- * 2. **Dirty-path diff** (pre-#652 behaviour) — only when no journal was open,
1013
- * which a live run never hits. Kept as the defensive fallback so a future
1014
- * caller that commits outside the journal window degrades to the previous,
1015
- * battle-tested heuristic instead of silently committing nothing.
1016
- *
1017
- * `unattributed` counts in-scope paths that became dirty DURING the run without
1018
- * this run writing them: concurrent human edits (correctly excluded) and, if the
1019
- * number is ever surprising, the fingerprint of a missing journal call site.
666
+ * The exact repo-relative paths the auto-sync commit stages (#652): the paths
667
+ * this run wrote that Git reports changed (a journaled path already dirty at
668
+ * start stays in — this run rewrote it). Without a journal, the pre-#652
669
+ * dirty-path diff. `unattributed` counts in-scope paths that went dirty during
670
+ * the run without this run writing them (concurrent edits, left alone). Lock
671
+ * files are never staged.
1020
672
  */
1021
673
  export function resolveSyncPathSet(input) {
1022
674
  const { repoDir, assetPrefix, changedPaths, initialPaths, writtenPaths, provenance } = input;
1023
- const inScope = (relativePath) => !isSyncExcludedPath(relativePath) &&
675
+ const inScope = (relativePath) => !path.basename(relativePath).includes(".lock") &&
1024
676
  (!assetPrefix || relativePath === assetPrefix || relativePath.startsWith(`${assetPrefix}/`));
1025
677
  if (!provenance) {
1026
678
  return { paths: changedPaths.filter((p) => !initialPaths.has(p) && inScope(p)), unattributed: [] };
@@ -1029,9 +681,8 @@ export function resolveSyncPathSet(input) {
1029
681
  const attributed = new Set();
1030
682
  for (const absolutePath of writtenPaths) {
1031
683
  const relativePath = relativeWrittenPath(repoDir, absolutePath);
1032
- if (!relativePath || !changed.has(relativePath) || !inScope(relativePath))
1033
- continue;
1034
- attributed.add(relativePath);
684
+ if (relativePath && changed.has(relativePath) && inScope(relativePath))
685
+ attributed.add(relativePath);
1035
686
  }
1036
687
  return {
1037
688
  paths: [...attributed].sort(),
@@ -1039,60 +690,46 @@ export function resolveSyncPathSet(input) {
1039
690
  };
1040
691
  }
1041
692
  /**
1042
- * Crash-safe / incremental stash sync (#662) — see the factory-returned
1043
- * closure's original doc block: the primary stash writes as a filesystem
1044
- * source DURING the run; this commits them at end-of-run AND from the catch
1045
- * path. Idempotent + NON-FATAL. `getEventsCtx`/`getInitialGitPaths`/
1046
- * `getWriteJournal` are getters because those outer bindings are (re)assigned
1047
- * after this factory runs — the returned closure must observe the live values.
693
+ * The auto-sync commit (#662), used at end of run and from the crash path:
694
+ * idempotent and never throws. The getters read bindings reassigned after this
695
+ * factory runs.
1048
696
  */
1049
697
  function makeCommitStashBatch(deps) {
1050
- const { writeTarget, primaryStashDir, effectiveSync, options, _earlyConfig, improveProfile } = deps.run;
698
+ const { writeTarget, primaryStashDir, effectiveSync, options, config, improveProfile } = deps.run;
1051
699
  return (messageContext) => {
1052
700
  const eventsCtx = deps.getEventsCtx();
1053
- const initialGitPaths = deps.getInitialGitPaths();
1054
701
  const writeJournal = deps.getWriteJournal();
1055
702
  const repoDir = writeTarget?.source.repoPath ?? primaryStashDir;
1056
703
  if (!primaryStashDir || !repoDir || effectiveSync.enabled === false || !isGitBackedStash(repoDir)) {
1057
704
  return undefined;
1058
705
  }
1059
706
  const saveGitStashFn = options.saveGitStashFn ?? saveGitStash;
1060
- const writableOverride = writeTarget ? resolveWritable(writeTarget.config) : resolveWritableOverride(_earlyConfig);
707
+ const writableOverride = writeTarget ? resolveWritable(writeTarget.config) : resolveWritableOverride(config);
1061
708
  const push = options.sync?.push ?? improveProfile.sync?.push ?? true;
1062
709
  const message = renderSyncCommitMessage(effectiveSync.message ?? "akm improve auto-sync", messageContext, Date.now());
710
+ const record = (metadata) => appendEvent({ eventType: "stash_synced", metadata }, eventsCtx);
1063
711
  try {
1064
712
  const assetRoot = writeTarget?.source.path ?? primaryStashDir;
1065
- const assetPrefix = assetRoot ? path.relative(repoDir, assetRoot).replaceAll(path.sep, "/") : "";
1066
713
  const { paths, unattributed } = resolveSyncPathSet({
1067
714
  repoDir,
1068
- assetPrefix,
715
+ assetPrefix: path.relative(repoDir, assetRoot).replaceAll(path.sep, "/"),
1069
716
  changedPaths: listGitChangedPaths(repoDir),
1070
- initialPaths: initialGitPaths,
717
+ initialPaths: deps.getInitialGitPaths(),
1071
718
  writtenPaths: writeJournal?.writtenPaths() ?? [],
1072
719
  provenance: writeJournal !== undefined,
1073
720
  });
1074
721
  if (unattributed.length > 0) {
1075
722
  warnVerbose(`[improve] auto-sync left ${unattributed.length} path(s) uncommitted — not written by this run: ${unattributed.join(", ")}`);
1076
723
  }
1077
- const syncResult = saveGitStashFn(undefined, message, writableOverride, {
1078
- push,
1079
- repoDir,
1080
- paths,
724
+ const syncResult = saveGitStashFn(undefined, message, writableOverride, { push, repoDir, paths });
725
+ record({
726
+ committed: syncResult.committed,
727
+ pushed: syncResult.pushed,
728
+ skipped: syncResult.skipped,
729
+ reason: syncResult.reason ?? null,
730
+ attributed: paths.length,
731
+ unattributed: unattributed.length,
1081
732
  });
1082
- appendEvent({
1083
- eventType: "stash_synced",
1084
- metadata: {
1085
- committed: syncResult.committed,
1086
- pushed: syncResult.pushed,
1087
- skipped: syncResult.skipped,
1088
- reason: syncResult.reason ?? null,
1089
- // #652 provenance audit trail: how many paths this run staged, and
1090
- // how many in-scope paths went dirty during the run that it did NOT
1091
- // write (concurrent edits — deliberately left for their author).
1092
- attributed: paths.length,
1093
- unattributed: unattributed.length,
1094
- },
1095
- }, eventsCtx);
1096
733
  return {
1097
734
  committed: syncResult.committed,
1098
735
  pushed: syncResult.pushed,
@@ -1103,296 +740,131 @@ function makeCommitStashBatch(deps) {
1103
740
  catch (syncErr) {
1104
741
  const reason = errMessage(syncErr);
1105
742
  warn(`improve: stash sync failed (non-fatal): ${reason}`);
1106
- appendEvent({
1107
- eventType: "stash_synced",
1108
- metadata: { committed: false, pushed: false, skipped: true, reason },
1109
- }, eventsCtx);
743
+ record({ committed: false, pushed: false, skipped: true, reason });
1110
744
  return { committed: false, pushed: false, skipped: true, reason };
1111
745
  }
1112
746
  };
1113
747
  }
1114
- /** D6: pre-load the last 30 days of proposal_rejected events once per run. */
1115
- function preloadRejectedProposals() {
1116
- // D6: pre-load all proposal_rejected events from the last 30 days once,
1117
- // so the per-asset loop can use a Map lookup instead of N DB round trips.
1118
- const REJECTED_PROPOSAL_WINDOW_MS = daysToMs(30);
1119
- const rejectedProposalSince = new Date(Date.now() - REJECTED_PROPOSAL_WINDOW_MS).toISOString();
1120
- const allRejectedProposalEvents = readEvents({ type: "proposal_rejected", since: rejectedProposalSince }).events;
1121
- const rejectedProposalsByRef = new Map();
1122
- for (const e of allRejectedProposalEvents) {
1123
- if (e.ref && (!rejectedProposalsByRef.has(e.ref) || e.ts > (rejectedProposalsByRef.get(e.ref)?.ts ?? ""))) {
1124
- rejectedProposalsByRef.set(e.ref, e);
1125
- }
1126
- }
1127
- return rejectedProposalsByRef;
1128
- }
1129
748
  /**
1130
- * Post-lock proactive cooldown re-filter: re-read cooldown timestamps
1131
- * immediately before the loop so external proposal writes that occurred
1132
- * before this run acquired its lock are visible.
749
+ * Re-read the improve ledger under the lock and drop proactive refs another run
750
+ * attempted after this one planned.
1133
751
  */
1134
- export function refilterProactiveLoopRefs(loopRefs, improveProfile) {
1135
- // Re-read cooldown timestamps immediately before execution so external
1136
- // proposal writes that occurred before this run acquired its lock are visible.
752
+ export function refilterProactiveLoopRefs(loopRefs, improveProfile, ledgerAccess) {
1137
753
  const proactiveLoopRefs = loopRefs.filter((r) => r.eligibilitySource === "proactive");
1138
- let postLockLoopRefs = loopRefs;
1139
- if (proactiveLoopRefs.length > 0) {
1140
- const proactiveRefStrs = proactiveLoopRefs.map((r) => r.ref);
1141
- // Correlate proposal timestamps on each candidate's durable key.
1142
- const proactiveItemRefByRef = new Map(proactiveLoopRefs.map((r) => [r.ref, r.itemRef]));
1143
- const freshReflectTs = buildLatestProposalTsMap(proactiveRefStrs, "reflect", proactiveItemRefByRef);
1144
- const freshDistillTs = buildLatestProposalTsMap(proactiveRefStrs, "distill", proactiveItemRefByRef);
1145
- const pmDueDays = improveProfile.processes?.proactiveMaintenance?.dueDays ?? DEFAULT_DUE_DAYS;
1146
- const stillDue = new Set(filterProactiveDue(proactiveLoopRefs, freshReflectTs, freshDistillTs, pmDueDays, Date.now()).map((r) => r.ref));
1147
- const dropped = proactiveLoopRefs.filter((r) => !stillDue.has(r.ref));
1148
- if (dropped.length > 0) {
1149
- info(`[improve] post-lock cooldown re-filter: dropped ${dropped.length} proactive ref(s) claimed by concurrent run (${dropped.map((r) => r.ref).join(", ")})`);
1150
- postLockLoopRefs = loopRefs.filter((r) => r.eligibilitySource !== "proactive" || stillDue.has(r.ref));
1151
- }
1152
- }
1153
- return postLockLoopRefs;
754
+ if (proactiveLoopRefs.length === 0 || !ledgerAccess.stashDir)
755
+ return loopRefs;
756
+ const ledger = loadLedgerSnapshot({ eventsCtx: ledgerAccess.eventsCtx }, ledgerAccess.stashDir, [
757
+ "reflect",
758
+ "distill",
759
+ ]);
760
+ const stillDue = new Set(filterProactiveDue(proactiveLoopRefs, lastAttemptByRef(ledger, "reflect", proactiveLoopRefs), lastAttemptByRef(ledger, "distill", proactiveLoopRefs), improveProfile.processes?.proactiveMaintenance?.dueDays ?? DEFAULT_DUE_DAYS, Date.now()).map((r) => r.ref));
761
+ const dropped = proactiveLoopRefs.filter((r) => !stillDue.has(r.ref));
762
+ if (dropped.length === 0)
763
+ return loopRefs;
764
+ info(`[improve] post-lock cooldown re-filter: dropped ${dropped.length} proactive ref(s) claimed by concurrent run (${dropped.map((r) => r.ref).join(", ")})`);
765
+ return loopRefs.filter((r) => r.eligibilitySource !== "proactive" || stillDue.has(r.ref));
1154
766
  }
1155
767
  /**
1156
- * Post-loop stage, or a no-op skip when the shared wall-clock budget is
1157
- * already exhausted. The result still finalizes normally on skip, so
1158
- * scheduled budget exhaustion exits 0 (extracted verbatim from
1159
- * `runImproveStageSequence` — fn-size decomposition, no logic change).
768
+ * The audit events for refs and lanes this run will not touch, then
769
+ * preparation → loop → post-loop. No post-loop work starts past the budget; the
770
+ * result still finalizes, so budget exhaustion exits 0.
1160
771
  */
1161
- async function runPostLoopStageOrSkip(args) {
1162
- const { budgetAbortController, scope, options, primaryStashDir, preparation, memoryRefsForInference, eventsCtx, improveProfile, resolvedPlan, runImprovePostLoopStageImpl, } = args;
1163
- // Do not start new post-loop work after the shared wall-clock budget. The
1164
- // result still finalizes normally, so scheduled budget exhaustion exits 0.
1165
- const emptyPostLoopResult = {
1166
- allWarnings: [],
1167
- memoryInferenceDurationMs: 0,
1168
- graphExtractionDurationMs: 0,
1169
- };
1170
- const remainingBudget = budgetAbortController.signal.remainingBudgetMs;
1171
- if (budgetAbortController.signal.aborted || (remainingBudget !== undefined && remainingBudget <= 0)) {
1172
- info("[improve] post-loop maintenance skipped (wall-clock budget exhausted)");
1173
- return emptyPostLoopResult;
772
+ async function runImproveStageSequence(run, collected, preEnsureCleanupWarnings, eventsCtx) {
773
+ const { scope, options, primaryStashDir, improveProfile, resolvedPlan, budgetAbortController } = run;
774
+ const strategy = run.selectedStrategy.name;
775
+ // One count-only row for planner-filtered refs, never one per ref (#592).
776
+ if (collected.strategyFilteredRefs.length > 0) {
777
+ recordImproveSkip(eventsCtx, undefined, {
778
+ strategy,
779
+ reason: "strategy_filtered_all_passes",
780
+ count: collected.strategyFilteredRefs.length,
781
+ });
1174
782
  }
1175
- return runImprovePostLoopStageImpl({
1176
- scope,
1177
- options,
1178
- primaryStashDir,
1179
- actionableRefs: preparation.actionableRefs,
1180
- appliedCleanup: preparation.appliedCleanup,
1181
- cleanupWarnings: preparation.cleanupWarnings,
1182
- memoryRefsForInference,
783
+ // A gated lane names the config key that would enable it.
784
+ for (const lane of [...resolvedPlan.autonomyGated, ...describeGatedLanes(collected.autonomyGatedDirectLanes)]) {
785
+ warn(`[improve] ${lane.lane} skipped — it ${lane.reason}. Set \`${lane.configKey}: true\` to enable it.`);
786
+ recordImproveSkip(eventsCtx, undefined, {
787
+ strategy,
788
+ reason: "autonomy_gated",
789
+ lane: lane.lane,
790
+ configKey: lane.configKey,
791
+ });
792
+ }
793
+ for (const item of resolvedPlan.engineUnavailable) {
794
+ warn(`[improve] ${item.process} skipped — it ${item.reason}.`);
795
+ recordImproveSkip(eventsCtx, undefined, {
796
+ strategy,
797
+ reason: "engine_unavailable",
798
+ process: item.process,
799
+ configKey: item.configKey,
800
+ });
801
+ }
802
+ const preparation = await run.runImprovePreparationStageImpl({
803
+ ...preparationArgs(run, collected, preEnsureCleanupWarnings),
804
+ eventsCtx,
805
+ });
806
+ const loopResult = await run.runImproveLoopStageImpl({
1183
807
  eventsCtx,
1184
808
  budgetSignal: budgetAbortController.signal,
809
+ primaryStashDir,
810
+ scope,
811
+ options,
812
+ reflectFn: run.reflectFn,
813
+ distillFn: run.distillFn,
814
+ loopRefs: refilterProactiveLoopRefs(preparation.loopRefs, improveProfile, {
815
+ stashDir: primaryStashDir ?? options.stashDir,
816
+ eventsCtx,
817
+ }),
818
+ actions: preparation.actions,
819
+ signalBearingSet: preparation.signalBearingSet,
820
+ distillCooledRefs: preparation.distillCooledRefs,
821
+ distillOnlyRefs: preparation.distillOnlyRefs,
822
+ recentErrors: preparation.recentErrors,
823
+ startMs: run.startMs,
824
+ budgetMs: run.budgetMs,
1185
825
  improveProfile,
1186
826
  resolvedPlan,
1187
- consolidationRan: preparation.consolidationRan,
1188
- // R5: floor violations from this run's consolidate pass, for the collapse
1189
- // detector's merge-floor advisory.
1190
- consolidationMergeFloorViolations: preparation.consolidation.mergeFloorViolations ?? 0,
1191
827
  });
1192
- }
1193
- /**
1194
- * Stage-sequencing: the strategy-filtered audit event, then the single
1195
- * prep → loop → post-loop pass via the #616 seams (D12). Returns every
1196
- * accumulator the result assembly reads — the old closure-scoped `let`s.
1197
- */
1198
- async function runImproveStageSequence(args) {
1199
- const { strategyFilteredRefs, plannedRefs, memoryCleanupPlan, memorySummary, preEnsureCleanupWarnings, eventsCtx, ctx, } = args;
1200
- const { scope, options, primaryStashDir, startMs, budgetMs, improveProfile, resolvedPlan, budgetAbortController, selectedStrategy, reflectFn, distillFn, reindexFn, runImprovePreparationStageImpl, runImproveLoopStageImpl, runImprovePostLoopStageImpl, } = args.run;
1201
- // 2026-05-27: one summary `improve_skipped` audit event (count only) for
1202
- // planner-pre-filtered refs — never per-ref (#592: O(n) sequential state.db
1203
- // writes cost ~500 s on a 9 000-ref stash; health reads the counters).
1204
- if (strategyFilteredRefs.length > 0) {
1205
- appendEvent({
1206
- eventType: "improve_skipped",
1207
- ref: undefined,
1208
- metadata: {
1209
- strategy: selectedStrategy.name,
1210
- reason: "strategy_filtered_all_passes",
1211
- count: strategyFilteredRefs.length,
1212
- },
1213
- }, eventsCtx);
1214
- }
1215
- // D8 — one event per lane the autonomy gate downgraded, naming the lane AND
1216
- // the config key that would enable it. This is the difference between a gate
1217
- // and a silent no-op: whatever the user would have seen happen, they now see
1218
- // explained. `health` already aggregates `improve_skipped` by reason
1219
- // (buildImproveSkipSummary), so these surface there without new machinery.
1220
- for (const lane of [...args.run.resolvedPlan.autonomyGated, ...describeGatedLanes(args.autonomyGatedDirectLanes)]) {
1221
- warn(`[improve] ${lane.lane} skipped — it ${lane.reason}. Set \`${lane.configKey}: true\` to enable it.`);
1222
- appendEvent({
1223
- eventType: "improve_skipped",
1224
- ref: undefined,
1225
- metadata: {
1226
- strategy: selectedStrategy.name,
1227
- reason: "autonomy_gated",
1228
- lane: lane.lane,
1229
- configKey: lane.configKey,
1230
- },
1231
- }, eventsCtx);
1232
- }
1233
- for (const item of resolvedPlan.engineUnavailable) {
1234
- warn(`[improve] ${item.process} skipped — it ${item.reason}.`);
1235
- appendEvent({
1236
- eventType: "improve_skipped",
1237
- ref: undefined,
1238
- metadata: {
1239
- strategy: selectedStrategy.name,
1240
- reason: "engine_unavailable",
1241
- process: item.process,
1242
- configKey: item.configKey,
1243
- },
1244
- }, eventsCtx);
828
+ let postLoop = { allWarnings: [], memoryInferenceDurationMs: 0, graphExtractionDurationMs: 0 };
829
+ const remainingBudget = budgetAbortController.signal.remainingBudgetMs;
830
+ if (budgetAbortController.signal.aborted || (remainingBudget !== undefined && remainingBudget <= 0)) {
831
+ info("[improve] post-loop maintenance skipped (wall-clock budget exhausted)");
1245
832
  }
1246
- // Single prep->loop->post-loop pass, run under the invocation's lock.
1247
- // Accumulators are direct assignments from the single pass's results.
1248
- let preparation;
1249
- let memoryRefsForInference = new Set();
1250
- let consolidation;
1251
- let memoryInference;
1252
- let graphExtraction;
1253
- let cycleMetrics;
1254
- // Summed counters/durations.
1255
- let reflectsWithErrorContext = 0;
1256
- let memoryInferenceDurationMs = 0;
1257
- let graphExtractionDurationMs = 0;
1258
- let orphansPurged;
1259
- let proposalsExpired;
1260
- // Concatenated arrays.
1261
- const allWarnings = [];
1262
- let deadUrls;
1263
- let deadUrlCoverage;
1264
- const finalActions = [];
1265
- {
1266
- const runPreparation = () => runImprovePreparationStageImpl({
833
+ else {
834
+ postLoop = await run.runImprovePostLoopStageImpl({
1267
835
  scope,
1268
836
  options,
1269
- plannedRefs,
1270
- memoryCleanupPlan,
1271
837
  primaryStashDir,
1272
- memorySummary,
1273
- reindexFn,
1274
- startMs,
1275
- budgetMs,
838
+ actionableRefs: preparation.actionableRefs,
839
+ appliedCleanup: preparation.appliedCleanup,
840
+ cleanupWarnings: preparation.cleanupWarnings,
841
+ memoryRefsForInference: loopResult.memoryRefsForInference,
1276
842
  eventsCtx,
1277
- initialCleanupWarnings: preEnsureCleanupWarnings,
1278
- improveProfile,
1279
- resolvedPlan,
1280
- strategyName: selectedStrategy.name,
1281
843
  budgetSignal: budgetAbortController.signal,
1282
- });
1283
- preparation = await runPreparation();
1284
- const rejectedProposalsByRef = preloadRejectedProposals();
1285
- const runLoop = () => {
1286
- const postLockLoopRefs = refilterProactiveLoopRefs(preparation.loopRefs, improveProfile);
1287
- return runImproveLoopStageImpl({
1288
- ctx,
1289
- primaryStashDir,
1290
- scope,
1291
- options,
1292
- reflectFn,
1293
- distillFn,
1294
- loopRefs: postLockLoopRefs,
1295
- actions: preparation.actions,
1296
- signalBearingSet: preparation.signalBearingSet,
1297
- distillCooledRefs: preparation.distillCooledRefs,
1298
- distillOnlyRefs: preparation.distillOnlyRefs,
1299
- recentErrors: preparation.recentErrors,
1300
- rejectedProposalsByRef,
1301
- utilityMap: preparation.utilityMap,
1302
- startMs,
1303
- budgetMs,
1304
- improveProfile,
1305
- resolvedPlan,
1306
- });
1307
- };
1308
- const loopResult = await runLoop();
1309
- reflectsWithErrorContext += loopResult.reflectsWithErrorContext;
1310
- memoryRefsForInference = loopResult.memoryRefsForInference;
1311
- // #551: consolidation now runs in the preparation stage (before extract);
1312
- // its result and run-flag are read from `preparation`, not the post-loop.
1313
- consolidation = preparation.consolidation;
1314
- const postLoopResult = await runPostLoopStageOrSkip({
1315
- budgetAbortController,
1316
- scope,
1317
- options,
1318
- primaryStashDir,
1319
- preparation,
1320
- memoryRefsForInference,
1321
- eventsCtx,
1322
844
  improveProfile,
1323
845
  resolvedPlan,
1324
- runImprovePostLoopStageImpl,
1325
846
  });
1326
- // Result objects (single pass — no cycle accumulation).
1327
- memoryInference = postLoopResult.memoryInference;
1328
- graphExtraction = postLoopResult.graphExtraction;
1329
- if (postLoopResult.cycleMetrics)
1330
- cycleMetrics = postLoopResult.cycleMetrics;
1331
- // Summed counters/durations.
1332
- memoryInferenceDurationMs += postLoopResult.memoryInferenceDurationMs;
1333
- graphExtractionDurationMs += postLoopResult.graphExtractionDurationMs;
1334
- if (postLoopResult.orphansPurged !== undefined) {
1335
- orphansPurged = (orphansPurged ?? 0) + postLoopResult.orphansPurged;
1336
- }
1337
- if (postLoopResult.proposalsExpired !== undefined) {
1338
- proposalsExpired = (proposalsExpired ?? 0) + postLoopResult.proposalsExpired;
1339
- }
1340
- // Concatenated arrays.
1341
- allWarnings.push(...postLoopResult.allWarnings);
1342
- if (postLoopResult.deadUrls !== undefined) {
1343
- deadUrls = [...(deadUrls ?? []), ...postLoopResult.deadUrls];
1344
- }
1345
- if (postLoopResult.deadUrlCoverage !== undefined) {
1346
- deadUrlCoverage = postLoopResult.deadUrlCoverage;
1347
- }
1348
- const maintenanceActions = postLoopResult.maintenanceActions;
1349
- if (maintenanceActions && maintenanceActions.length > 0) {
1350
- finalActions.push(...preparation.actions, ...maintenanceActions);
1351
- }
1352
- else {
1353
- finalActions.push(...preparation.actions);
1354
- }
1355
847
  }
1356
848
  return {
1357
849
  preparation,
1358
- memoryRefsForInference,
1359
- consolidation,
1360
- memoryInference,
1361
- graphExtraction,
1362
- cycleMetrics,
1363
- reflectsWithErrorContext,
1364
- memoryInferenceDurationMs,
1365
- graphExtractionDurationMs,
1366
- orphansPurged,
1367
- proposalsExpired,
1368
- allWarnings,
1369
- deadUrls,
1370
- deadUrlCoverage,
1371
- finalActions,
850
+ postLoop,
851
+ reflectsWithErrorContext: loopResult.reflectsWithErrorContext,
852
+ finalActions: [...preparation.actions, ...(postLoop.maintenanceActions ?? [])],
1372
853
  };
1373
854
  }
1374
- /**
1375
- * Run-teardown, result half: assemble the AkmImproveResult envelope and emit
1376
- * the improve_completed event (same adjacency as the old inline code — no
1377
- * side effects run between assembly and emit).
1378
- */
855
+ /** Assemble the result envelope and emit `improve_completed`. */
1379
856
  function finalizeImproveResult(args) {
1380
- const { guidance, memorySummary, memoryCleanupPlan, strategyFilteredRefs, rawPlannedRefs, indexSnapshot, triageDrain, ensureIndexDurationMs, eventsCtx, } = args;
1381
- const { selectedStrategy, scope, options, startMs, resolvedPlan } = args.run;
1382
- const { preparation, consolidation, memoryInference, graphExtraction, cycleMetrics, reflectsWithErrorContext, memoryInferenceDurationMs, graphExtractionDurationMs, orphansPurged, proposalsExpired, allWarnings, deadUrls, deadUrlCoverage, finalActions, } = args.seq;
1383
- // C1 (13-bus-factor): fold the per-ref `distill-skipped` rows (~13k/run,
1384
- // ~91% of result_json bytes) into a bounded aggregate BEFORE persistence.
1385
- // The metric total + per-reason breakdown are preserved on `distillSkipped`;
1386
- // the unbounded row list never reaches result_json. Reflect skip counters
1387
- // below still read `finalActions` (reflect skips are not folded).
857
+ const { run, collected, triageDrain, ensureIndexDurationMs, eventsCtx } = args;
858
+ const { preparation, postLoop, reflectsWithErrorContext, finalActions } = args.seq;
859
+ const { options, startMs, resolvedPlan } = run;
860
+ const { memoryCleanupPlan, strategyFilteredRefs } = collected;
861
+ const consolidation = preparation.consolidation;
862
+ const { memoryInference, graphExtraction, allWarnings, deadUrls, deadUrlCoverage, orphansPurged, proposalsExpired } = postLoop;
863
+ const { memoryInferenceDurationMs, graphExtractionDurationMs } = postLoop;
864
+ // The per-ref distill-skipped rows fold into a bounded aggregate before persistence (C1).
1388
865
  const { actions: persistedActions, aggregate: distillSkippedAggregate } = foldDistillSkipped(finalActions);
1389
- // #944 — this run's LLM call/token accounting, split by process x engine x
1390
- // model, plus which enabled processes made zero calls and why. `llm_usage`
1391
- // events carry no runId column, so bound the read to this run's own wall
1392
- // clock — the same per-run event-scoping technique `health/windows.ts`
1393
- // already uses for wall time. `until` is "now" (assembly happens at
1394
- // teardown, after every LLM call this run will make has already emitted
1395
- // its event).
866
+ // This run's LLM accounting (#944): llm_usage rows carry no run id, so the
867
+ // read is bounded by the run's own wall clock.
1396
868
  const usageEvents = readEvents({ since: new Date(startMs).toISOString(), type: LLM_USAGE_EVENT }, eventsCtx).events;
1397
869
  const usageReport = buildImproveUsageReport({
1398
870
  resolvedPlan,
@@ -1402,37 +874,36 @@ function finalizeImproveResult(args) {
1402
874
  persistedActions,
1403
875
  distillSkippedAggregate,
1404
876
  });
1405
- const notices = collectImproveNotices({
1406
- resolvedPlan: args.run.resolvedPlan,
1407
- actions: finalActions,
1408
- schemaRepairs: preparation.schemaRepairs,
877
+ const notices = collectImproveNotices(resolvedPlan, [
878
+ ...finalActions.map((action) => action.result),
879
+ ...preparation.schemaRepairs,
1409
880
  consolidation,
1410
- extract: preparation.extract,
881
+ ...(preparation.extract ?? []).flatMap((extract) => [extract, ...(extract.sessions ?? [])]),
1411
882
  memoryInference,
1412
883
  graphExtraction,
1413
884
  triageDrain,
1414
- });
885
+ ]);
886
+ const applied = preparation.appliedCleanup;
887
+ const countMode = (mode) => finalActions.filter((a) => a.mode === mode).length;
1415
888
  const result = {
1416
889
  schemaVersion: 2,
1417
890
  ok: true,
1418
- strategy: selectedStrategy.name,
1419
- scope,
891
+ strategy: run.selectedStrategy.name,
892
+ scope: run.scope,
1420
893
  dryRun: false,
1421
- ...(notices.length > 0 ? { notices } : {}),
1422
- ...(guidance ? { guidance } : {}),
1423
- memorySummary,
894
+ ...notices.fields(),
895
+ ...(collected.guidance ? { guidance: collected.guidance } : {}),
896
+ memorySummary: collected.memorySummary,
1424
897
  ...(memoryCleanupPlan
1425
898
  ? {
1426
899
  memoryCleanup: {
1427
900
  ...shapeMemoryCleanup(memoryCleanupPlan),
1428
- ...(preparation.appliedCleanup
901
+ ...(applied
1429
902
  ? {
1430
- archived: preparation.appliedCleanup.archived,
1431
- ...(preparation.appliedCleanup.transitionLogPath
1432
- ? { transitionLogPath: preparation.appliedCleanup.transitionLogPath }
1433
- : {}),
1434
- ...(preparation.appliedCleanup.transitionLogEntries !== undefined
1435
- ? { transitionLogEntries: preparation.appliedCleanup.transitionLogEntries }
903
+ archived: applied.archived,
904
+ ...(applied.transitionLogPath ? { transitionLogPath: applied.transitionLogPath } : {}),
905
+ ...(applied.transitionLogEntries !== undefined
906
+ ? { transitionLogEntries: applied.transitionLogEntries }
1436
907
  : {}),
1437
908
  ...(allWarnings.length > 0 ? { warnings: allWarnings } : {}),
1438
909
  }
@@ -1443,11 +914,7 @@ function finalizeImproveResult(args) {
1443
914
  }
1444
915
  : {}),
1445
916
  plannedRefs: preparation.loopRefs,
1446
- ...(preparation.planning
1447
- ? {
1448
- plan: buildResultExecutionPlan(args.run, preparation, rawPlannedRefs, strategyFilteredRefs, indexSnapshot, false),
1449
- }
1450
- : {}),
917
+ ...(preparation.planning ? { plan: buildResultExecutionPlan(run, preparation, collected, false) } : {}),
1451
918
  ...(strategyFilteredRefs.length > 0 ? { strategyFilteredRefs } : {}),
1452
919
  ...(resolvedPlan.engineUnavailable.length > 0 ? { skippedProcesses: resolvedPlan.engineUnavailable } : {}),
1453
920
  ...(options.engineProbe !== undefined ? { engineProbe: options.engineProbe } : {}),
@@ -1462,37 +929,21 @@ function finalizeImproveResult(args) {
1462
929
  ...(preparation.coverageGaps.length > 0 ? { coverageGaps: preparation.coverageGaps } : {}),
1463
930
  ...(preparation.extract && preparation.extract.length > 0 ? { extract: preparation.extract } : {}),
1464
931
  ...(deadUrls !== undefined && deadUrls.length > 0 ? { deadUrls } : {}),
1465
- // Present whenever the check ran, unlike `deadUrls` above — a clean run
1466
- // (zero dead links) still needs to tell the health report how much of
1467
- // the bundle it actually covered (#892).
932
+ // Present whenever the check ran, so health knows the coverage of a clean run (#892).
1468
933
  ...(deadUrlCoverage !== undefined ? { deadUrlCoverage } : {}),
1469
934
  ...(reflectsWithErrorContext > 0 ? { reflectsWithErrorContext } : {}),
1470
935
  ...(memoryInference ? { memoryInference } : {}),
1471
936
  ...(graphExtraction ? { graphExtraction } : {}),
1472
- // Per-phase wall-clock durations. Surfaced at the top level of the
1473
- // envelope (not nested) because `health.ts`'s `wallTime.byPhase`
1474
- // aggregator and the existing `memoryInference.durationMs` /
1475
- // `graphExtraction.durationMs` health buckets all read
1476
- // `result.{memoryInferenceDurationMs,graphExtractionDurationMs}`
1477
- // directly. Mirrors how `consolidation.durationMs` is surfaced inside
1478
- // the consolidation sub-object (different convention because the
1479
- // consolidation result type already owns that field). Phases that did
1480
- // not run (zero duration) are omitted so the aggregator's
1481
- // "phase actually ran" filter (`> 0`) excludes them from the median/p95
1482
- // sample. Plumbed in d1273d0's follow-up — see
1483
- // `/tmp/akm-health-investigations/metrics-taxonomy-review.md` §1k / §3.
937
+ // Top-level phase durations feed health's wall-time buckets; a phase that
938
+ // did not run is omitted, not zero.
1484
939
  ...(memoryInferenceDurationMs > 0 ? { memoryInferenceDurationMs } : {}),
1485
940
  ...(graphExtractionDurationMs > 0 ? { graphExtractionDurationMs } : {}),
1486
- // R6: the start-of-run implicit reindex's wall-clock cost, when one ran
1487
- // (absent — not zero — when the index was already fresh and no inline
1488
- // rebuild was needed).
1489
941
  ...(ensureIndexDurationMs !== undefined ? { ensureIndexDurationMs } : {}),
1490
- ...(cycleMetrics ? { cycleMetrics } : {}),
1491
942
  ...(orphansPurged !== undefined ? { orphansPurged } : {}),
1492
943
  ...(proposalsExpired !== undefined && proposalsExpired > 0 ? { proposalsExpired } : {}),
1493
- reflectCooldownActions: finalActions.filter((a) => a.mode === "reflect-cooldown").length,
1494
- reflectSkippedActions: finalActions.filter((a) => a.mode === "reflect-skipped").length,
1495
- reflectGuardRejectedActions: finalActions.filter((a) => a.mode === "reflect-guard-rejected").length,
944
+ reflectCooldownActions: countMode("reflect-cooldown"),
945
+ reflectSkippedActions: countMode("reflect-skipped"),
946
+ reflectGuardRejectedActions: countMode("reflect-guard-rejected"),
1496
947
  ...(triageDrain
1497
948
  ? {
1498
949
  triage: {
@@ -1507,116 +958,60 @@ function finalizeImproveResult(args) {
1507
958
  ...(preparation.proactiveMaintenance ? { proactiveMaintenance: preparation.proactiveMaintenance } : {}),
1508
959
  ...(options.runId !== undefined ? { runId: options.runId } : {}),
1509
960
  };
1510
- if (!result.dryRun)
1511
- emitImproveCompletedEvent(result, {
1512
- memoryInferenceDurationMs,
1513
- graphExtractionDurationMs,
1514
- totalDurationMs: Date.now() - startMs,
1515
- warningCount: allWarnings.length,
1516
- orphansPurged: orphansPurged ?? 0,
1517
- }, eventsCtx);
961
+ emitImproveCompletedEvent(result, {
962
+ memoryInferenceDurationMs,
963
+ graphExtractionDurationMs,
964
+ totalDurationMs: Date.now() - startMs,
965
+ warningCount: allWarnings.length,
966
+ orphansPurged: orphansPurged ?? 0,
967
+ }, eventsCtx);
1518
968
  return result;
1519
969
  }
1520
- function collectImproveNotices(args) {
1521
- const byKey = new Map();
970
+ /** Every lowering notice the plan and this run's stage results carry, deduplicated. */
971
+ function collectImproveNotices(resolvedPlan, carriers) {
972
+ const notices = noticeSet();
1522
973
  const collect = (carrier) => {
1523
- if (typeof carrier !== "object" || carrier === null)
1524
- return;
1525
- const notices = carrier.notices;
1526
- if (!Array.isArray(notices))
1527
- return;
1528
- for (const notice of notices)
1529
- byKey.set(JSON.stringify(notice), notice);
974
+ const list = carrier?.notices;
975
+ if (typeof carrier === "object" && Array.isArray(list))
976
+ notices.add(list);
1530
977
  };
1531
- for (const process of Object.values(args.resolvedPlan.processes))
978
+ for (const process of Object.values(resolvedPlan.processes))
1532
979
  collect(process);
1533
- for (const notice of args.resolvedPlan.triageJudgmentNotices ?? []) {
1534
- byKey.set(JSON.stringify(notice), notice);
1535
- }
1536
- for (const action of args.actions ?? [])
1537
- collect(action.result);
1538
- for (const repair of args.schemaRepairs ?? [])
1539
- collect(repair);
1540
- collect(args.consolidation);
1541
- for (const extract of args.extract ?? []) {
1542
- collect(extract);
1543
- for (const session of extract.sessions ?? [])
1544
- collect(session);
1545
- }
1546
- collect(args.memoryInference);
1547
- collect(args.graphExtraction);
1548
- collect(args.triageDrain);
1549
- return Object.freeze([...byKey.values()]);
980
+ notices.add(resolvedPlan.triageJudgmentNotices ?? []);
981
+ for (const carrier of carriers)
982
+ collect(carrier);
983
+ return notices;
1550
984
  }
985
+ /** `improve_completed` per-mode counters; keyed by every mode so a new one cannot be dropped. */
986
+ const ACTION_COUNTER = {
987
+ reflect: "reflectActions",
988
+ distill: "distillActions",
989
+ "distill-skipped": "distillSkippedActions",
990
+ "memory-prune": "memoryPruneActions",
991
+ "memory-inference": "memoryInferenceActions",
992
+ "graph-extraction": "graphExtractionActions",
993
+ error: "errorActions",
994
+ "reflect-failed": "reflectFailedActions",
995
+ "reflect-cooldown": "reflectCooldownActions",
996
+ "reflect-skipped": "reflectSkippedActions",
997
+ "reflect-guard-rejected": "reflectGuardRejectedActions",
998
+ };
1551
999
  function emitImproveCompletedEvent(result, durations, eventsCtx) {
1552
- const actionCounts = {
1553
- reflect: 0,
1554
- reflectFailed: 0,
1555
- reflectCooldown: 0,
1556
- reflectSkipped: 0,
1557
- reflectGuardRejected: 0,
1558
- distill: 0,
1559
- distillSkipped: 0,
1560
- memoryPrune: 0,
1561
- memoryInference: 0,
1562
- graphExtraction: 0,
1563
- error: 0,
1564
- };
1565
- // Coarse audit buckets, derived from the SAME classifyImproveAction the
1566
- // persisted metrics_json uses (state-db.ts#computeImproveRunMetrics) so the
1567
- // emitted event and the stored row can never disagree.
1000
+ const counts = Object.fromEntries(Object.values(ACTION_COUNTER).map((key) => [key, 0]));
1001
+ // The coarse buckets come from the same classifier the persisted metrics use.
1568
1002
  const classCounts = { accepted: 0, rejected: 0, skipped: 0, error: 0, noop: 0 };
1569
1003
  for (const action of result.actions ?? []) {
1004
+ const key = ACTION_COUNTER[action.mode];
1005
+ if (key)
1006
+ counts[key] = (counts[key] ?? 0) + 1;
1570
1007
  classCounts[classifyImproveAction(action.mode)] += 1;
1571
- // Per-variant counters for the event metadata. The default arm makes any
1572
- // new ImproveActionMode variant a compile error so a future variant cannot
1573
- // be silently dropped from the improve_completed event (the `reflect-guard-
1574
- // rejected` case below was previously missing here entirely).
1575
- switch (action.mode) {
1576
- case "reflect":
1577
- actionCounts.reflect += 1;
1578
- break;
1579
- case "reflect-failed":
1580
- actionCounts.reflectFailed += 1;
1581
- break;
1582
- case "reflect-cooldown":
1583
- actionCounts.reflectCooldown += 1;
1584
- break;
1585
- case "reflect-skipped":
1586
- actionCounts.reflectSkipped += 1;
1587
- break;
1588
- case "reflect-guard-rejected":
1589
- actionCounts.reflectGuardRejected += 1;
1590
- break;
1591
- case "distill":
1592
- actionCounts.distill += 1;
1593
- break;
1594
- case "distill-skipped":
1595
- actionCounts.distillSkipped += 1;
1596
- break;
1597
- case "memory-prune":
1598
- actionCounts.memoryPrune += 1;
1599
- break;
1600
- case "memory-inference":
1601
- actionCounts.memoryInference += 1;
1602
- break;
1603
- case "graph-extraction":
1604
- actionCounts.graphExtraction += 1;
1605
- break;
1606
- case "error":
1607
- actionCounts.error += 1;
1608
- break;
1609
- default:
1610
- assertNever(action.mode);
1611
- }
1612
1008
  }
1613
- // C1: distill-skipped rows are no longer in `result.actions` (folded into the
1614
- // bounded `distillSkipped` aggregate at assembly). Add the aggregate total to
1615
- // the per-variant counter AND the coarse `skipped` bucket so the emitted event
1616
- // still reports the true skipped volume.
1009
+ // distill-skipped rows were folded into the aggregate; count them back in.
1617
1010
  const distillSkippedTotal = result.distillSkipped?.total ?? 0;
1618
- actionCounts.distillSkipped += distillSkippedTotal;
1011
+ counts.distillSkippedActions = (counts.distillSkippedActions ?? 0) + distillSkippedTotal;
1619
1012
  classCounts.skipped += distillSkippedTotal;
1013
+ const cleanup = result.memoryCleanup;
1014
+ const quality = result.graphExtraction?.quality;
1620
1015
  appendEvent({
1621
1016
  eventType: "improve_completed",
1622
1017
  ref: result.scope.mode === "ref"
@@ -1625,19 +1020,7 @@ function emitImproveCompletedEvent(result, durations, eventsCtx) {
1625
1020
  metadata: {
1626
1021
  strategy: result.strategy,
1627
1022
  plannedRefs: result.plannedRefs.length,
1628
- reflectActions: actionCounts.reflect,
1629
- distillActions: actionCounts.distill,
1630
- distillSkippedActions: actionCounts.distillSkipped,
1631
- memoryPruneActions: actionCounts.memoryPrune,
1632
- memoryInferenceActions: actionCounts.memoryInference,
1633
- graphExtractionActions: actionCounts.graphExtraction,
1634
- errorActions: actionCounts.error,
1635
- reflectFailedActions: actionCounts.reflectFailed,
1636
- reflectCooldownActions: actionCounts.reflectCooldown,
1637
- reflectSkippedActions: actionCounts.reflectSkipped,
1638
- // Previously dropped from the event entirely; now emitted so the guard
1639
- // rejections are visible in improve_completed telemetry.
1640
- reflectGuardRejectedActions: actionCounts.reflectGuardRejected,
1023
+ ...counts,
1641
1024
  acceptedActions: classCounts.accepted,
1642
1025
  rejectedActions: classCounts.rejected,
1643
1026
  skippedActions: classCounts.skipped,
@@ -1650,32 +1033,29 @@ function emitImproveCompletedEvent(result, durations, eventsCtx) {
1650
1033
  deadUrlsSkipped: result.deadUrlCoverage?.skipped ?? 0,
1651
1034
  memoryEligible: result.memorySummary.eligible,
1652
1035
  memoryDerived: result.memorySummary.derived,
1653
- memoryCleanupPruneCandidates: result.memoryCleanup?.pruneCandidates.length ?? 0,
1654
- memoryCleanupContradictionCandidates: result.memoryCleanup?.contradictionCandidates.length ?? 0,
1655
- memoryCleanupBeliefStateTransitions: result.memoryCleanup?.beliefStateTransitions.length ?? 0,
1656
- memoryCleanupConsolidationCandidates: result.memoryCleanup?.consolidationCandidates.length ?? 0,
1657
- memoryCleanupArchived: result.memoryCleanup?.archived?.length ?? 0,
1658
- memoryCleanupWarnings: result.memoryCleanup?.warnings?.length ?? 0,
1036
+ memoryCleanupPruneCandidates: cleanup?.pruneCandidates.length ?? 0,
1037
+ memoryCleanupContradictionCandidates: cleanup?.contradictionCandidates.length ?? 0,
1038
+ memoryCleanupBeliefStateTransitions: cleanup?.beliefStateTransitions.length ?? 0,
1039
+ memoryCleanupConsolidationCandidates: cleanup?.consolidationCandidates.length ?? 0,
1040
+ memoryCleanupArchived: cleanup?.archived?.length ?? 0,
1041
+ memoryCleanupWarnings: cleanup?.warnings?.length ?? 0,
1659
1042
  consolidationProcessed: result.consolidation?.processed ?? 0,
1660
1043
  consolidationDurationMs: result.consolidation?.durationMs ?? 0,
1661
1044
  memoryInferenceWrites: result.memoryInference?.writtenFacts ?? 0,
1662
1045
  memoryInferenceDurationMs: durations.memoryInferenceDurationMs,
1663
- graphExtractionExtractedFiles: result.graphExtraction?.quality.extractedFiles ?? 0,
1046
+ graphExtractionExtractedFiles: quality?.extractedFiles ?? 0,
1664
1047
  graphExtractionDurationMs: durations.graphExtractionDurationMs,
1665
- // Layer-2 proactive-maintenance coverage (0 when the process is disabled
1666
- // or the run was ref-scoped) so a scheduled sweep's reach is trackable.
1667
1048
  proactiveSelected: result.proactiveMaintenance?.selected ?? 0,
1668
1049
  proactiveDueTotal: result.proactiveMaintenance?.dueTotal ?? 0,
1669
1050
  proactiveNeverReflected: result.proactiveMaintenance?.neverReflected ?? 0,
1670
- // New metrics for tuning the improve loop.
1671
- ...(durations.totalDurationMs !== undefined ? { durationMs: durations.totalDurationMs } : {}),
1672
- ...(durations.warningCount !== undefined ? { warningCount: durations.warningCount } : {}),
1673
- ...(durations.orphansPurged !== undefined ? { orphansPurged: durations.orphansPurged } : {}),
1674
- ...(result.graphExtraction?.quality
1051
+ durationMs: durations.totalDurationMs,
1052
+ warningCount: durations.warningCount,
1053
+ orphansPurged: durations.orphansPurged,
1054
+ ...(quality
1675
1055
  ? {
1676
- graphCoverage: result.graphExtraction.quality.extractionCoverage,
1677
- graphDensity: result.graphExtraction.quality.density,
1678
- graphEntities: result.graphExtraction.quality.entityCount,
1056
+ graphCoverage: quality.extractionCoverage,
1057
+ graphDensity: quality.density,
1058
+ graphEntities: quality.entityCount,
1679
1059
  }
1680
1060
  : {}),
1681
1061
  },