@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
@@ -0,0 +1,176 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import { layerGuidanceText } from "./audience-layer";
5
+ import { OPENCODE_MCP_SERVERS_AT } from "./runtime-document-manifest";
6
+
7
+ /**
8
+ * Reads an audience layer the OpenCode Runtime Surface wrote, as what the Mate
9
+ * OpenCode plugin applies through the plugin API. The surface re-exports it as
10
+ * its own reader; it lives under `runtime/` only so the import-isolated plugin
11
+ * can reach it. The plugin parses no OpenCode document itself.
12
+ */
13
+
14
+ export interface LayerMcpServer {
15
+ name: string;
16
+ /** The entry exactly as the surface wrote it under `mcp.servers`. */
17
+ config: Record<string, unknown>;
18
+ }
19
+
20
+ export interface LayerSkill {
21
+ id: string;
22
+ name: string;
23
+ description?: string;
24
+ /** The skill's `SKILL.md`. */
25
+ path: string;
26
+ content: string;
27
+ }
28
+
29
+ export type LayerAgentMode = "subagent" | "primary" | "all";
30
+
31
+ export interface LayerAgent {
32
+ id: string;
33
+ description?: string;
34
+ mode: LayerAgentMode;
35
+ /** The agent's prompt: the definition's body. */
36
+ system: string;
37
+ }
38
+
39
+ export interface OpenCodeLayerContents {
40
+ mcpServers: LayerMcpServer[];
41
+ skills: LayerSkill[];
42
+ agents: LayerAgent[];
43
+ /** The layer's guidance sections, managed-block markers removed. */
44
+ guidance: string;
45
+ }
46
+
47
+ function isRecord(value: unknown): value is Record<string, unknown> {
48
+ return typeof value === "object" && value !== null && !Array.isArray(value);
49
+ }
50
+
51
+ function splitFrontmatter(content: string): { fields: string; body: string } {
52
+ const match = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
53
+ return match
54
+ ? { fields: match[1]!, body: content.slice(match[0].length) }
55
+ : { fields: "", body: content };
56
+ }
57
+
58
+ function frontmatterScalarFromFields(fields: string, key: string): string | undefined {
59
+ const value = fields.match(new RegExp(`^${key}:\\s*(.+)$`, "m"))?.[1]?.trim();
60
+ return value?.replace(
61
+ /^(?:"([\s\S]*)"|'([\s\S]*)')$/,
62
+ (_all, doubleQuoted?: string, singleQuoted?: string) => doubleQuoted ?? singleQuoted ?? "",
63
+ );
64
+ }
65
+
66
+ /** One scalar of a simple YAML frontmatter, quotes removed. */
67
+ export function frontmatterScalar(content: string, key: string): string | undefined {
68
+ return frontmatterScalarFromFields(splitFrontmatter(content).fields, key);
69
+ }
70
+
71
+ /** An id OpenCode accepts for a skill or agent name. */
72
+ export function contributionId(name: string, fallback: string): string {
73
+ return (
74
+ name
75
+ .toLowerCase()
76
+ .replace(/[^a-z0-9_-]+/g, "-")
77
+ .replace(/^-+|-+$/g, "") || fallback
78
+ );
79
+ }
80
+
81
+ async function findSkillFiles(directory: string): Promise<string[]> {
82
+ try {
83
+ const entries = await fs.readdir(directory, { withFileTypes: true });
84
+ const nested = await Promise.all(
85
+ entries.map((entry) => {
86
+ const entryPath = path.join(directory, entry.name);
87
+ if (entry.isFile() && entry.name === "SKILL.md") return Promise.resolve([entryPath]);
88
+ return entry.isDirectory() ? findSkillFiles(entryPath) : Promise.resolve([]);
89
+ }),
90
+ );
91
+ return nested.flat().toSorted();
92
+ } catch {
93
+ return [];
94
+ }
95
+ }
96
+
97
+ /** Every `SKILL.md` under `roots`; an unreadable skill is skipped, never fatal. */
98
+ export async function loadSkillInfos(roots: readonly string[]): Promise<LayerSkill[]> {
99
+ const files = (await Promise.all(roots.map(findSkillFiles))).flat();
100
+ const skills = await Promise.all(
101
+ files.map(async (filePath): Promise<LayerSkill | null> => {
102
+ try {
103
+ const content = await fs.readFile(filePath, "utf8");
104
+ const { fields } = splitFrontmatter(content);
105
+ const name =
106
+ frontmatterScalarFromFields(fields, "name") || path.basename(path.dirname(filePath));
107
+ const description = frontmatterScalarFromFields(fields, "description");
108
+ return {
109
+ id: contributionId(name, "mate-skill"),
110
+ name,
111
+ ...(description ? { description } : {}),
112
+ path: filePath,
113
+ content,
114
+ };
115
+ } catch {
116
+ return null;
117
+ }
118
+ }),
119
+ );
120
+ return skills.filter((skill) => skill !== null);
121
+ }
122
+
123
+ async function readMcpServers(root: string): Promise<LayerMcpServer[]> {
124
+ const parsed = await fs
125
+ .readFile(path.join(root, ".opencode", "opencode.json"), "utf8")
126
+ .then((text) => JSON.parse(text) as unknown)
127
+ .catch(() => null);
128
+ let servers: unknown = parsed;
129
+ for (const key of OPENCODE_MCP_SERVERS_AT) servers = isRecord(servers) ? servers[key] : undefined;
130
+ if (!isRecord(servers)) return [];
131
+ return Object.entries(servers)
132
+ .filter((entry): entry is [string, Record<string, unknown>] => isRecord(entry[1]))
133
+ .map(([name, config]) => ({ name, config }))
134
+ .toSorted((left, right) => left.name.localeCompare(right.name));
135
+ }
136
+
137
+ function agentMode(value: string | undefined): LayerAgentMode {
138
+ return value === "subagent" || value === "primary" ? value : "all";
139
+ }
140
+
141
+ async function readAgents(root: string): Promise<LayerAgent[]> {
142
+ const dir = path.join(root, ".opencode", "agents");
143
+ const names = await fs.readdir(dir).catch(() => [] as string[]);
144
+ const agents = await Promise.all(
145
+ names
146
+ .filter((name) => name.endsWith(".md"))
147
+ .toSorted()
148
+ .map(async (name): Promise<LayerAgent | null> => {
149
+ const content = await fs.readFile(path.join(dir, name), "utf8").catch(() => null);
150
+ if (content === null) return null;
151
+ const { fields, body } = splitFrontmatter(content);
152
+ const description = frontmatterScalarFromFields(fields, "description");
153
+ return {
154
+ id: contributionId(path.basename(name, ".md"), "mate-agent"),
155
+ ...(description ? { description } : {}),
156
+ mode: agentMode(frontmatterScalarFromFields(fields, "mode")),
157
+ system: body.trim(),
158
+ };
159
+ }),
160
+ );
161
+ return agents.filter((agent) => agent !== null);
162
+ }
163
+
164
+ async function readGuidance(root: string): Promise<string> {
165
+ return layerGuidanceText(await fs.readFile(path.join(root, "AGENTS.md"), "utf8").catch(() => ""));
166
+ }
167
+
168
+ export async function readOpenCodeAudienceLayer(root: string): Promise<OpenCodeLayerContents> {
169
+ const [mcpServers, skills, agents, guidance] = await Promise.all([
170
+ readMcpServers(root),
171
+ loadSkillInfos([path.join(root, ".agents", "skills")]),
172
+ readAgents(root),
173
+ readGuidance(root),
174
+ ]);
175
+ return { mcpServers, skills, agents, guidance };
176
+ }
@@ -40,6 +40,17 @@ function capabilityNames(value: unknown): string[] {
40
40
  .filter((name): name is string => typeof name === "string");
41
41
  }
