@event4u/agent-config 14.17.0 → 14.18.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 +170 -79
- package/README.md +1 -1
- package/dist/agent-src/commands/pr/merge.md +35 -44
- package/dist/agent-src/skills/accessibility-auditor/SKILL.md +2 -0
- package/dist/agent-src/skills/ai-code-blindspots/SKILL.md +27 -1
- package/dist/agent-src/skills/code-intelligence/SKILL.md +60 -12
- package/dist/agent-src/skills/complexity-first-planning/SKILL.md +27 -0
- package/dist/agent-src/skills/design-review/SKILL.md +2 -0
- package/dist/agent-src/skills/feature-planning/SKILL.md +34 -0
- package/dist/agent-src/skills/git-workflow/SKILL.md +37 -12
- package/dist/agent-src/skills/git-workflow/references/push-closes-its-loop.md +176 -0
- package/dist/agent-src/skills/laravel-mail/SKILL.md +44 -0
- package/dist/agent-src/skills/testing-anti-patterns/SKILL.md +40 -0
- package/dist/agent-src/skills/using-git-worktrees/SKILL.md +12 -0
- package/dist/agent-src/skills/verify-repair-loop/SKILL.md +53 -8
- package/dist/agent-src/skills/worktree-lifecycle/SKILL.md +3 -1
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli-delegate/cmd_conformance.js +335 -659
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +15 -15
- 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 +959 -1304
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +671 -1002
- package/dist/shared/settingsConsent.js +1 -1
- package/package.json +1 -1
- package/src/config/conformance-claim-baseline.json +9 -0
- package/src/config/gate-violation-baselines.json +3 -3
- package/src/config/release-gate-locality.yml +129 -0
- package/src/scripts/_lib/release_highlights.ts +172 -1
- package/src/scripts/_lib/release_material.ts +66 -0
- package/src/scripts/_lib/repeated_run.ts +77 -0
- package/src/scripts/_lib/tamper_vocabulary.ts +242 -0
- package/src/scripts/_lib/unified_diff.ts +121 -0
- package/src/scripts/check_finding_dispositions.ts +179 -3
- package/src/scripts/check_installed_hooks_fresh.ts +381 -0
- package/src/scripts/check_release_highlights.ts +25 -13
- package/src/scripts/check_suppression_hygiene.ts +11 -0
- package/src/scripts/code_graph/build.ts +230 -33
- package/src/scripts/code_graph/extract.ts +168 -33
- package/src/scripts/code_graph/types.ts +37 -2
- package/src/scripts/code_graph/validate.ts +12 -1
- package/src/scripts/conformance_scan.ts +2 -2
- package/src/scripts/detect_silent_catch.ts +403 -0
- package/src/scripts/detect_verification_tampering.ts +538 -0
- package/src/scripts/git_authorization_hook.ts +153 -17
- package/src/scripts/hook_manifest.json +1 -1
- package/src/scripts/hook_manifest.yaml +33 -19
- package/src/scripts/hooks/block_no_verify.ts +3 -3
- package/src/scripts/hooks/concern_registry.ts +2 -2
- package/src/scripts/hooks/evidence_independence.ts +21 -2
- package/src/scripts/hooks/{block_unauthorized_git.ts → git_command_classifier.ts} +25 -369
- package/src/scripts/hooks/push_settle_hook.ts +250 -0
- package/src/scripts/hooks/ship_diff_volume_hook.ts +1 -1
- package/src/scripts/install-hooks.sh +134 -1
- package/src/scripts/language_mirror_hook.ts +27 -5
- package/src/scripts/release.ts +31 -143
- package/src/scripts/release_drill.ts +71 -1
- package/src/scripts/release_publication.ts +243 -3
- package/src/scripts/release_verify.ts +220 -0
- package/src/scripts/schemas/review-findings.schema.json +1 -1
- package/src/scripts/schemas/skill.schema.json +11 -1
- package/src/scripts/self_review_gate.ts +10 -1
- package/src/shared/settingsConsent.ts +1 -1
|
@@ -1,11 +1,28 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
1
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* `
|
|
7
|
-
*
|
|
8
|
-
*
|
|
2
|
+
* Git / GitHub / npm command classifier — a pure library, not a gate.
|
|
3
|
+
*
|
|
4
|
+
* WHAT THIS IS NOT, AND WHY. Until 2026-09-04 this file was the
|
|
5
|
+
* `block-unauthorized-git` `pre_tool_use` concern: it read the authorization
|
|
6
|
+
* ledger and REFUSED an operation the turn had not authorized. The owner
|
|
7
|
+
* removed that enforcement (ADR-254) after it was measured to refuse
|
|
8
|
+
* authorizations the owner had in fact given — a guardrail sentence inside an
|
|
9
|
+
* authorization ("never merge empty PRs") read as a withdrawal and erased the
|
|
10
|
+
* whole ledger, and an answer given as an option number ("1") matched no
|
|
11
|
+
* authorizing pattern at all.
|
|
12
|
+
*
|
|
13
|
+
* What survives is the half that never decided anything: the command parser and
|
|
14
|
+
* the operation vocabulary. Two callers depend on it and neither gates git —
|
|
15
|
+
* `block_no_verify.ts` needs `substitutionPayloads` so `git $(echo --no-verify)`
|
|
16
|
+
* cannot slip past the hook-bypass guard, and `conformance_scan.ts` needs
|
|
17
|
+
* `commandOp` / `BLOCK_OPS` to MEASURE how often an irreversible operation ran
|
|
18
|
+
* on an unauthorized turn. Measurement is all that is left here.
|
|
19
|
+
*
|
|
20
|
+
* `BLOCK_OPS` and `WARN_OPS` keep their names because they name the
|
|
21
|
+
* reversibility split `non-destructive-by-default` draws, and that split is
|
|
22
|
+
* still true. They no longer name an action this file takes.
|
|
23
|
+
*
|
|
24
|
+
* SEVERITY SPLIT — by reversibility, not by confidence. Retained below because
|
|
25
|
+
* it is what the two sets still mean.
|
|
9
26
|
*
|
|
10
27
|
* SEVERITY SPLIT — by reversibility, not by confidence.
|
|
11
28
|
*
|
|
@@ -56,33 +73,7 @@
|
|
|
56
73
|
* substitution in every position tried, `sh -c` and `eval` payloads, env
|
|
57
74
|
* assignment prefixes, and quotes inside the command word.
|
|
58
75
|
*/
|
|
59
|
-
import
|
|
60
|
-
import path from "node:path";
|
|
61
|
-
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
62
|
-
|
|
63
|
-
import { readHookStdin } from "./hook_stdin.js";
|
|
64
|
-
import { analyseMergeImpact, describeImpact } from "./merge_impact.js";
|
|
65
|
-
import { atomic_write_json } from "./state_io.js";
|
|
66
|
-
import {
|
|
67
|
-
consumeGrantTarget,
|
|
68
|
-
ledgerFileFor,
|
|
69
|
-
pendingFileFor,
|
|
70
|
-
PR_NUMBER_MAX,
|
|
71
|
-
readGrants,
|
|
72
|
-
STATE_FILE,
|
|
73
|
-
type GitOp,
|
|
74
|
-
type MergeGrant,
|
|
75
|
-
} from "../git_authorization_hook.js";
|
|
76
|
-
|
|
77
|
-
const EXIT_ALLOW = 0;
|
|
78
|
-
// MUST equal dispatch_hook.EXIT_BLOCK. The dispatcher's internal ladder is
|
|
79
|
-
// 0 allow / 1 block / 2 warn — NOT the 2-means-block shape a PreToolUse guard
|
|
80
|
-
// reads naturally from Claude's own native contract. This constant was 2 when
|
|
81
|
-
// this gate first shipped, so the dispatcher reduced every refusal to a WARN
|
|
82
|
-
// and the gate emitted advisory context while the operation went through.
|
|
83
|
-
// Pinned against the dispatcher's export by
|
|
84
|
-
// tests/hooks/concern_block_exit_parity.test.ts.
|
|
85
|
-
const EXIT_BLOCK = 1;
|
|
76
|
+
import { PR_NUMBER_MAX, type GitOp } from "../git_authorization_hook.js";
|
|
86
77
|
|
|
87
78
|
type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
|
|
88
79
|
type JsonObject = { [k: string]: JsonValue };
|
|
@@ -648,338 +639,3 @@ function _boundedTarget(digits: string): number | null {
|
|
|
648
639
|
const n = Number.parseInt(digits, 10);
|
|
649
640
|
return Number.isSafeInteger(n) && n > 0 && n <= PR_NUMBER_MAX ? n : null;
|
|
650
641
|
}
|
|
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
|
-
|
|
680
|
-
/**
|
|
681
|
-
* A ledger older than this is not "this turn" under any reading.
|
|
682
|
-
*
|
|
683
|
-
* **Widening this constant for a long run is forbidden practice.** On
|
|
684
|
-
* 2026-08-21 it was sed-patched to twelve times this value — six hours —
|
|
685
|
-
* behind a marker promising a revert that never came, so an autonomous PR-drain could
|
|
686
|
-
* merge past the 30-minute bound. The widening was committed to the trunk and
|
|
687
|
-
* left there. It is a twelvefold expansion of the authorization lifetime on
|
|
688
|
-
* the guard that gates `pr-merge`, which is a `BLOCK_OPS` member precisely
|
|
689
|
-
* because it is irreversible.
|
|
690
|
-
*
|
|
691
|
-
* Neither the widened expression nor the marker text is written out here: the
|
|
692
|
-
* obvious regression check for either is a grep, and a comment reciting the
|
|
693
|
-
* literal makes that grep match the guard's own prose on a clean tree.
|
|
694
|
-
*
|
|
695
|
-
* The supported answer to "my run is longer than the window" is that the run
|
|
696
|
-
* STOPS and REPORTS at expiry and the user re-authorizes — never that the
|
|
697
|
-
* window grows. The agent never edits this constant, this file, or the built
|
|
698
|
-
* bundle; `dist/hooks/dispatch.js` is what actually executes, and a source
|
|
699
|
-
* edit without a rebuild is silently inert.
|
|
700
|
-
*
|
|
701
|
-
* **ADR-251 (2026-08-30) decided this question and KEPT the value.** An AI
|
|
702
|
-
* council converged 2/2 on a different authorization SHAPE rather than a wider
|
|
703
|
-
* window, against the measured pressure that 7 of the 10 most recent sessions
|
|
704
|
-
* of this package outlive a 30-minute window (median span 3.1 h) and that the
|
|
705
|
-
* constant has been hand-widened twice. `LEDGER_MAX_AGE_MS` is unchanged at 30
|
|
706
|
-
* minutes and raising it remains forbidden — that transition is owner-reserved,
|
|
707
|
-
* and this record did not take it.
|
|
708
|
-
*
|
|
709
|
-
* What the record DID change is the behaviour at expiry: the run PAUSES,
|
|
710
|
-
* reports the remaining work, and ASKS for re-authorization, instead of ending.
|
|
711
|
-
* The user's reply renews this ledger through the path that already exists —
|
|
712
|
-
* `git_authorization_hook.ts` rewrites the session's ledger with a fresh
|
|
713
|
-
* `detected_at` on every human-typed prompt (`:512-537`), so a mid-run reply
|
|
714
|
-
* carrying an authorization phrase resets the age this constant is compared
|
|
715
|
-
* against. No new machinery, and the signal stays agent-unforgeable.
|
|
716
|
-
*
|
|
717
|
-
* See `docs/decisions/ADR-251-authorization-window-shape-not-width.md`.
|
|
718
|
-
*/
|
|
719
|
-
export const LEDGER_MAX_AGE_MS = 30 * 60 * 1000;
|
|
720
|
-
|
|
721
|
-
/**
|
|
722
|
-
* Load the ledger, and refuse one that does not belong to THIS session and
|
|
723
|
-
* THIS turn.
|
|
724
|
-
*
|
|
725
|
-
* The header claims the gate answers "did the user authorize this THIS turn?".
|
|
726
|
-
* Before this check it answered "did anyone, in any session, ever authorize
|
|
727
|
-
* it?" — the file records `session_id` and `detected_at` and neither was read.
|
|
728
|
-
* Two concurrent worktrees share one state root, so session A typing
|
|
729
|
-
* `mach den release` authorized session B.
|
|
730
|
-
*/
|
|
731
|
-
function _loadLedger(
|
|
732
|
-
consumer_root: string,
|
|
733
|
-
session_id: string,
|
|
734
|
-
now: number,
|
|
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);
|
|
744
|
-
// This session's own ledger first; the flat legacy file only as a fallback,
|
|
745
|
-
// so a ledger written by an older build still counts. Reading the flat file
|
|
746
|
-
// FIRST was the defect: any concurrent session in the repo overwrites it, and
|
|
747
|
-
// the session check below then discarded a real authorization as foreign.
|
|
748
|
-
const candidates = [ledgerFileFor(session_id)];
|
|
749
|
-
if (!candidates.includes(STATE_FILE)) {
|
|
750
|
-
candidates.push(STATE_FILE);
|
|
751
|
-
}
|
|
752
|
-
for (const rel of candidates) {
|
|
753
|
-
const found = _readLedgerFile(path.join(consumer_root, rel), session_id, now);
|
|
754
|
-
if (found !== null) {
|
|
755
|
-
return { ...found, grants };
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
return { authorized: new Set<GitOp>(), present: false, grants };
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
/**
|
|
762
|
-
* Read one ledger file. `null` means "this file does not answer the question" —
|
|
763
|
-
* absent, malformed, foreign session, or expired — so the caller may try the
|
|
764
|
-
* next candidate. A parsed, in-session, fresh ledger answers definitively.
|
|
765
|
-
*/
|
|
766
|
-
function _readLedgerFile(
|
|
767
|
-
file: string,
|
|
768
|
-
session_id: string,
|
|
769
|
-
now: number,
|
|
770
|
-
): { authorized: Set<GitOp>; present: boolean } | null {
|
|
771
|
-
try {
|
|
772
|
-
const raw = fs.readFileSync(file, "utf8");
|
|
773
|
-
const decoded = JSON.parse(raw) as unknown;
|
|
774
|
-
if (_isObject(decoded) && Array.isArray(decoded["authorized"])) {
|
|
775
|
-
const ledgerSession = typeof decoded["session_id"] === "string" ? decoded["session_id"] : "";
|
|
776
|
-
// A ledger from a different session is another conversation's consent.
|
|
777
|
-
if (session_id && ledgerSession && ledgerSession !== session_id) {
|
|
778
|
-
return null;
|
|
779
|
-
}
|
|
780
|
-
const at = Date.parse(String(decoded["detected_at"] ?? ""));
|
|
781
|
-
if (Number.isFinite(at) && now - at > LEDGER_MAX_AGE_MS) {
|
|
782
|
-
return null;
|
|
783
|
-
}
|
|
784
|
-
return {
|
|
785
|
-
authorized: new Set((decoded["authorized"] as string[]).filter(Boolean) as GitOp[]),
|
|
786
|
-
present: true,
|
|
787
|
-
};
|
|
788
|
-
}
|
|
789
|
-
} catch {
|
|
790
|
-
/* fall through */
|
|
791
|
-
}
|
|
792
|
-
return null;
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
export interface Decision {
|
|
796
|
-
exit: number;
|
|
797
|
-
stdout: string;
|
|
798
|
-
stderr: string;
|
|
799
|
-
/**
|
|
800
|
-
* The op this decision refused, when it refused one.
|
|
801
|
-
*
|
|
802
|
-
* `decide` stays pure; `run` turns this into the pending record that lets the
|
|
803
|
-
* user's answer to the refusal count as authorization for that one op. See
|
|
804
|
-
* `git_authorization_hook.PENDING_FILE` for why that record exists at all.
|
|
805
|
-
*/
|
|
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;
|
|
816
|
-
}
|
|
817
|
-
|
|
818
|
-
export function decide(
|
|
819
|
-
command: string | null,
|
|
820
|
-
ledger: { authorized: Set<GitOp>; present: boolean; grants?: MergeGrant[] },
|
|
821
|
-
impact?: string,
|
|
822
|
-
): Decision {
|
|
823
|
-
if (command === null) {
|
|
824
|
-
return { exit: EXIT_ALLOW, stdout: "", stderr: "" };
|
|
825
|
-
}
|
|
826
|
-
const op = commandOp(command);
|
|
827
|
-
if (op === null || ledger.authorized.has(op)) {
|
|
828
|
-
return { exit: EXIT_ALLOW, stdout: "", stderr: "" };
|
|
829
|
-
}
|
|
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
|
-
|
|
843
|
-
const shown = command.length > 120 ? `${command.slice(0, 117)}…` : command;
|
|
844
|
-
|
|
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}` : "";
|
|
851
|
-
const reason =
|
|
852
|
-
`Blocked: \`${op}\` with no authorization in this turn's prompt. ` +
|
|
853
|
-
`This operation is irreversible and non-destructive-by-default already declares it ` +
|
|
854
|
-
`never-autonomous. Command: ${shown}.${scan} ` +
|
|
855
|
-
`Ask the user, in one numbered-options block, and run it only after they answer this turn.`;
|
|
856
|
-
return { exit: EXIT_BLOCK, stdout: "", stderr: `${reason}\n`, blockedOp: op };
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
if (WARN_OPS.has(op)) {
|
|
860
|
-
const reason =
|
|
861
|
-
`No \`${op}\` authorization found in this turn's prompt (commit-policy: a one-shot ` +
|
|
862
|
-
`authorization is spent on the operation it named). If the user did authorize it this ` +
|
|
863
|
-
`turn, proceed; otherwise stop and ask. Command: ${shown}`;
|
|
864
|
-
return {
|
|
865
|
-
exit: EXIT_ALLOW,
|
|
866
|
-
stdout: `${JSON.stringify({ decision: "warn", reason })}\n`,
|
|
867
|
-
stderr: "",
|
|
868
|
-
};
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
return { exit: EXIT_ALLOW, stdout: "", stderr: "" };
|
|
872
|
-
}
|
|
873
|
-
|
|
874
|
-
export function run(stdin_text: string, options: { consumer_root: string }): number {
|
|
875
|
-
let envelope: JsonObject = {};
|
|
876
|
-
if (stdin_text.trim()) {
|
|
877
|
-
try {
|
|
878
|
-
const decoded = JSON.parse(stdin_text) as unknown;
|
|
879
|
-
if (_isObject(decoded)) {
|
|
880
|
-
envelope = decoded;
|
|
881
|
-
}
|
|
882
|
-
} catch {
|
|
883
|
-
return EXIT_ALLOW;
|
|
884
|
-
}
|
|
885
|
-
}
|
|
886
|
-
const session_id = typeof envelope["session_id"] === "string" ? envelope["session_id"] : "";
|
|
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
|
-
}
|
|
923
|
-
if (decision.blockedOp) {
|
|
924
|
-
// Record WHAT was refused, so the user's answer to the question this
|
|
925
|
-
// refusal just told the agent to ask can authorize that one operation.
|
|
926
|
-
// Best-effort: a failed write degrades to the previous behaviour, where a
|
|
927
|
-
// numbered answer is unrecordable and the refusal simply repeats.
|
|
928
|
-
try {
|
|
929
|
-
atomic_write_json(path.join(options.consumer_root, pendingFileFor(session_id)), {
|
|
930
|
-
op: decision.blockedOp,
|
|
931
|
-
session_id,
|
|
932
|
-
refused_at: new Date().toISOString(),
|
|
933
|
-
});
|
|
934
|
-
} catch {
|
|
935
|
-
/* observability only — see above */
|
|
936
|
-
}
|
|
937
|
-
}
|
|
938
|
-
if (decision.stdout) {
|
|
939
|
-
process.stdout.write(decision.stdout);
|
|
940
|
-
}
|
|
941
|
-
if (decision.stderr) {
|
|
942
|
-
process.stderr.write(decision.stderr);
|
|
943
|
-
}
|
|
944
|
-
return decision.exit;
|
|
945
|
-
}
|
|
946
|
-
|
|
947
|
-
export function main(argv?: string[]): number {
|
|
948
|
-
const args = argv ?? process.argv.slice(2);
|
|
949
|
-
let consumer_root = process.cwd();
|
|
950
|
-
for (let i = 0; i < args.length; i += 1) {
|
|
951
|
-
const a = args[i];
|
|
952
|
-
if (a === "--project-dir" && args[i + 1] !== undefined) {
|
|
953
|
-
consumer_root = args[i + 1] as string;
|
|
954
|
-
i += 1;
|
|
955
|
-
} else if (a !== undefined && a.startsWith("--project-dir=")) {
|
|
956
|
-
consumer_root = a.slice("--project-dir=".length);
|
|
957
|
-
}
|
|
958
|
-
}
|
|
959
|
-
return run(readHookStdin(), { consumer_root });
|
|
960
|
-
}
|
|
961
|
-
|
|
962
|
-
declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
|
|
963
|
-
function _isCliEntry(): boolean {
|
|
964
|
-
if (typeof __AGENT_CONFIG_BUNDLE__ !== "undefined" && __AGENT_CONFIG_BUNDLE__) {
|
|
965
|
-
return false;
|
|
966
|
-
}
|
|
967
|
-
if (process.argv[1] === undefined) {
|
|
968
|
-
return false;
|
|
969
|
-
}
|
|
970
|
-
const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
|
|
971
|
-
if (import.meta.url === argvUrl) {
|
|
972
|
-
return true;
|
|
973
|
-
}
|
|
974
|
-
try {
|
|
975
|
-
const here = fs.realpathSync(fileURLToPath(import.meta.url));
|
|
976
|
-
const argv = fs.realpathSync(path.resolve(process.argv[1]));
|
|
977
|
-
return here === argv;
|
|
978
|
-
} catch {
|
|
979
|
-
return false;
|
|
980
|
-
}
|
|
981
|
-
}
|
|
982
|
-
|
|
983
|
-
if (_isCliEntry()) {
|
|
984
|
-
process.exit(main());
|
|
985
|
-
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* PostToolUse push-settle reminder — the second half of "a push closes its own
|
|
4
|
+
* loop", and the half no gate could reach before.
|
|
5
|
+
*
|
|
6
|
+
* WHY THIS EXISTS — measured, not hypothetical.
|
|
7
|
+
*
|
|
8
|
+
* The pre-push hook answers "is this push safe to make". Nothing answered "is
|
|
9
|
+
* this push finished", and the difference is what reaches the user. Over the 30
|
|
10
|
+
* sessions and 50 PRs preceding 2026-09-04:
|
|
11
|
+
*
|
|
12
|
+
* · 22 of 30 sessions ran `gh run view --log-failed` — i.e. a red CI job was
|
|
13
|
+
* routine, not exceptional.
|
|
14
|
+
* · 17 of the 36 PRs whose workflow runs were still readable carried at least
|
|
15
|
+
* one FAILED run (56 failed runs in total).
|
|
16
|
+
* · 20 of 50 PRs carried a follow-up `fix(ci|gates|budget)…` commit — 41 of
|
|
17
|
+
* them — every one a repair made after a push that had looked done.
|
|
18
|
+
*
|
|
19
|
+
* The push itself always succeeded. What failed was the ending: the agent
|
|
20
|
+
* pushed, reported the work as delivered, and the red arrived afterwards, where
|
|
21
|
+
* the user found it. `verify-before-complete` already forbids that, and it is
|
|
22
|
+
* model-carried at exactly the moment a turn feels finished — which is the
|
|
23
|
+
* moment a behavior rule is least likely to fire.
|
|
24
|
+
*
|
|
25
|
+
* So this hook fires where the fact is deterministic: right after a shell
|
|
26
|
+
* command that ACTUALLY ADVANCED A REMOTE REF. It reads git's own push report
|
|
27
|
+
* rather than the exit code or the command text — a wrapper that pushes
|
|
28
|
+
* (`task push-ready`, a deploy script, a `git` alias) advances a ref without
|
|
29
|
+
* the words `git push` appearing anywhere, and those are the pushes furthest
|
|
30
|
+
* from a human's eye. So:
|
|
31
|
+
*
|
|
32
|
+
* · `Everything up-to-date` → nothing was pushed, nothing to settle, silent.
|
|
33
|
+
* · `! [rejected]` → the push did not happen; the agent already has
|
|
34
|
+
* a visible failure and does not need a second.
|
|
35
|
+
* · `--delete` → no ref advanced; silent.
|
|
36
|
+
* · `--dry-run` → prints the same report as a real push, so this
|
|
37
|
+
* one is caught on the COMMAND, not the output.
|
|
38
|
+
*
|
|
39
|
+
* It resolves the PR number for the pushed branch itself (one `gh` call, best
|
|
40
|
+
* effort) so the reminder carries the literal next command rather than a flag —
|
|
41
|
+
* per `active-remediation` § "The ask carries fixes, not a flag". With no `gh`,
|
|
42
|
+
* no auth, or no open PR, it names the branch and the two-step instead, and
|
|
43
|
+
* never claims a PR number it did not read.
|
|
44
|
+
*
|
|
45
|
+
* Warn only, never blocks (fail_closed: false). A push that is genuinely WIP is
|
|
46
|
+
* a legitimate thing to leave unsettled; what is not legitimate is ENDING THE
|
|
47
|
+
* TURN on one silently.
|
|
48
|
+
*
|
|
49
|
+
* Exit codes (dispatcher contract): 0 allow · 2 warn (+ `{decision,reason,
|
|
50
|
+
* additional_context}` JSON on stdout).
|
|
51
|
+
*/
|
|
52
|
+
import fs from "node:fs";
|
|
53
|
+
import path from "node:path";
|
|
54
|
+
import { execFileSync } from "node:child_process";
|
|
55
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
56
|
+
import { readHookStdin } from "./hook_stdin.js";
|
|
57
|
+
|
|
58
|
+
const EXIT_ALLOW = 0;
|
|
59
|
+
const EXIT_WARN = 2;
|
|
60
|
+
|
|
61
|
+
type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
|
|
62
|
+
type JsonObject = { [k: string]: JsonValue };
|
|
63
|
+
|
|
64
|
+
// Tool names across platforms whose command field carries a shell command
|
|
65
|
+
// (kept in lock-step with pr_url_reminder_hook.COMMAND_TOOLS).
|
|
66
|
+
const COMMAND_TOOLS: ReadonlySet<string> = new Set([
|
|
67
|
+
"launch-process",
|
|
68
|
+
"launch_process",
|
|
69
|
+
"Bash",
|
|
70
|
+
"BashTool",
|
|
71
|
+
"run-process",
|
|
72
|
+
"runProcess",
|
|
73
|
+
"shell",
|
|
74
|
+
"execute_shell",
|
|
75
|
+
"RunShellCommand",
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A push git was told NOT to actually make. `git push --dry-run` prints the SAME
|
|
80
|
+
* ref-advance report as a real push, so the command is where the two are told
|
|
81
|
+
* apart — and it is the ONLY thing the command is consulted for.
|
|
82
|
+
*
|
|
83
|
+
* Deliberately not the other way round. The first version required the command
|
|
84
|
+
* to match `git push`, and its own near-miss test exposed the cost: a wrapper
|
|
85
|
+
* that pushes — `task push-ready`, a deploy script, a `git` alias — advances a
|
|
86
|
+
* remote ref without the words `git push` appearing anywhere, and the hook
|
|
87
|
+
* would have been silent on exactly the pushes furthest from a human's eye.
|
|
88
|
+
* A false negative here is the failure this whole change exists to remove, so
|
|
89
|
+
* the authoritative condition is git's OWN report and the command only subtracts.
|
|
90
|
+
*/
|
|
91
|
+
const DRY_RUN = /(?:^|\s)(?:--dry-run|-n)(?:\s|$)/;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Git's own report that a ref MOVED. Both shapes it prints:
|
|
95
|
+
* ` b75d7f7..a1b2c3d feat/x -> feat/x` (fast-forward / forced)
|
|
96
|
+
* ` * [new branch] feat/x -> feat/x` (first push)
|
|
97
|
+
* The `->` is the load-bearing token: `Everything up-to-date` has none, and a
|
|
98
|
+
* `! [rejected]` line carries one but is excluded before this runs.
|
|
99
|
+
*/
|
|
100
|
+
const REF_ADVANCED = /(?:\[new branch\]|\[new tag\]|[0-9a-f]{7,}\.\.[0-9a-f]{7,})\s+(\S+)\s+->\s+(\S+)/;
|
|
101
|
+
|
|
102
|
+
/** A push git refused. Its `->` must never be read as an advance. */
|
|
103
|
+
const REJECTED = /^\s*!\s*\[(?:rejected|remote rejected)\]/m;
|
|
104
|
+
|
|
105
|
+
function isObject(v: unknown): v is JsonObject {
|
|
106
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Unwrap the dispatcher envelope `{event, platform, payload: <native>}`.
|
|
111
|
+
* Reading `tool_name` off the top level is the defect that left
|
|
112
|
+
* `pr-url-reminder` silently never firing in production (measured 2026-08-06).
|
|
113
|
+
*/
|
|
114
|
+
function unwrap(envelope: JsonObject): JsonObject {
|
|
115
|
+
const inner = envelope["payload"];
|
|
116
|
+
return isObject(inner) ? inner : envelope;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function extractCommand(payload: JsonObject): string | null {
|
|
120
|
+
const toolRaw = payload["tool_name"] ?? payload["toolName"] ?? payload["tool"];
|
|
121
|
+
const tool = typeof toolRaw === "string" ? toolRaw : null;
|
|
122
|
+
if (tool === null || !COMMAND_TOOLS.has(tool)) return null;
|
|
123
|
+
const ti = payload["tool_input"];
|
|
124
|
+
if (isObject(ti)) {
|
|
125
|
+
for (const key of ["command", "cmd", "shell_command"]) {
|
|
126
|
+
const v = ti[key];
|
|
127
|
+
if (typeof v === "string" && v) return v;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
for (const key of ["command", "cmd"]) {
|
|
131
|
+
const v = payload[key];
|
|
132
|
+
if (typeof v === "string" && v) return v;
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function toolOutput(payload: JsonObject): string {
|
|
138
|
+
for (const key of ["tool_response", "tool_result", "toolResponse", "output", "result", "stdout"]) {
|
|
139
|
+
const v = payload[key];
|
|
140
|
+
if (typeof v === "string") return v;
|
|
141
|
+
if (isObject(v) || Array.isArray(v)) return JSON.stringify(v);
|
|
142
|
+
}
|
|
143
|
+
return JSON.stringify(payload);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The pushed branch, taken from the REMOTE side of git's `local -> remote`
|
|
148
|
+
* report and stripped of any `refs/heads/` prefix. The remote side is the one
|
|
149
|
+
* that names the branch CI will run against; on a `HEAD:feat/x` push the local
|
|
150
|
+
* side says `HEAD` and would resolve to nothing.
|
|
151
|
+
*/
|
|
152
|
+
export function pushedBranch(output: string): string | null {
|
|
153
|
+
if (REJECTED.test(output)) return null;
|
|
154
|
+
const m = REF_ADVANCED.exec(output);
|
|
155
|
+
if (m === null) return null;
|
|
156
|
+
const remote = m[2];
|
|
157
|
+
if (remote === undefined) return null;
|
|
158
|
+
return remote.replace(/^refs\/heads\//, "") || null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Best-effort PR number for a branch. Never throws, never guesses. */
|
|
162
|
+
export function resolvePr(
|
|
163
|
+
branch: string,
|
|
164
|
+
run: (cmd: string, args: string[]) => string = (cmd, args) =>
|
|
165
|
+
execFileSync(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 15_000 }),
|
|
166
|
+
): number | null {
|
|
167
|
+
try {
|
|
168
|
+
const out = run("gh", ["pr", "view", branch, "--json", "number", "-q", ".number"]).trim();
|
|
169
|
+
const n = Number.parseInt(out, 10);
|
|
170
|
+
return Number.isInteger(n) && n > 0 ? n : null;
|
|
171
|
+
} catch {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The reminder text. Split out so the test can read it without a `gh` on PATH. */
|
|
177
|
+
export function buildReminder(branch: string, pr: number | null): { reason: string; additional_context: string } {
|
|
178
|
+
const settle =
|
|
179
|
+
pr === null
|
|
180
|
+
? `gh pr view ${branch} --json number -q .number # then:\n` +
|
|
181
|
+
`./scripts-run src/scripts/ci_settle <that number>`
|
|
182
|
+
: `./scripts-run src/scripts/ci_settle ${pr}`;
|
|
183
|
+
const subject = pr === null ? `branch ${branch}` : `PR #${pr} (${branch})`;
|
|
184
|
+
const reason =
|
|
185
|
+
`Pushed ${subject} — the turn is not finished until its CI has settled green ` +
|
|
186
|
+
`(verify-before-complete). Run ci_settle before you report this as done.`;
|
|
187
|
+
const additional_context =
|
|
188
|
+
`You just advanced a remote ref: ${subject}.\n\n` +
|
|
189
|
+
`A push is not a delivery. Per verify-before-complete, a completion claim needs ` +
|
|
190
|
+
`fresh evidence, and the evidence for a push is the CI verdict — not the push's ` +
|
|
191
|
+
`own exit code.\n\n` +
|
|
192
|
+
`Before this turn ends:\n` +
|
|
193
|
+
` 1. Settle it:\n ${settle}\n` +
|
|
194
|
+
` Exit 0 = settled green · 1 = settled, something failed · 2 = NOT a verdict ` +
|
|
195
|
+
`(timed out or unreadable — never report 2 as green).\n` +
|
|
196
|
+
` 2. Red? Read only the failing part:\n` +
|
|
197
|
+
` gh run view --job <id> --log-failed | grep -E '×|FAIL|Error'\n` +
|
|
198
|
+
` then fix it and push again (fix-what-you-see: the author is irrelevant).\n` +
|
|
199
|
+
` 3. Still red after 3 attempts on the same target, or blocked on something ` +
|
|
200
|
+
`only the user can decide — stop and say so, with the three attempts named ` +
|
|
201
|
+
`(autonomous-execution N=3).\n\n` +
|
|
202
|
+
`Handing the user a red PR with its cause named and unfixed is not a delivery ` +
|
|
203
|
+
`either. If you are deliberately leaving this WIP, say that in the reply — ` +
|
|
204
|
+
`silence is what this hook exists to stop.`;
|
|
205
|
+
return { reason, additional_context };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function main(): number {
|
|
209
|
+
let envelope: JsonValue;
|
|
210
|
+
try {
|
|
211
|
+
const raw = readHookStdin();
|
|
212
|
+
envelope = raw.trim() ? (JSON.parse(raw) as JsonValue) : {};
|
|
213
|
+
} catch {
|
|
214
|
+
return EXIT_ALLOW; // never block on a malformed envelope
|
|
215
|
+
}
|
|
216
|
+
if (!isObject(envelope)) return EXIT_ALLOW;
|
|
217
|
+
|
|
218
|
+
const inner = unwrap(envelope);
|
|
219
|
+
// A shell tool is still required: the report below is git's own text, and a
|
|
220
|
+
// structured forge tool does not produce it.
|
|
221
|
+
const command = extractCommand(inner);
|
|
222
|
+
if (command === null) return EXIT_ALLOW;
|
|
223
|
+
if (DRY_RUN.test(command)) return EXIT_ALLOW;
|
|
224
|
+
|
|
225
|
+
const branch = pushedBranch(toolOutput(inner));
|
|
226
|
+
if (branch === null) return EXIT_ALLOW; // up-to-date, rejected, delete — nothing advanced
|
|
227
|
+
|
|
228
|
+
const { reason, additional_context } = buildReminder(branch, resolvePr(branch));
|
|
229
|
+
process.stdout.write(`${JSON.stringify({ decision: "warn", reason, additional_context })}\n`);
|
|
230
|
+
return EXIT_WARN;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Bundle-safety: never auto-run when inlined into an esbuild bundle, where
|
|
234
|
+
// every module shares the bundle's `import.meta.url` (see cmd_migrate.ts).
|
|
235
|
+
declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
|
|
236
|
+
function isCliEntry(): boolean {
|
|
237
|
+
if (typeof __AGENT_CONFIG_BUNDLE__ !== "undefined" && __AGENT_CONFIG_BUNDLE__) return false;
|
|
238
|
+
if (process.argv[1] === undefined) return false;
|
|
239
|
+
const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
|
|
240
|
+
if (import.meta.url === argvUrl) return true;
|
|
241
|
+
try {
|
|
242
|
+
return fs.realpathSync(fileURLToPath(import.meta.url)) === fs.realpathSync(path.resolve(process.argv[1]));
|
|
243
|
+
} catch {
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (isCliEntry()) {
|
|
249
|
+
process.exit(main());
|
|
250
|
+
}
|
|
@@ -91,7 +91,7 @@ export function correctedVolume(numstat: string): { volume: number; excluded: nu
|
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
/**
|
|
94
|
-
* Tools that carry a shell command. Mirrors `
|
|
94
|
+
* Tools that carry a shell command. Mirrors `git_command_classifier`'s set.
|
|
95
95
|
*
|
|
96
96
|
* `SHIP_PATTERNS` are unanchored, so without this gate a `grep` for the string
|
|
97
97
|
* `git push` spawns two `git` subprocesses on a blocking slot. ALLOW-list: a
|