@provos/ironcurtain 0.11.0 → 0.13.0

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 (472) hide show
  1. package/README.md +23 -19
  2. package/dist/cli-help.d.ts +12 -0
  3. package/dist/cli-help.js +21 -0
  4. package/dist/cli-help.js.map +1 -1
  5. package/dist/cli.js +14 -6
  6. package/dist/cli.js.map +1 -1
  7. package/dist/config/agent-model-guard.d.ts +14 -0
  8. package/dist/config/agent-model-guard.js +24 -0
  9. package/dist/config/agent-model-guard.js.map +1 -0
  10. package/dist/config/config-command.d.ts +33 -1
  11. package/dist/config/config-command.js +723 -11
  12. package/dist/config/config-command.js.map +1 -1
  13. package/dist/config/first-start.js +2 -8
  14. package/dist/config/first-start.js.map +1 -1
  15. package/dist/config/model-provider.d.ts +9 -0
  16. package/dist/config/model-provider.js +13 -0
  17. package/dist/config/model-provider.js.map +1 -1
  18. package/dist/config/openrouter-catalog.d.ts +46 -0
  19. package/dist/config/openrouter-catalog.js +111 -0
  20. package/dist/config/openrouter-catalog.js.map +1 -0
  21. package/dist/config/openrouter-models-fallback.d.ts +15 -0
  22. package/dist/config/openrouter-models-fallback.js +49 -0
  23. package/dist/config/openrouter-models-fallback.js.map +1 -0
  24. package/dist/config/paths.d.ts +48 -6
  25. package/dist/config/paths.js +63 -8
  26. package/dist/config/paths.js.map +1 -1
  27. package/dist/config/types.d.ts +8 -0
  28. package/dist/config/user-config.d.ts +179 -6
  29. package/dist/config/user-config.js +282 -5
  30. package/dist/config/user-config.js.map +1 -1
  31. package/dist/daemon/daemon-command.js +14 -5
  32. package/dist/daemon/daemon-command.js.map +1 -1
  33. package/dist/daemon/ironcurtain-daemon.d.ts +18 -0
  34. package/dist/daemon/ironcurtain-daemon.js +28 -1
  35. package/dist/daemon/ironcurtain-daemon.js.map +1 -1
  36. package/dist/daemon-client/daemon-client.d.ts +123 -0
  37. package/dist/daemon-client/daemon-client.js +324 -0
  38. package/dist/daemon-client/daemon-client.js.map +1 -0
  39. package/dist/docker/adapters/claude-code.js +109 -1
  40. package/dist/docker/adapters/claude-code.js.map +1 -1
  41. package/dist/docker/adapters/codex.d.ts +18 -0
  42. package/dist/docker/adapters/codex.js +376 -0
  43. package/dist/docker/adapters/codex.js.map +1 -0
  44. package/dist/docker/adapters/goose.js +38 -2
  45. package/dist/docker/adapters/goose.js.map +1 -1
  46. package/dist/docker/agent-adapter.d.ts +34 -6
  47. package/dist/docker/agent-adapter.js +2 -0
  48. package/dist/docker/agent-adapter.js.map +1 -1
  49. package/dist/docker/agent-registry.js +5 -0
  50. package/dist/docker/agent-registry.js.map +1 -1
  51. package/dist/docker/apple-container-manager.d.ts +49 -0
  52. package/dist/docker/apple-container-manager.js +486 -0
  53. package/dist/docker/apple-container-manager.js.map +1 -0
  54. package/dist/docker/ca.js +9 -2
  55. package/dist/docker/ca.js.map +1 -1
  56. package/dist/docker/code-mode-proxy.d.ts +11 -0
  57. package/dist/docker/code-mode-proxy.js +3 -1
  58. package/dist/docker/code-mode-proxy.js.map +1 -1
  59. package/dist/docker/container-lifecycle.d.ts +2 -2
  60. package/dist/docker/container-lifecycle.js +9 -0
  61. package/dist/docker/container-lifecycle.js.map +1 -1
  62. package/dist/docker/container-runtime.d.ts +29 -0
  63. package/dist/docker/container-runtime.js +76 -0
  64. package/dist/docker/container-runtime.js.map +1 -0
  65. package/dist/docker/docker-agent-session.d.ts +31 -0
  66. package/dist/docker/docker-agent-session.js +97 -7
  67. package/dist/docker/docker-agent-session.js.map +1 -1
  68. package/dist/docker/docker-infrastructure.d.ts +232 -15
  69. package/dist/docker/docker-infrastructure.js +762 -118
  70. package/dist/docker/docker-infrastructure.js.map +1 -1
  71. package/dist/docker/docker-manager.d.ts +25 -5
  72. package/dist/docker/docker-manager.js +388 -21
  73. package/dist/docker/docker-manager.js.map +1 -1
  74. package/dist/docker/docker-probe.d.ts +45 -0
  75. package/dist/docker/docker-probe.js +86 -0
  76. package/dist/docker/docker-probe.js.map +1 -0
  77. package/dist/docker/docker-progress-sink.d.ts +1 -1
  78. package/dist/docker/docker-resource-lifecycle.d.ts +71 -0
  79. package/dist/docker/docker-resource-lifecycle.js +454 -0
  80. package/dist/docker/docker-resource-lifecycle.js.map +1 -0
  81. package/dist/docker/gc-command.d.ts +2 -0
  82. package/dist/docker/gc-command.js +37 -0
  83. package/dist/docker/gc-command.js.map +1 -0
  84. package/dist/docker/mitm-proxy.d.ts +58 -5
  85. package/dist/docker/mitm-proxy.js +338 -26
  86. package/dist/docker/mitm-proxy.js.map +1 -1
  87. package/dist/docker/network-topology.d.ts +88 -0
  88. package/dist/docker/network-topology.js +167 -0
  89. package/dist/docker/network-topology.js.map +1 -0
  90. package/dist/docker/oauth-credentials.d.ts +13 -0
  91. package/dist/docker/oauth-credentials.js +125 -0
  92. package/dist/docker/oauth-credentials.js.map +1 -1
  93. package/dist/docker/openrouter.d.ts +107 -0
  94. package/dist/docker/openrouter.js +236 -0
  95. package/dist/docker/openrouter.js.map +1 -0
  96. package/dist/docker/orientation.js +1 -1
  97. package/dist/docker/orientation.js.map +1 -1
  98. package/dist/docker/package-types.d.ts +1 -1
  99. package/dist/docker/platform.d.ts +3 -1
  100. package/dist/docker/platform.js +3 -1
  101. package/dist/docker/platform.js.map +1 -1
  102. package/dist/docker/provider-config.d.ts +35 -1
  103. package/dist/docker/provider-config.js +93 -3
  104. package/dist/docker/provider-config.js.map +1 -1
  105. package/dist/docker/provision-lock.d.ts +46 -0
  106. package/dist/docker/provision-lock.js +152 -0
  107. package/dist/docker/provision-lock.js.map +1 -0
  108. package/dist/docker/pty-session.d.ts +22 -0
  109. package/dist/docker/pty-session.js +238 -84
  110. package/dist/docker/pty-session.js.map +1 -1
  111. package/dist/docker/pty-types.d.ts +15 -0
  112. package/dist/docker/pty-types.js +8 -0
  113. package/dist/docker/pty-types.js.map +1 -1
  114. package/dist/docker/registry-proxy.d.ts +46 -0
  115. package/dist/docker/registry-proxy.js +255 -1
  116. package/dist/docker/registry-proxy.js.map +1 -1
  117. package/dist/docker/resource-limits.d.ts +14 -5
  118. package/dist/docker/resource-limits.js +11 -8
  119. package/dist/docker/resource-limits.js.map +1 -1
  120. package/dist/docker/sse-extractor.js +55 -5
  121. package/dist/docker/sse-extractor.js.map +1 -1
  122. package/dist/docker/stream-delay.d.ts +86 -0
  123. package/dist/docker/stream-delay.js +218 -0
  124. package/dist/docker/stream-delay.js.map +1 -0
  125. package/dist/docker/token-stream-types.d.ts +15 -0
  126. package/dist/docker/trajectory-capture.d.ts +79 -0
  127. package/dist/docker/trajectory-capture.js +521 -0
  128. package/dist/docker/trajectory-capture.js.map +1 -0
  129. package/dist/docker/trajectory-reassembler.d.ts +286 -0
  130. package/dist/docker/trajectory-reassembler.js +1010 -0
  131. package/dist/docker/trajectory-reassembler.js.map +1 -0
  132. package/dist/docker/trajectory-tap.d.ts +100 -0
  133. package/dist/docker/trajectory-tap.js +410 -0
  134. package/dist/docker/trajectory-tap.js.map +1 -0
  135. package/dist/docker/trajectory-types.d.ts +187 -0
  136. package/dist/docker/trajectory-types.js +43 -0
  137. package/dist/docker/trajectory-types.js.map +1 -0
  138. package/dist/docker/types.d.ts +116 -12
  139. package/dist/doctor/checks.d.ts +39 -6
  140. package/dist/doctor/checks.js +129 -25
  141. package/dist/doctor/checks.js.map +1 -1
  142. package/dist/doctor/doctor-command.js +39 -9
  143. package/dist/doctor/doctor-command.js.map +1 -1
  144. package/dist/doctor/mcp-liveness.d.ts +1 -1
  145. package/dist/doctor/mcp-liveness.js +12 -4
  146. package/dist/doctor/mcp-liveness.js.map +1 -1
  147. package/dist/escalation/escalation-watcher.js +6 -0
  148. package/dist/escalation/escalation-watcher.js.map +1 -1
  149. package/dist/{mux → escalation}/trusted-input.d.ts +7 -4
  150. package/dist/{mux → escalation}/trusted-input.js +8 -5
  151. package/dist/escalation/trusted-input.js.map +1 -0
  152. package/dist/hash.js.map +1 -1
  153. package/dist/index.js +65 -16
  154. package/dist/index.js.map +1 -1
  155. package/dist/mux/mux-app.d.ts +9 -0
  156. package/dist/mux/mux-app.js +18 -16
  157. package/dist/mux/mux-app.js.map +1 -1
  158. package/dist/mux/mux-command.js +26 -8
  159. package/dist/mux/mux-command.js.map +1 -1
  160. package/dist/mux/mux-input-handler.d.ts +26 -2
  161. package/dist/mux/mux-input-handler.js +93 -11
  162. package/dist/mux/mux-input-handler.js.map +1 -1
  163. package/dist/mux/mux-renderer.d.ts +2 -1
  164. package/dist/mux/mux-renderer.js +67 -0
  165. package/dist/mux/mux-renderer.js.map +1 -1
  166. package/dist/mux/provider-profile-snapshot.d.ts +44 -0
  167. package/dist/mux/provider-profile-snapshot.js +56 -0
  168. package/dist/mux/provider-profile-snapshot.js.map +1 -0
  169. package/dist/mux/types.d.ts +3 -2
  170. package/dist/mux/types.js +6 -2
  171. package/dist/mux/types.js.map +1 -1
  172. package/dist/observe/observe-command.js +112 -125
  173. package/dist/observe/observe-command.js.map +1 -1
  174. package/dist/persona/broad-policy-validator.d.ts +95 -0
  175. package/dist/persona/broad-policy-validator.js +171 -0
  176. package/dist/persona/broad-policy-validator.js.map +1 -0
  177. package/dist/persona/compile-persona-policy.d.ts +54 -2
  178. package/dist/persona/compile-persona-policy.js +29 -4
  179. package/dist/persona/compile-persona-policy.js.map +1 -1
  180. package/dist/persona/event-bus-progress-reporter.d.ts +57 -0
  181. package/dist/persona/event-bus-progress-reporter.js +66 -0
  182. package/dist/persona/event-bus-progress-reporter.js.map +1 -0
  183. package/dist/persona/persona-command.js +46 -76
  184. package/dist/persona/persona-command.js.map +1 -1
  185. package/dist/persona/persona-compile-orchestrator.d.ts +101 -0
  186. package/dist/persona/persona-compile-orchestrator.js +504 -0
  187. package/dist/persona/persona-compile-orchestrator.js.map +1 -0
  188. package/dist/persona/persona-service.d.ts +137 -0
  189. package/dist/persona/persona-service.js +263 -0
  190. package/dist/persona/persona-service.js.map +1 -0
  191. package/dist/persona/policy-mutation-audit.d.ts +109 -0
  192. package/dist/persona/policy-mutation-audit.js +179 -0
  193. package/dist/persona/policy-mutation-audit.js.map +1 -0
  194. package/dist/persona/rule-delta.d.ts +30 -0
  195. package/dist/persona/rule-delta.js +82 -0
  196. package/dist/persona/rule-delta.js.map +1 -0
  197. package/dist/persona/types.d.ts +12 -0
  198. package/dist/pipeline/annotate.js +10 -2
  199. package/dist/pipeline/annotate.js.map +1 -1
  200. package/dist/pipeline/compile.js +14 -2
  201. package/dist/pipeline/compile.js.map +1 -1
  202. package/dist/pipeline/constitution-compiler.js.map +1 -1
  203. package/dist/pipeline/generate-with-repair.js.map +1 -1
  204. package/dist/pipeline/pipeline-runner.d.ts +63 -4
  205. package/dist/pipeline/pipeline-runner.js +159 -50
  206. package/dist/pipeline/pipeline-runner.js.map +1 -1
  207. package/dist/pipeline/pipeline-shared.d.ts +32 -3
  208. package/dist/pipeline/pipeline-shared.js +76 -8
  209. package/dist/pipeline/pipeline-shared.js.map +1 -1
  210. package/dist/pipeline/refresh-lists.js +3 -5
  211. package/dist/pipeline/refresh-lists.js.map +1 -1
  212. package/dist/pipeline/scenario-schema-validator.js.map +1 -1
  213. package/dist/{mux → pty}/pty-bridge.d.ts +41 -3
  214. package/dist/{mux → pty}/pty-bridge.js +71 -18
  215. package/dist/pty/pty-bridge.js.map +1 -0
  216. package/dist/pty/resolve-ironcurtain-bin.d.ts +17 -0
  217. package/dist/pty/resolve-ironcurtain-bin.js +22 -0
  218. package/dist/pty/resolve-ironcurtain-bin.js.map +1 -0
  219. package/dist/sandbox/index.d.ts +19 -4
  220. package/dist/sandbox/index.js +31 -18
  221. package/dist/sandbox/index.js.map +1 -1
  222. package/dist/servers/fetch-server.js +2 -0
  223. package/dist/servers/fetch-server.js.map +1 -1
  224. package/dist/session/index.d.ts +1 -1
  225. package/dist/session/index.js +20 -2
  226. package/dist/session/index.js.map +1 -1
  227. package/dist/session/preflight.d.ts +20 -35
  228. package/dist/session/preflight.js +92 -109
  229. package/dist/session/preflight.js.map +1 -1
  230. package/dist/session/resource-budget-tracker.js +8 -0
  231. package/dist/session/resource-budget-tracker.js.map +1 -1
  232. package/dist/session/session-manager.d.ts +7 -0
  233. package/dist/session/session-manager.js.map +1 -1
  234. package/dist/session/types.d.ts +23 -0
  235. package/dist/signal/bot-command.js +6 -1
  236. package/dist/signal/bot-command.js.map +1 -1
  237. package/dist/signal/setup-signal.js +1 -1
  238. package/dist/signal/setup-signal.js.map +1 -1
  239. package/dist/signal/signal-bot-daemon.d.ts +1 -0
  240. package/dist/signal/signal-bot-daemon.js +9 -2
  241. package/dist/signal/signal-bot-daemon.js.map +1 -1
  242. package/dist/signal/signal-container.d.ts +3 -3
  243. package/dist/signal/signal-container.js +2 -2
  244. package/dist/signal/signal-container.js.map +1 -1
  245. package/dist/trusted-process/call-circuit-breaker.d.ts +6 -0
  246. package/dist/trusted-process/call-circuit-breaker.js +22 -3
  247. package/dist/trusted-process/call-circuit-breaker.js.map +1 -1
  248. package/dist/trusted-process/container-command.d.ts +35 -0
  249. package/dist/trusted-process/container-command.js +49 -0
  250. package/dist/trusted-process/container-command.js.map +1 -0
  251. package/dist/trusted-process/mcp-error-utils.js +4 -1
  252. package/dist/trusted-process/mcp-error-utils.js.map +1 -1
  253. package/dist/trusted-process/mcp-proxy-server.js +37 -3
  254. package/dist/trusted-process/mcp-proxy-server.js.map +1 -1
  255. package/dist/trusted-process/policy-engine.js +13 -2
  256. package/dist/trusted-process/policy-engine.js.map +1 -1
  257. package/dist/trusted-process/tcp-server-transport.d.ts +19 -1
  258. package/dist/trusted-process/tcp-server-transport.js +56 -10
  259. package/dist/trusted-process/tcp-server-transport.js.map +1 -1
  260. package/dist/trusted-process/tool-call-coordinator.d.ts +22 -8
  261. package/dist/trusted-process/tool-call-coordinator.js +149 -65
  262. package/dist/trusted-process/tool-call-coordinator.js.map +1 -1
  263. package/dist/trusted-process/tool-call-pipeline.d.ts +8 -0
  264. package/dist/trusted-process/tool-call-pipeline.js +68 -34
  265. package/dist/trusted-process/tool-call-pipeline.js.map +1 -1
  266. package/dist/util/atomic-write.d.ts +31 -0
  267. package/dist/util/atomic-write.js +43 -0
  268. package/dist/util/atomic-write.js.map +1 -0
  269. package/dist/utils/preflight-checks.js +1 -3
  270. package/dist/utils/preflight-checks.js.map +1 -1
  271. package/dist/utils/stdio-guard.d.ts +1 -0
  272. package/dist/utils/stdio-guard.js +27 -0
  273. package/dist/utils/stdio-guard.js.map +1 -0
  274. package/dist/web-ui/__tests__/config-dispatch.test.d.ts +13 -0
  275. package/dist/web-ui/__tests__/config-dispatch.test.js +383 -0
  276. package/dist/web-ui/__tests__/config-dispatch.test.js.map +1 -0
  277. package/dist/web-ui/__tests__/json-rpc-dispatch.test.js +6 -0
  278. package/dist/web-ui/__tests__/json-rpc-dispatch.test.js.map +1 -1
  279. package/dist/web-ui/conn-registry.d.ts +14 -0
  280. package/dist/web-ui/conn-registry.js +25 -0
  281. package/dist/web-ui/conn-registry.js.map +1 -0
  282. package/dist/web-ui/dispatch/config-dispatch.d.ts +15 -0
  283. package/dist/web-ui/dispatch/config-dispatch.js +277 -0
  284. package/dist/web-ui/dispatch/config-dispatch.js.map +1 -0
  285. package/dist/web-ui/dispatch/escalation-dispatch.js +11 -2
  286. package/dist/web-ui/dispatch/escalation-dispatch.js.map +1 -1
  287. package/dist/web-ui/dispatch/persona-dispatch.d.ts +46 -2
  288. package/dist/web-ui/dispatch/persona-dispatch.js +209 -56
  289. package/dist/web-ui/dispatch/persona-dispatch.js.map +1 -1
  290. package/dist/web-ui/dispatch/pty-dispatch.d.ts +15 -0
  291. package/dist/web-ui/dispatch/pty-dispatch.js +66 -0
  292. package/dist/web-ui/dispatch/pty-dispatch.js.map +1 -0
  293. package/dist/web-ui/dispatch/session-dispatch.js +89 -6
  294. package/dist/web-ui/dispatch/session-dispatch.js.map +1 -1
  295. package/dist/web-ui/dispatch/types.d.ts +35 -0
  296. package/dist/web-ui/dispatch/types.js +42 -1
  297. package/dist/web-ui/dispatch/types.js.map +1 -1
  298. package/dist/web-ui/dispatch/workflow-dispatch.js +116 -5
  299. package/dist/web-ui/dispatch/workflow-dispatch.js.map +1 -1
  300. package/dist/web-ui/json-rpc-dispatch.js +4 -1
  301. package/dist/web-ui/json-rpc-dispatch.js.map +1 -1
  302. package/dist/web-ui/pty-session-manager.d.ts +234 -0
  303. package/dist/web-ui/pty-session-manager.js +599 -0
  304. package/dist/web-ui/pty-session-manager.js.map +1 -0
  305. package/dist/web-ui/web-event-bus.d.ts +34 -1
  306. package/dist/web-ui/web-event-bus.js.map +1 -1
  307. package/dist/web-ui/web-session-transport.js.map +1 -1
  308. package/dist/web-ui/web-ui-server.d.ts +22 -0
  309. package/dist/web-ui/web-ui-server.js +41 -0
  310. package/dist/web-ui/web-ui-server.js.map +1 -1
  311. package/dist/web-ui/web-ui-types.d.ts +212 -5
  312. package/dist/web-ui/web-ui-types.js.map +1 -1
  313. package/dist/web-ui-static/assets/index-CCVI4nEK.css +1 -0
  314. package/dist/web-ui-static/assets/index-Dm012gvY.js +114 -0
  315. package/dist/web-ui-static/index.html +2 -2
  316. package/dist/workflow/cli-shared.d.ts +62 -0
  317. package/dist/workflow/cli-shared.js +117 -0
  318. package/dist/workflow/cli-shared.js.map +1 -0
  319. package/dist/workflow/cli-support.js +0 -2
  320. package/dist/workflow/cli-support.js.map +1 -1
  321. package/dist/workflow/container-snapshots.d.ts +29 -0
  322. package/dist/workflow/container-snapshots.js +137 -0
  323. package/dist/workflow/container-snapshots.js.map +1 -0
  324. package/dist/workflow/daemon-gate-commands.d.ts +21 -0
  325. package/dist/workflow/daemon-gate-commands.js +595 -0
  326. package/dist/workflow/daemon-gate-commands.js.map +1 -0
  327. package/dist/workflow/discovery.d.ts +29 -0
  328. package/dist/workflow/discovery.js +67 -7
  329. package/dist/workflow/discovery.js.map +1 -1
  330. package/dist/workflow/lane-template.d.ts +89 -0
  331. package/dist/workflow/lane-template.js +141 -0
  332. package/dist/workflow/lane-template.js.map +1 -0
  333. package/dist/workflow/lint.d.ts +7 -1
  334. package/dist/workflow/lint.js +186 -3
  335. package/dist/workflow/lint.js.map +1 -1
  336. package/dist/workflow/machine-builder.d.ts +90 -2
  337. package/dist/workflow/machine-builder.js +222 -29
  338. package/dist/workflow/machine-builder.js.map +1 -1
  339. package/dist/workflow/message-log.d.ts +15 -1
  340. package/dist/workflow/message-log.js.map +1 -1
  341. package/dist/workflow/orchestrator.d.ts +505 -1
  342. package/dist/workflow/orchestrator.js +1725 -69
  343. package/dist/workflow/orchestrator.js.map +1 -1
  344. package/dist/workflow/prompt-builder.js +2 -1
  345. package/dist/workflow/prompt-builder.js.map +1 -1
  346. package/dist/workflow/run-state-command.js +1 -1
  347. package/dist/workflow/run-state-command.js.map +1 -1
  348. package/dist/workflow/shutdown-signals.d.ts +7 -0
  349. package/dist/workflow/shutdown-signals.js +40 -0
  350. package/dist/workflow/shutdown-signals.js.map +1 -0
  351. package/dist/workflow/status-parser.js +19 -4
  352. package/dist/workflow/status-parser.js.map +1 -1
  353. package/dist/workflow/types.d.ts +123 -23
  354. package/dist/workflow/types.js +2 -0
  355. package/dist/workflow/types.js.map +1 -1
  356. package/dist/workflow/validate.d.ts +23 -1
  357. package/dist/workflow/validate.js +311 -22
  358. package/dist/workflow/validate.js.map +1 -1
  359. package/dist/workflow/workflow-command.d.ts +0 -17
  360. package/dist/workflow/workflow-command.js +118 -94
  361. package/dist/workflow/workflow-command.js.map +1 -1
  362. package/dist/workflow/workflow-discovery.d.ts +2 -0
  363. package/dist/workflow/workflow-discovery.js +22 -1
  364. package/dist/workflow/workflow-discovery.js.map +1 -1
  365. package/dist/workflow/workflow-manager.d.ts +34 -0
  366. package/dist/workflow/workflow-manager.js +52 -1
  367. package/dist/workflow/workflow-manager.js.map +1 -1
  368. package/dist/workflow/workflows/design-and-code/README.md +50 -0
  369. package/dist/workflow/workflows/deterministic-eval-smoke/scripts/format_report.js +29 -0
  370. package/dist/workflow/workflows/deterministic-eval-smoke/scripts/package.json +5 -0
  371. package/dist/workflow/workflows/deterministic-eval-smoke/scripts/requirements.txt +1 -0
  372. package/dist/workflow/workflows/deterministic-eval-smoke/scripts/run_eval.py +41 -0
  373. package/dist/workflow/workflows/deterministic-eval-smoke/workflow.yaml +57 -0
  374. package/dist/workflow/workflows/deterministic-verdict-smoke/scripts/classify.py +18 -0
  375. package/dist/workflow/workflows/deterministic-verdict-smoke/workflow.yaml +51 -0
  376. package/dist/workflow/workflows/evolve/README.md +66 -0
  377. package/dist/workflow/workflows/evolve/scripts/LICENSE +201 -0
  378. package/dist/workflow/workflows/evolve/scripts/README.md +314 -0
  379. package/dist/workflow/workflows/evolve/scripts/evolve-brief +16 -0
  380. package/dist/workflow/workflows/evolve/scripts/evolve-cognition +16 -0
  381. package/dist/workflow/workflows/evolve/scripts/evolve-db +16 -0
  382. package/dist/workflow/workflows/evolve/scripts/evolve-eval +16 -0
  383. package/dist/workflow/workflows/evolve/scripts/evolve-files +16 -0
  384. package/dist/workflow/workflows/evolve/scripts/evolve-summary +16 -0
  385. package/dist/workflow/workflows/evolve/scripts/evolve_core/__init__.py +14 -0
  386. package/dist/workflow/workflows/evolve/scripts/evolve_core/algorithms/__init__.py +17 -0
  387. package/dist/workflow/workflows/evolve/scripts/evolve_core/algorithms/base.py +21 -0
  388. package/dist/workflow/workflows/evolve/scripts/evolve_core/algorithms/factory.py +90 -0
  389. package/dist/workflow/workflows/evolve/scripts/evolve_core/algorithms/greedy.py +17 -0
  390. package/dist/workflow/workflows/evolve/scripts/evolve_core/algorithms/island.py +316 -0
  391. package/dist/workflow/workflows/evolve/scripts/evolve_core/algorithms/random.py +18 -0
  392. package/dist/workflow/workflows/evolve/scripts/evolve_core/algorithms/ucb1.py +55 -0
  393. package/dist/workflow/workflows/evolve/scripts/evolve_core/best_snapshot.py +62 -0
  394. package/dist/workflow/workflows/evolve/scripts/evolve_core/cli.py +752 -0
  395. package/dist/workflow/workflows/evolve/scripts/evolve_core/cognition.py +127 -0
  396. package/dist/workflow/workflows/evolve/scripts/evolve_core/database.py +218 -0
  397. package/dist/workflow/workflows/evolve/scripts/evolve_core/diff.py +57 -0
  398. package/dist/workflow/workflows/evolve/scripts/evolve_core/embedding.py +84 -0
  399. package/dist/workflow/workflows/evolve/scripts/evolve_core/file_lock.py +45 -0
  400. package/dist/workflow/workflows/evolve/scripts/evolve_core/run_state.py +343 -0
  401. package/dist/workflow/workflows/evolve/scripts/evolve_core/sampling_config.py +173 -0
  402. package/dist/workflow/workflows/evolve/scripts/evolve_core/structures.py +93 -0
  403. package/dist/workflow/workflows/evolve/scripts/evolve_core/vector_index.py +191 -0
  404. package/dist/workflow/workflows/evolve/scripts/evolve_result.py +1503 -0
  405. package/dist/workflow/workflows/evolve/scripts/requirements.txt +2 -0
  406. package/dist/workflow/workflows/evolve/workflow.yaml +646 -0
  407. package/dist/workflow/workflows/test-email-summary/workflow.yaml +1 -0
  408. package/dist/workflow/workflows/vuln-discovery/README.md +54 -0
  409. package/dist/workflow/workflows/vuln-discovery/skills/harness-design-fuzzing/SKILL.md +6 -1
  410. package/dist/workflow/workflows/vuln-discovery/skills/memory-safety-c-cpp/SKILL.md +1 -0
  411. package/dist/workflow/workflows/vuln-discovery/skills/vulnerability-triage/SKILL.md +4 -0
  412. package/dist/workflow/workflows/vuln-discovery/workflow.yaml +13 -8
  413. package/docker/Dockerfile.base +22 -0
  414. package/docker/Dockerfile.base.arm64 +18 -0
  415. package/docker/Dockerfile.claude-code +3 -1
  416. package/docker/Dockerfile.codex +17 -0
  417. package/docker/Dockerfile.goose +3 -1
  418. package/docker/entrypoint-claude-code.sh +28 -45
  419. package/docker/entrypoint-codex.sh +42 -0
  420. package/docker/entrypoint-goose.sh +5 -24
  421. package/docker/entrypoint-uid-remap.sh +93 -0
  422. package/package.json +39 -30
  423. package/src/workflow/workflows/design-and-code/README.md +50 -0
  424. package/src/workflow/workflows/deterministic-eval-smoke/scripts/format_report.js +29 -0
  425. package/src/workflow/workflows/deterministic-eval-smoke/scripts/package.json +5 -0
  426. package/src/workflow/workflows/deterministic-eval-smoke/scripts/requirements.txt +1 -0
  427. package/src/workflow/workflows/deterministic-eval-smoke/scripts/run_eval.py +41 -0
  428. package/src/workflow/workflows/deterministic-eval-smoke/workflow.yaml +57 -0
  429. package/src/workflow/workflows/deterministic-verdict-smoke/scripts/classify.py +18 -0
  430. package/src/workflow/workflows/deterministic-verdict-smoke/workflow.yaml +51 -0
  431. package/src/workflow/workflows/evolve/README.md +66 -0
  432. package/src/workflow/workflows/evolve/scripts/LICENSE +201 -0
  433. package/src/workflow/workflows/evolve/scripts/README.md +314 -0
  434. package/src/workflow/workflows/evolve/scripts/evolve-brief +16 -0
  435. package/src/workflow/workflows/evolve/scripts/evolve-cognition +16 -0
  436. package/src/workflow/workflows/evolve/scripts/evolve-db +16 -0
  437. package/src/workflow/workflows/evolve/scripts/evolve-eval +16 -0
  438. package/src/workflow/workflows/evolve/scripts/evolve-files +16 -0
  439. package/src/workflow/workflows/evolve/scripts/evolve-summary +16 -0
  440. package/src/workflow/workflows/evolve/scripts/evolve_core/__init__.py +14 -0
  441. package/src/workflow/workflows/evolve/scripts/evolve_core/algorithms/__init__.py +17 -0
  442. package/src/workflow/workflows/evolve/scripts/evolve_core/algorithms/base.py +21 -0
  443. package/src/workflow/workflows/evolve/scripts/evolve_core/algorithms/factory.py +90 -0
  444. package/src/workflow/workflows/evolve/scripts/evolve_core/algorithms/greedy.py +17 -0
  445. package/src/workflow/workflows/evolve/scripts/evolve_core/algorithms/island.py +316 -0
  446. package/src/workflow/workflows/evolve/scripts/evolve_core/algorithms/random.py +18 -0
  447. package/src/workflow/workflows/evolve/scripts/evolve_core/algorithms/ucb1.py +55 -0
  448. package/src/workflow/workflows/evolve/scripts/evolve_core/best_snapshot.py +62 -0
  449. package/src/workflow/workflows/evolve/scripts/evolve_core/cli.py +752 -0
  450. package/src/workflow/workflows/evolve/scripts/evolve_core/cognition.py +127 -0
  451. package/src/workflow/workflows/evolve/scripts/evolve_core/database.py +218 -0
  452. package/src/workflow/workflows/evolve/scripts/evolve_core/diff.py +57 -0
  453. package/src/workflow/workflows/evolve/scripts/evolve_core/embedding.py +84 -0
  454. package/src/workflow/workflows/evolve/scripts/evolve_core/file_lock.py +45 -0
  455. package/src/workflow/workflows/evolve/scripts/evolve_core/run_state.py +343 -0
  456. package/src/workflow/workflows/evolve/scripts/evolve_core/sampling_config.py +173 -0
  457. package/src/workflow/workflows/evolve/scripts/evolve_core/structures.py +93 -0
  458. package/src/workflow/workflows/evolve/scripts/evolve_core/vector_index.py +191 -0
  459. package/src/workflow/workflows/evolve/scripts/evolve_result.py +1503 -0
  460. package/src/workflow/workflows/evolve/scripts/requirements.txt +2 -0
  461. package/src/workflow/workflows/evolve/workflow.yaml +646 -0
  462. package/src/workflow/workflows/test-email-summary/workflow.yaml +1 -0
  463. package/src/workflow/workflows/vuln-discovery/README.md +54 -0
  464. package/src/workflow/workflows/vuln-discovery/skills/harness-design-fuzzing/SKILL.md +6 -1
  465. package/src/workflow/workflows/vuln-discovery/skills/memory-safety-c-cpp/SKILL.md +1 -0
  466. package/src/workflow/workflows/vuln-discovery/skills/vulnerability-triage/SKILL.md +4 -0
  467. package/src/workflow/workflows/vuln-discovery/workflow.yaml +13 -8
  468. package/dist/mux/pty-bridge.js.map +0 -1
  469. package/dist/mux/trusted-input.js.map +0 -1
  470. package/dist/web-ui-static/assets/index-BFO5z1js.js +0 -76
  471. package/dist/web-ui-static/assets/index-CCUwrkIL.css +0 -1
  472. package/packages/web-ui/README.md +0 -113
