@event4u/agent-config 8.2.0 → 8.4.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 (55) hide show
  1. package/.claude-plugin/marketplace.json +4 -426
  2. package/.claude-plugin/skills/install-agent-config/SKILL.md +32 -0
  3. package/CHANGELOG.md +57 -0
  4. package/MIGRATION.md +18 -0
  5. package/README.md +86 -3
  6. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  7. package/dist/cli/registry.js +1 -0
  8. package/dist/cli/registry.js.map +1 -1
  9. package/dist/discovery/deprecation-report.md +1 -1
  10. package/dist/discovery/discovery-manifest.json +1 -1
  11. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  12. package/dist/discovery/discovery-manifest.summary.md +1 -1
  13. package/dist/discovery/orphan-report.md +1 -1
  14. package/dist/discovery/packs.json +1 -1
  15. package/dist/discovery/trust-report.md +1 -1
  16. package/dist/discovery/workspaces.json +1 -1
  17. package/dist/install/install.mjs +586 -386
  18. package/dist/mcp/registry-manifest.json +2 -2
  19. package/docs/contracts/CHANGELOG-conventions.md +8 -5
  20. package/docs/contracts/branch-protection-policy.md +14 -5
  21. package/docs/contracts/mcp-phase-1-scope.md +75 -0
  22. package/docs/contracts/mcp-tool-inventory.md +22 -15
  23. package/docs/contracts/release-pr-gating.md +22 -11
  24. package/docs/contracts/surface-matrix.md +103 -0
  25. package/docs/decisions/ADR-111-canonical-glama-listing.md +97 -0
  26. package/docs/decisions/ADR-112-stdio-lite-stays-read-only.md +92 -0
  27. package/docs/decisions/ADR-113-ci-native-release-label-trigger.md +201 -0
  28. package/docs/decisions/INDEX.md +3 -0
  29. package/docs/distribution/registry-submissions.md +1 -1
  30. package/docs/getting-started.md +8 -6
  31. package/docs/installation.md +13 -14
  32. package/docs/mcp-server.md +32 -20
  33. package/docs/setup/mcp-cloud-endpoints.md +9 -2
  34. package/package.json +2 -2
  35. package/src/config/surface-matrix.yml +213 -0
  36. package/src/scripts/_cli/cmd_converge.ts +399 -0
  37. package/src/scripts/_cli/cmd_doctor.ts +203 -9
  38. package/src/scripts/_cli/cmd_uninstall.ts +22 -0
  39. package/src/scripts/_cli/cmd_upgrade.ts +101 -16
  40. package/src/scripts/_dispatch.bash +13 -0
  41. package/src/scripts/_lib/claude_settings_hooks.ts +251 -0
  42. package/src/scripts/condense.ts +74 -65
  43. package/src/scripts/hook_manifest.yaml +16 -7
  44. package/src/scripts/install +23 -4
  45. package/src/scripts/install.ts +30 -0
  46. package/src/scripts/lint_glama_drift.ts +75 -0
  47. package/src/scripts/lint_marketplace.ts +54 -56
  48. package/src/scripts/lint_surface_matrix.ts +200 -0
  49. package/src/scripts/mcp_exec/safety_envelope.ts +105 -0
  50. package/src/scripts/mcp_registry_submit.sh +14 -3
  51. package/src/scripts/mcp_server/__main__.ts +6 -8
  52. package/src/scripts/mcp_server/consumer_tool_catalog.json +78 -3
  53. package/src/scripts/mcp_server/tools.ts +731 -0
  54. package/src/scripts/release.ts +203 -39
  55. package/src/scripts/surface_probe_hook.ts +231 -0
