@event4u/agent-config 14.14.1 → 14.15.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.
@@ -61,12 +61,16 @@ 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
+ readGrants,
68
71
  STATE_FILE,
69
72
  type GitOp,
73
+ type MergeGrant,
70
74
  } from "../git_authorization_hook.js";
71
75
 
72
76
  const EXIT_ALLOW = 0;
@@ -95,6 +99,31 @@ export const BLOCK_OPS: ReadonlySet<GitOp> = new Set<GitOp>([
95
99
  // Enabling auto-merge commits the outcome to a condition the agent does not
96
100
  // control, so it is blocked on the same terms as the merge it schedules.
97
101
  "pr-merge-auto",
102
+
103
+ // ── Added 2026-09-03. Every one is already never-autonomous in the
104
+ // `non-destructive-by-default` prose; the measurement that drove it is in the
105
+ // `GitOp` union's own note — 17 of 25 borderline operations classified as
106
+ // NOTHING, including three worse than anything the guard already caught.
107
+ /** The undo that is more destructive than the act: lockfiles break. */
108
+ "unpublish",
109
+ /** Metadata only, and every install on earth starts warning. */
110
+ "deprecate",
111
+ /** Replaces bytes consumers already hold a checksum for. */
112
+ "release-asset",
113
+ /** Deletes the guard rather than the data — everything after is unguarded. */
114
+ "protection",
115
+ /** No data lost; the gate that protects everything downstream is off. */
116
+ "workflow-toggle",
117
+ /** Reach outside the repository, and mostly one-way. */
118
+ "repo-lifecycle",
119
+ /** No code moves, and an armed auto-merge is released. */
120
+ "review-approve",
121
+ /** Discards commits that landed after the last fetch, on a shared ref. */
122
+ "force-push",
123
+ /** Destroys uncommitted work belonging to a session this one cannot see. */
124
+ "worktree-remove",
125
+ /** `-x` is the flag that takes `.env` and local certificates. */
126
+ "clean-ignored",
98
127
  ]);
99
128
 
100
129
  /** Recoverable operations — warned without a this-turn authorization. */
@@ -103,6 +132,22 @@ export const WARN_OPS: ReadonlySet<GitOp> = new Set<GitOp>([
103
132
  "push",
104
133
  "pr-create",
105
134
  "branch",
135
+
136
+ // ── Added 2026-09-03, deliberately at warn rather than block.
137
+ //
138
+ // Each is destructive in some reading, and each is also routine enough that
139
+ // blocking it would be the friction that gets the whole guard routed around —
140
+ // the failure mode this estate has already recorded twice. `reset-hard` is the
141
+ // sharpest case and stays here for a decidable reason: the Hard Floor's
142
+ // qualifier is "past unpushed work", and the command text cannot say whether
143
+ // the work was pushed.
144
+ "tag-force",
145
+ "rebase",
146
+ "reset-hard",
147
+ "clean",
148
+ "stash-drop",
149
+ "branch-delete",
150
+ "close",
106
151
  ]);
107
152
 
