@uniqbit/mate-core 0.15.4-canary.5 → 0.15.4-canary.7

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 (43) hide show
  1. package/claude-plugin/.claude-plugin/plugin.json +4 -0
  2. package/claude-plugin/hooks/artifact-finish-nudge.mjs +6 -0
  3. package/claude-plugin/hooks/hooks.json +37 -0
  4. package/claude-plugin/hooks/session-banner.mjs +6 -0
  5. package/claude-plugin/hooks/validate-artifact-path.mjs +6 -0
  6. package/package.json +2 -1
  7. package/src/cli/commands/artifact/finish/openspec.ts +1 -5
  8. package/src/cli/commands/companion/link.ts +0 -5
  9. package/src/cli/commands/companion/tui.ts +0 -1
  10. package/src/cli/commands/doctor.ts +253 -166
  11. package/src/cli/commands/setup.ts +2 -4
  12. package/src/cli/companion-link-wizard.tsx +22 -78
  13. package/src/cli/main.ts +18 -5
  14. package/src/cli/repo-list-table.tsx +2 -7
  15. package/src/hooks/artifact-finish-nudge.ts +212 -0
  16. package/src/hooks/session-banner.ts +25 -0
  17. package/src/hooks/validate-artifact-path.ts +236 -0
  18. package/src/lib/install.ts +30 -38
  19. package/src/lib/orchestrator/adapters/base.ts +4 -4
  20. package/src/lib/orchestrator/adapters/claude.ts +16 -0
  21. package/src/lib/orchestrator/companion-hub.ts +1 -1
  22. package/src/lib/orchestrator/companion-store.ts +1 -35
  23. package/src/lib/orchestrator/config-store.ts +22 -7
  24. package/src/lib/orchestrator/framework-context.ts +0 -1
  25. package/src/lib/orchestrator/launcher.ts +3 -9
  26. package/src/lib/orchestrator/opencode-guidance.ts +1 -2
  27. package/src/lib/orchestrator/repo-local-registry.ts +3 -1
  28. package/src/lib/orchestrator/root-context.ts +117 -0
  29. package/src/lib/orchestrator/setup-compatibilities.ts +1 -1
  30. package/src/lib/orchestrator/setup-preflight.ts +1 -1
  31. package/src/lib/orchestrator/types.ts +1 -15
  32. package/src/lib/orchestrator/working-repo-store.ts +7 -1
  33. package/src/lib/package-paths.ts +36 -0
  34. package/src/playbooks/companion-guidance.ts +1 -1
  35. package/src/runtime/env.ts +0 -3
  36. package/src/tools/setup/capabilities/openspec.ts +3 -28
  37. package/src/tools/setup/policy.ts +4 -7
  38. package/src/tools/setup/providers/claude.ts +44 -74
  39. package/src/tools/setup/providers/opencode.ts +1 -1
  40. package/src/tools/setup.ts +10 -4
  41. package/src/templates/capabilities/openspec-cap/claude/hooks/mate-artifact-finish.sh +0 -237
  42. package/src/templates/providers/claude/.claude/hooks/mate-session-banner +0 -28
  43. package/src/templates/providers/claude/.claude/hooks/validate-artifact-path +0 -242
