@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
@@ -39,6 +39,7 @@ import {
39
39
  resolve_logical as _agent_src_resolve_logical,
40
40
  strip_source_prefix,
41
41
  } from './_lib/agent_src.js';
42
+ import { build_claude_hook_matrix } from './_lib/claude_settings_hooks.js';
42
43
  import { project_settings_path, load_agent_settings } from './_lib/agent_settings.js';
43
44
  import { info, success, flush_summary, resolve_level } from './_lib/script_output.js';
44
45
  import {
@@ -1809,65 +1810,96 @@ export function generate_subagent_host_contexts(): number {
1809
1810
  return count;
1810
1811
  }
1811
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
+
1812
1852
  export function generate_plugin_command_skills(): number {
1813
1853
  if (!_isDir(path.join(MODULE_STATE.PROJECT_ROOT, 'src', 'domains'))) {
1814
1854
  return 0;
1815
1855
  }
1816
1856
  _mkdirp(MODULE_STATE.PLUGIN_SKILLS_DIR);
1817
1857
 
1818
- let skill_names = new Set<string>();
1819
- if (_exists(MODULE_STATE.SKILLS_SOURCE)) {
1820
- skill_names = new Set(
1821
- _iterdirSorted(MODULE_STATE.SKILLS_SOURCE)
1822
- .filter((p) => _isDir(p))
1823
- .map((p) => path.basename(p)),
1824
- );
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);
1825
1864
  }
1826
-
1827
- const current_slugs = new Set<string>();
1828
- let count = 0;
1829
- for (const [source_file, slug] of iterCommands()) {
1830
- if (skill_names.has(slug)) {
1831
- continue;
1832
- }
1833
- current_slugs.add(slug);
1834
- const skill_dir = path.join(MODULE_STATE.PLUGIN_SKILLS_DIR, slug);
1835
- _mkdirp(skill_dir);
1836
- const skill_file = path.join(skill_dir, 'SKILL.md');
1837
- if (_existsOrSymlink(skill_file)) {
1838
- fs.unlinkSync(skill_file);
1839
- }
1840
- const rel_path = _relativeToPosix(source_file, MODULE_STATE.PROJECT_ROOT);
1841
- const rel_target = path.join('../../..', rel_path);
1842
- fs.symlinkSync(rel_target, skill_file);
1843
- 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);
1844
1867
  }
1845
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.
1846
1874
  let removed_dirs = 0;
1847
1875
  for (const item of _iterdirSorted(MODULE_STATE.PLUGIN_SKILLS_DIR)) {
1848
1876
  if (!_isDirNoFollow(item) || _isSymlink(item)) {
1849
1877
  continue;
1850
1878
  }
1851
- if (current_slugs.has(path.basename(item))) {
1879
+ if (path.basename(item) === PLUGIN_POINTER_SLUG) {
1852
1880
  continue;
1853
1881
  }
1854
- const skill_md = path.join(item, 'SKILL.md');
1855
- if (_isSymlink(skill_md) || _isFile(skill_md)) {
1856
- const entries = _iterdirSorted(item);
1857
- if (entries.length === 1 && path.basename(entries[0] as string) === 'SKILL.md') {
1858
- fs.unlinkSync(skill_md);
1859
- fs.rmdirSync(item);
1860
- 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);
1861
1891
  }
1892
+ fs.rmdirSync(item);
1893
+ removed_dirs += 1;
1862
1894
  }
1863
1895
  }
1864
1896
 
1865
- let msg = ` ✅ Created ${count} command entries in .claude-plugin/skills/`;
1897
+ let msg = ' ✅ Plugin skills: bootstrap shim (1 pointer skill) in .claude-plugin/skills/';
1866
1898
  if (removed_dirs) {
1867
- msg += ` (${removed_dirs} stale dirs removed)`;
1899
+ msg += ` (${removed_dirs} content entries pruned)`;
1868
1900
  }
1869
1901
  info(msg);
1870
- return count;
1902
+ return 1;
1871
1903
  }
1872
1904
 
