@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
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
 
4
- import type { CapabilityPlugin, SetupContext } from "../plugin";
4
+ import type { CapabilityPlugin, RuntimeContributionsByRuntime, SetupContext } from "../plugin";
5
5
  import type { InstallRequirement } from "../install-contract";
6
6
  import {
7
7
  isCommandOnPath,
@@ -11,7 +11,23 @@ import {
11
11
  } from "../utils";
12
12
  import { confirm } from "../../../cli/confirm";
13
13
  import { FRAMEWORK_NAME } from "../../../framework";
14
+ import { getWrapperBinPath } from "../../../lib/package-paths";
14
15
  import { isInstalledViaUvTool } from "../package-managers/uv";
16
+ import { stripSectionFromFile } from "../providers/agent-file-sections";
17
+ import { graphifySectionOptions } from "./graphify-shared";
18
+ export { removeGraphifySection } from "./graphify-shared";
19
+ import {
20
+ mergeClaudeSettingsJsonHooks,
21
+ patchClaudeSkillTree,
22
+ removeClaudeHookGroupsWhere,
23
+ stripClaudeForeignSections,
24
+ } from "../providers/claude";
25
+ import type { ClaudeHookGroup } from "../providers/claude-format";
26
+ import {
27
+ patchOpenCodeSkillTree,
28
+ removeOpenCodeForeignPluginReferences,
29
+ stripOpenCodeForeignSections,
30
+ } from "../providers/opencode";
15
31
 
16
32
  // Storage contract: <companionPath>/.graphify/<repositoryId>/graphify-out/
17
33
  export const GRAPHIFY_STORE_SEGMENT = ".graphify";
@@ -43,31 +59,8 @@ const GRAPHIFY_PROVIDER_DIRS: Record<string, string> = {
43
59
  export const GRAPHIFY_COMPANION_OUT_PREFIX =
44
60
  "$MATE_ARTIFACT_PATH/.graphify/$MATE_REPO_ID/graphify-out/";
45
61
 
46
- // Marker names derive from the framework identity, never the invocation name.
47
- const GRAPHIFY_START = () => `<!-- ${FRAMEWORK_NAME.toUpperCase()}:GRAPHIFY:START -->`;
48
- const GRAPHIFY_END = () => `<!-- ${FRAMEWORK_NAME.toUpperCase()}:GRAPHIFY:END -->`;
49
-
50
- // Per-provider root agent instruction files used for graphify cleanup on teardown.
51
- const GRAPHIFY_AGENT_FILES: Record<string, string> = {
52
- claude: "CLAUDE.md",
53
- opencode: "AGENTS.md",
54
- };
55
-
56
62
  export const GRAPHIFY_SUPPORTED_PROVIDERS = Object.keys(GRAPHIFY_PROVIDER_DIRS);
57
63
 
58
- interface ClaudeHookCommand {
59
- type?: string;
60
- command?: string;
61
- args?: string[];
62
- }
63
-
64
- interface ClaudeHookGroup {
65
- matcher?: string;
66
- hooks?: ClaudeHookCommand[];
67
- }
68
-
69
- type ClaudeHookMap = Record<string, ClaudeHookGroup[]>;
70
-
71
64
  const CLAUDE_MANAGED_HOOK_COMMAND_SUFFIXES = new Set([
72
65
  "/.claude/hooks/validate-artifact-path",
73
66
  "/.claude/hooks/mate-session-banner",
@@ -79,29 +72,6 @@ export function deriveGraphifyProviders(activeProviders: string[]): string[] {
79
72
  return GRAPHIFY_SUPPORTED_PROVIDERS.filter((p) => activeProviders.includes(p));
80
73
  }
81
74
 
82
- function isGraphifyHeading(line: string): boolean {
83
- return /^#{1,6}\s+graphify\s*$/i.test(line);
84
- }
85
-
86
- function stripGraphifyMarkers(lines: string[]): string[] {
87
- return lines.filter((line) => line !== GRAPHIFY_START() && line !== GRAPHIFY_END());
88
- }
89
-
90
- function findGraphifySectionBounds(lines: string[]): { start: number; end: number } | null {
91
- const start = lines.findIndex(isGraphifyHeading);
92
- if (start === -1) return null;
93
-
94
- let end = lines.length;
95
- for (let i = start + 1; i < lines.length; i++) {
96
- if (lines[i].startsWith("## ") || lines[i].startsWith("# ")) {
97
- end = i;
98
- break;
99
- }
100
- }
101
-
102
- return { start, end };
103
- }
104
-
105
75
  // graphifyy's `graphify install` emits a skill whose pipeline writes to a
106
76
  // cwd-relative graphify-out/ using literal path strings (inline Python + bash)
107
77
  // that graphifyy's own GRAPHIFY_OUT/paths.py override cannot redirect. Rewrite
@@ -133,99 +103,7 @@ export function rewriteGraphifySkillOutputPaths(content: string): string {
133
103
  // Reference files whose graphify-out/ paths are intentionally cwd-relative or
134
104
  // point at external/cloned repos (e.g. multi-repo merge) and must NOT be
135
105
  // rewritten to the companion store.
136
- const GRAPHIFY_SKILL_REWRITE_EXCLUDE = new Set(["github-and-merge.md"]);
137
-
138
- async function patchGraphifySkillFile(skillPath: string): Promise<void> {
139
- let content: string;
140
- try {
141
- content = await fs.readFile(skillPath, "utf8");
142
- } catch {
143
- return; // file absent (graphify binary not installed) — nothing to patch
144
- }
145
- const rewritten = rewriteGraphifySkillOutputPaths(content);
146
- if (rewritten !== content) await fs.writeFile(skillPath, rewritten, "utf8");
147
- }
148
-
149
- // Rewrite output paths across the installed skill: SKILL.md plus every
150
- // references/*.md (excluding GRAPHIFY_SKILL_REWRITE_EXCLUDE). Directory-driven
151
- // so reference files graphifyy adds in future releases are covered automatically.
152
- async function patchGraphifySkillTree(skillDir: string): Promise<void> {
153
- await patchGraphifySkillFile(path.join(skillDir, "SKILL.md"));
154
-
155
- const refsDir = path.join(skillDir, "references");
156
- let entries: string[];
157
- try {
158
- entries = await fs.readdir(refsDir);
159
- } catch {
160
- return; // no references/ dir
161
- }
162
- const targets = entries.filter(
163
- (name) => name.endsWith(".md") && !GRAPHIFY_SKILL_REWRITE_EXCLUDE.has(name),
164
- );
165
- await Promise.all(targets.map((name) => patchGraphifySkillFile(path.join(refsDir, name))));
166
- }
167
-
168
- // Removes the graphify section from content, collapsing leftover blank lines.
169
- export function removeGraphifySection(content: string): string {
170
- const normalizedLines = stripGraphifyMarkers(content.split("\n"));
171
- const bounds = findGraphifySectionBounds(normalizedLines);
172
- if (!bounds) {
173
- const strippedMarkersOnly = normalizedLines.join("\n");
174
- return strippedMarkersOnly === content ? content : strippedMarkersOnly;
175
- }
176
-
177
- const remaining = [
178
- ...normalizedLines.slice(0, bounds.start),
179
- ...normalizedLines.slice(bounds.end),
180
- ].join("\n");
181
- const collapsed = remaining.replace(/\n{3,}/g, "\n\n").trim();
182
- return collapsed ? collapsed + "\n" : "";
183
- }
184
-
185
- async function stripAgentFile(filePath: string): Promise<void> {
186
- let content: string;
187
- try {
188
- content = await fs.readFile(filePath, "utf8");
189
- } catch {
190
- return;
191
- }
192
- const stripped = removeGraphifySection(content);
193
- if (stripped === content) return;
194
- if (!stripped.trim()) {
195
- await fs.unlink(filePath);
196
- } else {
197
- await fs.writeFile(filePath, stripped, "utf8");
198
- }
199
- }
200
-
201
- async function stripLegacyClaudeGraphifyFile(companionPath: string): Promise<void> {
202
- const legacyPath = path.join(companionPath, ".claude", "CLAUDE.md");
203
- await stripAgentFile(legacyPath);
204
- await pruneEmptyAncestors(path.join(companionPath, ".claude"), companionPath);
205
- }
206
-
207
- function hookEntryKey(entry: ClaudeHookGroup): string {
208
- return JSON.stringify(entry);
209
- }
210
-
211
- function mergeClaudeHookGroups(
212
- existingHooks: ClaudeHookMap,
213
- incomingHooks: ClaudeHookMap,
214
- ): ClaudeHookMap {
215
- const merged: ClaudeHookMap = { ...existingHooks };
216
- for (const [event, entries] of Object.entries(incomingHooks)) {
217
- const current = merged[event] ?? [];
218
- const seen = new Set(current.map(hookEntryKey));
219
- merged[event] = [...current];
220
- for (const entry of entries ?? []) {
221
- const key = hookEntryKey(entry);
222
- if (seen.has(key)) continue;
223
- merged[event].push(entry);
224
- seen.add(key);
225
- }
226
- }
227
- return merged;
228
- }
106
+ const GRAPHIFY_SKILL_REWRITE_EXCLUDE = ["github-and-merge.md"];
229
107
 
230
108
  function isMateManagedClaudeHookCommand(command: string): boolean {
231
109
  for (const suffix of CLAUDE_MANAGED_HOOK_COMMAND_SUFFIXES) {
@@ -249,65 +127,8 @@ function isGraphifyClaudeHookGroup(entry: ClaudeHookGroup): boolean {
249
127
  });
250
128
  }
251
129
 
252
- async function removeGraphifyClaudeHooks(companionPath: string): Promise<void> {
253
- const settingsLocalPath = path.join(companionPath, ".claude", "settings.local.json");
254
-
255
- try {
256
- const raw = await fs.readFile(settingsLocalPath, "utf8");
257
- const settings = JSON.parse(raw) as { hooks?: ClaudeHookMap } & Record<string, unknown>;
258
- const existingHooks = settings.hooks ?? {};
259
- let changed = false;
260
-
261
- for (const [event, existingEntries] of Object.entries(existingHooks)) {
262
- if (!existingEntries?.length) continue;
263
- const nextEntries = existingEntries.filter((entry) => !isGraphifyClaudeHookGroup(entry));
264
-
265
- if (nextEntries.length !== existingEntries.length) {
266
- changed = true;
267
- if (nextEntries.length > 0) {
268
- existingHooks[event] = nextEntries;
269
- } else {
270
- delete existingHooks[event];
271
- }
272
- }
273
- }
274
-
275
- if (Object.keys(existingHooks).length === 0) {
276
- if (Object.prototype.hasOwnProperty.call(settings, "hooks")) changed = true;
277
- delete settings.hooks;
278
- } else {
279
- settings.hooks = existingHooks;
280
- }
281
-
282
- if (changed) {
283
- await fs.writeFile(settingsLocalPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
284
- }
285
- } catch {
286
- /* settings absent or malformed */
287
- }
288
- }
289
-
290
- async function removeOpenCodePlugin(filePath: string): Promise<void> {
291
- let raw: string;
292
- try {
293
- raw = await fs.readFile(filePath, "utf8");
294
- } catch {
295
- return;
296
- }
297
- try {
298
- const parsed = JSON.parse(raw) as Record<string, unknown>;
299
- if (Array.isArray(parsed.plugin)) {
300
- parsed.plugin = (parsed.plugin as string[]).filter((p) => !p.includes("graphify"));
301
- if ((parsed.plugin as string[]).length === 0) delete parsed.plugin;
302
- }
303
- if (Object.keys(parsed).length === 0) {
304
- await fs.unlink(filePath);
305
- } else {
306
- await fs.writeFile(filePath, JSON.stringify(parsed, null, 2) + "\n", "utf8");
307
- }
308
- } catch {
309
- // JSON parse error — leave file untouched
310
- }
130
+ function isGraphifyPluginReference(entry: unknown): boolean {
131
+ return typeof entry === "string" && entry.includes("graphify");
311
132
  }
312
133
 
313
134
  type GraphifyRunCommand = typeof runShellCommand;
@@ -336,6 +157,22 @@ export function createGraphifyPlugin(deps: GraphifyPluginDeps = {}): CapabilityP
336
157
  "Enable Graphify code graph analysis with companion-managed wrappers for supported agent sessions.",
337
158
  defaultSelected: false,
338
159
  isEnabled: (config) => (config.capabilities ?? []).some((c) => c.name === "graphify"),
160
+ // Permission pre-seeds are declared and reconciled by the Claude Runtime
161
+ // Surface. The graphify skill trees and agent-file sections are written by
162
+ // the external `graphify install` CLI, so they are reconciled through the
163
+ // Runtime Surface escape hatch below instead of declarations.
164
+ getRuntimeContributions(): RuntimeContributionsByRuntime {
165
+ return {
166
+ claude: {
167
+ permissionEntries: [
168
+ "Skill(graphify)",
169
+ "Bash(graphify:*)",
170
+ `Bash(${FRAMEWORK_NAME} cap graphify:*)`,
171
+ `Bash(${path.join(getWrapperBinPath(), "graphify")}:*)`,
172
+ ],
173
+ },
174
+ };
175
+ },
339
176
  gitignoreEntries: () => GRAPHIFY_GITIGNORE_ENTRIES,
340
177
  persistGitignoreEntries: true,
341
178
  getInstallRequirements: (): InstallRequirement[] => [
@@ -372,7 +209,10 @@ export function createGraphifyPlugin(deps: GraphifyPluginDeps = {}): CapabilityP
372
209
  },
373
210
 
374
211
  async teardown(ctx: SetupContext) {
375
- await stripAgentFile(path.join(ctx.companionPath, "AGENTS.md"));
212
+ await stripSectionFromFile(
213
+ path.join(ctx.companionPath, "AGENTS.md"),
214
+ graphifySectionOptions(),
215
+ );
376
216
  // Don't uninstall the global graphify binary — companion-managed files are
377
217
  // cleaned up per-provider in forProvider[].teardown. Graph data under
378
218
  // .graphify/ is retained as user data.
@@ -383,8 +223,6 @@ export function createGraphifyPlugin(deps: GraphifyPluginDeps = {}): CapabilityP
383
223
  providerId,
384
224
  {
385
225
  async apply(ctx: SetupContext) {
386
- const providerDir = GRAPHIFY_PROVIDER_DIRS[providerId];
387
-
388
226
  // Reconcile Graphify-managed provider assets from the companion root
389
227
  const isInstalled =
390
228
  checkPath("graphify", process.env.PATH ?? "") || checkUvTool("graphifyy");
@@ -395,93 +233,54 @@ export function createGraphifyPlugin(deps: GraphifyPluginDeps = {}): CapabilityP
395
233
  { cwd: ctx.companionPath },
396
234
  );
397
235
 
398
- // graphify install writes a ## graphify section to the agent file.
399
- // Strip it since guidance is now delivered at runtime via system prompt.
400
- const agentFile = GRAPHIFY_AGENT_FILES[providerId];
401
- if (agentFile) {
402
- // `graphify install` runs with cwd=companionPath, so it writes the
403
- // section to the companion root agent file (CLAUDE.md / AGENTS.md).
404
- // Strip there. Also strip the per-provider path in case a future
405
- // graphify release targets it instead.
406
- await stripAgentFile(path.join(ctx.companionPath, agentFile));
407
- await stripAgentFile(path.join(ctx.companionPath, providerDir, agentFile));
408
-
409
- // Also strip from the repo-level files when syncing from a linked repo.
410
- if (ctx.repoPath) {
411
- await stripAgentFile(path.join(ctx.repoPath, agentFile));
412
- }
236
+ // graphify install writes a ## graphify section to the agent
237
+ // files (companion root, per-provider dir, and — when syncing
238
+ // from a linked repo — the repo file). Strip them through the
239
+ // escape hatch since guidance is delivered via system prompt.
240
+ if (providerId === "claude") {
241
+ await stripClaudeForeignSections(ctx.companionPath, {
242
+ ...graphifySectionOptions(),
243
+ repoPath: ctx.repoPath,
244
+ });
245
+
246
+ // graphify install writes .claude/settings.json with hook
247
+ // guards; absorb them into the Mate-owned settings document.
248
+ await mergeClaudeSettingsJsonHooks(ctx.companionPath);
413
249
  }
414
250
 
415
- // graphify install also writes "plugin": [".opencode/plugins/graphify.js"]
416
- // to opencode.json. Because the config lives inside companionPath/.opencode/,
417
- // the relative path resolves to companionPath/.opencode/.opencode/plugins/
418
- // (double .opencode) — a broken path. Opencode auto-discovers plugins from
419
- // plugins/ anyway, so strip the explicit entry to avoid duplicate loading.
420
251
  if (providerId === "opencode") {
421
- await removeOpenCodePlugin(
422
- path.join(ctx.companionPath, ".opencode", "opencode.json"),
423
- );
424
- }
425
-
426
- // graphify install writes .claude/settings.json with hook guards.
427
- // Merge its hooks into settings.local.json (where hooks are managed),
428
- // then remove the tracked file.
429
- if (providerId === "claude") {
430
- const settingsJsonPath = path.join(ctx.companionPath, ".claude", "settings.json");
431
- const settingsLocalPath = path.join(
252
+ await stripOpenCodeForeignSections(ctx.companionPath, {
253
+ ...graphifySectionOptions(),
254
+ repoPath: ctx.repoPath,
255
+ });
256
+
257
+ // graphify install also writes "plugin": [".opencode/plugins/graphify.js"]
258
+ // to opencode.json. Because the config lives inside companionPath/.opencode/,
259
+ // the relative path resolves to companionPath/.opencode/.opencode/plugins/
260
+ // (double .opencode) — a broken path. Opencode auto-discovers plugins from
261
+ // plugins/ anyway, so strip the explicit entry to avoid duplicate loading.
262
+ await removeOpenCodeForeignPluginReferences(
432
263
  ctx.companionPath,
433
- ".claude",
434
- "settings.local.json",
264
+ isGraphifyPluginReference,
435
265
  );
436
- try {
437
- const settingsJsonRaw = await fs.readFile(settingsJsonPath, "utf8");
438
- const settingsJson = JSON.parse(settingsJsonRaw) as {
439
- hooks?: ClaudeHookMap;
440
- } & Record<string, unknown>;
441
-
442
- const settingsLocalRaw = await fs.readFile(settingsLocalPath, "utf8");
443
- const settingsLocal = JSON.parse(settingsLocalRaw) as {
444
- hooks?: ClaudeHookMap;
445
- } & Record<string, unknown>;
446
-
447
- if (settingsJson.hooks && Object.keys(settingsJson.hooks).length > 0) {
448
- settingsLocal.hooks = mergeClaudeHookGroups(
449
- settingsLocal.hooks ?? {},
450
- settingsJson.hooks,
451
- );
452
- }
453
-
454
- await fs.writeFile(
455
- settingsLocalPath,
456
- JSON.stringify(settingsLocal, null, 2) + "\n",
457
- "utf8",
458
- );
459
- } catch {
460
- /* settings files absent or malformed — just remove settings.json */
461
- }
462
- try {
463
- await fs.unlink(settingsJsonPath);
464
- } catch {
465
- /* not present */
466
- }
467
266
  }
468
267
  }
469
268
 
470
269
  // Redirect the freshly-installed skill's cwd-relative graphify-out/
471
270
  // paths (SKILL.md + references) to the companion-local store. Runs
472
271
  // every setup/sync so it survives graphifyy regenerating the skill.
473
- await patchGraphifySkillTree(
474
- path.join(ctx.companionPath, providerDir, "skills", "graphify"),
475
- );
272
+ const patchSkillTree =
273
+ providerId === "claude" ? patchClaudeSkillTree : patchOpenCodeSkillTree;
274
+ await patchSkillTree(ctx.companionPath, "graphify", rewriteGraphifySkillOutputPaths, {
275
+ excludeFiles: GRAPHIFY_SKILL_REWRITE_EXCLUDE,
276
+ });
476
277
  },
477
278
 
478
279
  async teardown(ctx: SetupContext) {
479
280
  const providerDir = GRAPHIFY_PROVIDER_DIRS[providerId];
480
281
 
481
- if (deriveGraphifyProviders(ctx.activeProviders).length === 0) {
482
- }
483
-
484
- // Remove Graphify-managed skills directory
282
+ // Remove the Graphify-managed skills directory (written by the
283
+ // external CLI, so removed here rather than via a declaration).
485
284
  try {
486
285
  await fs.rm(path.join(ctx.companionPath, providerDir, "skills", "graphify"), {
487
286
  recursive: true,
@@ -495,23 +294,17 @@ export function createGraphifyPlugin(deps: GraphifyPluginDeps = {}): CapabilityP
495
294
  ctx.companionPath,
496
295
  );
497
296
 
498
- // Strip the ## graphify section from the provider's agent instruction file,
499
- // but only when no other active provider also uses the same file.
500
- const agentFile = GRAPHIFY_AGENT_FILES[providerId];
501
- if (agentFile) {
502
- const sharedWithActive = GRAPHIFY_SUPPORTED_PROVIDERS.some(
503
- (otherId) =>
504
- otherId !== providerId &&
505
- GRAPHIFY_AGENT_FILES[otherId] === agentFile &&
506
- ctx.activeProviders.includes(otherId),
507
- );
508
- if (!sharedWithActive) {
509
- await stripAgentFile(path.join(ctx.companionPath, agentFile));
510
- }
511
- }
512
-
513
- // Provider-specific cleanup for files beyond skills/
514
- if (providerId === "opencode") {
297
+ // Strip the ## graphify section and provider-specific foreign
298
+ // config through the escape hatch. The shared AGENTS.md is kept
299
+ // while another active runtime still uses it.
300
+ if (providerId === "claude") {
301
+ await stripClaudeForeignSections(ctx.companionPath, graphifySectionOptions());
302
+ await removeClaudeHookGroupsWhere(ctx.companionPath, isGraphifyClaudeHookGroup);
303
+ } else if (providerId === "opencode") {
304
+ await stripOpenCodeForeignSections(ctx.companionPath, {
305
+ ...graphifySectionOptions(),
306
+ guardSharedFile: { activeProviders: ctx.activeProviders },
307
+ });
515
308
  try {
516
309
  await fs.unlink(
517
310
  path.join(ctx.companionPath, ".opencode", "plugins", "graphify.js"),
@@ -519,16 +312,14 @@ export function createGraphifyPlugin(deps: GraphifyPluginDeps = {}): CapabilityP
519
312
  } catch {
520
313
  /* not present */
521
314
  }
522
- await removeOpenCodePlugin(
523
- path.join(ctx.companionPath, ".opencode", "opencode.json"),
315
+ await removeOpenCodeForeignPluginReferences(
316
+ ctx.companionPath,
317
+ isGraphifyPluginReference,
524
318
  );
525
319
  await pruneEmptyAncestors(
526
320
  path.join(ctx.companionPath, ".opencode", "plugins"),
527
321
  ctx.companionPath,
528
322
  );
529
- } else if (providerId === "claude") {
530
- await removeGraphifyClaudeHooks(ctx.companionPath);
531
- await stripLegacyClaudeGraphifyFile(ctx.companionPath);
532
323
  }
533
324
  },
534
325
  },
@@ -4,10 +4,12 @@ import path from "node:path";
4
4
  import { promisify } from "node:util";
5
5
 
6
6
  import { getOpenSpecSchemaSelection } from "../../../lib/orchestrator/setup-compatibilities";
7
+ import { getWrapperBinPath } from "../../../lib/package-paths";
7
8
  import { fetchPublicPackageVersion } from "../../../lib/public-npm";
8
9
  import { isNewer } from "../../../lib/update-checker";
10
+ import { FRAMEWORK_NAME } from "../../../framework";
9
11
  import { confirm } from "../../../cli/confirm";
10
- import type { CapabilityPlugin, SetupContext } from "../plugin";
12
+ import type { CapabilityPlugin, RuntimeContributionsByRuntime, SetupContext } from "../plugin";
11
13
  import type { InstallRequirement } from "../install-contract";
12
14
  import {
13
15
  isCommandOnPath,
@@ -132,6 +134,16 @@ async function reconcileOpenSpecTools(
132
134
  ): Promise<void> {
133
135
  const tools = deriveOpenSpecTools(ctx.activeProviders);
134
136
  if (tools.length === 0) return;
137
+ /** Best-effort: `config reset` is missing on older openspec releases. */
138
+ try {
139
+ await runCommand("openspec", ["config", "reset", "--all", "-y"], {
140
+ cwd: ctx.companionPath,
141
+ });
142
+ } catch (error) {
143
+ process.stderr.write(
144
+ `openspec config reset skipped: ${error instanceof Error ? error.message : String(error)}\n`,
145
+ );
146
+ }
135
147
 
136
148
  await runCommand("openspec", ["init", "--tools", tools.join(","), "--force", ctx.companionPath], {
137
149
  cwd: ctx.companionPath,
@@ -309,6 +321,27 @@ export function createOpenspecPlugin(deps: OpenSpecPluginDeps = {}): CapabilityP
309
321
  description: "Route OpenSpec commands to the companion root and enable planning workflows.",
310
322
  defaultSelected: true,
311
323
  isEnabled: (config) => (config.capabilities ?? []).some((c) => c.name === "openspec"),
324
+ // Permission pre-seeds are declared and reconciled by the Claude Runtime
325
+ // Surface. Skill trees stay imperative on purpose: the workflow skills are
326
+ // written by the external `openspec` CLI (foreign trees), and the
327
+ // mate-authored skills are git-mode-conditional with per-tool sources.
328
+ getRuntimeContributions(): RuntimeContributionsByRuntime {
329
+ return {
330
+ claude: {
331
+ permissionEntries: [
332
+ "Skill(openspec-explore)",
333
+ "Skill(openspec-propose)",
334
+ "Skill(openspec-apply-change)",
335
+ "Skill(openspec-archive-change)",
336
+ "Skill(mate-artifact-finish)",
337
+ "Skill(mate-openspec-backfill)",
338
+ "Bash(openspec:*)",
339
+ `Bash(${FRAMEWORK_NAME} cap graphify:*)`,
340
+ `Bash(${path.join(getWrapperBinPath(), "openspec")}:*)`,
341
+ ],
342
+ },
343
+ };
344
+ },
312
345
  getInstallRequirements: (): InstallRequirement[] => [
313
346
  {
314
347
  id: "capability:openspec",
@@ -1,8 +1,8 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
 
4
- import type { CapabilityPlugin, SetupContext } from "../plugin";
5
- import { mergeDir, pruneEmptyAncestors } from "../utils";
4
+ import type { CapabilityPlugin, RuntimeContributionsByRuntime, SetupContext } from "../plugin";
5
+ import { pruneEmptyAncestors } from "../utils";
6
6
 
7
7
  export const SKILL_SRC = path.join(
8
8
  import.meta.dirname,
@@ -13,22 +13,6 @@ export const CLAUDE_HOOK_SRC = path.join(
13
13
  "../../../templates/capabilities/react-doctor/claude/hooks/react-doctor.sh",
14
14
  );
15
15
 
16
- export async function applyReactDoctorSkills(skillsDir: string): Promise<void> {
17
- await mergeDir(SKILL_SRC, skillsDir);
18
- }
19
-
20
- export async function teardownReactDoctorSkills(
21
- skillsDir: string,
22
- companionPath: string,
23
- ): Promise<void> {
24
- try {
25
- await fs.rm(skillsDir, { recursive: true, force: true });
26
- } catch {
27
- /* not present */
28
- }
29
- await pruneEmptyAncestors(path.dirname(skillsDir), companionPath);
30
- }
31
-
32
16
  export function createReactDoctorPlugin(): CapabilityPlugin {
33
17
  return {
34
18
  id: "react-doctor",
@@ -37,6 +21,41 @@ export function createReactDoctorPlugin(): CapabilityPlugin {
37
21
  description: "Install React Doctor skills and Claude post-edit scans.",
38
22
  defaultSelected: true,
39
23
  isEnabled: (config) => (config.capabilities ?? []).some((c) => c.name === "react-doctor"),
24
+ // Skills, session hooks, and permission pre-seeds are declared and
25
+ // reconciled by each runtime's Runtime Surface. Only the hook script file
26
+ // itself is copied imperatively below (script files are not a config
27
+ // format the surface owns).
28
+ getRuntimeContributions(ctx: SetupContext): RuntimeContributionsByRuntime {
29
+ const hookCommand = `sh "${ctx.companionPath}/.claude/hooks/react-doctor.sh"`;
30
+ const skillTrees = [{ name: "react-doctor", sourceDir: SKILL_SRC }];
31
+ return {
32
+ claude: {
33
+ hookGroups: [
34
+ // Record edits cheaply, then scan once when the edited turn finishes.
35
+ {
36
+ event: "PostToolUse",
37
+ marker: "react-doctor.sh",
38
+ group: {
39
+ matcher: "Write|Edit|MultiEdit|NotebookEdit|ApplyPatch",
40
+ hooks: [{ type: "command", command: hookCommand, timeout: 5 }],
41
+ },
42
+ },
43
+ {
44
+ event: "Stop",
45
+ marker: "react-doctor.sh",
46
+ group: { hooks: [{ type: "command", command: hookCommand, timeout: 45 }] },
47
+ },
48
+ ],
49
+ permissionEntries: [
50
+ "Skill(react-doctor)",
51
+ "Bash(npx react-doctor:*)",
52
+ "Bash(npx react-doctor@latest *)",
53
+ ],
54
+ skillTrees,
55
+ },
56
+ opencode: { skillTrees },
57
+ };
58
+ },
40
59
  async apply(ctx: SetupContext) {
41
60
  // Migrate: remove legacy shared .agents/skills/react-doctor/ from previous installs.
42
61
  try {
@@ -57,18 +76,12 @@ export function createReactDoctorPlugin(): CapabilityPlugin {
57
76
  forProvider: {
58
77
  claude: {
59
78
  async apply(ctx: SetupContext) {
60
- const skillsDir = path.join(ctx.companionPath, ".claude", "skills", "react-doctor");
61
- await applyReactDoctorSkills(skillsDir);
62
79
  const hookDest = path.join(ctx.companionPath, ".claude", "hooks", "react-doctor.sh");
63
80
  await fs.mkdir(path.dirname(hookDest), { recursive: true });
64
81
  await fs.copyFile(CLAUDE_HOOK_SRC, hookDest);
65
82
  await fs.chmod(hookDest, 0o755);
66
83
  },
67
84
  async teardown(ctx: SetupContext) {
68
- await teardownReactDoctorSkills(
69
- path.join(ctx.companionPath, ".claude", "skills", "react-doctor"),
70
- ctx.companionPath,
71
- );
72
85
  try {
73
86
  await fs.unlink(path.join(ctx.companionPath, ".claude", "hooks", "react-doctor.sh"));
74
87
  } catch {
@@ -80,19 +93,6 @@ export function createReactDoctorPlugin(): CapabilityPlugin {
80
93
  );
81
94
  },
82
95
  },
83
- opencode: {
84
- async apply(ctx: SetupContext) {
85
- await applyReactDoctorSkills(
86
- path.join(ctx.companionPath, ".opencode", "skills", "react-doctor"),
87
- );
88
- },
89
- async teardown(ctx: SetupContext) {
90
- await teardownReactDoctorSkills(
91
- path.join(ctx.companionPath, ".opencode", "skills", "react-doctor"),
92
- ctx.companionPath,
93
- );
94
- },
95
- },
96
96
  },
97
97
  };
98
98
  }
@@ -1,4 +1,4 @@
1
- import type { CapabilityPlugin, SetupContext } from "../plugin";
1
+ import type { CapabilityPlugin, RuntimeContributionsByRuntime, SetupContext } from "../plugin";
2
2
  import type { InstallRequirement } from "../install-contract";
3
3
  import { isCommandOnPath, runShellCommand, runShellCommandSilently } from "../utils";
4
4
  import { confirm } from "../../../cli/confirm";
@@ -52,6 +52,9 @@ export function createRtkPlugin(deps: RtkDeps = {}): CapabilityPlugin {
52
52
  defaultSelected: false,
53
53
  isEnabled: (config) =>
54
54
  (config.capabilities ?? []).some((capability) => capability.name === "rtk"),
55
+ getRuntimeContributions(): RuntimeContributionsByRuntime {
56
+ return { claude: { permissionEntries: ["Bash(rtk:*)"] } };
57
+ },
55
58
  getInstallRequirements: (): InstallRequirement[] => {
56
59
  const command = checkBrew() ? RTK_INSTALL_CMD_BREW : RTK_INSTALL_CMD_FALLBACK;
57
60
  return [