@ilya-lesikov/pi-pi 0.10.0 → 0.12.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.
Files changed (61) hide show
  1. package/3p/pi-subagents/package.json +0 -1
  2. package/3p/pi-subagents/src/agent-manager.ts +1 -0
  3. package/3p/pi-subagents/src/index.ts +1 -0
  4. package/3p/pi-subagents/src/types.ts +8 -0
  5. package/extensions/orchestrator/agents/advisor.ts +2 -1
  6. package/extensions/orchestrator/agents/code-reviewer.ts +1 -0
  7. package/extensions/orchestrator/agents/constraints.test.ts +43 -10
  8. package/extensions/orchestrator/agents/constraints.ts +26 -5
  9. package/extensions/orchestrator/agents/deep-debugger.ts +7 -6
  10. package/extensions/orchestrator/agents/explore.ts +1 -1
  11. package/extensions/orchestrator/agents/librarian.ts +1 -1
  12. package/extensions/orchestrator/agents/planner.ts +1 -1
  13. package/extensions/orchestrator/agents/prompts.test.ts +135 -1
  14. package/extensions/orchestrator/agents/reviewer.ts +2 -2
  15. package/extensions/orchestrator/agents/task.ts +6 -2
  16. package/extensions/orchestrator/agents/tool-routing.ts +22 -6
  17. package/extensions/orchestrator/assumptions.test.ts +77 -0
  18. package/extensions/orchestrator/assumptions.ts +85 -0
  19. package/extensions/orchestrator/cbm.ts +4 -1
  20. package/extensions/orchestrator/cbm.which.test.ts +92 -0
  21. package/extensions/orchestrator/command-handlers.ts +9 -1
  22. package/extensions/orchestrator/config.test.ts +1 -3
  23. package/extensions/orchestrator/config.ts +3 -4
  24. package/extensions/orchestrator/context.test.ts +2 -7
  25. package/extensions/orchestrator/context.ts +3 -3
  26. package/extensions/orchestrator/doctor.test.ts +35 -0
  27. package/extensions/orchestrator/doctor.ts +4 -2
  28. package/extensions/orchestrator/event-handlers.more.test.ts +185 -0
  29. package/extensions/orchestrator/event-handlers.test.ts +22 -2
  30. package/extensions/orchestrator/event-handlers.ts +156 -18
  31. package/extensions/orchestrator/flant-infra.more.test.ts +55 -0
  32. package/extensions/orchestrator/flant-infra.test.ts +0 -3
  33. package/extensions/orchestrator/flant-infra.ts +17 -7
  34. package/extensions/orchestrator/integration.test.ts +355 -173
  35. package/extensions/orchestrator/orchestrator.ts +3 -9
  36. package/extensions/orchestrator/phases/brainstorm.test.ts +25 -7
  37. package/extensions/orchestrator/phases/brainstorm.ts +35 -71
  38. package/extensions/orchestrator/phases/implementation.test.ts +22 -0
  39. package/extensions/orchestrator/phases/implementation.ts +6 -0
  40. package/extensions/orchestrator/phases/machine.test.ts +0 -28
  41. package/extensions/orchestrator/phases/machine.ts +0 -38
  42. package/extensions/orchestrator/phases/planning.test.ts +27 -1
  43. package/extensions/orchestrator/phases/planning.ts +1 -1
  44. package/extensions/orchestrator/phases/review-task.test.ts +7 -0
  45. package/extensions/orchestrator/phases/review-task.ts +1 -1
  46. package/extensions/orchestrator/phases/review.test.ts +47 -10
  47. package/extensions/orchestrator/phases/review.ts +36 -25
  48. package/extensions/orchestrator/pp-menu.from.test.ts +65 -0
  49. package/extensions/orchestrator/pp-menu.leaves.test.ts +17 -0
  50. package/extensions/orchestrator/pp-menu.test.ts +106 -49
  51. package/extensions/orchestrator/pp-menu.ts +280 -134
  52. package/extensions/orchestrator/pp-state-tools.ts +1 -1
  53. package/extensions/orchestrator/rate-limit-fallback-flow.test.ts +2 -2
  54. package/extensions/orchestrator/rate-limit-fallback.ts +1 -1
  55. package/extensions/orchestrator/state.test.ts +17 -17
  56. package/extensions/orchestrator/state.ts +35 -12
  57. package/package.json +6 -3
  58. package/scripts/lib/smoke-resolve.mjs +99 -0
  59. package/scripts/postinstall.mjs +93 -0
  60. package/scripts/test-package.sh +62 -0
  61. package/scripts/postinstall.sh +0 -18
@@ -365,6 +365,109 @@ describe("checkoutPrHead additional branches", () => {
365
365
  expect(result.message).toContain("no PR head commit provided");
366
366
  expect(exec).toHaveBeenCalledTimes(1);
367
367
  });
