@event4u/agent-config 8.3.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.
@@ -1810,65 +1810,96 @@ export function generate_subagent_host_contexts(): number {
1810
1810
  return count;
1811
1811
  }
1812
1812
 
1813
+ // Bootstrap shim (road-to-install-path-convergence): the Claude Code plugin
1814
+ // ships ZERO content skills. It carries only hooks/hooks.json (byte-identical
1815
+ // to the managed settings block, so Claude Code dedupes) plus this single
1816
+ // pointer skill whose description names the canonical install command.
1817
+ export const PLUGIN_POINTER_SLUG = 'install-agent-config';
1818
+ export const PLUGIN_POINTER_SKILL_BODY = `---
1819
+ name: install-agent-config
1820
+ description: >-
1821
+ Install or upgrade the event4u agent-config suite. This marketplace plugin
1822
+ is a bootstrap shim — it ships hooks plus this pointer only; ALL skills,
1823
+ rules, and commands are installed via \`npx -y @event4u/agent-config init\`.
1824
+ Use when the user asks to install, set up, or upgrade agent-config, or
1825
+ wonders why this plugin contains no other skills.
1826
+ ---
1827
+
1828
+ # Install agent-config
1829
+
1830
+ This plugin is a **bootstrap shim**. It exists so the marketplace listing
1831
+ stays discoverable and existing plugin installs keep working hooks — the
1832
+ content surface (skills, rules, commands, personas) is distributed as a
1833
+ file projection, not through this plugin.
1834
+
1835
+ ## Install (canonical, one command)
1836
+
1837
+ \`\`\`bash
1838
+ npx -y @event4u/agent-config init
1839
+ \`\`\`
1840
+
1841
+ The installer writes the full content projection, registers the dispatcher
1842
+ hooks as a managed block in \`~/.claude/settings.json\` (byte-identical to
1843
+ this plugin's hooks, so nothing double-fires), and keeps everything
1844
+ upgradeable via \`agent-config upgrade\`.
1845
+
1846
+ ## Already installed both?
1847
+
1848
+ Run \`agent-config doctor\` to check for duplicate surfaces, and
1849
+ \`agent-config converge\` to clean up consented duplicates.
1850
+ `;
1851
+
1813
1852
  export function generate_plugin_command_skills(): number {
1814
1853
  if (!_isDir(path.join(MODULE_STATE.PROJECT_ROOT, 'src', 'domains'))) {
1815
1854
  return 0;
1816
1855
  }
1817
1856
  _mkdirp(MODULE_STATE.PLUGIN_SKILLS_DIR);
1818
1857
 
1819
- let skill_names = new Set<string>();
1820
- if (_exists(MODULE_STATE.SKILLS_SOURCE)) {
1821
- skill_names = new Set(
1822
- _iterdirSorted(MODULE_STATE.SKILLS_SOURCE)
1823
- .filter((p) => _isDir(p))
1824
- .map((p) => path.basename(p)),
1825
- );
1858
+ // 1. Materialize the pointer skill (idempotent overwrite-on-drift).
1859
+ const pointer_dir = path.join(MODULE_STATE.PLUGIN_SKILLS_DIR, PLUGIN_POINTER_SLUG);
1860
+ _mkdirp(pointer_dir);
1861
+ const pointer_file = path.join(pointer_dir, 'SKILL.md');
1862
+ if (_isSymlink(pointer_file)) {
1863
+ fs.unlinkSync(pointer_file);
1826
1864
  }
1827
-
1828
- const current_slugs = new Set<string>();
1829
- let count = 0;
1830
- for (const [source_file, slug] of iterCommands()) {
1831
- if (skill_names.has(slug)) {
1832
- continue;
1833
- }
1834
- current_slugs.add(slug);
1835
- const skill_dir = path.join(MODULE_STATE.PLUGIN_SKILLS_DIR, slug);
1836
- _mkdirp(skill_dir);
1837
- const skill_file = path.join(skill_dir, 'SKILL.md');
1838
- if (_existsOrSymlink(skill_file)) {
1839
- fs.unlinkSync(skill_file);
1840
- }
1841
- const rel_path = _relativeToPosix(source_file, MODULE_STATE.PROJECT_ROOT);
1842
- const rel_target = path.join('../../..', rel_path);
1843
- fs.symlinkSync(rel_target, skill_file);
1844
- count += 1;
1865
+ if (!_isFile(pointer_file) || fs.readFileSync(pointer_file, 'utf-8') !== PLUGIN_POINTER_SKILL_BODY) {
1866
+ fs.writeFileSync(pointer_file, PLUGIN_POINTER_SKILL_BODY);
1845
1867
  }
1846
1868
 
1869
+ // 2. Prune every other skill dir so the tree converges to shim shape
1870
+ // from any prior state. Removable = every entry is a symlink (the
1871
+ // generated projection shape — SKILL.md and optional evals links into
1872
+ // src/), or the dir holds only a single SKILL.md file (the old sweep's
1873
+ // rule). Dirs with real extra files are never touched.
1847
1874
  let removed_dirs = 0;
1848
1875
  for (const item of _iterdirSorted(MODULE_STATE.PLUGIN_SKILLS_DIR)) {
1849
1876
  if (!_isDirNoFollow(item) || _isSymlink(item)) {
1850
1877
  continue;
1851
1878
  }
1852
- if (current_slugs.has(path.basename(item))) {
1879
+ if (path.basename(item) === PLUGIN_POINTER_SLUG) {
1853
1880
  continue;
1854
1881
  }
1855
- const skill_md = path.join(item, 'SKILL.md');
1856
- if (_isSymlink(skill_md) || _isFile(skill_md)) {
1857
- const entries = _iterdirSorted(item);
1858
- if (entries.length === 1 && path.basename(entries[0] as string) === 'SKILL.md') {
1859
- fs.unlinkSync(skill_md);
1860
- fs.rmdirSync(item);
1861
- removed_dirs += 1;
1882
+ const entries = _iterdirSorted(item);
1883
+ const all_symlinks = entries.length > 0 && entries.every((e) => _isSymlink(e));
1884
+ const single_skill_md =
1885
+ entries.length === 1 &&
1886
+ path.basename(entries[0] as string) === 'SKILL.md' &&
1887
+ _isFile(entries[0] as string);
1888
+ if (all_symlinks || single_skill_md) {
1889
+ for (const e of entries) {
1890
+ fs.unlinkSync(e);
1862
1891
  }
1892
+ fs.rmdirSync(item);
1893
+ removed_dirs += 1;
1863
1894
  }
1864
1895
  }
1865
1896
 
1866
- let msg = ` ✅ Created ${count} command entries in .claude-plugin/skills/`;
1897
+ let msg = ' ✅ Plugin skills: bootstrap shim (1 pointer skill) in .claude-plugin/skills/';
1867
1898
  if (removed_dirs) {
1868
- msg += ` (${removed_dirs} stale dirs removed)`;
1899
+ msg += ` (${removed_dirs} content entries pruned)`;
1869
1900
  }
1870
1901
  info(msg);
1871
- return count;
1902
+ return 1;
1872
1903
  }
1873
1904
 
1874
1905
  export function generate_persona_symlinks(): number {
@@ -125,17 +125,26 @@ concerns:
125
125
  script: src/scripts/wrapper_freshness_hook.ts
126
126
  args: []
127
127
  fail_closed: false
128
+ # road-to-install-path-convergence Phase 4 — duplicate-surface probe.
129
+ # Reads src/config/surface-matrix.yml, existence-checks declared duplicate
130
+ # classes, nudges ONCE per day (state: agents/runtime/state/
131
+ # surface-probe.json). Fail-open, never blocking; silent when clean or
132
+ # when install.auto_converge is already set.
133
+ surface-probe:
134
+ script: src/scripts/surface_probe_hook.ts
135
+ args: []
136
+ fail_closed: false
128
137
 
129
138
  platforms:
130
139
  augment:
131
- session_start: [chat-history, hot-context, first-run-gate, onboarding-gate, verify-before-complete, minimal-safe-diff, profile-staleness, wrapper-freshness]
140
+ session_start: [chat-history, hot-context, first-run-gate, onboarding-gate, verify-before-complete, minimal-safe-diff, profile-staleness, wrapper-freshness, surface-probe]
132
141
  session_end: [chat-history]
133
142
  stop: [chat-history, hot-context, verify-before-complete]
134
143
  pre_tool_use: [block-no-verify, rtk-wrap, design-slop]
135
144
  post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan]
136
145
 
137
146
  claude:
138
- session_start: [chat-history, hot-context, first-run-gate, onboarding-gate, verify-before-complete, minimal-safe-diff, profile-staleness, wrapper-freshness]
147
+ session_start: [chat-history, hot-context, first-run-gate, onboarding-gate, verify-before-complete, minimal-safe-diff, profile-staleness, wrapper-freshness, surface-probe]
139
148
  session_end: [chat-history]
140
149
  stop: [chat-history, hot-context, verify-before-complete]
141
150
  user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff]
@@ -157,7 +166,7 @@ platforms:
157
166
  # Decision matrix + upstream blockers tracked in
158
167
  # agents/settings/contexts/chat-history-platform-hooks.md § Cowork.
159
168
  cowork:
160
- session_start: [chat-history, hot-context, first-run-gate, onboarding-gate, verify-before-complete, minimal-safe-diff, profile-staleness, wrapper-freshness]
169
+ session_start: [chat-history, hot-context, first-run-gate, onboarding-gate, verify-before-complete, minimal-safe-diff, profile-staleness, wrapper-freshness, surface-probe]
161
170
  session_end: [chat-history]
162
171
  stop: [chat-history, hot-context, verify-before-complete]
163
172
  user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff]
@@ -172,7 +181,7 @@ platforms:
172
181
  # IDE-only — CLI-only users fall back to /checkpoint per
173
182
  # agents/settings/contexts/chat-history-platform-hooks.md.
174
183
  cursor:
175
- session_start: [chat-history, hot-context, first-run-gate, onboarding-gate, verify-before-complete, minimal-safe-diff, profile-staleness, wrapper-freshness]
184
+ session_start: [chat-history, hot-context, first-run-gate, onboarding-gate, verify-before-complete, minimal-safe-diff, profile-staleness, wrapper-freshness, surface-probe]
176
185
  session_end: [chat-history]
177
186
  stop: [chat-history, hot-context, verify-before-complete]
178
187
  user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff]
@@ -187,7 +196,7 @@ platforms:
187
196
  # both map to session_start. TaskCancel maps to stop because the
188
197
  # session is interrupted with partial state (mirrors Augment Stop).
189
198
  cline:
190
- session_start: [chat-history, hot-context, first-run-gate, onboarding-gate, verify-before-complete, minimal-safe-diff, profile-staleness, wrapper-freshness]
199
+ session_start: [chat-history, hot-context, first-run-gate, onboarding-gate, verify-before-complete, minimal-safe-diff, profile-staleness, wrapper-freshness, surface-probe]
191
200
  session_end: [chat-history]
192
201
  stop: [chat-history, hot-context, verify-before-complete]
193
202
  user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff]
@@ -206,7 +215,7 @@ platforms:
206
215
  # surface to record verification commands; documented limitation).
207
216
  # minimal-safe-diff is omitted entirely on Windsurf for the same reason.
208
217
  windsurf:
209
- session_start: [chat-history, hot-context, first-run-gate, onboarding-gate, verify-before-complete, profile-staleness, wrapper-freshness]
218
+ session_start: [chat-history, hot-context, first-run-gate, onboarding-gate, verify-before-complete, profile-staleness, wrapper-freshness, surface-probe]
210
219
  stop: [chat-history, hot-context, verify-before-complete]
211
220
  user_prompt_submit: [chat-history, verify-before-complete]
212
221
 
@@ -221,7 +230,7 @@ platforms:
221
230
  # turn-check semantics. AfterAgent fires when the agent loop ends
222
231
  # — this is our `stop` slot.
223
232
  gemini:
224
- session_start: [chat-history, hot-context, first-run-gate, onboarding-gate, verify-before-complete, minimal-safe-diff, profile-staleness, wrapper-freshness]
233
+ session_start: [chat-history, hot-context, first-run-gate, onboarding-gate, verify-before-complete, minimal-safe-diff, profile-staleness, wrapper-freshness, surface-probe]
225
234
  session_end: [chat-history]
226
235
  stop: [chat-history, hot-context, verify-before-complete]
227
236
  user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff]
