@event4u/agent-config 9.27.0 → 9.29.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 (220) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/AGENTS.md +13 -12
  3. package/CHANGELOG.md +165 -0
  4. package/README.md +1 -1
  5. package/agents/templates/.ai-council.yml.example +55 -24
  6. package/dist/agent-src/commands/analyze/conformance.md +24 -0
  7. package/dist/agent-src/commands/cost/report.md +2 -2
  8. package/dist/agent-src/commands/team/adversarial.md +8 -7
  9. package/dist/agent-src/commands/team/delegate.md +13 -12
  10. package/dist/agent-src/commands/team/review.md +9 -8
  11. package/dist/agent-src/commands/team/status.md +9 -8
  12. package/dist/agent-src/commands/team.md +29 -19
  13. package/dist/agent-src/contexts/execution/auto-dispatch-classification.md +88 -2
  14. package/dist/agent-src/contexts/execution/auto-orchestration-activation.md +29 -21
  15. package/dist/agent-src/contexts/execution/host-capability-manifest.md +26 -15
  16. package/dist/agent-src/contexts/execution/orchestration-telemetry.md +28 -3
  17. package/dist/agent-src/contexts/execution/subagent-response-contract.md +54 -0
  18. package/dist/agent-src/contexts/execution/subagent-routing.md +13 -11
  19. package/dist/agent-src/contexts/execution/subagent-spawn-contract.md +25 -0
  20. package/dist/agent-src/contexts/execution/subagent-steering.md +6 -3
  21. package/dist/agent-src/rules/artifact-engagement-recording.md +4 -1
  22. package/dist/agent-src/rules/autonomous-execution.md +1 -1
  23. package/dist/agent-src/rules/brand-source-of-truth.md +33 -6
  24. package/dist/agent-src/rules/context-hygiene.md +3 -1
  25. package/dist/agent-src/rules/council-availability.md +93 -0
  26. package/dist/agent-src/rules/cross-source-consistency.md +3 -0
  27. package/dist/agent-src/rules/delegation-policy.md +38 -10
  28. package/dist/agent-src/rules/design-fidelity.md +3 -0
  29. package/dist/agent-src/rules/evaluator-independence.md +2 -1
  30. package/dist/agent-src/rules/external-code-graph-interop.md +2 -1
  31. package/dist/agent-src/rules/finance-safety-floor.md +1 -1
  32. package/dist/agent-src/rules/git-history-discipline.md +1 -1
  33. package/dist/agent-src/rules/history-discipline.md +3 -1
  34. package/dist/agent-src/rules/legal-safety-floor.md +1 -1
  35. package/dist/agent-src/rules/no-pr-progress-comments.md +3 -1
  36. package/dist/agent-src/rules/onboarding-gate.md +3 -1
  37. package/dist/agent-src/rules/roadmap-ci-steps-policy.md +4 -1
  38. package/dist/agent-src/rules/role-mode-adherence.md +1 -1
  39. package/dist/agent-src/rules/scale-discipline.md +3 -1
  40. package/dist/agent-src/rules/self-repair-loop.md +103 -0
  41. package/dist/agent-src/rules/session-canary.md +2 -1
  42. package/dist/agent-src/rules/settings-ask-protocol.md +8 -7
  43. package/dist/agent-src/rules/strategy-safety-floor.md +1 -1
  44. package/dist/agent-src/rules/token-budget-discipline.md +4 -2
  45. package/dist/agent-src/rules/tool-safety.md +5 -1
  46. package/dist/agent-src/skills/adversarial-review/SKILL.md +1 -1
  47. package/dist/agent-src/skills/analysis-autonomous-mode/SKILL.md +1 -1
  48. package/dist/agent-src/skills/forensics-report/SKILL.md +114 -0
  49. package/dist/agent-src/skills/forensics-report/evals/triggers.json +17 -0
  50. package/dist/agent-src/skills/performance-analysis/SKILL.md +1 -1
  51. package/dist/agent-src/skills/persona-improvement/SKILL.md +1 -1
  52. package/dist/agent-src/skills/premortem/SKILL.md +30 -1
  53. package/dist/agent-src/skills/project-analyzer/SKILL.md +1 -1
  54. package/dist/agent-src/skills/reasoning-orchestrator/SKILL.md +2 -1
  55. package/dist/agent-src/skills/security-audit/SKILL.md +1 -1
  56. package/dist/agent-src/skills/sequential-thinking/SKILL.md +1 -1
  57. package/dist/agent-src/skills/skill-improvement-pipeline/SKILL.md +1 -1
  58. package/dist/agent-src/skills/subagent-orchestration/SKILL.md +7 -5
  59. package/dist/agent-src/skills/subagent-orchestration/schemas/subagent-status.json +61 -3
  60. package/dist/agent-src/skills/test-case-discovery/SKILL.md +1 -1
  61. package/dist/agent-src/skills/universal-project-analysis/SKILL.md +1 -1
  62. package/dist/agent-src/templates/AGENTS.md +8 -12
  63. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  64. package/dist/agent-src/templates/roadmaps.md +23 -0
  65. package/dist/cli/registry.js +7 -0
  66. package/dist/cli/registry.js.map +1 -1
  67. package/dist/cli-delegate/chunk-66OVWGB5.js +270 -0
  68. package/dist/cli-delegate/{chunk-HJ6DAJHR.js → chunk-LCFJIQ4J.js} +89 -7
  69. package/dist/cli-delegate/{chunk-VSYT7OHO.js → chunk-LVRYBKTI.js} +31 -2
  70. package/dist/cli-delegate/{chunk-DVRS2KVR.js → chunk-MMXA5GY7.js} +10 -17
  71. package/dist/cli-delegate/{chunk-WR6GTRHJ.js → chunk-MP3JT3IU.js} +3 -3
  72. package/dist/cli-delegate/{chunk-3KJIMFTB.js → chunk-NZ5N2XQL.js} +467 -305
  73. package/dist/cli-delegate/{chunk-AIIUU73N.js → chunk-OJM3MBJM.js} +1 -1
  74. package/dist/cli-delegate/{chunk-BCPIOAGV.js → chunk-RCIA67XN.js} +117 -96
  75. package/dist/cli-delegate/chunk-REKCMYOU.js +2991 -0
  76. package/dist/cli-delegate/cmd_analyze_session.js +1 -1
  77. package/dist/cli-delegate/cmd_brand_status.js +134 -0
  78. package/dist/cli-delegate/cmd_conformance.js +10 -47
  79. package/dist/cli-delegate/cmd_converge.js +3 -3
  80. package/dist/cli-delegate/cmd_doctor.js +10 -6
  81. package/dist/cli-delegate/cmd_explain.js +80 -330
  82. package/dist/cli-delegate/cmd_export.js +6 -6
  83. package/dist/cli-delegate/cmd_fleet.js +3 -3
  84. package/dist/cli-delegate/cmd_handoff.js +3 -3
  85. package/dist/cli-delegate/cmd_mcp_available.js +433 -0
  86. package/dist/cli-delegate/cmd_migrate.js +3 -3
  87. package/dist/cli-delegate/cmd_packs_active.js +153 -0
  88. package/dist/cli-delegate/cmd_preflight.js +1 -1
  89. package/dist/cli-delegate/cmd_prune.js +6 -6
  90. package/dist/cli-delegate/cmd_route_audit.js +8 -8
  91. package/dist/cli-delegate/cmd_route_explain.js +4 -4
  92. package/dist/cli-delegate/cmd_settings_get.js +266 -0
  93. package/dist/cli-delegate/cmd_settings_migrate.js +6 -6
  94. package/dist/cli-delegate/cmd_settings_set.js +8 -2968
  95. package/dist/cli-delegate/cmd_sync.js +6 -6
  96. package/dist/cli-delegate/cmd_uninstall.js +6 -6
  97. package/dist/cli-delegate/cmd_update.js +4 -4
  98. package/dist/cli-delegate/cmd_upgrade.js +4 -4
  99. package/dist/cli-delegate/cmd_validate.js +6 -6
  100. package/dist/cli-delegate/cmd_versions.js +4 -4
  101. package/dist/discovery/deprecation-report.md +1 -1
  102. package/dist/discovery/discovery-manifest.json +164 -55
  103. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  104. package/dist/discovery/discovery-manifest.summary.md +4 -3
  105. package/dist/discovery/orphan-report.md +1 -1
  106. package/dist/discovery/packs.json +44 -5
  107. package/dist/discovery/trust-report.md +11 -11
  108. package/dist/discovery/workspaces.json +16 -6
  109. package/dist/hooks/dispatch.js +9341 -6131
  110. package/dist/install/install.mjs +739 -524
  111. package/dist/mcp/registry-manifest.json +3 -3
  112. package/dist/mcp/server.json +2 -2
  113. package/dist/mcp/server.mjs +777 -281
  114. package/dist/router.json +1 -1
  115. package/dist/scripts/_lib/agent_settings.js +134 -15
  116. package/dist/scripts/_lib/agent_settings.js.map +1 -1
  117. package/dist/scripts/condense.js +147 -2
  118. package/dist/scripts/condense.js.map +1 -1
  119. package/dist/server/schemas/settings.js +6 -7
  120. package/dist/server/schemas/settings.js.map +1 -1
  121. package/dist/shared/settingsCarveOut.js +0 -9
  122. package/dist/shared/settingsCarveOut.js.map +1 -1
  123. package/dist/shared/settingsClasses.js +11 -9
  124. package/dist/shared/settingsClasses.js.map +1 -1
  125. package/dist/ui/assets/{index-DmR0mbk4.js → index-JDAF00bC.js} +2 -2
  126. package/dist/ui/assets/{index-DmR0mbk4.js.map → index-JDAF00bC.js.map} +1 -1
  127. package/dist/ui/index.html +1 -1
  128. package/docs/contracts/settings-classes.md +68 -17
  129. package/docs/guidelines/agent-infra/memory-access.md +14 -3
  130. package/package.json +1 -1
  131. package/src/agent-src/templates/AGENTS.md +8 -12
  132. package/src/config/agent-settings.template.yml +52 -66
  133. package/src/config/budgets.yml +29 -0
  134. package/src/config/discovery/packs.yml +11 -0
  135. package/src/config/discovery/workspaces.yml +1 -1
  136. package/src/config/evaluator-budgets.json +4 -4
  137. package/src/config/gate-coverage.yml +26 -0
  138. package/src/config/gate-violation-baselines.json +3 -3
  139. package/src/scripts/_cli/cmd_brand_status.ts +174 -0
  140. package/src/scripts/_cli/cmd_conformance.ts +14 -42
  141. package/src/scripts/_cli/cmd_doctor.ts +65 -48
  142. package/src/scripts/_cli/cmd_mcp_available.ts +266 -0
  143. package/src/scripts/_cli/cmd_packs_active.ts +225 -0
  144. package/src/scripts/_cli/cmd_settings_get.ts +324 -0
  145. package/src/scripts/_dispatch.bash +57 -0
  146. package/src/scripts/_lib/agent_settings.ts +140 -16
  147. package/src/scripts/_lib/auto_dispatch.corpus.test.ts +1 -1
  148. package/src/scripts/_lib/auto_dispatch.ts +23 -18
  149. package/src/scripts/_lib/capsule_trigger.ts +124 -0
  150. package/src/scripts/_lib/carrier_divergence.ts +135 -0
  151. package/src/scripts/_lib/host_capability.ts +144 -4
  152. package/src/scripts/_lib/judgment_ladder.ts +382 -0
  153. package/src/scripts/_lib/orchestration_gate.ts +8 -0
  154. package/src/scripts/_lib/orchestration_record.ts +37 -0
  155. package/src/scripts/_lib/orchestration_savings.ts +1 -1
  156. package/src/scripts/_lib/prompt_shape.ts +88 -0
  157. package/src/scripts/_lib/review_skipped_record.ts +102 -0
  158. package/src/scripts/_lib/rule_layer_overlap.ts +254 -0
  159. package/src/scripts/_lib/self_repair.ts +489 -0
  160. package/src/scripts/_lib/self_repair_store.ts +128 -0
  161. package/src/scripts/_lib/subagent_capsule.ts +182 -0
  162. package/src/scripts/_lib/subagent_response.ts +19 -0
  163. package/src/scripts/_lib/subagent_spawn.ts +13 -0
  164. package/src/scripts/_lib/subagent_steering.ts +9 -4
  165. package/src/scripts/_lib/tier_budget_routing.ts +6 -1
  166. package/src/scripts/_lib/transcript_entry.ts +80 -0
  167. package/src/scripts/_lib/worker_budget.ts +37 -2
  168. package/src/scripts/ai_council/clients.ts +76 -54
  169. package/src/scripts/ai_council/config.ts +94 -6
  170. package/src/scripts/ai_council/handoff.ts +113 -0
  171. package/src/scripts/ai_council/modes.ts +19 -4
  172. package/src/scripts/ai_council/orchestrator.ts +105 -0
  173. package/src/scripts/ai_council/quorum.ts +84 -0
  174. package/src/scripts/ai_council/session.ts +44 -0
  175. package/src/scripts/ai_council/transport_resolver.ts +127 -1
  176. package/src/scripts/ai_team/availability.ts +110 -0
  177. package/src/scripts/ai_team/config.ts +34 -14
  178. package/src/scripts/ai_team/team_dispatch.ts +71 -29
  179. package/src/scripts/bench_critic_protocol.ts +396 -0
  180. package/src/scripts/check_branch_freshness.ts +255 -0
  181. package/src/scripts/check_council_config_location.ts +125 -1
  182. package/src/scripts/check_detector_corpus.ts +202 -0
  183. package/src/scripts/check_hook_bundle_freshness.ts +4 -1
  184. package/src/scripts/check_standing_rule_delivery.ts +314 -0
  185. package/src/scripts/condense.ts +162 -2
  186. package/src/scripts/config/profiles.ts +13 -3
  187. package/src/scripts/conformance_scan.ts +272 -24
  188. package/src/scripts/council_availability_hook.ts +188 -0
  189. package/src/scripts/council_cli.ts +527 -32
  190. package/src/scripts/forensics_report.ts +333 -0
  191. package/src/scripts/hook_manifest.yaml +107 -26
  192. package/src/scripts/hooks/block_no_verify.ts +39 -2
  193. package/src/scripts/hooks/block_unauthorized_git.ts +156 -7
  194. package/src/scripts/hooks/concern_registry.ts +10 -0
  195. package/src/scripts/hooks/delegation_nudge_hook.ts +484 -0
  196. package/src/scripts/hooks/end_review_nudge_hook.ts +726 -0
  197. package/src/scripts/hooks/orchestration_record_hook.ts +342 -0
  198. package/src/scripts/install.ts +151 -0
  199. package/src/scripts/language_mirror_hook.ts +30 -37
  200. package/src/scripts/lint_discovery_vocabulary.ts +2 -0
  201. package/src/scripts/lint_never_silent.ts +276 -0
  202. package/src/scripts/lint_no_activation_gates.ts +271 -0
  203. package/src/scripts/measure_scope_dedup.ts +19 -13
  204. package/src/scripts/onboarding_gate_hook.ts +40 -1
  205. package/src/scripts/orchestration_record.ts +12 -1
  206. package/src/scripts/preamble_byte_census.ts +9 -20
  207. package/src/scripts/rebuild_hook_bundle.ts +177 -0
  208. package/src/scripts/release.ts +74 -2
  209. package/src/scripts/report_carrier_divergence.ts +364 -0
  210. package/src/scripts/report_skill_activation.ts +257 -0
  211. package/src/scripts/report_skill_obligation_violations.ts +579 -0
  212. package/src/scripts/routing_doctor.ts +95 -39
  213. package/src/scripts/score_skill_selection.ts +19 -5
  214. package/src/scripts/self_repair_cli.ts +470 -0
  215. package/src/scripts/self_repair_hook.ts +241 -0
  216. package/src/scripts/team_events_spike.ts +271 -0
  217. package/src/scripts/team_review_gate_hook.ts +31 -9
  218. package/src/server/schemas/settings.ts +10 -17
  219. package/src/shared/settingsCarveOut.ts +0 -10
  220. package/src/shared/settingsClasses.ts +11 -9
