@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,503 @@
1
+ import { getActiveDistribution } from "../../distribution";
2
+ import { FRAMEWORK_NAME } from "../../framework";
3
+ import { recordCompanionSyncUnfinished } from "../../runtime/companion-git-state";
4
+ import { withCompanionLock, type CompanionLockOptions } from "../../runtime/companion-lock";
5
+ import {
6
+ launchRecoveryStalenessLines,
7
+ syncCompanionUnattended,
8
+ type UnattendedSyncOutcome,
9
+ } from "../../runtime/companion-sync";
10
+ import { readWrapStateSync, type WrapState } from "../../runtime/wrap-state";
11
+ import { syncCompanionFiles } from "../../tools/setup";
12
+ import { renderAudienceLayer, type RenderedAudienceLayer } from "../../tools/setup/audience-layer";
13
+ import {
14
+ readAudienceSelection,
15
+ resolveAudience,
16
+ } from "../../tools/setup/dynamic-plugins/audiences";
17
+ import { buildSetupInstallationPlan } from "../../tools/setup/engine";
18
+ import type { CapabilityPlugin, LaunchPreflightContext } from "../../tools/setup/plugin";
19
+ import { attendedLockOptions, syncCompanionGit } from "./companion-git-sync";
20
+ import { readCompanionConfig } from "./config-store";
21
+ import { entryFailureMessage, firstFailure, type ProjectionChangedPaths } from "./projection-types";
22
+ import { LaunchPreflightError, type FrameworkConfig, type LinkedRepository } from "./types";
23
+ import { project, projectWorkingRepositoryBestEffort } from "./working-repo-projection";
24
+ import { runWrapPass } from "./wrap-pass";
25
+
26
+ /**
27
+ * Session Prepare: Git sync, file sync, one projection pass, preflight. Never
28
+ * spawns a runtime.
29
+ * - `attended`: managed launch; waits for the lock, stops at first failure.
30
+ * - `local`: plugin's awaited phase; no remote, only tries the lock.
31
+ * - `sync`: plugin's detached fast-forward, then `local` if the companion moved.
32
+ */
33
+
34
+ export type PrepareMode = "attended" | "local" | "sync";
35
+ export type PrepareRuntime = "claude" | "opencode";
36
+
37
+ export type PrepareStepName =
38
+ | "lock"
39
+ | "git-sync"
40
+ | "file-sync"
41
+ | "launch-projection"
42
+ | "working-target"
43
+ | "preflight"
44
+ | "audience-layer";
45
+
46
+ export interface PrepareStep {
47
+ step: PrepareStepName;
48
+ outcome: "ok" | "skipped" | "warning" | "failed";
49
+ detail?: string;
50
+ /** The runtimes a `working-target` or `preflight` step answered for. */
51
+ runtimes?: PrepareRuntime[];
52
+ }
53
+
54
+ export interface PrepareResult {
55
+ /** False when a step failed; warnings alone keep it true. */
56
+ ok: boolean;
57
+ mode: PrepareMode;
58
+ runtime: PrepareRuntime;
59
+ /** Every runtime this prepare reconciled and preflighted. */
60
+ runtimes: PrepareRuntime[];
61
+ repositoryId?: string;
62
+ companionPath: string;
63
+ steps: PrepareStep[];
64
+ warnings: string[];
65
+ /** The sync fast-forwarded the companion. */
66
+ companionChanged?: true;
67
+ /** The first failed step's own error, for an attended caller to raise as it was. */
68
+ failure?: unknown;
69
+ /** The wrap state the `working-target` step's pass left; a launch reads no manifest itself. */
70
+ wrapState?: WrapState;
71
+ /** The active audience's layer, rendered for an attended prepare only. */
72
+ audienceLayer?: RenderedAudienceLayer;
73
+ }
74
+
75
+ export interface PrepareSessionInput {
76
+ runtime: PrepareRuntime;
77
+ /**
78
+ * `local`/`sync`: the runtimes the repository is wrapped for, reconciled in
79
+ * one wrap pass and preflighted each; `[runtime]` when absent.
80
+ */
81
+ runtimes?: readonly PrepareRuntime[];
82
+ companionPath: string;
83
+ /** Absent for a companion-scope launch: nothing is projected. */
84
+ repository?: LinkedRepository;
85
+ mode: PrepareMode;
86
+ /** The companion config the caller already holds; read once here otherwise. */
87
+ config?: FrameworkConfig;
88
+ /** `--no-git`: an attended prepare skips only the Git sync. */
89
+ skipGit?: boolean;
90
+ }
91
+
92
+ /** The detached sync may queue behind an operator answering a merge conflict. */
93
+ const SYNC_LOCK_TIMEOUT_MS = 5 * 60_000;
94
+
95
+ /**
96
+ * Every enabled Capability's preflight for one runtime, run together. Throws a
97
+ * {@link LaunchPreflightError} listing what failed.
98
+ */
99
+ async function runCapabilityPreflight(context: LaunchPreflightContext): Promise<void> {
100
+ const { config, providerId } = context;
101
+ /** Base only: an audience plugin's `forProvider` hooks never run. */
102
+ const preflights = getActiveDistribution()
103
+ .registry.baseRegistrations()
104
+ .flatMap(({ plugin }) => {
105
+ if (plugin.kind !== "capability" || !plugin.isEnabled(config)) return [];
106
+ const capability = plugin as CapabilityPlugin;
107
+ const preflight = capability.forProvider?.[providerId]?.preflight;
108
+ return preflight ? [{ capability, preflight }] : [];
109
+ });
110
+ const results = await Promise.all(
111
+ preflights.map(async ({ capability, preflight }) => {
112
+ try {
113
+ return await preflight(context);
114
+ } catch (error) {
115
+ throw new LaunchPreflightError(
116
+ `Capability preflight failed for ${capability.id} on ${providerId}: ${message(error)}`,
117
+ { cause: error },
118
+ );
119
+ }
120
+ }),
121
+ );
122
+ const diagnostics = results.flat();
123
+
124
+ if (diagnostics.length > 0) {
125
+ throw new LaunchPreflightError(
126
+ ["Capability launch preflight failed.", ...diagnostics.map((item) => `- ${item}`)].join("\n"),
127
+ );
128
+ }
129
+ }
130
+
131
+ export const sessionPrepareDeps = {
132
+ readCompanionConfig,
133
+ syncCompanionGit,
134
+ syncCompanionUnattended,
135
+ recordCompanionSyncUnfinished,
136
+ syncCompanionFiles,
137
+ projectLaunch: (input: Parameters<typeof project>[1]) => project("launch", input),
138
+ /**
139
+ * The Projection Root a Claude launch guarantees for itself. Companion
140
+ * resolution also writes it, but only on the branch that resolved a companion
141
+ * from the Working Repository; asking here makes the guarantee independent of
142
+ * which branch answered. Best-effort — a launch carries its own configuration.
143
+ */
144
+ refreshProjectionRoot: projectWorkingRepositoryBestEffort,
145
+ runWrapPass,
146
+ runCapabilityPreflight,
147
+ withCompanionLock,
148
+ /** As the launch decides it: Git may prompt only with a terminal on both ends. */
149
+ isInteractiveTerminal: () => !!(process.stdin.isTTY && process.stdout.isTTY),
150
+ /** The launch's `MATE_AUDIENCE`; read by an attended prepare only. */
151
+ audienceSelection: () => readAudienceSelection(process.env),
152
+ /** The audience a checkout is wrapped for, from its manifest; `null` for base. */
153
+ wrappedAudience: (repoPath: string) => readWrapStateSync(repoPath).audience,
154
+ renderAudienceLayer,
155
+ };
156
+
157
+ function message(error: unknown): string {
158
+ return error instanceof Error ? error.message : String(error);
159
+ }
160
+
161
+ /** One prepare's running record; `failed` stops an attended prepare. */
162
+ class StepLog {
163
+ readonly steps: PrepareStep[] = [];
164
+ readonly warnings: string[] = [];
165
+ failure: unknown;
166
+ wrapState: WrapState | undefined;
167
+ audienceLayer: RenderedAudienceLayer | undefined;
168
+ stopped = false;
169
+ companionChanged = false;
170
+
171
+ constructor(private readonly attended: boolean) {}
172
+
173
+ push(step: PrepareStep): void {
174
+ this.steps.push(step);
175
+ }
176
+
177
+ fail(
178
+ step: PrepareStepName,
179
+ error: unknown,
180
+ overrides: Pick<PrepareStep, "detail" | "runtimes"> = {},
181
+ ): void {
182
+ this.steps.push({ step, outcome: "failed", detail: message(error), ...overrides });
183
+ if (this.failure === undefined) this.failure = error;
184
+ if (this.attended) this.stopped = true;
185
+ }
186
+ }
187
+
188
+ async function runFileSync(
189
+ input: PrepareSessionInput,
190
+ config: FrameworkConfig,
191
+ run: StepLog,
192
+ ): Promise<void> {
193
+ const deps = sessionPrepareDeps;
194
+ try {
195
+ if (input.repository) {
196
+ await deps.syncCompanionFiles(input.companionPath, config, input.repository.path);
197
+ } else {
198
+ await deps.syncCompanionFiles(input.companionPath, config);
199
+ }
200
+ run.push({ step: "file-sync", outcome: "ok" });
201
+ } catch (error) {
202
+ run.fail("file-sync", error);
203
+ }
204
+ }
205
+
206
+ function wrapDetail(wrapped: {
207
+ writtenDocuments: string[];
208
+ changed: ProjectionChangedPaths;
209
+ }): string {
210
+ const wrote = [...wrapped.writtenDocuments, ...wrapped.changed.written];
211
+ const withdrew = wrapped.changed.removed;
212
+ const parts: string[] = [];
213
+ if (wrote.length > 0) parts.push(`wrote ${wrote.join(", ")}`);
214
+ if (withdrew.length > 0) parts.push(`withdrew ${withdrew.join(", ")}`);
215
+ return parts.length > 0 ? `updated (${parts.join("; ")})` : "updated";
216
+ }
217
+
218
+ function runtimesOf(input: PrepareSessionInput): PrepareRuntime[] {
219
+ return input.mode !== "attended" && input.runtimes?.length
220
+ ? [...input.runtimes]
221
+ : [input.runtime];
222
+ }
223
+
224
+ /**
225
+ * One wrap pass for `runtimes`, OpenCode's copies included. Every managed
226
+ * launch is this pass followed by the agent, for either runtime.
227
+ */
228
+ async function reconcileWrap(
229
+ input: PrepareSessionInput,
230
+ repository: LinkedRepository,
231
+ runtimes: PrepareRuntime[],
232
+ config: FrameworkConfig,
233
+ run: StepLog,
234
+ ): Promise<void> {
235
+ const wrapped = await sessionPrepareDeps.runWrapPass(input.companionPath, repository, {
236
+ runtimes,
237
+ config,
238
+ });
239
+ if (wrapped.kind === "failed") {
240
+ run.fail("working-target", new LaunchPreflightError(wrapped.message), { runtimes });
241
+ return;
242
+ }
243
+ run.warnings.push(...wrapped.warnings);
244
+ run.wrapState = wrapped.wrapState;
245
+ run.push({
246
+ step: "working-target",
247
+ outcome: "ok",
248
+ detail: wrapped.kind === "written" ? wrapDetail(wrapped) : "current",
249
+ runtimes,
250
+ });
251
+ }
252
+
253
+ /**
254
+ * Claude: the launch scope, then the Projection Root. A non-degradable entry is
255
+ * a guardrail the session was promised, so it fails the prepare; a degradable
256
+ * one warns and the prepare proceeds.
257
+ */
258
+ async function projectClaude(
259
+ input: PrepareSessionInput,
260
+ repository: LinkedRepository,
261
+ config: FrameworkConfig,
262
+ run: StepLog,
263
+ ): Promise<void> {
264
+ const deps = sessionPrepareDeps;
265
+ const result = await deps.projectLaunch({
266
+ repoPath: repository.path,
267
+ companionPath: input.companionPath,
268
+ config,
269
+ });
270
+ for (const outcome of result.outcomes) {
271
+ if (outcome.state === "failed" && outcome.degradable) {
272
+ run.warnings.push(entryFailureMessage(outcome, repository.id));
273
+ }
274
+ }
275
+ const failure = firstFailure(result.outcomes);
276
+ if (failure) {
277
+ const detail = entryFailureMessage(failure, repository.id);
278
+ run.fail("launch-projection", new LaunchPreflightError(`${FRAMEWORK_NAME}: ${detail}`), {
279
+ detail,
280
+ });
281
+ return;
282
+ }
283
+ run.push({ step: "launch-projection", outcome: "ok" });
284
+ await deps.refreshProjectionRoot(input.companionPath, repository);
285
+ /** A launch is a wrap: created when missing, reconciled when stale, so the launch can defer to it. */
286
+ await reconcileWrap(input, repository, ["claude"], config, run);
287
+ }
288
+
289
+ /** Attended: the launch's own runtime. Otherwise one wrap pass for every wrapped runtime. */
290
+ async function projectRepository(
291
+ input: PrepareSessionInput,
292
+ repository: LinkedRepository,
293
+ runtimes: PrepareRuntime[],
294
+ config: FrameworkConfig,
295
+ run: StepLog,
296
+ ): Promise<void> {
297
+ if (input.mode === "attended" && input.runtime === "claude") {
298
+ await projectClaude(input, repository, config, run);
299
+ return;
300
+ }
301
+ await reconcileWrap(input, repository, runtimes, config, run);
302
+ }
303
+
304
+ /** File sync, one projection pass, and the capability preflight per runtime. */
305
+ async function runLocal(
306
+ input: PrepareSessionInput,
307
+ config: FrameworkConfig,
308
+ run: StepLog,
309
+ ): Promise<void> {
310
+ await runFileSync(input, config, run);
311
+ if (run.stopped) return;
312
+
313
+ const runtimes = runtimesOf(input);
314
+ if (input.repository) {
315
+ await projectRepository(input, input.repository, runtimes, config, run);
316
+ if (run.stopped) return;
317
+ }
318
+
319
+ /** Attended prepares one runtime, so running these together never skips a stop. */
320
+ const outcomes = await Promise.allSettled(
321
+ runtimes.map((runtime) =>
322
+ sessionPrepareDeps.runCapabilityPreflight({
323
+ companionPath: input.companionPath,
324
+ config,
325
+ repository: input.repository,
326
+ providerId: runtime,
327
+ }),
328
+ ),
329
+ );
330
+ outcomes.forEach((outcome, index) => {
331
+ const answered = [runtimes[index]!];
332
+ if (outcome.status === "fulfilled") {
333
+ run.push({ step: "preflight", outcome: "ok", runtimes: answered });
334
+ } else {
335
+ run.fail("preflight", outcome.reason, { runtimes: answered });
336
+ }
337
+ });
338
+ }
339
+
340
+ /**
341
+ * The active audience's launch layer, after the base prepare. Only a managed
342
+ * launch reaches here: a bare session's prepare never renders one, whatever
343
+ * `MATE_AUDIENCE` its environment carries. An audience the companion does not
344
+ * declare renders nothing; hydration already warned about it.
345
+ */
346
+ async function renderLayer(
347
+ input: PrepareSessionInput,
348
+ config: FrameworkConfig,
349
+ run: StepLog,
350
+ ): Promise<void> {
351
+ const deps = sessionPrepareDeps;
352
+ /** Adopted from the wrap: the wrap delivers it, so the launch renders no layer of its own. */
353
+ const wrapped = input.repository ? deps.wrappedAudience(input.repository.path) : null;
354
+ if (wrapped) {
355
+ run.push({ step: "audience-layer", outcome: "ok", detail: `${wrapped}: from the wrap` });
356
+ return;
357
+ }
358
+ const audience = resolveAudience(config, deps.audienceSelection());
359
+ if (!audience.ok || audience.active === null) return;
360
+ try {
361
+ const registry = getActiveDistribution().registry;
362
+ const layer = await deps.renderAudienceLayer({
363
+ companionPath: input.companionPath,
364
+ config,
365
+ audience: audience.active,
366
+ activeProviders: buildSetupInstallationPlan(config, registry.baseRegistrations())
367
+ .activeProviders,
368
+ registrations: registry.audienceRegistrations(audience.active),
369
+ });
370
+ run.audienceLayer = layer;
371
+ run.push({
372
+ step: "audience-layer",
373
+ outcome: "ok",
374
+ detail: `${layer.audience}: ${layer.rendered ? "rendered" : "current"}`,
375
+ });
376
+ } catch (error) {
377
+ run.fail("audience-layer", error);
378
+ }
379
+ }
380
+
381
+ /**
382
+ * A checkout wrapped for one audience refuses a launch for another before
383
+ * anything runs: the wrap's on-disk content would reach that session.
384
+ */
385
+ function audienceConflict(input: PrepareSessionInput, config: FrameworkConfig): string | null {
386
+ if (!input.repository) return null;
387
+ const wrapped = sessionPrepareDeps.wrappedAudience(input.repository.path);
388
+ if (!wrapped) return null;
389
+ const selected = resolveAudience(config, sessionPrepareDeps.audienceSelection());
390
+ if (!selected.ok || selected.active === null || selected.active === wrapped) return null;
391
+ return `${FRAMEWORK_NAME}: ${input.repository.id} is wrapped for audience "${wrapped}", but MATE_AUDIENCE selects "${selected.active}". Run \`${FRAMEWORK_NAME} wrap --audience ${selected.active}\` in ${input.repository.path}, or launch from another worktree wrapped for "${selected.active}".`;
392
+ }
393
+
394
+ async function runAttended(
395
+ input: PrepareSessionInput,
396
+ config: FrameworkConfig,
397
+ run: StepLog,
398
+ ): Promise<void> {
399
+ const deps = sessionPrepareDeps;
400
+ const conflict = audienceConflict(input, config);
401
+ if (conflict) {
402
+ run.fail("audience-layer", new LaunchPreflightError(conflict));
403
+ return;
404
+ }
405
+ if (input.skipGit) {
406
+ run.push({ step: "git-sync", outcome: "skipped", detail: "--no-git" });
407
+ } else if (config.git !== "auto") {
408
+ run.push({ step: "git-sync", outcome: "skipped", detail: "companion Git mode is not auto" });
409
+ } else {
410
+ try {
411
+ const synced = await deps.syncCompanionGit(
412
+ input.companionPath,
413
+ input.repository?.path,
414
+ deps.isInteractiveTerminal(),
415
+ );
416
+ run.push({ step: "git-sync", outcome: "ok" });
417
+ /** A pull may have changed the companion's own selection. */
418
+ if (synced?.changed) config = await deps.readCompanionConfig(input.companionPath);
419
+ } catch (error) {
420
+ run.fail("git-sync", error);
421
+ return;
422
+ }
423
+ }
424
+ await runLocal(input, config, run);
425
+ if (run.stopped) return;
426
+ await renderLayer(input, config, run);
427
+ }
428
+
429
+ /** The sync's own config read: only a fast-forward can have changed it. */
430
+ async function runSync(input: PrepareSessionInput, run: StepLog): Promise<void> {
431
+ const deps = sessionPrepareDeps;
432
+ let outcome: UnattendedSyncOutcome;
433
+ try {
434
+ outcome = await deps.syncCompanionUnattended(input.companionPath, { ttlMs: 0 });
435
+ } catch (error) {
436
+ const reason = message(error);
437
+ /** Persisted like any unfinished sync, so the TUI's session lines carry it. */
438
+ deps.recordCompanionSyncUnfinished(input.companionPath, reason);
439
+ outcome = { status: "unfinished", changed: false, companionPath: input.companionPath, reason };
440
+ }
441
+
442
+ if (outcome.status === "unfinished") {
443
+ run.push({ step: "git-sync", outcome: "warning", detail: outcome.reason });
444
+ run.warnings.push(...launchRecoveryStalenessLines(outcome, runtimesOf(input)));
445
+ return;
446
+ }
447
+ if (outcome.status !== "complete") {
448
+ run.push({ step: "git-sync", outcome: "skipped", detail: outcome.status });
449
+ return;
450
+ }
451
+ if (!outcome.changed) {
452
+ run.push({ step: "git-sync", outcome: "ok", detail: "current" });
453
+ return;
454
+ }
455
+ run.push({ step: "git-sync", outcome: "ok", detail: "fast-forwarded" });
456
+ run.companionChanged = true;
457
+ await runLocal(input, await deps.readCompanionConfig(input.companionPath), run);
458
+ }
459
+
460
+ function lockOptions(input: PrepareSessionInput): CompanionLockOptions {
461
+ if (input.mode === "attended") return attendedLockOptions(input.companionPath);
462
+ if (input.mode === "local") return { wait: false };
463
+ return { timeoutMs: SYNC_LOCK_TIMEOUT_MS };
464
+ }
465
+
466
+ export async function prepareSession(input: PrepareSessionInput): Promise<PrepareResult> {
467
+ const deps = sessionPrepareDeps;
468
+ const run = new StepLog(input.mode === "attended");
469
+ const result = (): PrepareResult => ({
470
+ ok: !run.steps.some((step) => step.outcome === "failed"),
471
+ mode: input.mode,
472
+ runtime: input.runtime,
473
+ runtimes: runtimesOf(input),
474
+ ...(input.repository ? { repositoryId: input.repository.id } : {}),
475
+ companionPath: input.companionPath,
476
+ steps: run.steps,
477
+ warnings: run.warnings,
478
+ ...(run.companionChanged ? { companionChanged: true as const } : {}),
479
+ ...(run.failure === undefined ? {} : { failure: run.failure }),
480
+ ...(run.wrapState ? { wrapState: run.wrapState } : {}),
481
+ ...(run.audienceLayer ? { audienceLayer: run.audienceLayer } : {}),
482
+ });
483
+
484
+ const locked = await deps.withCompanionLock(input.companionPath, lockOptions(input), async () => {
485
+ if (input.mode === "sync") return runSync(input, run);
486
+ const config = input.config ?? (await deps.readCompanionConfig(input.companionPath));
487
+ return input.mode === "attended"
488
+ ? runAttended(input, config, run)
489
+ : runLocal(input, config, run);
490
+ });
491
+
492
+ if (!locked.acquired) {
493
+ if (input.mode === "local") {
494
+ /** Another prepare is on it; the projection already in place serves the session. */
495
+ run.push({ step: "lock", outcome: "skipped", detail: "another prepare holds the companion" });
496
+ } else {
497
+ const detail = "another companion sync did not finish in time; skipped";
498
+ run.push({ step: "lock", outcome: "warning", detail });
499
+ run.warnings.push(detail);
500
+ }
501
+ }
502
+ return result();
503
+ }
@@ -173,7 +173,7 @@ export const BUILTIN_SETUP_COMPATIBILITIES: SetupCompatibility[] = [
173
173
  kind: "capability",
174
174
  capability: { name: "react-doctor" },
175
175
  label: "React Doctor",
176
- description: "Install React Doctor skills and Claude post-edit scans.",
176
+ description: "Install the mate-react-doctor skill that verifies React changes during apply.",
177
177
  defaultSelected: true,
178
178
  templateRoot: "capabilities/react-doctor",
179
179
  },
