@uniqbit/mate-core 0.15.4-canary.8 → 0.15.4

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 (44) hide show
  1. package/claude-plugin/hooks/artifact-finish-nudge.mjs +5 -3
  2. package/claude-plugin/hooks/session-banner.mjs +5 -3
  3. package/claude-plugin/hooks/ts-loader.mjs +28 -0
  4. package/claude-plugin/hooks/validate-artifact-path.mjs +5 -3
  5. package/package.json +1 -1
  6. package/src/cli/commands/cap/openspec.ts +20 -1
  7. package/src/cli/commands/companion/hub.ts +1 -0
  8. package/src/cli/commands/plugin/install.ts +15 -3
  9. package/src/hooks/artifact-finish-nudge.ts +35 -3
  10. package/src/lib/context-mode-package.ts +5 -3
  11. package/src/lib/orchestrator/adapters/opencode.ts +10 -60
  12. package/src/lib/orchestrator/companion-git-sync.ts +67 -8
  13. package/src/lib/orchestrator/companion-hub.ts +28 -6
  14. package/src/lib/orchestrator/types.ts +2 -2
  15. package/src/lib/package-paths.ts +1 -0
  16. package/src/opencode/companion-hooks.ts +33 -12
  17. package/src/playbooks/companion-guidance.ts +1 -1
  18. package/src/templates/capabilities/openspec-cap/mate-skills/agents/mate-openspec-backfill/SKILL.md +65 -0
  19. package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +1 -0
  20. package/src/templates/root/TEMPLATE_AGENTS.md +2 -0
  21. package/src/templates/root/TEMPLATE_CLAUDE.md +2 -0
  22. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +3612 -0
  23. package/src/tools/setup/capabilities/context-mode.ts +57 -83
  24. package/src/tools/setup/capabilities/graphify-shared.ts +27 -0
  25. package/src/tools/setup/capabilities/graphify.ts +86 -295
  26. package/src/tools/setup/capabilities/openspec.ts +34 -1
  27. package/src/tools/setup/capabilities/react-doctor.ts +37 -37
  28. package/src/tools/setup/capabilities/rtk.ts +4 -1
  29. package/src/tools/setup/capabilities/tokensave-shared.ts +6 -0
  30. package/src/tools/setup/capabilities/tokensave.ts +51 -72
  31. package/src/tools/setup/context-services.ts +29 -0
  32. package/src/tools/setup/dynamic-plugins/hydrate.ts +15 -1
  33. package/src/tools/setup/engine.ts +68 -1
  34. package/src/tools/setup/mate.ts +10 -2
  35. package/src/tools/setup/plugin.ts +105 -0
  36. package/src/tools/setup/plugins/gitignore.ts +7 -4
  37. package/src/tools/setup/plugins/guidance.ts +1 -1
  38. package/src/tools/setup/providers/agent-file-sections.ts +78 -0
  39. package/src/tools/setup/providers/claude-format.ts +159 -0
  40. package/src/tools/setup/providers/claude.ts +281 -227
  41. package/src/tools/setup/providers/opencode-format.ts +146 -0
  42. package/src/tools/setup/providers/opencode.ts +209 -60
  43. package/src/tools/setup/providers/skill-tree.ts +38 -0
  44. package/src/tools/setup.ts +7 -56
@@ -4,13 +4,38 @@ import path from "node:path";
4
4
 
5
5
  import { FRAMEWORK_NAME } from "../../../framework";
6
6
  import { GlobalConfigStore } from "../../../lib/orchestrator/global-config-store";
7
- import { getWrapperBinPath } from "../../../lib/package-paths";
8
7
  import type { FrameworkConfig } from "../../../lib/orchestrator/types";
9
8
  import { refreshFromTemplate, stripGuidanceBlock } from "../plugins/guidance";
10
- import { TOKENSAVE_WORKING_REPO_EXCLUDE_ENTRIES } from "../capabilities/tokensave-shared";
11
- import type { McpServerDescriptor, ProviderPlugin, SetupContext } from "../plugin";
9
+ import {
10
+ TOKENSAVE_CLAUDE_MD_MARKER,
11
+ TOKENSAVE_WORKING_REPO_EXCLUDE_ENTRIES,
12
+ } from "../capabilities/tokensave-shared";
13
+ import {
14
+ instructionBlockKey,
15
+ removeManagedBlocksForPlugin,
16
+ upsertManagedBlock,
17
+ } from "../context-services";
18
+ import type { CapabilityContributionInput, ProviderPlugin, SetupContext } from "../plugin";
12
19
  import { resolveGitInfoExcludePath } from "../git-utils";
