oh-my-codex 0.13.2 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (296) hide show
  1. package/Cargo.lock +5 -5
  2. package/Cargo.toml +1 -1
  3. package/dist/autoresearch/__tests__/skill-validation.test.d.ts +2 -0
  4. package/dist/autoresearch/__tests__/skill-validation.test.d.ts.map +1 -0
  5. package/dist/autoresearch/__tests__/skill-validation.test.js +91 -0
  6. package/dist/autoresearch/__tests__/skill-validation.test.js.map +1 -0
  7. package/dist/autoresearch/skill-validation.d.ts +13 -0
  8. package/dist/autoresearch/skill-validation.d.ts.map +1 -0
  9. package/dist/autoresearch/skill-validation.js +165 -0
  10. package/dist/autoresearch/skill-validation.js.map +1 -0
  11. package/dist/catalog/__tests__/schema.test.js +6 -0
  12. package/dist/catalog/__tests__/schema.test.js.map +1 -1
  13. package/dist/cli/__tests__/autoresearch-guided.test.js +236 -273
  14. package/dist/cli/__tests__/autoresearch-guided.test.js.map +1 -1
  15. package/dist/cli/__tests__/autoresearch.test.js +64 -653
  16. package/dist/cli/__tests__/autoresearch.test.js.map +1 -1
  17. package/dist/cli/__tests__/index.test.js +7 -0
  18. package/dist/cli/__tests__/index.test.js.map +1 -1
  19. package/dist/cli/__tests__/nested-help-routing.test.js +2 -1
  20. package/dist/cli/__tests__/nested-help-routing.test.js.map +1 -1
  21. package/dist/cli/__tests__/question.test.d.ts +2 -0
  22. package/dist/cli/__tests__/question.test.d.ts.map +1 -0
  23. package/dist/cli/__tests__/question.test.js +113 -0
  24. package/dist/cli/__tests__/question.test.js.map +1 -0
  25. package/dist/cli/__tests__/session-search-help.test.js +1 -1
  26. package/dist/cli/__tests__/session-search-help.test.js.map +1 -1
  27. package/dist/cli/__tests__/setup-skills-overwrite.test.js +2 -0
  28. package/dist/cli/__tests__/setup-skills-overwrite.test.js.map +1 -1
  29. package/dist/cli/autoresearch-guided.d.ts +24 -7
  30. package/dist/cli/autoresearch-guided.d.ts.map +1 -1
  31. package/dist/cli/autoresearch-guided.js +189 -130
  32. package/dist/cli/autoresearch-guided.js.map +1 -1
  33. package/dist/cli/autoresearch.d.ts +3 -2
  34. package/dist/cli/autoresearch.d.ts.map +1 -1
  35. package/dist/cli/autoresearch.js +29 -305
  36. package/dist/cli/autoresearch.js.map +1 -1
  37. package/dist/cli/doctor.d.ts.map +1 -1
  38. package/dist/cli/doctor.js +43 -0
  39. package/dist/cli/doctor.js.map +1 -1
  40. package/dist/cli/index.d.ts +1 -1
  41. package/dist/cli/index.d.ts.map +1 -1
  42. package/dist/cli/index.js +8 -1
  43. package/dist/cli/index.js.map +1 -1
  44. package/dist/cli/question.d.ts +3 -0
  45. package/dist/cli/question.d.ts.map +1 -0
  46. package/dist/cli/question.js +182 -0
  47. package/dist/cli/question.js.map +1 -0
  48. package/dist/hooks/__tests__/analyze-routing-contract.test.js +22 -13
  49. package/dist/hooks/__tests__/analyze-routing-contract.test.js.map +1 -1
  50. package/dist/hooks/__tests__/anti-slop-workflow.test.js +3 -3
  51. package/dist/hooks/__tests__/anti-slop-workflow.test.js.map +1 -1
  52. package/dist/hooks/__tests__/debugger-log-recency-contract.test.js +2 -2
  53. package/dist/hooks/__tests__/debugger-log-recency-contract.test.js.map +1 -1
  54. package/dist/hooks/__tests__/deep-interview-contract.test.js +22 -5
  55. package/dist/hooks/__tests__/deep-interview-contract.test.js.map +1 -1
  56. package/dist/hooks/__tests__/explore-sparkshell-guidance-contract.test.js +2 -2
  57. package/dist/hooks/__tests__/explore-sparkshell-guidance-contract.test.js.map +1 -1
  58. package/dist/hooks/__tests__/keyword-detector.test.js +308 -17
  59. package/dist/hooks/__tests__/keyword-detector.test.js.map +1 -1
  60. package/dist/hooks/__tests__/notify-fallback-watcher.test.js +570 -2
  61. package/dist/hooks/__tests__/notify-fallback-watcher.test.js.map +1 -1
  62. package/dist/hooks/__tests__/notify-hook-auto-nudge.test.js +717 -16
  63. package/dist/hooks/__tests__/notify-hook-auto-nudge.test.js.map +1 -1
  64. package/dist/hooks/__tests__/notify-hook-cross-worktree-heartbeat.test.js +25 -0
  65. package/dist/hooks/__tests__/notify-hook-cross-worktree-heartbeat.test.js.map +1 -1
  66. package/dist/hooks/__tests__/notify-hook-managed-tmux.test.js +894 -1
  67. package/dist/hooks/__tests__/notify-hook-managed-tmux.test.js.map +1 -1
  68. package/dist/hooks/__tests__/notify-hook-ralph-resume.test.js +34 -0
  69. package/dist/hooks/__tests__/notify-hook-ralph-resume.test.js.map +1 -1
  70. package/dist/hooks/__tests__/notify-hook-tmux-heal.test.js +132 -0
  71. package/dist/hooks/__tests__/notify-hook-tmux-heal.test.js.map +1 -1
  72. package/dist/hooks/__tests__/prompt-guidance-contract.test.js +22 -4
  73. package/dist/hooks/__tests__/prompt-guidance-contract.test.js.map +1 -1
  74. package/dist/hooks/__tests__/prompt-guidance-fragments.test.js +4 -2
  75. package/dist/hooks/__tests__/prompt-guidance-fragments.test.js.map +1 -1
  76. package/dist/hooks/__tests__/prompt-guidance-test-helpers.d.ts +1 -0
  77. package/dist/hooks/__tests__/prompt-guidance-test-helpers.d.ts.map +1 -1
  78. package/dist/hooks/__tests__/prompt-guidance-test-helpers.js +4 -1
  79. package/dist/hooks/__tests__/prompt-guidance-test-helpers.js.map +1 -1
  80. package/dist/hooks/__tests__/prompt-guidance-wave-two.test.js +28 -0
  81. package/dist/hooks/__tests__/prompt-guidance-wave-two.test.js.map +1 -1
  82. package/dist/hooks/__tests__/prompt-orchestration-boundary.test.js +5 -4
  83. package/dist/hooks/__tests__/prompt-orchestration-boundary.test.js.map +1 -1
  84. package/dist/hooks/__tests__/prompt-team-routing.test.js +2 -2
  85. package/dist/hooks/__tests__/prompt-team-routing.test.js.map +1 -1
  86. package/dist/hooks/__tests__/triage-config.test.d.ts +2 -0
  87. package/dist/hooks/__tests__/triage-config.test.d.ts.map +1 -0
  88. package/dist/hooks/__tests__/triage-config.test.js +211 -0
  89. package/dist/hooks/__tests__/triage-config.test.js.map +1 -0
  90. package/dist/hooks/__tests__/triage-heuristic.test.d.ts +2 -0
  91. package/dist/hooks/__tests__/triage-heuristic.test.d.ts.map +1 -0
  92. package/dist/hooks/__tests__/triage-heuristic.test.js +230 -0
  93. package/dist/hooks/__tests__/triage-heuristic.test.js.map +1 -0
  94. package/dist/hooks/__tests__/triage-state.test.d.ts +2 -0
  95. package/dist/hooks/__tests__/triage-state.test.d.ts.map +1 -0
  96. package/dist/hooks/__tests__/triage-state.test.js +426 -0
  97. package/dist/hooks/__tests__/triage-state.test.js.map +1 -0
  98. package/dist/hooks/keyword-detector.d.ts +26 -7
  99. package/dist/hooks/keyword-detector.d.ts.map +1 -1
  100. package/dist/hooks/keyword-detector.js +97 -26
  101. package/dist/hooks/keyword-detector.js.map +1 -1
  102. package/dist/hooks/keyword-registry.d.ts.map +1 -1
  103. package/dist/hooks/keyword-registry.js +16 -9
  104. package/dist/hooks/keyword-registry.js.map +1 -1
  105. package/dist/hooks/prompt-guidance-contract.d.ts.map +1 -1
  106. package/dist/hooks/prompt-guidance-contract.js +28 -1
  107. package/dist/hooks/prompt-guidance-contract.js.map +1 -1
  108. package/dist/hooks/triage-config.d.ts +33 -0
  109. package/dist/hooks/triage-config.d.ts.map +1 -0
  110. package/dist/hooks/triage-config.js +87 -0
  111. package/dist/hooks/triage-config.js.map +1 -0
  112. package/dist/hooks/triage-heuristic.d.ts +20 -0
  113. package/dist/hooks/triage-heuristic.d.ts.map +1 -0
  114. package/dist/hooks/triage-heuristic.js +210 -0
  115. package/dist/hooks/triage-heuristic.js.map +1 -0
  116. package/dist/hooks/triage-state.d.ts +63 -0
  117. package/dist/hooks/triage-state.d.ts.map +1 -0
  118. package/dist/hooks/triage-state.js +138 -0
  119. package/dist/hooks/triage-state.js.map +1 -0
  120. package/dist/hud/__tests__/reconcile.test.js +20 -0
  121. package/dist/hud/__tests__/reconcile.test.js.map +1 -1
  122. package/dist/hud/reconcile.d.ts +1 -0
  123. package/dist/hud/reconcile.d.ts.map +1 -1
  124. package/dist/hud/reconcile.js +2 -1
  125. package/dist/hud/reconcile.js.map +1 -1
  126. package/dist/mcp/__tests__/state-server.test.js +1 -0
  127. package/dist/mcp/__tests__/state-server.test.js.map +1 -1
  128. package/dist/mcp/state-server.d.ts +8 -0
  129. package/dist/mcp/state-server.d.ts.map +1 -1
  130. package/dist/mcp/state-server.js +4 -0
  131. package/dist/mcp/state-server.js.map +1 -1
  132. package/dist/modes/__tests__/base-ralph-contract.test.js +15 -0
  133. package/dist/modes/__tests__/base-ralph-contract.test.js.map +1 -1
  134. package/dist/modes/base.d.ts +1 -0
  135. package/dist/modes/base.d.ts.map +1 -1
  136. package/dist/modes/base.js +22 -6
  137. package/dist/modes/base.js.map +1 -1
  138. package/dist/notifications/__tests__/index.test.js +78 -0
  139. package/dist/notifications/__tests__/index.test.js.map +1 -1
  140. package/dist/notifications/index.d.ts.map +1 -1
  141. package/dist/notifications/index.js +39 -22
  142. package/dist/notifications/index.js.map +1 -1
  143. package/dist/openclaw/index.d.ts +5 -3
  144. package/dist/openclaw/index.d.ts.map +1 -1
  145. package/dist/openclaw/index.js +5 -3
  146. package/dist/openclaw/index.js.map +1 -1
  147. package/dist/question/__tests__/client.test.d.ts +2 -0
  148. package/dist/question/__tests__/client.test.d.ts.map +1 -0
  149. package/dist/question/__tests__/client.test.js +70 -0
  150. package/dist/question/__tests__/client.test.js.map +1 -0
  151. package/dist/question/__tests__/deep-interview.test.d.ts +2 -0
  152. package/dist/question/__tests__/deep-interview.test.d.ts.map +1 -0
  153. package/dist/question/__tests__/deep-interview.test.js +108 -0
  154. package/dist/question/__tests__/deep-interview.test.js.map +1 -0
  155. package/dist/question/__tests__/policy.test.d.ts +2 -0
  156. package/dist/question/__tests__/policy.test.d.ts.map +1 -0
  157. package/dist/question/__tests__/policy.test.js +107 -0
  158. package/dist/question/__tests__/policy.test.js.map +1 -0
  159. package/dist/question/__tests__/renderer.test.d.ts +2 -0
  160. package/dist/question/__tests__/renderer.test.d.ts.map +1 -0
  161. package/dist/question/__tests__/renderer.test.js +88 -0
  162. package/dist/question/__tests__/renderer.test.js.map +1 -0
  163. package/dist/question/__tests__/state.test.d.ts +2 -0
  164. package/dist/question/__tests__/state.test.d.ts.map +1 -0
  165. package/dist/question/__tests__/state.test.js +55 -0
  166. package/dist/question/__tests__/state.test.js.map +1 -0
  167. package/dist/question/__tests__/types.test.d.ts +2 -0
  168. package/dist/question/__tests__/types.test.d.ts.map +1 -0
  169. package/dist/question/__tests__/types.test.js +44 -0
  170. package/dist/question/__tests__/types.test.js.map +1 -0
  171. package/dist/question/__tests__/ui.test.d.ts +2 -0
  172. package/dist/question/__tests__/ui.test.d.ts.map +1 -0
  173. package/dist/question/__tests__/ui.test.js +169 -0
  174. package/dist/question/__tests__/ui.test.js.map +1 -0
  175. package/dist/question/client.d.ts +54 -0
  176. package/dist/question/client.d.ts.map +1 -0
  177. package/dist/question/client.js +77 -0
  178. package/dist/question/client.js.map +1 -0
  179. package/dist/question/deep-interview.d.ts +27 -0
  180. package/dist/question/deep-interview.d.ts.map +1 -0
  181. package/dist/question/deep-interview.js +101 -0
  182. package/dist/question/deep-interview.js.map +1 -0
  183. package/dist/question/policy.d.ts +18 -0
  184. package/dist/question/policy.d.ts.map +1 -0
  185. package/dist/question/policy.js +77 -0
  186. package/dist/question/policy.js.map +1 -0
  187. package/dist/question/renderer.d.ts +18 -0
  188. package/dist/question/renderer.d.ts.map +1 -0
  189. package/dist/question/renderer.js +128 -0
  190. package/dist/question/renderer.js.map +1 -0
  191. package/dist/question/state.d.ts +19 -0
  192. package/dist/question/state.d.ts.map +1 -0
  193. package/dist/question/state.js +108 -0
  194. package/dist/question/state.js.map +1 -0
  195. package/dist/question/types.d.ts +66 -0
  196. package/dist/question/types.d.ts.map +1 -0
  197. package/dist/question/types.js +82 -0
  198. package/dist/question/types.js.map +1 -0
  199. package/dist/question/ui.d.ts +38 -0
  200. package/dist/question/ui.d.ts.map +1 -0
  201. package/dist/question/ui.js +321 -0
  202. package/dist/question/ui.js.map +1 -0
  203. package/dist/ralph/contract.d.ts +1 -1
  204. package/dist/ralph/contract.d.ts.map +1 -1
  205. package/dist/ralph/contract.js +4 -1
  206. package/dist/ralph/contract.js.map +1 -1
  207. package/dist/ralplan/runtime.js +1 -1
  208. package/dist/ralplan/runtime.js.map +1 -1
  209. package/dist/runtime/__tests__/run-loop.test.d.ts +2 -0
  210. package/dist/runtime/__tests__/run-loop.test.d.ts.map +1 -0
  211. package/dist/runtime/__tests__/run-loop.test.js +35 -0
  212. package/dist/runtime/__tests__/run-loop.test.js.map +1 -0
  213. package/dist/runtime/__tests__/run-outcome.test.d.ts +2 -0
  214. package/dist/runtime/__tests__/run-outcome.test.d.ts.map +1 -0
  215. package/dist/runtime/__tests__/run-outcome.test.js +64 -0
  216. package/dist/runtime/__tests__/run-outcome.test.js.map +1 -0
  217. package/dist/runtime/run-loop.d.ts +41 -0
  218. package/dist/runtime/run-loop.d.ts.map +1 -0
  219. package/dist/runtime/run-loop.js +46 -0
  220. package/dist/runtime/run-loop.js.map +1 -0
  221. package/dist/runtime/run-outcome.d.ts +28 -0
  222. package/dist/runtime/run-outcome.d.ts.map +1 -0
  223. package/dist/runtime/run-outcome.js +136 -0
  224. package/dist/runtime/run-outcome.js.map +1 -0
  225. package/dist/runtime/run-state.d.ts +36 -0
  226. package/dist/runtime/run-state.d.ts.map +1 -0
  227. package/dist/runtime/run-state.js +110 -0
  228. package/dist/runtime/run-state.js.map +1 -0
  229. package/dist/scripts/__tests__/codex-native-hook.test.js +1128 -85
  230. package/dist/scripts/__tests__/codex-native-hook.test.js.map +1 -1
  231. package/dist/scripts/codex-native-hook.d.ts +2 -0
  232. package/dist/scripts/codex-native-hook.d.ts.map +1 -1
  233. package/dist/scripts/codex-native-hook.js +199 -11
  234. package/dist/scripts/codex-native-hook.js.map +1 -1
  235. package/dist/scripts/notify-fallback-watcher.js +81 -2
  236. package/dist/scripts/notify-fallback-watcher.js.map +1 -1
  237. package/dist/scripts/notify-hook/auto-nudge.d.ts +27 -0
  238. package/dist/scripts/notify-hook/auto-nudge.d.ts.map +1 -1
  239. package/dist/scripts/notify-hook/auto-nudge.js +83 -20
  240. package/dist/scripts/notify-hook/auto-nudge.js.map +1 -1
  241. package/dist/scripts/notify-hook/managed-tmux.d.ts.map +1 -1
  242. package/dist/scripts/notify-hook/managed-tmux.js +64 -38
  243. package/dist/scripts/notify-hook/managed-tmux.js.map +1 -1
  244. package/dist/scripts/notify-hook/ralph-session-resume.js +1 -1
  245. package/dist/scripts/notify-hook/ralph-session-resume.js.map +1 -1
  246. package/dist/scripts/notify-hook.js +15 -5
  247. package/dist/scripts/notify-hook.js.map +1 -1
  248. package/dist/scripts/sync-prompt-guidance-fragments.js +5 -0
  249. package/dist/scripts/sync-prompt-guidance-fragments.js.map +1 -1
  250. package/dist/state/__tests__/operations-ralph-phase.test.js +21 -0
  251. package/dist/state/__tests__/operations-ralph-phase.test.js.map +1 -1
  252. package/dist/state/__tests__/workflow-transition.test.js +11 -0
  253. package/dist/state/__tests__/workflow-transition.test.js.map +1 -1
  254. package/dist/state/operations.d.ts.map +1 -1
  255. package/dist/state/operations.js +15 -0
  256. package/dist/state/operations.js.map +1 -1
  257. package/dist/state/workflow-transition-reconcile.d.ts.map +1 -1
  258. package/dist/state/workflow-transition-reconcile.js +14 -1
  259. package/dist/state/workflow-transition-reconcile.js.map +1 -1
  260. package/dist/state/workflow-transition.d.ts.map +1 -1
  261. package/dist/state/workflow-transition.js +3 -1
  262. package/dist/state/workflow-transition.js.map +1 -1
  263. package/dist/team/__tests__/followup-planner.test.js +15 -0
  264. package/dist/team/__tests__/followup-planner.test.js.map +1 -1
  265. package/dist/team/__tests__/role-router.test.js +41 -0
  266. package/dist/team/__tests__/role-router.test.js.map +1 -1
  267. package/dist/team/followup-planner.d.ts.map +1 -1
  268. package/dist/team/followup-planner.js +31 -9
  269. package/dist/team/followup-planner.js.map +1 -1
  270. package/dist/team/role-router.d.ts.map +1 -1
  271. package/dist/team/role-router.js +73 -0
  272. package/dist/team/role-router.js.map +1 -1
  273. package/package.json +3 -2
  274. package/prompts/dependency-expert.md +3 -0
  275. package/prompts/executor.md +5 -0
  276. package/prompts/explore.md +2 -0
  277. package/prompts/planner.md +5 -0
  278. package/prompts/product-analyst.md +8 -8
  279. package/prompts/researcher.md +78 -30
  280. package/prompts/verifier.md +4 -0
  281. package/skills/autoresearch/SKILL.md +68 -0
  282. package/skills/deep-interview/SKILL.md +10 -9
  283. package/skills/help/SKILL.md +3 -1
  284. package/skills/ralplan/SKILL.md +1 -0
  285. package/skills/team/SKILL.md +1 -0
  286. package/skills/ultrawork/SKILL.md +1 -0
  287. package/src/scripts/__tests__/codex-native-hook.test.ts +1495 -188
  288. package/src/scripts/codex-native-hook.ts +235 -19
  289. package/src/scripts/notify-fallback-watcher.ts +92 -2
  290. package/src/scripts/notify-hook/auto-nudge.ts +89 -20
  291. package/src/scripts/notify-hook/managed-tmux.ts +70 -31
  292. package/src/scripts/notify-hook/ralph-session-resume.ts +1 -1
  293. package/src/scripts/notify-hook.ts +23 -5
  294. package/src/scripts/sync-prompt-guidance-fragments.ts +4 -0
  295. package/templates/AGENTS.md +48 -37
  296. package/templates/catalog-manifest.json +7 -0
