@event4u/agent-config 9.25.0 → 9.27.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 (222) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +58 -143
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/analyze/conformance.md +21 -2
  5. package/dist/agent-src/commands/review/changes.md +6 -5
  6. package/dist/agent-src/commands/roadmap/next.md +256 -0
  7. package/dist/agent-src/commands/roadmap.md +12 -4
  8. package/dist/agent-src/contexts/execution/subagent-routing.md +4 -1
  9. package/dist/agent-src/rules/active-remediation.md +2 -0
  10. package/dist/agent-src/rules/analysis-skill-routing.md +2 -0
  11. package/dist/agent-src/rules/architecture.md +2 -0
  12. package/dist/agent-src/rules/artifact-drafting-protocol.md +2 -0
  13. package/dist/agent-src/rules/artifact-engagement-recording.md +2 -0
  14. package/dist/agent-src/rules/augment-edit-discipline.md +2 -0
  15. package/dist/agent-src/rules/autonomous-execution.md +2 -0
  16. package/dist/agent-src/rules/brand-consistency.md +2 -0
  17. package/dist/agent-src/rules/brand-source-of-truth.md +2 -0
  18. package/dist/agent-src/rules/broken-access-control.md +2 -0
  19. package/dist/agent-src/rules/cli-output-handling.md +2 -0
  20. package/dist/agent-src/rules/code-comment-discipline.md +2 -0
  21. package/dist/agent-src/rules/code-provenance.md +2 -0
  22. package/dist/agent-src/rules/command-suggestion-policy.md +2 -0
  23. package/dist/agent-src/rules/commit-conventions.md +2 -0
  24. package/dist/agent-src/rules/communication-through-line.md +2 -0
  25. package/dist/agent-src/rules/content-quoting-floor.md +2 -0
  26. package/dist/agent-src/rules/context-hygiene.md +10 -0
  27. package/dist/agent-src/rules/copilot-routing.md +2 -0
  28. package/dist/agent-src/rules/cross-source-consistency.md +2 -0
  29. package/dist/agent-src/rules/decision-revisit-gate.md +2 -0
  30. package/dist/agent-src/rules/delegation-policy.md +5 -2
  31. package/dist/agent-src/rules/design-fidelity.md +19 -1
  32. package/dist/agent-src/rules/design-review-after-ui-write.md +120 -0
  33. package/dist/agent-src/rules/devcontainer-routing.md +2 -0
  34. package/dist/agent-src/rules/doc-screenshot-hygiene.md +2 -0
  35. package/dist/agent-src/rules/docker-commands.md +2 -0
  36. package/dist/agent-src/rules/domain-adoption-policy.md +2 -0
  37. package/dist/agent-src/rules/domain-safety-disclaimer.md +2 -0
  38. package/dist/agent-src/rules/domain-safety-pii.md +2 -0
  39. package/dist/agent-src/rules/domain-safety-retention.md +2 -0
  40. package/dist/agent-src/rules/downstream-changes.md +2 -0
  41. package/dist/agent-src/rules/engineering-safety-floor.md +2 -0
  42. package/dist/agent-src/rules/evaluator-independence.md +19 -3
  43. package/dist/agent-src/rules/external-code-graph-interop.md +2 -0
  44. package/dist/agent-src/rules/external-reference-deep-dive.md +2 -0
  45. package/dist/agent-src/rules/fast-path-marker-visibility.md +2 -0
  46. package/dist/agent-src/rules/finance-safety-floor.md +2 -0
  47. package/dist/agent-src/rules/framework-neutrality-in-generic-skills.md +2 -0
  48. package/dist/agent-src/rules/git-history-discipline.md +3 -1
  49. package/dist/agent-src/rules/guidelines.md +2 -0
  50. package/dist/agent-src/rules/history-discipline.md +2 -0
  51. package/dist/agent-src/rules/icon-consistency.md +2 -0
  52. package/dist/agent-src/rules/image-likeness-and-rights.md +2 -0
  53. package/dist/agent-src/rules/improve-before-implement.md +2 -0
  54. package/dist/agent-src/rules/invite-challenge.md +2 -0
  55. package/dist/agent-src/rules/laravel-routing.md +2 -0
  56. package/dist/agent-src/rules/laravel-translations.md +2 -0
  57. package/dist/agent-src/rules/legal-safety-floor.md +2 -0
  58. package/dist/agent-src/rules/lethal-trifecta-guard.md +2 -0
  59. package/dist/agent-src/rules/linked-projects-onboarding-gate.md +2 -0
  60. package/dist/agent-src/rules/low-impact-corpus-privacy-floor.md +2 -0
  61. package/dist/agent-src/rules/markdown-safe-codeblocks.md +2 -0
  62. package/dist/agent-src/rules/media-governance-routing.md +2 -0
  63. package/dist/agent-src/rules/media-sync-ground-truth.md +2 -0
  64. package/dist/agent-src/rules/minimal-safe-diff.md +2 -0
  65. package/dist/agent-src/rules/missing-tool-handling.md +2 -0
  66. package/dist/agent-src/rules/model-recommendation.md +2 -0
  67. package/dist/agent-src/rules/no-attribution-footers.md +2 -0
  68. package/dist/agent-src/rules/no-decorative-emojis-in-git-surfaces.md +2 -0
  69. package/dist/agent-src/rules/no-pr-progress-comments.md +2 -0
  70. package/dist/agent-src/rules/no-roadmap-references.md +2 -0
  71. package/dist/agent-src/rules/notes-first-reasoning.md +2 -0
  72. package/dist/agent-src/rules/onboarding-gate.md +2 -0
  73. package/dist/agent-src/rules/output-discipline.md +2 -0
  74. package/dist/agent-src/rules/package-ci-checks.md +2 -0
  75. package/dist/agent-src/rules/persona-governance.md +2 -0
  76. package/dist/agent-src/rules/php-coding.md +2 -0
  77. package/dist/agent-src/rules/prefer-enums-over-literals.md +2 -0
  78. package/dist/agent-src/rules/preservation-guard.md +2 -0
  79. package/dist/agent-src/rules/provider-lifecycle-discipline.md +2 -0
  80. package/dist/agent-src/rules/question-not-instruction.md +2 -0
  81. package/dist/agent-src/rules/reviewer-awareness.md +2 -0
  82. package/dist/agent-src/rules/roadmap-ci-steps-policy.md +2 -0
  83. package/dist/agent-src/rules/roadmap-progress-sync.md +2 -0
  84. package/dist/agent-src/rules/role-mode-adherence.md +2 -0
  85. package/dist/agent-src/rules/rule-type-governance.md +2 -0
  86. package/dist/agent-src/rules/runtime-safety.md +2 -0
  87. package/dist/agent-src/rules/scale-discipline.md +2 -0
  88. package/dist/agent-src/rules/secret-vcs-guard.md +2 -0
  89. package/dist/agent-src/rules/security-sensitive-stop.md +2 -0
  90. package/dist/agent-src/rules/senior-engineering-discipline.md +2 -0
  91. package/dist/agent-src/rules/session-canary.md +63 -19
  92. package/dist/agent-src/rules/settings-ask-protocol.md +180 -0
  93. package/dist/agent-src/rules/size-enforcement.md +2 -0
  94. package/dist/agent-src/rules/skill-improvement-trigger.md +2 -0
  95. package/dist/agent-src/rules/skill-quality.md +2 -0
  96. package/dist/agent-src/rules/slash-command-routing-policy.md +2 -0
  97. package/dist/agent-src/rules/source-confidentiality.md +2 -0
  98. package/dist/agent-src/rules/source-discovery-gate.md +2 -0
  99. package/dist/agent-src/rules/source-of-truth.md +2 -0
  100. package/dist/agent-src/rules/spreadsheet-source-quality.md +2 -0
  101. package/dist/agent-src/rules/strategy-safety-floor.md +2 -0
  102. package/dist/agent-src/rules/symfony-routing.md +2 -0
  103. package/dist/agent-src/rules/think-before-action.md +2 -0
  104. package/dist/agent-src/rules/token-budget-discipline.md +8 -3
  105. package/dist/agent-src/rules/token-efficiency.md +2 -0
  106. package/dist/agent-src/rules/token-optimizer-maintenance.md +2 -0
  107. package/dist/agent-src/rules/tool-safety.md +2 -0
  108. package/dist/agent-src/rules/ui-audit-gate.md +7 -0
  109. package/dist/agent-src/rules/untrusted-input-defense.md +2 -0
  110. package/dist/agent-src/rules/upstream-proposal.md +2 -0
  111. package/dist/agent-src/rules/user-interaction.md +5 -2
  112. package/dist/agent-src/rules/user-interrupt-priority.md +2 -0
  113. package/dist/agent-src/skills/recursive-verification/SKILL.md +13 -0
  114. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  115. package/dist/cli/registry.js +2 -0
  116. package/dist/cli/registry.js.map +1 -1
  117. package/dist/cli-delegate/{chunk-SJQMJI7M.js → chunk-3KJIMFTB.js} +1 -1
  118. package/dist/cli-delegate/{chunk-BSLUHV2F.js → chunk-BCPIOAGV.js} +148 -133
  119. package/dist/cli-delegate/cmd_conformance.js +1 -1
  120. package/dist/cli-delegate/cmd_doctor.js +1 -1
  121. package/dist/cli-delegate/cmd_export.js +1 -1
  122. package/dist/cli-delegate/cmd_prune.js +1 -1
  123. package/dist/cli-delegate/cmd_route_audit.js +4 -1
  124. package/dist/cli-delegate/cmd_route_explain.js +4 -1
  125. package/dist/cli-delegate/cmd_settings_migrate.js +1 -1
  126. package/dist/cli-delegate/cmd_sync.js +1 -1
  127. package/dist/cli-delegate/cmd_uninstall.js +1 -1
  128. package/dist/cli-delegate/cmd_validate.js +1 -1
  129. package/dist/discovery/deprecation-report.md +1 -1
  130. package/dist/discovery/discovery-manifest.json +191 -120
  131. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  132. package/dist/discovery/discovery-manifest.summary.md +4 -4
  133. package/dist/discovery/orphan-report.md +1 -1
  134. package/dist/discovery/packs.json +11 -8
  135. package/dist/discovery/trust-report.md +3 -3
  136. package/dist/discovery/workspaces.json +9 -6
  137. package/dist/hooks/dispatch.js +1148 -349
  138. package/dist/install/install.mjs +1 -1
  139. package/dist/mcp/registry-manifest.json +3 -3
  140. package/dist/mcp/server.json +2 -2
  141. package/dist/mcp/server.mjs +571 -417
  142. package/dist/router.json +1 -1
  143. package/dist/scripts/condense.js +33 -2
  144. package/dist/scripts/condense.js.map +1 -1
  145. package/dist/server/io/yamlIO.js +85 -0
  146. package/dist/server/io/yamlIO.js.map +1 -1
  147. package/dist/server/nicknameResolver.js +116 -0
  148. package/dist/server/nicknameResolver.js.map +1 -0
  149. package/dist/server/routes/ping.js +29 -11
  150. package/dist/server/routes/ping.js.map +1 -1
  151. package/dist/server/routes/settings.js +1 -12
  152. package/dist/server/routes/settings.js.map +1 -1
  153. package/dist/server/routes/wizard.js +52 -2
  154. package/dist/server/routes/wizard.js.map +1 -1
  155. package/dist/shared/interactiveContext.js +73 -0
  156. package/dist/shared/interactiveContext.js.map +1 -0
  157. package/dist/shared/nicknamePrefill.js +59 -0
  158. package/dist/shared/nicknamePrefill.js.map +1 -0
  159. package/dist/shared/settingsAsks.js +128 -0
  160. package/dist/shared/settingsAsks.js.map +1 -0
  161. package/dist/shared/settingsCarveOut.js +118 -0
  162. package/dist/shared/settingsCarveOut.js.map +1 -0
  163. package/dist/shared/settingsConsent.js +93 -0
  164. package/dist/shared/settingsConsent.js.map +1 -0
  165. package/dist/shared/settingsSurface.js +7 -1
  166. package/dist/shared/settingsSurface.js.map +1 -1
  167. package/dist/ui/assets/index-DmR0mbk4.js.map +1 -1
  168. package/package.json +1 -1
  169. package/src/config/agents-paths.yml +15 -0
  170. package/src/config/evaluator-budgets.json +4 -4
  171. package/src/config/gate-coverage.yml +29 -1
  172. package/src/config/gate-violation-baselines.json +13 -7
  173. package/src/scripts/_cli/cmd_doctor.ts +6 -21
  174. package/src/scripts/_cli/cmd_route_audit.ts +4 -1
  175. package/src/scripts/_cli/cmd_route_explain.ts +9 -1
  176. package/src/scripts/_dispatch.bash +18 -0
  177. package/src/scripts/_lib/git_common_dir.ts +128 -0
  178. package/src/scripts/_lib/obligation_frequency.ts +391 -0
  179. package/src/scripts/_lib/session_register.ts +361 -0
  180. package/src/scripts/ai-video/stitch.sh +27 -9
  181. package/src/scripts/before_complete_hook.ts +23 -4
  182. package/src/scripts/bench_ab_v2_run.ts +272 -16
  183. package/src/scripts/bench_ab_v2_stats.ts +156 -3
  184. package/src/scripts/check_completion_review.ts +80 -3
  185. package/src/scripts/check_corpus_staleness.ts +692 -0
  186. package/src/scripts/check_enforcement_coverage.ts +192 -7
  187. package/src/scripts/check_gate_coverage.ts +67 -0
  188. package/src/scripts/check_hook_bundle_freshness.ts +183 -0
  189. package/src/scripts/check_rule_projection_integrity.ts +364 -0
  190. package/src/scripts/condense.ts +36 -2
  191. package/src/scripts/conformance_scan.ts +82 -17
  192. package/src/scripts/dispatch_r2_reviewer.ts +31 -2
  193. package/src/scripts/generate_settings_reference.ts +232 -0
  194. package/src/scripts/git_authorization_hook.ts +75 -10
  195. package/src/scripts/hook_manifest.yaml +87 -27
  196. package/src/scripts/hooks/block_no_verify.ts +59 -2
  197. package/src/scripts/hooks/block_unauthorized_git.ts +199 -16
  198. package/src/scripts/hooks/concern_registry.ts +2 -0
  199. package/src/scripts/hooks/design_slop_hook.ts +13 -1
  200. package/src/scripts/hooks/dispatch_hook.ts +97 -2
  201. package/src/scripts/hooks/evidence_independence.ts +40 -6
  202. package/src/scripts/language_mirror_hook.ts +256 -17
  203. package/src/scripts/lint_abstraction_thresholds.ts +41 -8
  204. package/src/scripts/lint_agents_layout.ts +2 -0
  205. package/src/scripts/lint_hook_manifest.ts +29 -0
  206. package/src/scripts/memory_learn_hook.ts +109 -6
  207. package/src/scripts/orchestration_backfill.ts +354 -0
  208. package/src/scripts/pr_url_reminder_hook.ts +17 -2
  209. package/src/scripts/release.ts +180 -37
  210. package/src/scripts/schemas/review-findings.schema.json +99 -0
  211. package/src/scripts/schemas/rule.schema.json +5 -0
  212. package/src/scripts/session_canary_hook.ts +35 -3
  213. package/src/scripts/session_register_hook.ts +288 -0
  214. package/src/scripts/sessions_cli.ts +140 -0
  215. package/src/scripts/validate_frontmatter.ts +68 -0
  216. package/src/server/io/yamlIO.ts +87 -0
  217. package/src/shared/interactiveContext.ts +98 -0
  218. package/src/shared/nicknamePrefill.ts +96 -0
  219. package/src/shared/settingsAsks.ts +176 -0
  220. package/src/shared/settingsCarveOut.ts +143 -0
  221. package/src/shared/settingsConsent.ts +136 -0
  222. package/src/shared/settingsSurface.ts +7 -1
