@uniqbit/mate-core 0.18.3-canary.1 → 0.18.4

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 (127) hide show
  1. package/claude-plugin/hooks/hooks.json +10 -1
  2. package/claude-plugin/hooks/session-banner.mjs +4 -3
  3. package/claude-plugin/hooks/session-prepare.mjs +9 -0
  4. package/opencode-plugin/src/add-dir.ts +10 -76
  5. package/opencode-plugin/src/audience-layer.ts +91 -0
  6. package/opencode-plugin/src/companion.ts +70 -18
  7. package/opencode-plugin/src/launch-environment.ts +29 -0
  8. package/opencode-plugin/src/server.ts +60 -8
  9. package/package.json +3 -3
  10. package/src/cli/commands/artifact/finish/openspec.ts +1 -1
  11. package/src/cli/commands/artifact/pending/discovery.ts +60 -21
  12. package/src/cli/commands/cap/index-cmd.ts +2 -11
  13. package/src/cli/commands/companion/link.ts +16 -11
  14. package/src/cli/commands/companion/list.ts +4 -5
  15. package/src/cli/commands/companion/open.ts +4 -5
  16. package/src/cli/commands/companion/tui.ts +1 -1
  17. package/src/cli/commands/doctor.ts +87 -5
  18. package/src/cli/commands/install.ts +8 -0
  19. package/src/cli/commands/report/renderer.ts +9 -19
  20. package/src/cli/commands/session/prepare.ts +119 -0
  21. package/src/cli/commands/shared/companion-selection.ts +2 -2
  22. package/src/cli/commands/studio/reports.ts +0 -1
  23. package/src/cli/commands/studio/server.ts +6 -2
  24. package/src/cli/commands/studio/terminal-launch.ts +16 -7
  25. package/src/cli/commands/unwrap.ts +28 -3
  26. package/src/cli/commands/wrap.ts +79 -60
  27. package/src/cli/companion-selector.tsx +1 -1
  28. package/src/cli/main.ts +17 -0
  29. package/src/cli/usage.ts +5 -2
  30. package/src/hooks/session-banner.ts +19 -10
  31. package/src/hooks/session-guidance.ts +20 -2
  32. package/src/hooks/session-prepare.ts +101 -0
  33. package/src/lib/fs-utils.ts +13 -0
  34. package/src/lib/orchestrator/adapters/base.ts +45 -9
  35. package/src/lib/orchestrator/adapters/claude.ts +241 -30
  36. package/src/lib/orchestrator/adapters/opencode.ts +72 -34
  37. package/src/lib/orchestrator/companion-git-sync.ts +28 -1
  38. package/src/lib/orchestrator/companion-hub.ts +2 -1
  39. package/src/lib/orchestrator/config-store.ts +83 -1
  40. package/src/lib/orchestrator/framework-context.ts +5 -29
  41. package/src/lib/orchestrator/launcher.ts +36 -171
  42. package/src/lib/orchestrator/openspec-store.ts +202 -0
  43. package/src/lib/orchestrator/projection-claude-entry.ts +17 -6
  44. package/src/lib/orchestrator/projection-claude-skills.ts +126 -41
  45. package/src/lib/orchestrator/projection-entries.ts +124 -11
  46. package/src/lib/orchestrator/projection-opencode-copies.ts +230 -0
  47. package/src/lib/orchestrator/projection-openspec-store.ts +164 -0
  48. package/src/lib/orchestrator/projection-record.ts +7 -1
  49. package/src/lib/orchestrator/projection-runtime-documents.ts +83 -130
  50. package/src/lib/orchestrator/projection-types.ts +47 -34
  51. package/src/lib/orchestrator/repository-link.ts +473 -0
  52. package/src/lib/orchestrator/root-context.ts +6 -2
  53. package/src/lib/orchestrator/session-prepare.ts +503 -0
  54. package/src/lib/orchestrator/setup-compatibilities.ts +1 -1
  55. package/src/lib/orchestrator/setup-preflight.ts +3 -1
  56. package/src/lib/orchestrator/working-repo-projection.ts +73 -160
  57. package/src/lib/orchestrator/workspace-inventory.ts +2 -2
  58. package/src/lib/orchestrator/wrap-pass.ts +222 -0
  59. package/src/lib/runtime-directory-refresh.ts +186 -0
  60. package/src/lib/update-checker.ts +6 -0
  61. package/src/opencode/companion-hooks.ts +30 -135
  62. package/src/opencode/companion-policy.ts +26 -10
  63. package/src/opencode/index.ts +1 -7
  64. package/src/opencode/projected-guidance.ts +6 -13
  65. package/src/opencode/session-notes.ts +76 -0
  66. package/src/opencode/session-prepare.ts +139 -0
  67. package/src/opencode/tui.tsx +56 -9
  68. package/src/runtime/audience-layer.ts +84 -0
  69. package/src/runtime/claude-delivery-plan.ts +73 -0
  70. package/src/runtime/claude-hook-group.ts +21 -0
  71. package/src/runtime/companion-lock.ts +121 -0
  72. package/src/runtime/companion-sync.ts +66 -9
  73. package/src/runtime/guidance.ts +17 -0
  74. package/src/runtime/opencode-audience-layer.ts +176 -0
  75. package/src/runtime/policy.ts +14 -1
  76. package/src/runtime/projection.ts +1 -1
  77. package/src/runtime/runtime-directory.ts +43 -0
  78. package/src/runtime/runtime-document-manifest.ts +352 -0
  79. package/src/runtime/session-prepare-runner.ts +148 -0
  80. package/src/runtime/wrap-state.ts +94 -0
  81. package/src/runtime/write-atomic.ts +27 -0
  82. package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +2 -1
  83. package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +2 -1
  84. package/src/templates/capabilities/openspec-cap/openspec-conventions.yaml +5 -0
  85. package/src/templates/capabilities/react-doctor/mate-react-doctor/SKILL.md +84 -0
  86. package/src/templates/capabilities/react-doctor/{skill → mate-react-doctor}/references/explain.md +18 -15
  87. package/src/templates/mate-skills/agents/mate-openspec-backfill/SKILL.md +45 -29
  88. package/src/templates/mate-skills/agents/mate-show-me/SKILL.md +1 -1
  89. package/src/templates/mate-skills/claude/mate-openspec-backfill/SKILL.md +45 -29
  90. package/src/templates/mate-skills/claude/mate-show-me/SKILL.md +1 -1
  91. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +850 -499
  92. package/src/tools/setup/audience-isolation.fixture.ts +325 -0
  93. package/src/tools/setup/audience-layer.ts +207 -0
  94. package/src/tools/setup/audience-registrations.ts +52 -0
  95. package/src/tools/setup/capabilities/openspec.ts +9 -0
  96. package/src/tools/setup/capabilities/react-doctor.ts +34 -50
  97. package/src/tools/setup/capabilities/tokensave.ts +11 -0
  98. package/src/tools/setup/contribution-ledger.ts +207 -0
  99. package/src/tools/setup/dynamic-plugins/audiences.ts +89 -9
  100. package/src/tools/setup/dynamic-plugins/declarations.ts +3 -2
  101. package/src/tools/setup/dynamic-plugins/hydrate.ts +8 -5
  102. package/src/tools/setup/engine.ts +152 -10
  103. package/src/tools/setup/isolation-diagnostics.ts +139 -0
  104. package/src/tools/setup/mate.ts +1 -1
  105. package/src/tools/setup/plugin.ts +19 -4
  106. package/src/tools/setup/plugins/gitignore.ts +7 -1
  107. package/src/tools/setup/providers/claude-format.ts +11 -18
  108. package/src/tools/setup/providers/claude-plugin-hooks.ts +61 -24
  109. package/src/tools/setup/providers/claude.ts +50 -8
  110. package/src/tools/setup/providers/opencode.ts +10 -0
  111. package/src/tools/setup/registry.ts +24 -2
  112. package/src/tools/setup/runtime-documents.ts +152 -32
  113. package/src/tools/setup/surface-target.ts +13 -4
  114. package/src/tools/setup/upgrade-sweep.ts +153 -0
  115. package/src/tools/setup/working-repo-cleanup.ts +2 -0
  116. package/src/tools/setup/working-repo-local-state.ts +91 -14
  117. package/src/tools/setup.ts +11 -8
  118. package/wrappers/bin/openspec +42 -1
  119. package/src/lib/orchestrator/companion-registry-reader.ts +0 -37
  120. package/src/lib/orchestrator/companion-resolver.ts +0 -179
  121. package/src/lib/orchestrator/companion-store.ts +0 -94
  122. package/src/lib/orchestrator/repo-local-registry.ts +0 -143
  123. package/src/templates/capabilities/react-doctor/claude/hooks/react-doctor.sh +0 -144
  124. package/src/templates/capabilities/react-doctor/skill/SKILL.md +0 -51
  125. package/src/templates/report-assets/README.md +0 -32
  126. package/src/templates/report-assets/mermaid.LICENSE +0 -21
  127. package/src/templates/report-assets/mermaid.min.js +0 -4376