108
153
  /**
@@ -191,6 +236,74 @@ const COMMAND_OPS: ReadonlyArray<{ op: GitOp; re: RegExp }> = [
191
236
  op: "pr-create",
192
237
  re: new RegExp(`${P}gh\\s+pr\\s+create\\b|${ghApiWrite("\\/pulls\\b").source}`, "i"),
193
238
  },
239
+ // ── Added 2026-09-03. Placement inside this table is as load-bearing as the
240
+ // patterns: the loop takes the MOST SEVERE match per segment, but within one
241
+ // op the first pattern wins, so anything more specific than a plain `git push`
242
+ // has to sit above it.
243
+
244
+ // `npm unpublish` is NOT reached by the publish pattern — `\bpublish\b` needs
245
+ // a word boundary that `unpublish` does not provide between `n` and `p`. That
246
+ // is why it classified as nothing for as long as it did.
247
+ { op: "unpublish", re: new RegExp(`${P}(npm|pnpm|yarn)\\s+${G}unpublish\\b`, "i") },
248
+ { op: "deprecate", re: new RegExp(`${P}(npm|pnpm|yarn)\\s+${G}deprecate\\b`, "i") },
249
+ {
250
+ op: "release-asset",
251
+ re: new RegExp(`${P}gh\\s+release\\s+(upload|edit|delete)\\b`, "i"),
252
+ },
253
+ {
254
+ op: "protection",
255
+ re: new RegExp(
256
+ `${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`,
257
+ "i",
258
+ ),
259
+ },
260
+ {
261
+ op: "workflow-toggle",
262
+ re: new RegExp(`${P}gh\\s+workflow\\s+(disable|enable)\\b`, "i"),
263
+ },
264
+ {
265
+ op: "repo-lifecycle",
266
+ re: new RegExp(
267
+ `${P}gh\\s+repo\\s+(archive|delete|unarchive)\\b|${P}gh\\s+repo\\s+edit\\b(?=[^\\n]*--visibility\\b)`,
268
+ "i",
269
+ ),
270
+ },
271
+ {
272
+ op: "review-approve",
273
+ re: new RegExp(`${P}gh\\s+pr\\s+review\\b(?=[^\\n]*--approve\\b)`, "i"),
274
+ },
275
+ // Above `push`, and both spellings: `--force` and `--force-with-lease`. The
276
+ // "safe force" is in the same class here because what it is safe against is a
277
+ // stale local view, not a collaborator who pushed after your fetch.
278
+ {
279
+ op: "force-push",
280
+ re: new RegExp(`${P}git\\s+${G}push\\b[^\\n;|&]*(--force\\b|--force-with-lease\\b|\\s-f\\b)`, "i"),
281
+ },
282
+ {
283
+ op: "worktree-remove",
284
+ re: new RegExp(`${P}git\\s+worktree\\s+remove\\b[^\\n;|&]*(--force\\b|\\s-f\\b)`, "i"),
285
+ },
286
+ // `-x` (or `-X`) anywhere in the flag cluster is what reaches ignored files.
287
+ {
288
+ op: "clean-ignored",
289
+ re: new RegExp(`${P}git\\s+clean\\b[^\\n;|&]*-[A-Za-z]*x`, "i"),
290
+ },
291
+ { op: "tag-force", re: new RegExp(`${P}git\\s+tag\\b[^\\n;|&]*(-f\\b|--force\\b)`, "i") },
292
+ { op: "rebase", re: new RegExp(`${P}git\\s+${G}rebase\\b`, "i") },
293
+ { op: "reset-hard", re: new RegExp(`${P}git\\s+${G}reset\\b[^\\n;|&]*--hard\\b`, "i") },
294
+ { op: "clean", re: new RegExp(`${P}git\\s+clean\\b`, "i") },
295
+ { op: "stash-drop", re: new RegExp(`${P}git\\s+stash\\s+(drop|clear)\\b`, "i") },
296
+ // Remote deletion and the local `-D` are one op: both remove a ref, and the
297
+ // remote form additionally closes any open PR pointing at it.
298
+ {
299
+ op: "branch-delete",
300
+ re: new RegExp(
301
+ `${P}git\\s+branch\\b[^\\n;|&]*(-D\\b|--delete\\b)|${P}git\\s+${G}push\\b[^\\n;|&]*(--delete\\b|\\s:\\S)`,
302
+ "i",
303
+ ),
304
+ },
305
+ { op: "close", re: new RegExp(`${P}gh\\s+(pr|issue)\\s+close\\b`, "i") },
306
+
194
307
  { op: "push", re: new RegExp(`${P}git\\s+${G}push\\b`, "i") },
195
308
  { op: "commit", re: new RegExp(`${P}git\\s+${G}commit\\b`, "i") },
196
309
  { op: "branch", re: new RegExp(`${P}git\\s+${G}(checkout\\s+-b|switch\\s+-c)\\b`, "i") },
@@ -503,6 +616,55 @@ export function commandOp(command: string): GitOp | null {
503
616
  return found;
504
617
  }
505
618
 
619
+ /**
620
+ * The pull request a merge command acts on, or `null` when the command does not
621
+ * say.
622
+ *
623
+ * `gh pr merge` with no number merges whatever the current branch's PR is — the
624
+ * command text identifies no object, so no grant can match it and the
625
+ * clock-bound path applies unchanged. That is the fail-closed direction: an
626
+ * unnamed target never consumes a grant minted for a named one.
627
+ */
628
+ 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
+ if (gh) {
631
+ return Number.parseInt(gh[1] as string, 10);
632
+ }
633
+ // The same operation by its REST spelling. `commandOp` already classifies
634
+ // `gh api -X PUT repos/o/r/pulls/1499/merge` as `pr-merge`, so a reader that
635
+ // 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;
638
+ }
639
+
640
+ /**
641
+ * Does a standing grant cover this exact merge?
642
+ *
643
+ * Three conditions, all necessary: the grant is for this op, the command named
644
+ * a target, and that target sits in the grant's frozen set unspent.
645
+ *
646
+ * No clock is consulted here, and that is the whole of ADR-252. `authorized`
647
+ * answers "did the user name this operation recently"; a grant answers "did the
648
+ * user name this operation AND this pull request". The second question does not
649
+ * decay with time, because the object it names cannot drift into a different
650
+ * object — a force-push changes the SHA, but the grant is spent on first use, so
651
+ * a merge that already happened can never be replayed against a moved head.
652
+ */
653
+ export function grantCovers(
654
+ grants: MergeGrant[],
655
+ op: GitOp,
656
+ target: number | null,
657
+ ): MergeGrant | null {
658
+ if (op !== "pr-merge" || target === null) {
659
+ return null;
660
+ }
661
+ return (
662
+ grants.find(
663
+ (g) => g.op === "pr-merge" && g.targets.includes(target) && !g.consumed.includes(target),
664
+ ) ?? null
665
+ );
666
+ }
667
+
506
668
  /**
507
669
  * A ledger older than this is not "this turn" under any reading.
508
670
  *
@@ -558,7 +720,15 @@ function _loadLedger(
558
720
  consumer_root: string,
559
721
  session_id: string,
560
722
  now: number,
561
- ): { authorized: Set<GitOp>; present: boolean } {
723
+ ): { authorized: Set<GitOp>; present: boolean; grants: MergeGrant[] } {
724
+ // Grants are read on their OWN path, and the separation is load-bearing.
725
+ // `_readLedgerFile` below discards the entire ledger once `detected_at` is
726
+ // older than `LEDGER_MAX_AGE_MS`, so folding grants into its return value
727
+ // would let the clock silently kill them too — the change would ship, look
728
+ // correct, and do nothing after thirty minutes, with no test going red.
729
+ // `readGrants` performs the session check and no age check, which is exactly
730
+ // the contract: object identity, not recency (ADR-252).
731
+ const grants = readGrants(consumer_root, session_id);
562
732
  // This session's own ledger first; the flat legacy file only as a fallback,
563
733
  // so a ledger written by an older build still counts. Reading the flat file
564
734
  // FIRST was the defect: any concurrent session in the repo overwrites it, and
@@ -570,10 +740,10 @@ function _loadLedger(
570
740
  for (const rel of candidates) {
571
741
  const found = _readLedgerFile(path.join(consumer_root, rel), session_id, now);
572
742
  if (found !== null) {
573
- return found;
743
+ return { ...found, grants };
574
744
  }
575
745
  }
576
- return { authorized: new Set<GitOp>(), present: false };
746
+ return { authorized: new Set<GitOp>(), present: false, grants };
577
747
  }
578
748
 
579
749
  /**
@@ -622,11 +792,21 @@ export interface Decision {
622
792
  * `git_authorization_hook.PENDING_FILE` for why that record exists at all.
623
793
  */
