@event4u/agent-config 14.17.0 → 14.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +207 -77
  5. package/README.md +72 -150
  6. package/dist/agent-src/README.md +1 -1
  7. package/dist/agent-src/commands/agent-handoff.md +2 -2
  8. package/dist/agent-src/commands/pr/merge.md +35 -44
  9. package/dist/agent-src/commands/review/changes.md +19 -19
  10. package/dist/agent-src/commands/review.md +2 -2
  11. package/dist/agent-src/contexts/execution/host-capability-manifest.md +30 -3
  12. package/dist/agent-src/rules/autonomous-execution.md +1 -1
  13. package/dist/agent-src/rules/git-history-discipline.md +2 -2
  14. package/dist/agent-src/rules/guidelines.md +1 -1
  15. package/dist/agent-src/rules/session-canary.md +2 -2
  16. package/dist/agent-src/rules/token-efficiency.md +1 -1
  17. package/dist/agent-src/skills/accessibility-auditor/SKILL.md +2 -0
  18. package/dist/agent-src/skills/adversarial-review/SKILL.md +0 -8
  19. package/dist/agent-src/skills/ai-code-blindspots/SKILL.md +27 -1
  20. package/dist/agent-src/skills/canvas-design/SKILL.md +1 -1
  21. package/dist/agent-src/skills/code-intelligence/SKILL.md +60 -12
  22. package/dist/agent-src/skills/complexity-first-planning/SKILL.md +27 -0
  23. package/dist/agent-src/skills/design-intelligence/SKILL.md +1 -1
  24. package/dist/agent-src/skills/design-intelligence/data/app-interface.csv +1 -1
  25. package/dist/agent-src/skills/design-intelligence/data/motion.csv +17 -17
  26. package/dist/agent-src/skills/design-intelligence/data/ux-guidelines.csv +2 -2
  27. package/dist/agent-src/skills/design-intelligence/references/context-and-registers.md +17 -0
  28. package/dist/agent-src/skills/design-intelligence/references/design-rules-checklist.md +4 -4
  29. package/dist/agent-src/skills/design-review/SKILL.md +3 -1
  30. package/dist/agent-src/skills/design-variations/SKILL.md +1 -1
  31. package/dist/agent-src/skills/existing-ui-audit/SKILL.md +1 -1
  32. package/dist/agent-src/skills/fe-design/SKILL.md +32 -5
  33. package/dist/agent-src/skills/fe-design/references/craft-floor.md +7 -0
  34. package/dist/agent-src/skills/fe-design/references/design-patterns.md +47 -2
  35. package/dist/agent-src/skills/feature-planning/SKILL.md +34 -0
  36. package/dist/agent-src/skills/git-workflow/SKILL.md +37 -12
  37. package/dist/agent-src/skills/git-workflow/references/push-closes-its-loop.md +176 -0
  38. package/dist/agent-src/skills/laravel-mail/SKILL.md +44 -0
  39. package/dist/agent-src/skills/laravel-scheduling/SKILL.md +3 -1
  40. package/dist/agent-src/skills/project-analyzer/SKILL.md +0 -6
  41. package/dist/agent-src/skills/react-native-setup/SKILL.md +3 -3
  42. package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +1 -1
  43. package/dist/agent-src/skills/sequential-thinking/SKILL.md +18 -8
  44. package/dist/agent-src/skills/tailwind-engineer/SKILL.md +1 -1
  45. package/dist/agent-src/skills/testing-anti-patterns/SKILL.md +40 -0
  46. package/dist/agent-src/skills/ui-apply-generic/SKILL.md +1 -1
  47. package/dist/agent-src/skills/ui-component-architect/SKILL.md +1 -1
  48. package/dist/agent-src/skills/using-git-worktrees/SKILL.md +29 -2
  49. package/dist/agent-src/skills/verify-repair-loop/SKILL.md +53 -8
  50. package/dist/agent-src/skills/worktree-lifecycle/SKILL.md +3 -1
  51. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  52. package/dist/cli/commands/recordTriggerEval.js +2 -2
  53. package/dist/cli/main.js +1 -1
  54. package/dist/cli-delegate/{chunk-AKJGUQNO.js → chunk-GZTMRYCN.js} +405 -341
  55. package/dist/cli-delegate/{chunk-7RXIKF62.js → chunk-IDHUQIXP.js} +283 -132
  56. package/dist/cli-delegate/cmd_adr_effective.js +318 -81
  57. package/dist/cli-delegate/cmd_conformance.js +749 -669
  58. package/dist/cli-delegate/cmd_doctor.js +1 -1
  59. package/dist/cli-delegate/cmd_export.js +1 -1
  60. package/dist/cli-delegate/cmd_handoff.js +1 -1
  61. package/dist/cli-delegate/cmd_prune.js +1 -1
  62. package/dist/cli-delegate/cmd_route_audit.js +1 -1
  63. package/dist/cli-delegate/cmd_settings_migrate.js +1 -1
  64. package/dist/cli-delegate/cmd_sync.js +1 -1
  65. package/dist/cli-delegate/cmd_uninstall.js +1 -1
  66. package/dist/cli-delegate/cmd_validate.js +1 -1
  67. package/dist/discovery/deprecation-report.md +1 -1
  68. package/dist/discovery/discovery-manifest.json +39 -38
  69. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  70. package/dist/discovery/discovery-manifest.summary.md +1 -1
  71. package/dist/discovery/dormancy-report.md +1 -1
  72. package/dist/discovery/orphan-report.md +1 -1
  73. package/dist/discovery/packs.json +2 -2
  74. package/dist/discovery/trust-report.md +1 -1
  75. package/dist/discovery/workspaces.json +2 -2
  76. package/dist/hooks/dispatch.js +2764 -2439
  77. package/dist/install/install.mjs +588 -527
  78. package/dist/mcp/registry-manifest.json +2 -2
  79. package/dist/mcp/server.json +2 -2
  80. package/dist/mcp/server.mjs +1381 -1138
  81. package/dist/scripts/_lib/model_tier.js +1 -1
  82. package/dist/shared/settingsConsent.js +1 -1
  83. package/docs/guidelines/agent-infra/evaluator-independence-mechanics.md +1 -1
  84. package/docs/guidelines/agent-infra/roadmap-progress-mechanics.md +1 -1
  85. package/docs/guidelines/design-antipatterns.md +87 -64
  86. package/package.json +1 -1
  87. package/src/agent-src/README.md +1 -1
  88. package/src/config/ci-local-parity.yml +6 -0
  89. package/src/config/conformance-claim-baseline.json +9 -0
  90. package/src/config/gate-coverage.yml +170 -0
  91. package/src/config/gate-violation-baselines.json +4 -4
  92. package/src/config/release-gate-locality.yml +141 -0
  93. package/src/scripts/_cli/cmd_doctor.ts +2 -2
  94. package/src/scripts/_cli/cmd_handoff.ts +1 -1
  95. package/src/scripts/_cli/cmd_update.ts +1 -1
  96. package/src/scripts/_cli/cmd_versions.ts +1 -1
  97. package/src/scripts/_lib/adr_frontmatter.ts +44 -0
  98. package/src/scripts/_lib/consequence_objects.ts +152 -0
  99. package/src/scripts/_lib/exec_evidence.ts +7 -0
  100. package/src/scripts/_lib/global_deploy_inventory.ts +2 -2
  101. package/src/scripts/_lib/host_permission_checks.ts +251 -0
  102. package/src/scripts/_lib/model_tier.ts +1 -1
  103. package/src/scripts/_lib/obligation_frequency.ts +2 -2
  104. package/src/scripts/_lib/release_highlights.ts +265 -1
  105. package/src/scripts/_lib/release_material.ts +129 -0
  106. package/src/scripts/_lib/repeated_run.ts +77 -0
  107. package/src/scripts/_lib/runtime_journal.ts +26 -3
  108. package/src/scripts/_lib/runtime_wiring_checks.ts +12 -1
  109. package/src/scripts/_lib/session_register.ts +11 -6
  110. package/src/scripts/_lib/sqlite_schema.ts +22 -0
  111. package/src/scripts/_lib/surface.ts +86 -0
  112. package/src/scripts/_lib/tamper_vocabulary.ts +242 -0
  113. package/src/scripts/_lib/ui_authority.ts +116 -0
  114. package/src/scripts/_lib/unified_diff.ts +121 -0
  115. package/src/scripts/adr/regenerate_index.ts +54 -1
  116. package/src/scripts/adr_cite_check.ts +71 -15
  117. package/src/scripts/ai_council/clients.ts +1 -1
  118. package/src/scripts/autonomy_friction_corpus.ts +229 -0
  119. package/src/scripts/build_proof.ts +15 -2
  120. package/src/scripts/check_beta_review_markers.ts +141 -17
  121. package/src/scripts/check_enforcement_coverage.ts +1 -1
  122. package/src/scripts/check_enforcement_denominator.ts +121 -11
  123. package/src/scripts/check_finding_dispositions.ts +239 -6
  124. package/src/scripts/check_gate_coverage.ts +96 -0
  125. package/src/scripts/check_installed_hooks_fresh.ts +381 -0
  126. package/src/scripts/check_release_highlights.ts +99 -14
  127. package/src/scripts/check_skill_activation_claim.ts +179 -0
  128. package/src/scripts/check_suppression_hygiene.ts +11 -0
  129. package/src/scripts/check_trigger_evals.ts +1 -1
  130. package/src/scripts/code_graph/build.ts +230 -33
  131. package/src/scripts/code_graph/extract.ts +168 -33
  132. package/src/scripts/code_graph/types.ts +37 -2
  133. package/src/scripts/code_graph/validate.ts +12 -1
  134. package/src/scripts/conformance_scan.ts +45 -4
  135. package/src/scripts/detect_silent_catch.ts +403 -0
  136. package/src/scripts/detect_verification_tampering.ts +538 -0
  137. package/src/scripts/explain_run.ts +1 -1
  138. package/src/scripts/generate_capability_matrix.ts +1 -1
  139. package/src/scripts/git_authorization_hook.ts +228 -18
  140. package/src/scripts/hook_manifest.json +1 -1
  141. package/src/scripts/hook_manifest.yaml +47 -30
  142. package/src/scripts/hooks/augment-dispatcher.sh +1 -1
  143. package/src/scripts/hooks/block_no_verify.ts +3 -3
  144. package/src/scripts/hooks/category_a.ts +291 -0
  145. package/src/scripts/hooks/cline-dispatcher.sh +1 -1
  146. package/src/scripts/hooks/concern_registry.ts +2 -2
  147. package/src/scripts/hooks/cursor-dispatcher.sh +11 -5
  148. package/src/scripts/hooks/dispatch_hook.ts +17 -22
  149. package/src/scripts/hooks/evidence_independence.ts +21 -2
  150. package/src/scripts/hooks/{block_unauthorized_git.ts → git_command_classifier.ts} +175 -364
  151. package/src/scripts/hooks/host_lowering.ts +215 -0
  152. package/src/scripts/hooks/host_lowering.yaml +185 -0
  153. package/src/scripts/hooks/host_semantics.ts +105 -37
  154. package/src/scripts/hooks/journal_record_hook.ts +10 -0
  155. package/src/scripts/hooks/permission_gate.ts +117 -0
  156. package/src/scripts/hooks/push_settle_hook.ts +250 -0
  157. package/src/scripts/hooks/ship_diff_volume_hook.ts +1 -1
  158. package/src/scripts/hooks_status.ts +12 -10
  159. package/src/scripts/install-hooks.sh +134 -1
  160. package/src/scripts/install.ts +53 -82
  161. package/src/scripts/install_anthropic_key.sh +2 -2
  162. package/src/scripts/language_mirror_hook.ts +27 -5
  163. package/src/scripts/lint_design_antipattern_parity.ts +80 -0
  164. package/src/scripts/lint_evidence_artifacts.ts +45 -1
  165. package/src/scripts/lint_hook_manifest.ts +87 -3
  166. package/src/scripts/lint_motion_authority_drift.ts +731 -0
  167. package/src/scripts/lint_readme_size.ts +158 -12
  168. package/src/scripts/lint_reasoning_fixtures.ts +216 -0
  169. package/src/scripts/lint_skill_trigger_contradiction.ts +161 -0
  170. package/src/scripts/measure_sibling_naming.ts +150 -0
  171. package/src/scripts/redact_hook_capture.ts +1 -1
  172. package/src/scripts/release.ts +31 -143
  173. package/src/scripts/release_drill.ts +71 -1
  174. package/src/scripts/release_publication.ts +243 -3
  175. package/src/scripts/release_verify.ts +220 -0
  176. package/src/scripts/report_skill_activation.ts +319 -7
  177. package/src/scripts/schemas/review-findings.schema.json +6 -1
  178. package/src/scripts/schemas/skill.schema.json +12 -2
  179. package/src/scripts/self_review_gate.ts +10 -1
  180. package/src/scripts/session_canary_hook.ts +1 -1
  181. package/src/scripts/session_register_hook.ts +4 -3
  182. package/src/scripts/trigger_eval_grandfather.json +5 -1
  183. package/src/scripts/validate_agent_settings.ts +1 -1
  184. package/src/shared/settingsConsent.ts +1 -1
