@event4u/agent-config 14.15.0 → 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 (37) 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 +58 -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 +158 -33
  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 +169 -41
  24. package/dist/mcp/registry-manifest.json +2 -2
  25. package/dist/mcp/server.json +2 -2
  26. package/dist/mcp/server.mjs +160 -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/scripts/design_slop_rules.ts +19 -9
  32. package/src/scripts/git_authorization_hook.ts +257 -35
  33. package/src/scripts/hooks/block_unauthorized_git.ts +16 -4
  34. package/src/scripts/hooks/merge_impact.ts +68 -0
  35. package/src/scripts/release.ts +4 -17
  36. package/src/scripts/release_drill.ts +154 -1
  37. package/src/scripts/release_publication.ts +91 -0
@@ -296,20 +296,22 @@ const PHRASES: ReadonlyArray<{ op: GitOp; re: RegExp }> = [
296
296
  // "never auto-merge" each returned ["pr-merge"] — a prompt saying DO NOT
297
297
  // MERGE authorized a merge. The noun-sense lookahead was never a negator.
298
298
  //
299
- // The lookBEHIND is line-scoped by `[^.!?\n]` on purpose, the same scoping
300
- // `turn_end_gate_hook.ts` uses for its own negation exclusion (`:330`) and
301
- // its negated-claim window (`:490-493`). A sentence boundary ends a
302
- // negation's reach: "Do not push. Merge PR #12." is two instructions, and a
303
- // guard that let the first suppress the second would silently stop
304
- // authorizing merges the user DID order — the failure mode that is worse
305
- // than the defect, because nothing happens and nothing says why.
306
- //
307
- // Reusing that vocabulary rather than inventing a third is deliberate: two
299
+ // The inline negative lookBEHIND that used to live in this pattern was
300
+ // REMOVED on 2026-09-03, and its own comment is why. It warned that "two
308
301
  // negation vocabularies in one tree drift, and the drift is invisible until
309
- // a prompt lands in the gap between them.
302
+ // a prompt lands in the gap between them" — and by then they had drifted:
303
+ // `negatedBefore` became clause-scanned in both directions while this
304
+ // lookbehind stayed a blind 30-character window with no forward half. The
305
+ // prompt that landed in the gap is `"nicht pushen, aber mergen"`, which the
306
+ // lookbehind refused (13 characters, inside its window, across a contrast
307
+ // cue it could not see) while the clause scan correctly authorizes it.
308
+ //
309
+ // Every phrase match already passes through `negatedBefore` at the call
310
+ // site, so the guard is not weakened by the removal — it is applied once,
311
+ // by the one vocabulary this file's comment asked for.
310
312
  {
311
313
  op: "pr-merge",
312
- re: /(?<!\b(nicht|nichts|kein(e|en|em|er|es)?|niemals|nie|no|not|dont|don't|never|without|ohne)\b[^.!?\n]{0,30})\b(merge|merg(e|en|st|t)|zusammenf(ü|ue)hren|reinmergen)\b(?!\s*[- ]?(conflict|konflikt|commit|base|queue|state|status))/i,
314
+ re: /\b(merge|merg(e|en|st|t)|zusammenf(ü|ue)hren|reinmergen)\b(?!\s*[- ]?(conflict|konflikt|commit|base|queue|state|status))/i,
313
315
  },
314
316
  // A tag is an ACTION here — bare "Tag" is the German word for day, and
315
317
  // "Version" is an ordinary noun. Both authorized a BLOCK op before this.
@@ -450,15 +452,26 @@ export function isInterrogative(prose: string): boolean {
450
452
  if (!t) {
451
453
  return false;
452
454
  }
455
+ // An explicit trailing question mark OUTRANKS the imperative escape below.
456
+ // Measured 2026-09-03: the escape ran first, so "Can you do the release
457
+ // now?" needed `now` to stay OUT of the imperative list to be read as a
458
+ // question — which left `now` absent while its German counterpart `jetzt`
459
+ // was present, and "Do the release now." was therefore classified a question
460
+ // and authorized nothing while "Do the release jetzt." authorized `release`.
461
+ // Ordering the `?` first is what makes closing that parity gap safe: it is a
462
+ // NARROWING (more prompts read as questions), and it lets the widening below
463
+ // be a one-word list addition rather than a trade.
464
+ if (/\?\s*$/.test(t)) {
465
+ return true;
466
+ }
453
467
  const hasImperative =
454
- /\b(mach|mache|bitte|leg|lege|erstell|erstelle|f(ü|ue)hr|f(ü|ue)hre|setz|setze|starte|los|jetzt|go ahead|do it|ja[,.]?\s|ok[,.]?\s)\b/i.test(
468
+ /\b(mach|mache|bitte|leg|lege|erstell|erstelle|f(ü|ue)hr|f(ü|ue)hre|setz|setze|starte|los|jetzt|now|asap|go ahead|do it|ja[,.]?\s|ok[,.]?\s)\b/i.test(
455
469
  t,
456
470
  );
457
471
  if (hasImperative) {
458
472
  return false;
459
473
  }
460
474
  return (
461
- /\?\s*$/.test(t) ||
462
475
  /^(was|wie|warum|wieso|weshalb|wann|wer|welche[rs]?|wo|ist|sind|kann|kannst|k(ö|oe)nnen|soll|sollen|darf|d(ü|ue)rfen|why|what|how|when|which|who|is|are|can|could|should|does|do)\b/i.test(
463
476
  t,
464
477
  )
@@ -608,13 +621,43 @@ export interface Ledger extends JsonObject {
608
621
  * capability to a sentence that never identified an object, which is the exact
609
622
  * conflation of lexical and object specificity both council seats rejected.
610
623
  */
624
+ /** One-line, bounded rendering of a caught value for a diagnostic. */
625
+ function _errText(err: unknown): string {
626
+ const t = err instanceof Error ? err.message : String(err);
627
+ return t.replace(/\s+/g, " ").trim().slice(0, 200);
628
+ }
629
+
630
+ /**
631
+ * The largest pull-request number GitHub can issue: a signed 32-bit integer.
632
+ *
633
+ * Taken from the API contract rather than estimated, and shared by the mint
634
+ * site here and the consume site in `hooks/block_unauthorized_git.ts` so the
635
+ * two cannot drift — the failure this file already learned once with its
636
+ * negation vocabulary.
637
+ */
638
+ export const PR_NUMBER_MAX = 2147483647;
639
+
611
640
  export function extractMergeTargets(prompt: string): number[] {
612
641
  const out = new Set<number>();
613
- const re = /(?:#|\bpr[- ]?|\bpull[- ]request\s+#?)(\d{1,7})\b/giu;
642
+ // The BOUND comes from the API contract, not from a guess: GitHub issue and
643
+ // pull-request numbers are a signed 32-bit integer, so ten digits is the
644
+ // lexical ceiling and `PR_NUMBER_MAX` is the real one. The old `\d{1,7}` was
645
+ // neither — an eight-digit number simply failed to match, and since the
646
+ // `\b` sits after digit seven it failed SILENTLY rather than out of range.
647
+ //
648
+ // Widening here is required for the consume-side widening to do anything at
649
+ // all: this is where a grant's target set is frozen, so a target that cannot
650
+ // be MINTED can never be matched however well it resolves later.
651
+ //
652
+ // The `#` / `PR ` prefix requirement is deliberately unchanged. Over-matching
653
+ // here would hand a clockless capability to a sentence that never identified
654
+ // an object, which is the conflation of lexical and object specificity both
655
+ // council seats rejected.
656
+ const re = /(?:#|\bpr[- ]?|\bpull[- ]request\s+#?)(\d{1,10})\b/giu;
614
657
  let m: RegExpExecArray | null;
615
658
  while ((m = re.exec(prompt)) !== null) {
616
659
  const n = Number.parseInt(m[1] as string, 10);
617
- if (Number.isSafeInteger(n) && n > 0) {
660
+ if (Number.isSafeInteger(n) && n > 0 && n <= PR_NUMBER_MAX) {
618
661
  out.add(n);
619
662
  }
620
663
  }
@@ -725,21 +768,162 @@ const NEGATION_WORD =
725
768
  * says why. The 30-character window inside the sentence is inherited from the
726
769
  * `pr-merge` lookbehind that this generalises.
727
770
  */
728
- export function negatedBefore(text: string, index: number, matched = ""): boolean {
771
+ const CONTRAST_CUE = /\b(aber|sondern|jedoch|but|however|instead)\b/gi;
772
+
773
+ /**
774
+ * Abbreviation and decimal dots are NOT sentence boundaries.
775
+ *
776
+ * MEASURED. `"Du sollst nicht z.B. den PR #12 mergen."` authorized `pr-merge`
777
+ * at 022c0d240: the boundary scan took the second dot of `z.B.` as the sentence
778
+ * start, which put the window at `" den PR #12 "` and left `nicht` out of
779
+ * scope. `"Nach Release 1.5 bitte nicht mergen."` fails the same way on the
780
+ * decimal point.
781
+ *
782
+ * Two rules, both narrow on purpose. A dot is not a boundary when it sits
783
+ * between digits, or when the word-ish token ending at it is a known
784
+ * abbreviation — including a single letter, which is what makes `z.B.` two
785
+ * non-boundaries rather than one.
786
+ */
787
+ const ABBREVIATION_BEFORE_DOT =
788
+ /(?:^|[\s("'\u2018\u201c])(?:[a-z]|z\.b|bzw|ca|usw|d\.h|u\.a|evtl|ggf|inkl|max|min|nr|abb|e\.g|i\.e|etc|vs|approx|fig|no|st|dr|prof)\.$/i;
789
+
790
+ function isSentenceBoundary(text: string, at: number): boolean {
791
+ const mark = text[at];
792
+ if (mark !== ".") {
793
+ return true;
794
+ }
795
+ if (/\d/.test(text[at - 1] ?? "") && /\d/.test(text[at + 1] ?? "")) {
796
+ return false;
797
+ }
798
+ return !ABBREVIATION_BEFORE_DOT.test(text.slice(0, at + 1));
799
+ }
800
+
801
+ /** Bounds of the sentence containing `index`, abbreviation- and decimal-aware. */
802
+ export function sentenceBounds(text: string, index: number): { start: number; end: number } {
729
803
  let start = 0;
730
- for (const mark of [".", "!", "?", "\n"]) {
731
- const at = text.lastIndexOf(mark, index - 1);
732
- if (at + 1 > start) {
733
- start = at + 1;
804
+ for (let i = index - 1; i >= 0; i -= 1) {
805
+ const c = text[i] as string;
806
+ if ((c === "." || c === "!" || c === "?" || c === "\n") && isSentenceBoundary(text, i)) {
807
+ start = i + 1;
808
+ break;
734
809
  }
735
810
  }
736
- const before = text.slice(Math.max(start, index - 30), index);
811
+ let end = text.length;
812
+ for (let i = index; i < text.length; i += 1) {
813
+ const c = text[i] as string;
814
+ if ((c === "." || c === "!" || c === "?" || c === "\n") && isSentenceBoundary(text, i)) {
815
+ end = i;
816
+ break;
817
+ }
818
+ }
819
+ return { start, end };
820
+ }
821
+
822
+ /**
823
+ * Split prose into sentences on the same boundaries `sentenceBounds` uses, so
824
+ * an abbreviation or a decimal point cannot split one instruction into two.
825
+ */
826
+ export function _sentences(text: string): string[] {
827
+ const out: string[] = [];
828
+ let from = 0;
829
+ for (let i = 0; i < text.length; i += 1) {
830
+ const c = text[i] as string;
831
+ if ((c === "." || c === "!" || c === "?" || c === "\n") && isSentenceBoundary(text, i)) {
832
+ out.push(text.slice(from, i + 1));
833
+ from = i + 1;
834
+ }
835
+ }
836
+ if (from < text.length) {
837
+ out.push(text.slice(from));
838
+ }
839
+ return out.filter((t) => t.trim() !== "");
840
+ }
841
+
842
+ /** Last clause boundary at or before `to`, within `[from, to)`. */
843
+ function clauseStart(text: string, from: number, to: number): number {
844
+ let start = from;
845
+ const seg = text.slice(from, to);
846
+ for (let i = seg.length - 1; i >= 0; i -= 1) {
847
+ const c = seg[i] as string;
848
+ if (c === "," || c === ";" || c === ":") {
849
+ start = from + i + 1;
850
+ break;
851
+ }
852
+ }
853
+ CONTRAST_CUE.lastIndex = 0;
854
+ let m: RegExpExecArray | null;
855
+ while ((m = CONTRAST_CUE.exec(seg)) !== null) {
856
+ const after = from + m.index + m[0].length;
857
+ if (after > start) {
858
+ start = after;
859
+ }
860
+ }
861
+ return start;
862
+ }
863
+
864
+ /** First clause boundary at or after `from`, within `(from, to]`. */
865
+ function clauseEnd(text: string, from: number, to: number): number {
866
+ const seg = text.slice(from, to);
867
+ let end = to;
868
+ const punct = seg.search(/[,;:]/);
869
+ if (punct !== -1) {
870
+ end = from + punct;
871
+ }
872
+ CONTRAST_CUE.lastIndex = 0;
873
+ const m = CONTRAST_CUE.exec(seg);
874
+ if (m !== null && from + m.index < end) {
875
+ end = from + m.index;
876
+ }
877
+ return end;
878
+ }
879
+
880
+ /**
881
+ * Is the match at `index` under a negation?
882
+ *
883
+ * MEASURED, not anticipated. Probed on 2026-09-03 against the fifteen phrases
884
+ * added that day: **15 of 15 leaked** — "nicht unpublishen", "den workflow nicht
885
+ * deaktivieren", "kein force-push bitte" each authorized exactly the operation
886
+ * the sentence forbade. The same probe showed the hole predates them, since
887
+ * "kein force-push bitte" also authorized a plain `push` and "den branch nicht
888
+ * löschen" also authorized `branch`. Only `pr-merge` was protected, by a
889
+ * lookbehind written after the identical defect was found there in isolation.
890
+ *
891
+ * SENTENCE-SCOPED, and that bound is load-bearing rather than decorative.
892
+ * "Do not push. Merge PR #12." is two instructions, and a negation whose reach
893
+ * crossed the full stop would silently stop authorizing merges the user DID
894
+ * order — a failure worse than the defect, because nothing happens and nothing
895
+ * says why.
896
+ *
897
+ * CLAUSE-SCANNED IN BOTH DIRECTIONS since 2026-09-03, replacing a fixed
898
+ * 30-character lookbehind inherited from the `pr-merge` pattern. Four shapes
899
+ * leaked past that window, all measured at 022c0d240:
900
+ *
901
+ * "Merge PR #12 auf keinen Fall." negation AFTER the match
902
+ * "Merge #12 under no circumstances." negation AFTER the match
903
+ * "Du sollss nicht z.B. den PR #12 mergen." abbreviation dot cut the sentence
904
+ * "Bitte unter keinen Umstaenden … mergen" 38-44 chars, past the 30 window
905
+ *
906
+ * The scan runs to the nearest CLAUSE boundary rather than to the sentence
907
+ * bound, and that half is what keeps the widening from over-denying: a comma,
908
+ * a semicolon, a colon or a contrast cue ends the scan. Without it,
909
+ * "I do not want to wait any longer, please merge PR #12" would read as
910
+ * negated and silently refuse a merge the user ordered — the failure the
911
+ * sentence bound above exists to prevent, reintroduced one level down. With
912
+ * it, "Merge PR #123, but do not push to production." keeps authorizing the
913
+ * merge and keeps denying the push, which is the only reading a human gives it.
914
+ */
915
+ export function negatedBefore(text: string, index: number, matched = ""): boolean {
916
+ const { start, end } = sentenceBounds(text, index);
917
+ const before = text.slice(clauseStart(text, start, index), index);
918
+ const afterFrom = index + matched.length;
919
+ const after =
920
+ afterFrom < end ? text.slice(afterFrom, clauseEnd(text, afterFrom, end)) : "";
737
921
  // The negation is often INSIDE the match, not before it, and German word
738
922
  // order is why: "das asset nicht ersetzen" is matched by a noun-first pattern
739
923
  // that starts at "asset", so a look-behind from the match index sees an empty
740
924
  // window. Measured — nine of the fifteen rows in the negation corpus failed
741
925
  // exactly this way after the look-behind alone was added.
742
- return NEGATION_WORD.test(before) || NEGATION_WORD.test(matched);
926
+ return NEGATION_WORD.test(before) || NEGATION_WORD.test(matched) || NEGATION_WORD.test(after);
743
927
  }
744
928
 
745
929
  /** Classify which ops a prompt authorizes. Exported for direct testing. */
@@ -758,11 +942,30 @@ export function classifyAuthorization(prompt: string): {
758
942
  .filter((l) => !OUTPUT_LINE.test(l))
759
943
  .join("\n");
760
944
 
761
- for (const { op, re } of isInterrogative(instruction) ? [] : PHRASES) {
762
- const m = re.exec(instruction);
763
- if (m && !negatedBefore(instruction, m.index, m[0])) {
764
- authorized.add(op);
765
- evidence[op] = `prose: "${m[0]}"`;
945
+ // PER SENTENCE, not per prompt. `isInterrogative` reads a leading "Do " as a
946
+ // question, so `"Do not push. Merge PR #12."` classified the WHOLE block as
947
+ // interrogative and authorized nothing — a merge the user ordered in the
948
+ // second sentence, silently dropped because of the first word of the first.
949
+ // Measured at 022c0d240, and the archived roadmap that added the check had
950
+ // already recorded it as pre-existing. `"Do the release now. Merge PR #12."`
951
+ // and `"Is everything green. Merge PR #12."` fail the same way.
952
+ //
953
+ // The bound this restores is the one `question-not-instruction` states: a
954
+ // question sentence authorizes nothing, and an instruction sentence beside
955
+ // it still does. A genuine question is still refused whole —
956
+ // `"was macht npm publish eigentlich genau?"` is one sentence and stays
957
+ // denied.
958
+ for (const { op, re } of PHRASES) {
959
+ for (const sentence of _sentences(instruction)) {
960
+ if (isInterrogative(sentence)) {
961
+ continue;
962
+ }
963
+ const m = re.exec(sentence);
964
+ if (m && !negatedBefore(sentence, m.index, m[0])) {
965
+ authorized.add(op);
966
+ evidence[op] = `prose: "${m[0]}"`;
967
+ break;
968
+ }
766
969
  }
767
970
  }
768
971
 
@@ -868,10 +1071,20 @@ export function consumeGrantTarget(
868
1071
  if (touched) {
869
1072
  try {
870
1073
  atomic_write_json(file, decoded as JsonObject);
871
- } catch {
872
- /* Observability only. A failed write leaves the target unspent, which
873
- re-allows one merge of a PR the user did authorize by name — it fails
874
- toward the user's stated intent, never toward an unauthorized target. */
1074
+ } catch (err) {
1075
+ /* The FAILURE DIRECTION is unchanged and still defensible: a failed write
1076
+ leaves the target unspent, which re-allows one merge of a PR the user
1077
+ did authorize by name — it fails toward the user's stated intent, never
1078
+ toward an unauthorized target. What changed on 2026-09-03 is that it is
1079
+ no longer INVISIBLE. "Observability only" was the reason given for
1080
+ writing nothing, which inverted the word: a grant that silently fails
1081
+ to be consumed is a capability that outlives its single use, and the
1082
+ operator has no way to know. */
1083
+ process.stderr.write(
1084
+ `git-authorization: grant-consumption write failed (${_errText(err)}) — ` +
1085
+ "the target stays UNSPENT, so one further merge of a PR the user named " +
1086
+ "will still be authorized. Authorization outcome unchanged.\n",
1087
+ );
875
1088
  }
876
1089
  }
877
1090
  }
@@ -1005,10 +1218,19 @@ export function run(stdin_text: string, options: { consumer_root: string }): num
1005
1218
 
1006
1219
  try {
1007
1220
  atomic_write_json(path.join(options.consumer_root, ledgerFileFor(session)), ledger);
1008
- } catch {
1009
- // Observability only — a failed write degrades the gate to "no ledger",
1010
- // which the pre_tool_use concern treats as "not authorized" for the
1011
- // irreversible subset and as a warn for the rest.
1221
+ } catch (err) {
1222
+ // The SIBLING of the swallow in `consumeGrantTarget`, and named here
1223
+ // because AC-5 describes a behavioural class rather than one call site:
1224
+ // fixing one and leaving the other would have made the acceptance claim
1225
+ // half true. Same reasoning, different degradation — a failed write here
1226
+ // degrades the gate to "no ledger", which the pre_tool_use concern treats
1227
+ // as "not authorized" for the irreversible subset and as a warn for the
1228
+ // rest, so the direction is fail-closed and still unchanged.
1229
+ process.stderr.write(
1230
+ `git-authorization: session-ledger write failed (${_errText(err)}) — ` +
1231
+ "the gate degrades to \"no ledger\" for this session, which denies the " +
1232
+ "irreversible subset and warns on the rest. Authorization outcome unchanged.\n",
1233
+ );
1012
1234
  }
1013
1235
  return EXIT_ALLOW;
1014
1236
  }
@@ -67,6 +67,7 @@ import {
67
67
  consumeGrantTarget,
68
68
  ledgerFileFor,
69
69
  pendingFileFor,
70
+ PR_NUMBER_MAX,
70
71
  readGrants,
71
72
  STATE_FILE,
72
73
  type GitOp,
@@ -626,15 +627,26 @@ export function commandOp(command: string): GitOp | null {
626
627
  * unnamed target never consumes a grant minted for a named one.
627
628
  */
628
629
  export function mergeTargetOf(command: string): number | null {
629
- const gh = /\bgh\s+pr\s+merge\s+(?:-{1,2}\S+\s+)*?(\d{1,7})\b/i.exec(command);
630
+ // Ten digits lexically, `PR_NUMBER_MAX` numerically — the API contract's own
631
+ // bound, imported from the mint site so the two cannot drift. The old
632
+ // `\d{1,7}` put the `\b` after digit seven, so an eight-digit target did not
633
+ // resolve to an out-of-range value: it resolved to NOTHING, and the gate fell
634
+ // back to the clock-bound path with no diagnostic.
635
+ const gh = /\bgh\s+pr\s+merge\s+(?:-{1,2}\S+\s+)*?(\d{1,10})\b/i.exec(command);
630
636
  if (gh) {
631
- return Number.parseInt(gh[1] as string, 10);
637
+ return _boundedTarget(gh[1] as string);
632
638
  }
633
639
  // The same operation by its REST spelling. `commandOp` already classifies
634
640
  // `gh api -X PUT repos/o/r/pulls/1499/merge` as `pr-merge`, so a reader that
635
641
  // missed it here would silently fall back to the clock.
636
- const api = /\/pulls\/(\d{1,7})\/merge\b/i.exec(command);
637
- return api ? Number.parseInt(api[1] as string, 10) : null;
642
+ const api = /\/pulls\/(\d{1,10})\/merge\b/i.exec(command);
643
+ return api ? _boundedTarget(api[1] as string) : null;
644
+ }
645
+
646
+ /** A target only counts inside the API's own range; anything else is no target. */
647
+ function _boundedTarget(digits: string): number | null {
648
+ const n = Number.parseInt(digits, 10);
649
+ return Number.isSafeInteger(n) && n > 0 && n <= PR_NUMBER_MAX ? n : null;
638
650
  }
639
651
 
640
652
  /**
@@ -67,6 +67,70 @@ const DESTRUCTIVE_MARKERS: ReadonlyArray<{ label: string; re: RegExp }> = [
67
67
  { label: "a whole file was deleted", re: /^deleted file mode /m },
68
68
  ];
69
69
 
70
+ /**
71
+ * Schema-removal patterns, ADDED-side anchor stripped, for use inside a rename.
72
+ *
73
+ * Kept as its own list rather than derived by rewriting the table above,
74
+ * because a regex rewritten at runtime is a second source of truth that no
75
+ * test reads.
76
+ */
77
+ const SCHEMA_REMOVAL_BODY =
78
+ /\b(dropColumn|dropTable|dropIfExists|removeColumn|drop_column|drop_table|DROP\s+(TABLE|COLUMN|INDEX|CONSTRAINT)|TRUNCATE\s+TABLE)\b/i;
79
+
80
+ /** Git's own rename metadata for one file block. */
81
+ const RENAME_METADATA = /^rename (?:from|to) /m;
82
+
83
+ /**
84
+ * Per-file blocks of a unified diff, split on git's own file header.
85
+ *
86
+ * The first element of the split is whatever precedes the first `diff --git`
87
+ * (usually empty, sometimes a commit header) and is dropped.
88
+ */
89
+ function fileBlocks(patch: string): string[] {
90
+ return patch
91
+ .split(/^(?=diff --git )/m)
92
+ .filter((b) => b.startsWith('diff --git '));
93
+ }
94
+
95
+ /**
96
+ * Does a RENAMED file drop schema on the side the added-line anchors cannot see?
97
+ *
98
+ * MEASURED at 022c0d240. A rename with `similarity index 88%` whose hunk reads
99
+ *
100
+ * -Schema::dropTable("users");
101
+ * +Schema::create("users");
102
+ *
103
+ * classified `additive`, markers `[]`. Both schema markers in the table above
104
+ * are anchored to `^\+` for a good reason — a migration that REMOVES a
105
+ * `dropColumn` call is the opposite of destructive, and that counter-case is
106
+ * pinned by its own test. But the reason stops holding inside a rename: the
107
+ * content did not stay put, so a removal on the old path is not "the drop was
108
+ * taken out", it is "the drop moved and the file it lived in is gone".
109
+ *
110
+ * So the widened scan is CONDITIONAL on git's rename metadata, per the risk
111
+ * this roadmap recorded against exactly this step — it is not applied to every
112
+ * deleted line, which would score the pinned counter-case destructive.
113
+ *
114
+ * NOT COVERED, and stated rather than implied: a `similarity index 100%` rename
115
+ * carries no content lines at all, so nothing here fires on it and it stays
116
+ * `additive`. That is the honest reading — a pure move drops nothing — but it
117
+ * does mean a moved migration is invisible to this scan. Recorded as a residual
118
+ * rather than closed by guessing at a path-shape heuristic.
119
+ */
120
+ function renamedSchemaRemoval(patch: string): boolean {
121
+ for (const block of fileBlocks(patch)) {
122
+ if (!RENAME_METADATA.test(block)) {
123
+ continue;
124
+ }
125
+ for (const line of block.split('\n')) {
126
+ if (line.startsWith('-') && !line.startsWith('---') && SCHEMA_REMOVAL_BODY.test(line)) {
127
+ return true;
128
+ }
129
+ }
130
+ }
131
+ return false;
132
+ }
133
+
70
134
  /** A major-version bump in a manifest, which is the author declaring a break. */
71
135
  const MAJOR_BUMP =
72
136
  /^-\s*"version"\s*:\s*"(\d+)\.[^"]*"[\s\S]{0,200}?^\+\s*"version"\s*:\s*"(\d+)\./m;
@@ -89,6 +153,10 @@ export function classifyDiff(patch: string): MergeImpact {
89
153
  }
90
154
  }
91
155
 
156
+ if (renamedSchemaRemoval(patch)) {
157
+ markers.push("a renamed file drops a column or table");
158
+ }
159
+
92
160
  const bump = MAJOR_BUMP.exec(patch);
93
161
  if (bump && bump[1] !== bump[2]) {
94
162
  markers.push(`major version bump ${bump[1]}.x to ${bump[2]}.x`);
@@ -194,6 +194,7 @@ import {
194
194
  _target_from_branch,
195
195
  create_and_push_annotated_tag,
196
196
  guard_publication,
197
+ checkout_release_branch,
197
198
  guard_release_curation,
198
199
  die,
199
200
  gh,
@@ -1177,23 +1178,9 @@ function execute(
1177
1178
  const pr_merged = pr_state === 'MERGED';
1178
1179
 
1179
1180
  // ─── 1. branch ──────────────────────────────────────────────────────────
1180
- if (pr_merged) {
1181
- _step(1, total, `PR for ${branch} already merged — staying on ${MAIN_BRANCH}`);
1182
- if (git(['rev-parse', '--abbrev-ref', 'HEAD'], { capture: true }) !== MAIN_BRANCH) {
1183
- run(['git', 'checkout', MAIN_BRANCH]);
1184
- }
1185
- run(['git', 'pull', '--ff-only', REMOTE, MAIN_BRANCH]);
1186
- } else if (resume && _branch_exists_local(branch)) {
1187
- _step(1, total, `Branch ${branch} exists locally — checkout`);
1188
- run(['git', 'checkout', branch]);
1189
- } else if (resume && _branch_exists_remote(branch)) {
1190
- _step(1, total, `Branch ${branch} exists on ${REMOTE} — fetch + checkout`);
1191
- run(['git', 'fetch', REMOTE, branch]);
1192
- run(['git', 'checkout', '-b', branch, `${REMOTE}/${branch}`]);
1193
- } else {
1194
- _step(1, total, `Create branch ${branch}`);
1195
- run(['git', 'checkout', '-b', branch]);
1196
- }
1181
+ checkout_release_branch(branch, pr_merged, (m) => {
1182
+ _step(1, total, m);
1183
+ });
1197
1184
 
1198
1185
  // ─── 1b. era split (optional, separate commit) ─────────────────────────
1199
1186
  // Lands as `chore(changelog): split era ...` BEFORE the release commit