akm-cli 0.9.1 → 0.9.2-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (341) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/README.md +3 -1
  3. package/SECURITY.md +1 -1
  4. package/STABILITY.md +1 -1
  5. package/dist/akm +2 -2
  6. package/dist/akm-migrate +2 -2
  7. package/dist/assets/hints/cli-hints-full.md +14 -9
  8. package/dist/assets/improve-strategies/proactive-maintenance.json +1 -1
  9. package/dist/assets/improve-strategies/reflect-distill.json +1 -1
  10. package/dist/assets/models.json +35 -0
  11. package/dist/assets/tasks/core/extract.yml +6 -5
  12. package/dist/assets/tasks/core/improve.yml +6 -5
  13. package/dist/assets/tasks/core/index-refresh.yml +6 -5
  14. package/dist/assets/tasks/core/sync.yml +6 -5
  15. package/dist/assets/tasks/core/version-check.yml +6 -5
  16. package/dist/assets/tasks/improve/akm-graph-refresh-weekly.yml +6 -5
  17. package/dist/assets/tasks/improve/akm-improve-catchup.yml +6 -5
  18. package/dist/assets/tasks/improve/akm-improve-consolidate.yml +6 -5
  19. package/dist/assets/tasks/improve/akm-improve-frequent.yml +6 -5
  20. package/dist/assets/tasks/improve/akm-improve-nightly.yml +6 -5
  21. package/dist/cli/confirm.js +2 -2
  22. package/dist/cli/parse-args.js +3 -24
  23. package/dist/cli/retired-commands.js +1 -1
  24. package/dist/cli/shared.js +2 -2
  25. package/dist/cli.js +11 -9
  26. package/dist/commands/agent/agent-dispatch.js +55 -89
  27. package/dist/commands/agent/contribute-cli.js +12 -45
  28. package/dist/commands/command/builtin-action.js +32 -0
  29. package/dist/commands/command/command-cli.js +99 -0
  30. package/dist/commands/command/command-execution.js +308 -0
  31. package/dist/commands/command/execution-source-loader.js +176 -0
  32. package/dist/commands/command/portable-template.js +60 -0
  33. package/dist/commands/config-cli.js +10 -4
  34. package/dist/commands/env/env.js +4 -2
  35. package/dist/commands/feedback-cli.js +1 -1
  36. package/dist/commands/health/checks.js +241 -29
  37. package/dist/commands/health/html-report.js +0 -14
  38. package/dist/commands/health/report-view-model.js +0 -1
  39. package/dist/commands/health/surfaces.js +6 -7
  40. package/dist/commands/health/types.js +0 -2
  41. package/dist/commands/health.js +63 -18
  42. package/dist/commands/improve/collapse-detector.js +2 -2
  43. package/dist/commands/improve/consolidate.js +251 -214
  44. package/dist/commands/improve/distill/promote-memory.js +71 -34
  45. package/dist/commands/improve/distill/quality-gate.js +17 -5
  46. package/dist/commands/improve/distill.js +232 -155
  47. package/dist/commands/improve/eligibility.js +112 -79
  48. package/dist/commands/improve/execution.js +57 -0
  49. package/dist/commands/improve/extract-cli.js +5 -5
  50. package/dist/commands/improve/extract.js +521 -342
  51. package/dist/commands/improve/improve-strategies.js +43 -14
  52. package/dist/commands/improve/improve.js +249 -29
  53. package/dist/commands/improve/loop-stages.js +11 -17
  54. package/dist/commands/improve/memory/memory-contradiction-detect.js +90 -66
  55. package/dist/commands/improve/outcome-loop.js +22 -38
  56. package/dist/commands/improve/planner.js +134 -0
  57. package/dist/commands/improve/preparation.js +730 -409
  58. package/dist/commands/improve/reflect.js +386 -223
  59. package/dist/commands/improve/run-context.js +3 -4
  60. package/dist/commands/improve/salience.js +6 -58
  61. package/dist/commands/improve/session-asset.js +12 -12
  62. package/dist/commands/lint/index.js +101 -29
  63. package/dist/commands/migrate-cli.js +11 -69
  64. package/dist/commands/migration-tool.js +6 -9
  65. package/dist/commands/models-cli.js +27 -0
  66. package/dist/commands/proposal/drain.js +258 -186
  67. package/dist/commands/proposal/proposal-cli.js +32 -10
  68. package/dist/commands/proposal/proposal.js +2 -5
  69. package/dist/commands/proposal/propose.js +192 -172
  70. package/dist/commands/proposal/repository.js +54 -91
  71. package/dist/commands/proposal/validators/proposal-validators.js +9 -7
  72. package/dist/commands/read/curate.js +10 -0
  73. package/dist/commands/read/registry-search.js +25 -9
  74. package/dist/commands/read/remember-cli.js +14 -2
  75. package/dist/commands/read/search.js +10 -4
  76. package/dist/commands/read/show.js +139 -153
  77. package/dist/commands/registry-cli.js +16 -7
  78. package/dist/commands/remember.js +33 -18
  79. package/dist/commands/sources/add-cli.js +19 -178
  80. package/dist/commands/sources/bundle-cli.js +15 -3
  81. package/dist/commands/sources/dangerous-env-audit.js +135 -0
  82. package/dist/commands/sources/info.js +2 -1
  83. package/dist/commands/sources/installed-stashes.js +835 -152
  84. package/dist/commands/sources/schema-repair.js +174 -95
  85. package/dist/commands/sources/self-update.js +30 -74
  86. package/dist/commands/sources/source-add.js +3 -5
  87. package/dist/commands/sources/sources-cli.js +2 -15
  88. package/dist/commands/sources/update-transaction.js +220 -0
  89. package/dist/commands/tasks/tasks-cli.js +3 -3
  90. package/dist/commands/tasks/tasks.js +736 -317
  91. package/dist/commands/workflow-cli.js +2 -2
  92. package/dist/core/adapter/adapters/agent-skills-adapter.js +3 -0
  93. package/dist/core/adapter/adapters/akm-adapter.js +83 -34
  94. package/dist/core/adapter/adapters/akm-lint.js +54 -39
  95. package/dist/core/adapter/adapters/akm-metadata.js +45 -45
  96. package/dist/core/adapter/adapters/akm-task-adapter.js +32 -49
  97. package/dist/core/adapter/adapters/akm-workflow-adapter.js +38 -23
  98. package/dist/core/adapter/adapters/dotenv-adapter.js +30 -1
  99. package/dist/core/adapter/adapters/generic-files-adapter.js +11 -0
  100. package/dist/core/adapter/adapters/index.js +0 -9
  101. package/dist/core/adapter/adapters/llm-wiki-adapter.js +4 -0
  102. package/dist/core/adapter/adapters/okf-adapter.js +4 -0
  103. package/dist/core/adapter/adapters/opencode-adapter.js +5 -8
  104. package/dist/core/adapter/adapters/tool-dir-shared.js +63 -6
  105. package/dist/core/adapter/adapters/website-snapshot-adapter.js +4 -0
  106. package/dist/core/adapter/execution-source.js +308 -0
  107. package/dist/core/adapter/recognize-match.js +36 -13
  108. package/dist/core/adapter/registry.js +0 -9
  109. package/dist/core/asset/stash-meta.js +94 -4
  110. package/dist/core/common.js +6 -11
  111. package/dist/core/config/config-io.js +3 -3
  112. package/dist/core/config/config-schema.js +18 -40
  113. package/dist/core/config/config-sources.js +11 -21
  114. package/dist/core/config/config-walker.js +31 -13
  115. package/dist/core/config/config.js +21 -20
  116. package/dist/core/config/schema/engines.js +8 -7
  117. package/dist/core/config/schema/improve-processes.js +29 -5
  118. package/dist/core/config/schema/primitives.js +1 -23
  119. package/dist/core/config/schema/sources-bundles.js +13 -16
  120. package/dist/core/errors.js +2 -0
  121. package/dist/core/events.js +68 -32
  122. package/dist/core/extra-params.js +1 -0
  123. package/dist/core/improve-result.js +315 -0
  124. package/dist/core/lesson-lint.js +0 -6
  125. package/dist/core/maintenance-barrier.js +4 -4
  126. package/dist/core/network-policy.js +152 -0
  127. package/dist/core/paths.js +1 -1
  128. package/dist/core/recognition-util.js +4 -4
  129. package/dist/core/registry-url.js +456 -0
  130. package/dist/core/state/migrations.js +161 -47
  131. package/dist/core/state-db.js +453 -80
  132. package/dist/core/system-error.js +32 -0
  133. package/dist/core/time.js +2 -12
  134. package/dist/core/write-source.js +0 -18
  135. package/dist/execution/directory-identity.js +52 -0
  136. package/dist/execution/executable-identity.js +107 -0
  137. package/dist/execution/guarded-source.js +398 -0
  138. package/dist/execution/json.js +95 -0
  139. package/dist/{commands/health/types-session-log.js → execution/limits.js} +2 -1
  140. package/dist/execution/record.js +55 -0
  141. package/dist/execution/resolved-request.js +730 -0
  142. package/dist/execution/source.js +320 -0
  143. package/dist/indexer/bundle-identity-guard.js +5 -4
  144. package/dist/indexer/db/graph-db.js +33 -0
  145. package/dist/indexer/graph/graph-boost.js +3 -4
  146. package/dist/indexer/graph/graph-extraction.js +562 -373
  147. package/dist/indexer/index-written-assets.js +65 -34
  148. package/dist/indexer/indexer.js +443 -222
  149. package/dist/indexer/installations.js +6 -0
  150. package/dist/indexer/lookup/adapter-concept-owner.js +283 -0
  151. package/dist/indexer/passes/memory-inference.js +227 -174
  152. package/dist/indexer/scan/doc-to-entry.js +7 -9
  153. package/dist/indexer/scan/drain-dir.js +51 -23
  154. package/dist/indexer/search/db-search.js +98 -22
  155. package/dist/indexer/search/search-fields.js +1 -1
  156. package/dist/indexer/search/search-hit-enrichers.js +1 -1
  157. package/dist/indexer/search/search-source.js +7 -16
  158. package/dist/indexer/search/semantic-status.js +10 -1
  159. package/dist/indexer/usage/show-usage.js +105 -0
  160. package/dist/indexer/usage/usage-events.js +7 -2
  161. package/dist/indexer/walk/matchers.js +40 -10
  162. package/dist/indexer/walk/path-resolver.js +5 -2
  163. package/dist/indexer/walk/walker.js +20 -2
  164. package/dist/integrations/agent/builder-shared.js +3 -6
  165. package/dist/integrations/agent/conversation-fallback.js +16 -0
  166. package/dist/integrations/agent/engine-resolution.js +87 -87
  167. package/dist/integrations/agent/execution-cascade.js +566 -0
  168. package/dist/integrations/agent/execution-definitions.js +211 -0
  169. package/dist/integrations/agent/execution-lowering.js +811 -0
  170. package/dist/integrations/agent/execution-preparation.js +67 -0
  171. package/dist/integrations/agent/index.js +0 -2
  172. package/dist/integrations/agent/inline-execution.js +74 -0
  173. package/dist/integrations/agent/model-map.js +515 -0
  174. package/dist/integrations/agent/persona-fallback.js +30 -0
  175. package/dist/integrations/agent/request-lowering.js +186 -0
  176. package/dist/integrations/agent/runner-dispatch.js +230 -37
  177. package/dist/integrations/agent/runner.js +12 -83
  178. package/dist/integrations/harnesses/aider/agent-builder.js +8 -0
  179. package/dist/integrations/harnesses/aider/index.js +0 -1
  180. package/dist/integrations/harnesses/amazonq/agent-builder.js +8 -0
  181. package/dist/integrations/harnesses/amazonq/index.js +0 -1
  182. package/dist/integrations/harnesses/claude/agent-builder.js +14 -1
  183. package/dist/integrations/harnesses/claude/index.js +1 -5
  184. package/dist/integrations/harnesses/claude/session-log.js +3 -33
  185. package/dist/integrations/harnesses/codex/agent-builder.js +8 -0
  186. package/dist/integrations/harnesses/codex/index.js +0 -1
  187. package/dist/integrations/harnesses/copilot/agent-builder.js +8 -0
  188. package/dist/integrations/harnesses/copilot/index.js +0 -1
  189. package/dist/integrations/harnesses/gemini/agent-builder.js +8 -0
  190. package/dist/integrations/harnesses/gemini/index.js +0 -1
  191. package/dist/integrations/harnesses/index.js +4 -44
  192. package/dist/integrations/harnesses/opencode/agent-builder.js +16 -9
  193. package/dist/integrations/harnesses/opencode/index.js +0 -2
  194. package/dist/integrations/harnesses/opencode/session-log.js +14 -204
  195. package/dist/integrations/harnesses/opencode-sdk/harness.js +12 -1
  196. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +40 -42
  197. package/dist/integrations/harnesses/openhands/agent-builder.js +8 -0
  198. package/dist/integrations/harnesses/openhands/index.js +0 -1
  199. package/dist/integrations/harnesses/pi/agent-builder.js +8 -0
  200. package/dist/integrations/harnesses/pi/index.js +0 -1
  201. package/dist/integrations/harnesses/shared.js +0 -1
  202. package/dist/integrations/harnesses/types.js +1 -3
  203. package/dist/integrations/lockfile.js +82 -79
  204. package/dist/integrations/session-logs/index.js +6 -17
  205. package/dist/integrations/session-logs/provider-base.js +1 -29
  206. package/dist/llm/client.js +10 -5
  207. package/dist/llm/embedder.js +6 -7
  208. package/dist/llm/embedders/local.js +37 -88
  209. package/dist/llm/embedders/types.js +1 -1
  210. package/dist/llm/graph-extract.js +75 -50
  211. package/dist/llm/index-passes.js +43 -5
  212. package/dist/llm/memory-infer.js +8 -6
  213. package/dist/llm/metadata-enhance.js +5 -3
  214. package/dist/llm/structured-call.js +122 -25
  215. package/dist/output/format-exempt.js +1 -1
  216. package/dist/output/render-registry.js +0 -16
  217. package/dist/output/renderers.js +12 -7
  218. package/dist/output/shapes/curate.js +1 -0
  219. package/dist/output/shapes/helpers.js +10 -2
  220. package/dist/output/shapes/passthrough.js +2 -0
  221. package/dist/output/text/command-format.js +28 -32
  222. package/dist/output/text/health-format.js +1 -29
  223. package/dist/output/text/migrate.js +6 -56
  224. package/dist/output/text/proposal-format.js +16 -1
  225. package/dist/output/text/workflow-format.js +16 -0
  226. package/dist/registry/network.js +279 -0
  227. package/dist/registry/pinned-request-helper.js +247 -0
  228. package/dist/registry/pinned-transport.js +717 -0
  229. package/dist/registry/providers/skills-sh.js +18 -6
  230. package/dist/registry/providers/static-index.js +20 -7
  231. package/dist/registry/resolve.js +53 -28
  232. package/dist/scripts/akm-migrate-node.js +19122 -52218
  233. package/dist/scripts/akm-migrate.js +19064 -51567
  234. package/dist/setup/registry-stash-loader.js +64 -20
  235. package/dist/setup/semantic-assets.js +9 -34
  236. package/dist/setup/setup.js +12 -30
  237. package/dist/setup/source-identity.js +17 -0
  238. package/dist/setup/steps/sources.js +36 -15
  239. package/dist/setup/steps/tasks.js +39 -11
  240. package/dist/sources/providers/git-provider.js +3 -3
  241. package/dist/sources/providers/npm.js +2 -2
  242. package/dist/sources/providers/provider-utils.js +4 -3
  243. package/dist/sources/providers/website.js +11 -7
  244. package/dist/sources/snapshot-fetchers/host-guard.js +9 -136
  245. package/dist/sources/snapshot-fetchers/website-ingest.js +25 -109
  246. package/dist/sources/website-url.js +73 -0
  247. package/dist/storage/engines/sqlite-migrations.js +81 -26
  248. package/dist/storage/managed-db.js +27 -24
  249. package/dist/storage/repositories/events-repository.js +3 -0
  250. package/dist/storage/repositories/index-connection.js +42 -10
  251. package/dist/storage/repositories/index-entries-repository.js +167 -207
  252. package/dist/storage/repositories/index-entry-mapper.js +8 -12
  253. package/dist/storage/repositories/index-entry-schema.js +255 -0
  254. package/dist/storage/repositories/index-fts-repository.js +10 -10
  255. package/dist/storage/repositories/index-llm-cache-repository.js +8 -13
  256. package/dist/storage/repositories/index-meta-repository.js +0 -11
  257. package/dist/storage/repositories/index-schema.js +71 -342
  258. package/dist/storage/repositories/index-utility-repository.js +12 -17
  259. package/dist/storage/repositories/index-vec-repository.js +1 -1
  260. package/dist/storage/repositories/proposals-repository.js +4 -127
  261. package/dist/storage/repositories/registry-cache.js +2 -1
  262. package/dist/storage/repositories/task-history-repository.js +20 -40
  263. package/dist/storage/repositories/workflow-runs-repository.js +228 -129
  264. package/dist/storage/sqlite-read-snapshot.js +148 -0
  265. package/dist/tasks/backends/cron.js +170 -42
  266. package/dist/tasks/backends/index.js +1 -1
  267. package/dist/tasks/backends/launchd.js +787 -202
  268. package/dist/tasks/backends/schtasks.js +282 -83
  269. package/dist/tasks/embedded.js +7 -7
  270. package/dist/tasks/frozen-script.js +50 -0
  271. package/dist/tasks/resolve-akm-bin.js +5 -1
  272. package/dist/tasks/runner.js +239 -251
  273. package/dist/tasks/runtime-v3.js +281 -0
  274. package/dist/tasks/scheduler-binding.js +272 -0
  275. package/dist/tasks/scheduler-invocation.js +57 -43
  276. package/dist/tasks/scheduler-sync.js +654 -0
  277. package/dist/tasks/source-v3.js +752 -0
  278. package/dist/tasks/standalone-script-entry.js +5 -0
  279. package/dist/tasks/task-id.js +29 -0
  280. package/dist/workflows/authoring/authoring.js +15 -32
  281. package/dist/workflows/exec/dispatch-redaction.js +14 -8
  282. package/dist/workflows/exec/exec-unit.js +7 -28
  283. package/dist/workflows/exec/frozen-judge.js +57 -89
  284. package/dist/workflows/exec/lowering-notices.js +23 -0
  285. package/dist/workflows/exec/native-executor.js +301 -458
  286. package/dist/workflows/exec/param-secrets.js +4 -3
  287. package/dist/workflows/exec/run-workflow.js +26 -32
  288. package/dist/workflows/exec/step-work.js +105 -109
  289. package/dist/workflows/exec/unit-dispatch.js +103 -27
  290. package/dist/workflows/exec/unit-writer.js +3 -3
  291. package/dist/workflows/exec/worktree.js +2 -2
  292. package/dist/workflows/ir/compile.js +86 -72
  293. package/dist/workflows/ir/environment-v4.js +328 -0
  294. package/dist/workflows/ir/freeze-v4.js +122 -0
  295. package/dist/workflows/ir/plan-hash.js +13 -7
  296. package/dist/workflows/ir/schema-v4.js +525 -0
  297. package/dist/workflows/ir/schema.js +25 -284
  298. package/dist/workflows/ir/source-freeze-v4.js +506 -0
  299. package/dist/workflows/parser.js +27 -24
  300. package/dist/workflows/program/schema.js +1 -2
  301. package/dist/workflows/renderer.js +42 -29
  302. package/dist/workflows/resource-limits.js +4 -5
  303. package/dist/workflows/runtime/agent-identity.js +11 -13
  304. package/dist/workflows/runtime/plan-classifier.js +8 -8
  305. package/dist/workflows/runtime/runs.js +27 -43
  306. package/dist/workflows/runtime/workflow-asset-loader.js +45 -205
  307. package/dist/workflows/source-files.js +373 -0
  308. package/dist/workflows/source-ir/compile.js +196 -0
  309. package/dist/workflows/source-ir/github-yaml.js +577 -0
  310. package/dist/workflows/source-ir/ordering.js +38 -0
  311. package/dist/workflows/source-ir/program.js +50 -0
  312. package/dist/workflows/source-ir/result.js +26 -0
  313. package/dist/workflows/source-ir/schema.js +772 -0
  314. package/dist/workflows/source-ir/semantics.js +242 -0
  315. package/dist/workflows/source-ir/uses.js +14 -0
  316. package/docs/README.md +2 -0
  317. package/docs/migration/README.md +3 -1
  318. package/docs/migration/release-notes/0.9.2.md +55 -0
  319. package/docs/migration/release-notes/README.md +5 -0
  320. package/docs/migration/v0.8-to-v0.9.md +76 -1077
  321. package/docs/migration/v0.9.0-troubleshooting.md +104 -516
  322. package/docs/migration/v0.9.1-to-v0.9.2.md +150 -0
  323. package/docs/reference/README.md +1 -0
  324. package/docs/reference/cli.md +230 -98
  325. package/docs/reference/configuration.md +153 -27
  326. package/docs/reference/data-and-telemetry.md +19 -1
  327. package/docs/reference/supported-formats.md +23 -3
  328. package/docs/reference/tasks.md +182 -0
  329. package/docs/reference/workflow-schema.md +91 -40
  330. package/docs/reference/workflows.md +33 -6
  331. package/package.json +10 -6
  332. package/schemas/akm-config.json +372 -216
  333. package/schemas/akm-task.json +324 -80
  334. package/schemas/akm-workflow.json +6 -9
  335. package/dist/core/migration-operation.js +0 -75
  336. package/dist/integrations/agent/model-aliases.js +0 -74
  337. package/dist/tasks/parser.js +0 -380
  338. package/dist/tasks/schema.js +0 -123
  339. package/dist/tasks/validator.js +0 -80
  340. package/dist/workflows/ir/freeze.js +0 -320
  341. package/dist/workflows/runtime/document-cache.js +0 -13