368
+
369
+ it("creates a tracking branch and switches when the local branch is missing", async () => {
370
+ // HEAD is asked twice: initial (not on oid) then post-switch (on oid).
371
+ let headCalls = 0;
372
+ const exec = vi.fn(async (_cmd: string, args: string[]) => {
373
+ if (args[0] === "status") return { code: 0, stdout: "", stderr: "" };
374
+ if (args[0] === "rev-parse" && args[1] === "HEAD") return { code: 0, stdout: headCalls++ === 0 ? "othersha" : "abc123", stderr: "" };
375
+ if (args[0] === "rev-parse" && args.includes("--abbrev-ref")) return { code: 0, stdout: "main", stderr: "" };
376
+ if (args[0] === "fetch") return { code: 0, stdout: "", stderr: "" };
377
+ if (args[0] === "rev-parse" && args.includes("refs/remotes/origin/feature")) return { code: 0, stdout: "abc123", stderr: "" };
378
+ if (args[0] === "rev-parse" && args.includes("refs/heads/feature")) return { code: 1, stdout: "", stderr: "" };
379
+ return { code: 0, stdout: "", stderr: "" };
380
+ });
381
+ const result = await checkoutPrHead(orchWithExec(exec), "/repo", "feature", "abc123");
382
+ expect(result.ok).toBe(true);
383
+ expect(result.message).toContain('switched to PR head branch "feature"');
384
+ const createdTracking = exec.mock.calls.some((c: any[]) => c[1][0] === "checkout" && c[1].includes("-b"));
385
+ expect(createdTracking).toBe(true);
386
+ });
387
+
388
+ it("HALTs when the PR head branch cannot be fetched from origin (fork PR)", async () => {
389
+ let headCalls = 0;
390
+ const exec = vi.fn(async (_cmd: string, args: string[]) => {
391
+ if (args[0] === "status") return { code: 0, stdout: "", stderr: "" };
392
+ if (args[0] === "rev-parse" && args[1] === "HEAD") return { code: 0, stdout: headCalls++ === 0 ? "othersha" : "abc123", stderr: "" };
393
+ if (args[0] === "rev-parse" && args.includes("--abbrev-ref")) return { code: 0, stdout: "main", stderr: "" };
394
+ if (args[0] === "fetch") return { code: 128, stdout: "", stderr: "couldn't find remote ref" };
395
+ return { code: 0, stdout: "", stderr: "" };
396
+ });
397
+ const result = await checkoutPrHead(orchWithExec(exec), "/repo", "feature", "abc123");
398
+ expect(result.ok).toBe(false);
399
+ expect(result.message).toContain("HALT");
400
+ expect(result.message).toContain("could not fetch PR head branch");
401
+ });
402
+
403
+ it("HALTs when the fetched origin tip does not match the advertised oid", async () => {
404
+ let headCalls = 0;
405
+ const exec = vi.fn(async (_cmd: string, args: string[]) => {
406
+ if (args[0] === "status") return { code: 0, stdout: "", stderr: "" };
407
+ if (args[0] === "rev-parse" && args[1] === "HEAD") return { code: 0, stdout: headCalls++ === 0 ? "othersha" : "abc123", stderr: "" };
408
+ if (args[0] === "rev-parse" && args.includes("--abbrev-ref")) return { code: 0, stdout: "main", stderr: "" };
409
+ if (args[0] === "fetch") return { code: 0, stdout: "", stderr: "" };
410
+ if (args[0] === "rev-parse" && args.includes("refs/remotes/origin/feature")) return { code: 0, stdout: "differentsha", stderr: "" };
411
+ return { code: 0, stdout: "", stderr: "" };
412
+ });
413
+ const result = await checkoutPrHead(orchWithExec(exec), "/repo", "feature", "abc123");
414
+ expect(result.ok).toBe(false);
415
+ expect(result.message).toContain("HALT");
416
+ expect(result.message).toContain("not the advertised PR head");
417
+ });
418
+
419
+ it("fast-forwards and switches an existing local branch that is a safe ancestor of the PR head", async () => {
420
+ let headCalls = 0;
421
+ const exec = vi.fn(async (_cmd: string, args: string[]) => {
422
+ if (args[0] === "status") return { code: 0, stdout: "", stderr: "" };
423
+ if (args[0] === "rev-parse" && args[1] === "HEAD") return { code: 0, stdout: headCalls++ === 0 ? "othersha" : "abc123", stderr: "" };
424
+ if (args[0] === "rev-parse" && args.includes("--abbrev-ref")) return { code: 0, stdout: "main", stderr: "" };
425
+ if (args[0] === "fetch") return { code: 0, stdout: "", stderr: "" };
426
+ if (args[0] === "rev-parse" && args.includes("refs/remotes/origin/feature")) return { code: 0, stdout: "abc123", stderr: "" };
427
+ if (args[0] === "rev-parse" && args.includes("refs/heads/feature")) return { code: 0, stdout: "localtip", stderr: "" };
428
+ if (args[0] === "merge-base" && args.includes("--is-ancestor")) return { code: 0, stdout: "", stderr: "" };
429
+ return { code: 0, stdout: "", stderr: "" };
430
+ });
431
+ const result = await checkoutPrHead(orchWithExec(exec), "/repo", "feature", "abc123");
432
+ expect(result.ok).toBe(true);
433
+ expect(result.message).toContain('switched to PR head branch "feature"');
434
+ // Ancestry was proven BEFORE the checkout.
435
+ const order = exec.mock.calls.map((c: any[]) => c[1].join(" "));
436
+ const ancestorIdx = order.findIndex((c: string) => c.includes("merge-base --is-ancestor"));
437
+ const checkoutIdx = order.findIndex((c: string) => c.startsWith("checkout feature"));
438
+ expect(ancestorIdx).toBeGreaterThanOrEqual(0);
439
+ expect(checkoutIdx).toBeGreaterThan(ancestorIdx);
440
+ });
441
+
442
+ it("HALTs on a diverged local branch WITHOUT checking it out (never switches then fails)", async () => {
443
+ const exec = vi.fn(async (_cmd: string, args: string[]) => {
444
+ if (args[0] === "status") return { code: 0, stdout: "", stderr: "" };
445
+ if (args[0] === "rev-parse" && args[1] === "HEAD") return { code: 0, stdout: "othersha", stderr: "" };
446
+ if (args[0] === "rev-parse" && args.includes("--abbrev-ref")) return { code: 0, stdout: "main", stderr: "" };
447
+ if (args[0] === "fetch") return { code: 0, stdout: "", stderr: "" };
448
+ if (args[0] === "rev-parse" && args.includes("refs/remotes/origin/feature")) return { code: 0, stdout: "abc123", stderr: "" };
449
+ if (args[0] === "rev-parse" && args.includes("refs/heads/feature")) return { code: 0, stdout: "divergedtip", stderr: "" };
450
+ if (args[0] === "merge-base" && args.includes("--is-ancestor")) return { code: 1, stdout: "", stderr: "" };
451
+ return { code: 0, stdout: "", stderr: "" };
452
+ });
453
+ const result = await checkoutPrHead(orchWithExec(exec), "/repo", "feature", "abc123");
454
+ expect(result.ok).toBe(false);
455
+ expect(result.message).toContain("HALT");
456
+ expect(result.message).toContain("has diverged");
457
+ // The critical guarantee: no checkout was issued — the user stays on their branch.
458
+ const checkedOut = exec.mock.calls.some((c: any[]) => c[1][0] === "checkout");
459
+ expect(checkedOut).toBe(false);
460
+ });
461
+
462
+ it("still HALTs on a dirty tree regardless of branch", async () => {
463
+ const exec = vi.fn(async (_cmd: string, args: string[]) => {
464
+ if (args[0] === "status") return { code: 0, stdout: " M file.ts", stderr: "" };
465
+ return { code: 0, stdout: "", stderr: "" };
466
+ });
467
+ const result = await checkoutPrHead(orchWithExec(exec), "/repo", "feature", "abc123");
468
+ expect(result.ok).toBe(false);
469
+ expect(result.message).toContain("uncommitted changes");
470
+ });
368
471
  });
