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
@@ -19,7 +19,7 @@ import {
19
19
  writeTeamPhase,
20
20
  } from "../team/state.js";
21
21
  import { omxNotepadPath, omxProjectMemoryPath } from "../utils/paths.js";
22
- import { getStateFilePath } from "../mcp/state-paths.js";
22
+ import { getStateFilePath, getStatePath } from "../mcp/state-paths.js";
23
23
  import {
24
24
  detectKeywords,
25
25
  detectPrimaryKeyword,
@@ -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"
@@ -387,7 +386,7 @@ async function buildSessionStartContext(
387
386
 
388
387
  const modeSummaries: string[] = [];
389
388
  for (const mode of ["ralph", "autopilot", "ultrawork", "ultraqa", "ralplan", "deep-interview", "team"] as const) {
390
- const state = await readModeState(mode, cwd);
389
+ const state = await readJsonIfExists(getStatePath(mode, cwd, sessionId));
391
390
  if (state?.active !== true || !isNonTerminalPhase(state.current_phase)) continue;
392
391
  if (mode === "team") {
393
392
  const teamName = safeString(state.team_name).trim();
@@ -435,9 +434,22 @@ async function buildSessionStartContext(
435
434
  if (existsSync(omxNotepadPath(cwd))) {
436
435
  try {
437
436
  const notepad = await readFile(omxNotepadPath(cwd), "utf-8");
438
- const compact = notepad.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).slice(0, 3).join(" ");
439
- if (compact) {
440
- sections.push(`[Notepad]\n- ${compact.slice(0, 220)}`);
437
+ const header = "## PRIORITY";
438
+ const idx = notepad.indexOf(header);
439
+ if (idx >= 0) {
440
+ const nextHeader = notepad.indexOf("\n## ", idx + header.length);
441
+ const section = (
442
+ nextHeader < 0
443
+ ? notepad.slice(idx + header.length)
444
+ : notepad.slice(idx + header.length, nextHeader)
445
+ )
446
+ .split(/\r?\n/)
447
+ .map((line) => line.trim())
448
+ .filter(Boolean)
449
+ .join(" ");
450
+ if (section) {
451
+ sections.push(`[Priority notes]\n- ${section.slice(0, 220)}`);
452
+ }
441
453
  }
442
454
  } catch {
443
455
  // best effort only
@@ -964,29 +976,6 @@ function readNativeStopSessionKey(
964
976
  return resolveRepeatableStopSessionId(payload, canonicalSessionId) || readPayloadThreadId(payload) || "global";
965
977
  }
966
978
 
967
- function hasManagedStopSessionEnv(sessionIds: string[]): boolean {
968
- const envSessionId = safeString(process.env.OMX_SESSION_ID).trim();
969
- if (!envSessionId) return false;
970
- return sessionIds.length === 0 || sessionIds.includes(envSessionId);
971
- }
972
-
973
- async function hasManagedStopContext(
974
- cwd: string,
975
- payload: CodexHookPayload,
976
- canonicalSessionId: string,
977
- ): Promise<boolean> {
978
- if (hasTeamWorkerContext()) return true;
979
-
980
- const sessionIds = [...new Set([
981
- canonicalSessionId,
982
- readPayloadSessionId(payload),
983
- ].map((value) => safeString(value).trim()).filter(Boolean))];
984
-
985
- if (hasManagedStopSessionEnv(sessionIds)) return true;
986
-
987
- return sessionIds.some((sessionId) => existsSync(sessionModelInstructionsPath(cwd, sessionId)));
988
- }
989
-
990
979
  function readPreviousNativeStopSignature(
991
980
  state: Record<string, unknown>,
992
981
  sessionKey: string,
@@ -1025,10 +1014,11 @@ async function maybeReturnRepeatableStopOutput(
1025
1014
  signature: string,
1026
1015
  output: Record<string, unknown> | null,
1027
1016
  canonicalSessionId?: string,
1017
+ options: { allowRepeatDuringStopHook?: boolean } = {},
1028
1018
  ): Promise<Record<string, unknown> | null> {
1029
1019
  if (!output) return null;
1030
1020
  const stopHookActive = payload.stop_hook_active === true || payload.stopHookActive === true;
1031
- if (stopHookActive) {
1021
+ if (stopHookActive && options.allowRepeatDuringStopHook !== true) {
1032
1022
  const state = await readJsonIfExists(join(stateDir, NATIVE_STOP_STATE_FILE)) ?? {};
1033
1023
  const previousSignature = readPreviousNativeStopSignature(
1034
1024
  state,
@@ -1042,6 +1032,24 @@ async function maybeReturnRepeatableStopOutput(
1042
1032
  return output;
1043
1033
  }
1044
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
+
1045
1053
  async function findCanonicalActiveTeamForSession(
1046
1054
  cwd: string,
1047
1055
  sessionId: string,
@@ -1274,20 +1282,45 @@ async function buildStopHookOutput(
1274
1282
  const canonicalSessionId = await resolveInternalSessionIdForPayload(cwd, sessionId);
1275
1283
  const threadId = readPayloadThreadId(payload);
1276
1284
  const ralphState = await readActiveRalphState(stateDir, canonicalSessionId);
1277
- const stopHookActive = payload.stop_hook_active === true || payload.stopHookActive === true;
1278
- const managedStopContext = await hasManagedStopContext(cwd, payload, canonicalSessionId);
1279
1285
  if (!ralphState) {
1280
1286
  const teamWorkerOutput = await buildTeamWorkerStopOutput(cwd);
1281
- if (!stopHookActive && hasTeamWorkerContext()) return teamWorkerOutput;
1287
+ if (hasTeamWorkerContext() && teamWorkerOutput) return teamWorkerOutput;
1282
1288
 
1283
1289
  const autopilotOutput = await buildModeBasedStopOutput("autopilot", cwd, canonicalSessionId);
1284
- 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
+ }
1285
1300
 
1286
1301
  const ultraworkOutput = await buildModeBasedStopOutput("ultrawork", cwd, canonicalSessionId);
1287
- 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
+ }
1288
1312
 
1289
1313
  const ultraqaOutput = await buildModeBasedStopOutput("ultraqa", cwd, canonicalSessionId);
1290
- 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
+ }
1291
1324
 
1292
1325
  const releaseReadinessFinalizeResult = await maybeBuildReleaseReadinessFinalizeStopOutput(
1293
1326
  payload,
@@ -1299,16 +1332,11 @@ async function buildStopHookOutput(
1299
1332
 
1300
1333
  const teamOutput = await buildTeamStopOutput(cwd, canonicalSessionId);
1301
1334
  if (teamOutput) {
1302
- const teamSignature = buildRepeatableStopSignature(
1335
+ return await returnPersistentStopBlock(
1303
1336
  payload,
1337
+ stateDir,
1304
1338
  "team-stop",
1305
1339
  safeString(teamOutput.stopReason),
1306
- canonicalSessionId,
1307
- );
1308
- return await maybeReturnRepeatableStopOutput(
1309
- payload,
1310
- stateDir,
1311
- teamSignature,
1312
1340
  teamOutput,
1313
1341
  canonicalSessionId,
1314
1342
  );
@@ -1321,16 +1349,11 @@ async function buildStopHookOutput(
1321
1349
  canonicalTeam.teamName,
1322
1350
  canonicalTeam.phase,
1323
1351
  );
1324
- const canonicalTeamSignature = buildRepeatableStopSignature(
1352
+ const repeatedCanonicalTeamOutput = await returnPersistentStopBlock(
1325
1353
  payload,
1354
+ stateDir,
1326
1355
  "team-stop",
1327
1356
  `${canonicalTeam.teamName}|${canonicalTeam.phase}`,
1328
- canonicalSessionId,
1329
- );
1330
- const repeatedCanonicalTeamOutput = await maybeReturnRepeatableStopOutput(
1331
- payload,
1332
- stateDir,
1333
- canonicalTeamSignature,
1334
1357
  canonicalTeamOutput,
1335
1358
  canonicalSessionId,
1336
1359
  );
@@ -1338,12 +1361,18 @@ async function buildStopHookOutput(
1338
1361
  }
1339
1362
 
1340
1363
  const skillOutput = await buildSkillStopOutput(cwd, canonicalSessionId, threadId);
1341
- 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
+ }
1342
1374
  }
1343
1375
 
1344
- if (!managedStopContext) {
1345
- return null;
1346
- }
1347
1376
 
1348
1377
  const lastAssistantMessage = safeString(
1349
1378
  payload.last_assistant_message ?? payload.lastAssistantMessage,
@@ -1356,10 +1385,11 @@ async function buildStopHookOutput(
1356
1385
  && detectNativeStopStallPattern(lastAssistantMessage, autoNudgeConfig.patterns, autoNudgePhase)
1357
1386
  ) {
1358
1387
  const effectiveResponse = resolveEffectiveAutoNudgeResponse(autoNudgeConfig.response);
1359
- return await maybeReturnRepeatableStopOutput(
1388
+ return await returnPersistentStopBlock(
1360
1389
  payload,
1361
1390
  stateDir,
1362
- buildRepeatableStopSignature(payload, "auto-nudge", lastAssistantMessage, canonicalSessionId),
1391
+ "auto-nudge",
1392
+ lastAssistantMessage,
1363
1393
  {
1364
1394
  decision: "block",
1365
1395
  reason: effectiveResponse,
@@ -1374,21 +1404,24 @@ async function buildStopHookOutput(
1374
1404
  return null;
1375
1405
  }
1376
1406
 
1377
- if (stopHookActive) {
1378
- return null;
1379
- }
1380
-
1381
1407
  const currentPhase = safeString(ralphState?.current_phase).trim() || "executing";
1382
1408
  const stopReason = `ralph_${currentPhase}`;
1383
1409
  const systemMessage =
1384
1410
  `OMX Ralph is still active (phase: ${currentPhase}); continue the task and gather fresh verification evidence before stopping.`;
1385
1411
 
1386
- return {
1387
- decision: "block",
1388
- reason: systemMessage,
1389
- stopReason,
1390
- systemMessage,
1391
- };
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
+ );
1392
1425
  }
1393
1426
 
1394
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
+ }