42
42
 
43
+ /**
44
+ * A base-set key from `audiences.base`, the shape a companion is saved in. The
45
+ * lenient twin of the setup normalizer, which `runtime/` cannot import.
46
+ */
47
+ function baseAudienceKey(config: Record<string, unknown>, key: string): unknown {
48
+ const audiences = config.audiences;
49
+ if (!audiences || typeof audiences !== "object") return undefined;
50
+ const base = (audiences as Record<string, unknown>).base;
51
+ return base && typeof base === "object" ? (base as Record<string, unknown>)[key] : undefined;
52
+ }
53
+
43
54
  /**
44
55
  * Synchronous so it composes into `readCompanionRuntimeContext`. A missing or
45
56
  * unparseable companion config yields an inert policy rather than throwing.
@@ -56,7 +67,9 @@ export function readCompanionPolicy(companionPath: string): CompanionPolicy {
56
67
  const config = parsed as Record<string, unknown>;
57
68
  return {
58
69
  allowedAgents: stringList(config.allowedAgents),
59
- enabledCapabilities: capabilityNames(config.capabilities),
70
+ enabledCapabilities: capabilityNames(
71
+ config.capabilities ?? baseAudienceKey(config, "capabilities"),
72
+ ),
60
73
  gitAutoMode: config.git === "auto",
61
74
  };
62
75
  }
@@ -88,7 +88,7 @@ export function projectionEnvPath(repoRoot: string): string {
88
88
  return path.join(repoLocalDirPath(repoRoot), PROJECTION_ENV_FILE);
89
89
  }
90
90
 
91
- const generatedHeader = () => `# generated by ${FRAMEWORK_NAME} — do not edit`;
91
+ export const generatedHeader = (): string => `# generated by ${FRAMEWORK_NAME} — do not edit`;
92
92
 
93
93
  export function renderProjectionYaml(file: ProjectionFile): string {
94
94
  const document: Record<string, string> = { stamp: file.stamp };
@@ -80,6 +80,8 @@ export interface Manifest {
80
80
  readonly runtimes: Readonly<Record<string, WrapRuntime>>;
81
81
  readonly copies: Readonly<Record<string, CopyRecord>>;
82
82
  readonly links: Readonly<Record<string, LinkRecord>>;
83
+ /** The audience `mate wrap --audience` recorded; the only source of a bare session's audience. */
84
+ readonly audience?: string;
83
85
  }