@@ -0,0 +1,399 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * `agent-config converge` — consented cleanup of duplicate install surfaces
4
+ * (road-to-install-path-convergence Phase 3).
5
+ *
6
+ * Reads src/config/surface-matrix.yml, detects every tool whose declared
7
+ * duplicate-class paths are ALL present, and — with consent — performs the
8
+ * per-tool cleanup: the tool's own uninstall command plus tagged-orphan
9
+ * reaping of the matrix-declared cache locations. Emits a convergence report
10
+ * (what was removed, why, rollback hint).
11
+ *
12
+ * Consent model (council Q2, 2026-07-07 — no silent mutation of a
13
+ * user-owned surface):
14
+ *
15
+ * - `install.auto_converge: true` in the GLOBAL settings file
16
+ * (~/.event4u/agent-config/agent-settings.yml) is standing consent. The
17
+ * first successful interactive/explicit converge persists it.
18
+ * - Without the key, a TTY run asks ONE y/N question; a non-TTY run refuses
19
+ * with instructions (automation must opt in via the key or --yes).
20
+ * - `--dry-run` prints the exact actions and exits 0 without consent.
21
+ * - `--yes` grants consent for this run AND persists the key (explicit
22
+ * automation opt-in).
23
+ *
24
+ * Hard floor: converge removes ONLY matrix-declared, package-tagged paths
25
+ * (`converge.reaps`) and runs ONLY matrix-declared uninstall commands. It
26
+ * never deletes user-authored files; any path outside the matrix allowlist
27
+ * is refused.
28
+ *
29
+ * Exit codes: 0 = converged / nothing to do / dry-run · 1 = refused or a
30
+ * step failed · 2 = usage error.
31
+ */
32
+ import * as fs from 'node:fs';
33
+ import * as os from 'node:os';
34
+ import * as path from 'node:path';
35
+ import process from 'node:process';
36
+ import { spawnSync } from 'node:child_process';
37
+ import { fileURLToPath, pathToFileURL } from 'node:url';
38
+
39
+ import * as YAML from 'yaml';
40
+
41
+ import * as user_global_paths from '../_lib/user_global_paths.js';
42
+
43
+ type Dict = Record<string, unknown>;
44
+
45
+ interface OutSink {
46
+ write: (t: string) => void;
47
+ }
48
+ const _stdout: OutSink = { write: (t) => process.stdout.write(t) };
49
+ const _stderr: OutSink = { write: (t) => process.stderr.write(t) };
50
+
51
+ function _print(out: OutSink, line = ''): void {
52
+ out.write(`${line}\n`);
53
+ }
54
+
55
+ type Runner = (cmd: string[]) => number;
56
+
57
+ function _default_runner(cmd: string[]): number {
58
+ const r = spawnSync(cmd[0] as string, cmd.slice(1), { stdio: 'inherit' });
59
+ if (r.error) {
60
+ _stderr.write(`agent-config converge: cannot run ${cmd[0]}: ${String(r.error)}\n`);
61
+ return 1;
62
+ }
63
+ return r.status ?? 1;
64
+ }
65
+
66
+ const _HERE = fileURLToPath(import.meta.url);
67
+
68
+ /** Resolve the @event4u/agent-config package root (this repo). */
69
+ function _package_root(): string {
70
+ return path.resolve(path.dirname(_HERE), '..', '..', '..');
71
+ }
72
+
73
+ const SETTINGS_FILENAME = 'agent-settings.yml';
74
+ const CONSENT_KEY_PATH = ['install', 'auto_converge'] as const;
75
+
76
+ class ArgparseExit extends Error {
77
+ code: number;
78
+ constructor(code: number) {
79
+ super(`exit ${code}`);
80
+ this.code = code;
81
+ }
82
+ }
83
+
84
+ interface Args {
85
+ dry_run: boolean;
86
+ yes: boolean;
87
+ }
88
+
89
+ const PROG = 'agent-config converge';
90
+ const USAGE = `usage: ${PROG} [-h] [--dry-run] [--yes]\n`;
91
+
92
+ function _argError(msg: string): never {
93
+ _stderr.write(USAGE);
94
+ _stderr.write(`${PROG}: error: ${msg}\n`);
95
+ throw new ArgparseExit(2);
96
+ }
97
+
98
+ function _parse(argv: string[]): Args {
99
+ const args: Args = { dry_run: false, yes: false };
100
+ for (const a of argv) {
101
+ if (a === '-h' || a === '--help') {
102
+ _stdout.write(USAGE);
103
+ throw new ArgparseExit(0);
104
+ }
105
+ if (a === '--dry-run') {
106
+ args.dry_run = true;
107
+ continue;
108
+ }
109
+ if (a === '--yes' || a === '-y') {
110
+ args.yes = true;
111
+ continue;
112
+ }
113
+ _argError(`unrecognized arguments: ${a}`);
114
+ }
115
+ return args;
116
+ }
117
+
118
+ interface DuplicateFinding {
119
+ tool: string;
120
+ description: string;
121
+ command: string;
122
+ reaps: string[];
123
+ }
124
+
125
+ interface MatrixTool {
126
+ duplicate?: { description?: string; detect?: { all_of?: unknown } };
127
+ converge?: { command?: string; reaps?: unknown };
128
+ }
129
+
130
+ export function _load_matrix(matrix_path: string): Record<string, MatrixTool> {
131
+ const raw = YAML.parse(fs.readFileSync(matrix_path, 'utf-8'), { version: '1.1' }) as Dict;
132
+ const tools = raw['tools'];
133
+ if (!tools || typeof tools !== 'object' || Array.isArray(tools)) {
134
+ throw new Error('surface-matrix.yml: `tools` must be a mapping');
135
+ }
136
+ return tools as Record<string, MatrixTool>;
137
+ }
138
+
139
+ function _expand(p: string, home: string, pkg_root: string): string {
140
+ return p.startsWith('~/') ? path.join(home, p.slice(2)) : path.join(pkg_root, p);
141
+ }
142
+
143
+ function _exists(p: string): boolean {
144
+ try {
145
+ fs.statSync(p);
146
+ return true;
147
+ } catch {
148
+ return false;
149
+ }
150
+ }
151
+
152
+ /** Detect all live duplicate classes per the matrix. */
153
+ export function detect_duplicates(
154
+ tools: Record<string, MatrixTool>,
155
+ home: string,
156
+ pkg_root: string,
157
+ ): DuplicateFinding[] {
158
+ const findings: DuplicateFinding[] = [];
159
+ for (const [tool_id, entry] of Object.entries(tools)) {
160
+ const all_of = entry.duplicate?.detect?.all_of;
161
+ if (!Array.isArray(all_of) || all_of.length === 0) {
162
+ continue; // no defined class (pending_evidence is documented, never acted on)
163
+ }
164
+ const present = all_of.every(
165
+ (p) => typeof p === 'string' && _exists(_expand(p, home, pkg_root)),
166
+ );
167
+ if (!present) {
168
+ continue;
169
+ }
170
+ const reaps_raw = entry.converge?.reaps;
171
+ const reaps = Array.isArray(reaps_raw)
172
+ ? reaps_raw.filter((r): r is string => typeof r === 'string' && r.startsWith('~/'))
173
+ : [];
174
+ findings.push({
175
+ tool: tool_id,
176
+ description: entry.duplicate?.description ?? 'duplicate install surface',
177
+ command: entry.converge?.command ?? '',
178
+ reaps,
179
+ });
180
+ }
181
+ return findings;
182
+ }
183
+
184
+ /** Read install.auto_converge from the global settings file. */
185
+ export function read_consent(settings_path: string): boolean {
186
+ try {
187
+ const raw = YAML.parse(fs.readFileSync(settings_path, 'utf-8'), { version: '1.1' }) as Dict;
188
+ const install = raw?.[CONSENT_KEY_PATH[0]];
189
+ if (install && typeof install === 'object' && !Array.isArray(install)) {
190
+ return (install as Dict)[CONSENT_KEY_PATH[1]] === true;
191
+ }
192
+ } catch {
193
+ /* missing / unreadable → no consent */
194
+ }
195
+ return false;
196
+ }
197
+
198
+ /** Persist install.auto_converge: true into the global settings file. */
199
+ export function persist_consent(settings_path: string, out: OutSink): void {
200
+ let doc: Dict = {};
201
+ try {
202
+ const parsed = YAML.parse(fs.readFileSync(settings_path, 'utf-8'), { version: '1.1' });
203
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
204
+ doc = parsed as Dict;
205
+ }
206
+ } catch {
207
+ /* absent → create */
208
+ }
209
+ const install =
210
+ doc['install'] && typeof doc['install'] === 'object' && !Array.isArray(doc['install'])
211
+ ? (doc['install'] as Dict)
212
+ : {};
213
+ if (install[CONSENT_KEY_PATH[1]] === true) {
214
+ return;
215
+ }
216
+ install[CONSENT_KEY_PATH[1]] = true;
217
+ doc['install'] = install;
218
+ fs.mkdirSync(path.dirname(settings_path), { recursive: true });
219
+ fs.writeFileSync(settings_path, YAML.stringify(doc), 'utf-8');
220
+ _print(out, `✅ standing consent persisted: install.auto_converge: true (${settings_path})`);
221
+ }
222
+
223
+ /** One-time interactive y/N on a TTY; false everywhere else. */
224
+ function _tty_confirm(question: string): boolean {
225
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
226
+ return false;
227
+ }
228
+ // Synchronous single-line read from the TTY.
229
+ process.stdout.write(`${question} [y/N] `);
230
+ const buf = Buffer.alloc(64);
231
+ let answer = '';
232
+ try {
233
+ const fd = fs.openSync('/dev/tty', 'rs');
234
+ const n = fs.readSync(fd, buf, 0, 64, null);
235
+ fs.closeSync(fd);
236
+ answer = buf.toString('utf-8', 0, n).trim().toLowerCase();
237
+ } catch {
238
+ return false;
239
+ }
240
+ return answer === 'y' || answer === 'yes';
241
+ }
242
+
243
+ export interface MainOptions {
244
+ runner?: Runner;
245
+ home?: string;
246
+ package_root?: string;
247
+ settings_path?: string;
248
+ confirm?: (question: string) => boolean;
249
+ out?: OutSink;
250
+ err?: OutSink;
251
+ }
252
+
253
+ export function main(argv: string[] | null = null, opts: MainOptions = {}): number {
254
+ let args: Args;
255
+ try {
256
+ args = _parse(argv ?? process.argv.slice(2));
257
+ } catch (e) {
258
+ if (e instanceof ArgparseExit) {
259
+ return e.code;
260
+ }
261
+ throw e;
262
+ }
263
+
264
+ const out = opts.out ?? _stdout;
265
+ const err = opts.err ?? _stderr;
266
+ const runner = opts.runner ?? _default_runner;
267
+ const home = opts.home ?? os.homedir();
268
+ const pkg_root = opts.package_root ?? _package_root();
269
+ const settings_path =
270
+ opts.settings_path ?? user_global_paths.write_target(SETTINGS_FILENAME);
271
+ const confirm = opts.confirm ?? _tty_confirm;
272
+
273
+ const matrix_path = path.join(pkg_root, 'src', 'config', 'surface-matrix.yml');
274
+ if (!fs.existsSync(matrix_path)) {
275
+ _print(err, `❌ ${matrix_path} not found — reinstall the package (agent-config upgrade)`);
276
+ return 1;
277
+ }
278
+ let tools: Record<string, MatrixTool>;
279
+ try {
280
+ tools = _load_matrix(matrix_path);
281
+ } catch (e) {
282
+ _print(err, `❌ surface-matrix.yml unreadable: ${e instanceof Error ? e.message : String(e)}`);
283
+ return 1;
284
+ }
285
+
286
+ const findings = detect_duplicates(tools, home, pkg_root);
287
+ if (findings.length === 0) {
288
+ _print(out, '✅ no duplicate install surfaces detected — nothing to converge.');
289
+ return 0;
290
+ }
291
+
292
+ _print(out, `Duplicate install surface(s) detected on ${findings.length} tool(s):`);
293
+ for (const f of findings) {
294
+ _print(out, ` · ${f.tool}: ${f.description}`);
295
+ }
296
+ _print(out);
297
+
298
+ if (args.dry_run) {
299
+ _print(out, 'Dry run — the following actions WOULD run (nothing was touched):');
300
+ for (const f of findings) {
301
+ if (f.command) {
302
+ _print(out, ` → ${f.command}`);
303
+ }
304
+ for (const reap of f.reaps) {
305
+ _print(out, ` → rm -rf ${reap} (package-tagged orphan, matrix-declared)`);
306
+ }
307
+ }
308
+ return 0;
309
+ }
310
+
311
+ // Consent gate — standing key, --yes, or one interactive y/N.
312
+ let consented = args.yes || read_consent(settings_path);
313
+ if (!consented) {
314
+ consented = confirm(
315
+ `Converge now? This runs the per-tool uninstall command(s) above and ` +
316
+ `reaps the matrix-declared caches. Persists install.auto_converge: true.`,
317
+ );
318
+ if (!consented) {
319
+ _print(
320
+ err,
321
+ '❌ converge refused — no consent. Re-run interactively, pass --yes, or set\n' +
322
+ ` install.auto_converge: true in ${settings_path}`,
323
+ );
324
+ return 1;
325
+ }
326
+ }
327
+ persist_consent(settings_path, out);
328
+
329
+ // Perform the cleanup — matrix-declared actions ONLY.
330
+ let failures = 0;
331
+ const report: string[] = [];
332
+ for (const f of findings) {
333
+ if (f.command) {
334
+ _print(out, `→ ${f.command}`);
335
+ const rc = runner(f.command.split(' '));
336
+ if (rc !== 0) {
337
+ failures += 1;
338
+ _print(err, `⚠️ ${f.tool}: uninstall command exited ${rc} — continuing with reaps.`);
339
+ } else {
340
+ report.push(`${f.tool}: ran \`${f.command}\``);
341
+ }
342
+ }
343
+ for (const reap of f.reaps) {
344
+ // Hard floor: reap paths come from the matrix allowlist only and
345
+ // are always ~/-anchored (enforced at load; non-~/ entries were
346
+ // filtered in detect_duplicates).
347
+ const abs = _expand(reap, home, pkg_root);
348
+ if (!_exists(abs)) {
349
+ continue;
350
+ }
351
+ try {
352
+ fs.rmSync(abs, { recursive: true, force: true });
353
+ report.push(`${f.tool}: reaped package-tagged orphan \`${reap}\``);
354
+ _print(out, `→ reaped ${reap}`);
355
+ } catch (e) {
356
+ failures += 1;
357
+ _print(err, `⚠️ ${f.tool}: could not reap ${reap}: ${e instanceof Error ? e.message : String(e)}`);
358
+ }
359
+ }
360
+ }
361
+
362
+ _print(out);
363
+ _print(out, 'Convergence report:');
364
+ for (const line of report) {
365
+ _print(out, ` ✅ ${line}`);
366
+ }
367
+ if (report.length === 0) {
368
+ _print(out, ' (no actions completed)');
369
+ }
370
+ _print(
371
+ out,
372
+ 'Rollback hint: a removed Claude Code plugin can be reinstalled with\n' +
373
+ ' claude plugin marketplace add event4u-app/agent-config && ' +
374
+ 'claude plugin install agent-config@event4u-agent-config\n' +
375
+ '(the file projection is untouched — content and hooks keep working).',
376
+ );
377
+ return failures > 0 ? 1 : 0;
378
+ }
379
+
380
+ function _isCliEntry(): boolean {
381
+ if (process.argv[1] === undefined) {
382
+ return false;
383
+ }
384
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
385
+ if (import.meta.url === argvUrl) {
386
+ return true;
387
+ }
388
+ try {
389
+ const here = fs.realpathSync(fileURLToPath(import.meta.url));
390
+ const argv = fs.realpathSync(path.resolve(process.argv[1]));
391
+ return here === argv;
392
+ } catch {
393
+ return false;
394
+ }
395
+ }
396
+
397
+ if (_isCliEntry()) {
398
+ process.exit(main());
399
+ }
@@ -81,6 +81,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
81
81
  import * as YAML from 'yaml';
