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