@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
@@ -109,28 +109,32 @@ function changeKindFor(
109
109
  );
110
110
  }
111
111
 
112
+ /**
113
+ * Every file beneath `directory`, as POSIX paths under `relativeRoot`. Reads directory
114
+ * entry names only, never file contents. Throws when `directory` cannot be read.
115
+ */
116
+ async function filesUnder(directory: string, relativeRoot: string): Promise<string[]> {
117
+ const entries = await fs.readdir(directory, { withFileTypes: true });
118
+ const nested = await Promise.all(
119
+ entries.map(async (entry) => {
120
+ const entryRelative = path.posix.join(relativeRoot, entry.name);
121
+ if (entry.isDirectory()) return filesUnder(path.join(directory, entry.name), entryRelative);
122
+ return entry.isFile() ? [entryRelative] : [];
123
+ }),
124
+ );
125
+ return nested.flat();
126
+ }
127
+
112
128
  /**
113
129
  * Canonical specs the change's delta specs applied to. Only directory entry names under
114
130
  * the archive's `specs/` tree are read — never any file contents — so archived prose
115
131
  * cannot influence discovery.
116
132
  */
117
133
  async function canonicalSpecsFor(companionPath: string, anchor: string): Promise<string[]> {
118
- const collectDeltaSpecFiles = async (directory: string, relative = ""): Promise<string[]> => {
119
- const entries = await fs.readdir(directory, { withFileTypes: true });
120
- const nested = await Promise.all(
121
- entries.map(async (entry) => {
122
- const entryRelative = path.posix.join(relative, entry.name);
123
- if (entry.isDirectory()) {
124
- return collectDeltaSpecFiles(path.join(directory, entry.name), entryRelative);
125
- }
126
- return entry.isFile() ? [path.posix.join(SPECS_RELATIVE_DIR, entryRelative)] : [];
127
- }),
128
- );
129
- return nested.flat();
130
- };
131
134
  try {
132
- const specs = await collectDeltaSpecFiles(
135
+ const specs = await filesUnder(
133
136
  path.join(companionPath, ARCHIVE_RELATIVE_DIR, anchor, "specs"),
137
+ SPECS_RELATIVE_DIR,
134
138
  );
135
139
  return specs.toSorted();
136
140
  } catch {
@@ -216,6 +220,32 @@ async function attributionIndex(
216
220
  );
217
221
  }
218
222
 
223
+ /**
224
+ * The canonical spec files behind one uncommitted path. Porcelain collapses an untracked
225
+ * directory to `dir/`, so a collapsed path at or above a capability directory is expanded
226
+ * into the files beneath it, every one of them new; publish narrows by file, never by
227
+ * directory. Expansion reads directory entry names only, so spec prose cannot influence
228
+ * discovery.
229
+ */
230
+ async function specFilesFor(
231
+ companionPath: string,
232
+ worktreePath: string,
233
+ changeKinds: Readonly<Record<string, WorkingTreeChange>>,
234
+ ): Promise<Array<{ path: string; kind: WorkingTreeChange }>> {
235
+ if (!worktreePath.endsWith("/")) {
236
+ return [{ path: worktreePath, kind: changeKindFor(worktreePath, changeKinds) }];
237
+ }
238
+ const collapsed = withoutTrailingSlash(worktreePath);
239
+ const root = collapsed.startsWith(`${SPECS_RELATIVE_DIR}/`) ? collapsed : SPECS_RELATIVE_DIR;
240
+ try {
241
+ const files = await filesUnder(path.join(companionPath, root), root);
242
+ return files.map((file) => ({ path: file, kind: "new" as const }));
243
+ } catch {
244
+ // A collapsed ancestor such as `openspec/` need not contain a spec tree at all.
245
+ return [];
246
+ }
247
+ }
248
+
219
249
  /**
220
250
  * Uncommitted canonical specs that no pending change accounts for, each attributed to
221
251
  * every archive whose delta specs name it. An attributed spec belongs to a change whose
@@ -230,18 +260,27 @@ export async function unattributedSpecs(
230
260
  archives: ArchiveEntry[],
231
261
  changeKinds: Readonly<Record<string, WorkingTreeChange>> = {},
232
262
  ): Promise<UnattributedSpec[]> {
233
- const claimed = new Set(pendingArchives(archives).flatMap((entry) => entry.uncommittedSpecs));
234
- const specPaths = uncommittedPaths
235
- .filter(
236
- (worktreePath) => touches(worktreePath, SPECS_RELATIVE_DIR) && !claimed.has(worktreePath),
237
- )
238
- .toSorted();
263
+ const claimed = pendingArchives(archives).flatMap((entry) => entry.uncommittedSpecs);
264
+ const reported = await Promise.all(
265
+ uncommittedPaths.flatMap((worktreePath) =>
266
+ touches(worktreePath, SPECS_RELATIVE_DIR)
267
+ ? [specFilesFor(companionPath, worktreePath, changeKinds)]
268
+ : [],
269
+ ),
270
+ );
271
+ const driftKinds = new Map<string, WorkingTreeChange>();
272
+ for (const spec of reported.flat()) {
273
+ if (!claimed.some((claimedPath) => touches(claimedPath, spec.path))) {
274
+ driftKinds.set(spec.path, spec.kind);
275
+ }
276
+ }
277
+ const specPaths = [...driftKinds.keys()].toSorted();
239
278
  if (specPaths.length === 0) return [];
240
279
 
241
280
  const index = await attributionIndex(companionPath, archives);
242
281
  return specPaths.map((specPath) => ({
243
282
  path: specPath,
244
- kind: changeKindFor(specPath, changeKinds),
283
+ kind: driftKinds.get(specPath) ?? "modified",
245
284
  touchedByArchives: index.flatMap(({ anchor, state, specs }) =>
246
285
  specs.some((spec) => touches(specPath, spec)) ? [{ anchor, state }] : [],
247
286
  ),
@@ -1,6 +1,3 @@
1
- import fs from "node:fs/promises";
2
- import path from "node:path";
3
-
4
1
  import {
5
2
  hasGraphifyCapability,
6
3
  hasTokensaveCapability,
@@ -10,7 +7,7 @@ import type { CapabilityConfig } from "../../../lib/orchestrator/types";
10
7
  import {
11
8
  ensureTokensaveBranchingPosture,
12
9
  ensureTokensaveStoreExcluded,
13
- TOKENSAVE_STORE_DIR,
10
+ isTokensaveIndexed,
14
11
  } from "../../../tools/setup/capabilities/tokensave";
15
12
  import { ensureUnambiguousCompanion } from "../shared/companion-selection";
16
13
  import { runGraphifyCapCommand } from "./graphify";
@@ -43,13 +40,7 @@ async function defaultIsTokensaveInitialized(): Promise<boolean> {
43
40
  const repo =
44
41
  repository ?? (await workingRepoStore.load()).repos.find((entry) => entry.id === repositoryId);
45
42
  if (!repo) return false;
46
-
47
- try {
48
- const stat = await fs.stat(path.join(repo.path, TOKENSAVE_STORE_DIR));
49
- return stat.isDirectory();
50
- } catch {
51
- return false;
52
- }
43
+ return isTokensaveIndexed(repo.path);
53
44
  }
54
45
 
55
46
  // The reused cap commands (runGraphifyCapCommand / runTokensaveCapCommand) signal
@@ -10,10 +10,12 @@ import {
10
10
  } from "../../companion-link-wizard";
11
11
  import { extractRepoName } from "../artifact/extract-repo-name";
12
12
  import { GlobalConfigStore } from "../../../lib/orchestrator/global-config-store";
13
- import { CompanionResolver } from "../../../lib/orchestrator/companion-resolver";
14
- import { companionRootedStore } from "../../../lib/orchestrator/companion-store";
13
+ import {
14
+ CompanionResolver,
15
+ companionRootedStore,
16
+ findDescendantRepoLocalRegistries,
17
+ } from "../../../lib/orchestrator/repository-link";
15
18
  import { inspectSetupPreflight } from "../../../lib/orchestrator/setup-preflight";
16
- import { findDescendantRepoLocalRegistries } from "../../../lib/orchestrator/repo-local-registry";
17
19
  import { runSetupFlowAtPath } from "../setup";
18
20
  import { runInstallCommand } from "../install";
19
21
  import type { CompanionSource, LinkedRepository } from "../../../lib/orchestrator/types";
@@ -164,14 +166,15 @@ export async function runCompanionLinkCommandWithDeps(
164
166
  if (!inputs.companionPaths?.length) {
165
167
  exitWithError("mate: no existing companion was selected.");
166
168
  }
167
- companionPaths = [];
168
- for (const rawCompanionPath of inputs.companionPaths) {
169
- const resolvedCompanionPath = path.resolve(rawCompanionPath);
170
- const existingTarget = await stat(resolvedCompanionPath).catch(() => null);
171
- if (!existingTarget?.isDirectory()) {
172
- exitWithError(`mate: existing companion must be a directory: ${resolvedCompanionPath}`);
173
- }
174
- companionPaths.push(resolvedCompanionPath);
169
+ companionPaths = inputs.companionPaths.map((rawCompanionPath) =>
170
+ path.resolve(rawCompanionPath),
171
+ );
172
+ const targets = await Promise.all(
173
+ companionPaths.map((companionPath) => stat(companionPath).catch(() => null)),
174
+ );
175
+ const notADirectory = companionPaths.find((_, index) => !targets[index]?.isDirectory());
176
+ if (notADirectory) {
177
+ exitWithError(`mate: existing companion must be a directory: ${notADirectory}`);
175
178
  }
176
179
  } else {
177
180
  const rawCompanionPath = inputs.localPath?.trim();
@@ -205,6 +208,7 @@ export async function runCompanionLinkCommandWithDeps(
205
208
  // Managed companions are git-backed; explicitly pasted paths retain local provenance.
206
209
  const persistedSource: CompanionSource = companionSource === "local" ? "local" : "git";
207
210
 
211
+ /* oxlint-disable no-await-in-loop -- each companion is set up, linked and installed in turn; a declined install stops the rest */
208
212
  for (const companionPath of companionPaths) {
209
213
  const preflight = await inspectPreflight(companionPath);
210
214
  if (companionSource === "git" && preflight.kind !== "existing-companion") {
@@ -216,6 +220,7 @@ export async function runCompanionLinkCommandWithDeps(
216
220
  await invalidateInstallState({ kind: "companion", companionPath });
217
221
  if ((await installCompanion(companionPath)) === false) return;
218
222
  }
223
+ /* oxlint-enable no-await-in-loop */
219
224
 
220
225
  let shadowedPaths: string[] = [];
221
226
  try {
@@ -1,4 +1,4 @@
1
- import { CompanionStore } from "../../../lib/orchestrator/companion-store";
1
+ import { CompanionStore } from "../../../lib/orchestrator/repository-link";
2
2
  import { resolveFrameworkContext } from "../../../lib/orchestrator/framework-context";
3
3
  import { renderRepoListTable } from "../../repo-list-table";
4
4
  import { writeJsonStdout } from "../../write-json-stdout";
@@ -13,14 +13,13 @@ import { writeJsonStdout } from "../../write-json-stdout";
13
13
  * TTY (e.g. piped into another command).
14
14
  */
15
15
  export async function runCompanionListCommand(argv: string[] = []): Promise<void> {
16
- const { configStore, workingRepoStore, repository, contextKind } = await resolveFrameworkContext(
17
- process.cwd(),
18
- );
16
+ const { companionPath, workingRepoStore, repository, contextKind } =
17
+ await resolveFrameworkContext(process.cwd());
19
18
  const repositories = repository
20
19
  ? [repository]
21
20
  : contextKind === "companion-root"
22
21
  ? []
23
- : await new CompanionStore(configStore, workingRepoStore).listRepositories();
22
+ : await new CompanionStore(companionPath, workingRepoStore).listRepositories();
24
23
  const forceJson = argv.includes("--json");
25
24
 
26
25
  if (forceJson || !process.stdout.isTTY) {
@@ -1,4 +1,4 @@
1
- import { CompanionStore } from "../../../lib/orchestrator/companion-store";
1
+ import { CompanionStore } from "../../../lib/orchestrator/repository-link";
2
2
  import { resolveForLaunch } from "../../../lib/orchestrator/framework-context";
3
3
  import { injectEditorFolder, getPreferredEditorCli } from "../../../lib/orchestrator/editor";
4
4
  import { ensureUnambiguousCompanion } from "../launch/shared";
@@ -7,9 +7,9 @@ export const workspaceOpenCommandDeps = {
7
7
  ensureUnambiguousCompanion: () => ensureUnambiguousCompanion(),
8
8
  resolveLaunchContext: () => resolveForLaunch(process.cwd()),
9
9
  createStore: (
10
- configStore: ConstructorParameters<typeof CompanionStore>[0],
10
+ companionPath: ConstructorParameters<typeof CompanionStore>[0],
11
11
  workingRepoStore: ConstructorParameters<typeof CompanionStore>[1],
12
- ) => new CompanionStore(configStore, workingRepoStore),
12
+ ) => new CompanionStore(companionPath, workingRepoStore),
13
13
  getPreferredEditorCli: () => getPreferredEditorCli(),
14
14
  injectEditorFolder,
15
15
  };
@@ -35,13 +35,12 @@ export async function runWorkspaceOpenCommand(): Promise<void> {
35
35
  companionPath,
36
36
  repositoryId,
37
37
  repository: localRepository,
38
- configStore,
39
38
  workingRepoStore,
40
39
  } = await workspaceOpenCommandDeps.resolveLaunchContext();
41
40
  const repository =
42
41
  localRepository ??
43
42
  (await workspaceOpenCommandDeps
44
- .createStore(configStore, workingRepoStore)
43
+ .createStore(companionPath, workingRepoStore)
45
44
  .getRepository(repositoryId));
46
45
 
47
46
  if (!repository) {
@@ -4,7 +4,7 @@ import { accessSync } from "node:fs";
4
4
  import { getActiveDistribution } from "../../../distribution";
5
5
  import { FRAMEWORK_NAME } from "../../../framework";
6
6
  import { resolveFrameworkContext } from "../../../lib/orchestrator/framework-context";
7
- import { findRepoLocalLinkedRepository } from "../../../lib/orchestrator/repo-local-registry";
7
+ import { findRepoLocalLinkedRepository } from "../../../lib/orchestrator/repository-link";
8
8
  import { ensureUnambiguousCompanion } from "../launch/shared";
9
9
 
10
10
  export function renderBanner(repoPath: string, companionPath: string): string {
@@ -3,10 +3,11 @@ import path from "node:path";
3
3
 
4
4
  import { FRAMEWORK_NAME } from "../../framework";
5
5
  import { gitEnvironment } from "../../runtime/companion-git";
6
+ import { readWrapStateSync } from "../../runtime/wrap-state";
6
7
  import { getActiveDistribution } from "../../distribution";
7
8
  import { checkEngineRequirement } from "../../lib/orchestrator/engine-guard";
8
9
  import type { GlobalConfigStore } from "../../lib/orchestrator/global-config-store";
9
- import { pathIsDirectory } from "../../lib/orchestrator/repo-local-registry";
10
+ import { pathIsDirectory } from "../../lib/fs-utils";
10
11
  import { resolveRootContext, type RootContext } from "../../lib/orchestrator/root-context";
11
12
  import type { CapabilityConfig, FrameworkConfig, HubMember } from "../../lib/orchestrator/types";
12
13
  import {
@@ -19,7 +20,12 @@ import {
19
20
  type PluginInspection,
20
21
  type PluginVerificationFailure,
21
22
  } from "../../tools/setup/dynamic-plugins/verify";
23
+ import {
24
+ inspectAudienceIsolation,
25
+ type AudienceIsolationReport,
26
+ } from "../../tools/setup/isolation-diagnostics";
22
27
  import { getRequiredPluginDrift } from "../../tools/setup/policy";
28
+ import type { PluginRegistry } from "../../tools/setup/registry";
23
29
  import { resolveCommandOnPath } from "../../tools/setup/utils";
24
30
  import { writeJsonStdout } from "../write-json-stdout";
25
31
  import { printSection, renderKeyValueTable, renderTable } from "./status";
@@ -33,6 +39,8 @@ interface DoctorDeps {
33
39
  inspectPlugins?: (companionPath: string) => Promise<PluginInspection>;
34
40
  /** Environment `MATE_AUDIENCE` is read from; defaults to the process environment. */
35
41
  env?: Record<string, string | undefined>;
42
+ /** The hydrated registry; the active distribution's when absent. */
43
+ registry?: PluginRegistry;
36
44
  }
37
45
 
38
46
  export type DoctorKind = "core" | "working" | "companion" | "hub";
@@ -66,10 +74,15 @@ export interface DoctorReport {
66
74
  toolInstallations?: DoctorToolInstallation[];
67
75
  requiredPluginDrift?: Array<{ pluginId: string; kind: string; reason: string }>;
68
76
  pluginFailures?: PluginVerificationFailure[];
69
- /** Present for a companion that declares audiences or has one selected. */
70
- audiences?: { declared: string[]; active: string | null };
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 };
71
82
  /** Set when `MATE_AUDIENCE` names an audience the companion does not declare. */
72
83
  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;
73
86
  /** IDs of the capabilities the verified plugins provide. */
74
87
  pluginCapabilities?: string[];
75
88
  engineRequirement?: { range: string; ok: boolean; detail: string };
@@ -230,12 +243,18 @@ function collectAudiences(
230
243
  report: DoctorReport,
231
244
  config: FrameworkConfig,
232
245
  env: Record<string, string | undefined>,
246
+ repositoryPath: string | null,
233
247
  ): void {
234
248
  const declared = Object.keys(config.audiences ?? {});
235
249
  const requested = readAudienceSelection(env);
236
250
  if (declared.length === 0 && requested === null) return;
237
251
  const resolution = resolveAudience(config, requested);
238
- report.audiences = { declared, active: resolution.ok ? resolution.active : null };
252
+ const wrapped = repositoryPath ? readWrapStateSync(repositoryPath).audience : null;
253
+ report.audiences = {
254
+ declared,
255
+ active: resolution.ok ? resolution.active : null,
256
+ ...(wrapped ? { wrapped } : {}),
257
+ };
239
258
  if (!resolution.ok) report.audienceError = describeUnknownAudience(resolution);
240
259
  }
241
260
 
@@ -274,7 +293,16 @@ export async function collectDoctorReport(deps: DoctorDeps = {}): Promise<Doctor
274
293
 
275
294
  const env = deps.env ?? process.env;
276
295
  if (root.rootPath && root.config && kind !== "hub") {
277
- collectAudiences(report, root.config, env);
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
+ }
278
306
  }
279
307
 
280
308
  if (root.rootPath && root.config) {
@@ -325,6 +353,49 @@ function renderHubMembers(members: DoctorHubMember[]): string {
325
353
  );
326
354
  }
327
355
 
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
+
328
399
  function renderHumanReport(report: DoctorReport): void {
329
400
  console.log("Mate Doctor");
330
401
  console.log("");
@@ -371,9 +442,20 @@ function renderHumanReport(report: DoctorReport): void {
371
442
  renderKeyValueTable([
372
443
  ["Declared", report.audiences.declared.join(", ") || "none"],
373
444
  ["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
+ : []),
374
453
  ]),
375
454
  );
376
455
  }
456
+ if (report.audienceIsolation) {
457
+ printSection("Audience Isolation", renderAudienceIsolation(report.audienceIsolation));
458
+ }
377
459
  if (report.hub) printSection("Hub Members", renderHubMembers(report.hub.members));
378
460
  // Companions with no drift produce no section at all: required plugins are
379
461
  // silent when healthy.
@@ -1,5 +1,8 @@
1
+ import path from "node:path";
2
+
1
3
  import { confirm } from "../confirm";
2
4
  import { FRAMEWORK_NAME } from "../../framework";
5
+ import { ConfigStore } from "../../lib/orchestrator/config-store";
3
6
  import {
4
7
  buildInstallPlan,
5
8
  inspectInstallPlan,
@@ -176,6 +179,11 @@ export async function runInstallCommand(argv: string[], cwd = process.cwd()): Pr
176
179
  }
177
180
 
178
181
  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
+ }
179
187
  await reconcileInstalledCompanion(plan);
180
188
  await saveCompleteInstallState(plan, execution.results);
181
189
  } catch (error) {
@@ -1,6 +1,3 @@
1
- import { readFileSync } from "node:fs";
2
- import { join } from "node:path";
3
-
4
1
  import { reportDataToDocument } from "./adapter";
5
2
  import { validateReportDocument } from "./contract";
6
3
  import { highlightInline, highlightStyleBlock } from "./highlight";
@@ -14,26 +11,19 @@ import type {
14
11
  ReportValue,
15
12
  } from "./types";
16
13
 
17
- const MERMAID_RUNTIME_PATH = join(
18
- import.meta.dirname,
19
- "../../../templates/report-assets/mermaid.min.js",
20
- );
14
+ /**
15
+ * Pinned and integrity-checked, so a CDN cannot serve other code. Offline, the
16
+ * script fails to load and each diagram stays readable as its escaped source.
17
+ */
18
+ export const MERMAID_RUNTIME_URL =
19
+ "https://cdn.jsdelivr.net/npm/mermaid@12.0.0/dist/mermaid.min.js";
20
+ const MERMAID_RUNTIME_INTEGRITY =
21
+ "sha384-xzghz1GQ5u9HCpVskeDPqMsdogD1yvuMQbEK53+wi+G70+6J1AG0L2cfi9PHjDWI";
21
22
 
22
23
  const MERMAID_RUNTIME_ID = "mermaid-runtime";
23
24
 
24
25
  const CODE_BLOCK_SELECTOR = "pre.report-code";
25
26
 
26
- let mermaidRuntime: string | undefined;
27
-
28
- /** A literal `</script` inside the bundle would close the inline element, so it is neutralised. */
29
- function readMermaidRuntime(): string {
30
- mermaidRuntime ??= readFileSync(MERMAID_RUNTIME_PATH, "utf8").replaceAll(
31
- "</script",
32
- String.raw`<\/script`,
33
- );
34
- return mermaidRuntime;
35
- }
36
-
37
27
  const carriesMermaid = (sections: ReportSection[]): boolean =>
38
28
  sections.some((section) => section.type === "diagram" && section.mermaid !== undefined);
39
29
 
@@ -384,7 +374,7 @@ export function renderHTML(input: RenderInput): string {
384
374
  ? `\n <style>${highlightStyleBlock(CODE_BLOCK_SELECTOR)}${VISUAL_SECTION_CSS}</style>`
385
375
  : "";
386
376
  const diagramRuntime = carriesMermaid(data.sections)
387
- ? `\n <script id="${MERMAID_RUNTIME_ID}">${readMermaidRuntime()}</script>\n <script>mermaid.initialize({ startOnLoad: true, securityLevel: "strict" });</script>`
377
+ ? `\n <script id="${MERMAID_RUNTIME_ID}" src="${MERMAID_RUNTIME_URL}" integrity="${MERMAID_RUNTIME_INTEGRITY}" crossorigin="anonymous"></script>\n <script>window.mermaid?.initialize({ startOnLoad: true, securityLevel: "strict" });</script>`
388
378
  : "";
389
379
 
390
380
  return `<!doctype html>
@@ -0,0 +1,119 @@
1
+ import path from "node:path";
2
+
3
+ import { FRAMEWORK_NAME } from "../../../framework";
4
+ import { findRepoLocalLinkedRepository } from "../../../lib/orchestrator/repository-link";
5
+ import { prepareSession, type PrepareResult } from "../../../lib/orchestrator/session-prepare";
6
+ import { readProjectionFile } from "../../../runtime/projection";
7
+ import { readWrapState, WRAP_RUNTIMES } from "../../../runtime/wrap-state";
8
+ import { writeJsonStdout } from "../../write-json-stdout";
9
+ import { launchAmbiguityDeps } from "../shared/companion-selection";
10
+
11
+ export interface SessionPrepareArgs {
12
+ repo: string;
13
+ json: boolean;
14
+ /** Fast-forward the companion first, then the local phase when it moved. */
15
+ sync: boolean;
16
+ }
17
+
18
+ const USAGE = `Usage: ${FRAMEWORK_NAME} session prepare --repo <dir> [--json] [--sync]`;
19
+
20
+ function fail(message: string, usage = false): null {
21
+ console.error(`${FRAMEWORK_NAME}: ${message}`);
22
+ if (usage) console.error(USAGE);
23
+ process.exitCode = 1;
24
+ return null;
25
+ }
26
+
27
+ export function parseSessionPrepareArgs(
28
+ argv: string[],
29
+ cwd: string = process.cwd(),
30
+ ): SessionPrepareArgs | { error: string } {
31
+ const args: SessionPrepareArgs = { repo: cwd, json: false, sync: false };
32
+ for (let index = 0; index < argv.length; index += 1) {
33
+ const arg = argv[index]!;
34
+ const [flag, inline] = arg.startsWith("--") && arg.includes("=") ? arg.split(/=(.*)/s) : [arg];
35
+ if (flag === "--json") {
36
+ args.json = true;
37
+ } else if (flag === "--repo") {
38
+ const repo = inline ?? argv[++index];
39
+ if (!repo) return { error: "--repo requires a directory" };
40
+ args.repo = path.resolve(cwd, repo);
41
+ } else if (flag === "--sync") {
42
+ args.sync = true;
43
+ } else {
44
+ return { error: `unknown session prepare option: ${arg}` };
45
+ }
46
+ }
47
+ return args;
48
+ }
49
+
50
+ export const sessionPrepareCommandDeps = {
51
+ findRepoLocalLinkedRepository,
52
+ prepareSession,
53
+ resolveLinkedCompanions: launchAmbiguityDeps.resolveLinkedCompanions,
54
+ };
55
+
56
+ async function resolveCompanionPath(repoPath: string): Promise<string | undefined> {
57
+ const projected = readProjectionFile(repoPath)?.projection.companionPath;
58
+ if (projected) return projected;
59
+ return (await sessionPrepareCommandDeps.resolveLinkedCompanions(repoPath))[0]?.companionPath;
60
+ }
61
+
62
+ function printHuman(result: PrepareResult): void {
63
+ for (const step of result.steps) {
64
+ const detail = step.detail ? ` — ${step.detail}` : "";
65
+ const runtimes = step.runtimes ? ` (${step.runtimes.join(", ")})` : "";
66
+ console.log(` ${step.step}${runtimes}: ${step.outcome}${detail}`);
67
+ }
68
+ for (const warning of result.warnings) {
69
+ console.error(`${FRAMEWORK_NAME}: warning: ${warning}`);
70
+ }
71
+ }
72
+
73
+ /**
74
+ * `mate session prepare`: a launch's preparation for each runtime the
75
+ * repository is wrapped for, without the launch. Without `--sync` it touches no remote; with it,
76
+ * it fast-forwards the companion and re-projects only when the companion moved.
77
+ * Non-zero when the repository is not wrapped or a step failed; a Git conflict
78
+ * or an offline remote is only a warning.
79
+ */
80
+ export async function runSessionPrepareCommand(
81
+ argv: string[],
82
+ cwd: string = process.cwd(),
83
+ ): Promise<PrepareResult | null> {
84
+ const parsed = parseSessionPrepareArgs(argv, cwd);
85
+ if ("error" in parsed) return fail(parsed.error, true);
86
+
87
+ const deps = sessionPrepareCommandDeps;
88
+ const repository = await deps.findRepoLocalLinkedRepository(parsed.repo);
89
+ const wrapped = repository ? (await readWrapState(repository.path)).runtimes : new Set();
90
+ const runtimes = WRAP_RUNTIMES.filter((runtime) => wrapped.has(runtime));
91
+ if (!repository || runtimes.length === 0) {
92
+ return fail(`${parsed.repo} is not wrapped. Run \`${FRAMEWORK_NAME} wrap\` there first.`);
93
+ }
94
+
95
+ const companionPath = await resolveCompanionPath(repository.path);
96
+ if (!companionPath) return fail(`no companion is linked to ${repository.id}.`);
97
+
98
+ const result = await deps.prepareSession({
99
+ runtime: runtimes[0]!,
100
+ runtimes,
101
+ companionPath,
102
+ repository,
103
+ mode: parsed.sync ? "sync" : "local",
104
+ });
105
+ /** The failure's own error and the wrap state are for an in-process caller; the JSON names steps. */
106
+ const { failure: _failure, wrapState: _wrapState, ...reported } = result;
107
+ if (parsed.json) await writeJsonStdout(reported);
108
+ else printHuman(result);
109
+ if (!result.ok) process.exitCode = 1;
110
+ return result;
111
+ }
112
+
113
+ export async function runSessionCommand(subcommand: string | undefined, rest: string[]) {
114
+ if (subcommand === "prepare") {
115
+ await runSessionPrepareCommand(rest);
116
+ return;
117
+ }
118
+ fail(`unknown session subcommand: ${subcommand ?? ""}`.trimEnd(), true);
119
+ }
@@ -4,9 +4,9 @@ import { FRAMEWORK_NAME } from "../../../framework";
4
4
  import {
5
5
  CompanionResolver,
6
6
  type CompanionMatch,
7
- } from "../../../lib/orchestrator/companion-resolver";
7
+ findRepoLocalLinkedRepository,
8
+ } from "../../../lib/orchestrator/repository-link";
8
9
  import { GlobalConfigStore } from "../../../lib/orchestrator/global-config-store";
9
- import { findRepoLocalLinkedRepository } from "../../../lib/orchestrator/repo-local-registry";
10
10
  import { projectWorkingRepositoryBestEffort } from "../../../lib/orchestrator/working-repo-projection";
11
11
  import { resolveProjection } from "../../../runtime/projection";
12
12
  import { selectCompanion } from "../../companion-selector";
@@ -10,7 +10,6 @@ export const REPORT_LIMITS = {
10
10
  reports: 20,
11
11
  bytes: 50 * 1024 * 1024,
12
12
  ageMs: 7 * 24 * 60 * 60 * 1000,
13
- /** The bundled inlined Mermaid runtime alone is about 5.3 MiB. */
14
13
  uploadBytes: 10 * 1024 * 1024,
15
14
  };
16
15