akm-cli 0.9.0-rc.0 → 0.9.0-rc.13

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 (598) hide show
  1. package/CHANGELOG.md +1283 -22
  2. package/README.md +62 -37
  3. package/SECURITY.md +46 -31
  4. package/dist/akm +162 -38
  5. package/dist/akm-migrate +44 -0
  6. package/dist/assets/backends/schtasks-template.xml +2 -1
  7. package/dist/assets/hints/cli-hints-full.md +268 -118
  8. package/dist/assets/hints/cli-hints-short.md +87 -24
  9. package/dist/assets/{profiles → improve-strategies}/catchup.json +3 -1
  10. package/dist/assets/{profiles → improve-strategies}/consolidate.json +3 -1
  11. package/dist/assets/{profiles → improve-strategies}/default.json +6 -7
  12. package/dist/assets/improve-strategies/frequent.json +15 -0
  13. package/dist/assets/{profiles → improve-strategies}/graph-refresh.json +4 -2
  14. package/dist/assets/{profiles → improve-strategies}/memory-focus.json +4 -1
  15. package/dist/assets/{profiles → improve-strategies}/proactive-maintenance.json +5 -5
  16. package/dist/assets/{profiles → improve-strategies}/quick.json +4 -2
  17. package/dist/assets/improve-strategies/reflect-distill.json +30 -0
  18. package/dist/assets/{profiles → improve-strategies}/thorough.json +1 -1
  19. package/dist/assets/prompts/consolidate-system.md +5 -5
  20. package/dist/assets/prompts/extract-session.md +2 -6
  21. package/dist/assets/prompts/memory-infer-user.md +2 -3
  22. package/dist/assets/prompts/reflect-llm-framed-contract.md +11 -0
  23. package/dist/assets/prompts/reflect-llm-schema-contract.md +3 -0
  24. package/dist/assets/prompts/reflect-output-repair.md +3 -0
  25. package/dist/assets/prompts/workflow-unit-preamble.md +26 -0
  26. package/dist/assets/stash-skeleton/README.md +38 -10
  27. package/dist/assets/stash-skeleton/facts/conventions/assets/agent.md +8 -0
  28. package/dist/assets/stash-skeleton/facts/conventions/assets/command.md +8 -0
  29. package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +14 -1
  30. package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +13 -1
  31. package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +9 -1
  32. package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +11 -0
  33. package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +9 -0
  34. package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +9 -0
  35. package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +8 -0
  36. package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +100 -0
  37. package/dist/assets/stash-skeleton/facts/conventions/domains.md +64 -0
  38. package/dist/assets/stash-skeleton/facts/conventions/organization.md +136 -0
  39. package/dist/assets/tasks/core/extract.yml +3 -2
  40. package/dist/assets/tasks/core/improve.yml +2 -1
  41. package/dist/assets/tasks/core/index-refresh.yml +1 -0
  42. package/dist/assets/tasks/core/sync.yml +1 -0
  43. package/dist/assets/tasks/core/version-check.yml +2 -1
  44. package/dist/assets/tasks/improve/akm-graph-refresh-weekly.yml +5 -0
  45. package/dist/assets/tasks/improve/akm-improve-catchup.yml +8 -0
  46. package/dist/assets/tasks/improve/akm-improve-consolidate.yml +5 -0
  47. package/dist/assets/tasks/improve/akm-improve-frequent.yml +5 -0
  48. package/dist/assets/tasks/improve/akm-improve-nightly.yml +5 -0
  49. package/dist/assets/templates/html/health.html +5 -4
  50. package/dist/assets/workflows/workflow-template.md +31 -15
  51. package/dist/cli/invocation.js +279 -0
  52. package/dist/cli/parse-args.js +5 -90
  53. package/dist/cli/retired-commands.js +78 -0
  54. package/dist/cli/shared.js +158 -48
  55. package/dist/cli-node.mjs +2 -1
  56. package/dist/cli.js +747 -293
  57. package/dist/commands/agent/agent-dispatch.js +19 -18
  58. package/dist/commands/agent/agent-support.js +0 -24
  59. package/dist/commands/agent/contribute-cli.js +43 -97
  60. package/dist/commands/completions.js +80 -23
  61. package/dist/commands/config-cli.js +44 -281
  62. package/dist/commands/env/env-binding.js +99 -0
  63. package/dist/commands/env/env-cli.js +84 -224
  64. package/dist/commands/env/env.js +12 -163
  65. package/dist/commands/env/marker-path.js +6 -0
  66. package/dist/commands/env/secret-cli.js +45 -61
  67. package/dist/commands/env/secret.js +32 -62
  68. package/dist/commands/feedback-cli.js +179 -85
  69. package/dist/commands/health/accept-rate.js +58 -0
  70. package/dist/commands/health/advisories.js +7 -8
  71. package/dist/commands/health/checks.js +279 -94
  72. package/dist/commands/health/html-report.js +197 -578
  73. package/dist/commands/health/improve-metrics.js +277 -246
  74. package/dist/commands/health/llm-usage.js +19 -19
  75. package/dist/commands/health/md-report.js +16 -7
  76. package/dist/commands/health/metrics.js +67 -32
  77. package/dist/commands/health/renderers.js +47 -0
  78. package/dist/commands/health/report-view-model.js +508 -0
  79. package/dist/commands/health/stash-exposure.js +1 -1
  80. package/dist/commands/health/surfaces.js +16 -56
  81. package/dist/commands/health/task-runs.js +3 -67
  82. package/dist/{migrate-storage-node.mjs → commands/health/types-checks.js} +1 -5
  83. package/dist/commands/health/types-improve.js +29 -0
  84. package/dist/{output/text/save.js → commands/health/types-metrics.js} +1 -2
  85. package/dist/commands/health/types-result.js +7 -0
  86. package/dist/commands/health/types-runs.js +4 -0
  87. package/dist/commands/health/types-session-log.js +4 -0
  88. package/dist/commands/health/types-windows.js +4 -0
  89. package/dist/commands/health/types.js +26 -21
  90. package/dist/commands/health/windows.js +2 -3
  91. package/dist/commands/health.js +296 -167
  92. package/dist/commands/improve/anti-collapse.js +5 -5
  93. package/dist/commands/improve/autonomy-gate.js +68 -0
  94. package/dist/commands/improve/collapse-detector.js +65 -52
  95. package/dist/commands/improve/consolidate/chunking.js +9 -7
  96. package/dist/commands/improve/consolidate/eligibility.js +1 -23
  97. package/dist/commands/improve/consolidate/merge.js +4 -0
  98. package/dist/commands/improve/consolidate.js +454 -1354
  99. package/dist/commands/improve/content-hash.js +39 -0
  100. package/dist/commands/improve/distill/content-repair.js +4 -10
  101. package/dist/commands/improve/distill/promote-memory.js +89 -64
  102. package/dist/commands/improve/distill/quality-gate.js +118 -42
  103. package/dist/commands/improve/distill-guards.js +1 -1
  104. package/dist/commands/improve/distill-promotion-policy.js +33 -888
  105. package/dist/commands/improve/distill.js +607 -363
  106. package/dist/commands/improve/eligibility.js +165 -79
  107. package/dist/commands/improve/extract-cli.js +35 -126
  108. package/dist/commands/improve/extract-prompt.js +6 -35
  109. package/dist/commands/improve/extract.js +640 -391
  110. package/dist/commands/improve/feedback-valence.js +2 -12
  111. package/dist/commands/improve/improve-cli.js +134 -135
  112. package/dist/commands/improve/improve-result-file.js +30 -50
  113. package/dist/commands/improve/improve-run-types.js +4 -0
  114. package/dist/commands/improve/improve-strategies.js +135 -0
  115. package/dist/commands/improve/improve.js +904 -701
  116. package/dist/commands/improve/locks.js +64 -111
  117. package/dist/commands/improve/loop-stages.js +1110 -923
  118. package/dist/commands/improve/memory/derived-ref.js +124 -0
  119. package/dist/commands/improve/memory/memory-belief.js +79 -7
  120. package/dist/commands/improve/memory/memory-contradiction-detect.js +49 -52
  121. package/dist/commands/improve/memory/memory-improve.js +25 -37
  122. package/dist/commands/improve/outcome-loop.js +25 -88
  123. package/dist/commands/improve/preparation.js +1034 -813
  124. package/dist/commands/improve/proactive-maintenance.js +34 -9
  125. package/dist/commands/improve/proposal-envelope.js +31 -0
  126. package/dist/commands/improve/reflect.js +983 -794
  127. package/dist/commands/improve/run-context.js +119 -0
  128. package/dist/commands/improve/salience.js +24 -127
  129. package/dist/commands/improve/session-asset.js +7 -3
  130. package/dist/commands/improve/shared.js +14 -34
  131. package/dist/commands/improve/source-identity.js +28 -0
  132. package/dist/commands/improve/triage.js +20 -17
  133. package/dist/commands/lint/base-linter.js +340 -313
  134. package/dist/commands/lint/env-key-rules.js +31 -47
  135. package/dist/commands/lint/index.js +185 -30
  136. package/dist/commands/{events.js → log.js} +28 -38
  137. package/dist/commands/migrate-cli.js +54 -0
  138. package/dist/commands/migration-tool.js +55 -0
  139. package/dist/commands/observability-cli.js +70 -208
  140. package/dist/commands/proposal/diff-format.js +50 -0
  141. package/dist/commands/proposal/drain-policies.js +0 -6
  142. package/dist/commands/proposal/drain.js +91 -40
  143. package/dist/commands/proposal/proposal-cli.js +134 -132
  144. package/dist/commands/proposal/proposal-types.js +56 -0
  145. package/dist/commands/proposal/proposal.js +83 -65
  146. package/dist/commands/proposal/propose-cli.js +88 -0
  147. package/dist/commands/proposal/propose.js +105 -88
  148. package/dist/commands/proposal/repository.js +1303 -278
  149. package/dist/commands/proposal/validators/proposal-quality-validators.js +16 -6
  150. package/dist/commands/proposal/validators/proposal-validators.js +61 -12
  151. package/dist/commands/proposal/validators/proposals.js +6 -8
  152. package/dist/commands/read/curate.js +78 -73
  153. package/dist/commands/read/knowledge.js +510 -13
  154. package/dist/commands/read/registry-search.js +2 -2
  155. package/dist/commands/read/remember-cli.js +84 -15
  156. package/dist/commands/read/search-cli.js +203 -96
  157. package/dist/commands/read/search.js +126 -94
  158. package/dist/commands/read/show.js +226 -250
  159. package/dist/commands/registry-cli.js +34 -60
  160. package/dist/commands/remember.js +18 -57
  161. package/dist/commands/sources/add-cli.js +104 -49
  162. package/dist/commands/sources/bundle-cli.js +166 -0
  163. package/dist/commands/sources/bundle-config-ops.js +63 -0
  164. package/dist/commands/sources/info.js +27 -15
  165. package/dist/commands/sources/init.js +30 -40
  166. package/dist/commands/sources/installed-stashes.js +469 -172
  167. package/dist/commands/sources/migration-help.js +7 -4
  168. package/dist/commands/sources/schema-repair.js +10 -9
  169. package/dist/commands/sources/self-update.js +182 -121
  170. package/dist/commands/sources/source-add.js +169 -178
  171. package/dist/commands/sources/source-clone.js +144 -41
  172. package/dist/commands/sources/source-manage.js +94 -59
  173. package/dist/commands/sources/sources-cli.js +64 -205
  174. package/dist/commands/sources/stash-cli.js +91 -54
  175. package/dist/commands/sources/stash-skeleton.js +1 -1
  176. package/dist/commands/tasks/tasks-cli.js +106 -104
  177. package/dist/commands/tasks/tasks.js +445 -262
  178. package/dist/commands/workflow-cli.js +232 -121
  179. package/dist/core/action-contributors.js +1 -1
  180. package/dist/core/activation-policy.js +49 -0
  181. package/dist/core/adapter/adapters/agent-skills-adapter.js +181 -0
  182. package/dist/core/adapter/adapters/akm-adapter.js +528 -0
  183. package/dist/core/adapter/adapters/akm-lint.js +392 -0
  184. package/dist/core/adapter/adapters/akm-metadata.js +387 -0
  185. package/dist/core/adapter/adapters/akm-task-adapter.js +149 -0
  186. package/dist/core/adapter/adapters/akm-workflow-adapter.js +180 -0
  187. package/dist/core/adapter/adapters/claude-adapter.js +61 -0
  188. package/dist/core/adapter/adapters/dotenv-adapter.js +187 -0
  189. package/dist/core/adapter/adapters/generic-files-adapter.js +119 -0
  190. package/dist/core/adapter/adapters/index.js +80 -0
  191. package/dist/core/adapter/adapters/llm-wiki-adapter.js +419 -0
  192. package/dist/core/adapter/adapters/okf-adapter.js +391 -0
  193. package/dist/core/adapter/adapters/opencode-adapter.js +68 -0
  194. package/dist/core/adapter/adapters/shared.js +286 -0
  195. package/dist/core/adapter/adapters/tool-dir-shared.js +217 -0
  196. package/dist/core/adapter/adapters/website-snapshot-adapter.js +155 -0
  197. package/dist/core/adapter/bundle-adapter.js +4 -0
  198. package/dist/core/adapter/detect-adapter.js +17 -0
  199. package/dist/core/adapter/recognize-match.js +44 -0
  200. package/dist/core/adapter/registry.js +56 -0
  201. package/dist/core/adapter/types.js +4 -0
  202. package/dist/core/asset/akm-markdown.js +30 -0
  203. package/dist/core/asset/asset-placement.js +243 -0
  204. package/dist/core/asset/asset-ref.js +110 -79
  205. package/dist/core/asset/asset-serialize.js +20 -0
  206. package/dist/core/asset/frontmatter.js +28 -12
  207. package/dist/core/asset/markdown.js +40 -51
  208. package/dist/core/asset/resolve-ref.js +274 -0
  209. package/dist/core/asset/stash-meta.js +2 -2
  210. package/dist/core/bundle-id.js +51 -0
  211. package/dist/core/common.js +281 -86
  212. package/dist/core/config/config-io.js +42 -128
  213. package/dist/core/config/config-schema.js +233 -834
  214. package/dist/core/config/config-sources.js +162 -39
  215. package/dist/core/config/config-types.js +16 -11
  216. package/dist/core/config/config-version.js +29 -0
  217. package/dist/core/config/config-walker.js +126 -37
  218. package/dist/core/config/config.js +154 -331
  219. package/dist/core/config/deep-merge.js +41 -0
  220. package/dist/core/config/engine-semantics.js +28 -0
  221. package/dist/core/config/experimental.js +21 -0
  222. package/dist/core/config/schema/embedding.js +38 -0
  223. package/dist/core/config/schema/engines.js +116 -0
  224. package/dist/core/config/schema/experimental.js +47 -0
  225. package/dist/core/config/schema/feedback.js +31 -0
  226. package/dist/core/config/schema/improve-processes.js +389 -0
  227. package/dist/core/config/schema/improve.js +94 -0
  228. package/dist/core/config/schema/index-config.js +176 -0
  229. package/dist/core/config/schema/output.js +18 -0
  230. package/dist/core/config/schema/primitives.js +94 -0
  231. package/dist/core/config/schema/search.js +30 -0
  232. package/dist/core/config/schema/setup.js +18 -0
  233. package/dist/core/config/schema/sources-bundles.js +169 -0
  234. package/dist/core/config/schema/workflow.js +29 -0
  235. package/dist/core/env-secret-ref.js +155 -20
  236. package/dist/core/errors.js +17 -15
  237. package/dist/core/events-types.js +4 -0
  238. package/dist/core/events.js +46 -128
  239. package/dist/core/extra-params.js +62 -0
  240. package/dist/core/file-change.js +17 -0
  241. package/dist/core/file-lock.js +202 -57
  242. package/dist/core/fs-txn.js +392 -0
  243. package/dist/core/git-message.js +59 -0
  244. package/dist/core/improve-result.js +167 -0
  245. package/dist/core/json-schema.js +142 -0
  246. package/dist/core/lesson-lint.js +1 -17
  247. package/dist/core/logs-db.js +1 -1
  248. package/dist/core/maintenance-barrier.js +135 -0
  249. package/dist/core/migration-operation.js +44 -0
  250. package/dist/core/mutation-target.js +78 -0
  251. package/dist/core/paths.js +22 -25
  252. package/dist/core/platform.js +10 -0
  253. package/dist/core/recognition-util.js +128 -0
  254. package/dist/core/redaction.js +392 -0
  255. package/dist/core/standards/resolve-standards-context.js +36 -65
  256. package/dist/core/standards/resolve-stash-standards.js +2 -2
  257. package/dist/core/standards/resolve-type-conventions.js +5 -5
  258. package/dist/core/state/migrations.js +242 -11
  259. package/dist/core/state-db.js +98 -10
  260. package/dist/core/structured.js +1 -1
  261. package/dist/core/subprocess.js +303 -0
  262. package/dist/core/text-truncation.js +9 -5
  263. package/dist/core/time.js +20 -0
  264. package/dist/core/type-presentation.js +130 -0
  265. package/dist/core/warn.js +0 -3
  266. package/dist/core/write-source.js +834 -118
  267. package/dist/indexer/bundle-identity-guard.js +92 -0
  268. package/dist/indexer/db/graph-db.js +1 -25
  269. package/dist/indexer/db/llm-cache.js +1 -1
  270. package/dist/indexer/ensure-index.js +30 -9
  271. package/dist/indexer/graph/graph-boost.js +9 -30
  272. package/dist/indexer/graph/graph-extraction.js +41 -27
  273. package/dist/indexer/graph/graph-types.js +4 -0
  274. package/dist/indexer/index-writer-lock.js +93 -49
  275. package/dist/indexer/index-written-assets.js +100 -53
  276. package/dist/indexer/indexer.js +746 -329
  277. package/dist/indexer/init.js +18 -25
  278. package/dist/indexer/installations.js +142 -0
  279. package/dist/indexer/passes/dir-staleness.js +18 -10
  280. package/dist/indexer/passes/memory-inference.js +25 -15
  281. package/dist/indexer/passes/metadata.js +412 -243
  282. package/dist/indexer/scan/doc-to-entry.js +160 -0
  283. package/dist/indexer/scan/drain-dir.js +134 -0
  284. package/dist/indexer/search/db-search.js +292 -108
  285. package/dist/indexer/search/fts-query.js +64 -0
  286. package/dist/indexer/search/ranking-contributors.js +145 -25
  287. package/dist/indexer/search/ranking-types.js +4 -0
  288. package/dist/indexer/search/ranking.js +28 -71
  289. package/dist/indexer/search/search-attribution.js +67 -0
  290. package/dist/indexer/search/search-fields.js +18 -3
  291. package/dist/indexer/search/search-hit-enrichers.js +30 -40
  292. package/dist/indexer/search/search-source.js +157 -111
  293. package/dist/indexer/search/semantic-status.js +4 -1
  294. package/dist/indexer/usage/usage-events.js +10 -30
  295. package/dist/indexer/walk/file-context.js +3 -45
  296. package/dist/indexer/walk/matchers.js +42 -34
  297. package/dist/indexer/walk/path-resolver.js +11 -5
  298. package/dist/indexer/walk/walker.js +42 -14
  299. package/dist/integrations/agent/builder-shared.js +7 -0
  300. package/dist/integrations/agent/builders.js +5 -56
  301. package/dist/integrations/agent/config.js +3 -143
  302. package/dist/integrations/agent/detect.js +17 -2
  303. package/dist/integrations/agent/engine-resolution.js +231 -0
  304. package/dist/integrations/agent/index.js +1 -2
  305. package/dist/integrations/agent/model-aliases.js +16 -2
  306. package/dist/integrations/agent/profiles.js +36 -62
  307. package/dist/integrations/agent/prompts.js +46 -18
  308. package/dist/integrations/agent/runner-dispatch.js +93 -4
  309. package/dist/integrations/agent/runner.js +76 -208
  310. package/dist/integrations/agent/spawn.js +88 -196
  311. package/dist/integrations/harnesses/aider/agent-builder.js +114 -0
  312. package/dist/integrations/harnesses/aider/index.js +48 -0
  313. package/dist/integrations/harnesses/aider/result-extractor.js +53 -0
  314. package/dist/integrations/harnesses/amazonq/agent-builder.js +147 -0
  315. package/dist/integrations/harnesses/amazonq/index.js +45 -0
  316. package/dist/integrations/harnesses/amazonq/result-extractor.js +48 -0
  317. package/dist/integrations/harnesses/claude/agent-builder.js +46 -8
  318. package/dist/integrations/harnesses/claude/config-import.js +1 -3
  319. package/dist/integrations/harnesses/claude/index.js +24 -35
  320. package/dist/integrations/harnesses/claude/result-extractor.js +52 -0
  321. package/dist/integrations/harnesses/claude/session-log.js +27 -75
  322. package/dist/integrations/harnesses/codex/agent-builder.js +138 -0
  323. package/dist/integrations/harnesses/codex/index.js +52 -0
  324. package/dist/integrations/harnesses/codex/result-extractor.js +73 -0
  325. package/dist/integrations/harnesses/copilot/agent-builder.js +122 -0
  326. package/dist/integrations/harnesses/copilot/index.js +48 -0
  327. package/dist/integrations/harnesses/copilot/result-extractor.js +151 -0
  328. package/dist/integrations/harnesses/gemini/agent-builder.js +120 -0
  329. package/dist/integrations/harnesses/gemini/index.js +48 -0
  330. package/dist/integrations/harnesses/gemini/result-extractor.js +121 -0
  331. package/dist/integrations/harnesses/ids.js +24 -0
  332. package/dist/integrations/harnesses/index.js +54 -34
  333. package/dist/integrations/harnesses/opencode/agent-builder.js +23 -5
  334. package/dist/integrations/harnesses/opencode/config-import.js +1 -3
  335. package/dist/integrations/harnesses/opencode/index.js +14 -32
  336. package/dist/integrations/harnesses/opencode/session-log.js +67 -125
  337. package/dist/integrations/harnesses/opencode-sdk/harness.js +51 -0
  338. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +681 -108
  339. package/dist/integrations/harnesses/openhands/agent-builder.js +128 -0
  340. package/dist/integrations/harnesses/openhands/index.js +48 -0
  341. package/dist/integrations/harnesses/openhands/result-extractor.js +103 -0
  342. package/dist/integrations/harnesses/pi/agent-builder.js +97 -0
  343. package/dist/integrations/harnesses/pi/index.js +45 -0
  344. package/dist/integrations/harnesses/pi/result-extractor.js +135 -0
  345. package/dist/integrations/harnesses/shared.js +17 -0
  346. package/dist/integrations/harnesses/types.js +43 -32
  347. package/dist/integrations/lockfile.js +211 -24
  348. package/dist/integrations/session-logs/index.js +36 -39
  349. package/dist/integrations/session-logs/provider-base.js +113 -0
  350. package/dist/llm/client.js +182 -110
  351. package/dist/llm/embedders/deterministic.js +2 -2
  352. package/dist/llm/embedders/remote.js +21 -9
  353. package/dist/llm/feature-gate.js +17 -57
  354. package/dist/llm/graph-extract.js +12 -13
  355. package/dist/llm/index-passes.js +8 -42
  356. package/dist/llm/memory-infer.js +144 -1
  357. package/dist/llm/metadata-enhance.js +45 -30
  358. package/dist/llm/structured-call.js +16 -8
  359. package/dist/llm/usage-persist.js +30 -5
  360. package/dist/llm/usage-telemetry.js +59 -6
  361. package/dist/output/cli-hints.js +1 -2
  362. package/dist/output/command-registry.js +27 -0
  363. package/dist/output/context.js +22 -7
  364. package/dist/output/format-exempt.js +80 -0
  365. package/dist/output/generic-render.js +251 -0
  366. package/dist/output/html-render.js +11 -16
  367. package/dist/output/render-registry.js +57 -0
  368. package/dist/output/renderers.js +14 -279
  369. package/dist/output/shapes/curate.js +10 -1
  370. package/dist/output/shapes/events.js +12 -7
  371. package/dist/output/shapes/helpers.js +58 -84
  372. package/dist/output/shapes/passthrough.js +11 -39
  373. package/dist/output/shapes/proposal/producer.js +15 -7
  374. package/dist/output/shapes/registry.js +12 -6
  375. package/dist/output/shapes.js +0 -9
  376. package/dist/output/text/{init.js → bundle-create.js} +3 -1
  377. package/dist/output/text/bundle-show.js +7 -0
  378. package/dist/output/text/command-format.js +562 -0
  379. package/dist/output/text/env.js +1 -3
  380. package/dist/output/text/events.js +8 -7
  381. package/dist/output/text/helpers.js +15 -1164
  382. package/dist/output/text/proposal/producer.js +4 -2
  383. package/dist/output/text/proposal-format.js +202 -0
  384. package/dist/output/text/registry-commands.js +1 -2
  385. package/dist/output/text/registry.js +12 -6
  386. package/dist/output/text/show-directives.js +117 -0
  387. package/dist/output/text/show-format.js +103 -0
  388. package/dist/output/text/sync.js +5 -0
  389. package/dist/output/text/workflow-format.js +332 -0
  390. package/dist/output/text/workflow.js +3 -2
  391. package/dist/output/text.js +10 -19
  392. package/dist/registry/factory.js +4 -6
  393. package/dist/registry/origin-resolve.js +16 -27
  394. package/dist/registry/providers/skills-sh.js +3 -3
  395. package/dist/registry/providers/static-index.js +15 -25
  396. package/dist/registry/resolve.js +43 -94
  397. package/dist/registry/semver.js +43 -0
  398. package/dist/runtime.js +81 -12
  399. package/dist/scripts/akm-migrate.js +35529 -0
  400. package/dist/setup/detect.js +5 -7
  401. package/dist/setup/detected-engines.js +136 -0
  402. package/dist/setup/engine-config.js +100 -0
  403. package/dist/setup/registry-stash-loader.js +3 -3
  404. package/dist/setup/semantic-assets.js +12 -9
  405. package/dist/setup/setup.js +444 -208
  406. package/dist/setup/steps/connection-shared.js +120 -0
  407. package/dist/setup/steps/connection.js +108 -305
  408. package/dist/setup/steps/platforms.js +13 -12
  409. package/dist/setup/steps/semantic.js +15 -3
  410. package/dist/setup/steps/sources.js +21 -15
  411. package/dist/setup/steps/stashdir.js +6 -4
  412. package/dist/setup/steps/tasks.js +236 -119
  413. package/dist/setup/steps.js +3 -2
  414. package/dist/sources/freshness.js +39 -0
  415. package/dist/sources/provider-factory.js +11 -17
  416. package/dist/sources/providers/filesystem.js +2 -3
  417. package/dist/sources/providers/git-install.js +278 -34
  418. package/dist/sources/providers/git-provider.js +54 -56
  419. package/dist/sources/providers/git-stash.js +420 -91
  420. package/dist/sources/providers/git.js +2 -2
  421. package/dist/sources/providers/npm.js +16 -19
  422. package/dist/sources/providers/provider-utils.js +47 -22
  423. package/dist/sources/providers/sync-from-ref.js +3 -9
  424. package/dist/sources/providers/website.js +2 -2
  425. package/dist/sources/resolve.js +11 -10
  426. package/dist/sources/snapshot-fetchers/types.js +4 -0
  427. package/dist/sources/{website-ingest.js → snapshot-fetchers/website-ingest.js} +110 -41
  428. package/dist/storage/database.js +60 -4
  429. package/dist/storage/engines/sqlite-migrations.js +156 -5
  430. package/dist/storage/locations.js +1 -2
  431. package/dist/storage/repositories/canaries-repository.js +1 -1
  432. package/dist/storage/repositories/events-repository.js +51 -11
  433. package/dist/storage/repositories/improve-runs-repository.js +6 -32
  434. package/dist/storage/repositories/index-connection.js +79 -0
  435. package/dist/storage/repositories/index-db.js +4 -3
  436. package/dist/storage/repositories/index-entries-repository.js +863 -0
  437. package/dist/{indexer/db/entry-mapper.js → storage/repositories/index-entry-mapper.js} +19 -2
  438. package/dist/storage/repositories/index-entry-types.js +4 -0
  439. package/dist/storage/repositories/index-fts-repository.js +167 -0
  440. package/dist/storage/repositories/index-llm-cache-repository.js +108 -0
  441. package/dist/storage/repositories/index-meta-repository.js +49 -0
  442. package/dist/{indexer/db/schema.js → storage/repositories/index-schema.js} +226 -100
  443. package/dist/storage/repositories/index-sql.js +12 -0
  444. package/dist/storage/repositories/index-utility-repository.js +356 -0
  445. package/dist/storage/repositories/index-vec-repository.js +250 -0
  446. package/dist/storage/repositories/outcome-repository.js +119 -0
  447. package/dist/storage/repositories/proposals-repository.js +317 -75
  448. package/dist/storage/repositories/registry-cache.js +1 -1
  449. package/dist/storage/repositories/salience-repository.js +172 -0
  450. package/dist/storage/repositories/task-history-repository.js +110 -3
  451. package/dist/storage/repositories/workflow-runs-repository.js +240 -19
  452. package/dist/tasks/backends/cron.js +169 -46
  453. package/dist/tasks/backends/exec-utils.js +76 -3
  454. package/dist/tasks/backends/index.js +6 -9
  455. package/dist/tasks/backends/launchd.js +292 -55
  456. package/dist/tasks/backends/schtasks.js +557 -70
  457. package/dist/tasks/backends/types.js +4 -0
  458. package/dist/tasks/command-executable.js +93 -0
  459. package/dist/tasks/embedded.js +56 -38
  460. package/dist/tasks/parser.js +156 -64
  461. package/dist/tasks/resolve-akm-bin.js +144 -51
  462. package/dist/tasks/runner.js +377 -209
  463. package/dist/tasks/schedule.js +108 -19
  464. package/dist/tasks/scheduler-invocation.js +296 -0
  465. package/dist/tasks/schema.js +1 -1
  466. package/dist/tasks/task-id.js +35 -0
  467. package/dist/tasks/validator.js +30 -16
  468. package/dist/text-import-hook.mjs +1 -1
  469. package/dist/workflows/authoring/authoring.js +104 -43
  470. package/dist/workflows/authoring/scope-key.js +1 -1
  471. package/dist/workflows/cli.js +0 -16
  472. package/dist/workflows/concurrency-policy.js +15 -0
  473. package/dist/workflows/exec/brief.js +450 -0
  474. package/dist/workflows/exec/frozen-judge.js +47 -0
  475. package/dist/workflows/exec/native-executor.js +1038 -0
  476. package/dist/workflows/exec/param-secrets.js +115 -0
  477. package/dist/workflows/exec/report.js +1460 -0
  478. package/dist/workflows/exec/run-workflow.js +602 -0
  479. package/dist/workflows/exec/scheduler.js +71 -0
  480. package/dist/workflows/exec/step-work.js +1190 -0
  481. package/dist/workflows/exec/unit-writer.js +23 -0
  482. package/dist/workflows/exec/workflow-engine-gate.js +67 -0
  483. package/dist/workflows/exec/worktree.js +171 -0
  484. package/dist/workflows/ir/compile.js +246 -0
  485. package/dist/workflows/ir/freeze.js +233 -0
  486. package/dist/workflows/ir/params.js +54 -0
  487. package/dist/workflows/ir/plan-hash.js +68 -0
  488. package/dist/workflows/ir/schema.js +540 -0
  489. package/dist/workflows/parser.js +878 -304
  490. package/dist/workflows/program/expressions.js +181 -0
  491. package/dist/workflows/program/schema.js +51 -0
  492. package/dist/workflows/renderer.js +100 -45
  493. package/dist/workflows/resource-limits.js +22 -0
  494. package/dist/workflows/runtime/agent-identity.js +59 -14
  495. package/dist/workflows/runtime/checkin.js +1 -1
  496. package/dist/workflows/runtime/plan-classifier.js +131 -0
  497. package/dist/workflows/runtime/runs.js +376 -119
  498. package/dist/workflows/runtime/unit-checkin.js +45 -0
  499. package/dist/workflows/runtime/unit-phases.js +20 -0
  500. package/dist/workflows/runtime/workflow-asset-loader.js +241 -40
  501. package/dist/workflows/schema.js +1 -11
  502. package/dist/workflows/validate-summary.js +2 -3
  503. package/dist/workflows/validator.js +52 -30
  504. package/docs/README.md +42 -78
  505. package/docs/migration/README.md +8 -0
  506. package/docs/migration/release-notes/0.6.0.md +1 -1
  507. package/docs/migration/release-notes/0.7.0.md +9 -8
  508. package/docs/migration/release-notes/0.9.0.md +158 -14
  509. package/docs/migration/v0.7-to-v0.8.md +46 -47
  510. package/docs/migration/v0.8-to-v0.9.md +844 -0
  511. package/docs/reference/README.md +12 -0
  512. package/docs/reference/data-and-telemetry.md +333 -0
  513. package/package.json +21 -17
  514. package/schemas/akm-asset-envelope.json +93 -0
  515. package/schemas/akm-config.json +4636 -0
  516. package/schemas/akm-task.json +87 -0
  517. package/schemas/akm-workflow.json +373 -0
  518. package/dist/akm-migrate-storage +0 -38
  519. package/dist/assets/help/help-accept.md +0 -12
  520. package/dist/assets/help/help-improve.md +0 -84
  521. package/dist/assets/help/help-proposals.md +0 -17
  522. package/dist/assets/help/help-propose.md +0 -17
  523. package/dist/assets/help/help-reject.md +0 -11
  524. package/dist/assets/profiles/frequent.json +0 -13
  525. package/dist/assets/profiles/recombine-only.json +0 -21
  526. package/dist/assets/profiles/reflect-distill.json +0 -30
  527. package/dist/assets/profiles/synthesize.json +0 -15
  528. package/dist/assets/prompts/procedural-system.md +0 -44
  529. package/dist/assets/prompts/recombine-system.md +0 -40
  530. package/dist/assets/prompts/staleness-detect-system.md +0 -6
  531. package/dist/assets/tasks/core/backup.yml +0 -4
  532. package/dist/assets/tasks/graph-refresh-weekly.yml +0 -10
  533. package/dist/assets/templates/html/default.html +0 -78
  534. package/dist/assets/templates/html/vendor/echarts.min.js +0 -45
  535. package/dist/assets/wiki/index-template.md +0 -12
  536. package/dist/assets/wiki/ingest-workflow-template.md +0 -83
  537. package/dist/assets/wiki/log-template.md +0 -8
  538. package/dist/assets/wiki/schema-template.md +0 -61
  539. package/dist/cli/config-migrate.js +0 -150
  540. package/dist/cli/config-validate.js +0 -39
  541. package/dist/commands/graph/graph-cli.js +0 -124
  542. package/dist/commands/graph/graph.js +0 -487
  543. package/dist/commands/improve/calibration.js +0 -161
  544. package/dist/commands/improve/dedup.js +0 -482
  545. package/dist/commands/improve/extract-watch.js +0 -140
  546. package/dist/commands/improve/hot-probation.js +0 -45
  547. package/dist/commands/improve/improve-auto-accept.js +0 -276
  548. package/dist/commands/improve/improve-profiles.js +0 -168
  549. package/dist/commands/improve/procedural.js +0 -398
  550. package/dist/commands/improve/recombine.js +0 -818
  551. package/dist/commands/improve/schema-similarity-gate.js +0 -168
  552. package/dist/commands/lint/agent-linter.js +0 -44
  553. package/dist/commands/lint/command-linter.js +0 -44
  554. package/dist/commands/lint/default-linter.js +0 -16
  555. package/dist/commands/lint/fact-linter.js +0 -39
  556. package/dist/commands/lint/knowledge-linter.js +0 -16
  557. package/dist/commands/lint/memory-linter.js +0 -61
  558. package/dist/commands/lint/registry.js +0 -41
  559. package/dist/commands/lint/skill-linter.js +0 -45
  560. package/dist/commands/lint/task-linter.js +0 -50
  561. package/dist/commands/lint/workflow-linter.js +0 -81
  562. package/dist/commands/proposal/legacy-import.js +0 -115
  563. package/dist/commands/sources/history.js +0 -196
  564. package/dist/commands/tasks/default-tasks.js +0 -186
  565. package/dist/commands/wiki-cli.js +0 -292
  566. package/dist/core/asset/asset-registry.js +0 -76
  567. package/dist/core/asset/asset-spec.js +0 -259
  568. package/dist/core/config/config-migration.js +0 -602
  569. package/dist/core/deep-merge.js +0 -38
  570. package/dist/core/eval/rank-metrics.js +0 -113
  571. package/dist/core/ripgrep/install.js +0 -163
  572. package/dist/core/ripgrep/resolve.js +0 -81
  573. package/dist/indexer/db/db.js +0 -1413
  574. package/dist/indexer/manifest.js +0 -170
  575. package/dist/indexer/passes/metadata-contributors.js +0 -31
  576. package/dist/indexer/usage/unmigrated-vaults-guard.js +0 -94
  577. package/dist/integrations/harnesses/opencode-sdk/index.js +0 -49
  578. package/dist/llm/call-ai.js +0 -62
  579. package/dist/llm/memory-infer-impl.js +0 -138
  580. package/dist/output/shapes/distill.js +0 -14
  581. package/dist/output/shapes/history.js +0 -11
  582. package/dist/output/text/distill.js +0 -6
  583. package/dist/output/text/enable-disable.js +0 -8
  584. package/dist/output/text/history.js +0 -6
  585. package/dist/output/text/wiki.js +0 -16
  586. package/dist/registry/build-index.js +0 -386
  587. package/dist/scripts/migrate-storage.js +0 -19108
  588. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +0 -9411
  589. package/dist/scripts/migrations/v16-to-v17.js +0 -141
  590. package/dist/setup/legacy-config.js +0 -106
  591. package/dist/storage/repositories/consolidation-repository.js +0 -38
  592. package/dist/storage/repositories/recombine-repository.js +0 -213
  593. package/dist/wiki/wiki-templates.js +0 -15
  594. package/dist/wiki/wiki.js +0 -1012
  595. package/dist/workflows/db.js +0 -215
  596. package/docs/data-and-telemetry.md +0 -226
  597. /package/dist/sources/{wiki-fetchers → snapshot-fetchers}/registry.js +0 -0
  598. /package/dist/sources/{wiki-fetchers → snapshot-fetchers}/youtube.js +0 -0
