@uniqbit/mate-core 0.15.5-canary.0 → 0.15.5-canary.2

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/open.ts +5 -3
  9. package/src/cli/commands/workspace/workspace.ts +22 -0
  10. package/src/cli/main.ts +20 -1
  11. package/src/cli/usage.ts +8 -0
  12. package/src/cli/write-json-stdout.ts +14 -0
  13. package/src/lib/orchestrator/companion-registry-reader.ts +37 -0
  14. package/src/lib/orchestrator/{working-repo-store.ts → companion-registry-store.ts} +14 -7
  15. package/src/lib/orchestrator/companion-resolver.ts +50 -2
  16. package/src/lib/orchestrator/companion-store.ts +39 -10
  17. package/src/lib/orchestrator/editor.ts +32 -80
  18. package/src/lib/orchestrator/framework-context.ts +33 -3
  19. package/src/lib/orchestrator/global-config-store.ts +25 -0
  20. package/src/lib/orchestrator/repo-local-registry.ts +2 -22
  21. package/src/lib/orchestrator/types.ts +6 -2
  22. package/src/lib/orchestrator/workspace-inventory.ts +149 -0
  23. package/src/lib/orchestrator/workspace-materialize.ts +80 -0
  24. package/src/lib/orchestrator/yaml-file-store.ts +10 -1
  25. package/src/templates/root/TEMPLATE_AGENTS.md +0 -8
  26. package/src/templates/root/TEMPLATE_CLAUDE.md +0 -10
  27. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +44 -380
  28. package/src/tools/setup/dynamic-plugins/install.ts +18 -14
  29. package/src/tools/setup/plugins/gitignore.ts +4 -1
  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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniqbit/mate-core",
3
- "version": "0.15.5-canary.0",
3
+ "version": "0.15.5-canary.2",
4
4
  "description": "Core framework and plugin APIs for Mate.",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -2,6 +2,8 @@ import { spawnSync } from "node:child_process";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
 
5
+ import { parse } from "yaml";
6
+
5
7
  import { hasOpenspecCapability } from "../../../../lib/orchestrator/capabilities";
6
8
  import { runIndexCapCommand } from "../../cap/index-cmd";
7
9
  import type { ArtifactFinisher, FinishContext } from "./finisher";
@@ -57,6 +59,171 @@ async function commitPathsForArchive(
57
59
  ];
58
60
  }
59
61
 