@@ -6,7 +6,7 @@ import { parse } from "yaml";
6
6
  import { FRAMEWORK_NAME } from "../../framework";
7
7
  import { fileExists } from "../fs-utils";
8
8
  import { GlobalConfigStore } from "./global-config-store";
9
- import { CompanionResolver, type CompanionMatch } from "./companion-resolver";
9
+ import { CompanionResolver, type CompanionMatch } from "./repository-link";
10
10
 
11
11
  export type SetupPreflightResult =
12
12
  | { kind: "existing-companion" }
@@ -104,6 +104,7 @@ export async function looksLikeWorkingRepo(cwd: string): Promise<boolean> {
104
104
  const resolvedCwd = path.resolve(cwd);
105
105
 
106
106
  for (const marker of STRONG_FILE_MARKERS) {
107
+ // oxlint-disable-next-line no-await-in-loop -- one strong marker settles it; stop probing
107
108
  if (await fileExists(path.join(resolvedCwd, marker))) {
108
109
  return true;
109
110
  }
@@ -115,6 +116,7 @@ export async function looksLikeWorkingRepo(cwd: string): Promise<boolean> {
115
116
 
116
117
  let weakCount = 0;
117
118
  for (const marker of WEAK_FILE_MARKERS) {
119
+ // oxlint-disable-next-line no-await-in-loop -- stops probing at the second weak marker
118
120
  if (await fileExists(path.join(resolvedCwd, marker))) {
119
121
  weakCount++;
120
122
  if (weakCount >= 2) return true;