@event4u/agent-config 9.23.0 → 9.25.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 (52) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +82 -0
  3. package/README.md +2 -2
  4. package/dist/agent-src/commands/agent-handoff.md +10 -3
  5. package/dist/agent-src/commands/analyze/conformance.md +182 -0
  6. package/dist/agent-src/commands/analyze/inbox.md +221 -0
  7. package/dist/agent-src/commands/analyze.md +4 -3
  8. package/dist/agent-src/commands/pr/create.md +6 -6
  9. package/dist/agent-src/rules/context-hygiene.md +3 -0
  10. package/dist/agent-src/rules/decision-revisit-gate.md +2 -0
  11. package/dist/agent-src/rules/evaluator-independence.md +105 -0
  12. package/dist/agent-src/rules/session-canary.md +13 -1
  13. package/dist/agent-src/rules/user-interaction.md +22 -0
  14. package/dist/agent-src/skills/finishing-a-development-branch/SKILL.md +1 -1
  15. package/dist/agent-src/skills/rule-writing/SKILL.md +45 -0
  16. package/dist/agent-src/skills/skill-writing/SKILL.md +114 -0
  17. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  18. package/dist/cli/registry.js +1 -0
  19. package/dist/cli/registry.js.map +1 -1
  20. package/dist/discovery/deprecation-report.md +1 -1
  21. package/dist/discovery/discovery-manifest.json +96 -22
  22. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  23. package/dist/discovery/discovery-manifest.summary.md +4 -4
  24. package/dist/discovery/orphan-report.md +1 -1
  25. package/dist/discovery/packs.json +11 -8
  26. package/dist/discovery/trust-report.md +3 -3
  27. package/dist/discovery/workspaces.json +9 -6
  28. package/dist/hooks/dispatch.js +1026 -328
  29. package/dist/mcp/registry-manifest.json +2 -2
  30. package/dist/mcp/server.json +2 -2
  31. package/dist/router.json +1 -1
  32. package/package.json +1 -1
  33. package/src/config/evaluator-budgets.json +68 -68
  34. package/src/config/gate-coverage.yml +103 -5
  35. package/src/config/gate-violation-baselines.json +11 -6
  36. package/src/scripts/_dispatch.bash +12 -0
  37. package/src/scripts/before_complete_hook.ts +127 -5
  38. package/src/scripts/check_depth_budget.ts +295 -0
  39. package/src/scripts/conformance_scan.ts +392 -0
  40. package/src/scripts/git_authorization_hook.ts +263 -0
  41. package/src/scripts/hook_manifest.yaml +50 -9
  42. package/src/scripts/hooks/block_unauthorized_git.ts +254 -0
  43. package/src/scripts/hooks/concern_registry.ts +8 -0
  44. package/src/scripts/hooks/evidence_independence.ts +334 -0
  45. package/src/scripts/language_mirror_hook.ts +267 -0
  46. package/src/scripts/lint_decision_impact.ts +246 -0
  47. package/src/scripts/lint_example_fences.ts +609 -0
  48. package/src/scripts/lint_rule_interactions.ts +90 -0
  49. package/src/scripts/lint_rule_migration_ledger.ts +480 -0
  50. package/src/scripts/lint_skill_descriptions.ts +86 -2
  51. package/src/scripts/measure_rule_absoluta.ts +231 -0
  52. package/src/scripts/schemas/rule.schema.json +10 -0