84
86
 
85
87
  export const EMPTY_MANIFEST: Manifest = Object.freeze({
@@ -124,9 +126,23 @@ function manifestOf(parsed: Record<string, unknown>): Manifest {
124
126
  runtimes: isRecord(parsed.runtimes) ? (parsed.runtimes as Manifest["runtimes"]) : {},
125
127
  copies: isRecord(parsed.copies) ? (parsed.copies as Manifest["copies"]) : {},
126
128
  links: isRecord(parsed.links) ? (parsed.links as Manifest["links"]) : {},
129
+ ...(typeof parsed.audience === "string" && parsed.audience
130
+ ? { audience: parsed.audience }
131
+ : {}),
127
132
  };
128
133
  }
129
134
 
135
+ /** The audience the checkout is wrapped for; `null` for base only. */
136
+ export function recordedAudience(manifest: Manifest): string | null {
137
+ return manifest.audience ?? null;
138
+ }
139
+
140
+ /** Records the checkout's audience, or none with `null`. */
141
+ export function withAudience(manifest: Manifest, audience: string | null): Manifest {
142
+ const { audience: _previous, ...rest } = manifest;
143
+ return audience ? { ...rest, audience } : rest;
144
+ }
145
+
130
146
  function recordedRuntime(manifest: Manifest, documentPath: string): WrapRuntime {
131
147
  return manifest.runtimes[documentPath] ?? documentRuntime(documentPath);
132
148
  }
@@ -150,6 +166,7 @@ export function serializeManifest(manifest: Manifest): string | null {
150
166
  ...(recorded.length > 0 ? { runtimes } : {}),
151
167
  ...(copied.length > 0 ? { copies: manifest.copies } : {}),
152
168
  ...(linked.length > 0 ? { links: manifest.links } : {}),
169
+ ...(manifest.audience ? { audience: manifest.audience } : {}),
153
170
  },
154
171
  null,
155
172
  2,
@@ -14,6 +14,7 @@ import {
14
14
  parseManifestStrict,
15
15
  readManifestText,
16
16
  recordedListValues,
17
+ recordedAudience,
17
18
  recordedMapKeys,
18
19
  type Manifest,
19
20
  type WrapRuntime,
@@ -30,6 +31,8 @@ export interface WrapState {
30
31
  carriesClaudeHookGroup(group: ClaudeHookGroup): boolean;
31
32
  /** MCP server names the wrap declared in `.opencode/opencode.json`; user servers are not listed. */
32
33
  readonly openCodeMcpServers: readonly string[];
34
+ /** The audience the checkout is wrapped for; `null` for base only. */
35
+ readonly audience: string | null;
33
36
  }
34
37
 
35
38
  export const WRAP_RUNTIMES: readonly WrapRuntime[] = ["claude", "opencode"];
@@ -61,6 +64,7 @@ export function wrapStateOf(manifest: Manifest): WrapState {
61
64
  OPENCODE_CONFIG_DOCUMENT,
62
65
  OPENCODE_MCP_SERVERS_AT,
63
66
  ),
67
+ audience: recordedAudience(manifest),
64
68
  };
65
69
  }
66
70
 
