@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
@@ -0,0 +1,473 @@
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 {
8
+ ancestorDirectories,
9
+ repoLocalDirName,
10
+ repoLocalRegistryPath,
11
+ type RepoLocalRegistryFile,
12
+ } from "../../runtime/repo-local";
13
+ import { pathIsDirectory } from "../fs-utils";
14
+ import { CompanionRegistryStore } from "./companion-registry-store";
15
+ import type { GlobalConfigStore } from "./global-config-store";
16
+ import { firstFailure } from "./projection-types";
17
+ import {
18
+ normalizeLinkedRepository,
19
+ type RepoLocalCompanionPointer,
20
+ type RepoLocalRegistry,
21
+ RepoLocalRegistryStore,
22
+ } from "./repo-local-store";
23
+ import { project } from "./working-repo-projection";
24
+ import { type CompanionSource, ConfigError, type LinkedRepository } from "./types";
25
+
26
+ /**
27
+ * The Repository Link: the companion's `registry.yaml` is the source of
28
+ * truth, the Working Repository's repo-local `registry.yaml` is a cache. Every
29
+ * rule for persisting, reading, pruning, migrating, backfilling and
30
+ * self-healing a link lives here; `CompanionRegistryStore` and
31
+ * `repo-local-store.ts` only know the file shapes.
32
+ */
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Link, get and list (update read: migrates legacy registries on load)
36
+ // ---------------------------------------------------------------------------
37
+
38
+ /** Builds a {@link CompanionStore} whose registry file lives under `<companionPath>/.mate/config`, regardless of `process.cwd()`. */
39
+ export function companionRootedStore(companionPath: string): CompanionStore {
40
+ return new CompanionStore(
41
+ companionPath,
42
+ new CompanionRegistryStore(companionRegistryPath(companionPath)),
43
+ );
44
+ }
45
+
46
+ export class CompanionStore {
47
+ constructor(
48
+ private readonly companionPath = process.cwd(),
49
+ private readonly companionRegistryStore = new CompanionRegistryStore(),
50
+ ) {}
51
+
52
+ async registerRepository(
53
+ repository: LinkedRepository,
54
+ options: { companionSource?: CompanionSource } = {},
55
+ ): Promise<LinkedRepository> {
56
+ const stats = await fs.stat(repository.path).catch(() => null);
57
+ if (!stats?.isDirectory()) {
58
+ throw new ConfigError(
59
+ `Linked repository path must exist and be a directory: ${repository.path}`,
60
+ );
61
+ }
62
+
63
+ // A companion's registry.yaml may not exist yet (its first-ever link):
64
+ // that is not a real error here, unlike a working repo reading its own
65
+ // missing config, so fall back to an empty registry instead of throwing.
66
+ const working = await this.companionRegistryStore.load().catch((error) => {
67
+ if (error instanceof ConfigError) return CompanionRegistryStore.defaultConfig();
68
+ throw error;
69
+ });
70
+
71
+ // Prune dead entries (path no longer a directory) as part of this same
72
+ // write, piggybacking on the load/save that registration already does
73
+ // rather than adding a separate scan; never runs on a read-only list.
74
+ const checked = await Promise.all(
75
+ working.repos.map(async (repo) => ({ repo, exists: await pathIsDirectory(repo.path) })),
76
+ );
77
+ const survivors = checked.filter((entry) => entry.exists).map((entry) => entry.repo);
78
+
79
+ const existing = survivors.findIndex((r) => r.id === repository.id);
80
+ if (existing >= 0) {
81
+ survivors[existing] = repository;
82
+ } else {
83
+ survivors.push(repository);
84
+ }
85
+ working.repos = survivors;
86
+ await this.companionRegistryStore.save(working);
87
+
88
+ // Repo-local registry write is a cache, not the source of truth: the
89
+ // companion-side write above already succeeded, so a failure here (e.g. a
90
+ // read-only working tree) must not fail the overall link operation.
91
+ try {
92
+ await writeRepoLocalRegistryEntry(
93
+ repository.path,
94
+ path.resolve(this.companionPath),
95
+ repository,
96
+ options.companionSource ?? "git",
97
+ );
98
+ } catch (error) {
99
+ const message = error instanceof Error ? error.message : String(error);
100
+ console.error(
101
+ `mate: warning: failed to write repo-local registry for ${repository.path}: ${message}`,
102
+ );
103
+ }
104
+
105
+ return repository;
106
+ }
107
+
108
+ async getRepository(repositoryId: string): Promise<LinkedRepository | undefined> {
109
+ const working = await this.companionRegistryStore.load();
110
+ return working.repos.find((r) => r.id === repositoryId);
111
+ }
112
+
113
+ async listRepositories(): Promise<LinkedRepository[]> {
114
+ const working = await this.companionRegistryStore.load();
115
+ return working.repos;
116
+ }
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Pure read of the companion registry (never writes)
121
+ // ---------------------------------------------------------------------------
122
+
123
+ export function companionRegistryPath(companionPath: string): string {
124
+ return path.join(path.resolve(companionPath), `.${FRAMEWORK_NAME}`, "config", "registry.yaml");
125
+ }
126
+
127
+ export interface CompanionRegistryReadResult {
128
+ repos: LinkedRepository[];
129
+ }
130
+
131
+ /**
132
+ * Parses a companion's `registry.yaml` without the writes {@link
133
+ * CompanionRegistryStore.load} performs (legacy-field migration, stale-pointer
134
+ * cleanup). Callers that must not mutate registry state on a read (e.g.
135
+ * inventory, materialization) use this instead. Throws ENOENT when the file
136
+ * doesn't exist; throws on unparsable YAML.
137
+ */
138
+ export async function readCompanionRegistry(
139
+ companionPath: string,
140
+ ): Promise<CompanionRegistryReadResult> {
141
+ const raw = await fs.readFile(companionRegistryPath(companionPath), "utf8");
142
+ const parsed = parse(raw) as { repos?: unknown } | null;
143
+ const rawRepos = Array.isArray(parsed?.repos) ? parsed.repos : [];
144
+ const repos = rawRepos
145
+ .filter(
146
+ (entry): entry is LinkedRepository =>
147
+ Boolean(entry) && typeof entry.id === "string" && typeof entry.path === "string",
148
+ )
149
+ .map(({ id, path: repoPath }) => ({ id, path: repoPath }));
150
+ return { repos };
151
+ }
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // Launch backfill
155
+ // ---------------------------------------------------------------------------
156
+
157
+ /**
158
+ * Self-heals repos linked before the companion-side registry write existed
159
+ * (or whose companion registry was reset): backfills the companion-side
160
+ * entry from the already-trusted repo-local pointer on every launch
161
+ * resolution. Best-effort — never blocks a launch.
162
+ */
163
+ export async function backfillCompanionRegistration(
164
+ companionPath: string,
165
+ repository: LinkedRepository,
166
+ ): Promise<void> {
167
+ try {
168
+ const { repos } = await readCompanionRegistry(companionPath).catch(() => ({ repos: [] }));
169
+ const existing = repos.find((repo) => repo.id === repository.id);
170
+ if (existing?.path === repository.path) return;
171
+
172
+ await companionRootedStore(companionPath).registerRepository(repository);
173
+ } catch (error) {
174
+ console.error(
175
+ `${FRAMEWORK_NAME}: warning: failed to backfill companion registry for ${repository.id}: ${
176
+ error instanceof Error ? error.message : String(error)
177
+ }`,
178
+ );
179
+ }
180
+ }
181
+
182
+ // ---------------------------------------------------------------------------
183
+ // Resolution: repo-local cache first, companion scan as fallback
184
+ // ---------------------------------------------------------------------------
185
+
186
+ export interface CompanionMatch {
187
+ companionPath: string;
188
+ repositoryId: string;
189
+ }
190
+
191
+ export interface CompanionResolutionFailure {
192
+ companionPath: string;
193
+ message: string;
194
+ }
195
+
196
+ export interface CompanionResolutionResult {
197
+ match: CompanionMatch | null;
198
+ /**
199
+ * All companions whose registry links the same repo path at the best
200
+ * (longest) match length as `match`. Populated with more than one entry
201
+ * only when the same working repo is linked from more than one distinct
202
+ * companion, so callers that must pick a single companion (e.g. launch
203
+ * commands) can detect the ambiguity and prompt the user instead of
204
+ * silently using whichever companion happened to be listed first.
205
+ */
206
+ ambiguousMatches: CompanionMatch[];
207
+ failures: CompanionResolutionFailure[];
208
+ }
209
+
210
+ export class CompanionResolver {
211
+ constructor(private readonly globalConfigStore: GlobalConfigStore) {}
212
+
213
+ async resolve(cwd: string): Promise<CompanionMatch | null> {
214
+ return (await this.resolveWithDiagnostics(cwd, { logFailures: true })).match;
215
+ }
216
+
217
+ async resolveWithDiagnostics(
218
+ cwd: string,
219
+ options: { logFailures?: boolean } = {},
220
+ ): Promise<CompanionResolutionResult> {
221
+ const resolvedCwd = path.resolve(cwd);
222
+
223
+ const repoLocalResult = await this.resolveFromRepoLocalRegistry(resolvedCwd, options);
224
+ if (repoLocalResult) return repoLocalResult;
225
+
226
+ return this.resolveFromGlobalRegistry(resolvedCwd);
227
+ }
228
+
229
+ /**
230
+ * Fallback for when the repo-local cache is missing or unusable: scans
231
+ * every companion the global registry knows about and matches by
232
+ * repository path. A single match self-heals the repo-local cache so the
233
+ * fast path succeeds on the next resolution without repeating this scan.
234
+ */
235
+ private async resolveFromGlobalRegistry(resolvedCwd: string): Promise<CompanionResolutionResult> {
236
+ const candidates = await this.globalConfigStore.list();
237
+ const found = await Promise.all(
238
+ candidates.map(async (candidate) => {
239
+ const companionPath = path.resolve(candidate);
240
+ try {
241
+ const { repos } = await readCompanionRegistry(companionPath);
242
+ const repo = repos.find((r) => path.resolve(r.path) === resolvedCwd);
243
+ return repo ? { companionPath, repositoryId: repo.id } : null;
244
+ } catch {
245
+ return null;
246
+ }
247
+ }),
248
+ );
249
+
250
+ const matches = uniqueByCompanion(
251
+ found.filter((match): match is CompanionMatch => match !== null),
252
+ );
253
+
254
+ if (matches.length === 0) return { match: null, ambiguousMatches: [], failures: [] };
255
+
256
+ if (matches.length === 1) {
257
+ // Direct save, not `project("link")`: that scope also writes git
258
+ // excludes, the Projection Root, the companion link and the repo-local
259
+ // framework.yaml, and it upserts the pointer instead of replacing
260
+ // `companions`. A read-side self-heal must only restore the cache.
261
+ const store = new RepoLocalRegistryStore(repoLocalRegistryPath(resolvedCwd));
262
+ /** `repository` too: wrap and unwrap resolve the Repository Link from it. */
263
+ await store.save({
264
+ ...(await store.load().catch(() => ({}))),
265
+ repository: { id: matches[0]!.repositoryId, path: resolvedCwd },
266
+ companions: [{ path: matches[0]!.companionPath, repositoryId: matches[0]!.repositoryId }],
267
+ });
268
+ }
269
+
270
+ return resolvedTo(matches);
271
+ }
272
+
273
+ /**
274
+ * Fast path: reads the working repo's own `.mate/config/registry.yaml`
275
+ * before scanning every registered companion. Treats the file as the
276
+ * working repo's source of truth for companion pointers: existing
277
+ * directories are accepted immediately, stale paths are dropped, and
278
+ * absence of usable local data means the repo is not linked.
279
+ */
280
+ private async resolveFromRepoLocalRegistry(
281
+ resolvedCwd: string,
282
+ options: { logFailures?: boolean },
283
+ ): Promise<CompanionResolutionResult | null> {
284
+ const found = await findRepoLocalRegistryFile(resolvedCwd);
285
+ if (!found) return null;
286
+
287
+ let pointers: RepoLocalCompanionPointer[];
288
+ try {
289
+ const raw = await fs.readFile(found.registryPath, "utf8");
290
+ const parsed = parse(raw) as { companions?: unknown } | null;
291
+ if (!parsed || !Array.isArray(parsed.companions)) return null;
292
+ pointers = parsed.companions as RepoLocalCompanionPointer[];
293
+ } catch {
294
+ return null;
295
+ }
296
+
297
+ if (pointers.length === 0) return null;
298
+
299
+ const checkedPointers = await Promise.all(
300
+ pointers.map(async (pointer) => {
301
+ const companionPath = path.resolve(pointer.path);
302
+ return { pointer, companionPath, exists: await pathIsDirectory(companionPath) };
303
+ }),
304
+ );
305
+ const live = checkedPointers.filter((entry) => entry.exists);
306
+ const stale = checkedPointers.filter((entry) => !entry.exists);
307
+
308
+ if (options.logFailures) {
309
+ for (const { companionPath } of stale) {
310
+ console.error(
311
+ `mate: dropping stale repo-local companion pointer (no longer exists): ${companionPath}`,
312
+ );
313
+ }
314
+ }
315
+
316
+ if (stale.length > 0) {
317
+ const survivors: RepoLocalCompanionPointer[] = live.map(({ pointer, companionPath }) => ({
318
+ path: companionPath,
319
+ repositoryId: pointer.repositoryId,
320
+ source: pointer.source,
321
+ }));
322
+ // Direct save, not `project("link")`: pruning only removes pointers, and
323
+ // that scope would also write the projection files and could only
324
+ // upsert, never drop, a pointer.
325
+ const store = new RepoLocalRegistryStore(found.registryPath);
326
+ await store.save({ ...(await store.load()), companions: survivors });
327
+ }
328
+
329
+ if (live.length === 0) return null;
330
+
331
+ return resolvedTo(
332
+ uniqueByCompanion(
333
+ live.map(({ pointer, companionPath }) => ({
334
+ companionPath,
335
+ repositoryId: pointer.repositoryId,
336
+ })),
337
+ ),
338
+ );
339
+ }
340
+ }
341
+
342
+ /** Keeps the first match per companion, so one companion linking a path twice is not ambiguous. */
343
+ function uniqueByCompanion(matches: CompanionMatch[]): CompanionMatch[] {
344
+ const seenCompanionPaths = new Set<string>();
345
+ return matches.filter((match) => {
346
+ if (seenCompanionPaths.has(match.companionPath)) return false;
347
+ seenCompanionPaths.add(match.companionPath);
348
+ return true;
349
+ });
350
+ }
351
+
352
+ /** `matches` is non-empty and deduplicated; more than one is an ambiguity. */
353
+ function resolvedTo(matches: CompanionMatch[]): CompanionResolutionResult {
354
+ return {
355
+ match: matches[0]!,
356
+ ambiguousMatches: matches.length > 1 ? matches : [],
357
+ failures: [],
358
+ };
359
+ }
360
+
361
+ // ---------------------------------------------------------------------------
362
+ // Repo-local cache: write through the projection, look up by walking the tree
363
+ // ---------------------------------------------------------------------------
364
+
365
+ const repoLocalScanSkipDirNames = () =>
366
+ new Set([
367
+ ".git",
368
+ repoLocalDirName(),
369
+ "node_modules",
370
+ "dist",
371
+ "build",
372
+ ".next",
373
+ ".turbo",
374
+ ".venv",
375
+ "venv",
376
+ "target",
377
+ ".cache",
378
+ ]);
379
+
380
+ /**
381
+ * Records a Repository Link. Deliberately writes no projection file: wrapping is
382
+ * an explicit act, which is why the scope and not the inputs picks the entries.
383
+ * Throws on the first failed entry — this caller's own callers warn on it, so
384
+ * the owner's per-entry best-effort report must not silently become success.
385
+ */
386
+ export async function writeRepoLocalRegistryEntry(
387
+ repoPath: string,
388
+ companionPath: string,
389
+ repository: LinkedRepository,
390
+ source: CompanionSource,
391
+ ): Promise<void> {
392
+ const { outcomes } = await project("link", { repoPath, companionPath, repository, source });
393
+ const failure = firstFailure(outcomes);
394
+ if (failure) throw failure.error ?? new Error(`failed to project ${failure.path}`);
395
+ }
396
+
397
+ export async function findRepoLocalLinkedRepository(cwd: string): Promise<LinkedRepository | null> {
398
+ const found = await findRepoLocalRegistryFile(cwd);
399
+ if (!found) return null;
400
+
401
+ try {
402
+ const raw = await fs.readFile(found.registryPath, "utf8");
403
+ const parsed = parse(raw) as Partial<RepoLocalRegistry> | null;
404
+ if (!parsed?.repository) return null;
405
+ return normalizeLinkedRepository(parsed.repository);
406
+ } catch {
407
+ return null;
408
+ }
409
+ }
410
+
411
+ /** Walks up from `cwd` looking for the nearest ancestor holding a repo-local registry file. */
412
+ export async function findRepoLocalRegistryFile(
413
+ cwd: string,
414
+ ): Promise<RepoLocalRegistryFile | null> {
415
+ for (const dir of ancestorDirectories(cwd)) {
416
+ const candidate = repoLocalRegistryPath(dir);
417
+ try {
418
+ // oxlint-disable-next-line no-await-in-loop -- the nearest ancestor wins, so stop at the first hit
419
+ if ((await fs.stat(candidate)).isFile()) return { repoRoot: dir, registryPath: candidate };
420
+ } catch {
421
+ // keep walking up
422
+ }
423
+ }
424
+ return null;
425
+ }
426
+
427
+ /** Finds repo-local registries strictly beneath `rootPath`, skipping noisy directories. */
428
+ export async function findDescendantRepoLocalRegistries(rootPath: string): Promise<string[]> {
429
+ const resolvedRootPath = path.resolve(rootPath);
430
+ const pending = [resolvedRootPath];
431
+ const registryCandidates: Array<{ registryPath: string; shadowedPath: string }> = [];
432
+ const skipDirNames = repoLocalScanSkipDirNames();
433
+
434
+ while (pending.length > 0) {
435
+ const currentPath = pending.pop();
436
+ if (!currentPath) continue;
437
+
438
+ // oxlint-disable-next-line no-await-in-loop -- each listing feeds the queue the walk consumes
439
+ const entries = await fs.readdir(currentPath, { withFileTypes: true }).catch(() => null);
440
+ if (!entries) continue;
441
+
442
+ for (const entry of entries) {
443
+ if (!entry.isDirectory()) continue;
444
+
445
+ const entryPath = path.join(currentPath, entry.name);
446
+ if (entry.name === repoLocalDirName()) {
447
+ if (currentPath !== resolvedRootPath) {
448
+ registryCandidates.push({
449
+ registryPath: path.join(entryPath, "config", "registry.yaml"),
450
+ shadowedPath: currentPath,
451
+ });
452
+ }
453
+ continue;
454
+ }
455
+ if (skipDirNames.has(entry.name)) continue;
456
+
457
+ pending.push(entryPath);
458
+ }
459
+ }
460
+
461
+ const shadowedPaths = await Promise.all(
462
+ registryCandidates.map(async ({ registryPath, shadowedPath }) => {
463
+ try {
464
+ const stats = await fs.stat(registryPath);
465
+ return stats.isFile() ? shadowedPath : null;
466
+ } catch {
467
+ // A missing or unreadable registry is not a shadowed link.
468
+ return null;
469
+ }
470
+ }),
471
+ );
472
+ return shadowedPaths.filter((candidate): candidate is string => candidate !== null).toSorted();
473
+ }
@@ -2,10 +2,13 @@ import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
 
4
4
  import { FRAMEWORK_NAME } from "../../framework";
5
- import { CompanionResolver, type CompanionResolutionResult } from "./companion-resolver";
5
+ import {
6
+ CompanionResolver,
7
+ type CompanionResolutionResult,
8
+ findRepoLocalLinkedRepository,
9
+ } from "./repository-link";
6
10
  import { ConfigStore, mergeWithDefaults } from "./config-store";
7
11
  import { GlobalConfigStore } from "./global-config-store";
8
- import { findRepoLocalLinkedRepository } from "./repo-local-registry";
9
12
  import type { FrameworkConfig, LinkedRepository } from "./types";
10
13
 
11
14
  /** Root classification shared by install and doctor. `core` = no configured root. */
@@ -105,6 +108,7 @@ async function findConfiguredRoot(cwd: string): Promise<string | null> {
105
108
  let dir = path.resolve(cwd);
106
109
  for (;;) {
107
110
  try {
111
+ // oxlint-disable-next-line no-await-in-loop -- the nearest ancestor wins, so stop at the first hit
108
112
  await fs.access(path.join(dir, `.${FRAMEWORK_NAME}`, "config", "framework.yaml"));
109
113
  return dir;
110
114
  } catch {