@@ -6,16 +6,20 @@
6
6
  * Wave 4b). Mirrors the Python CLI contract exactly: same cwd-relative scan
7
7
  * roots, finding messages, stdout output, and exit codes.
8
8
  *
9
- * Validates the Claude Code Plugin Marketplace manifest against the canonical
10
- * manifest shape:
9
+ * Validates the Claude Code Plugin Marketplace manifest against the
10
+ * BOOTSTRAP-SHIM shape (road-to-install-path-convergence): the plugin ships
11
+ * ONLY hooks plus a single install-pointer skill — content skills are
12
+ * distributed via the npx file projection, never through this plugin.
11
13
  *
12
14
  * - Required top-level fields: name, owner, metadata, plugins
13
15
  * - owner must have name + email
14
16
  * - metadata must have description + version
15
17
  * - metadata.version must match package.json (single source of truth)
16
- * - every plugins[].skills[] entry must exist on disk and carry a SKILL.md
17
- * - every SKILL.md on disk under the committed skill sources must be listed
18
- * in some plugin's skills[] (drift detection)
18
+ * - the union of plugins[].skills[] must be EXACTLY the pointer skill
19
+ * (./.claude-plugin/skills/install-agent-config) — a repopulated
20
+ * content-skill list FAILS by design
21
+ * - the pointer entry must exist on disk and carry a SKILL.md
22
+ * - .claude-plugin/skills/ on disk must contain ONLY the pointer dir
19
23
  *
