@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
@@ -4,8 +4,15 @@ import path from "node:path";
4
4
 
5
5
  import { FRAMEWORK_NAME } from "../../../framework";
6
6
  import { MATE_ENV } from "../../../runtime/env-names";
7
+ import { getWrapperBinPath } from "../../package-paths";
7
8
  import { buildProjection, companionEnvironment, projectionEnvironment } from "../projection-record";
8
- import type { CapabilityConfig, GitModeProfile, LinkedRepository } from "../types";
9
+ import type { PrepareResult } from "../session-prepare";
10
+ import {
11
+ LaunchPreflightError,
12
+ type CapabilityConfig,
13
+ type GitModeProfile,
14
+ type LinkedRepository,
15
+ } from "../types";
9
16
 
10
17
  export interface AdapterContext {
11
18
  repository?: LinkedRepository;
@@ -47,15 +54,42 @@ function prependPathEntry(pathValue: string | undefined, entry: string): string
47
54
  return [entry, ...entries.filter((value) => value !== entry)].join(path.delimiter);
48
55
  }
49
56
 
50
- export abstract class LaunchAdapter {
57
+ export function prepareFailure(result: PrepareResult): string {
58
+ return [
59
+ `${FRAMEWORK_NAME}: preparing ${result.repositoryId} for OpenCode failed.`,
60
+ ...result.steps.flatMap((step) =>
61
+ step.outcome === "failed" ? [`- ${step.step}: ${step.detail ?? "failed"}`] : [],
62
+ ),
63
+ ].join("\n");
64
+ }
65
+
66
+ /**
67
+ * `C` is the context one launch carries from `planLaunch` to `run`: returned,
68
+ * never stored, since one adapter instance serves every launch.
69
+ */
70
+ export abstract class LaunchAdapter<C extends AdapterContext = AdapterContext> {
51
71
  abstract readonly toolName: string;
52
72
  readonly interactive: boolean = false;
53
73
 
54
- abstract buildArgs(context: AdapterContext, args: string[]): string[];
74
+ abstract buildArgs(context: C, args: string[]): string[];
75
+
76
+ /**
77
+ * The runtime's share of a launch after a successful Session Prepare. Nothing
78
+ * to plan by default; an adapter that narrows `C` overrides it.
79
+ */
80
+ async planLaunch(context: AdapterContext, _prepared: PrepareResult): Promise<C> {
81
+ return context as C;
82
+ }
83
+
84
+ /** What a failed prepare raises: the failed step's own error, else a summary. */
85
+ launchFailure(prepared: PrepareResult): unknown {
86
+ return prepared.failure ?? new LaunchPreflightError(prepareFailure(prepared));
87
+ }
55
88
 
56
- async validateLaunch(_context: AdapterContext): Promise<void> {}
89
+ async validateLaunch(_context: C): Promise<void> {}
57
90
 
58
- extendEnvironment(_context: AdapterContext): NodeJS.ProcessEnv {
91
+ /** `args` are the operator's arguments, before `buildArgs` rewrites them. */
92
+ extendEnvironment(_context: C, _args: string[] = []): NodeJS.ProcessEnv {
59
93
  return {};
60
94
  }
61
95
 
@@ -67,7 +101,9 @@ export abstract class LaunchAdapter {
67
101
  environment(context: AdapterContext): NodeJS.ProcessEnv {
68
102
  const reactDoctorEnabled = context.capabilities.some((c) => c.name === "react-doctor");
69
103
  const projection = context.repository
70
- ? projectionEnvironment(buildProjection(context.companionPath, context.repository))
104
+ ? projectionEnvironment(
105
+ buildProjection(context.companionPath, context.repository, getWrapperBinPath()),
106
+ )
71
107
  : companionEnvironment(context.companionPath);
72
108
  const env: NodeJS.ProcessEnv = {
73
109
  ...process.env,
@@ -92,10 +128,10 @@ export abstract class LaunchAdapter {
92
128
  return env;
93
129
  }
94
130
 
95
- async prepareLaunch(context: AdapterContext, args: string[]): Promise<PreparedLaunch> {
131
+ async prepareLaunch(context: C, args: string[]): Promise<PreparedLaunch> {
96
132
  const builtArgs = this.buildArgs(context, args);
97
133
  const baseEnv = this.environment(context);
98
- const extendedEnv = this.extendEnvironment(context);
134
+ const extendedEnv = this.extendEnvironment(context, args);
99
135
  const env = { ...baseEnv, ...extendedEnv };
100
136
 
101
137
  const processPath = process.env.PATH ?? "";
@@ -120,7 +156,7 @@ export abstract class LaunchAdapter {
120
156
  return !(this.interactive && process.stdin.isTTY);
121
157
  }
122
158
 
123
- async run(context: AdapterContext, args: string[]): Promise<AdapterResult> {
159
+ async run(context: C, args: string[]): Promise<AdapterResult> {
124
160
  const launch = await this.prepareLaunch(context, args);
125
161
 
126
162
  return new Promise((resolve, reject) => {
@@ -1,21 +1,223 @@
1
1
  import { existsSync } from "node:fs";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
2
4
 
5
+ import { FRAMEWORK_NAME } from "../../../framework";
3
6
  import { buildCompanionGuidance } from "../../../playbooks/companion-guidance";
4
- import { getContextModePackageRoot, validateContextModePackage } from "../../context-mode-package";
5
- import { getClaudePluginRoot, validateClaudePluginAssets } from "../../package-paths";
7
+ import {
8
+ planClaudeDelivery,
9
+ type ClaudeDeliveryPlan,
10
+ type SessionKind,
11
+ } from "../../../runtime/claude-delivery-plan";
12
+ import {
13
+ audienceLayerDir,
14
+ currentAudienceLayer,
15
+ readLayerGuidanceSync,
16
+ } from "../../../runtime/audience-layer";
17
+ import { hookGroupKey, type ClaudeHookGroup } from "../../../runtime/claude-hook-group";
18
+ import { NOT_WRAPPED } from "../../../runtime/wrap-state";
19
+ import { writeAtomic } from "../../../runtime/write-atomic";
6
20
  import {
7
21
  getCompanionClaudeMcpConfigPath,
8
22
  getCompanionClaudeSettingsPath,
9
23
  } from "../../../tools/setup/providers/claude";
24
+ import type { ClaudeSettings } from "../../../tools/setup/providers/claude-format";
25
+ import { getContextModePackageRoot, validateContextModePackage } from "../../context-mode-package";
26
+ import { getClaudePluginRoot, validateClaudePluginAssets } from "../../package-paths";
27
+ import type { RenderedAudienceLayer } from "../../../tools/setup/audience-layer";
28
+ import type { PrepareResult } from "../session-prepare";
29
+ import { LaunchPreflightError } from "../types";
10
30
  import { type AdapterContext, LaunchAdapter } from "./base";
11
31
 
12
- export class ClaudeAdapter extends LaunchAdapter {
32
+ /** The active audience's layer, attached to this launch only. */
33
+ interface ClaudeLayerDelivery {
34
+ audience: string;
35
+ root: string;
36
+ /** The layer's MCP configuration, when the layer holds one. */
37
+ mcpConfigPath?: string;
38
+ }
39
+
40
+ /** The paths are present only where the plan names that channel and the file exists. */
41
+ interface ClaudeDelivery {
42
+ plan: ClaudeDeliveryPlan;
43
+ settingsPath?: string;
44
+ mcpConfigPath?: string;
45
+ layer?: ClaudeLayerDelivery;
46
+ /**
47
+ * The guidance of the audience the checkout is wrapped for. The wrap carries
48
+ * the rest of that audience; its guidance hook stays silent in a managed
49
+ * session, so the launch's own system prompt carries this.
50
+ */
51
+ wrappedGuidance?: string;
52
+ }
53
+
54
+ /** A Claude launch's context: the delivery its plan chose, and the files it chose or wrote. */
55
+ interface ClaudeLaunchContext extends AdapterContext {
56
+ delivery: ClaudeDelivery;
57
+ }
58
+
59
+ function safeName(value: string): string {
60
+ return value.replace(/[^A-Za-z0-9._-]/g, "_");
61
+ }
62
+
63
+ /**
64
+ * Where a launch writes its derived `--settings`. Per Working Repository and
65
+ * audience, so concurrent launches never race; a companion-scoped launch
66
+ * derives one only for an audience. Without an audience it is git-ignored
67
+ * under the companion. With one it carries that audience's hooks and
68
+ * permission entries, so it lives beside the audience's layer, outside the
69
+ * companion, where no other audience's session can read it.
70
+ */
71
+ export function launchSettingsPath(
72
+ companionPath: string,
73
+ repositoryId: string | null,
74
+ audience?: string,
75
+ ): string {
76
+ const scope = repositoryId === null ? "companion-scope" : `settings.${safeName(repositoryId)}`;
77
+ if (audience) {
78
+ return path.join(audienceLayerDir(companionPath, audience), `launch-${scope}.json`);
79
+ }
80
+ return path.join(companionPath, ".claude", "state", `launch-${scope}.json`);
81
+ }
82
+
83
+ async function readSettings(settingsPath: string): Promise<ClaudeSettings> {
84
+ if (!existsSync(settingsPath)) return {};
85
+ return JSON.parse(await fs.readFile(settingsPath, "utf8")) as ClaudeSettings;
86
+ }
87
+
88
+ /**
89
+ * Companion settings minus `exclude`, so each wrap-carried group runs once,
90
+ * plus the layer's hook groups and permission entries. A file, not inline JSON
91
+ * (platform arg limits). Claude reads one `--settings` file only — a second
92
+ * one replaces the first — so the layer is merged here, never passed beside.
93
+ */
94
+ async function writeLaunchSettings(
95
+ companionSettingsPath: string,
96
+ target: string,
97
+ exclude: readonly ClaudeHookGroup[],
98
+ layerSettingsPath?: string,
99
+ ): Promise<void> {
100
+ const settings = await readSettings(companionSettingsPath);
101
+ const layer = layerSettingsPath ? await readSettings(layerSettingsPath) : {};
102
+ const excluded = new Set(exclude.map(hookGroupKey));
103
+ const hooks: Record<string, ClaudeHookGroup[]> = {};
104
+ for (const [event, groups] of Object.entries(settings.hooks ?? {})) {
105
+ const kept = (groups ?? []).filter((group) => !excluded.has(hookGroupKey(group)));
106
+ if (kept.length > 0) hooks[event] = kept;
107
+ }
108
+ for (const [event, groups] of Object.entries(layer.hooks ?? {})) {
109
+ hooks[event] = [...(hooks[event] ?? []), ...(groups ?? [])];
110
+ }
111
+ const derived: ClaudeSettings = { ...settings, hooks };
112
+ if (Object.keys(hooks).length === 0) delete derived.hooks;
113
+ const layerAllow = layer.permissions?.allow ?? [];
114
+ if (layerAllow.length > 0) {
115
+ const allow = [...new Set([...(settings.permissions?.allow ?? []), ...layerAllow])];
116
+ derived.permissions = { ...settings.permissions, allow };
117
+ }
118
+
119
+ const text = `${JSON.stringify(derived, null, 2)}\n`;
120
+ if (layerSettingsPath) {
121
+ /** Beside the audience's layer: as private as the layer itself. */
122
+ await fs.mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
123
+ await writeAtomic(target, text, 0o600);
124
+ } else {
125
+ await fs.mkdir(path.dirname(target), { recursive: true });
126
+ await writeAtomic(target, text);
127
+ }
128
+ }
129
+
130
+ /** The `--settings` file the plan's hook owner calls for, writing it when derived. */
131
+ async function settingsPathFor(
132
+ plan: ClaudeDeliveryPlan,
133
+ companionPath: string,
134
+ repository: AdapterContext["repository"],
135
+ layer: RenderedAudienceLayer | undefined,
136
+ ): Promise<string | undefined> {
137
+ const companionSettingsPath = getCompanionClaudeSettingsPath(companionPath);
138
+ if (plan.hooks.owner === "none") return undefined;
139
+ if (!layer && !existsSync(companionSettingsPath)) return undefined;
140
+ if (plan.hooks.owner === "settings" && !layer) return companionSettingsPath;
141
+ /** Owner `wrap` is planned only for `managed-repo`, which has a repository. */
142
+ const settingsPath = launchSettingsPath(
143
+ companionPath,
144
+ plan.hooks.owner === "wrap" ? repository!.id : null,
145
+ layer?.audience,
146
+ );
147
+ await writeLaunchSettings(
148
+ companionSettingsPath,
149
+ settingsPath,
150
+ plan.hooks.launchSettingsExclude,
151
+ layer ? getCompanionClaudeSettingsPath(layer.root) : undefined,
152
+ );
153
+ return settingsPath;
154
+ }
155
+
156
+ /** The layer as this launch attaches it: the directory, and its MCP configuration when present. */
157
+ function layerDelivery(layer: RenderedAudienceLayer | undefined): ClaudeLayerDelivery | undefined {
158
+ if (!layer) return undefined;
159
+ const mcpConfigPath = getCompanionClaudeMcpConfigPath(layer.root);
160
+ return {
161
+ audience: layer.audience,
162
+ root: layer.root,
163
+ ...(existsSync(mcpConfigPath) ? { mcpConfigPath } : {}),
164
+ };
165
+ }
166
+
167
+ export class ClaudeAdapter extends LaunchAdapter<ClaudeLaunchContext> {
13
168
  readonly toolName = "claude";
14
169
  readonly interactive = true;
15
170
 
16
- async validateLaunch(context: AdapterContext): Promise<void> {
17
- // The bundled mate plugin carries the artifact-path guard; never launch a
18
- // managed session without it.
171
+ /**
172
+ * Plans from the wrap state the prepare's pass left, never a fresh read. A
173
+ * repository left without a Claude wrap fails preflight: the plan would defer
174
+ * to a wrap that is not there.
175
+ */
176
+ async planLaunch(context: AdapterContext, prepared: PrepareResult): Promise<ClaudeLaunchContext> {
177
+ const { repository, companionPath } = context;
178
+ const kind: SessionKind = repository ? "managed-repo" : "managed-companion";
179
+ const wrap = prepared.wrapState ?? NOT_WRAPPED;
180
+ if (repository && !wrap.runtimes.has("claude")) {
181
+ throw new LaunchPreflightError(
182
+ `${FRAMEWORK_NAME}: ${repository.id} is not wrapped for Claude after its prepare; run \`${FRAMEWORK_NAME} wrap\` in ${repository.path}.`,
183
+ );
184
+ }
185
+ const plan = planClaudeDelivery(kind, wrap);
186
+ const mcpConfigPath = getCompanionClaudeMcpConfigPath(companionPath);
187
+ const settingsPath = await settingsPathFor(
188
+ plan,
189
+ companionPath,
190
+ repository,
191
+ prepared.audienceLayer,
192
+ );
193
+ const layer = layerDelivery(prepared.audienceLayer);
194
+ const wrappedLayer =
195
+ !layer && wrap.audience ? currentAudienceLayer(companionPath, wrap.audience) : null;
196
+ const wrappedGuidance = wrappedLayer ? readLayerGuidanceSync(wrappedLayer, "CLAUDE.md") : "";
197
+ const delivery: ClaudeDelivery = {
198
+ plan,
199
+ ...(plan.mcp === "mcp-config" && existsSync(mcpConfigPath) ? { mcpConfigPath } : {}),
200
+ ...(settingsPath ? { settingsPath } : {}),
201
+ ...(layer ? { layer } : {}),
202
+ ...(wrappedGuidance ? { wrappedGuidance } : {}),
203
+ };
204
+ return { ...context, delivery };
205
+ }
206
+
207
+ /** A failed Claude wrap pass names the entry that failed. */
208
+ launchFailure(prepared: PrepareResult): unknown {
209
+ const failed = prepared.steps.find((step) => step.outcome === "failed");
210
+ if (failed?.step === "working-target") {
211
+ return new LaunchPreflightError(
212
+ `${FRAMEWORK_NAME}: reconciling the Claude wrap of ${prepared.repositoryId} failed: ${failed.detail ?? "failed"}`,
213
+ { cause: prepared.failure },
214
+ );
215
+ }
216
+ return super.launchFailure(prepared);
217
+ }
218
+
219
+ async validateLaunch(context: ClaudeLaunchContext): Promise<void> {
220
+ /** The bundled plugin carries the artifact-path guard; never launch without it. */
19
221
  try {
20
222
  validateClaudePluginAssets();
21
223
  } catch (error) {
@@ -37,24 +239,35 @@ export class ClaudeAdapter extends LaunchAdapter {
37
239
  }
38
240
  }
39
241
 
40
- buildArgs(context: AdapterContext, args: string[]): string[] {
41
- // Load Mate-managed Claude config from companion settings when present,
42
- // layered on top of the user's user/project/local sources so global
43
- // skills and plugins still load. Only add the flags when the file
44
- // exists so a companion without generated settings still launches with
45
- // Claude's default discovery.
46
- const companionSettingsPath = getCompanionClaudeSettingsPath(context.companionPath);
47
- const settingsArgs = existsSync(companionSettingsPath)
48
- ? ["--setting-sources", "user,project,local", "--settings", companionSettingsPath]
49
- : [];
50
-
51
- // Load Mate-managed MCP servers from the companion `.mcp.json` when present.
52
- // It lives in the companion repo (not the working-repo cwd), so it must be
53
- // passed explicitly rather than relying on Claude's cwd auto-discovery.
54
- const companionMcpConfigPath = getCompanionClaudeMcpConfigPath(context.companionPath);
55
- const mcpConfigArgs = existsSync(companionMcpConfigPath)
56
- ? ["--mcp-config", companionMcpConfigPath]
57
- : [];
242
+ /**
243
+ * Plan in, flags out; decides nothing. `--append-system-prompt`, `--plugin-dir`
244
+ * and context-mode are unconditional: no settings document can carry them.
245
+ */
246
+ buildArgs(context: ClaudeLaunchContext, args: string[]): string[] {
247
+ const { plan, settingsPath, mcpConfigPath, layer, wrappedGuidance } = context.delivery;
248
+ const companionGuidance = buildCompanionGuidance(context);
249
+ const systemPrompt = wrappedGuidance
250
+ ? `${companionGuidance}\n\n${wrappedGuidance}`
251
+ : companionGuidance;
252
+ /** Layered on the user's own sources so global skills and plugins still load. */
253
+ const settingsArgs =
254
+ plan.hooks.owner !== "none" && settingsPath
255
+ ? ["--setting-sources", "user,project,local", "--settings", settingsPath]
256
+ : [];
257
+ /**
258
+ * The layer adds its skills, commands, agents and `CLAUDE.md` as one more
259
+ * directory, and its MCP servers as one more configuration beside every
260
+ * server the launch delivered before. Both flags are variadic, so each is
261
+ * followed by another flag, never by the operator's arguments.
262
+ */
263
+ const addDirArgs = [
264
+ ...(plan.dirAccess === "add-dir" ? ["--add-dir", context.companionPath] : []),
265
+ ...(layer ? ["--add-dir", layer.root] : []),
266
+ ];
267
+ const mcpConfigArgs = [
268
+ ...(plan.mcp === "mcp-config" && mcpConfigPath ? ["--mcp-config", mcpConfigPath] : []),
269
+ ...(layer?.mcpConfigPath ? ["--mcp-config", layer.mcpConfigPath] : []),
270
+ ];
58
271
  const contextModeArgs = context.capabilities.some(
59
272
  (capability) => capability.name === "context-mode",
60
273
  )
@@ -62,14 +275,12 @@ export class ClaudeAdapter extends LaunchAdapter {
62
275
  : [];
63
276
 
64
277
  return [
65
- "--add-dir",
66
- context.companionPath,
278
+ ...addDirArgs,
67
279
  "--append-system-prompt",
68
- buildCompanionGuidance(context),
280
+ systemPrompt,
69
281
  ...settingsArgs,
70
282
  ...mcpConfigArgs,
71
- // Bundled mate plugin (hooks) resolved from the running mate-core
72
- // installation, coexisting with the context-mode plugin dir.
283
+ /** The bundled plugin from the running install, beside context-mode's. */
73
284
  "--plugin-dir",
74
285
  getClaudePluginRoot(),
75
286
  ...contextModeArgs,
@@ -77,7 +288,7 @@ export class ClaudeAdapter extends LaunchAdapter {
77
288
  ];
78
289
  }
79
290
 
80
- extendEnvironment(_context: AdapterContext): NodeJS.ProcessEnv {
291
+ extendEnvironment(_context: ClaudeLaunchContext): NodeJS.ProcessEnv {
81
292
  return {
82
293
  CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: "1",
83
294
  };
@@ -7,15 +7,12 @@ const path = await import("node:path");
7
7
  import { MATE_ENV } from "../../../runtime/env";
8
8
  import { validateGuidanceData, type MateGuidanceFile } from "../../../runtime/guidance";
9
9
 
10
- import { getOpenCodePluginRoot, validateOpenCodePluginAssets } from "../../package-paths";
11
- import {
12
- mergeOpenCodeConfigContent,
13
- readOpenCodeConfig,
14
- } from "../../../tools/setup/providers/opencode-format";
15
- import { renderCompanionExternalDirectoryPermissions } from "../../../tools/setup/providers/opencode";
10
+ import { validateOpenCodePluginAssets } from "../../package-paths";
11
+ import { readOpenCodeConfig } from "../../../tools/setup/providers/opencode-format";
16
12
  import { buildOpenCodeGuidance } from "../opencode-guidance";
13
+ import type { PrepareResult } from "../session-prepare";
17
14
  import { LaunchPreflightError } from "../types";
18
- import { type AdapterContext, LaunchAdapter } from "./base";
15
+ import { type AdapterContext, LaunchAdapter, prepareFailure } from "./base";
19
16
 
20
17
  /** The Mate plugin is a V2 plugin; an older host rejects it at load time. */
21
18
  const MIN_OPENCODE_MAJOR = 2;
@@ -39,7 +36,32 @@ function parseOpenCodeMajor(output: string | undefined): number | undefined {
39
36
  return match ? Number(match[1]) : undefined;
40
37
  }
41
38
 
42
- export class OpenCodeAdapter extends LaunchAdapter {
39
+ interface OpenCodeInvocation {
40
+ command: string | undefined;
41
+ rest: string[];
42
+ /**
43
+ * The launch runs its own private server, where the plugin loads with this
44
+ * launch's environment: the TUI or `run`, not pointed at a remote server.
45
+ * Every other invocation may start or reach the shared service, which must
46
+ * never receive a guidance payload.
47
+ */
48
+ hostsPrivateServer: boolean;
49
+ }
50
+
51
+ function readInvocation(launchWorkingDirectory: string, args: string[]): OpenCodeInvocation {
52
+ const isTui = args.length === 0 || args[0]?.startsWith("-");
53
+ const [command, ...rest] = isTui ? [launchWorkingDirectory, ...args] : args;
54
+ const hostsServer = isTui || command === "run";
55
+ const choosesRemoteServer = rest.some((arg) => arg === "--server" || arg.startsWith("--server="));
56
+ return { command, rest, hostsPrivateServer: Boolean(hostsServer) && !choosesRemoteServer };
57
+ }
58
+
59
+ /** An OpenCode launch's context: the audience layer its prepare rendered, if any. */
60
+ interface OpenCodeLaunchContext extends AdapterContext {
61
+ audienceLayer?: string;
62
+ }
63
+
64
+ export class OpenCodeAdapter extends LaunchAdapter<OpenCodeLaunchContext> {
43
65
  readonly toolName = "opencode";
44
66
  readonly interactive = true;
45
67
  private readonly requiredRuntimeAssets = [path.join(".opencode", "opencode.json")] as const;
@@ -62,45 +84,61 @@ export class OpenCodeAdapter extends LaunchAdapter {
62
84
  );
63
85
  }
64
86
 
87
+ async planLaunch(
88
+ context: AdapterContext,
89
+ prepared: PrepareResult,
90
+ ): Promise<OpenCodeLaunchContext> {
91
+ return prepared.audienceLayer
92
+ ? { ...context, audienceLayer: prepared.audienceLayer.root }
93
+ : context;
94
+ }
95
+
96
+ /** A prepared repository reports its failure as one summary, as it always has. */
97
+ launchFailure(prepared: PrepareResult): unknown {
98
+ return prepared.repositoryId
99
+ ? new LaunchPreflightError(prepareFailure(prepared))
100
+ : super.launchFailure(prepared);
101
+ }
102
+
65
103
  buildArgs(context: AdapterContext, args: string[]): string[] {
66
- const codeDir = context.launchWorkingDirectory;
67
- const isTui = args.length === 0 || args[0]?.startsWith("-");
68
- const [command, ...rest] = isTui ? [codeDir, ...args] : args;
69
- if (!command) return args;
70
- const hostsServer = isTui || command === "run";
71
- const choosesServer = rest.some(
72
- (arg) => arg === "--standalone" || arg === "--server" || arg.startsWith("--server="),
104
+ const { command, rest, hostsPrivateServer } = readInvocation(
105
+ context.launchWorkingDirectory,
106
+ args,
73
107
  );
74
- return hostsServer && !choosesServer ? [command, STANDALONE_FLAG, ...rest] : [command, ...rest];
108
+ if (!command) return args;
109
+ const addsStandalone = hostsPrivateServer && !rest.includes(STANDALONE_FLAG);
110
+ return addsStandalone ? [command, STANDALONE_FLAG, ...rest] : [command, ...rest];
75
111
  }
76
112
 
77
- extendEnvironment(context: AdapterContext): NodeJS.ProcessEnv {
113
+ /**
114
+ * No `OPENCODE_CONFIG_DIR` and no config overlay: OpenCode 2.x treats the
115
+ * config dir as a replacement for the user's global one, and everything the
116
+ * overlay carried now lives in the wrapped `.opencode/opencode.json`. An
117
+ * inherited `OPENCODE_CONFIG_DIR` or `OPENCODE_CONFIG_CONTENT` passes through.
118
+ */
119
+ extendEnvironment(context: OpenCodeLaunchContext, args: string[] = []): NodeJS.ProcessEnv {
120
+ const { hostsPrivateServer } = readInvocation(context.launchWorkingDirectory, args);
78
121
  return {
79
- OPENCODE_CONFIG_DIR: path.join(context.companionPath, ".opencode"),
80
- OPENCODE_CONFIG_CONTENT: mergeOpenCodeConfigContent(
81
- {
82
- permissions: renderCompanionExternalDirectoryPermissions(context.companionPath),
83
- plugins: [getOpenCodePluginRoot()],
84
- references: {
85
- mate: context.companionPath,
86
- },
87
- },
88
- process.env,
89
- { appendSkillPaths: [path.join(context.companionPath, ".agents", "skills")] },
90
- ),
91
122
  // Built fresh per launch from the live capability config; always set so
92
123
  // a stale value inherited from an outer Mate session can never leak in.
93
- [MATE_ENV.guidanceJson]: JSON.stringify(this.buildGuidance(context)),
124
+ // Masked unless the launch hosts its own server: a payload that reached
125
+ // the shared service would apply this session's guidance — and its
126
+ // audience layer — to every other.
127
+ [MATE_ENV.guidanceJson]: hostsPrivateServer
128
+ ? JSON.stringify(this.buildGuidance(context))
129
+ : "",
94
130
  };
95
131
  }
96
132
 
97
- private buildGuidance(context: AdapterContext): MateGuidanceFile {
98
- return buildOpenCodeGuidance(context.capabilities, {
133
+ /** The audience layer travels as a path; the plugin reads it through the surface's reader. */
134
+ private buildGuidance(context: OpenCodeLaunchContext): MateGuidanceFile {
135
+ const guidance = buildOpenCodeGuidance(context.capabilities, {
99
136
  companionScoped: !context.repository,
100
137
  });
138
+ return context.audienceLayer ? { ...guidance, audienceLayer: context.audienceLayer } : guidance;
101
139
  }
102
140
 
103
- async validateLaunch(context: AdapterContext): Promise<void> {
141
+ async validateLaunch(context: OpenCodeLaunchContext): Promise<void> {
104
142
  this.validateOpenCodeVersion();
105
143
 
106
144
  const missingAssets = await Promise.all(
@@ -164,7 +202,7 @@ export class OpenCodeAdapter extends LaunchAdapter {
164
202
 
165
203
  // Self-check the guidance payload this launch is about to inject so a
166
204
  // broken playbook template fails preflight instead of inside OpenCode.
167
- private validateGuidance(context: AdapterContext): string[] {
205
+ private validateGuidance(context: OpenCodeLaunchContext): string[] {
168
206
  const built = this.buildGuidance(context);
169
207
  const { guidance, errors } = validateGuidanceData(built);
170
208
 
@@ -12,6 +12,8 @@ import {
12
12
  type UpstreamTarget,
13
13
  } from "../../runtime/companion-git";
14
14
  import { recordCompanionSync } from "../../runtime/companion-git-state";
15
+ import { withCompanionLock, type CompanionLockOptions } from "../../runtime/companion-lock";
16
+ import { FRAMEWORK_NAME } from "../../framework";
15
17
  import { LaunchPreflightError } from "./types";
16
18
 
17
19
  type GitCommandResult = GitResult;
@@ -427,10 +429,35 @@ export class CompanionGitSync {
427
429
  }
428
430
  }
429
431
 
432
+ /**
433
+ * An attended operation waits for whoever holds the companion lock, however
434
+ * long: the holder may be an operator answering a merge conflict. The line
435
+ * explains the pause; a crashed holder frees the lock by itself.
436
+ */
437
+ export function attendedLockOptions(companionPath: string): CompanionLockOptions {
438
+ return {
439
+ timeoutMs: Number.POSITIVE_INFINITY,
440
+ onWait: () =>
441
+ console.error(
442
+ `${FRAMEWORK_NAME}: waiting for another companion sync to finish (${companionPath})…`,
443
+ ),
444
+ };
445
+ }
446
+
447
+ /** Serialized with every other synchronization of this companion. */
430
448
  export async function syncCompanionGit(
431
449
  companionPath: string,
432
450
  workingRepoPath?: string,
433
451
  interactiveGit = false,
434
452
  ): Promise<CompanionGitSyncResult> {
435
- return new CompanionGitSync().sync(companionPath, workingRepoPath, interactiveGit);
453
+ const locked = await withCompanionLock(companionPath, attendedLockOptions(companionPath), () =>
454
+ new CompanionGitSync().sync(companionPath, workingRepoPath, interactiveGit),
455
+ );
456
+ if (!locked.acquired) {
457
+ throw new CompanionGitSyncError(
458
+ companionPath,
459
+ "Another companion synchronization is still running.",
460
+ );
461
+ }
462
+ return locked.value;
436
463
  }
@@ -15,7 +15,7 @@ import {
15
15
  } from "../../tools/setup/dynamic-plugins/install";
16
16
  import { hydrateDynamicPlugins } from "../../tools/setup/dynamic-plugins/hydrate";
17
17
  import { applySetupCompatibilities } from "../../tools/setup";
18
- import { findRepoLocalRegistryFile } from "./repo-local-registry";
18
+ import { findRepoLocalRegistryFile } from "./repository-link";
19
19
  import { companionGit, describeGitFailure, type CompanionGit } from "../../runtime/companion-git";
20
20
 
21
21
  export interface HubGitOptions {
@@ -319,6 +319,7 @@ export async function syncHub(
319
319
  const { config, store } = await assertHubRoot(hubPath);
320
320
  const results: HubSyncResult[] = [];
321
321
  for (const member of config.hub!.companions) {
322
+ // oxlint-disable-next-line no-await-in-loop -- members sync one at a time, in declared order
322
323
  const result = await syncHubMember(hubPath, member, options);
323
324
  results.push(result);
324
325
  if (result.materializedCommit && result.status === "updated") {