@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.
@@ -25850,12 +25850,39 @@ var ALL_OPS = [
25850
25850
  "pr-merge-auto",
25851
25851
  "tag",
25852
25852
  "release",
25853
- "publish"
25853
+ "publish",
25854
+ "unpublish",
25855
+ "deprecate",
25856
+ "release-asset",
25857
+ "protection",
25858
+ "workflow-toggle",
25859
+ "repo-lifecycle",
25860
+ "review-approve",
25861
+ "force-push",
25862
+ "worktree-remove",
25863
+ "clean-ignored",
25864
+ "tag-force",
25865
+ "rebase",
25866
+ "reset-hard",
25867
+ "clean",
25868
+ "stash-drop",
25869
+ "branch-delete",
25870
+ "close"
25854
25871
  ];
25855
25872
  var PHRASES = [
25856
25873
  { op: "commit", re: /\b(commit(e|et|te|ten)?|committe|einchecken)\b/i },
25857
25874
  { op: "push", re: /\b(push(e|en|t)?|hochladen|hochschieben|raufschieben)\b/i },
25858
- { op: "branch", re: /\b(branch|feature-branch|zweig)\b/i },
25875
+ // A CREATION VERB IS REQUIRED, as for `pr-create` above and for the same
25876
+ // reason. The bare noun could not tell an authorization from a mention, and
25877
+ // the mention is the dangerous half: "den branch nicht löschen" names the
25878
+ // object precisely in order to forbid acting on it, yet authorized branch
25879
+ // creation — the negation trails a bare-noun match, so neither the
25880
+ // look-behind nor the span check in `negatedBefore` can reach it. Widened
25881
+ // 2026-09-03 alongside the destructive-coverage work that measured it.
25882
+ {
25883
+ op: "branch",
25884
+ 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
25885
+ },
25859
25886
  // A creation verb is required — "schau dir den PR an" is not "open a PR".
25860
25887
  {
25861
25888
  op: "pr-create",
@@ -25892,7 +25919,111 @@ var PHRASES = [
25892
25919
  op: "release",
25893
25920
  re: /\b(releasen?|ver(ö|oe)ffentlich(e|en))\b(?!\s*[- ]?(notes?|branch|candidate|pr\b|datum|date|zweig))/i
25894
25921
  },
25895
- { op: "publish", re: /\b(publish(e|en)?|publiziere[n]?)\b/i }
25922
+ { op: "publish", re: /\b(publish(e|en)?|publiziere[n]?)\b/i },
25923
+ // ── Authorization vocabulary for the ops added 2026-09-03.
25924
+ //
25925
+ // NON-NEGOTIABLE, and the reason it ships in the same change as the new BLOCK
25926
+ // ops: a blocked operation with no phrase that authorizes it is a dead end.
25927
+ // The guard refuses, the user says "doch, mach das", the classifier records
25928
+ // nothing, and the refusal repeats forever. Every op the guard can block must
25929
+ // have a sentence that unblocks it, or the user's override is a claim rather
25930
+ // than a mechanism.
25931
+ //
25932
+ // Tight on purpose. Under-matching costs one confirmation; over-matching
25933
+ // authorizes something the user did not ask for, so each pattern demands the
25934
+ // object noun rather than a bare verb wherever the verb is ordinary German.
25935
+ //
25936
+ // `unpublish` before nothing in particular — `\bpublish\b` does not match
25937
+ // inside "unpublish" (no word boundary between `n` and `p`), so the two never
25938
+ // collide.
25939
+ // TWO SHAPES EVERY GERMAN PATTERN BELOW MUST CARRY, both learned from tests
25940
+ // that went red rather than from inspection:
25941
+ //
25942
+ // 1. THE IMPERATIVE DROPS ITS `-e`. "entfern die worktree", "archivier das
25943
+ // repo", "deaktivier den workflow" are what people type; `entfern(e|en)`
25944
+ // matches none of them. Every verb ending is therefore `(e|en)?`.
25945
+ // 2. WORD ORDER GOES BOTH WAYS. "archivier das repo" and "das repo
25946
+ // archivieren" are one instruction, and a pattern anchored on the noun
25947
+ // first sees only the second.
25948
+ //
25949
+ // The object noun stays REQUIRED in every pattern that has one. That is what
25950
+ // keeps the loosened verb endings from over-authorizing: "lösch" alone
25951
+ // authorizes nothing, "lösch den branch" authorizes a branch deletion.
25952
+ { op: "unpublish", re: /\bunpublish(e|en)?\b/i },
25953
+ {
25954
+ op: "deprecate",
25955
+ re: /\bdeprecate(d|n)?\b|\bals\s+veraltet\s+markier(e|en)?\b/i
25956
+ },
25957
+ {
25958
+ op: "release-asset",
25959
+ 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
25960
+ },
25961
+ // A VERB IS REQUIRED, like `tag` and `release` above and for the same reason.
25962
+ // A bare noun cannot tell an authorization from a mention, and here the
25963
+ // mention is the dangerous half: "die branch-protection nicht anfassen" names
25964
+ // the object precisely in order to forbid touching it. Measured — this was the
25965
+ // one row of the negation corpus that neither the look-behind nor the
25966
+ // span check could reach, because the negation trails a bare-noun match.
25967
+ {
25968
+ op: "protection",
25969
+ 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
25970
+ },
25971
+ {
25972
+ op: "workflow-toggle",
25973
+ 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
25974
+ },
25975
+ {
25976
+ op: "repo-lifecycle",
25977
+ 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
25978
+ },
25979
+ {
25980
+ op: "review-approve",
25981
+ 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
25982
+ },
25983
+ {
25984
+ op: "force-push",
25985
+ re: /\bforce[-\s]?push(e|en|st)?\b|--force(-with-lease)?\b|\bmit\s+gewalt\s+push(e|en)?\b/i
25986
+ },
25987
+ {
25988
+ op: "worktree-remove",
25989
+ 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
25990
+ },
25991
+ // The `-x` flag is the whole distinction: without it `git clean` spares
25992
+ // ignored files, with it `.env` and local certificates go too.
25993
+ {
25994
+ op: "clean-ignored",
25995
+ 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
25996
+ },
25997
+ {
25998
+ op: "tag-force",
25999
+ 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
26000
+ },
26001
+ { op: "rebase", re: /\brebase(n|st|d)?\b/i },
26002
+ {
26003
+ op: "reset-hard",
26004
+ re: /\breset\s+--hard\b|\bhard\s+reset\b|\bhart\s+zur(ü|ue)cksetz(e|en)?\b/i
26005
+ },
26006
+ { op: "clean", re: /\bgit\s+clean\b/i },
26007
+ {
26008
+ op: "stash-drop",
26009
+ 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
26010
+ },
26011
+ {
26012
+ op: "branch-delete",
26013
+ 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
26014
+ },
26015
+ {
26016
+ op: "close",
26017
+ 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
26018
+ },
26019
+ // Enabling auto-merge is its own op on the guard side, so it needs its own
26020
+ // sentence. Without one it is the dead end this block exists to prevent: the
26021
+ // plain merge phrase authorizes `pr-merge` and never `pr-merge-auto`, so the
26022
+ // refusal would have repeated forever.
26023
+ {
26024
+ op: "pr-merge-auto",
26025
+ re: /\bauto[-\s]?merge\b|\bmerge\b[^.\n]{0,20}\bautomatisch\b|\bautomatisch\b[^.\n]{0,20}\bmerg(e|en)\b/i
26026
+ }
25896
26027
  ];
25897
26028
  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;
25898
26029
  function isInterrogative(prose) {
@@ -25953,6 +26084,67 @@ function splitFences(prompt) {
25953
26084
  function _commandLines(fence) {
25954
26085
  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));
25955
26086
  }
26087
+ function extractMergeTargets(prompt) {
26088
+ const out = /* @__PURE__ */ new Set();
26089
+ const re = /(?:#|\bpr[- ]?|\bpull[- ]request\s+#?)(\d{1,7})\b/giu;
26090
+ let m;
26091
+ while ((m = re.exec(prompt)) !== null) {
26092
+ const n = Number.parseInt(m[1], 10);
26093
+ if (Number.isSafeInteger(n) && n > 0) {
26094
+ out.add(n);
26095
+ }
26096
+ }
26097
+ return [...out].sort((a, b) => a - b);
26098
+ }
26099
+ 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;
26100
+ function isRevocation(prompt) {
26101
+ return REVOKE_RE.test(prompt);
26102
+ }
26103
+ function _grantId(session_id, at, targets) {
26104
+ return `${sessionSlug(session_id) || "nosession"}:${at}:${targets.join(",")}`;
26105
+ }
26106
+ function foldGrants(prior, prompt, authorized, session_id, now) {
26107
+ if (isRevocation(prompt)) {
26108
+ return [];
26109
+ }
26110
+ const carried = prior.filter((g) => g.consumed.length < g.targets.length);
26111
+ if (!authorized.includes("pr-merge")) {
26112
+ return carried;
26113
+ }
26114
+ const targets = extractMergeTargets(prompt);
26115
+ if (targets.length === 0) {
26116
+ return carried;
26117
+ }
26118
+ const at = now.toISOString();
26119
+ const already = new Set(carried.flatMap((g) => g.targets));
26120
+ const fresh = targets.filter((t) => !already.has(t));
26121
+ if (fresh.length === 0) {
26122
+ return carried;
26123
+ }
26124
+ return [
26125
+ ...carried,
26126
+ {
26127
+ id: _grantId(session_id, at, fresh),
26128
+ op: "pr-merge",
26129
+ targets: fresh,
26130
+ consumed: [],
26131
+ granted_at: at,
26132
+ evidence: prompt.trim().slice(0, 120)
26133
+ }
26134
+ ];
26135
+ }
26136
+ var NEGATION_WORD = /\b(nicht|nichts|kein(e|en|em|er|es)?|niemals|nie|no|not|dont|don't|never|without|ohne)\b/i;
26137
+ function negatedBefore(text, index, matched = "") {
26138
+ let start = 0;
26139
+ for (const mark of [".", "!", "?", "\n"]) {
26140
+ const at = text.lastIndexOf(mark, index - 1);
26141
+ if (at + 1 > start) {
26142
+ start = at + 1;
26143
+ }
26144
+ }
26145
+ const before = text.slice(Math.max(start, index - 30), index);
26146
+ return NEGATION_WORD.test(before) || NEGATION_WORD.test(matched);
26147
+ }
25956
26148
  function classifyAuthorization(prompt) {
25957
26149
  const { prose, fences } = splitFences(prompt);
25958
26150
  const authorized = /* @__PURE__ */ new Set();
@@ -25960,7 +26152,7 @@ function classifyAuthorization(prompt) {
25960
26152
  const instruction = prose.split("\n").filter((l) => !OUTPUT_LINE.test(l)).join("\n");
25961
26153
  for (const { op, re } of isInterrogative(instruction) ? [] : PHRASES) {
25962
26154
  const m = re.exec(instruction);
25963
- if (m) {
26155
+ if (m && !negatedBefore(instruction, m.index, m[0])) {
25964
26156
  authorized.add(op);
25965
26157
  evidence[op] = `prose: "${m[0]}"`;
25966
26158
  }
@@ -25982,6 +26174,68 @@ function classifyAuthorization(prompt) {
25982
26174
  }
25983
26175
  return { authorized: [...authorized], evidence };
25984
26176
  }
26177
+ function readGrants(consumer_root, session_id) {
26178
+ try {
26179
+ const raw = fs55.readFileSync(path55.join(consumer_root, ledgerFileFor(session_id)), "utf8");
26180
+ const decoded = JSON.parse(raw);
26181
+ if (!_isObject8(decoded) || !Array.isArray(decoded["grants"])) {
26182
+ return [];
26183
+ }
26184
+ const owner = typeof decoded["session_id"] === "string" ? decoded["session_id"] : "";
26185
+ if (session_id && owner && owner !== session_id) {
26186
+ return [];
26187
+ }
26188
+ return decoded["grants"].flatMap((g) => {
26189
+ if (!_isObject8(g)) return [];
26190
+ const op = g["op"];
26191
+ if (typeof op !== "string" || !ALL_OPS.includes(op)) return [];
26192
+ const targets = Array.isArray(g["targets"]) ? g["targets"] : [];
26193
+ const consumed = Array.isArray(g["consumed"]) ? g["consumed"] : [];
26194
+ return [
26195
+ {
26196
+ id: String(g["id"] ?? ""),
26197
+ op,
26198
+ targets: targets.filter((t) => typeof t === "number"),
26199
+ consumed: consumed.filter((t) => typeof t === "number"),
26200
+ granted_at: String(g["granted_at"] ?? ""),
26201
+ evidence: String(g["evidence"] ?? "")
26202
+ }
26203
+ ];
26204
+ });
26205
+ } catch {
26206
+ return [];
26207
+ }
26208
+ }
26209
+ function consumeGrantTarget(consumer_root, session_id, target) {
26210
+ const file = path55.join(consumer_root, ledgerFileFor(session_id));
26211
+ let decoded;
26212
+ try {
26213
+ decoded = JSON.parse(fs55.readFileSync(file, "utf8"));
26214
+ } catch {
26215
+ return;
26216
+ }
26217
+ if (!_isObject8(decoded) || !Array.isArray(decoded["grants"])) {
26218
+ return;
26219
+ }
26220
+ let touched = false;
26221
+ for (const g of decoded["grants"]) {
26222
+ if (!_isObject8(g) || !Array.isArray(g["targets"]) || !Array.isArray(g["consumed"])) {
26223
+ continue;
26224
+ }
26225
+ const targets = g["targets"];
26226
+ const consumed = g["consumed"];
26227
+ if (targets.includes(target) && !consumed.includes(target)) {
26228
+ consumed.push(target);
26229
+ touched = true;
26230
+ }
26231
+ }
26232
+ if (touched) {
26233
+ try {
26234
+ atomic_write_json(file, decoded);
26235
+ } catch {
26236
+ }
26237
+ }
26238
+ }
25985
26239
  function takePending(consumer_root, session_id, now) {
25986
26240
  const file = path55.join(consumer_root, pendingFileFor(session_id));
25987
26241
  let raw;
@@ -26050,12 +26304,24 @@ function run10(stdin_text, options) {
26050
26304
  authorized.push(pending.op);
26051
26305
  evidence[pending.op] = `confirmation of the refused \`${pending.op}\` (refused ${pending.refused_at}): "${prompt.trim().slice(0, 40)}"`;
26052
26306
  }
26307
+ const now = /* @__PURE__ */ new Date();
26053
26308
  const ledger = {
26054
26309
  session_id: typeof envelope["session_id"] === "string" ? envelope["session_id"] : "",
26055
- detected_at: (/* @__PURE__ */ new Date()).toISOString(),
26310
+ detected_at: now.toISOString(),
26056
26311
  authorized,
26057
26312
  evidence,
26058
- prompt_chars: prompt.length
26313
+ prompt_chars: prompt.length,
26314
+ // Grants survive this write; `authorized` does not. That asymmetry IS the
26315
+ // decision. A bare operation name stays one-shot exactly as `commit-policy`
26316
+ // requires, and only an authorization that also froze its objects earns a
26317
+ // life longer than the turn. See ADR-252.
26318
+ grants: foldGrants(
26319
+ readGrants(options.consumer_root, session),
26320
+ prompt,
26321
+ authorized,
26322
+ session,
26323
+ now
26324
+ )
26059
26325
  };
26060
26326
  try {
26061
26327
  atomic_write_json(path55.join(options.consumer_root, ledgerFileFor(session)), ledger);
@@ -26104,6 +26370,95 @@ if (_isCliEntry24()) {
26104
26370
  import fs56 from "node:fs";
26105
26371
  import path56 from "node:path";
26106
26372
  import { fileURLToPath as fileURLToPath34, pathToFileURL as pathToFileURL27 } from "node:url";
26373
+
26374
+ // src/scripts/hooks/merge_impact.ts
26375
+ import { execFileSync as execFileSync3 } from "node:child_process";
26376
+ var DESTRUCTIVE_MARKERS = [
26377
+ // Schema removal. Matched on ADDED lines only — a migration that *removes* a
26378
+ // dropColumn call is the opposite of destructive, and matching the raw patch
26379
+ // would score it the same way.
26380
+ { label: "migration drops a column or table", re: /^\+.*\b(dropColumn|dropTable|dropIfExists|removeColumn|drop_column|drop_table)\b/im },
26381
+ { label: "raw DROP or TRUNCATE in a migration", re: /^\+.*\b(DROP\s+(TABLE|COLUMN|INDEX|CONSTRAINT)|TRUNCATE\s+TABLE)\b/im },
26382
+ // The author said so themselves. Conventional Commits marks a breaking change
26383
+ // with a `!` before the colon or a body trailer.
26384
+ { label: "author marked a BREAKING CHANGE", re: /^\+.*BREAKING[ -]CHANGE/m },
26385
+ // A removed export is the shape most likely to break an importer.
26386
+ { 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 },
26387
+ { label: "a public PHP or Python symbol was removed", re: /^-\s*(public\s+function|def\s+[a-z_]+\s*\()/m },
26388
+ // Route removal breaks callers that never imported anything.
26389
+ { 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 },
26390
+ { label: "a whole file was deleted", re: /^deleted file mode /m }
26391
+ ];
26392
+ var MAJOR_BUMP = /^-\s*"version"\s*:\s*"(\d+)\.[^"]*"[\s\S]{0,200}?^\+\s*"version"\s*:\s*"(\d+)\./m;
26393
+ function classifyDiff(patch) {
26394
+ if (patch.trim() === "") {
26395
+ return { verdict: "undecidable", markers: [], reason: "empty diff" };
26396
+ }
26397
+ const markers = [];
26398
+ for (const { label, re } of DESTRUCTIVE_MARKERS) {
26399
+ if (re.test(patch)) {
26400
+ markers.push(label);
26401
+ }
26402
+ }
26403
+ const bump = MAJOR_BUMP.exec(patch);
26404
+ if (bump && bump[1] !== bump[2]) {
26405
+ markers.push(`major version bump ${bump[1]}.x to ${bump[2]}.x`);
26406
+ }
26407
+ const filesChanged = (patch.match(/^diff --git /gm) ?? []).length;
26408
+ if (markers.length > 0) {
26409
+ return { verdict: "destructive", markers, filesChanged };
26410
+ }
26411
+ return { verdict: "additive", markers: [], filesChanged };
26412
+ }
26413
+ function fetchPatch(pr, opts = {}) {
26414
+ const run13 = opts.exec ?? execFileSync3;
26415
+ const cwd = opts.cwd ?? process.cwd();
26416
+ const capture = (file, args) => run13(file, args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 15e3 });
26417
+ for (const ref of [`refs/pull/${pr}/head`, `pull/${pr}/head`]) {
26418
+ try {
26419
+ const base = capture("git", ["merge-base", "HEAD", ref]).trim();
26420
+ if (base) {
26421
+ const patch = capture("git", ["diff", `${base}...${ref}`]);
26422
+ if (patch.trim() !== "") {
26423
+ return { patch, source: "local" };
26424
+ }
26425
+ }
26426
+ } catch {
26427
+ }
26428
+ }
26429
+ if (opts.allowNetwork === false) {
26430
+ return { patch: "", source: "none", reason: "no local ref and network reads disabled" };
26431
+ }
26432
+ try {
26433
+ const patch = capture("gh", ["pr", "diff", String(pr), "--patch"]);
26434
+ if (patch.trim() !== "") {
26435
+ return { patch, source: "gh" };
26436
+ }
26437
+ return { patch: "", source: "none", reason: "gh returned an empty diff" };
26438
+ } catch {
26439
+ return { patch: "", source: "none", reason: "no local ref, and gh pr diff failed" };
26440
+ }
26441
+ }
26442
+ function analyseMergeImpact(pr, opts = {}) {
26443
+ const got = fetchPatch(pr, opts);
26444
+ if (got.source === "none") {
26445
+ return { verdict: "undecidable", markers: [], ...got.reason ? { reason: got.reason } : {} };
26446
+ }
26447
+ return classifyDiff(got.patch);
26448
+ }
26449
+ function describeImpact(pr, impact) {
26450
+ const say = `Say "merge #${pr}" to authorize it.`;
26451
+ switch (impact.verdict) {
26452
+ case "additive":
26453
+ 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}`;
26454
+ case "destructive":
26455
+ return `Impact scan: PR #${pr} is NOT additive \u2014 ${impact.markers.join("; ")}. Read the diff before authorizing. ${say}`;
26456
+ default:
26457
+ return `Impact scan: could not read the diff for PR #${pr} (${impact.reason ?? "unknown"}), so nothing is known about what it changes. ${say}`;
26458
+ }
26459
+ }
26460
+
26461
+ // src/scripts/hooks/block_unauthorized_git.ts
26107
26462
  var EXIT_ALLOW11 = 0;
26108
26463
  var EXIT_BLOCK = 1;
26109
26464
  function _isObject9(v) {
@@ -26116,13 +26471,52 @@ var BLOCK_OPS = /* @__PURE__ */ new Set([
26116
26471
  "pr-merge",
26117
26472
  // Enabling auto-merge commits the outcome to a condition the agent does not
26118
26473
  // control, so it is blocked on the same terms as the merge it schedules.
26119
- "pr-merge-auto"
26474
+ "pr-merge-auto",
26475
+ // ── Added 2026-09-03. Every one is already never-autonomous in the
26476
+ // `non-destructive-by-default` prose; the measurement that drove it is in the
26477
+ // `GitOp` union's own note — 17 of 25 borderline operations classified as
26478
+ // NOTHING, including three worse than anything the guard already caught.
26479
+ /** The undo that is more destructive than the act: lockfiles break. */
26480
+ "unpublish",
26481
+ /** Metadata only, and every install on earth starts warning. */
26482
+ "deprecate",
26483
+ /** Replaces bytes consumers already hold a checksum for. */
26484
+ "release-asset",
26485
+ /** Deletes the guard rather than the data — everything after is unguarded. */
26486
+ "protection",
26487
+ /** No data lost; the gate that protects everything downstream is off. */
26488
+ "workflow-toggle",
26489
+ /** Reach outside the repository, and mostly one-way. */
26490
+ "repo-lifecycle",
26491
+ /** No code moves, and an armed auto-merge is released. */
26492
+ "review-approve",
26493
+ /** Discards commits that landed after the last fetch, on a shared ref. */
26494
+ "force-push",
26495
+ /** Destroys uncommitted work belonging to a session this one cannot see. */
26496
+ "worktree-remove",
26497
+ /** `-x` is the flag that takes `.env` and local certificates. */
26498
+ "clean-ignored"
26120
26499
  ]);
26121
26500
  var WARN_OPS = /* @__PURE__ */ new Set([
26122
26501
  "commit",
26123
26502
  "push",
26124
26503
  "pr-create",
26125
- "branch"
26504
+ "branch",
26505
+ // ── Added 2026-09-03, deliberately at warn rather than block.
26506
+ //
26507
+ // Each is destructive in some reading, and each is also routine enough that
26508
+ // blocking it would be the friction that gets the whole guard routed around —
26509
+ // the failure mode this estate has already recorded twice. `reset-hard` is the
26510
+ // sharpest case and stays here for a decidable reason: the Hard Floor's
26511
+ // qualifier is "past unpushed work", and the command text cannot say whether
26512
+ // the work was pushed.
26513
+ "tag-force",
26514
+ "rebase",
26515
+ "reset-hard",
26516
+ "clean",
26517
+ "stash-drop",
26518
+ "branch-delete",
26519
+ "close"
26126
26520
  ]);
26127
26521
  var G = "(?:-[A-Za-z-]+(?:=\\S+)?(?:\\s+\\S+)?\\s+)*";
26128
26522
  var P = "^(?:\\S*\\/)?";
@@ -26168,6 +26562,72 @@ var COMMAND_OPS = [
26168
26562
  op: "pr-create",
26169
26563
  re: new RegExp(`${P}gh\\s+pr\\s+create\\b|${ghApiWrite("\\/pulls\\b").source}`, "i")
26170
26564
  },
26565
+ // ── Added 2026-09-03. Placement inside this table is as load-bearing as the
26566
+ // patterns: the loop takes the MOST SEVERE match per segment, but within one
26567
+ // op the first pattern wins, so anything more specific than a plain `git push`
26568
+ // has to sit above it.
26569
+ // `npm unpublish` is NOT reached by the publish pattern — `\bpublish\b` needs
26570
+ // a word boundary that `unpublish` does not provide between `n` and `p`. That
26571
+ // is why it classified as nothing for as long as it did.
26572
+ { op: "unpublish", re: new RegExp(`${P}(npm|pnpm|yarn)\\s+${G}unpublish\\b`, "i") },
26573
+ { op: "deprecate", re: new RegExp(`${P}(npm|pnpm|yarn)\\s+${G}deprecate\\b`, "i") },
26574
+ {
26575
+ op: "release-asset",
26576
+ re: new RegExp(`${P}gh\\s+release\\s+(upload|edit|delete)\\b`, "i")
26577
+ },
26578
+ {
26579
+ op: "protection",
26580
+ re: new RegExp(
26581
+ `${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`,
26582
+ "i"
26583
+ )
26584
+ },
26585
+ {
26586
+ op: "workflow-toggle",
26587
+ re: new RegExp(`${P}gh\\s+workflow\\s+(disable|enable)\\b`, "i")
26588
+ },
26589
+ {
26590
+ op: "repo-lifecycle",
26591
+ re: new RegExp(
26592
+ `${P}gh\\s+repo\\s+(archive|delete|unarchive)\\b|${P}gh\\s+repo\\s+edit\\b(?=[^\\n]*--visibility\\b)`,
26593
+ "i"
26594
+ )
26595
+ },
26596
+ {
26597
+ op: "review-approve",
26598
+ re: new RegExp(`${P}gh\\s+pr\\s+review\\b(?=[^\\n]*--approve\\b)`, "i")
26599
+ },
26600
+ // Above `push`, and both spellings: `--force` and `--force-with-lease`. The
26601
+ // "safe force" is in the same class here because what it is safe against is a
26602
+ // stale local view, not a collaborator who pushed after your fetch.
26603
+ {
26604
+ op: "force-push",
26605
+ re: new RegExp(`${P}git\\s+${G}push\\b[^\\n;|&]*(--force\\b|--force-with-lease\\b|\\s-f\\b)`, "i")
26606
+ },
26607
+ {
26608
+ op: "worktree-remove",
26609
+ re: new RegExp(`${P}git\\s+worktree\\s+remove\\b[^\\n;|&]*(--force\\b|\\s-f\\b)`, "i")
26610
+ },
26611
+ // `-x` (or `-X`) anywhere in the flag cluster is what reaches ignored files.
26612
+ {
26613
+ op: "clean-ignored",
26614
+ re: new RegExp(`${P}git\\s+clean\\b[^\\n;|&]*-[A-Za-z]*x`, "i")
26615
+ },
26616
+ { op: "tag-force", re: new RegExp(`${P}git\\s+tag\\b[^\\n;|&]*(-f\\b|--force\\b)`, "i") },
26617
+ { op: "rebase", re: new RegExp(`${P}git\\s+${G}rebase\\b`, "i") },
26618
+ { op: "reset-hard", re: new RegExp(`${P}git\\s+${G}reset\\b[^\\n;|&]*--hard\\b`, "i") },
26619
+ { op: "clean", re: new RegExp(`${P}git\\s+clean\\b`, "i") },
26620
+ { op: "stash-drop", re: new RegExp(`${P}git\\s+stash\\s+(drop|clear)\\b`, "i") },
26621
+ // Remote deletion and the local `-D` are one op: both remove a ref, and the
26622
+ // remote form additionally closes any open PR pointing at it.
26623
+ {
26624
+ op: "branch-delete",
26625
+ re: new RegExp(
26626
+ `${P}git\\s+branch\\b[^\\n;|&]*(-D\\b|--delete\\b)|${P}git\\s+${G}push\\b[^\\n;|&]*(--delete\\b|\\s:\\S)`,
26627
+ "i"
26628
+ )
26629
+ },
26630
+ { op: "close", re: new RegExp(`${P}gh\\s+(pr|issue)\\s+close\\b`, "i") },
26171
26631
  { op: "push", re: new RegExp(`${P}git\\s+${G}push\\b`, "i") },
26172
26632
  { op: "commit", re: new RegExp(`${P}git\\s+${G}commit\\b`, "i") },
26173
26633
  { op: "branch", re: new RegExp(`${P}git\\s+${G}(checkout\\s+-b|switch\\s+-c)\\b`, "i") }
@@ -26360,8 +26820,25 @@ function commandOp(command) {
26360
26820
  }
26361
26821
  return found;
26362
26822
  }
26823
+ function mergeTargetOf(command) {
26824
+ const gh = /\bgh\s+pr\s+merge\s+(?:-{1,2}\S+\s+)*?(\d{1,7})\b/i.exec(command);
26825
+ if (gh) {
26826
+ return Number.parseInt(gh[1], 10);
26827
+ }
26828
+ const api = /\/pulls\/(\d{1,7})\/merge\b/i.exec(command);
26829
+ return api ? Number.parseInt(api[1], 10) : null;
26830
+ }
26831
+ function grantCovers(grants, op, target) {
26832
+ if (op !== "pr-merge" || target === null) {
26833
+ return null;
26834
+ }
26835
+ return grants.find(
26836
+ (g) => g.op === "pr-merge" && g.targets.includes(target) && !g.consumed.includes(target)
26837
+ ) ?? null;
26838
+ }
26363
26839
  var LEDGER_MAX_AGE_MS = 30 * 60 * 1e3;
26364
26840
  function _loadLedger(consumer_root, session_id, now) {
26841
+ const grants = readGrants(consumer_root, session_id);
26365
26842
  const candidates = [ledgerFileFor(session_id)];
26366
26843
  if (!candidates.includes(STATE_FILE7)) {
26367
26844
  candidates.push(STATE_FILE7);
@@ -26369,10 +26846,10 @@ function _loadLedger(consumer_root, session_id, now) {
26369
26846
  for (const rel of candidates) {
26370
26847
  const found = _readLedgerFile(path56.join(consumer_root, rel), session_id, now);
26371
26848
  if (found !== null) {
26372
- return found;
26849
+ return { ...found, grants };
26373
26850
  }
26374
26851
  }
26375
- return { authorized: /* @__PURE__ */ new Set(), present: false };
26852
+ return { authorized: /* @__PURE__ */ new Set(), present: false, grants };
26376
26853
  }
26377
26854
  function _readLedgerFile(file, session_id, now) {
26378
26855
  try {
@@ -26396,7 +26873,7 @@ function _readLedgerFile(file, session_id, now) {
26396
26873
  }
26397
26874
  return null;
26398
26875
  }
26399
- function decide(command, ledger) {
26876
+ function decide(command, ledger, impact) {
26400
26877
  if (command === null) {
26401
26878
  return { exit: EXIT_ALLOW11, stdout: "", stderr: "" };
26402
26879
  }
@@ -26404,9 +26881,14 @@ function decide(command, ledger) {
26404
26881
  if (op === null || ledger.authorized.has(op)) {
26405
26882
  return { exit: EXIT_ALLOW11, stdout: "", stderr: "" };
26406
26883
  }
26884
+ const target = mergeTargetOf(command);
26885
+ if (grantCovers(ledger.grants ?? [], op, target) && target !== null) {
26886
+ return { exit: EXIT_ALLOW11, stdout: "", stderr: "", consumedTarget: target };
26887
+ }
26407
26888
  const shown = command.length > 120 ? `${command.slice(0, 117)}\u2026` : command;
26408
26889
  if (BLOCK_OPS.has(op)) {
26409
- 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.`;
26890
+ const scan = op === "pr-merge" && target !== null && impact ? ` ${impact}` : "";
26891
+ 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.`;
26410
26892
  return { exit: EXIT_BLOCK, stdout: "", stderr: `${reason}
26411
26893
  `, blockedOp: op };
26412
26894
  }
@@ -26434,10 +26916,24 @@ function run11(stdin_text, options) {
26434
26916
  }
26435
26917
  }
26436
26918
  const session_id = typeof envelope["session_id"] === "string" ? envelope["session_id"] : "";
26437
- const decision = decide(
26438
- extractCommand(envelope),
26439
- _loadLedger(options.consumer_root, session_id, Date.now())
26440
- );
26919
+ const command = extractCommand(envelope);
26920
+ const ledger = _loadLedger(options.consumer_root, session_id, Date.now());
26921
+ let impact;
26922
+ const stage1 = command === null ? null : commandOp(command);
26923
+ const stage2Target = command === null ? null : mergeTargetOf(command);
26924
+ if (stage1 === "pr-merge" && stage2Target !== null && !ledger.authorized.has("pr-merge") && !grantCovers(ledger.grants ?? [], "pr-merge", stage2Target)) {
26925
+ try {
26926
+ impact = describeImpact(
26927
+ stage2Target,
26928
+ analyseMergeImpact(stage2Target, { cwd: options.consumer_root })
26929
+ );
26930
+ } catch {
26931
+ }
26932
+ }
26933
+ const decision = decide(command, ledger, impact);
26934
+ if (decision.consumedTarget !== void 0) {
26935
+ consumeGrantTarget(options.consumer_root, session_id, decision.consumedTarget);
26936
+ }
26441
26937
  if (decision.blockedOp) {
26442
26938
  try {
26443
26939
  atomic_write_json(path56.join(options.consumer_root, pendingFileFor(session_id)), {
@@ -29177,7 +29673,7 @@ import path67 from "node:path";
29177
29673
  import { fileURLToPath as fileURLToPath43, pathToFileURL as pathToFileURL36 } from "node:url";
29178
29674
 
29179
29675
  // src/scripts/code_graph/detect.ts
29180
- import { execFileSync as execFileSync3 } from "node:child_process";
29676
+ import { execFileSync as execFileSync4 } from "node:child_process";
29181
29677
  import * as fs66 from "node:fs";
29182
29678
  import * as path66 from "node:path";
29183
29679
 
@@ -29228,7 +29724,7 @@ function validateGraph(g) {
29228
29724
  var CONSUMER_CANDIDATES = ["graph.json", "code-graph.json", ".code-graph/graph.json"];
29229
29725
  function gitLastCommitEpoch(root) {
29230
29726
  try {
29231
- const out = execFileSync3("git", ["-C", root, "log", "-1", "--format=%ct"], {
29727
+ const out = execFileSync4("git", ["-C", root, "log", "-1", "--format=%ct"], {
29232
29728
  env: hardenedSpawnEnv(),
29233
29729
  encoding: "utf-8",
29234
29730
  stdio: ["ignore", "pipe", "ignore"]
@@ -29241,7 +29737,7 @@ function gitLastCommitEpoch(root) {
29241
29737
  }
29242
29738
  function commitsBehind(root, sha) {
29243
29739
  try {
29244
- const out = execFileSync3("git", ["-C", root, "rev-list", "--count", `${sha}..HEAD`], {
29740
+ const out = execFileSync4("git", ["-C", root, "rev-list", "--count", `${sha}..HEAD`], {
29245
29741
  env: hardenedSpawnEnv(),
29246
29742
  encoding: "utf-8",
29247
29743
  stdio: ["ignore", "pipe", "ignore"]
@@ -9,7 +9,7 @@
9
9
  "homepage": "https://event4u-app.github.io/agent-config",
10
10
  "name": "@event4u/agent-config",
11
11
  "repository": "https://github.com/event4u-app/agent-config",
12
- "version": "14.14.1"
12
+ "version": "14.15.0"
13
13
  },
14
14
  "registries": [
15
15
  {