20
24
  * Exit codes: 0 = clean, 1 = problems found, 3 = internal error.
21
25
  */
@@ -26,14 +30,13 @@ import { fileURLToPath, pathToFileURL } from "node:url";
26
30
  const ROOT = ".";
27
31
  const MARKETPLACE = path.join(ROOT, ".claude-plugin", "marketplace.json");
28
32
  const PACKAGE_JSON = path.join(ROOT, "package.json");
29
- // Committed marketplace skill sources (git-consumed): real skills resolve to
30
- // dist/agent-src/skills/<name>; command-as-skill entries to the committed
31
- // .claude-plugin/skills/<slug> projection. .claude/skills/ is a gitignored
32
- // local channel and is intentionally NOT a marketplace source.
33
- const SKILL_SOURCE_DIRS = [
34
- path.join(ROOT, "dist/agent-src", "skills"),
35
- path.join(ROOT, ".claude-plugin", "skills"),
36
- ];
33
+ // Bootstrap shim: the ONLY skill the plugin may list. Content skills live in
34
+ // the npx file projection (dist/agent-src/skills/ ships in the npm package,
35
+ // not through the marketplace). .claude/skills/ is a gitignored local channel
36
+ // and is intentionally NOT a marketplace source.
37
+ const POINTER_SKILL_ENTRY = "./.claude-plugin/skills/install-agent-config";
38
+ const PLUGIN_SKILLS_DIR = path.join(ROOT, ".claude-plugin", "skills");
39
+ const POINTER_SKILL_DIRNAME = "install-agent-config";
37
40
 
