@event4u/agent-config 14.18.0 → 14.20.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 (210) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +120 -129
  5. package/README.md +71 -149
  6. package/dist/agent-src/README.md +1 -1
  7. package/dist/agent-src/commands/agent-handoff.md +44 -43
  8. package/dist/agent-src/commands/review/changes.md +19 -19
  9. package/dist/agent-src/commands/review.md +2 -2
  10. package/dist/agent-src/contexts/execution/host-capability-manifest.md +37 -4
  11. package/dist/agent-src/rules/autonomous-execution.md +1 -1
  12. package/dist/agent-src/rules/git-history-discipline.md +2 -2
  13. package/dist/agent-src/rules/guidelines.md +1 -1
  14. package/dist/agent-src/rules/session-canary.md +2 -2
  15. package/dist/agent-src/rules/token-efficiency.md +1 -1
  16. package/dist/agent-src/rules/untrusted-input-defense.md +0 -2
  17. package/dist/agent-src/skills/adversarial-review/SKILL.md +0 -8
  18. package/dist/agent-src/skills/canvas-design/SKILL.md +1 -1
  19. package/dist/agent-src/skills/design-intelligence/SKILL.md +1 -1
  20. package/dist/agent-src/skills/design-intelligence/data/app-interface.csv +1 -1
  21. package/dist/agent-src/skills/design-intelligence/data/motion.csv +17 -17
  22. package/dist/agent-src/skills/design-intelligence/data/ux-guidelines.csv +2 -2
  23. package/dist/agent-src/skills/design-intelligence/references/context-and-registers.md +17 -0
  24. package/dist/agent-src/skills/design-intelligence/references/design-rules-checklist.md +4 -4
  25. package/dist/agent-src/skills/design-review/SKILL.md +1 -1
  26. package/dist/agent-src/skills/design-variations/SKILL.md +1 -1
  27. package/dist/agent-src/skills/evaluate-llm-feature/SKILL.md +1 -1
  28. package/dist/agent-src/skills/existing-ui-audit/SKILL.md +1 -1
  29. package/dist/agent-src/skills/fe-design/SKILL.md +32 -5
  30. package/dist/agent-src/skills/fe-design/references/craft-floor.md +7 -0
  31. package/dist/agent-src/skills/fe-design/references/design-patterns.md +47 -2
  32. package/dist/agent-src/skills/laravel-scheduling/SKILL.md +3 -1
  33. package/dist/agent-src/skills/markitdown/SKILL.md +1 -1
  34. package/dist/agent-src/skills/project-analyzer/SKILL.md +0 -6
  35. package/dist/agent-src/skills/react-native-setup/SKILL.md +3 -3
  36. package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +1 -1
  37. package/dist/agent-src/skills/sequential-thinking/SKILL.md +18 -8
  38. package/dist/agent-src/skills/skill-improvement-pipeline/SKILL.md +28 -6
  39. package/dist/agent-src/skills/tailwind-engineer/SKILL.md +1 -1
  40. package/dist/agent-src/skills/test-driven-development/SKILL.md +1 -3
  41. package/dist/agent-src/skills/ui-apply-generic/SKILL.md +1 -1
  42. package/dist/agent-src/skills/ui-component-architect/SKILL.md +1 -1
  43. package/dist/agent-src/skills/using-git-worktrees/SKILL.md +17 -2
  44. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  45. package/dist/agent-src/templates/roadmaps.md +14 -1
  46. package/dist/agent-src/templates/scripts/telemetry/mcp_lite.ts +213 -0
  47. package/dist/agent-src/templates/scripts/telemetry/report_renderer.ts +16 -0
  48. package/dist/agent-src/templates/scripts/telemetry_report.ts +22 -2
  49. package/dist/cli/commands/recordTriggerEval.js +2 -2
  50. package/dist/cli/main.js +1 -1
  51. package/dist/cli/mcp/dispatch.js +10 -0
  52. package/dist/cli/mcp/stdio.js +33 -2
  53. package/dist/cli/mcp/telemetry.js +227 -0
  54. package/dist/cli-delegate/{chunk-AKJGUQNO.js → chunk-C6IPZRDA.js} +484 -353
  55. package/dist/cli-delegate/{chunk-CYH5SP6M.js → chunk-I6RHFQEG.js} +151 -16
  56. package/dist/cli-delegate/{chunk-7RXIKF62.js → chunk-NUMQHLS5.js} +304 -136
  57. package/dist/cli-delegate/{chunk-PMEKGDED.js → chunk-PI2GU3DP.js} +163 -1
  58. package/dist/cli-delegate/{chunk-7PRE6ZV2.js → chunk-YS6XTLEK.js} +199 -4
  59. package/dist/cli-delegate/chunk-ZVBOHSVF.js +1109 -0
  60. package/dist/cli-delegate/cmd_adr_effective.js +318 -81
  61. package/dist/cli-delegate/cmd_conformance.js +489 -121
  62. package/dist/cli-delegate/cmd_doctor.js +2 -2
  63. package/dist/cli-delegate/cmd_export.js +2 -2
  64. package/dist/cli-delegate/cmd_handoff.js +42 -12
  65. package/dist/cli-delegate/cmd_memory_get.js +1 -1
  66. package/dist/cli-delegate/cmd_prune.js +2 -2
  67. package/dist/cli-delegate/cmd_route_audit.js +2 -2
  68. package/dist/cli-delegate/cmd_session_recycle.js +59 -902
  69. package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
  70. package/dist/cli-delegate/cmd_sync.js +2 -2
  71. package/dist/cli-delegate/cmd_uninstall.js +2 -2
  72. package/dist/cli-delegate/cmd_validate.js +2 -2
  73. package/dist/discovery/deprecation-report.md +1 -1
  74. package/dist/discovery/discovery-manifest.json +34 -33
  75. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  76. package/dist/discovery/discovery-manifest.summary.md +1 -1
  77. package/dist/discovery/dormancy-report.md +1 -1
  78. package/dist/discovery/orphan-report.md +1 -1
  79. package/dist/discovery/packs.json +2 -2
  80. package/dist/discovery/trust-report.md +1 -1
  81. package/dist/discovery/workspaces.json +2 -2
  82. package/dist/hooks/dispatch.js +4455 -3025
  83. package/dist/install/install.mjs +975 -689
  84. package/dist/mcp/registry-manifest.json +2 -2
  85. package/dist/mcp/server.json +2 -2
  86. package/dist/mcp/server.mjs +2087 -1134
  87. package/dist/scripts/_lib/model_tier.js +1 -1
  88. package/dist/scripts/mcp_server/telemetry.js +148 -0
  89. package/docs/guidelines/agent-infra/evaluator-independence-mechanics.md +1 -1
  90. package/docs/guidelines/agent-infra/naming.md +1 -1
  91. package/docs/guidelines/agent-infra/roadmap-progress-mechanics.md +1 -1
  92. package/docs/guidelines/agent-infra/security-lint-containment.md +46 -6
  93. package/docs/guidelines/agent-infra/self-improvement-pipeline.md +4 -4
  94. package/docs/guidelines/agent-infra/untrusted-input-spotlighting.md +1 -1
  95. package/docs/guidelines/design-antipatterns.md +87 -64
  96. package/docs/guidelines/redundancy-taxonomy.md +28 -0
  97. package/package.json +1 -1
  98. package/src/agent-src/README.md +1 -1
  99. package/src/agent-src/templates/scripts/telemetry/mcp_lite.ts +213 -0
  100. package/src/agent-src/templates/scripts/telemetry/report_renderer.ts +16 -0
  101. package/src/agent-src/templates/scripts/telemetry_report.ts +22 -2
  102. package/src/config/assurance-capability-registry.json +9 -0
  103. package/src/config/ci-local-parity.yml +6 -0
  104. package/src/config/gate-coverage.yml +225 -0
  105. package/src/config/gate-violation-baselines.json +18 -6
  106. package/src/config/release-gate-locality.yml +12 -0
  107. package/src/scripts/_cli/cmd_doctor.ts +2 -2
  108. package/src/scripts/_cli/cmd_handoff.ts +1 -1
  109. package/src/scripts/_cli/cmd_session_recycle.ts +51 -3
  110. package/src/scripts/_cli/cmd_update.ts +1 -1
  111. package/src/scripts/_cli/cmd_versions.ts +1 -1
  112. package/src/scripts/_lib/adr_frontmatter.ts +44 -0
  113. package/src/scripts/_lib/audit_field_provenance.ts +44 -3
  114. package/src/scripts/_lib/consequence_objects.ts +152 -0
  115. package/src/scripts/_lib/exec_evidence.ts +7 -0
  116. package/src/scripts/_lib/global_deploy_inventory.ts +2 -2
  117. package/src/scripts/_lib/host_capability.ts +38 -0
  118. package/src/scripts/_lib/host_permission_checks.ts +251 -0
  119. package/src/scripts/_lib/install_reach_checks.ts +33 -1
  120. package/src/scripts/_lib/mcp_bridge.ts +222 -10
  121. package/src/scripts/_lib/mcp_consent_residual.ts +173 -0
  122. package/src/scripts/_lib/model_tier.ts +1 -1
  123. package/src/scripts/_lib/obligation_frequency.ts +2 -2
  124. package/src/scripts/_lib/orchestration_record.ts +23 -1
  125. package/src/scripts/_lib/recycle_envelope_paths.ts +190 -0
  126. package/src/scripts/_lib/release_highlights.ts +93 -0
  127. package/src/scripts/_lib/release_material.ts +64 -1
  128. package/src/scripts/_lib/release_position.ts +96 -0
  129. package/src/scripts/_lib/review_skipped_record.ts +17 -1
  130. package/src/scripts/_lib/runtime_journal.ts +26 -3
  131. package/src/scripts/_lib/runtime_wiring_checks.ts +12 -1
  132. package/src/scripts/_lib/security_lint.ts +179 -7
  133. package/src/scripts/_lib/self_repair.ts +141 -6
  134. package/src/scripts/_lib/self_repair_store.ts +273 -7
  135. package/src/scripts/_lib/session_register.ts +11 -6
  136. package/src/scripts/_lib/sqlite_schema.ts +22 -0
  137. package/src/scripts/_lib/subagent_capsule.ts +155 -3
  138. package/src/scripts/_lib/surface.ts +86 -0
  139. package/src/scripts/_lib/ui_authority.ts +116 -0
  140. package/src/scripts/adr/regenerate_index.ts +54 -1
  141. package/src/scripts/adr_cite_check.ts +71 -15
  142. package/src/scripts/ai_council/clients.ts +1 -1
  143. package/src/scripts/autonomy_friction_corpus.ts +229 -0
  144. package/src/scripts/build_proof.ts +15 -2
  145. package/src/scripts/build_rule_trigger_matrix.ts +5 -0
  146. package/src/scripts/check_beta_review_markers.ts +141 -17
  147. package/src/scripts/check_enforcement_coverage.ts +1 -1
  148. package/src/scripts/check_enforcement_denominator.ts +121 -11
  149. package/src/scripts/check_finding_dispositions.ts +66 -9
  150. package/src/scripts/check_gate_coverage.ts +96 -0
  151. package/src/scripts/check_mcp_doc_drift.ts +217 -0
  152. package/src/scripts/check_pack_size.ts +321 -3
  153. package/src/scripts/check_release_highlights.ts +74 -1
  154. package/src/scripts/check_skill_activation_claim.ts +179 -0
  155. package/src/scripts/check_trigger_evals.ts +1 -1
  156. package/src/scripts/conformance_scan.ts +44 -3
  157. package/src/scripts/explain_run.ts +1 -1
  158. package/src/scripts/generate_capability_matrix.ts +1 -1
  159. package/src/scripts/git_authorization_hook.ts +75 -1
  160. package/src/scripts/handoff_context_hook.ts +54 -7
  161. package/src/scripts/hook_manifest.json +1 -1
  162. package/src/scripts/hook_manifest.yaml +14 -11
  163. package/src/scripts/hooks/augment-dispatcher.sh +1 -1
  164. package/src/scripts/hooks/category_a.ts +291 -0
  165. package/src/scripts/hooks/cline-dispatcher.sh +1 -1
  166. package/src/scripts/hooks/cursor-dispatcher.sh +11 -5
  167. package/src/scripts/hooks/dispatch_hook.ts +17 -22
  168. package/src/scripts/hooks/git_command_classifier.ts +156 -1
  169. package/src/scripts/hooks/host_lowering.ts +215 -0
  170. package/src/scripts/hooks/host_lowering.yaml +185 -0
  171. package/src/scripts/hooks/host_semantics.ts +105 -37
  172. package/src/scripts/hooks/journal_record_hook.ts +10 -0
  173. package/src/scripts/hooks/permission_gate.ts +117 -0
  174. package/src/scripts/hooks/session_eol_hook.ts +18 -7
  175. package/src/scripts/hooks_status.ts +12 -10
  176. package/src/scripts/install.ts +57 -86
  177. package/src/scripts/install_anthropic_key.sh +2 -2
  178. package/src/scripts/lint_agent_security.ts +316 -25
  179. package/src/scripts/lint_confusables.ts +42 -6
  180. package/src/scripts/lint_design_antipattern_parity.ts +80 -0
  181. package/src/scripts/lint_evidence_artifacts.ts +45 -1
  182. package/src/scripts/lint_handoffs.ts +0 -132
  183. package/src/scripts/lint_hidden_unicode.ts +49 -7
  184. package/src/scripts/lint_hook_manifest.ts +87 -3
  185. package/src/scripts/lint_instruction_smuggling.ts +42 -6
  186. package/src/scripts/lint_mcp_config_security.ts +42 -6
  187. package/src/scripts/lint_motion_authority_drift.ts +731 -0
  188. package/src/scripts/lint_readme_size.ts +158 -12
  189. package/src/scripts/lint_reasoning_fixtures.ts +216 -0
  190. package/src/scripts/lint_roadmap_later_disposition.ts +217 -27
  191. package/src/scripts/lint_skill_frontmatter_safety.ts +43 -7
  192. package/src/scripts/lint_skill_trigger_contradiction.ts +161 -0
  193. package/src/scripts/mcp_server/resources.ts +41 -0
  194. package/src/scripts/mcp_server/telemetry.ts +19 -4
  195. package/src/scripts/measure_sibling_naming.ts +150 -0
  196. package/src/scripts/orchestration_record.ts +12 -1
  197. package/src/scripts/redact_hook_capture.ts +1 -1
  198. package/src/scripts/release.ts +24 -24
  199. package/src/scripts/release_publication.ts +6 -2
  200. package/src/scripts/report_skill_activation.ts +319 -7
  201. package/src/scripts/schemas/review-findings.schema.json +5 -0
  202. package/src/scripts/schemas/skill.schema.json +2 -2
  203. package/src/scripts/self_repair_cli.ts +31 -1
  204. package/src/scripts/session_canary_hook.ts +1 -1
  205. package/src/scripts/session_register_hook.ts +4 -3
  206. package/src/scripts/skill_scout.ts +69 -0
  207. package/src/scripts/trigger_eval_grandfather.json +5 -1
  208. package/src/scripts/validate_agent_settings.ts +1 -1
  209. package/dist/cli-delegate/chunk-2GNWGMAL.js +0 -172
  210. package/dist/cli-delegate/chunk-LPPWO5CE.js +0 -161
