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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (343) hide show
  1. package/CHANGELOG.md +756 -0
  2. package/dist/akm +94 -196
  3. package/dist/cli/shared.js +6 -2
  4. package/dist/cli.js +22 -9
  5. package/dist/commands/agent/agent-dispatch.js +1 -1
  6. package/dist/commands/command/command-execution.js +24 -62
  7. package/dist/commands/feedback-cli.js +0 -1
  8. package/dist/commands/health/accept-rate.js +2 -2
  9. package/dist/commands/health/checks.js +30 -75
  10. package/dist/commands/health/config-skew.js +38 -0
  11. package/dist/commands/health/egress.js +54 -0
  12. package/dist/commands/health/html-report.js +0 -38
  13. package/dist/commands/health/improve-metrics.js +123 -562
  14. package/dist/commands/health/plugin-staleness.js +53 -3
  15. package/dist/commands/health/renderers.js +12 -4
  16. package/dist/commands/health/report-view-model.js +11 -106
  17. package/dist/commands/health/types-improve.js +4 -19
  18. package/dist/commands/health/windows.js +64 -73
  19. package/dist/commands/health.js +122 -143
  20. package/dist/commands/improve/consolidate/chunking.js +25 -100
  21. package/dist/commands/improve/consolidate/sanitize.js +54 -149
  22. package/dist/commands/improve/consolidate.js +538 -1075
  23. package/dist/commands/improve/content-hash.js +16 -24
  24. package/dist/commands/improve/distill/content-repair.js +18 -100
  25. package/dist/commands/improve/distill-guards.js +20 -81
  26. package/dist/commands/improve/distill-promotion-policy.js +23 -243
  27. package/dist/commands/improve/distill.js +608 -1075
  28. package/dist/commands/improve/eligibility.js +126 -400
  29. package/dist/commands/improve/execution.js +3 -5
  30. package/dist/commands/improve/extract.js +487 -1046
  31. package/dist/commands/improve/feedback-valence.js +0 -25
  32. package/dist/commands/improve/improve-cli.js +29 -166
  33. package/dist/commands/improve/improve-result-file.js +10 -66
  34. package/dist/commands/improve/improve-strategies.js +12 -7
  35. package/dist/commands/improve/improve-usage-report.js +18 -64
  36. package/dist/commands/improve/improve.js +443 -1063
  37. package/dist/commands/improve/ledger.js +114 -0
  38. package/dist/commands/improve/locks.js +2 -8
  39. package/dist/commands/improve/loop-stages.js +459 -1172
  40. package/dist/commands/improve/memory/derived-ref.js +12 -77
  41. package/dist/commands/improve/memory/memory-belief.js +14 -118
  42. package/dist/commands/improve/memory/memory-improve.js +4 -3
  43. package/dist/commands/improve/outcome-loop.js +28 -156
  44. package/dist/commands/improve/planner.js +5 -10
  45. package/dist/commands/improve/preparation.js +851 -2339
  46. package/dist/commands/improve/proactive-maintenance.js +34 -101
  47. package/dist/commands/improve/reflect-noise.js +104 -280
  48. package/dist/commands/improve/reflect.js +621 -1367
  49. package/dist/commands/improve/salience.js +46 -232
  50. package/dist/commands/improve/session-asset.js +19 -100
  51. package/dist/commands/improve/stage.js +323 -0
  52. package/dist/commands/proposal/drain.js +251 -644
  53. package/dist/commands/proposal/proposal-cli.js +3 -18
  54. package/dist/commands/proposal/proposal-types.js +20 -41
  55. package/dist/commands/proposal/proposal.js +1 -2
  56. package/dist/commands/proposal/propose.js +134 -160
  57. package/dist/commands/proposal/repository.js +502 -1487
  58. package/dist/commands/proposal/validators/proposal-quality-validators.js +71 -174
  59. package/dist/commands/proposal/validators/proposal-validators.js +1 -1
  60. package/dist/commands/proposal/validators/proposals.js +13 -89
  61. package/dist/commands/read/curate.js +63 -413
  62. package/dist/commands/read/search-cli.js +16 -33
  63. package/dist/commands/read/search.js +17 -23
  64. package/dist/commands/read/show.js +2 -13
  65. package/dist/commands/sources/bundle-cli.js +25 -2
  66. package/dist/commands/sources/bundle-config-ops.js +7 -0
  67. package/dist/commands/sources/dangerous-env-audit.js +1 -2
  68. package/dist/commands/sources/info.js +2 -11
  69. package/dist/commands/sources/installed-stashes.js +197 -746
  70. package/dist/commands/sources/schema-repair.js +98 -129
  71. package/dist/commands/sources/source-add.js +62 -12
  72. package/dist/commands/sources/stash-cli.js +1 -1
  73. package/dist/commands/tasks/explain.js +10 -13
  74. package/dist/commands/tasks/tasks-cli.js +9 -8
  75. package/dist/commands/tasks/tasks.js +326 -930
  76. package/dist/commands/tasks/validate.js +42 -21
  77. package/dist/commands/workflow/plan.js +22 -29
  78. package/dist/commands/workflow-cli.js +4 -4
  79. package/dist/core/adapter/adapters/akm-adapter.js +0 -1
  80. package/dist/core/adapter/adapters/akm-lint.js +2 -3
  81. package/dist/core/adapter/adapters/akm-metadata.js +11 -12
  82. package/dist/core/adapter/adapters/akm-workflow-adapter.js +1 -1
  83. package/dist/core/adapter/execution-source.js +17 -29
  84. package/dist/core/asset/resolve-ref.js +1 -1
  85. package/dist/core/bundle-id.js +42 -5
  86. package/dist/core/bundle-rename.js +291 -0
  87. package/dist/core/config/config-io.js +1 -2
  88. package/dist/core/config/config-schema.js +1 -33
  89. package/dist/core/config/config-walker.js +1 -1
  90. package/dist/core/config/config.js +163 -68
  91. package/dist/core/config/legacy-source-shape-shim.js +38 -9
  92. package/dist/core/config/schema/embedding.js +20 -5
  93. package/dist/core/config/schema/engines.js +5 -0
  94. package/dist/core/config/schema/execution.js +1 -1
  95. package/dist/core/config/schema/experimental.js +1 -1
  96. package/dist/core/config/schema/improve-processes.js +21 -95
  97. package/dist/core/config/schema/improve.js +4 -42
  98. package/dist/core/config/schema/scheduler.js +12 -12
  99. package/dist/core/config/schema/search.js +6 -22
  100. package/dist/core/env-secret-ref.js +0 -1
  101. package/dist/core/errors.js +8 -9
  102. package/dist/core/file-lock.js +76 -173
  103. package/dist/core/logs-db.js +2 -2
  104. package/dist/core/paths.js +0 -27
  105. package/dist/core/redaction.js +109 -2
  106. package/dist/core/run-lock.js +2 -5
  107. package/dist/core/spawn-env.js +1 -1
  108. package/dist/core/state/migrations.js +108 -61
  109. package/dist/core/state-db-scope.js +2 -4
  110. package/dist/core/state-db.js +126 -692
  111. package/dist/core/type-presentation.js +1 -9
  112. package/dist/core/write-source.js +293 -1012
  113. package/dist/execution/input-contract.js +1 -1
  114. package/dist/execution/resolved-request.js +135 -689
  115. package/dist/execution/source.js +63 -257
  116. package/dist/execution/target-ref.js +1 -1
  117. package/dist/indexer/bundle-identity-guard.js +2 -2
  118. package/dist/indexer/db/graph-db.js +106 -46
  119. package/dist/indexer/ensure-index.js +44 -85
  120. package/dist/indexer/graph/graph-extraction.js +340 -562
  121. package/dist/indexer/graph/graph-related.js +130 -0
  122. package/dist/indexer/index-rebuild-lock.js +3 -11
  123. package/dist/indexer/index-writer-lock.js +8 -17
  124. package/dist/indexer/index-written-assets.js +139 -151
  125. package/dist/indexer/indexer.js +524 -846
  126. package/dist/indexer/materialize-embeddings.js +60 -397
  127. package/dist/indexer/passes/memory-inference.js +81 -90
  128. package/dist/indexer/passes/metadata.js +132 -200
  129. package/dist/indexer/read-preflight.js +0 -7
  130. package/dist/indexer/scan/doc-to-entry.js +1 -3
  131. package/dist/indexer/scan/drain-dir.js +1 -1
  132. package/dist/indexer/search/db-search.js +181 -590
  133. package/dist/indexer/search/fts-query.js +30 -41
  134. package/dist/indexer/search/ranking.js +28 -154
  135. package/dist/indexer/search/search-attribution.js +12 -32
  136. package/dist/indexer/search/search-fields.js +11 -15
  137. package/dist/indexer/search/search-hit-enrichers.js +54 -85
  138. package/dist/indexer/search/search-source.js +1 -4
  139. package/dist/indexer/usage/usage-events.js +2 -7
  140. package/dist/integrations/agent/engine-fallback.js +23 -40
  141. package/dist/integrations/agent/engine-resolution.js +93 -183
  142. package/dist/integrations/agent/execution.js +507 -0
  143. package/dist/integrations/agent/model-map.js +28 -156
  144. package/dist/integrations/agent/request-lowering.js +66 -141
  145. package/dist/integrations/agent/runner-dispatch.js +143 -321
  146. package/dist/integrations/agent/runner.js +54 -14
  147. package/dist/integrations/lockfile.js +53 -101
  148. package/dist/llm/embedders/deterministic.js +2 -3
  149. package/dist/llm/embedders/profile.js +71 -0
  150. package/dist/llm/embedders/remote.js +10 -15
  151. package/dist/llm/graph-extract.js +3 -12
  152. package/dist/llm/index-passes.js +3 -5
  153. package/dist/llm/memory-infer.js +1 -2
  154. package/dist/llm/metadata-enhance.js +1 -2
  155. package/dist/llm/structured-call.js +5 -24
  156. package/dist/output/generic-render.js +23 -11
  157. package/dist/output/html-render.js +13 -10
  158. package/dist/output/render-registry.js +3 -32
  159. package/dist/output/shapes/helpers.js +2 -34
  160. package/dist/output/shapes/passthrough.js +1 -9
  161. package/dist/{indexer/search/ranking-types.js → output/text/bundle-rename.js} +4 -1
  162. package/dist/output/text/command-format.js +60 -23
  163. package/dist/output/text/helpers.js +1 -1
  164. package/dist/output/text/migrate.js +5 -14
  165. package/dist/output/text/proposal-format.js +1 -2
  166. package/dist/output/text/workflow-format.js +0 -32
  167. package/dist/output/text.js +2 -0
  168. package/dist/registry/factory.js +4 -19
  169. package/dist/registry/network.js +66 -220
  170. package/dist/registry/providers/index.js +0 -2
  171. package/dist/registry/providers/skills-sh.js +3 -14
  172. package/dist/registry/providers/static-index.js +24 -26
  173. package/dist/registry/resolve.js +55 -131
  174. package/dist/scripts/akm-migrate-node.js +43937 -93313
  175. package/dist/scripts/akm-migrate.js +43697 -93071
  176. package/dist/setup/registry-stash-loader.js +4 -13
  177. package/dist/setup/semantic-assets.js +3 -44
  178. package/dist/setup/setup.js +1 -1
  179. package/dist/setup/steps/tasks.js +25 -15
  180. package/dist/sources/provider-factory.js +17 -18
  181. package/dist/sources/providers/filesystem.js +2 -3
  182. package/dist/sources/providers/git-install.js +7 -1
  183. package/dist/sources/providers/git-provider.js +0 -3
  184. package/dist/sources/providers/git-stash.js +0 -17
  185. package/dist/sources/providers/npm.js +2 -4
  186. package/dist/sources/providers/provider-utils.js +5 -10
  187. package/dist/sources/providers/website.js +0 -2
  188. package/dist/sources/snapshot-fetchers/website-ingest.js +1 -1
  189. package/dist/sources/website-url.js +2 -2
  190. package/dist/storage/database.js +9 -35
  191. package/dist/storage/repositories/improve-ledger-repository.js +168 -0
  192. package/dist/storage/repositories/index-connection.js +34 -70
  193. package/dist/storage/repositories/index-entries-repository.js +69 -111
  194. package/dist/storage/repositories/index-entry-mapper.js +1 -2
  195. package/dist/storage/repositories/index-entry-schema.js +83 -269
  196. package/dist/storage/repositories/index-fts-repository.js +86 -256
  197. package/dist/storage/repositories/index-llm-cache-repository.js +17 -0
  198. package/dist/storage/repositories/index-meta-repository.js +6 -4
  199. package/dist/storage/repositories/index-schema.js +192 -220
  200. package/dist/storage/repositories/index-utility-repository.js +8 -29
  201. package/dist/storage/repositories/index-vec-repository.js +133 -414
  202. package/dist/storage/repositories/outcome-repository.js +2 -1
  203. package/dist/storage/repositories/proposals-repository.js +35 -0
  204. package/dist/storage/repositories/registry-index-cache-repository.js +100 -0
  205. package/dist/storage/repositories/task-history-repository.js +26 -4
  206. package/dist/storage/repositories/workflow-runs-repository.js +53 -244
  207. package/dist/storage/sqlite-migrations.js +136 -0
  208. package/dist/storage/sqlite-pragmas.js +11 -9
  209. package/dist/storage/sqlite-transaction.js +170 -0
  210. package/dist/storage/state-db-integrity.js +34 -27
  211. package/dist/tasks/activation-config.js +134 -62
  212. package/dist/tasks/backends/cron.js +129 -277
  213. package/dist/tasks/backends/exec-utils.js +2 -5
  214. package/dist/tasks/backends/launchd.js +125 -745
  215. package/dist/tasks/backends/schtasks.js +101 -620
  216. package/dist/tasks/prepare/prepare-support.js +5 -15
  217. package/dist/tasks/prepare/prepare.js +0 -2
  218. package/dist/tasks/resolve-akm-bin.js +20 -79
  219. package/dist/tasks/run/attempt-lifecycle.js +0 -1
  220. package/dist/tasks/scheduler-binding.js +18 -238
  221. package/dist/tasks/scheduler-invocation.js +52 -52
  222. package/dist/tasks/scheduler-lock.js +53 -0
  223. package/dist/tasks/scheduler-sync.js +363 -679
  224. package/dist/tasks/source/parse-task-source.js +160 -10
  225. package/dist/tasks/source/task-source-v3-frozen.js +3 -4
  226. package/dist/tasks/source/task-to-v4.js +2 -2
  227. package/dist/workflows/authoring/authoring.js +3 -12
  228. package/dist/workflows/compile.js +211 -0
  229. package/dist/workflows/concurrency-policy.js +13 -74
  230. package/dist/workflows/exec/child-invocation.js +3 -17
  231. package/dist/workflows/exec/child-workflow.js +32 -141
  232. package/dist/workflows/exec/dispatch-redaction.js +13 -53
  233. package/dist/workflows/exec/environment.js +98 -0
  234. package/dist/workflows/exec/exec-unit.js +33 -140
  235. package/dist/workflows/exec/frozen-judge.js +7 -59
  236. package/dist/workflows/exec/native-executor.js +82 -341
  237. package/dist/workflows/exec/param-secrets.js +29 -47
  238. package/dist/workflows/exec/run-workflow.js +154 -387
  239. package/dist/workflows/exec/scheduler.js +9 -36
  240. package/dist/workflows/exec/step-work.js +127 -430
  241. package/dist/workflows/exec/unit-dispatch.js +11 -63
  242. package/dist/workflows/exec/unit-writer.js +8 -52
  243. package/dist/workflows/exec/worktree.js +39 -273
  244. package/dist/workflows/freeze/child-output-references.js +4 -15
  245. package/dist/workflows/freeze/environment.js +99 -92
  246. package/dist/workflows/freeze/freeze.js +172 -0
  247. package/dist/workflows/freeze/step-values.js +19 -21
  248. package/dist/workflows/freeze/targets/child-workflow.js +23 -92
  249. package/dist/workflows/freeze/targets/command.js +10 -33
  250. package/dist/workflows/freeze/targets/script.js +5 -12
  251. package/dist/workflows/freeze/targets/shell.js +3 -6
  252. package/dist/workflows/freeze/targets/task.js +25 -80
  253. package/dist/workflows/freeze/task-bindings.js +20 -67
  254. package/dist/workflows/{source-ir/github-yaml.js → github-yaml.js} +88 -206
  255. package/dist/workflows/ir/params.js +6 -51
  256. package/dist/workflows/ir/plan-hash.js +2 -34
  257. package/dist/workflows/parser.js +140 -43
  258. package/dist/{commands/improve/consolidate/types.js → workflows/plan.js} +2 -1
  259. package/dist/workflows/renderer.js +36 -69
  260. package/dist/workflows/resource-limits.js +12 -120
  261. package/dist/workflows/runtime/agent-identity.js +8 -40
  262. package/dist/workflows/runtime/run-outputs.js +3 -6
  263. package/dist/workflows/runtime/run-plan.js +316 -0
  264. package/dist/workflows/runtime/runs.js +48 -200
  265. package/dist/workflows/runtime/workflow-asset-loader.js +24 -57
  266. package/dist/workflows/{source-ir/semantics.js → source-semantics.js} +16 -20
  267. package/dist/workflows/validate-summary.js +2 -7
  268. package/docs/integration/bundling-akm.md +49 -42
  269. package/docs/migration/README.md +1 -0
  270. package/docs/migration/release-notes/0.9.17.md +41 -0
  271. package/docs/migration/v0.9.1-to-v0.9.2.md +19 -7
  272. package/docs/reference/cli.md +182 -125
  273. package/docs/reference/configuration.md +49 -56
  274. package/docs/reference/data-and-telemetry.md +19 -20
  275. package/docs/reference/tasks.md +86 -38
  276. package/docs/reference/workflow-schema.md +14 -18
  277. package/docs/reference/workflows.md +6 -9
  278. package/package.json +1 -1
  279. package/schemas/akm-config.json +87 -406
  280. package/dist/commands/health/advisories.js +0 -150
  281. package/dist/commands/health/metrics.js +0 -329
  282. package/dist/commands/health/surfaces.js +0 -102
  283. package/dist/commands/improve/anti-collapse.js +0 -83
  284. package/dist/commands/improve/collapse-detector.js +0 -432
  285. package/dist/commands/improve/consolidate/eligibility.js +0 -48
  286. package/dist/commands/improve/consolidate/merge.js +0 -146
  287. package/dist/commands/improve/distill/promote-memory.js +0 -329
  288. package/dist/commands/improve/distill/quality-gate.js +0 -500
  289. package/dist/commands/improve/memory/memory-contradiction-detect.js +0 -291
  290. package/dist/commands/improve/proposal-envelope.js +0 -31
  291. package/dist/commands/improve/run-context.js +0 -123
  292. package/dist/commands/improve/shared.js +0 -21
  293. package/dist/commands/improve/source-identity.js +0 -28
  294. package/dist/commands/improve/triage.js +0 -96
  295. package/dist/commands/proposal/drain-policies.js +0 -151
  296. package/dist/commands/sources/update-transaction.js +0 -220
  297. package/dist/core/action-contributors.js +0 -28
  298. package/dist/core/config/config-version-shim.js +0 -101
  299. package/dist/core/config/retired-experimental-keys-shim.js +0 -62
  300. package/dist/core/fs-txn.js +0 -405
  301. package/dist/core/lexical-score.js +0 -25
  302. package/dist/core/maintenance-barrier.js +0 -167
  303. package/dist/execution/executable-identity.js +0 -105
  304. package/dist/execution/guarded-source.js +0 -427
  305. package/dist/indexer/graph/graph-boost.js +0 -427
  306. package/dist/indexer/graph/graph-dedup.js +0 -95
  307. package/dist/indexer/search/name-match.js +0 -35
  308. package/dist/indexer/search/ranking-contributors.js +0 -515
  309. package/dist/indexer/walk/project-context.js +0 -192
  310. package/dist/integrations/agent/execution-cascade.js +0 -566
  311. package/dist/integrations/agent/execution-definitions.js +0 -202
  312. package/dist/integrations/agent/execution-lowering.js +0 -841
  313. package/dist/integrations/agent/execution-preparation.js +0 -98
  314. package/dist/integrations/agent/inline-execution.js +0 -74
  315. package/dist/registry/create-provider-registry.js +0 -29
  316. package/dist/registry/pinned-request-helper.js +0 -247
  317. package/dist/registry/pinned-transport.js +0 -717
  318. package/dist/sources/providers/index.js +0 -14
  319. package/dist/storage/engines/sqlite-migrations.js +0 -271
  320. package/dist/storage/repositories/canaries-repository.js +0 -107
  321. package/dist/storage/repositories/embedding-salvage-repository.js +0 -184
  322. package/dist/storage/repositories/registry-cache.js +0 -113
  323. package/dist/tasks/scheduler-sync-preview.js +0 -52
  324. package/dist/workflows/freeze/resolve-steps.js +0 -86
  325. package/dist/workflows/freeze/source-freeze.js +0 -64
  326. package/dist/workflows/ir/compile.js +0 -321
  327. package/dist/workflows/ir/environment-v4.js +0 -330
  328. package/dist/workflows/ir/freeze-v4.js +0 -153
  329. package/dist/workflows/ir/schema-v4.js +0 -745
  330. package/dist/workflows/ir/schema.js +0 -354
  331. package/dist/workflows/program/schema.js +0 -78
  332. package/dist/workflows/runtime/checkin.js +0 -57
  333. package/dist/workflows/runtime/plan-classifier.js +0 -196
  334. package/dist/workflows/runtime/unit-checkin.js +0 -45
  335. package/dist/workflows/runtime/unit-phases.js +0 -20
  336. package/dist/workflows/schema.js +0 -4
  337. package/dist/workflows/source-ir/compile.js +0 -200
  338. package/dist/workflows/source-ir/program.js +0 -50
  339. package/dist/workflows/source-ir/result.js +0 -26
  340. package/dist/workflows/source-ir/schema.js +0 -786
  341. package/dist/workflows/source-ir/triggers.js +0 -79
  342. package/dist/workflows/source-ir/uses.js +0 -40
  343. package/dist/workflows/validator.js +0 -60
