@uniqbit/mate-core 0.18.3 → 0.18.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/opencode-plugin/src/add-dir.ts +10 -76
  2. package/opencode-plugin/src/audience-layer.ts +91 -0
  3. package/opencode-plugin/src/companion.ts +28 -11
  4. package/opencode-plugin/src/launch-environment.ts +29 -0
  5. package/opencode-plugin/src/server.ts +25 -8
  6. package/package.json +1 -1
  7. package/src/cli/commands/cap/index-cmd.ts +2 -11
  8. package/src/cli/commands/doctor.ts +86 -4
  9. package/src/cli/commands/install.ts +8 -0
  10. package/src/cli/commands/studio/terminal-launch.ts +16 -7
  11. package/src/cli/commands/wrap.ts +29 -4
  12. package/src/cli/usage.ts +2 -1
  13. package/src/hooks/session-guidance.ts +20 -2
  14. package/src/lib/orchestrator/adapters/base.ts +3 -2
  15. package/src/lib/orchestrator/adapters/claude.ts +130 -20
  16. package/src/lib/orchestrator/adapters/opencode.ts +55 -15
  17. package/src/lib/orchestrator/config-store.ts +74 -1
  18. package/src/lib/orchestrator/openspec-store.ts +202 -0
  19. package/src/lib/orchestrator/projection-claude-skills.ts +88 -35
  20. package/src/lib/orchestrator/projection-entries.ts +46 -0
  21. package/src/lib/orchestrator/projection-openspec-store.ts +164 -0
  22. package/src/lib/orchestrator/projection-types.ts +9 -1
  23. package/src/lib/orchestrator/session-prepare.ts +82 -4
  24. package/src/lib/orchestrator/working-repo-projection.ts +2 -1
  25. package/src/lib/orchestrator/wrap-pass.ts +92 -1
  26. package/src/opencode/companion-hooks.ts +7 -4
  27. package/src/opencode/projected-guidance.ts +6 -13
  28. package/src/runtime/audience-layer.ts +84 -0
  29. package/src/runtime/guidance.ts +17 -0
  30. package/src/runtime/opencode-audience-layer.ts +176 -0
  31. package/src/runtime/policy.ts +14 -1
  32. package/src/runtime/projection.ts +1 -1
  33. package/src/runtime/runtime-document-manifest.ts +17 -0
  34. package/src/runtime/wrap-state.ts +4 -0
  35. package/src/tools/setup/audience-isolation.fixture.ts +325 -0
  36. package/src/tools/setup/audience-layer.ts +207 -0
  37. package/src/tools/setup/audience-registrations.ts +52 -0
  38. package/src/tools/setup/capabilities/openspec.ts +9 -0
  39. package/src/tools/setup/capabilities/tokensave.ts +11 -0
  40. package/src/tools/setup/contribution-ledger.ts +207 -0
  41. package/src/tools/setup/dynamic-plugins/audiences.ts +89 -9
  42. package/src/tools/setup/dynamic-plugins/declarations.ts +3 -2
  43. package/src/tools/setup/dynamic-plugins/hydrate.ts +7 -4
  44. package/src/tools/setup/engine.ts +141 -10
  45. package/src/tools/setup/isolation-diagnostics.ts +139 -0
  46. package/src/tools/setup/plugin.ts +19 -4
  47. package/src/tools/setup/plugins/gitignore.ts +7 -1
  48. package/src/tools/setup/providers/claude.ts +27 -2
  49. package/src/tools/setup/providers/opencode.ts +10 -0
  50. package/src/tools/setup/registry.ts +24 -2
  51. package/src/tools/setup/surface-target.ts +13 -4
  52. package/src/tools/setup/upgrade-sweep.ts +153 -0
  53. package/src/tools/setup/working-repo-cleanup.ts +2 -0
  54. package/src/tools/setup/working-repo-local-state.ts +9 -1
  55. package/src/tools/setup.ts +9 -6
  56. package/wrappers/bin/openspec +42 -1