@@ -0,0 +1,1460 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * `akm workflow report <run>` — the MUTATING half of the harness-neutral driver
6
+ * protocol (redesign addendum R3). A driver that ran a unit from `akm workflow
7
+ * brief` reports its result back through THIS path, which ingests the result
8
+ * through the SAME shared step semantics the native engine uses
9
+ * (`step-work.ts`), so an engine-driven run and a brief/report-driven run of the
10
+ * same frozen plan produce byte-identical unit graphs (the invariant R4
11
+ * asserts).
12
+ *
13
+ * ## No duplicated semantics (the cardinal rule)
14
+ *
15
+ * Every decision report makes is a shared function:
16
+ * - the expected work-list (item resolution + content-derived unit ids + input
17
+ * hashes + prompt assembly with recovered gate feedback) — the SAME
18
+ * {@link computeStepWorkList} / {@link activeGateLoop} /
19
+ * {@link recoverGateFeedback} `brief` and the executor call;
20
+ * - the input hash stored on the unit row is the engine's
21
+ * ({@link StepWorkUnit.resolved.inputHash});
22
+ * - reducer / artifact promotion / output-schema validation via
23
+ * {@link reduceStepOutcomes} (the executor's post-dispatch reduction);
24
+ * - route evaluation + artifact-judged gate completion + gate-row journaling +
25
+ * the bounded-loop rejection contract via {@link finalizeExecutedStep} (the
26
+ * engine loop's completion path).
27
+ *
28
+ * ## The ONE mutating verb, guarded
29
+ *
30
+ * A report is REFUSED unless the run is active AND no live engine lease is held
31
+ * (the engine owns the spine while driving). The reported unit must belong to
32
+ * the active step's recomputed work-list. A COMPLETED unit re-reported with the
33
+ * same input hash is an idempotent no-op; a different hash is replay divergence.
34
+ * Declared budget ceilings are enforced (journal-seeded, same rule as the
35
+ * engine). When a report makes the active step's work-list fully terminal it
36
+ * runs the identical completion path — reducer → artifact promotion → schema
37
+ * validation → artifact-judged gate → `completeWorkflowStep` — honoring
38
+ * `on_error` and `gate.max_loops`.
39
+ */
40
+ import { randomUUID } from "node:crypto";
41
+ import { UsageError } from "../../core/errors.js";
42
+ import { appendEvent } from "../../core/events.js";
43
+ import { validateJsonSchemaSubset } from "../../core/json-schema.js";
44
+ import { acquireMaintenanceActivity } from "../../core/maintenance-barrier.js";
45
+ import { collectSensitiveValues, isEnvPassthroughValueSafeToExpose, redactSensitiveText, redactSensitiveValue, } from "../../core/redaction.js";
46
+ import { withWorkflowRunsRepo, } from "../../storage/repositories/workflow-runs-repository.js";
47
+ import { assertRunParamsSatisfyPlan } from "../ir/params.js";
48
+ import { PROGRAM_RETRY_REASONS } from "../program/schema.js";
49
+ import { requireExecutableWorkflowPlan } from "../runtime/plan-classifier.js";
50
+ import { completeWorkflowStep, getNextWorkflowStep, snapshotRunForDriver, } from "../runtime/runs.js";
51
+ import { UNIT_STALE_MS } from "../runtime/unit-checkin.js";
52
+ import { buildLease, resolveRunId } from "./brief.js";
53
+ import { frozenSummaryJudge } from "./frozen-judge.js";
54
+ import { activeGateLoop, cascadeSkippedRouter, computeStepWorkList, finalizeExecutedStep, isRetryEligibleFailure, recoverGateFeedback, reduceEmptyStep, reduceStepOutcomes, seedJournaledRouteDecisions, selectUnitAttemptRow, stepOutputsFromEvidence, unitOutcomeFromRow, unitStillNeedsReport, } from "./step-work.js";
55
+ /**
56
+ * Claim time-to-live. Equal to the unit-checkin stale window
57
+ * ({@link UNIT_STALE_MS}) so a claim expires at exactly the moment
58
+ * `workflow brief` starts surfacing the unit as stale — an expired claim is,
59
+ * by construction, a stale-driver claim another driver may reclaim.
60
+ */
61
+ const CLAIM_TTL_MS = UNIT_STALE_MS;
62
+ /**
63
+ * Finalization-lock TTL (review round 2, #5). The finalize path claims the run's
64
+ * engine lease as a short-lived lock so exactly ONE reporter runs a step's
65
+ * completion. Sized like the run lease; the loser never runs the judge (it
66
+ * returns idempotent success on a failed acquire), so the TTL only bounds crash
67
+ * recovery — and `completeWorkflowStep`'s own transactional CAS remains the true
68
+ * arbiter of the single spine advance regardless.
69
+ */
70
+ const FINALIZE_LOCK_TTL_MS = 90_000;
71
+ // ── Entry point ──────────────────────────────────────────────────────────────
72
+ export async function reportWorkflowUnit(input) {
73
+ const release = await acquireMaintenanceActivity("workflow-report");
74
+ try {
75
+ return await reportWorkflowUnitWithBarrier(input);
76
+ }
77
+ finally {
78
+ release();
79
+ }
80
+ }
81
+ async function reportWorkflowUnitWithBarrier(input) {
82
+ // Thin orchestrator over the five ordered phases the file header documents.
83
+ // The guards, SQL, ordering, and error paths are unchanged from the inlined
84
+ // barrier fn — each phase is a named top-level function below.
85
+ const loaded = await loadActiveReportStep(input);
86
+ const resolved = await resolveReportableUnit(loaded, input);
87
+ if (resolved.kind === "terminal")
88
+ return resolved.result;
89
+ const ctx = resolved.ctx;
90
+ // ── running: claim / heartbeat, never advances the spine ───────────────────
91
+ if (input.status === "running")
92
+ return reportRunningUnit(ctx);
93
+ // ── completed / failed: validate, guard, write, maybe finalize ─────────────
94
+ const written = await writeReportedUnit(ctx, input, input.status);
95
+ if (written.kind === "terminal")
96
+ return written.result;
97
+ return finalizeReportedUnit(ctx, input, written);
98
+ }
99
+ function assertRunStillActive(status, runId) {
100
+ if (status === "active")
101
+ return;
102
+ throw new UsageError(`Workflow run ${runId} is ${status ?? "missing"}; refusing stale driver continuation.`);
103
+ }
104
+ /**
105
+ * Phase 1 — load the run's spine under the report guards: refuse a non-active
106
+ * run, refuse a live engine lease, require a current step, honor `--expect-step`,
107
+ * and re-assert the journaled params still satisfy the frozen param schemas.
108
+ */
109
+ async function loadActiveReportStep(input) {
110
+ const nowFn = input.now ?? (() => new Date());
111
+ const nowIso = nowFn().toISOString();
112
+ const runId = await resolveRunId(input.target);
113
+ // #14: read the spine, run row, and unit journal in ONE snapshot so the guards
114
+ // below see a consistent point-in-time state (same fix as `brief`).
115
+ const { next, run: runRow, units } = await snapshotRunForDriver(runId);
116
+ const leaseHolder = runRow.engine_lease_holder;
117
+ const leaseUntil = runRow.engine_lease_until;
118
+ // Refuse a non-active run: there is no work-list to report against.
119
+ if (next.run.status !== "active" || next.done) {
120
+ throw new UsageError(`Workflow run ${runId} is ${next.run.status} — \`akm workflow report\` only records results for an ACTIVE ` +
121
+ `run. ${next.run.status === "completed" ? "The run is already done." : `Reopen it first: \`akm workflow resume ${runId}\`.`}`);
122
+ }
123
+ // Refuse while a LIVE engine lease is held — the engine owns the spine while
124
+ // driving; a report would race its completion.
125
+ const lease = buildLease(leaseHolder, leaseUntil);
126
+ if (lease?.live) {
127
+ throw new UsageError(`Workflow run ${runId} is being driven by engine ${lease.holder} (run lease expires ${lease.until}). ` +
128
+ `\`akm workflow report\` is refused while the engine lease is live — wait for the engine to finish or for ` +
129
+ `the lease to expire before reporting units.`);
130
+ }
131
+ if (!next.step) {
132
+ throw new UsageError(`Workflow run ${runId} is active but has no current step to report against.`);
133
+ }
134
+ // #14: the optional spine guard. `brief` embeds `--expect-step <activeStep>` in
135
+ // every report command; if the active step has since changed, refuse rather
136
+ // than record against a step the driver did not plan against.
137
+ if (input.expectStep !== undefined && next.step.id !== input.expectStep) {
138
+ throw new UsageError(`Workflow run ${runId} is now on step "${next.step.id}", not "${input.expectStep}" (--expect-step). The spine ` +
139
+ `moved since you briefed it — a concurrent report/run/manual completion advanced the run. Re-run ` +
140
+ `\`akm workflow brief ${runId}\` and report against the current step.`, "INVALID_FLAG_VALUE");
141
+ }
142
+ const plan = requireExecutableWorkflowPlan(runRow);
143
+ // Reviewer #12: the journaled params row must still satisfy the frozen param
144
+ // schemas before report resolves any unit prompt from it — a violation is
145
+ // post-start corruption, refused loudly (mirrors the frozen-plan hash check
146
+ // and the tampered-params replay-divergence path).
147
+ assertRunParamsSatisfyPlan(runId, plan, next.run.params ?? {});
148
+ return { runId, plan, next, units, nowFn, nowIso };
149
+ }
150
+ /**
151
+ * Phase 2 — resolve the unit the driver should report against. If the spine is
152
+ * parked on a NON-DISPATCHING step — a route-only step, an empty fan-out
153
+ * (`over: []`), a step whose every unit is an unresolvable expression, or a
154
+ * whole-list resolution failure — the engine auto-completes or fails it: there
155
+ * is no `report --unit` that could ever advance it. So `report` first settles
156
+ * the spine past such steps (mutating exactly as the engine would, through the
157
+ * SAME shared completion path), then resolves the reported unit against the
158
+ * resting step. This is a no-op when the active step already has real reportable
159
+ * work (the common case) — no settle runs, nothing mutates.
160
+ */
161
+ async function resolveReportableUnit(loaded, input) {
162
+ const { runId, plan, next, units, nowFn, nowIso } = loaded;
163
+ let ctx = buildStepContext(runId, plan, next, units);
164
+ if (!ctx.dispatching) {
165
+ const settled = await settleSpine({ plan, runId, summaryJudge: input.summaryJudge });
166
+ if (settled.done || settled.run.status !== "active" || !settled.step) {
167
+ return { kind: "terminal", result: settledTerminalResult(input, settled) };
168
+ }
169
+ const freshUnits = await withWorkflowRunsRepo((repo) => repo.getUnitsForRun(runId));
170
+ ctx = buildStepContext(runId, plan, settled, freshUnits);
171
+ }
172
+ const { next: state, stepState, stepPlan, workList, gateLoop, priorEvidence, units: unitRows } = ctx;
173
+ if (!workList) {
174
+ throw new UsageError(`Active step "${stepState.id}" of run ${runId} dispatches no reportable units` +
175
+ `${ctx.computeError ? ` (${ctx.computeError})` : " (route-only or empty)"}. There is nothing to ` +
176
+ `\`report --unit\` for it; run \`akm workflow brief ${runId}\` to see the current state.`);
177
+ }
178
+ const workUnit = workList.units.find((u) => u.unitId === input.unitId);
179
+ if (!workUnit) {
180
+ const valid = workList.units.map((u) => u.unitId).join(", ") || "(none)";
181
+ throw new UsageError(`Unit "${input.unitId}" does not belong to the active step "${stepState.id}" of run ${runId}. ` +
182
+ `Valid unit ids for this step: ${valid}. Run \`akm workflow brief ${runId}\` for the current work-list.`, "INVALID_FLAG_VALUE");
183
+ }
184
+ if (!workUnit.resolved.ok) {
185
+ throw new UsageError(`Unit "${input.unitId}" cannot be resolved (${workUnit.resolved.error}) — it has no dispatchable input to ` +
186
+ `report a result against. This is an authoring/data error in the workflow; fix it and start a new run.`);
187
+ }
188
+ if (!workUnit.engine || !workUnit.invocation) {
189
+ throw new UsageError(`Unit "${workUnit.unitId}" has no complete frozen engine attribution.`);
190
+ }
191
+ const engineName = workUnit.engine.name;
192
+ const exactModel = workUnit.invocation.model;
193
+ const inputHash = workUnit.resolved.inputHash;
194
+ const journalId = workUnit.journalBaseId;
195
+ const sensitiveValues = await collectReportedUnitSensitiveValues(workUnit);
196
+ const sessionId = input.sessionId === undefined ? undefined : redactSensitiveText(input.sessionId, sensitiveValues);
197
+ return {
198
+ kind: "resolved",
199
+ ctx: {
200
+ runId,
201
+ plan,
202
+ state,
203
+ stepState,
204
+ stepPlan,
205
+ workList,
206
+ gateLoop,
207
+ priorEvidence,
208
+ unitRows,
209
+ workUnit,
210
+ engineName,
211
+ exactModel,
212
+ inputHash,
213
+ journalId,
214
+ sensitiveValues,
215
+ sessionId,
216
+ nowFn,
217
+ nowIso,
218
+ },
219
+ };
220
+ }
221
+ /**
222
+ * Phase 3 — the `running` claim / heartbeat. Mints or refreshes the unit's
223
+ * claim under the same compare-and-set + stale-hash guards the finish path uses,
224
+ * and never advances the spine.
225
+ */
226
+ async function reportRunningUnit(ctx) {
227
+ const { runId, state, stepState, workList, gateLoop, unitRows, workUnit, engineName, exactModel, inputHash } = ctx;
228
+ const { journalId, sessionId, nowFn, nowIso } = ctx;
229
+ // The claim holder: the driver's --session-id, else a token we mint and
230
+ // return so the driver can reuse it (as --session-id) to heartbeat and
231
+ // finish the SAME claim. First unexpired claim wins.
232
+ const holder = sessionId ?? `claim:${randomUUID()}`;
233
+ const claimExpiresAt = new Date(nowFn().getTime() + CLAIM_TTL_MS).toISOString();
234
+ const claimed = await withWorkflowRunsRepo((repo) => repo.transaction(() => {
235
+ assertRunStillActive(repo.getRunById(runId)?.status, runId);
236
+ const existing = repo.getUnit(runId, journalId);
237
+ if (existing && (existing.status === "completed" || existing.status === "failed")) {
238
+ throw new UsageError(`Unit "${journalId}" of run ${runId} is already ${existing.status} — cannot claim a terminal unit as ` +
239
+ `running. Report a fresh result with --status completed|failed, or start a new run to redo it.`);
240
+ }
241
+ if (existing) {
242
+ // Stale-hash guard (#3): a running row whose recorded input_hash no
243
+ // longer matches the recomputed one is a replay divergence (a
244
+ // tampered/stale claim under a frozen plan) — refuse to heartbeat it.
245
+ assertNoHashDivergence(existing, inputHash, runId, journalId);
246
+ // Compare-and-set the claim owner: a LIVE claim held by a DIFFERENT
247
+ // holder blocks reclaim; an expired one (or a claim already ours) is
248
+ // (re)claimable. First unexpired claim wins (crash recovery on expiry).
249
+ assertClaimHeldByOrFree(existing, holder, nowIso, runId, journalId, "heartbeat");
250
+ if (!repo.updateUnitClaim(runId, journalId, holder, claimExpiresAt, nowIso)) {
251
+ throw new UsageError(`Workflow run ${runId} is no longer active; refusing a stale unit heartbeat.`);
252
+ }
253
+ return "heartbeat";
254
+ }
255
+ repo.insertUnit({
256
+ runId,
257
+ unitId: journalId,
258
+ stepId: stepState.id,
259
+ nodeId: workUnit.nodeId,
260
+ parentUnitId: workUnit.isFanOut ? `${stepState.id}.map` : null,
261
+ phase: null,
262
+ runner: workUnit.runner,
263
+ engine: engineName,
264
+ model: exactModel,
265
+ inputHash,
266
+ startedAt: nowIso,
267
+ claimHolder: holder,
268
+ claimExpiresAt,
269
+ });
270
+ if (!repo.updateUnitClaim(runId, journalId, holder, claimExpiresAt, nowIso)) {
271
+ throw new UsageError(`Workflow run ${runId} is no longer active; refusing a stale unit claim.`);
272
+ }
273
+ return "claim";
274
+ }));
275
+ appendEvent({
276
+ eventType: "workflow_unit_started",
277
+ ref: state.run.workflowRef,
278
+ metadata: { runId, stepId: stepState.id, unitId: journalId, status: "running" },
279
+ });
280
+ const remaining = countRemaining(workList.units, unitRows, journalId, "running");
281
+ return {
282
+ ok: true,
283
+ runId,
284
+ stepId: stepState.id,
285
+ unitId: journalId,
286
+ status: "running",
287
+ gateLoop,
288
+ recorded: "heartbeat",
289
+ remainingUnits: remaining,
290
+ runStatus: state.run.status,
291
+ claim: { holder, expiresAt: claimExpiresAt },
292
+ message: claimed === "claim"
293
+ ? `Claimed unit "${journalId}" of step "${stepState.id}" as ${holder} (until ${claimExpiresAt}). ` +
294
+ `Reuse --session-id ${holder} to heartbeat, then report the result.`
295
+ : `Heartbeat recorded for unit "${journalId}" of step "${stepState.id}" (claim ${holder} extended to ${claimExpiresAt}).`,
296
+ };
297
+ }
298
+ /**
299
+ * Phase 4 — the guarded completed/failed write. The idempotent re-report /
300
+ * replay-divergence check and the budget ceiling are evaluated INSIDE the same
301
+ * SQLite transaction as the insert+finish, so two concurrent reports serialize
302
+ * on the write lock. A crossed ceiling fails the step hard (terminal result);
303
+ * otherwise the write continues into the finalization decision.
304
+ */
305
+ async function writeReportedUnit(ctx, input, status) {
306
+ const { runId, plan, state, stepState, gateLoop, workUnit, engineName, exactModel } = ctx;
307
+ const { inputHash, journalId, sensitiveValues, sessionId, nowIso } = ctx;
308
+ const { resultJson, failureReason } = prepareResult(input, workUnit, sensitiveValues);
309
+ const thisTokens = input.tokens ?? 0;
310
+ const holder = sessionId ?? null;
311
+ const writeResult = await withWorkflowRunsRepo((repo) => repo.transaction(() => {
312
+ assertRunStillActive(repo.getRunById(runId)?.status, runId);
313
+ const existing = repo.getUnit(runId, journalId);
314
+ if (existing?.status === "completed") {
315
+ if (existing.input_hash === inputHash)
316
+ return { kind: "idempotent" };
317
+ throw new UsageError(`Replay divergence: unit "${journalId}" of run ${runId} is already recorded COMPLETED with a different ` +
318
+ `input hash than this report's. Under a frozen plan the same unit identity must reproduce the same ` +
319
+ `inputs — refusing to overwrite. Start a new run to re-execute this work.`);
320
+ }
321
+ // A FAILED terminal row is idempotence-protected (#25): the same content
322
+ // re-reported is a no-op; a DIFFERING re-report is refused unless --rerun
323
+ // is set, which records a NEW attempt (insertUnit REPLACE bumps attempts,
324
+ // budget admission re-applies below). A frozen-plan hash mismatch is a hard
325
+ // replay divergence regardless of --rerun.
326
+ if (existing?.status === "failed") {
327
+ assertNoHashDivergence(existing, inputHash, runId, journalId);
328
+ if (!input.rerun) {
329
+ const sameOutcome = status === "failed" && existing.result_json === resultJson && existing.failure_reason === failureReason;
330
+ if (sameOutcome)
331
+ return { kind: "idempotent" };
332
+ throw new UsageError(`Unit "${journalId}" of run ${runId} is already recorded FAILED. Re-reporting it with a different result ` +
333
+ `requires an explicit --rerun (which records a NEW attempt and re-applies the declared budget). Without ` +
334
+ `--rerun a differing re-report is refused; a same-content re-report is an idempotent no-op.`);
335
+ }
336
+ }
337
+ // A `running` row being finalized: the stale-hash guard (#3) refuses a
338
+ // tampered/stale claim whose input_hash diverges, and the claim
339
+ // compare-and-set requires the matching holder while the claim is live (a
340
+ // claimless row, or an expired claim, finishes freely — simple drivers).
341
+ if (existing?.status === "running") {
342
+ assertNoHashDivergence(existing, inputHash, runId, journalId);
343
+ assertClaimHeldByOrFree(existing, holder, nowIso, runId, journalId, "finish");
344
+ }
345
+ // Budget ceilings, journal-seeded exactly as the engine seeds
346
+ // `DispatchBudget` (dispatch rows only — gate rows excluded). The existing
347
+ // row's already-spent attempts are counted before admission (#4), then this
348
+ // write's own increment is added, so overwriting a prior attempt never
349
+ // erases it from the ceiling.
350
+ const verdict = assessBudget(plan, repo.getUnitsForRun(runId), journalId, thisTokens, existing);
351
+ // A `refuse` verdict crosses a ceiling on ADMISSION: the engine would fail
352
+ // this dispatch WITHOUT journaling it. Write nothing; the caller fails the
353
+ // step (matching the engine's terminal state, not a stuck run).
354
+ if (verdict.kind === "refuse")
355
+ return { kind: "budget-refused", message: verdict.message };
356
+ // Journal the dispatch row, then finalize it. When this unit was already
357
+ // claimed with `--status running`, its row exists with the SAME dispatch
358
+ // metadata this insert would write; re-inserting would bump `attempts`
359
+ // (migration 008) and count the claim+report of ONE execution as two
360
+ // dispatches — inflating budget accounting relative to the engine, which
361
+ // does a single `insertUnit` per dispatch. So skip the insert for a live
362
+ // `running` claim (finishUnit below finalizes it, `attempts` unchanged);
363
+ // a fresh report with no claim, or a re-dispatch over a prior FAILED row,
364
+ // still (re)inserts and is charged an attempt.
365
+ if (existing?.status !== "running") {
366
+ repo.insertUnit({
367
+ runId,
368
+ unitId: journalId,
369
+ stepId: stepState.id,
370
+ nodeId: workUnit.nodeId,
371
+ parentUnitId: workUnit.isFanOut ? `${stepState.id}.map` : null,
372
+ phase: null,
373
+ runner: workUnit.runner,
374
+ engine: engineName,
375
+ model: exactModel,
376
+ inputHash,
377
+ startedAt: existing?.started_at ?? nowIso,
378
+ });
379
+ }
380
+ // Run-lifetime token accounting (Codex round-3 finding A). `finishUnit`
381
+ // OVERWRITES the row's `tokens` column, so a `--rerun` over a prior FAILED
382
+ // attempt would otherwise ERASE that attempt's already-spent tokens from
383
+ // the run total — a budgeted run could spend 80 tokens on a failed report,
384
+ // rerun for 30 more under `max_tokens: 100`, and pass because only 30 were
385
+ // retained. The engine never hits this (its retries journal SEPARATE `~r`
386
+ // rows, each keeping its own tokens). To match, the `tokens` column on a
387
+ // re-dispatched row carries the CUMULATIVE spend across the row's attempts,
388
+ // mirroring how `attempts` accumulates: budget seeds that sum this column
389
+ // then see ALL tokens ever spent on the unit. A fresh write or a live
390
+ // `running`-claim finish (no prior tokens on the row) keeps the plain
391
+ // per-attempt value — and NULL when no tokens were reported — so the engine
392
+ // parity graph (which journals NULL for a usage-less unit) is unchanged.
393
+ const priorTokens = existing?.tokens ?? 0;
394
+ const carriedTokens = priorTokens > 0 ? priorTokens + thisTokens : (input.tokens ?? null);
395
+ repo.finishUnit({
396
+ runId,
397
+ unitId: journalId,
398
+ status,
399
+ resultJson,
400
+ tokens: carriedTokens,
401
+ failureReason,
402
+ sessionId: sessionId ?? null,
403
+ finishedAt: nowIso,
404
+ });
405
+ // A `tokens-cross` verdict: this unit's OWN tokens push the run total over
406
+ // `max_tokens`. The engine journals the unit (it dispatched), then aborts
407
+ // and fails the step. The row is written above; the caller fails the step.
408
+ if (verdict.kind === "tokens-cross")
409
+ return { kind: "budget-tokens", message: verdict.message };
410
+ return { kind: "written" };
411
+ }));
412
+ // Budget REFUSAL: no row written — fail the step hard, naming the ceiling
413
+ // (budget ceilings ignore on_error), so the run reaches the engine's terminal
414
+ // FAILED state rather than getting permanently stuck (peer review R3).
415
+ if (writeResult.kind === "budget-refused") {
416
+ return {
417
+ kind: "terminal",
418
+ result: await failStepOnBudget(runId, stepState.id, journalId, status, gateLoop, writeResult.message, false),
419
+ };
420
+ }
421
+ // Events carry ids/status only — never workflow-authored content. Emitted only
422
+ // when a row was actually written (idempotent/normal/token-crossing).
423
+ appendEvent({
424
+ eventType: "workflow_unit_started",
425
+ ref: state.run.workflowRef,
426
+ metadata: { runId, stepId: stepState.id, unitId: journalId, status: "running" },
427
+ });
428
+ appendEvent({
429
+ eventType: "workflow_unit_finished",
430
+ ref: state.run.workflowRef,
431
+ metadata: {
432
+ runId,
433
+ stepId: stepState.id,
434
+ unitId: journalId,
435
+ status,
436
+ ...(failureReason ? { failureReason } : {}),
437
+ ...(input.tokens !== undefined ? { tokens: input.tokens } : {}),
438
+ },
439
+ });
440
+ // Budget TOKEN crossing: the row is written; fail the step hard naming the
441
+ // ceiling (same terminal state as the engine's addTokens abort).
442
+ if (writeResult.kind === "budget-tokens") {
443
+ return {
444
+ kind: "terminal",
445
+ result: await failStepOnBudget(runId, stepState.id, journalId, status, gateLoop, writeResult.message, true),
446
+ };
447
+ }
448
+ return { kind: "continue", idempotent: writeResult.kind === "idempotent", status, failureReason };
449
+ }
450
+ /**
451
+ * Phase 5 — the shared step-finalization decision. Re-reads the journal so a
452
+ * concurrent sibling report is observed, fails-fast under `on_error: fail`,
453
+ * short-circuits a crash-recovering idempotent re-report whose step already
454
+ * advanced, and otherwise runs the shared completion path.
455
+ */
456
+ async function finalizeReportedUnit(ctx, input, written) {
457
+ const { runId, plan, state, stepState, stepPlan, workList, gateLoop, priorEvidence, workUnit, journalId, nowFn } = ctx;
458
+ const { idempotent, status, failureReason } = written;
459
+ // Is the step's work-list now fully terminal? Re-read the journal so a
460
+ // concurrent report of a sibling unit is observed. Unresolvable units are
461
+ // never reportable and count as terminally failed (the engine's
462
+ // expression_error), so they never keep a step outstanding.
463
+ const rowsAfter = await withWorkflowRunsRepo((repo) => repo.getUnitsForRun(runId));
464
+ const byUnit = indexDispatchRows(rowsAfter);
465
+ const remaining = remainingReportableUnits(workList, byUnit);
466
+ if (remaining > 0) {
467
+ // Fail-fast (Codex round-3 finding B). Under the default `on_error: fail` a
468
+ // single TERMINAL unit failure already fixes the step's verdict: the shared
469
+ // reducer maps ANY unit failure to a failed step, so the outstanding siblings
470
+ // cannot change the outcome. Waiting for them (the pre-fix behavior) only
471
+ // strands the run forever when a driver stops after its first failure. So a
472
+ // failed report finalizes the step NOW — through the SAME completion path +
473
+ // finalize CAS as a fully-terminal work-list — instead of returning as
474
+ // outstanding. Exceptions kept identical to the engine: `on_error: continue`
475
+ // always waits for the full work-list; a RETRY-ELIGIBLE failure is not yet
476
+ // terminal (the unit may still be re-run under its retry budget — the
477
+ // `--rerun` form brief advertises, mirroring the engine's `~r<n>` retry), so
478
+ // it too keeps waiting; and an idempotent re-report changed nothing to act on.
479
+ if (status === "failed" &&
480
+ !idempotent &&
481
+ workList.template.onError === "fail" &&
482
+ !isRetryEligibleFailure(workUnit, byUnit.get(journalId), failureReason)) {
483
+ return finalizeStep({
484
+ runId,
485
+ next: state,
486
+ plan,
487
+ stepPlan,
488
+ stepState,
489
+ workList,
490
+ byUnit,
491
+ gateLoop,
492
+ priorEvidence,
493
+ summaryJudge: input.summaryJudge === undefined ? frozenSummaryJudge(plan, stepPlan.gate.judge) : input.summaryJudge,
494
+ now: nowFn,
495
+ written: { unitId: journalId, status },
496
+ recorded: "written",
497
+ });
498
+ }
499
+ // Not fully terminal → nothing to finalize. A written report advanced the
500
+ // work-list by one; an idempotent re-report changed nothing.
501
+ return {
502
+ ok: true,
503
+ runId,
504
+ stepId: stepState.id,
505
+ unitId: journalId,
506
+ status,
507
+ gateLoop,
508
+ recorded: idempotent ? "idempotent" : "written",
509
+ remainingUnits: remaining,
510
+ runStatus: state.run.status,
511
+ message: idempotent
512
+ ? `Unit "${journalId}" was already recorded — no change (idempotent re-report). ${remaining} unit(s) still outstanding for step "${stepState.id}".`
513
+ : `Recorded unit "${journalId}" (${status}). ${remaining} unit(s) still outstanding for step "${stepState.id}".`,
514
+ };
515
+ }
516
+ // The work-list is fully terminal. For an idempotent re-report this is EITHER
517
+ // the common case (the step's completion already ran on the first report and
518
+ // the spine has since moved off this step) OR crash recovery: the process died
519
+ // between the last unit write and `completeWorkflowStep`, so every unit is
520
+ // journaled-terminal but the step never advanced. Re-read the spine: only when
521
+ // this step is STILL the active, pending step is there completion left to run —
522
+ // and then ANY driver (not just the engine) must be able to finalize it, or the
523
+ // step is permanently un-advanceable through brief/report (peer review R3,
524
+ // engine/driver crash-recovery symmetry). If the spine already moved past this
525
+ // step, a concurrent report finalized it first and this is a true no-op.
526
+ if (idempotent) {
527
+ const fresh = await getNextWorkflowStep(runId);
528
+ if (fresh.run.status !== "active" || fresh.step?.id !== stepState.id) {
529
+ return {
530
+ ok: true,
531
+ runId,
532
+ stepId: stepState.id,
533
+ unitId: journalId,
534
+ status,
535
+ gateLoop,
536
+ recorded: "idempotent",
537
+ remainingUnits: 0,
538
+ runStatus: fresh.run.status,
539
+ message: `Unit "${journalId}" was already recorded — no change (idempotent re-report).`,
540
+ };
541
+ }
542
+ }
543
+ // Fully terminal AND this step still needs completion → run the SHARED
544
+ // completion path (identical for a first report and a crash-recovering
545
+ // idempotent re-report).
546
+ return finalizeStep({
547
+ runId,
548
+ next: state,
549
+ plan,
550
+ stepPlan,
551
+ stepState,
552
+ workList,
553
+ byUnit,
554
+ gateLoop,
555
+ priorEvidence,
556
+ summaryJudge: input.summaryJudge === undefined ? frozenSummaryJudge(plan, stepPlan.gate.judge) : input.summaryJudge,
557
+ now: nowFn,
558
+ written: { unitId: journalId, status },
559
+ recorded: idempotent ? "idempotent" : "written",
560
+ });
561
+ }
562
+ // ── The settle verb (finding D) ──────────────────────────────────────────────
563
+ /**
564
+ * `akm workflow report <run> --settle` — the mutating verb that advances a run
565
+ * parked on a NON-DISPATCHING step (Codex round-3 finding D).
566
+ *
567
+ * A driver following the documented `brief → execute → report` loop has no
568
+ * `report --unit` it can run when the active step dispatches nothing: a
569
+ * params-based route step, an empty fan-out (`over: []`), or a step whose every
570
+ * unit is unresolvable. The engine auto-advances such steps, but a pure
571
+ * brief/report driver — one whose FIRST step is a params-routed route, with no
572
+ * preceding unit report to trigger the internal `settleSpine` — would get stuck.
573
+ *
574
+ * `--settle` runs the EXISTING deterministic settle path ({@link settleSpine} —
575
+ * route decision journaled, cascaded skips, spine advance) under the SAME guards
576
+ * as `report --unit`: it refuses a non-active run, refuses while a LIVE engine
577
+ * lease is held, honors `--expect-step`, and REFUSES when the active step has
578
+ * reportable units (a driver must `report --unit` those, not settle them). The
579
+ * settle runs under a short-lived engine-lease finalize lock (the same CAS
580
+ * `finalizeStep` uses) so two concurrent settles/reports cannot double-journal.
581
+ */
582
+ export async function settleWorkflowSpine(input) {
583
+ const release = await acquireMaintenanceActivity("workflow-report-settle");
584
+ try {
585
+ return await settleWorkflowSpineWithBarrier(input);
586
+ }
587
+ finally {
588
+ release();
589
+ }
590
+ }
591
+ async function settleWorkflowSpineWithBarrier(input) {
592
+ const nowFn = input.now ?? (() => new Date());
593
+ const runId = await resolveRunId(input.target);
594
+ const { next, run: runRow, units } = await snapshotRunForDriver(runId);
595
+ if (next.run.status !== "active" || next.done) {
596
+ throw new UsageError(`Workflow run ${runId} is ${next.run.status} — \`akm workflow report --settle\` only advances an ACTIVE run. ` +
597
+ `${next.run.status === "completed" ? "The run is already done." : `Reopen it first: \`akm workflow resume ${runId}\`.`}`);
598
+ }
599
+ const lease = buildLease(runRow.engine_lease_holder, runRow.engine_lease_until);
600
+ if (lease?.live) {
601
+ throw new UsageError(`Workflow run ${runId} is being driven by engine ${lease.holder} (run lease expires ${lease.until}). ` +
602
+ `\`akm workflow report --settle\` is refused while the engine lease is live — the engine settles its own ` +
603
+ `non-dispatching steps.`);
604
+ }
605
+ if (!next.step) {
606
+ throw new UsageError(`Workflow run ${runId} is active but has no current step to settle.`);
607
+ }
608
+ if (input.expectStep !== undefined && next.step.id !== input.expectStep) {
609
+ throw new UsageError(`Workflow run ${runId} is now on step "${next.step.id}", not "${input.expectStep}" (--expect-step). The spine ` +
610
+ `moved since you briefed it — re-run \`akm workflow brief ${runId}\` and settle against the current step.`, "INVALID_FLAG_VALUE");
611
+ }
612
+ const plan = requireExecutableWorkflowPlan(runRow);
613
+ assertRunParamsSatisfyPlan(runId, plan, next.run.params ?? {});
614
+ // A step with resolvable units is settled ONLY when its work-list is FULLY
615
+ // TERMINAL — every resolvable unit run to a terminal state, nothing left to
616
+ // `report --unit` — yet still un-finalized (for example, after a crash between
617
+ // the last unit write and completion; owner manual-validation finding 3).
618
+ // Such a list has no pending unit to report, so
619
+ // `--settle` runs the SAME shared completion path a report would
620
+ // (reducer → optional validation → advance/reject). A step with GENUINELY PENDING units
621
+ // (pending, in-flight, or retry-eligible failed) is still refused — those
622
+ // advance via `report --unit`.
623
+ const ctx = buildStepContext(runId, plan, next, units);
624
+ if (ctx.dispatching) {
625
+ const workList = ctx.workList;
626
+ if (!workList) {
627
+ throw new UsageError(`Active step "${ctx.stepState.id}" of run ${runId} has no work-list to settle.`);
628
+ }
629
+ const byUnit = indexDispatchRows(units);
630
+ const outstanding = workList.units.filter((u) => unitStillNeedsReport(u, byUnit));
631
+ if (outstanding.length > 0) {
632
+ const valid = outstanding.filter((u) => u.resolved.ok).map((u) => u.unitId);
633
+ throw new UsageError(`Active step "${ctx.stepState.id}" of run ${runId} has reportable units — advance it with ` +
634
+ `\`akm workflow report ${runId} --unit <id> ...\`, not --settle. Unit ids: ${valid.join(", ") || "(none)"}.`, "INVALID_FLAG_VALUE");
635
+ }
636
+ // Fully terminal: finalize through the shared completion path. `finalizeStep`
637
+ // runs its OWN finalize CAS (claims the engine lease as a lock), so we must
638
+ // NOT also hold the settle lock here — call it directly.
639
+ return finalizeStep({
640
+ runId,
641
+ next,
642
+ plan,
643
+ stepPlan: ctx.stepPlan,
644
+ stepState: ctx.stepState,
645
+ workList,
646
+ byUnit,
647
+ gateLoop: ctx.gateLoop,
648
+ priorEvidence: ctx.priorEvidence,
649
+ summaryJudge: input.summaryJudge,
650
+ now: nowFn,
651
+ written: { unitId: "(settle)", status: "skipped" },
652
+ recorded: "not-recorded",
653
+ });
654
+ }
655
+ // Finalize CAS: claim the engine lease as a short-lived settle lock (the shared
656
+ // {@link withFinalizeLock}), thread the holder through the settle so its
657
+ // `completeWorkflowStep` calls pass the single-driver guard, and release it in
658
+ // `finally`.
659
+ return withFinalizeLock({
660
+ runId,
661
+ holderPrefix: "report-settle",
662
+ now: nowFn,
663
+ onContended: async () => {
664
+ // A concurrent finalizer/settler holds the lock; report the fresh spine
665
+ // state as idempotent success rather than racing it.
666
+ const fresh = await getNextWorkflowStep(runId);
667
+ return settleVerbResult(runId, fresh, `A concurrent driver is settling run ${runId}`);
668
+ },
669
+ run: async (holder) => {
670
+ const settled = await settleSpine({ plan, runId, summaryJudge: input.summaryJudge, leaseHolder: holder });
671
+ return settleVerbResult(runId, settled);
672
+ },
673
+ });
674
+ }
675
+ /** Shape a `--settle` outcome as a (unit-less) report result. */
676
+ function settleVerbResult(runId, state, contendedNote) {
677
+ const runStatus = state.run.status;
678
+ const message = runStatus === "completed"
679
+ ? `Settled non-dispatching steps — the workflow run is now DONE.`
680
+ : state.step
681
+ ? `Settled non-dispatching steps. Next: run \`akm workflow brief ${runId}\` for step "${state.step.id}".`
682
+ : `Run ${runId} is ${runStatus} after settling.`;
683
+ return {
684
+ ok: true,
685
+ runId,
686
+ stepId: state.run.currentStepId ?? "(none)",
687
+ // No unit was reported — the settle verb advances the deterministic spine.
688
+ unitId: "(settle)",
689
+ status: "skipped",
690
+ gateLoop: 1,
691
+ recorded: "not-recorded",
692
+ remainingUnits: 0,
693
+ ...(runStatus === "completed" ? { stepOutcome: { kind: "advanced" } } : {}),
694
+ runStatus,
695
+ message: contendedNote ? `${contendedNote} (idempotent success). ${message}` : message,
696
+ };
697
+ }
698
+ /**
699
+ * Compute the report context for a run's active step: find the frozen step plan,
700
+ * project prior evidence, recover the gate loop + feedback, and compute the
701
+ * SHARED work-list (same ids/hashes/prompts the engine and `brief` compute).
702
+ * `dispatching` is false for the non-dispatching steps the engine auto-advances
703
+ * (route-only, empty fan-out, all-unresolvable, whole-list failure) — the report
704
+ * path settles past those rather than getting stuck at a step no `report --unit`
705
+ * can complete.
706
+ */
707
+ function buildStepContext(runId, plan, next, units) {
708
+ const stepState = next.step;
709
+ if (!stepState) {
710
+ throw new UsageError(`Workflow run ${runId} is active but has no current step to report against.`);
711
+ }
712
+ const stepPlan = plan.steps.find((s) => s.stepId === stepState.id);
713
+ if (!stepPlan) {
714
+ throw new UsageError(`Step "${stepState.id}" of run ${runId} is not present in the run's frozen plan — cannot report against it.`);
715
+ }
716
+ const priorEvidence = {};
717
+ for (const s of next.workflow.steps)
718
+ priorEvidence[s.id] = s.evidence;
719
+ const stepOutputs = stepOutputsFromEvidence(priorEvidence);
720
+ const gateLoop = activeGateLoop(units, stepState.id);
721
+ const gateFeedback = recoverGateFeedback(units, stepState.id, gateLoop);
722
+ // Route-only steps carry no execution subgraph — nothing to report.
723
+ if (!stepPlan.root) {
724
+ return { next, stepState, stepPlan, workList: null, gateLoop, priorEvidence, units, dispatching: false };
725
+ }
726
+ const computed = computeStepWorkList(stepPlan, {
727
+ runId,
728
+ params: next.run.params ?? {},
729
+ stepOutputs,
730
+ engines: plan.execution.engines,
731
+ gateLoop,
732
+ ...(gateFeedback ? { gateFeedback } : {}),
733
+ });
734
+ if (!computed.ok) {
735
+ return {
736
+ next,
737
+ stepState,
738
+ stepPlan,
739
+ workList: null,
740
+ computeError: computed.error,
741
+ gateLoop,
742
+ priorEvidence,
743
+ units,
744
+ dispatching: false,
745
+ };
746
+ }
747
+ const workList = computed.list;
748
+ // A step is dispatching only if a driver can actually report ≥1 unit: an empty
749
+ // fan-out or an all-unresolvable work-list has nothing to report.
750
+ const dispatching = workList.units.some((u) => u.resolved.ok);
751
+ return { next, stepState, stepPlan, workList, gateLoop, priorEvidence, units, dispatching };
752
+ }
753
+ /**
754
+ * Rebuild a step's unit outcomes from the journal and reduce them through the
755
+ * SAME functions the executor uses. An EMPTY work-list promotes the degenerate
756
+ * empty artifact ({@link reduceEmptyStep}); otherwise each unit is rehydrated
757
+ * from its journal row, and an UNRESOLVABLE unit (a bad `item.<path>` reference)
758
+ * is treated as the engine's immediate `expression_error` failure — never
759
+ * journaled, always reduced as a failed outcome — so a partially- or fully-
760
+ * unresolvable step reduces identically on both surfaces.
761
+ */
762
+ function reduceWorkListOutcomes(stepPlan, workList, byUnit) {
763
+ if (workList.units.length === 0) {
764
+ return reduceEmptyStep(stepPlan, workList.reducer);
765
+ }
766
+ const outcomes = [];
767
+ for (const u of workList.units) {
768
+ if (!u.resolved.ok) {
769
+ outcomes.push({ unitId: u.unitId, ok: false, failureReason: "expression_error", error: u.resolved.error });
770
+ continue;
771
+ }
772
+ // Reduce each unit by its BEST terminal attempt (base + `~r<n>` retries), the
773
+ // SAME reuse the engine applies (shared {@link selectUnitAttemptRow}). A unit
774
+ // whose base attempt failed but whose retry completed reduces as COMPLETED,
775
+ // exactly like engine resume (finding C). A still-outstanding sibling with no
776
+ // terminal row is excluded rather than reduced against a missing row — this
777
+ // only arises on the fail-fast path (a single failed unit under
778
+ // `on_error: fail` already fixes the step's verdict; unreported siblings play
779
+ // no part in it). On the normal finalize path every resolvable unit is
780
+ // terminal, so nothing is excluded and the reduction is unchanged.
781
+ const row = selectUnitAttemptRow(u, byUnit);
782
+ if (!row || (row.status !== "completed" && row.status !== "failed"))
783
+ continue;
784
+ outcomes.push(unitOutcomeFromRow(u.unitId, row, u.schema !== undefined));
785
+ }
786
+ return reduceStepOutcomes(stepPlan, workList.reducer, workList.isFanOut, workList.template.onError, outcomes);
787
+ }
788
+ /**
789
+ * Run ONE completion attempt for a reduced step outcome through the SHARED
790
+ * {@link finalizeExecutedStep} (route eval → artifact-judged gate → gate-row
791
+ * journaling → `completeWorkflowStep`), normalizing its result. A typed-artifact
792
+ * schema mismatch is a HARD failure on the report surface (documented call:
793
+ * the ENGINE recovers it from in-invocation memory, but no gate row is
794
+ * journaled, so the stateless report path cannot recover the feedback across
795
+ * invocations — and synthesizing a gate row would break engine/report unit-graph
796
+ * parity). A GATE rejection journals its `<stepId>.gate:l<loop>` row, so its
797
+ * feedback IS recoverable and stays a real bounded loop.
798
+ */
799
+ async function runStepCompletion(args) {
800
+ const maxLoops = Math.max(1, args.stepPlan.gate.maxLoops ?? 1);
801
+ const lease = args.leaseHolder !== undefined ? { leaseHolder: args.leaseHolder } : {};
802
+ const finalize = await finalizeExecutedStep({
803
+ runId: args.runId,
804
+ workflowRef: args.workflowRef,
805
+ stepId: args.stepId,
806
+ stepPlan: args.stepPlan,
807
+ completionCriteria: args.completionCriteria,
808
+ gateLoop: args.gateLoop,
809
+ loopsRemaining: args.gateLoop < maxLoops,
810
+ result: args.reduced,
811
+ priorEvidence: args.priorEvidence,
812
+ params: args.params,
813
+ routeSelected: args.routeSelected,
814
+ routeUnselected: args.routeUnselected,
815
+ summaryJudge: args.summaryJudge,
816
+ ...lease,
817
+ });
818
+ if (finalize.kind === "retry") {
819
+ if (!args.reduced.ok) {
820
+ // Typed-artifact schema mismatch → hard failure on the report surface.
821
+ await completeWorkflowStep({
822
+ runId: args.runId,
823
+ stepId: args.stepId,
824
+ status: "failed",
825
+ notes: args.reduced.summary,
826
+ evidence: args.reduced.evidence,
827
+ ...lease,
828
+ });
829
+ return { kind: "failed", summary: args.reduced.summary };
830
+ }
831
+ return {
832
+ kind: "gate-rejected",
833
+ loopsRemaining: true,
834
+ missing: finalize.gateFeedback.missing,
835
+ feedback: finalize.gateFeedback.feedback,
836
+ };
837
+ }
838
+ if (finalize.kind === "gate-exhausted") {
839
+ return {
840
+ kind: "gate-rejected",
841
+ loopsRemaining: false,
842
+ missing: finalize.gateRejection.missing,
843
+ feedback: finalize.gateRejection.feedback,
844
+ };
845
+ }
846
+ if (finalize.kind === "failed") {
847
+ return { kind: "failed", summary: finalize.summary };
848
+ }
849
+ return { kind: "advanced" };
850
+ }
851
+ /**
852
+ * The finalize-lock boundary (W2 finalize-lock split, §10.7). Claims the run's
853
+ * engine lease as a short-lived lock through the SAME atomic single-UPDATE CAS
854
+ * primitive (`acquireEngineLease`) so exactly ONE reporter runs a step's
855
+ * completion, threads the lock holder into the locked body, and releases the
856
+ * lease in `finally`. On a failed acquire the caller's `onContended` result is
857
+ * returned WITHOUT holding or releasing any lock. Sole owner of the
858
+ * lease-as-lock lifecycle for both the report finalize path ({@link finalizeStep})
859
+ * and the standalone `--settle` verb ({@link settleWorkflowSpineWithBarrier}).
860
+ */
861
+ async function withFinalizeLock(args) {
862
+ const holder = `${args.holderPrefix}:${randomUUID()}`;
863
+ const nowIso = args.now().toISOString();
864
+ const lockExpiry = new Date(args.now().getTime() + FINALIZE_LOCK_TTL_MS).toISOString();
865
+ const acquired = await withWorkflowRunsRepo((repo) => repo.acquireEngineLease(args.runId, holder, lockExpiry, nowIso));
866
+ if (!acquired)
867
+ return args.onContended();
868
+ try {
869
+ return await args.run(holder);
870
+ }
871
+ finally {
872
+ await withWorkflowRunsRepo((repo) => repo.releaseEngineLease(args.runId, holder));
873
+ }
874
+ }
875
+ async function finalizeStep(args) {
876
+ const { runId, next, plan, stepPlan, stepState, workList, byUnit, gateLoop, recorded } = args;
877
+ // ── Finalization CAS (#5) ──────────────────────────────────────────────────
878
+ // Two reporters that both observe the work-list fully terminal would otherwise
879
+ // BOTH run the completion path — double-judging the gate, journaling duplicate
880
+ // gate rows, racing `completeWorkflowStep`. Claim the run's engine lease as a
881
+ // short-lived finalize lock (the shared {@link withFinalizeLock} over the same
882
+ // atomic single-UPDATE primitive the engine uses): exactly one reporter wins.
883
+ // The loser (failed acquire) never runs the judge; it re-reads the spine and
884
+ // returns the winner's outcome as idempotent success — never a raw throw after
885
+ // useful work. `completeWorkflowStep`'s own transactional CAS remains the
886
+ // ultimate arbiter of the single spine advance. The lock is released before the
887
+ // trailing settle/messaging below runs its own (unlocked) `completeWorkflowStep`
888
+ // calls for downstream non-dispatching steps.
889
+ const locked = await withFinalizeLock({
890
+ runId,
891
+ holderPrefix: "report-finalize",
892
+ now: args.now,
893
+ onContended: async () => ({
894
+ // A concurrent finalizer holds the lock; it will advance the step exactly
895
+ // once. Return idempotent success reflecting the freshest spine state.
896
+ kind: "result",
897
+ result: await contendedFinalizeResult(runId, stepState, args.written, gateLoop, recorded),
898
+ }),
899
+ run: async (finalizeHolder) => {
900
+ // Under the lock, re-read the spine: a PRIOR finalizer may have already
901
+ // advanced this step (a sequential race — both reporters saw the work-list
902
+ // terminal, the first finished before this one acquired). If so, skip
903
+ // re-completion and report idempotent success — the step is done.
904
+ const fresh = await getNextWorkflowStep(runId);
905
+ if (fresh.run.status !== "active" || fresh.step?.id !== stepState.id) {
906
+ return {
907
+ kind: "result",
908
+ result: await contendedFinalizeResult(runId, stepState, args.written, gateLoop, recorded, fresh),
909
+ };
910
+ }
911
+ const reduced = reduceWorkListOutcomes(stepPlan, workList, byUnit);
912
+ // Route/skip bookkeeping seeded from the journal so cascaded skips survive
913
+ // (identical to the engine's resume seeding).
914
+ const routeSelected = new Set();
915
+ const routeUnselected = new Map();
916
+ seedJournaledRouteDecisions(plan, next, routeSelected, routeUnselected);
917
+ const completion = await runStepCompletion({
918
+ runId,
919
+ workflowRef: next.run.workflowRef,
920
+ stepPlan,
921
+ stepId: stepState.id,
922
+ completionCriteria: stepPlan.gate.criteria,
923
+ gateLoop,
924
+ reduced,
925
+ priorEvidence: args.priorEvidence,
926
+ params: next.run.params ?? {},
927
+ routeSelected,
928
+ routeUnselected,
929
+ summaryJudge: args.summaryJudge,
930
+ leaseHolder: finalizeHolder,
931
+ });
932
+ return { kind: "completion", completion };
933
+ },
934
+ });
935
+ if (locked.kind === "result")
936
+ return locked.result;
937
+ const completion = locked.completion;
938
+ const maxLoops = Math.max(1, stepPlan.gate.maxLoops ?? 1);
939
+ if (completion.kind === "failed") {
940
+ const state = await getNextWorkflowStep(runId);
941
+ return reportResult(runId, stepState.id, args.written, gateLoop, state.run.status, { kind: "failed", summary: completion.summary }, `Step "${stepState.id}" failed: ${completion.summary}`, recorded);
942
+ }
943
+ if (completion.kind === "gate-rejected") {
944
+ if (completion.loopsRemaining) {
945
+ const nextLoop = gateLoop + 1;
946
+ return reportResult(runId, stepState.id, args.written, gateLoop, "active", {
947
+ kind: "gate-rejected",
948
+ loopsRemaining: true,
949
+ missing: completion.missing,
950
+ feedback: completion.feedback,
951
+ summary: completion.feedback,
952
+ }, `Step "${stepState.id}" was rejected — run \`akm workflow brief ${runId}\` for loop ${nextLoop}'s work-list (feedback threaded in).`, recorded);
953
+ }
954
+ return reportResult(runId, stepState.id, args.written, gateLoop, "active", {
955
+ kind: "gate-rejected",
956
+ loopsRemaining: false,
957
+ missing: completion.missing,
958
+ feedback: completion.feedback,
959
+ summary: completion.feedback,
960
+ }, `Step "${stepState.id}" was rejected and its ${maxLoops}-loop gate budget is exhausted. Resolve it manually (\`akm workflow complete\`/\`resume\`/\`abandon\`).`, recorded);
961
+ }
962
+ // advanced — the spine moved. Settle forward over any non-dispatching steps
963
+ // (route-only / skipped / empty fan-out / all-unresolvable) so the run never
964
+ // gets stuck at a step no driver could report, then surface the resting state.
965
+ const state = await settleSpine({ plan, runId, summaryJudge: args.summaryJudge });
966
+ const message = state.run.status === "completed"
967
+ ? `Step "${stepState.id}" completed — the workflow run is now DONE.`
968
+ : state.step
969
+ ? `Step "${stepState.id}" completed. Next: run \`akm workflow brief ${runId}\` for step "${state.step.id}".`
970
+ : `Step "${stepState.id}" completed; run is ${state.run.status}.`;
971
+ return reportResult(runId, stepState.id, args.written, gateLoop, state.run.status, { kind: "advanced" }, message, recorded);
972
+ }
973
+ /**
974
+ * Settle the spine forward over every NON-DISPATCHING step the engine would
975
+ * auto-advance but no `report --unit` could ever complete: a route-skipped
976
+ * target, a route-only step, an empty fan-out (`over: []`), a step whose every
977
+ * unit is unresolvable, and a whole-list resolution failure. Each is completed
978
+ * (or failed) through the SAME shared helpers the engine uses, so the run does
979
+ * not get stuck — the exact gap peer review R3 flagged. Stops at the first step
980
+ * with real reportable work, or when the run leaves `active`.
981
+ */
982
+ async function settleSpine(args) {
983
+ const { plan, runId, summaryJudge, leaseHolder } = args;
984
+ const leaseArg = leaseHolder !== undefined ? { leaseHolder } : {};
985
+ let state = await getNextWorkflowStep(runId);
986
+ const routeSelected = new Set();
987
+ const routeUnselected = new Map();
988
+ seedJournaledRouteDecisions(plan, state, routeSelected, routeUnselected);
989
+ // Bounded: each iteration advances the spine by one step OR advances one gate
990
+ // loop of a stuck gated step (which journals a gate row and is capped by that
991
+ // step's max_loops). The sum-of-loops bound cannot be exceeded.
992
+ const cap = plan.steps.reduce((n, s) => n + Math.max(1, s.gate.maxLoops ?? 1) + 2, 1);
993
+ for (let guard = 0; guard < cap && state.run.status === "active" && state.step; guard++) {
994
+ const step = state.step;
995
+ const sp = plan.steps.find((s) => s.stepId === step.id);
996
+ if (!sp)
997
+ break;
998
+ const stepJudge = summaryJudge === undefined ? frozenSummaryJudge(plan, sp.gate.judge) : summaryJudge;
999
+ // A route-skipped target: complete it as skipped, cascading if it is itself
1000
+ // a router (identical to the engine loop's skip handling).
1001
+ const skipInfo = routeUnselected.get(step.id);
1002
+ if (skipInfo && !routeSelected.has(step.id)) {
1003
+ if (sp.route)
1004
+ cascadeSkippedRouter(sp.route, step.id, routeUnselected);
1005
+ const notes = skipInfo.selected === null
1006
+ ? `Skipped by route: step "${skipInfo.router}" was itself skipped, so none of its branch targets run.`
1007
+ : `Skipped by route: step "${skipInfo.router}" selected "${skipInfo.selected}".`;
1008
+ await completeWorkflowStep({ runId, stepId: step.id, status: "skipped", notes, ...leaseArg });
1009
+ state = await getNextWorkflowStep(runId);
1010
+ continue;
1011
+ }
1012
+ // A route-only step (no execution subgraph): evaluate + complete it here.
1013
+ if (!sp.root && sp.route) {
1014
+ const priorEvidence = {};
1015
+ for (const s of state.workflow.steps)
1016
+ priorEvidence[s.id] = s.evidence;
1017
+ const fin = await finalizeExecutedStep({
1018
+ runId,
1019
+ workflowRef: state.run.workflowRef,
1020
+ stepId: step.id,
1021
+ stepPlan: sp,
1022
+ completionCriteria: sp.gate.criteria,
1023
+ gateLoop: 1,
1024
+ loopsRemaining: false,
1025
+ result: {
1026
+ ok: true,
1027
+ units: [],
1028
+ evidence: {},
1029
+ summary: `Step "${step.id}" is a route step — no units dispatched.`,
1030
+ },
1031
+ priorEvidence,
1032
+ params: state.run.params ?? {},
1033
+ routeSelected,
1034
+ routeUnselected,
1035
+ summaryJudge: stepJudge,
1036
+ ...leaseArg,
1037
+ });
1038
+ if (fin.kind !== "advanced")
1039
+ break; // a route failure stops the walk
1040
+ state = await getNextWorkflowStep(runId);
1041
+ continue;
1042
+ }
1043
+ // An executing step. Settle it ONLY when it dispatches no reportable units
1044
+ // (empty fan-out, all-unresolvable, or a whole-list failure); otherwise the
1045
+ // driver briefs and reports it, so stop here.
1046
+ if (sp.root) {
1047
+ const freshUnits = await withWorkflowRunsRepo((repo) => repo.getUnitsForRun(runId));
1048
+ const priorEvidence = {};
1049
+ for (const s of state.workflow.steps)
1050
+ priorEvidence[s.id] = s.evidence;
1051
+ const stepOutputs = stepOutputsFromEvidence(priorEvidence);
1052
+ const gateLoop = activeGateLoop(freshUnits, step.id);
1053
+ const gateFeedback = recoverGateFeedback(freshUnits, step.id, gateLoop);
1054
+ const computed = computeStepWorkList(sp, {
1055
+ runId,
1056
+ params: state.run.params ?? {},
1057
+ stepOutputs,
1058
+ engines: plan.execution.engines,
1059
+ gateLoop,
1060
+ ...(gateFeedback ? { gateFeedback } : {}),
1061
+ });
1062
+ if (!computed.ok) {
1063
+ // Whole-list resolution failure → the engine fails the step (failedStep).
1064
+ await completeWorkflowStep({
1065
+ runId,
1066
+ stepId: step.id,
1067
+ status: "failed",
1068
+ notes: computed.error,
1069
+ evidence: { error: computed.error },
1070
+ ...leaseArg,
1071
+ });
1072
+ break; // run failed
1073
+ }
1074
+ const list = computed.list;
1075
+ if (list.units.some((u) => u.resolved.ok))
1076
+ break; // real reportable work — stop.
1077
+ // No reportable units (empty fan-out OR all-unresolvable). Auto-complete
1078
+ // it exactly as the engine would, through the SAME completion path.
1079
+ const byUnit = indexDispatchRows(freshUnits);
1080
+ const reduced = reduceWorkListOutcomes(sp, list, byUnit);
1081
+ const completion = await runStepCompletion({
1082
+ runId,
1083
+ workflowRef: state.run.workflowRef,
1084
+ stepPlan: sp,
1085
+ stepId: step.id,
1086
+ completionCriteria: sp.gate.criteria,
1087
+ gateLoop,
1088
+ reduced,
1089
+ priorEvidence,
1090
+ params: state.run.params ?? {},
1091
+ routeSelected,
1092
+ routeUnselected,
1093
+ summaryJudge: stepJudge,
1094
+ ...leaseArg,
1095
+ });
1096
+ if (completion.kind === "advanced") {
1097
+ state = await getNextWorkflowStep(runId);
1098
+ continue;
1099
+ }
1100
+ // A gate rejection on a zero-unit step re-runs the (unchanged) empty
1101
+ // artifact, but the journaled gate row advances the loop, so the next
1102
+ // iteration re-evaluates at gateLoop+1, bounded by max_loops. Loop the
1103
+ // SAME step WITHOUT advancing the spine.
1104
+ if (completion.kind === "gate-rejected" && completion.loopsRemaining)
1105
+ continue;
1106
+ // failed / gate-exhausted → nothing more to auto-advance.
1107
+ break;
1108
+ }
1109
+ break;
1110
+ }
1111
+ // Re-read the freshest run state: a terminal-break path (a failed step, an
1112
+ // exhausted gate) left `state` reflecting the pre-completion snapshot from the
1113
+ // top of the loop iteration, but the DB now holds the true resting state.
1114
+ return getNextWorkflowStep(runId);
1115
+ }
1116
+ // ── Helpers ──────────────────────────────────────────────────────────────────
1117
+ /**
1118
+ * Normalize a driver-supplied `--failure-reason` to the persisted taxonomy (PR
1119
+ * #714 review round 2, #16). An external driver can type ANY string; storing it
1120
+ * verbatim would let an arbitrary token masquerade as a first-class failure
1121
+ * vocabulary and (worse) accidentally match a workflow's `retry.on`. So:
1122
+ *
1123
+ * - an EMPTY/absent reason → the neutral default `reported_failure`;
1124
+ * - a reason IN the canonical taxonomy ({@link PROGRAM_RETRY_REASONS}, the
1125
+ * exact `AgentFailureReason` set `retry.on` accepts) → stored VERBATIM, so a
1126
+ * driver reporting e.g. `timeout` participates in retry semantics identically
1127
+ * to an engine-dispatched unit;
1128
+ * - anything ELSE → namespaced under `external:<slug>` (lowercase, `[a-z0-9_-]`,
1129
+ * clipped). An `external:*` value is BY CONSTRUCTION outside the taxonomy, so
1130
+ * `retry.on` (which only lists taxonomy reasons) can never fire on it — an
1131
+ * unknown external reason is recorded for observability without ever
1132
+ * triggering retry.
1133
+ */
1134
+ export function normalizeFailureReason(raw, sensitiveValues = []) {
1135
+ const trimmed = raw?.trim();
1136
+ if (!trimmed)
1137
+ return "reported_failure";
1138
+ if (redactSensitiveText(trimmed, sensitiveValues) !== trimmed)
1139
+ return "reported_failure";
1140
+ if (PROGRAM_RETRY_REASONS.includes(trimmed))
1141
+ return trimmed;
1142
+ const slug = trimmed
1143
+ .toLowerCase()
1144
+ .replace(/[^a-z0-9_-]+/g, "-")
1145
+ .replace(/^-+|-+$/g, "")
1146
+ .slice(0, 48);
1147
+ return `external:${slug || "unknown"}`;
1148
+ }
1149
+ /** Validate + shape the reported result into what `finishUnit` persists. */
1150
+ function prepareResult(input, workUnit, sensitiveValues) {
1151
+ if (input.status === "failed") {
1152
+ return {
1153
+ resultJson: input.resultRaw !== undefined && input.resultRaw !== ""
1154
+ ? JSON.stringify(redactSensitiveText(input.resultRaw, sensitiveValues))
1155
+ : null,
1156
+ failureReason: normalizeFailureReason(input.failureReason, sensitiveValues),
1157
+ };
1158
+ }
1159
+ // completed
1160
+ if (workUnit.schema) {
1161
+ const raw = input.resultRaw;
1162
+ if (raw === undefined || raw.trim() === "") {
1163
+ throw new UsageError(`Unit "${input.unitId}" declares an output schema — its --result must be a JSON value matching that schema, ` +
1164
+ `but no result was provided.`, "MISSING_REQUIRED_ARGUMENT");
1165
+ }
1166
+ let parsed;
1167
+ try {
1168
+ parsed = JSON.parse(raw);
1169
+ }
1170
+ catch (err) {
1171
+ throw new UsageError(`Unit "${input.unitId}" result is not valid JSON (its output schema requires a JSON value): ${redactSensitiveText(err instanceof Error ? err.message : String(err), sensitiveValues)}`, "INVALID_FLAG_VALUE");
1172
+ }
1173
+ const errors = validateJsonSchemaSubset(parsed, workUnit.schema);
1174
+ if (errors.length > 0) {
1175
+ throw new UsageError(`Unit "${input.unitId}" result failed validation against its declared output schema: ${redactSensitiveText(errors.join("; "), sensitiveValues)}.`, "INVALID_FLAG_VALUE");
1176
+ }
1177
+ return { resultJson: JSON.stringify(redactSensitiveValue(parsed, sensitiveValues)), failureReason: null };
1178
+ }
1179
+ // Free-text unit: journal the text as a JSON string EXACTLY as the executor
1180
+ // does — `native-executor.ts` finishUnit uses `outcome.text ? JSON.stringify… :
1181
+ // null`, so an empty (or absent) output journals result_json = NULL, not '""'.
1182
+ // Matching that keeps the promoted artifact and the dispatch row byte-identical
1183
+ // across the engine and report surfaces (the cardinal graph-parity rule), and
1184
+ // stays consistent with the FAILED branch above which also maps ""→null.
1185
+ return {
1186
+ resultJson: input.resultRaw ? JSON.stringify(redactSensitiveText(input.resultRaw, sensitiveValues)) : null,
1187
+ failureReason: null,
1188
+ };
1189
+ }
1190
+ async function collectReportedUnitSensitiveValues(workUnit) {
1191
+ const values = new Set();
1192
+ for (const ref of workUnit.env ?? []) {
1193
+ const { resolveEnvBinding } = await import("../../commands/env/env-binding.js");
1194
+ for (const value of Object.values(resolveEnvBinding(ref).values))
1195
+ values.add(value);
1196
+ }
1197
+ const collectEngine = (engine) => {
1198
+ if (!engine)
1199
+ return;
1200
+ if (engine.kind === "llm") {
1201
+ for (const name of engine.credential?.names ?? []) {
1202
+ const value = process.env[name]?.trim();
1203
+ if (value)
1204
+ values.add(value);
1205
+ }
1206
+ return;
1207
+ }
1208
+ for (const name of engine.envPassthrough) {
1209
+ const value = process.env[name];
1210
+ if (!isEnvPassthroughValueSafeToExpose(name, value) && value)
1211
+ values.add(value);
1212
+ }
1213
+ };
1214
+ collectEngine(workUnit.engine);
1215
+ collectEngine(workUnit.fallbackEngine);
1216
+ return collectSensitiveValues(values);
1217
+ }
1218
+ /**
1219
+ * Assess the frozen plan's declared budget ceilings for ONE report, seeded from
1220
+ * the journal exactly as the engine seeds `DispatchBudget`: dispatch rows
1221
+ * (phase = null) OTHER than the one being written count against `max_units`, and
1222
+ * their token sum against `max_tokens`. Unlike a simple admission check, this
1223
+ * mirrors the engine's TWO enforcement points so the report path reaches the
1224
+ * engine's terminal state (a HARD step failure naming the ceiling) instead of
1225
+ * throwing and leaving the run stuck (peer review R3, finding 1):
1226
+ *
1227
+ * - `refuse` — the engine's `tryConsume` refuses the (maxUnits+1)-th dispatch,
1228
+ * or a dispatch whose run token total is already at/over `max_tokens`,
1229
+ * WITHOUT journaling it. The report writes no row and fails the step.
1230
+ * - `tokens-cross` — the engine's `addTokens` crosses `max_tokens` AFTER a
1231
+ * dispatch: the unit IS journaled, then pending dispatches abort and the
1232
+ * step fails. The report writes the row, then fails the step.
1233
+ *
1234
+ * Budget ceilings fail the step regardless of `on_error` — a capped run must
1235
+ * never quietly pass its gate.
1236
+ */
1237
+ function assessBudget(plan, rows, journalId, thisTokens, existing) {
1238
+ const budget = plan.budget;
1239
+ if (!budget || (budget.maxUnits === undefined && budget.maxTokens === undefined))
1240
+ return { kind: "ok" };
1241
+ // The row being (re)written carries CUMULATIVE tokens across its attempts
1242
+ // (finding A): `finishUnit` will preserve them (prior spend + this write), so
1243
+ // they are part of the run's committed token total and count against
1244
+ // `max_tokens` here just like every other row's tokens. NULL ⇒ 0.
1245
+ const existingTokens = existing?.tokens ?? 0;
1246
+ let othersDispatched = 0;
1247
+ let othersTokens = 0;
1248
+ let existingAttempts = 0;
1249
+ for (const row of rows) {
1250
+ if (row.phase !== null)
1251
+ continue; // gate rows excluded
1252
+ if (row.unit_id === journalId) {
1253
+ // The row being (re)written. Its ALREADY-SPENT attempts must be counted
1254
+ // before admission (#4): excluding them let a re-report of a non-terminal
1255
+ // or failed unit erase the prior attempt from the ceiling. Its tokens are
1256
+ // accounted via `existingTokens` (the finish carries them forward, not
1257
+ // overwrites), so a rerun never drops a failed attempt's spend (finding A).
1258
+ existingAttempts = row.attempts;
1259
+ continue;
1260
+ }
1261
+ // Sum `attempts` (migration 008), not a per-row +1: a crash-retried unit
1262
+ // occupies ONE row whose `attempts` records every re-dispatch, so this
1263
+ // mirrors the engine seed (`run-workflow.ts`) and charges each dispatch.
1264
+ othersDispatched += row.attempts;
1265
+ othersTokens += row.tokens ?? 0;
1266
+ }
1267
+ // The attempts this write adds: a live `running` claim being finalized reuses
1268
+ // the claim's already-charged attempt (finishUnit, no insert ⇒ +0); every
1269
+ // other write (fresh, or a FAILED --rerun re-dispatch) inserts and bumps
1270
+ // `attempts` by one, mirroring the engine's charge-before-dispatch. The
1271
+ // projected total after this write must not exceed `max_units`.
1272
+ const increment = existing?.status === "running" ? 0 : 1;
1273
+ const projectedUnits = othersDispatched + existingAttempts + increment;
1274
+ if (budget.maxUnits !== undefined && projectedUnits > budget.maxUnits) {
1275
+ return {
1276
+ kind: "refuse",
1277
+ message: `budget exceeded (max_units ceiling): ${projectedUnits} unit dispatch(es) would be charged for this run ` +
1278
+ `against the workflow's declared budget.max_units of ${budget.maxUnits} — the step fails hard (budget ` +
1279
+ `ceilings ignore on_error).`,
1280
+ };
1281
+ }
1282
+ const committedTokens = othersTokens + existingTokens;
1283
+ if (budget.maxTokens !== undefined && committedTokens >= budget.maxTokens) {
1284
+ return {
1285
+ kind: "refuse",
1286
+ message: `budget exceeded (max_tokens ceiling): ${committedTokens} token(s) already spent for this run against the ` +
1287
+ `workflow's declared budget.max_tokens of ${budget.maxTokens} — the step fails hard (budget ceilings ignore on_error).`,
1288
+ };
1289
+ }
1290
+ if (budget.maxTokens !== undefined && committedTokens + thisTokens >= budget.maxTokens) {
1291
+ return {
1292
+ kind: "tokens-cross",
1293
+ message: `budget exceeded (max_tokens ceiling): ${committedTokens + thisTokens} token(s) spent for this run, reaching the ` +
1294
+ `workflow's declared budget.max_tokens of ${budget.maxTokens} — the step fails hard (budget ceilings ignore on_error).`,
1295
+ };
1296
+ }
1297
+ return { kind: "ok" };
1298
+ }
1299
+ // ── Claim + hash guards (shared by the running + finish transactions) ─────────
1300
+ /**
1301
+ * Stale-hash guard (review round 2, #3): under a frozen plan the same unit
1302
+ * identity must reproduce the same input hash, so an existing row whose recorded
1303
+ * `input_hash` differs from the recomputed one is a hard replay divergence — a
1304
+ * stale or tampered row. A NULL recorded hash (never seen in practice for a
1305
+ * report-claimed row) is treated as non-divergent.
1306
+ */
1307
+ function assertNoHashDivergence(existing, inputHash, runId, journalId) {
1308
+ if (existing.input_hash !== null && existing.input_hash !== inputHash) {
1309
+ throw new UsageError(`Replay divergence: unit "${journalId}" of run ${runId} has a journaled ${existing.status} row whose input ` +
1310
+ `hash differs from this report's. Under a frozen plan the same unit identity must reproduce the same inputs ` +
1311
+ `— refusing to heartbeat or finalize a stale/tampered row. Start a new run to re-execute this work.`);
1312
+ }
1313
+ }
1314
+ /**
1315
+ * Claim compare-and-set (review round 2, #3): a LIVE claim (holder set, expiry
1316
+ * in the future) held by a DIFFERENT holder blocks a reclaim/heartbeat/finish;
1317
+ * a free row, an EXPIRED claim (crash recovery), or a claim already ours passes.
1318
+ * A claimless row always passes — simple drivers skip claims entirely.
1319
+ */
1320
+ function assertClaimHeldByOrFree(existing, holder, nowIso, runId, journalId, action) {
1321
+ const claimLive = existing.claim_expires_at !== null && existing.claim_expires_at >= nowIso;
1322
+ if (existing.claim_holder !== null && claimLive && existing.claim_holder !== holder) {
1323
+ throw new UsageError(`Unit "${journalId}" of run ${runId} is claimed by ${existing.claim_holder} until ${existing.claim_expires_at}; ` +
1324
+ `only that holder can ${action} it while the claim is live. Pass --session-id ${existing.claim_holder} if you ` +
1325
+ `own the claim, or wait for it to expire (then it is reclaimable — crash recovery).`);
1326
+ }
1327
+ }
1328
+ /**
1329
+ * Fail the active step because a declared budget ceiling was crossed — the same
1330
+ * terminal state the engine reaches (`failedStep` → a FAILED step and run). The
1331
+ * failure goes through `completeWorkflowStep` (the gate spine is never bypassed)
1332
+ * with the ceiling-naming message as notes/evidence. `wroteRow` reflects whether
1333
+ * the crossing unit's row was journaled (a `tokens-cross`) or not (a `refuse`).
1334
+ */
1335
+ async function failStepOnBudget(runId, stepId, journalId, status, gateLoop, message, wroteRow) {
1336
+ await completeWorkflowStep({ runId, stepId, status: "failed", notes: message, evidence: { error: message } });
1337
+ const state = await getNextWorkflowStep(runId);
1338
+ return {
1339
+ ok: true,
1340
+ runId,
1341
+ stepId,
1342
+ unitId: journalId,
1343
+ status,
1344
+ gateLoop,
1345
+ recorded: wroteRow ? "written" : "not-recorded",
1346
+ remainingUnits: 0,
1347
+ stepOutcome: { kind: "failed", summary: message },
1348
+ runStatus: state.run.status,
1349
+ message: `Step "${stepId}" failed: ${message}`,
1350
+ };
1351
+ }
1352
+ /**
1353
+ * The result surfaced to the LOSER of the finalization CAS (#5): a concurrent
1354
+ * reporter holds (or already finished) the step's finalization. This report's
1355
+ * unit row IS durably journaled; the step advance is (being) done exactly once
1356
+ * by the winner, so this is idempotent success — never a raw throw. Re-reads the
1357
+ * freshest spine so the surfaced run status / advance is accurate.
1358
+ */
1359
+ async function contendedFinalizeResult(runId, stepState, written, gateLoop, recorded, fresh) {
1360
+ const state = fresh ?? (await getNextWorkflowStep(runId));
1361
+ const advancedPast = state.run.status !== "active" || state.step?.id !== stepState.id;
1362
+ return {
1363
+ ok: true,
1364
+ runId,
1365
+ stepId: stepState.id,
1366
+ unitId: written.unitId,
1367
+ status: written.status,
1368
+ gateLoop,
1369
+ // The UNIT write resolved as `recorded` (a real write, or an idempotent
1370
+ // re-report); only the STEP finalization was contended (a concurrent
1371
+ // reporter owns it), which the message conveys.
1372
+ recorded,
1373
+ remainingUnits: 0,
1374
+ ...(advancedPast ? { stepOutcome: { kind: "advanced" } } : {}),
1375
+ runStatus: state.run.status,
1376
+ message: advancedPast
1377
+ ? `Unit "${written.unitId}" recorded; step "${stepState.id}" was finalized by a concurrent reporter (idempotent success).`
1378
+ : `Unit "${written.unitId}" recorded; step "${stepState.id}" is being finalized by a concurrent reporter (idempotent success).`,
1379
+ };
1380
+ }
1381
+ /**
1382
+ * The result surfaced when settling non-dispatching steps drove the run to a
1383
+ * terminal (or step-less) state before the reported unit could be recorded —
1384
+ * the run advanced/failed on its own, so there is nothing left to journal.
1385
+ */
1386
+ function settledTerminalResult(input, settled) {
1387
+ const runStatus = settled.run.status;
1388
+ const message = runStatus === "completed"
1389
+ ? `The run advanced to completion while settling steps with no reportable work; unit "${input.unitId}" needed no report.`
1390
+ : `The run is ${runStatus} after settling steps with no reportable work; unit "${input.unitId}" could not be recorded.`;
1391
+ return {
1392
+ ok: true,
1393
+ runId: settled.run.id,
1394
+ stepId: settled.run.currentStepId ?? "(none)",
1395
+ unitId: input.unitId,
1396
+ status: input.status,
1397
+ gateLoop: 1,
1398
+ recorded: "not-recorded",
1399
+ remainingUnits: 0,
1400
+ stepOutcome: runStatus === "completed" ? { kind: "advanced" } : { kind: "failed", summary: message },
1401
+ runStatus,
1402
+ message,
1403
+ };
1404
+ }
1405
+ /**
1406
+ * Count the step's units that are still OUTSTANDING: resolvable units without a
1407
+ * terminal journal row. Unresolvable units are never reportable (the engine's
1408
+ * immediate `expression_error`), so they never keep a step outstanding — the
1409
+ * caller's reduction treats them as failed outcomes.
1410
+ */
1411
+ function remainingReportableUnits(workList, byUnit) {
1412
+ return workList.units.filter((u) => {
1413
+ if (!u.resolved.ok)
1414
+ return false;
1415
+ // A unit is terminal when its best attempt (base OR a completed `~r<n>`
1416
+ // retry) is terminal — the SAME reuse the reducer applies (finding C), so a
1417
+ // base-failed unit rescued by a completed retry is NOT counted outstanding.
1418
+ const row = selectUnitAttemptRow(u, byUnit);
1419
+ return !(row && (row.status === "completed" || row.status === "failed"));
1420
+ }).length;
1421
+ }
1422
+ /** Index the run's DISPATCH unit rows (phase != gate) by unit id. */
1423
+ function indexDispatchRows(rows) {
1424
+ const map = new Map();
1425
+ for (const row of rows) {
1426
+ if (row.phase === null)
1427
+ map.set(row.unit_id, row);
1428
+ }
1429
+ return map;
1430
+ }
1431
+ /** Count units whose journal row is not yet terminal (for progress messaging). */
1432
+ function countRemaining(workUnits, rows, justClaimed, claimedStatus) {
1433
+ const byUnit = indexDispatchRows(rows);
1434
+ return workUnits.filter((u) => {
1435
+ if (u.journalBaseId === justClaimed)
1436
+ return claimedStatus !== "completed" && claimedStatus !== "failed";
1437
+ // Unresolvable units are never reportable (the engine's expression_error), so
1438
+ // they never count as outstanding.
1439
+ if (!u.resolved.ok)
1440
+ return false;
1441
+ // Best terminal attempt (base + `~r<n>` retries), the shared reuse (finding C).
1442
+ const row = selectUnitAttemptRow(u, byUnit);
1443
+ return !(row && (row.status === "completed" || row.status === "failed"));
1444
+ }).length;
1445
+ }
1446
+ function reportResult(runId, stepId, written, gateLoop, runStatus, stepOutcome, message, recorded = "written") {
1447
+ return {
1448
+ ok: true,
1449
+ runId,
1450
+ stepId,
1451
+ unitId: written.unitId,
1452
+ status: written.status,
1453
+ gateLoop,
1454
+ recorded,
1455
+ remainingUnits: 0,
1456
+ stepOutcome,
1457
+ runStatus,
1458
+ message,
1459
+ };
1460
+ }