@@ -0,0 +1,333 @@
1
+ /**
2
+ * Forensic analyzers — hotspot risk + change coupling from `git log` alone
3
+ * (road-to-judgment-and-forensic-evidence Phase 3; pack `forensics`,
4
+ * default-off, read-only, advisory).
5
+ *
6
+ * Two deterministic analyzers over a PINNED revision range:
7
+ *
8
+ * hotspot(file) = norm(change_frequency) × norm(complexity)
9
+ * complexity = LOC + total indentation units (tabs = 1,
10
+ * 4 spaces = 1) of the file in the working tree — a cheap,
11
+ * language-agnostic indentation proxy, documented rather
12
+ * than clever. A hotspot is a place to LOOK, never a code
13
+ * quality verdict (both inputs can be correct for a file).
14
+ *
15
+ * coupling(A,B) = co_changes(A,B) / min(changes(A), changes(B))
16
+ * over commits with ≤ max_commit_files files (bulk sweeps
17
+ * poison coupling; skipped commits are counted in the
18
+ * report, never silently dropped).
19
+ *
20
+ * boundary_contradictions — coupling pairs ABOVE the threshold whose files
21
+ * live in different top-level modules. Recorded as a finding CLASS (the
22
+ * intended boundaries and the actual change pattern disagree), not as a
23
+ * failure — per the roadmap's step 3.6.
24
+ *
25
+ * Determinism bar (step 3.3): byte-stable output on a frozen fixture — same
26
+ * inputs, same bytes. Inputs are the git log for the pinned range plus file
27
+ * metrics; `--log-file` + `--metrics-file` inject both for fixture tests, so
28
+ * the analyzer's whole pipeline is exercised without a live repo. All maps
29
+ * are sorted before emission; scores are rounded to 4 decimals.
30
+ *
31
+ * Advisory wiring (step 3.4): `--findings-out` emits
32
+ * review-findings.schema.json-shaped items with kind `correctness` and
33
+ * severity `low|medium` — non-blocking by construction in
34
+ * `check_finding_dispositions.isBlocking` — for ingestion into the existing
35
+ * release findings ledger (`check_finding_dispositions --ingest`).
36
+ *
37
+ * Gaming risk: an empty scan (dead range, wrong cwd) yielding a green "no
38
+ * hotspots" report — mitigated by the shared scan-scope helper: zero scanned
39
+ * commits throws (DeadScopeError), it never publishes an empty finding set
40
+ * (step 3.2). Residual: a range that is real but trivially small still
41
+ * produces low-signal output; the report publishes commit + file counts so a
42
+ * reader can judge the denominator.
43
+ */
44
+ // ledger-exempt: on-demand advisory analyzer, not a CI gate — the report JSON
45
+ // itself carries the full denominator (scanned commits/files) per run.
46
+ import * as fs from 'node:fs';
47
+ import { createHash } from 'node:crypto';
48
+ import { execFileSync } from 'node:child_process';
49
+
50
+ import { reportScanned } from './_lib/scan_scope.js';
51
+
52
+ interface Params {
53
+ max_commit_files: number;
54
+ coupling_threshold: number;
55
+ min_cochanges: number;
56
+ top_hotspots: number;
57
+ }
58
+ const DEFAULTS: Params = {
59
+ max_commit_files: 50,
60
+ coupling_threshold: 0.5,
61
+ min_cochanges: 3,
62
+ top_hotspots: 25,
63
+ };
64
+
65
+ interface Commit {
66
+ hash: string;
67
+ files: string[];
68
+ }
69
+
70
+ /** Parse `git log --pretty=format:'C %H' --name-only` output. */
71
+ export function parseLog(text: string): Commit[] {
72
+ const commits: Commit[] = [];
73
+ let current: Commit | null = null;
74
+ for (const raw of text.split('\n')) {
75
+ const line = raw.trimEnd();
76
+ if (line.startsWith('C ')) {
77
+ if (current) commits.push(current);
78
+ current = { hash: line.slice(2).trim(), files: [] };
79
+ } else if (line.length > 0 && current) {
80
+ current.files.push(line);
81
+ }
82
+ }
83
+ if (current) commits.push(current);
84
+ return commits;
85
+ }
86
+
87
+ function gitLog(range: string): string {
88
+ return execFileSync('git', ['log', '--pretty=format:C %H', '--name-only', range], {
89
+ encoding: 'utf8',
90
+ maxBuffer: 256 * 1024 * 1024,
91
+ });
92
+ }
93
+
94
+ /** LOC + indentation units — the documented complexity proxy. */
95
+ export function complexityOf(content: string): number {
96
+ const lines = content.split('\n');
97
+ let indent = 0;
98
+ for (const l of lines) {
99
+ const m = /^[\t ]*/.exec(l);
100
+ const ws = m ? m[0] : '';
101
+ let units = 0;
102
+ let spaces = 0;
103
+ for (const ch of ws) {
104
+ if (ch === '\t') units += 1;
105
+ else spaces += 1;
106
+ }
107
+ units += Math.floor(spaces / 4);
108
+ indent += units;
109
+ }
110
+ return lines.length + indent;
111
+ }
112
+
113
+ function liveComplexity(file: string): number | null {
114
+ try {
115
+ const st = fs.statSync(file);
116
+ if (!st.isFile()) return null;
117
+ const buf = fs.readFileSync(file);
118
+ if (buf.includes(0)) return null; // binary
119
+ return complexityOf(buf.toString('utf8'));
120
+ } catch {
121
+ return null; // deleted / renamed away — frequency without complexity
122
+ }
123
+ }
124
+
125
+ function round4(n: number): number {
126
+ return Math.round(n * 10000) / 10000;
127
+ }
128
+
129
+ /** First path segment — the module boundary the router treats as independent. */
130
+ export function moduleOf(file: string): string {
131
+ const parts = file.split('/');
132
+ if (parts.length >= 2 && (parts[0] === 'src' || parts[0] === 'tests' || parts[0] === 'docs')) {
133
+ return `${parts[0]}/${parts[1] ?? ''}`;
134
+ }
135
+ return parts[0] ?? file;
136
+ }
137
+
138
+ interface Hotspot {
139
+ file: string;
140
+ changes: number;
141
+ complexity: number | null;
142
+ score: number;
143
+ }
144
+ interface CouplingPair {
145
+ a: string;
146
+ b: string;
147
+ co_changes: number;
148
+ changes_a: number;
149
+ changes_b: number;
150
+ coupling: number;
151
+ cross_module: boolean;
152
+ }
153
+
154
+ export interface Report {
155
+ mode: string;
156
+ range: string;
157
+ params: Params;
158
+ scanned: { commits: number; commits_skipped_bulk: number; files: number };
159
+ hotspots: Hotspot[];
160
+ coupling: CouplingPair[];
161
+ boundary_contradictions: CouplingPair[];
162
+ }
163
+
164
+ export function analyze(
165
+ commits: Commit[],
166
+ params: Params,
167
+ complexity: (file: string) => number | null,
168
+ range: string,
169
+ mode: string,
170
+ ): Report {
171
+ const changes = new Map<string, number>();
172
+ const co = new Map<string, number>();
173
+ let skipped = 0;
174
+ for (const c of commits) {
175
+ const files = [...new Set(c.files)].sort();
176
+ if (files.length > params.max_commit_files) {
177
+ skipped++;
178
+ continue;
179
+ }
180
+ for (const f of files) changes.set(f, (changes.get(f) ?? 0) + 1);
181
+ for (let i = 0; i < files.length; i++) {
182
+ for (let j = i + 1; j < files.length; j++) {
183
+ const key = `${files[i]}\u0000${files[j]}`;
184
+ co.set(key, (co.get(key) ?? 0) + 1);
185
+ }
186
+ }
187
+ }
188
+
189
+ const files = [...changes.keys()].sort();
190
+ const complexities = new Map<string, number | null>();
191
+ for (const f of files) complexities.set(f, complexity(f));
192
+ let maxChanges = 0;
193
+ for (const v of changes.values()) if (v > maxChanges) maxChanges = v;
194
+ let maxComplexity = 0;
195
+ for (const v of complexities.values()) if (v !== null && v > maxComplexity) maxComplexity = v;
196
+
197
+ const hotspots: Hotspot[] = files
198
+ .map((f) => {
199
+ const ch = changes.get(f) ?? 0;
200
+ const cx = complexities.get(f) ?? null;
201
+ const score =
202
+ cx === null || maxChanges === 0 || maxComplexity === 0
203
+ ? 0
204
+ : round4((ch / maxChanges) * (cx / maxComplexity));
205
+ return { file: f, changes: ch, complexity: cx, score };
206
+ })
207
+ .filter((h) => h.score > 0)
208
+ .sort((a, b) => b.score - a.score || a.file.localeCompare(b.file))
209
+ .slice(0, params.top_hotspots);
210
+
211
+ const coupling: CouplingPair[] = [];
212
+ for (const [key, n] of [...co.entries()].sort()) {
213
+ if (n < params.min_cochanges) continue;
214
+ const [a, b] = key.split('\u0000') as [string, string];
215
+ const ca = changes.get(a) ?? 0;
216
+ const cb = changes.get(b) ?? 0;
217
+ const denom = Math.min(ca, cb);
218
+ if (denom === 0) continue;
219
+ const strength = round4(n / denom);
220
+ if (strength < params.coupling_threshold) continue;
221
+ coupling.push({
222
+ a,
223
+ b,
224
+ co_changes: n,
225
+ changes_a: ca,
226
+ changes_b: cb,
227
+ coupling: strength,
228
+ cross_module: moduleOf(a) !== moduleOf(b),
229
+ });
230
+ }
231
+ coupling.sort((x, y) => y.coupling - x.coupling || x.a.localeCompare(y.a) || x.b.localeCompare(y.b));
232
+
233
+ return {
234
+ mode,
235
+ range,
236
+ params,
237
+ scanned: {
238
+ commits: commits.length - skipped,
239
+ commits_skipped_bulk: skipped,
240
+ files: files.length,
241
+ },
242
+ hotspots,
243
+ coupling,
244
+ boundary_contradictions: coupling.filter((p) => p.cross_module),
245
+ };
246
+ }
247
+
248
+ /** Same id convention as self_review_gate.findingId — the schema pins ^[0-9a-f]{12}$. */
249
+ function forensicsFindingId(kind: string, title: string, file: string): string {
250
+ return createHash('sha256').update(`${kind}|${title}|${file}`).digest('hex').slice(0, 12);
251
+ }
252
+
253
+ /** Findings-schema-shaped advisory items — non-blocking by construction. */
254
+ export function toFindings(report: Report): Record<string, unknown> {
255
+ const findings: Record<string, unknown>[] = [];
256
+ for (const h of report.hotspots.slice(0, 10)) {
257
+ const title = `hotspot: ${h.file} (score ${String(h.score)})`;
258
+ findings.push({
259
+ finding_id: forensicsFindingId('correctness', title, h.file),
260
+ severity: 'low',
261
+ kind: 'correctness',
262
+ title,
263
+ detail: `${String(h.changes)} changes in range × complexity ${String(h.complexity ?? 'n/a')} — a place to look, not a quality verdict.`,
264
+ file: h.file,
265
+ });
266
+ }
267
+ for (const p of report.boundary_contradictions) {
268
+ const title = `boundary contradiction: ${p.a} <-> ${p.b} (coupling ${String(p.coupling)})`;
269
+ findings.push({
270
+ finding_id: forensicsFindingId('correctness', title, p.a),
271
+ severity: 'medium',
272
+ kind: 'correctness',
273
+ title,
274
+ detail: `Cross-module co-change ${String(p.co_changes)}/${String(Math.min(p.changes_a, p.changes_b))} above threshold ${String(report.params.coupling_threshold)} — the intended module boundary and the actual change pattern disagree. A finding class, not a failure.`,
275
+ file: p.a,
276
+ });
277
+ }
278
+ return { schema_version: 1, findings };
279
+ }
280
+
281
+ function stringifyReport(report: Report): string {
282
+ return JSON.stringify(report, null, 2) + '\n';
283
+ }
284
+
285
+ function argValue(argv: string[], flag: string): string | null {
286
+ const i = argv.indexOf(flag);
287
+ return i >= 0 ? (argv[i + 1] ?? null) : null;
288
+ }
289
+
290
+ function main(argv: string[]): number {
291
+ const range = argValue(argv, '--range');
292
+ const logFile = argValue(argv, '--log-file');
293
+ const metricsFile = argValue(argv, '--metrics-file');
294
+ const outFile = argValue(argv, '--out');
295
+ const findingsOut = argValue(argv, '--findings-out');
296
+ if (!range && !logFile) {
297
+ process.stderr.write(
298
+ 'usage: forensics_report (--range <rev-range> | --log-file <fixture>) [--metrics-file <json>] [--out <file>] [--findings-out <file>]\n',
299
+ );
300
+ return 2;
301
+ }
302
+ const params: Params = { ...DEFAULTS };
303
+ const text = logFile ? fs.readFileSync(logFile, 'utf8') : gitLog(range as string);
304
+ const commits = parseLog(text);
305
+ let complexity: (file: string) => number | null = liveComplexity;
306
+ if (metricsFile) {
307
+ const metrics = JSON.parse(fs.readFileSync(metricsFile, 'utf8')) as Record<string, number>;
308
+ complexity = (f) => (Object.prototype.hasOwnProperty.call(metrics, f) ? metrics[f]! : null);
309
+ }
310
+ const report = analyze(commits, params, complexity, range ?? `log-file:${logFile ?? ''}`, 'run');
311
+
312
+ // Loud-fail on an empty scan BEFORE anything is written — a report over
313
+ // nothing is not a report, and it must not exist on disk either (throws
314
+ // DeadScopeError when zero commits were analyzed).
315
+ reportScanned({
316
+ gate: 'forensics_report',
317
+ scanned: report.scanned.commits,
318
+ units: 'commit(s)',
319
+ roots: [range ?? (logFile as string)],
320
+ });
321
+
322
+ const json = stringifyReport(report);
323
+ if (outFile) fs.writeFileSync(outFile, json);
324
+ process.stdout.write(json);
325
+ if (findingsOut) fs.writeFileSync(findingsOut, JSON.stringify(toFindings(report), null, 2) + '\n');
326
+ return 0;
327
+ }
328
+
329
+ if (import.meta.url === `file://${process.argv[1]}`) {
330
+ process.exit(main(process.argv.slice(2)));
331
+ }
332
+
333
+ export { main };
@@ -319,6 +319,29 @@ concerns:
319
319
  args: []