@@ -5,8 +5,17 @@ import { resolveGitInfoExcludePath } from "./git-utils";
5
5
 
6
6
  const MANAGED_BLOCK_START = "# mate managed: start";
7
7
  const MANAGED_BLOCK_END = "# mate managed: end";
8
- /** `.mcp.json` is the one working-target document no directory entry covers. */
9
- const CORE_EXCLUDE_ENTRIES = ["/.mate/", "/.claude/", "/.opencode/", "/.agents/", "/.mcp.json"];
8
+ /**
9
+ * Documents no directory entry covers. `.opencode/` is per file: the repository's
10
+ * own files there stay visible; copies get the copies block.
11
+ */
12
+ const CORE_EXCLUDE_ENTRIES = [
13
+ "/.mate/",
14
+ "/.claude/",
15
+ "/.opencode/opencode.json",
16
+ "/.agents/",
17
+ "/.mcp.json",
18
+ ];
10
19
  const LEGACY_CORE_EXCLUDE_ENTRIES = new Set([".mate/", ".claude/settings.local.json"]);
11
20
 
12
21
  async function readExclude(excludePath: string): Promise<string> {
@@ -60,16 +69,43 @@ async function writeExclude(
60
69
  return true;
61
70
  }
62
71
 
72
+ /** The first `startMarker`…`endMarker` pair, inclusive; `null` when either is missing. */
73
+ function findBlock(
74
+ lines: string[],
75
+ startMarker: string,
76
+ endMarker: string,
77
+ ): { start: number; end: number } | null {
78
+ const start = lines.findIndex((line) => line.trim() === startMarker);
79
+ if (start === -1) return null;
80
+ const end = lines.findIndex((line, index) => index > start && line.trim() === endMarker);
81
+ return end === -1 ? null : { start, end };
82
+ }
83
+
84
+ function withoutLegacy(lines: string[]): string[] {
85
+ return withoutManagedBlocks(lines).filter(
86
+ (line) => !LEGACY_CORE_EXCLUDE_ENTRIES.has(line.trim()),
87
+ );
88
+ }
89
+
63
90
  export async function ensureWorkingRepoLocalExcludes(repoPath: string): Promise<boolean> {
64
91
  const excludePath = await resolveGitInfoExcludePath(repoPath);
65
92
  if (!excludePath) return false;
66
93
  const existing = await readExclude(excludePath);
67
- const lines = trimTrailingEmptyLines(
68
- withoutManagedBlocks(contentLines(existing)).filter(
69
- (line) => !LEGACY_CORE_EXCLUDE_ENTRIES.has(line.trim()),
70
- ),
71
- );
72
- lines.push(MANAGED_BLOCK_START, ...CORE_EXCLUDE_ENTRIES, MANAGED_BLOCK_END);
94
+ const current = contentLines(existing);
95
+ const block = [MANAGED_BLOCK_START, ...CORE_EXCLUDE_ENTRIES, MANAGED_BLOCK_END];
96
+ /**
97
+ * Rewritten where it stands, not moved to the end: a block that follows it,
98
+ * like the companion copies', would otherwise trade places with it on every
99
+ * pass and turn an unchanged wrap into a write.
100
+ */
101
+ const managed = findBlock(current, MANAGED_BLOCK_START, MANAGED_BLOCK_END);
102
+ const lines = managed
103
+ ? trimTrailingEmptyLines([
104
+ ...withoutLegacy(current.slice(0, managed.start)),
105
+ ...block,
106
+ ...withoutLegacy(current.slice(managed.end + 1)),
107
+ ])
108
+ : [...trimTrailingEmptyLines(withoutLegacy(current)), ...block];
73
109
  return writeExclude(excludePath, existing, lines);
74
110
  }
75
111
 
@@ -82,8 +118,16 @@ export async function reconcileWorkingRepoCapabilityExcludes(
82
118
  if (!excludePath) return false;
83
119
  const existing = await readExclude(excludePath);
84
120
  const managed = new Set(managedEntries);
121
+ const desired = new Set(desiredEntries);
122
+ /**
123
+ * A desired line stays where it stands: re-appending it would trade places
124
+ * with a block written after it, like the companion copies', on every pass.
125
+ */
85
126
  const lines = trimTrailingEmptyLines(
86
- contentLines(existing).filter((line) => !managed.has(line.trim())),
127
+ contentLines(existing).filter((line) => {
128
+ const entry = line.trim();
129
+ return desired.has(entry) || !managed.has(entry);
130
+ }),
87
131
  );
88
132
  for (const entry of desiredEntries) {
89
133
  if (!lines.some((line) => line.trim() === entry)) lines.push(entry);
@@ -114,10 +158,43 @@ export async function removeWorkingRepoLocalExcludes(repoPath: string): Promise<
114
158
  const excludePath = await resolveGitInfoExcludePath(repoPath);
115
159
  if (!excludePath) return false;
116
160
  const existing = await readExclude(excludePath);
117
- const lines = trimTrailingEmptyLines(
118
- withoutManagedBlocks(contentLines(existing)).filter(
119
- (line) => !LEGACY_CORE_EXCLUDE_ENTRIES.has(line.trim()),
120
- ),
121
- );
161
+ const lines = trimTrailingEmptyLines(withoutLegacy(contentLines(existing)));
162
+ return writeExclude(excludePath, existing, lines);
163
+ }
164
+
165
+ const COPIES_BLOCK_START = "# mate managed copies: start";
166
+ const COPIES_BLOCK_END = "# mate managed copies: end";
167
+
168
+ /** A literal repo-relative path as an anchored exclude pattern. */
169
+ function literalExcludeEntry(relativePath: string): string {
170
+ const escaped = relativePath.replace(/[\\*?[\]]/g, "\\$&");
171
+ return `/${escaped}`;
172
+ }
173
+
174
+ function withoutCopiesBlock(lines: string[]): string[] {
175
+ const block = findBlock(lines, COPIES_BLOCK_START, COPIES_BLOCK_END);
176
+ return block ? [...lines.slice(0, block.start), ...lines.slice(block.end + 1)] : lines;
177
+ }
178
+
179
+ /**
180
+ * One exclude line per owned companion copy, in a block of its own so the core
181
+ * block's rewrite leaves it alone. Per file rather than `/.opencode/`, so a file
182
+ * the repository adds there itself stays visible to Git.
183
+ */
184
+ export async function reconcileWorkingRepoCopyExcludes(
185
+ repoPath: string,
186
+ relativePaths: readonly string[],
187
+ ): Promise<boolean> {
188
+ const excludePath = await resolveGitInfoExcludePath(repoPath);
189
+ if (!excludePath) return false;
190
+ const existing = await readExclude(excludePath);
191
+ const lines = trimTrailingEmptyLines(withoutCopiesBlock(contentLines(existing)));
192
+ if (relativePaths.length > 0) {
193
+ lines.push(
194
+ COPIES_BLOCK_START,
195
+ ...relativePaths.toSorted().map(literalExcludeEntry),
196
+ COPIES_BLOCK_END,
197
+ );
198
+ }
122
199
  return writeExclude(excludePath, existing, lines);
123
200
  }
@@ -40,9 +40,9 @@ import {
40
40
  buildSetupInstallationPlan,
41
41
  executeSetupInstallationPlan,
42
42
  renderRuntimeDocuments,
43
+ type RenderedRuntimeDocuments,
43
44
  type SetupInstallationOutcome,
44
45
  } from "./setup/engine";
45
- import type { RenderedRuntimeDocument } from "../lib/orchestrator/projection-types";
46
46
 
47
47
  export interface SetupInput {
48
48
  allowedAgents?: string[];
@@ -61,7 +61,7 @@ export async function applySetupCompatibilities(
61
61
  companionPath: string,
62
62
  config: FrameworkConfig,
63
63
  mode: "setup" | "sync",
64
- plugins: PluginRegistration[] = getActiveDistribution().registry.getEntries(),
64
+ plugins: PluginRegistration[] = getActiveDistribution().registry.baseRegistrations(),
65
65
  repoPath?: string,
66
66
  scope: SetupScope = config.type === "hub" ? "hub" : "companion",
67
67
  ): Promise<SetupInstallationOutcome> {
@@ -73,15 +73,16 @@ export async function applySetupCompatibilities(
73
73
 
74
74
  /**
75
75
  * The active Agent Runtimes' documents for a Working Repository, rendered
76
- * without a write. Runs no plugin phase: the working target reconciles
76
+ * without a write: base, plus the audience the checkout is wrapped for. Runs no plugin phase: the working target reconciles
77
77
  * declarations only, so nothing is written to the companion on its behalf.
78
78
  */
79
79
  export async function renderWorkingRuntimeDocuments(
80
80
  companionPath: string,
81
81
  config: FrameworkConfig,
82
82
  repoPath: string,
83
- plugins: PluginRegistration[] = getActiveDistribution().registry.getAll(),
84
- ): Promise<RenderedRuntimeDocument[]> {
83
+ plugins: PluginRegistration[] = getActiveDistribution().registry.baseRegistrations(),
84
+ wrapped?: { audience: string; registrations: PluginRegistration[] },
85
+ ): Promise<RenderedRuntimeDocuments> {
85
86
  const plan = buildSetupInstallationPlan(config, plugins);
86
87
  const ctx: SetupContext = {
87
88
  companionPath,
@@ -91,7 +92,7 @@ export async function renderWorkingRuntimeDocuments(
91
92
  repoPath,
92
93
  target: "working",
93
94
  };
94
- return renderRuntimeDocuments(ctx, plugins, plan);
95
+ return renderRuntimeDocuments(ctx, plugins, plan, wrapped);
95
96
  }
96
97
 
97
98
  export async function updateProjectGitignore(
@@ -99,7 +100,9 @@ export async function updateProjectGitignore(
99
100
  config: FrameworkConfig,
100
101
  ): Promise<void> {
101
102
  const ctx: SetupContext = { companionPath, config, mode: "sync", activeProviders: [] };
102
- const plugins = getActiveDistribution().registry.getAll();
103
+ const plugins = getActiveDistribution()
104
+ .registry.baseRegistrations()
105
+ .map((entry) => entry.plugin);
103
106
  const entries = collectManagedGitignoreEntries(ctx, plugins);
104
107
  await writeManagedGitignoreBlock(path.join(companionPath, ".gitignore"), FRAMEWORK_NAME, entries);
105
108
  }
@@ -113,7 +116,7 @@ export async function syncCompanionFiles(
113
116
  companionPath,
114
117
  config,
115
118
  "sync",
116
- getActiveDistribution().registry.getAll(),
119
+ getActiveDistribution().registry.baseRegistrations(),
117
120
  repoPath,
118
121
  );
119
122
  }
@@ -30,10 +30,12 @@ _mate_same_dir() {
30
30
  }
31
31
 
32
32
  _companion="$MATE_ARTIFACT_PATH"
33
+ _repo="$MATE_REPO_PATH"
33
34
  if [ -z "$_companion" ]; then
34
35
  _projection="$(_mate_projection_file)"
35
36
  if [ -n "$_projection" ]; then
36
37
  _companion="$(_mate_projected "$_projection" MATE_ARTIFACT_PATH)"
38
+ _repo="$(_mate_projected "$_projection" MATE_REPO_PATH)"
37
39
  _projected_wrapper_dir="$(_mate_projected "$_projection" MATE_WRAPPER_BIN_PATH)"
38
40
  if [ -n "$_companion" ] && ! _mate_same_dir "$_projected_wrapper_dir" "$_this_dir"; then
39
41
  {
@@ -92,4 +94,43 @@ if [ "$command" = "templates" ]; then
92
94
  fi
93
95
  fi
94
96
  fi
95
- exec env -C "$_companion" "$_real" "$@"
97
+
98
+ _mate_is_under() {
99
+ case "$1/" in "$2"/*) return 0 ;; esac
100
+ return 1
101
+ }
102
+
103
+ # Moves _cwd to the Working Repository root when the caller is inside that
104
+ # repository, outside the companion, and the root carries Mate's pointer.
105
+ _mate_use_store_pointer() {
106
+ [ -n "$_repo" ] && [ -d "$_repo" ] || return 0
107
+ local repo_root here companion_root pointer first
108
+ repo_root="$(cd "$_repo" && pwd -P)"
109
+ here="$(pwd -P)"
110
+ companion_root="$(cd "$_companion" 2>/dev/null && pwd -P)"
111
+ _mate_is_under "$here" "$repo_root" || return 0
112
+ [ -n "$companion_root" ] && _mate_is_under "$here" "$companion_root" && return 0
113
+ pointer="$repo_root/openspec/config.yaml"
114
+ if [ -f "$pointer" ]; then
115
+ IFS= read -r first < "$pointer" || true
116
+ case "$first" in
117
+ "# generated by "*" — do not edit") _cwd="$repo_root" ;;
118
+ esac
119
+ elif [ ! -f "$repo_root/openspec/config.yml" ]; then
120
+ echo "openspec: no OpenSpec store pointer in $repo_root, so edits are scoped to the companion — run \`mate wrap\` to enable Working Repository edit roots" >&2
121
+ fi
122
+ }
123
+
124
+ # Commands that resolve an OpenSpec root run from the Working Repository root
125
+ # when it carries Mate's store pointer: OpenSpec then plans in the companion's
126
+ # store and names the repository as the edit root. The root, not the caller's
127
+ # cwd, so a nested openspec/ cannot win. Every other case — including
128
+ # templates, init and update, which do not resolve stores — stays on the
129
+ # companion, exactly as before.
130
+ _cwd="$_companion"
131
+ case "$command" in
132
+ new|status|instructions|list|show|validate|archive|doctor|context|schemas|view)
133
+ _mate_use_store_pointer
134
+ ;;
135
+ esac
136
+ exec env -C "$_cwd" "$_real" "$@"
@@ -1,37 +0,0 @@
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
- }
@@ -1,179 +0,0 @@
1
- import fs from "node:fs/promises";
2
- import path from "node:path";
3
-
4
- import { parse } from "yaml";
5
-
6
- import { readCompanionRegistry } from "./companion-registry-reader";
7
- import type { GlobalConfigStore } from "./global-config-store";
8
- import {
9
- findRepoLocalRegistryFile,
10
- pathIsDirectory,
11
- repoLocalRegistryPath,
12
- type RepoLocalCompanionPointer,
13
- RepoLocalRegistryStore,
14
- } from "./repo-local-registry";
15
-
16
- export interface CompanionMatch {
17
- companionPath: string;
18
- repositoryId: string;
19
- }
20
-
21
- export interface CompanionResolutionFailure {
22
- companionPath: string;
23
- message: string;
24
- }
25
-
26
- export interface CompanionResolutionResult {
27
- match: CompanionMatch | null;
28
- /**
29
- * All companions whose registry links the same repo path at the best
30
- * (longest) match length as `match`. Populated with more than one entry
31
- * only when the same working repo is linked from more than one distinct
32
- * companion, so callers that must pick a single companion (e.g. launch
33
- * commands) can detect the ambiguity and prompt the user instead of
34
- * silently using whichever companion happened to be listed first.
35
- */
36
- ambiguousMatches: CompanionMatch[];
37
- failures: CompanionResolutionFailure[];
38
- }
39
-
40
- export class CompanionResolver {
41
- constructor(private readonly globalConfigStore: GlobalConfigStore) {}
42
-
43
- async resolve(cwd: string): Promise<CompanionMatch | null> {
44
- return (await this.resolveWithDiagnostics(cwd, { logFailures: true })).match;
45
- }
46
-
47
- async resolveWithDiagnostics(
48
- cwd: string,
49
- options: { logFailures?: boolean } = {},
50
- ): Promise<CompanionResolutionResult> {
51
- const resolvedCwd = path.resolve(cwd);
52
-
53
- const repoLocalResult = await this.resolveFromRepoLocalRegistry(resolvedCwd, options);
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
- };
101
- }
102
-
103
- /**
104
- * Fast path: reads the working repo's own `.mate/config/registry.yaml`
105
- * before scanning every registered companion. Treats the file as the
106
- * working repo's source of truth for companion pointers: existing
107
- * directories are accepted immediately, stale paths are dropped, and
108
- * absence of usable local data means the repo is not linked.
109
- */
110
- private async resolveFromRepoLocalRegistry(
111
- resolvedCwd: string,
112
- options: { logFailures?: boolean },
113
- ): Promise<CompanionResolutionResult | null> {
114
- const found = await findRepoLocalRegistryFile(resolvedCwd);
115
- if (!found) return null;
116
-
117
- let pointers: RepoLocalCompanionPointer[];
118
- try {
119
- const raw = await fs.readFile(found.registryPath, "utf8");
120
- const parsed = parse(raw) as { companions?: unknown } | null;
121
- if (!parsed || !Array.isArray(parsed.companions)) return null;
122
- pointers = parsed.companions as RepoLocalCompanionPointer[];
123
- } catch {
124
- return null;
125
- }
126
-
127
- if (pointers.length === 0) return null;
128
-
129
- const survivors: RepoLocalCompanionPointer[] = [];
130
- const validMatches: CompanionMatch[] = [];
131
- let changed = false;
132
-
133
- const checkedPointers = await Promise.all(
134
- pointers.map(async (pointer) => {
135
- const companionPath = path.resolve(pointer.path);
136
- return { pointer, companionPath, exists: await pathIsDirectory(companionPath) };
137
- }),
138
- );
139
-
140
- for (const { pointer, companionPath, exists } of checkedPointers) {
141
- if (exists) {
142
- survivors.push({
143
- path: companionPath,
144
- repositoryId: pointer.repositoryId,
145
- source: pointer.source,
146
- });
147
- validMatches.push({ companionPath, repositoryId: pointer.repositoryId });
148
- continue;
149
- }
150
-
151
- changed = true;
152
- if (options.logFailures) {
153
- console.error(
154
- `mate: dropping stale repo-local companion pointer (no longer exists): ${companionPath}`,
155
- );
156
- }
157
- }
158
-
159
- if (changed) {
160
- const store = new RepoLocalRegistryStore(found.registryPath);
161
- await store.save({ companions: survivors });
162
- }
163
-
164
- if (validMatches.length === 0) return null;
165
-
166
- const seenCompanionPaths = new Set<string>();
167
- const ambiguousMatches = validMatches.filter((match) => {
168
- if (seenCompanionPaths.has(match.companionPath)) return false;
169
- seenCompanionPaths.add(match.companionPath);
170
- return true;
171
- });
172
-
173
- return {
174
- match: ambiguousMatches[0]!,
175
- ambiguousMatches: ambiguousMatches.length > 1 ? ambiguousMatches : [],
176
- failures: [],
177
- };
178
- }
179
- }
@@ -1,94 +0,0 @@
1
- import fs from "node:fs/promises";
2
- import path from "node:path";
3
-
4
- import { FRAMEWORK_NAME } from "../../framework";
5
- import { CompanionRegistryStore } from "./companion-registry-store";
6
- import { ConfigStore } from "./config-store";
7
- import { pathIsDirectory, writeRepoLocalRegistryEntry } from "./repo-local-registry";
8
- import { type CompanionSource, ConfigError, type LinkedRepository } from "./types";
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
- }
18
-
19
- export class CompanionStore {
20
- constructor(
21
- private readonly configStore = new ConfigStore(),
22
- private readonly companionRegistryStore = new CompanionRegistryStore(),
23
- ) {}
24
-
25
- async registerRepository(
26
- repository: LinkedRepository,
27
- options: { companionSource?: CompanionSource } = {},
28
- ): Promise<LinkedRepository> {
29
- const stats = await fs.stat(repository.path).catch(() => null);
30
- if (!stats?.isDirectory()) {
31
- throw new ConfigError(
32
- `Linked repository path must exist and be a directory: ${repository.path}`,
33
- );
34
- }
35
-
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);
56
- if (existing >= 0) {
57
- survivors[existing] = repository;
58
- } else {
59
- survivors.push(repository);
60
- }
61
- working.repos = survivors;
62
- await this.companionRegistryStore.save(working);
63
-
64
- // Repo-local registry write is a cache, not the source of truth: the
65
- // companion-side write above already succeeded, so a failure here (e.g. a
66
- // read-only working tree) must not fail the overall link operation.
67
- try {
68
- const companionRoot = path.resolve(path.dirname(this.configStore.configPath), "..", "..");
69
- await writeRepoLocalRegistryEntry(
70
- repository.path,
71
- companionRoot,
72
- repository,
73
- options.companionSource ?? "git",
74
- );
75
- } catch (error) {
76
- const message = error instanceof Error ? error.message : String(error);
77
- console.error(
78
- `mate: warning: failed to write repo-local registry for ${repository.path}: ${message}`,
79
- );
80
- }
81
-
82
- return repository;
83
- }
84
-
85
- async getRepository(repositoryId: string): Promise<LinkedRepository | undefined> {
86
- const working = await this.companionRegistryStore.load();
87
- return working.repos.find((r) => r.id === repositoryId);
88
- }
89
-
90
- async listRepositories(): Promise<LinkedRepository[]> {
91
- const working = await this.companionRegistryStore.load();
92
- return working.repos;
93
- }
94
- }