@@ -0,0 +1,117 @@
1
+ /**
2
+ * permission_gate — whether a `pre_tool_use` call gets an explicit
3
+ * `permissionDecision`, and which one.
4
+ *
5
+ * Four independent conditions, each able to withhold an allow on its own: the
6
+ * host offers the field on `pre_tool_use` only; the dispatcher's reduced
7
+ * severity must already be `allow`; no concern may have voted `ask` or `deny`;
8
+ * and the call itself must be category A. An allow is emitted only when all
9
+ * four hold, so widening any one of them is a visible edit rather than an
10
+ * emergent effect.
11
+ *
12
+ * Separate from `dispatch_hook` because that file is against the per-file
13
+ * source-size budget, and because the seam is real: the dispatcher runs
14
+ * concerns and reduces exit codes, this module reads the result and decides
15
+ * what the host is told about permission.
16
+ */
17
+ import { isCategoryA, type ToolInput } from "./category_a.js";
18
+ import {
19
+ composePermissionDecision,
20
+ type PermissionDecision,
21
+ type PermissionEmission,
22
+ } from "./host_semantics.js";
23
+
24
+ type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
25
+ type JsonObject = { [k: string]: JsonValue };
26
+
27
+ function _isObject(v: unknown): v is JsonObject {
28
+ return typeof v === "object" && v !== null && !Array.isArray(v);
29
+ }
30
+
31
+ /** Mirrors `dispatch_hook.EXIT_ALLOW` / `EXIT_BLOCK`; imported would be a cycle. */
32
+ const EXIT_ALLOW = 0;
33
+ const EXIT_BLOCK = 1;
34
+
35
+ /** The host's `tool_name`, or `""` when the event carries none. */
36
+ export function _payload_tool_name(envelope: JsonObject): string {
37
+ const payload = envelope["payload"];
38
+ if (!_isObject(payload)) return "";
39
+ const raw = payload["tool_name"];
40
+ return typeof raw === "string" ? raw : "";
41
+ }
42
+
43
+ /** The host's `tool_input` object, or `{}` when the event carries none. */
44
+ export function _payload_tool_input(envelope: JsonObject): ToolInput {
45
+ const payload = envelope["payload"];
46
+ if (!_isObject(payload)) return {};
47
+ const raw = payload["tool_input"];
48
+ return _isObject(raw) ? (raw as ToolInput) : {};
49
+ }
50
+
51
+ /**
52
+ * The root every category-A path argument must resolve inside.
53
+ *
54
+ * The host's own `cwd` wins over `workspace_root` because the latter is the
55
+ * dispatcher's process cwd, which in a worktree is the parent checkout rather
56
+ * than the tree the user is working in — confining against it would admit a
57
+ * path outside the tree the call actually runs in.
58
+ */
59
+ export function _working_tree_root(envelope: JsonObject): string {
60
+ const payload = envelope["payload"];
61
+ if (_isObject(payload) && typeof payload["cwd"] === "string" && payload["cwd"].trim()) {
62
+ return payload["cwd"];
63
+ }
64
+ const workspace = envelope["workspace_root"];
65
+ return typeof workspace === "string" ? workspace : "";
66
+ }
67
+
68
+ /**
69
+ * One concern's permission verdict.
70
+ *
71
+ * A block is a deny. A concern may also say `{"decision":"ask"}` on stdout —
72
+ * the field has been recorded in the feedback file since v1 and read by
73
+ * nothing, so an advisory concern that wanted a confirmation got silence.
74
+ * `ask` is honoured regardless of the exit code: an advisory concern cannot
75
+ * block and must not have to in order to be heard. Everything else abstains,
76
+ * which composes as `allow`.
77
+ */
78
+ export function _concern_permission_verdict(
79
+ rc: number,
80
+ reply: JsonObject,
81
+ ): PermissionDecision {
82
+ if (rc === EXIT_BLOCK) return "deny";
83
+ const stated = String(reply["decision"] ?? "").trim().toLowerCase();
84
+ if (stated === "ask") return "ask";
85
+ if (stated === "deny") return "deny";
86
+ return "allow";
87
+ }
88
+
89
+ /**
90
+ * The permission field to hand the host, or `null` to keep the legacy envelope.
91
+ *
92
+ * Four independent conditions, each able to withhold the allow on its own:
93
+ * the host offers the field on `pre_tool_use` only; the reduced severity must
94
+ * already be `allow`; no concern may have voted `ask` or `deny`; and the call
95
+ * itself must be category A. An allow is emitted only when all four hold, so
96
+ * widening any one of them is a visible edit rather than an emergent effect.
97
+ */
98
+ export function _permission_for(
99
+ event: string,
100
+ envelope: JsonObject,
101
+ final_rc: number,
102
+ verdicts: readonly PermissionDecision[],
103
+ ): PermissionEmission | null {
104
+ if (event !== "pre_tool_use") return null;
105
+ if (final_rc !== EXIT_ALLOW) return null;
106
+ if (composePermissionDecision(verdicts) !== "allow") return null;
107
+ const tool_name = _payload_tool_name(envelope);
108
+ if (!isCategoryA(tool_name, _payload_tool_input(envelope), _working_tree_root(envelope))) {
109
+ return null;
110
+ }
111
+ return {
112
+ decision: "allow",
113
+ reason:
114
+ `agent-config: category-A ${tool_name} inside the working tree — ` +
115
+ `no concern gates it and none asked to.`,
116
+ };
117
+ }
@@ -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 `block_unauthorized_git`'s set.
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
@@ -45,7 +45,9 @@ const MANIFEST_PATH = path.join(
45
45
  "hook_manifest.yaml",
46
46
  );
