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
@@ -44,7 +44,6 @@ import type { HookEventEnvelope } from "../hooks/extensibility/types.js";
44
44
  import { dispatchHookEvent } from "../hooks/extensibility/dispatcher.js";
45
45
  import { reconcileHudForPromptSubmit } from "../hud/reconcile.js";
46
46
  import { onSessionStart as buildWikiSessionStartContext } from "../wiki/lifecycle.js";
47
- import { sessionModelInstructionsPath } from "../hooks/agents-overlay.js";
48
47
 
49
48
  type CodexHookEventName =
50
49
  | "SessionStart"
@@ -977,29 +976,6 @@ function readNativeStopSessionKey(
977
976
  return resolveRepeatableStopSessionId(payload, canonicalSessionId) || readPayloadThreadId(payload) || "global";
978
977
  }
979
978
 
980
- function hasManagedStopSessionEnv(sessionIds: string[]): boolean {
981
- const envSessionId = safeString(process.env.OMX_SESSION_ID).trim();
982
- if (!envSessionId) return false;
983
- return sessionIds.length === 0 || sessionIds.includes(envSessionId);
984
- }
985
-
986
- async function hasManagedStopContext(
987
- cwd: string,
988
- payload: CodexHookPayload,
989
- canonicalSessionId: string,
990
- ): Promise<boolean> {
991
- if (hasTeamWorkerContext()) return true;
992
-
993
- const sessionIds = [...new Set([
994
- canonicalSessionId,
995
- readPayloadSessionId(payload),
996
- ].map((value) => safeString(value).trim()).filter(Boolean))];
997
-
998
- if (hasManagedStopSessionEnv(sessionIds)) return true;
999
-
1000
- return sessionIds.some((sessionId) => existsSync(sessionModelInstructionsPath(cwd, sessionId)));
1001
- }
1002
-
1003
979
  function readPreviousNativeStopSignature(
1004
980
  state: Record<string, unknown>,
1005
981
  sessionKey: string,
@@ -1038,10 +1014,11 @@ async function maybeReturnRepeatableStopOutput(
1038
1014
  signature: string,
1039
1015
  output: Record<string, unknown> | null,
1040
1016
  canonicalSessionId?: string,
1017
+ options: { allowRepeatDuringStopHook?: boolean } = {},
1041
1018
  ): Promise<Record<string, unknown> | null> {
1042
1019
  if (!output) return null;
1043
1020
  const stopHookActive = payload.stop_hook_active === true || payload.stopHookActive === true;
1044
- if (stopHookActive) {
1021
+ if (stopHookActive && options.allowRepeatDuringStopHook !== true) {
1045
1022
  const state = await readJsonIfExists(join(stateDir, NATIVE_STOP_STATE_FILE)) ?? {};
1046
1023
  const previousSignature = readPreviousNativeStopSignature(
1047
1024
  state,
@@ -1055,6 +1032,24 @@ async function maybeReturnRepeatableStopOutput(
1055
1032
  return output;
1056
1033
  }
1057
1034
 
1035
+ async function returnPersistentStopBlock(
1036
+ payload: CodexHookPayload,
1037
+ stateDir: string,
1038
+ signatureKind: string,
1039
+ signatureValue: string,
1040
+ output: Record<string, unknown> | null,
1041
+ canonicalSessionId?: string,
1042
+ ): Promise<Record<string, unknown> | null> {
1043
+ return await maybeReturnRepeatableStopOutput(
1044
+ payload,
1045
+ stateDir,
1046
+ buildRepeatableStopSignature(payload, signatureKind, signatureValue, canonicalSessionId),
1047
+ output,
1048
+ canonicalSessionId,
1049
+ { allowRepeatDuringStopHook: true },
1050
+ );
1051
+ }
1052
+
1058
1053
  async function findCanonicalActiveTeamForSession(
1059
1054
  cwd: string,
1060
1055
  sessionId: string,
@@ -1287,20 +1282,45 @@ async function buildStopHookOutput(
1287
1282
  const canonicalSessionId = await resolveInternalSessionIdForPayload(cwd, sessionId);
1288
1283
  const threadId = readPayloadThreadId(payload);
1289
1284
  const ralphState = await readActiveRalphState(stateDir, canonicalSessionId);
1290
- const stopHookActive = payload.stop_hook_active === true || payload.stopHookActive === true;
1291
- const managedStopContext = await hasManagedStopContext(cwd, payload, canonicalSessionId);
1292
1285
  if (!ralphState) {
1293
1286
  const teamWorkerOutput = await buildTeamWorkerStopOutput(cwd);
1294
- if (!stopHookActive && hasTeamWorkerContext()) return teamWorkerOutput;
1287
+ if (hasTeamWorkerContext() && teamWorkerOutput) return teamWorkerOutput;
1295
1288
 
1296
1289
  const autopilotOutput = await buildModeBasedStopOutput("autopilot", cwd, canonicalSessionId);
1297
- if (!stopHookActive && autopilotOutput) return autopilotOutput;
1290
+ if (autopilotOutput) {
1291
+ return await returnPersistentStopBlock(
1292
+ payload,
1293
+ stateDir,
1294
+ "autopilot-stop",
1295
+ safeString(autopilotOutput.stopReason),
1296
+ autopilotOutput,
1297
+ canonicalSessionId,
1298
+ );
1299
+ }
1298
1300
 
1299
1301
  const ultraworkOutput = await buildModeBasedStopOutput("ultrawork", cwd, canonicalSessionId);
1300
- if (!stopHookActive && ultraworkOutput) return ultraworkOutput;
1302
+ if (ultraworkOutput) {
1303
+ return await returnPersistentStopBlock(
1304
+ payload,
1305
+ stateDir,
1306
+ "ultrawork-stop",
1307
+ safeString(ultraworkOutput.stopReason),
1308
+ ultraworkOutput,
1309
+ canonicalSessionId,
1310
+ );
1311
+ }
1301
1312
 
1302
1313
  const ultraqaOutput = await buildModeBasedStopOutput("ultraqa", cwd, canonicalSessionId);
1303
- if (!stopHookActive && ultraqaOutput) return ultraqaOutput;
1314
+ if (ultraqaOutput) {
1315
+ return await returnPersistentStopBlock(
1316
+ payload,
1317
+ stateDir,
1318
+ "ultraqa-stop",
1319
+ safeString(ultraqaOutput.stopReason),
1320
+ ultraqaOutput,
1321
+ canonicalSessionId,
1322
+ );
1323
+ }
1304
1324
 
1305
1325
  const releaseReadinessFinalizeResult = await maybeBuildReleaseReadinessFinalizeStopOutput(
1306
1326
  payload,
@@ -1312,16 +1332,11 @@ async function buildStopHookOutput(
1312
1332
 
1313
1333
  const teamOutput = await buildTeamStopOutput(cwd, canonicalSessionId);
1314
1334
  if (teamOutput) {
1315
- const teamSignature = buildRepeatableStopSignature(
1335
+ return await returnPersistentStopBlock(
1316
1336
  payload,
1337
+ stateDir,
1317
1338
  "team-stop",
1318
1339
  safeString(teamOutput.stopReason),
1319
- canonicalSessionId,
1320
- );
1321
- return await maybeReturnRepeatableStopOutput(
1322
- payload,
1323
- stateDir,
1324
- teamSignature,
1325
1340
  teamOutput,
1326
1341
  canonicalSessionId,
1327
1342
  );
@@ -1334,16 +1349,11 @@ async function buildStopHookOutput(
1334
1349
  canonicalTeam.teamName,
1335
1350
  canonicalTeam.phase,
1336
1351
  );
1337
- const canonicalTeamSignature = buildRepeatableStopSignature(
1352
+ const repeatedCanonicalTeamOutput = await returnPersistentStopBlock(
1338
1353
  payload,
1354
+ stateDir,
1339
1355
  "team-stop",
1340
1356
  `${canonicalTeam.teamName}|${canonicalTeam.phase}`,
1341
- canonicalSessionId,
1342
- );
1343
- const repeatedCanonicalTeamOutput = await maybeReturnRepeatableStopOutput(
1344
- payload,
1345
- stateDir,
1346
- canonicalTeamSignature,
1347
1357
  canonicalTeamOutput,
1348
1358
  canonicalSessionId,
1349
1359
  );
@@ -1351,12 +1361,18 @@ async function buildStopHookOutput(
1351
1361
  }
1352
1362
 
1353
1363
  const skillOutput = await buildSkillStopOutput(cwd, canonicalSessionId, threadId);
1354
- if (!stopHookActive && skillOutput) return skillOutput;
1364
+ if (skillOutput) {
1365
+ return await returnPersistentStopBlock(
1366
+ payload,
1367
+ stateDir,
1368
+ "skill-stop",
1369
+ safeString(skillOutput.stopReason),
1370
+ skillOutput,
1371
+ canonicalSessionId,
1372
+ );
1373
+ }
1355
1374
  }
1356
1375
 
1357
- if (!managedStopContext) {
1358
- return null;
1359
- }
1360
1376
 
1361
1377
  const lastAssistantMessage = safeString(
1362
1378
  payload.last_assistant_message ?? payload.lastAssistantMessage,
@@ -1369,10 +1385,11 @@ async function buildStopHookOutput(
1369
1385
  && detectNativeStopStallPattern(lastAssistantMessage, autoNudgeConfig.patterns, autoNudgePhase)
1370
1386
  ) {
1371
1387
  const effectiveResponse = resolveEffectiveAutoNudgeResponse(autoNudgeConfig.response);
1372
- return await maybeReturnRepeatableStopOutput(
1388
+ return await returnPersistentStopBlock(
1373
1389
  payload,
1374
1390
  stateDir,
1375
- buildRepeatableStopSignature(payload, "auto-nudge", lastAssistantMessage, canonicalSessionId),
1391
+ "auto-nudge",
1392
+ lastAssistantMessage,
1376
1393
  {
1377
1394
  decision: "block",
1378
1395
  reason: effectiveResponse,
@@ -1387,21 +1404,24 @@ async function buildStopHookOutput(
1387
1404
  return null;
1388
1405
  }
1389
1406
 
1390
- if (stopHookActive) {
1391
- return null;
1392
- }
1393
-
1394
1407
  const currentPhase = safeString(ralphState?.current_phase).trim() || "executing";
1395
1408
  const stopReason = `ralph_${currentPhase}`;
1396
1409
  const systemMessage =
1397
1410
  `OMX Ralph is still active (phase: ${currentPhase}); continue the task and gather fresh verification evidence before stopping.`;
1398
1411
 
1399
- return {
1400
- decision: "block",
1401
- reason: systemMessage,
1402
- stopReason,
1403
- systemMessage,
1404
- };
1412
+ return await returnPersistentStopBlock(
1413
+ payload,
1414
+ stateDir,
1415
+ "ralph-stop",
1416
+ currentPhase,
1417
+ {
1418
+ decision: "block",
1419
+ reason: systemMessage,
1420
+ stopReason,
1421
+ systemMessage,
1422
+ },
1423
+ canonicalSessionId,
1424
+ );
1405
1425
  }
1406
1426
 
1407
1427
  export async function dispatchCodexNativeHook(
@@ -0,0 +1,295 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync, writeFileSync } from 'node:fs';
3
+ import { resolve } from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { pathToFileURL } from 'node:url';
6
+
7
+ export interface Contributor {
8
+ displayName: string;
9
+ login?: string;
10
+ url?: string;
11
+ }
12
+
13
+ interface CompareCommit {
14
+ author?: {
15
+ login?: string;
16
+ html_url?: string;
17
+ } | null;
18
+ commit?: {
19
+ author?: {
20
+ name?: string;
21
+ } | null;
22
+ } | null;
23
+ }
24
+
25
+ interface CompareResponse {
26
+ commits?: CompareCommit[];
27
+ }
28
+
29
+ interface GenerateReleaseBodyOptions {
30
+ templatePath: string;
31
+ outPath: string;
32
+ currentTag?: string;
33
+ previousTag?: string;
34
+ repo?: string;
35
+ githubToken?: string;
36
+ cwd?: string;
37
+ }
38
+
39
+ function usage(): never {
40
+ console.error('Usage: node scripts/generate-release-body.mjs --template <path> --out <path> [--current-tag <tag>] [--previous-tag <tag>] [--repo <owner/name>]');
41
+ process.exit(1);
42
+ }
43
+
44
+ function arg(name: string): string | undefined {
45
+ const index = process.argv.indexOf(name);
46
+ if (index === -1) return undefined;
47
+ return process.argv[index + 1];
48
+ }
49
+
50
+ function runGit(args: string[], cwd: string, allowFailure = false): string {
51
+ const result = spawnSync('git', args, {
52
+ cwd,
53
+ encoding: 'utf-8',
54
+ stdio: 'pipe',
55
+ });
56
+ if (result.status !== 0) {
57
+ if (allowFailure) return '';
58
+ throw new Error(`git ${args.join(' ')} failed: ${(result.stderr || result.stdout || '').trim()}`.trim());
59
+ }
60
+ return String(result.stdout || '').trim();
61
+ }
62
+
63
+ function resolveRepositoryFromRemote(cwd: string): string | undefined {
64
+ const remote = runGit(['config', '--get', 'remote.origin.url'], cwd, true);
65
+ if (!remote) return undefined;
66
+ const httpsMatch = remote.match(/github\.com[:/](.+?)(?:\.git)?$/);
67
+ return httpsMatch?.[1];
68
+ }
69
+
70
+ export function resolveCurrentTag(cwd: string, explicit?: string): string {
71
+ if (explicit) return explicit;
72
+ if (process.env.GITHUB_REF_NAME) return process.env.GITHUB_REF_NAME;
73
+ const described = runGit(['describe', '--tags', '--exact-match'], cwd, true);
74
+ if (described) return described;
75
+ throw new Error('unable to determine current release tag; pass --current-tag or set GITHUB_REF_NAME');
76
+ }
77
+
78
+ export function resolvePreviousTag(cwd: string, currentTag: string, explicit?: string): string | undefined {
79
+ if (explicit) return explicit;
80
+ const tags = runGit(['tag', '--list', 'v*', '--sort=-v:refname'], cwd, true)
81
+ .split(/\r?\n/)
82
+ .map((line) => line.trim())
83
+ .filter(Boolean);
84
+ if (tags.length === 0) return undefined;
85
+ const currentIndex = tags.indexOf(currentTag);
86
+ if (currentIndex >= 0) return tags.slice(currentIndex + 1).find(Boolean);
87
+ return tags.find((tag) => tag !== currentTag);
88
+ }
89
+
90
+ function normalizeContributors(contributors: Contributor[]): Contributor[] {
91
+ const deduped = new Map<string, Contributor>();
92
+ for (const contributor of contributors) {
93
+ const login = contributor.login?.trim();
94
+ const displayName = contributor.displayName.trim();
95
+ if (!displayName && !login) continue;
96
+ const key = (login || displayName).toLowerCase();
97
+ if (!deduped.has(key)) {
98
+ deduped.set(key, {
99
+ displayName: displayName || `@${login}`,
100
+ ...(login ? { login } : {}),
101
+ ...(contributor.url ? { url: contributor.url } : {}),
102
+ });
103
+ }
104
+ }
105
+ return [...deduped.values()].sort((left, right) => {
106
+ const leftKey = (left.login || left.displayName).toLowerCase();
107
+ const rightKey = (right.login || right.displayName).toLowerCase();
108
+ return leftKey.localeCompare(rightKey);
109
+ });
110
+ }
111
+
112
+ export function formatContributor(contributor: Contributor): string {
113
+ if (contributor.login && contributor.url) {
114
+ return `[@${contributor.login}](${contributor.url})`;
115
+ }
116
+ if (contributor.login) {
117
+ return `@${contributor.login}`;
118
+ }
119
+ if (contributor.url) {
120
+ return `[${contributor.displayName}](${contributor.url})`;
121
+ }
122
+ return contributor.displayName;
123
+ }
124
+
125
+ function joinHumanList(values: string[]): string {
126
+ if (values.length === 0) return 'the contributors';
127
+ if (values.length === 1) return values[0]!;
128
+ if (values.length === 2) return `${values[0]} and ${values[1]}`;
129
+ return `${values.slice(0, -1).join(', ')}, and ${values.at(-1)}`;
130
+ }
131
+
132
+ export function renderContributorsSection(contributors: Contributor[]): string {
133
+ const normalized = normalizeContributors(contributors);
134
+ if (normalized.length === 0) {
135
+ return 'Thanks to the contributors who made this release possible.';
136
+ }
137
+ return `Thanks to ${joinHumanList(normalized.map((contributor) => formatContributor(contributor)))} for contributing to this release.`;
138
+ }
139
+
140
+ export function buildFullChangelogLine(repo: string, currentTag: string, previousTag?: string): string {
141
+ if (!repo) {
142
+ throw new Error('unable to determine GitHub repository; pass --repo or set GITHUB_REPOSITORY');
143
+ }
144
+ if (previousTag) {
145
+ return `**Full Changelog**: [\`${previousTag}...${currentTag}\`](https://github.com/${repo}/compare/${previousTag}...${currentTag})`;
146
+ }
147
+ return `**Full Changelog**: [\`${currentTag}\`](https://github.com/${repo}/releases/tag/${currentTag})`;
148
+ }
149
+
150
+ function replaceTitle(markdown: string, currentTag: string): string {
151
+ if (!/^#\s+/m.test(markdown)) {
152
+ throw new Error('release body template is missing a top-level title');
153
+ }
154
+ return markdown.replace(/^#\s+.*$/m, `# oh-my-codex ${currentTag}`);
155
+ }
156
+
157
+ function findSectionEnd(lines: string[], startIndex: number): number {
158
+ for (let index = startIndex + 1; index < lines.length; index += 1) {
159
+ if (/^##\s+/.test(lines[index] ?? '')) return index;
160
+ if (/^\*\*Full Changelog\*\*:/.test(lines[index] ?? '')) return index;
161
+ }
162
+ return lines.length;
163
+ }
164
+
165
+ export function replaceSectionBody(markdown: string, heading: string, body: string): string {
166
+ const lines = markdown.split(/\r?\n/);
167
+ const headingLine = `## ${heading}`;
168
+ const startIndex = lines.findIndex((line) => line.trim() === headingLine);
169
+ if (startIndex === -1) {
170
+ throw new Error(`release body template is missing section: ${headingLine}`);
171
+ }
172
+ const endIndex = findSectionEnd(lines, startIndex);
173
+ lines.splice(startIndex + 1, endIndex - startIndex - 1, '', ...body.split('\n'), '');
174
+ return `${lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()}\n`;
175
+ }
176
+
177
+ export function replaceFullChangelogLine(markdown: string, fullChangelogLine: string): string {
178
+ const lines = markdown.split(/\r?\n/);
179
+ const lineIndex = lines.findIndex((line) => /^\*\*Full Changelog\*\*:/.test(line));
180
+ if (lineIndex === -1) {
181
+ throw new Error('release body template is missing the Full Changelog line');
182
+ }
183
+ lines[lineIndex] = fullChangelogLine;
184
+ return `${lines.join('\n').trimEnd()}\n`;
185
+ }
186
+
187
+ function parseShortlogLine(line: string): Contributor | undefined {
188
+ const match = line.match(/^\s*\d+\s+(.+?)(?:\s+<[^>]+>)?$/);
189
+ if (!match) return undefined;
190
+ const displayName = match[1]?.trim();
191
+ if (!displayName) return undefined;
192
+ return { displayName };
193
+ }
194
+
195
+ export function getGitContributors(cwd: string, currentTag: string, previousTag?: string): Contributor[] {
196
+ const range = previousTag ? `${previousTag}..${currentTag}` : currentTag;
197
+ const shortlog = runGit(['shortlog', '-sne', range], cwd, true);
198
+ if (!shortlog) return [];
199
+ return normalizeContributors(shortlog.split(/\r?\n/).map((line) => parseShortlogLine(line)).filter((value): value is Contributor => Boolean(value)));
200
+ }
201
+
202
+ export async function getGitHubCompareContributors(
203
+ repo: string,
204
+ currentTag: string,
205
+ previousTag: string,
206
+ githubToken: string,
207
+ fetchImpl: typeof fetch = fetch,
208
+ ): Promise<Contributor[]> {
209
+ const response = await fetchImpl(`https://api.github.com/repos/${repo}/compare/${previousTag}...${currentTag}`, {
210
+ headers: {
211
+ Accept: 'application/vnd.github+json',
212
+ Authorization: `Bearer ${githubToken}`,
213
+ 'User-Agent': 'oh-my-codex-release-body-generator',
214
+ 'X-GitHub-Api-Version': '2022-11-28',
215
+ },
216
+ });
217
+ if (!response.ok) {
218
+ throw new Error(`GitHub compare API failed (${response.status})`);
219
+ }
220
+ const payload = await response.json() as CompareResponse;
221
+ const contributors = (payload.commits ?? []).map((commit) => {
222
+ if (commit.author?.login) {
223
+ return {
224
+ displayName: `@${commit.author.login}`,
225
+ login: commit.author.login,
226
+ ...(commit.author.html_url ? { url: commit.author.html_url } : {}),
227
+ } satisfies Contributor;
228
+ }
229
+ const name = commit.commit?.author?.name?.trim();
230
+ return name ? { displayName: name } satisfies Contributor : undefined;
231
+ }).filter((value): value is Contributor => Boolean(value));
232
+ return normalizeContributors(contributors);
233
+ }
234
+
235
+ export async function resolveContributors(options: {
236
+ cwd: string;
237
+ repo?: string;
238
+ currentTag: string;
239
+ previousTag?: string;
240
+ githubToken?: string;
241
+ }): Promise<Contributor[]> {
242
+ const { cwd, repo, currentTag, previousTag, githubToken } = options;
243
+ if (repo && previousTag && githubToken) {
244
+ try {
245
+ return await getGitHubCompareContributors(repo, currentTag, previousTag, githubToken);
246
+ } catch (error) {
247
+ console.error(`[generate-release-body] falling back to git shortlog: ${error instanceof Error ? error.message : String(error)}`);
248
+ }
249
+ }
250
+ return getGitContributors(cwd, currentTag, previousTag);
251
+ }
252
+
253
+ export async function generateReleaseBody(options: GenerateReleaseBodyOptions): Promise<string> {
254
+ const cwd = resolve(options.cwd ?? process.cwd());
255
+ const templatePath = resolve(cwd, options.templatePath);
256
+ const outPath = resolve(cwd, options.outPath);
257
+ const currentTag = resolveCurrentTag(cwd, options.currentTag);
258
+ const previousTag = resolvePreviousTag(cwd, currentTag, options.previousTag);
259
+ const repo = options.repo || process.env.GITHUB_REPOSITORY || resolveRepositoryFromRemote(cwd);
260
+ const contributors = await resolveContributors({
261
+ cwd,
262
+ repo,
263
+ currentTag,
264
+ previousTag,
265
+ githubToken: options.githubToken || process.env.GITHUB_TOKEN,
266
+ });
267
+
268
+ let markdown = readFileSync(templatePath, 'utf-8');
269
+ markdown = replaceTitle(markdown, currentTag);
270
+ markdown = replaceSectionBody(markdown, 'Contributors', renderContributorsSection(contributors));
271
+ markdown = replaceFullChangelogLine(markdown, buildFullChangelogLine(repo || '', currentTag, previousTag));
272
+ writeFileSync(outPath, markdown);
273
+ return markdown;
274
+ }
275
+
276
+ async function main(): Promise<void> {
277
+ const templatePath = arg('--template');
278
+ const outPath = arg('--out');
279
+ if (!templatePath || !outPath) usage();
280
+ await generateReleaseBody({
281
+ templatePath,
282
+ outPath,
283
+ currentTag: arg('--current-tag'),
284
+ previousTag: arg('--previous-tag'),
285
+ repo: arg('--repo'),
286
+ });
287
+ console.log(resolve(process.cwd(), outPath));
288
+ }
289
+
290
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
291
+ main().catch((error) => {
292
+ console.error(error instanceof Error ? error.message : String(error));
293
+ process.exit(1);
294
+ });
295
+ }