@@ -0,0 +1,4 @@
1
+ {
2
+ "name": "mate",
3
+ "description": "Mate-managed Claude Code hooks: artifact-path guardrail, session banner, and the OpenSpec archive-finish nudge. Ships inside @uniqbit/mate-core and is loaded per managed launch via --plugin-dir; it is never published separately."
4
+ }
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ // Thin shim: hook logic lives in src/hooks/artifact-finish-nudge.ts
3
+ // (loaded via node's native TypeScript type stripping, engines node >= 24).
4
+ import { run } from "../../src/hooks/artifact-finish-nudge.ts";
5
+
6
+ process.exitCode = await run();
@@ -0,0 +1,37 @@
1
+ {
2
+ "description": "Mate hooks — PreToolUse artifact-path guardrail, SessionStart banner, PostToolUse OpenSpec archive-finish nudge (self-gated at runtime)",
3
+ "hooks": {
4
+ "PreToolUse": [
5
+ {
6
+ "matcher": "Write|Edit|MultiEdit|Bash",
7
+ "hooks": [
8
+ {
9
+ "type": "command",
10
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/validate-artifact-path.mjs\""
11
+ }
12
+ ]
13
+ }
14
+ ],
15
+ "SessionStart": [
16
+ {
17
+ "hooks": [
18
+ {
19
+ "type": "command",
20
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session-banner.mjs\""
21
+ }
22
+ ]
23
+ }
24
+ ],
25
+ "PostToolUse": [
26
+ {
27
+ "matcher": "Bash",
28
+ "hooks": [
29
+ {
30
+ "type": "command",
31
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/artifact-finish-nudge.mjs\""
32
+ }
33
+ ]
34
+ }
35
+ ]
36
+ }
37
+ }
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ // Thin shim: hook logic lives in src/hooks/session-banner.ts
3
+ // (loaded via node's native TypeScript type stripping, engines node >= 24).
4
+ import { run } from "../../src/hooks/session-banner.ts";
5
+
6
+ process.exitCode = run();
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ // Thin shim: hook logic lives in src/hooks/validate-artifact-path.ts
3
+ // (loaded via node's native TypeScript type stripping, engines node >= 24).
4
+ import { run } from "../../src/hooks/validate-artifact-path.ts";
5
+
6
+ process.exitCode = await run();
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@uniqbit/mate-core",
3
- "version": "0.15.4-canary.5",
3
+ "version": "0.15.4-canary.7",
4
4
  "description": "Core framework and plugin APIs for Mate.",
5
5
  "license": "MIT",
6
6
  "files": [
7
7
  "src/",
8
8
  "wrappers/",
9
+ "claude-plugin/",
9
10
  "!src/**/*.test.ts",
10
11
  "!src/**/*.test.tsx"
11
12
  ],
