pi-orchestrator-config 4.7.2 → 4.8.0

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.
@@ -15,16 +15,23 @@ import path, { join, resolve, dirname } from "node:path";
15
15
  import { supportsAsyncLlm } from "./async-capability.js";
16
16
  import { getSetting } from "./project-settings.js";
17
17
  import { getProjectTmpDir, resolveWorktreeRoot } from "./utils.js";
18
+ import {
19
+ conflictCandidateDirs,
20
+ } from "./enforcement-helpers.js";
18
21
  import {
19
22
  DANGEROUS,
20
23
  getCurrentBranch,
24
+ isInsideGitWorkTree,
21
25
  getMainBranch,
22
26
  getProtectedBranches,
23
27
  getPrMergeStatus,
24
28
  hasGitSub,
25
29
  isBranchAhead,
26
30
  isBranchMerged,
31
+ createsAndResolvesConflict,
32
+ isConflictResolutionCommand,
27
33
  isGitRepo,
34
+ listUnmergedFiles,
28
35
  runGit,
29
36
  } from "./git-helpers.js";
30
37
  import { spawnSync } from "node:child_process";
@@ -561,6 +568,97 @@ export function registerEnforcement(pi: ExtensionAPI, inContainer?: boolean): vo
561
568
  }
562
569
  }
563
570
 
571
+ // Conflict resolution belongs to conflict-resolver. git-expert is routinely
572
+ // pinned to a small model, which cannot judge intent on both sides of a
573
+ // conflict, so the handoff is enforced here rather than left to the prompt.
574
+ // Reads (git status/diff) and `--abort` stay allowed.
575
+ if (process.env.PI_AGENT_NAME === "git-expert" && createsAndResolvesConflict(command)) {
576
+ // The index below is read *before* the command runs, so a command that
577
+ // starts a merge and then stages a side would pass the check and resolve
578
+ // the conflict it just created.
579
+ // error, not warn: the command was refused, not recovered from.
580
+ log.error("conflict_created_and_resolved", "agent", process.env.PI_AGENT_NAME ?? "unknown");
581
+ return {
582
+ block: true,
583
+ reason:
584
+ "\u26d4 This command starts a merge/rebase/cherry-pick and then stages a resolution of it. " +
585
+ "The conflict does not exist yet, so it cannot be checked. Delegate to the conflict-resolver " +
586
+ 'agent (subagent(agent="conflict-resolver")), and let git-expert commit the staged result.',
587
+ };
588
+ }
589
+ if (process.env.PI_AGENT_NAME === "git-expert" && isConflictResolutionCommand(command)) {
590
+ // Which directory does git run in? A conditional branch, a completed
591
+ // subshell, or several invocations in one command have no single answer,
592
+ // and guessing wrong lets a resolution through. So ask every directory the
593
+ // command could reach: block if any is conflicted. Over-blocking costs a
594
+ // message; under-blocking costs the guard.
595
+ const { all, envOverride, dynamicPath } = conflictCandidateDirs(command, ctx.cwd);
596
+ if (dynamicPath) {
597
+ // The shell expands this path at run time, so there is nothing to check.
598
+ return {
599
+ block: true,
600
+ reason:
601
+ `⛔ ${dynamicPath} is expanded by the shell, so the repository it selects cannot be verified. ` +
602
+ "Do not resolve conflicts in git-expert — abort and delegate to the " +
603
+ `conflict-resolver agent (subagent(agent="conflict-resolver")).`,
604
+ };
605
+ }
606
+ if (envOverride) {
607
+ // GIT_DIR/GIT_WORK_TREE can point the index anywhere. Rather than
608
+ // guess which repository it selects, refuse.
609
+ return {
610
+ block: true,
611
+ reason:
612
+ `⛔ ${envOverride} selects the Git repository or index for this command, so the conflict state ` +
613
+ "cannot be verified. Do not resolve conflicts in git-expert — abort and delegate to the " +
614
+ `conflict-resolver agent (subagent(agent="conflict-resolver")).`,
615
+ };
616
+ }
617
+ const conflicted: string[] = [];
618
+ let readable = 0;
619
+ const unreadable: string[] = [];
620
+ for (const dir of all) {
621
+ const lookup = listUnmergedFiles(dir);
622
+ if (!lookup.ok) {
623
+ // An unreadable repository is unknown, not clean — but a directory that
624
+ // is not a repository has no index to be unknown about.
625
+ if (isInsideGitWorkTree(dir)) unreadable.push(dir);
626
+ continue;
627
+ }
628
+ readable++;
629
+ // Paths are repo-relative; the directory names the repository checked.
630
+ for (const file of lookup.files) conflicted.push(dir === ctx.cwd ? file : `${dir} → ${file}`);
631
+ }
632
+ // Any unreadable repository means the command may be staging into a state
633
+ // nobody checked, so it is refused — not excused because another candidate
634
+ // happened to answer.
635
+ if (conflicted.length === 0 && unreadable.length > 0) {
636
+ // A repository whose index will not answer: the conflict state there is
637
+ // unknown, so it is refused. A directory that is not a repository at all
638
+ // has no index to be unknown about and is never in this list.
639
+ // error, not warn: the check that gates this block failed.
640
+ log.error("conflict_lookup_failed", unreadable.join(", "));
641
+ return {
642
+ block: true,
643
+ reason:
644
+ `⛔ Could not read the unmerged index in ${unreadable.join(", ")}, so the conflict state is ` +
645
+ "unknown. Refusing to run a resolution command blind: run `git status` to see where you are, " +
646
+ `or delegate to the conflict-resolver agent (subagent(agent="conflict-resolver")).`,
647
+ };
648
+ }
649
+ if (conflicted.length > 0) {
650
+ log.error("conflict_resolution_blocked", conflicted.join(", "));
651
+ return {
652
+ block: true,
653
+ reason:
654
+ `⛔ Unresolved conflicts in ${conflicted.join(", ")}. Do not resolve them in git-expert — ` +
655
+ `stop, report this message to the caller, and have it delegate to the conflict-resolver ` +
656
+ `agent (subagent(agent="conflict-resolver")). To back out instead: ` +
657
+ `git merge --abort / git rebase --abort / git cherry-pick --abort.`,
658
+ };
659
+ }
660
+ }
661
+
564
662
  // Enforce review loop — block commit unless review state is clean
565
663
  // Use resolveEffectiveCwd to detect the worktree from cd commands (e.g., cd .worktrees/issue-622 && git commit)
566
664
  if (process.env.PI_AGENT_NAME === "git-expert" && hasGitSub(command, "commit")) {