@uniqbit/mate-core 0.18.3 → 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 (56) hide show
  1. package/opencode-plugin/src/add-dir.ts +10 -76
  2. package/opencode-plugin/src/audience-layer.ts +91 -0
  3. package/opencode-plugin/src/companion.ts +28 -11
  4. package/opencode-plugin/src/launch-environment.ts +29 -0
  5. package/opencode-plugin/src/server.ts +25 -8
  6. package/package.json +1 -1
  7. package/src/cli/commands/cap/index-cmd.ts +2 -11
  8. package/src/cli/commands/doctor.ts +86 -4
  9. package/src/cli/commands/install.ts +8 -0
  10. package/src/cli/commands/studio/terminal-launch.ts +16 -7
  11. package/src/cli/commands/wrap.ts +29 -4
  12. package/src/cli/usage.ts +2 -1
  13. package/src/hooks/session-guidance.ts +20 -2
  14. package/src/lib/orchestrator/adapters/base.ts +3 -2
  15. package/src/lib/orchestrator/adapters/claude.ts +130 -20
  16. package/src/lib/orchestrator/adapters/opencode.ts +55 -15
  17. package/src/lib/orchestrator/config-store.ts +74 -1
  18. package/src/lib/orchestrator/openspec-store.ts +202 -0
  19. package/src/lib/orchestrator/projection-claude-skills.ts +88 -35
  20. package/src/lib/orchestrator/projection-entries.ts +46 -0
  21. package/src/lib/orchestrator/projection-openspec-store.ts +164 -0
  22. package/src/lib/orchestrator/projection-types.ts +9 -1
  23. package/src/lib/orchestrator/session-prepare.ts +82 -4
  24. package/src/lib/orchestrator/working-repo-projection.ts +2 -1
  25. package/src/lib/orchestrator/wrap-pass.ts +92 -1
  26. package/src/opencode/companion-hooks.ts +7 -4
  27. package/src/opencode/projected-guidance.ts +6 -13
  28. package/src/runtime/audience-layer.ts +84 -0
  29. package/src/runtime/guidance.ts +17 -0
  30. package/src/runtime/opencode-audience-layer.ts +176 -0
  31. package/src/runtime/policy.ts +14 -1
  32. package/src/runtime/projection.ts +1 -1
  33. package/src/runtime/runtime-document-manifest.ts +17 -0
  34. package/src/runtime/wrap-state.ts +4 -0
  35. package/src/tools/setup/audience-isolation.fixture.ts +325 -0
  36. package/src/tools/setup/audience-layer.ts +207 -0
  37. package/src/tools/setup/audience-registrations.ts +52 -0
  38. package/src/tools/setup/capabilities/openspec.ts +9 -0
  39. package/src/tools/setup/capabilities/tokensave.ts +11 -0
  40. package/src/tools/setup/contribution-ledger.ts +207 -0
  41. package/src/tools/setup/dynamic-plugins/audiences.ts +89 -9
  42. package/src/tools/setup/dynamic-plugins/declarations.ts +3 -2
  43. package/src/tools/setup/dynamic-plugins/hydrate.ts +7 -4
  44. package/src/tools/setup/engine.ts +141 -10
  45. package/src/tools/setup/isolation-diagnostics.ts +139 -0
  46. package/src/tools/setup/plugin.ts +19 -4
  47. package/src/tools/setup/plugins/gitignore.ts +7 -1
  48. package/src/tools/setup/providers/claude.ts +27 -2
  49. package/src/tools/setup/providers/opencode.ts +10 -0
  50. package/src/tools/setup/registry.ts +24 -2
  51. package/src/tools/setup/surface-target.ts +13 -4
  52. package/src/tools/setup/upgrade-sweep.ts +153 -0
  53. package/src/tools/setup/working-repo-cleanup.ts +2 -0
  54. package/src/tools/setup/working-repo-local-state.ts +9 -1
  55. package/src/tools/setup.ts +9 -6
  56. package/wrappers/bin/openspec +42 -1