38
41
  type JsonValue =
39
42
  | string
@@ -174,6 +177,18 @@ export function main(): number {
174
177
  }
175
178
  seen.add(p);
176
179
 
180
+ // Bootstrap-shim invariant: any entry other than the pointer skill is
181
+ // a repopulated content-skill list and must FAIL.
182
+ if (p !== POINTER_SKILL_ENTRY) {
183
+ errors.push(
184
+ `${entry} violates the bootstrap shim: the plugin must not ship ` +
185
+ `content skills (only \`${POINTER_SKILL_ENTRY}\` is allowed), ` +
186
+ `got \`${p}\` — content is distributed via ` +
187
+ "`npx -y @event4u/agent-config init`",
188
+ );
189
+ continue;
190
+ }
191
+
177
192
  // Resolve path relative to repo root (strip leading "./" only,
178
193
  // NOT every "." and "/" character)
179
194
  const rel = removePrefix(p, "./");
@@ -189,9 +204,7 @@ export function main(): number {
189
204
  }
190
205
  }
191
206
 
192
- // Reverse-completeness: every SKILL.md on disk under the committed skill
193
- // sources (dist/agent-src/skills/ + .claude-plugin/skills/) must appear in
194
- // some plugin's skills[].
207
+ // Shim completeness: the pointer skill must be listed by some plugin.
195
208
  const listed = new Set<string>();