@@ -2,134 +2,46 @@
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  /**
5
- * Deterministic proposal-drain engine (Proposal-Queue Triage, Phase 1).
6
- *
7
- * Drains the *standing pending backlog* of proposals using a deterministic,
8
- * no-LLM policy keyed on generator (proposal `source`) and diff size. This is
9
- * the engine behind `akm proposal drain` and (later) the `triage` improve
10
- * pre-pass.
11
- *
12
- * Design:
13
- * - Reuses `listProposals` (no source filter — generator filtering is
14
- * in-memory) and the `akmProposalAccept` / `akmProposalReject` wrappers from
15
- * `proposal.ts` so the standard `promoted` / `rejected` events are emitted.
16
- * Deterministic by design; only the configured drain policy decides.
17
- * - Backlog-only: `excludeIds` removes this-run's fresh proposals so triage
18
- * never re-adjudicates a current run's output (decision #2).
19
- * - Hard guardrails enforced in code: a `maxAccepts` ceiling checked *before*
20
- * the promote loop (remainder → `skippedByCap`); `maxDiffLines` defers large
21
- * accepts; `applyMode: "queue"` (the safe default) never promotes (stage
22
- * only); `rejectEmpty` rejects empty / near-empty diffs.
23
- * - The judgment tier (Phase 3) adjudicates the deferred items: when a
24
- * `judgment` RunnerSpec is supplied the engine pre-fetches context (the live
25
- * asset + sibling pending proposals for the same ref) into a prompt,
26
- * dispatches it through the shared resolved/lowered execution boundary,
27
- * and performs the resulting accept / reject *itself* (the runner only
28
- * judges).
29
- * Items the runner cannot resolve — and any deferred items when no runner is
30
- * configured — surface a `triage_deferred` event so "enabled, no agent"
31
- * never silently looks like full success.
32
- *
33
- * The promote / reject functions and the runner dispatch are injectable
34
- * (mirrors reflect's dual test seams) so tests can run the full engine without
35
- * touching the filesystem or spawning a process.
5
+ * The proposal drain behind `akm proposal drain` and improve's triage pre-pass.
6
+ * One rule decides the pending backlog:
7
+ * - an empty diff is rejected;
8
+ * - a proposal whose quality judge passed on this exact content (a `staged`
9
+ * gate decision carrying its content hash) is accepted, unless its target
10
+ * changed since mint — then it is auto-rejected as `stale-target`, never
11
+ * overwritten;
12
+ * - everything else needs a judge: the judgment tier decides it when a runner
13
+ * is configured, and whatever stays undecided is left for review
14
+ * (`review_needed` in the improve ledger).
15
+ * `maxAccepts` caps promotions across both tiers; `applyMode: "queue"` never
16
+ * promotes; `excludeIds` keeps this run's fresh proposals out; a proposal the
17
+ * distill quality gate routed to a human is left for that human.
36
18
  */
