@uniqbit/mate-core 0.15.3 → 0.15.4-canary.10

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 (94) hide show
  1. package/claude-plugin/.claude-plugin/plugin.json +4 -0
  2. package/claude-plugin/hooks/artifact-finish-nudge.mjs +8 -0
  3. package/claude-plugin/hooks/hooks.json +37 -0
  4. package/claude-plugin/hooks/session-banner.mjs +8 -0
  5. package/claude-plugin/hooks/ts-loader.mjs +28 -0
  6. package/claude-plugin/hooks/validate-artifact-path.mjs +8 -0
  7. package/package.json +4 -2
  8. package/src/cli/commands/artifact/finish/openspec.ts +1 -5
  9. package/src/cli/commands/companion/hub.ts +99 -0
  10. package/src/cli/commands/companion/link.ts +0 -5
  11. package/src/cli/commands/companion/tui.ts +3 -2
  12. package/src/cli/commands/doctor.ts +254 -170
  13. package/src/cli/commands/install.ts +18 -14
  14. package/src/cli/commands/launch/shared.ts +4 -4
  15. package/src/cli/commands/plugin/install.ts +94 -0
  16. package/src/cli/commands/plugin/plugin.ts +17 -0
  17. package/src/cli/commands/setup.ts +4 -6
  18. package/src/cli/commands/update.ts +9 -11
  19. package/src/cli/companion-link-wizard.tsx +22 -78
  20. package/src/cli/main.ts +57 -13
  21. package/src/cli/plugin-commands.ts +4 -4
  22. package/src/cli/repo-list-table.tsx +2 -7
  23. package/src/cli/usage.ts +7 -3
  24. package/src/distribution.ts +3 -5
  25. package/src/framework.ts +4 -36
  26. package/src/hooks/artifact-finish-nudge.ts +212 -0
  27. package/src/hooks/session-banner.ts +25 -0
  28. package/src/hooks/validate-artifact-path.ts +236 -0
  29. package/src/index.ts +4 -0
  30. package/src/lib/context-mode-package.ts +5 -3
  31. package/src/lib/install.ts +31 -43
  32. package/src/lib/orchestrator/adapters/base.ts +7 -8
  33. package/src/lib/orchestrator/adapters/claude.ts +16 -0
  34. package/src/lib/orchestrator/adapters/opencode.ts +10 -60
  35. package/src/lib/orchestrator/companion-hub.ts +380 -0
  36. package/src/lib/orchestrator/companion-store.ts +1 -35
  37. package/src/lib/orchestrator/config-store.ts +94 -7
  38. package/src/lib/orchestrator/editor.ts +4 -20
  39. package/src/lib/orchestrator/framework-context.ts +119 -24
  40. package/src/lib/orchestrator/global-config-store.ts +1 -5
  41. package/src/lib/orchestrator/launcher.ts +3 -9
  42. package/src/lib/orchestrator/migration.ts +0 -23
  43. package/src/lib/orchestrator/opencode-guidance.ts +1 -2
  44. package/src/lib/orchestrator/repo-local-registry.ts +3 -1
  45. package/src/lib/orchestrator/root-context.ts +117 -0
  46. package/src/lib/orchestrator/setup-compatibilities.ts +1 -1
  47. package/src/lib/orchestrator/setup-preflight.ts +5 -18
  48. package/src/lib/orchestrator/types.ts +27 -19
  49. package/src/lib/orchestrator/working-repo-store.ts +9 -3
  50. package/src/lib/package-paths.ts +37 -0
  51. package/src/lib/update-checker.ts +5 -5
  52. package/src/playbooks/companion-guidance.ts +7 -7
  53. package/src/runtime/env.ts +0 -4
  54. package/src/templates/capabilities/openspec-cap/mate-skills/{mate-artifact-finish → agents/mate-artifact-finish}/SKILL.md +8 -8
  55. package/src/templates/capabilities/openspec-cap/mate-skills/{mate-artifact-finish → agents/mate-artifact-finish}/references/openspec.md +3 -3
  56. package/src/templates/capabilities/openspec-cap/mate-skills/claude/mate-artifact-finish/SKILL.md +58 -0
  57. package/src/templates/capabilities/openspec-cap/mate-skills/claude/mate-artifact-finish/references/openspec.md +139 -0
  58. package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +6 -5
  59. package/src/templates/root/TEMPLATE_AGENTS.md +2 -0
  60. package/src/templates/root/TEMPLATE_CLAUDE.md +2 -0
  61. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +3541 -0
  62. package/src/tools/setup/capabilities/context-mode.ts +57 -83
  63. package/src/tools/setup/capabilities/graphify-shared.ts +27 -0
  64. package/src/tools/setup/capabilities/graphify.ts +86 -295
  65. package/src/tools/setup/capabilities/openspec.ts +27 -30
  66. package/src/tools/setup/capabilities/react-doctor.ts +37 -37
  67. package/src/tools/setup/capabilities/rtk.ts +4 -1
  68. package/src/tools/setup/capabilities/tokensave-shared.ts +6 -0
  69. package/src/tools/setup/capabilities/tokensave.ts +50 -71
  70. package/src/tools/setup/context-services.ts +29 -0
  71. package/src/tools/setup/dynamic-plugins/host.ts +2 -1
  72. package/src/tools/setup/dynamic-plugins/hydrate.ts +3 -13
  73. package/src/tools/setup/dynamic-plugins/install.ts +124 -109
  74. package/src/tools/setup/dynamic-plugins/loader.ts +23 -58
  75. package/src/tools/setup/dynamic-plugins/paths.ts +8 -19
  76. package/src/tools/setup/dynamic-plugins/registry-hint.ts +14 -0
  77. package/src/tools/setup/engine.ts +65 -1
  78. package/src/tools/setup/mate.ts +28 -31
  79. package/src/tools/setup/plugin.ts +81 -0
  80. package/src/tools/setup/plugins/gitignore.ts +19 -9
  81. package/src/tools/setup/plugins/guidance.ts +2 -6
  82. package/src/tools/setup/policy.ts +4 -7
  83. package/src/tools/setup/providers/agent-file-sections.ts +78 -0
  84. package/src/tools/setup/providers/claude-format.ts +159 -0
  85. package/src/tools/setup/providers/claude.ts +283 -292
  86. package/src/tools/setup/providers/opencode-format.ts +146 -0
  87. package/src/tools/setup/providers/opencode.ts +171 -66
  88. package/src/tools/setup/providers/skill-tree.ts +38 -0
  89. package/src/tools/setup.ts +12 -7
  90. package/src/tui.ts +5 -0
  91. package/src/templates/capabilities/openspec-cap/claude/hooks/mate-artifact-finish.sh +0 -161
  92. package/src/templates/providers/claude/.claude/hooks/mate-session-banner +0 -28
  93. package/src/templates/providers/claude/.claude/hooks/validate-artifact-path +0 -242
  94. package/src/tools/setup/dynamic-plugins/pin-store.ts +0 -30