1873
1905
  export function generate_persona_symlinks(): number {
@@ -1963,36 +1995,13 @@ export function generate_plugin_hooks(): number {
1963
1995
  return 0;
1964
1996
  }
1965
1997
 
1966
- // Python: `manifest = yaml.safe_load(...) or {}`.
1967
- let manifest = _yamlParse(_readText(manifest_path)) as Record<string, unknown> | null;
1968
- if (manifest === null || manifest === undefined) {
1969
- manifest = {};
1970
- }
1971
- const hook_spec = (manifest['schema_version'] as unknown) ?? 1;
1972
- const platforms = (manifest['platforms'] ?? {}) as Record<string, unknown>;
1973
- const claude_events = ((platforms['claude'] ?? {}) as Record<string, unknown>) || {};
1974
- const nativeAliasesAll = (manifest['native_event_aliases'] ?? {}) as Record<string, unknown>;
1975
- const aliases = ((nativeAliasesAll['claude'] ?? {}) as Record<string, unknown>) || {};
1976
- // Reverse the native→agent-config map.
1977
- const ac_to_native: Record<string, string> = {};
1978
- for (const [native, ac] of Object.entries(aliases)) {
1979
- ac_to_native[String(ac)] = native;
1980
- }
1981
-
1998
+ // Single source of truth: the SAME matrix derivation the installer uses
1999
+ // for the managed settings.json block (road-to-claude-code-single-surface
2000
+ // Phase 4) — plugin hooks.json and settings hooks cannot drift by
2001
+ // construction. Backstop: tests/install/claude_hook_matrix_parity.test.ts.
2002
+ const matrix = build_claude_hook_matrix(manifest_path);
1982
2003
  const hooks: Record<string, unknown[]> = {};
1983
- for (const [ac_event, concerns] of Object.entries(claude_events)) {
1984
- if (!concerns || (Array.isArray(concerns) && concerns.length === 0)) {
1985
- continue;
1986
- }
1987
- const native = ac_to_native[ac_event];
1988
- if (native === undefined) {
1989
- continue;
1990
- }
1991
- const command =
1992
- 'BIN="$CLAUDE_PROJECT_DIR/agent-config"; [ -x "$BIN" ] || BIN=agent-config; ' +
1993
- `"$BIN" dispatch:hook --platform claude --event ${ac_event} ` +
1994
- `--native-event ${native} --project-dir "$CLAUDE_PROJECT_DIR" ` +
1995
- `--min-version ${String(hook_spec)}`;
2004
+ for (const [native, command] of Object.entries(matrix)) {
1996
2005
  hooks[native] = [{ hooks: [{ type: 'command', command }] }];
1997
2006
  }
1998
2007
 
@@ -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]
@@ -27,6 +27,9 @@
27
27
  # the comma-separated values are unioned.
28
28
  # --list-tools Print supported tool IDs with descriptions, then exit
29
29
  # --yes, -y Non-interactive mode: do not prompt (default for non-TTY)
30
+ # --no-ui Unattended install: never launch the browser setup
31
+ # wizard and skip the terminal tool picker (tools default
32
+ # to all). Used by `agent-config upgrade`.
30
33
  # --force Overwrite existing bridge files
31
34
  # --dry-run Show what payload sync would do (does not run bridges)
32
35
  # --verbose Detailed payload sync output
@@ -95,12 +98,13 @@ MINIMAL=false
95
98
  VALIDATE_ONLY=false
96
99
  FLEET_CONFIG=""
97
100
  USER_TYPE=""
101
+ NO_UI=false
98
102
 
99
103
  # Single source of truth for valid tool IDs (also referenced by install.sh / install.py).
100
104
  VALID_TOOLS="claude-code claude-desktop cursor windsurf cline gemini-cli copilot augment aider codex roocode continue kilocode zed jetbrains kiro all"
101
105
 
102
106
  show_help() {
103
- sed -n '3,58p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
107
+ sed -n '3,61p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
104
108
  }
105
109
 
106
110
  list_tools() {
@@ -167,6 +171,7 @@ while [[ $# -gt 0 ]]; do
167
171
  --ai=*) TOOLS="${TOOLS:+$TOOLS,}${1#*=}"; TOOLS_EXPLICIT=true; shift ;;
168
172
  --list-tools) LIST_TOOLS=true; shift ;;
169
173
  --yes|-y) YES=true; shift ;;
174
+ --no-ui) NO_UI=true; shift ;;
170
175
  --force) FORCE=true; shift ;;
171
176
  --dry-run) DRY_RUN=true; shift ;;
172
177
  --verbose) VERBOSE=true; shift ;;
@@ -194,6 +199,15 @@ if $LIST_TOOLS; then
194
199
  exit 0
195
200
  fi
196
201
 
