@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.
Files changed (27) hide show
  1. package/package.json +1 -1
  2. package/src/cli/commands/doctor.ts +4 -4
  3. package/src/cli/commands/report/index.ts +5 -3
  4. package/src/index.ts +1 -0
  5. package/src/lib/context-mode-package.ts +94 -0
  6. package/src/lib/opencode-plugin-package.ts +27 -30
  7. package/src/lib/orchestrator/adapters/claude.ts +20 -0
  8. package/src/lib/orchestrator/adapters/opencode.ts +4 -3
  9. package/src/lib/orchestrator/config-store.ts +22 -2
  10. package/src/lib/orchestrator/launcher.ts +52 -0
  11. package/src/lib/orchestrator/setup-compatibilities.ts +8 -0
  12. package/src/lib/orchestrator/types.ts +1 -0
  13. package/src/opencode/companion-hooks.ts +1 -1
  14. package/src/plugins.ts +2 -0
  15. package/src/templates/capabilities/openspec-cap/claude/hooks/mate-artifact-finish.sh +160 -0
  16. package/src/templates/capabilities/openspec-cap/mate-skills/{mate-openspec-artifact-finish → mate-artifact-finish}/SKILL.md +1 -1
  17. package/src/templates/capabilities/openspec-cap/mate-skills/{mate-openspec-artifact-finish → mate-artifact-finish}/references/openspec.md +1 -1
  18. package/src/tools/setup/capabilities/context-mode.ts +197 -0
  19. package/src/tools/setup/capabilities/graphify.ts +1 -1
  20. package/src/tools/setup/capabilities/headroom.ts +3 -98
  21. package/src/tools/setup/capabilities/openspec.ts +20 -7
  22. package/src/tools/setup/capabilities/rtk.ts +107 -0
  23. package/src/tools/setup/context-services.ts +6 -2
  24. package/src/tools/setup/plugin.ts +10 -2
  25. package/src/tools/setup/providers/claude.ts +4 -18
  26. package/src/tools/setup.ts +1 -1
  27. package/src/templates/capabilities/openspec-cap/claude/hooks/mate-openspec-artifact-finish.sh +0 -236
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniqbit/mate-core",
3
- "version": "0.15.0",
3
+ "version": "0.15.1-canary.1",
4
4
  "description": "Core framework and plugin APIs for Mate.",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -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
- { name: "headroom", command: "headroom", source: "capability" },
132
- { name: "rtk", command: "rtk", source: "headroom dependency" },
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 installed" },
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
- // RTK savings
110
- rtkResult = await collectRTKSavings(workingRepoPath, deps);
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
@@ -24,6 +24,7 @@ export { ensureCapabilityEnabled, type EnsureCapabilityEnabledDeps } from "./cli
24
24
  export type {
25
25
  CapabilityPlugin,
26
26
  InstructionsService,
27
+ LaunchPreflightContext,
27
28
  McpServerDescriptor,
28
29
  McpService,
29
30
  Plugin,
@@ -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
- * Pre-fetch the pinned plugin package into OpenCode's npm plugin environment
49
- * (`<cache>/packages/<spec>/`), mirroring OpenCode's own on-demand install
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 reference = getOpenCodePluginPackageReference(version);
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: { [OPENCODE_PLUGIN_PACKAGE_NAME]: version } }, null, 2) + "\n",
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
- const output = `${result.stderr ?? ""}`.trim() || `npm install exited with ${result.status}`;
81
- return { ok: false, detail: output };
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
- return mergeWithDefaults(await super.load());
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",
@@ -86,6 +86,7 @@ export interface FrameworkConfig {
86
86
  git?: GitModeProfile;
87
87
  profiles: Record<string, PolicyProfile>;
88
88
  capabilities?: CapabilityConfig[];
89
+ migrations?: string[];
89
90
  cliTools?: CliToolConfig[];
90
91
  packageManagers?: string[];
91
92
  engines?: EngineConstraints;
@@ -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-openspec-artifact-finish skill to complete the finish pipeline ` +
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-openspec-artifact-finish
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-openspec-artifact-finish`: change lookup, resumable archive behavior, JSON field meanings, and conflict recovery.
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