@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
@@ -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";
@@ -89,6 +89,10 @@ function getBaseManagedPermissionEntries(companionPath: string): string[] {
89
89
  const LEGACY_MANAGED_PERMISSION_ENTRIES = [
90
90
  "Bash($MATE_COMPANION_BIN_PATH/openspec:*)",
91
91
  "Bash($MATE_COMPANION_BIN_PATH/graphify:*)",
92
+ // React Doctor's pre-seeds before it moved to the pinned mate-react-doctor skill.
93
+ "Skill(react-doctor)",
94
+ "Bash(npx react-doctor:*)",
95
+ "Bash(npx react-doctor@latest *)",
92
96
  ];
93
97
 
94
98
  function getAllManagedPermissionEntries(companionPath: string): Set<string> {
@@ -102,16 +106,12 @@ function getAllManagedPermissionEntries(companionPath: string): Set<string> {
102
106
  ]);
103
107
  }
104
108
 
105
- // Resolved path of the companion-owned Claude settings document. This is the
106
- // single source of truth for Mate-managed Claude hooks, permissions, and MCP
107
- // servers; it is loaded at launch via `claude --settings`.
109
+ /** Companion-owned Claude settings: sole source of Mate-managed hooks, permissions and MCP servers. */
108
110
  export function getCompanionClaudeSettingsPath(companionPath: string): string {
109
111
  return path.join(companionPath, ".claude", "settings.local.json");
110
112
  }
111
113
 
112
- // Resolved path of the companion-owned `.mcp.json`. Mate writes Mate-managed
113
- // MCP servers here and loads it at launch via `claude --mcp-config`, keeping
114
- // the MCP surface out of the working repo and out of settings.local.json.
114
+ /** Companion-owned `.mcp.json`; loaded via `--mcp-config` only by a companion-scoped launch. */
115
115
  export function getCompanionClaudeMcpConfigPath(companionPath: string): string {
116
116
  return path.join(companionPath, ".mcp.json");
117
117
  }
@@ -147,6 +147,40 @@ export function renderManagedClaudeMcpServers(
147
147
  return servers;
148
148
  }
149
149
 
150
+ /**
151
+ * Enabled Capabilities' Claude hook groups per event, in declared order, as a
152
+ * value: what a Claude wrap carries into the Working Repository.
153
+ */
154
+ export function renderManagedClaudeHookGroups(
155
+ contributions: CapabilityContributionInput[] = [],
156
+ ): Record<string, ClaudeHookGroup[]> {
157
+ const hooks: Record<string, ClaudeHookGroup[]> = {};
158
+ for (const input of contributions) {
159
+ if (!input.enabled) continue;
160
+ for (const hook of input.contributions.hookGroups ?? []) {
161
+ (hooks[hook.event] ??= []).push(hook.group);
162
+ }
163
+ }
164
+ return hooks;
165
+ }
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
+
150
184
  // Reconcile the Mate-managed Claude settings shape (hooks, permissions, MCP
151
185
  // servers) on top of whatever already exists in the settings document. Managed
152
186
  // groups/entries always lead their arrays so the emitted shape stays stable
