@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
@@ -61,12 +61,17 @@ import path from "node:path";
61
61
  import { fileURLToPath, pathToFileURL } from "node:url";
62
62
 
63
63
  import { readHookStdin } from "./hook_stdin.js";
64
+ import { analyseMergeImpact, describeImpact } from "./merge_impact.js";
64
65
  import { atomic_write_json } from "./state_io.js";
65
66
  import {
67
+ consumeGrantTarget,
66
68
  ledgerFileFor,
67
69
  pendingFileFor,
70
+ PR_NUMBER_MAX,
71
+ readGrants,
68
72
  STATE_FILE,
69
73
  type GitOp,
74
+ type MergeGrant,
70
75
  } from "../git_authorization_hook.js";
71
76
 
72
77
  const EXIT_ALLOW = 0;
@@ -95,6 +100,31 @@ export const BLOCK_OPS: ReadonlySet<GitOp> = new Set<GitOp>([
95
100
  // Enabling auto-merge commits the outcome to a condition the agent does not
96
101
  // control, so it is blocked on the same terms as the merge it schedules.
97
102
  "pr-merge-auto",
103
+
104
+ // ── Added 2026-09-03. Every one is already never-autonomous in the
105
+ // `non-destructive-by-default` prose; the measurement that drove it is in the
106
+ // `GitOp` union's own note — 17 of 25 borderline operations classified as
107
+ // NOTHING, including three worse than anything the guard already caught.
108
+ /** The undo that is more destructive than the act: lockfiles break. */
109
+ "unpublish",
110
+ /** Metadata only, and every install on earth starts warning. */
111
+ "deprecate",
112
+ /** Replaces bytes consumers already hold a checksum for. */
113
+ "release-asset",
114
+ /** Deletes the guard rather than the data — everything after is unguarded. */
115
+ "protection",
116
+ /** No data lost; the gate that protects everything downstream is off. */
117
+ "workflow-toggle",
118
+ /** Reach outside the repository, and mostly one-way. */
119
+ "repo-lifecycle",
120
+ /** No code moves, and an armed auto-merge is released. */
121
+ "review-approve",
122
+ /** Discards commits that landed after the last fetch, on a shared ref. */
123
+ "force-push",
124
+ /** Destroys uncommitted work belonging to a session this one cannot see. */
125
+ "worktree-remove",
126
+ /** `-x` is the flag that takes `.env` and local certificates. */
127
+ "clean-ignored",
98
128
  ]);
99
129
 
100
130
  /** Recoverable operations — warned without a this-turn authorization. */
@@ -103,6 +133,22 @@ export const WARN_OPS: ReadonlySet<GitOp> = new Set<GitOp>([
103
133
  "push",
104
134
  "pr-create",
105
135
  "branch",
136
+
137
+ // ── Added 2026-09-03, deliberately at warn rather than block.
138
+ //
139
+ // Each is destructive in some reading, and each is also routine enough that
140
+ // blocking it would be the friction that gets the whole guard routed around —
141
+ // the failure mode this estate has already recorded twice. `reset-hard` is the
142
+ // sharpest case and stays here for a decidable reason: the Hard Floor's
143
+ // qualifier is "past unpushed work", and the command text cannot say whether
144
+ // the work was pushed.
145
+ "tag-force",
146
+ "rebase",
147
+ "reset-hard",
148
+ "clean",
149
+ "stash-drop",
150
+ "branch-delete",
151
+ "close",
106
152
  ]);
107
153
 
