@uniqbit/mate-core 0.16.0 → 0.17.0-canary.2

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 (43) hide show
  1. package/package.json +1 -1
  2. package/src/cli/commands/companion/companion.ts +8 -0
  3. package/src/cli/commands/companion/prepare.ts +76 -0
  4. package/src/cli/commands/companion/register.ts +47 -0
  5. package/src/cli/commands/launch/claude.ts +5 -3
  6. package/src/cli/commands/launch/opencode.ts +5 -3
  7. package/src/cli/commands/launch/shared.ts +41 -12
  8. package/src/cli/commands/shared/companion-selection.ts +3 -1
  9. package/src/cli/commands/studio/index.ts +85 -6
  10. package/src/cli/commands/studio/routes.ts +1 -1
  11. package/src/cli/commands/studio/selection.ts +11 -2
  12. package/src/cli/commands/studio/server.ts +212 -17
  13. package/src/cli/commands/studio/vault.ts +607 -0
  14. package/src/cli/commands/studio/views/client.ts +141 -1
  15. package/src/cli/commands/studio/views/companion-selector.tsx +1 -1
  16. package/src/cli/commands/studio/views/document.tsx +136 -3
  17. package/src/cli/commands/studio/views/model.ts +13 -0
  18. package/src/cli/commands/studio/views/styles.ts +16 -0
  19. package/src/cli/main.ts +33 -4
  20. package/src/cli/usage.ts +6 -3
  21. package/src/index.ts +1 -0
  22. package/src/lib/context-mode-package.ts +7 -4
  23. package/src/lib/opencode-plugin-package.ts +10 -4
  24. package/src/lib/orchestrator/adapters/base.ts +65 -10
  25. package/src/lib/orchestrator/adapters/opencode.ts +4 -2
  26. package/src/lib/orchestrator/companion-registration.ts +73 -0
  27. package/src/lib/orchestrator/framework-context.ts +29 -3
  28. package/src/lib/orchestrator/launcher.ts +46 -22
  29. package/src/lib/orchestrator/projection-record.ts +24 -0
  30. package/src/lib/orchestrator/types.ts +11 -0
  31. package/src/lib/prebuilt-workspace-prepare.ts +152 -0
  32. package/src/lib/prebuilt-workspace.ts +236 -0
  33. package/src/lib/preinstalled-plugins.ts +82 -0
  34. package/src/lib/update-checker.ts +39 -0
  35. package/src/opencode/companion-hooks.ts +45 -20
  36. package/src/playbooks/companion-guidance.ts +1 -0
  37. package/src/runtime/companion-guidance.ts +61 -15
  38. package/src/runtime/env.ts +3 -3
  39. package/src/runtime/index.ts +1 -0
  40. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +127 -77
  41. package/src/tools/setup/capabilities/context-mode.ts +5 -0
  42. package/src/tools/setup/plugin.ts +7 -1
  43. package/src/tools/setup/providers/opencode.ts +24 -5
