@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,9 +1,16 @@
1
1
  // oxlint-disable no-await-in-loop
2
2
  import { getActiveDistribution } from "../../distribution";
3
3
  import { FRAMEWORK_NAME } from "../../framework";
4
- import type { RenderedRuntimeDocument } from "../../lib/orchestrator/projection-types";
5
4
  import type { FrameworkConfig } from "../../lib/orchestrator/types";
5
+ import type { RenderedRuntimeDocument } from "../../runtime/runtime-document-manifest";
6
6
  import { collectHostingProviders, ContextServiceMediator } from "./context-services";
7
+ import {
8
+ ledgerFromInputs,
9
+ ledgerTeardownInput,
10
+ readContributionLedger,
11
+ writeContributionLedger,
12
+ type ContributionLedger,
13
+ } from "./contribution-ledger";
7
14
  import type {
8
15
  CapabilityContributionInput,
9
16
  CapabilityPlugin,
@@ -13,8 +20,10 @@ import type {
13
20
  } from "./plugin";
14
21
  import { reconcileClaudeContributions } from "./providers/claude";
15
22
  import { reconcileOpenCodeContributions } from "./providers/opencode";
16
- import { renderDocumentsForTarget } from "./runtime-documents";
23
+ import { mergeCompanionOpenCodeConfig, renderDocumentsForTarget } from "./runtime-documents";
17
24
  import { normalizeRegistration, type NormalizedRegistration } from "./registry";
25
+ import { surfaceRoot } from "./surface-target";
26
+ import { removeForeignGuidanceBlocks, sweepAudienceDeclarations } from "./upgrade-sweep";
18
27
 
19
28
  export interface SetupInstallationPlanAction {
20
29
  phase: Plugin["kind"];
@@ -35,11 +44,23 @@ export interface SetupInstallationOutcome {
35
44
  warnings: string[];
36
45
  }
37
46
 
47
+ /**
48
+ * The registrations a pass that writes a persisted surface may reconcile:
49
+ * base only. An audience registration takes part in no such pass — neither
50
+ * its lifecycle hooks nor its declared contributions — whatever audience is
51
+ * active; its contributions reach sessions only through the launch layer.
52
+ */
53
+ export function persistedRegistrations(
54
+ registrations: PluginRegistration[],
55
+ ): NormalizedRegistration[] {
56
+ return registrations.map(normalizeRegistration).filter((entry) => entry.audience === null);
57
+ }
58
+
38
59
  export function buildSetupInstallationPlan(
39
60
  config: FrameworkConfig,
40
61
  registrations: PluginRegistration[],
41
62
  ): SetupInstallationPlan {
42
- const entries = registrations.map(normalizeRegistration);
63
+ const entries = persistedRegistrations(registrations);
43
64
  // Required plugins are always treated as enabled: saved selections cannot
44
65
  // deselect them and deselection never produces a teardown action.
45
66
  const isEntryEnabled = (entry: NormalizedRegistration) =>
@@ -109,7 +130,7 @@ export async function executeSetupInstallationPlan(
109
130
  registrations: PluginRegistration[],
110
131
  plan: SetupInstallationPlan,
111
132
  ): Promise<SetupInstallationOutcome> {
112
- const plugins = registrations.map((entry) => normalizeRegistration(entry).plugin);
133
+ const plugins = persistedRegistrations(registrations).map((entry) => entry.plugin);
113
134
  const pluginById = new Map(plugins.map((plugin) => [plugin.id, plugin]));
114
135
  const executedActions: SetupInstallationPlanAction[] = [];
115
136
  const skippedActions: SetupInstallationPlanAction[] = [];
@@ -176,13 +197,22 @@ export async function executeSetupInstallationPlan(
176
197
 
177
198
  await mediator.finalize();
178
199
 
179
- await reconcileCapabilityContributions(ctx, plugins, plan);
200
+ const reconcileWarnings = await reconcileCapabilityContributions(
201
+ ctx,
202
+ plugins,
203
+ plan,
204
+ registrations.map(normalizeRegistration),
205
+ );
206
+ for (const warning of reconcileWarnings) {
207
+ warnings.push(warning);
208
+ process.stderr.write(`${warning}\n`);
209
+ }
180
210
 
181
211
  return { executedActions, skippedActions, warnings };
182
212
  }
183
213
 
184
214
  // The Runtime Surface of each active Agent Runtime, by runtime id.
185
- const RUNTIME_SURFACE_RECONCILERS: Record<
215
+ export const RUNTIME_SURFACE_RECONCILERS: Record<
186
216
  string,
187
217
  (ctx: SetupContext, inputs: CapabilityContributionInput[]) => Promise<void>
188
218
  > = {
@@ -243,13 +273,110 @@ async function reconcileCapabilityContributions(
243
273
  ctx: SetupContext,
244
274
  plugins: Plugin[],
245
275
  plan: SetupInstallationPlan,
246
- ): Promise<void> {
276
+ registrations: NormalizedRegistration[],
277
+ ): Promise<string[]> {
247
278
  const inputsByRuntime = await collectContributionInputs(ctx, plugins, plan);
279
+ const ledgerRoot = surfaceRoot(ctx);
280
+ const ledger = await readContributionLedger(ledgerRoot);
281
+ const warnings: string[] = [];
282
+ let unswept = ledger?.unswept ?? [];
283
+ if (!ledger || unswept.length > 0) {
284
+ if (!ledger) {
285
+ await removeForeignGuidanceBlocks(ledgerRoot, new Set(plugins.map((plugin) => plugin.id)));
286
+ }
287
+ const sweep = await sweepAudienceDeclarations(ctx, {
288
+ registrations,
289
+ ...(ledger ? { packages: new Set(unswept) } : {}),
290
+ });
291
+ prependTeardown(inputsByRuntime, sweep.teardown);
292
+ unswept = sweep.unswept;
293
+ warnings.push(...sweep.warnings);
294
+ }
295
+ prependLedgerTeardown(inputsByRuntime, ledger);
248
296
  for (const [runtimeId, inputs] of inputsByRuntime) {
249
297
  const reconcile = RUNTIME_SURFACE_RECONCILERS[runtimeId];
250
298
  if (!reconcile || inputs.length === 0) continue;
251
299
  await reconcile(ctx, inputs);
252
300
  }
301
+ await writeContributionLedger(ledgerRoot, ledgerFromInputs(inputsByRuntime, unswept));
302
+ return warnings;
303
+ }
304
+
305
+ /**
306
+ * Disabled inputs placed first, skipping any owner that is an enabled input
307
+ * of the pass on that runtime: removal runs before enabled inputs apply.
308
+ */
309
+ function prependTeardown(
310
+ inputsByRuntime: Map<string, CapabilityContributionInput[]>,
311
+ teardownByRuntime: ReadonlyMap<string, readonly CapabilityContributionInput[]>,
312
+ ): void {
313
+ for (const [runtimeId, teardown] of teardownByRuntime) {
314
+ const inputs = inputsByRuntime.get(runtimeId) ?? [];
315
+ const enabled = new Set(inputs.filter((input) => input.enabled).map((input) => input.pluginId));
316
+ const kept = teardown.filter((input) => !enabled.has(input.pluginId));
317
+ if (kept.length > 0) inputsByRuntime.set(runtimeId, [...kept, ...inputs]);
318
+ }
319
+ }
320
+
321
+ /**
322
+ * Every recorded owner that is not an enabled input of this pass, as a
323
+ * disabled input placed first: its entries are removed before enabled inputs
324
+ * apply, so an identity another plugin still declares is re-applied after.
325
+ * Entries the ledger does not record are never named, so they survive.
326
+ */
327
+ function prependLedgerTeardown(
328
+ inputsByRuntime: Map<string, CapabilityContributionInput[]>,
329
+ ledger: ContributionLedger | null,
330
+ ): void {
331
+ prependTeardown(
332
+ inputsByRuntime,
333
+ new Map(
334
+ Object.entries(ledger?.runtimes ?? {}).map(([runtimeId, owners]) => [
335
+ runtimeId,
336
+ Object.entries(owners).map(([owner, identities]) => ledgerTeardownInput(owner, identities)),
337
+ ]),
338
+ ),
339
+ );
340
+ }
341
+
342
+ /**
343
+ * The declared contributions of one audience's plugins, as enabled inputs per
344
+ * active runtime. No plugin phase and no lifecycle hook runs: an audience
345
+ * plugin contributes declaratively only. OpenCode plugin references are
346
+ * dropped — the Mate plugin has no way to load another plugin.
347
+ */
348
+ export async function collectAudienceInputs(
349
+ ctx: SetupContext,
350
+ registrations: PluginRegistration[],
351
+ audience: string,
352
+ ): Promise<Map<string, CapabilityContributionInput[]>> {
353
+ const inputsByRuntime = new Map<string, CapabilityContributionInput[]>();
354
+ for (const entry of registrations.map(normalizeRegistration)) {
355
+ if (entry.audience !== audience || entry.plugin.kind !== "capability") continue;
356
+ const capability = entry.plugin as CapabilityPlugin;
357
+ if (!capability.getRuntimeContributions) continue;
358
+ if (entry.policy !== "required" && !capability.isEnabled(ctx.config)) continue;
359
+ const byRuntime = await capability.getRuntimeContributions(ctx);
360
+ for (const [runtimeId, contributions] of Object.entries(byRuntime)) {
361
+ if (!contributions || !ctx.activeProviders.includes(runtimeId)) continue;
362
+ const { pluginReferences: _dropped, ...declarative } = contributions;
363
+ const inputs = inputsByRuntime.get(runtimeId) ?? [];
364
+ inputs.push({
365
+ pluginId: capability.id,
366
+ enabled: true,
367
+ capabilityEnabled: true,
368
+ contributions: declarative,
369
+ });
370
+ inputsByRuntime.set(runtimeId, inputs);
371
+ }
372
+ }
373
+ return inputsByRuntime;
374
+ }
375
+
376
+ /** What a render returns: its warnings are data for the caller, never printed here. */
377
+ export interface RenderedRuntimeDocuments {
378
+ documents: RenderedRuntimeDocument[];
379
+ warnings: string[];
253
380
  }
254
381
 
255
382
  /**
@@ -261,7 +388,22 @@ export async function renderRuntimeDocuments(
261
388
  ctx: SetupContext,
262
389
  registrations: PluginRegistration[],
263
390
  plan: SetupInstallationPlan,
264
- ): Promise<RenderedRuntimeDocument[]> {
265
- const plugins = registrations.map((entry) => normalizeRegistration(entry).plugin);
266
- return renderDocumentsForTarget(ctx, await collectContributionInputs(ctx, plugins, plan));
391
+ wrapped?: { audience: string; registrations: PluginRegistration[] },
392
+ ): Promise<RenderedRuntimeDocuments> {
393
+ const plugins = persistedRegistrations(registrations).map((entry) => entry.plugin);
394
+ const inputsByRuntime = await collectContributionInputs(ctx, plugins, plan);
395
+ /** A checkout wrapped for an audience carries it beside base; no other audience ever. */
396
+ if (wrapped) {
397
+ const audienceInputs = await collectAudienceInputs(
398
+ ctx,
399
+ wrapped.registrations,
400
+ wrapped.audience,
401
+ );
402
+ for (const [runtimeId, inputs] of audienceInputs) {
403
+ inputsByRuntime.set(runtimeId, [...(inputsByRuntime.get(runtimeId) ?? []), ...inputs]);
404
+ }
405
+ }
406
+ const documents = renderDocumentsForTarget(ctx, inputsByRuntime);
407
+ if (ctx.target !== "working") return { documents, warnings: [] };
408
+ return mergeCompanionOpenCodeConfig(ctx.companionPath, documents);
267
409
  }
@@ -0,0 +1,139 @@
1
+ // oxlint-disable no-await-in-loop
2
+ import path from "node:path";
3
+
4
+ import type { FrameworkConfig } from "../../lib/orchestrator/types";
5
+ import {
6
+ readContributionLedger,
7
+ type ContributionLedger,
8
+ type LedgerIdentities,
9
+ } from "./contribution-ledger";
10
+ import type { CapabilityPlugin, SetupContext } from "./plugin";
11
+ import type { PluginRegistry } from "./registry";
12
+ import { managedGuidanceOwners } from "./upgrade-sweep";
13
+
14
+ /**
15
+ * What `mate doctor` reports about audience isolation: contributions on the
16
+ * companion's persisted surfaces that no base plugin owns, audience plugins
17
+ * the upgrade sweep could not load, and the active audience's plugins whose
18
+ * imperative parts a launch layer cannot deliver.
19
+ */
20
+
21
+ export interface LeakedContribution {
22
+ /** Companion-relative file or directory. */
23
+ surface: string;
24
+ kind: string;
25
+ /** The entry's name; absent where the surface entry is the owner's block. */
26
+ identity?: string;
27
+ owner: string;
28
+ }
29
+
30
+ export interface UndeliveredAudiencePlugin {
31
+ pluginId: string;
32
+ /** `apply`, `teardown`, `forProvider` or `pluginReferences`. */
33
+ parts: string[];
34
+ }
35
+
36
+ export interface AudienceIsolationReport {
37
+ leaked: LeakedContribution[];
38
+ unswept: string[];
39
+ undelivered: UndeliveredAudiencePlugin[];
40
+ }
41
+
42
+ /** Ledger kinds by where each runtime keeps them; guidance is read from the files themselves. */
43
+ const LEDGER_SURFACES: Record<string, Partial<Record<keyof LedgerIdentities, string>>> = {
44
+ claude: {
45
+ mcpServers: ".mcp.json",
46
+ skillTrees: path.join(".claude", "skills"),
47
+ agentDefinitions: path.join(".claude", "agents"),
48
+ hookMarkers: path.join(".claude", "settings.local.json"),
49
+ permissionEntries: path.join(".claude", "settings.local.json"),
50
+ },
51
+ opencode: {
52
+ mcpServers: path.join(".opencode", "opencode.json"),
53
+ pluginReferences: path.join(".opencode", "opencode.json"),
54
+ skillTrees: path.join(".agents", "skills"),
55
+ agentDefinitions: path.join(".opencode", "agents"),
56
+ },
57
+ };
58
+
59
+ function leakedLedgerContributions(
60
+ ledger: ContributionLedger | null,
61
+ baseIds: ReadonlySet<string>,
62
+ ): LeakedContribution[] {
63
+ const leaked: LeakedContribution[] = [];
64
+ for (const [runtimeId, owners] of Object.entries(ledger?.runtimes ?? {})) {
65
+ for (const [owner, identities] of Object.entries(owners)) {
66
+ if (baseIds.has(owner)) continue;
67
+ for (const [kind, surface] of Object.entries(LEDGER_SURFACES[runtimeId] ?? {})) {
68
+ for (const identity of identities[kind as keyof LedgerIdentities] ?? []) {
69
+ leaked.push({ surface: surface!, kind, identity, owner });
70
+ }
71
+ }
72
+ }
73
+ }
74
+ return leaked;
75
+ }
76
+
77
+ /**
78
+ * Whether a lifecycle hook does anything. `apply` and `teardown` are required
79
+ * members, so a declarative plugin carries empty ones; only a body counts.
80
+ */
81
+ function hasBody(hook: unknown): boolean {
82
+ if (typeof hook !== "function") return false;
83
+ const source = Function.prototype.toString.call(hook);
84
+ const open = source.indexOf("{");
85
+ const close = source.lastIndexOf("}");
86
+ if (open === -1 || close <= open) return true;
87
+ const body = source
88
+ .slice(open + 1, close)
89
+ .replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, "")
90
+ .trim();
91
+ return body.length > 0;
92
+ }
93
+
94
+ async function undeliveredParts(plugin: CapabilityPlugin, ctx: SetupContext): Promise<string[]> {
95
+ const parts: string[] = [];
96
+ if (hasBody(plugin.apply)) parts.push("apply");
97
+ if (hasBody(plugin.teardown)) parts.push("teardown");
98
+ if (plugin.forProvider && Object.keys(plugin.forProvider).length > 0) parts.push("forProvider");
99
+ try {
100
+ const byRuntime = (await plugin.getRuntimeContributions?.(ctx)) ?? {};
101
+ if (Object.values(byRuntime).some((runtime) => (runtime?.pluginReferences ?? []).length > 0)) {
102
+ parts.push("pluginReferences");
103
+ }
104
+ } catch {
105
+ /** A plugin whose declarations fail is the plugin checks' finding, not this one. */
106
+ }
107
+ return parts;
108
+ }
109
+
110
+ export async function inspectAudienceIsolation(
111
+ companionPath: string,
112
+ config: FrameworkConfig,
113
+ registry: PluginRegistry,
114
+ activeAudience: string | null,
115
+ ): Promise<AudienceIsolationReport> {
116
+ const baseIds = new Set(registry.baseRegistrations().map((entry) => entry.plugin.id));
117
+ const leaked: LeakedContribution[] = [];
118
+
119
+ for (const [surface, owners] of await managedGuidanceOwners(companionPath)) {
120
+ for (const owner of owners) {
121
+ if (!baseIds.has(owner)) leaked.push({ surface, kind: "guidance", owner });
122
+ }
123
+ }
124
+
125
+ const ledger = await readContributionLedger(companionPath);
126
+ leaked.push(...leakedLedgerContributions(ledger, baseIds));
127
+
128
+ const undelivered: UndeliveredAudiencePlugin[] = [];
129
+ if (activeAudience) {
130
+ const ctx: SetupContext = { companionPath, config, mode: "sync", activeProviders: [] };
131
+ for (const { plugin } of registry.audienceRegistrations(activeAudience)) {
132
+ if (plugin.kind !== "capability") continue;
133
+ const parts = await undeliveredParts(plugin as CapabilityPlugin, ctx);
134
+ if (parts.length > 0) undelivered.push({ pluginId: plugin.id, parts });
135
+ }
136
+ }
137
+
138
+ return { leaked, unswept: ledger?.unswept ?? [], undelivered };
139
+ }
@@ -81,7 +81,7 @@ A \`diff\` section carries a unified diff patch as produced by \`git diff\`. The
81
81
  { "id": "changes", "title": "Changes", "type": "diff", "patch": "diff --git a/src/acme.ts b/src/acme.ts\\n..." }
82
82
  \`\`\`
83
83
 
84
- A report carrying a mermaid payload inlines the diagram runtime; a report without one does not. Either way the page stays a single self-contained file that makes no network request.
84
+ A report carrying a mermaid payload loads the pinned, integrity-checked diagram runtime from a CDN; a report without one does not. Offline, each diagram stays readable as its source. Otherwise the page stays a single self-contained file.
85
85
 
86
86
  ## Invocation
87
87
 
@@ -7,9 +7,10 @@ export type SetupScope = "companion" | "hub";
7
7
  /**
8
8
  * Where a reconcile pass lands its documents. `companion` writes the material's
9
9
  * own repository; `working` renders the documents an Agent Runtime discovers by
10
- * its own upward walk and leaves the placing to the Projection Root.
10
+ * its own upward walk and leaves the placing to the Projection Root;
11
+ * `audience-layer` writes one audience's launch layer, outside every repository.
11
12
  */
12
- export type ProjectionTarget = "companion" | "working";
13
+ export type ProjectionTarget = "companion" | "working" | "audience-layer";
13
14
 
14
15
  export interface SetupContext {
15
16
  companionPath: string;
@@ -22,6 +23,14 @@ export interface SetupContext {
22
23
  repoPath?: string;
23
24
  /** Defaults to `companion`, so a caller that names no target is unchanged. */
24
25
  target?: ProjectionTarget;
26
+ /** The audience an `audience-layer` pass renders; required by that target only. */
27
+ audience?: string;
28
+ /**
29
+ * Where an `audience-layer` pass writes: the staging directory a render is
30
+ * built in before it is renamed into place. The audience's layer directory
31
+ * when absent.
32
+ */
33
+ layerRoot?: string;
25
34
  /**
26
35
  * Provider-mediated context services. Always populated when the engine
27
36
  * executes a plugin; optional so hand-built contexts (tests, direct calls)
@@ -122,9 +131,15 @@ export type PluginPolicy = "required" | "default" | "optional";
122
131
 
123
132
  /**
124
133
  * A plugin registration entry. Bare plugins derive their policy from
125
- * `defaultSelected` (`true` → "default", `false` → "optional").
134
+ * `defaultSelected` (`true` → "default", `false` → "optional"). `audience` is
135
+ * the source of a declared plugin: `null` (or absent) for compiled-in plugins
136
+ * and top-level `plugins` declarations, the audience name for a declaration
137
+ * under `audiences.<name>.plugins`. `packageName` names the declared package a
138
+ * dynamic plugin was loaded from.
126
139
  */
127
- export type PluginRegistration = Plugin | { plugin: Plugin; policy: PluginPolicy };
140
+ export type PluginRegistration =
141
+ | Plugin
142
+ | { plugin: Plugin; policy: PluginPolicy; audience?: string | null; packageName?: string };
128
143
 
129
144
  /**
130
145
  * A hook group a Capability contributes to a runtime's settings. `marker` is
@@ -97,7 +97,13 @@ export function createGitignorePlugin(
97
97
  ): Plugin {
98
98
  // Resolve lazily so plugin sets can be built before a distribution is active.
99
99
  const resolveName = typeof frameworkName === "function" ? frameworkName : () => frameworkName;
100
- const resolvePlugins = allPlugins ?? (() => getActiveDistribution().registry.getAll());
100
+ /** Base only: an audience plugin's entries would reach the committed `.gitignore`. */
101
+ const resolvePlugins =
102
+ allPlugins ??
103
+ (() =>
104
+ getActiveDistribution()
105
+ .registry.baseRegistrations()
106
+ .map((entry) => entry.plugin));
101
107
  return {
102
108
  id: "gitignore",
103
109
  kind: "root",
@@ -1,6 +1,8 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
 
4
+ import { hookGroupKey, type ClaudeHookGroup } from "../../../runtime/claude-hook-group";
5
+
4
6
  // Structurally matches `McpServerDescriptor` from ../plugin; declared here so
5
7
  // the format module stays dependency-free of the plugin contract (which
6
8
  // imports hook types from this module).
@@ -17,17 +19,11 @@ export interface McpEntryDescriptor {
17
19
  // groups are Mate-managed lives here too, because the companion-side reconcile
18
20
  // and the working-repo projection entry must strip the same set.
19
21
 
20
- export interface ClaudeHookCommand {
21
- type?: string;
22
- command?: string;
23
- args?: string[];
24
- timeout?: number;
25
- }
26
-
27
- export interface ClaudeHookGroup {
28
- matcher?: string;
29
- hooks?: ClaudeHookCommand[];
30
- }
22
+ export {
23
+ hookGroupKey,
24
+ type ClaudeHookCommand,
25
+ type ClaudeHookGroup,
26
+ } from "../../../runtime/claude-hook-group";
31
27
 
32
28
  export type ClaudeHookMap = Record<string, ClaudeHookGroup[]>;
33
29
 
@@ -43,10 +39,6 @@ export interface ClaudeMcpConfig {
43
39
  [key: string]: unknown;
44
40
  }
45
41
 
46
- function hookGroupKey(group: ClaudeHookGroup): string {
47
- return JSON.stringify(group);
48
- }
49
-
50
42
  /** Append incoming hook groups per event, skipping structurally identical ones. */
51
43
  export function mergeClaudeHookGroups(
52
44
  existingHooks: ClaudeHookMap,
@@ -84,9 +76,10 @@ export function filterClaudeHookGroups(
84
76
 
85
77
  // Command substrings that mark a hook group as Mate-managed. The mate plugin
86
78
  // hooks (validate-artifact-path, mate-session-banner) now ship in the bundled
87
- // Claude plugin, and the retired mate-artifact-finish.sh archive hook is gone
88
- // entirely; their markers are retained migration-only so stale managed groups
89
- // written by earlier releases keep being stripped, and are never re-added.
79
+ // Claude plugin, and the retired mate-artifact-finish.sh archive hook and
80
+ // react-doctor.sh post-edit scan are gone entirely; their markers are retained
81
+ // migration-only so stale managed groups written by earlier releases keep being
82
+ // stripped, and are never re-added.
90
83
  const MANAGED_HOOK_MARKERS = [
91
84
  "validate-artifact-path",
92
85
  "mate-session-banner",
@@ -3,7 +3,8 @@ import path from "node:path";
3
3
 
4
4
  import { PROJECTED_BANNER_FLAG } from "../../../hooks/session-banner";
5
5
  import { getClaudePluginRoot } from "../../../lib/package-paths";
6
- import type { ClaudeHookGroup } from "./claude-format";
6
+ import { runtimeClaudePluginRoot } from "../../../runtime/runtime-directory";
7
+ import { hookGroupKey, type ClaudeHookGroup } from "./claude-format";
7
8
 
8
9
  /**
9
10
  * The bundled Claude plugin's hook wiring, read as a value. A managed launch
@@ -17,11 +18,16 @@ import type { ClaudeHookGroup } from "./claude-format";
17
18
  * environment is present and so cannot double the prompt the launch already
18
19
  * appended; and the banner, whose carried copy is marked so it defers to the
19
20
  * launch's.
21
+ *
22
+ * The wiring is read from the installed plugin, but every command names
23
+ * `<mate home>/runtime/claude-plugin`, so a reinstall or Node switch does not
24
+ * strand a wrapped repository.
20
25
  */
21
26
 
22
27
  const GUARD_HOOK_SHIM = "validate-artifact-path.mjs";
23
28
  const GUIDANCE_HOOK_SHIM = "session-guidance.mjs";
24
29
  const BANNER_HOOK_SHIM = "session-banner.mjs";
30
+ const PREPARE_HOOK_SHIM = "session-prepare.mjs";
25
31
  const PLUGIN_ROOT_TOKEN = "${CLAUDE_PLUGIN_ROOT}";
26
32
 
27
33
  interface PluginHookWiring {
@@ -50,26 +56,24 @@ function resolveCommands(group: ClaudeHookGroup, pluginRoot: string): ClaudeHook
50
56
  };
51
57
  }
52
58
 
53
- function runsShim(shim: string): (group: ClaudeHookGroup) => boolean {
54
- return (group) => (group.hooks ?? []).some((hook) => (hook.command ?? "").includes(shim));
59
+ function runsShim(group: ClaudeHookGroup, shim: string): boolean {
60
+ return (group.hooks ?? []).some((hook) => (hook.command ?? "").includes(shim));
55
61
  }
56
62
 
57
- function groupsFor(
58
- event: string,
59
- shim: string,
60
- pluginRoot: string = getClaudePluginRoot(),
61
- ): ClaudeHookGroup[] {
62
- return (readPluginHookWiring(pluginRoot).hooks?.[event] ?? [])
63
- .filter(runsShim(shim))
64
- .map((group) => resolveCommands(group, pluginRoot));
63
+ function groupsFor(event: string, shim: string, commandRoot: string): ClaudeHookGroup[] {
64
+ return (readPluginHookWiring(getClaudePluginRoot()).hooks?.[event] ?? []).flatMap((group) =>
65
+ runsShim(group, shim) ? [resolveCommands(group, commandRoot)] : [],
66
+ );
65
67
  }
66
68
 
67
69
  /**
68
70
  * The guard's `PreToolUse` groups with `${CLAUDE_PLUGIN_ROOT}` resolved against
69
- * the running installation, so the command works with no launch environment.
71
+ * the runtime link, so the command works with no launch environment.
70
72
  */
71
- export function mateGuardHookGroups(pluginRoot: string = getClaudePluginRoot()): ClaudeHookGroup[] {
72
- return groupsFor("PreToolUse", GUARD_HOOK_SHIM, pluginRoot);
73
+ export function mateGuardHookGroups(
74
+ commandRoot: string = runtimeClaudePluginRoot(),
75
+ ): ClaudeHookGroup[] {
76
+ return groupsFor("PreToolUse", GUARD_HOOK_SHIM, commandRoot);
73
77
  }
74
78
 
75
79
  /**
@@ -79,9 +83,9 @@ export function mateGuardHookGroups(pluginRoot: string = getClaudePluginRoot()):
79
83
  * flag.
80
84
  */
81
85
  export function mateGuidanceHookGroups(
82
- pluginRoot: string = getClaudePluginRoot(),
86
+ commandRoot: string = runtimeClaudePluginRoot(),
83
87
  ): ClaudeHookGroup[] {
84
- return groupsFor("SessionStart", GUIDANCE_HOOK_SHIM, pluginRoot);
88
+ return groupsFor("SessionStart", GUIDANCE_HOOK_SHIM, commandRoot);
85
89
  }
86
90
 
87
91
  /**
@@ -91,9 +95,9 @@ export function mateGuidanceHookGroups(
91
95
  * would print the banner twice.
92
96
  */
93
97
  export function mateBannerHookGroups(
94
- pluginRoot: string = getClaudePluginRoot(),
98
+ commandRoot: string = runtimeClaudePluginRoot(),
95
99
  ): ClaudeHookGroup[] {
96
- return groupsFor("SessionStart", BANNER_HOOK_SHIM, pluginRoot).map((group) => ({
100
+ return groupsFor("SessionStart", BANNER_HOOK_SHIM, commandRoot).map((group) => ({
97
101
  ...group,
98
102
  hooks: (group.hooks ?? []).map((hook) => ({
99
103
  ...hook,
@@ -102,16 +106,49 @@ export function mateBannerHookGroups(
102
106
  }));
103
107
  }
104
108
 
109
+ /**
110
+ * The session-prepare `SessionStart` group, resolved the same way. Its declared
111
+ * `timeout` sits above the shim's own bound, so Claude Code never kills a
112
+ * prepare the shim has not given up on. Inert under a launch environment.
113
+ */
114
+ export function mateSessionPrepareHookGroups(
115
+ commandRoot: string = runtimeClaudePluginRoot(),
116
+ ): ClaudeHookGroup[] {
117
+ return groupsFor("SessionStart", PREPARE_HOOK_SHIM, commandRoot);
118
+ }
119
+
120
+ function mateOwnHookGroups(commandRoot: string): ClaudeHookGroup[] {
121
+ return [
122
+ ...mateGuardHookGroups(commandRoot),
123
+ ...mateGuidanceHookGroups(commandRoot),
124
+ ...mateBannerHookGroups(commandRoot),
125
+ ...mateSessionPrepareHookGroups(commandRoot),
126
+ ];
127
+ }
128
+
129
+ const ownGroupKeys = new Map<string, ReadonlySet<string>>();
130
+
131
+ /** Memoized per plugin-root pair; hooks are short-lived, so a refresh mid-process is not tracked. */
132
+ function mateOwnHookGroupKeys(runtimeRoot: string, installRoot: string): ReadonlySet<string> {
133
+ const memoKey = `${runtimeRoot}\0${installRoot}`;
134
+ let keys = ownGroupKeys.get(memoKey);
135
+ if (!keys) {
136
+ keys = new Set(
137
+ [...mateOwnHookGroups(runtimeRoot), ...mateOwnHookGroups(installRoot)].map(hookGroupKey),
138
+ );
139
+ ownGroupKeys.set(memoKey, keys);
140
+ }
141
+ return keys;
142
+ }
143
+
105
144
  /**
106
145
  * Whether a hook group is one the working target placed. It matches a managed
107
146
  * marker, so the entry that strips managed groups from a Working Repository has
108
- * to be told to leave these alone.
147
+ * to be told to leave these alone. Both the runtime-link form and the
148
+ * install-path form an older release wrote are recognized.
109
149
  */
110
150
  export function isMateGuardHookGroup(group: ClaudeHookGroup): boolean {
111
- const own = new Set(
112
- [...mateGuardHookGroups(), ...mateGuidanceHookGroups(), ...mateBannerHookGroups()].map(
113
- (candidate) => JSON.stringify(candidate),
114
- ),
151
+ return mateOwnHookGroupKeys(runtimeClaudePluginRoot(), getClaudePluginRoot()).has(
152
+ hookGroupKey(group),
115
153
  );
116
- return own.has(JSON.stringify(group));
117
154
  }