oh-my-codex 0.13.2 → 0.14.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 (406) hide show
  1. package/Cargo.lock +5 -5
  2. package/Cargo.toml +1 -1
  3. package/README.md +14 -8
  4. package/crates/omx-explore/src/main.rs +94 -1
  5. package/crates/omx-sparkshell/src/codex_bridge.rs +59 -12
  6. package/crates/omx-sparkshell/tests/execution.rs +48 -0
  7. package/dist/autoresearch/__tests__/skill-validation.test.d.ts +2 -0
  8. package/dist/autoresearch/__tests__/skill-validation.test.d.ts.map +1 -0
  9. package/dist/autoresearch/__tests__/skill-validation.test.js +91 -0
  10. package/dist/autoresearch/__tests__/skill-validation.test.js.map +1 -0
  11. package/dist/autoresearch/skill-validation.d.ts +13 -0
  12. package/dist/autoresearch/skill-validation.d.ts.map +1 -0
  13. package/dist/autoresearch/skill-validation.js +165 -0
  14. package/dist/autoresearch/skill-validation.js.map +1 -0
  15. package/dist/catalog/__tests__/schema.test.js +6 -0
  16. package/dist/catalog/__tests__/schema.test.js.map +1 -1
  17. package/dist/cli/__tests__/autoresearch-guided.test.js +236 -273
  18. package/dist/cli/__tests__/autoresearch-guided.test.js.map +1 -1
  19. package/dist/cli/__tests__/autoresearch.test.js +64 -653
  20. package/dist/cli/__tests__/autoresearch.test.js.map +1 -1
  21. package/dist/cli/__tests__/explore.test.js +33 -1
  22. package/dist/cli/__tests__/explore.test.js.map +1 -1
  23. package/dist/cli/__tests__/index.test.js +18 -2
  24. package/dist/cli/__tests__/index.test.js.map +1 -1
  25. package/dist/cli/__tests__/nested-help-routing.test.js +2 -1
  26. package/dist/cli/__tests__/nested-help-routing.test.js.map +1 -1
  27. package/dist/cli/__tests__/package-bin-contract.test.js +5 -0
  28. package/dist/cli/__tests__/package-bin-contract.test.js.map +1 -1
  29. package/dist/cli/__tests__/question.test.d.ts +2 -0
  30. package/dist/cli/__tests__/question.test.d.ts.map +1 -0
  31. package/dist/cli/__tests__/question.test.js +166 -0
  32. package/dist/cli/__tests__/question.test.js.map +1 -0
  33. package/dist/cli/__tests__/session-search-help.test.js +1 -1
  34. package/dist/cli/__tests__/session-search-help.test.js.map +1 -1
  35. package/dist/cli/__tests__/setup-agents-overwrite.test.js +32 -7
  36. package/dist/cli/__tests__/setup-agents-overwrite.test.js.map +1 -1
  37. package/dist/cli/__tests__/setup-refresh.test.js +8 -6
  38. package/dist/cli/__tests__/setup-refresh.test.js.map +1 -1
  39. package/dist/cli/__tests__/setup-skills-overwrite.test.js +2 -0
  40. package/dist/cli/__tests__/setup-skills-overwrite.test.js.map +1 -1
  41. package/dist/cli/__tests__/sparkshell-cli.test.js +23 -0
  42. package/dist/cli/__tests__/sparkshell-cli.test.js.map +1 -1
  43. package/dist/cli/__tests__/uninstall.test.js +65 -5
  44. package/dist/cli/__tests__/uninstall.test.js.map +1 -1
  45. package/dist/cli/__tests__/update.test.js +360 -26
  46. package/dist/cli/__tests__/update.test.js.map +1 -1
  47. package/dist/cli/autoresearch-guided.d.ts +24 -7
  48. package/dist/cli/autoresearch-guided.d.ts.map +1 -1
  49. package/dist/cli/autoresearch-guided.js +189 -130
  50. package/dist/cli/autoresearch-guided.js.map +1 -1
  51. package/dist/cli/autoresearch.d.ts +3 -2
  52. package/dist/cli/autoresearch.d.ts.map +1 -1
  53. package/dist/cli/autoresearch.js +29 -305
  54. package/dist/cli/autoresearch.js.map +1 -1
  55. package/dist/cli/doctor.d.ts.map +1 -1
  56. package/dist/cli/doctor.js +43 -0
  57. package/dist/cli/doctor.js.map +1 -1
  58. package/dist/cli/explore.d.ts.map +1 -1
  59. package/dist/cli/explore.js +18 -3
  60. package/dist/cli/explore.js.map +1 -1
  61. package/dist/cli/index.d.ts +2 -1
  62. package/dist/cli/index.d.ts.map +1 -1
  63. package/dist/cli/index.js +15 -3
  64. package/dist/cli/index.js.map +1 -1
  65. package/dist/cli/question.d.ts +3 -0
  66. package/dist/cli/question.d.ts.map +1 -0
  67. package/dist/cli/question.js +182 -0
  68. package/dist/cli/question.js.map +1 -0
  69. package/dist/cli/setup.d.ts.map +1 -1
  70. package/dist/cli/setup.js +25 -3
  71. package/dist/cli/setup.js.map +1 -1
  72. package/dist/cli/sparkshell.d.ts.map +1 -1
  73. package/dist/cli/sparkshell.js +11 -1
  74. package/dist/cli/sparkshell.js.map +1 -1
  75. package/dist/cli/team.d.ts.map +1 -1
  76. package/dist/cli/team.js +159 -394
  77. package/dist/cli/team.js.map +1 -1
  78. package/dist/cli/uninstall.d.ts.map +1 -1
  79. package/dist/cli/uninstall.js +3 -1
  80. package/dist/cli/uninstall.js.map +1 -1
  81. package/dist/cli/update.d.ts +37 -9
  82. package/dist/cli/update.d.ts.map +1 -1
  83. package/dist/cli/update.js +204 -26
  84. package/dist/cli/update.js.map +1 -1
  85. package/dist/config/__tests__/generator-idempotent.test.js +51 -14
  86. package/dist/config/__tests__/generator-idempotent.test.js.map +1 -1
  87. package/dist/config/__tests__/generator-notify.test.js +35 -10
  88. package/dist/config/__tests__/generator-notify.test.js.map +1 -1
  89. package/dist/config/generator.d.ts +1 -0
  90. package/dist/config/generator.d.ts.map +1 -1
  91. package/dist/config/generator.js +61 -7
  92. package/dist/config/generator.js.map +1 -1
  93. package/dist/hooks/__tests__/analyze-routing-contract.test.js +22 -13
  94. package/dist/hooks/__tests__/analyze-routing-contract.test.js.map +1 -1
  95. package/dist/hooks/__tests__/anti-slop-workflow.test.js +3 -3
  96. package/dist/hooks/__tests__/anti-slop-workflow.test.js.map +1 -1
  97. package/dist/hooks/__tests__/code-review-skill-contract.test.d.ts +2 -0
  98. package/dist/hooks/__tests__/code-review-skill-contract.test.d.ts.map +1 -0
  99. package/dist/hooks/__tests__/code-review-skill-contract.test.js +56 -0
  100. package/dist/hooks/__tests__/code-review-skill-contract.test.js.map +1 -0
  101. package/dist/hooks/__tests__/debugger-log-recency-contract.test.js +2 -2
  102. package/dist/hooks/__tests__/debugger-log-recency-contract.test.js.map +1 -1
  103. package/dist/hooks/__tests__/deep-interview-contract.test.js +51 -5
  104. package/dist/hooks/__tests__/deep-interview-contract.test.js.map +1 -1
  105. package/dist/hooks/__tests__/explicit-terminal-stop-docs-contract.test.d.ts +2 -0
  106. package/dist/hooks/__tests__/explicit-terminal-stop-docs-contract.test.d.ts.map +1 -0
  107. package/dist/hooks/__tests__/explicit-terminal-stop-docs-contract.test.js +43 -0
  108. package/dist/hooks/__tests__/explicit-terminal-stop-docs-contract.test.js.map +1 -0
  109. package/dist/hooks/__tests__/explicit-terminal-stop-model-docs-contract.test.d.ts +2 -0
  110. package/dist/hooks/__tests__/explicit-terminal-stop-model-docs-contract.test.d.ts.map +1 -0
  111. package/dist/hooks/__tests__/explicit-terminal-stop-model-docs-contract.test.js +38 -0
  112. package/dist/hooks/__tests__/explicit-terminal-stop-model-docs-contract.test.js.map +1 -0
  113. package/dist/hooks/__tests__/explore-sparkshell-guidance-contract.test.js +2 -2
  114. package/dist/hooks/__tests__/explore-sparkshell-guidance-contract.test.js.map +1 -1
  115. package/dist/hooks/__tests__/keyword-detector.test.js +308 -17
  116. package/dist/hooks/__tests__/keyword-detector.test.js.map +1 -1
  117. package/dist/hooks/__tests__/notify-fallback-watcher.test.js +570 -2
  118. package/dist/hooks/__tests__/notify-fallback-watcher.test.js.map +1 -1
  119. package/dist/hooks/__tests__/notify-hook-auto-nudge.test.js +717 -16
  120. package/dist/hooks/__tests__/notify-hook-auto-nudge.test.js.map +1 -1
  121. package/dist/hooks/__tests__/notify-hook-cross-worktree-heartbeat.test.js +25 -0
  122. package/dist/hooks/__tests__/notify-hook-cross-worktree-heartbeat.test.js.map +1 -1
  123. package/dist/hooks/__tests__/notify-hook-managed-tmux.test.js +894 -1
  124. package/dist/hooks/__tests__/notify-hook-managed-tmux.test.js.map +1 -1
  125. package/dist/hooks/__tests__/notify-hook-ralph-resume.test.js +34 -0
  126. package/dist/hooks/__tests__/notify-hook-ralph-resume.test.js.map +1 -1
  127. package/dist/hooks/__tests__/notify-hook-tmux-heal.test.js +132 -0
  128. package/dist/hooks/__tests__/notify-hook-tmux-heal.test.js.map +1 -1
  129. package/dist/hooks/__tests__/prompt-guidance-contract.test.js +22 -4
  130. package/dist/hooks/__tests__/prompt-guidance-contract.test.js.map +1 -1
  131. package/dist/hooks/__tests__/prompt-guidance-fragments.test.js +4 -2
  132. package/dist/hooks/__tests__/prompt-guidance-fragments.test.js.map +1 -1
  133. package/dist/hooks/__tests__/prompt-guidance-test-helpers.d.ts +1 -0
  134. package/dist/hooks/__tests__/prompt-guidance-test-helpers.d.ts.map +1 -1
  135. package/dist/hooks/__tests__/prompt-guidance-test-helpers.js +19 -1
  136. package/dist/hooks/__tests__/prompt-guidance-test-helpers.js.map +1 -1
  137. package/dist/hooks/__tests__/prompt-guidance-wave-two.test.js +28 -0
  138. package/dist/hooks/__tests__/prompt-guidance-wave-two.test.js.map +1 -1
  139. package/dist/hooks/__tests__/prompt-orchestration-boundary.test.js +5 -4
  140. package/dist/hooks/__tests__/prompt-orchestration-boundary.test.js.map +1 -1
  141. package/dist/hooks/__tests__/prompt-team-routing.test.js +2 -2
  142. package/dist/hooks/__tests__/prompt-team-routing.test.js.map +1 -1
  143. package/dist/hooks/__tests__/triage-config.test.d.ts +2 -0
  144. package/dist/hooks/__tests__/triage-config.test.d.ts.map +1 -0
  145. package/dist/hooks/__tests__/triage-config.test.js +211 -0
  146. package/dist/hooks/__tests__/triage-config.test.js.map +1 -0
  147. package/dist/hooks/__tests__/triage-heuristic.test.d.ts +2 -0
  148. package/dist/hooks/__tests__/triage-heuristic.test.d.ts.map +1 -0
  149. package/dist/hooks/__tests__/triage-heuristic.test.js +230 -0
  150. package/dist/hooks/__tests__/triage-heuristic.test.js.map +1 -0
  151. package/dist/hooks/__tests__/triage-state.test.d.ts +2 -0
  152. package/dist/hooks/__tests__/triage-state.test.d.ts.map +1 -0
  153. package/dist/hooks/__tests__/triage-state.test.js +426 -0
  154. package/dist/hooks/__tests__/triage-state.test.js.map +1 -0
  155. package/dist/hooks/keyword-detector.d.ts +26 -7
  156. package/dist/hooks/keyword-detector.d.ts.map +1 -1
  157. package/dist/hooks/keyword-detector.js +97 -26
  158. package/dist/hooks/keyword-detector.js.map +1 -1
  159. package/dist/hooks/keyword-registry.d.ts.map +1 -1
  160. package/dist/hooks/keyword-registry.js +16 -9
  161. package/dist/hooks/keyword-registry.js.map +1 -1
  162. package/dist/hooks/prompt-guidance-contract.d.ts.map +1 -1
  163. package/dist/hooks/prompt-guidance-contract.js +28 -1
  164. package/dist/hooks/prompt-guidance-contract.js.map +1 -1
  165. package/dist/hooks/triage-config.d.ts +33 -0
  166. package/dist/hooks/triage-config.d.ts.map +1 -0
  167. package/dist/hooks/triage-config.js +87 -0
  168. package/dist/hooks/triage-config.js.map +1 -0
  169. package/dist/hooks/triage-heuristic.d.ts +20 -0
  170. package/dist/hooks/triage-heuristic.d.ts.map +1 -0
  171. package/dist/hooks/triage-heuristic.js +210 -0
  172. package/dist/hooks/triage-heuristic.js.map +1 -0
  173. package/dist/hooks/triage-state.d.ts +63 -0
  174. package/dist/hooks/triage-state.d.ts.map +1 -0
  175. package/dist/hooks/triage-state.js +138 -0
  176. package/dist/hooks/triage-state.js.map +1 -0
  177. package/dist/hud/__tests__/reconcile.test.js +20 -0
  178. package/dist/hud/__tests__/reconcile.test.js.map +1 -1
  179. package/dist/hud/reconcile.d.ts +1 -0
  180. package/dist/hud/reconcile.d.ts.map +1 -1
  181. package/dist/hud/reconcile.js +2 -1
  182. package/dist/hud/reconcile.js.map +1 -1
  183. package/dist/mcp/__tests__/bootstrap.test.js +5 -24
  184. package/dist/mcp/__tests__/bootstrap.test.js.map +1 -1
  185. package/dist/mcp/__tests__/state-server.test.js +127 -0
  186. package/dist/mcp/__tests__/state-server.test.js.map +1 -1
  187. package/dist/mcp/bootstrap.d.ts +1 -1
  188. package/dist/mcp/bootstrap.d.ts.map +1 -1
  189. package/dist/mcp/bootstrap.js +3 -11
  190. package/dist/mcp/bootstrap.js.map +1 -1
  191. package/dist/mcp/state-server.d.ts +25 -0
  192. package/dist/mcp/state-server.d.ts.map +1 -1
  193. package/dist/mcp/state-server.js +41 -0
  194. package/dist/mcp/state-server.js.map +1 -1
  195. package/dist/modes/__tests__/base-ralph-contract.test.js +15 -0
  196. package/dist/modes/__tests__/base-ralph-contract.test.js.map +1 -1
  197. package/dist/modes/base.d.ts +1 -0
  198. package/dist/modes/base.d.ts.map +1 -1
  199. package/dist/modes/base.js +22 -6
  200. package/dist/modes/base.js.map +1 -1
  201. package/dist/notifications/__tests__/index.test.js +75 -0
  202. package/dist/notifications/__tests__/index.test.js.map +1 -1
  203. package/dist/notifications/__tests__/session-status.test.js +90 -0
  204. package/dist/notifications/__tests__/session-status.test.js.map +1 -1
  205. package/dist/notifications/index.d.ts.map +1 -1
  206. package/dist/notifications/index.js +39 -22
  207. package/dist/notifications/index.js.map +1 -1
  208. package/dist/notifications/session-status.d.ts +2 -0
  209. package/dist/notifications/session-status.d.ts.map +1 -1
  210. package/dist/notifications/session-status.js +19 -4
  211. package/dist/notifications/session-status.js.map +1 -1
  212. package/dist/openclaw/index.d.ts +5 -3
  213. package/dist/openclaw/index.d.ts.map +1 -1
  214. package/dist/openclaw/index.js +5 -3
  215. package/dist/openclaw/index.js.map +1 -1
  216. package/dist/question/__tests__/client.test.d.ts +2 -0
  217. package/dist/question/__tests__/client.test.d.ts.map +1 -0
  218. package/dist/question/__tests__/client.test.js +70 -0
  219. package/dist/question/__tests__/client.test.js.map +1 -0
  220. package/dist/question/__tests__/deep-interview.test.d.ts +2 -0
  221. package/dist/question/__tests__/deep-interview.test.d.ts.map +1 -0
  222. package/dist/question/__tests__/deep-interview.test.js +118 -0
  223. package/dist/question/__tests__/deep-interview.test.js.map +1 -0
  224. package/dist/question/__tests__/policy.test.d.ts +2 -0
  225. package/dist/question/__tests__/policy.test.d.ts.map +1 -0
  226. package/dist/question/__tests__/policy.test.js +107 -0
  227. package/dist/question/__tests__/policy.test.js.map +1 -0
  228. package/dist/question/__tests__/renderer.test.d.ts +2 -0
  229. package/dist/question/__tests__/renderer.test.d.ts.map +1 -0
  230. package/dist/question/__tests__/renderer.test.js +238 -0
  231. package/dist/question/__tests__/renderer.test.js.map +1 -0
  232. package/dist/question/__tests__/state.test.d.ts +2 -0
  233. package/dist/question/__tests__/state.test.d.ts.map +1 -0
  234. package/dist/question/__tests__/state.test.js +75 -0
  235. package/dist/question/__tests__/state.test.js.map +1 -0
  236. package/dist/question/__tests__/types.test.d.ts +2 -0
  237. package/dist/question/__tests__/types.test.d.ts.map +1 -0
  238. package/dist/question/__tests__/types.test.js +44 -0
  239. package/dist/question/__tests__/types.test.js.map +1 -0
  240. package/dist/question/__tests__/ui.test.d.ts +2 -0
  241. package/dist/question/__tests__/ui.test.d.ts.map +1 -0
  242. package/dist/question/__tests__/ui.test.js +169 -0
  243. package/dist/question/__tests__/ui.test.js.map +1 -0
  244. package/dist/question/client.d.ts +54 -0
  245. package/dist/question/client.d.ts.map +1 -0
  246. package/dist/question/client.js +77 -0
  247. package/dist/question/client.js.map +1 -0
  248. package/dist/question/deep-interview.d.ts +30 -0
  249. package/dist/question/deep-interview.d.ts.map +1 -0
  250. package/dist/question/deep-interview.js +118 -0
  251. package/dist/question/deep-interview.js.map +1 -0
  252. package/dist/question/policy.d.ts +18 -0
  253. package/dist/question/policy.d.ts.map +1 -0
  254. package/dist/question/policy.js +77 -0
  255. package/dist/question/policy.js.map +1 -0
  256. package/dist/question/renderer.d.ts +20 -0
  257. package/dist/question/renderer.d.ts.map +1 -0
  258. package/dist/question/renderer.js +190 -0
  259. package/dist/question/renderer.js.map +1 -0
  260. package/dist/question/state.d.ts +19 -0
  261. package/dist/question/state.d.ts.map +1 -0
  262. package/dist/question/state.js +108 -0
  263. package/dist/question/state.js.map +1 -0
  264. package/dist/question/types.d.ts +66 -0
  265. package/dist/question/types.d.ts.map +1 -0
  266. package/dist/question/types.js +82 -0
  267. package/dist/question/types.js.map +1 -0
  268. package/dist/question/ui.d.ts +38 -0
  269. package/dist/question/ui.d.ts.map +1 -0
  270. package/dist/question/ui.js +321 -0
  271. package/dist/question/ui.js.map +1 -0
  272. package/dist/ralph/contract.d.ts +1 -1
  273. package/dist/ralph/contract.d.ts.map +1 -1
  274. package/dist/ralph/contract.js +4 -1
  275. package/dist/ralph/contract.js.map +1 -1
  276. package/dist/ralplan/runtime.js +1 -1
  277. package/dist/ralplan/runtime.js.map +1 -1
  278. package/dist/runtime/__tests__/run-loop.test.d.ts +2 -0
  279. package/dist/runtime/__tests__/run-loop.test.d.ts.map +1 -0
  280. package/dist/runtime/__tests__/run-loop.test.js +35 -0
  281. package/dist/runtime/__tests__/run-loop.test.js.map +1 -0
  282. package/dist/runtime/__tests__/run-outcome.test.d.ts +2 -0
  283. package/dist/runtime/__tests__/run-outcome.test.d.ts.map +1 -0
  284. package/dist/runtime/__tests__/run-outcome.test.js +102 -0
  285. package/dist/runtime/__tests__/run-outcome.test.js.map +1 -0
  286. package/dist/runtime/__tests__/run-state.test.d.ts +2 -0
  287. package/dist/runtime/__tests__/run-state.test.d.ts.map +1 -0
  288. package/dist/runtime/__tests__/run-state.test.js +37 -0
  289. package/dist/runtime/__tests__/run-state.test.js.map +1 -0
  290. package/dist/runtime/run-loop.d.ts +45 -0
  291. package/dist/runtime/run-loop.d.ts.map +1 -0
  292. package/dist/runtime/run-loop.js +51 -0
  293. package/dist/runtime/run-loop.js.map +1 -0
  294. package/dist/runtime/run-outcome.d.ts +46 -0
  295. package/dist/runtime/run-outcome.d.ts.map +1 -0
  296. package/dist/runtime/run-outcome.js +285 -0
  297. package/dist/runtime/run-outcome.js.map +1 -0
  298. package/dist/runtime/run-state.d.ts +40 -0
  299. package/dist/runtime/run-state.d.ts.map +1 -0
  300. package/dist/runtime/run-state.js +120 -0
  301. package/dist/runtime/run-state.js.map +1 -0
  302. package/dist/runtime/terminal-lifecycle.d.ts +11 -0
  303. package/dist/runtime/terminal-lifecycle.d.ts.map +1 -0
  304. package/dist/runtime/terminal-lifecycle.js +52 -0
  305. package/dist/runtime/terminal-lifecycle.js.map +1 -0
  306. package/dist/scripts/__tests__/codex-native-hook.test.js +1459 -126
  307. package/dist/scripts/__tests__/codex-native-hook.test.js.map +1 -1
  308. package/dist/scripts/__tests__/postinstall.test.d.ts +2 -0
  309. package/dist/scripts/__tests__/postinstall.test.d.ts.map +1 -0
  310. package/dist/scripts/__tests__/postinstall.test.js +178 -0
  311. package/dist/scripts/__tests__/postinstall.test.js.map +1 -0
  312. package/dist/scripts/codex-native-hook.d.ts +3 -0
  313. package/dist/scripts/codex-native-hook.d.ts.map +1 -1
  314. package/dist/scripts/codex-native-hook.js +308 -61
  315. package/dist/scripts/codex-native-hook.js.map +1 -1
  316. package/dist/scripts/notify-fallback-watcher.js +81 -2
  317. package/dist/scripts/notify-fallback-watcher.js.map +1 -1
  318. package/dist/scripts/notify-hook/auto-nudge.d.ts +27 -0
  319. package/dist/scripts/notify-hook/auto-nudge.d.ts.map +1 -1
  320. package/dist/scripts/notify-hook/auto-nudge.js +83 -20
  321. package/dist/scripts/notify-hook/auto-nudge.js.map +1 -1
  322. package/dist/scripts/notify-hook/managed-tmux.d.ts.map +1 -1
  323. package/dist/scripts/notify-hook/managed-tmux.js +64 -38
  324. package/dist/scripts/notify-hook/managed-tmux.js.map +1 -1
  325. package/dist/scripts/notify-hook/ralph-session-resume.js +1 -1
  326. package/dist/scripts/notify-hook/ralph-session-resume.js.map +1 -1
  327. package/dist/scripts/notify-hook.js +15 -5
  328. package/dist/scripts/notify-hook.js.map +1 -1
  329. package/dist/scripts/postinstall.d.ts +22 -0
  330. package/dist/scripts/postinstall.d.ts.map +1 -0
  331. package/dist/scripts/postinstall.js +105 -0
  332. package/dist/scripts/postinstall.js.map +1 -0
  333. package/dist/scripts/sync-prompt-guidance-fragments.js +5 -0
  334. package/dist/scripts/sync-prompt-guidance-fragments.js.map +1 -1
  335. package/dist/state/__tests__/operations-ralph-phase.test.js +21 -0
  336. package/dist/state/__tests__/operations-ralph-phase.test.js.map +1 -1
  337. package/dist/state/__tests__/operations.test.js +18 -0
  338. package/dist/state/__tests__/operations.test.js.map +1 -1
  339. package/dist/state/__tests__/workflow-transition.test.js +11 -0
  340. package/dist/state/__tests__/workflow-transition.test.js.map +1 -1
  341. package/dist/state/operations.d.ts.map +1 -1
  342. package/dist/state/operations.js +15 -0
  343. package/dist/state/operations.js.map +1 -1
  344. package/dist/state/workflow-transition-reconcile.d.ts.map +1 -1
  345. package/dist/state/workflow-transition-reconcile.js +14 -1
  346. package/dist/state/workflow-transition-reconcile.js.map +1 -1
  347. package/dist/state/workflow-transition.d.ts.map +1 -1
  348. package/dist/state/workflow-transition.js +3 -1
  349. package/dist/state/workflow-transition.js.map +1 -1
  350. package/dist/team/__tests__/followup-planner.test.js +15 -0
  351. package/dist/team/__tests__/followup-planner.test.js.map +1 -1
  352. package/dist/team/__tests__/role-router.test.js +47 -0
  353. package/dist/team/__tests__/role-router.test.js.map +1 -1
  354. package/dist/team/__tests__/runtime.test.js +108 -2
  355. package/dist/team/__tests__/runtime.test.js.map +1 -1
  356. package/dist/team/followup-planner.d.ts.map +1 -1
  357. package/dist/team/followup-planner.js +31 -9
  358. package/dist/team/followup-planner.js.map +1 -1
  359. package/dist/team/role-router.d.ts.map +1 -1
  360. package/dist/team/role-router.js +73 -0
  361. package/dist/team/role-router.js.map +1 -1
  362. package/dist/team/runtime.d.ts.map +1 -1
  363. package/dist/team/runtime.js +18 -4
  364. package/dist/team/runtime.js.map +1 -1
  365. package/dist/utils/__tests__/dep-versions.test.js +25 -8
  366. package/dist/utils/__tests__/dep-versions.test.js.map +1 -1
  367. package/dist/utils/__tests__/paths.test.js +45 -0
  368. package/dist/utils/__tests__/paths.test.js.map +1 -1
  369. package/dist/utils/paths.d.ts +2 -0
  370. package/dist/utils/paths.d.ts.map +1 -1
  371. package/dist/utils/paths.js +22 -7
  372. package/dist/utils/paths.js.map +1 -1
  373. package/dist/verification/__tests__/ci-rust-gates.test.js +1 -1
  374. package/dist/verification/__tests__/ci-rust-gates.test.js.map +1 -1
  375. package/package.json +4 -2
  376. package/prompts/architect.md +4 -0
  377. package/prompts/code-reviewer.md +3 -0
  378. package/prompts/dependency-expert.md +3 -0
  379. package/prompts/executor.md +5 -0
  380. package/prompts/explore.md +2 -0
  381. package/prompts/planner.md +5 -0
  382. package/prompts/product-analyst.md +8 -8
  383. package/prompts/researcher.md +78 -30
  384. package/prompts/verifier.md +4 -0
  385. package/skills/autoresearch/SKILL.md +68 -0
  386. package/skills/code-review/SKILL.md +94 -28
  387. package/skills/deep-interview/SKILL.md +100 -9
  388. package/skills/help/SKILL.md +3 -1
  389. package/skills/ralplan/SKILL.md +1 -0
  390. package/skills/team/SKILL.md +1 -0
  391. package/skills/ultrawork/SKILL.md +1 -0
  392. package/src/scripts/__tests__/codex-native-hook.test.ts +2373 -692
  393. package/src/scripts/__tests__/postinstall.test.ts +210 -0
  394. package/src/scripts/codex-native-hook.ts +365 -66
  395. package/src/scripts/notify-fallback-watcher.ts +92 -2
  396. package/src/scripts/notify-hook/auto-nudge.ts +89 -20
  397. package/src/scripts/notify-hook/managed-tmux.ts +70 -31
  398. package/src/scripts/notify-hook/ralph-session-resume.ts +1 -1
  399. package/src/scripts/notify-hook.ts +23 -5
  400. package/src/scripts/postinstall-bootstrap.js +23 -0
  401. package/src/scripts/postinstall.ts +161 -0
  402. package/src/scripts/sync-prompt-guidance-fragments.ts +4 -0
  403. package/templates/AGENTS.md +48 -37
  404. package/templates/catalog-manifest.json +7 -0
  405. package/templates/model-instructions/explore-lightweight-AGENTS.md +11 -0
  406. package/templates/model-instructions/sparkshell-lightweight-AGENTS.md +10 -0
