oh-my-codex 0.14.4 → 0.15.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 (614) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/Cargo.lock +6 -6
  3. package/Cargo.toml +2 -1
  4. package/README.md +40 -7
  5. package/crates/omx-explore/Cargo.toml +1 -0
  6. package/crates/omx-explore/src/main.rs +246 -24
  7. package/crates/omx-mux/Cargo.toml +1 -0
  8. package/crates/omx-runtime/Cargo.toml +1 -0
  9. package/crates/omx-runtime-core/Cargo.toml +1 -0
  10. package/crates/omx-runtime-core/src/dispatch.rs +2 -1
  11. package/crates/omx-runtime-core/src/engine.rs +2 -2
  12. package/crates/omx-runtime-core/src/mailbox.rs +2 -1
  13. package/crates/omx-sparkshell/Cargo.toml +1 -0
  14. package/crates/omx-sparkshell/src/codex_bridge.rs +1 -0
  15. package/crates/omx-sparkshell/src/prompt.rs +1 -0
  16. package/crates/omx-sparkshell/src/threshold.rs +1 -0
  17. package/dist/agents/__tests__/native-config.test.js +98 -4
  18. package/dist/agents/__tests__/native-config.test.js.map +1 -1
  19. package/dist/agents/native-config.d.ts +4 -0
  20. package/dist/agents/native-config.d.ts.map +1 -1
  21. package/dist/agents/native-config.js +19 -3
  22. package/dist/agents/native-config.js.map +1 -1
  23. package/dist/agents/policy.d.ts +10 -0
  24. package/dist/agents/policy.d.ts.map +1 -0
  25. package/dist/agents/policy.js +61 -0
  26. package/dist/agents/policy.js.map +1 -0
  27. package/dist/catalog/__tests__/generator.test.js +2 -0
  28. package/dist/catalog/__tests__/generator.test.js.map +1 -1
  29. package/dist/catalog/__tests__/plugin-bundle-ssot.test.d.ts +2 -0
  30. package/dist/catalog/__tests__/plugin-bundle-ssot.test.d.ts.map +1 -0
  31. package/dist/catalog/__tests__/plugin-bundle-ssot.test.js +72 -0
  32. package/dist/catalog/__tests__/plugin-bundle-ssot.test.js.map +1 -0
  33. package/dist/catalog/installable.d.ts +5 -0
  34. package/dist/catalog/installable.d.ts.map +1 -0
  35. package/dist/catalog/installable.js +13 -0
  36. package/dist/catalog/installable.js.map +1 -0
  37. package/dist/catalog/skill-mirror.d.ts +20 -0
  38. package/dist/catalog/skill-mirror.d.ts.map +1 -0
  39. package/dist/catalog/skill-mirror.js +104 -0
  40. package/dist/catalog/skill-mirror.js.map +1 -0
  41. package/dist/cli/__tests__/ask.test.js +4 -1
  42. package/dist/cli/__tests__/ask.test.js.map +1 -1
  43. package/dist/cli/__tests__/codex-plugin-layout.test.d.ts +2 -0
  44. package/dist/cli/__tests__/codex-plugin-layout.test.d.ts.map +1 -0
  45. package/dist/cli/__tests__/codex-plugin-layout.test.js +210 -0
  46. package/dist/cli/__tests__/codex-plugin-layout.test.js.map +1 -0
  47. package/dist/cli/__tests__/doctor-team.test.js +46 -1
  48. package/dist/cli/__tests__/doctor-team.test.js.map +1 -1
  49. package/dist/cli/__tests__/doctor-warning-copy.test.js +225 -111
  50. package/dist/cli/__tests__/doctor-warning-copy.test.js.map +1 -1
  51. package/dist/cli/__tests__/exec.test.js +96 -1
  52. package/dist/cli/__tests__/exec.test.js.map +1 -1
  53. package/dist/cli/__tests__/explore.test.js +26 -9
  54. package/dist/cli/__tests__/explore.test.js.map +1 -1
  55. package/dist/cli/__tests__/index.test.js +301 -7
  56. package/dist/cli/__tests__/index.test.js.map +1 -1
  57. package/dist/cli/__tests__/launch-fallback.test.js +358 -4
  58. package/dist/cli/__tests__/launch-fallback.test.js.map +1 -1
  59. package/dist/cli/__tests__/list.test.d.ts +2 -0
  60. package/dist/cli/__tests__/list.test.d.ts.map +1 -0
  61. package/dist/cli/__tests__/list.test.js +38 -0
  62. package/dist/cli/__tests__/list.test.js.map +1 -0
  63. package/dist/cli/__tests__/mcp-parity.test.js +86 -0
  64. package/dist/cli/__tests__/mcp-parity.test.js.map +1 -1
  65. package/dist/cli/__tests__/mcp-serve.test.d.ts +2 -0
  66. package/dist/cli/__tests__/mcp-serve.test.d.ts.map +1 -0
  67. package/dist/cli/__tests__/mcp-serve.test.js +38 -0
  68. package/dist/cli/__tests__/mcp-serve.test.js.map +1 -0
  69. package/dist/cli/__tests__/nested-help-routing.test.js +1 -0
  70. package/dist/cli/__tests__/nested-help-routing.test.js.map +1 -1
  71. package/dist/cli/__tests__/package-bin-contract.test.js +58 -3
  72. package/dist/cli/__tests__/package-bin-contract.test.js.map +1 -1
  73. package/dist/cli/__tests__/question.test.js +209 -5
  74. package/dist/cli/__tests__/question.test.js.map +1 -1
  75. package/dist/cli/__tests__/setup-agents-overwrite.test.js +146 -3
  76. package/dist/cli/__tests__/setup-agents-overwrite.test.js.map +1 -1
  77. package/dist/cli/__tests__/setup-install-mode.test.d.ts +2 -0
  78. package/dist/cli/__tests__/setup-install-mode.test.d.ts.map +1 -0
  79. package/dist/cli/__tests__/setup-install-mode.test.js +873 -0
  80. package/dist/cli/__tests__/setup-install-mode.test.js.map +1 -0
  81. package/dist/cli/__tests__/setup-prompts-overwrite.test.js +83 -22
  82. package/dist/cli/__tests__/setup-prompts-overwrite.test.js.map +1 -1
  83. package/dist/cli/__tests__/setup-refresh.test.js +120 -2
  84. package/dist/cli/__tests__/setup-refresh.test.js.map +1 -1
  85. package/dist/cli/__tests__/setup-skills-overwrite.test.js +32 -13
  86. package/dist/cli/__tests__/setup-skills-overwrite.test.js.map +1 -1
  87. package/dist/cli/__tests__/sidecar.test.d.ts +2 -0
  88. package/dist/cli/__tests__/sidecar.test.d.ts.map +1 -0
  89. package/dist/cli/__tests__/sidecar.test.js +24 -0
  90. package/dist/cli/__tests__/sidecar.test.js.map +1 -0
  91. package/dist/cli/__tests__/team.test.js +90 -42
  92. package/dist/cli/__tests__/team.test.js.map +1 -1
  93. package/dist/cli/__tests__/version-sync-contract.test.js +11 -0
  94. package/dist/cli/__tests__/version-sync-contract.test.js.map +1 -1
  95. package/dist/cli/codex-home.d.ts +4 -5
  96. package/dist/cli/codex-home.d.ts.map +1 -1
  97. package/dist/cli/codex-home.js +9 -37
  98. package/dist/cli/codex-home.js.map +1 -1
  99. package/dist/cli/doctor.d.ts +1 -1
  100. package/dist/cli/doctor.d.ts.map +1 -1
  101. package/dist/cli/doctor.js +509 -278
  102. package/dist/cli/doctor.js.map +1 -1
  103. package/dist/cli/index.d.ts +13 -5
  104. package/dist/cli/index.d.ts.map +1 -1
  105. package/dist/cli/index.js +351 -60
  106. package/dist/cli/index.js.map +1 -1
  107. package/dist/cli/list.d.ts +2 -0
  108. package/dist/cli/list.d.ts.map +1 -0
  109. package/dist/cli/list.js +40 -0
  110. package/dist/cli/list.js.map +1 -0
  111. package/dist/cli/mcp-serve.d.ts +12 -0
  112. package/dist/cli/mcp-serve.d.ts.map +1 -0
  113. package/dist/cli/mcp-serve.js +63 -0
  114. package/dist/cli/mcp-serve.js.map +1 -0
  115. package/dist/cli/plugin-marketplace.d.ts +13 -0
  116. package/dist/cli/plugin-marketplace.d.ts.map +1 -0
  117. package/dist/cli/plugin-marketplace.js +77 -0
  118. package/dist/cli/plugin-marketplace.js.map +1 -0
  119. package/dist/cli/question.d.ts.map +1 -1
  120. package/dist/cli/question.js +27 -2
  121. package/dist/cli/question.js.map +1 -1
  122. package/dist/cli/setup-preferences.d.ts +20 -0
  123. package/dist/cli/setup-preferences.d.ts.map +1 -0
  124. package/dist/cli/setup-preferences.js +71 -0
  125. package/dist/cli/setup-preferences.js.map +1 -0
  126. package/dist/cli/setup.d.ts +12 -3
  127. package/dist/cli/setup.d.ts.map +1 -1
  128. package/dist/cli/setup.js +1080 -254
  129. package/dist/cli/setup.js.map +1 -1
  130. package/dist/cli/team.d.ts +1 -0
  131. package/dist/cli/team.d.ts.map +1 -1
  132. package/dist/cli/team.js +22 -3
  133. package/dist/cli/team.js.map +1 -1
  134. package/dist/cli/uninstall.d.ts.map +1 -1
  135. package/dist/cli/uninstall.js +2 -8
  136. package/dist/cli/uninstall.js.map +1 -1
  137. package/dist/config/__tests__/generator-idempotent.test.js +100 -3
  138. package/dist/config/__tests__/generator-idempotent.test.js.map +1 -1
  139. package/dist/config/__tests__/generator-notify.test.js +6 -5
  140. package/dist/config/__tests__/generator-notify.test.js.map +1 -1
  141. package/dist/config/__tests__/generator-status-line-presets.test.d.ts +2 -0
  142. package/dist/config/__tests__/generator-status-line-presets.test.d.ts.map +1 -0
  143. package/dist/config/__tests__/generator-status-line-presets.test.js +203 -0
  144. package/dist/config/__tests__/generator-status-line-presets.test.js.map +1 -0
  145. package/dist/config/__tests__/models.test.js +35 -2
  146. package/dist/config/__tests__/models.test.js.map +1 -1
  147. package/dist/config/codex-hooks.d.ts.map +1 -1
  148. package/dist/config/codex-hooks.js +2 -7
  149. package/dist/config/codex-hooks.js.map +1 -1
  150. package/dist/config/generator.d.ts +10 -0
  151. package/dist/config/generator.d.ts.map +1 -1
  152. package/dist/config/generator.js +259 -76
  153. package/dist/config/generator.js.map +1 -1
  154. package/dist/config/models.d.ts +13 -3
  155. package/dist/config/models.d.ts.map +1 -1
  156. package/dist/config/models.js +25 -5
  157. package/dist/config/models.js.map +1 -1
  158. package/dist/config/omx-first-party-mcp.d.ts +18 -0
  159. package/dist/config/omx-first-party-mcp.d.ts.map +1 -0
  160. package/dist/config/omx-first-party-mcp.js +76 -0
  161. package/dist/config/omx-first-party-mcp.js.map +1 -0
  162. package/dist/document-refresh/__tests__/enforcer.test.d.ts +2 -0
  163. package/dist/document-refresh/__tests__/enforcer.test.d.ts.map +1 -0
  164. package/dist/document-refresh/__tests__/enforcer.test.js +128 -0
  165. package/dist/document-refresh/__tests__/enforcer.test.js.map +1 -0
  166. package/dist/document-refresh/config.d.ts +9 -0
  167. package/dist/document-refresh/config.d.ts.map +1 -0
  168. package/dist/document-refresh/config.js +70 -0
  169. package/dist/document-refresh/config.js.map +1 -0
  170. package/dist/document-refresh/enforcer.d.ts +43 -0
  171. package/dist/document-refresh/enforcer.d.ts.map +1 -0
  172. package/dist/document-refresh/enforcer.js +329 -0
  173. package/dist/document-refresh/enforcer.js.map +1 -0
  174. package/dist/exec/followup.d.ts +44 -0
  175. package/dist/exec/followup.d.ts.map +1 -0
  176. package/dist/exec/followup.js +349 -0
  177. package/dist/exec/followup.js.map +1 -0
  178. package/dist/hooks/__tests__/codebase-map.test.js +63 -1
  179. package/dist/hooks/__tests__/codebase-map.test.js.map +1 -1
  180. package/dist/hooks/__tests__/deep-interview-contract.test.js +10 -7
  181. package/dist/hooks/__tests__/deep-interview-contract.test.js.map +1 -1
  182. package/dist/hooks/__tests__/keyword-detector.test.js +89 -0
  183. package/dist/hooks/__tests__/keyword-detector.test.js.map +1 -1
  184. package/dist/hooks/__tests__/notify-fallback-watcher.test.js +84 -1
  185. package/dist/hooks/__tests__/notify-fallback-watcher.test.js.map +1 -1
  186. package/dist/hooks/__tests__/notify-hook-all-workers-idle.test.js +23 -2
  187. package/dist/hooks/__tests__/notify-hook-all-workers-idle.test.js.map +1 -1
  188. package/dist/hooks/__tests__/notify-hook-auto-nudge.test.js +43 -0
  189. package/dist/hooks/__tests__/notify-hook-auto-nudge.test.js.map +1 -1
  190. package/dist/hooks/__tests__/notify-hook-cross-worktree-heartbeat.test.js +17 -0
  191. package/dist/hooks/__tests__/notify-hook-cross-worktree-heartbeat.test.js.map +1 -1
  192. package/dist/hooks/__tests__/notify-hook-managed-tmux.test.js +53 -0
  193. package/dist/hooks/__tests__/notify-hook-managed-tmux.test.js.map +1 -1
  194. package/dist/hooks/__tests__/notify-hook-regression-205.test.js +4 -4
  195. package/dist/hooks/__tests__/notify-hook-regression-205.test.js.map +1 -1
  196. package/dist/hooks/__tests__/notify-hook-team-dispatch.test.js +103 -0
  197. package/dist/hooks/__tests__/notify-hook-team-dispatch.test.js.map +1 -1
  198. package/dist/hooks/__tests__/notify-hook-team-tmux-guard.test.js +27 -13
  199. package/dist/hooks/__tests__/notify-hook-team-tmux-guard.test.js.map +1 -1
  200. package/dist/hooks/__tests__/notify-hook-team-worker-fail-closed.test.d.ts +2 -0
  201. package/dist/hooks/__tests__/notify-hook-team-worker-fail-closed.test.d.ts.map +1 -0
  202. package/dist/hooks/__tests__/notify-hook-team-worker-fail-closed.test.js +35 -0
  203. package/dist/hooks/__tests__/notify-hook-team-worker-fail-closed.test.js.map +1 -0
  204. package/dist/hooks/__tests__/notify-hook-worker-idle.test.js +70 -3
  205. package/dist/hooks/__tests__/notify-hook-worker-idle.test.js.map +1 -1
  206. package/dist/hooks/__tests__/pre-context-gate-skills.test.js +5 -0
  207. package/dist/hooks/__tests__/pre-context-gate-skills.test.js.map +1 -1
  208. package/dist/hooks/__tests__/prompt-guidance-fragments.test.js +3 -2
  209. package/dist/hooks/__tests__/prompt-guidance-fragments.test.js.map +1 -1
  210. package/dist/hooks/__tests__/prompt-refactor-contract.test.d.ts +2 -0
  211. package/dist/hooks/__tests__/prompt-refactor-contract.test.d.ts.map +1 -0
  212. package/dist/hooks/__tests__/prompt-refactor-contract.test.js +22 -0
  213. package/dist/hooks/__tests__/prompt-refactor-contract.test.js.map +1 -0
  214. package/dist/hooks/__tests__/team-runtime-gating-docs-contract.test.d.ts +2 -0
  215. package/dist/hooks/__tests__/team-runtime-gating-docs-contract.test.d.ts.map +1 -0
  216. package/dist/hooks/__tests__/team-runtime-gating-docs-contract.test.js +28 -0
  217. package/dist/hooks/__tests__/team-runtime-gating-docs-contract.test.js.map +1 -0
  218. package/dist/hooks/__tests__/triage-heuristic.test.js +59 -4
  219. package/dist/hooks/__tests__/triage-heuristic.test.js.map +1 -1
  220. package/dist/hooks/__tests__/visual-ralph-skill.test.d.ts +2 -0
  221. package/dist/hooks/__tests__/visual-ralph-skill.test.d.ts.map +1 -0
  222. package/dist/hooks/__tests__/visual-ralph-skill.test.js +44 -0
  223. package/dist/hooks/__tests__/visual-ralph-skill.test.js.map +1 -0
  224. package/dist/hooks/codebase-map.d.ts.map +1 -1
  225. package/dist/hooks/codebase-map.js +83 -6
  226. package/dist/hooks/codebase-map.js.map +1 -1
  227. package/dist/hooks/keyword-detector.d.ts.map +1 -1
  228. package/dist/hooks/keyword-detector.js +19 -14
  229. package/dist/hooks/keyword-detector.js.map +1 -1
  230. package/dist/hooks/prompt-guidance-contract.d.ts +6 -0
  231. package/dist/hooks/prompt-guidance-contract.d.ts.map +1 -1
  232. package/dist/hooks/prompt-guidance-contract.js +89 -0
  233. package/dist/hooks/prompt-guidance-contract.js.map +1 -1
  234. package/dist/hooks/session.d.ts +2 -0
  235. package/dist/hooks/session.d.ts.map +1 -1
  236. package/dist/hooks/session.js +6 -0
  237. package/dist/hooks/session.js.map +1 -1
  238. package/dist/hooks/triage-heuristic.d.ts +2 -2
  239. package/dist/hooks/triage-heuristic.d.ts.map +1 -1
  240. package/dist/hooks/triage-heuristic.js +97 -20
  241. package/dist/hooks/triage-heuristic.js.map +1 -1
  242. package/dist/hooks/triage-state.d.ts +1 -1
  243. package/dist/hooks/triage-state.d.ts.map +1 -1
  244. package/dist/hooks/triage-state.js +2 -1
  245. package/dist/hooks/triage-state.js.map +1 -1
  246. package/dist/hud/__tests__/index.test.js +4 -4
  247. package/dist/hud/__tests__/index.test.js.map +1 -1
  248. package/dist/hud/__tests__/state.test.js +4 -0
  249. package/dist/hud/__tests__/state.test.js.map +1 -1
  250. package/dist/hud/__tests__/types.test.js +27 -0
  251. package/dist/hud/__tests__/types.test.js.map +1 -1
  252. package/dist/hud/state.d.ts.map +1 -1
  253. package/dist/hud/state.js +8 -0
  254. package/dist/hud/state.js.map +1 -1
  255. package/dist/hud/types.d.ts +9 -0
  256. package/dist/hud/types.d.ts.map +1 -1
  257. package/dist/hud/types.js +3 -0
  258. package/dist/hud/types.js.map +1 -1
  259. package/dist/mcp/__tests__/bootstrap.test.js +45 -5
  260. package/dist/mcp/__tests__/bootstrap.test.js.map +1 -1
  261. package/dist/mcp/__tests__/server-lifecycle.test.js +50 -7
  262. package/dist/mcp/__tests__/server-lifecycle.test.js.map +1 -1
  263. package/dist/mcp/__tests__/state-server.test.js +67 -9
  264. package/dist/mcp/__tests__/state-server.test.js.map +1 -1
  265. package/dist/mcp/bootstrap.d.ts +12 -1
  266. package/dist/mcp/bootstrap.d.ts.map +1 -1
  267. package/dist/mcp/bootstrap.js +87 -29
  268. package/dist/mcp/bootstrap.js.map +1 -1
  269. package/dist/mcp/state-server.d.ts +5 -11
  270. package/dist/mcp/state-server.d.ts.map +1 -1
  271. package/dist/mcp/state-server.js +16 -432
  272. package/dist/mcp/state-server.js.map +1 -1
  273. package/dist/planning/__tests__/artifacts.test.js +152 -1
  274. package/dist/planning/__tests__/artifacts.test.js.map +1 -1
  275. package/dist/planning/artifacts.d.ts +9 -0
  276. package/dist/planning/artifacts.d.ts.map +1 -1
  277. package/dist/planning/artifacts.js +60 -1
  278. package/dist/planning/artifacts.js.map +1 -1
  279. package/dist/question/__tests__/deep-interview.test.js +1 -1
  280. package/dist/question/__tests__/deep-interview.test.js.map +1 -1
  281. package/dist/question/__tests__/renderer.test.js +70 -0
  282. package/dist/question/__tests__/renderer.test.js.map +1 -1
  283. package/dist/question/__tests__/ui.test.js +49 -0
  284. package/dist/question/__tests__/ui.test.js.map +1 -1
  285. package/dist/question/renderer.d.ts +14 -1
  286. package/dist/question/renderer.d.ts.map +1 -1
  287. package/dist/question/renderer.js +109 -10
  288. package/dist/question/renderer.js.map +1 -1
  289. package/dist/question/state.d.ts +2 -0
  290. package/dist/question/state.d.ts.map +1 -1
  291. package/dist/question/state.js +4 -0
  292. package/dist/question/state.js.map +1 -1
  293. package/dist/question/types.d.ts +2 -1
  294. package/dist/question/types.d.ts.map +1 -1
  295. package/dist/question/types.js.map +1 -1
  296. package/dist/question/ui.d.ts.map +1 -1
  297. package/dist/question/ui.js +28 -8
  298. package/dist/question/ui.js.map +1 -1
  299. package/dist/runtime/__tests__/bridge.test.js +143 -1
  300. package/dist/runtime/__tests__/bridge.test.js.map +1 -1
  301. package/dist/runtime/bridge.d.ts +21 -0
  302. package/dist/runtime/bridge.d.ts.map +1 -1
  303. package/dist/runtime/bridge.js +54 -4
  304. package/dist/runtime/bridge.js.map +1 -1
  305. package/dist/scripts/__tests__/codex-native-hook.test.js +1904 -230
  306. package/dist/scripts/__tests__/codex-native-hook.test.js.map +1 -1
  307. package/dist/scripts/__tests__/generate-release-body.test.js +90 -1
  308. package/dist/scripts/__tests__/generate-release-body.test.js.map +1 -1
  309. package/dist/scripts/__tests__/hook-derived-watcher.test.js +110 -2
  310. package/dist/scripts/__tests__/hook-derived-watcher.test.js.map +1 -1
  311. package/dist/scripts/__tests__/postinstall.test.js +7 -93
  312. package/dist/scripts/__tests__/postinstall.test.js.map +1 -1
  313. package/dist/scripts/__tests__/prompt-inventory.test.d.ts +2 -0
  314. package/dist/scripts/__tests__/prompt-inventory.test.d.ts.map +1 -0
  315. package/dist/scripts/__tests__/prompt-inventory.test.js +56 -0
  316. package/dist/scripts/__tests__/prompt-inventory.test.js.map +1 -0
  317. package/dist/scripts/__tests__/run-test-files.test.d.ts +2 -0
  318. package/dist/scripts/__tests__/run-test-files.test.d.ts.map +1 -0
  319. package/dist/scripts/__tests__/run-test-files.test.js +62 -0
  320. package/dist/scripts/__tests__/run-test-files.test.js.map +1 -0
  321. package/dist/scripts/__tests__/smoke-packed-install.test.js +13 -1
  322. package/dist/scripts/__tests__/smoke-packed-install.test.js.map +1 -1
  323. package/dist/scripts/__tests__/verify-native-agents.test.d.ts +2 -0
  324. package/dist/scripts/__tests__/verify-native-agents.test.d.ts.map +1 -0
  325. package/dist/scripts/__tests__/verify-native-agents.test.js +166 -0
  326. package/dist/scripts/__tests__/verify-native-agents.test.js.map +1 -0
  327. package/dist/scripts/codex-execution-surface.d.ts +16 -0
  328. package/dist/scripts/codex-execution-surface.d.ts.map +1 -0
  329. package/dist/scripts/codex-execution-surface.js +42 -0
  330. package/dist/scripts/codex-execution-surface.js.map +1 -0
  331. package/dist/scripts/codex-native-hook.d.ts.map +1 -1
  332. package/dist/scripts/codex-native-hook.js +418 -76
  333. package/dist/scripts/codex-native-hook.js.map +1 -1
  334. package/dist/scripts/codex-native-pre-post.d.ts +8 -0
  335. package/dist/scripts/codex-native-pre-post.d.ts.map +1 -1
  336. package/dist/scripts/codex-native-pre-post.js +133 -19
  337. package/dist/scripts/codex-native-pre-post.js.map +1 -1
  338. package/dist/scripts/generate-release-body.d.ts +1 -0
  339. package/dist/scripts/generate-release-body.d.ts.map +1 -1
  340. package/dist/scripts/generate-release-body.js +32 -3
  341. package/dist/scripts/generate-release-body.js.map +1 -1
  342. package/dist/scripts/hook-derived-watcher.js +46 -7
  343. package/dist/scripts/hook-derived-watcher.js.map +1 -1
  344. package/dist/scripts/notify-fallback-watcher.js +59 -9
  345. package/dist/scripts/notify-fallback-watcher.js.map +1 -1
  346. package/dist/scripts/notify-hook/__tests__/team-worker-posttooluse.test.d.ts +2 -0
  347. package/dist/scripts/notify-hook/__tests__/team-worker-posttooluse.test.d.ts.map +1 -0
  348. package/dist/scripts/notify-hook/__tests__/team-worker-posttooluse.test.js +153 -0
  349. package/dist/scripts/notify-hook/__tests__/team-worker-posttooluse.test.js.map +1 -0
  350. package/dist/scripts/notify-hook/managed-tmux.d.ts.map +1 -1
  351. package/dist/scripts/notify-hook/managed-tmux.js +18 -1
  352. package/dist/scripts/notify-hook/managed-tmux.js.map +1 -1
  353. package/dist/scripts/notify-hook/process-runner.d.ts.map +1 -1
  354. package/dist/scripts/notify-hook/process-runner.js +7 -3
  355. package/dist/scripts/notify-hook/process-runner.js.map +1 -1
  356. package/dist/scripts/notify-hook/team-dispatch.d.ts.map +1 -1
  357. package/dist/scripts/notify-hook/team-dispatch.js +96 -11
  358. package/dist/scripts/notify-hook/team-dispatch.js.map +1 -1
  359. package/dist/scripts/notify-hook/team-tmux-guard.js +3 -3
  360. package/dist/scripts/notify-hook/team-worker-posttooluse.d.ts +34 -0
  361. package/dist/scripts/notify-hook/team-worker-posttooluse.d.ts.map +1 -0
  362. package/dist/scripts/notify-hook/team-worker-posttooluse.js +434 -0
  363. package/dist/scripts/notify-hook/team-worker-posttooluse.js.map +1 -0
  364. package/dist/scripts/notify-hook/team-worker.d.ts +1 -1
  365. package/dist/scripts/notify-hook/team-worker.d.ts.map +1 -1
  366. package/dist/scripts/notify-hook/team-worker.js +3 -43
  367. package/dist/scripts/notify-hook/team-worker.js.map +1 -1
  368. package/dist/scripts/notify-hook.js +36 -5
  369. package/dist/scripts/notify-hook.js.map +1 -1
  370. package/dist/scripts/postinstall.d.ts +1 -5
  371. package/dist/scripts/postinstall.d.ts.map +1 -1
  372. package/dist/scripts/postinstall.js +3 -42
  373. package/dist/scripts/postinstall.js.map +1 -1
  374. package/dist/scripts/prompt-inventory.d.ts +29 -0
  375. package/dist/scripts/prompt-inventory.d.ts.map +1 -0
  376. package/dist/scripts/prompt-inventory.js +178 -0
  377. package/dist/scripts/prompt-inventory.js.map +1 -0
  378. package/dist/scripts/run-test-files.js +32 -2
  379. package/dist/scripts/run-test-files.js.map +1 -1
  380. package/dist/scripts/smoke-packed-install.d.ts +3 -0
  381. package/dist/scripts/smoke-packed-install.d.ts.map +1 -1
  382. package/dist/scripts/smoke-packed-install.js +9 -1
  383. package/dist/scripts/smoke-packed-install.js.map +1 -1
  384. package/dist/scripts/sync-plugin-mirror.d.ts +13 -0
  385. package/dist/scripts/sync-plugin-mirror.d.ts.map +1 -0
  386. package/dist/scripts/sync-plugin-mirror.js +242 -0
  387. package/dist/scripts/sync-plugin-mirror.js.map +1 -0
  388. package/dist/scripts/verify-native-agents.d.ts +16 -0
  389. package/dist/scripts/verify-native-agents.d.ts.map +1 -0
  390. package/dist/scripts/verify-native-agents.js +188 -0
  391. package/dist/scripts/verify-native-agents.js.map +1 -0
  392. package/dist/session-history/search.d.ts.map +1 -1
  393. package/dist/session-history/search.js +7 -2
  394. package/dist/session-history/search.js.map +1 -1
  395. package/dist/sidecar/__tests__/boundary.test.d.ts +2 -0
  396. package/dist/sidecar/__tests__/boundary.test.d.ts.map +1 -0
  397. package/dist/sidecar/__tests__/boundary.test.js +48 -0
  398. package/dist/sidecar/__tests__/boundary.test.js.map +1 -0
  399. package/dist/sidecar/__tests__/collector.test.d.ts +2 -0
  400. package/dist/sidecar/__tests__/collector.test.d.ts.map +1 -0
  401. package/dist/sidecar/__tests__/collector.test.js +162 -0
  402. package/dist/sidecar/__tests__/collector.test.js.map +1 -0
  403. package/dist/sidecar/__tests__/render.test.d.ts +2 -0
  404. package/dist/sidecar/__tests__/render.test.d.ts.map +1 -0
  405. package/dist/sidecar/__tests__/render.test.js +67 -0
  406. package/dist/sidecar/__tests__/render.test.js.map +1 -0
  407. package/dist/sidecar/__tests__/tmux.test.d.ts +2 -0
  408. package/dist/sidecar/__tests__/tmux.test.d.ts.map +1 -0
  409. package/dist/sidecar/__tests__/tmux.test.js +30 -0
  410. package/dist/sidecar/__tests__/tmux.test.js.map +1 -0
  411. package/dist/sidecar/__tests__/watch.test.d.ts +2 -0
  412. package/dist/sidecar/__tests__/watch.test.d.ts.map +1 -0
  413. package/dist/sidecar/__tests__/watch.test.js +42 -0
  414. package/dist/sidecar/__tests__/watch.test.js.map +1 -0
  415. package/dist/sidecar/collector.d.ts +4 -0
  416. package/dist/sidecar/collector.d.ts.map +1 -0
  417. package/dist/sidecar/collector.js +377 -0
  418. package/dist/sidecar/collector.js.map +1 -0
  419. package/dist/sidecar/index.d.ts +25 -0
  420. package/dist/sidecar/index.d.ts.map +1 -0
  421. package/dist/sidecar/index.js +165 -0
  422. package/dist/sidecar/index.js.map +1 -0
  423. package/dist/sidecar/render.d.ts +3 -0
  424. package/dist/sidecar/render.d.ts.map +1 -0
  425. package/dist/sidecar/render.js +72 -0
  426. package/dist/sidecar/render.js.map +1 -0
  427. package/dist/sidecar/tmux.d.ts +13 -0
  428. package/dist/sidecar/tmux.d.ts.map +1 -0
  429. package/dist/sidecar/tmux.js +44 -0
  430. package/dist/sidecar/tmux.js.map +1 -0
  431. package/dist/sidecar/types.d.ts +125 -0
  432. package/dist/sidecar/types.d.ts.map +1 -0
  433. package/dist/sidecar/types.js +2 -0
  434. package/dist/sidecar/types.js.map +1 -0
  435. package/dist/state/__tests__/operations.test.js +50 -22
  436. package/dist/state/__tests__/operations.test.js.map +1 -1
  437. package/dist/state/operations.d.ts +1 -1
  438. package/dist/state/operations.d.ts.map +1 -1
  439. package/dist/state/operations.js +19 -7
  440. package/dist/state/operations.js.map +1 -1
  441. package/dist/team/__tests__/commit-hygiene.test.d.ts +2 -0
  442. package/dist/team/__tests__/commit-hygiene.test.d.ts.map +1 -0
  443. package/dist/team/__tests__/commit-hygiene.test.js +93 -0
  444. package/dist/team/__tests__/commit-hygiene.test.js.map +1 -0
  445. package/dist/team/__tests__/delegation-policy.test.d.ts +2 -0
  446. package/dist/team/__tests__/delegation-policy.test.d.ts.map +1 -0
  447. package/dist/team/__tests__/delegation-policy.test.js +69 -0
  448. package/dist/team/__tests__/delegation-policy.test.js.map +1 -0
  449. package/dist/team/__tests__/events.test.js +54 -4
  450. package/dist/team/__tests__/events.test.js.map +1 -1
  451. package/dist/team/__tests__/hook-primary-e2e-contract.test.d.ts +2 -0
  452. package/dist/team/__tests__/hook-primary-e2e-contract.test.d.ts.map +1 -0
  453. package/dist/team/__tests__/hook-primary-e2e-contract.test.js +78 -0
  454. package/dist/team/__tests__/hook-primary-e2e-contract.test.js.map +1 -0
  455. package/dist/team/__tests__/model-contract.test.js +18 -2
  456. package/dist/team/__tests__/model-contract.test.js.map +1 -1
  457. package/dist/team/__tests__/repo-aware-decomposition.test.d.ts +2 -0
  458. package/dist/team/__tests__/repo-aware-decomposition.test.d.ts.map +1 -0
  459. package/dist/team/__tests__/repo-aware-decomposition.test.js +95 -0
  460. package/dist/team/__tests__/repo-aware-decomposition.test.js.map +1 -0
  461. package/dist/team/__tests__/runtime.test.js +677 -17
  462. package/dist/team/__tests__/runtime.test.js.map +1 -1
  463. package/dist/team/__tests__/state-root.test.js +177 -1
  464. package/dist/team/__tests__/state-root.test.js.map +1 -1
  465. package/dist/team/__tests__/state.test.js +110 -0
  466. package/dist/team/__tests__/state.test.js.map +1 -1
  467. package/dist/team/__tests__/tmux-session.test.js +301 -2
  468. package/dist/team/__tests__/tmux-session.test.js.map +1 -1
  469. package/dist/team/__tests__/worker-bootstrap.test.js +94 -0
  470. package/dist/team/__tests__/worker-bootstrap.test.js.map +1 -1
  471. package/dist/team/commit-hygiene.d.ts +22 -3
  472. package/dist/team/commit-hygiene.d.ts.map +1 -1
  473. package/dist/team/commit-hygiene.js +134 -2
  474. package/dist/team/commit-hygiene.js.map +1 -1
  475. package/dist/team/contracts.d.ts +1 -1
  476. package/dist/team/contracts.d.ts.map +1 -1
  477. package/dist/team/contracts.js +2 -0
  478. package/dist/team/contracts.js.map +1 -1
  479. package/dist/team/dag-schema.d.ts +38 -0
  480. package/dist/team/dag-schema.d.ts.map +1 -0
  481. package/dist/team/dag-schema.js +221 -0
  482. package/dist/team/dag-schema.js.map +1 -0
  483. package/dist/team/delegation-policy.d.ts +3 -0
  484. package/dist/team/delegation-policy.d.ts.map +1 -0
  485. package/dist/team/delegation-policy.js +82 -0
  486. package/dist/team/delegation-policy.js.map +1 -0
  487. package/dist/team/model-contract.d.ts +3 -1
  488. package/dist/team/model-contract.d.ts.map +1 -1
  489. package/dist/team/model-contract.js +44 -5
  490. package/dist/team/model-contract.js.map +1 -1
  491. package/dist/team/repo-aware-decomposition.d.ts +60 -0
  492. package/dist/team/repo-aware-decomposition.d.ts.map +1 -0
  493. package/dist/team/repo-aware-decomposition.js +229 -0
  494. package/dist/team/repo-aware-decomposition.js.map +1 -0
  495. package/dist/team/runtime.d.ts +34 -0
  496. package/dist/team/runtime.d.ts.map +1 -1
  497. package/dist/team/runtime.js +191 -50
  498. package/dist/team/runtime.js.map +1 -1
  499. package/dist/team/state/tasks.d.ts.map +1 -1
  500. package/dist/team/state/tasks.js +33 -0
  501. package/dist/team/state/tasks.js.map +1 -1
  502. package/dist/team/state/types.d.ts +23 -1
  503. package/dist/team/state/types.d.ts.map +1 -1
  504. package/dist/team/state/types.js.map +1 -1
  505. package/dist/team/state-root.d.ts +35 -0
  506. package/dist/team/state-root.d.ts.map +1 -1
  507. package/dist/team/state-root.js +281 -1
  508. package/dist/team/state-root.js.map +1 -1
  509. package/dist/team/state.d.ts +27 -1
  510. package/dist/team/state.d.ts.map +1 -1
  511. package/dist/team/state.js +6 -0
  512. package/dist/team/state.js.map +1 -1
  513. package/dist/team/tmux-session.d.ts +1 -0
  514. package/dist/team/tmux-session.d.ts.map +1 -1
  515. package/dist/team/tmux-session.js +83 -6
  516. package/dist/team/tmux-session.js.map +1 -1
  517. package/dist/team/worker-bootstrap.d.ts +3 -0
  518. package/dist/team/worker-bootstrap.d.ts.map +1 -1
  519. package/dist/team/worker-bootstrap.js +77 -4
  520. package/dist/team/worker-bootstrap.js.map +1 -1
  521. package/dist/utils/__tests__/agents-model-table.test.js +8 -0
  522. package/dist/utils/__tests__/agents-model-table.test.js.map +1 -1
  523. package/dist/utils/__tests__/paths.test.js +42 -9
  524. package/dist/utils/__tests__/paths.test.js.map +1 -1
  525. package/dist/utils/agents-md.d.ts +3 -0
  526. package/dist/utils/agents-md.d.ts.map +1 -1
  527. package/dist/utils/agents-md.js +25 -0
  528. package/dist/utils/agents-md.js.map +1 -1
  529. package/dist/utils/agents-model-table.d.ts.map +1 -1
  530. package/dist/utils/agents-model-table.js +2 -3
  531. package/dist/utils/agents-model-table.js.map +1 -1
  532. package/package.json +14 -7
  533. package/plugins/oh-my-codex/.app.json +3 -0
  534. package/plugins/oh-my-codex/.codex-plugin/plugin.json +30 -0
  535. package/plugins/oh-my-codex/.mcp.json +44 -0
  536. package/plugins/oh-my-codex/skills/ai-slop-cleaner/SKILL.md +114 -0
  537. package/plugins/oh-my-codex/skills/analyze/SKILL.md +148 -0
  538. package/plugins/oh-my-codex/skills/ask-claude/SKILL.md +61 -0
  539. package/plugins/oh-my-codex/skills/ask-gemini/SKILL.md +61 -0
  540. package/plugins/oh-my-codex/skills/autopilot/SKILL.md +231 -0
  541. package/plugins/oh-my-codex/skills/autoresearch/SKILL.md +68 -0
  542. package/plugins/oh-my-codex/skills/cancel/SKILL.md +399 -0
  543. package/plugins/oh-my-codex/skills/code-review/SKILL.md +290 -0
  544. package/plugins/oh-my-codex/skills/configure-notifications/SKILL.md +287 -0
  545. package/plugins/oh-my-codex/skills/deep-interview/SKILL.md +464 -0
  546. package/plugins/oh-my-codex/skills/doctor/SKILL.md +239 -0
  547. package/plugins/oh-my-codex/skills/help/SKILL.md +202 -0
  548. package/plugins/oh-my-codex/skills/hud/SKILL.md +98 -0
  549. package/plugins/oh-my-codex/skills/note/SKILL.md +62 -0
  550. package/plugins/oh-my-codex/skills/omx-setup/SKILL.md +135 -0
  551. package/plugins/oh-my-codex/skills/pipeline/SKILL.md +86 -0
  552. package/plugins/oh-my-codex/skills/plan/SKILL.md +276 -0
  553. package/plugins/oh-my-codex/skills/ralph/SKILL.md +269 -0
  554. package/plugins/oh-my-codex/skills/ralplan/SKILL.md +162 -0
  555. package/plugins/oh-my-codex/skills/security-review/SKILL.md +300 -0
  556. package/plugins/oh-my-codex/skills/skill/SKILL.md +835 -0
  557. package/plugins/oh-my-codex/skills/team/SKILL.md +510 -0
  558. package/plugins/oh-my-codex/skills/trace/SKILL.md +33 -0
  559. package/plugins/oh-my-codex/skills/ultraqa/SKILL.md +143 -0
  560. package/plugins/oh-my-codex/skills/ultrawork/SKILL.md +175 -0
  561. package/plugins/oh-my-codex/skills/visual-ralph/SKILL.md +153 -0
  562. package/plugins/oh-my-codex/skills/visual-verdict/SKILL.md +76 -0
  563. package/plugins/oh-my-codex/skills/wiki/SKILL.md +57 -0
  564. package/plugins/oh-my-codex/skills/worker/SKILL.md +106 -0
  565. package/prompts/critic.md +35 -83
  566. package/prompts/executor.md +32 -109
  567. package/prompts/explore-harness.md +1 -1
  568. package/prompts/explore.md +37 -90
  569. package/prompts/planner.md +28 -58
  570. package/prompts/researcher.md +46 -78
  571. package/prompts/verifier.md +23 -31
  572. package/skills/autopilot/SKILL.md +1 -4
  573. package/skills/deep-interview/SKILL.md +8 -5
  574. package/skills/doctor/SKILL.md +45 -17
  575. package/skills/help/SKILL.md +2 -2
  576. package/skills/omx-setup/SKILL.md +52 -9
  577. package/skills/plan/SKILL.md +1 -4
  578. package/skills/ralph/SKILL.md +3 -5
  579. package/skills/ralplan/SKILL.md +2 -6
  580. package/skills/team/SKILL.md +2 -5
  581. package/skills/ultraqa/SKILL.md +1 -4
  582. package/skills/ultrawork/SKILL.md +2 -3
  583. package/skills/visual-ralph/SKILL.md +153 -0
  584. package/skills/web-clone/SKILL.md +12 -354
  585. package/src/scripts/__tests__/codex-native-hook.test.ts +2306 -224
  586. package/src/scripts/__tests__/generate-release-body.test.ts +109 -0
  587. package/src/scripts/__tests__/hook-derived-watcher.test.ts +132 -2
  588. package/src/scripts/__tests__/postinstall.test.ts +7 -104
  589. package/src/scripts/__tests__/prompt-inventory.test.ts +64 -0
  590. package/src/scripts/__tests__/run-test-files.test.ts +72 -0
  591. package/src/scripts/__tests__/smoke-packed-install.test.ts +15 -0
  592. package/src/scripts/__tests__/verify-native-agents.test.ts +204 -0
  593. package/src/scripts/codex-execution-surface.ts +73 -0
  594. package/src/scripts/codex-native-hook.ts +579 -82
  595. package/src/scripts/codex-native-pre-post.ts +157 -19
  596. package/src/scripts/generate-release-body.ts +34 -2
  597. package/src/scripts/hook-derived-watcher.ts +51 -8
  598. package/src/scripts/notify-fallback-watcher.ts +65 -9
  599. package/src/scripts/notify-hook/__tests__/team-worker-posttooluse.test.ts +180 -0
  600. package/src/scripts/notify-hook/managed-tmux.ts +22 -4
  601. package/src/scripts/notify-hook/process-runner.ts +7 -3
  602. package/src/scripts/notify-hook/team-dispatch.ts +103 -11
  603. package/src/scripts/notify-hook/team-tmux-guard.ts +3 -3
  604. package/src/scripts/notify-hook/team-worker-posttooluse.ts +536 -0
  605. package/src/scripts/notify-hook/team-worker.ts +4 -48
  606. package/src/scripts/notify-hook.ts +36 -5
  607. package/src/scripts/postinstall.ts +4 -58
  608. package/src/scripts/prompt-inventory.ts +218 -0
  609. package/src/scripts/run-test-files.ts +41 -2
  610. package/src/scripts/smoke-packed-install.ts +10 -1
  611. package/src/scripts/sync-plugin-mirror.ts +360 -0
  612. package/src/scripts/verify-native-agents.ts +245 -0
  613. package/templates/AGENTS.md +26 -91
  614. package/templates/catalog-manifest.json +16 -1