@@ -7,8 +7,14 @@ import {
7
7
  syncCompanionUnattended,
8
8
  type UnattendedSyncOutcome,
9
9
  } from "../../runtime/companion-sync";
10
- import type { WrapState } from "../../runtime/wrap-state";
10
+ import { readWrapStateSync, type WrapState } from "../../runtime/wrap-state";
11
11
  import { syncCompanionFiles } from "../../tools/setup";
12
+ import { renderAudienceLayer, type RenderedAudienceLayer } from "../../tools/setup/audience-layer";
13
+ import {
14
+ readAudienceSelection,
15
+ resolveAudience,
16
+ } from "../../tools/setup/dynamic-plugins/audiences";
17
+ import { buildSetupInstallationPlan } from "../../tools/setup/engine";
12
18
  import type { CapabilityPlugin, LaunchPreflightContext } from "../../tools/setup/plugin";
13
19
  import { attendedLockOptions, syncCompanionGit } from "./companion-git-sync";
14
20
  import { readCompanionConfig } from "./config-store";
@@ -34,7 +40,8 @@ export type PrepareStepName =
34
40
  | "file-sync"
35
41
  | "launch-projection"
36
42
  | "working-target"
37
- | "preflight";
43
+ | "preflight"
44
+ | "audience-layer";
38
45
 
