@uniqbit/mate-core 0.15.4-canary.6 → 0.15.4-canary.7
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/claude-plugin/.claude-plugin/plugin.json +4 -0
- package/claude-plugin/hooks/artifact-finish-nudge.mjs +6 -0
- package/claude-plugin/hooks/hooks.json +37 -0
- package/claude-plugin/hooks/session-banner.mjs +6 -0
- package/claude-plugin/hooks/validate-artifact-path.mjs +6 -0
- package/package.json +2 -1
- package/src/cli/main.ts +1 -1
- package/src/hooks/artifact-finish-nudge.ts +212 -0
- package/src/hooks/session-banner.ts +25 -0
- package/src/hooks/validate-artifact-path.ts +236 -0
- package/src/lib/orchestrator/adapters/base.ts +1 -0
- package/src/lib/orchestrator/adapters/claude.ts +16 -0
- package/src/lib/package-paths.ts +36 -0
- package/src/tools/setup/capabilities/openspec.ts +3 -28
- package/src/tools/setup/providers/claude.ts +42 -56
- package/src/templates/capabilities/openspec-cap/claude/hooks/mate-artifact-finish.sh +0 -160
- package/src/templates/providers/claude/.claude/hooks/mate-session-banner +0 -28
- package/src/templates/providers/claude/.claude/hooks/validate-artifact-path +0 -242
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Thin shim: hook logic lives in src/hooks/artifact-finish-nudge.ts
|
|
3
|
+
// (loaded via node's native TypeScript type stripping, engines node >= 24).
|
|
4
|
+
import { run } from "../../src/hooks/artifact-finish-nudge.ts";
|
|
5
|
+
|
|
6
|
+
process.exitCode = await run();
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "Mate hooks — PreToolUse artifact-path guardrail, SessionStart banner, PostToolUse OpenSpec archive-finish nudge (self-gated at runtime)",
|
|
3
|
+
"hooks": {
|
|
4
|
+
"PreToolUse": [
|
|
5
|
+
{
|
|
6
|
+
"matcher": "Write|Edit|MultiEdit|Bash",
|
|
7
|
+
"hooks": [
|
|
8
|
+
{
|
|
9
|
+
"type": "command",
|
|
10
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/validate-artifact-path.mjs\""
|
|
11
|
+
}
|
|
12
|
+
]
|
|
13
|
+
}
|
|
14
|
+
],
|
|
15
|
+
"SessionStart": [
|
|
16
|
+
{
|
|
17
|
+
"hooks": [
|
|
18
|
+
{
|
|
19
|
+
"type": "command",
|
|
20
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session-banner.mjs\""
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
],
|
|
25
|
+
"PostToolUse": [
|
|
26
|
+
{
|
|
27
|
+
"matcher": "Bash",
|
|
28
|
+
"hooks": [
|
|
29
|
+
{
|
|
30
|
+
"type": "command",
|
|
31
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/artifact-finish-nudge.mjs\""
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
}
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Thin shim: hook logic lives in src/hooks/validate-artifact-path.ts
|
|
3
|
+
// (loaded via node's native TypeScript type stripping, engines node >= 24).
|
|
4
|
+
import { run } from "../../src/hooks/validate-artifact-path.ts";
|
|
5
|
+
|
|
6
|
+
process.exitCode = await run();
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniqbit/mate-core",
|
|
3
|
-
"version": "0.15.4-canary.
|
|
3
|
+
"version": "0.15.4-canary.7",
|
|
4
4
|
"description": "Core framework and plugin APIs for Mate.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"files": [
|
|
7
7
|
"src/",
|
|
8
8
|
"wrappers/",
|
|
9
|
+
"claude-plugin/",
|
|
9
10
|
"!src/**/*.test.ts",
|
|
10
11
|
"!src/**/*.test.tsx"
|
|
11
12
|
],
|
package/src/cli/main.ts
CHANGED
|
@@ -128,7 +128,7 @@ export async function main(argv = process.argv, deps: MainDeps = mainDeps): Prom
|
|
|
128
128
|
// Declares into framework.yaml and installs on the spot, so it needs
|
|
129
129
|
// the same gating as `install` — an unambiguous companion, but must
|
|
130
130
|
// stay runnable when installation is otherwise incomplete.
|
|
131
|
-
if (!(await gate({
|
|
131
|
+
if (!(await gate({ updateGuard: true }))) return;
|
|
132
132
|
await runPluginCommand(subcommand, rest);
|
|
133
133
|
return;
|
|
134
134
|
case "artifact":
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// Nudge the agent into the mate-artifact-finish workflow after a Bash command
|
|
2
|
+
// visibly archived an OpenSpec change. Stateless PostToolUse hook: never denies
|
|
3
|
+
// or blocks; emits additionalContext only on apparent success.
|
|
4
|
+
import type { HookEnv } from "./validate-artifact-path";
|
|
5
|
+
|
|
6
|
+
export interface NudgeOutcome {
|
|
7
|
+
exitCode: number;
|
|
8
|
+
stdout: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const ARCHIVE_PATH_PATTERN =
|
|
12
|
+
/(?:^|[\s/"'`,(])openspec\/changes\/archive\/(\d{4}-\d{2}-\d{2}-[^/\s"'`,)]+)/;
|
|
13
|
+
|
|
14
|
+
// The nudge only applies when the openspec capability is enabled and OpenSpec
|
|
15
|
+
// git auto mode is on; the plugin registers the hook unconditionally, so the
|
|
16
|
+
// gate is evaluated here from launch-injected environment before any parsing.
|
|
17
|
+
export function isGateEnabled(env: HookEnv): boolean {
|
|
18
|
+
return env.MATE_OPENSPEC_ENABLED === "1" && env.MATE_GIT_AUTO_MODE === "1";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function shellSplit(command: string): string[] {
|
|
22
|
+
const parts: string[] = [];
|
|
23
|
+
let current = "";
|
|
24
|
+
let quote: string | null = null;
|
|
25
|
+
let escaping = false;
|
|
26
|
+
for (let index = 0; index < command.length; index += 1) {
|
|
27
|
+
const char = command[index];
|
|
28
|
+
if (escaping) {
|
|
29
|
+
current += char;
|
|
30
|
+
escaping = false;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (char === "\\" && quote !== "'") {
|
|
34
|
+
const next = command[index + 1] || "";
|
|
35
|
+
if (next === "\\" || /\s/.test(next) || next === "'" || next === '"') {
|
|
36
|
+
escaping = true;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if ((char === "'" || char === '"') && !quote) {
|
|
41
|
+
quote = char;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (quote === char) {
|
|
45
|
+
quote = null;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const next = command[index + 1] || "";
|
|
49
|
+
const previous = command[index - 1] || "";
|
|
50
|
+
const isControlOperator =
|
|
51
|
+
!quote &&
|
|
52
|
+
(char === ";" ||
|
|
53
|
+
char === "|" ||
|
|
54
|
+
(char === "&" && next !== ">" && previous !== ">" && previous !== "<"));
|
|
55
|
+
if (isControlOperator) {
|
|
56
|
+
if (current) parts.push(current);
|
|
57
|
+
current = "";
|
|
58
|
+
if ((char === "|" || char === "&") && next === char) {
|
|
59
|
+
parts.push(char + next);
|
|
60
|
+
index += 1;
|
|
61
|
+
} else {
|
|
62
|
+
parts.push(char);
|
|
63
|
+
}
|
|
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: string): string | null {
|
|
78
|
+
const match = value.replaceAll("\\", "/").match(ARCHIVE_PATH_PATTERN);
|
|
79
|
+
return match ? match[1].replace(/^\d{4}-\d{2}-\d{2}-/, "") : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Shell syntax tokens are never change-name positionals: separators end the
|
|
83
|
+
// archive invocation, redirections are skipped (a bare operator also consumes
|
|
84
|
+
// its target token).
|
|
85
|
+
const SEPARATOR_PATTERN = /^(\|\|?|&&?|;)$/;
|
|
86
|
+
const REDIRECTION_PATTERN = /^(\d*(>>?|<)|&>>?)/;
|
|
87
|
+
const BARE_REDIRECTION_PATTERN = /^(\d*(>>?|<)|&>>?)$/;
|
|
88
|
+
|
|
89
|
+
export function extractArchiveCommand(command: string): string | null {
|
|
90
|
+
const parts = shellSplit(command);
|
|
91
|
+
const index = parts.findIndex(
|
|
92
|
+
(part, position) =>
|
|
93
|
+
part === "archive" &&
|
|
94
|
+
position > 0 &&
|
|
95
|
+
(parts[position - 1] === "openspec" || parts[position - 1].endsWith("/openspec")),
|
|
96
|
+
);
|
|
97
|
+
if (index < 0) return null;
|
|
98
|
+
for (let position = index + 1; position < parts.length; position += 1) {
|
|
99
|
+
const part = parts[position];
|
|
100
|
+
if (!part || part === "--") continue;
|
|
101
|
+
if (SEPARATOR_PATTERN.test(part)) return null;
|
|
102
|
+
if (REDIRECTION_PATTERN.test(part)) {
|
|
103
|
+
if (BARE_REDIRECTION_PATTERN.test(part)) position += 1;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (part.startsWith("-")) {
|
|
107
|
+
if (part === "--store") position += 1;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
return part;
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const MOVE_COMMANDS = new Set([
|
|
116
|
+
"mv",
|
|
117
|
+
"move",
|
|
118
|
+
"move-item",
|
|
119
|
+
"cmd",
|
|
120
|
+
"cmd.exe",
|
|
121
|
+
"powershell",
|
|
122
|
+
"powershell.exe",
|
|
123
|
+
"pwsh",
|
|
124
|
+
"pwsh.exe",
|
|
125
|
+
]);
|
|
126
|
+
|
|
127
|
+
function isPython(part: string): boolean {
|
|
128
|
+
return /^python(?:\d(?:\.\d+)*)?(?:\.exe)?$/i.test(part.split(/[\\/]/).pop() || part);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function extractMoveCommand(command: string): string | null {
|
|
132
|
+
const parts = shellSplit(command);
|
|
133
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
134
|
+
if (MOVE_COMMANDS.has(parts[index].toLowerCase())) {
|
|
135
|
+
for (const part of parts.slice(index + 1)) {
|
|
136
|
+
const change = extractChangeFromPath(part);
|
|
137
|
+
if (change) return change;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (isPython(parts[index]) && /(?:shutil\.move|os\.rename|\.rename\s*\()/.test(command)) {
|
|
141
|
+
return extractChangeFromPath(command);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Nudge only on apparent success: stay silent on a clear failure indication,
|
|
148
|
+
// nudge when success is indicated or indeterminate.
|
|
149
|
+
export function isClearFailure(response: unknown): boolean {
|
|
150
|
+
if (!response || typeof response !== "object") return false;
|
|
151
|
+
const value = response as Record<string, unknown>;
|
|
152
|
+
if (value.success === false || value.is_error === true) return true;
|
|
153
|
+
if (typeof value.error === "string" && value.error) return true;
|
|
154
|
+
if (value.interrupted === true) return true;
|
|
155
|
+
const exitCode = value.exit_code ?? value.exitCode;
|
|
156
|
+
return typeof exitCode === "number" && exitCode !== 0;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function evaluate(payload: unknown, env: HookEnv): NudgeOutcome {
|
|
160
|
+
const silent: NudgeOutcome = { exitCode: 0, stdout: "" };
|
|
161
|
+
if (!isGateEnabled(env)) return silent;
|
|
162
|
+
|
|
163
|
+
const input = payload && typeof payload === "object" ? (payload as Record<string, unknown>) : {};
|
|
164
|
+
if (input.hook_event_name !== "PostToolUse" || input.tool_name !== "Bash") return silent;
|
|
165
|
+
|
|
166
|
+
const toolInput =
|
|
167
|
+
input.tool_input && typeof input.tool_input === "object"
|
|
168
|
+
? (input.tool_input as Record<string, unknown>)
|
|
169
|
+
: {};
|
|
170
|
+
const command = typeof toolInput.command === "string" ? toolInput.command : "";
|
|
171
|
+
const change = extractArchiveCommand(command) || extractMoveCommand(command);
|
|
172
|
+
if (!change) return silent;
|
|
173
|
+
if (isClearFailure(input.tool_response)) return silent;
|
|
174
|
+
|
|
175
|
+
const context =
|
|
176
|
+
`OpenSpec change ${change} was just archived. Invoke the mate-artifact-finish ` +
|
|
177
|
+
`skill, then run \`mate artifact finish "${change}" --json\` to complete the ` +
|
|
178
|
+
"finish workflow (commit, tag, and push).";
|
|
179
|
+
return {
|
|
180
|
+
exitCode: 0,
|
|
181
|
+
stdout: JSON.stringify({
|
|
182
|
+
hookSpecificOutput: {
|
|
183
|
+
hookEventName: "PostToolUse",
|
|
184
|
+
additionalContext: context,
|
|
185
|
+
},
|
|
186
|
+
}),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function readStdin(): Promise<string> {
|
|
191
|
+
const chunks: Buffer[] = [];
|
|
192
|
+
for await (const chunk of process.stdin) {
|
|
193
|
+
chunks.push(Buffer.from(chunk));
|
|
194
|
+
}
|
|
195
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Plugin-shim entry. The gate check precedes stdin parsing so a disabled gate
|
|
199
|
+
// costs one env read.
|
|
200
|
+
export async function run(): Promise<number> {
|
|
201
|
+
if (!isGateEnabled(process.env)) return 0;
|
|
202
|
+
|
|
203
|
+
let payload: unknown = {};
|
|
204
|
+
try {
|
|
205
|
+
payload = JSON.parse(await readStdin());
|
|
206
|
+
} catch {
|
|
207
|
+
payload = {};
|
|
208
|
+
}
|
|
209
|
+
const outcome = evaluate(payload, process.env);
|
|
210
|
+
if (outcome.stdout) process.stdout.write(outcome.stdout);
|
|
211
|
+
return outcome.exitCode;
|
|
212
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Show the active Mate companion paths in Claude Code.
|
|
2
|
+
import type { HookEnv } from "./validate-artifact-path";
|
|
3
|
+
|
|
4
|
+
export interface BannerOutcome {
|
|
5
|
+
exitCode: number;
|
|
6
|
+
stdout: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Fail-soft outside managed sessions: no MATE_* env, no banner.
|
|
10
|
+
export function buildBanner(env: HookEnv): BannerOutcome {
|
|
11
|
+
const repoPath = env.MATE_REPO_PATH;
|
|
12
|
+
const artifactPath = env.MATE_ARTIFACT_PATH;
|
|
13
|
+
if (!repoPath || !artifactPath) return { exitCode: 0, stdout: "" };
|
|
14
|
+
|
|
15
|
+
const mateVersion = env.MATE_VERSION || "unknown";
|
|
16
|
+
const message = `mate v${mateVersion}\n repo: ${repoPath}\n mate: ${artifactPath}`;
|
|
17
|
+
return { exitCode: 0, stdout: JSON.stringify({ systemMessage: message }) + "\n" };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Plugin-shim entry.
|
|
21
|
+
export function run(): number {
|
|
22
|
+
const outcome = buildBanner(process.env);
|
|
23
|
+
if (outcome.stdout) process.stdout.write(outcome.stdout);
|
|
24
|
+
return outcome.exitCode;
|
|
25
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// Block artifact writes outside the companion framework path.
|
|
2
|
+
//
|
|
3
|
+
// Artifact-like files may be written in the working repository only when the
|
|
4
|
+
// target path is already ignored by git. That keeps local-only scratch files
|
|
5
|
+
// possible without weakening the default repo split.
|
|
6
|
+
//
|
|
7
|
+
// Editing a file that already exists is always allowed: the guard exists to
|
|
8
|
+
// stop new agent artifacts from landing in the working repo, not to freeze
|
|
9
|
+
// files that are already part of it.
|
|
10
|
+
import { spawnSync } from "node:child_process";
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
|
|
15
|
+
export type HookEnv = Record<string, string | undefined>;
|
|
16
|
+
|
|
17
|
+
export interface HookOutcome {
|
|
18
|
+
exitCode: number;
|
|
19
|
+
stderr: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const KNOWN_ARTIFACT_BASENAMES = new Set([
|
|
23
|
+
"CLAUDE.md",
|
|
24
|
+
"CONTEXT.md",
|
|
25
|
+
"design.md",
|
|
26
|
+
"explore-brief.md",
|
|
27
|
+
"proposal.md",
|
|
28
|
+
"spec.md",
|
|
29
|
+
"tasks.md",
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
const ARTIFACT_PATH_MARKERS = [
|
|
33
|
+
"/changes/",
|
|
34
|
+
"/openspec/",
|
|
35
|
+
"/specs/",
|
|
36
|
+
"/docs/adr/",
|
|
37
|
+
"/docs/adrs/",
|
|
38
|
+
"/docs/decisions/",
|
|
39
|
+
"/docs/prd/",
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const MD_REDIRECT_PATTERN = />{1,2}\s*([^\s;|&<>]+\.md)\b/g;
|
|
43
|
+
const MD_TEE_PATTERN = /\btee\s+(?:-a\s+)?([^\s;|&<>]+\.md)\b/g;
|
|
44
|
+
|
|
45
|
+
export function artifactLikePath(filePath: string): boolean {
|
|
46
|
+
if (!filePath) return false;
|
|
47
|
+
|
|
48
|
+
const basename = path.basename(filePath);
|
|
49
|
+
const normalized = "/" + filePath.replaceAll("\\", "/").replace(/^\/+/, "");
|
|
50
|
+
if (KNOWN_ARTIFACT_BASENAMES.has(basename)) return true;
|
|
51
|
+
|
|
52
|
+
if (ARTIFACT_PATH_MARKERS.some((marker) => normalized.includes(marker))) return true;
|
|
53
|
+
|
|
54
|
+
if (basename.endsWith(".md") && basename !== "README.md") return true;
|
|
55
|
+
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function repoRoot(env: HookEnv): string {
|
|
60
|
+
return env.MATE_REPO_PATH || process.cwd();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function companionRoot(env: HookEnv): string | null {
|
|
64
|
+
const companion = env.MATE_ARTIFACT_PATH;
|
|
65
|
+
return companion ? path.normalize(companion) : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Claude Code's own config/state directory (plan files, settings, todos).
|
|
69
|
+
// Writes there are agent-runtime state, never Mate artifacts.
|
|
70
|
+
function claudeConfigRoot(env: HookEnv): string {
|
|
71
|
+
const configured = env.CLAUDE_CONFIG_DIR;
|
|
72
|
+
if (configured) return path.normalize(configured);
|
|
73
|
+
return path.normalize(path.join(env.HOME || os.homedir(), ".claude"));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function normalizePath(filePath: string, env: HookEnv): string {
|
|
77
|
+
if (path.isAbsolute(filePath)) return path.normalize(filePath);
|
|
78
|
+
return path.normalize(path.join(repoRoot(env), filePath));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isUnder(target: string, root: string): boolean {
|
|
82
|
+
const relative = path.relative(path.normalize(root), path.normalize(target));
|
|
83
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isGitignored(target: string, env: HookEnv): boolean {
|
|
87
|
+
const root = repoRoot(env);
|
|
88
|
+
if (!isUnder(target, root)) return false;
|
|
89
|
+
|
|
90
|
+
const relPath = path.relative(root, target);
|
|
91
|
+
const result = spawnSync("git", ["-C", root, "check-ignore", "--no-index", "-q", "--", relPath], {
|
|
92
|
+
stdio: "ignore",
|
|
93
|
+
});
|
|
94
|
+
return result.status === 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// A file already tracked in the working repo is product code being edited,
|
|
98
|
+
// not a new agent artifact (e.g. packaged SKILL.md/spec.md template sources).
|
|
99
|
+
function isGitTracked(target: string, env: HookEnv): boolean {
|
|
100
|
+
const root = repoRoot(env);
|
|
101
|
+
if (!isUnder(target, root)) return false;
|
|
102
|
+
|
|
103
|
+
const relPath = path.relative(root, target);
|
|
104
|
+
const result = spawnSync("git", ["-C", root, "ls-files", "--error-unmatch", "--", relPath], {
|
|
105
|
+
stdio: "ignore",
|
|
106
|
+
});
|
|
107
|
+
return result.status === 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isProductDocumentationPath(target: string, env: HookEnv): boolean {
|
|
111
|
+
const root = repoRoot(env);
|
|
112
|
+
if (!isUnder(target, root)) return false;
|
|
113
|
+
|
|
114
|
+
const relPath = path.relative(root, target);
|
|
115
|
+
const parts = relPath.split(path.sep).filter(Boolean);
|
|
116
|
+
for (const [index, part] of parts.entries()) {
|
|
117
|
+
if (part === ".storybook" || part === "storybook") return true;
|
|
118
|
+
|
|
119
|
+
if (part !== "docs") continue;
|
|
120
|
+
|
|
121
|
+
const docsRoot = path.join(root, ...parts.slice(0, index + 1));
|
|
122
|
+
if (fs.existsSync(path.join(docsRoot, "package.json"))) return true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function blockWrite(target: string, companion: string): HookOutcome {
|
|
129
|
+
const lines = [
|
|
130
|
+
"Mate guardrail: artifact writes must go to the companion framework path.",
|
|
131
|
+
` target: ${target}`,
|
|
132
|
+
` companion: ${companion}`,
|
|
133
|
+
"Use an absolute path under MATE_ARTIFACT_PATH instead.",
|
|
134
|
+
];
|
|
135
|
+
if (path.basename(target) === "CLAUDE.md") {
|
|
136
|
+
lines.push(`Note: CLAUDE.md already lives in the companion repo at ${companion}/CLAUDE.md.`);
|
|
137
|
+
}
|
|
138
|
+
return { exitCode: 2, stderr: lines.join("\n") + "\n" };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function checkFilePath(
|
|
142
|
+
filePath: string,
|
|
143
|
+
companion: string | null,
|
|
144
|
+
env: HookEnv,
|
|
145
|
+
allowExisting = false,
|
|
146
|
+
): HookOutcome | null {
|
|
147
|
+
if (!filePath || !companion) return null;
|
|
148
|
+
|
|
149
|
+
const normalized = normalizePath(filePath, env);
|
|
150
|
+
if (normalized.startsWith(companion)) return null;
|
|
151
|
+
|
|
152
|
+
if (isUnder(normalized, claudeConfigRoot(env))) return null;
|
|
153
|
+
|
|
154
|
+
if (allowExisting && fs.existsSync(normalized) && fs.statSync(normalized).isFile()) return null;
|
|
155
|
+
|
|
156
|
+
if (isProductDocumentationPath(normalized, env)) return null;
|
|
157
|
+
|
|
158
|
+
if (!artifactLikePath(filePath)) return null;
|
|
159
|
+
|
|
160
|
+
if (
|
|
161
|
+
isUnder(normalized, repoRoot(env)) &&
|
|
162
|
+
(isGitignored(normalized, env) || isGitTracked(normalized, env))
|
|
163
|
+
) {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return blockWrite(filePath, companion);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function commandMatches(command: string): string[] {
|
|
171
|
+
const targets: string[] = [];
|
|
172
|
+
for (const pattern of [MD_REDIRECT_PATTERN, MD_TEE_PATTERN]) {
|
|
173
|
+
const matcher = new RegExp(pattern.source, pattern.flags);
|
|
174
|
+
for (const match of command.matchAll(matcher)) {
|
|
175
|
+
const target = match[1].trim().replace(/^["']+|["']+$/g, "");
|
|
176
|
+
if (target) targets.push(target);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return targets;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function evaluate(payload: unknown, env: HookEnv): HookOutcome {
|
|
183
|
+
const allow: HookOutcome = { exitCode: 0, stderr: "" };
|
|
184
|
+
|
|
185
|
+
const companion = companionRoot(env);
|
|
186
|
+
if (!companion) return allow;
|
|
187
|
+
|
|
188
|
+
const input = payload && typeof payload === "object" ? (payload as Record<string, unknown>) : {};
|
|
189
|
+
const toolName = typeof input.tool_name === "string" ? input.tool_name : "";
|
|
190
|
+
const toolInput =
|
|
191
|
+
input.tool_input && typeof input.tool_input === "object"
|
|
192
|
+
? (input.tool_input as Record<string, unknown>)
|
|
193
|
+
: {};
|
|
194
|
+
|
|
195
|
+
if (toolName === "Write" || toolName === "Edit" || toolName === "MultiEdit") {
|
|
196
|
+
const outcome = checkFilePath(
|
|
197
|
+
String(toolInput.file_path ?? ""),
|
|
198
|
+
companion,
|
|
199
|
+
env,
|
|
200
|
+
toolName === "Edit" || toolName === "MultiEdit",
|
|
201
|
+
);
|
|
202
|
+
return outcome ?? allow;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (toolName === "Bash") {
|
|
206
|
+
const command = String(toolInput.command ?? "");
|
|
207
|
+
for (const target of commandMatches(command)) {
|
|
208
|
+
const outcome = checkFilePath(target, companion, env);
|
|
209
|
+
if (outcome) return outcome;
|
|
210
|
+
}
|
|
211
|
+
return allow;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return allow;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function readStdin(): Promise<string> {
|
|
218
|
+
const chunks: Buffer[] = [];
|
|
219
|
+
for await (const chunk of process.stdin) {
|
|
220
|
+
chunks.push(Buffer.from(chunk));
|
|
221
|
+
}
|
|
222
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Plugin-shim entry: read the hook payload from stdin, evaluate, report.
|
|
226
|
+
export async function run(): Promise<number> {
|
|
227
|
+
let payload: unknown = {};
|
|
228
|
+
try {
|
|
229
|
+
payload = JSON.parse(await readStdin());
|
|
230
|
+
} catch {
|
|
231
|
+
payload = {};
|
|
232
|
+
}
|
|
233
|
+
const outcome = evaluate(payload, process.env);
|
|
234
|
+
if (outcome.stderr) process.stderr.write(outcome.stderr);
|
|
235
|
+
return outcome.exitCode;
|
|
236
|
+
}
|
|
@@ -83,6 +83,7 @@ export abstract class LaunchAdapter {
|
|
|
83
83
|
MATE_WRAPPER_BIN_PATH: wrapperBinPath,
|
|
84
84
|
PATH: prependPathEntry(process.env.PATH, wrapperBinPath),
|
|
85
85
|
MATE_GRAPHIFY_ENABLED: context.capabilities.some((c) => c.name === "graphify") ? "1" : "0",
|
|
86
|
+
MATE_OPENSPEC_ENABLED: context.capabilities.some((c) => c.name === "openspec") ? "1" : "0",
|
|
86
87
|
MATE_REACT_DOCTOR_ENABLED: reactDoctorEnabled ? "1" : "0",
|
|
87
88
|
MATE_GIT_AUTO_MODE: context.git === "auto" ? "1" : "0",
|
|
88
89
|
MATE_REPO_ID: context.repository.id,
|
|
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
|
|
|
2
2
|
|
|
3
3
|
import { buildCompanionGuidance } from "../../../playbooks/companion-guidance";
|
|
4
4
|
import { getContextModePackageRoot, validateContextModePackage } from "../../context-mode-package";
|
|
5
|
+
import { getClaudePluginRoot, validateClaudePluginAssets } from "../../package-paths";
|
|
5
6
|
import {
|
|
6
7
|
getCompanionClaudeMcpConfigPath,
|
|
7
8
|
getCompanionClaudeSettingsPath,
|
|
@@ -13,6 +14,17 @@ export class ClaudeAdapter extends LaunchAdapter {
|
|
|
13
14
|
readonly interactive = true;
|
|
14
15
|
|
|
15
16
|
async validateLaunch(context: AdapterContext): Promise<void> {
|
|
17
|
+
// The bundled mate plugin carries the artifact-path guard; never launch a
|
|
18
|
+
// managed session without it.
|
|
19
|
+
try {
|
|
20
|
+
validateClaudePluginAssets();
|
|
21
|
+
} catch (error) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
`Mate Claude plugin is unavailable: ${(error as Error).message}. Reinstall mate to repair it.`,
|
|
24
|
+
{ cause: error },
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
16
28
|
if (context.capabilities.some((capability) => capability.name === "context-mode")) {
|
|
17
29
|
try {
|
|
18
30
|
await validateContextModePackage(context.companionPath);
|
|
@@ -56,6 +68,10 @@ export class ClaudeAdapter extends LaunchAdapter {
|
|
|
56
68
|
buildCompanionGuidance(context),
|
|
57
69
|
...settingsArgs,
|
|
58
70
|
...mcpConfigArgs,
|
|
71
|
+
// Bundled mate plugin (hooks) resolved from the running mate-core
|
|
72
|
+
// installation, coexisting with the context-mode plugin dir.
|
|
73
|
+
"--plugin-dir",
|
|
74
|
+
getClaudePluginRoot(),
|
|
59
75
|
...contextModeArgs,
|
|
60
76
|
...args,
|
|
61
77
|
];
|
package/src/lib/package-paths.ts
CHANGED
|
@@ -18,6 +18,42 @@ export function getWrapperBinPath(): string {
|
|
|
18
18
|
return path.resolve(import.meta.dirname, "../../wrappers/bin");
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Resolved bundled Claude plugin root: a distribution asset root that ships
|
|
23
|
+
* `claude-plugin/` wins over core's bundled default. Loaded per managed
|
|
24
|
+
* launch via `claude --plugin-dir`, so hooks always match the installed
|
|
25
|
+
* mate-core version.
|
|
26
|
+
*/
|
|
27
|
+
export function getClaudePluginRoot(): string {
|
|
28
|
+
for (const root of getActiveDistribution().config.assetRoots ?? []) {
|
|
29
|
+
const candidate = path.join(root, "claude-plugin");
|
|
30
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
31
|
+
}
|
|
32
|
+
return path.resolve(import.meta.dirname, "../../claude-plugin");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const CLAUDE_PLUGIN_HOOK_SHIMS = [
|
|
36
|
+
"validate-artifact-path.mjs",
|
|
37
|
+
"session-banner.mjs",
|
|
38
|
+
"artifact-finish-nudge.mjs",
|
|
39
|
+
] as const;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Verify the bundled Claude plugin assets exist. Throws naming the missing
|
|
43
|
+
* assets; managed launches must not start without the artifact-path guard.
|
|
44
|
+
*/
|
|
45
|
+
export function validateClaudePluginAssets(pluginRoot = getClaudePluginRoot()): void {
|
|
46
|
+
const expected = [
|
|
47
|
+
path.join(".claude-plugin", "plugin.json"),
|
|
48
|
+
path.join("hooks", "hooks.json"),
|
|
49
|
+
...CLAUDE_PLUGIN_HOOK_SHIMS.map((shim) => path.join("hooks", shim)),
|
|
50
|
+
];
|
|
51
|
+
const missing = expected.filter((asset) => !fs.existsSync(path.join(pluginRoot, asset)));
|
|
52
|
+
if (missing.length > 0) {
|
|
53
|
+
throw new Error(`bundled Claude plugin at ${pluginRoot} is missing: ${missing.join(", ")}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
21
57
|
export function getReactDoctorBinPath(): string {
|
|
22
58
|
try {
|
|
23
59
|
const entryPath = require.resolve("react-doctor");
|
|
@@ -39,11 +39,6 @@ const MATE_V1_SCHEMA_SOURCE = path.join(
|
|
|
39
39
|
"../../../templates/capabilities/openspec-cap/mate-v1",
|
|
40
40
|
);
|
|
41
41
|
|
|
42
|
-
const CLAUDE_HOOK_SOURCE = path.join(
|
|
43
|
-
import.meta.dirname,
|
|
44
|
-
"../../../templates/capabilities/openspec-cap/claude/hooks/mate-artifact-finish.sh",
|
|
45
|
-
);
|
|
46
|
-
|
|
47
42
|
const OPENSPEC_TOOL_DIRS = {
|
|
48
43
|
claude: ".claude",
|
|
49
44
|
opencode: ".opencode",
|
|
@@ -347,19 +342,10 @@ export function createOpenspecPlugin(deps: OpenSpecPluginDeps = {}): CapabilityP
|
|
|
347
342
|
},
|
|
348
343
|
forProvider: {
|
|
349
344
|
claude: {
|
|
345
|
+
// The archive-finish nudge hook ships in the bundled mate Claude
|
|
346
|
+
// plugin and self-gates at runtime; openspec no longer contributes a
|
|
347
|
+
// Claude hook file. Only legacy per-session nudge state is migrated.
|
|
350
348
|
async apply(ctx: SetupContext) {
|
|
351
|
-
if (ctx.config.git !== "auto") {
|
|
352
|
-
return;
|
|
353
|
-
}
|
|
354
|
-
const hookDest = path.join(
|
|
355
|
-
ctx.companionPath,
|
|
356
|
-
".claude",
|
|
357
|
-
"hooks",
|
|
358
|
-
"mate-artifact-finish.sh",
|
|
359
|
-
);
|
|
360
|
-
await fs.mkdir(path.dirname(hookDest), { recursive: true });
|
|
361
|
-
await fs.copyFile(CLAUDE_HOOK_SOURCE, hookDest);
|
|
362
|
-
await fs.chmod(hookDest, 0o755);
|
|
363
349
|
const stateDir = path.join(ctx.companionPath, ".claude", "state");
|
|
364
350
|
try {
|
|
365
351
|
const entries = await fs.readdir(stateDir);
|
|
@@ -374,17 +360,6 @@ export function createOpenspecPlugin(deps: OpenSpecPluginDeps = {}): CapabilityP
|
|
|
374
360
|
}
|
|
375
361
|
},
|
|
376
362
|
async teardown(ctx: SetupContext) {
|
|
377
|
-
try {
|
|
378
|
-
await fs.unlink(
|
|
379
|
-
path.join(ctx.companionPath, ".claude", "hooks", "mate-artifact-finish.sh"),
|
|
380
|
-
);
|
|
381
|
-
} catch {
|
|
382
|
-
/* not present */
|
|
383
|
-
}
|
|
384
|
-
await pruneEmptyAncestors(
|
|
385
|
-
path.join(ctx.companionPath, ".claude", "hooks"),
|
|
386
|
-
ctx.companionPath,
|
|
387
|
-
);
|
|
388
363
|
await teardownToolRuntime(ctx.companionPath, "claude");
|
|
389
364
|
},
|
|
390
365
|
},
|
|
@@ -10,8 +10,8 @@ import { refreshFromTemplate, stripGuidanceBlock } from "../plugins/guidance";
|
|
|
10
10
|
import { TOKENSAVE_WORKING_REPO_EXCLUDE_ENTRIES } from "../capabilities/tokensave-shared";
|
|
11
11
|
import type { McpServerDescriptor, ProviderPlugin, SetupContext } from "../plugin";
|
|
12
12
|
import { resolveGitInfoExcludePath } from "../git-utils";
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
13
|
+
import { pruneEmptyAncestors, resolveCommandOnPath } from "../utils";
|
|
14
|
+
import { getSetupRootTemplates } from "./utils";
|
|
15
15
|
|
|
16
16
|
async function configureClaudeGuidance(companionPath: string): Promise<void> {
|
|
17
17
|
const rootTemplates = getSetupRootTemplates();
|
|
@@ -43,6 +43,10 @@ const ALL_MANAGED_EXCLUDE_ENTRIES = new Set([
|
|
|
43
43
|
]);
|
|
44
44
|
|
|
45
45
|
// Command substrings that mark a working-repo hook group as Mate-managed.
|
|
46
|
+
// The mate plugin hooks (validate-artifact-path, mate-session-banner,
|
|
47
|
+
// mate-artifact-finish.sh) now ship in the bundled Claude plugin; their
|
|
48
|
+
// markers are retained migration-only so stale managed groups written by
|
|
49
|
+
// earlier releases keep being stripped, and are never re-added.
|
|
46
50
|
const MANAGED_HOOK_MARKERS = [
|
|
47
51
|
"validate-artifact-path",
|
|
48
52
|
"mate-session-banner",
|
|
@@ -51,6 +55,26 @@ const MANAGED_HOOK_MARKERS = [
|
|
|
51
55
|
"tokensave",
|
|
52
56
|
];
|
|
53
57
|
|
|
58
|
+
// Hook files earlier releases copied into the companion; the bundled Claude
|
|
59
|
+
// plugin replaced them. Setup and launch sync delete stale copies.
|
|
60
|
+
const LEGACY_MATE_HOOK_FILES = [
|
|
61
|
+
"validate-artifact-path",
|
|
62
|
+
"mate-session-banner",
|
|
63
|
+
"mate-artifact-finish.sh",
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
async function removeLegacyMateHookFiles(companionPath: string): Promise<void> {
|
|
67
|
+
const hooksDir = path.join(companionPath, ".claude", "hooks");
|
|
68
|
+
for (const name of LEGACY_MATE_HOOK_FILES) {
|
|
69
|
+
try {
|
|
70
|
+
await fs.unlink(path.join(hooksDir, name));
|
|
71
|
+
} catch {
|
|
72
|
+
/* not present */
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
await pruneEmptyAncestors(hooksDir, companionPath);
|
|
76
|
+
}
|
|
77
|
+
|
|
54
78
|
// Base `permissions.allow` entries that Claude gets for Mate-managed workflows.
|
|
55
79
|
// Read/Edit are scoped to the companion path so routine reads and artifact
|
|
56
80
|
// writes of skills, specs, and change artifacts don't prompt for approval on
|
|
@@ -260,39 +284,13 @@ function buildManagedClaudeSettings(
|
|
|
260
284
|
): WorkingRepoSettings {
|
|
261
285
|
const capabilities = config.capabilities ?? [];
|
|
262
286
|
const enabledNames = new Set(capabilities.map((c) => c.name));
|
|
263
|
-
const openspecEnabled = enabledNames.has("openspec") && config.git === "auto";
|
|
264
287
|
const reactDoctorEnabled = enabledNames.has("react-doctor");
|
|
265
288
|
|
|
289
|
+
// Mate's own hooks (artifact-path guard, session banner, archive-finish
|
|
290
|
+
// nudge) ship in the bundled Claude plugin loaded at launch; settings-sync
|
|
291
|
+
// only strips their legacy managed groups (via removeManagedHookGroups) and
|
|
292
|
+
// reconciles the capability hooks that remain settings-delivered.
|
|
266
293
|
const hooks = removeManagedHookGroups(existing).hooks ?? {};
|
|
267
|
-
hooks.PreToolUse = [
|
|
268
|
-
{
|
|
269
|
-
matcher: "Write|Edit|MultiEdit|Bash",
|
|
270
|
-
hooks: [
|
|
271
|
-
{ type: "command", command: `${companionPath}/.claude/hooks/validate-artifact-path` },
|
|
272
|
-
],
|
|
273
|
-
},
|
|
274
|
-
...(hooks.PreToolUse ?? []),
|
|
275
|
-
];
|
|
276
|
-
hooks.SessionStart = [
|
|
277
|
-
{
|
|
278
|
-
hooks: [{ type: "command", command: `${companionPath}/.claude/hooks/mate-session-banner` }],
|
|
279
|
-
},
|
|
280
|
-
...(hooks.SessionStart ?? []),
|
|
281
|
-
];
|
|
282
|
-
if (openspecEnabled) {
|
|
283
|
-
hooks.PostToolUse = [
|
|
284
|
-
{
|
|
285
|
-
matcher: "Bash",
|
|
286
|
-
hooks: [
|
|
287
|
-
{
|
|
288
|
-
type: "command",
|
|
289
|
-
command: `sh "${companionPath}/.claude/hooks/mate-artifact-finish.sh"`,
|
|
290
|
-
},
|
|
291
|
-
],
|
|
292
|
-
},
|
|
293
|
-
...(hooks.PostToolUse ?? []),
|
|
294
|
-
];
|
|
295
|
-
}
|
|
296
294
|
if (reactDoctorEnabled) {
|
|
297
295
|
// Record edits cheaply, then scan once when the edited turn finishes.
|
|
298
296
|
hooks.PostToolUse = [
|
|
@@ -377,6 +375,9 @@ function buildManagedClaudeSettings(
|
|
|
377
375
|
delete mcpServers.tokensave;
|
|
378
376
|
|
|
379
377
|
const settings: WorkingRepoSettings = { ...existing, hooks, autoMemoryEnabled: false };
|
|
378
|
+
if (Object.keys(hooks).length === 0) {
|
|
379
|
+
delete settings.hooks;
|
|
380
|
+
}
|
|
380
381
|
if (Object.keys(permissions).length > 0) {
|
|
381
382
|
settings.permissions = permissions;
|
|
382
383
|
} else {
|
|
@@ -512,8 +513,11 @@ async function syncWorkingRepoClaudeAdditionalDirectories(
|
|
|
512
513
|
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
|
|
513
514
|
}
|
|
514
515
|
|
|
515
|
-
async function configureClaude(
|
|
516
|
-
|
|
516
|
+
async function configureClaude(companionPath: string): Promise<void> {
|
|
517
|
+
// Mate hooks are delivered by the bundled Claude plugin at launch; nothing
|
|
518
|
+
// is copied into `companion/.claude/hooks/` anymore. Stale copies from
|
|
519
|
+
// earlier releases are stripped so only the plugin-shipped hooks run.
|
|
520
|
+
await removeLegacyMateHookFiles(companionPath);
|
|
517
521
|
|
|
518
522
|
// Note: `.claude/settings.local.json` is now Mate-owned and generated by
|
|
519
523
|
// `syncCompanionClaudeSettings`; do not delete it here. `settings.json` is
|
|
@@ -524,16 +528,6 @@ async function configureClaude(src: string, companionPath: string): Promise<void
|
|
|
524
528
|
/* not present */
|
|
525
529
|
}
|
|
526
530
|
|
|
527
|
-
const hooksDir = path.join(companionPath, ".claude", "hooks");
|
|
528
|
-
try {
|
|
529
|
-
const entries = await fs.readdir(hooksDir);
|
|
530
|
-
for (const entry of entries) {
|
|
531
|
-
await fs.chmod(path.join(hooksDir, entry), 0o755);
|
|
532
|
-
}
|
|
533
|
-
} catch {
|
|
534
|
-
// hooks dir may not exist if no hooks are defined
|
|
535
|
-
}
|
|
536
|
-
|
|
537
531
|
const agentsMdSrc = path.join(getSetupRootTemplates(), "TEMPLATE_AGENTS.md");
|
|
538
532
|
const agentsMdDest = path.join(companionPath, "AGENTS.md");
|
|
539
533
|
try {
|
|
@@ -552,17 +546,9 @@ async function configureClaude(src: string, companionPath: string): Promise<void
|
|
|
552
546
|
}
|
|
553
547
|
|
|
554
548
|
async function teardownClaude(companionPath: string, allowedAgents: string[]): Promise<void> {
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
/* not present */
|
|
559
|
-
}
|
|
560
|
-
try {
|
|
561
|
-
await fs.unlink(path.join(companionPath, ".claude", "hooks", "mate-session-banner"));
|
|
562
|
-
} catch {
|
|
563
|
-
/* not present */
|
|
564
|
-
}
|
|
565
|
-
await pruneEmptyAncestors(path.join(companionPath, ".claude", "hooks"), companionPath);
|
|
549
|
+
// Migration-only: a companion last synced by a pre-plugin release may still
|
|
550
|
+
// carry copied mate hook files.
|
|
551
|
+
await removeLegacyMateHookFiles(companionPath);
|
|
566
552
|
try {
|
|
567
553
|
await fs.unlink(path.join(companionPath, ".claude", "settings.local.json"));
|
|
568
554
|
} catch {
|
|
@@ -693,7 +679,7 @@ export function createClaudePlugin(): ProviderPlugin {
|
|
|
693
679
|
},
|
|
694
680
|
},
|
|
695
681
|
async apply(ctx) {
|
|
696
|
-
await configureClaude(
|
|
682
|
+
await configureClaude(ctx.companionPath);
|
|
697
683
|
await configureClaudeGuidance(ctx.companionPath);
|
|
698
684
|
await teardownLegacyClaudeBin(ctx.companionPath);
|
|
699
685
|
await syncCompanionClaudeSettings(ctx.companionPath, ctx.config);
|
|
@@ -1,160 +0,0 @@
|
|
|
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,28 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Show the active Mate companion paths in Claude Code."""
|
|
3
|
-
|
|
4
|
-
from __future__ import annotations
|
|
5
|
-
|
|
6
|
-
import json
|
|
7
|
-
import os
|
|
8
|
-
import sys
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def main() -> int:
|
|
12
|
-
repo_path = os.environ.get("MATE_REPO_PATH")
|
|
13
|
-
artifact_path = os.environ.get("MATE_ARTIFACT_PATH")
|
|
14
|
-
if not repo_path or not artifact_path:
|
|
15
|
-
return 0
|
|
16
|
-
|
|
17
|
-
mate_version = os.environ.get("MATE_VERSION", "unknown")
|
|
18
|
-
message = (
|
|
19
|
-
f"mate v{mate_version}\n"
|
|
20
|
-
f" repo: {repo_path}\n"
|
|
21
|
-
f" mate: {artifact_path}"
|
|
22
|
-
)
|
|
23
|
-
print(json.dumps({"systemMessage": message}))
|
|
24
|
-
return 0
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
if __name__ == "__main__":
|
|
28
|
-
raise SystemExit(main())
|
|
@@ -1,242 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Block artifact writes outside the companion framework path.
|
|
3
|
-
|
|
4
|
-
Artifact-like files may be written in the working repository only when the
|
|
5
|
-
target path is already ignored by git. That keeps local-only scratch files
|
|
6
|
-
possible without weakening the default repo split.
|
|
7
|
-
|
|
8
|
-
Editing a file that already exists is always allowed: the guard exists to
|
|
9
|
-
stop new agent artifacts from landing in the working repo, not to freeze
|
|
10
|
-
files that are already part of it.
|
|
11
|
-
"""
|
|
12
|
-
|
|
13
|
-
from __future__ import annotations
|
|
14
|
-
|
|
15
|
-
import json
|
|
16
|
-
import os
|
|
17
|
-
import pathlib
|
|
18
|
-
import re
|
|
19
|
-
import subprocess
|
|
20
|
-
import sys
|
|
21
|
-
from typing import Any
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
KNOWN_ARTIFACT_BASENAMES = {
|
|
25
|
-
"CLAUDE.md",
|
|
26
|
-
"CONTEXT.md",
|
|
27
|
-
"design.md",
|
|
28
|
-
"explore-brief.md",
|
|
29
|
-
"proposal.md",
|
|
30
|
-
"spec.md",
|
|
31
|
-
"tasks.md",
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
ARTIFACT_PATH_MARKERS = (
|
|
35
|
-
"/changes/",
|
|
36
|
-
"/openspec/",
|
|
37
|
-
"/specs/",
|
|
38
|
-
"/docs/adr/",
|
|
39
|
-
"/docs/adrs/",
|
|
40
|
-
"/docs/decisions/",
|
|
41
|
-
"/docs/prd/",
|
|
42
|
-
)
|
|
43
|
-
MD_REDIRECT_RE = re.compile(r">{1,2}\s*([^\s;|&<>]+\.md)\b")
|
|
44
|
-
MD_TEE_RE = re.compile(r"\btee\s+(?:-a\s+)?([^\s;|&<>]+\.md)\b")
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def read_payload() -> dict[str, Any]:
|
|
48
|
-
try:
|
|
49
|
-
return json.load(sys.stdin)
|
|
50
|
-
except json.JSONDecodeError:
|
|
51
|
-
return {}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def artifact_like_path(file_path: str) -> bool:
|
|
55
|
-
if not file_path:
|
|
56
|
-
return False
|
|
57
|
-
|
|
58
|
-
basename = os.path.basename(file_path)
|
|
59
|
-
normalized = "/" + file_path.replace("\\", "/").lstrip("/")
|
|
60
|
-
if basename in KNOWN_ARTIFACT_BASENAMES:
|
|
61
|
-
return True
|
|
62
|
-
|
|
63
|
-
if any(marker in normalized for marker in ARTIFACT_PATH_MARKERS):
|
|
64
|
-
return True
|
|
65
|
-
|
|
66
|
-
if basename.endswith(".md") and basename != "README.md":
|
|
67
|
-
return True
|
|
68
|
-
|
|
69
|
-
return False
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
def repo_root() -> str:
|
|
73
|
-
return os.environ.get("MATE_REPO_PATH") or os.getcwd()
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def companion_root() -> str | None:
|
|
77
|
-
companion = os.environ.get("MATE_ARTIFACT_PATH")
|
|
78
|
-
return os.path.normpath(companion) if companion else None
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
def claude_config_root() -> str:
|
|
82
|
-
"""Claude Code's own config/state directory (plan files, settings, todos).
|
|
83
|
-
Writes there are agent-runtime state, never Mate artifacts."""
|
|
84
|
-
configured = os.environ.get("CLAUDE_CONFIG_DIR")
|
|
85
|
-
if configured:
|
|
86
|
-
return os.path.normpath(configured)
|
|
87
|
-
return os.path.normpath(os.path.join(os.path.expanduser("~"), ".claude"))
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
def normalize_path(file_path: str) -> str:
|
|
91
|
-
if os.path.isabs(file_path):
|
|
92
|
-
return os.path.normpath(file_path)
|
|
93
|
-
return os.path.normpath(os.path.join(repo_root(), file_path))
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
def is_under(path: str, root: str) -> bool:
|
|
97
|
-
try:
|
|
98
|
-
return os.path.commonpath([os.path.normpath(path), os.path.normpath(root)]) == os.path.normpath(root)
|
|
99
|
-
except ValueError:
|
|
100
|
-
return False
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
def is_gitignored(path: str) -> bool:
|
|
104
|
-
root = repo_root()
|
|
105
|
-
if not is_under(path, root):
|
|
106
|
-
return False
|
|
107
|
-
|
|
108
|
-
rel_path = os.path.relpath(path, root)
|
|
109
|
-
try:
|
|
110
|
-
result = subprocess.run(
|
|
111
|
-
["git", "-C", root, "check-ignore", "--no-index", "-q", "--", rel_path],
|
|
112
|
-
stdout=subprocess.DEVNULL,
|
|
113
|
-
stderr=subprocess.DEVNULL,
|
|
114
|
-
check=False,
|
|
115
|
-
)
|
|
116
|
-
except FileNotFoundError:
|
|
117
|
-
return False
|
|
118
|
-
return result.returncode == 0
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
def is_git_tracked(path: str) -> bool:
|
|
122
|
-
"""A file already tracked in the working repo is product code being edited,
|
|
123
|
-
not a new agent artifact (e.g. packaged SKILL.md/spec.md template sources)."""
|
|
124
|
-
root = repo_root()
|
|
125
|
-
if not is_under(path, root):
|
|
126
|
-
return False
|
|
127
|
-
|
|
128
|
-
rel_path = os.path.relpath(path, root)
|
|
129
|
-
try:
|
|
130
|
-
result = subprocess.run(
|
|
131
|
-
["git", "-C", root, "ls-files", "--error-unmatch", "--", rel_path],
|
|
132
|
-
stdout=subprocess.DEVNULL,
|
|
133
|
-
stderr=subprocess.DEVNULL,
|
|
134
|
-
check=False,
|
|
135
|
-
)
|
|
136
|
-
except FileNotFoundError:
|
|
137
|
-
return False
|
|
138
|
-
return result.returncode == 0
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
def is_product_documentation_path(path: str) -> bool:
|
|
142
|
-
root = repo_root()
|
|
143
|
-
if not is_under(path, root):
|
|
144
|
-
return False
|
|
145
|
-
|
|
146
|
-
rel_path = os.path.relpath(path, root)
|
|
147
|
-
parts = pathlib.Path(rel_path).parts
|
|
148
|
-
for index, part in enumerate(parts):
|
|
149
|
-
if part in {".storybook", "storybook"}:
|
|
150
|
-
return True
|
|
151
|
-
|
|
152
|
-
if part != "docs":
|
|
153
|
-
continue
|
|
154
|
-
|
|
155
|
-
docs_root = os.path.join(root, *parts[: index + 1])
|
|
156
|
-
if os.path.exists(os.path.join(docs_root, "package.json")):
|
|
157
|
-
return True
|
|
158
|
-
|
|
159
|
-
return False
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
def block_write(target: str, companion: str) -> None:
|
|
163
|
-
print("Mate guardrail: artifact writes must go to the companion framework path.", file=sys.stderr)
|
|
164
|
-
print(f" target: {target}", file=sys.stderr)
|
|
165
|
-
print(f" companion: {companion}", file=sys.stderr)
|
|
166
|
-
print("Use an absolute path under MATE_ARTIFACT_PATH instead.", file=sys.stderr)
|
|
167
|
-
|
|
168
|
-
if os.path.basename(target) == "CLAUDE.md":
|
|
169
|
-
print(
|
|
170
|
-
f"Note: CLAUDE.md already lives in the companion repo at {companion}/CLAUDE.md.",
|
|
171
|
-
file=sys.stderr,
|
|
172
|
-
)
|
|
173
|
-
|
|
174
|
-
raise SystemExit(2)
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
def check_file_path(file_path: str, companion: str | None, allow_existing: bool = False) -> None:
|
|
178
|
-
if not file_path or not companion:
|
|
179
|
-
return
|
|
180
|
-
|
|
181
|
-
normalized = normalize_path(file_path)
|
|
182
|
-
if normalized.startswith(companion):
|
|
183
|
-
return
|
|
184
|
-
|
|
185
|
-
if is_under(normalized, claude_config_root()):
|
|
186
|
-
return
|
|
187
|
-
|
|
188
|
-
if allow_existing and os.path.isfile(normalized):
|
|
189
|
-
return
|
|
190
|
-
|
|
191
|
-
if is_product_documentation_path(normalized):
|
|
192
|
-
return
|
|
193
|
-
|
|
194
|
-
if not artifact_like_path(file_path):
|
|
195
|
-
return
|
|
196
|
-
|
|
197
|
-
if is_under(normalized, repo_root()) and (
|
|
198
|
-
is_gitignored(normalized) or is_git_tracked(normalized)
|
|
199
|
-
):
|
|
200
|
-
return
|
|
201
|
-
|
|
202
|
-
block_write(file_path, companion)
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
def command_matches(command: str) -> list[str]:
|
|
206
|
-
targets: list[str] = []
|
|
207
|
-
for pattern in (MD_REDIRECT_RE, MD_TEE_RE):
|
|
208
|
-
for match in pattern.finditer(command):
|
|
209
|
-
target = match.group(1).strip().strip('"\'')
|
|
210
|
-
if target:
|
|
211
|
-
targets.append(target)
|
|
212
|
-
return targets
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
def main() -> int:
|
|
216
|
-
companion = companion_root()
|
|
217
|
-
if not companion:
|
|
218
|
-
return 0
|
|
219
|
-
|
|
220
|
-
payload = read_payload()
|
|
221
|
-
tool_name = payload.get("tool_name", "")
|
|
222
|
-
tool_input = payload.get("tool_input", {})
|
|
223
|
-
|
|
224
|
-
if tool_name in {"Write", "Edit", "MultiEdit"}:
|
|
225
|
-
check_file_path(
|
|
226
|
-
str(tool_input.get("file_path", "")),
|
|
227
|
-
companion,
|
|
228
|
-
allow_existing=tool_name in {"Edit", "MultiEdit"},
|
|
229
|
-
)
|
|
230
|
-
return 0
|
|
231
|
-
|
|
232
|
-
if tool_name == "Bash":
|
|
233
|
-
command = str(tool_input.get("command", ""))
|
|
234
|
-
for target in command_matches(command):
|
|
235
|
-
check_file_path(target, companion)
|
|
236
|
-
return 0
|
|
237
|
-
|
|
238
|
-
return 0
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
if __name__ == "__main__":
|
|
242
|
-
raise SystemExit(main())
|