oh-my-codex 0.13.0 → 0.13.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 (141) hide show
  1. package/Cargo.lock +5 -5
  2. package/Cargo.toml +1 -1
  3. package/README.md +40 -6
  4. package/crates/omx-explore/src/main.rs +221 -10
  5. package/dist/catalog/__tests__/generator.test.js +2 -0
  6. package/dist/catalog/__tests__/generator.test.js.map +1 -1
  7. package/dist/cli/__tests__/index.test.js +150 -1
  8. package/dist/cli/__tests__/index.test.js.map +1 -1
  9. package/dist/cli/__tests__/setup-skills-overwrite.test.js +41 -3
  10. package/dist/cli/__tests__/setup-skills-overwrite.test.js.map +1 -1
  11. package/dist/cli/__tests__/update.test.js +25 -1
  12. package/dist/cli/__tests__/update.test.js.map +1 -1
  13. package/dist/cli/index.d.ts +1 -0
  14. package/dist/cli/index.d.ts.map +1 -1
  15. package/dist/cli/index.js +73 -9
  16. package/dist/cli/index.js.map +1 -1
  17. package/dist/cli/setup.d.ts.map +1 -1
  18. package/dist/cli/setup.js +15 -0
  19. package/dist/cli/setup.js.map +1 -1
  20. package/dist/cli/update.js +1 -1
  21. package/dist/cli/update.js.map +1 -1
  22. package/dist/hooks/__tests__/agents-overlay.test.js +20 -2
  23. package/dist/hooks/__tests__/agents-overlay.test.js.map +1 -1
  24. package/dist/hooks/__tests__/analyze-routing-contract.test.d.ts +2 -0
  25. package/dist/hooks/__tests__/analyze-routing-contract.test.d.ts.map +1 -0
  26. package/dist/hooks/__tests__/analyze-routing-contract.test.js +36 -0
  27. package/dist/hooks/__tests__/analyze-routing-contract.test.js.map +1 -0
  28. package/dist/hooks/__tests__/analyze-skill-contract.test.d.ts +2 -0
  29. package/dist/hooks/__tests__/analyze-skill-contract.test.d.ts.map +1 -0
  30. package/dist/hooks/__tests__/analyze-skill-contract.test.js +48 -0
  31. package/dist/hooks/__tests__/analyze-skill-contract.test.js.map +1 -0
  32. package/dist/hooks/__tests__/keyword-detector.test.js +32 -0
  33. package/dist/hooks/__tests__/keyword-detector.test.js.map +1 -1
  34. package/dist/hooks/__tests__/notify-fallback-watcher.test.js +185 -8
  35. package/dist/hooks/__tests__/notify-fallback-watcher.test.js.map +1 -1
  36. package/dist/hooks/__tests__/notify-hook-session-idle-dedupe.test.js +26 -0
  37. package/dist/hooks/__tests__/notify-hook-session-idle-dedupe.test.js.map +1 -1
  38. package/dist/hooks/__tests__/notify-hook-session-scope.test.js +44 -0
  39. package/dist/hooks/__tests__/notify-hook-session-scope.test.js.map +1 -1
  40. package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js +126 -0
  41. package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js.map +1 -1
  42. package/dist/hooks/__tests__/session.test.js +21 -0
  43. package/dist/hooks/__tests__/session.test.js.map +1 -1
  44. package/dist/hooks/agents-overlay.d.ts.map +1 -1
  45. package/dist/hooks/agents-overlay.js +9 -0
  46. package/dist/hooks/agents-overlay.js.map +1 -1
  47. package/dist/hooks/keyword-detector.d.ts.map +1 -1
  48. package/dist/hooks/keyword-detector.js +8 -1
  49. package/dist/hooks/keyword-detector.js.map +1 -1
  50. package/dist/hooks/session.d.ts.map +1 -1
  51. package/dist/hooks/session.js +9 -0
  52. package/dist/hooks/session.js.map +1 -1
  53. package/dist/hud/__tests__/state.test.js +55 -0
  54. package/dist/hud/__tests__/state.test.js.map +1 -1
  55. package/dist/hud/state.d.ts.map +1 -1
  56. package/dist/hud/state.js +23 -4
  57. package/dist/hud/state.js.map +1 -1
  58. package/dist/mcp/__tests__/bootstrap.test.js +38 -0
  59. package/dist/mcp/__tests__/bootstrap.test.js.map +1 -1
  60. package/dist/mcp/bootstrap.d.ts +1 -1
  61. package/dist/mcp/bootstrap.d.ts.map +1 -1
  62. package/dist/mcp/bootstrap.js +11 -3
  63. package/dist/mcp/bootstrap.js.map +1 -1
  64. package/dist/notifications/__tests__/reply-listener.test.js +34 -1
  65. package/dist/notifications/__tests__/reply-listener.test.js.map +1 -1
  66. package/dist/notifications/reply-listener.d.ts +1 -0
  67. package/dist/notifications/reply-listener.d.ts.map +1 -1
  68. package/dist/notifications/reply-listener.js +14 -2
  69. package/dist/notifications/reply-listener.js.map +1 -1
  70. package/dist/scripts/__tests__/codex-native-hook.test.js +248 -15
  71. package/dist/scripts/__tests__/codex-native-hook.test.js.map +1 -1
  72. package/dist/scripts/__tests__/generate-release-body.test.d.ts +2 -0
  73. package/dist/scripts/__tests__/generate-release-body.test.d.ts.map +1 -0
  74. package/dist/scripts/__tests__/generate-release-body.test.js +144 -0
  75. package/dist/scripts/__tests__/generate-release-body.test.js.map +1 -0
  76. package/dist/scripts/codex-native-hook.d.ts.map +1 -1
  77. package/dist/scripts/codex-native-hook.js +39 -49
  78. package/dist/scripts/codex-native-hook.js.map +1 -1
  79. package/dist/scripts/generate-release-body.d.ts +34 -0
  80. package/dist/scripts/generate-release-body.d.ts.map +1 -0
  81. package/dist/scripts/generate-release-body.js +249 -0
  82. package/dist/scripts/generate-release-body.js.map +1 -0
  83. package/dist/scripts/notify-fallback-watcher.js +43 -20
  84. package/dist/scripts/notify-fallback-watcher.js.map +1 -1
  85. package/dist/scripts/notify-hook/active-team.d.ts.map +1 -1
  86. package/dist/scripts/notify-hook/active-team.js +2 -1
  87. package/dist/scripts/notify-hook/active-team.js.map +1 -1
  88. package/dist/scripts/notify-hook/ralph-session-resume.d.ts.map +1 -1
  89. package/dist/scripts/notify-hook/ralph-session-resume.js +17 -2
  90. package/dist/scripts/notify-hook/ralph-session-resume.js.map +1 -1
  91. package/dist/scripts/notify-hook/state-io.d.ts.map +1 -1
  92. package/dist/scripts/notify-hook/state-io.js +16 -0
  93. package/dist/scripts/notify-hook/state-io.js.map +1 -1
  94. package/dist/scripts/notify-hook/team-leader-nudge.d.ts.map +1 -1
  95. package/dist/scripts/notify-hook/team-leader-nudge.js +26 -5
  96. package/dist/scripts/notify-hook/team-leader-nudge.js.map +1 -1
  97. package/dist/scripts/notify-hook.js +1 -7
  98. package/dist/scripts/notify-hook.js.map +1 -1
  99. package/dist/team/__tests__/model-contract.test.js +6 -0
  100. package/dist/team/__tests__/model-contract.test.js.map +1 -1
  101. package/dist/team/__tests__/tmux-session.test.js +1 -1
  102. package/dist/team/__tests__/tmux-session.test.js.map +1 -1
  103. package/dist/team/__tests__/worker-runtime-identity.test.d.ts +2 -0
  104. package/dist/team/__tests__/worker-runtime-identity.test.d.ts.map +1 -0
  105. package/dist/team/__tests__/worker-runtime-identity.test.js +250 -0
  106. package/dist/team/__tests__/worker-runtime-identity.test.js.map +1 -0
  107. package/dist/team/leader-activity.d.ts.map +1 -1
  108. package/dist/team/leader-activity.js +26 -15
  109. package/dist/team/leader-activity.js.map +1 -1
  110. package/dist/team/model-contract.d.ts.map +1 -1
  111. package/dist/team/model-contract.js.map +1 -1
  112. package/dist/team/runtime.d.ts.map +1 -1
  113. package/dist/team/runtime.js +9 -8
  114. package/dist/team/runtime.js.map +1 -1
  115. package/dist/team/scaling.d.ts.map +1 -1
  116. package/dist/team/scaling.js +10 -9
  117. package/dist/team/scaling.js.map +1 -1
  118. package/dist/team/tmux-session.d.ts.map +1 -1
  119. package/dist/team/tmux-session.js +3 -2
  120. package/dist/team/tmux-session.js.map +1 -1
  121. package/dist/verification/__tests__/explore-harness-release-workflow.test.js +3 -0
  122. package/dist/verification/__tests__/explore-harness-release-workflow.test.js.map +1 -1
  123. package/dist/wiki/__tests__/slug-nonascii.test.js +11 -5
  124. package/dist/wiki/__tests__/slug-nonascii.test.js.map +1 -1
  125. package/dist/wiki/storage.d.ts.map +1 -1
  126. package/dist/wiki/storage.js +2 -1
  127. package/dist/wiki/storage.js.map +1 -1
  128. package/package.json +3 -1
  129. package/skills/analyze/SKILL.md +101 -134
  130. package/src/scripts/__tests__/codex-native-hook.test.ts +297 -17
  131. package/src/scripts/__tests__/generate-release-body.test.ts +166 -0
  132. package/src/scripts/codex-native-hook.ts +99 -66
  133. package/src/scripts/generate-release-body.ts +295 -0
  134. package/src/scripts/notify-fallback-watcher.ts +44 -21
  135. package/src/scripts/notify-hook/active-team.ts +2 -1
  136. package/src/scripts/notify-hook/ralph-session-resume.ts +17 -2
  137. package/src/scripts/notify-hook/state-io.ts +16 -0
  138. package/src/scripts/notify-hook/team-leader-nudge.ts +24 -4
  139. package/src/scripts/notify-hook.ts +1 -6
  140. package/templates/AGENTS.md +1 -1
  141. package/templates/catalog-manifest.json +2 -4