@@ -47,7 +47,14 @@ import { readHookStdin } from "./hook_stdin.js";
47
47
  import { STATE_FILE, type GitOp } from "../git_authorization_hook.js";
48
48
 
49
49
  const EXIT_ALLOW = 0;
50
- const EXIT_BLOCK = 2;
50
+ // MUST equal dispatch_hook.EXIT_BLOCK. The dispatcher's internal ladder is
51
+ // 0 allow / 1 block / 2 warn — NOT the 2-means-block shape a PreToolUse guard
52
+ // reads naturally from Claude's own native contract. This constant was 2 when
53
+ // this gate first shipped, so the dispatcher reduced every refusal to a WARN
54
+ // and the gate emitted advisory context while the operation went through.
55
+ // Pinned against the dispatcher's export by
56
+ // tests/hooks/concern_block_exit_parity.test.ts.
57
+ const EXIT_BLOCK = 1;
51
58
 
52
59
  type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
53
60
  type JsonObject = { [k: string]: JsonValue };
@@ -72,19 +79,34 @@ export const WARN_OPS: ReadonlySet<GitOp> = new Set<GitOp>([
72
79
  "branch",
73
80
  ]);
74
81
 
82
+ /**
83
+ * Command → operation. Order matters: the most specific pattern wins, so
84
+ * `git push --tags` is a tag push and not a plain push.
85
+ */
86
+ // `git`/`gh` accept global options before the subcommand (`git -C dir push`,
87
+ // `git --git-dir=x push`), and each of those was a silent bypass of the whole
88
+ // gate. `G` allows any run of them.
89
+ const G = "(?:-[A-Za-z-]+(?:=\\S+)?(?:\\s+\\S+)?\\s+)*";
90
+
75
91
  /**
76
92
  * Command → operation. Order matters: the most specific pattern wins, so
77
93
  * `git push --tags` is a tag push and not a plain push.
78
94
  */
