@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,207 @@
1
+ // oxlint-disable no-await-in-loop
2
+ import crypto from "node:crypto";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+
6
+ import { getActiveDistribution } from "../../distribution";
7
+ import type { FrameworkConfig } from "../../lib/orchestrator/types";
8
+ import {
9
+ audienceLayerDir,
10
+ audienceLayersRoot,
11
+ CURRENT_LAYER_LINK,
12
+ } from "../../runtime/audience-layer";
13
+ import {
14
+ dynamicPluginsWorkspaceRoot,
15
+ pluginLocalOverridesPath,
16
+ pluginPackageRoot,
17
+ } from "./dynamic-plugins/paths";
18
+ import { collectAudienceInputs, RUNTIME_SURFACE_RECONCILERS } from "./engine";
19
+ import type { PluginRegistration, SetupContext } from "./plugin";
20
+ import { projectionTarget } from "./surface-target";
21
+
22
+ /**
23
+ * Writes the active audience's layer through each runtime's Runtime Surface,
24
+ * under the pass's layer root only. Refuses any other target, and a pass that
25
+ * names no audience.
26
+ */
27
+ export async function reconcileAudienceLayer(
28
+ ctx: SetupContext,
29
+ registrations: PluginRegistration[],
30
+ ): Promise<void> {
31
+ if (projectionTarget(ctx) !== "audience-layer") {
32
+ throw new Error("an audience layer is reconciled with the audience-layer target only");
33
+ }
34
+ for (const [runtimeId, inputs] of await collectAudienceInputs(
35
+ ctx,
36
+ registrations,
37
+ ctx.audience!,
38
+ )) {
39
+ const reconcile = RUNTIME_SURFACE_RECONCILERS[runtimeId];
40
+ if (!reconcile || inputs.length === 0) continue;
41
+ await reconcile(ctx, inputs);
42
+ }
43
+ }
44
+
45
+ /** Bumped when the layout a layer pass writes changes, so old layers are re-rendered. */
46
+ const LAYER_FORMAT = 1;
47
+
48
+ /** A render that never finished, or a superseded layer, is left this long for sessions still reading it. */
49
+ const STALE_LAYER_MS = 24 * 60 * 60 * 1000;
50
+
51
+ const FINGERPRINT_FILE = ".fingerprint";
52
+
53
+ export interface AudienceLayerRequest {
54
+ companionPath: string;
55
+ config: FrameworkConfig;
56
+ audience: string;
57
+ /** The Agent Runtimes the layer is rendered for. */
58
+ activeProviders: string[];
59
+ /** The process's registrations; only the audience's own are rendered. */
60
+ registrations: PluginRegistration[];
61
+ homeDir?: string;
62
+ }
63
+
64
+ export interface RenderedAudienceLayer {
65
+ audience: string;
66
+ /** The complete layer a launch attaches; never changed once in place. */
67
+ root: string;
68
+ /** False when an unchanged layer was reused. */
69
+ rendered: boolean;
70
+ }
71
+
72
+ function isRecord(value: unknown): value is Record<string, unknown> {
73
+ return typeof value === "object" && value !== null && !Array.isArray(value);
74
+ }
75
+
76
+ /** The installed version of each package, from the committed lockfile, else the installed manifest. */
77
+ async function lockedVersions(
78
+ companionPath: string,
79
+ packages: string[],
80
+ ): Promise<Record<string, string | null>> {
81
+ const lock = await fs
82
+ .readFile(path.join(dynamicPluginsWorkspaceRoot(companionPath), "package-lock.json"), "utf8")
83
+ .then((text) => JSON.parse(text) as unknown)
84
+ .catch(() => null);
85
+ const lockPackages = isRecord(lock) && isRecord(lock.packages) ? lock.packages : {};
86
+ const versions: Record<string, string | null> = {};
87
+ for (const name of packages.toSorted()) {
88
+ const locked = lockPackages[`node_modules/${name}`];
89
+ if (isRecord(locked) && typeof locked.version === "string") {
90
+ versions[name] = locked.version;
91
+ continue;
92
+ }
93
+ const manifest = await fs
94
+ .readFile(path.join(pluginPackageRoot(companionPath, name), "package.json"), "utf8")
95
+ .then((text) => JSON.parse(text) as unknown)
96
+ .catch(() => null);
97
+ versions[name] =
98
+ isRecord(manifest) && typeof manifest.version === "string" ? manifest.version : null;
99
+ }
100
+ return versions;
101
+ }
102
+
103
+ /**
104
+ * What a layer depends on: the audience's declaration, the installed versions
105
+ * of its packages, the local plugin overrides, the Mate version and the active
106
+ * runtimes. Anything else changing leaves the layer as it is.
107
+ */
108
+ export async function audienceLayerFingerprint(request: AudienceLayerRequest): Promise<string> {
109
+ const entry = request.config.audiences?.[request.audience] ?? null;
110
+ const packages = (entry?.plugins ?? []).map((declaration) => declaration.package);
111
+ const overrides = await fs
112
+ .readFile(pluginLocalOverridesPath(request.companionPath), "utf8")
113
+ .catch(() => null);
114
+ const inputs = {
115
+ format: LAYER_FORMAT,
116
+ audience: request.audience,
117
+ entry,
118
+ versions: await lockedVersions(request.companionPath, packages),
119
+ overrides,
120
+ mate: getActiveDistribution().config.version,
121
+ runtimes: [...new Set(request.activeProviders)].toSorted(),
122
+ };
123
+ return crypto.createHash("sha256").update(JSON.stringify(inputs)).digest("hex").slice(0, 24);
124
+ }
125
+
126
+ async function exists(target: string): Promise<boolean> {
127
+ return fs
128
+ .access(target)
129
+ .then(() => true)
130
+ .catch(() => false);
131
+ }
132
+
133
+ /** Points `current` at `fingerprint` with one rename, so a reader never finds it missing. */
134
+ async function pointCurrentAt(dir: string, fingerprint: string): Promise<void> {
135
+ const link = path.join(dir, CURRENT_LAYER_LINK);
136
+ if ((await fs.readlink(link).catch(() => null)) === fingerprint) return;
137
+ const staged = path.join(dir, `.${CURRENT_LAYER_LINK}-${crypto.randomUUID()}`);
138
+ await fs.symlink(fingerprint, staged);
139
+ await fs.rename(staged, link);
140
+ }
141
+
142
+ /** Superseded layers and abandoned renders, once no session can still be starting from them. */
143
+ async function pruneStaleLayers(dir: string, keep: string): Promise<void> {
144
+ const now = Date.now();
145
+ for (const name of await fs.readdir(dir).catch(() => [])) {
146
+ /** Launch settings files live here too; each launch rewrites its own. */
147
+ if (name === keep || name === CURRENT_LAYER_LINK || name.startsWith("launch-")) continue;
148
+ const target = path.join(dir, name);
149
+ const stat = await fs.lstat(target).catch(() => null);
150
+ if (!stat || now - stat.mtimeMs < STALE_LAYER_MS) continue;
151
+ await fs.rm(target, { recursive: true, force: true });
152
+ }
153
+ }
154
+
155
+ /** Creates the layers root and every level below it for the current user only. */
156
+ async function ensurePrivateDir(root: string, dir: string): Promise<void> {
157
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
158
+ for (let level = dir; level.startsWith(root); level = path.dirname(level)) {
159
+ await fs.chmod(level, 0o700);
160
+ if (level === root) break;
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Renders the audience's layer, or reuses it when nothing it depends on
166
+ * changed. A render is built in a private staging directory and renamed into a
167
+ * directory named by its fingerprint, so a reader sees a complete layer or
168
+ * none; concurrent renders of the same inputs agree on one result.
169
+ */
170
+ export async function renderAudienceLayer(
171
+ request: AudienceLayerRequest,
172
+ ): Promise<RenderedAudienceLayer> {
173
+ const dir = audienceLayerDir(request.companionPath, request.audience, request.homeDir);
174
+ const fingerprint = await audienceLayerFingerprint(request);
175
+ const root = path.join(dir, fingerprint);
176
+
177
+ await ensurePrivateDir(audienceLayersRoot(request.homeDir), dir);
178
+ if (await exists(path.join(root, FINGERPRINT_FILE))) {
179
+ await pointCurrentAt(dir, fingerprint);
180
+ return { audience: request.audience, root, rendered: false };
181
+ }
182
+
183
+ const staging = await fs.mkdtemp(path.join(dir, ".render-"));
184
+ try {
185
+ await reconcileAudienceLayer(
186
+ {
187
+ companionPath: request.companionPath,
188
+ config: request.config,
189
+ mode: "sync",
190
+ activeProviders: request.activeProviders,
191
+ target: "audience-layer",
192
+ audience: request.audience,
193
+ layerRoot: staging,
194
+ },
195
+ request.registrations,
196
+ );
197
+ await fs.writeFile(path.join(staging, FINGERPRINT_FILE), `${fingerprint}\n`, "utf8");
198
+ await fs.rename(staging, root);
199
+ } catch (error) {
200
+ await fs.rm(staging, { recursive: true, force: true });
201
+ /** Another launch renamed the same inputs into place first: its layer is this one. */
202
+ if (!(await exists(path.join(root, FINGERPRINT_FILE)))) throw error;
203
+ }
204
+ await pointCurrentAt(dir, fingerprint);
205
+ await pruneStaleLayers(dir, fingerprint);
206
+ return { audience: request.audience, root, rendered: true };
207
+ }
@@ -0,0 +1,52 @@
1
+ // oxlint-disable no-await-in-loop
2
+ import { getActiveDistribution } from "../../distribution";
3
+ import type { FrameworkConfig } from "../../lib/orchestrator/types";
4
+ import { validateDeclaration } from "./dynamic-plugins/declarations";
5
+ import { loadDynamicPlugin, type DynamicPluginLoadDeps } from "./dynamic-plugins/loader";
6
+ import type { PluginRegistry, NormalizedRegistration } from "./registry";
7
+
8
+ export interface LoadedAudience {
9
+ registrations: NormalizedRegistration[];
10
+ warnings: string[];
11
+ }
12
+
13
+ /**
14
+ * The registrations of one named audience, independent of `MATE_AUDIENCE`: a
15
+ * plugin this process already hydrated is reused, any other is loaded for its
16
+ * declarations only — never activated, no lifecycle hook run. Used where the
17
+ * audience comes from a checkout's wrap rather than the environment.
18
+ */
19
+ export async function loadAudienceRegistrations(
20
+ companionPath: string,
21
+ config: FrameworkConfig,
22
+ audience: string,
23
+ registry: PluginRegistry = getActiveDistribution().registry,
24
+ deps: DynamicPluginLoadDeps = {},
25
+ ): Promise<LoadedAudience> {
26
+ const loaded: LoadedAudience = { registrations: [], warnings: [] };
27
+ const hydrated = registry.getEntries();
28
+ for (const entry of config.audiences?.[audience]?.plugins ?? []) {
29
+ const { declaration, error } = validateDeclaration(entry);
30
+ if (!declaration) {
31
+ if (error) loaded.warnings.push(error);
32
+ continue;
33
+ }
34
+ const known = hydrated.find((candidate) => candidate.packageName === declaration.package);
35
+ if (known) {
36
+ loaded.registrations.push({ ...known, audience });
37
+ continue;
38
+ }
39
+ const result = await loadDynamicPlugin(companionPath, declaration, deps);
40
+ if (!result.ok) {
41
+ loaded.warnings.push(`audience "${audience}": ${result.warning}`);
42
+ continue;
43
+ }
44
+ loaded.registrations.push({
45
+ plugin: result.plugin,
46
+ policy: "required",
47
+ audience,
48
+ packageName: declaration.package,
49
+ });
50
+ }
51
+ return loaded;
52
+ }
@@ -66,6 +66,13 @@ const OPENSPEC_TOOLS_BY_RUNTIME = {
66
66
 
67
67
  const OPENSPEC_INSTALL_COMMAND = "npm install -g @fission-ai/openspec@latest";
68
68
 
69
+ /**
70
+ * Store metadata `openspec store register` writes beside the companion. Its id
71
+ * is machine-local, and companion Git sync would otherwise stash it on every
72
+ * launch. Persisted: the registration outlives the capability.
73
+ */
74
+ const OPENSPEC_GITIGNORE_ENTRIES = [".openspec-store/"];
75
+
69
76
  type OpenSpecTool =
70
77
  (typeof OPENSPEC_TOOLS_BY_RUNTIME)[keyof typeof OPENSPEC_TOOLS_BY_RUNTIME][number];
71
78
  type OpenSpecRunCommand = typeof runCommandSilently;
@@ -394,6 +401,8 @@ export function createOpenspecPlugin(deps: OpenSpecPluginDeps = {}): CapabilityP
394
401
  },
395
402
  };
396
403
  },
