@uniqbit/mate-core 0.15.4-canary.9 → 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 (31) 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 +1 -1
  11. package/src/lib/orchestrator/companion-git-sync.ts +67 -8
  12. package/src/lib/orchestrator/companion-hub.ts +28 -6
  13. package/src/lib/orchestrator/types.ts +2 -2
  14. package/src/lib/package-paths.ts +1 -0
  15. package/src/opencode/companion-hooks.ts +33 -12
  16. package/src/playbooks/companion-guidance.ts +1 -1
  17. package/src/templates/capabilities/openspec-cap/mate-skills/agents/mate-openspec-backfill/SKILL.md +65 -0
  18. package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +1 -0
  19. package/src/templates/root/TEMPLATE_AGENTS.md +2 -0
  20. package/src/templates/root/TEMPLATE_CLAUDE.md +2 -0
  21. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +155 -45
  22. package/src/tools/setup/capabilities/context-mode.ts +9 -0
  23. package/src/tools/setup/capabilities/openspec.ts +11 -0
  24. package/src/tools/setup/dynamic-plugins/hydrate.ts +15 -1
  25. package/src/tools/setup/engine.ts +4 -1
  26. package/src/tools/setup/mate.ts +10 -2
  27. package/src/tools/setup/plugin.ts +26 -2
  28. package/src/tools/setup/plugins/gitignore.ts +7 -4
  29. package/src/tools/setup/providers/claude.ts +53 -8
  30. package/src/tools/setup/providers/opencode.ts +51 -8
  31. package/src/tools/setup.ts +7 -56
@@ -38,6 +38,12 @@ function getLegacyOwnershipPath(companionPath: string): string {
38
38
  return path.join(companionPath, ".mate", "state", "context-mode.json");
39
39
  }
40
40
 
41
+ // Legacy install location from before the relocate-distribution-deps-to-plugins-local
42
+ // change; self-heals on every apply the same way the ownership sidecar above does.
43
+ function getLegacyInstallDir(companionPath: string): string {
44
+ return path.join(companionPath, ".mate", "dependencies", CONTEXT_MODE_PACKAGE_NAME);
45
+ }
46
+
41
47
  function hasContextModeMcp(servers: Record<string, unknown>): boolean {
42
48
  return Object.entries(servers).some(
43
49
  ([name, value]) =>
@@ -117,6 +123,9 @@ export function createContextModePlugin(deps: ContextModePluginDeps = {}): Capab
117
123
  };
118
124
  },
