oh-my-codex 0.13.1 → 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 (131) hide show
  1. package/Cargo.lock +5 -5
  2. package/Cargo.toml +1 -1
  3. package/README.md +2 -0
  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 +95 -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 +70 -8
  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__/analyze-routing-contract.test.d.ts +2 -0
  23. package/dist/hooks/__tests__/analyze-routing-contract.test.d.ts.map +1 -0
  24. package/dist/hooks/__tests__/analyze-routing-contract.test.js +36 -0
  25. package/dist/hooks/__tests__/analyze-routing-contract.test.js.map +1 -0
  26. package/dist/hooks/__tests__/analyze-skill-contract.test.d.ts +2 -0
  27. package/dist/hooks/__tests__/analyze-skill-contract.test.d.ts.map +1 -0
  28. package/dist/hooks/__tests__/analyze-skill-contract.test.js +48 -0
  29. package/dist/hooks/__tests__/analyze-skill-contract.test.js.map +1 -0
  30. package/dist/hooks/__tests__/keyword-detector.test.js +32 -0
  31. package/dist/hooks/__tests__/keyword-detector.test.js.map +1 -1
  32. package/dist/hooks/__tests__/notify-fallback-watcher.test.js +185 -8
  33. package/dist/hooks/__tests__/notify-fallback-watcher.test.js.map +1 -1
  34. package/dist/hooks/__tests__/notify-hook-session-idle-dedupe.test.js +26 -0
  35. package/dist/hooks/__tests__/notify-hook-session-idle-dedupe.test.js.map +1 -1
  36. package/dist/hooks/__tests__/notify-hook-session-scope.test.js +44 -0
  37. package/dist/hooks/__tests__/notify-hook-session-scope.test.js.map +1 -1
  38. package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js +126 -0
  39. package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js.map +1 -1
  40. package/dist/hooks/keyword-detector.d.ts.map +1 -1
  41. package/dist/hooks/keyword-detector.js +8 -1
  42. package/dist/hooks/keyword-detector.js.map +1 -1
  43. package/dist/hud/__tests__/state.test.js +55 -0
  44. package/dist/hud/__tests__/state.test.js.map +1 -1
  45. package/dist/hud/state.d.ts.map +1 -1
  46. package/dist/hud/state.js +23 -4
  47. package/dist/hud/state.js.map +1 -1
  48. package/dist/mcp/__tests__/bootstrap.test.js +38 -0
  49. package/dist/mcp/__tests__/bootstrap.test.js.map +1 -1
  50. package/dist/mcp/bootstrap.d.ts +1 -1
  51. package/dist/mcp/bootstrap.d.ts.map +1 -1
  52. package/dist/mcp/bootstrap.js +11 -3
  53. package/dist/mcp/bootstrap.js.map +1 -1
  54. package/dist/notifications/__tests__/reply-listener.test.js +34 -1
  55. package/dist/notifications/__tests__/reply-listener.test.js.map +1 -1
  56. package/dist/notifications/reply-listener.d.ts +1 -0
  57. package/dist/notifications/reply-listener.d.ts.map +1 -1
  58. package/dist/notifications/reply-listener.js +14 -2
  59. package/dist/notifications/reply-listener.js.map +1 -1
  60. package/dist/scripts/__tests__/codex-native-hook.test.js +178 -15
  61. package/dist/scripts/__tests__/codex-native-hook.test.js.map +1 -1
  62. package/dist/scripts/__tests__/generate-release-body.test.d.ts +2 -0
  63. package/dist/scripts/__tests__/generate-release-body.test.d.ts.map +1 -0
  64. package/dist/scripts/__tests__/generate-release-body.test.js +144 -0
  65. package/dist/scripts/__tests__/generate-release-body.test.js.map +1 -0
  66. package/dist/scripts/codex-native-hook.d.ts.map +1 -1
  67. package/dist/scripts/codex-native-hook.js +23 -44
  68. package/dist/scripts/codex-native-hook.js.map +1 -1
  69. package/dist/scripts/generate-release-body.d.ts +34 -0
  70. package/dist/scripts/generate-release-body.d.ts.map +1 -0
  71. package/dist/scripts/generate-release-body.js +249 -0
  72. package/dist/scripts/generate-release-body.js.map +1 -0
  73. package/dist/scripts/notify-fallback-watcher.js +43 -20
  74. package/dist/scripts/notify-fallback-watcher.js.map +1 -1
  75. package/dist/scripts/notify-hook/active-team.d.ts.map +1 -1
  76. package/dist/scripts/notify-hook/active-team.js +2 -1
  77. package/dist/scripts/notify-hook/active-team.js.map +1 -1
  78. package/dist/scripts/notify-hook/ralph-session-resume.d.ts.map +1 -1
  79. package/dist/scripts/notify-hook/ralph-session-resume.js +17 -2
  80. package/dist/scripts/notify-hook/ralph-session-resume.js.map +1 -1
  81. package/dist/scripts/notify-hook/state-io.d.ts.map +1 -1
  82. package/dist/scripts/notify-hook/state-io.js +16 -0
  83. package/dist/scripts/notify-hook/state-io.js.map +1 -1
  84. package/dist/scripts/notify-hook/team-leader-nudge.d.ts.map +1 -1
  85. package/dist/scripts/notify-hook/team-leader-nudge.js +26 -5
  86. package/dist/scripts/notify-hook/team-leader-nudge.js.map +1 -1
  87. package/dist/scripts/notify-hook.js +1 -7
  88. package/dist/scripts/notify-hook.js.map +1 -1
  89. package/dist/team/__tests__/model-contract.test.js +6 -0
  90. package/dist/team/__tests__/model-contract.test.js.map +1 -1
  91. package/dist/team/__tests__/tmux-session.test.js +1 -1
  92. package/dist/team/__tests__/tmux-session.test.js.map +1 -1
  93. package/dist/team/__tests__/worker-runtime-identity.test.d.ts +2 -0
  94. package/dist/team/__tests__/worker-runtime-identity.test.d.ts.map +1 -0
  95. package/dist/team/__tests__/worker-runtime-identity.test.js +250 -0
  96. package/dist/team/__tests__/worker-runtime-identity.test.js.map +1 -0
  97. package/dist/team/leader-activity.d.ts.map +1 -1
  98. package/dist/team/leader-activity.js +26 -15
  99. package/dist/team/leader-activity.js.map +1 -1
  100. package/dist/team/model-contract.d.ts.map +1 -1
  101. package/dist/team/model-contract.js.map +1 -1
  102. package/dist/team/runtime.d.ts.map +1 -1
  103. package/dist/team/runtime.js +9 -8
  104. package/dist/team/runtime.js.map +1 -1
  105. package/dist/team/scaling.d.ts.map +1 -1
  106. package/dist/team/scaling.js +10 -9
  107. package/dist/team/scaling.js.map +1 -1
  108. package/dist/team/tmux-session.d.ts.map +1 -1
  109. package/dist/team/tmux-session.js +3 -2
  110. package/dist/team/tmux-session.js.map +1 -1
  111. package/dist/verification/__tests__/explore-harness-release-workflow.test.js +3 -0
  112. package/dist/verification/__tests__/explore-harness-release-workflow.test.js.map +1 -1
  113. package/dist/wiki/__tests__/slug-nonascii.test.js +11 -5
  114. package/dist/wiki/__tests__/slug-nonascii.test.js.map +1 -1
  115. package/dist/wiki/storage.d.ts.map +1 -1
  116. package/dist/wiki/storage.js +2 -1
  117. package/dist/wiki/storage.js.map +1 -1
  118. package/package.json +3 -1
  119. package/skills/analyze/SKILL.md +101 -134
  120. package/src/scripts/__tests__/codex-native-hook.test.ts +214 -17
  121. package/src/scripts/__tests__/generate-release-body.test.ts +166 -0
  122. package/src/scripts/codex-native-hook.ts +81 -61
  123. package/src/scripts/generate-release-body.ts +295 -0
  124. package/src/scripts/notify-fallback-watcher.ts +44 -21
  125. package/src/scripts/notify-hook/active-team.ts +2 -1
  126. package/src/scripts/notify-hook/ralph-session-resume.ts +17 -2
  127. package/src/scripts/notify-hook/state-io.ts +16 -0
  128. package/src/scripts/notify-hook/team-leader-nudge.ts +24 -4
  129. package/src/scripts/notify-hook.ts +1 -6
  130. package/templates/AGENTS.md +1 -1
  131. package/templates/catalog-manifest.json +2 -4