@@ -0,0 +1,236 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import semver from "semver";
5
+
6
+ import { OPENCODE_PLUGIN_PACKAGE_NAME } from "./opencode-plugin-package";
7
+ import { getCurrentVersion } from "./update-checker";
8
+ import {
9
+ CONTEXT_MODE_NODE_REQUIREMENT,
10
+ CONTEXT_MODE_PACKAGE_NAME,
11
+ CONTEXT_MODE_VERSION,
12
+ } from "./context-mode-package";
13
+ import { getLocalWorkspaceDir } from "./preinstalled-plugins";
14
+
15
+ /**
16
+ * Packages the distribution selects and pins for the machine-local workspace.
17
+ * A bundle may carry any subset; it may carry nothing else.
18
+ */
19
+ export function getDistributionLocalDependencies(): Record<string, string> {
20
+ return {
21
+ [CONTEXT_MODE_PACKAGE_NAME]: CONTEXT_MODE_VERSION,
22
+ [OPENCODE_PLUGIN_PACKAGE_NAME]: getCurrentVersion(),
23
+ };
24
+ }
25
+
26
+ /** Assets a package must carry to count as fully installed rather than merely present. */
27
+ const REQUIRED_PACKAGE_ASSETS: Record<string, string[]> = {
28
+ [CONTEXT_MODE_PACKAGE_NAME]: [
29
+ ".claude-plugin/plugin.json",
30
+ "hooks/hooks.json",
31
+ "skills/context-mode/SKILL.md",
32
+ "build/adapters/opencode/plugin.js",
33
+ ],
34
+ };
35
+
36
+ /** Runtime requirement the distribution knows independently of the installed manifest. */
37
+ const DECLARED_NODE_REQUIREMENTS: Record<string, string> = {
38
+ [CONTEXT_MODE_PACKAGE_NAME]: CONTEXT_MODE_NODE_REQUIREMENT,
39
+ };
40
+
41
+ export interface PrebuiltWorkspaceTarget {
42
+ platform: string;
43
+ arch: string;
44
+ nodeVersion: string;
45
+ }
46
+
47
+ export interface PrebuiltWorkspaceValidation {
48
+ ok: boolean;
49
+ /** One entry per failure, each naming the package or file it is about. */
50
+ failures: string[];
51
+ /** Installed versions keyed by package name, for comparing one bundle with another. */
52
+ packages: Record<string, string>;
53
+ }
54
+
55
+ export function currentTarget(): PrebuiltWorkspaceTarget {
56
+ return { platform: process.platform, arch: process.arch, nodeVersion: process.versions.node };
57
+ }
58
+
59
+ interface InstalledManifest {
60
+ name?: string;
61
+ version?: string;
62
+ dependencies?: Record<string, string>;
63
+ optionalDependencies?: Record<string, string>;
64
+ peerDependenciesMeta?: Record<string, { optional?: boolean }>;
65
+ engines?: { node?: string };
66
+ os?: string[];
67
+ cpu?: string[];
68
+ }
69
+
70
+ async function readJson<T>(file: string): Promise<T | null> {
71
+ try {
72
+ return JSON.parse(await fs.readFile(file, "utf8")) as T;
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ /** Node's own resolution: nearest `node_modules` first, then each ancestor up to the bundle root. */
79
+ async function resolveFromBundle(
80
+ nodeModules: string,
81
+ fromDir: string,
82
+ dependency: string,
83
+ ): Promise<InstalledManifest | null> {
84
+ let dir = fromDir;
85
+ for (;;) {
86
+ const candidate = path.join(dir, "node_modules", ...dependency.split("/"), "package.json");
87
+ const manifest = await readJson<InstalledManifest>(candidate);
88
+ if (manifest) return manifest;
89
+ if (path.resolve(dir, "node_modules") === path.resolve(nodeModules)) return null;
90
+ const parent = path.dirname(dir);
91
+ if (parent === dir) return null;
92
+ dir = parent;
93
+ }
94
+ }
95
+
96
+ async function listInstalledPackages(nodeModules: string): Promise<string[]> {
97
+ const entries = await fs.readdir(nodeModules, { withFileTypes: true }).catch(() => []);
98
+ const names: string[] = [];
99
+ for (const entry of entries) {
100
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
101
+ if (entry.name.startsWith(".")) continue;
102
+ if (entry.name.startsWith("@")) {
103
+ const scoped = await fs
104
+ .readdir(path.join(nodeModules, entry.name), { withFileTypes: true })
105
+ .catch(() => []);
106
+ for (const child of scoped) {
107
+ if (child.isDirectory() || child.isSymbolicLink())
108
+ names.push(`${entry.name}/${child.name}`);
109
+ }
110
+ continue;
111
+ }
112
+ names.push(entry.name);
113
+ }
114
+ return names;
115
+ }
116
+
117
+ /**
118
+ * Validates an explicitly supplied, fully installed dependency bundle before
119
+ * anything is copied: distribution-owned versions, a complete dependency
120
+ * graph, integrity, target architecture, and runtime compatibility. Nothing is
121
+ * installed, resolved, or downloaded — every answer comes off the filesystem.
122
+ */
123
+ export async function validatePrebuiltWorkspace(
124
+ bundlePath: string,
125
+ target: PrebuiltWorkspaceTarget = currentTarget(),
126
+ ): Promise<PrebuiltWorkspaceValidation> {
127
+ const root = path.resolve(bundlePath);
128
+ const failures: string[] = [];
129
+ const packages: Record<string, string> = {};
130
+
131
+ const manifest = await readJson<InstalledManifest>(path.join(root, "package.json"));
132
+ if (!manifest || typeof manifest.dependencies !== "object" || manifest.dependencies === null) {
133
+ return {
134
+ ok: false,
135
+ failures: [`${root}: no framework-generated package.json with a dependencies map.`],
136
+ packages,
137
+ };
138
+ }
139
+
140
+ const owned = getDistributionLocalDependencies();
141
+ const nodeModules = path.join(root, "node_modules");
142
+
143
+ for (const [name, version] of Object.entries(manifest.dependencies)) {
144
+ const pinned = owned[name];
145
+ if (pinned === undefined) {
146
+ failures.push(`${name}: not a distribution-owned dependency; the bundle may not carry it.`);
147
+ continue;
148
+ }
149
+ if (version !== pinned) {
150
+ failures.push(`${name}: the distribution pins ${pinned}; the bundle declares ${version}.`);
151
+ continue;
152
+ }
153
+
154
+ const installed = await readJson<InstalledManifest>(
155
+ path.join(nodeModules, ...name.split("/"), "package.json"),
156
+ );
157
+ if (!installed) {
158
+ failures.push(`${name}: declared by the bundle but not installed in its node_modules.`);
159
+ continue;
160
+ }
161
+ if (installed.version !== pinned) {
162
+ failures.push(
163
+ `${name}: expected ${pinned} installed; found ${installed.version ?? "an unknown version"}.`,
164
+ );
165
+ continue;
166
+ }
167
+ packages[name] = pinned;
168
+
169
+ const missingAssets: string[] = [];
170
+ for (const asset of REQUIRED_PACKAGE_ASSETS[name] ?? []) {
171
+ const assetPath = path.join(nodeModules, ...name.split("/"), ...asset.split("/"));
172
+ if (
173
+ !(await fs.access(assetPath).then(
174
+ () => true,
175
+ () => false,
176
+ ))
177
+ )
178
+ missingAssets.push(asset);
179
+ }
180
+ if (missingAssets.length > 0) {
181
+ failures.push(`${name}: installed copy is incomplete; missing ${missingAssets.join(", ")}.`);
182
+ }
183
+
184
+ const declaredRequirement = DECLARED_NODE_REQUIREMENTS[name];
185
+ if (declaredRequirement && !semver.satisfies(target.nodeVersion, declaredRequirement)) {
186
+ failures.push(
187
+ `${name}: requires Node.js ${declaredRequirement}; the target runs ${target.nodeVersion}.`,
188
+ );
189
+ }
190
+ }
191
+
192
+ for (const name of await listInstalledPackages(nodeModules)) {
193
+ const packageDir = path.join(nodeModules, ...name.split("/"));
194
+ const installed = await readJson<InstalledManifest>(path.join(packageDir, "package.json"));
195
+ if (!installed) {
196
+ failures.push(`${name}: installed directory has no readable package.json.`);
197
+ continue;
198
+ }
199
+
200
+ if (
201
+ Array.isArray(installed.os) &&
202
+ installed.os.length > 0 &&
203
+ !installed.os.includes(target.platform)
204
+ ) {
205
+ failures.push(
206
+ `${name}: built for ${installed.os.join(", ")}; the target platform is ${target.platform}.`,
207
+ );
208
+ }
209
+ if (
210
+ Array.isArray(installed.cpu) &&
211
+ installed.cpu.length > 0 &&
212
+ !installed.cpu.includes(target.arch)
213
+ ) {
214
+ failures.push(
215
+ `${name}: built for ${installed.cpu.join(", ")}; the target architecture is ${target.arch}.`,
216
+ );
217
+ }
218
+ if (installed.engines?.node && !semver.satisfies(target.nodeVersion, installed.engines.node)) {
219
+ failures.push(
220
+ `${name}: requires Node.js ${installed.engines.node}; the target runs ${target.nodeVersion}.`,
221
+ );
222
+ }
223
+
224
+ for (const dependency of Object.keys(installed.dependencies ?? {})) {
225
+ if (installed.optionalDependencies?.[dependency] !== undefined) continue;
226
+ const resolved = await resolveFromBundle(nodeModules, packageDir, dependency);
227
+ if (!resolved) {
228
+ failures.push(`${dependency}: required by ${name} but not installed in the bundle.`);
229
+ }
230
+ }
231
+ }
232
+
233
+ return { ok: failures.length === 0, failures, packages };
234
+ }
235
+
236
+ export { getLocalWorkspaceDir };
@@ -0,0 +1,82 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import semver from "semver";
5
+
6
+ import { FRAMEWORK_NAME } from "../framework";
7
+
8
+ /**
9
+ * A plugin reference bound to installed files is an absolute path to the
10
+ * installed package root, so the Agent Runtime loads it instead of resolving,
11
+ * fetching, or installing the published package.
12
+ */
13
+ /** The framework-owned machine-local workspace; a leaf definition, since the
14
+ * managed-reference predicates depend on it. */
15
+ export function getLocalWorkspaceDir(companionPath: string): string {
16
+ return path.join(companionPath, `.${FRAMEWORK_NAME}`, "plugins", ".local");
17
+ }
18
+
19
+ /**
20
+ * Written by prebuilt preparation, and the only mark that the distribution
21
+ * *supplied* an installed workspace. Ordinary setup installs into the same
22
+ * location, and that must keep writing the published reference.
23
+ */
24
+ export const PREBUILT_BUNDLE_MARKER = ".mate-prebuilt.json";
25
+
26
+ export function getPreinstalledPluginDir(companionPath: string, packageName: string): string {
27
+ return path.join(getLocalWorkspaceDir(companionPath), "node_modules", ...packageName.split("/"));
28
+ }
29
+
30
+ /** True for a reference that is a path whose last segment is the package itself. */
31
+ export function isPreinstalledPluginPath(entry: unknown, packageName: string): boolean {
32
+ if (typeof entry !== "string") return false;
33
+ if (!entry.includes("/") && !entry.includes("\\")) return false;
34
+ const normalized = entry.replace(/\\/g, "/").replace(/\/+$/, "");
35
+ return normalized.endsWith(`/${packageName}`);
36
+ }
37
+
38
+ export class PreinstalledPluginMismatchError extends Error {}
39
+
40
+ /**
41
+ * Resolves an installed copy of a declared plugin package from a workspace the
42
+ * distribution supplied. Returns null where none was supplied — the ordinary
43
+ * workstation, whose setup-installed workspace carries no bundle marker, so the
44
+ * published reference is written exactly as before. A supplied copy that does
45
+ * not match what the Capability declares is reported by name rather than bound,
46
+ * and nothing is installed to repair it.
47
+ */
48
+ export async function resolvePreinstalledPluginReference(
49
+ companionPath: string,
50
+ packageName: string,
51
+ expectedVersion: string,
52
+ nodeVersion: string = process.versions.node,
53
+ ): Promise<string | null> {
54
+ const workspace = getLocalWorkspaceDir(companionPath);
55
+ const supplied = await fs.access(path.join(workspace, PREBUILT_BUNDLE_MARKER)).then(
56
+ () => true,
57
+ () => false,
58
+ );
59
+ if (!supplied) return null;
60
+
61
+ const dir = getPreinstalledPluginDir(companionPath, packageName);
62
+ let manifest: { version?: string; engines?: { node?: string } };
63
+ try {
64
+ manifest = JSON.parse(
65
+ await fs.readFile(path.join(dir, "package.json"), "utf8"),
66
+ ) as typeof manifest;
67
+ } catch {
68
+ return null;
69
+ }
70
+
71
+ if (manifest.version !== expectedVersion) {
72
+ throw new PreinstalledPluginMismatchError(
73
+ `${packageName}: the installed copy at ${dir} is ${manifest.version ?? "an unknown version"}; the capability declares ${expectedVersion}.`,
74
+ );
75
+ }
76
+ if (manifest.engines?.node && !semver.satisfies(nodeVersion, manifest.engines.node)) {
77
+ throw new PreinstalledPluginMismatchError(
78
+ `${packageName}: the installed copy at ${dir} requires Node.js ${manifest.engines.node}; this runtime is ${nodeVersion}.`,
79
+ );
80
+ }
81
+ return dir;
82
+ }
@@ -20,6 +20,42 @@ export const updateCheckerDeps = {
20
20
  toIsoString: () => new Date().toISOString(),
21
21
  };
22
22
 
23
+ /** Declares that the installation's version is fixed by the artifact it was built into. */
24
+ export const UPDATE_POLICY_ENV = "MATE_UPDATE_POLICY";
25
+
26
+ export type UpdatePolicy = "default" | "pinned";
27
+
28
+ /**
29
+ * Reported at most once per process: the three automatic-update entry points
30
+ * each consult the policy, and an unrecognized value must not be repeated.
31
+ */
32
+ let unrecognizedPolicyReported = false;
33
+
34
+ /** Test seam — the policy is process-wide, so its report latch must be resettable. */
35
+ export function resetUpdatePolicyReport(): void {
36
+ unrecognizedPolicyReported = false;
37
+ }
38
+
39
+ export function getUpdatePolicy(env: NodeJS.ProcessEnv = process.env): UpdatePolicy {
40
+ const raw = env[UPDATE_POLICY_ENV];
41
+ if (raw === undefined || raw.trim() === "") return "default";
42
+
43
+ const value = raw.trim();
44
+ if (value === "pinned") return "pinned";
45
+
46
+ if (!unrecognizedPolicyReported) {
47
+ unrecognizedPolicyReported = true;
48
+ process.stderr.write(
49
+ `${FRAMEWORK_NAME}: unrecognized ${UPDATE_POLICY_ENV} value \`${value}\`; automatic update handling is unchanged.\n`,
50
+ );
51
+ }
52
+ return "default";
53
+ }
54
+
55
+ export function isPinnedDeployment(env: NodeJS.ProcessEnv = process.env): boolean {
56
+ return getUpdatePolicy(env) === "pinned";
57
+ }
58
+
23
59
  const updateStateFileSlug = (packageName: string): string =>
24
60
  packageName.replace(/^@/, "").replace(/[^a-zA-Z0-9._-]+/g, "-");
25
61
 
@@ -75,6 +111,7 @@ export function isNewer(latest: string, current: string): boolean {
75
111
  }
76
112
 
77
113
  export async function showUpdateBannerIfAvailable(store: UpdateStateStore): Promise<void> {
114
+ if (isPinnedDeployment()) return;
78
115
  try {
79
116
  const state = await store.load();
80
117
  if (!state.latestVersion) return;
@@ -95,6 +132,7 @@ export async function showUpdateBannerIfAvailable(store: UpdateStateStore): Prom
95
132
  * must stop; state-load failures never block.
96
133
  */
97
134
  export async function enforceUpdateIfRequired(store: UpdateStateStore): Promise<boolean> {
135
+ if (isPinnedDeployment()) return false;
98
136
  if (!getUpdateConfig().enforce) return false;
99
137
  try {
100
138
  const state = await store.load();
@@ -119,6 +157,7 @@ export async function fetchLatestVersion(): Promise<string> {
119
157
  }
120
158
 
121
159
  export function scheduleBackgroundCheck(store: UpdateStateStore): Promise<void> {
160
+ if (isPinnedDeployment()) return Promise.resolve();
122
161
  return (async () => {
123
162
  try {
124
163
  const state = await store.load();
@@ -9,6 +9,7 @@ import {
9
9
  unattendedSyncStalenessLines,
10
10
  } from "../runtime/companion-sync";
11
11
  import { hasLaunchEnvironment } from "../runtime/env";
12
+ import { MATE_ENV } from "../runtime/env-names";
12
13
  import {
13
14
  buildArtifactError,
14
15
  extractPatchPaths,
@@ -122,7 +123,12 @@ export async function repairCompanionGitOnce(
122
123
  client: PluginInput["client"] | undefined,
123
124
  env: Record<string, string | undefined> = process.env,
124
125
  ): Promise<string[]> {
125
- if (!context.companionPath || hasLaunchEnvironment(env)) return [];
126
+ if (
127
+ !context.companionPath ||
128
+ (hasLaunchEnvironment(env) &&
129
+ (env[MATE_ENV.repositoryPath] !== undefined || env[MATE_ENV.gitAutoMode] !== "1"))
130
+ )
131
+ return [];
126
132
  if (repairedCompanions.has(context.companionPath)) return [];
127
133
  repairedCompanions.add(context.companionPath);
128
134
 
@@ -182,7 +188,7 @@ type ToolAfterInput = Parameters<NonNullable<Hooks["tool.execute.after"]>>[0];
182
188
  export const CompanionHooksPlugin: Plugin = async (pluginInput = {} as PluginInput) => {
183
189
  const { client, $ } = pluginInput;
184
190
  const context = readContext();
185
- if (!context.companionPath || !context.repositoryPath) return {};
191
+ if (!context.companionPath) return {};
186
192
 
187
193
  /**
188
194
  * The repair sits above the returned hooks, so anything escaping it would
@@ -195,29 +201,33 @@ export const CompanionHooksPlugin: Plugin = async (pluginInput = {} as PluginInp
195
201
  const reactDoctorScansInFlight = new Set<string>();
196
202
 
197
203
  return {
198
- event: async ({ event }: PluginEventInput) => {
199
- if (event.type !== "session.idle") return;
200
- const sessionID = event.properties.sessionID;
201
- if (
202
- !context.reactDoctorEnabled ||
203
- reactDoctorScansInFlight.has(sessionID) ||
204
- !dirtyReactDoctorSessions.delete(sessionID)
205
- ) {
206
- return;
207
- }
208
- await runReactDoctorScan(context, client, $, sessionID, reactDoctorScansInFlight);
209
- },
204
+ ...(context.repositoryPath
205
+ ? {
206
+ event: async ({ event }: PluginEventInput) => {
207
+ if (event.type !== "session.idle") return;
208
+ const sessionID = event.properties.sessionID;
209
+ if (
210
+ !context.reactDoctorEnabled ||
211
+ reactDoctorScansInFlight.has(sessionID) ||
212
+ !dirtyReactDoctorSessions.delete(sessionID)
213
+ ) {
214
+ return;
215
+ }
216
+ await runReactDoctorScan(context, client, $, sessionID, reactDoctorScansInFlight);
217
+ },
218
+ }
219
+ : {}),
210
220
  "tool.execute.before": async (input: ToolBeforeInput, output: ToolBeforeOutput) => {
211
221
  const toolName = String(input.tool ?? "");
212
222
  const args = output.args ?? {};
213
- if (["write", "edit"].includes(toolName)) {
223
+ if (context.repositoryPath && ["write", "edit"].includes(toolName)) {
214
224
  const filePath = String(args.filePath ?? "");
215
225
  if (filePath && shouldBlockArtifactWrite(context, filePath)) {
216
226
  throw new Error(buildArtifactError(context, filePath));
217
227
  }
218
228
  refuseForkedCompanionWrite(context, filePath);
219
229
  }
220
- if (toolName === "apply_patch") {
230
+ if (context.repositoryPath && toolName === "apply_patch") {
221
231
  for (const filePath of extractPatchPaths(String(args.patchText ?? ""))) {
222
232
  if (shouldBlockArtifactWrite(context, filePath)) {
223
233
  throw new Error(buildArtifactError(context, filePath));
@@ -225,12 +235,27 @@ export const CompanionHooksPlugin: Plugin = async (pluginInput = {} as PluginInp
225
235
  refuseForkedCompanionWrite(context, filePath);
226
236
  }
227
237
  }
228
- },
229
- "tool.execute.after": async (input: ToolAfterInput) => {
230
- if (context.reactDoctorEnabled && REACT_DOCTOR_EDIT_TOOLS.has(input.tool)) {
231
- dirtyReactDoctorSessions.add(input.sessionID);
238
+ if (!context.repositoryPath && toolName === "write") {
239
+ refuseForkedCompanionWrite(context, String(args.filePath ?? ""));
240
+ }
241
+ if (!context.repositoryPath && toolName === "edit") {
242
+ refuseForkedCompanionWrite(context, String(args.filePath ?? ""));
243
+ }
244
+ if (!context.repositoryPath && toolName === "apply_patch") {
245
+ for (const filePath of extractPatchPaths(String(args.patchText ?? ""))) {
246
+ refuseForkedCompanionWrite(context, filePath);
247
+ }
232
248
  }
233
249
  },
250
+ ...(context.repositoryPath
251
+ ? {
252
+ "tool.execute.after": async (input: ToolAfterInput) => {
253
+ if (context.reactDoctorEnabled && REACT_DOCTOR_EDIT_TOOLS.has(input.tool)) {
254
+ dirtyReactDoctorSessions.add(input.sessionID);
255
+ }
256
+ },
257
+ }
258
+ : {}),
234
259
  };
235
260
  };
236
261
 
@@ -18,6 +18,7 @@ import { getWrapperBinPath } from "../lib/package-paths";
18
18
 
19
19
  export {
20
20
  GRAPHIFY_SHARED_COMPANION_PATH_CONTRACT,
21
+ GRAPHIFY_COMPANION_PATH_CONTRACT,
21
22
  buildCodebaseExplorationGuidanceSection,
22
23
  hasGraphifyCapability,
23
24
  hasOpenspecCapability,
@@ -25,7 +25,7 @@ export interface GuidanceCapability {
25
25
 
26
26
  export interface GuidanceContext {
27
27
  companionPath: string;
28
- repository: { id: string; path: string };
28
+ repository?: { id: string; path: string };
29
29
  capabilities?: GuidanceCapability[];
30
30
  }
31
31
 
@@ -43,14 +43,21 @@ export function hasTokensaveCapability(capabilities: GuidanceCapability[] = []):
43
43
 
44
44
  export const GRAPHIFY_SHARED_COMPANION_PATH_CONTRACT =
45
45
  "$MATE_ARTIFACT_PATH/.graphify/$MATE_REPO_ID/graphify-out/";
46
+ export const GRAPHIFY_COMPANION_PATH_CONTRACT =
47
+ "$MATE_ARTIFACT_PATH/.graphify/__companion__/graphify-out/";
46
48
 
47
49
  export function buildCodebaseExplorationGuidanceSection(
48
50
  options: {
49
51
  useGraphify?: boolean;
50
52
  useTokensave?: boolean;
53
+ graphifyOutContract?: string;
51
54
  } = {},
52
55
  ): string {
53
- const { useGraphify = false, useTokensave = false } = options;
56
+ const {
57
+ useGraphify = false,
58
+ useTokensave = false,
59
+ graphifyOutContract = GRAPHIFY_SHARED_COMPANION_PATH_CONTRACT,
60
+ } = options;
54
61
 
55
62
  if (!useGraphify && !useTokensave) {
56
63
  return "";
@@ -58,7 +65,7 @@ export function buildCodebaseExplorationGuidanceSection(
58
65
 
59
66
  if (useGraphify && useTokensave) {
60
67
  return `<codebase-exploration-rules priority="mandatory">
61
- <path role="graphify-out">${GRAPHIFY_SHARED_COMPANION_PATH_CONTRACT}</path>
68
+ <path role="graphify-out">${graphifyOutContract}</path>
62
69
  <trigger>Codebase-understanding: architecture, tracing, integrations, impact, "how does X work?"</trigger>
63
70
  <order>tokensave -> graphify -> grep/glob/read. MUST NOT skip steps.
64
71
  1. tokensave_context first.
@@ -71,7 +78,7 @@ export function buildCodebaseExplorationGuidanceSection(
71
78
 
72
79
  if (useGraphify) {
73
80
  return `<codebase-exploration-rules priority="mandatory">
74
- <path role="graphify-out">${GRAPHIFY_SHARED_COMPANION_PATH_CONTRACT}</path>
81
+ <path role="graphify-out">${graphifyOutContract}</path>
75
82
  <trigger>Codebase-understanding: architecture, tracing, integrations, impact, "how does X work?"</trigger>
76
83
  <order>graphify -> grep/glob/read. MUST try graphify before raw source.
77
84
  1. graphify query "<question>" first.
@@ -111,7 +118,6 @@ export function buildCompanionPolicyXml(
111
118
  ` <overview>You are operating inside the ${FRAMEWORK_NAME} companion repository.</overview>`,
112
119
  " <context>",
113
120
  " <paths>",
114
- ` <path role="working-repository" env="MATE_REPO_PATH">${context.repository.path}</path>`,
115
121
  ` <path role="companion-repository" env="MATE_ARTIFACT_PATH">${context.companionPath}</path>`,
116
122
  ` <path role="package-wrapper-bin" env="MATE_WRAPPER_BIN_PATH">${wrapperBinPath}</path>`,
117
123
  " </paths>",
@@ -120,19 +126,47 @@ export function buildCompanionPolicyXml(
120
126
  ` <cli name="graphify" type="wrapper" invokeAs="${path.join(wrapperBinPath, "graphify")}" />`,
121
127
  ` <cli name="${FRAMEWORK_NAME}" type="global" invokeAs="${FRAMEWORK_NAME}" />`,
122
128
  " </cli-tools>",
123
- ` <linked-repository id="${context.repository.id}" />`,
124
129
  " </context>",
125
130
  " <mandatory-rules>",
126
- ` <rule id="artifact-location" severity="critical">Agent artifacts MUST go to ${context.companionPath}, NEVER ${context.repository.path}. Artifacts include plans, specs, ADRs, todos, notes, handoffs, reasoning docs, and scratch files.</rule>`,
127
- ` <rule id="pre-write-classification" severity="critical">Before ANY write, classify the target as product-code or agent-artifact. If unsure, treat it as agent-artifact.</rule>`,
128
- ` <rule id="product-code-location" severity="critical">Product code (README, docs, source, tests) belongs in ${context.repository.path}. Agent-artifacts belong in ${context.companionPath}.</rule>`,
129
- ` <rule id="local-artifact-exception" severity="critical">Only write artifacts in ${context.repository.path} when the exact path is gitignored AND intentionally local-only; otherwise use ${context.companionPath}.</rule>`,
130
- ` <rule id="guardrail" severity="critical">Bad artifact writes to ${context.repository.path} are rejected. Classify correctly first.</rule>`,
131
- ` <rule id="companion-multi-repository" severity="critical">This Companion Repository may serve multiple Working Repositories. The working-repository path above identifies this session's single primary Working Repository, not the companion's full repository set. The Companion Repository is the shared artifact and context plane; the primary Working Repository is the product-code plane.</rule>`,
132
- ` <rule id="repository-area-scope" severity="critical">For domain modeling, identify the canonical Working Repository and its repository-relative Area first. A checkout basename or Area alone is not a repository identity. Shared context applies only when the Companion Repository's CONTEXT-MAP explicitly maps it to the current repository and Area.</rule>`,
133
131
  ` <rule id="wrapper-only-cli-execution" severity="critical">For every CLI declared in cli-tools, invoke the exact path in its invokeAs attribute. Correct: ${path.join(wrapperBinPath, "openspec")} status ... . Incorrect: openspec status ... . Do not run bare openspec or graphify commands and do not rely on PATH, aliases, or shell functions. If the exact wrapper path is unavailable, stop and report it.</rule>`,
134
132
  ];
135
133
 
134
+ if (context.repository) {
135
+ lines.splice(
136
+ 8,
137
+ 0,
138
+ ` <path role="working-repository" env="MATE_REPO_PATH">${context.repository.path}</path>`,
139
+ );
140
+ lines.splice(
141
+ lines.indexOf(" </context>"),
142
+ 0,
143
+ ` <linked-repository id="${context.repository.id}" />`,
144
+ );
145
+ lines.splice(
146
+ lines.indexOf(" </mandatory-rules>"),
147
+ 0,
148
+ ` <rule id="artifact-location" severity="critical">Agent artifacts MUST go to ${context.companionPath}, NEVER ${context.repository.path}. Artifacts include plans, specs, ADRs, todos, notes, handoffs, reasoning docs, and scratch files.</rule>`,
149
+ ` <rule id="pre-write-classification" severity="critical">Before ANY write, classify the target as product-code or agent-artifact. If unsure, treat it as agent-artifact.</rule>`,
150
+ ` <rule id="product-code-location" severity="critical">Product code (README, docs, source, tests) belongs in ${context.repository.path}. Agent-artifacts belong in ${context.companionPath}.</rule>`,
151
+ ` <rule id="local-artifact-exception" severity="critical">Only write artifacts in ${context.repository.path} when the exact path is gitignored AND intentionally local-only; otherwise use ${context.companionPath}.</rule>`,
152
+ ` <rule id="guardrail" severity="critical">Bad artifact writes to ${context.repository.path} are rejected. Classify correctly first.</rule>`,
153
+ ` <rule id="companion-multi-repository" severity="critical">This Companion Repository may serve multiple Working Repositories. The working-repository path above identifies this session's single primary Working Repository, not the companion's full repository set. The Companion Repository is the shared artifact and context plane; the primary Working Repository is the product-code plane.</rule>`,
154
+ ` <rule id="repository-area-scope" severity="critical">For domain modeling, identify the canonical Working Repository and its repository-relative Area first. A checkout basename or Area alone is not a repository identity. Shared context applies only when the Companion Repository's CONTEXT-MAP explicitly maps it to the current repository and Area.</rule>`,
155
+ );
156
+ } else {
157
+ lines.splice(
158
+ lines.indexOf(" </mandatory-rules>"),
159
+ 0,
160
+ " <session>Companion-scoped launch with no Working Repository.</session>",
161
+ );
162
+ lines.splice(
163
+ lines.indexOf(" </mandatory-rules>"),
164
+ 0,
165
+ ` <rule id="artifact-location" severity="critical">Agent artifacts MUST go to ${context.companionPath}. The Companion Repository is the artifact and context plane for this session.</rule>`,
166
+ ` <rule id="pre-write-classification" severity="critical">Before ANY write, classify the target as product-code or agent-artifact. If unsure, treat it as agent-artifact.</rule>`,
167
+ );
168
+ }
169
+
136
170
  if (hasOpenspecCapability(context.capabilities)) {
137
171
  lines.push(
138
172
  ` <rule id="openspec-scope" severity="critical">In OpenSpec, the frontmatter repository identifies the Working Repository. areas are repository-relative paths within that repository. Canonical specs use repository plus flat areas; change artifacts use paired scopes entries. In a monorepo, an Area normally stops at the package root. A cross-repository change may declare multiple scopes, but each resulting spec remains owned by one repository, and each requirement must bind its Area explicitly.</rule>`,
@@ -168,6 +202,9 @@ export function buildCompanionGuidance(
168
202
  buildCodebaseExplorationGuidanceSection({
169
203
  useGraphify: graphifyEnabled,
170
204
  useTokensave: tokensaveEnabled,
205
+ graphifyOutContract: context.repository
206
+ ? GRAPHIFY_SHARED_COMPANION_PATH_CONTRACT
207
+ : GRAPHIFY_COMPANION_PATH_CONTRACT,
171
208
  }),
172
209
  );
173
210
  }
@@ -186,11 +223,17 @@ export function buildCompanionGuidance(
186
223
  * so capability-gated companion-policy rules — e.g. openspec-publish — render
187
224
  * exactly as they do for the Claude provider.
188
225
  */
189
- export function buildOpenCodeGuidance(capabilities: GuidanceCapability[]): MateGuidanceFile {
226
+ export function buildOpenCodeGuidance(
227
+ capabilities: GuidanceCapability[],
228
+ options: { companionScoped?: boolean } = {},
229
+ ): MateGuidanceFile {
230
+ const repository = options.companionScoped
231
+ ? undefined
232
+ : { id: "$MATE_REPO_ID", path: "$MATE_REPO_PATH" };
190
233
  const companionGuidance = buildCompanionPolicyXml(
191
234
  {
192
235
  companionPath: "$MATE_ARTIFACT_PATH",
193
- repository: { id: "$MATE_REPO_ID", path: "$MATE_REPO_PATH" },
236
+ repository,
194
237
  capabilities,
195
238
  },
196
239
  { wrapperBinPath: "$MATE_WRAPPER_BIN_PATH" },
@@ -200,6 +243,9 @@ export function buildOpenCodeGuidance(capabilities: GuidanceCapability[]): MateG
200
243
  const codebaseExplorationGuidance = buildCodebaseExplorationGuidanceSection({
201
244
  useGraphify: graphifyEnabled,
202
245
  useTokensave: tokensaveEnabled,
246
+ graphifyOutContract: options.companionScoped
247
+ ? GRAPHIFY_COMPANION_PATH_CONTRACT
248
+ : GRAPHIFY_SHARED_COMPANION_PATH_CONTRACT,
203
249
  });
204
250
  const errors: string[] = [];
205
251
 
@@ -90,9 +90,9 @@ export function readCompanionRuntimeContext(
90
90
  }
91
91
 
92
92
  /**
93
- * A session is Mate-managed only when both the companion path and the working
94
- * repository path resolved. Plugins must stay inert otherwise.
93
+ * A session is Mate-managed when a companion path resolved. A companion-scoped
94
+ * launch intentionally has no working repository.
95
95
  */
96
96
  export function isManagedCompanionContext(context: CompanionRuntimeContext): boolean {
97
- return Boolean(context.companionPath && context.repositoryPath);
97
+ return Boolean(context.companionPath);
98
98
  }