@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
@@ -761,12 +761,39 @@ var ALL_OPS = [
761
761
  "pr-merge-auto",
762
762
  "tag",
763
763
  "release",
764
- "publish"
764
+ "publish",
765
+ "unpublish",
766
+ "deprecate",
767
+ "release-asset",
768
+ "protection",
769
+ "workflow-toggle",
770
+ "repo-lifecycle",
771
+ "review-approve",
772
+ "force-push",
773
+ "worktree-remove",
774
+ "clean-ignored",
775
+ "tag-force",
776
+ "rebase",
777
+ "reset-hard",
778
+ "clean",
779
+ "stash-drop",
780
+ "branch-delete",
781
+ "close"
765
782
  ];
766
783
  var PHRASES = [
767
784
  { op: "commit", re: /\b(commit(e|et|te|ten)?|committe|einchecken)\b/i },
768
785
  { op: "push", re: /\b(push(e|en|t)?|hochladen|hochschieben|raufschieben)\b/i },
769
- { op: "branch", re: /\b(branch|feature-branch|zweig)\b/i },
786
+ // A CREATION VERB IS REQUIRED, as for `pr-create` above and for the same
787
+ // reason. The bare noun could not tell an authorization from a mention, and
788
+ // the mention is the dangerous half: "den branch nicht löschen" names the
789
+ // object precisely in order to forbid acting on it, yet authorized branch
790
+ // creation — the negation trails a bare-noun match, so neither the
791
+ // look-behind nor the span check in `negatedBefore` can reach it. Widened
792
+ // 2026-09-03 alongside the destructive-coverage work that measured it.
793
+ {
794
+ op: "branch",
795
+ re: /\b(erstell(e|en)?|mach(e|en)?|leg(e|en)?\s+an|anleg(e|en)?|(er|be)?(ö|oe)ffne|open|create|new|neuer?|starte?|checkout|wechsl?e|switch)\b[^.\n]{0,30}\b(branch|feature-branch|zweig)\b|\b(branch|feature-branch|zweig)\b[^.\n]{0,25}\b(erstellen|anlegen|aufmachen|(ö|oe)ffnen|starten)\b/i
796
+ },
770
797
  // A creation verb is required — "schau dir den PR an" is not "open a PR".
771
798
  {
772
799
  op: "pr-create",
@@ -780,20 +807,22 @@ var PHRASES = [
780
807
  // "never auto-merge" each returned ["pr-merge"] — a prompt saying DO NOT
781
808
  // MERGE authorized a merge. The noun-sense lookahead was never a negator.
782
809
  //
783
- // The lookBEHIND is line-scoped by `[^.!?\n]` on purpose, the same scoping
784
- // `turn_end_gate_hook.ts` uses for its own negation exclusion (`:330`) and
785
- // its negated-claim window (`:490-493`). A sentence boundary ends a
786
- // negation's reach: "Do not push. Merge PR #12." is two instructions, and a
787
- // guard that let the first suppress the second would silently stop
788
- // authorizing merges the user DID order — the failure mode that is worse
789
- // than the defect, because nothing happens and nothing says why.
790
- //
791
- // Reusing that vocabulary rather than inventing a third is deliberate: two
810
+ // The inline negative lookBEHIND that used to live in this pattern was
811
+ // REMOVED on 2026-09-03, and its own comment is why. It warned that "two
792
812
  // negation vocabularies in one tree drift, and the drift is invisible until
793
- // a prompt lands in the gap between them.
813
+ // a prompt lands in the gap between them" — and by then they had drifted:
814
+ // `negatedBefore` became clause-scanned in both directions while this
815
+ // lookbehind stayed a blind 30-character window with no forward half. The
816
+ // prompt that landed in the gap is `"nicht pushen, aber mergen"`, which the
817
+ // lookbehind refused (13 characters, inside its window, across a contrast
818
+ // cue it could not see) while the clause scan correctly authorizes it.
819
+ //
820
+ // Every phrase match already passes through `negatedBefore` at the call
821
+ // site, so the guard is not weakened by the removal — it is applied once,
822
+ // by the one vocabulary this file's comment asked for.
794
823
  {
795
824
  op: "pr-merge",
796
- 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
825
+ re: /\b(merge|merg(e|en|st|t)|zusammenf(ü|ue)hren|reinmergen)\b(?!\s*[- ]?(conflict|konflikt|commit|base|queue|state|status))/i
797
826
  },
798
827
  // A tag is an ACTION here — bare "Tag" is the German word for day, and
799
828
  // "Version" is an ordinary noun. Both authorized a BLOCK op before this.
@@ -803,7 +832,111 @@ var PHRASES = [
803
832
  op: "release",
804
833
  re: /\b(releasen?|ver(ö|oe)ffentlich(e|en))\b(?!\s*[- ]?(notes?|branch|candidate|pr\b|datum|date|zweig))/i
805
834
  },
806
- { op: "publish", re: /\b(publish(e|en)?|publiziere[n]?)\b/i }
835
+ { op: "publish", re: /\b(publish(e|en)?|publiziere[n]?)\b/i },
836
+ // ── Authorization vocabulary for the ops added 2026-09-03.
837
+ //
838
+ // NON-NEGOTIABLE, and the reason it ships in the same change as the new BLOCK
839
+ // ops: a blocked operation with no phrase that authorizes it is a dead end.
840
+ // The guard refuses, the user says "doch, mach das", the classifier records
841
+ // nothing, and the refusal repeats forever. Every op the guard can block must
842
+ // have a sentence that unblocks it, or the user's override is a claim rather
843
+ // than a mechanism.
844
+ //
845
+ // Tight on purpose. Under-matching costs one confirmation; over-matching
846
+ // authorizes something the user did not ask for, so each pattern demands the
847
+ // object noun rather than a bare verb wherever the verb is ordinary German.
848
+ //
849
+ // `unpublish` before nothing in particular — `\bpublish\b` does not match
850
+ // inside "unpublish" (no word boundary between `n` and `p`), so the two never
851
+ // collide.
852
+ // TWO SHAPES EVERY GERMAN PATTERN BELOW MUST CARRY, both learned from tests
853
+ // that went red rather than from inspection:
854
+ //
855
+ // 1. THE IMPERATIVE DROPS ITS `-e`. "entfern die worktree", "archivier das
856
+ // repo", "deaktivier den workflow" are what people type; `entfern(e|en)`
857
+ // matches none of them. Every verb ending is therefore `(e|en)?`.
858
+ // 2. WORD ORDER GOES BOTH WAYS. "archivier das repo" and "das repo
859
+ // archivieren" are one instruction, and a pattern anchored on the noun
860
+ // first sees only the second.
861
+ //
862
+ // The object noun stays REQUIRED in every pattern that has one. That is what
863
+ // keeps the loosened verb endings from over-authorizing: "lösch" alone
864
+ // authorizes nothing, "lösch den branch" authorizes a branch deletion.
865
+ { op: "unpublish", re: /\bunpublish(e|en)?\b/i },
866
+ {
867
+ op: "deprecate",
868
+ re: /\bdeprecate(d|n)?\b|\bals\s+veraltet\s+markier(e|en)?\b/i
869
+ },
870
+ {
871
+ op: "release-asset",
872
+ re: /\brelease[-\s]?asset\b|\b(clobber|(ü|ue)berschreib(e|en)?|ersetz(e|en)?|replace|austausch(e|en)?)\b[^.\n]{0,25}\b(asset|artefakt|artifact|tarball|binary)\b|\b(asset|artefakt|artifact)\b[^.\n]{0,25}\b((ü|ue)berschreiben|ersetzen|austauschen|replace)\b/i
873
+ },
874
+ // A VERB IS REQUIRED, like `tag` and `release` above and for the same reason.
875
+ // A bare noun cannot tell an authorization from a mention, and here the
876
+ // mention is the dangerous half: "die branch-protection nicht anfassen" names
877
+ // the object precisely in order to forbid touching it. Measured — this was the
878
+ // one row of the negation corpus that neither the look-behind nor the
879
+ // span check could reach, because the negation trails a bare-noun match.
880
+ {
881
+ op: "protection",
882
+ re: /\b(entfern(e|en)?|l(ö|oe)sch(e|en)?|deaktivier(e|en)?|nimm|weg|remove|delete|disable|drop|lift)\b[^.\n]{0,30}\b(branch[-\s]?protection|protection[-\s]?rule|schutzregel|ruleset)\b|\b(branch[-\s]?protection|protection[-\s]?rule|schutzregel|ruleset)\b[^.\n]{0,30}\b(entfernen|l(ö|oe)schen|deaktivieren|abschalten|remove|delete|disable|drop|lift|weg)\b/i
883
+ },
884
+ {
885
+ op: "workflow-toggle",
886
+ re: /\bworkflow\b[^.\n]{0,25}\b(deaktivier(e|en)?|aktivier(e|en)?|disable|enable|abschalt(e|en)?|ausschalt(e|en)?)\b|\b(deaktivier(e|en)?|aktivier(e|en)?|disable|enable|abschalt(e|en)?|ausschalt(e|en)?)\b[^.\n]{0,25}\bworkflow\b/i
887
+ },
888
+ {
889
+ op: "repo-lifecycle",
890
+ re: /\b(repo|repository)\b[^.\n]{0,30}\b(archivier(e|en)?|archive|l(ö|oe)sch(e|en)?|delete)\b|\b(archivier(e|en)?|archive|l(ö|oe)sch(e|en)?|delete)\b[^.\n]{0,25}\b(repo|repository)\b|\b(repo|repository)\b[^.\n]{0,30}\bauf\s+(privat|public|öffentlich|private)\b/i
891
+ },
892
+ {
893
+ op: "review-approve",
894
+ re: /\b(approve|genehmig(e|en)?|freigeb(e|en)?|abnehm(e|en)?)\b[^.\n]{0,30}\b(review|pr|pull[-\s]request)\b|\b(review|pr|pull[-\s]request)\b[^.\n]{0,20}\b(approve|approven|genehmigen|freigeben)\b/i
895
+ },
896
+ {
897
+ op: "force-push",
898
+ re: /\bforce[-\s]?push(e|en|st)?\b|--force(-with-lease)?\b|\bmit\s+gewalt\s+push(e|en)?\b/i
899
+ },
900
+ {
901
+ op: "worktree-remove",
902
+ re: /\bworktree\b[^.\n]{0,25}\b(entfern(e|en)?|l(ö|oe)sch(e|en)?|remove|delete|weg(werfen|r(ä|ae)umen))\b|\b(entfern(e|en)?|l(ö|oe)sch(e|en)?|remove|delete)\b[^.\n]{0,25}\bworktree\b/i
903
+ },
904
+ // The `-x` flag is the whole distinction: without it `git clean` spares
905
+ // ignored files, with it `.env` and local certificates go too.
906
+ {
907
+ op: "clean-ignored",
908
+ re: /\bgit\s+clean\b[^.\n]{0,20}-[A-Za-z]*x|\b(ignorierte|ignored)\b[^.\n]{0,25}\b(dateien|files)\b[^.\n]{0,25}\b(l(ö|oe)sch(e|en)?|entfern(e|en)?|remove|delete)\b/i
909
+ },
910
+ {
911
+ op: "tag-force",
912
+ re: /\btag\b[^.\n]{0,25}\b(verschieb(e|en)?|(ü|ue)berschreib(e|en)?|umh(ä|ae)ng(e|en)?|force|move)\b|\b(verschieb(e|en)?|(ü|ue)berschreib(e|en)?)\b[^.\n]{0,20}\btag\b/i
913
+ },
914
+ { op: "rebase", re: /\brebase(n|st|d)?\b/i },
915
+ {
916
+ op: "reset-hard",
917
+ re: /\breset\s+--hard\b|\bhard\s+reset\b|\bhart\s+zur(ü|ue)cksetz(e|en)?\b/i
918
+ },
919
+ { op: "clean", re: /\bgit\s+clean\b/i },
920
+ {
921
+ op: "stash-drop",
922
+ re: /\bstash\b[^.\n]{0,25}\b(drop|clear|verwerf(e|en)?|l(ö|oe)sch(e|en)?)\b|\b(verwerf(e|en)?|l(ö|oe)sch(e|en)?|drop)\b[^.\n]{0,20}\bstash\b/i
923
+ },
924
+ {
925
+ op: "branch-delete",
926
+ re: /\b(l(ö|oe)sch(e|en)?|entfern(e|en)?|delete|remove)\b[^.\n]{0,25}\b(branch|branches|zweig)\b|\b(branch|branches|zweig)\b[^.\n]{0,25}\b(l(ö|oe)schen|entfernen|delete|remove)\b/i
927
+ },
928
+ {
929
+ op: "close",
930
+ re: /\b(schlie(ß|ss)(e|en)?|close)\b[^.\n]{0,30}\b(pr|pull[-\s]request|issue|ticket)\b|\b(pr|pull[-\s]request|issue|ticket)\b[^.\n]{0,20}\b(schlie(ß|ss)en|close)\b/i
931
+ },
932
+ // Enabling auto-merge is its own op on the guard side, so it needs its own
933
+ // sentence. Without one it is the dead end this block exists to prevent: the
934
+ // plain merge phrase authorizes `pr-merge` and never `pr-merge-auto`, so the
935
+ // refusal would have repeated forever.
936
+ {
937
+ op: "pr-merge-auto",
938
+ re: /\bauto[-\s]?merge\b|\bmerge\b[^.\n]{0,20}\bautomatisch\b|\bautomatisch\b[^.\n]{0,20}\bmerg(e|en)\b/i
939
+ }
807
940
  ];
808
941
  var OUTPUT_LINE = /^\s*(To\s+\S+|remote:|error:|fatal:|hint:|warning:|!\s|\?\?\s|\s*\^|[-+]{3}\s|@@\s|\$\s|>\s|\d+\s+(pass|fail)|npm ERR!|Error:|Traceback|at\s+\S+:\d+)/i;
809
942
  function isInterrogative(prose) {
@@ -811,13 +944,16 @@ function isInterrogative(prose) {
811
944
  if (!t) {
812
945
  return false;
813
946
  }
814
- const hasImperative = /\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(
947
+ if (/\?\s*$/.test(t)) {
948
+ return true;
949
+ }
950
+ const hasImperative = /\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(
815
951
  t
816
952
  );
817
953
  if (hasImperative) {
818
954
  return false;
819
955
  }
820
- return /\?\s*$/.test(t) || /^(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(
956
+ return /^(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(
821
957
  t
822
958
  );
823
959
  }
@@ -864,16 +1000,164 @@ function splitFences(prompt) {
864
1000
  function _commandLines(fence) {
865
1001
  return fence.split("\n").map((l) => l.trim()).filter((l) => l.length > 0).filter((l) => !/^(remote:|error:|hint:|fatal:|warning:|To |!\s|\s*\^)/i.test(l)).map((l) => l.replace(/^[$>#]\s*/, "")).filter((l) => /^(git|gh|npm|pnpm|yarn|task)\b/.test(l));
866
1002
  }
1003
+ function _errText(err) {
1004
+ const t = err instanceof Error ? err.message : String(err);
1005
+ return t.replace(/\s+/g, " ").trim().slice(0, 200);
1006
+ }
1007
+ var PR_NUMBER_MAX = 2147483647;
1008
+ function extractMergeTargets(prompt) {
1009
+ const out = /* @__PURE__ */ new Set();
1010
+ const re = /(?:#|\bpr[- ]?|\bpull[- ]request\s+#?)(\d{1,10})\b/giu;
1011
+ let m;
1012
+ while ((m = re.exec(prompt)) !== null) {
1013
+ const n = Number.parseInt(m[1], 10);
1014
+ if (Number.isSafeInteger(n) && n > 0 && n <= PR_NUMBER_MAX) {
1015
+ out.add(n);
1016
+ }
1017
+ }
1018
+ return [...out].sort((a, b) => a - b);
1019
+ }
1020
+ var REVOKE_RE = /\b(stop|stopp|halt|abbrechen|abbruch|cancel|revoke|widerruf(en)?|zur(ü|ue)ck(nehmen|ziehen)|hold\s+(everything|on|the\s+merge)|warte|wait)\b|(?:\b(nicht|kein(e|en)?|no|not|dont|don't|never|niemals|nie)\b[^.!?\n]{0,30}\b(merg(e|en|ing)|zusammenf(ü|ue)hren)\b)/iu;
1021
+ function isRevocation(prompt) {
1022
+ return REVOKE_RE.test(prompt);
1023
+ }
1024
+ function _grantId(session_id, at, targets) {
1025
+ return `${sessionSlug(session_id) || "nosession"}:${at}:${targets.join(",")}`;
1026
+ }
1027
+ function foldGrants(prior, prompt, authorized, session_id, now) {
1028
+ if (isRevocation(prompt)) {
1029
+ return [];
1030
+ }
1031
+ const carried = prior.filter((g) => g.consumed.length < g.targets.length);
1032
+ if (!authorized.includes("pr-merge")) {
1033
+ return carried;
1034
+ }
1035
+ const targets = extractMergeTargets(prompt);
1036
+ if (targets.length === 0) {
1037
+ return carried;
1038
+ }
1039
+ const at = now.toISOString();
1040
+ const already = new Set(carried.flatMap((g) => g.targets));
1041
+ const fresh = targets.filter((t) => !already.has(t));
1042
+ if (fresh.length === 0) {
1043
+ return carried;
1044
+ }
1045
+ return [
1046
+ ...carried,
1047
+ {
1048
+ id: _grantId(session_id, at, fresh),
1049
+ op: "pr-merge",
1050
+ targets: fresh,
1051
+ consumed: [],
1052
+ granted_at: at,
1053
+ evidence: prompt.trim().slice(0, 120)
1054
+ }
1055
+ ];
1056
+ }
1057
+ var NEGATION_WORD = /\b(nicht|nichts|kein(e|en|em|er|es)?|niemals|nie|no|not|dont|don't|never|without|ohne)\b/i;
1058
+ var CONTRAST_CUE = /\b(aber|sondern|jedoch|but|however|instead)\b/gi;
1059
+ var ABBREVIATION_BEFORE_DOT = /(?:^|[\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;
1060
+ function isSentenceBoundary(text, at) {
1061
+ const mark = text[at];
1062
+ if (mark !== ".") {
1063
+ return true;
1064
+ }
1065
+ if (/\d/.test(text[at - 1] ?? "") && /\d/.test(text[at + 1] ?? "")) {
1066
+ return false;
1067
+ }
1068
+ return !ABBREVIATION_BEFORE_DOT.test(text.slice(0, at + 1));
1069
+ }
1070
+ function sentenceBounds(text, index) {
1071
+ let start = 0;
1072
+ for (let i = index - 1; i >= 0; i -= 1) {
1073
+ const c = text[i];
1074
+ if ((c === "." || c === "!" || c === "?" || c === "\n") && isSentenceBoundary(text, i)) {
1075
+ start = i + 1;
1076
+ break;
1077
+ }
1078
+ }
1079
+ let end = text.length;
1080
+ for (let i = index; i < text.length; i += 1) {
1081
+ const c = text[i];
1082
+ if ((c === "." || c === "!" || c === "?" || c === "\n") && isSentenceBoundary(text, i)) {
1083
+ end = i;
1084
+ break;
1085
+ }
1086
+ }
1087
+ return { start, end };
1088
+ }
1089
+ function _sentences(text) {
1090
+ const out = [];
1091
+ let from = 0;
1092
+ for (let i = 0; i < text.length; i += 1) {
1093
+ const c = text[i];
1094
+ if ((c === "." || c === "!" || c === "?" || c === "\n") && isSentenceBoundary(text, i)) {
1095
+ out.push(text.slice(from, i + 1));
1096
+ from = i + 1;
1097
+ }
1098
+ }
1099
+ if (from < text.length) {
1100
+ out.push(text.slice(from));
1101
+ }
1102
+ return out.filter((t) => t.trim() !== "");
1103
+ }
1104
+ function clauseStart(text, from, to) {
1105
+ let start = from;
1106
+ const seg = text.slice(from, to);
1107
+ for (let i = seg.length - 1; i >= 0; i -= 1) {
1108
+ const c = seg[i];
1109
+ if (c === "," || c === ";" || c === ":") {
1110
+ start = from + i + 1;
1111
+ break;
1112
+ }
1113
+ }
1114
+ CONTRAST_CUE.lastIndex = 0;
1115
+ let m;
1116
+ while ((m = CONTRAST_CUE.exec(seg)) !== null) {
1117
+ const after = from + m.index + m[0].length;
1118
+ if (after > start) {
1119
+ start = after;
1120
+ }
1121
+ }
1122
+ return start;
1123
+ }
1124
+ function clauseEnd(text, from, to) {
1125
+ const seg = text.slice(from, to);
1126
+ let end = to;
1127
+ const punct = seg.search(/[,;:]/);
1128
+ if (punct !== -1) {
1129
+ end = from + punct;
1130
+ }
1131
+ CONTRAST_CUE.lastIndex = 0;
1132
+ const m = CONTRAST_CUE.exec(seg);
1133
+ if (m !== null && from + m.index < end) {
1134
+ end = from + m.index;
1135
+ }
1136
+ return end;
1137
+ }
1138
+ function negatedBefore(text, index, matched = "") {
1139
+ const { start, end } = sentenceBounds(text, index);
1140
+ const before = text.slice(clauseStart(text, start, index), index);
1141
+ const afterFrom = index + matched.length;
1142
+ const after = afterFrom < end ? text.slice(afterFrom, clauseEnd(text, afterFrom, end)) : "";
1143
+ return NEGATION_WORD.test(before) || NEGATION_WORD.test(matched) || NEGATION_WORD.test(after);
1144
+ }
867
1145
  function classifyAuthorization(prompt) {
868
1146
  const { prose, fences } = splitFences(prompt);
869
1147
  const authorized = /* @__PURE__ */ new Set();
870
1148
  const evidence = {};
871
1149
  const instruction = prose.split("\n").filter((l) => !OUTPUT_LINE.test(l)).join("\n");
872
- for (const { op, re } of isInterrogative(instruction) ? [] : PHRASES) {
873
- const m = re.exec(instruction);
874
- if (m) {
875
- authorized.add(op);
876
- evidence[op] = `prose: "${m[0]}"`;
1150
+ for (const { op, re } of PHRASES) {
1151
+ for (const sentence of _sentences(instruction)) {
1152
+ if (isInterrogative(sentence)) {
1153
+ continue;
1154
+ }
1155
+ const m = re.exec(sentence);
1156
+ if (m && !negatedBefore(sentence, m.index, m[0])) {
1157
+ authorized.add(op);
1158
+ evidence[op] = `prose: "${m[0]}"`;
1159
+ break;
1160
+ }
877
1161
  }
878
1162
  }
879
1163
  for (const fence of fences) {
@@ -893,6 +1177,72 @@ function classifyAuthorization(prompt) {
893
1177
  }
894
1178
  return { authorized: [...authorized], evidence };
895
1179
  }
1180
+ function readGrants(consumer_root, session_id) {
1181
+ try {
1182
+ const raw = fs2.readFileSync(path2.join(consumer_root, ledgerFileFor(session_id)), "utf8");
1183
+ const decoded = JSON.parse(raw);
1184
+ if (!_isObject3(decoded) || !Array.isArray(decoded["grants"])) {
1185
+ return [];
1186
+ }
1187
+ const owner = typeof decoded["session_id"] === "string" ? decoded["session_id"] : "";
1188
+ if (session_id && owner && owner !== session_id) {
1189
+ return [];
1190
+ }
1191
+ return decoded["grants"].flatMap((g) => {
1192
+ if (!_isObject3(g)) return [];
1193
+ const op = g["op"];
1194
+ if (typeof op !== "string" || !ALL_OPS.includes(op)) return [];
1195
+ const targets = Array.isArray(g["targets"]) ? g["targets"] : [];
1196
+ const consumed = Array.isArray(g["consumed"]) ? g["consumed"] : [];
1197
+ return [
1198
+ {
1199
+ id: String(g["id"] ?? ""),
1200
+ op,
1201
+ targets: targets.filter((t) => typeof t === "number"),
1202
+ consumed: consumed.filter((t) => typeof t === "number"),
1203
+ granted_at: String(g["granted_at"] ?? ""),
1204
+ evidence: String(g["evidence"] ?? "")
1205
+ }
1206
+ ];
1207
+ });
1208
+ } catch {
1209
+ return [];
1210
+ }
1211
+ }
1212
+ function consumeGrantTarget(consumer_root, session_id, target) {
1213
+ const file = path2.join(consumer_root, ledgerFileFor(session_id));
1214
+ let decoded;
1215
+ try {
1216
+ decoded = JSON.parse(fs2.readFileSync(file, "utf8"));
1217
+ } catch {
1218
+ return;
1219
+ }
1220
+ if (!_isObject3(decoded) || !Array.isArray(decoded["grants"])) {
1221
+ return;
1222
+ }
1223
+ let touched = false;
1224
+ for (const g of decoded["grants"]) {
1225
+ if (!_isObject3(g) || !Array.isArray(g["targets"]) || !Array.isArray(g["consumed"])) {
1226
+ continue;
1227
+ }
1228
+ const targets = g["targets"];
1229
+ const consumed = g["consumed"];
1230
+ if (targets.includes(target) && !consumed.includes(target)) {
1231
+ consumed.push(target);
1232
+ touched = true;
1233
+ }
1234
+ }
1235
+ if (touched) {
1236
+ try {
1237
+ atomic_write_json(file, decoded);
1238
+ } catch (err) {
1239
+ process.stderr.write(
1240
+ `git-authorization: grant-consumption write failed (${_errText(err)}) \u2014 the target stays UNSPENT, so one further merge of a PR the user named will still be authorized. Authorization outcome unchanged.
1241
+ `
1242
+ );
1243
+ }
1244
+ }
1245
+ }
896
1246
  function takePending(consumer_root, session_id, now) {
897
1247
  const file = path2.join(consumer_root, pendingFileFor(session_id));
898
1248
  let raw;
@@ -961,16 +1311,32 @@ function run2(stdin_text, options) {
961
1311
  authorized.push(pending.op);
962
1312
  evidence[pending.op] = `confirmation of the refused \`${pending.op}\` (refused ${pending.refused_at}): "${prompt.trim().slice(0, 40)}"`;
963
1313
  }
1314
+ const now = /* @__PURE__ */ new Date();
964
1315
  const ledger = {
965
1316
  session_id: typeof envelope["session_id"] === "string" ? envelope["session_id"] : "",
966
- detected_at: (/* @__PURE__ */ new Date()).toISOString(),
1317
+ detected_at: now.toISOString(),
967
1318
  authorized,
968
1319
  evidence,
969
- prompt_chars: prompt.length
1320
+ prompt_chars: prompt.length,
1321
+ // Grants survive this write; `authorized` does not. That asymmetry IS the
1322
+ // decision. A bare operation name stays one-shot exactly as `commit-policy`
1323
+ // requires, and only an authorization that also froze its objects earns a
1324
+ // life longer than the turn. See ADR-252.
1325
+ grants: foldGrants(
1326
+ readGrants(options.consumer_root, session),
1327
+ prompt,
1328
+ authorized,
1329
+ session,
1330
+ now
1331
+ )
970
1332
  };
971
1333
  try {
972
1334
  atomic_write_json(path2.join(options.consumer_root, ledgerFileFor(session)), ledger);
973
- } catch {
1335
+ } catch (err) {
1336
+ process.stderr.write(
1337
+ `git-authorization: session-ledger write failed (${_errText(err)}) \u2014 the gate degrades to "no ledger" for this session, which denies the irreversible subset and warns on the rest. Authorization outcome unchanged.
1338
+ `
1339
+ );
974
1340
  }
975
1341
  return EXIT_ALLOW2;
976
1342
  }
@@ -1015,6 +1381,116 @@ if (_isCliEntry2()) {
1015
1381
  import fs3 from "node:fs";
1016
1382
  import path3 from "node:path";
1017
1383
  import { fileURLToPath as fileURLToPath3, pathToFileURL as pathToFileURL3 } from "node:url";
1384
+
1385
+ // src/scripts/hooks/merge_impact.ts
1386
+ import { execFileSync } from "node:child_process";
1387
+ var DESTRUCTIVE_MARKERS = [
1388
+ // Schema removal. Matched on ADDED lines only — a migration that *removes* a
1389
+ // dropColumn call is the opposite of destructive, and matching the raw patch
1390
+ // would score it the same way.
1391
+ { label: "migration drops a column or table", re: /^\+.*\b(dropColumn|dropTable|dropIfExists|removeColumn|drop_column|drop_table)\b/im },
1392
+ { label: "raw DROP or TRUNCATE in a migration", re: /^\+.*\b(DROP\s+(TABLE|COLUMN|INDEX|CONSTRAINT)|TRUNCATE\s+TABLE)\b/im },
1393
+ // The author said so themselves. Conventional Commits marks a breaking change
1394
+ // with a `!` before the colon or a body trailer.
1395
+ { label: "author marked a BREAKING CHANGE", re: /^\+.*BREAKING[ -]CHANGE/m },
1396
+ // A removed export is the shape most likely to break an importer.
1397
+ { 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 },
1398
+ { label: "a public PHP or Python symbol was removed", re: /^-\s*(public\s+function|def\s+[a-z_]+\s*\()/m },
1399
+ // Route removal breaks callers that never imported anything.
1400
+ { 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 },
1401
+ { label: "a whole file was deleted", re: /^deleted file mode /m }
1402
+ ];
1403
+ var SCHEMA_REMOVAL_BODY = /\b(dropColumn|dropTable|dropIfExists|removeColumn|drop_column|drop_table|DROP\s+(TABLE|COLUMN|INDEX|CONSTRAINT)|TRUNCATE\s+TABLE)\b/i;
1404
+ var RENAME_METADATA = /^rename (?:from|to) /m;
1405
+ function fileBlocks(patch) {
1406
+ return patch.split(/^(?=diff --git )/m).filter((b) => b.startsWith("diff --git "));
1407
+ }
1408
+ function renamedSchemaRemoval(patch) {
1409
+ for (const block of fileBlocks(patch)) {
1410
+ if (!RENAME_METADATA.test(block)) {
1411
+ continue;
1412
+ }
1413
+ for (const line of block.split("\n")) {
1414
+ if (line.startsWith("-") && !line.startsWith("---") && SCHEMA_REMOVAL_BODY.test(line)) {
1415
+ return true;
1416
+ }
1417
+ }
1418
+ }
1419
+ return false;
1420
+ }
1421
+ var MAJOR_BUMP = /^-\s*"version"\s*:\s*"(\d+)\.[^"]*"[\s\S]{0,200}?^\+\s*"version"\s*:\s*"(\d+)\./m;
1422
+ function classifyDiff(patch) {
1423
+ if (patch.trim() === "") {
1424
+ return { verdict: "undecidable", markers: [], reason: "empty diff" };
1425
+ }
1426
+ const markers = [];
1427
+ for (const { label, re } of DESTRUCTIVE_MARKERS) {
1428
+ if (re.test(patch)) {
1429
+ markers.push(label);
1430
+ }
1431
+ }
1432
+ if (renamedSchemaRemoval(patch)) {
1433
+ markers.push("a renamed file drops a column or table");
1434
+ }
1435
+ const bump = MAJOR_BUMP.exec(patch);
1436
+ if (bump && bump[1] !== bump[2]) {
1437
+ markers.push(`major version bump ${bump[1]}.x to ${bump[2]}.x`);
1438
+ }
1439
+ const filesChanged = (patch.match(/^diff --git /gm) ?? []).length;
1440
+ if (markers.length > 0) {
1441
+ return { verdict: "destructive", markers, filesChanged };
1442
+ }
1443
+ return { verdict: "additive", markers: [], filesChanged };
1444
+ }
1445
+ function fetchPatch(pr, opts = {}) {
1446
+ const run5 = opts.exec ?? execFileSync;
1447
+ const cwd = opts.cwd ?? process.cwd();
1448
+ const capture = (file, args) => run5(file, args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 15e3 });
1449
+ for (const ref of [`refs/pull/${pr}/head`, `pull/${pr}/head`]) {
1450
+ try {
1451
+ const base = capture("git", ["merge-base", "HEAD", ref]).trim();
1452
+ if (base) {
1453
+ const patch = capture("git", ["diff", `${base}...${ref}`]);
1454
+ if (patch.trim() !== "") {
1455
+ return { patch, source: "local" };
1456
+ }
1457
+ }
1458
+ } catch {
1459
+ }
1460
+ }
1461
+ if (opts.allowNetwork === false) {
1462
+ return { patch: "", source: "none", reason: "no local ref and network reads disabled" };
1463
+ }
1464
+ try {
1465
+ const patch = capture("gh", ["pr", "diff", String(pr), "--patch"]);
1466
+ if (patch.trim() !== "") {
1467
+ return { patch, source: "gh" };
1468
+ }
1469
+ return { patch: "", source: "none", reason: "gh returned an empty diff" };
1470
+ } catch {
1471
+ return { patch: "", source: "none", reason: "no local ref, and gh pr diff failed" };
1472
+ }
1473
+ }
1474
+ function analyseMergeImpact(pr, opts = {}) {
1475
+ const got = fetchPatch(pr, opts);
1476
+ if (got.source === "none") {
1477
+ return { verdict: "undecidable", markers: [], ...got.reason ? { reason: got.reason } : {} };
1478
+ }
1479
+ return classifyDiff(got.patch);
1480
+ }
1481
+ function describeImpact(pr, impact) {
1482
+ const say = `Say "merge #${pr}" to authorize it.`;
1483
+ switch (impact.verdict) {
1484
+ case "additive":
1485
+ return `Impact scan: PR #${pr} touches ${impact.filesChanged ?? 0} file(s) and trips no destructive marker \u2014 no schema drop, no removed export or route, no major bump, no BREAKING CHANGE. Additive as far as the diff can show, which is not the same as safe to merge: the base has moved since review and merging may trigger a deploy. ${say}`;
1486
+ case "destructive":
1487
+ return `Impact scan: PR #${pr} is NOT additive \u2014 ${impact.markers.join("; ")}. Read the diff before authorizing. ${say}`;
1488
+ default:
1489
+ return `Impact scan: could not read the diff for PR #${pr} (${impact.reason ?? "unknown"}), so nothing is known about what it changes. ${say}`;
1490
+ }
1491
+ }
1492
+
1493
+ // src/scripts/hooks/block_unauthorized_git.ts
1018
1494
  var EXIT_ALLOW3 = 0;
1019
1495
  var EXIT_BLOCK = 1;
1020
1496
  function _isObject4(v) {
@@ -1027,13 +1503,52 @@ var BLOCK_OPS = /* @__PURE__ */ new Set([
1027
1503
  "pr-merge",
1028
1504
  // Enabling auto-merge commits the outcome to a condition the agent does not
1029
1505
  // control, so it is blocked on the same terms as the merge it schedules.
1030
- "pr-merge-auto"
1506
+ "pr-merge-auto",
1507
+ // ── Added 2026-09-03. Every one is already never-autonomous in the
1508
+ // `non-destructive-by-default` prose; the measurement that drove it is in the
1509
+ // `GitOp` union's own note — 17 of 25 borderline operations classified as
1510
+ // NOTHING, including three worse than anything the guard already caught.
1511
+ /** The undo that is more destructive than the act: lockfiles break. */
1512
+ "unpublish",
1513
+ /** Metadata only, and every install on earth starts warning. */
1514
+ "deprecate",
1515
+ /** Replaces bytes consumers already hold a checksum for. */
1516
+ "release-asset",
1517
+ /** Deletes the guard rather than the data — everything after is unguarded. */
1518
+ "protection",
1519
+ /** No data lost; the gate that protects everything downstream is off. */
1520
+ "workflow-toggle",
1521
+ /** Reach outside the repository, and mostly one-way. */
1522
+ "repo-lifecycle",
1523
+ /** No code moves, and an armed auto-merge is released. */
1524
+ "review-approve",
1525
+ /** Discards commits that landed after the last fetch, on a shared ref. */
1526
+ "force-push",
1527
+ /** Destroys uncommitted work belonging to a session this one cannot see. */
1528
+ "worktree-remove",
1529
+ /** `-x` is the flag that takes `.env` and local certificates. */
1530
+ "clean-ignored"
1031
1531
  ]);
1032
1532
  var WARN_OPS = /* @__PURE__ */ new Set([
1033
1533
  "commit",
1034
1534
  "push",
1035
1535
  "pr-create",
1036
- "branch"
1536
+ "branch",
1537
+ // ── Added 2026-09-03, deliberately at warn rather than block.
1538
+ //
1539
+ // Each is destructive in some reading, and each is also routine enough that
1540
+ // blocking it would be the friction that gets the whole guard routed around —
1541
+ // the failure mode this estate has already recorded twice. `reset-hard` is the
1542
+ // sharpest case and stays here for a decidable reason: the Hard Floor's
1543
+ // qualifier is "past unpushed work", and the command text cannot say whether
1544
+ // the work was pushed.
1545
+ "tag-force",
1546
+ "rebase",
1547
+ "reset-hard",
1548
+ "clean",
1549
+ "stash-drop",
1550
+ "branch-delete",
1551
+ "close"
1037
1552
  ]);
1038
1553
  var G = "(?:-[A-Za-z-]+(?:=\\S+)?(?:\\s+\\S+)?\\s+)*";
1039
1554
  var P = "^(?:\\S*\\/)?";
@@ -1079,6 +1594,72 @@ var COMMAND_OPS = [
1079
1594
  op: "pr-create",
1080
1595
  re: new RegExp(`${P}gh\\s+pr\\s+create\\b|${ghApiWrite("\\/pulls\\b").source}`, "i")
1081
1596
  },
1597
+ // ── Added 2026-09-03. Placement inside this table is as load-bearing as the
1598
+ // patterns: the loop takes the MOST SEVERE match per segment, but within one
1599
+ // op the first pattern wins, so anything more specific than a plain `git push`
1600
+ // has to sit above it.
1601
+ // `npm unpublish` is NOT reached by the publish pattern — `\bpublish\b` needs
1602
+ // a word boundary that `unpublish` does not provide between `n` and `p`. That
1603
+ // is why it classified as nothing for as long as it did.
1604
+ { op: "unpublish", re: new RegExp(`${P}(npm|pnpm|yarn)\\s+${G}unpublish\\b`, "i") },
1605
+ { op: "deprecate", re: new RegExp(`${P}(npm|pnpm|yarn)\\s+${G}deprecate\\b`, "i") },
1606
+ {
1607
+ op: "release-asset",
1608
+ re: new RegExp(`${P}gh\\s+release\\s+(upload|edit|delete)\\b`, "i")
1609
+ },
1610
+ {
1611
+ op: "protection",
1612
+ re: new RegExp(
1613
+ `${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`,
1614
+ "i"
1615
+ )
1616
+ },
1617
+ {
1618
+ op: "workflow-toggle",
1619
+ re: new RegExp(`${P}gh\\s+workflow\\s+(disable|enable)\\b`, "i")
1620
+ },
1621
+ {
1622
+ op: "repo-lifecycle",
1623
+ re: new RegExp(
1624
+ `${P}gh\\s+repo\\s+(archive|delete|unarchive)\\b|${P}gh\\s+repo\\s+edit\\b(?=[^\\n]*--visibility\\b)`,
1625
+ "i"
1626
+ )
1627
+ },
1628
+ {
1629
+ op: "review-approve",
1630
+ re: new RegExp(`${P}gh\\s+pr\\s+review\\b(?=[^\\n]*--approve\\b)`, "i")
1631
+ },
1632
+ // Above `push`, and both spellings: `--force` and `--force-with-lease`. The
1633
+ // "safe force" is in the same class here because what it is safe against is a
1634
+ // stale local view, not a collaborator who pushed after your fetch.
1635
+ {
1636
+ op: "force-push",
1637
+ re: new RegExp(`${P}git\\s+${G}push\\b[^\\n;|&]*(--force\\b|--force-with-lease\\b|\\s-f\\b)`, "i")
1638
+ },
1639
+ {
1640
+ op: "worktree-remove",
1641
+ re: new RegExp(`${P}git\\s+worktree\\s+remove\\b[^\\n;|&]*(--force\\b|\\s-f\\b)`, "i")
1642
+ },
1643
+ // `-x` (or `-X`) anywhere in the flag cluster is what reaches ignored files.
1644
+ {
1645
+ op: "clean-ignored",
1646
+ re: new RegExp(`${P}git\\s+clean\\b[^\\n;|&]*-[A-Za-z]*x`, "i")
1647
+ },
1648
+ { op: "tag-force", re: new RegExp(`${P}git\\s+tag\\b[^\\n;|&]*(-f\\b|--force\\b)`, "i") },
1649
+ { op: "rebase", re: new RegExp(`${P}git\\s+${G}rebase\\b`, "i") },
1650
+ { op: "reset-hard", re: new RegExp(`${P}git\\s+${G}reset\\b[^\\n;|&]*--hard\\b`, "i") },
1651
+ { op: "clean", re: new RegExp(`${P}git\\s+clean\\b`, "i") },
1652
+ { op: "stash-drop", re: new RegExp(`${P}git\\s+stash\\s+(drop|clear)\\b`, "i") },
1653
+ // Remote deletion and the local `-D` are one op: both remove a ref, and the
1654
+ // remote form additionally closes any open PR pointing at it.
1655
+ {
1656
+ op: "branch-delete",
1657
+ re: new RegExp(
1658
+ `${P}git\\s+branch\\b[^\\n;|&]*(-D\\b|--delete\\b)|${P}git\\s+${G}push\\b[^\\n;|&]*(--delete\\b|\\s:\\S)`,
1659
+ "i"
1660
+ )
1661
+ },
1662
+ { op: "close", re: new RegExp(`${P}gh\\s+(pr|issue)\\s+close\\b`, "i") },
1082
1663
  { op: "push", re: new RegExp(`${P}git\\s+${G}push\\b`, "i") },
1083
1664
  { op: "commit", re: new RegExp(`${P}git\\s+${G}commit\\b`, "i") },
1084
1665
  { op: "branch", re: new RegExp(`${P}git\\s+${G}(checkout\\s+-b|switch\\s+-c)\\b`, "i") }
@@ -1271,8 +1852,29 @@ function commandOp(command) {
1271
1852
  }
1272
1853
  return found;
1273
1854
  }
1855
+ function mergeTargetOf(command) {
1856
+ const gh = /\bgh\s+pr\s+merge\s+(?:-{1,2}\S+\s+)*?(\d{1,10})\b/i.exec(command);
1857
+ if (gh) {
1858
+ return _boundedTarget(gh[1]);
1859
+ }
1860
+ const api = /\/pulls\/(\d{1,10})\/merge\b/i.exec(command);
1861
+ return api ? _boundedTarget(api[1]) : null;
1862
+ }
1863
+ function _boundedTarget(digits) {
1864
+ const n = Number.parseInt(digits, 10);
1865
+ return Number.isSafeInteger(n) && n > 0 && n <= PR_NUMBER_MAX ? n : null;
1866
+ }
1867
+ function grantCovers(grants, op, target) {
1868
+ if (op !== "pr-merge" || target === null) {
1869
+ return null;
1870
+ }
1871
+ return grants.find(
1872
+ (g) => g.op === "pr-merge" && g.targets.includes(target) && !g.consumed.includes(target)
1873
+ ) ?? null;
1874
+ }
1274
1875
  var LEDGER_MAX_AGE_MS = 30 * 60 * 1e3;
1275
1876
  function _loadLedger(consumer_root, session_id, now) {
1877
+ const grants = readGrants(consumer_root, session_id);
1276
1878
  const candidates = [ledgerFileFor(session_id)];
1277
1879
  if (!candidates.includes(STATE_FILE2)) {
1278
1880
  candidates.push(STATE_FILE2);
@@ -1280,10 +1882,10 @@ function _loadLedger(consumer_root, session_id, now) {
1280
1882
  for (const rel of candidates) {
1281
1883
  const found = _readLedgerFile(path3.join(consumer_root, rel), session_id, now);
1282
1884
  if (found !== null) {
1283
- return found;
1885
+ return { ...found, grants };
1284
1886
  }
1285
1887
  }
1286
- return { authorized: /* @__PURE__ */ new Set(), present: false };
1888
+ return { authorized: /* @__PURE__ */ new Set(), present: false, grants };
1287
1889
  }
1288
1890
  function _readLedgerFile(file, session_id, now) {
1289
1891
  try {
@@ -1307,7 +1909,7 @@ function _readLedgerFile(file, session_id, now) {
1307
1909
  }
1308
1910
  return null;
1309
1911
  }
1310
- function decide(command, ledger) {
1912
+ function decide(command, ledger, impact) {
1311
1913
  if (command === null) {
1312
1914
  return { exit: EXIT_ALLOW3, stdout: "", stderr: "" };
1313
1915
  }
@@ -1315,9 +1917,14 @@ function decide(command, ledger) {
1315
1917
  if (op === null || ledger.authorized.has(op)) {
1316
1918
  return { exit: EXIT_ALLOW3, stdout: "", stderr: "" };
1317
1919
  }
1920
+ const target = mergeTargetOf(command);
1921
+ if (grantCovers(ledger.grants ?? [], op, target) && target !== null) {
1922
+ return { exit: EXIT_ALLOW3, stdout: "", stderr: "", consumedTarget: target };
1923
+ }
1318
1924
  const shown = command.length > 120 ? `${command.slice(0, 117)}\u2026` : command;
1319
1925
  if (BLOCK_OPS.has(op)) {
1320
- const reason = `Blocked: \`${op}\` with no authorization in this turn's prompt. This operation is irreversible and non-destructive-by-default already declares it never-autonomous. Command: ${shown}. Ask the user, in one numbered-options block, and run it only after they answer this turn.`;
1926
+ const scan = op === "pr-merge" && target !== null && impact ? ` ${impact}` : "";
1927
+ const reason = `Blocked: \`${op}\` with no authorization in this turn's prompt. This operation is irreversible and non-destructive-by-default already declares it never-autonomous. Command: ${shown}.${scan} Ask the user, in one numbered-options block, and run it only after they answer this turn.`;
1321
1928
  return { exit: EXIT_BLOCK, stdout: "", stderr: `${reason}
1322
1929
  `, blockedOp: op };
1323
1930
  }
@@ -1345,10 +1952,24 @@ function run3(stdin_text, options) {
1345
1952
  }
1346
1953
  }
1347
1954
  const session_id = typeof envelope["session_id"] === "string" ? envelope["session_id"] : "";
1348
- const decision = decide(
1349
- extractCommand(envelope),
1350
- _loadLedger(options.consumer_root, session_id, Date.now())
1351
- );
1955
+ const command = extractCommand(envelope);
1956
+ const ledger = _loadLedger(options.consumer_root, session_id, Date.now());
1957
+ let impact;
1958
+ const stage1 = command === null ? null : commandOp(command);
1959
+ const stage2Target = command === null ? null : mergeTargetOf(command);
1960
+ if (stage1 === "pr-merge" && stage2Target !== null && !ledger.authorized.has("pr-merge") && !grantCovers(ledger.grants ?? [], "pr-merge", stage2Target)) {
1961
+ try {
1962
+ impact = describeImpact(
1963
+ stage2Target,
1964
+ analyseMergeImpact(stage2Target, { cwd: options.consumer_root })
1965
+ );
1966
+ } catch {
1967
+ }
1968
+ }
1969
+ const decision = decide(command, ledger, impact);
1970
+ if (decision.consumedTarget !== void 0) {
1971
+ consumeGrantTarget(options.consumer_root, session_id, decision.consumedTarget);
1972
+ }
1352
1973
  if (decision.blockedOp) {
1353
1974
  try {
1354
1975
  atomic_write_json(path3.join(options.consumer_root, pendingFileFor(session_id)), {