@@ -0,0 +1,263 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Git-authorization ledger — `user_prompt_submit` concern.
4
+ *
5
+ * Records which git operations the user's OWN WORDS authorize this turn, so
6
+ * `hooks/block_unauthorized_git.ts` can check an operation against a fact
7
+ * instead of against the model's recollection of the conversation.
8
+ *
9
+ * WHY — the measured defect (30-session conformance audit, 2026-08-06):
10
+ *
11
+ * - A full release chain (prod-trunk merge, tag push, GitHub release, npm
12
+ * publish) executed after the agent itself wrote "das ist ein Hard-Floor-
13
+ * Schritt, dafür brauche ich Dein explizites Go" and never received one —
14
+ * the user's next turn was a pasted `git push … rejected` stack trace,
15
+ * which was read as implicit continuation.
16
+ * - Two complete PRs opened on turns carrying no git authorization at all.
17
+ * - Force-push over a bot's commits off a bare "fixe auch diese 4 pr's".
18
+ * - Twice the constraint was named in writing and contradicted minutes later.
19
+ *
20
+ * `commit-policy` and `scope-control` already forbid all of this in prose, and
21
+ * the prose was in context every time. The ledger is the missing fact, not a
22
+ * new obligation.
23
+ *
24
+ * State: `agents/state/git-authorization.json`
25
+ * { "session_id": str, "detected_at": iso8601, "authorized": [op, …],
26
+ * "evidence": { op: "phrase" }, "prompt_chars": int }
27
+ *
28
+ * Each user turn REPLACES the ledger — that is the point. `commit-policy`'s
29
+ * "one-shot authorization is not a standing license" says an authorization is
30
+ * spent on the operation it named; carrying it forward is the exact inference
31
+ * the rule forbids, so a new prompt with no authorization phrase yields an
32
+ * empty ledger rather than an inherited one.
33
+ *
34
+ * Never blocks. Exit 0 always. The blocking half lives in the pre_tool_use
35
+ * concern, which is where the operation is actually observable.
36
+ */
37
+ import fs from "node:fs";
38
+ import path from "node:path";
39
+ import { fileURLToPath, pathToFileURL } from "node:url";
40
+
41
+ import { atomic_write_json } from "./hooks/state_io.js";
42
+ import { readHookStdin } from "./hooks/hook_stdin.js";
43
+
44
+ const EXIT_ALLOW = 0;
45
+
46
+ export const STATE_FILE = path.join("agents", "state", "git-authorization.json");
47
+
48
+ type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
49
+ type JsonObject = { [k: string]: JsonValue };
50
+
51
+ function _isObject(v: unknown): v is JsonObject {
52
+ return typeof v === "object" && v !== null && !Array.isArray(v);
53
+ }
54
+
55
+ /** The operations the ledger can authorize. */
56
+ export type GitOp =
57
+ | "commit"
58
+ | "push"
59
+ | "branch"
60
+ | "pr-create"
61
+ | "pr-merge"
62
+ | "tag"
63
+ | "release"
64
+ | "publish";
65
+
66
+ export const ALL_OPS: readonly GitOp[] = [
67
+ "commit",
68
+ "push",
69
+ "branch",
70
+ "pr-create",
71
+ "pr-merge",
72
+ "tag",
73
+ "release",
74
+ "publish",
75
+ ];
76
+
77
+ /**
78
+ * Prose phrases, German and English, that authorize an operation.
79
+ *
80
+ * Deliberately a keyword list rather than an LLM judge: the classifier runs on
81
+ * every prompt and must be cheap, deterministic and reviewable. A miss costs a
82
+ * warn (or, on the irreversible subset, one confirmation); an LLM judge would
83
+ * cost latency on every turn and be unauditable.
84
+ */
85
+ const PHRASES: ReadonlyArray<{ op: GitOp; re: RegExp }> = [
86
+ { op: "commit", re: /\b(commit(e|et|ten|ted|ting)?|committe|einchecken)\b/i },
87
+ { op: "push", re: /\b(push(e|en|ed|ing)?|hochladen|hochschieben|raufschieben)\b/i },
88
+ { op: "branch", re: /\b(branch(e|es)?|zweig|feature-branch|erstelle einen branch)\b/i },
89
+ { op: "pr-create", re: /\b(pull[- ]request|pr\b|mach(e|en)? (einen|nen) pr|erstelle .{0,20}pr)\b/i },
90
+ { op: "pr-merge", re: /\b(merge[nrd]?|mergen|zusammenführen|zusammenfuehren|reinmergen|gemerged)\b/i },
91
+ { op: "tag", re: /\b(tag(ge|gen|ged|ging)?|version(iere|ieren)?)\b/i },
92
+ { op: "release", re: /\b(release[nd]?|releasen|veröffentlich(e|en|ung)|veroeffentlich(e|en))\b/i },
93
+ { op: "publish", re: /\b(publish(e|en|ed|ing)?|npm publish|publiziere[n]?)\b/i },
94
+ ];
95
+
96
+ /** Executable commands a user may paste, mapped to the op they authorize. */
97
+ const PASTED_COMMANDS: ReadonlyArray<{ op: GitOp; re: RegExp }> = [
98
+ { op: "publish", re: /\bnpm\s+publish\b/i },
99
+ { op: "tag", re: /\bgit\s+push\s+[^\n]*--tags\b|\bgit\s+tag\s+-a\b/i },
100
+ { op: "release", re: /\bgh\s+release\s+create\b/i },
101
+ { op: "pr-merge", re: /\bgh\s+pr\s+merge\b/i },
102
+ { op: "pr-create", re: /\bgh\s+pr\s+create\b/i },
103
+ { op: "push", re: /\bgit\s+push\b/i },
104
+ { op: "commit", re: /\bgit\s+commit\b/i },
105
+ { op: "branch", re: /\bgit\s+(checkout\s+-b|switch\s+-c|branch)\b/i },
106
+ ];
107
+
108
+ /**
109
+ * Split a prompt into prose and fenced-code regions.
110
+ *
111
+ * The council's point: a user who PASTES an executable `git push origin main`
112
+ * is authorizing that command; the same literal appearing inside a log line or
113
+ * an error trace is not authorization. The discriminator used here is whether
114
+ * the line reads as a command invocation (starts at the command, optionally
115
+ * after a shell prompt marker) rather than being embedded in prose or in a
116
+ * `remote:` / `error:` / `hint:` diagnostic line.
117
+ */
118
+ export function splitFences(prompt: string): { prose: string; fences: string[] } {
119
+ const fences: string[] = [];
120
+ const prose = prompt.replace(/```[^\n]*\n([\s\S]*?)```/g, (_m, body: string) => {
121
+ fences.push(body);
122
+ return " ";
123
+ });
124
+ return { prose, fences };
125
+ }
126
+
127
+ /** A fenced line that is an actual command invocation, not diagnostic output. */
128
+ function _commandLines(fence: string): string[] {
129
+ return fence
130
+ .split("\n")
131
+ .map((l) => l.trim())
132
+ .filter((l) => l.length > 0)
133
+ // Drop git/npm diagnostic output — this is what "pasted an error trace" looks like.
134
+ .filter((l) => !/^(remote:|error:|hint:|fatal:|warning:|To |!\s|\s*\^)/i.test(l))
135
+ // Strip a leading shell prompt marker.
136
+ .map((l) => l.replace(/^[$>#]\s*/, ""))
137
+ // Keep only lines that START with the command.
138
+ .filter((l) => /^(git|gh|npm|pnpm|yarn|task)\b/.test(l));
139
+ }
140
+
141
+ export interface Ledger extends JsonObject {
142
+ session_id: string;
143
+ detected_at: string;
144
+ authorized: GitOp[];
145
+ evidence: { [op: string]: string };
146
+ prompt_chars: number;
147
+ }
148
+
149
+ /** Classify which ops a prompt authorizes. Exported for direct testing. */
150
+ export function classifyAuthorization(prompt: string): {
151
+ authorized: GitOp[];
152
+ evidence: Record<string, string>;
153
+ } {
154
+ const { prose, fences } = splitFences(prompt);
155
+ const authorized = new Set<GitOp>();
156
+ const evidence: Record<string, string> = {};
157
+
158
+ for (const { op, re } of PHRASES) {
159
+ const m = re.exec(prose);
160
+ if (m) {
161
+ authorized.add(op);
162
+ evidence[op] = `prose: "${m[0]}"`;
163
+ }
164
+ }
165
+
166
+ for (const fence of fences) {
167
+ for (const line of _commandLines(fence)) {
168
+ for (const { op, re } of PASTED_COMMANDS) {
169
+ if (re.test(line)) {
170
+ authorized.add(op);
171
+ evidence[op] = `pasted command: "${line.slice(0, 80)}"`;
172
+ }
173
+ }
174
+ }
175
+ }
176
+
177
+ return { authorized: [...authorized], evidence };
178
+ }
179
+
180
+ export function run(stdin_text: string, options: { consumer_root: string }): number {
181
+ let envelope: JsonObject = {};
182
+ if (stdin_text.trim()) {
183
+ try {
184
+ const decoded = JSON.parse(stdin_text) as unknown;
185
+ if (_isObject(decoded)) {
186
+ envelope = decoded;
187
+ }
188
+ } catch {
189
+ return EXIT_ALLOW;
190
+ }
191
+ }
192
+
193
+ const payload = _isObject(envelope["payload"]) ? (envelope["payload"] as JsonObject) : envelope;
194
+ let prompt = "";
195
+ for (const key of ["prompt", "userPrompt", "user_prompt", "message", "text"]) {
196
+ const v = payload[key];
197
+ if (typeof v === "string" && v.trim()) {
198
+ prompt = v;
199
+ break;
200
+ }
201
+ }
202
+ if (!prompt) {
203
+ return EXIT_ALLOW;
204
+ }
205
+
206
+ const { authorized, evidence } = classifyAuthorization(prompt);
207
+ const ledger: Ledger = {
208
+ session_id: typeof envelope["session_id"] === "string" ? envelope["session_id"] : "",
209
+ detected_at: new Date().toISOString(),
210
+ authorized,
211
+ evidence,
212
+ prompt_chars: prompt.length,
213
+ };
214
+
215
+ try {
216
+ atomic_write_json(path.join(options.consumer_root, STATE_FILE), ledger);
217
+ } catch {
218
+ // Observability only — a failed write degrades the gate to "no ledger",
219
+ // which the pre_tool_use concern treats as "not authorized" for the
220
+ // irreversible subset and as a warn for the rest.
221
+ }
222
+ return EXIT_ALLOW;
223
+ }
224
+
225
+ export function main(argv?: string[]): number {
226
+ const args = argv ?? process.argv.slice(2);
227
+ let consumer_root = process.cwd();
228
+ for (let i = 0; i < args.length; i += 1) {
229
+ const a = args[i];
230
+ if (a === "--project-dir" && args[i + 1] !== undefined) {
231
+ consumer_root = args[i + 1] as string;
232
+ i += 1;
233
+ } else if (a !== undefined && a.startsWith("--project-dir=")) {
234
+ consumer_root = a.slice("--project-dir=".length);
235
+ }
236
+ }
237
+ return run(readHookStdin(), { consumer_root });
238
+ }
239
+
240
+ declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
241
+ function _isCliEntry(): boolean {
242
+ if (typeof __AGENT_CONFIG_BUNDLE__ !== "undefined" && __AGENT_CONFIG_BUNDLE__) {
243
+ return false;
244
+ }
245
+ if (process.argv[1] === undefined) {
246
+ return false;
247
+ }
248
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
249
+ if (import.meta.url === argvUrl) {
250
+ return true;
251
+ }
252
+ try {
253
+ const here = fs.realpathSync(fileURLToPath(import.meta.url));
254
+ const argv = fs.realpathSync(path.resolve(process.argv[1]));
255
+ return here === argv;
256
+ } catch {
257
+ return false;
258
+ }
259
+ }
260
+
261
+ if (_isCliEntry()) {
262
+ process.exit(main());
263
+ }
@@ -240,6 +240,45 @@ concerns:
240
240
  args: []
241
241
  fail_closed: false
242
242
  severity: advisory
243
+ # Language-mirror pin — carries the ONE fact the transcript cannot express:
244
+ # which language the human actually typed. 47 skill/slash-command bodies were
245
+ # measured arriving in the `user` role across 21 of 30 audited sessions, and
246
+ # in the worst case a 4,196-char English skill body was the last user-role
247
+ # content before 136 consecutive English replies to a German prompt.
248
+ # `user_prompt_submit` is the only event a skill body never reaches.
249
+ # Advisory: it supplies a fact, it never blocks.
250
+ language-mirror:
251
+ script: src/scripts/language_mirror_hook.ts
252
+ args: []
253
+ fail_closed: false
254
+ severity: advisory
255
+ # Git-authorization ledger — records which git ops the user's OWN WORDS
256
+ # authorize this turn. Writes state only; never blocks.
257
+ git-authorization:
258
+ script: src/scripts/git_authorization_hook.ts
259
+ args: []
260
+ fail_closed: false
261
+ severity: advisory
262
+ # The blocking half. BLOCKS only the irreversible subset that
263
+ # non-destructive-by-default already declares never-autonomous (npm publish,
264
+ # tag push, gh release create, gh pr merge); WARNS on commit / push /
265
+ # pr-create / branch. Severity, not reach — see the header of the script.
266
+ block-unauthorized-git:
267
+ script: src/scripts/hooks/block_unauthorized_git.ts
268
+ args: []
269
+ fail_closed: true
270
+ severity: blocking
271
+ # Evaluator independence. Gates EVALUATION dispatches only — a verdict
272
+ # pre-loaded into the prompt, or a second evaluation of the same work in one
273
+ # turn. Ordinary parallel fan-out is untouched: the council's "second dispatch
274
+ # of any kind" criterion would have blocked 6 of the 7 analysis subagents in
275
+ # the very session that produced this guard. fail_closed: false — a malformed
276
+ # envelope must never block a dispatch.
277
+ evidence-independence:
278
+ script: src/scripts/hooks/evidence_independence.ts
279
+ args: []
280
+ fail_closed: false
281
+ severity: blocking
243
282
  # session_start canary injection — deterministic carrier for the
244
283
  # session-canary rule (greet-by-name at task start + reply-close markers
245
284
  # as a user-observable context-degradation signal). Gated on
@@ -258,15 +297,15 @@ platforms:
258
297
  session_start: [chat-history, hot-context, handoff-context, session-canary, first-run-gate, onboarding-gate, verify-before-complete, minimal-safe-diff, profile-staleness, wrapper-freshness, surface-probe]
259
298
  session_end: [chat-history, memory-learn]
260
299
  stop: [chat-history, hot-context, verify-before-complete]
261
- pre_tool_use: [block-no-verify, block-kernel-rule-writes, block-config-weakening, rtk-wrap, design-slop, code-graph-nudge]
300
+ pre_tool_use: [block-no-verify, block-unauthorized-git, evidence-independence, block-kernel-rule-writes, block-config-weakening, rtk-wrap, design-slop, code-graph-nudge]
262
301
  post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan, pr-url-reminder]
263
302
 
264
303
  claude:
265
304
  session_start: [chat-history, hot-context, handoff-context, session-canary, first-run-gate, onboarding-gate, verify-before-complete, minimal-safe-diff, profile-staleness, wrapper-freshness, surface-probe]
266
305
  session_end: [chat-history, memory-learn]
267
306
  stop: [chat-history, hot-context, verify-before-complete, team-review-gate]
268
- user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff]
269
- pre_tool_use: [block-no-verify, block-kernel-rule-writes, block-config-weakening, rtk-wrap, design-slop, code-graph-nudge]
307
+ user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff, language-mirror, git-authorization]
308
+ pre_tool_use: [block-no-verify, block-unauthorized-git, evidence-independence, block-kernel-rule-writes, block-config-weakening, rtk-wrap, design-slop, code-graph-nudge]
270
309
  post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan, pr-url-reminder]
