@uniqbit/mate-core 0.18.4 → 0.18.5

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 (44) hide show
  1. package/opencode-plugin/src/add-dir.ts +76 -10
  2. package/opencode-plugin/src/companion.ts +1 -8
  3. package/opencode-plugin/src/server.ts +2 -15
  4. package/package.json +1 -1
  5. package/src/cli/commands/artifact/finish/openspec.ts +40 -8
  6. package/src/cli/commands/doctor.ts +92 -86
  7. package/src/cli/commands/install.ts +0 -8
  8. package/src/cli/commands/studio/terminal-launch.ts +7 -16
  9. package/src/cli/commands/wrap.ts +4 -29
  10. package/src/cli/usage.ts +1 -2
  11. package/src/hooks/session-guidance.ts +2 -20
  12. package/src/lib/orchestrator/adapters/claude.ts +20 -130
  13. package/src/lib/orchestrator/adapters/opencode.ts +7 -24
  14. package/src/lib/orchestrator/config-store.ts +2 -70
  15. package/src/lib/orchestrator/projection-claude-skills.ts +35 -88
  16. package/src/lib/orchestrator/projection-entries.ts +0 -1
  17. package/src/lib/orchestrator/projection-types.ts +0 -2
  18. package/src/lib/orchestrator/session-prepare.ts +4 -82
  19. package/src/lib/orchestrator/wrap-pass.ts +1 -88
  20. package/src/runtime/guidance.ts +0 -17
  21. package/src/runtime/policy.ts +1 -14
  22. package/src/runtime/runtime-document-manifest.ts +0 -17
  23. package/src/runtime/wrap-state.ts +0 -4
  24. package/src/tools/setup/capabilities/openspec.ts +140 -18
  25. package/src/tools/setup/dynamic-plugins/audiences.ts +7 -39
  26. package/src/tools/setup/dynamic-plugins/declarations.ts +1 -1
  27. package/src/tools/setup/dynamic-plugins/hydrate.ts +4 -7
  28. package/src/tools/setup/engine.ts +10 -141
  29. package/src/tools/setup/plugin.ts +4 -19
  30. package/src/tools/setup/plugins/gitignore.ts +1 -7
  31. package/src/tools/setup/providers/claude.ts +2 -27
  32. package/src/tools/setup/providers/opencode.ts +0 -10
  33. package/src/tools/setup/registry.ts +2 -24
  34. package/src/tools/setup/surface-target.ts +4 -13
  35. package/src/tools/setup.ts +6 -9
  36. package/opencode-plugin/src/audience-layer.ts +0 -91
  37. package/src/runtime/audience-layer.ts +0 -84
  38. package/src/runtime/opencode-audience-layer.ts +0 -176
  39. package/src/tools/setup/audience-isolation.fixture.ts +0 -325
  40. package/src/tools/setup/audience-layer.ts +0 -207
  41. package/src/tools/setup/audience-registrations.ts +0 -52
  42. package/src/tools/setup/contribution-ledger.ts +0 -207
  43. package/src/tools/setup/isolation-diagnostics.ts +0 -139
  44. package/src/tools/setup/upgrade-sweep.ts +0 -153
@@ -1,8 +1,15 @@
1
+ import fs from "node:fs/promises";
1
2
  import path from "node:path";
2
3
 
3
4
  import type { Context } from "@opencode/plugin/promise/plugin";
4
5
 
5
- import { loadSkillInfos } from "../../src/runtime/opencode-audience-layer";
6
+ type CompanionSkill = {
7
+ id: string;
8
+ name: string;
9
+ description?: string;
10
+ path: string;
11
+ content: string;
12
+ };
6
13
 
