okstra 0.173.0 → 0.175.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 (402) hide show
  1. package/README.md +18 -9
  2. package/bin/okstra +8 -4
  3. package/dist/cli-registry.d.mts +16 -0
  4. package/dist/cli-registry.mjs +560 -0
  5. package/dist/cli-registry.mjs.map +1 -0
  6. package/dist/commands/execute/agent-prompt.d.mts +1 -0
  7. package/{src → dist}/commands/execute/agent-prompt.mjs +10 -11
  8. package/dist/commands/execute/agent-prompt.mjs.map +1 -0
  9. package/dist/commands/execute/codex-dispatch.d.mts +3 -0
  10. package/dist/commands/execute/codex-dispatch.mjs +6 -0
  11. package/dist/commands/execute/codex-dispatch.mjs.map +1 -0
  12. package/dist/commands/execute/codex-run.d.mts +3 -0
  13. package/{src → dist}/commands/execute/codex-run.mjs +30 -37
  14. package/dist/commands/execute/codex-run.mjs.map +1 -0
  15. package/dist/commands/execute/convergence.d.mts +1 -0
  16. package/{src → dist}/commands/execute/convergence.mjs +15 -16
  17. package/dist/commands/execute/convergence.mjs.map +1 -0
  18. package/dist/commands/execute/error-log.d.mts +1 -0
  19. package/{src → dist}/commands/execute/error-log.mjs +2 -3
  20. package/dist/commands/execute/error-log.mjs.map +1 -0
  21. package/dist/commands/execute/git-reconcile.d.mts +1 -0
  22. package/{src → dist}/commands/execute/git-reconcile.mjs +10 -11
  23. package/dist/commands/execute/git-reconcile.mjs.map +1 -0
  24. package/dist/commands/execute/handoff.d.mts +1 -0
  25. package/{src → dist}/commands/execute/handoff.mjs +10 -11
  26. package/dist/commands/execute/handoff.mjs.map +1 -0
  27. package/dist/commands/execute/incremental-carry.d.mts +1 -0
  28. package/{src → dist}/commands/execute/incremental-carry.mjs +2 -3
  29. package/dist/commands/execute/incremental-carry.mjs.map +1 -0
  30. package/dist/commands/execute/incremental-scope.d.mts +1 -0
  31. package/{src → dist}/commands/execute/incremental-scope.mjs +2 -3
  32. package/dist/commands/execute/incremental-scope.mjs.map +1 -0
  33. package/dist/commands/execute/integrate-stages.d.mts +1 -0
  34. package/{src → dist}/commands/execute/integrate-stages.mjs +10 -11
  35. package/dist/commands/execute/integrate-stages.mjs.map +1 -0
  36. package/dist/commands/execute/pane-title.d.mts +1 -0
  37. package/dist/commands/execute/pane-title.mjs +20 -0
  38. package/dist/commands/execute/pane-title.mjs.map +1 -0
  39. package/dist/commands/execute/plan-items.d.mts +1 -0
  40. package/dist/commands/execute/plan-items.mjs +9 -0
  41. package/dist/commands/execute/plan-items.mjs.map +1 -0
  42. package/dist/commands/execute/plan-validate.d.mts +1 -0
  43. package/dist/commands/execute/plan-validate.mjs +68 -0
  44. package/dist/commands/execute/plan-validate.mjs.map +1 -0
  45. package/dist/commands/execute/plan-verify.d.mts +1 -0
  46. package/{src → dist}/commands/execute/plan-verify.mjs +13 -16
  47. package/dist/commands/execute/plan-verify.mjs.map +1 -0
  48. package/dist/commands/execute/render-bundle.d.mts +3 -0
  49. package/{src → dist}/commands/execute/render-bundle.mjs +65 -61
  50. package/dist/commands/execute/render-bundle.mjs.map +1 -0
  51. package/dist/commands/execute/run.d.mts +24 -0
  52. package/dist/commands/execute/run.mjs +196 -0
  53. package/dist/commands/execute/run.mjs.map +1 -0
  54. package/dist/commands/execute/spawn-followups.d.mts +1 -0
  55. package/{src → dist}/commands/execute/spawn-followups.mjs +6 -7
  56. package/dist/commands/execute/spawn-followups.mjs.map +1 -0
  57. package/dist/commands/execute/team.d.mts +3 -0
  58. package/dist/commands/execute/team.mjs +56 -0
  59. package/dist/commands/execute/team.mjs.map +1 -0
  60. package/dist/commands/execute/token-usage.d.mts +1 -0
  61. package/{src → dist}/commands/execute/token-usage.mjs +2 -3
  62. package/dist/commands/execute/token-usage.mjs.map +1 -0
  63. package/dist/commands/execute/wizard.d.mts +8 -0
  64. package/dist/commands/execute/wizard.mjs +191 -0
  65. package/dist/commands/execute/wizard.mjs.map +1 -0
  66. package/dist/commands/execute/worker-audit-check.d.mts +1 -0
  67. package/{src → dist}/commands/execute/worker-audit-check.mjs +11 -12
  68. package/dist/commands/execute/worker-audit-check.mjs.map +1 -0
  69. package/dist/commands/execute/worker-dispatch.d.mts +7 -0
  70. package/{src → dist}/commands/execute/worker-dispatch.mjs +34 -46
  71. package/dist/commands/execute/worker-dispatch.mjs.map +1 -0
  72. package/dist/commands/execute/worker-state.d.mts +1 -0
  73. package/{src → dist}/commands/execute/worker-state.mjs +15 -16
  74. package/dist/commands/execute/worker-state.mjs.map +1 -0
  75. package/dist/commands/execute/worktree-lookup.d.mts +1 -0
  76. package/{src → dist}/commands/execute/worktree-lookup.mjs +38 -37
  77. package/dist/commands/execute/worktree-lookup.mjs.map +1 -0
  78. package/dist/commands/execute/worktree-status.d.mts +1 -0
  79. package/dist/commands/execute/worktree-status.mjs +115 -0
  80. package/dist/commands/execute/worktree-status.mjs.map +1 -0
  81. package/dist/commands/inspect/code-review.d.mts +1 -0
  82. package/{src → dist}/commands/inspect/code-review.mjs +16 -19
  83. package/dist/commands/inspect/code-review.mjs.map +1 -0
  84. package/dist/commands/inspect/container.d.mts +1 -0
  85. package/{src → dist}/commands/inspect/container.mjs +11 -13
  86. package/dist/commands/inspect/container.mjs.map +1 -0
  87. package/dist/commands/inspect/context-cost.d.mts +1 -0
  88. package/{src → dist}/commands/inspect/context-cost.mjs +11 -13
  89. package/dist/commands/inspect/context-cost.mjs.map +1 -0
  90. package/dist/commands/inspect/design-prep.d.mts +1 -0
  91. package/{src → dist}/commands/inspect/design-prep.mjs +10 -11
  92. package/dist/commands/inspect/design-prep.mjs.map +1 -0
  93. package/dist/commands/inspect/error-report.d.mts +1 -0
  94. package/{src → dist}/commands/inspect/error-report.mjs +11 -13
  95. package/dist/commands/inspect/error-report.mjs.map +1 -0
  96. package/dist/commands/inspect/error-zip.d.mts +1 -0
  97. package/{src → dist}/commands/inspect/error-zip.mjs +11 -12
  98. package/dist/commands/inspect/error-zip.mjs.map +1 -0
  99. package/dist/commands/inspect/log-report.d.mts +1 -0
  100. package/{src → dist}/commands/inspect/log-report.mjs +11 -13
  101. package/dist/commands/inspect/log-report.mjs.map +1 -0
  102. package/dist/commands/inspect/profile-show.d.mts +1 -0
  103. package/{src → dist}/commands/inspect/profile-show.mjs +10 -11
  104. package/dist/commands/inspect/profile-show.mjs.map +1 -0
  105. package/dist/commands/inspect/recap.d.mts +1 -0
  106. package/{src → dist}/commands/inspect/recap.mjs +11 -13
  107. package/dist/commands/inspect/recap.mjs.map +1 -0
  108. package/dist/commands/inspect/resolve-task-key.d.mts +1 -0
  109. package/{src → dist}/commands/inspect/resolve-task-key.mjs +11 -13
  110. package/dist/commands/inspect/resolve-task-key.mjs.map +1 -0
  111. package/dist/commands/inspect/rollup.d.mts +1 -0
  112. package/{src → dist}/commands/inspect/rollup.mjs +11 -13
  113. package/dist/commands/inspect/rollup.mjs.map +1 -0
  114. package/dist/commands/inspect/run-audit.d.mts +1 -0
  115. package/{src → dist}/commands/inspect/run-audit.mjs +11 -12
  116. package/dist/commands/inspect/run-audit.mjs.map +1 -0
  117. package/dist/commands/inspect/set-work-status.d.mts +1 -0
  118. package/{src → dist}/commands/inspect/set-work-status.mjs +11 -13
  119. package/dist/commands/inspect/set-work-status.mjs.map +1 -0
  120. package/dist/commands/inspect/stage-map.d.mts +1 -0
  121. package/dist/commands/inspect/stage-map.mjs +110 -0
  122. package/dist/commands/inspect/stage-map.mjs.map +1 -0
  123. package/dist/commands/inspect/task-list.d.mts +1 -0
  124. package/dist/commands/inspect/task-list.mjs +103 -0
  125. package/dist/commands/inspect/task-list.mjs.map +1 -0
  126. package/dist/commands/inspect/task-show.d.mts +1 -0
  127. package/dist/commands/inspect/task-show.mjs +108 -0
  128. package/dist/commands/inspect/task-show.mjs.map +1 -0
  129. package/dist/commands/inspect/time-report.d.mts +1 -0
  130. package/{src → dist}/commands/inspect/time-report.mjs +11 -13
  131. package/dist/commands/inspect/time-report.mjs.map +1 -0
  132. package/dist/commands/inspect/usage-report.d.mts +1 -0
  133. package/{src → dist}/commands/inspect/usage-report.mjs +11 -13
  134. package/dist/commands/inspect/usage-report.mjs.map +1 -0
  135. package/dist/commands/inspect/user-response.d.mts +1 -0
  136. package/{src → dist}/commands/inspect/user-response.mjs +10 -11
  137. package/dist/commands/inspect/user-response.mjs.map +1 -0
  138. package/dist/commands/inspect/worker-liveness.d.mts +1 -0
  139. package/{src → dist}/commands/inspect/worker-liveness.mjs +11 -13
  140. package/dist/commands/inspect/worker-liveness.mjs.map +1 -0
  141. package/dist/commands/lifecycle/check-project.d.mts +21 -0
  142. package/dist/commands/lifecycle/check-project.mjs +175 -0
  143. package/dist/commands/lifecycle/check-project.mjs.map +1 -0
  144. package/dist/commands/lifecycle/config.d.mts +1 -0
  145. package/dist/commands/lifecycle/config.mjs +369 -0
  146. package/dist/commands/lifecycle/config.mjs.map +1 -0
  147. package/dist/commands/lifecycle/doctor.d.mts +10 -0
  148. package/dist/commands/lifecycle/doctor.mjs +317 -0
  149. package/dist/commands/lifecycle/doctor.mjs.map +1 -0
  150. package/dist/commands/lifecycle/install.d.mts +17 -0
  151. package/dist/commands/lifecycle/install.mjs +1077 -0
  152. package/dist/commands/lifecycle/install.mjs.map +1 -0
  153. package/dist/commands/lifecycle/migrate.d.mts +1 -0
  154. package/{src → dist}/commands/lifecycle/migrate.mjs +10 -11
  155. package/dist/commands/lifecycle/migrate.mjs.map +1 -0
  156. package/dist/commands/lifecycle/model.d.mts +1 -0
  157. package/dist/commands/lifecycle/model.mjs +22 -0
  158. package/dist/commands/lifecycle/model.mjs.map +1 -0
  159. package/dist/commands/lifecycle/paths.d.mts +1 -0
  160. package/dist/commands/lifecycle/paths.mjs +102 -0
  161. package/dist/commands/lifecycle/paths.mjs.map +1 -0
  162. package/dist/commands/lifecycle/preflight.d.mts +1 -0
  163. package/dist/commands/lifecycle/preflight.mjs +124 -0
  164. package/dist/commands/lifecycle/preflight.mjs.map +1 -0
  165. package/dist/commands/lifecycle/setup.d.mts +1 -0
  166. package/dist/commands/lifecycle/setup.mjs +297 -0
  167. package/dist/commands/lifecycle/setup.mjs.map +1 -0
  168. package/dist/commands/lifecycle/uninstall.d.mts +4 -0
  169. package/dist/commands/lifecycle/uninstall.mjs +262 -0
  170. package/dist/commands/lifecycle/uninstall.mjs.map +1 -0
  171. package/dist/commands/manager.d.mts +3 -0
  172. package/{src → dist}/commands/manager.mjs +25 -32
  173. package/dist/commands/manager.mjs.map +1 -0
  174. package/dist/commands/memory/memory.d.mts +1 -0
  175. package/dist/commands/memory/memory.mjs +569 -0
  176. package/dist/commands/memory/memory.mjs.map +1 -0
  177. package/dist/commands/pr/pr.d.mts +33 -0
  178. package/dist/commands/pr/pr.mjs +296 -0
  179. package/dist/commands/pr/pr.mjs.map +1 -0
  180. package/dist/commands/report/agent-activity.d.mts +1 -0
  181. package/{src → dist}/commands/report/agent-activity.mjs +11 -12
  182. package/dist/commands/report/agent-activity.mjs.map +1 -0
  183. package/dist/commands/report/finalize.d.mts +4 -0
  184. package/dist/commands/report/finalize.mjs +64 -0
  185. package/dist/commands/report/finalize.mjs.map +1 -0
  186. package/dist/commands/report/inject-report-index.d.mts +1 -0
  187. package/{src → dist}/commands/report/inject-report-index.mjs +6 -7
  188. package/dist/commands/report/inject-report-index.mjs.map +1 -0
  189. package/dist/commands/report/render-final-report.d.mts +1 -0
  190. package/{src → dist}/commands/report/render-final-report.mjs +6 -7
  191. package/dist/commands/report/render-final-report.mjs.map +1 -0
  192. package/dist/commands/report/render-views.d.mts +1 -0
  193. package/{src → dist}/commands/report/render-views.mjs +6 -7
  194. package/dist/commands/report/render-views.mjs.map +1 -0
  195. package/dist/commands/report/translate.d.mts +1 -0
  196. package/{src → dist}/commands/report/translate.mjs +6 -7
  197. package/dist/commands/report/translate.mjs.map +1 -0
  198. package/dist/lib/helper-scripts.d.mts +3 -0
  199. package/{src → dist}/lib/helper-scripts.mjs +11 -14
  200. package/dist/lib/helper-scripts.mjs.map +1 -0
  201. package/dist/lib/host-registry-client.d.mts +25 -0
  202. package/dist/lib/host-registry-client.mjs +152 -0
  203. package/dist/lib/host-registry-client.mjs.map +1 -0
  204. package/dist/lib/install-assets.d.mts +8 -0
  205. package/{src → dist}/lib/install-assets.mjs +17 -20
  206. package/dist/lib/install-assets.mjs.map +1 -0
  207. package/dist/lib/okstra-dirs.d.mts +4 -0
  208. package/{src → dist}/lib/okstra-dirs.mjs +3 -6
  209. package/dist/lib/okstra-dirs.mjs.map +1 -0
  210. package/dist/lib/paths.d.mts +4 -0
  211. package/dist/lib/paths.mjs +78 -0
  212. package/dist/lib/paths.mjs.map +1 -0
  213. package/dist/lib/proc.d.mts +3 -0
  214. package/dist/lib/proc.mjs +31 -0
  215. package/dist/lib/proc.mjs.map +1 -0
  216. package/dist/lib/python-helper.d.mts +8 -0
  217. package/dist/lib/python-helper.mjs +123 -0
  218. package/dist/lib/python-helper.mjs.map +1 -0
  219. package/dist/lib/runtime-manifest.d.mts +7 -0
  220. package/dist/lib/runtime-manifest.mjs +73 -0
  221. package/dist/lib/runtime-manifest.mjs.map +1 -0
  222. package/dist/lib/skill-catalog.d.mts +4 -0
  223. package/dist/lib/skill-catalog.mjs +50 -0
  224. package/dist/lib/skill-catalog.mjs.map +1 -0
  225. package/dist/lib/types.d.mts +129 -0
  226. package/dist/lib/types.mjs +2 -0
  227. package/dist/lib/types.mjs.map +1 -0
  228. package/dist/lib/version.d.mts +2 -0
  229. package/dist/lib/version.mjs +27 -0
  230. package/dist/lib/version.mjs.map +1 -0
  231. package/docs/architecture/storage-model.md +17 -3
  232. package/docs/architecture.md +12 -22
  233. package/docs/cli.md +29 -8
  234. package/docs/container.md +1 -1
  235. package/docs/contributor-change-matrix.md +3 -3
  236. package/docs/project-structure-overview.md +29 -20
  237. package/docs/task-process/README.md +3 -1
  238. package/docs/task-process/implementation-planning.md +2 -2
  239. package/docs/task-process/implementation.md +1 -1
  240. package/package.json +11 -5
  241. package/runtime/BUILD.json +2 -2
  242. package/runtime/agents/workers/claude-worker.md +3 -2
  243. package/runtime/agents/workers/report-writer-worker.md +3 -1
  244. package/runtime/agents/workers/translator-worker.md +3 -1
  245. package/runtime/bin/lib/okstra/cli.sh +12 -0
  246. package/runtime/bin/lib/okstra/globals.sh +4 -1
  247. package/runtime/bin/lib/okstra/usage.sh +4 -4
  248. package/runtime/bin/okstra-error-log.py +27 -0
  249. package/runtime/bin/okstra-provider-exec.py +245 -16
  250. package/runtime/bin/okstra-trace-cleanup.sh +58 -129
  251. package/runtime/bin/okstra.sh +10 -0
  252. package/runtime/prompts/lead/adapters/cmux.md +2 -0
  253. package/runtime/prompts/lead/convergence.md +27 -18
  254. package/runtime/prompts/lead/okstra-lead-contract.md +4 -2
  255. package/runtime/prompts/lead/plan-body-verification.md +13 -6
  256. package/runtime/prompts/lead/report-writer.md +6 -6
  257. package/runtime/prompts/lead/team-contract.md +1 -1
  258. package/runtime/prompts/profiles/_common-contract.md +2 -2
  259. package/runtime/prompts/profiles/_implementation-executor.md +2 -0
  260. package/runtime/prompts/profiles/_implementation-verifier.md +2 -2
  261. package/runtime/prompts/profiles/change-impact-analysis.md +16 -0
  262. package/runtime/prompts/profiles/error-analysis.md +21 -1
  263. package/runtime/prompts/profiles/feature-analysis.md +16 -0
  264. package/runtime/prompts/profiles/final-verification.md +16 -0
  265. package/runtime/prompts/profiles/implementation-option-selection.md +16 -0
  266. package/runtime/prompts/profiles/implementation-planning.md +32 -9
  267. package/runtime/prompts/profiles/implementation.md +17 -1
  268. package/runtime/prompts/profiles/improvement-discovery.md +16 -0
  269. package/runtime/prompts/profiles/project-analysis.md +16 -0
  270. package/runtime/prompts/profiles/release-handoff.md +5 -1
  271. package/runtime/prompts/profiles/requirements-discovery.md +20 -0
  272. package/runtime/prompts/wizard/prompts.ko.json +18 -0
  273. package/runtime/python/okstra_ctl/adapters/dispatch/__init__.py +1 -6
  274. package/runtime/python/okstra_ctl/adapters/dispatch/cli_wrapper.py +8 -0
  275. package/runtime/python/okstra_ctl/adapters/dispatch/cmux.py +8 -0
  276. package/runtime/python/okstra_ctl/adapters/dispatch/native_team.py +13 -0
  277. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/adapter.py +2 -2
  278. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +25 -5
  279. package/runtime/python/okstra_ctl/adapters/hosts/codex/adapter.py +2 -2
  280. package/runtime/python/okstra_ctl/adapters/hosts/external/adapter.py +4 -1
  281. package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +4 -4
  282. package/runtime/python/okstra_ctl/adapters/hosts/grok/adapter.py +2 -2
  283. package/runtime/python/okstra_ctl/adapters/hosts/kimi/adapter.py +2 -2
  284. package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +79 -11
  285. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +100 -25
  286. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +59 -14
  287. package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +55 -9
  288. package/runtime/python/okstra_ctl/adapters/providers/kimi/adapter.py +51 -12
  289. package/runtime/python/okstra_ctl/agent_activity.py +5 -1
  290. package/runtime/python/okstra_ctl/agent_invocation.py +608 -14
  291. package/runtime/python/okstra_ctl/agent_prompt_cli.py +249 -50
  292. package/runtime/python/okstra_ctl/application/resolve_assignment.py +53 -12
  293. package/runtime/python/okstra_ctl/assignment_environment.py +102 -0
  294. package/runtime/python/okstra_ctl/assignment_resolver.py +790 -0
  295. package/runtime/python/okstra_ctl/attempt_evidence.py +342 -0
  296. package/runtime/python/okstra_ctl/conformance.py +68 -0
  297. package/runtime/python/okstra_ctl/convergence.py +409 -3
  298. package/runtime/python/okstra_ctl/convergence_engine.py +248 -15
  299. package/runtime/python/okstra_ctl/convergence_store.py +170 -1
  300. package/runtime/python/okstra_ctl/dispatch_core.py +1181 -102
  301. package/runtime/python/okstra_ctl/dispatch_state.py +835 -22
  302. package/runtime/python/okstra_ctl/doctor.py +16 -2
  303. package/runtime/python/okstra_ctl/domain/host.py +32 -1
  304. package/runtime/python/okstra_ctl/domain/provider.py +212 -6
  305. package/runtime/python/okstra_ctl/domain/role.py +125 -0
  306. package/runtime/python/okstra_ctl/domain/wizard/interaction.py +13 -0
  307. package/runtime/python/okstra_ctl/domain/worker_exec.py +39 -1
  308. package/runtime/python/okstra_ctl/domain/worker_role.py +13 -3
  309. package/runtime/python/okstra_ctl/domain/worker_stream.py +24 -0
  310. package/runtime/python/okstra_ctl/error_log_write.py +9 -0
  311. package/runtime/python/okstra_ctl/error_report.py +21 -1
  312. package/runtime/python/okstra_ctl/execution_identity.py +529 -0
  313. package/runtime/python/okstra_ctl/execution_manifest.py +1281 -0
  314. package/runtime/python/okstra_ctl/execution_mutation_audit.py +619 -0
  315. package/runtime/python/okstra_ctl/final_report_schema.py +5 -4
  316. package/runtime/python/okstra_ctl/index.py +6 -2
  317. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +43 -3
  318. package/runtime/python/okstra_ctl/legacy_model_selection.py +490 -0
  319. package/runtime/python/okstra_ctl/model_cli.py +278 -0
  320. package/runtime/python/okstra_ctl/model_defaults.py +46 -0
  321. package/runtime/python/okstra_ctl/model_discovery.py +26 -7
  322. package/runtime/python/okstra_ctl/model_pool.py +501 -0
  323. package/runtime/python/okstra_ctl/models.py +47 -27
  324. package/runtime/python/okstra_ctl/mutation_recovery.py +293 -0
  325. package/runtime/python/okstra_ctl/pane_reclaim.py +13 -22
  326. package/runtime/python/okstra_ctl/pane_title.py +151 -0
  327. package/runtime/python/okstra_ctl/ports/host_model.py +48 -17
  328. package/runtime/python/okstra_ctl/ports/worker_dispatch.py +4 -0
  329. package/runtime/python/okstra_ctl/registry/provider_registry.py +8 -4
  330. package/runtime/python/okstra_ctl/render.py +78 -10
  331. package/runtime/python/okstra_ctl/render_final_report.py +34 -19
  332. package/runtime/python/okstra_ctl/report_contract.py +39 -1
  333. package/runtime/python/okstra_ctl/report_finalize.py +84 -9
  334. package/runtime/python/okstra_ctl/report_html/render.py +1 -0
  335. package/runtime/python/okstra_ctl/role_requirements.py +217 -0
  336. package/runtime/python/okstra_ctl/run.py +1250 -260
  337. package/runtime/python/okstra_ctl/run_index_row.py +7 -1
  338. package/runtime/python/okstra_ctl/schema_excerpt.py +1 -1
  339. package/runtime/python/okstra_ctl/scope_provenance.py +1 -1
  340. package/runtime/python/okstra_ctl/session.py +69 -12
  341. package/runtime/python/okstra_ctl/team.py +51 -25
  342. package/runtime/python/okstra_ctl/time_report.py +38 -4
  343. package/runtime/python/okstra_ctl/tmux.py +19 -149
  344. package/runtime/python/okstra_ctl/usage_identity.py +86 -0
  345. package/runtime/python/okstra_ctl/usage_report.py +15 -3
  346. package/runtime/python/okstra_ctl/wizard.py +1253 -62
  347. package/runtime/python/okstra_ctl/worker_audit_ledger.py +68 -65
  348. package/runtime/python/okstra_ctl/worker_request.py +9 -1
  349. package/runtime/python/okstra_ctl/worker_runner.py +130 -5
  350. package/runtime/python/okstra_ctl/workers.py +7 -5
  351. package/runtime/python/okstra_ctl/worktree.py +69 -3
  352. package/runtime/python/okstra_ctl/write_policy.py +571 -0
  353. package/runtime/python/okstra_token_usage/cli.py +1 -1
  354. package/runtime/python/okstra_token_usage/collect.py +66 -6
  355. package/runtime/schemas/convergence-groups-v2.0.schema.json +128 -0
  356. package/runtime/schemas/execution-manifest-v2.schema.json +340 -0
  357. package/runtime/schemas/final-report-v2.0.schema.json +25 -0
  358. package/runtime/skills/okstra-run/SKILL.md +9 -9
  359. package/runtime/skills/okstra-setup/references/project-config.md +11 -0
  360. package/runtime/templates/report-writer-prompt-preamble.md +2 -0
  361. package/runtime/templates/reports/final-report-v2.template.md +7 -1
  362. package/runtime/templates/reports/html/base.template.html +21 -0
  363. package/runtime/templates/reports/html/i18n/en.json +4 -0
  364. package/runtime/templates/reports/html/i18n/ko.json +4 -0
  365. package/runtime/templates/reports/settings.template.json +0 -24
  366. package/runtime/templates/worker-prompt-preamble.md +6 -2
  367. package/runtime/validators/lib/fixtures.sh +98 -33
  368. package/runtime/validators/validate-implementation-plan-stages.py +63 -3
  369. package/runtime/validators/validate-run.py +443 -508
  370. package/runtime/validators/validate_session_conformance.py +26 -8
  371. package/runtime/bin/okstra-subagent-reclaim.sh +0 -26
  372. package/runtime/schemas/final-report-v1.0.schema.json +0 -6366
  373. package/runtime/templates/reports/final-report.template.md +0 -1258
  374. package/src/cli-registry.mjs +0 -532
  375. package/src/commands/execute/codex-dispatch.mjs +0 -10
  376. package/src/commands/execute/plan-items.mjs +0 -9
  377. package/src/commands/execute/plan-validate.mjs +0 -68
  378. package/src/commands/execute/run.mjs +0 -208
  379. package/src/commands/execute/team.mjs +0 -63
  380. package/src/commands/execute/wizard.mjs +0 -167
  381. package/src/commands/inspect/stage-map.mjs +0 -98
  382. package/src/commands/inspect/task-list.mjs +0 -97
  383. package/src/commands/inspect/task-show.mjs +0 -100
  384. package/src/commands/lifecycle/check-project.mjs +0 -187
  385. package/src/commands/lifecycle/config.mjs +0 -385
  386. package/src/commands/lifecycle/doctor.mjs +0 -308
  387. package/src/commands/lifecycle/install.mjs +0 -1172
  388. package/src/commands/lifecycle/paths.mjs +0 -104
  389. package/src/commands/lifecycle/preflight.mjs +0 -125
  390. package/src/commands/lifecycle/setup.mjs +0 -333
  391. package/src/commands/lifecycle/uninstall.mjs +0 -279
  392. package/src/commands/memory/memory.mjs +0 -524
  393. package/src/commands/pr/pr.mjs +0 -261
  394. package/src/commands/report/finalize.mjs +0 -64
  395. package/src/lib/host-registry-client.mjs +0 -176
  396. package/src/lib/paths.mjs +0 -86
  397. package/src/lib/proc.mjs +0 -31
  398. package/src/lib/python-helper.mjs +0 -142
  399. package/src/lib/runtime-manifest.mjs +0 -70
  400. package/src/lib/skill-catalog.mjs +0 -53
  401. package/src/lib/version.mjs +0 -20
  402. /package/{src → dist}/commands/pr/default.md +0 -0
