@uniqbit/mate-core 0.18.3 → 0.18.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/opencode-plugin/src/add-dir.ts +10 -76
- package/opencode-plugin/src/audience-layer.ts +91 -0
- package/opencode-plugin/src/companion.ts +28 -11
- package/opencode-plugin/src/launch-environment.ts +29 -0
- package/opencode-plugin/src/server.ts +25 -8
- package/package.json +1 -1
- package/src/cli/commands/cap/index-cmd.ts +2 -11
- package/src/cli/commands/doctor.ts +86 -4
- package/src/cli/commands/install.ts +8 -0
- package/src/cli/commands/studio/terminal-launch.ts +16 -7
- package/src/cli/commands/wrap.ts +29 -4
- package/src/cli/usage.ts +2 -1
- package/src/hooks/session-guidance.ts +20 -2
- package/src/lib/orchestrator/adapters/base.ts +3 -2
- package/src/lib/orchestrator/adapters/claude.ts +130 -20
- package/src/lib/orchestrator/adapters/opencode.ts +55 -15
- package/src/lib/orchestrator/config-store.ts +74 -1
- package/src/lib/orchestrator/openspec-store.ts +202 -0
- package/src/lib/orchestrator/projection-claude-skills.ts +88 -35
- package/src/lib/orchestrator/projection-entries.ts +46 -0
- package/src/lib/orchestrator/projection-openspec-store.ts +164 -0
- package/src/lib/orchestrator/projection-types.ts +9 -1
- package/src/lib/orchestrator/session-prepare.ts +82 -4
- package/src/lib/orchestrator/working-repo-projection.ts +2 -1
- package/src/lib/orchestrator/wrap-pass.ts +92 -1
- package/src/opencode/companion-hooks.ts +7 -4
- package/src/opencode/projected-guidance.ts +6 -13
- package/src/runtime/audience-layer.ts +84 -0
- package/src/runtime/guidance.ts +17 -0
- package/src/runtime/opencode-audience-layer.ts +176 -0
- package/src/runtime/policy.ts +14 -1
- package/src/runtime/projection.ts +1 -1
- package/src/runtime/runtime-document-manifest.ts +17 -0
- package/src/runtime/wrap-state.ts +4 -0
- package/src/tools/setup/audience-isolation.fixture.ts +325 -0
- package/src/tools/setup/audience-layer.ts +207 -0
- package/src/tools/setup/audience-registrations.ts +52 -0
- package/src/tools/setup/capabilities/openspec.ts +9 -0
- package/src/tools/setup/capabilities/tokensave.ts +11 -0
- package/src/tools/setup/contribution-ledger.ts +207 -0
- package/src/tools/setup/dynamic-plugins/audiences.ts +89 -9
- package/src/tools/setup/dynamic-plugins/declarations.ts +3 -2
- package/src/tools/setup/dynamic-plugins/hydrate.ts +7 -4
- package/src/tools/setup/engine.ts +141 -10
- package/src/tools/setup/isolation-diagnostics.ts +139 -0
- package/src/tools/setup/plugin.ts +19 -4
- package/src/tools/setup/plugins/gitignore.ts +7 -1
- package/src/tools/setup/providers/claude.ts +27 -2
- package/src/tools/setup/providers/opencode.ts +10 -0
- package/src/tools/setup/registry.ts +24 -2
- package/src/tools/setup/surface-target.ts +13 -4
- package/src/tools/setup/upgrade-sweep.ts +153 -0
- package/src/tools/setup/working-repo-cleanup.ts +2 -0
- package/src/tools/setup/working-repo-local-state.ts +9 -1
- package/src/tools/setup.ts +9 -6
- package/wrappers/bin/openspec +42 -1
|
@@ -1,15 +1,8 @@
|
|
|
1
|
-
import fs from "node:fs/promises";
|
|
2
1
|
import path from "node:path";
|
|
3
2
|
|
|
4
3
|
import type { Context } from "@opencode/plugin/promise/plugin";
|
|
5
4
|
|
|
6
|
-
|
|
7
|
-
id: string;
|
|
8
|
-
name: string;
|
|
9
|
-
description?: string;
|
|
10
|
-
path: string;
|
|
11
|
-
content: string;
|
|
12
|
-
};
|
|
5
|
+
import { loadSkillInfos } from "../../src/runtime/opencode-audience-layer";
|
|
13
6
|
|
|
14
7
|
function companionSkillPaths(companionPath: string): string[] {
|
|
15
8
|
return [
|
|
@@ -23,87 +16,28 @@ function isWithin(root: string, candidate: string): boolean {
|
|
|
23
16
|
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
24
17
|
}
|
|
25
18
|
|
|
26
|
-
function parseFrontmatter(content: string): { name?: string; description?: string } {
|
|
27
|
-
const match = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
28
|
-
if (!match) return {};
|
|
29
|
-
|
|
30
|
-
const scalar = (key: string) => {
|
|
31
|
-
const value = match[1].match(new RegExp(`^${key}:\\s*(.+)$`, "m"))?.[1]?.trim();
|
|
32
|
-
return value?.replace(
|
|
33
|
-
/^(?:"([\s\S]*)"|'([\s\S]*)')$/,
|
|
34
|
-
(_all, doubleQuoted, singleQuoted) => doubleQuoted ?? singleQuoted,
|
|
35
|
-
);
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
return { name: scalar("name"), description: scalar("description") };
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
async function findSkillFiles(directory: string): Promise<string[]> {
|
|
42
|
-
try {
|
|
43
|
-
const entries = await fs.readdir(directory, { withFileTypes: true });
|
|
44
|
-
const files: string[] = [];
|
|
45
|
-
const subdirectories: Promise<string[]>[] = [];
|
|
46
|
-
for (const entry of entries) {
|
|
47
|
-
const entryPath = path.join(directory, entry.name);
|
|
48
|
-
if (entry.isFile() && entry.name === "SKILL.md") files.push(entryPath);
|
|
49
|
-
else if (entry.isDirectory()) subdirectories.push(findSkillFiles(entryPath));
|
|
50
|
-
}
|
|
51
|
-
const nested = await Promise.all(subdirectories);
|
|
52
|
-
return [...files, ...nested.flat()];
|
|
53
|
-
} catch {
|
|
54
|
-
return [];
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function skillID(name: string): string {
|
|
59
|
-
return (
|
|
60
|
-
name
|
|
61
|
-
.toLowerCase()
|
|
62
|
-
.replace(/[^a-z0-9_-]+/g, "-")
|
|
63
|
-
.replace(/^-+|-+$/g, "") || "mate-skill"
|
|
64
|
-
);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
async function loadCompanionSkills(companionPath: string): Promise<CompanionSkill[]> {
|
|
68
|
-
const files = (await Promise.all(companionSkillPaths(companionPath).map(findSkillFiles))).flat();
|
|
69
|
-
const skills = await Promise.all(
|
|
70
|
-
files.map(async (filePath): Promise<CompanionSkill | null> => {
|
|
71
|
-
try {
|
|
72
|
-
const content = await fs.readFile(filePath, "utf8");
|
|
73
|
-
const metadata = parseFrontmatter(content);
|
|
74
|
-
const name = metadata.name || path.basename(path.dirname(filePath));
|
|
75
|
-
return {
|
|
76
|
-
id: skillID(name),
|
|
77
|
-
name,
|
|
78
|
-
...(metadata.description ? { description: metadata.description } : {}),
|
|
79
|
-
path: filePath,
|
|
80
|
-
content,
|
|
81
|
-
};
|
|
82
|
-
} catch {
|
|
83
|
-
/** An unreadable companion skill must not prevent the session from loading. */
|
|
84
|
-
return null;
|
|
85
|
-
}
|
|
86
|
-
}),
|
|
87
|
-
);
|
|
88
|
-
return skills.filter((skill) => skill !== null);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
19
|
/**
|
|
92
20
|
* Grants the session access to the companion and registers its skills. The
|
|
93
21
|
* permission hook only widens access for companion resources; every other
|
|
94
22
|
* evaluation keeps OpenCode's own verdict.
|
|
95
23
|
*/
|
|
96
|
-
export async function registerCompanionAccess(
|
|
24
|
+
export async function registerCompanionAccess(
|
|
25
|
+
api: Context,
|
|
26
|
+
companionPath: string,
|
|
27
|
+
extraRoots: readonly string[] = [],
|
|
28
|
+
): Promise<void> {
|
|
97
29
|
const permissionRoot = path.resolve(companionPath);
|
|
30
|
+
/** An audience layer's files are as readable as the companion's, for this launch only. */
|
|
31
|
+
const allowedRoots = [permissionRoot, ...extraRoots.map((root) => path.resolve(root))];
|
|
98
32
|
|
|
99
33
|
await api.permission.hook("evaluate", (event) => {
|
|
100
|
-
if (event.resources.some((resource) => isWithin(
|
|
34
|
+
if (event.resources.some((resource) => allowedRoots.some((root) => isWithin(root, resource)))) {
|
|
101
35
|
event.effect = "allow";
|
|
102
36
|
delete event.message;
|
|
103
37
|
}
|
|
104
38
|
});
|
|
105
39
|
|
|
106
|
-
const skills = await
|
|
40
|
+
const skills = await loadSkillInfos(companionSkillPaths(permissionRoot));
|
|
107
41
|
if (skills.length === 0) return;
|
|
108
42
|
|
|
109
43
|
await api.skill.transform((editor) => {
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { Context } from "@opencode/plugin/promise/plugin";
|
|
2
|
+
|
|
3
|
+
import { repositoryWrappedForOpenCode } from "../../src/opencode/companion-hooks";
|
|
4
|
+
import { currentAudienceLayer } from "../../src/runtime/audience-layer";
|
|
5
|
+
import { MATE_ENV } from "../../src/runtime/env";
|
|
6
|
+
import { parseGuidanceContent } from "../../src/runtime/guidance";
|
|
7
|
+
import {
|
|
8
|
+
readOpenCodeAudienceLayer,
|
|
9
|
+
type OpenCodeLayerContents,
|
|
10
|
+
} from "../../src/runtime/opencode-audience-layer";
|
|
11
|
+
import { readWrapStateSync } from "../../src/runtime/wrap-state";
|
|
12
|
+
import type { LaunchEnvironment } from "./launch-environment";
|
|
13
|
+
|
|
14
|
+
export interface TrustedAudienceLayer {
|
|
15
|
+
root: string;
|
|
16
|
+
contents: OpenCodeLayerContents;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The audience layer named by a payload the plugin trusts. `env` is the
|
|
21
|
+
* launch environment `pluginLaunchEnvironment` decided on, so a masked
|
|
22
|
+
* payload, a bare session and the shared service all read as `{}` here and
|
|
23
|
+
* never apply a layer.
|
|
24
|
+
*/
|
|
25
|
+
export async function readTrustedAudienceLayer(
|
|
26
|
+
env: LaunchEnvironment,
|
|
27
|
+
): Promise<TrustedAudienceLayer | null> {
|
|
28
|
+
const payload = env[MATE_ENV.guidanceJson];
|
|
29
|
+
if (!payload?.trim()) return null;
|
|
30
|
+
const root = parseGuidanceContent(payload).guidance.audienceLayer;
|
|
31
|
+
if (!root) return null;
|
|
32
|
+
return { root, contents: await readOpenCodeAudienceLayer(root) };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The layer of the audience the session's checkout is wrapped for. The wrap is
|
|
37
|
+
* resolved from the session's own directory, so the shared service serves each
|
|
38
|
+
* checkout its own audience; the environment never selects one.
|
|
39
|
+
*/
|
|
40
|
+
export async function readWrappedAudienceLayer(
|
|
41
|
+
directory: string,
|
|
42
|
+
companionPath: string,
|
|
43
|
+
): Promise<TrustedAudienceLayer | null> {
|
|
44
|
+
const repoRoot = repositoryWrappedForOpenCode(directory);
|
|
45
|
+
if (!repoRoot) return null;
|
|
46
|
+
const audience = readWrapStateSync(repoRoot).audience;
|
|
47
|
+
if (!audience) return null;
|
|
48
|
+
const root = currentAudienceLayer(companionPath, audience);
|
|
49
|
+
return root ? { root, contents: await readOpenCodeAudienceLayer(root) } : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Applies a layer's MCP servers, skills and agents through the plugin API. A
|
|
54
|
+
* new agent id is created by updating it; its prompt goes in `system`.
|
|
55
|
+
*/
|
|
56
|
+
export async function registerAudienceLayer(
|
|
57
|
+
api: Context,
|
|
58
|
+
layer: OpenCodeLayerContents,
|
|
59
|
+
): Promise<void> {
|
|
60
|
+
if (layer.mcpServers.length > 0) {
|
|
61
|
+
await api.mcp.transform((editor) => {
|
|
62
|
+
for (const server of layer.mcpServers) editor.set(server.name, server.config as never);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (layer.skills.length > 0) {
|
|
67
|
+
await api.skill.transform((editor) => {
|
|
68
|
+
for (const skill of layer.skills) {
|
|
69
|
+
if (editor.get(skill.id)) {
|
|
70
|
+
editor.update(skill.id, (current) => Object.assign(current, skill));
|
|
71
|
+
} else {
|
|
72
|
+
editor.add(skill as never);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (layer.agents.length > 0) {
|
|
79
|
+
await api.agent.transform((editor) => {
|
|
80
|
+
for (const agent of layer.agents) {
|
|
81
|
+
editor.update(agent.id, (current) => {
|
|
82
|
+
const target = current as Record<string, unknown>;
|
|
83
|
+
target.name ??= agent.id;
|
|
84
|
+
if (agent.description) target.description = agent.description;
|
|
85
|
+
target.mode = agent.mode;
|
|
86
|
+
target.system = agent.system;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -6,6 +6,7 @@ import { MATE_ENV } from "../../src/runtime/env";
|
|
|
6
6
|
import { COMPANION_POLICY_MARKER, type MateGuidanceFile } from "../../src/runtime/guidance";
|
|
7
7
|
|
|
8
8
|
import type { CompanionContext } from "../../src/opencode/companion-policy";
|
|
9
|
+
import type { LaunchEnvironment } from "./launch-environment";
|
|
9
10
|
import { resolveOpenCodeGuidance } from "../../src/opencode/projected-guidance";
|
|
10
11
|
import {
|
|
11
12
|
runtimeDirectoryPath,
|
|
@@ -23,8 +24,8 @@ function prependPathEntry(pathValue: string | undefined, entry: string): string
|
|
|
23
24
|
* keeps current. `null` when neither exists: guidance naming an unresolved
|
|
24
25
|
* placeholder would send the model to a path that is not there.
|
|
25
26
|
*/
|
|
26
|
-
function resolveWrapperBinPath(): string | null {
|
|
27
|
-
if (
|
|
27
|
+
function resolveWrapperBinPath(env: LaunchEnvironment): string | null {
|
|
28
|
+
if (env.MATE_WRAPPER_BIN_PATH) return env.MATE_WRAPPER_BIN_PATH;
|
|
28
29
|
const runtime = runtimeWrapperBinPath();
|
|
29
30
|
return fs.existsSync(runtime) ? runtime : null;
|
|
30
31
|
}
|
|
@@ -54,8 +55,8 @@ function buildStartupError(details: string[]): Error {
|
|
|
54
55
|
* an Unmanaged Session in a wrapped repository builds the same payload from the
|
|
55
56
|
* companion the projection names, with the Capability names read live.
|
|
56
57
|
*/
|
|
57
|
-
function loadGuidance(directory: string): MateGuidanceFile | null {
|
|
58
|
-
const { guidance, errors } = resolveOpenCodeGuidance(
|
|
58
|
+
function loadGuidance(env: LaunchEnvironment, directory: string): MateGuidanceFile | null {
|
|
59
|
+
const { guidance, errors } = resolveOpenCodeGuidance(env, directory);
|
|
59
60
|
if (errors.length > 0) throw buildStartupError(errors);
|
|
60
61
|
return guidance;
|
|
61
62
|
}
|
|
@@ -76,6 +77,7 @@ function buildSystemPrompt(
|
|
|
76
77
|
context: CompanionContext,
|
|
77
78
|
guidance: MateGuidanceFile,
|
|
78
79
|
wrapperBinPath: string,
|
|
80
|
+
layerGuidance: string,
|
|
79
81
|
): string[] {
|
|
80
82
|
const lines = [materializeCompanionGuidance(guidance.companionGuidance, context, wrapperBinPath)];
|
|
81
83
|
|
|
@@ -87,6 +89,11 @@ function buildSystemPrompt(
|
|
|
87
89
|
lines.push("", `<agents.md>${context.agentsMd.trim()}</agents.md>`);
|
|
88
90
|
}
|
|
89
91
|
|
|
92
|
+
/** The audience's guidance follows the companion's, never replaces it. */
|
|
93
|
+
if (layerGuidance.trim()) {
|
|
94
|
+
lines.push("", layerGuidance.trim());
|
|
95
|
+
}
|
|
96
|
+
|
|
90
97
|
return lines;
|
|
91
98
|
}
|
|
92
99
|
|
|
@@ -101,9 +108,10 @@ function applyCompanionShellEnv(
|
|
|
101
108
|
context: CompanionContext,
|
|
102
109
|
env: Record<string, string | undefined>,
|
|
103
110
|
wrapperBinPath: string,
|
|
111
|
+
launchEnv: LaunchEnvironment,
|
|
104
112
|
): void {
|
|
105
113
|
env.MATE_NAME = context.frameworkName;
|
|
106
|
-
env.MATE_VERSION =
|
|
114
|
+
env.MATE_VERSION = launchEnv.MATE_VERSION ?? "unknown";
|
|
107
115
|
env.MATE_ARTIFACT_PATH = context.companionPath;
|
|
108
116
|
env.MATE_WRAPPER_BIN_PATH = wrapperBinPath;
|
|
109
117
|
if (context.repositoryPath) {
|
|
@@ -171,18 +179,27 @@ function mergeCompanionSystem(system: unknown[], companion: string): string[] {
|
|
|
171
179
|
return parts.length > 0 ? [parts.join("\n\n")] : [];
|
|
172
180
|
}
|
|
173
181
|
|
|
174
|
-
/**
|
|
175
|
-
|
|
176
|
-
|
|
182
|
+
/**
|
|
183
|
+
* Registers guidance, compaction context, shell env, and `companion_paths`.
|
|
184
|
+
* `env` is the launch environment the caller decided to trust; `{}` resolves
|
|
185
|
+
* everything from the Projection Root.
|
|
186
|
+
*/
|
|
187
|
+
export async function registerCompanion(
|
|
188
|
+
api: Context,
|
|
189
|
+
context: CompanionContext,
|
|
190
|
+
env: LaunchEnvironment = process.env,
|
|
191
|
+
layerGuidance = "",
|
|
192
|
+
): Promise<void> {
|
|
193
|
+
const guidance = loadGuidance(env, api.location.directory);
|
|
177
194
|
if (!guidance) return;
|
|
178
195
|
/** Reported, not thrown: the guardrails registered before this must stay live. */
|
|
179
|
-
const wrapperBinPath = resolveWrapperBinPath();
|
|
196
|
+
const wrapperBinPath = resolveWrapperBinPath(env);
|
|
180
197
|
if (!wrapperBinPath) {
|
|
181
198
|
console.error(`mate: ${missingRuntimeDirectoryMessage()}`);
|
|
182
199
|
return;
|
|
183
200
|
}
|
|
184
201
|
|
|
185
|
-
const companion = buildSystemPrompt(context, guidance, wrapperBinPath).join("\n");
|
|
202
|
+
const companion = buildSystemPrompt(context, guidance, wrapperBinPath, layerGuidance).join("\n");
|
|
186
203
|
|
|
187
204
|
await api.session.hook("context", (event) => {
|
|
188
205
|
const merged = mergeCompanionSystem(event.system, companion);
|
|
@@ -199,7 +216,7 @@ export async function registerCompanion(api: Context, context: CompanionContext)
|
|
|
199
216
|
});
|
|
200
217
|
|
|
201
218
|
await api.shell.hook("create.before", (event) => {
|
|
202
|
-
applyCompanionShellEnv(context, event.env, wrapperBinPath);
|
|
219
|
+
applyCompanionShellEnv(context, event.env, wrapperBinPath, env);
|
|
203
220
|
});
|
|
204
221
|
|
|
205
222
|
await api.tool.transform((editor) => {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { MATE_ENV } from "../../src/runtime/env";
|
|
2
|
+
|
|
3
|
+
export type LaunchEnvironment = Record<string, string | undefined>;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* True inside OpenCode's shared background service (`opencode serve --service`),
|
|
7
|
+
* which hosts plugins in-process for every bare `opencode` session. Its
|
|
8
|
+
* environment belongs to whichever process started it, not to any session.
|
|
9
|
+
*/
|
|
10
|
+
export function runsInSharedService(argv: readonly string[]): boolean {
|
|
11
|
+
return argv.includes("serve") && argv.includes("--service");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The environment the plugin may treat as a Mate launch environment: `env`
|
|
16
|
+
* itself for a managed launch, `{}` otherwise so every reader falls back to
|
|
17
|
+
* the Projection Root.
|
|
18
|
+
*
|
|
19
|
+
* Inherited `MATE_*` variables prove nothing — every shell under a Mate session
|
|
20
|
+
* carries them. The guidance payload does: only `mate opencode` sets it, and
|
|
21
|
+
* the plugin masks it in every shell it configures.
|
|
22
|
+
*/
|
|
23
|
+
export function pluginLaunchEnvironment(
|
|
24
|
+
env: LaunchEnvironment,
|
|
25
|
+
argv: readonly string[],
|
|
26
|
+
): LaunchEnvironment {
|
|
27
|
+
const managed = Boolean(env[MATE_ENV.guidanceJson]?.trim()) && !runsInSharedService(argv);
|
|
28
|
+
return managed ? env : {};
|
|
29
|
+
}
|
|
@@ -11,15 +11,21 @@ import { spawnPrepareRunner } from "../../src/runtime/session-prepare-runner";
|
|
|
11
11
|
import { readWrapStateSync } from "../../src/runtime/wrap-state";
|
|
12
12
|
|
|
13
13
|
import { registerCompanionAccess } from "./add-dir";
|
|
14
|
+
import {
|
|
15
|
+
readTrustedAudienceLayer,
|
|
16
|
+
readWrappedAudienceLayer,
|
|
17
|
+
registerAudienceLayer,
|
|
18
|
+
} from "./audience-layer";
|
|
14
19
|
import { missingRuntimeDirectoryMessage, registerCompanion, resolveMateBin } from "./companion";
|
|
20
|
+
import { pluginLaunchEnvironment, type LaunchEnvironment } from "./launch-environment";
|
|
15
21
|
|
|
16
22
|
/**
|
|
17
23
|
* Only a location in a repository wrapped for OpenCode prepares, and only
|
|
18
24
|
* without a launch environment: `mate opencode` has already prepared the
|
|
19
25
|
* session it starts.
|
|
20
26
|
*/
|
|
21
|
-
async function registerPrepare(api: Context, companionPath: string) {
|
|
22
|
-
if (hasLaunchEnvironment(
|
|
27
|
+
async function registerPrepare(api: Context, companionPath: string, env: LaunchEnvironment) {
|
|
28
|
+
if (hasLaunchEnvironment(env)) return undefined;
|
|
23
29
|
const repoRoot = repositoryWrappedForOpenCode(api.location.directory);
|
|
24
30
|
if (!repoRoot) return undefined;
|
|
25
31
|
const mateBin = resolveMateBin();
|
|
@@ -39,19 +45,30 @@ async function registerPrepare(api: Context, companionPath: string) {
|
|
|
39
45
|
/**
|
|
40
46
|
* Mate OpenCode server plugin (OpenCode 2.x). Loaded through the package's
|
|
41
47
|
* `./server` export; stays inert when no companion resolves from the launch
|
|
42
|
-
* environment or a Projection Root above the session directory.
|
|
48
|
+
* environment or a Projection Root above the session directory. The launch
|
|
49
|
+
* environment is decided once here: outside a managed launch every reader
|
|
50
|
+
* gets `{}`, so an environment inherited by the shared service never counts.
|
|
43
51
|
*/
|
|
44
52
|
export const MateOpenCodePlugin = Plugin.define({
|
|
45
53
|
id: "mate-opencode-plugin",
|
|
46
54
|
async setup(api) {
|
|
47
|
-
const
|
|
55
|
+
const env = pluginLaunchEnvironment(process.env, process.argv);
|
|
56
|
+
const context = readContext(env, api.location.directory);
|
|
48
57
|
if (!context.companionPath) return;
|
|
49
58
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
59
|
+
/**
|
|
60
|
+
* A layer comes from a payload this launch trusts, else from the wrap of
|
|
61
|
+
* the session's checkout; an inherited environment never names one.
|
|
62
|
+
*/
|
|
63
|
+
const layer =
|
|
64
|
+
(await readTrustedAudienceLayer(env)) ??
|
|
65
|
+
(await readWrappedAudienceLayer(api.location.directory, context.companionPath));
|
|
66
|
+
await registerCompanionAccess(api, context.companionPath, layer ? [layer.root] : []);
|
|
67
|
+
await registerCompanionHooks(api, context, env);
|
|
68
|
+
await registerCompanion(api, context, env, layer?.contents.guidance);
|
|
69
|
+
if (layer) await registerAudienceLayer(api, layer.contents);
|
|
53
70
|
/** Last: the guardrails above are live while the local phase holds `setup`. */
|
|
54
|
-
const prepareCleanup = await registerPrepare(api, context.companionPath);
|
|
71
|
+
const prepareCleanup = await registerPrepare(api, context.companionPath, env);
|
|
55
72
|
return async () => {
|
|
56
73
|
await prepareCleanup?.();
|
|
57
74
|
};
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
|
@@ -3,6 +3,7 @@ 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";
|
|
@@ -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
|
-
/**
|
|
70
|
-
|
|
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
|
-
|
|
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) {
|
|
@@ -3,9 +3,11 @@ import path from "node:path";
|
|
|
3
3
|
|
|
4
4
|
import { FRAMEWORK_NAME } from "../../../framework";
|
|
5
5
|
import { ConfigStore, mergeWithDefaults } from "../../../lib/orchestrator/config-store";
|
|
6
|
+
import { currentAudienceLayer } from "../../../runtime/audience-layer";
|
|
6
7
|
import {
|
|
7
8
|
effectiveStudioAgent,
|
|
8
9
|
readAudienceSelection,
|
|
10
|
+
resolveAudience,
|
|
9
11
|
} from "../../../tools/setup/dynamic-plugins/audiences";
|
|
10
12
|
import type { StudioInventory, StudioInventoryCompanion } from "./inventory";
|
|
11
13
|
import { findAction, promptArgs, skillInstalledFor, SUBJECT_PATTERN } from "./actions";
|
|
@@ -51,7 +53,8 @@ export interface DefaultAgentOutcome {
|
|
|
51
53
|
/**
|
|
52
54
|
* The companion's effective default agent for one provider: the active
|
|
53
55
|
* audience's `studio.terminal.agent` (Studio's own `MATE_AUDIENCE`, never the
|
|
54
|
-
* browser's), else the base one.
|
|
56
|
+
* browser's), else the base one. Its definition may come from the companion
|
|
57
|
+
* or from the active audience's rendered layer. Never throws and never echoes the configured
|
|
55
58
|
* value into the notice.
|
|
56
59
|
*/
|
|
57
60
|
export async function defaultAgentFor(
|
|
@@ -62,11 +65,13 @@ export async function defaultAgentFor(
|
|
|
62
65
|
const configPath = path.join(companionPath, `.${FRAMEWORK_NAME}`, "config", "framework.yaml");
|
|
63
66
|
if (!fs.existsSync(configPath)) return {};
|
|
64
67
|
let name: unknown;
|
|
68
|
+
let layer: string | null = null;
|
|
65
69
|
try {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
);
|
|
70
|
+
const config = await new ConfigStore(configPath).load();
|
|
71
|
+
const audience = resolveAudience(config, readAudienceSelection(env));
|
|
72
|
+
const active = audience.ok ? audience.active : null;
|
|
73
|
+
name = effectiveStudioAgent(config, active);
|
|
74
|
+
if (active) layer = currentAudienceLayer(companionPath, active);
|
|
70
75
|
} catch {
|
|
71
76
|
return {};
|
|
72
77
|
}
|
|
@@ -76,8 +81,12 @@ export async function defaultAgentFor(
|
|
|
76
81
|
notice: "Default agent not applied: studio.terminal.agent is not a valid agent name.",
|
|
77
82
|
};
|
|
78
83
|
}
|
|
79
|
-
|
|
80
|
-
|
|
84
|
+
/** The audience's own agents live in its launch layer, which the terminal's launch attaches. */
|
|
85
|
+
const roots = layer ? [companionPath, layer] : [companionPath];
|
|
86
|
+
const defined = roots.some((root) =>
|
|
87
|
+
fs.existsSync(path.join(root, AGENT_DEFINITION_DIRS[agent], `${name}.md`)),
|
|
88
|
+
);
|
|
89
|
+
if (!defined) {
|
|
81
90
|
return {
|
|
82
91
|
notice: `Default agent "${name}" not applied: this companion has no ${agent} definition for it.`,
|
|
83
92
|
};
|