@event4u/agent-config 14.14.1 → 14.16.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 (40) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +88 -0
  5. package/dist/agent-src/rules/security-sensitive-stop.md +7 -5
  6. package/dist/agent-src/skills/accessibility-auditor/SKILL.md +20 -2
  7. package/dist/agent-src/skills/design-intelligence/data/ux-guidelines.csv +99 -99
  8. package/dist/agent-src/skills/fe-design/SKILL.md +6 -3
  9. package/dist/agent-src/skills/fe-design/references/design-patterns.md +28 -0
  10. package/dist/agent-src/skills/iconography/SKILL.md +37 -6
  11. package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +1 -1
  12. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  13. package/dist/cli-delegate/cmd_conformance.js +655 -34
  14. package/dist/discovery/deprecation-report.md +1 -1
  15. package/dist/discovery/discovery-manifest.json +7 -7
  16. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  17. package/dist/discovery/discovery-manifest.summary.md +1 -1
  18. package/dist/discovery/dormancy-report.md +1 -1
  19. package/dist/discovery/orphan-report.md +1 -1
  20. package/dist/discovery/packs.json +2 -2
  21. package/dist/discovery/trust-report.md +1 -1
  22. package/dist/discovery/workspaces.json +2 -2
  23. package/dist/hooks/dispatch.js +669 -45
  24. package/dist/mcp/registry-manifest.json +2 -2
  25. package/dist/mcp/server.json +2 -2
  26. package/dist/mcp/server.mjs +661 -35
  27. package/dist/router.json +1 -1
  28. package/docs/guidelines/design-antipatterns.md +10 -2
  29. package/package.json +1 -1
  30. package/src/config/gate-violation-baselines.json +2 -2
  31. package/src/config/rule-enforcement-baseline.json +0 -1
  32. package/src/scripts/_lib/release_highlights.ts +63 -7
  33. package/src/scripts/design_slop_rules.ts +19 -9
  34. package/src/scripts/git_authorization_hook.ts +719 -27
  35. package/src/scripts/hooks/block_unauthorized_git.ts +250 -9
  36. package/src/scripts/hooks/merge_impact.ts +275 -0
  37. package/src/scripts/release.ts +9 -34
  38. package/src/scripts/release_drill.ts +180 -8
  39. package/src/scripts/release_env.ts +31 -0
  40. package/src/scripts/release_publication.ts +158 -1
@@ -160,6 +160,8 @@ import {
160
160
  SystemExitError,
161
161
  set_augment_manifest_version,
162
162
  set_marketplace_version,
163
+ _rstrip,
164
+ _splitlines,
163
165
  } from './release_env.js';
164
166
  import type { RunResult } from './release_publication.js';
165
167
  // Re-export surface for the six names tests import from `release.js` and that