@@ -324,6 +324,89 @@ describe("codex native hook dispatch", () => {
324
324
  }
325
325
  });
326
326
 
327
+ it("starts a fresh native session without inheriting stale task-scoped context", async () => {
328
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-session-isolation-"));
329
+ try {
330
+ const stateDir = join(cwd, ".omx", "state");
331
+ const priorSessionId = "omx-old-session";
332
+ await mkdir(join(stateDir, "sessions", priorSessionId), { recursive: true });
333
+ await writeSessionStart(cwd, priorSessionId, {
334
+ nativeSessionId: "codex-native-old",
335
+ });
336
+ await writeJson(join(stateDir, "sessions", priorSessionId, "ralph-state.json"), {
337
+ active: true,
338
+ current_phase: "executing",
339
+ });
340
+ await writeJson(join(stateDir, "subagent-tracking.json"), {
341
+ schemaVersion: 1,
342
+ sessions: {
343
+ [priorSessionId]: {
344
+ session_id: priorSessionId,
345
+ leader_thread_id: "leader-1",
346
+ updated_at: new Date().toISOString(),
347
+ threads: {
348
+ "leader-1": {
349
+ thread_id: "leader-1",
350
+ kind: "leader",
351
+ first_seen_at: new Date().toISOString(),
352
+ last_seen_at: new Date().toISOString(),
353
+ turn_count: 1,
354
+ },
355
+ "sub-1": {
356
+ thread_id: "sub-1",
357
+ kind: "subagent",
358
+ first_seen_at: new Date().toISOString(),
359
+ last_seen_at: new Date().toISOString(),
360
+ turn_count: 1,
361
+ },
362
+ },
363
+ },
364
+ },
365
+ });
366
+ await writeFile(
367
+ join(cwd, ".omx", "notepad.md"),
368
+ [
369
+ "# OMX Notepad",
370
+ "",
371
+ "## PRIORITY",
372
+ "Preserve durable project guidance.",
373
+ "",
374
+ "## WORKING MEMORY",
375
+ "[2026-04-06T00:33:44Z] stale UI rework context snapshot .omx/context/ui-rework-plan-01-20260406T003344Z.md",
376
+ ].join("\n"),
377
+ );
378
+
379
+ const result = await dispatchCodexNativeHook(
380
+ {
381
+ hook_event_name: "SessionStart",
382
+ cwd,
383
+ session_id: "codex-native-new",
384
+ },
385
+ {
386
+ cwd,
387
+ sessionOwnerPid: process.pid,
388
+ },
389
+ );
390
+
391
+ const sessionState = JSON.parse(
392
+ await readFile(join(stateDir, "session.json"), "utf-8"),
393
+ ) as { session_id?: string; native_session_id?: string };
394
+ assert.equal(sessionState.session_id, "codex-native-new");
395
+ assert.equal(sessionState.native_session_id, "codex-native-new");
396
+
397
+ const additionalContext = String(
398
+ (result.outputJson as { hookSpecificOutput?: { additionalContext?: string } })?.hookSpecificOutput?.additionalContext ?? "",
399
+ );
400
+ assert.match(additionalContext, /\[Priority notes\]/);
401
+ assert.match(additionalContext, /Preserve durable project guidance/);
402
+ assert.doesNotMatch(additionalContext, /stale UI rework context snapshot/);
403
+ assert.doesNotMatch(additionalContext, /\[Subagents\]/);
404
+ assert.doesNotMatch(additionalContext, /ralph phase: executing/);
405
+ } finally {
406
+ await rm(cwd, { recursive: true, force: true });
407
+ }
408
+ });
409
+
327
410
  it("resolves the Codex owner from ancestry without mistaking codex-native-hook wrappers for Codex", () => {
328
411
  const commands = new Map<number, string>([
329
412
  [2100, 'sh -c node "/repo/dist/scripts/codex-native-hook.js"'],
@@ -381,6 +464,33 @@ describe("codex native hook dispatch", () => {
381
464
  }
382
465
  });
383
466
 
467
+ it("does not activate Ralph workflow state from a plain conversational mention", async () => {
468
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-ralph-plain-text-"));
469
+ try {
470
+ await mkdir(join(cwd, ".omx", "state"), { recursive: true });
471
+ const result = await dispatchCodexNativeHook(
472
+ {
473
+ hook_event_name: "UserPromptSubmit",
474
+ cwd,
475
+ session_id: "sess-ralph-plain-text",
476
+ thread_id: "thread-ralph-plain-text",
477
+ turn_id: "turn-ralph-plain-text",
478
+ prompt: "why does ralph keep blocking stop?",
479
+ },
480
+ { cwd },
481
+ );
482
+
483
+ assert.equal(result.omxEventName, "keyword-detector");
484
+ assert.equal(result.skillState, null);
485
+ assert.equal(result.outputJson, null);
486
+ assert.equal(existsSync(join(cwd, ".omx", "state", "skill-active-state.json")), false);
487
+ assert.equal(existsSync(join(cwd, ".omx", "state", "sessions", "sess-ralph-plain-text", "skill-active-state.json")), false);
488
+ assert.equal(existsSync(join(cwd, ".omx", "state", "sessions", "sess-ralph-plain-text", "ralph-state.json")), false);
489
+ } finally {
490
+ await rm(cwd, { recursive: true, force: true });
491
+ }
492
+ });
493
+
384
494
  it("clarifies that prompt-side $ralph activation does not invoke the PRD-gated CLI path", async () => {
385
495
  const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-ralph-routing-"));
386
496
  try {
@@ -2028,7 +2138,13 @@ esac
2028
2138
  { cwd },
2029
2139
  );
2030
2140
 
2031
- assert.equal(result.outputJson, null);
2141
+ assert.deepEqual(result.outputJson, {
2142
+ decision: "block",
2143
+ reason:
2144
+ `OMX team pipeline is still active (worker-stop-team-terminal) at phase team-exec; continue coordinating until the team reaches a terminal phase.${TEAM_STOP_COMMIT_GUIDANCE}`,
2145
+ stopReason: "team_team-exec",
2146
+ systemMessage: "OMX team pipeline is still active at phase team-exec.",
2147
+ });
2032
2148
  } finally {
2033
2149
  if (typeof prevTeamWorker === "string") process.env.OMX_TEAM_WORKER = prevTeamWorker;
2034
2150
  else delete process.env.OMX_TEAM_WORKER;
@@ -2859,8 +2975,9 @@ esac
2859
2975
  }
2860
2976
  });
2861
2977
 
2862
- it("does not re-block Ralph when Stop already continued once", async () => {
2863
- const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-ralph-once-"));
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;
2864
2981
  try {
2865
2982
  const stateDir = join(cwd, ".omx", "state");
2866
2983
  await mkdir(stateDir, { recursive: true });
@@ -2872,23 +2989,42 @@ esac
2872
2989
  }),
2873
2990
  );
2874
2991
 
2875
- const result = await dispatchCodexNativeHook(
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 = {
2999
+ decision: "block",
3000
+ reason:
3001
+ "OMX Ralph is still active (phase: executing); continue the task and gather fresh verification evidence before stopping.",
3002
+ stopReason: "ralph_executing",
3003
+ systemMessage:
3004
+ "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(
2876
3009
  {
2877
- hook_event_name: "Stop",
2878
- cwd,
2879
- session_id: "sess-stop-ralph-once",
3010
+ ...payload,
2880
3011
  stop_hook_active: true,
2881
3012
  },
2882
3013
  { cwd },
2883
3014
  );
2884
3015
 
2885
- assert.equal(result.omxEventName, "stop");
2886
- assert.equal(result.outputJson, null);
3016
+ assert.equal(first.omxEventName, "stop");
3017
+ assert.deepEqual(first.outputJson, expected);
3018
+ assert.equal(replay.omxEventName, "stop");
3019
+ assert.deepEqual(replay.outputJson, expected);
2887
3020
  } finally {
3021
+ if (typeof previousOmxSessionId === "string") process.env.OMX_SESSION_ID = previousOmxSessionId;
3022
+ else delete process.env.OMX_SESSION_ID;
2888
3023
  await rm(cwd, { recursive: true, force: true });
2889
3024
  }
2890
3025
  });
