jorgex-stack 1.9.46 → 1.9.47

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jorgex-stack",
3
- "version": "1.9.46",
3
+ "version": "1.9.47",
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",
@@ -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.args || {};
379
- const command = args.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
- const commandLower = command.toLowerCase();
417
- const pathContains = toSlashes(
418
- config.pathContains || "worktrees/",
419
- ).toLowerCase();
592
+ if (!isWorktreeAddCommand(command)) {
593
+ const stale = getCallID(input, output);
594
+ if (stale) pending.delete(stale);
595
+ return;
596
+ }
420
597
 
421
- if (!commandLower.includes("git worktree add")) {
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
- const parsedWorktreePath = parseWorktreePath(command);
426
- if (!parsedWorktreePath) return;
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
- const worktreeName = getWorktreeName(parsedWorktreePath);
429
- if (!worktreeName) return;
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
- const branchName = worktreeName;
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
- // .quiet() suppresses stderr to prevent noisy TUI logs from git calls
434
- const gitRoot = await $`git rev-parse --show-toplevel`.quiet().text();
435
- const projectRoot = String(gitRoot).trim().replace(/\\/g, "/");
436
- const commandCwd = getCommandCwd(args, directory);
437
- const absoluteWorktreePath = resolvePath(commandCwd, parsedWorktreePath);
438
- const expectedWorktreePath = joinProjectPath(
439
- projectRoot,
440
- `worktrees/${worktreeName}`,
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
- const isCanonicalPath = samePath(
444
- absoluteWorktreePath,
445
- expectedWorktreePath,
446
- );
447
- if (!isCanonicalPath) {
448
- appendToolOutput(output, [
449
- `Worktree path is not canonical: ${absoluteWorktreePath}`,
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
- if (configError) {
456
- appendToolOutput(output, [configError]);
457
- return;
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
- if (!isCanonicalPath) {
461
- return;
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
- if (!normalizePath(absoluteWorktreePath).toLowerCase().includes(pathContains)) {
465
- return;
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
- const setupScript = resolveProjectPath(directory, config.setupScript);
469
- if (setupScript) {
470
- const setupResult = await runScript(client, directory, setupScript, {
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
- `Worktree setup failed for ${worktreeName}.`,
489
- setupErrorMessage || "No additional details from setup script.",
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
- } else {
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 setup complete: ${worktreeName}`,
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
- const reminderLines = (config.reminderLines || []).map((line) =>
499
- replaceToken(
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(line, "{worktreeName}", worktreeName),
502
- "{worktreePath}",
503
- absoluteWorktreePath,
819
+ replaceToken(
820
+ replaceToken(line, "{worktreeName}", branchName),
821
+ "{worktreePath}",
822
+ absoluteWorktreePath,
823
+ ),
824
+ "{branchName}",
825
+ branchName,
504
826
  ),
505
- "{branchName}",
506
- branchName,
507
- ),
508
- );
827
+ );
509
828
 
510
- if (reminderLines.length > 0) {
511
- const banner = [
512
- "--------------------------------------------------",
513
- ...reminderLines,
514
- "--------------------------------------------------",
515
- ].join("\n");
516
- appendToolOutput(output, [banner]);
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(