@@ -1,8 +1,8 @@
1
1
  """Worker dispatch plumbing shared by both lead dispatchers.
2
2
 
3
3
  `dispatch_core` (claude / external lead) and `codex_dispatch` (codex lead) differ
4
- in capability — only the former has tmux panes, blocking waits, and retry from a
5
- persisted record. What they do *not* differ in is the job value object and the
4
+ in capability — only the former has cmux surfaces, blocking waits, and retry from
5
+ a persisted record. What they do *not* differ in is the job value object and the
6
6
  run's state files: the same run-manifest keys, the same team-state document, the
7
7
  same wrapper argv.
8
8
 
@@ -22,6 +22,7 @@ import fcntl
22
22
  import json
23
23
  import os
24
24
  import tempfile
25
+ import uuid
25
26
  from dataclasses import dataclass
26
27
  from datetime import datetime, timezone
27
28
  from pathlib import Path
@@ -31,8 +32,18 @@ from . import cmux
31
32
  from .agent_invocation import (
32
33
  AgentInvocationError,
33
34
  agent_model_assignment_from_payload,
35
+ invocation_metadata_identity,
36
+ v2_role_assignment_authority_errors,
34
37
  verify_agent_invocation,
35
38
  )
39
+ from .domain.role import RoleCatalogError, role_for_duty
40
+ from .execution_identity import Attempt, Invocation
41
+ from .execution_manifest import (
42
+ finish_attempt_mutation,
43
+ read_execution_manifest,
44
+ record_invocation_attempt,
45
+ )
46
+ from .execution_mutation_audit import ExecutionMutationAudit, MutationSnapshot
36
47
  from .final_report_paths import (
37
48
  final_report_data_path,
38
49
  final_report_markdown_path,
@@ -44,25 +55,33 @@ from .worker_prompt_contract import (
44
55
  validate_reverify_prompt,
45
56
  )
46
57
  from .worker_runner import LIVE, QUIET
58
+ from .worker_request import verifier_extra_dirs
59
+ from .worker_artifact_paths import audit_sidecar_rel
60
+ from .wrapper_status import status_path_for_prompt
61
+ from .write_policy import (
62
+ build_invocation_write_contract,
63
+ planned_paths_from_run_manifest,
64
+ write_policy_from_payload,
65
+ )
66
+ from .convergence_store import write_json_atomic
47
67
 
48
68
  BACKEND_CLI_WRAPPER = "cli-wrapper"
49
- BACKEND_TMUX_PANE = "tmux-pane"
50
69
  BACKEND_CMUX_PANE = "cmux-pane"
51
70
  BACKEND_MIXED = "mixed"
52
71
 
53
72
 
54
73
  def detect_terminal_backend() -> str:
55
- """Which pane backend this run gets. Called once, by prepare.
74
+ """Which backend this run's workers get. Called once, by prepare.
56
75
 
57
- cmux wins wherever it is usable: the only thing a tmux session buys a lead
58
- is Claude Code's AgentTeam, and the cmux path deliberately does not use it.
59
- Everywhere else this answers `tmux-pane`, which is what every run got before
60
- cmux existed. The answer is written to the run manifest and read back from
61
- there — consumers must not re-detect, or two phases of one run can disagree.
76
+ cmux is the only backend that gives a worker a surface of its own. Without
77
+ it a worker runs as a cli-wrapper subprocess, which owns no pane and is read
78
+ through its status sidecar instead. The answer is written to the run manifest
79
+ and read back from there — consumers must not re-detect, or two phases of one
80
+ run can disagree.
62
81
  """