2891
3026
 
3027
+
2892
3028
  it("returns Stop continuation output for native auto-nudge stall prompts", async () => {
2893
3029
  const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-auto-nudge-"));
2894
3030
  try {
@@ -2919,7 +3055,7 @@ esac
2919
3055
  }
2920
3056
  });
2921
3057
 
2922
- it("suppresses duplicate native auto-nudge replays for the same Stop reply", async () => {
3058
+ it("re-blocks duplicate native auto-nudge replays for the same Stop reply", async () => {
2923
3059
  const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-auto-nudge-once-"));
2924
3060
  try {
2925
3061
  const stateDir = join(cwd, ".omx", "state");
@@ -2952,13 +3088,19 @@ esac
2952
3088
  );
2953
3089
 
2954
3090
  assert.equal(result.omxEventName, "stop");
2955
- assert.equal(result.outputJson, null);
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
+ });
2956
3098
  } finally {
2957
3099
  await rm(cwd, { recursive: true, force: true });
2958
3100
  }
2959
3101
  });
2960
3102
 
2961
- it("suppresses duplicate native auto-nudge replays across native/canonical session-id drift", async () => {
3103
+ it("re-blocks duplicate native auto-nudge replays across native/canonical session-id drift", async () => {
2962
3104
  const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-auto-nudge-session-drift-"));
2963
3105
  try {
2964
3106
  const stateDir = join(cwd, ".omx", "state");
@@ -2995,7 +3137,13 @@ esac
2995
3137
  );
2996
3138
 
2997
3139
  assert.equal(result.omxEventName, "stop");
2998
- assert.equal(result.outputJson, null);
3140
+ assert.deepEqual(result.outputJson, {
3141
+ decision: "block",
3142
+ reason: DEFAULT_AUTO_NUDGE_RESPONSE,
3143
+ stopReason: "auto_nudge",
3144
+ systemMessage:
3145
+ "OMX native Stop detected a stall/permission-style handoff and continued the turn automatically.",
3146
+ });
2999
3147
 
3000
3148
  const persisted = JSON.parse(
3001
3149
  await readFile(join(stateDir, "native-stop-state.json"), "utf-8"),
@@ -3382,7 +3530,7 @@ esac
3382
3530
  }
3383
3531
  });
3384
3532
 
3385
- it("does not auto-continue native Stop for a plain Codex session started outside OMX runtime", async () => {
3533
+ it("auto-continues native Stop for permission-seeking prompts even outside OMX runtime", async () => {
3386
3534
  const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-auto-nudge-plain-session-"));
3387
3535
  try {
3388
3536
  await dispatchCodexNativeHook(
@@ -3404,13 +3552,19 @@ esac
3404
3552
  session_id: "plain-stop-session",
3405
3553
  thread_id: "plain-thread",
3406
3554
  turn_id: "plain-turn-1",
3407
- last_assistant_message: "Keep going and finish the cleanup.",
3555
+ last_assistant_message: "If you want, I can continue with the cleanup from here.",
3408
3556
  },
3409
3557
  { cwd },
3410
3558
  );
3411
3559
 
3412
3560
  assert.equal(result.omxEventName, "stop");
3413
- assert.equal(result.outputJson, null);
3561
+ assert.deepEqual(result.outputJson, {
3562
+ decision: "block",
3563
+ reason: DEFAULT_AUTO_NUDGE_RESPONSE,
3564
+ stopReason: "auto_nudge",
3565
+ systemMessage:
3566
+ "OMX native Stop detected a stall/permission-style handoff and continued the turn automatically.",
3567
+ });
3414
3568
  } finally {
3415
3569
  await rm(cwd, { recursive: true, force: true });
3416
3570
  }
@@ -3518,7 +3672,13 @@ esac
3518
3672
  );
3519
3673
 
3520
3674
  assert.equal(duplicate.omxEventName, "stop");
3521
- assert.equal(duplicate.outputJson, null);
3675
+ assert.deepEqual(duplicate.outputJson, {
3676
+ decision: "block",
3677
+ reason:
3678
+ `OMX team pipeline is still active (current-team) at phase team-verify; continue coordinating until the team reaches a terminal phase.${TEAM_STOP_COMMIT_GUIDANCE}`,
3679
+ stopReason: "team_team-verify",
3680
+ systemMessage: "OMX team pipeline is still active at phase team-verify.",
3681
+ });
3522
3682
 
3523
3683
  const fresh = await dispatchCodexNativeHook(
3524
3684
  {
@@ -3550,6 +3710,126 @@ esac
3550
3710
  }
3551
3711
  });
3552
3712
 
3713
+ it("re-blocks active execution modes on repeated Stop hooks", async () => {
3714
+ const cases = [
3715
+ {
3716
+ mode: "autopilot",
3717
+ phase: "execution",
3718
+ reason:
3719
+ "OMX autopilot is still active (phase: execution); continue the task and gather fresh verification evidence before stopping.",
3720
+ },
3721
+ {
3722
+ mode: "ultrawork",
3723
+ phase: "executing",
3724
+ reason:
3725
+ "OMX ultrawork is still active (phase: executing); continue the task and gather fresh verification evidence before stopping.",
3726
+ },
3727
+ {
3728
+ mode: "ultraqa",
3729
+ phase: "diagnose",
3730
+ reason:
3731
+ "OMX ultraqa is still active (phase: diagnose); continue the task and gather fresh verification evidence before stopping.",
3732
+ },
3733
+ ] as const;
3734
+
3735
+ for (const testCase of cases) {
3736
+ const cwd = await mkdtemp(join(tmpdir(), `omx-native-hook-stop-${testCase.mode}-repeat-`));
3737
+ try {
3738
+ const stateDir = join(cwd, ".omx", "state");
3739
+ await mkdir(stateDir, { recursive: true });
3740
+ await writeJson(join(stateDir, `${testCase.mode}-state.json`), {
3741
+ active: true,
3742
+ current_phase: testCase.phase,
3743
+ });
3744
+
3745
+ await dispatchCodexNativeHook(
3746
+ {
3747
+ hook_event_name: "Stop",
3748
+ cwd,
3749
+ session_id: `sess-stop-${testCase.mode}-repeat`,
3750
+ thread_id: `thread-stop-${testCase.mode}-repeat`,
3751
+ turn_id: `turn-stop-${testCase.mode}-repeat-1`,
3752
+ },
3753
+ { cwd },
3754
+ );
3755
+
3756
+ const repeated = await dispatchCodexNativeHook(
3757
+ {
3758
+ hook_event_name: "Stop",
3759
+ cwd,
3760
+ session_id: `sess-stop-${testCase.mode}-repeat`,
3761
+ thread_id: `thread-stop-${testCase.mode}-repeat`,
3762
+ turn_id: `turn-stop-${testCase.mode}-repeat-1`,
3763
+ stop_hook_active: true,
3764
+ },
3765
+ { cwd },
3766
+ );
3767
+
3768
+ assert.equal(repeated.omxEventName, "stop");
3769
+ assert.deepEqual(repeated.outputJson, {
3770
+ decision: "block",
3771
+ reason: testCase.reason,
3772
+ stopReason: `${testCase.mode}_${testCase.phase}`,
3773
+ systemMessage: `OMX ${testCase.mode} is still active (phase: ${testCase.phase}).`,
3774
+ });
3775
+ } finally {
3776
+ await rm(cwd, { recursive: true, force: true });
3777
+ }
3778
+ }
3779
+ });
3780
+
3781
+ it("re-blocks active ralplan skill state on repeated Stop hooks", async () => {
3782
+ const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-skill-repeat-"));
3783
+ try {
3784
+ const stateDir = join(cwd, ".omx", "state");
3785
+ await mkdir(join(stateDir, "sessions", "sess-stop-skill-repeat"), { recursive: true });
3786
+ await writeJson(join(stateDir, "session.json"), { session_id: "sess-stop-skill-repeat" });
3787
+ await writeJson(join(stateDir, "sessions", "sess-stop-skill-repeat", "skill-active-state.json"), {
3788
+ active: true,
3789
+ skill: "ralplan",
3790
+ phase: "planning",
3791
+ });
3792
+ await writeJson(join(stateDir, "sessions", "sess-stop-skill-repeat", "ralplan-state.json"), {
3793
+ active: true,
3794
+ current_phase: "planning",
3795
+ });
3796
+
3797
+ await dispatchCodexNativeHook(
3798
+ {
3799
+ hook_event_name: "Stop",
3800
+ cwd,
3801
+ session_id: "sess-stop-skill-repeat",
3802
+ thread_id: "thread-stop-skill-repeat",
3803
+ turn_id: "turn-stop-skill-repeat-1",
3804
+ },
3805
+ { cwd },
3806
+ );
3807
+
3808
+ const repeated = await dispatchCodexNativeHook(
3809
+ {
3810
+ hook_event_name: "Stop",
3811
+ cwd,
3812
+ session_id: "sess-stop-skill-repeat",
3813
+ thread_id: "thread-stop-skill-repeat",
3814
+ turn_id: "turn-stop-skill-repeat-1",
3815
+ stop_hook_active: true,
3816
+ },
3817
+ { cwd },
3818
+ );
3819
+
3820
+ assert.equal(repeated.omxEventName, "stop");
3821
+ assert.deepEqual(repeated.outputJson, {
3822
+ decision: "block",
3823
+ reason:
3824
+ "OMX skill ralplan is still active (phase: planning); continue until the current ralplan workflow reaches a terminal state.",
3825
+ stopReason: "skill_ralplan_planning",
3826
+ systemMessage: "OMX skill ralplan is still active (phase: planning).",
3827
+ });
3828
+ } finally {
3829
+ await rm(cwd, { recursive: true, force: true });
3830
+ }
3831
+ });
3832
+
3553
3833
  it("does not block Stop from another session's stale root team state when no scoped team state exists", async () => {
3554
3834
  const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-stale-root-team-"));
3555
3835
  try {
@@ -0,0 +1,166 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { describe, it } from 'node:test';
6
+ import { spawnSync } from 'node:child_process';
7
+ import {
8
+ buildFullChangelogLine,
9
+ generateReleaseBody,
10
+ renderContributorsSection,
11
+ type Contributor,
12
+ } from '../generate-release-body.js';
13
+
14
+ function git(cwd: string, args: string[], env: NodeJS.ProcessEnv = {}): string {
15
+ const result = spawnSync('git', args, {
16
+ cwd,
17
+ encoding: 'utf-8',
18
+ env: { ...process.env, ...env },
19
+ });
20
+ assert.equal(result.status, 0, result.stderr || result.stdout);
21
+ return String(result.stdout || '').trim();
22
+ }
23
+
24
+ const TEMPLATE = `# oh-my-codex v0.0.0
25
+
26
+ ## Summary
27
+
28
+ Custom summary that must stay intact.
29
+
30
+ ## Fixed
31
+
32
+ - Keep this handwritten section.
33
+
34
+ ## Verification
35
+
36
+ - npm test
37
+
38
+ ## Contributors
39
+
40
+ Outdated contributor text.
41
+
42
+ **Full Changelog**: [\`v0.0.0...v0.0.1\`](https://github.com/example/compare/v0.0.0...v0.0.1)
43
+ `;
44
+
45
+ describe('generate-release-body', () => {
46
+ it('preserves custom sections while refreshing contributors and compare metadata from git', async () => {
47
+ const root = await mkdtemp(join(tmpdir(), 'omx-generate-release-body-'));
48
+ const originalGitHubRepository = process.env.GITHUB_REPOSITORY;
49
+ try {
50
+ git(root, ['init']);
51
+ git(root, ['config', 'user.name', 'Release Bot']);
52
+ git(root, ['config', 'user.email', 'release@example.com']);
53
+ git(root, ['remote', 'add', 'origin', 'https://github.com/example/oh-my-codex.git']);
54
+
55
+ await writeFile(join(root, 'RELEASE_BODY.md'), TEMPLATE);
56
+ await writeFile(join(root, 'notes.txt'), 'base\n');
57
+ git(root, ['add', '.']);
58
+ git(root, ['commit', '-m', 'base']);
59
+ git(root, ['tag', 'v0.12.0']);
60
+
61
+ await writeFile(join(root, 'notes.txt'), 'alice\n');
62
+ git(root, ['add', 'notes.txt']);
63
+ git(root, ['commit', '-m', 'alice change'], { GIT_AUTHOR_NAME: 'Alice Example', GIT_AUTHOR_EMAIL: 'alice@example.com' });
64
+
65
+ await writeFile(join(root, 'notes.txt'), 'bob\n');
66
+ git(root, ['add', 'notes.txt']);
67
+ git(root, ['commit', '-m', 'bob change'], { GIT_AUTHOR_NAME: 'Bob Example', GIT_AUTHOR_EMAIL: 'bob@example.com' });
68
+ git(root, ['tag', 'v0.13.0']);
69
+
70
+ delete process.env.GITHUB_REPOSITORY;
71
+ await generateReleaseBody({
72
+ cwd: root,
73
+ templatePath: 'RELEASE_BODY.md',
74
+ outPath: 'RELEASE_BODY.generated.md',
75
+ currentTag: 'v0.13.0',
76
+ });
77
+
78
+ const generated = await readFile(join(root, 'RELEASE_BODY.generated.md'), 'utf-8');
79
+ assert.match(generated, /^# oh-my-codex v0.13.0/m);
80
+ assert.match(generated, /Custom summary that must stay intact\./);
81
+ assert.match(generated, /Keep this handwritten section\./);
82
+ assert.match(generated, /## Contributors\n\nThanks to Alice Example and Bob Example for contributing to this release\./);
83
+ assert.match(generated, /\*\*Full Changelog\*\*: \[`v0\.12\.0\.\.\.v0\.13\.0`\]\(https:\/\/github\.com\/example\/oh-my-codex\/compare\/v0\.12\.0\.\.\.v0\.13\.0\)/);
84
+ } finally {
85
+ if (originalGitHubRepository === undefined) {
86
+ delete process.env.GITHUB_REPOSITORY;
87
+ } else {
88
+ process.env.GITHUB_REPOSITORY = originalGitHubRepository;
89
+ }
90
+ await rm(root, { recursive: true, force: true });
91
+ }
92
+ });
93
+
94
+ it('prefers GitHub contributor handles when compare metadata is available', async () => {
95
+ const root = await mkdtemp(join(tmpdir(), 'omx-generate-release-body-gh-'));
96
+ try {
97
+ await writeFile(join(root, 'RELEASE_BODY.md'), TEMPLATE);
98
+ const originalFetch = global.fetch;
99
+ global.fetch = (async () => new Response(JSON.stringify({
100
+ commits: [
101
+ { author: { login: 'alice', html_url: 'https://github.com/alice' } },
102
+ { author: { login: 'bob', html_url: 'https://github.com/bob' } },
103
+ { author: { login: 'alice', html_url: 'https://github.com/alice' } },
104
+ ],
105
+ }), { status: 200, headers: { 'content-type': 'application/json' } })) as typeof fetch;
106
+
107
+ try {
108
+ await generateReleaseBody({
109
+ cwd: root,
110
+ templatePath: 'RELEASE_BODY.md',
111
+ outPath: 'RELEASE_BODY.generated.md',
112
+ currentTag: 'v0.13.1',
113
+ previousTag: 'v0.13.0',
114
+ repo: 'example/oh-my-codex',
115
+ githubToken: 'test-token',
116
+ });
117
+ } finally {
118
+ global.fetch = originalFetch;
119
+ }
120
+
121
+ const generated = await readFile(join(root, 'RELEASE_BODY.generated.md'), 'utf-8');
122
+ assert.match(generated, /Thanks to \[@alice\]\(https:\/\/github\.com\/alice\) and \[@bob\]\(https:\/\/github\.com\/bob\) for contributing to this release\./);
123
+ } finally {
124
+ await rm(root, { recursive: true, force: true });
125
+ }
126
+ });
127
+
128
+
129
+ it('fails validation when the template is missing required metadata anchors', async () => {
130
+ const root = await mkdtemp(join(tmpdir(), 'omx-generate-release-body-invalid-'));
131
+ try {
132
+ await writeFile(join(root, 'RELEASE_BODY.md'), `# oh-my-codex v0.0.0
133
+
134
+ ## Summary
135
+
136
+ Missing required sections.
137
+ `);
138
+ await assert.rejects(
139
+ generateReleaseBody({
140
+ cwd: root,
141
+ templatePath: 'RELEASE_BODY.md',
142
+ outPath: 'RELEASE_BODY.generated.md',
143
+ currentTag: 'v0.13.1',
144
+ previousTag: 'v0.13.0',
145
+ repo: 'example/oh-my-codex',
146
+ }),
147
+ /missing section: ## Contributors|missing the Full Changelog line/,
148
+ );
149
+ } finally {
150
+ await rm(root, { recursive: true, force: true });
151
+ }
152
+ });
153
+
154
+ it('renders contributor and changelog helpers for edge cases', () => {
155
+ const contributors: Contributor[] = [];
156
+ assert.equal(renderContributorsSection(contributors), 'Thanks to the contributors who made this release possible.');
157
+ assert.equal(
158
+ buildFullChangelogLine('example/oh-my-codex', 'v0.13.1', 'v0.13.0'),
159
+ '**Full Changelog**: [`v0.13.0...v0.13.1`](https://github.com/example/oh-my-codex/compare/v0.13.0...v0.13.1)',
160
+ );
161
+ assert.equal(
162
+ buildFullChangelogLine('example/oh-my-codex', 'v0.1.0'),
163
+ '**Full Changelog**: [`v0.1.0`](https://github.com/example/oh-my-codex/releases/tag/v0.1.0)',
164
+ );
165
+ });
166
+ });