404
+ gitignoreEntries: () => OPENSPEC_GITIGNORE_ENTRIES,
405
+ persistGitignoreEntries: true,
397
406
  getInstallRequirements: (): InstallRequirement[] => [
398
407
  {
399
408
  id: "capability:openspec",
@@ -14,6 +14,8 @@ export { TOKENSAVE_WORKING_REPO_EXCLUDE_ENTRIES } from "./tokensave-shared";
14
14
  export const TOKENSAVE_SUPPORTED_AGENTS = new Set(["claude", "opencode"]);
15
15
  export const TOKENSAVE_STORE_DIR = ".tokensave";
16
16
  export const TOKENSAVE_STORE_CONFIG_FILE = "config.json";
17
+ /** The index itself: `tokensave sync` refuses a store without it. */
18
+ const TOKENSAVE_STORE_DATABASE_FILE = "tokensave.db";
17
19
  export const TOKENSAVE_MIN_RUST_VERSION = "1.91.0";
18
20
  /** Single-graph posture Mate owns in the per-project store config; every other key is left as found. */
19
21
  const TOKENSAVE_OWNED_POSTURE = { auto_track: false, suppress_scope_warning: true } as const;
@@ -357,6 +359,15 @@ async function deactivateTokensaveBranchMetadata(repoPath: string): Promise<void
357
359
  }
358
360
  }