@@ -0,0 +1,251 @@
1
+ /**
2
+ * host_permission_checks — report the host permission settings that produce
3
+ * confirmation prompts, and write none of them.
4
+ *
5
+ * WHY REPORT AND NOT WRITE. Three settings decide how often a normal coding
6
+ * task is interrupted, and all three belong to the consumer:
7
+ * `permissions.deny` rules that cover reads, the working tree's absence from
8
+ * `permissions.additionalDirectories`, and `permissions.defaultMode`. Writing
9
+ * any of them would be this package deciding a consumer's permission posture
10
+ * from inside their repository, which is the one thing a permission surface
11
+ * must never do quietly. `src/templates/consumer-settings/claude-settings.json`
12
+ * carries `enabledPlugins` and `hooks` and zero `permissions` keys; this check
13
+ * exists so that stays true and the consumer still gets the answer.
14
+ *
15
+ * WHY IT NAMES ALL THREE EVEN WHEN ALL THREE ARE ABSENT. Absent is a real
16
+ * answer and the most common one. A check that goes silent on the default
17
+ * project reports "nothing found" and "I did not look" identically, which is
18
+ * the measured-zero-versus-not-measured failure this tree's own report
19
+ * contracts argue against. So each setting is named on every run, with its
20
+ * resolved value or the word absent.
21
+ *
22
+ * READ ONLY, AND FAILURE-TOLERANT. An unreadable or malformed settings file is
23
+ * reported, never repaired: a consumer's JSON is theirs, and a doctor that
24
+ * rewrites what it came to inspect is not a doctor.
25
+ */
26
+ import * as fs from "node:fs";
27
+ import * as os from "node:os";
28
+ import * as path from "node:path";
29
+
30
+ import type { WiringCheck } from "./runtime_wiring_checks.js";
31
+
32
+ /** Injected so a test never reads the developer's own home directory. */
33
+ export interface HostPermissionDeps {
34
+ projectRoot: string;
35
+ homeDir: string;
36
+ readFile: (p: string) => string | null;
37
+ isDirectory: (p: string) => boolean;
38
+ }
39
+
40
+ /** The permission block as the host writes it; every field optional. */
41
+ interface PermissionBlock {
42
+ allow?: unknown;
43
+ deny?: unknown;
44
+ ask?: unknown;
45
+ additionalDirectories?: unknown;
46
+ defaultMode?: unknown;
47
+ }
48
+
49
+ /** One settings layer, in the host's own precedence order. */
50
+ export interface SettingsLayer {
51
+ label: string;
52
+ file: string;
53
+ present: boolean;
54
+ unreadable: boolean;
55
+ permissions: PermissionBlock;
56
+ }
57
+
58
+ /**
59
+ * Tool names whose every documented effect is a read, plus the read-shaped
60
+ * `Bash(<cmd>:*)` heads. A deny covering one of these is the class that turns a
61
+ * category-A call back into a prompt, which is why it is worth naming
62
+ * separately from a deny on a write.
63
+ */
64
+ const READ_SHAPED = [
65
+ "Read", "Glob", "Grep", "LS", "NotebookRead",
66
+ "Bash(cat", "Bash(ls", "Bash(head", "Bash(tail", "Bash(grep",
67
+ "Bash(rg", "Bash(find", "Bash(wc", "Bash(git status", "Bash(git log",
68
+ "Bash(git diff", "Bash(git show",
69
+ ];
70
+
71
+ /** The four `defaultMode` values probed from Claude Code 2.1.263. */
72
+ const KNOWN_MODES = ["default", "acceptEdits", "plan", "bypassPermissions"];
73
+
74
+ function _strings(value: unknown): string[] {
75
+ return Array.isArray(value) ? value.filter((v): v is string => typeof v === "string") : [];
76
+ }
77
+
78
+ /** Default deps — the only place the real filesystem is touched. */
79
+ export function defaultHostPermissionDeps(projectRoot: string): HostPermissionDeps {
80
+ return {
81
+ projectRoot,
82
+ homeDir: os.homedir(),
83
+ readFile: (p) => {
84
+ try {
85
+ return fs.readFileSync(p, "utf8");
86
+ } catch {
87
+ return null;
88
+ }
89
+ },
90
+ isDirectory: (p) => {
91
+ try {
92
+ return fs.statSync(p).isDirectory();
93
+ } catch {
94
+ return false;
95
+ }
96
+ },
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Resolve the three settings layers the host reads, in its precedence order.
102
+ *
103
+ * There was no resolver for this before — `security_audit_config.ts` is the
104
+ * only place all three paths appear together, and only as glob strings — so
105
+ * this is the first code in the tree that reads `permissions` at all.
106
+ */
107
+ export function readSettingsLayers(deps: HostPermissionDeps): SettingsLayer[] {
108
+ const candidates: Array<[string, string]> = [
109
+ ["user-global", path.join(deps.homeDir, ".claude", "settings.json")],
110
+ ["project", path.join(deps.projectRoot, ".claude", "settings.json")],
111
+ ["project-local", path.join(deps.projectRoot, ".claude", "settings.local.json")],
112
+ ];
113
+ return candidates.map(([label, file]) => {
114
+ const raw = deps.readFile(file);
115
+ if (raw === null) {
116
+ return { label, file, present: false, unreadable: false, permissions: {} };
117
+ }
118
+ try {
119
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
120
+ const perms = parsed["permissions"];
121
+ return {
122
+ label,
123
+ file,
124
+ present: true,
125
+ unreadable: false,
126
+ permissions:
127
+ perms !== null && typeof perms === "object" ? (perms as PermissionBlock) : {},
128
+ };
129
+ } catch {
130
+ return { label, file, present: true, unreadable: true, permissions: {} };
131
+ }
132
+ });
133
+ }
134
+
135
+ /** Deny rules that cover a read, with the layer each came from. */
136
+ export function readDenyRules(layers: readonly SettingsLayer[]): Array<[string, string]> {
137
+ const hits: Array<[string, string]> = [];
138
+ for (const layer of layers) {
139
+ for (const rule of _strings(layer.permissions.deny)) {
140
+ if (READ_SHAPED.some((prefix) => rule.startsWith(prefix))) {
141
+ hits.push([layer.label, rule]);
142
+ }
143
+ }
144
+ }
145
+ return hits;
146
+ }
147
+
148
+ /** Every `additionalDirectories` entry across the layers; the host unions them. */
149
+ export function additionalDirectories(layers: readonly SettingsLayer[]): string[] {
150
+ const seen: string[] = [];
151
+ for (const layer of layers) {
152
+ for (const dir of _strings(layer.permissions.additionalDirectories)) {
153
+ if (!seen.includes(dir)) seen.push(dir);
154
+ }
155
+ }
156
+ return seen;
157
+ }
158
+
159
+ /** The winning `defaultMode`, or `null` when no layer sets one. */
160
+ export function resolvedDefaultMode(layers: readonly SettingsLayer[]): string | null {
161
+ let winner: string | null = null;
162
+ for (const layer of layers) {
163
+ const mode = layer.permissions.defaultMode;
164
+ if (typeof mode === "string" && mode.trim()) winner = mode.trim();
165
+ }
166
+ return winner;
167
+ }
168
+
169
+ /**
170
+ * Is the project root a LINKED worktree?
171
+ *
172
+ * A linked worktree's `.git` is a file holding a `gitdir:` pointer, never a
173
+ * directory. It matters here because a worktree root is a working directory the
174
+ * host was not started in, which is exactly the case
175
+ * `additionalDirectories` exists to cover — and the case a consumer is least
176
+ * likely to have thought about.
177
+ */
178
+ export function isLinkedWorktree(deps: HostPermissionDeps): boolean {
179
+ const dotGit = path.join(deps.projectRoot, ".git");
180
+ const raw = deps.readFile(dotGit);
181
+ if (raw === null) return false;
182
+ return !deps.isDirectory(dotGit) && raw.trimStart().startsWith("gitdir:");
183
+ }
184
+
185
+ /** The copyable snippet — the consumer pastes it, this package never writes it. */
186
+ export function permissionSnippet(projectRoot: string): string {
187
+ return JSON.stringify(
188
+ { permissions: { additionalDirectories: [projectRoot], defaultMode: "default" } },
189
+ null,
190
+ 2,
191
+ );
192
+ }
193
+
194
+ /**
195
+ * The doctor row.
196
+ *
197
+ * `warn` when something reportable was found, `ok` otherwise. Never `fail`:
198
+ * these are the consumer's own choices, and a doctor that fails a build over a
199
+ * setting it is forbidden to write would be asking for a change it will not
200
+ * make.
201
+ */
202
+ export function checkHostPermissionSettings(deps: HostPermissionDeps): WiringCheck {
203
+ const layers = readSettingsLayers(deps);
204
+ const unreadable = layers.filter((l) => l.unreadable);
205
+ const denies = readDenyRules(layers);
206
+ const dirs = additionalDirectories(layers);
207
+ const mode = resolvedDefaultMode(layers);
208
+ const worktree = isLinkedWorktree(deps);
209
+ const rootListed = dirs.some(
210
+ (d) => path.resolve(d) === path.resolve(deps.projectRoot),
211
+ );
212
+
213
+ const lines: string[] = [];
214
+ lines.push(
215
+ denies.length === 0
216
+ ? "read-deny rules: absent — no permissions.deny rule covers a read"
217
+ : `read-deny rules: ${denies.length} — ` +
218
+ denies.map(([layer, rule]) => `${rule} (${layer})`).join(", "),
219
+ );
220
+ lines.push(
221
+ rootListed
222
+ ? `working tree in additionalDirectories: present (${deps.projectRoot})`
223
+ : `working tree in additionalDirectories: absent (${deps.projectRoot}` +
224
+ `${worktree ? ", a linked worktree" : ""})`,
225
+ );
226
+ lines.push(
227
+ mode === null
228
+ ? "permissions.defaultMode: absent — the host's own built-in default applies"
229
+ : `permissions.defaultMode: ${mode}` +
230
+ (KNOWN_MODES.includes(mode) ? "" : " — not a value this tree has probed"),
231
+ );
232
+ for (const layer of unreadable) {
233
+ lines.push(`unreadable: ${layer.file} (${layer.label}) is not valid JSON`);
234
+ }
235
+
236
+ // A linked worktree unlisted in additionalDirectories is the one finding
237
+ // worth surfacing on its own: the host was started somewhere else, so every
238
+ // call into this tree is a call into a directory it was not granted.
239
+ const reportable =
240
+ denies.length > 0 || unreadable.length > 0 || (worktree && !rootListed);
241
+
242
+ return {
243
+ id: "host-permission-settings",
244
+ status: reportable ? "warn" : "ok",
245
+ message: lines.join("; "),
246
+ remedy:
247
+ "agent-config never writes permissions — paste this into " +
248
+ `${path.join(deps.projectRoot, ".claude", "settings.local.json")} yourself if you want it: ` +
249
+ permissionSnippet(deps.projectRoot).replace(/\s+/g, " "),
250
+ };
251
+ }
@@ -34,6 +34,7 @@ import * as fs from 'node:fs';
34
34
  import * as path from 'node:path';
35
35
 
36
36
  import type { WiringCheck } from './runtime_wiring_checks.js';
37
+ import { TOOL_TO_HOST, residualReport } from './mcp_consent_residual.js';
37
38
 
38
39
  /**
39
40
  * Root agent-instruction files, as a multi-vendor ecosystem convention rather
@@ -477,11 +478,41 @@ export function checkOverrideSet(projectRoot: string): WiringCheck {
477
478
  };
478
479
  }
479
480
 
481
+ /**
482
+ * Step 3.2 — what remains non-automatic per host, and where that came from.
483
+ *
484
+ * Belongs to reach rather than to a doctor-local check because that is what it
485
+ * measures: the gap between "installed" and "actually running". A residual is
486
+ * exactly the distance the install did not cover.
487
+ *
488
+ * Reports the residual, never a checklist of what worked — a list of successes
489
+ * is noise a reader has to filter, while a named residual is the one thing they
490
+ * can act on. An UNRECORDED host warns too, because "nobody checked" must not
491
+ * read as "nothing to do".
492
+ */
493
+ export function checkMcpConsentResidual(): WiringCheck {
494
+ const id = 'mcp-consent-residual';
495
+ const lines = residualReport(Object.keys(TOOL_TO_HOST));
496
+ if (lines.length === 0) {
497
+ return { id, status: 'ok', message: 'no MCP consent residual recorded for any known host', remedy: '' };
498
+ }
499
+ const actionable = lines.filter((l) => l.state === 'residual');
500
+ return {
501
+ id,
502
+ status: 'warn',
503
+ message: lines.map((l) => l.text).join(' · '),
504
+ remedy:
505
+ actionable.length > 0
506
+ ? 'complete the step named above on each host you use'
507
+ : 'record an observation for the unchecked hosts, or ignore if you do not use them',
508
+ };
509
+ }
510
+
480
511
  // ---------------------------------------------------------------------------
481
512
  // Wiring — the shape `cmd_doctor` consumes
482
513
  // ---------------------------------------------------------------------------
483
514
 
484
- export const REACH_CHECK_IDS = ['instruction-path-reach', 'version-axis', 'override-set'] as const;
515
+ export const REACH_CHECK_IDS = ['instruction-path-reach', 'version-axis', 'override-set', 'mcp-consent-residual'] as const;
485
516
 
486
517
  export function reachRunners(opts: {
487
518
  projectRoot: string;
@@ -491,5 +522,6 @@ export function reachRunners(opts: {
491
522
  'instruction-path-reach': () => checkInstructionPaths(opts.projectRoot),
492
523
  'version-axis': () => checkVersionAxis(opts.projectRoot, opts.resolvableVersion),
493
524
  'override-set': () => checkOverrideSet(opts.projectRoot),
525
+ 'mcp-consent-residual': () => checkMcpConsentResidual(),
494
526
  };
495
527
  }
@@ -15,6 +15,7 @@
15
15
 
16
16
  import * as fs from 'node:fs';
17
17
  import * as path from 'node:path';
18
+ import { probeHostCapabilities } from './host_capability.js';
18
19
 
19
20
  /** `install.ts`'s deploy tuple: [written, skipped, status, paths]. */
20
21
  export type DeployTuple = [number, number, string, string[]];
@@ -35,14 +36,43 @@ export type MergeJsonFile = (
35
36
  * installer end up with the same entry rather than two that drift.
36
37
  */
37
38
  export const MCP_SERVER_KEY = 'agent-config';
38
- export const MCP_BRIDGE_ENTRY = {
39
- mcpServers: {
40
- [MCP_SERVER_KEY]: {
41
- command: 'npx',
42
- args: ['-y', '@event4u/agent-config', 'mcp-server'],
39
+ export const MCP_PACKAGE_NAME = '@event4u/agent-config';
40
+
41
+ /**
42
+ * The entry, pinned to the version the installer is running from.
43
+ *
44
+ * Unpinned, `npx -y @event4u/agent-config` resolves the `latest` dist-tag on
45
+ * every server start, so the server a consumer runs is whatever the registry
46
+ * served most recently — not the one their installer approved, and not the one
47
+ * their lockfile records. The pin makes those the same artefact.
48
+ *
49
+ * A version that cannot be read is left unpinned rather than guessed: an
50
+ * invented specifier would fail to resolve at server start, which is a worse
51
+ * failure than the drift it was meant to close.
52
+ */
53
+ export function mcpBridgeEntry(packageRoot: string): Record<string, unknown> {
54
+ const version = readPackageVersion(packageRoot);
55
+ const spec = version === null ? MCP_PACKAGE_NAME : `${MCP_PACKAGE_NAME}@${version}`;
56
+ return {
57
+ mcpServers: {
58
+ [MCP_SERVER_KEY]: {
59
+ command: 'npx',
60
+ args: ['-y', spec, 'mcp-server'],
61
+ },
43
62
  },
44
- },
45
- };
63
+ };
64
+ }
65
+
66
+ /** The manifest `version`, or `null` when it is absent or unreadable. */
67
+ export function readPackageVersion(packageRoot: string): string | null {
68
+ try {
69
+ const raw = fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf-8');
70
+ const v = (JSON.parse(raw) as { version?: unknown }).version;
71
+ return typeof v === 'string' && v.trim() !== '' ? v : null;
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
46
76
 
47
77
  /**
48
78
  * `.mcp.json` — the project-scope MCP config Claude Code reads.
@@ -66,9 +96,191 @@ export const MCP_BRIDGE_ENTRY = {
66
96
  */
67
97
  export function makeEnsureMcpBridge(
68
98
  mergeJsonFile: MergeJsonFile,
69
- ): (projectRoot: string, force: boolean) => Record<string, unknown>[] {
70
- return (projectRoot, force) =>
71
- mergeJsonFile(path.join(projectRoot, '.mcp.json'), MCP_BRIDGE_ENTRY, force, '.mcp.json');
99
+ ): (projectRoot: string, force: boolean, packageRoot?: string) => Record<string, unknown>[] {
100
+ return (projectRoot, force, packageRoot = projectRoot) =>
101
+ mergeJsonFile(
102
+ path.join(projectRoot, '.mcp.json'),
103
+ mcpBridgeEntry(packageRoot),
104
+ force,
105
+ '.mcp.json',
106
+ );
107
+ }
108
+
109
+ /**
110
+ * Where each host reads a PROJECT-scope MCP config, and which capability-manifest
111
+ * row gates writing to it (roadmap step 3.3).
112
+ *
113
+ * Keyed by the installer's `--tools` id; `hostId` is the SEPARATE
114
+ * platform identifier the capability manifest is keyed by (`claude`, not
115
+ * `claude-code`). Conflating the two namespaces is the mistake
116
+ * `host_capability.ts` warns about in its own registry doc, so the mapping is
117
+ * written out rather than derived.
118
+ *
119
+ * Presence in this table is NOT permission to write. It records where the file
120
+ * would go IF the axis said the host reads one; the axis decides, and it says
121
+ * `true` only once someone observed it.
122
+ */
123
+ export const MCP_PROJECT_CONFIG: Readonly<Record<string, { hostId: string; relPath: string }>> = {
124
+ 'claude-code': { hostId: 'claude', relPath: '.mcp.json' },
125
+ cursor: { hostId: 'cursor', relPath: '.cursor/mcp.json' },
126
+ 'gemini-cli': { hostId: 'gemini', relPath: '.gemini/settings.json' },
127
+ };
128
+
129
+ /** One host to register, resolved from the `--tools` set and the capability axis. */
130
+ export interface McpRegistrationTarget {
131
+ toolId: string;
132
+ hostId: string;
133
+ relPath: string;
134
+ }
135
+
136
+ /** Injectable capability reader — the installer passes the real probe. */
137
+ export type ReadsProjectMcpConfig = (hostId: string) => boolean;
138
+
139
+ /**
140
+ * The hosts an install may register the server on, beyond `claude-code`.
141
+ *
142
+ * TWO gates, both required. `--tools` is the consumer's selection; the
143
+ * capability axis is the evidence. A host is registered only where BOTH say so,
144
+ * so a config file this package already writes for hooks does not silently
145
+ * acquire an MCP entry on the strength of the file existing.
146
+ *
147
+ * `claude-code` is excluded here on purpose: its `.mcp.json` registration
148
+ * predates the axis and is written unconditionally by `makeEnsureMcpBridge`.
149
+ * Routing it through the axis would DELETE a working registration, because the
150
+ * axis reports `false` for every host today — "nobody answered", not "checked
151
+ * and absent". This function adds reach; it never removes any.
152
+ *
153
+ * MEASURED STATE, 2026-09-07: the capability registry carries one row
154
+ * (`claude`) and it does not set `reads_project_mcp_config`, so this returns an
155
+ * EMPTY list for every `--tools` set. That is the honest reading of the axis,
156
+ * not a defect in this function — and it is a recorded absence, never a finding
157
+ * that these hosts do not read such a file.
158
+ */
159
+ export function mcpRegistrationTargets(
160
+ tools: ReadonlySet<string>,
161
+ readsConfig: ReadsProjectMcpConfig,
162
+ ): McpRegistrationTarget[] {
163
+ const out: McpRegistrationTarget[] = [];
164
+ for (const [toolId, spec] of Object.entries(MCP_PROJECT_CONFIG)) {
165
+ if (toolId === 'claude-code') continue;
166
+ if (!tools.has(toolId)) continue;
167
+ if (!readsConfig(spec.hostId)) continue;
168
+ out.push({ toolId, hostId: spec.hostId, relPath: spec.relPath });
169
+ }
170
+ return out;
171
+ }
172
+
173
+ /**
174
+ * Merge the server entry into every target's config file.
175
+ *
176
+ * Returns the merged keys per tool id, in the shape the installer's
177
+ * `merged_keys_by_tool` lockfile record expects. An empty map is the normal
178
+ * result today and is not an error.
179
+ */
180
+ export function makeEnsureMcpRegistrations(
181
+ mergeJsonFile: MergeJsonFile,
182
+ ): (
183
+ projectRoot: string,
184
+ force: boolean,
185
+ packageRoot: string,
186
+ targets: readonly McpRegistrationTarget[],
187
+ ) => Record<string, Record<string, unknown>[]> {
188
+ return (projectRoot, force, packageRoot, targets) => {
189
+ const merged: Record<string, Record<string, unknown>[]> = {};
190
+ for (const t of targets) {
191
+ merged[t.toolId] = mergeJsonFile(
192
+ path.join(projectRoot, ...t.relPath.split('/')),
193
+ mcpBridgeEntry(packageRoot),
194
+ force,
195
+ t.relPath,
196
+ );
197
+ }
198
+ return merged;
199
+ };
200
+ }
201
+
202
+ /** The axis reader the installer uses. Injected in tests, never overridden there. */
203
+ function readsProjectMcpConfig(hostId: string): boolean {
204
+ return probeHostCapabilities(hostId).reads_project_mcp_config;
205
+ }
206
+
207
+ /**
208
+ * Write every MCP registration one install owes, and record the merged keys.
209
+ *
210
+ * ONE call site rather than three, because `install.ts` is 3,900 lines over the
211
+ * source-size ratchet's ceiling and every line added there is a line the ratchet
212
+ * charges for. The composition is the same either way; where it lives is the
213
+ * only thing that differs, and it belongs beside the entry it writes.
214
+ *
215
+ * `claude-code` is registered whenever it is selected — that write predates the
216
+ * axis and routing it through would DELETE it, the axis being `false` for every
217
+ * host today. Every OTHER host needs both gates: the `--tools` selection and an
218
+ * OBSERVED `reads_project_mcp_config`.
219
+ */
220
+ export function registerMcpHosts(
221
+ mergeJsonFile: MergeJsonFile,
222
+ projectRoot: string,
223
+ force: boolean,
224
+ packageRoot: string,
225
+ tools: ReadonlySet<string>,
226
+ into: Record<string, Record<string, unknown>[]>,
227
+ readsConfig: ReadsProjectMcpConfig = readsProjectMcpConfig,
228
+ ): void {
229
+ const ensure = makeEnsureMcpRegistrations(mergeJsonFile);
230
+ const claude = MCP_PROJECT_CONFIG['claude-code'];
231
+ if (tools.has('claude-code') && claude !== undefined) {
232
+ const merged = ensure(projectRoot, force, packageRoot, [
233
+ { toolId: 'claude-code', hostId: claude.hostId, relPath: claude.relPath },
234
+ ]);
235
+ (into['claude-code'] ??= []).push(...(merged['claude-code'] ?? []));
236
+ }
237
+ for (const [toolId, keys] of Object.entries(
238
+ ensure(projectRoot, force, packageRoot, mcpRegistrationTargets(tools, readsConfig)),
239
+ )) {
240
+ (into[toolId] ??= []).push(...keys);
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Rewrite a stale `.mcp.json` entry in place, touching only the key we own.
246
+ *
247
+ * Install writes the entry once; nothing re-read it afterwards, so a pin
248
+ * written by one install stayed frozen at that version for the life of the
249
+ * file — which is exactly the drift the pin was added to close, one release
250
+ * later. This is the update path.
251
+ *
252
+ * Scope discipline: the ONLY key rewritten is `mcpServers[MCP_SERVER_KEY]`.
253
+ * A server a consumer added by hand sits beside it and is not read, compared,
254
+ * reordered or rewritten — the same contract `merge_json_file` honours on the
255
+ * install path, and the reason uninstall can subtract exactly one key.
256
+ *
257
+ * Returns `'absent'` (no file, or no entry of ours to repair — install owns
258
+ * that case, not this one), `'current'`, or `'rewritten'`.
259
+ */
260
+ export function migrateMcpBridge(
261
+ projectRoot: string,
262
+ packageRoot: string,
263
+ ): 'absent' | 'current' | 'rewritten' {
264
+ const file = path.join(projectRoot, '.mcp.json');
265
+ let doc: Record<string, unknown>;
266
+ try {
267
+ doc = JSON.parse(fs.readFileSync(file, 'utf-8')) as Record<string, unknown>;
268
+ } catch {
269
+ return 'absent';
270
+ }
271
+ const servers = doc['mcpServers'];
272
+ if (servers === null || typeof servers !== 'object' || Array.isArray(servers)) return 'absent';
273
+ const table = servers as Record<string, unknown>;
274
+ if (table[MCP_SERVER_KEY] === undefined) return 'absent';
275
+
276
+ const wanted = (mcpBridgeEntry(packageRoot)['mcpServers'] as Record<string, unknown>)[
277
+ MCP_SERVER_KEY
278
+ ];
279
+ if (JSON.stringify(table[MCP_SERVER_KEY]) === JSON.stringify(wanted)) return 'current';
280
+
281
+ table[MCP_SERVER_KEY] = wanted;
282
+ fs.writeFileSync(file, `${JSON.stringify(doc, null, 2)}\n`, 'utf-8');
283
+ return 'rewritten';
72
284
  }
73
285
 
74
286
  /**