@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
@@ -1,15 +1,8 @@
1
- import fs from "node:fs/promises";
2
1
  import path from "node:path";
3
2
 
4
3
  import type { Context } from "@opencode/plugin/promise/plugin";
5
4
 
6
- type CompanionSkill = {
7
- id: string;
8
- name: string;
9
- description?: string;
10
- path: string;
11
- content: string;
12
- };
5
+ import { loadSkillInfos } from "../../src/runtime/opencode-audience-layer";
13
6
 
14
7
  function companionSkillPaths(companionPath: string): string[] {
15
8
  return [
@@ -23,87 +16,28 @@ function isWithin(root: string, candidate: string): boolean {
23
16
  return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
24
17
  }
25
18
 
26
- function parseFrontmatter(content: string): { name?: string; description?: string } {
27
- const match = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
28
- if (!match) return {};
29
-
30
- const scalar = (key: string) => {
31
- const value = match[1].match(new RegExp(`^${key}:\\s*(.+)$`, "m"))?.[1]?.trim();
32
- return value?.replace(
33
- /^(?:"([\s\S]*)"|'([\s\S]*)')$/,
34
- (_all, doubleQuoted, singleQuoted) => doubleQuoted ?? singleQuoted,
35
- );
36
- };
37
-
38
- return { name: scalar("name"), description: scalar("description") };
39
- }
40
-
41
- async function findSkillFiles(directory: string): Promise<string[]> {
42
- try {
43
- const entries = await fs.readdir(directory, { withFileTypes: true });
44
- const files: string[] = [];
45
- const subdirectories: Promise<string[]>[] = [];
46
- for (const entry of entries) {
47
- const entryPath = path.join(directory, entry.name);
48
- if (entry.isFile() && entry.name === "SKILL.md") files.push(entryPath);
49
- else if (entry.isDirectory()) subdirectories.push(findSkillFiles(entryPath));
50
- }
51
- const nested = await Promise.all(subdirectories);
52
- return [...files, ...nested.flat()];
53
- } catch {
54
- return [];
55
- }
56
- }
57
-
58
- function skillID(name: string): string {
59
- return (
60
- name
61
- .toLowerCase()
62
- .replace(/[^a-z0-9_-]+/g, "-")
63
- .replace(/^-+|-+$/g, "") || "mate-skill"
64
- );
65
- }
66
-
67
- async function loadCompanionSkills(companionPath: string): Promise<CompanionSkill[]> {
68
- const files = (await Promise.all(companionSkillPaths(companionPath).map(findSkillFiles))).flat();
69
- const skills = await Promise.all(
70
- files.map(async (filePath): Promise<CompanionSkill | null> => {
71
- try {
72
- const content = await fs.readFile(filePath, "utf8");
73
- const metadata = parseFrontmatter(content);
74
- const name = metadata.name || path.basename(path.dirname(filePath));
75
- return {
76
- id: skillID(name),
77
- name,
78
- ...(metadata.description ? { description: metadata.description } : {}),
79
- path: filePath,
80
- content,
81
- };
82
- } catch {
83
- /** An unreadable companion skill must not prevent the session from loading. */
84
- return null;
85
- }
86
- }),
87
- );
88
- return skills.filter((skill) => skill !== null);
89
- }
90
-
91
19
  /**
92
20
  * Grants the session access to the companion and registers its skills. The
93
21
  * permission hook only widens access for companion resources; every other
94
22
  * evaluation keeps OpenCode's own verdict.
95
23
  */
96
- export async function registerCompanionAccess(api: Context, companionPath: string): Promise<void> {
24
+ export async function registerCompanionAccess(
25
+ api: Context,
26
+ companionPath: string,
27
+ extraRoots: readonly string[] = [],
28
+ ): Promise<void> {
97
29
  const permissionRoot = path.resolve(companionPath);
30
+ /** An audience layer's files are as readable as the companion's, for this launch only. */
31
+ const allowedRoots = [permissionRoot, ...extraRoots.map((root) => path.resolve(root))];
98
32
 
99
33
  await api.permission.hook("evaluate", (event) => {
100
- if (event.resources.some((resource) => isWithin(permissionRoot, resource))) {
34
+ if (event.resources.some((resource) => allowedRoots.some((root) => isWithin(root, resource)))) {
101
35
  event.effect = "allow";
102
36
  delete event.message;
103
37
  }
104
38
  });
105
39
 
106
- const skills = await loadCompanionSkills(permissionRoot);
40
+ const skills = await loadSkillInfos(companionSkillPaths(permissionRoot));
107
41
  if (skills.length === 0) return;
108
42
 
109
43
  await api.skill.transform((editor) => {
@@ -0,0 +1,91 @@
1
+ import type { Context } from "@opencode/plugin/promise/plugin";
2
+
3
+ import { repositoryWrappedForOpenCode } from "../../src/opencode/companion-hooks";
4
+ import { currentAudienceLayer } from "../../src/runtime/audience-layer";
5
+ import { MATE_ENV } from "../../src/runtime/env";
6
+ import { parseGuidanceContent } from "../../src/runtime/guidance";
7
+ import {
8
+ readOpenCodeAudienceLayer,
9
+ type OpenCodeLayerContents,
10
+ } from "../../src/runtime/opencode-audience-layer";
11
+ import { readWrapStateSync } from "../../src/runtime/wrap-state";
12
+ import type { LaunchEnvironment } from "./launch-environment";
13
+
14
+ export interface TrustedAudienceLayer {
15
+ root: string;
16
+ contents: OpenCodeLayerContents;
17
+ }
18
+
19
+ /**
20
+ * The audience layer named by a payload the plugin trusts. `env` is the
21
+ * launch environment `pluginLaunchEnvironment` decided on, so a masked
22
+ * payload, a bare session and the shared service all read as `{}` here and
23
+ * never apply a layer.
24
+ */
25
+ export async function readTrustedAudienceLayer(
26
+ env: LaunchEnvironment,
27
+ ): Promise<TrustedAudienceLayer | null> {
28
+ const payload = env[MATE_ENV.guidanceJson];
29
+ if (!payload?.trim()) return null;
30
+ const root = parseGuidanceContent(payload).guidance.audienceLayer;
31
+ if (!root) return null;
32
+ return { root, contents: await readOpenCodeAudienceLayer(root) };
33
+ }
34
+
35
+ /**
36
+ * The layer of the audience the session's checkout is wrapped for. The wrap is
37
+ * resolved from the session's own directory, so the shared service serves each
38
+ * checkout its own audience; the environment never selects one.
39
+ */
40
+ export async function readWrappedAudienceLayer(
41
+ directory: string,
42
+ companionPath: string,
43
+ ): Promise<TrustedAudienceLayer | null> {
44
+ const repoRoot = repositoryWrappedForOpenCode(directory);
45
+ if (!repoRoot) return null;
46
+ const audience = readWrapStateSync(repoRoot).audience;
47
+ if (!audience) return null;
48
+ const root = currentAudienceLayer(companionPath, audience);
49
+ return root ? { root, contents: await readOpenCodeAudienceLayer(root) } : null;
50
+ }
51
+
52
+ /**
53
+ * Applies a layer's MCP servers, skills and agents through the plugin API. A
54
+ * new agent id is created by updating it; its prompt goes in `system`.
55
+ */
56
+ export async function registerAudienceLayer(
57
+ api: Context,
58
+ layer: OpenCodeLayerContents,
59
+ ): Promise<void> {
60
+ if (layer.mcpServers.length > 0) {
61
+ await api.mcp.transform((editor) => {
62
+ for (const server of layer.mcpServers) editor.set(server.name, server.config as never);
63
+ });
64
+ }
65
+
66
+ if (layer.skills.length > 0) {
67
+ await api.skill.transform((editor) => {
68
+ for (const skill of layer.skills) {
69
+ if (editor.get(skill.id)) {
70
+ editor.update(skill.id, (current) => Object.assign(current, skill));
71
+ } else {
72
+ editor.add(skill as never);
73
+ }
74
+ }
75
+ });
76
+ }
77
+
78
+ if (layer.agents.length > 0) {
79
+ await api.agent.transform((editor) => {
80
+ for (const agent of layer.agents) {
81
+ editor.update(agent.id, (current) => {
82
+ const target = current as Record<string, unknown>;
83
+ target.name ??= agent.id;
84
+ if (agent.description) target.description = agent.description;
85
+ target.mode = agent.mode;
86
+ target.system = agent.system;
87
+ });
88
+ }
89
+ });
90
+ }
91
+ }
@@ -6,6 +6,7 @@ import { MATE_ENV } from "../../src/runtime/env";
6
6
  import { COMPANION_POLICY_MARKER, type MateGuidanceFile } from "../../src/runtime/guidance";
7
7
 
8
8
  import type { CompanionContext } from "../../src/opencode/companion-policy";
9
+ import type { LaunchEnvironment } from "./launch-environment";
9
10
  import { resolveOpenCodeGuidance } from "../../src/opencode/projected-guidance";
10
11
  import {
11
12
  runtimeDirectoryPath,
@@ -23,8 +24,8 @@ function prependPathEntry(pathValue: string | undefined, entry: string): string
23
24
  * keeps current. `null` when neither exists: guidance naming an unresolved
24
25
  * placeholder would send the model to a path that is not there.
25
26
  */
26
- function resolveWrapperBinPath(): string | null {
27
- if (process.env.MATE_WRAPPER_BIN_PATH) return process.env.MATE_WRAPPER_BIN_PATH;
27
+ function resolveWrapperBinPath(env: LaunchEnvironment): string | null {
28
+ if (env.MATE_WRAPPER_BIN_PATH) return env.MATE_WRAPPER_BIN_PATH;
28
29
  const runtime = runtimeWrapperBinPath();
29
30
  return fs.existsSync(runtime) ? runtime : null;
30
31
  }
@@ -54,8 +55,8 @@ function buildStartupError(details: string[]): Error {
54
55
  * an Unmanaged Session in a wrapped repository builds the same payload from the
55
56
  * companion the projection names, with the Capability names read live.
56
57
  */
57
- function loadGuidance(directory: string): MateGuidanceFile | null {
58
- const { guidance, errors } = resolveOpenCodeGuidance(process.env, directory);
58
+ function loadGuidance(env: LaunchEnvironment, directory: string): MateGuidanceFile | null {
59
+ const { guidance, errors } = resolveOpenCodeGuidance(env, directory);
59
60
  if (errors.length > 0) throw buildStartupError(errors);
60
61
  return guidance;
61
62
  }
@@ -76,6 +77,7 @@ function buildSystemPrompt(
76
77
  context: CompanionContext,
77
78
  guidance: MateGuidanceFile,
78
79
  wrapperBinPath: string,
80
+ layerGuidance: string,
79
81
  ): string[] {
80
82
  const lines = [materializeCompanionGuidance(guidance.companionGuidance, context, wrapperBinPath)];
81
83
 
@@ -87,6 +89,11 @@ function buildSystemPrompt(
87
89
  lines.push("", `<agents.md>${context.agentsMd.trim()}</agents.md>`);
88
90
  }
89
91
 
92
+ /** The audience's guidance follows the companion's, never replaces it. */
93
+ if (layerGuidance.trim()) {
94
+ lines.push("", layerGuidance.trim());
95
+ }
96
+
90
97
  return lines;
91
98
  }
92
99
 
@@ -101,9 +108,10 @@ function applyCompanionShellEnv(
101
108
  context: CompanionContext,
102
109
  env: Record<string, string | undefined>,
103
110
  wrapperBinPath: string,
111
+ launchEnv: LaunchEnvironment,
104
112
  ): void {
105
113
  env.MATE_NAME = context.frameworkName;
106
- env.MATE_VERSION = process.env.MATE_VERSION ?? "unknown";
114
+ env.MATE_VERSION = launchEnv.MATE_VERSION ?? "unknown";
107
115
  env.MATE_ARTIFACT_PATH = context.companionPath;
108
116
  env.MATE_WRAPPER_BIN_PATH = wrapperBinPath;
109
117
  if (context.repositoryPath) {
@@ -171,18 +179,27 @@ function mergeCompanionSystem(system: unknown[], companion: string): string[] {
171
179
  return parts.length > 0 ? [parts.join("\n\n")] : [];
172
180
  }
173
181
 
174
- /** Registers guidance, compaction context, shell env, and `companion_paths`. */
175
- export async function registerCompanion(api: Context, context: CompanionContext): Promise<void> {
176
- const guidance = loadGuidance(api.location.directory);
182
+ /**
183
+ * Registers guidance, compaction context, shell env, and `companion_paths`.
184
+ * `env` is the launch environment the caller decided to trust; `{}` resolves
185
+ * everything from the Projection Root.
186
+ */
187
+ export async function registerCompanion(
188
+ api: Context,
189
+ context: CompanionContext,
190
+ env: LaunchEnvironment = process.env,
191
+ layerGuidance = "",
192
+ ): Promise<void> {
193
+ const guidance = loadGuidance(env, api.location.directory);
177
194
  if (!guidance) return;
178
195
  /** Reported, not thrown: the guardrails registered before this must stay live. */
179
- const wrapperBinPath = resolveWrapperBinPath();
196
+ const wrapperBinPath = resolveWrapperBinPath(env);
180
197
  if (!wrapperBinPath) {
181
198
  console.error(`mate: ${missingRuntimeDirectoryMessage()}`);
182
199
  return;
183
200
  }
184
201
 
185
- const companion = buildSystemPrompt(context, guidance, wrapperBinPath).join("\n");
202
+ const companion = buildSystemPrompt(context, guidance, wrapperBinPath, layerGuidance).join("\n");
186
203
 
187
204
  await api.session.hook("context", (event) => {
188
205
  const merged = mergeCompanionSystem(event.system, companion);
@@ -199,7 +216,7 @@ export async function registerCompanion(api: Context, context: CompanionContext)
199
216
  });
200
217
 
201
218
  await api.shell.hook("create.before", (event) => {
202
- applyCompanionShellEnv(context, event.env, wrapperBinPath);
219
+ applyCompanionShellEnv(context, event.env, wrapperBinPath, env);
203
220
  });
204
221
 
205
222
  await api.tool.transform((editor) => {
@@ -0,0 +1,29 @@
1
+ import { MATE_ENV } from "../../src/runtime/env";
2
+
3
+ export type LaunchEnvironment = Record<string, string | undefined>;
4
+
5
+ /**
6
+ * True inside OpenCode's shared background service (`opencode serve --service`),
7
+ * which hosts plugins in-process for every bare `opencode` session. Its
8
+ * environment belongs to whichever process started it, not to any session.
9
+ */
10
+ export function runsInSharedService(argv: readonly string[]): boolean {
11
+ return argv.includes("serve") && argv.includes("--service");
12
+ }
13
+
14
+ /**
15
+ * The environment the plugin may treat as a Mate launch environment: `env`
16
+ * itself for a managed launch, `{}` otherwise so every reader falls back to
17
+ * the Projection Root.
18
+ *
19
+ * Inherited `MATE_*` variables prove nothing — every shell under a Mate session
20
+ * carries them. The guidance payload does: only `mate opencode` sets it, and
21
+ * the plugin masks it in every shell it configures.
22
+ */
23
+ export function pluginLaunchEnvironment(
24
+ env: LaunchEnvironment,
25
+ argv: readonly string[],
26
+ ): LaunchEnvironment {
27
+ const managed = Boolean(env[MATE_ENV.guidanceJson]?.trim()) && !runsInSharedService(argv);
28
+ return managed ? env : {};
29
+ }
@@ -11,15 +11,21 @@ import { spawnPrepareRunner } from "../../src/runtime/session-prepare-runner";
11
11
  import { readWrapStateSync } from "../../src/runtime/wrap-state";
12
12
 
13
13
  import { registerCompanionAccess } from "./add-dir";
14
+ import {
15
+ readTrustedAudienceLayer,
16
+ readWrappedAudienceLayer,
17
+ registerAudienceLayer,
18
+ } from "./audience-layer";
14
19
  import { missingRuntimeDirectoryMessage, registerCompanion, resolveMateBin } from "./companion";
20
+ import { pluginLaunchEnvironment, type LaunchEnvironment } from "./launch-environment";
15
21
 
16
22
  /**
17
23
  * Only a location in a repository wrapped for OpenCode prepares, and only
18
24
  * without a launch environment: `mate opencode` has already prepared the
19
25
  * session it starts.
20
26
  */
21
- async function registerPrepare(api: Context, companionPath: string) {
22
- if (hasLaunchEnvironment(process.env)) return undefined;
27
+ async function registerPrepare(api: Context, companionPath: string, env: LaunchEnvironment) {
28
+ if (hasLaunchEnvironment(env)) return undefined;
23
29
  const repoRoot = repositoryWrappedForOpenCode(api.location.directory);
24
30
  if (!repoRoot) return undefined;
25
31
  const mateBin = resolveMateBin();
@@ -39,19 +45,30 @@ async function registerPrepare(api: Context, companionPath: string) {
39
45
  /**
40
46
  * Mate OpenCode server plugin (OpenCode 2.x). Loaded through the package's
41
47
  * `./server` export; stays inert when no companion resolves from the launch
42
- * environment or a Projection Root above the session directory.
48
+ * environment or a Projection Root above the session directory. The launch
49
+ * environment is decided once here: outside a managed launch every reader
50
+ * gets `{}`, so an environment inherited by the shared service never counts.
43
51
  */
44
52
  export const MateOpenCodePlugin = Plugin.define({
45
53
  id: "mate-opencode-plugin",
46
54
  async setup(api) {
47
- const context = readContext(process.env, api.location.directory);
55
+ const env = pluginLaunchEnvironment(process.env, process.argv);
56
+ const context = readContext(env, api.location.directory);
48
57
  if (!context.companionPath) return;
49
58
 
50
- await registerCompanionAccess(api, context.companionPath);
51
- await registerCompanionHooks(api, context);
52
- await registerCompanion(api, context);
59
+ /**
60
+ * A layer comes from a payload this launch trusts, else from the wrap of
61
+ * the session's checkout; an inherited environment never names one.
62
+ */
63
+ const layer =
64
+ (await readTrustedAudienceLayer(env)) ??
65
+ (await readWrappedAudienceLayer(api.location.directory, context.companionPath));
66
+ await registerCompanionAccess(api, context.companionPath, layer ? [layer.root] : []);
67
+ await registerCompanionHooks(api, context, env);
68
+ await registerCompanion(api, context, env, layer?.contents.guidance);
69
+ if (layer) await registerAudienceLayer(api, layer.contents);
53
70
  /** Last: the guardrails above are live while the local phase holds `setup`. */
54
- const prepareCleanup = await registerPrepare(api, context.companionPath);
71
+ const prepareCleanup = await registerPrepare(api, context.companionPath, env);
55
72
  return async () => {
56
73
  await prepareCleanup?.();
57
74
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniqbit/mate-core",
3
- "version": "0.18.3",
3
+ "version": "0.18.4",
4
4
  "description": "Core framework and plugin APIs for Mate.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,3 @@
1
- import fs from "node:fs/promises";
2
- import path from "node:path";
3
-
4
1
  import {
5
2
  hasGraphifyCapability,
6
3
  hasTokensaveCapability,
@@ -10,7 +7,7 @@ import type { CapabilityConfig } from "../../../lib/orchestrator/types";
10
7
  import {
11
8
  ensureTokensaveBranchingPosture,
12
9
  ensureTokensaveStoreExcluded,
13
- TOKENSAVE_STORE_DIR,
10
+ isTokensaveIndexed,
14
11
  } from "../../../tools/setup/capabilities/tokensave";
15
12
  import { ensureUnambiguousCompanion } from "../shared/companion-selection";
16
13
  import { runGraphifyCapCommand } from "./graphify";
@@ -43,13 +40,7 @@ async function defaultIsTokensaveInitialized(): Promise<boolean> {
43
40
  const repo =
44
41
  repository ?? (await workingRepoStore.load()).repos.find((entry) => entry.id === repositoryId);
45
42
  if (!repo) return false;
46
-
47
- try {
48
- const stat = await fs.stat(path.join(repo.path, TOKENSAVE_STORE_DIR));
49
- return stat.isDirectory();
50
- } catch {
51
- return false;
52
- }
43
+ return isTokensaveIndexed(repo.path);
53
44
  }
54
45
 
55
46
  // The reused cap commands (runGraphifyCapCommand / runTokensaveCapCommand) signal
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
 
4
4
  import { FRAMEWORK_NAME } from "../../framework";
5
5
  import { gitEnvironment } from "../../runtime/companion-git";
6
+ import { readWrapStateSync } from "../../runtime/wrap-state";
6
7
  import { getActiveDistribution } from "../../distribution";
7
8
  import { checkEngineRequirement } from "../../lib/orchestrator/engine-guard";
8
9
  import type { GlobalConfigStore } from "../../lib/orchestrator/global-config-store";
@@ -19,7 +20,12 @@ import {
19
20
  type PluginInspection,
20
21
  type PluginVerificationFailure,
21
22
  } from "../../tools/setup/dynamic-plugins/verify";
23
+ import {
24
+ inspectAudienceIsolation,
25
+ type AudienceIsolationReport,
26
+ } from "../../tools/setup/isolation-diagnostics";
22
27
  import { getRequiredPluginDrift } from "../../tools/setup/policy";
28
+ import type { PluginRegistry } from "../../tools/setup/registry";
23
29
  import { resolveCommandOnPath } from "../../tools/setup/utils";
24
30
  import { writeJsonStdout } from "../write-json-stdout";
25
31
  import { printSection, renderKeyValueTable, renderTable } from "./status";
@@ -33,6 +39,8 @@ interface DoctorDeps {
33
39
  inspectPlugins?: (companionPath: string) => Promise<PluginInspection>;
34
40
  /** Environment `MATE_AUDIENCE` is read from; defaults to the process environment. */
35
41
  env?: Record<string, string | undefined>;
42
+ /** The hydrated registry; the active distribution's when absent. */
43
+ registry?: PluginRegistry;
36
44
  }
37
45
 
38
46
  export type DoctorKind = "core" | "working" | "companion" | "hub";
@@ -66,10 +74,15 @@ export interface DoctorReport {
66
74
  toolInstallations?: DoctorToolInstallation[];
67
75
  requiredPluginDrift?: Array<{ pluginId: string; kind: string; reason: string }>;
68
76
  pluginFailures?: PluginVerificationFailure[];
69
- /** Present for a companion that declares audiences or has one selected. */
70
- audiences?: { declared: string[]; active: string | null };
77
+ /**
78
+ * Present for a companion that declares audiences or has one selected.
79
+ * `wrapped` is the audience the working repository is wrapped for, when any.
80
+ */
81
+ audiences?: { declared: string[]; active: string | null; wrapped?: string };
71
82
  /** Set when `MATE_AUDIENCE` names an audience the companion does not declare. */
72
83
  audienceError?: string;
84
+ /** Present when an audience's contributions reached a persisted surface, or a launch layer cannot deliver part of one. */
85
+ audienceIsolation?: AudienceIsolationReport;
73
86
  /** IDs of the capabilities the verified plugins provide. */
74
87
  pluginCapabilities?: string[];
75
88
  engineRequirement?: { range: string; ok: boolean; detail: string };
@@ -230,12 +243,18 @@ function collectAudiences(
230
243
  report: DoctorReport,
231
244
  config: FrameworkConfig,
232
245
  env: Record<string, string | undefined>,
246
+ repositoryPath: string | null,
233
247
  ): void {
234
248
  const declared = Object.keys(config.audiences ?? {});
235
249
  const requested = readAudienceSelection(env);
236
250
  if (declared.length === 0 && requested === null) return;
237
251
  const resolution = resolveAudience(config, requested);
238
- report.audiences = { declared, active: resolution.ok ? resolution.active : null };
252
+ const wrapped = repositoryPath ? readWrapStateSync(repositoryPath).audience : null;
253
+ report.audiences = {
254
+ declared,
255
+ active: resolution.ok ? resolution.active : null,
256
+ ...(wrapped ? { wrapped } : {}),
257
+ };
239
258
  if (!resolution.ok) report.audienceError = describeUnknownAudience(resolution);
240
259
  }
241
260
 
@@ -274,7 +293,16 @@ export async function collectDoctorReport(deps: DoctorDeps = {}): Promise<Doctor
274
293
 
275
294
  const env = deps.env ?? process.env;
276
295
  if (root.rootPath && root.config && kind !== "hub") {
277
- collectAudiences(report, root.config, env);
296
+ collectAudiences(report, root.config, env, root.linkedRepository?.path ?? null);
297
+ const isolation = await inspectAudienceIsolation(
298
+ root.rootPath,
299
+ root.config,
300
+ deps.registry ?? getActiveDistribution().registry,
301
+ report.audiences?.active ?? null,
302
+ );
303
+ if (isolation.leaked.length + isolation.unswept.length + isolation.undelivered.length > 0) {
304
+ report.audienceIsolation = isolation;
305
+ }
278
306
  }
279
307
 
280
308
  if (root.rootPath && root.config) {
@@ -325,6 +353,49 @@ function renderHubMembers(members: DoctorHubMember[]): string {
325
353
  );
326
354
  }
327
355
 
356
+ function renderAudienceIsolation(isolation: AudienceIsolationReport): string {
357
+ const parts: string[] = [];
358
+ if (isolation.leaked.length > 0) {
359
+ parts.push(
360
+ "Contributions on persisted surfaces that no base plugin owns; the next sync removes them:",
361
+ renderTable(
362
+ ["Surface", "Kind", "Entry", "Owner"],
363
+ isolation.leaked.map((entry) => [
364
+ entry.surface,
365
+ entry.kind,
366
+ entry.identity ?? "",
367
+ entry.owner,
368
+ ]),
369
+ ),
370
+ );
371
+ }
372
+ if (isolation.unswept.length > 0) {
373
+ parts.push(
374
+ `Audience plugins not swept (could not be loaded); run \`MATE_AUDIENCE=<audience> ${FRAMEWORK_NAME} install --yes\` with their credentials:`,
375
+ renderList(isolation.unswept, ""),
376
+ );
377
+ }
378
+ if (isolation.undelivered.length > 0) {
379
+ parts.push(
380
+ "Active audience plugins contribute declaratively only; these parts are not delivered:",
381
+ renderList(
382
+ isolation.undelivered.map(
383
+ (entry) =>
384
+ `${entry.pluginId}: ${entry.parts
385
+ .map((part) =>
386
+ part === "pluginReferences"
387
+ ? "OpenCode plugin references are not delivered"
388
+ : `${part} is not run`,
389
+ )
390
+ .join("; ")}`,
391
+ ),
392
+ "",
393
+ ),
394
+ );
395
+ }
396
+ return parts.join("\n\n");
397
+ }
398
+
328
399
  function renderHumanReport(report: DoctorReport): void {
329
400
  console.log("Mate Doctor");
330
401
  console.log("");
@@ -371,9 +442,20 @@ function renderHumanReport(report: DoctorReport): void {
371
442
  renderKeyValueTable([
372
443
  ["Declared", report.audiences.declared.join(", ") || "none"],
373
444
  ["Active", report.audiences.active ?? "none"],
445
+ ...(report.audiences.wrapped
446
+ ? [
447
+ ["Wrapped", `${report.audiences.wrapped} (bare sessions of this checkout)`] as [
448
+ string,
449
+ string,
450
+ ],
451
+ ]
452
+ : []),
374
453
  ]),
375
454
  );
376
455
  }
456
+ if (report.audienceIsolation) {
457
+ printSection("Audience Isolation", renderAudienceIsolation(report.audienceIsolation));
458
+ }
377
459
  if (report.hub) printSection("Hub Members", renderHubMembers(report.hub.members));
378
460
  // Companions with no drift produce no section at all: required plugins are
379
461
  // silent when healthy.
@@ -1,5 +1,8 @@
1
+ import path from "node:path";
2
+
1
3
  import { confirm } from "../confirm";
2
4
  import { FRAMEWORK_NAME } from "../../framework";
5
+ import { ConfigStore } from "../../lib/orchestrator/config-store";
3
6
  import {
4
7
  buildInstallPlan,
5
8
  inspectInstallPlan,
@@ -176,6 +179,11 @@ export async function runInstallCommand(argv: string[], cwd = process.cwd()): Pr
176
179
  }
177
180
 
178
181
  try {
182
+ if (!frozen && context.kind === "companion" && context.companionPath) {
183
+ await new ConfigStore(
184
+ path.join(context.companionPath, `.${FRAMEWORK_NAME}`, "config", "framework.yaml"),
185
+ ).save(context.config);
186
+ }
179
187
  await reconcileInstalledCompanion(plan);
180
188
  await saveCompleteInstallState(plan, execution.results);
181
189
  } catch (error) {
@@ -3,9 +3,11 @@ import path from "node:path";
3
3
 
4
4
  import { FRAMEWORK_NAME } from "../../../framework";
5
5
  import { ConfigStore, mergeWithDefaults } from "../../../lib/orchestrator/config-store";
6
+ import { currentAudienceLayer } from "../../../runtime/audience-layer";
6
7
  import {
7
8
  effectiveStudioAgent,
8
9
  readAudienceSelection,
10
+ resolveAudience,
9
11
  } from "../../../tools/setup/dynamic-plugins/audiences";
10
12
  import type { StudioInventory, StudioInventoryCompanion } from "./inventory";
11
13
  import { findAction, promptArgs, skillInstalledFor, SUBJECT_PATTERN } from "./actions";
@@ -51,7 +53,8 @@ export interface DefaultAgentOutcome {
51
53
  /**
52
54
  * The companion's effective default agent for one provider: the active
53
55
  * audience's `studio.terminal.agent` (Studio's own `MATE_AUDIENCE`, never the
54
- * browser's), else the base one. Never throws and never echoes the configured
56
+ * browser's), else the base one. Its definition may come from the companion
57
+ * or from the active audience's rendered layer. Never throws and never echoes the configured
55
58
  * value into the notice.
56
59
  */
57
60
  export async function defaultAgentFor(
@@ -62,11 +65,13 @@ export async function defaultAgentFor(
62
65
  const configPath = path.join(companionPath, `.${FRAMEWORK_NAME}`, "config", "framework.yaml");
63
66
  if (!fs.existsSync(configPath)) return {};
64
67
  let name: unknown;
68
+ let layer: string | null = null;
65
69
  try {
66
- name = effectiveStudioAgent(
67
- await new ConfigStore(configPath).load(),
68
- readAudienceSelection(env),
69
- );
70
+ const config = await new ConfigStore(configPath).load();
71
+ const audience = resolveAudience(config, readAudienceSelection(env));
72
+ const active = audience.ok ? audience.active : null;
73
+ name = effectiveStudioAgent(config, active);
74
+ if (active) layer = currentAudienceLayer(companionPath, active);
70
75
  } catch {
71
76
  return {};
72
77
  }
@@ -76,8 +81,12 @@ export async function defaultAgentFor(
76
81
  notice: "Default agent not applied: studio.terminal.agent is not a valid agent name.",
77
82
  };
78
83
  }
79
- const definition = path.join(companionPath, AGENT_DEFINITION_DIRS[agent], `${name}.md`);
80
- if (!fs.existsSync(definition)) {
84
+ /** The audience's own agents live in its launch layer, which the terminal's launch attaches. */
85
+ const roots = layer ? [companionPath, layer] : [companionPath];
86
+ const defined = roots.some((root) =>
87
+ fs.existsSync(path.join(root, AGENT_DEFINITION_DIRS[agent], `${name}.md`)),
88
+ );
89
+ if (!defined) {
81
90
  return {
82
91
  notice: `Default agent "${name}" not applied: this companion has no ${agent} definition for it.`,
83
92
  };