359
361
 
362
+ /**
363
+ * Whether the working repo holds a TokenSave index `tokensave sync` accepts.
364
+ * The database, not the store folder: an empty or half-initialized
365
+ * `.tokensave/` must still get `tokensave init`, or every pass fails on sync.
366
+ */
367
+ export async function isTokensaveIndexed(repoPath: string): Promise<boolean> {
368
+ return fileExists(path.join(repoPath, TOKENSAVE_STORE_DIR, TOKENSAVE_STORE_DATABASE_FILE));
369
+ }
370
+
360
371
  /**
361
372
  * Reconciles Mate's single-graph branching posture into the working repo's
362
373
  * git-excluded `.tokensave/config.json` and deactivates existing branch
@@ -0,0 +1,207 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import { FRAMEWORK_NAME } from "../../framework";
5
+ import { writeAtomic } from "../../runtime/write-atomic";
6
+ import { instructionBlockKey } from "./context-services";
7
+ import type { CapabilityContributionInput, RuntimeContributions } from "./plugin";
8
+
9
+ /**
10
+ * Contribution Ledger: which plugin owns each contribution a persisted pass
11
+ * applied to the companion, per Agent Runtime. Committed with the companion,
12
+ * because it describes committed files: a clone that never loaded a plugin
13
+ * must still be able to remove what another clone committed. It records
14
+ * identities only (names, block keys, markers, entries), never a value, so it
15
+ * carries no secret.
16
+ */
17
+
18
+ export const CONTRIBUTION_LEDGER_VERSION = 1;
19
+
20
+ /** The identities one owner applied on one runtime. Absent kinds are none. */
21
+ export interface LedgerIdentities {
22
+ mcpServers?: string[];
23
+ guidanceKeys?: string[];
24
+ skillTrees?: string[];
25
+ agentDefinitions?: string[];
26
+ hookMarkers?: string[];
27
+ permissionEntries?: string[];
28
+ pluginReferences?: string[];
29
+ }
30
+
31
+ export interface ContributionLedger {
32
+ version: number;
33
+ /** Runtime id → owning plugin id → identities. */
34
+ runtimes: Record<string, Record<string, LedgerIdentities>>;
35
+ /** Audience plugin packages the upgrade sweep could not load; retried by every pass. */
36
+ unswept?: string[];
37
+ }
38
+
39
+ export function contributionLedgerPath(companionPath: string): string {
40
+ return path.join(companionPath, `.${FRAMEWORK_NAME}`, "config", "contribution-ledger.json");
41
+ }
42
+
43
+ function isRecord(value: unknown): value is Record<string, unknown> {
44
+ return typeof value === "object" && value !== null && !Array.isArray(value);
45
+ }
46
+
47
+ function stringList(value: unknown): string[] {
48
+ return Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
49
+ }
50
+
51
+ const IDENTITY_KINDS = [
52
+ "agentDefinitions",
53
+ "guidanceKeys",
54
+ "hookMarkers",
55
+ "mcpServers",
56
+ "permissionEntries",
57
+ "pluginReferences",
58
+ "skillTrees",
59
+ ] as const satisfies readonly (keyof LedgerIdentities)[];
60
+
61
+ function sortedUnique(values: Iterable<string>): string[] {
62
+ return [...new Set(values)].toSorted();
63
+ }
64
+
65
+ /** Sorted kinds and values, empty kinds dropped; `null` when nothing is left. */
66
+ function normalizeIdentities(identities: LedgerIdentities): LedgerIdentities | null {
67
+ const normalized: LedgerIdentities = {};
68
+ for (const kind of IDENTITY_KINDS) {
69
+ const values = sortedUnique(identities[kind] ?? []);
70
+ if (values.length > 0) normalized[kind] = values;
71
+ }
72
+ return Object.keys(normalized).length > 0 ? normalized : null;
73
+ }
74
+
75
+ /** Sorted runtimes and owners; empty owners and runtimes dropped. */
76
+ function normalizeLedger(ledger: ContributionLedger): ContributionLedger {
77
+ const runtimes: ContributionLedger["runtimes"] = {};
78
+ for (const runtimeId of Object.keys(ledger.runtimes).toSorted()) {
79
+ const owners: Record<string, LedgerIdentities> = {};
80
+ for (const owner of Object.keys(ledger.runtimes[runtimeId] ?? {}).toSorted()) {
81
+ const identities = normalizeIdentities(ledger.runtimes[runtimeId]![owner]!);
82
+ if (identities) owners[owner] = identities;
83
+ }
84
+ if (Object.keys(owners).length > 0) runtimes[runtimeId] = owners;
85
+ }
86
+ const unswept = sortedUnique(ledger.unswept ?? []);
87
+ return {
88
+ version: CONTRIBUTION_LEDGER_VERSION,
89
+ runtimes,
90
+ ...(unswept.length > 0 ? { unswept } : {}),
91
+ };
92
+ }
93
+
94
+ /** The companion's ledger; `null` when absent or unreadable, which a pass treats as never recorded. */
95
+ export async function readContributionLedger(
96
+ companionPath: string,
97
+ ): Promise<ContributionLedger | null> {
98
+ let parsed: unknown;
99
+ try {
100
+ parsed = JSON.parse(await fs.readFile(contributionLedgerPath(companionPath), "utf8"));
101
+ } catch {
102
+ return null;
103
+ }
104
+ if (!isRecord(parsed) || !isRecord(parsed.runtimes)) return null;
105
+ const runtimes: ContributionLedger["runtimes"] = {};
106
+ for (const [runtimeId, owners] of Object.entries(parsed.runtimes)) {
107
+ if (!isRecord(owners)) continue;
108
+ runtimes[runtimeId] = {};
109
+ for (const [owner, identities] of Object.entries(owners)) {
110
+ if (!isRecord(identities)) continue;
111
+ const read: LedgerIdentities = {};
112
+ for (const kind of IDENTITY_KINDS) read[kind] = stringList(identities[kind]);
113
+ runtimes[runtimeId][owner] = read;
114
+ }
115
+ }
116
+ return normalizeLedger({
117
+ version: CONTRIBUTION_LEDGER_VERSION,
118
+ runtimes,
119
+ unswept: stringList(parsed.unswept),
120
+ });
121
+ }
122
+
123
+ /** Writes the ledger in its stable form; an unchanged ledger leaves the file untouched. */
124
+ export async function writeContributionLedger(
125
+ companionPath: string,
126
+ ledger: ContributionLedger,
127
+ ): Promise<void> {
128
+ const target = contributionLedgerPath(companionPath);
129
+ const text = `${JSON.stringify(normalizeLedger(ledger), null, 2)}\n`;
130
+ const existing = await fs.readFile(target, "utf8").catch(() => null);
131
+ if (existing === text) return;
132
+ await fs.mkdir(path.dirname(target), { recursive: true });
133
+ await writeAtomic(target, text);
134
+ }
135
+
136
+ /** The identities a contribution set applies for `pluginId`. */
137
+ export function identitiesOf(
138
+ pluginId: string,
139
+ contributions: RuntimeContributions,
140
+ ): LedgerIdentities {
141
+ return {
142
+ mcpServers: (contributions.mcpServers ?? []).map((server) => server.name),
143
+ guidanceKeys: (contributions.guidanceSections ?? []).map((section) =>
144
+ instructionBlockKey(pluginId, section.content),
145
+ ),
146
+ skillTrees: (contributions.skillTrees ?? []).map((tree) => tree.name),
147
+ agentDefinitions: (contributions.agentDefinitions ?? []).map((agent) => agent.name),
148
+ hookMarkers: (contributions.hookGroups ?? []).map((hook) => hook.marker),
149
+ permissionEntries: [...(contributions.permissionEntries ?? [])],
150
+ pluginReferences: (contributions.pluginReferences ?? []).map((entry) => entry.reference),
151
+ };
152
+ }
153
+
154
+ /** The ledger a pass leaves: what its enabled inputs applied, per runtime and owner. */
155
+ export function ledgerFromInputs(
156
+ inputsByRuntime: ReadonlyMap<string, readonly CapabilityContributionInput[]>,
157
+ unswept: readonly string[] = [],
158
+ ): ContributionLedger {
159
+ const runtimes: ContributionLedger["runtimes"] = {};
160
+ for (const [runtimeId, inputs] of inputsByRuntime) {
161
+ const owners: Record<string, LedgerIdentities> = {};
162
+ for (const input of inputs) {
163
+ if (!input.enabled) continue;
164
+ const identities = identitiesOf(input.pluginId, input.contributions);
165
+ const merged = owners[input.pluginId] ?? {};
166
+ for (const kind of IDENTITY_KINDS) {
167
+ merged[kind] = [...(merged[kind] ?? []), ...(identities[kind] ?? [])];
168
+ }
169
+ owners[input.pluginId] = merged;
170
+ }
171
+ runtimes[runtimeId] = owners;
172
+ }
173
+ return normalizeLedger({ version: CONTRIBUTION_LEDGER_VERSION, runtimes, unswept: [...unswept] });
174
+ }
175
+
176
+ /**
177
+ * A disabled input naming every recorded identity of `owner`, so each Runtime
178
+ * Surface removes them through its existing disabled-input path. Removal keys
179
+ * on identity only, so no value is needed: guidance is stripped by owner, a
180
+ * hook group by its marker, a plugin reference by its exact entry.
181
+ */
182
+ export function ledgerTeardownInput(
183
+ owner: string,
184
+ identities: LedgerIdentities,
185
+ ): CapabilityContributionInput {
186
+ return {
187
+ pluginId: owner,
188
+ enabled: false,
189
+ capabilityEnabled: false,
190
+ contributions: {
191
+ mcpServers: (identities.mcpServers ?? []).map((name) => ({ name })),
192
+ guidanceSections: (identities.guidanceKeys ?? []).length > 0 ? [{ content: "" }] : [],
193
+ skillTrees: (identities.skillTrees ?? []).map((name) => ({ name, sourceDir: "" })),
194
+ agentDefinitions: (identities.agentDefinitions ?? []).map((name) => ({ name, content: "" })),
195
+ hookGroups: (identities.hookMarkers ?? []).map((marker) => ({
196
+ event: "",
197
+ marker,
198
+ group: {},
199
+ })),
200
+ permissionEntries: [...(identities.permissionEntries ?? [])],
201
+ pluginReferences: (identities.pluginReferences ?? []).map((reference) => ({
202
+ reference,
203
+ isManagedReference: (entry: unknown) => entry === reference,
204
+ })),
205
+ },
206
+ };
207
+ }
@@ -4,10 +4,16 @@ import path from "node:path";
4
4
  import { parse } from "yaml";