108
154
  /**
@@ -191,6 +237,74 @@ const COMMAND_OPS: ReadonlyArray<{ op: GitOp; re: RegExp }> = [
191
237
  op: "pr-create",
192
238
  re: new RegExp(`${P}gh\\s+pr\\s+create\\b|${ghApiWrite("\\/pulls\\b").source}`, "i"),
193
239
  },
240
+ // ── Added 2026-09-03. Placement inside this table is as load-bearing as the
241
+ // patterns: the loop takes the MOST SEVERE match per segment, but within one
242
+ // op the first pattern wins, so anything more specific than a plain `git push`
243
+ // has to sit above it.
244
+
245
+ // `npm unpublish` is NOT reached by the publish pattern — `\bpublish\b` needs
246
+ // a word boundary that `unpublish` does not provide between `n` and `p`. That
247
+ // is why it classified as nothing for as long as it did.
248
+ { op: "unpublish", re: new RegExp(`${P}(npm|pnpm|yarn)\\s+${G}unpublish\\b`, "i") },
249
+ { op: "deprecate", re: new RegExp(`${P}(npm|pnpm|yarn)\\s+${G}deprecate\\b`, "i") },
250
+ {
251
+ op: "release-asset",
252
+ re: new RegExp(`${P}gh\\s+release\\s+(upload|edit|delete)\\b`, "i"),
253
+ },
254
+ {
255
+ op: "protection",
256
+ re: new RegExp(
257
+ `${P}gh\\s+api\\b(?=[^\\n]*(?:protection|rulesets?)\\b)(?=[^\\n]*(?:-X|--method)\\s+(?:DELETE|PUT|PATCH|POST)\\b)|${P}gh\\s+ruleset\\s+delete\\b`,
258
+ "i",
259
+ ),
260
+ },
261
+ {
262
+ op: "workflow-toggle",
263
+ re: new RegExp(`${P}gh\\s+workflow\\s+(disable|enable)\\b`, "i"),
264
+ },
265
+ {
266
+ op: "repo-lifecycle",
267
+ re: new RegExp(
268
+ `${P}gh\\s+repo\\s+(archive|delete|unarchive)\\b|${P}gh\\s+repo\\s+edit\\b(?=[^\\n]*--visibility\\b)`,
269
+ "i",
270
+ ),
271
+ },
272
+ {
273
+ op: "review-approve",
274
+ re: new RegExp(`${P}gh\\s+pr\\s+review\\b(?=[^\\n]*--approve\\b)`, "i"),
275
+ },
276
+ // Above `push`, and both spellings: `--force` and `--force-with-lease`. The
277
+ // "safe force" is in the same class here because what it is safe against is a
278
+ // stale local view, not a collaborator who pushed after your fetch.
279
+ {
280
+ op: "force-push",
281
+ re: new RegExp(`${P}git\\s+${G}push\\b[^\\n;|&]*(--force\\b|--force-with-lease\\b|\\s-f\\b)`, "i"),
282
+ },
283
+ {
284
+ op: "worktree-remove",
285
+ re: new RegExp(`${P}git\\s+worktree\\s+remove\\b[^\\n;|&]*(--force\\b|\\s-f\\b)`, "i"),
286
+ },
287
+ // `-x` (or `-X`) anywhere in the flag cluster is what reaches ignored files.
288
+ {
289
+ op: "clean-ignored",
290
+ re: new RegExp(`${P}git\\s+clean\\b[^\\n;|&]*-[A-Za-z]*x`, "i"),
291
+ },
292
+ { op: "tag-force", re: new RegExp(`${P}git\\s+tag\\b[^\\n;|&]*(-f\\b|--force\\b)`, "i") },
293
+ { op: "rebase", re: new RegExp(`${P}git\\s+${G}rebase\\b`, "i") },
294
+ { op: "reset-hard", re: new RegExp(`${P}git\\s+${G}reset\\b[^\\n;|&]*--hard\\b`, "i") },
295
+ { op: "clean", re: new RegExp(`${P}git\\s+clean\\b`, "i") },
296
+ { op: "stash-drop", re: new RegExp(`${P}git\\s+stash\\s+(drop|clear)\\b`, "i") },
297
+ // Remote deletion and the local `-D` are one op: both remove a ref, and the
298
+ // remote form additionally closes any open PR pointing at it.
299
+ {
300
+ op: "branch-delete",
301
+ re: new RegExp(
302
+ `${P}git\\s+branch\\b[^\\n;|&]*(-D\\b|--delete\\b)|${P}git\\s+${G}push\\b[^\\n;|&]*(--delete\\b|\\s:\\S)`,
303
+ "i",
304
+ ),
305
+ },
306
+ { op: "close", re: new RegExp(`${P}gh\\s+(pr|issue)\\s+close\\b`, "i") },
307
+
194
308
  { op: "push", re: new RegExp(`${P}git\\s+${G}push\\b`, "i") },
195
309
  { op: "commit", re: new RegExp(`${P}git\\s+${G}commit\\b`, "i") },
196
310
  { op: "branch", re: new RegExp(`${P}git\\s+${G}(checkout\\s+-b|switch\\s+-c)\\b`, "i") },
@@ -503,6 +617,66 @@ export function commandOp(command: string): GitOp | null {
503
617
  return found;
504
618
  }
505
619
 
620
+ /**
621
+ * The pull request a merge command acts on, or `null` when the command does not
622
+ * say.
623
+ *
624
+ * `gh pr merge` with no number merges whatever the current branch's PR is — the
625
+ * command text identifies no object, so no grant can match it and the
626
+ * clock-bound path applies unchanged. That is the fail-closed direction: an
627
+ * unnamed target never consumes a grant minted for a named one.
628
+ */
629
+ export function mergeTargetOf(command: string): number | null {
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);
636
+ if (gh) {
637
+ return _boundedTarget(gh[1] as string);
638
+ }
639
+ // The same operation by its REST spelling. `commandOp` already classifies
640
+ // `gh api -X PUT repos/o/r/pulls/1499/merge` as `pr-merge`, so a reader that
641
+ // missed it here would silently fall back to the clock.
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;
650
+ }
651
+
652
+ /**
653
+ * Does a standing grant cover this exact merge?
654
+ *
655
+ * Three conditions, all necessary: the grant is for this op, the command named
656
+ * a target, and that target sits in the grant's frozen set unspent.
657
+ *
658
+ * No clock is consulted here, and that is the whole of ADR-252. `authorized`
659
+ * answers "did the user name this operation recently"; a grant answers "did the
660
+ * user name this operation AND this pull request". The second question does not
661
+ * decay with time, because the object it names cannot drift into a different
662
+ * object — a force-push changes the SHA, but the grant is spent on first use, so
663
+ * a merge that already happened can never be replayed against a moved head.
664
+ */
665
+ export function grantCovers(
666
+ grants: MergeGrant[],
667
+ op: GitOp,
668
+ target: number | null,
669
+ ): MergeGrant | null {
670
+ if (op !== "pr-merge" || target === null) {
671
+ return null;
672
+ }
673
+ return (
674
+ grants.find(
675
+ (g) => g.op === "pr-merge" && g.targets.includes(target) && !g.consumed.includes(target),
676
+ ) ?? null
677
+ );
678
+ }
679
+
506
680
  /**
507
681
  * A ledger older than this is not "this turn" under any reading.
508
682
  *
@@ -558,7 +732,15 @@ function _loadLedger(
558
732
  consumer_root: string,
559
733
  session_id: string,
560
734
  now: number,
561
- ): { authorized: Set<GitOp>; present: boolean } {
735
+ ): { authorized: Set<GitOp>; present: boolean; grants: MergeGrant[] } {
736
+ // Grants are read on their OWN path, and the separation is load-bearing.
737
+ // `_readLedgerFile` below discards the entire ledger once `detected_at` is
738
+ // older than `LEDGER_MAX_AGE_MS`, so folding grants into its return value
739
+ // would let the clock silently kill them too — the change would ship, look
740
+ // correct, and do nothing after thirty minutes, with no test going red.
741
+ // `readGrants` performs the session check and no age check, which is exactly
742
+ // the contract: object identity, not recency (ADR-252).
743
+ const grants = readGrants(consumer_root, session_id);
562
744
  // This session's own ledger first; the flat legacy file only as a fallback,
563
745
  // so a ledger written by an older build still counts. Reading the flat file
564
746
  // FIRST was the defect: any concurrent session in the repo overwrites it, and
@@ -570,10 +752,10 @@ function _loadLedger(
570
752
  for (const rel of candidates) {
571
753
  const found = _readLedgerFile(path.join(consumer_root, rel), session_id, now);
572
754
  if (found !== null) {
573
- return found;
755
+ return { ...found, grants };
574
756
  }
575
757
  }
576
- return { authorized: new Set<GitOp>(), present: false };
758
+ return { authorized: new Set<GitOp>(), present: false, grants };
577
759
  }
578
760
 
579
761
  /**
@@ -622,11 +804,21 @@ export interface Decision {
622
804
  * `git_authorization_hook.PENDING_FILE` for why that record exists at all.
623
805
  */
