@uniqbit/mate-core 0.15.4 → 0.15.5-canary.1

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 (32) hide show
  1. package/package.json +1 -1
  2. package/src/cli/commands/artifact/finish/openspec.ts +180 -12
  3. package/src/cli/commands/companion/link.ts +9 -13
  4. package/src/cli/commands/working/cleanup.ts +125 -0
  5. package/src/cli/commands/working/working.ts +15 -0
  6. package/src/cli/commands/workspace/list.ts +25 -0
  7. package/src/cli/commands/workspace/materialize.ts +46 -0
  8. package/src/cli/commands/workspace/workspace.ts +22 -0
  9. package/src/cli/main.ts +20 -1
  10. package/src/cli/usage.ts +8 -0
  11. package/src/cli/write-json-stdout.ts +14 -0
  12. package/src/lib/orchestrator/companion-registry-reader.ts +37 -0
  13. package/src/lib/orchestrator/{working-repo-store.ts → companion-registry-store.ts} +14 -7
  14. package/src/lib/orchestrator/companion-resolver.ts +50 -2
  15. package/src/lib/orchestrator/companion-store.ts +39 -10
  16. package/src/lib/orchestrator/editor.ts +36 -14
  17. package/src/lib/orchestrator/framework-context.ts +33 -3
  18. package/src/lib/orchestrator/global-config-store.ts +25 -0
  19. package/src/lib/orchestrator/repo-local-registry.ts +2 -22
  20. package/src/lib/orchestrator/types.ts +1 -1
  21. package/src/lib/orchestrator/workspace-inventory.ts +149 -0
  22. package/src/lib/orchestrator/workspace-materialize.ts +80 -0
  23. package/src/lib/orchestrator/yaml-file-store.ts +10 -1
  24. package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +40 -14
  25. package/src/templates/capabilities/openspec-cap/mate-v1/templates/spec.md +9 -6
  26. package/src/templates/root/TEMPLATE_AGENTS.md +0 -8
  27. package/src/templates/root/TEMPLATE_CLAUDE.md +0 -10
  28. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +44 -380
  29. package/src/tools/setup/plugins/gitignore.ts +3 -0
  30. package/src/tools/setup/providers/claude.ts +68 -56
  31. package/src/tools/setup/working-repo-cleanup.ts +40 -0
  32. package/src/tools/setup/working-repo-local-state.ts +103 -0
@@ -3,18 +3,25 @@ import path from "node:path";
3
3
  import { FRAMEWORK_NAME } from "../../framework";
4
4
  import { migrateRegistryData } from "./migration";
5
5
  import { YamlFileStore } from "./yaml-file-store";
6
- import { ConfigError, type WorkingRepoConfig } from "./types";
6
+ import { ConfigError, type CompanionRegistryConfig } from "./types";
7
7
 