271
310
 
272
311
  # Cowork — the Claude desktop app's local-agent-mode runtime, built
@@ -287,8 +326,8 @@ platforms:
287
326
  session_start: [chat-history, hot-context, handoff-context, session-canary, first-run-gate, onboarding-gate, verify-before-complete, minimal-safe-diff, profile-staleness, wrapper-freshness, surface-probe]
288
327
  session_end: [chat-history, memory-learn]
289
328
  stop: [chat-history, hot-context, verify-before-complete]
290
- user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff]
291
- pre_tool_use: [block-no-verify, block-kernel-rule-writes, block-config-weakening, rtk-wrap, design-slop, code-graph-nudge]
329
+ user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff, language-mirror, git-authorization]
330
+ pre_tool_use: [block-no-verify, block-unauthorized-git, evidence-independence, block-kernel-rule-writes, block-config-weakening, rtk-wrap, design-slop, code-graph-nudge]
292
331
  post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan, pr-url-reminder]
293
332
 
294
333
  # Phase 7.5 — Cursor. `.cursor/hooks.json` (project) is read by the
@@ -302,7 +341,7 @@ platforms:
302
341
  session_start: [chat-history, hot-context, handoff-context, session-canary, first-run-gate, onboarding-gate, verify-before-complete, minimal-safe-diff, profile-staleness, wrapper-freshness, surface-probe]