37
- import { createHash } from "node:crypto";
38
19
  import fs from "node:fs";
39
20
  import path from "node:path";
40
21
  import { assetPathForName, stashDirFor } from "../../core/asset/asset-placement.js";
41
- import { computeNormalizedContentHash, parseFrontmatter } from "../../core/asset/frontmatter.js";
22
+ import { parseFrontmatter } from "../../core/asset/frontmatter.js";
42
23
  import { parseRefInput } from "../../core/asset/resolve-ref.js";
43
24
  import { ConfigError } from "../../core/errors.js";
44
25
  import { appendEvent } from "../../core/events.js";
45
26
  import { escapeJsonStringControls, stripCodeFences, stripThinkBlocks } from "../../core/parse.js";
46
27
  import { info, warn } from "../../core/warn.js";
47
- import { acquireLoweredExecutionDispatchLease, dispatchLoweredExecutionRequest, disposeLoweredExecutionDispatchLease, lowerResolvedExecutionRequestWithRunner, } from "../../integrations/agent/execution-lowering.js";
48
- import { prepareInlineExecutionWithRunner } from "../../integrations/agent/inline-execution.js";
28
+ import { buildExecution, resolveExecution } from "../../integrations/agent/execution.js";
29
+ import { assertRunnerCredentials, runExecution, } from "../../integrations/agent/runner-dispatch.js";
30
+ import { errMessage, noticeSet } from "../improve/stage.js";
49
31
  import { akmProposalAccept, akmProposalReject } from "./proposal.js";
50
32
  import { STALE_TARGET_GATE_REASON } from "./proposal-types.js";