@@ -33,12 +33,29 @@ function reportChangedPaths(changed: ProjectionChangedPaths): void {
33
33
 
34
34
  /**
35
35
  * `--companion` is consumed by the dispatch gate before the command runs, so it
36
- * is parsed there too; this only rejects anything else.
36
+ * is parsed there too. `--audience` names the audience the checkout's bare
37
+ * sessions get beside base.
37
38
  */
38
- export function parseWrapArgs(argv: string[]): { companion?: string } | { error: string } {
39
+ export function parseWrapArgs(
40
+ argv: string[],
41
+ ): { companion?: string; audience?: string } | { error: string } {
39
42
  let companion: string | undefined;
43
+ let audience: string | undefined;
40
44
  for (let index = 0; index < argv.length; index += 1) {
41
45
  const arg = argv[index]!;
46
+ if (arg === "--audience") {
47
+ const value = argv[index + 1];
48
+ if (!value || value.startsWith("--")) return { error: "--audience requires a name" };
49
+ audience = value;
50
+ index += 1;
51
+ continue;
52
+ }
53
+ if (arg.startsWith("--audience=")) {
54
+ const value = arg.slice("--audience=".length);
55
+ if (!value) return { error: "--audience requires a name" };
56
+ audience = value;
57
+ continue;
58
+ }
42
59
  if (arg === "--companion") {
43
60
  const value = argv[index + 1];
44
61
  if (!value || value.startsWith("--")) return { error: "--companion requires a path" };
@@ -54,7 +71,10 @@ export function parseWrapArgs(argv: string[]): { companion?: string } | { error:
54
71
  }
55
72
  return { error: `unknown wrap option: ${arg}` };
56
73
  }
57
- return companion === undefined ? {} : { companion };
74
+ return {
75
+ ...(companion === undefined ? {} : { companion }),
76
+ ...(audience === undefined ? {} : { audience }),
77
+ };
58
78
  }
59
79
 
60
80
  /**
@@ -108,8 +128,12 @@ export async function runWrapCommand(
108
128
  }
109
129
 
110
130
  /** Serialized with Session Prepare and every sync, which rewrite the same manifest. */
131
+ /**
132
+ * The flag is the only way to give a checkout's bare sessions an audience;
133
+ * without it the wrap is base and any recorded audience is withdrawn.
134
+ */
111
135
  const locked = await withCompanionLock(companionPath, attendedLockOptions(companionPath), () =>
112
- wrap(companionPath, repository),
136
+ wrap(companionPath, repository, { audience: parsed.audience ?? null }),
113
137
  );
114
138
  const result: WrapPassResult = locked.acquired
115
139
  ? locked.value
@@ -126,6 +150,7 @@ export async function runWrapCommand(
126
150
  : `Already wrapped: ${result.projectionRoot}`,
127
151
  );
128
152
  console.log(` Companion: ${result.companionPath}`);
153
+ if (result.audience) console.log(` Audience: ${result.audience}`);
129
154
  for (const document of result.documents) {
130
155
  console.log(` Runtime: ${document}`);
131
156
  }
package/src/cli/usage.ts CHANGED
@@ -21,7 +21,7 @@ export function usage(): string {
21
21
  ` ${n} companion open`,
22
22
  ` ${n} companion tui`,
23
23
  ` ${n} working cleanup`,
24
- ` ${n} wrap [--companion PATH]`,
24
+ ` ${n} wrap [--companion PATH] [--audience NAME]`,
25
25
  ` ${n} unwrap`,
26
26
  ` ${n} session prepare --repo <dir> [--json] [--sync]`,
27
27
  ` ${n} hub init [folder]`,
@@ -54,6 +54,7 @@ export function usage(): string {
54
54
  "",
55
55
  "Wrapped repositories:",
56
56
  ` ${n} wrap configures the repository so sessions you start yourself load the companion`,
57
+ ` ${n} wrap --audience NAME gives those sessions one audience beside base; plain wrap returns to base`,
57
58
  ` ${n} claude does not run while a repository is wrapped for Claude`,
58
59
  ` ${n} opencode wraps the repository for OpenCode itself, then starts plain \`opencode\``,
59
60
  ` ${n} unwrap withdraws the wrap and restores managed launches; the link is kept`,
@@ -1,5 +1,8 @@
1
1
  // Deliver the companion guidance to a Claude session Mate did not launch.
2
+ import { currentAudienceLayer, readLayerGuidanceSync } from "../runtime/audience-layer";
3
+ import { resolveProjection } from "../runtime/projection";
2
4
  import { buildProjectedGuidance } from "../runtime/projected-guidance";
5
+ import { readWrapStateSync } from "../runtime/wrap-state";
3
6
  import type { HookEnv } from "./validate-artifact-path";
4
7
 
5
8
  export interface GuidanceOutcome {
@@ -7,6 +10,19 @@ export interface GuidanceOutcome {
7
10
  stdout: string;
8
11
  }
9
12
 
13
+ /**
14
+ * The guidance of the audience this checkout is wrapped for, from its layer.
15
+ * The wrap is the only source: an inherited `MATE_AUDIENCE` selects nothing.
16
+ */
17
+ function wrappedAudienceGuidance(cwd: string): string {
18
+ const projection = resolveProjection(cwd);
19
+ if (!projection) return "";
20
+ const audience = readWrapStateSync(projection.repoRoot).audience;
21
+ if (!audience) return "";
22
+ const layer = currentAudienceLayer(projection.companionPath, audience);
23
+ return layer ? readLayerGuidanceSync(layer, "CLAUDE.md") : "";
24
+ }
25
+
10
26
  /**
11
27
  * `SessionStart.additionalContext` is the file-based form of the managed
12
28
  * launch's `--append-system-prompt`: a settings document can declare a hook,
@@ -18,8 +34,10 @@ export interface GuidanceOutcome {
18
34
  * repository must read exactly as it did before the hook existed.
19
35
  */
20
36
  export function buildSessionGuidance(env: HookEnv, cwd: string = process.cwd()): GuidanceOutcome {
21
- const guidance = buildProjectedGuidance(env, cwd);
22
- if (!guidance) return { exitCode: 0, stdout: "" };
37
+ const companionGuidance = buildProjectedGuidance(env, cwd);
38
+ if (!companionGuidance) return { exitCode: 0, stdout: "" };
39
+ const layer = wrappedAudienceGuidance(cwd);
40
+ const guidance = layer ? `${companionGuidance}\n\n${layer}` : companionGuidance;
23
41
 
24
42
  return {
25
43
  exitCode: 0,
@@ -88,7 +88,8 @@ export abstract class LaunchAdapter<C extends AdapterContext = AdapterContext> {
88
88
 
89
89
  async validateLaunch(_context: C): Promise<void> {}
90
90
 
91
- extendEnvironment(_context: C): NodeJS.ProcessEnv {
91
+ /** `args` are the operator's arguments, before `buildArgs` rewrites them. */
92
+ extendEnvironment(_context: C, _args: string[] = []): NodeJS.ProcessEnv {
92
93
  return {};
93
94
  }
94
95
 
@@ -130,7 +131,7 @@ export abstract class LaunchAdapter<C extends AdapterContext = AdapterContext> {
130
131
  async prepareLaunch(context: C, args: string[]): Promise<PreparedLaunch> {
131
132
  const builtArgs = this.buildArgs(context, args);
132
133
  const baseEnv = this.environment(context);
133
- const extendedEnv = this.extendEnvironment(context);
134
+ const extendedEnv = this.extendEnvironment(context, args);
134
135
  const env = { ...baseEnv, ...extendedEnv };
135
136
 
136
137
  const processPath = process.env.PATH ?? "";
@@ -9,6 +9,11 @@ import {
9
9
  type ClaudeDeliveryPlan,
10
10
  type SessionKind,
11
11
  } from "../../../runtime/claude-delivery-plan";
12
+ import {
13
+ audienceLayerDir,
14
+ currentAudienceLayer,
15
+ readLayerGuidanceSync,
16
+ } from "../../../runtime/audience-layer";
12
17
  import { hookGroupKey, type ClaudeHookGroup } from "../../../runtime/claude-hook-group";
13
18
  import { NOT_WRAPPED } from "../../../runtime/wrap-state";
14
19
  import { writeAtomic } from "../../../runtime/write-atomic";
@@ -19,15 +24,31 @@ import {
19
24
  import type { ClaudeSettings } from "../../../tools/setup/providers/claude-format";
20
25
  import { getContextModePackageRoot, validateContextModePackage } from "../../context-mode-package";
21
26
  import { getClaudePluginRoot, validateClaudePluginAssets } from "../../package-paths";
27
+ import type { RenderedAudienceLayer } from "../../../tools/setup/audience-layer";
22
28
  import type { PrepareResult } from "../session-prepare";
23
29
  import { LaunchPreflightError } from "../types";
24
30
  import { type AdapterContext, LaunchAdapter } from "./base";
25
31
 
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
+
26
40
  /** The paths are present only where the plan names that channel and the file exists. */
27
41
  interface ClaudeDelivery {
28
42
  plan: ClaudeDeliveryPlan;
29
43
  settingsPath?: string;
30
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;
31
52
  }
32
53
 
33
54
  /** A Claude launch's context: the delivery its plan chose, and the files it chose or wrote. */
@@ -35,34 +56,75 @@ interface ClaudeLaunchContext extends AdapterContext {
35
56
  delivery: ClaudeDelivery;
36
57
  }
37
58
 
38
- /** Where a launch with a Working Repository writes its derived `--settings`; git-ignored. */
39
- export function launchSettingsPath(companionPath: string, repositoryId: string): string {
40
- const safeId = repositoryId.replace(/[^A-Za-z0-9._-]/g, "_");
41
- return path.join(companionPath, ".claude", "state", `launch-settings.${safeId}.json`);
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;
42
86
  }
43
87
 
44
88
  /**
45
- * Companion settings minus `exclude`, so each wrap-carried group runs once. A
46
- * file, not inline JSON (platform arg limits); per repository, so concurrent
47
- * launches never race.
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.
48
93
  */
49
94
  async function writeLaunchSettings(
50
95
  companionSettingsPath: string,
51
96
  target: string,
52
97
  exclude: readonly ClaudeHookGroup[],
98
+ layerSettingsPath?: string,
53
99
  ): Promise<void> {
54
- const settings = JSON.parse(await fs.readFile(companionSettingsPath, "utf8")) as ClaudeSettings;
100
+ const settings = await readSettings(companionSettingsPath);
101
+ const layer = layerSettingsPath ? await readSettings(layerSettingsPath) : {};
55
102
  const excluded = new Set(exclude.map(hookGroupKey));
56
103
  const hooks: Record<string, ClaudeHookGroup[]> = {};
57
104
  for (const [event, groups] of Object.entries(settings.hooks ?? {})) {
58
105
  const kept = (groups ?? []).filter((group) => !excluded.has(hookGroupKey(group)));
59
106
  if (kept.length > 0) hooks[event] = kept;
60
107
  }
108
+ for (const [event, groups] of Object.entries(layer.hooks ?? {})) {
109
+ hooks[event] = [...(hooks[event] ?? []), ...(groups ?? [])];
110
+ }
61
111
  const derived: ClaudeSettings = { ...settings, hooks };
62
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
+ }
63
118
 
64
- await fs.mkdir(path.dirname(target), { recursive: true });
65
- await writeAtomic(target, `${JSON.stringify(derived, null, 2)}\n`);
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
+ }
66
128
  }
67
129
 
68
130
  /** The `--settings` file the plan's hook owner calls for, writing it when derived. */
@@ -70,16 +132,38 @@ async function settingsPathFor(
70
132
  plan: ClaudeDeliveryPlan,
71
133
  companionPath: string,
72
134
  repository: AdapterContext["repository"],
135
+ layer: RenderedAudienceLayer | undefined,
73
136
  ): Promise<string | undefined> {
74
137
  const companionSettingsPath = getCompanionClaudeSettingsPath(companionPath);
75
- if (plan.hooks.owner === "none" || !existsSync(companionSettingsPath)) return undefined;
76
- if (plan.hooks.owner === "settings") return companionSettingsPath;
138
+ if (plan.hooks.owner === "none") return undefined;
139
+ if (!layer && !existsSync(companionSettingsPath)) return undefined;
140
+ if (plan.hooks.owner === "settings" && !layer) return companionSettingsPath;
77
141
  /** Owner `wrap` is planned only for `managed-repo`, which has a repository. */
78
- const settingsPath = launchSettingsPath(companionPath, repository!.id);
79
- await writeLaunchSettings(companionSettingsPath, settingsPath, plan.hooks.launchSettingsExclude);
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
+ );
80
153
  return settingsPath;
81
154
  }
82
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
+
83
167
  export class ClaudeAdapter extends LaunchAdapter<ClaudeLaunchContext> {
84
168
  readonly toolName = "claude";
85
169
  readonly interactive = true;
@@ -100,11 +184,22 @@ export class ClaudeAdapter extends LaunchAdapter<ClaudeLaunchContext> {
100
184
  }
101
185
  const plan = planClaudeDelivery(kind, wrap);
102
186
  const mcpConfigPath = getCompanionClaudeMcpConfigPath(companionPath);
103
- const settingsPath = await settingsPathFor(plan, companionPath, repository);
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") : "";
104
197
  const delivery: ClaudeDelivery = {
105
198
  plan,
106
199
  ...(plan.mcp === "mcp-config" && existsSync(mcpConfigPath) ? { mcpConfigPath } : {}),
107
200
  ...(settingsPath ? { settingsPath } : {}),
201
+ ...(layer ? { layer } : {}),
202
+ ...(wrappedGuidance ? { wrappedGuidance } : {}),
108
203
  };
109
204
  return { ...context, delivery };
110
205
  }
@@ -149,15 +244,30 @@ export class ClaudeAdapter extends LaunchAdapter<ClaudeLaunchContext> {
149
244
  * and context-mode are unconditional: no settings document can carry them.
150
245
  */
151
246
  buildArgs(context: ClaudeLaunchContext, args: string[]): string[] {
152
- const { plan, settingsPath, mcpConfigPath } = context.delivery;
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;
153
252
  /** Layered on the user's own sources so global skills and plugins still load. */
154
253
  const settingsArgs =
155
254
  plan.hooks.owner !== "none" && settingsPath
156
255
  ? ["--setting-sources", "user,project,local", "--settings", settingsPath]
157
256
  : [];
158
- const addDirArgs = plan.dirAccess === "add-dir" ? ["--add-dir", context.companionPath] : [];
159
- const mcpConfigArgs =
160
- plan.mcp === "mcp-config" && mcpConfigPath ? ["--mcp-config", mcpConfigPath] : [];
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
+ ];
161
271
  const contextModeArgs = context.capabilities.some(
162
272
  (capability) => capability.name === "context-mode",
163
273
  )
@@ -167,7 +277,7 @@ export class ClaudeAdapter extends LaunchAdapter<ClaudeLaunchContext> {
167
277
  return [
168
278
  ...addDirArgs,
169
279
  "--append-system-prompt",
170
- buildCompanionGuidance(context),
280
+ systemPrompt,
171
281
  ...settingsArgs,
172
282
  ...mcpConfigArgs,
173
283
  /** The bundled plugin from the running install, beside context-mode's. */
@@ -36,7 +36,32 @@ function parseOpenCodeMajor(output: string | undefined): number | undefined {
36
36
  return match ? Number(match[1]) : undefined;
37
37
  }
38
38
 
39
- 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> {
40
65
  readonly toolName = "opencode";
41
66
  readonly interactive = true;
42
67
  private readonly requiredRuntimeAssets = [path.join(".opencode", "opencode.json")] as const;
@@ -59,6 +84,15 @@ export class OpenCodeAdapter extends LaunchAdapter {
59
84
  );
60
85
  }
61
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
+
62
96
  /** A prepared repository reports its failure as one summary, as it always has. */
63
97
  launchFailure(prepared: PrepareResult): unknown {
64
98
  return prepared.repositoryId
@@ -67,15 +101,13 @@ export class OpenCodeAdapter extends LaunchAdapter {
67
101
  }
68
102
 
69
103
  buildArgs(context: AdapterContext, args: string[]): string[] {
70
- const codeDir = context.launchWorkingDirectory;
71
- const isTui = args.length === 0 || args[0]?.startsWith("-");
72
- const [command, ...rest] = isTui ? [codeDir, ...args] : args;
73
- if (!command) return args;
74
- const hostsServer = isTui || command === "run";
75
- const choosesServer = rest.some(
76
- (arg) => arg === "--standalone" || arg === "--server" || arg.startsWith("--server="),
104
+ const { command, rest, hostsPrivateServer } = readInvocation(
105
+ context.launchWorkingDirectory,
106
+ args,
77
107
  );
78
- 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];
79
111
  }
80
112
 
81
113
  /**
@@ -84,21 +116,29 @@ export class OpenCodeAdapter extends LaunchAdapter {
84
116
  * overlay carried now lives in the wrapped `.opencode/opencode.json`. An
85
117
  * inherited `OPENCODE_CONFIG_DIR` or `OPENCODE_CONFIG_CONTENT` passes through.
86
118
  */
87
- extendEnvironment(context: AdapterContext): NodeJS.ProcessEnv {
119
+ extendEnvironment(context: OpenCodeLaunchContext, args: string[] = []): NodeJS.ProcessEnv {
120
+ const { hostsPrivateServer } = readInvocation(context.launchWorkingDirectory, args);
88
121
  return {
89
122
  // Built fresh per launch from the live capability config; always set so
90
123
  // a stale value inherited from an outer Mate session can never leak in.
91
- [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
+ : "",
92
130
  };
93
131
  }
94
132
 
95
- private buildGuidance(context: AdapterContext): MateGuidanceFile {
96
- 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, {
97
136
  companionScoped: !context.repository,
98
137
  });
138
+ return context.audienceLayer ? { ...guidance, audienceLayer: context.audienceLayer } : guidance;
99
139
  }
100
140
 
101
- async validateLaunch(context: AdapterContext): Promise<void> {
141
+ async validateLaunch(context: OpenCodeLaunchContext): Promise<void> {
102
142
  this.validateOpenCodeVersion();
103
143
 
104
144
  const missingAssets = await Promise.all(
@@ -162,7 +202,7 @@ export class OpenCodeAdapter extends LaunchAdapter {
162
202
 
163
203
  // Self-check the guidance payload this launch is about to inject so a
164
204
  // broken playbook template fails preflight instead of inside OpenCode.
165
- private validateGuidance(context: AdapterContext): string[] {
205
+ private validateGuidance(context: OpenCodeLaunchContext): string[] {
166
206
  const built = this.buildGuidance(context);
167
207
  const { guidance, errors } = validateGuidanceData(built);
168
208
 
@@ -1,5 +1,14 @@
1
1
  import path from "node:path";
2
2
 
3
+ import semver from "semver";
4
+
5
+ import {
6
+ BASE_AUDIENCE,
7
+ BASE_AUDIENCE_KEYS,
8
+ normalizeBaseAudience,
9
+ } from "../../tools/setup/dynamic-plugins/audiences";
10
+
11
+ import { getActiveDistribution } from "../../distribution";
3
12
  import { FRAMEWORK_NAME } from "../../framework";
4
13
  import {
5
14
  getDefaultSetupSelections,
@@ -291,19 +300,83 @@ function validateAudiences(config: FrameworkConfig): void {
291
300
  }
292
301
  }
293
302
 
303
+ /**
304
+ * The release that introduces the `audiences.base` shape. An older Mate would
305
+ * read a saved file as an empty base plus an audience called `base`, so every
306
+ * save in the new shape raises the companion's `engines` floor to it — capped
307
+ * at the running release, so a pre-release build never locks itself out.
308
+ */
309
+ export const BASE_AUDIENCE_MIN_VERSION = "0.19.0";
310
+
311
+ /** The floor version a save writes: the introducing release, or the running one when older. */
312
+ export function baseAudienceFloorVersion(
313
+ runningVersion: string = getActiveDistribution().config.version,
314
+ ): string {
315
+ const running = semver.coerce(runningVersion)?.version;
316
+ return running && semver.lt(running, BASE_AUDIENCE_MIN_VERSION)
317
+ ? running
318
+ : BASE_AUDIENCE_MIN_VERSION;
319
+ }
320
+
321
+ /** Only companions carry audiences; a hub or a working repository keeps the root shape. */
322
+ function usesAudiences(config: FrameworkConfig): boolean {
323
+ return config.type === undefined || config.type === "companion";
324
+ }
325
+
326
+ /** The floor that admits the base audience shape, keeping a higher one. */
327
+ function raisedEngineFloor(range: string | undefined): string {
328
+ const version = baseAudienceFloorVersion();
329
+ const floor = `>=${version}`;
330
+ if (!range || semver.validRange(range) === null) return range ?? floor;
331
+ const minimum = semver.minVersion(range);
332
+ return minimum && semver.gte(minimum, version) ? range : floor;
333
+ }
334
+
335
+ /**
336
+ * The file shape of a companion config: the base set's audience-scoped keys
337
+ * under `audiences.base`, first among the audiences. The in-memory config
338
+ * keeps them at the root, so its readers never see the difference.
339
+ */
340
+ function toFileShape(config: FrameworkConfig): FrameworkConfig {
341
+ if (!usesAudiences(config)) return config;
342
+ const base: Record<string, unknown> = {};
343
+ const rest: Record<string, unknown> = { ...config };
344
+ for (const key of BASE_AUDIENCE_KEYS) {
345
+ if (rest[key] === undefined) continue;
346
+ base[key] = rest[key];
347
+ delete rest[key];
348
+ }
349
+ if (Object.keys(base).length === 0) return config;
350
+ return {
351
+ ...(rest as unknown as FrameworkConfig),
352
+ audiences: { [BASE_AUDIENCE]: base, ...config.audiences } as FrameworkConfig["audiences"],
353
+ engines: {
354
+ ...config.engines,
355
+ [FRAMEWORK_NAME]: raisedEngineFloor(config.engines?.[FRAMEWORK_NAME]),
356
+ },
357
+ };
358
+ }
359
+
294
360
  export class ConfigStore extends YamlFileStore<FrameworkConfig> {
295
361
  constructor(configPath = process.env.MATE_CONFIG ?? defaultConfigPath()) {
296
362
  super(path.resolve(configPath));
297
363
  }
298
364
 
365
+ /** Either file shape loads as the base set at the root; the new shape persists on the next save. */
299
366
  override async load(): Promise<FrameworkConfig> {
300
- const merged = mergeWithDefaults(migrateProfilesToAllowedAgents(await super.load()));
367
+ const merged = mergeWithDefaults(
368
+ migrateProfilesToAllowedAgents(normalizeBaseAudience(await super.load())),
369
+ );
301
370
  validateHubConfig(merged);
302
371
  validatePluginDeclarations(merged);
303
372
  validateAudiences(merged);
304
373
  return merged;
305
374
  }
306
375
 
376
+ override async save(config: FrameworkConfig): Promise<void> {
377
+ await super.save(toFileShape(config));
378
+ }
379
+
307
380
  protected async onMissing(): Promise<FrameworkConfig> {
308
381
  const config = defaultConfig();
309
382
  await this.save(config);