624
794
  blockedOp?: GitOp;
795
+ /**
796
+ * The grant target this decision spent, when a standing grant is what allowed
797
+ * it.
798
+ *
799
+ * `decide` stays pure; `run` turns this into the ledger write that marks the
800
+ * target used. Single use is the property that keeps a grant from being a
801
+ * standing licence: the user authorized merging PR #1499, once.
802
+ */
803
+ consumedTarget?: number;
625
804
  }
626
805
 
627
806
  export function decide(
628
807
  command: string | null,
629
- ledger: { authorized: Set<GitOp>; present: boolean },
808
+ ledger: { authorized: Set<GitOp>; present: boolean; grants?: MergeGrant[] },
809
+ impact?: string,
630
810
  ): Decision {
631
811
  if (command === null) {
632
812
  return { exit: EXIT_ALLOW, stdout: "", stderr: "" };
@@ -636,13 +816,30 @@ export function decide(
636
816
  return { exit: EXIT_ALLOW, stdout: "", stderr: "" };
637
817
  }
638
818
 
819
+ // A standing grant is the SECOND way an operation can be authorized, and it
820
+ // is strictly narrower than the first rather than an escape from it. Only
821
+ // `pr-merge` can hold one, the command must name its pull request, and that
822
+ // number must sit unspent in a set the user's own sentence froze. An unnamed
823
+ // target matches nothing and a spent target matches nothing, so every path
824
+ // that is not exactly "the user named this PR and it has not been merged yet"
825
+ // falls through to the clock-bound decision below unchanged (ADR-252).
826
+ const target = mergeTargetOf(command);
827
+ if (grantCovers(ledger.grants ?? [], op, target) && target !== null) {
828
+ return { exit: EXIT_ALLOW, stdout: "", stderr: "", consumedTarget: target };
829
+ }
830
+
639
831
  const shown = command.length > 120 ? `${command.slice(0, 117)}…` : command;
640
832
 
641
833
  if (BLOCK_OPS.has(op)) {
834
+ // STAGE 2. The refusal stands either way — what the scan changes is whether
835
+ // the user has to go read the diff to answer it. `impact` is injected by
836
+ // `run`, never computed here, so `decide` stays pure and testable without a
837
+ // repository.
838
+ const scan = op === "pr-merge" && target !== null && impact ? ` ${impact}` : "";
642
839
  const reason =
643
840
  `Blocked: \`${op}\` with no authorization in this turn's prompt. ` +
644
841
  `This operation is irreversible and non-destructive-by-default already declares it ` +
645
- `never-autonomous. Command: ${shown}. ` +
842
+ `never-autonomous. Command: ${shown}.${scan} ` +
646
843
  `Ask the user, in one numbered-options block, and run it only after they answer this turn.`;
647
844
  return { exit: EXIT_BLOCK, stdout: "", stderr: `${reason}\n`, blockedOp: op };
648
845
  }
@@ -675,10 +872,42 @@ export function run(stdin_text: string, options: { consumer_root: string }): num
675
872
  }
676
873
  }
