@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
@@ -70,6 +70,9 @@ export function collectManagedGitignoreEntries(ctx: SetupContext, plugins: Plugi
70
70
  const entries = [
71
71
  "node_modules/",
72
72
  `.${FRAMEWORK_NAME}/plugins/.local/`,
73
+ // Holds absolute local paths of every linked working repo; never shared
74
+ // across machines or committed to the companion repo.
75
+ `.${FRAMEWORK_NAME}/config/registry.yaml`,
73
76
  ".mcp.json*",
74
77
  ...plugins
75
78
  .filter((p) => p.kind !== "root" && (p.isEnabled(ctx.config) || p.persistGitignoreEntries))
@@ -16,8 +16,11 @@ import {
16
16
  upsertManagedBlock,
17
17
  } from "../context-services";
18
18
  import type { CapabilityContributionInput, ProviderPlugin, SetupContext } from "../plugin";
19
- import { resolveGitInfoExcludePath } from "../git-utils";
20
19
  import { mergeDir, pruneEmptyAncestors } from "../utils";
20
+ import {
21
+ ensureWorkingRepoLocalExcludes,
22
+ reconcileWorkingRepoCapabilityExcludes,
23
+ } from "../working-repo-local-state";
21
24
  import {
22
25
  cutFromMarker,
23
26
  stripSectionFromFile,
@@ -59,13 +62,7 @@ async function configureClaudeGuidance(companionPath: string): Promise<void> {
59
62
  );
60
63
  }
61
64
 
62
- const BASE_WORKING_REPO_LOCAL_EXCLUDE_ENTRIES = [".claude/settings.local.json"];
63
- const COMPANION_GITIGNORE_ENTRIES = [...BASE_WORKING_REPO_LOCAL_EXCLUDE_ENTRIES, ".claude/state/"];
64
- // Union of all managed exclude entries — used to identify which lines we own.
65
- const ALL_MANAGED_EXCLUDE_ENTRIES = new Set([
66
- ...BASE_WORKING_REPO_LOCAL_EXCLUDE_ENTRIES,
67
- ...TOKENSAVE_WORKING_REPO_EXCLUDE_ENTRIES,
68
- ]);
65
+ const COMPANION_GITIGNORE_ENTRIES = [".claude/settings.local.json", ".claude/state/"];
69
66
 
70
67
  // Command substrings that mark a working-repo hook group as Mate-managed.
71
68
  // The mate plugin hooks (validate-artifact-path, mate-session-banner,
@@ -151,66 +148,23 @@ function removeManagedHookGroups(
151
148
  return next;
152
149
  }
153
150
 
154
- export async function ensureWorkingRepoLocalExcludes(
155
- workingRepoPath: string,
156
- config: FrameworkConfig,
157
- ): Promise<void> {
158
- const excludePath = await resolveGitInfoExcludePath(workingRepoPath);
159
- if (!excludePath) {
160
- return;
161
- }
162
-
163
- const tokensaveEnabled = (config.capabilities ?? []).some((c) => c.name === "tokensave");
164
- const desiredEntries = new Set([
165
- ...BASE_WORKING_REPO_LOCAL_EXCLUDE_ENTRIES,
166
- ...(tokensaveEnabled ? TOKENSAVE_WORKING_REPO_EXCLUDE_ENTRIES : []),
167
- ]);
168
-
169
- let existing = "";
170
- try {
171
- existing = await fs.readFile(excludePath, "utf8");
172
- } catch {
173
- // Fresh repos may not have created info/exclude yet.
174
- }
175
-
176
- const lines = existing
177
- .split(/\r?\n/)
178
- .map((line) => line.trim())
179
- .filter(Boolean);
180
-
181
- // Reconcile: keep non-managed lines, add desired managed entries, remove undesired.
182
- const nextLines = lines.filter((line) => !ALL_MANAGED_EXCLUDE_ENTRIES.has(line));
183
- for (const entry of desiredEntries) {
184
- if (!nextLines.includes(entry)) {
185
- nextLines.push(entry);
186
- }
187
- }
188
-
189
- const nextContent = nextLines.length > 0 ? nextLines.join("\n") + "\n" : "";
190
- if (nextContent === existing) {
191
- return;
192
- }
193
-
194
- await fs.mkdir(path.dirname(excludePath), { recursive: true });
195
- await fs.writeFile(excludePath, nextContent, "utf8");
196
- }
197
-
198
- async function stripTokensaveClaudeMdAppend(workingRepoPath: string): Promise<void> {
151
+ async function stripTokensaveClaudeMdAppend(workingRepoPath: string): Promise<boolean> {
199
152
  const claudeMdPath = path.join(workingRepoPath, "CLAUDE.md");
200
153
  let content: string;
201
154
  try {
202
155
  content = await fs.readFile(claudeMdPath, "utf8");
203
156
  } catch {
204
- return;
157
+ return false;
205
158
  }
206
159
 
207
160
  const stripped = cutFromMarker(content, TOKENSAVE_CLAUDE_MD_MARKER);
208
- if (stripped === content) return;
161
+ if (stripped === content) return false;
209
162
  if (stripped.length > 0) {
210
163
  await fs.writeFile(claudeMdPath, stripped, "utf8");
211
164
  } else {
212
165
  await fs.unlink(claudeMdPath);
213
166
  }
167
+ return true;
214
168
  }
215
169
 
216
170
  // Resolved path of the companion-owned Claude settings document. This is the
@@ -456,7 +410,15 @@ export async function syncWorkingRepoClaudeSettings(
456
410
  config: FrameworkConfig,
457
411
  globalConfigStore = new GlobalConfigStore(),
458
412
  ): Promise<void> {
459
- await ensureWorkingRepoLocalExcludes(workingRepoPath, config);
413
+ await ensureWorkingRepoLocalExcludes(workingRepoPath);
414
+ const tokensaveEnabled = (config.capabilities ?? []).some(
415
+ (capability) => capability.name === "tokensave",
416
+ );
417
+ await reconcileWorkingRepoCapabilityExcludes(
418
+ workingRepoPath,
419
+ tokensaveEnabled ? TOKENSAVE_WORKING_REPO_EXCLUDE_ENTRIES : [],
420
+ TOKENSAVE_WORKING_REPO_EXCLUDE_ENTRIES,
421
+ );
460
422
  await syncWorkingRepoClaudeAdditionalDirectories(
461
423
  workingRepoPath,
462
424
  companionPath,
@@ -507,6 +469,56 @@ async function syncWorkingRepoClaudeAdditionalDirectories(
507
469
  await writeClaudeSettings(settingsPath, settings);
508
470
  }
509
471
 
472
+ export async function cleanupWorkingRepoClaudeSettings(
473
+ workingRepoPath: string,
474
+ registeredCompanionPaths: string[],
475
+ ): Promise<boolean> {
476
+ const settingsPath = path.join(workingRepoPath, ".claude", "settings.local.json");
477
+ let existing: ClaudeSettings;
478
+ try {
479
+ const parsed = JSON.parse(await fs.readFile(settingsPath, "utf8")) as unknown;
480
+ if (!parsed || typeof parsed !== "object") return stripTokensaveClaudeMdAppend(workingRepoPath);
481
+ existing = parsed as ClaudeSettings;
482
+ } catch (error) {
483
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
484
+ return stripTokensaveClaudeMdAppend(workingRepoPath);
485
+ }
486
+ return false;
487
+ }
488
+
489
+ const settings = removeManagedHookGroups(existing);
490
+ const permissions = { ...settings.permissions };
491
+ const managedPaths = new Set(
492
+ registeredCompanionPaths.map((candidate) => path.resolve(candidate)),
493
+ );
494
+ const currentDirectories = Array.isArray(permissions.additionalDirectories)
495
+ ? permissions.additionalDirectories
496
+ : [];
497
+ const additionalDirectories = currentDirectories.filter(
498
+ (candidate) => !managedPaths.has(path.resolve(candidate)),
499
+ );
500
+ if (additionalDirectories.length > 0) {
501
+ permissions.additionalDirectories = additionalDirectories;
502
+ } else {
503
+ delete permissions.additionalDirectories;
504
+ }
505
+ if (Object.keys(permissions).length > 0) {
506
+ settings.permissions = permissions;
507
+ } else {
508
+ delete settings.permissions;
509
+ }
510
+
511
+ const settingsChanged = JSON.stringify(settings) !== JSON.stringify(existing);
512
+ if (settingsChanged && Object.keys(settings).length === 0) {
513
+ await fs.unlink(settingsPath);
514
+ await pruneEmptyAncestors(path.join(workingRepoPath, ".claude"), workingRepoPath);
515
+ } else if (settingsChanged) {
516
+ await writeClaudeSettings(settingsPath, settings);
517
+ }
518
+ const guidanceChanged = await stripTokensaveClaudeMdAppend(workingRepoPath);
519
+ return settingsChanged || guidanceChanged;
520
+ }
521
+
510
522
  async function configureClaude(companionPath: string): Promise<void> {
511
523
  // Mate hooks are delivered by the bundled Claude plugin at launch; nothing
512
524
  // is copied into `companion/.claude/hooks/` anymore. Stale copies from
@@ -0,0 +1,40 @@
1
+ import fs from "node:fs/promises";
2
+
3
+ import { repoLocalDirPath } from "../../lib/orchestrator/repo-local-registry";
4
+ import { cleanupWorkingRepoClaudeSettings } from "./providers/claude";
5
+ import { removeWorkingRepoLocalExcludes } from "./working-repo-local-state";
6
+
7
+ export interface WorkingRepoCleanupResult {
8
+ changed: boolean;
9
+ removed: string[];
10
+ updated: string[];
11
+ }
12
+
13
+ async function pathExists(candidatePath: string): Promise<boolean> {
14
+ try {
15
+ await fs.access(candidatePath);
16
+ return true;
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ export async function cleanupWorkingRepository(
23
+ repoPath: string,
24
+ registeredCompanionPaths: string[],
25
+ ): Promise<WorkingRepoCleanupResult> {
26
+ const updated: string[] = [];
27
+ const removed: string[] = [];
28
+ if (await cleanupWorkingRepoClaudeSettings(repoPath, registeredCompanionPaths)) {
29
+ updated.push("claude-settings");
30
+ }
31
+ if (await removeWorkingRepoLocalExcludes(repoPath)) {
32
+ removed.push("git-excludes");
33
+ }
34
+ const localDir = repoLocalDirPath(repoPath);
35
+ if (await pathExists(localDir)) {
36
+ await fs.rm(localDir, { recursive: true, force: true });
37
+ removed.unshift(".mate");
38
+ }
39
+ return { changed: removed.length > 0 || updated.length > 0, removed, updated };
40
+ }
@@ -0,0 +1,103 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import { resolveGitInfoExcludePath } from "./git-utils";
5
+
6
+ const MANAGED_BLOCK_START = "# mate managed: start";
7
+ const MANAGED_BLOCK_END = "# mate managed: end";
8
+ const CORE_EXCLUDE_ENTRIES = ["/.mate/", "/.claude/", "/.opencode/", "/.agents/"];
9
+ const LEGACY_CORE_EXCLUDE_ENTRIES = new Set([".mate/", ".claude/settings.local.json"]);
10
+
11
+ async function readExclude(excludePath: string): Promise<string> {
12
+ try {
13
+ return await fs.readFile(excludePath, "utf8");
14
+ } catch {
15
+ return "";
16
+ }
17
+ }
18
+
19
+ function contentLines(content: string): string[] {
20
+ const lines = content.split(/\r?\n/);
21
+ if (lines.at(-1) === "") lines.pop();
22
+ return lines;
23
+ }
24
+
25
+ function withoutManagedBlocks(lines: string[]): string[] {
26
+ const next: string[] = [];
27
+ for (let index = 0; index < lines.length; index += 1) {
28
+ if (lines[index].trim() !== MANAGED_BLOCK_START) {
29
+ next.push(lines[index]);
30
+ continue;
31
+ }
32
+ const end = lines.findIndex(
33
+ (line, candidateIndex) => candidateIndex > index && line.trim() === MANAGED_BLOCK_END,
34
+ );
35
+ if (end === -1) {
36
+ next.push(lines[index]);
37
+ continue;
38
+ }
39
+ index = end;
40
+ }
41
+ return next;
42
+ }
43
+
44
+ function trimTrailingEmptyLines(lines: string[]): string[] {
45
+ const next = [...lines];
46
+ while (next.at(-1)?.trim() === "") next.pop();
47
+ return next;
48
+ }
49
+
50
+ async function writeExclude(
51
+ excludePath: string,
52
+ existing: string,
53
+ lines: string[],
54
+ ): Promise<boolean> {
55
+ const nextContent = lines.length > 0 ? `${lines.join("\n")}\n` : "";
56
+ if (nextContent === existing) return false;
57
+ await fs.mkdir(path.dirname(excludePath), { recursive: true });
58
+ await fs.writeFile(excludePath, nextContent, "utf8");
59
+ return true;
60
+ }
61
+
62
+ export async function ensureWorkingRepoLocalExcludes(repoPath: string): Promise<boolean> {
63
+ const excludePath = await resolveGitInfoExcludePath(repoPath);
64
+ if (!excludePath) return false;
65
+ const existing = await readExclude(excludePath);
66
+ const lines = trimTrailingEmptyLines(
67
+ withoutManagedBlocks(contentLines(existing)).filter(
68
+ (line) => !LEGACY_CORE_EXCLUDE_ENTRIES.has(line.trim()),
69
+ ),
70
+ );
71
+ lines.push(MANAGED_BLOCK_START, ...CORE_EXCLUDE_ENTRIES, MANAGED_BLOCK_END);
72
+ return writeExclude(excludePath, existing, lines);
73
+ }
74
+
75
+ export async function reconcileWorkingRepoCapabilityExcludes(
76
+ repoPath: string,
77
+ desiredEntries: string[],
78
+ managedEntries: string[],
79
+ ): Promise<boolean> {
80
+ const excludePath = await resolveGitInfoExcludePath(repoPath);
81
+ if (!excludePath) return false;
82
+ const existing = await readExclude(excludePath);
83
+ const managed = new Set(managedEntries);
84
+ const lines = trimTrailingEmptyLines(
85
+ contentLines(existing).filter((line) => !managed.has(line.trim())),
86
+ );
87
+ for (const entry of desiredEntries) {
88
+ if (!lines.some((line) => line.trim() === entry)) lines.push(entry);
89
+ }
90
+ return writeExclude(excludePath, existing, lines);
91
+ }
92
+
93
+ export async function removeWorkingRepoLocalExcludes(repoPath: string): Promise<boolean> {
94
+ const excludePath = await resolveGitInfoExcludePath(repoPath);
95
+ if (!excludePath) return false;
96
+ const existing = await readExclude(excludePath);
97
+ const lines = trimTrailingEmptyLines(
98
+ withoutManagedBlocks(contentLines(existing)).filter(
99
+ (line) => !LEGACY_CORE_EXCLUDE_ENTRIES.has(line.trim()),
100
+ ),
101
+ );
102
+ return writeExclude(excludePath, existing, lines);
103
+ }