119
125
  async apply(ctx) {
126
+ const legacyInstallDir = getLegacyInstallDir(ctx.companionPath);
127
+ await fs.rm(legacyInstallDir, { recursive: true, force: true });
128
+ await pruneEmptyAncestors(path.dirname(legacyInstallDir), ctx.companionPath);
120
129
  if (ctx.mode === "setup") {
121
130
  await installPackage(ctx.companionPath);
122
131
  } else {
@@ -134,6 +134,16 @@ async function reconcileOpenSpecTools(
134
134
  ): Promise<void> {
135
135
  const tools = deriveOpenSpecTools(ctx.activeProviders);
136
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
+ }
137
147
 
138
148
  await runCommand("openspec", ["init", "--tools", tools.join(","), "--force", ctx.companionPath], {
139
149
  cwd: ctx.companionPath,
@@ -324,6 +334,7 @@ export function createOpenspecPlugin(deps: OpenSpecPluginDeps = {}): CapabilityP
324
334
  "Skill(openspec-apply-change)",
325
335
  "Skill(openspec-archive-change)",
326
336
  "Skill(mate-artifact-finish)",
337
+ "Skill(mate-openspec-backfill)",
327
338
  "Bash(openspec:*)",
328
339
  `Bash(${FRAMEWORK_NAME} cap graphify:*)`,
329
340
  `Bash(${path.join(getWrapperBinPath(), "openspec")}:*)`,
@@ -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,3 +1,4 @@
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";
@@ -125,6 +126,8 @@ export async function executeSetupInstallationPlan(
125
126
  });
126
127
 
127
128
  for (const action of plan.actions) {
129
+ if (ctx.scope === "hub" && action.phase !== "provider") continue;
130
+
128
131
  const plugin = pluginById.get(action.pluginId);
129
132
  if (!plugin) {
130
133
  continue;
@@ -198,7 +201,7 @@ async function reconcileCapabilityContributions(
198
201
  const capability = plugin as CapabilityPlugin;
199
202
  if (!capability.getRuntimeContributions) continue;
200
203
  const enabled = enabledByPluginId.get(capability.id) ?? false;
201
- const byRuntime = capability.getRuntimeContributions(ctx);
204
+ const byRuntime = await capability.getRuntimeContributions(ctx);
202
205
  for (const [runtimeId, contributions] of Object.entries(byRuntime)) {
203
206
  if (!contributions) continue;
204
207
  // Inactive runtimes still reconcile — with everything disabled — so a
@@ -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 {
@@ -2,11 +2,15 @@ import type { FrameworkConfig, LinkedRepository } from "../../lib/orchestrator/t
2
2
  import type { InstallRequirement, InstallRequirementContext } from "./install-contract";
3
3
  import type { ClaudeHookGroup } from "./providers/claude-format";
4
4
 
5
+ export type SetupScope = "companion" | "hub";
6
+
5
7
  export interface SetupContext {
6
8
  companionPath: string;
7
9
  config: FrameworkConfig;
8
10
  mode: "setup" | "sync";
9
11
  activeProviders: string[];
12
+ /** Hub setup runs providers but excludes companion-only surfaces. */
13
+ scope?: SetupScope;
10
14
  /** Working repository path when syncing from a linked repo (e.g. during launch). */
11
15
  repoPath?: string;
12
16
  /**
@@ -153,6 +157,22 @@ export interface PluginReferenceContribution {
153
157
  configFiles?: string[];
154
158
  }
155
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
+
156
176
  /**
157
177
  * Declarative Agent Runtime contributions of one Capability for one runtime.
158
178
  * The runtime's Runtime Surface reconciles these symmetrically: applied while
@@ -165,6 +185,7 @@ export interface RuntimeContributions {
165
185
  guidanceSections?: GuidanceSectionContribution[];
166
186
  skillTrees?: SkillTreeContribution[];
167
187
  pluginReferences?: PluginReferenceContribution[];
188
+ agentDefinitions?: AgentDefinitionContribution[];
168
189
  }
169
190
 
170
191
  /**
@@ -193,9 +214,12 @@ export interface CapabilityPlugin extends Plugin {
193
214
  * Declare Agent Runtime contributions as data. Called on every setup/sync
194
215
  * pass for all registered Capabilities — enabled ones contribute their
195
216
  * entries, disabled ones only widen the managed strip set so their previous
196
- * entries are removed.
217
+ * entries are removed. May be async (e.g. reading a companion-local
218
+ * override file) — the engine always awaits the result.
197
219
  */
198
- getRuntimeContributions?(ctx: SetupContext): RuntimeContributionsByRuntime;
220
+ getRuntimeContributions?(
221
+ ctx: SetupContext,
222
+ ): RuntimeContributionsByRuntime | Promise<RuntimeContributionsByRuntime>;
199
223
  forProvider?: Record<
200
224
  string,
201
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))
@@ -346,6 +346,12 @@ export async function reconcileClaudeContributions(
346
346
  ): Promise<void> {
347
347
  const { companionPath, config } = ctx;
348
348
 
349
+ if (ctx.scope === "hub") {
350
+ await reconcileClaudeMcpContributions(ctx, inputs);
351
+ await reconcileClaudeAgentDefinitionContributions(ctx, inputs);
352
+ return;
353
+ }
354
+
349
355
  // The settings sync (re)creates the managed settings document, so it only
350
356
  // runs while the Claude runtime is active; a deactivated runtime's pass is
351
357
  // teardown-only and must not resurrect files the provider teardown removed.
@@ -353,15 +359,10 @@ export async function reconcileClaudeContributions(
353
359
  await syncCompanionClaudeSettings(companionPath, config, inputs);
354
360
  }
355
361
 
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
- }
362
+ await reconcileClaudeMcpContributions(ctx, inputs);
363
+ await reconcileClaudeAgentDefinitionContributions(ctx, inputs);
364
364
 
365
+ for (const input of inputs) {
365
366
  // Guidance sections are managed blocks in CLAUDE.md. Current sections are
366
367
  // upserted, then stale keys (content changes, disabled capability) are
367
368
  // swept. CLAUDE.md is Claude-exclusive, so no shared-file guard applies.
@@ -389,6 +390,46 @@ export async function reconcileClaudeContributions(
389
390
  }
390
391
  }
391
392
 
393
+ /** Reconcile only MCP entries without touching Claude settings or guidance. */
394
+ async function reconcileClaudeMcpContributions(
395
+ ctx: SetupContext,
396
+ inputs: CapabilityContributionInput[],
397
+ ): Promise<void> {
398
+ for (const input of inputs) {
399
+ for (const descriptor of input.contributions.mcpServers ?? []) {
400
+ await updateClaudeMcpServer(
401
+ getCompanionClaudeMcpConfigPath(ctx.companionPath),
402
+ descriptor.name,
403
+ input.enabled ? toClaudeMcpEntry(descriptor) : null,
404
+ );
405
+ }
406
+ }
407
+ }
408
+
409
+ /**
410
+ * Reconcile declared agent definition files under `.claude/agents/`. Runs in
411
+ * both companion and hub scope — an agent definition is fully self-contained
412
+ * (no shared-file merge), so it needs no companion-only surface.
413
+ */
414
+ async function reconcileClaudeAgentDefinitionContributions(
415
+ ctx: SetupContext,
416
+ inputs: CapabilityContributionInput[],
417
+ ): Promise<void> {
418
+ const agentsDir = path.join(ctx.companionPath, ".claude", "agents");
419
+ for (const input of inputs) {
420
+ for (const agent of input.contributions.agentDefinitions ?? []) {
421
+ const agentPath = path.join(agentsDir, `${agent.name}.md`);
422
+ if (input.enabled) {
423
+ await fs.mkdir(agentsDir, { recursive: true });
424
+ await fs.writeFile(agentPath, agent.content, "utf8");
425
+ } else {
426
+ await fs.rm(agentPath, { force: true });
427
+ await pruneEmptyAncestors(agentsDir, ctx.companionPath);
428
+ }
429
+ }
430
+ }
431
+ }
432
+
392
433
  // Maintain the companion `.mcp.json` shell. Managed MCP servers are reconciled
393
434
  // from declared Capability contributions (and legacy `ctx.mcp` hosting); this
394
435
  // only guarantees the file exists with an `mcpServers` map. Loaded at launch
@@ -688,6 +729,10 @@ export function createClaudePlugin(): ProviderPlugin {
688
729
  },
689
730
  },
690
731
  async apply(ctx) {
732
+ if (ctx.scope === "hub") {
733
+ await syncCompanionClaudeSettings(ctx.companionPath, ctx.config);
734
+ return;
735
+ }
691
736
  await configureClaude(ctx.companionPath);
692
737
  await configureClaudeGuidance(ctx.companionPath);
693
738
  await teardownLegacyClaudeBin(ctx.companionPath);
@@ -426,15 +426,16 @@ export async function reconcileOpenCodeContributions(
426
426
  ): Promise<void> {
427
427
  const { companionPath } = ctx;
428
428
 
429
- for (const input of inputs) {
430
- for (const descriptor of input.contributions.mcpServers ?? []) {
431
- await updateOpenCodeMcpServer(
432
- getCompanionOpenCodeConfigPath(companionPath),
433
- descriptor.name,
434
- input.enabled ? toOpenCodeMcpEntry(descriptor) : null,
435
- );
436
- }
429
+ if (ctx.scope === "hub") {
430
+ await reconcileOpenCodeMcpContributions(ctx, inputs);
431
+ await reconcileOpenCodeAgentDefinitionContributions(ctx, inputs);
432
+ return;
433
+ }
434
+
435
+ await reconcileOpenCodeMcpContributions(ctx, inputs);
436
+ await reconcileOpenCodeAgentDefinitionContributions(ctx, inputs);
437
437
 
438
+ for (const input of inputs) {
438
439
  for (const pluginReference of input.contributions.pluginReferences ?? []) {
439
440
  const configFiles = pluginReference.configFiles ?? OPENCODE_CONTRIBUTION_CONFIG_FILES;
440
441
  for (const name of configFiles) {
@@ -479,6 +480,47 @@ export async function reconcileOpenCodeContributions(
479
480
  }
480
481
  }
481
482
 
483
+ /** Reconcile only MCP entries without touching OpenCode plugins or guidance. */
484
+ async function reconcileOpenCodeMcpContributions(
485
+ ctx: SetupContext,
486
+ inputs: CapabilityContributionInput[],
487
+ ): Promise<void> {
488
+ for (const input of inputs) {
489
+ for (const descriptor of input.contributions.mcpServers ?? []) {
490
+ await updateOpenCodeMcpServer(
491
+ getCompanionOpenCodeConfigPath(ctx.companionPath),
492
+ descriptor.name,
493
+ input.enabled ? toOpenCodeMcpEntry(descriptor) : null,
494
+ );
495
+ }
496
+ }
497
+ }
498
+
499
+ /**
500
+ * Reconcile declared agent definition files under `.opencode/agents/`. Runs
501
+ * in both companion and hub scope — an agent definition is fully
502
+ * self-contained (no shared-file merge), so it needs no companion-only
503
+ * surface.
504
+ */
505
+ async function reconcileOpenCodeAgentDefinitionContributions(
506
+ ctx: SetupContext,
507
+ inputs: CapabilityContributionInput[],
508
+ ): Promise<void> {
509
+ const agentsDir = path.join(ctx.companionPath, ".opencode", "agents");
510
+ for (const input of inputs) {
511
+ for (const agent of input.contributions.agentDefinitions ?? []) {
512
+ const agentPath = path.join(agentsDir, `${agent.name}.md`);
513
+ if (input.enabled) {
514
+ await fs.mkdir(agentsDir, { recursive: true });
515
+ await fs.writeFile(agentPath, agent.content, "utf8");
516
+ } else {
517
+ await fs.rm(agentPath, { force: true });
518
+ await pruneEmptyAncestors(agentsDir, ctx.companionPath);
519
+ }
520
+ }
521
+ }
522
+ }
523
+
482
524
  export function createOpenCodePlugin(): ProviderPlugin {
483
525
  return {
484
526
  id: "opencode",
@@ -509,6 +551,7 @@ export function createOpenCodePlugin(): ProviderPlugin {
509
551
  },
510
552
  },
511
553
  async apply(ctx: SetupContext) {
554
+ if (ctx.scope === "hub") return;
512
555
  await syncOpenCodeRuntimeFiles(
513
556
  path.join(getSetupProvidersRoot(), "opencode"),
514
557
  ctx.companionPath,
@@ -1,5 +1,4 @@
1
1
  // oxlint-disable no-await-in-loop
2
- import fs from "node:fs/promises";
3
2
  import path from "node:path";
4
3
 
5
4
  import { FRAMEWORK_NAME } from "../framework";
@@ -25,7 +24,7 @@ import {
25
24
  collectManagedGitignoreEntries,
26
25
  writeManagedGitignoreBlock,
27
26
  } from "./setup/plugins/gitignore";
28
- import type { PluginRegistration, SetupContext } from "./setup/plugin";
27
+ import type { PluginRegistration, SetupContext, SetupScope } from "./setup/plugin";
29
28
  import { getActiveDistribution } from "../distribution";
30
29
  import { createUvPlugin } from "./setup/package-managers/uv";
31
30
  import type { PackageManagerSetupDeps } from "./setup/package-managers/uv";
@@ -60,10 +59,11 @@ export async function applySetupCompatibilities(
60
59
  mode: "setup" | "sync",
61
60
  plugins: PluginRegistration[] = getActiveDistribution().registry.getEntries(),
62
61
  repoPath?: string,
62
+ scope: SetupScope = config.type === "hub" ? "hub" : "companion",
63
63
  ): Promise<SetupInstallationOutcome> {
64
64
  const plan = buildSetupInstallationPlan(config, plugins);
65
65
  const activeProviders = plan.activeProviders;
66
- const ctx: SetupContext = { companionPath, config, mode, activeProviders, repoPath };
66
+ const ctx: SetupContext = { companionPath, config, mode, activeProviders, repoPath, scope };
67
67
  return executeSetupInstallationPlan(ctx, plugins, plan);
68
68
  }
69
69
 
@@ -91,48 +91,6 @@ export async function syncCompanionFiles(
91
91
  );
92
92
  }
93
93
 
94
- export function mateFolderReadme(): string {
95
- const n = FRAMEWORK_NAME;
96
- const packageName =
97
- getActiveDistribution().config.update?.packageName ?? `@uniqbit/${FRAMEWORK_NAME}`;
98
- return [
99
- `# .${FRAMEWORK_NAME}`,
100
- ``,
101
- `This directory is managed by the **${FRAMEWORK_NAME}** companion framework (\`${packageName}\`).`,
102
- ``,
103
- `The ${FRAMEWORK_NAME} framework keeps your AI agent's companion artifacts separate from the code it works on.`,
104
- `Specs, notes, and agent config live here; code stays in the linked working repository.`,
105
- ``,
106
- `## Common commands`,
107
- ``,
108
- `| Command | Description |`,
109
- `|---|---|`,
110
- `| \`${n} companion setup\` | Initialize or re-configure this companion |`,
111
- `| \`${n} companion link\` | Link a working repository to a companion |`,
112
- `| \`${n} companion list\` | List linked repositories for the active working repo context |`,
113
- `| \`${n} companion open\` | Inject the resolved companion into the current editor window |`,
114
- `| \`${n} claude\` / \`${n} opencode\` | Launch an allowed agent from a linked working repository |`,
115
- `| \`${n} doctor\` | Check current link state, installed tools, and active capabilities |`,
116
- ``,
117
- `Run \`${n} claude\` or \`${n} opencode\` from any linked working repository directory.`,
118
- ``,
119
- `## Configuration`,
120
- ``,
121
- `Edit \`.${FRAMEWORK_NAME}/config/framework.yaml\` to configure:`,
122
- ``,
123
- `- **allowedAgents** — agents permitted to launch from linked repositories`,
124
- `- **capabilities** — skill and CLI tool capabilities (e.g. react-doctor, openspec, tokensave, headroom, rtk)`,
125
- `- **git** — set to \`auto\` to synchronize the companion before agent launches`,
126
- ``,
127
- ].join("\n");
128
- }
129
-
130
- async function writeMateReadme(companionPath: string): Promise<void> {
131
- const readmePath = path.join(companionPath, `.${FRAMEWORK_NAME}`, "README.md");
132
- await fs.mkdir(path.dirname(readmePath), { recursive: true });
133
- await fs.writeFile(readmePath, mateFolderReadme(), "utf8");
134
- }
135
-
136
94
  export const setupToolDeps = {
137
95
  executeSetup: (input: SetupInput) => executeSetup(input),
138
96
  };
@@ -160,14 +118,6 @@ export async function executeSetup(
160
118
  deps.configStore ??
161
119
  new ConfigStore(path.join(cwd, `.${FRAMEWORK_NAME}`, "config", "framework.yaml"));
162
120
  const config = mergeWithDefaults(await configStore.load());
163
- // Hubs are never companions: no setup entry path (CLI, wizard, or the
164
- // setup framework tool) may write agent guidance into a hub root.
165
- if (config.type === "hub") {
166
- throw new ConfigError(
167
- `A companion hub cannot be set up as a companion: ${cwd}. Use \`${FRAMEWORK_NAME} hub\` commands to manage the hub.`,
168
- );
169
- }
170
-
171
121
  if (input.allowedAgents !== undefined) {
172
122
  config.allowedAgents = [...new Set(input.allowedAgents)];
173
123
  }
@@ -202,12 +152,13 @@ export async function executeSetup(
202
152
  await hydrateDynamicPlugins({ companionPath });
203
153
  }
204
154
  await applySetupCompatibilities(companionPath, config, "setup");
205
- await invalidateInstallState({ kind: "companion", companionPath });
155
+ await invalidateInstallState({
156
+ kind: config.type === "hub" ? "hub" : "companion",
157
+ companionPath,
158
+ });
206
159
 
207
160
  await globalConfigStore.register(companionPath);
208
161
 
209
- await writeMateReadme(companionPath);
210
-
211
162
  // Setup no longer fans out to linked repos: capabilities that touch a working repo
212
163
  // (companion files on launch, the tokensave/graphify graph via `mate cap index`)
213
164
  // are applied in that repo's own context, not eagerly from here.