oh-my-codex 0.14.0 → 0.14.2

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 (185) hide show
  1. package/Cargo.lock +5 -5
  2. package/Cargo.toml +1 -1
  3. package/README.md +14 -8
  4. package/crates/omx-explore/src/main.rs +94 -1
  5. package/crates/omx-sparkshell/src/codex_bridge.rs +59 -12
  6. package/crates/omx-sparkshell/tests/execution.rs +48 -0
  7. package/dist/cli/__tests__/explore.test.js +33 -1
  8. package/dist/cli/__tests__/explore.test.js.map +1 -1
  9. package/dist/cli/__tests__/index.test.js +11 -2
  10. package/dist/cli/__tests__/index.test.js.map +1 -1
  11. package/dist/cli/__tests__/package-bin-contract.test.js +5 -0
  12. package/dist/cli/__tests__/package-bin-contract.test.js.map +1 -1
  13. package/dist/cli/__tests__/question.test.js +139 -25
  14. package/dist/cli/__tests__/question.test.js.map +1 -1
  15. package/dist/cli/__tests__/session-scoped-runtime.test.js +30 -0
  16. package/dist/cli/__tests__/session-scoped-runtime.test.js.map +1 -1
  17. package/dist/cli/__tests__/setup-agents-overwrite.test.js +32 -7
  18. package/dist/cli/__tests__/setup-agents-overwrite.test.js.map +1 -1
  19. package/dist/cli/__tests__/setup-refresh.test.js +8 -6
  20. package/dist/cli/__tests__/setup-refresh.test.js.map +1 -1
  21. package/dist/cli/__tests__/sparkshell-cli.test.js +23 -0
  22. package/dist/cli/__tests__/sparkshell-cli.test.js.map +1 -1
  23. package/dist/cli/__tests__/uninstall.test.js +65 -5
  24. package/dist/cli/__tests__/uninstall.test.js.map +1 -1
  25. package/dist/cli/__tests__/update.test.js +360 -26
  26. package/dist/cli/__tests__/update.test.js.map +1 -1
  27. package/dist/cli/explore.d.ts.map +1 -1
  28. package/dist/cli/explore.js +18 -3
  29. package/dist/cli/explore.js.map +1 -1
  30. package/dist/cli/index.d.ts +2 -1
  31. package/dist/cli/index.d.ts.map +1 -1
  32. package/dist/cli/index.js +7 -2
  33. package/dist/cli/index.js.map +1 -1
  34. package/dist/cli/setup.d.ts.map +1 -1
  35. package/dist/cli/setup.js +25 -3
  36. package/dist/cli/setup.js.map +1 -1
  37. package/dist/cli/sparkshell.d.ts.map +1 -1
  38. package/dist/cli/sparkshell.js +11 -1
  39. package/dist/cli/sparkshell.js.map +1 -1
  40. package/dist/cli/team.d.ts.map +1 -1
  41. package/dist/cli/team.js +159 -394
  42. package/dist/cli/team.js.map +1 -1
  43. package/dist/cli/uninstall.d.ts.map +1 -1
  44. package/dist/cli/uninstall.js +3 -1
  45. package/dist/cli/uninstall.js.map +1 -1
  46. package/dist/cli/update.d.ts +37 -9
  47. package/dist/cli/update.d.ts.map +1 -1
  48. package/dist/cli/update.js +204 -26
  49. package/dist/cli/update.js.map +1 -1
  50. package/dist/config/__tests__/generator-idempotent.test.js +51 -14
  51. package/dist/config/__tests__/generator-idempotent.test.js.map +1 -1
  52. package/dist/config/__tests__/generator-notify.test.js +35 -10
  53. package/dist/config/__tests__/generator-notify.test.js.map +1 -1
  54. package/dist/config/generator.d.ts +1 -0
  55. package/dist/config/generator.d.ts.map +1 -1
  56. package/dist/config/generator.js +61 -7
  57. package/dist/config/generator.js.map +1 -1
  58. package/dist/hooks/__tests__/code-review-skill-contract.test.d.ts +2 -0
  59. package/dist/hooks/__tests__/code-review-skill-contract.test.d.ts.map +1 -0
  60. package/dist/hooks/__tests__/code-review-skill-contract.test.js +56 -0
  61. package/dist/hooks/__tests__/code-review-skill-contract.test.js.map +1 -0
  62. package/dist/hooks/__tests__/deep-interview-contract.test.js +31 -0
  63. package/dist/hooks/__tests__/deep-interview-contract.test.js.map +1 -1
  64. package/dist/hooks/__tests__/explicit-terminal-stop-docs-contract.test.d.ts +2 -0
  65. package/dist/hooks/__tests__/explicit-terminal-stop-docs-contract.test.d.ts.map +1 -0
  66. package/dist/hooks/__tests__/explicit-terminal-stop-docs-contract.test.js +43 -0
  67. package/dist/hooks/__tests__/explicit-terminal-stop-docs-contract.test.js.map +1 -0
  68. package/dist/hooks/__tests__/explicit-terminal-stop-model-docs-contract.test.d.ts +2 -0
  69. package/dist/hooks/__tests__/explicit-terminal-stop-model-docs-contract.test.d.ts.map +1 -0
  70. package/dist/hooks/__tests__/explicit-terminal-stop-model-docs-contract.test.js +38 -0
  71. package/dist/hooks/__tests__/explicit-terminal-stop-model-docs-contract.test.js.map +1 -0
  72. package/dist/hooks/__tests__/keyword-detector.test.js +108 -0
  73. package/dist/hooks/__tests__/keyword-detector.test.js.map +1 -1
  74. package/dist/hooks/__tests__/prompt-guidance-test-helpers.d.ts.map +1 -1
  75. package/dist/hooks/__tests__/prompt-guidance-test-helpers.js +16 -1
  76. package/dist/hooks/__tests__/prompt-guidance-test-helpers.js.map +1 -1
  77. package/dist/hooks/keyword-detector.d.ts.map +1 -1
  78. package/dist/hooks/keyword-detector.js +34 -8
  79. package/dist/hooks/keyword-detector.js.map +1 -1
  80. package/dist/mcp/__tests__/bootstrap.test.js +7 -25
  81. package/dist/mcp/__tests__/bootstrap.test.js.map +1 -1
  82. package/dist/mcp/__tests__/server-lifecycle.test.js +60 -0
  83. package/dist/mcp/__tests__/server-lifecycle.test.js.map +1 -1
  84. package/dist/mcp/__tests__/state-server.test.js +177 -0
  85. package/dist/mcp/__tests__/state-server.test.js.map +1 -1
  86. package/dist/mcp/bootstrap.d.ts.map +1 -1
  87. package/dist/mcp/bootstrap.js +36 -18
  88. package/dist/mcp/bootstrap.js.map +1 -1
  89. package/dist/mcp/state-server.d.ts +17 -0
  90. package/dist/mcp/state-server.d.ts.map +1 -1
  91. package/dist/mcp/state-server.js +55 -1
  92. package/dist/mcp/state-server.js.map +1 -1
  93. package/dist/notifications/__tests__/index.test.js +0 -3
  94. package/dist/notifications/__tests__/index.test.js.map +1 -1
  95. package/dist/notifications/__tests__/session-status.test.js +90 -0
  96. package/dist/notifications/__tests__/session-status.test.js.map +1 -1
  97. package/dist/notifications/session-status.d.ts +2 -0
  98. package/dist/notifications/session-status.d.ts.map +1 -1
  99. package/dist/notifications/session-status.js +19 -4
  100. package/dist/notifications/session-status.js.map +1 -1
  101. package/dist/question/__tests__/deep-interview.test.js +44 -0
  102. package/dist/question/__tests__/deep-interview.test.js.map +1 -1
  103. package/dist/question/__tests__/renderer.test.js +192 -12
  104. package/dist/question/__tests__/renderer.test.js.map +1 -1
  105. package/dist/question/__tests__/state.test.js +21 -1
  106. package/dist/question/__tests__/state.test.js.map +1 -1
  107. package/dist/question/deep-interview.d.ts +3 -0
  108. package/dist/question/deep-interview.d.ts.map +1 -1
  109. package/dist/question/deep-interview.js +18 -1
  110. package/dist/question/deep-interview.js.map +1 -1
  111. package/dist/question/renderer.d.ts +4 -2
  112. package/dist/question/renderer.d.ts.map +1 -1
  113. package/dist/question/renderer.js +87 -18
  114. package/dist/question/renderer.js.map +1 -1
  115. package/dist/runtime/__tests__/run-outcome.test.js +38 -0
  116. package/dist/runtime/__tests__/run-outcome.test.js.map +1 -1
  117. package/dist/runtime/__tests__/run-state.test.d.ts +2 -0
  118. package/dist/runtime/__tests__/run-state.test.d.ts.map +1 -0
  119. package/dist/runtime/__tests__/run-state.test.js +37 -0
  120. package/dist/runtime/__tests__/run-state.test.js.map +1 -0
  121. package/dist/runtime/run-loop.d.ts +5 -1
  122. package/dist/runtime/run-loop.d.ts.map +1 -1
  123. package/dist/runtime/run-loop.js +8 -3
  124. package/dist/runtime/run-loop.js.map +1 -1
  125. package/dist/runtime/run-outcome.d.ts +18 -0
  126. package/dist/runtime/run-outcome.d.ts.map +1 -1
  127. package/dist/runtime/run-outcome.js +156 -7
  128. package/dist/runtime/run-outcome.js.map +1 -1
  129. package/dist/runtime/run-state.d.ts +5 -1
  130. package/dist/runtime/run-state.d.ts.map +1 -1
  131. package/dist/runtime/run-state.js +13 -3
  132. package/dist/runtime/run-state.js.map +1 -1
  133. package/dist/runtime/terminal-lifecycle.d.ts +11 -0
  134. package/dist/runtime/terminal-lifecycle.d.ts.map +1 -0
  135. package/dist/runtime/terminal-lifecycle.js +52 -0
  136. package/dist/runtime/terminal-lifecycle.js.map +1 -0
  137. package/dist/scripts/__tests__/codex-native-hook.test.js +370 -56
  138. package/dist/scripts/__tests__/codex-native-hook.test.js.map +1 -1
  139. package/dist/scripts/__tests__/postinstall.test.d.ts +2 -0
  140. package/dist/scripts/__tests__/postinstall.test.d.ts.map +1 -0
  141. package/dist/scripts/__tests__/postinstall.test.js +178 -0
  142. package/dist/scripts/__tests__/postinstall.test.js.map +1 -0
  143. package/dist/scripts/codex-native-hook.d.ts +1 -0
  144. package/dist/scripts/codex-native-hook.d.ts.map +1 -1
  145. package/dist/scripts/codex-native-hook.js +115 -56
  146. package/dist/scripts/codex-native-hook.js.map +1 -1
  147. package/dist/scripts/postinstall.d.ts +22 -0
  148. package/dist/scripts/postinstall.d.ts.map +1 -0
  149. package/dist/scripts/postinstall.js +105 -0
  150. package/dist/scripts/postinstall.js.map +1 -0
  151. package/dist/state/__tests__/operations.test.js +60 -0
  152. package/dist/state/__tests__/operations.test.js.map +1 -1
  153. package/dist/state/operations.d.ts.map +1 -1
  154. package/dist/state/operations.js +18 -1
  155. package/dist/state/operations.js.map +1 -1
  156. package/dist/team/__tests__/role-router.test.js +6 -0
  157. package/dist/team/__tests__/role-router.test.js.map +1 -1
  158. package/dist/team/__tests__/runtime.test.js +108 -2
  159. package/dist/team/__tests__/runtime.test.js.map +1 -1
  160. package/dist/team/runtime.d.ts.map +1 -1
  161. package/dist/team/runtime.js +18 -4
  162. package/dist/team/runtime.js.map +1 -1
  163. package/dist/utils/__tests__/dep-versions.test.js +25 -8
  164. package/dist/utils/__tests__/dep-versions.test.js.map +1 -1
  165. package/dist/utils/__tests__/paths.test.js +45 -0
  166. package/dist/utils/__tests__/paths.test.js.map +1 -1
  167. package/dist/utils/paths.d.ts +2 -0
  168. package/dist/utils/paths.d.ts.map +1 -1
  169. package/dist/utils/paths.js +22 -7
  170. package/dist/utils/paths.js.map +1 -1
  171. package/dist/verification/__tests__/ci-rust-gates.test.js +1 -1
  172. package/dist/verification/__tests__/ci-rust-gates.test.js.map +1 -1
  173. package/package.json +3 -2
  174. package/prompts/architect.md +4 -0
  175. package/prompts/code-reviewer.md +3 -0
  176. package/skills/code-review/SKILL.md +94 -28
  177. package/skills/deep-interview/SKILL.md +91 -0
  178. package/src/scripts/__tests__/codex-native-hook.test.ts +468 -64
  179. package/src/scripts/__tests__/postinstall.test.ts +210 -0
  180. package/src/scripts/codex-native-hook.ts +136 -53
  181. package/src/scripts/postinstall-bootstrap.js +23 -0
  182. package/src/scripts/postinstall.ts +161 -0
  183. package/templates/AGENTS.md +1 -1
  184. package/templates/model-instructions/explore-lightweight-AGENTS.md +11 -0
  185. package/templates/model-instructions/sparkshell-lightweight-AGENTS.md +10 -0