79
95
  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 },
96
+ { op: "publish", re: new RegExp(`\\b(npm|pnpm|yarn)\\s+${G}publish\\b`, "i") },
97
+ { op: "release", re: /\bgh\s+release\s+create\b|\bgh\s+api\b[^\n]*\/releases\b/i },
98
+ {
99
+ op: "tag",
100
+ re: new RegExp(
101
+ `\\bgit\\s+${G}push\\b[^\\n;|&]*(--tags|--follow-tags|refs\\/tags\\/)`,
102
+ "i",
103
+ ),
104
+ },
105
+ { op: "pr-merge", re: /\bgh\s+pr\s+merge\b|\bgh\s+api\b[^\n]*\/pulls\/\d+\/merge\b/i },
106
+ { op: "pr-create", re: /\bgh\s+pr\s+create\b|\bgh\s+api\b[^\n]*-X\s+POST[^\n]*\/pulls\b/i },
107
+ { op: "push", re: new RegExp(`\\bgit\\s+${G}push\\b`, "i") },
108
+ { op: "commit", re: new RegExp(`\\bgit\\s+${G}commit\\b`, "i") },
109
+ { op: "branch", re: new RegExp(`\\bgit\\s+${G}(checkout\\s+-b|switch\\s+-c)\\b`, "i") },
88
110
  ];