@@ -4,6 +4,7 @@ import { existsSync } from "node:fs";
4
4
  import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
5
5
  import { tmpdir } from "node:os";
6
6
  import { dirname, join } from "node:path";
7
+ import { pathToFileURL } from "node:url";
7
8
  import { afterEach, beforeEach, describe, it } from "node:test";
8
9
  import { buildManagedCodexHooksConfig } from "../../config/codex-hooks.js";
9
10
  import {
@@ -14,16 +15,42 @@ import {
14
15
  } from "../../team/state.js";
15
16
  import {
16
17
  dispatchCodexNativeHook,
18
+ isCodexNativeHookMainModule,
17
19
  mapCodexHookEventToOmxEvent,
18
20
  resolveSessionOwnerPidFromAncestry,
19
21
  } from "../codex-native-hook.js";
20
22
  import { writeSessionStart } from "../../hooks/session.js";
23
+ import { resetTriageConfigCache } from "../../hooks/triage-config.js";
21
24
 
22
25
  async function writeJson(path: string, value: unknown): Promise<void> {
23
26
  await mkdir(dirname(path), { recursive: true }).catch(() => {});
24
27
  await writeFile(path, JSON.stringify(value, null, 2));
25
28
  }
26
29
 
30
+ async function writeHookCounterPlugin(cwd: string): Promise<string> {
31
+ const markerPath = join(cwd, ".omx", "stop-hook-counter.json");
32
+ await mkdir(join(cwd, ".omx", "hooks"), { recursive: true });
33
+ await writeFile(
34
+ join(cwd, ".omx", "hooks", "count-stop-hook.mjs"),
35
+ `import { mkdir, readFile, writeFile } from "node:fs/promises";
36
+ import { dirname, join } from "node:path";
37
+
38
+ export async function onHookEvent(event) {
39
+ if (event.event !== "stop") return;
40
+ const outPath = join(process.cwd(), ".omx", "stop-hook-counter.json");
41
+ await mkdir(dirname(outPath), { recursive: true });
42
+ let count = 0;
43
+ try {
44
+ count = JSON.parse(await readFile(outPath, "utf-8")).count || 0;
45
+ } catch {}
46
+ await writeFile(outPath, JSON.stringify({ count: count + 1 }, null, 2));
47
+ }
48
+ `,
49
+ "utf-8",
50
+ );
51
+ return markerPath;
52
+ }
53
+
27
54
  async function writeReleaseReadinessLeaderAttention(
28
55
  teamName: string,
29
56
  sessionId: string,
@@ -142,6 +169,25 @@ describe("codex native hook config", () => {
142
169
  });
143
170
 
144
171
  describe("codex native hook dispatch", () => {
172
+ it("treats space-containing argv entry paths as the main module", () => {
173
+ const entryPath = "/tmp/omx native/codex-native-hook.js";
174
+
175
+ assert.equal(
176
+ isCodexNativeHookMainModule(pathToFileURL(entryPath).href, entryPath),
177
+ true,
178
+ );
179
+ });
180
+
181
+ it("does not treat a different module url as the main module", () => {
182
+ assert.equal(
183
+ isCodexNativeHookMainModule(
184
+ pathToFileURL("/tmp/omx native/other-script.js").href,
185
+ "/tmp/omx native/codex-native-hook.js",
186
+ ),
187
+ false,
188
+ );
189
+ });
190
+
145
191
  it("emits deterministic JSON stdout when CLI stdin is malformed", () => {
146
192
  const stdout = execFileSync(
147
193
  process.execPath,
@@ -262,7 +308,46 @@ describe("codex native hook dispatch", () => {
262
308
  }
263
309
  });
264
310
 
265
- it("appends .omx/ to repo-root .gitignore during SessionStart when missing", async () => {
311
+ it("passes the canonical OMX session id when UserPromptSubmit revives HUD", async () => {
312
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-hud-session-revive-"));
313
+ try {
314
+ const stateDir = join(cwd, ".omx", "state");
315
+ const canonicalSessionId = "omx-launch-hud";
316
+ const nativeSessionId = "codex-native-hud";
317
+ await mkdir(join(stateDir, "sessions", canonicalSessionId), { recursive: true });
318
+ await writeSessionStart(cwd, canonicalSessionId);
319
+
320
+ let reconcileCall: { cwd: string; sessionId?: string } | null = null;
321
+ const promptResult = await dispatchCodexNativeHook(
322
+ {
323
+ hook_event_name: "UserPromptSubmit",
324
+ cwd,
325
+ session_id: nativeSessionId,
326
+ thread_id: "thread-hud",
327
+ turn_id: "turn-hud",
328
+ prompt: "$ralplan fix orphaned hud session handoff",
329
+ },
330
+ {
331
+ cwd,
332
+ reconcileHudForPromptSubmitFn: async (hookCwd, deps = {}) => {
333
+ reconcileCall = { cwd: hookCwd, sessionId: deps.sessionId };
334
+ return { status: 'recreated', paneId: '%9', desiredHeight: 3, duplicateCount: 0 };
335
+ },
336
+ },
337
+ );
338
+
339
+ assert.equal(promptResult.omxEventName, "keyword-detector");
340
+ assert.deepEqual(reconcileCall, { cwd, sessionId: canonicalSessionId });
341
+ assert.equal(existsSync(join(stateDir, "sessions", canonicalSessionId, "skill-active-state.json")), true);
342
+ assert.equal(existsSync(join(stateDir, "sessions", canonicalSessionId, "ralplan-state.json")), true);
343
+ assert.equal(existsSync(join(stateDir, "sessions", nativeSessionId, "skill-active-state.json")), false);
344
+ assert.equal(existsSync(join(stateDir, "sessions", nativeSessionId, "ralplan-state.json")), false);
345
+ } finally {
346
+ await rm(cwd, { recursive: true, force: true });
347
+ }
348
+ });
349
+
350
+ it("adds .omx/ to git info/exclude during SessionStart instead of mutating repo .gitignore", async () => {
266
351
  const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-session-gitignore-"));
267
352
  try {
268
353
  await writeFile(join(cwd, ".gitignore"), "node_modules/\n");
@@ -279,11 +364,68 @@ describe("codex native hook dispatch", () => {
279
364
 
280
365
  assert.equal(result.omxEventName, "session-start");
281
366
  const gitignore = await readFile(join(cwd, ".gitignore"), "utf-8");
282
- assert.match(gitignore, /^node_modules\/\n\.omx\/\n$/);
367
+ assert.equal(gitignore, "node_modules/\n");
368
+ const exclude = await readFile(join(cwd, ".git", "info", "exclude"), "utf-8");
369
+ assert.match(exclude, /(?:^|\n)\.omx\/\n/);
283
370
  assert.match(
284
371
  JSON.stringify(result.outputJson),
285
- /Added \.omx\/ to .*\.gitignore/,
372
+ /Added \.omx\/ to .*\.git[\/]info[\/]exclude/,
373
+ );
374
+ } finally {
375
+ await rm(cwd, { recursive: true, force: true });
376
+ }
377
+ });
378
+
379
+ it("keeps SessionStart quiet when .omx/ is already ignored by repo-level gitignore", async () => {
380
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-session-existing-ignore-"));
381
+ try {
382
+ await writeFile(join(cwd, ".gitignore"), "node_modules/\n.omx/\n");
383
+ execFileSync("git", ["init"], { cwd, stdio: "pipe" });
384
+
385
+ const result = await dispatchCodexNativeHook(
386
+ {
387
+ hook_event_name: "SessionStart",
388
+ cwd,
389
+ session_id: "sess-gitignore-existing",
390
+ },
391
+ { cwd, sessionOwnerPid: 43210 },
392
+ );
393
+
394
+ assert.equal(result.omxEventName, "session-start");
395
+ const gitignore = await readFile(join(cwd, ".gitignore"), "utf-8");
396
+ assert.equal(gitignore, "node_modules/\n.omx/\n");
397
+ const exclude = await readFile(join(cwd, ".git", "info", "exclude"), "utf-8");
398
+ assert.doesNotMatch(exclude, /(?:^|\n)\.omx\/\n/);
399
+ assert.doesNotMatch(JSON.stringify(result.outputJson), /Added \.omx\//);
400
+ } finally {
401
+ await rm(cwd, { recursive: true, force: true });
402
+ }
403
+ });
404
+
405
+ it("respects existing Git ignore resolution before writing local excludes", async () => {
406
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-session-global-ignore-"));
407
+ const excludesFile = join(cwd, "global-ignore");
408
+ try {
409
+ await writeFile(join(cwd, ".gitignore"), "node_modules/\n");
410
+ await writeFile(excludesFile, ".omx/\n");
411
+ execFileSync("git", ["init"], { cwd, stdio: "pipe" });
412
+ execFileSync("git", ["config", "core.excludesfile", excludesFile], { cwd, stdio: "pipe" });
413
+
414
+ const result = await dispatchCodexNativeHook(
415
+ {
416
+ hook_event_name: "SessionStart",
417
+ cwd,
418
+ session_id: "sess-gitignore-global",
419
+ },
420
+ { cwd, sessionOwnerPid: 43210 },
286
421
  );
422
+
423
+ assert.equal(result.omxEventName, "session-start");
424
+ const gitignore = await readFile(join(cwd, ".gitignore"), "utf-8");
425
+ assert.equal(gitignore, "node_modules/\n");
426
+ const exclude = await readFile(join(cwd, ".git", "info", "exclude"), "utf-8");
427
+ assert.doesNotMatch(exclude, /(?:^|\n)\.omx\/\n/);
428
+ assert.doesNotMatch(JSON.stringify(result.outputJson), /Added \.omx\//);
287
429
  } finally {
288
430
  await rm(cwd, { recursive: true, force: true });
289
431
  }
@@ -482,7 +624,14 @@ describe("codex native hook dispatch", () => {
482
624
 
483
625
  assert.equal(result.omxEventName, "keyword-detector");
484
626
  assert.equal(result.skillState, null);
485
- assert.equal(result.outputJson, null);
627
+ // Triage may inject advisory LIGHT/explore context for the question-shaped
628
+ // prompt, but the invariant this test guards is that no Ralph workflow state
629
+ // is seeded and no Ralph-activation message is emitted.
630
+ const advisoryContext = String(
631
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext || "",
632
+ );
633
+ assert.doesNotMatch(advisoryContext, /skill:\s*ralph/i);
634
+ assert.doesNotMatch(advisoryContext, /ralph-state\.json/i);
486
635
  assert.equal(existsSync(join(cwd, ".omx", "state", "skill-active-state.json")), false);
487
636
  assert.equal(existsSync(join(cwd, ".omx", "state", "sessions", "sess-ralph-plain-text", "skill-active-state.json")), false);
488
637
  assert.equal(existsSync(join(cwd, ".omx", "state", "sessions", "sess-ralph-plain-text", "ralph-state.json")), false);
@@ -491,6 +640,67 @@ describe("codex native hook dispatch", () => {
491
640
  }
492
641
  });
493
642
 
643
+ it("adds execution handoff context for non-keyword prompts that authorize implementation", async () => {
644
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-execution-handoff-"));
645
+ try {
646
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
647
+ const prompts = [
648
+ "按照这个plan开始执行优化",
649
+ "开始执行",
650
+ "继续优化",
651
+ "直接修复",
652
+ ];
653
+
654
+ for (const [index, prompt] of prompts.entries()) {
655
+ const result = await dispatchCodexNativeHook(
656
+ {
657
+ hook_event_name: "UserPromptSubmit",
658
+ cwd,
659
+ session_id: `sess-exec-handoff-${index}`,
660
+ thread_id: `thread-exec-handoff-${index}`,
661
+ turn_id: `turn-exec-handoff-${index}`,
662
+ prompt,
663
+ },
664
+ { cwd },
665
+ );
666
+
667
+ const message = String(
668
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext || "",
669
+ );
670
+ assert.match(message, /execution handoff/i, prompt);
671
+ assert.match(message, /Do not restate the prior plan/i, prompt);
672
+ }
673
+ } finally {
674
+ await rm(cwd, { recursive: true, force: true });
675
+ }
676
+ });
677
+
678
+ it("adds latest-followup priority context for short same-thread follow-up prompts", async () => {
679
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-followup-priority-"));
680
+ try {
681
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
682
+ const result = await dispatchCodexNativeHook(
683
+ {
684
+ hook_event_name: "UserPromptSubmit",
685
+ cwd,
686
+ session_id: "sess-followup-priority",
687
+ thread_id: "thread-followup-priority",
688
+ turn_id: "turn-followup-priority",
689
+ prompt: "这些优化都做了么",
690
+ },
691
+ { cwd },
692
+ );
693
+
694
+ const message = String(
695
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext || "",
696
+ );
697
+ assert.match(message, /same-thread follow-up/i);
698
+ assert.match(message, /prefer it over older unresolved prompts/i);
699
+ } finally {
700
+ await rm(cwd, { recursive: true, force: true });
701
+ }
702
+ });
703
+
494
704
  it("clarifies that prompt-side $ralph activation does not invoke the PRD-gated CLI path", async () => {
495
705
  const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-ralph-routing-"));
496
706
  try {
@@ -521,6 +731,146 @@ describe("codex native hook dispatch", () => {
521
731
  }
522
732
  });
523
733
 
734
+ it("keeps bare keep-going continuation on the active autopilot skill instead of denying with generic ralph overlap", async () => {
735
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-autopilot-bare-continuation-"));
736
+ try {
737
+ const sessionId = "sess-autopilot-cont";
738
+ const sessionDir = join(cwd, ".omx", "state", "sessions", sessionId);
739
+ await mkdir(sessionDir, { recursive: true });
740
+ await writeJson(join(sessionDir, "skill-active-state.json"), {
741
+ version: 1,
742
+ active: true,
743
+ skill: "autopilot",
744
+ keyword: "$autopilot",
745
+ phase: "planning",
746
+ session_id: sessionId,
747
+ active_skills: [
748
+ { skill: "autopilot", phase: "planning", active: true, session_id: sessionId },
749
+ ],
750
+ });
751
+ await writeJson(join(sessionDir, "autopilot-state.json"), {
752
+ active: true,
753
+ mode: "autopilot",
754
+ current_phase: "execution",
755
+ started_at: "2026-04-19T00:00:00.000Z",
756
+ updated_at: "2026-04-19T00:10:00.000Z",
757
+ session_id: sessionId,
758
+ });
759
+
760
+ const result = await dispatchCodexNativeHook(
761
+ {
762
+ hook_event_name: "UserPromptSubmit",
763
+ cwd,
764
+ session_id: sessionId,
765
+ thread_id: "thread-autopilot-cont",
766
+ turn_id: "turn-autopilot-cont",
767
+ prompt: "\ keep going now",
768
+ },
769
+ { cwd },
770
+ );
771
+
772
+ assert.equal(result.omxEventName, "keyword-detector");
773
+ assert.equal(result.skillState?.skill, "autopilot");
774
+ const message = String(
775
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext || "",
776
+ );
777
+ assert.match(message, /"keep going" -> ralph/);
778
+ assert.doesNotMatch(message, /denied workflow keyword/i);
779
+ assert.doesNotMatch(message, /Unsupported workflow overlap: autopilot \+ ralph\./);
780
+ assert.doesNotMatch(message, /Prompt-side `\$ralph` activation/);
781
+ assert.equal(existsSync(join(sessionDir, "ralph-state.json")), false);
782
+ } finally {
783
+ await rm(cwd, { recursive: true, force: true });
784
+ }
785
+ });
786
+
787
+ it("clarifies that prompt-side deep-interview activation must use omx question", async () => {
788
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-deep-interview-routing-"));
789
+ try {
790
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
791
+ const result = await dispatchCodexNativeHook(
792
+ {
793
+ hook_event_name: "UserPromptSubmit",
794
+ cwd,
795
+ session_id: "sess-deep-interview-msg",
796
+ thread_id: "thread-deep-interview-msg",
797
+ turn_id: "turn-deep-interview-msg",
798
+ prompt: "$deep-interview gather requirements",
799
+ },
800
+ { cwd },
801
+ );
802
+
803
+ assert.equal(result.omxEventName, "keyword-detector");
804
+ assert.equal(result.skillState?.skill, "deep-interview");
805
+ const message = String(
806
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext || "",
807
+ );
808
+ assert.match(message, /\$deep-interview" -> deep-interview/);
809
+ assert.match(message, /skill: deep-interview activated and initial state initialized at \.omx\/state\/sessions\/sess-deep-interview-msg\/deep-interview-state\.json; write subsequent updates via omx_state MCP\./);
810
+ assert.match(message, /Deep-interview must ask each interview round via `omx question`/);
811
+ assert.match(message, /do not fall back to `request_user_input` or plain-text questioning/i);
812
+ assert.match(message, /If bare `omx question` is unavailable in this reused session, use the current-session CLI bridge command:/);
813
+ assert.match(message, /`'.+' '.+dist\/cli\/omx\.js' question`/);
814
+ assert.match(message, /Stop remains blocked while a deep-interview question obligation is pending\./);
815
+ } finally {
816
+ await rm(cwd, { recursive: true, force: true });
817
+ }
818
+ });
819
+
820
+ it("keeps bare keep-going continuation on the active ralph skill without resetting through generic keep-going routing", async () => {
821
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-ralph-bare-continuation-"));
822
+ try {
823
+ const sessionId = "sess-ralph-cont";
824
+ const sessionDir = join(cwd, ".omx", "state", "sessions", sessionId);
825
+ await mkdir(sessionDir, { recursive: true });
826
+ await writeJson(join(sessionDir, "skill-active-state.json"), {
827
+ version: 1,
828
+ active: true,
829
+ skill: "ralph",
830
+ keyword: "$ralph",
831
+ phase: "executing",
832
+ session_id: sessionId,
833
+ active_skills: [
834
+ { skill: "ralph", phase: "executing", active: true, session_id: sessionId },
835
+ ],
836
+ });
837
+ await writeJson(join(sessionDir, "ralph-state.json"), {
838
+ active: true,
839
+ mode: "ralph",
840
+ current_phase: "verifying",
841
+ started_at: "2026-04-19T00:00:00.000Z",
842
+ updated_at: "2026-04-19T00:10:00.000Z",
843
+ iteration: 4,
844
+ max_iterations: 50,
845
+ session_id: sessionId,
846
+ });
847
+
848
+ const result = await dispatchCodexNativeHook(
849
+ {
850
+ hook_event_name: "UserPromptSubmit",
851
+ cwd,
852
+ session_id: sessionId,
853
+ thread_id: "thread-ralph-cont",
854
+ turn_id: "turn-ralph-cont",
855
+ prompt: "keep going now",
856
+ },
857
+ { cwd },
858
+ );
859
+
860
+ assert.equal(result.omxEventName, "keyword-detector");
861
+ assert.equal(result.skillState?.skill, "ralph");
862
+ const message = String(
863
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext || "",
864
+ );
865
+ assert.match(message, /"keep going" -> ralph/);
866
+ assert.doesNotMatch(message, /denied workflow keyword/i);
867
+ assert.doesNotMatch(message, /mode transiting:/);
868
+ } finally {
869
+ await rm(cwd, { recursive: true, force: true });
870
+ }
871
+ });
872
+
873
+
524
874
  it("ignores generic wrapper fields so metadata cannot trigger workflow routing or Stop blocking", async () => {
525
875
  const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-wrapper-metadata-"));
526
876
  try {
@@ -1942,6 +2292,33 @@ esac
1942
2292
  }
1943
2293
  });
1944
2294
 
2295
+ it("does not block Stop when an explicit blocked_on_user run_outcome is present on a mode state", async () => {
2296
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-autopilot-blocked-outcome-"));
2297
+ try {
2298
+ const stateDir = join(cwd, ".omx", "state");
2299
+ await mkdir(stateDir, { recursive: true });
2300
+ await writeJson(join(stateDir, "autopilot-state.json"), {
2301
+ active: true,
2302
+ current_phase: "execution",
2303
+ run_outcome: "blocked_on_user",
2304
+ });
2305
+
2306
+ const result = await dispatchCodexNativeHook(
2307
+ {
2308
+ hook_event_name: "Stop",
2309
+ cwd,
2310
+ session_id: "sess-stop-autopilot-blocked-outcome",
2311
+ },
2312
+ { cwd },
2313
+ );
2314
+
2315
+ assert.equal(result.omxEventName, "stop");
2316
+ assert.equal(result.outputJson, null);
2317
+ } finally {
2318
+ await rm(cwd, { recursive: true, force: true });
2319
+ }
2320
+ });
2321
+
1945
2322
  it("returns Stop continuation output while Ultrawork is active", async () => {
1946
2323
  const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-ultrawork-"));
1947
2324
  try {
@@ -2699,201 +3076,371 @@ esac
2699
3076
  }
2700
3077
  });
2701
3078
 
2702
- it("does not block Stop solely because deep-interview is active", async () => {
2703
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-deep-interview-"));
3079
+ it("blocks Stop while autoresearch is active without validator completion", async () => {
3080
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-autoresearch-"));
2704
3081
  try {
2705
3082
  const stateDir = join(cwd, ".omx", "state");
2706
- await mkdir(join(stateDir, "sessions", "sess-stop-deep-interview"), { recursive: true });
2707
- await writeJson(join(stateDir, "session.json"), { session_id: "sess-stop-deep-interview" });
2708
- await writeJson(join(stateDir, "sessions", "sess-stop-deep-interview", "skill-active-state.json"), {
2709
- active: true,
2710
- skill: "deep-interview",
2711
- phase: "planning",
2712
- });
2713
- await writeJson(join(stateDir, "sessions", "sess-stop-deep-interview", "deep-interview-state.json"), {
3083
+ await mkdir(join(stateDir, "sessions", "sess-stop-autoresearch"), { recursive: true });
3084
+ await writeJson(join(stateDir, "session.json"), { session_id: "sess-stop-autoresearch", cwd });
3085
+ await writeJson(join(stateDir, "sessions", "sess-stop-autoresearch", "autoresearch-state.json"), {
2714
3086
  active: true,
2715
- current_phase: "planning",
3087
+ mode: "autoresearch",
3088
+ current_phase: "executing",
3089
+ session_id: "sess-stop-autoresearch",
3090
+ validation_mode: "mission-validator-script",
3091
+ mission_validator_command: "node scripts/validate.js",
3092
+ completion_artifact_path: '.omx/specs/autoresearch-demo/completion.json',
2716
3093
  });
2717
3094
 
2718
3095
  const result = await dispatchCodexNativeHook(
2719
3096
  {
2720
3097
  hook_event_name: "Stop",
2721
3098
  cwd,
2722
- session_id: "sess-stop-deep-interview",
3099
+ session_id: "sess-stop-autoresearch",
2723
3100
  },
2724
3101
  { cwd },
2725
3102
  );
2726
3103
 
2727
- assert.equal(result.outputJson, null);
3104
+ assert.equal(result.omxEventName, "stop");
3105
+ assert.deepEqual(result.outputJson, {
3106
+ decision: "block",
3107
+ reason: "OMX autoresearch is still active (phase: executing); continue until validator evidence is complete before stopping.",
3108
+ stopReason: "autoresearch_executing",
3109
+ systemMessage: "OMX autoresearch is still active (phase: executing); continue until validator evidence is complete before stopping.",
3110
+ });
2728
3111
  } finally {
2729
3112
  await rm(cwd, { recursive: true, force: true });
2730
3113
  }
2731
3114
  });
2732
3115
 
2733
- it("ignores root skill-active fallback from a different thread when evaluating Stop", async () => {
2734
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-foreign-thread-"));
3116
+ it("allows Stop once autoresearch validator evidence is complete", async () => {
3117
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-autoresearch-complete-"));
2735
3118
  try {
2736
3119
  const stateDir = join(cwd, ".omx", "state");
2737
- await mkdir(stateDir, { recursive: true });
2738
- await writeJson(join(stateDir, "skill-active-state.json"), {
3120
+ const specDir = join(cwd, '.omx', 'specs', 'autoresearch-demo');
3121
+ await mkdir(join(stateDir, "sessions", "sess-stop-autoresearch-complete"), { recursive: true });
3122
+ await mkdir(specDir, { recursive: true });
3123
+ await writeJson(join(stateDir, "session.json"), { session_id: "sess-stop-autoresearch-complete", cwd });
3124
+ await writeJson(join(stateDir, "sessions", "sess-stop-autoresearch-complete", "autoresearch-state.json"), {
2739
3125
  active: true,
2740
- skill: "deep-interview",
2741
- phase: "planning",
2742
- session_id: "",
2743
- thread_id: "other-thread",
3126
+ mode: "autoresearch",
3127
+ current_phase: "reviewing",
3128
+ session_id: "sess-stop-autoresearch-complete",
3129
+ validation_mode: "mission-validator-script",
3130
+ mission_validator_command: "node scripts/validate.js",
3131
+ completion_artifact_path: '.omx/specs/autoresearch-demo/completion.json',
2744
3132
  });
3133
+ await writeJson(join(specDir, 'completion.json'), { status: 'passed', passed: true });
2745
3134
 
2746
3135
  const result = await dispatchCodexNativeHook(
2747
3136
  {
2748
3137
  hook_event_name: "Stop",
2749
3138
  cwd,
2750
- session_id: "sess-stop-main",
2751
- thread_id: "main-thread",
3139
+ session_id: "sess-stop-autoresearch-complete",
2752
3140
  },
2753
3141
  { cwd },
2754
3142
  );
2755
3143
 
3144
+ assert.equal(result.omxEventName, "stop");
2756
3145
  assert.equal(result.outputJson, null);
2757
3146
  } finally {
2758
3147
  await rm(cwd, { recursive: true, force: true });
2759
3148
  }
2760
3149
  });
2761
3150
 
2762
- it("returns Stop continuation output while Ralph is active without an explicit session pin", async () => {
2763
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-"));
3151
+ it("does not block Stop from stale root autoresearch state when the explicit session has no scoped autoresearch state", async () => {
3152
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-stale-root-autoresearch-"));
2764
3153
  try {
2765
3154
  const stateDir = join(cwd, ".omx", "state");
2766
- await mkdir(stateDir, { recursive: true });
2767
- await writeFile(
2768
- join(stateDir, "ralph-state.json"),
2769
- JSON.stringify({
2770
- active: true,
2771
- current_phase: "executing",
2772
- }),
2773
- );
3155
+ const specDir = join(cwd, '.omx', 'specs', 'autoresearch-demo');
3156
+ await mkdir(join(stateDir, 'sessions', 'sess-current'), { recursive: true });
3157
+ await mkdir(specDir, { recursive: true });
3158
+ await writeJson(join(stateDir, 'session.json'), { session_id: 'sess-current', cwd });
3159
+ await writeJson(join(stateDir, 'autoresearch-state.json'), {
3160
+ active: true,
3161
+ mode: 'autoresearch',
3162
+ current_phase: 'executing',
3163
+ validation_mode: 'mission-validator-script',
3164
+ mission_validator_command: 'node scripts/validate.js',
3165
+ completion_artifact_path: '.omx/specs/autoresearch-demo/completion.json',
3166
+ });
2774
3167
 
2775
3168
  const result = await dispatchCodexNativeHook(
2776
3169
  {
2777
- hook_event_name: "Stop",
3170
+ hook_event_name: 'Stop',
2778
3171
  cwd,
3172
+ session_id: 'sess-current',
2779
3173
  },
2780
3174
  { cwd },
2781
3175
  );
2782
3176
 
2783
- assert.equal(result.omxEventName, "stop");
2784
- assert.deepEqual(result.outputJson, {
2785
- decision: "block",
2786
- reason:
2787
- "OMX Ralph is still active (phase: executing); continue the task and gather fresh verification evidence before stopping.",
2788
- stopReason: "ralph_executing",
2789
- systemMessage:
2790
- "OMX Ralph is still active (phase: executing); continue the task and gather fresh verification evidence before stopping.",
2791
- });
3177
+ assert.equal(result.omxEventName, 'stop');
3178
+ assert.equal(result.outputJson, null);
2792
3179
  } finally {
2793
3180
  await rm(cwd, { recursive: true, force: true });
2794
3181
  }
2795
3182
  });
2796
3183
 
2797
- it("blocks Stop from session-scoped Ralph state when session.json points to another session", async () => {
2798
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-ralph-session-mismatch-"));
3184
+ it("does not block Stop solely because deep-interview is active", async () => {
3185
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-deep-interview-"));
2799
3186
  try {
2800
3187
  const stateDir = join(cwd, ".omx", "state");
2801
- await mkdir(join(stateDir, "sessions", "sess-live-ralph"), { recursive: true });
2802
- await writeJson(join(stateDir, "session.json"), { session_id: "sess-other-ralph" });
2803
- await writeJson(join(stateDir, "sessions", "sess-live-ralph", "ralph-state.json"), {
3188
+ await mkdir(join(stateDir, "sessions", "sess-stop-deep-interview"), { recursive: true });
3189
+ await writeJson(join(stateDir, "session.json"), { session_id: "sess-stop-deep-interview" });
3190
+ await writeJson(join(stateDir, "sessions", "sess-stop-deep-interview", "skill-active-state.json"), {
2804
3191
  active: true,
2805
- current_phase: "executing",
2806
- session_id: "sess-live-ralph",
3192
+ skill: "deep-interview",
3193
+ phase: "planning",
3194
+ });
3195
+ await writeJson(join(stateDir, "sessions", "sess-stop-deep-interview", "deep-interview-state.json"), {
3196
+ active: true,
3197
+ current_phase: "planning",
2807
3198
  });
2808
3199
 
2809
3200
  const result = await dispatchCodexNativeHook(
2810
3201
  {
2811
3202
  hook_event_name: "Stop",
2812
3203
  cwd,
2813
- session_id: "sess-live-ralph",
3204
+ session_id: "sess-stop-deep-interview",
2814
3205
  },
2815
3206
  { cwd },
2816
3207
  );
2817
3208
 
2818
- assert.equal(result.omxEventName, "stop");
2819
- assert.deepEqual(result.outputJson, {
2820
- decision: "block",
2821
- reason:
2822
- "OMX Ralph is still active (phase: executing); continue the task and gather fresh verification evidence before stopping.",
2823
- stopReason: "ralph_executing",
2824
- systemMessage:
2825
- "OMX Ralph is still active (phase: executing); continue the task and gather fresh verification evidence before stopping.",
2826
- });
3209
+ assert.equal(result.outputJson, null);
2827
3210
  } finally {
2828
3211
  await rm(cwd, { recursive: true, force: true });
2829
3212
  }
2830
3213
  });
2831
3214
 
2832
- it("does not block Stop from stale session-scoped Ralph state that belongs to another session", async () => {
2833
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-stale-session-ralph-"));
3215
+ it("blocks Stop when deep-interview has a pending omx question obligation", async () => {
3216
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-deep-interview-question-"));
2834
3217
  try {
2835
3218
  const stateDir = join(cwd, ".omx", "state");
2836
- await mkdir(join(stateDir, "sessions", "sess-current"), { recursive: true });
2837
- await mkdir(join(stateDir, "sessions", "sess-stale"), { recursive: true });
2838
- await writeJson(join(stateDir, "session.json"), { session_id: "sess-current" });
2839
- await writeJson(join(stateDir, "sessions", "sess-stale", "ralph-state.json"), {
3219
+ await mkdir(join(stateDir, "sessions", "sess-stop-deep-interview-question"), { recursive: true });
3220
+ await writeJson(join(stateDir, "session.json"), { session_id: "sess-stop-deep-interview-question" });
3221
+ await writeJson(join(stateDir, "sessions", "sess-stop-deep-interview-question", "skill-active-state.json"), {
3222
+ version: 1,
2840
3223
  active: true,
2841
- current_phase: "starting",
2842
- session_id: "sess-stale",
3224
+ skill: "deep-interview",
3225
+ phase: "planning",
3226
+ session_id: "sess-stop-deep-interview-question",
3227
+ thread_id: "thread-stop-deep-interview-question",
3228
+ });
3229
+ await writeJson(join(stateDir, "sessions", "sess-stop-deep-interview-question", "deep-interview-state.json"), {
3230
+ active: true,
3231
+ mode: "deep-interview",
3232
+ current_phase: "intent-first",
3233
+ session_id: "sess-stop-deep-interview-question",
3234
+ thread_id: "thread-stop-deep-interview-question",
3235
+ question_enforcement: {
3236
+ obligation_id: "obligation-1",
3237
+ source: "omx-question",
3238
+ status: "pending",
3239
+ requested_at: "2026-04-19T03:20:00.000Z",
3240
+ },
2843
3241
  });
2844
3242
 
2845
3243
  const result = await dispatchCodexNativeHook(
2846
3244
  {
2847
3245
  hook_event_name: "Stop",
2848
3246
  cwd,
2849
- session_id: "sess-current",
3247
+ session_id: "sess-stop-deep-interview-question",
3248
+ thread_id: "thread-stop-deep-interview-question",
2850
3249
  },
2851
3250
  { cwd },
2852
3251
  );
2853
3252
 
2854
3253
  assert.equal(result.omxEventName, "stop");
2855
- assert.equal(result.outputJson, null);
3254
+ assert.deepEqual(result.outputJson, {
3255
+ decision: "block",
3256
+ reason:
3257
+ "Deep interview is still active (phase: intent-first) and has a pending structured question obligation; use `omx question` before stopping.",
3258
+ stopReason: "deep_interview_question_required",
3259
+ systemMessage:
3260
+ "OMX deep-interview is still active (phase: intent-first) and requires a structured question via omx question before stopping.",
3261
+ });
2856
3262
  } finally {
2857
3263
  await rm(cwd, { recursive: true, force: true });
2858
3264
  }
2859
3265
  });
2860
3266
 
2861
- it("does not block Stop from another session-scoped Ralph state when an explicit session_id has no active Ralph state", async () => {
2862
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-explicit-session-ralph-"));
3267
+ it("blocks Stop when a same-session deep-interview question obligation is pending even after the mode marked itself inactive", async () => {
3268
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-deep-interview-question-inactive-"));
2863
3269
  try {
2864
3270
  const stateDir = join(cwd, ".omx", "state");
2865
- await mkdir(join(stateDir, "sessions", "sess-other"), { recursive: true });
2866
- await writeJson(join(stateDir, "sessions", "sess-other", "ralph-state.json"), {
3271
+ await mkdir(join(stateDir, "sessions", "sess-stop-deep-interview-question-inactive"), { recursive: true });
3272
+ await writeJson(join(stateDir, "session.json"), { session_id: "sess-stop-deep-interview-question-inactive" });
3273
+ await writeJson(join(stateDir, "sessions", "sess-stop-deep-interview-question-inactive", "skill-active-state.json"), {
3274
+ version: 1,
2867
3275
  active: true,
2868
- current_phase: "starting",
2869
- session_id: "sess-other",
3276
+ skill: "deep-interview",
3277
+ phase: "planning",
3278
+ session_id: "sess-stop-deep-interview-question-inactive",
3279
+ thread_id: "thread-stop-deep-interview-question-inactive",
3280
+ });
3281
+ await writeJson(join(stateDir, "sessions", "sess-stop-deep-interview-question-inactive", "deep-interview-state.json"), {
3282
+ active: false,
3283
+ mode: "deep-interview",
3284
+ current_phase: "intent-first",
3285
+ lifecycle_outcome: "askuserQuestion",
3286
+ run_outcome: "blocked_on_user",
3287
+ completed_at: "2026-04-19T03:20:30.000Z",
3288
+ session_id: "sess-stop-deep-interview-question-inactive",
3289
+ thread_id: "thread-stop-deep-interview-question-inactive",
3290
+ question_enforcement: {
3291
+ obligation_id: "obligation-inactive",
3292
+ source: "omx-question",
3293
+ status: "pending",
3294
+ lifecycle_outcome: "askuserQuestion",
3295
+ requested_at: "2026-04-19T03:20:00.000Z",
3296
+ },
2870
3297
  });
2871
3298
 
2872
3299
  const result = await dispatchCodexNativeHook(
2873
3300
  {
2874
3301
  hook_event_name: "Stop",
2875
3302
  cwd,
2876
- session_id: "sess-current",
3303
+ session_id: "sess-stop-deep-interview-question-inactive",
3304
+ thread_id: "thread-stop-deep-interview-question-inactive",
2877
3305
  },
2878
3306
  { cwd },
2879
3307
  );
2880
3308
 
2881
3309
  assert.equal(result.omxEventName, "stop");
2882
- assert.equal(result.outputJson, null);
3310
+ assert.deepEqual(result.outputJson, {
3311
+ decision: "block",
3312
+ reason:
3313
+ "Deep interview is still active (phase: intent-first) and has a pending structured question obligation; use `omx question` before stopping.",
3314
+ stopReason: "deep_interview_question_required",
3315
+ systemMessage:
3316
+ "OMX deep-interview is still active (phase: intent-first) and requires a structured question via omx question before stopping.",
3317
+ });
2883
3318
  } finally {
2884
3319
  await rm(cwd, { recursive: true, force: true });
2885
3320
  }
2886
3321
  });
2887
3322
 
2888
- it("does not block Stop from root Ralph fallback when the current session has no scoped Ralph state", async () => {
2889
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-root-fallback-ralph-"));
3323
+ it("keeps blocking pending deep-interview question Stop replays until the obligation changes", async () => {
3324
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-deep-interview-question-replay-"));
3325
+ try {
3326
+ const stateDir = join(cwd, ".omx", "state");
3327
+ await mkdir(join(stateDir, "sessions", "sess-stop-deep-interview-question-replay"), { recursive: true });
3328
+ await writeJson(join(stateDir, "session.json"), { session_id: "sess-stop-deep-interview-question-replay" });
3329
+ await writeJson(join(stateDir, "sessions", "sess-stop-deep-interview-question-replay", "skill-active-state.json"), {
3330
+ version: 1,
3331
+ active: true,
3332
+ skill: "deep-interview",
3333
+ phase: "planning",
3334
+ session_id: "sess-stop-deep-interview-question-replay",
3335
+ });
3336
+ await writeJson(join(stateDir, "sessions", "sess-stop-deep-interview-question-replay", "deep-interview-state.json"), {
3337
+ active: true,
3338
+ mode: "deep-interview",
3339
+ current_phase: "intent-first",
3340
+ question_enforcement: {
3341
+ obligation_id: "obligation-replay",
3342
+ source: "omx-question",
3343
+ status: "pending",
3344
+ requested_at: "2026-04-19T03:20:00.000Z",
3345
+ },
3346
+ });
3347
+
3348
+ const payload = {
3349
+ hook_event_name: "Stop",
3350
+ cwd,
3351
+ session_id: "sess-stop-deep-interview-question-replay",
3352
+ };
3353
+ const expected = {
3354
+ decision: "block",
3355
+ reason:
3356
+ "Deep interview is still active (phase: intent-first) and has a pending structured question obligation; use `omx question` before stopping.",
3357
+ stopReason: "deep_interview_question_required",
3358
+ systemMessage:
3359
+ "OMX deep-interview is still active (phase: intent-first) and requires a structured question via omx question before stopping.",
3360
+ };
3361
+
3362
+ const first = await dispatchCodexNativeHook(payload, { cwd });
3363
+ const replay = await dispatchCodexNativeHook({ ...payload, stop_hook_active: true }, { cwd });
3364
+
3365
+ assert.equal(first.omxEventName, "stop");
3366
+ assert.deepEqual(first.outputJson, expected);
3367
+ assert.equal(replay.omxEventName, "stop");
3368
+ assert.deepEqual(replay.outputJson, expected);
3369
+ } finally {
3370
+ await rm(cwd, { recursive: true, force: true });
3371
+ }
3372
+ });
3373
+
3374
+ it("does not block Stop once the deep-interview question obligation is satisfied or cleared", async () => {
3375
+ for (const status of ["satisfied", "cleared"] as const) {
3376
+ const cwd = await mkdtemp(join(tmpdir(), `omx-native-hook-stop-deep-interview-question-${status}-`));
3377
+ try {
3378
+ const stateDir = join(cwd, ".omx", "state");
3379
+ await mkdir(join(stateDir, "sessions", `sess-stop-deep-interview-question-${status}`), { recursive: true });
3380
+ await writeJson(join(stateDir, "session.json"), { session_id: `sess-stop-deep-interview-question-${status}` });
3381
+ await writeJson(join(stateDir, "sessions", `sess-stop-deep-interview-question-${status}`, "skill-active-state.json"), {
3382
+ version: 1,
3383
+ active: true,
3384
+ skill: "deep-interview",
3385
+ phase: "planning",
3386
+ session_id: `sess-stop-deep-interview-question-${status}`,
3387
+ });
3388
+ await writeJson(join(stateDir, "sessions", `sess-stop-deep-interview-question-${status}`, "deep-interview-state.json"), {
3389
+ active: true,
3390
+ mode: "deep-interview",
3391
+ current_phase: "intent-first",
3392
+ question_enforcement: {
3393
+ obligation_id: `obligation-${status}`,
3394
+ source: "omx-question",
3395
+ status,
3396
+ requested_at: "2026-04-19T03:20:00.000Z",
3397
+ ...(status === "satisfied"
3398
+ ? { question_id: "question-1", satisfied_at: "2026-04-19T03:21:00.000Z" }
3399
+ : { cleared_at: "2026-04-19T03:21:00.000Z", clear_reason: "error" }),
3400
+ },
3401
+ });
3402
+
3403
+ const result = await dispatchCodexNativeHook(
3404
+ {
3405
+ hook_event_name: "Stop",
3406
+ cwd,
3407
+ session_id: `sess-stop-deep-interview-question-${status}`,
3408
+ },
3409
+ { cwd },
3410
+ );
3411
+
3412
+ assert.equal(result.omxEventName, "stop");
3413
+ assert.equal(result.outputJson, null);
3414
+ } finally {
3415
+ await rm(cwd, { recursive: true, force: true });
3416
+ }
3417
+ }
3418
+ });
3419
+
3420
+ it("ignores pending deep-interview question obligations from another session", async () => {
3421
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-deep-interview-question-foreign-session-"));
2890
3422
  try {
2891
3423
  const stateDir = join(cwd, ".omx", "state");
3424
+ await mkdir(join(stateDir, "sessions", "sess-other"), { recursive: true });
2892
3425
  await mkdir(join(stateDir, "sessions", "sess-current"), { recursive: true });
2893
- await writeJson(join(stateDir, "session.json"), { session_id: "sess-current", cwd });
2894
- await writeJson(join(stateDir, "ralph-state.json"), {
3426
+ await writeJson(join(stateDir, "session.json"), { session_id: "sess-current" });
3427
+ await writeJson(join(stateDir, "sessions", "sess-other", "skill-active-state.json"), {
3428
+ version: 1,
2895
3429
  active: true,
2896
- current_phase: "executing",
3430
+ skill: "deep-interview",
3431
+ phase: "planning",
3432
+ session_id: "sess-other",
3433
+ });
3434
+ await writeJson(join(stateDir, "sessions", "sess-other", "deep-interview-state.json"), {
3435
+ active: true,
3436
+ mode: "deep-interview",
3437
+ current_phase: "intent-first",
3438
+ question_enforcement: {
3439
+ obligation_id: "obligation-foreign",
3440
+ source: "omx-question",
3441
+ status: "pending",
3442
+ requested_at: "2026-04-19T03:20:00.000Z",
3443
+ },
2897
3444
  });
2898
3445
 
2899
3446
  const result = await dispatchCodexNativeHook(
@@ -2912,72 +3459,87 @@ esac
2912
3459
  }
2913
3460
  });
2914
3461
 
2915
- it("does not block Stop when the current session Ralph state is cancelled even if stale root fallback remains", async () => {
2916
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-cancelled-session-ralph-"));
3462
+ it("blocks a new same-session deep-interview question obligation even after an earlier round was satisfied", async () => {
3463
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-deep-interview-question-next-round-"));
2917
3464
  try {
2918
3465
  const stateDir = join(cwd, ".omx", "state");
2919
- await mkdir(join(stateDir, "sessions", "sess-current"), { recursive: true });
2920
- await writeJson(join(stateDir, "session.json"), { session_id: "sess-current", cwd });
2921
- await writeJson(join(stateDir, "sessions", "sess-current", "ralph-state.json"), {
2922
- active: false,
2923
- current_phase: "cancelled",
2924
- completed_at: "2026-04-10T23:30:38.000Z",
2925
- session_id: "sess-current",
3466
+ await mkdir(join(stateDir, "sessions", "sess-stop-deep-interview-question-next-round"), { recursive: true });
3467
+ await writeJson(join(stateDir, "session.json"), { session_id: "sess-stop-deep-interview-question-next-round" });
3468
+ await writeJson(join(stateDir, "sessions", "sess-stop-deep-interview-question-next-round", "skill-active-state.json"), {
3469
+ version: 1,
3470
+ active: true,
3471
+ skill: "deep-interview",
3472
+ phase: "planning",
3473
+ session_id: "sess-stop-deep-interview-question-next-round",
2926
3474
  });
2927
- await writeJson(join(stateDir, "ralph-state.json"), {
3475
+ await writeJson(join(stateDir, "sessions", "sess-stop-deep-interview-question-next-round", "deep-interview-state.json"), {
2928
3476
  active: true,
2929
- current_phase: "starting",
3477
+ mode: "deep-interview",
3478
+ current_phase: "intent-first",
3479
+ question_enforcement: {
3480
+ obligation_id: "obligation-next-round",
3481
+ source: "omx-question",
3482
+ status: "pending",
3483
+ requested_at: "2026-04-19T03:22:00.000Z",
3484
+ question_id: "question-old-round",
3485
+ satisfied_at: "2026-04-19T03:21:00.000Z",
3486
+ },
2930
3487
  });
2931
3488
 
2932
3489
  const result = await dispatchCodexNativeHook(
2933
3490
  {
2934
3491
  hook_event_name: "Stop",
2935
3492
  cwd,
2936
- session_id: "sess-current",
3493
+ session_id: "sess-stop-deep-interview-question-next-round",
2937
3494
  },
2938
3495
  { cwd },
2939
3496
  );
2940
3497
 
2941
3498
  assert.equal(result.omxEventName, "stop");
2942
- assert.equal(result.outputJson, null);
3499
+ assert.deepEqual(result.outputJson, {
3500
+ decision: "block",
3501
+ reason:
3502
+ "Deep interview is still active (phase: intent-first) and has a pending structured question obligation; use `omx question` before stopping.",
3503
+ stopReason: "deep_interview_question_required",
3504
+ systemMessage:
3505
+ "OMX deep-interview is still active (phase: intent-first) and requires a structured question via omx question before stopping.",
3506
+ });
2943
3507
  } finally {
2944
3508
  await rm(cwd, { recursive: true, force: true });
2945
3509
  }
2946
3510
  });
2947
3511
 
2948
- it("does not block Stop from root Ralph fallback when an explicit session_id is present and session.json points to another worktree", async () => {
2949
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-root-fallback-cwd-mismatch-"));
3512
+ it("ignores root skill-active fallback from a different thread when evaluating Stop", async () => {
3513
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-foreign-thread-"));
2950
3514
  try {
2951
3515
  const stateDir = join(cwd, ".omx", "state");
2952
3516
  await mkdir(stateDir, { recursive: true });
2953
- await writeJson(join(stateDir, "session.json"), {
2954
- session_id: "sess-elsewhere",
2955
- cwd: join(cwd, "..", "different-worktree"),
2956
- });
2957
- await writeJson(join(stateDir, "ralph-state.json"), {
3517
+ await writeJson(join(stateDir, "skill-active-state.json"), {
2958
3518
  active: true,
2959
- current_phase: "executing",
3519
+ skill: "deep-interview",
3520
+ phase: "planning",
3521
+ session_id: "",
3522
+ thread_id: "other-thread",
2960
3523
  });
2961
3524
 
2962
3525
  const result = await dispatchCodexNativeHook(
2963
3526
  {
2964
3527
  hook_event_name: "Stop",
2965
3528
  cwd,
2966
- session_id: "sess-current",
3529
+ session_id: "sess-stop-main",
3530
+ thread_id: "main-thread",
2967
3531
  },
2968
3532
  { cwd },
2969
3533
  );
2970
3534
 
2971
- assert.equal(result.omxEventName, "stop");
2972
3535
  assert.equal(result.outputJson, null);
2973
3536
  } finally {
2974
3537
  await rm(cwd, { recursive: true, force: true });
2975
3538
  }
2976
3539
  });
2977
3540
 
2978
- it("keeps blocking Ralph Stop replays until the active task advances", async () => {
2979
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-ralph-replay-"));
2980
- const previousOmxSessionId = process.env.OMX_SESSION_ID;
3541
+ it("returns Stop continuation output while Ralph is active without an explicit session pin", async () => {
3542
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-"));
2981
3543
  try {
2982
3544
  const stateDir = join(cwd, ".omx", "state");
2983
3545
  await mkdir(stateDir, { recursive: true });
@@ -2989,55 +3551,45 @@ esac
2989
3551
  }),
2990
3552
  );
2991
3553
 
2992
- process.env.OMX_SESSION_ID = "sess-stop-ralph-replay";
2993
- const payload = {
2994
- hook_event_name: "Stop",
2995
- cwd,
2996
- last_assistant_message: "Next active targets:\n\n1. scheduler integration\n\nI am continuing.",
2997
- };
2998
- const expected = {
3554
+ const result = await dispatchCodexNativeHook(
3555
+ {
3556
+ hook_event_name: "Stop",
3557
+ cwd,
3558
+ },
3559
+ { cwd },
3560
+ );
3561
+
3562
+ assert.equal(result.omxEventName, "stop");
3563
+ assert.deepEqual(result.outputJson, {
2999
3564
  decision: "block",
3000
3565
  reason:
3001
3566
  "OMX Ralph is still active (phase: executing); continue the task and gather fresh verification evidence before stopping.",
3002
3567
  stopReason: "ralph_executing",
3003
3568
  systemMessage:
3004
3569
  "OMX Ralph is still active (phase: executing); continue the task and gather fresh verification evidence before stopping.",
3005
- };
3006
-
3007
- const first = await dispatchCodexNativeHook(payload, { cwd });
3008
- const replay = await dispatchCodexNativeHook(
3009
- {
3010
- ...payload,
3011
- stop_hook_active: true,
3012
- },
3013
- { cwd },
3014
- );
3015
-
3016
- assert.equal(first.omxEventName, "stop");
3017
- assert.deepEqual(first.outputJson, expected);
3018
- assert.equal(replay.omxEventName, "stop");
3019
- assert.deepEqual(replay.outputJson, expected);
3570
+ });
3020
3571
  } finally {
3021
- if (typeof previousOmxSessionId === "string") process.env.OMX_SESSION_ID = previousOmxSessionId;
3022
- else delete process.env.OMX_SESSION_ID;
3023
3572
  await rm(cwd, { recursive: true, force: true });
3024
3573
  }
3025
3574
  });
3026
3575
 
3027
-
3028
- it("returns Stop continuation output for native auto-nudge stall prompts", async () => {
3029
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-auto-nudge-"));
3576
+ it("blocks Stop from session-scoped Ralph state when session.json points to another session", async () => {
3577
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-ralph-session-mismatch-"));
3030
3578
  try {
3031
3579
  const stateDir = join(cwd, ".omx", "state");
3032
- await mkdir(stateDir, { recursive: true });
3033
- process.env.OMX_SESSION_ID = "sess-stop-auto";
3034
-
3035
- const result = await dispatchCodexNativeHook(
3580
+ await mkdir(join(stateDir, "sessions", "sess-live-ralph"), { recursive: true });
3581
+ await writeJson(join(stateDir, "session.json"), { session_id: "sess-other-ralph" });
3582
+ await writeJson(join(stateDir, "sessions", "sess-live-ralph", "ralph-state.json"), {
3583
+ active: true,
3584
+ current_phase: "executing",
3585
+ session_id: "sess-live-ralph",
3586
+ });
3587
+
3588
+ const result = await dispatchCodexNativeHook(
3036
3589
  {
3037
3590
  hook_event_name: "Stop",
3038
3591
  cwd,
3039
- session_id: "sess-stop-auto",
3040
- last_assistant_message: "Keep going and finish the cleanup.",
3592
+ session_id: "sess-live-ralph",
3041
3593
  },
3042
3594
  { cwd },
3043
3595
  );
@@ -3045,258 +3597,201 @@ esac
3045
3597
  assert.equal(result.omxEventName, "stop");
3046
3598
  assert.deepEqual(result.outputJson, {
3047
3599
  decision: "block",
3048
- reason: DEFAULT_AUTO_NUDGE_RESPONSE,
3049
- stopReason: "auto_nudge",
3600
+ reason:
3601
+ "OMX Ralph is still active (phase: executing); continue the task and gather fresh verification evidence before stopping.",
3602
+ stopReason: "ralph_executing",
3050
3603
  systemMessage:
3051
- "OMX native Stop detected a stall/permission-style handoff and continued the turn automatically.",
3604
+ "OMX Ralph is still active (phase: executing); continue the task and gather fresh verification evidence before stopping.",
3052
3605
  });
3053
3606
  } finally {
3054
3607
  await rm(cwd, { recursive: true, force: true });
3055
3608
  }
3056
3609
  });
3057
3610
 
3058
- it("re-blocks duplicate native auto-nudge replays for the same Stop reply", async () => {
3059
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-auto-nudge-once-"));
3611
+ it("does not block Stop from stale session-scoped Ralph state that belongs to another session", async () => {
3612
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-stale-session-ralph-"));
3060
3613
  try {
3061
3614
  const stateDir = join(cwd, ".omx", "state");
3062
- await mkdir(stateDir, { recursive: true });
3063
- process.env.OMX_SESSION_ID = "sess-stop-auto-once";
3064
-
3065
- await dispatchCodexNativeHook(
3066
- {
3067
- hook_event_name: "Stop",
3068
- cwd,
3069
- session_id: "sess-stop-auto-once",
3070
- thread_id: "thread-stop-auto",
3071
- turn_id: "turn-stop-auto-1",
3072
- last_assistant_message: "Keep going and finish the cleanup.",
3073
- },
3074
- { cwd },
3075
- );
3615
+ await mkdir(join(stateDir, "sessions", "sess-current"), { recursive: true });
3616
+ await mkdir(join(stateDir, "sessions", "sess-stale"), { recursive: true });
3617
+ await writeJson(join(stateDir, "session.json"), { session_id: "sess-current" });
3618
+ await writeJson(join(stateDir, "sessions", "sess-stale", "ralph-state.json"), {
3619
+ active: true,
3620
+ current_phase: "starting",
3621
+ session_id: "sess-stale",
3622
+ });
3076
3623
 
3077
3624
  const result = await dispatchCodexNativeHook(
3078
3625
  {
3079
3626
  hook_event_name: "Stop",
3080
3627
  cwd,
3081
- session_id: "sess-stop-auto-once",
3082
- thread_id: "thread-stop-auto",
3083
- turn_id: "turn-stop-auto-1",
3084
- stop_hook_active: true,
3085
- last_assistant_message: "Keep going and finish the cleanup.",
3628
+ session_id: "sess-current",
3086
3629
  },
3087
3630
  { cwd },
3088
3631
  );
3089
3632
 
3090
3633
  assert.equal(result.omxEventName, "stop");
3091
- assert.deepEqual(result.outputJson, {
3092
- decision: "block",
3093
- reason: DEFAULT_AUTO_NUDGE_RESPONSE,
3094
- stopReason: "auto_nudge",
3095
- systemMessage:
3096
- "OMX native Stop detected a stall/permission-style handoff and continued the turn automatically.",
3097
- });
3634
+ assert.equal(result.outputJson, null);
3098
3635
  } finally {
3099
3636
  await rm(cwd, { recursive: true, force: true });
3100
3637
  }
3101
3638
  });
3102
3639
 
3103
- it("re-blocks duplicate native auto-nudge replays across native/canonical session-id drift", async () => {
3104
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-auto-nudge-session-drift-"));
3640
+ it("does not block Stop from stale current-session Ralph state when session.json points to a dead owner", async () => {
3641
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-stale-current-session-ralph-"));
3105
3642
  try {
3106
3643
  const stateDir = join(cwd, ".omx", "state");
3107
- await mkdir(stateDir, { recursive: true });
3108
- process.env.OMX_SESSION_ID = "omx-canonical";
3644
+ await mkdir(join(stateDir, "sessions", "sess-dead"), { recursive: true });
3109
3645
  await writeJson(join(stateDir, "session.json"), {
3110
- session_id: "omx-canonical",
3111
- native_session_id: "codex-native",
3646
+ session_id: "sess-dead",
3647
+ cwd,
3648
+ pid: Number.MAX_SAFE_INTEGER,
3649
+ started_at: "2026-01-01T00:00:00.000Z",
3112
3650
  });
3113
-
3114
- await dispatchCodexNativeHook(
3115
- {
3116
- hook_event_name: "Stop",
3117
- cwd,
3118
- session_id: "codex-native",
3119
- thread_id: "thread-stop-auto-drift",
3120
- turn_id: "turn-stop-auto-drift-1",
3121
- last_assistant_message: "Keep going and finish the cleanup.",
3651
+ await writeJson(join(stateDir, "sessions", "sess-dead", "ralph-state.json"), {
3652
+ active: true,
3653
+ current_phase: "verifying",
3654
+ session_id: "sess-dead",
3655
+ });
3656
+ await writeJson(join(stateDir, "skill-active-state.json"), {
3657
+ active: true,
3658
+ skill: "team",
3659
+ phase: "team-exec",
3660
+ active_skills: [{ skill: "team", phase: "team-exec", active: true, session_id: "sess-dead" }],
3661
+ });
3662
+ await writeJson(join(stateDir, "native-stop-state.json"), {
3663
+ sessions: {
3664
+ "sess-dead": {
3665
+ last_signature: "ralph-stop|sess-dead|thread-1|no-message|verifying",
3666
+ updated_at: "2026-04-20T21:00:00.000Z",
3667
+ },
3122
3668
  },
3123
- { cwd },
3124
- );
3669
+ });
3125
3670
 
3126
3671
  const result = await dispatchCodexNativeHook(
3127
3672
  {
3128
3673
  hook_event_name: "Stop",
3129
3674
  cwd,
3130
- session_id: "omx-canonical",
3131
- thread_id: "thread-stop-auto-drift",
3132
- turn_id: "turn-stop-auto-drift-1",
3675
+ session_id: "sess-dead",
3676
+ thread_id: "thread-1",
3133
3677
  stop_hook_active: true,
3134
- last_assistant_message: "Keep going and finish the cleanup.",
3135
3678
  },
3136
3679
  { cwd },
3137
3680
  );
3138
3681
 
3139
3682
  assert.equal(result.omxEventName, "stop");
3140
- assert.deepEqual(result.outputJson, {
3141
- decision: "block",
3142
- reason: DEFAULT_AUTO_NUDGE_RESPONSE,
3143
- stopReason: "auto_nudge",
3144
- systemMessage:
3145
- "OMX native Stop detected a stall/permission-style handoff and continued the turn automatically.",
3146
- });
3147
-
3148
- const persisted = JSON.parse(
3149
- await readFile(join(stateDir, "native-stop-state.json"), "utf-8"),
3150
- ) as { sessions?: Record<string, unknown> };
3151
- assert.deepEqual(Object.keys(persisted.sessions ?? {}), ["omx-canonical"]);
3683
+ assert.equal(result.outputJson, null);
3152
3684
  } finally {
3153
3685
  await rm(cwd, { recursive: true, force: true });
3154
3686
  }
3155
3687
  });
3156
3688
 
3157
- it("re-fires native auto-nudge for a later fresh Stop reply even when stop_hook_active is true", async () => {
3158
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-auto-nudge-refire-"));
3689
+ it("does not block Stop from another session-scoped Ralph state when an explicit session_id has no active Ralph state", async () => {
3690
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-explicit-session-ralph-"));
3159
3691
  try {
3160
3692
  const stateDir = join(cwd, ".omx", "state");
3161
- await mkdir(stateDir, { recursive: true });
3162
- process.env.OMX_SESSION_ID = "sess-stop-auto-refire";
3163
-
3164
- await dispatchCodexNativeHook(
3165
- {
3166
- hook_event_name: "Stop",
3167
- cwd,
3168
- session_id: "sess-stop-auto-refire",
3169
- thread_id: "thread-stop-auto-refire",
3170
- turn_id: "turn-stop-auto-refire-1",
3171
- last_assistant_message: "Keep going and finish the cleanup.",
3172
- },
3173
- { cwd },
3174
- );
3693
+ await mkdir(join(stateDir, "sessions", "sess-other"), { recursive: true });
3694
+ await writeJson(join(stateDir, "sessions", "sess-other", "ralph-state.json"), {
3695
+ active: true,
3696
+ current_phase: "starting",
3697
+ session_id: "sess-other",
3698
+ });
3175
3699
 
3176
3700
  const result = await dispatchCodexNativeHook(
3177
3701
  {
3178
3702
  hook_event_name: "Stop",
3179
3703
  cwd,
3180
- session_id: "sess-stop-auto-refire",
3181
- thread_id: "thread-stop-auto-refire",
3182
- turn_id: "turn-stop-auto-refire-2",
3183
- stop_hook_active: true,
3184
- last_assistant_message: "Continue with the cleanup from here.",
3704
+ session_id: "sess-current",
3185
3705
  },
3186
3706
  { cwd },
3187
3707
  );
3188
3708
 
3189
3709
  assert.equal(result.omxEventName, "stop");
3190
- assert.deepEqual(result.outputJson, {
3191
- decision: "block",
3192
- reason: DEFAULT_AUTO_NUDGE_RESPONSE,
3193
- stopReason: "auto_nudge",
3194
- systemMessage:
3195
- "OMX native Stop detected a stall/permission-style handoff and continued the turn automatically.",
3196
- });
3710
+ assert.equal(result.outputJson, null);
3197
3711
  } finally {
3198
3712
  await rm(cwd, { recursive: true, force: true });
3199
3713
  }
3200
3714
  });
3201
3715
 
3202
- it("auto-continues native Stop on permission-seeking prompts", async () => {
3203
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-auto-nudge-permission-"));
3716
+ it("does not block Stop from root Ralph fallback when the current session has no scoped Ralph state", async () => {
3717
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-root-fallback-ralph-"));
3204
3718
  try {
3205
- await mkdir(join(cwd, ".omx", "state"), { recursive: true });
3206
- process.env.OMX_SESSION_ID = "sess-stop-auto-permission";
3719
+ const stateDir = join(cwd, ".omx", "state");
3720
+ await mkdir(join(stateDir, "sessions", "sess-current"), { recursive: true });
3721
+ await writeJson(join(stateDir, "session.json"), { session_id: "sess-current", cwd });
3722
+ await writeJson(join(stateDir, "ralph-state.json"), {
3723
+ active: true,
3724
+ current_phase: "executing",
3725
+ });
3207
3726
 
3208
3727
  const result = await dispatchCodexNativeHook(
3209
3728
  {
3210
3729
  hook_event_name: "Stop",
3211
3730
  cwd,
3212
- session_id: "sess-stop-auto-permission",
3213
- last_assistant_message: "Would you like me to continue with the cleanup?",
3731
+ session_id: "sess-current",
3214
3732
  },
3215
3733
  { cwd },
3216
3734
  );
3217
3735
 
3218
3736
  assert.equal(result.omxEventName, "stop");
3219
- assert.deepEqual(result.outputJson, {
3220
- decision: "block",
3221
- reason: DEFAULT_AUTO_NUDGE_RESPONSE,
3222
- stopReason: "auto_nudge",
3223
- systemMessage:
3224
- "OMX native Stop detected a stall/permission-style handoff and continued the turn automatically.",
3225
- });
3737
+ assert.equal(result.outputJson, null);
3226
3738
  } finally {
3227
3739
  await rm(cwd, { recursive: true, force: true });
3228
3740
  }
3229
3741
  });
3230
3742
 
3231
- it("auto-continues native Stop on \"if you want\" permission-seeking prompts", async () => {
3232
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-auto-nudge-if-you-want-"));
3743
+ it("does not block Stop when the current session Ralph state is cancelled even if stale root fallback remains", async () => {
3744
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-cancelled-session-ralph-"));
3233
3745
  try {
3234
- await mkdir(join(cwd, ".omx", "state"), { recursive: true });
3235
- process.env.OMX_SESSION_ID = "sess-stop-auto-if-you-want";
3746
+ const stateDir = join(cwd, ".omx", "state");
3747
+ await mkdir(join(stateDir, "sessions", "sess-current"), { recursive: true });
3748
+ await writeJson(join(stateDir, "session.json"), { session_id: "sess-current", cwd });
3749
+ await writeJson(join(stateDir, "sessions", "sess-current", "ralph-state.json"), {
3750
+ active: false,
3751
+ current_phase: "cancelled",
3752
+ completed_at: "2026-04-10T23:30:38.000Z",
3753
+ session_id: "sess-current",
3754
+ });
3755
+ await writeJson(join(stateDir, "ralph-state.json"), {
3756
+ active: true,
3757
+ current_phase: "starting",
3758
+ });
3236
3759
 
3237
3760
  const result = await dispatchCodexNativeHook(
3238
3761
  {
3239
3762
  hook_event_name: "Stop",
3240
3763
  cwd,
3241
- session_id: "sess-stop-auto-if-you-want",
3242
- last_assistant_message: "If you want, I can continue with the cleanup from here.",
3764
+ session_id: "sess-current",
3243
3765
  },
3244
3766
  { cwd },
3245
3767
  );
3246
3768
 
3247
3769
  assert.equal(result.omxEventName, "stop");
3248
- assert.deepEqual(result.outputJson, {
3249
- decision: "block",
3250
- reason: DEFAULT_AUTO_NUDGE_RESPONSE,
3251
- stopReason: "auto_nudge",
3252
- systemMessage:
3253
- "OMX native Stop detected a stall/permission-style handoff and continued the turn automatically.",
3254
- });
3770
+ assert.equal(result.outputJson, null);
3255
3771
  } finally {
3256
3772
  await rm(cwd, { recursive: true, force: true });
3257
3773
  }
3258
3774
  });
3259
3775
 
3260
- it("does not auto-continue native Stop while deep-interview is waiting on an intent-first question", async () => {
3261
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-auto-nudge-deep-interview-question-"));
3776
+ it("does not block Stop from root Ralph fallback when an explicit session_id is present and session.json points to another worktree", async () => {
3777
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-root-fallback-cwd-mismatch-"));
3262
3778
  try {
3263
3779
  const stateDir = join(cwd, ".omx", "state");
3264
- await mkdir(join(stateDir, "sessions", "sess-stop-auto-question"), { recursive: true });
3265
- process.env.OMX_SESSION_ID = "sess-stop-auto-question";
3266
- await writeJson(join(stateDir, "session.json"), { session_id: "sess-stop-auto-question" });
3267
- await writeJson(join(stateDir, "sessions", "sess-stop-auto-question", "skill-active-state.json"), {
3268
- version: 1,
3269
- active: true,
3270
- skill: "deep-interview",
3271
- phase: "planning",
3272
- session_id: "sess-stop-auto-question",
3273
- thread_id: "thread-stop-auto-question",
3274
- input_lock: {
3275
- active: true,
3276
- scope: "deep-interview-auto-approval",
3277
- blocked_inputs: ["yes", "proceed"],
3278
- message: "Deep interview is active; auto-approval shortcuts are blocked until the interview finishes.",
3279
- },
3780
+ await mkdir(stateDir, { recursive: true });
3781
+ await writeJson(join(stateDir, "session.json"), {
3782
+ session_id: "sess-elsewhere",
3783
+ cwd: join(cwd, "..", "different-worktree"),
3280
3784
  });
3281
- await writeJson(join(stateDir, "sessions", "sess-stop-auto-question", "deep-interview-state.json"), {
3785
+ await writeJson(join(stateDir, "ralph-state.json"), {
3282
3786
  active: true,
3283
- mode: "deep-interview",
3284
- current_phase: "intent-first",
3787
+ current_phase: "executing",
3285
3788
  });
3286
3789
 
3287
3790
  const result = await dispatchCodexNativeHook(
3288
3791
  {
3289
3792
  hook_event_name: "Stop",
3290
3793
  cwd,
3291
- session_id: "sess-stop-auto-question",
3292
- thread_id: "thread-stop-auto-question",
3293
- turn_id: "turn-stop-auto-question-1",
3294
- last_assistant_message: [
3295
- "Round 2 | Target: Decision boundary | Ambiguity: 24%",
3296
- "",
3297
- "If an existing project spider still declares session_mode = \"owned\", should ZenX fail loudly so the stale attribute is removed, or should it ignore the attribute and initialize the session pool anyway?",
3298
- "Keep going once I have your answer.",
3299
- ].join("\n"),
3794
+ session_id: "sess-current",
3300
3795
  },
3301
3796
  { cwd },
3302
3797
  );
@@ -3308,124 +3803,179 @@ esac
3308
3803
  }
3309
3804
  });
3310
3805
 
3311
- it("suppresses native auto-nudge re-fire while session-scoped deep-interview state is still active", async () => {
3312
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-auto-nudge-deep-interview-state-"));
3806
+ it("keeps blocking Ralph Stop replays until the active task advances", async () => {
3807
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-ralph-replay-"));
3808
+ const previousOmxSessionId = process.env.OMX_SESSION_ID;
3313
3809
  try {
3314
3810
  const stateDir = join(cwd, ".omx", "state");
3315
- await mkdir(join(stateDir, "sessions", "sess-stop-auto-interview"), { recursive: true });
3316
- process.env.OMX_SESSION_ID = "sess-stop-auto-interview";
3317
- await writeJson(join(stateDir, "session.json"), { session_id: "sess-stop-auto-interview" });
3318
- await writeJson(join(stateDir, "sessions", "sess-stop-auto-interview", "deep-interview-state.json"), {
3319
- active: true,
3320
- mode: "deep-interview",
3321
- current_phase: "intent-first",
3322
- });
3811
+ await mkdir(stateDir, { recursive: true });
3812
+ await writeFile(
3813
+ join(stateDir, "ralph-state.json"),
3814
+ JSON.stringify({
3815
+ active: true,
3816
+ current_phase: "executing",
3817
+ }),
3818
+ );
3323
3819
 
3324
- const result = await dispatchCodexNativeHook(
3820
+ process.env.OMX_SESSION_ID = "sess-stop-ralph-replay";
3821
+ const payload = {
3822
+ hook_event_name: "Stop",
3823
+ cwd,
3824
+ last_assistant_message: "Next active targets:\n\n1. scheduler integration\n\nI am continuing.",
3825
+ };
3826
+ const expected = {
3827
+ decision: "block",
3828
+ reason:
3829
+ "OMX Ralph is still active (phase: executing); continue the task and gather fresh verification evidence before stopping.",
3830
+ stopReason: "ralph_executing",
3831
+ systemMessage:
3832
+ "OMX Ralph is still active (phase: executing); continue the task and gather fresh verification evidence before stopping.",
3833
+ };
3834
+
3835
+ const first = await dispatchCodexNativeHook(payload, { cwd });
3836
+ const replay = await dispatchCodexNativeHook(
3325
3837
  {
3326
- hook_event_name: "Stop",
3327
- cwd,
3328
- session_id: "sess-stop-auto-interview",
3329
- thread_id: "thread-stop-auto-interview",
3330
- turn_id: "turn-stop-auto-interview-2",
3838
+ ...payload,
3331
3839
  stop_hook_active: true,
3332
- last_assistant_message: "If you want, I can keep going from here.",
3333
3840
  },
3334
3841
  { cwd },
3335
3842
  );
3336
3843
 
3337
- assert.equal(result.omxEventName, "stop");
3338
- assert.equal(result.outputJson, null);
3844
+ assert.equal(first.omxEventName, "stop");
3845
+ assert.deepEqual(first.outputJson, expected);
3846
+ assert.equal(replay.omxEventName, "stop");
3847
+ assert.deepEqual(replay.outputJson, expected);
3339
3848
  } finally {
3849
+ if (typeof previousOmxSessionId === "string") process.env.OMX_SESSION_ID = previousOmxSessionId;
3850
+ else delete process.env.OMX_SESSION_ID;
3340
3851
  await rm(cwd, { recursive: true, force: true });
3341
3852
  }
3342
3853
  });
3343
3854
 
3344
- it("suppresses native auto-nudge when root deep-interview mode state is active without an explicit session", async () => {
3345
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-auto-nudge-deep-interview-mode-"));
3855
+ it("lets dispatcher dedupe identical native stop hook replays after Stop payload normalization", async () => {
3856
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-ralph-hook-dedupe-"));
3857
+ const previousOmxSessionId = process.env.OMX_SESSION_ID;
3346
3858
  try {
3347
3859
  const stateDir = join(cwd, ".omx", "state");
3348
- await mkdir(stateDir, { recursive: true });
3349
- process.env.OMX_SESSION_ID = "sess-stop-auto-mode";
3350
- await writeJson(join(stateDir, "deep-interview-state.json"), {
3351
- active: true,
3352
- mode: "deep-interview",
3353
- current_phase: "intent-first",
3354
- });
3860
+ await mkdir(join(stateDir, "sessions", "sess-stop-ralph-hook-dedupe"), { recursive: true });
3861
+ await writeHookCounterPlugin(cwd);
3862
+ await writeFile(
3863
+ join(stateDir, "sessions", "sess-stop-ralph-hook-dedupe", "ralph-state.json"),
3864
+ JSON.stringify({
3865
+ active: true,
3866
+ current_phase: "executing",
3867
+ session_id: "sess-stop-ralph-hook-dedupe",
3868
+ }),
3869
+ );
3355
3870
 
3356
- const result = await dispatchCodexNativeHook(
3871
+ process.env.OMX_SESSION_ID = "sess-stop-ralph-hook-dedupe";
3872
+ const payload = {
3873
+ hook_event_name: "Stop",
3874
+ cwd,
3875
+ session_id: "sess-stop-ralph-hook-dedupe",
3876
+ thread_id: "thread-stop-ralph-hook-dedupe",
3877
+ turn_id: "turn-stop-ralph-hook-dedupe-1",
3878
+ last_assistant_message: "Next active targets:\n\n1. scheduler integration\n\nI am continuing.",
3879
+ };
3880
+
3881
+ await dispatchCodexNativeHook(payload, { cwd });
3882
+ await dispatchCodexNativeHook(
3357
3883
  {
3358
- hook_event_name: "Stop",
3359
- cwd,
3360
- turn_id: "turn-stop-auto-mode-1",
3361
- last_assistant_message: "Would you like me to continue with the next step?",
3884
+ ...payload,
3885
+ stop_hook_active: true,
3362
3886
  },
3363
3887
  { cwd },
3364
3888
  );
3365
3889
 
3366
- assert.equal(result.omxEventName, "stop");
3367
- assert.equal(result.outputJson, null);
3890
+ const marker = JSON.parse(
3891
+ await readFile(join(cwd, ".omx", "stop-hook-counter.json"), "utf-8"),
3892
+ ) as { count: number };
3893
+ assert.equal(marker.count, 1);
3368
3894
  } finally {
3895
+ if (typeof previousOmxSessionId === "string") process.env.OMX_SESSION_ID = previousOmxSessionId;
3896
+ else delete process.env.OMX_SESSION_ID;
3369
3897
  await rm(cwd, { recursive: true, force: true });
3370
3898
  }
3371
3899
  });
3372
3900
 
3373
- it("does not suppress native auto-nudge from stale root deep-interview mode state when the explicit session-scoped mode state is absent", async () => {
3374
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-auto-nudge-stale-root-mode-"));
3901
+ it("preserves per-turn native stop hook delivery even when stop_hook_active remains true", async () => {
3902
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-ralph-hook-refire-"));
3903
+ const previousOmxSessionId = process.env.OMX_SESSION_ID;
3375
3904
  try {
3376
3905
  const stateDir = join(cwd, ".omx", "state");
3377
- await mkdir(stateDir, { recursive: true });
3378
- process.env.OMX_SESSION_ID = "sess-stop-auto-stale-root-mode";
3379
- await writeJson(join(stateDir, "deep-interview-state.json"), {
3380
- active: true,
3381
- mode: "deep-interview",
3382
- current_phase: "intent-first",
3383
- });
3906
+ await mkdir(join(stateDir, "sessions", "sess-stop-ralph-hook-refire"), { recursive: true });
3907
+ await writeHookCounterPlugin(cwd);
3908
+ await writeFile(
3909
+ join(stateDir, "sessions", "sess-stop-ralph-hook-refire", "ralph-state.json"),
3910
+ JSON.stringify({
3911
+ active: true,
3912
+ current_phase: "executing",
3913
+ session_id: "sess-stop-ralph-hook-refire",
3914
+ }),
3915
+ );
3384
3916
 
3385
- const result = await dispatchCodexNativeHook(
3917
+ process.env.OMX_SESSION_ID = "sess-stop-ralph-hook-refire";
3918
+ const payload = {
3919
+ hook_event_name: "Stop",
3920
+ cwd,
3921
+ session_id: "sess-stop-ralph-hook-refire",
3922
+ thread_id: "thread-stop-ralph-hook-refire",
3923
+ turn_id: "turn-stop-ralph-hook-refire-1",
3924
+ last_assistant_message: "Continuing current task.",
3925
+ };
3926
+
3927
+ await dispatchCodexNativeHook(payload, { cwd });
3928
+ await dispatchCodexNativeHook(
3386
3929
  {
3387
- hook_event_name: "Stop",
3388
- cwd,
3389
- session_id: "sess-stop-auto-stale-root-mode",
3390
- thread_id: "thread-stop-auto-stale-root-mode",
3391
- turn_id: "turn-stop-auto-stale-root-mode-1",
3392
- last_assistant_message: "Keep going and finish the cleanup.",
3930
+ ...payload,
3931
+ turn_id: "turn-stop-ralph-hook-refire-2",
3932
+ stop_hook_active: true,
3393
3933
  },
3394
3934
  { cwd },
3395
3935
  );
3396
3936
 
3397
- assert.equal(result.omxEventName, "stop");
3398
- assert.deepEqual(result.outputJson, {
3399
- decision: "block",
3400
- reason: DEFAULT_AUTO_NUDGE_RESPONSE,
3401
- stopReason: "auto_nudge",
3402
- systemMessage:
3403
- "OMX native Stop detected a stall/permission-style handoff and continued the turn automatically.",
3404
- });
3937
+ await writeFile(
3938
+ join(stateDir, "sessions", "sess-stop-ralph-hook-refire", "ralph-state.json"),
3939
+ JSON.stringify({
3940
+ active: true,
3941
+ current_phase: "executing",
3942
+ session_id: "sess-stop-ralph-hook-refire",
3943
+ }),
3944
+ );
3945
+
3946
+ await dispatchCodexNativeHook(
3947
+ {
3948
+ ...payload,
3949
+ turn_id: "turn-stop-ralph-hook-refire-3",
3950
+ stop_hook_active: true,
3951
+ },
3952
+ { cwd },
3953
+ );
3954
+
3955
+ const marker = JSON.parse(
3956
+ await readFile(join(cwd, ".omx", "stop-hook-counter.json"), "utf-8"),
3957
+ ) as { count: number };
3958
+ assert.equal(marker.count, 3);
3405
3959
  } finally {
3960
+ if (typeof previousOmxSessionId === "string") process.env.OMX_SESSION_ID = previousOmxSessionId;
3961
+ else delete process.env.OMX_SESSION_ID;
3406
3962
  await rm(cwd, { recursive: true, force: true });
3407
3963
  }
3408
3964
  });
3409
3965
 
3410
- it("does not suppress native auto-nudge from stale root deep-interview skill state when the explicit session-scoped canonical skill state is absent", async () => {
3411
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-auto-nudge-stale-root-skill-"));
3966
+
3967
+ it("returns Stop continuation output for native auto-nudge stall prompts", async () => {
3968
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-auto-nudge-"));
3412
3969
  try {
3413
3970
  const stateDir = join(cwd, ".omx", "state");
3414
3971
  await mkdir(stateDir, { recursive: true });
3415
- process.env.OMX_SESSION_ID = "sess-stop-auto-stale-root-skill";
3416
- await writeJson(join(stateDir, "skill-active-state.json"), {
3417
- active: true,
3418
- skill: "deep-interview",
3419
- phase: "planning",
3420
- });
3972
+ process.env.OMX_SESSION_ID = "sess-stop-auto";
3421
3973
 
3422
3974
  const result = await dispatchCodexNativeHook(
3423
3975
  {
3424
3976
  hook_event_name: "Stop",
3425
3977
  cwd,
3426
- session_id: "sess-stop-auto-stale-root-skill",
3427
- thread_id: "thread-stop-auto-stale-root-skill",
3428
- turn_id: "turn-stop-auto-stale-root-skill-1",
3978
+ session_id: "sess-stop-auto",
3429
3979
  last_assistant_message: "Keep going and finish the cleanup.",
3430
3980
  },
3431
3981
  { cwd },
@@ -3444,31 +3994,33 @@ esac
3444
3994
  }
3445
3995
  });
3446
3996
 
3447
- it("does not suppress native auto-nudge from stale root deep-interview input lock when the explicit session-scoped canonical skill state is absent", async () => {
3448
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-auto-nudge-stale-root-lock-"));
3997
+ it("re-blocks duplicate native auto-nudge replays for the same Stop reply", async () => {
3998
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-auto-nudge-once-"));
3449
3999
  try {
3450
4000
  const stateDir = join(cwd, ".omx", "state");
3451
4001
  await mkdir(stateDir, { recursive: true });
3452
- process.env.OMX_SESSION_ID = "sess-stop-auto-stale-root-lock";
3453
- await writeJson(join(stateDir, "skill-active-state.json"), {
3454
- active: true,
3455
- skill: "deep-interview",
3456
- phase: "planning",
3457
- input_lock: {
3458
- active: true,
3459
- scope: "deep-interview-auto-approval",
3460
- blocked_inputs: ["yes", "proceed"],
3461
- message: "Deep interview is active; auto-approval shortcuts are blocked until the interview finishes.",
4002
+ process.env.OMX_SESSION_ID = "sess-stop-auto-once";
4003
+
4004
+ await dispatchCodexNativeHook(
4005
+ {
4006
+ hook_event_name: "Stop",
4007
+ cwd,
4008
+ session_id: "sess-stop-auto-once",
4009
+ thread_id: "thread-stop-auto",
4010
+ turn_id: "turn-stop-auto-1",
4011
+ last_assistant_message: "Keep going and finish the cleanup.",
3462
4012
  },
3463
- });
4013
+ { cwd },
4014
+ );
3464
4015
 
3465
4016
  const result = await dispatchCodexNativeHook(
3466
4017
  {
3467
4018
  hook_event_name: "Stop",
3468
4019
  cwd,
3469
- session_id: "sess-stop-auto-stale-root-lock",
3470
- thread_id: "thread-stop-auto-stale-root-lock",
3471
- turn_id: "turn-stop-auto-stale-root-lock-1",
4020
+ session_id: "sess-stop-auto-once",
4021
+ thread_id: "thread-stop-auto",
4022
+ turn_id: "turn-stop-auto-1",
4023
+ stop_hook_active: true,
3472
4024
  last_assistant_message: "Keep going and finish the cleanup.",
3473
4025
  },
3474
4026
  { cwd },
@@ -3487,31 +4039,37 @@ esac
3487
4039
  }
3488
4040
  });
3489
4041
 
3490
- it("does not suppress native auto-nudge from active root deep-interview state when the current scoped mode state is explicitly inactive", async () => {
3491
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-auto-nudge-inactive-scoped-mode-"));
4042
+ it("re-blocks duplicate native auto-nudge replays across native/canonical session-id drift", async () => {
4043
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-auto-nudge-session-drift-"));
3492
4044
  try {
3493
4045
  const stateDir = join(cwd, ".omx", "state");
3494
- await mkdir(join(stateDir, "sessions", "sess-stop-auto-inactive-mode"), { recursive: true });
3495
- process.env.OMX_SESSION_ID = "sess-stop-auto-inactive-mode";
3496
- await writeJson(join(stateDir, "session.json"), { session_id: "sess-stop-auto-inactive-mode" });
3497
- await writeJson(join(stateDir, "sessions", "sess-stop-auto-inactive-mode", "deep-interview-state.json"), {
3498
- active: false,
3499
- mode: "deep-interview",
3500
- current_phase: "completed",
3501
- });
3502
- await writeJson(join(stateDir, "deep-interview-state.json"), {
3503
- active: true,
3504
- mode: "deep-interview",
3505
- current_phase: "intent-first",
4046
+ await mkdir(stateDir, { recursive: true });
4047
+ process.env.OMX_SESSION_ID = "omx-canonical";
4048
+ await writeJson(join(stateDir, "session.json"), {
4049
+ session_id: "omx-canonical",
4050
+ native_session_id: "codex-native",
3506
4051
  });
3507
4052
 
4053
+ await dispatchCodexNativeHook(
4054
+ {
4055
+ hook_event_name: "Stop",
4056
+ cwd,
4057
+ session_id: "codex-native",
4058
+ thread_id: "thread-stop-auto-drift",
4059
+ turn_id: "turn-stop-auto-drift-1",
4060
+ last_assistant_message: "Keep going and finish the cleanup.",
4061
+ },
4062
+ { cwd },
4063
+ );
4064
+
3508
4065
  const result = await dispatchCodexNativeHook(
3509
4066
  {
3510
4067
  hook_event_name: "Stop",
3511
4068
  cwd,
3512
- session_id: "sess-stop-auto-inactive-mode",
3513
- thread_id: "thread-stop-auto-inactive-mode",
3514
- turn_id: "turn-stop-auto-inactive-mode-1",
4069
+ session_id: "omx-canonical",
4070
+ thread_id: "thread-stop-auto-drift",
4071
+ turn_id: "turn-stop-auto-drift-1",
4072
+ stop_hook_active: true,
3515
4073
  last_assistant_message: "Keep going and finish the cleanup.",
3516
4074
  },
3517
4075
  { cwd },
@@ -3525,76 +4083,94 @@ esac
3525
4083
  systemMessage:
3526
4084
  "OMX native Stop detected a stall/permission-style handoff and continued the turn automatically.",
3527
4085
  });
4086
+
4087
+ const persisted = JSON.parse(
4088
+ await readFile(join(stateDir, "native-stop-state.json"), "utf-8"),
4089
+ ) as { sessions?: Record<string, unknown> };
4090
+ assert.deepEqual(Object.keys(persisted.sessions ?? {}), ["omx-canonical"]);
3528
4091
  } finally {
3529
4092
  await rm(cwd, { recursive: true, force: true });
3530
4093
  }
3531
4094
  });
3532
4095
 
3533
- it("auto-continues native Stop for permission-seeking prompts even outside OMX runtime", async () => {
3534
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-auto-nudge-plain-session-"));
4096
+ it("dedupes native stop hook replay across owner launch SessionStart reconciliation drift", async () => {
4097
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-dispatch-session-drift-"));
3535
4098
  try {
4099
+ const stateDir = join(cwd, ".omx", "state");
4100
+ await mkdir(join(stateDir, "sessions", "omx-canonical"), { recursive: true });
4101
+ await writeHookCounterPlugin(cwd);
4102
+ process.env.OMX_SESSION_ID = "omx-canonical";
4103
+ await writeSessionStart(cwd, "omx-canonical");
4104
+ await writeJson(join(stateDir, "sessions", "omx-canonical", "ralph-state.json"), {
4105
+ active: true,
4106
+ current_phase: "executing",
4107
+ session_id: "omx-canonical",
4108
+ });
4109
+
3536
4110
  await dispatchCodexNativeHook(
3537
4111
  {
3538
4112
  hook_event_name: "SessionStart",
3539
4113
  cwd,
3540
- session_id: "plain-stop-session",
4114
+ session_id: "codex-native-new",
3541
4115
  },
4116
+ { cwd, sessionOwnerPid: process.pid },
4117
+ );
4118
+
4119
+ await dispatchCodexNativeHook(
3542
4120
  {
4121
+ hook_event_name: "Stop",
3543
4122
  cwd,
3544
- sessionOwnerPid: process.pid,
4123
+ session_id: "codex-native-new",
4124
+ thread_id: "thread-stop-hook-drift",
4125
+ turn_id: "turn-stop-hook-drift-1",
4126
+ last_assistant_message: "Keep going and finish the cleanup.",
3545
4127
  },
4128
+ { cwd },
3546
4129
  );
3547
4130
 
3548
- const result = await dispatchCodexNativeHook(
4131
+ await dispatchCodexNativeHook(
3549
4132
  {
3550
4133
  hook_event_name: "Stop",
3551
4134
  cwd,
3552
- session_id: "plain-stop-session",
3553
- thread_id: "plain-thread",
3554
- turn_id: "plain-turn-1",
3555
- last_assistant_message: "If you want, I can continue with the cleanup from here.",
4135
+ session_id: "omx-canonical",
4136
+ thread_id: "thread-stop-hook-drift",
4137
+ turn_id: "turn-stop-hook-drift-1",
4138
+ stop_hook_active: true,
4139
+ last_assistant_message: "Keep going and finish the cleanup.",
3556
4140
  },
3557
4141
  { cwd },
3558
4142
  );
3559
4143
 
3560
- assert.equal(result.omxEventName, "stop");
3561
- assert.deepEqual(result.outputJson, {
3562
- decision: "block",
3563
- reason: DEFAULT_AUTO_NUDGE_RESPONSE,
3564
- stopReason: "auto_nudge",
3565
- systemMessage:
3566
- "OMX native Stop detected a stall/permission-style handoff and continued the turn automatically.",
3567
- });
3568
- } finally {
3569
- await rm(cwd, { recursive: true, force: true });
4144
+ const marker = JSON.parse(
4145
+ await readFile(join(cwd, ".omx", "stop-hook-counter.json"), "utf-8"),
4146
+ ) as { count: number };
4147
+ assert.equal(marker.count, 1);
4148
+
4149
+ const sessionState = JSON.parse(
4150
+ await readFile(join(stateDir, "session.json"), "utf-8"),
4151
+ ) as { session_id?: string; native_session_id?: string };
4152
+ assert.equal(sessionState.session_id, "omx-canonical");
4153
+ assert.equal(sessionState.native_session_id, "codex-native-new");
4154
+ } finally {
4155
+ await rm(cwd, { recursive: true, force: true });
3570
4156
  }
3571
4157
  });
3572
4158
 
3573
- it("re-fires team Stop output for a later fresh Stop reply while the team is still active", async () => {
3574
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-team-refire-"));
4159
+ it("re-fires native auto-nudge for a later fresh Stop reply even when stop_hook_active is true", async () => {
4160
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-auto-nudge-refire-"));
3575
4161
  try {
3576
4162
  const stateDir = join(cwd, ".omx", "state");
3577
4163
  await mkdir(stateDir, { recursive: true });
3578
- await writeJson(join(stateDir, "team-state.json"), {
3579
- active: true,
3580
- current_phase: "team-exec",
3581
- team_name: "review-team",
3582
- });
3583
- await writeJson(join(stateDir, "team", "review-team", "phase.json"), {
3584
- current_phase: "team-verify",
3585
- max_fix_attempts: 3,
3586
- current_fix_attempt: 0,
3587
- transitions: [],
3588
- updated_at: new Date().toISOString(),
3589
- });
4164
+ process.env.OMX_SESSION_ID = "sess-stop-auto-refire";
3590
4165
 
3591
4166
  await dispatchCodexNativeHook(
3592
4167
  {
3593
4168
  hook_event_name: "Stop",
3594
4169
  cwd,
3595
- session_id: "sess-stop-team-refire",
3596
- thread_id: "thread-stop-team-refire",
3597
- turn_id: "turn-stop-team-refire-1",
4170
+ session_id: "sess-stop-auto-refire",
4171
+ thread_id: "thread-stop-auto-refire",
4172
+ turn_id: "turn-stop-auto-refire-1",
4173
+ last_assistant_message: "Keep going and finish the cleanup.",
3598
4174
  },
3599
4175
  { cwd },
3600
4176
  );
@@ -3603,10 +4179,11 @@ esac
3603
4179
  {
3604
4180
  hook_event_name: "Stop",
3605
4181
  cwd,
3606
- session_id: "sess-stop-team-refire",
3607
- thread_id: "thread-stop-team-refire",
3608
- turn_id: "turn-stop-team-refire-2",
4182
+ session_id: "sess-stop-auto-refire",
4183
+ thread_id: "thread-stop-auto-refire",
4184
+ turn_id: "turn-stop-auto-refire-2",
3609
4185
  stop_hook_active: true,
4186
+ last_assistant_message: "Continue with the cleanup from here.",
3610
4187
  },
3611
4188
  { cwd },
3612
4189
  );
@@ -3614,379 +4191,1483 @@ esac
3614
4191
  assert.equal(result.omxEventName, "stop");
3615
4192
  assert.deepEqual(result.outputJson, {
3616
4193
  decision: "block",
3617
- reason:
3618
- `OMX team pipeline is still active (review-team) at phase team-verify; continue coordinating until the team reaches a terminal phase.${TEAM_STOP_COMMIT_GUIDANCE}`,
3619
- stopReason: "team_team-verify",
3620
- systemMessage: "OMX team pipeline is still active at phase team-verify.",
4194
+ reason: DEFAULT_AUTO_NUDGE_RESPONSE,
4195
+ stopReason: "auto_nudge",
4196
+ systemMessage:
4197
+ "OMX native Stop detected a stall/permission-style handoff and continued the turn automatically.",
3621
4198
  });
3622
4199
  } finally {
3623
4200
  await rm(cwd, { recursive: true, force: true });
3624
4201
  }
3625
4202
  });
3626
4203
 
3627
- it("suppresses duplicate team Stop replays across native/canonical session-id drift", async () => {
3628
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-team-session-drift-"));
4204
+ it("auto-continues native Stop on permission-seeking prompts", async () => {
4205
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-auto-nudge-permission-"));
3629
4206
  try {
3630
- const stateDir = join(cwd, ".omx", "state");
3631
- await mkdir(join(stateDir, "sessions", "omx-canonical"), { recursive: true });
3632
- process.env.OMX_SESSION_ID = "omx-canonical";
3633
- await writeJson(join(stateDir, "session.json"), {
3634
- session_id: "omx-canonical",
3635
- native_session_id: "codex-native",
4207
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
4208
+ process.env.OMX_SESSION_ID = "sess-stop-auto-permission";
4209
+
4210
+ const result = await dispatchCodexNativeHook(
4211
+ {
4212
+ hook_event_name: "Stop",
4213
+ cwd,
4214
+ session_id: "sess-stop-auto-permission",
4215
+ last_assistant_message: "Would you like me to continue with the cleanup?",
4216
+ },
4217
+ { cwd },
4218
+ );
4219
+
4220
+ assert.equal(result.omxEventName, "stop");
4221
+ assert.deepEqual(result.outputJson, {
4222
+ decision: "block",
4223
+ reason: DEFAULT_AUTO_NUDGE_RESPONSE,
4224
+ stopReason: "auto_nudge",
4225
+ systemMessage:
4226
+ "OMX native Stop detected a stall/permission-style handoff and continued the turn automatically.",
3636
4227
  });
3637
- await writeJson(join(stateDir, "sessions", "omx-canonical", "team-state.json"), {
4228
+ } finally {
4229
+ await rm(cwd, { recursive: true, force: true });
4230
+ }
4231
+ });
4232
+
4233
+ it("auto-continues native Stop on \"if you want\" permission-seeking prompts", async () => {
4234
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-auto-nudge-if-you-want-"));
4235
+ try {
4236
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
4237
+ process.env.OMX_SESSION_ID = "sess-stop-auto-if-you-want";
4238
+
4239
+ const result = await dispatchCodexNativeHook(
4240
+ {
4241
+ hook_event_name: "Stop",
4242
+ cwd,
4243
+ session_id: "sess-stop-auto-if-you-want",
4244
+ last_assistant_message: "If you want, I can continue with the cleanup from here.",
4245
+ },
4246
+ { cwd },
4247
+ );
4248
+
4249
+ assert.equal(result.omxEventName, "stop");
4250
+ assert.deepEqual(result.outputJson, {
4251
+ decision: "block",
4252
+ reason: DEFAULT_AUTO_NUDGE_RESPONSE,
4253
+ stopReason: "auto_nudge",
4254
+ systemMessage:
4255
+ "OMX native Stop detected a stall/permission-style handoff and continued the turn automatically.",
4256
+ });
4257
+ } finally {
4258
+ await rm(cwd, { recursive: true, force: true });
4259
+ }
4260
+ });
4261
+
4262
+ it("does not auto-continue native Stop while deep-interview is waiting on an intent-first question", async () => {
4263
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-auto-nudge-deep-interview-question-"));
4264
+ try {
4265
+ const stateDir = join(cwd, ".omx", "state");
4266
+ await mkdir(join(stateDir, "sessions", "sess-stop-auto-question"), { recursive: true });
4267
+ process.env.OMX_SESSION_ID = "sess-stop-auto-question";
4268
+ await writeJson(join(stateDir, "session.json"), { session_id: "sess-stop-auto-question" });
4269
+ await writeJson(join(stateDir, "sessions", "sess-stop-auto-question", "skill-active-state.json"), {
4270
+ version: 1,
3638
4271
  active: true,
3639
- current_phase: "starting",
3640
- team_name: "current-team",
3641
- session_id: "omx-canonical",
4272
+ skill: "deep-interview",
4273
+ phase: "planning",
4274
+ session_id: "sess-stop-auto-question",
4275
+ thread_id: "thread-stop-auto-question",
4276
+ input_lock: {
4277
+ active: true,
4278
+ scope: "deep-interview-auto-approval",
4279
+ blocked_inputs: ["yes", "proceed"],
4280
+ message: "Deep interview is active; auto-approval shortcuts are blocked until the interview finishes.",
4281
+ },
3642
4282
  });
3643
- await writeJson(join(stateDir, "team", "current-team", "phase.json"), {
3644
- current_phase: "team-verify",
3645
- max_fix_attempts: 3,
3646
- current_fix_attempt: 1,
3647
- transitions: [],
3648
- updated_at: new Date().toISOString(),
4283
+ await writeJson(join(stateDir, "sessions", "sess-stop-auto-question", "deep-interview-state.json"), {
4284
+ active: true,
4285
+ mode: "deep-interview",
4286
+ current_phase: "intent-first",
3649
4287
  });
3650
4288
 
3651
- await dispatchCodexNativeHook(
4289
+ const result = await dispatchCodexNativeHook(
3652
4290
  {
3653
4291
  hook_event_name: "Stop",
3654
4292
  cwd,
3655
- session_id: "codex-native",
3656
- thread_id: "thread-stop-team-drift",
3657
- turn_id: "turn-stop-team-drift-1",
4293
+ session_id: "sess-stop-auto-question",
4294
+ thread_id: "thread-stop-auto-question",
4295
+ turn_id: "turn-stop-auto-question-1",
4296
+ last_assistant_message: [
4297
+ "Round 2 | Target: Decision boundary | Ambiguity: 24%",
4298
+ "",
4299
+ "If an existing project spider still declares session_mode = \"owned\", should ZenX fail loudly so the stale attribute is removed, or should it ignore the attribute and initialize the session pool anyway?",
4300
+ "Keep going once I have your answer.",
4301
+ ].join("\n"),
3658
4302
  },
3659
4303
  { cwd },
3660
4304
  );
3661
4305
 
3662
- const duplicate = await dispatchCodexNativeHook(
4306
+ assert.equal(result.omxEventName, "stop");
4307
+ assert.equal(result.outputJson, null);
4308
+ } finally {
4309
+ await rm(cwd, { recursive: true, force: true });
4310
+ }
4311
+ });
4312
+
4313
+ it("suppresses native auto-nudge re-fire while session-scoped deep-interview state is still active", async () => {
4314
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-auto-nudge-deep-interview-state-"));
4315
+ try {
4316
+ const stateDir = join(cwd, ".omx", "state");
4317
+ await mkdir(join(stateDir, "sessions", "sess-stop-auto-interview"), { recursive: true });
4318
+ process.env.OMX_SESSION_ID = "sess-stop-auto-interview";
4319
+ await writeJson(join(stateDir, "session.json"), { session_id: "sess-stop-auto-interview" });
4320
+ await writeJson(join(stateDir, "sessions", "sess-stop-auto-interview", "deep-interview-state.json"), {
4321
+ active: true,
4322
+ mode: "deep-interview",
4323
+ current_phase: "intent-first",
4324
+ });
4325
+
4326
+ const result = await dispatchCodexNativeHook(
3663
4327
  {
3664
4328
  hook_event_name: "Stop",
3665
4329
  cwd,
3666
- session_id: "omx-canonical",
3667
- thread_id: "thread-stop-team-drift",
3668
- turn_id: "turn-stop-team-drift-1",
4330
+ session_id: "sess-stop-auto-interview",
4331
+ thread_id: "thread-stop-auto-interview",
4332
+ turn_id: "turn-stop-auto-interview-2",
3669
4333
  stop_hook_active: true,
4334
+ last_assistant_message: "If you want, I can keep going from here.",
3670
4335
  },
3671
4336
  { cwd },
3672
4337
  );
3673
4338
 
3674
- assert.equal(duplicate.omxEventName, "stop");
3675
- assert.deepEqual(duplicate.outputJson, {
3676
- decision: "block",
3677
- reason:
3678
- `OMX team pipeline is still active (current-team) at phase team-verify; continue coordinating until the team reaches a terminal phase.${TEAM_STOP_COMMIT_GUIDANCE}`,
3679
- stopReason: "team_team-verify",
3680
- systemMessage: "OMX team pipeline is still active at phase team-verify.",
4339
+ assert.equal(result.omxEventName, "stop");
4340
+ assert.equal(result.outputJson, null);
4341
+ } finally {
4342
+ await rm(cwd, { recursive: true, force: true });
4343
+ }
4344
+ });
4345
+
4346
+ it("suppresses native auto-nudge when root deep-interview mode state is active without an explicit session", async () => {
4347
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-auto-nudge-deep-interview-mode-"));
4348
+ try {
4349
+ const stateDir = join(cwd, ".omx", "state");
4350
+ await mkdir(stateDir, { recursive: true });
4351
+ process.env.OMX_SESSION_ID = "sess-stop-auto-mode";
4352
+ await writeJson(join(stateDir, "deep-interview-state.json"), {
4353
+ active: true,
4354
+ mode: "deep-interview",
4355
+ current_phase: "intent-first",
4356
+ });
4357
+
4358
+ const result = await dispatchCodexNativeHook(
4359
+ {
4360
+ hook_event_name: "Stop",
4361
+ cwd,
4362
+ turn_id: "turn-stop-auto-mode-1",
4363
+ last_assistant_message: "Would you like me to continue with the next step?",
4364
+ },
4365
+ { cwd },
4366
+ );
4367
+
4368
+ assert.equal(result.omxEventName, "stop");
4369
+ assert.equal(result.outputJson, null);
4370
+ } finally {
4371
+ await rm(cwd, { recursive: true, force: true });
4372
+ }
4373
+ });
4374
+
4375
+ it("does not suppress native auto-nudge from stale root deep-interview mode state when the explicit session-scoped mode state is absent", async () => {
4376
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-auto-nudge-stale-root-mode-"));
4377
+ try {
4378
+ const stateDir = join(cwd, ".omx", "state");
4379
+ await mkdir(stateDir, { recursive: true });
4380
+ process.env.OMX_SESSION_ID = "sess-stop-auto-stale-root-mode";
4381
+ await writeJson(join(stateDir, "deep-interview-state.json"), {
4382
+ active: true,
4383
+ mode: "deep-interview",
4384
+ current_phase: "intent-first",
3681
4385
  });
3682
4386
 
3683
- const fresh = await dispatchCodexNativeHook(
4387
+ const result = await dispatchCodexNativeHook(
4388
+ {
4389
+ hook_event_name: "Stop",
4390
+ cwd,
4391
+ session_id: "sess-stop-auto-stale-root-mode",
4392
+ thread_id: "thread-stop-auto-stale-root-mode",
4393
+ turn_id: "turn-stop-auto-stale-root-mode-1",
4394
+ last_assistant_message: "Keep going and finish the cleanup.",
4395
+ },
4396
+ { cwd },
4397
+ );
4398
+
4399
+ assert.equal(result.omxEventName, "stop");
4400
+ assert.deepEqual(result.outputJson, {
4401
+ decision: "block",
4402
+ reason: DEFAULT_AUTO_NUDGE_RESPONSE,
4403
+ stopReason: "auto_nudge",
4404
+ systemMessage:
4405
+ "OMX native Stop detected a stall/permission-style handoff and continued the turn automatically.",
4406
+ });
4407
+ } finally {
4408
+ await rm(cwd, { recursive: true, force: true });
4409
+ }
4410
+ });
4411
+
4412
+ it("does not suppress native auto-nudge from stale root deep-interview skill state when the explicit session-scoped canonical skill state is absent", async () => {
4413
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-auto-nudge-stale-root-skill-"));
4414
+ try {
4415
+ const stateDir = join(cwd, ".omx", "state");
4416
+ await mkdir(stateDir, { recursive: true });
4417
+ process.env.OMX_SESSION_ID = "sess-stop-auto-stale-root-skill";
4418
+ await writeJson(join(stateDir, "skill-active-state.json"), {
4419
+ active: true,
4420
+ skill: "deep-interview",
4421
+ phase: "planning",
4422
+ });
4423
+
4424
+ const result = await dispatchCodexNativeHook(
4425
+ {
4426
+ hook_event_name: "Stop",
4427
+ cwd,
4428
+ session_id: "sess-stop-auto-stale-root-skill",
4429
+ thread_id: "thread-stop-auto-stale-root-skill",
4430
+ turn_id: "turn-stop-auto-stale-root-skill-1",
4431
+ last_assistant_message: "Keep going and finish the cleanup.",
4432
+ },
4433
+ { cwd },
4434
+ );
4435
+
4436
+ assert.equal(result.omxEventName, "stop");
4437
+ assert.deepEqual(result.outputJson, {
4438
+ decision: "block",
4439
+ reason: DEFAULT_AUTO_NUDGE_RESPONSE,
4440
+ stopReason: "auto_nudge",
4441
+ systemMessage:
4442
+ "OMX native Stop detected a stall/permission-style handoff and continued the turn automatically.",
4443
+ });
4444
+ } finally {
4445
+ await rm(cwd, { recursive: true, force: true });
4446
+ }
4447
+ });
4448
+
4449
+ it("does not suppress native auto-nudge from stale root deep-interview input lock when the explicit session-scoped canonical skill state is absent", async () => {
4450
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-auto-nudge-stale-root-lock-"));
4451
+ try {
4452
+ const stateDir = join(cwd, ".omx", "state");
4453
+ await mkdir(stateDir, { recursive: true });
4454
+ process.env.OMX_SESSION_ID = "sess-stop-auto-stale-root-lock";
4455
+ await writeJson(join(stateDir, "skill-active-state.json"), {
4456
+ active: true,
4457
+ skill: "deep-interview",
4458
+ phase: "planning",
4459
+ input_lock: {
4460
+ active: true,
4461
+ scope: "deep-interview-auto-approval",
4462
+ blocked_inputs: ["yes", "proceed"],
4463
+ message: "Deep interview is active; auto-approval shortcuts are blocked until the interview finishes.",
4464
+ },
4465
+ });
4466
+
4467
+ const result = await dispatchCodexNativeHook(
4468
+ {
4469
+ hook_event_name: "Stop",
4470
+ cwd,
4471
+ session_id: "sess-stop-auto-stale-root-lock",
4472
+ thread_id: "thread-stop-auto-stale-root-lock",
4473
+ turn_id: "turn-stop-auto-stale-root-lock-1",
4474
+ last_assistant_message: "Keep going and finish the cleanup.",
4475
+ },
4476
+ { cwd },
4477
+ );
4478
+
4479
+ assert.equal(result.omxEventName, "stop");
4480
+ assert.deepEqual(result.outputJson, {
4481
+ decision: "block",
4482
+ reason: DEFAULT_AUTO_NUDGE_RESPONSE,
4483
+ stopReason: "auto_nudge",
4484
+ systemMessage:
4485
+ "OMX native Stop detected a stall/permission-style handoff and continued the turn automatically.",
4486
+ });
4487
+ } finally {
4488
+ await rm(cwd, { recursive: true, force: true });
4489
+ }
4490
+ });
4491
+
4492
+ it("does not suppress native auto-nudge from active root deep-interview state when the current scoped mode state is explicitly inactive", async () => {
4493
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-auto-nudge-inactive-scoped-mode-"));
4494
+ try {
4495
+ const stateDir = join(cwd, ".omx", "state");
4496
+ await mkdir(join(stateDir, "sessions", "sess-stop-auto-inactive-mode"), { recursive: true });
4497
+ process.env.OMX_SESSION_ID = "sess-stop-auto-inactive-mode";
4498
+ await writeJson(join(stateDir, "session.json"), { session_id: "sess-stop-auto-inactive-mode" });
4499
+ await writeJson(join(stateDir, "sessions", "sess-stop-auto-inactive-mode", "deep-interview-state.json"), {
4500
+ active: false,
4501
+ mode: "deep-interview",
4502
+ current_phase: "completed",
4503
+ });
4504
+ await writeJson(join(stateDir, "deep-interview-state.json"), {
4505
+ active: true,
4506
+ mode: "deep-interview",
4507
+ current_phase: "intent-first",
4508
+ });
4509
+
4510
+ const result = await dispatchCodexNativeHook(
4511
+ {
4512
+ hook_event_name: "Stop",
4513
+ cwd,
4514
+ session_id: "sess-stop-auto-inactive-mode",
4515
+ thread_id: "thread-stop-auto-inactive-mode",
4516
+ turn_id: "turn-stop-auto-inactive-mode-1",
4517
+ last_assistant_message: "Keep going and finish the cleanup.",
4518
+ },
4519
+ { cwd },
4520
+ );
4521
+
4522
+ assert.equal(result.omxEventName, "stop");
4523
+ assert.deepEqual(result.outputJson, {
4524
+ decision: "block",
4525
+ reason: DEFAULT_AUTO_NUDGE_RESPONSE,
4526
+ stopReason: "auto_nudge",
4527
+ systemMessage:
4528
+ "OMX native Stop detected a stall/permission-style handoff and continued the turn automatically.",
4529
+ });
4530
+ } finally {
4531
+ await rm(cwd, { recursive: true, force: true });
4532
+ }
4533
+ });
4534
+
4535
+ it("auto-continues native Stop for permission-seeking prompts even outside OMX runtime", async () => {
4536
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-auto-nudge-plain-session-"));
4537
+ try {
4538
+ await dispatchCodexNativeHook(
4539
+ {
4540
+ hook_event_name: "SessionStart",
4541
+ cwd,
4542
+ session_id: "plain-stop-session",
4543
+ },
4544
+ {
4545
+ cwd,
4546
+ sessionOwnerPid: process.pid,
4547
+ },
4548
+ );
4549
+
4550
+ const result = await dispatchCodexNativeHook(
4551
+ {
4552
+ hook_event_name: "Stop",
4553
+ cwd,
4554
+ session_id: "plain-stop-session",
4555
+ thread_id: "plain-thread",
4556
+ turn_id: "plain-turn-1",
4557
+ last_assistant_message: "If you want, I can continue with the cleanup from here.",
4558
+ },
4559
+ { cwd },
4560
+ );
4561
+
4562
+ assert.equal(result.omxEventName, "stop");
4563
+ assert.deepEqual(result.outputJson, {
4564
+ decision: "block",
4565
+ reason: DEFAULT_AUTO_NUDGE_RESPONSE,
4566
+ stopReason: "auto_nudge",
4567
+ systemMessage:
4568
+ "OMX native Stop detected a stall/permission-style handoff and continued the turn automatically.",
4569
+ });
4570
+ } finally {
4571
+ await rm(cwd, { recursive: true, force: true });
4572
+ }
4573
+ });
4574
+
4575
+ it("re-fires team Stop output for a later fresh Stop reply while the team is still active", async () => {
4576
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-team-refire-"));
4577
+ try {
4578
+ const stateDir = join(cwd, ".omx", "state");
4579
+ await mkdir(stateDir, { recursive: true });
4580
+ await writeJson(join(stateDir, "team-state.json"), {
4581
+ active: true,
4582
+ current_phase: "team-exec",
4583
+ team_name: "review-team",
4584
+ });
4585
+ await writeJson(join(stateDir, "team", "review-team", "phase.json"), {
4586
+ current_phase: "team-verify",
4587
+ max_fix_attempts: 3,
4588
+ current_fix_attempt: 0,
4589
+ transitions: [],
4590
+ updated_at: new Date().toISOString(),
4591
+ });
4592
+
4593
+ await dispatchCodexNativeHook(
4594
+ {
4595
+ hook_event_name: "Stop",
4596
+ cwd,
4597
+ session_id: "sess-stop-team-refire",
4598
+ thread_id: "thread-stop-team-refire",
4599
+ turn_id: "turn-stop-team-refire-1",
4600
+ },
4601
+ { cwd },
4602
+ );
4603
+
4604
+ const result = await dispatchCodexNativeHook(
4605
+ {
4606
+ hook_event_name: "Stop",
4607
+ cwd,
4608
+ session_id: "sess-stop-team-refire",
4609
+ thread_id: "thread-stop-team-refire",
4610
+ turn_id: "turn-stop-team-refire-2",
4611
+ stop_hook_active: true,
4612
+ },
4613
+ { cwd },
4614
+ );
4615
+
4616
+ assert.equal(result.omxEventName, "stop");
4617
+ assert.deepEqual(result.outputJson, {
4618
+ decision: "block",
4619
+ reason:
4620
+ `OMX team pipeline is still active (review-team) at phase team-verify; continue coordinating until the team reaches a terminal phase.${TEAM_STOP_COMMIT_GUIDANCE}`,
4621
+ stopReason: "team_team-verify",
4622
+ systemMessage: "OMX team pipeline is still active at phase team-verify.",
4623
+ });
4624
+ } finally {
4625
+ await rm(cwd, { recursive: true, force: true });
4626
+ }
4627
+ });
4628
+
4629
+ it("suppresses duplicate team Stop replays across native/canonical session-id drift", async () => {
4630
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-team-session-drift-"));
4631
+ try {
4632
+ const stateDir = join(cwd, ".omx", "state");
4633
+ await mkdir(join(stateDir, "sessions", "omx-canonical"), { recursive: true });
4634
+ process.env.OMX_SESSION_ID = "omx-canonical";
4635
+ await writeJson(join(stateDir, "session.json"), {
4636
+ session_id: "omx-canonical",
4637
+ native_session_id: "codex-native",
4638
+ });
4639
+ await writeJson(join(stateDir, "sessions", "omx-canonical", "team-state.json"), {
4640
+ active: true,
4641
+ current_phase: "starting",
4642
+ team_name: "current-team",
4643
+ session_id: "omx-canonical",
4644
+ });
4645
+ await writeJson(join(stateDir, "team", "current-team", "phase.json"), {
4646
+ current_phase: "team-verify",
4647
+ max_fix_attempts: 3,
4648
+ current_fix_attempt: 1,
4649
+ transitions: [],
4650
+ updated_at: new Date().toISOString(),
4651
+ });
4652
+
4653
+ await dispatchCodexNativeHook(
4654
+ {
4655
+ hook_event_name: "Stop",
4656
+ cwd,
4657
+ session_id: "codex-native",
4658
+ thread_id: "thread-stop-team-drift",
4659
+ turn_id: "turn-stop-team-drift-1",
4660
+ },
4661
+ { cwd },
4662
+ );
4663
+
4664
+ const duplicate = await dispatchCodexNativeHook(
4665
+ {
4666
+ hook_event_name: "Stop",
4667
+ cwd,
4668
+ session_id: "omx-canonical",
4669
+ thread_id: "thread-stop-team-drift",
4670
+ turn_id: "turn-stop-team-drift-1",
4671
+ stop_hook_active: true,
4672
+ },
4673
+ { cwd },
4674
+ );
4675
+
4676
+ assert.equal(duplicate.omxEventName, "stop");
4677
+ assert.deepEqual(duplicate.outputJson, {
4678
+ decision: "block",
4679
+ reason:
4680
+ `OMX team pipeline is still active (current-team) at phase team-verify; continue coordinating until the team reaches a terminal phase.${TEAM_STOP_COMMIT_GUIDANCE}`,
4681
+ stopReason: "team_team-verify",
4682
+ systemMessage: "OMX team pipeline is still active at phase team-verify.",
4683
+ });
4684
+
4685
+ const fresh = await dispatchCodexNativeHook(
4686
+ {
4687
+ hook_event_name: "Stop",
4688
+ cwd,
4689
+ session_id: "omx-canonical",
4690
+ thread_id: "thread-stop-team-drift",
4691
+ turn_id: "turn-stop-team-drift-2",
4692
+ stop_hook_active: true,
4693
+ },
4694
+ { cwd },
4695
+ );
4696
+
4697
+ assert.equal(fresh.omxEventName, "stop");
4698
+ assert.deepEqual(fresh.outputJson, {
4699
+ decision: "block",
4700
+ reason:
4701
+ `OMX team pipeline is still active (current-team) at phase team-verify; continue coordinating until the team reaches a terminal phase.${TEAM_STOP_COMMIT_GUIDANCE}`,
4702
+ stopReason: "team_team-verify",
4703
+ systemMessage: "OMX team pipeline is still active at phase team-verify.",
4704
+ });
4705
+
4706
+ const persisted = JSON.parse(
4707
+ await readFile(join(stateDir, "native-stop-state.json"), "utf-8"),
4708
+ ) as { sessions?: Record<string, unknown> };
4709
+ assert.deepEqual(Object.keys(persisted.sessions ?? {}), ["omx-canonical"]);
4710
+ } finally {
4711
+ await rm(cwd, { recursive: true, force: true });
4712
+ }
4713
+ });
4714
+
4715
+ it("suppresses duplicate ultrawork Stop replays while stop_hook_active stays true", async () => {
4716
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-ultrawork-repeat-"));
4717
+ try {
4718
+ const stateDir = join(cwd, ".omx", "state");
4719
+ await mkdir(stateDir, { recursive: true });
4720
+ await writeJson(join(stateDir, "ultrawork-state.json"), {
4721
+ active: true,
4722
+ current_phase: "executing",
4723
+ });
4724
+
4725
+ const first = await dispatchCodexNativeHook(
4726
+ {
4727
+ hook_event_name: "Stop",
4728
+ cwd,
4729
+ session_id: "sess-stop-ultrawork-repeat",
4730
+ thread_id: "thread-stop-ultrawork-repeat",
4731
+ turn_id: "turn-stop-ultrawork-repeat-1",
4732
+ },
4733
+ { cwd },
4734
+ );
4735
+
4736
+ const repeated = await dispatchCodexNativeHook(
4737
+ {
4738
+ hook_event_name: "Stop",
4739
+ cwd,
4740
+ session_id: "sess-stop-ultrawork-repeat",
4741
+ thread_id: "thread-stop-ultrawork-repeat",
4742
+ turn_id: "turn-stop-ultrawork-repeat-1",
4743
+ stop_hook_active: true,
4744
+ },
4745
+ { cwd },
4746
+ );
4747
+
4748
+ const fresh = await dispatchCodexNativeHook(
4749
+ {
4750
+ hook_event_name: "Stop",
4751
+ cwd,
4752
+ session_id: "sess-stop-ultrawork-repeat",
4753
+ thread_id: "thread-stop-ultrawork-repeat",
4754
+ turn_id: "turn-stop-ultrawork-repeat-2",
4755
+ stop_hook_active: true,
4756
+ },
4757
+ { cwd },
4758
+ );
4759
+
4760
+ assert.equal(first.omxEventName, "stop");
4761
+ assert.deepEqual(repeated.outputJson, null);
4762
+ assert.equal(fresh.omxEventName, "stop");
4763
+ assert.deepEqual(fresh.outputJson, {
4764
+ decision: "block",
4765
+ reason: "OMX ultrawork is still active (phase: executing); continue the task and gather fresh verification evidence before stopping.",
4766
+ stopReason: "ultrawork_executing",
4767
+ systemMessage: "OMX ultrawork is still active (phase: executing).",
4768
+ });
4769
+ } finally {
4770
+ await rm(cwd, { recursive: true, force: true });
4771
+ }
4772
+ });
4773
+
4774
+ it("re-blocks active ralplan skill state on repeated Stop hooks", async () => {
4775
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-skill-repeat-"));
4776
+ try {
4777
+ const stateDir = join(cwd, ".omx", "state");
4778
+ await mkdir(join(stateDir, "sessions", "sess-stop-skill-repeat"), { recursive: true });
4779
+ await writeJson(join(stateDir, "session.json"), { session_id: "sess-stop-skill-repeat" });
4780
+ await writeJson(join(stateDir, "sessions", "sess-stop-skill-repeat", "skill-active-state.json"), {
4781
+ active: true,
4782
+ skill: "ralplan",
4783
+ phase: "planning",
4784
+ });
4785
+ await writeJson(join(stateDir, "sessions", "sess-stop-skill-repeat", "ralplan-state.json"), {
4786
+ active: true,
4787
+ current_phase: "planning",
4788
+ });
4789
+
4790
+ await dispatchCodexNativeHook(
4791
+ {
4792
+ hook_event_name: "Stop",
4793
+ cwd,
4794
+ session_id: "sess-stop-skill-repeat",
4795
+ thread_id: "thread-stop-skill-repeat",
4796
+ turn_id: "turn-stop-skill-repeat-1",
4797
+ },
4798
+ { cwd },
4799
+ );
4800
+
4801
+ const repeated = await dispatchCodexNativeHook(
4802
+ {
4803
+ hook_event_name: "Stop",
4804
+ cwd,
4805
+ session_id: "sess-stop-skill-repeat",
4806
+ thread_id: "thread-stop-skill-repeat",
4807
+ turn_id: "turn-stop-skill-repeat-1",
4808
+ stop_hook_active: true,
4809
+ },
4810
+ { cwd },
4811
+ );
4812
+
4813
+ assert.equal(repeated.omxEventName, "stop");
4814
+ assert.deepEqual(repeated.outputJson, {
4815
+ decision: "block",
4816
+ reason:
4817
+ "OMX skill ralplan is still active (phase: planning); continue until the current ralplan workflow reaches a terminal state.",
4818
+ stopReason: "skill_ralplan_planning",
4819
+ systemMessage: "OMX skill ralplan is still active (phase: planning).",
4820
+ });
4821
+ } finally {
4822
+ await rm(cwd, { recursive: true, force: true });
4823
+ }
4824
+ });
4825
+
4826
+ it("does not block Stop from another session's stale root team state when no scoped team state exists", async () => {
4827
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-stale-root-team-"));
4828
+ try {
4829
+ const stateDir = join(cwd, ".omx", "state");
4830
+ await mkdir(join(stateDir, "sessions", "sess-current"), { recursive: true });
4831
+ await writeJson(join(stateDir, "session.json"), { session_id: "sess-current" });
4832
+ await writeJson(join(stateDir, "team-state.json"), {
4833
+ active: true,
4834
+ current_phase: "starting",
4835
+ team_name: "stale-root-team",
4836
+ session_id: "sess-other",
4837
+ });
4838
+ await writeJson(join(stateDir, "team", "stale-root-team", "phase.json"), {
4839
+ current_phase: "team-exec",
4840
+ max_fix_attempts: 3,
4841
+ current_fix_attempt: 0,
4842
+ transitions: [],
4843
+ updated_at: new Date().toISOString(),
4844
+ });
4845
+
4846
+ const result = await dispatchCodexNativeHook(
4847
+ {
4848
+ hook_event_name: "Stop",
4849
+ cwd,
4850
+ session_id: "sess-current",
4851
+ },
4852
+ { cwd },
4853
+ );
4854
+
4855
+ assert.equal(result.omxEventName, "stop");
4856
+ assert.equal(result.outputJson, null);
4857
+ } finally {
4858
+ await rm(cwd, { recursive: true, force: true });
4859
+ }
4860
+ });
4861
+
4862
+ it("does not block Stop from orphaned team mode state after cleanup removed canonical team artifacts", async () => {
4863
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-orphaned-team-state-"));
4864
+ try {
4865
+ const stateDir = join(cwd, ".omx", "state");
4866
+ await mkdir(join(stateDir, "sessions", "sess-current"), { recursive: true });
4867
+ await writeJson(join(stateDir, "session.json"), { session_id: "sess-current" });
4868
+ await writeJson(join(stateDir, "team-state.json"), {
4869
+ active: true,
4870
+ current_phase: "starting",
4871
+ team_name: "cleaned-team",
4872
+ session_id: "sess-current",
4873
+ });
4874
+
4875
+ const result = await dispatchCodexNativeHook(
4876
+ {
4877
+ hook_event_name: "Stop",
4878
+ cwd,
4879
+ session_id: "sess-current",
4880
+ },
4881
+ { cwd },
4882
+ );
4883
+
4884
+ assert.equal(result.omxEventName, "stop");
4885
+ assert.equal(result.outputJson, null);
4886
+ } finally {
4887
+ await rm(cwd, { recursive: true, force: true });
4888
+ }
4889
+ });
4890
+
4891
+ it("prefers the current session team state over a stale root team fallback during Stop", async () => {
4892
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-current-session-team-preferred-"));
4893
+ try {
4894
+ const stateDir = join(cwd, ".omx", "state");
4895
+ await mkdir(join(stateDir, "sessions", "sess-current"), { recursive: true });
4896
+ await writeJson(join(stateDir, "session.json"), { session_id: "sess-current" });
4897
+ await writeJson(join(stateDir, "sessions", "sess-current", "team-state.json"), {
4898
+ active: true,
4899
+ current_phase: "starting",
4900
+ team_name: "current-team",
4901
+ session_id: "sess-current",
4902
+ });
4903
+ await writeJson(join(stateDir, "team", "current-team", "phase.json"), {
4904
+ current_phase: "team-verify",
4905
+ max_fix_attempts: 3,
4906
+ current_fix_attempt: 1,
4907
+ transitions: [],
4908
+ updated_at: new Date().toISOString(),
4909
+ });
4910
+ await writeJson(join(stateDir, "team-state.json"), {
4911
+ active: true,
4912
+ current_phase: "starting",
4913
+ team_name: "stale-root-team",
4914
+ session_id: "sess-other",
4915
+ });
4916
+ await writeJson(join(stateDir, "team", "stale-root-team", "phase.json"), {
4917
+ current_phase: "team-exec",
4918
+ max_fix_attempts: 3,
4919
+ current_fix_attempt: 0,
4920
+ transitions: [],
4921
+ updated_at: new Date().toISOString(),
4922
+ });
4923
+
4924
+ const result = await dispatchCodexNativeHook(
4925
+ {
4926
+ hook_event_name: "Stop",
4927
+ cwd,
4928
+ session_id: "sess-current",
4929
+ },
4930
+ { cwd },
4931
+ );
4932
+
4933
+ assert.equal(result.omxEventName, "stop");
4934
+ assert.deepEqual(result.outputJson, {
4935
+ decision: "block",
4936
+ reason:
4937
+ `OMX team pipeline is still active (current-team) at phase team-verify; continue coordinating until the team reaches a terminal phase.${TEAM_STOP_COMMIT_GUIDANCE}`,
4938
+ stopReason: "team_team-verify",
4939
+ systemMessage: "OMX team pipeline is still active at phase team-verify.",
4940
+ });
4941
+ } finally {
4942
+ await rm(cwd, { recursive: true, force: true });
4943
+ }
4944
+ });
4945
+
4946
+ it("does not fall back to active root team state when the current scoped team state is inactive", async () => {
4947
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-inactive-scoped-team-"));
4948
+ try {
4949
+ const stateDir = join(cwd, ".omx", "state");
4950
+ await mkdir(join(stateDir, "sessions", "sess-current"), { recursive: true });
4951
+ await writeJson(join(stateDir, "session.json"), { session_id: "sess-current" });
4952
+ await writeJson(join(stateDir, "sessions", "sess-current", "team-state.json"), {
4953
+ active: false,
4954
+ current_phase: "complete",
4955
+ team_name: "scoped-finished-team",
4956
+ session_id: "sess-current",
4957
+ });
4958
+ await writeJson(join(stateDir, "team-state.json"), {
4959
+ active: true,
4960
+ current_phase: "starting",
4961
+ team_name: "root-fallback-team",
4962
+ session_id: "sess-current",
4963
+ });
4964
+ await writeJson(join(stateDir, "team", "root-fallback-team", "phase.json"), {
4965
+ current_phase: "team-exec",
4966
+ max_fix_attempts: 3,
4967
+ current_fix_attempt: 0,
4968
+ transitions: [],
4969
+ updated_at: new Date().toISOString(),
4970
+ });
4971
+
4972
+ const result = await dispatchCodexNativeHook(
4973
+ {
4974
+ hook_event_name: "Stop",
4975
+ cwd,
4976
+ session_id: "sess-current",
4977
+ },
4978
+ { cwd },
4979
+ );
4980
+
4981
+ assert.equal(result.omxEventName, "stop");
4982
+ assert.equal(result.outputJson, null);
4983
+ } finally {
4984
+ await rm(cwd, { recursive: true, force: true });
4985
+ }
4986
+ });
4987
+ });
4988
+
4989
+ // ---------------------------------------------------------------------------
4990
+ // Triage layer integration tests
4991
+ // ---------------------------------------------------------------------------
4992
+
4993
+ describe("codex native hook triage integration", () => {
4994
+ const priorCodexHome = process.env.CODEX_HOME;
4995
+
4996
+ beforeEach(() => {
4997
+ resetTriageConfigCache();
4998
+ });
4999
+
5000
+ afterEach(() => {
5001
+ if (typeof priorCodexHome === "string") process.env.CODEX_HOME = priorCodexHome;
5002
+ else delete process.env.CODEX_HOME;
5003
+ resetTriageConfigCache();
5004
+ });
5005
+
5006
+ // ── Group 1: Keyword bypass (triage must NOT run) ────────────────────────
5007
+
5008
+ it("does not inject triage advisory for $ralplan keyword prompts", async () => {
5009
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-keyword-ralplan-"));
5010
+ try {
5011
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
5012
+ const result = await dispatchCodexNativeHook(
5013
+ {
5014
+ hook_event_name: "UserPromptSubmit",
5015
+ cwd,
5016
+ session_id: "triage-kw-ralplan-1",
5017
+ thread_id: "thread-triage-kw-1",
5018
+ turn_id: "turn-triage-kw-1",
5019
+ prompt: "$ralplan implement issue #1307",
5020
+ },
5021
+ { cwd },
5022
+ );
5023
+
5024
+ const additionalContext = String(
5025
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
5026
+ );
5027
+ assert.doesNotMatch(additionalContext, /multi-step goal with no workflow keyword/);
5028
+ assert.doesNotMatch(additionalContext, /read-only\/question-shaped/);
5029
+ assert.doesNotMatch(additionalContext, /narrow edit-shaped/);
5030
+ assert.doesNotMatch(additionalContext, /visual\/style request/);
5031
+
5032
+ const stateFile = join(cwd, ".omx", "state", "sessions", "triage-kw-ralplan-1", "prompt-routing-state.json");
5033
+ assert.equal(existsSync(stateFile), false);
5034
+ } finally {
5035
+ await rm(cwd, { recursive: true, force: true });
5036
+ }
5037
+ });
5038
+
5039
+ it("does not inject triage advisory for autopilot keyword prompts", async () => {
5040
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-keyword-autopilot-"));
5041
+ try {
5042
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
5043
+ const result = await dispatchCodexNativeHook(
5044
+ {
5045
+ hook_event_name: "UserPromptSubmit",
5046
+ cwd,
5047
+ session_id: "triage-kw-autopilot-1",
5048
+ thread_id: "thread-triage-kw-ap-1",
5049
+ turn_id: "turn-triage-kw-ap-1",
5050
+ prompt: "$autopilot build this",
5051
+ },
5052
+ { cwd },
5053
+ );
5054
+
5055
+ const additionalContext = String(
5056
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
5057
+ );
5058
+ assert.doesNotMatch(additionalContext, /multi-step goal with no workflow keyword/);
5059
+ assert.doesNotMatch(additionalContext, /read-only\/question-shaped/);
5060
+ assert.doesNotMatch(additionalContext, /narrow edit-shaped/);
5061
+ assert.doesNotMatch(additionalContext, /visual\/style request/);
5062
+
5063
+ const stateFile = join(cwd, ".omx", "state", "sessions", "triage-kw-autopilot-1", "prompt-routing-state.json");
5064
+ assert.equal(existsSync(stateFile), false);
5065
+ } finally {
5066
+ await rm(cwd, { recursive: true, force: true });
5067
+ }
5068
+ });
5069
+
5070
+ // ── Group 2: HEAVY injection ─────────────────────────────────────────────
5071
+
5072
+ it("injects HEAVY advisory and writes prompt-routing-state for a multi-step goal prompt", async () => {
5073
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-heavy-"));
5074
+ try {
5075
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
5076
+ const result = await dispatchCodexNativeHook(
5077
+ {
5078
+ hook_event_name: "UserPromptSubmit",
5079
+ cwd,
5080
+ session_id: "triage-heavy-1",
5081
+ thread_id: "thread-triage-heavy-1",
5082
+ turn_id: "turn-triage-heavy-1",
5083
+ prompt: "add dark mode toggle to the settings page",
5084
+ },
5085
+ { cwd },
5086
+ );
5087
+
5088
+ const additionalContext = String(
5089
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
5090
+ );
5091
+ assert.match(additionalContext, /multi-step goal with no workflow keyword/);
5092
+ assert.match(additionalContext, /Prefer the existing autopilot-style workflow/);
5093
+
5094
+ // skill-active-state.json must NOT be written (triage is advisory only)
5095
+ assert.equal(existsSync(join(cwd, ".omx", "state", "skill-active-state.json")), false);
5096
+
5097
+ // prompt-routing-state.json must be written with lane=HEAVY
5098
+ const stateFile = join(cwd, ".omx", "state", "sessions", "triage-heavy-1", "prompt-routing-state.json");
5099
+ assert.equal(existsSync(stateFile), true);
5100
+ const state = JSON.parse(await readFile(stateFile, "utf-8")) as {
5101
+ version?: number;
5102
+ last_triage?: { lane?: string; destination?: string };
5103
+ suppress_followup?: boolean;
5104
+ };
5105
+ assert.equal(state.version, 1);
5106
+ assert.equal(state.last_triage?.lane, "HEAVY");
5107
+ assert.equal(state.last_triage?.destination, "autopilot");
5108
+ assert.equal(state.suppress_followup, true);
5109
+ } finally {
5110
+ await rm(cwd, { recursive: true, force: true });
5111
+ }
5112
+ });
5113
+
5114
+ // ── Group 3: LIGHT/explore ────────────────────────────────────────────────
5115
+
5116
+ it("injects LIGHT/explore advisory and writes state for a question-shaped prompt", async () => {
5117
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-light-explore-"));
5118
+ try {
5119
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
5120
+ const result = await dispatchCodexNativeHook(
5121
+ {
5122
+ hook_event_name: "UserPromptSubmit",
5123
+ cwd,
5124
+ session_id: "triage-explore-1",
5125
+ thread_id: "thread-triage-explore-1",
5126
+ turn_id: "turn-triage-explore-1",
5127
+ prompt: "explain this function",
5128
+ },
5129
+ { cwd },
5130
+ );
5131
+
5132
+ const additionalContext = String(
5133
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
5134
+ );
5135
+ assert.match(additionalContext, /read-only\/question-shaped/);
5136
+ assert.match(additionalContext, /Prefer the explore role surface/);
5137
+
5138
+ const stateFile = join(cwd, ".omx", "state", "sessions", "triage-explore-1", "prompt-routing-state.json");
5139
+ assert.equal(existsSync(stateFile), true);
5140
+ const state = JSON.parse(await readFile(stateFile, "utf-8")) as {
5141
+ last_triage?: { lane?: string; destination?: string };
5142
+ suppress_followup?: boolean;
5143
+ };
5144
+ assert.equal(state.last_triage?.lane, "LIGHT");
5145
+ assert.equal(state.last_triage?.destination, "explore");
5146
+ assert.equal(state.suppress_followup, true);
5147
+ } finally {
5148
+ await rm(cwd, { recursive: true, force: true });
5149
+ }
5150
+ });
5151
+
5152
+ // ── Group 4: LIGHT/executor ───────────────────────────────────────────────
5153
+
5154
+ it("injects LIGHT/executor advisory and writes state for a narrow edit-shaped prompt", async () => {
5155
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-light-executor-"));
5156
+ try {
5157
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
5158
+ const result = await dispatchCodexNativeHook(
5159
+ {
5160
+ hook_event_name: "UserPromptSubmit",
5161
+ cwd,
5162
+ session_id: "triage-executor-1",
5163
+ thread_id: "thread-triage-executor-1",
5164
+ turn_id: "turn-triage-executor-1",
5165
+ prompt: "fix typo in src/foo.ts",
5166
+ },
5167
+ { cwd },
5168
+ );
5169
+
5170
+ const additionalContext = String(
5171
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
5172
+ );
5173
+ assert.match(additionalContext, /narrow edit-shaped/);
5174
+ assert.match(additionalContext, /Prefer the executor role surface/);
5175
+
5176
+ const stateFile = join(cwd, ".omx", "state", "sessions", "triage-executor-1", "prompt-routing-state.json");
5177
+ assert.equal(existsSync(stateFile), true);
5178
+ const state = JSON.parse(await readFile(stateFile, "utf-8")) as {
5179
+ last_triage?: { lane?: string; destination?: string };
5180
+ };
5181
+ assert.equal(state.last_triage?.lane, "LIGHT");
5182
+ assert.equal(state.last_triage?.destination, "executor");
5183
+ } finally {
5184
+ await rm(cwd, { recursive: true, force: true });
5185
+ }
5186
+ });
5187
+
5188
+ // ── Group 5: LIGHT/designer ───────────────────────────────────────────────
5189
+
5190
+ it("injects LIGHT/designer advisory and writes state for a visual/style prompt", async () => {
5191
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-light-designer-"));
5192
+ try {
5193
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
5194
+ const result = await dispatchCodexNativeHook(
5195
+ {
5196
+ hook_event_name: "UserPromptSubmit",
5197
+ cwd,
5198
+ session_id: "triage-designer-1",
5199
+ thread_id: "thread-triage-designer-1",
5200
+ turn_id: "turn-triage-designer-1",
5201
+ prompt: "make the button blue",
5202
+ },
5203
+ { cwd },
5204
+ );
5205
+
5206
+ const additionalContext = String(
5207
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
5208
+ );
5209
+ assert.match(additionalContext, /visual\/style request/);
5210
+ assert.match(additionalContext, /Prefer the designer role surface/);
5211
+
5212
+ const stateFile = join(cwd, ".omx", "state", "sessions", "triage-designer-1", "prompt-routing-state.json");
5213
+ assert.equal(existsSync(stateFile), true);
5214
+ const state = JSON.parse(await readFile(stateFile, "utf-8")) as {
5215
+ last_triage?: { lane?: string; destination?: string };
5216
+ };
5217
+ assert.equal(state.last_triage?.lane, "LIGHT");
5218
+ assert.equal(state.last_triage?.destination, "designer");
5219
+ } finally {
5220
+ await rm(cwd, { recursive: true, force: true });
5221
+ }
5222
+ });
5223
+
5224
+ // ── Group 6: PASS (no triage injection, no state) ────────────────────────
5225
+
5226
+ it("produces no triage advisory and no state for trivial greeting prompts", async () => {
5227
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-pass-hello-"));
5228
+ try {
5229
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
5230
+ const result = await dispatchCodexNativeHook(
5231
+ {
5232
+ hook_event_name: "UserPromptSubmit",
5233
+ cwd,
5234
+ session_id: "triage-pass-hello-1",
5235
+ thread_id: "thread-triage-pass-1",
5236
+ turn_id: "turn-triage-pass-1",
5237
+ prompt: "hello",
5238
+ },
5239
+ { cwd },
5240
+ );
5241
+
5242
+ const additionalContext = String(
5243
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
5244
+ );
5245
+ assert.doesNotMatch(additionalContext, /multi-step goal with no workflow keyword/);
5246
+ assert.doesNotMatch(additionalContext, /read-only\/question-shaped/);
5247
+ assert.doesNotMatch(additionalContext, /narrow edit-shaped/);
5248
+ assert.doesNotMatch(additionalContext, /visual\/style request/);
5249
+
5250
+ const stateFile = join(cwd, ".omx", "state", "sessions", "triage-pass-hello-1", "prompt-routing-state.json");
5251
+ assert.equal(existsSync(stateFile), false);
5252
+ } finally {
5253
+ await rm(cwd, { recursive: true, force: true });
5254
+ }
5255
+ });
5256
+
5257
+ it("produces no triage advisory and no state for ambiguous short prompts", async () => {
5258
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-pass-short-"));
5259
+ try {
5260
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
5261
+ const result = await dispatchCodexNativeHook(
5262
+ {
5263
+ hook_event_name: "UserPromptSubmit",
5264
+ cwd,
5265
+ session_id: "triage-pass-short-1",
5266
+ thread_id: "thread-triage-pass-short-1",
5267
+ turn_id: "turn-triage-pass-short-1",
5268
+ prompt: "fix the thing",
5269
+ },
5270
+ { cwd },
5271
+ );
5272
+
5273
+ const additionalContext = String(
5274
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
5275
+ );
5276
+ assert.doesNotMatch(additionalContext, /multi-step goal with no workflow keyword/);
5277
+ assert.doesNotMatch(additionalContext, /read-only\/question-shaped/);
5278
+ assert.doesNotMatch(additionalContext, /narrow edit-shaped/);
5279
+ assert.doesNotMatch(additionalContext, /visual\/style request/);
5280
+
5281
+ const stateFile = join(cwd, ".omx", "state", "sessions", "triage-pass-short-1", "prompt-routing-state.json");
5282
+ assert.equal(existsSync(stateFile), false);
5283
+ } finally {
5284
+ await rm(cwd, { recursive: true, force: true });
5285
+ }
5286
+ });
5287
+
5288
+ // ── Group 7: Turn-2 suppression (same session across two invocations) ────
5289
+
5290
+ it("suppresses HEAVY triage re-injection on a short follow-up in the same session", async () => {
5291
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-suppress-heavy-"));
5292
+ const sessionId = "triage-suppress-heavy-1";
5293
+ try {
5294
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
5295
+
5296
+ // Turn 1: HEAVY fires
5297
+ const turn1 = await dispatchCodexNativeHook(
5298
+ {
5299
+ hook_event_name: "UserPromptSubmit",
5300
+ cwd,
5301
+ session_id: sessionId,
5302
+ thread_id: "thread-suppress-heavy-1",
5303
+ turn_id: "turn-suppress-heavy-1",
5304
+ prompt: "add dark mode toggle to the settings page",
5305
+ },
5306
+ { cwd },
5307
+ );
5308
+ const ctx1 = String(
5309
+ (turn1.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
5310
+ );
5311
+ assert.match(ctx1, /multi-step goal with no workflow keyword/);
5312
+
5313
+ // Turn 2: short follow-up — triage suppressed
5314
+ const turn2 = await dispatchCodexNativeHook(
5315
+ {
5316
+ hook_event_name: "UserPromptSubmit",
5317
+ cwd,
5318
+ session_id: sessionId,
5319
+ thread_id: "thread-suppress-heavy-1",
5320
+ turn_id: "turn-suppress-heavy-2",
5321
+ prompt: "yes, settings page",
5322
+ },
5323
+ { cwd },
5324
+ );
5325
+ const ctx2 = String(
5326
+ (turn2.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
5327
+ );
5328
+ assert.doesNotMatch(ctx2, /multi-step goal/);
5329
+ } finally {
5330
+ await rm(cwd, { recursive: true, force: true });
5331
+ }
5332
+ });
5333
+
5334
+ it("suppresses LIGHT/explore triage re-injection on a short follow-up in the same session", async () => {
5335
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-suppress-explore-"));
5336
+ const sessionId = "triage-suppress-explore-1";
5337
+ try {
5338
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
5339
+
5340
+ // Turn 1: LIGHT/explore fires
5341
+ await dispatchCodexNativeHook(
5342
+ {
5343
+ hook_event_name: "UserPromptSubmit",
5344
+ cwd,
5345
+ session_id: sessionId,
5346
+ thread_id: "thread-suppress-explore-1",
5347
+ turn_id: "turn-suppress-explore-1",
5348
+ prompt: "explain this function",
5349
+ },
5350
+ { cwd },
5351
+ );
5352
+
5353
+ // Turn 2: short follow-up — no duplicate LIGHT injection
5354
+ const turn2 = await dispatchCodexNativeHook(
5355
+ {
5356
+ hook_event_name: "UserPromptSubmit",
5357
+ cwd,
5358
+ session_id: sessionId,
5359
+ thread_id: "thread-suppress-explore-1",
5360
+ turn_id: "turn-suppress-explore-2",
5361
+ prompt: "the auth helper",
5362
+ },
5363
+ { cwd },
5364
+ );
5365
+ const ctx2 = String(
5366
+ (turn2.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
5367
+ );
5368
+ assert.doesNotMatch(ctx2, /read-only\/question-shaped/);
5369
+ } finally {
5370
+ await rm(cwd, { recursive: true, force: true });
5371
+ }
5372
+ });
5373
+
5374
+ // ── Group 8: First-turn PASS does NOT block later triage ─────────────────
5375
+
5376
+ it("still applies triage on turn 2 when turn 1 was a PASS with no state written", async () => {
5377
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-pass-then-light-"));
5378
+ const sessionId = "triage-pass-then-light-1";
5379
+ try {
5380
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
5381
+
5382
+ // Turn 1: PASS — no state written
5383
+ await dispatchCodexNativeHook(
5384
+ {
5385
+ hook_event_name: "UserPromptSubmit",
5386
+ cwd,
5387
+ session_id: sessionId,
5388
+ thread_id: "thread-pass-then-light-1",
5389
+ turn_id: "turn-pass-then-light-1",
5390
+ prompt: "hello",
5391
+ },
5392
+ { cwd },
5393
+ );
5394
+ assert.equal(
5395
+ existsSync(join(cwd, ".omx", "state", "sessions", sessionId, "prompt-routing-state.json")),
5396
+ false,
5397
+ );
5398
+
5399
+ // Turn 2: LIGHT/executor should fire normally
5400
+ const turn2 = await dispatchCodexNativeHook(
5401
+ {
5402
+ hook_event_name: "UserPromptSubmit",
5403
+ cwd,
5404
+ session_id: sessionId,
5405
+ thread_id: "thread-pass-then-light-1",
5406
+ turn_id: "turn-pass-then-light-2",
5407
+ prompt: "fix typo in src/foo.ts",
5408
+ },
5409
+ { cwd },
5410
+ );
5411
+ const ctx2 = String(
5412
+ (turn2.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
5413
+ );
5414
+ assert.match(ctx2, /narrow edit-shaped/);
5415
+ } finally {
5416
+ await rm(cwd, { recursive: true, force: true });
5417
+ }
5418
+ });
5419
+
5420
+ // ── Group 9: Opt-out forces PASS ─────────────────────────────────────────
5421
+
5422
+ it("produces no triage advisory when prompt contains 'just chat' opt-out", async () => {
5423
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-optout-chat-"));
5424
+ try {
5425
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
5426
+ const result = await dispatchCodexNativeHook(
3684
5427
  {
3685
- hook_event_name: "Stop",
5428
+ hook_event_name: "UserPromptSubmit",
3686
5429
  cwd,
3687
- session_id: "omx-canonical",
3688
- thread_id: "thread-stop-team-drift",
3689
- turn_id: "turn-stop-team-drift-2",
3690
- stop_hook_active: true,
5430
+ session_id: "triage-optout-chat-1",
5431
+ thread_id: "thread-optout-chat-1",
5432
+ turn_id: "turn-optout-chat-1",
5433
+ prompt: "add dark mode toggle to the settings page, but just chat about it",
3691
5434
  },
3692
5435
  { cwd },
3693
5436
  );
3694
5437
 
3695
- assert.equal(fresh.omxEventName, "stop");
3696
- assert.deepEqual(fresh.outputJson, {
3697
- decision: "block",
3698
- reason:
3699
- `OMX team pipeline is still active (current-team) at phase team-verify; continue coordinating until the team reaches a terminal phase.${TEAM_STOP_COMMIT_GUIDANCE}`,
3700
- stopReason: "team_team-verify",
3701
- systemMessage: "OMX team pipeline is still active at phase team-verify.",
3702
- });
5438
+ const additionalContext = String(
5439
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
5440
+ );
5441
+ assert.doesNotMatch(additionalContext, /multi-step goal with no workflow keyword/);
5442
+ assert.doesNotMatch(additionalContext, /read-only\/question-shaped/);
3703
5443
 
3704
- const persisted = JSON.parse(
3705
- await readFile(join(stateDir, "native-stop-state.json"), "utf-8"),
3706
- ) as { sessions?: Record<string, unknown> };
3707
- assert.deepEqual(Object.keys(persisted.sessions ?? {}), ["omx-canonical"]);
5444
+ const stateFile = join(cwd, ".omx", "state", "sessions", "triage-optout-chat-1", "prompt-routing-state.json");
5445
+ assert.equal(existsSync(stateFile), false);
3708
5446
  } finally {
3709
5447
  await rm(cwd, { recursive: true, force: true });
3710
5448
  }
3711
5449
  });
3712
5450
 
3713
- it("re-blocks active execution modes on repeated Stop hooks", async () => {
3714
- const cases = [
3715
- {
3716
- mode: "autopilot",
3717
- phase: "execution",
3718
- reason:
3719
- "OMX autopilot is still active (phase: execution); continue the task and gather fresh verification evidence before stopping.",
3720
- },
3721
- {
3722
- mode: "ultrawork",
3723
- phase: "executing",
3724
- reason:
3725
- "OMX ultrawork is still active (phase: executing); continue the task and gather fresh verification evidence before stopping.",
3726
- },
3727
- {
3728
- mode: "ultraqa",
3729
- phase: "diagnose",
3730
- reason:
3731
- "OMX ultraqa is still active (phase: diagnose); continue the task and gather fresh verification evidence before stopping.",
3732
- },
3733
- ] as const;
3734
-
3735
- for (const testCase of cases) {
3736
- const cwd = await mkdtemp(join(tmpdir(), `omx-native-hook-stop-${testCase.mode}-repeat-`));
3737
- try {
3738
- const stateDir = join(cwd, ".omx", "state");
3739
- await mkdir(stateDir, { recursive: true });
3740
- await writeJson(join(stateDir, `${testCase.mode}-state.json`), {
3741
- active: true,
3742
- current_phase: testCase.phase,
3743
- });
3744
-
3745
- await dispatchCodexNativeHook(
3746
- {
3747
- hook_event_name: "Stop",
3748
- cwd,
3749
- session_id: `sess-stop-${testCase.mode}-repeat`,
3750
- thread_id: `thread-stop-${testCase.mode}-repeat`,
3751
- turn_id: `turn-stop-${testCase.mode}-repeat-1`,
3752
- },
3753
- { cwd },
3754
- );
5451
+ it("produces no triage advisory when prompt contains 'no workflow' opt-out", async () => {
5452
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-optout-noworkflow-"));
5453
+ try {
5454
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
5455
+ const result = await dispatchCodexNativeHook(
5456
+ {
5457
+ hook_event_name: "UserPromptSubmit",
5458
+ cwd,
5459
+ session_id: "triage-optout-noworkflow-1",
5460
+ thread_id: "thread-optout-noworkflow-1",
5461
+ turn_id: "turn-optout-noworkflow-1",
5462
+ prompt: "make the button blue, no workflow",
5463
+ },
5464
+ { cwd },
5465
+ );
3755
5466
 
3756
- const repeated = await dispatchCodexNativeHook(
3757
- {
3758
- hook_event_name: "Stop",
3759
- cwd,
3760
- session_id: `sess-stop-${testCase.mode}-repeat`,
3761
- thread_id: `thread-stop-${testCase.mode}-repeat`,
3762
- turn_id: `turn-stop-${testCase.mode}-repeat-1`,
3763
- stop_hook_active: true,
3764
- },
3765
- { cwd },
3766
- );
5467
+ const additionalContext = String(
5468
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
5469
+ );
5470
+ assert.doesNotMatch(additionalContext, /visual\/style request/);
3767
5471
 
3768
- assert.equal(repeated.omxEventName, "stop");
3769
- assert.deepEqual(repeated.outputJson, {
3770
- decision: "block",
3771
- reason: testCase.reason,
3772
- stopReason: `${testCase.mode}_${testCase.phase}`,
3773
- systemMessage: `OMX ${testCase.mode} is still active (phase: ${testCase.phase}).`,
3774
- });
3775
- } finally {
3776
- await rm(cwd, { recursive: true, force: true });
3777
- }
5472
+ const stateFile = join(cwd, ".omx", "state", "sessions", "triage-optout-noworkflow-1", "prompt-routing-state.json");
5473
+ assert.equal(existsSync(stateFile), false);
5474
+ } finally {
5475
+ await rm(cwd, { recursive: true, force: true });
3778
5476
  }
3779
5477
  });
3780
5478
 
3781
- it("re-blocks active ralplan skill state on repeated Stop hooks", async () => {
3782
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-skill-repeat-"));
5479
+ // ── Group 10: Keyword on follow-up turn wins cleanly ─────────────────────
5480
+
5481
+ it("keyword on turn 2 suppresses triage and writes no triage state", async () => {
5482
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-kw-followup-"));
5483
+ const sessionId = "triage-kw-followup-1";
3783
5484
  try {
3784
- const stateDir = join(cwd, ".omx", "state");
3785
- await mkdir(join(stateDir, "sessions", "sess-stop-skill-repeat"), { recursive: true });
3786
- await writeJson(join(stateDir, "session.json"), { session_id: "sess-stop-skill-repeat" });
3787
- await writeJson(join(stateDir, "sessions", "sess-stop-skill-repeat", "skill-active-state.json"), {
3788
- active: true,
3789
- skill: "ralplan",
3790
- phase: "planning",
3791
- });
3792
- await writeJson(join(stateDir, "sessions", "sess-stop-skill-repeat", "ralplan-state.json"), {
3793
- active: true,
3794
- current_phase: "planning",
3795
- });
5485
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
3796
5486
 
5487
+ // Turn 1: neutral prompt — triage may or may not fire, doesn't matter
3797
5488
  await dispatchCodexNativeHook(
3798
5489
  {
3799
- hook_event_name: "Stop",
5490
+ hook_event_name: "UserPromptSubmit",
3800
5491
  cwd,
3801
- session_id: "sess-stop-skill-repeat",
3802
- thread_id: "thread-stop-skill-repeat",
3803
- turn_id: "turn-stop-skill-repeat-1",
5492
+ session_id: sessionId,
5493
+ thread_id: "thread-kw-followup-1",
5494
+ turn_id: "turn-kw-followup-1",
5495
+ prompt: "hello",
3804
5496
  },
3805
5497
  { cwd },
3806
5498
  );
3807
5499
 
3808
- const repeated = await dispatchCodexNativeHook(
5500
+ // Turn 2: keyword prompt — keyword fast-path runs, triage does NOT add extra advisory
5501
+ const turn2 = await dispatchCodexNativeHook(
3809
5502
  {
3810
- hook_event_name: "Stop",
5503
+ hook_event_name: "UserPromptSubmit",
3811
5504
  cwd,
3812
- session_id: "sess-stop-skill-repeat",
3813
- thread_id: "thread-stop-skill-repeat",
3814
- turn_id: "turn-stop-skill-repeat-1",
3815
- stop_hook_active: true,
5505
+ session_id: sessionId,
5506
+ thread_id: "thread-kw-followup-1",
5507
+ turn_id: "turn-kw-followup-2",
5508
+ prompt: "$ralph continue",
3816
5509
  },
3817
5510
  { cwd },
3818
5511
  );
3819
5512
 
3820
- assert.equal(repeated.omxEventName, "stop");
3821
- assert.deepEqual(repeated.outputJson, {
3822
- decision: "block",
3823
- reason:
3824
- "OMX skill ralplan is still active (phase: planning); continue until the current ralplan workflow reaches a terminal state.",
3825
- stopReason: "skill_ralplan_planning",
3826
- systemMessage: "OMX skill ralplan is still active (phase: planning).",
3827
- });
5513
+ assert.equal(turn2.skillState?.skill, "ralph");
5514
+
5515
+ const ctx2 = String(
5516
+ (turn2.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
5517
+ );
5518
+ assert.doesNotMatch(ctx2, /multi-step goal with no workflow keyword/);
5519
+ assert.doesNotMatch(ctx2, /read-only\/question-shaped/);
5520
+ assert.doesNotMatch(ctx2, /narrow edit-shaped/);
5521
+ assert.doesNotMatch(ctx2, /visual\/style request/);
5522
+
5523
+ // No triage state written on the keyword turn
5524
+ const triageState = join(cwd, ".omx", "state", "sessions", sessionId, "prompt-routing-state.json");
5525
+ // The state from turn 1 (if any) must not have been created either (hello = PASS)
5526
+ assert.equal(existsSync(triageState), false);
3828
5527
  } finally {
3829
5528
  await rm(cwd, { recursive: true, force: true });
3830
5529
  }
3831
5530
  });
3832
5531
 
3833
- it("does not block Stop from another session's stale root team state when no scoped team state exists", async () => {
3834
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-stale-root-team-"));
5532
+ // ── Group 11: Config-disabled path ───────────────────────────────────────
5533
+
5534
+ it("produces no triage advisory and no state when triage is disabled in config", async () => {
5535
+ const tmpHome = await mkdtemp(join(tmpdir(), "omx-triage-config-disabled-home-"));
5536
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-config-disabled-cwd-"));
3835
5537
  try {
3836
- const stateDir = join(cwd, ".omx", "state");
3837
- await mkdir(join(stateDir, "sessions", "sess-current"), { recursive: true });
3838
- await writeJson(join(stateDir, "session.json"), { session_id: "sess-current" });
3839
- await writeJson(join(stateDir, "team-state.json"), {
3840
- active: true,
3841
- current_phase: "starting",
3842
- team_name: "stale-root-team",
3843
- session_id: "sess-other",
3844
- });
3845
- await writeJson(join(stateDir, "team", "stale-root-team", "phase.json"), {
3846
- current_phase: "team-exec",
3847
- max_fix_attempts: 3,
3848
- current_fix_attempt: 0,
3849
- transitions: [],
3850
- updated_at: new Date().toISOString(),
5538
+ // Write a .omx-config.json in the fake CODEX_HOME that disables triage
5539
+ await writeJson(join(tmpHome, ".omx-config.json"), {
5540
+ promptRouting: { triage: { enabled: false } },
3851
5541
  });
5542
+ process.env.CODEX_HOME = tmpHome;
5543
+ resetTriageConfigCache();
3852
5544
 
5545
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
3853
5546
  const result = await dispatchCodexNativeHook(
3854
5547
  {
3855
- hook_event_name: "Stop",
5548
+ hook_event_name: "UserPromptSubmit",
3856
5549
  cwd,
3857
- session_id: "sess-current",
5550
+ session_id: "triage-disabled-1",
5551
+ thread_id: "thread-triage-disabled-1",
5552
+ turn_id: "turn-triage-disabled-1",
5553
+ prompt: "add dark mode toggle to the settings page",
3858
5554
  },
3859
5555
  { cwd },
3860
5556
  );
3861
5557
 
3862
- assert.equal(result.omxEventName, "stop");
3863
- assert.equal(result.outputJson, null);
5558
+ const additionalContext = String(
5559
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
5560
+ );
5561
+ assert.doesNotMatch(additionalContext, /multi-step goal with no workflow keyword/);
5562
+
5563
+ const stateFile = join(cwd, ".omx", "state", "sessions", "triage-disabled-1", "prompt-routing-state.json");
5564
+ assert.equal(existsSync(stateFile), false);
3864
5565
  } finally {
5566
+ await rm(tmpHome, { recursive: true, force: true });
3865
5567
  await rm(cwd, { recursive: true, force: true });
3866
5568
  }
3867
5569
  });
3868
5570
 
3869
- it("does not block Stop from orphaned team mode state after cleanup removed canonical team artifacts", async () => {
3870
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-orphaned-team-state-"));
5571
+ it("keeps triage default-enabled when config omits promptRouting.triage.enabled", async () => {
5572
+ const tmpHome = await mkdtemp(join(tmpdir(), "omx-triage-config-omitted-home-"));
5573
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-config-omitted-cwd-"));
5574
+ const previousCodexHome = process.env.CODEX_HOME;
3871
5575
  try {
3872
- const stateDir = join(cwd, ".omx", "state");
3873
- await mkdir(join(stateDir, "sessions", "sess-current"), { recursive: true });
3874
- await writeJson(join(stateDir, "session.json"), { session_id: "sess-current" });
3875
- await writeJson(join(stateDir, "team-state.json"), {
3876
- active: true,
3877
- current_phase: "starting",
3878
- team_name: "cleaned-team",
3879
- session_id: "sess-current",
5576
+ await writeJson(join(tmpHome, ".omx-config.json"), {
5577
+ promptRouting: {},
3880
5578
  });
5579
+ process.env.CODEX_HOME = tmpHome;
5580
+ resetTriageConfigCache();
3881
5581
 
5582
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
3882
5583
  const result = await dispatchCodexNativeHook(
3883
5584
  {
3884
- hook_event_name: "Stop",
5585
+ hook_event_name: "UserPromptSubmit",
3885
5586
  cwd,
3886
- session_id: "sess-current",
5587
+ session_id: "triage-defaulted-1",
5588
+ thread_id: "thread-triage-defaulted-1",
5589
+ turn_id: "turn-triage-defaulted-1",
5590
+ prompt: "add dark mode toggle to the settings page",
3887
5591
  },
3888
5592
  { cwd },
3889
5593
  );
3890
5594
 
3891
- assert.equal(result.omxEventName, "stop");
3892
- assert.equal(result.outputJson, null);
5595
+ const additionalContext = String(
5596
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
5597
+ );
5598
+ assert.match(additionalContext, /multi-step goal with no workflow keyword/);
5599
+
5600
+ const stateFile = join(cwd, ".omx", "state", "sessions", "triage-defaulted-1", "prompt-routing-state.json");
5601
+ assert.equal(existsSync(stateFile), true);
3893
5602
  } finally {
5603
+ if (typeof previousCodexHome === "string") process.env.CODEX_HOME = previousCodexHome;
5604
+ else delete process.env.CODEX_HOME;
5605
+ resetTriageConfigCache();
5606
+ await rm(tmpHome, { recursive: true, force: true });
3894
5607
  await rm(cwd, { recursive: true, force: true });
3895
5608
  }
3896
5609
  });
3897
5610
 
3898
- it("prefers the current session team state over a stale root team fallback during Stop", async () => {
3899
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-current-session-team-preferred-"));
5611
+ it("does not suppress a short anchored follow-up that is a new request", async () => {
5612
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-short-new-request-"));
5613
+ const sessionId = "triage-short-new-request-1";
3900
5614
  try {
3901
- const stateDir = join(cwd, ".omx", "state");
3902
- await mkdir(join(stateDir, "sessions", "sess-current"), { recursive: true });
3903
- await writeJson(join(stateDir, "session.json"), { session_id: "sess-current" });
3904
- await writeJson(join(stateDir, "sessions", "sess-current", "team-state.json"), {
3905
- active: true,
3906
- current_phase: "starting",
3907
- team_name: "current-team",
3908
- session_id: "sess-current",
3909
- });
3910
- await writeJson(join(stateDir, "team", "current-team", "phase.json"), {
3911
- current_phase: "team-verify",
3912
- max_fix_attempts: 3,
3913
- current_fix_attempt: 1,
3914
- transitions: [],
3915
- updated_at: new Date().toISOString(),
3916
- });
3917
- await writeJson(join(stateDir, "team-state.json"), {
3918
- active: true,
3919
- current_phase: "starting",
3920
- team_name: "stale-root-team",
3921
- session_id: "sess-other",
3922
- });
3923
- await writeJson(join(stateDir, "team", "stale-root-team", "phase.json"), {
3924
- current_phase: "team-exec",
3925
- max_fix_attempts: 3,
3926
- current_fix_attempt: 0,
3927
- transitions: [],
3928
- updated_at: new Date().toISOString(),
3929
- });
5615
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
3930
5616
 
3931
- const result = await dispatchCodexNativeHook(
5617
+ await dispatchCodexNativeHook(
3932
5618
  {
3933
- hook_event_name: "Stop",
5619
+ hook_event_name: "UserPromptSubmit",
3934
5620
  cwd,
3935
- session_id: "sess-current",
5621
+ session_id: sessionId,
5622
+ thread_id: "thread-short-new-request-1",
5623
+ turn_id: "turn-short-new-request-1",
5624
+ prompt: "add dark mode toggle to the settings page",
3936
5625
  },
3937
5626
  { cwd },
3938
5627
  );
3939
5628
 
3940
- assert.equal(result.omxEventName, "stop");
3941
- assert.deepEqual(result.outputJson, {
3942
- decision: "block",
3943
- reason:
3944
- `OMX team pipeline is still active (current-team) at phase team-verify; continue coordinating until the team reaches a terminal phase.${TEAM_STOP_COMMIT_GUIDANCE}`,
3945
- stopReason: "team_team-verify",
3946
- systemMessage: "OMX team pipeline is still active at phase team-verify.",
3947
- });
5629
+ const turn2 = await dispatchCodexNativeHook(
5630
+ {
5631
+ hook_event_name: "UserPromptSubmit",
5632
+ cwd,
5633
+ session_id: sessionId,
5634
+ thread_id: "thread-short-new-request-1",
5635
+ turn_id: "turn-short-new-request-2",
5636
+ prompt: "fix typo in src/foo.ts",
5637
+ },
5638
+ { cwd },
5639
+ );
5640
+
5641
+ const ctx2 = String(
5642
+ (turn2.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
5643
+ );
5644
+ assert.match(ctx2, /narrow edit-shaped/);
3948
5645
  } finally {
3949
5646
  await rm(cwd, { recursive: true, force: true });
3950
5647
  }
3951
5648
  });
3952
5649
 
3953
- it("does not fall back to active root team state when the current scoped team state is inactive", async () => {
3954
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-inactive-scoped-team-"));
5650
+ it("skips triage state persistence for malformed explicit session ids without writing root state", async () => {
5651
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-invalid-session-"));
3955
5652
  try {
3956
- const stateDir = join(cwd, ".omx", "state");
3957
- await mkdir(join(stateDir, "sessions", "sess-current"), { recursive: true });
3958
- await writeJson(join(stateDir, "session.json"), { session_id: "sess-current" });
3959
- await writeJson(join(stateDir, "sessions", "sess-current", "team-state.json"), {
3960
- active: false,
3961
- current_phase: "complete",
3962
- team_name: "scoped-finished-team",
3963
- session_id: "sess-current",
3964
- });
3965
- await writeJson(join(stateDir, "team-state.json"), {
3966
- active: true,
3967
- current_phase: "starting",
3968
- team_name: "root-fallback-team",
3969
- session_id: "sess-current",
3970
- });
3971
- await writeJson(join(stateDir, "team", "root-fallback-team", "phase.json"), {
3972
- current_phase: "team-exec",
3973
- max_fix_attempts: 3,
3974
- current_fix_attempt: 0,
3975
- transitions: [],
3976
- updated_at: new Date().toISOString(),
3977
- });
3978
-
5653
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
3979
5654
  const result = await dispatchCodexNativeHook(
3980
5655
  {
3981
- hook_event_name: "Stop",
5656
+ hook_event_name: "UserPromptSubmit",
3982
5657
  cwd,
3983
- session_id: "sess-current",
5658
+ session_id: "bad/session",
5659
+ thread_id: "thread-triage-invalid-session-1",
5660
+ turn_id: "turn-triage-invalid-session-1",
5661
+ prompt: "add dark mode toggle to the settings page",
3984
5662
  },
3985
5663
  { cwd },
3986
5664
  );
3987
5665
 
3988
- assert.equal(result.omxEventName, "stop");
3989
- assert.equal(result.outputJson, null);
5666
+ const additionalContext = String(
5667
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
5668
+ );
5669
+ assert.match(additionalContext, /multi-step goal with no workflow keyword/);
5670
+ assert.equal(existsSync(join(cwd, ".omx", "state", "prompt-routing-state.json")), false);
3990
5671
  } finally {
3991
5672
  await rm(cwd, { recursive: true, force: true });
3992
5673
  }