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,1156 @@
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
+ * Shared step semantics — the ONE implementation of a step's orchestration
6
+ * decisions, consumed by BOTH the engine loop (`run-workflow.ts` +
7
+ * `native-executor.ts`) and, from R3 on, the harness-neutral driver protocol
8
+ * (`workflow brief` / `workflow report`). The cardinal rule of the driver
9
+ * protocol (redesign addendum R3) is *no duplicated semantics*: work-list
10
+ * computation, prompt assembly, reducer/artifact promotion, output-schema
11
+ * validation, artifact-judged gate summaries, gate-feedback recovery, and
12
+ * route evaluation live here so an engine-driven run and a brief/report-driven
13
+ * run of the same frozen plan produce byte-identical unit graphs.
14
+ *
15
+ * ## What is PURE here
16
+ *
17
+ * {@link computeStepWorkList} — given the frozen step plan and a
18
+ * {@link WorkListInput} (params, prior step outputs, gate-loop number + its
19
+ * recovered feedback) — is a pure function: same inputs ⇒ same unit ids, input
20
+ * hashes, and fully-resolved prompts. It takes NO clock, NO IO, and NO journal
21
+ * (journal-derived state, i.e. the recovered gate feedback, is passed in). This
22
+ * is the load-bearing guarantee that `brief` can predict exactly the units the
23
+ * engine would dispatch. So are the reducer/artifact helpers
24
+ * ({@link buildEvidence}, {@link projectStepOutput}, {@link validateStepArtifact},
25
+ * {@link buildArtifactSummary}), the gate-feedback recovery
26
+ * ({@link recoverGateFeedback} / {@link activeGateLoop}), and route evaluation
27
+ * ({@link evaluateRoute} and its bookkeeping).
28
+ *
29
+ * ## What does IO here
30
+ *
31
+ * The gate-evaluation journaling ({@link journalGateEvaluationStart} /
32
+ * {@link journalGateEvaluationFinish}) writes `workflow_run_units` rows through
33
+ * the serialized writer queue — an engine-driven judge call is an LLM call and
34
+ * is journaled like a unit. It lives here (not in the engine loop) so the
35
+ * report path journals gate evaluations through the identical writer.
36
+ *
37
+ * This module NEVER dispatches a unit and NEVER writes step rows: dispatch is
38
+ * the executor's job (`native-executor.ts`), advancing the gated spine is the
39
+ * engine loop's job (`run-workflow.ts` via `completeWorkflowStep`).
40
+ */
41
+ import { createHash } from "node:crypto";
42
+ import unitPreambleTemplate from "../../assets/prompts/workflow-unit-preamble.md" with { type: "text" };
43
+ import { UsageError } from "../../core/errors.js";
44
+ import { appendEvent } from "../../core/events.js";
45
+ import { validateJsonSchemaSubset } from "../../core/json-schema.js";
46
+ import { withWorkflowRunsRepo } from "../../storage/repositories/workflow-runs-repository.js";
47
+ import { computePlanHash } from "../ir/plan-hash.js";
48
+ import { parseTemplate, resolveTemplate, resolveWholeValue, } from "../program/expressions.js";
49
+ import { buildDefaultSummaryJudge, completeWorkflowStep, } from "../runtime/runs.js";
50
+ import { enqueueUnitWrite } from "./unit-writer.js";
51
+ /**
52
+ * Default per-unit timeout. Deliberately NOT the 60 s agent default
53
+ * (`DEFAULT_AGENT_TIMEOUT_MS`) — workflow units routinely run real coding
54
+ * tasks on slow local models; 10 minutes matches the LLM-path default
55
+ * (`tryLlmFeature`). A unit's `timeout` declaration overrides this; `none`
56
+ * disables.
57
+ */
58
+ export const DEFAULT_UNIT_TIMEOUT_MS = 600_000;
59
+ /** How much raw unit output is retained in step evidence (full text lives on the unit row). */
60
+ const EVIDENCE_TEXT_CLIP = 2_000;
61
+ /** How much artifact JSON the completion-criteria judge receives (addendum R2, artifact-judging gates). */
62
+ const GATE_ARTIFACT_CLIP = 4_000;
63
+ /**
64
+ * Compute a step's expected work-list PURELY from the frozen plan and its
65
+ * inputs: resolve the fan-out list, derive content-derived unit ids, assemble
66
+ * each unit's prompt (preamble + interpolated instructions + gate feedback +
67
+ * schema directive), and hash the resolved input. Same inputs ⇒ byte-identical
68
+ * ids/hashes/prompts — the invariant `brief` relies on to predict the engine.
69
+ *
70
+ * Whole-list failures (missing subgraph, template parse error, unresolvable /
71
+ * non-array `over`, duplicate fan-out items) return `{ ok: false }`; a per-unit
72
+ * expression-resolution failure is carried on that unit's `resolved` field so
73
+ * the caller fails just that unit (mirroring the engine's `expression_error`
74
+ * outcome), never the whole step.
75
+ */
76
+ export function computeStepWorkList(plan, input) {
77
+ const root = plan.root;
78
+ // Route-only steps (YAML `route:`) carry no execution subgraph.
79
+ if (!root) {
80
+ return {
81
+ ok: false,
82
+ error: `Step "${plan.stepId}" has no execution subgraph (a route-only step); the native executor cannot dispatch it.`,
83
+ };
84
+ }
85
+ const template = root.kind === "map" ? root.template : root;
86
+ const reducer = root.kind === "map" ? root.reducer : "collect";
87
+ const scope = { params: input.params, stepOutputs: input.stepOutputs };
88
+ // Parse the instruction template ONCE (deterministic; resolution is a single
89
+ // pass per unit — substituted content is never re-scanned). Only nodes the
90
+ // frontend marked `templating: "expressions"` carry the `${{ … }}` grammar;
91
+ // classic linear markdown is opaque verbatim text.
92
+ let instructionSegments;
93
+ if (template.templating === "expressions") {
94
+ const parsedInstructions = parseTemplate(template.instructions);
95
+ if (!parsedInstructions.ok) {
96
+ return {
97
+ ok: false,
98
+ error: `Step "${plan.stepId}" instructions template failed to parse: ` +
99
+ parsedInstructions.errors.map((e) => e.message).join(" "),
100
+ };
101
+ }
102
+ instructionSegments = parsedInstructions.segments;
103
+ }
104
+ else {
105
+ instructionSegments = [{ kind: "literal", text: template.instructions }];
106
+ }
107
+ // Resolve fan-out items: `over` is a single whole-value `${{ … }}` reference
108
+ // naming its producer explicitly — no ambient key search.
109
+ let items;
110
+ if (root.kind === "map") {
111
+ const source = resolveWholeValue(root.over, scope);
112
+ if (!source.ok) {
113
+ return {
114
+ ok: false,
115
+ error: `Step "${plan.stepId}" fan-out "over" (${root.over}) failed to resolve: ${source.error.message}`,
116
+ };
117
+ }
118
+ if (!Array.isArray(source.value)) {
119
+ return {
120
+ ok: false,
121
+ error: `Step "${plan.stepId}" fan-out "over" (${root.over}) resolved to ${typeof source.value}, not an array.`,
122
+ };
123
+ }
124
+ items = source.value;
125
+ }
126
+ else {
127
+ items = [undefined];
128
+ }
129
+ // Content-derived unit identity: compute every id up front. Duplicate items
130
+ // collide on identity — an authoring error caught HERE, deterministically.
131
+ const isFanOut = root.kind === "map";
132
+ const unitIds = items.map((item) => unitIdFor(template.id, item, isFanOut));
133
+ if (isFanOut) {
134
+ const firstIndexByCanonical = new Map();
135
+ for (let i = 0; i < items.length; i++) {
136
+ const canonical = canonicalJson(items[i]) ?? "null";
137
+ const firstIndex = firstIndexByCanonical.get(canonical);
138
+ if (firstIndex !== undefined) {
139
+ return {
140
+ ok: false,
141
+ error: `Step "${plan.stepId}" fan-out list contains duplicate items (indices ${firstIndex} and ${i}: ` +
142
+ `${clip(canonical, 200)}). Content-derived unit identity requires distinct items — ` +
143
+ `deduplicate the list this workflow fans out over.`,
144
+ };
145
+ }
146
+ firstIndexByCanonical.set(canonical, i);
147
+ }
148
+ }
149
+ const gateLoop = input.gateLoop ?? 1;
150
+ const timeoutMs = template.timeoutMs === undefined ? DEFAULT_UNIT_TIMEOUT_MS : template.timeoutMs;
151
+ const units = items.map((item, index) => {
152
+ const unitId = unitIds[index];
153
+ // Gate loops (>= 2) journal under `<unitId>~l<loop>` so loop 1's rows are
154
+ // never clobbered; the content-derived identity (and the prompt's
155
+ // {{UNIT_ID}}) stays the base id.
156
+ const journalBaseId = gateLoop > 1 ? `${unitId}~l${gateLoop}` : unitId;
157
+ // Single-pass resolution of the pre-parsed template against this unit's
158
+ // scope. A resolution failure is deterministic authoring/data breakage.
159
+ const unitScope = isFanOut ? { ...scope, item, itemIndex: index } : scope;
160
+ const resolvedInstr = resolveTemplate(instructionSegments, unitScope);
161
+ let resolved;
162
+ if (!resolvedInstr.ok) {
163
+ resolved = {
164
+ ok: false,
165
+ error: `instructions failed to resolve: ${resolvedInstr.errors.map((e) => e.message).join(" ")}`,
166
+ };
167
+ }
168
+ else {
169
+ const prompt = buildUnitPrompt({
170
+ runId: input.runId,
171
+ stepId: plan.stepId,
172
+ unitId,
173
+ params: input.params,
174
+ ...(input.gateFeedback ? { gateFeedback: input.gateFeedback } : {}),
175
+ ...(template.schema ? { schema: template.schema } : {}),
176
+ instructions: resolvedInstr.text,
177
+ });
178
+ // Canonical dispatch-input envelope (reviewer finding #1). Every field
179
+ // here is a PLAN-FROZEN input that changes what the backend is actually
180
+ // asked to do, so a completed unit is reused ONLY when all of them match;
181
+ // a change to any of them re-dispatches. Key order is FIXED — it is the
182
+ // hash preimage (JSON.stringify preserves insertion order) — and shared
183
+ // by ALL surfaces, since this is the ONE place a unit's inputHash is
184
+ // computed (engine, brief, and report all call computeStepWorkList), so
185
+ // the byte-identical hash across surfaces is structural, not coincidental.
186
+ //
187
+ // Included beyond the R4 baseline (prompt/runner/model/schema): profile,
188
+ // resolved timeoutMs, the env asset ref NAMES, and isolation — each
189
+ // reaches dispatch (native-executor's UnitDispatchRequest) and a changed
190
+ // one yields a materially different call. `env` carries NAMES ONLY, never
191
+ // resolved values: hashing a resolved secret would leak it into a
192
+ // durable hash oracle and would spuriously re-dispatch on every secret
193
+ // rotation. `retry`/`onError` are DELIBERATELY excluded — they govern
194
+ // failed-unit re-dispatch and step-level failure reduction, not a
195
+ // COMPLETED unit's inputs/output, so a completed row stays valid across
196
+ // policy changes.
197
+ //
198
+ // Ambient config is DELIBERATELY excluded — the model-alias table, the
199
+ // resolved backend/connection, and the working directory (`ctx.workDir` /
200
+ // process.cwd()) are NOT plan-frozen. The frozen plan is the identity
201
+ // boundary (redesign addendum determinism bar #2): config drift under an
202
+ // in-flight run is out of scope by design.
203
+ const inputHash = createHash("sha256")
204
+ .update(JSON.stringify({
205
+ prompt,
206
+ runner: template.runner,
207
+ profile: template.profile ?? null,
208
+ model: template.model ?? null,
209
+ schema: template.schema ?? null,
210
+ timeoutMs,
211
+ env: template.env ?? null,
212
+ isolation: template.isolation ?? null,
213
+ }))
214
+ .digest("hex");
215
+ resolved = { ok: true, prompt, inputHash };
216
+ }
217
+ return {
218
+ unitId,
219
+ nodeId: template.id,
220
+ index,
221
+ item,
222
+ isFanOut,
223
+ journalBaseId,
224
+ runner: template.runner,
225
+ ...(template.profile ? { profile: template.profile } : {}),
226
+ ...(template.model ? { model: template.model } : {}),
227
+ timeoutMs,
228
+ ...(template.schema ? { schema: template.schema } : {}),
229
+ ...(template.env ? { env: template.env } : {}),
230
+ ...(template.retry ? { retry: template.retry } : {}),
231
+ onError: template.onError,
232
+ ...(template.isolation ? { isolation: template.isolation } : {}),
233
+ resolved,
234
+ };
235
+ });
236
+ const concurrency = root.kind === "map" ? root.concurrency : 1;
237
+ return {
238
+ ok: true,
239
+ list: { template, reducer, isFanOut, ...(concurrency !== undefined ? { concurrency } : {}), items, units },
240
+ };
241
+ }
242
+ /**
243
+ * Assemble the final prompt: engine preamble + resolved instructions
244
+ * (+ gate feedback on loop re-executions, + schema directive). Workflow-
245
+ * authored interpolation happened upstream via the expression module; only
246
+ * the ENGINE's own preamble placeholders are substituted here.
247
+ */
248
+ export function buildUnitPrompt(input) {
249
+ const { runId, stepId, unitId, params, gateFeedback, schema, instructions } = input;
250
+ // Function replacements throughout: a string replacement would interpret
251
+ // GetSubstitution patterns ($&, $$, $', $`) inside VALUES and silently
252
+ // corrupt the prompt (e.g. a param value containing "$&").
253
+ const preamble = unitPreambleTemplate
254
+ .replaceAll("{{RUN_ID}}", () => runId)
255
+ .replaceAll("{{STEP_ID}}", () => stepId)
256
+ .replaceAll("{{UNIT_ID}}", () => unitId)
257
+ .replaceAll("{{PARAMS_JSON}}", () => safeJson(params));
258
+ // Gate-loop feedback (R2 max_loops): the judge's rejection is appended so
259
+ // the re-executed unit can address it — and so the input hash changes,
260
+ // making the loop's re-dispatch natural instead of a durable-row reuse.
261
+ const gateBlock = gateFeedback
262
+ ? `\n\n## Completion-gate feedback (previous attempt rejected)\n` +
263
+ `A completion-criteria judge rejected this step's previous results. Address this feedback:\n` +
264
+ gateFeedback.feedback +
265
+ (gateFeedback.missing.length > 0
266
+ ? `\nUnmet criteria:\n${gateFeedback.missing.map((m) => `- ${m}`).join("\n")}`
267
+ : "")
268
+ : "";
269
+ const schemaDirective = schema
270
+ ? `\n\nRespond with ONLY a JSON value matching this JSON Schema (no prose, no code fences):\n${safeJson(schema)}`
271
+ : "";
272
+ return `${preamble}\n${instructions}${gateBlock}${schemaDirective}`;
273
+ }
274
+ /**
275
+ * Content-derived unit identity (module doc): `<node_id>:<hash12>` for a
276
+ * fan-out item, `<node_id>:solo` otherwise. The hash is over the item's
277
+ * canonical JSON (sorted keys — same canonicalization the vote reducer
278
+ * counts with), so identity survives list reordering/regeneration and is
279
+ * independent of item position. Retry attempts stack `~r<n>` on top.
280
+ */
281
+ export function unitIdFor(nodeId, item, isFanOut) {
282
+ if (!isFanOut)
283
+ return `${nodeId}:solo`;
284
+ const canonical = canonicalJson(item) ?? "null";
285
+ return `${nodeId}:${createHash("sha256").update(canonical).digest("hex").slice(0, 12)}`;
286
+ }
287
+ // ── Step outputs + reducers + typed artifacts ────────────────────────────────
288
+ /**
289
+ * The value `${{ steps.<id>.output }}` resolves to for ONE step, given that
290
+ * step's journaled evidence: an engine-executed step carries a promoted
291
+ * ARTIFACT under `evidence.output` (solo unit result/text, collect array, or
292
+ * vote winner); evidence without an `output` key (manually-completed steps) is
293
+ * exposed as-is.
294
+ */
295
+ export function projectStepOutput(evidence) {
296
+ return Object.hasOwn(evidence, "output") ? evidence.output : evidence;
297
+ }
298
+ /** Project the engine's evidence map into the expression scope's `stepOutputs`. */
299
+ export function stepOutputsFromEvidence(evidence) {
300
+ const outputs = {};
301
+ for (const [stepId, stepEvidence] of Object.entries(evidence)) {
302
+ if (stepEvidence !== undefined)
303
+ outputs[stepId] = projectStepOutput(stepEvidence);
304
+ }
305
+ return outputs;
306
+ }
307
+ /**
308
+ * Typed artifacts (addendum, R2): validate the promoted step artifact against
309
+ * `IrStepPlan.outputSchema`. Returns the step-failure summary (validation
310
+ * errors included) on mismatch, undefined when valid or when no schema is
311
+ * declared.
312
+ */
313
+ export function validateStepArtifact(plan, evidence) {
314
+ if (!plan.outputSchema)
315
+ return undefined;
316
+ const errors = validateJsonSchemaSubset(projectStepOutput(evidence), plan.outputSchema);
317
+ if (errors.length === 0)
318
+ return undefined;
319
+ return (`Step "${plan.stepId}" artifact failed validation against the step's declared output schema: ` +
320
+ `${errors.join("; ")}.`);
321
+ }
322
+ /**
323
+ * Build the summary the completion-criteria gate judges for a step (addendum
324
+ * R2, "typed artifacts, honest gates"): a one-line unit count followed by the
325
+ * promoted step artifact as canonical JSON, clipped at {@link GATE_ARTIFACT_CLIP}
326
+ * chars. This replaces machine-prose so the gate evaluates real results.
327
+ */
328
+ export function buildArtifactSummary(stepId, units, evidence) {
329
+ const failedCount = units.filter((u) => !u.ok).length;
330
+ const json = canonicalJson(projectStepOutput(evidence)) ?? "null";
331
+ return (`Step "${stepId}" executed ${units.length} unit(s) (${units.length - failedCount} succeeded, ${failedCount} failed). ` +
332
+ `Step artifact (canonical JSON${json.length > GATE_ARTIFACT_CLIP ? `, clipped at ${GATE_ARTIFACT_CLIP} chars` : ""}):\n` +
333
+ clip(json, GATE_ARTIFACT_CLIP));
334
+ }
335
+ /** A unit's contribution to the step artifact: structured result, else text, else null (failures). */
336
+ function unitOutputValue(unit) {
337
+ if (!unit.ok)
338
+ return null;
339
+ if (unit.result !== undefined)
340
+ return unit.result;
341
+ return unit.text ?? null;
342
+ }
343
+ export function buildEvidence(units, reducer, isFanOut) {
344
+ // Per-unit evidence is the DURABLE, surface-independent projection the two
345
+ // driver surfaces (engine + brief/report) must agree on byte-for-byte (R4
346
+ // conformance, "identical unit graph"). It therefore carries ONLY fields both
347
+ // surfaces can reproduce from the journal:
348
+ // - a SUCCESS keeps its promoted contribution (structured `result` or clipped
349
+ // `text`) — the report path rehydrates exactly these from the unit row;
350
+ // - a FAILURE keeps only its `failureReason` (the durable, journaled failure
351
+ // vocabulary). The engine's in-memory dispatch diagnostic (`error`) and any
352
+ // residual `text` on a failed unit are NOT persisted here: a driver-reported
353
+ // failure carries neither, so persisting them on the engine surface alone
354
+ // would diverge the durable graph. The full raw text/reason still lives on
355
+ // the unit row for engine-side diagnostics; this is the shared graph.
356
+ const collected = units.map((u) => u.ok
357
+ ? {
358
+ unitId: u.unitId,
359
+ ok: true,
360
+ ...(u.result !== undefined ? { result: u.result } : {}),
361
+ ...(u.text !== undefined ? { text: clip(u.text, EVIDENCE_TEXT_CLIP) } : {}),
362
+ }
363
+ : {
364
+ unitId: u.unitId,
365
+ ok: false,
366
+ ...(u.failureReason ? { failureReason: u.failureReason } : {}),
367
+ });
368
+ const evidence = { units: collected, itemCount: units.length };
369
+ // Promoted step artifact (`evidence.output`) — what `${{ steps.<id>.output }}`
370
+ // resolves to (see projectStepOutput). Values are UNCLIPPED.
371
+ if (reducer === "vote") {
372
+ evidence.output = null;
373
+ }
374
+ else {
375
+ evidence.output = isFanOut ? units.map(unitOutputValue) : unitOutputValue(units[0]);
376
+ }
377
+ if (reducer === "vote") {
378
+ const counts = new Map();
379
+ for (const unit of units) {
380
+ if (!unit.ok)
381
+ continue;
382
+ const value = unit.result !== undefined ? unit.result : unit.text;
383
+ const key = canonicalJson(value);
384
+ const entry = counts.get(key);
385
+ if (entry)
386
+ entry.count++;
387
+ else
388
+ counts.set(key, { value, count: 1 });
389
+ }
390
+ const ranked = [...counts.values()].sort((a, b) => b.count - a.count);
391
+ if (ranked.length === 0) {
392
+ evidence.voteError = "Vote reducer had no successful unit results to count.";
393
+ }
394
+ else if (ranked.length > 1 && ranked[0].count === ranked[1].count) {
395
+ evidence.voteError = `Vote reducer tied at ${ranked[0].count} vote(s) — no majority.`;
396
+ }
397
+ else {
398
+ evidence.vote = { winner: ranked[0].value, votes: ranked[0].count, total: units.length };
399
+ evidence.output = ranked[0].value;
400
+ }
401
+ }
402
+ return evidence;
403
+ }
404
+ /**
405
+ * Reduce a step's terminal unit outcomes into the promoted artifact + step
406
+ * verdict — the shared semantics between native dispatch and the report path.
407
+ * Applies the `on_error` policy (`fail` vs `continue`), the reducer (via
408
+ * {@link buildEvidence}), the vote-tie failure, and the typed-artifact schema
409
+ * validation (fail-fast, errors in the summary, `artifactSchemaFailure` marker).
410
+ * Callers own dispatch-specific concerns (replay-divergence, budget) BEFORE
411
+ * calling this; those never occur on the report path (units are journaled).
412
+ */
413
+ export function reduceStepOutcomes(plan, reducer, isFanOut, onError, units) {
414
+ const failed = units.filter((u) => !u.ok);
415
+ const evidence = buildEvidence(units, reducer, isFanOut);
416
+ const reducerNote = typeof evidence.voteError === "string" ? ` ${evidence.voteError}` : "";
417
+ const tolerateFailures = onError === "continue";
418
+ let ok = (tolerateFailures || failed.length === 0) && !evidence.voteError;
419
+ let summary = `Executed ${units.length} unit(s) for step "${plan.stepId}" via workflow orchestration: ` +
420
+ `${units.length - failed.length} succeeded, ${failed.length} failed.` +
421
+ (failed.length > 0
422
+ ? ` Failures${tolerateFailures ? " (recorded, on_error: continue)" : ""}: ${failed
423
+ .map((u) => `${u.unitId} (${u.failureReason ?? "error"})`)
424
+ .join(", ")}.`
425
+ : "") +
426
+ reducerNote;
427
+ let artifactSchemaFailure = false;
428
+ if (ok) {
429
+ const schemaFailure = validateStepArtifact(plan, evidence);
430
+ if (schemaFailure !== undefined) {
431
+ ok = false;
432
+ summary = schemaFailure;
433
+ artifactSchemaFailure = true;
434
+ }
435
+ }
436
+ return { ok, units, evidence, summary, ...(artifactSchemaFailure ? { artifactSchemaFailure: true } : {}) };
437
+ }
438
+ /**
439
+ * The reduced outcome of a step whose fan-out list resolved to EMPTY (`over: []`
440
+ * or a producer that yielded `[]`): no units are dispatched, so the promoted
441
+ * artifact is the degenerate empty value — the empty array for a `collect`
442
+ * reducer, `null` for `vote` (references into a missing winner fail loudly at
443
+ * resolution rather than silently reading the envelope). Even the degenerate
444
+ * artifact must honor the step's declared `outputSchema` before it can complete.
445
+ *
446
+ * Shared by native dispatch (`executeStepPlan`'s `items.length === 0` branch)
447
+ * and the R3 driver protocol (`report` auto-completes an empty step the spine
448
+ * reaches, since no `report --unit` can ever advance a zero-unit step) so both
449
+ * surfaces promote the SAME artifact and apply the SAME schema verdict — the
450
+ * anti-drift guarantee. Deliberately does NOT run the reducer/vote-tie logic:
451
+ * an empty step has no successful results to count, and a vote-tie "failure"
452
+ * would diverge from the engine's long-standing empty-list semantics.
453
+ */
454
+ export function reduceEmptyStep(plan, reducer) {
455
+ const evidence = { units: [], itemCount: 0, output: reducer === "collect" ? [] : null };
456
+ const schemaFailure = validateStepArtifact(plan, evidence);
457
+ return {
458
+ ok: schemaFailure === undefined,
459
+ units: [],
460
+ evidence,
461
+ summary: schemaFailure ?? `Step "${plan.stepId}" fan-out list was empty — no units dispatched.`,
462
+ ...(schemaFailure !== undefined ? { artifactSchemaFailure: true } : {}),
463
+ };
464
+ }
465
+ /**
466
+ * Rehydrate a journaled unit row into a {@link UnitOutcome}. Shared by the
467
+ * executor's durable-row reuse (`native-executor.ts`, completed rows only) and
468
+ * the R3 report path (which reduces completed AND failed rows replayed from the
469
+ * journal). A completed row's text unit journals its output as a JSON string; a
470
+ * schema unit journals the validated structure. A failed row carries its
471
+ * `failure_reason`; any journaled text is surfaced too.
472
+ */
473
+ export function unitOutcomeFromRow(unitId, row, hasSchema) {
474
+ let parsed;
475
+ try {
476
+ parsed = row.result_json === null ? undefined : JSON.parse(row.result_json);
477
+ }
478
+ catch {
479
+ parsed = undefined;
480
+ }
481
+ if (row.status === "completed") {
482
+ return {
483
+ unitId,
484
+ ok: true,
485
+ ...(hasSchema
486
+ ? { result: parsed }
487
+ : typeof parsed === "string"
488
+ ? { text: parsed }
489
+ : parsed !== undefined
490
+ ? { result: parsed }
491
+ : {}),
492
+ ...(row.tokens !== null ? { tokens: row.tokens } : {}),
493
+ ...(row.session_id !== null && row.session_id !== undefined ? { sessionId: row.session_id } : {}),
494
+ };
495
+ }
496
+ return {
497
+ unitId,
498
+ ok: false,
499
+ failureReason: row.failure_reason ?? "reported_failure",
500
+ ...(typeof parsed === "string" ? { text: parsed } : {}),
501
+ ...(row.tokens !== null ? { tokens: row.tokens } : {}),
502
+ };
503
+ }
504
+ /**
505
+ * Select the journaled attempt row that determines a unit's TERMINAL outcome on
506
+ * a REPLAY surface — the engine's durable-row reuse AND the harness-neutral
507
+ * brief/report driver protocol — given the run's dispatch rows indexed by
508
+ * unit_id. This is the ONE place all surfaces resolve "which journaled row IS
509
+ * this unit's outcome," so they cannot drift from each other or from the engine.
510
+ *
511
+ * It mirrors the executor's {@link classifyUnitReuse} attempt scan
512
+ * (native-executor.ts): among the base attempt and its `~r<n>` retries — all
513
+ * stacked on `journalBaseId`, which already carries the active `~l<loop>` gate
514
+ * suffix — the FIRST completed attempt is the effective result. So a unit whose
515
+ * base attempt FAILED but whose later retry COMPLETED reduces as COMPLETED,
516
+ * exactly like an engine resume reusing the `~r1` row (Codex round-3 finding C);
517
+ * reading only the base row would reduce it as failed and diverge the two
518
+ * surfaces. With no completed attempt the HIGHEST journaled attempt stands (a
519
+ * terminal failure, or a still-running row); no attempt row at all ⇒ `undefined`
520
+ * (the unit is still outstanding).
521
+ */
522
+ export function selectUnitAttemptRow(workUnit, dispatchRows) {
523
+ const base = workUnit.journalBaseId;
524
+ const maxAttempts = 1 + Math.max(0, workUnit.retry?.max ?? 0);
525
+ let fallback;
526
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
527
+ const row = dispatchRows.get(attempt === 0 ? base : `${base}~r${attempt}`);
528
+ if (!row)
529
+ continue;
530
+ if (row.status === "completed")
531
+ return row;
532
+ fallback = row; // remember the highest journaled (non-completed) attempt
533
+ }
534
+ return fallback;
535
+ }
536
+ /**
537
+ * Is a FAILED unit still RETRY-ELIGIBLE — i.e. NOT terminal, because a driver
538
+ * could still re-run it via the `--rerun` form (the engine's automatic
539
+ * `<baseId>~r<n>` retry)? A unit whose declared `retry.on` matches the recorded
540
+ * failure reason AND whose attempt budget (`1 + retry.max`) is not yet spent can
541
+ * still be re-run. No `retry`, an off-list reason, or an exhausted attempt budget
542
+ * ⇒ the failure IS terminal. Shared by the report fail-fast decision, the
543
+ * `--settle` refusal, and `brief`'s fully-terminal detection so all three agree
544
+ * on when a failed unit is genuinely done vs. still re-runnable. The normalized
545
+ * failure reason is compared against `retry.on` directly (a canonical taxonomy
546
+ * reason is stored verbatim; an `external:*` reason is by construction outside
547
+ * the taxonomy `retry.on` lists).
548
+ */
549
+ export function isRetryEligibleFailure(workUnit, row, failureReason) {
550
+ const retry = workUnit.retry;
551
+ if (!retry || failureReason === null || !retry.on.includes(failureReason))
552
+ return false;
553
+ const attempts = row?.attempts ?? 1;
554
+ return attempts < 1 + Math.max(0, retry.max);
555
+ }
556
+ /**
557
+ * Does a resolvable unit still need a driver to execute + report it (or re-run
558
+ * it)? True for a unit with no terminal row (pending), a still-`running` row (a
559
+ * live/stale claim another driver holds), or a FAILED row that is still
560
+ * retry-eligible. False for a COMPLETED row, a terminal non-retry-eligible
561
+ * FAILURE, or an UNRESOLVABLE unit (the engine's immediate `expression_error` —
562
+ * never reportable). The best terminal attempt (base + `~r<n>` retries) is the
563
+ * one consulted, the SAME reuse the engine and reducer apply.
564
+ */
565
+ export function unitStillNeedsReport(workUnit, dispatchRows) {
566
+ if (!workUnit.resolved.ok)
567
+ return false;
568
+ const row = selectUnitAttemptRow(workUnit, dispatchRows);
569
+ if (!row)
570
+ return true; // no journal row → pending
571
+ if (row.status === "running")
572
+ return true; // a live/stale claim is still in flight
573
+ if (row.status === "failed")
574
+ return isRetryEligibleFailure(workUnit, row, row.failure_reason);
575
+ return false; // completed (or a non-retry-eligible failure) → terminal
576
+ }
577
+ /**
578
+ * Is the active step's work-list FULLY TERMINAL — every resolvable unit run to a
579
+ * terminal (done, or non-retry-eligible failed) state with nothing left to
580
+ * execute or per-unit report — yet still needing finalization? This is the
581
+ * driver-recovery state after a required-gate block is resumed, or a crash
582
+ * between the last unit write and the step's completion (owner manual-validation
583
+ * finding 3): the work-list is done but the step never advanced. `brief`
584
+ * surfaces it with a single `report --settle` command and `--settle` runs the
585
+ * shared completion path for it. A list with ANY outstanding unit (pending,
586
+ * in-flight, or retry-eligible failed) is NOT fully terminal — the driver
587
+ * `report --unit`s those. A route-only / empty / all-unresolvable list (no
588
+ * resolvable units) is a DIFFERENT non-dispatching state, handled separately.
589
+ */
590
+ export function isWorkListFullyTerminal(workList, dispatchRows) {
591
+ if (!workList.units.some((u) => u.resolved.ok))
592
+ return false;
593
+ return workList.units.every((u) => !unitStillNeedsReport(u, dispatchRows));
594
+ }
595
+ /** Stable stringify (sorted object keys, recursively) so equal values vote together. */
596
+ export function canonicalJson(value) {
597
+ return JSON.stringify(sortKeys(value));
598
+ }
599
+ function sortKeys(value) {
600
+ if (Array.isArray(value))
601
+ return value.map(sortKeys);
602
+ if (value && typeof value === "object") {
603
+ return Object.fromEntries(Object.entries(value)
604
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
605
+ .map(([k, v]) => [k, sortKeys(v)]));
606
+ }
607
+ return value;
608
+ }
609
+ // ── Gate-feedback recovery (PURE) ────────────────────────────────────────────
610
+ //
611
+ // A gate rejection is journaled as `<stepId>.gate:l<loop>` with result_json
612
+ // `{ complete: false, missing, feedback }` (see journalGateEvaluationFinish).
613
+ // The feedback stored there is BYTE-IDENTICAL to what the engine threads into
614
+ // the next loop's prompts — both are the same `rejection.feedback`/`.missing`.
615
+ // `brief` recovers it from the journal so its loop-N work-list matches the
616
+ // engine's (redesign addendum R3, task item 2). `native-executor.test.ts`
617
+ // asserts the round-trip identity.
618
+ /**
619
+ * `phase` marker stamped on gate-evaluation unit rows. Step ids cannot contain
620
+ * dots (`PROGRAM_STEP_ID_PATTERN`), so a step can never be NAMED `x.gate` and
621
+ * the synthetic `<stepId>.gate` node id is collision-free against user step
622
+ * ids. The phase column is nonetheless the discriminator we key on — an
623
+ * explicit marker, not a `node_id` suffix match, so recovery stays robust even
624
+ * if the id scheme evolves. Dispatch rows always journal `phase: null`.
625
+ */
626
+ export const GATE_EVALUATION_PHASE = "gate";
627
+ /** The unit id of a step's gate-evaluation row for a given 1-based loop. */
628
+ export function gateUnitId(stepId, loop) {
629
+ return `${stepId}.gate:l${loop}`;
630
+ }
631
+ /**
632
+ * The gate loop the engine is about to (re-)run for an ACTIVE step, derived
633
+ * purely from the journal: one past the highest journaled loop that REJECTED
634
+ * (`complete: false`). No rejected gate rows ⇒ loop 1 (the first execution).
635
+ * A passed gate would have advanced the spine, so an active step never has a
636
+ * `complete: true` row as its latest gate evaluation.
637
+ *
638
+ * Reviewer #17: a gate row that EXISTS but cannot be parsed (or carries an
639
+ * invalid verdict shape) is CORRUPTION — {@link parseGateVerdict} throws loudly
640
+ * rather than letting `gateRowRejected` swallow the parse error, which would
641
+ * silently drop the loop back to 1 and re-dispatch work whose gate outcome is
642
+ * unknown.
643
+ */
644
+ export function activeGateLoop(rows, stepId) {
645
+ let maxRejectedLoop = 0;
646
+ for (const row of rows) {
647
+ if (row.phase !== GATE_EVALUATION_PHASE || row.step_id !== stepId)
648
+ continue;
649
+ const loop = gateLoopOf(row.unit_id, stepId);
650
+ if (loop === undefined)
651
+ continue;
652
+ // Throws loudly on a corrupt/malformed gate row — never treated as absent.
653
+ if (parseGateVerdict(row).kind === "rejected" && loop > maxRejectedLoop)
654
+ maxRejectedLoop = loop;
655
+ }
656
+ return maxRejectedLoop + 1;
657
+ }
658
+ /**
659
+ * Recover the gate feedback the engine threads into `loop`'s unit prompts: the
660
+ * `{ feedback, missing }` journaled by the previous loop's rejection
661
+ * (`<stepId>.gate:l<loop-1>`). Loop 1 (or a missing/passed/errored previous row)
662
+ * has no feedback. Pure — the journal rows are passed in.
663
+ *
664
+ * Reviewer #17: a PRESENT previous gate row that cannot be parsed fails LOUDLY
665
+ * (via {@link parseGateVerdict}) instead of returning undefined — a corrupt row
666
+ * must not make an in-loop step look like loop 1 with no recovered feedback.
667
+ */
668
+ export function recoverGateFeedback(rows, stepId, loop) {
669
+ if (loop <= 1)
670
+ return undefined;
671
+ const prevId = gateUnitId(stepId, loop - 1);
672
+ const prev = rows.find((r) => r.unit_id === prevId && r.phase === GATE_EVALUATION_PHASE);
673
+ if (!prev)
674
+ return undefined;
675
+ const verdict = parseGateVerdict(prev);
676
+ return verdict.kind === "rejected" ? { feedback: verdict.feedback, missing: verdict.missing } : undefined;
677
+ }
678
+ /** The 1-based loop encoded in a `<stepId>.gate:l<n>` unit id, if well-formed. */
679
+ function gateLoopOf(unitId, stepId) {
680
+ const prefix = `${stepId}.gate:l`;
681
+ if (!unitId.startsWith(prefix))
682
+ return undefined;
683
+ const n = Number.parseInt(unitId.slice(prefix.length), 10);
684
+ return Number.isInteger(n) && n >= 1 ? n : undefined;
685
+ }
686
+ /**
687
+ * Classify a gate-evaluation row's journaled verdict, failing LOUDLY on a
688
+ * corrupt one (reviewer #17). A NULL `result_json` is the LEGITIMATE
689
+ * errored-judge / in-flight shape (`journalGateEvaluationFinish` writes null for
690
+ * an errored judge, and a `running` row has no verdict yet) and classifies as
691
+ * `empty`. But a PRESENT `result_json` that does not parse as JSON, or parses to
692
+ * anything other than an object with a boolean `complete` field, is corruption —
693
+ * a truncated or hand-edited row — and MUST NOT be silently treated as absent
694
+ * (which would reset an active step's gate loop to 1 and re-dispatch work whose
695
+ * completion outcome is unknown). We refuse to guess.
696
+ */
697
+ function parseGateVerdict(row) {
698
+ if (row.result_json === null)
699
+ return { kind: "empty" };
700
+ let verdict;
701
+ try {
702
+ verdict = JSON.parse(row.result_json);
703
+ }
704
+ catch {
705
+ throw new UsageError(gateCorruptionMessage(row, "its result_json is not valid JSON"));
706
+ }
707
+ if (typeof verdict !== "object" || verdict === null || Array.isArray(verdict)) {
708
+ throw new UsageError(gateCorruptionMessage(row, "its result_json is not a JSON object"));
709
+ }
710
+ const v = verdict;
711
+ if (typeof v.complete !== "boolean") {
712
+ throw new UsageError(gateCorruptionMessage(row, 'its verdict has no boolean "complete" field'));
713
+ }
714
+ if (v.complete === false) {
715
+ const feedback = typeof v.feedback === "string" ? v.feedback : "";
716
+ const missing = Array.isArray(v.missing) ? v.missing.filter((m) => typeof m === "string") : [];
717
+ return { kind: "rejected", missing, feedback };
718
+ }
719
+ return { kind: "passed" };
720
+ }
721
+ function gateCorruptionMessage(row, why) {
722
+ return (`Workflow run ${row.run_id} has a corrupt gate-evaluation row "${row.unit_id}" for step "${row.step_id}" — ${why}. ` +
723
+ `A gate verdict must be {"complete": true|false, …}; refusing to treat a malformed gate row as absent, which would ` +
724
+ `silently restart the step's gate loop and re-dispatch work whose completion outcome is unknown. Fix or remove the ` +
725
+ `journaled row, then resume the run.`);
726
+ }
727
+ /** Insert the gate-evaluation unit row (running) just before the judge runs. */
728
+ export async function journalGateEvaluationStart(gate) {
729
+ const unitId = gateUnitId(gate.stepId, gate.loop);
730
+ await enqueueUnitWrite(() => withWorkflowRunsRepo((repo) => repo.insertUnit({
731
+ runId: gate.runId,
732
+ unitId,
733
+ stepId: gate.stepId,
734
+ nodeId: `${gate.stepId}.gate`,
735
+ parentUnitId: null,
736
+ // Marks the row as a judge call, NOT a dispatch: the budget/lifetime
737
+ // seed in `driveRun` skips these so resume accounting matches live.
738
+ phase: GATE_EVALUATION_PHASE,
739
+ runner: "llm",
740
+ model: null,
741
+ inputHash: null,
742
+ startedAt: new Date().toISOString(),
743
+ })));
744
+ appendEvent({
745
+ eventType: "workflow_unit_started",
746
+ ref: gate.workflowRef,
747
+ metadata: { runId: gate.runId, stepId: gate.stepId, unitId },
748
+ });
749
+ }
750
+ /**
751
+ * Finish the gate-evaluation unit row with the verdict as observed from the
752
+ * completion outcome: a rejection journals `{ complete: false, missing,
753
+ * feedback }`; a pass journals `{ complete: true, missing: [] }`; a judge that
754
+ * threw (or, on a required gate, returned an unparseable verdict) journals a
755
+ * failed row with a NULL verdict. A NON-required errored gate then fails OPEN
756
+ * inside `validateStepSummary`; a REQUIRED errored gate BLOCKS the step
757
+ * (`finalizeExecutedStep`, Codex round-3 finding A).
758
+ */
759
+ export async function journalGateEvaluationFinish(gate, errored, rejection) {
760
+ const unitId = gateUnitId(gate.stepId, gate.loop);
761
+ const verdict = errored
762
+ ? null
763
+ : rejection
764
+ ? { complete: false, missing: rejection.missing, feedback: rejection.feedback }
765
+ : { complete: true, missing: [] };
766
+ const status = errored ? "failed" : "completed";
767
+ await enqueueUnitWrite(() => withWorkflowRunsRepo((repo) => repo.finishUnit({
768
+ runId: gate.runId,
769
+ unitId,
770
+ status,
771
+ resultJson: verdict ? JSON.stringify(verdict) : null,
772
+ tokens: null,
773
+ failureReason: errored ? "dispatch_error" : null,
774
+ finishedAt: new Date().toISOString(),
775
+ })));
776
+ appendEvent({
777
+ eventType: "workflow_unit_finished",
778
+ ref: gate.workflowRef,
779
+ metadata: { runId: gate.runId, stepId: gate.stepId, unitId, status },
780
+ });
781
+ }
782
+ /**
783
+ * Resolve a route's input (a single whole-value `${{ … }}` reference) and pick
784
+ * the branch. No ambient key search. Only primitive values route; the
785
+ * comparison is exact string equality against the declared `when:` matches.
786
+ */
787
+ export function evaluateRoute(route, scope) {
788
+ const resolved = resolveWholeValue(route.input, scope);
789
+ if (!resolved.ok) {
790
+ return { ok: false, error: `route input ${route.input} failed to resolve: ${resolved.error.message}` };
791
+ }
792
+ const value = resolved.value;
793
+ if (typeof value === "object" && value !== null) {
794
+ return {
795
+ ok: false,
796
+ error: `route input ${route.input} resolved to a non-primitive value; branches match on strings/numbers/booleans.`,
797
+ };
798
+ }
799
+ const valueString = typeof value === "string" ? value : String(value);
800
+ // Own-property check: `when` is author-controlled, and a value such as
801
+ // "constructor" must not resolve through Object.prototype.
802
+ const selected = Object.hasOwn(route.when, valueString) ? route.when[valueString] : route.defaultStepId;
803
+ if (!selected) {
804
+ return {
805
+ ok: false,
806
+ error: `value "${valueString}" matched no "when:" branch and the route declares no default.`,
807
+ };
808
+ }
809
+ return { ok: true, value: valueString, selected };
810
+ }
811
+ /**
812
+ * Cascade a SKIPPED router: it never evaluated its route, so every declared
813
+ * target (branches + default) is marked skip-on-reach unless an earlier router
814
+ * already claimed it. Shared by the live skip path and the journal replay.
815
+ */
816
+ export function cascadeSkippedRouter(route, routerId, routeUnselected) {
817
+ const targets = [...Object.values(route.when), ...(route.defaultStepId ? [route.defaultStepId] : [])];
818
+ for (const target of targets) {
819
+ if (!routeUnselected.has(target)) {
820
+ routeUnselected.set(target, { router: routerId, selected: null });
821
+ }
822
+ }
823
+ }
824
+ /**
825
+ * Record one router's decision in the skip bookkeeping: the selected target is
826
+ * protected, every other declared target (branches + default) is marked
827
+ * skip-on-reach unless an earlier router already claimed it. Shared by the live
828
+ * evaluation path and the journal replay.
829
+ */
830
+ export function applyRouteDecision(route, routerId, selected, routeSelected, routeUnselected) {
831
+ routeSelected.add(selected);
832
+ const targets = [...Object.values(route.when), ...(route.defaultStepId ? [route.defaultStepId] : [])];
833
+ for (const target of targets) {
834
+ if (target !== selected && !routeUnselected.has(target)) {
835
+ routeUnselected.set(target, { router: routerId, selected });
836
+ }
837
+ }
838
+ }
839
+ /**
840
+ * The `stepOutputs` scope a route resolves against: every prior step's recorded
841
+ * evidence plus the just-finished step's fresh evidence — each projected
842
+ * through {@link projectStepOutput}. Same projection as unit templates, so the
843
+ * two scopes cannot drift.
844
+ */
845
+ export function routeStepOutputs(evidence, currentStepId, currentEvidence) {
846
+ const outputs = {};
847
+ for (const [stepId, stepEvidence] of Object.entries(evidence)) {
848
+ if (stepEvidence !== undefined)
849
+ outputs[stepId] = projectStepOutput(stepEvidence);
850
+ }
851
+ outputs[currentStepId] = projectStepOutput(currentEvidence);
852
+ return outputs;
853
+ }
854
+ /** The `selected` target journaled on a route step's evidence, if well-formed. */
855
+ function journaledRouteSelection(evidence) {
856
+ const route = evidence?.route;
857
+ if (typeof route !== "object" || route === null || Array.isArray(route))
858
+ return undefined;
859
+ const selected = route.selected;
860
+ return typeof selected === "string" && selected !== "" ? selected : undefined;
861
+ }
862
+ /** The set of steps a route may legally select: its `when` branches + default. */
863
+ function routeTargets(route) {
864
+ return new Set([...Object.values(route.when), ...(route.defaultStepId ? [route.defaultStepId] : [])]);
865
+ }
866
+ /**
867
+ * Reviewer #7: a journaled route decision must name a target the route actually
868
+ * DECLARES (`when` branch or `default`). Corrupted or hand-edited evidence can
869
+ * otherwise mark a non-existent step as `selected` — which unselects and skips
870
+ * every REAL branch target, silently steering the run down a phantom branch.
871
+ * `evaluateRoute` can only ever produce a declared target, so a stored value
872
+ * outside that set is provably tampered evidence: fail loudly rather than seed a
873
+ * bogus skip set.
874
+ */
875
+ function assertRouteTargetDeclared(route, stepId, selected, runId) {
876
+ const targets = routeTargets(route);
877
+ if (!targets.has(selected)) {
878
+ throw new UsageError(`Workflow run ${runId} has a completed route step "${stepId}" whose journaled route decision selected ` +
879
+ `"${selected}", which is not a declared branch or default target of the route (valid targets: ` +
880
+ `${[...targets].join(", ") || "(none)"}). The route evidence was corrupted or manually edited — refusing to ` +
881
+ `apply a bogus route decision that would skip the real branch targets. Start a new run.`);
882
+ }
883
+ }
884
+ /**
885
+ * Validate every COMPLETED route step's journaled selection against its declared
886
+ * targets (reviewer #7). Read-only: it throws on a PRESENT-but-invalid selection
887
+ * and is silent on an absent one, so it never false-positives on a healthy run —
888
+ * making it safe to call from the read-only `brief` surface as well as the
889
+ * resume/report surfaces that already re-apply the decisions.
890
+ */
891
+ export function assertJournaledRouteSelectionsValid(plan, state) {
892
+ for (const stepPlan of plan.steps) {
893
+ if (!stepPlan.route)
894
+ continue;
895
+ const stepState = state.workflow.steps.find((s) => s.id === stepPlan.stepId);
896
+ if (!stepState || stepState.status !== "completed")
897
+ continue;
898
+ const selected = journaledRouteSelection(stepState.evidence);
899
+ if (selected !== undefined) {
900
+ assertRouteTargetDeclared(stepPlan.route, stepPlan.stepId, selected, state.run.id);
901
+ }
902
+ }
903
+ }
904
+ /**
905
+ * Replay journaled route decisions into the skip bookkeeping (resume path).
906
+ * For every COMPLETED route step of the frozen plan, in spine order: the
907
+ * journaled decision wins; else a re-derivation from the frozen plan +
908
+ * journaled evidence; else fail loudly. A SKIPPED route step cascades its
909
+ * targets into the skip set exactly as on the live path.
910
+ */
911
+ export function seedJournaledRouteDecisions(plan, state, routeSelected, routeUnselected) {
912
+ const evidence = {};
913
+ for (const s of state.workflow.steps)
914
+ evidence[s.id] = s.evidence;
915
+ for (const stepPlan of plan.steps) {
916
+ if (!stepPlan.route)
917
+ continue;
918
+ const stepState = state.workflow.steps.find((s) => s.id === stepPlan.stepId);
919
+ if (!stepState)
920
+ continue;
921
+ if (stepState.status === "skipped") {
922
+ cascadeSkippedRouter(stepPlan.route, stepPlan.stepId, routeUnselected);
923
+ continue;
924
+ }
925
+ if (stepState.status !== "completed")
926
+ continue;
927
+ let selected = journaledRouteSelection(stepState.evidence);
928
+ if (selected !== undefined) {
929
+ // Reviewer #7: a stored decision must name a declared target — a bogus one
930
+ // (tampered/hand-edited evidence) fails loudly rather than seeding a skip
931
+ // set that buries the real branches.
932
+ assertRouteTargetDeclared(stepPlan.route, stepPlan.stepId, selected, state.run.id);
933
+ }
934
+ if (selected === undefined) {
935
+ const scope = {
936
+ params: state.run.params ?? {},
937
+ stepOutputs: routeStepOutputs(evidence, stepPlan.stepId, stepState.evidence ?? {}),
938
+ };
939
+ const decision = evaluateRoute(stepPlan.route, scope);
940
+ if (decision.ok)
941
+ selected = decision.selected;
942
+ }
943
+ if (selected === undefined) {
944
+ throw new UsageError(`Workflow run ${state.run.id} has a completed route step "${stepPlan.stepId}" with no journaled route ` +
945
+ `decision, and the decision cannot be re-derived from the journaled evidence. Refusing to guess which ` +
946
+ `branch was selected — advance the remaining steps manually with \`akm workflow complete\`.`);
947
+ }
948
+ applyRouteDecision(stepPlan.route, stepPlan.stepId, selected, routeSelected, routeUnselected);
949
+ }
950
+ }
951
+ /**
952
+ * Perform ONE completion attempt for an executed step:
953
+ *
954
+ * - a hard unit failure completes the step `failed` (a retryable typed-artifact
955
+ * mismatch with loops remaining returns `retry` WITHOUT journaling a gate row
956
+ * — no judge ran, exactly like the engine);
957
+ * - a route decision is evaluated against params + prior/fresh step outputs; an
958
+ * unroutable value fails the step; a valid decision is journaled on the
959
+ * step evidence and applied to the skip bookkeeping;
960
+ * - the completion gate judges a summary BUILT FROM the promoted artifact (when
961
+ * the step declares criteria), journaled as a `<stepId>.gate:l<loop>` unit
962
+ * row; a rejection with loops remaining returns `retry` (feedback threaded
963
+ * into the next loop), a rejection with none returns `gate-exhausted`, a pass
964
+ * returns `advanced`.
965
+ *
966
+ * Every DB advance goes through {@link completeWorkflowStep} — the gate spine is
967
+ * never bypassed. Behavior is byte-identical to the engine's former inline loop
968
+ * body (its tests prove it).
969
+ */
970
+ export async function finalizeExecutedStep(input) {
971
+ const { runId, workflowRef, stepId, stepPlan, completionCriteria, gateLoop, loopsRemaining, result } = input;
972
+ const lease = input.leaseHolder !== undefined ? { leaseHolder: input.leaseHolder } : {};
973
+ if (!result.ok) {
974
+ // Typed-artifact mismatch with loop budget left: regenerate-with-errors
975
+ // (the validation errors become the next loop's feedback). No judge ran, so
976
+ // no gate row is journaled for this attempt.
977
+ if (result.artifactSchemaFailure && loopsRemaining) {
978
+ return { kind: "retry", gateFeedback: { feedback: result.summary, missing: [] } };
979
+ }
980
+ await completeWorkflowStep({
981
+ runId,
982
+ stepId,
983
+ status: "failed",
984
+ notes: result.summary,
985
+ evidence: result.evidence,
986
+ ...lease,
987
+ });
988
+ return { kind: "failed", summary: result.summary };
989
+ }
990
+ // Resolve the completion-criteria judge ONCE (reused by the gate below). A
991
+ // `null` result means NO judge is available: `undefined` builds the default
992
+ // from config (null when offline / misconfigured), and an explicit `null`
993
+ // caller override is offline by construction.
994
+ const innerJudge = input.summaryJudge === undefined ? buildDefaultSummaryJudge() : input.summaryJudge;
995
+ // Reviewer #18: a REQUIRED completion gate must actually be judged. When the
996
+ // gate carries criteria but no judge is available, `validateStepSummary` would
997
+ // fail OPEN and silently pass the gate — exactly the offline/misconfigured
998
+ // bypass a required gate exists to prevent. BLOCK the step instead (a human
999
+ // resolves it via the documented manual path), rather than advance the spine
1000
+ // on an unjudged gate. `gate.required` rides the frozen plan (both surfaces);
1001
+ // `requireGates` is the engine's run-wide `--require-gates` override. Checked
1002
+ // BEFORE route evaluation so a blocked step journals no route decision.
1003
+ const gateRequired = stepPlan.gate.required === true || input.requireGates === true;
1004
+ if (gateRequired && completionCriteria.length > 0 && innerJudge === null) {
1005
+ const notes = `Step "${stepId}" has a REQUIRED completion gate but no summary-validation judge is available ` +
1006
+ `(no LLM is configured, or default LLM resolution failed). A required gate must be judged — refusing to fail ` +
1007
+ `open and silently pass it. The step is BLOCKED: configure an LLM, then \`akm workflow resume ${runId}\` to ` +
1008
+ `re-evaluate the gate, or advance the step manually with \`akm workflow complete\`.`;
1009
+ await completeWorkflowStep({ runId, stepId, status: "blocked", notes, evidence: result.evidence, ...lease });
1010
+ return { kind: "blocked", summary: notes };
1011
+ }
1012
+ // Route evaluation BEFORE completion: an unroutable value is an
1013
+ // authoring/config failure that must fail the step deterministically.
1014
+ let summaryOverride;
1015
+ if (stepPlan.route) {
1016
+ const scope = {
1017
+ params: input.params,
1018
+ stepOutputs: routeStepOutputs(input.priorEvidence, stepId, result.evidence),
1019
+ };
1020
+ const decision = evaluateRoute(stepPlan.route, scope);
1021
+ if (!decision.ok) {
1022
+ const notes = `Step "${stepId}" route failed: ${decision.error}`;
1023
+ await completeWorkflowStep({ runId, stepId, status: "failed", notes, evidence: result.evidence, ...lease });
1024
+ return { kind: "failed", summary: notes, routeFailure: true };
1025
+ }
1026
+ applyRouteDecision(stepPlan.route, stepId, decision.selected, input.routeSelected, input.routeUnselected);
1027
+ // Journal the decision on the evidence: resume replays it via
1028
+ // seedJournaledRouteDecisions, so the skip set survives re-invocation.
1029
+ result.evidence.route = { input: stepPlan.route.input, value: decision.value, selected: decision.selected };
1030
+ if (!stepPlan.root) {
1031
+ summaryOverride = `Step "${stepId}" routed on ${stepPlan.route.input}: value "${decision.value}" selected step "${decision.selected}".`;
1032
+ }
1033
+ }
1034
+ // Artifact-judging gate: a criteria-bearing executing step is judged on a
1035
+ // summary BUILT FROM the promoted artifact; everything else keeps the machine
1036
+ // summary (a route-only step's summary IS its decision).
1037
+ const summary = stepPlan.root && completionCriteria.length > 0
1038
+ ? buildArtifactSummary(stepId, result.units, result.evidence)
1039
+ : (summaryOverride ?? result.summary);
1040
+ // Journal engine-driven judge calls as unit rows (they are LLM calls). The
1041
+ // wrapper's `invoked` stays false when the gate is fail-open (no criteria / no
1042
+ // judge) — nothing is journaled, and human approvals are never cached.
1043
+ const gateUnit = { runId, workflowRef, stepId, loop: gateLoop };
1044
+ const judgeState = { invoked: false, errored: false };
1045
+ const summaryJudge = innerJudge
1046
+ ? async (prompt) => {
1047
+ judgeState.invoked = true;
1048
+ await journalGateEvaluationStart(gateUnit);
1049
+ try {
1050
+ return await innerJudge(prompt);
1051
+ }
1052
+ catch (err) {
1053
+ judgeState.errored = true;
1054
+ throw err;
1055
+ }
1056
+ }
1057
+ : null;
1058
+ // Reviewer #6: once the judge is invoked, its gate row is journaled `running`
1059
+ // (journalGateEvaluationStart) and MUST be finished on every exit. The
1060
+ // already-fixed window is the judge itself throwing (caught inside
1061
+ // validateStepSummary — `judgeState.errored` records it; a non-required gate
1062
+ // fails open, a required gate blocks below). The remaining
1063
+ // window is `completeWorkflowStep` throwing AFTER the judge ran — a stolen
1064
+ // lease, a concurrent state change, a DB error — which would otherwise skip the
1065
+ // finish and strand the gate row in `running`. Finish it as an errored row (the
1066
+ // observed outcome: the completion did not succeed), then re-propagate.
1067
+ let completion;
1068
+ try {
1069
+ completion = await completeWorkflowStep({
1070
+ runId,
1071
+ stepId,
1072
+ status: "completed",
1073
+ summary,
1074
+ evidence: result.evidence,
1075
+ summaryJudge,
1076
+ // Codex round-3 finding A: mark this completion's gate REQUIRED so
1077
+ // `validateStepSummary` does NOT fail open when the judge throws / is
1078
+ // unreachable / returns garbage — it flags `errored` and we block below.
1079
+ ...(gateRequired ? { requireGate: true } : {}),
1080
+ ...lease,
1081
+ });
1082
+ }
1083
+ catch (err) {
1084
+ if (judgeState.invoked)
1085
+ await journalGateEvaluationFinish(gateUnit, true, undefined);
1086
+ throw err;
1087
+ }
1088
+ const rejection = "ok" in completion && completion.ok === false ? completion : undefined;
1089
+ // A required gate whose judge could not be evaluated is an errored gate, not a
1090
+ // real rejection: journal the gate row as errored (verdict null) so the
1091
+ // observed outcome is honest, driven by EITHER the wrapper catching a throw OR
1092
+ // validateStepSummary flagging an unparseable verdict.
1093
+ const gateErrored = judgeState.errored || rejection?.errored === true;
1094
+ if (judgeState.invoked) {
1095
+ await journalGateEvaluationFinish(gateUnit, gateErrored, rejection);
1096
+ }
1097
+ // Codex round-3 finding A: a REQUIRED gate that could not be judged (the judge
1098
+ // threw, was unreachable, or returned an unparseable verdict) must NOT fail
1099
+ // open and advance. The gate row is journaled errored above; BLOCK the step (a
1100
+ // human resolves it) instead of silently passing an unjudged required gate.
1101
+ if (rejection?.errored) {
1102
+ const notes = `Step "${stepId}" has a REQUIRED completion gate but its summary-validation judge failed to return a verdict ` +
1103
+ `(the LLM threw, was unreachable, or returned an unparseable response). A required gate must be judged — refusing ` +
1104
+ `to fail open and silently pass it. The step is BLOCKED: fix the LLM/connection, then \`akm workflow resume ${runId}\` ` +
1105
+ `to re-evaluate the gate, or advance the step manually with \`akm workflow complete\`.`;
1106
+ await completeWorkflowStep({ runId, stepId, status: "blocked", notes, evidence: result.evidence, ...lease });
1107
+ return { kind: "blocked", summary: notes };
1108
+ }
1109
+ if (!rejection) {
1110
+ return { kind: "advanced", ...(summaryOverride !== undefined ? { summaryOverride } : {}) };
1111
+ }
1112
+ if (loopsRemaining) {
1113
+ return { kind: "retry", gateFeedback: { feedback: rejection.feedback, missing: rejection.missing } };
1114
+ }
1115
+ return {
1116
+ kind: "gate-exhausted",
1117
+ gateRejection: { stepId, missing: rejection.missing, feedback: rejection.feedback },
1118
+ };
1119
+ }
1120
+ // ── Frozen plan parse + integrity check (shared) ─────────────────────────────
1121
+ /**
1122
+ * Parse and integrity-check a run's frozen plan JSON (migration 006). Shared by
1123
+ * the engine loop's plan loader (`run-workflow.ts`) and the R3 brief/report
1124
+ * surfaces so all three apply the SAME corruption + hash checks — the frozen
1125
+ * plan the engine executes is the exact plan brief describes and report
1126
+ * validates against. A NULL `plan_json` is the CALLER's decision (the engine
1127
+ * warns and compiles from the asset; brief/report error), so this helper only
1128
+ * handles a PRESENT plan string.
1129
+ */
1130
+ export function parseFrozenPlan(runId, planJson, planHash) {
1131
+ let plan;
1132
+ try {
1133
+ plan = JSON.parse(planJson);
1134
+ }
1135
+ catch {
1136
+ throw new UsageError(`Workflow run ${runId} has a corrupt frozen plan (plan_json is not valid JSON). ` +
1137
+ `The journaled plan cannot be executed — start a new run.`);
1138
+ }
1139
+ if (computePlanHash(plan) !== planHash) {
1140
+ throw new UsageError(`Workflow run ${runId} failed the frozen-plan integrity check: plan_json does not match plan_hash. ` +
1141
+ `The journaled plan was modified after the run started — refusing to execute it. Start a new run.`);
1142
+ }
1143
+ return plan;
1144
+ }
1145
+ // ── Small helpers ────────────────────────────────────────────────────────────
1146
+ function safeJson(value) {
1147
+ try {
1148
+ return JSON.stringify(value) ?? "null";
1149
+ }
1150
+ catch {
1151
+ return "null";
1152
+ }
1153
+ }
1154
+ function clip(text, max) {
1155
+ return text.length > max ? `${text.slice(0, max)}…` : text;
1156
+ }