@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
@@ -6,13 +6,14 @@ import { parse } from "yaml";
6
6
  import { FRAMEWORK_NAME } from "../../../framework";
7
7
  import { PLUGIN_DECLARATION_POLICIES } from "../../../lib/orchestrator/config-store";
8
8
  import type { PluginDeclaration } from "../../../lib/orchestrator/types";
9
+ import { normalizeBaseAudience } from "./audiences";
9
10
 
10
11
  function isRecord(value: unknown): value is Record<string, unknown> {
11
12
  return typeof value === "object" && value !== null && !Array.isArray(value);
12
13
  }
13
14
 
14
15
  /**
15
- * Raw read of the companion's `plugins:` list. Deliberately avoids
16
+ * Raw read of the companion's base `plugins:` list, from either shape. Deliberately avoids
16
17
  * `ConfigStore.load()` — hydration runs on every invocation and must never
17
18
  * create or migrate config files as a side effect.
18
19
  */
@@ -22,7 +23,7 @@ export async function readDeclarations(companionPath: string): Promise<unknown[]
22
23
  path.join(companionPath, `.${FRAMEWORK_NAME}`, "config", "framework.yaml"),
23
24
  "utf8",
24
25
  );
25
- const parsed = parse(raw) as unknown;
26
+ const parsed = normalizeBaseAudience(parse(raw) as unknown);
26
27
  if (!isRecord(parsed) || !Array.isArray(parsed.plugins)) return [];
27
28
  return parsed.plugins;
