session-orchestrator 4.0.0 → 4.1.0

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 (123) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +3 -2
  4. package/.codex-plugin/skills/architecture/SKILL.md +20 -0
  5. package/.codex-plugin/skills/autopilot/SKILL.md +21 -0
  6. package/.codex-plugin/skills/autopilot/agents/openai.yaml +5 -0
  7. package/.codex-plugin/skills/bootstrap/SKILL.md +22 -0
  8. package/.codex-plugin/skills/bootstrap/agents/openai.yaml +5 -0
  9. package/.codex-plugin/skills/brainstorm/SKILL.md +22 -0
  10. package/.codex-plugin/skills/brainstorm/agents/openai.yaml +5 -0
  11. package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +17 -0
  12. package/.codex-plugin/skills/close/SKILL.md +21 -0
  13. package/.codex-plugin/skills/close/agents/openai.yaml +5 -0
  14. package/.codex-plugin/skills/convergence-monitoring/SKILL.md +24 -0
  15. package/.codex-plugin/skills/debug/SKILL.md +21 -0
  16. package/.codex-plugin/skills/debug/agents/openai.yaml +5 -0
  17. package/.codex-plugin/skills/discovery/SKILL.md +21 -0
  18. package/.codex-plugin/skills/discovery/agents/openai.yaml +5 -0
  19. package/.codex-plugin/skills/dispatcher/SKILL.md +21 -0
  20. package/.codex-plugin/skills/dispatcher/agents/openai.yaml +5 -0
  21. package/.codex-plugin/skills/docs-orchestrator/SKILL.md +20 -0
  22. package/.codex-plugin/skills/ecosystem-health/SKILL.md +22 -0
  23. package/.codex-plugin/skills/eli5/SKILL.md +21 -0
  24. package/.codex-plugin/skills/eli5/agents/openai.yaml +5 -0
  25. package/.codex-plugin/skills/eval/SKILL.md +21 -0
  26. package/.codex-plugin/skills/eval/agents/openai.yaml +5 -0
  27. package/.codex-plugin/skills/evolve/SKILL.md +21 -0
  28. package/.codex-plugin/skills/evolve/agents/openai.yaml +5 -0
  29. package/.codex-plugin/skills/frontmatter-guard/SKILL.md +17 -0
  30. package/.codex-plugin/skills/gitlab-ops/SKILL.md +22 -0
  31. package/.codex-plugin/skills/gitlab-portfolio/SKILL.md +17 -0
  32. package/.codex-plugin/skills/go/SKILL.md +22 -0
  33. package/.codex-plugin/skills/go/agents/openai.yaml +5 -0
  34. package/.codex-plugin/skills/grill/SKILL.md +21 -0
  35. package/.codex-plugin/skills/grill/agents/openai.yaml +5 -0
  36. package/.codex-plugin/skills/harness-audit/SKILL.md +19 -0
  37. package/.codex-plugin/skills/harness-audit/agents/openai.yaml +5 -0
  38. package/.codex-plugin/skills/hook-development/SKILL.md +17 -0
  39. package/.codex-plugin/skills/mcp-builder/SKILL.md +17 -0
  40. package/.codex-plugin/skills/memory-cleanup/SKILL.md +21 -0
  41. package/.codex-plugin/skills/memory-cleanup/agents/openai.yaml +5 -0
  42. package/.codex-plugin/skills/mode-selector/SKILL.md +19 -0
  43. package/.codex-plugin/skills/npm-publish/SKILL.md +18 -0
  44. package/.codex-plugin/skills/peekaboo-driver/SKILL.md +20 -0
  45. package/.codex-plugin/skills/persona-panel/SKILL.md +22 -0
  46. package/.codex-plugin/skills/persona-panel/agents/openai.yaml +5 -0
  47. package/.codex-plugin/skills/plan/SKILL.md +22 -0
  48. package/.codex-plugin/skills/plan/agents/openai.yaml +5 -0
  49. package/.codex-plugin/skills/playwright-driver/SKILL.md +22 -0
  50. package/.codex-plugin/skills/portfolio/SKILL.md +21 -0
  51. package/.codex-plugin/skills/portfolio/agents/openai.yaml +5 -0
  52. package/.codex-plugin/skills/quality-gates/SKILL.md +22 -0
  53. package/.codex-plugin/skills/reconcile/SKILL.md +21 -0
  54. package/.codex-plugin/skills/reconcile/agents/openai.yaml +5 -0
  55. package/.codex-plugin/skills/release/SKILL.md +22 -0
  56. package/.codex-plugin/skills/release/agents/openai.yaml +5 -0
  57. package/.codex-plugin/skills/remote-offload/SKILL.md +22 -0
  58. package/.codex-plugin/skills/repo-audit/SKILL.md +19 -0
  59. package/.codex-plugin/skills/repo-audit/agents/openai.yaml +5 -0
  60. package/.codex-plugin/skills/session/SKILL.md +21 -0
  61. package/.codex-plugin/skills/session/agents/openai.yaml +5 -0
  62. package/.codex-plugin/skills/session-end/SKILL.md +22 -0
  63. package/.codex-plugin/skills/session-plan/SKILL.md +22 -0
  64. package/.codex-plugin/skills/session-start/SKILL.md +22 -0
  65. package/.codex-plugin/skills/spinout/SKILL.md +21 -0
  66. package/.codex-plugin/skills/spinout/agents/openai.yaml +5 -0
  67. package/.codex-plugin/skills/sunset-review/SKILL.md +21 -0
  68. package/.codex-plugin/skills/sunset-review/agents/openai.yaml +5 -0
  69. package/.codex-plugin/skills/templates-ack/SKILL.md +21 -0
  70. package/.codex-plugin/skills/templates-ack/agents/openai.yaml +5 -0
  71. package/.codex-plugin/skills/test/SKILL.md +21 -0
  72. package/.codex-plugin/skills/test/agents/openai.yaml +5 -0
  73. package/.codex-plugin/skills/test-runner/SKILL.md +22 -0
  74. package/.codex-plugin/skills/tmux-layout/SKILL.md +23 -0
  75. package/.codex-plugin/skills/using-orchestrator/SKILL.md +19 -0
  76. package/.codex-plugin/skills/vault-mirror/SKILL.md +17 -0
  77. package/.codex-plugin/skills/vault-sync/SKILL.md +17 -0
  78. package/.codex-plugin/skills/wave-executor/SKILL.md +22 -0
  79. package/.codex-plugin/skills/write-executable-plan/SKILL.md +24 -0
  80. package/{plugin.json → .cursor-plugin/plugin.json} +5 -2
  81. package/CHANGELOG.md +213 -1
  82. package/README.md +70 -58
  83. package/commands/release.md +4 -4
  84. package/docs/codex-setup.md +43 -9
  85. package/docs/components.md +3 -2
  86. package/docs/instruction-delivery.md +12 -5
  87. package/docs/migration-v4.md +33 -9
  88. package/hooks/_lib/hook-import-set.json +4 -3
  89. package/hooks/hooks-codex.json +1 -1
  90. package/hooks/hooks.json +1 -1
  91. package/hooks/on-stop.mjs +25 -4
  92. package/package.json +2 -2
  93. package/scripts/ci/assert-coverage-green.mjs +100 -0
  94. package/scripts/generate-codex-skills.mjs +246 -0
  95. package/scripts/generate-hook-import-set.mjs +51 -8
  96. package/scripts/lib/codex/plugin-contract.mjs +6 -0
  97. package/scripts/lib/config/host-paths.mjs +20 -4
  98. package/scripts/lib/events.mjs +3 -3
  99. package/scripts/lib/gates/gate-full.mjs +7 -3
  100. package/scripts/lib/owner-config-banner.mjs +7 -9
  101. package/scripts/lib/owner-yaml.mjs +8 -1
  102. package/scripts/lib/plugin-update-banner.mjs +10 -2
  103. package/scripts/lib/project-hygiene.mjs +182 -6
  104. package/scripts/lib/reconcile/engine.mjs +38 -7
  105. package/scripts/lib/session-identity/own-session.mjs +24 -13
  106. package/scripts/lib/session-schema/constants.mjs +38 -11
  107. package/scripts/lib/session-start-probes.mjs +12 -0
  108. package/scripts/lib/telemetry/schema.mjs +39 -18
  109. package/scripts/lib/telemetry-flush-health-banner.mjs +211 -0
  110. package/scripts/lib/validate/check-codex-skills.mjs +191 -0
  111. package/scripts/lib/validate/check-owner-leakage.mjs +107 -62
  112. package/scripts/lib/validate/check-skill-links.mjs +37 -7
  113. package/scripts/lib/validate/check-test-git-config-target.mjs +192 -12
  114. package/scripts/lib/validate/check-unwired-features.mjs +163 -13
  115. package/scripts/lib/validate/confidential-names.mjs +95 -30
  116. package/scripts/lib/validate/repo-files.mjs +48 -14
  117. package/scripts/lib/vault-mirror/render-sessions.mjs +8 -1
  118. package/scripts/release.mjs +141 -29
  119. package/scripts/site-numbers.mjs +344 -8
  120. package/scripts/validate-plugin.mjs +3 -0
  121. package/skills/session-start/SKILL.md +2 -2
  122. package/skills/session-start/references/phase-4-ssot-environment-check.md +5 -0
  123. package/skills/vault-sync/SKILL.md +10 -0
