@uniqbit/mate-core 0.15.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/cli/commands/doctor.ts +4 -4
- package/src/cli/commands/report/index.ts +5 -3
- 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/config-store.ts +22 -2
- package/src/lib/orchestrator/launcher.ts +52 -0
- package/src/lib/orchestrator/setup-compatibilities.ts +8 -0
- package/src/lib/orchestrator/types.ts +1 -0
- package/src/opencode/companion-hooks.ts +1 -1
- package/src/plugins.ts +2 -0
- package/src/templates/capabilities/openspec-cap/claude/hooks/mate-artifact-finish.sh +160 -0
- package/src/templates/capabilities/openspec-cap/mate-skills/{mate-openspec-artifact-finish → mate-artifact-finish}/SKILL.md +1 -1
- package/src/templates/capabilities/openspec-cap/mate-skills/{mate-openspec-artifact-finish → mate-artifact-finish}/references/openspec.md +1 -1
- package/src/tools/setup/capabilities/context-mode.ts +197 -0
- package/src/tools/setup/capabilities/graphify.ts +1 -1
- package/src/tools/setup/capabilities/headroom.ts +3 -98
- package/src/tools/setup/capabilities/openspec.ts +20 -7
- package/src/tools/setup/capabilities/rtk.ts +107 -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 +4 -18
- package/src/tools/setup.ts +1 -1
- package/src/templates/capabilities/openspec-cap/claude/hooks/mate-openspec-artifact-finish.sh +0 -236
package/package.json
CHANGED
|
@@ -127,10 +127,10 @@ function getSelectedToolChecks(config: {
|
|
|
127
127
|
checks.push({ name: "tokensave", command: "tokensave", source: "capability" });
|
|
128
128
|
}
|
|
129
129
|
if (selectedCapabilities.has("headroom")) {
|
|
130
|
-
checks.push(
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
);
|
|
130
|
+
checks.push({ name: "headroom", command: "headroom", source: "capability" });
|
|
131
|
+
}
|
|
132
|
+
if (selectedCapabilities.has("rtk")) {
|
|
133
|
+
checks.push({ name: "rtk", command: "rtk", source: "capability" });
|
|
134
134
|
}
|
|
135
135
|
if (selectedCapabilities.has("graphify")) {
|
|
136
136
|
checks.push({ name: "graphify", command: "graphify", source: "capability" });
|
|
@@ -74,6 +74,7 @@ export async function runReportCommand(
|
|
|
74
74
|
|
|
75
75
|
const tokensaveEnabled = enabledCapabilities.includes("tokensave");
|
|
76
76
|
const headroomEnabled = enabledCapabilities.includes("headroom");
|
|
77
|
+
const rtkEnabled = enabledCapabilities.includes("rtk");
|
|
77
78
|
|
|
78
79
|
// Collect spending via ccusage (covers all agents: Claude, OpenCode, Codex, etc.)
|
|
79
80
|
const ccusageResult = await collectCcusageSpending(options.days, deps);
|
|
@@ -92,7 +93,7 @@ export async function runReportCommand(
|
|
|
92
93
|
};
|
|
93
94
|
let rtkResult: Awaited<ReturnType<typeof collectRTKSavings>> = {
|
|
94
95
|
entry: null,
|
|
95
|
-
status: { name: "rtk", enabled: false, status: "not
|
|
96
|
+
status: { name: "rtk", enabled: false, status: "not enabled" },
|
|
96
97
|
};
|
|
97
98
|
let headroomResult: Awaited<ReturnType<typeof collectHeadroomSavings>> = {
|
|
98
99
|
entry: null,
|
|
@@ -106,8 +107,9 @@ export async function runReportCommand(
|
|
|
106
107
|
]);
|
|
107
108
|
}
|
|
108
109
|
|
|
109
|
-
|
|
110
|
-
|
|
110
|
+
if (rtkEnabled) {
|
|
111
|
+
rtkResult = await collectRTKSavings(workingRepoPath, deps);
|
|
112
|
+
}
|
|
111
113
|
|
|
112
114
|
if (headroomEnabled) {
|
|
113
115
|
headroomResult = await collectHeadroomSavings(options.days, deps);
|
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)) {
|
|
@@ -5,6 +5,8 @@ import { getDefaultSetupSelections } from "./setup-compatibilities";
|
|
|
5
5
|
import { YamlFileStore } from "./yaml-file-store";
|
|
6
6
|
import { type FrameworkConfig } from "./types";
|
|
7
7
|
|
|
8
|
+
export const RTK_CAPABILITY_SPLIT_MIGRATION = "rtk-capability-split-v1";
|
|
9
|
+
|
|
8
10
|
function defaultConfigPath(): string {
|
|
9
11
|
return `.${frameworkConfig.name}/config/framework.yaml`;
|
|
10
12
|
}
|
|
@@ -38,17 +40,35 @@ export function mergeWithDefaults(existing: FrameworkConfig): FrameworkConfig {
|
|
|
38
40
|
};
|
|
39
41
|
}
|
|
40
42
|
|
|
43
|
+
export function migrateRtkCapabilitySplit(config: FrameworkConfig): FrameworkConfig {
|
|
44
|
+
const migrations = config.migrations ?? [];
|
|
45
|
+
if (migrations.includes(RTK_CAPABILITY_SPLIT_MIGRATION)) return config;
|
|
46
|
+
|
|
47
|
+
const capabilities = config.capabilities ?? [];
|
|
48
|
+
const hasHeadroom = capabilities.some((capability) => capability.name === "headroom");
|
|
49
|
+
const hasRtk = capabilities.some((capability) => capability.name === "rtk");
|
|
50
|
+
return {
|
|
51
|
+
...config,
|
|
52
|
+
capabilities: hasHeadroom && !hasRtk ? [...capabilities, { name: "rtk" }] : capabilities,
|
|
53
|
+
migrations: [...migrations, RTK_CAPABILITY_SPLIT_MIGRATION],
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
41
57
|
export class ConfigStore extends YamlFileStore<FrameworkConfig> {
|
|
42
58
|
constructor(configPath = process.env.MATE_CONFIG ?? defaultConfigPath()) {
|
|
43
59
|
super(path.resolve(configPath));
|
|
44
60
|
}
|
|
45
61
|
|
|
46
62
|
override async load(): Promise<FrameworkConfig> {
|
|
47
|
-
|
|
63
|
+
const merged = mergeWithDefaults(await super.load());
|
|
64
|
+
const needsMigration = !(merged.migrations ?? []).includes(RTK_CAPABILITY_SPLIT_MIGRATION);
|
|
65
|
+
const config = migrateRtkCapabilitySplit(merged);
|
|
66
|
+
if (needsMigration) await this.save(config);
|
|
67
|
+
return config;
|
|
48
68
|
}
|
|
49
69
|
|
|
50
70
|
protected async onMissing(): Promise<FrameworkConfig> {
|
|
51
|
-
const config = defaultConfig();
|
|
71
|
+
const config = migrateRtkCapabilitySplit(defaultConfig());
|
|
52
72
|
await this.save(config);
|
|
53
73
|
return config;
|
|
54
74
|
}
|
|
@@ -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
|
}
|
|
@@ -193,6 +193,14 @@ export const BUILTIN_SETUP_COMPATIBILITIES: SetupCompatibility[] = [
|
|
|
193
193
|
description: "Wrap supported agent launches through Headroom when the binary is installed.",
|
|
194
194
|
defaultSelected: false,
|
|
195
195
|
},
|
|
196
|
+
{
|
|
197
|
+
id: "rtk",
|
|
198
|
+
kind: "capability",
|
|
199
|
+
capability: { name: "rtk" },
|
|
200
|
+
label: "RTK",
|
|
201
|
+
description: "Patch supported agent providers with RTK independently from Headroom.",
|
|
202
|
+
defaultSelected: false,
|
|
203
|
+
},
|
|
196
204
|
{
|
|
197
205
|
id: "graphify",
|
|
198
206
|
kind: "capability",
|
|
@@ -211,7 +211,7 @@ function appendOpenSpecFinishNudge(
|
|
|
211
211
|
const nudges = uniqueChanges.map(
|
|
212
212
|
(change) =>
|
|
213
213
|
`ACTION REQUIRED: An openspec change (${change}) was just archived. ` +
|
|
214
|
-
`Immediately invoke the mate-
|
|
214
|
+
`Immediately invoke the mate-artifact-finish skill to complete the finish pipeline ` +
|
|
215
215
|
"(invoke the CLI as `mate`, never through a companion-local wrapper path or as `" +
|
|
216
216
|
"$MATE_COMPANION_BIN_PATH/mate`). " +
|
|
217
217
|
"`mate artifact finish` resumes from the already-archived change and completes the commit, dated tag, and push itself — " +
|
package/src/plugins.ts
CHANGED
|
@@ -12,5 +12,7 @@ export { createOpenspecPlugin } from "./tools/setup/capabilities/openspec";
|
|
|
12
12
|
export { createReactDoctorPlugin } from "./tools/setup/capabilities/react-doctor";
|
|
13
13
|
export { createTokensavePlugin } from "./tools/setup/capabilities/tokensave";
|
|
14
14
|
export { createHeadroomPlugin } from "./tools/setup/capabilities/headroom";
|
|
15
|
+
export { createRtkPlugin } from "./tools/setup/capabilities/rtk";
|
|
15
16
|
export { createGraphifyPlugin } from "./tools/setup/capabilities/graphify";
|
|
17
|
+
export { createContextModePlugin } from "./tools/setup/capabilities/context-mode";
|
|
16
18
|
export { createGitignorePlugin } from "./tools/setup/plugins/gitignore";
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
set -u
|
|
3
|
+
|
|
4
|
+
input_file=$(mktemp "${TMPDIR:-/tmp}/mate-artifact-finish.XXXXXX")
|
|
5
|
+
trap 'rm -f "$input_file"' EXIT
|
|
6
|
+
cat >"$input_file"
|
|
7
|
+
|
|
8
|
+
node -e '
|
|
9
|
+
const fs = require("node:fs");
|
|
10
|
+
|
|
11
|
+
const inputFile = process.argv[1];
|
|
12
|
+
const archivePathPattern = /(?:^|[\s\/"\x27`,(])openspec\/changes\/archive\/(\d{4}-\d{2}-\d{2}-[^\/\s"\x27`,)]+)/;
|
|
13
|
+
|
|
14
|
+
function shellSplit(command) {
|
|
15
|
+
const parts = [];
|
|
16
|
+
const singleQuote = String.fromCharCode(39);
|
|
17
|
+
let current = "";
|
|
18
|
+
let quote = null;
|
|
19
|
+
let escaping = false;
|
|
20
|
+
for (let index = 0; index < command.length; index += 1) {
|
|
21
|
+
const char = command[index];
|
|
22
|
+
if (escaping) {
|
|
23
|
+
current += char;
|
|
24
|
+
escaping = false;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (char === "\\" && quote !== singleQuote) {
|
|
28
|
+
const next = command[index + 1] || "";
|
|
29
|
+
if (next === "\\" || /\s/.test(next) || next === singleQuote || next === String.fromCharCode(34)) {
|
|
30
|
+
escaping = true;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if ((char === singleQuote || char === String.fromCharCode(34)) && !quote) {
|
|
35
|
+
quote = char;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (quote === char) {
|
|
39
|
+
quote = null;
|
|
40
|
+
continue;
|
|
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
|
+
}
|
|
59
|
+
if (!quote && /\s/.test(char)) {
|
|
60
|
+
if (current) parts.push(current);
|
|
61
|
+
current = "";
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
current += char;
|
|
65
|
+
}
|
|
66
|
+
if (current) parts.push(current);
|
|
67
|
+
return parts;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function extractChangeFromPath(value) {
|
|
71
|
+
const match = value.replace(/\\/g, "/").match(archivePathPattern);
|
|
72
|
+
return match ? match[1].replace(/^\d{4}-\d{2}-\d{2}-/, "") : null;
|
|
73
|
+
}
|
|
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
|
+
|
|
82
|
+
function extractArchiveCommand(command) {
|
|
83
|
+
const parts = shellSplit(command);
|
|
84
|
+
const index = parts.findIndex((part, position) =>
|
|
85
|
+
part === "archive" && position > 0 &&
|
|
86
|
+
(parts[position - 1] === "openspec" || parts[position - 1].endsWith("/openspec")));
|
|
87
|
+
if (index < 0) return null;
|
|
88
|
+
for (let position = index + 1; position < parts.length; position += 1) {
|
|
89
|
+
const part = parts[position];
|
|
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
|
+
}
|
|
96
|
+
if (part.startsWith("-")) {
|
|
97
|
+
if (part === "--store") position += 1;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
return part;
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function extractMoveCommand(command) {
|
|
106
|
+
const parts = shellSplit(command);
|
|
107
|
+
const moveCommands = new Set([
|
|
108
|
+
"mv", "move", "move-item", "cmd", "cmd.exe", "powershell", "powershell.exe", "pwsh", "pwsh.exe",
|
|
109
|
+
]);
|
|
110
|
+
const isPython = (part) => /^python(?:\d(?:\.\d+)*)?(?:\.exe)?$/i.test(part.split(/[\\/]/).pop() || part);
|
|
111
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
112
|
+
if (moveCommands.has(parts[index].toLowerCase())) {
|
|
113
|
+
for (const part of parts.slice(index + 1)) {
|
|
114
|
+
const change = extractChangeFromPath(part);
|
|
115
|
+
if (change) return change;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (isPython(parts[index]) && /(?:shutil\.move|os\.rename|\.rename\s*\()/.test(command)) {
|
|
119
|
+
return extractChangeFromPath(command);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
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
|
+
|
|
136
|
+
let input = {};
|
|
137
|
+
try {
|
|
138
|
+
input = JSON.parse(fs.readFileSync(inputFile, "utf8") || "{}");
|
|
139
|
+
} catch {}
|
|
140
|
+
|
|
141
|
+
if (input.hook_event_name !== "PostToolUse" || input.tool_name !== "Bash") process.exit(0);
|
|
142
|
+
const command = input.tool_input && typeof input.tool_input.command === "string"
|
|
143
|
+
? input.tool_input.command
|
|
144
|
+
: "";
|
|
145
|
+
const change = extractArchiveCommand(command) || extractMoveCommand(command);
|
|
146
|
+
if (!change) process.exit(0);
|
|
147
|
+
if (isClearFailure(input.tool_response)) process.exit(0);
|
|
148
|
+
|
|
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).";
|
|
153
|
+
process.stdout.write(JSON.stringify({
|
|
154
|
+
hookSpecificOutput: {
|
|
155
|
+
hookEventName: "PostToolUse",
|
|
156
|
+
additionalContext: context,
|
|
157
|
+
},
|
|
158
|
+
}));
|
|
159
|
+
' "$input_file"
|
|
160
|
+
exit 0
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
name: mate-
|
|
2
|
+
name: mate-artifact-finish
|
|
3
3
|
description: Finish a completed artifact in one step via `mate artifact finish`. Use when the user wants to finish, ship, or archive-and-push a completed artifact and anchor it with a dated revert tag.
|
|
4
4
|
allowed-tools: Bash(mate:*), Bash(git:*), Bash(openspec:*)
|
|
5
5
|
license: MIT
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# OpenSpec Finish Reference
|
|
2
2
|
|
|
3
|
-
Use this reference when you need the OpenSpec-specific parts of `mate-
|
|
3
|
+
Use this reference when you need the OpenSpec-specific parts of `mate-artifact-finish`: change lookup, resumable archive behavior, JSON field meanings, and conflict recovery.
|
|
4
4
|
|
|
5
5
|
## OpenSpec Input Rules
|
|
6
6
|
|