@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 +1,7 @@
1
1
  export const TOKENSAVE_WORKING_REPO_EXCLUDE_ENTRIES = [".tokensave/"];
2
+
3
+ // Marker that identifies the tokensave installer's CLAUDE.md append block.
4
+ // Declared here (capability-owned) and consumed by the Claude Runtime Surface
5
+ // when reconciling working-repo files at launch.
6
+ export const TOKENSAVE_CLAUDE_MD_MARKER =
7
+ "## MANDATORY: No Explore Agents When Tokensave Is Available";
@@ -3,29 +3,20 @@ import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
 
5
5
  import { resolveGitInfoExcludePath } from "../git-utils";
6
- import type { CapabilityPlugin } from "../plugin";
7
- import { isCommandOnPath, runCommand, runShellCommand } from "../utils";
6
+ import type { CapabilityPlugin, RuntimeContributionsByRuntime } from "../plugin";
7
+ import { isCommandOnPath, resolveCommandOnPath, runCommand, runShellCommand } from "../utils";
8
+ import { TOKENSAVE_CLAUDE_MD_MARKER } from "./tokensave-shared";
8
9
  export { TOKENSAVE_WORKING_REPO_EXCLUDE_ENTRIES } from "./tokensave-shared";
9
10
 
10
11
  export const TOKENSAVE_SUPPORTED_AGENTS = new Set(["claude", "opencode"]);
11
12
  export const TOKENSAVE_STORE_DIR = ".tokensave";
12
13
  export const TOKENSAVE_MIN_RUST_VERSION = "1.91.0";
13
- const TOKENSAVE_CLAUDE_MD_MARKER = "## MANDATORY: No Explore Agents When Tokensave Is Available";
14
14
  const TOKENSAVE_STORE_EXCLUDE_ENTRY = `${TOKENSAVE_STORE_DIR}/`;
15
15
  const TOKENSAVE_BREW_INSTALL_CMD = "brew install aovestdipaperino/tap/tokensave";
16
16
  const TOKENSAVE_CARGO_INSTALL_CMD = "cargo install --locked tokensave";
17
17
  const TOKENSAVE_SCOOP_INSTALL_CMD =
18
18
  "scoop bucket add tokensave https://github.com/aovestdipaperino/scoop-bucket && scoop install tokensave";
19
19
 