5
5
 
6
6
  import { FRAMEWORK_NAME } from "../../../framework";
7
- import type { PluginDeclaration } from "../../../lib/orchestrator/types";
7
+ import { ConfigError, type PluginDeclaration } from "../../../lib/orchestrator/types";
8
8
 
9
9
  export const AUDIENCE_ENV = "MATE_AUDIENCE";
10
10
 
11
+ /** The reserved audience holding the base set: always applied, never selected or listed. */
12
+ export const BASE_AUDIENCE = "base";
13
+
14
+ /** The base set's audience-scoped keys; companion-wide keys stay at the root. */
15
+ export const BASE_AUDIENCE_KEYS = ["capabilities", "plugins", "studio"] as const;
16
+
11
17
  export type AudienceResolution =
12
18
  | { ok: true; active: string | null }
13
19
  | { ok: false; requested: string; declared: string[] };
@@ -17,11 +23,19 @@ function isRecord(value: unknown): value is Record<string, unknown> {
17
23
  }
18
24
 
19
25
  function pluginList(value: unknown): unknown[] {
26
+ if (
27
+ isRecord(value) &&
28
+ isRecord(value.audiences) &&
29
+ Object.hasOwn(value.audiences, BASE_AUDIENCE)
30
+ ) {
31
+ return pluginList(normalizeBaseAudience(value));
32
+ }
20
33
  return isRecord(value) && Array.isArray(value.plugins) ? value.plugins : [];
21
34
  }