624
806
  blockedOp?: GitOp;
807
+ /**
808
+ * The grant target this decision spent, when a standing grant is what allowed
809
+ * it.
810
+ *
811
+ * `decide` stays pure; `run` turns this into the ledger write that marks the
812
+ * target used. Single use is the property that keeps a grant from being a
813
+ * standing licence: the user authorized merging PR #1499, once.
814
+ */
815
+ consumedTarget?: number;
625
816
  }
626
817
 
627
818
  export function decide(
628
819
  command: string | null,
629
- ledger: { authorized: Set<GitOp>; present: boolean },
820
+ ledger: { authorized: Set<GitOp>; present: boolean; grants?: MergeGrant[] },
821
+ impact?: string,
630
822
  ): Decision {
631
823
  if (command === null) {
632
824
  return { exit: EXIT_ALLOW, stdout: "", stderr: "" };
@@ -636,13 +828,30 @@ export function decide(
636
828
  return { exit: EXIT_ALLOW, stdout: "", stderr: "" };
637
829
  }
638
830
 
831
+ // A standing grant is the SECOND way an operation can be authorized, and it
832
+ // is strictly narrower than the first rather than an escape from it. Only
833
+ // `pr-merge` can hold one, the command must name its pull request, and that
834
+ // number must sit unspent in a set the user's own sentence froze. An unnamed
835
+ // target matches nothing and a spent target matches nothing, so every path
836
+ // that is not exactly "the user named this PR and it has not been merged yet"
837
+ // falls through to the clock-bound decision below unchanged (ADR-252).
838
+ const target = mergeTargetOf(command);
839
+ if (grantCovers(ledger.grants ?? [], op, target) && target !== null) {
840
+ return { exit: EXIT_ALLOW, stdout: "", stderr: "", consumedTarget: target };
841
+ }
842
+
639
843
  const shown = command.length > 120 ? `${command.slice(0, 117)}…` : command;
640
844
 
641
845
  if (BLOCK_OPS.has(op)) {
846
+ // STAGE 2. The refusal stands either way — what the scan changes is whether
847
+ // the user has to go read the diff to answer it. `impact` is injected by
848
+ // `run`, never computed here, so `decide` stays pure and testable without a
849
+ // repository.
850
+ const scan = op === "pr-merge" && target !== null && impact ? ` ${impact}` : "";
642
851
  const reason =
643
852
  `Blocked: \`${op}\` with no authorization in this turn's prompt. ` +
644
853
  `This operation is irreversible and non-destructive-by-default already declares it ` +
645
- `never-autonomous. Command: ${shown}. ` +
854
+ `never-autonomous. Command: ${shown}.${scan} ` +
646
855
  `Ask the user, in one numbered-options block, and run it only after they answer this turn.`;
647
856
  return { exit: EXIT_BLOCK, stdout: "", stderr: `${reason}\n`, blockedOp: op };
648
857
  }
@@ -675,10 +884,42 @@ export function run(stdin_text: string, options: { consumer_root: string }): num
675
884
  }
676
885
  }
