akm-cli 0.9.1 → 0.9.2-alpha.2

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 (350) hide show
  1. package/CHANGELOG.md +103 -28
  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/stash-skeleton/facts/conventions/backlinks.md +3 -4
  12. package/dist/assets/stash-skeleton/facts/conventions/organization.md +1 -3
  13. package/dist/assets/tasks/core/extract.yml +6 -5
  14. package/dist/assets/tasks/core/improve.yml +6 -5
  15. package/dist/assets/tasks/core/index-refresh.yml +6 -5
  16. package/dist/assets/tasks/core/sync.yml +6 -5
  17. package/dist/assets/tasks/core/version-check.yml +6 -5
  18. package/dist/assets/tasks/improve/akm-graph-refresh-weekly.yml +6 -5
  19. package/dist/assets/tasks/improve/akm-improve-catchup.yml +6 -5
  20. package/dist/assets/tasks/improve/akm-improve-consolidate.yml +6 -5
  21. package/dist/assets/tasks/improve/akm-improve-frequent.yml +6 -5
  22. package/dist/assets/tasks/improve/akm-improve-nightly.yml +6 -5
  23. package/dist/cli/confirm.js +2 -2
  24. package/dist/cli/parse-args.js +3 -24
  25. package/dist/cli/retired-commands.js +1 -1
  26. package/dist/cli/shared.js +2 -2
  27. package/dist/cli.js +11 -9
  28. package/dist/commands/agent/agent-dispatch.js +55 -89
  29. package/dist/commands/agent/contribute-cli.js +12 -45
  30. package/dist/commands/command/builtin-action.js +32 -0
  31. package/dist/commands/command/command-cli.js +99 -0
  32. package/dist/commands/command/command-execution.js +308 -0
  33. package/dist/commands/command/execution-source-loader.js +176 -0
  34. package/dist/commands/command/portable-template.js +60 -0
  35. package/dist/commands/config-cli.js +10 -4
  36. package/dist/commands/env/env.js +4 -2
  37. package/dist/commands/feedback-cli.js +1 -1
  38. package/dist/commands/health/checks.js +241 -29
  39. package/dist/commands/health/html-report.js +0 -14
  40. package/dist/commands/health/report-view-model.js +0 -1
  41. package/dist/commands/health/surfaces.js +6 -7
  42. package/dist/commands/health/types.js +0 -2
  43. package/dist/commands/health.js +63 -18
  44. package/dist/commands/improve/collapse-detector.js +5 -6
  45. package/dist/commands/improve/consolidate.js +251 -214
  46. package/dist/commands/improve/distill/promote-memory.js +71 -34
  47. package/dist/commands/improve/distill/quality-gate.js +17 -5
  48. package/dist/commands/improve/distill.js +232 -155
  49. package/dist/commands/improve/eligibility.js +112 -79
  50. package/dist/commands/improve/execution.js +57 -0
  51. package/dist/commands/improve/extract-cli.js +5 -5
  52. package/dist/commands/improve/extract-prompt.js +64 -22
  53. package/dist/commands/improve/extract.js +608 -360
  54. package/dist/commands/improve/improve-strategies.js +43 -14
  55. package/dist/commands/improve/improve.js +249 -29
  56. package/dist/commands/improve/loop-stages.js +11 -17
  57. package/dist/commands/improve/memory/memory-contradiction-detect.js +90 -66
  58. package/dist/commands/improve/outcome-loop.js +22 -38
  59. package/dist/commands/improve/planner.js +134 -0
  60. package/dist/commands/improve/preparation.js +730 -409
  61. package/dist/commands/improve/reflect.js +386 -223
  62. package/dist/commands/improve/run-context.js +3 -4
  63. package/dist/commands/improve/salience.js +6 -58
  64. package/dist/commands/improve/session-asset.js +12 -12
  65. package/dist/commands/lint/index.js +101 -29
  66. package/dist/commands/migrate-cli.js +11 -69
  67. package/dist/commands/migration-tool.js +6 -9
  68. package/dist/commands/models-cli.js +27 -0
  69. package/dist/commands/proposal/drain.js +258 -186
  70. package/dist/commands/proposal/proposal-cli.js +32 -10
  71. package/dist/commands/proposal/proposal.js +2 -5
  72. package/dist/commands/proposal/propose.js +192 -172
  73. package/dist/commands/proposal/repository.js +54 -91
  74. package/dist/commands/proposal/validators/proposal-validators.js +9 -7
  75. package/dist/commands/read/curate.js +53 -22
  76. package/dist/commands/read/registry-search.js +25 -9
  77. package/dist/commands/read/remember-cli.js +14 -2
  78. package/dist/commands/read/search.js +10 -4
  79. package/dist/commands/read/show.js +139 -153
  80. package/dist/commands/registry-cli.js +16 -7
  81. package/dist/commands/remember.js +33 -18
  82. package/dist/commands/sources/add-cli.js +19 -178
  83. package/dist/commands/sources/bundle-cli.js +15 -3
  84. package/dist/commands/sources/dangerous-env-audit.js +135 -0
  85. package/dist/commands/sources/info.js +2 -1
  86. package/dist/commands/sources/installed-stashes.js +901 -177
  87. package/dist/commands/sources/schema-repair.js +174 -95
  88. package/dist/commands/sources/self-update.js +30 -74
  89. package/dist/commands/sources/source-add.js +3 -5
  90. package/dist/commands/sources/sources-cli.js +2 -15
  91. package/dist/commands/sources/update-transaction.js +220 -0
  92. package/dist/commands/tasks/tasks-cli.js +3 -3
  93. package/dist/commands/tasks/tasks.js +736 -317
  94. package/dist/commands/workflow-cli.js +2 -2
  95. package/dist/core/adapter/adapters/agent-skills-adapter.js +3 -0
  96. package/dist/core/adapter/adapters/akm-adapter.js +85 -35
  97. package/dist/core/adapter/adapters/akm-lint.js +54 -39
  98. package/dist/core/adapter/adapters/akm-metadata.js +45 -45
  99. package/dist/core/adapter/adapters/akm-task-adapter.js +32 -49
  100. package/dist/core/adapter/adapters/akm-workflow-adapter.js +38 -23
  101. package/dist/core/adapter/adapters/dotenv-adapter.js +30 -1
  102. package/dist/core/adapter/adapters/generic-files-adapter.js +11 -0
  103. package/dist/core/adapter/adapters/index.js +0 -9
  104. package/dist/core/adapter/adapters/llm-wiki-adapter.js +4 -0
  105. package/dist/core/adapter/adapters/okf-adapter.js +4 -0
  106. package/dist/core/adapter/adapters/opencode-adapter.js +5 -8
  107. package/dist/core/adapter/adapters/tool-dir-shared.js +63 -6
  108. package/dist/core/adapter/adapters/website-snapshot-adapter.js +4 -0
  109. package/dist/core/adapter/execution-source.js +308 -0
  110. package/dist/core/adapter/recognize-match.js +36 -13
  111. package/dist/core/adapter/registry.js +0 -9
  112. package/dist/core/asset/stash-meta.js +94 -4
  113. package/dist/core/common.js +6 -11
  114. package/dist/core/config/config-io.js +3 -3
  115. package/dist/core/config/config-schema.js +18 -40
  116. package/dist/core/config/config-sources.js +11 -21
  117. package/dist/core/config/config-walker.js +31 -13
  118. package/dist/core/config/config.js +23 -26
  119. package/dist/core/config/schema/engines.js +8 -7
  120. package/dist/core/config/schema/improve-processes.js +29 -5
  121. package/dist/core/config/schema/index-config.js +0 -27
  122. package/dist/core/config/schema/primitives.js +1 -23
  123. package/dist/core/config/schema/sources-bundles.js +13 -16
  124. package/dist/core/errors.js +2 -0
  125. package/dist/core/events.js +68 -32
  126. package/dist/core/extra-params.js +1 -0
  127. package/dist/core/improve-result.js +315 -0
  128. package/dist/core/lesson-lint.js +0 -6
  129. package/dist/core/maintenance-barrier.js +4 -4
  130. package/dist/core/network-policy.js +152 -0
  131. package/dist/core/paths.js +1 -1
  132. package/dist/core/recognition-util.js +4 -4
  133. package/dist/core/registry-url.js +456 -0
  134. package/dist/core/state/migrations.js +161 -47
  135. package/dist/core/state-db.js +453 -80
  136. package/dist/core/system-error.js +32 -0
  137. package/dist/core/time.js +2 -12
  138. package/dist/core/write-source.js +0 -18
  139. package/dist/execution/directory-identity.js +52 -0
  140. package/dist/execution/executable-identity.js +107 -0
  141. package/dist/execution/guarded-source.js +398 -0
  142. package/dist/execution/json.js +95 -0
  143. package/dist/{commands/health/types-session-log.js → execution/limits.js} +2 -1
  144. package/dist/execution/record.js +55 -0
  145. package/dist/execution/resolved-request.js +730 -0
  146. package/dist/execution/source.js +320 -0
  147. package/dist/indexer/bundle-identity-guard.js +5 -4
  148. package/dist/indexer/db/graph-db.js +33 -0
  149. package/dist/indexer/graph/graph-boost.js +3 -4
  150. package/dist/indexer/graph/graph-extraction.js +562 -373
  151. package/dist/indexer/index-written-assets.js +78 -39
  152. package/dist/indexer/indexer.js +471 -432
  153. package/dist/indexer/installations.js +6 -0
  154. package/dist/indexer/lookup/adapter-concept-owner.js +283 -0
  155. package/dist/indexer/materialize-embeddings.js +155 -0
  156. package/dist/indexer/passes/memory-inference.js +227 -174
  157. package/dist/indexer/passes/metadata.js +263 -118
  158. package/dist/indexer/scan/doc-to-entry.js +7 -10
  159. package/dist/indexer/scan/drain-dir.js +51 -23
  160. package/dist/indexer/search/db-search.js +156 -50
  161. package/dist/indexer/search/fts-query.js +40 -40
  162. package/dist/indexer/search/ranking.js +36 -1
  163. package/dist/indexer/search/search-attribution.js +3 -1
  164. package/dist/indexer/search/search-fields.js +23 -14
  165. package/dist/indexer/search/search-hit-enrichers.js +1 -1
  166. package/dist/indexer/search/search-source.js +7 -16
  167. package/dist/indexer/search/semantic-status.js +10 -1
  168. package/dist/indexer/usage/show-usage.js +105 -0
  169. package/dist/indexer/usage/usage-events.js +7 -2
  170. package/dist/indexer/walk/matchers.js +40 -10
  171. package/dist/indexer/walk/path-resolver.js +5 -2
  172. package/dist/indexer/walk/walker.js +20 -2
  173. package/dist/integrations/agent/builder-shared.js +3 -6
  174. package/dist/integrations/agent/conversation-fallback.js +16 -0
  175. package/dist/integrations/agent/engine-resolution.js +87 -87
  176. package/dist/integrations/agent/execution-cascade.js +566 -0
  177. package/dist/integrations/agent/execution-definitions.js +211 -0
  178. package/dist/integrations/agent/execution-lowering.js +811 -0
  179. package/dist/integrations/agent/execution-preparation.js +67 -0
  180. package/dist/integrations/agent/index.js +0 -2
  181. package/dist/integrations/agent/inline-execution.js +74 -0
  182. package/dist/integrations/agent/model-map.js +515 -0
  183. package/dist/integrations/agent/persona-fallback.js +30 -0
  184. package/dist/integrations/agent/request-lowering.js +186 -0
  185. package/dist/integrations/agent/runner-dispatch.js +230 -37
  186. package/dist/integrations/agent/runner.js +12 -83
  187. package/dist/integrations/harnesses/aider/agent-builder.js +8 -0
  188. package/dist/integrations/harnesses/aider/index.js +0 -1
  189. package/dist/integrations/harnesses/amazonq/agent-builder.js +8 -0
  190. package/dist/integrations/harnesses/amazonq/index.js +0 -1
  191. package/dist/integrations/harnesses/claude/agent-builder.js +14 -1
  192. package/dist/integrations/harnesses/claude/index.js +1 -5
  193. package/dist/integrations/harnesses/claude/session-log.js +3 -33
  194. package/dist/integrations/harnesses/codex/agent-builder.js +8 -0
  195. package/dist/integrations/harnesses/codex/index.js +0 -1
  196. package/dist/integrations/harnesses/copilot/agent-builder.js +8 -0
  197. package/dist/integrations/harnesses/copilot/index.js +0 -1
  198. package/dist/integrations/harnesses/gemini/agent-builder.js +8 -0
  199. package/dist/integrations/harnesses/gemini/index.js +0 -1
  200. package/dist/integrations/harnesses/index.js +4 -44
  201. package/dist/integrations/harnesses/opencode/agent-builder.js +16 -9
  202. package/dist/integrations/harnesses/opencode/index.js +0 -2
  203. package/dist/integrations/harnesses/opencode/session-log.js +14 -204
  204. package/dist/integrations/harnesses/opencode-sdk/harness.js +12 -1
  205. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +40 -42
  206. package/dist/integrations/harnesses/openhands/agent-builder.js +8 -0
  207. package/dist/integrations/harnesses/openhands/index.js +0 -1
  208. package/dist/integrations/harnesses/pi/agent-builder.js +8 -0
  209. package/dist/integrations/harnesses/pi/index.js +0 -1
  210. package/dist/integrations/harnesses/shared.js +0 -1
  211. package/dist/integrations/harnesses/types.js +1 -3
  212. package/dist/integrations/lockfile.js +82 -79
  213. package/dist/integrations/session-logs/index.js +6 -17
  214. package/dist/integrations/session-logs/provider-base.js +1 -29
  215. package/dist/llm/client.js +10 -5
  216. package/dist/llm/embedder.js +6 -7
  217. package/dist/llm/embedders/local.js +37 -88
  218. package/dist/llm/embedders/types.js +1 -1
  219. package/dist/llm/graph-extract.js +75 -50
  220. package/dist/llm/index-passes.js +43 -5
  221. package/dist/llm/memory-infer.js +8 -6
  222. package/dist/llm/metadata-enhance.js +5 -3
  223. package/dist/llm/structured-call.js +122 -25
  224. package/dist/output/format-exempt.js +1 -1
  225. package/dist/output/render-registry.js +0 -16
  226. package/dist/output/renderers.js +12 -7
  227. package/dist/output/shapes/curate.js +1 -0
  228. package/dist/output/shapes/helpers.js +10 -2
  229. package/dist/output/shapes/passthrough.js +2 -0
  230. package/dist/output/text/command-format.js +31 -33
  231. package/dist/output/text/health-format.js +1 -29
  232. package/dist/output/text/migrate.js +6 -56
  233. package/dist/output/text/proposal-format.js +16 -1
  234. package/dist/output/text/workflow-format.js +16 -0
  235. package/dist/registry/network.js +279 -0
  236. package/dist/registry/pinned-request-helper.js +247 -0
  237. package/dist/registry/pinned-transport.js +717 -0
  238. package/dist/registry/providers/skills-sh.js +18 -6
  239. package/dist/registry/providers/static-index.js +20 -7
  240. package/dist/registry/resolve.js +53 -28
  241. package/dist/scripts/akm-migrate-node.js +19334 -52269
  242. package/dist/scripts/akm-migrate.js +19270 -51612
  243. package/dist/setup/registry-stash-loader.js +64 -20
  244. package/dist/setup/semantic-assets.js +9 -34
  245. package/dist/setup/setup.js +12 -30
  246. package/dist/setup/source-identity.js +17 -0
  247. package/dist/setup/steps/sources.js +36 -15
  248. package/dist/setup/steps/tasks.js +39 -11
  249. package/dist/sources/providers/git-provider.js +3 -3
  250. package/dist/sources/providers/npm.js +2 -2
  251. package/dist/sources/providers/provider-utils.js +4 -3
  252. package/dist/sources/providers/website.js +11 -7
  253. package/dist/sources/snapshot-fetchers/host-guard.js +9 -136
  254. package/dist/sources/snapshot-fetchers/website-ingest.js +25 -109
  255. package/dist/sources/website-url.js +73 -0
  256. package/dist/storage/engines/sqlite-migrations.js +81 -26
  257. package/dist/storage/managed-db.js +27 -24
  258. package/dist/storage/repositories/events-repository.js +3 -0
  259. package/dist/storage/repositories/index-connection.js +42 -10
  260. package/dist/storage/repositories/index-entries-repository.js +203 -229
  261. package/dist/storage/repositories/index-entry-mapper.js +8 -12
  262. package/dist/storage/repositories/index-entry-schema.js +255 -0
  263. package/dist/storage/repositories/index-fts-repository.js +64 -71
  264. package/dist/storage/repositories/index-llm-cache-repository.js +8 -13
  265. package/dist/storage/repositories/index-meta-repository.js +0 -11
  266. package/dist/storage/repositories/index-schema.js +74 -350
  267. package/dist/storage/repositories/index-utility-repository.js +12 -17
  268. package/dist/storage/repositories/index-vec-repository.js +56 -7
  269. package/dist/storage/repositories/proposals-repository.js +4 -127
  270. package/dist/storage/repositories/registry-cache.js +2 -1
  271. package/dist/storage/repositories/task-history-repository.js +20 -40
  272. package/dist/storage/repositories/workflow-runs-repository.js +228 -129
  273. package/dist/storage/sqlite-read-snapshot.js +148 -0
  274. package/dist/tasks/backends/cron.js +170 -42
  275. package/dist/tasks/backends/index.js +1 -1
  276. package/dist/tasks/backends/launchd.js +787 -202
  277. package/dist/tasks/backends/schtasks.js +282 -83
  278. package/dist/tasks/embedded.js +7 -7
  279. package/dist/tasks/frozen-script.js +50 -0
  280. package/dist/tasks/resolve-akm-bin.js +5 -1
  281. package/dist/tasks/runner.js +239 -251
  282. package/dist/tasks/runtime-v3.js +281 -0
  283. package/dist/tasks/scheduler-binding.js +272 -0
  284. package/dist/tasks/scheduler-invocation.js +57 -43
  285. package/dist/tasks/scheduler-sync.js +654 -0
  286. package/dist/tasks/source-v3.js +752 -0
  287. package/dist/tasks/standalone-script-entry.js +5 -0
  288. package/dist/tasks/task-id.js +29 -0
  289. package/dist/workflows/authoring/authoring.js +15 -32
  290. package/dist/workflows/exec/dispatch-redaction.js +14 -8
  291. package/dist/workflows/exec/exec-unit.js +7 -28
  292. package/dist/workflows/exec/frozen-judge.js +57 -89
  293. package/dist/workflows/exec/lowering-notices.js +23 -0
  294. package/dist/workflows/exec/native-executor.js +301 -458
  295. package/dist/workflows/exec/param-secrets.js +4 -3
  296. package/dist/workflows/exec/run-workflow.js +26 -32
  297. package/dist/workflows/exec/step-work.js +105 -109
  298. package/dist/workflows/exec/unit-dispatch.js +103 -27
  299. package/dist/workflows/exec/unit-writer.js +3 -3
  300. package/dist/workflows/exec/worktree.js +2 -2
  301. package/dist/workflows/ir/compile.js +86 -72
  302. package/dist/workflows/ir/environment-v4.js +328 -0
  303. package/dist/workflows/ir/freeze-v4.js +122 -0
  304. package/dist/workflows/ir/plan-hash.js +13 -7
  305. package/dist/workflows/ir/schema-v4.js +525 -0
  306. package/dist/workflows/ir/schema.js +25 -284
  307. package/dist/workflows/ir/source-freeze-v4.js +506 -0
  308. package/dist/workflows/parser.js +27 -24
  309. package/dist/workflows/program/schema.js +1 -2
  310. package/dist/workflows/renderer.js +42 -29
  311. package/dist/workflows/resource-limits.js +4 -5
  312. package/dist/workflows/runtime/agent-identity.js +11 -13
  313. package/dist/workflows/runtime/plan-classifier.js +8 -8
  314. package/dist/workflows/runtime/runs.js +27 -43
  315. package/dist/workflows/runtime/workflow-asset-loader.js +45 -205
  316. package/dist/workflows/source-files.js +373 -0
  317. package/dist/workflows/source-ir/compile.js +196 -0
  318. package/dist/workflows/source-ir/github-yaml.js +577 -0
  319. package/dist/workflows/source-ir/ordering.js +38 -0
  320. package/dist/workflows/source-ir/program.js +50 -0
  321. package/dist/workflows/source-ir/result.js +26 -0
  322. package/dist/workflows/source-ir/schema.js +772 -0
  323. package/dist/workflows/source-ir/semantics.js +242 -0
  324. package/dist/workflows/source-ir/uses.js +14 -0
  325. package/docs/README.md +2 -0
  326. package/docs/migration/README.md +3 -1
  327. package/docs/migration/release-notes/0.9.2.md +55 -0
  328. package/docs/migration/release-notes/README.md +5 -0
  329. package/docs/migration/v0.8-to-v0.9.md +76 -1077
  330. package/docs/migration/v0.9.0-troubleshooting.md +104 -516
  331. package/docs/migration/v0.9.1-to-v0.9.2.md +150 -0
  332. package/docs/reference/README.md +1 -0
  333. package/docs/reference/cli.md +230 -98
  334. package/docs/reference/configuration.md +159 -36
  335. package/docs/reference/data-and-telemetry.md +19 -1
  336. package/docs/reference/supported-formats.md +23 -3
  337. package/docs/reference/tasks.md +182 -0
  338. package/docs/reference/workflow-schema.md +91 -40
  339. package/docs/reference/workflows.md +33 -6
  340. package/package.json +10 -6
  341. package/schemas/akm-config.json +372 -224
  342. package/schemas/akm-task.json +324 -80
  343. package/schemas/akm-workflow.json +6 -9
  344. package/dist/core/migration-operation.js +0 -75
  345. package/dist/integrations/agent/model-aliases.js +0 -74
  346. package/dist/tasks/parser.js +0 -380
  347. package/dist/tasks/schema.js +0 -123
  348. package/dist/tasks/validator.js +0 -80
  349. package/dist/workflows/ir/freeze.js +0 -320
  350. 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`.
@@ -32,23 +32,25 @@ import { ConfigError, UsageError } from "../../core/errors.js";
32
32
  import { appendEvent } from "../../core/events.js";
33
33
  import { createLockPayload, probeLock, reclaimStaleLock, releaseLock, tryAcquireLockSync, } from "../../core/file-lock.js";
34
34
  import { tryAcquireMaintenanceBarrier } from "../../core/maintenance-barrier.js";
35
+ import { redactErrorBody } from "../../core/redaction.js";
35
36
  import { resolveStashStandards } from "../../core/standards/resolve-stash-standards.js";
36
37
  import { resolveTypeConventions, typeConventionRef } from "../../core/standards/resolve-type-conventions.js";
37
- import { getStateDbPath, openStateDatabase, withStateDb } from "../../core/state-db.js";
38
+ import { getStateDbPath, openStateDatabase } from "../../core/state-db.js";
39
+ import { runStructured } from "../../core/structured.js";
38
40
  import { repairTruncatedDescription } from "../../core/text-truncation.js";
39
41
  import { DURATION_UNITS, parseDuration } from "../../core/time.js";
40
- import { warn } from "../../core/warn.js";
42
+ import { warn, warnVerbose } from "../../core/warn.js";
41
43
  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";
44
+ import { disposeLoweredExecutionDispatchLease, } from "../../integrations/agent/execution-lowering.js";
45
45
  import { getAvailableHarnesses } from "../../integrations/session-logs/index.js";
46
46
  import { preFilterSession } from "../../integrations/session-logs/pre-filter.js";
47
- import { callStructured } from "../../llm/structured-call.js";
47
+ import { callStructured, preflightStructuredLlmRunner } from "../../llm/structured-call.js";
48
48
  import { sha256Hex } from "../../runtime.js";
49
49
  import { getExtractedSessionsMap, getLastExtractRunAt, shouldSkipAlreadyExtractedSession, upsertExtractedSession, } from "../../storage/repositories/extract-sessions-repository.js";
50
+ import { openSqliteReadSnapshot } from "../../storage/sqlite-read-snapshot.js";
50
51
  import { isProposalSkipped } from "../proposal/repository.js";
51
- import { buildExtractPrompt, EXTRACT_JSON_SCHEMA, parseExtractPayload } from "./extract-prompt.js";
52
+ import { resolveImproveLlmExecution } from "./execution.js";
53
+ import { buildExtractPrompt, EXTRACT_JSON_SCHEMA, parseExtractPayload, } from "./extract-prompt.js";
52
54
  import { resolveImproveStrategy, resolveProcessEnabled } from "./improve-strategies.js";
53
55
  import { emitProposal } from "./proposal-envelope.js";
54
56
  import { createRunContext, resolveRunStashDir } from "./run-context.js";
@@ -99,21 +101,35 @@ function resolveDefaultSinceMs(harnessName, now, opts) {
99
101
  const floor = now - DEFAULT_SINCE_FLOOR_MS;
100
102
  if (opts.skipTracking)
101
103
  return floor;
104
+ let snapshot;
102
105
  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 });
106
+ let db = opts.stateDb;
107
+ if (!db) {
108
+ snapshot = openSqliteReadSnapshot(opts.stateDbPath ?? getStateDbPath());
109
+ db = snapshot;
110
+ }
111
+ if (!db)
112
+ return floor;
113
+ const lastRun = getLastExtractRunAt(db, harnessName);
114
+ return lastRun != null ? Math.min(lastRun, floor) : floor;
107
115
  }
108
116
  catch {
109
117
  return floor;
110
118
  }
119
+ finally {
120
+ snapshot?.close();
121
+ }
111
122
  }
112
123
  /** Filesystem-safe per-session lock path, co-located with the state.db. */
113
124
  function getExtractSessionLockPath(harness, sessionId, stateDbPath) {
114
125
  const safe = `${harness}-${sessionId}`.replace(/[^A-Za-z0-9._-]/g, "_");
115
126
  return path.join(path.dirname(stateDbPath), "extract-locks", `extract-${safe}.lock`);
116
127
  }
128
+ function extractSessionLockIsUnavailable(harness, sessionId, stateDbPath) {
129
+ const lockPath = getExtractSessionLockPath(harness, sessionId, stateDbPath);
130
+ const probe = probeLock(lockPath, { staleAfterMs: EXTRACT_SESSION_LOCK_STALE_MS });
131
+ return probe.state === "held" || probe.state === "inaccessible";
132
+ }
117
133
  /**
118
134
  * Try to claim the per-session extract lock so a concurrent extract (e.g. a
119
135
  * session-end hook firing `--session-id` while the hourly improve pass runs
@@ -172,27 +188,28 @@ export function resolveStandaloneExtractPlan(config, selection) {
172
188
  ...(selection.engine ? { engine: selection.engine } : {}),
173
189
  ...(Object.hasOwn(selection, "timeoutMs") ? { timeoutMs: selection.timeoutMs ?? null } : {}),
174
190
  };
175
- const resolved = resolveLlmEngineUse(config, [selected.config, process, invocation], { optional: true });
191
+ const resolved = resolveImproveLlmExecution({
192
+ config,
193
+ profile: selected.config,
194
+ process,
195
+ current: invocation,
196
+ processName: "extract",
197
+ });
176
198
  if (!resolved) {
177
199
  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
200
  }
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
- };
201
+ const runner = resolved.runner;
186
202
  return Object.freeze({
187
203
  strategy: selected.name,
188
- engine: resolved.engine,
204
+ engine: runner.engine,
189
205
  // `akm extract` is an explicit operation. The strategy supplies behavior,
190
206
  // but its improve-stage enablement gate does not disable this command.
191
207
  enabled: true,
192
208
  process,
193
209
  runner: cloneAndFreeze(runner),
194
- timeoutMs: resolved.timeoutMs,
210
+ timeoutMs: Object.hasOwn(runner, "timeoutMs") ? (runner.timeoutMs ?? null) : 600_000,
195
211
  embeddingConfig: cloneAndFreeze(config.embedding),
212
+ ...(resolved.notices.length > 0 ? { notices: cloneAndFreeze(resolved.notices) } : {}),
196
213
  });
197
214
  }
198
215
  // ── Helpers ──────────────────────────────────────────────────────────────────
@@ -233,15 +250,7 @@ export function parseSinceArg(value, now = Date.now()) {
233
250
  */
234
251
  function resolveHarness(type, harnesses) {
235
252
  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);
253
+ return pool.find((h) => h.name === type);
245
254
  }
246
255
  /**
247
256
  * Build the ref + content for a candidate. The body must contain a
@@ -366,19 +375,7 @@ function runPreLlmSessionGates(args) {
366
375
  // `--force` overrides it to re-extract a previously-extracted session.
367
376
  const contentHash = hashSessionContent(data);
368
377
  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
- };
378
+ return { skip: alreadyExtractedResult(harness.name, sessionRef.sessionId, prior, contentHash) };
382
379
  }
383
380
  const filtered = preFilterSession(data, {
384
381
  ...(typeof maxTotalChars === "number" ? { maxTotalChars } : {}),
@@ -440,57 +437,180 @@ function runPreLlmSessionGates(args) {
440
437
  }
441
438
  return { data, filtered, contentHash };
442
439
  }
443
- /**
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`
449
- * side-channel value that distinguishes fallback-took-over from a
450
- * genuinely-empty response.
451
- */
440
+ function alreadyExtractedResult(harness, sessionId, prior, contentHash) {
441
+ return {
442
+ sessionId,
443
+ harness,
444
+ candidateCount: 0,
445
+ proposalIds: [],
446
+ preFilter: { inputCount: 0, outputCount: 0, truncatedCount: 0 },
447
+ warnings: [`already extracted (content unchanged) at ${prior?.processed_at}; pass --force to re-process`],
448
+ skipped: true,
449
+ skipReason: "already_extracted",
450
+ contentHash,
451
+ };
452
+ }
453
+ function lockedConcurrentResult(harness, summary) {
454
+ return {
455
+ sessionId: summary.sessionId,
456
+ harness,
457
+ candidateCount: 0,
458
+ proposalIds: [],
459
+ preFilter: { inputCount: 0, outputCount: 0, truncatedCount: 0 },
460
+ warnings: ["concurrent extract holds this session's lock — skipped (handled by the other run)"],
461
+ skipped: true,
462
+ skipReason: "locked_concurrent",
463
+ };
464
+ }
465
+ function planExtractSessions(args) {
466
+ const { candidates, options, harness, seenMap, maxSessionsPerRun, trackingEnabled, dryRun } = args;
467
+ const plans = [];
468
+ let modelCount = 0;
469
+ for (let index = 0; index < candidates.length; index++) {
470
+ if (options.signal?.aborted)
471
+ return { plans, deferredCandidates: candidates.slice(index) };
472
+ if (!options.sessionId && !options.force && maxSessionsPerRun > 0 && modelCount >= maxSessionsPerRun) {
473
+ return { plans, deferredCandidates: candidates.slice(index) };
474
+ }
475
+ const summary = candidates[index];
476
+ if (!summary)
477
+ continue;
478
+ if (trackingEnabled && !dryRun && !options.stateDb) {
479
+ if (extractSessionLockIsUnavailable(harness.name, summary.sessionId, options.stateDbPath ?? getStateDbPath())) {
480
+ plans.push({ kind: "skip", summary, result: lockedConcurrentResult(harness.name, summary) });
481
+ continue;
482
+ }
483
+ }
484
+ const gate = runPreLlmSessionGates({
485
+ harness,
486
+ sessionRef: summary,
487
+ prior: seenMap.get(summary.sessionId),
488
+ force: options.force === true,
489
+ maxTotalChars: args.maxTotalChars,
490
+ minContentChars: args.minContentChars,
491
+ triage: args.triage,
492
+ });
493
+ if ("skip" in gate) {
494
+ plans.push({ kind: "skip", summary, result: gate.skip });
495
+ continue;
496
+ }
497
+ // Reading and classifying a session can take long enough for a concurrent
498
+ // session-end hook to claim its lock. Re-probe the fully classified model
499
+ // plan before it consumes a cap slot or forces credential materialization.
500
+ if (trackingEnabled &&
501
+ !dryRun &&
502
+ !options.stateDb &&
503
+ extractSessionLockIsUnavailable(harness.name, summary.sessionId, options.stateDbPath ?? getStateDbPath())) {
504
+ plans.push({ kind: "skip", summary, result: lockedConcurrentResult(harness.name, summary) });
505
+ continue;
506
+ }
507
+ plans.push({ kind: "model", summary, gate });
508
+ modelCount += 1;
509
+ }
510
+ return { plans, deferredCandidates: [] };
511
+ }
512
+ const EXTRACT_LLM_UNAVAILABLE = Symbol("extract-llm-unavailable");
452
513
  async function runSessionExtractionLlmCall(args) {
453
- const { config, getLlmConfig, chat, prompt, timeoutMs, signal } = args;
454
- let extractLlm;
514
+ const { config, llmRunner, lease, chat, prompt, timeoutMs, signal, onNotices } = args;
455
515
  try {
456
- extractLlm = getLlmConfig();
457
- }
458
- catch {
459
- extractLlm = undefined;
460
- }
461
- 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 } : {}),
516
+ const result = await runStructured({
517
+ dispatch: async (feedback) => {
518
+ const content = feedback ? `${prompt}\n\n## Corrective output instruction\n\n${feedback}` : prompt;
519
+ const dispatched = await callStructured({
520
+ feature: "session_extraction",
521
+ akmConfig: config,
522
+ runner: llmRunner,
523
+ lease,
524
+ messages: [{ role: "user", content }],
525
+ request: {
526
+ timeoutMs,
527
+ responseSchema: EXTRACT_JSON_SCHEMA,
528
+ ...(signal ? { signal } : {}),
529
+ ...(chat ? { chat } : {}),
530
+ },
531
+ onNotices,
532
+ parse: (raw) => ({ kind: "response", raw: raw ?? "" }),
533
+ onError: () => ({ kind: "unavailable" }),
534
+ fallback: { kind: "unavailable" },
535
+ });
536
+ if (dispatched.kind === "unavailable")
537
+ throw EXTRACT_LLM_UNAVAILABLE;
538
+ return dispatched.raw;
474
539
  },
475
540
  parse: (raw) => {
476
- llmRaw = raw ?? "";
477
- return llmRaw;
541
+ const payload = parseExtractPayload(raw);
542
+ return payload.parseFailure ? undefined : payload;
478
543
  },
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: "",
544
+ validate: (payload) => ({ ok: true, value: payload }),
545
+ maxAttempts: llmRunner.connection.supportsJsonSchema === true ? 1 : 2,
546
+ buildFeedback: () => "Your previous response did not contain a valid extraction payload. Respond with ONLY a JSON object matching the requested schema, with a candidates array and no prose or code fences.",
483
547
  });