13
- import { pruneEmptyAncestors, resolveCommandOnPath } from "../utils";
20
+ import { mergeDir, pruneEmptyAncestors } from "../utils";
21
+ import {
22
+ cutFromMarker,
23
+ stripSectionFromFile,
24
+ type RemoveHeadingSectionOptions,
25
+ } from "./agent-file-sections";
26
+ import { patchSkillTreeMarkdownFiles } from "./skill-tree";
27
+ import {
28
+ filterClaudeHookGroups,
29
+ mergeClaudeHookGroups,
30
+ readClaudeMcpConfig,
31
+ readClaudeSettings,
32
+ toClaudeMcpEntry,
33
+ updateClaudeMcpServer,
34
+ writeClaudeMcpConfig,
35
+ writeClaudeSettings,
36
+ type ClaudeHookGroup,
37
+ type ClaudeSettings,
38
+ } from "./claude-format";
14
39
  import { getSetupRootTemplates } from "./utils";
15
40
 
16
41
  async function configureClaudeGuidance(companionPath: string): Promise<void> {
@@ -89,46 +114,9 @@ const LEGACY_MANAGED_PERMISSION_ENTRIES = [
89
114
  "Bash($MATE_COMPANION_BIN_PATH/graphify:*)",
90
115
  ];
91
116
 
92
- // Enabled capability -> the `permissions.allow` entries it pre-seeds.
93
- function getCapabilityPermissionEntries(): Record<string, string[]> {
94
- const wrapperBinPath = getWrapperBinPath();
95
- return {
96
- openspec: [
97
- "Skill(openspec-explore)",
98
- "Skill(openspec-propose)",
99
- "Skill(openspec-apply-change)",
100
- "Skill(openspec-archive-change)",
101
- "Skill(mate-artifact-finish)",
102
- "Bash(openspec:*)",
103
- `Bash(${FRAMEWORK_NAME} cap graphify:*)`,
104
- `Bash(${path.join(wrapperBinPath, "openspec")}:*)`,
105
- ],
106
- rtk: ["Bash(rtk:*)"],
107
- graphify: [
108
- "Skill(graphify)",
109
- "Bash(graphify:*)",
110
- `Bash(${FRAMEWORK_NAME} cap graphify:*)`,
111
- `Bash(${path.join(wrapperBinPath, "graphify")}:*)`,
112
- ],
113
- "react-doctor": [
114
- "Skill(react-doctor)",
115
- "Bash(npx react-doctor:*)",
116
- "Bash(npx react-doctor@latest *)",
117
- ],
118
- tokensave: ["mcp__tokensave__*"],
119
- // The context-mode Claude plugin exposes its skill and MCP tools under the
120
- // plugin namespace; pre-seed both so routine routing doesn't prompt.
121
- "context-mode": [
122
- "Skill(context-mode:context-mode)",
123
- "mcp__plugin_context-mode_context-mode__*",
124
- ],
125
- };
126
- }
127
-
128
117
  function getAllManagedPermissionEntries(companionPath: string): Set<string> {
129
118
  return new Set([
130
119
  ...getBaseManagedPermissionEntries(companionPath),
131
- ...Object.values(getCapabilityPermissionEntries()).flat(),
132
120
  ...LEGACY_MANAGED_PERMISSION_ENTRIES,
133
121
  // Legacy base entry: Claude Code never matched Glob() rules for file
134
122
  // permission checks and warns about them, so setup no longer emits it.
@@ -137,37 +125,22 @@ function getAllManagedPermissionEntries(companionPath: string): Set<string> {
137
125
  ]);
138
126
  }
139
127
 
140
- interface HookCommand {
141
- type?: string;
142
- command?: string;
143
- args?: string[];
144
- timeout?: number;
145
- }
146
- interface HookGroup {
147
- matcher?: string;
148
- hooks?: HookCommand[];
149
- }
150
- interface WorkingRepoSettings {
151
- hooks?: Record<string, HookGroup[]>;
152
- permissions?: { additionalDirectories?: string[]; allow?: string[] } & Record<string, unknown>;
153
- mcpServers?: Record<string, { command?: string; args?: string[] }>;
154
- [key: string]: unknown;
155
- }
156
-
157
- function isManagedHookGroup(group: HookGroup): boolean {
128
+ function isManagedHookGroup(group: ClaudeHookGroup, extraMarkers: string[] = []): boolean {
158
129
  return (group.hooks ?? []).some((hook) =>
159
- MANAGED_HOOK_MARKERS.some((marker) => (hook.command ?? "").includes(marker)),
130
+ [...MANAGED_HOOK_MARKERS, ...extraMarkers].some((marker) =>
131
+ (hook.command ?? "").includes(marker),
132
+ ),
160
133
  );
161
134
  }
162
135
 
163
- function removeManagedHookGroups(settings: WorkingRepoSettings): WorkingRepoSettings {
164
- const hooks: Record<string, HookGroup[]> = {};
165
- for (const [event, groups] of Object.entries(settings.hooks ?? {})) {
166
- const remainingGroups = (groups ?? []).filter((group) => !isManagedHookGroup(group));
167
- if (remainingGroups.length > 0) {
168
- hooks[event] = remainingGroups;
169
- }
170
- }
136
+ function removeManagedHookGroups(
137
+ settings: ClaudeSettings,
138
+ extraMarkers: string[] = [],
139
+ ): ClaudeSettings {
140
+ const hooks = filterClaudeHookGroups(
141
+ settings.hooks ?? {},
142
+ (group) => !isManagedHookGroup(group, extraMarkers),
143
+ );
171
144
 
172
145
  const next = { ...settings };
173
146
  if (Object.keys(hooks).length > 0) {
@@ -178,18 +151,6 @@ function removeManagedHookGroups(settings: WorkingRepoSettings): WorkingRepoSett
178
151
  return next;
179
152
  }
180
153
 
181
- async function readWorkingRepoSettings(settingsPath: string): Promise<WorkingRepoSettings> {
182
- try {
183
- const parsed = JSON.parse(await fs.readFile(settingsPath, "utf8")) as unknown;
184
- if (parsed && typeof parsed === "object") {
185
- return parsed as WorkingRepoSettings;
186
- }
187
- } catch {
188
- // Absent or unparseable — start from an empty object.
189
- }
190
- return {};
191
- }
192
-
193
154
  export async function ensureWorkingRepoLocalExcludes(
194
155
  workingRepoPath: string,
195
156
  config: FrameworkConfig,
@@ -234,9 +195,6 @@ export async function ensureWorkingRepoLocalExcludes(
234
195
  await fs.writeFile(excludePath, nextContent, "utf8");
235
196
  }
236
197
 
237
- // Marker that identifies the tokensave installer's CLAUDE.md append block.
238
- const TOKENSAVE_CLAUDE_MD_MARKER = "## MANDATORY: No Explore Agents When Tokensave Is Available";
239
-
240
198
  async function stripTokensaveClaudeMdAppend(workingRepoPath: string): Promise<void> {
241
199
  const claudeMdPath = path.join(workingRepoPath, "CLAUDE.md");
242
200
  let content: string;
@@ -246,13 +204,10 @@ async function stripTokensaveClaudeMdAppend(workingRepoPath: string): Promise<vo
246
204
  return;
247
205
  }
248
206
 
249
- const idx = content.indexOf(TOKENSAVE_CLAUDE_MD_MARKER);
250
- if (idx === -1) return;
251
-
252
- // Cut from the marker to EOF. Trim trailing whitespace before the cut point.
253
- const before = content.slice(0, idx).replace(/\s+$/, "");
254
- if (before.length > 0) {
255
- await fs.writeFile(claudeMdPath, before + "\n", "utf8");
207
+ const stripped = cutFromMarker(content, TOKENSAVE_CLAUDE_MD_MARKER);
208
+ if (stripped === content) return;
209
+ if (stripped.length > 0) {
210
+ await fs.writeFile(claudeMdPath, stripped, "utf8");
256
211
  } else {
257
212
  await fs.unlink(claudeMdPath);
258
213
  }
@@ -277,68 +232,37 @@ export function getCompanionClaudeMcpConfigPath(companionPath: string): string {
277
232
  // groups/entries always lead their arrays so the emitted shape stays stable
278
233
  // across syncs; unmanaged content is preserved untouched.
279
234
  function buildManagedClaudeSettings(
280
- existing: WorkingRepoSettings,
235
+ existing: ClaudeSettings,
281
236
  companionPath: string,
282
237
  config: FrameworkConfig,
283
- tokensaveCommandPath: string,
284
- ): WorkingRepoSettings {
285
- const capabilities = config.capabilities ?? [];
286
- const enabledNames = new Set(capabilities.map((c) => c.name));
287
- const reactDoctorEnabled = enabledNames.has("react-doctor");
238
+ contributions: CapabilityContributionInput[] = [],
239
+ ): ClaudeSettings {
240
+ // Declared markers and permission entries widen the managed strip set for
241
+ // every registered Capability, enabled or not, so deselection tears down.
242
+ const declaredMarkers = contributions.flatMap((input) =>
243
+ (input.contributions.hookGroups ?? []).map((hook) => hook.marker),
244
+ );
245
+ const declaredPermissionEntries = contributions.flatMap(
246
+ (input) => input.contributions.permissionEntries ?? [],
247
+ );
248
+ const enabledDeclaredPermissionEntries = contributions
249
+ .filter((input) => input.enabled)
250
+ .flatMap((input) => input.contributions.permissionEntries ?? []);
288
251
 
289
252
  // Mate's own hooks (artifact-path guard, session banner, archive-finish
290
253
  // nudge) ship in the bundled Claude plugin loaded at launch; settings-sync
291
254
  // only strips their legacy managed groups (via removeManagedHookGroups) and
292
255
  // reconciles the capability hooks that remain settings-delivered.
293
- const hooks = removeManagedHookGroups(existing).hooks ?? {};
294
- if (reactDoctorEnabled) {
295
- // Record edits cheaply, then scan once when the edited turn finishes.
296
- hooks.PostToolUse = [
297
- {
298
- matcher: "Write|Edit|MultiEdit|NotebookEdit|ApplyPatch",
299
- hooks: [
300
- {
301
- type: "command",
302
- command: `sh "${companionPath}/.claude/hooks/react-doctor.sh"`,
303
- timeout: 5,
304
- },
305
- ],
306
- },
307
- ...(hooks.PostToolUse ?? []),
308
- ];
309
- hooks.Stop = [
310
- {
311
- hooks: [
312
- {
313
- type: "command",
314
- command: `sh "${companionPath}/.claude/hooks/react-doctor.sh"`,
315
- timeout: 45,
316
- },
317
- ],
318
- },
319
- ...(hooks.Stop ?? []),
320
- ];
321
- }
322
- if (enabledNames.has("tokensave")) {
323
- hooks.PreToolUse = [
324
- {
325
- matcher: "Agent|Grep|Bash",
326
- hooks: [{ type: "command", command: tokensaveCommandPath, args: ["hook-pre-tool-use"] }],
327
- },
328
- ...(hooks.PreToolUse ?? []),
329
- ];
330
- hooks.UserPromptSubmit = [
331
- {
332
- hooks: [{ type: "command", command: tokensaveCommandPath, args: ["hook-prompt-submit"] }],
333
- },
334
- ...(hooks.UserPromptSubmit ?? []),
335
- ];
336
- hooks.Stop = [
337
- {
338
- hooks: [{ type: "command", command: tokensaveCommandPath, args: ["hook-stop"] }],
339
- },
340
- ...(hooks.Stop ?? []),
341
- ];
256
+ const hooks = removeManagedHookGroups(existing, declaredMarkers).hooks ?? {};
257
+ // Declared hook groups are applied after the table-driven blocks so managed
258
+ // groups keep leading their event arrays in the same order as before the
259
+ // capabilities migrated to declarations.
260
+ for (const input of contributions) {
261
+ if (!input.enabled) continue;
262
+ // Reversed so multiple groups of one declaration end up in declared order.
263
+ for (const hook of (input.contributions.hookGroups ?? []).toReversed()) {
264
+ hooks[hook.event] = [hook.group, ...(hooks[hook.event] ?? [])];
265
+ }
342
266
  }
343
267
  for (const event of Object.keys(hooks)) {
344
268
  if (hooks[event].length === 0) delete hooks[event];
@@ -347,13 +271,13 @@ function buildManagedClaudeSettings(
347
271
  // permissions.allow: preserve unmanaged entries in place, then union the
348
272
  // Mate-managed base entries and enabled capability entries. Dropping a
349
273
  // capability removes only its managed entry.
350
- const capabilityPermissionEntries = getCapabilityPermissionEntries();
351
- const allManagedPermissionEntries = getAllManagedPermissionEntries(companionPath);
274
+ const allManagedPermissionEntries = new Set([
275
+ ...getAllManagedPermissionEntries(companionPath),
276
+ ...declaredPermissionEntries,
277
+ ]);
352
278
  const managedAllow = [
353
279
  ...getBaseManagedPermissionEntries(companionPath),
354
- ...Object.entries(capabilityPermissionEntries)
355
- .filter(([name]) => enabledNames.has(name))
356
- .flatMap(([, entries]) => entries),
280
+ ...enabledDeclaredPermissionEntries,
357
281
  ];
358
282
  const existingPermissions = existing.permissions ?? {};
359
283
  const existingAllow = Array.isArray(existingPermissions.allow) ? existingPermissions.allow : [];
@@ -374,7 +298,7 @@ function buildManagedClaudeSettings(
374
298
  // entry is removed so it cannot shadow the `.mcp.json` definition.
375
299
  delete mcpServers.tokensave;
376
300
 
377
- const settings: WorkingRepoSettings = { ...existing, hooks, autoMemoryEnabled: false };
301
+ const settings: ClaudeSettings = { ...existing, hooks, autoMemoryEnabled: false };
378
302
  if (Object.keys(hooks).length === 0) {
379
303
  delete settings.hooks;
380
304
  }
@@ -398,54 +322,125 @@ function buildManagedClaudeSettings(
398
322
  export async function syncCompanionClaudeSettings(
399
323
  companionPath: string,
400
324
  config: FrameworkConfig,
325
+ contributions: CapabilityContributionInput[] = [],
401
326
  ): Promise<void> {
402
- const tokensaveCommandPath =
403
- resolveCommandOnPath("tokensave", process.env.PATH ?? "") ?? "tokensave";
404
-
405
327
  const settingsPath = getCompanionClaudeSettingsPath(companionPath);
406
- const existing = await readWorkingRepoSettings(settingsPath);
407
- const settings = buildManagedClaudeSettings(
408
- existing,
409
- companionPath,
410
- config,
411
- tokensaveCommandPath,
412
- );
328
+ const existing = await readClaudeSettings(settingsPath);
329
+ const settings = buildManagedClaudeSettings(existing, companionPath, config, contributions);
413
330
 
414
- await fs.mkdir(path.dirname(settingsPath), { recursive: true });
415
- await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
331
+ await writeClaudeSettings(settingsPath, settings);
416
332
 
417
- await syncCompanionClaudeMcpConfig(companionPath, config);
333
+ await syncCompanionClaudeMcpConfig(companionPath);
418
334
  }
419
335
 
420
- // Maintain the companion `.mcp.json` shell. MCP servers are registered by
421
- // capabilities through `ctx.mcp` (provider hosting) now; this only prunes the
422
- // legacy `tokensave` entry written by releases that predate the bookkeeping
423
- // manifest when the capability is disabled. Loaded at launch via
424
- // `claude --mcp-config`.
425
- async function syncCompanionClaudeMcpConfig(
426
- companionPath: string,
427
- config: FrameworkConfig,
336
+ // ---------------------------------------------------------------------------
337
+ // Runtime Surface reconciliation: apply/remove declared Capability
338
+ // contributions (spec: runtime-surface). Managed identity uses the existing
339
+ // marker scheme — hook markers, permission-entry membership, managed guidance
340
+ // blocks, and named skill trees / MCP servers.
341
+ // ---------------------------------------------------------------------------
342
+
343
+ export async function reconcileClaudeContributions(
344
+ ctx: SetupContext,
345
+ inputs: CapabilityContributionInput[],
428
346
  ): Promise<void> {
429
- const enabledNames = new Set((config.capabilities ?? []).map((c) => c.name));
430
- const mcpConfigPath = getCompanionClaudeMcpConfigPath(companionPath);
347
+ const { companionPath, config } = ctx;
431
348
 
432
- let existing: { mcpServers?: Record<string, unknown> } & Record<string, unknown> = {};
433
- try {
434
- const parsed = JSON.parse(await fs.readFile(mcpConfigPath, "utf8")) as unknown;
435
- if (parsed && typeof parsed === "object") {
436
- existing = parsed as typeof existing;
349
+ if (ctx.scope === "hub") {
350
+ await reconcileClaudeMcpContributions(ctx, inputs);
351
+ await reconcileClaudeAgentDefinitionContributions(ctx, inputs);
352
+ return;
353
+ }
354
+
355
+ // The settings sync (re)creates the managed settings document, so it only
356
+ // runs while the Claude runtime is active; a deactivated runtime's pass is
357
+ // teardown-only and must not resurrect files the provider teardown removed.
358
+ if (ctx.activeProviders.includes("claude")) {
359
+ await syncCompanionClaudeSettings(companionPath, config, inputs);
360
+ }
361
+
362
+ await reconcileClaudeMcpContributions(ctx, inputs);
363
+ await reconcileClaudeAgentDefinitionContributions(ctx, inputs);
364
+
365
+ for (const input of inputs) {
366
+ // Guidance sections are managed blocks in CLAUDE.md. Current sections are
367
+ // upserted, then stale keys (content changes, disabled capability) are
368
+ // swept. CLAUDE.md is Claude-exclusive, so no shared-file guard applies.
369
+ const guidancePath = path.join(companionPath, "CLAUDE.md");
370
+ const sections = input.enabled ? (input.contributions.guidanceSections ?? []) : [];
371
+ const keepKeys = new Set<string>();
372
+ for (const section of sections) {
373
+ const blockKey = instructionBlockKey(input.pluginId, section.content);
374
+ keepKeys.add(blockKey);
375
+ await upsertManagedBlock(guidancePath, FRAMEWORK_NAME, blockKey, section.content);
376
+ }
377
+ if ((input.contributions.guidanceSections ?? []).length > 0) {
378
+ await removeManagedBlocksForPlugin(guidancePath, FRAMEWORK_NAME, input.pluginId, keepKeys);
379
+ }
380
+
381
+ for (const skillTree of input.contributions.skillTrees ?? []) {
382
+ const skillDir = path.join(companionPath, ".claude", "skills", skillTree.name);
383
+ if (input.enabled) {
384
+ await mergeDir(skillTree.sourceDir, skillDir);
385
+ } else {
386
+ await fs.rm(skillDir, { recursive: true, force: true });
387
+ await pruneEmptyAncestors(path.join(companionPath, ".claude", "skills"), companionPath);
388
+ }
389
+ }
390
+ }
391
+ }
392
+
393
+ /** Reconcile only MCP entries without touching Claude settings or guidance. */
394
+ async function reconcileClaudeMcpContributions(
395
+ ctx: SetupContext,
396
+ inputs: CapabilityContributionInput[],
397
+ ): Promise<void> {
398
+ for (const input of inputs) {
399
+ for (const descriptor of input.contributions.mcpServers ?? []) {
400
+ await updateClaudeMcpServer(
401
+ getCompanionClaudeMcpConfigPath(ctx.companionPath),
402
+ descriptor.name,
403
+ input.enabled ? toClaudeMcpEntry(descriptor) : null,
404
+ );
437
405
  }
438
- } catch {
439
- // Absent or unparseable — start from an empty object.
440
406
  }
407
+ }
441
408
 
442
- const mcpServers: Record<string, unknown> = { ...existing.mcpServers };
443
- if (!enabledNames.has("tokensave")) {
444
- delete mcpServers.tokensave;
409
+ /**
410
+ * Reconcile declared agent definition files under `.claude/agents/`. Runs in
411
+ * both companion and hub scope — an agent definition is fully self-contained
412
+ * (no shared-file merge), so it needs no companion-only surface.
413
+ */
414
+ async function reconcileClaudeAgentDefinitionContributions(
415
+ ctx: SetupContext,
416
+ inputs: CapabilityContributionInput[],
417
+ ): Promise<void> {
418
+ const agentsDir = path.join(ctx.companionPath, ".claude", "agents");
419
+ for (const input of inputs) {
420
+ for (const agent of input.contributions.agentDefinitions ?? []) {
421
+ const agentPath = path.join(agentsDir, `${agent.name}.md`);
422
+ if (input.enabled) {
423
+ await fs.mkdir(agentsDir, { recursive: true });
424
+ await fs.writeFile(agentPath, agent.content, "utf8");
425
+ } else {
426
+ await fs.rm(agentPath, { force: true });
427
+ await pruneEmptyAncestors(agentsDir, ctx.companionPath);
428
+ }
429
+ }
445
430
  }
431
+ }
446
432
 
447
- const next = { ...existing, mcpServers };
448
- await fs.writeFile(mcpConfigPath, JSON.stringify(next, null, 2) + "\n", "utf8");
433
+ // Maintain the companion `.mcp.json` shell. Managed MCP servers are reconciled
434
+ // from declared Capability contributions (and legacy `ctx.mcp` hosting); this
435
+ // only guarantees the file exists with an `mcpServers` map. Loaded at launch
436
+ // via `claude --mcp-config`.
437
+ async function syncCompanionClaudeMcpConfig(companionPath: string): Promise<void> {
438
+ const mcpConfigPath = getCompanionClaudeMcpConfigPath(companionPath);
439
+ const { config: existing } = await readClaudeMcpConfig(mcpConfigPath);
440
+ await writeClaudeMcpConfig(mcpConfigPath, {
441
+ ...existing,
442
+ mcpServers: { ...existing.mcpServers },
443
+ });
449
444
  }
450
445
 
451
446
  // Reconcile the working repo for a Claude launch. Mate-managed Claude settings
@@ -478,7 +473,7 @@ async function syncWorkingRepoClaudeAdditionalDirectories(
478
473
  const settingsPath = path.join(workingRepoPath, ".claude", "settings.local.json");
479
474
  // Migrate hooks only. Existing permissions and MCP entries stay in the
480
475
  // working repo; additionalDirectories is reconciled below.
481
- const existing = removeManagedHookGroups(await readWorkingRepoSettings(settingsPath));
476
+ const existing = removeManagedHookGroups(await readClaudeSettings(settingsPath));
482
477
  const permissions = { ...existing.permissions };
483
478
  const existingAdditionalDirectories = Array.isArray(permissions.additionalDirectories)
484
479
  ? permissions.additionalDirectories
@@ -501,7 +496,7 @@ async function syncWorkingRepoClaudeAdditionalDirectories(
501
496
  ),
502
497
  );
503
498
 
504
- const settings: WorkingRepoSettings = {
499
+ const settings: ClaudeSettings = {
505
500
  ...existing,
506
501
  permissions: {
507
502
  ...permissions,
@@ -509,8 +504,7 @@ async function syncWorkingRepoClaudeAdditionalDirectories(
509
504
  },
510
505
  };
511
506
 
512
- await fs.mkdir(path.dirname(settingsPath), { recursive: true });
513
- await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
507
+ await writeClaudeSettings(settingsPath, settings);
514
508
  }
515
509
 
516
510
  async function configureClaude(companionPath: string): Promise<void> {
@@ -608,48 +602,100 @@ async function teardownLegacyClaudeBin(companionPath: string): Promise<void> {
608
602
  await pruneEmptyAncestors(path.join(companionPath, ".claude"), companionPath);
609
603
  }
610
604
 
611
- // Reconcile a single Mate-managed server entry in the companion `.mcp.json`
612
- // while preserving every unrelated entry. `entry: null` removes the server.
613
- async function updateCompanionClaudeMcpServer(
605
+ // ---------------------------------------------------------------------------
606
+ // Runtime Surface escape hatch (spec: runtime-surface). Imperative operations
607
+ // for effects a declaration cannot express — patching skill trees an external
608
+ // CLI wrote, absorbing/stripping foreign config another tool produced. Format
609
+ // knowledge stays in this module; capabilities pass only predicates.
610
+ // ---------------------------------------------------------------------------
611
+
612
+ /** Patch markdown files of an externally written `.claude/skills/<name>` tree. */
613
+ export async function patchClaudeSkillTree(
614
614
  companionPath: string,
615
615
  name: string,
616
- entry: Record<string, unknown> | null,
616
+ transform: (content: string) => string,
617
+ options: { excludeFiles?: readonly string[] } = {},
617
618
  ): Promise<void> {
618
- const mcpConfigPath = getCompanionClaudeMcpConfigPath(companionPath);
619
- let existing: { mcpServers?: Record<string, unknown> } & Record<string, unknown> = {};
620
- let present = false;
619
+ await patchSkillTreeMarkdownFiles(
620
+ path.join(companionPath, ".claude", "skills", name),
621
+ transform,
622
+ new Set(options.excludeFiles ?? []),
623
+ );
624
+ }
625
+
626
+ /**
627
+ * Strip a foreign heading section from the Claude guidance surfaces: the
628
+ * companion CLAUDE.md, the legacy `.claude/CLAUDE.md`, and — when syncing from
629
+ * a linked repo — the repo-level CLAUDE.md.
630
+ */
631
+ export async function stripClaudeForeignSections(
632
+ companionPath: string,
633
+ options: RemoveHeadingSectionOptions & { repoPath?: string },
634
+ ): Promise<void> {
635
+ await stripSectionFromFile(path.join(companionPath, "CLAUDE.md"), options);
636
+ await stripSectionFromFile(path.join(companionPath, ".claude", "CLAUDE.md"), options);
637
+ await pruneEmptyAncestors(path.join(companionPath, ".claude"), companionPath);
638
+ if (options.repoPath) {
639
+ await stripSectionFromFile(path.join(options.repoPath, "CLAUDE.md"), options);
640
+ }
641
+ }
642
+
643
+ /**
644
+ * Merge hook groups an external tool wrote to `.claude/settings.json` into the
645
+ * Mate-owned `settings.local.json`, then remove the tracked file so it cannot
646
+ * shadow companion config. Malformed or absent files skip the merge but the
647
+ * `settings.json` removal still happens.
648
+ */
649
+ export async function mergeClaudeSettingsJsonHooks(companionPath: string): Promise<void> {
650
+ const settingsJsonPath = path.join(companionPath, ".claude", "settings.json");
651
+ const settingsLocalPath = getCompanionClaudeSettingsPath(companionPath);
621
652
  try {
622
- const parsed = JSON.parse(await fs.readFile(mcpConfigPath, "utf8")) as unknown;
623
- if (parsed && typeof parsed === "object") {
624
- existing = parsed as typeof existing;
625
- present = true;
653
+ const settingsJson = JSON.parse(await fs.readFile(settingsJsonPath, "utf8")) as ClaudeSettings;
654
+ const settingsLocal = JSON.parse(
655
+ await fs.readFile(settingsLocalPath, "utf8"),
656
+ ) as ClaudeSettings;
657
+
658
+ if (settingsJson.hooks && Object.keys(settingsJson.hooks).length > 0) {
659
+ settingsLocal.hooks = mergeClaudeHookGroups(settingsLocal.hooks ?? {}, settingsJson.hooks);
626
660
  }
661
+
662
+ await writeClaudeSettings(settingsLocalPath, settingsLocal);
627
663
  } catch {
628
- // Absent or unparseable — start from an empty object.
664
+ /* settings files absent or malformed — just remove settings.json */
629
665
  }
630
- if (entry === null && !present) return;
631
-
632
- const mcpServers: Record<string, unknown> = { ...existing.mcpServers };
633
- if (entry === null) {
634
- if (!(name in mcpServers)) return;
635
- delete mcpServers[name];
636
- } else {
637
- mcpServers[name] = entry;
666
+ try {
667
+ await fs.unlink(settingsJsonPath);
668
+ } catch {
669
+ /* not present */
638
670
  }
639
- await fs.writeFile(
640
- mcpConfigPath,
641
- JSON.stringify({ ...existing, mcpServers }, null, 2) + "\n",
642
- "utf8",
643
- );
644
671
  }
645
672
 
646
- function claudeMcpEntry(descriptor: McpServerDescriptor): Record<string, unknown> {
647
- if (descriptor.url) return { url: descriptor.url };
648
- return {
649
- command: descriptor.command,
650
- ...(descriptor.args ? { args: descriptor.args } : {}),
651
- ...(descriptor.env ? { env: descriptor.env } : {}),
652
- };
673
+ /** Remove settings hook groups matching `isForeign`, pruning emptied containers. */
674
+ export async function removeClaudeHookGroupsWhere(
675
+ companionPath: string,
676
+ isForeign: (group: ClaudeHookGroup) => boolean,
677
+ ): Promise<void> {
678
+ const settingsLocalPath = getCompanionClaudeSettingsPath(companionPath);
679
+ let settings: ClaudeSettings;
680
+ try {
681
+ settings = JSON.parse(await fs.readFile(settingsLocalPath, "utf8")) as ClaudeSettings;
682
+ } catch {
683
+ return; /* settings absent or malformed */
684
+ }
685
+
686
+ const existingHooks = settings.hooks ?? {};
687
+ const hooks = filterClaudeHookGroups(existingHooks, (group) => !isForeign(group));
688
+ const changed =
689
+ JSON.stringify(hooks) !== JSON.stringify(existingHooks) ||
690
+ (Object.keys(hooks).length === 0 && Object.prototype.hasOwnProperty.call(settings, "hooks"));
691
+ if (!changed) return;
692
+
693
+ if (Object.keys(hooks).length > 0) {
694
+ settings.hooks = hooks;
695
+ } else {
696
+ delete settings.hooks;
697
+ }
698
+ await writeClaudeSettings(settingsLocalPath, settings);
653
699
  }
654
700
 
655
701
  export function createClaudePlugin(): ProviderPlugin {
@@ -663,15 +709,19 @@ export function createClaudePlugin(): ProviderPlugin {
663
709
  gitignoreEntries: () => COMPANION_GITIGNORE_ENTRIES,
664
710
  hosting: {
665
711
  mcp: {
666
- async register(ctx: SetupContext, descriptor: McpServerDescriptor) {
667
- await updateCompanionClaudeMcpServer(
668
- ctx.companionPath,
712
+ async register(ctx: SetupContext, descriptor) {
713
+ await updateClaudeMcpServer(
714
+ getCompanionClaudeMcpConfigPath(ctx.companionPath),
669
715
  descriptor.name,
670
- claudeMcpEntry(descriptor),
716
+ toClaudeMcpEntry(descriptor),
671
717
  );
672
718
  },
673
719
  async unregister(ctx: SetupContext, name: string) {
674
- await updateCompanionClaudeMcpServer(ctx.companionPath, name, null);
720
+ await updateClaudeMcpServer(
721
+ getCompanionClaudeMcpConfigPath(ctx.companionPath),
722
+ name,
723
+ null,
724
+ );
675
725
  },
676
726
  },
677
727
  instructions: {
@@ -679,6 +729,10 @@ export function createClaudePlugin(): ProviderPlugin {
679
729
  },
680
730
  },
681
731
  async apply(ctx) {
732
+ if (ctx.scope === "hub") {
733
+ await syncCompanionClaudeSettings(ctx.companionPath, ctx.config);
734
+ return;
735
+ }
682
736
  await configureClaude(ctx.companionPath);
683
737
  await configureClaudeGuidance(ctx.companionPath);
684
738
  await teardownLegacyClaudeBin(ctx.companionPath);