8
- export function getDefaultWorkingRepoPath(): string {
8
+ export function getDefaultCompanionRegistryPath(): string {
9
9
  return `.${FRAMEWORK_NAME}/config/registry.yaml`;
10
10
  }
11
11
 
12
- export class WorkingRepoStore extends YamlFileStore<WorkingRepoConfig> {
13
- constructor(configPath = getDefaultWorkingRepoPath()) {
12
+ /**
13
+ * Companion-side reverse index (`{ repos: [...] }`) at
14
+ * `<companion>/.mate/config/registry.yaml` — distinct from the repo-local
15
+ * cache ({@link RepoLocalRegistryStore}) despite the shared "registry.yaml"
16
+ * filename. Renamed from `WorkingRepoStore`: every call site pointed this
17
+ * at a companion's config dir, never an actual working repo.
18
+ */
19
+ export class CompanionRegistryStore extends YamlFileStore<CompanionRegistryConfig> {
20
+ constructor(configPath = getDefaultCompanionRegistryPath()) {
14
21
  super(path.resolve(configPath));
15
22
  }
16
23
 
17
- override async load(): Promise<WorkingRepoConfig> {
24
+ override async load(): Promise<CompanionRegistryConfig> {
18
25
  await migrateRegistryData(this.configPath);
19
26
  const config = await super.load();
20
27
  // Silently drop legacy per-repo policy fields (profile/overrides); the
@@ -25,13 +32,13 @@ export class WorkingRepoStore extends YamlFileStore<WorkingRepoConfig> {
25
32
  };
26
33
  }
27
34
 
28
- protected async onMissing(): Promise<WorkingRepoConfig> {
35
+ protected async onMissing(): Promise<CompanionRegistryConfig> {
29
36
  throw new ConfigError(
30
37
  `Working repo config not found. Please run \`${FRAMEWORK_NAME} companion setup\` to initialize the framework.`,
31
38
  );
32
39
  }
33
40
 
34
- static defaultConfig(): WorkingRepoConfig {
41
+ static defaultConfig(): CompanionRegistryConfig {
35
42
  return { repos: [] };
36
43
  }
37
44
  }
@@ -3,10 +3,12 @@ import path from "node:path";
3
3
 
4
4
  import { parse } from "yaml";
5
5
 
6
+ import { readCompanionRegistry } from "./companion-registry-reader";
6
7
  import type { GlobalConfigStore } from "./global-config-store";
7
8
  import {
8
9
  findRepoLocalRegistryFile,
9
10
  pathIsDirectory,
11
+ repoLocalRegistryPath,
10
12
  type RepoLocalCompanionPointer,
11
13
  RepoLocalRegistryStore,
12
14
  } from "./repo-local-registry";
@@ -36,7 +38,7 @@ export interface CompanionResolutionResult {
36
38
  }
37
39
 
38
40
  export class CompanionResolver {
39
- constructor(_globalConfigStore: GlobalConfigStore) {}
41
+ constructor(private readonly globalConfigStore: GlobalConfigStore) {}
40
42
 
41
43
  async resolve(cwd: string): Promise<CompanionMatch | null> {
42
44
  return (await this.resolveWithDiagnostics(cwd, { logFailures: true })).match;
@@ -49,7 +51,53 @@ export class CompanionResolver {
49
51
  const resolvedCwd = path.resolve(cwd);
50
52
 
51
53
  const repoLocalResult = await this.resolveFromRepoLocalRegistry(resolvedCwd, options);
52
- return repoLocalResult ?? { match: null, ambiguousMatches: [], failures: [] };
54
+ if (repoLocalResult) return repoLocalResult;
55
+
56
+ return this.resolveFromGlobalRegistry(resolvedCwd);
57
+ }
58
+
59
+ /**
60
+ * Fallback for when the repo-local cache is missing or unusable: scans
61
+ * every companion the global registry knows about and matches by
62
+ * repository path. A single match self-heals the repo-local cache so the
63
+ * fast path succeeds on the next resolution without repeating this scan.
64
+ */
65
+ private async resolveFromGlobalRegistry(resolvedCwd: string): Promise<CompanionResolutionResult> {
66
+ const candidates = await this.globalConfigStore.list();
67
+ const found = await Promise.all(
68
+ candidates.map(async (candidate) => {
69
+ const companionPath = path.resolve(candidate);
70
+ try {
71
+ const { repos } = await readCompanionRegistry(companionPath);
72
+ const repo = repos.find((r) => path.resolve(r.path) === resolvedCwd);
73
+ return repo ? { companionPath, repositoryId: repo.id } : null;
74
+ } catch {
75
+ return null;
76
+ }
77
+ }),
78
+ );
79
+
80
+ const seenCompanionPaths = new Set<string>();
81
+ const matches = found.filter((match): match is CompanionMatch => {
82
+ if (!match || seenCompanionPaths.has(match.companionPath)) return false;
83
+ seenCompanionPaths.add(match.companionPath);
84
+ return true;
85
+ });
86
+
87
+ if (matches.length === 0) return { match: null, ambiguousMatches: [], failures: [] };
88
+
89
+ if (matches.length === 1) {
90
+ const store = new RepoLocalRegistryStore(repoLocalRegistryPath(resolvedCwd));
91
+ await store.save({
92
+ companions: [{ path: matches[0]!.companionPath, repositoryId: matches[0]!.repositoryId }],
93
+ });
94
+ }
95
+
96
+ return {
97
+ match: matches[0]!,
98
+ ambiguousMatches: matches.length > 1 ? matches : [],
99
+ failures: [],
100
+ };
53
101
  }
54
102
 
55
103
  /**
@@ -1,15 +1,25 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
 
4
+ import { FRAMEWORK_NAME } from "../../framework";
5
+ import { CompanionRegistryStore } from "./companion-registry-store";
4
6
  import { ConfigStore } from "./config-store";
5
- import { writeRepoLocalRegistryEntry } from "./repo-local-registry";
7
+ import { pathIsDirectory, writeRepoLocalRegistryEntry } from "./repo-local-registry";
6
8
  import { type CompanionSource, ConfigError, type LinkedRepository } from "./types";
7
- import { WorkingRepoStore } from "./working-repo-store";
9
+
10
+ /** Builds a {@link CompanionStore} whose config/registry files live under `<companionPath>/.mate/config`, regardless of `process.cwd()`. */
11
+ export function companionRootedStore(companionPath: string): CompanionStore {
12
+ const configDir = path.join(path.resolve(companionPath), `.${FRAMEWORK_NAME}`, "config");
13
+ return new CompanionStore(
14
+ new ConfigStore(path.join(configDir, "framework.yaml")),
15
+ new CompanionRegistryStore(path.join(configDir, "registry.yaml")),
16
+ );
17
+ }
8
18
 
9
19
  export class CompanionStore {
10
20
  constructor(
11
21
  private readonly configStore = new ConfigStore(),
12
- private readonly workingRepoStore = new WorkingRepoStore(),
22
+ private readonly companionRegistryStore = new CompanionRegistryStore(),
13
23
  ) {}
14
24
 
15
25
  async registerRepository(
@@ -23,14 +33,33 @@ export class CompanionStore {
23
33
  );
24
34
  }
25
35
 
26
- const working = await this.workingRepoStore.load();
27
- const existing = working.repos.findIndex((r) => r.id === repository.id);
36
+ // A companion's registry.yaml may not exist yet (its first-ever link):
37
+ // that is not a real error here, unlike a working repo reading its own
38
+ // missing config, so fall back to an empty registry instead of throwing.
39
+ const working = await this.companionRegistryStore.load().catch((error) => {
40
+ if (error instanceof ConfigError) return CompanionRegistryStore.defaultConfig();
41
+ throw error;
42
+ });
43
+
44
+ // Prune dead entries (path no longer a directory) as part of this same
45
+ // write, piggybacking on the load/save that registration already does
46
+ // rather than adding a separate scan; never runs on a read-only list.
47
+ const checked = await Promise.all(
48
+ working.repos.map(async (repo) => ({ repo, exists: await pathIsDirectory(repo.path) })),
49
+ );
50
+ const survivors: LinkedRepository[] = [];
51
+ for (const entry of checked) {
52
+ if (entry.exists) survivors.push(entry.repo);
53
+ }
54
+
55
+ const existing = survivors.findIndex((r) => r.id === repository.id);
28
56
  if (existing >= 0) {
29
- working.repos[existing] = repository;
57
+ survivors[existing] = repository;
30
58
  } else {
31
- working.repos.push(repository);
59
+ survivors.push(repository);
32
60
  }
33
- await this.workingRepoStore.save(working);
61
+ working.repos = survivors;
62
+ await this.companionRegistryStore.save(working);
34
63
 
35
64
  // Repo-local registry write is a cache, not the source of truth: the
36
65
  // companion-side write above already succeeded, so a failure here (e.g. a
@@ -54,12 +83,12 @@ export class CompanionStore {
54
83
  }
55
84
 
56
85
  async getRepository(repositoryId: string): Promise<LinkedRepository | undefined> {
57
- const working = await this.workingRepoStore.load();
86
+ const working = await this.companionRegistryStore.load();
58
87
  return working.repos.find((r) => r.id === repositoryId);
59
88
  }
60
89
 
61
90
  async listRepositories(): Promise<LinkedRepository[]> {
62
- const working = await this.workingRepoStore.load();
91
+ const working = await this.companionRegistryStore.load();
63
92
  return working.repos;
64
93
  }
65
94
  }
@@ -140,6 +140,39 @@ export function detectEditorWorkspaceState(
140
140
  }
141
141
  }
142
142
 
143
+ export interface WorkspaceDocument {
144
+ workspacePath: string;
145
+ folders: [string, string];
146
+ }
147
+
148
+ /**
149
+ * Writes the generated `.mate/workspace.code-workspace` file (working
150
+ * repository first, companion second) and returns its path and folder
151
+ * order. Shared by editor-launching `injectEditorFolder` and the
152
+ * non-launching `workspace materialize` command — both must produce the
153
+ * same document for the same pairing.
154
+ */
155
+ export async function writeWorkspaceDocument(
156
+ companionPath: string,
157
+ repoPath: string,
158
+ ): Promise<WorkspaceDocument> {
159
+ const folders: [string, string] = [path.resolve(repoPath), path.resolve(companionPath)];
160
+ const workspacePath = editorWorkspacePath(repoPath);
161
+ await fs.mkdir(path.dirname(workspacePath), { recursive: true });
162
+ await fs.writeFile(
163
+ workspacePath,
164
+ `${JSON.stringify(
165
+ {
166
+ folders: folders.map((candidate) => ({ path: candidate })),
167
+ },
168
+ null,
169
+ 2,
170
+ )}\n`,
171
+ "utf8",
172
+ );
173
+ return { workspacePath, folders };
174
+ }
175
+
143
176
  export async function injectEditorFolder(
144
177
  companionPath: string,
145
178
  repoPath: string,
@@ -163,20 +196,9 @@ export async function injectEditorFolder(
163
196
 
164
197
  const { cli: resolvedCli, binary } = resolved;
165
198
 
166
- const workspaceFolders = [path.resolve(repoPath), path.resolve(companionPath)];
167
-
168
- const workspacePath = editorWorkspacePath(repoPath);
169
- await fs.mkdir(path.dirname(workspacePath), { recursive: true });
170
- await fs.writeFile(
171
- workspacePath,
172
- `${JSON.stringify(
173
- {
174
- folders: workspaceFolders.map((candidate) => ({ path: candidate })),
175
- },
176
- null,
177
- 2,
178
- )}\n`,
179
- "utf8",
199
+ const { workspacePath, folders: workspaceFolders } = await writeWorkspaceDocument(
200
+ companionPath,
201
+ repoPath,
180
202
  );
181
203
 
182
204
  if (detectWorkspace(workspacePath, resolvedCli) === "open") return true;
@@ -3,7 +3,10 @@ import path from "node:path";
3
3
 
4
4
  import { FRAMEWORK_NAME } from "../../framework";
5
5
  import { parse } from "yaml";
6
+ import { CompanionRegistryStore } from "./companion-registry-store";
6
7
  import { CompanionResolver } from "./companion-resolver";
8
+ import { companionRootedStore } from "./companion-store";
9
+ import { readCompanionRegistry } from "./companion-registry-reader";
7
10
  import { ConfigStore } from "./config-store";
8
11
  import { GlobalConfigStore } from "./global-config-store";
9
12
  import { findRepoLocalLinkedRepository } from "./repo-local-registry";
@@ -16,11 +19,10 @@ import {
16
19
  RepositoryNotFoundError,
17
20
  WorkingRepoRequiredError,
18
21
  } from "./types";
19
- import { WorkingRepoStore } from "./working-repo-store";
20
22
 
21
23
  export interface FrameworkContext {
22
24
  configStore: ConfigStore;
23
- workingRepoStore: WorkingRepoStore;
25
+ workingRepoStore: CompanionRegistryStore;
24
26
  companionPath: string;
25
27
  repository?: LinkedRepository;
26
28
  contextKind: "env" | "working-repo" | "companion-root" | "hub";
@@ -52,7 +54,7 @@ function makeContext(
52
54
  const configDir = path.join(companionPath, `.${FRAMEWORK_NAME}`, "config");
53
55
  return {
54
56
  configStore: new ConfigStore(path.join(configDir, "framework.yaml")),
55
- workingRepoStore: new WorkingRepoStore(path.join(configDir, "registry.yaml")),
57
+ workingRepoStore: new CompanionRegistryStore(path.join(configDir, "registry.yaml")),
56
58
  companionPath,
57
59
  repository,
58
60
  contextKind,
@@ -212,6 +214,9 @@ export async function resolveForLaunch(
212
214
 
213
215
  if (resolution.match) {
214
216
  const repository = (await findRepoLocalLinkedRepository(cwd)) ?? undefined;
217
+ if (repository) {
218
+ await backfillCompanionRegistration(resolution.match.companionPath, repository);
219
+ }
215
220
  const context = await withResolvedHub(
216
221
  makeContext(resolution.match.companionPath, "working-repo", repository),
217
222
  );
@@ -224,6 +229,31 @@ export async function resolveForLaunch(
224
229
  throw new WorkingRepoRequiredError();
225
230
  }
226
231
 
232
+ /**
233
+ * Self-heals repos linked before the companion-side registry write existed
234
+ * (or whose companion registry was reset): backfills the companion-side
235
+ * entry from the already-trusted repo-local pointer on every launch
236
+ * resolution. Best-effort — never blocks a launch.
237
+ */
238
+ async function backfillCompanionRegistration(
239
+ companionPath: string,
240
+ repository: LinkedRepository,
241
+ ): Promise<void> {
242
+ try {
243
+ const { repos } = await readCompanionRegistry(companionPath).catch(() => ({ repos: [] }));
244
+ const existing = repos.find((repo) => repo.id === repository.id);
245
+ if (existing?.path === repository.path) return;
246
+
247
+ await companionRootedStore(companionPath).registerRepository(repository);
248
+ } catch (error) {
249
+ console.error(
250
+ `${FRAMEWORK_NAME}: warning: failed to backfill companion registry for ${repository.id}: ${
251
+ error instanceof Error ? error.message : String(error)
252
+ }`,
253
+ );
254
+ }
255
+ }
256
+
227
257
  // Resolves context for `mate cap` commands. Unlike resolveForLaunch, this allows
228
258
  // running from the companion directory itself — cap commands manage companion state
229
259
  // and don't need a working-repo cwd. resolveForLaunch intentionally lacks this
@@ -2,6 +2,7 @@ import os from "node:os";
2
2
  import path from "node:path";
3
3
 
4
4
  import { FRAMEWORK_NAME } from "../../framework";
5
+ import { pathIsDirectory } from "./repo-local-registry";
5
6
  import { YamlFileStore } from "./yaml-file-store";
6
7
 
7
8
  interface CompanionEntry {
@@ -50,4 +51,28 @@ export class GlobalConfigStore extends YamlFileStore<GlobalConfig> {
50
51
  await this.save(config);
51
52
  }
52
53
  }
54
+
55
+ /**
56
+ * One-time/idempotent hygiene pass: drops entries whose path is not a
57
+ * directory on disk (stale test-fixture pollution, removed companions).
58
+ * Safe to call repeatedly — a clean registry is left unchanged and unsaved.
59
+ */
60
+ async prune(): Promise<{ removed: string[] }> {
61
+ const config = await this.load();
62
+ const checked = await Promise.all(
63
+ config.companions.map(async (c) => ({ path: c.path, exists: await pathIsDirectory(c.path) })),
64
+ );
65
+
66
+ const removed: string[] = [];
67
+ const kept: CompanionEntry[] = [];
68
+ for (const entry of checked) {
69
+ if (entry.exists) kept.push({ path: entry.path });
70
+ else removed.push(entry.path);
71
+ }
72
+ if (removed.length === 0) return { removed };
73
+
74
+ config.companions = kept;
75
+ await this.save(config);
76
+ return { removed };
77
+ }
53
78
  }
@@ -4,7 +4,7 @@ import path from "node:path";
4
4
  import { parse, stringify } from "yaml";
5
5
 
6
6
  import { FRAMEWORK_NAME } from "../../framework";
7
- import { resolveGitInfoExcludePath } from "../../tools/setup/git-utils";
7
+ import { ensureWorkingRepoLocalExcludes } from "../../tools/setup/working-repo-local-state";
8
8
  import { YamlFileStore } from "./yaml-file-store";
9
9
  import type { CompanionSource, LinkedRepository } from "./types";
10
10
 
@@ -20,7 +20,6 @@ export interface RepoLocalRegistry {
20
20
  }
21
21
 
22
22
  const repoLocalDirName = () => `.${FRAMEWORK_NAME}`;
23
- const repoLocalExcludeEntry = () => `${repoLocalDirName()}/`;
24
23
  const repoLocalScanSkipDirNames = () =>
25
24
  new Set([
26
25
  ".git",
@@ -59,26 +58,7 @@ export class RepoLocalRegistryStore extends YamlFileStore<RepoLocalRegistry> {
59
58
  * never needs a tracked `.gitignore` entry, mirroring `ensureTokensaveStoreExcluded`.
60
59
  */
61
60
  export async function ensureRepoLocalDirExcluded(repoPath: string): Promise<void> {
62
- const excludePath = await resolveGitInfoExcludePath(repoPath);
63
- if (!excludePath) return;
64
-
65
- let existing = "";
66
- try {
67
- existing = await fs.readFile(excludePath, "utf8");
68
- } catch {
69
- // Fresh repos may not have created info/exclude yet.
70
- }
71
-
72
- const lines = existing
73
- .split(/\r?\n/)
74
- .map((line) => line.trim())
75
- .filter(Boolean);
76
-
77
- if (lines.includes(repoLocalExcludeEntry())) return;
78
-
79
- lines.push(repoLocalExcludeEntry());
80
- await fs.mkdir(path.dirname(excludePath), { recursive: true });
81
- await fs.writeFile(excludePath, lines.join("\n") + "\n", "utf8");
61
+ await ensureWorkingRepoLocalExcludes(repoPath);
82
62
  }
83
63
 
84
64
  /** Writes `.mate/config/framework.yaml` with `type: "working"` if it does not already exist. */
@@ -123,7 +123,7 @@ export interface FrameworkConfig {
123
123
  engines?: EngineConstraints;
124
124
  }
125
125
 
126
- export interface WorkingRepoConfig {
126
+ export interface CompanionRegistryConfig {
127
127
  repos: LinkedRepository[];
128
128
  }
129
129
 
@@ -0,0 +1,149 @@
1
+ import path from "node:path";
2
+
3
+ import { pathIsDirectory } from "./repo-local-registry";
4
+ import { readCompanionRegistry } from "./companion-registry-reader";
5
+ import { GlobalConfigStore } from "./global-config-store";
6
+ import type { LinkedRepository } from "./types";
7
+
8
+ /** Envelope version for `mate workspace list --json`; bump on any breaking shape change. */
9
+ export const WORKSPACE_INVENTORY_SCHEMA_VERSION = 1;
10
+
11
+ export type CompanionInventoryHealth = "ready" | "missing" | "unreadable";
12
+ export type PairingInventoryHealth =
13
+ | "ready"
14
+ | "missing-companion"
15
+ | "missing-repository"
16
+ | "unreadable";
17
+
18
+ export interface WorkspaceInventoryCompanionEntry {
19
+ path: string;
20
+ health: CompanionInventoryHealth;
21
+ diagnostic?: string;
22
+ }
23
+
24
+ export interface WorkspaceInventoryPairingEntry {
25
+ companionPath: string;
26
+ repository: LinkedRepository;
27
+ health: PairingInventoryHealth;
28
+ ambiguous: boolean;
29
+ diagnostic?: string;
30
+ }
31
+
32
+ export interface WorkspaceInventoryV1 {
33
+ schemaVersion: 1;
34
+ companions: WorkspaceInventoryCompanionEntry[];
35
+ pairings: WorkspaceInventoryPairingEntry[];
36
+ }
37
+
38
+ export interface WorkspaceInventoryDeps {
39
+ listCompanionPaths: () => Promise<string[]>;
40
+ isDirectory: (candidatePath: string) => Promise<boolean>;
41
+ readCompanionRegistry: typeof readCompanionRegistry;
42
+ }
43
+
44
+ export function defaultWorkspaceInventoryDeps(
45
+ globalConfigStore = new GlobalConfigStore(),
46
+ ): WorkspaceInventoryDeps {
47
+ return {
48
+ listCompanionPaths: () => globalConfigStore.list(),
49
+ isDirectory: pathIsDirectory,
50
+ readCompanionRegistry,
51
+ };
52
+ }
53
+
54
+ interface CompanionScanResult {
55
+ companionPath: string;
56
+ health: CompanionInventoryHealth;
57
+ diagnostic?: string;
58
+ repos: LinkedRepository[];
59
+ }
60
+
61
+ async function scanCompanion(
62
+ companionPath: string,
63
+ deps: WorkspaceInventoryDeps,
64
+ ): Promise<CompanionScanResult> {
65
+ if (!(await deps.isDirectory(companionPath))) {
66
+ return { companionPath, health: "missing", repos: [] };
67
+ }
68
+
69
+ try {
70
+ const { repos } = await deps.readCompanionRegistry(companionPath);
71
+ return { companionPath, health: "ready", repos };
72
+ } catch (error) {
73
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
74
+ return { companionPath, health: "ready", repos: [] };
75
+ }
76
+ return {
77
+ companionPath,
78
+ health: "unreadable",
79
+ diagnostic: error instanceof Error ? error.message : String(error),
80
+ repos: [],
81
+ };
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Read-only aggregate over the global companion registry and each
87
+ * companion's own linked-repository registry. Never migrates, repairs, or
88
+ * removes registry state — partial failures are reported as data.
89
+ */
90
+ export async function collectWorkspaceInventory(
91
+ deps: WorkspaceInventoryDeps = defaultWorkspaceInventoryDeps(),
92
+ ): Promise<WorkspaceInventoryV1> {
93
+ const companionPaths = [
94
+ ...new Set((await deps.listCompanionPaths()).map((p) => path.resolve(p))),
95
+ ];
96
+
97
+ const scans = await Promise.all(
98
+ companionPaths.map((companionPath) => scanCompanion(companionPath, deps)),
99
+ );
100
+
101
+ const repoPathsByCompanion = new Map<string, Set<string>>();
102
+ for (const scan of scans) {
103
+ for (const repo of scan.repos) {
104
+ const repoPath = path.resolve(repo.path);
105
+ const companions = repoPathsByCompanion.get(repoPath) ?? new Set<string>();
106
+ companions.add(scan.companionPath);
107
+ repoPathsByCompanion.set(repoPath, companions);
108
+ }
109
+ }
110
+
111
+ const repoExistence = new Map<string, boolean>();
112
+ await Promise.all(
113
+ [...repoPathsByCompanion.keys()].map(async (repoPath) => {
114
+ repoExistence.set(repoPath, await deps.isDirectory(repoPath));
115
+ }),
116
+ );
117
+
118
+ const companions: WorkspaceInventoryCompanionEntry[] = scans.map((scan) => ({
119
+ path: scan.companionPath,
120
+ health: scan.health,
121
+ ...(scan.diagnostic ? { diagnostic: scan.diagnostic } : {}),
122
+ }));
123
+
124
+ const pairings: WorkspaceInventoryPairingEntry[] = scans.flatMap((scan) =>
125
+ scan.repos.map((repo) => {
126
+ const repoPath = path.resolve(repo.path);
127
+ const ambiguous = (repoPathsByCompanion.get(repoPath)?.size ?? 0) > 1;
128
+ const health: PairingInventoryHealth = repoExistence.get(repoPath)
129
+ ? "ready"
130
+ : "missing-repository";
131
+ return {
132
+ companionPath: scan.companionPath,
133
+ repository: { id: repo.id, path: repoPath },
134
+ health,
135
+ ambiguous,
136
+ };
137
+ }),
138
+ );
139
+
140
+ companions.sort((a, b) => a.path.localeCompare(b.path));
141
+ pairings.sort(
142
+ (a, b) =>
143
+ a.companionPath.localeCompare(b.companionPath) ||
144
+ a.repository.id.localeCompare(b.repository.id) ||
145
+ a.repository.path.localeCompare(b.repository.path),
146
+ );
147
+
148
+ return { schemaVersion: WORKSPACE_INVENTORY_SCHEMA_VERSION, companions, pairings };
149
+ }
@@ -0,0 +1,80 @@
1
+ import path from "node:path";
2
+
3
+ import { readCompanionRegistry } from "./companion-registry-reader";
4
+ import { pathIsDirectory } from "./repo-local-registry";
5
+ import { MateError } from "./types";
6
+ import { writeWorkspaceDocument } from "./editor";
7
+
8
+ /** Envelope version for `mate workspace materialize --json`. */
9
+ export const MATERIALIZED_WORKSPACE_SCHEMA_VERSION = 1;
10
+
11
+ export interface MaterializedWorkspaceV1 {
12
+ schemaVersion: 1;
13
+ workspacePath: string;
14
+ folders: [string, string];
15
+ }
16
+
17
+ export class WorkspacePairingNotFoundError extends MateError {
18
+ constructor(repositoryId: string, companionPath: string) {
19
+ super(`mate: repository "${repositoryId}" is not linked by companion: ${companionPath}`);
20
+ }
21
+ }
22
+
23
+ export class WorkspaceRootUnavailableError extends MateError {
24
+ constructor(
25
+ readonly root: "companion" | "repository",
26
+ readonly rootPath: string,
27
+ ) {
28
+ super(`mate: ${root} path does not exist: ${rootPath}`);
29
+ }
30
+ }
31
+
32
+ export interface MaterializeWorkspaceRequest {
33
+ repositoryId: string;
34
+ companionPath: string;
35
+ }
36
+
37
+ export interface MaterializeWorkspaceDeps {
38
+ isDirectory: (candidatePath: string) => Promise<boolean>;
39
+ readCompanionRegistry: typeof readCompanionRegistry;
40
+ writeWorkspaceDocument: typeof writeWorkspaceDocument;
41
+ }
42
+
43
+ export function defaultMaterializeWorkspaceDeps(): MaterializeWorkspaceDeps {
44
+ return { isDirectory: pathIsDirectory, readCompanionRegistry, writeWorkspaceDocument };
45
+ }
46
+
47
+ /**
48
+ * Resolves an explicit companion-path/repository-id pairing, validates both
49
+ * roots exist, and writes the shared workspace document — without touching
50
+ * the registry or spawning an editor. Distinct from `injectEditorFolder`,
51
+ * which additionally launches/reloads an editor CLI.
52
+ */
53
+ export async function materializeWorkspace(
54
+ request: MaterializeWorkspaceRequest,
55
+ deps: MaterializeWorkspaceDeps = defaultMaterializeWorkspaceDeps(),
56
+ ): Promise<MaterializedWorkspaceV1> {
57
+ const companionPath = path.resolve(request.companionPath);
58
+
59
+ if (!(await deps.isDirectory(companionPath))) {
60
+ throw new WorkspaceRootUnavailableError("companion", companionPath);
61
+ }
62
+
63
+ const { repos } = await deps.readCompanionRegistry(companionPath).catch(() => ({ repos: [] }));
64
+ const repository = repos.find((repo) => repo.id === request.repositoryId);
65
+ if (!repository) {
66
+ throw new WorkspacePairingNotFoundError(request.repositoryId, companionPath);
67
+ }
68
+
69
+ const repositoryPath = path.resolve(repository.path);
70
+ if (!(await deps.isDirectory(repositoryPath))) {
71
+ throw new WorkspaceRootUnavailableError("repository", repositoryPath);
72
+ }
73
+
74
+ const { workspacePath, folders } = await deps.writeWorkspaceDocument(
75
+ companionPath,
76
+ repositoryPath,
77
+ );
78
+
79
+ return { schemaVersion: MATERIALIZED_WORKSPACE_SCHEMA_VERSION, workspacePath, folders };
80
+ }