303
342
  session_end: [chat-history, memory-learn]
304
343
  stop: [chat-history, hot-context, verify-before-complete]
305
- user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff]
344
+ user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff, language-mirror, git-authorization]
306
345
  post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan, pr-url-reminder]
307
346
 
308
347
  # Phase 7.6 — Cline. Hooks live under `.clinerules/hooks/<HookName>`
@@ -317,7 +356,7 @@ platforms:
317
356
  session_start: [chat-history, hot-context, handoff-context, session-canary, first-run-gate, onboarding-gate, verify-before-complete, minimal-safe-diff, profile-staleness, wrapper-freshness, surface-probe]
318
357
  session_end: [chat-history, memory-learn]
319
358
  stop: [chat-history, hot-context, verify-before-complete]
320
- user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff]
359
+ user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff, language-mirror, git-authorization]
321
360
  post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan, pr-url-reminder]
322
361
 
323
362
  # Phase 7.7 — Windsurf (Cascade). Hooks live at `.windsurf/hooks.json`
@@ -335,7 +374,9 @@ platforms:
335
374
  windsurf:
336
375
  session_start: [chat-history, hot-context, handoff-context, session-canary, first-run-gate, onboarding-gate, verify-before-complete, profile-staleness, wrapper-freshness, surface-probe]