@@ -121,12 +121,10 @@ export const SURFACES = [
121
121
  patterns: [/"version":\s*"(\d+\.\d+\.\d+)"/],
122
122
  },
123
123
  {
124
- // Root `plugin.json` the agent-plugins.org 1.0.0 manifest, added in 4.0.0. It is a
125
- // FOURTH manifest carrying the version, and it was missing from this table on the 4.0.0
126
- // cut: `--set-version` bumped the other three and `validate-plugin` then failed with
127
- // "plugin.json version '3.24.0' != package.json version '4.0.0'". Exactly the incident
128
- // class the table exists for, caught one gate later than it should have been.
129
- file: 'plugin.json',
124
+ // Cursor's native manifest replaces the root Agent Plugins manifest so
125
+ // Codex can load its own component paths and cache version independently.
126
+ // It remains a required version surface for every release.
127
+ file: '.cursor-plugin/plugin.json',
130
128
  patterns: [/"version":\s*"(\d+\.\d+\.\d+)"/],
131
129
  },
132
130
  {
@@ -191,6 +189,14 @@ export const SURFACES = [
191
189
  patterns: [/data-metric="version"[^>]*>(\d+\.\d+\.\d+)</g],
192
190
  checkOnly: true,
193
191
  },
192
+ {
193
+ // The German landing page (2026-09-07 redesign) mirrors the EN metric cells
194
+ // one-to-one; same single writer (scripts/site-numbers.mjs --write), so it is
195
+ // checkOnly for the same reason as site/index.html above.
196
+ file: 'site/de/index.html',
197
+ patterns: [/data-metric="version"[^>]*>(\d+\.\d+\.\d+)</g],
198
+ checkOnly: true,
199
+ },
194
200
  {
195
201
  file: 'site/llms.txt',
196
202
  patterns: [/Version:\s*(\d+\.\d+\.\d+)/],
@@ -362,7 +368,18 @@ const INTENTIONAL_TEST_ASSET_PATHS = new Set([
362
368
  // tests/scripts/site-numbers.test.mjs, which forbids ANY vX.Y.Z and the current
363
369
  // package version outside a `data-metric` cell on EVERY shipped page, and
364
370
  // exempts exactly the lines marked `site-numbers:historical`.
365
- export const HISTORY_ALLOWLIST = /^(CHANGELOG\.md|README\.md|docs\/|tests\/|skills\/npm-publish\/|scripts\/release\.mjs|\.orchestrator\/|site\/leaderboard\.json|site\/guide\/index\.html|commands\/release\.md)/;
371
+ // 4.0.1 (measured 2026-09-07): the 4.0.0 sweep would have flagged five files whose only literal
372
+ // is PROSE HISTORY of the major ("moved out of agents/ in 4.0.0", "removed in 4.0.0",
373
+ // "releases 4.0.0 would have blocked on itself") — CLAUDE.md, its generated twin AGENTS.md,
374
+ // CONTRIBUTING.md, NOTICE and .husky/pre-push. None of them is a version SURFACE (no
375
+ // `"version":`, badge or `vX.Y.Z` form), so they join the history allowlist rather than being
376
+ // reworded to dodge the sweep.
377
+ // Four more prose-history files surfaced once the detail line stopped truncating at five hits
378
+ // (same 2026-09-07 sweep): site/llms-full.txt ("The v4.0.0 release REMOVES public surfaces" — its
379
+ // version SURFACE is checked separately by the SURFACES row, so the sweep on it is redundant),
380
+ // skills/architecture/references/domain-model.md ("Merged here in v4.0.0"), skills/autopilot/SKILL.md
381
+ // ("4.0.0 — see docs/migration-v4.md") and templates/_shared/journey-manifest.md ("Retired … in 4.0.0").
382
+ export const HISTORY_ALLOWLIST = /^(CHANGELOG\.md|README\.md|CLAUDE\.md|AGENTS\.md|CONTRIBUTING\.md|NOTICE|\.husky\/pre-push|docs\/|tests\/|skills\/npm-publish\/|skills\/architecture\/references\/domain-model\.md|skills\/autopilot\/SKILL\.md|templates\/_shared\/journey-manifest\.md|scripts\/release\.mjs|\.orchestrator\/|site\/leaderboard\.json|site\/guide\/index\.html|site\/llms-full\.txt|commands\/release\.md)/;
366
383
 
367
384
  /** Pure check over packed-entry lines. Returns violations: {name, line}[]. */
368
385
  export function checkLeakage(lines) {
@@ -443,28 +460,95 @@ export const MIN_PACKED_ENTRIES = 400;
443
460
  // ---------------------------------------------------------------------------
444
461
 
445
462
  /**
446
- * Is EVERY occurrence of `literal` on this line a dependency range (`^X.Y.Z` / `~X.Y.Z`)?
463
+ * A regex matching `literal` as a VERSION TOKEN, not as a substring.
464
+ *
465
+ * THE BUG (measured 2026-09-07, mid-release): the sweep matched the previous tag `4.0.0`
466
+ * inside `>=24.0.0` — `package.json`'s own engines field and a `scripts/lib/` string that
467
+ * quotes it — so a release could not be cut without either rewording an engines constraint or
468
+ * widening the allowlist over two files that carry no version surface at all. The boundary is
469
+ * therefore part of WHAT IS SWEPT FOR, not an allowlist row: `24.0.0`, `14.0.0` and `4.0.0.1`
470
+ * are different literals, at every path, forever.
471
+ *
472
+ * @param {string} literal — the previous release version
473
+ * @returns {RegExp} global regex; `4.0.0` matches only when not preceded by `[0-9.]` and not
474
+ * continued by a further numeric component (`(?!\.?[0-9])`).
475
+ */
476
+ function versionTokenRegex(literal) {
477
+ return new RegExp(`(?<![0-9.])${literal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?!\\.?[0-9])`, 'g');
478
+ }
479
+
480
+ /**
481
+ * Is EVERY token occurrence of `literal` on this line a dependency range (`^X.Y.Z` / `~X.Y.Z`)?
447
482
  *
448
483
  * One bare occurrence anywhere on the line is enough to call the whole line drift — a comment
449
- * that also happens to mention a ranged dep must not be excused by that mention.
484
+ * that also happens to mention a ranged dep must not be excused by that mention. Occurrences are
485
+ * counted with {@link versionTokenRegex}, so `>=24.0.0` is not an occurrence of `4.0.0` here
486
+ * either — otherwise a line pinning `^4.0.0` beside an engines constraint would read as drift.
450
487
  *
451
488
  * @param {string} content — the matching line's text
452
- * @param {string} literal — the previous release version, matched literally
489
+ * @param {string} literal — the previous release version, matched as a token
453
490
  * @returns {boolean}
454
491
  */
455
492
  export function isDependencyRangeOnly(content, literal) {
456
- let from = 0;
493
+ const re = versionTokenRegex(literal);
457
494
  let seen = 0;
458
- for (;;) {
459
- const at = content.indexOf(literal, from);
460
- if (at === -1) break;
495
+ for (let m = re.exec(content); m; m = re.exec(content)) {
461
496
  seen += 1;
462
- if (!(at > 0 && (content[at - 1] === '^' || content[at - 1] === '~'))) return false;
463
- from = at + literal.length;
497
+ const before = m.index > 0 ? content[m.index - 1] : '';
498
+ if (before !== '^' && before !== '~') return false;
464
499
  }
465
500
  return seen > 0;
466
501
  }
467
502
 
503
+ /** Lockfiles whose dependency entries are third-party history, never our surface. */
504
+ const LOCKFILE_BASENAMES = new Set(['package-lock.json', 'npm-shrinkwrap.json']);
505
+
506
+ /** Code files in which a `//`, `*`, `/*` or `#` line is comment prose, never a version surface. */
507
+ const CODE_COMMENT_EXTENSIONS = new Set(['.mjs', '.js', '.cjs', '.ts', '.sh']);
508
+
509
+ /**
510
+ * Is this `path:line:content` row version HISTORY rather than a stale surface?
511
+ *
512
+ * Three classes, all measured on the 4.0.1 cut (2026-09-07) as FALSE POSITIVES of the raw
513
+ * substring sweep, and all expressed as PREDICATES for the same reason the range carve-out
514
+ * above is one: a per-path allowlist row fixes the instance and leaves the class open.
515
+ *
516
+ * 1. **Dependency range** — `^X.Y.Z` / `~X.Y.Z` (see {@link isDependencyRangeOnly}).
517
+ * 2. **Lockfile dependency entry** — `package-lock.json` carried 72 hits for `4.0.0`, every one
518
+ * a third-party package version or an engines range. Our OWN entry there is still swept: the
519
+ * root package's `"version"` line, which npm writes in the `packages[""]` record at the top
520
+ * of the file. NAMED CEILING (BV-004): "at the top" is read as `line <= 20`, which covers
521
+ * every lockfileVersion-3 file npm writes today (the root record starts at line 5). Revisit
522
+ * trigger: a lockfile whose root `"version"` sits below line 20 — then key on the enclosing
523
+ * JSON path instead of the line number.
524
+ * 3. **Comment prose in a code file** — `// (pre-4.0.0 checkouts, …)` and a `* since 4.0.0`
525
+ * docblock line. No SURFACES pattern is ever a comment (every one is `"version": "X.Y.Z"`,
526
+ * `vX.Y.Z` or a badge), so excusing comment lines cannot mask a stale surface.
527
+ *
528
+ * @param {string} file — repo-relative path
529
+ * @param {number} line — 1-based line number
530
+ * @param {string} content — the matching line's text
531
+ * @param {string} prevTag — the previous release literal being swept for
532
+ * @returns {boolean} true = history, skip the row
533
+ */
534
+ function isHistoryRow(file, line, content, prevTag) {
535
+ if (!versionTokenRegex(prevTag).test(content)) return true;
536
+ if (isDependencyRangeOnly(content, prevTag)) return true;
537
+ const base = file.split('/').pop();
538
+ if (LOCKFILE_BASENAMES.has(base)) {
539
+ const isRootVersionLine = line <= 20 && new RegExp(`"version":\\s*"${prevTag.replace(/\./g, '\\.')}"`).test(content);
540
+ return !isRootVersionLine;
541
+ }
542
+ const dot = base.lastIndexOf('.');
543
+ const ext = dot === -1 ? '' : base.slice(dot);
544
+ if (CODE_COMMENT_EXTENSIONS.has(ext)) {
545
+ const trimmed = content.trim();
546
+ if (trimmed.startsWith('//') || trimmed.startsWith('/*') || trimmed.startsWith('*')) return true;
547
+ if (ext === '.sh' && trimmed.startsWith('#')) return true;
548
+ }
549
+ return false;
550
+ }
551
+
468
552
  /**
469
553
  * Drift sweep verdict over a grep-shaped result (`-l` file list or `-n` line hits).
470
554
  *
@@ -488,6 +572,13 @@ export function isDependencyRangeOnly(content, literal) {
488
572
  * written as one (see SURFACES above: every pattern is an exact `"version": "X.Y.Z"`, `vX.Y.Z`
489
573
  * or badge form), so the predicate cannot mask a stale surface.
490
574
  *
575
+ * THREE FURTHER CLASSES are history for the same reason, all measured 2026-09-07 mid-release
576
+ * and all decided by {@link isHistoryRow}, never by an allowlist row: a literal that is only a
577
+ * SUBSTRING of a longer version (`4.0.0` inside `>=24.0.0`), a `package-lock.json` /
578
+ * `npm-shrinkwrap.json` row that is not the root package's own `"version"` line (72 of the 72
579
+ * lockfile hits on that cut were third-party), and COMMENT PROSE in a code file
580
+ * (`// (pre-4.0.0 checkouts, …)`). See that function for each one's ceiling.
581
+ *
491
582
  * Accepts BOTH `git grep` output shapes. A bare `path` (from `-l`) carries no content and is
492
583
  * therefore always a hit — the fail-closed reading, unchanged, and the shape {@link collectDriftHits}
493
584
  * emits for a file it could not READ. `path:line:content` (from `-n`)
@@ -510,13 +601,13 @@ export function evaluateDriftSweep(grep, prevTag, allowlist) {
510
601
  const withContent = row.match(/^(.+?):(\d+):(.*)$/);
511
602
  const file = withContent ? withContent[1] : row;
512
603
  if (allowlist.test(file)) continue;
513
- if (withContent && isDependencyRangeOnly(withContent[3], prevTag)) continue;
604
+ if (withContent && isHistoryRow(file, Number(withContent[2]), withContent[3], prevTag)) continue;
514
605
  if (!hits.includes(file)) hits.push(file);
515
606
  }
516
607
  return {
517
608
  ok: hits.length === 0,
518
609
  detail: hits.length
519
- ? `still carry ${prevTag}: ${hits.slice(0, 5).join(', ')}`
610
+ ? `still carry ${prevTag} (${hits.length} file(s)): ${hits.slice(0, 5).join(', ')}${hits.length > 5 ? ', …' : ''}`
520
611
  : `no file outside the allowlist still carries ${prevTag} (tracked + untracked-not-ignored)`,
521
612
  };
522
613
  }
@@ -1429,10 +1520,15 @@ export function printPublishOutcome(outcome, target, io = {}) {
1429
1520
  log(` ${outcome.release.detail}.`);
1430
1521
  } else {
1431
1522
  error(`\nRECONCILIATION: ${outcome.release.detail}`);
1432
- if (outcome.release.state === 'create-failed') {
1433
- error(` Recover with: gh release create ${outcome.tag} --verify-tag --title ${outcome.tag} --notes-file <changelog excerpt>`);
1523
+ const recovery = outcome.release.recovery;
1524
+ if (recovery?.inspect) {
1525
+ error(` Inspect with: ${renderRecoveryCommand(recovery.inspect)}`);
1526
+ if (outcome.release.state === 'create-failed' && recovery.create) {
1527
+ error(` Recover with: ${renderRecoveryCommand(recovery.create)}`);
1528
+ error(' The notes file is retained for recovery; remove it after the release is reconciled.');
1529
+ }
1434
1530
  } else {
1435
- error(' Inspect `gh release view` and its authentication/network state before attempting any create.');
1531
+ error(' Resolve the GitHub repository identity before inspecting or creating the release.');
1436
1532
  }
1437
1533
  }
1438
1534
 
@@ -1462,6 +1558,11 @@ export function printPublishOutcome(outcome, target, io = {}) {
1462
1558
  return 0;
1463
1559
  }
1464
1560
 
1561
+ /** Render argv for a POSIX shell without interpreting paths as shell code. */
1562
+ function renderRecoveryCommand(argv) {
1563
+ return argv.map((arg) => /^[\w./:@=+-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", "'\\''")}'`).join(' ');
1564
+ }
1565
+
1465
1566
  /**
1466
1567
  * Create the GitHub release for `v<target>`, or confirm the existing one.
1467
1568
  *
@@ -1491,18 +1592,23 @@ export function printPublishOutcome(outcome, target, io = {}) {
1491
1592
  * @param {string} repoRoot
1492
1593
  * @param {string} target
1493
1594
  * @param {{runImpl?: Function, repoSpec?: string}} [deps] — injection seam for tests
1494
- * @returns {{ok: boolean, created: boolean, tag: string, state: 'exists'|'created'|'unknown'|'create-failed', detail: string, argv?: string[]}}
1595
+ * @returns {{ok: boolean, created: boolean, tag: string, state: 'exists'|'created'|'unknown'|'create-failed', detail: string, argv?: string[], recovery?: {inspect: string[], create?: string[]}}}
1495
1596
  */
1496
1597
  export function ensureGithubRelease(repoRoot, target, deps = {}) {
1497
1598
  const runImpl = deps.runImpl ?? run;
1498
1599
  const tag = `v${target}`;
1499
- const spec = deps.repoSpec ?? resolveRepoSpec({ repoRoot, vcs: 'github' });
1500
- // resolveRepoSpec returns undefined when it cannot auto-detect; its contract
1501
- // is that callers OMIT the flag rather than pass `-R undefined`.
1502
- const repoFlag = spec ? ['--repo', spec] : [];
1600
+ let recovery;
1503
1601
 
1504
1602
  try {
1505
- const existing = runImpl('gh', ['release', 'view', tag, ...repoFlag], { cwd: repoRoot });
1603
+ const spec = deps.repoSpec ?? resolveRepoSpec({ repoRoot, vcs: 'github' });
1604
+ if (typeof spec !== 'string' || !spec.trim()) {
1605
+ return { ok: false, created: false, tag, state: 'unknown', detail: 'GitHub repository identity could not be resolved' };
1606
+ }
1607
+ // Recovery must carry the same resolved identity as the real invocation.
1608
+ // An absent identity cannot safely fall back to the caller's ambient repo.
1609
+ const repoFlag = ['--repo', spec];
1610
+ recovery = { inspect: ['gh', 'release', 'view', tag, ...repoFlag] };
1611
+ const existing = runImpl(recovery.inspect[0], recovery.inspect.slice(1), { cwd: repoRoot });
1506
1612
  const viewOutput = `${existing.stdout || ''}\n${existing.stderr || ''}`.trim();
1507
1613
  if (existing.status === 0 && viewOutput) {
1508
1614
  return { ok: true, created: false, tag, state: 'exists', detail: `GitHub release ${tag} already exists — no-op` };
@@ -1516,6 +1622,7 @@ export function ensureGithubRelease(repoRoot, target, deps = {}) {
1516
1622
  created: false,
1517
1623
  tag,
1518
1624
  state: 'unknown',
1625
+ recovery,
1519
1626
  detail: `could not determine whether GitHub release ${tag} exists (gh release view exited ${existing.status}: ${viewOutput.slice(0, 300) || 'empty output'})`,
1520
1627
  };
1521
1628
  }
@@ -1523,26 +1630,31 @@ export function ensureGithubRelease(repoRoot, target, deps = {}) {
1523
1630
  const notesDir = mkdtempSync(join(tmpdir(), 'release-ghnotes-'));
1524
1631
  const notesFile = join(notesDir, 'notes.md');
1525
1632
  let argv;
1633
+ let retainNotes = false;
1526
1634
  try {
1527
1635
  writeFileSync(notesFile, `${changelogExcerpt(repoRoot, target)}\n`);
1528
1636
  argv = ['release', 'create', tag, ...repoFlag, '--verify-tag', '--title', tag, '--notes-file', notesFile];
1529
1637
  const created = runImpl('gh', argv, { cwd: repoRoot });
1530
1638
  if (created.status !== 0) {
1639
+ // A recovery argv pointing to a file deleted by finally is unusable.
1640
+ // Preserve only this failed-create excerpt; successful runs still clean up.
1641
+ retainNotes = true;
1531
1642
  return {
1532
1643
  ok: false,
1533
1644
  created: false,
1534
1645
  tag,
1535
1646
  state: 'create-failed',
1536
1647
  argv,
1648
+ recovery: { ...recovery, create: ['gh', ...argv] },
1537
1649
  detail: `gh release create exited ${created.status}: ${(created.stderr || created.stdout || '').trim().slice(0, 300)}`,
1538
1650
  };
1539
1651
  }
1540
1652
  return { ok: true, created: true, tag, state: 'created', argv, detail: `GitHub release ${tag} created (--verify-tag)` };
1541
1653
  } finally {
1542
- rmSync(notesDir, { recursive: true, force: true });
1654
+ if (!retainNotes) rmSync(notesDir, { recursive: true, force: true });
1543
1655
  }
1544
1656
  } catch (err) {
1545
- return { ok: false, created: false, tag, state: 'unknown', detail: `gh could not be run: ${err.message}` };
1657
+ return { ok: false, created: false, tag, state: 'unknown', ...(recovery ? { recovery } : {}), detail: `gh could not be run: ${err.message}` };
1546
1658
  }
1547
1659
  }
1548
1660