51
- import { listProposals, listProposalsReadOnly, preflightProposalPromotion, proposalContent, recordGateDecision, } from "./repository.js";
52
- // ---------------------------------------------------------------------------
53
- // Content helpers
54
- // ---------------------------------------------------------------------------
55
- /** Number of non-empty body lines (frontmatter excluded). */
56
- export function contentBodyLineCount(content) {
57
- // Reuse the canonical frontmatter parser so CRLF / BOM are handled
58
- // consistently with the rest of the stash (parseFrontmatter returns the body
59
- // in `content`).
60
- return parseFrontmatter(content)
61
- .content.split("\n")
62
- .filter((line) => line.trim().length > 0).length;
63
- }
64
- /** Total line count of the proposed content (matches the bulk-accept measure). */
65
- export function contentLineCount(content) {
66
- return content.split("\n").length;
67
- }
68
- /** An empty / near-empty diff has no meaningful body content. */
33
+ import { listProposals, listProposalsReadOnly, preflightProposalPromotion, proposalContent, proposalContentHash, readFreshProposalTarget, recordGateDecision, } from "./repository.js";
34
+ /** The gate label on every decision the drain records. */
35
+ const DRAIN_GATE = "triage";
36
+ /** An empty diff: no non-blank body line outside the frontmatter. */
69
37
  export function isEmptyDiff(proposal) {
70
38
  const content = proposalContent(proposal);
71
39
  if (content.trim().length === 0)
72
40
  return true;
73
- return contentBodyLineCount(content) === 0;
74
- }
75
- /**
76
- * Decide a deterministic verdict for a single backlog proposal under `policy`.
77
- * Returns `null` when no rule applies (the proposal is left pending untouched).
78
- */
79
- export function classifyProposal(proposal, policy, maxDiffLines) {
80
- const content = proposalContent(proposal);
81
- // Empty / near-empty diffs reject first (the reject-empty floor).
82
- if (policy.rejectEmpty && isEmptyDiff(proposal)) {
83
- return { verdict: "reject", reason: "empty diff", gate: { reason: "empty-diff" } };
84
- }
85
- const rule = policy.accept.find((r) => {
86
- if (r.generator !== proposal.source)
87
- return false;
88
- if (r.requireType !== undefined) {
89
- const fm = parseFrontmatter(proposalContent(proposal)).data;
90
- if (typeof fm.type !== "string" || fm.type !== r.requireType)
91
- return false;
92
- }
93
- return true;
94
- });
95
- if (rule) {
96
- const lines = contentLineCount(content);
97
- const body = contentBodyLineCount(content);
98
- // Per-rule and global diff bounds defer large accepts (no silent rewrites).
99
- const effectiveMax = Math.min(rule.maxDiffLines ?? Number.POSITIVE_INFINITY, maxDiffLines ?? Number.POSITIVE_INFINITY);
100
- if (lines > effectiveMax) {
101
- return {
102
- verdict: "defer",
103
- reason: "mid-band",
104
- gate: { reason: "max-diff-lines", measured: lines, thresholds: { maxDiffLines: effectiveMax } },
105
- };
106
- }
107
- if (rule.minContentLines !== undefined && body < rule.minContentLines) {
108
- // Too little content to confidently auto-accept — leave for judgment.
109
- return {
110
- verdict: "defer",
111
- reason: "mid-band",
112
- gate: { reason: "min-content-lines", measured: body, thresholds: { minContentLines: rule.minContentLines } },
113
- };
114
- }
115
- return { verdict: "accept", gate: { reason: "policy-accept" } };
116
- }
117
- if (policy.defer.includes(proposal.source)) {
118
- const reason = deferReasonForSource(proposal.source);
119
- return { verdict: "defer", reason, gate: { reason } };
120
- }
121
- // No matching rule — leave pending, untouched.
122
- return null;
123
- }
124
- function deferReasonForSource(source) {
125
- return source === "distill" ? "possible-dup" : "mid-band";
41
+ return !parseFrontmatter(content)
42
+ .content.split("\n")
43
+ .some((line) => line.trim().length > 0);
126
44
  }