47
47
 
48
- // (label, project-relative bridge path, install hint).
48
+ // (project-relative bridge path, one-line note shown when the bridge is not
49
+ // live). The note is printed verbatim, so a note that is not a command must
50
+ // not read as one.
49
51
  // Path may be a directory (cline) — existence => any file inside.
50
52
  //
51
53
  // Cowork has no project-scope bridge path: the Claude desktop app's
@@ -58,17 +60,17 @@ const MANIFEST_PATH = path.join(
58
60
  // Once upstream lands the fix and a stable settings location is
59
61
  // documented, swap the empty path here for that location.
60
62
  export const PLATFORM_BRIDGES: Record<string, [string, string]> = {
61
- augment: [".augment/settings.json", "src/scripts/install.py"],
62
- claude: [".claude/settings.json", "src/scripts/install.py"],
63
+ augment: [".augment/settings.json", "run `agent-config install`"],
64
+ claude: [".claude/settings.json", "run `agent-config install`"],
63
65
  cowork: [
64
66
  "",
65
67
  "upstream-blocked: anthropics/claude-code#40495 + #27398 (settings.json ignored in Cowork sandbox)",
66
68
  ],
67
- cursor: [".cursor/hooks.json", "src/scripts/install.py"],
68
- cline: [".clinerules/hooks", "src/scripts/install.py"],
69
- windsurf: [".windsurf/hooks.json", "src/scripts/install.py"],
70
- gemini: [".gemini/settings.json", "src/scripts/install.py"],
71
- copilot: ["", "rule-only fallback (no hook surface)"],
69
+ cursor: [".cursor/hooks.json", "run `agent-config install`"],
70
+ cline: [".clinerules/hooks", "run `agent-config install`"],
71
+ windsurf: [".windsurf/hooks.json", "run `agent-config install`"],
72
+ gemini: [".gemini/settings.json", "run `agent-config install`"],
73
+ copilot: ["", "rule-only fallback — this package binds no hook here"],
72
74
  };
73
75
 
74
76
  export interface PlatformRow {
@@ -180,11 +182,11 @@ export function _render_table(matrix: StatusMatrix): string {
180
182
  lines.push(` ${event.padEnd(22)} → ${concerns}`);
181
183
  }
182
184
  if (row.hint) {
183
- lines.push(` hint: run ${row.hint}`);
185
+ lines.push(` hint: ${row.hint}`);
184
186
  }
185
187
  }
186
188
  lines.push("");
187
- lines.push("Source of truth: scripts/hook_manifest.yaml");
189
+ lines.push("Source of truth: src/scripts/hook_manifest.yaml");
188
190
  lines.push("Contract: docs/contracts/hook-architecture-v1.md");
189
191
  return lines.join("\n");
190
192
  }
