@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
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { layerGuidanceText } from "./audience-layer";
|
|
5
|
+
import { OPENCODE_MCP_SERVERS_AT } from "./runtime-document-manifest";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Reads an audience layer the OpenCode Runtime Surface wrote, as what the Mate
|
|
9
|
+
* OpenCode plugin applies through the plugin API. The surface re-exports it as
|
|
10
|
+
* its own reader; it lives under `runtime/` only so the import-isolated plugin
|
|
11
|
+
* can reach it. The plugin parses no OpenCode document itself.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface LayerMcpServer {
|
|
15
|
+
name: string;
|
|
16
|
+
/** The entry exactly as the surface wrote it under `mcp.servers`. */
|
|
17
|
+
config: Record<string, unknown>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface LayerSkill {
|
|
21
|
+
id: string;
|
|
22
|
+
name: string;
|
|
23
|
+
description?: string;
|
|
24
|
+
/** The skill's `SKILL.md`. */
|
|
25
|
+
path: string;
|
|
26
|
+
content: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type LayerAgentMode = "subagent" | "primary" | "all";
|
|
30
|
+
|
|
31
|
+
export interface LayerAgent {
|
|
32
|
+
id: string;
|
|
33
|
+
description?: string;
|
|
34
|
+
mode: LayerAgentMode;
|
|
35
|
+
/** The agent's prompt: the definition's body. */
|
|
36
|
+
system: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface OpenCodeLayerContents {
|
|
40
|
+
mcpServers: LayerMcpServer[];
|
|
41
|
+
skills: LayerSkill[];
|
|
42
|
+
agents: LayerAgent[];
|
|
43
|
+
/** The layer's guidance sections, managed-block markers removed. */
|
|
44
|
+
guidance: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
48
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function splitFrontmatter(content: string): { fields: string; body: string } {
|
|
52
|
+
const match = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
53
|
+
return match
|
|
54
|
+
? { fields: match[1]!, body: content.slice(match[0].length) }
|
|
55
|
+
: { fields: "", body: content };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function frontmatterScalarFromFields(fields: string, key: string): string | undefined {
|
|
59
|
+
const value = fields.match(new RegExp(`^${key}:\\s*(.+)$`, "m"))?.[1]?.trim();
|
|
60
|
+
return value?.replace(
|
|
61
|
+
/^(?:"([\s\S]*)"|'([\s\S]*)')$/,
|
|
62
|
+
(_all, doubleQuoted?: string, singleQuoted?: string) => doubleQuoted ?? singleQuoted ?? "",
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** One scalar of a simple YAML frontmatter, quotes removed. */
|
|
67
|
+
export function frontmatterScalar(content: string, key: string): string | undefined {
|
|
68
|
+
return frontmatterScalarFromFields(splitFrontmatter(content).fields, key);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** An id OpenCode accepts for a skill or agent name. */
|
|
72
|
+
export function contributionId(name: string, fallback: string): string {
|
|
73
|
+
return (
|
|
74
|
+
name
|
|
75
|
+
.toLowerCase()
|
|
76
|
+
.replace(/[^a-z0-9_-]+/g, "-")
|
|
77
|
+
.replace(/^-+|-+$/g, "") || fallback
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function findSkillFiles(directory: string): Promise<string[]> {
|
|
82
|
+
try {
|
|
83
|
+
const entries = await fs.readdir(directory, { withFileTypes: true });
|
|
84
|
+
const nested = await Promise.all(
|
|
85
|
+
entries.map((entry) => {
|
|
86
|
+
const entryPath = path.join(directory, entry.name);
|
|
87
|
+
if (entry.isFile() && entry.name === "SKILL.md") return Promise.resolve([entryPath]);
|
|
88
|
+
return entry.isDirectory() ? findSkillFiles(entryPath) : Promise.resolve([]);
|
|
89
|
+
}),
|
|
90
|
+
);
|
|
91
|
+
return nested.flat().toSorted();
|
|
92
|
+
} catch {
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Every `SKILL.md` under `roots`; an unreadable skill is skipped, never fatal. */
|
|
98
|
+
export async function loadSkillInfos(roots: readonly string[]): Promise<LayerSkill[]> {
|
|
99
|
+
const files = (await Promise.all(roots.map(findSkillFiles))).flat();
|
|
100
|
+
const skills = await Promise.all(
|
|
101
|
+
files.map(async (filePath): Promise<LayerSkill | null> => {
|
|
102
|
+
try {
|
|
103
|
+
const content = await fs.readFile(filePath, "utf8");
|
|
104
|
+
const { fields } = splitFrontmatter(content);
|
|
105
|
+
const name =
|
|
106
|
+
frontmatterScalarFromFields(fields, "name") || path.basename(path.dirname(filePath));
|
|
107
|
+
const description = frontmatterScalarFromFields(fields, "description");
|
|
108
|
+
return {
|
|
109
|
+
id: contributionId(name, "mate-skill"),
|
|
110
|
+
name,
|
|
111
|
+
...(description ? { description } : {}),
|
|
112
|
+
path: filePath,
|
|
113
|
+
content,
|
|
114
|
+
};
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}),
|
|
119
|
+
);
|
|
120
|
+
return skills.filter((skill) => skill !== null);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function readMcpServers(root: string): Promise<LayerMcpServer[]> {
|
|
124
|
+
const parsed = await fs
|
|
125
|
+
.readFile(path.join(root, ".opencode", "opencode.json"), "utf8")
|
|
126
|
+
.then((text) => JSON.parse(text) as unknown)
|
|
127
|
+
.catch(() => null);
|
|
128
|
+
let servers: unknown = parsed;
|
|
129
|
+
for (const key of OPENCODE_MCP_SERVERS_AT) servers = isRecord(servers) ? servers[key] : undefined;
|
|
130
|
+
if (!isRecord(servers)) return [];
|
|
131
|
+
return Object.entries(servers)
|
|
132
|
+
.filter((entry): entry is [string, Record<string, unknown>] => isRecord(entry[1]))
|
|
133
|
+
.map(([name, config]) => ({ name, config }))
|
|
134
|
+
.toSorted((left, right) => left.name.localeCompare(right.name));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function agentMode(value: string | undefined): LayerAgentMode {
|
|
138
|
+
return value === "subagent" || value === "primary" ? value : "all";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function readAgents(root: string): Promise<LayerAgent[]> {
|
|
142
|
+
const dir = path.join(root, ".opencode", "agents");
|
|
143
|
+
const names = await fs.readdir(dir).catch(() => [] as string[]);
|
|
144
|
+
const agents = await Promise.all(
|
|
145
|
+
names
|
|
146
|
+
.filter((name) => name.endsWith(".md"))
|
|
147
|
+
.toSorted()
|
|
148
|
+
.map(async (name): Promise<LayerAgent | null> => {
|
|
149
|
+
const content = await fs.readFile(path.join(dir, name), "utf8").catch(() => null);
|
|
150
|
+
if (content === null) return null;
|
|
151
|
+
const { fields, body } = splitFrontmatter(content);
|
|
152
|
+
const description = frontmatterScalarFromFields(fields, "description");
|
|
153
|
+
return {
|
|
154
|
+
id: contributionId(path.basename(name, ".md"), "mate-agent"),
|
|
155
|
+
...(description ? { description } : {}),
|
|
156
|
+
mode: agentMode(frontmatterScalarFromFields(fields, "mode")),
|
|
157
|
+
system: body.trim(),
|
|
158
|
+
};
|
|
159
|
+
}),
|
|
160
|
+
);
|
|
161
|
+
return agents.filter((agent) => agent !== null);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function readGuidance(root: string): Promise<string> {
|
|
165
|
+
return layerGuidanceText(await fs.readFile(path.join(root, "AGENTS.md"), "utf8").catch(() => ""));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function readOpenCodeAudienceLayer(root: string): Promise<OpenCodeLayerContents> {
|
|
169
|
+
const [mcpServers, skills, agents, guidance] = await Promise.all([
|
|
170
|
+
readMcpServers(root),
|
|
171
|
+
loadSkillInfos([path.join(root, ".agents", "skills")]),
|
|
172
|
+
readAgents(root),
|
|
173
|
+
readGuidance(root),
|
|
174
|
+
]);
|
|
175
|
+
return { mcpServers, skills, agents, guidance };
|
|
176
|
+
}
|
package/src/runtime/policy.ts
CHANGED
|
@@ -40,6 +40,17 @@ function capabilityNames(value: unknown): string[] {
|
|
|
40
40
|
.filter((name): name is string => typeof name === "string");
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* A base-set key from `audiences.base`, the shape a companion is saved in. The
|
|
45
|
+
* lenient twin of the setup normalizer, which `runtime/` cannot import.
|
|
46
|
+
*/
|
|
47
|
+
function baseAudienceKey(config: Record<string, unknown>, key: string): unknown {
|
|
48
|
+
const audiences = config.audiences;
|
|
49
|
+
if (!audiences || typeof audiences !== "object") return undefined;
|
|
50
|
+
const base = (audiences as Record<string, unknown>).base;
|
|
51
|
+
return base && typeof base === "object" ? (base as Record<string, unknown>)[key] : undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
43
54
|
/**
|
|
44
55
|
* Synchronous so it composes into `readCompanionRuntimeContext`. A missing or
|
|
45
56
|
* unparseable companion config yields an inert policy rather than throwing.
|
|
@@ -56,7 +67,9 @@ export function readCompanionPolicy(companionPath: string): CompanionPolicy {
|
|
|
56
67
|
const config = parsed as Record<string, unknown>;
|
|
57
68
|
return {
|
|
58
69
|
allowedAgents: stringList(config.allowedAgents),
|
|
59
|
-
enabledCapabilities: capabilityNames(
|
|
70
|
+
enabledCapabilities: capabilityNames(
|
|
71
|
+
config.capabilities ?? baseAudienceKey(config, "capabilities"),
|
|
72
|
+
),
|
|
60
73
|
gitAutoMode: config.git === "auto",
|
|
61
74
|
};
|
|
62
75
|
}
|
|
@@ -88,7 +88,7 @@ export function projectionEnvPath(repoRoot: string): string {
|
|
|
88
88
|
return path.join(repoLocalDirPath(repoRoot), PROJECTION_ENV_FILE);
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
const generatedHeader = () => `# generated by ${FRAMEWORK_NAME} — do not edit`;
|
|
91
|
+
export const generatedHeader = (): string => `# generated by ${FRAMEWORK_NAME} — do not edit`;
|
|
92
92
|
|
|
93
93
|
export function renderProjectionYaml(file: ProjectionFile): string {
|
|
94
94
|
const document: Record<string, string> = { stamp: file.stamp };
|
|
@@ -80,6 +80,8 @@ export interface Manifest {
|
|
|
80
80
|
readonly runtimes: Readonly<Record<string, WrapRuntime>>;
|
|
81
81
|
readonly copies: Readonly<Record<string, CopyRecord>>;
|
|
82
82
|
readonly links: Readonly<Record<string, LinkRecord>>;
|
|
83
|
+
/** The audience `mate wrap --audience` recorded; the only source of a bare session's audience. */
|
|
84
|
+
readonly audience?: string;
|
|
83
85
|
}
|
|
84
86
|
|
|
85
87
|
export const EMPTY_MANIFEST: Manifest = Object.freeze({
|
|
@@ -124,9 +126,23 @@ function manifestOf(parsed: Record<string, unknown>): Manifest {
|
|
|
124
126
|
runtimes: isRecord(parsed.runtimes) ? (parsed.runtimes as Manifest["runtimes"]) : {},
|
|
125
127
|
copies: isRecord(parsed.copies) ? (parsed.copies as Manifest["copies"]) : {},
|
|
126
128
|
links: isRecord(parsed.links) ? (parsed.links as Manifest["links"]) : {},
|
|
129
|
+
...(typeof parsed.audience === "string" && parsed.audience
|
|
130
|
+
? { audience: parsed.audience }
|
|
131
|
+
: {}),
|
|
127
132
|
};
|
|
128
133
|
}
|
|
129
134
|
|
|
135
|
+
/** The audience the checkout is wrapped for; `null` for base only. */
|
|
136
|
+
export function recordedAudience(manifest: Manifest): string | null {
|
|
137
|
+
return manifest.audience ?? null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Records the checkout's audience, or none with `null`. */
|
|
141
|
+
export function withAudience(manifest: Manifest, audience: string | null): Manifest {
|
|
142
|
+
const { audience: _previous, ...rest } = manifest;
|
|
143
|
+
return audience ? { ...rest, audience } : rest;
|
|
144
|
+
}
|
|
145
|
+
|
|
130
146
|
function recordedRuntime(manifest: Manifest, documentPath: string): WrapRuntime {
|
|
131
147
|
return manifest.runtimes[documentPath] ?? documentRuntime(documentPath);
|
|
132
148
|
}
|
|
@@ -150,6 +166,7 @@ export function serializeManifest(manifest: Manifest): string | null {
|
|
|
150
166
|
...(recorded.length > 0 ? { runtimes } : {}),
|
|
151
167
|
...(copied.length > 0 ? { copies: manifest.copies } : {}),
|
|
152
168
|
...(linked.length > 0 ? { links: manifest.links } : {}),
|
|
169
|
+
...(manifest.audience ? { audience: manifest.audience } : {}),
|
|
153
170
|
},
|
|
154
171
|
null,
|
|
155
172
|
2,
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
parseManifestStrict,
|
|
15
15
|
readManifestText,
|
|
16
16
|
recordedListValues,
|
|
17
|
+
recordedAudience,
|
|
17
18
|
recordedMapKeys,
|
|
18
19
|
type Manifest,
|
|
19
20
|
type WrapRuntime,
|
|
@@ -30,6 +31,8 @@ export interface WrapState {
|
|
|
30
31
|
carriesClaudeHookGroup(group: ClaudeHookGroup): boolean;
|
|
31
32
|
/** MCP server names the wrap declared in `.opencode/opencode.json`; user servers are not listed. */
|
|
32
33
|
readonly openCodeMcpServers: readonly string[];
|
|
34
|
+
/** The audience the checkout is wrapped for; `null` for base only. */
|
|
35
|
+
readonly audience: string | null;
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
export const WRAP_RUNTIMES: readonly WrapRuntime[] = ["claude", "opencode"];
|
|
@@ -61,6 +64,7 @@ export function wrapStateOf(manifest: Manifest): WrapState {
|
|
|
61
64
|
OPENCODE_CONFIG_DOCUMENT,
|
|
62
65
|
OPENCODE_MCP_SERVERS_AT,
|
|
63
66
|
),
|
|
67
|
+
audience: recordedAudience(manifest),
|
|
64
68
|
};
|
|
65
69
|
}
|
|
66
70
|
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
// oxlint-disable no-await-in-loop
|
|
2
|
+
import type { Dirent } from "node:fs";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
import type { FrameworkConfig } from "../../lib/orchestrator/types";
|
|
8
|
+
import { pluginPackageRoot } from "./dynamic-plugins/paths";
|
|
9
|
+
import type {
|
|
10
|
+
CapabilityPlugin,
|
|
11
|
+
PluginRegistration,
|
|
12
|
+
ProviderPlugin,
|
|
13
|
+
RuntimeContributions,
|
|
14
|
+
} from "./plugin";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Audience isolation harness: one companion whose base plugin and audience
|
|
18
|
+
* `ba` and `qa` plugins each declare every contribution kind, every value
|
|
19
|
+
* carrying its owner's marker. A test runs a pass, then asserts which markers
|
|
20
|
+
* a surface holds — a marker found where it must not be is a leak.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export type FixtureOwner = "base" | "ba" | "qa";
|
|
24
|
+
|
|
25
|
+
export const AUDIENCE_MARKERS: Record<FixtureOwner, string> = {
|
|
26
|
+
base: "BASEMARK7",
|
|
27
|
+
ba: "BAMARK7",
|
|
28
|
+
qa: "QAMARK7",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const FIXTURE_AUDIENCES = ["ba", "qa"] as const;
|
|
32
|
+
|
|
33
|
+
export function fixturePluginId(owner: FixtureOwner): string {
|
|
34
|
+
return `${owner}-plugin`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function fixturePackage(owner: FixtureOwner): string {
|
|
38
|
+
return `@fixture/${owner}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface FixtureCapabilityOptions {
|
|
42
|
+
apply?: () => Promise<void>;
|
|
43
|
+
teardown?: () => Promise<void>;
|
|
44
|
+
forProvider?: CapabilityPlugin["forProvider"];
|
|
45
|
+
pluginReference?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function contributionsFor(
|
|
49
|
+
owner: FixtureOwner,
|
|
50
|
+
skillSourceDir: string,
|
|
51
|
+
runtime: "claude" | "opencode",
|
|
52
|
+
options: FixtureCapabilityOptions,
|
|
53
|
+
): RuntimeContributions {
|
|
54
|
+
const marker = AUDIENCE_MARKERS[owner];
|
|
55
|
+
const contributions: RuntimeContributions = {
|
|
56
|
+
mcpServers: [{ name: `${owner}-mcp`, command: `${marker}-server`, args: ["serve"] }],
|
|
57
|
+
guidanceSections: [{ content: `${marker} guidance for ${owner}.` }],
|
|
58
|
+
skillTrees: [{ name: `${owner}-skill`, sourceDir: skillSourceDir }],
|
|
59
|
+
agentDefinitions: [
|
|
60
|
+
{
|
|
61
|
+
name: `${owner}-agent`,
|
|
62
|
+
content: `---\nname: ${owner}-agent\ndescription: ${owner} agent\n---\n${marker} agent prompt.\n`,
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
permissionEntries: [`Bash(${marker}:*)`],
|
|
66
|
+
};
|
|
67
|
+
if (runtime === "claude") {
|
|
68
|
+
contributions.hookGroups = [
|
|
69
|
+
{
|
|
70
|
+
event: "PreToolUse",
|
|
71
|
+
marker: `${marker}-hook.sh`,
|
|
72
|
+
group: { matcher: "Bash", hooks: [{ type: "command", command: `${marker}-hook.sh` }] },
|
|
73
|
+
},
|
|
74
|
+
];
|
|
75
|
+
}
|
|
76
|
+
if (runtime === "opencode" && options.pluginReference) {
|
|
77
|
+
const reference = options.pluginReference;
|
|
78
|
+
contributions.pluginReferences = [
|
|
79
|
+
{ reference, isManagedReference: (entry) => entry === reference },
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
return contributions;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A capability declaring every contribution kind, each value carrying the owner's marker. */
|
|
86
|
+
export function markedCapability(
|
|
87
|
+
owner: FixtureOwner,
|
|
88
|
+
skillSourceDir: string,
|
|
89
|
+
options: FixtureCapabilityOptions = {},
|
|
90
|
+
): CapabilityPlugin {
|
|
91
|
+
return {
|
|
92
|
+
id: fixturePluginId(owner),
|
|
93
|
+
kind: "capability",
|
|
94
|
+
label: owner,
|
|
95
|
+
description: `${owner} fixture capability`,
|
|
96
|
+
defaultSelected: true,
|
|
97
|
+
isEnabled: () => true,
|
|
98
|
+
apply: options.apply ?? (async () => {}),
|
|
99
|
+
teardown: options.teardown ?? (async () => {}),
|
|
100
|
+
...(options.forProvider ? { forProvider: options.forProvider } : {}),
|
|
101
|
+
getRuntimeContributions() {
|
|
102
|
+
return {
|
|
103
|
+
claude: contributionsFor(owner, skillSourceDir, "claude", options),
|
|
104
|
+
opencode: contributionsFor(owner, skillSourceDir, "opencode", options),
|
|
105
|
+
};
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function fixtureProvider(id: "claude" | "opencode"): ProviderPlugin {
|
|
111
|
+
return {
|
|
112
|
+
id,
|
|
113
|
+
kind: "provider",
|
|
114
|
+
label: id,
|
|
115
|
+
description: id,
|
|
116
|
+
defaultSelected: true,
|
|
117
|
+
isEnabled: () => true,
|
|
118
|
+
async apply() {},
|
|
119
|
+
async teardown() {},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface AudienceFixture {
|
|
124
|
+
root: string;
|
|
125
|
+
companionPath: string;
|
|
126
|
+
repoPath: string;
|
|
127
|
+
config: FrameworkConfig;
|
|
128
|
+
skillSources: Record<FixtureOwner, string>;
|
|
129
|
+
/** Providers, the base plugin, and both audiences' plugins, tagged with their source. */
|
|
130
|
+
registrations(
|
|
131
|
+
options?: Partial<Record<FixtureOwner, FixtureCapabilityOptions>>,
|
|
132
|
+
): PluginRegistration[];
|
|
133
|
+
cleanup(): Promise<void>;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const FRAMEWORK_YAML = [
|
|
137
|
+
"type: companion",
|
|
138
|
+
"allowedAgents:",
|
|
139
|
+
" - claude",
|
|
140
|
+
" - opencode",
|
|
141
|
+
"plugins:",
|
|
142
|
+
' - package: "@fixture/base"',
|
|
143
|
+
' version: "1.0.0"',
|
|
144
|
+
"audiences:",
|
|
145
|
+
" ba:",
|
|
146
|
+
" plugins:",
|
|
147
|
+
' - package: "@fixture/ba"',
|
|
148
|
+
' version: "1.0.0"',
|
|
149
|
+
" qa:",
|
|
150
|
+
" plugins:",
|
|
151
|
+
' - package: "@fixture/qa"',
|
|
152
|
+
' version: "1.0.0"',
|
|
153
|
+
"",
|
|
154
|
+
].join("\n");
|
|
155
|
+
|
|
156
|
+
export async function makeAudienceFixture(): Promise<AudienceFixture> {
|
|
157
|
+
const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "audience-isolation-")));
|
|
158
|
+
const companionPath = path.join(root, "companion");
|
|
159
|
+
const repoPath = path.join(root, "repo");
|
|
160
|
+
await fs.mkdir(path.join(companionPath, ".mate", "config"), { recursive: true });
|
|
161
|
+
await fs.mkdir(repoPath, { recursive: true });
|
|
162
|
+
await fs.writeFile(
|
|
163
|
+
path.join(companionPath, ".mate", "config", "framework.yaml"),
|
|
164
|
+
FRAMEWORK_YAML,
|
|
165
|
+
"utf8",
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const skillSources = {} as Record<FixtureOwner, string>;
|
|
169
|
+
for (const owner of ["base", "ba", "qa"] as const) {
|
|
170
|
+
const dir = path.join(root, "skill-sources", `${owner}-skill`);
|
|
171
|
+
await fs.mkdir(dir, { recursive: true });
|
|
172
|
+
await fs.writeFile(
|
|
173
|
+
path.join(dir, "SKILL.md"),
|
|
174
|
+
`---\nname: ${owner}-skill\ndescription: ${owner} skill\n---\n${AUDIENCE_MARKERS[owner]} skill body.\n`,
|
|
175
|
+
"utf8",
|
|
176
|
+
);
|
|
177
|
+
skillSources[owner] = dir;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const config: FrameworkConfig = {
|
|
181
|
+
type: "companion",
|
|
182
|
+
allowedAgents: ["claude", "opencode"],
|
|
183
|
+
capabilities: [],
|
|
184
|
+
plugins: [{ package: "@fixture/base", version: "1.0.0" }],
|
|
185
|
+
audiences: {
|
|
186
|
+
ba: { plugins: [{ package: "@fixture/ba", version: "1.0.0" }] },
|
|
187
|
+
qa: { plugins: [{ package: "@fixture/qa", version: "1.0.0" }] },
|
|
188
|
+
},
|
|
189
|
+
} as FrameworkConfig;
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
root,
|
|
193
|
+
companionPath,
|
|
194
|
+
repoPath,
|
|
195
|
+
config,
|
|
196
|
+
skillSources,
|
|
197
|
+
registrations(options = {}) {
|
|
198
|
+
return [
|
|
199
|
+
{ plugin: fixtureProvider("claude"), policy: "required" },
|
|
200
|
+
{ plugin: fixtureProvider("opencode"), policy: "required" },
|
|
201
|
+
{
|
|
202
|
+
plugin: markedCapability("base", skillSources.base, options.base),
|
|
203
|
+
policy: "required",
|
|
204
|
+
audience: null,
|
|
205
|
+
packageName: fixturePackage("base"),
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
plugin: markedCapability("ba", skillSources.ba, options.ba),
|
|
209
|
+
policy: "optional",
|
|
210
|
+
audience: "ba",
|
|
211
|
+
packageName: fixturePackage("ba"),
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
plugin: markedCapability("qa", skillSources.qa, options.qa),
|
|
215
|
+
policy: "optional",
|
|
216
|
+
audience: "qa",
|
|
217
|
+
packageName: fixturePackage("qa"),
|
|
218
|
+
},
|
|
219
|
+
];
|
|
220
|
+
},
|
|
221
|
+
cleanup: () => fs.rm(root, { recursive: true, force: true }),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Every regular file under `root` (symlinks followed), as relative path → text. Missing root: none. */
|
|
226
|
+
export async function readTree(root: string): Promise<Map<string, string>> {
|
|
227
|
+
const files = new Map<string, string>();
|
|
228
|
+
async function walk(dir: string): Promise<void> {
|
|
229
|
+
let entries: Dirent[];
|
|
230
|
+
try {
|
|
231
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
232
|
+
} catch {
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
for (const entry of entries) {
|
|
236
|
+
const full = path.join(dir, entry.name);
|
|
237
|
+
const stat = await fs.stat(full).catch(() => null);
|
|
238
|
+
if (!stat) continue;
|
|
239
|
+
if (stat.isDirectory()) await walk(full);
|
|
240
|
+
else if (stat.isFile()) files.set(path.relative(root, full), await fs.readFile(full, "utf8"));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
await walk(root);
|
|
244
|
+
return files;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** `<where>: <marker>` for every marker of `owners` found in `text`. */
|
|
248
|
+
export function markerHits(where: string, text: string, owners: readonly FixtureOwner[]): string[] {
|
|
249
|
+
return owners
|
|
250
|
+
.filter((owner) => text.includes(AUDIENCE_MARKERS[owner]) || ownerIdentityIn(text, owner))
|
|
251
|
+
.map((owner) => `${where}: ${owner}`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Identities without the marker (MCP, skill, agent names) still count as the owner's. */
|
|
255
|
+
function ownerIdentityIn(text: string, owner: FixtureOwner): boolean {
|
|
256
|
+
return [`${owner}-mcp`, `${owner}-skill`, `${owner}-agent`, `${owner}-plugin`].some((identity) =>
|
|
257
|
+
text.includes(identity),
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** The installed plugin packages: what a plugin is, not what a session loads. */
|
|
262
|
+
const PLUGIN_WORKSPACE = `${path.join(".mate", "plugins")}${path.sep}`;
|
|
263
|
+
|
|
264
|
+
/** Marker hits of `owners` in every file (path and content) under `root`, the plugin workspace aside. */
|
|
265
|
+
export async function markerHitsInTree(
|
|
266
|
+
root: string,
|
|
267
|
+
owners: readonly FixtureOwner[],
|
|
268
|
+
skip: readonly string[] = [],
|
|
269
|
+
): Promise<string[]> {
|
|
270
|
+
const hits: string[] = [];
|
|
271
|
+
for (const [relative, text] of await readTree(root)) {
|
|
272
|
+
if (relative.startsWith(PLUGIN_WORKSPACE)) continue;
|
|
273
|
+
if (skip.some((prefix) => relative.startsWith(prefix))) continue;
|
|
274
|
+
hits.push(...markerHits(relative, `${relative}\n${text}`, owners));
|
|
275
|
+
}
|
|
276
|
+
return hits;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Marker hits of `owners` in any JSON-serializable value: rendered documents, launch arguments. */
|
|
280
|
+
export function markerHitsInValue(
|
|
281
|
+
where: string,
|
|
282
|
+
value: unknown,
|
|
283
|
+
owners: readonly FixtureOwner[],
|
|
284
|
+
): string[] {
|
|
285
|
+
return markerHits(where, JSON.stringify(value), owners);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Installs `owner`'s fixture plugin into the companion's plugin workspace, so
|
|
290
|
+
* a pass can load it from its declaration alone. Declares exactly what
|
|
291
|
+
* `markedCapability` declares, hook groups and all.
|
|
292
|
+
*/
|
|
293
|
+
export async function installFixturePackage(
|
|
294
|
+
fixture: AudienceFixture,
|
|
295
|
+
owner: FixtureOwner,
|
|
296
|
+
): Promise<void> {
|
|
297
|
+
const name = fixturePackage(owner);
|
|
298
|
+
const root = pluginPackageRoot(fixture.companionPath, name);
|
|
299
|
+
await fs.mkdir(root, { recursive: true });
|
|
300
|
+
await fs.writeFile(
|
|
301
|
+
path.join(root, "package.json"),
|
|
302
|
+
JSON.stringify({ name, version: "1.0.0", type: "module", main: "index.js" }),
|
|
303
|
+
"utf8",
|
|
304
|
+
);
|
|
305
|
+
const declared = markedCapability(owner, fixture.skillSources[owner]).getRuntimeContributions!(
|
|
306
|
+
{} as never,
|
|
307
|
+
);
|
|
308
|
+
const source = `
|
|
309
|
+
const contributions = ${JSON.stringify(declared)};
|
|
310
|
+
export default function createPlugin() {
|
|
311
|
+
return {
|
|
312
|
+
id: ${JSON.stringify(fixturePluginId(owner))},
|
|
313
|
+
kind: "capability",
|
|
314
|
+
label: ${JSON.stringify(owner)},
|
|
315
|
+
description: "installed fixture",
|
|
316
|
+
defaultSelected: true,
|
|
317
|
+
isEnabled: () => true,
|
|
318
|
+
apply: async () => {},
|
|
319
|
+
teardown: async () => {},
|
|
320
|
+
getRuntimeContributions: () => contributions,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
`;
|
|
324
|
+
await fs.writeFile(path.join(root, "index.js"), source, "utf8");
|
|
325
|
+
}
|