jorgex-stack 1.9.46 → 1.9.48
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/dist/cli.js +0 -15
- package/package.json +1 -1
- package/stack/agents/implementer.md +1 -1
- package/stack/agents/tester.md +1 -1
- package/stack/agents/translator.md +1 -1
- package/stack/config/defaults.json +35 -0
- package/stack/plugins/opencode/worktree.ts +418 -95
- package/stack/scripts/repair-worktree-config.cjs +2 -2
- package/stack/scripts/block-destructive-git.cjs +0 -110
package/dist/cli.js
CHANGED
|
@@ -1401,11 +1401,6 @@ ${gitReadCommands.map((command) => `- \`${command}\``).join("\n")}
|
|
|
1401
1401
|
import path10 from "path";
|
|
1402
1402
|
import fs7 from "fs";
|
|
1403
1403
|
import { isDeepStrictEqual as isDeepStrictEqual2 } from "util";
|
|
1404
|
-
|
|
1405
|
-
// src/lib/git-guard.ts
|
|
1406
|
-
var GIT_GUARD_SCRIPT = "block-destructive-git.cjs";
|
|
1407
|
-
|
|
1408
|
-
// src/adapters/claude-code.ts
|
|
1409
1404
|
function yamlString2(value) {
|
|
1410
1405
|
return JSON.stringify(value);
|
|
1411
1406
|
}
|
|
@@ -1551,16 +1546,6 @@ ${agent.body}`;
|
|
|
1551
1546
|
const tools = toolsFor(agent);
|
|
1552
1547
|
if (tools !== null) lines.push(`tools: ${tools}`);
|
|
1553
1548
|
lines.push(`model: ${resolveAgentModel(models, agent.name, agent.tier).model}`);
|
|
1554
|
-
if (agent.bash === "full") {
|
|
1555
|
-
lines.push(
|
|
1556
|
-
"hooks:",
|
|
1557
|
-
" PreToolUse:",
|
|
1558
|
-
' - matcher: "Bash|PowerShell"',
|
|
1559
|
-
" hooks:",
|
|
1560
|
-
" - type: command",
|
|
1561
|
-
` command: "node \\"{{SCRIPTS_DIR}}/${GIT_GUARD_SCRIPT}\\""`
|
|
1562
|
-
);
|
|
1563
|
-
}
|
|
1564
1549
|
return [
|
|
1565
1550
|
{
|
|
1566
1551
|
file: `${agent.name}.md`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jorgex-stack",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.48",
|
|
4
4
|
"description": "Harness multi-agente portable: instala la config JorgeX (agentes, skills, hooks, Engram, MCPs) en Claude Code, Codex CLI, OpenCode y Pi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -13,7 +13,7 @@ You implement real changes. You don't stop at analysis, you don't answer with ju
|
|
|
13
13
|
|
|
14
14
|
**Mandatory first action**: load the `agent-delegation` skill.
|
|
15
15
|
|
|
16
|
-
**
|
|
16
|
+
**Ask before destructive git** (`reset`, `clean`, `checkout --`, `restore`, `push --force`) — it can discard work or rewrite history. Prefer commit forward; if you need to discard or reset repo state, ask the main agent/orchestrator first and only proceed with explicit approval.
|
|
17
17
|
|
|
18
18
|
**Conditional skill**:
|
|
19
19
|
|
package/stack/agents/tester.md
CHANGED
|
@@ -13,7 +13,7 @@ Your job is to produce the strongest testing evidence for the risk—not to maxi
|
|
|
13
13
|
|
|
14
14
|
**Mandatory first action**: load the `tdd` and `agent-delegation` skills.
|
|
15
15
|
|
|
16
|
-
**
|
|
16
|
+
**Ask before destructive git** (`reset`, `clean`, `checkout --`, `restore`, `push --force`) — it can discard work or rewrite history. Prefer commit forward; if you need to discard or reset repo state, ask the main agent/orchestrator first and only proceed with explicit approval.
|
|
17
17
|
|
|
18
18
|
## Before acting
|
|
19
19
|
|
|
@@ -13,7 +13,7 @@ You handle translations, multi-language text and integration with the project's
|
|
|
13
13
|
|
|
14
14
|
**Mandatory first action**: load the `agent-delegation` skill.
|
|
15
15
|
|
|
16
|
-
**
|
|
16
|
+
**Ask before destructive git** (`reset`, `clean`, `checkout --`, `restore`, `push --force`) — it can discard work or rewrite history. Prefer commit forward; if you need to discard or reset repo state, ask the main agent/orchestrator first and only proceed with explicit approval.
|
|
17
17
|
|
|
18
18
|
**Final output, last of all**: your final report (ending with the Result contract) must be the very last thing you emit. If you need to save anything to memory, do it BEFORE that output — never after.
|
|
19
19
|
|
|
@@ -162,6 +162,41 @@
|
|
|
162
162
|
"git.exe *push*": "ask",
|
|
163
163
|
"*/git.exe *push*": "ask",
|
|
164
164
|
"*\\git.exe *push*": "ask",
|
|
165
|
+
"git *checkout*--*": "ask",
|
|
166
|
+
"*/git *checkout*--*": "ask",
|
|
167
|
+
"git.exe *checkout*--*": "ask",
|
|
168
|
+
"*/git.exe *checkout*--*": "ask",
|
|
169
|
+
"*\\git.exe *checkout*--*": "ask",
|
|
170
|
+
"git *checkout* -f*": "ask",
|
|
171
|
+
"*/git *checkout* -f*": "ask",
|
|
172
|
+
"git.exe *checkout* -f*": "ask",
|
|
173
|
+
"*/git.exe *checkout* -f*": "ask",
|
|
174
|
+
"*\\git.exe *checkout* -f*": "ask",
|
|
175
|
+
"git *restore*": "ask",
|
|
176
|
+
"*/git *restore*": "ask",
|
|
177
|
+
"git.exe *restore*": "ask",
|
|
178
|
+
"*/git.exe *restore*": "ask",
|
|
179
|
+
"*\\git.exe *restore*": "ask",
|
|
180
|
+
"git *switch*--discard-changes*": "ask",
|
|
181
|
+
"*/git *switch*--discard-changes*": "ask",
|
|
182
|
+
"git.exe *switch*--discard-changes*": "ask",
|
|
183
|
+
"*/git.exe *switch*--discard-changes*": "ask",
|
|
184
|
+
"*\\git.exe *switch*--discard-changes*": "ask",
|
|
185
|
+
"git *rebase*": "ask",
|
|
186
|
+
"*/git *rebase*": "ask",
|
|
187
|
+
"git.exe *rebase*": "ask",
|
|
188
|
+
"*/git.exe *rebase*": "ask",
|
|
189
|
+
"*\\git.exe *rebase*": "ask",
|
|
190
|
+
"* git *checkout*--*": "ask",
|
|
191
|
+
"* git.exe *checkout*--*": "ask",
|
|
192
|
+
"* git *checkout* -f*": "ask",
|
|
193
|
+
"* git.exe *checkout* -f*": "ask",
|
|
194
|
+
"* git *restore*": "ask",
|
|
195
|
+
"* git.exe *restore*": "ask",
|
|
196
|
+
"* git *switch*--discard-changes*": "ask",
|
|
197
|
+
"* git.exe *switch*--discard-changes*": "ask",
|
|
198
|
+
"* git *rebase*": "ask",
|
|
199
|
+
"* git.exe *rebase*": "ask",
|
|
165
200
|
"git -c *": "ask",
|
|
166
201
|
"*/git -c *": "ask",
|
|
167
202
|
"git.exe -c *": "ask",
|
|
@@ -284,18 +284,6 @@ const truncateMessage = (value: string, maxLength = 1200) => {
|
|
|
284
284
|
return `${value.slice(0, maxLength)}\n...`;
|
|
285
285
|
};
|
|
286
286
|
|
|
287
|
-
const parseWorktreePath = (command: string) => {
|
|
288
|
-
const match = command.match(
|
|
289
|
-
/git\s+worktree\s+add\s+(?:-[^\s]+\s+)*(?:-b\s+[^\s]+\s+)?(?:"([^"]+)"|'([^']+)'|(\S+))/i,
|
|
290
|
-
);
|
|
291
|
-
return match?.[1] || match?.[2] || match?.[3] || null;
|
|
292
|
-
};
|
|
293
|
-
|
|
294
|
-
const getWorktreeName = (worktreePath: string) => {
|
|
295
|
-
const segments = toSlashes(worktreePath).split("/").filter(Boolean);
|
|
296
|
-
return segments[segments.length - 1] || null;
|
|
297
|
-
};
|
|
298
|
-
|
|
299
287
|
const normalizePath = (value: string) =>
|
|
300
288
|
toSlashes(value).replace(/\/+$/, "");
|
|
301
289
|
|
|
@@ -328,6 +316,132 @@ const isMissingFileError = (error: unknown) => {
|
|
|
328
316
|
);
|
|
329
317
|
};
|
|
330
318
|
|
|
319
|
+
// Pre/post validation against real Git state. The Bash command is only a
|
|
320
|
+
// cheap candidate filter; path, branch and detached state come from `git
|
|
321
|
+
// worktree list --porcelain -z` (NUL-separated fields, NUL-terminated
|
|
322
|
+
// records). Repository identity comes from `git rev-parse
|
|
323
|
+
// --path-format=absolute --git-common-dir`, which is shared by every worktree
|
|
324
|
+
// of one repository: it is the sufficient authority, so sibling linked
|
|
325
|
+
// worktrees are admitted and foreign repositories are rejected.
|
|
326
|
+
const isWorktreeAddCommand = (command: string) =>
|
|
327
|
+
/\bgit\b.*\bworktree\s+add\b/i.test(command);
|
|
328
|
+
|
|
329
|
+
interface PorcelainWorktree {
|
|
330
|
+
path: string;
|
|
331
|
+
branch?: string;
|
|
332
|
+
detached: boolean;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const NUL = String.fromCharCode(0);
|
|
336
|
+
|
|
337
|
+
const parsePorcelain = (raw: string): PorcelainWorktree[] => {
|
|
338
|
+
const text = String(raw);
|
|
339
|
+
if (!text) throw new Error("Empty porcelain inventory.");
|
|
340
|
+
if (!text.endsWith(NUL + NUL)) {
|
|
341
|
+
throw new Error("Truncated porcelain inventory: missing record terminator.");
|
|
342
|
+
}
|
|
343
|
+
const chunks = text.split(NUL + NUL);
|
|
344
|
+
chunks.pop();
|
|
345
|
+
const entries: PorcelainWorktree[] = [];
|
|
346
|
+
const seenBranches = new Set<string>();
|
|
347
|
+
for (const chunk of chunks) {
|
|
348
|
+
if (!chunk) throw new Error("Malformed porcelain inventory: empty record.");
|
|
349
|
+
const fields = chunk.split(NUL);
|
|
350
|
+
const first = fields[0];
|
|
351
|
+
if (typeof first !== "string" || !first.startsWith("worktree ")) {
|
|
352
|
+
throw new Error("Malformed porcelain inventory: record without worktree path.");
|
|
353
|
+
}
|
|
354
|
+
const worktreePath = first.slice("worktree ".length);
|
|
355
|
+
if (!worktreePath) {
|
|
356
|
+
throw new Error("Malformed porcelain inventory: empty worktree path.");
|
|
357
|
+
}
|
|
358
|
+
let head: string | undefined;
|
|
359
|
+
let branch: string | undefined;
|
|
360
|
+
let sawBranch = false;
|
|
361
|
+
let detached = false;
|
|
362
|
+
for (const field of fields.slice(1)) {
|
|
363
|
+
if (field.startsWith("worktree ")) {
|
|
364
|
+
throw new Error("Malformed porcelain inventory: nested worktree record.");
|
|
365
|
+
} else if (field.startsWith("HEAD ")) {
|
|
366
|
+
if (head !== undefined) {
|
|
367
|
+
throw new Error("Malformed porcelain inventory: duplicate HEAD.");
|
|
368
|
+
}
|
|
369
|
+
head = field;
|
|
370
|
+
} else if (field.startsWith("branch ")) {
|
|
371
|
+
if (sawBranch) {
|
|
372
|
+
throw new Error("Malformed porcelain inventory: duplicate branch.");
|
|
373
|
+
}
|
|
374
|
+
sawBranch = true;
|
|
375
|
+
const ref = field.slice("branch ".length).trim();
|
|
376
|
+
const prefix = "refs/heads/";
|
|
377
|
+
if (!ref.startsWith(prefix) || ref.length === prefix.length) {
|
|
378
|
+
throw new Error("Malformed porcelain inventory: malformed branch ref.");
|
|
379
|
+
}
|
|
380
|
+
branch = ref.slice(prefix.length);
|
|
381
|
+
} else if (field === "detached") {
|
|
382
|
+
if (detached) {
|
|
383
|
+
throw new Error("Malformed porcelain inventory: duplicate detached.");
|
|
384
|
+
}
|
|
385
|
+
detached = true;
|
|
386
|
+
}
|
|
387
|
+
// Any other legitimate optional Git field (bare, locked, prunable, ...)
|
|
388
|
+
// is accepted as-is.
|
|
389
|
+
}
|
|
390
|
+
if (head === undefined) {
|
|
391
|
+
throw new Error("Malformed porcelain inventory: record without HEAD.");
|
|
392
|
+
}
|
|
393
|
+
if (branch !== undefined && detached) {
|
|
394
|
+
throw new Error("Malformed porcelain inventory: branch with detached HEAD.");
|
|
395
|
+
}
|
|
396
|
+
if (branch !== undefined) {
|
|
397
|
+
if (seenBranches.has(branch)) {
|
|
398
|
+
throw new Error(`Malformed porcelain inventory: duplicate branch ${branch}.`);
|
|
399
|
+
}
|
|
400
|
+
seenBranches.add(branch);
|
|
401
|
+
}
|
|
402
|
+
entries.push({ path: toSlashes(worktreePath), branch, detached });
|
|
403
|
+
}
|
|
404
|
+
return entries;
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
const diffNewWorktrees = (
|
|
408
|
+
pre: PorcelainWorktree[],
|
|
409
|
+
post: PorcelainWorktree[],
|
|
410
|
+
) => post.filter((postEntry) => !pre.some((preEntry) => samePath(preEntry.path, postEntry.path)));
|
|
411
|
+
|
|
412
|
+
const getCallID = (input: any, output: any) => {
|
|
413
|
+
const value =
|
|
414
|
+
input?.callID ?? input?.callId ?? output?.callID ?? output?.callId;
|
|
415
|
+
return typeof value === "string" && value ? value : undefined;
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
const getToolArgs = (input: any, output: any): Record<string, unknown> => {
|
|
419
|
+
const args = input?.args ?? output?.args;
|
|
420
|
+
return isPlainObject(args) ? args : {};
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
interface PendingCapturing {
|
|
424
|
+
status: "capturing";
|
|
425
|
+
cwd: string;
|
|
426
|
+
ambiguous: boolean;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
interface PendingReadyCapture {
|
|
430
|
+
status: "ready";
|
|
431
|
+
pre: string;
|
|
432
|
+
repo: string;
|
|
433
|
+
cwd: string;
|
|
434
|
+
ambiguous: boolean;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
interface PendingFailedCapture {
|
|
438
|
+
status: "failed";
|
|
439
|
+
cwd: string;
|
|
440
|
+
error: string;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
type PendingCapture = PendingCapturing | PendingReadyCapture | PendingFailedCapture;
|
|
444
|
+
|
|
331
445
|
export const WorktreePlugin: Plugin = async ({ $, client, directory }) => {
|
|
332
446
|
let config: WorktreePluginConfig = {
|
|
333
447
|
pathContains: "worktrees/",
|
|
@@ -371,12 +485,74 @@ export const WorktreePlugin: Plugin = async ({ $, client, directory }) => {
|
|
|
371
485
|
}
|
|
372
486
|
}
|
|
373
487
|
|
|
488
|
+
const pending = new Map<string, PendingCapture>();
|
|
489
|
+
|
|
490
|
+
const readCommonDir = async (cwd: string) =>
|
|
491
|
+
toSlashes(
|
|
492
|
+
String(
|
|
493
|
+
await $`git -C ${cwd} rev-parse --path-format=absolute --git-common-dir`.quiet().text(),
|
|
494
|
+
).trim(),
|
|
495
|
+
);
|
|
496
|
+
|
|
497
|
+
const failCapture = async (callID: string, cwd: string, cause: unknown) => {
|
|
498
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
499
|
+
pending.set(callID, { status: "failed", cwd, error: truncateMessage(message) });
|
|
500
|
+
await logToOpenCode(client, "error", "Worktree pre-inventory failed", {
|
|
501
|
+
error: message,
|
|
502
|
+
});
|
|
503
|
+
};
|
|
504
|
+
|
|
374
505
|
return {
|
|
506
|
+
"tool.execute.before": async (input: any, output: any) => {
|
|
507
|
+
try {
|
|
508
|
+
const tool = String(input?.tool || "").toLowerCase();
|
|
509
|
+
if (tool !== "bash") return;
|
|
510
|
+
const args = getToolArgs(input, output);
|
|
511
|
+
const command = args.command;
|
|
512
|
+
if (typeof command !== "string" || !isWorktreeAddCommand(command)) return;
|
|
513
|
+
const callID = getCallID(input, output);
|
|
514
|
+
if (!callID) return;
|
|
515
|
+
const commandCwd = getCommandCwd(args, directory);
|
|
516
|
+
const provisional: PendingCapturing = { status: "capturing", cwd: commandCwd, ambiguous: false };
|
|
517
|
+
for (const [id, cap] of pending) {
|
|
518
|
+
if (id !== callID) {
|
|
519
|
+
if (cap.status === "capturing" || cap.status === "ready") cap.ambiguous = true;
|
|
520
|
+
provisional.ambiguous = true;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
pending.set(callID, provisional);
|
|
524
|
+
let preRaw: string;
|
|
525
|
+
try {
|
|
526
|
+
preRaw = String(
|
|
527
|
+
await $`git -C ${commandCwd} worktree list --porcelain -z`.quiet().text(),
|
|
528
|
+
);
|
|
529
|
+
} catch (error) {
|
|
530
|
+
await failCapture(callID, commandCwd, error);
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
let repo: string;
|
|
534
|
+
try {
|
|
535
|
+
repo = await readCommonDir(commandCwd);
|
|
536
|
+
if (!isAbsolutePath(repo)) {
|
|
537
|
+
throw new Error(`empty repository identity (common-dir) for ${commandCwd}`);
|
|
538
|
+
}
|
|
539
|
+
} catch (error) {
|
|
540
|
+
await failCapture(callID, commandCwd, error);
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
pending.set(callID, { status: "ready", pre: preRaw, repo, cwd: commandCwd, ambiguous: provisional.ambiguous });
|
|
544
|
+
} catch (error) {
|
|
545
|
+
await logToOpenCode(client, "error", "Worktree plugin execution failed", {
|
|
546
|
+
error: error instanceof Error ? error.message : String(error),
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
},
|
|
375
550
|
"tool.execute.after": async (input: any, output: any) => {
|
|
376
551
|
try {
|
|
377
|
-
const tool = (input.tool || "").toLowerCase();
|
|
378
|
-
const args = input
|
|
379
|
-
const command =
|
|
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) : "";
|
|
380
556
|
|
|
381
557
|
const payload = {
|
|
382
558
|
event: "tool.execute.after",
|
|
@@ -413,107 +589,254 @@ export const WorktreePlugin: Plugin = async ({ $, client, directory }) => {
|
|
|
413
589
|
|
|
414
590
|
if (tool !== "bash") return;
|
|
415
591
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
592
|
+
if (!isWorktreeAddCommand(command)) {
|
|
593
|
+
const stale = getCallID(input, output);
|
|
594
|
+
if (stale) pending.delete(stale);
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
420
597
|
|
|
421
|
-
|
|
598
|
+
const callID = getCallID(input, output);
|
|
599
|
+
const capture = callID ? pending.get(callID) : undefined;
|
|
600
|
+
if (!callID || !capture) {
|
|
601
|
+
appendToolOutput(output, [
|
|
602
|
+
"Worktree creation is ambiguous: missing pre-execution inventory. Skipping setup.",
|
|
603
|
+
"Run `git worktree list --porcelain` to inspect the current inventory.",
|
|
604
|
+
]);
|
|
422
605
|
return;
|
|
423
606
|
}
|
|
607
|
+
try {
|
|
608
|
+
if (capture.status === "failed") {
|
|
609
|
+
appendToolOutput(output, [
|
|
610
|
+
`Worktree pre-inventory capture failed for ${capture.cwd}; skipping worktree setup.`,
|
|
611
|
+
"Run `git worktree list --porcelain -z` from the project root and retry.",
|
|
612
|
+
"Run `git rev-parse --show-toplevel` from the project root and retry.",
|
|
613
|
+
...(capture.error ? [`Details: ${capture.error}`] : []),
|
|
614
|
+
]);
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
if (capture.status === "capturing") {
|
|
619
|
+
appendToolOutput(output, [
|
|
620
|
+
"Worktree creation is ambiguous: overlapping candidate commands in the same repository cannot be attributed to a single call. Skipping setup.",
|
|
621
|
+
"Run `git worktree list --porcelain` to inspect the current inventory.",
|
|
622
|
+
]);
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
let preList: PorcelainWorktree[];
|
|
627
|
+
try {
|
|
628
|
+
preList = parsePorcelain(capture.pre);
|
|
629
|
+
} catch (error) {
|
|
630
|
+
appendToolOutput(output, [
|
|
631
|
+
"Worktree inventory is unreadable; skipping worktree setup.",
|
|
632
|
+
"Run `git worktree list --porcelain` from the project root and retry.",
|
|
633
|
+
]);
|
|
634
|
+
await logToOpenCode(client, "error", "Worktree inventory parse failed", {
|
|
635
|
+
error: error instanceof Error ? error.message : String(error),
|
|
636
|
+
});
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
424
639
|
|
|
425
|
-
|
|
426
|
-
|
|
640
|
+
const exit = output?.metadata?.exit;
|
|
641
|
+
if (typeof exit !== "number" || exit !== 0) {
|
|
642
|
+
const ambiguous =
|
|
643
|
+
exit === undefined || exit === null
|
|
644
|
+
? "Worktree command result is ambiguous (missing exit code); skipping worktree setup."
|
|
645
|
+
: `Worktree command did not succeed (exit ${String(exit)}); skipping worktree setup.`;
|
|
646
|
+
appendToolOutput(output, [ambiguous]);
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
427
649
|
|
|
428
|
-
|
|
429
|
-
|
|
650
|
+
if (capture.ambiguous) {
|
|
651
|
+
appendToolOutput(output, [
|
|
652
|
+
"Worktree creation is ambiguous: overlapping candidate commands in the same repository cannot be attributed to a single call. Skipping setup.",
|
|
653
|
+
"Run `git worktree list --porcelain` to inspect the current inventory.",
|
|
654
|
+
]);
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
430
657
|
|
|
431
|
-
|
|
658
|
+
const commandCwd = getCommandCwd(args, directory);
|
|
659
|
+
let postRaw: string;
|
|
660
|
+
let gitRoot: string;
|
|
661
|
+
let commonCwd: string;
|
|
662
|
+
let commonDir: string;
|
|
663
|
+
try {
|
|
664
|
+
postRaw = String(
|
|
665
|
+
await $`git -C ${commandCwd} worktree list --porcelain -z`.quiet().text(),
|
|
666
|
+
);
|
|
667
|
+
gitRoot = String(
|
|
668
|
+
await $`git -C ${commandCwd} rev-parse --show-toplevel`.quiet().text(),
|
|
669
|
+
)
|
|
670
|
+
.trim()
|
|
671
|
+
.replace(/\\/g, "/");
|
|
672
|
+
commonCwd = await readCommonDir(commandCwd);
|
|
673
|
+
commonDir = await readCommonDir(directory);
|
|
674
|
+
} catch (error) {
|
|
675
|
+
const details = truncateMessage(
|
|
676
|
+
error instanceof Error ? error.message : String(error),
|
|
677
|
+
);
|
|
678
|
+
appendToolOutput(output, [
|
|
679
|
+
"Worktree plugin could not process this worktree command.",
|
|
680
|
+
"Run `git rev-parse --show-toplevel` from the project root and retry.",
|
|
681
|
+
...(details ? [`Details: ${details}`] : []),
|
|
682
|
+
]);
|
|
683
|
+
await logToOpenCode(client, "error", "Worktree plugin execution failed", {
|
|
684
|
+
error: error instanceof Error ? error.message : String(error),
|
|
685
|
+
});
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
432
688
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
689
|
+
// A concurrent candidate may have started while the post reads above
|
|
690
|
+
// were in flight and flagged this capture: re-check after every await
|
|
691
|
+
// and right before attributing effects.
|
|
692
|
+
if (capture.ambiguous) {
|
|
693
|
+
appendToolOutput(output, [
|
|
694
|
+
"Worktree creation is ambiguous: overlapping candidate commands in the same repository cannot be attributed to a single call. Skipping setup.",
|
|
695
|
+
"Run `git worktree list --porcelain` to inspect the current inventory.",
|
|
696
|
+
]);
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
442
699
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
`Use the project-local path instead: ${expectedWorktreePath}`,
|
|
451
|
-
"Canonical rule: <project-root>/worktrees/<canonical-name> or <project-root>/worktrees/<canonical-name>-prNN.",
|
|
452
|
-
]);
|
|
453
|
-
}
|
|
700
|
+
if (!samePath(commonCwd, capture.repo)) {
|
|
701
|
+
appendToolOutput(output, [
|
|
702
|
+
"Worktree creation is ambiguous: repository identity changed between pre- and post-execution inventory. Skipping setup.",
|
|
703
|
+
"Run `git worktree list --porcelain` to inspect the current inventory.",
|
|
704
|
+
]);
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
454
707
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
708
|
+
const toplevel = toSlashes(gitRoot).replace(/\/+$/, "");
|
|
709
|
+
if (!samePath(commonCwd, commonDir)) {
|
|
710
|
+
appendToolOutput(output, [
|
|
711
|
+
`Worktree command targets a foreign repository: ${toplevel}. Skipping setup.`,
|
|
712
|
+
`The plugin directory belongs to a different repository (${normalizePath(directory)}). Run the command inside the project repository.`,
|
|
713
|
+
]);
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
459
716
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
717
|
+
let postList: PorcelainWorktree[];
|
|
718
|
+
try {
|
|
719
|
+
postList = parsePorcelain(postRaw);
|
|
720
|
+
} catch (error) {
|
|
721
|
+
appendToolOutput(output, [
|
|
722
|
+
"Worktree inventory is unreadable; skipping worktree setup.",
|
|
723
|
+
"Run `git worktree list --porcelain` from the project root and retry.",
|
|
724
|
+
]);
|
|
725
|
+
await logToOpenCode(client, "error", "Worktree inventory parse failed", {
|
|
726
|
+
error: error instanceof Error ? error.message : String(error),
|
|
727
|
+
});
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
463
730
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
731
|
+
const added = diffNewWorktrees(preList, postList);
|
|
732
|
+
if (added.length !== 1) {
|
|
733
|
+
appendToolOutput(output, [
|
|
734
|
+
`Worktree creation is ambiguous: expected 1 new worktree, found ${added.length}. Skipping setup.`,
|
|
735
|
+
"Run `git worktree list --porcelain` to inspect the current inventory.",
|
|
736
|
+
]);
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
467
739
|
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
payload: {
|
|
472
|
-
...payload,
|
|
473
|
-
worktreePath: absoluteWorktreePath,
|
|
474
|
-
worktreeName,
|
|
475
|
-
branchName,
|
|
476
|
-
},
|
|
477
|
-
commandArgs: ["-WorktreePath", absoluteWorktreePath],
|
|
478
|
-
activeWorktreePath: absoluteWorktreePath,
|
|
479
|
-
});
|
|
480
|
-
|
|
481
|
-
if (setupResult.exitCode !== 0) {
|
|
482
|
-
const setupErrorMessage = truncateMessage(
|
|
483
|
-
[setupResult.stderr.trim(), setupResult.stdout.trim()]
|
|
484
|
-
.filter(Boolean)
|
|
485
|
-
.join("\n\n"),
|
|
486
|
-
);
|
|
740
|
+
const created = added[0] as PorcelainWorktree;
|
|
741
|
+
const branchName = created.detached ? undefined : created.branch;
|
|
742
|
+
if (!branchName) {
|
|
487
743
|
appendToolOutput(output, [
|
|
488
|
-
|
|
489
|
-
|
|
744
|
+
created.detached
|
|
745
|
+
? "Worktree is detached; skipping setup. Use a branch worktree under <project-root>/worktrees/<branch>."
|
|
746
|
+
: "Worktree branch could not be determined from Git; skipping setup.",
|
|
747
|
+
"Run `git worktree list --porcelain` to inspect the current inventory.",
|
|
490
748
|
]);
|
|
491
|
-
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
const absoluteWorktreePath = toSlashes(created.path);
|
|
753
|
+
const projectRoot = toplevel;
|
|
754
|
+
const expectedWorktreePath = resolvePath(
|
|
755
|
+
projectRoot,
|
|
756
|
+
`worktrees/${branchName}`,
|
|
757
|
+
);
|
|
758
|
+
const pathContains = toSlashes(
|
|
759
|
+
config.pathContains || "worktrees/",
|
|
760
|
+
).toLowerCase();
|
|
761
|
+
|
|
762
|
+
const isCanonicalPath = samePath(
|
|
763
|
+
absoluteWorktreePath,
|
|
764
|
+
expectedWorktreePath,
|
|
765
|
+
);
|
|
766
|
+
if (!isCanonicalPath) {
|
|
492
767
|
appendToolOutput(output, [
|
|
493
|
-
`Worktree
|
|
768
|
+
`Worktree path is not canonical: ${absoluteWorktreePath}`,
|
|
769
|
+
`Use the project-local path instead: ${expectedWorktreePath}`,
|
|
770
|
+
"Canonical rule: <project-root>/worktrees/<canonical-name> or <project-root>/worktrees/<canonical-name>-prNN.",
|
|
494
771
|
]);
|
|
495
772
|
}
|
|
496
|
-
}
|
|
497
773
|
|
|
498
|
-
|
|
499
|
-
|
|
774
|
+
if (configError) {
|
|
775
|
+
appendToolOutput(output, [configError]);
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
if (!isCanonicalPath) {
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
if (!normalizePath(absoluteWorktreePath).toLowerCase().includes(pathContains)) {
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
const setupScript = resolveProjectPath(directory, config.setupScript);
|
|
788
|
+
if (setupScript) {
|
|
789
|
+
const setupResult = await runScript(client, directory, setupScript, {
|
|
790
|
+
payload: {
|
|
791
|
+
...payload,
|
|
792
|
+
worktreePath: absoluteWorktreePath,
|
|
793
|
+
worktreeName: branchName,
|
|
794
|
+
branchName,
|
|
795
|
+
},
|
|
796
|
+
commandArgs: ["-WorktreePath", absoluteWorktreePath],
|
|
797
|
+
activeWorktreePath: absoluteWorktreePath,
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
if (setupResult.exitCode !== 0) {
|
|
801
|
+
const setupErrorMessage = truncateMessage(
|
|
802
|
+
[setupResult.stderr.trim(), setupResult.stdout.trim()]
|
|
803
|
+
.filter(Boolean)
|
|
804
|
+
.join("\n\n"),
|
|
805
|
+
);
|
|
806
|
+
appendToolOutput(output, [
|
|
807
|
+
`Worktree setup failed for ${branchName}.`,
|
|
808
|
+
setupErrorMessage || "No additional details from setup script.",
|
|
809
|
+
]);
|
|
810
|
+
} else {
|
|
811
|
+
appendToolOutput(output, [
|
|
812
|
+
`Worktree setup complete: ${branchName}`,
|
|
813
|
+
]);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
const reminderLines = (config.reminderLines || []).map((line) =>
|
|
500
818
|
replaceToken(
|
|
501
|
-
replaceToken(
|
|
502
|
-
|
|
503
|
-
|
|
819
|
+
replaceToken(
|
|
820
|
+
replaceToken(line, "{worktreeName}", branchName),
|
|
821
|
+
"{worktreePath}",
|
|
822
|
+
absoluteWorktreePath,
|
|
823
|
+
),
|
|
824
|
+
"{branchName}",
|
|
825
|
+
branchName,
|
|
504
826
|
),
|
|
505
|
-
|
|
506
|
-
branchName,
|
|
507
|
-
),
|
|
508
|
-
);
|
|
827
|
+
);
|
|
509
828
|
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
829
|
+
if (reminderLines.length > 0) {
|
|
830
|
+
const banner = [
|
|
831
|
+
"--------------------------------------------------",
|
|
832
|
+
...reminderLines,
|
|
833
|
+
"--------------------------------------------------",
|
|
834
|
+
].join("\n");
|
|
835
|
+
appendToolOutput(output, [banner]);
|
|
836
|
+
}
|
|
837
|
+
return;
|
|
838
|
+
} finally {
|
|
839
|
+
if (pending.get(callID) === capture) pending.delete(callID);
|
|
517
840
|
}
|
|
518
841
|
} catch (error) {
|
|
519
842
|
const details = truncateMessage(
|
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
//
|
|
18
18
|
// Cross-agent (Claude Code / Codex / OpenCode) y cross-platform: node puro, sin deps.
|
|
19
19
|
// Idempotente, INDEPENDIENTE del cwd de escritura (opera con --file sobre rutas absolutas),
|
|
20
|
-
// fail-open y exit 0 SIEMPRE (un guardian nunca debe bloquear una tool)
|
|
21
|
-
//
|
|
20
|
+
// fail-open y exit 0 SIEMPRE (un guardian nunca debe bloquear una tool): un
|
|
21
|
+
// fail-open deja rastro en stderr para ser diagnosticable.
|
|
22
22
|
|
|
23
23
|
const { execFileSync } = require("node:child_process");
|
|
24
24
|
const fs = require("node:fs");
|
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// PreToolUse guard (Claude Code): blocks destructive git commands in the
|
|
3
|
-
// subagents that declare it, leaving the main agent unrestricted. Claude Code
|
|
4
|
-
// has no per-subagent command denylist, so this is the documented mechanism
|
|
5
|
-
// (sub-agents docs -> "Conditional rules with hooks"). Reads the PreToolUse
|
|
6
|
-
// payload from stdin and exits 2 to block if any segment of the command would
|
|
7
|
-
// discard work or rewrite history. Best-effort guardrail, not an adversarial
|
|
8
|
-
// sandbox. Fail-open: any read/parse problem allows the command (exit 0) so the
|
|
9
|
-
// guard can never wedge legitimate work — but it logs why, so a silent no-op is
|
|
10
|
-
// diagnosable.
|
|
11
|
-
|
|
12
|
-
// git global options that sit BETWEEN `git` and the subcommand. Without skipping
|
|
13
|
-
// them, `git -C /repo reset --hard` would slip past a naive `git reset` match.
|
|
14
|
-
const GLOBAL_OPT_WITH_ARG = new Set([
|
|
15
|
-
"-C", "-c", "--git-dir", "--work-tree", "--namespace", "--exec-path", "--super-prefix", "--config-env",
|
|
16
|
-
]);
|
|
17
|
-
|
|
18
|
-
const isForceFlag = (token) =>
|
|
19
|
-
token === "--force" || token === "--force-with-lease" || /^-[a-z]*f[a-z]*$/i.test(token);
|
|
20
|
-
|
|
21
|
-
/** True if a single command segment is a destructive git invocation. */
|
|
22
|
-
function isDestructiveSegment(segment) {
|
|
23
|
-
const tokens = segment.trim().split(/\s+/).filter(Boolean);
|
|
24
|
-
const gitIdx = tokens.indexOf("git");
|
|
25
|
-
if (gitIdx === -1) return false;
|
|
26
|
-
|
|
27
|
-
// Skip leading git global options to reach the real subcommand.
|
|
28
|
-
let i = gitIdx + 1;
|
|
29
|
-
while (i < tokens.length) {
|
|
30
|
-
const token = tokens[i];
|
|
31
|
-
if (token === "--") break;
|
|
32
|
-
if (/^(--git-dir|--work-tree|--namespace|--exec-path|--super-prefix|--config-env)=/.test(token)) { i++; continue; }
|
|
33
|
-
if (GLOBAL_OPT_WITH_ARG.has(token)) { i += 2; continue; } // option + its value
|
|
34
|
-
if (token.startsWith("-")) { i++; continue; } // any other global flag
|
|
35
|
-
break;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const sub = tokens[i];
|
|
39
|
-
const rest = tokens.slice(i + 1);
|
|
40
|
-
if (!sub) return false;
|
|
41
|
-
|
|
42
|
-
// Always destructive: discard worktree/index, delete untracked files.
|
|
43
|
-
if (sub === "reset" || sub === "clean" || sub === "restore") return true;
|
|
44
|
-
// git switch --discard-changes drops local modifications.
|
|
45
|
-
if (sub === "switch") return rest.includes("--discard-changes");
|
|
46
|
-
// git checkout: a branch switch is safe; the discard forms carry "--", a
|
|
47
|
-
// force flag, or a "." pathspec (git checkout . / git checkout HEAD -- .).
|
|
48
|
-
if (sub === "checkout") {
|
|
49
|
-
return rest.includes("--") || rest.includes(".") || rest.some(isForceFlag);
|
|
50
|
-
}
|
|
51
|
-
// git push --force / --force-with-lease / -f (incl. bundled like -fq) or a
|
|
52
|
-
// leading "+" refspec all force-overwrite remote history.
|
|
53
|
-
if (sub === "push") {
|
|
54
|
-
return rest.some((t) => isForceFlag(t) || /^\+/.test(t));
|
|
55
|
-
}
|
|
56
|
-
return false;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function isDestructive(command) {
|
|
60
|
-
// Evaluate each command segment independently so a destructive op anywhere in
|
|
61
|
-
// a compound command is caught (cd x && git reset), without a quoted token in
|
|
62
|
-
// one command (git commit -m "git reset") leaking into another's match.
|
|
63
|
-
return command
|
|
64
|
-
.split(/&&|\|\||[;|\n]/)
|
|
65
|
-
.some((segment) => isDestructiveSegment(segment));
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function extractCommand(payload) {
|
|
69
|
-
const toolInput = payload && payload.tool_input;
|
|
70
|
-
const raw = toolInput && (toolInput.command != null ? toolInput.command : toolInput.script);
|
|
71
|
-
// Codex-style hooks pass argv as an array; normalize like post-pr-review.cjs.
|
|
72
|
-
if (Array.isArray(raw)) return raw.join(" ");
|
|
73
|
-
return typeof raw === "string" ? raw : "";
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function allow(reason) {
|
|
77
|
-
if (reason) process.stderr.write(`block-destructive-git: ${reason} (fail-open, allowing)\n`);
|
|
78
|
-
process.exit(0);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
let input = "";
|
|
82
|
-
process.stdin.on("data", (chunk) => {
|
|
83
|
-
input += chunk;
|
|
84
|
-
});
|
|
85
|
-
process.stdin.on("error", () => allow("stdin error"));
|
|
86
|
-
process.stdin.on("end", () => {
|
|
87
|
-
let command = "";
|
|
88
|
-
try {
|
|
89
|
-
command = extractCommand(JSON.parse(input || "{}"));
|
|
90
|
-
} catch {
|
|
91
|
-
allow("unreadable payload");
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
if (command.trim() === "") process.exit(0);
|
|
95
|
-
|
|
96
|
-
if (isDestructive(command)) {
|
|
97
|
-
process.stderr.write(
|
|
98
|
-
"Blocked: destructive git is not allowed in this subagent " +
|
|
99
|
-
"(reset / clean / checkout discard / restore / switch --discard-changes / push --force). " +
|
|
100
|
-
"Don't discard or rewrite repo state — commit forward, or stop and ask the user.\n",
|
|
101
|
-
);
|
|
102
|
-
process.exit(2);
|
|
103
|
-
}
|
|
104
|
-
process.exit(0);
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
// Safety nets so the guard can never hang the tool call: resume the stream (some
|
|
108
|
-
// shells need it to emit EOF) and bail out fail-open if stdin never ends.
|
|
109
|
-
process.stdin.resume();
|
|
110
|
-
setTimeout(() => allow("stdin timeout"), 2000).unref();
|