@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
@@ -1,14 +1,17 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
 
4
- import { FRAMEWORK_NAME, frameworkConfig } from "../../framework";
4
+ import { FRAMEWORK_NAME } from "../../framework";
5
+ import { parse } from "yaml";
5
6
  import { CompanionResolver } from "./companion-resolver";
6
7
  import { ConfigStore } from "./config-store";
7
8
  import { GlobalConfigStore } from "./global-config-store";
8
- import { migrateConfigDir } from "./migration";
9
9
  import { findRepoLocalLinkedRepository } from "./repo-local-registry";
10
10
  import {
11
11
  AmbiguousCompanionError,
12
+ ConfigError,
13
+ type FrameworkConfig,
14
+ type HubConfig,
12
15
  type LinkedRepository,
13
16
  RepositoryNotFoundError,
14
17
  WorkingRepoRequiredError,
@@ -20,7 +23,8 @@ export interface FrameworkContext {
20
23
  workingRepoStore: WorkingRepoStore;
21
24
  companionPath: string;
22
25
  repository?: LinkedRepository;
23
- contextKind: "env" | "working-repo" | "companion-root";
26
+ contextKind: "env" | "working-repo" | "companion-root" | "hub";
27
+ hub?: HubConfig;
24
28
  }
25
29
 
26
30
  export interface LaunchContext extends FrameworkContext {
@@ -34,7 +38,6 @@ function repositoryFromEnvironment(): LinkedRepository | undefined {
34
38
  return {
35
39
  id: process.env.MATE_REPO_ID ?? path.basename(path.resolve(repositoryPath)),
36
40
  path: path.resolve(repositoryPath),
37
- profile: process.env.MATE_REPO_PROFILE ?? "default",
38
41
  };
39
42
  }
40
43
 
@@ -44,6 +47,7 @@ function makeContext(
44
47
  companionPath: string,
45
48
  contextKind: FrameworkContext["contextKind"],
46
49
  repository?: LinkedRepository,
50
+ hub?: HubConfig,
47
51
  ): FrameworkContext {
48
52
  const configDir = path.join(companionPath, `.${FRAMEWORK_NAME}`, "config");
49
53
  return {
@@ -52,9 +56,90 @@ function makeContext(
52
56
  companionPath,
53
57
  repository,
54
58
  contextKind,
59
+ hub,
55
60
  };
56
61
  }
57
62
 
63
+ function isStrictChildPath(parentPath: string, candidatePath: string): boolean {
64
+ const relative = path.relative(parentPath, candidatePath);
65
+ return relative !== "" && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative);
66
+ }
67
+
68
+ async function validateHubMember(
69
+ resolvedRoot: string,
70
+ realRoot: string,
71
+ member: HubConfig["companions"][number],
72
+ ): Promise<ConfigError | null> {
73
+ const memberPath = path.resolve(resolvedRoot, member.path);
74
+ if (!isStrictChildPath(resolvedRoot, memberPath)) {
75
+ return new ConfigError(
76
+ `Hub member "${member.id}" path resolves outside the hub root: ${member.path}`,
77
+ );
78
+ }
79
+
80
+ const stats = await fs.stat(memberPath).catch(() => null);
81
+ if (!stats?.isDirectory()) {
82
+ return new ConfigError(`Hub member "${member.id}" directory is missing: ${memberPath}`);
83
+ }
84
+
85
+ const realMemberPath = await fs.realpath(memberPath).catch(() => memberPath);
86
+ if (!isStrictChildPath(realRoot, realMemberPath)) {
87
+ return new ConfigError(
88
+ `Hub member "${member.id}" path resolves outside the hub root: ${member.path}`,
89
+ );
90
+ }
91
+
92
+ const childConfigPath = path.join(memberPath, `.${FRAMEWORK_NAME}`, "config", "framework.yaml");
93
+ let childConfig: Partial<FrameworkConfig> | null;
94
+ try {
95
+ childConfig = parse(
96
+ await fs.readFile(childConfigPath, "utf8"),
97
+ ) as Partial<FrameworkConfig> | null;
98
+ } catch {
99
+ return new ConfigError(
100
+ `Hub member "${member.id}" must contain a framework.yaml declaring type "companion".`,
101
+ );
102
+ }
103
+
104
+ if (childConfig?.type !== "companion") {
105
+ return new ConfigError(
106
+ `Hub member "${member.id}" must declare type "companion" in ${childConfigPath}.`,
107
+ );
108
+ }
109
+
110
+ return null;
111
+ }
112
+
113
+ async function validateHubMembers(hubRoot: string, hub: HubConfig): Promise<void> {
114
+ const resolvedRoot = path.resolve(hubRoot);
115
+ const realRoot = await fs.realpath(resolvedRoot).catch(() => resolvedRoot);
116
+ const errors = await Promise.all(
117
+ hub.companions.map((member) => validateHubMember(resolvedRoot, realRoot, member)),
118
+ );
119
+ const firstError = errors.find((error): error is ConfigError => error !== null);
120
+ if (firstError) throw firstError;
121
+ }
122
+
123
+ async function withResolvedHub(context: FrameworkContext): Promise<FrameworkContext> {
124
+ let rawConfig: Partial<FrameworkConfig> | null;
125
+ try {
126
+ rawConfig = parse(
127
+ await fs.readFile(context.configStore.configPath, "utf8"),
128
+ ) as Partial<FrameworkConfig> | null;
129
+ } catch {
130
+ return context;
131
+ }
132
+
133
+ if (rawConfig?.type !== "hub") return context;
134
+
135
+ const config = await context.configStore.load();
136
+ if (!config.hub) {
137
+ throw new ConfigError('A "hub" framework requires a hub.companions array.');
138
+ }
139
+ await validateHubMembers(context.companionPath, config.hub);
140
+ return { ...context, contextKind: "hub", hub: config.hub };
141
+ }
142
+
58
143
  // Resolves the companion framework context without a repositoryId. Used by commands
59
144
  // that only need companion config (e.g. `mate doctor`, `mate config`, `mate report`). Resolution order:
60
145
  // 1. MATE_ARTIFACT_PATH env var (agent-launched sessions)
@@ -67,31 +152,32 @@ export async function resolveFrameworkContext(
67
152
  const envCompanionPath = process.env.MATE_ARTIFACT_PATH;
68
153
  if (envCompanionPath) {
69
154
  const repository = repositoryFromEnvironment() ?? (await findRepoLocalLinkedRepository(cwd));
70
- return makeContext(path.resolve(envCompanionPath), "env", repository ?? undefined);
155
+ return withResolvedHub(
156
+ makeContext(path.resolve(envCompanionPath), "env", repository ?? undefined),
157
+ );
71
158
  }
72
159
 
73
160
  const match = await new CompanionResolver(globalConfigStore).resolve(cwd);
74
161
  if (match) {
75
- return makeContext(
76
- match.companionPath,
77
- "working-repo",
78
- (await findRepoLocalLinkedRepository(cwd)) ?? undefined,
162
+ return withResolvedHub(
163
+ makeContext(
164
+ match.companionPath,
165
+ "working-repo",
166
+ (await findRepoLocalLinkedRepository(cwd)) ?? undefined,
167
+ ),
79
168
  );
80
169
  }
81
170
 
82
171
  const localDir = path.join(cwd, `.${FRAMEWORK_NAME}`);
83
- const localLegacyDirs = frameworkConfig.legacyNames.map((n) => path.join(cwd, `.${n}`));
84
- await migrateConfigDir(localDir, localLegacyDirs);
85
172
 
86
173
  const localConfigPath = path.join(localDir, "config", "framework.yaml");
87
174
  try {
88
175
  await fs.access(localConfigPath);
89
- return makeContext(cwd, "companion-root");
90
176
  } catch {
91
177
  // no local config
178
+ throw new RepositoryNotFoundError(`No companion found for current directory: ${cwd}`);
92
179
  }
93
-
94
- throw new RepositoryNotFoundError(`No companion found for current directory: ${cwd}`);
180
+ return withResolvedHub(makeContext(cwd, "companion-root"));
95
181
  }
96
182
 
97
183
  // Resolves context for agent-launch commands. Returns a LaunchContext with repositoryId.
@@ -108,8 +194,11 @@ export async function resolveForLaunch(
108
194
  if (envCompanionPath) {
109
195
  const repository = repositoryFromEnvironment() ?? (await findRepoLocalLinkedRepository(cwd));
110
196
  const repositoryId = process.env.MATE_REPO_ID ?? repository?.id ?? "";
197
+ const context = await withResolvedHub(
198
+ makeContext(path.resolve(envCompanionPath), "env", repository ?? undefined),
199
+ );
111
200
  return {
112
- ...makeContext(path.resolve(envCompanionPath), "env", repository ?? undefined),
201
+ ...context,
113
202
  repositoryId,
114
203
  };
115
204
  }
@@ -123,8 +212,11 @@ export async function resolveForLaunch(
123
212
 
124
213
  if (resolution.match) {
125
214
  const repository = (await findRepoLocalLinkedRepository(cwd)) ?? undefined;
215
+ const context = await withResolvedHub(
216
+ makeContext(resolution.match.companionPath, "working-repo", repository),
217
+ );
126
218
  return {
127
- ...makeContext(resolution.match.companionPath, "working-repo", repository),
219
+ ...context,
128
220
  repositoryId: resolution.match.repositoryId,
129
221
  };
130
222
  }
@@ -143,7 +235,9 @@ export async function resolveForCapability(
143
235
  const envCompanionPath = process.env.MATE_ARTIFACT_PATH;
144
236
  if (envCompanionPath) {
145
237
  const repository = repositoryFromEnvironment() ?? (await findRepoLocalLinkedRepository(cwd));
146
- const ctx = makeContext(path.resolve(envCompanionPath), "env", repository ?? undefined);
238
+ const ctx = await withResolvedHub(
239
+ makeContext(path.resolve(envCompanionPath), "env", repository ?? undefined),
240
+ );
147
241
  const repositoryId = process.env.MATE_REPO_ID;
148
242
  if (repositoryId) {
149
243
  return { ...ctx, repositoryId };
@@ -158,12 +252,15 @@ export async function resolveForCapability(
158
252
 
159
253
  const match = await new CompanionResolver(globalConfigStore).resolve(cwd);
160
254
  if (match) {
161
- return {
162
- ...makeContext(
255
+ const context = await withResolvedHub(
256
+ makeContext(
163
257
  match.companionPath,
164
258
  "working-repo",
165
259
  (await findRepoLocalLinkedRepository(cwd)) ?? undefined,
166
260
  ),
261
+ );
262
+ return {
263
+ ...context,
167
264
  repositoryId: match.repositoryId,
168
265
  };
169
266
  }
@@ -171,16 +268,14 @@ export async function resolveForCapability(
171
268
  // Fallback: cwd is the companion directory — resolve from its local config.
172
269
  // This is specific to mate cap; resolveForLaunch does NOT get this fallback.
173
270
  const localDir = path.join(cwd, `.${FRAMEWORK_NAME}`);
174
- const localLegacyDirs = frameworkConfig.legacyNames.map((n) => path.join(cwd, `.${n}`));
175
- await migrateConfigDir(localDir, localLegacyDirs);
176
271
 
177
272
  const localConfigPath = path.join(localDir, "config", "framework.yaml");
178
273
  try {
179
274
  await fs.access(localConfigPath);
180
- return { ...makeContext(cwd, "companion-root"), repositoryId: process.env.MATE_REPO_ID ?? "" };
181
275
  } catch {
182
276
  // no local config
277
+ throw new WorkingRepoRequiredError("cap");
183
278
  }
184
-
185
- throw new WorkingRepoRequiredError("cap");
279
+ const context = await withResolvedHub(makeContext(cwd, "companion-root"));
280
+ return { ...context, repositoryId: process.env.MATE_REPO_ID ?? "" };
186
281
  }
@@ -1,9 +1,8 @@
1
1
  import os from "node:os";
2
2
  import path from "node:path";
3
3
 
4
- import { FRAMEWORK_NAME, frameworkConfig } from "../../framework";
4
+ import { FRAMEWORK_NAME } from "../../framework";
5
5
  import { YamlFileStore } from "./yaml-file-store";
6
- import { migrateConfigDir } from "./migration";
7
6
 
8
7
  interface CompanionEntry {
9
8
  path: string;
@@ -35,9 +34,6 @@ export class GlobalConfigStore extends YamlFileStore<GlobalConfig> {
35
34
  }
36
35
 
37
36
  override async load(): Promise<GlobalConfig> {
38
- const currentDir = path.dirname(this.configPath);
39
- const legacyDirs = frameworkConfig.legacyNames.map((n) => path.join(os.homedir(), `.${n}`));
40
- await migrateConfigDir(currentDir, legacyDirs);
41
37
  return normalizeGlobalConfig(await super.load());
42
38
  }
43
39
 
@@ -5,7 +5,7 @@ import type { CapabilityPlugin, LaunchPreflightContext } from "../../tools/setup
5
5
  import type { LaunchAdapter, AdapterContext } from "./adapters/base";
6
6
  import { ClaudeAdapter } from "./adapters/claude";
7
7
  import { OpenCodeAdapter } from "./adapters/opencode";
8
- import { CompanionStore, resolvePolicyFromConfig } from "./companion-store";
8
+ import { CompanionStore } from "./companion-store";
9
9
  import { syncCompanionGit } from "./companion-git-sync";
10
10
  import { resolveForLaunch, type LaunchContext } from "./framework-context";
11
11
  import {
@@ -16,7 +16,6 @@ import {
16
16
  type LaunchRequest,
17
17
  type LaunchResult,
18
18
  type LinkedRepository,
19
- type PolicySettings,
20
19
  } from "./types";
21
20
 
22
21
  export interface LaunchPreview {
@@ -34,7 +33,6 @@ interface ResolvedLaunchState {
34
33
  adapter: LaunchAdapter;
35
34
  companionPath: string;
36
35
  config: FrameworkConfig;
37
- policy: PolicySettings;
38
36
  repository: LinkedRepository;
39
37
  }
40
38
 
@@ -100,10 +98,7 @@ export class FrameworkLauncher {
100
98
  }
101
99
 
102
100
  const config = await configStore.load();
103
- const policy = localRepository
104
- ? resolvePolicyFromConfig(config, [repository], repository.id)
105
- : await store.resolvePolicy(repository.id);
106
- if (!policy.allowedAgents.includes(request.tool)) {
101
+ if (!config.allowedAgents.includes(request.tool)) {
107
102
  throw new ToolNotAllowedError(`Tool is disallowed by policy: ${request.tool}`);
108
103
  }
109
104
 
@@ -116,7 +111,6 @@ export class FrameworkLauncher {
116
111
  adapter,
117
112
  companionPath,
118
113
  config,
119
- policy,
120
114
  repository,
121
115
  };
122
116
  }
@@ -124,7 +118,7 @@ export class FrameworkLauncher {
124
118
  private makeAdapterContext(state: ResolvedLaunchState): AdapterContext {
125
119
  return {
126
120
  repository: state.repository,
127
- policy: state.policy,
121
+ allowedAgents: state.config.allowedAgents,
128
122
  companionPath: state.companionPath,
129
123
  capabilities: state.config.capabilities ?? [],
130
124
  git: state.config.git,
@@ -1,6 +1,4 @@
1
- // oxlint-disable no-await-in-loop
2
1
  import fs from "node:fs/promises";
3
- import path from "node:path";
4
2
 
5
3
  import { parse, stringify } from "yaml";
6
4
 
@@ -18,24 +16,3 @@ export async function migrateRegistryData(filePath: string): Promise<void> {
18
16
  await fs.writeFile(filePath, stringify(rest), "utf8");
19
17
  }
20
18
  }
21
-
22
- export async function migrateConfigDir(currentDir: string, legacyDirs: string[]): Promise<void> {
23
- try {
24
- await fs.access(currentDir);
25
- return;
26
- } catch {
27
- // current dir missing — check legacy locations
28
- }
29
-
30
- for (const legacyDir of legacyDirs) {
31
- try {
32
- await fs.access(legacyDir);
33
- await fs.mkdir(path.dirname(currentDir), { recursive: true });
34
- await fs.rename(legacyDir, currentDir);
35
- process.stderr.write(`[migration] ${legacyDir} → ${currentDir}\n`);
36
- return;
37
- } catch {
38
- // this legacy dir also absent, try next
39
- }
40
- }
41
- }
@@ -25,9 +25,8 @@ export function buildOpenCodeGuidance(capabilities: CapabilityConfig[]): MateGui
25
25
  repository: {
26
26
  id: "$MATE_REPO_ID",
27
27
  path: "$MATE_REPO_PATH",
28
- profile: "$MATE_REPO_PROFILE",
29
28
  },
30
- policy: { allowedAgents: [] },
29
+ allowedAgents: [],
31
30
  capabilities,
32
31
  },
33
32
  { wrapperBinPath: "$MATE_WRAPPER_BIN_PATH" },
@@ -105,9 +105,11 @@ async function saveRepoLocalRegistry(repoPath: string, registry: RepoLocalRegist
105
105
  await store.save(registry);
106
106
  }
107
107
 
108
+ // Field-explicit on purpose: also strips legacy per-repo policy fields
109
+ // (profile/overrides) from entries read from or written to disk.
108
110
  function normalizeLinkedRepository(repository: LinkedRepository): LinkedRepository {
109
111
  return {
110
- ...repository,
112
+ id: repository.id,
111
113
  path: path.resolve(repository.path),
112
114
  };
113
115
  }
@@ -0,0 +1,117 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import { FRAMEWORK_NAME } from "../../framework";
5
+ import { CompanionResolver, type CompanionResolutionResult } from "./companion-resolver";
6
+ import { ConfigStore, mergeWithDefaults } from "./config-store";
7
+ import { GlobalConfigStore } from "./global-config-store";
8
+ import { findRepoLocalLinkedRepository } from "./repo-local-registry";
9
+ import type { FrameworkConfig, LinkedRepository } from "./types";
10
+
11
+ /** Root classification shared by install and doctor. `core` = no configured root. */
12
+ export type RootKind = "core" | "working" | "companion" | "hub";
13
+
14
+ /** How the configured root was found. */
15
+ export type RootOrigin = "env" | "registry" | "local" | "none";
16
+
17
+ export interface RootContext {
18
+ /** Kind of the resolved configured root (from `config.type`), or `core`. */
19
+ kind: RootKind;
20
+ origin: RootOrigin;
21
+ rootPath?: string;
22
+ /** Merged config of the resolved root; absent for `core`. */
23
+ config?: FrameworkConfig;
24
+ repositoryId?: string;
25
+ /** Repo-local `registry.yaml` repository entry for cwd, when present. */
26
+ linkedRepository: LinkedRepository | null;
27
+ resolution: CompanionResolutionResult;
28
+ }
29
+
30
+ export interface RootContextDeps {
31
+ globalConfigStore?: GlobalConfigStore;
32
+ }
33
+
34
+ const emptyResolution = (): CompanionResolutionResult => ({
35
+ match: null,
36
+ ambiguousMatches: [],
37
+ failures: [],
38
+ });
39
+
40
+ function kindForConfig(config: FrameworkConfig): Exclude<RootKind, "core"> {
41
+ return config.type === "hub" ? "hub" : config.type === "working" ? "working" : "companion";
42
+ }
43
+
44
+ async function loadRootConfig(rootPath: string): Promise<FrameworkConfig> {
45
+ const configPath = path.join(rootPath, `.${FRAMEWORK_NAME}`, "config", "framework.yaml");
46
+ return mergeWithDefaults(await new ConfigStore(configPath).load());
47
+ }
48
+
49
+ export async function resolveRootContext(
50
+ cwd = process.cwd(),
51
+ deps: RootContextDeps = {},
52
+ ): Promise<RootContext> {
53
+ const resolvedCwd = path.resolve(cwd);
54
+ const linkedRepository = await findRepoLocalLinkedRepository(resolvedCwd);
55
+
56
+ const envRootPath = process.env.MATE_ARTIFACT_PATH;
57
+ if (envRootPath) {
58
+ const rootPath = path.resolve(envRootPath);
59
+ const config = await loadRootConfig(rootPath);
60
+ return {
61
+ kind: kindForConfig(config),
62
+ origin: "env",
63
+ rootPath,
64
+ config,
65
+ repositoryId: process.env.MATE_REPO_ID ?? linkedRepository?.id,
66
+ linkedRepository,
67
+ resolution: emptyResolution(),
68
+ };
69
+ }
70
+
71
+ const resolver = new CompanionResolver(deps.globalConfigStore ?? new GlobalConfigStore());
72
+ const resolution = await resolver.resolveWithDiagnostics(resolvedCwd);
73
+ if (resolution.match) {
74
+ const rootPath = resolution.match.companionPath;
75
+ const config = await loadRootConfig(rootPath);
76
+ return {
77
+ kind: kindForConfig(config),
78
+ origin: "registry",
79
+ rootPath,
80
+ config,
81
+ repositoryId: linkedRepository?.id ?? resolution.match.repositoryId,
82
+ linkedRepository,
83
+ resolution,
84
+ };
85
+ }
86
+
87
+ const localRoot = await findConfiguredRoot(resolvedCwd);
88
+ if (localRoot) {
89
+ const config = await loadRootConfig(localRoot);
90
+ return {
91
+ kind: kindForConfig(config),
92
+ origin: "local",
93
+ rootPath: localRoot,
94
+ config,
95
+ linkedRepository,
96
+ resolution,
97
+ };
98
+ }
99
+
100
+ return { kind: "core", origin: "none", linkedRepository, resolution };
101
+ }
102
+
103
+ /** Nearest ancestor of `cwd` (inclusive) holding `.<framework>/config/framework.yaml`. */
104
+ export async function findConfiguredRoot(cwd: string): Promise<string | null> {
105
+ let dir = path.resolve(cwd);
106
+ for (;;) {
107
+ try {
108
+ await fs.access(path.join(dir, `.${FRAMEWORK_NAME}`, "config", "framework.yaml"));
109
+ return dir;
110
+ } catch {
111
+ // keep walking up
112
+ }
113
+ const parent = path.dirname(dir);
114
+ if (parent === dir) return null;
115
+ dir = parent;
116
+ }
117
+ }
@@ -287,7 +287,7 @@ export function getDefaultSetupSelections(): SetupSelections {
287
287
 
288
288
  export function getSetupSelectionsFromConfig(config: FrameworkConfig): SetupSelections {
289
289
  return {
290
- allowedAgents: [...new Set(config.profiles.default?.allowedAgents ?? [])],
290
+ allowedAgents: [...new Set(config.allowedAgents ?? [])],
291
291
  packageManagers: [
292
292
  ...new Set([...getLockedPackageManagers(), ...(config.packageManagers ?? [])]),
293
293
  ],
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
 
4
4
  import { parse } from "yaml";
5
5
 
6
- import { FRAMEWORK_NAME, frameworkCommandName, frameworkConfig } from "../../framework";
6
+ import { FRAMEWORK_NAME } from "../../framework";
7
7
  import { fileExists } from "../fs-utils";
8
8
  import { GlobalConfigStore } from "./global-config-store";
9
9
  import { CompanionResolver, type CompanionMatch } from "./companion-resolver";
@@ -126,20 +126,7 @@ export async function looksLikeWorkingRepo(cwd: string): Promise<boolean> {
126
126
 
127
127
  export async function hasLocalCompanionConfig(cwd: string): Promise<boolean> {
128
128
  const resolvedCwd = path.resolve(cwd);
129
- const candidatePaths = [
130
- path.join(resolvedCwd, `.${FRAMEWORK_NAME}`, "config", "framework.yaml"),
131
- ...frameworkConfig.legacyNames.map((name) =>
132
- path.join(resolvedCwd, `.${name}`, "config", "framework.yaml"),
133
- ),
134
- ];
135
-
136
- for (const candidatePath of candidatePaths) {
137
- if (await fileExists(candidatePath)) {
138
- return true;
139
- }
140
- }
141
-
142
- return false;
129
+ return fileExists(path.join(resolvedCwd, `.${FRAMEWORK_NAME}`, "config", "framework.yaml"));
143
130
  }
144
131
 
145
132
  async function hasWorkingRepoConfig(cwd: string): Promise<boolean> {
@@ -167,7 +154,7 @@ export async function inspectSetupPreflight(
167
154
  // linked working repo, even if it sits inside another repo's working tree.
168
155
  // A stale registration of a working repo is the exception: its local type
169
156
  // marker is authoritative and must not make setup load working-repo config
170
- // as a companion profile config.
157
+ // as a companion framework config.
171
158
  if (isRegisteredCompanion && !(await hasWorkingRepoConfig(resolvedCwd))) {
172
159
  return { kind: "existing-companion" };
173
160
  }
@@ -190,7 +177,7 @@ export async function inspectSetupPreflight(
190
177
 
191
178
  export function formatSetupGuardrailError(cwd: string, match: CompanionMatch): string {
192
179
  return [
193
- `\`${frameworkCommandName()} setup\` cannot run here: ${path.resolve(cwd)} is inside linked working repo \`${match.repositoryId}\`.`,
194
- `Run \`${frameworkCommandName()} setup\` from the companion repo instead: ${match.companionPath}`,
180
+ `\`${FRAMEWORK_NAME} setup\` cannot run here: ${path.resolve(cwd)} is inside linked working repo \`${match.repositoryId}\`.`,
181
+ `Run \`${FRAMEWORK_NAME} setup\` from the companion repo instead: ${match.companionPath}`,
195
182
  ].join("\n");
196
183
  }
@@ -42,23 +42,9 @@ export class WorkingRepoRequiredError extends MateError {
42
42
 
43
43
  export type CompanionSource = "git" | "existing" | "local";
44
44
 
45
- export interface PolicySettings {
46
- allowedAgents: string[];
47
- }
48
-
49
- export interface PolicyOverride {
50
- allowedAgents?: string[];
51
- }
52
-
53
- export interface PolicyProfile extends PolicySettings {
54
- name: string;
55
- }
56
-
57
45
  export interface LinkedRepository {
58
46
  id: string;
59
47
  path: string;
60
- profile: string;
61
- overrides?: PolicyOverride;
62
48
  }
63
49
 
64
50
  export interface CliToolConfig {
@@ -74,6 +60,26 @@ export interface CapabilityConfig {
74
60
  schemaProfile?: OpenSpecSchemaProfile;
75
61
  }
76
62
 
63
+ export type FrameworkType = "working" | "companion" | "hub";
64
+
65
+ export interface HubMemberSource {
66
+ kind: "git" | "local";
67
+ url?: string;
68
+ ref?: string;
69
+ path?: string;
70
+ }
71
+
72
+ export interface HubMember {
73
+ id: string;
74
+ path: string;
75
+ source: HubMemberSource;
76
+ materializedCommit?: string;
77
+ }
78
+
79
+ export interface HubConfig {
80
+ companions: HubMember[];
81
+ }
82
+
77
83
  /**
78
84
  * Distribution-name-keyed semver ranges (e.g. `{ mate: ">=0.15.0" }` or
79
85
  * `{ "acme-mate": ">=1.2.0" }`). A running CLI only checks the key matching
@@ -88,9 +94,10 @@ export type EngineConstraints = Record<string, string>;
88
94
  export type PluginDeclarationPolicy = "default" | "optional";
89
95
 
90
96
  /**
91
- * A companion-declared npm plugin: installed by setup/install into
92
- * `.mate/dependencies/plugins/` and loaded on every invocation. Declaration
93
- * registers the plugin; enablement stays in the `capabilities` list.
97
+ * A companion-declared npm plugin: installed by setup/install into the
98
+ * companion's own shared plugin workspace (`.mate/plugins/`) and loaded
99
+ * from there on every invocation. Declaration registers the plugin;
100
+ * enablement stays in the `capabilities` list.
94
101
  */
95
102
  export interface PluginDeclaration {
96
103
  /** npm package name (e.g. `@acme/custom-plugin`). */
@@ -104,9 +111,10 @@ export interface PluginDeclaration {
104
111
  }
105
112
 
106
113
  export interface FrameworkConfig {
107
- type?: "working" | "companion";
114
+ type?: FrameworkType;
108
115
  git?: GitModeProfile;
109
- profiles: Record<string, PolicyProfile>;
116
+ hub?: HubConfig;
117
+ allowedAgents: string[];
110
118
  capabilities?: CapabilityConfig[];
111
119
  plugins?: PluginDeclaration[];
112
120
  migrations?: string[];
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
 
3
- import { FRAMEWORK_NAME, frameworkCommandName } from "../../framework";
3
+ import { FRAMEWORK_NAME } from "../../framework";
4
4
  import { migrateRegistryData } from "./migration";
5
5
  import { YamlFileStore } from "./yaml-file-store";
6
6
  import { ConfigError, type WorkingRepoConfig } from "./types";
@@ -16,12 +16,18 @@ export class WorkingRepoStore extends YamlFileStore<WorkingRepoConfig> {
16
16
 
17
17
  override async load(): Promise<WorkingRepoConfig> {
18
18
  await migrateRegistryData(this.configPath);
19
- return super.load();
19
+ const config = await super.load();
20
+ // Silently drop legacy per-repo policy fields (profile/overrides); the
21
+ // clean shape persists on the next save.
22
+ return {
23
+ ...config,
24
+ repos: (config.repos ?? []).map(({ id, path: repoPath }) => ({ id, path: repoPath })),
25
+ };
20
26
  }
21
27
 
22
28
  protected async onMissing(): Promise<WorkingRepoConfig> {
23
29
  throw new ConfigError(
24
- `Working repo config not found. Please run \`${frameworkCommandName()} companion setup\` to initialize the framework.`,
30
+ `Working repo config not found. Please run \`${FRAMEWORK_NAME} companion setup\` to initialize the framework.`,
25
31
  );
26
32
  }
27
33