@uniqbit/mate-core 0.18.3-canary.1 → 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 (127) hide show
  1. package/claude-plugin/hooks/hooks.json +10 -1
  2. package/claude-plugin/hooks/session-banner.mjs +4 -3
  3. package/claude-plugin/hooks/session-prepare.mjs +9 -0
  4. package/opencode-plugin/src/add-dir.ts +10 -76
  5. package/opencode-plugin/src/audience-layer.ts +91 -0
  6. package/opencode-plugin/src/companion.ts +70 -18
  7. package/opencode-plugin/src/launch-environment.ts +29 -0
  8. package/opencode-plugin/src/server.ts +60 -8
  9. package/package.json +3 -3
  10. package/src/cli/commands/artifact/finish/openspec.ts +1 -1
  11. package/src/cli/commands/artifact/pending/discovery.ts +60 -21
  12. package/src/cli/commands/cap/index-cmd.ts +2 -11
  13. package/src/cli/commands/companion/link.ts +16 -11
  14. package/src/cli/commands/companion/list.ts +4 -5
  15. package/src/cli/commands/companion/open.ts +4 -5
  16. package/src/cli/commands/companion/tui.ts +1 -1
  17. package/src/cli/commands/doctor.ts +87 -5
  18. package/src/cli/commands/install.ts +8 -0
  19. package/src/cli/commands/report/renderer.ts +9 -19
  20. package/src/cli/commands/session/prepare.ts +119 -0
  21. package/src/cli/commands/shared/companion-selection.ts +2 -2
  22. package/src/cli/commands/studio/reports.ts +0 -1
  23. package/src/cli/commands/studio/server.ts +6 -2
  24. package/src/cli/commands/studio/terminal-launch.ts +16 -7
  25. package/src/cli/commands/unwrap.ts +28 -3
  26. package/src/cli/commands/wrap.ts +79 -60
  27. package/src/cli/companion-selector.tsx +1 -1
  28. package/src/cli/main.ts +17 -0
  29. package/src/cli/usage.ts +5 -2
  30. package/src/hooks/session-banner.ts +19 -10
  31. package/src/hooks/session-guidance.ts +20 -2
  32. package/src/hooks/session-prepare.ts +101 -0
  33. package/src/lib/fs-utils.ts +13 -0
  34. package/src/lib/orchestrator/adapters/base.ts +45 -9
  35. package/src/lib/orchestrator/adapters/claude.ts +241 -30
  36. package/src/lib/orchestrator/adapters/opencode.ts +72 -34
  37. package/src/lib/orchestrator/companion-git-sync.ts +28 -1
  38. package/src/lib/orchestrator/companion-hub.ts +2 -1
  39. package/src/lib/orchestrator/config-store.ts +83 -1
  40. package/src/lib/orchestrator/framework-context.ts +5 -29
  41. package/src/lib/orchestrator/launcher.ts +36 -171
  42. package/src/lib/orchestrator/openspec-store.ts +202 -0
  43. package/src/lib/orchestrator/projection-claude-entry.ts +17 -6
  44. package/src/lib/orchestrator/projection-claude-skills.ts +126 -41
  45. package/src/lib/orchestrator/projection-entries.ts +124 -11
  46. package/src/lib/orchestrator/projection-opencode-copies.ts +230 -0
  47. package/src/lib/orchestrator/projection-openspec-store.ts +164 -0
  48. package/src/lib/orchestrator/projection-record.ts +7 -1
  49. package/src/lib/orchestrator/projection-runtime-documents.ts +83 -130
  50. package/src/lib/orchestrator/projection-types.ts +47 -34
  51. package/src/lib/orchestrator/repository-link.ts +473 -0
  52. package/src/lib/orchestrator/root-context.ts +6 -2
  53. package/src/lib/orchestrator/session-prepare.ts +503 -0
  54. package/src/lib/orchestrator/setup-compatibilities.ts +1 -1
  55. package/src/lib/orchestrator/setup-preflight.ts +3 -1
  56. package/src/lib/orchestrator/working-repo-projection.ts +73 -160
  57. package/src/lib/orchestrator/workspace-inventory.ts +2 -2
  58. package/src/lib/orchestrator/wrap-pass.ts +222 -0
  59. package/src/lib/runtime-directory-refresh.ts +186 -0
  60. package/src/lib/update-checker.ts +6 -0
  61. package/src/opencode/companion-hooks.ts +30 -135
  62. package/src/opencode/companion-policy.ts +26 -10
  63. package/src/opencode/index.ts +1 -7
  64. package/src/opencode/projected-guidance.ts +6 -13
  65. package/src/opencode/session-notes.ts +76 -0
  66. package/src/opencode/session-prepare.ts +139 -0
  67. package/src/opencode/tui.tsx +56 -9
  68. package/src/runtime/audience-layer.ts +84 -0
  69. package/src/runtime/claude-delivery-plan.ts +73 -0
  70. package/src/runtime/claude-hook-group.ts +21 -0
  71. package/src/runtime/companion-lock.ts +121 -0
  72. package/src/runtime/companion-sync.ts +66 -9
  73. package/src/runtime/guidance.ts +17 -0
  74. package/src/runtime/opencode-audience-layer.ts +176 -0
  75. package/src/runtime/policy.ts +14 -1
  76. package/src/runtime/projection.ts +1 -1
  77. package/src/runtime/runtime-directory.ts +43 -0
  78. package/src/runtime/runtime-document-manifest.ts +352 -0
  79. package/src/runtime/session-prepare-runner.ts +148 -0
  80. package/src/runtime/wrap-state.ts +94 -0
  81. package/src/runtime/write-atomic.ts +27 -0
  82. package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +2 -1
  83. package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +2 -1
  84. package/src/templates/capabilities/openspec-cap/openspec-conventions.yaml +5 -0
  85. package/src/templates/capabilities/react-doctor/mate-react-doctor/SKILL.md +84 -0
  86. package/src/templates/capabilities/react-doctor/{skill → mate-react-doctor}/references/explain.md +18 -15
  87. package/src/templates/mate-skills/agents/mate-openspec-backfill/SKILL.md +45 -29
  88. package/src/templates/mate-skills/agents/mate-show-me/SKILL.md +1 -1
  89. package/src/templates/mate-skills/claude/mate-openspec-backfill/SKILL.md +45 -29
  90. package/src/templates/mate-skills/claude/mate-show-me/SKILL.md +1 -1
  91. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +850 -499
  92. package/src/tools/setup/audience-isolation.fixture.ts +325 -0
  93. package/src/tools/setup/audience-layer.ts +207 -0
  94. package/src/tools/setup/audience-registrations.ts +52 -0
  95. package/src/tools/setup/capabilities/openspec.ts +9 -0
  96. package/src/tools/setup/capabilities/react-doctor.ts +34 -50
  97. package/src/tools/setup/capabilities/tokensave.ts +11 -0
  98. package/src/tools/setup/contribution-ledger.ts +207 -0
  99. package/src/tools/setup/dynamic-plugins/audiences.ts +89 -9
  100. package/src/tools/setup/dynamic-plugins/declarations.ts +3 -2
  101. package/src/tools/setup/dynamic-plugins/hydrate.ts +8 -5
  102. package/src/tools/setup/engine.ts +152 -10
  103. package/src/tools/setup/isolation-diagnostics.ts +139 -0
  104. package/src/tools/setup/mate.ts +1 -1
  105. package/src/tools/setup/plugin.ts +19 -4
  106. package/src/tools/setup/plugins/gitignore.ts +7 -1
  107. package/src/tools/setup/providers/claude-format.ts +11 -18
  108. package/src/tools/setup/providers/claude-plugin-hooks.ts +61 -24
  109. package/src/tools/setup/providers/claude.ts +50 -8
  110. package/src/tools/setup/providers/opencode.ts +10 -0
  111. package/src/tools/setup/registry.ts +24 -2
  112. package/src/tools/setup/runtime-documents.ts +152 -32
  113. package/src/tools/setup/surface-target.ts +13 -4
  114. package/src/tools/setup/upgrade-sweep.ts +153 -0
  115. package/src/tools/setup/working-repo-cleanup.ts +2 -0
  116. package/src/tools/setup/working-repo-local-state.ts +91 -14
  117. package/src/tools/setup.ts +11 -8
  118. package/wrappers/bin/openspec +42 -1
  119. package/src/lib/orchestrator/companion-registry-reader.ts +0 -37
  120. package/src/lib/orchestrator/companion-resolver.ts +0 -179
  121. package/src/lib/orchestrator/companion-store.ts +0 -94
  122. package/src/lib/orchestrator/repo-local-registry.ts +0 -143
  123. package/src/templates/capabilities/react-doctor/claude/hooks/react-doctor.sh +0 -144
  124. package/src/templates/capabilities/react-doctor/skill/SKILL.md +0 -51
  125. package/src/templates/report-assets/README.md +0 -32
  126. package/src/templates/report-assets/mermaid.LICENSE +0 -21
  127. package/src/templates/report-assets/mermaid.min.js +0 -4376