127
- /**
128
- * Map a thrown error's message to one of `DrainResult.failed`'s stable reason
129
- * codes, falling back to `fallback` for anything not specifically recognized.
130
- * Recognizes the write-time guards a proposal can trip during promotion
131
- * (see repository.ts's `promoteProposalWithLease` / `preflightProposalPromotion`).
132
- */
133
45
  function categorizeDrainFailure(message, fallback) {
134
46
  if (/target (?:changed after|was created after) proposal/.test(message))
135
47
  return STALE_TARGET_GATE_REASON;
@@ -137,108 +49,87 @@ function categorizeDrainFailure(message, fallback) {
137
49
  return "validation";
138
50
  return fallback;
139
51
  }
140
- function pushDrainFailure(result, id, err, fallbackReason) {
141
- const message = err instanceof Error ? err.message : String(err);
142
- result.failed.push({ id, reason: categorizeDrainFailure(message, fallbackReason), detail: message });
143
- return message;
144
- }
145
52
  /**
146
- * A `stale-target` promote failure (STALE, R20) is not a merit rejection —
147
- * the guard tripped because the target changed after mint (often akm's own
148
- * bookkeeping), not because of anything wrong with the proposed content. So
149
- * instead of leaving the row pending to retry and fail identically every run,
150
- * the drain auto-rejects it once with a structured marker.
151
- * `checkFingerprintAndBackoff` (repository.ts) excludes this reason from
152
- * rejection-backoff, so the ref stays re-proposable against its current
153
- * content. Returns `true` when the reject succeeded (the caller should treat
154
- * the item as resolved, not failed); `false` leaves it to the caller's
155
- * existing failure handling.
53
+ * The one accept path both tiers share. A dry run exercises the same stamped
54
+ * candidate, lint and freshness boundary a promotion would (tests that pass no
55
+ * config keep the classification-only seam). A stale target is not a merit
56
+ * rejection, so instead of failing identically every run it is auto-rejected
57
+ * once; the ledger records `failed`, keeping the ref re-proposable.
156
58
  */
157
- async function autoRejectStaleTarget(stashDir, gateLabel, id, message, rejectFn) {
59
+ async function acceptProposal(opts, proposal, id, reason, promoteFn, rejectFn) {
60
+ const gateDecision = { outcome: "auto-accepted", reason, gate: DRAIN_GATE };
158
61
  try {
159
- await rejectFn({
160
- stashDir,
161
- id,
162
- reason: `stale-target: ${message}`,
163
- gateDecision: { outcome: "auto-rejected", reason: STALE_TARGET_GATE_REASON, gate: gateLabel },
164
- });
165
- return true;
62
+ if (!opts.dryRun) {
63
+ await promoteFn({
64
+ stashDir: opts.stashDir,
65
+ id,
66
+ ...(opts.target ? { target: opts.target } : {}),
67
+ ...(opts.config ? { config: opts.config } : {}),
68
+ gateDecision,
69
+ });
70
+ }
71
+ else if (opts.config) {
72
+ if (!proposal)
73
+ throw new Error(`Proposal ${id} disappeared during drain preflight.`);
74
+ const preflight = preflightProposalPromotion(opts.config, proposal, {
75
+ ...(opts.target ? { target: opts.target } : {}),
76
+ gateDecision,
77
+ });
78
+ readFreshProposalTarget(proposal, preflight.assetPath, preflight.stampedContent);
79
+ }
80
+ return "promoted";
166
81
  }
167
82
  catch (err) {
168
- warn(`[triage] stale-target auto-reject failed for ${id}: ${err instanceof Error ? err.message : String(err)}`);
169
- return false;
170
- }
171
- }
172
- /**
173
- * Mirror repository.ts's `promoteProposalWithLease` stale-target guard so a
174
- * dry-run preflight predicts the same refusal a real promote would hit,
175
- * without writing anything. `assetPath` is the path `preflightProposalPromotion`
176
- * already resolved for this proposal.
177
- */
178
- function assertProposalTargetFresh(proposal, assetPath) {
179
- const backup = fs.existsSync(assetPath) ? fs.readFileSync(assetPath) : undefined;
180
- if (proposal.beforeHash !== undefined) {
181
- // STALE (R20): mirrors repository.ts's promote guard — a normalized
182
- // before-hash is insensitive to a same-run bookkeeping rewrite of the
183
- // target; a legacy proposal without one keeps the raw-hash check.
184
- const fresh = proposal.beforeHashNormalized !== undefined
185
- ? backup !== undefined &&
186
- computeNormalizedContentHash(backup.toString("utf8")) === proposal.beforeHashNormalized
187
- : backup !== undefined && createHash("sha256").update(backup).digest("hex") === proposal.beforeHash;
188
- if (!fresh) {
189
- throw new Error(`Proposal target changed after proposal ${proposal.id} was created; refusing to overwrite newer content.`);
83
+ const message = errMessage(err);
84
+ if (categorizeDrainFailure(message, "") !== STALE_TARGET_GATE_REASON)
85
+ return { message };
86
+ if (opts.dryRun)
87
+ return "rejected";
88
+ try {
89
+ await rejectFn({
90
+ stashDir: opts.stashDir,
91
+ id,
92
+ reason: `stale-target: ${message}`,
93
+ gateDecision: { outcome: "auto-rejected", reason: STALE_TARGET_GATE_REASON, gate: DRAIN_GATE },
94
+ });
95
+ return "rejected";
96
+ }
97
+ catch (rejectErr) {
98
+ warn(`[triage] stale-target auto-reject failed for ${id}: ${errMessage(rejectErr)}`);
99
+ return { message };
190
100
  }
191
- }
192
- if (proposal.beforeHash === undefined &&
193
- backup !== undefined &&
194
- proposal.changes.some((change) => change.op === "create")) {
195
- throw new Error(`Proposal target was created after proposal ${proposal.id} was created; refusing to overwrite newer content.`);
196
101
  }
197
102
  }
198
- // ---------------------------------------------------------------------------
199
- // Judgment tier (Phase 3)
200
- // ---------------------------------------------------------------------------
201
- /** Read the live on-disk content of a proposal's target asset, if it exists. */
202
- function readLiveAssetContent(stashDir, ref) {
103
+ /** Reject one proposal (nothing in a dry run); the error message on failure. */
104
+ async function rejectProposal(opts, id, reason, gateReason, rejectFn) {
105
+ if (opts.dryRun)
106
+ return undefined;
203
107
  try {
204
- const parsed = parseRefInput(ref);
205
- const typeDir = stashDirFor(parsed.type);
206
- if (!typeDir)
207
- return undefined;
208
- const typeRoot = path.join(stashDir, typeDir);
209
- const assetPath = assetPathForName(parsed.type, typeRoot, parsed.name);
210
- if (!fs.existsSync(assetPath))
211
- return undefined;
212
- return fs.readFileSync(assetPath, "utf8");
213
- }
214
- catch {
108
+ await rejectFn({
109
+ stashDir: opts.stashDir,
110
+ id,
111
+ reason,
112
+ gateDecision: { outcome: "auto-rejected", reason: gateReason, gate: DRAIN_GATE },
113
+ });
215
114
  return undefined;
216
115
  }
116
+ catch (err) {
117
+ return errMessage(err);
118
+ }
217
119
  }
218
- /**
219
- * Pre-fetch the context the judgment runner needs to adjudicate one deferred
220
- * proposal: the proposed content, the live asset it would overwrite, and the
221
- * sibling pending proposals for the same ref (so a dedup verdict can compare).
222
- */
223
- function prefetchJudgmentContext(stashDir, proposal, pending) {
224
- const liveAsset = readLiveAssetContent(stashDir, proposal.ref);
225
- const siblings = pending.filter((p) => p.ref === proposal.ref && p.id !== proposal.id);
226
- return { liveAsset, siblings };
227
- }
228
- /** Build the judgment prompt with the proposed content + pre-fetched context. */
120
+ /** The judgment prompt: the proposal, the live asset it would overwrite, and same-ref siblings. */
229
121
  export function buildJudgmentPrompt(proposal, reason, ctx) {
230
- const proposed = proposalContent(proposal);
231
122
  const sections = [
232
- "You are adjudicating a pending knowledge-base proposal that the deterministic",
233
- "triage pass could not resolve. Decide whether to accept, reject, or defer it.",
123
+ "You are adjudicating a pending knowledge-base proposal no quality judge has",
124
+ "passed yet. Decide whether to accept, reject, or defer it.",
234
125
  "",
235
126
  `Asset ref: ${proposal.ref}`,
236
127
  `Generator (source): ${proposal.source}`,
237
- `Deferred because: ${reason}`,
128
+ `Left for judgment because: ${reason === "needs-judgment" ? "no quality judge has passed this content yet" : reason}`,
238
129
  "",
239
130
  "## Proposed content",
240
131
  "```",
241
- proposed,
132
+ proposalContent(proposal),
242
133
  "```",
243
134
  ];
244
135
  if (ctx.liveAsset !== undefined) {
@@ -256,15 +147,12 @@ export function buildJudgmentPrompt(proposal, reason, ctx) {
256
147
  sections.push("", "## Your task", 'Return ONLY a JSON object: {"decision": "accept" | "reject" | "defer", "reason": "<short reason>"}.', "- accept: the proposed content is a correct, valuable update worth committing.", "- reject: the proposal is wrong, a duplicate, or contradicts the live asset.", "- defer: you cannot decide from the provided context (leave it pending).", "Output the JSON object and nothing else.");
257
148
  return sections.join("\n");
258
149
  }
259
- /** Parse a {@link JudgmentVerdict} from raw runner output. Lenient. */
150
+ /** A verdict from raw runner output (the first JSON object), or null. */
260
151
  export function parseJudgmentVerdict(raw) {
261
152
  const cleaned = escapeJsonStringControls(stripCodeFences(stripThinkBlocks(raw))).trim();
262
- if (!cleaned)
263
- return null;
264
- // Find the first balanced-looking JSON object in the output.
265
153
  const start = cleaned.indexOf("{");
266
154
  const end = cleaned.lastIndexOf("}");
267
- if (start === -1 || end === -1 || end <= start)
155
+ if (start === -1 || end <= start)
268
156
  return null;
269
157
  let obj;
270
158
  try {
@@ -273,512 +161,237 @@ export function parseJudgmentVerdict(raw) {
273
161
  catch {
274
162
  return null;
275
163
  }
276
- if (typeof obj !== "object" || obj === null)
277
- return null;
278
- const decision = obj.decision;
279
- const reason = obj.reason;
164
+ const { decision, reason } = (obj ?? {});
280
165
  if (decision !== "accept" && decision !== "reject" && decision !== "defer")
281
166
  return null;
282
167
  return { decision, reason: typeof reason === "string" ? reason : "" };
283
168
  }
284
- async function dispatchJudgment(runner, prompt, seams, lease) {
285
- const prepared = prepareInlineExecutionWithRunner({
286
- content: prompt,
287
- runner,
288
- invocationKind: "direct",
289
- });
290
- const lowered = lowerResolvedExecutionRequestWithRunner(prepared.request, prepared.runner);
291
- const chat = seams.chat;
292
- const llmRunner = lowered.runner.kind === "llm" ? lowered.runner : undefined;
293
- const dispatchOptions = {
294
- lease,
295
- ...(seams.runAgentFn ? { runAgent: seams.runAgentFn } : {}),
296
- ...(seams.runSdkFn ? { runSdk: seams.runSdkFn } : {}),
297
- ...(chat && llmRunner
298
- ? {
299
- chat: async (connection, messages) => chat({ ...llmRunner, connection }, messages),
300
- }
301
- : {}),
302
- };
303
- let result;
169
+ /** Lower the judgment prompt through the frozen runner and dispatch it. */
170
+ async function dispatchJudgment(runner, prompt, seams) {
171
+ let notices = [];
304
172
  try {
305
- result = await dispatchLoweredExecutionRequest(lowered, dispatchOptions);
173
+ const prepared = resolveExecution({ content: prompt, runner });
174
+ const lowered = buildExecution(prepared.request, prepared.runner);
175
+ notices = lowered.notices;
176
+ const chat = seams.chat;
177
+ const llmRunner = lowered.runner.kind === "llm" ? lowered.runner : undefined;
178
+ const result = await runExecution(lowered, {
179
+ ...(seams.runAgentFn ? { runAgent: seams.runAgentFn } : {}),
180
+ ...(seams.runSdkFn ? { runSdk: seams.runSdkFn } : {}),
181
+ ...(chat && llmRunner
182
+ ? { chat: async (connection, messages) => chat({ ...llmRunner, connection }, messages) }
183
+ : {}),
184
+ });
185
+ if (!result.ok)
186
+ return { verdict: null, notices, error: result.error ?? result.reason ?? "unknown error" };
187
+ return { verdict: parseJudgmentVerdict(result.stdout), notices };
306
188
  }
307
189
  catch (error) {
308
190
  if (error instanceof ConfigError)
309
191
  throw error;
310
- return {
311
- verdict: null,
312
- notices: lowered.notices,
313
- error: error instanceof Error ? error.message : String(error),
314
- };
315
- }
316
- if (!result.ok) {
317
- return {
318
- verdict: null,
319
- notices: lowered.notices,
320
- error: result.error ?? result.reason ?? "unknown error",
321
- };
192
+ return { verdict: null, notices, error: errMessage(error) };
322
193
  }
323
- return { verdict: parseJudgmentVerdict(result.stdout), notices: lowered.notices };
324
- }
325
- /** Validate symbolic judgment credentials without contacting a provider. */
326
- async function preflightJudgmentRunner(runner) {
327
- const prepared = prepareInlineExecutionWithRunner({
328
- content: "Validate the selected proposal judgment runner before mutation.",
329
- runner,
330
- invocationKind: "direct",
331
- });
332
- const lowered = lowerResolvedExecutionRequestWithRunner(prepared.request, prepared.runner);
333
- return acquireLoweredExecutionDispatchLease(lowered);
334
- }
335
- function judgedContentHash(proposal) {
336
- return createHash("sha256").update(proposalContent(proposal), "utf8").digest("hex");
337
194
  }
338
195
  /**
339
- * Run the judgment tier over the deferred items. The runner only *judges*; the
340
- * engine performs the resulting accept (respecting `applyMode`) / reject write.
341
- * Returns the ids the engine promoted / rejected, the ids staged (judge said
342
- * "accept" but queue mode did not promote), the ids dropped by the accept cap,
343
- * and the items still unresolved (verdict "defer", parse failure, or a runner
344
- * error).
196
+ * The judgment tier: the runner only judges; the drain performs the accept
197
+ * (under `applyMode` and the remaining accept budget) or the reject. A defer, an
198
+ * unparseable verdict or a runner error leaves the item undecided.
345
199
  */
346
- async function runJudgmentTier(input) {
347
- const byId = new Map(input.pending.map((p) => [p.id, p]));
348
- const promoted = [];
349
- const rejected = [];
350
- const staged = [];
351
- const skippedByCap = [];
200
+ async function runJudgmentTier(opts, result, pending, acceptBudget, promoteFn, rejectFn, seams) {
201
+ const byId = new Map(pending.map((p) => [p.id, p]));
202
+ const notices = noticeSet();
352
203
  const stillDeferred = [];
353
- const noticesByKey = new Map();
354
- // Remaining accept budget shared with the deterministic promote loop.
355
- let acceptBudget = Math.max(0, input.remainingAcceptBudget);
356
- for (const item of input.deferred) {
204
+ const cappedBefore = result.skippedByCap.length;
205
+ for (const item of result.deferred) {
357
206
  const proposal = byId.get(item.id);
358
207
  if (!proposal) {
359
208
  stillDeferred.push(item);
360
209
  continue;
361
210
  }
362
- const ctx = prefetchJudgmentContext(input.stashDir, proposal, input.pending);
363
- const prompt = buildJudgmentPrompt(proposal, item.reason, ctx);
364
- let dispatch;
365
- try {
366
- dispatch = await dispatchJudgment(input.runner, prompt, input.seams, input.lease);
367
- }
368
- catch (err) {
369
- if (err instanceof ConfigError)
370
- throw err;
371
- warn(`[triage] judgment dispatch failed for ${item.id}: ${err instanceof Error ? err.message : String(err)}`);
372
- stillDeferred.push(item);
373
- continue;
374
- }
375
- for (const notice of dispatch.notices) {
376
- const key = JSON.stringify(notice);
377
- if (!noticesByKey.has(key))
378
- noticesByKey.set(key, notice);
379
- }
380
- if (dispatch.error) {
211
+ const prompt = buildJudgmentPrompt(proposal, item.reason, {
212
+ liveAsset: readLiveAssetContent(opts.stashDir, proposal.ref),
213
+ siblings: pending.filter((p) => p.ref === proposal.ref && p.id !== proposal.id),
214
+ });
215
+ const dispatch = await dispatchJudgment(opts.judgment, prompt, seams);
216
+ notices.add(dispatch.notices);
217
+ if (dispatch.error)
381
218
  warn(`[triage] judgment dispatch failed for ${item.id}: ${dispatch.error}`);
382
- stillDeferred.push(item);
383
- continue;
384
- }
385
- const verdict = dispatch.verdict;
219
+ const verdict = dispatch.error ? null : dispatch.verdict;
386
220
  if (!verdict || verdict.decision === "defer") {
387
221
  stillDeferred.push(item);
388
222
  continue;
389
223
  }
390
224
  if (verdict.decision === "reject") {
391
- if (input.dryRun) {
392
- rejected.push(item.id);
225
+ const failure = await rejectProposal(opts, item.id, verdict.reason || "judgment: reject", "judgment-reject", rejectFn);
226
+ if (failure === undefined) {
227
+ result.rejected.push(item.id);
228
+ }
229
+ else {
230
+ warn(`[triage] judgment reject failed for ${item.id}: ${failure}`);
231
+ stillDeferred.push(item);
232
+ }
233
+ continue;
234
+ }
235
+ // Queue mode never writes the asset: the verdict is staged for a later promote run.
236
+ if (opts.applyMode !== "promote") {
237
+ if (opts.dryRun) {
238
+ result.staged.push(item.id);
393
239
  continue;
394
240
  }
395
241
  try {
396
- await input.rejectFn({
397
- stashDir: input.stashDir,
398
- id: item.id,
399
- reason: verdict.reason || "judgment: reject",
400
- gateDecision: { outcome: "auto-rejected", reason: "judgment-reject", gate: input.gateLabel },
242
+ recordGateDecision(opts.stashDir, item.id, {
243
+ outcome: "staged",
244
+ reason: "judgment-accept",
245
+ contentHash: proposalContentHash(proposal),
246
+ gate: DRAIN_GATE,
401
247
  });
402
- rejected.push(item.id);
248
+ result.staged.push(item.id);
403
249
  }
404
250
  catch (err) {
405
- warn(`[triage] judgment reject failed for ${item.id}: ${err instanceof Error ? err.message : String(err)}`);
251
+ warn(`[triage] failed to stage judgment for ${item.id}: ${errMessage(err)}`);
406
252
  stillDeferred.push(item);
407
253
  }
408
254
  continue;
409
255
  }
410
- // decision === "accept" — gated on applyMode, exactly like the
411
- // deterministic accept path (queue mode never writes).
412
- if (input.applyMode !== "promote") {
413
- // Staged: a queue-mode run never promotes, so the item stays pending but
414
- // is RESOLVED (the runner judged it). Track separately so it is NOT
415
- // reported as "left unresolved" and a follow-up promote run picks it up.
416
- staged.push(item.id);
417
- if (!input.dryRun) {
418
- try {
419
- recordGateDecision(input.stashDir, item.id, {
420
- outcome: "staged",
421
- reason: "judgment-accept",
422
- contentHash: judgedContentHash(proposal),
423
- gate: input.gateLabel,
424
- });
425
- }
426
- catch (err) {
427
- warn(`[triage] failed to stage judgment for ${item.id}: ${err instanceof Error ? err.message : String(err)}`);
428
- staged.pop();
429
- stillDeferred.push(item);
430
- }
431
- }
432
- continue;
433
- }
434
- // Accept cap: once the shared budget is exhausted, route further accepts to
435
- // skippedByCap instead of promoting (keeps total promotions ≤ maxAccepts).
436
256
  if (acceptBudget <= 0) {
437
- skippedByCap.push(item.id);
257
+ result.skippedByCap.push(item.id);
438
258
  continue;
439
259
  }
440
- if (input.dryRun) {
441
- try {
442
- if (input.config) {
443
- const preflight = preflightProposalPromotion(input.config, proposal, {
444
- ...(input.target ? { target: input.target } : {}),
445
- gateDecision: { outcome: "auto-accepted", reason: "judgment-accept", gate: input.gateLabel },
446
- });
447
- assertProposalTargetFresh(proposal, preflight.assetPath);
448
- }
449
- }
450
- catch (err) {
451
- const message = err instanceof Error ? err.message : String(err);
452
- if (categorizeDrainFailure(message, "promote-error") === STALE_TARGET_GATE_REASON) {
453
- rejected.push(item.id);
454
- continue;
455
- }
456
- warn(`[triage] judgment preflight failed for ${item.id}: ${message}`);
457
- stillDeferred.push(item);
458
- continue;
459
- }
460
- promoted.push(item.id);
260
+ const outcome = await acceptProposal(opts, proposal, item.id, "judgment-accept", promoteFn, rejectFn);
261
+ if (outcome === "promoted") {
262
+ result.promoted.push(item.id);
461
263
  acceptBudget -= 1;
462
- continue;
463
264
  }
464
- try {
465
- await input.promoteFn({
466
- stashDir: input.stashDir,
467
- id: item.id,
468
- ...(input.target ? { target: input.target } : {}),
469
- ...(input.config ? { config: input.config } : {}),
470
- gateDecision: { outcome: "auto-accepted", reason: "judgment-accept", gate: input.gateLabel },
471
- });
472
- promoted.push(item.id);
473
- acceptBudget -= 1;
265
+ else if (outcome === "rejected") {
266
+ result.rejected.push(item.id);
474
267
  }
475
- catch (err) {
476
- const message = err instanceof Error ? err.message : String(err);
477
- if (categorizeDrainFailure(message, "promote-error") === STALE_TARGET_GATE_REASON &&
478
- (await autoRejectStaleTarget(input.stashDir, input.gateLabel, item.id, message, input.rejectFn))) {
479
- rejected.push(item.id);
480
- continue;
481
- }
482
- warn(`[triage] judgment promote failed for ${item.id}: ${message}`);
268
+ else {
269
+ warn(`[triage] judgment ${opts.dryRun ? "preflight" : "promote"} failed for ${item.id}: ${outcome.message}`);
483
270
  stillDeferred.push(item);
484
271
  }
485
272
  }
486
- return {
487
- promoted,
488
- rejected,
489
- staged,
490
- skippedByCap,
491
- stillDeferred,
492
- notices: Object.freeze([...noticesByKey.values()]),
493
- };
273
+ const capped = result.skippedByCap.length - cappedBefore;
274
+ if (capped > 0) {
275
+ info(`[triage] accept ceiling reached in judgment tier: ${capped} judged-accept items skipped by cap (maxAccepts=${opts.maxAccepts})`);
276
+ }
277
+ if (notices.list().length > 0)
278
+ result.notices = notices.list();
279
+ result.deferred = stillDeferred;
280
+ }
281
+ /** The live asset a proposal would overwrite, if any. */
282
+ function readLiveAssetContent(stashDir, ref) {
283
+ try {
284
+ const parsed = parseRefInput(ref);
285
+ const typeDir = stashDirFor(parsed.type);
286
+ if (!typeDir)
287
+ return undefined;
288
+ const assetPath = assetPathForName(parsed.type, path.join(stashDir, typeDir), parsed.name);
289
+ return fs.existsSync(assetPath) ? fs.readFileSync(assetPath, "utf8") : undefined;
290
+ }
291
+ catch {
292
+ return undefined;
293
+ }
494
294
  }
495
- /** Classify the queue without mutating proposal, event, or promotion state. */
496
- function classifyPendingProposals(opts) {
295
+ /**
296
+ * Drain the pending backlog. `promoteFn` / `rejectFn` / `judgmentSeams` are
297
+ * test seams.
298
+ */
299
+ export async function drainProposals(opts, promoteFn = akmProposalAccept, rejectFn = akmProposalReject, judgmentSeams = {}) {
497
300
  const exclude = opts.excludeIds ?? new Set();
498
- // A configured judgment runner must be credential-validated before any live
499
- // state connection or migration. Its classification pass therefore reads an
500
- // isolated SQLite snapshot; deterministic-only drains retain the historical
501
- // live/migrating queue read.
502
- const pending = (opts.judgment ? listProposalsReadOnly : listProposals)(opts.stashDir, {
503
- status: "pending",
504
- }).filter((proposal) => !exclude.has(proposal.id));
505
- const acceptIds = [];
506
- const acceptGateReasons = new Map();
507
- const rejectTargets = [];
508
- const deferred = [];
509
- const deferredGateDecisions = [];
510
- const gateLabel = `triage:${opts.policy.name}`;
511
- const needsJudge = new Set();
301
+ // A judgment runner's credentials are validated before any live state
302
+ // connection, so its classification reads an isolated snapshot.
303
+ const pending = (opts.judgment ? listProposalsReadOnly : listProposals)(opts.stashDir, { status: "pending" }).filter((proposal) => !exclude.has(proposal.id));
304
+ const result = { promoted: [], rejected: [], deferred: [], skippedByCap: [], staged: [], failed: [] };
305
+ const accepts = [];
306
+ const empties = [];
512
307
  for (const proposal of pending) {
513
- // An authoritative rejection from another gate stays pending and is never
514
- // silently overwritten by this triage policy.
515
- if (proposal.gateDecision?.outcome === "auto-rejected" && !proposal.gateDecision.gate?.startsWith("triage:")) {
516
- continue;
517
- }
518
- // REVIEW: a `review_needed` distill/promote-memory row is stamped
519
- // `deferred`/`quality-gate` by `writeQualityRejection` (distill/quality-gate.ts)
520
- // precisely because the quality judge could not decide and wants a human,
521
- // not the judgment tier, to see it. Skip it here — before `classifyProposal`
522
- // would otherwise defer it to the judgment tier (which can auto-accept
523
- // under `applyMode: promote`) and before the policy-deferred re-stamp loop
524
- // in `drainProposals` would overwrite this stamp with a `triage:` one.
525
- if (proposal.gateDecision?.outcome === "deferred" && proposal.gateDecision.gate === "quality-gate") {
526
- continue;
527
- }
528
- if (proposal.gateDecision?.outcome === "staged" &&
529
- proposal.gateDecision.gate === gateLabel &&
530
- proposal.gateDecision.contentHash === judgedContentHash(proposal)) {
531
- acceptIds.push(proposal.id);
532
- acceptGateReasons.set(proposal.id, "judgment-accept");
308
+ const decision = proposal.gateDecision;
309
+ // Another gate's rejection stands; a human-review deferral from the distill
310
+ // quality gate is left for that human.
311
+ if (decision?.outcome === "auto-rejected" && !decision.gate?.startsWith(DRAIN_GATE))
533
312
  continue;
534
- }
535
- const decision = classifyProposal(proposal, opts.policy, opts.maxDiffLines);
536
- if (decision === null)
313
+ if (decision?.outcome === "deferred" && decision.gate === "quality-gate")
537
314
  continue;
538
- if (decision.verdict === "defer") {
539
- deferredGateDecisions.push({
540
- id: proposal.id,
541
- decision: {
542
- outcome: "deferred",
543
- reason: decision.gate.reason,
544
- ...(decision.gate.measured !== undefined ? { measured: decision.gate.measured } : {}),
545
- ...(decision.gate.thresholds ? { thresholds: decision.gate.thresholds } : {}),
546
- gate: gateLabel,
547
- },
548
- });
549
- if (!decision.gate.thresholds)
550
- needsJudge.add(proposal.id);
315
+ if (isEmptyDiff(proposal)) {
316
+ empties.push(proposal.id);
551
317
  }
552
- if (decision.verdict === "accept") {
553
- acceptIds.push(proposal.id);
554
- acceptGateReasons.set(proposal.id, "policy-accept");
555
- }
556
- else if (decision.verdict === "reject") {
557
- rejectTargets.push({ id: proposal.id, reason: decision.reason });
318
+ else if (decision?.outcome === "staged" && decision.contentHash === proposalContentHash(proposal)) {
319
+ accepts.push({ id: proposal.id, reason: decision.gate === "quality-gate" ? "judge-passed" : "judgment-accept" });
558
320
  }
559
321
  else {
560
- deferred.push({ id: proposal.id, reason: decision.reason });
322
+ result.deferred.push({ id: proposal.id, reason: "needs-judgment" });
561
323
  }
562
324
  }
563
- return {
564
- pending,
565
- acceptIds,
566
- acceptGateReasons,
567
- rejectTargets,
568
- deferred,
569
- deferredGateDecisions,
570
- gateLabel,
571
- needsJudge,
572
- };
573
- }
574
- /**
575
- * Drain the standing pending backlog under a deterministic policy.
576
- *
577
- * @param opts Drain options (policy, applyMode, ceilings, dry-run).
578
- * @param promoteFn Injectable override for `akmProposalAccept` (test seam).
579
- * @param rejectFn Injectable override for `akmProposalReject` (test seam).
580
- */
581
- export async function drainProposals(opts, promoteFn = akmProposalAccept, rejectFn = akmProposalReject, judgmentSeams = {}) {
582
- const classification = classifyPendingProposals(opts);
583
- const { pending, acceptIds, acceptGateReasons, rejectTargets, deferredGateDecisions, gateLabel, needsJudge } = classification;
584
- const result = {
585
- promoted: [],
586
- rejected: [],
587
- deferred: classification.deferred,
588
- skippedByCap: [],
589
- staged: [],
590
- failed: [],
591
- };
592
- // A configured judgment runner makes every deferred item dispatch-eligible.
593
- // Validate its symbolic credentials before applying any deterministic gate,
594
- // reject, promote, or event mutation. Provider/runtime failures remain the
595
- // judgment tier's fail-soft responsibility after this configuration fence.
596
- const dispatchLease = opts.judgment && result.deferred.length > 0 ? await preflightJudgmentRunner(opts.judgment) : undefined;
597
- try {
598
- for (const { id, decision } of deferredGateDecisions)
599
- stampGateDecision(opts, id, decision);
600
- // --- Reject empties (independent of the accept ceiling / applyMode) ---
601
- for (const target of rejectTargets) {
602
- if (opts.dryRun) {
603
- result.rejected.push(target.id);
604
- continue;
605
- }
606
- try {
607
- await rejectFn({
608
- stashDir: opts.stashDir,
609
- id: target.id,
610
- reason: target.reason,
611
- gateDecision: { outcome: "auto-rejected", reason: "empty-diff", gate: gateLabel },
612
- });
613
- result.rejected.push(target.id);
614
- }
615
- catch (err) {
616
- const message = pushDrainFailure(result, target.id, err, "reject-error");
617
- warn(`[triage] reject failed for ${target.id}: ${message}`);
618
- }
325
+ if (opts.judgment && result.deferred.length > 0) {
326
+ // Symbolic credentials are checked before any gate, reject or promote.
327
+ const prepared = resolveExecution({
328
+ content: "Validate the selected proposal judgment runner before mutation.",
329
+ runner: opts.judgment,
330
+ });
331
+ assertRunnerCredentials(buildExecution(prepared.request, prepared.runner).runner);
332
+ }
333
+ for (const id of empties) {
334
+ const failure = await rejectProposal(opts, id, "empty diff", "empty-diff", rejectFn);
335
+ if (failure === undefined) {
336
+ result.rejected.push(id);
619
337
  }
620
- // --- Accept ceiling: enforced BEFORE the promote loop ---
621
- const withinCap = acceptIds.slice(0, Math.max(0, opts.maxAccepts));
622
- result.skippedByCap = acceptIds.slice(Math.max(0, opts.maxAccepts));
623
- if (result.skippedByCap.length > 0) {
624
- info(`[triage] accept ceiling reached: ${withinCap.length} promoted, ${result.skippedByCap.length} skipped by cap (maxAccepts=${opts.maxAccepts})`);
338
+ else {
339
+ result.failed.push({ id, reason: categorizeDrainFailure(failure, "reject-error"), detail: failure });
340
+ warn(`[triage] reject failed for ${id}: ${failure}`);
625
341
  }
626
- // --- Promotion gate: applyMode "queue" never promotes (stage only) ---
627
- // Count deterministic promotions so the judgment tier shares the same accept
628
- // budget (deterministic + judgment promotions ≤ maxAccepts).
629
- let deterministicPromoted = 0;
630
- if (opts.applyMode === "promote" && !opts.dryRun) {
342
+ }
343
+ const cap = Math.max(0, opts.maxAccepts);
344
+ const withinCap = accepts.slice(0, cap);
345
+ result.skippedByCap = accepts.slice(cap).map((a) => a.id);
346
+ if (result.skippedByCap.length > 0) {
347
+ info(`[triage] accept ceiling reached: ${withinCap.length} promoted, ${result.skippedByCap.length} skipped by cap (maxAccepts=${opts.maxAccepts})`);
348
+ }
349
+ let promotedHere = 0;
350
+ if (opts.applyMode === "promote") {
351
+ if (!opts.dryRun)
631
352
  info(`[triage] auto-promote active: ${withinCap.length} accepts allowed this run`);
632
- for (const id of withinCap) {
633
- try {
634
- await promoteFn({
635
- stashDir: opts.stashDir,
636
- id,
637
- ...(opts.target ? { target: opts.target } : {}),
638
- ...(opts.config ? { config: opts.config } : {}),
639
- gateDecision: {
640
- outcome: "auto-accepted",
641
- reason: acceptGateReasons.get(id) ?? "policy-accept",
642
- gate: gateLabel,
643
- },
644
- });
645
- result.promoted.push(id);
646
- deterministicPromoted += 1;
647
- }
648
- catch (err) {
649
- const message = err instanceof Error ? err.message : String(err);
650
- if (categorizeDrainFailure(message, "promote-error") === STALE_TARGET_GATE_REASON &&
651
- (await autoRejectStaleTarget(opts.stashDir, gateLabel, id, message, rejectFn))) {
652
- result.rejected.push(id);
653
- continue;
654
- }
655
- pushDrainFailure(result, id, err, "promote-error");
656
- warn(`[triage] promote failed for ${id}: ${message}`);
657
- }
353
+ const byId = new Map(pending.map((proposal) => [proposal.id, proposal]));
354
+ for (const { id, reason } of withinCap) {
355
+ const outcome = await acceptProposal(opts, byId.get(id), id, reason, promoteFn, rejectFn);
356
+ if (outcome === "promoted") {
357
+ result.promoted.push(id);
358
+ promotedHere += 1;
658
359
  }
659
- }
660
- else if (opts.applyMode === "promote" && opts.dryRun) {
661
- // Exercise the same stamped candidate, lint, and stale-target boundary as
662
- // real promotion so a dry-run's predicted promotions match what a real
663
- // run would do. Tests that omit config retain the classification-only seam.
664
- const byId = new Map(pending.map((proposal) => [proposal.id, proposal]));
665
- for (const id of withinCap) {
666
- try {
667
- if (opts.config) {
668
- const proposal = byId.get(id);
669
- if (!proposal)
670
- throw new Error(`Proposal ${id} disappeared during drain preflight.`);
671
- const preflight = preflightProposalPromotion(opts.config, proposal, {
672
- ...(opts.target ? { target: opts.target } : {}),
673
- gateDecision: {
674
- outcome: "auto-accepted",
675
- reason: acceptGateReasons.get(id) ?? "policy-accept",
676
- gate: gateLabel,
677
- },
678
- });
679
- assertProposalTargetFresh(proposal, preflight.assetPath);
680
- }
681
- result.promoted.push(id);
682
- deterministicPromoted += 1;
683
- }
684
- catch (err) {
685
- const message = err instanceof Error ? err.message : String(err);
686
- if (categorizeDrainFailure(message, "promote-error") === STALE_TARGET_GATE_REASON) {
687
- result.rejected.push(id);
688
- continue;
689
- }
690
- pushDrainFailure(result, id, err, "promote-error");
691
- warn(`[triage] preflight failed for ${id}: ${message}`);
692
- }
360
+ else if (outcome === "rejected") {
361
+ result.rejected.push(id);
693
362
  }
694
- }
695
- // applyMode "queue": leave accept candidates pending (staged). No promotion.
696
- // Remaining accept budget for the judgment tier: maxAccepts minus what was
697
- // actually promoted deterministically. Bounds the TOTAL promotions, not just
698
- // the deterministic path. Moot in queue mode (it promotes nothing).
699
- const remainingAcceptBudget = Math.max(0, Math.max(0, opts.maxAccepts) - deterministicPromoted);
700
- // --- Judgment tier (Phase 3): adjudicate the deferred items ---
701
- // Only runs when a RunnerSpec is configured. The runner returns a verdict; the
702
- // ENGINE performs the resulting accept (respecting applyMode) / reject write.
703
- if (opts.judgment && result.deferred.length > 0) {
704
- if (!dispatchLease)
705
- throw new TypeError("proposal judgment work requires an operation dispatch lease");
706
- const tier = await runJudgmentTier({
707
- stashDir: opts.stashDir,
708
- applyMode: opts.applyMode,
709
- dryRun: opts.dryRun,
710
- runner: opts.judgment,
711
- lease: dispatchLease,
712
- deferred: result.deferred,
713
- pending,
714
- promoteFn,
715
- rejectFn,
716
- seams: judgmentSeams,
717
- ...(opts.target ? { target: opts.target } : {}),
718
- ...(opts.config ? { config: opts.config } : {}),
719
- remainingAcceptBudget,
720
- gateLabel,
721
- });
722
- result.promoted.push(...tier.promoted);
723
- result.rejected.push(...tier.rejected);
724
- result.staged.push(...tier.staged);
725
- if (tier.notices.length > 0)
726
- result.notices = tier.notices;
727
- // Judgment-tier accepts dropped by the shared accept cap surface under
728
- // skippedByCap, same as deterministic cap drops.
729
- result.skippedByCap.push(...tier.skippedByCap);
730
- if (tier.skippedByCap.length > 0) {
731
- info(`[triage] accept ceiling reached in judgment tier: ${tier.skippedByCap.length} judged-accept items skipped by cap (maxAccepts=${opts.maxAccepts})`);
732
- }
733
- // Replace the deferred list with only the items the judgment tier could NOT
734
- // resolve (verdict "defer", parse failure, or runner error). Staged
735
- // queue-mode accepts are RESOLVED and tracked in result.staged instead.
736
- result.deferred = tier.stillDeferred;
737
- }
738
- else if (result.deferred.length > 0) {
739
- // #577: no judgment runner configured — items deferred *because they need a
740
- // judge* (mid-band / possible-dup, no threshold reason) stay pending solely
741
- // for lack of one. Re-stamp those as `no-judge-configured` so the operator
742
- // sees a per-proposal reason instead of inferring it from the run-level
743
- // triage_deferred aggregate. Band-deferred items keep their specific reason
744
- // (e.g. `max-diff-lines`), which is more actionable than "no judge".
745
- for (const item of result.deferred) {
746
- if (needsJudge.has(item.id)) {
747
- stampGateDecision(opts, item.id, { outcome: "deferred", reason: "no-judge-configured", gate: gateLabel });
748
- }
363
+ else {
364
+ result.failed.push({
365
+ id,
366
+ reason: categorizeDrainFailure(outcome.message, "promote-error"),
367
+ detail: outcome.message,
368
+ });
369
+ warn(`[triage] ${opts.dryRun ? "preflight" : "promote"} failed for ${id}: ${outcome.message}`);
749
370
  }
750
371
  }
751
- emitDrainEvents(opts, result);
752
- return result;
753
372
  }
754
- finally {
755
- if (dispatchLease)
756
- disposeLoweredExecutionDispatchLease(dispatchLease);
373
+ if (opts.judgment && result.deferred.length > 0) {
374
+ await runJudgmentTier({ ...opts, judgment: opts.judgment }, result, pending, cap - promotedHere, promoteFn, rejectFn, judgmentSeams);
757
375
  }
758
- }
759
- /**
760
- * Persist a gate decision onto a proposal, honouring the dry-run contract
761
- * (a dry run performs zero writes, so it records nothing) and never letting a
762
- * persistence failure abort the drain (#577). Best-effort by design.
763
- */
764
- function stampGateDecision(opts, id, decision) {
765
- if (opts.dryRun)
766
- return;
767
- try {
768
- recordGateDecision(opts.stashDir, id, decision);
769
- }
770
- catch (err) {
771
- warn(`[triage] failed to record gate decision for ${id}: ${err instanceof Error ? err.message : String(err)}`);
376
+ // #577: whatever stays undecided is left for review (`review_needed` in the ledger).
377
+ if (!opts.dryRun) {
378
+ const reviewReason = opts.judgment ? "judgment-deferred" : "no-judge-configured";
379
+ for (const item of result.deferred) {
380
+ try {
381
+ recordGateDecision(opts.stashDir, item.id, { outcome: "deferred", reason: reviewReason, gate: DRAIN_GATE });
382
+ }
383
+ catch (err) {
384
+ warn(`[triage] failed to record gate decision for ${item.id}: ${errMessage(err)}`);
385
+ }
386
+ }
772
387
  }
388
+ emitDrainEvents(opts, result);
389
+ return result;
773
390
  }
774
- // ---------------------------------------------------------------------------
775
- // Events
776
- // ---------------------------------------------------------------------------
777
391
  function emitDrainEvents(opts, result) {
778
392
  const deferredByReason = {};
779
- for (const d of result.deferred) {
393
+ for (const d of result.deferred)
780
394
  deferredByReason[d.reason] = (deferredByReason[d.reason] ?? 0) + 1;
781
- }
782
395
  appendEvent({
783
396
  eventType: "triage_drained",
784
397
  metadata: {
@@ -787,17 +400,11 @@ function emitDrainEvents(opts, result) {
787
400
  deferredByReason,
788
401
  skippedByCap: result.skippedByCap.length,
789
402
  ...(result.staged.length > 0 ? { staged: result.staged.length } : {}),
790
- policy: opts.policy.name,
791
403
  applyMode: opts.applyMode,
792
404
  ...(opts.dryRun ? { dryRun: true } : {}),
793
405
  },
794
406
  }, opts.eventsCtx ?? {});
795
- // Surface any items the judge could NOT resolve after the (optional) judgment
796
- // tier so a backlog of deferred items never silently looks like full success.
797
- // This fires when no runner is configured OR the judgment tier ran but could
798
- // not resolve every item (verdict "defer", parse failure, or a runner error).
799
- // Queue-mode staged accepts are RESOLVED (the judge decided) and live in
800
- // result.staged, so they are deliberately excluded from this "unresolved" count.
407
+ // Undecided items must never look like full success. Staged accepts were decided.
801
408
  if (result.deferred.length > 0) {
802
409
  appendEvent({
803
410
  eventType: "triage_deferred",