@event4u/agent-config 14.15.0 → 14.17.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 +84 -153
- package/README.md +1 -1
- package/dist/agent-src/commands/analyze/inbox.md +172 -6
- package/dist/agent-src/commands/fix/commit-messages.md +336 -0
- package/dist/agent-src/commands/fix.md +5 -3
- package/dist/agent-src/rules/commit-conventions.md +1 -1
- 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/aws-infrastructure/SKILL.md +41 -0
- package/dist/agent-src/skills/conventional-commits-writing/SKILL.md +234 -4
- 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/skills/terraform/SKILL.md +29 -0
- package/dist/agent-src/skills/threat-modeling/data/reasoning.csv +1 -0
- package/dist/agent-src/skills/threat-modeling/data/surfaces.csv +1 -0
- package/dist/agent-src/skills/threat-modeling/data/threats.csv +5 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli-delegate/cmd_conformance.js +158 -33
- package/dist/cli-delegate/cmd_explain.js +24 -112
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +47 -21
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +3 -3
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +6 -5
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +5 -4
- package/dist/hooks/dispatch.js +169 -41
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +160 -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/web-launch-readiness.json +1 -1
- package/src/scripts/_cli/explain_last/index.ts +1 -1
- package/src/scripts/_cli/explain_last/memory.ts +8 -104
- package/src/scripts/check_artefact_count_messaging.ts +9 -0
- package/src/scripts/check_release_highlights.ts +77 -4
- package/src/scripts/check_web_launch_readiness.ts +295 -15
- package/src/scripts/design_slop_rules.ts +19 -9
- package/src/scripts/git_authorization_hook.ts +257 -35
- package/src/scripts/hooks/block_unauthorized_git.ts +16 -4
- package/src/scripts/hooks/merge_impact.ts +68 -0
- package/src/scripts/measure_release_mix.ts +384 -0
- package/src/scripts/release.ts +4 -17
- package/src/scripts/release_drill.ts +154 -1
- package/src/scripts/release_mix_taxonomy.json +172 -0
- package/src/scripts/release_publication.ts +91 -0
- package/src/scripts/attest_artifact.ts +0 -193
|
@@ -296,20 +296,22 @@ const PHRASES: ReadonlyArray<{ op: GitOp; re: RegExp }> = [
|
|
|
296
296
|
// "never auto-merge" each returned ["pr-merge"] — a prompt saying DO NOT
|
|
297
297
|
// MERGE authorized a merge. The noun-sense lookahead was never a negator.
|
|
298
298
|
//
|
|
299
|
-
// The lookBEHIND
|
|
300
|
-
//
|
|
301
|
-
// its negated-claim window (`:490-493`). A sentence boundary ends a
|
|
302
|
-
// negation's reach: "Do not push. Merge PR #12." is two instructions, and a
|
|
303
|
-
// guard that let the first suppress the second would silently stop
|
|
304
|
-
// authorizing merges the user DID order — the failure mode that is worse
|
|
305
|
-
// than the defect, because nothing happens and nothing says why.
|
|
306
|
-
//
|
|
307
|
-
// Reusing that vocabulary rather than inventing a third is deliberate: two
|
|
299
|
+
// The inline negative lookBEHIND that used to live in this pattern was
|
|
300
|
+
// REMOVED on 2026-09-03, and its own comment is why. It warned that "two
|
|
308
301
|
// negation vocabularies in one tree drift, and the drift is invisible until
|
|
309
|
-
// a prompt lands in the gap between them
|
|
302
|
+
// a prompt lands in the gap between them" — and by then they had drifted:
|
|
303
|
+
// `negatedBefore` became clause-scanned in both directions while this
|
|
304
|
+
// lookbehind stayed a blind 30-character window with no forward half. The
|
|
305
|
+
// prompt that landed in the gap is `"nicht pushen, aber mergen"`, which the
|
|
306
|
+
// lookbehind refused (13 characters, inside its window, across a contrast
|
|
307
|
+
// cue it could not see) while the clause scan correctly authorizes it.
|
|
308
|
+
//
|
|
309
|
+
// Every phrase match already passes through `negatedBefore` at the call
|
|
310
|
+
// site, so the guard is not weakened by the removal — it is applied once,
|
|
311
|
+
// by the one vocabulary this file's comment asked for.
|
|
310
312
|
{
|
|
311
313
|
op: "pr-merge",
|
|
312
|
-
re:
|
|
314
|
+
re: /\b(merge|merg(e|en|st|t)|zusammenf(ü|ue)hren|reinmergen)\b(?!\s*[- ]?(conflict|konflikt|commit|base|queue|state|status))/i,
|
|
313
315
|
},
|
|
314
316
|
// A tag is an ACTION here — bare "Tag" is the German word for day, and
|
|
315
317
|
// "Version" is an ordinary noun. Both authorized a BLOCK op before this.
|
|
@@ -450,15 +452,26 @@ export function isInterrogative(prose: string): boolean {
|
|
|
450
452
|
if (!t) {
|
|
451
453
|
return false;
|
|
452
454
|
}
|
|
455
|
+
// An explicit trailing question mark OUTRANKS the imperative escape below.
|
|
456
|
+
// Measured 2026-09-03: the escape ran first, so "Can you do the release
|
|
457
|
+
// now?" needed `now` to stay OUT of the imperative list to be read as a
|
|
458
|
+
// question — which left `now` absent while its German counterpart `jetzt`
|
|
459
|
+
// was present, and "Do the release now." was therefore classified a question
|
|
460
|
+
// and authorized nothing while "Do the release jetzt." authorized `release`.
|
|
461
|
+
// Ordering the `?` first is what makes closing that parity gap safe: it is a
|
|
462
|
+
// NARROWING (more prompts read as questions), and it lets the widening below
|
|
463
|
+
// be a one-word list addition rather than a trade.
|
|
464
|
+
if (/\?\s*$/.test(t)) {
|
|
465
|
+
return true;
|
|
466
|
+
}
|
|
453
467
|
const hasImperative =
|
|
454
|
-
/\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(
|
|
468
|
+
/\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(
|
|
455
469
|
t,
|
|
456
470
|
);
|
|
457
471
|
if (hasImperative) {
|
|
458
472
|
return false;
|
|
459
473
|
}
|
|
460
474
|
return (
|
|
461
|
-
/\?\s*$/.test(t) ||
|
|
462
475
|
/^(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(
|
|
463
476
|
t,
|
|
464
477
|
)
|
|
@@ -608,13 +621,43 @@ export interface Ledger extends JsonObject {
|
|
|
608
621
|
* capability to a sentence that never identified an object, which is the exact
|
|
609
622
|
* conflation of lexical and object specificity both council seats rejected.
|
|
610
623
|
*/
|
|
624
|
+
/** One-line, bounded rendering of a caught value for a diagnostic. */
|
|
625
|
+
function _errText(err: unknown): string {
|
|
626
|
+
const t = err instanceof Error ? err.message : String(err);
|
|
627
|
+
return t.replace(/\s+/g, " ").trim().slice(0, 200);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* The largest pull-request number GitHub can issue: a signed 32-bit integer.
|
|
632
|
+
*
|
|
633
|
+
* Taken from the API contract rather than estimated, and shared by the mint
|
|
634
|
+
* site here and the consume site in `hooks/block_unauthorized_git.ts` so the
|
|
635
|
+
* two cannot drift — the failure this file already learned once with its
|
|
636
|
+
* negation vocabulary.
|
|
637
|
+
*/
|
|
638
|
+
export const PR_NUMBER_MAX = 2147483647;
|
|
639
|
+
|
|
611
640
|
export function extractMergeTargets(prompt: string): number[] {
|
|
612
641
|
const out = new Set<number>();
|
|
613
|
-
|
|
642
|
+
// The BOUND comes from the API contract, not from a guess: GitHub issue and
|
|
643
|
+
// pull-request numbers are a signed 32-bit integer, so ten digits is the
|
|
644
|
+
// lexical ceiling and `PR_NUMBER_MAX` is the real one. The old `\d{1,7}` was
|
|
645
|
+
// neither — an eight-digit number simply failed to match, and since the
|
|
646
|
+
// `\b` sits after digit seven it failed SILENTLY rather than out of range.
|
|
647
|
+
//
|
|
648
|
+
// Widening here is required for the consume-side widening to do anything at
|
|
649
|
+
// all: this is where a grant's target set is frozen, so a target that cannot
|
|
650
|
+
// be MINTED can never be matched however well it resolves later.
|
|
651
|
+
//
|
|
652
|
+
// The `#` / `PR ` prefix requirement is deliberately unchanged. Over-matching
|
|
653
|
+
// here would hand a clockless capability to a sentence that never identified
|
|
654
|
+
// an object, which is the conflation of lexical and object specificity both
|
|
655
|
+
// council seats rejected.
|
|
656
|
+
const re = /(?:#|\bpr[- ]?|\bpull[- ]request\s+#?)(\d{1,10})\b/giu;
|
|
614
657
|
let m: RegExpExecArray | null;
|
|
615
658
|
while ((m = re.exec(prompt)) !== null) {
|
|
616
659
|
const n = Number.parseInt(m[1] as string, 10);
|
|
617
|
-
if (Number.isSafeInteger(n) && n > 0) {
|
|
660
|
+
if (Number.isSafeInteger(n) && n > 0 && n <= PR_NUMBER_MAX) {
|
|
618
661
|
out.add(n);
|
|
619
662
|
}
|
|
620
663
|
}
|
|
@@ -725,21 +768,162 @@ const NEGATION_WORD =
|
|
|
725
768
|
* says why. The 30-character window inside the sentence is inherited from the
|
|
726
769
|
* `pr-merge` lookbehind that this generalises.
|
|
727
770
|
*/
|
|
728
|
-
|
|
771
|
+
const CONTRAST_CUE = /\b(aber|sondern|jedoch|but|however|instead)\b/gi;
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* Abbreviation and decimal dots are NOT sentence boundaries.
|
|
775
|
+
*
|
|
776
|
+
* MEASURED. `"Du sollst nicht z.B. den PR #12 mergen."` authorized `pr-merge`
|
|
777
|
+
* at 022c0d240: the boundary scan took the second dot of `z.B.` as the sentence
|
|
778
|
+
* start, which put the window at `" den PR #12 "` and left `nicht` out of
|
|
779
|
+
* scope. `"Nach Release 1.5 bitte nicht mergen."` fails the same way on the
|
|
780
|
+
* decimal point.
|
|
781
|
+
*
|
|
782
|
+
* Two rules, both narrow on purpose. A dot is not a boundary when it sits
|
|
783
|
+
* between digits, or when the word-ish token ending at it is a known
|
|
784
|
+
* abbreviation — including a single letter, which is what makes `z.B.` two
|
|
785
|
+
* non-boundaries rather than one.
|
|
786
|
+
*/
|
|
787
|
+
const ABBREVIATION_BEFORE_DOT =
|
|
788
|
+
/(?:^|[\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;
|
|
789
|
+
|
|
790
|
+
function isSentenceBoundary(text: string, at: number): boolean {
|
|
791
|
+
const mark = text[at];
|
|
792
|
+
if (mark !== ".") {
|
|
793
|
+
return true;
|
|
794
|
+
}
|
|
795
|
+
if (/\d/.test(text[at - 1] ?? "") && /\d/.test(text[at + 1] ?? "")) {
|
|
796
|
+
return false;
|
|
797
|
+
}
|
|
798
|
+
return !ABBREVIATION_BEFORE_DOT.test(text.slice(0, at + 1));
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/** Bounds of the sentence containing `index`, abbreviation- and decimal-aware. */
|
|
802
|
+
export function sentenceBounds(text: string, index: number): { start: number; end: number } {
|
|
729
803
|
let start = 0;
|
|
730
|
-
for (
|
|
731
|
-
const
|
|
732
|
-
if (
|
|
733
|
-
start =
|
|
804
|
+
for (let i = index - 1; i >= 0; i -= 1) {
|
|
805
|
+
const c = text[i] as string;
|
|
806
|
+
if ((c === "." || c === "!" || c === "?" || c === "\n") && isSentenceBoundary(text, i)) {
|
|
807
|
+
start = i + 1;
|
|
808
|
+
break;
|
|
734
809
|
}
|
|
735
810
|
}
|
|
736
|
-
|
|
811
|
+
let end = text.length;
|
|
812
|
+
for (let i = index; i < text.length; i += 1) {
|
|
813
|
+
const c = text[i] as string;
|
|
814
|
+
if ((c === "." || c === "!" || c === "?" || c === "\n") && isSentenceBoundary(text, i)) {
|
|
815
|
+
end = i;
|
|
816
|
+
break;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
return { start, end };
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* Split prose into sentences on the same boundaries `sentenceBounds` uses, so
|
|
824
|
+
* an abbreviation or a decimal point cannot split one instruction into two.
|
|
825
|
+
*/
|
|
826
|
+
export function _sentences(text: string): string[] {
|
|
827
|
+
const out: string[] = [];
|
|
828
|
+
let from = 0;
|
|
829
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
830
|
+
const c = text[i] as string;
|
|
831
|
+
if ((c === "." || c === "!" || c === "?" || c === "\n") && isSentenceBoundary(text, i)) {
|
|
832
|
+
out.push(text.slice(from, i + 1));
|
|
833
|
+
from = i + 1;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
if (from < text.length) {
|
|
837
|
+
out.push(text.slice(from));
|
|
838
|
+
}
|
|
839
|
+
return out.filter((t) => t.trim() !== "");
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/** Last clause boundary at or before `to`, within `[from, to)`. */
|
|
843
|
+
function clauseStart(text: string, from: number, to: number): number {
|
|
844
|
+
let start = from;
|
|
845
|
+
const seg = text.slice(from, to);
|
|
846
|
+
for (let i = seg.length - 1; i >= 0; i -= 1) {
|
|
847
|
+
const c = seg[i] as string;
|
|
848
|
+
if (c === "," || c === ";" || c === ":") {
|
|
849
|
+
start = from + i + 1;
|
|
850
|
+
break;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
CONTRAST_CUE.lastIndex = 0;
|
|
854
|
+
let m: RegExpExecArray | null;
|
|
855
|
+
while ((m = CONTRAST_CUE.exec(seg)) !== null) {
|
|
856
|
+
const after = from + m.index + m[0].length;
|
|
857
|
+
if (after > start) {
|
|
858
|
+
start = after;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
return start;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/** First clause boundary at or after `from`, within `(from, to]`. */
|
|
865
|
+
function clauseEnd(text: string, from: number, to: number): number {
|
|
866
|
+
const seg = text.slice(from, to);
|
|
867
|
+
let end = to;
|
|
868
|
+
const punct = seg.search(/[,;:]/);
|
|
869
|
+
if (punct !== -1) {
|
|
870
|
+
end = from + punct;
|
|
871
|
+
}
|
|
872
|
+
CONTRAST_CUE.lastIndex = 0;
|
|
873
|
+
const m = CONTRAST_CUE.exec(seg);
|
|
874
|
+
if (m !== null && from + m.index < end) {
|
|
875
|
+
end = from + m.index;
|
|
876
|
+
}
|
|
877
|
+
return end;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* Is the match at `index` under a negation?
|
|
882
|
+
*
|
|
883
|
+
* MEASURED, not anticipated. Probed on 2026-09-03 against the fifteen phrases
|
|
884
|
+
* added that day: **15 of 15 leaked** — "nicht unpublishen", "den workflow nicht
|
|
885
|
+
* deaktivieren", "kein force-push bitte" each authorized exactly the operation
|
|
886
|
+
* the sentence forbade. The same probe showed the hole predates them, since
|
|
887
|
+
* "kein force-push bitte" also authorized a plain `push` and "den branch nicht
|
|
888
|
+
* löschen" also authorized `branch`. Only `pr-merge` was protected, by a
|
|
889
|
+
* lookbehind written after the identical defect was found there in isolation.
|
|
890
|
+
*
|
|
891
|
+
* SENTENCE-SCOPED, and that bound is load-bearing rather than decorative.
|
|
892
|
+
* "Do not push. Merge PR #12." is two instructions, and a negation whose reach
|
|
893
|
+
* crossed the full stop would silently stop authorizing merges the user DID
|
|
894
|
+
* order — a failure worse than the defect, because nothing happens and nothing
|
|
895
|
+
* says why.
|
|
896
|
+
*
|
|
897
|
+
* CLAUSE-SCANNED IN BOTH DIRECTIONS since 2026-09-03, replacing a fixed
|
|
898
|
+
* 30-character lookbehind inherited from the `pr-merge` pattern. Four shapes
|
|
899
|
+
* leaked past that window, all measured at 022c0d240:
|
|
900
|
+
*
|
|
901
|
+
* "Merge PR #12 auf keinen Fall." negation AFTER the match
|
|
902
|
+
* "Merge #12 under no circumstances." negation AFTER the match
|
|
903
|
+
* "Du sollss nicht z.B. den PR #12 mergen." abbreviation dot cut the sentence
|
|
904
|
+
* "Bitte unter keinen Umstaenden … mergen" 38-44 chars, past the 30 window
|
|
905
|
+
*
|
|
906
|
+
* The scan runs to the nearest CLAUSE boundary rather than to the sentence
|
|
907
|
+
* bound, and that half is what keeps the widening from over-denying: a comma,
|
|
908
|
+
* a semicolon, a colon or a contrast cue ends the scan. Without it,
|
|
909
|
+
* "I do not want to wait any longer, please merge PR #12" would read as
|
|
910
|
+
* negated and silently refuse a merge the user ordered — the failure the
|
|
911
|
+
* sentence bound above exists to prevent, reintroduced one level down. With
|
|
912
|
+
* it, "Merge PR #123, but do not push to production." keeps authorizing the
|
|
913
|
+
* merge and keeps denying the push, which is the only reading a human gives it.
|
|
914
|
+
*/
|
|
915
|
+
export function negatedBefore(text: string, index: number, matched = ""): boolean {
|
|
916
|
+
const { start, end } = sentenceBounds(text, index);
|
|
917
|
+
const before = text.slice(clauseStart(text, start, index), index);
|
|
918
|
+
const afterFrom = index + matched.length;
|
|
919
|
+
const after =
|
|
920
|
+
afterFrom < end ? text.slice(afterFrom, clauseEnd(text, afterFrom, end)) : "";
|
|
737
921
|
// The negation is often INSIDE the match, not before it, and German word
|
|
738
922
|
// order is why: "das asset nicht ersetzen" is matched by a noun-first pattern
|
|
739
923
|
// that starts at "asset", so a look-behind from the match index sees an empty
|
|
740
924
|
// window. Measured — nine of the fifteen rows in the negation corpus failed
|
|
741
925
|
// exactly this way after the look-behind alone was added.
|
|
742
|
-
return NEGATION_WORD.test(before) || NEGATION_WORD.test(matched);
|
|
926
|
+
return NEGATION_WORD.test(before) || NEGATION_WORD.test(matched) || NEGATION_WORD.test(after);
|
|
743
927
|
}
|
|
744
928
|
|
|
745
929
|
/** Classify which ops a prompt authorizes. Exported for direct testing. */
|
|
@@ -758,11 +942,30 @@ export function classifyAuthorization(prompt: string): {
|
|
|
758
942
|
.filter((l) => !OUTPUT_LINE.test(l))
|
|
759
943
|
.join("\n");
|
|
760
944
|
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
945
|
+
// PER SENTENCE, not per prompt. `isInterrogative` reads a leading "Do " as a
|
|
946
|
+
// question, so `"Do not push. Merge PR #12."` classified the WHOLE block as
|
|
947
|
+
// interrogative and authorized nothing — a merge the user ordered in the
|
|
948
|
+
// second sentence, silently dropped because of the first word of the first.
|
|
949
|
+
// Measured at 022c0d240, and the archived roadmap that added the check had
|
|
950
|
+
// already recorded it as pre-existing. `"Do the release now. Merge PR #12."`
|
|
951
|
+
// and `"Is everything green. Merge PR #12."` fail the same way.
|
|
952
|
+
//
|
|
953
|
+
// The bound this restores is the one `question-not-instruction` states: a
|
|
954
|
+
// question sentence authorizes nothing, and an instruction sentence beside
|
|
955
|
+
// it still does. A genuine question is still refused whole —
|
|
956
|
+
// `"was macht npm publish eigentlich genau?"` is one sentence and stays
|
|
957
|
+
// denied.
|
|
958
|
+
for (const { op, re } of PHRASES) {
|
|
959
|
+
for (const sentence of _sentences(instruction)) {
|
|
960
|
+
if (isInterrogative(sentence)) {
|
|
961
|
+
continue;
|
|
962
|
+
}
|
|
963
|
+
const m = re.exec(sentence);
|
|
964
|
+
if (m && !negatedBefore(sentence, m.index, m[0])) {
|
|
965
|
+
authorized.add(op);
|
|
966
|
+
evidence[op] = `prose: "${m[0]}"`;
|
|
967
|
+
break;
|
|
968
|
+
}
|
|
766
969
|
}
|
|
767
970
|
}
|
|
768
971
|
|
|
@@ -868,10 +1071,20 @@ export function consumeGrantTarget(
|
|
|
868
1071
|
if (touched) {
|
|
869
1072
|
try {
|
|
870
1073
|
atomic_write_json(file, decoded as JsonObject);
|
|
871
|
-
} catch {
|
|
872
|
-
/*
|
|
873
|
-
re-allows one merge of a PR the user
|
|
874
|
-
toward the user's stated intent, never
|
|
1074
|
+
} catch (err) {
|
|
1075
|
+
/* The FAILURE DIRECTION is unchanged and still defensible: a failed write
|
|
1076
|
+
leaves the target unspent, which re-allows one merge of a PR the user
|
|
1077
|
+
did authorize by name — it fails toward the user's stated intent, never
|
|
1078
|
+
toward an unauthorized target. What changed on 2026-09-03 is that it is
|
|
1079
|
+
no longer INVISIBLE. "Observability only" was the reason given for
|
|
1080
|
+
writing nothing, which inverted the word: a grant that silently fails
|
|
1081
|
+
to be consumed is a capability that outlives its single use, and the
|
|
1082
|
+
operator has no way to know. */
|
|
1083
|
+
process.stderr.write(
|
|
1084
|
+
`git-authorization: grant-consumption write failed (${_errText(err)}) — ` +
|
|
1085
|
+
"the target stays UNSPENT, so one further merge of a PR the user named " +
|
|
1086
|
+
"will still be authorized. Authorization outcome unchanged.\n",
|
|
1087
|
+
);
|
|
875
1088
|
}
|
|
876
1089
|
}
|
|
877
1090
|
}
|
|
@@ -1005,10 +1218,19 @@ export function run(stdin_text: string, options: { consumer_root: string }): num
|
|
|
1005
1218
|
|
|
1006
1219
|
try {
|
|
1007
1220
|
atomic_write_json(path.join(options.consumer_root, ledgerFileFor(session)), ledger);
|
|
1008
|
-
} catch {
|
|
1009
|
-
//
|
|
1010
|
-
//
|
|
1011
|
-
//
|
|
1221
|
+
} catch (err) {
|
|
1222
|
+
// The SIBLING of the swallow in `consumeGrantTarget`, and named here
|
|
1223
|
+
// because AC-5 describes a behavioural class rather than one call site:
|
|
1224
|
+
// fixing one and leaving the other would have made the acceptance claim
|
|
1225
|
+
// half true. Same reasoning, different degradation — a failed write here
|
|
1226
|
+
// degrades the gate to "no ledger", which the pre_tool_use concern treats
|
|
1227
|
+
// as "not authorized" for the irreversible subset and as a warn for the
|
|
1228
|
+
// rest, so the direction is fail-closed and still unchanged.
|
|
1229
|
+
process.stderr.write(
|
|
1230
|
+
`git-authorization: session-ledger write failed (${_errText(err)}) — ` +
|
|
1231
|
+
"the gate degrades to \"no ledger\" for this session, which denies the " +
|
|
1232
|
+
"irreversible subset and warns on the rest. Authorization outcome unchanged.\n",
|
|
1233
|
+
);
|
|
1012
1234
|
}
|
|
1013
1235
|
return EXIT_ALLOW;
|
|
1014
1236
|
}
|
|
@@ -67,6 +67,7 @@ import {
|
|
|
67
67
|
consumeGrantTarget,
|
|
68
68
|
ledgerFileFor,
|
|
69
69
|
pendingFileFor,
|
|
70
|
+
PR_NUMBER_MAX,
|
|
70
71
|
readGrants,
|
|
71
72
|
STATE_FILE,
|
|
72
73
|
type GitOp,
|
|
@@ -626,15 +627,26 @@ export function commandOp(command: string): GitOp | null {
|
|
|
626
627
|
* unnamed target never consumes a grant minted for a named one.
|
|
627
628
|
*/
|
|
628
629
|
export function mergeTargetOf(command: string): number | null {
|
|
629
|
-
|
|
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);
|
|
630
636
|
if (gh) {
|
|
631
|
-
return
|
|
637
|
+
return _boundedTarget(gh[1] as string);
|
|
632
638
|
}
|
|
633
639
|
// The same operation by its REST spelling. `commandOp` already classifies
|
|
634
640
|
// `gh api -X PUT repos/o/r/pulls/1499/merge` as `pr-merge`, so a reader that
|
|
635
641
|
// missed it here would silently fall back to the clock.
|
|
636
|
-
const api = /\/pulls\/(\d{1,
|
|
637
|
-
return api ?
|
|
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;
|
|
638
650
|
}
|
|
639
651
|
|
|
640
652
|
/**
|
|
@@ -67,6 +67,70 @@ const DESTRUCTIVE_MARKERS: ReadonlyArray<{ label: string; re: RegExp }> = [
|
|
|
67
67
|
{ label: "a whole file was deleted", re: /^deleted file mode /m },
|
|
68
68
|
];
|
|
69
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
|
+
|
|
70
134
|
/** A major-version bump in a manifest, which is the author declaring a break. */
|
|
71
135
|
const MAJOR_BUMP =
|
|
72
136
|
/^-\s*"version"\s*:\s*"(\d+)\.[^"]*"[\s\S]{0,200}?^\+\s*"version"\s*:\s*"(\d+)\./m;
|
|
@@ -89,6 +153,10 @@ export function classifyDiff(patch: string): MergeImpact {
|
|
|
89
153
|
}
|
|
90
154
|
}
|
|
91
155
|
|
|
156
|
+
if (renamedSchemaRemoval(patch)) {
|
|
157
|
+
markers.push("a renamed file drops a column or table");
|
|
158
|
+
}
|
|
159
|
+
|
|
92
160
|
const bump = MAJOR_BUMP.exec(patch);
|
|
93
161
|
if (bump && bump[1] !== bump[2]) {
|
|
94
162
|
markers.push(`major version bump ${bump[1]}.x to ${bump[2]}.x`);
|