@@ -464,6 +464,33 @@ describe("codex native hook dispatch", () => {
464
464
  }
465
465
  });
466
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
+
467
494
  it("clarifies that prompt-side $ralph activation does not invoke the PRD-gated CLI path", async () => {
468
495
  const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-ralph-routing-"));
469
496
  try {
@@ -2111,7 +2138,13 @@ esac
2111
2138
  { cwd },
2112
2139
  );
2113
2140
 
2114
- 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
+ });
2115
2148
  } finally {
2116
2149
  if (typeof prevTeamWorker === "string") process.env.OMX_TEAM_WORKER = prevTeamWorker;
2117
2150
  else delete process.env.OMX_TEAM_WORKER;
@@ -2942,8 +2975,9 @@ esac
2942
2975
  }
2943
2976
  });
2944
2977
 
2945
- it("does not re-block Ralph when Stop already continued once", async () => {
2946
- 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;
2947
2981
  try {
2948
2982
  const stateDir = join(cwd, ".omx", "state");
2949
2983
  await mkdir(stateDir, { recursive: true });
@@ -2955,23 +2989,42 @@ esac
2955
2989
  }),
2956
2990
  );
2957
2991
 
2958
- 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(
2959
3009
  {
2960
- hook_event_name: "Stop",
2961
- cwd,
2962
- session_id: "sess-stop-ralph-once",
3010
+ ...payload,
2963
3011
  stop_hook_active: true,
2964
3012
  },
2965
3013
  { cwd },
2966
3014
  );