320
320
  fail_closed: false
321
321
  severity: advisory
322
+ # Council-availability fact. Carries whether a council is configured and from
323
+ # where, because the always-loaded layer refers to the council conditionally
324
+ # and never says how to check. Measured 2026-08-08: an agent announced "kein
325
+ # Council konfiguriert (keine .agent-settings.yml)" and substituted a weaker
326
+ # path; the council was configured user-globally the whole time, and the
327
+ # project tree is never searched for it (ADR-104). Advisory: supplies a fact,
328
+ # never blocks.
329
+ council-availability:
330
+ script: src/scripts/council_availability_hook.ts
331
+ args: []
332
+ fail_closed: false
333
+ severity: advisory
334
+ # Self-repair intake (road-to-self-repair Phase 1). Two slots, one queue:
335
+ # user_prompt_submit opens a record when the user states the agent worked
336
+ # wrongly and injects the open-queue line; stop runs the deterministic
337
+ # detectors over the finished turn and records silently (a reminder fired
338
+ # after the reply cannot shape it). ADVISORY by construction — it records,
339
+ # it never gates; the outward step is a separate, user-run CLI command.
340
+ self-repair:
341
+ script: src/scripts/self_repair_hook.ts
342
+ args: []
343
+ fail_closed: false
344
+ severity: advisory
322
345
  # Shared per-session register (road-to-parallel-session-coordination) — writes
