@uniqbit/mate-core 0.15.1-canary.0 → 0.15.1-canary.1
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/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/lib/context-mode-package.ts +94 -0
- package/src/lib/opencode-plugin-package.ts +27 -30
- package/src/lib/orchestrator/adapters/claude.ts +20 -0
- package/src/lib/orchestrator/adapters/opencode.ts +4 -3
- package/src/lib/orchestrator/launcher.ts +52 -0
- package/src/plugins.ts +1 -0
- package/src/templates/capabilities/openspec-cap/claude/hooks/mate-artifact-finish.sh +48 -8
- package/src/tools/setup/capabilities/context-mode.ts +197 -0
- package/src/tools/setup/context-services.ts +6 -2
- package/src/tools/setup/plugin.ts +10 -2
- package/src/tools/setup/providers/claude.ts +2 -2
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { PUBLIC_NPM_REGISTRY } from "./public-npm";
|
|
6
|
+
|
|
7
|
+
export const CONTEXT_MODE_PACKAGE_NAME = "context-mode";
|
|
8
|
+
export const CONTEXT_MODE_VERSION = "1.0.169";
|
|
9
|
+
export const CONTEXT_MODE_NODE_REQUIREMENT = ">=22.5.0";
|
|
10
|
+
|
|
11
|
+
export function getContextModePackageReference(): string {
|
|
12
|
+
return `${CONTEXT_MODE_PACKAGE_NAME}@${CONTEXT_MODE_VERSION}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function isContextModePackageReference(value: unknown): value is string {
|
|
16
|
+
return (
|
|
17
|
+
typeof value === "string" &&
|
|
18
|
+
(value === CONTEXT_MODE_PACKAGE_NAME || value.startsWith(`${CONTEXT_MODE_PACKAGE_NAME}@`))
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function getContextModeInstallDir(companionPath: string): string {
|
|
23
|
+
return path.join(companionPath, ".mate", "dependencies", CONTEXT_MODE_PACKAGE_NAME);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function getContextModePackageRoot(companionPath: string): string {
|
|
27
|
+
return path.join(
|
|
28
|
+
getContextModeInstallDir(companionPath),
|
|
29
|
+
"node_modules",
|
|
30
|
+
CONTEXT_MODE_PACKAGE_NAME,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function isContextModeNodeVersionSupported(version: string): boolean {
|
|
35
|
+
const [major = 0, minor = 0] = version.replace(/^v/, "").split(".").map(Number);
|
|
36
|
+
return major > 22 || (major === 22 && minor >= 5);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function validateContextModeNodeRuntime(): void {
|
|
40
|
+
const result = spawnSync("node", ["--version"], { encoding: "utf8" });
|
|
41
|
+
const version = result.stdout?.trim() ?? "";
|
|
42
|
+
if (result.status !== 0 || !isContextModeNodeVersionSupported(version)) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`context-mode ${CONTEXT_MODE_VERSION} requires Node.js ${CONTEXT_MODE_NODE_REQUIREMENT}; found ${version || "no usable node executable"}.`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function validateContextModePackage(companionPath: string): Promise<void> {
|
|
50
|
+
validateContextModeNodeRuntime();
|
|
51
|
+
const root = getContextModePackageRoot(companionPath);
|
|
52
|
+
const manifest = JSON.parse(await fs.readFile(path.join(root, "package.json"), "utf8")) as {
|
|
53
|
+
version?: string;
|
|
54
|
+
};
|
|
55
|
+
if (manifest.version !== CONTEXT_MODE_VERSION) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`Expected ${getContextModePackageReference()} at ${root}; found ${manifest.version ?? "an unknown version"}. Re-run \`mate companion setup\`.`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
await Promise.all(
|
|
61
|
+
[
|
|
62
|
+
".claude-plugin/plugin.json",
|
|
63
|
+
"hooks/hooks.json",
|
|
64
|
+
"skills/context-mode/SKILL.md",
|
|
65
|
+
"build/adapters/opencode/plugin.js",
|
|
66
|
+
].map((asset) => fs.access(path.join(root, asset))),
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function installContextModePackage(companionPath: string): Promise<void> {
|
|
71
|
+
validateContextModeNodeRuntime();
|
|
72
|
+
const installDir = getContextModeInstallDir(companionPath);
|
|
73
|
+
await fs.mkdir(installDir, { recursive: true });
|
|
74
|
+
await fs.writeFile(
|
|
75
|
+
path.join(installDir, "package.json"),
|
|
76
|
+
JSON.stringify(
|
|
77
|
+
{ private: true, dependencies: { [CONTEXT_MODE_PACKAGE_NAME]: CONTEXT_MODE_VERSION } },
|
|
78
|
+
null,
|
|
79
|
+
2,
|
|
80
|
+
) + "\n",
|
|
81
|
+
"utf8",
|
|
82
|
+
);
|
|
83
|
+
const result = spawnSync(
|
|
84
|
+
"npm",
|
|
85
|
+
["install", "--no-audit", "--no-fund", "--silent", "--registry", PUBLIC_NPM_REGISTRY],
|
|
86
|
+
{ cwd: installDir, encoding: "utf8" },
|
|
87
|
+
);
|
|
88
|
+
if (result.error || result.status !== 0) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`Could not install ${getContextModePackageReference()}: ${result.error?.message ?? result.stderr?.trim() ?? `npm exited with ${result.status}`}`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
await validateContextModePackage(companionPath);
|
|
94
|
+
}
|
|
@@ -44,53 +44,50 @@ export const opencodePluginCacheDeps = {
|
|
|
44
44
|
}),
|
|
45
45
|
};
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
* layout, so the first managed launch after a setup or update does not depend
|
|
51
|
-
* on registry access. Best effort: failures are reported, never thrown.
|
|
52
|
-
*/
|
|
53
|
-
export async function warmOpenCodePluginCache(
|
|
54
|
-
version: string = getCurrentVersion(),
|
|
47
|
+
export async function warmOpenCodePackageCache(
|
|
48
|
+
packageName: string,
|
|
49
|
+
packageReference: string,
|
|
55
50
|
env: NodeJS.ProcessEnv = process.env,
|
|
56
51
|
registry = PUBLIC_NPM_REGISTRY,
|
|
57
52
|
): Promise<WarmPluginCacheResult> {
|
|
58
|
-
// Escape hatch for tests and intentionally offline environments: skip the
|
|
59
|
-
// pre-fetch entirely instead of attempting a registry install.
|
|
60
53
|
if (env.MATE_DISABLE_OPENCODE_PLUGIN_PREFETCH === "1") {
|
|
61
54
|
return { ok: true, detail: "pre-fetch disabled via MATE_DISABLE_OPENCODE_PLUGIN_PREFETCH" };
|
|
62
55
|
}
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
const specDir = path.join(getOpenCodeCacheDir(env), "packages", reference);
|
|
66
|
-
|
|
56
|
+
const version = packageReference.slice(packageName.length + 1);
|
|
57
|
+
const specDir = path.join(getOpenCodeCacheDir(env), "packages", packageReference);
|
|
67
58
|
try {
|
|
68
59
|
await fs.mkdir(specDir, { recursive: true });
|
|
69
60
|
await fs.writeFile(
|
|
70
61
|
path.join(specDir, "package.json"),
|
|
71
|
-
JSON.stringify({ dependencies: { [
|
|
62
|
+
JSON.stringify({ dependencies: { [packageName]: version } }, null, 2) + "\n",
|
|
72
63
|
"utf8",
|
|
73
64
|
);
|
|
74
|
-
|
|
75
65
|
const result = opencodePluginCacheDeps.runInstall(specDir, registry);
|
|
76
|
-
if (result.error) {
|
|
77
|
-
return { ok: false, detail: result.error.message };
|
|
78
|
-
}
|
|
66
|
+
if (result.error) return { ok: false, detail: result.error.message };
|
|
79
67
|
if (result.status !== 0) {
|
|
80
|
-
|
|
81
|
-
|
|
68
|
+
return {
|
|
69
|
+
ok: false,
|
|
70
|
+
detail: `${result.stderr ?? ""}`.trim() || `npm install exited with ${result.status}`,
|
|
71
|
+
};
|
|
82
72
|
}
|
|
83
|
-
|
|
84
|
-
await fs.access(
|
|
85
|
-
path.join(
|
|
86
|
-
specDir,
|
|
87
|
-
"node_modules",
|
|
88
|
-
...OPENCODE_PLUGIN_PACKAGE_NAME.split("/"),
|
|
89
|
-
"package.json",
|
|
90
|
-
),
|
|
91
|
-
);
|
|
73
|
+
await fs.access(path.join(specDir, "node_modules", ...packageName.split("/"), "package.json"));
|
|
92
74
|
return { ok: true };
|
|
93
75
|
} catch (error) {
|
|
94
76
|
return { ok: false, detail: (error as Error).message };
|
|
95
77
|
}
|
|
96
78
|
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Pre-fetch the pinned plugin package into OpenCode's npm plugin environment
|
|
82
|
+
* (`<cache>/packages/<spec>/`), mirroring OpenCode's own on-demand install
|
|
83
|
+
* layout, so the first managed launch after a setup or update does not depend
|
|
84
|
+
* on registry access. Best effort: failures are reported, never thrown.
|
|
85
|
+
*/
|
|
86
|
+
export async function warmOpenCodePluginCache(
|
|
87
|
+
version: string = getCurrentVersion(),
|
|
88
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
89
|
+
registry = PUBLIC_NPM_REGISTRY,
|
|
90
|
+
): Promise<WarmPluginCacheResult> {
|
|
91
|
+
const reference = getOpenCodePluginPackageReference(version);
|
|
92
|
+
return warmOpenCodePackageCache(OPENCODE_PLUGIN_PACKAGE_NAME, reference, env, registry);
|
|
93
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
|
|
3
3
|
import { buildCompanionGuidance } from "../../../playbooks/companion-guidance";
|
|
4
|
+
import { getContextModePackageRoot, validateContextModePackage } from "../../context-mode-package";
|
|
4
5
|
import {
|
|
5
6
|
getCompanionClaudeMcpConfigPath,
|
|
6
7
|
getCompanionClaudeSettingsPath,
|
|
@@ -11,6 +12,19 @@ export class ClaudeAdapter extends LaunchAdapter {
|
|
|
11
12
|
readonly toolName = "claude";
|
|
12
13
|
readonly interactive = true;
|
|
13
14
|
|
|
15
|
+
async validateLaunch(context: AdapterContext): Promise<void> {
|
|
16
|
+
if (context.capabilities.some((capability) => capability.name === "context-mode")) {
|
|
17
|
+
try {
|
|
18
|
+
await validateContextModePackage(context.companionPath);
|
|
19
|
+
} catch (error) {
|
|
20
|
+
throw new Error(
|
|
21
|
+
`Claude context-mode plugin is unavailable: ${(error as Error).message} Repair it with \`mate companion setup\`.`,
|
|
22
|
+
{ cause: error },
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
14
28
|
buildArgs(context: AdapterContext, args: string[]): string[] {
|
|
15
29
|
// Load Mate-managed Claude config from companion settings when present,
|
|
16
30
|
// layered on top of the user's user/project/local sources so global
|
|
@@ -29,6 +43,11 @@ export class ClaudeAdapter extends LaunchAdapter {
|
|
|
29
43
|
const mcpConfigArgs = existsSync(companionMcpConfigPath)
|
|
30
44
|
? ["--mcp-config", companionMcpConfigPath]
|
|
31
45
|
: [];
|
|
46
|
+
const contextModeArgs = context.capabilities.some(
|
|
47
|
+
(capability) => capability.name === "context-mode",
|
|
48
|
+
)
|
|
49
|
+
? ["--plugin-dir", getContextModePackageRoot(context.companionPath)]
|
|
50
|
+
: [];
|
|
32
51
|
|
|
33
52
|
return [
|
|
34
53
|
"--add-dir",
|
|
@@ -37,6 +56,7 @@ export class ClaudeAdapter extends LaunchAdapter {
|
|
|
37
56
|
buildCompanionGuidance(context),
|
|
38
57
|
...settingsArgs,
|
|
39
58
|
...mcpConfigArgs,
|
|
59
|
+
...contextModeArgs,
|
|
40
60
|
...args,
|
|
41
61
|
];
|
|
42
62
|
}
|
|
@@ -171,7 +171,6 @@ export class OpenCodeAdapter extends LaunchAdapter {
|
|
|
171
171
|
)),
|
|
172
172
|
...this.validateGuidance(context),
|
|
173
173
|
];
|
|
174
|
-
|
|
175
174
|
if (errors.length > 0) {
|
|
176
175
|
throw new LaunchPreflightError(
|
|
177
176
|
[
|
|
@@ -197,9 +196,11 @@ export class OpenCodeAdapter extends LaunchAdapter {
|
|
|
197
196
|
} catch {
|
|
198
197
|
return [`Unreadable OpenCode configuration: ${configPath}`];
|
|
199
198
|
}
|
|
199
|
+
if (!isRecord(config)) {
|
|
200
|
+
return [`Unreadable OpenCode configuration: ${configPath}`];
|
|
201
|
+
}
|
|
200
202
|
|
|
201
|
-
const plugins =
|
|
202
|
-
isRecord(config) && Array.isArray(config.plugin) ? (config.plugin as unknown[]) : [];
|
|
203
|
+
const plugins = Array.isArray(config.plugin) ? (config.plugin as unknown[]) : [];
|
|
203
204
|
const mateReferences = plugins.filter(isMateOpenCodePluginReference) as string[];
|
|
204
205
|
|
|
205
206
|
if (mateReferences.includes(expectedPluginReference)) {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// oxlint-disable no-underscore-dangle
|
|
2
2
|
import { syncCompanionFiles, syncWorkingRepoClaudeSettings } from "../../tools/setup";
|
|
3
|
+
import { getActiveDistribution } from "../../distribution";
|
|
4
|
+
import type { CapabilityPlugin, LaunchPreflightContext } from "../../tools/setup/plugin";
|
|
3
5
|
import type { LaunchAdapter, AdapterContext } from "./adapters/base";
|
|
4
6
|
import { ClaudeAdapter } from "./adapters/claude";
|
|
5
7
|
import { OpenCodeAdapter } from "./adapters/opencode";
|
|
@@ -8,6 +10,7 @@ import { syncCompanionGit } from "./companion-git-sync";
|
|
|
8
10
|
import { resolveForLaunch, type LaunchContext } from "./framework-context";
|
|
9
11
|
import {
|
|
10
12
|
RepositoryNotSelectedError,
|
|
13
|
+
LaunchPreflightError,
|
|
11
14
|
ToolNotAllowedError,
|
|
12
15
|
type FrameworkConfig,
|
|
13
16
|
type LaunchRequest,
|
|
@@ -70,6 +73,7 @@ export class FrameworkLauncher {
|
|
|
70
73
|
state.companionPath,
|
|
71
74
|
state.config,
|
|
72
75
|
);
|
|
76
|
+
await this.runCapabilityPreflight(state, request.tool);
|
|
73
77
|
await state.adapter.validateLaunch(adapterContext);
|
|
74
78
|
|
|
75
79
|
return {
|
|
@@ -127,6 +131,54 @@ export class FrameworkLauncher {
|
|
|
127
131
|
};
|
|
128
132
|
}
|
|
129
133
|
|
|
134
|
+
private async runCapabilityPreflight(
|
|
135
|
+
state: ResolvedLaunchState,
|
|
136
|
+
providerId: string,
|
|
137
|
+
): Promise<void> {
|
|
138
|
+
const context: LaunchPreflightContext = {
|
|
139
|
+
companionPath: state.companionPath,
|
|
140
|
+
config: state.config,
|
|
141
|
+
repository: state.repository,
|
|
142
|
+
providerId,
|
|
143
|
+
};
|
|
144
|
+
const preflights = getActiveDistribution()
|
|
145
|
+
.registry.getAll()
|
|
146
|
+
.reduce<
|
|
147
|
+
Array<{
|
|
148
|
+
capability: CapabilityPlugin;
|
|
149
|
+
preflight: NonNullable<NonNullable<CapabilityPlugin["forProvider"]>[string]["preflight"]>;
|
|
150
|
+
}>
|
|
151
|
+
>((entries, plugin) => {
|
|
152
|
+
if (plugin.kind !== "capability" || !plugin.isEnabled(state.config)) return entries;
|
|
153
|
+
const capability = plugin as CapabilityPlugin;
|
|
154
|
+
const preflight = capability.forProvider?.[providerId]?.preflight;
|
|
155
|
+
if (preflight) entries.push({ capability, preflight });
|
|
156
|
+
return entries;
|
|
157
|
+
}, []);
|
|
158
|
+
const results = await Promise.all(
|
|
159
|
+
preflights.map(async ({ capability, preflight }) => {
|
|
160
|
+
try {
|
|
161
|
+
return await preflight(context);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
164
|
+
throw new LaunchPreflightError(
|
|
165
|
+
`Capability preflight failed for ${capability.id} on ${providerId}: ${detail}`,
|
|
166
|
+
{ cause: error },
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
171
|
+
const diagnostics = results.flat();
|
|
172
|
+
|
|
173
|
+
if (diagnostics.length > 0) {
|
|
174
|
+
throw new LaunchPreflightError(
|
|
175
|
+
["Capability launch preflight failed.", ...diagnostics.map((item) => `- ${item}`)].join(
|
|
176
|
+
"\n",
|
|
177
|
+
),
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
130
182
|
private async resolveConfig(): Promise<LaunchContext> {
|
|
131
183
|
return resolveForLaunch(process.cwd());
|
|
132
184
|
}
|
package/src/plugins.ts
CHANGED
|
@@ -14,4 +14,5 @@ export { createTokensavePlugin } from "./tools/setup/capabilities/tokensave";
|
|
|
14
14
|
export { createHeadroomPlugin } from "./tools/setup/capabilities/headroom";
|
|
15
15
|
export { createRtkPlugin } from "./tools/setup/capabilities/rtk";
|
|
16
16
|
export { createGraphifyPlugin } from "./tools/setup/capabilities/graphify";
|
|
17
|
+
export { createContextModePlugin } from "./tools/setup/capabilities/context-mode";
|
|
17
18
|
export { createGitignorePlugin } from "./tools/setup/plugins/gitignore";
|
|
@@ -39,6 +39,23 @@ function shellSplit(command) {
|
|
|
39
39
|
quote = null;
|
|
40
40
|
continue;
|
|
41
41
|
}
|
|
42
|
+
const next = command[index + 1] || "";
|
|
43
|
+
const previous = command[index - 1] || "";
|
|
44
|
+
const isControlOperator = !quote && (
|
|
45
|
+
char === ";" || char === "|" ||
|
|
46
|
+
(char === "&" && next !== ">" && previous !== ">" && previous !== "<")
|
|
47
|
+
);
|
|
48
|
+
if (isControlOperator) {
|
|
49
|
+
if (current) parts.push(current);
|
|
50
|
+
current = "";
|
|
51
|
+
if ((char === "|" || char === "&") && next === char) {
|
|
52
|
+
parts.push(char + next);
|
|
53
|
+
index += 1;
|
|
54
|
+
} else {
|
|
55
|
+
parts.push(char);
|
|
56
|
+
}
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
42
59
|
if (!quote && /\s/.test(char)) {
|
|
43
60
|
if (current) parts.push(current);
|
|
44
61
|
current = "";
|
|
@@ -55,6 +72,13 @@ function extractChangeFromPath(value) {
|
|
|
55
72
|
return match ? match[1].replace(/^\d{4}-\d{2}-\d{2}-/, "") : null;
|
|
56
73
|
}
|
|
57
74
|
|
|
75
|
+
// Shell syntax tokens are never change-name positionals: separators end the
|
|
76
|
+
// archive invocation, redirections are skipped (a bare operator also consumes
|
|
77
|
+
// its target token).
|
|
78
|
+
const separatorPattern = /^(\|\|?|&&?|;)$/;
|
|
79
|
+
const redirectionPattern = /^(\d*(>>?|<)|&>>?)/;
|
|
80
|
+
const bareRedirectionPattern = /^(\d*(>>?|<)|&>>?)$/;
|
|
81
|
+
|
|
58
82
|
function extractArchiveCommand(command) {
|
|
59
83
|
const parts = shellSplit(command);
|
|
60
84
|
const index = parts.findIndex((part, position) =>
|
|
@@ -64,6 +88,11 @@ function extractArchiveCommand(command) {
|
|
|
64
88
|
for (let position = index + 1; position < parts.length; position += 1) {
|
|
65
89
|
const part = parts[position];
|
|
66
90
|
if (!part || part === "--") continue;
|
|
91
|
+
if (separatorPattern.test(part)) return null;
|
|
92
|
+
if (redirectionPattern.test(part)) {
|
|
93
|
+
if (bareRedirectionPattern.test(part)) position += 1;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
67
96
|
if (part.startsWith("-")) {
|
|
68
97
|
if (part === "--store") position += 1;
|
|
69
98
|
continue;
|
|
@@ -93,27 +122,38 @@ function extractMoveCommand(command) {
|
|
|
93
122
|
return null;
|
|
94
123
|
}
|
|
95
124
|
|
|
125
|
+
// Nudge only on apparent success: stay silent on a clear failure indication,
|
|
126
|
+
// nudge when success is indicated or indeterminate.
|
|
127
|
+
function isClearFailure(response) {
|
|
128
|
+
if (!response || typeof response !== "object") return false;
|
|
129
|
+
if (response.success === false || response.is_error === true) return true;
|
|
130
|
+
if (typeof response.error === "string" && response.error) return true;
|
|
131
|
+
if (response.interrupted === true) return true;
|
|
132
|
+
const exitCode = response.exit_code ?? response.exitCode;
|
|
133
|
+
return typeof exitCode === "number" && exitCode !== 0;
|
|
134
|
+
}
|
|
135
|
+
|
|
96
136
|
let input = {};
|
|
97
137
|
try {
|
|
98
138
|
input = JSON.parse(fs.readFileSync(inputFile, "utf8") || "{}");
|
|
99
139
|
} catch {}
|
|
100
140
|
|
|
101
|
-
if (input.hook_event_name !== "
|
|
141
|
+
if (input.hook_event_name !== "PostToolUse" || input.tool_name !== "Bash") process.exit(0);
|
|
102
142
|
const command = input.tool_input && typeof input.tool_input.command === "string"
|
|
103
143
|
? input.tool_input.command
|
|
104
144
|
: "";
|
|
105
145
|
const change = extractArchiveCommand(command) || extractMoveCommand(command);
|
|
106
146
|
if (!change) process.exit(0);
|
|
147
|
+
if (isClearFailure(input.tool_response)) process.exit(0);
|
|
107
148
|
|
|
108
|
-
const
|
|
109
|
-
"
|
|
110
|
-
"
|
|
111
|
-
"
|
|
149
|
+
const context =
|
|
150
|
+
"OpenSpec change " + change + " was just archived. Invoke the mate-artifact-finish " +
|
|
151
|
+
"skill, then run `mate artifact finish \"" + change + "\" --json` to complete the " +
|
|
152
|
+
"finish workflow (commit, tag, and push).";
|
|
112
153
|
process.stdout.write(JSON.stringify({
|
|
113
154
|
hookSpecificOutput: {
|
|
114
|
-
hookEventName: "
|
|
115
|
-
|
|
116
|
-
permissionDecisionReason: reason,
|
|
155
|
+
hookEventName: "PostToolUse",
|
|
156
|
+
additionalContext: context,
|
|
117
157
|
},
|
|
118
158
|
}));
|
|
119
159
|
' "$input_file"
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// oxlint-disable no-await-in-loop -- config files are reconciled sequentially with shared ownership state
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
CONTEXT_MODE_PACKAGE_NAME,
|
|
7
|
+
getContextModeInstallDir,
|
|
8
|
+
getContextModePackageReference,
|
|
9
|
+
installContextModePackage,
|
|
10
|
+
isContextModePackageReference,
|
|
11
|
+
validateContextModePackage,
|
|
12
|
+
} from "../../../lib/context-mode-package";
|
|
13
|
+
import { warmOpenCodePackageCache } from "../../../lib/opencode-plugin-package";
|
|
14
|
+
import type { CapabilityPlugin, LaunchPreflightContext, SetupContext } from "../plugin";
|
|
15
|
+
import { pruneEmptyAncestors } from "../utils";
|
|
16
|
+
|
|
17
|
+
type Config = Record<string, unknown>;
|
|
18
|
+
interface ContextModePluginDeps {
|
|
19
|
+
installPackage?: typeof installContextModePackage;
|
|
20
|
+
validatePackage?: typeof validateContextModePackage;
|
|
21
|
+
warmOpenCodePackage?: typeof warmOpenCodePackageCache;
|
|
22
|
+
}
|
|
23
|
+
interface OwnershipState {
|
|
24
|
+
opencodeConfigs: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const CONTEXT_MODE_OPENCODE_GUIDANCE = `## Context Mode
|
|
28
|
+
|
|
29
|
+
Use Context Mode tools for commands or tool calls whose output may be large, for sandboxed analysis of large files or structured data, and when indexing large fetched content.
|
|
30
|
+
|
|
31
|
+
Canonical Mate policy and any more specific tool-routing instructions remain authoritative. In particular, follow explicit requirements to use TokenSave for codebase exploration, Context7 for library documentation, and specialized tools for their assigned operations; Context Mode complements rather than replaces them.`;
|
|
32
|
+
|
|
33
|
+
function getOwnershipPath(companionPath: string): string {
|
|
34
|
+
return path.join(companionPath, ".mate", "state", "context-mode.json");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function readOwnership(companionPath: string): Promise<OwnershipState> {
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(await fs.readFile(getOwnershipPath(companionPath), "utf8")) as OwnershipState;
|
|
40
|
+
} catch {
|
|
41
|
+
return { opencodeConfigs: [] };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function writeOwnership(companionPath: string, state: OwnershipState): Promise<void> {
|
|
46
|
+
const statePath = getOwnershipPath(companionPath);
|
|
47
|
+
if (state.opencodeConfigs.length === 0) {
|
|
48
|
+
await fs.rm(statePath, { force: true });
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
await fs.mkdir(path.dirname(statePath), { recursive: true });
|
|
52
|
+
await fs.writeFile(statePath, JSON.stringify(state, null, 2) + "\n", "utf8");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isRecord(value: unknown): value is Config {
|
|
56
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function readConfig(filePath: string): Promise<Config> {
|
|
60
|
+
try {
|
|
61
|
+
const parsed = JSON.parse(await fs.readFile(filePath, "utf8")) as unknown;
|
|
62
|
+
return isRecord(parsed) ? parsed : {};
|
|
63
|
+
} catch {
|
|
64
|
+
return {};
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function hasContextModeMcp(config: Config): boolean {
|
|
69
|
+
const servers = isRecord(config.mcpServers)
|
|
70
|
+
? config.mcpServers
|
|
71
|
+
: isRecord(config.mcp)
|
|
72
|
+
? config.mcp
|
|
73
|
+
: {};
|
|
74
|
+
return Object.entries(servers).some(
|
|
75
|
+
([name, value]) =>
|
|
76
|
+
name.toLowerCase().includes("context-mode") || JSON.stringify(value).includes("context-mode"),
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function assertNoMcpConflict(ctx: SetupContext, provider: "claude" | "opencode") {
|
|
81
|
+
const configPath =
|
|
82
|
+
provider === "claude"
|
|
83
|
+
? path.join(ctx.companionPath, ".mcp.json")
|
|
84
|
+
: path.join(ctx.companionPath, ".opencode", "opencode.json");
|
|
85
|
+
if (hasContextModeMcp(await readConfig(configPath))) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`Cannot enable context-mode for ${provider}: ${configPath} already contains a context-mode MCP registration. Remove the duplicate registration or deselect the context-mode capability; Mate did not modify it.`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function updateOpenCodeConfig(ctx: SetupContext, enabled: boolean): Promise<void> {
|
|
93
|
+
const reference = getContextModePackageReference();
|
|
94
|
+
const ownership = await readOwnership(ctx.companionPath);
|
|
95
|
+
for (const name of ["opencode.json", "tui.json"]) {
|
|
96
|
+
const configPath = path.join(ctx.companionPath, ".opencode", name);
|
|
97
|
+
const owned = ownership.opencodeConfigs.includes(name);
|
|
98
|
+
if (!enabled && !owned) continue;
|
|
99
|
+
const config = await readConfig(configPath);
|
|
100
|
+
const plugins = Array.isArray(config.plugin) ? config.plugin : [];
|
|
101
|
+
const contextModeReferences = plugins.filter(isContextModePackageReference);
|
|
102
|
+
if (enabled && contextModeReferences.some((entry) => entry !== reference)) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`Cannot enable ${reference}: ${configPath} contains user-owned context-mode reference ${contextModeReferences.join(", ")}. Mate did not modify it.`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
const preserved = plugins.filter((entry) => !(entry === reference && owned));
|
|
108
|
+
if (enabled) {
|
|
109
|
+
if (!contextModeReferences.includes(reference)) {
|
|
110
|
+
config.plugin = [...plugins, reference];
|
|
111
|
+
ownership.opencodeConfigs.push(name);
|
|
112
|
+
}
|
|
113
|
+
} else if (preserved.length > 0) {
|
|
114
|
+
config.plugin = preserved;
|
|
115
|
+
} else {
|
|
116
|
+
delete config.plugin;
|
|
117
|
+
}
|
|
118
|
+
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
|
119
|
+
await fs.writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
120
|
+
}
|
|
121
|
+
if (!enabled) ownership.opencodeConfigs = [];
|
|
122
|
+
await writeOwnership(ctx.companionPath, ownership);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function validateOpenCodeReferences(ctx: LaunchPreflightContext): Promise<string[]> {
|
|
126
|
+
const expected = getContextModePackageReference();
|
|
127
|
+
const diagnostics: string[] = [];
|
|
128
|
+
for (const name of ["opencode.json", "tui.json"]) {
|
|
129
|
+
const configPath = path.join(ctx.companionPath, ".opencode", name);
|
|
130
|
+
const config = await readConfig(configPath);
|
|
131
|
+
const plugins = Array.isArray(config.plugin) ? config.plugin : [];
|
|
132
|
+
if (!plugins.includes(expected)) {
|
|
133
|
+
diagnostics.push(
|
|
134
|
+
`Missing or stale context-mode package reference in ${configPath}; expected ${expected}.`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return diagnostics;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function createContextModePlugin(deps: ContextModePluginDeps = {}): CapabilityPlugin {
|
|
142
|
+
const installPackage = deps.installPackage ?? installContextModePackage;
|
|
143
|
+
const validatePackage = deps.validatePackage ?? validateContextModePackage;
|
|
144
|
+
const warmOpenCodePackage = deps.warmOpenCodePackage ?? warmOpenCodePackageCache;
|
|
145
|
+
return {
|
|
146
|
+
id: "context-mode",
|
|
147
|
+
kind: "capability",
|
|
148
|
+
label: "Context Mode",
|
|
149
|
+
description: "Protect context windows with pinned native Claude and OpenCode plugins.",
|
|
150
|
+
defaultSelected: false,
|
|
151
|
+
isEnabled: (config) =>
|
|
152
|
+
(config.capabilities ?? []).some((capability) => capability.name === "context-mode"),
|
|
153
|
+
async apply(ctx) {
|
|
154
|
+
if (ctx.mode === "setup") {
|
|
155
|
+
await installPackage(ctx.companionPath);
|
|
156
|
+
} else {
|
|
157
|
+
await validatePackage(ctx.companionPath);
|
|
158
|
+
}
|
|
159
|
+
await ctx.instructions?.append(CONTEXT_MODE_OPENCODE_GUIDANCE, {
|
|
160
|
+
providers: ["opencode"],
|
|
161
|
+
});
|
|
162
|
+
},
|
|
163
|
+
async teardown(ctx) {
|
|
164
|
+
await fs.rm(getContextModeInstallDir(ctx.companionPath), { recursive: true, force: true });
|
|
165
|
+
await pruneEmptyAncestors(getContextModeInstallDir(ctx.companionPath), ctx.companionPath);
|
|
166
|
+
},
|
|
167
|
+
forProvider: {
|
|
168
|
+
claude: {
|
|
169
|
+
async apply(ctx) {
|
|
170
|
+
await assertNoMcpConflict(ctx, "claude");
|
|
171
|
+
},
|
|
172
|
+
async teardown() {},
|
|
173
|
+
},
|
|
174
|
+
opencode: {
|
|
175
|
+
preflight: validateOpenCodeReferences,
|
|
176
|
+
async apply(ctx) {
|
|
177
|
+
await assertNoMcpConflict(ctx, "opencode");
|
|
178
|
+
await updateOpenCodeConfig(ctx, true);
|
|
179
|
+
if (ctx.mode === "setup") {
|
|
180
|
+
const result = await warmOpenCodePackage(
|
|
181
|
+
CONTEXT_MODE_PACKAGE_NAME,
|
|
182
|
+
getContextModePackageReference(),
|
|
183
|
+
);
|
|
184
|
+
if (!result.ok) {
|
|
185
|
+
process.stderr.write(
|
|
186
|
+
`mate: could not pre-fetch ${getContextModePackageReference()} for OpenCode: ${result.detail ?? "unknown error"}\n`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
async teardown(ctx) {
|
|
192
|
+
await updateOpenCodeConfig(ctx, false);
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
}
|
|
@@ -234,8 +234,12 @@ export class ContextServiceMediator {
|
|
|
234
234
|
};
|
|
235
235
|
|
|
236
236
|
const instructions: InstructionsService = {
|
|
237
|
-
append: async (content: string) => {
|
|
238
|
-
const hosts = hostingProviders.filter(
|
|
237
|
+
append: async (content: string, options) => {
|
|
238
|
+
const hosts = hostingProviders.filter(
|
|
239
|
+
(provider) =>
|
|
240
|
+
provider.hosting.instructions &&
|
|
241
|
+
(options?.providers === undefined || options.providers.includes(provider.id)),
|
|
242
|
+
);
|
|
239
243
|
if (hosts.length === 0) {
|
|
240
244
|
warn(`${pluginId}: no active provider hosts instruction injection; skipping append`);
|
|
241
245
|
return;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { FrameworkConfig } from "../../lib/orchestrator/types";
|
|
1
|
+
import type { FrameworkConfig, LinkedRepository } from "../../lib/orchestrator/types";
|
|
2
2
|
import type { InstallRequirement, InstallRequirementContext } from "./install-contract";
|
|
3
3
|
|
|
4
4
|
export interface SetupContext {
|
|
@@ -18,6 +18,13 @@ export interface SetupContext {
|
|
|
18
18
|
templates?: TemplatesService;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
export interface LaunchPreflightContext {
|
|
22
|
+
companionPath: string;
|
|
23
|
+
config: FrameworkConfig;
|
|
24
|
+
repository: LinkedRepository;
|
|
25
|
+
providerId: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
21
28
|
/** Provider-agnostic description of an MCP server to register. */
|
|
22
29
|
export interface McpServerDescriptor {
|
|
23
30
|
name: string;
|
|
@@ -32,7 +39,7 @@ export interface McpService {
|
|
|
32
39
|
}
|
|
33
40
|
|
|
34
41
|
export interface InstructionsService {
|
|
35
|
-
append(content: string): Promise<void>;
|
|
42
|
+
append(content: string, options?: { providers?: string[] }): Promise<void>;
|
|
36
43
|
}
|
|
37
44
|
|
|
38
45
|
export interface TemplatesService {
|
|
@@ -113,6 +120,7 @@ export interface CapabilityPlugin extends Plugin {
|
|
|
113
120
|
{
|
|
114
121
|
apply(ctx: SetupContext): Promise<void>;
|
|
115
122
|
teardown(ctx: SetupContext): Promise<void>;
|
|
123
|
+
preflight?(ctx: LaunchPreflightContext): Promise<string[]>;
|
|
116
124
|
}
|
|
117
125
|
>;
|
|
118
126
|
}
|
|
@@ -267,7 +267,7 @@ function buildManagedClaudeSettings(
|
|
|
267
267
|
...(hooks.SessionStart ?? []),
|
|
268
268
|
];
|
|
269
269
|
if (openspecEnabled) {
|
|
270
|
-
hooks.
|
|
270
|
+
hooks.PostToolUse = [
|
|
271
271
|
{
|
|
272
272
|
matcher: "Bash",
|
|
273
273
|
hooks: [
|
|
@@ -277,7 +277,7 @@ function buildManagedClaudeSettings(
|
|
|
277
277
|
},
|
|
278
278
|
],
|
|
279
279
|
},
|
|
280
|
-
...(hooks.
|
|
280
|
+
...(hooks.PostToolUse ?? []),
|
|
281
281
|
];
|
|
282
282
|
}
|
|
283
283
|
if (reactDoctorEnabled) {
|