369
472
 
370
473
  describe("registered handler branches", () => {
@@ -428,6 +531,33 @@ describe("registered handler branches", () => {
428
531
  }
429
532
  });
430
533
 
534
+ it("marks a phased-batch agent's record consumed to suppress the vendor nudge; leaves free agents alone", () => {
535
+ const records = new Map<string, { resultConsumed?: boolean }>();
536
+ const managerKey = Symbol.for("pi-subagents:manager");
537
+ (globalThis as any)[managerKey] = { getRecord: (id: string) => records.get(id) };
538
+ try {
539
+ // Phased batch: the record IS marked consumed (nudge suppressed).
540
+ records.set("agent-p", {});
541
+ orchestrator.active = makeActiveTask();
542
+ orchestrator.active.state.step = "await_reviewers";
543
+ orchestrator.spawnedAgentIds.add("agent-p");
544
+ orchestrator.agentDescriptions.set("agent-p", "reviewer gpt");
545
+ getEventHandler("subagents:completed")({ id: "agent-p", description: "reviewer gpt" }, {});
546
+ expect(records.get("agent-p")!.resultConsumed).toBe(true);
547
+
548
+ // Free (non-phased) agent: the record is NOT touched (keeps its nudge).
549
+ records.set("agent-free", {});
550
+ orchestrator.active = makeActiveTask();
551
+ orchestrator.active.state.step = "llm_work";
552
+ orchestrator.spawnedAgentIds.add("agent-free");
553
+ orchestrator.agentDescriptions.set("agent-free", "advisor gpt");
554
+ getEventHandler("subagents:completed")({ id: "agent-free", description: "advisor gpt" }, {});
555
+ expect(records.get("agent-free")!.resultConsumed).toBeUndefined();
556
+ } finally {
557
+ delete (globalThis as any)[managerKey];
558
+ }
559
+ });
560
+
431
561
  it("cleans up a stopped subagent without emitting an error", () => {
432
562
  orchestrator.active = makeActiveTask();
433
563
  orchestrator.spawnedAgentIds.add("agent-1");
@@ -528,6 +658,7 @@ describe("pp_phase_complete tool", () => {
528
658
  orchestrator.active.state.phase = "plan";
529
659
  orchestrator.active.state.step = "llm_work";
530
660
  orchestrator.active.state.mode = "autonomous";
661
+ orchestrator.active.state.reconciledPhase = "plan";
531
662
  const transitionSpy = vi.fn(async () => ({ ok: true as const }));
532
663
  orchestrator.transitionToNextPhase = transitionSpy;
533
664
  registerOrchestratorToolsForTest(orchestrator);
@@ -542,6 +673,7 @@ describe("pp_phase_complete tool", () => {
542
673
  orchestrator.active.state.phase = "plan";
543
674
  orchestrator.active.state.step = "llm_work";
544
675
  orchestrator.active.state.mode = "autonomous";
676
+ orchestrator.active.state.reconciledPhase = "plan";
545
677
  orchestrator.transitionToNextPhase = async () => ({ ok: false, error: "boom" });
546
678
  registerOrchestratorToolsForTest(orchestrator);
547
679
  const tool = getTool("pp_phase_complete");
@@ -553,9 +685,62 @@ describe("pp_phase_complete tool", () => {
553
685
  orchestrator.active = makeActiveTask();
554
686
  orchestrator.active.state.phase = "implement";
555
687
  orchestrator.active.state.step = "llm_work";
688
+ orchestrator.active.state.reconciledPhase = "implement";
689
+ registerOrchestratorToolsForTest(orchestrator);
690
+ const tool = getTool("pp_phase_complete");
691
+ const result = await tool.execute("id", { summary: "s" }, undefined, undefined, ctxWithUi());
692
+ expect(result.content[0].text).toBe("MENU_RESULT");
693
+ });
694
+
695
+ it("prompts to reconcile on the first call of a phase, then proceeds on the re-call", async () => {
696
+ orchestrator.active = makeActiveTask();
697
+ orchestrator.active.state.phase = "implement";
698
+ orchestrator.active.state.step = "llm_work";
699
+ const transitionSpy = vi.fn(async () => ({ ok: true as const }));
700
+ orchestrator.transitionToNextPhase = transitionSpy;
556
701
  registerOrchestratorToolsForTest(orchestrator);
557
702
  const tool = getTool("pp_phase_complete");
703
+
704
+ const first = await tool.execute("id", { summary: "s" }, undefined, undefined, ctxWithUi());
705
+ expect(first.content[0].text).toContain("reconcile the task's state files");
706
+ expect(first.isError).toBeUndefined();
707
+ expect(transitionSpy).not.toHaveBeenCalled();
708
+ expect(orchestrator.active.state.phase).toBe("implement");
709
+ expect(orchestrator.active.state.reviewCycle).toBeNull();
710
+
711
+ const second = await tool.execute("id", { summary: "s" }, undefined, undefined, ctxWithUi());
712
+ expect(second.content[0].text).toBe("MENU_RESULT");
713
+ });
714
+
715
+ it("an autonomous run reconciles once then advances on the immediate re-call (no loop)", async () => {
716
+ orchestrator.active = makeActiveTask();
717
+ orchestrator.active.state.phase = "plan";
718
+ orchestrator.active.state.step = "llm_work";
719
+ orchestrator.active.state.mode = "autonomous";
720
+ const transitionSpy = vi.fn(async () => ({ ok: true as const }));
721
+ orchestrator.transitionToNextPhase = transitionSpy;
722
+ registerOrchestratorToolsForTest(orchestrator);
723
+ const tool = getTool("pp_phase_complete");
724
+
725
+ const first = await tool.execute("id", { summary: "s" }, undefined, undefined, ctxWithUi());
726
+ expect(first.content[0].text).toContain("reconcile the task's state files");
727
+ expect(transitionSpy).not.toHaveBeenCalled();
728
+
729
+ const second = await tool.execute("id", { summary: "s" }, undefined, undefined, ctxWithUi());
730
+ expect(second.content[0].text).toBe("");
731
+ expect(transitionSpy).toHaveBeenCalledTimes(1);
732
+ });
733
+
734
+ it("never gates a quick-phase task", async () => {
735
+ orchestrator.active = makeActiveTask();
736
+ orchestrator.active.type = "quick";
737
+ orchestrator.active.state.phase = "quick";
738
+ orchestrator.active.state.step = "llm_work";
739
+ registerOrchestratorToolsForTest(orchestrator);
740
+ const tool = getTool("pp_phase_complete");
741
+
558
742
  const result = await tool.execute("id", { summary: "s" }, undefined, undefined, ctxWithUi());
743
+ expect(result.content[0].text).not.toContain("reconcile the task's state files");
559
744
  expect(result.content[0].text).toBe("MENU_RESULT");
560
745
  });
561
746
  });
@@ -615,16 +615,36 @@ describe("checkoutPrHead", () => {
615
615
  expect(calls.some((c) => c[0] === "checkout")).toBe(false);
616
616
  });
617
617
 
618
- it("HALTs when on a different branch, and never runs git checkout", async () => {
618
+ it("switches to the PR head branch (clean tree, different branch) after fetch+verify", async () => {
619
+ const { orchestrator, calls } = makeGitOrchestrator({
620
+ "status --porcelain": { code: 0, stdout: "" },
621
+ "rev-parse HEAD": [{ code: 0, stdout: "othersha\n" }, { code: 0, stdout: "abc123\n" }],
622
+ "rev-parse --abbrev-ref HEAD": { code: 0, stdout: "main\n" },
623
+ "fetch origin": { code: 0, stdout: "" },
624
+ "rev-parse --verify refs/remotes/origin/feature": { code: 0, stdout: "abc123\n" },
625
+ "rev-parse --verify --quiet refs/heads/feature": { code: 0, stdout: "localsha\n" },
626
+ "checkout feature": { code: 0, stdout: "" },
627
+ "merge --ff-only abc123": { code: 0, stdout: "Updating localsha..abc123\n" },
628
+ });
629
+ const result = await checkoutPrHead(orchestrator, "/repo", "feature", "abc123");
630
+ expect(result.ok).toBe(true);
631
+ expect(result.message).toContain('switched to PR head branch "feature"');
632
+ expect(calls.some((c) => c[0] === "checkout" && c[1] === "feature")).toBe(true);
633
+ expect(calls.some((c) => c[0] === "fetch")).toBe(true);
634
+ });
635
+
636
+ it("HALTs on a different branch when the fetched tip does not match the advertised oid", async () => {
619
637
  const { orchestrator, calls } = makeGitOrchestrator({
620
638
  "status --porcelain": { code: 0, stdout: "" },
621
639
  "rev-parse HEAD": { code: 0, stdout: "othersha\n" },
622
640
  "rev-parse --abbrev-ref HEAD": { code: 0, stdout: "main\n" },
641
+ "fetch origin": { code: 0, stdout: "" },
642
+ "rev-parse --verify refs/remotes/origin/feature": { code: 0, stdout: "differentsha\n" },
623
643
  });
624
644
  const result = await checkoutPrHead(orchestrator, "/repo", "feature", "abc123");
625
645
  expect(result.ok).toBe(false);
626
646
  expect(result.message).toContain("HALT");
627
- expect(result.message).toContain("feature");
647
+ expect(result.message).toContain("not the advertised PR head");
628
648
  expect(calls.some((c) => c[0] === "checkout")).toBe(false);
629
649
  expect(calls.some((c) => c[0] === "merge")).toBe(false);
630
650
  });
@@ -16,8 +16,8 @@ import {
16
16
  getArtifactManifest,
17
17
  loadPhaseReviewOutputs,
18
18
  } from "./context.js";
19
- import { PRINCIPLES_BLOCK, toolsBlock, identityBlock, delegationBlock } from "./agents/tool-routing.js";
20
- import { constraintsBlock, phaseConstraint } from "./agents/constraints.js";
19
+ import { PRINCIPLES_BLOCK, IMPLEMENTATION_PRINCIPLES_BLOCK, toolsBlock, identityBlock, delegationBlock } from "./agents/tool-routing.js";
20
+ import { constraintsBlock, phaseConstraint, isReadOnlyPhase } from "./agents/constraints.js";
21
21
  import { registerCbmTools } from "./cbm.js";
22
22
  import { registerExaTools } from "./exa.js";
23
23
  import { registerAstSearchTool } from "./ast-search.js";
@@ -564,11 +564,15 @@ function registerOrchestratorTools(orchestrator: Orchestrator): void {
564
564
  // Extension-side PR-head checkout for the review phase. Performed here (never via the
565
565
  // agent prompt) so REVIEW_READONLY_CONSTRAINT stays truthful — the agent never runs
566
566
  // `git checkout` itself. Lands the repo on its PR head under strict safety rules (no stash,
567
- // no --force, no branch switch, no worktree, no detached checkout): when the tree is clean
568
- // and on the PR head's branch but merely behind, it fast-forwards the branch to the head
569
- // (`git merge --ff-only`, which never detaches, never rewrites, and refuses a non-ff move).
570
- // A dirty tree, a different branch, or a non-fast-forwardable divergence all HALT with a
571
- // message for the user. Checking HEAD's SHA first makes this re-entrant and detached-safe.
567
+ // no --force, no worktree, no detached checkout): when the tree is clean and on the PR head's
568
+ // branch but merely behind, it fast-forwards the branch to the head (`git merge --ff-only`,
569
+ // which never detaches, never rewrites, and refuses a non-ff move). When the tree is clean but
570
+ // on a DIFFERENT branch, it fetches the PR head ref from origin (forced refspec into the
571
+ // remote-tracking ref only), verifies the fetched tip equals the advertised oid, then fast-
572
+ // forwards an existing local branch or creates a tracking branch at the oid and checks it out.
573
+ // A dirty tree, a detached HEAD, a fork/unfetchable ref, an oid mismatch, or a non-fast-
574
+ // forwardable divergence all HALT with a message for the user. Checking HEAD's SHA first makes
575
+ // this re-entrant and detached-safe.
572
576
  export async function checkoutPrHead(
573
577
  orchestrator: Orchestrator,
574
578
  repoPath: string,
@@ -632,12 +636,91 @@ export async function checkoutPrHead(
632
636
  };
633
637
  }
634
638
  if (name && currentBranch !== name) {
635
- return {
636
- ok: false,
637
- message:
638
- `HALT: ${repoPath} is on "${currentBranch}", not the PR head branch "${name}" (target commit ${oid}). ` +
639
- "Check out that branch at its PR head, then ask me to continue. I will not switch branches for you.",
640
- };
639
+ // Clean tree on the wrong branch: switch to the PR head branch when it is
640
+ // provably safe. The tree is already known clean (checked above), so no
641
+ // stash is ever needed. We fetch the head ref, verify it matches the
642
+ // advertised oid, then either fast-forward an existing local branch to it
643
+ // or create a fresh tracking branch at it. Anything ambiguous HALTs.
644
+ if (!/^[^\s]+$/.test(name) || name.startsWith("-")) {
645
+ return {
646
+ ok: false,
647
+ message: `HALT: ${repoPath} PR head branch name "${name}" is not a safe ref to fetch. Bring the repo to its PR head (${oid}) yourself, then ask me to continue.`,
648
+ };
649
+ }
650
+ const fetchRes = await run(["fetch", "origin", `+refs/heads/${name}:refs/remotes/origin/${name}`]);
651
+ if (fetchRes.code !== 0) {
652
+ return {
653
+ ok: false,
654
+ message:
655
+ `HALT: ${repoPath} could not fetch PR head branch "${name}" from origin (a fork PR, or the branch is not on origin). ` +
656
+ `Fetch/add the correct remote and bring the repo to the PR head ${oid} yourself, then ask me to continue. I will not guess a remote for you.` +
657
+ `${fetchRes.stderr?.trim() ? `\n\n${fetchRes.stderr.trim()}` : ""}`,
658
+ };
659
+ }
660
+ let remoteTip;
661
+ try {
662
+ remoteTip = await run(["rev-parse", "--verify", `refs/remotes/origin/${name}`]);
663
+ } catch (err: any) {
664
+ return { ok: false, message: `Cannot resolve origin/${name} in ${repoPath} after fetch: ${err?.message ?? "git rev-parse failed"}` };
665
+ }
666
+ if (remoteTip.code !== 0 || remoteTip.stdout.trim() !== oid) {
667
+ return {
668
+ ok: false,
669
+ message:
670
+ `HALT: ${repoPath} fetched origin/${name} is ${remoteTip.stdout.trim() || "unresolvable"}, not the advertised PR head ${oid}. ` +
671
+ "The PR head may have moved or lives on a fork. Bring the repo to the exact PR head yourself, then ask me to continue.",
672
+ };
673
+ }
674
+ const localExists = (await run(["rev-parse", "--verify", "--quiet", `refs/heads/${name}`])).code === 0;
675
+ if (localExists) {
676
+ // Prove the fast-forward is safe BEFORE moving HEAD: if the local branch tip
677
+ // is not an ancestor of the PR head, it has diverged and we must HALT while
678
+ // the user is still on their original branch (never switch then fail).
679
+ const localTip = await run(["rev-parse", "--verify", `refs/heads/${name}`]);
680
+ const ancestry = await run(["merge-base", "--is-ancestor", localTip.stdout.trim(), oid]);
681
+ if (ancestry.code !== 0) {
682
+ return {
683
+ ok: false,
684
+ message:
685
+ `HALT: ${repoPath} local branch "${name}" cannot fast-forward to PR head ${oid} — it has diverged. ` +
686
+ "Reconcile it with the PR head, then ask me to continue. I will not force-move or rebase it for you.",
687
+ };
688
+ }
689
+ const co = await run(["checkout", name]);
690
+ if (co.code !== 0) {
691
+ return { ok: false, message: `HALT: ${repoPath} could not check out "${name}": ${co.stderr?.trim() || "git checkout failed"}. Resolve it, then ask me to continue.` };
692
+ }
693
+ const ffSwitch = await run(["merge", "--ff-only", oid]);
694
+ if (ffSwitch.code !== 0) {
695
+ return {
696
+ ok: false,
697
+ message:
698
+ `HALT: ${repoPath} local branch "${name}" cannot fast-forward to PR head ${oid} — it has diverged. ` +
699
+ "Reconcile it with the PR head, then ask me to continue. I will not force-move or rebase it for you." +
700
+ `${ffSwitch.stderr?.trim() ? `\n\n${ffSwitch.stderr.trim()}` : ""}`,
701
+ };
702
+ }
703
+ } else {
704
+ const co = await run(["checkout", "-b", name, "--track", `refs/remotes/origin/${name}`]);
705
+ if (co.code !== 0) {
706
+ return { ok: false, message: `HALT: ${repoPath} could not create tracking branch "${name}" at the PR head: ${co.stderr?.trim() || "git checkout -b failed"}. Resolve it, then ask me to continue.` };
707
+ }
708
+ }
709
+ let switched;
710
+ try {
711
+ switched = await run(["rev-parse", "HEAD"]);
712
+ } catch (err: any) {
713
+ return { ok: false, message: `Cannot confirm HEAD of ${repoPath} after switching to "${name}": ${err?.message ?? "git rev-parse failed"}` };
714
+ }
715
+ if (switched.stdout.trim() !== oid) {
716
+ return {
717
+ ok: false,
718
+ message:
719
+ `HALT: ${repoPath} is on "${name}" but at ${switched.stdout.trim()}, not the PR head ${oid}. ` +
720
+ "Bring it to the exact PR head yourself, then ask me to continue.",
721
+ };
722
+ }
723
+ return { ok: true, message: `${repoPath} switched to PR head branch "${name}" at ${oid}.` };
641
724
  }
642
725
 
643
726
  // Clean tree, on the PR head's branch, but behind: advance the branch to the head with a
@@ -690,10 +773,13 @@ function registerCheckoutPrHeadTool(orchestrator: Orchestrator): void {
690
773
  "Call this ONLY for a PR-scoped review, once per repo, AFTER you have resolved the PR " +
691
774
  "(e.g. via `gh pr view --json headRefName,headRefOid`). Do NOT call it for a branch, " +
692
775
  "commit-range, or uncommitted-changes review. The extension does this safely (no worktree, " +
693
- "no stash, no --force, no branch switch, no detached checkout): a clean repo already on the " +
776
+ "no stash, no --force, no detached checkout): a clean repo already on the " +
694
777
  "PR head succeeds; a clean repo on the PR head's branch but behind is fast-forwarded to the " +
695
- "head; a dirty tree, a different branch, or a diverged branch HALTS with a message for you to " +
696
- "relay to the user. You never run `git checkout` yourself.",
778
+ "head; a clean repo on a DIFFERENT branch is switched to the PR head branch only after fetching " +
779
+ "origin and verifying the fetched tip matches the advertised commit (fast-forwarding an existing " +
780
+ "local branch or creating a tracking one); a dirty tree, a detached HEAD, a fork/unfetchable ref, " +
781
+ "an oid mismatch, or a diverged branch HALTS with a message for you to relay to the user. You " +
782
+ "never run `git checkout` yourself.",
697
783
  parameters: Type.Object({
698
784
  repoPath: Type.String({ description: "Absolute path to the registered repository" }),
699
785
  headRefName: Type.String({ description: "PR head branch name (headRefName)" }),
@@ -937,6 +1023,37 @@ function registerCommitTool(orchestrator: Orchestrator): void {
937
1023
  });
938
1024
  }
939
1025
 
1026
+ // Returns a one-time reconcile directive on the first pp_phase_complete of a
1027
+ // phase, null on the acknowledging re-call. Bounded to one round-trip so
1028
+ // autonomous runs never loop.
1029
+ function maybePromptReconcile(orchestrator: Orchestrator): string | null {
1030
+ const active = orchestrator.active;
1031
+ if (!active) return null;
1032
+ const phase = active.state.phase;
1033
+ if (phase === "quick") return null;
1034
+
1035
+ if (active.state.reconciledPhase === phase && !active.state.reconcilePending) {
1036
+ return null;
1037
+ }
1038
+ const alreadyPrompted = active.state.reconcilePromptedPhase === phase && !active.state.reconcilePending;
1039
+ if (alreadyPrompted) {
1040
+ active.state.reconciledPhase = phase;
1041
+ active.state.reconcilePending = false;
1042
+ saveTask(active.dir, active.state);
1043
+ return null;
1044
+ }
1045
+ active.state.reconcilePromptedPhase = phase;
1046
+ active.state.reconcilePending = false;
1047
+ saveTask(active.dir, active.state);
1048
+ return (
1049
+ "Before finishing this phase, reconcile the task's state files with everything you've learned so far: " +
1050
+ "update USER_REQUEST.md, RESEARCH.md, the active synthesized plan, and any artifacts/*.md you own so a " +
1051
+ "freshly-spawned planner or reviewer (which only sees these files) is not working from a stale snapshot. " +
1052
+ "Use pp_write_state_file / pp_edit_state_file. Then call pp_phase_complete again to proceed — if nothing " +
1053
+ "needed changing, just call it again. (This one-time reconcile check runs once per phase.)"
1054
+ );
1055
+ }
1056
+
940
1057
  function registerPhaseCompleteTool(orchestrator: Orchestrator): void {
941
1058
  const pi = orchestrator.pi;
942
1059
 
@@ -961,6 +1078,12 @@ function registerPhaseCompleteTool(orchestrator: Orchestrator): void {
961
1078
  const count = orchestrator.spawnedAgentIds.size + orchestrator.pendingSubagentSpawns;
962
1079
  return { content: [{ type: "text" as const, text: `${count} subagent(s) still running. Wait for them to complete before calling pp_phase_complete.` }], isError: true as const, details: {} };
963
1080
  }
1081
+
1082
+ const reconcileDirective = maybePromptReconcile(orchestrator);
1083
+ if (reconcileDirective) {
1084
+ return { content: [{ type: "text" as const, text: reconcileDirective }], details: {} };
1085
+ }
1086
+
964
1087
  // Gate on the effective PHASE mode, not the task mode: brainstorm/debug/
965
1088
  // review are always user-driven even for an autonomous task, so they must
966
1089
  // fall through to the guided menu path below rather than auto-advancing.
@@ -1441,6 +1564,18 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1441
1564
  }
1442
1565
  }
1443
1566
 
1567
+ // Mark ONE agent's record consumed. The subagents:completed event is emitted
1568
+ // synchronously from pi-subagents' onComplete callback BEFORE it checks
1569
+ // record.resultConsumed to decide whether to send the individual nudge, so
1570
+ // setting the flag here (during the event) suppresses the per-agent nudge for
1571
+ // a phased-batch member whose completion is instead reported by the single
1572
+ // aggregated signal once the whole batch finishes.
1573
+ function markAgentConsumed(id: string): void {
1574
+ const mgr = (globalThis as any)[Symbol.for("pi-subagents:manager")];
1575
+ const record = mgr?.getRecord?.(id);
1576
+ if (record) record.resultConsumed = true;
1577
+ }
1578
+
1444
1579
  function checkPlannerCompletion(): void {
1445
1580
  if (
1446
1581
  !orchestrator.active ||
@@ -1866,7 +2001,9 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1866
2001
  // only completion signal.
1867
2002
  const step = orchestrator.active.state.step;
1868
2003
  const isPhasedBatch = step === "await_planners" || step === "await_reviewers";
1869
- if (!isPhasedBatch) {
2004
+ if (isPhasedBatch) {
2005
+ markAgentConsumed(data.id);
2006
+ } else {
1870
2007
  const desc = data.description || data.type || data.id;
1871
2008
  const duration = data.durationMs ? `${(data.durationMs / 1000).toFixed(1)}s` : "";
1872
2009
  const tokens = data.tokens?.total ? `${data.tokens.total} tok` : "";
@@ -2134,7 +2271,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
2134
2271
  // user's initiating prompt, collapse whitespace, and cap at ~700 chars so
2135
2272
  // the state file never holds an unbounded prompt. Only the genuine first
2136
2273
  // user prompt qualifies (not a [PI-PI] injection).
2137
- const GENERIC_DESCRIPTIONS = ["implement", "debug", "brainstorm", "review", "quick"];
2274
+ const GENERIC_DESCRIPTIONS = ["implement", "brainstorm", "review", "quick"];
2138
2275
  if (GENERIC_DESCRIPTIONS.includes(orchestrator.active.state.description) && event.prompt) {
2139
2276
  // Collapse the whole multi-line prompt into one line (newlines → spaces)
2140
2277
  // so a request whose intent spans several lines isn't cut off at line 1;
@@ -2202,6 +2339,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
2202
2339
  identity,
2203
2340
  constraintsBlock(phase as Phase, effectiveMode),
2204
2341
  PRINCIPLES_BLOCK,
2342
+ isReadOnlyPhase(phase as Phase) ? "" : IMPLEMENTATION_PRINCIPLES_BLOCK,
2205
2343
  toolsBlock(mainToolNames),
2206
2344
  delegation,
2207
2345
  projectContext,
@@ -235,6 +235,61 @@ describe("updateFlantInfra", () => {
235
235
  expect([...pi.registered.keys()].sort()).toEqual(["pp-flant-anthropic", "pp-flant-openai"]);
236
236
  });
237
237
 
238
+ it("serves a fresh cache without re-fetching by default", async () => {
239
+ const dir = makeTempDir();
240
+ const cacheDir = join(dir, "extensions", "pp", "cache");
241
+ mkdirSync(cacheDir, { recursive: true });
242
+ writeFileSync(
243
+ join(cacheDir, "flant-models.json"),
244
+ JSON.stringify({
245
+ enabled: true,
246
+ cacheTTLDays: 7,
247
+ lastUpdated: new Date().toISOString(),
248
+ cachedFlantModels: ["claude-opus-4-8"],
249
+ cachedOpenRouterData: {},
250
+ }),
251
+ "utf-8",
252
+ );
253
+ process.env.FLANT_API_KEY = "flant-k";
254
+ const mod = await loadModule(dir);
255
+ const fetchFn = stubFetch(() => { throw new Error("should not fetch"); });
256
+ const res = await mod.updateFlantInfra(makePi());
257
+ expect(res.ok).toBe(true);
258
+ expect(res.models).toEqual(["claude-opus-4-8"]);
259
+ expect(fetchFn).not.toHaveBeenCalled();
260
+ });
261
+
262
+ it("force bypasses a fresh cache and re-fetches the model list", async () => {
263
+ const dir = makeTempDir();
264
+ const cacheDir = join(dir, "extensions", "pp", "cache");
265
+ mkdirSync(cacheDir, { recursive: true });
266
+ writeFileSync(
267
+ join(cacheDir, "flant-models.json"),
268
+ JSON.stringify({
269
+ enabled: true,
270
+ cacheTTLDays: 7,
271
+ lastUpdated: new Date().toISOString(),
272
+ cachedFlantModels: ["claude-opus-4-8"],
273
+ cachedOpenRouterData: {},
274
+ }),
275
+ "utf-8",
276
+ );
277
+ process.env.FLANT_API_KEY = "flant-k";
278
+ const mod = await loadModule(dir);
279
+ stubFetch((url: string) => {
280
+ if (url.includes("llm-api.flant.ru/v1/models")) {
281
+ return { ok: true, status: 200, json: async () => ({ data: [{ id: "sub/claude-fable-5" }, { id: "claude-fable-5" }] }) };
282
+ }
283
+ if (url.includes("openrouter.ai")) {
284
+ return { ok: true, status: 200, json: async () => ({ data: [] }) };
285
+ }
286
+ throw new Error(`unexpected ${url}`);
287
+ });
288
+ const res = await mod.updateFlantInfra(makePi(), { force: true });
289
+ expect(res.ok).toBe(true);
290
+ expect(res.models).toContain("sub/claude-fable-5");
291
+ });
292
+
238
293
  it("falls back to cached models when discovery throws", async () => {
239
294
  const dir = makeTempDir();
240
295
  const cacheDir = join(dir, "extensions", "pp", "cache");
@@ -422,7 +422,6 @@ describe("flant-infra", () => {
422
422
 
423
423
  expect(config.agents.orchestrators.implement.model).toBe("pp-flant-anthropic/claude-opus-4-6");
424
424
  expect(config.agents.orchestrators.plan.model).toBe("pp-flant-anthropic/claude-opus-4-6");
425
- expect(config.agents.orchestrators.debug.model).toBe("pp-flant-openai/gpt-5-4");
426
425
  expect(config.agents.orchestrators.brainstorm.model).toBe("pp-flant-anthropic/claude-opus-4-6");
427
426
  expect(config.agents.orchestrators.review.model).toBe("pp-flant-anthropic/claude-opus-4-6");
428
427
  expect(config.agents.subagents.simple.explore.model).toBe("pp-flant-openai/gemini-3-1-flash");
@@ -448,7 +447,6 @@ describe("flant-infra", () => {
448
447
  expect(config.agents.subagents.simple.task.model).toBe("pp-flant-anthropic-sub/sub/claude-opus-4-8");
449
448
  expect(config.agents.subagents.presetGroups.planners.presets.regular.agents.opus.model).toBe("pp-flant-anthropic-sub/sub/claude-opus-4-8");
450
449
  // Non-Claude roles stay on the openai (company-billed) provider
451
- expect(config.agents.orchestrators.debug.model).toBe("pp-flant-openai/gpt-5-4");
452
450
  expect(config.agents.subagents.simple.explore.model).toBe("pp-flant-openai/gemini-3-1-flash");
453
451
  expect(config.agents.subagents.presetGroups.planners.presets.regular.agents.gpt.model).toBe("pp-flant-openai/gpt-5-4");
454
452
  });
@@ -541,7 +539,6 @@ describe("flant-infra", () => {
541
539
 
542
540
  expect(config.agents.orchestrators.implement.model).toBe("pp-flant-anthropic/claude-opus-4-7");
543
541
  expect(config.agents.orchestrators.plan.model).toBe("pp-flant-anthropic/claude-opus-4-7");
544
- expect(config.agents.orchestrators.debug.model).toBe("pp-flant-openai/gpt-5-4");
545
542
  expect(config.agents.subagents.presetGroups.planners.presets.regular.agents.gemini.model).toBe("pp-flant-openai/gemini-3-1-pro");
546
543
  expect(config.agents.subagents.simple.explore.model).toBe("pp-flant-openai/gemini-3-1-flash-lite");
547
544
  });
@@ -606,10 +606,10 @@ export function registerFlantProviders(
606
606
  const subscription = options.subscription ?? loadFlantSettings().subscription;
607
607
  let subModels: string[] = [];
608
608
  if (subscription) {
609
- // The gateway only defines `sub/` model groups for a subset of claude
610
- // models (e.g. no sub/claude-fable-5) registering unconfirmed ones
611
- // yields "Invalid model name" 400s. Model lists cached before the gateway
612
- // exposed `sub/…` ids have none; fall back to all claude models then.
609
+ // The gateway defines `sub/` model groups for only some claude models —
610
+ // registering unconfirmed ones yields "Invalid model name" 400s. Model
611
+ // lists cached before the gateway exposed `sub/…` ids have none; fall back
612
+ // to all claude models then.
613
613
  const subEligible = subConfirmed.size > 0
614
614
  ? anthropicModels.filter((m) => subConfirmed.has(m))
615
615
  : anthropicModels;
@@ -786,7 +786,6 @@ export function generateFlantConfig(models: string[], subscriptionActive = false
786
786
  orchestrators: {
787
787
  implement: { model: modelSpec(implementModel, sub(implementModel)), thinking: "high" },
788
788
  plan: { model: modelSpec(implementModel, sub(implementModel)), thinking: "high" },
789
- debug: { model: modelSpec(debugModel, sub(debugModel)), thinking: "high" },
790
789
  brainstorm: { model: modelSpec(brainstormModel, sub(brainstormModel)), thinking: "high" },
791
790
  review: { model: modelSpec(implementModel, sub(implementModel)), thinking: "high" },
792
791
  quick: { model: modelSpec(implementModel, sub(implementModel)), thinking: "high" },
@@ -899,8 +898,18 @@ export function getFlantGeneratedConfig(): Partial<PiPiConfig> | null {
899
898
 
900
899
  (globalThis as any)[Symbol.for("pi-pi:flant-config")] = getFlantGeneratedConfig;
901
900
 
901
+ export interface UpdateFlantOptions {
902
+ /**
903
+ * Bypass the TTL cache and re-fetch the model list from the gateway even when
904
+ * the cache is still fresh. Set by the explicit "Update now" action so it does
905
+ * what its label promises; auto/startup callers omit it and stay cache-bound.
906
+ */
907
+ force?: boolean;
908
+ }
909
+
902
910
  export async function updateFlantInfra(
903
911
  pi: ExtensionAPI,
912
+ options: UpdateFlantOptions = {},
904
913
  ): Promise<{ ok: boolean; error?: string; models?: string[] }> {
905
914
  setPI(pi);
906
915
  const settings = loadFlantSettings();
@@ -911,8 +920,9 @@ export async function updateFlantInfra(
911
920
  await refreshClaudeOAuthToken();
912
921
  }
913
922
 
914
- let models = isCacheValid(settings) ? settings.cachedFlantModels : null;
915
- let metadata = isCacheValid(settings) ? settings.cachedOpenRouterData : null;
923
+ const cacheOk = !options.force && isCacheValid(settings);
924
+ let models = cacheOk ? settings.cachedFlantModels : null;
925
+ let metadata = cacheOk ? settings.cachedOpenRouterData : null;
916
926
  let refreshed = false;
917
927
 
918
928
  if (!models || !metadata) {