@@ -192,6 +194,8 @@ import {
192
194
  _target_from_branch,
193
195
  create_and_push_annotated_tag,
194
196
  guard_publication,
197
+ checkout_release_branch,
198
+ guard_release_curation,
195
199
  die,
196
200
  gh,
197
201
  git,
@@ -744,22 +748,6 @@ function prepend_changelog(p: string, entry: string): void {
744
748
  }
745
749
 
746
750
  /** Mirror of Python `str.splitlines()` (no trailing empty for a final newline). */
747
- function _splitlines(text: string): string[] {
748
- if (text === '') {
749
- return [];
750
- }
751
- const parts = text.split(/\r\n|\r|\n/);
752
- if (parts.length > 0 && parts[parts.length - 1] === '') {
753
- parts.pop();
754
- }
755
- return parts;
756
- }
757
-
758
- /** Mirror of Python `str.rstrip()` (trailing whitespace). */
759
- function _rstrip(text: string): string {
760
- return text.replace(/\s+$/u, '');
761
- }
762
-
763
751
  // ─── file mutations ───────────────────────────────────────────────────────────
764
752
 
765
753
  /** Update the top-level `version` field; preserve 4-space indentation. */
@@ -1167,7 +1155,6 @@ function _step(n: number, total: number, msg: string): void {
1167
1155
  process.stdout.write(`[${n}/${total}] ${msg}\n`);
1168
1156
  }
1169
1157
 
1170
-
1171
1158
  function execute(
1172
1159
  plan: Plan,
1173
1160
  opts: { wait_for_checks: boolean; dry_run: boolean; resume?: boolean; ci?: boolean },
@@ -1191,23 +1178,9 @@ function execute(
1191
1178
  const pr_merged = pr_state === 'MERGED';
1192
1179
 
1193
1180
  // ─── 1. branch ──────────────────────────────────────────────────────────
1194
- if (pr_merged) {
1195
- _step(1, total, `PR for ${branch} already merged — staying on ${MAIN_BRANCH}`);
1196
- if (git(['rev-parse', '--abbrev-ref', 'HEAD'], { capture: true }) !== MAIN_BRANCH) {
1197
- run(['git', 'checkout', MAIN_BRANCH]);
1198
- }
1199
- run(['git', 'pull', '--ff-only', REMOTE, MAIN_BRANCH]);
1200
- } else if (resume && _branch_exists_local(branch)) {
1201
- _step(1, total, `Branch ${branch} exists locally — checkout`);
1202
- run(['git', 'checkout', branch]);
1203
- } else if (resume && _branch_exists_remote(branch)) {
1204
- _step(1, total, `Branch ${branch} exists on ${REMOTE} — fetch + checkout`);
1205
- run(['git', 'fetch', REMOTE, branch]);
1206
- run(['git', 'checkout', '-b', branch, `${REMOTE}/${branch}`]);
1207
- } else {
1208
- _step(1, total, `Create branch ${branch}`);
1209
- run(['git', 'checkout', '-b', branch]);
1210
- }
1181
+ checkout_release_branch(branch, pr_merged, (m) => {
1182
+ _step(1, total, m);
1183
+ });
1211
1184
 
1212
1185
  // ─── 1b. era split (optional, separate commit) ─────────────────────────
1213
1186
  // Lands as `chore(changelog): split era ...` BEFORE the release commit
@@ -1276,6 +1249,8 @@ function execute(
1276
1249
  run(['task', 'release-prepare']);
1277
1250
  }
1278
1251
 
1252
+ guard_release_curation(plan.target, pr_merged);
1253
+
1279
1254
  // ─── 3. commit ──────────────────────────────────────────────────────────
1280
1255
  if (pr_merged) {
1281
1256
  _step(3, total, 'PR already merged — skip commit');
@@ -38,6 +38,58 @@ import {
38
38
  SystemExitError,
39
39
  } from './release.js';
40
40
  import { DERIVED_MARKER } from './_lib/release_highlights.js';
41
+ import {
42
+ AUGMENT_MARKETPLACE_JSON,
43
+ AUGMENT_PLUGIN_JSON,
44
+ CHANGELOG,
45
+ MARKETPLACE_JSON,
46
+ PACKAGE_JSON,
47
+ PACKAGE_LOCK_JSON,
48
+ PROJECT_TEMPLATE,
49
+ } from './release_env.js';
50
+
51
+ /**
52
+ * The files step 2 mutates on disk, snapshotted around every scenario.
53
+ *
54
+ * The FakeWorld intercepts COMMANDS, not `fs` writes, and step 2's bump is
55
+ * seven direct writes. Every scenario before 2026-09-03 passed `--resume` with
56
+ * `package.json` already at the target, so step 2 short-circuited and nothing
57
+ * was ever written — the drill looked side-effect-free because no scenario had
58
+ * reached the writing arm. The first `resume: false` scenario appended twelve
59
+ * `## 14.15.0` headings to the REAL `CHANGELOG.md`.
60
+ *
61
+ * Snapshot-and-restore rather than a writer seam: a seam means threading an
62
+ * injection point through seven call sites in `release.ts`, which sits past the
63
+ * 1500-line cap where every line is charged. This is contained to the runner
64
+ * and covers any future scenario without further thought.
65
+ */
66
+ const _MUTATED_BY_STEP_2: readonly string[] = [
67
+ CHANGELOG,
68
+ PACKAGE_JSON,
69
+ PACKAGE_LOCK_JSON,
70
+ MARKETPLACE_JSON,
71
+ AUGMENT_PLUGIN_JSON,
72
+ AUGMENT_MARKETPLACE_JSON,
73
+ PROJECT_TEMPLATE,
74
+ ];
75
+
76
+ function _snapshot_step_2_files(): Map<string, string> {
77
+ const snap = new Map<string, string>();
78
+ for (const f of _MUTATED_BY_STEP_2) {
79
+ if (fs.existsSync(f)) {
80
+ snap.set(f, fs.readFileSync(f, 'utf-8'));
81
+ }
82
+ }
83
+ return snap;
84
+ }
85
+
86
+ function _restore_step_2_files(snap: ReadonlyMap<string, string>): void {
87
+ for (const [f, content] of snap) {
88
+ if (fs.readFileSync(f, 'utf-8') !== content) {
89
+ fs.writeFileSync(f, content);
90
+ }
91
+ }
92
+ }
41
93
 
42
94
  const _HERE = fileURLToPath(import.meta.url);
43
95
  const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
@@ -64,6 +116,27 @@ interface WorldConfig {
64
116
  push_rejections?: number;
65
117
  /** mergeStateStatus reports BEHIND this many probes in a row (9.16.0). */
66
118
  behind_probes?: number;
119
+ /**
120
+ * Commits the release branch is behind `origin/main` at step 1.
121
+ *
122
+ * Distinct from `behind_probes`, which fakes the PR's own
123
+ * `mergeStateStatus` at step 6. This one fakes `git rev-list --count
124
+ * HEAD..origin/main`, the probe `integrate_main_if_behind` uses — the
125
+ * defect it covers surfaced at the PUSH, six steps before that PR ever
126
+ * existed.
127
+ */
128
+ branch_behind_main?: number;
129
+ /**
130
+ * Whether `execute()` is invoked with `--resume`. Defaults to TRUE so every
131
+ * pre-existing scenario keeps the shape it was written against.
132
+ *
133
+ * Configurable since 2026-09-03 because the drill could not otherwise
134
+ * reach the defect: branch reuse used to be gated on `resume`, so a plain
135
+ * `task release` over an existing branch died at `git checkout -b` with
136
+ * exit 128 — and every scenario here passed `resume: true`, which is
137
+ * exactly the arm that worked.
138
+ */
139
+ resume?: boolean;
67
140
  /** `gh pr merge` fails once with the not-up-to-date error (the race). */
68
141
  merge_races_once?: boolean;
69
142
  /** `gh pr merge` fails hard with an unrelated error (must surface). */
@@ -199,6 +272,7 @@ class FakeWorld {
199
272
 
200
273
  private push_rejections: number;
201
274
  private behind_probes: number;
275
+ private branch_behind_main: number;
202
276
  private merge_races_once: boolean;
203
277
  private merge_fails_hard: boolean;
204
278
  private checks_fail: boolean;
@@ -217,6 +291,7 @@ class FakeWorld {
217
291
  this.tag_local = (cfg.tag_created_unpushed ?? false) || (cfg.tag_already_remote ?? false);
218
292
  this.tag_remote = cfg.tag_already_remote ?? false;
219
293
  this.behind_probes = cfg.behind_probes ?? 0;
294
+ this.branch_behind_main = cfg.branch_behind_main ?? 0;
220
295
  this.merge_races_once = cfg.merge_races_once ?? false;
221
296
  this.merge_fails_hard = cfg.merge_fails_hard ?? false;
222
297
  this.checks_fail = cfg.checks_fail ?? false;
@@ -235,6 +310,15 @@ class FakeWorld {
235
310
  this.head = args[2] === '-b' ? (args[3] as string) : (args[2] as string);
236
311
  return OK;
237
312
  }
313
+ if (cmd === 'git rev-list --count HEAD..origin/main') {
314
+ return { ...OK, stdout: `${String(this.branch_behind_main)}\n` };
315
+ }
316
+ if (cmd === 'git merge origin/main --no-edit') {
317
+ // A real merge closes the gap, so a second probe reads 0. Without
318
+ // this the scenario could not tell one merge from a loop.
319
+ this.branch_behind_main = 0;
320
+ return OK;
321
+ }
238
322
  if (cmd === `git rev-parse --verify --quiet refs/heads/${this.branch}`) {
239
323
  return this.pr_merged ? { ...OK, status: 1 } : OK;
240
324
  }
@@ -345,6 +429,15 @@ class FakeWorld {
345
429
  // live body before deciding whether to edit.
346
430
  return { ...OK, stdout: this.pr_body };
347
431
  }
432
+ if (args[0] === 'gh' && args[1] === 'pr' && args[2] === 'create') {
433
+ // Only reachable on the NON-resume path: a resumed run refreshes an
434
+ // existing PR body instead (step 5's other arm), which is why every
435
+ // scenario before 2026-09-03 could leave this unscripted.
436
+ const bodyIdx = args.indexOf('--body');
437
+ this.pr_body = bodyIdx >= 0 ? String(args[bodyIdx + 1] ?? '') : '';
438
+ this.branch_exists_remote = true;
439
+ return { ...OK, stdout: `https://github.com/event4u-app/agent-config/pull/999\n` };
440
+ }
348
441
  if (args[0] === 'gh' && args[1] === 'pr' && args[2] === 'edit' && args[3] === this.branch) {
349
442
  const bodyIdx = args.indexOf('--body');
350
443
  this.pr_body = bodyIdx >= 0 ? String(args[bodyIdx + 1] ?? '') : this.pr_body;
@@ -390,22 +483,99 @@ function _count(world: FakeWorld, needle: string): number {
390
483
  }
391
484
 
392
485
  const SCENARIOS: Record<string, Scenario> = {
393
- 'marker-refuses-before-branch-push': {
486
+ 'plain-run-reuses-an-existing-branch': {
487
+ summary:
488
+ 'step 1: a PLAIN run (no --resume) over an existing release branch checks it out instead of dying at `git checkout -b`',
489
+ // The defect, measured on 14.15.0: branch reuse read
490
+ // `resume && _branch_exists_local(branch)`, so without --resume the
491
+ // run fell through to the create arm and died with exit 128. That is
492
+ // precisely the state `guard_release_curation` leaves behind — it stops
493
+ // BEFORE the commit, on a branch step 1 already created — while its own
494
+ // message says to re-run `task release`. The code refused the remedy its
495
+ // own message prescribed.
496
+ config: { resume: false },
497
+ expect_success: true,
498
+ verify: (w) => {
499
+ const f: string[] = [];
500
+ _expect(
501
+ w.calls.includes(`git checkout ${w.branch}`),
502
+ 'the existing branch was not checked out',
503
+ f,
504
+ );
505
+ _expect(
506
+ !w.calls.includes(`git checkout -b ${w.branch}`),
507
+ 'took the create arm over an existing branch — this is the exit-128 path',
508
+ f,
509
+ );
510
+ return f;
511
+ },
512
+ },
513
+ 'stale-branch-merges-main-at-step-1': {
514
+ summary:
515
+ 'step 1: a branch behind origin/main merges it in there — NOT at the push, where the preflight used to be the first to notice',
516
+ // Second half of the same defect. On 14.15.0 the branch had been cut
517
+ // from an older main during an earlier aborted run; steps 2 and 3
518
+ // bumped and committed, and only the pre-push preflight said `branch is
519
+ // BEHIND origin/main`. The cheapest moment to integrate main is the
520
+ // moment the branch is checked out.
521
+ config: { resume: false, branch_behind_main: 3 },
522
+ expect_success: true,
523
+ verify: (w) => {
524
+ const f: string[] = [];
525
+ const merge = w.calls.indexOf('git merge origin/main --no-edit');
526
+ const push = w.calls.indexOf(`git push -u origin ${w.branch}`);
527
+ _expect(merge >= 0, 'main was never merged into the stale branch', f);
528
+ _expect(push >= 0, 'the branch was never pushed', f);
529
+ // Ordering is the whole point: a merge AFTER the push would mean the
530
+ // preflight had already refused.
531
+ _expect(
532
+ merge >= 0 && push >= 0 && merge < push,
533
+ 'main was merged after the push, not before it',
534
+ f,
535
+ );
536
+ _expect(
537
+ _count(w, 'git merge origin/main --no-edit') === 1,
538
+ 'merged main more than once — the probe is not being re-read after the merge',
539
+ f,
540
+ );
541
+ return f;
542
+ },
543
+ },
544
+ 'marker-refuses-before-commit': {
394
545
  summary:
395
- 'step 4: a draft marker refuses the branch push — the first remote state — so no PR is ever opened',
546
+ 'step 3: a draft marker refuses BEFORE the release commit — no local release state, and therefore no remote state either',
396
547
  config: { changelog_file: markedChangelogFixture(current_version()) },
397
548
  expect_success: false,
398
549
  verify: (w, err) => {
399
550
  const f: string[] = [];
400
551
  _expect(
401
- (err ?? '').includes(`refusing to push ${w.branch}`),
552
+ (err ?? '').includes('release highlights are still the generator'),
402
553
  `died for the wrong reason: ${err ?? '(no error)'}`,
403
554
  f,
404
555
  );
405
- // The load-bearing assertions are about what did NOT happen. Each
406
- // one is a remote side effect the 14.14.0 run actually produced
407
- // before this guard existed: a pushed branch, an open PR, and a
408
- // check watch on a check that could not pass.
556
+ _expect(
557
+ !w.calls.some((c) => c.startsWith('git commit')),
558
+ 'a release commit was made after the refusal',
559
+ f,
560
+ );
561
+ // RENAMED from `marker-refuses-before-branch-push` when
562
+ // `guard_release_curation` moved the refusal one step earlier.
563
+ // Every negative assertion below is UNCHANGED and still
564
+ // load-bearing — each is a remote side effect the 14.14.0 run
565
+ // actually produced: a pushed branch, an open PR, a check watch on
566
+ // a check that could not pass. They now hold more strongly, since
567
+ // the run stops before the commit rather than after it, and the
568
+ // no-commit assertion above is the property that is new.
569
+ //
570
+ // What this scenario no longer reaches, said plainly rather than
571
+ // left for a reader to infer: `guard_release_branch_push`. On the
572
+ // non-merged path it is now unreachable in ONE run, because a
573
+ // section marked at step 4 was already marked at step 3. It stays
574
+ // as defence-in-depth for a section that changes BETWEEN the two —
575
+ // a hand edit mid-run, or a resume over an altered changelog — and
576
+ // its own behaviour stays pinned by the unit tests in
577
+ // `tests/scripts/release_push_failure_masking.test.ts`, which is
578
+ // where that coverage moved to rather than being dropped.
409
579
  _expect(
410
580
  !w.calls.includes(`git push -u origin ${w.branch}`),
411
581
  'the branch was pushed after the refusal',
@@ -666,12 +836,13 @@ function run_scenario(name: string): ScenarioOutcome {
666
836
  captured.push(String(s));
667
837
  return true;
668
838
  };
839
+ const tree_snapshot = _snapshot_step_2_files();
669
840
  _set_exec_override((args) => world.exec(args));
670
841
  // The working-tree read, faked for the same reason the command layer is.
671
842
  // Without this the drill's step 8 reads the repository's real CHANGELOG.md.
672
843
  _set_changelog_reader(() => world.changelog_file);
673
844
  try {
674
- execute(plan, { wait_for_checks: true, dry_run: false, resume: true });
845
+ execute(plan, { wait_for_checks: true, dry_run: false, resume: scenario.config.resume ?? true });
675
846
  } catch (err) {
676
847
  if (err instanceof SystemExitError) {
677
848
  error = `SystemExit(${err.code}): ${captured.join('')}`;
@@ -683,6 +854,7 @@ function run_scenario(name: string): ScenarioOutcome {
683
854
  _set_changelog_reader(null);
684
855
  process.stdout.write = orig_out;
685
856
  process.stderr.write = orig_err;
857
+ _restore_step_2_files(tree_snapshot);
686
858
  }
687
859
 
688
860
  const failures: string[] = [];
@@ -303,6 +303,35 @@ function set_marketplace_version(p: string, version: string): void {
303
303
  fs.writeFileSync(p, jsonDumpsIndent(data, 2) + '\n', 'utf-8');
304
304
  }
305
305
 
306
+ /**
307
+ * Mirror of Python `str.splitlines()` — split on CR/LF/CRLF and drop a single
308
+ * trailing empty element.
309
+ *
310
+ * Lives here rather than in `release.ts` because that file is 2000+ lines and
311
+ * every line above 1500 is charged by `check_source_size_budget`, while this
312
+ * module is a third of the cap. Behaviour unchanged; it is a pure text helper
313
+ * beside the other pure text helpers in this module.
314
+ *
315
+ * Known duplication, recorded rather than fixed here: `adoption_report.ts:106`
316
+ * and `readme_linter.ts:418` carry their own copies. Collapsing all three onto
317
+ * one definition touches three modules and is not this change's subject.
318
+ */
319
+ function _splitlines(text: string): string[] {
320
+ if (text === '') {
321
+ return [];
322
+ }
323
+ const parts = text.split(/\r\n|\r|\n/);
324
+ if (parts.length > 0 && parts[parts.length - 1] === '') {
325
+ parts.pop();
326
+ }
327
+ return parts;
328
+ }
329
+
330
+ /** Mirror of Python `str.rstrip()` (trailing whitespace). */
331
+ function _rstrip(text: string): string {
332
+ return text.replace(/\s+$/u, '');
333
+ }
334
+
306
335
  export {
307
336
  ArgparseExit,
308
337
  commaGroup,
@@ -330,4 +359,6 @@ export {
330
359
  REPO_ROOT,
331
360
  REPO_SLUG,
332
361
  SystemExitError,
362
+ _rstrip,
363
+ _splitlines,
333
364
  };
@@ -16,7 +16,7 @@
16
16
  * import path changes.
17
17
  */
18
18
  import { spawnSync } from 'node:child_process';
19
- import { publication_blockers } from './_lib/release_highlights.js';
19
+ import { DERIVED_MARKER, publication_blockers } from './_lib/release_highlights.js';
20
20
  import * as fs from 'node:fs';
21
21
  import process from 'node:process';
22
22
 
@@ -479,6 +479,163 @@ export function guard_release_branch_push(branch: string): void {
479
479
  );
480
480
  }
481
481
 
482
+ /**
483
+ * Ask for the curation BEFORE the pipeline commits anything — the fifth guard
484
+ * site, and the one that closes the gap the other four only narrowed.
485
+ *
486
+ * `guard_release_branch_push` already refuses before any REMOTE state exists,
487
+ * which is why 14.14.1 cost nothing but a re-run. What it cannot do is refuse
488
+ * before LOCAL state exists: by the time the push is attempted, step 2 has
489
+ * bumped six files and regenerated the derived trees and step 3 has committed
490
+ * all of it as `release: X.Y.Z`. The operator then edits prose on top of a
491
+ * commit that already claims to be the release and continues with `--resume`.
492
+ *
493
+ * The sentence this fixes is in that guard's own docstring: the 2026-09-01
494
+ * flip's risk register named *"the releaser edits prose before merge"* as the
495
+ * mitigation, and *"nothing in the pipeline asked, so nothing did"*. Refusing
496
+ * is not asking. This is the ask, placed where the answer is cheapest: the
497
+ * changelog section has just been written, nothing is committed, and the fix is
498
+ * an edit to a file already open in the working tree.
499
+ *
500
+ * It names the labels rather than only the marker, because the operator's next
501
+ * action is to rewrite specific lines and hunting for them in a 30-line section
502
+ * is the friction that made the refusal feel like a failure rather than a step.
503
+ *
504
+ * Deliberately silent when the section does not exist — same reasoning as the
505
+ * push guard: a missing section is the publication sites' refusal to make, not
506
+ * this one's.
507
+ *
508
+ * `prMerged` is a parameter rather than a check at the call site, and it is
509
+ * load-bearing in both directions. On the merged path steps 2 and 3 do not run,
510
+ * so there is no pre-commit moment to ask about; firing anyway PREEMPTS the
511
+ * step-8 and step-9 guards, whose whole subject is the merged section. That is
512
+ * measured, not anticipated — the unconditional form red three `release_drill`
513
+ * scenarios that exist to pin exactly those later refusals. It lives here
514
+ * rather than in an `if` at the call site because `release.ts` is 2000+ lines
515
+ * and every line above 1500 is charged by the growth ratchet, while this module
516
+ * is far below it.
517
+ */
518
+ /**
519
+ * Own the whole step-1 branch decision: reuse an existing release branch, and
520
+ * make sure whatever we end up on carries current `main`.
521
+ *
522
+ * Three defects it closes, all measured on 14.15.0 (2026-09-03) in one sitting,
523
+ * and all the same underlying mistake — a release branch that is not on current
524
+ * `main`, discovered later than it could have been.
525
+ *
526
+ * 1. **Reuse was gated on `--resume`.** The two "branch exists" arms read
527
+ * `resume && _branch_exists_local(branch)`, so a PLAIN `task release` over an
528
+ * existing branch fell through to `git checkout -b` and died with exit 128.
529
+ * That is exactly the state `guard_release_curation` leaves behind — it stops
530
+ * before the commit, and the branch it stops on already exists — while its
531
+ * own message says to re-run `task release`. The message was right about
532
+ * what to do and the code refused to do it. Reuse no longer consults
533
+ * `resume`: an existing release branch is a fact about the repository, not
534
+ * about the flag.
535
+ * 2. **A branch cut from an older `main` failed at the PUSH.** The pre-push
536
+ * preflight reported `branch is BEHIND origin/main` after steps 2 and 3 had
537
+ * already bumped and committed — the cheapest moment to integrate `main` is
538
+ * the moment we check the branch out, not eight steps later.
539
+ * 3. **A fresh branch was cut from whatever local `main` happened to be.** The
540
+ * create arm never pulled, so a stale local `main` produced a stale release
541
+ * branch and defect 2 followed on the next run. Fixed on the same terms
542
+ * rather than left as the half that still bites: leaving it would reproduce
543
+ * the "fix that does not fix it" pattern the other two came from.
544
+ *
545
+ * `say` is a callback rather than a direct write because the `[N/total]` step
546
+ * prefix belongs to the orchestrator, which knows the total; this module does
547
+ * not. Keeping the printing there and the decision here is also what lets
548
+ * `release.ts` shed twenty lines — it sits ~500 lines past the 1500-line cap,
549
+ * where every line is charged by the growth ratchet, and this module is under
550
+ * it.
551
+ *
552
+ * The merge is `git merge`, never a rebase and never a force: a release branch
553
+ * can already carry a pushed commit and somebody else's merge, and neither is
554
+ * ours to rewrite.
555
+ */
556
+ export function checkout_release_branch(
557
+ branch: string,
558
+ prMerged: boolean,
559
+ say: (msg: string) => void,
560
+ ): void {
561
+ if (prMerged) {
562
+ say(`PR for ${branch} already merged — staying on ${MAIN_BRANCH}`);
563
+ if (git(['rev-parse', '--abbrev-ref', 'HEAD'], { capture: true }) !== MAIN_BRANCH) {
564
+ run(['git', 'checkout', MAIN_BRANCH]);
565
+ }
566
+ run(['git', 'pull', '--ff-only', REMOTE, MAIN_BRANCH]);
567
+ return;
568
+ }
569
+ if (_branch_exists_local(branch)) {
570
+ say(`Branch ${branch} exists locally — checkout`);
571
+ run(['git', 'checkout', branch]);
572
+ } else if (_branch_exists_remote(branch)) {
573
+ say(`Branch ${branch} exists on ${REMOTE} — fetch + checkout`);
574
+ run(['git', 'fetch', REMOTE, branch]);
575
+ run(['git', 'checkout', '-b', branch, `${REMOTE}/${branch}`]);
576
+ } else {
577
+ // Cut from CURRENT main, not from whatever the local ref happens to be.
578
+ say(`Create branch ${branch} from current ${MAIN_BRANCH}`);
579
+ if (git(['rev-parse', '--abbrev-ref', 'HEAD'], { capture: true }) === MAIN_BRANCH) {
580
+ run(['git', 'pull', '--ff-only', REMOTE, MAIN_BRANCH]);
581
+ }
582
+ run(['git', 'checkout', '-b', branch]);
583
+ return;
584
+ }
585
+ integrate_main_if_behind(branch, say);
586
+ }
587
+
588
+ /**
589
+ * Merge `origin/<default>` into the checked-out release branch when it is
590
+ * behind, so the pre-push preflight is never the first place that surfaces.
591
+ *
592
+ * Silent when the branch is already current — a release run should not print a
593
+ * merge it did not make. Counting with `rev-list --count` against the fetched
594
+ * remote ref rather than the local one: the local `main` is exactly what went
595
+ * stale in the case this exists for.
596
+ */
597
+ export function integrate_main_if_behind(branch: string, say: (msg: string) => void): void {
598
+ run(['git', 'fetch', REMOTE, MAIN_BRANCH], { check: false });
599
+ const behind = git(['rev-list', '--count', `HEAD..${REMOTE}/${MAIN_BRANCH}`], {
600
+ capture: true,
601
+ }).trim();
602
+ if (behind === '' || behind === '0') {
603
+ return;
604
+ }
605
+ say(`Branch ${branch} is ${behind} commit(s) behind ${REMOTE}/${MAIN_BRANCH} — merging it in`);
606
+ run(['git', 'merge', `${REMOTE}/${MAIN_BRANCH}`, '--no-edit']);
607
+ }
608
+
609
+ export function guard_release_curation(version: string, prMerged = false): void {
610
+ if (prMerged) {
611
+ return;
612
+ }
613
+ const section = extract_changelog_section(read_changelog_text(), version);
614
+ if (!section) {
615
+ return;
616
+ }
617
+ const blockers = publication_blockers(section.body, version, `\`release/${version}\``);
618
+ if (blockers.length === 0) {
619
+ return;
620
+ }
621
+ const marked = section.body
622
+ .split('\n')
623
+ .filter((l) => l.includes(DERIVED_MARKER))
624
+ .map((l) => ` ${l.trim()}`);
625
+ die(
626
+ `the ${version} release highlights are still the generator's draft ` +
627
+ `(${String(blockers.length)} blocker(s)):\n` +
628
+ blockers.map((b) => ` - ${b}`).join('\n') +
629
+ (marked.length > 0
630
+ ? `\n Lines to rewrite in \`CHANGELOG.md\` under \`## [${version}]\`:\n` +
631
+ marked.join('\n')
632
+ : '') +
633
+ '\n Stopped BEFORE committing — no release commit, no branch on ' +
634
+ `${REMOTE}, no pull request, no tag. Curate the ` +
635
+ '`### Release highlights` head, then re-run `task release`.',
636
+ );
637
+ }
638
+
482
639
  function push_release_branch(branch: string): void {
483
640
  guard_release_branch_push(branch);
484
641
  const first = run(['git', 'push', '-u', REMOTE, branch], { check: false, capture: true });