@@ -4,6 +4,7 @@ import { existsSync } from "node:fs";
4
4
  import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
5
5
  import { tmpdir } from "node:os";
6
6
  import { dirname, join } from "node:path";
7
+ import { pathToFileURL } from "node:url";
7
8
  import { afterEach, beforeEach, describe, it } from "node:test";
8
9
  import { buildManagedCodexHooksConfig } from "../../config/codex-hooks.js";
9
10
  import {
@@ -14,6 +15,7 @@ import {
14
15
  } from "../../team/state.js";
15
16
  import {
16
17
  dispatchCodexNativeHook,
18
+ isCodexNativeHookMainModule,
17
19
  mapCodexHookEventToOmxEvent,
18
20
  resolveSessionOwnerPidFromAncestry,
19
21
  } from "../codex-native-hook.js";
@@ -25,6 +27,30 @@ async function writeJson(path: string, value: unknown): Promise<void> {
25
27
  await writeFile(path, JSON.stringify(value, null, 2));
26
28
  }
27
29
 
30
+ async function writeHookCounterPlugin(cwd: string): Promise<string> {
31
+ const markerPath = join(cwd, ".omx", "stop-hook-counter.json");
32
+ await mkdir(join(cwd, ".omx", "hooks"), { recursive: true });
33
+ await writeFile(
34
+ join(cwd, ".omx", "hooks", "count-stop-hook.mjs"),
35
+ `import { mkdir, readFile, writeFile } from "node:fs/promises";
36
+ import { dirname, join } from "node:path";
37
+
38
+ export async function onHookEvent(event) {
39
+ if (event.event !== "stop") return;
40
+ const outPath = join(process.cwd(), ".omx", "stop-hook-counter.json");
41
+ await mkdir(dirname(outPath), { recursive: true });
42
+ let count = 0;
43
+ try {
44
+ count = JSON.parse(await readFile(outPath, "utf-8")).count || 0;
45
+ } catch {}
46
+ await writeFile(outPath, JSON.stringify({ count: count + 1 }, null, 2));
47
+ }
48
+ `,
49
+ "utf-8",
50
+ );
51
+ return markerPath;
52
+ }
53
+
28
54
  async function writeReleaseReadinessLeaderAttention(
29
55
  teamName: string,
30
56
  sessionId: string,
@@ -143,6 +169,25 @@ describe("codex native hook config", () => {
143
169
  });
144
170
 
145
171
  describe("codex native hook dispatch", () => {
172
+ it("treats space-containing argv entry paths as the main module", () => {
173
+ const entryPath = "/tmp/omx native/codex-native-hook.js";
174
+
175
+ assert.equal(
176
+ isCodexNativeHookMainModule(pathToFileURL(entryPath).href, entryPath),
177
+ true,
178
+ );
179
+ });
180
+
181
+ it("does not treat a different module url as the main module", () => {
182
+ assert.equal(
183
+ isCodexNativeHookMainModule(
184
+ pathToFileURL("/tmp/omx native/other-script.js").href,
185
+ "/tmp/omx native/codex-native-hook.js",
186
+ ),
187
+ false,
188
+ );
189
+ });
190
+
146
191
  it("emits deterministic JSON stdout when CLI stdin is malformed", () => {
147
192
  const stdout = execFileSync(
148
193
  process.execPath,
@@ -302,7 +347,7 @@ describe("codex native hook dispatch", () => {
302
347
  }
303
348
  });
304
349
 
305
- it("appends .omx/ to repo-root .gitignore during SessionStart when missing", async () => {
350
+ it("adds .omx/ to git info/exclude during SessionStart instead of mutating repo .gitignore", async () => {
306
351
  const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-session-gitignore-"));
307
352
  try {
308
353
  await writeFile(join(cwd, ".gitignore"), "node_modules/\n");
@@ -319,11 +364,68 @@ describe("codex native hook dispatch", () => {
319
364
 
320
365
  assert.equal(result.omxEventName, "session-start");
321
366
  const gitignore = await readFile(join(cwd, ".gitignore"), "utf-8");
322
- assert.match(gitignore, /^node_modules\/\n\.omx\/\n$/);
367
+ assert.equal(gitignore, "node_modules/\n");
368
+ const exclude = await readFile(join(cwd, ".git", "info", "exclude"), "utf-8");
369
+ assert.match(exclude, /(?:^|\n)\.omx\/\n/);
323
370
  assert.match(
324
371
  JSON.stringify(result.outputJson),
325
- /Added \.omx\/ to .*\.gitignore/,
372
+ /Added \.omx\/ to .*\.git[\/]info[\/]exclude/,
373
+ );
374
+ } finally {
375
+ await rm(cwd, { recursive: true, force: true });
376
+ }
377
+ });
378
+
379
+ it("keeps SessionStart quiet when .omx/ is already ignored by repo-level gitignore", async () => {
380
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-session-existing-ignore-"));
381
+ try {
382
+ await writeFile(join(cwd, ".gitignore"), "node_modules/\n.omx/\n");
383
+ execFileSync("git", ["init"], { cwd, stdio: "pipe" });
384
+
385
+ const result = await dispatchCodexNativeHook(
386
+ {
387
+ hook_event_name: "SessionStart",
388
+ cwd,
389
+ session_id: "sess-gitignore-existing",
390
+ },
391
+ { cwd, sessionOwnerPid: 43210 },
392
+ );
393
+
394
+ assert.equal(result.omxEventName, "session-start");
395
+ const gitignore = await readFile(join(cwd, ".gitignore"), "utf-8");
396
+ assert.equal(gitignore, "node_modules/\n.omx/\n");
397
+ const exclude = await readFile(join(cwd, ".git", "info", "exclude"), "utf-8");
398
+ assert.doesNotMatch(exclude, /(?:^|\n)\.omx\/\n/);
399
+ assert.doesNotMatch(JSON.stringify(result.outputJson), /Added \.omx\//);
400
+ } finally {
401
+ await rm(cwd, { recursive: true, force: true });
402
+ }
403
+ });
404
+
405
+ it("respects existing Git ignore resolution before writing local excludes", async () => {
406
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-session-global-ignore-"));
407
+ const excludesFile = join(cwd, "global-ignore");
408
+ try {
409
+ await writeFile(join(cwd, ".gitignore"), "node_modules/\n");
410
+ await writeFile(excludesFile, ".omx/\n");
411
+ execFileSync("git", ["init"], { cwd, stdio: "pipe" });
412
+ execFileSync("git", ["config", "core.excludesfile", excludesFile], { cwd, stdio: "pipe" });
413
+
414
+ const result = await dispatchCodexNativeHook(
415
+ {
416
+ hook_event_name: "SessionStart",
417
+ cwd,
418
+ session_id: "sess-gitignore-global",
419
+ },
420
+ { cwd, sessionOwnerPid: 43210 },
326
421
  );
422
+
423
+ assert.equal(result.omxEventName, "session-start");
424
+ const gitignore = await readFile(join(cwd, ".gitignore"), "utf-8");
425
+ assert.equal(gitignore, "node_modules/\n");
426
+ const exclude = await readFile(join(cwd, ".git", "info", "exclude"), "utf-8");
427
+ assert.doesNotMatch(exclude, /(?:^|\n)\.omx\/\n/);
428
+ assert.doesNotMatch(JSON.stringify(result.outputJson), /Added \.omx\//);
327
429
  } finally {
328
430
  await rm(cwd, { recursive: true, force: true });
329
431
  }
@@ -504,6 +606,35 @@ describe("codex native hook dispatch", () => {
504
606
  }
505
607
  });
506
608
 
609
+ it("normalizes the Korean keyboard typo for ulw during UserPromptSubmit activation", async () => {
610
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-ulw-ko-"));
611
+ try {
612
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
613
+ const result = await dispatchCodexNativeHook(
614
+ {
615
+ hook_event_name: "UserPromptSubmit",
616
+ cwd,
617
+ session_id: "sess-ulw-ko",
618
+ thread_id: "thread-ulw-ko",
619
+ turn_id: "turn-ulw-ko",
620
+ prompt: "ㅕㅣㅈ로 병렬 처리해줘",
621
+ },
622
+ { cwd },
623
+ );
624
+
625
+ assert.equal(result.omxEventName, "keyword-detector");
626
+ assert.equal(result.skillState?.skill, "ultrawork");
627
+ assert.equal(result.skillState?.keyword, "ulw");
628
+ const additionalContext = String(
629
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext || "",
630
+ );
631
+ assert.match(additionalContext, /workflow keyword \"ulw\" -> ultrawork/);
632
+ assert.equal(existsSync(join(cwd, ".omx", "state", "sessions", "sess-ulw-ko", "ultrawork-state.json")), true);
633
+ } finally {
634
+ await rm(cwd, { recursive: true, force: true });
635
+ }
636
+ });
637
+
507
638
  it("does not activate Ralph workflow state from a plain conversational mention", async () => {
508
639
  const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-ralph-plain-text-"));
509
640
  try {
@@ -707,6 +838,9 @@ describe("codex native hook dispatch", () => {
707
838
  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
839
  assert.match(message, /Deep-interview must ask each interview round via `omx question`/);
709
840
  assert.match(message, /do not fall back to `request_user_input` or plain-text questioning/i);
841
+ assert.match(message, /After starting `omx question` in a background terminal, wait for that terminal to finish and read the JSON answer before continuing the interview\./);
842
+ assert.match(message, /If bare `omx question` is unavailable in this reused session, use the current-session CLI bridge command:/);
843
+ assert.match(message, /`'.+' '.+dist\/cli\/omx\.js' question`/);
710
844
  assert.match(message, /Stop remains blocked while a deep-interview question obligation is pending\./);
711
845
  } finally {
712
846
  await rm(cwd, { recursive: true, force: true });
@@ -3160,6 +3294,62 @@ esac
3160
3294
  }
3161
3295
  });
3162
3296
 
3297
+ it("blocks Stop when a same-session deep-interview question obligation is pending even after the mode marked itself inactive", async () => {
3298
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-deep-interview-question-inactive-"));
3299
+ try {
3300
+ const stateDir = join(cwd, ".omx", "state");
3301
+ await mkdir(join(stateDir, "sessions", "sess-stop-deep-interview-question-inactive"), { recursive: true });
3302
+ await writeJson(join(stateDir, "session.json"), { session_id: "sess-stop-deep-interview-question-inactive" });
3303
+ await writeJson(join(stateDir, "sessions", "sess-stop-deep-interview-question-inactive", "skill-active-state.json"), {
3304
+ version: 1,
3305
+ active: true,
3306
+ skill: "deep-interview",
3307
+ phase: "planning",
3308
+ session_id: "sess-stop-deep-interview-question-inactive",
3309
+ thread_id: "thread-stop-deep-interview-question-inactive",
3310
+ });
3311
+ await writeJson(join(stateDir, "sessions", "sess-stop-deep-interview-question-inactive", "deep-interview-state.json"), {
3312
+ active: false,
3313
+ mode: "deep-interview",
3314
+ current_phase: "intent-first",
3315
+ lifecycle_outcome: "askuserQuestion",
3316
+ run_outcome: "blocked_on_user",
3317
+ completed_at: "2026-04-19T03:20:30.000Z",
3318
+ session_id: "sess-stop-deep-interview-question-inactive",
3319
+ thread_id: "thread-stop-deep-interview-question-inactive",
3320
+ question_enforcement: {
3321
+ obligation_id: "obligation-inactive",
3322
+ source: "omx-question",
3323
+ status: "pending",
3324
+ lifecycle_outcome: "askuserQuestion",
3325
+ requested_at: "2026-04-19T03:20:00.000Z",
3326
+ },
3327
+ });
3328
+
3329
+ const result = await dispatchCodexNativeHook(
3330
+ {
3331
+ hook_event_name: "Stop",
3332
+ cwd,
3333
+ session_id: "sess-stop-deep-interview-question-inactive",
3334
+ thread_id: "thread-stop-deep-interview-question-inactive",
3335
+ },
3336
+ { cwd },
3337
+ );
3338
+
3339
+ assert.equal(result.omxEventName, "stop");
3340
+ assert.deepEqual(result.outputJson, {
3341
+ decision: "block",
3342
+ reason:
3343
+ "Deep interview is still active (phase: intent-first) and has a pending structured question obligation; use `omx question` before stopping.",
3344
+ stopReason: "deep_interview_question_required",
3345
+ systemMessage:
3346
+ "OMX deep-interview is still active (phase: intent-first) and requires a structured question via omx question before stopping.",
3347
+ });
3348
+ } finally {
3349
+ await rm(cwd, { recursive: true, force: true });
3350
+ }
3351
+ });
3352
+
3163
3353
  it("keeps blocking pending deep-interview question Stop replays until the obligation changes", async () => {
3164
3354
  const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-deep-interview-question-replay-"));
3165
3355
  try {
@@ -3477,6 +3667,55 @@ esac
3477
3667
  }
3478
3668
  });
3479
3669
 
3670
+ it("does not block Stop from stale current-session Ralph state when session.json points to a dead owner", async () => {
3671
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-stale-current-session-ralph-"));
3672
+ try {
3673
+ const stateDir = join(cwd, ".omx", "state");
3674
+ await mkdir(join(stateDir, "sessions", "sess-dead"), { recursive: true });
3675
+ await writeJson(join(stateDir, "session.json"), {
3676
+ session_id: "sess-dead",
3677
+ cwd,
3678
+ pid: Number.MAX_SAFE_INTEGER,
3679
+ started_at: "2026-01-01T00:00:00.000Z",
3680
+ });
3681
+ await writeJson(join(stateDir, "sessions", "sess-dead", "ralph-state.json"), {
3682
+ active: true,
3683
+ current_phase: "verifying",
3684
+ session_id: "sess-dead",
3685
+ });
3686
+ await writeJson(join(stateDir, "skill-active-state.json"), {
3687
+ active: true,
3688
+ skill: "team",
3689
+ phase: "team-exec",
3690
+ active_skills: [{ skill: "team", phase: "team-exec", active: true, session_id: "sess-dead" }],
3691
+ });
3692
+ await writeJson(join(stateDir, "native-stop-state.json"), {
3693
+ sessions: {
3694
+ "sess-dead": {
3695
+ last_signature: "ralph-stop|sess-dead|thread-1|no-message|verifying",
3696
+ updated_at: "2026-04-20T21:00:00.000Z",
3697
+ },
3698
+ },
3699
+ });
3700
+
3701
+ const result = await dispatchCodexNativeHook(
3702
+ {
3703
+ hook_event_name: "Stop",
3704
+ cwd,
3705
+ session_id: "sess-dead",
3706
+ thread_id: "thread-1",
3707
+ stop_hook_active: true,
3708
+ },
3709
+ { cwd },
3710
+ );
3711
+
3712
+ assert.equal(result.omxEventName, "stop");
3713
+ assert.equal(result.outputJson, null);
3714
+ } finally {
3715
+ await rm(cwd, { recursive: true, force: true });
3716
+ }
3717
+ });
3718
+
3480
3719
  it("does not block Stop from another session-scoped Ralph state when an explicit session_id has no active Ralph state", async () => {
3481
3720
  const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-explicit-session-ralph-"));
3482
3721
  try {
@@ -3643,6 +3882,117 @@ esac
3643
3882
  }
3644
3883
  });
3645
3884
 
3885
+ it("lets dispatcher dedupe identical native stop hook replays after Stop payload normalization", async () => {
3886
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-ralph-hook-dedupe-"));
3887
+ const previousOmxSessionId = process.env.OMX_SESSION_ID;
3888
+ try {
3889
+ const stateDir = join(cwd, ".omx", "state");
3890
+ await mkdir(join(stateDir, "sessions", "sess-stop-ralph-hook-dedupe"), { recursive: true });
3891
+ await writeHookCounterPlugin(cwd);
3892
+ await writeFile(
3893
+ join(stateDir, "sessions", "sess-stop-ralph-hook-dedupe", "ralph-state.json"),
3894
+ JSON.stringify({
3895
+ active: true,
3896
+ current_phase: "executing",
3897
+ session_id: "sess-stop-ralph-hook-dedupe",
3898
+ }),
3899
+ );
3900
+
3901
+ process.env.OMX_SESSION_ID = "sess-stop-ralph-hook-dedupe";
3902
+ const payload = {
3903
+ hook_event_name: "Stop",
3904
+ cwd,
3905
+ session_id: "sess-stop-ralph-hook-dedupe",
3906
+ thread_id: "thread-stop-ralph-hook-dedupe",
3907
+ turn_id: "turn-stop-ralph-hook-dedupe-1",
3908
+ last_assistant_message: "Next active targets:\n\n1. scheduler integration\n\nI am continuing.",
3909
+ };
3910
+
3911
+ await dispatchCodexNativeHook(payload, { cwd });
3912
+ await dispatchCodexNativeHook(
3913
+ {
3914
+ ...payload,
3915
+ stop_hook_active: true,
3916
+ },
3917
+ { cwd },
3918
+ );
3919
+
3920
+ const marker = JSON.parse(
3921
+ await readFile(join(cwd, ".omx", "stop-hook-counter.json"), "utf-8"),
3922
+ ) as { count: number };
3923
+ assert.equal(marker.count, 1);
3924
+ } finally {
3925
+ if (typeof previousOmxSessionId === "string") process.env.OMX_SESSION_ID = previousOmxSessionId;
3926
+ else delete process.env.OMX_SESSION_ID;
3927
+ await rm(cwd, { recursive: true, force: true });
3928
+ }
3929
+ });
3930
+
3931
+ it("preserves per-turn native stop hook delivery even when stop_hook_active remains true", async () => {
3932
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-ralph-hook-refire-"));
3933
+ const previousOmxSessionId = process.env.OMX_SESSION_ID;
3934
+ try {
3935
+ const stateDir = join(cwd, ".omx", "state");
3936
+ await mkdir(join(stateDir, "sessions", "sess-stop-ralph-hook-refire"), { recursive: true });
3937
+ await writeHookCounterPlugin(cwd);
3938
+ await writeFile(
3939
+ join(stateDir, "sessions", "sess-stop-ralph-hook-refire", "ralph-state.json"),
3940
+ JSON.stringify({
3941
+ active: true,
3942
+ current_phase: "executing",
3943
+ session_id: "sess-stop-ralph-hook-refire",
3944
+ }),
3945
+ );
3946
+
3947
+ process.env.OMX_SESSION_ID = "sess-stop-ralph-hook-refire";
3948
+ const payload = {
3949
+ hook_event_name: "Stop",
3950
+ cwd,
3951
+ session_id: "sess-stop-ralph-hook-refire",
3952
+ thread_id: "thread-stop-ralph-hook-refire",
3953
+ turn_id: "turn-stop-ralph-hook-refire-1",
3954
+ last_assistant_message: "Continuing current task.",
3955
+ };
3956
+
3957
+ await dispatchCodexNativeHook(payload, { cwd });
3958
+ await dispatchCodexNativeHook(
3959
+ {
3960
+ ...payload,
3961
+ turn_id: "turn-stop-ralph-hook-refire-2",
3962
+ stop_hook_active: true,
3963
+ },
3964
+ { cwd },
3965
+ );
3966
+
3967
+ await writeFile(
3968
+ join(stateDir, "sessions", "sess-stop-ralph-hook-refire", "ralph-state.json"),
3969
+ JSON.stringify({
3970
+ active: true,
3971
+ current_phase: "executing",
3972
+ session_id: "sess-stop-ralph-hook-refire",
3973
+ }),
3974
+ );
3975
+
3976
+ await dispatchCodexNativeHook(
3977
+ {
3978
+ ...payload,
3979
+ turn_id: "turn-stop-ralph-hook-refire-3",
3980
+ stop_hook_active: true,
3981
+ },
3982
+ { cwd },
3983
+ );
3984
+
3985
+ const marker = JSON.parse(
3986
+ await readFile(join(cwd, ".omx", "stop-hook-counter.json"), "utf-8"),
3987
+ ) as { count: number };
3988
+ assert.equal(marker.count, 3);
3989
+ } finally {
3990
+ if (typeof previousOmxSessionId === "string") process.env.OMX_SESSION_ID = previousOmxSessionId;
3991
+ else delete process.env.OMX_SESSION_ID;
3992
+ await rm(cwd, { recursive: true, force: true });
3993
+ }
3994
+ });
3995
+
3646
3996
 
3647
3997
  it("returns Stop continuation output for native auto-nudge stall prompts", async () => {
3648
3998
  const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-auto-nudge-"));
@@ -3773,6 +4123,69 @@ esac
3773
4123
  }
3774
4124
  });
3775
4125
 
4126
+ it("dedupes native stop hook replay across owner launch SessionStart reconciliation drift", async () => {
4127
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-dispatch-session-drift-"));
4128
+ try {
4129
+ const stateDir = join(cwd, ".omx", "state");
4130
+ await mkdir(join(stateDir, "sessions", "omx-canonical"), { recursive: true });
4131
+ await writeHookCounterPlugin(cwd);
4132
+ process.env.OMX_SESSION_ID = "omx-canonical";
4133
+ await writeSessionStart(cwd, "omx-canonical");
4134
+ await writeJson(join(stateDir, "sessions", "omx-canonical", "ralph-state.json"), {
4135
+ active: true,
4136
+ current_phase: "executing",
4137
+ session_id: "omx-canonical",
4138
+ });
4139
+
4140
+ await dispatchCodexNativeHook(
4141
+ {
4142
+ hook_event_name: "SessionStart",
4143
+ cwd,
4144
+ session_id: "codex-native-new",
4145
+ },
4146
+ { cwd, sessionOwnerPid: process.pid },
4147
+ );
4148
+
4149
+ await dispatchCodexNativeHook(
4150
+ {
4151
+ hook_event_name: "Stop",
4152
+ cwd,
4153
+ session_id: "codex-native-new",
4154
+ thread_id: "thread-stop-hook-drift",
4155
+ turn_id: "turn-stop-hook-drift-1",
4156
+ last_assistant_message: "Keep going and finish the cleanup.",
4157
+ },
4158
+ { cwd },
4159
+ );
4160
+
4161
+ await dispatchCodexNativeHook(
4162
+ {
4163
+ hook_event_name: "Stop",
4164
+ cwd,
4165
+ session_id: "omx-canonical",
4166
+ thread_id: "thread-stop-hook-drift",
4167
+ turn_id: "turn-stop-hook-drift-1",
4168
+ stop_hook_active: true,
4169
+ last_assistant_message: "Keep going and finish the cleanup.",
4170
+ },
4171
+ { cwd },
4172
+ );
4173
+
4174
+ const marker = JSON.parse(
4175
+ await readFile(join(cwd, ".omx", "stop-hook-counter.json"), "utf-8"),
4176
+ ) as { count: number };
4177
+ assert.equal(marker.count, 1);
4178
+
4179
+ const sessionState = JSON.parse(
4180
+ await readFile(join(stateDir, "session.json"), "utf-8"),
4181
+ ) as { session_id?: string; native_session_id?: string };
4182
+ assert.equal(sessionState.session_id, "omx-canonical");
4183
+ assert.equal(sessionState.native_session_id, "codex-native-new");
4184
+ } finally {
4185
+ await rm(cwd, { recursive: true, force: true });
4186
+ }
4187
+ });
4188
+
3776
4189
  it("re-fires native auto-nudge for a later fresh Stop reply even when stop_hook_active is true", async () => {
3777
4190
  const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-auto-nudge-refire-"));
3778
4191
  try {
@@ -4329,71 +4742,62 @@ esac
4329
4742
  }
4330
4743
  });
4331
4744
 
4332
- it("re-blocks active execution modes on repeated Stop hooks", async () => {
4333
- const cases = [
4334
- {
4335
- mode: "autopilot",
4336
- phase: "execution",
4337
- reason:
4338
- "OMX autopilot is still active (phase: execution); continue the task and gather fresh verification evidence before stopping.",
4339
- },
4340
- {
4341
- mode: "ultrawork",
4342
- phase: "executing",
4343
- reason:
4344
- "OMX ultrawork is still active (phase: executing); continue the task and gather fresh verification evidence before stopping.",
4345
- },
4346
- {
4347
- mode: "ultraqa",
4348
- phase: "diagnose",
4349
- reason:
4350
- "OMX ultraqa is still active (phase: diagnose); continue the task and gather fresh verification evidence before stopping.",
4351
- },
4352
- ] as const;
4745
+ it("suppresses duplicate ultrawork Stop replays while stop_hook_active stays true", async () => {
4746
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-ultrawork-repeat-"));
4747
+ try {
4748
+ const stateDir = join(cwd, ".omx", "state");
4749
+ await mkdir(stateDir, { recursive: true });
4750
+ await writeJson(join(stateDir, "ultrawork-state.json"), {
4751
+ active: true,
4752
+ current_phase: "executing",
4753
+ });
4353
4754
 
4354
- for (const testCase of cases) {
4355
- const cwd = await mkdtemp(join(tmpdir(), `omx-native-hook-stop-${testCase.mode}-repeat-`));
4356
- try {
4357
- const stateDir = join(cwd, ".omx", "state");
4358
- await mkdir(stateDir, { recursive: true });
4359
- await writeJson(join(stateDir, `${testCase.mode}-state.json`), {
4360
- active: true,
4361
- current_phase: testCase.phase,
4362
- });
4755
+ const first = await dispatchCodexNativeHook(
4756
+ {
4757
+ hook_event_name: "Stop",
4758
+ cwd,
4759
+ session_id: "sess-stop-ultrawork-repeat",
4760
+ thread_id: "thread-stop-ultrawork-repeat",
4761
+ turn_id: "turn-stop-ultrawork-repeat-1",
4762
+ },
4763
+ { cwd },
4764
+ );
4363
4765
 
4364
- await dispatchCodexNativeHook(
4365
- {
4366
- hook_event_name: "Stop",
4367
- cwd,
4368
- session_id: `sess-stop-${testCase.mode}-repeat`,
4369
- thread_id: `thread-stop-${testCase.mode}-repeat`,
4370
- turn_id: `turn-stop-${testCase.mode}-repeat-1`,
4371
- },
4372
- { cwd },
4373
- );
4766
+ const repeated = await dispatchCodexNativeHook(
4767
+ {
4768
+ hook_event_name: "Stop",
4769
+ cwd,
4770
+ session_id: "sess-stop-ultrawork-repeat",
4771
+ thread_id: "thread-stop-ultrawork-repeat",
4772
+ turn_id: "turn-stop-ultrawork-repeat-1",
4773
+ stop_hook_active: true,
4774
+ },
4775
+ { cwd },
4776
+ );
4374
4777
 
4375
- const repeated = await dispatchCodexNativeHook(
4376
- {
4377
- hook_event_name: "Stop",
4378
- cwd,
4379
- session_id: `sess-stop-${testCase.mode}-repeat`,
4380
- thread_id: `thread-stop-${testCase.mode}-repeat`,
4381
- turn_id: `turn-stop-${testCase.mode}-repeat-1`,
4382
- stop_hook_active: true,
4383
- },
4384
- { cwd },
4385
- );
4778
+ const fresh = await dispatchCodexNativeHook(
4779
+ {
4780
+ hook_event_name: "Stop",
4781
+ cwd,
4782
+ session_id: "sess-stop-ultrawork-repeat",
4783
+ thread_id: "thread-stop-ultrawork-repeat",
4784
+ turn_id: "turn-stop-ultrawork-repeat-2",
4785
+ stop_hook_active: true,
4786
+ },
4787
+ { cwd },
4788
+ );
4386
4789
 
4387
- assert.equal(repeated.omxEventName, "stop");
4388
- assert.deepEqual(repeated.outputJson, {
4389
- decision: "block",
4390
- reason: testCase.reason,
4391
- stopReason: `${testCase.mode}_${testCase.phase}`,
4392
- systemMessage: `OMX ${testCase.mode} is still active (phase: ${testCase.phase}).`,
4393
- });
4394
- } finally {
4395
- await rm(cwd, { recursive: true, force: true });
4396
- }
4790
+ assert.equal(first.omxEventName, "stop");
4791
+ assert.deepEqual(repeated.outputJson, null);
4792
+ assert.equal(fresh.omxEventName, "stop");
4793
+ assert.deepEqual(fresh.outputJson, {
4794
+ decision: "block",
4795
+ reason: "OMX ultrawork is still active (phase: executing); continue the task and gather fresh verification evidence before stopping.",
4796
+ stopReason: "ultrawork_executing",
4797
+ systemMessage: "OMX ultrawork is still active (phase: executing).",
4798
+ });
4799
+ } finally {
4800
+ await rm(cwd, { recursive: true, force: true });
4397
4801
  }
4398
4802
  });
4399
4803