@uniqbit/mate-core 0.15.0 → 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 +1 -1
- package/src/cli/commands/doctor.ts +4 -4
- package/src/cli/commands/report/index.ts +5 -3
- package/src/lib/orchestrator/config-store.ts +22 -2
- 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 +1 -0
- package/src/templates/capabilities/openspec-cap/claude/hooks/mate-artifact-finish.sh +120 -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/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/providers/claude.ts +6 -20
- 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);
|
|
@@ -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
|
}
|
|
@@ -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,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-
|
|
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
|
|
|
@@ -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-
|
|
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
|
|
1
|
+
import type { CapabilityPlugin } from "../plugin";
|
|
2
2
|
import type { InstallRequirement } from "../install-contract";
|
|
3
|
-
import { isCommandOnPath, runShellCommand
|
|
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
|
-
|
|
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
|
|
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-
|
|
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, ...
|
|
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
|
|
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
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
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.
|
|
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-
|
|
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.
|
|
280
|
+
...(hooks.PreToolUse ?? []),
|
|
295
281
|
];
|
|
296
282
|
}
|
|
297
283
|
if (reactDoctorEnabled) {
|
package/src/tools/setup.ts
CHANGED
|
@@ -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");
|
package/src/templates/capabilities/openspec-cap/claude/hooks/mate-openspec-artifact-finish.sh
DELETED
|
@@ -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
|