@@ -1,88 +1,72 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
 
4
+ import { getReactDoctorBinPath } from "../../../lib/package-paths";
4
5
  import type { CapabilityPlugin, RuntimeContributionsByRuntime, SetupContext } from "../plugin";
5
- import { removeLegacyOpenCodeSkills } from "../providers/agents-skill-root";
6
+ import { removeLegacyOpenCodeSkills, removeSharedSkills } from "../providers/agents-skill-root";
6
7
  import { pruneEmptyAncestors } from "../utils";
7
8
 
8
- const SKILL_NAME = "react-doctor";
9
+ const SKILL_NAME = "mate-react-doctor";
10
+ // The upstream skill earlier releases installed under its own name.
11
+ const LEGACY_SKILL_NAME = "react-doctor";
9
12
 
10
13
  const SKILL_SRC = path.join(
11
14
  import.meta.dirname,
12
- "../../../templates/capabilities/react-doctor/skill",
13
- );
14
- export const CLAUDE_HOOK_SRC = path.join(
15
- import.meta.dirname,
16
- "../../../templates/capabilities/react-doctor/claude/hooks/react-doctor.sh",
15
+ "../../../templates/capabilities/react-doctor/mate-react-doctor",
17
16
  );
18
17
 
18
+ /** Removes the skill copies earlier releases installed under the upstream name. */
19
+ async function removeLegacySkills(companionPath: string): Promise<void> {
20
+ const claudeSkillsDir = path.join(companionPath, ".claude", "skills");
21
+ await fs.rm(path.join(claudeSkillsDir, LEGACY_SKILL_NAME), { recursive: true, force: true });
22
+ await pruneEmptyAncestors(claudeSkillsDir, companionPath);
23
+ await removeSharedSkills(companionPath, [LEGACY_SKILL_NAME]);
24
+ await removeLegacyOpenCodeSkills(companionPath, [LEGACY_SKILL_NAME]);
25
+ }
26
+
27
+ /** Removes the post-edit hook script earlier releases copied into the companion. */
28
+ async function removeLegacyHookScript(companionPath: string): Promise<void> {
29
+ const hooksDir = path.join(companionPath, ".claude", "hooks");
30
+ await fs.rm(path.join(hooksDir, "react-doctor.sh"), { force: true });
31
+ await pruneEmptyAncestors(hooksDir, companionPath);
32
+ }
33
+
19
34
  export function createReactDoctorPlugin(): CapabilityPlugin {
20
35
  return {
21
36
  id: "react-doctor",
22
37
  kind: "capability",
23
38
  label: "React Doctor",
24
- description: "Install React Doctor skills and Claude post-edit scans.",
39
+ description: "Install the mate-react-doctor skill that verifies React changes during apply.",
25
40
  defaultSelected: true,
26
41
  isEnabled: (config) => (config.capabilities ?? []).some((c) => c.name === "react-doctor"),
27
- // Skills, session hooks, and permission pre-seeds are declared and
28
- // reconciled by each runtime's Runtime Surface. Only the hook script file
29
- // itself is copied imperatively below (script files are not a config
30
- // format the surface owns).
31
- getRuntimeContributions(ctx: SetupContext): RuntimeContributionsByRuntime {
32
- const hookCommand = `sh "${ctx.companionPath}/.claude/hooks/react-doctor.sh"`;
42
+ // React Doctor runs only when an agent invokes the skill (the shared Mate
43
+ // verification rule adds it to OpenSpec task lists), so the capability
44
+ // contributes no hooks: just the skill and its permission pre-seeds.
45
+ getRuntimeContributions(): RuntimeContributionsByRuntime {
33
46
  const skillTrees = [{ name: SKILL_NAME, sourceDir: SKILL_SRC }];
34
47
  return {
35
48
  claude: {
36
- hookGroups: [
37
- // Record edits cheaply, then scan once when the edited turn finishes.
38
- {
39
- event: "PostToolUse",
40
- marker: "react-doctor.sh",
41
- group: {
42
- matcher: "Write|Edit|MultiEdit|NotebookEdit|ApplyPatch",
43
- hooks: [{ type: "command", command: hookCommand, timeout: 5 }],
44
- },
45
- },
46
- {
47
- event: "Stop",
48
- marker: "react-doctor.sh",
49
- group: { hooks: [{ type: "command", command: hookCommand, timeout: 45 }] },
50
- },
51
- ],
52
- permissionEntries: [
53
- "Skill(react-doctor)",
54
- "Bash(npx react-doctor:*)",
55
- "Bash(npx react-doctor@latest *)",
56
- ],
49
+ permissionEntries: [`Skill(${SKILL_NAME})`, `Bash(${getReactDoctorBinPath()}:*)`],
57
50
  skillTrees,
58
51
  },
59
52
  opencode: { skillTrees },
60
53
  };
61
54
  },
62
55
  async apply(ctx: SetupContext) {
63
- await removeLegacyOpenCodeSkills(ctx.companionPath, [SKILL_NAME]);
56
+ await removeLegacySkills(ctx.companionPath);
64
57
  },
65
58
  async teardown(ctx: SetupContext) {
66
- await removeLegacyOpenCodeSkills(ctx.companionPath, [SKILL_NAME]);
59
+ await removeLegacySkills(ctx.companionPath);
67
60
  },
61
+ // Cleanup only: stale hook groups are stripped by settings-sync through the
62
+ // `react-doctor.sh` managed marker; the script file itself is removed here.
68
63
  forProvider: {
69
64
  claude: {
70
65
  async apply(ctx: SetupContext) {
71
- const hookDest = path.join(ctx.companionPath, ".claude", "hooks", "react-doctor.sh");
72
- await fs.mkdir(path.dirname(hookDest), { recursive: true });
73
- await fs.copyFile(CLAUDE_HOOK_SRC, hookDest);
74
- await fs.chmod(hookDest, 0o755);
66
+ await removeLegacyHookScript(ctx.companionPath);
75
67
  },
76
68
  async teardown(ctx: SetupContext) {
77
- try {
78
- await fs.unlink(path.join(ctx.companionPath, ".claude", "hooks", "react-doctor.sh"));
79
- } catch {
80
- /* not present */
81
- }
82
- await pruneEmptyAncestors(
83
- path.join(ctx.companionPath, ".claude", "hooks"),
84
- ctx.companionPath,
85
- );
69
+ await removeLegacyHookScript(ctx.companionPath);
86
70
  },
87
71
  },
88
72
  },
@@ -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(
@@ -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 {
@@ -3,13 +3,13 @@ import path from "node:path";
3
3
 
4
4
  import { getActiveDistribution } from "../../../distribution";
5
5
  import { FRAMEWORK_NAME } from "../../../framework";
6
- import { CompanionResolver } from "../../../lib/orchestrator/companion-resolver";
6
+ import { CompanionResolver } from "../../../lib/orchestrator/repository-link";
7
7
  import { GlobalConfigStore } from "../../../lib/orchestrator/global-config-store";
8
8
  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
  }