323
346
  # this session into <git-common-dir>/agent-sessions/<session_id>.json so every
324
347
  # other worktree of the repo can see it, and on session_start injects the live
@@ -351,22 +374,70 @@ concerns:
351
374
  args: []
352
375
  fail_closed: false
353
376
  severity: advisory
377
+ # road-to-orchestrator-discipline-carriers Phase 3 (F6) — deterministic
378
+ # PostToolUse orchestration-telemetry capture. Fires only on an Agent/Task
379
+ # tool-use completion (the two observed subagent-dispatch tool names) and
380
+ # appends one `orchestration_record` line — no model step, replacing the
381
+ # measured 0.27% model-carried capture rate for future dispatches. Silent
382
+ # on every path (never writes to stdout); exit 0 always, including a
383
+ # malformed envelope or a write failure. fail_closed: false.
384
+ orchestration-record:
385
+ script: src/scripts/hooks/orchestration_record_hook.ts
386
+ args: []
387
+ fail_closed: false
388
+ severity: advisory
389
+ tools: [Agent, Task]
390
+ # road-to-orchestrator-discipline-carriers Phase 4 (F3-lite) — conditional
391
+ # delegation nudge. Extracts cheap structural signals from the SUBMITTED
392
+ # prompt (enumerated file lists, "for each"/"alle …" shapes, explicit slice
393
+ # counts, ordered-plan markers, multi-deliverable conjunctions), classifies
394
+ # via `classifyTask`, and — only on a positive do-in-parallel/do-in-steps
395
+ # verdict — reports `{decision:"warn", reason, additional_context}` at exit
396
+ # 2, mirroring `language-mirror`'s shipped delivery pattern so the verdict
397
+ # line actually reaches `hookSpecificOutput.additionalContext` on the
398
+ # verified `claude` platform. Silence is the anti-canary default: no
399
+ # enumerated signal → no injection at all. Never blocks the turn — see the
400
+ # hook's file header for the `host_semantics.emitFor` proof. fail_closed:
401
+ # false.
402
+ delegation-nudge:
403
+ script: src/scripts/hooks/delegation_nudge_hook.ts
404
+ args: []
405
+ fail_closed: false
406
+ severity: advisory
407
+ # road-to-orchestrator-discipline-carriers Phase 5 (F4-lite) — end-review
408
+ # nudge. Fires ONLY when the session mutated tracked+untracked, non-doc
409
+ # files beyond MUTATION_LINE_THRESHOLD (50 lines; untracked files added
410
+ # post-review, F6) AND no reviewer ran this session (scanned from the
411
+ # Stop payload's `transcript_path`) AND this session has not already
412
+ # fired once (F2 — a once-per-session gate; state under
413
+ # agents/runtime/state/end-review-nudge/, gitignored). On fire, reports
414
+ # `{decision:"warn", reason, additional_context}` at exit 2 (same delivery
415
+ # pattern as `language-mirror`/`delegation-nudge` above) and appends one
416
+ # `review_skipped` telemetry line — hook-carried, not model-carried — so a
417
+ # future blocking decision has a measured distribution to cite. Never
418
+ # blocks the turn — see the hook's file header for the `host_semantics.
419
+ # emitFor` proof. fail_closed: false.
420
+ end-review-nudge:
421
+ script: src/scripts/hooks/end_review_nudge_hook.ts
422
+ args: []
423
+ fail_closed: false
424
+ severity: advisory
354
425
 