82
82
 
83
83
  import * as claude_plugin from '../_lib/claude_plugin.js';
84
+ import * as claude_settings_hooks from '../_lib/claude_settings_hooks.js';
84
85
  import { _is_source_repo } from './cmd_refresh.js';
85
86
  import * as installed_lock from '../_lib/installed_lock.js';
86
87
  import * as installed_tools from '../_lib/installed_tools.js';
@@ -730,6 +731,8 @@ const CHECK_IDS = [
730
731
  'scope',
731
732
  'global-binary',
732
733
  'claude-plugin',
734
+ 'surface-state',
735
+ 'hook-wiring',
733
736
  'stale-orphans',
734
737
  'manifest-integrity',
735
738
  'lockfile-freshness',
@@ -749,6 +752,8 @@ const GLOBAL_CHECK_IDS: ReadonlySet<string> = new Set([
749
752
  'scope',
750
753
  'global-binary',
751
754
  'claude-plugin',
755
+ 'surface-state',
756
+ 'hook-wiring',
752
757
  'stale-orphans',
753
758
  'mcp-mode',
754
759
  'mcp-beta-readiness',
@@ -1005,12 +1010,14 @@ function lstripV(s: string): string {
1005
1010
  }
1006
1011
 
1007
1012
  /**
1008
- * Claude Code plugin state. The plugin is OPTIONAL — the `~/.claude/` file
1009
- * projection is a full install on its own — so a missing plugin is `ok`,
1010
- * never a warning. What DOES warn is a stale installed plugin: Claude Code
1011
- * pins the plugin to its install-time git SHA, so its command surface
1012
- * silently lags every upgrade until `claude plugin update` runs
1013
- * (`agent-config upgrade` now does that automatically).
1013
+ * Claude Code plugin state under the single-surface model
1014
+ * (road-to-claude-code-single-surface): the `~/.claude/` file projection is
1015
+ * THE distribution surface for Claude Code — content, and hooks via the
1016
+ * managed settings.json block. An installed marketplace plugin next to the
1017
+ * projection is a **duplicate surface**: every skill/command lists twice
1018
+ * and the plugin's SHA snapshot rots silently. That is `fail`, with the
1019
+ * uninstall command as the fix (never uninstalled autonomously — the plugin
1020
+ * is a user-owned surface).
1014
1021
  */
1015
1022
  function _check_claude_plugin(): Dict {
1016
1023
  const claude = shutilWhich('claude');
@@ -1027,11 +1034,27 @@ function _check_claude_plugin(): Dict {
1027
1034
  id: 'claude-plugin',
1028
1035
  status: 'ok',
1029
1036
  message:
1030
- 'plugin not installed (optional — the ~/.claude/ file projection ' +
1031
- 'already carries the full skill/command set)',
1037
+ 'plugin not installed (single-surface model — the ~/.claude/ file ' +
1038
+ 'projection carries content AND hooks)',
1032
1039
  remedy: '',
1033
1040
  };
1034
1041
  }
1042
+ const projection = path.join(os.homedir(), '.claude', 'skills');
1043
+ const projection_present = isDir(projection);
1044
+ if (projection_present) {
1045
+ return {
1046
+ id: 'claude-plugin',
1047
+ status: 'fail',
1048
+ message:
1049
+ 'duplicate surface: the marketplace plugin AND the ~/.claude/ file ' +
1050
+ 'projection are both installed — every skill/command lists twice and ' +
1051
+ 'the plugin snapshot rots silently',
1052
+ remedy:
1053
+ `claude plugin uninstall ${claude_plugin.CLAUDE_PLUGIN_ID}@${claude_plugin.CLAUDE_MARKETPLACE_NAME} ` +
1054
+ '(hooks stay — they are registered in ~/.claude/settings.json; ' +
1055
+ 'verify with the hook-wiring check)',
1056
+ };
1057
+ }
1035
1058
  const snapshot_v = lstripV(claude_plugin.claude_plugin_snapshot_version() || '');
1036
1059
  const binary_v = lstripV(_current_package_version() || '');
1037
1060
  if (snapshot_v && binary_v && snapshot_v !== binary_v) {
@@ -1046,12 +1069,179 @@ function _check_claude_plugin(): Dict {
1046
1069
  }
1047
1070
  return {
1048
1071
  id: 'claude-plugin',
1072
+ status: 'warn',
1073
+ message:
1074
+ `plugin installed${snapshot_v ? ` (snapshot ${snapshot_v})` : ''} without the ` +
1075
+ 'file projection — deprecated surface; the projection is the supported path',
1076
+ remedy: 'agent-config global (then uninstall the plugin per the printed hint)',
1077
+ };
1078
+ }
1079
+
1080
+ /**
1081
+ * Matrix-driven duplicate-surface check (road-to-install-path-convergence
1082
+ * Phase 2): reads src/config/surface-matrix.yml and flags every tool whose
1083
+ * declared duplicate-class detect paths ALL exist on this machine.
1084
+ * Generalizes the `claude-plugin` check — which stays as the named first
1085
+ * consumer — to every tool with a defined duplicate class; tools marked
1086
+ * `pending_evidence` are documented but never acted on.
1087
+ */
1088
+ function _check_surface_state(): Dict {
1089
+ const matrix_path = path.join(_package_root(), 'src', 'config', 'surface-matrix.yml');
1090
+ if (!isFile(matrix_path)) {
1091
+ return {
1092
+ id: 'surface-state',
1093
+ status: 'skipped',
1094
+ message: 'surface-matrix.yml not found in this package',
1095
+ remedy: '',
1096
+ };
1097
+ }
1098
+ let tools: Dict = {};
1099
+ try {
1100
+ const raw = YAML.parse(readText(matrix_path), { version: '1.1' }) as Dict;
1101
+ const t = raw['tools'];
1102
+ if (t && typeof t === 'object' && !Array.isArray(t)) {
1103
+ tools = t as Dict;
1104
+ }
1105
+ } catch {
1106
+ return {
1107
+ id: 'surface-state',
1108
+ status: 'warn',
1109
+ message: 'surface-matrix.yml unreadable — duplicate-surface state unknown',
1110
+ remedy: 'agent-config upgrade (reinstalls the package data files)',
1111
+ };
1112
+ }
1113
+
1114
+ const _expand = (p: string): string =>
1115
+ p.startsWith('~/') ? path.join(os.homedir(), p.slice(2)) : path.join(_package_root(), p);
1116
+
1117
+ const violations: string[] = [];
1118
+ const remedies: string[] = [];
1119
+ let checked = 0;
1120
+ for (const [tool_id, entry] of Object.entries(tools)) {
1121
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
1122
+ continue;
1123
+ }
1124
+ const dup = (entry as Dict)['duplicate'] as Dict | undefined;
1125
+ const detect = dup ? (dup['detect'] as Dict | undefined) : undefined;
1126
+ const all_of = detect ? (detect['all_of'] as unknown) : undefined;
1127
+ if (!Array.isArray(all_of) || all_of.length === 0) {
1128
+ continue;
1129
+ }
1130
+ checked += 1;
1131
+ const present = all_of.every((p) => typeof p === 'string' && pathExistsAny(_expand(p)));
1132
+ if (present) {
1133
+ violations.push(tool_id);
1134
+ const conv = (entry as Dict)['converge'] as Dict | undefined;
1135
+ const cmd = conv ? String(conv['command'] ?? '') : '';
1136
+ if (cmd) {
1137
+ remedies.push(cmd);
1138
+ }
1139
+ }
1140
+ }
1141
+
1142
+ if (violations.length > 0) {
1143
+ return {
1144
+ id: 'surface-state',
1145
+ status: 'fail',
1146
+ message:
1147
+ `duplicate surface on ${violations.length} tool(s): ${violations.join(', ')} — ` +
1148
+ 'canonical + secondary install surfaces are both present',
1149
+ remedy: `${remedies.join(' · ')}${remedies.length ? ' — or: ' : ''}agent-config converge`,
1150
+ };
1151
+ }
1152
+ return {
1153
+ id: 'surface-state',
1049
1154
  status: 'ok',
1050
- message: `plugin installed${snapshot_v ? ` (snapshot ${snapshot_v})` : ''}`,
1155
+ message: `${checked} declared duplicate class(es) checked, none present`,
1051
1156
  remedy: '',
1052
1157
  };
1053
1158
  }
1054
1159
 
1160
+ /** exists() that is true for files AND directories (surface detect paths). */
1161
+ function pathExistsAny(p: string): boolean {
1162
+ try {
1163
+ fs.statSync(p);
1164
+ return true;
1165
+ } catch {
1166
+ return false;
1167
+ }
1168
+ }
1169
+
1170
+ /**
1171
+ * Managed hook wiring (single-surface model): the deterministic hook matrix
1172
+ * must be registered in ~/.claude/settings.json AND the dispatch target
1173
+ * (`agent-config` binary) must resolve on PATH. Both halves are required —
1174
+ * hooks without a binary silently no-op every session.
1175
+ */
1176
+ function _check_hook_wiring(): Dict {
1177
+ const claude = shutilWhich('claude');
1178
+ if (claude === null) {
1179
+ return {
1180
+ id: 'hook-wiring',
1181
+ status: 'skipped',
1182
+ message: 'Claude Code CLI not on PATH — hook-wiring check not applicable',
1183
+ remedy: '',
1184
+ };
1185
+ }
1186
+ const settings_path = path.join(os.homedir(), '.claude', 'settings.json');
1187
+ let missing: string[];
1188
+ try {
1189
+ const matrix = claude_settings_hooks.build_claude_hook_matrix(
1190
+ path.join(_package_root(), 'src', 'scripts', 'hook_manifest.yaml'),
1191
+ );
1192
+ const settings = pathExists(settings_path)
1193
+ ? (JSON.parse(fs.readFileSync(settings_path, 'utf8')) as Record<string, unknown>)
1194
+ : {};
1195
+ const hooks = ((settings['hooks'] ?? {}) as Record<string, unknown>) || {};
1196
+ missing = Object.keys(matrix).filter((ev) => {
1197
+ const groups = hooks[ev];
1198
+ if (!Array.isArray(groups)) return true;
1199
+ return !JSON.stringify(groups).includes(claude_settings_hooks.MANAGED_SIGNATURE);
1200
+ });
1201
+ } catch (e) {
1202
+ return {
1203
+ id: 'hook-wiring',
1204
+ status: 'fail',
1205
+ message: `cannot evaluate managed hooks: ${String(e)}`,
1206
+ remedy: 'fix ~/.claude/settings.json (invalid JSON?), then agent-config refresh --global',
1207
+ };
1208
+ }
1209
+ const binary_ok = shutilWhich('agent-config') !== null;
1210
+ if (missing.length === 0 && binary_ok) {
1211
+ return {
1212
+ id: 'hook-wiring',
1213
+ status: 'ok',
1214
+ message: 'managed hooks registered in ~/.claude/settings.json; dispatch binary on PATH',
1215
+ remedy: '',
1216
+ };
1217
+ }
1218
+ if (missing.length > 0 && claude_plugin.claude_plugin_installed()) {
1219
+ return {
1220
+ id: 'hook-wiring',
1221
+ status: 'warn',
1222
+ message:
1223
+ `managed hooks missing for: ${missing.join(', ')} — currently carried by the ` +
1224
+ 'installed plugin (deprecated surface)',
1225
+ remedy: 'agent-config refresh --global (writes the managed settings.json block)',
1226
+ };
1227
+ }
1228
+ const parts: string[] = [];
1229
+ if (missing.length > 0) parts.push(`managed hooks missing for: ${missing.join(', ')}`);
1230
+ if (!binary_ok) parts.push('`agent-config` not on PATH (hooks would silently no-op)');
1231
+ if (parts.length === 0) {
1232
+ // hooks complete, binary missing was the only issue → covered above.
1233
+ parts.push('`agent-config` not on PATH');
1234
+ }
1235
+ return {
1236
+ id: 'hook-wiring',
1237
+ status: 'fail',
1238
+ message: parts.join('; '),
1239
+ remedy: binary_ok
1240
+ ? 'agent-config refresh --global'
1241
+ : 'npm install -g @event4u/agent-config, then agent-config refresh --global',
1242
+ };
1243
+ }
1244
+
1055
1245
  function _check_bridge_drift(
1056
1246
  missing: Dict[],
1057
1247
  modified: Dict[],
@@ -1818,6 +2008,8 @@ function _run_checks(
1818
2008
  scope: () => _check_scope(project_root),
1819
2009
  'global-binary': () => _check_global_binary(project_root),
1820
2010
  'claude-plugin': _check_claude_plugin,
2011
+ 'surface-state': _check_surface_state,
2012
+ 'hook-wiring': _check_hook_wiring,
1821
2013
  'stale-orphans': _check_stale_orphans,
1822
2014
  'manifest-integrity': () => _check_manifest_integrity(manifest),
1823
2015
  'lockfile-freshness': () => _check_lockfile_freshness(manifest),
@@ -1890,6 +2082,8 @@ function _run_checks_no_manifest(
1890
2082
  scope: () => _check_scope(project_root),
1891
2083
  'global-binary': () => _check_global_binary(project_root),
1892
2084
  'claude-plugin': _check_claude_plugin,
2085
+ 'surface-state': _check_surface_state,
2086
+ 'hook-wiring': _check_hook_wiring,
1893
2087
  'stale-orphans': _check_stale_orphans,
1894
2088
  'manifest-integrity': () => _skipped_manifest_check('manifest-integrity'),
1895
2089
  'lockfile-freshness': () => _skipped_manifest_check('lockfile-freshness'),