89
111
 
90
112
  const COMMAND_TOOLS: ReadonlySet<string> = new Set([
@@ -121,21 +143,178 @@ export function extractCommand(envelope: JsonObject): string | null {
121
143
  return null;
122
144
  }
123
145
 
124
- /** Classify a shell command into the operation it performs, if any. */
146
+ /**
147
+ * Split on shell separators that are OUTSIDE quotes.
148
+ *
149
+ * Separators recognised, matching the raw regex this replaces: newline, `;`,
150
+ * `&&`, `||`, `|`, `&`. A separator inside `'…'` or `"…"` is a literal, so it
151
+ * does not split. Backslash escapes the next character outside single quotes,
152
+ * per POSIX.
153
+ *
154
+ * Deliberately NOT a full tokeniser. `shlexSplit` in `block_no_verify` throws on
155
+ * an unbalanced quote, which is the right behaviour for a guard that fail-closes;
156
+ * this guard must classify every command it sees, including a malformed one. So
157
+ * an unterminated quote here degrades to "the rest of the string is quoted",
158
+ * which yields one segment starting at the real command word — the conservative
159
+ * outcome, since the command word is what `commandOp` matches on.
160
+ */
161
+ export function splitOutsideQuotes(command: string): string[] {
162
+ const parts: string[] = [];
163
+ let cur = "";
164
+ let quote: '"' | "'" | null = null;
165
+ for (let i = 0; i < command.length; i += 1) {
166
+ const ch = command[i] as string;
167
+ if (quote !== null) {
168
+ cur += ch;
169
+ if (ch === quote) {
170
+ quote = null;
171
+ } else if (ch === "\\" && quote === '"' && i + 1 < command.length) {
172
+ cur += command[i + 1] as string;
173
+ i += 1;
174
+ }
175
+ continue;
176
+ }
177
+ if (ch === '"' || ch === "'") {
178
+ quote = ch;
179
+ cur += ch;
180
+ continue;
181
+ }
182
+ if (ch === "\\" && i + 1 < command.length) {
183
+ cur += ch + (command[i + 1] as string);
184
+ i += 1;
185
+ continue;
186
+ }
187
+ if (ch === "\n" || ch === ";" || ch === "|" || ch === "&") {
188
+ // `&&` and `||` are two-character separators; consume the pair.
189
+ if ((ch === "|" || ch === "&") && command[i + 1] === ch) {
190
+ i += 1;
191
+ }
192
+ parts.push(cur);
193
+ cur = "";
194
+ continue;
195
+ }
196
+ cur += ch;
197
+ }
198
+ parts.push(cur);
199
+ return parts;
200
+ }
201
+
202
+ /**
203
+ * Split a shell command into the segments that are actually INVOKED.
204
+ *
205
+ * A substring match over the whole command string is wrong, and measurably so:
206
+ * the round-2 self-scan found this gate firing on two commands of the auditor's
207
+ * own session whose only sin was carrying `npm publish` inside a `printf`
208
+ * argument as test data. Blocking a command because it mentions an operation is
209
+ * the worst false-positive shape a refusing gate can have.
210
+ *
211
+ * So: split on shell separators, drop leading env assignments, and keep the
212
+ * segment only from its command word. `printf '{"cmd":"npm publish"}'` yields a
213
+ * segment starting at `printf` and never matches.
214
+ *
215
+ * Quoted payloads are NOT simply discarded — `sh -c "npm publish"` really does
216
+ * publish. Those are unwrapped and re-split, so closing the false positive does
217
+ * not open a bypass.
218
+ *
219
+ * The separator split is QUOTE-AWARE, and that is not a refinement — it is the
220
+ * same false-positive class the paragraph above describes, surviving in the one
221
+ * place the round-2 fix did not reach. Measured in the round-5 audit
222
+ * (2026-08-07): a read-only `grep -n -E "…|npm publish|…"` was refused with
223
+ * "Blocked: `publish` with no authorization in this turn's prompt", because a
224
+ * raw `split` on `|` cut the quoted alternation into a segment beginning with
225
+ * `npm`. A `|` inside quotes is data, exactly like a heredoc body; only an
226
+ * unquoted one is a pipe. Splitting outside quotes closes that without
227
+ * discarding quoted payloads, so `sh -c "npm publish"` still unwraps below.
228
+ */
229
+ export function invokedSegments(command: string, depth = 0): string[] {
230
+ if (depth > 3) {
231
+ return [];
232
+ }
233
+ const out: string[] = [];
234
+ // Strip heredoc bodies before splitting: a line inside `<<EOF … EOF` is data
235
+ // being written, not a command being run, but after a newline split it looks
236
+ // exactly like an invocation.
237
+ const withoutHeredocs = command.replace(
238
+ /<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1[\s\S]*?^\s*\2\s*$/gm,
239
+ "<<HEREDOC",
240
+ );
241
+ for (const raw of splitOutsideQuotes(withoutHeredocs)) {
242
+ let seg = raw.trim();
243
+ if (!seg) {
244
+ continue;
245
+ }
246
+ // Drop leading env assignments: `FOO=bar BAZ=1 npm publish`.
247
+ seg = seg.replace(/^(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S*)\s+)+/, "");
248
+ // Unwrap `sh -c '<payload>'` / `bash -c "<payload>"` and recurse.
249
+ const wrapped = /^(?:\S*\/)?(?:ba|z|k)?sh\s+-[a-z]*c\s+(?:"([^"]*)"|'([^']*)'|(\S+))/.exec(seg);
250
+ if (wrapped) {
251
+ out.push(...invokedSegments(wrapped[1] ?? wrapped[2] ?? wrapped[3] ?? "", depth + 1));
252
+ continue;
253
+ }
254
+ out.push(seg);
255
+ }
256
+ return out;
257
+ }
258
+
259
+ /**
260
+ * Classify a shell command into the operation it performs, if any.
261
+ *
262
+ * Matches per invoked segment and only when the segment BEGINS with the tool —
263
+ * a mention inside an argument is not an invocation.
264
+ */
125
265
  export function commandOp(command: string): GitOp | null {
126
- for (const { op, re } of COMMAND_OPS) {
127
- if (re.test(command)) {
128
- return op;
266
+ // Every segment is classified and the MOST SEVERE result wins. Returning the
267
+ // first match let `npm publish && git push --tags` pass its tag push
268
+ // unchecked once `publish` was authorized.
269
+ let found: GitOp | null = null;
270
+ for (const seg of invokedSegments(command)) {
271
+ if (!/^(?:\S*\/)?(git|gh|npm|pnpm|yarn)\b/.test(seg)) {
272
+ continue;
273
+ }
274
+ for (const { op, re } of COMMAND_OPS) {
275
+ if (re.test(seg)) {
276
+ if (BLOCK_OPS.has(op)) {
277
+ return op;
278
+ }
279
+ found ??= op;
280
+ break;
281
+ }
129
282
  }
130
283
  }
131
- return null;
284
+ return found;
132
285
  }
133
286
 
134
- function _loadLedger(consumer_root: string): { authorized: Set<GitOp>; present: boolean } {
287
+ /** A ledger older than this is not "this turn" under any reading. */
288
+ export const LEDGER_MAX_AGE_MS = 30 * 60 * 1000;
289
+
290
+ /**
291
+ * Load the ledger, and refuse one that does not belong to THIS session and
292
+ * THIS turn.
293
+ *
294
+ * The header claims the gate answers "did the user authorize this THIS turn?".
295
+ * Before this check it answered "did anyone, in any session, ever authorize
296
+ * it?" — the file records `session_id` and `detected_at` and neither was read.
297
+ * Two concurrent worktrees share one state root, so session A typing
298
+ * `mach den release` authorized session B.
299
+ */
300
+ function _loadLedger(
301
+ consumer_root: string,
302
+ session_id: string,
303
+ now: number,
304
+ ): { authorized: Set<GitOp>; present: boolean } {
135
305
  try {
136
306
  const raw = fs.readFileSync(path.join(consumer_root, STATE_FILE), "utf8");
137
307
  const decoded = JSON.parse(raw) as unknown;
138
308
  if (_isObject(decoded) && Array.isArray(decoded["authorized"])) {
309
+ const ledgerSession = typeof decoded["session_id"] === "string" ? decoded["session_id"] : "";
310
+ // A ledger from a different session is another conversation's consent.
311
+ if (session_id && ledgerSession && ledgerSession !== session_id) {
312
+ return { authorized: new Set<GitOp>(), present: false };
313
+ }
314
+ const at = Date.parse(String(decoded["detected_at"] ?? ""));
315
+ if (Number.isFinite(at) && now - at > LEDGER_MAX_AGE_MS) {
316
+ return { authorized: new Set<GitOp>(), present: false };
317
+ }
139
318
  return {
140
319
  authorized: new Set((decoded["authorized"] as string[]).filter(Boolean) as GitOp[]),
141
320
  present: true,
@@ -203,7 +382,11 @@ export function run(stdin_text: string, options: { consumer_root: string }): num
203
382
  return EXIT_ALLOW;
204
383
  }
205
384
  }
206
- const decision = decide(extractCommand(envelope), _loadLedger(options.consumer_root));
385
+ const session_id = typeof envelope["session_id"] === "string" ? envelope["session_id"] : "";
386
+ const decision = decide(
387
+ extractCommand(envelope),
388
+ _loadLedger(options.consumer_root, session_id, Date.now()),
389
+ );
207
390
  if (decision.stdout) {
208
391
  process.stdout.write(decision.stdout);
209
392
  }
@@ -41,6 +41,7 @@ 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 sessionRegisterMain } from '../session_register_hook.js';
44
45
  import { main as languageMirrorMain } from '../language_mirror_hook.js';
45
46
  import { main as gitAuthorizationMain } from '../git_authorization_hook.js';
46
47
  import { main as blockUnauthorizedGitMain } from './block_unauthorized_git.js';
@@ -74,6 +75,7 @@ export const CONCERN_REGISTRY: Readonly<Record<string, ConcernMain>> = {
74
75
  'src/scripts/team_review_gate_hook.ts': teamReviewGateMain as ConcernMain,
75
76
  'src/scripts/pr_url_reminder_hook.ts': prUrlReminderMain as ConcernMain,
76
77
  'src/scripts/session_canary_hook.ts': sessionCanaryMain as ConcernMain,
78
+ 'src/scripts/session_register_hook.ts': sessionRegisterMain as ConcernMain,
77
79
  'src/scripts/language_mirror_hook.ts': languageMirrorMain as ConcernMain,
78
80
  'src/scripts/git_authorization_hook.ts': gitAuthorizationMain as ConcernMain,
79
81
  'src/scripts/hooks/block_unauthorized_git.ts': blockUnauthorizedGitMain as ConcernMain,
@@ -71,8 +71,20 @@ function enabled(root: string): boolean {
71
71
  return false;
72
72
  }
73
73
 
74
+ /**
75
+ * The dispatcher nests the host-shaped tool fields under `payload`; a bare host
76
+ * invocation puts them at the top level. Read both — this hook shipped reading
77
+ * only the top level, so under the dispatcher `extract` always returned null and
78
+ * the concern was silent on every write it was supposed to flag.
79
+ */
80
+ function _unwrap(envelope: JsonObject): JsonObject {
81
+ const inner = envelope["payload"];
82
+ return isObject(inner) ? inner : envelope;
83
+ }
84
+
74
85
  /** Best-effort: pull (toolName, filePath, proposedContent) from the PreToolUse envelope. */
75
- function extract(envelope: JsonObject): { file: string; content: string } | null {
86
+ function extract(outer: JsonObject): { file: string; content: string } | null {
87
+ const envelope = _unwrap(outer);
76
88
  const ti = envelope["tool_input"] ?? envelope["toolInput"] ?? envelope["input"];
77
89
  if (!isObject(ti)) return null;
78
90
  const fileVal = ti["file_path"] ?? ti["path"] ?? ti["filePath"];
@@ -231,6 +231,79 @@ interface ConcernDef extends JsonObject {
231
231
  name: string;
232
232
  }
233
233
 
234
+ /**
235
+ * The tool name the host reported for this event, or `""`.
236
+ *
237
+ * Only tool events carry one. Read defensively: the payload is host-shaped and
238
+ * a missing / non-string field must degrade to "unknown tool", never throw.
239
+ */
240
+ export function _payload_tool_name(envelope: JsonObject): string {
241
+ const payload = envelope["payload"];
242
+ if (!_isObject(payload)) {
243
+ return "";
244
+ }
245
+ const raw = payload["tool_name"];
246
+ return typeof raw === "string" ? raw : "";
247
+ }
248
+
249
+ /**
250
+ * Per-concern tool filter — the `tools:` key.
251
+ *
252
+ * 13 concerns fire on EVERY tool call (6 pre + 7 post), and three of them
253
+ * already re-read `tool_name` and return early — after the dispatcher has
254
+ * already spawned the work. A concern may now declare which tools it cares
255
+ * about, and the dispatcher skips it in-process for everything else.
256
+ *
257
+ * ## Why the filter is here and not in the generated host config
258
+ *
259
+ * The obvious alternative is a host `matcher` per concern group. Rejected on
260
+ * two measurements:
261
+ *
262
+ * - `build_claude_hook_matrix` collapses each event to ONE command string, and
263
+ * `claude_hook_matrix_parity.test.ts` asserts exactly one group per event with
264
+ * exactly one command. Per-concern matchers mean per-concern groups, which
265
+ * breaks that parity contract for a filter the dispatcher can apply itself.
266
+ * - A host matcher would only help the two hosts that support one (Claude,
267
+ * Gemini). The in-process skip helps all eight platforms in the manifest.
268
+ *
269
+ * ## What this does NOT claim
270
+ *
271
+ * It does not claim a latency win. The measured hook cost that was fixed was
272
+ * the *invocation path* (~370 of ~450–500 ms was eager CLI imports); nothing in
273
+ * the tree measures how much of the current ~84 ms p95 the concern bodies
274
+ * account for. `bench_hook_latency` reads the manifest, so the claim is
275
+ * benchable — it is simply not yet benched, and is therefore not asserted.
276
+ * What IS true without a benchmark: a concern that cannot fire on a tool no
277
+ * longer runs at all, instead of running and returning early.
278
+ *
279
+ * Absent `tools:` → the concern runs on every event, unchanged. `"*"` is the
280
+ * explicit form of the same thing. Matching is exact on the host's `tool_name`.
281
+ * A non-tool event (session_start, stop, …) reports no tool name and is NEVER
282
+ * filtered — a lifecycle concern must not be silently skipped by a key that
283
+ * only describes tool events.
284
+ */
285
+ export function _concern_matches_tool(concern: JsonObject, tool_name: string): boolean {
286
+ const raw = concern["tools"];
287
+ if (raw === undefined || raw === null) {
288
+ return true;
289
+ }
290
+ if (!Array.isArray(raw) || raw.length === 0) {
291
+ // A malformed or empty filter must not silence a concern — fail toward
292
+ // running it, the same direction `fail_closed` guards take.
293
+ return true;
294
+ }
295
+ const names = raw.filter((t): t is string => typeof t === "string");
296
+ if (names.length === 0 || names.includes("*")) {
297
+ return true;
298
+ }
299
+ // No tool in the payload → not a tool event (or a host that omits it).
300
+ // Either way this key cannot decide, so it does not.
301
+ if (tool_name === "") {
302
+ return true;
303
+ }
304
+ return names.includes(tool_name);
305
+ }
306
+
234
307
  /**
235
308
  * Return the ordered concern definitions for (platform, event).
236
309
  */
@@ -995,7 +1068,13 @@ export function main(argv?: string[]): number {
995
1068
  // fixed-field schema (PII-exclusion-by-construction), and a concern's raw
996
1069
  // stderr is free-form content. This array is in-memory only.
997
1070
  const concern_messages: Array<{ rc: number; text: string }> = [];
1071
+ // Per-concern `tools:` filter (see _concern_matches_tool). Applied here so it
1072
+ // is one place, after the envelope exists and before any concern is spawned.
1073
+ const tool_name = _payload_tool_name(envelope);
998
1074
  for (const concern of concerns) {
1075
+ if (!_concern_matches_tool(concern, tool_name)) {
1076
+ continue;
1077
+ }
999
1078
  const concern_started = _now_iso();
1000
1079
  const { rc: rawRcResult, stderr: stderr_text, stdout: stdout_text, duration_ms } =
1001
1080
  _run_concern(concern, envelope);
@@ -1032,8 +1111,24 @@ export function main(argv?: string[]): number {
1032
1111
  typeof reply["reason"] === "string" && (reply["reason"] as string).trim()
1033
1112
  ? (reply["reason"] as string).trim()
1034
1113
  : stderr_text.trim();
1035
- if (stated) {
1036
- concern_messages.push({ rc, text: stated });
1114
+ // `additional_context` — the longer, model-facing half of a concern's
1115
+ // reply. The contract has promised since v1 that it is forwarded and
1116
+ // "concatenated with \n\n separators"
1117
+ // (docs/contracts/hook-architecture-v1.md § reply shape), but nothing here
1118
+ // ever read the key: `reason` is capped at ~200 chars by convention and is
1119
+ // written for a human reading stderr, so every concern that put its
1120
+ // actionable instruction in `additional_context` delivered it nowhere.
1121
+ // Found 2026-08-06 by tracing why the language-mirror pin reached the model
1122
+ // on no path; `pr_url_reminder` had the same silent gap, and its own header
1123
+ // calls `additional_context` "what surfaces back to the model".
1124
+ const extra =
1125
+ typeof reply["additional_context"] === "string" &&
1126
+ (reply["additional_context"] as string).trim()
1127
+ ? (reply["additional_context"] as string).trim()
1128
+ : "";
1129
+ const message = [stated, extra].filter(Boolean).join("\n\n");
1130
+ if (message) {
1131
+ concern_messages.push({ rc, text: message });
1037
1132
  }
1038
1133
  if (
1039
1134
  args.event === "session_start" &&
@@ -52,7 +52,14 @@ import { atomic_write_json } from "./state_io.js";
52
52
  import { readHookStdin } from "./hook_stdin.js";
53
53
 
54
54
  const EXIT_ALLOW = 0;
55
- const EXIT_BLOCK = 2;
55
+ // MUST equal dispatch_hook.EXIT_BLOCK. The dispatcher's internal ladder is
56
+ // 0 allow / 1 block / 2 warn — NOT the 2-means-block shape a PreToolUse guard
57
+ // reads naturally from Claude's own native contract. This constant was 2 when
58
+ // this gate first shipped, so the dispatcher reduced every refusal to a WARN
59
+ // and the gate emitted advisory context while the operation went through.
60
+ // Pinned against the dispatcher's export by
61
+ // tests/hooks/concern_block_exit_parity.test.ts.
62
+ const EXIT_BLOCK = 1;
56
63
 
57
64
  export const STATE_FILE = path.join("agents", "state", "evidence-dispatch.json");
58
65
 
@@ -77,7 +84,7 @@ const DISPATCH_TOOLS: ReadonlySet<string> = new Set([
77
84
 
78
85
  /** The dispatch is an EVALUATION of work rather than ordinary fan-out. */
79
86
  const EVALUATION_RE =
80
- /\b(review|reviewer|audit|auditor|judge|verdict|blind[- ]pass|blind review|adversarial|critique|assess(ment)?|verify (my|the) (work|change|diff|implementation)|find (any )?(bugs|issues|defects|problems) in (my|the))\b/i;
87
+ /\b(review|reviewer|audit|auditor|judge|verdict|blind[- ]pass|blind review|adversarial|critique|assess(ment)?|verify (my|the) (work|change|diff|implementation)|find (any )?(bugs|issues|defects|problems) in (my|the)|pr(ü|ue)f(e|ung)?|begutachte|gutachten|bewerte|(gegen)?check(e)?|durchsicht|review(e|iere))\b/i;
81
88
 
82
89
  /**
83
90
  * Verdict pre-loading. Each pattern is a phrase that tells the evaluator what
@@ -85,6 +92,16 @@ const EVALUATION_RE =
85
92
  * fabricated honest-null.
86
93
  */
87
94
  const PRELOADED_VERDICT_RE = [
95
+ // German — the operator writes German, and an English-only list meant no
96
+ // German dispatch was ever screened at all.
97
+ /\bich erwarte (hier )?(keine|nichts)\b/i,
98
+ /\b(du )?(wirst|solltest) (hier )?nichts finden\b/i,
99
+ /\bbest(ä|ae)tige (nur|kurz)\b/i,
100
+ /\b(ist|sollte) (wahrscheinlich|vermutlich|sicher) (sauber|korrekt|in ordnung|fine)\b/i,
101
+ /\bkeine findings? (erwartet|zu erwarten)\b/i,
102
+ // English paraphrases of the audited construct.
103
+ /\bi am confident there is nothing\b/i,
104
+ /\ba clean pass is the likely\b/i,
88
105
  /\bno[- ]findings? (is|are) (expected|welcome|fine|acceptable|the likely)/i,
89
106
  /\b(you )?(should|will|probably) find (nothing|no (issues|problems|bugs|findings))/i,
90
107
  /\bconfirm (that )?there (are|is) no\b/i,
@@ -162,6 +179,20 @@ interface DispatchState extends JsonObject {
162
179
  evaluations: JsonValue[];
163
180
  }
164
181
 
182
+ /** The current user turn's stamp, or "" when no ledger exists yet. */
183
+ function _ledgerStamp(consumer_root: string): string {
184
+ try {
185
+ const raw = fs.readFileSync(
186
+ path.join(consumer_root, "agents", "state", "git-authorization.json"),
187
+ "utf8",
188
+ );
189
+ const d = JSON.parse(raw) as Record<string, unknown>;
190
+ return typeof d["detected_at"] === "string" ? d["detected_at"] : "";
191
+ } catch {
192
+ return "";
193
+ }
194
+ }
195
+
165
196
  function _load(target: string): DispatchState {
166
197
  try {
167
198
  const decoded = JSON.parse(fs.readFileSync(target, "utf8")) as unknown;
@@ -258,10 +289,13 @@ export function run(stdin_text: string, options: { consumer_root: string }): num
258
289
  const state = _load(target);
259
290
  const session_id = typeof envelope["session_id"] === "string" ? envelope["session_id"] : "";
260
291
 
261
- // The authorization ledger owns turn boundaries; this guard reads its own
262
- // marker so it never depends on another concern's ordering.
263
- const turnMarker =
264
- typeof envelope["turn_id"] === "string" ? (envelope["turn_id"] as string) : session_id;
292
+ // Turn boundary. `turn_id` does NOT exist in the production envelope — the
293
+ // dispatcher never sets it, so the original marker fell through to
294
+ // `session_id` and the counter was session-scoped: one self-review anywhere
295
+ // in a session blocked every later one. The only per-user-turn stamp that
296
+ // actually exists is `detected_at` in the authorization ledger, which
297
+ // `git_authorization_hook` rewrites on every `user_prompt_submit`.
298
+ const turnMarker = `${session_id}:${_ledgerStamp(options.consumer_root)}`;
265
299
  if (state.session_id !== turnMarker) {
266
300
  state.session_id = turnMarker;
267
301
  state.turn_started_at = new Date().toISOString();