202
+ # --no-ui = unattended install (the `agent-config upgrade` path). Export the
203
+ # suppression env so every downstream stage (install.ts honors both the flag
204
+ # and AGENT_CONFIG_NO_UI) skips the browser setup wizard — the wizard is an
205
+ # onboarding surface whose foreground server used to block `upgrade` until
206
+ # Ctrl-C.
207
+ if $NO_UI; then
208
+ export AGENT_CONFIG_NO_UI=1
209
+ fi
210
+
197
211
  # road-to-global-only-install § Phase 3.3 — consumer-floor gate.
198
212
  #
199
213
  # Consumer installs are global-only (ADR-020). Explicit --scope=project
@@ -282,8 +296,8 @@ prompt_tools() {
282
296
  # When an interactive global install will hand off to the browser wizard,
283
297
  # the wizard is the single tool-selection surface — skip the terminal
284
298
  # picker so it does not pre-empt (and, via TOOLS_EXPLICIT, suppress) the
285
- # GUI. Mirrors install.py::_wizard_should_launch minus the --no-ui flag,
286
- # which the bash orchestrator never receives (it errors on unknown args).
299
+ # GUI. Mirrors install.py::_wizard_should_launch (the --no-ui flag is
300
+ # handled here too — it suppresses both the wizard and the picker).
287
301
  # Headless paths (no TTY / CI / AGENT_CONFIG_NO_UI) still get the picker.
288
302
  wizard_will_handle_tools=false
289
303
  if $GLOBAL && [[ -t 0 && -t 1 && -z "${CI:-}" ]] \
@@ -291,8 +305,12 @@ if $GLOBAL && [[ -t 0 && -t 1 && -z "${CI:-}" ]] \
291
305
  wizard_will_handle_tools=true
292
306
  fi
293
307
 
308
+ # --no-ui also skips the terminal picker: the flag means "fully unattended"
309
+ # (its only caller today is `agent-config upgrade`, where a mid-upgrade
310
+ # prompt reads as a hang). Tools fall through to the "all" default below —
311
+ # the same effective selection the wizard-handoff path used.
294
312
  if ! $MINIMAL && ! $TOOLS_EXPLICIT && ! $YES && ! $QUIET && ! $LIST_TOOLS \
295
- && [[ -t 0 && -t 1 ]] && ! $wizard_will_handle_tools; then
313
+ && [[ -t 0 && -t 1 ]] && ! $wizard_will_handle_tools && ! $NO_UI; then
296
314
  prompt_tools
297
315
  TOOLS_EXPLICIT=true
298
316
  fi
@@ -416,6 +434,7 @@ run_bridges() {
416
434
  [[ -n "$CUSTOM_PATH" ]] && args+=(--custom-path="$CUSTOM_PATH")
417
435
  $OFFLINE && args+=(--offline)
418
436
  $MINIMAL && args+=(--minimal)
437
+ $NO_UI && args+=(--no-ui)
419
438
  args+=(--tools="$TOOLS")
420
439
  "${runner[@]}" "${args[@]}"
421
440
  }
@@ -78,6 +78,7 @@ import * as global_deploy_inventory from './_lib/global_deploy_inventory.js';
78
78
  import * as installed_tools from './_lib/installed_tools.js';
79
79
  import * as user_global_paths from './_lib/user_global_paths.js';
80
80
  import * as claude_desktop_bundler from './_lib/claude_desktop_bundler.js';
81
+ import * as claude_settings_hooks from './_lib/claude_settings_hooks.js';
81
82
  import { find_project_root_with_anchor, load_agent_settings } from './_lib/agent_settings.js';
82
83
  import { detect_module_roots } from './_lib/module_detection.js';
83
84
  import {
@@ -2914,6 +2915,35 @@ function _deploy_global_content(
2914
2915
  }
2915
2916
  _emit_progress({ type: 'reaped', tool: tool_id, count: reaped.length });
2916
2917
  results[tool_id] = [written_total, skipped_total, 'deployed', written_paths];
2918
+
2919
+ // Single-surface model (road-to-claude-code-single-surface): the
2920
+ // deterministic hook matrix registers directly in the user-scope
2921
+ // settings file — the marketplace plugin is no longer required for
2922
+ // hooks. Failure here must not sink the content deploy (hooks can
2923
+ // be re-wired via `agent-config refresh --global` / doctor).
2924
+ if (tool_id === 'claude-code') {
2925
+ try {
2926
+ const manifest = path.join(package_root, 'src', 'scripts', 'hook_manifest.yaml');
2927
+ const matrix = claude_settings_hooks.build_claude_hook_matrix(manifest);
2928
+ const res = claude_settings_hooks.ensure_managed_hooks(
2929
+ path.join(anchor, 'settings.json'),
2930
+ matrix,
2931
+ );
2932
+ if (!state.QUIET) {
2933
+ if (res.changed) {
2934
+ success(
2935
+ `claude-code: managed hooks registered in ~/.claude/settings.json ` +
2936
+ `(${res.events.length} event(s))`,
2937
+ );
2938
+ } else {
2939
+ skip('claude-code: managed hooks already registered');
2940
+ }
2941
+ }
2942
+ } catch (e) {
2943
+ warn(`claude-code: managed-hook registration failed — ${String(e)}`);
2944
+ _emit_progress({ type: 'hooks_failed', tool: tool_id, error: String(e) });
2945
+ }
2946
+ }
2917
2947
  }
2918
2948
  return results;
2919
2949
  }
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Guard against `internal/glama/README.md` drifting from the committed
4
+ * `internal/glama/build` / `internal/glama/run` scripts it documents.
5
+ *
6
+ * Extracts the single canonical command line each script actually runs
7
+ * (the last non-comment, non-`set -euo pipefail` line — `exec ` stripped)
8
+ * and asserts that literal string appears verbatim somewhere in the README.
9
+ * Catches the exact failure mode fixed in road-to-mcp-full-power.md Phase 1:
10
+ * the README described a Python/uv build long after the scripts moved to
11
+ * Node/tsx.
12
+ *
13
+ * Exit codes: 0 no drift, 1 one or more scripts' canonical command is not
14
+ * documented verbatim in the README.
15
+ */
16
+
17
+ import * as fs from 'node:fs';
18
+ import * as path from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ const _HERE = fileURLToPath(import.meta.url);
22
+ const REPO = path.resolve(path.dirname(_HERE), '..', '..');
23
+ const GLAMA_DIR = path.join(REPO, 'internal', 'glama');
24
+
25
+ interface ScriptSpec {
26
+ file: string;
27
+ /** Strip this exact prefix from the extracted command line before comparing. */
28
+ stripPrefix?: string;
29
+ }
30
+
31
+ const SCRIPTS: readonly ScriptSpec[] = [
32
+ { file: 'build' },
33
+ { file: 'run', stripPrefix: 'exec ' },
34
+ ];
35
+
36
+ function _canonicalCommand(scriptPath: string, stripPrefix?: string): string {
37
+ const lines = fs.readFileSync(scriptPath, 'utf8').split('\n');
38
+ const candidates = lines
39
+ .map((l) => l.trim())
40
+ .filter((l) => l.length > 0 && !l.startsWith('#') && l !== 'set -euo pipefail');
41
+ const last = candidates[candidates.length - 1] ?? '';
42
+ return stripPrefix && last.startsWith(stripPrefix) ? last.slice(stripPrefix.length) : last;
43
+ }
44
+
45
+ function main(): number {
46
+ const readmePath = path.join(GLAMA_DIR, 'README.md');
47
+ const readme = fs.readFileSync(readmePath, 'utf8');
48
+
49
+ let failed = false;
50
+ for (const spec of SCRIPTS) {
51
+ const scriptPath = path.join(GLAMA_DIR, spec.file);
52
+ const cmd = _canonicalCommand(scriptPath, spec.stripPrefix);
53
+ if (!cmd) {
54
+ console.error(`❌ internal/glama/${spec.file}: could not extract a canonical command line`);
55
+ failed = true;
56
+ continue;
57
+ }
58
+ if (!readme.includes(cmd)) {
59
+ console.error(
60
+ `❌ drift: internal/glama/${spec.file} runs \`${cmd}\` — not found verbatim in internal/glama/README.md`,
61
+ );
62
+ failed = true;
63
+ }
64
+ }
65
+
66
+ if (failed) {
67
+ console.error('\nUpdate internal/glama/README.md to match the committed build/run scripts.');
68
+ return 1;
69
+ }
70
+
71
+ process.stdout.write('✅ internal/glama/README.md matches build/run scripts — no drift.\n');
72
+ return 0;
73
+ }
74
+
75
+ process.exit(main());
@@ -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;