484
- return { llmResult, llmRaw };
548
+ if (result.ok)
549
+ return { kind: "success", payload: result.value, attempts: result.attempts };
550
+ const payload = parseExtractPayload(result.raw);
551
+ return {
552
+ kind: "malformed",
553
+ raw: result.raw,
554
+ attempts: result.attempts,
555
+ failure: payload.parseFailure ??
556
+ { code: "invalid_payload", message: result.errors.join("; ") },
557
+ };
558
+ }
559
+ catch (err) {
560
+ if (err === EXTRACT_LLM_UNAVAILABLE)
561
+ return { kind: "unavailable" };
562
+ throw err;
563
+ }
564
+ }
565
+ function extractNoticeFields(getNotices) {
566
+ const notices = getNotices();
567
+ return notices.length > 0 ? { notices } : {};
568
+ }
569
+ function extractPreFilterStats(filtered) {
570
+ return {
571
+ inputCount: filtered.stats.inputCount,
572
+ outputCount: filtered.stats.outputCount,
573
+ truncatedCount: filtered.stats.truncatedCount,
574
+ };
575
+ }
576
+ function malformedExtractionResult(args) {
577
+ const { extraction, sessionRef, harness, preFilter, contentHash, notices } = args;
578
+ const diagnostic = `malformed_model_output: ${extraction.failure.message}; attempts=${extraction.attempts}; responseLength=${extraction.raw.length}; responseSha256=${sha256Hex(extraction.raw)}`;
579
+ warnVerbose(`[extract] malformed model output for session ${sessionRef.sessionId}: ${redactErrorBody(extraction.raw)}`);
580
+ return {
581
+ sessionId: sessionRef.sessionId,
582
+ harness,
583
+ candidateCount: 0,
584
+ proposalIds: [],
585
+ preFilter,
586
+ warnings: [diagnostic],
587
+ skipped: true,
588
+ skipReason: "malformed_model_output",
589
+ contentHash,
590
+ ...notices,
591
+ };
592
+ }
593
+ function unavailableExtractionResult(args) {
594
+ return {
595
+ sessionId: args.sessionRef.sessionId,
596
+ harness: args.harness,
597
+ candidateCount: 0,
598
+ proposalIds: [],
599
+ preFilter: args.preFilter,
600
+ warnings: ["session_extraction feature returned empty (disabled / timeout / error)"],
601
+ skipped: true,
602
+ skipReason: "llm_unavailable",
603
+ contentHash: args.contentHash,
604
+ ...args.notices,
605
+ };
485
606
  }