@@ -12,7 +12,25 @@ PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
12
12
  # (where $PROJECT_ROOT/.git is a file, not a dir, and a literal .git/hooks path
13
13
  # would break under `set -e`). --git-common-dir points at the main .git even from
14
14
  # a worktree; fall back to the literal path only outside a git repo.
15
- if COMMON_GIT_DIR="$(git -C "$PROJECT_ROOT" rev-parse --git-common-dir 2>/dev/null)"; then
15
+ #
16
+ # AGENT_CONFIG_HOOKS_DIR overrides the destination. It exists so that
17
+ # `check_installed_hooks_fresh` can render what this installer WOULD write into
18
+ # a scratch directory and byte-compare it against what is installed — the only
19
+ # way to answer "is the installed copy stale?" without re-parsing these
20
+ # heredocs, which carry an appended block (post-merge / post-checkout) that no
21
+ # heredoc slice can reproduce. It is a test/inspection seam; a normal install
22
+ # never sets it.
23
+ if [ -n "${AGENT_CONFIG_HOOKS_DIR+set}" ]; then
24
+ # SET-BUT-EMPTY is refused rather than ignored. A caller that meant to
25
+ # redirect an inspection run and passed an empty value would otherwise fall
26
+ # through to the real .git/hooks and INSTALL over it — including over a
27
+ # pre-push that is running the inspection, which truncates it silently.
28
+ if [ -z "$AGENT_CONFIG_HOOKS_DIR" ]; then
29
+ echo "❌ AGENT_CONFIG_HOOKS_DIR is set but empty — refusing to fall back to the real .git/hooks." >&2
30
+ exit 2
31
+ fi
32
+ HOOKS_DIR="$AGENT_CONFIG_HOOKS_DIR"
33
+ elif COMMON_GIT_DIR="$(git -C "$PROJECT_ROOT" rev-parse --git-common-dir 2>/dev/null)"; then
16
34
  case "$COMMON_GIT_DIR" in /*) : ;; *) COMMON_GIT_DIR="$PROJECT_ROOT/$COMMON_GIT_DIR" ;; esac
17
35
  HOOKS_DIR="$COMMON_GIT_DIR/hooks"
18
36
  else
@@ -20,6 +38,12 @@ else
20
38
  fi
21
39
 
22
40
  mkdir -p "$HOOKS_DIR"
41
+ # Name the destination. AGENT_CONFIG_HOOKS_DIR is a global override, and a
42
+ # stray non-empty value would otherwise redirect a real `task install-hooks`
43
+ # while it printed six "hook installed" lines that name no path at all — the
44
+ # freshness check would then report every hook missing with nothing pointing at
45
+ # the cause.
46
+ echo "→ installing git hooks into $HOOKS_DIR" >&2
23
47
 
24
48
  cat > "$HOOKS_DIR/pre-push" << 'EOF'
25
49
  #!/usr/bin/env bash
@@ -85,6 +109,96 @@ fi
85
109
 
86
110
  fail=0
87
111
 
112
+ # BASE FRESHNESS — first, and it EXITS rather than setting fail=1.
113
+ #
114
+ # The header above has promised "when this hook refuses for staleness" since the
115
+ # hook was written, and nothing below ever asked. Measured over the 50 PRs
116
+ # preceding 2026-09-04: 25 of them carried at least one `Merge branch 'main'
117
+ # into <branch>` commit (52 such commits in total), and 17 of the 36 PRs whose
118
+ # workflow runs were still readable had at least one FAILED run — the three
119
+ # workflows that failed most (Tests 18, Consistency 16, Rule Backstops 13) are
120
+ # exactly the base-relative ones: ratchets, budgets and generated counts that
121
+ # were green against the base the branch forked from and red against the base it
122
+ # merges into. A branch pushed behind its base buys a CONFLICTING PR, a red run,
123
+ # or both, and the contributor pays for it after the work looks finished.
124
+ #
125
+ # It runs FIRST because everything after it is invalidated by the merge that has
126
+ # to happen anyway: `task consistency` regenerates derived files against the
127
+ # stale base, the merge then re-dirties them, and the ~10s is spent twice. And
128
+ # it EXITS instead of accumulating into `fail`, for the same reason — there is
129
+ # no useful second finding to collect from a tree that is about to move.
130
+ #
131
+ # It never merges. `check_branch_freshness` asks the REMOTE (one `ls-remote`,
132
+ # measured 4.5s) and returns 1 only on a VERIFIED behind state; offline, in CI,
133
+ # on a detached HEAD, or standing on the base itself it returns 0 and says so.
134
+ # So an unreachable network cannot block a push, and a stale local tracking ref
135
+ # cannot fake a green.
136
+ echo "🔍 Base freshness — is this branch behind the base it will merge into?"
137
+ if [ "${AGENT_CONFIG_SKIP_PREPUSH_FRESHNESS:-}" = "1" ]; then
138
+ echo "⏭️ skipped via AGENT_CONFIG_SKIP_PREPUSH_FRESHNESS=1"
139
+ elif [ ! -x ./scripts-run ]; then
140
+ echo "⚠️ ./scripts-run not found — skipping the freshness check for this push."
141
+ elif ! ./scripts-run src/scripts/check_branch_freshness --quiet; then
142
+ echo ""
143
+ echo " Push blocked — the branch is behind its base, and the gates you just"
144
+ echo " passed were answered against a base that no longer exists."
145
+ echo ""
146
+ echo " task push-ready # fetch → integrate the base SET → regenerate"
147
+ echo " # → verify → re-check freshness, then push"
148
+ echo " task push-ready DRY=1 # the same steps, read-only"
149
+ echo ""
150
+ echo " This hook refuses; it never merges. Bypass a genuine WIP push with"
151
+ echo " AGENT_CONFIG_SKIP_PREPUSH_FRESHNESS=1."
152
+ exit 1
153
+ fi
154
+
155
+ # INSTALLED-HOOK FRESHNESS — ADVISORY, and deliberately AFTER base freshness.
156
+ #
157
+ # What it answers: are the hooks in .git/hooks still what THIS checkout's
158
+ # install-hooks.sh would write? Measured 2026-09-05: the installed pre-push here
159
+ # was 146 lines against a 189-line source body, missing the entire base-freshness
160
+ # gate above, merged five days earlier. It read as live in the source, in CI and
161
+ # in the skill documenting it, and did not run here.
162
+ #
163
+ # WHY IT DOES NOT REFUSE. The predicate is "installed == what this checkout
164
+ # renders", and .git/hooks is SHARED by every linked worktree through the common
165
+ # dir. With two worktrees whose installer bodies differ, at most one can satisfy
166
+ # it at a time and each re-install flips which — so a refusal would fire on
167
+ # ordinary parallel work, and the documented skip variable would become routine,
168
+ # which retires the gate rather than enforcing it. An AI council (claude-sonnet-4-5
169
+ # + codex-default, 2026-09-05, 2 of 2 seats) chose advisory unanimously after an
170
+ # independent review raised exactly that. Blocking returns only with a
171
+ # single-referent predicate — per-worktree hook isolation via core.hooksPath, or a
172
+ # branch-independent dispatcher installed once in the common dir.
173
+ #
174
+ # WHY AFTER BASE FRESHNESS. Being behind a base that moved the installer is the
175
+ # most common cause of a mismatch, and the repair for THAT is to merge, not to
176
+ # re-install: re-installing from a behind-base checkout writes the OLDER hook set
177
+ # over the shared directory. Base freshness exits first, so that contributor is
178
+ # told the correct thing and never reaches this notice.
179
+ echo "🔍 Installed-hook freshness — do .git/hooks still match this checkout?"
180
+ if [ "${AGENT_CONFIG_SKIP_PREPUSH_HOOKFRESH:-}" = "1" ]; then
181
+ echo "⏭️ skipped via AGENT_CONFIG_SKIP_PREPUSH_HOOKFRESH=1"
182
+ elif [ ! -x ./scripts-run ] || [ ! -d node_modules ] \
183
+ || [ ! -f src/scripts/check_installed_hooks_fresh.ts ]; then
184
+ # Three guards, and each has been a real miss. `-d node_modules`: without
185
+ # dependencies ./scripts-run cannot start tsx and the non-zero exit would
186
+ # read as staleness. `-f`: .git/hooks is shared, so this file also runs in a
187
+ # worktree standing on a branch that predates the gate, and calling a script
188
+ # that is not on that checkout would report the wrong thing.
189
+ echo "⏭️ check_installed_hooks_fresh not runnable on this checkout — skipping."
190
+ else
191
+ ./scripts-run src/scripts/check_installed_hooks_fresh --quiet
192
+ hookfresh_rc=$?
193
+ # The gate's three exit codes mean three different things, and collapsing
194
+ # them reports a false cause: 0 = match or nothing installed, 1 = mismatch,
195
+ # 2 = it could not render (installer failed, unwritable TMPDIR, usage error).
196
+ if [ "$hookfresh_rc" = "2" ]; then
197
+ echo "⚠️ could not compare the installed hooks (see above) — not a staleness verdict."
198
+ fi
199
+ # Advisory in both cases: nothing here sets fail=1 or exits.
200
+ fi
201
+
88
202
  if ! command -v task >/dev/null 2>&1; then
89
203
  echo "⚠️ 'task' (go-task) not found — cannot run the consistency gate locally."
90
204
  echo " Install it (https://taskfile.dev) so pre-push can mirror the CI check;"
@@ -381,6 +495,25 @@ if [ -n "\$prev" ] && [ -n "\$new" ] && [ "\$prev" != "\$new" ]; then
381
495
  if command -v npm >/dev/null 2>&1 && [ -d node_modules ]; then
382
496
  npm run build:cli >/dev/null 2>&1 || true
383
497
  fi
498
+ # Installed-hook staleness — REPORT it here, never repair it here.
499
+ #
500
+ # This is the event that CAUSES the staleness, so it is where the news
501
+ # belongs. It is NOT where the repair belongs: linked worktrees SHARE
502
+ # .git/hooks through the common dir, so re-running the installer from a
503
+ # checkout would let one worktree silently redefine the gates every
504
+ # other worktree runs, and an older branch would reinstall older hooks
505
+ # over newer ones. An AI council (claude-sonnet-4-5 + codex-default,
506
+ # 2026-09-05, 2 of 2 seats) refused that mutation unanimously.
507
+ #
508
+ # Output goes to stderr and is NOT swallowed: the rest of this block is
509
+ # "|| true" so a pull is never blocked, and a repair notice nobody can
510
+ # see is the same silence this check exists to remove.
511
+ # The -f guard is not belt-and-braces: this hook is shared across
512
+ # linked worktrees, so it also runs on checkouts that predate the gate.
513
+ if [ -x ./scripts-run ] && [ -d node_modules ] \
514
+ && [ -f src/scripts/check_installed_hooks_fresh.ts ]; then
515
+ ./scripts-run src/scripts/check_installed_hooks_fresh --quiet >&2 || true
516
+ fi
384
517
  fi
385
518
  fi
386
519
  EOF