22
35
 
23
36
  function audienceEntry(raw: unknown, audience: string | null): unknown {
24
- if (audience === null || !isRecord(raw) || !isRecord(raw.audiences)) return undefined;
37
+ if (audience === null || audience === BASE_AUDIENCE) return undefined;
38
+ if (!isRecord(raw) || !isRecord(raw.audiences)) return undefined;
25
39
  return Object.hasOwn(raw.audiences, audience) ? raw.audiences[audience] : undefined;
26
40
  }
27
41
 
@@ -32,6 +46,38 @@ function studioAgent(value: unknown): unknown {
32
46
  return value.studio.terminal.agent;
33
47
  }
34
48
 
49
+ /**
50
+ * The base set at the root, whichever shape the file uses: `audiences.base`
51
+ * keys are lifted to the root and `base` leaves `audiences`. A file in the
52
+ * root shape is returned as it is. Throws when a key is declared in both
53
+ * places, or `audiences.base` holds a key the base set does not have.
54
+ */
55
+ export function normalizeBaseAudience<T>(raw: T): T {
56
+ if (!isRecord(raw) || !isRecord(raw.audiences) || !Object.hasOwn(raw.audiences, BASE_AUDIENCE)) {
57
+ return raw;
58
+ }
59
+ const { [BASE_AUDIENCE]: baseEntry, ...others } = raw.audiences;
60
+ const lifted: Record<string, unknown> = { ...raw };
61
+ if (isRecord(baseEntry)) {
62
+ for (const [key, value] of Object.entries(baseEntry)) {
63
+ if (!(BASE_AUDIENCE_KEYS as readonly string[]).includes(key)) {
64
+ throw new ConfigError(
65
+ `audiences.${BASE_AUDIENCE} may contain only ${BASE_AUDIENCE_KEYS.join(", ")}; "${key}" belongs at the root of framework.yaml.`,
66
+ );
67
+ }
68
+ if (Object.hasOwn(raw, key)) {
69
+ throw new ConfigError(
70
+ `"${key}" is declared both at the root of framework.yaml and under audiences.${BASE_AUDIENCE}; keep one.`,
71
+ );
72
+ }
73
+ lifted[key] = value;
74
+ }
75
+ }
76
+ if (Object.keys(others).length > 0) lifted.audiences = others;
77
+ else delete lifted.audiences;
78
+ return lifted as T;
79
+ }
80
+
35
81
  /** `MATE_AUDIENCE` from the environment; unset, empty or blank means none. */
