@uniqbit/mate-core 0.15.0-canary.9 → 0.15.1-canary.0

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniqbit/mate-core",
3
- "version": "0.15.0-canary.9",
3
+ "version": "0.15.1-canary.0",
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);
@@ -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
  }
@@ -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,6 @@ 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";
16
17
  export { createGitignorePlugin } from "./tools/setup/plugins/gitignore";
@@ -0,0 +1,120 @@
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
+ if (!quote && /\s/.test(char)) {
43
+ if (current) parts.push(current);
44
+ current = "";
45
+ continue;
46
+ }
47
+ current += char;
48
+ }
49
+ if (current) parts.push(current);
50
+ return parts;
51
+ }
52
+
53
+ function extractChangeFromPath(value) {
54
+ const match = value.replace(/\\/g, "/").match(archivePathPattern);
55
+ return match ? match[1].replace(/^\d{4}-\d{2}-\d{2}-/, "") : null;
56
+ }
57
+
58
+ function extractArchiveCommand(command) {
59
+ const parts = shellSplit(command);
60
+ const index = parts.findIndex((part, position) =>
61
+ part === "archive" && position > 0 &&
62
+ (parts[position - 1] === "openspec" || parts[position - 1].endsWith("/openspec")));
63
+ if (index < 0) return null;
64
+ for (let position = index + 1; position < parts.length; position += 1) {
65
+ const part = parts[position];
66
+ if (!part || part === "--") continue;
67
+ if (part.startsWith("-")) {
68
+ if (part === "--store") position += 1;
69
+ continue;
70
+ }
71
+ return part;
72
+ }
73
+ return null;
74
+ }
75
+
76
+ function extractMoveCommand(command) {
77
+ const parts = shellSplit(command);
78
+ const moveCommands = new Set([
79
+ "mv", "move", "move-item", "cmd", "cmd.exe", "powershell", "powershell.exe", "pwsh", "pwsh.exe",
80
+ ]);
81
+ const isPython = (part) => /^python(?:\d(?:\.\d+)*)?(?:\.exe)?$/i.test(part.split(/[\\/]/).pop() || part);
82
+ for (let index = 0; index < parts.length; index += 1) {
83
+ if (moveCommands.has(parts[index].toLowerCase())) {
84
+ for (const part of parts.slice(index + 1)) {
85
+ const change = extractChangeFromPath(part);
86
+ if (change) return change;
87
+ }
88
+ }
89
+ if (isPython(parts[index]) && /(?:shutil\.move|os\.rename|\.rename\s*\()/.test(command)) {
90
+ return extractChangeFromPath(command);
91
+ }
92
+ }
93
+ return null;
94
+ }
95
+
96
+ let input = {};
97
+ try {
98
+ input = JSON.parse(fs.readFileSync(inputFile, "utf8") || "{}");
99
+ } catch {}
100
+
101
+ if (input.hook_event_name !== "PreToolUse" || input.tool_name !== "Bash") process.exit(0);
102
+ const command = input.tool_input && typeof input.tool_input.command === "string"
103
+ ? input.tool_input.command
104
+ : "";
105
+ const change = extractArchiveCommand(command) || extractMoveCommand(command);
106
+ if (!change) process.exit(0);
107
+
108
+ const reason =
109
+ "Standalone archival for OpenSpec change " + change + " is blocked. Invoke the " +
110
+ "mate-artifact-finish skill, then run `mate artifact finish \"" + change + "\" --json`. " +
111
+ "The finish command performs the normal OpenSpec archive and completes commit, tag, and push.";
112
+ process.stdout.write(JSON.stringify({
113
+ hookSpecificOutput: {
114
+ hookEventName: "PreToolUse",
115
+ permissionDecision: "deny",
116
+ permissionDecisionReason: reason,
117
+ },
118
+ }));
119
+ ' "$input_file"
120
+ 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
 
@@ -72,7 +72,7 @@ const CLAUDE_MANAGED_HOOK_COMMAND_SUFFIXES = new Set([
72
72
  "/.claude/hooks/validate-artifact-path",
73
73
  "/.claude/hooks/mate-session-banner",
74
74
  "/.claude/hooks/react-doctor.sh",
75
- "/.claude/hooks/mate-openspec-artifact-finish.sh",
75
+ "/.claude/hooks/mate-artifact-finish.sh",
76
76
  ]);
77
77
 
78
78
  export function deriveGraphifyProviders(activeProviders: string[]): string[] {
@@ -1,6 +1,6 @@
1
- import type { CapabilityPlugin, SetupContext } from "../plugin";
1
+ import type { CapabilityPlugin } from "../plugin";
2
2
  import type { InstallRequirement } from "../install-contract";
3
- import { isCommandOnPath, runShellCommand, runShellCommandSilently } from "../utils";
3
+ import { isCommandOnPath, runShellCommand } from "../utils";
4
4
  import { confirm } from "../../../cli/confirm";
5
5
  import { isInstalledViaUvTool } from "../package-managers/uv";
6
6
 
@@ -8,55 +8,16 @@ const HEADROOM_GITIGNORE_ENTRIES = ["# headroom", ".headroom/"];
8
8
 
9
9
  const HEADROOM_INSTALL_CMD = `uv tool install --python ">=3.13" "headroom-ai[all]"`;
10
10
 
11
- const RTK_INSTALL_CMD_BREW = "brew install rtk-ai/tap/rtk";
12
- const RTK_INSTALL_CMD_FALLBACK = "curl -fsSL https://rtk.ai/install | bash";
13
-
14
- function defaultIsRtkOnPath(): boolean {
15
- const pathValue = process.env.PATH ?? "";
16
- return isCommandOnPath("rtk", pathValue);
17
- }
18
-
19
- function defaultIsBrewAvailable(): boolean {
20
- const pathValue = process.env.PATH ?? "";
21
- return isCommandOnPath("brew", pathValue);
22
- }
23
-
24
- const RTK_INIT_COMMANDS: Record<string, string> = {
25
- claude: "rtk init -g --auto-patch",
26
- opencode: "rtk init -g --opencode --auto-patch",
27
- };
28
-
29
- const RTK_UNINSTALL_COMMANDS: Record<string, string> = {
30
- claude: "rtk init -g --uninstall",
31
- opencode: "rtk init -g --uninstall --opencode",
32
- };
33
-
34
11
  type HeadroomDeps = {
35
12
  confirm?: typeof confirm;
36
13
  isCommandOnPath?: typeof isCommandOnPath;
37
14
  isInstalledViaUvTool?: (pkgName: string) => boolean;
38
- isRtkOnPath?: () => boolean;
39
- isBrewAvailable?: () => boolean;
40
- runRtkInstallCmd?: (cmd: string) => Promise<void>;
41
- runShellCommandSilently?: (cmd: string) => Promise<void>;
42
15
  };
43
16
 
44
17
  export function createHeadroomPlugin(deps: HeadroomDeps = {}): CapabilityPlugin {
45
18
  const askConfirm = deps.confirm ?? confirm;
46
19
  const checkPath = deps.isCommandOnPath ?? isCommandOnPath;
47
20
  const checkUvTool = deps.isInstalledViaUvTool ?? isInstalledViaUvTool;
48
- const checkRtkPath = deps.isRtkOnPath ?? defaultIsRtkOnPath;
49
- const checkBrew = deps.isBrewAvailable ?? defaultIsBrewAvailable;
50
- const runRtkInstall = deps.runRtkInstallCmd ?? runShellCommand;
51
- const runSilently = deps.runRtkInstallCmd ?? runShellCommandSilently;
52
-
53
- const hasOtherActiveRtkProvider = (
54
- currentProviderId: string,
55
- activeProviders: string[],
56
- ): boolean =>
57
- Object.keys(RTK_INIT_COMMANDS).some(
58
- (pid) => pid !== currentProviderId && activeProviders.includes(pid),
59
- );
60
21
 
61
22
  return {
62
23
  id: "headroom",
@@ -78,75 +39,19 @@ export function createHeadroomPlugin(deps: HeadroomDeps = {}): CapabilityPlugin
78
39
  install: () => runShellCommand(HEADROOM_INSTALL_CMD),
79
40
  verify: () => checkPath("headroom", process.env.PATH ?? "") || checkUvTool("headroom-ai"),
80
41
  },
81
- {
82
- id: "capability:rtk",
83
- label: "RTK CLI",
84
- group: "companion",
85
- source: "Headroom capability",
86
- command: checkBrew() ? RTK_INSTALL_CMD_BREW : RTK_INSTALL_CMD_FALLBACK,
87
- fingerprint: `rtk:${checkBrew() ? RTK_INSTALL_CMD_BREW : RTK_INSTALL_CMD_FALLBACK}`,
88
- detect: checkRtkPath,
89
- install: () => runRtkInstall(checkBrew() ? RTK_INSTALL_CMD_BREW : RTK_INSTALL_CMD_FALLBACK),
90
- verify: checkRtkPath,
91
- },
92
42
  ],
93
43
  async apply(ctx) {
94
44
  const pathValue = process.env.PATH ?? "";
95
- let isHeadroomInstalled = checkPath("headroom", pathValue) || checkUvTool("headroom-ai");
45
+ const isHeadroomInstalled = checkPath("headroom", pathValue) || checkUvTool("headroom-ai");
96
46
 
97
47
  if (!isHeadroomInstalled && ctx.mode === "setup") {
98
48
  process.stdout.write(`headroom binary not found. To install:\n ${HEADROOM_INSTALL_CMD}\n`);
99
49
  const ok = await askConfirm("Run this install command now?");
100
50
  if (ok) {
101
51
  await runShellCommand(HEADROOM_INSTALL_CMD);
102
- isHeadroomInstalled = true;
103
- }
104
- }
105
-
106
- if (!isHeadroomInstalled) {
107
- return;
108
- }
109
-
110
- let isRtkInstalled = checkRtkPath();
111
- if (!isRtkInstalled && ctx.mode === "setup") {
112
- const useBrew = checkBrew();
113
- const rtkCmd = useBrew ? RTK_INSTALL_CMD_BREW : RTK_INSTALL_CMD_FALLBACK;
114
- process.stdout.write(`rtk binary not found. To install:\n ${rtkCmd}\n`);
115
- const ok = await askConfirm("Run this install command now?");
116
- if (ok) {
117
- await runRtkInstall(rtkCmd);
118
- isRtkInstalled = true;
119
52
  }
120
53
  }
121
54
  },
122
55
  async teardown(_ctx) {},
123
- forProvider: {
124
- claude: {
125
- async apply(ctx: SetupContext) {
126
- if (checkRtkPath()) {
127
- const exec = ctx.mode === "sync" ? runSilently : runRtkInstall;
128
- await exec(RTK_INIT_COMMANDS.claude);
129
- }
130
- },
131
- async teardown(ctx: SetupContext) {
132
- if (checkRtkPath() && !hasOtherActiveRtkProvider("claude", ctx.activeProviders)) {
133
- await runRtkInstall(RTK_UNINSTALL_COMMANDS.claude);
134
- }
135
- },
136
- },
137
- opencode: {
138
- async apply(ctx: SetupContext) {
139
- if (checkRtkPath()) {
140
- const exec = ctx.mode === "sync" ? runSilently : runRtkInstall;
141
- await exec(RTK_INIT_COMMANDS.opencode);
142
- }
143
- },
144
- async teardown(ctx: SetupContext) {
145
- if (checkRtkPath() && !hasOtherActiveRtkProvider("opencode", ctx.activeProviders)) {
146
- await runRtkInstall(RTK_UNINSTALL_COMMANDS.opencode);
147
- }
148
- },
149
- },
150
- },
151
56
  };
152
57
  }
