@uniqbit/mate-core 0.18.3-canary.0 → 0.18.3

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 (96) 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/companion.ts +47 -12
  5. package/opencode-plugin/src/server.ts +39 -4
  6. package/package.json +3 -3
  7. package/src/cli/commands/artifact/finish/openspec.ts +1 -1
  8. package/src/cli/commands/artifact/pending/discovery.ts +60 -21
  9. package/src/cli/commands/companion/link.ts +16 -11
  10. package/src/cli/commands/companion/list.ts +4 -5
  11. package/src/cli/commands/companion/open.ts +4 -5
  12. package/src/cli/commands/companion/tui.ts +1 -1
  13. package/src/cli/commands/doctor.ts +1 -1
  14. package/src/cli/commands/report/renderer.ts +9 -19
  15. package/src/cli/commands/session/prepare.ts +119 -0
  16. package/src/cli/commands/shared/companion-selection.ts +2 -2
  17. package/src/cli/commands/studio/reports.ts +0 -1
  18. package/src/cli/commands/studio/server.ts +6 -2
  19. package/src/cli/commands/unwrap.ts +28 -3
  20. package/src/cli/commands/wrap.ts +53 -59
  21. package/src/cli/companion-selector.tsx +1 -1
  22. package/src/cli/main.ts +17 -0
  23. package/src/cli/usage.ts +3 -1
  24. package/src/hooks/session-banner.ts +19 -10
  25. package/src/hooks/session-prepare.ts +101 -0
  26. package/src/lib/fs-utils.ts +13 -0
  27. package/src/lib/orchestrator/adapters/base.ts +43 -8
  28. package/src/lib/orchestrator/adapters/claude.ts +130 -29
  29. package/src/lib/orchestrator/adapters/opencode.ts +17 -19
  30. package/src/lib/orchestrator/companion-git-sync.ts +28 -1
  31. package/src/lib/orchestrator/companion-hub.ts +2 -1
  32. package/src/lib/orchestrator/config-store.ts +56 -2
  33. package/src/lib/orchestrator/framework-context.ts +5 -29
  34. package/src/lib/orchestrator/launcher.ts +36 -171
  35. package/src/lib/orchestrator/projection-claude-entry.ts +17 -6
  36. package/src/lib/orchestrator/projection-claude-skills.ts +54 -22
  37. package/src/lib/orchestrator/projection-entries.ts +78 -11
  38. package/src/lib/orchestrator/projection-opencode-copies.ts +230 -0
  39. package/src/lib/orchestrator/projection-record.ts +7 -1
  40. package/src/lib/orchestrator/projection-runtime-documents.ts +83 -130
  41. package/src/lib/orchestrator/projection-types.ts +38 -33
  42. package/src/lib/orchestrator/repository-link.ts +473 -0
  43. package/src/lib/orchestrator/root-context.ts +6 -2
  44. package/src/lib/orchestrator/session-prepare.ts +425 -0
  45. package/src/lib/orchestrator/setup-compatibilities.ts +1 -1
  46. package/src/lib/orchestrator/setup-preflight.ts +3 -1
  47. package/src/lib/orchestrator/types.ts +4 -1
  48. package/src/lib/orchestrator/working-repo-projection.ts +71 -159
  49. package/src/lib/orchestrator/workspace-inventory.ts +2 -2
  50. package/src/lib/orchestrator/wrap-pass.ts +131 -0
  51. package/src/lib/runtime-directory-refresh.ts +186 -0
  52. package/src/lib/update-checker.ts +6 -0
  53. package/src/opencode/companion-hooks.ts +24 -132
  54. package/src/opencode/companion-policy.ts +26 -10
  55. package/src/opencode/index.ts +1 -7
  56. package/src/opencode/session-notes.ts +76 -0
  57. package/src/opencode/session-prepare.ts +139 -0
  58. package/src/opencode/tui.tsx +56 -9
  59. package/src/runtime/claude-delivery-plan.ts +73 -0
  60. package/src/runtime/claude-hook-group.ts +21 -0
  61. package/src/runtime/companion-lock.ts +121 -0
  62. package/src/runtime/companion-sync.ts +66 -9
  63. package/src/runtime/runtime-directory.ts +43 -0
  64. package/src/runtime/runtime-document-manifest.ts +335 -0
  65. package/src/runtime/session-prepare-runner.ts +148 -0
  66. package/src/runtime/wrap-state.ts +90 -0
  67. package/src/runtime/write-atomic.ts +27 -0
  68. package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +2 -1
  69. package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +2 -1
  70. package/src/templates/capabilities/openspec-cap/openspec-conventions.yaml +5 -0
  71. package/src/templates/capabilities/react-doctor/mate-react-doctor/SKILL.md +84 -0
  72. package/src/templates/capabilities/react-doctor/{skill → mate-react-doctor}/references/explain.md +18 -15
  73. package/src/templates/mate-skills/agents/mate-openspec-backfill/SKILL.md +45 -29
  74. package/src/templates/mate-skills/agents/mate-show-me/SKILL.md +1 -1
  75. package/src/templates/mate-skills/claude/mate-openspec-backfill/SKILL.md +45 -29
  76. package/src/templates/mate-skills/claude/mate-show-me/SKILL.md +1 -1
  77. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +850 -499
  78. package/src/tools/setup/capabilities/react-doctor.ts +34 -50
  79. package/src/tools/setup/dynamic-plugins/hydrate.ts +1 -1
  80. package/src/tools/setup/engine.ts +15 -4
  81. package/src/tools/setup/mate.ts +1 -1
  82. package/src/tools/setup/providers/claude-format.ts +11 -18
  83. package/src/tools/setup/providers/claude-plugin-hooks.ts +61 -24
  84. package/src/tools/setup/providers/claude.ts +23 -6
  85. package/src/tools/setup/runtime-documents.ts +152 -32
  86. package/src/tools/setup/working-repo-local-state.ts +82 -13
  87. package/src/tools/setup.ts +2 -2
  88. package/src/lib/orchestrator/companion-registry-reader.ts +0 -37
  89. package/src/lib/orchestrator/companion-resolver.ts +0 -179
  90. package/src/lib/orchestrator/companion-store.ts +0 -94
  91. package/src/lib/orchestrator/repo-local-registry.ts +0 -143
  92. package/src/templates/capabilities/react-doctor/claude/hooks/react-doctor.sh +0 -144
  93. package/src/templates/capabilities/react-doctor/skill/SKILL.md +0 -51
  94. package/src/templates/report-assets/README.md +0 -32
  95. package/src/templates/report-assets/mermaid.LICENSE +0 -21
  96. package/src/templates/report-assets/mermaid.min.js +0 -4376
