akm-cli 0.9.0-beta.9 → 0.9.0-rc.1

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