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
@@ -18,6 +18,7 @@ import {
18
18
  resolveSessionOwnerPidFromAncestry,
19
19
  } from "../codex-native-hook.js";
20
20
  import { writeSessionStart } from "../../hooks/session.js";
21
+ import { resetTriageConfigCache } from "../../hooks/triage-config.js";
21
22
 
22
23
  async function writeJson(path: string, value: unknown): Promise<void> {
23
24
  await mkdir(dirname(path), { recursive: true }).catch(() => {});
@@ -262,6 +263,45 @@ describe("codex native hook dispatch", () => {
262
263
  }
263
264
  });
264
265
 
266
+ it("passes the canonical OMX session id when UserPromptSubmit revives HUD", async () => {
267
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-hud-session-revive-"));
268
+ try {
269
+ const stateDir = join(cwd, ".omx", "state");
270
+ const canonicalSessionId = "omx-launch-hud";
271
+ const nativeSessionId = "codex-native-hud";
272
+ await mkdir(join(stateDir, "sessions", canonicalSessionId), { recursive: true });
273
+ await writeSessionStart(cwd, canonicalSessionId);
274
+
275
+ let reconcileCall: { cwd: string; sessionId?: string } | null = null;
276
+ const promptResult = await dispatchCodexNativeHook(
277
+ {
278
+ hook_event_name: "UserPromptSubmit",
279
+ cwd,
280
+ session_id: nativeSessionId,
281
+ thread_id: "thread-hud",
282
+ turn_id: "turn-hud",
283
+ prompt: "$ralplan fix orphaned hud session handoff",
284
+ },
285
+ {
286
+ cwd,
287
+ reconcileHudForPromptSubmitFn: async (hookCwd, deps = {}) => {
288
+ reconcileCall = { cwd: hookCwd, sessionId: deps.sessionId };
289
+ return { status: 'recreated', paneId: '%9', desiredHeight: 3, duplicateCount: 0 };
290
+ },
291
+ },
292
+ );
293
+
294
+ assert.equal(promptResult.omxEventName, "keyword-detector");
295
+ assert.deepEqual(reconcileCall, { cwd, sessionId: canonicalSessionId });
296
+ assert.equal(existsSync(join(stateDir, "sessions", canonicalSessionId, "skill-active-state.json")), true);
297
+ assert.equal(existsSync(join(stateDir, "sessions", canonicalSessionId, "ralplan-state.json")), true);
298
+ assert.equal(existsSync(join(stateDir, "sessions", nativeSessionId, "skill-active-state.json")), false);
299
+ assert.equal(existsSync(join(stateDir, "sessions", nativeSessionId, "ralplan-state.json")), false);
300
+ } finally {
301
+ await rm(cwd, { recursive: true, force: true });
302
+ }
303
+ });
304
+
265
305
  it("appends .omx/ to repo-root .gitignore during SessionStart when missing", async () => {
266
306
  const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-session-gitignore-"));
267
307
  try {
@@ -482,7 +522,14 @@ describe("codex native hook dispatch", () => {
482
522
 
483
523
  assert.equal(result.omxEventName, "keyword-detector");
484
524
  assert.equal(result.skillState, null);
485
- assert.equal(result.outputJson, null);
525
+ // Triage may inject advisory LIGHT/explore context for the question-shaped
526
+ // prompt, but the invariant this test guards is that no Ralph workflow state
527
+ // is seeded and no Ralph-activation message is emitted.
528
+ const advisoryContext = String(
529
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext || "",
530
+ );
531
+ assert.doesNotMatch(advisoryContext, /skill:\s*ralph/i);
532
+ assert.doesNotMatch(advisoryContext, /ralph-state\.json/i);
486
533
  assert.equal(existsSync(join(cwd, ".omx", "state", "skill-active-state.json")), false);
487
534
  assert.equal(existsSync(join(cwd, ".omx", "state", "sessions", "sess-ralph-plain-text", "skill-active-state.json")), false);
488
535
  assert.equal(existsSync(join(cwd, ".omx", "state", "sessions", "sess-ralph-plain-text", "ralph-state.json")), false);
@@ -491,6 +538,67 @@ describe("codex native hook dispatch", () => {
491
538
  }
492
539
  });
493
540
 
541
+ it("adds execution handoff context for non-keyword prompts that authorize implementation", async () => {
542
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-execution-handoff-"));
543
+ try {
544
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
545
+ const prompts = [
546
+ "按照这个plan开始执行优化",
547
+ "开始执行",
548
+ "继续优化",
549
+ "直接修复",
550
+ ];
551
+
552
+ for (const [index, prompt] of prompts.entries()) {
553
+ const result = await dispatchCodexNativeHook(
554
+ {
555
+ hook_event_name: "UserPromptSubmit",
556
+ cwd,
557
+ session_id: `sess-exec-handoff-${index}`,
558
+ thread_id: `thread-exec-handoff-${index}`,
559
+ turn_id: `turn-exec-handoff-${index}`,
560
+ prompt,
561
+ },
562
+ { cwd },
563
+ );
564
+
565
+ const message = String(
566
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext || "",
567
+ );
568
+ assert.match(message, /execution handoff/i, prompt);
569
+ assert.match(message, /Do not restate the prior plan/i, prompt);
570
+ }
571
+ } finally {
572
+ await rm(cwd, { recursive: true, force: true });
573
+ }
574
+ });
575
+
576
+ it("adds latest-followup priority context for short same-thread follow-up prompts", async () => {
577
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-followup-priority-"));
578
+ try {
579
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
580
+ const result = await dispatchCodexNativeHook(
581
+ {
582
+ hook_event_name: "UserPromptSubmit",
583
+ cwd,
584
+ session_id: "sess-followup-priority",
585
+ thread_id: "thread-followup-priority",
586
+ turn_id: "turn-followup-priority",
587
+ prompt: "这些优化都做了么",
588
+ },
589
+ { cwd },
590
+ );
591
+
592
+ const message = String(
593
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext || "",
594
+ );
595
+ assert.match(message, /same-thread follow-up/i);
596
+ assert.match(message, /prefer it over older unresolved prompts/i);
597
+ } finally {
598
+ await rm(cwd, { recursive: true, force: true });
599
+ }
600
+ });
601
+
494
602
  it("clarifies that prompt-side $ralph activation does not invoke the PRD-gated CLI path", async () => {
495
603
  const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-ralph-routing-"));
496
604
  try {
@@ -521,6 +629,144 @@ describe("codex native hook dispatch", () => {
521
629
  }
522
630
  });
523
631
 
632
+ it("keeps bare keep-going continuation on the active autopilot skill instead of denying with generic ralph overlap", async () => {
633
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-autopilot-bare-continuation-"));
634
+ try {
635
+ const sessionId = "sess-autopilot-cont";
636
+ const sessionDir = join(cwd, ".omx", "state", "sessions", sessionId);
637
+ await mkdir(sessionDir, { recursive: true });
638
+ await writeJson(join(sessionDir, "skill-active-state.json"), {
639
+ version: 1,
640
+ active: true,
641
+ skill: "autopilot",
642
+ keyword: "$autopilot",
643
+ phase: "planning",
644
+ session_id: sessionId,
645
+ active_skills: [
646
+ { skill: "autopilot", phase: "planning", active: true, session_id: sessionId },
647
+ ],
648
+ });
649
+ await writeJson(join(sessionDir, "autopilot-state.json"), {
650
+ active: true,
651
+ mode: "autopilot",
652
+ current_phase: "execution",
653
+ started_at: "2026-04-19T00:00:00.000Z",
654
+ updated_at: "2026-04-19T00:10:00.000Z",
655
+ session_id: sessionId,
656
+ });
657
+
658
+ const result = await dispatchCodexNativeHook(
659
+ {
660
+ hook_event_name: "UserPromptSubmit",
661
+ cwd,
662
+ session_id: sessionId,
663
+ thread_id: "thread-autopilot-cont",
664
+ turn_id: "turn-autopilot-cont",
665
+ prompt: "\ keep going now",
666
+ },
667
+ { cwd },
668
+ );
669
+
670
+ assert.equal(result.omxEventName, "keyword-detector");
671
+ assert.equal(result.skillState?.skill, "autopilot");
672
+ const message = String(
673
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext || "",
674
+ );
675
+ assert.match(message, /"keep going" -> ralph/);
676
+ assert.doesNotMatch(message, /denied workflow keyword/i);
677
+ assert.doesNotMatch(message, /Unsupported workflow overlap: autopilot \+ ralph\./);
678
+ assert.doesNotMatch(message, /Prompt-side `\$ralph` activation/);
679
+ assert.equal(existsSync(join(sessionDir, "ralph-state.json")), false);
680
+ } finally {
681
+ await rm(cwd, { recursive: true, force: true });
682
+ }
683
+ });
684
+
685
+ it("clarifies that prompt-side deep-interview activation must use omx question", async () => {
686
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-deep-interview-routing-"));
687
+ try {
688
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
689
+ const result = await dispatchCodexNativeHook(
690
+ {
691
+ hook_event_name: "UserPromptSubmit",
692
+ cwd,
693
+ session_id: "sess-deep-interview-msg",
694
+ thread_id: "thread-deep-interview-msg",
695
+ turn_id: "turn-deep-interview-msg",
696
+ prompt: "$deep-interview gather requirements",
697
+ },
698
+ { cwd },
699
+ );
700
+
701
+ assert.equal(result.omxEventName, "keyword-detector");
702
+ assert.equal(result.skillState?.skill, "deep-interview");
703
+ const message = String(
704
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext || "",
705
+ );
706
+ assert.match(message, /\$deep-interview" -> deep-interview/);
707
+ 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\./);
708
+ assert.match(message, /Deep-interview must ask each interview round via `omx question`/);
709
+ assert.match(message, /do not fall back to `request_user_input` or plain-text questioning/i);
710
+ assert.match(message, /Stop remains blocked while a deep-interview question obligation is pending\./);
711
+ } finally {
712
+ await rm(cwd, { recursive: true, force: true });
713
+ }
714
+ });
715
+
716
+ it("keeps bare keep-going continuation on the active ralph skill without resetting through generic keep-going routing", async () => {
717
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-ralph-bare-continuation-"));
718
+ try {
719
+ const sessionId = "sess-ralph-cont";
720
+ const sessionDir = join(cwd, ".omx", "state", "sessions", sessionId);
721
+ await mkdir(sessionDir, { recursive: true });
722
+ await writeJson(join(sessionDir, "skill-active-state.json"), {
723
+ version: 1,
724
+ active: true,
725
+ skill: "ralph",
726
+ keyword: "$ralph",
727
+ phase: "executing",
728
+ session_id: sessionId,
729
+ active_skills: [
730
+ { skill: "ralph", phase: "executing", active: true, session_id: sessionId },
731
+ ],
732
+ });
733
+ await writeJson(join(sessionDir, "ralph-state.json"), {
734
+ active: true,
735
+ mode: "ralph",
736
+ current_phase: "verifying",
737
+ started_at: "2026-04-19T00:00:00.000Z",
738
+ updated_at: "2026-04-19T00:10:00.000Z",
739
+ iteration: 4,
740
+ max_iterations: 50,
741
+ session_id: sessionId,
742
+ });
743
+
744
+ const result = await dispatchCodexNativeHook(
745
+ {
746
+ hook_event_name: "UserPromptSubmit",
747
+ cwd,
748
+ session_id: sessionId,
749
+ thread_id: "thread-ralph-cont",
750
+ turn_id: "turn-ralph-cont",
751
+ prompt: "keep going now",
752
+ },
753
+ { cwd },
754
+ );
755
+
756
+ assert.equal(result.omxEventName, "keyword-detector");
757
+ assert.equal(result.skillState?.skill, "ralph");
758
+ const message = String(
759
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext || "",
760
+ );
761
+ assert.match(message, /"keep going" -> ralph/);
762
+ assert.doesNotMatch(message, /denied workflow keyword/i);
763
+ assert.doesNotMatch(message, /mode transiting:/);
764
+ } finally {
765
+ await rm(cwd, { recursive: true, force: true });
766
+ }
767
+ });
768
+
769
+
524
770
  it("ignores generic wrapper fields so metadata cannot trigger workflow routing or Stop blocking", async () => {
525
771
  const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-wrapper-metadata-"));
526
772
  try {
@@ -1942,6 +2188,33 @@ esac
1942
2188
  }
1943
2189
  });
1944
2190
 
2191
+ it("does not block Stop when an explicit blocked_on_user run_outcome is present on a mode state", async () => {
2192
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-autopilot-blocked-outcome-"));
2193
+ try {
2194
+ const stateDir = join(cwd, ".omx", "state");
2195
+ await mkdir(stateDir, { recursive: true });
2196
+ await writeJson(join(stateDir, "autopilot-state.json"), {
2197
+ active: true,
2198
+ current_phase: "execution",
2199
+ run_outcome: "blocked_on_user",
2200
+ });
2201
+
2202
+ const result = await dispatchCodexNativeHook(
2203
+ {
2204
+ hook_event_name: "Stop",
2205
+ cwd,
2206
+ session_id: "sess-stop-autopilot-blocked-outcome",
2207
+ },
2208
+ { cwd },
2209
+ );
2210
+
2211
+ assert.equal(result.omxEventName, "stop");
2212
+ assert.equal(result.outputJson, null);
2213
+ } finally {
2214
+ await rm(cwd, { recursive: true, force: true });
2215
+ }
2216
+ });
2217
+
1945
2218
  it("returns Stop continuation output while Ultrawork is active", async () => {
1946
2219
  const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-ultrawork-"));
1947
2220
  try {
@@ -2699,201 +2972,315 @@ esac
2699
2972
  }
2700
2973
  });
2701
2974
 
2702
- it("does not block Stop solely because deep-interview is active", async () => {
2703
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-deep-interview-"));
2975
+ it("blocks Stop while autoresearch is active without validator completion", async () => {
2976
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-autoresearch-"));
2704
2977
  try {
2705
2978
  const stateDir = join(cwd, ".omx", "state");
2706
- await mkdir(join(stateDir, "sessions", "sess-stop-deep-interview"), { recursive: true });
2707
- await writeJson(join(stateDir, "session.json"), { session_id: "sess-stop-deep-interview" });
2708
- await writeJson(join(stateDir, "sessions", "sess-stop-deep-interview", "skill-active-state.json"), {
2709
- active: true,
2710
- skill: "deep-interview",
2711
- phase: "planning",
2712
- });
2713
- await writeJson(join(stateDir, "sessions", "sess-stop-deep-interview", "deep-interview-state.json"), {
2979
+ await mkdir(join(stateDir, "sessions", "sess-stop-autoresearch"), { recursive: true });
2980
+ await writeJson(join(stateDir, "session.json"), { session_id: "sess-stop-autoresearch", cwd });
2981
+ await writeJson(join(stateDir, "sessions", "sess-stop-autoresearch", "autoresearch-state.json"), {
2714
2982
  active: true,
2715
- current_phase: "planning",
2983
+ mode: "autoresearch",
2984
+ current_phase: "executing",
2985
+ session_id: "sess-stop-autoresearch",
2986
+ validation_mode: "mission-validator-script",
2987
+ mission_validator_command: "node scripts/validate.js",
2988
+ completion_artifact_path: '.omx/specs/autoresearch-demo/completion.json',
2716
2989
  });
2717
2990
 
2718
2991
  const result = await dispatchCodexNativeHook(
2719
2992
  {
2720
2993
  hook_event_name: "Stop",
2721
2994
  cwd,
2722
- session_id: "sess-stop-deep-interview",
2995
+ session_id: "sess-stop-autoresearch",
2723
2996
  },
2724
2997
  { cwd },
2725
2998
  );
2726
2999
 
2727
- assert.equal(result.outputJson, null);
3000
+ assert.equal(result.omxEventName, "stop");
3001
+ assert.deepEqual(result.outputJson, {
3002
+ decision: "block",
3003
+ reason: "OMX autoresearch is still active (phase: executing); continue until validator evidence is complete before stopping.",
3004
+ stopReason: "autoresearch_executing",
3005
+ systemMessage: "OMX autoresearch is still active (phase: executing); continue until validator evidence is complete before stopping.",
3006
+ });
2728
3007
  } finally {
2729
3008
  await rm(cwd, { recursive: true, force: true });
2730
3009
  }
2731
3010
  });
2732
3011
 
2733
- it("ignores root skill-active fallback from a different thread when evaluating Stop", async () => {
2734
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-foreign-thread-"));
3012
+ it("allows Stop once autoresearch validator evidence is complete", async () => {
3013
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-autoresearch-complete-"));
2735
3014
  try {
2736
3015
  const stateDir = join(cwd, ".omx", "state");
2737
- await mkdir(stateDir, { recursive: true });
2738
- await writeJson(join(stateDir, "skill-active-state.json"), {
3016
+ const specDir = join(cwd, '.omx', 'specs', 'autoresearch-demo');
3017
+ await mkdir(join(stateDir, "sessions", "sess-stop-autoresearch-complete"), { recursive: true });
3018
+ await mkdir(specDir, { recursive: true });
3019
+ await writeJson(join(stateDir, "session.json"), { session_id: "sess-stop-autoresearch-complete", cwd });
3020
+ await writeJson(join(stateDir, "sessions", "sess-stop-autoresearch-complete", "autoresearch-state.json"), {
2739
3021
  active: true,
2740
- skill: "deep-interview",
2741
- phase: "planning",
2742
- session_id: "",
2743
- thread_id: "other-thread",
3022
+ mode: "autoresearch",
3023
+ current_phase: "reviewing",
3024
+ session_id: "sess-stop-autoresearch-complete",
3025
+ validation_mode: "mission-validator-script",
3026
+ mission_validator_command: "node scripts/validate.js",
3027
+ completion_artifact_path: '.omx/specs/autoresearch-demo/completion.json',
2744
3028
  });
3029
+ await writeJson(join(specDir, 'completion.json'), { status: 'passed', passed: true });
2745
3030
 
2746
3031
  const result = await dispatchCodexNativeHook(
2747
3032
  {
2748
3033
  hook_event_name: "Stop",
2749
3034
  cwd,
2750
- session_id: "sess-stop-main",
2751
- thread_id: "main-thread",
3035
+ session_id: "sess-stop-autoresearch-complete",
2752
3036
  },
2753
3037
  { cwd },
2754
3038
  );
2755
3039
 
3040
+ assert.equal(result.omxEventName, "stop");
2756
3041
  assert.equal(result.outputJson, null);
2757
3042
  } finally {
2758
3043
  await rm(cwd, { recursive: true, force: true });
2759
3044
  }
2760
3045
  });
2761
3046
 
2762
- it("returns Stop continuation output while Ralph is active without an explicit session pin", async () => {
2763
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-"));
3047
+ it("does not block Stop from stale root autoresearch state when the explicit session has no scoped autoresearch state", async () => {
3048
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-stale-root-autoresearch-"));
2764
3049
  try {
2765
3050
  const stateDir = join(cwd, ".omx", "state");
2766
- await mkdir(stateDir, { recursive: true });
2767
- await writeFile(
2768
- join(stateDir, "ralph-state.json"),
2769
- JSON.stringify({
2770
- active: true,
2771
- current_phase: "executing",
2772
- }),
2773
- );
3051
+ const specDir = join(cwd, '.omx', 'specs', 'autoresearch-demo');
3052
+ await mkdir(join(stateDir, 'sessions', 'sess-current'), { recursive: true });
3053
+ await mkdir(specDir, { recursive: true });
3054
+ await writeJson(join(stateDir, 'session.json'), { session_id: 'sess-current', cwd });
3055
+ await writeJson(join(stateDir, 'autoresearch-state.json'), {
3056
+ active: true,
3057
+ mode: 'autoresearch',
3058
+ current_phase: 'executing',
3059
+ validation_mode: 'mission-validator-script',
3060
+ mission_validator_command: 'node scripts/validate.js',
3061
+ completion_artifact_path: '.omx/specs/autoresearch-demo/completion.json',
3062
+ });
2774
3063
 
2775
3064
  const result = await dispatchCodexNativeHook(
2776
3065
  {
2777
- hook_event_name: "Stop",
3066
+ hook_event_name: 'Stop',
2778
3067
  cwd,
3068
+ session_id: 'sess-current',
2779
3069
  },
2780
3070
  { cwd },
2781
3071
  );
2782
3072
 
2783
- assert.equal(result.omxEventName, "stop");
2784
- assert.deepEqual(result.outputJson, {
2785
- decision: "block",
2786
- reason:
2787
- "OMX Ralph is still active (phase: executing); continue the task and gather fresh verification evidence before stopping.",
2788
- stopReason: "ralph_executing",
2789
- systemMessage:
2790
- "OMX Ralph is still active (phase: executing); continue the task and gather fresh verification evidence before stopping.",
2791
- });
3073
+ assert.equal(result.omxEventName, 'stop');
3074
+ assert.equal(result.outputJson, null);
2792
3075
  } finally {
2793
3076
  await rm(cwd, { recursive: true, force: true });
2794
3077
  }
2795
3078
  });
2796
3079
 
2797
- it("blocks Stop from session-scoped Ralph state when session.json points to another session", async () => {
2798
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-ralph-session-mismatch-"));
3080
+ it("does not block Stop solely because deep-interview is active", async () => {
3081
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-deep-interview-"));
2799
3082
  try {
2800
3083
  const stateDir = join(cwd, ".omx", "state");
2801
- await mkdir(join(stateDir, "sessions", "sess-live-ralph"), { recursive: true });
2802
- await writeJson(join(stateDir, "session.json"), { session_id: "sess-other-ralph" });
2803
- await writeJson(join(stateDir, "sessions", "sess-live-ralph", "ralph-state.json"), {
3084
+ await mkdir(join(stateDir, "sessions", "sess-stop-deep-interview"), { recursive: true });
3085
+ await writeJson(join(stateDir, "session.json"), { session_id: "sess-stop-deep-interview" });
3086
+ await writeJson(join(stateDir, "sessions", "sess-stop-deep-interview", "skill-active-state.json"), {
2804
3087
  active: true,
2805
- current_phase: "executing",
2806
- session_id: "sess-live-ralph",
3088
+ skill: "deep-interview",
3089
+ phase: "planning",
3090
+ });
3091
+ await writeJson(join(stateDir, "sessions", "sess-stop-deep-interview", "deep-interview-state.json"), {
3092
+ active: true,
3093
+ current_phase: "planning",
2807
3094
  });
2808
3095
 
2809
3096
  const result = await dispatchCodexNativeHook(
2810
3097
  {
2811
3098
  hook_event_name: "Stop",
2812
3099
  cwd,
2813
- session_id: "sess-live-ralph",
3100
+ session_id: "sess-stop-deep-interview",
2814
3101
  },
2815
3102
  { cwd },
2816
3103
  );
2817
3104
 
2818
- assert.equal(result.omxEventName, "stop");
2819
- assert.deepEqual(result.outputJson, {
2820
- decision: "block",
2821
- reason:
2822
- "OMX Ralph is still active (phase: executing); continue the task and gather fresh verification evidence before stopping.",
2823
- stopReason: "ralph_executing",
2824
- systemMessage:
2825
- "OMX Ralph is still active (phase: executing); continue the task and gather fresh verification evidence before stopping.",
2826
- });
3105
+ assert.equal(result.outputJson, null);
2827
3106
  } finally {
2828
3107
  await rm(cwd, { recursive: true, force: true });
2829
3108
  }
2830
3109
  });
2831
3110
 
2832
- it("does not block Stop from stale session-scoped Ralph state that belongs to another session", async () => {
2833
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-stale-session-ralph-"));
3111
+ it("blocks Stop when deep-interview has a pending omx question obligation", async () => {
3112
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-deep-interview-question-"));
2834
3113
  try {
2835
3114
  const stateDir = join(cwd, ".omx", "state");
2836
- await mkdir(join(stateDir, "sessions", "sess-current"), { recursive: true });
2837
- await mkdir(join(stateDir, "sessions", "sess-stale"), { recursive: true });
2838
- await writeJson(join(stateDir, "session.json"), { session_id: "sess-current" });
2839
- await writeJson(join(stateDir, "sessions", "sess-stale", "ralph-state.json"), {
3115
+ await mkdir(join(stateDir, "sessions", "sess-stop-deep-interview-question"), { recursive: true });
3116
+ await writeJson(join(stateDir, "session.json"), { session_id: "sess-stop-deep-interview-question" });
3117
+ await writeJson(join(stateDir, "sessions", "sess-stop-deep-interview-question", "skill-active-state.json"), {
3118
+ version: 1,
2840
3119
  active: true,
2841
- current_phase: "starting",
2842
- session_id: "sess-stale",
3120
+ skill: "deep-interview",
3121
+ phase: "planning",
3122
+ session_id: "sess-stop-deep-interview-question",
3123
+ thread_id: "thread-stop-deep-interview-question",
3124
+ });
3125
+ await writeJson(join(stateDir, "sessions", "sess-stop-deep-interview-question", "deep-interview-state.json"), {
3126
+ active: true,
3127
+ mode: "deep-interview",
3128
+ current_phase: "intent-first",
3129
+ session_id: "sess-stop-deep-interview-question",
3130
+ thread_id: "thread-stop-deep-interview-question",
3131
+ question_enforcement: {
3132
+ obligation_id: "obligation-1",
3133
+ source: "omx-question",
3134
+ status: "pending",
3135
+ requested_at: "2026-04-19T03:20:00.000Z",
3136
+ },
2843
3137
  });
2844
3138
 
2845
3139
  const result = await dispatchCodexNativeHook(
2846
3140
  {
2847
3141
  hook_event_name: "Stop",
2848
3142
  cwd,
2849
- session_id: "sess-current",
3143
+ session_id: "sess-stop-deep-interview-question",
3144
+ thread_id: "thread-stop-deep-interview-question",
2850
3145
  },
2851
3146
  { cwd },
2852
3147
  );
2853
3148
 
2854
3149
  assert.equal(result.omxEventName, "stop");
2855
- assert.equal(result.outputJson, null);
3150
+ assert.deepEqual(result.outputJson, {
3151
+ decision: "block",
3152
+ reason:
3153
+ "Deep interview is still active (phase: intent-first) and has a pending structured question obligation; use `omx question` before stopping.",
3154
+ stopReason: "deep_interview_question_required",
3155
+ systemMessage:
3156
+ "OMX deep-interview is still active (phase: intent-first) and requires a structured question via omx question before stopping.",
3157
+ });
2856
3158
  } finally {
2857
3159
  await rm(cwd, { recursive: true, force: true });
2858
3160
  }
2859
3161
  });
2860
3162
 
2861
- it("does not block Stop from another session-scoped Ralph state when an explicit session_id has no active Ralph state", async () => {
2862
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-explicit-session-ralph-"));
3163
+ it("keeps blocking pending deep-interview question Stop replays until the obligation changes", async () => {
3164
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-deep-interview-question-replay-"));
2863
3165
  try {
2864
3166
  const stateDir = join(cwd, ".omx", "state");
2865
- await mkdir(join(stateDir, "sessions", "sess-other"), { recursive: true });
2866
- await writeJson(join(stateDir, "sessions", "sess-other", "ralph-state.json"), {
3167
+ await mkdir(join(stateDir, "sessions", "sess-stop-deep-interview-question-replay"), { recursive: true });
3168
+ await writeJson(join(stateDir, "session.json"), { session_id: "sess-stop-deep-interview-question-replay" });
3169
+ await writeJson(join(stateDir, "sessions", "sess-stop-deep-interview-question-replay", "skill-active-state.json"), {
3170
+ version: 1,
2867
3171
  active: true,
2868
- current_phase: "starting",
2869
- session_id: "sess-other",
3172
+ skill: "deep-interview",
3173
+ phase: "planning",
3174
+ session_id: "sess-stop-deep-interview-question-replay",
2870
3175
  });
2871
-
2872
- const result = await dispatchCodexNativeHook(
2873
- {
2874
- hook_event_name: "Stop",
2875
- cwd,
2876
- session_id: "sess-current",
3176
+ await writeJson(join(stateDir, "sessions", "sess-stop-deep-interview-question-replay", "deep-interview-state.json"), {
3177
+ active: true,
3178
+ mode: "deep-interview",
3179
+ current_phase: "intent-first",
3180
+ question_enforcement: {
3181
+ obligation_id: "obligation-replay",
3182
+ source: "omx-question",
3183
+ status: "pending",
3184
+ requested_at: "2026-04-19T03:20:00.000Z",
2877
3185
  },
2878
- { cwd },
2879
- );
3186
+ });
2880
3187
 
2881
- assert.equal(result.omxEventName, "stop");
2882
- assert.equal(result.outputJson, null);
3188
+ const payload = {
3189
+ hook_event_name: "Stop",
3190
+ cwd,
3191
+ session_id: "sess-stop-deep-interview-question-replay",
3192
+ };
3193
+ const expected = {
3194
+ decision: "block",
3195
+ reason:
3196
+ "Deep interview is still active (phase: intent-first) and has a pending structured question obligation; use `omx question` before stopping.",
3197
+ stopReason: "deep_interview_question_required",
3198
+ systemMessage:
3199
+ "OMX deep-interview is still active (phase: intent-first) and requires a structured question via omx question before stopping.",
3200
+ };
3201
+
3202
+ const first = await dispatchCodexNativeHook(payload, { cwd });
3203
+ const replay = await dispatchCodexNativeHook({ ...payload, stop_hook_active: true }, { cwd });
3204
+
3205
+ assert.equal(first.omxEventName, "stop");
3206
+ assert.deepEqual(first.outputJson, expected);
3207
+ assert.equal(replay.omxEventName, "stop");
3208
+ assert.deepEqual(replay.outputJson, expected);
2883
3209
  } finally {
2884
3210
  await rm(cwd, { recursive: true, force: true });
2885
3211
  }
2886
3212
  });
2887
3213
 
2888
- it("does not block Stop from root Ralph fallback when the current session has no scoped Ralph state", async () => {
2889
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-root-fallback-ralph-"));
3214
+ it("does not block Stop once the deep-interview question obligation is satisfied or cleared", async () => {
3215
+ for (const status of ["satisfied", "cleared"] as const) {
3216
+ const cwd = await mkdtemp(join(tmpdir(), `omx-native-hook-stop-deep-interview-question-${status}-`));
3217
+ try {
3218
+ const stateDir = join(cwd, ".omx", "state");
3219
+ await mkdir(join(stateDir, "sessions", `sess-stop-deep-interview-question-${status}`), { recursive: true });
3220
+ await writeJson(join(stateDir, "session.json"), { session_id: `sess-stop-deep-interview-question-${status}` });
3221
+ await writeJson(join(stateDir, "sessions", `sess-stop-deep-interview-question-${status}`, "skill-active-state.json"), {
3222
+ version: 1,
3223
+ active: true,
3224
+ skill: "deep-interview",
3225
+ phase: "planning",
3226
+ session_id: `sess-stop-deep-interview-question-${status}`,
3227
+ });
3228
+ await writeJson(join(stateDir, "sessions", `sess-stop-deep-interview-question-${status}`, "deep-interview-state.json"), {
3229
+ active: true,
3230
+ mode: "deep-interview",
3231
+ current_phase: "intent-first",
3232
+ question_enforcement: {
3233
+ obligation_id: `obligation-${status}`,
3234
+ source: "omx-question",
3235
+ status,
3236
+ requested_at: "2026-04-19T03:20:00.000Z",
3237
+ ...(status === "satisfied"
3238
+ ? { question_id: "question-1", satisfied_at: "2026-04-19T03:21:00.000Z" }
3239
+ : { cleared_at: "2026-04-19T03:21:00.000Z", clear_reason: "error" }),
3240
+ },
3241
+ });
3242
+
3243
+ const result = await dispatchCodexNativeHook(
3244
+ {
3245
+ hook_event_name: "Stop",
3246
+ cwd,
3247
+ session_id: `sess-stop-deep-interview-question-${status}`,
3248
+ },
3249
+ { cwd },
3250
+ );
3251
+
3252
+ assert.equal(result.omxEventName, "stop");
3253
+ assert.equal(result.outputJson, null);
3254
+ } finally {
3255
+ await rm(cwd, { recursive: true, force: true });
3256
+ }
3257
+ }
3258
+ });
3259
+
3260
+ it("ignores pending deep-interview question obligations from another session", async () => {
3261
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-deep-interview-question-foreign-session-"));
2890
3262
  try {
2891
3263
  const stateDir = join(cwd, ".omx", "state");
3264
+ await mkdir(join(stateDir, "sessions", "sess-other"), { recursive: true });
2892
3265
  await mkdir(join(stateDir, "sessions", "sess-current"), { recursive: true });
2893
- await writeJson(join(stateDir, "session.json"), { session_id: "sess-current", cwd });
2894
- await writeJson(join(stateDir, "ralph-state.json"), {
3266
+ await writeJson(join(stateDir, "session.json"), { session_id: "sess-current" });
3267
+ await writeJson(join(stateDir, "sessions", "sess-other", "skill-active-state.json"), {
3268
+ version: 1,
2895
3269
  active: true,
2896
- current_phase: "executing",
3270
+ skill: "deep-interview",
3271
+ phase: "planning",
3272
+ session_id: "sess-other",
3273
+ });
3274
+ await writeJson(join(stateDir, "sessions", "sess-other", "deep-interview-state.json"), {
3275
+ active: true,
3276
+ mode: "deep-interview",
3277
+ current_phase: "intent-first",
3278
+ question_enforcement: {
3279
+ obligation_id: "obligation-foreign",
3280
+ source: "omx-question",
3281
+ status: "pending",
3282
+ requested_at: "2026-04-19T03:20:00.000Z",
3283
+ },
2897
3284
  });
2898
3285
 
2899
3286
  const result = await dispatchCodexNativeHook(
@@ -2912,72 +3299,87 @@ esac
2912
3299
  }
2913
3300
  });
2914
3301
 
2915
- it("does not block Stop when the current session Ralph state is cancelled even if stale root fallback remains", async () => {
2916
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-cancelled-session-ralph-"));
3302
+ it("blocks a new same-session deep-interview question obligation even after an earlier round was satisfied", async () => {
3303
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-deep-interview-question-next-round-"));
2917
3304
  try {
2918
3305
  const stateDir = join(cwd, ".omx", "state");
2919
- await mkdir(join(stateDir, "sessions", "sess-current"), { recursive: true });
2920
- await writeJson(join(stateDir, "session.json"), { session_id: "sess-current", cwd });
2921
- await writeJson(join(stateDir, "sessions", "sess-current", "ralph-state.json"), {
2922
- active: false,
2923
- current_phase: "cancelled",
2924
- completed_at: "2026-04-10T23:30:38.000Z",
2925
- session_id: "sess-current",
3306
+ await mkdir(join(stateDir, "sessions", "sess-stop-deep-interview-question-next-round"), { recursive: true });
3307
+ await writeJson(join(stateDir, "session.json"), { session_id: "sess-stop-deep-interview-question-next-round" });
3308
+ await writeJson(join(stateDir, "sessions", "sess-stop-deep-interview-question-next-round", "skill-active-state.json"), {
3309
+ version: 1,
3310
+ active: true,
3311
+ skill: "deep-interview",
3312
+ phase: "planning",
3313
+ session_id: "sess-stop-deep-interview-question-next-round",
2926
3314
  });
2927
- await writeJson(join(stateDir, "ralph-state.json"), {
3315
+ await writeJson(join(stateDir, "sessions", "sess-stop-deep-interview-question-next-round", "deep-interview-state.json"), {
2928
3316
  active: true,
2929
- current_phase: "starting",
3317
+ mode: "deep-interview",
3318
+ current_phase: "intent-first",
3319
+ question_enforcement: {
3320
+ obligation_id: "obligation-next-round",
3321
+ source: "omx-question",
3322
+ status: "pending",
3323
+ requested_at: "2026-04-19T03:22:00.000Z",
3324
+ question_id: "question-old-round",
3325
+ satisfied_at: "2026-04-19T03:21:00.000Z",
3326
+ },
2930
3327
  });
2931
3328
 
2932
3329
  const result = await dispatchCodexNativeHook(
2933
3330
  {
2934
3331
  hook_event_name: "Stop",
2935
3332
  cwd,
2936
- session_id: "sess-current",
3333
+ session_id: "sess-stop-deep-interview-question-next-round",
2937
3334
  },
2938
3335
  { cwd },
2939
3336
  );
2940
3337
 
2941
3338
  assert.equal(result.omxEventName, "stop");
2942
- assert.equal(result.outputJson, null);
3339
+ assert.deepEqual(result.outputJson, {
3340
+ decision: "block",
3341
+ reason:
3342
+ "Deep interview is still active (phase: intent-first) and has a pending structured question obligation; use `omx question` before stopping.",
3343
+ stopReason: "deep_interview_question_required",
3344
+ systemMessage:
3345
+ "OMX deep-interview is still active (phase: intent-first) and requires a structured question via omx question before stopping.",
3346
+ });
2943
3347
  } finally {
2944
3348
  await rm(cwd, { recursive: true, force: true });
2945
3349
  }
2946
3350
  });
2947
3351
 
2948
- it("does not block Stop from root Ralph fallback when an explicit session_id is present and session.json points to another worktree", async () => {
2949
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-root-fallback-cwd-mismatch-"));
3352
+ it("ignores root skill-active fallback from a different thread when evaluating Stop", async () => {
3353
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-foreign-thread-"));
2950
3354
  try {
2951
3355
  const stateDir = join(cwd, ".omx", "state");
2952
3356
  await mkdir(stateDir, { recursive: true });
2953
- await writeJson(join(stateDir, "session.json"), {
2954
- session_id: "sess-elsewhere",
2955
- cwd: join(cwd, "..", "different-worktree"),
2956
- });
2957
- await writeJson(join(stateDir, "ralph-state.json"), {
3357
+ await writeJson(join(stateDir, "skill-active-state.json"), {
2958
3358
  active: true,
2959
- current_phase: "executing",
3359
+ skill: "deep-interview",
3360
+ phase: "planning",
3361
+ session_id: "",
3362
+ thread_id: "other-thread",
2960
3363
  });
2961
3364
 
2962
3365
  const result = await dispatchCodexNativeHook(
2963
3366
  {
2964
3367
  hook_event_name: "Stop",
2965
3368
  cwd,
2966
- session_id: "sess-current",
3369
+ session_id: "sess-stop-main",
3370
+ thread_id: "main-thread",
2967
3371
  },
2968
3372
  { cwd },
2969
3373
  );
2970
3374
 
2971
- assert.equal(result.omxEventName, "stop");
2972
3375
  assert.equal(result.outputJson, null);
2973
3376
  } finally {
2974
3377
  await rm(cwd, { recursive: true, force: true });
2975
3378
  }
2976
3379
  });
2977
3380
 
2978
- it("keeps blocking Ralph Stop replays until the active task advances", async () => {
2979
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-ralph-replay-"));
2980
- const previousOmxSessionId = process.env.OMX_SESSION_ID;
3381
+ it("returns Stop continuation output while Ralph is active without an explicit session pin", async () => {
3382
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-"));
2981
3383
  try {
2982
3384
  const stateDir = join(cwd, ".omx", "state");
2983
3385
  await mkdir(stateDir, { recursive: true });
@@ -2989,55 +3391,45 @@ esac
2989
3391
  }),
2990
3392
  );
2991
3393
 
2992
- process.env.OMX_SESSION_ID = "sess-stop-ralph-replay";
2993
- const payload = {
2994
- hook_event_name: "Stop",
2995
- cwd,
2996
- last_assistant_message: "Next active targets:\n\n1. scheduler integration\n\nI am continuing.",
2997
- };
2998
- const expected = {
3394
+ const result = await dispatchCodexNativeHook(
3395
+ {
3396
+ hook_event_name: "Stop",
3397
+ cwd,
3398
+ },
3399
+ { cwd },
3400
+ );
3401
+
3402
+ assert.equal(result.omxEventName, "stop");
3403
+ assert.deepEqual(result.outputJson, {
2999
3404
  decision: "block",
3000
3405
  reason:
3001
3406
  "OMX Ralph is still active (phase: executing); continue the task and gather fresh verification evidence before stopping.",
3002
3407
  stopReason: "ralph_executing",
3003
3408
  systemMessage:
3004
3409
  "OMX Ralph is still active (phase: executing); continue the task and gather fresh verification evidence before stopping.",
3005
- };
3006
-
3007
- const first = await dispatchCodexNativeHook(payload, { cwd });
3008
- const replay = await dispatchCodexNativeHook(
3009
- {
3010
- ...payload,
3011
- stop_hook_active: true,
3012
- },
3013
- { cwd },
3014
- );
3015
-
3016
- assert.equal(first.omxEventName, "stop");
3017
- assert.deepEqual(first.outputJson, expected);
3018
- assert.equal(replay.omxEventName, "stop");
3019
- assert.deepEqual(replay.outputJson, expected);
3410
+ });
3020
3411
  } finally {
3021
- if (typeof previousOmxSessionId === "string") process.env.OMX_SESSION_ID = previousOmxSessionId;
3022
- else delete process.env.OMX_SESSION_ID;
3023
3412
  await rm(cwd, { recursive: true, force: true });
3024
3413
  }
3025
3414
  });
3026
3415
 
3027
-
3028
- it("returns Stop continuation output for native auto-nudge stall prompts", async () => {
3029
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-auto-nudge-"));
3416
+ it("blocks Stop from session-scoped Ralph state when session.json points to another session", async () => {
3417
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-ralph-session-mismatch-"));
3030
3418
  try {
3031
3419
  const stateDir = join(cwd, ".omx", "state");
3032
- await mkdir(stateDir, { recursive: true });
3033
- process.env.OMX_SESSION_ID = "sess-stop-auto";
3420
+ await mkdir(join(stateDir, "sessions", "sess-live-ralph"), { recursive: true });
3421
+ await writeJson(join(stateDir, "session.json"), { session_id: "sess-other-ralph" });
3422
+ await writeJson(join(stateDir, "sessions", "sess-live-ralph", "ralph-state.json"), {
3423
+ active: true,
3424
+ current_phase: "executing",
3425
+ session_id: "sess-live-ralph",
3426
+ });
3034
3427
 
3035
3428
  const result = await dispatchCodexNativeHook(
3036
3429
  {
3037
3430
  hook_event_name: "Stop",
3038
3431
  cwd,
3039
- session_id: "sess-stop-auto",
3040
- last_assistant_message: "Keep going and finish the cleanup.",
3432
+ session_id: "sess-live-ralph",
3041
3433
  },
3042
3434
  { cwd },
3043
3435
  );
@@ -3045,73 +3437,300 @@ esac
3045
3437
  assert.equal(result.omxEventName, "stop");
3046
3438
  assert.deepEqual(result.outputJson, {
3047
3439
  decision: "block",
3048
- reason: DEFAULT_AUTO_NUDGE_RESPONSE,
3049
- stopReason: "auto_nudge",
3440
+ reason:
3441
+ "OMX Ralph is still active (phase: executing); continue the task and gather fresh verification evidence before stopping.",
3442
+ stopReason: "ralph_executing",
3050
3443
  systemMessage:
3051
- "OMX native Stop detected a stall/permission-style handoff and continued the turn automatically.",
3444
+ "OMX Ralph is still active (phase: executing); continue the task and gather fresh verification evidence before stopping.",
3052
3445
  });
3053
3446
  } finally {
3054
3447
  await rm(cwd, { recursive: true, force: true });
3055
3448
  }
3056
3449
  });
3057
3450
 
3058
- it("re-blocks duplicate native auto-nudge replays for the same Stop reply", async () => {
3059
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-auto-nudge-once-"));
3451
+ it("does not block Stop from stale session-scoped Ralph state that belongs to another session", async () => {
3452
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-stale-session-ralph-"));
3060
3453
  try {
3061
3454
  const stateDir = join(cwd, ".omx", "state");
3062
- await mkdir(stateDir, { recursive: true });
3063
- process.env.OMX_SESSION_ID = "sess-stop-auto-once";
3064
-
3065
- await dispatchCodexNativeHook(
3066
- {
3067
- hook_event_name: "Stop",
3068
- cwd,
3069
- session_id: "sess-stop-auto-once",
3070
- thread_id: "thread-stop-auto",
3071
- turn_id: "turn-stop-auto-1",
3072
- last_assistant_message: "Keep going and finish the cleanup.",
3073
- },
3074
- { cwd },
3075
- );
3455
+ await mkdir(join(stateDir, "sessions", "sess-current"), { recursive: true });
3456
+ await mkdir(join(stateDir, "sessions", "sess-stale"), { recursive: true });
3457
+ await writeJson(join(stateDir, "session.json"), { session_id: "sess-current" });
3458
+ await writeJson(join(stateDir, "sessions", "sess-stale", "ralph-state.json"), {
3459
+ active: true,
3460
+ current_phase: "starting",
3461
+ session_id: "sess-stale",
3462
+ });
3076
3463
 
3077
3464
  const result = await dispatchCodexNativeHook(
3078
3465
  {
3079
3466
  hook_event_name: "Stop",
3080
3467
  cwd,
3081
- session_id: "sess-stop-auto-once",
3082
- thread_id: "thread-stop-auto",
3083
- turn_id: "turn-stop-auto-1",
3084
- stop_hook_active: true,
3085
- last_assistant_message: "Keep going and finish the cleanup.",
3468
+ session_id: "sess-current",
3086
3469
  },
3087
3470
  { cwd },
3088
3471
  );
3089
3472
 
3090
3473
  assert.equal(result.omxEventName, "stop");
3091
- assert.deepEqual(result.outputJson, {
3092
- decision: "block",
3093
- reason: DEFAULT_AUTO_NUDGE_RESPONSE,
3094
- stopReason: "auto_nudge",
3095
- systemMessage:
3096
- "OMX native Stop detected a stall/permission-style handoff and continued the turn automatically.",
3097
- });
3474
+ assert.equal(result.outputJson, null);
3098
3475
  } finally {
3099
3476
  await rm(cwd, { recursive: true, force: true });
3100
3477
  }
3101
3478
  });
3102
3479
 
3103
- it("re-blocks duplicate native auto-nudge replays across native/canonical session-id drift", async () => {
3104
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-auto-nudge-session-drift-"));
3480
+ it("does not block Stop from another session-scoped Ralph state when an explicit session_id has no active Ralph state", async () => {
3481
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-explicit-session-ralph-"));
3105
3482
  try {
3106
3483
  const stateDir = join(cwd, ".omx", "state");
3107
- await mkdir(stateDir, { recursive: true });
3108
- process.env.OMX_SESSION_ID = "omx-canonical";
3109
- await writeJson(join(stateDir, "session.json"), {
3110
- session_id: "omx-canonical",
3111
- native_session_id: "codex-native",
3484
+ await mkdir(join(stateDir, "sessions", "sess-other"), { recursive: true });
3485
+ await writeJson(join(stateDir, "sessions", "sess-other", "ralph-state.json"), {
3486
+ active: true,
3487
+ current_phase: "starting",
3488
+ session_id: "sess-other",
3112
3489
  });
3113
3490
 
3114
- await dispatchCodexNativeHook(
3491
+ const result = await dispatchCodexNativeHook(
3492
+ {
3493
+ hook_event_name: "Stop",
3494
+ cwd,
3495
+ session_id: "sess-current",
3496
+ },
3497
+ { cwd },
3498
+ );
3499
+
3500
+ assert.equal(result.omxEventName, "stop");
3501
+ assert.equal(result.outputJson, null);
3502
+ } finally {
3503
+ await rm(cwd, { recursive: true, force: true });
3504
+ }
3505
+ });
3506
+
3507
+ it("does not block Stop from root Ralph fallback when the current session has no scoped Ralph state", async () => {
3508
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-root-fallback-ralph-"));
3509
+ try {
3510
+ const stateDir = join(cwd, ".omx", "state");
3511
+ await mkdir(join(stateDir, "sessions", "sess-current"), { recursive: true });
3512
+ await writeJson(join(stateDir, "session.json"), { session_id: "sess-current", cwd });
3513
+ await writeJson(join(stateDir, "ralph-state.json"), {
3514
+ active: true,
3515
+ current_phase: "executing",
3516
+ });
3517
+
3518
+ const result = await dispatchCodexNativeHook(
3519
+ {
3520
+ hook_event_name: "Stop",
3521
+ cwd,
3522
+ session_id: "sess-current",
3523
+ },
3524
+ { cwd },
3525
+ );
3526
+
3527
+ assert.equal(result.omxEventName, "stop");
3528
+ assert.equal(result.outputJson, null);
3529
+ } finally {
3530
+ await rm(cwd, { recursive: true, force: true });
3531
+ }
3532
+ });
3533
+
3534
+ it("does not block Stop when the current session Ralph state is cancelled even if stale root fallback remains", async () => {
3535
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-cancelled-session-ralph-"));
3536
+ try {
3537
+ const stateDir = join(cwd, ".omx", "state");
3538
+ await mkdir(join(stateDir, "sessions", "sess-current"), { recursive: true });
3539
+ await writeJson(join(stateDir, "session.json"), { session_id: "sess-current", cwd });
3540
+ await writeJson(join(stateDir, "sessions", "sess-current", "ralph-state.json"), {
3541
+ active: false,
3542
+ current_phase: "cancelled",
3543
+ completed_at: "2026-04-10T23:30:38.000Z",
3544
+ session_id: "sess-current",
3545
+ });
3546
+ await writeJson(join(stateDir, "ralph-state.json"), {
3547
+ active: true,
3548
+ current_phase: "starting",
3549
+ });
3550
+
3551
+ const result = await dispatchCodexNativeHook(
3552
+ {
3553
+ hook_event_name: "Stop",
3554
+ cwd,
3555
+ session_id: "sess-current",
3556
+ },
3557
+ { cwd },
3558
+ );
3559
+
3560
+ assert.equal(result.omxEventName, "stop");
3561
+ assert.equal(result.outputJson, null);
3562
+ } finally {
3563
+ await rm(cwd, { recursive: true, force: true });
3564
+ }
3565
+ });
3566
+
3567
+ it("does not block Stop from root Ralph fallback when an explicit session_id is present and session.json points to another worktree", async () => {
3568
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-root-fallback-cwd-mismatch-"));
3569
+ try {
3570
+ const stateDir = join(cwd, ".omx", "state");
3571
+ await mkdir(stateDir, { recursive: true });
3572
+ await writeJson(join(stateDir, "session.json"), {
3573
+ session_id: "sess-elsewhere",
3574
+ cwd: join(cwd, "..", "different-worktree"),
3575
+ });
3576
+ await writeJson(join(stateDir, "ralph-state.json"), {
3577
+ active: true,
3578
+ current_phase: "executing",
3579
+ });
3580
+
3581
+ const result = await dispatchCodexNativeHook(
3582
+ {
3583
+ hook_event_name: "Stop",
3584
+ cwd,
3585
+ session_id: "sess-current",
3586
+ },
3587
+ { cwd },
3588
+ );
3589
+
3590
+ assert.equal(result.omxEventName, "stop");
3591
+ assert.equal(result.outputJson, null);
3592
+ } finally {
3593
+ await rm(cwd, { recursive: true, force: true });
3594
+ }
3595
+ });
3596
+
3597
+ it("keeps blocking Ralph Stop replays until the active task advances", async () => {
3598
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-ralph-replay-"));
3599
+ const previousOmxSessionId = process.env.OMX_SESSION_ID;
3600
+ try {
3601
+ const stateDir = join(cwd, ".omx", "state");
3602
+ await mkdir(stateDir, { recursive: true });
3603
+ await writeFile(
3604
+ join(stateDir, "ralph-state.json"),
3605
+ JSON.stringify({
3606
+ active: true,
3607
+ current_phase: "executing",
3608
+ }),
3609
+ );
3610
+
3611
+ process.env.OMX_SESSION_ID = "sess-stop-ralph-replay";
3612
+ const payload = {
3613
+ hook_event_name: "Stop",
3614
+ cwd,
3615
+ last_assistant_message: "Next active targets:\n\n1. scheduler integration\n\nI am continuing.",
3616
+ };
3617
+ const expected = {
3618
+ decision: "block",
3619
+ reason:
3620
+ "OMX Ralph is still active (phase: executing); continue the task and gather fresh verification evidence before stopping.",
3621
+ stopReason: "ralph_executing",
3622
+ systemMessage:
3623
+ "OMX Ralph is still active (phase: executing); continue the task and gather fresh verification evidence before stopping.",
3624
+ };
3625
+
3626
+ const first = await dispatchCodexNativeHook(payload, { cwd });
3627
+ const replay = await dispatchCodexNativeHook(
3628
+ {
3629
+ ...payload,
3630
+ stop_hook_active: true,
3631
+ },
3632
+ { cwd },
3633
+ );
3634
+
3635
+ assert.equal(first.omxEventName, "stop");
3636
+ assert.deepEqual(first.outputJson, expected);
3637
+ assert.equal(replay.omxEventName, "stop");
3638
+ assert.deepEqual(replay.outputJson, expected);
3639
+ } finally {
3640
+ if (typeof previousOmxSessionId === "string") process.env.OMX_SESSION_ID = previousOmxSessionId;
3641
+ else delete process.env.OMX_SESSION_ID;
3642
+ await rm(cwd, { recursive: true, force: true });
3643
+ }
3644
+ });
3645
+
3646
+
3647
+ it("returns Stop continuation output for native auto-nudge stall prompts", async () => {
3648
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-auto-nudge-"));
3649
+ try {
3650
+ const stateDir = join(cwd, ".omx", "state");
3651
+ await mkdir(stateDir, { recursive: true });
3652
+ process.env.OMX_SESSION_ID = "sess-stop-auto";
3653
+
3654
+ const result = await dispatchCodexNativeHook(
3655
+ {
3656
+ hook_event_name: "Stop",
3657
+ cwd,
3658
+ session_id: "sess-stop-auto",
3659
+ last_assistant_message: "Keep going and finish the cleanup.",
3660
+ },
3661
+ { cwd },
3662
+ );
3663
+
3664
+ assert.equal(result.omxEventName, "stop");
3665
+ assert.deepEqual(result.outputJson, {
3666
+ decision: "block",
3667
+ reason: DEFAULT_AUTO_NUDGE_RESPONSE,
3668
+ stopReason: "auto_nudge",
3669
+ systemMessage:
3670
+ "OMX native Stop detected a stall/permission-style handoff and continued the turn automatically.",
3671
+ });
3672
+ } finally {
3673
+ await rm(cwd, { recursive: true, force: true });
3674
+ }
3675
+ });
3676
+
3677
+ it("re-blocks duplicate native auto-nudge replays for the same Stop reply", async () => {
3678
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-auto-nudge-once-"));
3679
+ try {
3680
+ const stateDir = join(cwd, ".omx", "state");
3681
+ await mkdir(stateDir, { recursive: true });
3682
+ process.env.OMX_SESSION_ID = "sess-stop-auto-once";
3683
+
3684
+ await dispatchCodexNativeHook(
3685
+ {
3686
+ hook_event_name: "Stop",
3687
+ cwd,
3688
+ session_id: "sess-stop-auto-once",
3689
+ thread_id: "thread-stop-auto",
3690
+ turn_id: "turn-stop-auto-1",
3691
+ last_assistant_message: "Keep going and finish the cleanup.",
3692
+ },
3693
+ { cwd },
3694
+ );
3695
+
3696
+ const result = await dispatchCodexNativeHook(
3697
+ {
3698
+ hook_event_name: "Stop",
3699
+ cwd,
3700
+ session_id: "sess-stop-auto-once",
3701
+ thread_id: "thread-stop-auto",
3702
+ turn_id: "turn-stop-auto-1",
3703
+ stop_hook_active: true,
3704
+ last_assistant_message: "Keep going and finish the cleanup.",
3705
+ },
3706
+ { cwd },
3707
+ );
3708
+
3709
+ assert.equal(result.omxEventName, "stop");
3710
+ assert.deepEqual(result.outputJson, {
3711
+ decision: "block",
3712
+ reason: DEFAULT_AUTO_NUDGE_RESPONSE,
3713
+ stopReason: "auto_nudge",
3714
+ systemMessage:
3715
+ "OMX native Stop detected a stall/permission-style handoff and continued the turn automatically.",
3716
+ });
3717
+ } finally {
3718
+ await rm(cwd, { recursive: true, force: true });
3719
+ }
3720
+ });
3721
+
3722
+ it("re-blocks duplicate native auto-nudge replays across native/canonical session-id drift", async () => {
3723
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-auto-nudge-session-drift-"));
3724
+ try {
3725
+ const stateDir = join(cwd, ".omx", "state");
3726
+ await mkdir(stateDir, { recursive: true });
3727
+ process.env.OMX_SESSION_ID = "omx-canonical";
3728
+ await writeJson(join(stateDir, "session.json"), {
3729
+ session_id: "omx-canonical",
3730
+ native_session_id: "codex-native",
3731
+ });
3732
+
3733
+ await dispatchCodexNativeHook(
3115
3734
  {
3116
3735
  hook_event_name: "Stop",
3117
3736
  cwd,
@@ -3992,3 +4611,691 @@ esac
3992
4611
  }
3993
4612
  });
3994
4613
  });
4614
+
4615
+ // ---------------------------------------------------------------------------
4616
+ // Triage layer integration tests
4617
+ // ---------------------------------------------------------------------------
4618
+
4619
+ describe("codex native hook triage integration", () => {
4620
+ const priorCodexHome = process.env.CODEX_HOME;
4621
+
4622
+ beforeEach(() => {
4623
+ resetTriageConfigCache();
4624
+ });
4625
+
4626
+ afterEach(() => {
4627
+ if (typeof priorCodexHome === "string") process.env.CODEX_HOME = priorCodexHome;
4628
+ else delete process.env.CODEX_HOME;
4629
+ resetTriageConfigCache();
4630
+ });
4631
+
4632
+ // ── Group 1: Keyword bypass (triage must NOT run) ────────────────────────
4633
+
4634
+ it("does not inject triage advisory for $ralplan keyword prompts", async () => {
4635
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-keyword-ralplan-"));
4636
+ try {
4637
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
4638
+ const result = await dispatchCodexNativeHook(
4639
+ {
4640
+ hook_event_name: "UserPromptSubmit",
4641
+ cwd,
4642
+ session_id: "triage-kw-ralplan-1",
4643
+ thread_id: "thread-triage-kw-1",
4644
+ turn_id: "turn-triage-kw-1",
4645
+ prompt: "$ralplan implement issue #1307",
4646
+ },
4647
+ { cwd },
4648
+ );
4649
+
4650
+ const additionalContext = String(
4651
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
4652
+ );
4653
+ assert.doesNotMatch(additionalContext, /multi-step goal with no workflow keyword/);
4654
+ assert.doesNotMatch(additionalContext, /read-only\/question-shaped/);
4655
+ assert.doesNotMatch(additionalContext, /narrow edit-shaped/);
4656
+ assert.doesNotMatch(additionalContext, /visual\/style request/);
4657
+
4658
+ const stateFile = join(cwd, ".omx", "state", "sessions", "triage-kw-ralplan-1", "prompt-routing-state.json");
4659
+ assert.equal(existsSync(stateFile), false);
4660
+ } finally {
4661
+ await rm(cwd, { recursive: true, force: true });
4662
+ }
4663
+ });
4664
+
4665
+ it("does not inject triage advisory for autopilot keyword prompts", async () => {
4666
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-keyword-autopilot-"));
4667
+ try {
4668
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
4669
+ const result = await dispatchCodexNativeHook(
4670
+ {
4671
+ hook_event_name: "UserPromptSubmit",
4672
+ cwd,
4673
+ session_id: "triage-kw-autopilot-1",
4674
+ thread_id: "thread-triage-kw-ap-1",
4675
+ turn_id: "turn-triage-kw-ap-1",
4676
+ prompt: "$autopilot build this",
4677
+ },
4678
+ { cwd },
4679
+ );
4680
+
4681
+ const additionalContext = String(
4682
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
4683
+ );
4684
+ assert.doesNotMatch(additionalContext, /multi-step goal with no workflow keyword/);
4685
+ assert.doesNotMatch(additionalContext, /read-only\/question-shaped/);
4686
+ assert.doesNotMatch(additionalContext, /narrow edit-shaped/);
4687
+ assert.doesNotMatch(additionalContext, /visual\/style request/);
4688
+
4689
+ const stateFile = join(cwd, ".omx", "state", "sessions", "triage-kw-autopilot-1", "prompt-routing-state.json");
4690
+ assert.equal(existsSync(stateFile), false);
4691
+ } finally {
4692
+ await rm(cwd, { recursive: true, force: true });
4693
+ }
4694
+ });
4695
+
4696
+ // ── Group 2: HEAVY injection ─────────────────────────────────────────────
4697
+
4698
+ it("injects HEAVY advisory and writes prompt-routing-state for a multi-step goal prompt", async () => {
4699
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-heavy-"));
4700
+ try {
4701
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
4702
+ const result = await dispatchCodexNativeHook(
4703
+ {
4704
+ hook_event_name: "UserPromptSubmit",
4705
+ cwd,
4706
+ session_id: "triage-heavy-1",
4707
+ thread_id: "thread-triage-heavy-1",
4708
+ turn_id: "turn-triage-heavy-1",
4709
+ prompt: "add dark mode toggle to the settings page",
4710
+ },
4711
+ { cwd },
4712
+ );
4713
+
4714
+ const additionalContext = String(
4715
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
4716
+ );
4717
+ assert.match(additionalContext, /multi-step goal with no workflow keyword/);
4718
+ assert.match(additionalContext, /Prefer the existing autopilot-style workflow/);
4719
+
4720
+ // skill-active-state.json must NOT be written (triage is advisory only)
4721
+ assert.equal(existsSync(join(cwd, ".omx", "state", "skill-active-state.json")), false);
4722
+
4723
+ // prompt-routing-state.json must be written with lane=HEAVY
4724
+ const stateFile = join(cwd, ".omx", "state", "sessions", "triage-heavy-1", "prompt-routing-state.json");
4725
+ assert.equal(existsSync(stateFile), true);
4726
+ const state = JSON.parse(await readFile(stateFile, "utf-8")) as {
4727
+ version?: number;
4728
+ last_triage?: { lane?: string; destination?: string };
4729
+ suppress_followup?: boolean;
4730
+ };
4731
+ assert.equal(state.version, 1);
4732
+ assert.equal(state.last_triage?.lane, "HEAVY");
4733
+ assert.equal(state.last_triage?.destination, "autopilot");
4734
+ assert.equal(state.suppress_followup, true);
4735
+ } finally {
4736
+ await rm(cwd, { recursive: true, force: true });
4737
+ }
4738
+ });
4739
+
4740
+ // ── Group 3: LIGHT/explore ────────────────────────────────────────────────
4741
+
4742
+ it("injects LIGHT/explore advisory and writes state for a question-shaped prompt", async () => {
4743
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-light-explore-"));
4744
+ try {
4745
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
4746
+ const result = await dispatchCodexNativeHook(
4747
+ {
4748
+ hook_event_name: "UserPromptSubmit",
4749
+ cwd,
4750
+ session_id: "triage-explore-1",
4751
+ thread_id: "thread-triage-explore-1",
4752
+ turn_id: "turn-triage-explore-1",
4753
+ prompt: "explain this function",
4754
+ },
4755
+ { cwd },
4756
+ );
4757
+
4758
+ const additionalContext = String(
4759
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
4760
+ );
4761
+ assert.match(additionalContext, /read-only\/question-shaped/);
4762
+ assert.match(additionalContext, /Prefer the explore role surface/);
4763
+
4764
+ const stateFile = join(cwd, ".omx", "state", "sessions", "triage-explore-1", "prompt-routing-state.json");
4765
+ assert.equal(existsSync(stateFile), true);
4766
+ const state = JSON.parse(await readFile(stateFile, "utf-8")) as {
4767
+ last_triage?: { lane?: string; destination?: string };
4768
+ suppress_followup?: boolean;
4769
+ };
4770
+ assert.equal(state.last_triage?.lane, "LIGHT");
4771
+ assert.equal(state.last_triage?.destination, "explore");
4772
+ assert.equal(state.suppress_followup, true);
4773
+ } finally {
4774
+ await rm(cwd, { recursive: true, force: true });
4775
+ }
4776
+ });
4777
+
4778
+ // ── Group 4: LIGHT/executor ───────────────────────────────────────────────
4779
+
4780
+ it("injects LIGHT/executor advisory and writes state for a narrow edit-shaped prompt", async () => {
4781
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-light-executor-"));
4782
+ try {
4783
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
4784
+ const result = await dispatchCodexNativeHook(
4785
+ {
4786
+ hook_event_name: "UserPromptSubmit",
4787
+ cwd,
4788
+ session_id: "triage-executor-1",
4789
+ thread_id: "thread-triage-executor-1",
4790
+ turn_id: "turn-triage-executor-1",
4791
+ prompt: "fix typo in src/foo.ts",
4792
+ },
4793
+ { cwd },
4794
+ );
4795
+
4796
+ const additionalContext = String(
4797
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
4798
+ );
4799
+ assert.match(additionalContext, /narrow edit-shaped/);
4800
+ assert.match(additionalContext, /Prefer the executor role surface/);
4801
+
4802
+ const stateFile = join(cwd, ".omx", "state", "sessions", "triage-executor-1", "prompt-routing-state.json");
4803
+ assert.equal(existsSync(stateFile), true);
4804
+ const state = JSON.parse(await readFile(stateFile, "utf-8")) as {
4805
+ last_triage?: { lane?: string; destination?: string };
4806
+ };
4807
+ assert.equal(state.last_triage?.lane, "LIGHT");
4808
+ assert.equal(state.last_triage?.destination, "executor");
4809
+ } finally {
4810
+ await rm(cwd, { recursive: true, force: true });
4811
+ }
4812
+ });
4813
+
4814
+ // ── Group 5: LIGHT/designer ───────────────────────────────────────────────
4815
+
4816
+ it("injects LIGHT/designer advisory and writes state for a visual/style prompt", async () => {
4817
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-light-designer-"));
4818
+ try {
4819
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
4820
+ const result = await dispatchCodexNativeHook(
4821
+ {
4822
+ hook_event_name: "UserPromptSubmit",
4823
+ cwd,
4824
+ session_id: "triage-designer-1",
4825
+ thread_id: "thread-triage-designer-1",
4826
+ turn_id: "turn-triage-designer-1",
4827
+ prompt: "make the button blue",
4828
+ },
4829
+ { cwd },
4830
+ );
4831
+
4832
+ const additionalContext = String(
4833
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
4834
+ );
4835
+ assert.match(additionalContext, /visual\/style request/);
4836
+ assert.match(additionalContext, /Prefer the designer role surface/);
4837
+
4838
+ const stateFile = join(cwd, ".omx", "state", "sessions", "triage-designer-1", "prompt-routing-state.json");
4839
+ assert.equal(existsSync(stateFile), true);
4840
+ const state = JSON.parse(await readFile(stateFile, "utf-8")) as {
4841
+ last_triage?: { lane?: string; destination?: string };
4842
+ };
4843
+ assert.equal(state.last_triage?.lane, "LIGHT");
4844
+ assert.equal(state.last_triage?.destination, "designer");
4845
+ } finally {
4846
+ await rm(cwd, { recursive: true, force: true });
4847
+ }
4848
+ });
4849
+
4850
+ // ── Group 6: PASS (no triage injection, no state) ────────────────────────
4851
+
4852
+ it("produces no triage advisory and no state for trivial greeting prompts", async () => {
4853
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-pass-hello-"));
4854
+ try {
4855
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
4856
+ const result = await dispatchCodexNativeHook(
4857
+ {
4858
+ hook_event_name: "UserPromptSubmit",
4859
+ cwd,
4860
+ session_id: "triage-pass-hello-1",
4861
+ thread_id: "thread-triage-pass-1",
4862
+ turn_id: "turn-triage-pass-1",
4863
+ prompt: "hello",
4864
+ },
4865
+ { cwd },
4866
+ );
4867
+
4868
+ const additionalContext = String(
4869
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
4870
+ );
4871
+ assert.doesNotMatch(additionalContext, /multi-step goal with no workflow keyword/);
4872
+ assert.doesNotMatch(additionalContext, /read-only\/question-shaped/);
4873
+ assert.doesNotMatch(additionalContext, /narrow edit-shaped/);
4874
+ assert.doesNotMatch(additionalContext, /visual\/style request/);
4875
+
4876
+ const stateFile = join(cwd, ".omx", "state", "sessions", "triage-pass-hello-1", "prompt-routing-state.json");
4877
+ assert.equal(existsSync(stateFile), false);
4878
+ } finally {
4879
+ await rm(cwd, { recursive: true, force: true });
4880
+ }
4881
+ });
4882
+
4883
+ it("produces no triage advisory and no state for ambiguous short prompts", async () => {
4884
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-pass-short-"));
4885
+ try {
4886
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
4887
+ const result = await dispatchCodexNativeHook(
4888
+ {
4889
+ hook_event_name: "UserPromptSubmit",
4890
+ cwd,
4891
+ session_id: "triage-pass-short-1",
4892
+ thread_id: "thread-triage-pass-short-1",
4893
+ turn_id: "turn-triage-pass-short-1",
4894
+ prompt: "fix the thing",
4895
+ },
4896
+ { cwd },
4897
+ );
4898
+
4899
+ const additionalContext = String(
4900
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
4901
+ );
4902
+ assert.doesNotMatch(additionalContext, /multi-step goal with no workflow keyword/);
4903
+ assert.doesNotMatch(additionalContext, /read-only\/question-shaped/);
4904
+ assert.doesNotMatch(additionalContext, /narrow edit-shaped/);
4905
+ assert.doesNotMatch(additionalContext, /visual\/style request/);
4906
+
4907
+ const stateFile = join(cwd, ".omx", "state", "sessions", "triage-pass-short-1", "prompt-routing-state.json");
4908
+ assert.equal(existsSync(stateFile), false);
4909
+ } finally {
4910
+ await rm(cwd, { recursive: true, force: true });
4911
+ }
4912
+ });
4913
+
4914
+ // ── Group 7: Turn-2 suppression (same session across two invocations) ────
4915
+
4916
+ it("suppresses HEAVY triage re-injection on a short follow-up in the same session", async () => {
4917
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-suppress-heavy-"));
4918
+ const sessionId = "triage-suppress-heavy-1";
4919
+ try {
4920
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
4921
+
4922
+ // Turn 1: HEAVY fires
4923
+ const turn1 = await dispatchCodexNativeHook(
4924
+ {
4925
+ hook_event_name: "UserPromptSubmit",
4926
+ cwd,
4927
+ session_id: sessionId,
4928
+ thread_id: "thread-suppress-heavy-1",
4929
+ turn_id: "turn-suppress-heavy-1",
4930
+ prompt: "add dark mode toggle to the settings page",
4931
+ },
4932
+ { cwd },
4933
+ );
4934
+ const ctx1 = String(
4935
+ (turn1.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
4936
+ );
4937
+ assert.match(ctx1, /multi-step goal with no workflow keyword/);
4938
+
4939
+ // Turn 2: short follow-up — triage suppressed
4940
+ const turn2 = await dispatchCodexNativeHook(
4941
+ {
4942
+ hook_event_name: "UserPromptSubmit",
4943
+ cwd,
4944
+ session_id: sessionId,
4945
+ thread_id: "thread-suppress-heavy-1",
4946
+ turn_id: "turn-suppress-heavy-2",
4947
+ prompt: "yes, settings page",
4948
+ },
4949
+ { cwd },
4950
+ );
4951
+ const ctx2 = String(
4952
+ (turn2.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
4953
+ );
4954
+ assert.doesNotMatch(ctx2, /multi-step goal/);
4955
+ } finally {
4956
+ await rm(cwd, { recursive: true, force: true });
4957
+ }
4958
+ });
4959
+
4960
+ it("suppresses LIGHT/explore triage re-injection on a short follow-up in the same session", async () => {
4961
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-suppress-explore-"));
4962
+ const sessionId = "triage-suppress-explore-1";
4963
+ try {
4964
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
4965
+
4966
+ // Turn 1: LIGHT/explore fires
4967
+ await dispatchCodexNativeHook(
4968
+ {
4969
+ hook_event_name: "UserPromptSubmit",
4970
+ cwd,
4971
+ session_id: sessionId,
4972
+ thread_id: "thread-suppress-explore-1",
4973
+ turn_id: "turn-suppress-explore-1",
4974
+ prompt: "explain this function",
4975
+ },
4976
+ { cwd },
4977
+ );
4978
+
4979
+ // Turn 2: short follow-up — no duplicate LIGHT injection
4980
+ const turn2 = await dispatchCodexNativeHook(
4981
+ {
4982
+ hook_event_name: "UserPromptSubmit",
4983
+ cwd,
4984
+ session_id: sessionId,
4985
+ thread_id: "thread-suppress-explore-1",
4986
+ turn_id: "turn-suppress-explore-2",
4987
+ prompt: "the auth helper",
4988
+ },
4989
+ { cwd },
4990
+ );
4991
+ const ctx2 = String(
4992
+ (turn2.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
4993
+ );
4994
+ assert.doesNotMatch(ctx2, /read-only\/question-shaped/);
4995
+ } finally {
4996
+ await rm(cwd, { recursive: true, force: true });
4997
+ }
4998
+ });
4999
+
5000
+ // ── Group 8: First-turn PASS does NOT block later triage ─────────────────
5001
+
5002
+ it("still applies triage on turn 2 when turn 1 was a PASS with no state written", async () => {
5003
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-pass-then-light-"));
5004
+ const sessionId = "triage-pass-then-light-1";
5005
+ try {
5006
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
5007
+
5008
+ // Turn 1: PASS — no state written
5009
+ await dispatchCodexNativeHook(
5010
+ {
5011
+ hook_event_name: "UserPromptSubmit",
5012
+ cwd,
5013
+ session_id: sessionId,
5014
+ thread_id: "thread-pass-then-light-1",
5015
+ turn_id: "turn-pass-then-light-1",
5016
+ prompt: "hello",
5017
+ },
5018
+ { cwd },
5019
+ );
5020
+ assert.equal(
5021
+ existsSync(join(cwd, ".omx", "state", "sessions", sessionId, "prompt-routing-state.json")),
5022
+ false,
5023
+ );
5024
+
5025
+ // Turn 2: LIGHT/executor should fire normally
5026
+ const turn2 = await dispatchCodexNativeHook(
5027
+ {
5028
+ hook_event_name: "UserPromptSubmit",
5029
+ cwd,
5030
+ session_id: sessionId,
5031
+ thread_id: "thread-pass-then-light-1",
5032
+ turn_id: "turn-pass-then-light-2",
5033
+ prompt: "fix typo in src/foo.ts",
5034
+ },
5035
+ { cwd },
5036
+ );
5037
+ const ctx2 = String(
5038
+ (turn2.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
5039
+ );
5040
+ assert.match(ctx2, /narrow edit-shaped/);
5041
+ } finally {
5042
+ await rm(cwd, { recursive: true, force: true });
5043
+ }
5044
+ });
5045
+
5046
+ // ── Group 9: Opt-out forces PASS ─────────────────────────────────────────
5047
+
5048
+ it("produces no triage advisory when prompt contains 'just chat' opt-out", async () => {
5049
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-optout-chat-"));
5050
+ try {
5051
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
5052
+ const result = await dispatchCodexNativeHook(
5053
+ {
5054
+ hook_event_name: "UserPromptSubmit",
5055
+ cwd,
5056
+ session_id: "triage-optout-chat-1",
5057
+ thread_id: "thread-optout-chat-1",
5058
+ turn_id: "turn-optout-chat-1",
5059
+ prompt: "add dark mode toggle to the settings page, but just chat about it",
5060
+ },
5061
+ { cwd },
5062
+ );
5063
+
5064
+ const additionalContext = String(
5065
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
5066
+ );
5067
+ assert.doesNotMatch(additionalContext, /multi-step goal with no workflow keyword/);
5068
+ assert.doesNotMatch(additionalContext, /read-only\/question-shaped/);
5069
+
5070
+ const stateFile = join(cwd, ".omx", "state", "sessions", "triage-optout-chat-1", "prompt-routing-state.json");
5071
+ assert.equal(existsSync(stateFile), false);
5072
+ } finally {
5073
+ await rm(cwd, { recursive: true, force: true });
5074
+ }
5075
+ });
5076
+
5077
+ it("produces no triage advisory when prompt contains 'no workflow' opt-out", async () => {
5078
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-optout-noworkflow-"));
5079
+ try {
5080
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
5081
+ const result = await dispatchCodexNativeHook(
5082
+ {
5083
+ hook_event_name: "UserPromptSubmit",
5084
+ cwd,
5085
+ session_id: "triage-optout-noworkflow-1",
5086
+ thread_id: "thread-optout-noworkflow-1",
5087
+ turn_id: "turn-optout-noworkflow-1",
5088
+ prompt: "make the button blue, no workflow",
5089
+ },
5090
+ { cwd },
5091
+ );
5092
+
5093
+ const additionalContext = String(
5094
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
5095
+ );
5096
+ assert.doesNotMatch(additionalContext, /visual\/style request/);
5097
+
5098
+ const stateFile = join(cwd, ".omx", "state", "sessions", "triage-optout-noworkflow-1", "prompt-routing-state.json");
5099
+ assert.equal(existsSync(stateFile), false);
5100
+ } finally {
5101
+ await rm(cwd, { recursive: true, force: true });
5102
+ }
5103
+ });
5104
+
5105
+ // ── Group 10: Keyword on follow-up turn wins cleanly ─────────────────────
5106
+
5107
+ it("keyword on turn 2 suppresses triage and writes no triage state", async () => {
5108
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-kw-followup-"));
5109
+ const sessionId = "triage-kw-followup-1";
5110
+ try {
5111
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
5112
+
5113
+ // Turn 1: neutral prompt — triage may or may not fire, doesn't matter
5114
+ await dispatchCodexNativeHook(
5115
+ {
5116
+ hook_event_name: "UserPromptSubmit",
5117
+ cwd,
5118
+ session_id: sessionId,
5119
+ thread_id: "thread-kw-followup-1",
5120
+ turn_id: "turn-kw-followup-1",
5121
+ prompt: "hello",
5122
+ },
5123
+ { cwd },
5124
+ );
5125
+
5126
+ // Turn 2: keyword prompt — keyword fast-path runs, triage does NOT add extra advisory
5127
+ const turn2 = await dispatchCodexNativeHook(
5128
+ {
5129
+ hook_event_name: "UserPromptSubmit",
5130
+ cwd,
5131
+ session_id: sessionId,
5132
+ thread_id: "thread-kw-followup-1",
5133
+ turn_id: "turn-kw-followup-2",
5134
+ prompt: "$ralph continue",
5135
+ },
5136
+ { cwd },
5137
+ );
5138
+
5139
+ assert.equal(turn2.skillState?.skill, "ralph");
5140
+
5141
+ const ctx2 = String(
5142
+ (turn2.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
5143
+ );
5144
+ assert.doesNotMatch(ctx2, /multi-step goal with no workflow keyword/);
5145
+ assert.doesNotMatch(ctx2, /read-only\/question-shaped/);
5146
+ assert.doesNotMatch(ctx2, /narrow edit-shaped/);
5147
+ assert.doesNotMatch(ctx2, /visual\/style request/);
5148
+
5149
+ // No triage state written on the keyword turn
5150
+ const triageState = join(cwd, ".omx", "state", "sessions", sessionId, "prompt-routing-state.json");
5151
+ // The state from turn 1 (if any) must not have been created either (hello = PASS)
5152
+ assert.equal(existsSync(triageState), false);
5153
+ } finally {
5154
+ await rm(cwd, { recursive: true, force: true });
5155
+ }
5156
+ });
5157
+
5158
+ // ── Group 11: Config-disabled path ───────────────────────────────────────
5159
+
5160
+ it("produces no triage advisory and no state when triage is disabled in config", async () => {
5161
+ const tmpHome = await mkdtemp(join(tmpdir(), "omx-triage-config-disabled-home-"));
5162
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-config-disabled-cwd-"));
5163
+ try {
5164
+ // Write a .omx-config.json in the fake CODEX_HOME that disables triage
5165
+ await writeJson(join(tmpHome, ".omx-config.json"), {
5166
+ promptRouting: { triage: { enabled: false } },
5167
+ });
5168
+ process.env.CODEX_HOME = tmpHome;
5169
+ resetTriageConfigCache();
5170
+
5171
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
5172
+ const result = await dispatchCodexNativeHook(
5173
+ {
5174
+ hook_event_name: "UserPromptSubmit",
5175
+ cwd,
5176
+ session_id: "triage-disabled-1",
5177
+ thread_id: "thread-triage-disabled-1",
5178
+ turn_id: "turn-triage-disabled-1",
5179
+ prompt: "add dark mode toggle to the settings page",
5180
+ },
5181
+ { cwd },
5182
+ );
5183
+
5184
+ const additionalContext = String(
5185
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
5186
+ );
5187
+ assert.doesNotMatch(additionalContext, /multi-step goal with no workflow keyword/);
5188
+
5189
+ const stateFile = join(cwd, ".omx", "state", "sessions", "triage-disabled-1", "prompt-routing-state.json");
5190
+ assert.equal(existsSync(stateFile), false);
5191
+ } finally {
5192
+ await rm(tmpHome, { recursive: true, force: true });
5193
+ await rm(cwd, { recursive: true, force: true });
5194
+ }
5195
+ });
5196
+
5197
+ it("keeps triage default-enabled when config omits promptRouting.triage.enabled", async () => {
5198
+ const tmpHome = await mkdtemp(join(tmpdir(), "omx-triage-config-omitted-home-"));
5199
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-config-omitted-cwd-"));
5200
+ const previousCodexHome = process.env.CODEX_HOME;
5201
+ try {
5202
+ await writeJson(join(tmpHome, ".omx-config.json"), {
5203
+ promptRouting: {},
5204
+ });
5205
+ process.env.CODEX_HOME = tmpHome;
5206
+ resetTriageConfigCache();
5207
+
5208
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
5209
+ const result = await dispatchCodexNativeHook(
5210
+ {
5211
+ hook_event_name: "UserPromptSubmit",
5212
+ cwd,
5213
+ session_id: "triage-defaulted-1",
5214
+ thread_id: "thread-triage-defaulted-1",
5215
+ turn_id: "turn-triage-defaulted-1",
5216
+ prompt: "add dark mode toggle to the settings page",
5217
+ },
5218
+ { cwd },
5219
+ );
5220
+
5221
+ const additionalContext = String(
5222
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
5223
+ );
5224
+ assert.match(additionalContext, /multi-step goal with no workflow keyword/);
5225
+
5226
+ const stateFile = join(cwd, ".omx", "state", "sessions", "triage-defaulted-1", "prompt-routing-state.json");
5227
+ assert.equal(existsSync(stateFile), true);
5228
+ } finally {
5229
+ if (typeof previousCodexHome === "string") process.env.CODEX_HOME = previousCodexHome;
5230
+ else delete process.env.CODEX_HOME;
5231
+ resetTriageConfigCache();
5232
+ await rm(tmpHome, { recursive: true, force: true });
5233
+ await rm(cwd, { recursive: true, force: true });
5234
+ }
5235
+ });
5236
+
5237
+ it("does not suppress a short anchored follow-up that is a new request", async () => {
5238
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-short-new-request-"));
5239
+ const sessionId = "triage-short-new-request-1";
5240
+ try {
5241
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
5242
+
5243
+ await dispatchCodexNativeHook(
5244
+ {
5245
+ hook_event_name: "UserPromptSubmit",
5246
+ cwd,
5247
+ session_id: sessionId,
5248
+ thread_id: "thread-short-new-request-1",
5249
+ turn_id: "turn-short-new-request-1",
5250
+ prompt: "add dark mode toggle to the settings page",
5251
+ },
5252
+ { cwd },
5253
+ );
5254
+
5255
+ const turn2 = await dispatchCodexNativeHook(
5256
+ {
5257
+ hook_event_name: "UserPromptSubmit",
5258
+ cwd,
5259
+ session_id: sessionId,
5260
+ thread_id: "thread-short-new-request-1",
5261
+ turn_id: "turn-short-new-request-2",
5262
+ prompt: "fix typo in src/foo.ts",
5263
+ },
5264
+ { cwd },
5265
+ );
5266
+
5267
+ const ctx2 = String(
5268
+ (turn2.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
5269
+ );
5270
+ assert.match(ctx2, /narrow edit-shaped/);
5271
+ } finally {
5272
+ await rm(cwd, { recursive: true, force: true });
5273
+ }
5274
+ });
5275
+
5276
+ it("skips triage state persistence for malformed explicit session ids without writing root state", async () => {
5277
+ const cwd = await mkdtemp(join(tmpdir(), "omx-triage-invalid-session-"));
5278
+ try {
5279
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
5280
+ const result = await dispatchCodexNativeHook(
5281
+ {
5282
+ hook_event_name: "UserPromptSubmit",
5283
+ cwd,
5284
+ session_id: "bad/session",
5285
+ thread_id: "thread-triage-invalid-session-1",
5286
+ turn_id: "turn-triage-invalid-session-1",
5287
+ prompt: "add dark mode toggle to the settings page",
5288
+ },
5289
+ { cwd },
5290
+ );
5291
+
5292
+ const additionalContext = String(
5293
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
5294
+ );
5295
+ assert.match(additionalContext, /multi-step goal with no workflow keyword/);
5296
+ assert.equal(existsSync(join(cwd, ".omx", "state", "prompt-routing-state.json")), false);
5297
+ } finally {
5298
+ await rm(cwd, { recursive: true, force: true });
5299
+ }
5300
+ });
5301
+ });