355
426
  platforms:
356
427
  augment:
357
- 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, session-register]
428
+ session_start: [chat-history, hot-context, handoff-context, session-canary, council-availability, first-run-gate, onboarding-gate, verify-before-complete, minimal-safe-diff, profile-staleness, wrapper-freshness, surface-probe, session-register]
358
429
  session_end: [chat-history, memory-learn, session-register]
359
- stop: [chat-history, hot-context, verify-before-complete, session-register]
430
+ stop: [chat-history, hot-context, verify-before-complete, self-repair, session-register]
360
431
  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]
361
- post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan, pr-url-reminder]
432
+ post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan, pr-url-reminder, orchestration-record]
362
433
 
363
434
  claude:
364
- 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, session-register]
435
+ session_start: [chat-history, hot-context, handoff-context, session-canary, council-availability, first-run-gate, onboarding-gate, verify-before-complete, minimal-safe-diff, profile-staleness, wrapper-freshness, surface-probe, session-register]
365
436
  session_end: [chat-history, memory-learn, session-register]
366
- stop: [chat-history, hot-context, verify-before-complete, team-review-gate, session-register]
367
- user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff, language-mirror, git-authorization, session-canary, session-register]
437
+ stop: [chat-history, hot-context, verify-before-complete, team-review-gate, end-review-nudge, self-repair, session-register]
438
+ user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff, language-mirror, delegation-nudge, git-authorization, session-canary, self-repair, session-register]
368
439
  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]
