@uniqbit/mate-opencode-plugin 0.15.0-canary.0 → 0.15.0-canary.10
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 +3 -3
- package/src/companion.ts +2 -2
- package/src/server.ts +1 -1
- package/src/tui.tsx +1 -73
- package/src/companion-hooks.ts +0 -370
- package/src/companion-policy.ts +0 -177
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniqbit/mate-opencode-plugin",
|
|
3
|
-
"version": "0.15.0-canary.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.15.0-canary.10",
|
|
4
|
+
"description": "OpenCode plugin integration for Mate.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"files": [
|
|
7
7
|
"src/",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"@opentui/core": "^0.4.5",
|
|
29
29
|
"@opentui/keymap": "^0.4.5",
|
|
30
30
|
"@opentui/solid": "^0.4.5",
|
|
31
|
-
"@uniqbit/mate-core": "0.15.0-canary.
|
|
31
|
+
"@uniqbit/mate-core": "0.15.0-canary.10"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
34
|
"@types/bun": "^1.3.14"
|
package/src/companion.ts
CHANGED
|
@@ -2,9 +2,9 @@ import path from "node:path";
|
|
|
2
2
|
|
|
3
3
|
import type { Plugin } from "@opencode-ai/plugin";
|
|
4
4
|
import { tool } from "@opencode-ai/plugin";
|
|
5
|
-
import { MATE_ENV, parseGuidanceContent, type MateGuidanceFile } from "@uniqbit/mate-core";
|
|
5
|
+
import { MATE_ENV, parseGuidanceContent, type MateGuidanceFile } from "@uniqbit/mate-core/runtime";
|
|
6
6
|
|
|
7
|
-
import { readContext, type CompanionContext } from "
|
|
7
|
+
import { readContext, type CompanionContext } from "@uniqbit/mate-core/opencode";
|
|
8
8
|
|
|
9
9
|
function prependPathEntry(pathValue: string | undefined, entry: string): string {
|
|
10
10
|
const entries = (pathValue ?? "").split(path.delimiter).filter(Boolean);
|
package/src/server.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import type { Hooks, Plugin, PluginInput } from "@opencode-ai/plugin";
|
|
3
3
|
|
|
4
4
|
import { AddDirPlugin } from "./add-dir";
|
|
5
|
-
import { CompanionHooksPlugin } from "
|
|
5
|
+
import { CompanionHooksPlugin } from "@uniqbit/mate-core/opencode";
|
|
6
6
|
import { CompanionPlugin } from "./companion";
|
|
7
7
|
|
|
8
8
|
// Compose the implementation modules in the execution order the previously
|
package/src/tui.tsx
CHANGED
|
@@ -1,73 +1 @@
|
|
|
1
|
-
|
|
2
|
-
/* oxlint-disable react/no-unknown-property, react/react-in-jsx-scope */
|
|
3
|
-
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui";
|
|
4
|
-
|
|
5
|
-
import { readContext } from "./companion-policy";
|
|
6
|
-
|
|
7
|
-
const MIDNIGHT_PURPLE_BRIGHT = "#c084fc";
|
|
8
|
-
const NARROW_TERMINAL_WIDTH = 80;
|
|
9
|
-
const MATE_VERSION = process.env.MATE_VERSION ?? "unknown";
|
|
10
|
-
|
|
11
|
-
function SessionContext({
|
|
12
|
-
api,
|
|
13
|
-
compact = false,
|
|
14
|
-
sidebar = false,
|
|
15
|
-
}: {
|
|
16
|
-
api: TuiPluginApi;
|
|
17
|
-
compact?: boolean;
|
|
18
|
-
sidebar?: boolean;
|
|
19
|
-
}) {
|
|
20
|
-
const context = readContext(process.env.MATE_ARTIFACT_PATH ?? "");
|
|
21
|
-
const theme = api.theme.current;
|
|
22
|
-
|
|
23
|
-
if (compact) {
|
|
24
|
-
return (
|
|
25
|
-
<box width="100%" paddingTop={0} flexShrink={0}>
|
|
26
|
-
<text fg={theme.textMuted}>
|
|
27
|
-
mate v{MATE_VERSION} | repo: {context.repositoryPath} | mate: {context.companionPath}
|
|
28
|
-
</text>
|
|
29
|
-
</box>
|
|
30
|
-
);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
return (
|
|
34
|
-
<box width="100%" maxWidth={75} paddingTop={sidebar ? 0 : 2} paddingBottom={1} flexShrink={0}>
|
|
35
|
-
<text fg={MIDNIGHT_PURPLE_BRIGHT}>mate v{MATE_VERSION}</text>
|
|
36
|
-
<text fg={theme.textMuted}>repo: {context.repositoryPath}</text>
|
|
37
|
-
<text fg={theme.textMuted}>mate: {context.companionPath}</text>
|
|
38
|
-
</box>
|
|
39
|
-
);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const tui: TuiPlugin = async (api) => {
|
|
43
|
-
const context = readContext(process.env.MATE_ARTIFACT_PATH ?? "");
|
|
44
|
-
if (!context.companionPath || !context.repositoryPath) {
|
|
45
|
-
return;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
api.slots.register({
|
|
49
|
-
order: 0,
|
|
50
|
-
slots: {
|
|
51
|
-
home_bottom() {
|
|
52
|
-
return <SessionContext api={api} />;
|
|
53
|
-
},
|
|
54
|
-
sidebar_content() {
|
|
55
|
-
return <SessionContext api={api} sidebar />;
|
|
56
|
-
},
|
|
57
|
-
app_bottom() {
|
|
58
|
-
if (api.renderer.width >= NARROW_TERMINAL_WIDTH || api.route.current.name !== "session") {
|
|
59
|
-
return null;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
return <SessionContext api={api} compact />;
|
|
63
|
-
},
|
|
64
|
-
},
|
|
65
|
-
});
|
|
66
|
-
};
|
|
67
|
-
|
|
68
|
-
const plugin: TuiPluginModule & { id: string } = {
|
|
69
|
-
id: "mate-companion-tui",
|
|
70
|
-
tui,
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
export default plugin;
|
|
1
|
+
export { tuiPlugin as default } from "@uniqbit/mate-core/opencode";
|
package/src/companion-hooks.ts
DELETED
|
@@ -1,370 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
|
|
4
|
-
import type { Hooks, Plugin, PluginInput } from "@opencode-ai/plugin";
|
|
5
|
-
|
|
6
|
-
import {
|
|
7
|
-
buildArtifactError,
|
|
8
|
-
extractPatchPaths,
|
|
9
|
-
readContext,
|
|
10
|
-
shouldBlockArtifactWrite,
|
|
11
|
-
type CompanionContext,
|
|
12
|
-
} from "./companion-policy";
|
|
13
|
-
|
|
14
|
-
const ARCHIVE_ENTRY_PATTERN = /^\d{4}-\d{2}-\d{2}-.+$/;
|
|
15
|
-
const ARCHIVE_PATH_PATTERN =
|
|
16
|
-
/(?:^|[\s/"'`,(])openspec\/changes\/archive\/(\d{4}-\d{2}-\d{2}-[^/\s"'`,)]+)/;
|
|
17
|
-
|
|
18
|
-
function readArchiveEntries(archiveDir: string): Set<string> {
|
|
19
|
-
try {
|
|
20
|
-
return new Set(
|
|
21
|
-
fs
|
|
22
|
-
.readdirSync(archiveDir, { withFileTypes: true })
|
|
23
|
-
.filter((entry) => entry.isDirectory() && ARCHIVE_ENTRY_PATTERN.test(entry.name))
|
|
24
|
-
.map((entry) => entry.name),
|
|
25
|
-
);
|
|
26
|
-
} catch {
|
|
27
|
-
return new Set();
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function shellSplit(command: string): string[] {
|
|
32
|
-
const parts: string[] = [];
|
|
33
|
-
let current = "";
|
|
34
|
-
let quote: "'" | '"' | null = null;
|
|
35
|
-
let escaping = false;
|
|
36
|
-
|
|
37
|
-
for (let index = 0; index < command.length; index += 1) {
|
|
38
|
-
const char = command[index];
|
|
39
|
-
if (escaping) {
|
|
40
|
-
current += char;
|
|
41
|
-
escaping = false;
|
|
42
|
-
continue;
|
|
43
|
-
}
|
|
44
|
-
if (char === "\\" && quote !== "'") {
|
|
45
|
-
const next = command[index + 1] ?? "";
|
|
46
|
-
if (/^[\\\s'"]$/.test(next)) {
|
|
47
|
-
escaping = true;
|
|
48
|
-
continue;
|
|
49
|
-
}
|
|
50
|
-
current += char;
|
|
51
|
-
continue;
|
|
52
|
-
}
|
|
53
|
-
if ((char === "'" || char === '"') && !quote) {
|
|
54
|
-
quote = char;
|
|
55
|
-
continue;
|
|
56
|
-
}
|
|
57
|
-
if (quote === char) {
|
|
58
|
-
quote = null;
|
|
59
|
-
continue;
|
|
60
|
-
}
|
|
61
|
-
if (!quote && /\s/.test(char)) {
|
|
62
|
-
if (current) {
|
|
63
|
-
parts.push(current);
|
|
64
|
-
current = "";
|
|
65
|
-
}
|
|
66
|
-
continue;
|
|
67
|
-
}
|
|
68
|
-
current += char;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
if (current) parts.push(current);
|
|
72
|
-
return parts;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function extractArchivedChangeFromPath(value: string): string | null {
|
|
76
|
-
const normalized = value.replace(/\\/g, "/");
|
|
77
|
-
const match = normalized.match(ARCHIVE_PATH_PATTERN);
|
|
78
|
-
return match ? match[1].replace(/^\d{4}-\d{2}-\d{2}-/, "") : null;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function extractArchiveCommandChange(command: string): string | null {
|
|
82
|
-
const parts = shellSplit(command);
|
|
83
|
-
const archiveIndex = parts.findIndex(
|
|
84
|
-
(part, index) =>
|
|
85
|
-
part === "archive" &&
|
|
86
|
-
index > 0 &&
|
|
87
|
-
(parts[index - 1] === "openspec" || parts[index - 1].endsWith("/openspec")),
|
|
88
|
-
);
|
|
89
|
-
if (archiveIndex < 0) return null;
|
|
90
|
-
|
|
91
|
-
for (let index = archiveIndex + 1; index < parts.length; index += 1) {
|
|
92
|
-
const part = parts[index];
|
|
93
|
-
if (!part || part === "--") continue;
|
|
94
|
-
if (part.startsWith("-")) {
|
|
95
|
-
if (part === "--store") index += 1;
|
|
96
|
-
continue;
|
|
97
|
-
}
|
|
98
|
-
return part;
|
|
99
|
-
}
|
|
100
|
-
return null;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function extractMoveArchiveChange(commandText: string): string | null {
|
|
104
|
-
const parts = shellSplit(commandText);
|
|
105
|
-
const moveCommands = new Set([
|
|
106
|
-
"mv",
|
|
107
|
-
"move",
|
|
108
|
-
"move-item",
|
|
109
|
-
"cmd",
|
|
110
|
-
"cmd.exe",
|
|
111
|
-
"powershell",
|
|
112
|
-
"powershell.exe",
|
|
113
|
-
"pwsh",
|
|
114
|
-
"pwsh.exe",
|
|
115
|
-
]);
|
|
116
|
-
const isPython = (part: string) =>
|
|
117
|
-
/^python(?:\d(?:\.\d+)*)?(?:\.exe)?$/i.test(part.split(/[\\/]/).pop() ?? part);
|
|
118
|
-
|
|
119
|
-
for (let index = 0; index < parts.length; index += 1) {
|
|
120
|
-
const command = parts[index].toLowerCase();
|
|
121
|
-
if (moveCommands.has(command)) {
|
|
122
|
-
for (const part of parts.slice(index + 1)) {
|
|
123
|
-
const change = extractArchivedChangeFromPath(part);
|
|
124
|
-
if (change) return change;
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
if (isPython(parts[index]) && /(?:shutil\.move|os\.rename|\.rename\s*\()/.test(commandText)) {
|
|
128
|
-
return extractArchivedChangeFromPath(commandText);
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
return null;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function collectCommands(value: unknown): string[] {
|
|
135
|
-
if (!value || typeof value !== "object") return [];
|
|
136
|
-
const record = value as Record<string, unknown>;
|
|
137
|
-
const commands: string[] = [];
|
|
138
|
-
if (typeof record.command === "string") commands.push(record.command);
|
|
139
|
-
for (const key of ["args", "tool_input"] as const) {
|
|
140
|
-
const nested = record[key];
|
|
141
|
-
if (nested && typeof nested === "object") {
|
|
142
|
-
const command = (nested as Record<string, unknown>).command;
|
|
143
|
-
if (typeof command === "string") commands.push(command);
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
return commands;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
function commandSucceeded(value: unknown): boolean {
|
|
150
|
-
if (!value || typeof value !== "object") return true;
|
|
151
|
-
const record = value as Record<string, unknown>;
|
|
152
|
-
const candidates = [
|
|
153
|
-
record,
|
|
154
|
-
record.result,
|
|
155
|
-
record.output,
|
|
156
|
-
record.tool_output,
|
|
157
|
-
record.tool_response,
|
|
158
|
-
];
|
|
159
|
-
for (const candidate of candidates) {
|
|
160
|
-
if (!candidate || typeof candidate !== "object") continue;
|
|
161
|
-
const result = candidate as Record<string, unknown>;
|
|
162
|
-
if (typeof result.success === "boolean") return result.success;
|
|
163
|
-
for (const key of ["exitCode", "exit_code", "status", "statusCode", "status_code"] as const) {
|
|
164
|
-
const status = result[key];
|
|
165
|
-
if (typeof status === "number") return status === 0;
|
|
166
|
-
if (typeof status === "string" && status.trim()) return Number(status) === 0;
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
return true;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function detectCommandArchive(input: unknown, output: unknown): string | null {
|
|
173
|
-
if (!commandSucceeded(input) || !commandSucceeded(output)) return null;
|
|
174
|
-
for (const command of [...collectCommands(input), ...collectCommands(output)]) {
|
|
175
|
-
const change = extractArchiveCommandChange(command) ?? extractMoveArchiveChange(command);
|
|
176
|
-
if (change) return change;
|
|
177
|
-
}
|
|
178
|
-
return null;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
function detectNewlyArchivedChanges(archiveDir: string, snapshot: Set<string>): string[] {
|
|
182
|
-
const current = readArchiveEntries(archiveDir);
|
|
183
|
-
const newlyArchived = [...current].filter((entry) => !snapshot.has(entry)).toSorted();
|
|
184
|
-
snapshot.clear();
|
|
185
|
-
for (const entry of current) snapshot.add(entry);
|
|
186
|
-
return newlyArchived;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function appendOpenSpecFinishNudge(
|
|
190
|
-
context: CompanionContext,
|
|
191
|
-
archiveSnapshot: Set<string>,
|
|
192
|
-
nudgedCommandChanges: Set<string>,
|
|
193
|
-
input: { tool?: unknown },
|
|
194
|
-
output: { output?: string },
|
|
195
|
-
): void {
|
|
196
|
-
if (!context.gitAutoModeEnabled) return;
|
|
197
|
-
|
|
198
|
-
const archiveDir = path.join(context.companionPath, "openspec", "changes", "archive");
|
|
199
|
-
const changes = detectNewlyArchivedChanges(archiveDir, archiveSnapshot).map((entry) =>
|
|
200
|
-
entry.slice("YYYY-MM-DD-".length),
|
|
201
|
-
);
|
|
202
|
-
const commandChange =
|
|
203
|
-
String(input.tool ?? "").toLowerCase() === "bash" ? detectCommandArchive(input, output) : null;
|
|
204
|
-
if (commandChange && !nudgedCommandChanges.has(commandChange)) {
|
|
205
|
-
changes.push(commandChange);
|
|
206
|
-
nudgedCommandChanges.add(commandChange);
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
const uniqueChanges = changes.filter((change, index) => changes.indexOf(change) === index);
|
|
210
|
-
if (uniqueChanges.length === 0) return;
|
|
211
|
-
const nudges = uniqueChanges.map(
|
|
212
|
-
(change) =>
|
|
213
|
-
`ACTION REQUIRED: An openspec change (${change}) was just archived. ` +
|
|
214
|
-
`Immediately invoke the mate-openspec-artifact-finish skill to complete the finish pipeline ` +
|
|
215
|
-
"(invoke the CLI as `mate`, never through a companion-local wrapper path or as `" +
|
|
216
|
-
"$MATE_COMPANION_BIN_PATH/mate`). " +
|
|
217
|
-
"`mate artifact finish` resumes from the already-archived change and completes the commit, dated tag, and push itself — " +
|
|
218
|
-
"do not hand-commit, do not hand-tag, and do not re-apply delta specs manually. " +
|
|
219
|
-
`Follow the skill's guardrails, completing the non-interactive happy path without asking for confirmation.`,
|
|
220
|
-
);
|
|
221
|
-
output.output = [output.output, ...nudges]
|
|
222
|
-
.map((part) => (part ?? "").trim())
|
|
223
|
-
.filter(Boolean)
|
|
224
|
-
.join("\n\n");
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
const REACT_DOCTOR_VERSION = "0.8.1";
|
|
228
|
-
|
|
229
|
-
const REACT_DOCTOR_NON_LINT_FAILURES = [
|
|
230
|
-
/No React dependency found/i,
|
|
231
|
-
/Could not resolve config/i,
|
|
232
|
-
/is not a git repository/i,
|
|
233
|
-
];
|
|
234
|
-
|
|
235
|
-
const REACT_DOCTOR_EDIT_TOOLS = new Set(["write", "edit", "apply_patch"]);
|
|
236
|
-
const REACT_DOCTOR_ARGS = [
|
|
237
|
-
"--yes",
|
|
238
|
-
"--verbose",
|
|
239
|
-
"--scope",
|
|
240
|
-
"changed",
|
|
241
|
-
"--base",
|
|
242
|
-
"HEAD",
|
|
243
|
-
"--blocking",
|
|
244
|
-
"warning",
|
|
245
|
-
"--no-score",
|
|
246
|
-
"--max-duration",
|
|
247
|
-
"30",
|
|
248
|
-
];
|
|
249
|
-
|
|
250
|
-
async function runReactDoctorScan(
|
|
251
|
-
context: CompanionContext,
|
|
252
|
-
client: PluginInput["client"],
|
|
253
|
-
$: PluginInput["$"],
|
|
254
|
-
sessionID: string,
|
|
255
|
-
scansInFlight: Set<string>,
|
|
256
|
-
): Promise<void> {
|
|
257
|
-
const repo = context.repositoryPath;
|
|
258
|
-
if (!repo || scansInFlight.has(sessionID)) return;
|
|
259
|
-
scansInFlight.add(sessionID);
|
|
260
|
-
try {
|
|
261
|
-
const localBin = path.join(repo, "node_modules", ".bin", "react-doctor");
|
|
262
|
-
const mateBin = process.env.MATE_REACT_DOCTOR_BIN_PATH;
|
|
263
|
-
const doctorBin = mateBin && fs.existsSync(mateBin) ? mateBin : localBin;
|
|
264
|
-
|
|
265
|
-
const result = fs.existsSync(doctorBin)
|
|
266
|
-
? await $`${doctorBin} ${REACT_DOCTOR_ARGS}`.cwd(repo).nothrow().quiet()
|
|
267
|
-
: await $`npx --yes ${`react-doctor@${REACT_DOCTOR_VERSION}`} ${REACT_DOCTOR_ARGS}`
|
|
268
|
-
.cwd(repo)
|
|
269
|
-
.nothrow()
|
|
270
|
-
.quiet();
|
|
271
|
-
|
|
272
|
-
if (result.exitCode === 0) return;
|
|
273
|
-
|
|
274
|
-
const output = `${result.stdout?.toString() ?? ""}${result.stderr?.toString() ?? ""}`.trim();
|
|
275
|
-
if (!output || REACT_DOCTOR_NON_LINT_FAILURES.some((re) => re.test(output))) return;
|
|
276
|
-
|
|
277
|
-
await client.tui
|
|
278
|
-
.showToast({
|
|
279
|
-
query: { directory: repo },
|
|
280
|
-
body: {
|
|
281
|
-
title: "React Doctor",
|
|
282
|
-
message: "Found issues in the changed files — review before finishing.",
|
|
283
|
-
variant: "warning",
|
|
284
|
-
},
|
|
285
|
-
})
|
|
286
|
-
.catch(() => {});
|
|
287
|
-
await client.session
|
|
288
|
-
.promptAsync({
|
|
289
|
-
path: { id: sessionID },
|
|
290
|
-
query: { directory: repo },
|
|
291
|
-
body: {
|
|
292
|
-
parts: [
|
|
293
|
-
{
|
|
294
|
-
type: "text",
|
|
295
|
-
synthetic: true,
|
|
296
|
-
text:
|
|
297
|
-
"React Doctor found issues in the changed files. Review this output and fix " +
|
|
298
|
-
"the regressions before finishing. For confirmed issues that cannot be fixed " +
|
|
299
|
-
"now, create GitHub issues with the rule, file/line, confidence, impact, and " +
|
|
300
|
-
`proposed fix.\n\n${output}`,
|
|
301
|
-
},
|
|
302
|
-
],
|
|
303
|
-
},
|
|
304
|
-
})
|
|
305
|
-
.catch(() => {});
|
|
306
|
-
} catch {
|
|
307
|
-
// Never break the session because a diagnostic scan failed.
|
|
308
|
-
} finally {
|
|
309
|
-
scansInFlight.delete(sessionID);
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
type PluginEventInput = Parameters<NonNullable<Hooks["event"]>>[0];
|
|
314
|
-
type ToolAfterInput = Parameters<NonNullable<Hooks["tool.execute.after"]>>[0];
|
|
315
|
-
type ToolAfterOutput = Parameters<NonNullable<Hooks["tool.execute.after"]>>[1];
|
|
316
|
-
|
|
317
|
-
export const CompanionHooksPlugin: Plugin = async (pluginInput = {} as PluginInput) => {
|
|
318
|
-
const { client, $ } = pluginInput;
|
|
319
|
-
const context = readContext(process.env.MATE_ARTIFACT_PATH ?? "");
|
|
320
|
-
if (!context.companionPath || !context.repositoryPath) return {};
|
|
321
|
-
|
|
322
|
-
const archiveDir = path.join(context.companionPath, "openspec", "changes", "archive");
|
|
323
|
-
const archiveSnapshot = readArchiveEntries(archiveDir);
|
|
324
|
-
const nudgedCommandChanges = new Set<string>();
|
|
325
|
-
const dirtyReactDoctorSessions = new Set<string>();
|
|
326
|
-
const reactDoctorScansInFlight = new Set<string>();
|
|
327
|
-
|
|
328
|
-
return {
|
|
329
|
-
event: async ({ event }: PluginEventInput) => {
|
|
330
|
-
if (event.type !== "session.idle") return;
|
|
331
|
-
const sessionID = event.properties.sessionID;
|
|
332
|
-
if (
|
|
333
|
-
!context.reactDoctorEnabled ||
|
|
334
|
-
reactDoctorScansInFlight.has(sessionID) ||
|
|
335
|
-
!dirtyReactDoctorSessions.delete(sessionID)
|
|
336
|
-
) {
|
|
337
|
-
return;
|
|
338
|
-
}
|
|
339
|
-
await runReactDoctorScan(context, client, $, sessionID, reactDoctorScansInFlight);
|
|
340
|
-
},
|
|
341
|
-
"tool.execute.before": async (
|
|
342
|
-
input: { tool: unknown },
|
|
343
|
-
output: { args: { filePath?: unknown; patchText?: unknown } | undefined },
|
|
344
|
-
) => {
|
|
345
|
-
const toolName = String(input.tool ?? "");
|
|
346
|
-
const args = output.args ?? {};
|
|
347
|
-
if (["write", "edit"].includes(toolName)) {
|
|
348
|
-
const filePath = String(args.filePath ?? "");
|
|
349
|
-
if (filePath && shouldBlockArtifactWrite(context, filePath)) {
|
|
350
|
-
throw new Error(buildArtifactError(context, filePath));
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
if (toolName === "apply_patch") {
|
|
354
|
-
for (const filePath of extractPatchPaths(String(args.patchText ?? ""))) {
|
|
355
|
-
if (shouldBlockArtifactWrite(context, filePath)) {
|
|
356
|
-
throw new Error(buildArtifactError(context, filePath));
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
},
|
|
361
|
-
"tool.execute.after": async (input: ToolAfterInput, output: ToolAfterOutput) => {
|
|
362
|
-
if (context.reactDoctorEnabled && REACT_DOCTOR_EDIT_TOOLS.has(input.tool)) {
|
|
363
|
-
dirtyReactDoctorSessions.add(input.sessionID);
|
|
364
|
-
}
|
|
365
|
-
appendOpenSpecFinishNudge(context, archiveSnapshot, nudgedCommandChanges, input, output);
|
|
366
|
-
},
|
|
367
|
-
};
|
|
368
|
-
};
|
|
369
|
-
|
|
370
|
-
export default CompanionHooksPlugin;
|
package/src/companion-policy.ts
DELETED
|
@@ -1,177 +0,0 @@
|
|
|
1
|
-
import { spawnSync } from "node:child_process";
|
|
2
|
-
import fs from "node:fs";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
|
|
5
|
-
import { readCompanionRuntimeContext, type CompanionRuntimeContext } from "@uniqbit/mate-core";
|
|
6
|
-
|
|
7
|
-
export type CompanionContext = CompanionRuntimeContext & {
|
|
8
|
-
agentsMd: string;
|
|
9
|
-
};
|
|
10
|
-
|
|
11
|
-
const ARTIFACT_BASENAMES = new Set([
|
|
12
|
-
"CLAUDE.md",
|
|
13
|
-
"CONTEXT.md",
|
|
14
|
-
"design.md",
|
|
15
|
-
"explore-brief.md",
|
|
16
|
-
"proposal.md",
|
|
17
|
-
"spec.md",
|
|
18
|
-
"tasks.md",
|
|
19
|
-
]);
|
|
20
|
-
const ARTIFACT_SEGMENTS = [
|
|
21
|
-
"/changes/",
|
|
22
|
-
"/openspec/",
|
|
23
|
-
"/specs/",
|
|
24
|
-
"/docs/adr/",
|
|
25
|
-
"/docs/adrs/",
|
|
26
|
-
"/docs/decisions/",
|
|
27
|
-
"/docs/prd/",
|
|
28
|
-
];
|
|
29
|
-
function normalizedPolicyPath(filePath: string): string {
|
|
30
|
-
return `/${filePath.replace(/\\/g, "/").replace(/^\/+/, "")}`;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export function isArtifactPath(filePath: string): boolean {
|
|
34
|
-
const basename = path.basename(filePath);
|
|
35
|
-
const ext = path.extname(basename).toLowerCase();
|
|
36
|
-
const normalized = normalizedPolicyPath(filePath);
|
|
37
|
-
|
|
38
|
-
if (ARTIFACT_BASENAMES.has(basename)) {
|
|
39
|
-
return true;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
if (ARTIFACT_SEGMENTS.some((segment) => normalized.includes(segment))) {
|
|
43
|
-
return true;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
if (ext === ".md" && basename !== "README.md") {
|
|
47
|
-
return true;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
return false;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export function isProductDocumentationPath(context: CompanionContext, filePath: string): boolean {
|
|
54
|
-
const normalized = normalizeTargetPath(context, filePath);
|
|
55
|
-
const relativePath = path.relative(context.repositoryPath, normalized);
|
|
56
|
-
if (!relativePath || relativePath.startsWith("..")) {
|
|
57
|
-
return false;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const parts = relativePath.split(path.sep);
|
|
61
|
-
for (let index = 0; index < parts.length; index += 1) {
|
|
62
|
-
const part = parts[index];
|
|
63
|
-
if (part === ".storybook" || part === "storybook") {
|
|
64
|
-
return true;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
if (part !== "docs") {
|
|
68
|
-
continue;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const docsRoot = path.join(context.repositoryPath, ...parts.slice(0, index + 1));
|
|
72
|
-
if (fs.existsSync(path.join(docsRoot, "package.json"))) {
|
|
73
|
-
return true;
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
return false;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export function normalizeTargetPath(context: CompanionContext, filePath: string): string {
|
|
81
|
-
if (path.isAbsolute(filePath)) {
|
|
82
|
-
return path.normalize(filePath);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
return path.normalize(path.join(context.repositoryPath, filePath));
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export function isGitignored(context: CompanionContext, filePath: string): boolean {
|
|
89
|
-
if (!context.repositoryPath) {
|
|
90
|
-
return false;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const normalized = normalizeTargetPath(context, filePath);
|
|
94
|
-
if (!normalized.startsWith(path.normalize(context.repositoryPath))) {
|
|
95
|
-
return false;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const relativePath = path.relative(context.repositoryPath, normalized);
|
|
99
|
-
if (!relativePath || relativePath.startsWith("..")) {
|
|
100
|
-
return false;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
const result = spawnSync(
|
|
104
|
-
"git",
|
|
105
|
-
["-C", context.repositoryPath, "check-ignore", "--no-index", "-q", "--", relativePath],
|
|
106
|
-
{
|
|
107
|
-
encoding: "utf8",
|
|
108
|
-
stdio: "ignore",
|
|
109
|
-
},
|
|
110
|
-
);
|
|
111
|
-
|
|
112
|
-
return result.status === 0;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
export function shouldBlockArtifactWrite(context: CompanionContext, filePath: string): boolean {
|
|
116
|
-
if (!filePath) {
|
|
117
|
-
return false;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
if (normalizeTargetPath(context, filePath).startsWith(path.normalize(context.companionPath))) {
|
|
121
|
-
return false;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
if (isProductDocumentationPath(context, filePath)) {
|
|
125
|
-
return false;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
if (!isArtifactPath(filePath)) {
|
|
129
|
-
return false;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
return !isGitignored(context, filePath);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
const PATCH_FILE_MARKER = /\*\*\* (?:Add|Update|Move to|Delete) File:\s*(.+)$/gm;
|
|
136
|
-
|
|
137
|
-
export function extractPatchPaths(patchText: string): string[] {
|
|
138
|
-
const paths: string[] = [];
|
|
139
|
-
let match;
|
|
140
|
-
while ((match = PATCH_FILE_MARKER.exec(patchText)) !== null) {
|
|
141
|
-
const raw = match[1].trim();
|
|
142
|
-
if (raw) {
|
|
143
|
-
paths.push(raw);
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
return paths;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
export function buildArtifactError(context: CompanionContext, filePath: string): string {
|
|
150
|
-
const rel = path.relative(context.repositoryPath, filePath);
|
|
151
|
-
const suggested = path.join(context.companionPath, rel);
|
|
152
|
-
|
|
153
|
-
return [
|
|
154
|
-
`${context.frameworkName} guardrail: artifact writes must go to the companion framework path.`,
|
|
155
|
-
` target: ${filePath}`,
|
|
156
|
-
` suggested: ${suggested}`,
|
|
157
|
-
`Rewrite to ${suggested} and retry.`,
|
|
158
|
-
].join("\n");
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
export function readContext(companionPath: string): CompanionContext {
|
|
162
|
-
const agentsMdPath = path.join(companionPath, "AGENTS.md");
|
|
163
|
-
let agentsMd = "";
|
|
164
|
-
try {
|
|
165
|
-
if (fs.existsSync(agentsMdPath)) {
|
|
166
|
-
agentsMd = fs.readFileSync(agentsMdPath, "utf8");
|
|
167
|
-
}
|
|
168
|
-
} catch {
|
|
169
|
-
// Ignore read errors; AGENTS.md is optional
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
return {
|
|
173
|
-
...readCompanionRuntimeContext(process.env),
|
|
174
|
-
companionPath,
|
|
175
|
-
agentsMd,
|
|
176
|
-
};
|
|
177
|
-
}
|