36
82
  export function readAudienceSelection(env: Record<string, string | undefined>): string | null {
37
83
  const value = env[AUDIENCE_ENV]?.trim();
@@ -40,11 +86,14 @@ export function readAudienceSelection(env: Record<string, string | undefined>):
40
86
 
41
87
  /** Audience names declared by raw parsed `framework.yaml`, in declared order. */
42
88
  export function declaredAudiences(raw: unknown): string[] {
43
- return isRecord(raw) && isRecord(raw.audiences) ? Object.keys(raw.audiences) : [];
89
+ return isRecord(raw) && isRecord(raw.audiences)
90
+ ? Object.keys(raw.audiences).filter((name) => name !== BASE_AUDIENCE)
91
+ : [];
44
92
  }
45
93
 
94
+ /** `MATE_AUDIENCE=base` selects the base set alone, the same as no audience. */
46
95
  export function resolveAudience(raw: unknown, audience: string | null): AudienceResolution {
47
- if (audience === null) return { ok: true, active: null };
96
+ if (audience === null || audience === BASE_AUDIENCE) return { ok: true, active: null };
48
97
  const declared = declaredAudiences(raw);
49
98
  if (declared.includes(audience)) return { ok: true, active: audience };
50
99
  return { ok: false, requested: audience, declared };
@@ -58,6 +107,33 @@ export function effectivePluginDeclarations(raw: unknown, audience: string | nul
58
107
  return [...pluginList(raw), ...pluginList(audienceEntry(raw, audience))];
59
108
  }
60
109
 
110
+ /** One effective declaration and its source: `null` for base, else the audience name. */
111
+ export interface SourcedDeclaration {
112
+ entry: unknown;
113
+ audience: string | null;
114
+ }
115
+
116
+ /** `effectivePluginDeclarations`, each entry marked with the source that declared it. */
117
+ export function effectivePluginDeclarationSources(
118
+ raw: unknown,
119
+ audience: string | null,
120
+ ): SourcedDeclaration[] {
121
+ return [
122
+ ...pluginList(raw).map((entry) => ({ entry, audience: null })),
123
+ ...pluginList(audienceEntry(raw, audience)).map((entry) => ({ entry, audience })),
124
+ ];
125
+ }
126
+
127
+ /** The declarations of one audience only, without base. */
128
+ export function audiencePluginDeclarations(raw: unknown, audience: string): unknown[] {
129
+ return pluginList(audienceEntry(raw, audience));
130
+ }
131
+
132
+ /** The raw `audiences.<name>` entry, for fingerprinting what a layer depends on. */
133
+ export function audienceDeclarationEntry(raw: unknown, audience: string): unknown {
134
+ return audienceEntry(raw, audience);
135
+ }
136
+
61
137
  /**
62
138
  * Base plugins followed by every audience's, for installation only: a
63
139
  * package shared by audiences appears once since validation guarantees one
@@ -96,24 +172,28 @@ export function installPluginDeclarations(config: unknown): PluginDeclaration[]
96
172
  * as found: the caller owns rejecting a value that is not a safe name.
97
173
  */
98
174
  export function effectiveStudioAgent(raw: unknown, audience: string | null): unknown {
99
- return studioAgent(audienceEntry(raw, audience)) ?? studioAgent(raw);
175
+ return studioAgent(audienceEntry(raw, audience)) ?? studioAgent(normalizeBaseAudience(raw));
100
176
  }
101
177
 
102
178
  /**
103
- * Raw read of the companion's `framework.yaml`. Deliberately avoids
104
- * `ConfigStore.load()` — hydration runs on every invocation and must never
105
- * create or migrate config files as a side effect.
179
+ * Raw read of the companion's `framework.yaml`, the base set at the root
180
+ * whichever shape the file uses. Deliberately avoids `ConfigStore.load()` —
181
+ * hydration runs on every invocation and must never create or migrate config
182
+ * files as a side effect.
106
183
  */
107
184
  export async function readFrameworkRaw(companionPath: string): Promise<unknown> {
185
+ let raw: unknown;
108
186
  try {
109
187
  const text = await fs.readFile(
110
188
  path.join(companionPath, `.${FRAMEWORK_NAME}`, "config", "framework.yaml"),
111
189
  "utf8",
112
190
  );
113
- return (parse(text) as unknown) ?? null;
191
+ raw = (parse(text) as unknown) ?? null;
114
192
  } catch {
115
193
  return null;
116
194
  }
195
+ /** Outside the catch: a base key in both places is the operator's to fix, never silently base-less. */
196
+ return normalizeBaseAudience(raw);
117
197
  }
118
198
 
119
199
  export async function readEffectiveDeclarations(