@@ -270,7 +304,15 @@ export async function reconcileClaudeContributions(
270
304
  // The settings sync (re)creates the managed settings document, so it only
271
305
  // runs while the Claude runtime is active; a deactivated runtime's pass is
272
306
  // teardown-only and must not resurrect files the provider teardown removed.
273
- if (ctx.activeProviders.includes("claude")) {
307
+ // A layer carries its plugins' hooks and permission entries only, for the
308
+ // launch to merge into its one `--settings` file.
309
+ if (projectionTarget(ctx) === "audience-layer") {
310
+ await writeClaudeSettings(
311
+ getCompanionClaudeSettingsPath(root),
312
+ renderLayerClaudeSettings(inputs),
313
+ );
314
+ await syncCompanionClaudeMcpConfig(root);
315
+ } else if (ctx.activeProviders.includes("claude")) {
274
316
  await syncCompanionClaudeSettings(root, config, inputs);
275
317
  }
276
318
 
@@ -416,6 +416,16 @@ export async function removeOpenCodeForeignPluginReferences(
416
416
 
417
417
  const OPENCODE_CONTRIBUTION_CONFIG_FILES = ["opencode.json"];
418
418
 
419
+ /**
420
+ * The surface's reader for an audience layer it wrote: what the Mate OpenCode
421
+ * plugin applies. Defined under `runtime/` so the import-isolated plugin can
422
+ * reach it; this surface owns the format it reads.
423
+ */
424
+ export {
425
+ readOpenCodeAudienceLayer,
426
+ type OpenCodeLayerContents,
427
+ } from "../../../runtime/opencode-audience-layer";
428
+
419
429
  export async function reconcileOpenCodeContributions(
420
430
  ctx: SetupContext,
421
431
  inputs: CapabilityContributionInput[],
@@ -3,13 +3,22 @@ import type { Plugin, PluginPolicy, PluginRegistration } from "./plugin";
3
3
  export interface NormalizedRegistration {
4
4
  plugin: Plugin;
5
5
  policy: PluginPolicy;
6
+ /** The audience that declared the plugin; `null` for base. */
7
+ audience: string | null;
8
+ /** The declared package a dynamic plugin was loaded from; absent for compiled-in plugins. */
9
+ packageName?: string;
6
10
  }
7
11
 
8
12
  export function normalizeRegistration(entry: PluginRegistration): NormalizedRegistration {
9
13
  if ("plugin" in entry && "policy" in entry) {
10
- return { plugin: entry.plugin, policy: entry.policy };
14
+ return {
15
+ plugin: entry.plugin,
16
+ policy: entry.policy,
17
+ audience: entry.audience ?? null,
18
+ ...(entry.packageName ? { packageName: entry.packageName } : {}),
19
+ };
11
20
  }
12
- return { plugin: entry, policy: entry.defaultSelected ? "default" : "optional" };
21
+ return { plugin: entry, policy: entry.defaultSelected ? "default" : "optional", audience: null };
13
22
  }
14
23
 
15
24
  export class PluginRegistry {
@@ -32,6 +41,19 @@ export class PluginRegistry {
32
41
  return this.entries.map((entry) => ({ ...entry }));
33
42
  }
34
43
 
44
+ /**
45
+ * Compiled-in plugins and top-level declarations: the only registrations a
46
+ * pass that writes a persisted surface may reconcile.
47
+ */
48
+ baseRegistrations(): NormalizedRegistration[] {
49
+ return this.getEntries().filter((entry) => entry.audience === null);
50
+ }
51
+
52
+ /** The registrations one audience declared; only its launch layer renders them. */
53
+ audienceRegistrations(audience: string): NormalizedRegistration[] {
54
+ return this.getEntries().filter((entry) => entry.audience === audience);
55
+ }
56
+
35
57
  getPolicy(pluginId: string): PluginPolicy | undefined {
36
58
  return this.entries.find((entry) => entry.plugin.id === pluginId)?.policy;
37
59
  }
@@ -1,20 +1,23 @@
1
+ import path from "node:path";
2
+
3
+ import { runtimeOpenCodePluginRoot } from "../../runtime/runtime-directory";
1
4
  import {
2
5
  CLAUDE_LOCAL_CONFIG_DOCUMENT,
3
6
  CLAUDE_SETTINGS_DOCUMENT,
4
7
  OPENCODE_CONFIG_DOCUMENT,
5
- } from "../../lib/orchestrator/projection-runtime-documents";
6
- import type {
7
- ManagedRegion,
8
- RenderedRuntimeDocument,
9
- } from "../../lib/orchestrator/projection-types";
10
- import { getOpenCodePluginRoot } from "../../lib/package-paths";
8
+ OPENCODE_MCP_SERVERS_AT,
9
+ type ManagedRegion,
10
+ type RenderedRuntimeDocument,
11
+ } from "../../runtime/runtime-document-manifest";
11
12
  import type { CapabilityContributionInput, SetupContext } from "./plugin";
12
13
  import {
13
14
  mateBannerHookGroups,
14
15
  mateGuardHookGroups,
15
16
  mateGuidanceHookGroups,
17
+ mateSessionPrepareHookGroups,
16
18
  } from "./providers/claude-plugin-hooks";
17
19
  import {
20
+ renderManagedClaudeHookGroups,
18
21
  renderManagedClaudeMcpServers,
19
22
  renderManagedClaudePermissionEntries,
20
23
  } from "./providers/claude";
@@ -22,6 +25,7 @@ import {
22
25
  renderCompanionExternalDirectoryPermissions,
23
26
  renderManagedOpenCodeMcpServers,
24
27
  } from "./providers/opencode";
28
+ import { isRecord, readOpenCodeConfig } from "./providers/opencode-format";
25
29
  import { projectionRepoRoot } from "./surface-target";
26
30
 
27
31
  /**
@@ -47,40 +51,39 @@ function document(documentPath: string, regions: ManagedRegion[]): RenderedRunti
47
51
  /**
48
52
  * The Mate plugin root is what makes an Unmanaged OpenCode session load the
49
53
  * companion guidance at all: OpenCode has no config channel for a generated
50
- * system prompt, so the plugin is the only injection point, and a bare session
51
- * has no launch overlay to find it in. The root is machine-local, which this
52
- * git-excluded document tolerates and a committed config would not.
53
- *
54
- * A managed launch lists the same root in its `OPENCODE_CONFIG_CONTENT`
55
- * overlay; OpenCode dedupes identical plugin paths across config sources.
54
+ * system prompt, so the plugin is the only injection point. It is named through
55
+ * the runtime directory, so a reinstall does not strand a wrapped repository.
56
+ * The managed launch carries no overlay of its own, so this document is the one
57
+ * place every OpenCode session in the repository loads it from.
56
58
  */
57
59
 
58
60
  /**
59
- * Capability hook groups stay companion-only. A managed session loads this
60
- * document as its `local` source *alongside* the companion's, so anything here
61
- * that the launch also delivers runs twice; the guard, the guidance hook and
62
- * the banner are the contributions whose absence is a correctness hole and
63
- * whose double run is inert — the guard because its verdict depends only on the
64
- * tool input, the guidance hook because it yields nothing under a launch
65
- * environment, and the banner because the copy carried here is flagged
66
- * `--projected` and defers to the one the launch's own plugin prints.
67
- *
68
- * `CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD` is what a managed launch sets
69
- * beside `--add-dir`. Without it the companion directories this document
70
- * already permits are reachable but their `CLAUDE.md` is not loaded, so the
71
- * permission alone delivers no guidance.
61
+ * Loaded by a managed session beside the launch's plugin. Mate hooks here run
62
+ * twice there, inertly: guard is input-pure, guidance/prepare yield under a
63
+ * launch env, banner is `--projected`. Capability groups run once: the launch
64
+ * omits them from `--settings`. A launch passes no `--add-dir`; it relies on
65
+ * `CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD` for companion `CLAUDE.md`.
72
66
  */
73
67
  function claudeSettingsRegions(
74
68
  ctx: SetupContext,
75
69
  inputs: CapabilityContributionInput[],
76
70
  ): ManagedRegion[] {
71
+ const capabilityHooks = renderManagedClaudeHookGroups(inputs);
72
+ const mateHooks: Record<string, unknown[]> = {
73
+ PreToolUse: mateGuardHookGroups(),
74
+ SessionStart: [
75
+ ...mateBannerHookGroups(),
76
+ ...mateGuidanceHookGroups(),
77
+ ...mateSessionPrepareHookGroups(),
78
+ ],
79
+ };
80
+ const events = [...new Set([...Object.keys(mateHooks), ...Object.keys(capabilityHooks)])];
77
81
  return [
78
- { at: ["hooks", "PreToolUse"], kind: "list", values: mateGuardHookGroups() },
79
- {
80
- at: ["hooks", "SessionStart"],
82
+ ...events.map((event): ManagedRegion => ({
83
+ at: ["hooks", event],
81
84
  kind: "list",
82
- values: [...mateBannerHookGroups(), ...mateGuidanceHookGroups()],
83
- },
85
+ values: [...(mateHooks[event] ?? []), ...(capabilityHooks[event] ?? [])],
86
+ })),
84
87
  /**
85
88
  * `stripPrefix` heals a repository an older Mate wrapped. That version wrote
86
89
  * the whole launch environment here, and Claude Code applies `env` to the
@@ -152,10 +155,10 @@ export function renderDocumentsForTarget(
152
155
  {
153
156
  at: ["plugins"],
154
157
  kind: "list",
155
- values: [getOpenCodePluginRoot()],
158
+ values: [runtimeOpenCodePluginRoot()],
156
159
  },
157
160
  {
158
- at: ["mcp", "servers"],
161
+ at: [...OPENCODE_MCP_SERVERS_AT],
159
162
  kind: "map",
160
163
  entries: renderManagedOpenCodeMcpServers(inputs),
161
164
  },
@@ -164,8 +167,125 @@ export function renderDocumentsForTarget(
164
167
  kind: "list",
165
168
  values: renderCompanionExternalDirectoryPermissions(ctx.companionPath),
166
169
  },
170
+ {
171
+ at: ["skills"],
172
+ kind: "list",
173
+ values: [path.join(ctx.companionPath, ".agents", "skills")],
174
+ },
175
+ { at: ["references"], kind: "map", entries: { mate: ctx.companionPath } },
167
176
  ]),
168
177
  );
169
178
  }
170
179
  return documents;
171
180
  }
181
+
182
+ /** Keys Mate renders its own regions for; the companion's values join those regions. */
183
+ const MATE_OPENCODE_KEYS = new Set(["plugins", "permissions", "skills", "mcp", "references"]);
184
+
185
+ function relativePathValues(value: unknown, at: string[]): string[] {
186
+ if (typeof value === "string") {
187
+ return value.startsWith("./") || value.startsWith("../") ? [at.join(".")] : [];
188
+ }
189
+ if (Array.isArray(value)) {
190
+ return value.flatMap((entry, index) => relativePathValues(entry, [...at, String(index)]));
191
+ }
192
+ if (isRecord(value)) {
193
+ return Object.entries(value).flatMap(([key, entry]) => relativePathValues(entry, [...at, key]));
194
+ }
195
+ return [];
196
+ }
197
+
198
+ /** Replaces the region at `at` with what `merge` builds from it, or appends one. */
199
+ function upsertRegion(
200
+ regions: ManagedRegion[],
201
+ at: string[],
202
+ merge: (found: ManagedRegion | undefined) => ManagedRegion,
203
+ ): void {
204
+ const key = JSON.stringify(at);
205
+ const index = regions.findIndex((region) => JSON.stringify(region.at) === key);
206
+ if (index === -1) regions.push(merge(undefined));
207
+ else regions[index] = merge(regions[index]);
208
+ }
209
+
210
+ /** Mate's own values stay; companion values it does not already list go first. */
211
+ function mergeListRegion(regions: ManagedRegion[], at: string[], extra: unknown[]): void {
212
+ upsertRegion(regions, at, (found) => {
213
+ const own = found?.kind === "list" ? found.values : [];
214
+ const seen = new Set(own.map((value) => JSON.stringify(value)));
215
+ const values = [...extra.filter((value) => !seen.has(JSON.stringify(value))), ...own];
216
+ return { at, kind: "list", values };
217
+ });
218
+ }
219
+
220
+ /** Mate's own entries win over companion entries of the same name. */
221
+ function mergeMapRegion(
222
+ regions: ManagedRegion[],
223
+ at: string[],
224
+ extra: Record<string, unknown>,
225
+ ): void {
226
+ upsertRegion(regions, at, (found) => {
227
+ const own = found?.kind === "map" ? found.entries : {};
228
+ return { at, kind: "map", entries: { ...extra, ...own } };
229
+ });
230
+ }
231
+
232
+ /** Relative reference paths resolved against the companion's `.opencode/`. */
233
+ function resolveReferences(
234
+ references: Record<string, unknown>,
235
+ companionDir: string,
236
+ ): Record<string, unknown> {
237
+ return Object.fromEntries(
238
+ Object.entries(references).map(([name, reference]) => [
239
+ name,
240
+ typeof reference === "string" && !path.isAbsolute(reference)
241
+ ? path.resolve(companionDir, reference)
242
+ : reference,
243
+ ]),
244
+ );
245
+ }
246
+
247
+ /**
248
+ * Folds the companion's `.opencode/opencode.json` into the wrapped document.
249
+ * Mate's regions beat companion values; other top-level keys yield to the
250
+ * repository's. `references` resolve against the companion's `.opencode/`;
251
+ * other relative paths are reported, not rewritten.
252
+ */
253
+ export async function mergeCompanionOpenCodeConfig(
254
+ companionPath: string,
255
+ documents: RenderedRuntimeDocument[],
256
+ ): Promise<{ documents: RenderedRuntimeDocument[]; warnings: string[] }> {
257
+ const index = documents.findIndex((document) => document.path === OPENCODE_CONFIG_DOCUMENT);
258
+ const companionConfigPath = path.join(companionPath, ".opencode", "opencode.json");
259
+ const companion = index === -1 ? null : await readOpenCodeConfig(companionConfigPath);
260
+ if (!companion?.present) return { documents, warnings: [] };
261
+
262
+ const regions = [...documents[index]!.regions] as ManagedRegion[];
263
+ const warnings: string[] = [];
264
+
265
+ const companionDir = path.dirname(companionConfigPath);
266
+ for (const [key, value] of Object.entries(companion.config)) {
267
+ if (key === "$schema") continue;
268
+ if (key === "references" && isRecord(value)) {
269
+ mergeMapRegion(regions, ["references"], resolveReferences(value, companionDir));
270
+ continue;
271
+ }
272
+ for (const at of relativePathValues(value, [key])) {
273
+ warnings.push(
274
+ `${companionConfigPath}: ${at} is a relative path, which resolves against the working repository once merged into ${OPENCODE_CONFIG_DOCUMENT}; use an absolute path.`,
275
+ );
276
+ }
277
+ if (MATE_OPENCODE_KEYS.has(key)) {
278
+ if (key === "mcp" && isRecord(value) && isRecord(value.servers)) {
279
+ mergeMapRegion(regions, [...OPENCODE_MCP_SERVERS_AT], value.servers);
280
+ } else if (Array.isArray(value)) {
281
+ mergeListRegion(regions, [key], value);
282
+ }
283
+ continue;
284
+ }
285
+ regions.push({ at: [key], kind: "value", value, yields: true });
286
+ }
287
+
288
+ const next = [...documents];
289
+ next[index] = { path: OPENCODE_CONFIG_DOCUMENT, regions };
290
+ return { documents: next, warnings };
291
+ }
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
2
 
3
+ import { audienceLayerDir } from "../../runtime/audience-layer";
3
4
  import type { ProjectionTarget, SetupContext } from "./plugin";
4
5
 
5
6
  /**
@@ -14,6 +15,9 @@ export function projectionTarget(ctx: SetupContext): ProjectionTarget {
14
15
  "a working-target reconcile needs a Working Repository in scope; none was supplied",
15
16
  );
16
17
  }
18
+ if (target === "audience-layer" && !ctx.audience) {
19
+ throw new Error("an audience-layer reconcile needs an audience; none was supplied");
20
+ }
17
21
  return target;
18
22
  }
19
23
 
@@ -38,13 +42,18 @@ export function projectionRepoRoot(ctx: SetupContext): string {
38
42
  }
39
43
 
40
44
  /**
41
- * The root a Runtime Surface writes into. Defined for the companion target
42
- * only: a working-target pass renders its documents for the Projection Root to
43
- * place, so no Runtime Surface ever computes a path inside a Working Repository.
45
+ * The root a Runtime Surface writes into: the companion, or one audience's
46
+ * layer outside every repository. Never a Working Repository: a working-target
47
+ * pass renders its documents for the Projection Root to place, so no Runtime
48
+ * Surface ever computes a path inside one.
44
49
  */
45
50
  export function surfaceRoot(ctx: SetupContext): string {
46
- if (projectionTarget(ctx) === "working") {
51
+ const target = projectionTarget(ctx);
52
+ if (target === "working") {
47
53
  throw new Error("a working-target reconcile renders its documents; it writes no surface root");
48
54
  }
55
+ if (target === "audience-layer") {
56
+ return ctx.layerRoot ?? audienceLayerDir(ctx.companionPath, ctx.audience!);
57
+ }
49
58
  return ctx.companionPath;
50
59
  }
@@ -0,0 +1,153 @@
1
+ // oxlint-disable no-await-in-loop
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import { FRAMEWORK_NAME } from "../../framework";
6
+ import { removeManagedBlocksForPlugin } from "./context-services";
7
+ import {
8
+ audiencePluginDeclarations,
9
+ declaredAudiences,
10
+ effectivePluginDeclarations,
11
+ readFrameworkRaw,
12
+ } from "./dynamic-plugins/audiences";
13
+ import { validateDeclaration } from "./dynamic-plugins/declarations";
14
+ import { loadDynamicPlugin, type DynamicPluginLoadDeps } from "./dynamic-plugins/loader";
15
+ import type { CapabilityContributionInput, CapabilityPlugin, Plugin, SetupContext } from "./plugin";
16
+ import type { NormalizedRegistration } from "./registry";
17
+
18
+ /**
19
+ * Upgrade Sweep: removes what audience plugins leaked into a companion before
20
+ * the contribution ledger existed. Runs once, when a persisted pass finds no
21
+ * ledger, and again only for the packages it could not load then.
22
+ */
23
+
24
+ /** The companion files Mate's managed guidance blocks live in. */
25
+ const GUIDANCE_FILES = ["CLAUDE.md", "AGENTS.md", path.join(".opencode", "AGENTS.md")];
26
+
27
+ const MANAGED_BLOCK_START = new RegExp(
28
+ `<!-- ${FRAMEWORK_NAME}:managed:([^\\n]+):[0-9a-f]+ start -->`,
29
+ "g",
30
+ );
31
+
32
+ /** Plugin ids owning a managed guidance block in `text`. */
33
+ export function managedBlockOwners(text: string): string[] {
34
+ return [...new Set([...text.matchAll(MANAGED_BLOCK_START)].map((match) => match[1]!))];
35
+ }
36
+
37
+ /** `relative path → owners` for every managed guidance block under `root`. */
38
+ export async function managedGuidanceOwners(root: string): Promise<Map<string, string[]>> {
39
+ const owners = new Map<string, string[]>();
40
+ for (const file of GUIDANCE_FILES) {
41
+ const text = await fs.readFile(path.join(root, file), "utf8").catch(() => null);
42
+ if (text === null) continue;
43
+ const found = managedBlockOwners(text);
44
+ if (found.length > 0) owners.set(file, found);
45
+ }
46
+ return owners;
47
+ }
48
+
49
+ /**
50
+ * Guidance blocks name their owner, so this needs no plugin loaded: every
51
+ * block whose owner is not a base registration goes.
52
+ */
53
+ export async function removeForeignGuidanceBlocks(
54
+ root: string,
55
+ baseIds: ReadonlySet<string>,
56
+ ): Promise<void> {
57
+ for (const [file, owners] of await managedGuidanceOwners(root)) {
58
+ for (const owner of owners) {
59
+ if (baseIds.has(owner)) continue;
60
+ await removeManagedBlocksForPlugin(path.join(root, file), FRAMEWORK_NAME, owner);
61
+ }
62
+ }
63
+ }
64
+
65
+ export interface AudienceSweep {
66
+ /** Disabled inputs for every swept plugin, per runtime. */
67
+ teardown: Map<string, CapabilityContributionInput[]>;
68
+ /** Packages that could not be loaded; their non-guidance entries stay until a retry. */
69
+ unswept: string[];
70
+ warnings: string[];
71
+ }
72
+
73
+ export interface AudienceSweepOptions {
74
+ /** Every registration of the process; an audience plugin already loaded is reused, not reloaded. */
75
+ registrations: readonly NormalizedRegistration[];
76
+ /** Restrict the sweep to these packages; every audience declaration when absent. */
77
+ packages?: ReadonlySet<string>;
78
+ deps?: DynamicPluginLoadDeps;
79
+ }
80
+
81
+ /** One distinct package per audience declaration, base packages excluded. */
82
+ function audiencePackages(raw: unknown): Map<string, ReturnType<typeof validateDeclaration>> {
83
+ const basePackages = new Set(
84
+ effectivePluginDeclarations(raw, null).flatMap((entry) => {
85
+ const { declaration } = validateDeclaration(entry);
86
+ return declaration ? [declaration.package] : [];
87
+ }),
88
+ );
89
+ const found = new Map<string, ReturnType<typeof validateDeclaration>>();
90
+ for (const audience of declaredAudiences(raw)) {
91
+ for (const entry of audiencePluginDeclarations(raw, audience)) {
92
+ const validated = validateDeclaration(entry);
93
+ const name = validated.declaration?.package;
94
+ if (!name || basePackages.has(name) || found.has(name)) continue;
95
+ found.set(name, validated);
96
+ }
97
+ }
98
+ return found;
99
+ }
100
+
101
+ /**
102
+ * Collects the declarations of every audience plugin as disabled inputs:
103
+ * loaded for its declarations only — never activated, no lifecycle hook run.
104
+ * An unloadable plugin is reported, never fatal.
105
+ */
106
+ export async function sweepAudienceDeclarations(
107
+ ctx: SetupContext,
108
+ options: AudienceSweepOptions,
109
+ ): Promise<AudienceSweep> {
110
+ const sweep: AudienceSweep = { teardown: new Map(), unswept: [], warnings: [] };
111
+ const raw = await readFrameworkRaw(ctx.companionPath);
112
+
113
+ for (const [name, { declaration }] of audiencePackages(raw)) {
114
+ if (options.packages && !options.packages.has(name)) continue;
115
+ let plugin: Plugin | undefined = options.registrations.find(
116
+ (entry) => entry.packageName === name,
117
+ )?.plugin;
118
+ if (!plugin) {
119
+ const loaded = await loadDynamicPlugin(ctx.companionPath, declaration!, options.deps);
120
+ if (!loaded.ok) {
121
+ sweep.unswept.push(name);
122
+ sweep.warnings.push(
123
+ `audience plugin "${name}" was not swept from the companion: ${loaded.warning} Its entries stay until a pass can load it.`,
124
+ );
125
+ continue;
126
+ }
127
+ plugin = loaded.plugin;
128
+ }
129
+
130
+ const capability = plugin as CapabilityPlugin;
131
+ if (plugin.kind !== "capability" || !capability.getRuntimeContributions) continue;
132
+ try {
133
+ const byRuntime = await capability.getRuntimeContributions(ctx);
134
+ for (const [runtimeId, contributions] of Object.entries(byRuntime)) {
135
+ if (!contributions) continue;
136
+ const inputs = sweep.teardown.get(runtimeId) ?? [];
137
+ inputs.push({
138
+ pluginId: plugin.id,
139
+ enabled: false,
140
+ capabilityEnabled: false,
141
+ contributions,
142
+ });
143
+ sweep.teardown.set(runtimeId, inputs);
144
+ }
145
+ } catch (error) {
146
+ sweep.unswept.push(name);
147
+ sweep.warnings.push(
148
+ `audience plugin "${name}" was not swept from the companion: ${error instanceof Error ? error.message : String(error)}`,
149
+ );
150
+ }
151
+ }
152
+ return sweep;
153
+ }
@@ -18,6 +18,7 @@ const REPORTED_AS: Partial<
18
18
  > = {
19
19
  "projection-root": { as: "removed", label: ".mate" },
20
20
  "git-excludes": { as: "removed", label: "git-excludes" },
21
+ "openspec-store-pointer": { as: "removed", label: "openspec/config.yaml" },
21
22
  "claude-working-settings": { as: "updated", label: "claude-settings" },
22
23
  "legacy-tokensave-claude-md": { as: "updated", label: "claude-settings" },
23
24
  };
@@ -36,6 +37,7 @@ export async function cleanupWorkingRepository(
36
37
  retained.push(entry.id);
37
38
  continue;
38
39
  }
40
+ if (entry.retains && !retained.includes(entry.retains)) retained.push(entry.retains);
39
41
  if (entry.state !== "removed") continue;
40
42
  const reported = REPORTED_AS[entry.id];
41
43
  if (!reported) continue;