337
376
  stop: [chat-history, hot-context, verify-before-complete]
338
- user_prompt_submit: [chat-history, verify-before-complete]
377
+ # language-mirror IS bound here: unlike minimal-safe-diff it needs only the
378
+ # user_prompt_submit slot, which Windsurf has.
379
+ user_prompt_submit: [chat-history, verify-before-complete, language-mirror, git-authorization]
339
380
 
340
381
  # Phase 7.8 — Gemini CLI. Hooks live at `.gemini/settings.json`
341
382
  # (project) or `~/.gemini/settings.json` (user). Per Gemini docs
@@ -351,7 +392,7 @@ platforms:
351
392
  session_start: [chat-history, hot-context, handoff-context, session-canary, first-run-gate, onboarding-gate, verify-before-complete, minimal-safe-diff, profile-staleness, wrapper-freshness, surface-probe]
352
393
  session_end: [chat-history, memory-learn]
353
394
  stop: [chat-history, hot-context, verify-before-complete]
354
- user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff]
395
+ user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff, language-mirror, git-authorization]
355
396
  post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan, pr-url-reminder]
356
397
 
357
398
  # Phase 7.9 — Copilot has no hook surface. Concerns route through
@@ -0,0 +1,254 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Unauthorized-git gate — `pre_tool_use` concern.
4
+ *
5
+ * Checks a git / GitHub / npm operation against the authorization ledger that
6
+ * `git_authorization_hook.ts` writes on `user_prompt_submit`, so the question
7
+ * "did the user authorize this THIS turn?" is answered from a recorded fact
8
+ * rather than from the model's recollection.
9
+ *
10
+ * SEVERITY SPLIT — by reversibility, not by confidence.
11
+ *
12
+ * The AI council (2026-08-06) split on block-vs-warn: one member argued block
13
+ * from day one because a published npm release had already happened; the other
14
+ * argued warn-first to gather false-positive data. The repo's locked
15
+ * `ratchet severity, never reach` decision settles it without overriding
16
+ * either:
17
+ *
18
+ * BLOCK — only operations that `non-destructive-by-default` ALREADY declares
19
+ * never-autonomous and that cannot be undone: `npm publish`, a tag push,
20
+ * `gh release create`, `gh pr merge`. Blocking these adds no new prohibition;
21
+ * it makes an existing one mechanical. That is severity, not reach.
22
+ *
23
+ * WARN — `git commit`, `git push`, `gh pr create`, branch creation,
24
+ * force-push. Real violations were measured here too (two PRs opened on turns
25
+ * with zero authorization), but these are recoverable, and a false positive on
26
+ * `git commit` would be a workflow cliff.
27
+ *
28
+ * KNOWN CONSERVATIVE OVER-BLOCK: `gh pr merge` does not name its base in the
29
+ * command, so a merge into a non-production base is blocked as if it were a
30
+ * production-trunk merge. Stated rather than silently narrowed — the escape is
31
+ * one authorizing word in the prompt, and in this repo virtually every PR
32
+ * targets `main`.
33
+ *
34
+ * NO LEDGER = NOT AUTHORIZED for the block subset, warn for the rest. A hook
35
+ * that fails open on its own state file would be decorative on exactly the
36
+ * turns where the state write failed.
37
+ *
38
+ * Exit codes (dispatcher contract): 0 allow · 2 block (stderr carries the
39
+ * reason on block-capable events) — the dispatcher's `host_semantics`
40
+ * translation owns the per-platform mapping.
41
+ */
42
+ import fs from "node:fs";
43
+ import path from "node:path";
44
+ import { fileURLToPath, pathToFileURL } from "node:url";
45
+
46
+ import { readHookStdin } from "./hook_stdin.js";
47
+ import { STATE_FILE, type GitOp } from "../git_authorization_hook.js";
48
+
49
+ const EXIT_ALLOW = 0;
50
+ const EXIT_BLOCK = 2;
51
+
52
+ type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
53
+ type JsonObject = { [k: string]: JsonValue };
54
+
55
+ function _isObject(v: unknown): v is JsonObject {
56
+ return typeof v === "object" && v !== null && !Array.isArray(v);
57
+ }
58
+
59
+ /** Irreversible operations — blocked without a this-turn authorization. */
60
+ export const BLOCK_OPS: ReadonlySet<GitOp> = new Set<GitOp>([
61
+ "publish",
62
+ "tag",
63
+ "release",
64
+ "pr-merge",
65
+ ]);
66
+
67
+ /** Recoverable operations — warned without a this-turn authorization. */
68
+ export const WARN_OPS: ReadonlySet<GitOp> = new Set<GitOp>([
69
+ "commit",
70
+ "push",
71
+ "pr-create",
72
+ "branch",
73
+ ]);
74
+
75
+ /**
76
+ * Command → operation. Order matters: the most specific pattern wins, so
77
+ * `git push --tags` is a tag push and not a plain push.
78
+ */
79
+ const COMMAND_OPS: ReadonlyArray<{ op: GitOp; re: RegExp }> = [
80
+ { op: "publish", re: /\b(npm|pnpm|yarn)\s+publish\b/i },
81
+ { op: "release", re: /\bgh\s+release\s+create\b/i },
82
+ { op: "tag", re: /\bgit\s+push\b[^\n;|&]*--tags\b|\bgit\s+push\b[^\n;|&]*\brefs\/tags\//i },
83
+ { op: "pr-merge", re: /\bgh\s+pr\s+merge\b/i },
84
+ { op: "pr-create", re: /\bgh\s+pr\s+create\b/i },
85
+ { op: "push", re: /\bgit\s+push\b/i },
86
+ { op: "commit", re: /\bgit\s+commit\b/i },
87
+ { op: "branch", re: /\bgit\s+(checkout\s+-b|switch\s+-c)\b/i },
88
+ ];
89
+
90
+ const COMMAND_TOOLS: ReadonlySet<string> = new Set([
91
+ "launch-process",
92
+ "launch_process",
93
+ "Bash",
94
+ "BashTool",
95
+ "run-process",
96
+ "runProcess",
97
+ "shell",
98
+ "execute_shell",
99
+ "RunShellCommand",
100
+ ]);
101
+
102
+ /** Extract the shell command from a platform-native pre-tool envelope. */
103
+ export function extractCommand(envelope: JsonObject): string | null {
104
+ const payload = _isObject(envelope["payload"]) ? (envelope["payload"] as JsonObject) : envelope;
105
+ const toolRaw = payload["tool_name"] ?? payload["toolName"] ?? payload["tool"];
106
+ const tool = typeof toolRaw === "string" ? toolRaw : null;
107
+ if (tool !== null && !COMMAND_TOOLS.has(tool)) {
108
+ return null;
109
+ }
110
+ const input = _isObject(payload["tool_input"])
111
+ ? (payload["tool_input"] as JsonObject)
112
+ : _isObject(payload["toolInput"])
113
+ ? (payload["toolInput"] as JsonObject)
114
+ : payload;
115
+ for (const key of ["command", "cmd", "script"]) {
116
+ const v = input[key];
117
+ if (typeof v === "string" && v.trim()) {
118
+ return v;
119
+ }
120
+ }
121
+ return null;
122
+ }
123
+
124
+ /** Classify a shell command into the operation it performs, if any. */
125
+ export function commandOp(command: string): GitOp | null {
126
+ for (const { op, re } of COMMAND_OPS) {
127
+ if (re.test(command)) {
128
+ return op;
129
+ }
130
+ }
131
+ return null;
132
+ }
133
+
134
+ function _loadLedger(consumer_root: string): { authorized: Set<GitOp>; present: boolean } {
135
+ try {
136
+ const raw = fs.readFileSync(path.join(consumer_root, STATE_FILE), "utf8");
137
+ const decoded = JSON.parse(raw) as unknown;
138
+ if (_isObject(decoded) && Array.isArray(decoded["authorized"])) {
139
+ return {
140
+ authorized: new Set((decoded["authorized"] as string[]).filter(Boolean) as GitOp[]),
141
+ present: true,
142
+ };
143
+ }
144
+ } catch {
145
+ /* fall through */
146
+ }
147
+ return { authorized: new Set<GitOp>(), present: false };
148
+ }
149
+
150
+ export interface Decision {
151
+ exit: number;
152
+ stdout: string;
153
+ stderr: string;
154
+ }
155
+
156
+ export function decide(
157
+ command: string | null,
158
+ ledger: { authorized: Set<GitOp>; present: boolean },
159
+ ): Decision {
160
+ if (command === null) {
161
+ return { exit: EXIT_ALLOW, stdout: "", stderr: "" };
162
+ }
163
+ const op = commandOp(command);
164
+ if (op === null || ledger.authorized.has(op)) {
165
+ return { exit: EXIT_ALLOW, stdout: "", stderr: "" };
166
+ }
167
+
168
+ const shown = command.length > 120 ? `${command.slice(0, 117)}…` : command;
169
+
170
+ if (BLOCK_OPS.has(op)) {
171
+ const reason =
172
+ `Blocked: \`${op}\` with no authorization in this turn's prompt. ` +
173
+ `This operation is irreversible and non-destructive-by-default already declares it ` +
174
+ `never-autonomous. Command: ${shown}. ` +
175
+ `Ask the user, in one numbered-options block, and run it only after they answer this turn.`;
176
+ return { exit: EXIT_BLOCK, stdout: "", stderr: `${reason}\n` };
177
+ }
178
+
179
+ if (WARN_OPS.has(op)) {
180
+ const reason =
181
+ `No \`${op}\` authorization found in this turn's prompt (commit-policy: a one-shot ` +
182
+ `authorization is spent on the operation it named). If the user did authorize it this ` +
183
+ `turn, proceed; otherwise stop and ask. Command: ${shown}`;
184
+ return {
185
+ exit: EXIT_ALLOW,
186
+ stdout: `${JSON.stringify({ decision: "warn", reason })}\n`,
187
+ stderr: "",
188
+ };
189
+ }
190
+
191
+ return { exit: EXIT_ALLOW, stdout: "", stderr: "" };
192
+ }
193
+
194
+ export function run(stdin_text: string, options: { consumer_root: string }): number {
195
+ let envelope: JsonObject = {};
196
+ if (stdin_text.trim()) {
197
+ try {
198
+ const decoded = JSON.parse(stdin_text) as unknown;
199
+ if (_isObject(decoded)) {
200
+ envelope = decoded;
201
+ }
202
+ } catch {
203
+ return EXIT_ALLOW;
204
+ }
205
+ }
206
+ const decision = decide(extractCommand(envelope), _loadLedger(options.consumer_root));
207
+ if (decision.stdout) {
208
+ process.stdout.write(decision.stdout);
209
+ }
210
+ if (decision.stderr) {
211
+ process.stderr.write(decision.stderr);
212
+ }
213
+ return decision.exit;
214
+ }
215
+
216
+ export function main(argv?: string[]): number {
217
+ const args = argv ?? process.argv.slice(2);
218
+ let consumer_root = process.cwd();
219
+ for (let i = 0; i < args.length; i += 1) {
220
+ const a = args[i];
221
+ if (a === "--project-dir" && args[i + 1] !== undefined) {
222
+ consumer_root = args[i + 1] as string;
223
+ i += 1;
224
+ } else if (a !== undefined && a.startsWith("--project-dir=")) {
225
+ consumer_root = a.slice("--project-dir=".length);
226
+ }
227
+ }
228
+ return run(readHookStdin(), { consumer_root });
229
+ }
230
+
231
+ declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
232
+ function _isCliEntry(): boolean {
233
+ if (typeof __AGENT_CONFIG_BUNDLE__ !== "undefined" && __AGENT_CONFIG_BUNDLE__) {
234
+ return false;
235
+ }
236
+ if (process.argv[1] === undefined) {
237
+ return false;
238
+ }
239
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
240
+ if (import.meta.url === argvUrl) {
241
+ return true;
242
+ }
243
+ try {
244
+ const here = fs.realpathSync(fileURLToPath(import.meta.url));
245
+ const argv = fs.realpathSync(path.resolve(process.argv[1]));
246
+ return here === argv;
247
+ } catch {
248
+ return false;
249
+ }
250
+ }
251
+
252
+ if (_isCliEntry()) {
253
+ process.exit(main());
254
+ }
@@ -41,6 +41,10 @@ import { main as surfaceProbeMain } from '../surface_probe_hook.js';
41
41
  import { main as teamReviewGateMain } from '../team_review_gate_hook.js';
42
42
  import { main as prUrlReminderMain } from '../pr_url_reminder_hook.js';
43
43
  import { main as sessionCanaryMain } from '../session_canary_hook.js';
44
+ import { main as languageMirrorMain } from '../language_mirror_hook.js';
45
+ import { main as gitAuthorizationMain } from '../git_authorization_hook.js';
46
+ import { main as blockUnauthorizedGitMain } from './block_unauthorized_git.js';
47
+ import { main as evidenceIndependenceMain } from './evidence_independence.js';
44
48
  import { main as blockNoVerifyMain } from './block_no_verify.js';
45
49
  import { main as blockKernelRuleWritesMain } from './block_kernel_rule_writes.js';
46
50
  import { main as blockConfigWeakeningMain } from './block_config_weakening.js';
@@ -70,6 +74,10 @@ export const CONCERN_REGISTRY: Readonly<Record<string, ConcernMain>> = {
70
74
  'src/scripts/team_review_gate_hook.ts': teamReviewGateMain as ConcernMain,
71
75
  'src/scripts/pr_url_reminder_hook.ts': prUrlReminderMain as ConcernMain,
72
76
  'src/scripts/session_canary_hook.ts': sessionCanaryMain as ConcernMain,
77
+ 'src/scripts/language_mirror_hook.ts': languageMirrorMain as ConcernMain,
78
+ 'src/scripts/git_authorization_hook.ts': gitAuthorizationMain as ConcernMain,
79
+ 'src/scripts/hooks/block_unauthorized_git.ts': blockUnauthorizedGitMain as ConcernMain,
80
+ 'src/scripts/hooks/evidence_independence.ts': evidenceIndependenceMain as ConcernMain,
73
81
  'src/scripts/hooks/block_no_verify.ts': blockNoVerifyMain as ConcernMain,
74
82
  'src/scripts/hooks/block_kernel_rule_writes.ts': blockKernelRuleWritesMain as ConcernMain,
75
83
  'src/scripts/hooks/block_config_weakening.ts': blockConfigWeakeningMain as ConcernMain,