@@ -0,0 +1,325 @@
1
+ // oxlint-disable no-await-in-loop
2
+ import type { Dirent } from "node:fs";
3
+ import fs from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+
7
+ import type { FrameworkConfig } from "../../lib/orchestrator/types";
8
+ import { pluginPackageRoot } from "./dynamic-plugins/paths";
9
+ import type {
10
+ CapabilityPlugin,
11
+ PluginRegistration,
12
+ ProviderPlugin,
13
+ RuntimeContributions,
14
+ } from "./plugin";
15
+
16
+ /**
17
+ * Audience isolation harness: one companion whose base plugin and audience
18
+ * `ba` and `qa` plugins each declare every contribution kind, every value
19
+ * carrying its owner's marker. A test runs a pass, then asserts which markers
20
+ * a surface holds — a marker found where it must not be is a leak.
21
+ */
22
+
23
+ export type FixtureOwner = "base" | "ba" | "qa";
24
+
25
+ export const AUDIENCE_MARKERS: Record<FixtureOwner, string> = {
26
+ base: "BASEMARK7",
27
+ ba: "BAMARK7",
28
+ qa: "QAMARK7",
29
+ };
30
+
31
+ export const FIXTURE_AUDIENCES = ["ba", "qa"] as const;
32
+
33
+ export function fixturePluginId(owner: FixtureOwner): string {
34
+ return `${owner}-plugin`;
35
+ }
36
+
37
+ export function fixturePackage(owner: FixtureOwner): string {
38
+ return `@fixture/${owner}`;
39
+ }
40
+
41
+ export interface FixtureCapabilityOptions {
42
+ apply?: () => Promise<void>;
43
+ teardown?: () => Promise<void>;
44
+ forProvider?: CapabilityPlugin["forProvider"];
45
+ pluginReference?: string;
46
+ }
47
+
48
+ function contributionsFor(
49
+ owner: FixtureOwner,
50
+ skillSourceDir: string,
51
+ runtime: "claude" | "opencode",
52
+ options: FixtureCapabilityOptions,
53
+ ): RuntimeContributions {
54
+ const marker = AUDIENCE_MARKERS[owner];
55
+ const contributions: RuntimeContributions = {
56
+ mcpServers: [{ name: `${owner}-mcp`, command: `${marker}-server`, args: ["serve"] }],
57
+ guidanceSections: [{ content: `${marker} guidance for ${owner}.` }],
58
+ skillTrees: [{ name: `${owner}-skill`, sourceDir: skillSourceDir }],
59
+ agentDefinitions: [
60
+ {
61
+ name: `${owner}-agent`,
62
+ content: `---\nname: ${owner}-agent\ndescription: ${owner} agent\n---\n${marker} agent prompt.\n`,
63
+ },
64
+ ],
65
+ permissionEntries: [`Bash(${marker}:*)`],
66
+ };
67
+ if (runtime === "claude") {
68
+ contributions.hookGroups = [
69
+ {
70
+ event: "PreToolUse",
71
+ marker: `${marker}-hook.sh`,
72
+ group: { matcher: "Bash", hooks: [{ type: "command", command: `${marker}-hook.sh` }] },
73
+ },
74
+ ];
75
+ }
76
+ if (runtime === "opencode" && options.pluginReference) {
77
+ const reference = options.pluginReference;
78
+ contributions.pluginReferences = [
79
+ { reference, isManagedReference: (entry) => entry === reference },
80
+ ];
81
+ }
82
+ return contributions;
83
+ }
84
+
85
+ /** A capability declaring every contribution kind, each value carrying the owner's marker. */
86
+ export function markedCapability(
87
+ owner: FixtureOwner,
88
+ skillSourceDir: string,
89
+ options: FixtureCapabilityOptions = {},
90
+ ): CapabilityPlugin {
91
+ return {
92
+ id: fixturePluginId(owner),
93
+ kind: "capability",
94
+ label: owner,
95
+ description: `${owner} fixture capability`,
96
+ defaultSelected: true,
97
+ isEnabled: () => true,
98
+ apply: options.apply ?? (async () => {}),
99
+ teardown: options.teardown ?? (async () => {}),
100
+ ...(options.forProvider ? { forProvider: options.forProvider } : {}),
101
+ getRuntimeContributions() {
102
+ return {
103
+ claude: contributionsFor(owner, skillSourceDir, "claude", options),
104
+ opencode: contributionsFor(owner, skillSourceDir, "opencode", options),
105
+ };
106
+ },
107
+ };
108
+ }
109
+
110
+ export function fixtureProvider(id: "claude" | "opencode"): ProviderPlugin {
111
+ return {
112
+ id,
113
+ kind: "provider",
114
+ label: id,
115
+ description: id,
116
+ defaultSelected: true,
117
+ isEnabled: () => true,
118
+ async apply() {},
119
+ async teardown() {},
120
+ };
121
+ }
122
+
123
+ export interface AudienceFixture {
124
+ root: string;
125
+ companionPath: string;
126
+ repoPath: string;
127
+ config: FrameworkConfig;
128
+ skillSources: Record<FixtureOwner, string>;
129
+ /** Providers, the base plugin, and both audiences' plugins, tagged with their source. */
130
+ registrations(
131
+ options?: Partial<Record<FixtureOwner, FixtureCapabilityOptions>>,
132
+ ): PluginRegistration[];
133
+ cleanup(): Promise<void>;
134
+ }
135
+
136
+ const FRAMEWORK_YAML = [
137
+ "type: companion",
138
+ "allowedAgents:",
139
+ " - claude",
140
+ " - opencode",
141
+ "plugins:",
142
+ ' - package: "@fixture/base"',
143
+ ' version: "1.0.0"',
144
+ "audiences:",
145
+ " ba:",
146
+ " plugins:",
147
+ ' - package: "@fixture/ba"',
148
+ ' version: "1.0.0"',
149
+ " qa:",
150
+ " plugins:",
151
+ ' - package: "@fixture/qa"',
152
+ ' version: "1.0.0"',
153
+ "",
154
+ ].join("\n");
155
+
156
+ export async function makeAudienceFixture(): Promise<AudienceFixture> {
157
+ const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "audience-isolation-")));
158
+ const companionPath = path.join(root, "companion");
159
+ const repoPath = path.join(root, "repo");
160
+ await fs.mkdir(path.join(companionPath, ".mate", "config"), { recursive: true });
161
+ await fs.mkdir(repoPath, { recursive: true });
162
+ await fs.writeFile(
163
+ path.join(companionPath, ".mate", "config", "framework.yaml"),
164
+ FRAMEWORK_YAML,
165
+ "utf8",
166
+ );
167
+
168
+ const skillSources = {} as Record<FixtureOwner, string>;
169
+ for (const owner of ["base", "ba", "qa"] as const) {
170
+ const dir = path.join(root, "skill-sources", `${owner}-skill`);
171
+ await fs.mkdir(dir, { recursive: true });
172
+ await fs.writeFile(
173
+ path.join(dir, "SKILL.md"),
174
+ `---\nname: ${owner}-skill\ndescription: ${owner} skill\n---\n${AUDIENCE_MARKERS[owner]} skill body.\n`,
175
+ "utf8",
176
+ );
177
+ skillSources[owner] = dir;
178
+ }
179
+
180
+ const config: FrameworkConfig = {
181
+ type: "companion",
182
+ allowedAgents: ["claude", "opencode"],
183
+ capabilities: [],
184
+ plugins: [{ package: "@fixture/base", version: "1.0.0" }],
185
+ audiences: {
186
+ ba: { plugins: [{ package: "@fixture/ba", version: "1.0.0" }] },
187
+ qa: { plugins: [{ package: "@fixture/qa", version: "1.0.0" }] },
188
+ },
189
+ } as FrameworkConfig;
190
+
191
+ return {
192
+ root,
193
+ companionPath,
194
+ repoPath,
195
+ config,
196
+ skillSources,
197
+ registrations(options = {}) {
198
+ return [
199
+ { plugin: fixtureProvider("claude"), policy: "required" },
200
+ { plugin: fixtureProvider("opencode"), policy: "required" },
201
+ {
202
+ plugin: markedCapability("base", skillSources.base, options.base),
203
+ policy: "required",
204
+ audience: null,
205
+ packageName: fixturePackage("base"),
206
+ },
207
+ {
208
+ plugin: markedCapability("ba", skillSources.ba, options.ba),
209
+ policy: "optional",
210
+ audience: "ba",
211
+ packageName: fixturePackage("ba"),
212
+ },
213
+ {
214
+ plugin: markedCapability("qa", skillSources.qa, options.qa),
215
+ policy: "optional",
216
+ audience: "qa",
217
+ packageName: fixturePackage("qa"),
218
+ },
219
+ ];
220
+ },
221
+ cleanup: () => fs.rm(root, { recursive: true, force: true }),
222
+ };
223
+ }
224
+
225
+ /** Every regular file under `root` (symlinks followed), as relative path → text. Missing root: none. */
226
+ export async function readTree(root: string): Promise<Map<string, string>> {
227
+ const files = new Map<string, string>();
228
+ async function walk(dir: string): Promise<void> {
229
+ let entries: Dirent[];
230
+ try {
231
+ entries = await fs.readdir(dir, { withFileTypes: true });
232
+ } catch {
233
+ return;
234
+ }
235
+ for (const entry of entries) {
236
+ const full = path.join(dir, entry.name);
237
+ const stat = await fs.stat(full).catch(() => null);
238
+ if (!stat) continue;
239
+ if (stat.isDirectory()) await walk(full);
240
+ else if (stat.isFile()) files.set(path.relative(root, full), await fs.readFile(full, "utf8"));
241
+ }
242
+ }
243
+ await walk(root);
244
+ return files;
245
+ }
246
+
247
+ /** `<where>: <marker>` for every marker of `owners` found in `text`. */
248
+ export function markerHits(where: string, text: string, owners: readonly FixtureOwner[]): string[] {
249
+ return owners
250
+ .filter((owner) => text.includes(AUDIENCE_MARKERS[owner]) || ownerIdentityIn(text, owner))
251
+ .map((owner) => `${where}: ${owner}`);
252
+ }
253
+
254
+ /** Identities without the marker (MCP, skill, agent names) still count as the owner's. */
255
+ function ownerIdentityIn(text: string, owner: FixtureOwner): boolean {
256
+ return [`${owner}-mcp`, `${owner}-skill`, `${owner}-agent`, `${owner}-plugin`].some((identity) =>
257
+ text.includes(identity),
258
+ );
259
+ }
260
+
261
+ /** The installed plugin packages: what a plugin is, not what a session loads. */
262
+ const PLUGIN_WORKSPACE = `${path.join(".mate", "plugins")}${path.sep}`;
263
+
264
+ /** Marker hits of `owners` in every file (path and content) under `root`, the plugin workspace aside. */
265
+ export async function markerHitsInTree(
266
+ root: string,
267
+ owners: readonly FixtureOwner[],
268
+ skip: readonly string[] = [],
269
+ ): Promise<string[]> {
270
+ const hits: string[] = [];
271
+ for (const [relative, text] of await readTree(root)) {
272
+ if (relative.startsWith(PLUGIN_WORKSPACE)) continue;
273
+ if (skip.some((prefix) => relative.startsWith(prefix))) continue;
274
+ hits.push(...markerHits(relative, `${relative}\n${text}`, owners));
275
+ }
276
+ return hits;
277
+ }
278
+
279
+ /** Marker hits of `owners` in any JSON-serializable value: rendered documents, launch arguments. */
280
+ export function markerHitsInValue(
281
+ where: string,
282
+ value: unknown,
283
+ owners: readonly FixtureOwner[],
284
+ ): string[] {
285
+ return markerHits(where, JSON.stringify(value), owners);
286
+ }
287
+
288
+ /**
289
+ * Installs `owner`'s fixture plugin into the companion's plugin workspace, so
290
+ * a pass can load it from its declaration alone. Declares exactly what
291
+ * `markedCapability` declares, hook groups and all.
292
+ */
293
+ export async function installFixturePackage(
294
+ fixture: AudienceFixture,
295
+ owner: FixtureOwner,
296
+ ): Promise<void> {
297
+ const name = fixturePackage(owner);
298
+ const root = pluginPackageRoot(fixture.companionPath, name);
299
+ await fs.mkdir(root, { recursive: true });
300
+ await fs.writeFile(
301
+ path.join(root, "package.json"),
302
+ JSON.stringify({ name, version: "1.0.0", type: "module", main: "index.js" }),
303
+ "utf8",
304
+ );
305
+ const declared = markedCapability(owner, fixture.skillSources[owner]).getRuntimeContributions!(
306
+ {} as never,
307
+ );
308
+ const source = `
309
+ const contributions = ${JSON.stringify(declared)};
310
+ export default function createPlugin() {
311
+ return {
312
+ id: ${JSON.stringify(fixturePluginId(owner))},
313
+ kind: "capability",
314
+ label: ${JSON.stringify(owner)},
315
+ description: "installed fixture",
316
+ defaultSelected: true,
317
+ isEnabled: () => true,
318
+ apply: async () => {},
319
+ teardown: async () => {},
320
+ getRuntimeContributions: () => contributions,
321
+ };
322
+ }
323
+ `;
324
+ await fs.writeFile(path.join(root, "index.js"), source, "utf8");
325
+ }