@@ -29,7 +29,8 @@ export const OPENSPEC_SKILLS = [
29
29
 
30
30
  // Mate-authored skills (not generated by the openspec CLI) that ship alongside the
31
31
  // openspec workflow skills and are installed per active provider.
32
- export const MATE_OPENSPEC_SKILLS = ["mate-openspec-artifact-finish"] as const;
32
+ export const MATE_ARTIFACT_SKILLS = ["mate-artifact-finish"] as const;
33
+ const LEGACY_CLAUDE_STATE_PATTERN = /^mate-artifact-finish\..+\.json$/;
33
34
 
34
35
  const MATE_SKILLS_SOURCE = path.join(
35
36
  import.meta.dirname,
@@ -43,7 +44,7 @@ const MATE_V1_SCHEMA_SOURCE = path.join(
43
44
 
44
45
  const CLAUDE_HOOK_SOURCE = path.join(
45
46
  import.meta.dirname,
46
- "../../../templates/capabilities/openspec-cap/claude/hooks/mate-openspec-artifact-finish.sh",
47
+ "../../../templates/capabilities/openspec-cap/claude/hooks/mate-artifact-finish.sh",
47
48
  );
48
49
 
49
50
  const OPENSPEC_TOOL_DIRS = {
@@ -108,7 +109,7 @@ export async function teardownOpenspecSkills(
108
109
  skillsDir: string,
109
110
  companionPath: string,
110
111
  ): Promise<void> {
111
- for (const skill of [...OPENSPEC_SKILLS, ...MATE_OPENSPEC_SKILLS]) {
112
+ for (const skill of [...OPENSPEC_SKILLS, ...MATE_ARTIFACT_SKILLS]) {
112
113
  try {
113
114
  await fs.rm(path.join(skillsDir, skill), { recursive: true, force: true });
114
115
  } catch {
@@ -119,7 +120,7 @@ export async function teardownOpenspecSkills(
119
120
  }
120
121
 
121
122
  async function teardownMateOpenspecSkills(skillsDir: string, companionPath: string): Promise<void> {
122
- for (const skill of MATE_OPENSPEC_SKILLS) {
123
+ for (const skill of MATE_ARTIFACT_SKILLS) {
123
124
  try {
124
125
  await fs.rm(path.join(skillsDir, skill), { recursive: true, force: true });
125
126
  } catch {
@@ -139,7 +140,7 @@ async function applyMateOpenspecSkills(
139
140
  ): Promise<void> {
140
141
  for (const tool of tools) {
141
142
  const skillsDir = getSkillsDir(companionPath, tool);
142
- for (const skill of MATE_OPENSPEC_SKILLS) {
143
+ for (const skill of MATE_ARTIFACT_SKILLS) {
143
144
  await mergeDir(path.join(MATE_SKILLS_SOURCE, skill), path.join(skillsDir, skill));
144
145
  }
145
146
  }
@@ -365,16 +366,28 @@ export function createOpenspecPlugin(deps: OpenSpecPluginDeps = {}): CapabilityP
365
366
  ctx.companionPath,
366
367
  ".claude",
367
368
  "hooks",
368
- "mate-openspec-artifact-finish.sh",
369
+ "mate-artifact-finish.sh",
369
370
  );
370
371
  await fs.mkdir(path.dirname(hookDest), { recursive: true });
371
372
  await fs.copyFile(CLAUDE_HOOK_SOURCE, hookDest);
372
373
  await fs.chmod(hookDest, 0o755);
374
+ const stateDir = path.join(ctx.companionPath, ".claude", "state");
375
+ try {
376
+ const entries = await fs.readdir(stateDir);
377
+ await Promise.all(
378
+ entries
379
+ .filter((entry) => LEGACY_CLAUDE_STATE_PATTERN.test(entry))
380
+ .map((entry) => fs.unlink(path.join(stateDir, entry))),
381
+ );
382
+ await pruneEmptyAncestors(stateDir, ctx.companionPath);
383
+ } catch {
384
+ /* no legacy state */
385
+ }
373
386
  },
374
387
  async teardown(ctx: SetupContext) {
375
388
  try {
376
389
  await fs.unlink(
377
- path.join(ctx.companionPath, ".claude", "hooks", "mate-openspec-artifact-finish.sh"),
390
+ path.join(ctx.companionPath, ".claude", "hooks", "mate-artifact-finish.sh"),
378
391
  );
379
392
  } catch {
380
393
  /* not present */
@@ -0,0 +1,107 @@
1
+ import type { CapabilityPlugin, SetupContext } from "../plugin";
2
+ import type { InstallRequirement } from "../install-contract";
3
+ import { isCommandOnPath, runShellCommand, runShellCommandSilently } from "../utils";
4
+ import { confirm } from "../../../cli/confirm";
5
+
6
+ export const RTK_INSTALL_CMD_BREW = "brew install rtk-ai/tap/rtk";
7
+ export const RTK_INSTALL_CMD_FALLBACK = "curl -fsSL https://rtk.ai/install | bash";
8
+
9
+ const RTK_INIT_COMMANDS: Record<string, string> = {
10
+ claude: "rtk init -g --auto-patch",
11
+ opencode: "rtk init -g --opencode --auto-patch",
12
+ };
13
+
14
+ const RTK_UNINSTALL_COMMANDS: Record<string, string> = {
15
+ claude: "rtk init -g --uninstall",
16
+ opencode: "rtk init -g --uninstall --opencode",
17
+ };
18
+
19
+ function defaultIsRtkOnPath(): boolean {
20
+ return isCommandOnPath("rtk", process.env.PATH ?? "");
21
+ }
22
+
23
+ function defaultIsBrewAvailable(): boolean {
24
+ return isCommandOnPath("brew", process.env.PATH ?? "");
25
+ }
26
+
27
+ export type RtkDeps = {
28
+ confirm?: typeof confirm;
29
+ isRtkOnPath?: () => boolean;
30
+ isBrewAvailable?: () => boolean;
31
+ runRtkInstallCmd?: (cmd: string) => Promise<void>;
32
+ runRtkCommandSilently?: (cmd: string) => Promise<void>;
33
+ };
34
+
35
+ export function createRtkPlugin(deps: RtkDeps = {}): CapabilityPlugin {
36
+ const askConfirm = deps.confirm ?? confirm;
37
+ const checkRtkPath = deps.isRtkOnPath ?? defaultIsRtkOnPath;
38
+ const checkBrew = deps.isBrewAvailable ?? defaultIsBrewAvailable;
39
+ const runRtkInstall = deps.runRtkInstallCmd ?? runShellCommand;
40
+ const runSilently = deps.runRtkCommandSilently ?? runShellCommandSilently;
41
+ const hasOtherActiveRtkProvider = (currentProviderId: string, activeProviders: string[]) =>
42
+ Object.keys(RTK_INIT_COMMANDS).some(
43
+ (providerId) => providerId !== currentProviderId && activeProviders.includes(providerId),
44
+ );
45
+
46
+ return {
47
+ id: "rtk",
48
+ kind: "capability",
49
+ label: "RTK",
50
+ description: "Patch supported agent providers with RTK when selected.",
51
+ defaultSelected: false,
52
+ isEnabled: (config) =>
53
+ (config.capabilities ?? []).some((capability) => capability.name === "rtk"),
54
+ getInstallRequirements: (): InstallRequirement[] => {
55
+ const command = checkBrew() ? RTK_INSTALL_CMD_BREW : RTK_INSTALL_CMD_FALLBACK;
56
+ return [
57
+ {
58
+ id: "capability:rtk",
59
+ label: "RTK CLI",
60
+ group: "companion",
61
+ source: "RTK capability",
62
+ command,
63
+ fingerprint: `rtk:${command}`,
64
+ detect: checkRtkPath,
65
+ install: () => runRtkInstall(command),
66
+ verify: checkRtkPath,
67
+ },
68
+ ];
69
+ },
70
+ async apply(ctx) {
71
+ if (checkRtkPath() || ctx.mode !== "setup") return;
72
+
73
+ const command = checkBrew() ? RTK_INSTALL_CMD_BREW : RTK_INSTALL_CMD_FALLBACK;
74
+ process.stdout.write(`rtk binary not found. To install:\n ${command}\n`);
75
+ if (await askConfirm("Run this install command now?")) {
76
+ await runRtkInstall(command);
77
+ }
78
+ },
79
+ async teardown(_ctx) {},
80
+ forProvider: {
81
+ claude: {
82
+ async apply(ctx: SetupContext) {
83
+ if (!checkRtkPath()) return;
84
+ const run = ctx.mode === "sync" ? runSilently : runRtkInstall;
85
+ await run(RTK_INIT_COMMANDS.claude);
86
+ },
87
+ async teardown(ctx: SetupContext) {
88
+ if (checkRtkPath() && !hasOtherActiveRtkProvider("claude", ctx.activeProviders)) {
89
+ await runRtkInstall(RTK_UNINSTALL_COMMANDS.claude);
90
+ }
91
+ },
92
+ },
93
+ opencode: {
94
+ async apply(ctx: SetupContext) {
95
+ if (!checkRtkPath()) return;
96
+ const run = ctx.mode === "sync" ? runSilently : runRtkInstall;
97
+ await run(RTK_INIT_COMMANDS.opencode);
98
+ },
99
+ async teardown(ctx: SetupContext) {
100
+ if (checkRtkPath() && !hasOtherActiveRtkProvider("opencode", ctx.activeProviders)) {
101
+ await runRtkInstall(RTK_UNINSTALL_COMMANDS.opencode);
102
+ }
103
+ },
104
+ },
105
+ },
106
+ };
107
+ }
@@ -46,7 +46,7 @@ const MANAGED_HOOK_MARKERS = [
46
46
  "validate-artifact-path",
47
47
  "mate-session-banner",
48
48
  "react-doctor.sh",
49
- "mate-openspec-artifact-finish.sh",
49
+ "mate-artifact-finish.sh",
50
50
  "tokensave",
51
51
  ];
52
52
 
@@ -71,12 +71,12 @@ function getCapabilityPermissionEntries(): Record<string, string[]> {
71
71
  "Skill(openspec-propose)",
72
72
  "Skill(openspec-apply-change)",
73
73
  "Skill(openspec-archive-change)",
74
- "Skill(mate-openspec-artifact-finish)",
74
+ "Skill(mate-artifact-finish)",
75
75
  "Bash(openspec:*)",
76
76
  "Bash(mate cap graphify:*)",
77
77
  `Bash(${path.join(wrapperBinPath, "openspec")}:*)`,
78
78
  ],
79
- headroom: ["Bash(rtk:*)"],
79
+ rtk: ["Bash(rtk:*)"],
80
80
  graphify: [
81
81
  "Skill(graphify)",
82
82
  "Bash(graphify:*)",
@@ -267,31 +267,17 @@ function buildManagedClaudeSettings(
267
267
  ...(hooks.SessionStart ?? []),
268
268
  ];
269
269
  if (openspecEnabled) {
270
- hooks.PostToolUse = [
270
+ hooks.PreToolUse = [
271
271
  {
272
272
  matcher: "Bash",
273
273
  hooks: [
274
274
  {
275
275
  type: "command",
276
- command: `sh "${companionPath}/.claude/hooks/mate-openspec-artifact-finish.sh"`,
277
- },
278
- ],
279
- },
280
- ...(hooks.PostToolUse ?? []),
281
- ];
282
- // End-of-turn catch-all: an archive created by anything other than a matched Bash
283
- // command (or as the last action of a turn) still gets a finish nudge on Stop.
284
- hooks.Stop = [
285
- {
286
- hooks: [
287
- {
288
- type: "command",
289
- command: `sh "${companionPath}/.claude/hooks/mate-openspec-artifact-finish.sh"`,
290
- timeout: 10,
276
+ command: `sh "${companionPath}/.claude/hooks/mate-artifact-finish.sh"`,
291
277
  },
292
278
  ],
293
279
  },
294
- ...(hooks.Stop ?? []),
280
+ ...(hooks.PreToolUse ?? []),
295
281
  ];
296
282
  }
297
283
  if (reactDoctorEnabled) {
@@ -121,7 +121,7 @@ function mateFolderReadme(): string {
121
121
  `Edit \`.${n}/config/framework.yaml\` to configure:`,
122
122
  ``,
123
123
  `- **profiles** — per-profile allowed agents list`,
124
- `- **capabilities** — skill and CLI tool capabilities (e.g. react-doctor, openspec, tokensave, headroom)`,
124
+ `- **capabilities** — skill and CLI tool capabilities (e.g. react-doctor, openspec, tokensave, headroom, rtk)`,
125
125
  `- **git** — set to \`auto\` to synchronize the companion before agent launches`,
126
126
  ``,
127
127
  ].join("\n");
@@ -1,236 +0,0 @@
1
- #!/bin/sh
2
- set -u
3
-
4
- input_file=$(mktemp "${TMPDIR:-/tmp}/mate-openspec-artifact-finish.XXXXXX")
5
- trap 'rm -f "$input_file"' EXIT
6
- cat >"$input_file"
7
-
8
- script_dir=$(CDPATH= cd "$(dirname "$0")" && pwd)
9
- companion_path=${MATE_ARTIFACT_PATH:-}
10
- if [ -z "$companion_path" ]; then
11
- companion_path=$(CDPATH= cd "$script_dir/../.." && pwd)
12
- fi
13
-
14
- archive_dir="$companion_path/openspec/changes/archive"
15
- state_dir="$companion_path/.claude/state"
16
-
17
- node -e '
18
- const fs = require("node:fs");
19
- const path = require("node:path");
20
-
21
- const archiveDir = process.argv[1];
22
- const stateDir = process.argv[2];
23
- const inputFile = process.argv[3];
24
- const archiveEntryPattern = /^\d{4}-\d{2}-\d{2}-.+$/;
25
- const archivePathPattern = /(?:^|[\s\/"\x27`,(])openspec\/changes\/archive\/(\d{4}-\d{2}-\d{2}-[^\/\s"\x27`,)]+)/;
26
-
27
- function readArchiveEntries() {
28
- try {
29
- return fs.readdirSync(archiveDir, { withFileTypes: true })
30
- .filter((entry) => entry.isDirectory() && archiveEntryPattern.test(entry.name))
31
- .map((entry) => entry.name)
32
- .sort();
33
- } catch {
34
- return [];
35
- }
36
- }
37
-
38
- function shellSplit(command) {
39
- const parts = [];
40
- const singleQuote = String.fromCharCode(39);
41
- let current = "";
42
- let quote = null;
43
- let escaping = false;
44
- for (let index = 0; index < command.length; index += 1) {
45
- const char = command[index];
46
- if (escaping) {
47
- current += char;
48
- escaping = false;
49
- continue;
50
- }
51
- if (char === "\\" && quote !== singleQuote) {
52
- const next = command[index + 1] || "";
53
- if (next === "\\" || /\s/.test(next) || next === singleQuote || next === String.fromCharCode(34)) {
54
- escaping = true;
55
- continue;
56
- }
57
- }
58
- if ((char === singleQuote || char === String.fromCharCode(34)) && !quote) {
59
- quote = char;
60
- continue;
61
- }
62
- if (quote === char) {
63
- quote = null;
64
- continue;
65
- }
66
- if (!quote && /\s/.test(char)) {
67
- if (current) parts.push(current);
68
- current = "";
69
- continue;
70
- }
71
- current += char;
72
- }
73
- if (current) parts.push(current);
74
- return parts;
75
- }
76
-
77
- function extractChangeFromPath(value) {
78
- const match = value.replace(/\\/g, "/").match(archivePathPattern);
79
- return match ? match[1].replace(/^\d{4}-\d{2}-\d{2}-/, "") : null;
80
- }
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 (part.startsWith("-")) {
92
- if (part === "--store") position += 1;
93
- continue;
94
- }
95
- return part;
96
- }
97
- return null;
98
- }
99
-
100
- function extractMoveCommand(command) {
101
- const parts = shellSplit(command);
102
- const moveCommands = new Set([
103
- "mv", "move", "move-item", "cmd", "cmd.exe", "powershell", "powershell.exe", "pwsh", "pwsh.exe",
104
- ]);
105
- const isPython = (part) => /^python(?:\d(?:\.\d+)*)?(?:\.exe)?$/i.test(part.split(/[\\/]/).pop() || part);
106
- for (let index = 0; index < parts.length; index += 1) {
107
- if (moveCommands.has(parts[index].toLowerCase())) {
108
- for (const part of parts.slice(index + 1)) {
109
- const change = extractChangeFromPath(part);
110
- if (change) return change;
111
- }
112
- }
113
- if (isPython(parts[index]) && /(?:shutil\.move|os\.rename|\.rename\s*\()/.test(command)) {
114
- return extractChangeFromPath(command);
115
- }
116
- }
117
- return null;
118
- }
119
-
120
- function extractFinishCommand(command) {
121
- const parts = shellSplit(command);
122
- return parts.some((part, position) =>
123
- part === "finish" && position > 1 && parts[position - 1] === "artifact" &&
124
- (parts[position - 2] === "mate" || parts[position - 2].endsWith("/mate")));
125
- }
126
-
127
- function commandSucceeded(value) {
128
- if (!value || typeof value !== "object") return true;
129
- const candidates = [value, value.result, value.output, value.tool_output, value.tool_response];
130
- for (const candidate of candidates) {
131
- if (!candidate || typeof candidate !== "object") continue;
132
- if (typeof candidate.success === "boolean") return candidate.success;
133
- for (const key of ["exitCode", "exit_code", "status", "statusCode", "status_code"]) {
134
- const status = candidate[key];
135
- if (typeof status === "number") return status === 0;
136
- if (typeof status === "string" && status.trim()) return Number(status) === 0;
137
- }
138
- }
139
- return true;
140
- }
141
-
142
- function bashCommands(input) {
143
- if (!input || typeof input !== "object") return [];
144
- const toolName = String(input.tool_name || input.toolName || input.tool || "");
145
- if (toolName !== "Bash") return [];
146
- return [
147
- input.command,
148
- input.args?.command,
149
- input.tool_input?.command,
150
- ].filter((command) => typeof command === "string");
151
- }
152
-
153
- function commandArchive(input) {
154
- if (!commandSucceeded(input)) return null;
155
- for (const command of bashCommands(input)) {
156
- const change = extractArchiveCommand(command) || extractMoveCommand(command);
157
- if (change) return change;
158
- }
159
- return null;
160
- }
161
-
162
- // `mate artifact finish` archives as part of its own pipeline; new archive entries it
163
- // creates are already being finished and must not trigger a nudge (its JSON result
164
- // already tells the agent how to resume after a failure).
165
- function commandFinish(input) {
166
- return bashCommands(input).some((command) => extractFinishCommand(command));
167
- }
168
-
169
- let input = {};
170
- try {
171
- input = JSON.parse(fs.readFileSync(inputFile, "utf8") || "{}");
172
- } catch {}
173
-
174
- const sessionId = typeof input.session_id === "string" && input.session_id ? input.session_id : null;
175
- const stateFile = sessionId
176
- ? path.join(stateDir, "mate-openspec-artifact-finish." + sessionId.replace(/[^a-zA-Z0-9_-]/g, "_") + ".json")
177
- : path.join(stateDir, "mate-openspec-artifact-finish.archive-snapshot.json");
178
-
179
- const currentEntries = readArchiveEntries();
180
- let previousEntries;
181
- let validState = false;
182
- try {
183
- const state = JSON.parse(fs.readFileSync(stateFile, "utf8"));
184
- if (state && state.version === 1 && Array.isArray(state.entries) &&
185
- state.entries.every((entry) => typeof entry === "string" && archiveEntryPattern.test(entry))) {
186
- previousEntries = new Set(state.entries);
187
- validState = true;
188
- }
189
- } catch {}
190
-
191
- try {
192
- fs.mkdirSync(path.dirname(stateFile), { recursive: true });
193
- fs.writeFileSync(stateFile, JSON.stringify({ version: 1, entries: currentEntries }) + "\n");
194
- } catch {
195
- process.exit(0);
196
- }
197
-
198
- function nudgeFor(change) {
199
- return (
200
- "An openspec change (" + change + ") was just archived but not finished. Invoke the " +
201
- "mate-openspec-artifact-finish skill now to finish it (invoke the CLI as `mate`, never through a companion-local wrapper path or as `$MATE_COMPANION_BIN_PATH/mate`). " +
202
- "`mate artifact finish` resumes from the already-archived change and completes the commit, dated tag, and push itself — " +
203
- "do not hand-commit, do not hand-tag, and do not re-apply delta specs manually. " +
204
- "Follow the skill guardrails, including confirming with the user in chat before pushing."
205
- );
206
- }
207
-
208
- const eventName = typeof input.hook_event_name === "string" && input.hook_event_name
209
- ? input.hook_event_name
210
- : "PostToolUse";
211
-
212
- function emit(message) {
213
- if (eventName === "Stop") {
214
- // Stop hooks deliver instructions via a block decision, not additionalContext.
215
- process.stdout.write(JSON.stringify({ decision: "block", reason: message }));
216
- } else {
217
- process.stdout.write(JSON.stringify({
218
- hookSpecificOutput: { hookEventName: eventName, additionalContext: message },
219
- }));
220
- }
221
- process.exit(0);
222
- }
223
-
224
- // A finish run archives and finishes in one pipeline: absorb its entries silently.
225
- if (commandFinish(input)) process.exit(0);
226
-
227
- const commandChange = commandArchive(input);
228
- if (commandChange) emit(nudgeFor(commandChange));
229
-
230
- if (!validState) process.exit(0);
231
- const newlyArchived = currentEntries.filter((entry) => !previousEntries.has(entry));
232
- if (newlyArchived.length === 0) process.exit(0);
233
-
234
- emit(newlyArchived.map((entry) => nudgeFor(entry.slice("YYYY-MM-DD-".length))).join("\n\n"));
235
- ' "$archive_dir" "$state_dir" "$input_file"
236
- exit 0