@@ -2,16 +2,41 @@
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
 
5
- import { frameworkCommandName } from "../../../framework";
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 { mergeDir, pruneEmptyAncestors, resolveCommandOnPath } from "../utils";
14
- import { getSetupProvidersRoot, getSetupRootTemplates } 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";
39
+ import { getSetupRootTemplates } from "./utils";
15
40
 
16
41
  async function configureClaudeGuidance(companionPath: string): Promise<void> {
17
42
  const rootTemplates = getSetupRootTemplates();
@@ -43,6 +68,10 @@ const ALL_MANAGED_EXCLUDE_ENTRIES = new Set([
43
68
  ]);
44
69
 
45
70
  // Command substrings that mark a working-repo hook group as Mate-managed.
71
+ // The mate plugin hooks (validate-artifact-path, mate-session-banner,
72
+ // mate-artifact-finish.sh) now ship in the bundled Claude plugin; their
73
+ // markers are retained migration-only so stale managed groups written by
74
+ // earlier releases keep being stripped, and are never re-added.
46
75
  const MANAGED_HOOK_MARKERS = [
47
76
  "validate-artifact-path",
48
77
  "mate-session-banner",
@@ -51,17 +80,33 @@ const MANAGED_HOOK_MARKERS = [
51
80
  "tokensave",
52
81
  ];
53
82
 
83
+ // Hook files earlier releases copied into the companion; the bundled Claude
84
+ // plugin replaced them. Setup and launch sync delete stale copies.
85
+ const LEGACY_MATE_HOOK_FILES = [
86
+ "validate-artifact-path",
87
+ "mate-session-banner",
88
+ "mate-artifact-finish.sh",
89
+ ];
90
+
91
+ async function removeLegacyMateHookFiles(companionPath: string): Promise<void> {
92
+ const hooksDir = path.join(companionPath, ".claude", "hooks");
93
+ for (const name of LEGACY_MATE_HOOK_FILES) {
94
+ try {
95
+ await fs.unlink(path.join(hooksDir, name));
96
+ } catch {
97
+ /* not present */
98
+ }
99
+ }
100
+ await pruneEmptyAncestors(hooksDir, companionPath);
101
+ }
102
+
54
103
  // Base `permissions.allow` entries that Claude gets for Mate-managed workflows.
55
104
  // Read/Edit are scoped to the companion path so routine reads and artifact
56
105
  // writes of skills, specs, and change artifacts don't prompt for approval on
57
106
  // every file. Claude Code ignores `Glob()` rules for file-permission checks
58
107
  // (only Read/Edit rules gate file tools), so no Glob entry is emitted.
59
108
  function getBaseManagedPermissionEntries(companionPath: string): string[] {
60
- return [
61
- `Bash(${frameworkCommandName()}:*)`,
62
- `Read(${companionPath}/**)`,
63
- `Edit(${companionPath}/**)`,
64
- ];
109
+ return [`Bash(${FRAMEWORK_NAME}:*)`, `Read(${companionPath}/**)`, `Edit(${companionPath}/**)`];
65
110
  }
66
111
 
67
112
  const LEGACY_MANAGED_PERMISSION_ENTRIES = [
@@ -69,46 +114,9 @@ const LEGACY_MANAGED_PERMISSION_ENTRIES = [
69
114
  "Bash($MATE_COMPANION_BIN_PATH/graphify:*)",
70
115
  ];
71
116
 
72
- // Enabled capability -> the `permissions.allow` entries it pre-seeds.
73
- function getCapabilityPermissionEntries(): Record<string, string[]> {
74
- const wrapperBinPath = getWrapperBinPath();
75
- return {
76
- openspec: [
77
- "Skill(openspec-explore)",
78
- "Skill(openspec-propose)",
79
- "Skill(openspec-apply-change)",
80
- "Skill(openspec-archive-change)",
81
- "Skill(mate-artifact-finish)",
82
- "Bash(openspec:*)",
83
- `Bash(${frameworkCommandName()} cap graphify:*)`,
84
- `Bash(${path.join(wrapperBinPath, "openspec")}:*)`,
85
- ],
86
- rtk: ["Bash(rtk:*)"],
87
- graphify: [
88
- "Skill(graphify)",
89
- "Bash(graphify:*)",
90
- `Bash(${frameworkCommandName()} cap graphify:*)`,
91
- `Bash(${path.join(wrapperBinPath, "graphify")}:*)`,
92
- ],
93
- "react-doctor": [
94
- "Skill(react-doctor)",
95
- "Bash(npx react-doctor:*)",
96
- "Bash(npx react-doctor@latest *)",
97
- ],
98
- tokensave: ["mcp__tokensave__*"],
99
- // The context-mode Claude plugin exposes its skill and MCP tools under the
100
- // plugin namespace; pre-seed both so routine routing doesn't prompt.
101
- "context-mode": [
102
- "Skill(context-mode:context-mode)",
103
- "mcp__plugin_context-mode_context-mode__*",
104
- ],
105
- };
106
- }
107
-
108
117
  function getAllManagedPermissionEntries(companionPath: string): Set<string> {
109
118
  return new Set([
110
119
  ...getBaseManagedPermissionEntries(companionPath),
111
- ...Object.values(getCapabilityPermissionEntries()).flat(),
112
120
  ...LEGACY_MANAGED_PERMISSION_ENTRIES,
113
121
  // Legacy base entry: Claude Code never matched Glob() rules for file
114
122
  // permission checks and warns about them, so setup no longer emits it.
@@ -117,37 +125,22 @@ function getAllManagedPermissionEntries(companionPath: string): Set<string> {
117
125
  ]);
118
126
  }
119
127
 
120
- interface HookCommand {
121
- type?: string;
122
- command?: string;
123
- args?: string[];
124
- timeout?: number;
125
- }
126
- interface HookGroup {
127
- matcher?: string;
128
- hooks?: HookCommand[];
129
- }
130
- interface WorkingRepoSettings {
131
- hooks?: Record<string, HookGroup[]>;
132
- permissions?: { additionalDirectories?: string[]; allow?: string[] } & Record<string, unknown>;
133
- mcpServers?: Record<string, { command?: string; args?: string[] }>;
134
- [key: string]: unknown;
135
- }
136
-
137
- function isManagedHookGroup(group: HookGroup): boolean {
128
+ function isManagedHookGroup(group: ClaudeHookGroup, extraMarkers: string[] = []): boolean {
138
129
  return (group.hooks ?? []).some((hook) =>
139
- MANAGED_HOOK_MARKERS.some((marker) => (hook.command ?? "").includes(marker)),
130
+ [...MANAGED_HOOK_MARKERS, ...extraMarkers].some((marker) =>
131
+ (hook.command ?? "").includes(marker),
132
+ ),
140
133
  );
141
134
  }
142
135
 
143
- function removeManagedHookGroups(settings: WorkingRepoSettings): WorkingRepoSettings {
144
- const hooks: Record<string, HookGroup[]> = {};
145
- for (const [event, groups] of Object.entries(settings.hooks ?? {})) {
146
- const remainingGroups = (groups ?? []).filter((group) => !isManagedHookGroup(group));
147
- if (remainingGroups.length > 0) {
148
- hooks[event] = remainingGroups;
149
- }
150
- }
136
+ function removeManagedHookGroups(
137
+ settings: ClaudeSettings,
138
+ extraMarkers: string[] = [],
139
+ ): ClaudeSettings {
140
+ const hooks = filterClaudeHookGroups(
141
+ settings.hooks ?? {},
142
+ (group) => !isManagedHookGroup(group, extraMarkers),
143
+ );
151
144
 
152
145
  const next = { ...settings };
153
146
  if (Object.keys(hooks).length > 0) {
@@ -158,18 +151,6 @@ function removeManagedHookGroups(settings: WorkingRepoSettings): WorkingRepoSett
158
151
  return next;
159
152
  }
160
153
 
161
- async function readWorkingRepoSettings(settingsPath: string): Promise<WorkingRepoSettings> {
162
- try {
163
- const parsed = JSON.parse(await fs.readFile(settingsPath, "utf8")) as unknown;
164
- if (parsed && typeof parsed === "object") {
165
- return parsed as WorkingRepoSettings;
166
- }
167
- } catch {
168
- // Absent or unparseable — start from an empty object.
169
- }
170
- return {};
171
- }
172
-
173
154
  export async function ensureWorkingRepoLocalExcludes(
174
155
  workingRepoPath: string,
175
156
  config: FrameworkConfig,
@@ -214,9 +195,6 @@ export async function ensureWorkingRepoLocalExcludes(
214
195
  await fs.writeFile(excludePath, nextContent, "utf8");
215
196
  }
216
197
 
217
- // Marker that identifies the tokensave installer's CLAUDE.md append block.
218
- const TOKENSAVE_CLAUDE_MD_MARKER = "## MANDATORY: No Explore Agents When Tokensave Is Available";
219
-
220
198
  async function stripTokensaveClaudeMdAppend(workingRepoPath: string): Promise<void> {
221
199
  const claudeMdPath = path.join(workingRepoPath, "CLAUDE.md");
222
200
  let content: string;
@@ -226,13 +204,10 @@ async function stripTokensaveClaudeMdAppend(workingRepoPath: string): Promise<vo
226
204
  return;
227
205
  }
228
206
 
229
- const idx = content.indexOf(TOKENSAVE_CLAUDE_MD_MARKER);
230
- if (idx === -1) return;
231
-
232
- // Cut from the marker to EOF. Trim trailing whitespace before the cut point.
233
- const before = content.slice(0, idx).replace(/\s+$/, "");
234
- if (before.length > 0) {
235
- 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");
236
211
  } else {
237
212
  await fs.unlink(claudeMdPath);
238
213
  }
@@ -257,94 +232,37 @@ export function getCompanionClaudeMcpConfigPath(companionPath: string): string {
257
232
  // groups/entries always lead their arrays so the emitted shape stays stable
258
233
  // across syncs; unmanaged content is preserved untouched.
259
234
  function buildManagedClaudeSettings(
260
- existing: WorkingRepoSettings,
235
+ existing: ClaudeSettings,
261
236
  companionPath: string,
262
237
  config: FrameworkConfig,
263
- tokensaveCommandPath: string,
264
- ): WorkingRepoSettings {
265
- const capabilities = config.capabilities ?? [];
266
- const enabledNames = new Set(capabilities.map((c) => c.name));
267
- const openspecEnabled = enabledNames.has("openspec") && config.git === "auto";
268
- const reactDoctorEnabled = enabledNames.has("react-doctor");
269
-
270
- const hooks = removeManagedHookGroups(existing).hooks ?? {};
271
- hooks.PreToolUse = [
272
- {
273
- matcher: "Write|Edit|MultiEdit|Bash",
274
- hooks: [
275
- { type: "command", command: `${companionPath}/.claude/hooks/validate-artifact-path` },
276
- ],
277
- },
278
- ...(hooks.PreToolUse ?? []),
279
- ];
280
- hooks.SessionStart = [
281
- {
282
- hooks: [{ type: "command", command: `${companionPath}/.claude/hooks/mate-session-banner` }],
283
- },
284
- ...(hooks.SessionStart ?? []),
285
- ];
286
- if (openspecEnabled) {
287
- hooks.PostToolUse = [
288
- {
289
- matcher: "Bash",
290
- hooks: [
291
- {
292
- type: "command",
293
- command: `sh "${companionPath}/.claude/hooks/mate-artifact-finish.sh"`,
294
- },
295
- ],
296
- },
297
- ...(hooks.PostToolUse ?? []),
298
- ];
299
- }
300
- if (reactDoctorEnabled) {
301
- // Record edits cheaply, then scan once when the edited turn finishes.
302
- hooks.PostToolUse = [
303
- {
304
- matcher: "Write|Edit|MultiEdit|NotebookEdit|ApplyPatch",
305
- hooks: [
306
- {
307
- type: "command",
308
- command: `sh "${companionPath}/.claude/hooks/react-doctor.sh"`,
309
- timeout: 5,
310
- },
311
- ],
312
- },
313
- ...(hooks.PostToolUse ?? []),
314
- ];
315
- hooks.Stop = [
316
- {
317
- hooks: [
318
- {
319
- type: "command",
320
- command: `sh "${companionPath}/.claude/hooks/react-doctor.sh"`,
321
- timeout: 45,
322
- },
323
- ],
324
- },
325
- ...(hooks.Stop ?? []),
326
- ];
327
- }
328
- if (enabledNames.has("tokensave")) {
329
- hooks.PreToolUse = [
330
- {
331
- matcher: "Agent|Grep|Bash",
332
- hooks: [{ type: "command", command: tokensaveCommandPath, args: ["hook-pre-tool-use"] }],
333
- },
334
- ...(hooks.PreToolUse ?? []),
335
- ];
336
- hooks.UserPromptSubmit = [
337
- {
338
- hooks: [{ type: "command", command: tokensaveCommandPath, args: ["hook-prompt-submit"] }],
339
- },
340
- ...(hooks.UserPromptSubmit ?? []),
341
- ];
342
- hooks.Stop = [
343
- {
344
- hooks: [{ type: "command", command: tokensaveCommandPath, args: ["hook-stop"] }],
345
- },
346
- ...(hooks.Stop ?? []),
347
- ];
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 ?? []);
251
+
252
+ // Mate's own hooks (artifact-path guard, session banner, archive-finish
253
+ // nudge) ship in the bundled Claude plugin loaded at launch; settings-sync
254
+ // only strips their legacy managed groups (via removeManagedHookGroups) and
255
+ // reconciles the capability hooks that remain settings-delivered.
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
+ }
348
266
  }
349
267
  for (const event of Object.keys(hooks)) {
350
268
  if (hooks[event].length === 0) delete hooks[event];
@@ -353,13 +271,13 @@ function buildManagedClaudeSettings(
353
271
  // permissions.allow: preserve unmanaged entries in place, then union the
354
272
  // Mate-managed base entries and enabled capability entries. Dropping a
355
273
  // capability removes only its managed entry.
356
- const capabilityPermissionEntries = getCapabilityPermissionEntries();
357
- const allManagedPermissionEntries = getAllManagedPermissionEntries(companionPath);
274
+ const allManagedPermissionEntries = new Set([
275
+ ...getAllManagedPermissionEntries(companionPath),
276
+ ...declaredPermissionEntries,
277
+ ]);
358
278
  const managedAllow = [
359
279
  ...getBaseManagedPermissionEntries(companionPath),
360
- ...Object.entries(capabilityPermissionEntries)
361
- .filter(([name]) => enabledNames.has(name))
362
- .flatMap(([, entries]) => entries),
280
+ ...enabledDeclaredPermissionEntries,
363
281
  ];
364
282
  const existingPermissions = existing.permissions ?? {};
365
283
  const existingAllow = Array.isArray(existingPermissions.allow) ? existingPermissions.allow : [];
@@ -380,7 +298,10 @@ function buildManagedClaudeSettings(
380
298
  // entry is removed so it cannot shadow the `.mcp.json` definition.
381
299
  delete mcpServers.tokensave;
382
300
 
383
- const settings: WorkingRepoSettings = { ...existing, hooks, autoMemoryEnabled: false };
301
+ const settings: ClaudeSettings = { ...existing, hooks, autoMemoryEnabled: false };
302
+ if (Object.keys(hooks).length === 0) {
303
+ delete settings.hooks;
304
+ }
384
305
  if (Object.keys(permissions).length > 0) {
385
306
  settings.permissions = permissions;
386
307
  } else {
@@ -401,54 +322,84 @@ function buildManagedClaudeSettings(
401
322
  export async function syncCompanionClaudeSettings(
402
323
  companionPath: string,
403
324
  config: FrameworkConfig,
325
+ contributions: CapabilityContributionInput[] = [],
404
326
  ): Promise<void> {
405
- const tokensaveCommandPath =
406
- resolveCommandOnPath("tokensave", process.env.PATH ?? "") ?? "tokensave";
407
-
408
327
  const settingsPath = getCompanionClaudeSettingsPath(companionPath);
409
- const existing = await readWorkingRepoSettings(settingsPath);
410
- const settings = buildManagedClaudeSettings(
411
- existing,
412
- companionPath,
413
- config,
414
- tokensaveCommandPath,
415
- );
328
+ const existing = await readClaudeSettings(settingsPath);
329
+ const settings = buildManagedClaudeSettings(existing, companionPath, config, contributions);
416
330
 
417
- await fs.mkdir(path.dirname(settingsPath), { recursive: true });
418
- await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
331
+ await writeClaudeSettings(settingsPath, settings);
419
332
 
420
- await syncCompanionClaudeMcpConfig(companionPath, config);
333
+ await syncCompanionClaudeMcpConfig(companionPath);
421
334
  }
422
335
 
423
- // Maintain the companion `.mcp.json` shell. MCP servers are registered by
424
- // capabilities through `ctx.mcp` (provider hosting) now; this only prunes the
425
- // legacy `tokensave` entry written by releases that predate the bookkeeping
426
- // manifest when the capability is disabled. Loaded at launch via
427
- // `claude --mcp-config`.
428
- async function syncCompanionClaudeMcpConfig(
429
- companionPath: string,
430
- 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[],
431
346
  ): Promise<void> {
432
- const enabledNames = new Set((config.capabilities ?? []).map((c) => c.name));
433
- const mcpConfigPath = getCompanionClaudeMcpConfigPath(companionPath);
347
+ const { companionPath, config } = ctx;
348
+
349
+ // The settings sync (re)creates the managed settings document, so it only
350
+ // runs while the Claude runtime is active; a deactivated runtime's pass is
351
+ // teardown-only and must not resurrect files the provider teardown removed.
352
+ if (ctx.activeProviders.includes("claude")) {
353
+ await syncCompanionClaudeSettings(companionPath, config, inputs);
354
+ }
355
+
356
+ for (const input of inputs) {
357
+ for (const descriptor of input.contributions.mcpServers ?? []) {
358
+ await updateClaudeMcpServer(
359
+ getCompanionClaudeMcpConfigPath(companionPath),
360
+ descriptor.name,
361
+ input.enabled ? toClaudeMcpEntry(descriptor) : null,
362
+ );
363
+ }
434
364
 
435
- let existing: { mcpServers?: Record<string, unknown> } & Record<string, unknown> = {};
436
- try {
437
- const parsed = JSON.parse(await fs.readFile(mcpConfigPath, "utf8")) as unknown;
438
- if (parsed && typeof parsed === "object") {
439
- existing = parsed as typeof existing;
365
+ // Guidance sections are managed blocks in CLAUDE.md. Current sections are
366
+ // upserted, then stale keys (content changes, disabled capability) are
367
+ // swept. CLAUDE.md is Claude-exclusive, so no shared-file guard applies.
368
+ const guidancePath = path.join(companionPath, "CLAUDE.md");
369
+ const sections = input.enabled ? (input.contributions.guidanceSections ?? []) : [];
370
+ const keepKeys = new Set<string>();
371
+ for (const section of sections) {
372
+ const blockKey = instructionBlockKey(input.pluginId, section.content);
373
+ keepKeys.add(blockKey);
374
+ await upsertManagedBlock(guidancePath, FRAMEWORK_NAME, blockKey, section.content);
375
+ }
376
+ if ((input.contributions.guidanceSections ?? []).length > 0) {
377
+ await removeManagedBlocksForPlugin(guidancePath, FRAMEWORK_NAME, input.pluginId, keepKeys);
440
378
  }
441
- } catch {
442
- // Absent or unparseable — start from an empty object.
443
- }
444
379
 
445
- const mcpServers: Record<string, unknown> = { ...existing.mcpServers };
446
- if (!enabledNames.has("tokensave")) {
447
- delete mcpServers.tokensave;
380
+ for (const skillTree of input.contributions.skillTrees ?? []) {
381
+ const skillDir = path.join(companionPath, ".claude", "skills", skillTree.name);
382
+ if (input.enabled) {
383
+ await mergeDir(skillTree.sourceDir, skillDir);
384
+ } else {
385
+ await fs.rm(skillDir, { recursive: true, force: true });
386
+ await pruneEmptyAncestors(path.join(companionPath, ".claude", "skills"), companionPath);
387
+ }
388
+ }
448
389
  }
390
+ }
449
391
 
450
- const next = { ...existing, mcpServers };
451
- await fs.writeFile(mcpConfigPath, JSON.stringify(next, null, 2) + "\n", "utf8");
392
+ // Maintain the companion `.mcp.json` shell. Managed MCP servers are reconciled
393
+ // from declared Capability contributions (and legacy `ctx.mcp` hosting); this
394
+ // only guarantees the file exists with an `mcpServers` map. Loaded at launch
395
+ // via `claude --mcp-config`.
396
+ async function syncCompanionClaudeMcpConfig(companionPath: string): Promise<void> {
397
+ const mcpConfigPath = getCompanionClaudeMcpConfigPath(companionPath);
398
+ const { config: existing } = await readClaudeMcpConfig(mcpConfigPath);
399
+ await writeClaudeMcpConfig(mcpConfigPath, {
400
+ ...existing,
401
+ mcpServers: { ...existing.mcpServers },
402
+ });
452
403
  }
453
404
 
454
405
  // Reconcile the working repo for a Claude launch. Mate-managed Claude settings
@@ -481,7 +432,7 @@ async function syncWorkingRepoClaudeAdditionalDirectories(
481
432
  const settingsPath = path.join(workingRepoPath, ".claude", "settings.local.json");
482
433
  // Migrate hooks only. Existing permissions and MCP entries stay in the
483
434
  // working repo; additionalDirectories is reconciled below.
484
- const existing = removeManagedHookGroups(await readWorkingRepoSettings(settingsPath));
435
+ const existing = removeManagedHookGroups(await readClaudeSettings(settingsPath));
485
436
  const permissions = { ...existing.permissions };
486
437
  const existingAdditionalDirectories = Array.isArray(permissions.additionalDirectories)
487
438
  ? permissions.additionalDirectories
@@ -504,7 +455,7 @@ async function syncWorkingRepoClaudeAdditionalDirectories(
504
455
  ),
505
456
  );
506
457
 
507
- const settings: WorkingRepoSettings = {
458
+ const settings: ClaudeSettings = {
508
459
  ...existing,
509
460
  permissions: {
510
461
  ...permissions,
@@ -512,12 +463,14 @@ async function syncWorkingRepoClaudeAdditionalDirectories(
512
463
  },
513
464
  };
514
465
 
515
- await fs.mkdir(path.dirname(settingsPath), { recursive: true });
516
- await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
466
+ await writeClaudeSettings(settingsPath, settings);
517
467
  }
518
468
 
519
- async function configureClaude(src: string, companionPath: string): Promise<void> {
520
- await mergeDir(path.join(src, ".claude"), path.join(companionPath, ".claude"));
469
+ async function configureClaude(companionPath: string): Promise<void> {
470
+ // Mate hooks are delivered by the bundled Claude plugin at launch; nothing
471
+ // is copied into `companion/.claude/hooks/` anymore. Stale copies from
472
+ // earlier releases are stripped so only the plugin-shipped hooks run.
473
+ await removeLegacyMateHookFiles(companionPath);
521
474
 
522
475
  // Note: `.claude/settings.local.json` is now Mate-owned and generated by
523
476
  // `syncCompanionClaudeSettings`; do not delete it here. `settings.json` is
@@ -528,16 +481,6 @@ async function configureClaude(src: string, companionPath: string): Promise<void
528
481
  /* not present */
529
482
  }
530
483
 
531
- const hooksDir = path.join(companionPath, ".claude", "hooks");
532
- try {
533
- const entries = await fs.readdir(hooksDir);
534
- for (const entry of entries) {
535
- await fs.chmod(path.join(hooksDir, entry), 0o755);
536
- }
537
- } catch {
538
- // hooks dir may not exist if no hooks are defined
539
- }
540
-
541
484
  const agentsMdSrc = path.join(getSetupRootTemplates(), "TEMPLATE_AGENTS.md");
542
485
  const agentsMdDest = path.join(companionPath, "AGENTS.md");
543
486
  try {
@@ -556,17 +499,9 @@ async function configureClaude(src: string, companionPath: string): Promise<void
556
499
  }
557
500
 
558
501
  async function teardownClaude(companionPath: string, allowedAgents: string[]): Promise<void> {
559
- try {
560
- await fs.unlink(path.join(companionPath, ".claude", "hooks", "validate-artifact-path"));
561
- } catch {
562
- /* not present */
563
- }
564
- try {
565
- await fs.unlink(path.join(companionPath, ".claude", "hooks", "mate-session-banner"));
566
- } catch {
567
- /* not present */
568
- }
569
- await pruneEmptyAncestors(path.join(companionPath, ".claude", "hooks"), companionPath);
502
+ // Migration-only: a companion last synced by a pre-plugin release may still
503
+ // carry copied mate hook files.
504
+ await removeLegacyMateHookFiles(companionPath);
570
505
  try {
571
506
  await fs.unlink(path.join(companionPath, ".claude", "settings.local.json"));
572
507
  } catch {
@@ -626,48 +561,100 @@ async function teardownLegacyClaudeBin(companionPath: string): Promise<void> {
626
561
  await pruneEmptyAncestors(path.join(companionPath, ".claude"), companionPath);
627
562
  }
628
563
 
629
- // Reconcile a single Mate-managed server entry in the companion `.mcp.json`
630
- // while preserving every unrelated entry. `entry: null` removes the server.
631
- async function updateCompanionClaudeMcpServer(
564
+ // ---------------------------------------------------------------------------
565
+ // Runtime Surface escape hatch (spec: runtime-surface). Imperative operations
566
+ // for effects a declaration cannot express — patching skill trees an external
567
+ // CLI wrote, absorbing/stripping foreign config another tool produced. Format
568
+ // knowledge stays in this module; capabilities pass only predicates.
569
+ // ---------------------------------------------------------------------------
570
+
571
+ /** Patch markdown files of an externally written `.claude/skills/<name>` tree. */
572
+ export async function patchClaudeSkillTree(
632
573
  companionPath: string,
633
574
  name: string,
634
- entry: Record<string, unknown> | null,
575
+ transform: (content: string) => string,
576
+ options: { excludeFiles?: readonly string[] } = {},
635
577
  ): Promise<void> {
636
- const mcpConfigPath = getCompanionClaudeMcpConfigPath(companionPath);
637
- let existing: { mcpServers?: Record<string, unknown> } & Record<string, unknown> = {};
638
- let present = false;
578
+ await patchSkillTreeMarkdownFiles(
579
+ path.join(companionPath, ".claude", "skills", name),
580
+ transform,
581
+ new Set(options.excludeFiles ?? []),
582
+ );
583
+ }
584
+
585
+ /**
586
+ * Strip a foreign heading section from the Claude guidance surfaces: the
587
+ * companion CLAUDE.md, the legacy `.claude/CLAUDE.md`, and — when syncing from
588
+ * a linked repo — the repo-level CLAUDE.md.
589
+ */
590
+ export async function stripClaudeForeignSections(
591
+ companionPath: string,
592
+ options: RemoveHeadingSectionOptions & { repoPath?: string },
593
+ ): Promise<void> {
594
+ await stripSectionFromFile(path.join(companionPath, "CLAUDE.md"), options);
595
+ await stripSectionFromFile(path.join(companionPath, ".claude", "CLAUDE.md"), options);
596
+ await pruneEmptyAncestors(path.join(companionPath, ".claude"), companionPath);
597
+ if (options.repoPath) {
598
+ await stripSectionFromFile(path.join(options.repoPath, "CLAUDE.md"), options);
599
+ }
600
+ }
601
+
602
+ /**
603
+ * Merge hook groups an external tool wrote to `.claude/settings.json` into the
604
+ * Mate-owned `settings.local.json`, then remove the tracked file so it cannot
605
+ * shadow companion config. Malformed or absent files skip the merge but the
606
+ * `settings.json` removal still happens.
607
+ */
608
+ export async function mergeClaudeSettingsJsonHooks(companionPath: string): Promise<void> {
609
+ const settingsJsonPath = path.join(companionPath, ".claude", "settings.json");
610
+ const settingsLocalPath = getCompanionClaudeSettingsPath(companionPath);
639
611
  try {
640
- const parsed = JSON.parse(await fs.readFile(mcpConfigPath, "utf8")) as unknown;
641
- if (parsed && typeof parsed === "object") {
642
- existing = parsed as typeof existing;
643
- present = true;
612
+ const settingsJson = JSON.parse(await fs.readFile(settingsJsonPath, "utf8")) as ClaudeSettings;
613
+ const settingsLocal = JSON.parse(
614
+ await fs.readFile(settingsLocalPath, "utf8"),
615
+ ) as ClaudeSettings;
616
+
617
+ if (settingsJson.hooks && Object.keys(settingsJson.hooks).length > 0) {
618
+ settingsLocal.hooks = mergeClaudeHookGroups(settingsLocal.hooks ?? {}, settingsJson.hooks);
644
619
  }
620
+
621
+ await writeClaudeSettings(settingsLocalPath, settingsLocal);
645
622
  } catch {
646
- // Absent or unparseable — start from an empty object.
623
+ /* settings files absent or malformed — just remove settings.json */
647
624
  }
648
- if (entry === null && !present) return;
649
-
650
- const mcpServers: Record<string, unknown> = { ...existing.mcpServers };
651
- if (entry === null) {
652
- if (!(name in mcpServers)) return;
653
- delete mcpServers[name];
654
- } else {
655
- mcpServers[name] = entry;
625
+ try {
626
+ await fs.unlink(settingsJsonPath);
627
+ } catch {
628
+ /* not present */
656
629
  }
657
- await fs.writeFile(
658
- mcpConfigPath,
659
- JSON.stringify({ ...existing, mcpServers }, null, 2) + "\n",
660
- "utf8",
661
- );
662
630
  }
663
631
 
664
- function claudeMcpEntry(descriptor: McpServerDescriptor): Record<string, unknown> {
665
- if (descriptor.url) return { url: descriptor.url };
666
- return {
667
- command: descriptor.command,
668
- ...(descriptor.args ? { args: descriptor.args } : {}),
669
- ...(descriptor.env ? { env: descriptor.env } : {}),
670
- };
632
+ /** Remove settings hook groups matching `isForeign`, pruning emptied containers. */
633
+ export async function removeClaudeHookGroupsWhere(
634
+ companionPath: string,
635
+ isForeign: (group: ClaudeHookGroup) => boolean,
636
+ ): Promise<void> {
637
+ const settingsLocalPath = getCompanionClaudeSettingsPath(companionPath);
638
+ let settings: ClaudeSettings;
639
+ try {
640
+ settings = JSON.parse(await fs.readFile(settingsLocalPath, "utf8")) as ClaudeSettings;
641
+ } catch {
642
+ return; /* settings absent or malformed */
643
+ }
644
+
645
+ const existingHooks = settings.hooks ?? {};
646
+ const hooks = filterClaudeHookGroups(existingHooks, (group) => !isForeign(group));
647
+ const changed =
648
+ JSON.stringify(hooks) !== JSON.stringify(existingHooks) ||
649
+ (Object.keys(hooks).length === 0 && Object.prototype.hasOwnProperty.call(settings, "hooks"));
650
+ if (!changed) return;
651
+
652
+ if (Object.keys(hooks).length > 0) {
653
+ settings.hooks = hooks;
654
+ } else {
655
+ delete settings.hooks;
656
+ }
657
+ await writeClaudeSettings(settingsLocalPath, settings);
671
658
  }
672
659
 
673
660
  export function createClaudePlugin(): ProviderPlugin {
@@ -677,19 +664,23 @@ export function createClaudePlugin(): ProviderPlugin {
677
664
  label: "Claude",
678
665
  description: "Install Claude companion files, hooks, and guidance.",
679
666
  defaultSelected: true,
680
- isEnabled: (config) => (config.profiles.default?.allowedAgents ?? []).includes("claude"),
667
+ isEnabled: (config) => (config.allowedAgents ?? []).includes("claude"),
681
668
  gitignoreEntries: () => COMPANION_GITIGNORE_ENTRIES,
682
669
  hosting: {
683
670
  mcp: {
684
- async register(ctx: SetupContext, descriptor: McpServerDescriptor) {
685
- await updateCompanionClaudeMcpServer(
686
- ctx.companionPath,
671
+ async register(ctx: SetupContext, descriptor) {
672
+ await updateClaudeMcpServer(
673
+ getCompanionClaudeMcpConfigPath(ctx.companionPath),
687
674
  descriptor.name,
688
- claudeMcpEntry(descriptor),
675
+ toClaudeMcpEntry(descriptor),
689
676
  );
690
677
  },
691
678
  async unregister(ctx: SetupContext, name: string) {
692
- await updateCompanionClaudeMcpServer(ctx.companionPath, name, null);
679
+ await updateClaudeMcpServer(
680
+ getCompanionClaudeMcpConfigPath(ctx.companionPath),
681
+ name,
682
+ null,
683
+ );
693
684
  },
694
685
  },
695
686
  instructions: {
@@ -697,13 +688,13 @@ export function createClaudePlugin(): ProviderPlugin {
697
688
  },
698
689
  },
699
690
  async apply(ctx) {
700
- await configureClaude(path.join(getSetupProvidersRoot(), "claude"), ctx.companionPath);
691
+ await configureClaude(ctx.companionPath);
701
692
  await configureClaudeGuidance(ctx.companionPath);
702
693
  await teardownLegacyClaudeBin(ctx.companionPath);
703
694
  await syncCompanionClaudeSettings(ctx.companionPath, ctx.config);
704
695
  },
705
696
  async teardown(ctx) {
706
- await teardownClaude(ctx.companionPath, ctx.config.profiles.default?.allowedAgents ?? []);
697
+ await teardownClaude(ctx.companionPath, ctx.config.allowedAgents ?? []);
707
698
  },
708
699
  };
709
700
  }