486
607
  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;
608
+ const { harness, stashDir, config, llmRunner, lease, onNotices, getNotices, chat, ctx, eventsCtx, sourceRun, dryRun, timeoutMs, sessionIndexing, signal, standardsContext, } = runCtx;
609
+ const { sessionRef, gate } = session;
489
610
  const warnings = [];
490
- const gate = runPreLlmSessionGates({ harness, sessionRef, prior, force, maxTotalChars, minContentChars, triage });
491
- if ("skip" in gate)
492
- return gate.skip;
493
611
  const { data, filtered, contentHash } = gate;
612
+ if (!lease)
613
+ throw new TypeError("extract model work requires an operation dispatch lease");
494
614
  const prompt = buildExtractPrompt({
495
615
  data,
496
616
  events: filtered.events,
@@ -508,7 +628,7 @@ async function processSession(runCtx, session) {
508
628
  if (!sessionMeetsDurationGate(data, sessionIndexing.minDurationMinutes))
509
629
  return {};
510
630
  try {
511
- const result = await writeSessionAsset(data, stashDir, sessionIndexing.generate);
631
+ const result = await writeSessionAsset(data, stashDir, (summaryData) => sessionIndexing.generate(summaryData, lease));
512
632
  if (result.written) {
513
633
  // Write-path indexing (itself fail-open): standalone `akm extract`
514
634
  // (session-end hook) has no post-loop reindex to pick this file up.
@@ -521,37 +641,43 @@ async function processSession(runCtx, session) {
521
641
  }
522
642
  }
523
643
  catch (err) {
644
+ if (err instanceof ConfigError)
645
+ throw err;
524
646
  warnings.push(`session asset write failed: ${err instanceof Error ? err.message : String(err)}`);
525
647
  }
526
648
  return {};
527
649
  };
528
- const { llmResult, llmRaw } = await runSessionExtractionLlmCall({
650
+ const extraction = await runSessionExtractionLlmCall({
529
651
  config,
530
- getLlmConfig,
652
+ llmRunner,
653
+ lease,
531
654
  chat,
532
655
  prompt,
533
656
  timeoutMs,
534
657
  signal,
658
+ onNotices,
535
659
  });
536
- if (llmResult === "" && !llmRaw) {
660
+ if (extraction.kind === "unavailable") {
537
661
  // The seam took the fallback path (disabled / timeout / error). Return skipped.
538
- return {
539
- sessionId: sessionRef.sessionId,
662
+ return unavailableExtractionResult({
663
+ sessionRef,
540
664
  harness: harness.name,
541
- candidateCount: 0,
542
- proposalIds: [],
543
- preFilter: {
544
- inputCount: filtered.stats.inputCount,
545
- outputCount: filtered.stats.outputCount,
546
- truncatedCount: filtered.stats.truncatedCount,
547
- },
548
- warnings: ["session_extraction feature returned empty (disabled / timeout / error)"],
549
- skipped: true,
550
- skipReason: "llm_unavailable",
665
+ preFilter: extractPreFilterStats(filtered),
551
666
  contentHash,
552
- };
667
+ notices: extractNoticeFields(getNotices),
668
+ });
669
+ }
670
+ if (extraction.kind === "malformed") {
671
+ return malformedExtractionResult({
672
+ extraction,
673
+ sessionRef,
674
+ harness: harness.name,
675
+ preFilter: extractPreFilterStats(filtered),
676
+ contentHash,
677
+ notices: extractNoticeFields(getNotices),
678
+ });
553
679
  }
554
- const payload = parseExtractPayload(llmRaw);
680
+ const { payload } = extraction;
555
681
  const proposalIds = [];
556
682
  // Provenance refs are added only after the cited session asset exists.
557
683
  const sessionAsset = await maybeWriteSessionAsset();
@@ -565,6 +691,7 @@ async function processSession(runCtx, session) {
565
691
  harness: harness.name,
566
692
  sourceRun,
567
693
  rationale: payload.rationale_if_empty,
694
+ repairAttempts: extraction.attempts - 1,
568
695
  preFilterInput: filtered.stats.inputCount,
569
696
  preFilterOutput: filtered.stats.outputCount,
570
697
  },
@@ -583,17 +710,12 @@ async function processSession(runCtx, session) {
583
710
  warnings,
584
711
  contentHash,
585
712
  ...sessionAsset,
713
+ ...extractNoticeFields(getNotices),
586
714
  };
587
715
  }
588
716
  // §23.6 fingerprint model-id term: the profile resolved for this session's
589
717
  // 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
- }
718
+ const extractModelId = llmRunner.connection.model;
597
719
  for (const candidate of payload.candidates) {
598
720
  const built = buildCandidateProposal(candidate, data.ref, sessionAsset.sessionAssetRef);
599
721
  if (dryRun) {
@@ -643,6 +765,7 @@ async function processSession(runCtx, session) {
643
765
  proposalCount: proposalIds.length,
644
766
  preFilterInput: filtered.stats.inputCount,
645
767
  preFilterOutput: filtered.stats.outputCount,
768
+ repairAttempts: extraction.attempts - 1,
646
769
  },
647
770
  }, eventsCtx);
648
771
  return {
@@ -658,8 +781,77 @@ async function processSession(runCtx, session) {
658
781
  warnings,
659
782
  contentHash,
660
783
  ...sessionAsset,
784
+ ...extractNoticeFields(getNotices),
661
785
  };
662
786
  }
787
+ function recordExtractSessionOutcome(args) {
788
+ const { stateDb, trackingEnabled, dryRun, harness, summary, result, sourceRun } = args;
789
+ if (!trackingEnabled ||
790
+ !stateDb ||
791
+ dryRun ||
792
+ result.skipReason === "already_extracted" ||
793
+ result.skipReason === "locked_concurrent")
794
+ return;
795
+ try {
796
+ const outcome = result.skipped
797
+ ? result.skipReason === "read_failed" ||
798
+ result.skipReason === "exception" ||
799
+ result.skipReason === "malformed_model_output"
800
+ ? "failed"
801
+ : "skipped"
802
+ : result.candidateCount === 0
803
+ ? "no_candidates"
804
+ : "candidates_queued";
805
+ upsertExtractedSession(stateDb, {
806
+ harness,
807
+ sessionId: summary.sessionId,
808
+ processedAt: new Date().toISOString(),
809
+ sessionEndedAt: summary.endedAt ?? null,
810
+ outcome,
811
+ candidateCount: result.candidateCount,
812
+ proposalCount: result.proposalIds.length,
813
+ rationale: result.rationaleIfEmpty ?? null,
814
+ sourceRun,
815
+ contentHash: result.skipReason === "llm_unavailable" ||
816
+ result.skipReason === "triaged_out" ||
817
+ result.skipReason === "malformed_model_output"
818
+ ? null
819
+ : (result.contentHash ?? null),
820
+ metadata: {
821
+ preFilterInputCount: result.preFilter.inputCount,
822
+ preFilterOutputCount: result.preFilter.outputCount,
823
+ preFilterTruncatedCount: result.preFilter.truncatedCount,
824
+ ...(result.skipReason ? { skipReason: result.skipReason } : {}),
825
+ ...(result.sessionLogPath ? { logPath: result.sessionLogPath } : {}),
826
+ ...(result.sessionAssetRef ? { sessionAssetRef: result.sessionAssetRef } : {}),
827
+ },
828
+ });
829
+ }
830
+ catch (err) {
831
+ warn(`[extract] failed to record session ${summary.sessionId} in state.db: ${err instanceof Error ? err.message : String(err)}`);
832
+ }
833
+ }
834
+ function accountExtractSessionResult(result, triageEnabled, output) {
835
+ output.sessions.push(result);
836
+ if (triageEnabled) {
837
+ const preempted = result.skipReason === "read_failed" ||
838
+ result.skipReason === "too_short" ||
839
+ result.skipReason === "already_extracted" ||
840
+ result.skipReason === "locked_concurrent";
841
+ if (!preempted) {
842
+ output.triageEvaluated += 1;
843
+ if (result.skipReason === "triaged_out")
844
+ output.triagedOut += 1;
845
+ else
846
+ output.triagePassed += 1;
847
+ }
848
+ }
849
+ if (result.skipped)
850
+ output.skippedCount += 1;
851
+ else
852
+ output.processedCount += 1;
853
+ output.allProposalIds.push(...result.proposalIds);
854
+ }
663
855
  /**
664
856
  * Iterate the discovered candidate sessions: enforce the per-run cap, take the
665
857
  * per-session cross-process lock, dispatch to {@link processSession}, aggregate
@@ -668,164 +860,141 @@ async function processSession(runCtx, session) {
668
860
  * aggregation, and seen-row upsert are byte-identical.
669
861
  */
670
862
  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;
863
+ const { plans, deferredCandidates, seenMap, options, harness, stateDb, trackingEnabled, dryRun, stashDir, config, llmRunner, lease, onNotices, getNotices, chat, sourceRun, timeoutMs, triage, sessionIndexing, extractStandardsContext, topLevelWarnings, } = args;
672
864
  // WI-7.7 §2: run-scoped processSession inputs, resolved once per run.
673
865
  const sessionRunCtx = {
674
866
  harness,
675
867
  stashDir,
676
868
  config,
677
- getLlmConfig,
869
+ llmRunner,
870
+ lease,
871
+ onNotices,
872
+ getNotices,
678
873
  chat,
679
874
  ctx: options.ctx,
680
875
  eventsCtx: options.eventsCtx,
681
876
  sourceRun,
682
877
  dryRun,
683
878
  timeoutMs,
684
- maxTotalChars,
685
- minContentChars,
686
- triage,
687
879
  sessionIndexing,
688
880
  signal: options.signal,
689
881
  standardsContext: extractStandardsContext,
690
882
  };
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) {
883
+ const output = {
884
+ sessions: [],
885
+ processedCount: 0,
886
+ skippedCount: 0,
887
+ triageEvaluated: 0,
888
+ triagePassed: 0,
889
+ triagedOut: 0,
890
+ allProposalIds: [],
891
+ deferred: 0,
892
+ };
893
+ const workPlans = [...plans];
894
+ let remainingCandidates = deferredCandidates;
895
+ const refillModelSlot = () => {
896
+ if (remainingCandidates.length === 0 || options.signal?.aborted)
897
+ return;
898
+ const refill = planExtractSessions({
899
+ candidates: remainingCandidates,
900
+ options,
901
+ harness,
902
+ seenMap,
903
+ maxTotalChars: args.maxTotalChars,
904
+ minContentChars: args.minContentChars,
905
+ maxSessionsPerRun: 1,
906
+ triage,
907
+ trackingEnabled,
908
+ dryRun,
909
+ });
910
+ workPlans.push(...refill.plans);
911
+ remainingCandidates = refill.deferredCandidates;
912
+ };
913
+ for (const plan of workPlans) {
700
914
  if (options.signal?.aborted)
701
915
  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;
916
+ const { summary } = plan;
917
+ if (plan.kind === "skip") {
918
+ accountExtractSessionResult(plan.result, triage.enabled, output);
919
+ recordExtractSessionOutcome({
920
+ stateDb,
921
+ trackingEnabled,
922
+ dryRun,
923
+ harness: harness.name,
924
+ summary,
925
+ result: plan.result,
926
+ sourceRun,
927
+ });
928
+ continue;
713
929
  }
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
930
  let sessionLockOwnership;
722
931
  if (trackingEnabled && !dryRun && !options.stateDb) {
723
932
  const sessionLockPath = getExtractSessionLockPath(harness.name, summary.sessionId, options.stateDbPath ?? getStateDbPath());
724
933
  const sessionLock = acquireExtractSessionLock(sessionLockPath);
725
934
  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;
935
+ accountExtractSessionResult(lockedConcurrentResult(harness.name, summary), triage.enabled, output);
936
+ refillModelSlot();
737
937
  continue;
738
938
  }
739
939
  sessionLockOwnership = sessionLock.ownership;
740
940
  }
741
941
  try {
742
- const result = await processSession(sessionRunCtx, {
942
+ // Planning stays read-only so a credential failure creates no state. Once
943
+ // this run owns the session lock, read and gate the session again: the log
944
+ // may have grown, become too short after replacement, or been completed by
945
+ // another extractor between the planning snapshot and acquisition.
946
+ const currentPrior = stateDb
947
+ ? getExtractedSessionsMap(stateDb, harness.name, [summary.sessionId]).get(summary.sessionId)
948
+ : seenMap.get(summary.sessionId);
949
+ const executionGate = runPreLlmSessionGates({
950
+ harness,
743
951
  sessionRef: summary,
744
- prior,
952
+ prior: currentPrior,
745
953
  force: options.force === true,
954
+ maxTotalChars: args.maxTotalChars,
955
+ minContentChars: args.minContentChars,
956
+ triage,
746
957
  });
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
- }
958
+ if ("skip" in executionGate) {
959
+ accountExtractSessionResult(executionGate.skip, triage.enabled, output);
960
+ recordExtractSessionOutcome({
961
+ stateDb,
962
+ trackingEnabled,
963
+ dryRun,
964
+ harness: harness.name,
965
+ summary,
966
+ result: executionGate.skip,
967
+ sourceRun,
968
+ });
969
+ refillModelSlot();
970
+ continue;
764
971
  }
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
- }
972
+ const result = await processSession(sessionRunCtx, {
973
+ sessionRef: summary,
974
+ gate: executionGate,
975
+ });
976
+ if (result.skipReason === "malformed_model_output") {
977
+ for (const warning of result.warnings)
978
+ topLevelWarnings.push(`session ${summary.sessionId}: ${warning}`);
822
979
  }
980
+ accountExtractSessionResult(result, triage.enabled, output);
981
+ recordExtractSessionOutcome({
982
+ stateDb,
983
+ trackingEnabled,
984
+ dryRun,
985
+ harness: harness.name,
986
+ summary,
987
+ result,
988
+ sourceRun,
989
+ });
823
990
  }
824
991
  catch (err) {
992
+ if (err instanceof ConfigError)
993
+ throw err;
825
994
  const msg = err instanceof Error ? err.message : String(err);
826
995
  warn(`[extract] session ${summary.sessionId} threw: ${msg}`);
827
996
  topLevelWarnings.push(`session ${summary.sessionId} threw: ${msg}`);
828
- sessions.push({
997
+ accountExtractSessionResult({
829
998
  sessionId: summary.sessionId,
830
999
  harness: harness.name,
831
1000
  candidateCount: 0,
@@ -834,15 +1003,16 @@ async function runExtractSessionLoop(args) {
834
1003
  warnings: [msg],
835
1004
  skipped: true,
836
1005
  skipReason: "exception",
837
- });
838
- skippedCount += 1;
1006
+ ...extractNoticeFields(getNotices),
1007
+ }, triage.enabled, output);
839
1008
  }
840
1009
  finally {
841
1010
  if (sessionLockOwnership)
842
1011
  releaseLock(sessionLockOwnership);
843
1012
  }
844
1013
  }
845
- return { sessions, processedCount, skippedCount, triageEvaluated, triagePassed, triagedOut, allProposalIds };
1014
+ output.deferred = remainingCandidates.length;
1015
+ return output;
846
1016
  }
847
1017
  /**
848
1018
  * Resolve the run-scoped LLM/engine, budget, triage, and session-indexing
@@ -851,25 +1021,43 @@ async function runExtractSessionLoop(args) {
851
1021
  * session-summary generator seam, and the default resolutions are byte-identical.
852
1022
  */
853
1023
  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) {
1024
+ const executionNotices = new Map();
1025
+ const onNotices = (notices) => {
1026
+ for (const notice of notices)
1027
+ executionNotices.set(JSON.stringify(notice), notice);
1028
+ };
1029
+ const getNotices = () => Object.freeze([...executionNotices.values()]);
1030
+ // Improve supplies its invocation-owned symbolic runner. Standalone extract
1031
+ // resolves the selected process engine through the shared execution planner.
1032
+ let llmRunner;
1033
+ if (options.resolvedPlan) {
1034
+ llmRunner = options.resolvedPlan.runner;
1035
+ onNotices(options.resolvedPlan.notices ?? []);
1036
+ }
1037
+ else if (options.llmRunner) {
1038
+ llmRunner = options.llmRunner;
1039
+ }
1040
+ else {
1041
+ const resolved = resolveImproveLlmExecution({
1042
+ config,
1043
+ profile: activeProfile,
1044
+ process: extractProcess,
1045
+ processName: "extract",
1046
+ });
1047
+ llmRunner = resolved?.runner;
1048
+ if (resolved)
1049
+ onNotices(resolved.notices);
1050
+ }
1051
+ if (!llmRunner) {
861
1052
  throw new ConfigError("No LLM engine configured for extract. Set defaults.llmEngine or improve.strategies.<name>.processes.extract.engine.", "LLM_NOT_CONFIGURED");
862
1053
  }
863
1054
  const timeoutMs = options.resolvedPlan
864
1055
  ? options.resolvedPlan.timeoutMs
865
1056
  : Object.hasOwn(options, "timeoutMs")
866
1057
  ? (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;
1058
+ : Object.hasOwn(llmRunner, "timeoutMs")
1059
+ ? (llmRunner.timeoutMs ?? null)
1060
+ : 600_000;
873
1061
  // Pre-filter budget — process config can raise it for large-context models.
874
1062
  const maxTotalChars = typeof extractProcess?.maxTotalChars === "number" ? extractProcess.maxTotalChars : undefined;
875
1063
  // #595/#596 — minimum raw session size; sessions below it skip the LLM call
@@ -899,36 +1087,28 @@ function resolveExtractRunConfig(options, config, extractProcess, activeProfile)
899
1087
  // same fail-open `callStructured` seam as the rest of extract. Returns
900
1088
  // `undefined` on disablement / timeout / error so no asset is written.
901
1089
  // 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
- }
1090
+ const defaultSessionSummaryGenerator = async (data, lease) => {
912
1091
  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
- }
1092
+ await callStructured({
1093
+ feature: "session_extraction",
1094
+ akmConfig: config,
1095
+ runner: llmRunner,
1096
+ ...(lease ? { lease } : {}),
1097
+ messages: [{ role: "user", content: buildSessionSummaryPrompt(data) }],
1098
+ request: {
1099
+ timeoutMs,
1100
+ responseSchema: SESSION_SUMMARY_JSON_SCHEMA,
1101
+ ...(options.signal ? { signal: options.signal } : {}),
1102
+ ...(options.chat ? { chat: options.chat } : {}),
1103
+ },
1104
+ onNotices,
1105
+ parse: (r) => {
1106
+ raw = r ?? "";
1107
+ return raw;
1108
+ },
1109
+ onError: () => "",
1110
+ fallback: "",
1111
+ });
932
1112
  return parseSessionSummary(raw);
933
1113
  };
934
1114
  const sessionIndexing = {
@@ -938,7 +1118,9 @@ function resolveExtractRunConfig(options, config, extractProcess, activeProfile)
938
1118
  };
939
1119
  return {
940
1120
  timeoutMs,
941
- getLlmConfig,
1121
+ llmRunner,
1122
+ onNotices,
1123
+ getNotices,
942
1124
  maxTotalChars,
943
1125
  minContentChars,
944
1126
  maxSessionsPerRun,
@@ -999,30 +1181,11 @@ function discoverExtractCandidates(options, harness, effectiveSince, startMs, dr
999
1181
  /**
1000
1182
  * WI-9.10: build one `akm extract` run's {@link RunContext} from values
1001
1183
  * `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.
1184
+ * stashDir, dryRun, sourceRun, and `resolveExtractRunConfig`'s symbolic runner)
1185
+ * — no second config load, credential materialization, or new db handle.
1015
1186
  */
1016
1187
  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
- };
1188
+ const { options, config, stashDir, dryRun, sourceRun, llmRunner } = args;
1026
1189
  return createRunContext({
1027
1190
  stashDir,
1028
1191
  config,
@@ -1030,16 +1193,71 @@ function buildExtractRunContext(args) {
1030
1193
  // Not yet wired into any proposal call site this stage (mirrors
1031
1194
  // buildImproveRunContext's proposalsCtx comment in improve.ts).
1032
1195
  proposalsCtx: options.ctx ?? {},
1033
- getLlmConfig: getRunContextLlmConfig,
1196
+ getLlmRunner: () => llmRunner,
1034
1197
  sourceRun,
1035
1198
  dryRun,
1036
1199
  signal: options.signal,
1037
1200
  });
1038
1201
  }
1202
+ function loadExtractSeenMapReadOnly(args) {
1203
+ const { options, harness, candidates, trackingEnabled, warnings } = args;
1204
+ if (!trackingEnabled || candidates.length === 0)
1205
+ return new Map();
1206
+ let snapshot;
1207
+ try {
1208
+ if (!options.stateDb)
1209
+ snapshot = openSqliteReadSnapshot(options.stateDbPath ?? getStateDbPath());
1210
+ const db = options.stateDb ?? snapshot;
1211
+ return db
1212
+ ? getExtractedSessionsMap(db, harness, candidates.map((candidate) => candidate.sessionId))
1213
+ : new Map();
1214
+ }
1215
+ catch (err) {
1216
+ const msg = err instanceof Error ? err.message : String(err);
1217
+ warn(`[extract] state.db snapshot unavailable, planning without skip-tracking: ${msg}`);
1218
+ warnings.push(`state.db snapshot unavailable: ${msg}`);
1219
+ return new Map();
1220
+ }
1221
+ finally {
1222
+ snapshot?.close();
1223
+ }
1224
+ }
1225
+ function openExtractLiveStateDb(args) {
1226
+ const { options, trackingEnabled, hasModelWork, dryRun, warnings } = args;
1227
+ if (!trackingEnabled)
1228
+ return undefined;
1229
+ if (options.stateDb)
1230
+ return options.stateDb;
1231
+ if (!hasModelWork || dryRun)
1232
+ return undefined;
1233
+ try {
1234
+ return openStateDatabase(options.stateDbPath);
1235
+ }
1236
+ catch (err) {
1237
+ const msg = err instanceof Error ? err.message : String(err);
1238
+ warn(`[extract] state.db unavailable, processing without skip-tracking: ${msg}`);
1239
+ warnings.push(`state.db unavailable: ${msg}`);
1240
+ return undefined;
1241
+ }
1242
+ }
1243
+ function emitExtractTriageEvent(args) {
1244
+ const { modelPlanCount, triageEnabled, result, sourceRun, eventsCtx } = args;
1245
+ if (modelPlanCount === 0 || !triageEnabled || result.triageEvaluated === 0)
1246
+ return;
1247
+ appendEvent({
1248
+ eventType: "extract_triaged",
1249
+ metadata: {
1250
+ evaluated: result.triageEvaluated,
1251
+ passed: result.triagePassed,
1252
+ triagedOut: result.triagedOut,
1253
+ sourceRun,
1254
+ },
1255
+ }, eventsCtx);
1256
+ }
1039
1257
  export async function akmExtract(options) {
1040
1258
  const startMs = Date.now();
1041
1259
  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");
1260
+ throw new UsageError("--type is required. Pass a harness name (e.g. --type claude).", "MISSING_REQUIRED_ARGUMENT");
1043
1261
  }
1044
1262
  const config = options.config ?? loadConfig();
1045
1263
  const stashDir = resolveRunStashDir(options.stashDir);
@@ -1075,10 +1293,10 @@ export async function akmExtract(options) {
1075
1293
  durationMs: Date.now() - startMs,
1076
1294
  };
1077
1295
  }
1078
- const { timeoutMs, getLlmConfig, maxTotalChars, minContentChars, maxSessionsPerRun, effectiveSince, triage, sessionIndexing, } = resolveExtractRunConfig(options, config, extractProcess, activeProfile);
1296
+ const { timeoutMs, llmRunner, onNotices, getNotices, maxTotalChars, minContentChars, maxSessionsPerRun, effectiveSince, triage, sessionIndexing, } = resolveExtractRunConfig(options, config, extractProcess, activeProfile);
1079
1297
  // WI-9.10: construct this run's RunContext (extracted to
1080
1298
  // buildExtractRunContext to keep akmExtract under the fn-size bar — R31).
1081
- const ctx = buildExtractRunContext({ options, config, stashDir, dryRun, sourceRun, getLlmConfig });
1299
+ const ctx = buildExtractRunContext({ options, config, stashDir, dryRun, sourceRun, llmRunner });
1082
1300
  const harness = resolveHarness(options.type, options.harnesses);
1083
1301
  if (!harness) {
1084
1302
  return {
@@ -1118,76 +1336,94 @@ export async function akmExtract(options) {
1118
1336
  return discovery.notFound;
1119
1337
  const candidates = discovery.candidates;
1120
1338
  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
1339
  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({
1340
+ const seenMap = loadExtractSeenMapReadOnly({
1341
+ options,
1342
+ harness: harness.name,
1343
+ candidates,
1344
+ trackingEnabled,
1345
+ warnings: topLevelWarnings,
1346
+ });
1347
+ const planned = planExtractSessions({
1146
1348
  candidates,
1147
1349
  options,
1148
1350
  harness,
1149
1351
  seenMap,
1150
- stateDb,
1151
- trackingEnabled,
1152
- dryRun,
1153
- stashDir,
1154
- config,
1155
- getLlmConfig,
1156
- chat: options.chat,
1157
- sourceRun,
1158
- timeoutMs,
1159
1352
  maxTotalChars,
1160
1353
  minContentChars,
1161
1354
  maxSessionsPerRun,
1162
1355
  triage,
1163
- sessionIndexing,
1164
- extractStandardsContext,
1165
- topLevelWarnings,
1356
+ trackingEnabled,
1357
+ dryRun,
1166
1358
  });
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
1359
+ const modelPlanCount = planned.plans.filter((plan) => plan.kind === "model").length;
1360
+ // Eligible dry-runs still dispatch to produce their candidate preview. Only
1361
+ // deterministic no-work plans are credential-free. Materialize once after
1362
+ // every read-only gate and before opening live state or acquiring a lock.
1363
+ const dispatchLease = modelPlanCount > 0 ? await preflightStructuredLlmRunner(llmRunner) : undefined;
1364
+ let stateDb;
1365
+ let loopResult;
1366
+ try {
1367
+ stateDb = openExtractLiveStateDb({
1368
+ options,
1369
+ trackingEnabled,
1370
+ hasModelWork: modelPlanCount > 0,
1371
+ dryRun,
1372
+ warnings: topLevelWarnings,
1373
+ });
1374
+ // Stash authoring standards (convention/meta fact bodies) for non-wiki
1375
+ // extract output. Resolved ONCE per run and threaded into each session's
1376
+ // prompt so facts are not re-read per session.
1377
+ const extractStandardsContext = modelPlanCount > 0 ? resolveExtractStandards(stashDir) : "";
1378
+ loopResult = await runExtractSessionLoop({
1379
+ plans: planned.plans,
1380
+ deferredCandidates: planned.deferredCandidates,
1381
+ seenMap,
1382
+ options,
1383
+ harness,
1384
+ stateDb,
1385
+ trackingEnabled,
1386
+ dryRun,
1387
+ stashDir,
1388
+ config,
1389
+ llmRunner,
1390
+ lease: dispatchLease,
1391
+ onNotices,
1392
+ getNotices,
1393
+ chat: options.chat,
1394
+ sourceRun,
1395
+ timeoutMs,
1396
+ maxTotalChars,
1397
+ minContentChars,
1398
+ triage,
1399
+ sessionIndexing,
1400
+ extractStandardsContext,
1401
+ topLevelWarnings,
1402
+ });
1403
+ }
1404
+ finally {
1405
+ if (stateDb && !options.stateDb) {
1406
+ try {
1407
+ stateDb.close();
1408
+ }
1409
+ catch {
1410
+ // best-effort close
1411
+ }
1175
1412
  }
1413
+ if (dispatchLease)
1414
+ disposeLoweredExecutionDispatchLease(dispatchLease);
1176
1415
  }
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);
1416
+ const { sessions, processedCount, skippedCount, allProposalIds } = loopResult;
1417
+ if (loopResult.deferred > 0) {
1418
+ topLevelWarnings.push(`Reached maxSessionsPerRun=${maxSessionsPerRun}; ${loopResult.deferred} session(s) deferred to a later run.`);
1190
1419
  }
1420
+ emitExtractTriageEvent({
1421
+ modelPlanCount,
1422
+ triageEnabled: triage.enabled,
1423
+ result: loopResult,
1424
+ sourceRun,
1425
+ eventsCtx: options.eventsCtx,
1426
+ });
1191
1427
  return {
1192
1428
  schemaVersion: 1,
1193
1429
  ok: true,
@@ -1206,6 +1442,7 @@ export async function akmExtract(options) {
1206
1442
  sessions,
1207
1443
  warnings: topLevelWarnings,
1208
1444
  durationMs: Date.now() - startMs,
1445
+ ...(getNotices().length > 0 ? { notices: getNotices() } : {}),
1209
1446
  };
1210
1447
  }
1211
1448
  /**
@@ -1241,10 +1478,21 @@ export function countNewExtractCandidates(_config, options = {}) {
1241
1478
  resolveDefaultSinceMs(harness.name, Date.now(), {
1242
1479
  ...(options.stateDb ? { stateDb: options.stateDb } : {}),
1243
1480
  ...(options.stateDbPath ? { stateDbPath: options.stateDbPath } : {}),
1481
+ ...(options.readOnly && !options.stateDb ? { skipTracking: true } : {}),
1244
1482
  });
1245
- const candidates = harness.listSessions({ sinceMs });
1483
+ const candidates = harness.listSessions({
1484
+ sinceMs,
1485
+ ...(options.readOnly ? { isolatedSnapshot: true } : {}),
1486
+ });
1246
1487
  if (candidates.length === 0)
1247
1488
  continue;
1489
+ // A dry planner with no pre-existing state database has no seen-session
1490
+ // ledger by definition. Count the discovered sessions directly instead
1491
+ // of creating state.db merely to prove that it is empty.
1492
+ if (options.readOnly && !stateDb) {
1493
+ total += candidates.length;
1494
+ continue;
1495
+ }
1248
1496
  let seenMap = new Map();
1249
1497
  try {
1250
1498
  if (!stateDb) {