@@ -9,6 +9,7 @@ import { buildManagedCodexHooksConfig } from "../../config/codex-hooks.js";
9
9
  import { initTeamState, readTeamLeaderAttention, readTeamPhase, writeTeamLeaderAttention, } from "../../team/state.js";
10
10
  import { dispatchCodexNativeHook, mapCodexHookEventToOmxEvent, resolveSessionOwnerPidFromAncestry, } from "../codex-native-hook.js";
11
11
  import { writeSessionStart } from "../../hooks/session.js";
12
+ import { resetTriageConfigCache } from "../../hooks/triage-config.js";
12
13
  async function writeJson(path, value) {
13
14
  await mkdir(dirname(path), { recursive: true }).catch(() => { });
14
15
  await writeFile(path, JSON.stringify(value, null, 2));
@@ -174,6 +175,40 @@ describe("codex native hook dispatch", () => {
174
175
  await rm(cwd, { recursive: true, force: true });
175
176
  }
176
177
  });
178
+ it("passes the canonical OMX session id when UserPromptSubmit revives HUD", async () => {
179
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-hud-session-revive-"));
180
+ try {
181
+ const stateDir = join(cwd, ".omx", "state");
182
+ const canonicalSessionId = "omx-launch-hud";
183
+ const nativeSessionId = "codex-native-hud";
184
+ await mkdir(join(stateDir, "sessions", canonicalSessionId), { recursive: true });
185
+ await writeSessionStart(cwd, canonicalSessionId);
186
+ let reconcileCall = null;
187
+ const promptResult = await dispatchCodexNativeHook({
188
+ hook_event_name: "UserPromptSubmit",
189
+ cwd,
190
+ session_id: nativeSessionId,
191
+ thread_id: "thread-hud",
192
+ turn_id: "turn-hud",
193
+ prompt: "$ralplan fix orphaned hud session handoff",
194
+ }, {
195
+ cwd,
196
+ reconcileHudForPromptSubmitFn: async (hookCwd, deps = {}) => {
197
+ reconcileCall = { cwd: hookCwd, sessionId: deps.sessionId };
198
+ return { status: 'recreated', paneId: '%9', desiredHeight: 3, duplicateCount: 0 };
199
+ },
200
+ });
201
+ assert.equal(promptResult.omxEventName, "keyword-detector");
202
+ assert.deepEqual(reconcileCall, { cwd, sessionId: canonicalSessionId });
203
+ assert.equal(existsSync(join(stateDir, "sessions", canonicalSessionId, "skill-active-state.json")), true);
204
+ assert.equal(existsSync(join(stateDir, "sessions", canonicalSessionId, "ralplan-state.json")), true);
205
+ assert.equal(existsSync(join(stateDir, "sessions", nativeSessionId, "skill-active-state.json")), false);
206
+ assert.equal(existsSync(join(stateDir, "sessions", nativeSessionId, "ralplan-state.json")), false);
207
+ }
208
+ finally {
209
+ await rm(cwd, { recursive: true, force: true });
210
+ }
211
+ });
177
212
  it("appends .omx/ to repo-root .gitignore during SessionStart when missing", async () => {
178
213
  const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-session-gitignore-"));
179
214
  try {
@@ -352,7 +387,12 @@ describe("codex native hook dispatch", () => {
352
387
  }, { cwd });
353
388
  assert.equal(result.omxEventName, "keyword-detector");
354
389
  assert.equal(result.skillState, null);
355
- assert.equal(result.outputJson, null);
390
+ // Triage may inject advisory LIGHT/explore context for the question-shaped
391
+ // prompt, but the invariant this test guards is that no Ralph workflow state
392
+ // is seeded and no Ralph-activation message is emitted.
393
+ const advisoryContext = String(result.outputJson?.hookSpecificOutput?.additionalContext || "");
394
+ assert.doesNotMatch(advisoryContext, /skill:\s*ralph/i);
395
+ assert.doesNotMatch(advisoryContext, /ralph-state\.json/i);
356
396
  assert.equal(existsSync(join(cwd, ".omx", "state", "skill-active-state.json")), false);
357
397
  assert.equal(existsSync(join(cwd, ".omx", "state", "sessions", "sess-ralph-plain-text", "skill-active-state.json")), false);
358
398
  assert.equal(existsSync(join(cwd, ".omx", "state", "sessions", "sess-ralph-plain-text", "ralph-state.json")), false);
@@ -361,6 +401,54 @@ describe("codex native hook dispatch", () => {
361
401
  await rm(cwd, { recursive: true, force: true });
362
402
  }
363
403
  });
404
+ it("adds execution handoff context for non-keyword prompts that authorize implementation", async () => {
405
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-execution-handoff-"));
406
+ try {
407
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
408
+ const prompts = [
409
+ "按照这个plan开始执行优化",
410
+ "开始执行",
411
+ "继续优化",
412
+ "直接修复",
413
+ ];
414
+ for (const [index, prompt] of prompts.entries()) {
415
+ const result = await dispatchCodexNativeHook({
416
+ hook_event_name: "UserPromptSubmit",
417
+ cwd,
418
+ session_id: `sess-exec-handoff-${index}`,
419
+ thread_id: `thread-exec-handoff-${index}`,
420
+ turn_id: `turn-exec-handoff-${index}`,
421
+ prompt,
422
+ }, { cwd });
423
+ const message = String(result.outputJson?.hookSpecificOutput?.additionalContext || "");
424
+ assert.match(message, /execution handoff/i, prompt);
425
+ assert.match(message, /Do not restate the prior plan/i, prompt);
426
+ }
427
+ }
428
+ finally {
429
+ await rm(cwd, { recursive: true, force: true });
430
+ }
431
+ });
432
+ it("adds latest-followup priority context for short same-thread follow-up prompts", async () => {
433
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-followup-priority-"));
434
+ try {
435
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
436
+ const result = await dispatchCodexNativeHook({
437
+ hook_event_name: "UserPromptSubmit",
438
+ cwd,
439
+ session_id: "sess-followup-priority",
440
+ thread_id: "thread-followup-priority",
441
+ turn_id: "turn-followup-priority",
442
+ prompt: "这些优化都做了么",
443
+ }, { cwd });
444
+ const message = String(result.outputJson?.hookSpecificOutput?.additionalContext || "");
445
+ assert.match(message, /same-thread follow-up/i);
446
+ assert.match(message, /prefer it over older unresolved prompts/i);
447
+ }
448
+ finally {
449
+ await rm(cwd, { recursive: true, force: true });
450
+ }
451
+ });
364
452
  it("clarifies that prompt-side $ralph activation does not invoke the PRD-gated CLI path", async () => {
365
453
  const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-ralph-routing-"));
366
454
  try {
@@ -385,6 +473,123 @@ describe("codex native hook dispatch", () => {
385
473
  await rm(cwd, { recursive: true, force: true });
386
474
  }
387
475
  });
476
+ it("keeps bare keep-going continuation on the active autopilot skill instead of denying with generic ralph overlap", async () => {
477
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-autopilot-bare-continuation-"));
478
+ try {
479
+ const sessionId = "sess-autopilot-cont";
480
+ const sessionDir = join(cwd, ".omx", "state", "sessions", sessionId);
481
+ await mkdir(sessionDir, { recursive: true });
482
+ await writeJson(join(sessionDir, "skill-active-state.json"), {
483
+ version: 1,
484
+ active: true,
485
+ skill: "autopilot",
486
+ keyword: "$autopilot",
487
+ phase: "planning",
488
+ session_id: sessionId,
489
+ active_skills: [
490
+ { skill: "autopilot", phase: "planning", active: true, session_id: sessionId },
491
+ ],
492
+ });
493
+ await writeJson(join(sessionDir, "autopilot-state.json"), {
494
+ active: true,
495
+ mode: "autopilot",
496
+ current_phase: "execution",
497
+ started_at: "2026-04-19T00:00:00.000Z",
498
+ updated_at: "2026-04-19T00:10:00.000Z",
499
+ session_id: sessionId,
500
+ });
501
+ const result = await dispatchCodexNativeHook({
502
+ hook_event_name: "UserPromptSubmit",
503
+ cwd,
504
+ session_id: sessionId,
505
+ thread_id: "thread-autopilot-cont",
506
+ turn_id: "turn-autopilot-cont",
507
+ prompt: "\ keep going now",
508
+ }, { cwd });
509
+ assert.equal(result.omxEventName, "keyword-detector");
510
+ assert.equal(result.skillState?.skill, "autopilot");
511
+ const message = String(result.outputJson?.hookSpecificOutput?.additionalContext || "");
512
+ assert.match(message, /"keep going" -> ralph/);
513
+ assert.doesNotMatch(message, /denied workflow keyword/i);
514
+ assert.doesNotMatch(message, /Unsupported workflow overlap: autopilot \+ ralph\./);
515
+ assert.doesNotMatch(message, /Prompt-side `\$ralph` activation/);
516
+ assert.equal(existsSync(join(sessionDir, "ralph-state.json")), false);
517
+ }
518
+ finally {
519
+ await rm(cwd, { recursive: true, force: true });
520
+ }
521
+ });
522
+ it("clarifies that prompt-side deep-interview activation must use omx question", async () => {
523
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-deep-interview-routing-"));
524
+ try {
525
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
526
+ const result = await dispatchCodexNativeHook({
527
+ hook_event_name: "UserPromptSubmit",
528
+ cwd,
529
+ session_id: "sess-deep-interview-msg",
530
+ thread_id: "thread-deep-interview-msg",
531
+ turn_id: "turn-deep-interview-msg",
532
+ prompt: "$deep-interview gather requirements",
533
+ }, { cwd });
534
+ assert.equal(result.omxEventName, "keyword-detector");
535
+ assert.equal(result.skillState?.skill, "deep-interview");
536
+ const message = String(result.outputJson?.hookSpecificOutput?.additionalContext || "");
537
+ assert.match(message, /\$deep-interview" -> deep-interview/);
538
+ 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\./);
539
+ assert.match(message, /Deep-interview must ask each interview round via `omx question`/);
540
+ assert.match(message, /do not fall back to `request_user_input` or plain-text questioning/i);
541
+ assert.match(message, /Stop remains blocked while a deep-interview question obligation is pending\./);
542
+ }
543
+ finally {
544
+ await rm(cwd, { recursive: true, force: true });
545
+ }
546
+ });
547
+ it("keeps bare keep-going continuation on the active ralph skill without resetting through generic keep-going routing", async () => {
548
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-ralph-bare-continuation-"));
549
+ try {
550
+ const sessionId = "sess-ralph-cont";
551
+ const sessionDir = join(cwd, ".omx", "state", "sessions", sessionId);
552
+ await mkdir(sessionDir, { recursive: true });
553
+ await writeJson(join(sessionDir, "skill-active-state.json"), {
554
+ version: 1,
555
+ active: true,
556
+ skill: "ralph",
557
+ keyword: "$ralph",
558
+ phase: "executing",
559
+ session_id: sessionId,
560
+ active_skills: [
561
+ { skill: "ralph", phase: "executing", active: true, session_id: sessionId },
562
+ ],
563
+ });
564
+ await writeJson(join(sessionDir, "ralph-state.json"), {
565
+ active: true,
566
+ mode: "ralph",
567
+ current_phase: "verifying",
568
+ started_at: "2026-04-19T00:00:00.000Z",
569
+ updated_at: "2026-04-19T00:10:00.000Z",
570
+ iteration: 4,
571
+ max_iterations: 50,
572
+ session_id: sessionId,
573
+ });
574
+ const result = await dispatchCodexNativeHook({
575
+ hook_event_name: "UserPromptSubmit",
576
+ cwd,
577
+ session_id: sessionId,
578
+ thread_id: "thread-ralph-cont",
579
+ turn_id: "turn-ralph-cont",
580
+ prompt: "keep going now",
581
+ }, { cwd });
582
+ assert.equal(result.omxEventName, "keyword-detector");
583
+ assert.equal(result.skillState?.skill, "ralph");
584
+ const message = String(result.outputJson?.hookSpecificOutput?.additionalContext || "");
585
+ assert.match(message, /"keep going" -> ralph/);
586
+ assert.doesNotMatch(message, /denied workflow keyword/i);
587
+ assert.doesNotMatch(message, /mode transiting:/);
588
+ }
589
+ finally {
590
+ await rm(cwd, { recursive: true, force: true });
591
+ }
592
+ });
388
593
  it("ignores generic wrapper fields so metadata cannot trigger workflow routing or Stop blocking", async () => {
389
594
  const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-wrapper-metadata-"));
390
595
  try {
@@ -1517,6 +1722,28 @@ esac
1517
1722
  await rm(cwd, { recursive: true, force: true });
1518
1723
  }
1519
1724
  });
1725
+ it("does not block Stop when an explicit blocked_on_user run_outcome is present on a mode state", async () => {
1726
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-autopilot-blocked-outcome-"));
1727
+ try {
1728
+ const stateDir = join(cwd, ".omx", "state");
1729
+ await mkdir(stateDir, { recursive: true });
1730
+ await writeJson(join(stateDir, "autopilot-state.json"), {
1731
+ active: true,
1732
+ current_phase: "execution",
1733
+ run_outcome: "blocked_on_user",
1734
+ });
1735
+ const result = await dispatchCodexNativeHook({
1736
+ hook_event_name: "Stop",
1737
+ cwd,
1738
+ session_id: "sess-stop-autopilot-blocked-outcome",
1739
+ }, { cwd });
1740
+ assert.equal(result.omxEventName, "stop");
1741
+ assert.equal(result.outputJson, null);
1742
+ }
1743
+ finally {
1744
+ await rm(cwd, { recursive: true, force: true });
1745
+ }
1746
+ });
1520
1747
  it("returns Stop continuation output while Ultrawork is active", async () => {
1521
1748
  const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-ultrawork-"));
1522
1749
  try {
@@ -2082,166 +2309,468 @@ esac
2082
2309
  await rm(cwd, { recursive: true, force: true });
2083
2310
  }
2084
2311
  });
2085
- it("does not block Stop solely because deep-interview is active", async () => {
2086
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-deep-interview-"));
2312
+ it("blocks Stop while autoresearch is active without validator completion", async () => {
2313
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-autoresearch-"));
2087
2314
  try {
2088
2315
  const stateDir = join(cwd, ".omx", "state");
2089
- await mkdir(join(stateDir, "sessions", "sess-stop-deep-interview"), { recursive: true });
2090
- await writeJson(join(stateDir, "session.json"), { session_id: "sess-stop-deep-interview" });
2091
- await writeJson(join(stateDir, "sessions", "sess-stop-deep-interview", "skill-active-state.json"), {
2092
- active: true,
2093
- skill: "deep-interview",
2094
- phase: "planning",
2095
- });
2096
- await writeJson(join(stateDir, "sessions", "sess-stop-deep-interview", "deep-interview-state.json"), {
2316
+ await mkdir(join(stateDir, "sessions", "sess-stop-autoresearch"), { recursive: true });
2317
+ await writeJson(join(stateDir, "session.json"), { session_id: "sess-stop-autoresearch", cwd });
2318
+ await writeJson(join(stateDir, "sessions", "sess-stop-autoresearch", "autoresearch-state.json"), {
2097
2319
  active: true,
2098
- current_phase: "planning",
2320
+ mode: "autoresearch",
2321
+ current_phase: "executing",
2322
+ session_id: "sess-stop-autoresearch",
2323
+ validation_mode: "mission-validator-script",
2324
+ mission_validator_command: "node scripts/validate.js",
2325
+ completion_artifact_path: '.omx/specs/autoresearch-demo/completion.json',
2099
2326
  });
2100
2327
  const result = await dispatchCodexNativeHook({
2101
2328
  hook_event_name: "Stop",
2102
2329
  cwd,
2103
- session_id: "sess-stop-deep-interview",
2330
+ session_id: "sess-stop-autoresearch",
2104
2331
  }, { cwd });
2105
- assert.equal(result.outputJson, null);
2332
+ assert.equal(result.omxEventName, "stop");
2333
+ assert.deepEqual(result.outputJson, {
2334
+ decision: "block",
2335
+ reason: "OMX autoresearch is still active (phase: executing); continue until validator evidence is complete before stopping.",
2336
+ stopReason: "autoresearch_executing",
2337
+ systemMessage: "OMX autoresearch is still active (phase: executing); continue until validator evidence is complete before stopping.",
2338
+ });
2106
2339
  }
2107
2340
  finally {
2108
2341
  await rm(cwd, { recursive: true, force: true });
2109
2342
  }
2110
2343
  });
2111
- it("ignores root skill-active fallback from a different thread when evaluating Stop", async () => {
2112
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-foreign-thread-"));
2344
+ it("allows Stop once autoresearch validator evidence is complete", async () => {
2345
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-autoresearch-complete-"));
2113
2346
  try {
2114
2347
  const stateDir = join(cwd, ".omx", "state");
2115
- await mkdir(stateDir, { recursive: true });
2116
- await writeJson(join(stateDir, "skill-active-state.json"), {
2348
+ const specDir = join(cwd, '.omx', 'specs', 'autoresearch-demo');
2349
+ await mkdir(join(stateDir, "sessions", "sess-stop-autoresearch-complete"), { recursive: true });
2350
+ await mkdir(specDir, { recursive: true });
2351
+ await writeJson(join(stateDir, "session.json"), { session_id: "sess-stop-autoresearch-complete", cwd });
2352
+ await writeJson(join(stateDir, "sessions", "sess-stop-autoresearch-complete", "autoresearch-state.json"), {
2117
2353
  active: true,
2118
- skill: "deep-interview",
2119
- phase: "planning",
2120
- session_id: "",
2121
- thread_id: "other-thread",
2354
+ mode: "autoresearch",
2355
+ current_phase: "reviewing",
2356
+ session_id: "sess-stop-autoresearch-complete",
2357
+ validation_mode: "mission-validator-script",
2358
+ mission_validator_command: "node scripts/validate.js",
2359
+ completion_artifact_path: '.omx/specs/autoresearch-demo/completion.json',
2122
2360
  });
2361
+ await writeJson(join(specDir, 'completion.json'), { status: 'passed', passed: true });
2123
2362
  const result = await dispatchCodexNativeHook({
2124
2363
  hook_event_name: "Stop",
2125
2364
  cwd,
2126
- session_id: "sess-stop-main",
2127
- thread_id: "main-thread",
2365
+ session_id: "sess-stop-autoresearch-complete",
2128
2366
  }, { cwd });
2367
+ assert.equal(result.omxEventName, "stop");
2129
2368
  assert.equal(result.outputJson, null);
2130
2369
  }
2131
2370
  finally {
2132
2371
  await rm(cwd, { recursive: true, force: true });
2133
2372
  }
2134
2373
  });
2135
- it("returns Stop continuation output while Ralph is active without an explicit session pin", async () => {
2136
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-"));
2374
+ it("does not block Stop from stale root autoresearch state when the explicit session has no scoped autoresearch state", async () => {
2375
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-stale-root-autoresearch-"));
2137
2376
  try {
2138
2377
  const stateDir = join(cwd, ".omx", "state");
2139
- await mkdir(stateDir, { recursive: true });
2140
- await writeFile(join(stateDir, "ralph-state.json"), JSON.stringify({
2378
+ const specDir = join(cwd, '.omx', 'specs', 'autoresearch-demo');
2379
+ await mkdir(join(stateDir, 'sessions', 'sess-current'), { recursive: true });
2380
+ await mkdir(specDir, { recursive: true });
2381
+ await writeJson(join(stateDir, 'session.json'), { session_id: 'sess-current', cwd });
2382
+ await writeJson(join(stateDir, 'autoresearch-state.json'), {
2141
2383
  active: true,
2142
- current_phase: "executing",
2143
- }));
2384
+ mode: 'autoresearch',
2385
+ current_phase: 'executing',
2386
+ validation_mode: 'mission-validator-script',
2387
+ mission_validator_command: 'node scripts/validate.js',
2388
+ completion_artifact_path: '.omx/specs/autoresearch-demo/completion.json',
2389
+ });
2144
2390
  const result = await dispatchCodexNativeHook({
2145
- hook_event_name: "Stop",
2391
+ hook_event_name: 'Stop',
2146
2392
  cwd,
2393
+ session_id: 'sess-current',
2147
2394
  }, { cwd });
2148
- assert.equal(result.omxEventName, "stop");
2149
- assert.deepEqual(result.outputJson, {
2150
- decision: "block",
2151
- reason: "OMX Ralph is still active (phase: executing); continue the task and gather fresh verification evidence before stopping.",
2152
- stopReason: "ralph_executing",
2153
- systemMessage: "OMX Ralph is still active (phase: executing); continue the task and gather fresh verification evidence before stopping.",
2154
- });
2395
+ assert.equal(result.omxEventName, 'stop');
2396
+ assert.equal(result.outputJson, null);
2155
2397
  }
2156
2398
  finally {
2157
2399
  await rm(cwd, { recursive: true, force: true });
2158
2400
  }
2159
2401
  });
2160
- it("blocks Stop from session-scoped Ralph state when session.json points to another session", async () => {
2161
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-ralph-session-mismatch-"));
2402
+ it("does not block Stop solely because deep-interview is active", async () => {
2403
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-deep-interview-"));
2162
2404
  try {
2163
2405
  const stateDir = join(cwd, ".omx", "state");
2164
- await mkdir(join(stateDir, "sessions", "sess-live-ralph"), { recursive: true });
2165
- await writeJson(join(stateDir, "session.json"), { session_id: "sess-other-ralph" });
2166
- await writeJson(join(stateDir, "sessions", "sess-live-ralph", "ralph-state.json"), {
2406
+ await mkdir(join(stateDir, "sessions", "sess-stop-deep-interview"), { recursive: true });
2407
+ await writeJson(join(stateDir, "session.json"), { session_id: "sess-stop-deep-interview" });
2408
+ await writeJson(join(stateDir, "sessions", "sess-stop-deep-interview", "skill-active-state.json"), {
2167
2409
  active: true,
2168
- current_phase: "executing",
2169
- session_id: "sess-live-ralph",
2410
+ skill: "deep-interview",
2411
+ phase: "planning",
2412
+ });
2413
+ await writeJson(join(stateDir, "sessions", "sess-stop-deep-interview", "deep-interview-state.json"), {
2414
+ active: true,
2415
+ current_phase: "planning",
2170
2416
  });
2171
2417
  const result = await dispatchCodexNativeHook({
2172
2418
  hook_event_name: "Stop",
2173
2419
  cwd,
2174
- session_id: "sess-live-ralph",
2420
+ session_id: "sess-stop-deep-interview",
2175
2421
  }, { cwd });
2176
- assert.equal(result.omxEventName, "stop");
2177
- assert.deepEqual(result.outputJson, {
2178
- decision: "block",
2179
- reason: "OMX Ralph is still active (phase: executing); continue the task and gather fresh verification evidence before stopping.",
2180
- stopReason: "ralph_executing",
2181
- systemMessage: "OMX Ralph is still active (phase: executing); continue the task and gather fresh verification evidence before stopping.",
2182
- });
2422
+ assert.equal(result.outputJson, null);
2183
2423
  }
2184
2424
  finally {
2185
2425
  await rm(cwd, { recursive: true, force: true });
2186
2426
  }
2187
2427
  });
2188
- it("does not block Stop from stale session-scoped Ralph state that belongs to another session", async () => {
2189
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-stale-session-ralph-"));
2428
+ it("blocks Stop when deep-interview has a pending omx question obligation", async () => {
2429
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-deep-interview-question-"));
2190
2430
  try {
2191
2431
  const stateDir = join(cwd, ".omx", "state");
2192
- await mkdir(join(stateDir, "sessions", "sess-current"), { recursive: true });
2193
- await mkdir(join(stateDir, "sessions", "sess-stale"), { recursive: true });
2194
- await writeJson(join(stateDir, "session.json"), { session_id: "sess-current" });
2195
- await writeJson(join(stateDir, "sessions", "sess-stale", "ralph-state.json"), {
2432
+ await mkdir(join(stateDir, "sessions", "sess-stop-deep-interview-question"), { recursive: true });
2433
+ await writeJson(join(stateDir, "session.json"), { session_id: "sess-stop-deep-interview-question" });
2434
+ await writeJson(join(stateDir, "sessions", "sess-stop-deep-interview-question", "skill-active-state.json"), {
2435
+ version: 1,
2196
2436
  active: true,
2197
- current_phase: "starting",
2198
- session_id: "sess-stale",
2437
+ skill: "deep-interview",
2438
+ phase: "planning",
2439
+ session_id: "sess-stop-deep-interview-question",
2440
+ thread_id: "thread-stop-deep-interview-question",
2441
+ });
2442
+ await writeJson(join(stateDir, "sessions", "sess-stop-deep-interview-question", "deep-interview-state.json"), {
2443
+ active: true,
2444
+ mode: "deep-interview",
2445
+ current_phase: "intent-first",
2446
+ session_id: "sess-stop-deep-interview-question",
2447
+ thread_id: "thread-stop-deep-interview-question",
2448
+ question_enforcement: {
2449
+ obligation_id: "obligation-1",
2450
+ source: "omx-question",
2451
+ status: "pending",
2452
+ requested_at: "2026-04-19T03:20:00.000Z",
2453
+ },
2199
2454
  });
2200
2455
  const result = await dispatchCodexNativeHook({
2201
2456
  hook_event_name: "Stop",
2202
2457
  cwd,
2203
- session_id: "sess-current",
2458
+ session_id: "sess-stop-deep-interview-question",
2459
+ thread_id: "thread-stop-deep-interview-question",
2204
2460
  }, { cwd });
2205
2461
  assert.equal(result.omxEventName, "stop");
2206
- assert.equal(result.outputJson, null);
2462
+ assert.deepEqual(result.outputJson, {
2463
+ decision: "block",
2464
+ reason: "Deep interview is still active (phase: intent-first) and has a pending structured question obligation; use `omx question` before stopping.",
2465
+ stopReason: "deep_interview_question_required",
2466
+ systemMessage: "OMX deep-interview is still active (phase: intent-first) and requires a structured question via omx question before stopping.",
2467
+ });
2207
2468
  }
2208
2469
  finally {
2209
2470
  await rm(cwd, { recursive: true, force: true });
2210
2471
  }
2211
2472
  });
2212
- it("does not block Stop from another session-scoped Ralph state when an explicit session_id has no active Ralph state", async () => {
2213
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-explicit-session-ralph-"));
2473
+ it("keeps blocking pending deep-interview question Stop replays until the obligation changes", async () => {
2474
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-deep-interview-question-replay-"));
2214
2475
  try {
2215
2476
  const stateDir = join(cwd, ".omx", "state");
2216
- await mkdir(join(stateDir, "sessions", "sess-other"), { recursive: true });
2217
- await writeJson(join(stateDir, "sessions", "sess-other", "ralph-state.json"), {
2477
+ await mkdir(join(stateDir, "sessions", "sess-stop-deep-interview-question-replay"), { recursive: true });
2478
+ await writeJson(join(stateDir, "session.json"), { session_id: "sess-stop-deep-interview-question-replay" });
2479
+ await writeJson(join(stateDir, "sessions", "sess-stop-deep-interview-question-replay", "skill-active-state.json"), {
2480
+ version: 1,
2218
2481
  active: true,
2219
- current_phase: "starting",
2220
- session_id: "sess-other",
2482
+ skill: "deep-interview",
2483
+ phase: "planning",
2484
+ session_id: "sess-stop-deep-interview-question-replay",
2221
2485
  });
2222
- const result = await dispatchCodexNativeHook({
2486
+ await writeJson(join(stateDir, "sessions", "sess-stop-deep-interview-question-replay", "deep-interview-state.json"), {
2487
+ active: true,
2488
+ mode: "deep-interview",
2489
+ current_phase: "intent-first",
2490
+ question_enforcement: {
2491
+ obligation_id: "obligation-replay",
2492
+ source: "omx-question",
2493
+ status: "pending",
2494
+ requested_at: "2026-04-19T03:20:00.000Z",
2495
+ },
2496
+ });
2497
+ const payload = {
2223
2498
  hook_event_name: "Stop",
2224
2499
  cwd,
2225
- session_id: "sess-current",
2226
- }, { cwd });
2227
- assert.equal(result.omxEventName, "stop");
2228
- assert.equal(result.outputJson, null);
2500
+ session_id: "sess-stop-deep-interview-question-replay",
2501
+ };
2502
+ const expected = {
2503
+ decision: "block",
2504
+ reason: "Deep interview is still active (phase: intent-first) and has a pending structured question obligation; use `omx question` before stopping.",
2505
+ stopReason: "deep_interview_question_required",
2506
+ systemMessage: "OMX deep-interview is still active (phase: intent-first) and requires a structured question via omx question before stopping.",
2507
+ };
2508
+ const first = await dispatchCodexNativeHook(payload, { cwd });
2509
+ const replay = await dispatchCodexNativeHook({ ...payload, stop_hook_active: true }, { cwd });
2510
+ assert.equal(first.omxEventName, "stop");
2511
+ assert.deepEqual(first.outputJson, expected);
2512
+ assert.equal(replay.omxEventName, "stop");
2513
+ assert.deepEqual(replay.outputJson, expected);
2229
2514
  }
2230
2515
  finally {
2231
2516
  await rm(cwd, { recursive: true, force: true });
2232
2517
  }
2233
2518
  });
2234
- it("does not block Stop from root Ralph fallback when the current session has no scoped Ralph state", async () => {
2235
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-root-fallback-ralph-"));
2236
- try {
2237
- const stateDir = join(cwd, ".omx", "state");
2238
- await mkdir(join(stateDir, "sessions", "sess-current"), { recursive: true });
2239
- await writeJson(join(stateDir, "session.json"), { session_id: "sess-current", cwd });
2240
- await writeJson(join(stateDir, "ralph-state.json"), {
2241
- active: true,
2242
- current_phase: "executing",
2243
- });
2244
- const result = await dispatchCodexNativeHook({
2519
+ it("does not block Stop once the deep-interview question obligation is satisfied or cleared", async () => {
2520
+ for (const status of ["satisfied", "cleared"]) {
2521
+ const cwd = await mkdtemp(join(tmpdir(), `omx-native-hook-stop-deep-interview-question-${status}-`));
2522
+ try {
2523
+ const stateDir = join(cwd, ".omx", "state");
2524
+ await mkdir(join(stateDir, "sessions", `sess-stop-deep-interview-question-${status}`), { recursive: true });
2525
+ await writeJson(join(stateDir, "session.json"), { session_id: `sess-stop-deep-interview-question-${status}` });
2526
+ await writeJson(join(stateDir, "sessions", `sess-stop-deep-interview-question-${status}`, "skill-active-state.json"), {
2527
+ version: 1,
2528
+ active: true,
2529
+ skill: "deep-interview",
2530
+ phase: "planning",
2531
+ session_id: `sess-stop-deep-interview-question-${status}`,
2532
+ });
2533
+ await writeJson(join(stateDir, "sessions", `sess-stop-deep-interview-question-${status}`, "deep-interview-state.json"), {
2534
+ active: true,
2535
+ mode: "deep-interview",
2536
+ current_phase: "intent-first",
2537
+ question_enforcement: {
2538
+ obligation_id: `obligation-${status}`,
2539
+ source: "omx-question",
2540
+ status,
2541
+ requested_at: "2026-04-19T03:20:00.000Z",
2542
+ ...(status === "satisfied"
2543
+ ? { question_id: "question-1", satisfied_at: "2026-04-19T03:21:00.000Z" }
2544
+ : { cleared_at: "2026-04-19T03:21:00.000Z", clear_reason: "error" }),
2545
+ },
2546
+ });
2547
+ const result = await dispatchCodexNativeHook({
2548
+ hook_event_name: "Stop",
2549
+ cwd,
2550
+ session_id: `sess-stop-deep-interview-question-${status}`,
2551
+ }, { cwd });
2552
+ assert.equal(result.omxEventName, "stop");
2553
+ assert.equal(result.outputJson, null);
2554
+ }
2555
+ finally {
2556
+ await rm(cwd, { recursive: true, force: true });
2557
+ }
2558
+ }
2559
+ });
2560
+ it("ignores pending deep-interview question obligations from another session", async () => {
2561
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-deep-interview-question-foreign-session-"));
2562
+ try {
2563
+ const stateDir = join(cwd, ".omx", "state");
2564
+ await mkdir(join(stateDir, "sessions", "sess-other"), { recursive: true });
2565
+ await mkdir(join(stateDir, "sessions", "sess-current"), { recursive: true });
2566
+ await writeJson(join(stateDir, "session.json"), { session_id: "sess-current" });
2567
+ await writeJson(join(stateDir, "sessions", "sess-other", "skill-active-state.json"), {
2568
+ version: 1,
2569
+ active: true,
2570
+ skill: "deep-interview",
2571
+ phase: "planning",
2572
+ session_id: "sess-other",
2573
+ });
2574
+ await writeJson(join(stateDir, "sessions", "sess-other", "deep-interview-state.json"), {
2575
+ active: true,
2576
+ mode: "deep-interview",
2577
+ current_phase: "intent-first",
2578
+ question_enforcement: {
2579
+ obligation_id: "obligation-foreign",
2580
+ source: "omx-question",
2581
+ status: "pending",
2582
+ requested_at: "2026-04-19T03:20:00.000Z",
2583
+ },
2584
+ });
2585
+ const result = await dispatchCodexNativeHook({
2586
+ hook_event_name: "Stop",
2587
+ cwd,
2588
+ session_id: "sess-current",
2589
+ }, { cwd });
2590
+ assert.equal(result.omxEventName, "stop");
2591
+ assert.equal(result.outputJson, null);
2592
+ }
2593
+ finally {
2594
+ await rm(cwd, { recursive: true, force: true });
2595
+ }
2596
+ });
2597
+ it("blocks a new same-session deep-interview question obligation even after an earlier round was satisfied", async () => {
2598
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-deep-interview-question-next-round-"));
2599
+ try {
2600
+ const stateDir = join(cwd, ".omx", "state");
2601
+ await mkdir(join(stateDir, "sessions", "sess-stop-deep-interview-question-next-round"), { recursive: true });
2602
+ await writeJson(join(stateDir, "session.json"), { session_id: "sess-stop-deep-interview-question-next-round" });
2603
+ await writeJson(join(stateDir, "sessions", "sess-stop-deep-interview-question-next-round", "skill-active-state.json"), {
2604
+ version: 1,
2605
+ active: true,
2606
+ skill: "deep-interview",
2607
+ phase: "planning",
2608
+ session_id: "sess-stop-deep-interview-question-next-round",
2609
+ });
2610
+ await writeJson(join(stateDir, "sessions", "sess-stop-deep-interview-question-next-round", "deep-interview-state.json"), {
2611
+ active: true,
2612
+ mode: "deep-interview",
2613
+ current_phase: "intent-first",
2614
+ question_enforcement: {
2615
+ obligation_id: "obligation-next-round",
2616
+ source: "omx-question",
2617
+ status: "pending",
2618
+ requested_at: "2026-04-19T03:22:00.000Z",
2619
+ question_id: "question-old-round",
2620
+ satisfied_at: "2026-04-19T03:21:00.000Z",
2621
+ },
2622
+ });
2623
+ const result = await dispatchCodexNativeHook({
2624
+ hook_event_name: "Stop",
2625
+ cwd,
2626
+ session_id: "sess-stop-deep-interview-question-next-round",
2627
+ }, { cwd });
2628
+ assert.equal(result.omxEventName, "stop");
2629
+ assert.deepEqual(result.outputJson, {
2630
+ decision: "block",
2631
+ reason: "Deep interview is still active (phase: intent-first) and has a pending structured question obligation; use `omx question` before stopping.",
2632
+ stopReason: "deep_interview_question_required",
2633
+ systemMessage: "OMX deep-interview is still active (phase: intent-first) and requires a structured question via omx question before stopping.",
2634
+ });
2635
+ }
2636
+ finally {
2637
+ await rm(cwd, { recursive: true, force: true });
2638
+ }
2639
+ });
2640
+ it("ignores root skill-active fallback from a different thread when evaluating Stop", async () => {
2641
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-foreign-thread-"));
2642
+ try {
2643
+ const stateDir = join(cwd, ".omx", "state");
2644
+ await mkdir(stateDir, { recursive: true });
2645
+ await writeJson(join(stateDir, "skill-active-state.json"), {
2646
+ active: true,
2647
+ skill: "deep-interview",
2648
+ phase: "planning",
2649
+ session_id: "",
2650
+ thread_id: "other-thread",
2651
+ });
2652
+ const result = await dispatchCodexNativeHook({
2653
+ hook_event_name: "Stop",
2654
+ cwd,
2655
+ session_id: "sess-stop-main",
2656
+ thread_id: "main-thread",
2657
+ }, { cwd });
2658
+ assert.equal(result.outputJson, null);
2659
+ }
2660
+ finally {
2661
+ await rm(cwd, { recursive: true, force: true });
2662
+ }
2663
+ });
2664
+ it("returns Stop continuation output while Ralph is active without an explicit session pin", async () => {
2665
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-"));
2666
+ try {
2667
+ const stateDir = join(cwd, ".omx", "state");
2668
+ await mkdir(stateDir, { recursive: true });
2669
+ await writeFile(join(stateDir, "ralph-state.json"), JSON.stringify({
2670
+ active: true,
2671
+ current_phase: "executing",
2672
+ }));
2673
+ const result = await dispatchCodexNativeHook({
2674
+ hook_event_name: "Stop",
2675
+ cwd,
2676
+ }, { cwd });
2677
+ assert.equal(result.omxEventName, "stop");
2678
+ assert.deepEqual(result.outputJson, {
2679
+ decision: "block",
2680
+ reason: "OMX Ralph is still active (phase: executing); continue the task and gather fresh verification evidence before stopping.",
2681
+ stopReason: "ralph_executing",
2682
+ systemMessage: "OMX Ralph is still active (phase: executing); continue the task and gather fresh verification evidence before stopping.",
2683
+ });
2684
+ }
2685
+ finally {
2686
+ await rm(cwd, { recursive: true, force: true });
2687
+ }
2688
+ });
2689
+ it("blocks Stop from session-scoped Ralph state when session.json points to another session", async () => {
2690
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-ralph-session-mismatch-"));
2691
+ try {
2692
+ const stateDir = join(cwd, ".omx", "state");
2693
+ await mkdir(join(stateDir, "sessions", "sess-live-ralph"), { recursive: true });
2694
+ await writeJson(join(stateDir, "session.json"), { session_id: "sess-other-ralph" });
2695
+ await writeJson(join(stateDir, "sessions", "sess-live-ralph", "ralph-state.json"), {
2696
+ active: true,
2697
+ current_phase: "executing",
2698
+ session_id: "sess-live-ralph",
2699
+ });
2700
+ const result = await dispatchCodexNativeHook({
2701
+ hook_event_name: "Stop",
2702
+ cwd,
2703
+ session_id: "sess-live-ralph",
2704
+ }, { cwd });
2705
+ assert.equal(result.omxEventName, "stop");
2706
+ assert.deepEqual(result.outputJson, {
2707
+ decision: "block",
2708
+ reason: "OMX Ralph is still active (phase: executing); continue the task and gather fresh verification evidence before stopping.",
2709
+ stopReason: "ralph_executing",
2710
+ systemMessage: "OMX Ralph is still active (phase: executing); continue the task and gather fresh verification evidence before stopping.",
2711
+ });
2712
+ }
2713
+ finally {
2714
+ await rm(cwd, { recursive: true, force: true });
2715
+ }
2716
+ });
2717
+ it("does not block Stop from stale session-scoped Ralph state that belongs to another session", async () => {
2718
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-stale-session-ralph-"));
2719
+ try {
2720
+ const stateDir = join(cwd, ".omx", "state");
2721
+ await mkdir(join(stateDir, "sessions", "sess-current"), { recursive: true });
2722
+ await mkdir(join(stateDir, "sessions", "sess-stale"), { recursive: true });
2723
+ await writeJson(join(stateDir, "session.json"), { session_id: "sess-current" });
2724
+ await writeJson(join(stateDir, "sessions", "sess-stale", "ralph-state.json"), {
2725
+ active: true,
2726
+ current_phase: "starting",
2727
+ session_id: "sess-stale",
2728
+ });
2729
+ const result = await dispatchCodexNativeHook({
2730
+ hook_event_name: "Stop",
2731
+ cwd,
2732
+ session_id: "sess-current",
2733
+ }, { cwd });
2734
+ assert.equal(result.omxEventName, "stop");
2735
+ assert.equal(result.outputJson, null);
2736
+ }
2737
+ finally {
2738
+ await rm(cwd, { recursive: true, force: true });
2739
+ }
2740
+ });
2741
+ it("does not block Stop from another session-scoped Ralph state when an explicit session_id has no active Ralph state", async () => {
2742
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-explicit-session-ralph-"));
2743
+ try {
2744
+ const stateDir = join(cwd, ".omx", "state");
2745
+ await mkdir(join(stateDir, "sessions", "sess-other"), { recursive: true });
2746
+ await writeJson(join(stateDir, "sessions", "sess-other", "ralph-state.json"), {
2747
+ active: true,
2748
+ current_phase: "starting",
2749
+ session_id: "sess-other",
2750
+ });
2751
+ const result = await dispatchCodexNativeHook({
2752
+ hook_event_name: "Stop",
2753
+ cwd,
2754
+ session_id: "sess-current",
2755
+ }, { cwd });
2756
+ assert.equal(result.omxEventName, "stop");
2757
+ assert.equal(result.outputJson, null);
2758
+ }
2759
+ finally {
2760
+ await rm(cwd, { recursive: true, force: true });
2761
+ }
2762
+ });
2763
+ it("does not block Stop from root Ralph fallback when the current session has no scoped Ralph state", async () => {
2764
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-root-fallback-ralph-"));
2765
+ try {
2766
+ const stateDir = join(cwd, ".omx", "state");
2767
+ await mkdir(join(stateDir, "sessions", "sess-current"), { recursive: true });
2768
+ await writeJson(join(stateDir, "session.json"), { session_id: "sess-current", cwd });
2769
+ await writeJson(join(stateDir, "ralph-state.json"), {
2770
+ active: true,
2771
+ current_phase: "executing",
2772
+ });
2773
+ const result = await dispatchCodexNativeHook({
2245
2774
  hook_event_name: "Stop",
2246
2775
  cwd,
2247
2776
  session_id: "sess-current",
@@ -3142,4 +3671,518 @@ esac
3142
3671
  }
3143
3672
  });
3144
3673
  });
3674
+ // ---------------------------------------------------------------------------
3675
+ // Triage layer integration tests
3676
+ // ---------------------------------------------------------------------------
3677
+ describe("codex native hook triage integration", () => {
3678
+ const priorCodexHome = process.env.CODEX_HOME;
3679
+ beforeEach(() => {
3680
+ resetTriageConfigCache();
3681
+ });
3682
+ afterEach(() => {
3683
+ if (typeof priorCodexHome === "string")
3684
+ process.env.CODEX_HOME = priorCodexHome;
3685
+ else
3686
+ delete process.env.CODEX_HOME;
3687
+ resetTriageConfigCache();
3688
+ });
3689
+ // ── Group 1: Keyword bypass (triage must NOT run) ────────────────────────
3690
+ it("does not inject triage advisory for $ralplan keyword prompts", async () => {
3691
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-keyword-ralplan-"));
3692
+ try {
3693
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
3694
+ const result = await dispatchCodexNativeHook({
3695
+ hook_event_name: "UserPromptSubmit",
3696
+ cwd,
3697
+ session_id: "triage-kw-ralplan-1",
3698
+ thread_id: "thread-triage-kw-1",
3699
+ turn_id: "turn-triage-kw-1",
3700
+ prompt: "$ralplan implement issue #1307",
3701
+ }, { cwd });
3702
+ const additionalContext = String(result.outputJson?.hookSpecificOutput?.additionalContext ?? "");
3703
+ assert.doesNotMatch(additionalContext, /multi-step goal with no workflow keyword/);
3704
+ assert.doesNotMatch(additionalContext, /read-only\/question-shaped/);
3705
+ assert.doesNotMatch(additionalContext, /narrow edit-shaped/);
3706
+ assert.doesNotMatch(additionalContext, /visual\/style request/);
3707
+ const stateFile = join(cwd, ".omx", "state", "sessions", "triage-kw-ralplan-1", "prompt-routing-state.json");
3708
+ assert.equal(existsSync(stateFile), false);
3709
+ }
3710
+ finally {
3711
+ await rm(cwd, { recursive: true, force: true });
3712
+ }
3713
+ });
3714
+ it("does not inject triage advisory for autopilot keyword prompts", async () => {
3715
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-keyword-autopilot-"));
3716
+ try {
3717
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
3718
+ const result = await dispatchCodexNativeHook({
3719
+ hook_event_name: "UserPromptSubmit",
3720
+ cwd,
3721
+ session_id: "triage-kw-autopilot-1",
3722
+ thread_id: "thread-triage-kw-ap-1",
3723
+ turn_id: "turn-triage-kw-ap-1",
3724
+ prompt: "$autopilot build this",
3725
+ }, { cwd });
3726
+ const additionalContext = String(result.outputJson?.hookSpecificOutput?.additionalContext ?? "");
3727
+ assert.doesNotMatch(additionalContext, /multi-step goal with no workflow keyword/);
3728
+ assert.doesNotMatch(additionalContext, /read-only\/question-shaped/);
3729
+ assert.doesNotMatch(additionalContext, /narrow edit-shaped/);
3730
+ assert.doesNotMatch(additionalContext, /visual\/style request/);
3731
+ const stateFile = join(cwd, ".omx", "state", "sessions", "triage-kw-autopilot-1", "prompt-routing-state.json");
3732
+ assert.equal(existsSync(stateFile), false);
3733
+ }
3734
+ finally {
3735
+ await rm(cwd, { recursive: true, force: true });
3736
+ }
3737
+ });
3738
+ // ── Group 2: HEAVY injection ─────────────────────────────────────────────
3739
+ it("injects HEAVY advisory and writes prompt-routing-state for a multi-step goal prompt", async () => {
3740
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-heavy-"));
3741
+ try {
3742
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
3743
+ const result = await dispatchCodexNativeHook({
3744
+ hook_event_name: "UserPromptSubmit",
3745
+ cwd,
3746
+ session_id: "triage-heavy-1",
3747
+ thread_id: "thread-triage-heavy-1",
3748
+ turn_id: "turn-triage-heavy-1",
3749
+ prompt: "add dark mode toggle to the settings page",
3750
+ }, { cwd });
3751
+ const additionalContext = String(result.outputJson?.hookSpecificOutput?.additionalContext ?? "");
3752
+ assert.match(additionalContext, /multi-step goal with no workflow keyword/);
3753
+ assert.match(additionalContext, /Prefer the existing autopilot-style workflow/);
3754
+ // skill-active-state.json must NOT be written (triage is advisory only)
3755
+ assert.equal(existsSync(join(cwd, ".omx", "state", "skill-active-state.json")), false);
3756
+ // prompt-routing-state.json must be written with lane=HEAVY
3757
+ const stateFile = join(cwd, ".omx", "state", "sessions", "triage-heavy-1", "prompt-routing-state.json");
3758
+ assert.equal(existsSync(stateFile), true);
3759
+ const state = JSON.parse(await readFile(stateFile, "utf-8"));
3760
+ assert.equal(state.version, 1);
3761
+ assert.equal(state.last_triage?.lane, "HEAVY");
3762
+ assert.equal(state.last_triage?.destination, "autopilot");
3763
+ assert.equal(state.suppress_followup, true);
3764
+ }
3765
+ finally {
3766
+ await rm(cwd, { recursive: true, force: true });
3767
+ }
3768
+ });
3769
+ // ── Group 3: LIGHT/explore ────────────────────────────────────────────────
3770
+ it("injects LIGHT/explore advisory and writes state for a question-shaped prompt", async () => {
3771
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-light-explore-"));
3772
+ try {
3773
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
3774
+ const result = await dispatchCodexNativeHook({
3775
+ hook_event_name: "UserPromptSubmit",
3776
+ cwd,
3777
+ session_id: "triage-explore-1",
3778
+ thread_id: "thread-triage-explore-1",
3779
+ turn_id: "turn-triage-explore-1",
3780
+ prompt: "explain this function",
3781
+ }, { cwd });
3782
+ const additionalContext = String(result.outputJson?.hookSpecificOutput?.additionalContext ?? "");
3783
+ assert.match(additionalContext, /read-only\/question-shaped/);
3784
+ assert.match(additionalContext, /Prefer the explore role surface/);
3785
+ const stateFile = join(cwd, ".omx", "state", "sessions", "triage-explore-1", "prompt-routing-state.json");
3786
+ assert.equal(existsSync(stateFile), true);
3787
+ const state = JSON.parse(await readFile(stateFile, "utf-8"));
3788
+ assert.equal(state.last_triage?.lane, "LIGHT");
3789
+ assert.equal(state.last_triage?.destination, "explore");
3790
+ assert.equal(state.suppress_followup, true);
3791
+ }
3792
+ finally {
3793
+ await rm(cwd, { recursive: true, force: true });
3794
+ }
3795
+ });
3796
+ // ── Group 4: LIGHT/executor ───────────────────────────────────────────────
3797
+ it("injects LIGHT/executor advisory and writes state for a narrow edit-shaped prompt", async () => {
3798
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-light-executor-"));
3799
+ try {
3800
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
3801
+ const result = await dispatchCodexNativeHook({
3802
+ hook_event_name: "UserPromptSubmit",
3803
+ cwd,
3804
+ session_id: "triage-executor-1",
3805
+ thread_id: "thread-triage-executor-1",
3806
+ turn_id: "turn-triage-executor-1",
3807
+ prompt: "fix typo in src/foo.ts",
3808
+ }, { cwd });
3809
+ const additionalContext = String(result.outputJson?.hookSpecificOutput?.additionalContext ?? "");
3810
+ assert.match(additionalContext, /narrow edit-shaped/);
3811
+ assert.match(additionalContext, /Prefer the executor role surface/);
3812
+ const stateFile = join(cwd, ".omx", "state", "sessions", "triage-executor-1", "prompt-routing-state.json");
3813
+ assert.equal(existsSync(stateFile), true);
3814
+ const state = JSON.parse(await readFile(stateFile, "utf-8"));
3815
+ assert.equal(state.last_triage?.lane, "LIGHT");
3816
+ assert.equal(state.last_triage?.destination, "executor");
3817
+ }
3818
+ finally {
3819
+ await rm(cwd, { recursive: true, force: true });
3820
+ }
3821
+ });
3822
+ // ── Group 5: LIGHT/designer ───────────────────────────────────────────────
3823
+ it("injects LIGHT/designer advisory and writes state for a visual/style prompt", async () => {
3824
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-light-designer-"));
3825
+ try {
3826
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
3827
+ const result = await dispatchCodexNativeHook({
3828
+ hook_event_name: "UserPromptSubmit",
3829
+ cwd,
3830
+ session_id: "triage-designer-1",
3831
+ thread_id: "thread-triage-designer-1",
3832
+ turn_id: "turn-triage-designer-1",
3833
+ prompt: "make the button blue",
3834
+ }, { cwd });
3835
+ const additionalContext = String(result.outputJson?.hookSpecificOutput?.additionalContext ?? "");
3836
+ assert.match(additionalContext, /visual\/style request/);
3837
+ assert.match(additionalContext, /Prefer the designer role surface/);
3838
+ const stateFile = join(cwd, ".omx", "state", "sessions", "triage-designer-1", "prompt-routing-state.json");
3839
+ assert.equal(existsSync(stateFile), true);
3840
+ const state = JSON.parse(await readFile(stateFile, "utf-8"));
3841
+ assert.equal(state.last_triage?.lane, "LIGHT");
3842
+ assert.equal(state.last_triage?.destination, "designer");
3843
+ }
3844
+ finally {
3845
+ await rm(cwd, { recursive: true, force: true });
3846
+ }
3847
+ });
3848
+ // ── Group 6: PASS (no triage injection, no state) ────────────────────────
3849
+ it("produces no triage advisory and no state for trivial greeting prompts", async () => {
3850
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-pass-hello-"));
3851
+ try {
3852
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
3853
+ const result = await dispatchCodexNativeHook({
3854
+ hook_event_name: "UserPromptSubmit",
3855
+ cwd,
3856
+ session_id: "triage-pass-hello-1",
3857
+ thread_id: "thread-triage-pass-1",
3858
+ turn_id: "turn-triage-pass-1",
3859
+ prompt: "hello",
3860
+ }, { cwd });
3861
+ const additionalContext = String(result.outputJson?.hookSpecificOutput?.additionalContext ?? "");
3862
+ assert.doesNotMatch(additionalContext, /multi-step goal with no workflow keyword/);
3863
+ assert.doesNotMatch(additionalContext, /read-only\/question-shaped/);
3864
+ assert.doesNotMatch(additionalContext, /narrow edit-shaped/);
3865
+ assert.doesNotMatch(additionalContext, /visual\/style request/);
3866
+ const stateFile = join(cwd, ".omx", "state", "sessions", "triage-pass-hello-1", "prompt-routing-state.json");
3867
+ assert.equal(existsSync(stateFile), false);
3868
+ }
3869
+ finally {
3870
+ await rm(cwd, { recursive: true, force: true });
3871
+ }
3872
+ });
3873
+ it("produces no triage advisory and no state for ambiguous short prompts", async () => {
3874
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-pass-short-"));
3875
+ try {
3876
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
3877
+ const result = await dispatchCodexNativeHook({
3878
+ hook_event_name: "UserPromptSubmit",
3879
+ cwd,
3880
+ session_id: "triage-pass-short-1",
3881
+ thread_id: "thread-triage-pass-short-1",
3882
+ turn_id: "turn-triage-pass-short-1",
3883
+ prompt: "fix the thing",
3884
+ }, { cwd });
3885
+ const additionalContext = String(result.outputJson?.hookSpecificOutput?.additionalContext ?? "");
3886
+ assert.doesNotMatch(additionalContext, /multi-step goal with no workflow keyword/);
3887
+ assert.doesNotMatch(additionalContext, /read-only\/question-shaped/);
3888
+ assert.doesNotMatch(additionalContext, /narrow edit-shaped/);
3889
+ assert.doesNotMatch(additionalContext, /visual\/style request/);
3890
+ const stateFile = join(cwd, ".omx", "state", "sessions", "triage-pass-short-1", "prompt-routing-state.json");
3891
+ assert.equal(existsSync(stateFile), false);
3892
+ }
3893
+ finally {
3894
+ await rm(cwd, { recursive: true, force: true });
3895
+ }
3896
+ });
3897
+ // ── Group 7: Turn-2 suppression (same session across two invocations) ────
3898
+ it("suppresses HEAVY triage re-injection on a short follow-up in the same session", async () => {
3899
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-suppress-heavy-"));
3900
+ const sessionId = "triage-suppress-heavy-1";
3901
+ try {
3902
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
3903
+ // Turn 1: HEAVY fires
3904
+ const turn1 = await dispatchCodexNativeHook({
3905
+ hook_event_name: "UserPromptSubmit",
3906
+ cwd,
3907
+ session_id: sessionId,
3908
+ thread_id: "thread-suppress-heavy-1",
3909
+ turn_id: "turn-suppress-heavy-1",
3910
+ prompt: "add dark mode toggle to the settings page",
3911
+ }, { cwd });
3912
+ const ctx1 = String(turn1.outputJson?.hookSpecificOutput?.additionalContext ?? "");
3913
+ assert.match(ctx1, /multi-step goal with no workflow keyword/);
3914
+ // Turn 2: short follow-up — triage suppressed
3915
+ const turn2 = await dispatchCodexNativeHook({
3916
+ hook_event_name: "UserPromptSubmit",
3917
+ cwd,
3918
+ session_id: sessionId,
3919
+ thread_id: "thread-suppress-heavy-1",
3920
+ turn_id: "turn-suppress-heavy-2",
3921
+ prompt: "yes, settings page",
3922
+ }, { cwd });
3923
+ const ctx2 = String(turn2.outputJson?.hookSpecificOutput?.additionalContext ?? "");
3924
+ assert.doesNotMatch(ctx2, /multi-step goal/);
3925
+ }
3926
+ finally {
3927
+ await rm(cwd, { recursive: true, force: true });
3928
+ }
3929
+ });
3930
+ it("suppresses LIGHT/explore triage re-injection on a short follow-up in the same session", async () => {
3931
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-suppress-explore-"));
3932
+ const sessionId = "triage-suppress-explore-1";
3933
+ try {
3934
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
3935
+ // Turn 1: LIGHT/explore fires
3936
+ await dispatchCodexNativeHook({
3937
+ hook_event_name: "UserPromptSubmit",
3938
+ cwd,
3939
+ session_id: sessionId,
3940
+ thread_id: "thread-suppress-explore-1",
3941
+ turn_id: "turn-suppress-explore-1",
3942
+ prompt: "explain this function",
3943
+ }, { cwd });
3944
+ // Turn 2: short follow-up — no duplicate LIGHT injection
3945
+ const turn2 = await dispatchCodexNativeHook({
3946
+ hook_event_name: "UserPromptSubmit",
3947
+ cwd,
3948
+ session_id: sessionId,
3949
+ thread_id: "thread-suppress-explore-1",
3950
+ turn_id: "turn-suppress-explore-2",
3951
+ prompt: "the auth helper",
3952
+ }, { cwd });
3953
+ const ctx2 = String(turn2.outputJson?.hookSpecificOutput?.additionalContext ?? "");
3954
+ assert.doesNotMatch(ctx2, /read-only\/question-shaped/);
3955
+ }
3956
+ finally {
3957
+ await rm(cwd, { recursive: true, force: true });
3958
+ }
3959
+ });
3960
+ // ── Group 8: First-turn PASS does NOT block later triage ─────────────────
3961
+ it("still applies triage on turn 2 when turn 1 was a PASS with no state written", async () => {
3962
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-pass-then-light-"));
3963
+ const sessionId = "triage-pass-then-light-1";
3964
+ try {
3965
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
3966
+ // Turn 1: PASS — no state written
3967
+ await dispatchCodexNativeHook({
3968
+ hook_event_name: "UserPromptSubmit",
3969
+ cwd,
3970
+ session_id: sessionId,
3971
+ thread_id: "thread-pass-then-light-1",
3972
+ turn_id: "turn-pass-then-light-1",
3973
+ prompt: "hello",
3974
+ }, { cwd });
3975
+ assert.equal(existsSync(join(cwd, ".omx", "state", "sessions", sessionId, "prompt-routing-state.json")), false);
3976
+ // Turn 2: LIGHT/executor should fire normally
3977
+ const turn2 = await dispatchCodexNativeHook({
3978
+ hook_event_name: "UserPromptSubmit",
3979
+ cwd,
3980
+ session_id: sessionId,
3981
+ thread_id: "thread-pass-then-light-1",
3982
+ turn_id: "turn-pass-then-light-2",
3983
+ prompt: "fix typo in src/foo.ts",
3984
+ }, { cwd });
3985
+ const ctx2 = String(turn2.outputJson?.hookSpecificOutput?.additionalContext ?? "");
3986
+ assert.match(ctx2, /narrow edit-shaped/);
3987
+ }
3988
+ finally {
3989
+ await rm(cwd, { recursive: true, force: true });
3990
+ }
3991
+ });
3992
+ // ── Group 9: Opt-out forces PASS ─────────────────────────────────────────
3993
+ it("produces no triage advisory when prompt contains 'just chat' opt-out", async () => {
3994
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-optout-chat-"));
3995
+ try {
3996
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
3997
+ const result = await dispatchCodexNativeHook({
3998
+ hook_event_name: "UserPromptSubmit",
3999
+ cwd,
4000
+ session_id: "triage-optout-chat-1",
4001
+ thread_id: "thread-optout-chat-1",
4002
+ turn_id: "turn-optout-chat-1",
4003
+ prompt: "add dark mode toggle to the settings page, but just chat about it",
4004
+ }, { cwd });
4005
+ const additionalContext = String(result.outputJson?.hookSpecificOutput?.additionalContext ?? "");
4006
+ assert.doesNotMatch(additionalContext, /multi-step goal with no workflow keyword/);
4007
+ assert.doesNotMatch(additionalContext, /read-only\/question-shaped/);
4008
+ const stateFile = join(cwd, ".omx", "state", "sessions", "triage-optout-chat-1", "prompt-routing-state.json");
4009
+ assert.equal(existsSync(stateFile), false);
4010
+ }
4011
+ finally {
4012
+ await rm(cwd, { recursive: true, force: true });
4013
+ }
4014
+ });
4015
+ it("produces no triage advisory when prompt contains 'no workflow' opt-out", async () => {
4016
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-optout-noworkflow-"));
4017
+ try {
4018
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
4019
+ const result = await dispatchCodexNativeHook({
4020
+ hook_event_name: "UserPromptSubmit",
4021
+ cwd,
4022
+ session_id: "triage-optout-noworkflow-1",
4023
+ thread_id: "thread-optout-noworkflow-1",
4024
+ turn_id: "turn-optout-noworkflow-1",
4025
+ prompt: "make the button blue, no workflow",
4026
+ }, { cwd });
4027
+ const additionalContext = String(result.outputJson?.hookSpecificOutput?.additionalContext ?? "");
4028
+ assert.doesNotMatch(additionalContext, /visual\/style request/);
4029
+ const stateFile = join(cwd, ".omx", "state", "sessions", "triage-optout-noworkflow-1", "prompt-routing-state.json");
4030
+ assert.equal(existsSync(stateFile), false);
4031
+ }
4032
+ finally {
4033
+ await rm(cwd, { recursive: true, force: true });
4034
+ }
4035
+ });
4036
+ // ── Group 10: Keyword on follow-up turn wins cleanly ─────────────────────
4037
+ it("keyword on turn 2 suppresses triage and writes no triage state", async () => {
4038
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-kw-followup-"));
4039
+ const sessionId = "triage-kw-followup-1";
4040
+ try {
4041
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
4042
+ // Turn 1: neutral prompt — triage may or may not fire, doesn't matter
4043
+ await dispatchCodexNativeHook({
4044
+ hook_event_name: "UserPromptSubmit",
4045
+ cwd,
4046
+ session_id: sessionId,
4047
+ thread_id: "thread-kw-followup-1",
4048
+ turn_id: "turn-kw-followup-1",
4049
+ prompt: "hello",
4050
+ }, { cwd });
4051
+ // Turn 2: keyword prompt — keyword fast-path runs, triage does NOT add extra advisory
4052
+ const turn2 = await dispatchCodexNativeHook({
4053
+ hook_event_name: "UserPromptSubmit",
4054
+ cwd,
4055
+ session_id: sessionId,
4056
+ thread_id: "thread-kw-followup-1",
4057
+ turn_id: "turn-kw-followup-2",
4058
+ prompt: "$ralph continue",
4059
+ }, { cwd });
4060
+ assert.equal(turn2.skillState?.skill, "ralph");
4061
+ const ctx2 = String(turn2.outputJson?.hookSpecificOutput?.additionalContext ?? "");
4062
+ assert.doesNotMatch(ctx2, /multi-step goal with no workflow keyword/);
4063
+ assert.doesNotMatch(ctx2, /read-only\/question-shaped/);
4064
+ assert.doesNotMatch(ctx2, /narrow edit-shaped/);
4065
+ assert.doesNotMatch(ctx2, /visual\/style request/);
4066
+ // No triage state written on the keyword turn
4067
+ const triageState = join(cwd, ".omx", "state", "sessions", sessionId, "prompt-routing-state.json");
4068
+ // The state from turn 1 (if any) must not have been created either (hello = PASS)
4069
+ assert.equal(existsSync(triageState), false);
4070
+ }
4071
+ finally {
4072
+ await rm(cwd, { recursive: true, force: true });
4073
+ }
4074
+ });
4075
+ // ── Group 11: Config-disabled path ───────────────────────────────────────
4076
+ it("produces no triage advisory and no state when triage is disabled in config", async () => {
4077
+ const tmpHome = await mkdtemp(join(tmpdir(), "omx-triage-config-disabled-home-"));
4078
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-config-disabled-cwd-"));
4079
+ try {
4080
+ // Write a .omx-config.json in the fake CODEX_HOME that disables triage
4081
+ await writeJson(join(tmpHome, ".omx-config.json"), {
4082
+ promptRouting: { triage: { enabled: false } },
4083
+ });
4084
+ process.env.CODEX_HOME = tmpHome;
4085
+ resetTriageConfigCache();
4086
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
4087
+ const result = await dispatchCodexNativeHook({
4088
+ hook_event_name: "UserPromptSubmit",
4089
+ cwd,
4090
+ session_id: "triage-disabled-1",
4091
+ thread_id: "thread-triage-disabled-1",
4092
+ turn_id: "turn-triage-disabled-1",
4093
+ prompt: "add dark mode toggle to the settings page",
4094
+ }, { cwd });
4095
+ const additionalContext = String(result.outputJson?.hookSpecificOutput?.additionalContext ?? "");
4096
+ assert.doesNotMatch(additionalContext, /multi-step goal with no workflow keyword/);
4097
+ const stateFile = join(cwd, ".omx", "state", "sessions", "triage-disabled-1", "prompt-routing-state.json");
4098
+ assert.equal(existsSync(stateFile), false);
4099
+ }
4100
+ finally {
4101
+ await rm(tmpHome, { recursive: true, force: true });
4102
+ await rm(cwd, { recursive: true, force: true });
4103
+ }
4104
+ });
4105
+ it("keeps triage default-enabled when config omits promptRouting.triage.enabled", async () => {
4106
+ const tmpHome = await mkdtemp(join(tmpdir(), "omx-triage-config-omitted-home-"));
4107
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-config-omitted-cwd-"));
4108
+ const previousCodexHome = process.env.CODEX_HOME;
4109
+ try {
4110
+ await writeJson(join(tmpHome, ".omx-config.json"), {
4111
+ promptRouting: {},
4112
+ });
4113
+ process.env.CODEX_HOME = tmpHome;
4114
+ resetTriageConfigCache();
4115
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
4116
+ const result = await dispatchCodexNativeHook({
4117
+ hook_event_name: "UserPromptSubmit",
4118
+ cwd,
4119
+ session_id: "triage-defaulted-1",
4120
+ thread_id: "thread-triage-defaulted-1",
4121
+ turn_id: "turn-triage-defaulted-1",
4122
+ prompt: "add dark mode toggle to the settings page",
4123
+ }, { cwd });
4124
+ const additionalContext = String(result.outputJson?.hookSpecificOutput?.additionalContext ?? "");
4125
+ assert.match(additionalContext, /multi-step goal with no workflow keyword/);
4126
+ const stateFile = join(cwd, ".omx", "state", "sessions", "triage-defaulted-1", "prompt-routing-state.json");
4127
+ assert.equal(existsSync(stateFile), true);
4128
+ }
4129
+ finally {
4130
+ if (typeof previousCodexHome === "string")
4131
+ process.env.CODEX_HOME = previousCodexHome;
4132
+ else
4133
+ delete process.env.CODEX_HOME;
4134
+ resetTriageConfigCache();
4135
+ await rm(tmpHome, { recursive: true, force: true });
4136
+ await rm(cwd, { recursive: true, force: true });
4137
+ }
4138
+ });
4139
+ it("does not suppress a short anchored follow-up that is a new request", async () => {
4140
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-short-new-request-"));
4141
+ const sessionId = "triage-short-new-request-1";
4142
+ try {
4143
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
4144
+ await dispatchCodexNativeHook({
4145
+ hook_event_name: "UserPromptSubmit",
4146
+ cwd,
4147
+ session_id: sessionId,
4148
+ thread_id: "thread-short-new-request-1",
4149
+ turn_id: "turn-short-new-request-1",
4150
+ prompt: "add dark mode toggle to the settings page",
4151
+ }, { cwd });
4152
+ const turn2 = await dispatchCodexNativeHook({
4153
+ hook_event_name: "UserPromptSubmit",
4154
+ cwd,
4155
+ session_id: sessionId,
4156
+ thread_id: "thread-short-new-request-1",
4157
+ turn_id: "turn-short-new-request-2",
4158
+ prompt: "fix typo in src/foo.ts",
4159
+ }, { cwd });
4160
+ const ctx2 = String(turn2.outputJson?.hookSpecificOutput?.additionalContext ?? "");
4161
+ assert.match(ctx2, /narrow edit-shaped/);
4162
+ }
4163
+ finally {
4164
+ await rm(cwd, { recursive: true, force: true });
4165
+ }
4166
+ });
4167
+ it("skips triage state persistence for malformed explicit session ids without writing root state", async () => {
4168
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-invalid-session-"));
4169
+ try {
4170
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
4171
+ const result = await dispatchCodexNativeHook({
4172
+ hook_event_name: "UserPromptSubmit",
4173
+ cwd,
4174
+ session_id: "bad/session",
4175
+ thread_id: "thread-triage-invalid-session-1",
4176
+ turn_id: "turn-triage-invalid-session-1",
4177
+ prompt: "add dark mode toggle to the settings page",
4178
+ }, { cwd });
4179
+ const additionalContext = String(result.outputJson?.hookSpecificOutput?.additionalContext ?? "");
4180
+ assert.match(additionalContext, /multi-step goal with no workflow keyword/);
4181
+ assert.equal(existsSync(join(cwd, ".omx", "state", "prompt-routing-state.json")), false);
4182
+ }
4183
+ finally {
4184
+ await rm(cwd, { recursive: true, force: true });
4185
+ }
4186
+ });
4187
+ });
3145
4188
  //# sourceMappingURL=codex-native-hook.test.js.map