jorgex-stack 1.9.73 → 1.9.74
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/README.md +24 -7
- package/dist/cli.js +2506 -1565
- package/package.json +3 -2
- package/stack/plugins/opencode/hooks.ts +404 -514
- package/stack/plugins/opencode/package.json +1 -4
- package/stack/plugins/opencode/worktree.ts +291 -320
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
|
|
4
5
|
interface WorktreePluginConfig {
|
|
@@ -16,31 +17,21 @@ interface ScriptResult {
|
|
|
16
17
|
scriptPath: string;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
) => {
|
|
25
|
-
if (typeof client !== "object" || client === null) return;
|
|
26
|
-
const app = (client as { app?: unknown }).app;
|
|
27
|
-
if (typeof app !== "object" || app === null) return;
|
|
28
|
-
const log = (app as { log?: unknown }).log;
|
|
29
|
-
if (typeof log !== "function") return;
|
|
30
|
-
|
|
31
|
-
const safeExtra =
|
|
32
|
-
extra instanceof Error
|
|
33
|
-
? { message: extra.message, stack: extra.stack }
|
|
34
|
-
: extra;
|
|
20
|
+
interface RunScriptOptions {
|
|
21
|
+
payload?: unknown;
|
|
22
|
+
commandArgs?: string[];
|
|
23
|
+
activeWorktreePath?: string;
|
|
24
|
+
}
|
|
35
25
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
26
|
+
interface GitResult {
|
|
27
|
+
stdout: string;
|
|
28
|
+
stderr: string;
|
|
29
|
+
exitCode: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// El host v2 llama `shell` a la herramienta de shell; `bash` se conserva como
|
|
33
|
+
// alias porque el nombre observado varía entre builds.
|
|
34
|
+
const SHELL_TOOLS = new Set(["shell", "bash"]);
|
|
44
35
|
|
|
45
36
|
const isAbsolutePath = (value: string) =>
|
|
46
37
|
/^[a-zA-Z]:[\\/]/.test(value) || value.startsWith("/");
|
|
@@ -57,8 +48,7 @@ const joinProjectPath = (directory: string, target: string) => {
|
|
|
57
48
|
|
|
58
49
|
const resolvePath = (base: string, target: string) => {
|
|
59
50
|
const api = isWindowsPath(base) || isWindowsPath(target) ? path.win32 : path.posix;
|
|
60
|
-
|
|
61
|
-
return toSlashes(resolved);
|
|
51
|
+
return toSlashes(api.resolve(base, target));
|
|
62
52
|
};
|
|
63
53
|
|
|
64
54
|
const resolveProjectPath = (directory: string, target?: string) => {
|
|
@@ -69,6 +59,11 @@ const resolveProjectPath = (directory: string, target?: string) => {
|
|
|
69
59
|
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
|
70
60
|
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
71
61
|
|
|
62
|
+
const commandText = (command: unknown): string => {
|
|
63
|
+
if (Array.isArray(command)) return command.map(String).join(" ");
|
|
64
|
+
return typeof command === "string" ? command : "";
|
|
65
|
+
};
|
|
66
|
+
|
|
72
67
|
const validateConfig = (config: Record<string, unknown>) => {
|
|
73
68
|
for (const field of ["setupScript", "docsReminderScript", "pathContains"]) {
|
|
74
69
|
if (field in config && typeof config[field] !== "string") {
|
|
@@ -85,19 +80,6 @@ const validateConfig = (config: Record<string, unknown>) => {
|
|
|
85
80
|
}
|
|
86
81
|
};
|
|
87
82
|
|
|
88
|
-
const getPayloadWorktreePath = (payload: unknown) => {
|
|
89
|
-
if (!isPlainObject(payload)) return undefined;
|
|
90
|
-
|
|
91
|
-
const topLevel = payload.activeWorktreePath || payload.worktreePath;
|
|
92
|
-
if (typeof topLevel === "string" && topLevel) return topLevel;
|
|
93
|
-
|
|
94
|
-
const args = payload.args;
|
|
95
|
-
if (!isPlainObject(args)) return undefined;
|
|
96
|
-
|
|
97
|
-
const argPath = args.activeWorktreePath || args.worktreePath;
|
|
98
|
-
return typeof argPath === "string" && argPath ? argPath : undefined;
|
|
99
|
-
};
|
|
100
|
-
|
|
101
83
|
const buildScriptEnv = (scriptPath: string, activeWorktreePath?: string) => {
|
|
102
84
|
const env = Object.fromEntries(
|
|
103
85
|
Object.entries(process.env).filter((entry): entry is [string, string] =>
|
|
@@ -122,22 +104,13 @@ const buildScriptEnv = (scriptPath: string, activeWorktreePath?: string) => {
|
|
|
122
104
|
return env;
|
|
123
105
|
};
|
|
124
106
|
|
|
125
|
-
interface RunScriptOptions {
|
|
126
|
-
payload?: unknown;
|
|
127
|
-
commandArgs?: string[];
|
|
128
|
-
activeWorktreePath?: string;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
107
|
const runScript = async (
|
|
132
|
-
client: any,
|
|
133
108
|
directory: string,
|
|
134
109
|
script: string,
|
|
135
110
|
options?: RunScriptOptions,
|
|
136
111
|
): Promise<ScriptResult> => {
|
|
137
112
|
const payload = options?.payload;
|
|
138
|
-
const scriptPath = isAbsolutePath(script)
|
|
139
|
-
? script
|
|
140
|
-
: joinProjectPath(directory, script);
|
|
113
|
+
const scriptPath = isAbsolutePath(script) ? script : joinProjectPath(directory, script);
|
|
141
114
|
const stdin = payload ? JSON.stringify(payload) : "";
|
|
142
115
|
const stdinSource = new Response(stdin);
|
|
143
116
|
|
|
@@ -145,7 +118,6 @@ const runScript = async (
|
|
|
145
118
|
|
|
146
119
|
// Resolve bash path: Bun's spawned process may not inherit Git Bash in PATH on Windows
|
|
147
120
|
const resolveBash = (): string => {
|
|
148
|
-
const fs = require("fs");
|
|
149
121
|
const candidates = [
|
|
150
122
|
"C:/Program Files/Git/usr/bin/bash.exe",
|
|
151
123
|
"C:/Program Files/Git/bin/bash.exe",
|
|
@@ -155,7 +127,7 @@ const runScript = async (
|
|
|
155
127
|
].filter(Boolean) as string[];
|
|
156
128
|
for (const candidate of candidates) {
|
|
157
129
|
try {
|
|
158
|
-
if (
|
|
130
|
+
if (existsSync(candidate)) return candidate;
|
|
159
131
|
} catch {
|
|
160
132
|
/* skip */
|
|
161
133
|
}
|
|
@@ -184,21 +156,20 @@ const runScript = async (
|
|
|
184
156
|
}
|
|
185
157
|
|
|
186
158
|
if (!command) {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
return { exitCode: 1, stdout: "", stderr, script, scriptPath };
|
|
159
|
+
return {
|
|
160
|
+
exitCode: 1,
|
|
161
|
+
stdout: "",
|
|
162
|
+
stderr: `Unsupported worktree hook script extension for ${scriptPath}.`,
|
|
163
|
+
script,
|
|
164
|
+
scriptPath,
|
|
165
|
+
};
|
|
195
166
|
}
|
|
196
167
|
|
|
197
168
|
try {
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
169
|
+
const spawnEnv = buildScriptEnv(
|
|
170
|
+
scriptPath,
|
|
171
|
+
options?.activeWorktreePath,
|
|
172
|
+
);
|
|
202
173
|
const proc = (globalThis as any).Bun.spawn(command, {
|
|
203
174
|
stdin: stdinSource,
|
|
204
175
|
stdout: "pipe",
|
|
@@ -213,28 +184,8 @@ const runScript = async (
|
|
|
213
184
|
proc.exited,
|
|
214
185
|
]);
|
|
215
186
|
|
|
216
|
-
if (exitCode !== 0) {
|
|
217
|
-
await logToOpenCode(
|
|
218
|
-
client,
|
|
219
|
-
"error",
|
|
220
|
-
"Worktree hook script execution failed",
|
|
221
|
-
{
|
|
222
|
-
script,
|
|
223
|
-
scriptPath,
|
|
224
|
-
exitCode,
|
|
225
|
-
stderr: stderr.trim(),
|
|
226
|
-
stdout: stdout.trim(),
|
|
227
|
-
},
|
|
228
|
-
);
|
|
229
|
-
}
|
|
230
|
-
|
|
231
187
|
return { exitCode, stdout, stderr, script, scriptPath };
|
|
232
188
|
} catch (error) {
|
|
233
|
-
await logToOpenCode(client, "error", "Worktree hook script execution failed", {
|
|
234
|
-
script,
|
|
235
|
-
scriptPath,
|
|
236
|
-
error: error instanceof Error ? error.message : String(error),
|
|
237
|
-
});
|
|
238
189
|
return {
|
|
239
190
|
exitCode: 1,
|
|
240
191
|
stdout: "",
|
|
@@ -260,23 +211,23 @@ const extractScriptMessage = (text: string) => {
|
|
|
260
211
|
}
|
|
261
212
|
};
|
|
262
213
|
|
|
263
|
-
|
|
264
|
-
|
|
214
|
+
// v2 entrega `result.content` como array readonly. Se reemplaza el objeto
|
|
215
|
+
// entero (nunca se muta el original) conservando output/metadata y anexando el
|
|
216
|
+
// aviso como bloque de texto.
|
|
217
|
+
const appendToResult = (event: any, messages: string[]) => {
|
|
218
|
+
const result = event?.result;
|
|
219
|
+
if (!result || typeof result !== "object" || messages.length === 0) return;
|
|
265
220
|
const section = messages.join("\n\n");
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
} else {
|
|
277
|
-
output.output = section;
|
|
278
|
-
output.content = section;
|
|
279
|
-
}
|
|
221
|
+
const current = result.content;
|
|
222
|
+
const base = Array.isArray(current)
|
|
223
|
+
? current
|
|
224
|
+
: typeof current === "string" && current
|
|
225
|
+
? [{ type: "text", text: current }]
|
|
226
|
+
: [];
|
|
227
|
+
event.result = {
|
|
228
|
+
...result,
|
|
229
|
+
content: [...base, { type: "text", text: `\n\n${section}` }],
|
|
230
|
+
};
|
|
280
231
|
};
|
|
281
232
|
|
|
282
233
|
const truncateMessage = (value: string, maxLength = 1200) => {
|
|
@@ -284,8 +235,7 @@ const truncateMessage = (value: string, maxLength = 1200) => {
|
|
|
284
235
|
return `${value.slice(0, maxLength)}\n...`;
|
|
285
236
|
};
|
|
286
237
|
|
|
287
|
-
const normalizePath = (value: string) =>
|
|
288
|
-
toSlashes(value).replace(/\/+$/, "");
|
|
238
|
+
const normalizePath = (value: string) => toSlashes(value).replace(/\/+$/, "");
|
|
289
239
|
|
|
290
240
|
const samePath = (left: string, right: string) => {
|
|
291
241
|
const normalizedLeft = normalizePath(left);
|
|
@@ -296,12 +246,6 @@ const samePath = (left: string, right: string) => {
|
|
|
296
246
|
return normalizedLeft === normalizedRight;
|
|
297
247
|
};
|
|
298
248
|
|
|
299
|
-
const getCommandCwd = (args: Record<string, unknown>, directory: string) => {
|
|
300
|
-
const cwd = args.workdir || args.cwd;
|
|
301
|
-
if (typeof cwd !== "string" || cwd.length === 0) return directory;
|
|
302
|
-
return isAbsolutePath(cwd) ? cwd : resolvePath(directory, cwd);
|
|
303
|
-
};
|
|
304
|
-
|
|
305
249
|
const replaceToken = (value: string, token: string, replacement: string) =>
|
|
306
250
|
value.split(token).join(replacement);
|
|
307
251
|
|
|
@@ -316,6 +260,33 @@ const isMissingFileError = (error: unknown) => {
|
|
|
316
260
|
);
|
|
317
261
|
};
|
|
318
262
|
|
|
263
|
+
// El cwd del servidor es compartido. La sesión aporta el directorio del
|
|
264
|
+
// proyecto; `input.workdir` (relativo o absoluto) afina el comando concreto.
|
|
265
|
+
const resolveSessionDirectory = async (
|
|
266
|
+
ctx: any,
|
|
267
|
+
sessionID: unknown,
|
|
268
|
+
): Promise<string | undefined> => {
|
|
269
|
+
if (!ctx?.session || typeof ctx.session.get !== "function") return undefined;
|
|
270
|
+
if (typeof sessionID !== "string" || !sessionID) return undefined;
|
|
271
|
+
try {
|
|
272
|
+
const session = await ctx.session.get({ sessionID });
|
|
273
|
+
const directory = session?.location?.directory;
|
|
274
|
+
return typeof directory === "string" && directory ? directory : undefined;
|
|
275
|
+
} catch {
|
|
276
|
+
return undefined;
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const resolveDirectories = async (ctx: any, event: any) => {
|
|
281
|
+
const base = await resolveSessionDirectory(ctx, event?.sessionID);
|
|
282
|
+
const workdir = event?.input?.workdir;
|
|
283
|
+
if (typeof workdir === "string" && workdir) {
|
|
284
|
+
if (isAbsolutePath(workdir)) return { base, command: workdir };
|
|
285
|
+
return { base, command: base ? resolvePath(base, workdir) : undefined };
|
|
286
|
+
}
|
|
287
|
+
return { base, command: base };
|
|
288
|
+
};
|
|
289
|
+
|
|
319
290
|
// Pre/post validation against real Git state. The Bash command is only a
|
|
320
291
|
// cheap candidate filter; path, branch and detached state come from `git
|
|
321
292
|
// worktree list --porcelain -z` (NUL-separated fields, NUL-terminated
|
|
@@ -409,15 +380,74 @@ const diffNewWorktrees = (
|
|
|
409
380
|
post: PorcelainWorktree[],
|
|
410
381
|
) => post.filter((postEntry) => !pre.some((preEntry) => samePath(preEntry.path, postEntry.path)));
|
|
411
382
|
|
|
412
|
-
const
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
383
|
+
const MAX_BUFFER = 32 * 1024 * 1024;
|
|
384
|
+
|
|
385
|
+
const runGit = (args: string[], cwd: string): Promise<GitResult> =>
|
|
386
|
+
new Promise((resolve) => {
|
|
387
|
+
execFile(
|
|
388
|
+
"git",
|
|
389
|
+
args,
|
|
390
|
+
{ cwd, encoding: "utf8", maxBuffer: MAX_BUFFER },
|
|
391
|
+
(error, stdout, stderr) => {
|
|
392
|
+
const exitCode =
|
|
393
|
+
error && typeof (error as { code?: unknown }).code === "number"
|
|
394
|
+
? (error as { code: number }).code
|
|
395
|
+
: error
|
|
396
|
+
? 1
|
|
397
|
+
: 0;
|
|
398
|
+
resolve({ stdout: stdout ?? "", stderr: stderr ?? "", exitCode });
|
|
399
|
+
},
|
|
400
|
+
);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
const gitText = async (args: string[], cwd: string): Promise<string> => {
|
|
404
|
+
const { stdout, stderr, exitCode } = await runGit(args, cwd);
|
|
405
|
+
if (exitCode !== 0) {
|
|
406
|
+
throw new Error(
|
|
407
|
+
stderr.trim() || `git ${args.join(" ")} failed with exit code ${exitCode}`,
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
return stdout;
|
|
416
411
|
};
|
|
417
412
|
|
|
418
|
-
const
|
|
419
|
-
|
|
420
|
-
|
|
413
|
+
const readPorcelain = (cwd: string) =>
|
|
414
|
+
gitText(["worktree", "list", "--porcelain", "-z"], cwd);
|
|
415
|
+
|
|
416
|
+
const readCommonDir = async (cwd: string) =>
|
|
417
|
+
toSlashes(
|
|
418
|
+
(await gitText(["rev-parse", "--path-format=absolute", "--git-common-dir"], cwd)).trim(),
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
const defaultConfig = (): WorktreePluginConfig => ({
|
|
422
|
+
pathContains: "worktrees/",
|
|
423
|
+
reminderLines: [],
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
const readWorktreeConfig = async (
|
|
427
|
+
directory: string,
|
|
428
|
+
): Promise<{ config: WorktreePluginConfig; error?: string }> => {
|
|
429
|
+
try {
|
|
430
|
+
const configFile = (globalThis as any).Bun.file(`${directory}/.opencode/worktree.json`);
|
|
431
|
+
if (typeof configFile.exists === "function" && !(await configFile.exists())) {
|
|
432
|
+
return { config: defaultConfig() };
|
|
433
|
+
}
|
|
434
|
+
const parsed = JSON.parse(await configFile.text()) as unknown;
|
|
435
|
+
if (!isPlainObject(parsed)) {
|
|
436
|
+
return { config: defaultConfig(), error: "Worktree config must be a JSON object." };
|
|
437
|
+
}
|
|
438
|
+
const error = validateConfig(parsed);
|
|
439
|
+
if (error) return { config: defaultConfig(), error };
|
|
440
|
+
return { config: { ...defaultConfig(), ...(parsed as WorktreePluginConfig) } };
|
|
441
|
+
} catch (error) {
|
|
442
|
+
if (isMissingFileError(error)) return { config: defaultConfig() };
|
|
443
|
+
const couldNotParse = error instanceof SyntaxError;
|
|
444
|
+
return {
|
|
445
|
+
config: defaultConfig(),
|
|
446
|
+
error: couldNotParse
|
|
447
|
+
? "Worktree config could not be parsed as JSON."
|
|
448
|
+
: "Worktree config could not be read.",
|
|
449
|
+
};
|
|
450
|
+
}
|
|
421
451
|
};
|
|
422
452
|
|
|
423
453
|
interface PendingCapturing {
|
|
@@ -442,94 +472,52 @@ interface PendingFailedCapture {
|
|
|
442
472
|
|
|
443
473
|
type PendingCapture = PendingCapturing | PendingReadyCapture | PendingFailedCapture;
|
|
444
474
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
};
|
|
450
|
-
let configError: string | undefined;
|
|
451
|
-
|
|
452
|
-
try {
|
|
453
|
-
const configPath = `${directory}/.opencode/worktree.json`;
|
|
454
|
-
const configFile = (globalThis as any).Bun.file(configPath);
|
|
455
|
-
if (
|
|
456
|
-
typeof configFile.exists === "function" &&
|
|
457
|
-
!(await configFile.exists())
|
|
458
|
-
) {
|
|
459
|
-
// Optional project config.
|
|
460
|
-
} else {
|
|
461
|
-
const parsed = JSON.parse(await configFile.text()) as unknown;
|
|
462
|
-
if (!isPlainObject(parsed)) {
|
|
463
|
-
configError = "Worktree config must be a JSON object.";
|
|
464
|
-
} else {
|
|
465
|
-
configError = validateConfig(parsed);
|
|
466
|
-
if (!configError) {
|
|
467
|
-
config = { ...config, ...(parsed as WorktreePluginConfig) };
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
} catch (error) {
|
|
472
|
-
if (!isMissingFileError(error)) {
|
|
473
|
-
const couldNotParse = error instanceof SyntaxError;
|
|
474
|
-
configError = couldNotParse
|
|
475
|
-
? "Worktree config could not be parsed as JSON."
|
|
476
|
-
: "Worktree config could not be read.";
|
|
477
|
-
await logToOpenCode(
|
|
478
|
-
client,
|
|
479
|
-
"warn",
|
|
480
|
-
couldNotParse
|
|
481
|
-
? "Worktree config could not be parsed"
|
|
482
|
-
: "Worktree config could not be read",
|
|
483
|
-
error,
|
|
484
|
-
);
|
|
485
|
-
}
|
|
486
|
-
}
|
|
475
|
+
const AMBIGUOUS_MESSAGES = (reason: string) => [
|
|
476
|
+
reason,
|
|
477
|
+
"Run `git worktree list --porcelain` to inspect the current inventory.",
|
|
478
|
+
];
|
|
487
479
|
|
|
488
|
-
|
|
480
|
+
export default {
|
|
481
|
+
id: "stack-worktree",
|
|
489
482
|
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
String(
|
|
493
|
-
await $`git -C ${cwd} rev-parse --path-format=absolute --git-common-dir`.quiet().text(),
|
|
494
|
-
).trim(),
|
|
495
|
-
);
|
|
483
|
+
async setup(ctx: any) {
|
|
484
|
+
const pending = new Map<string, PendingCapture>();
|
|
496
485
|
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
error: message,
|
|
502
|
-
});
|
|
503
|
-
};
|
|
486
|
+
const failCapture = (id: string, cwd: string, cause: unknown) => {
|
|
487
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
488
|
+
pending.set(id, { status: "failed", cwd, error: truncateMessage(message) });
|
|
489
|
+
};
|
|
504
490
|
|
|
505
|
-
|
|
506
|
-
"tool.execute.before": async (input: any, output: any) => {
|
|
491
|
+
const handleBefore = async (event: any) => {
|
|
507
492
|
try {
|
|
508
|
-
|
|
509
|
-
if (
|
|
510
|
-
const
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
const
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
493
|
+
if (!SHELL_TOOLS.has(String(event?.tool ?? "").toLowerCase())) return;
|
|
494
|
+
if (!isWorktreeAddCommand(commandText(event?.input?.command))) return;
|
|
495
|
+
const id = typeof event?.id === "string" ? event.id : "";
|
|
496
|
+
if (!id) return;
|
|
497
|
+
const { command: commandCwd } = await resolveDirectories(ctx, event);
|
|
498
|
+
if (!commandCwd) return;
|
|
499
|
+
|
|
500
|
+
const provisional: PendingCapturing = {
|
|
501
|
+
status: "capturing",
|
|
502
|
+
cwd: commandCwd,
|
|
503
|
+
ambiguous: false,
|
|
504
|
+
};
|
|
505
|
+
for (const [otherId, cap] of pending) {
|
|
506
|
+
if (otherId !== id) {
|
|
519
507
|
if (cap.status === "capturing" || cap.status === "ready") cap.ambiguous = true;
|
|
520
508
|
provisional.ambiguous = true;
|
|
521
509
|
}
|
|
522
510
|
}
|
|
523
|
-
pending.set(
|
|
511
|
+
pending.set(id, provisional);
|
|
512
|
+
|
|
524
513
|
let preRaw: string;
|
|
525
514
|
try {
|
|
526
|
-
preRaw =
|
|
527
|
-
await $`git -C ${commandCwd} worktree list --porcelain -z`.quiet().text(),
|
|
528
|
-
);
|
|
515
|
+
preRaw = await readPorcelain(commandCwd);
|
|
529
516
|
} catch (error) {
|
|
530
|
-
|
|
517
|
+
failCapture(id, commandCwd, error);
|
|
531
518
|
return;
|
|
532
519
|
}
|
|
520
|
+
|
|
533
521
|
let repo: string;
|
|
534
522
|
try {
|
|
535
523
|
repo = await readCommonDir(commandCwd);
|
|
@@ -537,76 +525,66 @@ export const WorktreePlugin: Plugin = async ({ $, client, directory }) => {
|
|
|
537
525
|
throw new Error(`empty repository identity (common-dir) for ${commandCwd}`);
|
|
538
526
|
}
|
|
539
527
|
} catch (error) {
|
|
540
|
-
|
|
528
|
+
failCapture(id, commandCwd, error);
|
|
541
529
|
return;
|
|
542
530
|
}
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
531
|
+
|
|
532
|
+
pending.set(id, {
|
|
533
|
+
status: "ready",
|
|
534
|
+
pre: preRaw,
|
|
535
|
+
repo,
|
|
536
|
+
cwd: commandCwd,
|
|
537
|
+
ambiguous: provisional.ambiguous,
|
|
547
538
|
});
|
|
539
|
+
} catch {
|
|
540
|
+
// fail-open: un fallo del guardia no bloquea la herramienta.
|
|
548
541
|
}
|
|
549
|
-
}
|
|
550
|
-
"tool.execute.after": async (input: any, output: any) => {
|
|
551
|
-
try {
|
|
552
|
-
const tool = ((input.tool || "") as string).toLowerCase();
|
|
553
|
-
const args = getToolArgs(input, output);
|
|
554
|
-
const command =
|
|
555
|
-
typeof args.command === "string" ? (args.command as string) : "";
|
|
556
|
-
|
|
557
|
-
const payload = {
|
|
558
|
-
event: "tool.execute.after",
|
|
559
|
-
directory,
|
|
560
|
-
tool: input.tool,
|
|
561
|
-
args: input.args || {},
|
|
562
|
-
};
|
|
542
|
+
};
|
|
563
543
|
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
544
|
+
const handleAfter = async (event: any) => {
|
|
545
|
+
try {
|
|
546
|
+
if (!SHELL_TOOLS.has(String(event?.tool ?? "").toLowerCase())) return;
|
|
547
|
+
const command = commandText(event?.input?.command);
|
|
548
|
+
const id = typeof event?.id === "string" ? event.id : "";
|
|
569
549
|
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
config.docsReminderScript,
|
|
573
|
-
);
|
|
574
|
-
if (docsReminderScript) {
|
|
575
|
-
const result = await runScript(
|
|
576
|
-
client,
|
|
577
|
-
directory,
|
|
578
|
-
docsReminderScript,
|
|
579
|
-
{ payload },
|
|
580
|
-
);
|
|
581
|
-
const messages = [
|
|
582
|
-
extractScriptMessage(result.stdout),
|
|
583
|
-
extractScriptMessage(result.stderr),
|
|
584
|
-
].filter(Boolean);
|
|
585
|
-
appendToolOutput(output, messages);
|
|
586
|
-
}
|
|
550
|
+
if (!isWorktreeAddCommand(command)) {
|
|
551
|
+
if (id) pending.delete(id);
|
|
587
552
|
return;
|
|
588
553
|
}
|
|
589
554
|
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
555
|
+
// Un `error` es terminal: la captura de ese ID se retira para no dejar
|
|
556
|
+
// un solapamiento fantasma. El error original no se toca ni se fabrica
|
|
557
|
+
// result. `running`/`background` no son terminales y conservan la
|
|
558
|
+
// captura para el `completed` del mismo ID.
|
|
559
|
+
if (event?.status === "error") {
|
|
560
|
+
if (id) pending.delete(id);
|
|
595
561
|
return;
|
|
596
562
|
}
|
|
597
563
|
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
if (
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
564
|
+
// Sin un result terminal no hay éxito que validar: `running`/`background`
|
|
565
|
+
// (o un error sin result) nunca fabrican setup.
|
|
566
|
+
if (event?.status !== "completed" || !isPlainObject(event.result)) return;
|
|
567
|
+
|
|
568
|
+
const { base, command: commandCwd } = await resolveDirectories(ctx, event);
|
|
569
|
+
const projectDir = base ?? commandCwd;
|
|
570
|
+
const { config, error: configError } = projectDir
|
|
571
|
+
? await readWorktreeConfig(projectDir)
|
|
572
|
+
: { config: defaultConfig(), error: undefined };
|
|
573
|
+
|
|
574
|
+
const capture = id ? pending.get(id) : undefined;
|
|
575
|
+
if (!capture) {
|
|
576
|
+
appendToResult(
|
|
577
|
+
event,
|
|
578
|
+
AMBIGUOUS_MESSAGES(
|
|
579
|
+
"Worktree creation is ambiguous: missing pre-execution inventory. Skipping setup.",
|
|
580
|
+
),
|
|
581
|
+
);
|
|
605
582
|
return;
|
|
606
583
|
}
|
|
584
|
+
|
|
607
585
|
try {
|
|
608
586
|
if (capture.status === "failed") {
|
|
609
|
-
|
|
587
|
+
appendToResult(event, [
|
|
610
588
|
`Worktree pre-inventory capture failed for ${capture.cwd}; skipping worktree setup.`,
|
|
611
589
|
"Run `git worktree list --porcelain -z` from the project root and retry.",
|
|
612
590
|
"Run `git rev-parse --show-toplevel` from the project root and retry.",
|
|
@@ -616,73 +594,74 @@ export const WorktreePlugin: Plugin = async ({ $, client, directory }) => {
|
|
|
616
594
|
}
|
|
617
595
|
|
|
618
596
|
if (capture.status === "capturing") {
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
597
|
+
appendToResult(
|
|
598
|
+
event,
|
|
599
|
+
AMBIGUOUS_MESSAGES(
|
|
600
|
+
"Worktree creation is ambiguous: overlapping candidate commands in the same repository cannot be attributed to a single call. Skipping setup.",
|
|
601
|
+
),
|
|
602
|
+
);
|
|
623
603
|
return;
|
|
624
604
|
}
|
|
625
605
|
|
|
626
606
|
let preList: PorcelainWorktree[];
|
|
627
607
|
try {
|
|
628
608
|
preList = parsePorcelain(capture.pre);
|
|
629
|
-
} catch
|
|
630
|
-
|
|
609
|
+
} catch {
|
|
610
|
+
appendToResult(event, [
|
|
631
611
|
"Worktree inventory is unreadable; skipping worktree setup.",
|
|
632
612
|
"Run `git worktree list --porcelain` from the project root and retry.",
|
|
633
613
|
]);
|
|
634
|
-
await logToOpenCode(client, "error", "Worktree inventory parse failed", {
|
|
635
|
-
error: error instanceof Error ? error.message : String(error),
|
|
636
|
-
});
|
|
637
614
|
return;
|
|
638
615
|
}
|
|
639
616
|
|
|
640
|
-
const exit =
|
|
617
|
+
const exit = event.result?.metadata?.exit;
|
|
641
618
|
if (typeof exit !== "number" || exit !== 0) {
|
|
642
|
-
|
|
619
|
+
appendToResult(event, [
|
|
643
620
|
exit === undefined || exit === null
|
|
644
621
|
? "Worktree command result is ambiguous (missing exit code); skipping worktree setup."
|
|
645
|
-
: `Worktree command did not succeed (exit ${String(exit)}); skipping worktree setup
|
|
646
|
-
|
|
622
|
+
: `Worktree command did not succeed (exit ${String(exit)}); skipping worktree setup.`,
|
|
623
|
+
]);
|
|
647
624
|
return;
|
|
648
625
|
}
|
|
649
626
|
|
|
650
627
|
if (capture.ambiguous) {
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
628
|
+
appendToResult(
|
|
629
|
+
event,
|
|
630
|
+
AMBIGUOUS_MESSAGES(
|
|
631
|
+
"Worktree creation is ambiguous: overlapping candidate commands in the same repository cannot be attributed to a single call. Skipping setup.",
|
|
632
|
+
),
|
|
633
|
+
);
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
if (!commandCwd) {
|
|
638
|
+
appendToResult(event, [
|
|
639
|
+
"Worktree setup omitted: could not resolve the session directory or the command workdir.",
|
|
640
|
+
"The plugin never falls back to its own server cwd. Verify `ctx.session.get` and the command workdir, then retry.",
|
|
654
641
|
]);
|
|
655
642
|
return;
|
|
656
643
|
}
|
|
657
644
|
|
|
658
|
-
const commandCwd = getCommandCwd(args, directory);
|
|
659
645
|
let postRaw: string;
|
|
660
646
|
let gitRoot: string;
|
|
661
647
|
let commonCwd: string;
|
|
662
648
|
let commonDir: string;
|
|
663
649
|
try {
|
|
664
|
-
postRaw =
|
|
665
|
-
|
|
650
|
+
postRaw = await readPorcelain(commandCwd);
|
|
651
|
+
gitRoot = toSlashes(
|
|
652
|
+
(await gitText(["rev-parse", "--show-toplevel"], commandCwd)).trim(),
|
|
666
653
|
);
|
|
667
|
-
gitRoot = String(
|
|
668
|
-
await $`git -C ${commandCwd} rev-parse --show-toplevel`.quiet().text(),
|
|
669
|
-
)
|
|
670
|
-
.trim()
|
|
671
|
-
.replace(/\\/g, "/");
|
|
672
654
|
commonCwd = await readCommonDir(commandCwd);
|
|
673
|
-
commonDir = await readCommonDir(
|
|
655
|
+
commonDir = await readCommonDir(projectDir ?? commandCwd);
|
|
674
656
|
} catch (error) {
|
|
675
657
|
const details = truncateMessage(
|
|
676
658
|
error instanceof Error ? error.message : String(error),
|
|
677
659
|
);
|
|
678
|
-
|
|
660
|
+
appendToResult(event, [
|
|
679
661
|
"Worktree plugin could not process this worktree command.",
|
|
680
662
|
"Run `git rev-parse --show-toplevel` from the project root and retry.",
|
|
681
663
|
...(details ? [`Details: ${details}`] : []),
|
|
682
664
|
]);
|
|
683
|
-
await logToOpenCode(client, "error", "Worktree plugin execution failed", {
|
|
684
|
-
error: error instanceof Error ? error.message : String(error),
|
|
685
|
-
});
|
|
686
665
|
return;
|
|
687
666
|
}
|
|
688
667
|
|
|
@@ -690,26 +669,30 @@ export const WorktreePlugin: Plugin = async ({ $, client, directory }) => {
|
|
|
690
669
|
// were in flight and flagged this capture: re-check after every await
|
|
691
670
|
// and right before attributing effects.
|
|
692
671
|
if (capture.ambiguous) {
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
672
|
+
appendToResult(
|
|
673
|
+
event,
|
|
674
|
+
AMBIGUOUS_MESSAGES(
|
|
675
|
+
"Worktree creation is ambiguous: overlapping candidate commands in the same repository cannot be attributed to a single call. Skipping setup.",
|
|
676
|
+
),
|
|
677
|
+
);
|
|
697
678
|
return;
|
|
698
679
|
}
|
|
699
680
|
|
|
700
681
|
if (!samePath(commonCwd, capture.repo)) {
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
682
|
+
appendToResult(
|
|
683
|
+
event,
|
|
684
|
+
AMBIGUOUS_MESSAGES(
|
|
685
|
+
"Worktree creation is ambiguous: repository identity changed between pre- and post-execution inventory. Skipping setup.",
|
|
686
|
+
),
|
|
687
|
+
);
|
|
705
688
|
return;
|
|
706
689
|
}
|
|
707
690
|
|
|
708
691
|
const toplevel = toSlashes(gitRoot).replace(/\/+$/, "");
|
|
709
692
|
if (!samePath(commonCwd, commonDir)) {
|
|
710
|
-
|
|
693
|
+
appendToResult(event, [
|
|
711
694
|
`Worktree command targets a foreign repository: ${toplevel}. Skipping setup.`,
|
|
712
|
-
`The plugin directory belongs to a different repository (${normalizePath(
|
|
695
|
+
`The plugin directory belongs to a different repository (${normalizePath(projectDir ?? commandCwd)}). Run the command inside the project repository.`,
|
|
713
696
|
]);
|
|
714
697
|
return;
|
|
715
698
|
}
|
|
@@ -717,20 +700,17 @@ export const WorktreePlugin: Plugin = async ({ $, client, directory }) => {
|
|
|
717
700
|
let postList: PorcelainWorktree[];
|
|
718
701
|
try {
|
|
719
702
|
postList = parsePorcelain(postRaw);
|
|
720
|
-
} catch
|
|
721
|
-
|
|
703
|
+
} catch {
|
|
704
|
+
appendToResult(event, [
|
|
722
705
|
"Worktree inventory is unreadable; skipping worktree setup.",
|
|
723
706
|
"Run `git worktree list --porcelain` from the project root and retry.",
|
|
724
707
|
]);
|
|
725
|
-
await logToOpenCode(client, "error", "Worktree inventory parse failed", {
|
|
726
|
-
error: error instanceof Error ? error.message : String(error),
|
|
727
|
-
});
|
|
728
708
|
return;
|
|
729
709
|
}
|
|
730
710
|
|
|
731
711
|
const added = diffNewWorktrees(preList, postList);
|
|
732
712
|
if (added.length !== 1) {
|
|
733
|
-
|
|
713
|
+
appendToResult(event, [
|
|
734
714
|
`Worktree creation is ambiguous: expected 1 new worktree, found ${added.length}. Skipping setup.`,
|
|
735
715
|
"Run `git worktree list --porcelain` to inspect the current inventory.",
|
|
736
716
|
]);
|
|
@@ -740,7 +720,7 @@ export const WorktreePlugin: Plugin = async ({ $, client, directory }) => {
|
|
|
740
720
|
const created = added[0] as PorcelainWorktree;
|
|
741
721
|
const branchName = created.detached ? undefined : created.branch;
|
|
742
722
|
if (!branchName) {
|
|
743
|
-
|
|
723
|
+
appendToResult(event, [
|
|
744
724
|
created.detached
|
|
745
725
|
? "Worktree is detached; skipping setup. Use a branch worktree under <project-root>/worktrees/<branch>."
|
|
746
726
|
: "Worktree branch could not be determined from Git; skipping setup.",
|
|
@@ -751,20 +731,12 @@ export const WorktreePlugin: Plugin = async ({ $, client, directory }) => {
|
|
|
751
731
|
|
|
752
732
|
const absoluteWorktreePath = toSlashes(created.path);
|
|
753
733
|
const projectRoot = toplevel;
|
|
754
|
-
const expectedWorktreePath = resolvePath(
|
|
755
|
-
|
|
756
|
-
`worktrees/${branchName}`,
|
|
757
|
-
);
|
|
758
|
-
const pathContains = toSlashes(
|
|
759
|
-
config.pathContains || "worktrees/",
|
|
760
|
-
).toLowerCase();
|
|
734
|
+
const expectedWorktreePath = resolvePath(projectRoot, `worktrees/${branchName}`);
|
|
735
|
+
const pathContains = toSlashes(config.pathContains || "worktrees/").toLowerCase();
|
|
761
736
|
|
|
762
|
-
const isCanonicalPath = samePath(
|
|
763
|
-
absoluteWorktreePath,
|
|
764
|
-
expectedWorktreePath,
|
|
765
|
-
);
|
|
737
|
+
const isCanonicalPath = samePath(absoluteWorktreePath, expectedWorktreePath);
|
|
766
738
|
if (!isCanonicalPath) {
|
|
767
|
-
|
|
739
|
+
appendToResult(event, [
|
|
768
740
|
`Worktree path is not canonical: ${absoluteWorktreePath}`,
|
|
769
741
|
`Use the project-local path instead: ${expectedWorktreePath}`,
|
|
770
742
|
"Canonical rule: <project-root>/worktrees/<canonical-name> or <project-root>/worktrees/<canonical-name>-prNN.",
|
|
@@ -772,23 +744,24 @@ export const WorktreePlugin: Plugin = async ({ $, client, directory }) => {
|
|
|
772
744
|
}
|
|
773
745
|
|
|
774
746
|
if (configError) {
|
|
775
|
-
|
|
747
|
+
appendToResult(event, [configError]);
|
|
776
748
|
return;
|
|
777
749
|
}
|
|
778
750
|
|
|
779
|
-
if (!isCanonicalPath)
|
|
780
|
-
return;
|
|
781
|
-
}
|
|
751
|
+
if (!isCanonicalPath) return;
|
|
782
752
|
|
|
783
753
|
if (!normalizePath(absoluteWorktreePath).toLowerCase().includes(pathContains)) {
|
|
784
754
|
return;
|
|
785
755
|
}
|
|
786
756
|
|
|
787
|
-
const setupScript = resolveProjectPath(
|
|
757
|
+
const setupScript = resolveProjectPath(projectDir ?? commandCwd, config.setupScript);
|
|
788
758
|
if (setupScript) {
|
|
789
|
-
const setupResult = await runScript(
|
|
759
|
+
const setupResult = await runScript(projectDir ?? commandCwd, setupScript, {
|
|
790
760
|
payload: {
|
|
791
|
-
|
|
761
|
+
event: "tool.execute.after",
|
|
762
|
+
directory: projectDir ?? commandCwd,
|
|
763
|
+
tool: "bash",
|
|
764
|
+
args: { command },
|
|
792
765
|
worktreePath: absoluteWorktreePath,
|
|
793
766
|
worktreeName: branchName,
|
|
794
767
|
branchName,
|
|
@@ -803,14 +776,12 @@ export const WorktreePlugin: Plugin = async ({ $, client, directory }) => {
|
|
|
803
776
|
.filter(Boolean)
|
|
804
777
|
.join("\n\n"),
|
|
805
778
|
);
|
|
806
|
-
|
|
779
|
+
appendToResult(event, [
|
|
807
780
|
`Worktree setup failed for ${branchName}.`,
|
|
808
781
|
setupErrorMessage || "No additional details from setup script.",
|
|
809
782
|
]);
|
|
810
783
|
} else {
|
|
811
|
-
|
|
812
|
-
`Worktree setup complete: ${branchName}`,
|
|
813
|
-
]);
|
|
784
|
+
appendToResult(event, [`Worktree setup complete: ${branchName}`]);
|
|
814
785
|
}
|
|
815
786
|
}
|
|
816
787
|
|
|
@@ -832,25 +803,25 @@ export const WorktreePlugin: Plugin = async ({ $, client, directory }) => {
|
|
|
832
803
|
...reminderLines,
|
|
833
804
|
"--------------------------------------------------",
|
|
834
805
|
].join("\n");
|
|
835
|
-
|
|
806
|
+
appendToResult(event, [banner]);
|
|
836
807
|
}
|
|
837
808
|
return;
|
|
838
809
|
} finally {
|
|
839
|
-
if (pending.get(
|
|
810
|
+
if (pending.get(id) === capture) pending.delete(id);
|
|
840
811
|
}
|
|
841
812
|
} catch (error) {
|
|
842
813
|
const details = truncateMessage(
|
|
843
814
|
error instanceof Error ? error.message : String(error),
|
|
844
815
|
);
|
|
845
|
-
|
|
816
|
+
appendToResult(event, [
|
|
846
817
|
"Worktree plugin could not process this worktree command.",
|
|
847
818
|
"Run `git rev-parse --show-toplevel` from the project root and retry.",
|
|
848
819
|
...(details ? [`Details: ${details}`] : []),
|
|
849
820
|
]);
|
|
850
|
-
await logToOpenCode(client, "error", "Worktree plugin execution failed", {
|
|
851
|
-
error: error instanceof Error ? error.message : String(error),
|
|
852
|
-
});
|
|
853
821
|
}
|
|
854
|
-
}
|
|
855
|
-
|
|
822
|
+
};
|
|
823
|
+
|
|
824
|
+
await ctx.tool.hook("execute.before", handleBefore);
|
|
825
|
+
await ctx.tool.hook("execute.after", handleAfter);
|
|
826
|
+
},
|
|
856
827
|
};
|