369
- post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan, pr-url-reminder]
440
+ post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan, pr-url-reminder, orchestration-record]
370
441
 
371
442
  # Cowork — the Claude desktop app's local-agent-mode runtime, built
372
443
  # on top of the Claude Code CLI. Same lifecycle vocabulary, same
@@ -383,12 +454,22 @@ platforms:
383
454
  # Decision matrix + upstream blockers tracked in
384
455
  # agents/settings/contexts/chat-history-platform-hooks.md § Cowork.
385
456
  cowork:
386
- 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, session-register]
457
+ session_start: [chat-history, hot-context, handoff-context, session-canary, council-availability, first-run-gate, onboarding-gate, verify-before-complete, minimal-safe-diff, profile-staleness, wrapper-freshness, surface-probe, session-register]
387
458
  session_end: [chat-history, memory-learn, session-register]
388
- stop: [chat-history, hot-context, verify-before-complete, session-register]
389
- user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff, language-mirror, git-authorization, session-canary, session-register]
459
+ # end-review-nudge and delegation-nudge are DELIBERATELY NOT bound on
460
+ # this platform (F10, review) — cowork-dispatcher.sh's own header states
461
+ # it "always exit[s] 0" and its dispatch line discards the dispatcher's
462
+ # exit code AND stdout unconditionally (`>/dev/null 2>&1 || true`). Both
463
+ # concerns' delivery depends entirely on `additional_context` reaching
464
+ # the model via stdout at the verified exit-2 warn path — a delivery this
465
+ # trampoline makes structurally impossible regardless of which platform
466
+ # is later verified to forward it. Binding them here would be dead
467
+ # weight: the concern still runs (git diff, transcript scan, classifier),
468
+ # still costs the dispatch, and its entire output is thrown away.
469
+ stop: [chat-history, hot-context, verify-before-complete, self-repair, session-register]
470
+ user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff, language-mirror, git-authorization, session-canary, self-repair, session-register]
390
471
  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]