7
14
  function companionSkillPaths(companionPath: string): string[] {
8
15
  return [
@@ -16,28 +23,87 @@ function isWithin(root: string, candidate: string): boolean {
16
23
  return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
17
24
  }
18
25
 
26
+ function parseFrontmatter(content: string): { name?: string; description?: string } {
27
+ const match = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
28
+ if (!match) return {};
29
+
30
+ const scalar = (key: string) => {
31
+ const value = match[1].match(new RegExp(`^${key}:\\s*(.+)$`, "m"))?.[1]?.trim();
32
+ return value?.replace(
33
+ /^(?:"([\s\S]*)"|'([\s\S]*)')$/,
34
+ (_all, doubleQuoted, singleQuoted) => doubleQuoted ?? singleQuoted,
35
+ );
36
+ };
37
+
38
+ return { name: scalar("name"), description: scalar("description") };
39
+ }
40
+
41
+ async function findSkillFiles(directory: string): Promise<string[]> {
42
+ try {
43
+ const entries = await fs.readdir(directory, { withFileTypes: true });
44
+ const files: string[] = [];
45
+ const subdirectories: Promise<string[]>[] = [];
46
+ for (const entry of entries) {
47
+ const entryPath = path.join(directory, entry.name);
48
+ if (entry.isFile() && entry.name === "SKILL.md") files.push(entryPath);
49
+ else if (entry.isDirectory()) subdirectories.push(findSkillFiles(entryPath));
50
+ }
51
+ const nested = await Promise.all(subdirectories);
52
+ return [...files, ...nested.flat()];
53
+ } catch {
54
+ return [];
55
+ }
56
+ }
57
+
58
+ function skillID(name: string): string {
59
+ return (
60
+ name
61
+ .toLowerCase()
62
+ .replace(/[^a-z0-9_-]+/g, "-")
63
+ .replace(/^-+|-+$/g, "") || "mate-skill"
64
+ );
65
+ }
66
+
67
+ async function loadCompanionSkills(companionPath: string): Promise<CompanionSkill[]> {
68
+ const files = (await Promise.all(companionSkillPaths(companionPath).map(findSkillFiles))).flat();
69
+ const skills = await Promise.all(
70
+ files.map(async (filePath): Promise<CompanionSkill | null> => {
71
+ try {
72
+ const content = await fs.readFile(filePath, "utf8");
73
+ const metadata = parseFrontmatter(content);
74
+ const name = metadata.name || path.basename(path.dirname(filePath));
75
+ return {
76
+ id: skillID(name),
77
+ name,
78
+ ...(metadata.description ? { description: metadata.description } : {}),
79
+ path: filePath,
80
+ content,
81
+ };
82
+ } catch {
83
+ /** An unreadable companion skill must not prevent the session from loading. */
84
+ return null;
85
+ }
86
+ }),
87
+ );
88
+ return skills.filter((skill) => skill !== null);
89
+ }
90
+
19
91
  /**
20
92
  * Grants the session access to the companion and registers its skills. The
21
93
  * permission hook only widens access for companion resources; every other
22
94
  * evaluation keeps OpenCode's own verdict.
23
95
  */
24
- export async function registerCompanionAccess(
25
- api: Context,
26
- companionPath: string,
27
- extraRoots: readonly string[] = [],
28
- ): Promise<void> {
96
+ export async function registerCompanionAccess(api: Context, companionPath: string): Promise<void> {
29
97
  const permissionRoot = path.resolve(companionPath);
30
- /** An audience layer's files are as readable as the companion's, for this launch only. */
31
- const allowedRoots = [permissionRoot, ...extraRoots.map((root) => path.resolve(root))];
32
98
 
33
99
  await api.permission.hook("evaluate", (event) => {
34
- if (event.resources.some((resource) => allowedRoots.some((root) => isWithin(root, resource)))) {
100
+ if (event.resources.some((resource) => isWithin(permissionRoot, resource))) {
35
101
  event.effect = "allow";
36
102
  delete event.message;
37
103
  }
38
104
  });
39
105
 
40
- const skills = await loadSkillInfos(companionSkillPaths(permissionRoot));
106
+ const skills = await loadCompanionSkills(permissionRoot);
41
107
  if (skills.length === 0) return;
42
108
 
43
109
  await api.skill.transform((editor) => {
@@ -77,7 +77,6 @@ function buildSystemPrompt(
77
77
  context: CompanionContext,
78
78
  guidance: MateGuidanceFile,
79
79
  wrapperBinPath: string,
80
- layerGuidance: string,
81
80
  ): string[] {
82
81
  const lines = [materializeCompanionGuidance(guidance.companionGuidance, context, wrapperBinPath)];
83
82
 
@@ -89,11 +88,6 @@ function buildSystemPrompt(
89
88
  lines.push("", `<agents.md>${context.agentsMd.trim()}</agents.md>`);
90
89
  }
91
90
 
92
- /** The audience's guidance follows the companion's, never replaces it. */
93
- if (layerGuidance.trim()) {
94
- lines.push("", layerGuidance.trim());
95
- }
96
-
97
91
  return lines;
98
92
  }
99
93
 
@@ -188,7 +182,6 @@ export async function registerCompanion(
188
182
  api: Context,
189
183
  context: CompanionContext,
190
184
  env: LaunchEnvironment = process.env,
191
- layerGuidance = "",
192
185
  ): Promise<void> {
193
186
  const guidance = loadGuidance(env, api.location.directory);
194
187
  if (!guidance) return;
@@ -199,7 +192,7 @@ export async function registerCompanion(
199
192
  return;
200
193
  }
201
194
 
202
- const companion = buildSystemPrompt(context, guidance, wrapperBinPath, layerGuidance).join("\n");
195
+ const companion = buildSystemPrompt(context, guidance, wrapperBinPath).join("\n");
203
196
 
204
197
  await api.session.hook("context", (event) => {
205
198
  const merged = mergeCompanionSystem(event.system, companion);
@@ -11,11 +11,6 @@ import { spawnPrepareRunner } from "../../src/runtime/session-prepare-runner";
11
11
  import { readWrapStateSync } from "../../src/runtime/wrap-state";
12
12
 
13
13
  import { registerCompanionAccess } from "./add-dir";
14
- import {
15
- readTrustedAudienceLayer,
16
- readWrappedAudienceLayer,
17
- registerAudienceLayer,
18
- } from "./audience-layer";
19
14
  import { missingRuntimeDirectoryMessage, registerCompanion, resolveMateBin } from "./companion";
20
15
  import { pluginLaunchEnvironment, type LaunchEnvironment } from "./launch-environment";
21
16
 
@@ -56,17 +51,9 @@ export const MateOpenCodePlugin = Plugin.define({
56
51
  const context = readContext(env, api.location.directory);
57
52
  if (!context.companionPath) return;
58
53
 
59
- /**
60
- * A layer comes from a payload this launch trusts, else from the wrap of
61
- * the session's checkout; an inherited environment never names one.
62
- */
63
- const layer =
64
- (await readTrustedAudienceLayer(env)) ??
65
- (await readWrappedAudienceLayer(api.location.directory, context.companionPath));
66
- await registerCompanionAccess(api, context.companionPath, layer ? [layer.root] : []);
54
+ await registerCompanionAccess(api, context.companionPath);
67
55
  await registerCompanionHooks(api, context, env);
68
- await registerCompanion(api, context, env, layer?.contents.guidance);
69
- if (layer) await registerAudienceLayer(api, layer.contents);
56
+ await registerCompanion(api, context, env);
70
57
  /** Last: the guardrails above are live while the local phase holds `setup`. */
71
58
  const prepareCleanup = await registerPrepare(api, context.companionPath, env);
72
59
  return async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniqbit/mate-core",
3
- "version": "0.18.4",
3
+ "version": "0.18.5",
4
4
  "description": "Core framework and plugin APIs for Mate.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -73,9 +73,12 @@ type ProjectionResult =
73
73
  | { ok: true; repository: string; areas: string[] }
74
74
  | { ok: false; reason: string };
75
75
 
76
+ /** A leading `---` block; group 1 is its YAML, the match ends after the closing line. */
77
+ const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
78
+
76
79
  function parseDeltaScopes(source: string): ScopePair[] {
77
80
  const text = source.startsWith(BOM) ? source.slice(BOM.length) : source;
78
- const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(text);
81
+ const match = FRONTMATTER_PATTERN.exec(text);
79
82
  if (!match) return [];
80
83
 
81
84
  try {
@@ -95,6 +98,26 @@ function parseDeltaScopes(source: string): ScopePair[] {
95
98
  }
96
99
  }
97
100
 
101
+ const DELTA_TAGS = new Set(["openspec/change", "openspec/delta"]);
102
+
103
+ /**
104
+ * A header an agent copied from a delta: it declares `type: delta-spec`, carries a
105
+ * `change` key, or a delta tag. A nested `scopes` key alone does not count, since
106
+ * legacy canonical specs carry one.
107
+ */
108
+ function isDeltaShapedFrontmatter(yamlSource: string): boolean {
109
+ try {
110
+ const parsed = parse(yamlSource) as unknown;
111
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return false;
112
+ const header = parsed as Record<string, unknown>;
113
+ if (header.type === "delta-spec" || "change" in header) return true;
114
+ const tags = Array.isArray(header.tags) ? header.tags : [header.tags];
115
+ return tags.some((tag) => typeof tag === "string" && DELTA_TAGS.has(tag));
116
+ } catch {
117
+ return false;
118
+ }
119
+ }
120
+
98
121
  function projectDeltaScopes(scopes: ScopePair[]): ProjectionResult {
99
122
  if (scopes.length === 0) return { ok: false, reason: "delta declares no scopes entries" };
100
123
  const repositories = [...new Set(scopes.map((entry) => entry.repository))];
@@ -133,7 +156,10 @@ async function archivedChangeUsesMateSchema(
133
156
  }
134
157
  }
135
158
 
136
- /** Scope keys are omitted when the delta had none — a partial block beats no frontmatter. */
159
+ /**
160
+ * The block through its closing line. Scope keys are omitted when the delta had
161
+ * none — a partial block beats no frontmatter.
162
+ */
137
163
  function renderCanonicalFrontmatter(
138
164
  capability: string,
139
165
  scope: { repository: string; areas: string[] } | null,
@@ -146,14 +172,14 @@ function renderCanonicalFrontmatter(
146
172
  "tags: [openspec/spec]",
147
173
  "---",
148
174
  "",
149
- "",
150
175
  ].join("\n");
151
176
  }
152
177
 
153
178
  /**
154
179
  * Repairs `openspec archive` output: prepends canonical frontmatter to main specs the
155
- * archive run created bare. Publish does not archive, so this runs the first time publish
156
- * resolves that archive, however the archive was made.
180
+ * archive run created bare, and replaces a delta-shaped header an agent copied onto one.
181
+ * Publish does not archive, so this runs the first time publish resolves that archive,
182
+ * however the archive was made.
157
183
  *
158
184
  * `openspec archive` rebuilds a brand-new main spec from a skeleton with no frontmatter slot,
159
185
  * so scope metadata dies exactly once, at spec birth; existing specs keep theirs because only
@@ -197,8 +223,14 @@ async function reconcileMainSpecFrontmatter(
197
223
  const canonicalPath = path.join(companionPath, canonicalRelative);
198
224
 
199
225
  try {
200
- const canonical = await fs.readFile(canonicalPath, "utf8");
201
- if (canonical.replace(BOM, "").startsWith("---")) continue;
226
+ const canonical = (await fs.readFile(canonicalPath, "utf8")).replace(BOM, "");
227
+ /** The body that follows the written block: the whole file, or what trails a delta-shaped header. */
228
+ let body = `\n${canonical}`;
229
+ if (canonical.startsWith("---")) {
230
+ const header = FRONTMATTER_PATTERN.exec(canonical);
231
+ if (!header || !isDeltaShapedFrontmatter(header[1])) continue;
232
+ body = canonical.slice(header[0].length);
233
+ }
202
234
 
203
235
  const deltaSource = await fs.readFile(path.join(directory, entry.name), "utf8");
204
236
  const scopes = parseDeltaScopes(deltaSource);
@@ -215,7 +247,7 @@ async function reconcileMainSpecFrontmatter(
215
247
  capability,
216
248
  projected.ok ? { repository: projected.repository, areas: projected.areas } : null,
217
249
  );
218
- await fs.writeFile(canonicalPath, head + canonical.replace(BOM, ""), "utf8");
250
+ await fs.writeFile(canonicalPath, head + body, "utf8");
219
251
  reconciled.push(canonicalRelative);
220
252
  } catch (error) {
221
253
  process.stderr.write(
@@ -1,10 +1,13 @@
1
1
  import { execFileSync } from "node:child_process";
2
+ import fs from "node:fs/promises";
2
3
  import path from "node:path";
3
4
 
5
+ import { parse as parseYaml } from "yaml";
6
+
4
7
  import { FRAMEWORK_NAME } from "../../framework";
5
8
  import { gitEnvironment } from "../../runtime/companion-git";
6
- import { readWrapStateSync } from "../../runtime/wrap-state";
7
9
  import { getActiveDistribution } from "../../distribution";
10
+ import { hasOpenspecCapability } from "../../lib/orchestrator/capabilities";
8
11
  import { checkEngineRequirement } from "../../lib/orchestrator/engine-guard";
9
12
  import type { GlobalConfigStore } from "../../lib/orchestrator/global-config-store";
10
13
  import { pathIsDirectory } from "../../lib/fs-utils";
@@ -20,12 +23,7 @@ import {
20
23
  type PluginInspection,
21
24
  type PluginVerificationFailure,
22
25
  } from "../../tools/setup/dynamic-plugins/verify";
23
- import {
24
- inspectAudienceIsolation,
25
- type AudienceIsolationReport,
26
- } from "../../tools/setup/isolation-diagnostics";
27
26
  import { getRequiredPluginDrift } from "../../tools/setup/policy";
28
- import type { PluginRegistry } from "../../tools/setup/registry";
29
27
  import { resolveCommandOnPath } from "../../tools/setup/utils";
30
28
  import { writeJsonStdout } from "../write-json-stdout";
31
29
  import { printSection, renderKeyValueTable, renderTable } from "./status";
@@ -39,8 +37,6 @@ interface DoctorDeps {
39
37
  inspectPlugins?: (companionPath: string) => Promise<PluginInspection>;
40
38
  /** Environment `MATE_AUDIENCE` is read from; defaults to the process environment. */
41
39
  env?: Record<string, string | undefined>;
42
- /** The hydrated registry; the active distribution's when absent. */
43
- registry?: PluginRegistry;
44
40
  }
45
41
 
46
42
  export type DoctorKind = "core" | "working" | "companion" | "hub";
@@ -74,18 +70,15 @@ export interface DoctorReport {
74
70
  toolInstallations?: DoctorToolInstallation[];
75
71
  requiredPluginDrift?: Array<{ pluginId: string; kind: string; reason: string }>;
76
72
  pluginFailures?: PluginVerificationFailure[];
77
- /**
78
- * Present for a companion that declares audiences or has one selected.
79
- * `wrapped` is the audience the working repository is wrapped for, when any.
80
- */
81
- audiences?: { declared: string[]; active: string | null; wrapped?: string };
73
+ /** Present for a companion that declares audiences or has one selected. */
74
+ audiences?: { declared: string[]; active: string | null };
82
75
  /** Set when `MATE_AUDIENCE` names an audience the companion does not declare. */
83
76
  audienceError?: string;
84
- /** Present when an audience's contributions reached a persisted surface, or a launch layer cannot deliver part of one. */
85
- audienceIsolation?: AudienceIsolationReport;
86
77
  /** IDs of the capabilities the verified plugins provide. */
87
78
  pluginCapabilities?: string[];
88
79
  engineRequirement?: { range: string; ok: boolean; detail: string };
80
+ /** Present when the companion's `openspec/config.yaml` has `rules` keys OpenSpec ignores. */
81
+ openspecRuleWarnings?: Array<{ key: string; message: string }>;
89
82
  hub?: { members: DoctorHubMember[] };
90
83
  resolutionFailures: Array<{ companionPath: string; message: string }>;
91
84
  }
@@ -243,21 +236,79 @@ function collectAudiences(
243
236
  report: DoctorReport,
244
237
  config: FrameworkConfig,
245
238
  env: Record<string, string | undefined>,
246
- repositoryPath: string | null,
247
239
  ): void {
248
240
  const declared = Object.keys(config.audiences ?? {});
249
241
  const requested = readAudienceSelection(env);
250
242
  if (declared.length === 0 && requested === null) return;
251
243
  const resolution = resolveAudience(config, requested);
252
- const wrapped = repositoryPath ? readWrapStateSync(repositoryPath).audience : null;
253
- report.audiences = {
254
- declared,
255
- active: resolution.ok ? resolution.active : null,
256
- ...(wrapped ? { wrapped } : {}),
257
- };
244
+ report.audiences = { declared, active: resolution.ok ? resolution.active : null };
258
245
  if (!resolution.ok) report.audienceError = describeUnknownAudience(resolution);
259
246
  }
260
247
 
248
+ /** Artifact IDs of OpenSpec's built-in `spec-driven` schema. */
249
+ const BUILT_IN_OPENSPEC_ARTIFACTS = ["proposal", "specs", "design", "tasks"];
250
+
251
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
252
+ return value && typeof value === "object" && !Array.isArray(value)
253
+ ? (value as Record<string, unknown>)
254
+ : undefined;
255
+ }
256
+
257
+ /** Built-in IDs plus every `artifacts[].id` from a `schema.yaml` under the companion's `openspec/schemas/`. */
258
+ async function knownOpenSpecArtifactIds(companionPath: string): Promise<Set<string>> {
259
+ const schemasDir = path.join(companionPath, "openspec", "schemas");
260
+ let schemaNames: string[] = [];
261
+ try {
262
+ schemaNames = await fs.readdir(schemasDir);
263
+ } catch {
264
+ /* no companion schemas */
265
+ }
266
+ const schemaIds = await Promise.all(
267
+ schemaNames.map(async (name) => {
268
+ try {
269
+ const schema = asRecord(
270
+ parseYaml(await fs.readFile(path.join(schemasDir, name, "schema.yaml"), "utf8")),
271
+ );
272
+ const artifacts: unknown[] = Array.isArray(schema?.artifacts) ? schema.artifacts : [];
273
+ return artifacts.flatMap((artifact) => {
274
+ const id = asRecord(artifact)?.id;
275
+ return typeof id === "string" ? [id] : [];
276
+ });
277
+ } catch {
278
+ return []; // not a readable schema
279
+ }
280
+ }),
281
+ );
282
+ return new Set([...BUILT_IN_OPENSPEC_ARTIFACTS, ...schemaIds.flat()]);
283
+ }
284
+
285
+ /**
286
+ * OpenSpec ignores a `rules` key that names no artifact, warning only on stderr
287
+ * of the command that reads it. Read-only; a missing or unparseable config is silent.
288
+ */
289
+ async function collectOpenSpecRuleWarnings(
290
+ companionPath: string,
291
+ ): Promise<NonNullable<DoctorReport["openspecRuleWarnings"]>> {
292
+ let rules: Record<string, unknown> | undefined;
293
+ try {
294
+ const config = parseYaml(
295
+ await fs.readFile(path.join(companionPath, "openspec", "config.yaml"), "utf8"),
296
+ );
297
+ rules = asRecord(asRecord(config)?.rules);
298
+ } catch {
299
+ return [];
300
+ }
301
+ if (!rules) return [];
302
+
303
+ const known = await knownOpenSpecArtifactIds(companionPath);
304
+ return Object.keys(rules)
305
+ .filter((key) => !known.has(key))
306
+ .map((key) => ({
307
+ key,
308
+ message: `rules.${key} matches no OpenSpec artifact and is ignored; put workflow guidance in operations.apply.guidance or operations.archive.guidance`,
309
+ }));
310
+ }
311
+
261
312
  export async function collectDoctorReport(deps: DoctorDeps = {}): Promise<DoctorReport> {
262
313
  const cwd = path.resolve(deps.cwd ?? process.cwd());
263
314
  const pathValue = deps.pathValue ?? process.env.PATH ?? "";
@@ -293,16 +344,16 @@ export async function collectDoctorReport(deps: DoctorDeps = {}): Promise<Doctor
293
344
 
294
345
  const env = deps.env ?? process.env;
295
346
  if (root.rootPath && root.config && kind !== "hub") {
296
- collectAudiences(report, root.config, env, root.linkedRepository?.path ?? null);
297
- const isolation = await inspectAudienceIsolation(
298
- root.rootPath,
299
- root.config,
300
- deps.registry ?? getActiveDistribution().registry,
301
- report.audiences?.active ?? null,
302
- );
303
- if (isolation.leaked.length + isolation.unswept.length + isolation.undelivered.length > 0) {
304
- report.audienceIsolation = isolation;
305
- }
347
+ collectAudiences(report, root.config, env);
348
+ }
349
+
350
+ if (
351
+ root.rootPath &&
352
+ (kind === "working" || kind === "companion") &&
353
+ hasOpenspecCapability(root.config?.capabilities)
354
+ ) {
355
+ const warnings = await collectOpenSpecRuleWarnings(root.rootPath);
356
+ if (warnings.length > 0) report.openspecRuleWarnings = warnings;
306
357
  }
307
358
 
308
359
  if (root.rootPath && root.config) {
@@ -353,49 +404,6 @@ function renderHubMembers(members: DoctorHubMember[]): string {
353
404
  );
354
405
  }
355
406
 
356
- function renderAudienceIsolation(isolation: AudienceIsolationReport): string {
357
- const parts: string[] = [];
358
- if (isolation.leaked.length > 0) {
359
- parts.push(
360
- "Contributions on persisted surfaces that no base plugin owns; the next sync removes them:",
361
- renderTable(
362
- ["Surface", "Kind", "Entry", "Owner"],
363
- isolation.leaked.map((entry) => [
364
- entry.surface,
365
- entry.kind,
366
- entry.identity ?? "",
367
- entry.owner,
368
- ]),
369
- ),
370
- );
371
- }
372
- if (isolation.unswept.length > 0) {
373
- parts.push(
374
- `Audience plugins not swept (could not be loaded); run \`MATE_AUDIENCE=<audience> ${FRAMEWORK_NAME} install --yes\` with their credentials:`,
375
- renderList(isolation.unswept, ""),
376
- );
377
- }
378
- if (isolation.undelivered.length > 0) {
379
- parts.push(
380
- "Active audience plugins contribute declaratively only; these parts are not delivered:",
381
- renderList(
382
- isolation.undelivered.map(
383
- (entry) =>
384
- `${entry.pluginId}: ${entry.parts
385
- .map((part) =>
386
- part === "pluginReferences"
387
- ? "OpenCode plugin references are not delivered"
388
- : `${part} is not run`,
389
- )
390
- .join("; ")}`,
391
- ),
392
- "",
393
- ),
394
- );
395
- }
396
- return parts.join("\n\n");
397
- }
398
-
399
407
  function renderHumanReport(report: DoctorReport): void {
400
408
  console.log("Mate Doctor");
401
409
  console.log("");
@@ -442,20 +450,9 @@ function renderHumanReport(report: DoctorReport): void {
442
450
  renderKeyValueTable([
443
451
  ["Declared", report.audiences.declared.join(", ") || "none"],
444
452
  ["Active", report.audiences.active ?? "none"],
445
- ...(report.audiences.wrapped
446
- ? [
447
- ["Wrapped", `${report.audiences.wrapped} (bare sessions of this checkout)`] as [
448
- string,
449
- string,
450
- ],
451
- ]
452
- : []),
453
453
  ]),
454
454
  );
455
455
  }
456
- if (report.audienceIsolation) {
457
- printSection("Audience Isolation", renderAudienceIsolation(report.audienceIsolation));
458
- }
459
456
  if (report.hub) printSection("Hub Members", renderHubMembers(report.hub.members));
460
457
  // Companions with no drift produce no section at all: required plugins are
461
458
  // silent when healthy.
@@ -480,6 +477,15 @@ function renderHumanReport(report: DoctorReport): void {
480
477
  if (report.engineRequirement) {
481
478
  printSection("Version Requirement", report.engineRequirement.detail);
482
479
  }
480
+ if (report.openspecRuleWarnings) {
481
+ printSection(
482
+ "OpenSpec Config",
483
+ renderList(
484
+ report.openspecRuleWarnings.map((warning) => warning.message),
485
+ "",
486
+ ),
487
+ );
488
+ }
483
489
 
484
490
  printSection(
485
491
  "Resolution Failures",
@@ -1,8 +1,5 @@
1
- import path from "node:path";
2
-
3
1
  import { confirm } from "../confirm";
4
2
  import { FRAMEWORK_NAME } from "../../framework";
5
- import { ConfigStore } from "../../lib/orchestrator/config-store";
6
3
  import {
7
4
  buildInstallPlan,
8
5
  inspectInstallPlan,
@@ -179,11 +176,6 @@ export async function runInstallCommand(argv: string[], cwd = process.cwd()): Pr
179
176
  }
180
177
 
181
178
  try {
182
- if (!frozen && context.kind === "companion" && context.companionPath) {
183
- await new ConfigStore(
184
- path.join(context.companionPath, `.${FRAMEWORK_NAME}`, "config", "framework.yaml"),
185
- ).save(context.config);
186
- }
187
179
  await reconcileInstalledCompanion(plan);
188
180
  await saveCompleteInstallState(plan, execution.results);
189
181
  } catch (error) {
@@ -3,11 +3,9 @@ import path from "node:path";
3
3
 
4
4
  import { FRAMEWORK_NAME } from "../../../framework";
5
5
  import { ConfigStore, mergeWithDefaults } from "../../../lib/orchestrator/config-store";
6
- import { currentAudienceLayer } from "../../../runtime/audience-layer";
7
6
  import {
8
7
  effectiveStudioAgent,
9
8
  readAudienceSelection,
10
- resolveAudience,
11
9
  } from "../../../tools/setup/dynamic-plugins/audiences";
12
10
  import type { StudioInventory, StudioInventoryCompanion } from "./inventory";
13
11
  import { findAction, promptArgs, skillInstalledFor, SUBJECT_PATTERN } from "./actions";
@@ -53,8 +51,7 @@ export interface DefaultAgentOutcome {
53
51
  /**
54
52
  * The companion's effective default agent for one provider: the active
55
53
  * audience's `studio.terminal.agent` (Studio's own `MATE_AUDIENCE`, never the
56
- * browser's), else the base one. Its definition may come from the companion
57
- * or from the active audience's rendered layer. Never throws and never echoes the configured
54
+ * browser's), else the base one. Never throws and never echoes the configured
58
55
  * value into the notice.
59
56
  */
60
57
  export async function defaultAgentFor(
@@ -65,13 +62,11 @@ export async function defaultAgentFor(
65
62
  const configPath = path.join(companionPath, `.${FRAMEWORK_NAME}`, "config", "framework.yaml");
66
63
  if (!fs.existsSync(configPath)) return {};
67
64
  let name: unknown;
68
- let layer: string | null = null;
69
65
  try {
70
- const config = await new ConfigStore(configPath).load();
71
- const audience = resolveAudience(config, readAudienceSelection(env));
72
- const active = audience.ok ? audience.active : null;
73
- name = effectiveStudioAgent(config, active);
74
- if (active) layer = currentAudienceLayer(companionPath, active);
66
+ name = effectiveStudioAgent(
67
+ await new ConfigStore(configPath).load(),
68
+ readAudienceSelection(env),
69
+ );
75
70
  } catch {
76
71
  return {};
77
72
  }
@@ -81,12 +76,8 @@ export async function defaultAgentFor(
81
76
  notice: "Default agent not applied: studio.terminal.agent is not a valid agent name.",
82
77
  };
83
78
  }
84
- /** The audience's own agents live in its launch layer, which the terminal's launch attaches. */
85
- const roots = layer ? [companionPath, layer] : [companionPath];
86
- const defined = roots.some((root) =>
87
- fs.existsSync(path.join(root, AGENT_DEFINITION_DIRS[agent], `${name}.md`)),
88
- );
89
- if (!defined) {
79
+ const definition = path.join(companionPath, AGENT_DEFINITION_DIRS[agent], `${name}.md`);
80
+ if (!fs.existsSync(definition)) {
90
81
  return {
91
82
  notice: `Default agent "${name}" not applied: this companion has no ${agent} definition for it.`,
92
83
  };