677
874
  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
- );
875
+ const command = extractCommand(envelope);
876
+ const ledger = _loadLedger(options.consumer_root, session_id, Date.now());
877
+
878
+ // STAGE 2 runs only on the path where it can change something: a merge that is
879
+ // ABOUT to be refused. Not on an allowed merge (nothing to tell the user), not
880
+ // on an op whose target the command does not name, and never on publish or
881
+ // release — a scan costs a diff read, and spending one where it cannot change
882
+ // the outcome is latency in the user's critical path for nothing.
883
+ let impact: string | undefined;
884
+ const stage1 = command === null ? null : commandOp(command);
885
+ const stage2Target = command === null ? null : mergeTargetOf(command);
886
+ if (
887
+ stage1 === "pr-merge" &&
888
+ stage2Target !== null &&
889
+ !ledger.authorized.has("pr-merge") &&
890
+ !grantCovers(ledger.grants ?? [], "pr-merge", stage2Target)
891
+ ) {
892
+ try {
893
+ impact = describeImpact(
894
+ stage2Target,
895
+ analyseMergeImpact(stage2Target, { cwd: options.consumer_root }),
896
+ );
897
+ } catch {
898
+ // The scan is an enrichment, never a gate. A refusal without it is the
899
+ // pre-stage-2 message, which is still correct.
900
+ }
901
+ }
902
+
903
+ const decision = decide(command, ledger, impact);
904
+ if (decision.consumedTarget !== undefined) {
905
+ // Spend the target at the moment the merge is actually let through, never
906
+ // when the grant was minted. A grant the run never reached stays whole, and
907
+ // a pull request that merged can never be replayed — which is what makes a
908
+ // force-push back to an already-merged head harmless here.
909
+ consumeGrantTarget(options.consumer_root, session_id, decision.consumedTarget);
910
+ }
682
911
  if (decision.blockedOp) {
683
912
  // Record WHAT was refused, so the user's answer to the question this
684
913
  // refusal just told the agent to ask can authorize that one operation.
@@ -0,0 +1,207 @@
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
+ /** A major-version bump in a manifest, which is the author declaring a break. */
71
+ const MAJOR_BUMP =
72
+ /^-\s*"version"\s*:\s*"(\d+)\.[^"]*"[\s\S]{0,200}?^\+\s*"version"\s*:\s*"(\d+)\./m;
73
+
74
+ /**
75
+ * Classify a unified diff.
76
+ *
77
+ * Pure — the caller supplies the patch, so this is testable without a network,
78
+ * a repository, or a fixture PR.
79
+ */
80
+ export function classifyDiff(patch: string): MergeImpact {
81
+ if (patch.trim() === "") {
82
+ return { verdict: "undecidable", markers: [], reason: "empty diff" };
83
+ }
84
+
85
+ const markers: string[] = [];
86
+ for (const { label, re } of DESTRUCTIVE_MARKERS) {
87
+ if (re.test(patch)) {
88
+ markers.push(label);
89
+ }
90
+ }
91
+
92
+ const bump = MAJOR_BUMP.exec(patch);
93
+ if (bump && bump[1] !== bump[2]) {
94
+ markers.push(`major version bump ${bump[1]}.x to ${bump[2]}.x`);
95
+ }
96
+
97
+ const filesChanged = (patch.match(/^diff --git /gm) ?? []).length;
98
+
99
+ if (markers.length > 0) {
100
+ return { verdict: "destructive", markers, filesChanged };
101
+ }
102
+ return { verdict: "additive", markers: [], filesChanged };
103
+ }
104
+
105
+ /** How the patch was obtained, so a refusal can say which source answered. */
106
+ export type PatchSource = "local" | "gh" | "none";
107
+
108
+ export interface PatchResult {
109
+ patch: string;
110
+ source: PatchSource;
111
+ reason?: string;
112
+ }
113
+
114
+ /**
115
+ * Fetch the diff for a pull request, local first.
116
+ *
117
+ * The ordering is not a preference, it is a cost decision. A local
118
+ * `git diff` against the merge base costs milliseconds and works offline; a
119
+ * `gh pr diff` costs a network round trip on a `pre_tool_use` hook, which is in
120
+ * the user's critical path. Local answers whenever the ref happens to be
121
+ * fetched, which in a drain run is almost always, because the run just synced
122
+ * the branch it is about to merge.
123
+ *
124
+ * Every failure returns `none` rather than throwing. The caller turns that into
125
+ * `undecidable`, which asks the user — a hook that crashed would be worse than
126
+ * a hook that admitted it did not know.
127
+ */
128
+ export function fetchPatch(
129
+ pr: number,
130
+ opts: { cwd?: string; allowNetwork?: boolean; exec?: typeof execFileSync } = {},
131
+ ): PatchResult {
132
+ const run = opts.exec ?? execFileSync;
133
+ const cwd = opts.cwd ?? process.cwd();
134
+ const capture = (file: string, args: string[]): string =>
135
+ run(file, args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 15_000 }) as unknown as string;
136
+
137
+ // A fetched PR ref is what a synced drain run leaves behind.
138
+ for (const ref of [`refs/pull/${pr}/head`, `pull/${pr}/head`]) {
139
+ try {
140
+ const base = capture("git", ["merge-base", "HEAD", ref]).trim();
141
+ if (base) {
142
+ const patch = capture("git", ["diff", `${base}...${ref}`]);
143
+ if (patch.trim() !== "") {
144
+ return { patch, source: "local" };
145
+ }
146
+ }
147
+ } catch {
148
+ /* try the next spelling, then the network */
149
+ }
150
+ }
151
+
152
+ if (opts.allowNetwork === false) {
153
+ return { patch: "", source: "none", reason: "no local ref and network reads disabled" };
154
+ }
155
+
156
+ try {
157
+ const patch = capture("gh", ["pr", "diff", String(pr), "--patch"]);
158
+ if (patch.trim() !== "") {
159
+ return { patch, source: "gh" };
160
+ }
161
+ return { patch: "", source: "none", reason: "gh returned an empty diff" };
162
+ } catch {
163
+ return { patch: "", source: "none", reason: "no local ref, and gh pr diff failed" };
164
+ }
165
+ }
166
+
167
+ /** Analyse a pull request end to end. `undecidable` whenever the patch is not readable. */
168
+ export function analyseMergeImpact(
169
+ pr: number,
170
+ opts: Parameters<typeof fetchPatch>[1] = {},
171
+ ): MergeImpact {
172
+ const got = fetchPatch(pr, opts);
173
+ if (got.source === "none") {
174
+ return { verdict: "undecidable", markers: [], ...(got.reason ? { reason: got.reason } : {}) };
175
+ }
176
+ return classifyDiff(got.patch);
177
+ }
178
+
179
+ /**
180
+ * One line for a refusal message.
181
+ *
182
+ * Phrased so the user can answer it out loud, which is the point of the whole
183
+ * stage: the shortest reply that unblocks the operation is quoted back to them.
184
+ */
185
+ export function describeImpact(pr: number, impact: MergeImpact): string {
186
+ const say = `Say "merge #${pr}" to authorize it.`;
187
+ switch (impact.verdict) {
188
+ case "additive":
189
+ return (
190
+ `Impact scan: PR #${pr} touches ${impact.filesChanged ?? 0} file(s) and trips no ` +
191
+ `destructive marker — no schema drop, no removed export or route, no major bump, ` +
192
+ `no BREAKING CHANGE. Additive as far as the diff can show, which is not the same ` +
193
+ `as safe to merge: the base has moved since review and merging may trigger a ` +
194
+ `deploy. ${say}`
195
+ );
196
+ case "destructive":
197
+ return (
198
+ `Impact scan: PR #${pr} is NOT additive — ${impact.markers.join("; ")}. ` +
199
+ `Read the diff before authorizing. ${say}`
200
+ );
201
+ default:
202
+ return (
203
+ `Impact scan: could not read the diff for PR #${pr} (${impact.reason ?? "unknown"}), ` +
204
+ `so nothing is known about what it changes. ${say}`
205
+ );
206
+ }
207
+ }
@@ -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,7 @@ import {
192
194
  _target_from_branch,
193
195
  create_and_push_annotated_tag,
194
196
  guard_publication,
197
+ guard_release_curation,
195
198
  die,
196
199
  gh,
197
200
  git,
@@ -744,22 +747,6 @@ function prepend_changelog(p: string, entry: string): void {
744
747
  }
745
748
 
746
749
  /** 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
750
  // ─── file mutations ───────────────────────────────────────────────────────────
764
751
 
765
752
  /** Update the top-level `version` field; preserve 4-space indentation. */
@@ -1167,7 +1154,6 @@ function _step(n: number, total: number, msg: string): void {
1167
1154
  process.stdout.write(`[${n}/${total}] ${msg}\n`);
1168
1155
  }
1169
1156
 
1170
-
1171
1157
  function execute(
1172
1158
  plan: Plan,
1173
1159
  opts: { wait_for_checks: boolean; dry_run: boolean; resume?: boolean; ci?: boolean },
@@ -1276,6 +1262,8 @@ function execute(
1276
1262
  run(['task', 'release-prepare']);
1277
1263
  }
1278
1264
 
1265
+ guard_release_curation(plan.target, pr_merged);
1266
+
1279
1267
  // ─── 3. commit ──────────────────────────────────────────────────────────
1280
1268
  if (pr_merged) {
1281
1269
  _step(3, total, 'PR already merged — skip commit');
@@ -390,22 +390,41 @@ function _count(world: FakeWorld, needle: string): number {
390
390
  }
391
391
 
392
392
  const SCENARIOS: Record<string, Scenario> = {
393
- 'marker-refuses-before-branch-push': {
393
+ 'marker-refuses-before-commit': {
394
394
  summary:
395
- 'step 4: a draft marker refuses the branch push — the first remote state — so no PR is ever opened',
395
+ 'step 3: a draft marker refuses BEFORE the release commit — no local release state, and therefore no remote state either',
396
396
  config: { changelog_file: markedChangelogFixture(current_version()) },
397
397
  expect_success: false,
398
398
  verify: (w, err) => {
399
399
  const f: string[] = [];
400
400
  _expect(
401
- (err ?? '').includes(`refusing to push ${w.branch}`),
401
+ (err ?? '').includes('release highlights are still the generator'),
402
402
  `died for the wrong reason: ${err ?? '(no error)'}`,
403
403
  f,
404
404
  );
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.
405
+ _expect(
406
+ !w.calls.some((c) => c.startsWith('git commit')),
407
+ 'a release commit was made after the refusal',
408
+ f,
409
+ );
410
+ // RENAMED from `marker-refuses-before-branch-push` when
411
+ // `guard_release_curation` moved the refusal one step earlier.
412
+ // Every negative assertion below is UNCHANGED and still
413
+ // load-bearing — each is a remote side effect the 14.14.0 run
414
+ // actually produced: a pushed branch, an open PR, a check watch on
415
+ // a check that could not pass. They now hold more strongly, since
416
+ // the run stops before the commit rather than after it, and the
417
+ // no-commit assertion above is the property that is new.
418
+ //
419
+ // What this scenario no longer reaches, said plainly rather than
420
+ // left for a reader to infer: `guard_release_branch_push`. On the
421
+ // non-merged path it is now unreachable in ONE run, because a
422
+ // section marked at step 4 was already marked at step 3. It stays
423
+ // as defence-in-depth for a section that changes BETWEEN the two —
424
+ // a hand edit mid-run, or a resume over an altered changelog — and
425
+ // its own behaviour stays pinned by the unit tests in
426
+ // `tests/scripts/release_push_failure_masking.test.ts`, which is
427
+ // where that coverage moved to rather than being dropped.
409
428
  _expect(
410
429
  !w.calls.includes(`git push -u origin ${w.branch}`),
411
430
  'the branch was pushed after the refusal',