63
82
  if cmux.cmux_available():
64
83
  return BACKEND_CMUX_PANE
65
- return BACKEND_TMUX_PANE
84
+ return BACKEND_CLI_WRAPPER
66
85
 
67
86
  # `livenessMode` picks which artifact answers "is this worker still alive": the
68
87
  # in-process worker's audit sidecar heartbeat, or the CLI wrapper's status
@@ -88,10 +107,26 @@ WORKER_STATUSES = frozenset(
88
107
  REASON_REQUIRED_STATUSES = frozenset({"timeout", "error", "not-run"})
89
108
 
90
109
 
110
+ def generate_claude_session_id() -> str:
111
+ """A UUIDv4 for `claude --session-id`.
112
+
113
+ The lead mints one for its own session (`run`) and dispatch mints one per
114
+ worker (`dispatch_session_id`); they are the same kind of value, so one
115
+ generator serves both. It sits here rather than in `session` because
116
+ `session` imports this module, so a worker-side call the other way would
117
+ close an import cycle.
118
+ """
119
+ return str(uuid.uuid4())
120
+
121
+
91
122
  class DispatchError(Exception):
92
123
  """Raised when a worker dispatch request cannot be executed."""
93
124
 
94
125
 
126
+ class IncompletePromptWriteArtifacts(DispatchError):
127
+ """Raised when a worker prompt omits the standard artifact headers."""
128
+
129
+
95
130
  @dataclass(frozen=True)
96
131
  class WorkerJob:
97
132
  worker_id: str
@@ -119,11 +154,23 @@ class WorkerJob:
119
154
  prompt_digest: str = ""
120
155
  host_model_value: str | None = None
121
156
  enforcement_mode: str = ""
157
+ # The session id dispatch mints and carries through to the provider CLI, so
158
+ # token collection can find the worker's jsonl by this value rather than by
159
+ # guessing at `agentName`. A worker started in a cmux pane is a separate CLI
160
+ # process and leaves no `agentName` behind, so without this its usage cannot
161
+ # be attributed at all.
162
+ session_id: str = ""
163
+ participant_ref: str = ""
164
+ role_execution_ref: str = ""
165
+ execution_label: str = ""
166
+ duty_id: str = ""
167
+ invocation_ref: str = ""
168
+ attempt: int = 0
122
169
 
123
170
  @property
124
171
  def command(self) -> list[str]:
125
172
  # The flag trails the positional contract the entrypoint reads by index.
126
- return [
173
+ argv = [
127
174
  str(self.wrapper_path),
128
175
  str(self.project_root),
129
176
  self.model_execution_value,
@@ -134,6 +181,14 @@ class WorkerJob:
134
181
  "--presentation",
135
182
  self._presentation(),
136
183
  ]
184
+ if self.has_execution_identity:
185
+ argv.extend([
186
+ "--invocation-metadata",
187
+ str(self.prompt_metadata_path),
188
+ ])
189
+ if self.session_id:
190
+ argv += ["--session-id", self.session_id]
191
+ return argv
137
192
 
138
193
  def _presentation(self) -> str:
139
194
  # A pane is a screen a person watches; a cli-wrapper dispatch's stdout is
@@ -143,7 +198,6 @@ class WorkerJob:
143
198
 
144
199
  def to_payload(self) -> dict[str, Any]:
145
200
  payload = {
146
- "workerId": self.worker_id,
147
201
  "provider": self.provider,
148
202
  "backend": self.backend,
149
203
  "modelExecutionValue": self.model_execution_value,
@@ -157,6 +211,19 @@ class WorkerJob:
157
211
  "dispatchKind": self.dispatch_kind,
158
212
  "command": self.command,
159
213
  }
214
+ if self.has_execution_identity:
215
+ payload.update({
216
+ "schemaVersion": "2.0",
217
+ "executionIdentityVersion": 2,
218
+ "participantRef": self.participant_ref,
219
+ "roleExecutionRef": self.role_execution_ref,
220
+ "executionLabel": self.execution_label,
221
+ "dutyId": self.duty_id,
222
+ "invocationRef": self.invocation_ref,
223
+ "attempt": self.attempt,
224
+ })
225
+ else:
226
+ payload.update({"schemaVersion": 1, "workerId": self.worker_id})
160
227
  if self.invocation_id:
161
228
  payload.update({
162
229
  "invocationId": self.invocation_id,
@@ -169,6 +236,22 @@ class WorkerJob:
169
236
  })
170
237
  return payload
171
238
 
239
+ @property
240
+ def has_execution_identity(self) -> bool:
241
+ values = (
242
+ self.participant_ref,
243
+ self.role_execution_ref,
244
+ self.execution_label,
245
+ self.duty_id,
246
+ self.invocation_ref,
247
+ )
248
+ present = tuple(bool(value) for value in values)
249
+ if any(present) and not all(present):
250
+ raise DispatchError("worker job has partial v2 execution identity")
251
+ if all(present) and self.attempt < 1:
252
+ raise DispatchError("worker job v2 attempt must be positive")
253
+ return all(present)
254
+
172
255
  @property
173
256
  def digests(self) -> dict[str, str]:
174
257
  return {
@@ -298,7 +381,23 @@ def append_worker_dispatch(
298
381
  team_state_path: Path,
299
382
  record: Mapping[str, Any],
300
383
  ) -> None:
301
- """Append one worker dispatch without losing another state writer's update."""
384
+ """Record one worker dispatch without losing another state writer's update.
385
+
386
+ A row carrying a dispatch id replaces the row that already holds it rather
387
+ than stacking beside it: an observed lead re-sent `okstra team dispatch` for
388
+ a prompt and attempt it had already sent, team-state kept both copies, and
389
+ `validate-run` failed that run with `agent dispatch ID is duplicated`.
390
+
391
+ The replaced row takes its `paneId` with it, and recorded ids are the only
392
+ reclaim candidates `team._reclaimable_panes` has, so that pane is left on
393
+ screen for the session. That is the price of the trade: before this, the
394
+ same re-send cost the whole run its report.
395
+
396
+ A job with no invocation carries no dispatch id, so it has no key to
397
+ collapse on and folding those rows would merge two unrelated dispatches into
398
+ one. They still stack, and `update_worker_dispatch_status` settles the
399
+ newest of them.
400
+ """
302
401
  with _team_state_lock(team_state_path):
303
402
  payload = load_json_object(team_state_path, "team-state")
304
403
  dispatches = payload.setdefault("workerDispatches", [])
@@ -306,7 +405,20 @@ def append_worker_dispatch(
306
405
  raise DispatchError(
307
406
  f"team-state workerDispatches must be an array: {team_state_path}"
308
407
  )
309
- dispatches.append(dict(record))
408
+ new_record = dict(record)
409
+ new_id = str(new_record.get("dispatchId") or "")
410
+ replaced = False
411
+ if new_id:
412
+ for index, existing in enumerate(dispatches):
413
+ if (
414
+ isinstance(existing, Mapping)
415
+ and existing.get("dispatchId") == new_id
416
+ ):
417
+ dispatches[index] = new_record
418
+ replaced = True
419
+ break
420
+ if not replaced:
421
+ dispatches.append(new_record)
310
422
  write_json(team_state_path, payload)
311
423
 
312
424
 
@@ -388,9 +500,22 @@ def record_verified_agent_dispatch(
388
500
  assignment = agent_model_assignment_from_payload(assignments.get(assignment_ref))
389
501
  except AgentInvocationError as exc:
390
502
  raise DispatchError(str(exc)) from exc
391
- worker_id = require_string(metadata, "workerId")
503
+ try:
504
+ execution_identity = invocation_metadata_identity(metadata)
505
+ except AgentInvocationError as exc:
506
+ raise DispatchError(str(exc)) from exc
507
+ worker_id = (
508
+ require_string(metadata, "workerId")
509
+ if execution_identity is None
510
+ else None
511
+ )
392
512
  audience = require_string(metadata, "audience")
393
513
  invocation_id = require_string(metadata, "invocationId")
514
+ if (
515
+ execution_identity is not None
516
+ and execution_identity.invocation_ref != invocation_id
517
+ ):
518
+ raise DispatchError("v2 invocation reference does not match invocation ID")
394
519
  errors = verify_agent_invocation(
395
520
  metadata_path,
396
521
  project_root=project_root,
@@ -400,6 +525,15 @@ def record_verified_agent_dispatch(
400
525
  expected_worker_id=worker_id,
401
526
  expected_assignment_ref=assignment_ref,
402
527
  expected_audience=audience,
528
+ expected_participant_ref=(
529
+ execution_identity.participant_ref if execution_identity else None
530
+ ),
531
+ expected_role_execution_ref=(
532
+ execution_identity.role_execution_ref if execution_identity else None
533
+ ),
534
+ expected_invocation_ref=(
535
+ execution_identity.invocation_ref if execution_identity else None
536
+ ),
403
537
  )
404
538
  if errors:
405
539
  raise DispatchError("agent invocation verification failed: " + "; ".join(errors))
@@ -428,10 +562,15 @@ def record_verified_agent_dispatch(
428
562
  if not isinstance(prompt, Mapping):
429
563
  raise DispatchError("agent invocation prompt is missing")
430
564
  prompt_path = require_string(prompt, "path")
431
- dispatch_id = f"{invocation_id}:attempt-1"
565
+ # Identity-bearing records carry the stored attempt. A path without
566
+ # identity still records the first attempt; the conflict guard below
567
+ # refuses a second write under that id.
568
+ dispatch_id = build_dispatch_id(
569
+ invocation_id,
570
+ execution_identity.attempt if execution_identity else 1,
571
+ )
432
572
  record = {
433
573
  "dispatchId": dispatch_id,
434
- "workerId": worker_id,
435
574
  "audience": audience,
436
575
  "invocationId": invocation_id,
437
576
  "assignmentRef": assignment_ref,
@@ -444,7 +583,99 @@ def record_verified_agent_dispatch(
444
583
  "promptDeliveryVerified": False,
445
584
  "status": "dispatched",
446
585
  }
586
+ if execution_identity is None:
587
+ record["workerId"] = worker_id
588
+ else:
589
+ record.update({
590
+ "schemaVersion": "2.0",
591
+ "executionIdentityVersion": 2,
592
+ "invocationRef": execution_identity.invocation_ref,
593
+ "participantRef": execution_identity.participant_ref,
594
+ "roleExecutionRef": execution_identity.role_execution_ref,
595
+ "executionLabel": execution_identity.execution_label,
596
+ "dutyId": execution_identity.duty_id,
597
+ "attempt": execution_identity.attempt,
598
+ "dispatchKind": require_string(metadata, "dispatchKind"),
599
+ })
447
600
  team_state_path = resolve_required_path(project_root, manifest, "teamStatePath")
601
+ write_policy = None
602
+ write_enforcement = None
603
+ mutation_snapshot_path = metadata_path.with_suffix(
604
+ metadata_path.suffix + ".mutation-audit.json"
605
+ )
606
+ if execution_identity is not None:
607
+ write_policy, write_enforcement = _agent_write_contract(
608
+ project_root,
609
+ run_manifest_path,
610
+ manifest,
611
+ execution_identity.role_execution_ref,
612
+ resolve_project_path(project_root, prompt_path),
613
+ assignment_ref,
614
+ )
615
+ record.update({
616
+ "writePolicyDigest": write_policy.digest,
617
+ "writeEnforcement": write_enforcement.to_payload(),
618
+ "mutationAuditSnapshotPath": (
619
+ _relative_project_path(project_root, mutation_snapshot_path)
620
+ if write_enforcement.mutation_audit == "batch"
621
+ else ""
622
+ ),
623
+ })
624
+ existing_state = load_json_object(team_state_path, "team-state")
625
+ existing = [
626
+ item
627
+ for item in existing_state.get("agentDispatches") or []
628
+ if isinstance(item, Mapping) and item.get("dispatchId") == dispatch_id
629
+ ]
630
+ if existing:
631
+ if len(existing) != 1 or dict(existing[0]) != record:
632
+ raise DispatchError(f"agent dispatch ID conflicts: {dispatch_id}")
633
+ return record
634
+ if execution_identity is not None:
635
+ dispatch_kind = require_string(metadata, "dispatchKind")
636
+ prompt_digest = require_string(digests, "promptDigest")
637
+ if write_policy is None or write_enforcement is None:
638
+ raise DispatchError("agent write contract was not prepared")
639
+ record_invocation_attempt(
640
+ run_manifest_path,
641
+ Invocation(
642
+ invocation_ref=execution_identity.invocation_ref,
643
+ participant_ref=execution_identity.participant_ref,
644
+ role_execution_ref=execution_identity.role_execution_ref,
645
+ source_invocation_ref=None,
646
+ recovery_ref=None,
647
+ duty_id=execution_identity.duty_id,
648
+ dispatch_kind=dispatch_kind,
649
+ round=_execution_dispatch_round(dispatch_kind),
650
+ input_digest=prompt_digest,
651
+ write_policy=write_policy.to_payload(),
652
+ write_policy_digest=write_policy.digest,
653
+ write_enforcement=write_enforcement.to_payload(),
654
+ ),
655
+ Attempt(
656
+ invocation_ref=execution_identity.invocation_ref,
657
+ attempt=execution_identity.attempt,
658
+ started_at=utc_now(),
659
+ finished_at=None,
660
+ status="started",
661
+ result_path=None,
662
+ error_path=None,
663
+ change_summary={},
664
+ evidence_seal_ref=None,
665
+ ),
666
+ task_key=require_string(manifest, "taskKey"),
667
+ )
668
+ if write_enforcement.mutation_audit == "batch":
669
+ snapshot = ExecutionMutationAudit().snapshot(
670
+ (write_policy,),
671
+ orchestrator_paths=(
672
+ run_manifest_path,
673
+ team_state_path,
674
+ Path(f"{team_state_path}.lock"),
675
+ mutation_snapshot_path,
676
+ ),
677
+ )
678
+ write_json_atomic(mutation_snapshot_path, snapshot.to_payload())
448
679
  with _team_state_lock(team_state_path):
449
680
  team_state = load_json_object(team_state_path, "team-state")
450
681
  dispatches = team_state.setdefault("agentDispatches", [])
@@ -463,13 +694,191 @@ def record_verified_agent_dispatch(
463
694
  return record
464
695
 
465
696
 
697
+ def _agent_write_contract(
698
+ project_root: Path,
699
+ manifest_path: Path,
700
+ authority: Mapping[str, Any],
701
+ role_execution_ref: str,
702
+ prompt_path: Path,
703
+ assignment_ref: str,
704
+ ):
705
+ execution_manifest = read_execution_manifest(manifest_path)
706
+ execution = next(
707
+ (
708
+ row for row in execution_manifest.role_executions
709
+ if row.role_execution_ref == role_execution_ref
710
+ ),
711
+ None,
712
+ )
713
+ capability = (
714
+ execution.binding.worker_write_capability
715
+ if execution is not None and execution.binding is not None
716
+ else None
717
+ )
718
+ if execution is None:
719
+ raise DispatchError("agent write policy has no role execution")
720
+ if execution.role == "leader":
721
+ artifacts = _leader_artifact_paths(project_root, authority)
722
+ worktree = None
723
+ maximum_precision = "none"
724
+ elif capability is not None:
725
+ try:
726
+ artifacts, worktree = _prompt_write_paths(project_root, prompt_path)
727
+ except IncompletePromptWriteArtifacts:
728
+ artifacts, worktree = _manifest_worker_write_paths(
729
+ project_root, authority, assignment_ref, prompt_path
730
+ )
731
+ maximum_precision = capability.max_boundary_precision
732
+ else:
733
+ raise DispatchError("agent write policy has no runner capability")
734
+ try:
735
+ return build_invocation_write_contract(
736
+ role=execution.role,
737
+ project_root=project_root,
738
+ worktree=worktree,
739
+ artifact_paths=artifacts,
740
+ maximum_precision=maximum_precision,
741
+ planned_paths=(
742
+ planned_paths_from_run_manifest(project_root, authority)
743
+ if execution.role == "implementer"
744
+ else ()
745
+ ),
746
+ auxiliary_roots=verifier_extra_dirs(execution.role),
747
+ validated_auxiliary_roots=verifier_extra_dirs(execution.role),
748
+ )
749
+ except (OSError, ValueError) as exc:
750
+ raise DispatchError(f"agent write policy is invalid: {exc}") from exc
751
+
752
+
753
+ def _leader_artifact_paths(
754
+ project_root: Path, authority: Mapping[str, Any]
755
+ ) -> tuple[Path, ...]:
756
+ value = authority.get("taskRootPath") or authority.get("runDirectoryPath")
757
+ if not isinstance(value, str) or not value.strip():
758
+ raise DispatchError("leader write policy has no artifact root")
759
+ artifact_home = project_root / ".okstra"
760
+ if not artifact_home.is_dir():
761
+ raise DispatchError("leader write policy artifact home does not exist")
762
+ return (artifact_home,)
763
+
764
+
765
+ def _manifest_worker_write_paths(
766
+ project_root: Path,
767
+ authority: Mapping[str, Any],
768
+ assignment_ref: str,
769
+ prompt_path: Path,
770
+ ) -> tuple[tuple[Path, ...], Path | None]:
771
+ prefix = "initial/"
772
+ if not assignment_ref.startswith(prefix):
773
+ raise DispatchError("agent prompt write artifact headers are incomplete")
774
+ worker_id = assignment_ref.removeprefix(prefix)
775
+ team_state_path = resolve_required_path(
776
+ project_root, authority, "teamStatePath"
777
+ )
778
+ team_state = load_json_object(team_state_path, "team-state")
779
+ matches = [
780
+ row for row in team_state.get("workers") or []
781
+ if isinstance(row, Mapping) and row.get("workerId") == worker_id
782
+ ]
783
+ if len(matches) != 1:
784
+ raise DispatchError("agent prompt has no unique worker artifact authority")
785
+ result = resolve_project_path(
786
+ project_root, require_string(matches[0], "resultPath")
787
+ )
788
+ paths = {
789
+ *dispatch_completion_paths(worker_id, result, authority, project_root),
790
+ result,
791
+ Path(audit_sidecar_rel(str(result))),
792
+ status_path_for_prompt(prompt_path),
793
+ prompt_path.with_suffix(prompt_path.suffix + ".log"),
794
+ }
795
+ active_value = authority.get("activeRunContextPath")
796
+ active = (
797
+ load_json_object(
798
+ resolve_project_path(project_root, active_value),
799
+ "active run context",
800
+ )
801
+ if isinstance(active_value, str) and active_value
802
+ else {}
803
+ )
804
+ error_logs = active.get("errorLogs")
805
+ sidecars = (
806
+ error_logs.get("sidecarsByWorkerId")
807
+ if isinstance(error_logs, Mapping)
808
+ else None
809
+ )
810
+ error_value = sidecars.get(worker_id) if isinstance(sidecars, Mapping) else None
811
+ if isinstance(error_value, str) and error_value:
812
+ paths.add(resolve_project_path(project_root, error_value))
813
+ worktree_value = worktree_path(authority, active)
814
+ worktree = Path(worktree_value) if worktree_value else None
815
+ return tuple(sorted(paths, key=str)), worktree
816
+
817
+
818
+ def _prompt_write_paths(
819
+ project_root: Path, prompt_path: Path
820
+ ) -> tuple[tuple[Path, ...], Path | None]:
821
+ try:
822
+ lines = prompt_path.read_text(encoding="utf-8").splitlines()
823
+ except (OSError, UnicodeError) as exc:
824
+ raise DispatchError("agent prompt cannot be read for write policy") from exc
825
+ values: dict[str, str] = {}
826
+ labels = {
827
+ "Result Path",
828
+ "Worker Result Path",
829
+ "Audit sidecar path",
830
+ "Errors sidecar path",
831
+ "Worktree",
832
+ }
833
+ for line in lines:
834
+ for label in labels:
835
+ prefix = f"**{label}:** "
836
+ if line.startswith(prefix):
837
+ values[label] = line.removeprefix(prefix).strip()
838
+ required = {"Result Path", "Audit sidecar path", "Errors sidecar path"}
839
+ if not required <= values.keys():
840
+ raise IncompletePromptWriteArtifacts(
841
+ "agent prompt write artifact headers are incomplete"
842
+ )
843
+ artifact_values = [values[label] for label in sorted(required)]
844
+ if "Worker Result Path" in values:
845
+ artifact_values.append(values["Worker Result Path"])
846
+ artifacts = {
847
+ _project_or_absolute(project_root, value) for value in artifact_values
848
+ }
849
+ artifacts.add(status_path_for_prompt(prompt_path))
850
+ artifacts.add(prompt_path.with_suffix(prompt_path.suffix + ".log"))
851
+ worktree_value = values.get("Worktree")
852
+ worktree = (
853
+ _project_or_absolute(project_root, worktree_value)
854
+ if worktree_value
855
+ else None
856
+ )
857
+ return tuple(sorted(artifacts, key=str)), worktree
858
+
859
+
860
+ def _project_or_absolute(project_root: Path, value: str) -> Path:
861
+ path = Path(value)
862
+ return path if path.is_absolute() else project_root / path
863
+
864
+
865
+ def _execution_dispatch_round(dispatch_kind: str) -> int:
866
+ prefix = "reverify-r"
867
+ if not dispatch_kind.startswith(prefix):
868
+ return 1
869
+ suffix = dispatch_kind[len(prefix):]
870
+ if not suffix.isdigit() or int(suffix) < 1:
871
+ raise DispatchError(f"invalid reverify dispatch kind: {dispatch_kind}")
872
+ return int(suffix)
873
+
874
+
466
875
  def link_agent_dispatch_result(
467
876
  *,
468
877
  project_root: Path,
469
878
  run_manifest_path: Path,
470
879
  dispatch_id: str,
471
880
  result_path: Path,
472
- ) -> dict[str, str]:
881
+ ) -> dict[str, Any]:
473
882
  """Link an existing result artifact to exactly one verified dispatch."""
474
883
  project_root = project_root.resolve()
475
884
  manifest_path = resolve_project_path(
@@ -486,7 +895,20 @@ def link_agent_dispatch_result(
486
895
  raise DispatchError("agent result link must stay inside the current run")
487
896
  result_relative = _relative_project_path(project_root, resolved_result)
488
897
  team_state_path = resolve_required_path(project_root, manifest, "teamStatePath")
489
- link = {"dispatchId": dispatch_id, "resultPath": result_relative}
898
+ preview = load_json_object(team_state_path, "team-state")
899
+ native_dispatches = [
900
+ item for item in preview.get("agentDispatches") or []
901
+ if isinstance(item, Mapping) and item.get("dispatchId") == dispatch_id
902
+ ]
903
+ if len(native_dispatches) == 1:
904
+ _finish_native_agent_attempt(
905
+ project_root=project_root,
906
+ manifest_path=manifest_path,
907
+ manifest=manifest,
908
+ dispatch=native_dispatches[0],
909
+ result_path=resolved_result,
910
+ result_relative=result_relative,
911
+ )
490
912
  with _team_state_lock(team_state_path):
491
913
  team_state = load_json_object(team_state_path, "team-state")
492
914
  dispatches = [
@@ -502,6 +924,21 @@ def link_agent_dispatch_result(
502
924
  raise DispatchError(
503
925
  f"agent result link requires exactly one dispatch record: {dispatch_id}"
504
926
  )
927
+ dispatch = dispatches[0]
928
+ link: dict[str, Any] = {
929
+ "dispatchId": dispatch_id,
930
+ "resultPath": result_relative,
931
+ }
932
+ identity = worker_execution_identity(dispatch)
933
+ if identity is not None:
934
+ link.update({
935
+ "participantRef": identity["participant_ref"],
936
+ "roleExecutionRef": identity["role_execution_ref"],
937
+ "executionLabel": identity["execution_label"],
938
+ "dutyId": identity["duty_id"],
939
+ "invocationRef": identity["invocation_ref"],
940
+ "attempt": identity["attempt"],
941
+ })
505
942
  links = team_state.setdefault("agentResultLinks", [])
506
943
  if not isinstance(links, list):
507
944
  raise DispatchError("team-state agentResultLinks must be an array")
@@ -539,6 +976,137 @@ def link_agent_dispatch_result(
539
976
  return link
540
977
 
541
978
 
979
+ def _finish_native_agent_attempt(
980
+ *,
981
+ project_root: Path,
982
+ manifest_path: Path,
983
+ manifest: Mapping[str, Any],
984
+ dispatch: Mapping[str, Any],
985
+ result_path: Path,
986
+ result_relative: str,
987
+ ) -> None:
988
+ identity = worker_execution_identity(dispatch)
989
+ if identity is None:
990
+ return
991
+ execution = read_execution_manifest(manifest_path)
992
+ invocation = next(
993
+ (
994
+ row for row in execution.invocations
995
+ if row.invocation_ref == identity["invocation_ref"]
996
+ ),
997
+ None,
998
+ )
999
+ if invocation is None:
1000
+ raise DispatchError("agent result has no canonical invocation policy")
1001
+ if dispatch.get("writePolicyDigest") != invocation.write_policy_digest:
1002
+ raise DispatchError("agent dispatch write policy digest changed")
1003
+ if dispatch.get("writeEnforcement") != invocation.write_enforcement:
1004
+ raise DispatchError("agent dispatch write enforcement changed")
1005
+ policy = write_policy_from_payload(invocation.write_policy)
1006
+ snapshot_value = string_value(dispatch.get("mutationAuditSnapshotPath"))
1007
+ if snapshot_value:
1008
+ snapshot_payload = load_json_object(
1009
+ resolve_project_path(project_root, snapshot_value),
1010
+ "mutation audit snapshot",
1011
+ )
1012
+ try:
1013
+ snapshot = MutationSnapshot.from_payload(snapshot_payload)
1014
+ except (KeyError, TypeError, ValueError) as exc:
1015
+ raise DispatchError("mutation audit snapshot is invalid") from exc
1016
+ result = ExecutionMutationAudit().compare(
1017
+ snapshot,
1018
+ (policy,),
1019
+ out_of_plan_edits=_native_out_of_plan_paths(result_path),
1020
+ attempt_succeeded=True,
1021
+ result_present=True,
1022
+ )
1023
+ summary = result.change_summary()
1024
+ status = result.status
1025
+ else:
1026
+ status = "ok"
1027
+ summary = {
1028
+ "mutationStatus": "ok",
1029
+ "changedPaths": [],
1030
+ "sourceChanged": False,
1031
+ "gitChanged": False,
1032
+ "attribution": "call",
1033
+ "retryAllowed": False,
1034
+ "violations": [],
1035
+ "beforeDigest": "",
1036
+ "afterDigest": "",
1037
+ }
1038
+ _close_native_attempt(
1039
+ manifest_path,
1040
+ manifest,
1041
+ identity,
1042
+ status,
1043
+ result_relative,
1044
+ summary,
1045
+ )
1046
+ if status != "ok":
1047
+ violations = summary.get("violations")
1048
+ detail = "; ".join(violations) if isinstance(violations, list) else status
1049
+ raise DispatchError(f"agent mutation audit failed: {detail or status}")
1050
+
1051
+
1052
+ def _close_native_attempt(
1053
+ manifest_path: Path,
1054
+ manifest: Mapping[str, Any],
1055
+ identity: Mapping[str, Any],
1056
+ status: str,
1057
+ result_path: str,
1058
+ summary: Mapping[str, Any],
1059
+ ) -> None:
1060
+ execution = read_execution_manifest(manifest_path)
1061
+ existing = next(
1062
+ (
1063
+ row for row in execution.attempts
1064
+ if row.invocation_ref == identity["invocation_ref"]
1065
+ and row.attempt == identity["attempt"]
1066
+ ),
1067
+ None,
1068
+ )
1069
+ if existing is not None and existing.finished_at is not None:
1070
+ if (
1071
+ existing.status == status
1072
+ and existing.result_path == result_path
1073
+ and dict(existing.change_summary) == dict(summary)
1074
+ ):
1075
+ return
1076
+ raise DispatchError("agent attempt terminal mutation result drift")
1077
+ finish_attempt_mutation(
1078
+ manifest_path,
1079
+ invocation_ref=identity["invocation_ref"],
1080
+ attempt=identity["attempt"],
1081
+ finished_at=utc_now(),
1082
+ status=status,
1083
+ result_path=result_path,
1084
+ error_path=None,
1085
+ change_summary=summary,
1086
+ task_key=require_string(manifest, "taskKey"),
1087
+ )
1088
+
1089
+
1090
+ def _native_out_of_plan_paths(result_path: Path) -> tuple[str, ...]:
1091
+ if result_path.suffix != ".json":
1092
+ return ()
1093
+ try:
1094
+ payload = json.loads(result_path.read_text(encoding="utf-8"))
1095
+ except (OSError, UnicodeError, json.JSONDecodeError):
1096
+ return ()
1097
+ implementation = payload.get("implementation") if isinstance(payload, Mapping) else None
1098
+ rows = implementation.get("outOfPlanEdits") if isinstance(implementation, Mapping) else None
1099
+ if not isinstance(rows, list):
1100
+ return ()
1101
+ return tuple(
1102
+ str(row["file"])
1103
+ for row in rows
1104
+ if isinstance(row, Mapping)
1105
+ and isinstance(row.get("file"), str)
1106
+ and row["file"]
1107
+ )
1108
+
1109
+
542
1110
  def reject_agent_dispatch_result(
543
1111
  *,
544
1112
  project_root: Path,
@@ -712,6 +1280,25 @@ def _has_recorded_team_create(team_state: Mapping[str, Any]) -> bool:
712
1280
 
713
1281
  # --- job facts ----------------------------------------------------------------
714
1282
 
1283
+ def build_dispatch_id(invocation_id: str, attempt: int) -> str:
1284
+ """The only place Python assembles a dispatch id.
1285
+
1286
+ Two generators drifted: this module pinned the attempt to 1 while
1287
+ `dispatch_core` used the real one, so a re-dispatch took the first attempt's
1288
+ id, team-state kept the same row twice, and `validate-run` failed that run
1289
+ with `agent dispatch ID is duplicated`.
1290
+
1291
+ Prose mirrors of this shape live outside Python, where no guard reaches
1292
+ them: `prompts/lead/convergence.md`, `prompts/lead/report-writer.md`,
1293
+ `prompts/lead/plan-body-verification.md` and the five
1294
+ `adapters/hosts/*/relay.md` each spell an `--dispatch-id` argument out for
1295
+ the lead. They agree with this function only because
1296
+ `record_verified_agent_dispatch` records the first attempt and no other;
1297
+ changing that convention has to change them too.
1298
+ """
1299
+ return f"{invocation_id}:attempt-{attempt}"
1300
+
1301
+
715
1302
  def dispatch_mode(jobs: Sequence[WorkerJob]) -> str:
716
1303
  backends = {job.backend for job in jobs}
717
1304
  if len(backends) == 1:
@@ -723,6 +1310,59 @@ def missing_completion_paths(job: WorkerJob) -> tuple[Path, ...]:
723
1310
  return tuple(path for path in job.completion_paths if not path.is_file())
724
1311
 
725
1312
 
1313
+ def dispatch_session_id(provider: str) -> str:
1314
+ """The session this dispatch mints for *provider*, or '' for one that has no
1315
+ such flag.
1316
+
1317
+ Only the claude CLI reads `--session-id`; handing the value to another
1318
+ wrapper would start it with an argument it does not know. An empty value is
1319
+ the same as not issuing one — `WorkerJob.command` appends the flag only when
1320
+ it is set.
1321
+
1322
+ This lives beside `worker_jobs_from_file` for the same reason
1323
+ `dispatch_result_path` does: both job constructors apply it, and a rule
1324
+ written twice is a rule that drifts.
1325
+ """
1326
+ return generate_claude_session_id() if provider == "claude" else ""
1327
+
1328
+
1329
+ def worker_session_ids(
1330
+ team_state: Mapping[str, Any], worker_id: str | None = None
1331
+ ) -> list[str]:
1332
+ """The claude sessions this run's dispatches issued, in the order recorded.
1333
+
1334
+ A worker started in a cmux pane is its own `claude -p` process and tags its
1335
+ session jsonl with neither `agentName` nor `teamName`, so nothing in the
1336
+ transcript says which worker wrote it. The id `dispatch_session_id` minted
1337
+ and `_dispatch_record` wrote is the only handle on it.
1338
+
1339
+ Every id, not one: a retry opens a new session, so one worker legitimately
1340
+ owns several rows with different `sessionId` values, and taking only the
1341
+ first or last bills a single attempt of a retried worker.
1342
+
1343
+ Pass ``worker_id`` for one worker's sessions, or omit it for the run's.
1344
+ This reads a `workerDispatches[]` row shape, so it belongs beside the code
1345
+ that writes that shape rather than in each of the two modules that read it.
1346
+
1347
+ A non-list log yields nothing rather than raising: `session.observe_lead_session`
1348
+ calls this for its exclusion set and catches only OSError, so a `TypeError`
1349
+ from iterating a number here would take down `team dispatch` / `team await` /
1350
+ `report-finalize`. Observation not breaking its caller is a contract kept
1351
+ here, the same way `add_observed_session` keeps it for the keys it appends to.
1352
+ """
1353
+ session_ids: list[str] = []
1354
+ dispatches = team_state.get("workerDispatches")
1355
+ for record in dispatches if isinstance(dispatches, list) else []:
1356
+ if not isinstance(record, Mapping):
1357
+ continue
1358
+ if worker_id is not None and record.get("workerId") != worker_id:
1359
+ continue
1360
+ session_id = str(record.get("sessionId") or "").strip()
1361
+ if session_id and session_id not in session_ids:
1362
+ session_ids.append(session_id)
1363
+ return session_ids
1364
+
1365
+
726
1366
  def dispatch_result_path(
727
1367
  worker_id: str,
728
1368
  worker_result_path: Path,
@@ -921,6 +1561,121 @@ def _validate_agent_invocations(
921
1561
  raise DispatchError("agent invocation contract: " + "; ".join(errors))
922
1562
 
923
1563
 
1564
+ def _v2_job_authority_errors(
1565
+ job: WorkerJob,
1566
+ metadata: Mapping[str, Any],
1567
+ manifest: Mapping[str, Any],
1568
+ ) -> list[str]:
1569
+ try:
1570
+ identity = invocation_metadata_identity(metadata)
1571
+ except AgentInvocationError as exc:
1572
+ return [str(exc)]
1573
+ if identity is None:
1574
+ return ["jobs file v2 identity has v1 invocation metadata"]
1575
+ errors: list[str] = []
1576
+ if job.invocation_ref != identity.invocation_ref:
1577
+ errors.append("jobs file invocationRef does not match invocation metadata")
1578
+ if (
1579
+ job.participant_ref != identity.participant_ref
1580
+ or job.role_execution_ref != identity.role_execution_ref
1581
+ or job.execution_label != identity.execution_label
1582
+ or job.duty_id != identity.duty_id
1583
+ or job.attempt != identity.attempt
1584
+ ):
1585
+ errors.append("jobs file v2 identity does not match invocation metadata")
1586
+ prompt = metadata.get("prompt")
1587
+ metadata_prompt_value = (
1588
+ string_value(prompt.get("path")) if isinstance(prompt, Mapping) else ""
1589
+ )
1590
+ metadata_prompt_path = resolve_project_path(
1591
+ job.project_root, metadata_prompt_value
1592
+ )
1593
+ if (
1594
+ not metadata_prompt_value
1595
+ or job.prompt_path.resolve() != metadata_prompt_path.resolve()
1596
+ ):
1597
+ errors.append("jobs file promptPath does not match invocation metadata")
1598
+ role_executions = manifest.get("roleExecutions")
1599
+ execution = next((
1600
+ row for row in role_executions
1601
+ if isinstance(row, Mapping)
1602
+ and row.get("roleExecutionRef") == identity.role_execution_ref
1603
+ ), None) if isinstance(role_executions, list) else None
1604
+ participants = manifest.get("participantAssignments")
1605
+ participant = next((
1606
+ row for row in participants
1607
+ if isinstance(row, Mapping)
1608
+ and row.get("participantRef") == identity.participant_ref
1609
+ ), None) if isinstance(participants, list) else None
1610
+ assignment = metadata.get("modelAssignment")
1611
+ try:
1612
+ duty_role = role_for_duty(job.duty_id)
1613
+ except RoleCatalogError:
1614
+ duty_role = ""
1615
+ if isinstance(assignment, Mapping):
1616
+ try:
1617
+ parsed_assignment = agent_model_assignment_from_payload(assignment)
1618
+ except AgentInvocationError as exc:
1619
+ errors.append(str(exc))
1620
+ else:
1621
+ errors.extend(
1622
+ "jobs file " + error
1623
+ for error in v2_role_assignment_authority_errors(
1624
+ manifest,
1625
+ role_execution_ref=identity.role_execution_ref,
1626
+ participant_ref=identity.participant_ref,
1627
+ assignment=parsed_assignment,
1628
+ )
1629
+ )
1630
+ if (
1631
+ not isinstance(execution, Mapping)
1632
+ or not isinstance(participant, Mapping)
1633
+ or not isinstance(assignment, Mapping)
1634
+ or execution.get("participantRef") != job.participant_ref
1635
+ or execution.get("executionLabel") != job.execution_label
1636
+ or execution.get("role") != job.role
1637
+ or execution.get("role") != duty_role
1638
+ or execution.get("provider") != job.provider
1639
+ or assignment.get("modelExecutionValue") != job.model_execution_value
1640
+ or metadata.get("dispatchKind") != job.dispatch_kind
1641
+ ):
1642
+ errors.append("jobs file v2 identity does not match role execution authority")
1643
+ return errors
1644
+
1645
+
1646
+ def _validate_v2_jobs_file_authority(
1647
+ manifest: Mapping[str, Any], jobs: Sequence[WorkerJob]
1648
+ ) -> None:
1649
+ if (
1650
+ manifest.get("schemaVersion") != "2.0"
1651
+ or manifest.get("executionIdentityVersion") != 2
1652
+ ):
1653
+ return
1654
+ _validate_agent_invocations(manifest, jobs)
1655
+ errors: list[str] = []
1656
+ for job in jobs:
1657
+ metadata = load_json_object(job.prompt_metadata_path, "agent metadata")
1658
+ errors.extend(
1659
+ f"{job.worker_id}: {error}"
1660
+ for error in _v2_job_authority_errors(job, metadata, manifest)
1661
+ )
1662
+ if errors:
1663
+ raise DispatchError("jobs file v2 authority: " + "; ".join(errors))
1664
+ role_executions = {
1665
+ string_value(row.get("roleExecutionRef")): row
1666
+ for row in manifest.get("roleExecutions") or []
1667
+ if isinstance(row, Mapping)
1668
+ }
1669
+ if any(
1670
+ isinstance(role_executions.get(job.role_execution_ref), Mapping)
1671
+ and role_executions[job.role_execution_ref].get("role") == "translator"
1672
+ for job in jobs
1673
+ ):
1674
+ raise DispatchError(
1675
+ "jobs file translator must use canonical reservation dispatch"
1676
+ )
1677
+
1678
+
924
1679
  def worker_jobs_from_file(
925
1680
  project_root: Path,
926
1681
  jobs_file: Path,
@@ -940,7 +1695,7 @@ def worker_jobs_from_file(
940
1695
  workers = payload.get("workers")
941
1696
  if not isinstance(workers, list):
942
1697
  raise DispatchError("jobs file workers must be an array")
943
- return [
1698
+ jobs = [
944
1699
  _worker_job_from_file(
945
1700
  project_root,
946
1701
  item,
@@ -954,6 +1709,8 @@ def worker_jobs_from_file(
954
1709
  for item in workers
955
1710
  if isinstance(item, Mapping)
956
1711
  ]
1712
+ _validate_v2_jobs_file_authority(manifest, jobs)
1713
+ return jobs
957
1714
 
958
1715
 
959
1716
  def _worker_job_from_file(
@@ -967,8 +1724,17 @@ def _worker_job_from_file(
967
1724
  resolve_wrapper: Callable[[str], Path],
968
1725
  default_provider: Callable[[str], str],
969
1726
  ) -> WorkerJob:
970
- worker_id = require_string(item, "workerId")
971
- provider = string_value(item.get("provider")) or default_provider(worker_id)
1727
+ identity = worker_execution_identity(item)
1728
+ worker_id = (
1729
+ require_string(item, "workerId")
1730
+ if identity is None
1731
+ else v2_worker_state_key(item)
1732
+ )
1733
+ provider = (
1734
+ string_value(item.get("provider")) or default_provider(worker_id)
1735
+ if identity is None
1736
+ else require_string(item, "provider")
1737
+ )
972
1738
  prompt_path = resolve_project_path(
973
1739
  project_root, require_string(item, "promptPath")
974
1740
  )
@@ -1006,6 +1772,10 @@ def _worker_job_from_file(
1006
1772
  role=require_string(item, "role"),
1007
1773
  idle_timeout_seconds=idle_timeout_seconds,
1008
1774
  dispatch_kind=dispatch_kind,
1775
+ # Minted here, not read from the file: the lead writes this file by
1776
+ # hand, and a session id it typed would be unfalsifiable — nothing can
1777
+ # tell an id that names no session from one that names the wrong one.
1778
+ session_id=dispatch_session_id(provider),
1009
1779
  invocation_id=string_value(item.get("invocationId")),
1010
1780
  audience=string_value(item.get("audience")),
1011
1781
  assignment_ref=string_value(item.get("assignmentRef")),
@@ -1020,9 +1790,52 @@ def _worker_job_from_file(
1020
1790
  prompt_digest=string_value(digest_values.get("promptDigest")),
1021
1791
  host_model_value=host_model_value,
1022
1792
  enforcement_mode=string_value(item.get("enforcementMode")),
1793
+ **(identity or {}),
1023
1794
  )
1024
1795
 
1025
1796
 
1797
+ def worker_execution_identity(
1798
+ item: Mapping[str, Any],
1799
+ ) -> dict[str, Any] | None:
1800
+ identity_keys = {
1801
+ "executionIdentityVersion",
1802
+ "participantRef",
1803
+ "roleExecutionRef",
1804
+ "executionLabel",
1805
+ "dutyId",
1806
+ "invocationRef",
1807
+ }
1808
+ schema = item.get("schemaVersion")
1809
+ has_v2_fields = bool(identity_keys.intersection(item))
1810
+ if schema in (None, 1, "1", "1.0"):
1811
+ if has_v2_fields:
1812
+ raise DispatchError("jobs file worker mixes v1 and v2 identity")
1813
+ return None
1814
+ if schema != "2.0" or item.get("executionIdentityVersion") != 2:
1815
+ raise DispatchError("jobs file worker has unsupported identity version")
1816
+ if "workerId" in item:
1817
+ raise DispatchError("jobs file worker mixes v1 and v2 identity")
1818
+ attempt = item.get("attempt")
1819
+ if not isinstance(attempt, int) or isinstance(attempt, bool) or attempt < 1:
1820
+ raise DispatchError("jobs file v2 attempt must be positive")
1821
+ return {
1822
+ "participant_ref": require_string(item, "participantRef"),
1823
+ "role_execution_ref": require_string(item, "roleExecutionRef"),
1824
+ "execution_label": require_string(item, "executionLabel"),
1825
+ "duty_id": require_string(item, "dutyId"),
1826
+ "invocation_ref": require_string(item, "invocationRef"),
1827
+ "attempt": attempt,
1828
+ }
1829
+
1830
+
1831
+ def v2_worker_state_key(item: Mapping[str, Any]) -> str:
1832
+ assignment_ref = require_string(item, "assignmentRef")
1833
+ worker_id = assignment_ref.rsplit("/", 1)[-1]
1834
+ if not worker_id:
1835
+ raise DispatchError("jobs file v2 assignmentRef has no worker projection")
1836
+ return worker_id
1837
+
1838
+
1026
1839
  def utc_now() -> str:
1027
1840
  return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
1028
1841