@@ -4,8 +4,15 @@ import path from "node:path";
4
4
 
5
5
  import { FRAMEWORK_NAME } from "../../../framework";
6
6
  import { MATE_ENV } from "../../../runtime/env-names";
7
+ import { getWrapperBinPath } from "../../package-paths";
7
8
  import { buildProjection, companionEnvironment, projectionEnvironment } from "../projection-record";
8
- import type { CapabilityConfig, GitModeProfile, LinkedRepository } from "../types";
9
+ import type { PrepareResult } from "../session-prepare";
10
+ import {
11
+ LaunchPreflightError,
12
+ type CapabilityConfig,
13
+ type GitModeProfile,
14
+ type LinkedRepository,
15
+ } from "../types";
9
16
 
10
17
  export interface AdapterContext {
11
18
  repository?: LinkedRepository;
@@ -47,15 +54,41 @@ function prependPathEntry(pathValue: string | undefined, entry: string): string
47
54
  return [entry, ...entries.filter((value) => value !== entry)].join(path.delimiter);
48
55
  }
49
56
 
50
- export abstract class LaunchAdapter {
57
+ export function prepareFailure(result: PrepareResult): string {
58
+ return [
59
+ `${FRAMEWORK_NAME}: preparing ${result.repositoryId} for OpenCode failed.`,
60
+ ...result.steps.flatMap((step) =>
61
+ step.outcome === "failed" ? [`- ${step.step}: ${step.detail ?? "failed"}`] : [],
62
+ ),
63
+ ].join("\n");
64
+ }
65
+
66
+ /**
67
+ * `C` is the context one launch carries from `planLaunch` to `run`: returned,
68
+ * never stored, since one adapter instance serves every launch.
69
+ */
70
+ export abstract class LaunchAdapter<C extends AdapterContext = AdapterContext> {
51
71
  abstract readonly toolName: string;
52
72
  readonly interactive: boolean = false;
53
73
 
54
- abstract buildArgs(context: AdapterContext, args: string[]): string[];
74
+ abstract buildArgs(context: C, args: string[]): string[];
75
+
76
+ /**
77
+ * The runtime's share of a launch after a successful Session Prepare. Nothing
78
+ * to plan by default; an adapter that narrows `C` overrides it.
79
+ */
80
+ async planLaunch(context: AdapterContext, _prepared: PrepareResult): Promise<C> {
81
+ return context as C;
82
+ }
83
+
84
+ /** What a failed prepare raises: the failed step's own error, else a summary. */
85
+ launchFailure(prepared: PrepareResult): unknown {
86
+ return prepared.failure ?? new LaunchPreflightError(prepareFailure(prepared));
87
+ }
55
88
 
56
- async validateLaunch(_context: AdapterContext): Promise<void> {}
89
+ async validateLaunch(_context: C): Promise<void> {}
57
90
 
58
- extendEnvironment(_context: AdapterContext): NodeJS.ProcessEnv {
91
+ extendEnvironment(_context: C): NodeJS.ProcessEnv {
59
92
  return {};
60
93
  }
61
94
 
@@ -67,7 +100,9 @@ export abstract class LaunchAdapter {
67
100
  environment(context: AdapterContext): NodeJS.ProcessEnv {
68
101
  const reactDoctorEnabled = context.capabilities.some((c) => c.name === "react-doctor");
69
102
  const projection = context.repository
70
- ? projectionEnvironment(buildProjection(context.companionPath, context.repository))
103
+ ? projectionEnvironment(
104
+ buildProjection(context.companionPath, context.repository, getWrapperBinPath()),
105
+ )
71
106
  : companionEnvironment(context.companionPath);
72
107
  const env: NodeJS.ProcessEnv = {
73
108
  ...process.env,
@@ -92,7 +127,7 @@ export abstract class LaunchAdapter {
92
127
  return env;
93
128
  }
94
129
 
95
- async prepareLaunch(context: AdapterContext, args: string[]): Promise<PreparedLaunch> {
130
+ async prepareLaunch(context: C, args: string[]): Promise<PreparedLaunch> {
96
131
  const builtArgs = this.buildArgs(context, args);
97
132
  const baseEnv = this.environment(context);
98
133
  const extendedEnv = this.extendEnvironment(context);
@@ -120,7 +155,7 @@ export abstract class LaunchAdapter {
120
155
  return !(this.interactive && process.stdin.isTTY);
121
156
  }
122
157
 
123
- async run(context: AdapterContext, args: string[]): Promise<AdapterResult> {
158
+ async run(context: C, args: string[]): Promise<AdapterResult> {
124
159
  const launch = await this.prepareLaunch(context, args);
125
160
 
126
161
  return new Promise((resolve, reject) => {
@@ -1,21 +1,128 @@
1
1
  import { existsSync } from "node:fs";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
2
4
 
5
+ import { FRAMEWORK_NAME } from "../../../framework";
3
6
  import { buildCompanionGuidance } from "../../../playbooks/companion-guidance";
4
- import { getContextModePackageRoot, validateContextModePackage } from "../../context-mode-package";
5
- import { getClaudePluginRoot, validateClaudePluginAssets } from "../../package-paths";
7
+ import {
8
+ planClaudeDelivery,
9
+ type ClaudeDeliveryPlan,
10
+ type SessionKind,
11
+ } from "../../../runtime/claude-delivery-plan";
12
+ import { hookGroupKey, type ClaudeHookGroup } from "../../../runtime/claude-hook-group";
13
+ import { NOT_WRAPPED } from "../../../runtime/wrap-state";
14
+ import { writeAtomic } from "../../../runtime/write-atomic";
6
15
  import {
7
16
  getCompanionClaudeMcpConfigPath,
8
17
  getCompanionClaudeSettingsPath,
9
18
  } from "../../../tools/setup/providers/claude";
19
+ import type { ClaudeSettings } from "../../../tools/setup/providers/claude-format";
20
+ import { getContextModePackageRoot, validateContextModePackage } from "../../context-mode-package";
21
+ import { getClaudePluginRoot, validateClaudePluginAssets } from "../../package-paths";
22
+ import type { PrepareResult } from "../session-prepare";
23
+ import { LaunchPreflightError } from "../types";
10
24
  import { type AdapterContext, LaunchAdapter } from "./base";
11
25
 
12
- export class ClaudeAdapter extends LaunchAdapter {
26
+ /** The paths are present only where the plan names that channel and the file exists. */
27
+ interface ClaudeDelivery {
28
+ plan: ClaudeDeliveryPlan;
29
+ settingsPath?: string;
30
+ mcpConfigPath?: string;
31
+ }
32
+
33
+ /** A Claude launch's context: the delivery its plan chose, and the files it chose or wrote. */
34
+ interface ClaudeLaunchContext extends AdapterContext {
35
+ delivery: ClaudeDelivery;
36
+ }
37
+
38
+ /** Where a launch with a Working Repository writes its derived `--settings`; git-ignored. */
39
+ export function launchSettingsPath(companionPath: string, repositoryId: string): string {
40
+ const safeId = repositoryId.replace(/[^A-Za-z0-9._-]/g, "_");
41
+ return path.join(companionPath, ".claude", "state", `launch-settings.${safeId}.json`);
42
+ }
43
+
44
+ /**
45
+ * Companion settings minus `exclude`, so each wrap-carried group runs once. A
46
+ * file, not inline JSON (platform arg limits); per repository, so concurrent
47
+ * launches never race.
48
+ */
49
+ async function writeLaunchSettings(
50
+ companionSettingsPath: string,
51
+ target: string,
52
+ exclude: readonly ClaudeHookGroup[],
53
+ ): Promise<void> {
54
+ const settings = JSON.parse(await fs.readFile(companionSettingsPath, "utf8")) as ClaudeSettings;
55
+ const excluded = new Set(exclude.map(hookGroupKey));
56
+ const hooks: Record<string, ClaudeHookGroup[]> = {};
57
+ for (const [event, groups] of Object.entries(settings.hooks ?? {})) {
58
+ const kept = (groups ?? []).filter((group) => !excluded.has(hookGroupKey(group)));
59
+ if (kept.length > 0) hooks[event] = kept;
60
+ }
61
+ const derived: ClaudeSettings = { ...settings, hooks };
62
+ if (Object.keys(hooks).length === 0) delete derived.hooks;
63
+
64
+ await fs.mkdir(path.dirname(target), { recursive: true });
65
+ await writeAtomic(target, `${JSON.stringify(derived, null, 2)}\n`);
66
+ }
67
+
68
+ /** The `--settings` file the plan's hook owner calls for, writing it when derived. */
69
+ async function settingsPathFor(
70
+ plan: ClaudeDeliveryPlan,
71
+ companionPath: string,
72
+ repository: AdapterContext["repository"],
73
+ ): Promise<string | undefined> {
74
+ const companionSettingsPath = getCompanionClaudeSettingsPath(companionPath);
75
+ if (plan.hooks.owner === "none" || !existsSync(companionSettingsPath)) return undefined;
76
+ if (plan.hooks.owner === "settings") return companionSettingsPath;
77
+ /** Owner `wrap` is planned only for `managed-repo`, which has a repository. */
78
+ const settingsPath = launchSettingsPath(companionPath, repository!.id);
79
+ await writeLaunchSettings(companionSettingsPath, settingsPath, plan.hooks.launchSettingsExclude);
80
+ return settingsPath;
81
+ }
82
+
83
+ export class ClaudeAdapter extends LaunchAdapter<ClaudeLaunchContext> {
13
84
  readonly toolName = "claude";
14
85
  readonly interactive = true;
15
86
 
16
- async validateLaunch(context: AdapterContext): Promise<void> {
17
- // The bundled mate plugin carries the artifact-path guard; never launch a
18
- // managed session without it.
87
+ /**
88
+ * Plans from the wrap state the prepare's pass left, never a fresh read. A
89
+ * repository left without a Claude wrap fails preflight: the plan would defer
90
+ * to a wrap that is not there.
91
+ */
92
+ async planLaunch(context: AdapterContext, prepared: PrepareResult): Promise<ClaudeLaunchContext> {
93
+ const { repository, companionPath } = context;
94
+ const kind: SessionKind = repository ? "managed-repo" : "managed-companion";
95
+ const wrap = prepared.wrapState ?? NOT_WRAPPED;
96
+ if (repository && !wrap.runtimes.has("claude")) {
97
+ throw new LaunchPreflightError(
98
+ `${FRAMEWORK_NAME}: ${repository.id} is not wrapped for Claude after its prepare; run \`${FRAMEWORK_NAME} wrap\` in ${repository.path}.`,
99
+ );
100
+ }
101
+ const plan = planClaudeDelivery(kind, wrap);
102
+ const mcpConfigPath = getCompanionClaudeMcpConfigPath(companionPath);
103
+ const settingsPath = await settingsPathFor(plan, companionPath, repository);
104
+ const delivery: ClaudeDelivery = {
105
+ plan,
106
+ ...(plan.mcp === "mcp-config" && existsSync(mcpConfigPath) ? { mcpConfigPath } : {}),
107
+ ...(settingsPath ? { settingsPath } : {}),
108
+ };
109
+ return { ...context, delivery };
110
+ }
111
+
112
+ /** A failed Claude wrap pass names the entry that failed. */
113
+ launchFailure(prepared: PrepareResult): unknown {
114
+ const failed = prepared.steps.find((step) => step.outcome === "failed");
115
+ if (failed?.step === "working-target") {
116
+ return new LaunchPreflightError(
117
+ `${FRAMEWORK_NAME}: reconciling the Claude wrap of ${prepared.repositoryId} failed: ${failed.detail ?? "failed"}`,
118
+ { cause: prepared.failure },
119
+ );
120
+ }
121
+ return super.launchFailure(prepared);
122
+ }
123
+
124
+ async validateLaunch(context: ClaudeLaunchContext): Promise<void> {
125
+ /** The bundled plugin carries the artifact-path guard; never launch without it. */
19
126
  try {
20
127
  validateClaudePluginAssets();
21
128
  } catch (error) {
@@ -37,24 +144,20 @@ export class ClaudeAdapter extends LaunchAdapter {
37
144
  }
38
145
  }
39
146
 
40
- buildArgs(context: AdapterContext, args: string[]): string[] {
41
- // Load Mate-managed Claude config from companion settings when present,
42
- // layered on top of the user's user/project/local sources so global
43
- // skills and plugins still load. Only add the flags when the file
44
- // exists so a companion without generated settings still launches with
45
- // Claude's default discovery.
46
- const companionSettingsPath = getCompanionClaudeSettingsPath(context.companionPath);
47
- const settingsArgs = existsSync(companionSettingsPath)
48
- ? ["--setting-sources", "user,project,local", "--settings", companionSettingsPath]
49
- : [];
50
-
51
- // Load Mate-managed MCP servers from the companion `.mcp.json` when present.
52
- // It lives in the companion repo (not the working-repo cwd), so it must be
53
- // passed explicitly rather than relying on Claude's cwd auto-discovery.
54
- const companionMcpConfigPath = getCompanionClaudeMcpConfigPath(context.companionPath);
55
- const mcpConfigArgs = existsSync(companionMcpConfigPath)
56
- ? ["--mcp-config", companionMcpConfigPath]
57
- : [];
147
+ /**
148
+ * Plan in, flags out; decides nothing. `--append-system-prompt`, `--plugin-dir`
149
+ * and context-mode are unconditional: no settings document can carry them.
150
+ */
151
+ buildArgs(context: ClaudeLaunchContext, args: string[]): string[] {
152
+ const { plan, settingsPath, mcpConfigPath } = context.delivery;
153
+ /** Layered on the user's own sources so global skills and plugins still load. */
154
+ const settingsArgs =
155
+ plan.hooks.owner !== "none" && settingsPath
156
+ ? ["--setting-sources", "user,project,local", "--settings", settingsPath]
157
+ : [];
158
+ const addDirArgs = plan.dirAccess === "add-dir" ? ["--add-dir", context.companionPath] : [];
159
+ const mcpConfigArgs =
160
+ plan.mcp === "mcp-config" && mcpConfigPath ? ["--mcp-config", mcpConfigPath] : [];
58
161
  const contextModeArgs = context.capabilities.some(
59
162
  (capability) => capability.name === "context-mode",
60
163
  )
@@ -62,14 +165,12 @@ export class ClaudeAdapter extends LaunchAdapter {
62
165
  : [];
63
166
 
64
167
  return [
65
- "--add-dir",
66
- context.companionPath,
168
+ ...addDirArgs,
67
169
  "--append-system-prompt",
68
170
  buildCompanionGuidance(context),
69
171
  ...settingsArgs,
70
172
  ...mcpConfigArgs,
71
- // Bundled mate plugin (hooks) resolved from the running mate-core
72
- // installation, coexisting with the context-mode plugin dir.
173
+ /** The bundled plugin from the running install, beside context-mode's. */
73
174
  "--plugin-dir",
74
175
  getClaudePluginRoot(),
75
176
  ...contextModeArgs,
@@ -77,7 +178,7 @@ export class ClaudeAdapter extends LaunchAdapter {
77
178
  ];
78
179
  }
79
180
 
80
- extendEnvironment(_context: AdapterContext): NodeJS.ProcessEnv {
181
+ extendEnvironment(_context: ClaudeLaunchContext): NodeJS.ProcessEnv {
81
182
  return {
82
183
  CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: "1",
83
184
  };
@@ -7,15 +7,12 @@ const path = await import("node:path");
7
7
  import { MATE_ENV } from "../../../runtime/env";
8
8
  import { validateGuidanceData, type MateGuidanceFile } from "../../../runtime/guidance";
9
9
 
10
- import { getOpenCodePluginRoot, validateOpenCodePluginAssets } from "../../package-paths";
11
- import {
12
- mergeOpenCodeConfigContent,
13
- readOpenCodeConfig,
14
- } from "../../../tools/setup/providers/opencode-format";
15
- import { renderCompanionExternalDirectoryPermissions } from "../../../tools/setup/providers/opencode";
10
+ import { validateOpenCodePluginAssets } from "../../package-paths";
11
+ import { readOpenCodeConfig } from "../../../tools/setup/providers/opencode-format";
16
12
  import { buildOpenCodeGuidance } from "../opencode-guidance";
13
+ import type { PrepareResult } from "../session-prepare";
17
14
  import { LaunchPreflightError } from "../types";
18
- import { type AdapterContext, LaunchAdapter } from "./base";
15
+ import { type AdapterContext, LaunchAdapter, prepareFailure } from "./base";
19
16
 
20
17
  /** The Mate plugin is a V2 plugin; an older host rejects it at load time. */
21
18
  const MIN_OPENCODE_MAJOR = 2;
@@ -62,6 +59,13 @@ export class OpenCodeAdapter extends LaunchAdapter {
62
59
  );
63
60
  }
64
61
 
62
+ /** A prepared repository reports its failure as one summary, as it always has. */
63
+ launchFailure(prepared: PrepareResult): unknown {
64
+ return prepared.repositoryId
65
+ ? new LaunchPreflightError(prepareFailure(prepared))
66
+ : super.launchFailure(prepared);
67
+ }
68
+
65
69
  buildArgs(context: AdapterContext, args: string[]): string[] {
66
70
  const codeDir = context.launchWorkingDirectory;
67
71
  const isTui = args.length === 0 || args[0]?.startsWith("-");
@@ -74,20 +78,14 @@ export class OpenCodeAdapter extends LaunchAdapter {
74
78
  return hostsServer && !choosesServer ? [command, STANDALONE_FLAG, ...rest] : [command, ...rest];
75
79
  }
76
80
 
81
+ /**
82
+ * No `OPENCODE_CONFIG_DIR` and no config overlay: OpenCode 2.x treats the
83
+ * config dir as a replacement for the user's global one, and everything the
84
+ * overlay carried now lives in the wrapped `.opencode/opencode.json`. An
85
+ * inherited `OPENCODE_CONFIG_DIR` or `OPENCODE_CONFIG_CONTENT` passes through.
86
+ */
77
87
  extendEnvironment(context: AdapterContext): NodeJS.ProcessEnv {
78
88
  return {
79
- OPENCODE_CONFIG_DIR: path.join(context.companionPath, ".opencode"),
80
- OPENCODE_CONFIG_CONTENT: mergeOpenCodeConfigContent(
81
- {
82
- permissions: renderCompanionExternalDirectoryPermissions(context.companionPath),
83
- plugins: [getOpenCodePluginRoot()],
84
- references: {
85
- mate: context.companionPath,
86
- },
87
- },
88
- process.env,
89
- { appendSkillPaths: [path.join(context.companionPath, ".agents", "skills")] },
90
- ),
91
89
  // Built fresh per launch from the live capability config; always set so
92
90
  // a stale value inherited from an outer Mate session can never leak in.
93
91
  [MATE_ENV.guidanceJson]: JSON.stringify(this.buildGuidance(context)),
@@ -12,6 +12,8 @@ import {
12
12
  type UpstreamTarget,
13
13
  } from "../../runtime/companion-git";
14
14
  import { recordCompanionSync } from "../../runtime/companion-git-state";
15
+ import { withCompanionLock, type CompanionLockOptions } from "../../runtime/companion-lock";
16
+ import { FRAMEWORK_NAME } from "../../framework";
15
17
  import { LaunchPreflightError } from "./types";
16
18
 
17
19
  type GitCommandResult = GitResult;
@@ -427,10 +429,35 @@ export class CompanionGitSync {
427
429
  }
428
430
  }
429
431
 
432
+ /**
433
+ * An attended operation waits for whoever holds the companion lock, however
434
+ * long: the holder may be an operator answering a merge conflict. The line
435
+ * explains the pause; a crashed holder frees the lock by itself.
436
+ */
437
+ export function attendedLockOptions(companionPath: string): CompanionLockOptions {
438
+ return {
439
+ timeoutMs: Number.POSITIVE_INFINITY,
440
+ onWait: () =>
441
+ console.error(
442
+ `${FRAMEWORK_NAME}: waiting for another companion sync to finish (${companionPath})…`,
443
+ ),
444
+ };
445
+ }
446
+
447
+ /** Serialized with every other synchronization of this companion. */
430
448
  export async function syncCompanionGit(
431
449
  companionPath: string,
432
450
  workingRepoPath?: string,
433
451
  interactiveGit = false,
434
452
  ): Promise<CompanionGitSyncResult> {
435
- return new CompanionGitSync().sync(companionPath, workingRepoPath, interactiveGit);
453
+ const locked = await withCompanionLock(companionPath, attendedLockOptions(companionPath), () =>
454
+ new CompanionGitSync().sync(companionPath, workingRepoPath, interactiveGit),
455
+ );
456
+ if (!locked.acquired) {
457
+ throw new CompanionGitSyncError(
458
+ companionPath,
459
+ "Another companion synchronization is still running.",
460
+ );
461
+ }
462
+ return locked.value;
436
463
  }
@@ -15,7 +15,7 @@ import {
15
15
  } from "../../tools/setup/dynamic-plugins/install";
16
16
  import { hydrateDynamicPlugins } from "../../tools/setup/dynamic-plugins/hydrate";
17
17
  import { applySetupCompatibilities } from "../../tools/setup";
18
- import { findRepoLocalRegistryFile } from "./repo-local-registry";
18
+ import { findRepoLocalRegistryFile } from "./repository-link";
19
19
  import { companionGit, describeGitFailure, type CompanionGit } from "../../runtime/companion-git";
20
20
 
21
21
  export interface HubGitOptions {
@@ -319,6 +319,7 @@ export async function syncHub(
319
319
  const { config, store } = await assertHubRoot(hubPath);
320
320
  const results: HubSyncResult[] = [];
321
321
  for (const member of config.hub!.companions) {
322
+ // oxlint-disable-next-line no-await-in-loop -- members sync one at a time, in declared order
322
323
  const result = await syncHubMember(hubPath, member, options);
323
324
  results.push(result);
324
325
  if (result.materializedCommit && result.status === "updated") {
@@ -1,7 +1,10 @@
1
1
  import path from "node:path";
2
2
 
3
3
  import { FRAMEWORK_NAME } from "../../framework";
4
- import { getDefaultSetupSelections } from "./setup-compatibilities";
4
+ import {
5
+ getDefaultSetupSelections,
6
+ listSetupCapabilityCompatibilities,
7
+ } from "./setup-compatibilities";
5
8
  import { YamlFileStore } from "./yaml-file-store";
6
9
  import { ConfigError, type FrameworkConfig } from "./types";
7
10
 
@@ -143,7 +146,7 @@ function validatePluginDeclarations(config: FrameworkConfig): void {
143
146
 
144
147
  export const AUDIENCE_NAME_PATTERN = /^[a-z][a-z0-9-]{0,63}$/;
145
148
 
146
- const AUDIENCE_KEYS = ["plugins", "studio"] as const;
149
+ const AUDIENCE_KEYS = ["capabilities", "plugins", "studio"] as const;
147
150
 
148
151
  function isRecord(value: unknown): value is Record<string, unknown> {
149
152
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -182,6 +185,43 @@ function validateAudiencePlugins(audience: string, plugins: unknown): Map<string
182
185
  return versions;
183
186
  }
184
187
 
188
+ function validateAudienceCapabilities(
189
+ audience: string,
190
+ capabilities: unknown,
191
+ baseNames: ReadonlySet<string>,
192
+ ): void {
193
+ if (!Array.isArray(capabilities)) {
194
+ throw new ConfigError(`audience "${audience}": capabilities must be a list.`);
195
+ }
196
+ const builtin = new Set(
197
+ listSetupCapabilityCompatibilities().map((entry) => entry.capability.name),
198
+ );
199
+ const seen = new Set<string>();
200
+ for (const entry of capabilities) {
201
+ if (!isRecord(entry) || typeof entry.name !== "string" || entry.name === "") {
202
+ throw new ConfigError(
203
+ `audience "${audience}": capabilities entry must have a string name: ${JSON.stringify(entry)}`,
204
+ );
205
+ }
206
+ if (builtin.has(entry.name)) {
207
+ throw new ConfigError(
208
+ `audience "${audience}": capability "${entry.name}" is built in; declare it in the base capabilities.`,
209
+ );
210
+ }
211
+ if (baseNames.has(entry.name)) {
212
+ throw new ConfigError(
213
+ `audience "${audience}": capability "${entry.name}" is also declared in base capabilities.`,
214
+ );
215
+ }
216
+ if (seen.has(entry.name)) {
217
+ throw new ConfigError(
218
+ `audience "${audience}": capability "${entry.name}" is declared more than once.`,
219
+ );
220
+ }
221
+ seen.add(entry.name);
222
+ }
223
+ }
224
+
185
225
  /**
186
226
  * Validates the optional `audiences` mapping. Only companions may declare
187
227
  * audiences; names are safe identifiers and keys form a closed set so typos
@@ -198,6 +238,7 @@ function validateAudiences(config: FrameworkConfig): void {
198
238
  }
199
239
 
200
240
  const basePackages = new Set((config.plugins ?? []).map((plugin) => plugin.package));
241
+ const baseCapabilities = new Set((config.capabilities ?? []).map((entry) => entry.name));
201
242
  const seen = new Map<string, { audience: string; version: string }>();
202
243
 
203
244
  for (const [name, audience] of Object.entries(audiences)) {
@@ -217,6 +258,10 @@ function validateAudiences(config: FrameworkConfig): void {
217
258
  }
218
259
  }
219
260
 
261
+ if (audience.capabilities !== undefined) {
262
+ validateAudienceCapabilities(name, audience.capabilities, baseCapabilities);
263
+ }
264
+
220
265
  if (audience.plugins !== undefined) {
221
266
  for (const [pkg, version] of validateAudiencePlugins(name, audience.plugins)) {
222
267
  if (basePackages.has(pkg)) {
@@ -265,3 +310,12 @@ export class ConfigStore extends YamlFileStore<FrameworkConfig> {
265
310
  return config;
266
311
  }
267
312
  }
313
+
314
+ /** The companion's own selection, read from files: wrapping runs no setup pass. */
315
+ export async function readCompanionConfig(companionPath: string): Promise<FrameworkConfig> {
316
+ return mergeWithDefaults(
317
+ await new ConfigStore(
318
+ path.join(companionPath, `.${FRAMEWORK_NAME}`, "config", "framework.yaml"),
319
+ ).load(),
320
+ );
321
+ }
@@ -4,12 +4,13 @@ import path from "node:path";
4
4
  import { FRAMEWORK_NAME } from "../../framework";
5
5
  import { parse } from "yaml";
6
6
  import { CompanionRegistryStore } from "./companion-registry-store";
7
- import { CompanionResolver } from "./companion-resolver";
8
- import { companionRootedStore } from "./companion-store";
9
- import { readCompanionRegistry } from "./companion-registry-reader";
10
7
  import { ConfigStore } from "./config-store";
11
8
  import { GlobalConfigStore } from "./global-config-store";
12
- import { findRepoLocalLinkedRepository } from "./repo-local-registry";
9
+ import {
10
+ backfillCompanionRegistration,
11
+ CompanionResolver,
12
+ findRepoLocalLinkedRepository,
13
+ } from "./repository-link";
13
14
  import { projectWorkingRepositoryBestEffort } from "./working-repo-projection";
14
15
  import {
15
16
  AmbiguousCompanionError,
@@ -257,31 +258,6 @@ export async function resolveForLaunch(
257
258
  throw new WorkingRepoRequiredError();
258
259
  }
259
260
 
260
- /**
261
- * Self-heals repos linked before the companion-side registry write existed
262
- * (or whose companion registry was reset): backfills the companion-side
263
- * entry from the already-trusted repo-local pointer on every launch
264
- * resolution. Best-effort — never blocks a launch.
265
- */
266
- async function backfillCompanionRegistration(
267
- companionPath: string,
268
- repository: LinkedRepository,
269
- ): Promise<void> {
270
- try {
271
- const { repos } = await readCompanionRegistry(companionPath).catch(() => ({ repos: [] }));
272
- const existing = repos.find((repo) => repo.id === repository.id);
273
- if (existing?.path === repository.path) return;
274
-
275
- await companionRootedStore(companionPath).registerRepository(repository);
276
- } catch (error) {
277
- console.error(
278
- `${FRAMEWORK_NAME}: warning: failed to backfill companion registry for ${repository.id}: ${
279
- error instanceof Error ? error.message : String(error)
280
- }`,
281
- );
282
- }
283
- }
284
-
285
261
  // Resolves context for `mate cap` commands. Unlike resolveForLaunch, this allows
286
262
  // running from the companion directory itself — cap commands manage companion state
287
263
  // and don't need a working-repo cwd. resolveForLaunch intentionally lacks this