@@ -5,7 +5,7 @@
5
5
  * `akm extract` — session-insight extractor.
6
6
  *
7
7
  * Replaces the akm-plugin session-checkpoint hook with an on-demand extractor
8
- * that reads native session files (claude-code JSONL, opencode storage tree)
8
+ * that reads native session files (claude JSONL, opencode storage tree)
9
9
  * through the {@link SessionLogHarness} registry, pre-filters noise, and asks
10
10
  * a bounded in-tree LLM to produce candidate memory/lesson/knowledge proposals
11
11
  * for content the agent did NOT preserve via inline `akm remember`/`akm feedback`.
@@ -34,20 +34,20 @@ import { createLockPayload, probeLock, reclaimStaleLock, releaseLock, tryAcquire
34
34
  import { tryAcquireMaintenanceBarrier } from "../../core/maintenance-barrier.js";
35
35
  import { resolveStashStandards } from "../../core/standards/resolve-stash-standards.js";
36
36
  import { resolveTypeConventions, typeConventionRef } from "../../core/standards/resolve-type-conventions.js";
37
- import { getStateDbPath, openStateDatabase, withStateDb } from "../../core/state-db.js";
37
+ import { getStateDbPath, openStateDatabase } from "../../core/state-db.js";
38
38
  import { repairTruncatedDescription } from "../../core/text-truncation.js";
39
39
  import { DURATION_UNITS, parseDuration } from "../../core/time.js";
40
40
  import { warn } from "../../core/warn.js";
41
41
  import { indexWrittenAssets } from "../../indexer/index-written-assets.js";
42
- import { resolveLlmEngineUse } from "../../integrations/agent/engine-resolution.js";
43
- import { materializeLlmRunnerConnection, resolveImproveProcessRunner, } from "../../integrations/agent/runner.js";
44
- import { normalizeHarnessId } from "../../integrations/harnesses/index.js";
42
+ import { disposeLoweredExecutionDispatchLease, } from "../../integrations/agent/execution-lowering.js";
45
43
  import { getAvailableHarnesses } from "../../integrations/session-logs/index.js";
46
44
  import { preFilterSession } from "../../integrations/session-logs/pre-filter.js";
47
- import { callStructured } from "../../llm/structured-call.js";
45
+ import { callStructured, preflightStructuredLlmRunner } from "../../llm/structured-call.js";
48
46
  import { sha256Hex } from "../../runtime.js";
49
47
  import { getExtractedSessionsMap, getLastExtractRunAt, shouldSkipAlreadyExtractedSession, upsertExtractedSession, } from "../../storage/repositories/extract-sessions-repository.js";
48
+ import { openSqliteReadSnapshot } from "../../storage/sqlite-read-snapshot.js";
50
49
  import { isProposalSkipped } from "../proposal/repository.js";
50
+ import { resolveImproveLlmExecution } from "./execution.js";
51
51
  import { buildExtractPrompt, EXTRACT_JSON_SCHEMA, parseExtractPayload } from "./extract-prompt.js";
52
52
  import { resolveImproveStrategy, resolveProcessEnabled } from "./improve-strategies.js";
53
53
  import { emitProposal } from "./proposal-envelope.js";
@@ -99,21 +99,35 @@ function resolveDefaultSinceMs(harnessName, now, opts) {
99
99
  const floor = now - DEFAULT_SINCE_FLOOR_MS;
100
100
  if (opts.skipTracking)
101
101
  return floor;
102
+ let snapshot;
102
103
  try {
103
- return withStateDb((db) => {
104
- const lastRun = getLastExtractRunAt(db, harnessName);
105
- return lastRun != null ? Math.min(lastRun, floor) : floor;
106
- }, { path: opts.stateDbPath, borrowed: opts.stateDb });
104
+ let db = opts.stateDb;
105
+ if (!db) {
106
+ snapshot = openSqliteReadSnapshot(opts.stateDbPath ?? getStateDbPath());
107
+ db = snapshot;
108
+ }
109
+ if (!db)
110
+ return floor;
111
+ const lastRun = getLastExtractRunAt(db, harnessName);
112
+ return lastRun != null ? Math.min(lastRun, floor) : floor;
107
113
  }
108
114
  catch {
109
115
  return floor;
110
116
  }
117
+ finally {
118
+ snapshot?.close();
119
+ }
111
120
  }
112
121
  /** Filesystem-safe per-session lock path, co-located with the state.db. */
113
122
  function getExtractSessionLockPath(harness, sessionId, stateDbPath) {
114
123
  const safe = `${harness}-${sessionId}`.replace(/[^A-Za-z0-9._-]/g, "_");
115
124
  return path.join(path.dirname(stateDbPath), "extract-locks", `extract-${safe}.lock`);
116
125
  }
126
+ function extractSessionLockIsUnavailable(harness, sessionId, stateDbPath) {
127
+ const lockPath = getExtractSessionLockPath(harness, sessionId, stateDbPath);
128
+ const probe = probeLock(lockPath, { staleAfterMs: EXTRACT_SESSION_LOCK_STALE_MS });
129
+ return probe.state === "held" || probe.state === "inaccessible";
130
+ }
117
131
  /**
118
132
  * Try to claim the per-session extract lock so a concurrent extract (e.g. a
119
133
  * session-end hook firing `--session-id` while the hourly improve pass runs
@@ -172,27 +186,28 @@ export function resolveStandaloneExtractPlan(config, selection) {
172
186
  ...(selection.engine ? { engine: selection.engine } : {}),
173
187
  ...(Object.hasOwn(selection, "timeoutMs") ? { timeoutMs: selection.timeoutMs ?? null } : {}),
174
188
  };
175
- const resolved = resolveLlmEngineUse(config, [selected.config, process, invocation], { optional: true });
189
+ const resolved = resolveImproveLlmExecution({
190
+ config,
191
+ profile: selected.config,
192
+ process,
193
+ current: invocation,
194
+ processName: "extract",
195
+ });
176
196
  if (!resolved) {
177
197
  throw new ConfigError("No LLM engine configured for extract. Set defaults.llmEngine, pass --engine, or select an improve strategy with processes.extract.engine.", "LLM_NOT_CONFIGURED");
178
198
  }
179
- const runner = {
180
- kind: "llm",
181
- engine: resolved.engine,
182
- connection: resolved.connection,
183
- ...(resolved.credential ? { credential: resolved.credential } : {}),
184
- timeoutMs: resolved.timeoutMs,
185
- };
199
+ const runner = resolved.runner;
186
200
  return Object.freeze({
187
201
  strategy: selected.name,
188
- engine: resolved.engine,
202
+ engine: runner.engine,
189
203
  // `akm extract` is an explicit operation. The strategy supplies behavior,
190
204
  // but its improve-stage enablement gate does not disable this command.
191
205
  enabled: true,
192
206
  process,
193
207
  runner: cloneAndFreeze(runner),
194
- timeoutMs: resolved.timeoutMs,
208
+ timeoutMs: Object.hasOwn(runner, "timeoutMs") ? (runner.timeoutMs ?? null) : 600_000,
195
209
  embeddingConfig: cloneAndFreeze(config.embedding),
210
+ ...(resolved.notices.length > 0 ? { notices: cloneAndFreeze(resolved.notices) } : {}),
196
211
  });
197
212
  }
198
213
  // ── Helpers ──────────────────────────────────────────────────────────────────
@@ -233,15 +248,7 @@ export function parseSinceArg(value, now = Date.now()) {
233
248
  */
234
249
  function resolveHarness(type, harnesses) {
235
250
  const pool = harnesses ?? getAvailableHarnesses();
236
- // #563 id-normalization bridge: a provider's `name` is its runtime id (e.g.
237
- // the Claude provider is "claude-code"), but the canonical harness id is
238
- // "claude". Normalize BOTH the requested `--type` and each provider name to
239
- // canonical before comparing, so `--type claude` and `--type claude-code`
240
- // both resolve to the Claude provider. Behaviour fix: previously only the
241
- // exact runtime string ("claude-code") matched; the canonical "claude" used
242
- // everywhere else (agent profiles, config schema) silently found nothing.
243
- const wanted = normalizeHarnessId(type);
244
- return pool.find((h) => normalizeHarnessId(h.name) === wanted);
251
+ return pool.find((h) => h.name === type);
245
252
  }
246
253
  /**
247
254
  * Build the ref + content for a candidate. The body must contain a
@@ -366,19 +373,7 @@ function runPreLlmSessionGates(args) {
366
373
  // `--force` overrides it to re-extract a previously-extracted session.
367
374
  const contentHash = hashSessionContent(data);
368
375
  if (!force && shouldSkipAlreadyExtractedSession(prior, contentHash)) {
369
- return {
370
- skip: {
371
- sessionId: sessionRef.sessionId,
372
- harness: harness.name,
373
- candidateCount: 0,
374
- proposalIds: [],
375
- preFilter: { inputCount: 0, outputCount: 0, truncatedCount: 0 },
376
- warnings: [`already extracted (content unchanged) at ${prior?.processed_at}; pass --force to re-process`],
377
- skipped: true,
378
- skipReason: "already_extracted",
379
- contentHash,
380
- },
381
- };
376
+ return { skip: alreadyExtractedResult(harness.name, sessionRef.sessionId, prior, contentHash) };
382
377
  }
383
378
  const filtered = preFilterSession(data, {
384
379
  ...(typeof maxTotalChars === "number" ? { maxTotalChars } : {}),
@@ -440,57 +435,124 @@ function runPreLlmSessionGates(args) {
440
435
  }
441
436
  return { data, filtered, contentHash };
442
437
  }
438
+ function alreadyExtractedResult(harness, sessionId, prior, contentHash) {
439
+ return {
440
+ sessionId,
441
+ harness,
442
+ candidateCount: 0,
443
+ proposalIds: [],
444
+ preFilter: { inputCount: 0, outputCount: 0, truncatedCount: 0 },
445
+ warnings: [`already extracted (content unchanged) at ${prior?.processed_at}; pass --force to re-process`],
446
+ skipped: true,
447
+ skipReason: "already_extracted",
448
+ contentHash,
449
+ };
450
+ }
451
+ function lockedConcurrentResult(harness, summary) {
452
+ return {
453
+ sessionId: summary.sessionId,
454
+ harness,
455
+ candidateCount: 0,
456
+ proposalIds: [],
457
+ preFilter: { inputCount: 0, outputCount: 0, truncatedCount: 0 },
458
+ warnings: ["concurrent extract holds this session's lock — skipped (handled by the other run)"],
459
+ skipped: true,
460
+ skipReason: "locked_concurrent",
461
+ };
462
+ }
463
+ function planExtractSessions(args) {
464
+ const { candidates, options, harness, seenMap, maxSessionsPerRun, trackingEnabled, dryRun } = args;
465
+ const plans = [];
466
+ let modelCount = 0;
467
+ for (let index = 0; index < candidates.length; index++) {
468
+ if (options.signal?.aborted)
469
+ return { plans, deferredCandidates: candidates.slice(index) };
470
+ if (!options.sessionId && !options.force && maxSessionsPerRun > 0 && modelCount >= maxSessionsPerRun) {
471
+ return { plans, deferredCandidates: candidates.slice(index) };
472
+ }
473
+ const summary = candidates[index];
474
+ if (!summary)
475
+ continue;
476
+ if (trackingEnabled && !dryRun && !options.stateDb) {
477
+ if (extractSessionLockIsUnavailable(harness.name, summary.sessionId, options.stateDbPath ?? getStateDbPath())) {
478
+ plans.push({ kind: "skip", summary, result: lockedConcurrentResult(harness.name, summary) });
479
+ continue;
480
+ }
481
+ }
482
+ const gate = runPreLlmSessionGates({
483
+ harness,
484
+ sessionRef: summary,
485
+ prior: seenMap.get(summary.sessionId),
486
+ force: options.force === true,
487
+ maxTotalChars: args.maxTotalChars,
488
+ minContentChars: args.minContentChars,
489
+ triage: args.triage,
490
+ });
491
+ if ("skip" in gate) {
492
+ plans.push({ kind: "skip", summary, result: gate.skip });
493
+ continue;
494
+ }
495
+ // Reading and classifying a session can take long enough for a concurrent
496
+ // session-end hook to claim its lock. Re-probe the fully classified model
497
+ // plan before it consumes a cap slot or forces credential materialization.
498
+ if (trackingEnabled &&
499
+ !dryRun &&
500
+ !options.stateDb &&
501
+ extractSessionLockIsUnavailable(harness.name, summary.sessionId, options.stateDbPath ?? getStateDbPath())) {
502
+ plans.push({ kind: "skip", summary, result: lockedConcurrentResult(harness.name, summary) });
503
+ continue;
504
+ }
505
+ plans.push({ kind: "model", summary, gate });
506
+ modelCount += 1;
507
+ }
508
+ return { plans, deferredCandidates: [] };
509
+ }
443
510
  /**
444
- * The bounded per-session extraction LLM call. Resolves the connection with
445
- * the same fail-open contract the gated fn had (a `getLlmConfig()` throw —
446
- * `materializeLlmConnection` can raise ConfigError takes the skipped path,
447
- * never propagates), then routes through `callStructured` under the
448
- * `session_extraction` gate. Returns the seam result plus the `llmRaw`
511
+ * The bounded per-session extraction LLM call. Routes the already-resolved
512
+ * symbolic runner through `callStructured` under the `session_extraction`
513
+ * gate. Invalid configuration escapes before session/proposal state is
514
+ * persisted. Returns the seam result plus the `llmRaw`
449
515
  * side-channel value that distinguishes fallback-took-over from a
450
516
  * genuinely-empty response.
451
517
  */
452
518
  async function runSessionExtractionLlmCall(args) {
453
- const { config, getLlmConfig, chat, prompt, timeoutMs, signal } = args;
454
- let extractLlm;
455
- try {
456
- extractLlm = getLlmConfig();
457
- }
458
- catch {
459
- extractLlm = undefined;
460
- }
519
+ const { config, llmRunner, lease, chat, prompt, timeoutMs, signal, onNotices } = args;
461
520
  let llmRaw = "";
462
- const llmResult = extractLlm === undefined
463
- ? ""
464
- : await callStructured({
465
- feature: "session_extraction",
466
- akmConfig: config,
467
- config: extractLlm,
468
- messages: [{ role: "user", content: prompt }],
469
- request: {
470
- timeoutMs,
471
- responseSchema: EXTRACT_JSON_SCHEMA,
472
- ...(signal ? { signal } : {}),
473
- ...(chat ? { chat } : {}),
474
- },
475
- parse: (raw) => {
476
- llmRaw = raw ?? "";
477
- return llmRaw;
478
- },
479
- // A transport throw takes the "" fallback with llmRaw left unset —
480
- // the same skipped path the gated-fn throw produced before.
481
- onError: () => "",
482
- fallback: "",
483
- });
521
+ const llmResult = await callStructured({
522
+ feature: "session_extraction",
523
+ akmConfig: config,
524
+ runner: llmRunner,
525
+ lease,
526
+ messages: [{ role: "user", content: prompt }],
527
+ request: {
528
+ timeoutMs,
529
+ responseSchema: EXTRACT_JSON_SCHEMA,
530
+ ...(signal ? { signal } : {}),
531
+ ...(chat ? { chat } : {}),
532
+ },
533
+ onNotices,
534
+ parse: (raw) => {
535
+ llmRaw = raw ?? "";
536
+ return llmRaw;
537
+ },
538
+ // A transport throw takes the "" fallback with llmRaw left unset —
539
+ // the same skipped path the gated-fn throw produced before.
540
+ onError: () => "",
541
+ fallback: "",
542
+ });
484
543
  return { llmResult, llmRaw };
485
544
  }
545
+ function extractNoticeFields(getNotices) {
546
+ const notices = getNotices();
547
+ return notices.length > 0 ? { notices } : {};
548
+ }
486
549
  async function processSession(runCtx, session) {
487
- const { harness, stashDir, config, getLlmConfig, chat, ctx, eventsCtx, sourceRun, dryRun, timeoutMs, maxTotalChars, minContentChars, triage, sessionIndexing, signal, standardsContext, } = runCtx;
488
- const { sessionRef, prior, force } = session;
550
+ const { harness, stashDir, config, llmRunner, lease, onNotices, getNotices, chat, ctx, eventsCtx, sourceRun, dryRun, timeoutMs, sessionIndexing, signal, standardsContext, } = runCtx;
551
+ const { sessionRef, gate } = session;
489
552
  const warnings = [];
490
- const gate = runPreLlmSessionGates({ harness, sessionRef, prior, force, maxTotalChars, minContentChars, triage });
491
- if ("skip" in gate)
492
- return gate.skip;
493
553
  const { data, filtered, contentHash } = gate;
554
+ if (!lease)
555
+ throw new TypeError("extract model work requires an operation dispatch lease");
494
556
  const prompt = buildExtractPrompt({
495
557
  data,
496
558
  events: filtered.events,
@@ -508,7 +570,7 @@ async function processSession(runCtx, session) {
508
570
  if (!sessionMeetsDurationGate(data, sessionIndexing.minDurationMinutes))
509
571
  return {};
510
572
  try {
511
- const result = await writeSessionAsset(data, stashDir, sessionIndexing.generate);
573
+ const result = await writeSessionAsset(data, stashDir, (summaryData) => sessionIndexing.generate(summaryData, lease));
512
574
  if (result.written) {
513
575
  // Write-path indexing (itself fail-open): standalone `akm extract`
514
576
  // (session-end hook) has no post-loop reindex to pick this file up.
@@ -521,17 +583,21 @@ async function processSession(runCtx, session) {
521
583
  }
522
584
  }
523
585
  catch (err) {
586
+ if (err instanceof ConfigError)
587
+ throw err;
524
588
  warnings.push(`session asset write failed: ${err instanceof Error ? err.message : String(err)}`);
525
589
  }
526
590
  return {};
527
591
  };
528
592
  const { llmResult, llmRaw } = await runSessionExtractionLlmCall({
529
593
  config,
530
- getLlmConfig,
594
+ llmRunner,
595
+ lease,
531
596
  chat,
532
597
  prompt,
533
598
  timeoutMs,
534
599
  signal,
600
+ onNotices,
535
601
  });
536
602
  if (llmResult === "" && !llmRaw) {
537
603
  // The seam took the fallback path (disabled / timeout / error). Return skipped.
@@ -549,6 +615,7 @@ async function processSession(runCtx, session) {
549
615
  skipped: true,
550
616
  skipReason: "llm_unavailable",
551
617
  contentHash,
618
+ ...extractNoticeFields(getNotices),
552
619
  };
553
620
  }
554
621
  const payload = parseExtractPayload(llmRaw);
@@ -583,17 +650,12 @@ async function processSession(runCtx, session) {
583
650
  warnings,
584
651
  contentHash,
585
652
  ...sessionAsset,
653
+ ...extractNoticeFields(getNotices),
586
654
  };
587
655
  }
588
656
  // §23.6 fingerprint model-id term: the profile resolved for this session's
589
657
  // LLM call (best-effort — an unconfigured profile leaves the term empty).
590
- let extractModelId;
591
- try {
592
- extractModelId = runCtx.getLlmConfig().model;
593
- }
594
- catch {
595
- extractModelId = undefined;
596
- }
658
+ const extractModelId = llmRunner.connection.model;
597
659
  for (const candidate of payload.candidates) {
598
660
  const built = buildCandidateProposal(candidate, data.ref, sessionAsset.sessionAssetRef);
599
661
  if (dryRun) {
@@ -658,8 +720,73 @@ async function processSession(runCtx, session) {
658
720
  warnings,
659
721
  contentHash,
660
722
  ...sessionAsset,
723
+ ...extractNoticeFields(getNotices),
661
724
  };
662
725
  }
726
+ function recordExtractSessionOutcome(args) {
727
+ const { stateDb, trackingEnabled, dryRun, harness, summary, result, sourceRun } = args;
728
+ if (!trackingEnabled ||
729
+ !stateDb ||
730
+ dryRun ||
731
+ result.skipReason === "already_extracted" ||
732
+ result.skipReason === "locked_concurrent")
733
+ return;
734
+ try {
735
+ const outcome = result.skipped
736
+ ? result.skipReason === "read_failed" || result.skipReason === "exception"
737
+ ? "failed"
738
+ : "skipped"
739
+ : result.candidateCount === 0
740
+ ? "no_candidates"
741
+ : "candidates_queued";
742
+ upsertExtractedSession(stateDb, {
743
+ harness,
744
+ sessionId: summary.sessionId,
745
+ processedAt: new Date().toISOString(),
746
+ sessionEndedAt: summary.endedAt ?? null,
747
+ outcome,
748
+ candidateCount: result.candidateCount,
749
+ proposalCount: result.proposalIds.length,
750
+ rationale: result.rationaleIfEmpty ?? null,
751
+ sourceRun,
752
+ contentHash: result.skipReason === "llm_unavailable" || result.skipReason === "triaged_out"
753
+ ? null
754
+ : (result.contentHash ?? null),
755
+ metadata: {
756
+ preFilterInputCount: result.preFilter.inputCount,
757
+ preFilterOutputCount: result.preFilter.outputCount,
758
+ preFilterTruncatedCount: result.preFilter.truncatedCount,
759
+ ...(result.skipReason ? { skipReason: result.skipReason } : {}),
760
+ ...(result.sessionLogPath ? { logPath: result.sessionLogPath } : {}),
761
+ ...(result.sessionAssetRef ? { sessionAssetRef: result.sessionAssetRef } : {}),
762
+ },
763
+ });
764
+ }
765
+ catch (err) {
766
+ warn(`[extract] failed to record session ${summary.sessionId} in state.db: ${err instanceof Error ? err.message : String(err)}`);
767
+ }
768
+ }
769
+ function accountExtractSessionResult(result, triageEnabled, output) {
770
+ output.sessions.push(result);
771
+ if (triageEnabled) {
772
+ const preempted = result.skipReason === "read_failed" ||
773
+ result.skipReason === "too_short" ||
774
+ result.skipReason === "already_extracted" ||
775
+ result.skipReason === "locked_concurrent";
776
+ if (!preempted) {
777
+ output.triageEvaluated += 1;
778
+ if (result.skipReason === "triaged_out")
779
+ output.triagedOut += 1;
780
+ else
781
+ output.triagePassed += 1;
782
+ }
783
+ }
784
+ if (result.skipped)
785
+ output.skippedCount += 1;
786
+ else
787
+ output.processedCount += 1;
788
+ output.allProposalIds.push(...result.proposalIds);
789
+ }
663
790
  /**
664
791
  * Iterate the discovered candidate sessions: enforce the per-run cap, take the
665
792
  * per-session cross-process lock, dispatch to {@link processSession}, aggregate
@@ -668,164 +795,137 @@ async function processSession(runCtx, session) {
668
795
  * aggregation, and seen-row upsert are byte-identical.
669
796
  */
670
797
  async function runExtractSessionLoop(args) {
671
- const { candidates, options, harness, seenMap, stateDb, trackingEnabled, dryRun, stashDir, config, getLlmConfig, chat, sourceRun, timeoutMs, maxTotalChars, minContentChars, maxSessionsPerRun, triage, sessionIndexing, extractStandardsContext, topLevelWarnings, } = args;
798
+ const { plans, deferredCandidates, seenMap, options, harness, stateDb, trackingEnabled, dryRun, stashDir, config, llmRunner, lease, onNotices, getNotices, chat, sourceRun, timeoutMs, triage, sessionIndexing, extractStandardsContext, topLevelWarnings, } = args;
672
799
  // WI-7.7 §2: run-scoped processSession inputs, resolved once per run.
673
800
  const sessionRunCtx = {
674
801
  harness,
675
802
  stashDir,
676
803
  config,
677
- getLlmConfig,
804
+ llmRunner,
805
+ lease,
806
+ onNotices,
807
+ getNotices,
678
808
  chat,
679
809
  ctx: options.ctx,
680
810
  eventsCtx: options.eventsCtx,
681
811
  sourceRun,
682
812
  dryRun,
683
813
  timeoutMs,
684
- maxTotalChars,
685
- minContentChars,
686
- triage,
687
814
  sessionIndexing,
688
815
  signal: options.signal,
689
816
  standardsContext: extractStandardsContext,
690
817
  };
691
- const sessions = [];
692
- let processedCount = 0;
693
- let skippedCount = 0;
694
- // #626 — per-run triage aggregation counters (counts-only telemetry, AC4).
695
- let triageEvaluated = 0;
696
- let triagePassed = 0;
697
- let triagedOut = 0;
698
- const allProposalIds = [];
699
- for (const summary of candidates) {
818
+ const output = {
819
+ sessions: [],
820
+ processedCount: 0,
821
+ skippedCount: 0,
822
+ triageEvaluated: 0,
823
+ triagePassed: 0,
824
+ triagedOut: 0,
825
+ allProposalIds: [],
826
+ deferred: 0,
827
+ };
828
+ const workPlans = [...plans];
829
+ let remainingCandidates = deferredCandidates;
830
+ const refillModelSlot = () => {
831
+ if (remainingCandidates.length === 0 || options.signal?.aborted)
832
+ return;
833
+ const refill = planExtractSessions({
834
+ candidates: remainingCandidates,
835
+ options,
836
+ harness,
837
+ seenMap,
838
+ maxTotalChars: args.maxTotalChars,
839
+ minContentChars: args.minContentChars,
840
+ maxSessionsPerRun: 1,
841
+ triage,
842
+ trackingEnabled,
843
+ dryRun,
844
+ });
845
+ workPlans.push(...refill.plans);
846
+ remainingCandidates = refill.deferredCandidates;
847
+ };
848
+ for (const plan of workPlans) {
700
849
  if (options.signal?.aborted)
701
850
  break;
702
- // #602 the already-extracted skip moved INTO processSession (the content
703
- // hash needs the session body, only available after readSession). The prior
704
- // row + bypass flags are threaded through; an unchanged session returns
705
- // skipReason 'already_extracted' WITHOUT any LLM call.
706
- const prior = seenMap.get(summary.sessionId);
707
- // Per-run cap on LLM-processed sessions (skip-tracked seen sessions above
708
- // don't count). Single-session / --force modes bypass the cap (explicit
709
- // intent). Overflow sessions are left unseen for the next run.
710
- if (!options.sessionId && !options.force && maxSessionsPerRun > 0 && processedCount >= maxSessionsPerRun) {
711
- topLevelWarnings.push(`Reached maxSessionsPerRun=${maxSessionsPerRun}; ${candidates.length - processedCount - skippedCount} session(s) deferred to a later run.`);
712
- break;
851
+ const { summary } = plan;
852
+ if (plan.kind === "skip") {
853
+ accountExtractSessionResult(plan.result, triage.enabled, output);
854
+ recordExtractSessionOutcome({
855
+ stateDb,
856
+ trackingEnabled,
857
+ dryRun,
858
+ harness: harness.name,
859
+ summary,
860
+ result: plan.result,
861
+ sourceRun,
862
+ });
863
+ continue;
713
864
  }
714
- // Q5 — per-session lock so two concurrent extracts (e.g. a session-end hook
715
- // firing `--session-id` while the hourly improve discovery pass runs) can't
716
- // both LLM-process the SAME session. The holder records the outcome; a
717
- // second run skips without any LLM call. Engaged only for real cross-process
718
- // runs (those that open their own state.db): dry-run is read-only, an
719
- // injected `stateDb` handle is an in-process/test scenario with no cross-
720
- // process race, and skip-tracking-off opts out entirely.
721
865
  let sessionLockOwnership;
722
866
  if (trackingEnabled && !dryRun && !options.stateDb) {
723
867
  const sessionLockPath = getExtractSessionLockPath(harness.name, summary.sessionId, options.stateDbPath ?? getStateDbPath());
724
868
  const sessionLock = acquireExtractSessionLock(sessionLockPath);
725
869
  if (!sessionLock.proceed) {
726
- sessions.push({
727
- sessionId: summary.sessionId,
728
- harness: harness.name,
729
- candidateCount: 0,
730
- proposalIds: [],
731
- preFilter: { inputCount: 0, outputCount: 0, truncatedCount: 0 },
732
- warnings: ["concurrent extract holds this session's lock — skipped (handled by the other run)"],
733
- skipped: true,
734
- skipReason: "locked_concurrent",
735
- });
736
- skippedCount += 1;
870
+ accountExtractSessionResult(lockedConcurrentResult(harness.name, summary), triage.enabled, output);
871
+ refillModelSlot();
737
872
  continue;
738
873
  }
739
874
  sessionLockOwnership = sessionLock.ownership;
740
875
  }
741
876
  try {
742
- const result = await processSession(sessionRunCtx, {
877
+ // Planning stays read-only so a credential failure creates no state. Once
878
+ // this run owns the session lock, read and gate the session again: the log
879
+ // may have grown, become too short after replacement, or been completed by
880
+ // another extractor between the planning snapshot and acquisition.
881
+ const currentPrior = stateDb
882
+ ? getExtractedSessionsMap(stateDb, harness.name, [summary.sessionId]).get(summary.sessionId)
883
+ : seenMap.get(summary.sessionId);
884
+ const executionGate = runPreLlmSessionGates({
885
+ harness,
743
886
  sessionRef: summary,
744
- prior,
887
+ prior: currentPrior,
745
888
  force: options.force === true,
889
+ maxTotalChars: args.maxTotalChars,
890
+ minContentChars: args.minContentChars,
891
+ triage,
746
892
  });
747
- sessions.push(result);
748
- // #626 — triage aggregation. A session reached the triage gate only when it
749
- // was NOT already preempted by an earlier skip (read_failed / too_short /
750
- // already_extracted handled above the processSession call). When triage is
751
- // enabled, processSession either triages-out (skipReason 'triaged_out') or
752
- // proceeds past the gate — both count as "evaluated".
753
- if (triage.enabled) {
754
- const preemptedBeforeTriage = result.skipReason === "read_failed" ||
755
- result.skipReason === "too_short" ||
756
- result.skipReason === "already_extracted";
757
- if (!preemptedBeforeTriage) {
758
- triageEvaluated += 1;
759
- if (result.skipReason === "triaged_out")
760
- triagedOut += 1;
761
- else
762
- triagePassed += 1;
763
- }
764
- }
765
- if (result.skipped)
766
- skippedCount += 1;
767
- else
768
- processedCount += 1;
769
- allProposalIds.push(...result.proposalIds);
770
- // Persist outcome so the next run skips this session unless its content
771
- // changes. We only track non-dry-run paths — dry-run is for inspection
772
- // and should never poison the seen-table. #602: an `already_extracted`
773
- // skip is a no-op (the row already carries the matching hash), so don't
774
- // re-write it — that keeps `processed_at` stable across unchanged runs.
775
- if (trackingEnabled && stateDb && !dryRun && result.skipReason !== "already_extracted") {
776
- try {
777
- const outcome = result.skipped
778
- ? result.skipReason === "read_failed" || result.skipReason === "exception"
779
- ? "failed"
780
- : "skipped"
781
- : result.candidateCount === 0
782
- ? "no_candidates"
783
- : "candidates_queued";
784
- upsertExtractedSession(stateDb, {
785
- harness: harness.name,
786
- sessionId: summary.sessionId,
787
- processedAt: new Date().toISOString(),
788
- sessionEndedAt: summary.endedAt ?? null,
789
- outcome,
790
- candidateCount: result.candidateCount,
791
- proposalCount: result.proposalIds.length,
792
- rationale: result.rationaleIfEmpty ?? null,
793
- sourceRun,
794
- // #602 — persist the freshly computed content hash so the NEXT run
795
- // can compare byte-for-byte. read_failed (before hash) → null, which
796
- // keeps the row eligible for retry (matches failed-row semantics).
797
- // R4 — llm_unavailable (LLM was down) and triaged_out (deferred by the
798
- // triage gate) are transient outcomes: persist null so the null-hash
799
- // retry re-processes them on a later run instead of pinning them as
800
- // "seen" forever against the current byte content.
801
- contentHash: result.skipReason === "llm_unavailable" || result.skipReason === "triaged_out"
802
- ? null
803
- : (result.contentHash ?? null),
804
- metadata: {
805
- preFilterInputCount: result.preFilter.inputCount,
806
- preFilterOutputCount: result.preFilter.outputCount,
807
- preFilterTruncatedCount: result.preFilter.truncatedCount,
808
- ...(result.skipReason ? { skipReason: result.skipReason } : {}),
809
- // #561 — record the session's log_path for correlation across
810
- // index rebuilds (the session asset frontmatter is the primary
811
- // durable key; this is the state-db mirror of it).
812
- ...(result.sessionLogPath ? { logPath: result.sessionLogPath } : {}),
813
- ...(result.sessionAssetRef ? { sessionAssetRef: result.sessionAssetRef } : {}),
814
- },
815
- });
816
- }
817
- catch (err) {
818
- // Tracking failure must not abort the run — log + continue.
819
- const msg = err instanceof Error ? err.message : String(err);
820
- warn(`[extract] failed to record session ${summary.sessionId} in state.db: ${msg}`);
821
- }
893
+ if ("skip" in executionGate) {
894
+ accountExtractSessionResult(executionGate.skip, triage.enabled, output);
895
+ recordExtractSessionOutcome({
896
+ stateDb,
897
+ trackingEnabled,
898
+ dryRun,
899
+ harness: harness.name,
900
+ summary,
901
+ result: executionGate.skip,
902
+ sourceRun,
903
+ });
904
+ refillModelSlot();
905
+ continue;
822
906
  }
907
+ const result = await processSession(sessionRunCtx, {
908
+ sessionRef: summary,
909
+ gate: executionGate,
910
+ });
911
+ accountExtractSessionResult(result, triage.enabled, output);
912
+ recordExtractSessionOutcome({
913
+ stateDb,
914
+ trackingEnabled,
915
+ dryRun,
916
+ harness: harness.name,
917
+ summary,
918
+ result,
919
+ sourceRun,
920
+ });
823
921
  }
824
922
  catch (err) {
923
+ if (err instanceof ConfigError)
924
+ throw err;
825
925
  const msg = err instanceof Error ? err.message : String(err);
826
926
  warn(`[extract] session ${summary.sessionId} threw: ${msg}`);
827
927
  topLevelWarnings.push(`session ${summary.sessionId} threw: ${msg}`);
828
- sessions.push({
928
+ accountExtractSessionResult({
829
929
  sessionId: summary.sessionId,
830
930
  harness: harness.name,
831
931
  candidateCount: 0,
@@ -834,15 +934,16 @@ async function runExtractSessionLoop(args) {
834
934
  warnings: [msg],
835
935
  skipped: true,
836
936
  skipReason: "exception",
837
- });
838
- skippedCount += 1;
937
+ ...extractNoticeFields(getNotices),
938
+ }, triage.enabled, output);
839
939
  }
840
940
  finally {
841
941
  if (sessionLockOwnership)
842
942
  releaseLock(sessionLockOwnership);
843
943
  }
844
944
  }
845
- return { sessions, processedCount, skippedCount, triageEvaluated, triagePassed, triagedOut, allProposalIds };
945
+ output.deferred = remainingCandidates.length;
946
+ return output;
846
947
  }
847
948
  /**
848
949
  * Resolve the run-scoped LLM/engine, budget, triage, and session-indexing
@@ -851,25 +952,43 @@ async function runExtractSessionLoop(args) {
851
952
  * session-summary generator seam, and the default resolutions are byte-identical.
852
953
  */
853
954
  function resolveExtractRunConfig(options, config, extractProcess, activeProfile) {
854
- // Improve supplies its invocation-owned connection. Standalone extract
855
- // resolves the selected process engine, then defaults.llmEngine.
856
- const runnerSpec = options.resolvedPlan
857
- ? options.resolvedPlan.runner
858
- : resolveImproveProcessRunner(activeProfile, "extract", config);
859
- const fixedLlmConfig = options.resolvedPlan ? undefined : options.llmConfig;
860
- if (!runnerSpec && !fixedLlmConfig) {
955
+ const executionNotices = new Map();
956
+ const onNotices = (notices) => {
957
+ for (const notice of notices)
958
+ executionNotices.set(JSON.stringify(notice), notice);
959
+ };
960
+ const getNotices = () => Object.freeze([...executionNotices.values()]);
961
+ // Improve supplies its invocation-owned symbolic runner. Standalone extract
962
+ // resolves the selected process engine through the shared execution planner.
963
+ let llmRunner;
964
+ if (options.resolvedPlan) {
965
+ llmRunner = options.resolvedPlan.runner;
966
+ onNotices(options.resolvedPlan.notices ?? []);
967
+ }
968
+ else if (options.llmRunner) {
969
+ llmRunner = options.llmRunner;
970
+ }
971
+ else {
972
+ const resolved = resolveImproveLlmExecution({
973
+ config,
974
+ profile: activeProfile,
975
+ process: extractProcess,
976
+ processName: "extract",
977
+ });
978
+ llmRunner = resolved?.runner;
979
+ if (resolved)
980
+ onNotices(resolved.notices);
981
+ }
982
+ if (!llmRunner) {
861
983
  throw new ConfigError("No LLM engine configured for extract. Set defaults.llmEngine or improve.strategies.<name>.processes.extract.engine.", "LLM_NOT_CONFIGURED");
862
984
  }
863
985
  const timeoutMs = options.resolvedPlan
864
986
  ? options.resolvedPlan.timeoutMs
865
987
  : Object.hasOwn(options, "timeoutMs")
866
988
  ? (options.timeoutMs ?? null)
867
- : runnerSpec?.timeoutMs !== undefined
868
- ? runnerSpec.timeoutMs
869
- : fixedLlmConfig && Object.hasOwn(fixedLlmConfig, "timeoutMs")
870
- ? (fixedLlmConfig.timeoutMs ?? null)
871
- : 600_000;
872
- const getLlmConfig = () => runnerSpec ? materializeLlmRunnerConnection(runnerSpec) : fixedLlmConfig;
989
+ : Object.hasOwn(llmRunner, "timeoutMs")
990
+ ? (llmRunner.timeoutMs ?? null)
991
+ : 600_000;
873
992
  // Pre-filter budget — process config can raise it for large-context models.
874
993
  const maxTotalChars = typeof extractProcess?.maxTotalChars === "number" ? extractProcess.maxTotalChars : undefined;
875
994
  // #595/#596 — minimum raw session size; sessions below it skip the LLM call
@@ -899,36 +1018,28 @@ function resolveExtractRunConfig(options, config, extractProcess, activeProfile)
899
1018
  // same fail-open `callStructured` seam as the rest of extract. Returns
900
1019
  // `undefined` on disablement / timeout / error so no asset is written.
901
1020
  // Tests inject a fake.
902
- const defaultSessionSummaryGenerator = async (data) => {
903
- // Same fail-open contract as the per-session call: a getLlmConfig()
904
- // throw takes the "" fallback rather than propagating.
905
- let summaryLlm;
906
- try {
907
- summaryLlm = getLlmConfig();
908
- }
909
- catch {
910
- summaryLlm = undefined;
911
- }
1021
+ const defaultSessionSummaryGenerator = async (data, lease) => {
912
1022
  let raw = "";
913
- if (summaryLlm !== undefined) {
914
- await callStructured({
915
- feature: "session_extraction",
916
- akmConfig: config,
917
- config: summaryLlm,
918
- messages: [{ role: "user", content: buildSessionSummaryPrompt(data) }],
919
- request: {
920
- timeoutMs,
921
- responseSchema: SESSION_SUMMARY_JSON_SCHEMA,
922
- ...(options.chat ? { chat: options.chat } : {}),
923
- },
924
- parse: (r) => {
925
- raw = r ?? "";
926
- return raw;
927
- },
928
- onError: () => "",
929
- fallback: "",
930
- });
931
- }
1023
+ await callStructured({
1024
+ feature: "session_extraction",
1025
+ akmConfig: config,
1026
+ runner: llmRunner,
1027
+ ...(lease ? { lease } : {}),
1028
+ messages: [{ role: "user", content: buildSessionSummaryPrompt(data) }],
1029
+ request: {
1030
+ timeoutMs,
1031
+ responseSchema: SESSION_SUMMARY_JSON_SCHEMA,
1032
+ ...(options.signal ? { signal: options.signal } : {}),
1033
+ ...(options.chat ? { chat: options.chat } : {}),
1034
+ },
1035
+ onNotices,
1036
+ parse: (r) => {
1037
+ raw = r ?? "";
1038
+ return raw;
1039
+ },
1040
+ onError: () => "",
1041
+ fallback: "",
1042
+ });
932
1043
  return parseSessionSummary(raw);
933
1044
  };
934
1045
  const sessionIndexing = {
@@ -938,7 +1049,9 @@ function resolveExtractRunConfig(options, config, extractProcess, activeProfile)
938
1049
  };
939
1050
  return {
940
1051
  timeoutMs,
941
- getLlmConfig,
1052
+ llmRunner,
1053
+ onNotices,
1054
+ getNotices,
942
1055
  maxTotalChars,
943
1056
  minContentChars,
944
1057
  maxSessionsPerRun,
@@ -999,30 +1112,11 @@ function discoverExtractCandidates(options, harness, effectiveSince, startMs, dr
999
1112
  /**
1000
1113
  * WI-9.10: build one `akm extract` run's {@link RunContext} from values
1001
1114
  * `akmExtract` has already resolved by the time it calls this (config,
1002
- * stashDir, dryRun, sourceRun, and `resolveExtractRunConfig`'s own
1003
- * `getLlmConfig`) — no second config load, no new db handle.
1004
- *
1005
- * `RunContext.getLlmConfig` is typed `() => LlmConnectionConfig | null`, but
1006
- * extract's own resolved `getLlmConfig` returns `LlmProfileConfig` (a
1007
- * superset — `supportsJsonSchema` — of `LlmConnectionConfig`) and, per its
1008
- * documented fail-open contract, MAY THROW (`materializeLlmConnection` can
1009
- * raise ConfigError) rather than return null; every existing caller in this
1010
- * file wraps it in try/catch for exactly that reason. The thin closure below
1011
- * adapts at the boundary: it derives from the SAME already-resolved
1012
- * runner/profile (this doesn't widen `RunContext.getLlmConfig`'s type), and —
1013
- * matching the file's own fail-open contract — coalesces a throw to `null`
1014
- * instead of propagating.
1115
+ * stashDir, dryRun, sourceRun, and `resolveExtractRunConfig`'s symbolic runner)
1116
+ * — no second config load, credential materialization, or new db handle.
1015
1117
  */
1016
1118
  function buildExtractRunContext(args) {
1017
- const { options, config, stashDir, dryRun, sourceRun, getLlmConfig } = args;
1018
- const getRunContextLlmConfig = () => {
1019
- try {
1020
- return getLlmConfig();
1021
- }
1022
- catch {
1023
- return null;
1024
- }
1025
- };
1119
+ const { options, config, stashDir, dryRun, sourceRun, llmRunner } = args;
1026
1120
  return createRunContext({
1027
1121
  stashDir,
1028
1122
  config,
@@ -1030,16 +1124,71 @@ function buildExtractRunContext(args) {
1030
1124
  // Not yet wired into any proposal call site this stage (mirrors
1031
1125
  // buildImproveRunContext's proposalsCtx comment in improve.ts).
1032
1126
  proposalsCtx: options.ctx ?? {},
1033
- getLlmConfig: getRunContextLlmConfig,
1127
+ getLlmRunner: () => llmRunner,
1034
1128
  sourceRun,
1035
1129
  dryRun,
1036
1130
  signal: options.signal,
1037
1131
  });
1038
1132
  }
1133
+ function loadExtractSeenMapReadOnly(args) {
1134
+ const { options, harness, candidates, trackingEnabled, warnings } = args;
1135
+ if (!trackingEnabled || candidates.length === 0)
1136
+ return new Map();
1137
+ let snapshot;
1138
+ try {
1139
+ if (!options.stateDb)
1140
+ snapshot = openSqliteReadSnapshot(options.stateDbPath ?? getStateDbPath());
1141
+ const db = options.stateDb ?? snapshot;
1142
+ return db
1143
+ ? getExtractedSessionsMap(db, harness, candidates.map((candidate) => candidate.sessionId))
1144
+ : new Map();
1145
+ }
1146
+ catch (err) {
1147
+ const msg = err instanceof Error ? err.message : String(err);
1148
+ warn(`[extract] state.db snapshot unavailable, planning without skip-tracking: ${msg}`);
1149
+ warnings.push(`state.db snapshot unavailable: ${msg}`);
1150
+ return new Map();
1151
+ }
1152
+ finally {
1153
+ snapshot?.close();
1154
+ }
1155
+ }
1156
+ function openExtractLiveStateDb(args) {
1157
+ const { options, trackingEnabled, hasModelWork, dryRun, warnings } = args;
1158
+ if (!trackingEnabled)
1159
+ return undefined;
1160
+ if (options.stateDb)
1161
+ return options.stateDb;
1162
+ if (!hasModelWork || dryRun)
1163
+ return undefined;
1164
+ try {
1165
+ return openStateDatabase(options.stateDbPath);
1166
+ }
1167
+ catch (err) {
1168
+ const msg = err instanceof Error ? err.message : String(err);
1169
+ warn(`[extract] state.db unavailable, processing without skip-tracking: ${msg}`);
1170
+ warnings.push(`state.db unavailable: ${msg}`);
1171
+ return undefined;
1172
+ }
1173
+ }
1174
+ function emitExtractTriageEvent(args) {
1175
+ const { modelPlanCount, triageEnabled, result, sourceRun, eventsCtx } = args;
1176
+ if (modelPlanCount === 0 || !triageEnabled || result.triageEvaluated === 0)
1177
+ return;
1178
+ appendEvent({
1179
+ eventType: "extract_triaged",
1180
+ metadata: {
1181
+ evaluated: result.triageEvaluated,
1182
+ passed: result.triagePassed,
1183
+ triagedOut: result.triagedOut,
1184
+ sourceRun,
1185
+ },
1186
+ }, eventsCtx);
1187
+ }
1039
1188
  export async function akmExtract(options) {
1040
1189
  const startMs = Date.now();
1041
1190
  if (!options.type || options.type.trim() === "") {
1042
- throw new UsageError("--type is required. Pass a harness name (e.g. --type claude-code).", "MISSING_REQUIRED_ARGUMENT");
1191
+ throw new UsageError("--type is required. Pass a harness name (e.g. --type claude).", "MISSING_REQUIRED_ARGUMENT");
1043
1192
  }
1044
1193
  const config = options.config ?? loadConfig();
1045
1194
  const stashDir = resolveRunStashDir(options.stashDir);
@@ -1075,10 +1224,10 @@ export async function akmExtract(options) {
1075
1224
  durationMs: Date.now() - startMs,
1076
1225
  };
1077
1226
  }
1078
- const { timeoutMs, getLlmConfig, maxTotalChars, minContentChars, maxSessionsPerRun, effectiveSince, triage, sessionIndexing, } = resolveExtractRunConfig(options, config, extractProcess, activeProfile);
1227
+ const { timeoutMs, llmRunner, onNotices, getNotices, maxTotalChars, minContentChars, maxSessionsPerRun, effectiveSince, triage, sessionIndexing, } = resolveExtractRunConfig(options, config, extractProcess, activeProfile);
1079
1228
  // WI-9.10: construct this run's RunContext (extracted to
1080
1229
  // buildExtractRunContext to keep akmExtract under the fn-size bar — R31).
1081
- const ctx = buildExtractRunContext({ options, config, stashDir, dryRun, sourceRun, getLlmConfig });
1230
+ const ctx = buildExtractRunContext({ options, config, stashDir, dryRun, sourceRun, llmRunner });
1082
1231
  const harness = resolveHarness(options.type, options.harnesses);
1083
1232
  if (!harness) {
1084
1233
  return {
@@ -1118,76 +1267,94 @@ export async function akmExtract(options) {
1118
1267
  return discovery.notFound;
1119
1268
  const candidates = discovery.candidates;
1120
1269
  const topLevelWarnings = [];
1121
- // Open state.db once for the run and bulk-load seen-rows for the candidate
1122
- // set so we can decide skip/process in O(1) per session. Tracking is opt-out
1123
- // via options.skipTracking (used by tests + one-shot debug calls).
1124
1270
  const trackingEnabled = options.skipTracking !== true;
1125
- let stateDb;
1126
- let seenMap = new Map();
1127
- if (trackingEnabled && candidates.length > 0) {
1128
- try {
1129
- stateDb = options.stateDb ?? openStateDatabase(options.stateDbPath);
1130
- seenMap = getExtractedSessionsMap(stateDb, harness.name, candidates.map((c) => c.sessionId));
1131
- }
1132
- catch (err) {
1133
- // state.db open is best-effort — log and proceed without skip-tracking
1134
- // so a transient sqlite error never blocks the actual extraction.
1135
- const msg = err instanceof Error ? err.message : String(err);
1136
- warn(`[extract] state.db unavailable, processing without skip-tracking: ${msg}`);
1137
- topLevelWarnings.push(`state.db unavailable: ${msg}`);
1138
- stateDb = undefined;
1139
- }
1140
- }
1141
- // Stash authoring standards (convention/meta fact bodies) for non-wiki
1142
- // extract output. Resolved ONCE per run and threaded into each session's
1143
- // prompt so facts are not re-read per session.
1144
- const extractStandardsContext = resolveExtractStandards(stashDir);
1145
- const { sessions, processedCount, skippedCount, triageEvaluated, triagePassed, triagedOut, allProposalIds } = await runExtractSessionLoop({
1271
+ const seenMap = loadExtractSeenMapReadOnly({
1272
+ options,
1273
+ harness: harness.name,
1274
+ candidates,
1275
+ trackingEnabled,
1276
+ warnings: topLevelWarnings,
1277
+ });
1278
+ const planned = planExtractSessions({
1146
1279
  candidates,
1147
1280
  options,
1148
1281
  harness,
1149
1282
  seenMap,
1150
- stateDb,
1151
- trackingEnabled,
1152
- dryRun,
1153
- stashDir,
1154
- config,
1155
- getLlmConfig,
1156
- chat: options.chat,
1157
- sourceRun,
1158
- timeoutMs,
1159
1283
  maxTotalChars,
1160
1284
  minContentChars,
1161
1285
  maxSessionsPerRun,
1162
1286
  triage,
1163
- sessionIndexing,
1164
- extractStandardsContext,
1165
- topLevelWarnings,
1287
+ trackingEnabled,
1288
+ dryRun,
1166
1289
  });
1167
- // Close the state.db connection we opened. Callers that injected stateDb
1168
- // via the test seam own its lifecycle.
1169
- if (stateDb && !options.stateDb) {
1170
- try {
1171
- stateDb.close();
1172
- }
1173
- catch {
1174
- // best-effort close
1290
+ const modelPlanCount = planned.plans.filter((plan) => plan.kind === "model").length;
1291
+ // Eligible dry-runs still dispatch to produce their candidate preview. Only
1292
+ // deterministic no-work plans are credential-free. Materialize once after
1293
+ // every read-only gate and before opening live state or acquiring a lock.
1294
+ const dispatchLease = modelPlanCount > 0 ? await preflightStructuredLlmRunner(llmRunner) : undefined;
1295
+ let stateDb;
1296
+ let loopResult;
1297
+ try {
1298
+ stateDb = openExtractLiveStateDb({
1299
+ options,
1300
+ trackingEnabled,
1301
+ hasModelWork: modelPlanCount > 0,
1302
+ dryRun,
1303
+ warnings: topLevelWarnings,
1304
+ });
1305
+ // Stash authoring standards (convention/meta fact bodies) for non-wiki
1306
+ // extract output. Resolved ONCE per run and threaded into each session's
1307
+ // prompt so facts are not re-read per session.
1308
+ const extractStandardsContext = modelPlanCount > 0 ? resolveExtractStandards(stashDir) : "";
1309
+ loopResult = await runExtractSessionLoop({
1310
+ plans: planned.plans,
1311
+ deferredCandidates: planned.deferredCandidates,
1312
+ seenMap,
1313
+ options,
1314
+ harness,
1315
+ stateDb,
1316
+ trackingEnabled,
1317
+ dryRun,
1318
+ stashDir,
1319
+ config,
1320
+ llmRunner,
1321
+ lease: dispatchLease,
1322
+ onNotices,
1323
+ getNotices,
1324
+ chat: options.chat,
1325
+ sourceRun,
1326
+ timeoutMs,
1327
+ maxTotalChars,
1328
+ minContentChars,
1329
+ triage,
1330
+ sessionIndexing,
1331
+ extractStandardsContext,
1332
+ topLevelWarnings,
1333
+ });
1334
+ }
1335
+ finally {
1336
+ if (stateDb && !options.stateDb) {
1337
+ try {
1338
+ stateDb.close();
1339
+ }
1340
+ catch {
1341
+ // best-effort close
1342
+ }
1175
1343
  }
1344
+ if (dispatchLease)
1345
+ disposeLoweredExecutionDispatchLease(dispatchLease);
1176
1346
  }
1177
- // #626 counts-only triage telemetry (AC4). Exactly ONE aggregated event per
1178
- // run, emitted only when the gate was enabled and actually evaluated at least
1179
- // one session. No per-session events (avoids the log-spam the issue warns of).
1180
- if (triage.enabled && triageEvaluated > 0) {
1181
- appendEvent({
1182
- eventType: "extract_triaged",
1183
- metadata: {
1184
- evaluated: triageEvaluated,
1185
- passed: triagePassed,
1186
- triagedOut,
1187
- sourceRun,
1188
- },
1189
- }, options.eventsCtx);
1347
+ const { sessions, processedCount, skippedCount, allProposalIds } = loopResult;
1348
+ if (loopResult.deferred > 0) {
1349
+ topLevelWarnings.push(`Reached maxSessionsPerRun=${maxSessionsPerRun}; ${loopResult.deferred} session(s) deferred to a later run.`);
1190
1350
  }
1351
+ emitExtractTriageEvent({
1352
+ modelPlanCount,
1353
+ triageEnabled: triage.enabled,
1354
+ result: loopResult,
1355
+ sourceRun,
1356
+ eventsCtx: options.eventsCtx,
1357
+ });
1191
1358
  return {
1192
1359
  schemaVersion: 1,
1193
1360
  ok: true,
@@ -1206,6 +1373,7 @@ export async function akmExtract(options) {
1206
1373
  sessions,
1207
1374
  warnings: topLevelWarnings,
1208
1375
  durationMs: Date.now() - startMs,
1376
+ ...(getNotices().length > 0 ? { notices: getNotices() } : {}),
1209
1377
  };
1210
1378
  }
1211
1379
  /**
@@ -1241,10 +1409,21 @@ export function countNewExtractCandidates(_config, options = {}) {
1241
1409
  resolveDefaultSinceMs(harness.name, Date.now(), {
1242
1410
  ...(options.stateDb ? { stateDb: options.stateDb } : {}),
1243
1411
  ...(options.stateDbPath ? { stateDbPath: options.stateDbPath } : {}),
1412
+ ...(options.readOnly && !options.stateDb ? { skipTracking: true } : {}),
1244
1413
  });
1245
- const candidates = harness.listSessions({ sinceMs });
1414
+ const candidates = harness.listSessions({
1415
+ sinceMs,
1416
+ ...(options.readOnly ? { isolatedSnapshot: true } : {}),
1417
+ });
1246
1418
  if (candidates.length === 0)
1247
1419
  continue;
1420
+ // A dry planner with no pre-existing state database has no seen-session
1421
+ // ledger by definition. Count the discovered sessions directly instead
1422
+ // of creating state.db merely to prove that it is empty.
1423
+ if (options.readOnly && !stateDb) {
1424
+ total += candidates.length;
1425
+ continue;
1426
+ }
1248
1427
  let seenMap = new Map();
1249
1428
  try {
1250
1429
  if (!stateDb) {