@@ -0,0 +1,1503 @@
1
+ #!/usr/bin/env python3
2
+ """Translate evolve_core helper output into IronCurtain result-file verdicts."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import hashlib
8
+ import json
9
+ import os
10
+ import re
11
+ import subprocess
12
+ import sys
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+
17
+ SCRIPT_DIR = Path(__file__).resolve().parent
18
+ DETERMINISTIC_ENV = {**os.environ, "PYTHONHASHSEED": "0"}
19
+ IMPROVEMENT_EPS = 1e-9
20
+ TARGET_RE = re.compile(
21
+ r"^\s*([A-Za-z_]\w*)\s*(>=|>|==|<=|<)\s*([-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?)\s*$"
22
+ )
23
+ STOCHASTIC_SAMPLERS = {"random", "ucb1", "island"}
24
+ # Step-name format seam: `step_<NNNN>` or `step_<NNNN>_lane_<k>`. The `lane_<k>`
25
+ # directory half of this same convention lives on the TS side as
26
+ # DEFAULT_EVOLVE_LANE_DIR in src/workflow/lane-template.ts — keep both in sync if
27
+ # the lane/step naming ever changes.
28
+ STEP_NAME_RE = re.compile(r"^step_(\d+)(?:_lane_(\d+))?$")
29
+
30
+
31
+ def _write_json(path: Path, payload: dict[str, Any]) -> None:
32
+ path.parent.mkdir(parents=True, exist_ok=True)
33
+ path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
34
+
35
+
36
+ def _write_json_atomic(path: Path, payload: dict[str, Any]) -> None:
37
+ # Temp-file + os.replace so a concurrent reader (the orchestrator polling the
38
+ # canonical stop_signals.json at the barrier) never observes a half-written
39
+ # file. os.replace is atomic on the same filesystem.
40
+ path.parent.mkdir(parents=True, exist_ok=True)
41
+ tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp")
42
+ tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
43
+ os.replace(tmp, path)
44
+
45
+
46
+ def _load_json(path: Path) -> Any:
47
+ return json.loads(path.read_text(encoding="utf-8"))
48
+
49
+
50
+ def _load_structured(path: Path) -> dict[str, Any]:
51
+ text = path.read_text(encoding="utf-8")
52
+ try:
53
+ loaded = json.loads(text)
54
+ except json.JSONDecodeError:
55
+ try:
56
+ import yaml
57
+ except Exception as exc: # pragma: no cover - dependency is present in the workflow image
58
+ raise RuntimeError(f"could not import yaml to read {path}") from exc
59
+ loaded = yaml.safe_load(text)
60
+ if not isinstance(loaded, dict):
61
+ return {}
62
+ return loaded
63
+
64
+
65
+ def _run_dir(args: argparse.Namespace) -> Path:
66
+ return Path(args.run_dir)
67
+
68
+
69
+ def _resolve_lane(args: argparse.Namespace) -> int | None:
70
+ raw_lane = getattr(args, "lane", None)
71
+ if raw_lane is None:
72
+ raw_lane = os.environ.get("EVOLVE_LANE")
73
+ if raw_lane is None or raw_lane == "":
74
+ return None
75
+ try:
76
+ lane = int(raw_lane)
77
+ except (TypeError, ValueError) as exc:
78
+ raise SystemExit("evolve_result: --lane / EVOLVE_LANE must be a non-negative integer") from exc
79
+ if lane < 0:
80
+ raise SystemExit("evolve_result: --lane / EVOLVE_LANE must be a non-negative integer")
81
+ return lane
82
+
83
+
84
+ def _current_dir(run_dir: Path, lane: int | None = None) -> Path:
85
+ base = run_dir / "current"
86
+ return base / f"lane_{lane}" if lane is not None else base
87
+
88
+
89
+ def _stop_signals_path(run_dir: Path, lane: int | None = None) -> Path:
90
+ # The CANONICAL stop_signals.json (the file the orchestrator reads once per
91
+ # batch to route continue/complete/escalate) is barrier-owned and lives at
92
+ # the bare `current/stop_signals.json` (lane is None). Under fan-out, the
93
+ # canonical file is computed exactly once at the join (the `compute_stop_signals`
94
+ # subcommand); per-lane `attach_analysis` writes only its OWN lane-scoped
95
+ # `current/lane_<k>/stop_signals.json` so N lanes never race the routing input.
96
+ return _current_dir(run_dir, lane) / "stop_signals.json"
97
+
98
+
99
+ def _clear_current_round(run_dir: Path, lane: int | None = None) -> None:
100
+ # stop_signals.json is cleared here so a human "run N more rounds" extension
101
+ # past an early stop does not re-route `complete` on a stale stop_reason from
102
+ # the round that triggered the stop (the human directive resets after one turn).
103
+ current_dir = _current_dir(run_dir, lane)
104
+ names = [
105
+ "step_name",
106
+ "context.json",
107
+ "sample.json",
108
+ "result.json",
109
+ "analysis.md",
110
+ "analysis_record.json",
111
+ "cognition_item.json",
112
+ ]
113
+ if lane is None:
114
+ names.append("stop_signals.json")
115
+ for name in names:
116
+ (current_dir / name).unlink(missing_ok=True)
117
+
118
+
119
+ def _workspace_relative_for(run_dir: Path, path: Path) -> str:
120
+ workspace_root = run_dir.parent.parent
121
+ try:
122
+ return path.relative_to(workspace_root).as_posix()
123
+ except ValueError:
124
+ return str(path)
125
+
126
+
127
+ def _step_name_from_current(run_dir: Path, lane: int | None = None) -> str:
128
+ return (_current_dir(run_dir, lane) / "step_name").read_text(encoding="utf-8").strip()
129
+
130
+
131
+ def _load_current_context(run_dir: Path, lane: int | None = None) -> dict[str, Any]:
132
+ context_path = _current_dir(run_dir, lane) / "context.json"
133
+ loaded = _load_json(context_path)
134
+ if not isinstance(loaded, dict):
135
+ raise ValueError(f"current context is not a JSON object: {context_path}")
136
+ return loaded
137
+
138
+
139
+ def _resolve_step_name(args: argparse.Namespace) -> str:
140
+ if getattr(args, "step_from_current", False):
141
+ return _step_name_from_current(_run_dir(args), _resolve_lane(args))
142
+ if args.step_name is None:
143
+ raise SystemExit("evolve_result: one of --step-name or --step-from-current is required")
144
+ return args.step_name
145
+
146
+
147
+ def _resolve_code_path(args: argparse.Namespace, step_name: str) -> str:
148
+ if getattr(args, "code_from_current", False):
149
+ run_dir = _run_dir(args)
150
+ return _workspace_relative_for(run_dir, run_dir / "steps" / step_name / "code")
151
+ if args.code_path is None:
152
+ raise SystemExit("evolve_result: one of --code-path or --code-from-current is required")
153
+ return args.code_path
154
+
155
+
156
+ def _resolve_results_file(args: argparse.Namespace, step_name: str) -> str:
157
+ if getattr(args, "results_from_current", False):
158
+ run_dir = _run_dir(args)
159
+ return _workspace_relative_for(run_dir, run_dir / "steps" / step_name / "results.json")
160
+ if args.results_file is None:
161
+ raise SystemExit("evolve_result: one of --results-file or --results-from-current is required")
162
+ return args.results_file
163
+
164
+
165
+ def _resolve_round_name(args: argparse.Namespace, step_name: str) -> str:
166
+ if getattr(args, "name_from_current", False):
167
+ try:
168
+ round_num = int(step_name.removeprefix("step_"))
169
+ except ValueError:
170
+ return step_name
171
+ return f"round-{round_num}"
172
+ if args.name is None:
173
+ raise SystemExit("evolve_result: one of --name or --name-from-current is required")
174
+ return args.name
175
+
176
+
177
+ def _resolve_parent(args: argparse.Namespace) -> list[int]:
178
+ if not getattr(args, "parent_from_current", False):
179
+ return list(args.parent or [])
180
+ context = _load_current_context(_run_dir(args), _resolve_lane(args))
181
+ parents = context.get("parents")
182
+ parent_ids: list[int] = []
183
+ for parent in parents if isinstance(parents, list) else []:
184
+ parent_id = parent.get("id") if isinstance(parent, dict) else None
185
+ if isinstance(parent_id, bool) or not isinstance(parent_id, int):
186
+ continue
187
+ parent_ids.append(parent_id)
188
+ return parent_ids
189
+
190
+
191
+ def _node_count(run_dir: Path) -> int:
192
+ return len(_nodes_payload(run_dir))
193
+
194
+
195
+ def _node_step_name(node: dict[str, Any]) -> str | None:
196
+ """Extract a recorded node's `meta_info.step_name`, or None if absent/wrong-typed."""
197
+ meta = node.get("meta_info")
198
+ step_name = meta.get("step_name") if isinstance(meta, dict) else None
199
+ return step_name if isinstance(step_name, str) else None
200
+
201
+
202
+ def _parse_step_name(node: dict[str, Any]) -> re.Match[str] | None:
203
+ """Parse a recorded node's step_name into its STEP_NAME_RE match (batch + lane).
204
+
205
+ `match.group(1)` is the batch index; `match.group(2)` is the lane id (or None
206
+ for a legacy `step_<N>` node). Returns None when the node has no step_name or
207
+ it does not match the `step_<N>[_lane_<k>]` shape. Single source for the
208
+ extraction shared by `_next_batch_index` and `_generation_key`."""
209
+ step_name = _node_step_name(node)
210
+ return STEP_NAME_RE.match(step_name) if step_name is not None else None
211
+
212
+
213
+ def _next_batch_index(run_dir: Path, workers: int | None = None) -> int:
214
+ """Return the next batch index, or a partially recorded in-flight one.
215
+
216
+ The batch index is the `step_NNNN` number parsed from each node's
217
+ `meta_info.step_name` (with or without a `_lane_<k>` suffix, per STEP_NAME_RE).
218
+ Completed batches advance to `1 + max(recorded batch_index)`.
219
+
220
+ Crash-resume of a fan-out batch is the exception: when the highest
221
+ lane-tagged batch has only some current-worker lanes recorded, that highest
222
+ index is the in-flight batch and must be reused so missing lanes write the
223
+ same `step_<batch>_lane_<k>` names. A fully-drained batch still has no
224
+ recorded lane-tagged nodes, so it naturally reuses its never-used index."""
225
+ highest = 0
226
+ lanes_by_batch: dict[int, set[int]] = {}
227
+ for node in _sorted_nodes(run_dir):
228
+ match = _parse_step_name(node)
229
+ if match is None:
230
+ continue
231
+ batch_index = int(match.group(1))
232
+ highest = max(highest, batch_index)
233
+ lane = match.group(2)
234
+ if lane is not None:
235
+ lanes_by_batch.setdefault(batch_index, set()).add(int(lane))
236
+ if workers is not None and workers > 1 and highest > 0:
237
+ highest_lanes = lanes_by_batch.get(highest, set())
238
+ expected_lanes = set(range(workers))
239
+ if highest_lanes and not expected_lanes.issubset(highest_lanes):
240
+ return highest
241
+ return highest + 1
242
+
243
+
244
+ def _nodes_payload(run_dir: Path) -> dict[str, Any]:
245
+ nodes_path = run_dir / "database_data" / "nodes.json"
246
+ if not nodes_path.exists():
247
+ return {}
248
+ loaded = _load_json(nodes_path)
249
+ if not isinstance(loaded, dict):
250
+ return {}
251
+ nodes = loaded.get("nodes")
252
+ return nodes if isinstance(nodes, dict) else {}
253
+
254
+
255
+ def _node_id(node: dict[str, Any], fallback: str) -> int | None:
256
+ raw = node.get("id")
257
+ if isinstance(raw, bool):
258
+ return None
259
+ if isinstance(raw, int):
260
+ return raw
261
+ try:
262
+ return int(fallback)
263
+ except ValueError:
264
+ return None
265
+
266
+
267
+ def _sorted_nodes(run_dir: Path) -> list[dict[str, Any]]:
268
+ items: list[tuple[int, dict[str, Any]]] = []
269
+ for key, value in _nodes_payload(run_dir).items():
270
+ if not isinstance(value, dict):
271
+ continue
272
+ node_id = _node_id(value, key)
273
+ if node_id is None:
274
+ continue
275
+ items.append((node_id, value))
276
+ return [node for _, node in sorted(items, key=lambda item: item[0])]
277
+
278
+
279
+ def _find_recorded_node_for_step(run_dir: Path, step_name: str) -> dict[str, Any] | None:
280
+ for node in _sorted_nodes(run_dir):
281
+ if _node_step_name(node) == step_name:
282
+ return node
283
+ return None
284
+
285
+
286
+ def _safe_int(value: Any, default: int = 0) -> int:
287
+ if isinstance(value, bool) or value is None:
288
+ return default
289
+ if isinstance(value, int):
290
+ return value
291
+ if isinstance(value, float) and value.is_integer():
292
+ return int(value)
293
+ if isinstance(value, str):
294
+ try:
295
+ return int(value)
296
+ except ValueError:
297
+ return default
298
+ return default
299
+
300
+
301
+ def _parse_success_target(spec: dict[str, Any]) -> tuple[dict[str, Any] | None, str | None]:
302
+ evaluation = spec.get("evaluation")
303
+ if not isinstance(evaluation, dict):
304
+ return None, "run_spec.yaml has no evaluation object"
305
+
306
+ core_score = str(evaluation.get("core_score") or "eval_score")
307
+ raw_criteria = evaluation.get("success_criteria")
308
+ criteria = raw_criteria if isinstance(raw_criteria, list) else []
309
+ for raw in criteria:
310
+ if not isinstance(raw, str):
311
+ continue
312
+ match = TARGET_RE.match(raw)
313
+ if match is None:
314
+ continue
315
+ metric, comparator, threshold = match.groups()
316
+ if metric != core_score:
317
+ continue
318
+ if comparator in {"<", "<="}:
319
+ return None, f"unsupported maximize-only comparator in success criterion: {raw}"
320
+ return (
321
+ {
322
+ "metric": metric,
323
+ "comparator": comparator,
324
+ "threshold": float(threshold),
325
+ "raw": raw,
326
+ },
327
+ None,
328
+ )
329
+ return None, f"no canonical success criterion found for {core_score}"
330
+
331
+
332
+ def _node_score(node: dict[str, Any]) -> float | None:
333
+ raw = node.get("score")
334
+ if isinstance(raw, bool) or raw is None:
335
+ return None
336
+ if isinstance(raw, (int, float)):
337
+ return float(raw)
338
+ return None
339
+
340
+
341
+ def _target_is_met(best_score: float | None, target: dict[str, Any] | None) -> bool:
342
+ if best_score is None or target is None:
343
+ return False
344
+ threshold = float(target["threshold"])
345
+ comparator = target["comparator"]
346
+ if comparator == ">=":
347
+ return best_score + IMPROVEMENT_EPS >= threshold
348
+ if comparator == ">":
349
+ return best_score > threshold + IMPROVEMENT_EPS
350
+ if comparator == "==":
351
+ return abs(best_score - threshold) <= IMPROVEMENT_EPS
352
+ return False
353
+
354
+
355
+ def _generation_key(node: dict[str, Any], fallback_index: int) -> tuple[int, int]:
356
+ """Group key for the GENERATION (batch) a recorded node belongs to.
357
+
358
+ A generation is a distinct fan-out batch. The batch index is the `step_<N>`
359
+ number parsed from the node's `meta_info.step_name` via STEP_NAME_RE (the same
360
+ parse `_next_batch_index` uses). Lanes of one batch share `step_<N>_lane_<k>`,
361
+ so they share batch `N` and thus one generation. Legacy workers:1 nodes are
362
+ `step_<N>` with NO lane suffix — each is its OWN generation (batch index `N`,
363
+ one node per batch), which is what makes the generation-denominated stop
364
+ counters byte-identical to the old node-denominated ones at workers:1.
365
+
366
+ The returned key is `(batch_index, lane_tiebreak)` so the secondary slot only
367
+ ever disambiguates legacy/unparseable nodes that would otherwise collide on
368
+ the same batch number; lane-tagged nodes all carry tiebreak 0 so a whole batch
369
+ collapses to one generation. A node whose step_name is missing or unparseable
370
+ gets a UNIQUE key (negative batch sentinel keyed by its position) so it still
371
+ counts as its own generation rather than silently merging with another.
372
+ """
373
+ match = _parse_step_name(node)
374
+ if match is None:
375
+ # Unparseable/missing step_name: stand-alone generation, keyed off its
376
+ # sorted position so two such nodes never share a generation.
377
+ return (-1, fallback_index)
378
+ batch_index = int(match.group(1))
379
+ lane = match.group(2)
380
+ # Lane-tagged nodes of one batch share the generation (tiebreak 0). A legacy
381
+ # `step_<N>` node keeps a per-node tiebreak so two legacy nodes that happen to
382
+ # carry the same batch number stay distinct generations (the workers:1 case
383
+ # never collides in practice, but this keeps grouping injective).
384
+ #
385
+ # The `+ 1` keeps a legacy node's tiebreak strictly positive: within the same
386
+ # batch a lane-tagged node sits at tiebreak 0, so starting legacy tiebreaks at
387
+ # 1 (not 0) means a legacy `step_<N>` can never collide with lane 0's
388
+ # generation of batch N. (The unparseable `(-1, …)` sentinel space above is
389
+ # already disjoint from real batches by its first element, so it needs no such
390
+ # offset.) Do not "simplify" the `+ 1` away.
391
+ return (batch_index, 0 if lane is not None else fallback_index + 1)
392
+
393
+
394
+ def _compute_stop_signals(run_dir: Path) -> dict[str, Any]:
395
+ spec_path = run_dir / "run_spec.yaml"
396
+ spec = _load_structured(spec_path) if spec_path.exists() else {}
397
+ budget = spec.get("budget")
398
+ if not isinstance(budget, dict):
399
+ budget = {}
400
+
401
+ target, target_parse_warning = _parse_success_target(spec)
402
+ max_rounds = _safe_int(budget.get("max_rounds"))
403
+ patience = _safe_int(budget.get("patience"))
404
+ nodes = _sorted_nodes(run_dir)
405
+ warnings: list[str] = []
406
+
407
+ # Per-node warnings are emitted independently of generation grouping so the
408
+ # `warnings` payload stays byte-identical to the old node-denominated output.
409
+ for index, node in enumerate(nodes):
410
+ if _node_score(node) is None:
411
+ node_id = _node_id(node, str(index))
412
+ warnings.append(f"node {node_id if node_id is not None else index} has no numeric score; treated as -inf")
413
+
414
+ # Group nodes into GENERATIONS (batches) in generation order. The best score
415
+ # of a generation is the max lane score in its batch (the achieving node id is
416
+ # tracked so best_node_id stays meaningful). At workers:1 each generation is a
417
+ # single node, so this reduces to the old per-node walk exactly.
418
+ generations: dict[tuple[int, int], tuple[float | None, int | None]] = {}
419
+ for index, node in enumerate(nodes):
420
+ key = _generation_key(node, index)
421
+ score = _node_score(node)
422
+ node_id = _node_id(node, str(index))
423
+ gen_best, gen_best_id = generations.get(key, (None, None))
424
+ if score is not None and (gen_best is None or score > gen_best):
425
+ gen_best, gen_best_id = score, node_id
426
+ generations[key] = (gen_best, gen_best_id)
427
+
428
+ best_score: float | None = None
429
+ best_node_id: int | None = None
430
+ rounds_since_improvement = 0
431
+ for _key, (gen_best, gen_best_id) in sorted(generations.items()):
432
+ if gen_best is not None and (best_score is None or gen_best > best_score + IMPROVEMENT_EPS):
433
+ best_score = gen_best
434
+ best_node_id = gen_best_id
435
+ rounds_since_improvement = 0
436
+ elif best_node_id is not None:
437
+ rounds_since_improvement += 1
438
+
439
+ # done_rounds now counts GENERATIONS (distinct batches), not nodes. At
440
+ # workers:1 generation-count == node-count, so this is byte-identical there.
441
+ done_rounds = len(generations)
442
+ node_count = len(nodes)
443
+ target_met = _target_is_met(best_score, target)
444
+ patience_exceeded = patience > 0 and rounds_since_improvement >= patience
445
+ max_rounds_reached = max_rounds > 0 and done_rounds >= max_rounds
446
+ if target_met:
447
+ stop_reason = "target_met"
448
+ elif patience_exceeded:
449
+ stop_reason = "patience"
450
+ elif max_rounds_reached:
451
+ stop_reason = "max_rounds"
452
+ else:
453
+ stop_reason = None
454
+
455
+ payload: dict[str, Any] = {
456
+ "best_score": best_score,
457
+ "best_node_id": best_node_id,
458
+ "evolution_rounds": done_rounds,
459
+ "rounds_since_improvement": rounds_since_improvement,
460
+ "target": target,
461
+ "target_met": target_met,
462
+ "patience": patience,
463
+ "patience_exceeded": patience_exceeded,
464
+ "done_rounds": done_rounds,
465
+ # Generation-denominated counters (done_rounds/rounds_since_improvement)
466
+ # count BATCHES; node_count is the raw recorded-node tally for
467
+ # observability. At workers:1 the two coincide (one node per generation).
468
+ "node_count": node_count,
469
+ "max_rounds": max_rounds,
470
+ "stop_reason": stop_reason,
471
+ "improvement_epsilon": IMPROVEMENT_EPS,
472
+ }
473
+ if target_parse_warning is not None:
474
+ payload["target_parse_warning"] = target_parse_warning
475
+ if warnings:
476
+ payload["warnings"] = warnings
477
+ return payload
478
+
479
+
480
+ def _write_stop_signals(run_dir: Path, lane: int | None = None) -> dict[str, Any]:
481
+ # Atomic so the orchestrator (which reads the canonical bare file once per
482
+ # batch) never sees a torn write. With lane=None this writes the canonical
483
+ # barrier-owned file; with a lane it writes that lane's private copy only.
484
+ signals = _compute_stop_signals(run_dir)
485
+ _write_json_atomic(_stop_signals_path(run_dir, lane), signals)
486
+ return signals
487
+
488
+
489
+ def _cognition_item_count(run_dir: Path) -> int:
490
+ cognition_path = run_dir / "cognition_data" / "cognition.json"
491
+ if not cognition_path.exists():
492
+ return 0
493
+ loaded = _load_json(cognition_path)
494
+ if not isinstance(loaded, dict):
495
+ return 0
496
+ items = loaded.get("items")
497
+ return len(items) if isinstance(items, dict) else 0
498
+
499
+
500
+ def _objective_query(run_dir: Path) -> str:
501
+ spec = _load_structured(run_dir / "run_spec.yaml")
502
+ return str(spec.get("objective", "") or "")
503
+
504
+
505
+ def _sampling_config(run_dir: Path) -> dict[str, Any]:
506
+ spec_path = run_dir / "run_spec.yaml"
507
+ spec = _load_structured(spec_path) if spec_path.exists() else {}
508
+ sampling = spec.get("sampling")
509
+ return sampling if isinstance(sampling, dict) else {}
510
+
511
+
512
+ def _sample_n_from_spec(run_dir: Path) -> int:
513
+ return max(1, _safe_int(_sampling_config(run_dir).get("sample_n"), default=1))
514
+
515
+
516
+ def _sampling_algorithm(run_dir: Path) -> str:
517
+ # Normalize so the STOCHASTIC_SAMPLERS membership check (and thus the seed
518
+ # requirement) is case/whitespace-insensitive: a spec "UCB1" / " island "
519
+ # must not slip past as non-stochastic and run unseeded.
520
+ algorithm = _sampling_config(run_dir).get("algorithm")
521
+ return str(algorithm or "greedy").strip().lower()
522
+
523
+
524
+ def _sampling_seed(run_dir: Path) -> int | None:
525
+ seed_file = run_dir / "sampling_seed.txt"
526
+ if not seed_file.exists():
527
+ return None
528
+ try:
529
+ return int(seed_file.read_text(encoding="utf-8").strip())
530
+ except ValueError:
531
+ return None
532
+
533
+
534
+ def _numeric_score(results: dict[str, Any]) -> float | None:
535
+ raw = results.get("eval_score", results.get("score"))
536
+ if isinstance(raw, bool) or raw is None:
537
+ return None
538
+ if isinstance(raw, (int, float)):
539
+ return float(raw)
540
+ return None
541
+
542
+
543
+ def _run_helper(argv: list[str]) -> tuple[int, dict[str, Any] | None, str]:
544
+ completed = subprocess.run(argv, capture_output=True, text=True, env=DETERMINISTIC_ENV)
545
+ error = completed.stderr.strip()
546
+ try:
547
+ payload = json.loads(completed.stdout) if completed.stdout.strip() else None
548
+ except json.JSONDecodeError as exc:
549
+ payload = None
550
+ error = f"{error}\ninvalid helper JSON: {exc}".strip()
551
+ return completed.returncode, payload, error
552
+
553
+
554
+ def _run_seeded_helper(script_path: Path, helper_args: list[str], seed: int) -> tuple[int, dict[str, Any] | None, str]:
555
+ preamble = (
556
+ "import random, runpy, sys; "
557
+ f"random.seed({seed!r}); "
558
+ f"sys.argv = {[str(script_path), *helper_args]!r}; "
559
+ f"runpy.run_path({str(script_path)!r}, run_name='__main__')"
560
+ )
561
+ return _run_helper([sys.executable, "-c", preamble])
562
+
563
+
564
+ def _parent_ids(parents: list[dict[str, Any]]) -> list[int]:
565
+ # Collect node ids from sampled parent dicts, dropping any that have no
566
+ # resolvable integer id. The empty fallback in _node_id(parent, "") means an
567
+ # id-less parent yields None, which is filtered out.
568
+ return [parent_id for parent_id in (_node_id(parent, "") for parent in parents) if parent_id is not None]
569
+
570
+
571
+ def _run_cognition_seed_init(run_dir: Path, result_file: Path) -> tuple[dict[str, Any] | None, bool]:
572
+ # Seed the cognition store from cognition_seed.md on the first sample of a run
573
+ # (when the store is empty). Returns (seed_payload, ok): on a helper failure it
574
+ # writes the sample_error to result_file and returns (None, False) so the caller
575
+ # bails; on success or no-op it returns (payload-or-None, True).
576
+ if _cognition_item_count(run_dir) != 0:
577
+ return None, True
578
+ seed_file = run_dir / "cognition_seed.md"
579
+ if not seed_file.exists():
580
+ return None, True
581
+ seed_code, seed_payload, seed_error = _run_helper(
582
+ [
583
+ sys.executable,
584
+ str(SCRIPT_DIR / "evolve-cognition"),
585
+ "init",
586
+ "--run-dir",
587
+ str(run_dir),
588
+ "--seed-file",
589
+ str(seed_file),
590
+ ]
591
+ )
592
+ if seed_code != 0 or not isinstance(seed_payload, dict):
593
+ _write_json(
594
+ result_file,
595
+ {
596
+ "verdict": "sample_error",
597
+ "payload": {"stage": "cognition_init", "error": seed_error},
598
+ "passed": False,
599
+ },
600
+ )
601
+ return None, False
602
+ return seed_payload, True
603
+
604
+
605
+ def _run_db_sample(
606
+ run_dir: Path,
607
+ result_file: Path,
608
+ n: int,
609
+ sampling_algorithm: str,
610
+ sampling_seed: int | None,
611
+ ) -> dict[str, Any] | None:
612
+ # Run the db sampler for `n` parents. Stochastic samplers REQUIRE a seed for
613
+ # reproducibility — a missing seed is a hard error (written to result_file).
614
+ # On any failure this writes the sample_error and returns None; on success it
615
+ # returns the helper payload.
616
+ sample_helper = SCRIPT_DIR / "evolve-db"
617
+ sample_helper_args = ["sample", "--run-dir", str(run_dir), "--n", str(n)]
618
+ if sampling_algorithm in STOCHASTIC_SAMPLERS:
619
+ if sampling_seed is None:
620
+ seed_file = run_dir / "sampling_seed.txt"
621
+ detail = "exists but is not a single integer" if seed_file.exists() else "is missing"
622
+ _write_json(
623
+ result_file,
624
+ {
625
+ "verdict": "sample_error",
626
+ "payload": {
627
+ "stage": "db_sample",
628
+ "error": (
629
+ f"stochastic sampler {sampling_algorithm} requires a sampling_seed.txt "
630
+ f"with a single integer; the file {detail}"
631
+ ),
632
+ },
633
+ "passed": False,
634
+ },
635
+ )
636
+ return None
637
+ sample_code, sample_payload, sample_error = _run_seeded_helper(sample_helper, sample_helper_args, sampling_seed)
638
+ else:
639
+ sample_code, sample_payload, sample_error = _run_helper([sys.executable, str(sample_helper), *sample_helper_args])
640
+ if sample_code != 0 or not isinstance(sample_payload, dict):
641
+ _write_json(
642
+ result_file,
643
+ {
644
+ "verdict": "sample_error",
645
+ "payload": {"stage": "db_sample", "error": sample_error},
646
+ "passed": False,
647
+ },
648
+ )
649
+ return None
650
+ return sample_payload
651
+
652
+
653
+ def _run_cognition_search(run_dir: Path, result_file: Path, query: str, top_k: int) -> dict[str, Any] | None:
654
+ # Retrieve the top-k cognition matches for `query`. On failure this writes the
655
+ # sample_error and returns None; on success it returns the helper payload.
656
+ cognition_code, cognition_payload, cognition_error = _run_helper(
657
+ [
658
+ sys.executable,
659
+ str(SCRIPT_DIR / "evolve-cognition"),
660
+ "search",
661
+ "--run-dir",
662
+ str(run_dir),
663
+ "--query",
664
+ query,
665
+ "--top-k",
666
+ str(top_k),
667
+ ]
668
+ )
669
+ if cognition_code != 0 or not isinstance(cognition_payload, dict):
670
+ _write_json(
671
+ result_file,
672
+ {
673
+ "verdict": "sample_error",
674
+ "payload": {"stage": "cognition_search", "error": cognition_error},
675
+ "passed": False,
676
+ },
677
+ )
678
+ return None
679
+ return cognition_payload
680
+
681
+
682
+ def _partition_sampled_parents(
683
+ parents: list[dict[str, Any]],
684
+ workers: int,
685
+ sampling_seed: int | None,
686
+ lane_ids: list[int] | None = None,
687
+ ) -> list[list[dict[str, Any]]]:
688
+ """Assign each lane ONE distinct parent from a single batch-side sample.
689
+
690
+ WHY one barrier-side sample is partitioned here (rather than N independent
691
+ sample() draws): independent samples over the same DB could draw the same
692
+ parent into multiple lanes, collapsing lane diversity. sample_batch() draws
693
+ n=workers parents once, and this splits them one-per-lane so the lanes start
694
+ from disjoint parents.
695
+
696
+ The per-lane seed offset `sampling_seed + lane` decorrelates the lanes' picks
697
+ while staying reproducible (same seed -> same partition across resume). With a
698
+ seed, each lane deterministically picks the remaining parent whose
699
+ `sha256(lane_seed:node_id:index)` digest is smallest — a stable hash tiebreak
700
+ that gives a fixed, reproducible assignment independent of insertion order.
701
+ Without a seed the lanes just take parents in order (FIFO pop).
702
+
703
+ `lane_ids` maps the positional partition slot to the lane's TRUE id for the
704
+ per-lane seed. On a fresh batch it is `range(workers)` (slot i == lane i), so
705
+ omitting it is equivalent. On a crash-resume that re-runs only the missing
706
+ lanes (`workers` here is `len(lane_ids)`), slot i may map to a non-contiguous
707
+ lane id (e.g. slot 0 -> lane 2), so passing `lane_ids` keeps each re-run lane's
708
+ `sampling_seed + lane` offset identical to its pre-crash value — without it,
709
+ the seed would key off the slot index and the re-drawn parent could differ.
710
+ """
711
+ remaining = list(parents)
712
+ partitions: list[list[dict[str, Any]]] = []
713
+ for lane_index in range(workers):
714
+ if not remaining:
715
+ partitions.append([])
716
+ continue
717
+ if sampling_seed is None:
718
+ partitions.append([remaining.pop(0)])
719
+ continue
720
+
721
+ lane = lane_ids[lane_index] if lane_ids is not None else lane_index
722
+ lane_seed = sampling_seed + lane
723
+ selected_index = min(
724
+ range(len(remaining)),
725
+ key=lambda index: hashlib.sha256(
726
+ f"{lane_seed}:{_node_id(remaining[index], str(index))}:{index}".encode("utf-8")
727
+ ).hexdigest(),
728
+ )
729
+ partitions.append([remaining.pop(selected_index)])
730
+ return partitions
731
+
732
+
733
+ def _promote_lesson(
734
+ run_dir: Path,
735
+ analysis_file: Path,
736
+ step_name: str,
737
+ node_id: Any,
738
+ best_updated: Any,
739
+ lane: int | None = None,
740
+ ) -> dict[str, Any]:
741
+ if not analysis_file.exists():
742
+ return {"promoted": False, "reason": "no_analysis_file"}
743
+ lesson = analysis_file.read_text(encoding="utf-8").strip()
744
+ if not lesson:
745
+ return {"promoted": False, "reason": "empty_lesson"}
746
+ digest = hashlib.sha256(lesson.encode("utf-8")).hexdigest()
747
+ # This read-modify-write ledger is correct because callers are single-writer:
748
+ # workers:1 still promotes inline from the linear FSM path, and workers>1
749
+ # promotes all recorded lane lessons from one barrier-side process.
750
+ ledger_path = run_dir / "cognition_promoted.json"
751
+ ledger: Any = {}
752
+ if ledger_path.exists():
753
+ # A crash mid-write can leave the ledger truncated; a corrupt dedup
754
+ # ledger must not fail the whole run — fall back to an empty ledger
755
+ # (worst case: a lesson is promoted twice, which the engine tolerates).
756
+ try:
757
+ ledger = _load_json(ledger_path)
758
+ except (json.JSONDecodeError, OSError):
759
+ ledger = {}
760
+ if not isinstance(ledger, dict):
761
+ ledger = {}
762
+ if digest in ledger:
763
+ return {"promoted": False, "reason": "duplicate", "first_seen": ledger[digest]}
764
+
765
+ score: float | None = None
766
+ existing_node = _find_recorded_node_for_step(run_dir, step_name)
767
+ if existing_node is not None:
768
+ score = _node_score(existing_node)
769
+ item = {
770
+ "content": lesson,
771
+ "source": step_name,
772
+ "metadata": {
773
+ "kind": "round_lesson",
774
+ "node_id": node_id,
775
+ "best_updated": bool(best_updated),
776
+ "score": score,
777
+ },
778
+ }
779
+ item_json = _current_dir(run_dir, lane) / "cognition_item.json"
780
+ _write_json(item_json, item)
781
+ code, output, error = _run_helper(
782
+ [
783
+ sys.executable,
784
+ str(SCRIPT_DIR / "evolve-cognition"),
785
+ "add",
786
+ "--run-dir",
787
+ str(run_dir),
788
+ "--json-file",
789
+ str(item_json),
790
+ ]
791
+ )
792
+ if code != 0 or not isinstance(output, dict):
793
+ return {"promoted": False, "reason": "helper_error", "error": error}
794
+
795
+ ledger[digest] = step_name
796
+ _write_json(ledger_path, ledger)
797
+ return {
798
+ "promoted": True,
799
+ "items_added": output.get("items_added"),
800
+ "total_items": output.get("total_items"),
801
+ }
802
+
803
+
804
+ def _lane_record_payload(run_dir: Path, lane: int) -> dict[str, Any]:
805
+ # Reads the lane's recorded `analysis_record.json` payload (step_name, node_id,
806
+ # best_updated) so the barrier promote can reuse it. Any failure — missing or
807
+ # torn record, non-dict shape — degrades to {} rather than raising: a single
808
+ # lane's bad record must NOT abort the whole barrier promote. The caller then
809
+ # falls back to the on-disk `step_name` (and the recorded node) instead.
810
+ record_path = _current_dir(run_dir, lane) / "analysis_record.json"
811
+ if not record_path.exists():
812
+ return {}
813
+ try:
814
+ record = _load_json(record_path)
815
+ except (json.JSONDecodeError, OSError):
816
+ return {}
817
+ if not isinstance(record, dict):
818
+ return {}
819
+ payload = record.get("payload")
820
+ return payload if isinstance(payload, dict) else {}
821
+
822
+
823
+ def _dedupe_lanes(lanes: list[int] | None) -> list[int]:
824
+ # Order-preserving dedupe so a repeated `--lane k` promotes once and the
825
+ # per-lane tally stays accurate. Do NOT collapse to sorted(set(...)): the
826
+ # input order is asserted by tests (e.g. lanes==[0,1,2] is echoed verbatim).
827
+ ordered: list[int] = []
828
+ seen: set[int] = set()
829
+ for lane in lanes or []:
830
+ if lane in seen:
831
+ continue
832
+ ordered.append(lane)
833
+ seen.add(lane)
834
+ return ordered
835
+
836
+
837
+ def evaluate(args: argparse.Namespace) -> int:
838
+ # Validate --lane / EVOLVE_LANE up front and discard the value: evaluate
839
+ # uses explicit --step-name / --code-path, so it never reads the lane, but
840
+ # we still reject a malformed lane here so no subcommand path can slip past
841
+ # the injection-validation that the --step-from-current path performs.
842
+ _resolve_lane(args)
843
+ result_file = Path(args.result_file)
844
+ step_name = _resolve_step_name(args)
845
+ code_path = _resolve_code_path(args, step_name)
846
+ helper = SCRIPT_DIR / "evolve-eval"
847
+ helper_args = [
848
+ sys.executable,
849
+ str(helper),
850
+ "run",
851
+ "--run-dir",
852
+ args.run_dir,
853
+ "--code-path",
854
+ code_path,
855
+ "--step-name",
856
+ step_name,
857
+ ]
858
+ if args.timeout is not None:
859
+ helper_args.extend(["--timeout", str(args.timeout)])
860
+ helper_return_code, engine, helper_error = _run_helper(helper_args)
861
+
862
+ payload: dict[str, Any] = {}
863
+ if isinstance(engine, dict):
864
+ payload.update(
865
+ {
866
+ "return_code": engine.get("return_code"),
867
+ "success": engine.get("success"),
868
+ "results_path": engine.get("results_path"),
869
+ "step_dir": engine.get("step_dir"),
870
+ }
871
+ )
872
+
873
+ if helper_return_code != 0 or not isinstance(engine, dict):
874
+ payload["error"] = helper_error or f"evolve-eval exited with code {helper_return_code}"
875
+ _write_json(result_file, {"verdict": "evaluator_blocked", "payload": payload, "passed": False})
876
+ return 0
877
+
878
+ engine_return_code = engine.get("return_code")
879
+ results_path = Path(engine.get("results_path") or Path(args.run_dir) / "steps" / step_name / "results.json")
880
+ results: dict[str, Any] = {}
881
+ if results_path.exists():
882
+ try:
883
+ loaded = _load_json(results_path)
884
+ if isinstance(loaded, dict):
885
+ results = loaded
886
+ else:
887
+ payload["error"] = f"results file is not a JSON object: {results_path}"
888
+ except Exception as exc:
889
+ payload["error"] = f"could not read results file {results_path}: {exc}"
890
+ else:
891
+ payload["error"] = f"results file not found: {results_path}"
892
+
893
+ score = _numeric_score(results)
894
+ if score is not None:
895
+ payload["score"] = score
896
+
897
+ if engine_return_code == 0 and score is not None:
898
+ verdict = "evaluated"
899
+ passed = True
900
+ else:
901
+ verdict = "evaluator_blocked"
902
+ passed = False
903
+ if "error" not in payload:
904
+ payload["error"] = results.get("error") or f"evaluator returned code {engine_return_code}"
905
+
906
+ _write_json(result_file, {"verdict": verdict, "payload": payload, "passed": passed})
907
+ return 0
908
+
909
+
910
+ def sample(args: argparse.Namespace) -> int:
911
+ run_dir = Path(args.run_dir)
912
+ lane = _resolve_lane(args)
913
+ result_file = Path(args.result_file)
914
+ current_dir = _current_dir(run_dir, lane)
915
+ current_dir.mkdir(parents=True, exist_ok=True)
916
+ _clear_current_round(run_dir, lane)
917
+
918
+ seed_payload, seed_ok = _run_cognition_seed_init(run_dir, result_file)
919
+ if not seed_ok:
920
+ return 0
921
+
922
+ done_rounds = _node_count(run_dir)
923
+ if lane is None:
924
+ step_name = f"step_{done_rounds + 1:04d}"
925
+ else:
926
+ step_name = f"step_{done_rounds + 1:04d}_lane_{lane}"
927
+ (current_dir / "step_name").write_text(step_name + "\n", encoding="utf-8")
928
+
929
+ n = _sample_n_from_spec(run_dir) if args.n_from_spec else max(1, args.n)
930
+ sampling_algorithm = _sampling_algorithm(run_dir)
931
+ sampling_seed = _sampling_seed(run_dir)
932
+ sample_payload = _run_db_sample(run_dir, result_file, n, sampling_algorithm, sampling_seed)
933
+ if sample_payload is None:
934
+ return 0
935
+
936
+ query = _objective_query(run_dir) if args.query_from_spec else args.query
937
+ cognition_payload = _run_cognition_search(run_dir, result_file, query, args.top_k)
938
+ if cognition_payload is None:
939
+ return 0
940
+
941
+ sampled_nodes = sample_payload.get("nodes")
942
+ parents = [node for node in sampled_nodes if isinstance(node, dict)] if isinstance(sampled_nodes, list) else []
943
+ parent_ids = _parent_ids(parents)
944
+ matches = cognition_payload.get("matches")
945
+ context = {
946
+ "step_name": step_name,
947
+ "parents": parents,
948
+ "cognition": {
949
+ "query": query,
950
+ "matches": matches if isinstance(matches, list) else [],
951
+ },
952
+ }
953
+ context_file = Path(args.context_file)
954
+ _write_json(context_file, context)
955
+
956
+ _write_json(
957
+ result_file,
958
+ {
959
+ "verdict": "sampled",
960
+ "payload": {
961
+ "step_name": step_name,
962
+ **({"lane": lane} if lane is not None else {}),
963
+ "parent_ids": parent_ids,
964
+ "sample_n": n,
965
+ "sampling_algorithm": sampling_algorithm,
966
+ "sampling_seed": sampling_seed,
967
+ "cognition_seeded_items": seed_payload.get("total_items") if isinstance(seed_payload, dict) else None,
968
+ "cognition_matches": len(context["cognition"]["matches"]),
969
+ },
970
+ "passed": True,
971
+ },
972
+ )
973
+ return 0
974
+
975
+
976
+ def sample_batch(args: argparse.Namespace) -> int:
977
+ """Barrier-side fan-out sampler: draw one parent per active lane, partition them
978
+ one-per-lane, and write each lane's `current/lane_<k>/` context + sample.json.
979
+
980
+ `--lane <k>` (repeatable) restricts the draw to a subset of lanes; absent, every
981
+ lane `0..workers-1` is drawn (the fresh-batch case). `--batch-index <n>` pins the
982
+ `step_<n>_lane_<k>` batch number instead of deriving it from nodes.json. Both are
983
+ set by the orchestrator's crash-resume path (reconstructFanOutBatchFromDb in
984
+ src/workflow/orchestrator.ts): on resume it passes ONLY the lanes that did not
985
+ record before the crash and pins the in-flight batch index so the re-run lanes
986
+ reuse their original `step_<batch>_lane_<k>` names. The pinned index must match
987
+ `_next_batch_index(run_dir, workers)`'s in-flight reuse rule — see that function
988
+ and the TS twin for the shared contract.
989
+
990
+ Verdict contract: `sample_batch_prepared` (passed) with a per-lane payload list
991
+ the orchestrator replays as each child's `sample` result, or `sample_error`
992
+ (not passed) when the requested lanes are out of range, `--batch-index` is not
993
+ positive, seeding/sampling/cognition fails, the sampler is greedy under
994
+ workers>1 (cannot yield distinct parents), or the draw cannot be partitioned
995
+ into one distinct parent per active lane."""
996
+ run_dir = Path(args.run_dir)
997
+ result_file = Path(args.result_file)
998
+ workers = max(1, args.workers)
999
+ lane_ids = _dedupe_lanes(args.lane) if args.lane else list(range(workers))
1000
+ invalid_lanes = [lane for lane in lane_ids if lane < 0 or lane >= workers]
1001
+ if not lane_ids or invalid_lanes:
1002
+ _write_json(
1003
+ result_file,
1004
+ {
1005
+ "verdict": "sample_error",
1006
+ "payload": {
1007
+ "stage": "db_sample",
1008
+ "error": "sample_batch lanes must be within the worker range",
1009
+ "workers": workers,
1010
+ "lanes": lane_ids,
1011
+ "invalid_lanes": invalid_lanes,
1012
+ },
1013
+ "passed": False,
1014
+ },
1015
+ )
1016
+ return 0
1017
+ if args.batch_index is not None and args.batch_index <= 0:
1018
+ _write_json(
1019
+ result_file,
1020
+ {
1021
+ "verdict": "sample_error",
1022
+ "payload": {
1023
+ "stage": "db_sample",
1024
+ "error": "sample_batch --batch-index must be positive",
1025
+ "batch_index": args.batch_index,
1026
+ },
1027
+ "passed": False,
1028
+ },
1029
+ )
1030
+ return 0
1031
+ _current_dir(run_dir, None).mkdir(parents=True, exist_ok=True)
1032
+ _clear_current_round(run_dir, None)
1033
+
1034
+ sampling_algorithm = _sampling_algorithm(run_dir)
1035
+ if workers > 1 and sampling_algorithm == "greedy":
1036
+ _write_json(
1037
+ result_file,
1038
+ {
1039
+ "verdict": "sample_error",
1040
+ "payload": {
1041
+ "stage": "db_sample",
1042
+ "error": "greedy sampling cannot provide distinct parents for workers > 1",
1043
+ "workers": workers,
1044
+ "sampling_algorithm": sampling_algorithm,
1045
+ },
1046
+ "passed": False,
1047
+ },
1048
+ )
1049
+ return 0
1050
+
1051
+ seed_payload, seed_ok = _run_cognition_seed_init(run_dir, result_file)
1052
+ if not seed_ok:
1053
+ return 0
1054
+
1055
+ sampling_seed = _sampling_seed(run_dir)
1056
+ sample_payload = _run_db_sample(run_dir, result_file, len(lane_ids), sampling_algorithm, sampling_seed)
1057
+ if sample_payload is None:
1058
+ return 0
1059
+
1060
+ sampled_nodes = sample_payload.get("nodes")
1061
+ parents = [node for node in sampled_nodes if isinstance(node, dict)] if isinstance(sampled_nodes, list) else []
1062
+ parent_ids = _parent_ids(parents)
1063
+ distinct_parent_ids = set(parent_ids)
1064
+ available_parent_count = _node_count(run_dir)
1065
+ if len(distinct_parent_ids) != len(parent_ids) or (
1066
+ available_parent_count >= len(lane_ids) and len(distinct_parent_ids) < len(lane_ids)
1067
+ ):
1068
+ _write_json(
1069
+ result_file,
1070
+ {
1071
+ "verdict": "sample_error",
1072
+ "payload": {
1073
+ "stage": "db_sample",
1074
+ "error": "sample_batch could not partition distinct parent IDs across lanes",
1075
+ "workers": workers,
1076
+ "lanes": lane_ids,
1077
+ "parent_ids": parent_ids,
1078
+ "available_parent_count": available_parent_count,
1079
+ "sampling_algorithm": sampling_algorithm,
1080
+ },
1081
+ "passed": False,
1082
+ },
1083
+ )
1084
+ return 0
1085
+
1086
+ query = _objective_query(run_dir) if args.query_from_spec else args.query
1087
+ cognition_payload = _run_cognition_search(run_dir, result_file, query, args.top_k)
1088
+ if cognition_payload is None:
1089
+ return 0
1090
+
1091
+ matches = cognition_payload.get("matches")
1092
+ batch_index = args.batch_index if args.batch_index is not None else _next_batch_index(run_dir, workers)
1093
+ parent_partitions = _partition_sampled_parents(parents, len(lane_ids), sampling_seed, lane_ids)
1094
+ lanes: list[dict[str, Any]] = []
1095
+ for partition_index, lane in enumerate(lane_ids):
1096
+ current_dir = _current_dir(run_dir, lane)
1097
+ current_dir.mkdir(parents=True, exist_ok=True)
1098
+ _clear_current_round(run_dir, lane)
1099
+ step_name = f"step_{batch_index:04d}_lane_{lane}"
1100
+ lane_parents = parent_partitions[partition_index]
1101
+ lane_parent_ids = _parent_ids(lane_parents)
1102
+ context = {
1103
+ "step_name": step_name,
1104
+ "parents": lane_parents,
1105
+ "cognition": {
1106
+ "query": query,
1107
+ "matches": matches if isinstance(matches, list) else [],
1108
+ },
1109
+ }
1110
+ (current_dir / "step_name").write_text(step_name + "\n", encoding="utf-8")
1111
+ _write_json(current_dir / "context.json", context)
1112
+ lane_payload: dict[str, Any] = {
1113
+ "step_name": step_name,
1114
+ "lane": lane,
1115
+ "parent_ids": lane_parent_ids,
1116
+ "batch_index": batch_index,
1117
+ # sample_n is the number of parents ACTUALLY drawn this invocation
1118
+ # (== len(lane_ids)), not the configured worker count. On a fresh
1119
+ # batch the two coincide; on a crash-resume that re-runs only the
1120
+ # missing lanes they differ (e.g. workers=3 but only 2 lanes drawn),
1121
+ # so reporting `workers` here would overstate the draw. `workers`
1122
+ # remains available verbatim in the batch-level sample_batch_prepared
1123
+ # payload for any consumer that wants the configured count.
1124
+ "sample_n": len(lane_ids),
1125
+ "active_lane_count": len(lane_ids),
1126
+ "sampling_algorithm": sampling_algorithm,
1127
+ "sampling_seed": sampling_seed,
1128
+ "lane_seed": sampling_seed + lane if sampling_seed is not None else None,
1129
+ "cognition_seeded_items": seed_payload.get("total_items") if isinstance(seed_payload, dict) else None,
1130
+ "cognition_matches": len(context["cognition"]["matches"]),
1131
+ }
1132
+ _write_json(current_dir / "sample.json", {"verdict": "sampled", "payload": lane_payload, "passed": True})
1133
+ lanes.append(lane_payload)
1134
+
1135
+ _write_json(
1136
+ result_file,
1137
+ {
1138
+ "verdict": "sample_batch_prepared",
1139
+ "payload": {
1140
+ "workers": workers,
1141
+ "active_lanes": lane_ids,
1142
+ "active_lane_count": len(lane_ids),
1143
+ "batch_index": batch_index,
1144
+ "parent_ids": parent_ids,
1145
+ "sampling_algorithm": sampling_algorithm,
1146
+ "sampling_seed": sampling_seed,
1147
+ "lanes": lanes,
1148
+ },
1149
+ "passed": True,
1150
+ },
1151
+ )
1152
+ return 0
1153
+
1154
+
1155
+ def record(args: argparse.Namespace) -> int:
1156
+ # Validate --lane / EVOLVE_LANE up front and discard the value: record takes
1157
+ # explicit --step-name, so it never reads the lane, but validating here keeps
1158
+ # the lane-injection check uniform across every subcommand (see evaluate).
1159
+ _resolve_lane(args)
1160
+ result_file = Path(args.result_file)
1161
+ helper = SCRIPT_DIR / "evolve-db"
1162
+ helper_return_code, engine, helper_error = _run_helper(
1163
+ [
1164
+ sys.executable,
1165
+ str(helper),
1166
+ "record",
1167
+ "--run-dir",
1168
+ args.run_dir,
1169
+ "--step-name",
1170
+ args.step_name,
1171
+ "--name",
1172
+ args.name,
1173
+ "--code-path",
1174
+ args.code_path,
1175
+ "--results-file",
1176
+ args.results_file,
1177
+ ]
1178
+ )
1179
+
1180
+ payload: dict[str, Any] = {}
1181
+ if isinstance(engine, dict):
1182
+ payload.update(
1183
+ {
1184
+ "node_id": engine.get("node_id"),
1185
+ "best_updated": engine.get("best_updated"),
1186
+ "step_dir": engine.get("step_dir"),
1187
+ }
1188
+ )
1189
+
1190
+ if helper_return_code == 0 and isinstance(engine, dict) and engine.get("node_id") is not None:
1191
+ verdict = "recorded"
1192
+ passed = True
1193
+ else:
1194
+ verdict = "needs_repair"
1195
+ passed = False
1196
+ payload["error"] = helper_error or f"evolve-db exited with code {helper_return_code}"
1197
+
1198
+ _write_json(result_file, {"verdict": verdict, "payload": payload, "passed": passed})
1199
+ return 0
1200
+
1201
+
1202
+ def attach_analysis(args: argparse.Namespace) -> int:
1203
+ result_file = Path(args.result_file)
1204
+ run_dir = _run_dir(args)
1205
+ lane = _resolve_lane(args)
1206
+ step_name = _resolve_step_name(args)
1207
+ existing_node = _find_recorded_node_for_step(run_dir, step_name)
1208
+ if existing_node is not None:
1209
+ node_id = _node_id(existing_node, "")
1210
+ parent = existing_node.get("parent")
1211
+ payload: dict[str, Any] = {
1212
+ "step_name": step_name,
1213
+ "parent": parent if isinstance(parent, list) else [],
1214
+ "node_id": node_id,
1215
+ "best_updated": False,
1216
+ "step_dir": str(run_dir / "steps" / step_name),
1217
+ "idempotent_skip": True,
1218
+ }
1219
+ # Lane-scoped under fan-out (lane is not None): a lane writes only its
1220
+ # OWN stop_signals copy and never the canonical bare file, which is
1221
+ # barrier-owned (computed once at the join by `compute_stop_signals`).
1222
+ # At workers:1 (lane is None) this stays the canonical bare write.
1223
+ signals = _write_stop_signals(run_dir, lane)
1224
+ payload["stop_reason"] = signals.get("stop_reason")
1225
+ payload["stop_signals_path"] = str(_stop_signals_path(run_dir, lane))
1226
+ if "target_parse_warning" in signals:
1227
+ payload["target_parse_warning"] = signals["target_parse_warning"]
1228
+ _write_json(result_file, {"verdict": "recorded", "payload": payload, "passed": True})
1229
+ return 0
1230
+
1231
+ code_path = _resolve_code_path(args, step_name)
1232
+ results_file = _resolve_results_file(args, step_name)
1233
+ name = _resolve_round_name(args, step_name)
1234
+ parents = _resolve_parent(args)
1235
+
1236
+ argv = [
1237
+ sys.executable,
1238
+ str(SCRIPT_DIR / "evolve-db"),
1239
+ "record",
1240
+ "--run-dir",
1241
+ args.run_dir,
1242
+ "--step-name",
1243
+ step_name,
1244
+ "--name",
1245
+ name,
1246
+ "--code-path",
1247
+ code_path,
1248
+ "--results-file",
1249
+ results_file,
1250
+ "--analysis-file",
1251
+ args.analysis_file,
1252
+ ]
1253
+ for parent_id in parents:
1254
+ argv.extend(["--parent", str(parent_id)])
1255
+
1256
+ helper_return_code, engine, helper_error = _run_helper(argv)
1257
+
1258
+ payload: dict[str, Any] = {"step_name": step_name, "parent": parents}
1259
+ if isinstance(engine, dict):
1260
+ payload.update(
1261
+ {
1262
+ "node_id": engine.get("node_id"),
1263
+ "best_updated": engine.get("best_updated"),
1264
+ "step_dir": engine.get("step_dir"),
1265
+ }
1266
+ )
1267
+
1268
+ if helper_return_code == 0 and isinstance(engine, dict) and engine.get("node_id") is not None:
1269
+ verdict = "recorded"
1270
+ passed = True
1271
+ else:
1272
+ verdict = "needs_repair"
1273
+ passed = False
1274
+ payload["error"] = helper_error or f"evolve-db exited with code {helper_return_code}"
1275
+
1276
+ # See the idempotent-skip branch above: lane-scoped under fan-out, canonical
1277
+ # bare write only at workers:1. The barrier's compute_stop_signals owns the
1278
+ # single canonical file when workers > 1.
1279
+ signals = _write_stop_signals(run_dir, lane)
1280
+ payload["stop_reason"] = signals.get("stop_reason")
1281
+ payload["stop_signals_path"] = str(_stop_signals_path(run_dir, lane))
1282
+ if "target_parse_warning" in signals:
1283
+ payload["target_parse_warning"] = signals["target_parse_warning"]
1284
+ # Mode gate (the Phase-5 pivot): only the lone-lane path promotes inline.
1285
+ # At workers:1 (lane is None) this process is the sole writer, so it promotes
1286
+ # here. Under fan-out (lane is not None) each lane only RECORDS its node;
1287
+ # promotion is deferred to the barrier `promote_cognition`, where a single
1288
+ # process is the sole writer of the dedup ledger + cognition store. That
1289
+ # single-writer invariant is what replaced the retired `.promote.lock`.
1290
+ if verdict == "recorded" and payload.get("node_id") is not None and lane is None:
1291
+ payload["cognition_promoted"] = _promote_lesson(
1292
+ run_dir,
1293
+ analysis_file=Path(args.analysis_file),
1294
+ step_name=step_name,
1295
+ node_id=payload.get("node_id"),
1296
+ best_updated=payload.get("best_updated"),
1297
+ lane=lane,
1298
+ )
1299
+
1300
+ _write_json(result_file, {"verdict": verdict, "payload": payload, "passed": passed})
1301
+ return 0
1302
+
1303
+
1304
+ def promote_cognition(args: argparse.Namespace) -> int:
1305
+ """Barrier-owned fan-out promoter: the orchestrator calls this ONCE after all
1306
+ lanes have recorded. It promotes each lane's lesson serially in this single
1307
+ process, preserving the workers:1 single-writer invariant without a
1308
+ cross-process lock.
1309
+
1310
+ Verdict contract: `cognition_promoted` (passed) when every lane is handled,
1311
+ or `needs_repair` (not passed, fatal) when `errors` is non-empty. The
1312
+ per-lane skip reasons that populate `errors` are FATAL — `missing_step_name`
1313
+ (no recorded step_name on disk) and `recorded_node_missing` (the recorded
1314
+ step has no durable node) both mean the barrier saw a recorded lane it could
1315
+ not reconcile.
1316
+
1317
+ `skipped_count`/`duplicate_count` are NOT fatal: a duplicate (lesson already
1318
+ in the ledger) or an empty lesson is a successful no-op, so they leave the
1319
+ verdict `cognition_promoted`. The three counts are not a partition —
1320
+ `skipped_count` counts every non-promoted lane and therefore SUPERSETS
1321
+ `duplicate_count` (duplicates are one kind of skip)."""
1322
+ run_dir = _run_dir(args)
1323
+ result_file = Path(args.result_file)
1324
+ lanes = _dedupe_lanes(args.lane)
1325
+ promotions: list[dict[str, Any]] = []
1326
+ errors: list[str] = []
1327
+
1328
+ if not lanes:
1329
+ errors.append("no lanes supplied")
1330
+
1331
+ for lane in lanes:
1332
+ record_payload = _lane_record_payload(run_dir, lane)
1333
+ step_name = record_payload.get("step_name")
1334
+ if not isinstance(step_name, str) or not step_name:
1335
+ try:
1336
+ step_name = _step_name_from_current(run_dir, lane)
1337
+ except OSError:
1338
+ step_name = ""
1339
+
1340
+ if not step_name:
1341
+ reason = "missing_step_name"
1342
+ errors.append(f"lane {lane}: {reason}")
1343
+ promotions.append({"lane": lane, "promoted": False, "reason": reason})
1344
+ continue
1345
+
1346
+ recorded_node = _find_recorded_node_for_step(run_dir, step_name)
1347
+ if recorded_node is None:
1348
+ reason = "recorded_node_missing"
1349
+ errors.append(f"lane {lane}: {reason} ({step_name})")
1350
+ promotions.append({"lane": lane, "step_name": step_name, "promoted": False, "reason": reason})
1351
+ continue
1352
+
1353
+ node_id = record_payload.get("node_id")
1354
+ # bool is a subclass of int in Python, so isinstance(True, int) is True;
1355
+ # reject a stale/boolean recorded id and re-derive the real id from the
1356
+ # on-disk node instead of trusting a corrupt payload value.
1357
+ if isinstance(node_id, bool) or not isinstance(node_id, int):
1358
+ node_id = _node_id(recorded_node, "")
1359
+ best_updated = record_payload.get("best_updated")
1360
+ # _promote_lesson re-derives the recorded node internally (its own
1361
+ # _find_recorded_node_for_step lookup) to score the lesson; this loop's
1362
+ # earlier lookup is the node_id fallback above, not a redundant fetch we
1363
+ # could thread through — keep the engine path (_promote_lesson) untouched.
1364
+ promoted = _promote_lesson(
1365
+ run_dir,
1366
+ analysis_file=_current_dir(run_dir, lane) / "analysis.md",
1367
+ step_name=step_name,
1368
+ node_id=node_id,
1369
+ best_updated=bool(best_updated),
1370
+ lane=lane,
1371
+ )
1372
+ promotions.append({"lane": lane, "step_name": step_name, "node_id": node_id, **promoted})
1373
+
1374
+ fatal = len(errors) > 0
1375
+ payload: dict[str, Any] = {
1376
+ "lanes": lanes,
1377
+ "promotions": promotions,
1378
+ "promoted_count": sum(1 for item in promotions if item.get("promoted") is True),
1379
+ "duplicate_count": sum(1 for item in promotions if item.get("reason") == "duplicate"),
1380
+ "skipped_count": sum(1 for item in promotions if item.get("promoted") is not True),
1381
+ }
1382
+ if errors:
1383
+ payload["errors"] = errors
1384
+ _write_json(
1385
+ result_file,
1386
+ {
1387
+ "verdict": "needs_repair" if fatal else "cognition_promoted",
1388
+ "payload": payload,
1389
+ "passed": not fatal,
1390
+ },
1391
+ )
1392
+ return 0
1393
+
1394
+
1395
+ def compute_stop_signals(args: argparse.Namespace) -> int:
1396
+ # Barrier-owned: compute the single canonical current/stop_signals.json ONCE
1397
+ # at the fan-out join, over the nodes.json that grew by N this batch. Under
1398
+ # fan-out the per-lane attach_analysis writes only lane-scoped copies, so this
1399
+ # is the only writer of the bare file the orchestrator routes on (SHOULD-FIX-1).
1400
+ # The write is atomic (temp+rename) so the orchestrator never reads a torn file.
1401
+ run_dir = _run_dir(args)
1402
+ result_file = Path(args.result_file)
1403
+ signals = _write_stop_signals(run_dir, None)
1404
+ payload: dict[str, Any] = {
1405
+ "stop_reason": signals.get("stop_reason"),
1406
+ "stop_signals_path": str(_stop_signals_path(run_dir, None)),
1407
+ "done_rounds": signals.get("done_rounds"),
1408
+ }
1409
+ if "target_parse_warning" in signals:
1410
+ payload["target_parse_warning"] = signals["target_parse_warning"]
1411
+ _write_json(result_file, {"verdict": "stop_signals_computed", "payload": payload, "passed": True})
1412
+ return 0
1413
+
1414
+
1415
+ def build_parser() -> argparse.ArgumentParser:
1416
+ parser = argparse.ArgumentParser(description=__doc__)
1417
+ subparsers = parser.add_subparsers(dest="command", required=True)
1418
+
1419
+ def add_lane_argument(subparser: argparse.ArgumentParser) -> None:
1420
+ subparser.add_argument("--lane", type=int)
1421
+
1422
+ eval_parser = subparsers.add_parser("evaluate")
1423
+ add_lane_argument(eval_parser)
1424
+ eval_parser.add_argument("--run-dir", required=True)
1425
+ eval_parser.add_argument("--step-name")
1426
+ eval_parser.add_argument("--step-from-current", action="store_true")
1427
+ eval_parser.add_argument("--code-path")
1428
+ eval_parser.add_argument("--code-from-current", action="store_true")
1429
+ eval_parser.add_argument("--result-file", required=True)
1430
+ eval_parser.add_argument("--timeout", type=int)
1431
+ eval_parser.set_defaults(func=evaluate)
1432
+
1433
+ sample_parser = subparsers.add_parser("sample")
1434
+ add_lane_argument(sample_parser)
1435
+ sample_parser.add_argument("--run-dir", required=True)
1436
+ sample_parser.add_argument("--query", default="")
1437
+ sample_parser.add_argument("--query-from-spec", action="store_true")
1438
+ sample_parser.add_argument("--top-k", type=int, default=5)
1439
+ sample_parser.add_argument("--n", type=int, default=1)
1440
+ sample_parser.add_argument("--n-from-spec", action="store_true")
1441
+ sample_parser.add_argument("--context-file", required=True)
1442
+ sample_parser.add_argument("--result-file", required=True)
1443
+ sample_parser.set_defaults(func=sample)
1444
+
1445
+ sample_batch_parser = subparsers.add_parser("sample_batch")
1446
+ sample_batch_parser.add_argument("--run-dir", required=True)
1447
+ sample_batch_parser.add_argument("--workers", type=int, required=True)
1448
+ sample_batch_parser.add_argument("--lane", type=int, action="append")
1449
+ sample_batch_parser.add_argument("--batch-index", type=int)
1450
+ sample_batch_parser.add_argument("--query", default="")
1451
+ sample_batch_parser.add_argument("--query-from-spec", action="store_true")
1452
+ sample_batch_parser.add_argument("--top-k", type=int, default=5)
1453
+ sample_batch_parser.add_argument("--result-file", required=True)
1454
+ sample_batch_parser.set_defaults(func=sample_batch)
1455
+
1456
+ record_parser = subparsers.add_parser("record")
1457
+ add_lane_argument(record_parser)
1458
+ record_parser.add_argument("--run-dir", required=True)
1459
+ record_parser.add_argument("--step-name", required=True)
1460
+ record_parser.add_argument("--name", required=True)
1461
+ record_parser.add_argument("--code-path", required=True)
1462
+ record_parser.add_argument("--results-file", required=True)
1463
+ record_parser.add_argument("--result-file", required=True)
1464
+ record_parser.set_defaults(func=record)
1465
+
1466
+ attach_parser = subparsers.add_parser("attach_analysis")
1467
+ add_lane_argument(attach_parser)
1468
+ attach_parser.add_argument("--run-dir", required=True)
1469
+ attach_parser.add_argument("--step-name")
1470
+ attach_parser.add_argument("--step-from-current", action="store_true")
1471
+ attach_parser.add_argument("--name")
1472
+ attach_parser.add_argument("--name-from-current", action="store_true")
1473
+ attach_parser.add_argument("--parent", type=int, action="append")
1474
+ attach_parser.add_argument("--parent-from-current", action="store_true")
1475
+ attach_parser.add_argument("--code-path")
1476
+ attach_parser.add_argument("--code-from-current", action="store_true")
1477
+ attach_parser.add_argument("--results-file")
1478
+ attach_parser.add_argument("--results-from-current", action="store_true")
1479
+ attach_parser.add_argument("--analysis-file", required=True)
1480
+ attach_parser.add_argument("--result-file", required=True)
1481
+ attach_parser.set_defaults(func=attach_analysis)
1482
+
1483
+ promote_parser = subparsers.add_parser("promote_cognition")
1484
+ promote_parser.add_argument("--run-dir", required=True)
1485
+ promote_parser.add_argument("--lane", type=int, action="append", required=True)
1486
+ promote_parser.add_argument("--result-file", required=True)
1487
+ promote_parser.set_defaults(func=promote_cognition)
1488
+
1489
+ stop_signals_parser = subparsers.add_parser("compute_stop_signals")
1490
+ stop_signals_parser.add_argument("--run-dir", required=True)
1491
+ stop_signals_parser.add_argument("--result-file", required=True)
1492
+ stop_signals_parser.set_defaults(func=compute_stop_signals)
1493
+
1494
+ return parser
1495
+
1496
+
1497
+ def main(argv: list[str] | None = None) -> int:
1498
+ args = build_parser().parse_args(argv)
1499
+ return args.func(args)
1500
+
1501
+
1502
+ if __name__ == "__main__":
1503
+ raise SystemExit(main())