@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.
- package/opencode-plugin/src/add-dir.ts +10 -76
- package/opencode-plugin/src/audience-layer.ts +91 -0
- package/opencode-plugin/src/companion.ts +28 -11
- package/opencode-plugin/src/launch-environment.ts +29 -0
- package/opencode-plugin/src/server.ts +25 -8
- package/package.json +1 -1
- package/src/cli/commands/cap/index-cmd.ts +2 -11
- package/src/cli/commands/doctor.ts +86 -4
- package/src/cli/commands/install.ts +8 -0
- package/src/cli/commands/studio/terminal-launch.ts +16 -7
- package/src/cli/commands/wrap.ts +29 -4
- package/src/cli/usage.ts +2 -1
- package/src/hooks/session-guidance.ts +20 -2
- package/src/lib/orchestrator/adapters/base.ts +3 -2
- package/src/lib/orchestrator/adapters/claude.ts +130 -20
- package/src/lib/orchestrator/adapters/opencode.ts +55 -15
- package/src/lib/orchestrator/config-store.ts +74 -1
- package/src/lib/orchestrator/openspec-store.ts +202 -0
- package/src/lib/orchestrator/projection-claude-skills.ts +88 -35
- package/src/lib/orchestrator/projection-entries.ts +46 -0
- package/src/lib/orchestrator/projection-openspec-store.ts +164 -0
- package/src/lib/orchestrator/projection-types.ts +9 -1
- package/src/lib/orchestrator/session-prepare.ts +82 -4
- package/src/lib/orchestrator/working-repo-projection.ts +2 -1
- package/src/lib/orchestrator/wrap-pass.ts +92 -1
- package/src/opencode/companion-hooks.ts +7 -4
- package/src/opencode/projected-guidance.ts +6 -13
- package/src/runtime/audience-layer.ts +84 -0
- package/src/runtime/guidance.ts +17 -0
- package/src/runtime/opencode-audience-layer.ts +176 -0
- package/src/runtime/policy.ts +14 -1
- package/src/runtime/projection.ts +1 -1
- package/src/runtime/runtime-document-manifest.ts +17 -0
- package/src/runtime/wrap-state.ts +4 -0
- package/src/tools/setup/audience-isolation.fixture.ts +325 -0
- package/src/tools/setup/audience-layer.ts +207 -0
- package/src/tools/setup/audience-registrations.ts +52 -0
- package/src/tools/setup/capabilities/openspec.ts +9 -0
- package/src/tools/setup/capabilities/tokensave.ts +11 -0
- package/src/tools/setup/contribution-ledger.ts +207 -0
- package/src/tools/setup/dynamic-plugins/audiences.ts +89 -9
- package/src/tools/setup/dynamic-plugins/declarations.ts +3 -2
- package/src/tools/setup/dynamic-plugins/hydrate.ts +7 -4
- package/src/tools/setup/engine.ts +141 -10
- package/src/tools/setup/isolation-diagnostics.ts +139 -0
- package/src/tools/setup/plugin.ts +19 -4
- package/src/tools/setup/plugins/gitignore.ts +7 -1
- package/src/tools/setup/providers/claude.ts +27 -2
- package/src/tools/setup/providers/opencode.ts +10 -0
- package/src/tools/setup/registry.ts +24 -2
- package/src/tools/setup/surface-target.ts +13 -4
- package/src/tools/setup/upgrade-sweep.ts +153 -0
- package/src/tools/setup/working-repo-cleanup.ts +2 -0
- package/src/tools/setup/working-repo-local-state.ts +9 -1
- package/src/tools/setup.ts +9 -6
- 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
|
-
|
|
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.
|
|
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 =
|
|
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
|
|
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) =>
|
|
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(
|
|
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
|
-
|
|
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) =>
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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
|
-
|
|
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
|
}
|