@uniqbit/mate-core 0.15.4-canary.6 → 0.15.4-canary.8
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/commands/install.ts +8 -4
- package/src/cli/commands/plugin/install.ts +3 -3
- package/src/cli/main.ts +26 -11
- 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.8",
|
|
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
|
],
|
|
@@ -56,10 +56,14 @@ export async function runInstallCommand(argv: string[], cwd = process.cwd()): Pr
|
|
|
56
56
|
process.exitCode = 1;
|
|
57
57
|
return false;
|
|
58
58
|
}
|
|
59
|
-
//
|
|
60
|
-
// before the plan is built, so one run goes install → load →
|
|
61
|
-
// own install requirements are part of the plan.
|
|
62
|
-
if (
|
|
59
|
+
// Root-declared plugins (companion or hub) install first and hydrate into
|
|
60
|
+
// the registry before the plan is built, so one run goes install → load →
|
|
61
|
+
// plan and their own install requirements are part of the plan.
|
|
62
|
+
if (
|
|
63
|
+
(context.kind === "companion" || context.kind === "hub") &&
|
|
64
|
+
context.companionPath &&
|
|
65
|
+
context.config.plugins?.length
|
|
66
|
+
) {
|
|
63
67
|
reportPluginInstallResults(
|
|
64
68
|
await installDeclaredPlugins(context.companionPath, context.config.plugins),
|
|
65
69
|
);
|
|
@@ -37,7 +37,7 @@ export interface PluginInstallCommandDeps {
|
|
|
37
37
|
|
|
38
38
|
/**
|
|
39
39
|
* @command mate plugin install <package>[@version]
|
|
40
|
-
* @description Declares `<package>` in the companion's `framework.yaml`
|
|
40
|
+
* @description Declares `<package>` in the companion's (or hub's) `framework.yaml`
|
|
41
41
|
* `plugins:` list (adding it, or updating its version if already declared)
|
|
42
42
|
* and installs it immediately into the shared workspace, the same way
|
|
43
43
|
* `mate install` would on its next run.
|
|
@@ -62,9 +62,9 @@ export async function runPluginInstallCommand(
|
|
|
62
62
|
process.exitCode = 1;
|
|
63
63
|
return false;
|
|
64
64
|
}
|
|
65
|
-
if (context.kind !== "companion" || !context.companionPath) {
|
|
65
|
+
if ((context.kind !== "companion" && context.kind !== "hub") || !context.companionPath) {
|
|
66
66
|
process.stderr.write(
|
|
67
|
-
`${FRAMEWORK_NAME}: \`plugin install\` requires a companion context; run it from inside a linked working repository.\n`,
|
|
67
|
+
`${FRAMEWORK_NAME}: \`plugin install\` requires a companion or hub context; run it from inside a linked working repository or a hub root.\n`,
|
|
68
68
|
);
|
|
69
69
|
process.exitCode = 1;
|
|
70
70
|
return false;
|
package/src/cli/main.ts
CHANGED
|
@@ -30,6 +30,8 @@ export interface GateNeeds {
|
|
|
30
30
|
updateGuard?: boolean;
|
|
31
31
|
/** Block the command when the current directory resolves to a hub root. */
|
|
32
32
|
notHubRoot?: boolean;
|
|
33
|
+
/** Block the command when the current directory resolves to a companion root. */
|
|
34
|
+
notCompanionRoot?: boolean;
|
|
33
35
|
/** Require an unambiguous companion (selection wizard on ambiguity) before dispatch. */
|
|
34
36
|
companion?: boolean;
|
|
35
37
|
/** Require a complete installation (install preflight) before dispatch. */
|
|
@@ -91,12 +93,22 @@ export async function main(argv = process.argv, deps: MainDeps = mainDeps): Prom
|
|
|
91
93
|
await showUpdateBannerIfAvailable(updateStore);
|
|
92
94
|
}
|
|
93
95
|
|
|
94
|
-
if (needs.notHubRoot
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
96
|
+
if (needs.notHubRoot || needs.notCompanionRoot) {
|
|
97
|
+
const kind = (await deps.resolveRootContext()).kind;
|
|
98
|
+
if (needs.notHubRoot && kind === "hub") {
|
|
99
|
+
console.error(
|
|
100
|
+
`${FRAMEWORK_NAME}: this directory resolves to a companion hub root; companion commands are not available here. Use \`${FRAMEWORK_NAME} hub ...\` to manage the hub.`,
|
|
101
|
+
);
|
|
102
|
+
process.exitCode = 1;
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
if (needs.notCompanionRoot && kind === "companion") {
|
|
106
|
+
console.error(
|
|
107
|
+
`${FRAMEWORK_NAME}: this directory resolves to a companion root; hub commands are not available here. Run them from a hub root.`,
|
|
108
|
+
);
|
|
109
|
+
process.exitCode = 1;
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
100
112
|
}
|
|
101
113
|
|
|
102
114
|
if (needs.companion && !(await deps.ensureUnambiguousCompanion())) {
|
|
@@ -126,8 +138,9 @@ export async function main(argv = process.argv, deps: MainDeps = mainDeps): Prom
|
|
|
126
138
|
return;
|
|
127
139
|
case "plugin":
|
|
128
140
|
// Declares into framework.yaml and installs on the spot, so it needs
|
|
129
|
-
// the same gating as `install` — an unambiguous companion
|
|
130
|
-
// stay runnable when installation is otherwise
|
|
141
|
+
// the same gating as `install` — an unambiguous companion (hubs pass
|
|
142
|
+
// trivially), but must stay runnable when installation is otherwise
|
|
143
|
+
// incomplete.
|
|
131
144
|
if (!(await gate({ companion: true }))) return;
|
|
132
145
|
await runPluginCommand(subcommand, rest);
|
|
133
146
|
return;
|
|
@@ -144,9 +157,10 @@ export async function main(argv = process.argv, deps: MainDeps = mainDeps): Prom
|
|
|
144
157
|
if (!(await gate({ notHubRoot: true }))) return;
|
|
145
158
|
break;
|
|
146
159
|
// Hub commands establish and operate on a local hub root directly;
|
|
147
|
-
// they must not require a linked working repository or installation
|
|
160
|
+
// they must not require a linked working repository or installation —
|
|
161
|
+
// but companions are never hubs.
|
|
148
162
|
case "hub":
|
|
149
|
-
if (!(await gate({ updateGuard: true }))) return;
|
|
163
|
+
if (!(await gate({ updateGuard: true, notCompanionRoot: true }))) return;
|
|
150
164
|
break;
|
|
151
165
|
// open/tui consume a companion context.
|
|
152
166
|
case "open":
|
|
@@ -160,7 +174,8 @@ export async function main(argv = process.argv, deps: MainDeps = mainDeps): Prom
|
|
|
160
174
|
await runCompanionCommand(subcommand, rest);
|
|
161
175
|
return;
|
|
162
176
|
case "hub":
|
|
163
|
-
|
|
177
|
+
// Hubs are never companions: hub commands are blocked in companion roots.
|
|
178
|
+
if (!(await gate({ updateGuard: true, notCompanionRoot: true }))) return;
|
|
164
179
|
await runHubCommand(subcommand ? [subcommand, ...rest] : []);
|
|
165
180
|
return;
|
|
166
181
|
case "claude":
|
|
@@ -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,
|