@@ -61,10 +61,7 @@ async function capSync(context: FinishContext): Promise<boolean> {
61
61
  const previous = process.exitCode;
62
62
  process.exitCode = 0;
63
63
  const previousEnv = new Map(
64
- ["MATE_ARTIFACT_PATH", "MATE_REPO_PATH", "MATE_REPO_ID", "MATE_REPO_PROFILE"].map((key) => [
65
- key,
66
- process.env[key],
67
- ]),
64
+ ["MATE_ARTIFACT_PATH", "MATE_REPO_PATH", "MATE_REPO_ID"].map((key) => [key, process.env[key]]),
68
65
  );
69
66
 
70
67
  // Pass both sides of the companion/working-repo boundary explicitly. Capability
@@ -73,7 +70,6 @@ async function capSync(context: FinishContext): Promise<boolean> {
73
70
  if (context.repository) {
74
71
  process.env.MATE_REPO_PATH = context.repository.path;
75
72
  process.env.MATE_REPO_ID = context.repository.id;
76
- process.env.MATE_REPO_PROFILE = context.repository.profile;
77
73
  }
78
74
  try {
79
75
  await runIndexCapCommand([]);
@@ -10,7 +10,6 @@ import {
10
10
  type CompanionLinkWizardOptions,
11
11
  } from "../../companion-link-wizard";
12
12
  import { extractRepoName } from "../artifact/extract-repo-name";
13
- import { defaultConfig } from "../../../lib/orchestrator/config-store";
14
13
  import { GlobalConfigStore } from "../../../lib/orchestrator/global-config-store";
15
14
  import { CompanionResolver } from "../../../lib/orchestrator/companion-resolver";
16
15
  import { inspectSetupPreflight } from "../../../lib/orchestrator/setup-preflight";
@@ -131,7 +130,6 @@ export async function runCompanionLinkCommandWithDeps(
131
130
 
132
131
  const cwd = path.resolve(process.cwd());
133
132
  const existingMatch = await resolveCompanion(cwd);
134
- const profiles = Object.keys(defaultConfig().profiles);
135
133
  const companionsDir = companionsHomeDir();
136
134
  const candidatePaths = new Set(await listCompanions());
137
135
  if (existingMatch) candidatePaths.add(existingMatch.companionPath);
@@ -148,7 +146,6 @@ export async function runCompanionLinkCommandWithDeps(
148
146
  ).filter((candidate): candidate is string => candidate !== null);
149
147
 
150
148
  const inputs = await selectInputs({
151
- profiles,
152
149
  existingCompanions,
153
150
  });
154
151
  if (!inputs) {
@@ -241,7 +238,6 @@ export async function runCompanionLinkCommandWithDeps(
241
238
  const repository: LinkedRepository = {
242
239
  id: path.basename(cwd),
243
240
  path: cwd,
244
- profile: inputs.profile,
245
241
  };
246
242
 
247
243
  const registerRepository =
@@ -292,6 +288,5 @@ export async function runCompanionLinkCommandWithDeps(
292
288
  console.log(` Companion: ${companionPath}`);
293
289
  }
294
290
  console.log(` Source: ${persistedSource}`);
295
- console.log(` Profile: ${repository.profile}`);
296
291
  console.log(" Workspace: run `mate companion open` to open the workspace in your editor");
297
292
  }
@@ -81,7 +81,6 @@ export async function runCompanionTuiCommand(): Promise<void> {
81
81
  if (repository) {
82
82
  process.env.MATE_REPO_PATH = repository.path;
83
83
  process.env.MATE_REPO_ID = repository.id;
84
- process.env.MATE_REPO_PROFILE = repository.profile;
85
84
  }
86
85
 
87
86
  console.log(renderBanner(repoPath, companionPath));
@@ -1,16 +1,13 @@
1
- import fs from "node:fs/promises";
1
+ import { execFileSync } from "node:child_process";
2
2
  import path from "node:path";
3
3
 
4
4
  import { FRAMEWORK_NAME } from "../../framework";
5
5
  import { getActiveDistribution } from "../../distribution";
6
- import { CompanionResolver } from "../../lib/orchestrator/companion-resolver";
7
- import { CompanionStore, resolvePolicyFromConfig } from "../../lib/orchestrator/companion-store";
8
- import { ConfigStore } from "../../lib/orchestrator/config-store";
9
6
  import { checkEngineRequirement } from "../../lib/orchestrator/engine-guard";
10
- import { GlobalConfigStore } from "../../lib/orchestrator/global-config-store";
11
- import { findRepoLocalLinkedRepository } from "../../lib/orchestrator/repo-local-registry";
12
- import type { CapabilityConfig, FrameworkConfig } from "../../lib/orchestrator/types";
13
- import { WorkingRepoStore } from "../../lib/orchestrator/working-repo-store";
7
+ import type { GlobalConfigStore } from "../../lib/orchestrator/global-config-store";
8
+ import { pathIsDirectory } from "../../lib/orchestrator/repo-local-registry";
9
+ import { resolveRootContext, type RootContext } from "../../lib/orchestrator/root-context";
10
+ import type { CapabilityConfig, FrameworkConfig, HubMember } from "../../lib/orchestrator/types";
14
11
  import { getRequiredPluginDrift } from "../../tools/setup/policy";
15
12
  import { resolveCommandOnPath } from "../../tools/setup/utils";
16
13
  import { printSection, renderKeyValueTable, renderTable } from "./status";
@@ -19,11 +16,43 @@ interface DoctorDeps {
19
16
  globalConfigStore?: GlobalConfigStore;
20
17
  cwd?: string;
21
18
  pathValue?: string;
19
+ /** Returns a member checkout's HEAD commit, or null when unreadable. */
20
+ gitHead?: (memberPath: string) => string | null;
22
21
  }
23
22
 
24
- interface CompanionStores {
25
- configStore: ConfigStore;
26
- workingRepoStore: WorkingRepoStore;
23
+ export type DoctorKind = "core" | "working" | "companion" | "hub";
24
+
25
+ export interface DoctorToolInstallation {
26
+ tool: string;
27
+ source: string;
28
+ status: "ok" | "missing";
29
+ path: string | null;
30
+ }
31
+
32
+ export interface DoctorHubMember {
33
+ id: string;
34
+ source: "git" | "local";
35
+ path: string;
36
+ exists: boolean;
37
+ commitStatus: "ok" | "drifted" | "missing" | "unknown";
38
+ materializedCommit: string | null;
39
+ actualCommit: string | null;
40
+ }
41
+
42
+ export interface DoctorReport {
43
+ kind: DoctorKind;
44
+ cwd: string;
45
+ companionPath: string | null;
46
+ repositoryId: string | null;
47
+ multipleCompanions: Array<{ companionPath: string; repositoryId: string }>;
48
+ policy?: { allowedAgents: string[] };
49
+ policyError?: string;
50
+ capabilities?: string[];
51
+ toolInstallations?: DoctorToolInstallation[];
52
+ requiredPluginDrift?: Array<{ pluginId: string; kind: string; reason: string }>;
53
+ engineRequirement?: { range: string; ok: boolean; detail: string };
54
+ hub?: { members: DoctorHubMember[] };
55
+ resolutionFailures: Array<{ companionPath: string; message: string }>;
27
56
  }
28
57
 
29
58
  interface ToolCheck {
@@ -32,75 +61,26 @@ interface ToolCheck {
32
61
  source: string;
33
62
  }
34
63
 
35
- function storesForCompanion(companionPath: string): CompanionStores {
36
- const configDir = path.join(companionPath, `.${FRAMEWORK_NAME}`, "config");
37
- return {
38
- configStore: new ConfigStore(path.join(configDir, "framework.yaml")),
39
- workingRepoStore: new WorkingRepoStore(path.join(configDir, "registry.yaml")),
40
- };
64
+ /** Doctor's view of the shared root kind: registry/env resolution means cwd is a working repo. */
65
+ function doctorKind(root: RootContext): DoctorKind {
66
+ if (root.kind === "core") return "core";
67
+ if (root.kind === "hub") return "hub";
68
+ if (root.origin === "env" || root.origin === "registry") return "working";
69
+ return root.kind;
41
70
  }
42
71
 
43
- async function hasLocalCompanionConfig(cwd: string): Promise<boolean> {
44
- const localDir = path.join(cwd, `.${FRAMEWORK_NAME}`);
45
-
72
+ function defaultGitHead(memberPath: string): string | null {
46
73
  try {
47
- await fs.access(path.join(localDir, "config", "framework.yaml"));
48
- return true;
74
+ return execFileSync("git", ["-C", memberPath, "rev-parse", "HEAD"], {
75
+ stdio: ["ignore", "pipe", "ignore"],
76
+ })
77
+ .toString()
78
+ .trim();
49
79
  } catch {
50
- return false;
80
+ return null;
51
81
  }
52
82
  }
53
83
 
54
- function renderList(items: string[], empty: string): string {
55
- return items.length > 0 ? items.map((item) => `- ${item}`).join("\n") : empty;
56
- }
57
-
58
- function renderEngineRequirement(
59
- config: FrameworkConfig,
60
- distributionName: string,
61
- currentVersion: string,
62
- ): string {
63
- const range = config.engines?.[distributionName];
64
- const result = checkEngineRequirement(config, distributionName, currentVersion);
65
- return result.ok
66
- ? `engines.${distributionName}: satisfied (requires ${range}, running ${currentVersion})`
67
- : `engines.${distributionName}: ${result.reason}`;
68
- }
69
-
70
- // Only the engines key matching the running distribution produces a
71
- // diagnostic; foreign distribution keys are ignored entirely.
72
- function printEngineRequirement(config: FrameworkConfig): void {
73
- const distribution = getActiveDistribution().config;
74
- if (!config.engines?.[FRAMEWORK_NAME]) return;
75
- printSection(
76
- "Version Requirement",
77
- renderEngineRequirement(config, FRAMEWORK_NAME, distribution.version),
78
- );
79
- }
80
-
81
- // Companions with no drift produce no section at all: required plugins are
82
- // silent when healthy.
83
- function printRequiredPluginDrift(config: FrameworkConfig): void {
84
- const drift = getRequiredPluginDrift(config);
85
- if (drift.length === 0) return;
86
- printSection(
87
- "Required Plugin Drift",
88
- renderTable(
89
- ["Plugin", "Kind", "Issue"],
90
- drift.map((entry) => [entry.pluginId, entry.kind, entry.reason]),
91
- ),
92
- );
93
- }
94
-
95
- function renderCapabilities(capabilities: CapabilityConfig[]): string {
96
- return capabilities.length > 0
97
- ? renderTable(
98
- ["Capability"],
99
- capabilities.map((capability) => [capability.name]),
100
- )
101
- : "No capabilities configured.";
102
- }
103
-
104
84
  function getSelectedToolChecks(config: {
105
85
  capabilities?: CapabilityConfig[];
106
86
  packageManagers?: string[];
@@ -136,76 +116,183 @@ function getSelectedToolChecks(config: {
136
116
  return checks;
137
117
  }
138
118
 
139
- function renderToolInstallations(
140
- config: {
141
- capabilities?: CapabilityConfig[];
142
- packageManagers?: string[];
143
- },
119
+ function collectToolInstallations(
120
+ config: FrameworkConfig,
144
121
  pathValue: string,
145
- ): string {
146
- const checks = getSelectedToolChecks(config);
147
- if (checks.length === 0) {
148
- return "No selected capabilities or package managers require command checks.";
122
+ ): DoctorToolInstallation[] {
123
+ return getSelectedToolChecks(config).map((check) => {
124
+ const resolved = resolveCommandOnPath(check.command, pathValue);
125
+ return {
126
+ tool: check.name,
127
+ source: check.source,
128
+ status: resolved ? "ok" : "missing",
129
+ path: resolved ?? null,
130
+ };
131
+ });
132
+ }
133
+
134
+ // Only the engines key matching the running distribution produces a
135
+ // diagnostic; foreign distribution keys are ignored entirely.
136
+ function collectEngineRequirement(
137
+ config: FrameworkConfig,
138
+ ): DoctorReport["engineRequirement"] | undefined {
139
+ const range = config.engines?.[FRAMEWORK_NAME];
140
+ if (!range) return undefined;
141
+ const currentVersion = getActiveDistribution().config.version;
142
+ const result = checkEngineRequirement(config, FRAMEWORK_NAME, currentVersion);
143
+ return {
144
+ range,
145
+ ok: result.ok,
146
+ detail: result.ok
147
+ ? `engines.${FRAMEWORK_NAME}: satisfied (requires ${range}, running ${currentVersion})`
148
+ : `engines.${FRAMEWORK_NAME}: ${result.reason}`,
149
+ };
150
+ }
151
+
152
+ async function collectHubMember(
153
+ hubPath: string,
154
+ member: HubMember,
155
+ gitHead: (memberPath: string) => string | null,
156
+ ): Promise<DoctorHubMember> {
157
+ const memberPath = path.resolve(hubPath, member.path);
158
+ const base = {
159
+ id: member.id,
160
+ source: member.source.kind,
161
+ path: memberPath,
162
+ materializedCommit: member.materializedCommit ?? null,
163
+ };
164
+ if (!(await pathIsDirectory(memberPath))) {
165
+ return { ...base, exists: false, commitStatus: "missing", actualCommit: null };
166
+ }
167
+ if (!member.materializedCommit) {
168
+ return { ...base, exists: true, commitStatus: "ok", actualCommit: null };
149
169
  }
170
+ const actualCommit = gitHead(memberPath);
171
+ return {
172
+ ...base,
173
+ exists: true,
174
+ commitStatus:
175
+ actualCommit === null
176
+ ? "unknown"
177
+ : actualCommit === member.materializedCommit
178
+ ? "ok"
179
+ : "drifted",
180
+ actualCommit,
181
+ };
182
+ }
150
183
 
151
- return renderTable(
152
- ["Tool", "Source", "Status", "Path"],
153
- checks.map((check) => {
154
- const resolved = resolveCommandOnPath(check.command, pathValue);
155
- return [check.name, check.source, resolved ? "ok" : "missing", resolved ?? ""];
156
- }),
157
- );
184
+ async function collectWorkingSections(
185
+ report: DoctorReport,
186
+ root: RootContext,
187
+ pathValue: string,
188
+ ): Promise<void> {
189
+ if (!root.rootPath || !root.repositoryId || !root.config) {
190
+ report.policyError = "No repository ID was provided for the resolved companion.";
191
+ return;
192
+ }
193
+
194
+ report.policy = { allowedAgents: root.config.allowedAgents ?? [] };
195
+ collectConfiguredRootSections(report, root.config, pathValue);
158
196
  }
159
197
 
160
- /**
161
- * @command mate doctor
162
- * @description Reports whether the current directory resolves as a linked
163
- * working repository, a companion repository, or neither. For linked working
164
- * repositories it also prints the effective policy and enabled capabilities.
165
- */
166
- export async function runDoctorCommand(_argv: string[] = [], deps: DoctorDeps = {}): Promise<void> {
167
- const cwd = deps.cwd ?? process.cwd();
198
+ function collectConfiguredRootSections(
199
+ report: DoctorReport,
200
+ config: FrameworkConfig,
201
+ pathValue: string,
202
+ ): void {
203
+ report.capabilities = (config.capabilities ?? []).map((capability) => capability.name);
204
+ report.toolInstallations = collectToolInstallations(config, pathValue);
205
+ report.requiredPluginDrift = getRequiredPluginDrift(config);
206
+ report.engineRequirement = collectEngineRequirement(config);
207
+ }
208
+
209
+ export async function collectDoctorReport(deps: DoctorDeps = {}): Promise<DoctorReport> {
210
+ const cwd = path.resolve(deps.cwd ?? process.cwd());
168
211
  const pathValue = deps.pathValue ?? process.env.PATH ?? "";
169
- const envCompanionPath = process.env.MATE_ARTIFACT_PATH;
170
- const globalConfigStore = deps.globalConfigStore ?? new GlobalConfigStore();
171
- const resolver = new CompanionResolver(globalConfigStore);
212
+ const gitHead = deps.gitHead ?? defaultGitHead;
213
+ const root = await resolveRootContext(cwd, deps);
214
+ const kind = doctorKind(root);
172
215
 
173
- let state: "linked-working-repository" | "companion-repository" | "not-linked";
174
- let companionPath = "";
175
- let repositoryId = "";
176
- let repository = await findRepoLocalLinkedRepository(cwd);
177
- const resolution = envCompanionPath
178
- ? { match: null, failures: [], ambiguousMatches: [] }
179
- : await resolver.resolveWithDiagnostics(cwd);
216
+ const report: DoctorReport = {
217
+ kind,
218
+ cwd,
219
+ companionPath: root.rootPath ?? null,
220
+ repositoryId: root.repositoryId ?? null,
221
+ multipleCompanions:
222
+ root.resolution.ambiguousMatches.length > 1 ? root.resolution.ambiguousMatches : [],
223
+ resolutionFailures: root.resolution.failures,
224
+ };
180
225
 
181
- if (envCompanionPath) {
182
- state = "linked-working-repository";
183
- companionPath = path.resolve(envCompanionPath);
184
- repositoryId = process.env.MATE_REPO_ID ?? repository?.id ?? "";
185
- } else if (resolution.match) {
186
- state = "linked-working-repository";
187
- companionPath = resolution.match.companionPath;
188
- repositoryId = repository?.id ?? resolution.match.repositoryId;
189
- } else if (await hasLocalCompanionConfig(cwd)) {
190
- state = "companion-repository";
191
- companionPath = cwd;
192
- } else {
193
- state = "not-linked";
226
+ if (kind === "working") {
227
+ await collectWorkingSections(report, root, pathValue);
228
+ } else if (kind === "companion" && root.config) {
229
+ collectConfiguredRootSections(report, root.config, pathValue);
230
+ } else if (kind === "hub" && root.rootPath && root.config) {
231
+ report.hub = {
232
+ members: await Promise.all(
233
+ (root.config.hub?.companions ?? []).map((member) =>
234
+ collectHubMember(root.rootPath!, member, gitHead),
235
+ ),
236
+ ),
237
+ };
238
+ report.requiredPluginDrift = getRequiredPluginDrift(root.config);
239
+ report.engineRequirement = collectEngineRequirement(root.config);
194
240
  }
195
241
 
242
+ return report;
243
+ }
244
+
245
+ function renderList(items: string[], empty: string): string {
246
+ return items.length > 0 ? items.map((item) => `- ${item}`).join("\n") : empty;
247
+ }
248
+
249
+ function renderCapabilities(capabilities: string[]): string {
250
+ return capabilities.length > 0
251
+ ? renderTable(
252
+ ["Capability"],
253
+ capabilities.map((capability) => [capability]),
254
+ )
255
+ : "No capabilities configured.";
256
+ }
257
+
258
+ function renderToolInstallations(installations: DoctorToolInstallation[]): string {
259
+ if (installations.length === 0) {
260
+ return "No selected capabilities or package managers require command checks.";
261
+ }
262
+ return renderTable(
263
+ ["Tool", "Source", "Status", "Path"],
264
+ installations.map((item) => [item.tool, item.source, item.status, item.path ?? ""]),
265
+ );
266
+ }
267
+
268
+ function renderHubMembers(members: DoctorHubMember[]): string {
269
+ if (members.length === 0) return "No hub members configured.";
270
+ return renderTable(
271
+ ["Member", "Source", "Path", "Exists", "Commit"],
272
+ members.map((member) => [
273
+ member.id,
274
+ member.source,
275
+ member.path,
276
+ member.exists ? "yes" : "no",
277
+ member.commitStatus,
278
+ ]),
279
+ );
280
+ }
281
+
282
+ function renderHumanReport(report: DoctorReport): void {
196
283
  console.log("Mate Doctor");
197
284
  console.log("");
198
285
  printSection(
199
286
  "Context",
200
287
  renderKeyValueTable([
201
- ["State", state],
202
- ["Current Working Directory", cwd],
203
- ["Companion Path", companionPath || "none"],
204
- ["Repository ID", repositoryId || "none"],
288
+ ["State", report.kind],
289
+ ["Current Working Directory", report.cwd],
290
+ ["Companion Path", report.companionPath ?? "none"],
291
+ ["Repository ID", report.repositoryId ?? "none"],
205
292
  ]),
206
293
  );
207
294
 
208
- if (resolution.ambiguousMatches.length > 1) {
295
+ if (report.multipleCompanions.length > 1) {
209
296
  printSection(
210
297
  "Multiple Companions",
211
298
  [
@@ -215,63 +302,63 @@ export async function runDoctorCommand(_argv: string[] = [], deps: DoctorDeps =
215
302
  "",
216
303
  renderTable(
217
304
  ["Companion Path", "Repository ID"],
218
- resolution.ambiguousMatches.map((match) => [match.companionPath, match.repositoryId]),
305
+ report.multipleCompanions.map((match) => [match.companionPath, match.repositoryId]),
219
306
  ),
220
307
  ].join("\n"),
221
308
  );
222
309
  }
223
310
 
224
- if (state === "linked-working-repository" && companionPath && repositoryId) {
225
- const { configStore, workingRepoStore } = storesForCompanion(companionPath);
226
- const config = await configStore.load();
227
- let resolvedPolicy;
228
-
229
- if (repository) {
230
- resolvedPolicy = resolvePolicyFromConfig(config, [repository], repository.id);
231
- } else {
232
- resolvedPolicy = await new CompanionStore(configStore, workingRepoStore).resolvePolicy(
233
- repositoryId,
234
- );
235
- }
236
-
237
- if (!repository) {
238
- const working = await workingRepoStore.load();
239
- repository = working.repos.find((repo) => repo.id === repositoryId) ?? null;
240
- }
241
-
311
+ if (report.policy) {
242
312
  printSection(
243
- "Policy",
244
- renderKeyValueTable([
245
- ["Profile", repository?.profile ?? "unknown"],
246
- ["Allowed Agents", resolvedPolicy.allowedAgents.join(", ") || "none"],
247
- ]),
313
+ "Allowed Agents",
314
+ renderList(report.policy.allowedAgents, "No allowed agents configured."),
248
315
  );
316
+ }
317
+ if (report.policyError) printSection("Policy", report.policyError);
318
+ if (report.capabilities) printSection("Capabilities", renderCapabilities(report.capabilities));
319
+ if (report.toolInstallations) {
320
+ printSection("Tool Installations", renderToolInstallations(report.toolInstallations));
321
+ }
322
+ if (report.hub) printSection("Hub Members", renderHubMembers(report.hub.members));
323
+ // Companions with no drift produce no section at all: required plugins are
324
+ // silent when healthy.
325
+ if (report.requiredPluginDrift && report.requiredPluginDrift.length > 0) {
249
326
  printSection(
250
- "Allowed Agents",
251
- renderList(resolvedPolicy.allowedAgents, "No allowed agents configured."),
327
+ "Required Plugin Drift",
328
+ renderTable(
329
+ ["Plugin", "Kind", "Issue"],
330
+ report.requiredPluginDrift.map((entry) => [entry.pluginId, entry.kind, entry.reason]),
331
+ ),
252
332
  );
253
- printSection("Capabilities", renderCapabilities(config.capabilities ?? []));
254
- printSection("Tool Installations", renderToolInstallations(config, pathValue));
255
- printRequiredPluginDrift(config);
256
- printEngineRequirement(config);
257
- } else if (state === "linked-working-repository") {
258
- printSection("Policy", "No repository ID was provided for the resolved companion.");
259
- } else if (companionPath) {
260
- const { configStore } = storesForCompanion(companionPath);
261
- const config = await configStore.load();
262
- printSection("Capabilities", renderCapabilities(config.capabilities ?? []));
263
- printSection("Tool Installations", renderToolInstallations(config, pathValue));
264
- printRequiredPluginDrift(config);
265
- printEngineRequirement(config);
333
+ }
334
+ if (report.engineRequirement) {
335
+ printSection("Version Requirement", report.engineRequirement.detail);
266
336
  }
267
337
 
268
338
  printSection(
269
339
  "Resolution Failures",
270
- resolution.failures.length > 0
340
+ report.resolutionFailures.length > 0
271
341
  ? renderTable(
272
342
  ["Companion Path", "Error"],
273
- resolution.failures.map((failure) => [failure.companionPath, failure.message]),
343
+ report.resolutionFailures.map((failure) => [failure.companionPath, failure.message]),
274
344
  )
275
345
  : "No companion resolution failures encountered.",
276
346
  );
277
347
  }
348
+
349
+ /**
350
+ * @command mate doctor
351
+ * @description Reports the current directory's root kind — `working` (linked
352
+ * working repository), `companion`, `hub`, or `core` (not linked) — with
353
+ * kind-specific diagnostics: policy and capabilities for working repos,
354
+ * capabilities and tool checks for companions, member health for hubs.
355
+ * `--json` emits the same report as one JSON document.
356
+ */
357
+ export async function runDoctorCommand(argv: string[] = [], deps: DoctorDeps = {}): Promise<void> {
358
+ const report = await collectDoctorReport(deps);
359
+ if (argv.includes("--json")) {
360
+ console.log(JSON.stringify(report, null, 2));
361
+ return;
362
+ }
363
+ renderHumanReport(report);
364
+ }
@@ -56,7 +56,7 @@ export async function runSetupCommand(argv: string[]): Promise<void> {
56
56
  * Resolves agent/package-manager/capability selections either from CLI flags
57
57
  * (non-interactive) or from the interactive `selectSetupCompatibilities` picker,
58
58
  * seeded from the existing config when one exists. Writes the resulting config
59
- * via `setup.execute` and prints a summary table of profiles, package managers,
59
+ * via `setup.execute` and prints a summary table of allowed agents, package managers,
60
60
  * and capabilities.
61
61
  * @flags
62
62
  * - `--agents <list>` — parsed via `parseAllowedAgents`.
@@ -192,9 +192,7 @@ async function runSetupFlow(
192
192
  const { config } = result;
193
193
  const rows: [string, string][] = [];
194
194
 
195
- for (const [profileName, profile] of Object.entries(config.profiles)) {
196
- rows.push([`profile:${profileName}`, (profile.allowedAgents ?? []).join(", ") || "(none)"]);
197
- }
195
+ rows.push(["allowedAgents", (config.allowedAgents ?? []).join(", ") || "(none)"]);
198
196
 
199
197
  const capabilities = config.capabilities ?? [];
200
198
  rows.push(["packageManagers", (config.packageManagers ?? ["bun"]).join(", ") || "(none)"]);