677
886
  const session_id = typeof envelope["session_id"] === "string" ? envelope["session_id"] : "";
678
- const decision = decide(
679
- extractCommand(envelope),
680
- _loadLedger(options.consumer_root, session_id, Date.now()),
681
- );
887
+ const command = extractCommand(envelope);
888
+ const ledger = _loadLedger(options.consumer_root, session_id, Date.now());
889
+
890
+ // STAGE 2 runs only on the path where it can change something: a merge that is
891
+ // ABOUT to be refused. Not on an allowed merge (nothing to tell the user), not
892
+ // on an op whose target the command does not name, and never on publish or
893
+ // release — a scan costs a diff read, and spending one where it cannot change
894
+ // the outcome is latency in the user's critical path for nothing.
895
+ let impact: string | undefined;
896
+ const stage1 = command === null ? null : commandOp(command);
897
+ const stage2Target = command === null ? null : mergeTargetOf(command);
898
+ if (
899
+ stage1 === "pr-merge" &&
900
+ stage2Target !== null &&
901
+ !ledger.authorized.has("pr-merge") &&
902
+ !grantCovers(ledger.grants ?? [], "pr-merge", stage2Target)
903
+ ) {
904
+ try {
905
+ impact = describeImpact(
906
+ stage2Target,
907
+ analyseMergeImpact(stage2Target, { cwd: options.consumer_root }),
908
+ );
909
+ } catch {
910
+ // The scan is an enrichment, never a gate. A refusal without it is the
911
+ // pre-stage-2 message, which is still correct.
912
+ }
913
+ }
914
+
915
+ const decision = decide(command, ledger, impact);
916
+ if (decision.consumedTarget !== undefined) {
917
+ // Spend the target at the moment the merge is actually let through, never
918
+ // when the grant was minted. A grant the run never reached stays whole, and
919
+ // a pull request that merged can never be replayed — which is what makes a
920
+ // force-push back to an already-merged head harmless here.
921
+ consumeGrantTarget(options.consumer_root, session_id, decision.consumedTarget);
922
+ }
682
923
  if (decision.blockedOp) {
683
924
  // Record WHAT was refused, so the user's answer to the question this
684
925
  // refusal just told the agent to ask can authorize that one operation.
@@ -0,0 +1,275 @@
1
+ /**
2
+ * Stage-2 impact analysis for a merge the guard is about to refuse.
3
+ *
4
+ * STAGE 1 asks "is this operation in the blocked set?" — a question about the
5
+ * command's shape, answered by `commandOp`. STAGE 2 asks "what would it
6
+ * actually do?" — a question about the diff, answered here.
7
+ *
8
+ * WHAT THIS DOES NOT DO, stated first because the opposite is the tempting
9
+ * reading: it never downgrades a refusal into an allow. Both council seats
10
+ * (2026-09-03, anthropic + codex-default, deep) rejected exactly that, and the
11
+ * argument is not about diff parsing being imperfect — it is that a purely
12
+ * additive diff can still fire a production deploy, close issues, hit outbound
13
+ * webhooks, and land against a base that moved since the review. Diff content
14
+ * is one axis; the destination is the other, and nothing readable from a patch
15
+ * tells you what merging into that branch triggers.
16
+ *
17
+ * So the verdict enriches the REFUSAL. A block that says "this PR adds two
18
+ * files and touches no migration, no exported symbol and no version — say
19
+ * `merge #1499` to authorize" is a question the user can answer in four words,
20
+ * including out loud. A block that says "refused" makes them go read the diff
21
+ * themselves. Same gate, and the difference is entirely in whether the human
22
+ * can act on it.
23
+ *
24
+ * THREE-VALUED ON PURPOSE. `undecidable` is a real answer and the most common
25
+ * one offline: no ref, no network, an unparsable patch. Collapsing it into
26
+ * `additive` would be the failure this module exists to avoid, so every unknown
27
+ * resolves toward "ask".
28
+ */
29
+ import { execFileSync } from "node:child_process";
30
+
31
+ export type ImpactVerdict = "additive" | "destructive" | "undecidable";
32
+
33
+ export interface MergeImpact {
34
+ verdict: ImpactVerdict;
35
+ /** Human-readable markers behind a `destructive` verdict, in diff order. */
36
+ markers: string[];
37
+ /** Why the analysis could not run, when the verdict is `undecidable`. */
38
+ reason?: string;
39
+ /** Files the diff touches, for the one-line summary in a refusal. */
40
+ filesChanged?: number;
41
+ }
42
+
43
+ /**
44
+ * Destructive markers, each decidable from the patch text alone.
45
+ *
46
+ * DELIBERATELY A PROXY SET, NOT A BREAKING-CHANGE ORACLE. "Does this break a
47
+ * consumer" is undecidable in general; these are the shapes that are cheap to
48
+ * see and expensive to miss. A marker firing means "a human should look", never
49
+ * "this is certainly breaking" — and the absence of every marker means "nothing
50
+ * cheap to see fired", never "this is certainly safe". That asymmetry is why an
51
+ * `additive` verdict still refuses.
52
+ */
53
+ const DESTRUCTIVE_MARKERS: ReadonlyArray<{ label: string; re: RegExp }> = [
54
+ // Schema removal. Matched on ADDED lines only — a migration that *removes* a
55
+ // dropColumn call is the opposite of destructive, and matching the raw patch
56
+ // would score it the same way.
57
+ { label: "migration drops a column or table", re: /^\+.*\b(dropColumn|dropTable|dropIfExists|removeColumn|drop_column|drop_table)\b/im },
58
+ { label: "raw DROP or TRUNCATE in a migration", re: /^\+.*\b(DROP\s+(TABLE|COLUMN|INDEX|CONSTRAINT)|TRUNCATE\s+TABLE)\b/im },
59
+ // The author said so themselves. Conventional Commits marks a breaking change
60
+ // with a `!` before the colon or a body trailer.
61
+ { label: "author marked a BREAKING CHANGE", re: /^\+.*BREAKING[ -]CHANGE/m },
62
+ // A removed export is the shape most likely to break an importer.
63
+ { label: "an exported symbol was removed", re: /^-\s*export\s+(default\s+|const\s+|function\s+|class\s+|interface\s+|type\s+|async\s+)/m },
64
+ { label: "a public PHP or Python symbol was removed", re: /^-\s*(public\s+function|def\s+[a-z_]+\s*\()/m },
65
+ // Route removal breaks callers that never imported anything.
66
+ { label: "a route or endpoint was removed", re: /^-.*\b(Route::(get|post|put|patch|delete)|@(Get|Post|Put|Patch|Delete)Mapping|app\.(get|post|put|patch|delete)\s*\()/m },
67
+ { label: "a whole file was deleted", re: /^deleted file mode /m },
68
+ ];
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
+
134
+ /** A major-version bump in a manifest, which is the author declaring a break. */
135
+ const MAJOR_BUMP =
136
+ /^-\s*"version"\s*:\s*"(\d+)\.[^"]*"[\s\S]{0,200}?^\+\s*"version"\s*:\s*"(\d+)\./m;
137
+
138
+ /**
139
+ * Classify a unified diff.
140
+ *
141
+ * Pure — the caller supplies the patch, so this is testable without a network,
142
+ * a repository, or a fixture PR.
143
+ */
144
+ export function classifyDiff(patch: string): MergeImpact {
145
+ if (patch.trim() === "") {
146
+ return { verdict: "undecidable", markers: [], reason: "empty diff" };
147
+ }
148
+
149
+ const markers: string[] = [];
150
+ for (const { label, re } of DESTRUCTIVE_MARKERS) {
151
+ if (re.test(patch)) {
152
+ markers.push(label);
153
+ }
154
+ }
155
+
156
+ if (renamedSchemaRemoval(patch)) {
157
+ markers.push("a renamed file drops a column or table");
158
+ }
159
+
160
+ const bump = MAJOR_BUMP.exec(patch);
161
+ if (bump && bump[1] !== bump[2]) {
162
+ markers.push(`major version bump ${bump[1]}.x to ${bump[2]}.x`);
163
+ }
164
+
165
+ const filesChanged = (patch.match(/^diff --git /gm) ?? []).length;
166
+
167
+ if (markers.length > 0) {
168
+ return { verdict: "destructive", markers, filesChanged };
169
+ }
170
+ return { verdict: "additive", markers: [], filesChanged };
171
+ }
172
+
173
+ /** How the patch was obtained, so a refusal can say which source answered. */
174
+ export type PatchSource = "local" | "gh" | "none";
175
+
176
+ export interface PatchResult {
177
+ patch: string;
178
+ source: PatchSource;
179
+ reason?: string;
180
+ }
181
+
182
+ /**
183
+ * Fetch the diff for a pull request, local first.
184
+ *
185
+ * The ordering is not a preference, it is a cost decision. A local
186
+ * `git diff` against the merge base costs milliseconds and works offline; a
187
+ * `gh pr diff` costs a network round trip on a `pre_tool_use` hook, which is in
188
+ * the user's critical path. Local answers whenever the ref happens to be
189
+ * fetched, which in a drain run is almost always, because the run just synced
190
+ * the branch it is about to merge.
191
+ *
192
+ * Every failure returns `none` rather than throwing. The caller turns that into
193
+ * `undecidable`, which asks the user — a hook that crashed would be worse than
194
+ * a hook that admitted it did not know.
195
+ */
196
+ export function fetchPatch(
197
+ pr: number,
198
+ opts: { cwd?: string; allowNetwork?: boolean; exec?: typeof execFileSync } = {},
199
+ ): PatchResult {
200
+ const run = opts.exec ?? execFileSync;
201
+ const cwd = opts.cwd ?? process.cwd();
202
+ const capture = (file: string, args: string[]): string =>
203
+ run(file, args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 15_000 }) as unknown as string;
204
+
205
+ // A fetched PR ref is what a synced drain run leaves behind.
206
+ for (const ref of [`refs/pull/${pr}/head`, `pull/${pr}/head`]) {
207
+ try {
208
+ const base = capture("git", ["merge-base", "HEAD", ref]).trim();
209
+ if (base) {
210
+ const patch = capture("git", ["diff", `${base}...${ref}`]);
211
+ if (patch.trim() !== "") {
212
+ return { patch, source: "local" };
213
+ }
214
+ }
215
+ } catch {
216
+ /* try the next spelling, then the network */
217
+ }
218
+ }
219
+
220
+ if (opts.allowNetwork === false) {
221
+ return { patch: "", source: "none", reason: "no local ref and network reads disabled" };
222
+ }
223
+
224
+ try {
225
+ const patch = capture("gh", ["pr", "diff", String(pr), "--patch"]);
226
+ if (patch.trim() !== "") {
227
+ return { patch, source: "gh" };
228
+ }
229
+ return { patch: "", source: "none", reason: "gh returned an empty diff" };
230
+ } catch {
231
+ return { patch: "", source: "none", reason: "no local ref, and gh pr diff failed" };
232
+ }
233
+ }
234
+
235
+ /** Analyse a pull request end to end. `undecidable` whenever the patch is not readable. */
236
+ export function analyseMergeImpact(
237
+ pr: number,
238
+ opts: Parameters<typeof fetchPatch>[1] = {},
239
+ ): MergeImpact {
240
+ const got = fetchPatch(pr, opts);
241
+ if (got.source === "none") {
242
+ return { verdict: "undecidable", markers: [], ...(got.reason ? { reason: got.reason } : {}) };
243
+ }
244
+ return classifyDiff(got.patch);
245
+ }
246
+
247
+ /**
248
+ * One line for a refusal message.
249
+ *
250
+ * Phrased so the user can answer it out loud, which is the point of the whole
251
+ * stage: the shortest reply that unblocks the operation is quoted back to them.
252
+ */
253
+ export function describeImpact(pr: number, impact: MergeImpact): string {
254
+ const say = `Say "merge #${pr}" to authorize it.`;
255
+ switch (impact.verdict) {
256
+ case "additive":
257
+ return (
258
+ `Impact scan: PR #${pr} touches ${impact.filesChanged ?? 0} file(s) and trips no ` +
259
+ `destructive marker — no schema drop, no removed export or route, no major bump, ` +
260
+ `no BREAKING CHANGE. Additive as far as the diff can show, which is not the same ` +
261
+ `as safe to merge: the base has moved since review and merging may trigger a ` +
262
+ `deploy. ${say}`
263
+ );
264
+ case "destructive":
265
+ return (
266
+ `Impact scan: PR #${pr} is NOT additive — ${impact.markers.join("; ")}. ` +
267
+ `Read the diff before authorizing. ${say}`
268
+ );
269
+ default:
270
+ return (
271
+ `Impact scan: could not read the diff for PR #${pr} (${impact.reason ?? "unknown"}), ` +
272
+ `so nothing is known about what it changes. ${say}`
273
+ );
274
+ }
275
+ }