391
- post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan, pr-url-reminder]
472
+ post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan, pr-url-reminder, orchestration-record]
392
473
 
393
474
  # Phase 7.5 — Cursor. `.cursor/hooks.json` (project) is read by the
394
475
  # IDE and CLI; `~/.cursor/hooks.json` (user) is opt-in via
@@ -398,11 +479,11 @@ platforms:
398
479
  # IDE-only — CLI-only users fall back to /checkpoint per
399
480
  # agents/settings/contexts/chat-history-platform-hooks.md.
400
481
  cursor:
401
- 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, session-register]
482
+ session_start: [chat-history, hot-context, handoff-context, session-canary, council-availability, first-run-gate, onboarding-gate, verify-before-complete, minimal-safe-diff, profile-staleness, wrapper-freshness, surface-probe, session-register]
402
483
  session_end: [chat-history, memory-learn, session-register]
403
- stop: [chat-history, hot-context, verify-before-complete, session-register]
404
- user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff, language-mirror, git-authorization, session-canary, session-register]
405
- post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan, pr-url-reminder]
484
+ stop: [chat-history, hot-context, verify-before-complete, self-repair, session-register]
485
+ user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff, language-mirror, git-authorization, session-canary, self-repair, session-register]
486
+ post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan, pr-url-reminder, orchestration-record]
406
487
 
407
488
  # Phase 7.6 — Cline. Hooks live under `.clinerules/hooks/<HookName>`
408
489
  # (project, no file extension, must be executable per Cline docs) or
@@ -413,11 +494,11 @@ platforms:
413
494
  # both map to session_start. TaskCancel maps to stop because the
414
495
  # session is interrupted with partial state (mirrors Augment Stop).
415
496
  cline:
416
- 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, session-register]
497
+ session_start: [chat-history, hot-context, handoff-context, session-canary, council-availability, first-run-gate, onboarding-gate, verify-before-complete, minimal-safe-diff, profile-staleness, wrapper-freshness, surface-probe, session-register]
417
498
  session_end: [chat-history, memory-learn, session-register]
418
- stop: [chat-history, hot-context, verify-before-complete, session-register]
419
- user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff, language-mirror, git-authorization, session-canary, session-register]
420
- post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan, pr-url-reminder]
499
+ stop: [chat-history, hot-context, verify-before-complete, self-repair, session-register]
500
+ user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff, language-mirror, git-authorization, session-canary, self-repair, session-register]
501
+ post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan, pr-url-reminder, orchestration-record]
421
502
 