62
+ const BOM = "";
63
+
64
+ interface ScopePair {
65
+ repository: string;
66
+ area: string;
67
+ }
68
+
69
+ type ProjectionResult =
70
+ | { ok: true; repository: string; areas: string[] }
71
+ | { ok: false; reason: string };
72
+
73
+ function parseDeltaScopes(source: string): ScopePair[] {
74
+ const text = source.startsWith(BOM) ? source.slice(BOM.length) : source;
75
+ const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(text);
76
+ if (!match) return [];
77
+
78
+ try {
79
+ const parsed = parse(match[1]) as unknown;
80
+ if (!parsed || typeof parsed !== "object") return [];
81
+ const scopes = (parsed as Record<string, unknown>).scopes;
82
+ if (!Array.isArray(scopes)) return [];
83
+ return scopes.flatMap((entry) => {
84
+ if (!entry || typeof entry !== "object") return [];
85
+ const pair = entry as Record<string, unknown>;
86
+ return typeof pair.repository === "string" && typeof pair.area === "string"
87
+ ? [{ repository: pair.repository, area: pair.area }]
88
+ : [];
89
+ });
90
+ } catch {
91
+ return [];
92
+ }
93
+ }
94
+
95
+ function projectDeltaScopes(scopes: ScopePair[]): ProjectionResult {
96
+ if (scopes.length === 0) return { ok: false, reason: "delta declares no scopes entries" };
97
+ const repositories = [...new Set(scopes.map((entry) => entry.repository))];
98
+ if (repositories.length > 1) {
99
+ return {
100
+ ok: false,
101
+ reason: `delta names ${repositories.length} repositories (${repositories.join(", ")}); a spec names exactly one`,
102
+ };
103
+ }
104
+ return {
105
+ ok: true,
106
+ repository: repositories[0],
107
+ areas: [...new Set(scopes.map((entry) => entry.area))],
108
+ };
109
+ }
110
+
111
+ async function archivedChangeUsesMateV1(
112
+ companionPath: string,
113
+ anchorName: string,
114
+ ): Promise<boolean> {
115
+ try {
116
+ const metadataPath = path.join(
117
+ companionPath,
118
+ "openspec",
119
+ "changes",
120
+ "archive",
121
+ anchorName,
122
+ ".openspec.yaml",
123
+ );
124
+ const parsed = parse(await fs.readFile(metadataPath, "utf8")) as unknown;
125
+ if (!parsed || typeof parsed !== "object") return false;
126
+ const schema = (parsed as Record<string, unknown>).schema;
127
+ return typeof schema === "string" && schema.trim() === "mate-v1";
128
+ } catch {
129
+ return false;
130
+ }
131
+ }
132
+
133
+ /** Scope keys are omitted when the delta had none — a partial block beats no frontmatter. */
134
+ function renderCanonicalFrontmatter(
135
+ capability: string,
136
+ scope: { repository: string; areas: string[] } | null,
137
+ ): string {
138
+ return [
139
+ "---",
140
+ "type: spec",
141
+ `capability: ${capability}`,
142
+ ...(scope ? [`repository: ${scope.repository}`, `areas: [${scope.areas.join(", ")}]`] : []),
143
+ "tags: [openspec/spec]",
144
+ "---",
145
+ "",
146
+ "",
147
+ ].join("\n");
148
+ }
149
+
150
+ /**
151
+ * Prepends canonical frontmatter to main specs born during this archive.
152
+ *
153
+ * `openspec archive` rebuilds a brand-new main spec from a skeleton with no frontmatter slot,
154
+ * so scope metadata dies exactly once, at spec birth; existing specs keep theirs because only
155
+ * requirement blocks are spliced. Best-effort by design — the archive already succeeded, so a
156
+ * failure here warns rather than stranding the change archived-but-unfinished.
157
+ */
158
+ async function reconcileMainSpecFrontmatter(
159
+ companionPath: string,
160
+ anchorName: string,
161
+ ): Promise<string[]> {
162
+ if (!(await archivedChangeUsesMateV1(companionPath, anchorName))) return [];
163
+
164
+ const deltaSpecsDir = path.join(
165
+ companionPath,
166
+ "openspec",
167
+ "changes",
168
+ "archive",
169
+ anchorName,
170
+ "specs",
171
+ );
172
+ const reconciled: string[] = [];
173
+
174
+ const walk = async (directory: string, relative = ""): Promise<void> => {
175
+ let entries;
176
+ try {
177
+ entries = await fs.readdir(directory, { withFileTypes: true });
178
+ } catch {
179
+ return;
180
+ }
181
+ for (const entry of entries) {
182
+ const entryRelative = path.posix.join(relative, entry.name);
183
+ if (entry.isDirectory()) {
184
+ await walk(path.join(directory, entry.name), entryRelative);
185
+ continue;
186
+ }
187
+ if (!entry.isFile() || entry.name !== "spec.md") continue;
188
+
189
+ const capability = path.posix.dirname(entryRelative);
190
+ if (capability === ".") continue;
191
+ const canonicalRelative = path.posix.join("openspec", "specs", entryRelative);
192
+ const canonicalPath = path.join(companionPath, canonicalRelative);
193
+
194
+ try {
195
+ const canonical = await fs.readFile(canonicalPath, "utf8");
196
+ if (canonical.replace(BOM, "").startsWith("---")) continue;
197
+
198
+ const deltaSource = await fs.readFile(path.join(directory, entry.name), "utf8");
199
+ const scopes = parseDeltaScopes(deltaSource);
200
+ const projected = projectDeltaScopes(scopes);
201
+
202
+ /** Multi-repository is invalid input, so skip; absent scopes still earn a partial block. */
203
+ if (!projected.ok && scopes.length > 0) {
204
+ process.stderr.write(
205
+ `mate: skipped frontmatter for ${canonicalRelative}: ${projected.reason}\n`,
206
+ );
207
+ continue;
208
+ }
209
+ const head = renderCanonicalFrontmatter(
210
+ capability,
211
+ projected.ok ? { repository: projected.repository, areas: projected.areas } : null,
212
+ );
213
+ await fs.writeFile(canonicalPath, head + canonical.replace(BOM, ""), "utf8");
214
+ reconciled.push(canonicalRelative);
215
+ } catch (error) {
216
+ process.stderr.write(
217
+ `mate: could not reconcile frontmatter for ${canonicalRelative}: ${String(error)}\n`,
218
+ );
219
+ }
220
+ }
221
+ };
222
+
223
+ await walk(deltaSpecsDir);
224
+ return reconciled;
225
+ }
226
+
60
227
  async function capSync(context: FinishContext): Promise<boolean> {
61
228
  const previous = process.exitCode;
62
229
  process.exitCode = 0;
@@ -92,7 +259,10 @@ async function capSync(context: FinishContext): Promise<boolean> {
92
259
  * to `openspec/`. Resumable detection reads the dated `openspec/changes/archive/` folder
93
260
  * openspec actually created so the tag is never a computed date.
94
261
  */
95
- export function openspecFinisher(contextOrPath: FinishContext | string): ArtifactFinisher {
262
+ export function openspecFinisher(
263
+ contextOrPath: FinishContext | string,
264
+ runCommand: typeof run = run,
265
+ ): ArtifactFinisher {
96
266
  const context: FinishContext =
97
267
  typeof contextOrPath === "string"
98
268
  ? { companionPath: contextOrPath, repositoryId: "" }
@@ -106,7 +276,7 @@ export function openspecFinisher(contextOrPath: FinishContext | string): Artifac
106
276
  return hasOpenspecCapability(capabilities);
107
277
  },
108
278
  async validate(name) {
109
- const res = run(companionPath, ["openspec", "validate", name, "--json"]);
279
+ const res = runCommand(companionPath, ["openspec", "validate", name, "--json"]);
110
280
  try {
111
281
  const parsed = JSON.parse(res.stdout) as {
112
282
  items?: Array<{ id: string; valid: boolean; issues?: Array<{ message: string }> }>;
@@ -124,7 +294,7 @@ export function openspecFinisher(contextOrPath: FinishContext | string): Artifac
124
294
  }
125
295
  },
126
296
  async isComplete(name) {
127
- const res = run(companionPath, ["openspec", "list", "--json"]);
297
+ const res = runCommand(companionPath, ["openspec", "list", "--json"]);
128
298
  try {
129
299
  const parsed = JSON.parse(res.stdout) as {
130
300
  changes?: Array<{ name: string; completedTasks: number; totalTasks: number }>;
@@ -157,13 +327,12 @@ export function openspecFinisher(contextOrPath: FinishContext | string): Artifac
157
327
  }
158
328
  // Latest dated folder wins if the same change name was ever archived twice.
159
329
  const anchorName = matches[matches.length - 1];
160
- return {
161
- anchorName,
162
- commitPaths: await commitPathsForArchive(companionPath, name, anchorName),
163
- };
330
+ const commitPaths = await commitPathsForArchive(companionPath, name, anchorName);
331
+ await reconcileMainSpecFrontmatter(companionPath, anchorName);
332
+ return { anchorName, commitPaths };
164
333
  },
165
334
  async produce(name) {
166
- const res = run(companionPath, ["openspec", "archive", name, "--yes"]);
335
+ const res = runCommand(companionPath, ["openspec", "archive", name, "--yes"]);
167
336
  if (res.status !== 0) {
168
337
  return {
169
338
  ok: false,
@@ -176,12 +345,11 @@ export function openspecFinisher(contextOrPath: FinishContext | string): Artifac
176
345
  if (!match) {
177
346
  return { ok: false, produced: null, message: "could not detect archived folder name" };
178
347
  }
348
+ const commitPaths = await commitPathsForArchive(companionPath, name, match[1]);
349
+ await reconcileMainSpecFrontmatter(companionPath, match[1]);
179
350
  return {
180
351
  ok: true,
181
- produced: {
182
- anchorName: match[1],
183
- commitPaths: await commitPathsForArchive(companionPath, name, match[1]),
184
- },
352
+ produced: { anchorName: match[1], commitPaths },
185
353
  message: res.stdout.trim(),
186
354
  };
187
355
  },
@@ -12,11 +12,9 @@ import {
12
12
  import { extractRepoName } from "../artifact/extract-repo-name";
13
13
  import { GlobalConfigStore } from "../../../lib/orchestrator/global-config-store";
14
14
  import { CompanionResolver } from "../../../lib/orchestrator/companion-resolver";
15
+ import { companionRootedStore } from "../../../lib/orchestrator/companion-store";
15
16
  import { inspectSetupPreflight } from "../../../lib/orchestrator/setup-preflight";
16
- import {
17
- findDescendantRepoLocalRegistries,
18
- writeRepoLocalRegistryEntry,
19
- } from "../../../lib/orchestrator/repo-local-registry";
17
+ import { findDescendantRepoLocalRegistries } from "../../../lib/orchestrator/repo-local-registry";
20
18
  import { runSetupFlowAtPath } from "../setup";
21
19
  import { runInstallCommand } from "../install";
22
20
  import type { CompanionSource, LinkedRepository } from "../../../lib/orchestrator/types";
@@ -240,17 +238,15 @@ export async function runCompanionLinkCommandWithDeps(
240
238
  path: cwd,
241
239
  };
242
240
 
241
+ // Writes both the companion-side registry entry (companionRootedStore, the
242
+ // source of truth for `mate workspace list`) and the repo-local pointer
243
+ // (CompanionStore's internal dual write) in one call.
243
244
  const registerRepository =
244
245
  deps.registerRepository ??
245
- (async (nextRepository, options) => {
246
- await writeRepoLocalRegistryEntry(
247
- nextRepository.path,
248
- options.companionPath,
249
- nextRepository,
250
- options.companionSource,
251
- );
252
- return nextRepository;
253
- });
246
+ ((nextRepository, options) =>
247
+ companionRootedStore(options.companionPath).registerRepository(nextRepository, {
248
+ companionSource: options.companionSource,
249
+ }));
254
250
 
255
251
  // Managed companions are git-backed; explicitly pasted paths retain local provenance.
256
252
  const persistedSource: CompanionSource = companionSource === "local" ? "local" : "git";
@@ -0,0 +1,125 @@
1
+ import { execFile } from "node:child_process";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { promisify } from "node:util";
5
+
6
+ import { parse } from "yaml";
7
+
8
+ import { FRAMEWORK_NAME } from "../../../framework";
9
+ import type { RootKind } from "../../../lib/orchestrator/root-context";
10
+ import {
11
+ collectWorkspaceInventory,
12
+ type WorkspaceInventoryV1,
13
+ } from "../../../lib/orchestrator/workspace-inventory";
14
+ import {
15
+ cleanupWorkingRepository,
16
+ type WorkingRepoCleanupResult,
17
+ } from "../../../tools/setup/working-repo-cleanup";
18
+
19
+ const execFileAsync = promisify(execFile);
20
+
21
+ async function canonicalPath(candidatePath: string): Promise<string> {
22
+ try {
23
+ return await fs.realpath(candidatePath);
24
+ } catch {
25
+ return path.resolve(candidatePath);
26
+ }
27
+ }
28
+
29
+ export interface WorkingCleanupCommandDeps {
30
+ cwd: string;
31
+ resolveGitRoot: (cwd: string) => Promise<string | null>;
32
+ localRootKind: (repoPath: string) => Promise<RootKind | null>;
33
+ collectInventory: () => Promise<WorkspaceInventoryV1>;
34
+ cleanup: (
35
+ repoPath: string,
36
+ registeredCompanionPaths: string[],
37
+ ) => Promise<WorkingRepoCleanupResult>;
38
+ }
39
+
40
+ async function resolveGitRoot(cwd: string): Promise<string | null> {
41
+ try {
42
+ const { stdout } = await execFileAsync("git", ["-C", cwd, "rev-parse", "--show-toplevel"]);
43
+ return canonicalPath(stdout.trim());
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ async function localRootKind(repoPath: string): Promise<RootKind | null> {
50
+ try {
51
+ const raw = await fs.readFile(
52
+ path.join(repoPath, `.${FRAMEWORK_NAME}`, "config", "framework.yaml"),
53
+ "utf8",
54
+ );
55
+ const config = parse(raw) as { type?: string } | null;
56
+ if (config?.type === "working" || config?.type === "hub") return config.type;
57
+ return "companion";
58
+ } catch (error) {
59
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
60
+ return "companion";
61
+ }
62
+ }
63
+
64
+ const defaultDeps: WorkingCleanupCommandDeps = {
65
+ cwd: process.cwd(),
66
+ resolveGitRoot,
67
+ localRootKind,
68
+ collectInventory: collectWorkspaceInventory,
69
+ cleanup: cleanupWorkingRepository,
70
+ };
71
+
72
+ export async function runWorkingCleanupCommand(
73
+ argv: string[],
74
+ deps: WorkingCleanupCommandDeps = defaultDeps,
75
+ ): Promise<void> {
76
+ if (argv.length > 0) {
77
+ process.stderr.write(`${FRAMEWORK_NAME}: usage: ${FRAMEWORK_NAME} working cleanup\n`);
78
+ process.exitCode = 1;
79
+ return;
80
+ }
81
+ const repoPath = await deps.resolveGitRoot(deps.cwd);
82
+ if (!repoPath) {
83
+ process.stderr.write(
84
+ `${FRAMEWORK_NAME}: working cleanup must run from a linked Git working repository\n`,
85
+ );
86
+ process.exitCode = 1;
87
+ return;
88
+ }
89
+ const kind = await deps.localRootKind(repoPath);
90
+ if (kind === "companion" || kind === "hub") {
91
+ process.stderr.write(
92
+ `${FRAMEWORK_NAME}: working cleanup is unavailable in a ${kind} repository\n`,
93
+ );
94
+ process.exitCode = 1;
95
+ return;
96
+ }
97
+ const inventory = await deps.collectInventory();
98
+ const resolvedPairings = await Promise.all(
99
+ inventory.pairings.map(async (pairing) => ({
100
+ pairing,
101
+ repositoryPath: await canonicalPath(pairing.repository.path),
102
+ })),
103
+ );
104
+ const matches = resolvedPairings
105
+ .filter(({ repositoryPath }) => repositoryPath === repoPath)
106
+ .map(({ pairing }) => pairing);
107
+ if (matches.length === 0) {
108
+ process.stderr.write(
109
+ `${FRAMEWORK_NAME}: working cleanup must run from a linked Git working repository\n`,
110
+ );
111
+ process.exitCode = 1;
112
+ return;
113
+ }
114
+ const companionPaths = [...new Set(matches.map((pairing) => pairing.companionPath))];
115
+ const result = await deps.cleanup(repoPath, companionPaths);
116
+ if (!result.changed) {
117
+ console.log(`${FRAMEWORK_NAME}: working repository already clean`);
118
+ return;
119
+ }
120
+ const details = [
121
+ ...(result.removed.length > 0 ? [`removed: ${result.removed.join(", ")}`] : []),
122
+ ...(result.updated.length > 0 ? [`updated: ${result.updated.join(", ")}`] : []),
123
+ ];
124
+ console.log(`${FRAMEWORK_NAME}: cleaned working repository (${details.join("; ")})`);
125
+ }
@@ -0,0 +1,15 @@
1
+ import { usage } from "../../usage";
2
+ import { runWorkingCleanupCommand } from "./cleanup";
3
+
4
+ export async function runWorkingCommand(
5
+ subcommand: string | undefined,
6
+ argv: string[],
7
+ ): Promise<void> {
8
+ if (subcommand === "cleanup") {
9
+ await runWorkingCleanupCommand(argv);
10
+ return;
11
+ }
12
+ console.error(`Unknown working command: ${subcommand ?? ""}`);
13
+ console.error(usage());
14
+ process.exitCode = 1;
15
+ }
@@ -0,0 +1,25 @@
1
+ import { collectWorkspaceInventory } from "../../../lib/orchestrator/workspace-inventory";
2
+ import { writeJsonStdout } from "../../write-json-stdout";
3
+
4
+ export const workspaceListCommandDeps = {
5
+ collectWorkspaceInventory: () => collectWorkspaceInventory(),
6
+ };
7
+
8
+ /**
9
+ * @command mate workspace list
10
+ * @description Prints the aggregate inventory of registered companions and
11
+ * their linked working-repository pairings as one JSON document. Runs
12
+ * without a linked working repository or any active companion context.
13
+ * @flags
14
+ * - `--json` — required; this command only supports JSON output.
15
+ */
16
+ export async function runWorkspaceListCommand(argv: string[] = []): Promise<void> {
17
+ if (!argv.includes("--json")) {
18
+ process.stderr.write("mate: `workspace list` requires --json.\n");
19
+ process.exitCode = 1;
20
+ return;
21
+ }
22
+
23
+ const inventory = await workspaceListCommandDeps.collectWorkspaceInventory();
24
+ await writeJsonStdout(inventory);
25
+ }
@@ -0,0 +1,46 @@
1
+ import { materializeWorkspace } from "../../../lib/orchestrator/workspace-materialize";
2
+ import { writeJsonStdout } from "../../write-json-stdout";
3
+
4
+ export const workspaceMaterializeCommandDeps = {
5
+ materializeWorkspace: (repositoryId: string, companionPath: string) =>
6
+ materializeWorkspace({ repositoryId, companionPath }),
7
+ };
8
+
9
+ function readFlag(argv: string[], name: string): string | undefined {
10
+ const index = argv.indexOf(name);
11
+ return index >= 0 ? argv[index + 1] : undefined;
12
+ }
13
+
14
+ /**
15
+ * @command mate workspace materialize
16
+ * @description Validates an explicit repository/companion pairing, writes
17
+ * the generated `.mate/workspace.code-workspace` file, and prints its path
18
+ * and folder order as one JSON document. Never spawns or focuses an editor.
19
+ * @flags
20
+ * - `--repository <id>` — required; the linked repository id to pair.
21
+ * - `--companion <path>` — required; the companion path that must link it.
22
+ * - `--json` — required; this command only supports JSON output.
23
+ */
24
+ export async function runWorkspaceMaterializeCommand(argv: string[] = []): Promise<void> {
25
+ const repositoryId = readFlag(argv, "--repository");
26
+ const companionPath = readFlag(argv, "--companion");
27
+
28
+ if (!repositoryId || !companionPath || !argv.includes("--json")) {
29
+ process.stderr.write(
30
+ "mate: `workspace materialize` requires --repository <id>, --companion <path>, and --json.\n",
31
+ );
32
+ process.exitCode = 1;
33
+ return;
34
+ }
35
+
36
+ try {
37
+ const result = await workspaceMaterializeCommandDeps.materializeWorkspace(
38
+ repositoryId,
39
+ companionPath,
40
+ );
41
+ await writeJsonStdout(result);
42
+ } catch (error) {
43
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
44
+ process.exitCode = 1;
45
+ }
46
+ }
@@ -17,8 +17,8 @@ export const workspaceOpenCommandDeps = {
17
17
  /**
18
18
  * @command mate companion open
19
19
  * @description Updates the managed `.mate/workspace.code-workspace` file for
20
- * the active working repository and companion. When that workspace is open,
21
- * the editor reloads it in place; otherwise the command falls back to `--add`.
20
+ * the active working repository and companion, then adds both folders to the
21
+ * current editor window via `--add`.
22
22
  * @remarks Throws if the active repository (from `MATE_REPO_ID`) isn't found
23
23
  * in the registry; sets a non-zero exit code if the editor CLI is unavailable,
24
24
  * or if the working repo is linked from multiple companions and the ambiguity
@@ -59,5 +59,7 @@ export async function runWorkspaceOpenCommand(): Promise<void> {
59
59
  return;
60
60
  }
61
61
 
62
- console.log('Workspace: updated .mate/workspace.code-workspace; open it via "Open Workspace"');
62
+ console.log(
63
+ "Workspace: added to the current editor window; persistent file at .mate/workspace.code-workspace",
64
+ );
63
65
  }
@@ -0,0 +1,22 @@
1
+ import { usage } from "../../usage";
2
+ import { runWorkspaceListCommand } from "./list";
3
+ import { runWorkspaceMaterializeCommand } from "./materialize";
4
+
5
+ /** Dispatches `mate workspace <subcommand>`. Distinct from `mate companion open`. */
6
+ export async function runWorkspaceCommand(
7
+ subcommand: string | undefined,
8
+ argv: string[],
9
+ ): Promise<void> {
10
+ switch (subcommand) {
11
+ case "list":
12
+ await runWorkspaceListCommand(argv);
13
+ return;
14
+ case "materialize":
15
+ await runWorkspaceMaterializeCommand(argv);
16
+ return;
17
+ default:
18
+ console.error(`Unknown workspace command: ${subcommand ?? ""}`);
19
+ console.error(usage());
20
+ process.exitCode = 1;
21
+ }
22
+ }
package/src/cli/main.ts CHANGED
@@ -17,6 +17,8 @@ import { runLaunchOpenCodeCommand } from "./commands/launch/opencode";
17
17
  import { runPluginCommand } from "./commands/plugin/plugin";
18
18
  import { runReportCommand } from "./commands/report";
19
19
  import { runUpdateCommand } from "./commands/update";
20
+ import { runWorkspaceCommand } from "./commands/workspace/workspace";
21
+ import { runWorkingCommand } from "./commands/working/working";
20
22
  import { runInstallCommand } from "./commands/install";
21
23
  import { inspectInstallPreflight } from "../lib/install";
22
24
  import { resolveRootContext } from "../lib/orchestrator/root-context";
@@ -78,12 +80,19 @@ export async function main(argv = process.argv, deps: MainDeps = mainDeps): Prom
78
80
  const isPluginCommand =
79
81
  command === "cap" && findPluginCliCommand(subcommand, rest[0]) !== undefined;
80
82
 
83
+ // `workspace list`/`workspace materialize` are machine-JSON contracts an
84
+ // editor extension may poll frequently; their stdout must be pure JSON,
85
+ // and the un-awaited scheduleBackgroundCheck() has been observed to race
86
+ // a large final stdout write against process exit, truncating it. Both
87
+ // reasons put them in the same stdout-owning bucket as plugin commands.
88
+ const ownsStdout = isPluginCommand || command === "workspace";
89
+
81
90
  // Every dispatch case opens with a gate() call declaring what the command
82
91
  // needs before it may run. Declared gates run in fixed order — update
83
92
  // enforcement, companion selection, install preflight — and a blocked gate
84
93
  // sets the exit code, so callers only need `if (!(await gate(...))) return`.
85
94
  const gate = async (needs: GateNeeds): Promise<boolean> => {
86
- if (!isPluginCommand) {
95
+ if (!ownsStdout) {
87
96
  const updateStore = new UpdateStateStore();
88
97
  scheduleBackgroundCheck(updateStore);
89
98
  if (needs.updateGuard && (await enforceUpdateIfRequired(updateStore))) {
@@ -206,6 +215,16 @@ export async function main(argv = process.argv, deps: MainDeps = mainDeps): Prom
206
215
  if (!(await gate({}))) return;
207
216
  await runUpdateCommand(argv.slice(3));
208
217
  return;
218
+ case "workspace":
219
+ // list/materialize are context-independent: no companion, install, or
220
+ // update gating — an editor extension may call these frequently.
221
+ if (!(await gate({}))) return;
222
+ await runWorkspaceCommand(subcommand, rest);
223
+ return;
224
+ case "working":
225
+ if (!(await gate({}))) return;
226
+ await runWorkingCommand(subcommand, rest);
227
+ return;
209
228
  default:
210
229
  // Unknown commands fail fast: no case matched, so no gate ever ran.
211
230
  console.error(`Unknown command: ${command}`);
package/src/cli/usage.ts CHANGED
@@ -16,6 +16,9 @@ export function usage(): string {
16
16
  ` ${n} companion list`,
17
17
  ` ${n} companion open`,
18
18
  ` ${n} companion tui`,
19
+ ` ${n} workspace list --json`,
20
+ ` ${n} workspace materialize --repository ID --companion PATH --json`,
21
+ ` ${n} working cleanup`,
19
22
  ` ${n} hub init [folder]`,
20
23
  ` ${n} hub add [source] [--id ID] [--path PATH]`,
21
24
  ` ${n} hub sync [--json] (companions + hub plugins)`,
@@ -33,6 +36,11 @@ export function usage(): string {
33
36
  ` ${n} update`,
34
37
  ` ${n} update --check`,
35
38
  "",
39
+ "Working repository state:",
40
+ " linked repositories locally exclude root .claude/, .opencode/, and .agents/ directories",
41
+ ` ${n} working cleanup removes Mate-owned local integration without resetting product work`,
42
+ " tracked files and capability data remain untouched; link or launch recreates required state",
43
+ "",
36
44
  "Doctor states:",
37
45
  " linked-working-repository — cwd is inside a registered working repository",
38
46
  " companion-repository — cwd is the companion repository itself",
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Writes one JSON document to stdout and resolves only once the underlying
3
+ * write completes. `console.log`/bare `process.stdout.write` on a large
4
+ * payload can be truncated when stdout is a pipe and the process exits
5
+ * before the write flushes (observed at exactly the 64KB pipe boundary) —
6
+ * awaiting the write callback is what actually guarantees delivery.
7
+ */
8
+ export function writeJsonStdout(data: unknown): Promise<void> {
9
+ return new Promise((resolve, reject) => {
10
+ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`, (error) =>
11
+ error ? reject(error) : resolve(),
12
+ );
13
+ });
14
+ }
@@ -0,0 +1,37 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import { parse } from "yaml";
5
+
6
+ import { FRAMEWORK_NAME } from "../../framework";
7
+ import type { LinkedRepository } from "./types";
8
+
9
+ export function companionRegistryPath(companionPath: string): string {
10
+ return path.join(path.resolve(companionPath), `.${FRAMEWORK_NAME}`, "config", "registry.yaml");
11
+ }
12
+
13
+ export interface CompanionRegistryReadResult {
14
+ repos: LinkedRepository[];
15
+ }
16
+
17
+ /**
18
+ * Parses a companion's `registry.yaml` without the writes {@link
19
+ * CompanionRegistryStore.load} performs (legacy-field migration, stale-pointer
20
+ * cleanup). Callers that must not mutate registry state on a read (e.g.
21
+ * inventory, materialization) use this instead. Throws ENOENT when the file
22
+ * doesn't exist; throws on unparsable YAML.
23
+ */
24
+ export async function readCompanionRegistry(
25
+ companionPath: string,
26
+ ): Promise<CompanionRegistryReadResult> {
27
+ const raw = await fs.readFile(companionRegistryPath(companionPath), "utf8");
28
+ const parsed = parse(raw) as { repos?: unknown } | null;
29
+ const rawRepos = Array.isArray(parsed?.repos) ? parsed.repos : [];
30
+ const repos = rawRepos
31
+ .filter(
32
+ (entry): entry is LinkedRepository =>
33
+ Boolean(entry) && typeof entry.id === "string" && typeof entry.path === "string",
34
+ )
35
+ .map(({ id, path: repoPath }) => ({ id, path: repoPath }));
36
+ return { repos };
37
+ }