20
- interface CompanionOpenCodeSettings {
21
- mcp?: Record<string, unknown>;
22
- [key: string]: unknown;
23
- }
24
-
25
- function getCompanionOpenCodeConfigPath(companionPath: string): string {
26
- return path.join(companionPath, ".opencode", "opencode.json");
27
- }
28
-
29
20
  export interface TokensaveRunResult {
30
21
  ok: boolean;
31
22
  stderr: string;
@@ -34,10 +25,14 @@ export interface TokensaveRunResult {
34
25
 
35
26
  export const tokensaveDeps = {
36
27
  run(args: string[], cwd: string): TokensaveRunResult {
28
+ // env is passed explicitly: bun's spawnSync otherwise resolves the binary
29
+ // against the process's original PATH, ignoring in-process PATH changes
30
+ // (which test stubs and shell-integration wrappers rely on).
37
31
  const result = spawnSync("tokensave", args, {
38
32
  cwd,
39
33
  encoding: "utf8",
40
34
  stdio: ["ignore", "pipe", "pipe"],
35
+ env: process.env,
41
36
  });
42
37
  return {
43
38
  ok: result.status === 0 && !result.error,
@@ -58,6 +53,7 @@ export const tokensaveDeps = {
58
53
  const result = spawnSync("rustc", ["--version"], {
59
54
  encoding: "utf8",
60
55
  stdio: ["ignore", "pipe", "pipe"],
56
+ env: process.env,
61
57
  });
62
58
  return {
63
59
  ok: result.status === 0 && !result.error,
@@ -73,41 +69,6 @@ export const tokensaveDeps = {
73
69
  },
74
70
  };
75
71
 
76
- async function readCompanionOpenCodeSettings(
77
- companionPath: string,
78
- ): Promise<CompanionOpenCodeSettings> {
79
- try {
80
- const parsed = JSON.parse(
81
- await fs.readFile(getCompanionOpenCodeConfigPath(companionPath), "utf8"),
82
- ) as unknown;
83
- if (parsed && typeof parsed === "object") {
84
- return parsed as CompanionOpenCodeSettings;
85
- }
86
- } catch {
87
- /* absent or malformed */
88
- }
89
- return {};
90
- }
91
-
92
- async function removeOpenCodeMcpServer(companionPath: string): Promise<void> {
93
- const configPath = getCompanionOpenCodeConfigPath(companionPath);
94
- const settings = await readCompanionOpenCodeSettings(companionPath);
95
- if (!settings.mcp || typeof settings.mcp !== "object") {
96
- return;
97
- }
98
-
99
- const mcp = { ...settings.mcp };
100
- delete mcp.tokensave;
101
- if (Object.keys(mcp).length > 0) {
102
- settings.mcp = mcp;
103
- } else {
104
- delete settings.mcp;
105
- }
106
-
107
- await fs.mkdir(path.dirname(configPath), { recursive: true });
108
- await fs.writeFile(configPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
109
- }
110
-
111
72
  async function cleanupRepoLocalTokensaveArtifacts(repoPath: string): Promise<void> {
112
73
  const claudeMdPath = path.join(repoPath, "CLAUDE.md");
113
74
  try {
@@ -314,22 +275,55 @@ export function createTokensavePlugin(): CapabilityPlugin {
314
275
  },
315
276
  ];
316
277
  },
278
+ // MCP access, session hooks, and permission pre-seeds are declared below
279
+ // and reconciled by each runtime's Runtime Surface. The bare `tokensave`
280
+ // command name in the MCP entry is resolved against PATH at spawn time by
281
+ // each provider, so it never pins a since-moved/upgraded binary.
282
+ getRuntimeContributions(): RuntimeContributionsByRuntime {
283
+ const mcpServers = [{ name: "tokensave", command: "tokensave", args: ["serve"] }];
284
+ // Hook commands pin the resolved binary path (hooks run without the
285
+ // launch environment's PATH guarantees).
286
+ const tokensaveCommandPath =
287
+ resolveCommandOnPath("tokensave", process.env.PATH ?? "") ?? "tokensave";
288
+ const hookGroups = [
289
+ {
290
+ event: "PreToolUse",
291
+ marker: "tokensave",
292
+ group: {
293
+ matcher: "Agent|Grep|Bash",
294
+ hooks: [
295
+ { type: "command", command: tokensaveCommandPath, args: ["hook-pre-tool-use"] },
296
+ ],
297
+ },
298
+ },
299
+ {
300
+ event: "UserPromptSubmit",
301
+ marker: "tokensave",
302
+ group: {
303
+ hooks: [
304
+ { type: "command", command: tokensaveCommandPath, args: ["hook-prompt-submit"] },
305
+ ],
306
+ },
307
+ },
308
+ {
309
+ event: "Stop",
310
+ marker: "tokensave",
311
+ group: {
312
+ hooks: [{ type: "command", command: tokensaveCommandPath, args: ["hook-stop"] }],
313
+ },
314
+ },
315
+ ];
316
+ return {
317
+ claude: { mcpServers, hookGroups, permissionEntries: ["mcp__tokensave__*"] },
318
+ opencode: { mcpServers },
319
+ };
320
+ },
317
321
  async apply(ctx) {
318
322
  // Presence only: make sure the tokensave binary is installed so the graph can be
319
323
  // built later. The graph build itself (init/sync) lives in `mate cap index`, never
320
324
  // in setup or launch-time sync — keeping launch fast and indexing an explicit step.
321
325
  if (ctx.activeProviders.filter((p) => TOKENSAVE_SUPPORTED_AGENTS.has(p)).length === 0) return;
322
326
 
323
- // MCP access is provider-mediated: every active hosting provider gets the
324
- // server in its native config, and teardown bookkeeping removes it again.
325
- // The bare command name is resolved against PATH at spawn time by each
326
- // provider, so the registration never pins a since-moved/upgraded binary.
327
- await ctx.mcp?.register({
328
- name: "tokensave",
329
- command: "tokensave",
330
- args: ["serve"],
331
- });
332
-
333
327
  const targetPath = ctx.repoPath ?? ctx.companionPath;
334
328
  if (!(await ensureTokensaveInstalled(targetPath))) {
335
329
  return;
@@ -342,20 +336,5 @@ export function createTokensavePlugin(): CapabilityPlugin {
342
336
  if (!ctx.repoPath) return;
343
337
  await teardownDriver(ctx.repoPath, ctx.activeProviders);
344
338
  },
345
- forProvider: {
346
- claude: {
347
- async apply(_ctx) {},
348
- async teardown(_ctx) {},
349
- },
350
- opencode: {
351
- // Registration goes through ctx.mcp now. Teardown stays as a legacy
352
- // prune for entries written by releases that predate the bookkeeping
353
- // manifest.
354
- async apply(_ctx) {},
355
- async teardown(ctx) {
356
- await removeOpenCodeMcpServer(ctx.companionPath);
357
- },
358
- },
359
- },
360
339
  };
361
340
  }
@@ -99,6 +99,35 @@ export async function upsertManagedBlock(
99
99
  await fs.writeFile(filePath, existing + (existing ? separator : "") + block, "utf8");
100
100
  }
101
101
 
102
+ /**
103
+ * Remove every managed block a plugin owns in `filePath`, except blocks whose
104
+ * key is in `keepKeys`. Used by contribution reconciliation: current sections
105
+ * are upserted (content-hashed keys), then stale keys are swept here.
106
+ */
107
+ export async function removeManagedBlocksForPlugin(
108
+ filePath: string,
109
+ frameworkName: string,
110
+ pluginId: string,
111
+ keepKeys: Set<string> = new Set(),
112
+ ): Promise<void> {
113
+ let existing: string;
114
+ try {
115
+ existing = await fs.readFile(filePath, "utf8");
116
+ } catch {
117
+ return;
118
+ }
119
+ const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
120
+ const keyPattern = new RegExp(
121
+ `<!-- ${escape(frameworkName)}:managed:(${escape(pluginId)}:[0-9a-f]+) start -->`,
122
+ "g",
123
+ );
124
+ const keys = [...existing.matchAll(keyPattern)].map((match) => match[1]);
125
+ for (const key of keys) {
126
+ if (keepKeys.has(key)) continue;
127
+ await removeManagedBlock(filePath, frameworkName, key);
128
+ }
129
+ }
130
+
102
131
  /** Remove a marker-delimited block if present. No-op when file or block is absent. */
103
132
  export async function removeManagedBlock(
104
133
  filePath: string,
@@ -9,6 +9,7 @@ import { CompanionResolver } from "../../../lib/orchestrator/companion-resolver"
9
9
  import { GlobalConfigStore } from "../../../lib/orchestrator/global-config-store";
10
10
  import { PLUGIN_DECLARATION_POLICIES } from "../../../lib/orchestrator/config-store";
11
11
  import type { PluginDeclaration } from "../../../lib/orchestrator/types";
12
+ import type { Plugin } from "../plugin";
12
13
  import type { PluginRegistry } from "../registry";
13
14
  import { loadDynamicPlugin, type DynamicPluginLoadDeps } from "./loader";
14
15
 
@@ -89,6 +90,16 @@ function validateDeclaration(entry: unknown): { declaration?: PluginDeclaration;
89
90
  return { declaration: entry as unknown as PluginDeclaration };
90
91
  }
91
92
 
93
+ /**
94
+ * A framework declaration is the activation switch for a dynamic plugin.
95
+ * Own-property override rather than a spread: class-instance plugins keep
96
+ * their prototype members.
97
+ */
98
+ function activateDeclaredPlugin(plugin: Plugin): Plugin {
99
+ plugin.isEnabled = () => true;
100
+ return plugin;
101
+ }
102
+
92
103
  /**
93
104
  * Registers the companion's declared plugins into the active registry —
94
105
  * compiled-in plugins first (they are already registered), declared order
@@ -135,7 +146,10 @@ export async function hydrateDynamicPlugins(deps: HydrateDynamicPluginsDeps = {}
135
146
  );
136
147
  }
137
148
 
138
- registry.register({ plugin: result.plugin, policy: declaration.policy ?? "optional" });
149
+ registry.register({
150
+ plugin: activateDeclaredPlugin(result.plugin),
151
+ policy: declaration.policy ?? "optional",
152
+ });
139
153
  hydratedPackages.add(declaration.package);
140
154
  }
141
155
  } catch (error) {
@@ -1,8 +1,17 @@
1
+ // oxlint-disable no-await-in-loop
1
2
  import { getActiveDistribution } from "../../distribution";
2
3
  import { FRAMEWORK_NAME } from "../../framework";
3
4
  import type { FrameworkConfig } from "../../lib/orchestrator/types";
4
5
  import { collectHostingProviders, ContextServiceMediator } from "./context-services";
5
- import type { CapabilityPlugin, Plugin, PluginRegistration, SetupContext } from "./plugin";
6
+ import type {
7
+ CapabilityContributionInput,
8
+ CapabilityPlugin,
9
+ Plugin,
10
+ PluginRegistration,
11
+ SetupContext,
12
+ } from "./plugin";
13
+ import { reconcileClaudeContributions } from "./providers/claude";
14
+ import { reconcileOpenCodeContributions } from "./providers/opencode";
6
15
  import { normalizeRegistration, type NormalizedRegistration } from "./registry";
7
16
 
8
17
  export interface SetupInstallationPlanAction {
@@ -117,6 +126,8 @@ export async function executeSetupInstallationPlan(
117
126
  });
118
127
 
119
128
  for (const action of plan.actions) {
129
+ if (ctx.scope === "hub" && action.phase !== "provider") continue;
130
+
120
131
  const plugin = pluginById.get(action.pluginId);
121
132
  if (!plugin) {
122
133
  continue;
@@ -151,5 +162,61 @@ export async function executeSetupInstallationPlan(
151
162
 
152
163
  await mediator.finalize();
153
164
 
165
+ await reconcileCapabilityContributions(ctx, plugins, plan);
166
+
154
167
  return { executedActions, skippedActions, warnings };
155
168
  }
169
+
170
+ // The Runtime Surface of each active Agent Runtime, by runtime id.
171
+ const RUNTIME_SURFACE_RECONCILERS: Record<
172
+ string,
173
+ (ctx: SetupContext, inputs: CapabilityContributionInput[]) => Promise<void>
174
+ > = {
175
+ claude: reconcileClaudeContributions,
176
+ opencode: reconcileOpenCodeContributions,
177
+ };
178
+
179
+ /**
180
+ * Reconcile declared Capability contributions through every active runtime's
181
+ * Runtime Surface (spec: plugin-engine). Runs after all plugin phases, so
182
+ * providers are always done first. Disabled capabilities participate with
183
+ * `enabled: false` and drive teardown of their managed entries.
184
+ */
185
+ async function reconcileCapabilityContributions(
186
+ ctx: SetupContext,
187
+ plugins: Plugin[],
188
+ plan: SetupInstallationPlan,
189
+ ): Promise<void> {
190
+ // The plan already resolved enablement (policy, saved selection, package
191
+ // manager requirements); mirror it instead of recomputing.
192
+ const enabledByPluginId = new Map(
193
+ plan.actions
194
+ .filter((action) => action.phase === "capability" && action.providerId === undefined)
195
+ .map((action) => [action.pluginId, action.action === "apply"]),
196
+ );
197
+
198
+ const inputsByRuntime = new Map<string, CapabilityContributionInput[]>();
199
+ for (const plugin of plugins) {
200
+ if (plugin.kind !== "capability") continue;
201
+ const capability = plugin as CapabilityPlugin;
202
+ if (!capability.getRuntimeContributions) continue;
203
+ const enabled = enabledByPluginId.get(capability.id) ?? false;
204
+ const byRuntime = await capability.getRuntimeContributions(ctx);
205
+ for (const [runtimeId, contributions] of Object.entries(byRuntime)) {
206
+ if (!contributions) continue;
207
+ // Inactive runtimes still reconcile — with everything disabled — so a
208
+ // deselected runtime's managed entries (skill trees, guidance blocks)
209
+ // are torn down. The surfaces never create files for disabled inputs.
210
+ const runtimeActive = plan.activeProviders.includes(runtimeId);
211
+ const inputs = inputsByRuntime.get(runtimeId) ?? [];
212
+ inputs.push({ pluginId: capability.id, enabled: enabled && runtimeActive, contributions });
213
+ inputsByRuntime.set(runtimeId, inputs);
214
+ }
215
+ }
216
+
217
+ for (const [runtimeId, inputs] of inputsByRuntime) {
218
+ const reconcile = RUNTIME_SURFACE_RECONCILERS[runtimeId];
219
+ if (!reconcile || inputs.length === 0) continue;
220
+ await reconcile(ctx, inputs);
221
+ }
222
+ }
@@ -4,7 +4,12 @@ import path from "node:path";
4
4
  import { pruneEmptyAncestors } from "./utils";
5
5
 
6
6
  export const MATE_ARTIFACT_SKILLS = ["mate-artifact-finish"] as const;
7
- export const MATE_SKILLS = ["mate-artifact-finish", "mate-create-report"] as const;
7
+ export const MATE_SKILLS = [
8
+ "mate-artifact-finish",
9
+ "mate-create-report",
10
+ "mate-openspec-backfill",
11
+ ] as const;
12
+ const LEGACY_MATE_SKILLS = ["mate-openspec-artifact-finish"] as const;
8
13
 
9
14
  const MATE_SKILLS_SOURCE = path.join(
10
15
  import.meta.dirname,
@@ -98,6 +103,9 @@ async function resolveMateSkillSource(skill: string, tool: string): Promise<stri
98
103
  }
99
104
 
100
105
  export async function applyMateSkills(skillsDir: string, tool: string): Promise<void> {
106
+ for (const skill of LEGACY_MATE_SKILLS) {
107
+ await fs.rm(path.join(skillsDir, skill), { recursive: true, force: true });
108
+ }
101
109
  for (const skill of MATE_SKILLS) {
102
110
  const destination = path.join(skillsDir, skill);
103
111
  if (skill === "mate-create-report") {
@@ -110,7 +118,7 @@ export async function applyMateSkills(skillsDir: string, tool: string): Promise<
110
118
  }
111
119
 
112
120
  export async function teardownMateSkills(skillsDir: string, companionPath: string): Promise<void> {
113
- for (const skill of MATE_SKILLS) {
121
+ for (const skill of [...MATE_SKILLS, ...LEGACY_MATE_SKILLS]) {
114
122
  try {
115
123
  await fs.rm(path.join(skillsDir, skill), { recursive: true, force: true });
116
124
  } catch {
@@ -1,11 +1,16 @@
1
1
  import type { FrameworkConfig, LinkedRepository } from "../../lib/orchestrator/types";
2
2
  import type { InstallRequirement, InstallRequirementContext } from "./install-contract";
3
+ import type { ClaudeHookGroup } from "./providers/claude-format";
4
+
5
+ export type SetupScope = "companion" | "hub";
3
6
 
4
7
  export interface SetupContext {
5
8
  companionPath: string;
6
9
  config: FrameworkConfig;
7
10
  mode: "setup" | "sync";
8
11
  activeProviders: string[];
12
+ /** Hub setup runs providers but excludes companion-only surfaces. */
13
+ scope?: SetupScope;
9
14
  /** Working repository path when syncing from a linked repo (e.g. during launch). */
10
15
  repoPath?: string;
11
16
  /**
@@ -112,9 +117,109 @@ export type PluginPolicy = "required" | "default" | "optional";
112
117
  */
113
118
  export type PluginRegistration = Plugin | { plugin: Plugin; policy: PluginPolicy };
114
119
 
120
+ /**
121
+ * A hook group a Capability contributes to a runtime's settings. `marker` is
122
+ * the command substring that identifies the group as Mate-managed (D4 marker
123
+ * scheme): reconciliation strips every group whose command contains any
124
+ * declared marker before re-adding the groups of enabled Capabilities.
125
+ */
126
+ export interface HookGroupContribution {
127
+ event: string;
128
+ marker: string;
129
+ group: ClaudeHookGroup;
130
+ }
131
+
132
+ /** A skill directory copied into `<runtime dir>/skills/<name>`. */
133
+ export interface SkillTreeContribution {
134
+ name: string;
135
+ sourceDir: string;
136
+ }
137
+
138
+ /**
139
+ * A managed guidance section in the runtime's agent instruction file
140
+ * (CLAUDE.md / AGENTS.md). Reconciled as a framework-managed block; on
141
+ * teardown a section in a file shared with another active runtime is only
142
+ * stripped together with the last runtime using that file.
143
+ */
144
+ export interface GuidanceSectionContribution {
145
+ content: string;
146
+ }
147
+
148
+ /**
149
+ * A plugin reference entry in the runtime's config (OpenCode `plugin` array).
150
+ * `isManagedReference` identifies entries this contribution owns so stale
151
+ * variants (e.g. older pins) are replaced and teardown removes only them.
152
+ */
153
+ export interface PluginReferenceContribution {
154
+ reference: string;
155
+ isManagedReference(entry: unknown): boolean;
156
+ /** Config files to reconcile, relative to the runtime dir. Defaults to both OpenCode configs. */
157
+ configFiles?: string[];
158
+ }
159
+
160
+ /**
161
+ * A real, provider-native agent definition file, written to
162
+ * `<runtime dir>/agents/<name>.md` and selectable via `--agent <name>`.
163
+ * Unlike a guidance section, the whole file is managed content (no merge with
164
+ * unmanaged text) — the Capability pre-renders `content` per runtime, since
165
+ * frontmatter shape (Claude's `name`/`hidden`, OpenCode's `mode`) differs.
166
+ * Reconciled in both companion and hub scope: an agent definition is exactly
167
+ * the kind of artifact a repo-less hub needs.
168
+ */
169
+ export interface AgentDefinitionContribution {
170
+ /** Agent name; the file is written to `<runtime dir>/agents/<name>.md`. */
171
+ name: string;
172
+ /** Full file content, including frontmatter, ready to write as-is. */
173
+ content: string;
174
+ }
175
+
176
+ /**
177
+ * Declarative Agent Runtime contributions of one Capability for one runtime.
178
+ * The runtime's Runtime Surface reconciles these symmetrically: applied while
179
+ * the Capability is enabled, removed when it is not, idempotent across runs.
180
+ */
181
+ export interface RuntimeContributions {
182
+ mcpServers?: McpServerDescriptor[];
183
+ hookGroups?: HookGroupContribution[];
184
+ permissionEntries?: string[];
185
+ guidanceSections?: GuidanceSectionContribution[];
186
+ skillTrees?: SkillTreeContribution[];
187
+ pluginReferences?: PluginReferenceContribution[];
188
+ agentDefinitions?: AgentDefinitionContribution[];
189
+ }
190
+
191
+ /**
192
+ * Contributions keyed by runtime id ("claude", "opencode"). Keyed-by-runtime
193
+ * because the payload shapes are runtime-specific (hook groups are
194
+ * Claude-shaped, plugin references OpenCode-shaped); a runtime-agnostic
195
+ * declaration would only push translation into every capability.
196
+ */
197
+ export type RuntimeContributionsByRuntime = Partial<Record<string, RuntimeContributions>>;
198
+
199
+ /**
200
+ * One Capability's contributions for one runtime, as handed to that runtime's
201
+ * Runtime Surface reconciliation. Disabled Capabilities participate too: their
202
+ * declarations define the managed entries to strip.
203
+ */
204
+ export interface CapabilityContributionInput {
205
+ pluginId: string;
206
+ enabled: boolean;
207
+ contributions: RuntimeContributions;
208
+ }
209
+
115
210
  export interface CapabilityPlugin extends Plugin {
116
211
  kind: "capability";
117
212
  requires?: { packageManagers: string[] };
213
+ /**
214
+ * Declare Agent Runtime contributions as data. Called on every setup/sync
215
+ * pass for all registered Capabilities — enabled ones contribute their
216
+ * entries, disabled ones only widen the managed strip set so their previous
217
+ * entries are removed. May be async (e.g. reading a companion-local
218
+ * override file) — the engine always awaits the result.
219
+ */
220
+ getRuntimeContributions?(
221
+ ctx: SetupContext,
222
+ ): RuntimeContributionsByRuntime | Promise<RuntimeContributionsByRuntime>;
118
223
  forProvider?: Record<
119
224
  string,
120
225
  {
@@ -61,12 +61,15 @@ export async function writeManagedGitignoreBlock(
61
61
 
62
62
  export function collectManagedGitignoreEntries(ctx: SetupContext, plugins: Plugin[]): string[] {
63
63
  // Baseline for every companion: node_modules never versions wherever a tool
64
- // materializes it, and everything directly under the dependencies tree
65
- // (context-mode, future consumers) is regenerated by setup/install —
66
- // version pins live in .mate/config and in code, never in the tree itself.
64
+ // materializes it, and the machine-local distribution-dependencies workspace
65
+ // (.mate/plugins/.local/ — context-mode, future consumers) is regenerated by
66
+ // setup/install in full — version pins live in .mate/config and in code,
67
+ // never in the tree itself. This is distinct from the committed dynamic-
68
+ // plugins workspace at .mate/plugins/, whose package.json and lockfile stay
69
+ // trackable.
67
70
  const entries = [
68
71
  "node_modules/",
69
- `.${FRAMEWORK_NAME}/dependencies/*`,
72
+ `.${FRAMEWORK_NAME}/plugins/.local/`,
70
73
  ".mcp.json*",
71
74
  ...plugins
72
75
  .filter((p) => p.kind !== "root" && (p.isEnabled(ctx.config) || p.persistGitignoreEntries))
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
 
4
4
  import { FRAMEWORK_NAME } from "../../../framework";
5
- import { removeGraphifySection } from "../capabilities/graphify";
5
+ import { removeGraphifySection } from "../capabilities/graphify-shared";
6
6
 
7
7
  // Marker names derive from the framework identity, never the invocation name.
8
8
  const upperName = () => FRAMEWORK_NAME.toUpperCase();
@@ -0,0 +1,78 @@
1
+ // Shared markdown section primitives for agent instruction files (CLAUDE.md,
2
+ // AGENTS.md), plus a thin file-level wrapper for stripping sections in place.
3
+
4
+ import fs from "node:fs/promises";
5
+
6
+ export interface RemoveHeadingSectionOptions {
7
+ /** Matches the heading line that opens the section to remove. */
8
+ isHeading(line: string): boolean;
9
+ /** Exact lines (e.g. HTML comment markers) stripped wherever they appear. */
10
+ markerLines?: string[];
11
+ }
12
+
13
+ /**
14
+ * Remove a heading-delimited section (heading line up to the next `#`/`##`
15
+ * heading or EOF), collapsing leftover blank runs. Marker lines are stripped
16
+ * even when no section heading is present.
17
+ */
18
+ export function removeHeadingSection(
19
+ content: string,
20
+ options: RemoveHeadingSectionOptions,
21
+ ): string {
22
+ const markerLines = new Set(options.markerLines ?? []);
23
+ const normalizedLines = content.split("\n").filter((line) => !markerLines.has(line));
24
+
25
+ const start = normalizedLines.findIndex(options.isHeading);
26
+ if (start === -1) {
27
+ const strippedMarkersOnly = normalizedLines.join("\n");
28
+ return strippedMarkersOnly === content ? content : strippedMarkersOnly;
29
+ }
30
+
31
+ let end = normalizedLines.length;
32
+ for (let i = start + 1; i < normalizedLines.length; i++) {
33
+ if (normalizedLines[i].startsWith("## ") || normalizedLines[i].startsWith("# ")) {
34
+ end = i;
35
+ break;
36
+ }
37
+ }
38
+
39
+ const remaining = [...normalizedLines.slice(0, start), ...normalizedLines.slice(end)].join("\n");
40
+ const collapsed = remaining.replace(/\n{3,}/g, "\n\n").trim();
41
+ return collapsed ? collapsed + "\n" : "";
42
+ }
43
+
44
+ /**
45
+ * Cut everything from `marker` to EOF. Returns the content unchanged when the
46
+ * marker is absent, and "" when nothing but whitespace precedes it (callers
47
+ * typically delete the file then).
48
+ */
49
+ export function cutFromMarker(content: string, marker: string): string {
50
+ const idx = content.indexOf(marker);
51
+ if (idx === -1) return content;
52
+
53
+ const before = content.slice(0, idx).replace(/\s+$/, "");
54
+ return before.length > 0 ? before + "\n" : "";
55
+ }
56
+
57
+ /**
58
+ * Strip a heading-delimited section from a file in place. Absent files are a
59
+ * no-op; a file left without content is deleted.
60
+ */
61
+ export async function stripSectionFromFile(
62
+ filePath: string,
63
+ options: RemoveHeadingSectionOptions,
64
+ ): Promise<void> {
65
+ let content: string;
66
+ try {
67
+ content = await fs.readFile(filePath, "utf8");
68
+ } catch {
69
+ return;
70
+ }
71
+ const stripped = removeHeadingSection(content, options);
72
+ if (stripped === content) return;
73
+ if (!stripped.trim()) {
74
+ await fs.unlink(filePath);
75
+ } else {
76
+ await fs.writeFile(filePath, stripped, "utf8");
77
+ }
78
+ }