196
209
  for (const plugin of plugins) {
197
210
  if (!isObject(plugin)) {
@@ -201,43 +214,38 @@ export function main(): number {
201
214
  if (Array.isArray(skills)) {
202
215
  for (const p of skills) {
203
216
  if (typeof p === "string") {
204
- listed.add(removePrefix(p, "./"));
217
+ listed.add(p);
205
218
  }
206
219
  }
207
220
  }
208
221
  }
222
+ if (plugins.length > 0 && !listed.has(POINTER_SKILL_ENTRY)) {
223
+ errors.push(
224
+ `bootstrap shim: pointer skill \`${POINTER_SKILL_ENTRY}\` is not ` +
225
+ "listed in any plugin's skills[]",
226
+ );
227
+ }
209
228
 
210
- for (const sourceDir of SKILL_SOURCE_DIRS) {
211
- if (!_exists(sourceDir)) {
212
- continue;
213
- }
214
- // source_dir.relative_to(ROOT).as_posix() — ROOT is ".", so this is the
215
- // dir spelled relative to cwd with POSIX separators.
216
- const prefix = relPosix(sourceDir, ROOT);
217
- let entries: fs.Dirent[];
229
+ // Shim disk shape: .claude-plugin/skills/ must contain ONLY the pointer
230
+ // dir — a repopulated symlink tree (e.g. from a stale generator) FAILS.
231
+ if (_exists(PLUGIN_SKILLS_DIR)) {
232
+ let entries: fs.Dirent[] = [];
218
233
  try {
219
- entries = fs.readdirSync(sourceDir, { withFileTypes: true });
234
+ entries = fs.readdirSync(PLUGIN_SKILLS_DIR, { withFileTypes: true });
220
235
  } catch {
221
- continue;
236
+ /* unreadable → nothing to assert */
222
237
  }
223
- // sorted(source_dir.iterdir()) — full-path string sort.
224
- const dirs = entries
225
- .map((e) => ({ name: e.name, full: path.join(sourceDir, e.name) }))
226
- .sort((a, b) => (a.full < b.full ? -1 : a.full > b.full ? 1 : 0));
227
- for (const d of dirs) {
228
- if (!_isDir(d.full)) {
229
- continue;
230
- }
231
- if (!_exists(path.join(d.full, "SKILL.md"))) {
232
- continue;
233
- }
234
- const rel = `${prefix}/${d.name}`;
235
- if (!listed.has(rel)) {
236
- errors.push(
237
- "skill exists on disk but is not listed in marketplace.json: " +
238
- `\`./${rel}\``,
239
- );
240
- }
238
+ const extra = entries
239
+ .filter((e) => _isDir(path.join(PLUGIN_SKILLS_DIR, e.name)))
240
+ .map((e) => e.name)
241
+ .filter((name) => name !== POINTER_SKILL_DIRNAME)
242
+ .sort();
243
+ for (const name of extra) {
244
+ errors.push(
245
+ "bootstrap shim: unexpected skill dir on disk (plugin ships no " +
246
+ `content skills): \`.claude-plugin/skills/${name}\` — remove it ` +
247
+ "or re-run the generator",
248
+ );
241
249
  }
242
250
  }
243
251
 
@@ -246,15 +254,9 @@ export function main(): number {
246
254
  }
247
255
 
248
256
  const pluginCount = plugins.length;
249
- let skillCount = 0;
250
- for (const p of plugins) {
251
- if (isObject(p) && Array.isArray(p["skills"])) {
252
- skillCount += p["skills"].length;
253
- }
254
- }
255
257
  process.stdout.write(
256
258
  `✅ marketplace.json (${pluginCount} plugin` +
257
- `${pluginCount !== 1 ? "s" : ""}, ${skillCount} skills total)\n`,
259
+ `${pluginCount !== 1 ? "s" : ""}, bootstrap shim: 1 pointer skill)\n`,
258
260
  );
259
261
  process.stdout.write(" No issues found.\n");
260
262
  return 0;
@@ -268,10 +270,6 @@ function _isDir(p: string): boolean {
268
270
  }
269
271
  }
270
272
 
271
- function relPosix(child: string, base: string): string {
272
- return path.relative(base, child).split(path.sep).join("/");
273
- }
274
-
275
273
  function _isCliEntry(): boolean {
276
274
  if (process.argv[1] === undefined) {
277
275
  return false;
@@ -0,0 +1,200 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Lint src/config/surface-matrix.yml — the machine-checked per-tool
4
+ * canonical-surface inventory (road-to-install-path-convergence Phase 2).
5
+ *
6
+ * Guarantees:
7
+ * - every tool in install.ts::USER_SCOPE_PATHS has a matrix entry, and the
8
+ * matrix declares no tool the installer does not know (set equality)
9
+ * - each entry's scope_path matches USER_SCOPE_PATHS (drift fails)
10
+ * - surface / hooks values come from the closed enums
11
+ * - duplicate.detect.all_of paths are user-scope (~/) or repo-relative
12
+ * (./); repo-relative paths must resolve on disk
13
+ * - a defined duplicate class must carry a converge action, and the
14
+ * claude-code converge command must name the real plugin id (drift guard
15
+ * against _lib/claude_plugin.ts)
16
+ *
17
+ * Exit codes: 0 = clean, 1 = problems found, 3 = internal error.
18
+ */
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+ import { fileURLToPath, pathToFileURL } from 'node:url';
22
+ import YAML from 'yaml';
23
+
24
+ import { USER_SCOPE_PATHS } from './install.js';
25
+ import { CLAUDE_MARKETPLACE_NAME, CLAUDE_PLUGIN_ID } from './_lib/claude_plugin.js';
26
+
27
+ const REPO_ROOT = path.resolve(fileURLToPath(import.meta.url), '..', '..', '..');
28
+ export const MATRIX_PATH = path.join(REPO_ROOT, 'src', 'config', 'surface-matrix.yml');
29
+
30
+ const SURFACE_VALUES = new Set(['projection', 'plugin', 'bundles', 'export-only']);
31
+ const HOOKS_VALUES = new Set(['managed-settings-block', 'settings-hooks-opt-in', 'plugin', 'none']);
32
+
33
+ type JsonObject = Record<string, unknown>;
34
+
35
+ function isObject(v: unknown): v is JsonObject {
36
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
37
+ }
38
+
39
+ export interface SurfaceMatrixTool {
40
+ surface: string;
41
+ scope_path: string;
42
+ hooks: string;
43
+ duplicate?: {
44
+ description?: string;
45
+ pending_evidence?: string;
46
+ detect?: { all_of?: string[] };
47
+ };
48
+ converge?: { action?: string; command?: string; reaps?: string[] };
49
+ notes?: string;
50
+ }
51
+
52
+ export function load_surface_matrix(matrixPath: string = MATRIX_PATH): Record<string, SurfaceMatrixTool> {
53
+ const raw = YAML.parse(fs.readFileSync(matrixPath, 'utf-8'), { version: '1.1' }) as JsonObject;
54
+ const tools = raw['tools'];
55
+ if (!isObject(tools)) {
56
+ throw new Error('surface-matrix.yml: `tools` must be a mapping');
57
+ }
58
+ return tools as unknown as Record<string, SurfaceMatrixTool>;
59
+ }
60
+
61
+ export function main(): number {
62
+ if (!fs.existsSync(MATRIX_PATH)) {
63
+ process.stdout.write(`❌ ${MATRIX_PATH} not found\n`);
64
+ return 1;
65
+ }
66
+
67
+ const errors: string[] = [];
68
+ let tools: Record<string, SurfaceMatrixTool>;
69
+ try {
70
+ tools = load_surface_matrix();
71
+ } catch (e) {
72
+ const msg = e instanceof Error ? e.message : String(e);
73
+ process.stdout.write(`❌ surface-matrix.yml unreadable: ${msg}\n`);
74
+ return 1;
75
+ }
76
+
77
+ // Coverage — set equality with the installer's tool inventory.
78
+ const matrixIds = new Set(Object.keys(tools));
79
+ const installerIds = new Set(Object.keys(USER_SCOPE_PATHS));
80
+ for (const id of installerIds) {
81
+ if (!matrixIds.has(id)) {
82
+ errors.push(`tool \`${id}\` is in USER_SCOPE_PATHS but has no surface-matrix entry`);
83
+ }
84
+ }
85
+ for (const id of matrixIds) {
86
+ if (!installerIds.has(id)) {
87
+ errors.push(`tool \`${id}\` is in the surface matrix but not in USER_SCOPE_PATHS`);
88
+ }
89
+ }
90
+
91
+ for (const [id, entry] of Object.entries(tools)) {
92
+ const where = `tools.${id}`;
93
+ if (!isObject(entry)) {
94
+ errors.push(`${where} must be a mapping`);
95
+ continue;
96
+ }
97
+ if (!SURFACE_VALUES.has(entry.surface)) {
98
+ errors.push(`${where}.surface \`${String(entry.surface)}\` not in {${[...SURFACE_VALUES].join(', ')}}`);
99
+ }
100
+ if (!HOOKS_VALUES.has(entry.hooks)) {
101
+ errors.push(`${where}.hooks \`${String(entry.hooks)}\` not in {${[...HOOKS_VALUES].join(', ')}}`);
102
+ }
103
+ const expectedPath = USER_SCOPE_PATHS[id];
104
+ if (expectedPath && entry.scope_path !== expectedPath) {
105
+ errors.push(
106
+ `${where}.scope_path \`${String(entry.scope_path)}\` drifted from ` +
107
+ `USER_SCOPE_PATHS \`${expectedPath}\``,
108
+ );
109
+ }
110
+
111
+ const dup = entry.duplicate;
112
+ if (dup !== undefined) {
113
+ if (!isObject(dup)) {
114
+ errors.push(`${where}.duplicate must be a mapping`);
115
+ continue;
116
+ }
117
+ const detect = dup.detect;
118
+ if (detect !== undefined) {
119
+ const allOf = isObject(detect) ? detect['all_of'] : undefined;
120
+ if (!Array.isArray(allOf) || allOf.length === 0) {
121
+ errors.push(`${where}.duplicate.detect.all_of must be a non-empty list`);
122
+ } else {
123
+ for (const p of allOf) {
124
+ if (typeof p !== 'string' || !(p.startsWith('~/') || p.startsWith('./'))) {
125
+ errors.push(
126
+ `${where}.duplicate.detect path \`${String(p)}\` must start with ~/ or ./`,
127
+ );
128
+ continue;
129
+ }
130
+ if (p.startsWith('./') && !fs.existsSync(path.join(REPO_ROOT, p.slice(2)))) {
131
+ errors.push(`${where}.duplicate.detect repo path does not resolve: \`${p}\``);
132
+ }
133
+ }
134
+ }
135
+ // A defined (non-pending) duplicate class needs a converge action.
136
+ const conv = entry.converge;
137
+ if (!isObject(conv) || typeof conv['command'] !== 'string' || conv['command'] === '') {
138
+ errors.push(`${where} defines a duplicate class but no converge.command`);
139
+ }
140
+ } else if (typeof dup['pending_evidence'] !== 'string') {
141
+ errors.push(
142
+ `${where}.duplicate must carry either detect.all_of or pending_evidence`,
143
+ );
144
+ }
145
+ }
146
+ }
147
+
148
+ // Drift guard: the claude-code converge command must name the real
149
+ // plugin id + marketplace name from _lib/claude_plugin.ts.
150
+ const claude = tools['claude-code'];
151
+ const claudeCmd = claude && isObject(claude.converge) ? String(claude.converge['command'] ?? '') : '';
152
+ const expectedRef = `${CLAUDE_PLUGIN_ID}@${CLAUDE_MARKETPLACE_NAME}`;
153
+ if (claudeCmd !== '' && !claudeCmd.includes(expectedRef)) {
154
+ errors.push(
155
+ `tools.claude-code.converge.command must reference \`${expectedRef}\` ` +
156
+ `(got \`${claudeCmd}\`)`,
157
+ );
158
+ }
159
+
160
+ if (errors.length > 0) {
161
+ process.stdout.write('❌ surface-matrix.yml has problems:\n');
162
+ for (const e of errors) {
163
+ process.stdout.write(` - ${e}\n`);
164
+ }
165
+ return 1;
166
+ }
167
+
168
+ process.stdout.write(
169
+ `✅ surface-matrix.yml (${matrixIds.size} tools, coverage matches USER_SCOPE_PATHS)\n`,
170
+ );
171
+ process.stdout.write(' No issues found.\n');
172
+ return 0;
173
+ }
174
+
175
+ function _isCliEntry(): boolean {
176
+ if (process.argv[1] === undefined) {
177
+ return false;
178
+ }
179
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
180
+ if (import.meta.url === argvUrl) {
181
+ return true;
182
+ }
183
+ try {
184
+ const here = fs.realpathSync(fileURLToPath(import.meta.url));
185
+ const argv = fs.realpathSync(path.resolve(process.argv[1]));
186
+ return here === argv;
187
+ } catch {
188
+ return false;
189
+ }
190
+ }
191
+
192
+ if (_isCliEntry()) {
193
+ try {
194
+ process.exit(main());
195
+ } catch (exc) {
196
+ const msg = exc instanceof Error ? exc.message : String(exc);
197
+ process.stderr.write(`❌ internal error: ${msg}\n`);
198
+ process.exit(3);
199
+ }
200
+ }