39
46
  export interface PrepareStep {
40
47
  step: PrepareStepName;
@@ -61,6 +68,8 @@ export interface PrepareResult {
61
68
  failure?: unknown;
62
69
  /** The wrap state the `working-target` step's pass left; a launch reads no manifest itself. */
63
70
  wrapState?: WrapState;
71
+ /** The active audience's layer, rendered for an attended prepare only. */
72
+ audienceLayer?: RenderedAudienceLayer;
64
73
  }
65
74
 
66
75
  export interface PrepareSessionInput {
@@ -89,9 +98,10 @@ const SYNC_LOCK_TIMEOUT_MS = 5 * 60_000;
89
98
  */
90
99
  async function runCapabilityPreflight(context: LaunchPreflightContext): Promise<void> {
91
100
  const { config, providerId } = context;
101
+ /** Base only: an audience plugin's `forProvider` hooks never run. */
92
102
  const preflights = getActiveDistribution()
93
- .registry.getAll()
94
- .flatMap((plugin) => {
103
+ .registry.baseRegistrations()
104
+ .flatMap(({ plugin }) => {
95
105
  if (plugin.kind !== "capability" || !plugin.isEnabled(config)) return [];
96
106
  const capability = plugin as CapabilityPlugin;
97
107
  const preflight = capability.forProvider?.[providerId]?.preflight;
@@ -137,6 +147,11 @@ export const sessionPrepareDeps = {
137
147
  withCompanionLock,
138
148
  /** As the launch decides it: Git may prompt only with a terminal on both ends. */
139
149
  isInteractiveTerminal: () => !!(process.stdin.isTTY && process.stdout.isTTY),
150
+ /** The launch's `MATE_AUDIENCE`; read by an attended prepare only. */
151
+ audienceSelection: () => readAudienceSelection(process.env),
152
+ /** The audience a checkout is wrapped for, from its manifest; `null` for base. */
153
+ wrappedAudience: (repoPath: string) => readWrapStateSync(repoPath).audience,
154
+ renderAudienceLayer,
140
155
  };
141
156
 
142
157
  function message(error: unknown): string {
@@ -149,6 +164,7 @@ class StepLog {
149
164
  readonly warnings: string[] = [];
150
165
  failure: unknown;
151
166
  wrapState: WrapState | undefined;
167
+ audienceLayer: RenderedAudienceLayer | undefined;
152
168
  stopped = false;
153
169
  companionChanged = false;
154
170
 
@@ -321,12 +337,71 @@ async function runLocal(
321
337
  });
322
338
  }
323
339
 
340
+ /**
341
+ * The active audience's launch layer, after the base prepare. Only a managed
342
+ * launch reaches here: a bare session's prepare never renders one, whatever
343
+ * `MATE_AUDIENCE` its environment carries. An audience the companion does not
344
+ * declare renders nothing; hydration already warned about it.
345
+ */
346
+ async function renderLayer(
347
+ input: PrepareSessionInput,
348
+ config: FrameworkConfig,
349
+ run: StepLog,
350
+ ): Promise<void> {
351
+ const deps = sessionPrepareDeps;
352
+ /** Adopted from the wrap: the wrap delivers it, so the launch renders no layer of its own. */
353
+ const wrapped = input.repository ? deps.wrappedAudience(input.repository.path) : null;
354
+ if (wrapped) {
355
+ run.push({ step: "audience-layer", outcome: "ok", detail: `${wrapped}: from the wrap` });
356
+ return;
357
+ }
358
+ const audience = resolveAudience(config, deps.audienceSelection());
359
+ if (!audience.ok || audience.active === null) return;
360
+ try {
361
+ const registry = getActiveDistribution().registry;
362
+ const layer = await deps.renderAudienceLayer({
363
+ companionPath: input.companionPath,
364
+ config,
365
+ audience: audience.active,
366
+ activeProviders: buildSetupInstallationPlan(config, registry.baseRegistrations())
367
+ .activeProviders,
368
+ registrations: registry.audienceRegistrations(audience.active),
369
+ });
370
+ run.audienceLayer = layer;
371
+ run.push({
372
+ step: "audience-layer",
373
+ outcome: "ok",
374
+ detail: `${layer.audience}: ${layer.rendered ? "rendered" : "current"}`,
375
+ });
376
+ } catch (error) {
377
+ run.fail("audience-layer", error);
378
+ }
379
+ }
380
+
381
+ /**
382
+ * A checkout wrapped for one audience refuses a launch for another before
383
+ * anything runs: the wrap's on-disk content would reach that session.
384
+ */
385
+ function audienceConflict(input: PrepareSessionInput, config: FrameworkConfig): string | null {
386
+ if (!input.repository) return null;
387
+ const wrapped = sessionPrepareDeps.wrappedAudience(input.repository.path);
388
+ if (!wrapped) return null;
389
+ const selected = resolveAudience(config, sessionPrepareDeps.audienceSelection());
390
+ if (!selected.ok || selected.active === null || selected.active === wrapped) return null;
391
+ return `${FRAMEWORK_NAME}: ${input.repository.id} is wrapped for audience "${wrapped}", but MATE_AUDIENCE selects "${selected.active}". Run \`${FRAMEWORK_NAME} wrap --audience ${selected.active}\` in ${input.repository.path}, or launch from another worktree wrapped for "${selected.active}".`;
392
+ }
393
+
324
394
  async function runAttended(
325
395
  input: PrepareSessionInput,
326
396
  config: FrameworkConfig,
327
397
  run: StepLog,
328
398
  ): Promise<void> {
329
399
  const deps = sessionPrepareDeps;
400
+ const conflict = audienceConflict(input, config);
401
+ if (conflict) {
402
+ run.fail("audience-layer", new LaunchPreflightError(conflict));
403
+ return;
404
+ }
330
405
  if (input.skipGit) {
331
406
  run.push({ step: "git-sync", outcome: "skipped", detail: "--no-git" });
332
407
  } else if (config.git !== "auto") {
@@ -347,6 +422,8 @@ async function runAttended(
347
422
  }
348
423
  }
349
424
  await runLocal(input, config, run);
425
+ if (run.stopped) return;
426
+ await renderLayer(input, config, run);
350
427
  }
351
428
 
352
429
  /** The sync's own config read: only a fast-forward can have changed it. */
@@ -401,6 +478,7 @@ export async function prepareSession(input: PrepareSessionInput): Promise<Prepar
401
478
  ...(run.companionChanged ? { companionChanged: true as const } : {}),
402
479
  ...(run.failure === undefined ? {} : { failure: run.failure }),
403
480
  ...(run.wrapState ? { wrapState: run.wrapState } : {}),
481
+ ...(run.audienceLayer ? { audienceLayer: run.audienceLayer } : {}),
404
482
  });
405
483
 
406
484
  const locked = await deps.withCompanionLock(input.companionPath, lockOptions(input), async () => {
@@ -120,7 +120,8 @@ export async function unproject(input: ProjectionRemovalInput): Promise<Projecti
120
120
  }
121
121
  try {
122
122
  // oxlint-disable-next-line no-await-in-loop -- reverse catalogue order is the contract
123
- outcomes.push(outcome(entry, await removal.remove(input)));
123
+ const removed = outcome(entry, await removal.remove(input));
124
+ outcomes.push(removal.retains ? { ...removed, retains: removal.retains } : removed);
124
125
  } catch (error) {
125
126
  outcomes.push(outcome(entry, "failed", toError(error)));
126
127
  }
@@ -1,11 +1,24 @@
1
1
  import path from "node:path";
2
2
 
3
3
  import { renderWorkingRuntimeDocuments } from "../../tools/setup";
4
+ import { renderAudienceLayer } from "../../tools/setup/audience-layer";
5
+ import { loadAudienceRegistrations } from "../../tools/setup/audience-registrations";
6
+ import { declaredAudiences } from "../../tools/setup/dynamic-plugins/audiences";
7
+ import { buildSetupInstallationPlan } from "../../tools/setup/engine";
8
+ import { getActiveDistribution } from "../../distribution";
9
+ import { audienceLayerDir } from "../../runtime/audience-layer";
10
+ import {
11
+ readManifest,
12
+ recordedAudience,
13
+ updateManifest,
14
+ withAudience,
15
+ } from "../../runtime/runtime-document-manifest";
4
16
  import { readCompanionConfig } from "./config-store";
5
17
  import { GlobalConfigStore } from "./global-config-store";
6
18
  import { entryFailureMessage, firstFailure, type ProjectionChangedPaths } from "./projection-types";
7
19
  import type { WrapRuntime } from "../../runtime/runtime-document-manifest";
8
20
  import { readWrapState, type WrapState } from "../../runtime/wrap-state";
21
+ import type { NormalizedRegistration } from "../../tools/setup/registry";
9
22
  import type { FrameworkConfig, LinkedRepository } from "./types";
10
23
  import { project } from "./working-repo-projection";
11
24
 
@@ -13,6 +26,8 @@ export interface WrapPassDependencies {
13
26
  renderWorkingRuntimeDocuments: typeof renderWorkingRuntimeDocuments;
14
27
  readCompanionConfig: typeof readCompanionConfig;
15
28
  project: typeof project;
29
+ loadAudienceRegistrations: typeof loadAudienceRegistrations;
30
+ renderAudienceLayer: typeof renderAudienceLayer;
16
31
  /** The registered companions, whose stale skill links a re-pin withdraws; the user's registry when absent. */
17
32
  globalConfigStore?: GlobalConfigStore;
18
33
  }
@@ -21,6 +36,8 @@ export const wrapPassDeps: WrapPassDependencies = {
21
36
  renderWorkingRuntimeDocuments,
22
37
  readCompanionConfig,
23
38
  project,
39
+ loadAudienceRegistrations,
40
+ renderAudienceLayer,
24
41
  };
25
42
 
26
43
  export interface WrapPassOptions {
@@ -28,6 +45,12 @@ export interface WrapPassOptions {
28
45
  runtimes?: readonly WrapRuntime[];
29
46
  /** The companion config the caller already read; read from files otherwise. */
30
47
  config?: FrameworkConfig;
48
+ /**
49
+ * The audience to wrap the checkout for: a name records it, `null` withdraws
50
+ * it, absent keeps the one the manifest records. Never read from the
51
+ * environment: it is the only source of a bare session's audience.
52
+ */
53
+ audience?: string | null;
31
54
  }
32
55
 
33
56
  export type WrapPassResult =
@@ -47,9 +70,21 @@ export type WrapPassResult =
47
70
  runtimes: ReadonlySet<WrapRuntime>;
48
71
  /** The wrap state as this pass left it. */
49
72
  wrapState: WrapState;
73
+ /** The audience the checkout is wrapped for after this pass. */
74
+ audience: string | null;
50
75
  }
51
76
  | { kind: "failed"; message: string };
52
77
 
78
+ /** An audience a checkout may be wrapped for: declared by the companion, never the reserved base. */
79
+ function wrappableAudience(config: FrameworkConfig, audience: string): boolean {
80
+ return audience !== "base" && declaredAudiences(config).includes(audience);
81
+ }
82
+
83
+ function describeUnwrappable(config: FrameworkConfig, audience: string): string {
84
+ const declared = declaredAudiences(config).filter((name) => name !== "base");
85
+ return `audience "${audience}" cannot be wrapped: the companion declares ${declared.length > 0 ? declared.join(", ") : "no audiences"}.`;
86
+ }
87
+
53
88
  /**
54
89
  * One pass of the `wrap` scope, restricted to the requested runtimes by each
55
90
  * entry's runtime tag. Best-effort per entry like every projection: a failed
@@ -63,10 +98,50 @@ export async function runWrapPass(
63
98
  ): Promise<WrapPassResult> {
64
99
  const resolvedCompanionPath = path.resolve(companionPath);
65
100
  const config = options.config ?? (await deps.readCompanionConfig(resolvedCompanionPath));
101
+
102
+ const audienceWarnings: string[] = [];
103
+ let audience: string | null;
104
+ if (options.audience === undefined) {
105
+ const recorded = recordedAudience(await readManifest(repository.path));
106
+ /** A recorded audience the companion no longer declares falls back to base, never fails a prepare. */
107
+ if (recorded && !wrappableAudience(config, recorded)) {
108
+ audienceWarnings.push(
109
+ `${describeUnwrappable(config, recorded)} ${repository.id} is wrapped for base only.`,
110
+ );
111
+ audience = null;
112
+ } else {
113
+ audience = recorded;
114
+ }
115
+ } else {
116
+ audience = options.audience;
117
+ if (audience && !wrappableAudience(config, audience)) {
118
+ return { kind: "failed", message: describeUnwrappable(config, audience) };
119
+ }
120
+ }
121
+
122
+ let wrapped: { audience: string; registrations: NormalizedRegistration[] } | undefined;
123
+ if (audience) {
124
+ const loaded = await deps.loadAudienceRegistrations(resolvedCompanionPath, config, audience);
125
+ audienceWarnings.push(...loaded.warnings);
126
+ await deps.renderAudienceLayer({
127
+ companionPath: resolvedCompanionPath,
128
+ config,
129
+ audience,
130
+ activeProviders: buildSetupInstallationPlan(
131
+ config,
132
+ getActiveDistribution().registry.baseRegistrations(),
133
+ ).activeProviders,
134
+ registrations: loaded.registrations,
135
+ });
136
+ wrapped = { audience, registrations: loaded.registrations };
137
+ }
138
+
66
139
  const rendered = await deps.renderWorkingRuntimeDocuments(
67
140
  resolvedCompanionPath,
68
141
  config,
69
142
  repository.path,
143
+ undefined,
144
+ wrapped,
70
145
  );
71
146
  const result = await deps.project("wrap", {
72
147
  repoPath: repository.path,
@@ -74,6 +149,7 @@ export async function runWrapPass(
74
149
  repository,
75
150
  config,
76
151
  runtimeDocuments: rendered.documents,
152
+ audienceLayerDir: audience ? audienceLayerDir(resolvedCompanionPath, audience) : null,
77
153
  globalConfigStore: deps.globalConfigStore ?? new GlobalConfigStore(),
78
154
  ...(options.runtimes ? { runtimes: options.runtimes } : {}),
79
155
  });
@@ -81,6 +157,12 @@ export async function runWrapPass(
81
157
  const failure = firstFailure(result.outcomes);
82
158
  if (failure) return { kind: "failed", message: entryFailureMessage(failure, repository.id) };
83
159
 
160
+ const before = recordedAudience(await readManifest(repository.path));
161
+ const audienceChanged = before !== audience;
162
+ if (audienceChanged) {
163
+ await updateManifest(repository.path, (manifest) => withAudience(manifest, audience));
164
+ }
165
+
84
166
  /** Read once under the caller's lock, so it is exactly what the pass recorded. */
85
167
  let wrapState: WrapState;
86
168
  try {
@@ -112,7 +194,10 @@ export async function runWrapPass(
112
194
  .map((outcome) => outcome.path);
113
195
 
114
196
  return {
115
- kind: result.outcomes.some((outcome) => outcome.state === "written") ? "written" : "current",
197
+ kind:
198
+ audienceChanged || result.outcomes.some((outcome) => outcome.state === "written")
199
+ ? "written"
200
+ : "current",
116
201
  projectionRoot: result.root,
117
202
  companionPath: resolvedCompanionPath,
118
203
  documents: [...new Set(documents)],
@@ -122,10 +207,16 @@ export async function runWrapPass(
122
207
  removed: result.outcomes.flatMap((outcome) => outcome.changed?.removed ?? []),
123
208
  },
124
209
  warnings: [
210
+ ...audienceWarnings,
125
211
  ...rendered.warnings,
126
212
  ...result.outcomes.flatMap((outcome) => outcome.warnings ?? []),
213
+ /** A degradable failure does not fail the pass, but it is never silent. */
214
+ ...result.outcomes
215
+ .filter((outcome) => outcome.state === "failed" && outcome.degradable)
216
+ .map((outcome) => entryFailureMessage(outcome, repository.id)),
127
217
  ],
128
218
  runtimes,
129
219
  wrapState,
220
+ audience,
130
221
  };
131
222
  }
@@ -99,6 +99,7 @@ export async function guardToolInput(
99
99
  context: CompanionContext,
100
100
  tool: string,
101
101
  input: unknown,
102
+ env: Record<string, string | undefined> = process.env,
102
103
  ): Promise<void> {
103
104
  const args = (input ?? {}) as Record<string, unknown>;
104
105
  const filePaths =
@@ -113,25 +114,27 @@ export async function guardToolInput(
113
114
  throw new Error(buildArtifactError(context, filePath));
114
115
  }
115
116
  // oxlint-disable-next-line no-await-in-loop -- the first refusal wins
116
- await refuseForkedCompanionWrite(context, filePath);
117
+ await refuseForkedCompanionWrite(context, filePath, env);
117
118
  }
118
119
  }
119
120
 
120
121
  /**
121
122
  * Registers the companion guardrails on an OpenCode V2 plugin context.
122
- * The caller resolves `context` and skips this when no companion resolved.
123
+ * The caller resolves `context` and skips this when no companion resolved;
124
+ * `env` is the launch environment the caller decided to trust.
123
125
  */
124
126
  export async function registerCompanionHooks(
125
127
  api: Context,
126
128
  context: CompanionContext,
129
+ env: Record<string, string | undefined> = process.env,
127
130
  ): Promise<void> {
128
131
  /**
129
132
  * Anything escaping the repair would cost the session every guardrail
130
133
  * below to save a synchronization that is optional by design.
131
134
  */
132
- await repairCompanionGitOnce(context, process.env, api.location.directory).catch(() => []);
135
+ await repairCompanionGitOnce(context, env, api.location.directory).catch(() => []);
133
136
 
134
137
  await api.tool.hook("execute.before", (event) =>
135
- guardToolInput(context, event.tool, event.input),
138
+ guardToolInput(context, event.tool, event.input, env),
136
139
  );
137
140
  }
@@ -1,14 +1,13 @@
1
1
  import { buildOpenCodeGuidance } from "../runtime/companion-guidance";
2
2
  import { MATE_ENV } from "../runtime/env-names";
3
- import { hasLaunchEnvironment } from "../runtime/env";
4
3
  import { parseGuidanceContent, type MateGuidanceFile } from "../runtime/guidance";
5
4
  import { readCompanionPolicy } from "../runtime/policy";
6
5
  import { resolveProjection } from "../runtime/projection";
7
6
 
8
7
  /**
9
8
  * `null` guidance with no errors is the inert case — no companion resolves, so
10
- * the plugin contributes nothing. Errors are startup failures: a managed launch
11
- * that failed to inject its payload is broken and must say so.
9
+ * the plugin contributes nothing. Errors are startup failures: a payload that
10
+ * does not parse, or projected guidance that cannot be built.
12
11
  */
13
12
  export interface OpenCodeGuidanceResolution {
14
13
  guidance: MateGuidanceFile | null;
@@ -26,9 +25,10 @@ export interface OpenCodeGuidanceResolution {
26
25
  * means a Capability toggled after a wrap changes the guidance on the next
27
26
  * session with no re-wrap.
28
27
  *
29
- * A missing payload while a launch environment *is* present stays a hard error.
30
- * That combination means a managed launch failed to inject, which the projection
31
- * must not paper over.
28
+ * Without a payload the projection decides, whatever other `MATE_*` variables
29
+ * `env` carries: those are inherited by every descendant of a Mate session and
30
+ * do not mark a managed launch. A present but malformed payload stays a hard
31
+ * error.
32
32
  */
33
33
  export function resolveOpenCodeGuidance(
34
34
  env: Record<string, string | undefined> = process.env,
@@ -40,13 +40,6 @@ export function resolveOpenCodeGuidance(
40
40
  return { guidance: errors.length > 0 ? null : guidance, errors };
41
41
  }
42
42
 
43
- if (hasLaunchEnvironment(env)) {
44
- return {
45
- guidance: null,
46
- errors: [`missing ${MATE_ENV.guidanceJson} in the launch environment`],
47
- };
48
- }
49
-
50
43
  const projection = resolveProjection(cwd);
51
44
  if (!projection) return { guidance: null, errors: [] };
52
45
 
@@ -0,0 +1,84 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ import { FRAMEWORK_NAME } from "./framework";
6
+ import { mateHome } from "./runtime-directory";
7
+
8
+ /**
9
+ * Audience Layer locations. A layer holds one audience's plugin contributions
10
+ * for one companion, rendered for managed launches only. It lives under Mate's
11
+ * machine-local state, never inside a repository: a layer inside the companion
12
+ * would be readable by every session with companion access, and could carry
13
+ * values resolved from plugin configuration.
14
+ *
15
+ * <state>/audience-layers/<companion-key>/<audience>/
16
+ * <fingerprint>/ one complete render; never changed once renamed into place
17
+ * current symlink to the newest complete render
18
+ *
19
+ * A launch attaches the fingerprint directory itself, so a later re-render
20
+ * never changes what a running session reads.
21
+ */
22
+
23
+ /** Mirrors `AUDIENCE_NAME_PATTERN`; repeated here because `runtime/` imports nothing from core. */
24
+ const SAFE_AUDIENCE = /^[a-z][a-z0-9-]{0,63}$/;
25
+
26
+ export const CURRENT_LAYER_LINK = "current";
27
+
28
+ export function audienceLayersRoot(homeDir?: string): string {
29
+ return path.join(mateHome(homeDir), "state", "audience-layers");
30
+ }
31
+
32
+ /** A short, stable key for one companion checkout: a hash of its resolved path. */
33
+ export function companionLayerKey(companionPath: string): string {
34
+ return crypto.createHash("sha256").update(path.resolve(companionPath)).digest("hex").slice(0, 16);
35
+ }
36
+
37
+ /** The directory holding one audience's layers for one companion. */
38
+ export function audienceLayerDir(
39
+ companionPath: string,
40
+ audience: string,
41
+ homeDir?: string,
42
+ ): string {
43
+ if (!SAFE_AUDIENCE.test(audience)) {
44
+ throw new Error(`"${audience}" is not a valid audience name for a layer directory`);
45
+ }
46
+ return path.join(audienceLayersRoot(homeDir), companionLayerKey(companionPath), audience);
47
+ }
48
+
49
+ /** The newest complete layer of `audience`, or `null` when none was rendered on this machine. */
50
+ export function currentAudienceLayer(
51
+ companionPath: string,
52
+ audience: string,
53
+ homeDir?: string,
54
+ ): string | null {
55
+ try {
56
+ const dir = audienceLayerDir(companionPath, audience, homeDir);
57
+ const layer = path.join(dir, fs.readlinkSync(path.join(dir, CURRENT_LAYER_LINK)));
58
+ return fs.existsSync(layer) ? layer : null;
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ const MANAGED_MARKER = new RegExp(
65
+ `^<!-- ${FRAMEWORK_NAME}:managed:[^\\n]* (?:start|end) -->$`,
66
+ "gm",
67
+ );
68
+
69
+ /** A layer guidance document's text, managed-block markers removed; empty when absent. */
70
+ export function layerGuidanceText(text: string): string {
71
+ return text
72
+ .replace(MANAGED_MARKER, "")
73
+ .replace(/\n{3,}/g, "\n\n")
74
+ .trim();
75
+ }
76
+
77
+ /** `file` (`CLAUDE.md`, `AGENTS.md`) of the layer at `root`, markers removed; empty when absent. */
78
+ export function readLayerGuidanceSync(root: string, file: string): string {
79
+ try {
80
+ return layerGuidanceText(fs.readFileSync(path.join(root, file), "utf8"));
81
+ } catch {
82
+ return "";
83
+ }
84
+ }
@@ -1,3 +1,5 @@
1
+ import path from "node:path";
2
+
1
3
  /**
2
4
  * Versioned companion guidance contract. The CLI builds the guidance data
3
5
  * from its playbooks at launch time and delivers it through the
@@ -16,6 +18,12 @@ export type MateGuidanceFile = {
16
18
  companionGuidance: string;
17
19
  codebaseExplorationGuidance: string;
18
20
  errors: string[];
21
+ /**
22
+ * The active audience's layer, for a launch that hosts its own server only.
23
+ * The plugin applies it only from a payload it trusts; a masked payload
24
+ * carries none, so the shared service and bare sessions never see one.
25
+ */
26
+ audienceLayer?: string;
19
27
  };
20
28
 
21
29
  export type GuidanceValidation = {
@@ -76,12 +84,21 @@ export function validateGuidanceData(value: unknown): GuidanceValidation {
76
84
  errors.push("injected codebase exploration guidance is malformed");
77
85
  }
78
86
 
87
+ const audienceLayer =
88
+ typeof value.audienceLayer === "string" && path.isAbsolute(value.audienceLayer)
89
+ ? value.audienceLayer
90
+ : undefined;
91
+ if (value.audienceLayer !== undefined && audienceLayer === undefined) {
92
+ errors.push("injected audience layer is not an absolute path");
93
+ }
94
+
79
95
  return {
80
96
  guidance: {
81
97
  version: GUIDANCE_FILE_VERSION,
82
98
  companionGuidance,
83
99
  codebaseExplorationGuidance,
84
100
  errors,
101
+ ...(audienceLayer ? { audienceLayer } : {}),
85
102
  },
86
103
  errors,
87
104
  };