422
503
  # Phase 7.7 — Windsurf (Cascade). Hooks live at `.windsurf/hooks.json`
423
504
  # (project) or `~/.codeium/windsurf/hooks.json` (user). Cascade has
@@ -432,11 +513,11 @@ platforms:
432
513
  # surface to record verification commands; documented limitation).
433
514
  # minimal-safe-diff is omitted entirely on Windsurf for the same reason.
434
515
  windsurf:
435
- session_start: [chat-history, hot-context, handoff-context, session-canary, first-run-gate, onboarding-gate, verify-before-complete, profile-staleness, wrapper-freshness, surface-probe, session-register]
436
- stop: [chat-history, hot-context, verify-before-complete, session-register]
516
+ session_start: [chat-history, hot-context, handoff-context, session-canary, council-availability, first-run-gate, onboarding-gate, verify-before-complete, profile-staleness, wrapper-freshness, surface-probe, session-register]
517
+ stop: [chat-history, hot-context, verify-before-complete, self-repair, session-register]
437
518
  # language-mirror IS bound here: unlike minimal-safe-diff it needs only the
438
519
  # user_prompt_submit slot, which Windsurf has.
439
- user_prompt_submit: [chat-history, verify-before-complete, language-mirror, git-authorization, session-canary, session-register]
520
+ user_prompt_submit: [chat-history, verify-before-complete, language-mirror, git-authorization, session-canary, self-repair, session-register]
440
521
 
441
522
  # Phase 7.8 — Gemini CLI. Hooks live at `.gemini/settings.json`
442
523
  # (project) or `~/.gemini/settings.json` (user). Per Gemini docs
@@ -449,11 +530,11 @@ platforms:
449
530
  # turn-check semantics. AfterAgent fires when the agent loop ends
450
531
  # — this is our `stop` slot.
451
532
  gemini:
452
- 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, session-register]
533
+ session_start: [chat-history, hot-context, handoff-context, session-canary, council-availability, first-run-gate, onboarding-gate, verify-before-complete, minimal-safe-diff, profile-staleness, wrapper-freshness, surface-probe, session-register]
453
534
  session_end: [chat-history, memory-learn, session-register]
454
- stop: [chat-history, hot-context, verify-before-complete, session-register]
455
- user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff, language-mirror, git-authorization, session-canary, session-register]
456
- post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan, pr-url-reminder]
535
+ stop: [chat-history, hot-context, verify-before-complete, self-repair, session-register]
536
+ user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff, language-mirror, git-authorization, session-canary, self-repair, session-register]
537
+ post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan, pr-url-reminder, orchestration-record]
457
538
 
458
539
  # Phase 7.9 — Copilot has no hook surface. Concerns route through
459
540
  # rule-only fallback; the dispatcher silently no-ops on --platform copilot.
@@ -20,12 +20,21 @@
20
20
  * 2 — warn (not used by this guard)
21
21
  *
22
22
  * No ALLOW_NO_VERIFY-style env bypass is provided. See src/rules/git-history-discipline.md
23
+ *
24
+ * WHAT THIS GUARD DOES NOT SEE. Command substitutions ARE now checked — see
25
+ * `_check_command` for why they were not, and why the well-formed case was the
26
+ * unprotected one. What remains uncovered is an invocation whose command word
27
+ * only exists at runtime (`P=commit; git $P --no-verify` via indirection, or a
28
+ * word composed by `xargs`). Those execute under bash and are not classified
29
+ * here. Stated rather than left implied: this file's own round-5 defect was a
30
+ * docstring that described coverage it did not have.
23
31
  */
24
32
 
25
33
  import * as fs from 'node:fs';
26
34
  import { fileURLToPath, pathToFileURL } from 'node:url';
27
35
  import * as path from 'node:path';
28
36
  import { readHookStdin } from './hook_stdin.js';
37
+ import { substitutionPayloads } from './block_unauthorized_git.js';
29
38
 
30
39
  const _HERE = fileURLToPath(import.meta.url);
31
40
 
@@ -384,8 +393,36 @@ export function _looks_like_git_invocation(cmd: string, depth = 0): boolean {
384
393
  return false;
385
394
  }
386
395
 
387
- /** Return (blocked, reason). Fail-closed on parse error for git commands. */
388
- function _check_command(cmd: string): [boolean, string] {
396
+ /**
397
+ * Return (blocked, reason). Fail-closed on parse error for git commands.
398
+ *
399
+ * COMMAND SUBSTITUTIONS ARE CHECKED SEPARATELY, and the reason they were not is
400
+ * the shape of the hole: this file does split on `$(` and backtick, but only
401
+ * inside `_looks_like_git_invocation`, which runs exclusively in the
402
+ * shlex-failure branch. A well-formed `echo "$(git commit --no-verify)"` parses
403
+ * cleanly, so shlex succeeds, the substitution stays inside one token, and
404
+ * `_git_base` never sees git. The more parseable the command, the less it was
405
+ * protected. Classification inside the payload is by command position, so a
406
+ * substitution that INVOKES git is checked and one that merely names it in an
407
+ * argument is not — matching what `block_unauthorized_git` does one level up.
408
+ *
409
+ * Heredoc bodies are removed before extraction: a backtick or `$(` inside a
410
+ * commit message being written to a file is data, and feeding it back through
411
+ * this function is exactly the false positive the round-5 repair closed.
412
+ */
413
+ function _check_command(cmd: string, depth = 0): [boolean, string] {
414
+ if (depth <= 3) {
415
+ const withoutHeredocs = cmd.replace(
416
+ /<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1[\s\S]*?^\s*\2\s*$/gm,
417
+ '<<HEREDOC',
418
+ );
419
+ for (const payload of substitutionPayloads(withoutHeredocs)) {
420
+ const [blocked, reason] = _check_command(payload, depth + 1);
421
+ if (blocked) {
422
+ return [true, reason];
423
+ }
424
+ }
425
+ }
389
426
  let tokens: string[];
390
427
  try {
391
428
  tokens = shlexSplit(cmd);