@@ -1,12 +1,13 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { execFileSync } from "node:child_process";
3
3
  import { existsSync } from "node:fs";
4
- import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
4
+ import { chmod, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
5
5
  import { tmpdir } from "node:os";
6
6
  import { dirname, join } from "node:path";
7
7
  import { pathToFileURL } from "node:url";
8
8
  import { afterEach, beforeEach, describe, it } from "node:test";
9
9
  import { buildManagedCodexHooksConfig } from "../../config/codex-hooks.js";
10
+ import { DOCUMENT_REFRESH_EXEMPTION_PREFIX } from "../../document-refresh/enforcer.js";
10
11
  import {
11
12
  initTeamState,
12
13
  readTeamLeaderAttention,
@@ -21,12 +22,51 @@ import {
21
22
  } from "../codex-native-hook.js";
22
23
  import { writeSessionStart } from "../../hooks/session.js";
23
24
  import { resetTriageConfigCache } from "../../hooks/triage-config.js";
25
+ import { executeStateOperation } from "../../state/operations.js";
26
+
27
+ function nativeHookScriptPath(): string {
28
+ return join(process.cwd(), "dist", "scripts", "codex-native-hook.js");
29
+ }
30
+
31
+ function parseSingleJsonStdout(stdout: string): Record<string, unknown> {
32
+ const trimmed = stdout.trim();
33
+ assert.notEqual(trimmed, "");
34
+ assert.equal(trimmed.split("\n").length, 1);
35
+ return JSON.parse(trimmed) as Record<string, unknown>;
36
+ }
37
+
38
+ function runNativeHookCli(
39
+ payload: Record<string, unknown> | string,
40
+ options: { cwd?: string; env?: NodeJS.ProcessEnv } = {},
41
+ ): string {
42
+ return execFileSync(
43
+ process.execPath,
44
+ [nativeHookScriptPath()],
45
+ {
46
+ cwd: options.cwd ?? process.cwd(),
47
+ input: typeof payload === "string" ? payload : JSON.stringify(payload),
48
+ encoding: "utf-8",
49
+ stdio: ["pipe", "pipe", "pipe"],
50
+ env: options.env ?? process.env,
51
+ },
52
+ );
53
+ }
24
54
 
25
55
  async function writeJson(path: string, value: unknown): Promise<void> {
26
56
  await mkdir(dirname(path), { recursive: true }).catch(() => {});
27
57
  await writeFile(path, JSON.stringify(value, null, 2));
28
58
  }
29
59
 
60
+ async function writeActiveAutopilotSession(cwd: string, sessionId: string): Promise<void> {
61
+ await writeJson(join(cwd, ".omx", "state", "session.json"), {
62
+ session_id: sessionId,
63
+ });
64
+ await writeJson(join(cwd, ".omx", "state", "sessions", sessionId, "autopilot-state.json"), {
65
+ active: true,
66
+ current_phase: "execution",
67
+ });
68
+ }
69
+
30
70
  async function writeHookCounterPlugin(cwd: string): Promise<string> {
31
71
  const markerPath = join(cwd, ".omx", "stop-hook-counter.json");
32
72
  await mkdir(join(cwd, ".omx", "hooks"), { recursive: true });
@@ -101,6 +141,9 @@ const TEAM_ENV_KEYS = [
101
141
  "OMX_TEAM_STATE_ROOT",
102
142
  "OMX_TEAM_LEADER_CWD",
103
143
  "OMX_SESSION_ID",
144
+ "OMX_QUESTION_RETURN_PANE",
145
+ "OMX_LEADER_PANE_ID",
146
+ "TMUX",
104
147
  "TMUX_PANE",
105
148
  ] as const;
106
149
 
@@ -150,6 +193,7 @@ describe("codex native hook config", () => {
150
193
  String(preToolUse.hooks?.[0]?.command || ""),
151
194
  /codex-native-hook\.js"?$/,
152
195
  );
196
+ assert.equal(preToolUse.hooks?.[0]?.statusMessage, undefined);
153
197
 
154
198
  const postToolUse = config.hooks.PostToolUse[0] as {
155
199
  matcher?: string;
@@ -160,7 +204,18 @@ describe("codex native hook config", () => {
160
204
  String(postToolUse.hooks?.[0]?.command || ""),
161
205
  /codex-native-hook\.js"?$/,
162
206
  );
163
- assert.equal(postToolUse.hooks?.[0]?.statusMessage, "Running OMX tool review");
207
+ assert.equal(postToolUse.hooks?.[0]?.statusMessage, undefined);
208
+
209
+ const userPromptSubmit = config.hooks.UserPromptSubmit[0] as {
210
+ matcher?: string;
211
+ hooks?: Array<Record<string, unknown>>;
212
+ };
213
+ assert.equal(userPromptSubmit.matcher, undefined);
214
+ assert.match(
215
+ String(userPromptSubmit.hooks?.[0]?.command || ""),
216
+ /codex-native-hook\.js"?$/,
217
+ );
218
+ assert.equal(userPromptSubmit.hooks?.[0]?.statusMessage, undefined);
164
219
 
165
220
  const stop = config.hooks.Stop[0] as {
166
221
  hooks?: Array<Record<string, unknown>>;
@@ -190,18 +245,9 @@ describe("codex native hook dispatch", () => {
190
245
  });
191
246
 
192
247
  it("emits deterministic JSON stdout when CLI stdin is malformed", () => {
193
- const stdout = execFileSync(
194
- process.execPath,
195
- [join(process.cwd(), "dist", "scripts", "codex-native-hook.js")],
196
- {
197
- cwd: process.cwd(),
198
- input: "{",
199
- encoding: "utf-8",
200
- stdio: ["pipe", "pipe", "pipe"],
201
- },
202
- );
248
+ const stdout = runNativeHookCli("{");
203
249
 
204
- const output = JSON.parse(stdout.trim()) as {
250
+ const output = parseSingleJsonStdout(stdout) as {
205
251
  decision?: string;
206
252
  reason?: string;
207
253
  hookSpecificOutput?: { hookEventName?: string; additionalContext?: string };
@@ -219,6 +265,129 @@ describe("codex native hook dispatch", () => {
219
265
  );
220
266
  });
221
267
 
268
+ it("emits parseable no-op JSON stdout for inactive Stop CLI runs", async () => {
269
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-cli-stop-noop-json-"));
270
+ try {
271
+ const stdout = runNativeHookCli({
272
+ hook_event_name: "Stop",
273
+ cwd,
274
+ session_id: "sess-cli-stop-noop-json",
275
+ thread_id: "thread-cli-stop-noop-json",
276
+ turn_id: "turn-cli-stop-noop-json",
277
+ }, { cwd });
278
+ const output = parseSingleJsonStdout(stdout);
279
+
280
+ assert.deepEqual(output, {});
281
+ } finally {
282
+ await rm(cwd, { recursive: true, force: true });
283
+ }
284
+ });
285
+
286
+ it("does not crash Stop hook dispatch when the exec follow-up queue is malformed", async () => {
287
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-stop-exec-followup-corrupt-"));
288
+ try {
289
+ const session = await writeSessionStart(cwd, "sess-exec-followup-corrupt");
290
+ const queuePath = join(cwd, ".omx", "state", "sessions", session.session_id, "exec-followups.json");
291
+ await mkdir(dirname(queuePath), { recursive: true });
292
+ await writeFile(queuePath, '{"version":1,"records":[', "utf-8");
293
+
294
+ const result = await dispatchCodexNativeHook({
295
+ hook_event_name: "Stop",
296
+ cwd,
297
+ session_id: session.session_id,
298
+ });
299
+
300
+ assert.equal(result.hookEventName, "Stop");
301
+ assert.equal(result.outputJson, null);
302
+ const queueDirEntries = await readdir(dirname(queuePath));
303
+ assert.ok(queueDirEntries.some((entry) => entry.startsWith("exec-followups.json.corrupt-")));
304
+ const auditPath = join(cwd, ".omx", "logs", `exec-followups-${new Date().toISOString().slice(0, 10)}.jsonl`);
305
+ assert.match(await readFile(auditPath, "utf-8"), /exec_followup_queue_corrupt_recovered/);
306
+ } finally {
307
+ await rm(cwd, { recursive: true, force: true });
308
+ }
309
+ });
310
+
311
+ it("emits exactly one parseable JSON object for active Stop CLI continuation", async () => {
312
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-cli-stop-json-"));
313
+ try {
314
+ await writeActiveAutopilotSession(cwd, "sess-cli-stop-json");
315
+
316
+ const stdout = runNativeHookCli({
317
+ hook_event_name: "Stop",
318
+ cwd,
319
+ session_id: "sess-cli-stop-json",
320
+ thread_id: "thread-cli-stop-json",
321
+ turn_id: "turn-cli-stop-json",
322
+ }, { cwd });
323
+ const output = parseSingleJsonStdout(stdout);
324
+
325
+ assert.equal(output.decision, "block");
326
+ assert.equal(output.stopReason, "autopilot_execution");
327
+ } finally {
328
+ await rm(cwd, { recursive: true, force: true });
329
+ }
330
+ });
331
+
332
+ it("keeps noisy Stop hook plugin stdout out of native Stop CLI stdout", async () => {
333
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-cli-stop-noisy-plugin-"));
334
+ try {
335
+ await writeActiveAutopilotSession(cwd, "sess-cli-stop-noisy-plugin");
336
+ await mkdir(join(cwd, ".omx", "hooks"), { recursive: true });
337
+ await writeFile(
338
+ join(cwd, ".omx", "hooks", "noisy.mjs"),
339
+ `export async function onHookEvent(event) {
340
+ if (event.event === "stop") console.log("PLUGIN_NOISE");
341
+ }
342
+ `,
343
+ "utf-8",
344
+ );
345
+
346
+ const stdout = runNativeHookCli({
347
+ hook_event_name: "Stop",
348
+ cwd,
349
+ session_id: "sess-cli-stop-noisy-plugin",
350
+ thread_id: "thread-cli-stop-noisy-plugin",
351
+ turn_id: "turn-cli-stop-noisy-plugin",
352
+ }, { cwd });
353
+ assert.doesNotMatch(stdout, /PLUGIN_NOISE/);
354
+ const output = parseSingleJsonStdout(stdout);
355
+
356
+ assert.equal(output.decision, "block");
357
+ assert.equal(output.stopReason, "autopilot_execution");
358
+ } finally {
359
+ await rm(cwd, { recursive: true, force: true });
360
+ }
361
+ });
362
+
363
+ it("emits deterministic Stop JSON stdout when Stop dispatch fails", async () => {
364
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-cli-stop-dispatch-failure-"));
365
+ try {
366
+ const stdout = runNativeHookCli({
367
+ hook_event_name: "Stop",
368
+ cwd,
369
+ session_id: "sess-cli-stop-dispatch-failure",
370
+ thread_id: "thread-cli-stop-dispatch-failure",
371
+ turn_id: "turn-cli-stop-dispatch-failure",
372
+ }, {
373
+ cwd,
374
+ env: {
375
+ ...process.env,
376
+ NODE_ENV: "test",
377
+ OMX_NATIVE_HOOK_TEST_THROW_STOP_DISPATCH: "1",
378
+ },
379
+ });
380
+ const output = parseSingleJsonStdout(stdout);
381
+
382
+ assert.equal(output.decision, "block");
383
+ assert.equal(output.stopReason, "native_stop_dispatch_failure");
384
+ assert.match(String(output.reason), /failed before normal continuation handling/);
385
+ assert.match(String(output.systemMessage), /test-induced Stop dispatch failure/);
386
+ } finally {
387
+ await rm(cwd, { recursive: true, force: true });
388
+ }
389
+ });
390
+
222
391
  it("maps Codex events onto OMX logical surfaces", () => {
223
392
  assert.equal(mapCodexHookEventToOmxEvent("SessionStart"), "session-start");
224
393
  assert.equal(mapCodexHookEventToOmxEvent("UserPromptSubmit"), "keyword-detector");
@@ -227,7 +396,7 @@ describe("codex native hook dispatch", () => {
227
396
  assert.equal(mapCodexHookEventToOmxEvent("Stop"), "stop");
228
397
  });
229
398
 
230
- it("writes SessionStart state against the long-lived session owner pid and stays quiet for clean sessions", async () => {
399
+ it("writes SessionStart state against the long-lived session owner pid and injects environment context", async () => {
231
400
  const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-session-start-"));
232
401
  try {
233
402
  const result = await dispatchCodexNativeHook(
@@ -243,7 +412,13 @@ describe("codex native hook dispatch", () => {
243
412
  );
244
413
 
245
414
  assert.equal(result.omxEventName, "session-start");
246
- assert.equal(result.outputJson, null);
415
+ const additionalContext = String(
416
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
417
+ );
418
+ assert.match(additionalContext, /\[Execution environment\]/);
419
+ assert.match(additionalContext, /native-hook \/ Codex App outside tmux/);
420
+ assert.match(additionalContext, /omx team, omx hud, and omx quest(?:ion) need an attached tmux OMX CLI shell|omx team and omx hud need an attached tmux OMX CLI shell/);
421
+ assert.match(additionalContext, /not available from this outside-tmux surface/);
247
422
  const sessionState = JSON.parse(
248
423
  await readFile(join(cwd, ".omx", "state", "session.json"), "utf-8"),
249
424
  ) as { session_id?: string; native_session_id?: string; pid?: number };
@@ -309,6 +484,221 @@ describe("codex native hook dispatch", () => {
309
484
  }
310
485
  });
311
486
 
487
+ it("keeps subagent SessionStart from replacing the canonical leader session", async () => {
488
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-subagent-session-start-"));
489
+ try {
490
+ const stateDir = join(cwd, ".omx", "state");
491
+ const canonicalSessionId = "omx-leader-session";
492
+ const leaderNativeSessionId = "codex-leader-thread";
493
+ const childNativeSessionId = "codex-child-thread";
494
+ await mkdir(join(stateDir, "sessions", canonicalSessionId), { recursive: true });
495
+ await writeSessionStart(cwd, canonicalSessionId, {
496
+ nativeSessionId: leaderNativeSessionId,
497
+ });
498
+ await writeJson(join(stateDir, "sessions", canonicalSessionId, "ralph-state.json"), {
499
+ active: true,
500
+ mode: "ralph",
501
+ current_phase: "executing",
502
+ iteration: 1,
503
+ max_iterations: 5,
504
+ });
505
+ const transcriptPath = join(cwd, "subagent-rollout.jsonl");
506
+ await writeFile(
507
+ transcriptPath,
508
+ `${JSON.stringify({
509
+ type: "session_meta",
510
+ payload: {
511
+ id: childNativeSessionId,
512
+ source: {
513
+ subagent: {
514
+ thread_spawn: {
515
+ parent_thread_id: leaderNativeSessionId,
516
+ depth: 1,
517
+ agent_nickname: "Hegel",
518
+ agent_role: "critic",
519
+ },
520
+ },
521
+ },
522
+ agent_nickname: "Hegel",
523
+ agent_role: "critic",
524
+ },
525
+ })}\n`,
526
+ );
527
+
528
+ const result = await dispatchCodexNativeHook(
529
+ {
530
+ hook_event_name: "SessionStart",
531
+ cwd,
532
+ session_id: childNativeSessionId,
533
+ transcript_path: transcriptPath,
534
+ },
535
+ { cwd, sessionOwnerPid: process.pid },
536
+ );
537
+
538
+ const sessionState = JSON.parse(
539
+ await readFile(join(stateDir, "session.json"), "utf-8"),
540
+ ) as { session_id?: string; native_session_id?: string };
541
+ assert.equal(sessionState.session_id, canonicalSessionId);
542
+ assert.equal(sessionState.native_session_id, leaderNativeSessionId);
543
+ assert.equal(
544
+ existsSync(join(stateDir, "sessions", childNativeSessionId, "ralph-state.json")),
545
+ false,
546
+ );
547
+ assert.ok(result.outputJson);
548
+
549
+ const leaderRalph = JSON.parse(
550
+ await readFile(join(stateDir, "sessions", canonicalSessionId, "ralph-state.json"), "utf-8"),
551
+ ) as { active?: boolean; current_phase?: string };
552
+ assert.equal(leaderRalph.active, true);
553
+ assert.equal(leaderRalph.current_phase, "executing");
554
+
555
+ const tracking = JSON.parse(
556
+ await readFile(join(stateDir, "subagent-tracking.json"), "utf-8"),
557
+ ) as {
558
+ sessions?: Record<string, {
559
+ leader_thread_id?: string;
560
+ threads?: Record<string, { kind?: string; mode?: string }>;
561
+ }>;
562
+ };
563
+ assert.equal(tracking.sessions?.[canonicalSessionId]?.leader_thread_id, leaderNativeSessionId);
564
+ assert.equal(tracking.sessions?.[canonicalSessionId]?.threads?.[childNativeSessionId]?.kind, "subagent");
565
+ assert.equal(tracking.sessions?.[canonicalSessionId]?.threads?.[childNativeSessionId]?.mode, "critic");
566
+ assert.equal(tracking.sessions?.[leaderNativeSessionId]?.leader_thread_id, leaderNativeSessionId);
567
+ assert.equal(tracking.sessions?.[leaderNativeSessionId]?.threads?.[childNativeSessionId]?.kind, "subagent");
568
+ assert.equal(tracking.sessions?.[leaderNativeSessionId]?.threads?.[childNativeSessionId]?.mode, "critic");
569
+ } finally {
570
+ await rm(cwd, { recursive: true, force: true });
571
+ }
572
+ });
573
+
574
+ it("does not attach a subagent SessionStart to an unrelated canonical leader", async () => {
575
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-subagent-session-start-mismatch-"));
576
+ try {
577
+ const stateDir = join(cwd, ".omx", "state");
578
+ const canonicalSessionId = "omx-leader-session-a";
579
+ const leaderNativeSessionId = "codex-leader-thread-a";
580
+ const unrelatedParentNativeSessionId = "codex-leader-thread-b";
581
+ const childNativeSessionId = "codex-child-thread-b";
582
+ await mkdir(join(stateDir, "sessions", canonicalSessionId), { recursive: true });
583
+ await writeSessionStart(cwd, canonicalSessionId, {
584
+ nativeSessionId: leaderNativeSessionId,
585
+ });
586
+ await writeJson(join(stateDir, "sessions", canonicalSessionId, "ralph-state.json"), {
587
+ active: true,
588
+ mode: "ralph",
589
+ current_phase: "executing",
590
+ iteration: 1,
591
+ max_iterations: 5,
592
+ });
593
+ const transcriptPath = join(cwd, "unrelated-subagent-rollout.jsonl");
594
+ await writeFile(
595
+ transcriptPath,
596
+ `${JSON.stringify({
597
+ type: "session_meta",
598
+ payload: {
599
+ id: childNativeSessionId,
600
+ source: {
601
+ subagent: {
602
+ thread_spawn: {
603
+ parent_thread_id: unrelatedParentNativeSessionId,
604
+ depth: 1,
605
+ agent_nickname: "Spinoza",
606
+ agent_role: "critic",
607
+ },
608
+ },
609
+ },
610
+ agent_nickname: "Spinoza",
611
+ agent_role: "critic",
612
+ },
613
+ })}\n`,
614
+ );
615
+
616
+ const result = await dispatchCodexNativeHook(
617
+ {
618
+ hook_event_name: "SessionStart",
619
+ cwd,
620
+ session_id: childNativeSessionId,
621
+ transcript_path: transcriptPath,
622
+ },
623
+ { cwd, sessionOwnerPid: process.pid },
624
+ );
625
+
626
+ const sessionState = JSON.parse(
627
+ await readFile(join(stateDir, "session.json"), "utf-8"),
628
+ ) as { session_id?: string; native_session_id?: string };
629
+ assert.equal(sessionState.session_id, canonicalSessionId);
630
+ assert.equal(sessionState.native_session_id, leaderNativeSessionId);
631
+ assert.equal(existsSync(join(stateDir, "subagent-tracking.json")), false);
632
+ assert.equal(existsSync(join(stateDir, "sessions", childNativeSessionId)), false);
633
+ assert.equal(result.outputJson, null);
634
+
635
+ const leaderRalph = JSON.parse(
636
+ await readFile(join(stateDir, "sessions", canonicalSessionId, "ralph-state.json"), "utf-8"),
637
+ ) as { active?: boolean; current_phase?: string };
638
+ assert.equal(leaderRalph.active, true);
639
+ assert.equal(leaderRalph.current_phase, "executing");
640
+ } finally {
641
+ await rm(cwd, { recursive: true, force: true });
642
+ }
643
+ });
644
+
645
+ it("describes attached tmux runtime in SessionStart context when TMUX is present", async () => {
646
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-session-start-tmux-"));
647
+ process.env.TMUX = "/tmp/tmux-attached";
648
+ process.env.TMUX_PANE = "%11";
649
+ try {
650
+ const result = await dispatchCodexNativeHook(
651
+ {
652
+ hook_event_name: "SessionStart",
653
+ cwd,
654
+ session_id: "sess-start-tmux-1",
655
+ },
656
+ {
657
+ cwd,
658
+ sessionOwnerPid: process.pid,
659
+ },
660
+ );
661
+
662
+ const additionalContext = String(
663
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
664
+ );
665
+ assert.match(additionalContext, /\[Execution environment\]/);
666
+ assert.match(additionalContext, /attached tmux runtime/);
667
+ assert.match(additionalContext, /omx team, omx hud, and omx quest(?:ion) are directly usable in this session/);
668
+ assert.match(additionalContext, /visible renderer available from the current pane/);
669
+ } finally {
670
+ await rm(cwd, { recursive: true, force: true });
671
+ }
672
+ });
673
+
674
+ it("describes direct CLI outside tmux in SessionStart context when the launch source is cli", async () => {
675
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-session-start-cli-"));
676
+ try {
677
+ const result = await dispatchCodexNativeHook(
678
+ {
679
+ hook_event_name: "SessionStart",
680
+ cwd,
681
+ session_id: "sess-start-cli-1",
682
+ source: "cli",
683
+ },
684
+ {
685
+ cwd,
686
+ sessionOwnerPid: process.pid,
687
+ },
688
+ );
689
+
690
+ const additionalContext = String(
691
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
692
+ );
693
+ assert.match(additionalContext, /\[Execution environment\]/);
694
+ assert.match(additionalContext, /direct CLI outside tmux/);
695
+ assert.doesNotMatch(additionalContext, /native-hook \/ Codex App outside tmux/);
696
+ assert.match(additionalContext, /omx team, omx hud, and omx quest(?:ion) need an attached tmux OMX CLI shell|omx team and omx hud need an attached tmux OMX CLI shell/);
697
+ } finally {
698
+ await rm(cwd, { recursive: true, force: true });
699
+ }
700
+ });
701
+
312
702
  it("passes the canonical OMX session id when UserPromptSubmit revives HUD", async () => {
313
703
  const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-hud-session-revive-"));
314
704
  try {
@@ -540,6 +930,8 @@ describe("codex native hook dispatch", () => {
540
930
  const additionalContext = String(
541
931
  (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
542
932
  );
933
+ assert.match(additionalContext, /\[Execution environment\]/);
934
+ assert.match(additionalContext, /native-hook \/ Codex App outside tmux/);
543
935
  assert.match(additionalContext, /\[Priority notes\]/);
544
936
  assert.match(additionalContext, /Preserve durable project guidance/);
545
937
  assert.doesNotMatch(additionalContext, /stale UI rework context snapshot/);
@@ -607,6 +999,35 @@ describe("codex native hook dispatch", () => {
607
999
  }
608
1000
  });
609
1001
 
1002
+ it("records plugin-prefixed keyword activation from UserPromptSubmit payloads", async () => {
1003
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-plugin-prefixed-"));
1004
+ try {
1005
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
1006
+ const result = await dispatchCodexNativeHook(
1007
+ {
1008
+ hook_event_name: "UserPromptSubmit",
1009
+ cwd,
1010
+ session_id: "sess-plugin-1",
1011
+ thread_id: "thread-plugin-1",
1012
+ turn_id: "turn-plugin-1",
1013
+ prompt: "$oh-my-codex:ralplan implement issue #1307",
1014
+ },
1015
+ { cwd },
1016
+ );
1017
+
1018
+ assert.equal(result.omxEventName, "keyword-detector");
1019
+ assert.equal(result.skillState?.skill, "ralplan");
1020
+ const message = String(
1021
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext || "",
1022
+ );
1023
+ assert.match(message, /\$oh-my-codex:ralplan" -> ralplan/);
1024
+ assert.match(message, /skill: ralplan activated and initial state initialized at \.omx\/state\/sessions\/sess-plugin-1\/ralplan-state\.json; write subsequent updates via omx_state MCP\./);
1025
+ assert.equal(existsSync(join(cwd, ".omx", "state", "sessions", "sess-plugin-1", "ralplan-state.json")), true);
1026
+ } finally {
1027
+ await rm(cwd, { recursive: true, force: true });
1028
+ }
1029
+ });
1030
+
610
1031
  it("normalizes the Korean keyboard typo for ulw during UserPromptSubmit activation", async () => {
611
1032
  const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-ulw-ko-"));
612
1033
  try {
@@ -792,6 +1213,35 @@ describe("codex native hook dispatch", () => {
792
1213
  }
793
1214
  });
794
1215
 
1216
+ it("clarifies that plugin-prefixed prompt-side $ralph activation does not invoke the PRD-gated CLI path", async () => {
1217
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-plugin-ralph-routing-"));
1218
+ try {
1219
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
1220
+ const result = await dispatchCodexNativeHook(
1221
+ {
1222
+ hook_event_name: "UserPromptSubmit",
1223
+ cwd,
1224
+ session_id: "sess-plugin-ralph-msg",
1225
+ thread_id: "thread-plugin-ralph-msg",
1226
+ turn_id: "turn-plugin-ralph-msg",
1227
+ prompt: "$oh-my-codex:ralph continue verification",
1228
+ },
1229
+ { cwd },
1230
+ );
1231
+
1232
+ assert.equal(result.omxEventName, "keyword-detector");
1233
+ assert.equal(result.skillState?.skill, "ralph");
1234
+ const message = String(
1235
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext || "",
1236
+ );
1237
+ assert.match(message, /\$oh-my-codex:ralph" -> ralph/);
1238
+ assert.match(message, /skill: ralph activated and initial state initialized at \.omx\/state\/sessions\/sess-plugin-ralph-msg\/ralph-state\.json; write subsequent updates via omx_state MCP\./);
1239
+ assert.match(message, /Prompt-side `\$ralph` activation seeds Ralph workflow state only; it does not invoke `omx ralph`\./);
1240
+ } finally {
1241
+ await rm(cwd, { recursive: true, force: true });
1242
+ }
1243
+ });
1244
+
795
1245
  it("keeps bare keep-going continuation on the active autopilot skill instead of denying with generic ralph overlap", async () => {
796
1246
  const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-autopilot-bare-continuation-"));
797
1247
  try {
@@ -845,7 +1295,7 @@ describe("codex native hook dispatch", () => {
845
1295
  }
846
1296
  });
847
1297
 
848
- it("clarifies that prompt-side deep-interview activation must use omx question", async () => {
1298
+ it("clarifies outside-tmux prompt-side deep-interview activation without pretending omx question is directly available", async () => {
849
1299
  const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-deep-interview-routing-"));
850
1300
  try {
851
1301
  await mkdir(join(cwd, ".omx", "state"), { recursive: true });
@@ -868,42 +1318,30 @@ describe("codex native hook dispatch", () => {
868
1318
  );
869
1319
  assert.match(message, /\$deep-interview" -> deep-interview/);
870
1320
  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\./);
871
- assert.match(message, /Deep-interview must ask each interview round via `omx question`/);
872
- assert.match(message, /do not fall back to `request_user_input` or plain-text questioning/i);
873
- assert.match(message, /After starting `omx question` in a background terminal, wait for that terminal to finish and read the JSON answer before continuing the interview\./);
874
- assert.match(message, /If bare `omx question` is unavailable in this reused session, use the current-session CLI bridge command:/);
875
- assert.match(message, /'.+' '.+dist\/cli\/omx\.js' question/);
1321
+ assert.match(message, /Deep-interview is active, but this session is not attached to tmux/);
1322
+ assert.match(message, /Do not invoke `omx question`, `omx hud`, or `omx team`/);
1323
+ assert.match(message, /native structured question tool when available/);
1324
+ assert.match(message, /ask exactly one concise plain-text question/);
1325
+ assert.match(message, /no tmux question obligation should be created outside tmux/);
876
1326
  assert.doesNotMatch(message, /OMX_QUESTION_RETURN_PANE=/);
877
1327
  assert.doesNotMatch(message, /preserve the leader pane/i);
878
- assert.match(message, /Stop remains blocked while a deep-interview question obligation is pending\./);
879
1328
  } finally {
880
1329
  await rm(cwd, { recursive: true, force: true });
881
1330
  }
882
1331
  });
883
1332
 
884
-
885
- it("includes leader-pane preservation guidance when a pane hint is available", async () => {
886
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-deep-interview-pane-hint-"));
1333
+ it("uses native fallback deep-interview guidance on Windows outside tmux", async () => {
1334
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-deep-interview-routing-win32-"));
1335
+ const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
887
1336
  try {
888
- const sessionId = "sess-deep-interview-pane-hint";
889
- const sessionDir = join(cwd, ".omx", "state", "sessions", sessionId);
890
- await mkdir(sessionDir, { recursive: true });
891
- await writeJson(join(sessionDir, "deep-interview-state.json"), {
892
- active: true,
893
- mode: "deep-interview",
894
- current_phase: "intent-first",
895
- started_at: "2026-04-21T10:00:00.000Z",
896
- updated_at: "2026-04-21T10:00:00.000Z",
897
- tmux_pane_id: "%77",
898
- });
899
-
1337
+ Object.defineProperty(process, "platform", { value: "win32", configurable: true });
900
1338
  const result = await dispatchCodexNativeHook(
901
1339
  {
902
1340
  hook_event_name: "UserPromptSubmit",
903
1341
  cwd,
904
- session_id: sessionId,
905
- thread_id: "thread-deep-interview-pane-hint",
906
- turn_id: "turn-deep-interview-pane-hint",
1342
+ session_id: "sess-deep-interview-msg-win32",
1343
+ thread_id: "thread-deep-interview-msg-win32",
1344
+ turn_id: "turn-deep-interview-msg-win32",
907
1345
  prompt: "$deep-interview gather requirements",
908
1346
  },
909
1347
  { cwd },
@@ -914,10 +1352,99 @@ describe("codex native hook dispatch", () => {
914
1352
  const message = String(
915
1353
  (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext || "",
916
1354
  );
917
- assert.match(message, /OMX_QUESTION_RETURN_PANE='%77'/);
918
- assert.match(message, /preserve the leader pane/i);
919
- assert.match(message, /OMX_QUESTION_RETURN_PANE=%77/);
1355
+ assert.match(message, /Deep-interview is active, but this session is not attached to tmux/);
1356
+ assert.match(message, /native structured question tool when available/);
1357
+ assert.doesNotMatch(message, /OMX_QUESTION_RETURN_PANE=/);
1358
+ assert.doesNotMatch(message, /current-session CLI bridge command/);
920
1359
  } finally {
1360
+ if (originalPlatform) Object.defineProperty(process, "platform", originalPlatform);
1361
+ await rm(cwd, { recursive: true, force: true });
1362
+ }
1363
+ });
1364
+
1365
+
1366
+ it("includes leader-pane preservation guidance when a pane hint is available", async () => {
1367
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-deep-interview-pane-hint-"));
1368
+ try {
1369
+ const sessionId = "sess-deep-interview-pane-hint";
1370
+ const sessionDir = join(cwd, ".omx", "state", "sessions", sessionId);
1371
+ await mkdir(sessionDir, { recursive: true });
1372
+ await writeJson(join(sessionDir, "deep-interview-state.json"), {
1373
+ active: true,
1374
+ mode: "deep-interview",
1375
+ current_phase: "intent-first",
1376
+ started_at: "2026-04-21T10:00:00.000Z",
1377
+ updated_at: "2026-04-21T10:00:00.000Z",
1378
+ tmux_pane_id: "%77",
1379
+ });
1380
+
1381
+ const result = await dispatchCodexNativeHook(
1382
+ {
1383
+ hook_event_name: "UserPromptSubmit",
1384
+ cwd,
1385
+ session_id: sessionId,
1386
+ thread_id: "thread-deep-interview-pane-hint",
1387
+ turn_id: "turn-deep-interview-pane-hint",
1388
+ prompt: "$deep-interview gather requirements",
1389
+ },
1390
+ { cwd },
1391
+ );
1392
+
1393
+ assert.equal(result.omxEventName, "keyword-detector");
1394
+ assert.equal(result.skillState?.skill, "deep-interview");
1395
+ const message = String(
1396
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext || "",
1397
+ );
1398
+ assert.match(message, /not attached to tmux/);
1399
+ assert.match(message, /native structured question tool when available/);
1400
+ assert.match(message, /tmux return bridge \(%77\) is recorded/);
1401
+ assert.doesNotMatch(message, /current-session CLI bridge command/);
1402
+ } finally {
1403
+ await rm(cwd, { recursive: true, force: true });
1404
+ }
1405
+ });
1406
+
1407
+ it("uses native fallback guidance on Windows when a pane hint is available", async () => {
1408
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-deep-interview-pane-hint-win32-"));
1409
+ const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
1410
+ try {
1411
+ Object.defineProperty(process, "platform", { value: "win32", configurable: true });
1412
+ const sessionId = "sess-deep-interview-pane-hint-win32";
1413
+ const sessionDir = join(cwd, ".omx", "state", "sessions", sessionId);
1414
+ await mkdir(sessionDir, { recursive: true });
1415
+ await writeJson(join(sessionDir, "deep-interview-state.json"), {
1416
+ active: true,
1417
+ mode: "deep-interview",
1418
+ current_phase: "intent-first",
1419
+ started_at: "2026-04-21T10:00:00.000Z",
1420
+ updated_at: "2026-04-21T10:00:00.000Z",
1421
+ tmux_pane_id: "%77",
1422
+ });
1423
+
1424
+ const result = await dispatchCodexNativeHook(
1425
+ {
1426
+ hook_event_name: "UserPromptSubmit",
1427
+ cwd,
1428
+ session_id: sessionId,
1429
+ thread_id: "thread-deep-interview-pane-hint-win32",
1430
+ turn_id: "turn-deep-interview-pane-hint-win32",
1431
+ prompt: "$deep-interview gather requirements",
1432
+ },
1433
+ { cwd },
1434
+ );
1435
+
1436
+ assert.equal(result.omxEventName, "keyword-detector");
1437
+ assert.equal(result.skillState?.skill, "deep-interview");
1438
+ const message = String(
1439
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext || "",
1440
+ );
1441
+ assert.match(message, /not attached to tmux/);
1442
+ assert.match(message, /native structured question tool when available/);
1443
+ assert.match(message, /tmux return bridge \(%77\) is recorded/);
1444
+ assert.doesNotMatch(message, /OMX_QUESTION_RETURN_PANE=/);
1445
+ assert.doesNotMatch(message, /PowerShell\/background-terminal/);
1446
+ } finally {
1447
+ if (originalPlatform) Object.defineProperty(process, "platform", originalPlatform);
921
1448
  await rm(cwd, { recursive: true, force: true });
922
1449
  }
923
1450
  });
@@ -1090,14 +1617,40 @@ export async function onHookEvent(event) {
1090
1617
  }
1091
1618
  });
1092
1619
 
1093
- it("nudges $team prompt-submit routing toward omx team runtime usage", async () => {
1094
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-team-"));
1620
+ it("does not emit UserPromptSubmit routing context for unknown plugin-prefixed $tokens", async () => {
1621
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-unknown-plugin-token-"));
1622
+ try {
1623
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
1624
+ const result = await dispatchCodexNativeHook(
1625
+ {
1626
+ hook_event_name: "UserPromptSubmit",
1627
+ cwd,
1628
+ session_id: "sess-unknown-plugin-1",
1629
+ thread_id: "thread-unknown-plugin-1",
1630
+ turn_id: "turn-unknown-plugin-1",
1631
+ prompt: "$oh-my-codex:maer-thinking 다시 설명해봐",
1632
+ },
1633
+ { cwd },
1634
+ );
1635
+
1636
+ assert.equal(result.omxEventName, "keyword-detector");
1637
+ assert.equal(result.skillState, null);
1638
+ assert.equal(result.outputJson, null);
1639
+ assert.equal(existsSync(join(cwd, ".omx", "state", "skill-active-state.json")), false);
1640
+ } finally {
1641
+ await rm(cwd, { recursive: true, force: true });
1642
+ }
1643
+ });
1644
+
1645
+ it("denies direct $team prompt activation from Codex App/native outside tmux", async () => {
1646
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-team-native-block-"));
1095
1647
  try {
1096
1648
  await mkdir(join(cwd, ".omx", "state"), { recursive: true });
1097
1649
  const result = await dispatchCodexNativeHook(
1098
1650
  {
1099
1651
  hook_event_name: "UserPromptSubmit",
1100
1652
  cwd,
1653
+ source: "codex-app",
1101
1654
  session_id: "sess-team-1",
1102
1655
  thread_id: "thread-team-1",
1103
1656
  turn_id: "turn-team-1",
@@ -1108,19 +1661,105 @@ export async function onHookEvent(event) {
1108
1661
 
1109
1662
  assert.equal(result.omxEventName, "keyword-detector");
1110
1663
  assert.equal(result.skillState?.skill, "team");
1111
- assert.match(
1112
- JSON.stringify(result.outputJson),
1113
- /skill: team activated and initial state initialized at \.omx\/state\/team-state\.json; write subsequent updates via omx_state MCP\./,
1664
+ assert.equal(result.skillState?.active, false);
1665
+ assert.match(String(result.skillState?.transition_error || ""), /cannot activate the tmux-only `team` workflow directly/);
1666
+ const message = String(
1667
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } } | null)?.hookSpecificOutput?.additionalContext ?? "",
1668
+ );
1669
+ assert.match(message, /denied workflow keyword "\$team" -> team/);
1670
+ assert.match(message, /attached tmux shell first/);
1671
+ assert.equal(existsSync(join(cwd, ".omx", "state", "team-state.json")), false);
1672
+ } finally {
1673
+ await rm(cwd, { recursive: true, force: true });
1674
+ }
1675
+ });
1676
+
1677
+ it("still denies direct $team prompt activation from Codex App/native outside tmux when a tmux return bridge exists", async () => {
1678
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-team-native-bridge-block-"));
1679
+ try {
1680
+ await mkdir(join(cwd, ".omx", "state", "sessions", "sess-team-bridge"), { recursive: true });
1681
+ await writeJson(join(cwd, ".omx", "state", "sessions", "sess-team-bridge", "ralph-state.json"), {
1682
+ mode: "ralph",
1683
+ active: true,
1684
+ tmux_pane_id: "%42",
1685
+ });
1686
+ const result = await dispatchCodexNativeHook(
1687
+ {
1688
+ hook_event_name: "UserPromptSubmit",
1689
+ cwd,
1690
+ source: "codex-app",
1691
+ session_id: "sess-team-bridge",
1692
+ thread_id: "thread-team-bridge",
1693
+ turn_id: "turn-team-bridge",
1694
+ prompt: "$team ship this fix with verification",
1695
+ },
1696
+ { cwd },
1114
1697
  );
1115
- assert.match(JSON.stringify(result.outputJson), /Use the durable OMX team runtime via `omx team \.\.\.`/);
1116
- assert.match(JSON.stringify(result.outputJson), /If you need runtime syntax, run `omx team --help` yourself\./);
1117
1698
 
1118
- const state = JSON.parse(
1119
- await readFile(join(cwd, ".omx", "state", "team-state.json"), "utf-8"),
1120
- ) as { mode?: string; active?: boolean; current_phase?: string };
1121
- assert.equal(state.mode, "team");
1122
- assert.equal(state.active, true);
1123
- assert.equal(state.current_phase, "starting");
1699
+ assert.equal(result.omxEventName, "keyword-detector");
1700
+ assert.equal(result.skillState?.skill, "team");
1701
+ assert.equal(result.skillState?.active, false);
1702
+ const message = String(
1703
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } } | null)?.hookSpecificOutput?.additionalContext ?? "",
1704
+ );
1705
+ assert.match(message, /attached tmux shell first/);
1706
+ assert.equal(existsSync(join(cwd, ".omx", "state", "team-state.json")), false);
1707
+ } finally {
1708
+ await rm(cwd, { recursive: true, force: true });
1709
+ }
1710
+ });
1711
+
1712
+ it("keeps direct CLI outside-tmux $team prompt guidance compatible with manual shell launch", async () => {
1713
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-team-cli-guidance-"));
1714
+ try {
1715
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
1716
+ const result = await dispatchCodexNativeHook(
1717
+ {
1718
+ hook_event_name: "UserPromptSubmit",
1719
+ cwd,
1720
+ source: "cli",
1721
+ session_id: "sess-team-cli-guidance",
1722
+ thread_id: "thread-team-cli-guidance",
1723
+ turn_id: "turn-team-cli-guidance",
1724
+ prompt: "$team ship this fix with verification",
1725
+ },
1726
+ { cwd },
1727
+ );
1728
+
1729
+ const message = String(
1730
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } } | null)?.hookSpecificOutput?.additionalContext ?? "",
1731
+ );
1732
+ assert.match(message, /run `omx team \.\.\.` yourself from shell/);
1733
+ assert.doesNotMatch(message, /not directly available here/);
1734
+ } finally {
1735
+ await rm(cwd, { recursive: true, force: true });
1736
+ }
1737
+ });
1738
+
1739
+ it("keeps $team prompt-submit routing directly tmux-capable when already inside tmux", async () => {
1740
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-team-tmux-"));
1741
+ process.env.TMUX = "/tmp/tmux-live";
1742
+ process.env.TMUX_PANE = "%5";
1743
+ try {
1744
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
1745
+ const result = await dispatchCodexNativeHook(
1746
+ {
1747
+ hook_event_name: "UserPromptSubmit",
1748
+ cwd,
1749
+ session_id: "sess-team-tmux-1",
1750
+ thread_id: "thread-team-tmux-1",
1751
+ turn_id: "turn-team-tmux-1",
1752
+ prompt: "$team ship this fix with verification",
1753
+ },
1754
+ { cwd },
1755
+ );
1756
+
1757
+ const message = String(
1758
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
1759
+ );
1760
+ assert.match(message, /Use the durable OMX team runtime via `omx team \.\.\.`/);
1761
+ assert.match(message, /run `omx team --help` yourself/);
1762
+ assert.doesNotMatch(message, /not directly available here/);
1124
1763
  } finally {
1125
1764
  await rm(cwd, { recursive: true, force: true });
1126
1765
  }
@@ -1241,6 +1880,37 @@ export async function onHookEvent(event) {
1241
1880
  }
1242
1881
  });
1243
1882
 
1883
+ it("keeps the planning skill active for mixed plugin-prefixed and bare workflow invocations together", async () => {
1884
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-plugin-planning-precedence-"));
1885
+ try {
1886
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
1887
+
1888
+ const result = await dispatchCodexNativeHook(
1889
+ {
1890
+ hook_event_name: "UserPromptSubmit",
1891
+ cwd,
1892
+ session_id: "sess-plugin-multi-1",
1893
+ thread_id: "thread-plugin-multi-1",
1894
+ turn_id: "turn-plugin-multi-1",
1895
+ prompt: "$oh-my-codex:ralplan $team $oh-my-codex:ralph ship this fix",
1896
+ },
1897
+ { cwd },
1898
+ );
1899
+
1900
+ const message = String(
1901
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext || '',
1902
+ );
1903
+ assert.match(message, /\$oh-my-codex:ralplan" -> ralplan/);
1904
+ assert.match(message, /\$team" -> team/);
1905
+ assert.match(message, /\$oh-my-codex:ralph" -> ralph/);
1906
+ assert.doesNotMatch(message, /mode transiting:/);
1907
+ assert.match(message, /planning preserved over simultaneous execution follow-up; deferred skills: team, ralph\./);
1908
+ assert.match(message, /skill: ralplan activated and initial state initialized at \.omx\/state\/sessions\/sess-plugin-multi-1\/ralplan-state\.json; write subsequent updates via omx_state MCP\./);
1909
+ } finally {
1910
+ await rm(cwd, { recursive: true, force: true });
1911
+ }
1912
+ });
1913
+
1244
1914
  it("runs prompt-submit HUD reconciliation as a best-effort tmux-only side effect", async () => {
1245
1915
  const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-hud-reconcile-"));
1246
1916
  const originalTmux = process.env.TMUX;
@@ -1380,154 +2050,451 @@ esac
1380
2050
  }
1381
2051
  });
1382
2052
 
1383
- it("blocks Bash node omx.js question when the command does not preserve the leader-pane return hint", async () => {
1384
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-pretool-question-node-block-"));
2053
+ it("allows PowerShell env bridge forms for omx question return panes", async () => {
2054
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-pretool-question-powershell-allow-"));
2055
+ try {
2056
+ const commands = [
2057
+ `$env:OMX_QUESTION_RETURN_PANE=$env:TMUX_PANE; omx question --json --input '{"question":"Q?","options":["A"],"allow_other":true}'`,
2058
+ `$env:OMX_QUESTION_RETURN_PANE='%42'; node ./dist/cli/omx.js question --json --input '{"question":"Q?","options":["A"],"allow_other":true}'`,
2059
+ `$env:OMX_LEADER_PANE_ID="%43"; omx question --json --input '{"question":"Q?","options":["A"],"allow_other":true}'`,
2060
+ ];
2061
+
2062
+ for (const [index, command] of commands.entries()) {
2063
+ const result = await dispatchCodexNativeHook(
2064
+ {
2065
+ hook_event_name: "PreToolUse",
2066
+ cwd,
2067
+ tool_name: "Bash",
2068
+ tool_use_id: `tool-question-powershell-allow-${index}`,
2069
+ tool_input: { command },
2070
+ },
2071
+ { cwd },
2072
+ );
2073
+
2074
+ assert.equal(result.omxEventName, "pre-tool-use");
2075
+ assert.equal(result.outputJson, null);
2076
+ }
2077
+ } finally {
2078
+ await rm(cwd, { recursive: true, force: true });
2079
+ }
2080
+ });
2081
+
2082
+ it("allows Bash omx question when a valid inherited OMX_QUESTION_RETURN_PANE bridge is already exported", async () => {
2083
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-pretool-question-env-allow-"));
2084
+ const originalReturnPane = process.env.OMX_QUESTION_RETURN_PANE;
1385
2085
  try {
2086
+ process.env.OMX_QUESTION_RETURN_PANE = "%42";
1386
2087
  const result = await dispatchCodexNativeHook(
1387
2088
  {
1388
2089
  hook_event_name: "PreToolUse",
1389
2090
  cwd,
1390
2091
  tool_name: "Bash",
1391
- tool_use_id: "tool-question-node-block",
1392
- tool_input: { command: `node ./dist/cli/omx.js question --json --input '{"question":"Q?","options":["A"],"allow_other":true}'` },
2092
+ tool_use_id: "tool-question-env-allow",
2093
+ tool_input: { command: `omx question --json --input '{"question":"Q?","options":["A"],"allow_other":true}'` },
1393
2094
  },
1394
2095
  { cwd },
1395
2096
  );
1396
2097
 
1397
2098
  assert.equal(result.omxEventName, "pre-tool-use");
1398
- assert.equal((result.outputJson as { decision?: string } | null)?.decision, "block");
2099
+ assert.equal(result.outputJson, null);
1399
2100
  } finally {
2101
+ if (originalReturnPane === undefined) delete process.env.OMX_QUESTION_RETURN_PANE;
2102
+ else process.env.OMX_QUESTION_RETURN_PANE = originalReturnPane;
1400
2103
  await rm(cwd, { recursive: true, force: true });
1401
2104
  }
1402
2105
  });
1403
2106
 
1404
- it("returns a destructive-command caution on PreToolUse for rm -rf dist", async () => {
1405
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-pretool-danger-"));
2107
+ it("allows Bash omx question when a valid inherited OMX_LEADER_PANE_ID bridge is already exported", async () => {
2108
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-pretool-question-leader-env-allow-"));
2109
+ const originalLeaderPane = process.env.OMX_LEADER_PANE_ID;
1406
2110
  try {
2111
+ process.env.OMX_LEADER_PANE_ID = "%43";
1407
2112
  const result = await dispatchCodexNativeHook(
1408
2113
  {
1409
2114
  hook_event_name: "PreToolUse",
1410
2115
  cwd,
1411
2116
  tool_name: "Bash",
1412
- tool_use_id: "tool-danger",
1413
- tool_input: { command: "rm -rf dist" },
2117
+ tool_use_id: "tool-question-leader-env-allow",
2118
+ tool_input: { command: `omx question --json --input '{"question":"Q?","options":["A"],"allow_other":true}'` },
1414
2119
  },
1415
2120
  { cwd },
1416
2121
  );
1417
2122
 
1418
2123
  assert.equal(result.omxEventName, "pre-tool-use");
1419
- assert.deepEqual(result.outputJson, {
1420
- hookSpecificOutput: {
1421
- hookEventName: "PreToolUse",
1422
- },
1423
- systemMessage:
1424
- "Destructive Bash command detected (`rm -rf dist`). Confirm the target and expected side effects before running it.",
1425
- });
2124
+ assert.equal(result.outputJson, null);
1426
2125
  } finally {
2126
+ if (originalLeaderPane === undefined) delete process.env.OMX_LEADER_PANE_ID;
2127
+ else process.env.OMX_LEADER_PANE_ID = originalLeaderPane;
1427
2128
  await rm(cwd, { recursive: true, force: true });
1428
2129
  }
1429
2130
  });
1430
2131
 
1431
- it("stays silent on PreToolUse for neutral pwd", async () => {
1432
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-pretool-neutral-"));
2132
+ it("still blocks Bash omx question when an inherited OMX_QUESTION_RETURN_PANE value is malformed", async () => {
2133
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-pretool-question-env-malformed-"));
2134
+ const originalReturnPane = process.env.OMX_QUESTION_RETURN_PANE;
1433
2135
  try {
2136
+ process.env.OMX_QUESTION_RETURN_PANE = "not-a-pane";
1434
2137
  const result = await dispatchCodexNativeHook(
1435
2138
  {
1436
2139
  hook_event_name: "PreToolUse",
1437
2140
  cwd,
1438
2141
  tool_name: "Bash",
1439
- tool_use_id: "tool-neutral",
1440
- tool_input: { command: "pwd" },
2142
+ tool_use_id: "tool-question-env-malformed",
2143
+ tool_input: { command: `omx question --json --input '{"question":"Q?","options":["A"],"allow_other":true}'` },
1441
2144
  },
1442
2145
  { cwd },
1443
2146
  );
1444
2147
 
1445
2148
  assert.equal(result.omxEventName, "pre-tool-use");
1446
- assert.equal(result.outputJson, null);
2149
+ assert.equal((result.outputJson as { decision?: string } | null)?.decision, "block");
1447
2150
  } finally {
2151
+ if (originalReturnPane === undefined) delete process.env.OMX_QUESTION_RETURN_PANE;
2152
+ else process.env.OMX_QUESTION_RETURN_PANE = originalReturnPane;
1448
2153
  await rm(cwd, { recursive: true, force: true });
1449
2154
  }
1450
2155
  });
1451
2156
 
1452
- it("blocks PreToolUse git commit when the inline message is not Lore-compliant", async () => {
1453
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-pretool-git-commit-invalid-"));
2157
+ it("blocks Bash node omx.js question when the command does not preserve the leader-pane return hint", async () => {
2158
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-pretool-question-node-block-"));
1454
2159
  try {
1455
2160
  const result = await dispatchCodexNativeHook(
1456
2161
  {
1457
2162
  hook_event_name: "PreToolUse",
1458
2163
  cwd,
1459
2164
  tool_name: "Bash",
1460
- tool_use_id: "tool-git-commit-invalid",
1461
- tool_input: { command: 'git commit -m "fix tests"' },
2165
+ tool_use_id: "tool-question-node-block",
2166
+ tool_input: { command: `node ./dist/cli/omx.js question --json --input '{"question":"Q?","options":["A"],"allow_other":true}'` },
1462
2167
  },
1463
2168
  { cwd },
1464
2169
  );
1465
2170
 
1466
2171
  assert.equal(result.omxEventName, "pre-tool-use");
1467
- assert.deepEqual(result.outputJson, {
1468
- decision: "block",
1469
- reason:
1470
- "git commit is blocked until the inline commit message satisfies the Lore format and includes the required OmX co-author trailer.",
1471
- hookSpecificOutput: {
1472
- hookEventName: "PreToolUse",
1473
- additionalContext: [
1474
- "Lore-format git commit enforcement triggered.",
1475
- "- Add a blank line after the subject before the narrative body.",
1476
- "- Add a narrative body paragraph explaining the decision context.",
1477
- "- Add at least one Lore trailer such as `Constraint:`, `Confidence:`, or `Tested:`.",
1478
- "- Add the required co-author trailer: `Co-authored-by: OmX <omx@oh-my-codex.dev>`.",
1479
- ].join("\n"),
1480
- },
1481
- systemMessage: [
1482
- "git commit is blocked until the inline commit message follows the Lore protocol and includes `Co-authored-by: OmX <omx@oh-my-codex.dev>`.",
1483
- "- Add a blank line after the subject before the narrative body.",
1484
- "- Add a narrative body paragraph explaining the decision context.",
1485
- "- Add at least one Lore trailer such as `Constraint:`, `Confidence:`, or `Tested:`.",
1486
- "- Add the required co-author trailer: `Co-authored-by: OmX <omx@oh-my-codex.dev>`.",
1487
- ].join("\n"),
1488
- });
2172
+ assert.equal((result.outputJson as { decision?: string } | null)?.decision, "block");
1489
2173
  } finally {
1490
2174
  await rm(cwd, { recursive: true, force: true });
1491
2175
  }
1492
2176
  });
1493
2177
 
1494
- it("stays silent on PreToolUse for `git help commit`", async () => {
1495
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-pretool-git-help-commit-"));
2178
+ it("blocks native/App Bash omx question with bridge-specific outside-tmux guidance", async () => {
2179
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-pretool-question-native-block-"));
1496
2180
  try {
1497
2181
  const result = await dispatchCodexNativeHook(
1498
2182
  {
1499
2183
  hook_event_name: "PreToolUse",
1500
2184
  cwd,
2185
+ source: "codex-app",
2186
+ session_id: "sess-question-native-block",
1501
2187
  tool_name: "Bash",
1502
- tool_use_id: "tool-git-help-commit",
1503
- tool_input: { command: "git help commit" },
2188
+ tool_use_id: "tool-question-native-block",
2189
+ tool_input: { command: `omx question --json --input '{"question":"Q?","options":["A"],"allow_other":true}'` },
1504
2190
  },
1505
2191
  { cwd },
1506
2192
  );
1507
2193
 
1508
2194
  assert.equal(result.omxEventName, "pre-tool-use");
1509
- assert.equal(result.outputJson, null);
2195
+ assert.equal((result.outputJson as { decision?: string } | null)?.decision, "block");
2196
+ assert.equal((result.outputJson as { hookSpecificOutput?: unknown } | null)?.hookSpecificOutput, undefined);
2197
+ assert.match(String((result.outputJson as { reason?: string } | null)?.reason || ""), /Codex App\/native outside-tmux Bash sessions/);
2198
+ assert.match(String((result.outputJson as { systemMessage?: string } | null)?.systemMessage || ""), /native structured question tool/);
1510
2199
  } finally {
1511
2200
  await rm(cwd, { recursive: true, force: true });
1512
2201
  }
1513
2202
  });
1514
2203
 
1515
- it("stays silent on PreToolUse for `git config alias.ci commit`", async () => {
1516
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-pretool-git-config-alias-commit-"));
2204
+ it("blocks native/App Bash omx question even when the command preserves a tmux return bridge", async () => {
2205
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-pretool-question-native-allow-"));
1517
2206
  try {
1518
2207
  const result = await dispatchCodexNativeHook(
1519
2208
  {
1520
2209
  hook_event_name: "PreToolUse",
1521
2210
  cwd,
2211
+ source: "codex-app",
2212
+ session_id: "sess-question-native-bridge-block",
1522
2213
  tool_name: "Bash",
1523
- tool_use_id: "tool-git-config-alias-commit",
1524
- tool_input: { command: "git config alias.ci commit" },
2214
+ tool_use_id: "tool-question-native-bridge-block",
2215
+ tool_input: { command: `OMX_QUESTION_RETURN_PANE=$TMUX_PANE omx question --json --input '{"question":"Q?","options":["A"],"allow_other":true}'` },
1525
2216
  },
1526
2217
  { cwd },
1527
2218
  );
1528
2219
 
1529
2220
  assert.equal(result.omxEventName, "pre-tool-use");
1530
- assert.equal(result.outputJson, null);
2221
+ assert.equal((result.outputJson as { decision?: string } | null)?.decision, "block");
2222
+ assert.match(String((result.outputJson as { systemMessage?: string } | null)?.systemMessage || ""), /native structured question tool/);
2223
+ } finally {
2224
+ await rm(cwd, { recursive: true, force: true });
2225
+ }
2226
+ });
2227
+
2228
+ it("blocks native/App Bash omx question when a valid inherited OMX_QUESTION_RETURN_PANE bridge is already exported", async () => {
2229
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-pretool-question-native-env-allow-"));
2230
+ const originalReturnPane = process.env.OMX_QUESTION_RETURN_PANE;
2231
+ try {
2232
+ process.env.OMX_QUESTION_RETURN_PANE = "%42";
2233
+ const result = await dispatchCodexNativeHook(
2234
+ {
2235
+ hook_event_name: "PreToolUse",
2236
+ cwd,
2237
+ source: "codex-app",
2238
+ session_id: "sess-question-native-env-allow",
2239
+ tool_name: "Bash",
2240
+ tool_use_id: "tool-question-native-env-allow",
2241
+ tool_input: { command: `omx question --json --input '{"question":"Q?","options":["A"],"allow_other":true}'` },
2242
+ },
2243
+ { cwd },
2244
+ );
2245
+
2246
+ assert.equal(result.omxEventName, "pre-tool-use");
2247
+ assert.equal((result.outputJson as { decision?: string } | null)?.decision, "block");
2248
+ } finally {
2249
+ if (originalReturnPane === undefined) delete process.env.OMX_QUESTION_RETURN_PANE;
2250
+ else process.env.OMX_QUESTION_RETURN_PANE = originalReturnPane;
2251
+ await rm(cwd, { recursive: true, force: true });
2252
+ }
2253
+ });
2254
+
2255
+ it("blocks Bash omx hud from Codex App/native outside tmux without PreToolUse additionalContext", async () => {
2256
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-pretool-hud-native-block-"));
2257
+ try {
2258
+ const result = await dispatchCodexNativeHook(
2259
+ {
2260
+ hook_event_name: "PreToolUse",
2261
+ cwd,
2262
+ source: "codex-app",
2263
+ session_id: "sess-hud-native-block",
2264
+ tool_name: "Bash",
2265
+ tool_use_id: "tool-hud-native-block",
2266
+ tool_input: { command: "omx hud --tmux" },
2267
+ },
2268
+ { cwd },
2269
+ );
2270
+
2271
+ assert.equal(result.omxEventName, "pre-tool-use");
2272
+ assert.equal((result.outputJson as { decision?: string } | null)?.decision, "block");
2273
+ assert.equal((result.outputJson as { hookSpecificOutput?: unknown } | null)?.hookSpecificOutput, undefined);
2274
+ assert.match(String((result.outputJson as { systemMessage?: string } | null)?.systemMessage || ""), /attached tmux shell first/);
2275
+ } finally {
2276
+ await rm(cwd, { recursive: true, force: true });
2277
+ }
2278
+ });
2279
+
2280
+ it("blocks Bash omx team from Codex App/native outside tmux", async () => {
2281
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-pretool-team-native-block-"));
2282
+ try {
2283
+ const result = await dispatchCodexNativeHook(
2284
+ {
2285
+ hook_event_name: "PreToolUse",
2286
+ cwd,
2287
+ source: "codex-app",
2288
+ session_id: "sess-team-native-block",
2289
+ tool_name: "Bash",
2290
+ tool_use_id: "tool-team-native-block",
2291
+ tool_input: { command: "omx team status my-team" },
2292
+ },
2293
+ { cwd },
2294
+ );
2295
+
2296
+ assert.equal(result.omxEventName, "pre-tool-use");
2297
+ assert.equal((result.outputJson as { decision?: string } | null)?.decision, "block");
2298
+ assert.equal((result.outputJson as { hookSpecificOutput?: unknown } | null)?.hookSpecificOutput, undefined);
2299
+ assert.match(String((result.outputJson as { reason?: string } | null)?.reason || ""), /cannot be launched directly from Codex App\/native outside-tmux Bash sessions/);
2300
+ assert.match(String((result.outputJson as { systemMessage?: string } | null)?.systemMessage || ""), /launch OMX CLI from an attached tmux shell first/);
2301
+ } finally {
2302
+ await rm(cwd, { recursive: true, force: true });
2303
+ }
2304
+ });
2305
+
2306
+ it("blocks Bash node omx.js team from Codex App/native outside tmux", async () => {
2307
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-pretool-team-node-native-block-"));
2308
+ try {
2309
+ const result = await dispatchCodexNativeHook(
2310
+ {
2311
+ hook_event_name: "PreToolUse",
2312
+ cwd,
2313
+ source: "codex-app",
2314
+ session_id: "sess-team-node-native-block",
2315
+ tool_name: "Bash",
2316
+ tool_use_id: "tool-team-node-native-block",
2317
+ tool_input: { command: "node ./dist/cli/omx.js team status my-team" },
2318
+ },
2319
+ { cwd },
2320
+ );
2321
+
2322
+ assert.equal(result.omxEventName, "pre-tool-use");
2323
+ assert.equal((result.outputJson as { decision?: string } | null)?.decision, "block");
2324
+ assert.match(String((result.outputJson as { systemMessage?: string } | null)?.systemMessage || ""), /Codex App\/native outside-tmux sessions/);
2325
+ } finally {
2326
+ await rm(cwd, { recursive: true, force: true });
2327
+ }
2328
+ });
2329
+
2330
+ it("preserves direct CLI outside-tmux omx team Bash behavior", async () => {
2331
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-pretool-team-cli-outside-"));
2332
+ try {
2333
+ const result = await dispatchCodexNativeHook(
2334
+ {
2335
+ hook_event_name: "PreToolUse",
2336
+ cwd,
2337
+ source: "cli",
2338
+ session_id: "sess-team-cli-outside",
2339
+ tool_name: "Bash",
2340
+ tool_use_id: "tool-team-cli-outside",
2341
+ tool_input: { command: "omx team status my-team" },
2342
+ },
2343
+ { cwd },
2344
+ );
2345
+
2346
+ assert.equal(result.omxEventName, "pre-tool-use");
2347
+ assert.equal(result.outputJson, null);
2348
+ } finally {
2349
+ await rm(cwd, { recursive: true, force: true });
2350
+ }
2351
+ });
2352
+
2353
+ it("preserves source-less outside-tmux omx team Bash behavior when no native session evidence exists", async () => {
2354
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-pretool-team-cli-nosource-"));
2355
+ try {
2356
+ const result = await dispatchCodexNativeHook(
2357
+ {
2358
+ hook_event_name: "PreToolUse",
2359
+ cwd,
2360
+ session_id: "sess-team-cli-nosource",
2361
+ tool_name: "Bash",
2362
+ tool_use_id: "tool-team-cli-nosource",
2363
+ tool_input: { command: "omx team status my-team" },
2364
+ },
2365
+ { cwd },
2366
+ );
2367
+
2368
+ assert.equal(result.omxEventName, "pre-tool-use");
2369
+ assert.equal(result.outputJson, null);
2370
+ } finally {
2371
+ await rm(cwd, { recursive: true, force: true });
2372
+ }
2373
+ });
2374
+
2375
+ it("returns a destructive-command caution on PreToolUse for rm -rf dist", async () => {
2376
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-pretool-danger-"));
2377
+ try {
2378
+ const result = await dispatchCodexNativeHook(
2379
+ {
2380
+ hook_event_name: "PreToolUse",
2381
+ cwd,
2382
+ tool_name: "Bash",
2383
+ tool_use_id: "tool-danger",
2384
+ tool_input: { command: "rm -rf dist" },
2385
+ },
2386
+ { cwd },
2387
+ );
2388
+
2389
+ assert.equal(result.omxEventName, "pre-tool-use");
2390
+ assert.deepEqual(result.outputJson, {
2391
+ hookSpecificOutput: {
2392
+ hookEventName: "PreToolUse",
2393
+ },
2394
+ systemMessage:
2395
+ "Destructive Bash command detected (`rm -rf dist`). Confirm the target and expected side effects before running it.",
2396
+ });
2397
+ } finally {
2398
+ await rm(cwd, { recursive: true, force: true });
2399
+ }
2400
+ });
2401
+
2402
+ it("stays silent on PreToolUse for neutral pwd", async () => {
2403
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-pretool-neutral-"));
2404
+ try {
2405
+ const result = await dispatchCodexNativeHook(
2406
+ {
2407
+ hook_event_name: "PreToolUse",
2408
+ cwd,
2409
+ tool_name: "Bash",
2410
+ tool_use_id: "tool-neutral",
2411
+ tool_input: { command: "pwd" },
2412
+ },
2413
+ { cwd },
2414
+ );
2415
+
2416
+ assert.equal(result.omxEventName, "pre-tool-use");
2417
+ assert.equal(result.outputJson, null);
2418
+ } finally {
2419
+ await rm(cwd, { recursive: true, force: true });
2420
+ }
2421
+ });
2422
+
2423
+ it("blocks PreToolUse git commit with supported response shape when the inline message is not Lore-compliant", async () => {
2424
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-pretool-git-commit-invalid-"));
2425
+ try {
2426
+ const result = await dispatchCodexNativeHook(
2427
+ {
2428
+ hook_event_name: "PreToolUse",
2429
+ cwd,
2430
+ tool_name: "Bash",
2431
+ tool_use_id: "tool-git-commit-invalid",
2432
+ tool_input: { command: 'git commit -m "fix tests"' },
2433
+ },
2434
+ { cwd },
2435
+ );
2436
+
2437
+ assert.equal(result.omxEventName, "pre-tool-use");
2438
+ assert.deepEqual(result.outputJson, {
2439
+ decision: "block",
2440
+ reason:
2441
+ "git commit is blocked until the inline commit message satisfies the Lore format and includes the required OmX co-author trailer.",
2442
+ hookSpecificOutput: {
2443
+ hookEventName: "PreToolUse",
2444
+ },
2445
+ systemMessage: [
2446
+ "git commit is blocked until the inline commit message follows the Lore protocol and includes `Co-authored-by: OmX <omx@oh-my-codex.dev>`.",
2447
+ "- Add a blank line after the subject before the narrative body.",
2448
+ "- Add a narrative body paragraph explaining the decision context.",
2449
+ "- Add at least one Lore trailer such as `Constraint:`, `Confidence:`, or `Tested:`.",
2450
+ "- Add the required co-author trailer: `Co-authored-by: OmX <omx@oh-my-codex.dev>`.",
2451
+ ].join("\n"),
2452
+ });
2453
+ const hookSpecificOutput = (result.outputJson as { hookSpecificOutput?: Record<string, unknown> })
2454
+ .hookSpecificOutput ?? {};
2455
+ assert.equal("additionalContext" in hookSpecificOutput, false);
2456
+ } finally {
2457
+ await rm(cwd, { recursive: true, force: true });
2458
+ }
2459
+ });
2460
+
2461
+ it("stays silent on PreToolUse for `git help commit`", async () => {
2462
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-pretool-git-help-commit-"));
2463
+ try {
2464
+ const result = await dispatchCodexNativeHook(
2465
+ {
2466
+ hook_event_name: "PreToolUse",
2467
+ cwd,
2468
+ tool_name: "Bash",
2469
+ tool_use_id: "tool-git-help-commit",
2470
+ tool_input: { command: "git help commit" },
2471
+ },
2472
+ { cwd },
2473
+ );
2474
+
2475
+ assert.equal(result.omxEventName, "pre-tool-use");
2476
+ assert.equal(result.outputJson, null);
2477
+ } finally {
2478
+ await rm(cwd, { recursive: true, force: true });
2479
+ }
2480
+ });
2481
+
2482
+ it("stays silent on PreToolUse for `git config alias.ci commit`", async () => {
2483
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-pretool-git-config-alias-commit-"));
2484
+ try {
2485
+ const result = await dispatchCodexNativeHook(
2486
+ {
2487
+ hook_event_name: "PreToolUse",
2488
+ cwd,
2489
+ tool_name: "Bash",
2490
+ tool_use_id: "tool-git-config-alias-commit",
2491
+ tool_input: { command: "git config alias.ci commit" },
2492
+ },
2493
+ { cwd },
2494
+ );
2495
+
2496
+ assert.equal(result.omxEventName, "pre-tool-use");
2497
+ assert.equal(result.outputJson, null);
1531
2498
  } finally {
1532
2499
  await rm(cwd, { recursive: true, force: true });
1533
2500
  }
@@ -1575,13 +2542,6 @@ esac
1575
2542
  "git commit is blocked until the inline commit message satisfies the Lore format and includes the required OmX co-author trailer.",
1576
2543
  hookSpecificOutput: {
1577
2544
  hookEventName: "PreToolUse",
1578
- additionalContext: [
1579
- "Lore-format git commit enforcement triggered.",
1580
- "- Add a blank line after the subject before the narrative body.",
1581
- "- Add a narrative body paragraph explaining the decision context.",
1582
- "- Add at least one Lore trailer such as `Constraint:`, `Confidence:`, or `Tested:`.",
1583
- "- Add the required co-author trailer: `Co-authored-by: OmX <omx@oh-my-codex.dev>`.",
1584
- ].join("\n"),
1585
2545
  },
1586
2546
  systemMessage: [
1587
2547
  "git commit is blocked until the inline commit message follows the Lore protocol and includes `Co-authored-by: OmX <omx@oh-my-codex.dev>`.",
@@ -1617,13 +2577,6 @@ esac
1617
2577
  "git commit is blocked until the inline commit message satisfies the Lore format and includes the required OmX co-author trailer.",
1618
2578
  hookSpecificOutput: {
1619
2579
  hookEventName: "PreToolUse",
1620
- additionalContext: [
1621
- "Lore-format git commit enforcement triggered.",
1622
- "- Add a blank line after the subject before the narrative body.",
1623
- "- Add a narrative body paragraph explaining the decision context.",
1624
- "- Add at least one Lore trailer such as `Constraint:`, `Confidence:`, or `Tested:`.",
1625
- "- Add the required co-author trailer: `Co-authored-by: OmX <omx@oh-my-codex.dev>`.",
1626
- ].join("\n"),
1627
2580
  },
1628
2581
  systemMessage: [
1629
2582
  "git commit is blocked until the inline commit message follows the Lore protocol and includes `Co-authored-by: OmX <omx@oh-my-codex.dev>`.",
@@ -1659,13 +2612,6 @@ esac
1659
2612
  "git commit is blocked until the inline commit message satisfies the Lore format and includes the required OmX co-author trailer.",
1660
2613
  hookSpecificOutput: {
1661
2614
  hookEventName: "PreToolUse",
1662
- additionalContext: [
1663
- "Lore-format git commit enforcement triggered.",
1664
- "- Add a blank line after the subject before the narrative body.",
1665
- "- Add a narrative body paragraph explaining the decision context.",
1666
- "- Add at least one Lore trailer such as `Constraint:`, `Confidence:`, or `Tested:`.",
1667
- "- Add the required co-author trailer: `Co-authored-by: OmX <omx@oh-my-codex.dev>`.",
1668
- ].join("\n"),
1669
2615
  },
1670
2616
  systemMessage: [
1671
2617
  "git commit is blocked until the inline commit message follows the Lore protocol and includes `Co-authored-by: OmX <omx@oh-my-codex.dev>`.",
@@ -1701,13 +2647,6 @@ esac
1701
2647
  "git commit is blocked until the inline commit message satisfies the Lore format and includes the required OmX co-author trailer.",
1702
2648
  hookSpecificOutput: {
1703
2649
  hookEventName: "PreToolUse",
1704
- additionalContext: [
1705
- "Lore-format git commit enforcement triggered.",
1706
- "- Add a blank line after the subject before the narrative body.",
1707
- "- Add a narrative body paragraph explaining the decision context.",
1708
- "- Add at least one Lore trailer such as `Constraint:`, `Confidence:`, or `Tested:`.",
1709
- "- Add the required co-author trailer: `Co-authored-by: OmX <omx@oh-my-codex.dev>`.",
1710
- ].join("\n"),
1711
2650
  },
1712
2651
  systemMessage: [
1713
2652
  "git commit is blocked until the inline commit message follows the Lore protocol and includes `Co-authored-by: OmX <omx@oh-my-codex.dev>`.",
@@ -1743,13 +2682,6 @@ esac
1743
2682
  "git commit is blocked until the inline commit message satisfies the Lore format and includes the required OmX co-author trailer.",
1744
2683
  hookSpecificOutput: {
1745
2684
  hookEventName: "PreToolUse",
1746
- additionalContext: [
1747
- "Lore-format git commit enforcement triggered.",
1748
- "- Add a blank line after the subject before the narrative body.",
1749
- "- Add a narrative body paragraph explaining the decision context.",
1750
- "- Add at least one Lore trailer such as `Constraint:`, `Confidence:`, or `Tested:`.",
1751
- "- Add the required co-author trailer: `Co-authored-by: OmX <omx@oh-my-codex.dev>`.",
1752
- ].join("\n"),
1753
2685
  },
1754
2686
  systemMessage: [
1755
2687
  "git commit is blocked until the inline commit message follows the Lore protocol and includes `Co-authored-by: OmX <omx@oh-my-codex.dev>`.",
@@ -1785,13 +2717,6 @@ esac
1785
2717
  "git commit is blocked until the inline commit message satisfies the Lore format and includes the required OmX co-author trailer.",
1786
2718
  hookSpecificOutput: {
1787
2719
  hookEventName: "PreToolUse",
1788
- additionalContext: [
1789
- "Lore-format git commit enforcement triggered.",
1790
- "- Add a blank line after the subject before the narrative body.",
1791
- "- Add a narrative body paragraph explaining the decision context.",
1792
- "- Add at least one Lore trailer such as `Constraint:`, `Confidence:`, or `Tested:`.",
1793
- "- Add the required co-author trailer: `Co-authored-by: OmX <omx@oh-my-codex.dev>`.",
1794
- ].join("\n"),
1795
2720
  },
1796
2721
  systemMessage: [
1797
2722
  "git commit is blocked until the inline commit message follows the Lore protocol and includes `Co-authored-by: OmX <omx@oh-my-codex.dev>`.",
@@ -1827,13 +2752,6 @@ esac
1827
2752
  "git commit is blocked until the inline commit message satisfies the Lore format and includes the required OmX co-author trailer.",
1828
2753
  hookSpecificOutput: {
1829
2754
  hookEventName: "PreToolUse",
1830
- additionalContext: [
1831
- "Lore-format git commit enforcement triggered.",
1832
- "- Add a blank line after the subject before the narrative body.",
1833
- "- Add a narrative body paragraph explaining the decision context.",
1834
- "- Add at least one Lore trailer such as `Constraint:`, `Confidence:`, or `Tested:`.",
1835
- "- Add the required co-author trailer: `Co-authored-by: OmX <omx@oh-my-codex.dev>`.",
1836
- ].join("\n"),
1837
2755
  },
1838
2756
  systemMessage: [
1839
2757
  "git commit is blocked until the inline commit message follows the Lore protocol and includes `Co-authored-by: OmX <omx@oh-my-codex.dev>`.",
@@ -1869,13 +2787,6 @@ esac
1869
2787
  "git commit is blocked until the inline commit message satisfies the Lore format and includes the required OmX co-author trailer.",
1870
2788
  hookSpecificOutput: {
1871
2789
  hookEventName: "PreToolUse",
1872
- additionalContext: [
1873
- "Lore-format git commit enforcement triggered.",
1874
- "- Add a blank line after the subject before the narrative body.",
1875
- "- Add a narrative body paragraph explaining the decision context.",
1876
- "- Add at least one Lore trailer such as `Constraint:`, `Confidence:`, or `Tested:`.",
1877
- "- Add the required co-author trailer: `Co-authored-by: OmX <omx@oh-my-codex.dev>`.",
1878
- ].join("\n"),
1879
2790
  },
1880
2791
  systemMessage: [
1881
2792
  "git commit is blocked until the inline commit message follows the Lore protocol and includes `Co-authored-by: OmX <omx@oh-my-codex.dev>`.",
@@ -1911,13 +2822,6 @@ esac
1911
2822
  "git commit is blocked until the inline commit message satisfies the Lore format and includes the required OmX co-author trailer.",
1912
2823
  hookSpecificOutput: {
1913
2824
  hookEventName: "PreToolUse",
1914
- additionalContext: [
1915
- "Lore-format git commit enforcement triggered.",
1916
- "- Add a blank line after the subject before the narrative body.",
1917
- "- Add a narrative body paragraph explaining the decision context.",
1918
- "- Add at least one Lore trailer such as `Constraint:`, `Confidence:`, or `Tested:`.",
1919
- "- Add the required co-author trailer: `Co-authored-by: OmX <omx@oh-my-codex.dev>`.",
1920
- ].join("\n"),
1921
2825
  },
1922
2826
  systemMessage: [
1923
2827
  "git commit is blocked until the inline commit message follows the Lore protocol and includes `Co-authored-by: OmX <omx@oh-my-codex.dev>`.",
@@ -1953,10 +2857,6 @@ esac
1953
2857
  "git commit is blocked until the inline commit message satisfies the Lore format and includes the required OmX co-author trailer.",
1954
2858
  hookSpecificOutput: {
1955
2859
  hookEventName: "PreToolUse",
1956
- additionalContext: [
1957
- "Lore-format git commit enforcement triggered.",
1958
- "- Use inline `git commit -m ...` paragraphs for Lore-format commits in this path; file/editor/reuse/fixup message sources are not inspectable safely from pre-tool-use enforcement.",
1959
- ].join("\n"),
1960
2860
  },
1961
2861
  systemMessage: [
1962
2862
  "git commit is blocked until the inline commit message follows the Lore protocol and includes `Co-authored-by: OmX <omx@oh-my-codex.dev>`.",
@@ -1997,10 +2897,6 @@ esac
1997
2897
  "git commit is blocked until the inline commit message satisfies the Lore format and includes the required OmX co-author trailer.",
1998
2898
  hookSpecificOutput: {
1999
2899
  hookEventName: "PreToolUse",
2000
- additionalContext: [
2001
- "Lore-format git commit enforcement triggered.",
2002
- "- Add the required co-author trailer: `Co-authored-by: OmX <omx@oh-my-codex.dev>`.",
2003
- ].join("\n"),
2004
2900
  },
2005
2901
  systemMessage: [
2006
2902
  "git commit is blocked until the inline commit message follows the Lore protocol and includes `Co-authored-by: OmX <omx@oh-my-codex.dev>`.",
@@ -2042,23 +2938,198 @@ esac
2042
2938
  }
2043
2939
  });
2044
2940
 
2045
- it("returns PostToolUse remediation guidance for command-not-found output", async () => {
2046
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-posttool-failure-"));
2941
+ it("warns on PreToolUse git commit when mapped source changes lack staged docs refresh", async () => {
2942
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-pretool-document-refresh-warn-"));
2047
2943
  try {
2944
+ execFileSync("git", ["init"], { cwd, stdio: "ignore" });
2945
+ execFileSync("git", ["config", "user.email", "test@example.com"], { cwd, stdio: "ignore" });
2946
+ execFileSync("git", ["config", "user.name", "Test User"], { cwd, stdio: "ignore" });
2947
+ await mkdir(join(cwd, "src", "scripts"), { recursive: true });
2948
+ await writeFile(join(cwd, "src", "scripts", "codex-native-hook.ts"), "export const hook = 1;\n", "utf-8");
2949
+ await writeFile(join(cwd, "README.md"), "base\n", "utf-8");
2950
+ execFileSync("git", ["add", "README.md", "src/scripts/codex-native-hook.ts"], { cwd, stdio: "ignore" });
2951
+ execFileSync("git", ["commit", "-m", "init"], { cwd, stdio: "ignore" });
2952
+ await writeFile(join(cwd, "src", "scripts", "codex-native-hook.ts"), "export const hook = 2;\n", "utf-8");
2953
+ execFileSync("git", ["add", "src/scripts/codex-native-hook.ts"], { cwd, stdio: "ignore" });
2954
+
2048
2955
  const result = await dispatchCodexNativeHook(
2049
2956
  {
2050
- hook_event_name: "PostToolUse",
2957
+ hook_event_name: "PreToolUse",
2051
2958
  cwd,
2052
2959
  tool_name: "Bash",
2053
- tool_use_id: "tool-fail",
2054
- tool_input: { command: "foo --version" },
2055
- tool_response: "{\"exit_code\":127,\"stdout\":\"\",\"stderr\":\"bash: foo: command not found\"}",
2960
+ tool_use_id: "tool-git-commit-doc-refresh-warn",
2961
+ tool_input: {
2962
+ command: [
2963
+ 'git commit',
2964
+ '-m "Keep native hooks aligned with docs"',
2965
+ '-m "Update the stop hook internals without refreshing the operator docs yet."',
2966
+ '-m "Constraint: native hook warning MVP must remain non-blocking on commit path"',
2967
+ '-m "Tested: node --test dist/scripts/__tests__/codex-native-hook.test.js"',
2968
+ '-m "Co-authored-by: OmX <omx@oh-my-codex.dev>"',
2969
+ ].join(" "),
2970
+ },
2056
2971
  },
2057
2972
  { cwd },
2058
2973
  );
2059
2974
 
2060
- assert.equal(result.omxEventName, "post-tool-use");
2061
- assert.deepEqual(result.outputJson, {
2975
+ assert.equal(result.omxEventName, "pre-tool-use");
2976
+ assert.equal((result.outputJson as { decision?: string } | null)?.decision, undefined);
2977
+ assert.equal((result.outputJson as { hookSpecificOutput?: { hookEventName?: string } } | null)?.hookSpecificOutput?.hookEventName, "PreToolUse");
2978
+ assert.match(JSON.stringify(result.outputJson), /Document-refresh warning/);
2979
+ assert.match(JSON.stringify(result.outputJson), /docs\/codex-native-hooks\.md/);
2980
+ } finally {
2981
+ await rm(cwd, { recursive: true, force: true });
2982
+ }
2983
+ });
2984
+
2985
+ it("does not warn on PreToolUse when relevant docs are staged", async () => {
2986
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-pretool-document-refresh-docs-"));
2987
+ try {
2988
+ await mkdir(join(cwd, "src", "scripts"), { recursive: true });
2989
+ await mkdir(join(cwd, "docs"), { recursive: true });
2990
+ execFileSync("git", ["init"], { cwd, stdio: "ignore" });
2991
+ execFileSync("git", ["config", "user.email", "test@example.com"], { cwd, stdio: "ignore" });
2992
+ execFileSync("git", ["config", "user.name", "Test User"], { cwd, stdio: "ignore" });
2993
+ await writeFile(join(cwd, "src", "scripts", "codex-native-hook.ts"), "export const hook = 1;\n", "utf-8");
2994
+ await writeFile(join(cwd, "docs", "codex-native-hooks.md"), "initial\n", "utf-8");
2995
+ execFileSync("git", ["add", "src/scripts/codex-native-hook.ts", "docs/codex-native-hooks.md"], { cwd, stdio: "ignore" });
2996
+ execFileSync("git", ["commit", "-m", "init"], { cwd, stdio: "ignore" });
2997
+ await writeFile(join(cwd, "src", "scripts", "codex-native-hook.ts"), "export const hook = 2;\n", "utf-8");
2998
+ await writeFile(join(cwd, "docs", "codex-native-hooks.md"), "updated\n", "utf-8");
2999
+ execFileSync("git", ["add", "src/scripts/codex-native-hook.ts", "docs/codex-native-hooks.md"], { cwd, stdio: "ignore" });
3000
+
3001
+ const result = await dispatchCodexNativeHook(
3002
+ {
3003
+ hook_event_name: "PreToolUse",
3004
+ cwd,
3005
+ tool_name: "Bash",
3006
+ tool_use_id: "tool-git-commit-doc-refresh-docs",
3007
+ tool_input: {
3008
+ command: [
3009
+ 'git commit',
3010
+ '-m "Keep native hooks aligned with docs"',
3011
+ '-m "Update the stop hook internals and refresh the native hook docs together."',
3012
+ '-m "Constraint: native hook warning MVP must remain non-blocking on commit path"',
3013
+ '-m "Tested: node --test dist/scripts/__tests__/codex-native-hook.test.js"',
3014
+ '-m "Co-authored-by: OmX <omx@oh-my-codex.dev>"',
3015
+ ].join(" "),
3016
+ },
3017
+ },
3018
+ { cwd },
3019
+ );
3020
+
3021
+ assert.equal(result.outputJson, null);
3022
+ } finally {
3023
+ await rm(cwd, { recursive: true, force: true });
3024
+ }
3025
+ });
3026
+
3027
+ it("does not run commit-path document-refresh against payload cwd when git -C targets another repo", async () => {
3028
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-pretool-document-refresh-chdir-"));
3029
+ const otherRepo = await mkdtemp(join(tmpdir(), "omx-native-hook-pretool-document-refresh-other-"));
3030
+ try {
3031
+ await mkdir(join(cwd, "src", "scripts"), { recursive: true });
3032
+ execFileSync("git", ["init"], { cwd, stdio: "ignore" });
3033
+ execFileSync("git", ["config", "user.email", "test@example.com"], { cwd, stdio: "ignore" });
3034
+ execFileSync("git", ["config", "user.name", "Test User"], { cwd, stdio: "ignore" });
3035
+ await writeFile(join(cwd, "src", "scripts", "codex-native-hook.ts"), "export const hook = 1;\n", "utf-8");
3036
+ execFileSync("git", ["add", "src/scripts/codex-native-hook.ts"], { cwd, stdio: "ignore" });
3037
+ execFileSync("git", ["commit", "-m", "init"], { cwd, stdio: "ignore" });
3038
+ await writeFile(join(cwd, "src", "scripts", "codex-native-hook.ts"), "export const hook = 2;\n", "utf-8");
3039
+ execFileSync("git", ["add", "src/scripts/codex-native-hook.ts"], { cwd, stdio: "ignore" });
3040
+
3041
+ execFileSync("git", ["init"], { cwd: otherRepo, stdio: "ignore" });
3042
+ execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: otherRepo, stdio: "ignore" });
3043
+ execFileSync("git", ["config", "user.name", "Test User"], { cwd: otherRepo, stdio: "ignore" });
3044
+ await writeFile(join(otherRepo, "README.md"), "base\n", "utf-8");
3045
+ execFileSync("git", ["add", "README.md"], { cwd: otherRepo, stdio: "ignore" });
3046
+ execFileSync("git", ["commit", "-m", "init"], { cwd: otherRepo, stdio: "ignore" });
3047
+
3048
+ const result = await dispatchCodexNativeHook(
3049
+ {
3050
+ hook_event_name: "PreToolUse",
3051
+ cwd,
3052
+ tool_name: "Bash",
3053
+ tool_use_id: "tool-git-commit-doc-refresh-chdir",
3054
+ tool_input: {
3055
+ command: [
3056
+ `git -C ${JSON.stringify(otherRepo)}`,
3057
+ 'commit',
3058
+ '-m "Keep native hooks aligned with docs"',
3059
+ '-m "Document-refresh check should not inspect the caller cwd when commit targets another repo."',
3060
+ '-m "Constraint: alternate git targets are skipped unless hook-side repo resolution is added explicitly"',
3061
+ '-m "Tested: node --test dist/scripts/__tests__/codex-native-hook.test.js"',
3062
+ '-m "Co-authored-by: OmX <omx@oh-my-codex.dev>"',
3063
+ ].join(" "),
3064
+ },
3065
+ },
3066
+ { cwd },
3067
+ );
3068
+
3069
+ assert.equal(result.outputJson, null);
3070
+ } finally {
3071
+ await rm(cwd, { recursive: true, force: true });
3072
+ await rm(otherRepo, { recursive: true, force: true });
3073
+ }
3074
+ });
3075
+
3076
+ it("suppresses PreToolUse document-refresh warning when commit message includes an exemption", async () => {
3077
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-pretool-document-refresh-exempt-"));
3078
+ try {
3079
+ await mkdir(join(cwd, "src", "scripts"), { recursive: true });
3080
+ execFileSync("git", ["init"], { cwd, stdio: "ignore" });
3081
+ execFileSync("git", ["config", "user.email", "test@example.com"], { cwd, stdio: "ignore" });
3082
+ execFileSync("git", ["config", "user.name", "Test User"], { cwd, stdio: "ignore" });
3083
+ await writeFile(join(cwd, "src", "scripts", "codex-native-hook.ts"), "export const hook = 1;\n", "utf-8");
3084
+ execFileSync("git", ["add", "src/scripts/codex-native-hook.ts"], { cwd, stdio: "ignore" });
3085
+ execFileSync("git", ["commit", "-m", "init"], { cwd, stdio: "ignore" });
3086
+ await writeFile(join(cwd, "src", "scripts", "codex-native-hook.ts"), "export const hook = 2;\n", "utf-8");
3087
+ execFileSync("git", ["add", "src/scripts/codex-native-hook.ts"], { cwd, stdio: "ignore" });
3088
+
3089
+ const result = await dispatchCodexNativeHook(
3090
+ {
3091
+ hook_event_name: "PreToolUse",
3092
+ cwd,
3093
+ tool_name: "Bash",
3094
+ tool_use_id: "tool-git-commit-doc-refresh-exempt",
3095
+ tool_input: {
3096
+ command: [
3097
+ 'git commit',
3098
+ '-m "Keep native hooks aligned with docs"',
3099
+ '-m "Update the stop hook internals without docs refresh because behavior is internal-only."',
3100
+ '-m "Constraint: native hook warning MVP must remain non-blocking on commit path"',
3101
+ `-m "${DOCUMENT_REFRESH_EXEMPTION_PREFIX} internal-only behavior verified"`,
3102
+ '-m "Tested: node --test dist/scripts/__tests__/codex-native-hook.test.js"',
3103
+ '-m "Co-authored-by: OmX <omx@oh-my-codex.dev>"',
3104
+ ].join(" "),
3105
+ },
3106
+ },
3107
+ { cwd },
3108
+ );
3109
+
3110
+ assert.equal(result.outputJson, null);
3111
+ } finally {
3112
+ await rm(cwd, { recursive: true, force: true });
3113
+ }
3114
+ });
3115
+
3116
+ it("returns PostToolUse remediation guidance for command-not-found output", async () => {
3117
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-posttool-failure-"));
3118
+ try {
3119
+ const result = await dispatchCodexNativeHook(
3120
+ {
3121
+ hook_event_name: "PostToolUse",
3122
+ cwd,
3123
+ tool_name: "Bash",
3124
+ tool_use_id: "tool-fail",
3125
+ tool_input: { command: "foo --version" },
3126
+ tool_response: "{\"exit_code\":127,\"stdout\":\"\",\"stderr\":\"bash: foo: command not found\"}",
3127
+ },
3128
+ { cwd },
3129
+ );
3130
+
3131
+ assert.equal(result.omxEventName, "post-tool-use");
3132
+ assert.deepEqual(result.outputJson, {
2062
3133
  decision: "block",
2063
3134
  reason: "The Bash output indicates a command/setup failure that should be fixed before retrying.",
2064
3135
  hookSpecificOutput: {
@@ -2072,6 +3143,181 @@ esac
2072
3143
  }
2073
3144
  });
2074
3145
 
3146
+ it("stays silent when successful search output contains old Bash failure text", async () => {
3147
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-posttool-successful-search-"));
3148
+ try {
3149
+ const result = await dispatchCodexNativeHook(
3150
+ {
3151
+ hook_event_name: "PostToolUse",
3152
+ cwd,
3153
+ tool_name: "Bash",
3154
+ tool_use_id: "tool-search-log",
3155
+ tool_input: { command: "rg 'command not found' .omx/logs" },
3156
+ tool_response: JSON.stringify({
3157
+ exit_code: 0,
3158
+ stdout: "old-session.log: bash: foo: command not found",
3159
+ stderr: "",
3160
+ }),
3161
+ },
3162
+ { cwd },
3163
+ );
3164
+
3165
+ assert.equal(result.omxEventName, "post-tool-use");
3166
+ assert.equal(result.outputJson, null);
3167
+ } finally {
3168
+ await rm(cwd, { recursive: true, force: true });
3169
+ }
3170
+ });
3171
+
3172
+ it("stays silent when Bash stdout only contains failure-like source text", async () => {
3173
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-posttool-failure-source-text-"));
3174
+ try {
3175
+ const result = await dispatchCodexNativeHook(
3176
+ {
3177
+ hook_event_name: "PostToolUse",
3178
+ cwd,
3179
+ tool_name: "Bash",
3180
+ tool_use_id: "tool-source-text",
3181
+ tool_input: { command: "sed -n '1,40p' hook-source.ts" },
3182
+ tool_response: "const text = 'bash: foo: command not found';\nconst detail = 'permission denied';",
3183
+ },
3184
+ { cwd },
3185
+ );
3186
+
3187
+ assert.equal(result.omxEventName, "post-tool-use");
3188
+ assert.equal(result.outputJson, null);
3189
+ } finally {
3190
+ await rm(cwd, { recursive: true, force: true });
3191
+ }
3192
+ });
3193
+
3194
+ it("stays silent for rc-zero build logs that mention missing grep paths", async () => {
3195
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-posttool-build-log-"));
3196
+ try {
3197
+ const result = await dispatchCodexNativeHook(
3198
+ {
3199
+ hook_event_name: "PostToolUse",
3200
+ cwd,
3201
+ tool_name: "Bash",
3202
+ tool_use_id: "tool-build-log",
3203
+ tool_input: { command: "npm run build" },
3204
+ tool_response: JSON.stringify({
3205
+ exit_code: 0,
3206
+ stdout: "build passed\nnote: grep fixture says no such file or directory",
3207
+ stderr: "",
3208
+ }),
3209
+ },
3210
+ { cwd },
3211
+ );
3212
+
3213
+ assert.equal(result.omxEventName, "post-tool-use");
3214
+ assert.equal(result.outputJson, null);
3215
+ } finally {
3216
+ await rm(cwd, { recursive: true, force: true });
3217
+ }
3218
+ });
3219
+
3220
+ it("does not treat Bash output containing MCP transport text as MCP transport death", async () => {
3221
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-posttool-mcp-source-text-"));
3222
+ try {
3223
+ const result = await dispatchCodexNativeHook(
3224
+ {
3225
+ hook_event_name: "PostToolUse",
3226
+ cwd,
3227
+ tool_name: "Bash",
3228
+ tool_use_id: "tool-mcp-source-text",
3229
+ tool_input: { command: "sed -n '580,620p' codex-native-pre-post.ts" },
3230
+ tool_response: JSON.stringify({
3231
+ exit_code: 0,
3232
+ stdout: "reason: 'MCP transport closed before response over stdio pipe closed'",
3233
+ stderr: "",
3234
+ }),
3235
+ },
3236
+ { cwd },
3237
+ );
3238
+
3239
+ assert.equal(result.omxEventName, "post-tool-use");
3240
+ assert.equal(result.outputJson, null);
3241
+ } finally {
3242
+ await rm(cwd, { recursive: true, force: true });
3243
+ }
3244
+ });
3245
+
3246
+ it("stays silent when successful output includes prior hook context text", async () => {
3247
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-posttool-recursive-context-"));
3248
+ try {
3249
+ const result = await dispatchCodexNativeHook(
3250
+ {
3251
+ hook_event_name: "PostToolUse",
3252
+ cwd,
3253
+ tool_name: "Bash",
3254
+ tool_use_id: "tool-hook-context",
3255
+ tool_input: { command: "cat transcript.txt" },
3256
+ tool_response: JSON.stringify({
3257
+ exit_code: 0,
3258
+ stdout:
3259
+ "Bash reported `command not found`, `permission denied`, or a missing file/path. Verify the command, dependency installation, PATH, file permissions, and referenced paths before retrying.",
3260
+ stderr: "",
3261
+ }),
3262
+ },
3263
+ { cwd },
3264
+ );
3265
+
3266
+ assert.equal(result.omxEventName, "post-tool-use");
3267
+ assert.equal(result.outputJson, null);
3268
+ } finally {
3269
+ await rm(cwd, { recursive: true, force: true });
3270
+ }
3271
+ });
3272
+
3273
+ it("stays silent when successful Bash output quotes MCP transport warnings", async () => {
3274
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-posttool-bash-mcp-quote-"));
3275
+ try {
3276
+ const result = await dispatchCodexNativeHook(
3277
+ {
3278
+ hook_event_name: "PostToolUse",
3279
+ cwd,
3280
+ tool_name: "Bash",
3281
+ tool_use_id: "tool-bash-mcp-quote",
3282
+ tool_input: { command: "cat diagnostic-log.txt" },
3283
+ tool_response: JSON.stringify({
3284
+ exit_code: 0,
3285
+ stdout: "diagnostic log quoted: MCP transport closed; stdio pipe closed before response",
3286
+ stderr: "",
3287
+ }),
3288
+ },
3289
+ { cwd },
3290
+ );
3291
+
3292
+ assert.equal(result.omxEventName, "post-tool-use");
3293
+ assert.equal(result.outputJson, null);
3294
+ } finally {
3295
+ await rm(cwd, { recursive: true, force: true });
3296
+ }
3297
+ });
3298
+
3299
+ it("stays silent when Bash hard-failure text has no parsed exit code", async () => {
3300
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-posttool-bash-unparsed-failure-"));
3301
+ try {
3302
+ const result = await dispatchCodexNativeHook(
3303
+ {
3304
+ hook_event_name: "PostToolUse",
3305
+ cwd,
3306
+ tool_name: "Bash",
3307
+ tool_use_id: "tool-bash-unparsed-failure",
3308
+ tool_input: { command: "cat captured-output.txt" },
3309
+ tool_response: "captured transcript says: bash: foo: command not found",
3310
+ },
3311
+ { cwd },
3312
+ );
3313
+
3314
+ assert.equal(result.omxEventName, "post-tool-use");
3315
+ assert.equal(result.outputJson, null);
3316
+ } finally {
3317
+ await rm(cwd, { recursive: true, force: true });
3318
+ }
3319
+ });
3320
+
2075
3321
  it("returns PostToolUse MCP transport fallback guidance for clear MCP transport death", async () => {
2076
3322
  const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-posttool-mcp-transport-"));
2077
3323
  try {
@@ -2688,7 +3934,17 @@ esac
2688
3934
  undefined,
2689
3935
  { ...process.env, OMX_SESSION_ID: "sess-stop-team-worker" },
2690
3936
  );
3937
+ const workerCwd = join(cwd, ".omx", "team", "worker-stop-team", "worktrees", "worker-1");
2691
3938
  const workerDir = join(cwd, ".omx", "state", "team", "worker-stop-team", "workers", "worker-1");
3939
+ await mkdir(workerCwd, { recursive: true });
3940
+ await writeJson(join(workerDir, "identity.json"), {
3941
+ name: "worker-1",
3942
+ index: 1,
3943
+ role: "executor",
3944
+ assigned_tasks: ["1"],
3945
+ worktree_path: workerCwd,
3946
+ team_state_root: join(cwd, ".omx", "state"),
3947
+ });
2692
3948
  await writeJson(join(workerDir, "status.json"), {
2693
3949
  state: "idle",
2694
3950
  current_task_id: "1",
@@ -2710,10 +3966,10 @@ esac
2710
3966
  const result = await dispatchCodexNativeHook(
2711
3967
  {
2712
3968
  hook_event_name: "Stop",
2713
- cwd: join(cwd, ".omx", "team", "worker-stop-team", "worktrees", "worker-1"),
3969
+ cwd: workerCwd,
2714
3970
  session_id: "sess-stop-team-worker",
2715
3971
  },
2716
- { cwd: join(cwd, ".omx", "team", "worker-stop-team", "worktrees", "worker-1") },
3972
+ { cwd: workerCwd },
2717
3973
  );
2718
3974
 
2719
3975
  assert.deepEqual(result.outputJson, {
@@ -2734,51 +3990,148 @@ esac
2734
3990
  }
2735
3991
  });
2736
3992
 
2737
- it("does not block Stop for a team worker when assigned task is terminal", async () => {
2738
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-team-worker-terminal-"));
2739
- const prevTeamWorker = process.env.OMX_TEAM_WORKER;
2740
- const prevTeamStateRoot = process.env.OMX_TEAM_STATE_ROOT;
3993
+ it("suppresses identical team worker Stop replays but re-blocks fresh turns and task state changes", async () => {
3994
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-team-worker-repeat-"));
2741
3995
  try {
2742
3996
  await initTeamState(
2743
- "worker-stop-team-terminal",
2744
- "worker stop terminal fallback",
3997
+ "worker-repeat-team",
3998
+ "worker stop repeat guard",
2745
3999
  "executor",
2746
4000
  1,
2747
4001
  cwd,
2748
4002
  undefined,
2749
- { ...process.env, OMX_SESSION_ID: "sess-stop-team-worker-terminal" },
4003
+ { ...process.env, OMX_SESSION_ID: "sess-stop-team-worker-repeat" },
2750
4004
  );
2751
- const workerDir = join(cwd, ".omx", "state", "team", "worker-stop-team-terminal", "workers", "worker-1");
4005
+ const stateDir = join(cwd, ".omx", "state");
4006
+ const workerDir = join(stateDir, "team", "worker-repeat-team", "workers", "worker-1");
4007
+ const taskPath = join(stateDir, "team", "worker-repeat-team", "tasks", "task-1.json");
4008
+ const workerCwd = join(cwd, ".omx", "team", "worker-repeat-team", "worktrees", "worker-1");
4009
+ await mkdir(workerCwd, { recursive: true });
4010
+ await writeJson(join(workerDir, "identity.json"), {
4011
+ name: "worker-1",
4012
+ index: 1,
4013
+ role: "executor",
4014
+ assigned_tasks: ["1"],
4015
+ worktree_path: workerCwd,
4016
+ team_state_root: stateDir,
4017
+ });
2752
4018
  await writeJson(join(workerDir, "status.json"), {
2753
- state: "done",
4019
+ state: "idle",
2754
4020
  current_task_id: "1",
2755
4021
  updated_at: new Date().toISOString(),
2756
4022
  });
2757
- await writeJson(join(cwd, ".omx", "state", "team", "worker-stop-team-terminal", "tasks", "task-1.json"), {
4023
+ await writeJson(taskPath, {
2758
4024
  id: "1",
2759
4025
  subject: "hook task",
2760
4026
  description: "finish hook task",
2761
- status: "completed",
4027
+ status: "in_progress",
2762
4028
  owner: "worker-1",
2763
4029
  created_at: new Date().toISOString(),
2764
4030
  });
2765
4031
 
2766
- process.env.OMX_TEAM_WORKER = "worker-stop-team-terminal/worker-1";
2767
- process.env.OMX_TEAM_STATE_ROOT = join(cwd, ".omx", "state");
2768
-
2769
- const result = await dispatchCodexNativeHook(
2770
- {
2771
- hook_event_name: "Stop",
2772
- cwd,
2773
- session_id: "sess-stop-team-worker-terminal",
2774
- },
2775
- { cwd },
2776
- );
4032
+ process.env.OMX_TEAM_WORKER = "worker-repeat-team/worker-1";
4033
+ process.env.OMX_TEAM_STATE_ROOT = stateDir;
4034
+ process.env.OMX_TEAM_LEADER_CWD = cwd;
2777
4035
 
2778
- assert.deepEqual(result.outputJson, {
4036
+ const basePayload = {
4037
+ hook_event_name: "Stop",
4038
+ cwd: workerCwd,
4039
+ session_id: "sess-stop-team-worker-repeat",
4040
+ thread_id: "thread-stop-team-worker-repeat",
4041
+ turn_id: "turn-stop-team-worker-repeat-1",
4042
+ last_assistant_message: "I need to stop before this task is done.",
4043
+ };
4044
+ const expectedInProgress = {
2779
4045
  decision: "block",
2780
4046
  reason:
2781
- `OMX team pipeline is still active (worker-stop-team-terminal) at phase team-exec; continue coordinating until the team reaches a terminal phase.${TEAM_STOP_COMMIT_GUIDANCE}`,
4047
+ "OMX team worker worker-1 is still assigned non-terminal task 1 (in_progress); continue the current assigned task or report a concrete blocker before stopping.",
4048
+ stopReason: "team_worker_worker-1_1_in_progress",
4049
+ systemMessage: "OMX team worker worker-1 is still assigned task 1 (in_progress).",
4050
+ };
4051
+
4052
+ const first = await dispatchCodexNativeHook(basePayload, { cwd: workerCwd });
4053
+ const replay = await dispatchCodexNativeHook(
4054
+ { ...basePayload, stop_hook_active: true },
4055
+ { cwd: workerCwd },
4056
+ );
4057
+ const freshTurn = await dispatchCodexNativeHook(
4058
+ { ...basePayload, turn_id: "turn-stop-team-worker-repeat-2", stop_hook_active: true },
4059
+ { cwd: workerCwd },
4060
+ );
4061
+
4062
+ await writeJson(taskPath, {
4063
+ id: "1",
4064
+ subject: "hook task",
4065
+ description: "finish hook task",
4066
+ status: "blocked",
4067
+ owner: "worker-1",
4068
+ created_at: new Date().toISOString(),
4069
+ });
4070
+ const stateChanged = await dispatchCodexNativeHook(
4071
+ { ...basePayload, turn_id: "turn-stop-team-worker-repeat-2", stop_hook_active: true },
4072
+ { cwd: workerCwd },
4073
+ );
4074
+
4075
+ assert.deepEqual(first.outputJson, expectedInProgress);
4076
+ assert.deepEqual(replay.outputJson, null);
4077
+ assert.deepEqual(freshTurn.outputJson, expectedInProgress);
4078
+ assert.deepEqual(stateChanged.outputJson, {
4079
+ decision: "block",
4080
+ reason:
4081
+ "OMX team worker worker-1 is still assigned non-terminal task 1 (blocked); continue the current assigned task or report a concrete blocker before stopping.",
4082
+ stopReason: "team_worker_worker-1_1_blocked",
4083
+ systemMessage: "OMX team worker worker-1 is still assigned task 1 (blocked).",
4084
+ });
4085
+ } finally {
4086
+ await rm(cwd, { recursive: true, force: true });
4087
+ }
4088
+ });
4089
+
4090
+ it("does not block Stop for a team worker when assigned task is terminal", async () => {
4091
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-team-worker-terminal-"));
4092
+ const prevTeamWorker = process.env.OMX_TEAM_WORKER;
4093
+ const prevTeamStateRoot = process.env.OMX_TEAM_STATE_ROOT;
4094
+ try {
4095
+ await initTeamState(
4096
+ "worker-stop-team-terminal",
4097
+ "worker stop terminal fallback",
4098
+ "executor",
4099
+ 1,
4100
+ cwd,
4101
+ undefined,
4102
+ { ...process.env, OMX_SESSION_ID: "sess-stop-team-worker-terminal" },
4103
+ );
4104
+ const workerDir = join(cwd, ".omx", "state", "team", "worker-stop-team-terminal", "workers", "worker-1");
4105
+ await writeJson(join(workerDir, "status.json"), {
4106
+ state: "done",
4107
+ current_task_id: "1",
4108
+ updated_at: new Date().toISOString(),
4109
+ });
4110
+ await writeJson(join(cwd, ".omx", "state", "team", "worker-stop-team-terminal", "tasks", "task-1.json"), {
4111
+ id: "1",
4112
+ subject: "hook task",
4113
+ description: "finish hook task",
4114
+ status: "completed",
4115
+ owner: "worker-1",
4116
+ created_at: new Date().toISOString(),
4117
+ });
4118
+
4119
+ process.env.OMX_TEAM_WORKER = "worker-stop-team-terminal/worker-1";
4120
+ process.env.OMX_TEAM_STATE_ROOT = join(cwd, ".omx", "state");
4121
+
4122
+ const result = await dispatchCodexNativeHook(
4123
+ {
4124
+ hook_event_name: "Stop",
4125
+ cwd,
4126
+ session_id: "sess-stop-team-worker-terminal",
4127
+ },
4128
+ { cwd },
4129
+ );
4130
+
4131
+ assert.deepEqual(result.outputJson, {
4132
+ decision: "block",
4133
+ reason:
4134
+ `OMX team pipeline is still active (worker-stop-team-terminal) at phase team-exec; continue coordinating until the team reaches a terminal phase.${TEAM_STOP_COMMIT_GUIDANCE}`,
2782
4135
  stopReason: "team_team-exec",
2783
4136
  systemMessage: "OMX team pipeline is still active at phase team-exec.",
2784
4137
  });
@@ -2921,6 +4274,51 @@ esac
2921
4274
  }
2922
4275
  });
2923
4276
 
4277
+ it("honors terminal team run-state before later canonical-team Stop fallback", async () => {
4278
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-team-terminal-run-state-canonical-"));
4279
+ try {
4280
+ const stateDir = join(cwd, ".omx", "state");
4281
+ const sessionId = "sess-stop-team-terminal-run-state";
4282
+ await initTeamState(
4283
+ "terminal-run-state-team",
4284
+ "terminal team stop canonical fallback regression",
4285
+ "executor",
4286
+ 1,
4287
+ cwd,
4288
+ undefined,
4289
+ { ...process.env, OMX_SESSION_ID: sessionId },
4290
+ );
4291
+ await mkdir(join(stateDir, "sessions", sessionId), { recursive: true });
4292
+ await writeJson(join(stateDir, "session.json"), { session_id: sessionId, cwd });
4293
+ await writeJson(join(stateDir, "sessions", sessionId, "run-state.json"), {
4294
+ version: 1,
4295
+ mode: "team",
4296
+ active: false,
4297
+ outcome: "finish",
4298
+ lifecycle_outcome: "finished",
4299
+ current_phase: "complete",
4300
+ completed_at: "2026-04-27T12:00:00.000Z",
4301
+ updated_at: "2026-04-27T12:00:00.000Z",
4302
+ });
4303
+
4304
+ const result = await dispatchCodexNativeHook(
4305
+ {
4306
+ hook_event_name: "Stop",
4307
+ cwd,
4308
+ session_id: sessionId,
4309
+ thread_id: "thread-stop-team-terminal-run-state",
4310
+ turn_id: "turn-stop-team-terminal-run-state-1",
4311
+ },
4312
+ { cwd },
4313
+ );
4314
+
4315
+ assert.equal(result.omxEventName, "stop");
4316
+ assert.equal(result.outputJson, null);
4317
+ } finally {
4318
+ await rm(cwd, { recursive: true, force: true });
4319
+ }
4320
+ });
4321
+
2924
4322
  it("re-fires canonical-team Stop output for a later fresh Stop reply when coarse mode state is missing", async () => {
2925
4323
  const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-team-canonical-refire-"));
2926
4324
  try {
@@ -3883,6 +5281,123 @@ esac
3883
5281
  }
3884
5282
  });
3885
5283
 
5284
+ it("returns a non-blocking Stop document-refresh warning before auto-nudge when Ralph is not active", async () => {
5285
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-document-refresh-"));
5286
+ try {
5287
+ await mkdir(join(cwd, "src", "scripts"), { recursive: true });
5288
+ execFileSync("git", ["init"], { cwd, stdio: "ignore" });
5289
+ execFileSync("git", ["config", "user.email", "test@example.com"], { cwd, stdio: "ignore" });
5290
+ execFileSync("git", ["config", "user.name", "Test User"], { cwd, stdio: "ignore" });
5291
+ await writeFile(join(cwd, "src", "scripts", "codex-native-hook.ts"), "export const hook = 1;\n", "utf-8");
5292
+ execFileSync("git", ["add", "src/scripts/codex-native-hook.ts"], { cwd, stdio: "ignore" });
5293
+ execFileSync("git", ["commit", "-m", "init"], { cwd, stdio: "ignore" });
5294
+ await writeFile(join(cwd, "src", "scripts", "codex-native-hook.ts"), "export const hook = 2;\n", "utf-8");
5295
+
5296
+ const result = await dispatchCodexNativeHook(
5297
+ {
5298
+ hook_event_name: "Stop",
5299
+ cwd,
5300
+ session_id: "sess-stop-doc-refresh",
5301
+ last_assistant_message: "Launch-ready: yes",
5302
+ },
5303
+ { cwd },
5304
+ );
5305
+
5306
+ assert.equal(result.omxEventName, "stop");
5307
+ assert.equal((result.outputJson as { decision?: string } | null)?.decision, undefined);
5308
+ assert.equal((result.outputJson as { hookSpecificOutput?: { hookEventName?: string } } | null)?.hookSpecificOutput?.hookEventName, "Stop");
5309
+ assert.match(JSON.stringify(result.outputJson), /Document-refresh warning/);
5310
+ assert.match(JSON.stringify(result.outputJson), /staged \+ unstaged changes/);
5311
+ } finally {
5312
+ await rm(cwd, { recursive: true, force: true });
5313
+ }
5314
+ });
5315
+
5316
+ it("does not warn on ordinary non-terminal Stop attempts before auto-nudge", async () => {
5317
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-document-refresh-nonterminal-"));
5318
+ try {
5319
+ await mkdir(join(cwd, "src", "scripts"), { recursive: true });
5320
+ execFileSync("git", ["init"], { cwd, stdio: "ignore" });
5321
+ execFileSync("git", ["config", "user.email", "test@example.com"], { cwd, stdio: "ignore" });
5322
+ execFileSync("git", ["config", "user.name", "Test User"], { cwd, stdio: "ignore" });
5323
+ await writeFile(join(cwd, "src", "scripts", "codex-native-hook.ts"), "export const hook = 1;\n", "utf-8");
5324
+ execFileSync("git", ["add", "src/scripts/codex-native-hook.ts"], { cwd, stdio: "ignore" });
5325
+ execFileSync("git", ["commit", "-m", "init"], { cwd, stdio: "ignore" });
5326
+ await writeFile(join(cwd, "src", "scripts", "codex-native-hook.ts"), "export const hook = 2;\n", "utf-8");
5327
+
5328
+ const result = await dispatchCodexNativeHook(
5329
+ {
5330
+ hook_event_name: "Stop",
5331
+ cwd,
5332
+ session_id: "sess-stop-doc-refresh-nonterminal",
5333
+ last_assistant_message: "Continuing implementation; next I will run focused tests.",
5334
+ },
5335
+ { cwd },
5336
+ );
5337
+
5338
+ assert.equal(result.outputJson, null);
5339
+ } finally {
5340
+ await rm(cwd, { recursive: true, force: true });
5341
+ }
5342
+ });
5343
+
5344
+ it("dedupes identical Stop document-refresh warnings during active Stop-hook replays", async () => {
5345
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-document-refresh-dedupe-"));
5346
+ try {
5347
+ await mkdir(join(cwd, "src", "scripts"), { recursive: true });
5348
+ execFileSync("git", ["init"], { cwd, stdio: "ignore" });
5349
+ execFileSync("git", ["config", "user.email", "test@example.com"], { cwd, stdio: "ignore" });
5350
+ execFileSync("git", ["config", "user.name", "Test User"], { cwd, stdio: "ignore" });
5351
+ await writeFile(join(cwd, "src", "scripts", "codex-native-hook.ts"), "export const hook = 1;\n", "utf-8");
5352
+ execFileSync("git", ["add", "src/scripts/codex-native-hook.ts"], { cwd, stdio: "ignore" });
5353
+ execFileSync("git", ["commit", "-m", "init"], { cwd, stdio: "ignore" });
5354
+ await writeFile(join(cwd, "src", "scripts", "codex-native-hook.ts"), "export const hook = 2;\n", "utf-8");
5355
+
5356
+ const payload = {
5357
+ hook_event_name: "Stop",
5358
+ cwd,
5359
+ session_id: "sess-stop-doc-refresh-dedupe",
5360
+ last_assistant_message: "Launch-ready: yes",
5361
+ } as const;
5362
+
5363
+ const first = await dispatchCodexNativeHook(payload, { cwd });
5364
+ const replay = await dispatchCodexNativeHook({ ...payload, stop_hook_active: true }, { cwd });
5365
+
5366
+ assert.match(JSON.stringify(first.outputJson), /Document-refresh warning/);
5367
+ assert.equal(replay.outputJson, null);
5368
+ } finally {
5369
+ await rm(cwd, { recursive: true, force: true });
5370
+ }
5371
+ });
5372
+
5373
+ it("suppresses Stop document-refresh warning when the final handoff message includes an exemption", async () => {
5374
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-document-refresh-exempt-"));
5375
+ try {
5376
+ await mkdir(join(cwd, "src", "scripts"), { recursive: true });
5377
+ execFileSync("git", ["init"], { cwd, stdio: "ignore" });
5378
+ execFileSync("git", ["config", "user.email", "test@example.com"], { cwd, stdio: "ignore" });
5379
+ execFileSync("git", ["config", "user.name", "Test User"], { cwd, stdio: "ignore" });
5380
+ await writeFile(join(cwd, "src", "scripts", "codex-native-hook.ts"), "export const hook = 1;\n", "utf-8");
5381
+ execFileSync("git", ["add", "src/scripts/codex-native-hook.ts"], { cwd, stdio: "ignore" });
5382
+ execFileSync("git", ["commit", "-m", "init"], { cwd, stdio: "ignore" });
5383
+ await writeFile(join(cwd, "src", "scripts", "codex-native-hook.ts"), "export const hook = 2;\n", "utf-8");
5384
+
5385
+ const result = await dispatchCodexNativeHook(
5386
+ {
5387
+ hook_event_name: "Stop",
5388
+ cwd,
5389
+ session_id: "sess-stop-doc-refresh-exempt",
5390
+ last_assistant_message: `${DOCUMENT_REFRESH_EXEMPTION_PREFIX} internal-only behavior verified`,
5391
+ },
5392
+ { cwd },
5393
+ );
5394
+
5395
+ assert.equal(result.outputJson, null);
5396
+ } finally {
5397
+ await rm(cwd, { recursive: true, force: true });
5398
+ }
5399
+ });
5400
+
3886
5401
  it("returns Stop continuation output while Ralph is active without an explicit session pin", async () => {
3887
5402
  const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-"));
3888
5403
  try {
@@ -3908,10 +5423,10 @@ esac
3908
5423
  assert.deepEqual(result.outputJson, {
3909
5424
  decision: "block",
3910
5425
  reason:
3911
- "OMX Ralph is still active (phase: executing); continue the task and gather fresh verification evidence before stopping.",
5426
+ "OMX Ralph is still active (phase: executing; state: .omx/state/ralph-state.json); continue the task and gather fresh verification evidence before stopping.",
3912
5427
  stopReason: "ralph_executing",
3913
5428
  systemMessage:
3914
- "OMX Ralph is still active (phase: executing); continue the task and gather fresh verification evidence before stopping.",
5429
+ "OMX Ralph is still active (phase: executing; state: .omx/state/ralph-state.json); continue the task and gather fresh verification evidence before stopping.",
3915
5430
  });
3916
5431
  } finally {
3917
5432
  await rm(cwd, { recursive: true, force: true });
@@ -3943,10 +5458,10 @@ esac
3943
5458
  assert.deepEqual(result.outputJson, {
3944
5459
  decision: "block",
3945
5460
  reason:
3946
- "OMX Ralph is still active (phase: executing); continue the task and gather fresh verification evidence before stopping.",
5461
+ "OMX Ralph is still active (phase: executing; state: .omx/state/sessions/sess-live-ralph/ralph-state.json); continue the task and gather fresh verification evidence before stopping.",
3947
5462
  stopReason: "ralph_executing",
3948
5463
  systemMessage:
3949
- "OMX Ralph is still active (phase: executing); continue the task and gather fresh verification evidence before stopping.",
5464
+ "OMX Ralph is still active (phase: executing; state: .omx/state/sessions/sess-live-ralph/ralph-state.json); continue the task and gather fresh verification evidence before stopping.",
3950
5465
  });
3951
5466
  } finally {
3952
5467
  await rm(cwd, { recursive: true, force: true });
@@ -4031,6 +5546,87 @@ esac
4031
5546
  }
4032
5547
  });
4033
5548
 
5549
+ it("does not hard-block Stop on stale session-scoped Ralph starting state after visible active modes are cleared", async () => {
5550
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-cleared-stale-ralph-"));
5551
+ try {
5552
+ const stateDir = join(cwd, ".omx", "state");
5553
+ const sessionId = "sess-cleared-ralph";
5554
+ await mkdir(join(stateDir, "sessions", sessionId), { recursive: true });
5555
+ await writeJson(join(stateDir, "sessions", sessionId, "ralph-state.json"), {
5556
+ active: true,
5557
+ mode: "ralph",
5558
+ current_phase: "starting",
5559
+ session_id: sessionId,
5560
+ });
5561
+ await writeJson(join(stateDir, "skill-active-state.json"), {
5562
+ active: false,
5563
+ skill: "ralph",
5564
+ active_skills: [],
5565
+ });
5566
+
5567
+ const listActive = await executeStateOperation("state_list_active", {
5568
+ workingDirectory: cwd,
5569
+ });
5570
+ assert.deepEqual(listActive.payload, { active_modes: [] });
5571
+
5572
+ const result = await dispatchCodexNativeHook(
5573
+ {
5574
+ hook_event_name: "Stop",
5575
+ cwd,
5576
+ session_id: sessionId,
5577
+ },
5578
+ { cwd },
5579
+ );
5580
+
5581
+ assert.equal(result.omxEventName, "stop");
5582
+ assert.equal(result.outputJson, null);
5583
+ } finally {
5584
+ await rm(cwd, { recursive: true, force: true });
5585
+ }
5586
+ });
5587
+
5588
+ it("blocks Stop on visible active session-scoped Ralph starting state and reports its path", async () => {
5589
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-visible-starting-ralph-"));
5590
+ try {
5591
+ const stateDir = join(cwd, ".omx", "state");
5592
+ const sessionId = "sess-visible-ralph";
5593
+ await mkdir(join(stateDir, "sessions", sessionId), { recursive: true });
5594
+ await writeJson(join(stateDir, "sessions", sessionId, "ralph-state.json"), {
5595
+ active: true,
5596
+ mode: "ralph",
5597
+ current_phase: "starting",
5598
+ session_id: sessionId,
5599
+ });
5600
+ await writeJson(join(stateDir, "sessions", sessionId, "skill-active-state.json"), {
5601
+ active: true,
5602
+ skill: "ralph",
5603
+ phase: "starting",
5604
+ active_skills: [{ skill: "ralph", phase: "starting", active: true, session_id: sessionId }],
5605
+ });
5606
+
5607
+ const result = await dispatchCodexNativeHook(
5608
+ {
5609
+ hook_event_name: "Stop",
5610
+ cwd,
5611
+ session_id: sessionId,
5612
+ },
5613
+ { cwd },
5614
+ );
5615
+
5616
+ assert.equal(result.omxEventName, "stop");
5617
+ assert.deepEqual(result.outputJson, {
5618
+ decision: "block",
5619
+ reason:
5620
+ "OMX Ralph is still active (phase: starting; state: .omx/state/sessions/sess-visible-ralph/ralph-state.json); continue the task and gather fresh verification evidence before stopping.",
5621
+ stopReason: "ralph_starting",
5622
+ systemMessage:
5623
+ "OMX Ralph is still active (phase: starting; state: .omx/state/sessions/sess-visible-ralph/ralph-state.json); continue the task and gather fresh verification evidence before stopping.",
5624
+ });
5625
+ } finally {
5626
+ await rm(cwd, { recursive: true, force: true });
5627
+ }
5628
+ });
5629
+
4034
5630
  it("does not block Stop from another session-scoped Ralph state when an explicit session_id has no active Ralph state", async () => {
4035
5631
  const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-explicit-session-ralph-"));
4036
5632
  try {
@@ -4058,6 +5654,46 @@ esac
4058
5654
  }
4059
5655
  });
4060
5656
 
5657
+ it("prefers canonical run-state terminal lifecycle before stale session Ralph state during Stop", async () => {
5658
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-canonical-run-state-ralph-"));
5659
+ try {
5660
+ const stateDir = join(cwd, ".omx", "state");
5661
+ const sessionId = "sess-canonical-run-state-ralph";
5662
+ await mkdir(join(stateDir, "sessions", sessionId), { recursive: true });
5663
+ await writeJson(join(stateDir, "session.json"), { session_id: sessionId, cwd });
5664
+ await writeJson(join(stateDir, "sessions", sessionId, "run-state.json"), {
5665
+ version: 1,
5666
+ mode: "ralph",
5667
+ active: false,
5668
+ outcome: "finish",
5669
+ lifecycle_outcome: "finished",
5670
+ current_phase: "complete",
5671
+ completed_at: "2026-04-27T12:00:00.000Z",
5672
+ updated_at: "2026-04-27T12:00:00.000Z",
5673
+ });
5674
+ await writeJson(join(stateDir, "sessions", sessionId, "ralph-state.json"), {
5675
+ active: true,
5676
+ mode: "ralph",
5677
+ current_phase: "verifying",
5678
+ session_id: sessionId,
5679
+ });
5680
+
5681
+ const result = await dispatchCodexNativeHook(
5682
+ {
5683
+ hook_event_name: "Stop",
5684
+ cwd,
5685
+ session_id: sessionId,
5686
+ },
5687
+ { cwd },
5688
+ );
5689
+
5690
+ assert.equal(result.omxEventName, "stop");
5691
+ assert.equal(result.outputJson, null);
5692
+ } finally {
5693
+ await rm(cwd, { recursive: true, force: true });
5694
+ }
5695
+ });
5696
+
4061
5697
  it("does not block Stop from root Ralph fallback when the current session has no scoped Ralph state", async () => {
4062
5698
  const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-root-fallback-ralph-"));
4063
5699
  try {
@@ -4171,10 +5807,10 @@ esac
4171
5807
  const expected = {
4172
5808
  decision: "block",
4173
5809
  reason:
4174
- "OMX Ralph is still active (phase: executing); continue the task and gather fresh verification evidence before stopping.",
5810
+ "OMX Ralph is still active (phase: executing; state: .omx/state/ralph-state.json); continue the task and gather fresh verification evidence before stopping.",
4175
5811
  stopReason: "ralph_executing",
4176
5812
  systemMessage:
4177
- "OMX Ralph is still active (phase: executing); continue the task and gather fresh verification evidence before stopping.",
5813
+ "OMX Ralph is still active (phase: executing; state: .omx/state/ralph-state.json); continue the task and gather fresh verification evidence before stopping.",
4178
5814
  };
4179
5815
 
4180
5816
  const first = await dispatchCodexNativeHook(payload, { cwd });
@@ -5566,6 +7202,452 @@ describe("codex native hook triage integration", () => {
5566
7202
  }
5567
7203
  });
5568
7204
 
7205
+ it("injects LIGHT/researcher advisory and writes state for an official-doc lookup prompt", async () => {
7206
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-light-researcher-"));
7207
+ try {
7208
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
7209
+ const result = await dispatchCodexNativeHook(
7210
+ {
7211
+ hook_event_name: "UserPromptSubmit",
7212
+ cwd,
7213
+ session_id: "triage-researcher-1",
7214
+ thread_id: "thread-triage-researcher-1",
7215
+ turn_id: "turn-triage-researcher-1",
7216
+ prompt: "Find the official docs and version compatibility notes for this SDK",
7217
+ },
7218
+ { cwd },
7219
+ );
7220
+
7221
+ const additionalContext = String(
7222
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
7223
+ );
7224
+ assert.match(additionalContext, /external documentation\/reference research request/);
7225
+ assert.match(additionalContext, /Prefer the researcher role surface/);
7226
+ assert.doesNotMatch(additionalContext, /skill: researcher activated/);
7227
+
7228
+ assert.equal(existsSync(join(cwd, ".omx", "state", "skill-active-state.json")), false);
7229
+
7230
+ const stateFile = join(cwd, ".omx", "state", "sessions", "triage-researcher-1", "prompt-routing-state.json");
7231
+ assert.equal(existsSync(stateFile), true);
7232
+ const state = JSON.parse(await readFile(stateFile, "utf-8")) as {
7233
+ last_triage?: { lane?: string; destination?: string; reason?: string };
7234
+ suppress_followup?: boolean;
7235
+ };
7236
+ assert.equal(state.last_triage?.lane, "LIGHT");
7237
+ assert.equal(state.last_triage?.destination, "researcher");
7238
+ assert.equal(state.last_triage?.reason, "external_reference_research");
7239
+ assert.equal(state.suppress_followup, true);
7240
+ } finally {
7241
+ await rm(cwd, { recursive: true, force: true });
7242
+ }
7243
+ });
7244
+
7245
+ it("routes Korean external lookup phrasing to researcher without treating it as workflow activation", async () => {
7246
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-light-researcher-ko-"));
7247
+ try {
7248
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
7249
+ const result = await dispatchCodexNativeHook(
7250
+ {
7251
+ hook_event_name: "UserPromptSubmit",
7252
+ cwd,
7253
+ session_id: "triage-researcher-ko-1",
7254
+ thread_id: "thread-triage-researcher-ko-1",
7255
+ turn_id: "turn-triage-researcher-ko-1",
7256
+ prompt: "OpenAI Responses API 공식 문서 찾아줘",
7257
+ },
7258
+ { cwd },
7259
+ );
7260
+
7261
+ const additionalContext = String(
7262
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
7263
+ );
7264
+ assert.match(additionalContext, /Prefer the researcher role surface/);
7265
+ assert.equal(result.skillState, null);
7266
+
7267
+ const stateFile = join(cwd, ".omx", "state", "sessions", "triage-researcher-ko-1", "prompt-routing-state.json");
7268
+ const state = JSON.parse(await readFile(stateFile, "utf-8")) as {
7269
+ last_triage?: { lane?: string; destination?: string };
7270
+ };
7271
+ assert.equal(state.last_triage?.lane, "LIGHT");
7272
+ assert.equal(state.last_triage?.destination, "researcher");
7273
+ } finally {
7274
+ await rm(cwd, { recursive: true, force: true });
7275
+ }
7276
+ });
7277
+
7278
+ it("routes official-doc question prompts to researcher instead of explore", async () => {
7279
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-question-researcher-"));
7280
+ try {
7281
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
7282
+ const result = await dispatchCodexNativeHook(
7283
+ {
7284
+ hook_event_name: "UserPromptSubmit",
7285
+ cwd,
7286
+ session_id: "triage-question-researcher-1",
7287
+ thread_id: "thread-triage-question-researcher-1",
7288
+ turn_id: "turn-triage-question-researcher-1",
7289
+ prompt: "where can I find official docs for OpenAI Responses API?",
7290
+ },
7291
+ { cwd },
7292
+ );
7293
+
7294
+ const additionalContext = String(
7295
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
7296
+ );
7297
+ assert.doesNotMatch(additionalContext, /Prefer the explore role surface/);
7298
+ assert.match(additionalContext, /Prefer the researcher role surface/);
7299
+
7300
+ const stateFile = join(cwd, ".omx", "state", "sessions", "triage-question-researcher-1", "prompt-routing-state.json");
7301
+ const state = JSON.parse(await readFile(stateFile, "utf-8")) as {
7302
+ last_triage?: { lane?: string; destination?: string; reason?: string };
7303
+ };
7304
+ assert.equal(state.last_triage?.lane, "LIGHT");
7305
+ assert.equal(state.last_triage?.destination, "researcher");
7306
+ assert.equal(state.last_triage?.reason, "external_reference_research");
7307
+ } finally {
7308
+ await rm(cwd, { recursive: true, force: true });
7309
+ }
7310
+ });
7311
+
7312
+ it("routes endpoint-shaped official-doc lookups to researcher instead of local explore", async () => {
7313
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-endpoint-researcher-"));
7314
+ try {
7315
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
7316
+ const result = await dispatchCodexNativeHook(
7317
+ {
7318
+ hook_event_name: "UserPromptSubmit",
7319
+ cwd,
7320
+ session_id: "triage-endpoint-researcher-1",
7321
+ thread_id: "thread-triage-endpoint-researcher-1",
7322
+ turn_id: "turn-triage-endpoint-researcher-1",
7323
+ prompt: "find official docs for api/v1/responses",
7324
+ },
7325
+ { cwd },
7326
+ );
7327
+
7328
+ const additionalContext = String(
7329
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
7330
+ );
7331
+ assert.doesNotMatch(additionalContext, /Prefer the explore role surface/);
7332
+ assert.match(additionalContext, /Prefer the researcher role surface/);
7333
+
7334
+ const stateFile = join(cwd, ".omx", "state", "sessions", "triage-endpoint-researcher-1", "prompt-routing-state.json");
7335
+ const state = JSON.parse(await readFile(stateFile, "utf-8")) as {
7336
+ last_triage?: { lane?: string; destination?: string; reason?: string };
7337
+ };
7338
+ assert.equal(state.last_triage?.lane, "LIGHT");
7339
+ assert.equal(state.last_triage?.destination, "researcher");
7340
+ assert.equal(state.last_triage?.reason, "external_reference_research");
7341
+ } finally {
7342
+ await rm(cwd, { recursive: true, force: true });
7343
+ }
7344
+ });
7345
+
7346
+ it("routes dotted technology official-doc lookups to researcher instead of local explore", async () => {
7347
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-dotted-tech-researcher-"));
7348
+ try {
7349
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
7350
+ const result = await dispatchCodexNativeHook(
7351
+ {
7352
+ hook_event_name: "UserPromptSubmit",
7353
+ cwd,
7354
+ session_id: "triage-dotted-tech-researcher-1",
7355
+ thread_id: "thread-triage-dotted-tech-researcher-1",
7356
+ turn_id: "turn-triage-dotted-tech-researcher-1",
7357
+ prompt: "find official docs for Node.js",
7358
+ },
7359
+ { cwd },
7360
+ );
7361
+
7362
+ const additionalContext = String(
7363
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
7364
+ );
7365
+ assert.doesNotMatch(additionalContext, /Prefer the explore role surface/);
7366
+ assert.match(additionalContext, /Prefer the researcher role surface/);
7367
+
7368
+ const stateFile = join(cwd, ".omx", "state", "sessions", "triage-dotted-tech-researcher-1", "prompt-routing-state.json");
7369
+ const state = JSON.parse(await readFile(stateFile, "utf-8")) as {
7370
+ last_triage?: { lane?: string; destination?: string; reason?: string };
7371
+ };
7372
+ assert.equal(state.last_triage?.lane, "LIGHT");
7373
+ assert.equal(state.last_triage?.destination, "researcher");
7374
+ assert.equal(state.last_triage?.reason, "external_reference_research");
7375
+ } finally {
7376
+ await rm(cwd, { recursive: true, force: true });
7377
+ }
7378
+ });
7379
+
7380
+ it("routes URL-shaped official-doc lookups with repo paths to researcher instead of local routes", async () => {
7381
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-url-path-researcher-"));
7382
+ try {
7383
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
7384
+ const result = await dispatchCodexNativeHook(
7385
+ {
7386
+ hook_event_name: "UserPromptSubmit",
7387
+ cwd,
7388
+ session_id: "triage-url-path-researcher-1",
7389
+ thread_id: "thread-triage-url-path-researcher-1",
7390
+ turn_id: "turn-triage-url-path-researcher-1",
7391
+ prompt: "find official docs for github.com/org/repo/src/foo.ts",
7392
+ },
7393
+ { cwd },
7394
+ );
7395
+
7396
+ const additionalContext = String(
7397
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
7398
+ );
7399
+ assert.doesNotMatch(additionalContext, /Prefer the executor role surface/);
7400
+ assert.doesNotMatch(additionalContext, /Prefer the explore role surface/);
7401
+ assert.match(additionalContext, /Prefer the researcher role surface/);
7402
+
7403
+ const stateFile = join(cwd, ".omx", "state", "sessions", "triage-url-path-researcher-1", "prompt-routing-state.json");
7404
+ const state = JSON.parse(await readFile(stateFile, "utf-8")) as {
7405
+ last_triage?: { lane?: string; destination?: string; reason?: string };
7406
+ };
7407
+ assert.equal(state.last_triage?.lane, "LIGHT");
7408
+ assert.equal(state.last_triage?.destination, "researcher");
7409
+ assert.equal(state.last_triage?.reason, "external_reference_research");
7410
+ } finally {
7411
+ await rm(cwd, { recursive: true, force: true });
7412
+ }
7413
+ });
7414
+
7415
+ it("keeps implementation-shaped official-doc prompts on HEAVY instead of researcher", async () => {
7416
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-researcher-implementation-"));
7417
+ try {
7418
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
7419
+ const result = await dispatchCodexNativeHook(
7420
+ {
7421
+ hook_event_name: "UserPromptSubmit",
7422
+ cwd,
7423
+ session_id: "triage-researcher-implementation-1",
7424
+ thread_id: "thread-triage-researcher-implementation-1",
7425
+ turn_id: "turn-triage-researcher-implementation-1",
7426
+ prompt: "implement auth using official docs for the SDK",
7427
+ },
7428
+ { cwd },
7429
+ );
7430
+
7431
+ const additionalContext = String(
7432
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
7433
+ );
7434
+ assert.doesNotMatch(additionalContext, /Prefer the researcher role surface/);
7435
+ assert.match(additionalContext, /multi-step goal with no workflow keyword/);
7436
+
7437
+ const stateFile = join(cwd, ".omx", "state", "sessions", "triage-researcher-implementation-1", "prompt-routing-state.json");
7438
+ const state = JSON.parse(await readFile(stateFile, "utf-8")) as {
7439
+ last_triage?: { lane?: string; destination?: string; reason?: string };
7440
+ };
7441
+ assert.equal(state.last_triage?.lane, "HEAVY");
7442
+ assert.equal(state.last_triage?.destination, "autopilot");
7443
+ assert.equal(state.last_triage?.reason, "implementation_research_goal");
7444
+ } finally {
7445
+ await rm(cwd, { recursive: true, force: true });
7446
+ }
7447
+ });
7448
+
7449
+ it("keeps planning-shaped official-doc prompts on HEAVY instead of researcher", async () => {
7450
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-researcher-planning-"));
7451
+ try {
7452
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
7453
+ const result = await dispatchCodexNativeHook(
7454
+ {
7455
+ hook_event_name: "UserPromptSubmit",
7456
+ cwd,
7457
+ session_id: "triage-researcher-planning-1",
7458
+ thread_id: "thread-triage-researcher-planning-1",
7459
+ turn_id: "turn-triage-researcher-planning-1",
7460
+ prompt: "research and plan auth migration using official docs for the SDK",
7461
+ },
7462
+ { cwd },
7463
+ );
7464
+
7465
+ const additionalContext = String(
7466
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
7467
+ );
7468
+ assert.doesNotMatch(additionalContext, /Prefer the researcher role surface/);
7469
+ assert.match(additionalContext, /multi-step goal with no workflow keyword/);
7470
+
7471
+ const stateFile = join(cwd, ".omx", "state", "sessions", "triage-researcher-planning-1", "prompt-routing-state.json");
7472
+ const state = JSON.parse(await readFile(stateFile, "utf-8")) as {
7473
+ last_triage?: { lane?: string; destination?: string; reason?: string };
7474
+ };
7475
+ assert.equal(state.last_triage?.lane, "HEAVY");
7476
+ assert.equal(state.last_triage?.destination, "autopilot");
7477
+ assert.equal(state.last_triage?.reason, "implementation_research_goal");
7478
+ } finally {
7479
+ await rm(cwd, { recursive: true, force: true });
7480
+ }
7481
+ });
7482
+
7483
+ it("keeps local source lookup prompts off researcher", async () => {
7484
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-local-source-explore-"));
7485
+ try {
7486
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
7487
+ const result = await dispatchCodexNativeHook(
7488
+ {
7489
+ hook_event_name: "UserPromptSubmit",
7490
+ cwd,
7491
+ session_id: "triage-local-source-1",
7492
+ thread_id: "thread-triage-local-source-1",
7493
+ turn_id: "turn-triage-local-source-1",
7494
+ prompt: "search source for parseConfig in src/config.ts",
7495
+ },
7496
+ { cwd },
7497
+ );
7498
+
7499
+ const additionalContext = String(
7500
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
7501
+ );
7502
+ assert.doesNotMatch(additionalContext, /Prefer the researcher role surface/);
7503
+ assert.match(additionalContext, /Prefer the executor role surface/);
7504
+
7505
+ const stateFile = join(cwd, ".omx", "state", "sessions", "triage-local-source-1", "prompt-routing-state.json");
7506
+ const state = JSON.parse(await readFile(stateFile, "utf-8")) as {
7507
+ last_triage?: { lane?: string; destination?: string };
7508
+ };
7509
+ assert.equal(state.last_triage?.lane, "LIGHT");
7510
+ assert.equal(state.last_triage?.destination, "executor");
7511
+ } finally {
7512
+ await rm(cwd, { recursive: true, force: true });
7513
+ }
7514
+ });
7515
+
7516
+ it("keeps anchored local API usage prompts on executor instead of researcher", async () => {
7517
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-local-api-executor-"));
7518
+ try {
7519
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
7520
+ const result = await dispatchCodexNativeHook(
7521
+ {
7522
+ hook_event_name: "UserPromptSubmit",
7523
+ cwd,
7524
+ session_id: "triage-local-api-1",
7525
+ thread_id: "thread-triage-local-api-1",
7526
+ turn_id: "turn-triage-local-api-1",
7527
+ prompt: "find API usage in src/foo.ts",
7528
+ },
7529
+ { cwd },
7530
+ );
7531
+
7532
+ const additionalContext = String(
7533
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
7534
+ );
7535
+ assert.doesNotMatch(additionalContext, /Prefer the researcher role surface/);
7536
+ assert.match(additionalContext, /Prefer the executor role surface/);
7537
+
7538
+ const stateFile = join(cwd, ".omx", "state", "sessions", "triage-local-api-1", "prompt-routing-state.json");
7539
+ const state = JSON.parse(await readFile(stateFile, "utf-8")) as {
7540
+ last_triage?: { lane?: string; destination?: string };
7541
+ };
7542
+ assert.equal(state.last_triage?.lane, "LIGHT");
7543
+ assert.equal(state.last_triage?.destination, "executor");
7544
+ } finally {
7545
+ await rm(cwd, { recursive: true, force: true });
7546
+ }
7547
+ });
7548
+
7549
+ it("keeps project-scoped local API usage prompts on explore instead of researcher", async () => {
7550
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-project-api-explore-"));
7551
+ try {
7552
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
7553
+ const result = await dispatchCodexNativeHook(
7554
+ {
7555
+ hook_event_name: "UserPromptSubmit",
7556
+ cwd,
7557
+ session_id: "triage-project-api-1",
7558
+ thread_id: "thread-triage-project-api-1",
7559
+ turn_id: "turn-triage-project-api-1",
7560
+ prompt: "find API usage in this project",
7561
+ },
7562
+ { cwd },
7563
+ );
7564
+
7565
+ const additionalContext = String(
7566
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
7567
+ );
7568
+ assert.doesNotMatch(additionalContext, /Prefer the researcher role surface/);
7569
+ assert.match(additionalContext, /Prefer the explore role surface/);
7570
+
7571
+ const stateFile = join(cwd, ".omx", "state", "sessions", "triage-project-api-1", "prompt-routing-state.json");
7572
+ const state = JSON.parse(await readFile(stateFile, "utf-8")) as {
7573
+ last_triage?: { lane?: string; destination?: string; reason?: string };
7574
+ };
7575
+ assert.equal(state.last_triage?.lane, "LIGHT");
7576
+ assert.equal(state.last_triage?.destination, "explore");
7577
+ assert.equal(state.last_triage?.reason, "local_reference_lookup");
7578
+ } finally {
7579
+ await rm(cwd, { recursive: true, force: true });
7580
+ }
7581
+ });
7582
+
7583
+ it("keeps repository changelog lookup prompts on explore despite generic docs terms", async () => {
7584
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-repo-changelog-explore-"));
7585
+ try {
7586
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
7587
+ const result = await dispatchCodexNativeHook(
7588
+ {
7589
+ hook_event_name: "UserPromptSubmit",
7590
+ cwd,
7591
+ session_id: "triage-repo-changelog-1",
7592
+ thread_id: "thread-triage-repo-changelog-1",
7593
+ turn_id: "turn-triage-repo-changelog-1",
7594
+ prompt: "find changelog in this repository",
7595
+ },
7596
+ { cwd },
7597
+ );
7598
+
7599
+ const additionalContext = String(
7600
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
7601
+ );
7602
+ assert.doesNotMatch(additionalContext, /Prefer the researcher role surface/);
7603
+ assert.match(additionalContext, /Prefer the explore role surface/);
7604
+
7605
+ const stateFile = join(cwd, ".omx", "state", "sessions", "triage-repo-changelog-1", "prompt-routing-state.json");
7606
+ const state = JSON.parse(await readFile(stateFile, "utf-8")) as {
7607
+ last_triage?: { lane?: string; destination?: string; reason?: string };
7608
+ };
7609
+ assert.equal(state.last_triage?.lane, "LIGHT");
7610
+ assert.equal(state.last_triage?.destination, "explore");
7611
+ assert.equal(state.last_triage?.reason, "local_reference_lookup");
7612
+ } finally {
7613
+ await rm(cwd, { recursive: true, force: true });
7614
+ }
7615
+ });
7616
+
7617
+ it("routes anchored read-only questions through explore before executor", async () => {
7618
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-anchored-question-explore-"));
7619
+ try {
7620
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
7621
+ const result = await dispatchCodexNativeHook(
7622
+ {
7623
+ hook_event_name: "UserPromptSubmit",
7624
+ cwd,
7625
+ session_id: "triage-anchored-question-1",
7626
+ thread_id: "thread-triage-anchored-question-1",
7627
+ turn_id: "turn-triage-anchored-question-1",
7628
+ prompt: "what does src/foo.ts do?",
7629
+ },
7630
+ { cwd },
7631
+ );
7632
+
7633
+ const additionalContext = String(
7634
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
7635
+ );
7636
+ assert.doesNotMatch(additionalContext, /Prefer the executor role surface/);
7637
+ assert.match(additionalContext, /Prefer the explore role surface/);
7638
+
7639
+ const stateFile = join(cwd, ".omx", "state", "sessions", "triage-anchored-question-1", "prompt-routing-state.json");
7640
+ const state = JSON.parse(await readFile(stateFile, "utf-8")) as {
7641
+ last_triage?: { lane?: string; destination?: string; reason?: string };
7642
+ };
7643
+ assert.equal(state.last_triage?.lane, "LIGHT");
7644
+ assert.equal(state.last_triage?.destination, "explore");
7645
+ assert.equal(state.last_triage?.reason, "question_or_explanation");
7646
+ } finally {
7647
+ await rm(cwd, { recursive: true, force: true });
7648
+ }
7649
+ });
7650
+
5569
7651
  // ── Group 6: PASS (no triage injection, no state) ────────────────────────
5570
7652
 
5571
7653
  it("produces no triage advisory and no state for trivial greeting prompts", async () => {