2967
3015
 
2968
- assert.equal(result.omxEventName, "stop");
2969
- 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);
2970
3020
  } finally {
3021
+ if (typeof previousOmxSessionId === "string") process.env.OMX_SESSION_ID = previousOmxSessionId;
3022
+ else delete process.env.OMX_SESSION_ID;
2971
3023
  await rm(cwd, { recursive: true, force: true });
2972
3024
  }
2973
3025
  });
2974
3026
 
3027
+
2975
3028
  it("returns Stop continuation output for native auto-nudge stall prompts", async () => {
2976
3029
  const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-auto-nudge-"));
2977
3030
  try {
@@ -3002,7 +3055,7 @@ esac
3002
3055
  }
3003
3056
  });
3004
3057
 
3005
- 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 () => {
3006
3059
  const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-auto-nudge-once-"));
3007
3060
  try {
3008
3061
  const stateDir = join(cwd, ".omx", "state");
@@ -3035,13 +3088,19 @@ esac
3035
3088
  );
3036
3089
 
3037
3090
  assert.equal(result.omxEventName, "stop");
3038
- 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
+ });
3039
3098
  } finally {
3040
3099
  await rm(cwd, { recursive: true, force: true });
3041
3100
  }
3042
3101
  });
3043
3102
 
3044
- 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 () => {
3045
3104
  const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-auto-nudge-session-drift-"));
3046
3105
  try {
3047
3106
  const stateDir = join(cwd, ".omx", "state");
@@ -3078,7 +3137,13 @@ esac
3078
3137
  );
3079
3138
 
3080
3139
  assert.equal(result.omxEventName, "stop");
3081
- 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
+ });
3082
3147
 
3083
3148
  const persisted = JSON.parse(
3084
3149
  await readFile(join(stateDir, "native-stop-state.json"), "utf-8"),
@@ -3465,7 +3530,7 @@ esac
3465
3530
  }
3466
3531
  });
3467
3532
 
3468
- 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 () => {
3469
3534
  const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-auto-nudge-plain-session-"));
3470
3535
  try {
3471
3536
  await dispatchCodexNativeHook(
@@ -3487,13 +3552,19 @@ esac
3487
3552
  session_id: "plain-stop-session",
3488
3553
  thread_id: "plain-thread",
3489
3554
  turn_id: "plain-turn-1",
3490
- last_assistant_message: "Keep going and finish the cleanup.",
3555
+ last_assistant_message: "If you want, I can continue with the cleanup from here.",
3491
3556
  },
3492
3557
  { cwd },
3493
3558
  );
3494
3559
 
3495
3560
  assert.equal(result.omxEventName, "stop");
3496
- 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
+ });
3497
3568
  } finally {
3498
3569
  await rm(cwd, { recursive: true, force: true });
3499
3570
  }
@@ -3601,7 +3672,13 @@ esac
3601
3672
  );
3602
3673
 
3603
3674
  assert.equal(duplicate.omxEventName, "stop");
3604
- 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
+ });
3605
3682
 
3606
3683
  const fresh = await dispatchCodexNativeHook(
3607
3684
  {
@@ -3633,6 +3710,126 @@ esac
3633
3710
  }
3634
3711
  });
3635
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
+
3636
3833
  it("does not block Stop from another session's stale root team state when no scoped team state exists", async () => {
3637
3834
  const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-stop-stale-root-team-"));
3638
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
+ });