@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
package/src/cli/commands/wrap.ts
CHANGED
|
@@ -33,12 +33,29 @@ function reportChangedPaths(changed: ProjectionChangedPaths): void {
|
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
35
|
* `--companion` is consumed by the dispatch gate before the command runs, so it
|
|
36
|
-
* is parsed there too
|
|
36
|
+
* is parsed there too. `--audience` names the audience the checkout's bare
|
|
37
|
+
* sessions get beside base.
|
|
37
38
|
*/
|
|
38
|
-
export function parseWrapArgs(
|
|
39
|
+
export function parseWrapArgs(
|
|
40
|
+
argv: string[],
|
|
41
|
+
): { companion?: string; audience?: string } | { error: string } {
|
|
39
42
|
let companion: string | undefined;
|
|
43
|
+
let audience: string | undefined;
|
|
40
44
|
for (let index = 0; index < argv.length; index += 1) {
|
|
41
45
|
const arg = argv[index]!;
|
|
46
|
+
if (arg === "--audience") {
|
|
47
|
+
const value = argv[index + 1];
|
|
48
|
+
if (!value || value.startsWith("--")) return { error: "--audience requires a name" };
|
|
49
|
+
audience = value;
|
|
50
|
+
index += 1;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (arg.startsWith("--audience=")) {
|
|
54
|
+
const value = arg.slice("--audience=".length);
|
|
55
|
+
if (!value) return { error: "--audience requires a name" };
|
|
56
|
+
audience = value;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
42
59
|
if (arg === "--companion") {
|
|
43
60
|
const value = argv[index + 1];
|
|
44
61
|
if (!value || value.startsWith("--")) return { error: "--companion requires a path" };
|
|
@@ -54,7 +71,10 @@ export function parseWrapArgs(argv: string[]): { companion?: string } | { error:
|
|
|
54
71
|
}
|
|
55
72
|
return { error: `unknown wrap option: ${arg}` };
|
|
56
73
|
}
|
|
57
|
-
return
|
|
74
|
+
return {
|
|
75
|
+
...(companion === undefined ? {} : { companion }),
|
|
76
|
+
...(audience === undefined ? {} : { audience }),
|
|
77
|
+
};
|
|
58
78
|
}
|
|
59
79
|
|
|
60
80
|
/**
|
|
@@ -108,8 +128,12 @@ export async function runWrapCommand(
|
|
|
108
128
|
}
|
|
109
129
|
|
|
110
130
|
/** Serialized with Session Prepare and every sync, which rewrite the same manifest. */
|
|
131
|
+
/**
|
|
132
|
+
* The flag is the only way to give a checkout's bare sessions an audience;
|
|
133
|
+
* without it the wrap is base and any recorded audience is withdrawn.
|
|
134
|
+
*/
|
|
111
135
|
const locked = await withCompanionLock(companionPath, attendedLockOptions(companionPath), () =>
|
|
112
|
-
wrap(companionPath, repository),
|
|
136
|
+
wrap(companionPath, repository, { audience: parsed.audience ?? null }),
|
|
113
137
|
);
|
|
114
138
|
const result: WrapPassResult = locked.acquired
|
|
115
139
|
? locked.value
|
|
@@ -126,6 +150,7 @@ export async function runWrapCommand(
|
|
|
126
150
|
: `Already wrapped: ${result.projectionRoot}`,
|
|
127
151
|
);
|
|
128
152
|
console.log(` Companion: ${result.companionPath}`);
|
|
153
|
+
if (result.audience) console.log(` Audience: ${result.audience}`);
|
|
129
154
|
for (const document of result.documents) {
|
|
130
155
|
console.log(` Runtime: ${document}`);
|
|
131
156
|
}
|
package/src/cli/usage.ts
CHANGED
|
@@ -21,7 +21,7 @@ export function usage(): string {
|
|
|
21
21
|
` ${n} companion open`,
|
|
22
22
|
` ${n} companion tui`,
|
|
23
23
|
` ${n} working cleanup`,
|
|
24
|
-
` ${n} wrap [--companion PATH]`,
|
|
24
|
+
` ${n} wrap [--companion PATH] [--audience NAME]`,
|
|
25
25
|
` ${n} unwrap`,
|
|
26
26
|
` ${n} session prepare --repo <dir> [--json] [--sync]`,
|
|
27
27
|
` ${n} hub init [folder]`,
|
|
@@ -54,6 +54,7 @@ export function usage(): string {
|
|
|
54
54
|
"",
|
|
55
55
|
"Wrapped repositories:",
|
|
56
56
|
` ${n} wrap configures the repository so sessions you start yourself load the companion`,
|
|
57
|
+
` ${n} wrap --audience NAME gives those sessions one audience beside base; plain wrap returns to base`,
|
|
57
58
|
` ${n} claude does not run while a repository is wrapped for Claude`,
|
|
58
59
|
` ${n} opencode wraps the repository for OpenCode itself, then starts plain \`opencode\``,
|
|
59
60
|
` ${n} unwrap withdraws the wrap and restores managed launches; the link is kept`,
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
// Deliver the companion guidance to a Claude session Mate did not launch.
|
|
2
|
+
import { currentAudienceLayer, readLayerGuidanceSync } from "../runtime/audience-layer";
|
|
3
|
+
import { resolveProjection } from "../runtime/projection";
|
|
2
4
|
import { buildProjectedGuidance } from "../runtime/projected-guidance";
|
|
5
|
+
import { readWrapStateSync } from "../runtime/wrap-state";
|
|
3
6
|
import type { HookEnv } from "./validate-artifact-path";
|
|
4
7
|
|
|
5
8
|
export interface GuidanceOutcome {
|
|
@@ -7,6 +10,19 @@ export interface GuidanceOutcome {
|
|
|
7
10
|
stdout: string;
|
|
8
11
|
}
|
|
9
12
|
|
|
13
|
+
/**
|
|
14
|
+
* The guidance of the audience this checkout is wrapped for, from its layer.
|
|
15
|
+
* The wrap is the only source: an inherited `MATE_AUDIENCE` selects nothing.
|
|
16
|
+
*/
|
|
17
|
+
function wrappedAudienceGuidance(cwd: string): string {
|
|
18
|
+
const projection = resolveProjection(cwd);
|
|
19
|
+
if (!projection) return "";
|
|
20
|
+
const audience = readWrapStateSync(projection.repoRoot).audience;
|
|
21
|
+
if (!audience) return "";
|
|
22
|
+
const layer = currentAudienceLayer(projection.companionPath, audience);
|
|
23
|
+
return layer ? readLayerGuidanceSync(layer, "CLAUDE.md") : "";
|
|
24
|
+
}
|
|
25
|
+
|
|
10
26
|
/**
|
|
11
27
|
* `SessionStart.additionalContext` is the file-based form of the managed
|
|
12
28
|
* launch's `--append-system-prompt`: a settings document can declare a hook,
|
|
@@ -18,8 +34,10 @@ export interface GuidanceOutcome {
|
|
|
18
34
|
* repository must read exactly as it did before the hook existed.
|
|
19
35
|
*/
|
|
20
36
|
export function buildSessionGuidance(env: HookEnv, cwd: string = process.cwd()): GuidanceOutcome {
|
|
21
|
-
const
|
|
22
|
-
if (!
|
|
37
|
+
const companionGuidance = buildProjectedGuidance(env, cwd);
|
|
38
|
+
if (!companionGuidance) return { exitCode: 0, stdout: "" };
|
|
39
|
+
const layer = wrappedAudienceGuidance(cwd);
|
|
40
|
+
const guidance = layer ? `${companionGuidance}\n\n${layer}` : companionGuidance;
|
|
23
41
|
|
|
24
42
|
return {
|
|
25
43
|
exitCode: 0,
|
|
@@ -88,7 +88,8 @@ export abstract class LaunchAdapter<C extends AdapterContext = AdapterContext> {
|
|
|
88
88
|
|
|
89
89
|
async validateLaunch(_context: C): Promise<void> {}
|
|
90
90
|
|
|
91
|
-
|
|
91
|
+
/** `args` are the operator's arguments, before `buildArgs` rewrites them. */
|
|
92
|
+
extendEnvironment(_context: C, _args: string[] = []): NodeJS.ProcessEnv {
|
|
92
93
|
return {};
|
|
93
94
|
}
|
|
94
95
|
|
|
@@ -130,7 +131,7 @@ export abstract class LaunchAdapter<C extends AdapterContext = AdapterContext> {
|
|
|
130
131
|
async prepareLaunch(context: C, args: string[]): Promise<PreparedLaunch> {
|
|
131
132
|
const builtArgs = this.buildArgs(context, args);
|
|
132
133
|
const baseEnv = this.environment(context);
|
|
133
|
-
const extendedEnv = this.extendEnvironment(context);
|
|
134
|
+
const extendedEnv = this.extendEnvironment(context, args);
|
|
134
135
|
const env = { ...baseEnv, ...extendedEnv };
|
|
135
136
|
|
|
136
137
|
const processPath = process.env.PATH ?? "";
|
|
@@ -9,6 +9,11 @@ import {
|
|
|
9
9
|
type ClaudeDeliveryPlan,
|
|
10
10
|
type SessionKind,
|
|
11
11
|
} from "../../../runtime/claude-delivery-plan";
|
|
12
|
+
import {
|
|
13
|
+
audienceLayerDir,
|
|
14
|
+
currentAudienceLayer,
|
|
15
|
+
readLayerGuidanceSync,
|
|
16
|
+
} from "../../../runtime/audience-layer";
|
|
12
17
|
import { hookGroupKey, type ClaudeHookGroup } from "../../../runtime/claude-hook-group";
|
|
13
18
|
import { NOT_WRAPPED } from "../../../runtime/wrap-state";
|
|
14
19
|
import { writeAtomic } from "../../../runtime/write-atomic";
|
|
@@ -19,15 +24,31 @@ import {
|
|
|
19
24
|
import type { ClaudeSettings } from "../../../tools/setup/providers/claude-format";
|
|
20
25
|
import { getContextModePackageRoot, validateContextModePackage } from "../../context-mode-package";
|
|
21
26
|
import { getClaudePluginRoot, validateClaudePluginAssets } from "../../package-paths";
|
|
27
|
+
import type { RenderedAudienceLayer } from "../../../tools/setup/audience-layer";
|
|
22
28
|
import type { PrepareResult } from "../session-prepare";
|
|
23
29
|
import { LaunchPreflightError } from "../types";
|
|
24
30
|
import { type AdapterContext, LaunchAdapter } from "./base";
|
|
25
31
|
|
|
32
|
+
/** The active audience's layer, attached to this launch only. */
|
|
33
|
+
interface ClaudeLayerDelivery {
|
|
34
|
+
audience: string;
|
|
35
|
+
root: string;
|
|
36
|
+
/** The layer's MCP configuration, when the layer holds one. */
|
|
37
|
+
mcpConfigPath?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
26
40
|
/** The paths are present only where the plan names that channel and the file exists. */
|
|
27
41
|
interface ClaudeDelivery {
|
|
28
42
|
plan: ClaudeDeliveryPlan;
|
|
29
43
|
settingsPath?: string;
|
|
30
44
|
mcpConfigPath?: string;
|
|
45
|
+
layer?: ClaudeLayerDelivery;
|
|
46
|
+
/**
|
|
47
|
+
* The guidance of the audience the checkout is wrapped for. The wrap carries
|
|
48
|
+
* the rest of that audience; its guidance hook stays silent in a managed
|
|
49
|
+
* session, so the launch's own system prompt carries this.
|
|
50
|
+
*/
|
|
51
|
+
wrappedGuidance?: string;
|
|
31
52
|
}
|
|
32
53
|
|
|
33
54
|
/** A Claude launch's context: the delivery its plan chose, and the files it chose or wrote. */
|
|
@@ -35,34 +56,75 @@ interface ClaudeLaunchContext extends AdapterContext {
|
|
|
35
56
|
delivery: ClaudeDelivery;
|
|
36
57
|
}
|
|
37
58
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
59
|
+
function safeName(value: string): string {
|
|
60
|
+
return value.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Where a launch writes its derived `--settings`. Per Working Repository and
|
|
65
|
+
* audience, so concurrent launches never race; a companion-scoped launch
|
|
66
|
+
* derives one only for an audience. Without an audience it is git-ignored
|
|
67
|
+
* under the companion. With one it carries that audience's hooks and
|
|
68
|
+
* permission entries, so it lives beside the audience's layer, outside the
|
|
69
|
+
* companion, where no other audience's session can read it.
|
|
70
|
+
*/
|
|
71
|
+
export function launchSettingsPath(
|
|
72
|
+
companionPath: string,
|
|
73
|
+
repositoryId: string | null,
|
|
74
|
+
audience?: string,
|
|
75
|
+
): string {
|
|
76
|
+
const scope = repositoryId === null ? "companion-scope" : `settings.${safeName(repositoryId)}`;
|
|
77
|
+
if (audience) {
|
|
78
|
+
return path.join(audienceLayerDir(companionPath, audience), `launch-${scope}.json`);
|
|
79
|
+
}
|
|
80
|
+
return path.join(companionPath, ".claude", "state", `launch-${scope}.json`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function readSettings(settingsPath: string): Promise<ClaudeSettings> {
|
|
84
|
+
if (!existsSync(settingsPath)) return {};
|
|
85
|
+
return JSON.parse(await fs.readFile(settingsPath, "utf8")) as ClaudeSettings;
|
|
42
86
|
}
|
|
43
87
|
|
|
44
88
|
/**
|
|
45
|
-
* Companion settings minus `exclude`, so each wrap-carried group runs once
|
|
46
|
-
*
|
|
47
|
-
*
|
|
89
|
+
* Companion settings minus `exclude`, so each wrap-carried group runs once,
|
|
90
|
+
* plus the layer's hook groups and permission entries. A file, not inline JSON
|
|
91
|
+
* (platform arg limits). Claude reads one `--settings` file only — a second
|
|
92
|
+
* one replaces the first — so the layer is merged here, never passed beside.
|
|
48
93
|
*/
|
|
49
94
|
async function writeLaunchSettings(
|
|
50
95
|
companionSettingsPath: string,
|
|
51
96
|
target: string,
|
|
52
97
|
exclude: readonly ClaudeHookGroup[],
|
|
98
|
+
layerSettingsPath?: string,
|
|
53
99
|
): Promise<void> {
|
|
54
|
-
const settings =
|
|
100
|
+
const settings = await readSettings(companionSettingsPath);
|
|
101
|
+
const layer = layerSettingsPath ? await readSettings(layerSettingsPath) : {};
|
|
55
102
|
const excluded = new Set(exclude.map(hookGroupKey));
|
|
56
103
|
const hooks: Record<string, ClaudeHookGroup[]> = {};
|
|
57
104
|
for (const [event, groups] of Object.entries(settings.hooks ?? {})) {
|
|
58
105
|
const kept = (groups ?? []).filter((group) => !excluded.has(hookGroupKey(group)));
|
|
59
106
|
if (kept.length > 0) hooks[event] = kept;
|
|
60
107
|
}
|
|
108
|
+
for (const [event, groups] of Object.entries(layer.hooks ?? {})) {
|
|
109
|
+
hooks[event] = [...(hooks[event] ?? []), ...(groups ?? [])];
|
|
110
|
+
}
|
|
61
111
|
const derived: ClaudeSettings = { ...settings, hooks };
|
|
62
112
|
if (Object.keys(hooks).length === 0) delete derived.hooks;
|
|
113
|
+
const layerAllow = layer.permissions?.allow ?? [];
|
|
114
|
+
if (layerAllow.length > 0) {
|
|
115
|
+
const allow = [...new Set([...(settings.permissions?.allow ?? []), ...layerAllow])];
|
|
116
|
+
derived.permissions = { ...settings.permissions, allow };
|
|
117
|
+
}
|
|
63
118
|
|
|
64
|
-
|
|
65
|
-
|
|
119
|
+
const text = `${JSON.stringify(derived, null, 2)}\n`;
|
|
120
|
+
if (layerSettingsPath) {
|
|
121
|
+
/** Beside the audience's layer: as private as the layer itself. */
|
|
122
|
+
await fs.mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
123
|
+
await writeAtomic(target, text, 0o600);
|
|
124
|
+
} else {
|
|
125
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
126
|
+
await writeAtomic(target, text);
|
|
127
|
+
}
|
|
66
128
|
}
|
|
67
129
|
|
|
68
130
|
/** The `--settings` file the plan's hook owner calls for, writing it when derived. */
|
|
@@ -70,16 +132,38 @@ async function settingsPathFor(
|
|
|
70
132
|
plan: ClaudeDeliveryPlan,
|
|
71
133
|
companionPath: string,
|
|
72
134
|
repository: AdapterContext["repository"],
|
|
135
|
+
layer: RenderedAudienceLayer | undefined,
|
|
73
136
|
): Promise<string | undefined> {
|
|
74
137
|
const companionSettingsPath = getCompanionClaudeSettingsPath(companionPath);
|
|
75
|
-
if (plan.hooks.owner === "none"
|
|
76
|
-
if (
|
|
138
|
+
if (plan.hooks.owner === "none") return undefined;
|
|
139
|
+
if (!layer && !existsSync(companionSettingsPath)) return undefined;
|
|
140
|
+
if (plan.hooks.owner === "settings" && !layer) return companionSettingsPath;
|
|
77
141
|
/** Owner `wrap` is planned only for `managed-repo`, which has a repository. */
|
|
78
|
-
const settingsPath = launchSettingsPath(
|
|
79
|
-
|
|
142
|
+
const settingsPath = launchSettingsPath(
|
|
143
|
+
companionPath,
|
|
144
|
+
plan.hooks.owner === "wrap" ? repository!.id : null,
|
|
145
|
+
layer?.audience,
|
|
146
|
+
);
|
|
147
|
+
await writeLaunchSettings(
|
|
148
|
+
companionSettingsPath,
|
|
149
|
+
settingsPath,
|
|
150
|
+
plan.hooks.launchSettingsExclude,
|
|
151
|
+
layer ? getCompanionClaudeSettingsPath(layer.root) : undefined,
|
|
152
|
+
);
|
|
80
153
|
return settingsPath;
|
|
81
154
|
}
|
|
82
155
|
|
|
156
|
+
/** The layer as this launch attaches it: the directory, and its MCP configuration when present. */
|
|
157
|
+
function layerDelivery(layer: RenderedAudienceLayer | undefined): ClaudeLayerDelivery | undefined {
|
|
158
|
+
if (!layer) return undefined;
|
|
159
|
+
const mcpConfigPath = getCompanionClaudeMcpConfigPath(layer.root);
|
|
160
|
+
return {
|
|
161
|
+
audience: layer.audience,
|
|
162
|
+
root: layer.root,
|
|
163
|
+
...(existsSync(mcpConfigPath) ? { mcpConfigPath } : {}),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
83
167
|
export class ClaudeAdapter extends LaunchAdapter<ClaudeLaunchContext> {
|
|
84
168
|
readonly toolName = "claude";
|
|
85
169
|
readonly interactive = true;
|
|
@@ -100,11 +184,22 @@ export class ClaudeAdapter extends LaunchAdapter<ClaudeLaunchContext> {
|
|
|
100
184
|
}
|
|
101
185
|
const plan = planClaudeDelivery(kind, wrap);
|
|
102
186
|
const mcpConfigPath = getCompanionClaudeMcpConfigPath(companionPath);
|
|
103
|
-
const settingsPath = await settingsPathFor(
|
|
187
|
+
const settingsPath = await settingsPathFor(
|
|
188
|
+
plan,
|
|
189
|
+
companionPath,
|
|
190
|
+
repository,
|
|
191
|
+
prepared.audienceLayer,
|
|
192
|
+
);
|
|
193
|
+
const layer = layerDelivery(prepared.audienceLayer);
|
|
194
|
+
const wrappedLayer =
|
|
195
|
+
!layer && wrap.audience ? currentAudienceLayer(companionPath, wrap.audience) : null;
|
|
196
|
+
const wrappedGuidance = wrappedLayer ? readLayerGuidanceSync(wrappedLayer, "CLAUDE.md") : "";
|
|
104
197
|
const delivery: ClaudeDelivery = {
|
|
105
198
|
plan,
|
|
106
199
|
...(plan.mcp === "mcp-config" && existsSync(mcpConfigPath) ? { mcpConfigPath } : {}),
|
|
107
200
|
...(settingsPath ? { settingsPath } : {}),
|
|
201
|
+
...(layer ? { layer } : {}),
|
|
202
|
+
...(wrappedGuidance ? { wrappedGuidance } : {}),
|
|
108
203
|
};
|
|
109
204
|
return { ...context, delivery };
|
|
110
205
|
}
|
|
@@ -149,15 +244,30 @@ export class ClaudeAdapter extends LaunchAdapter<ClaudeLaunchContext> {
|
|
|
149
244
|
* and context-mode are unconditional: no settings document can carry them.
|
|
150
245
|
*/
|
|
151
246
|
buildArgs(context: ClaudeLaunchContext, args: string[]): string[] {
|
|
152
|
-
const { plan, settingsPath, mcpConfigPath } = context.delivery;
|
|
247
|
+
const { plan, settingsPath, mcpConfigPath, layer, wrappedGuidance } = context.delivery;
|
|
248
|
+
const companionGuidance = buildCompanionGuidance(context);
|
|
249
|
+
const systemPrompt = wrappedGuidance
|
|
250
|
+
? `${companionGuidance}\n\n${wrappedGuidance}`
|
|
251
|
+
: companionGuidance;
|
|
153
252
|
/** Layered on the user's own sources so global skills and plugins still load. */
|
|
154
253
|
const settingsArgs =
|
|
155
254
|
plan.hooks.owner !== "none" && settingsPath
|
|
156
255
|
? ["--setting-sources", "user,project,local", "--settings", settingsPath]
|
|
157
256
|
: [];
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
257
|
+
/**
|
|
258
|
+
* The layer adds its skills, commands, agents and `CLAUDE.md` as one more
|
|
259
|
+
* directory, and its MCP servers as one more configuration beside every
|
|
260
|
+
* server the launch delivered before. Both flags are variadic, so each is
|
|
261
|
+
* followed by another flag, never by the operator's arguments.
|
|
262
|
+
*/
|
|
263
|
+
const addDirArgs = [
|
|
264
|
+
...(plan.dirAccess === "add-dir" ? ["--add-dir", context.companionPath] : []),
|
|
265
|
+
...(layer ? ["--add-dir", layer.root] : []),
|
|
266
|
+
];
|
|
267
|
+
const mcpConfigArgs = [
|
|
268
|
+
...(plan.mcp === "mcp-config" && mcpConfigPath ? ["--mcp-config", mcpConfigPath] : []),
|
|
269
|
+
...(layer?.mcpConfigPath ? ["--mcp-config", layer.mcpConfigPath] : []),
|
|
270
|
+
];
|
|
161
271
|
const contextModeArgs = context.capabilities.some(
|
|
162
272
|
(capability) => capability.name === "context-mode",
|
|
163
273
|
)
|
|
@@ -167,7 +277,7 @@ export class ClaudeAdapter extends LaunchAdapter<ClaudeLaunchContext> {
|
|
|
167
277
|
return [
|
|
168
278
|
...addDirArgs,
|
|
169
279
|
"--append-system-prompt",
|
|
170
|
-
|
|
280
|
+
systemPrompt,
|
|
171
281
|
...settingsArgs,
|
|
172
282
|
...mcpConfigArgs,
|
|
173
283
|
/** The bundled plugin from the running install, beside context-mode's. */
|
|
@@ -36,7 +36,32 @@ function parseOpenCodeMajor(output: string | undefined): number | undefined {
|
|
|
36
36
|
return match ? Number(match[1]) : undefined;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
interface OpenCodeInvocation {
|
|
40
|
+
command: string | undefined;
|
|
41
|
+
rest: string[];
|
|
42
|
+
/**
|
|
43
|
+
* The launch runs its own private server, where the plugin loads with this
|
|
44
|
+
* launch's environment: the TUI or `run`, not pointed at a remote server.
|
|
45
|
+
* Every other invocation may start or reach the shared service, which must
|
|
46
|
+
* never receive a guidance payload.
|
|
47
|
+
*/
|
|
48
|
+
hostsPrivateServer: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function readInvocation(launchWorkingDirectory: string, args: string[]): OpenCodeInvocation {
|
|
52
|
+
const isTui = args.length === 0 || args[0]?.startsWith("-");
|
|
53
|
+
const [command, ...rest] = isTui ? [launchWorkingDirectory, ...args] : args;
|
|
54
|
+
const hostsServer = isTui || command === "run";
|
|
55
|
+
const choosesRemoteServer = rest.some((arg) => arg === "--server" || arg.startsWith("--server="));
|
|
56
|
+
return { command, rest, hostsPrivateServer: Boolean(hostsServer) && !choosesRemoteServer };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** An OpenCode launch's context: the audience layer its prepare rendered, if any. */
|
|
60
|
+
interface OpenCodeLaunchContext extends AdapterContext {
|
|
61
|
+
audienceLayer?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class OpenCodeAdapter extends LaunchAdapter<OpenCodeLaunchContext> {
|
|
40
65
|
readonly toolName = "opencode";
|
|
41
66
|
readonly interactive = true;
|
|
42
67
|
private readonly requiredRuntimeAssets = [path.join(".opencode", "opencode.json")] as const;
|
|
@@ -59,6 +84,15 @@ export class OpenCodeAdapter extends LaunchAdapter {
|
|
|
59
84
|
);
|
|
60
85
|
}
|
|
61
86
|
|
|
87
|
+
async planLaunch(
|
|
88
|
+
context: AdapterContext,
|
|
89
|
+
prepared: PrepareResult,
|
|
90
|
+
): Promise<OpenCodeLaunchContext> {
|
|
91
|
+
return prepared.audienceLayer
|
|
92
|
+
? { ...context, audienceLayer: prepared.audienceLayer.root }
|
|
93
|
+
: context;
|
|
94
|
+
}
|
|
95
|
+
|
|
62
96
|
/** A prepared repository reports its failure as one summary, as it always has. */
|
|
63
97
|
launchFailure(prepared: PrepareResult): unknown {
|
|
64
98
|
return prepared.repositoryId
|
|
@@ -67,15 +101,13 @@ export class OpenCodeAdapter extends LaunchAdapter {
|
|
|
67
101
|
}
|
|
68
102
|
|
|
69
103
|
buildArgs(context: AdapterContext, args: string[]): string[] {
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
if (!command) return args;
|
|
74
|
-
const hostsServer = isTui || command === "run";
|
|
75
|
-
const choosesServer = rest.some(
|
|
76
|
-
(arg) => arg === "--standalone" || arg === "--server" || arg.startsWith("--server="),
|
|
104
|
+
const { command, rest, hostsPrivateServer } = readInvocation(
|
|
105
|
+
context.launchWorkingDirectory,
|
|
106
|
+
args,
|
|
77
107
|
);
|
|
78
|
-
|
|
108
|
+
if (!command) return args;
|
|
109
|
+
const addsStandalone = hostsPrivateServer && !rest.includes(STANDALONE_FLAG);
|
|
110
|
+
return addsStandalone ? [command, STANDALONE_FLAG, ...rest] : [command, ...rest];
|
|
79
111
|
}
|
|
80
112
|
|
|
81
113
|
/**
|
|
@@ -84,21 +116,29 @@ export class OpenCodeAdapter extends LaunchAdapter {
|
|
|
84
116
|
* overlay carried now lives in the wrapped `.opencode/opencode.json`. An
|
|
85
117
|
* inherited `OPENCODE_CONFIG_DIR` or `OPENCODE_CONFIG_CONTENT` passes through.
|
|
86
118
|
*/
|
|
87
|
-
extendEnvironment(context:
|
|
119
|
+
extendEnvironment(context: OpenCodeLaunchContext, args: string[] = []): NodeJS.ProcessEnv {
|
|
120
|
+
const { hostsPrivateServer } = readInvocation(context.launchWorkingDirectory, args);
|
|
88
121
|
return {
|
|
89
122
|
// Built fresh per launch from the live capability config; always set so
|
|
90
123
|
// a stale value inherited from an outer Mate session can never leak in.
|
|
91
|
-
|
|
124
|
+
// Masked unless the launch hosts its own server: a payload that reached
|
|
125
|
+
// the shared service would apply this session's guidance — and its
|
|
126
|
+
// audience layer — to every other.
|
|
127
|
+
[MATE_ENV.guidanceJson]: hostsPrivateServer
|
|
128
|
+
? JSON.stringify(this.buildGuidance(context))
|
|
129
|
+
: "",
|
|
92
130
|
};
|
|
93
131
|
}
|
|
94
132
|
|
|
95
|
-
|
|
96
|
-
|
|
133
|
+
/** The audience layer travels as a path; the plugin reads it through the surface's reader. */
|
|
134
|
+
private buildGuidance(context: OpenCodeLaunchContext): MateGuidanceFile {
|
|
135
|
+
const guidance = buildOpenCodeGuidance(context.capabilities, {
|
|
97
136
|
companionScoped: !context.repository,
|
|
98
137
|
});
|
|
138
|
+
return context.audienceLayer ? { ...guidance, audienceLayer: context.audienceLayer } : guidance;
|
|
99
139
|
}
|
|
100
140
|
|
|
101
|
-
async validateLaunch(context:
|
|
141
|
+
async validateLaunch(context: OpenCodeLaunchContext): Promise<void> {
|
|
102
142
|
this.validateOpenCodeVersion();
|
|
103
143
|
|
|
104
144
|
const missingAssets = await Promise.all(
|
|
@@ -162,7 +202,7 @@ export class OpenCodeAdapter extends LaunchAdapter {
|
|
|
162
202
|
|
|
163
203
|
// Self-check the guidance payload this launch is about to inject so a
|
|
164
204
|
// broken playbook template fails preflight instead of inside OpenCode.
|
|
165
|
-
private validateGuidance(context:
|
|
205
|
+
private validateGuidance(context: OpenCodeLaunchContext): string[] {
|
|
166
206
|
const built = this.buildGuidance(context);
|
|
167
207
|
const { guidance, errors } = validateGuidanceData(built);
|
|
168
208
|
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
|
|
3
|
+
import semver from "semver";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
BASE_AUDIENCE,
|
|
7
|
+
BASE_AUDIENCE_KEYS,
|
|
8
|
+
normalizeBaseAudience,
|
|
9
|
+
} from "../../tools/setup/dynamic-plugins/audiences";
|
|
10
|
+
|
|
11
|
+
import { getActiveDistribution } from "../../distribution";
|
|
3
12
|
import { FRAMEWORK_NAME } from "../../framework";
|
|
4
13
|
import {
|
|
5
14
|
getDefaultSetupSelections,
|
|
@@ -291,19 +300,83 @@ function validateAudiences(config: FrameworkConfig): void {
|
|
|
291
300
|
}
|
|
292
301
|
}
|
|
293
302
|
|
|
303
|
+
/**
|
|
304
|
+
* The release that introduces the `audiences.base` shape. An older Mate would
|
|
305
|
+
* read a saved file as an empty base plus an audience called `base`, so every
|
|
306
|
+
* save in the new shape raises the companion's `engines` floor to it — capped
|
|
307
|
+
* at the running release, so a pre-release build never locks itself out.
|
|
308
|
+
*/
|
|
309
|
+
export const BASE_AUDIENCE_MIN_VERSION = "0.19.0";
|
|
310
|
+
|
|
311
|
+
/** The floor version a save writes: the introducing release, or the running one when older. */
|
|
312
|
+
export function baseAudienceFloorVersion(
|
|
313
|
+
runningVersion: string = getActiveDistribution().config.version,
|
|
314
|
+
): string {
|
|
315
|
+
const running = semver.coerce(runningVersion)?.version;
|
|
316
|
+
return running && semver.lt(running, BASE_AUDIENCE_MIN_VERSION)
|
|
317
|
+
? running
|
|
318
|
+
: BASE_AUDIENCE_MIN_VERSION;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Only companions carry audiences; a hub or a working repository keeps the root shape. */
|
|
322
|
+
function usesAudiences(config: FrameworkConfig): boolean {
|
|
323
|
+
return config.type === undefined || config.type === "companion";
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** The floor that admits the base audience shape, keeping a higher one. */
|
|
327
|
+
function raisedEngineFloor(range: string | undefined): string {
|
|
328
|
+
const version = baseAudienceFloorVersion();
|
|
329
|
+
const floor = `>=${version}`;
|
|
330
|
+
if (!range || semver.validRange(range) === null) return range ?? floor;
|
|
331
|
+
const minimum = semver.minVersion(range);
|
|
332
|
+
return minimum && semver.gte(minimum, version) ? range : floor;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* The file shape of a companion config: the base set's audience-scoped keys
|
|
337
|
+
* under `audiences.base`, first among the audiences. The in-memory config
|
|
338
|
+
* keeps them at the root, so its readers never see the difference.
|
|
339
|
+
*/
|
|
340
|
+
function toFileShape(config: FrameworkConfig): FrameworkConfig {
|
|
341
|
+
if (!usesAudiences(config)) return config;
|
|
342
|
+
const base: Record<string, unknown> = {};
|
|
343
|
+
const rest: Record<string, unknown> = { ...config };
|
|
344
|
+
for (const key of BASE_AUDIENCE_KEYS) {
|
|
345
|
+
if (rest[key] === undefined) continue;
|
|
346
|
+
base[key] = rest[key];
|
|
347
|
+
delete rest[key];
|
|
348
|
+
}
|
|
349
|
+
if (Object.keys(base).length === 0) return config;
|
|
350
|
+
return {
|
|
351
|
+
...(rest as unknown as FrameworkConfig),
|
|
352
|
+
audiences: { [BASE_AUDIENCE]: base, ...config.audiences } as FrameworkConfig["audiences"],
|
|
353
|
+
engines: {
|
|
354
|
+
...config.engines,
|
|
355
|
+
[FRAMEWORK_NAME]: raisedEngineFloor(config.engines?.[FRAMEWORK_NAME]),
|
|
356
|
+
},
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
294
360
|
export class ConfigStore extends YamlFileStore<FrameworkConfig> {
|
|
295
361
|
constructor(configPath = process.env.MATE_CONFIG ?? defaultConfigPath()) {
|
|
296
362
|
super(path.resolve(configPath));
|
|
297
363
|
}
|
|
298
364
|
|
|
365
|
+
/** Either file shape loads as the base set at the root; the new shape persists on the next save. */
|
|
299
366
|
override async load(): Promise<FrameworkConfig> {
|
|
300
|
-
const merged = mergeWithDefaults(
|
|
367
|
+
const merged = mergeWithDefaults(
|
|
368
|
+
migrateProfilesToAllowedAgents(normalizeBaseAudience(await super.load())),
|
|
369
|
+
);
|
|
301
370
|
validateHubConfig(merged);
|
|
302
371
|
validatePluginDeclarations(merged);
|
|
303
372
|
validateAudiences(merged);
|
|
304
373
|
return merged;
|
|
305
374
|
}
|
|
306
375
|
|
|
376
|
+
override async save(config: FrameworkConfig): Promise<void> {
|
|
377
|
+
await super.save(toFileShape(config));
|
|
378
|
+
}
|
|
379
|
+
|
|
307
380
|
protected async onMissing(): Promise<FrameworkConfig> {
|
|
308
381
|
const config = defaultConfig();
|
|
309
382
|
await this.save(config);
|