28
29
  } catch {
@@ -9,7 +9,7 @@ import type { Plugin } from "../plugin";
9
9
  import type { PluginRegistry } from "../registry";
10
10
  import {
11
11
  describeUnknownAudience,
12
- effectivePluginDeclarations,
12
+ effectivePluginDeclarationSources,
13
13
  readAudienceSelection,
14
14
  readFrameworkRaw,
15
15
  resolveAudience,
@@ -71,7 +71,8 @@ function activateDeclaredPlugin(plugin: Plugin): Plugin {
71
71
  /**
72
72
  * Registers the companion's effective plugins (base plus the active
73
73
  * `MATE_AUDIENCE`) into the active registry — compiled-in plugins first
74
- * (they are already registered), declared order after. Runs before cap-command detection on every invocation. No-op
74
+ * (they are already registered), declared order after. Each registration is
75
+ * marked with its source, so persisted passes can keep to base. Runs before cap-command detection on every invocation. No-op
75
76
  * without a companion or `plugins:` entries; all diagnostics go to stderr;
76
77
  * never throws.
77
78
  */
@@ -89,12 +90,12 @@ export async function hydrateDynamicPlugins(deps: HydrateDynamicPluginsDeps = {}
89
90
  if (!audience.ok) {
90
91
  warn(`${describeUnknownAudience(audience)}; applying the base configuration only`);
91
92
  }
92
- const entries = effectivePluginDeclarations(raw, audience.ok ? audience.active : null);
93
+ const entries = effectivePluginDeclarationSources(raw, audience.ok ? audience.active : null);
93
94
  if (entries.length === 0) return;
94
95
 
95
96
  const registry = deps.registry ?? getActiveDistribution().registry;
96
97
 
97
- for (const entry of entries) {
98
+ for (const { entry, audience: source } of entries) {
98
99
  const { declaration, error } = validateDeclaration(entry);
99
100
  if (!declaration) {
100
101
  if (error) warn(error);
@@ -122,6 +123,8 @@ export async function hydrateDynamicPlugins(deps: HydrateDynamicPluginsDeps = {}
122
123
  registry.register({
123
124
  plugin: activateDeclaredPlugin(result.plugin),
124
125
  policy: declaration.policy ?? "optional",
126
+ audience: source,
127
+ packageName: declaration.package,
125
128
  });
126
129
  hydratedPackages.add(declaration.package);
127
130
  }
@@ -4,6 +4,13 @@ import { FRAMEWORK_NAME } from "../../framework";
4
4
  import type { FrameworkConfig } from "../../lib/orchestrator/types";
5
5
  import type { RenderedRuntimeDocument } from "../../runtime/runtime-document-manifest";
6
6
  import { collectHostingProviders, ContextServiceMediator } from "./context-services";
7
+ import {
8
+ ledgerFromInputs,
9
+ ledgerTeardownInput,
10
+ readContributionLedger,
11
+ writeContributionLedger,
12
+ type ContributionLedger,
13
+ } from "./contribution-ledger";
7
14
  import type {
8
15
  CapabilityContributionInput,
9
16
  CapabilityPlugin,
@@ -15,6 +22,8 @@ import { reconcileClaudeContributions } from "./providers/claude";
15
22
  import { reconcileOpenCodeContributions } from "./providers/opencode";
16
23
  import { mergeCompanionOpenCodeConfig, renderDocumentsForTarget } from "./runtime-documents";
17
24
  import { normalizeRegistration, type NormalizedRegistration } from "./registry";
25
+ import { surfaceRoot } from "./surface-target";
26
+ import { removeForeignGuidanceBlocks, sweepAudienceDeclarations } from "./upgrade-sweep";
18
27
 
19
28
  export interface SetupInstallationPlanAction {
20
29
  phase: Plugin["kind"];
@@ -35,11 +44,23 @@ export interface SetupInstallationOutcome {
35
44
  warnings: string[];
36
45
  }
37
46
 
47
+ /**
48
+ * The registrations a pass that writes a persisted surface may reconcile:
49
+ * base only. An audience registration takes part in no such pass — neither
50
+ * its lifecycle hooks nor its declared contributions — whatever audience is
51
+ * active; its contributions reach sessions only through the launch layer.
52
+ */
53
+ export function persistedRegistrations(
54
+ registrations: PluginRegistration[],
55
+ ): NormalizedRegistration[] {
56
+ return registrations.map(normalizeRegistration).filter((entry) => entry.audience === null);
57
+ }
58
+
38
59
  export function buildSetupInstallationPlan(
39
60
  config: FrameworkConfig,
40
61
  registrations: PluginRegistration[],
41
62
  ): SetupInstallationPlan {
42
- const entries = registrations.map(normalizeRegistration);
63
+ const entries = persistedRegistrations(registrations);
43
64
  // Required plugins are always treated as enabled: saved selections cannot
44
65
  // deselect them and deselection never produces a teardown action.
45
66
  const isEntryEnabled = (entry: NormalizedRegistration) =>
@@ -109,7 +130,7 @@ export async function executeSetupInstallationPlan(
109
130
  registrations: PluginRegistration[],
110
131
  plan: SetupInstallationPlan,
111
132
  ): Promise<SetupInstallationOutcome> {
112
- const plugins = registrations.map((entry) => normalizeRegistration(entry).plugin);
133
+ const plugins = persistedRegistrations(registrations).map((entry) => entry.plugin);
113
134
  const pluginById = new Map(plugins.map((plugin) => [plugin.id, plugin]));
114
135
  const executedActions: SetupInstallationPlanAction[] = [];
115
136
  const skippedActions: SetupInstallationPlanAction[] = [];
@@ -176,13 +197,22 @@ export async function executeSetupInstallationPlan(
176
197
 
177
198
  await mediator.finalize();
178
199
 
179
- await reconcileCapabilityContributions(ctx, plugins, plan);
200
+ const reconcileWarnings = await reconcileCapabilityContributions(
201
+ ctx,
202
+ plugins,
203
+ plan,
204
+ registrations.map(normalizeRegistration),
205
+ );
206
+ for (const warning of reconcileWarnings) {
207
+ warnings.push(warning);
208
+ process.stderr.write(`${warning}\n`);
209
+ }
180
210
 
181
211
  return { executedActions, skippedActions, warnings };
182
212
  }
183
213
 
184
214
  // The Runtime Surface of each active Agent Runtime, by runtime id.
185
- const RUNTIME_SURFACE_RECONCILERS: Record<
215
+ export const RUNTIME_SURFACE_RECONCILERS: Record<
186
216
  string,
187
217
  (ctx: SetupContext, inputs: CapabilityContributionInput[]) => Promise<void>
188
218
  > = {
@@ -243,13 +273,104 @@ async function reconcileCapabilityContributions(
243
273
  ctx: SetupContext,
244
274
  plugins: Plugin[],
245
275
  plan: SetupInstallationPlan,
246
- ): Promise<void> {
276
+ registrations: NormalizedRegistration[],
277
+ ): Promise<string[]> {
247
278
  const inputsByRuntime = await collectContributionInputs(ctx, plugins, plan);
279
+ const ledgerRoot = surfaceRoot(ctx);
280
+ const ledger = await readContributionLedger(ledgerRoot);
281
+ const warnings: string[] = [];
282
+ let unswept = ledger?.unswept ?? [];
283
+ if (!ledger || unswept.length > 0) {
284
+ if (!ledger) {
285
+ await removeForeignGuidanceBlocks(ledgerRoot, new Set(plugins.map((plugin) => plugin.id)));
286
+ }
287
+ const sweep = await sweepAudienceDeclarations(ctx, {
288
+ registrations,
289
+ ...(ledger ? { packages: new Set(unswept) } : {}),
290
+ });
291
+ prependTeardown(inputsByRuntime, sweep.teardown);
292
+ unswept = sweep.unswept;
293
+ warnings.push(...sweep.warnings);
294
+ }
295
+ prependLedgerTeardown(inputsByRuntime, ledger);
248
296
  for (const [runtimeId, inputs] of inputsByRuntime) {
249
297
  const reconcile = RUNTIME_SURFACE_RECONCILERS[runtimeId];
250
298
  if (!reconcile || inputs.length === 0) continue;
251
299
  await reconcile(ctx, inputs);
252
300
  }
301
+ await writeContributionLedger(ledgerRoot, ledgerFromInputs(inputsByRuntime, unswept));
302
+ return warnings;
303
+ }
304
+
305
+ /**
306
+ * Disabled inputs placed first, skipping any owner that is an enabled input
307
+ * of the pass on that runtime: removal runs before enabled inputs apply.
308
+ */
309
+ function prependTeardown(
310
+ inputsByRuntime: Map<string, CapabilityContributionInput[]>,
311
+ teardownByRuntime: ReadonlyMap<string, readonly CapabilityContributionInput[]>,
312
+ ): void {
313
+ for (const [runtimeId, teardown] of teardownByRuntime) {
314
+ const inputs = inputsByRuntime.get(runtimeId) ?? [];
315
+ const enabled = new Set(inputs.filter((input) => input.enabled).map((input) => input.pluginId));
316
+ const kept = teardown.filter((input) => !enabled.has(input.pluginId));
317
+ if (kept.length > 0) inputsByRuntime.set(runtimeId, [...kept, ...inputs]);
318
+ }
319
+ }
320
+
321
+ /**
322
+ * Every recorded owner that is not an enabled input of this pass, as a
323
+ * disabled input placed first: its entries are removed before enabled inputs
324
+ * apply, so an identity another plugin still declares is re-applied after.
325
+ * Entries the ledger does not record are never named, so they survive.
326
+ */
327
+ function prependLedgerTeardown(
328
+ inputsByRuntime: Map<string, CapabilityContributionInput[]>,
329
+ ledger: ContributionLedger | null,
330
+ ): void {
331
+ prependTeardown(
332
+ inputsByRuntime,
333
+ new Map(
334
+ Object.entries(ledger?.runtimes ?? {}).map(([runtimeId, owners]) => [
335
+ runtimeId,
336
+ Object.entries(owners).map(([owner, identities]) => ledgerTeardownInput(owner, identities)),
337
+ ]),
338
+ ),
339
+ );
340
+ }
341
+
342
+ /**
343
+ * The declared contributions of one audience's plugins, as enabled inputs per
344
+ * active runtime. No plugin phase and no lifecycle hook runs: an audience
345
+ * plugin contributes declaratively only. OpenCode plugin references are
346
+ * dropped — the Mate plugin has no way to load another plugin.
347
+ */
348
+ export async function collectAudienceInputs(
349
+ ctx: SetupContext,
350
+ registrations: PluginRegistration[],
351
+ audience: string,
352
+ ): Promise<Map<string, CapabilityContributionInput[]>> {
353
+ const inputsByRuntime = new Map<string, CapabilityContributionInput[]>();
354
+ for (const entry of registrations.map(normalizeRegistration)) {
355
+ if (entry.audience !== audience || entry.plugin.kind !== "capability") continue;
356
+ const capability = entry.plugin as CapabilityPlugin;
357
+ if (!capability.getRuntimeContributions) continue;
358
+ if (entry.policy !== "required" && !capability.isEnabled(ctx.config)) continue;
359
+ const byRuntime = await capability.getRuntimeContributions(ctx);
360
+ for (const [runtimeId, contributions] of Object.entries(byRuntime)) {
361
+ if (!contributions || !ctx.activeProviders.includes(runtimeId)) continue;
362
+ const { pluginReferences: _dropped, ...declarative } = contributions;
363
+ const inputs = inputsByRuntime.get(runtimeId) ?? [];
364
+ inputs.push({
365
+ pluginId: capability.id,
366
+ enabled: true,
367
+ capabilityEnabled: true,
368
+ contributions: declarative,
369
+ });
370
+ inputsByRuntime.set(runtimeId, inputs);
371
+ }
372
+ }
373
+ return inputsByRuntime;
253
374
  }
254
375
 
255
376
  /** What a render returns: its warnings are data for the caller, never printed here. */
@@ -267,12 +388,22 @@ export async function renderRuntimeDocuments(
267
388
  ctx: SetupContext,
268
389
  registrations: PluginRegistration[],
269
390
  plan: SetupInstallationPlan,
391
+ wrapped?: { audience: string; registrations: PluginRegistration[] },
270
392
  ): Promise<RenderedRuntimeDocuments> {
271
- const plugins = registrations.map((entry) => normalizeRegistration(entry).plugin);
272
- const documents = renderDocumentsForTarget(
273
- ctx,
274
- await collectContributionInputs(ctx, plugins, plan),
275
- );
393
+ const plugins = persistedRegistrations(registrations).map((entry) => entry.plugin);
394
+ const inputsByRuntime = await collectContributionInputs(ctx, plugins, plan);
395
+ /** A checkout wrapped for an audience carries it beside base; no other audience ever. */
396
+ if (wrapped) {
397
+ const audienceInputs = await collectAudienceInputs(
398
+ ctx,
399
+ wrapped.registrations,
400
+ wrapped.audience,
401
+ );
402
+ for (const [runtimeId, inputs] of audienceInputs) {
403
+ inputsByRuntime.set(runtimeId, [...(inputsByRuntime.get(runtimeId) ?? []), ...inputs]);
404
+ }
405
+ }
406
+ const documents = renderDocumentsForTarget(ctx, inputsByRuntime);
276
407
  if (ctx.target !== "working") return { documents, warnings: [] };
277
408
  return mergeCompanionOpenCodeConfig(ctx.companionPath, documents);
278
409
  }
@@ -0,0 +1,139 @@
1
+ // oxlint-disable no-await-in-loop
2
+ import path from "node:path";
3
+
4
+ import type { FrameworkConfig } from "../../lib/orchestrator/types";
5
+ import {
6
+ readContributionLedger,
7
+ type ContributionLedger,
8
+ type LedgerIdentities,
9
+ } from "./contribution-ledger";
10
+ import type { CapabilityPlugin, SetupContext } from "./plugin";
11
+ import type { PluginRegistry } from "./registry";
12
+ import { managedGuidanceOwners } from "./upgrade-sweep";
13
+
14
+ /**
15
+ * What `mate doctor` reports about audience isolation: contributions on the
16
+ * companion's persisted surfaces that no base plugin owns, audience plugins
17
+ * the upgrade sweep could not load, and the active audience's plugins whose
18
+ * imperative parts a launch layer cannot deliver.
19
+ */
20
+
21
+ export interface LeakedContribution {
22
+ /** Companion-relative file or directory. */
23
+ surface: string;
24
+ kind: string;
25
+ /** The entry's name; absent where the surface entry is the owner's block. */
26
+ identity?: string;
27
+ owner: string;
28
+ }
29
+
30
+ export interface UndeliveredAudiencePlugin {
31
+ pluginId: string;
32
+ /** `apply`, `teardown`, `forProvider` or `pluginReferences`. */
33
+ parts: string[];
34
+ }
35
+
36
+ export interface AudienceIsolationReport {
37
+ leaked: LeakedContribution[];
38
+ unswept: string[];
39
+ undelivered: UndeliveredAudiencePlugin[];
40
+ }
41
+
42
+ /** Ledger kinds by where each runtime keeps them; guidance is read from the files themselves. */
43
+ const LEDGER_SURFACES: Record<string, Partial<Record<keyof LedgerIdentities, string>>> = {
44
+ claude: {
45
+ mcpServers: ".mcp.json",
46
+ skillTrees: path.join(".claude", "skills"),
47
+ agentDefinitions: path.join(".claude", "agents"),
48
+ hookMarkers: path.join(".claude", "settings.local.json"),
49
+ permissionEntries: path.join(".claude", "settings.local.json"),
50
+ },
51
+ opencode: {
52
+ mcpServers: path.join(".opencode", "opencode.json"),
53
+ pluginReferences: path.join(".opencode", "opencode.json"),
54
+ skillTrees: path.join(".agents", "skills"),
55
+ agentDefinitions: path.join(".opencode", "agents"),
56
+ },
57
+ };
58
+
59
+ function leakedLedgerContributions(
60
+ ledger: ContributionLedger | null,
61
+ baseIds: ReadonlySet<string>,
62
+ ): LeakedContribution[] {
63
+ const leaked: LeakedContribution[] = [];
64
+ for (const [runtimeId, owners] of Object.entries(ledger?.runtimes ?? {})) {
65
+ for (const [owner, identities] of Object.entries(owners)) {
66
+ if (baseIds.has(owner)) continue;
67
+ for (const [kind, surface] of Object.entries(LEDGER_SURFACES[runtimeId] ?? {})) {
68
+ for (const identity of identities[kind as keyof LedgerIdentities] ?? []) {
69
+ leaked.push({ surface: surface!, kind, identity, owner });
70
+ }
71
+ }
72
+ }
73
+ }
74
+ return leaked;
75
+ }
76
+
77
+ /**
78
+ * Whether a lifecycle hook does anything. `apply` and `teardown` are required
79
+ * members, so a declarative plugin carries empty ones; only a body counts.
80
+ */
81
+ function hasBody(hook: unknown): boolean {
82
+ if (typeof hook !== "function") return false;
83
+ const source = Function.prototype.toString.call(hook);
84
+ const open = source.indexOf("{");
85
+ const close = source.lastIndexOf("}");
86
+ if (open === -1 || close <= open) return true;
87
+ const body = source
88
+ .slice(open + 1, close)
89
+ .replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, "")
90
+ .trim();
91
+ return body.length > 0;
92
+ }
93
+
94
+ async function undeliveredParts(plugin: CapabilityPlugin, ctx: SetupContext): Promise<string[]> {
95
+ const parts: string[] = [];
96
+ if (hasBody(plugin.apply)) parts.push("apply");
97
+ if (hasBody(plugin.teardown)) parts.push("teardown");
98
+ if (plugin.forProvider && Object.keys(plugin.forProvider).length > 0) parts.push("forProvider");
99
+ try {
100
+ const byRuntime = (await plugin.getRuntimeContributions?.(ctx)) ?? {};
101
+ if (Object.values(byRuntime).some((runtime) => (runtime?.pluginReferences ?? []).length > 0)) {
102
+ parts.push("pluginReferences");
103
+ }
104
+ } catch {
105
+ /** A plugin whose declarations fail is the plugin checks' finding, not this one. */
106
+ }
107
+ return parts;
108
+ }
109
+
110
+ export async function inspectAudienceIsolation(
111
+ companionPath: string,
112
+ config: FrameworkConfig,
113
+ registry: PluginRegistry,
114
+ activeAudience: string | null,
115
+ ): Promise<AudienceIsolationReport> {
116
+ const baseIds = new Set(registry.baseRegistrations().map((entry) => entry.plugin.id));
117
+ const leaked: LeakedContribution[] = [];
118
+
119
+ for (const [surface, owners] of await managedGuidanceOwners(companionPath)) {
120
+ for (const owner of owners) {
121
+ if (!baseIds.has(owner)) leaked.push({ surface, kind: "guidance", owner });
122
+ }
123
+ }
124
+
125
+ const ledger = await readContributionLedger(companionPath);
126
+ leaked.push(...leakedLedgerContributions(ledger, baseIds));
127
+
128
+ const undelivered: UndeliveredAudiencePlugin[] = [];
129
+ if (activeAudience) {
130
+ const ctx: SetupContext = { companionPath, config, mode: "sync", activeProviders: [] };
131
+ for (const { plugin } of registry.audienceRegistrations(activeAudience)) {
132
+ if (plugin.kind !== "capability") continue;
133
+ const parts = await undeliveredParts(plugin as CapabilityPlugin, ctx);
134
+ if (parts.length > 0) undelivered.push({ pluginId: plugin.id, parts });
135
+ }
136
+ }
137
+
138
+ return { leaked, unswept: ledger?.unswept ?? [], undelivered };
139
+ }
@@ -7,9 +7,10 @@ export type SetupScope = "companion" | "hub";
7
7
  /**
8
8
  * Where a reconcile pass lands its documents. `companion` writes the material's
9
9
  * own repository; `working` renders the documents an Agent Runtime discovers by
10
- * its own upward walk and leaves the placing to the Projection Root.
10
+ * its own upward walk and leaves the placing to the Projection Root;
11
+ * `audience-layer` writes one audience's launch layer, outside every repository.
11
12
  */
12
- export type ProjectionTarget = "companion" | "working";
13
+ export type ProjectionTarget = "companion" | "working" | "audience-layer";
13
14
 
14
15
  export interface SetupContext {
15
16
  companionPath: string;
@@ -22,6 +23,14 @@ export interface SetupContext {
22
23
  repoPath?: string;
23
24
  /** Defaults to `companion`, so a caller that names no target is unchanged. */
24
25
  target?: ProjectionTarget;
26
+ /** The audience an `audience-layer` pass renders; required by that target only. */
27
+ audience?: string;
28
+ /**
29
+ * Where an `audience-layer` pass writes: the staging directory a render is
30
+ * built in before it is renamed into place. The audience's layer directory
31
+ * when absent.
32
+ */
33
+ layerRoot?: string;
25
34
  /**
26
35
  * Provider-mediated context services. Always populated when the engine
27
36
  * executes a plugin; optional so hand-built contexts (tests, direct calls)
@@ -122,9 +131,15 @@ export type PluginPolicy = "required" | "default" | "optional";
122
131
 
123
132
  /**
124
133
  * A plugin registration entry. Bare plugins derive their policy from
125
- * `defaultSelected` (`true` → "default", `false` → "optional").
134
+ * `defaultSelected` (`true` → "default", `false` → "optional"). `audience` is
135
+ * the source of a declared plugin: `null` (or absent) for compiled-in plugins
136
+ * and top-level `plugins` declarations, the audience name for a declaration
137
+ * under `audiences.<name>.plugins`. `packageName` names the declared package a
138
+ * dynamic plugin was loaded from.
126
139
  */
127
- export type PluginRegistration = Plugin | { plugin: Plugin; policy: PluginPolicy };
140
+ export type PluginRegistration =
141
+ | Plugin
142
+ | { plugin: Plugin; policy: PluginPolicy; audience?: string | null; packageName?: string };
128
143
 
129
144
  /**
130
145
  * A hook group a Capability contributes to a runtime's settings. `marker` is
@@ -97,7 +97,13 @@ export function createGitignorePlugin(
97
97
  ): Plugin {
98
98
  // Resolve lazily so plugin sets can be built before a distribution is active.
99
99
  const resolveName = typeof frameworkName === "function" ? frameworkName : () => frameworkName;
100
- const resolvePlugins = allPlugins ?? (() => getActiveDistribution().registry.getAll());
100
+ /** Base only: an audience plugin's entries would reach the committed `.gitignore`. */
101
+ const resolvePlugins =
102
+ allPlugins ??
103
+ (() =>
104
+ getActiveDistribution()
105
+ .registry.baseRegistrations()
106
+ .map((entry) => entry.plugin));
101
107
  return {
102
108
  id: "gitignore",
103
109
  kind: "root",
@@ -11,7 +11,7 @@ import {
11
11
  upsertManagedBlock,
12
12
  } from "../context-services";
13
13
  import type { CapabilityContributionInput, ProviderPlugin, SetupContext } from "../plugin";
14
- import { surfaceRoot } from "../surface-target";
14
+ import { projectionTarget, surfaceRoot } from "../surface-target";
15
15
  import { mergeDir, pruneEmptyAncestors } from "../utils";
16
16
  import { stripSectionFromFile, type RemoveHeadingSectionOptions } from "./agent-file-sections";
17
17
  import { patchSkillTreeMarkdownFiles } from "./skill-tree";
@@ -164,6 +164,23 @@ export function renderManagedClaudeHookGroups(
164
164
  return hooks;
165
165
  }
166
166
 
167
+ /**
168
+ * A layer's Claude settings: the enabled inputs' hook groups and permission
169
+ * entries, nothing of Mate's own. The launch merges them over its base settings.
170
+ */
171
+ export function renderLayerClaudeSettings(
172
+ contributions: CapabilityContributionInput[] = [],
173
+ ): ClaudeSettings {
174
+ const hooks = renderManagedClaudeHookGroups(contributions);
175
+ const allow = contributions
176
+ .filter((input) => input.enabled)
177
+ .flatMap((input) => input.contributions.permissionEntries ?? []);
178
+ return {
179
+ ...(Object.keys(hooks).length > 0 ? { hooks } : {}),
180
+ ...(allow.length > 0 ? { permissions: { allow } } : {}),
181
+ };
182
+ }
183
+
167
184
  // Reconcile the Mate-managed Claude settings shape (hooks, permissions, MCP
168
185
  // servers) on top of whatever already exists in the settings document. Managed
169
186
  // groups/entries always lead their arrays so the emitted shape stays stable
@@ -287,7 +304,15 @@ export async function reconcileClaudeContributions(
287
304
  // The settings sync (re)creates the managed settings document, so it only
288
305
  // runs while the Claude runtime is active; a deactivated runtime's pass is
289
306
  // teardown-only and must not resurrect files the provider teardown removed.
290
- if (ctx.activeProviders.includes("claude")) {
307
+ // A layer carries its plugins' hooks and permission entries only, for the
308
+ // launch to merge into its one `--settings` file.
309
+ if (projectionTarget(ctx) === "audience-layer") {
310
+ await writeClaudeSettings(
311
+ getCompanionClaudeSettingsPath(root),
312
+ renderLayerClaudeSettings(inputs),
313
+ );
314
+ await syncCompanionClaudeMcpConfig(root);
315
+ } else if (ctx.activeProviders.includes("claude")) {
291
316
  await syncCompanionClaudeSettings(root, config, inputs);
292
317
  }
293
318
 
@@ -416,6 +416,16 @@ export async function removeOpenCodeForeignPluginReferences(
416
416
 
417
417
  const OPENCODE_CONTRIBUTION_CONFIG_FILES = ["opencode.json"];
418
418
 
419
+ /**
420
+ * The surface's reader for an audience layer it wrote: what the Mate OpenCode
421
+ * plugin applies. Defined under `runtime/` so the import-isolated plugin can
422
+ * reach it; this surface owns the format it reads.
423
+ */
424
+ export {
425
+ readOpenCodeAudienceLayer,
426
+ type OpenCodeLayerContents,
427
+ } from "../../../runtime/opencode-audience-layer";
428
+
419
429
  export async function reconcileOpenCodeContributions(
420
430
  ctx: SetupContext,
421
431
  inputs: CapabilityContributionInput[],
@@ -3,13 +3,22 @@ import type { Plugin, PluginPolicy, PluginRegistration } from "./plugin";
3
3
  export interface NormalizedRegistration {
4
4
  plugin: Plugin;
5
5
  policy: PluginPolicy;
6
+ /** The audience that declared the plugin; `null` for base. */
7
+ audience: string | null;
8
+ /** The declared package a dynamic plugin was loaded from; absent for compiled-in plugins. */
9
+ packageName?: string;
6
10
  }
7
11
 
8
12
  export function normalizeRegistration(entry: PluginRegistration): NormalizedRegistration {
9
13
  if ("plugin" in entry && "policy" in entry) {
10
- return { plugin: entry.plugin, policy: entry.policy };
14
+ return {
15
+ plugin: entry.plugin,
16
+ policy: entry.policy,
17
+ audience: entry.audience ?? null,
18
+ ...(entry.packageName ? { packageName: entry.packageName } : {}),
19
+ };
11
20
  }
12
- return { plugin: entry, policy: entry.defaultSelected ? "default" : "optional" };
21
+ return { plugin: entry, policy: entry.defaultSelected ? "default" : "optional", audience: null };
13
22
  }
14
23
 
15
24
  export class PluginRegistry {
@@ -32,6 +41,19 @@ export class PluginRegistry {
32
41
  return this.entries.map((entry) => ({ ...entry }));
33
42
  }
34
43
 
44
+ /**
45
+ * Compiled-in plugins and top-level declarations: the only registrations a
46
+ * pass that writes a persisted surface may reconcile.
47
+ */
48
+ baseRegistrations(): NormalizedRegistration[] {
49
+ return this.getEntries().filter((entry) => entry.audience === null);
50
+ }
51
+
52
+ /** The registrations one audience declared; only its launch layer renders them. */
53
+ audienceRegistrations(audience: string): NormalizedRegistration[] {
54
+ return this.getEntries().filter((entry) => entry.audience === audience);
55
+ }
56
+
35
57
  getPolicy(pluginId: string): PluginPolicy | undefined {
36
58
  return this.entries.find((entry) => entry.plugin.id === pluginId)?.policy;
37
59
  }
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
2
 
3
+ import { audienceLayerDir } from "../../runtime/audience-layer";
3
4
  import type { ProjectionTarget, SetupContext } from "./plugin";
4
5
 
5
6
  /**
@@ -14,6 +15,9 @@ export function projectionTarget(ctx: SetupContext): ProjectionTarget {
14
15
  "a working-target reconcile needs a Working Repository in scope; none was supplied",
15
16
  );
16
17
  }
18
+ if (target === "audience-layer" && !ctx.audience) {
19
+ throw new Error("an audience-layer reconcile needs an audience; none was supplied");
20
+ }
17
21
  return target;
18
22
  }
19
23
 
@@ -38,13 +42,18 @@ export function projectionRepoRoot(ctx: SetupContext): string {
38
42
  }
39
43
 
40
44
  /**
41
- * The root a Runtime Surface writes into. Defined for the companion target
42
- * only: a working-target pass renders its documents for the Projection Root to
43
- * place, so no Runtime Surface ever computes a path inside a Working Repository.
45
+ * The root a Runtime Surface writes into: the companion, or one audience's
46
+ * layer outside every repository. Never a Working Repository: a working-target
47
+ * pass renders its documents for the Projection Root to place, so no Runtime
48
+ * Surface ever computes a path inside one.
44
49
  */
45
50
  export function surfaceRoot(ctx: SetupContext): string {
46
- if (projectionTarget(ctx) === "working") {
51
+ const target = projectionTarget(ctx);
52
+ if (target === "working") {
47
53
  throw new Error("a working-target reconcile renders its documents; it writes no surface root");
48
54
  }
55
+ if (target === "audience-layer") {
56
+ return ctx.layerRoot ?? audienceLayerDir(ctx.companionPath, ctx.audience!);
57
+ }
49
58
  return ctx.companionPath;
50
59
  }