@ilya-lesikov/pi-pi 0.8.0 → 0.9.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 (33) hide show
  1. package/extensions/orchestrator/agents/code-reviewer.ts +21 -5
  2. package/extensions/orchestrator/agents/constraints.test.ts +39 -1
  3. package/extensions/orchestrator/agents/constraints.ts +63 -2
  4. package/extensions/orchestrator/agents/tool-routing.ts +30 -14
  5. package/extensions/orchestrator/ai-comment-cleanup.test.ts +89 -0
  6. package/extensions/orchestrator/ai-comment-cleanup.ts +73 -0
  7. package/extensions/orchestrator/command-handlers.test.ts +47 -0
  8. package/extensions/orchestrator/command-handlers.ts +43 -27
  9. package/extensions/orchestrator/config.test.ts +40 -1
  10. package/extensions/orchestrator/config.ts +13 -0
  11. package/extensions/orchestrator/context.ts +16 -0
  12. package/extensions/orchestrator/custom-footer.test.ts +91 -0
  13. package/extensions/orchestrator/custom-footer.ts +20 -20
  14. package/extensions/orchestrator/event-handlers.test.ts +315 -1
  15. package/extensions/orchestrator/event-handlers.ts +397 -201
  16. package/extensions/orchestrator/integration.test.ts +305 -9
  17. package/extensions/orchestrator/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  18. package/extensions/orchestrator/orchestrator.ts +46 -58
  19. package/extensions/orchestrator/phases/brainstorm.ts +11 -7
  20. package/extensions/orchestrator/phases/machine.test.ts +36 -0
  21. package/extensions/orchestrator/phases/machine.ts +11 -1
  22. package/extensions/orchestrator/phases/review-task.test.ts +20 -0
  23. package/extensions/orchestrator/phases/review-task.ts +44 -16
  24. package/extensions/orchestrator/phases/review.test.ts +26 -0
  25. package/extensions/orchestrator/phases/review.ts +40 -3
  26. package/extensions/orchestrator/pp-menu.test.ts +207 -1
  27. package/extensions/orchestrator/pp-menu.ts +376 -378
  28. package/extensions/orchestrator/state.test.ts +9 -0
  29. package/extensions/orchestrator/state.ts +15 -0
  30. package/extensions/orchestrator/transition-controller.test.ts +100 -0
  31. package/extensions/orchestrator/transition-controller.ts +61 -3
  32. package/package.json +1 -1
  33. package/AGENTS.md +0 -28
@@ -1,5 +1,4 @@
1
- import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from "fs";
2
- import { tmpdir } from "os";
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "fs";
3
2
  import { resolve, basename, join, relative, dirname, isAbsolute, sep } from "path";
4
3
  import { validateUserRequest, validateResearch, validateArtifact } from "./validate-artifacts.js";
5
4
  import { Type } from "@sinclair/typebox";
@@ -25,8 +24,9 @@ import { registerCbmTools } from "./cbm.js";
25
24
  import { registerExaTools } from "./exa.js";
26
25
  import { registerAstSearchTool } from "./ast-search.js";
27
26
  import { SUBAGENT_SESSION_KEY } from "./index.js";
28
- import { registerCommandHandlers } from "./command-handlers.js";
27
+ import { registerCommandHandlers, runAfterImplementForActive } from "./command-handlers.js";
29
28
  import { registerStateFileTools } from "./pp-state-tools.js";
29
+ import { isAiCommentOnlyChange } from "./ai-comment-cleanup.js";
30
30
  import { handleMainRateLimit, handleSubagentRateLimit, isRateLimitError, isSdkRetryableError } from "./rate-limit-fallback.js";
31
31
  import { setExtensionOnlyMode, unregisterAgentDefinitions } from "./agents/registry.js";
32
32
  import { resolveModel, getModelInfo, updateRegistryFromAvailableModels } from "./model-registry.js";
@@ -34,11 +34,11 @@ import { spawnPlanners, spawnPlanReviewers } from "./phases/planning.js";
34
34
  import { spawnCodeReviewers } from "./phases/review.js";
35
35
  import { spawnBrainstormReviewers } from "./phases/brainstorm.js";
36
36
  import { reviewPassUnanimousApprove } from "./phases/verdict.js";
37
- import { validateExitCriteria } from "./phases/machine.js";
37
+ import { nextPhase, validateExitCriteria } from "./phases/machine.js";
38
38
  import { openPlannotator, waitForPlannotatorResult, cancelPendingPlannotatorWait } from "./plannotator.js";
39
39
  import { advanceBanner } from "./messages.js";
40
40
  import { Orchestrator, type ActiveTask } from "./orchestrator.js";
41
- import { createCustomFooter, setFooterContext, setFooterTracker } from "./custom-footer.js";
41
+ import { createCustomFooter, setFooterContext, setFooterTracker, setFooterOrchestrator } from "./custom-footer.js";
42
42
  import { createUsageTracker, dumpUsageSummary, loadUsageSummary, isSubscriptionRouted, type UsageTracker } from "./usage-tracker.js";
43
43
  import { askUser, isCancel } from "../../3p/pi-ask-user/index.js";
44
44
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
@@ -121,15 +121,21 @@ export async function detectDefaultBranch(orchestrator: Orchestrator, repos: Rep
121
121
  }
122
122
 
123
123
  export async function selectOption(ctx: any, question: string, options: string[]): Promise<string | undefined> {
124
- const result = await askUser(ctx, {
125
- question,
126
- options,
127
- allowFreeform: false,
128
- allowComment: false,
129
- allowMultiple: false,
130
- });
131
- if (!result || isCancel(result) || result.kind !== "selection") return undefined;
132
- return result.selections[0];
124
+ const orchestrator = Orchestrator.current;
125
+ if (orchestrator) orchestrator.interactivePromptOpen = true;
126
+ try {
127
+ const result = await askUser(ctx, {
128
+ question,
129
+ options,
130
+ allowFreeform: false,
131
+ allowComment: false,
132
+ allowMultiple: false,
133
+ });
134
+ if (!result || isCancel(result) || result.kind !== "selection") return undefined;
135
+ return result.selections[0];
136
+ } finally {
137
+ if (orchestrator) orchestrator.interactivePromptOpen = false;
138
+ }
133
139
  }
134
140
 
135
141
  function resolveReviewers(
@@ -318,7 +324,9 @@ export async function enterReviewCycle(
318
324
  orchestrator.active.state.reviewCycle = null;
319
325
  orchestrator.active.state.step = "llm_work";
320
326
  saveTask(orchestrator.active.dir, orchestrator.active.state);
321
- return "User wants a Plannotator code review. Call pp_specify_reviews with the list of repositories and commit ranges to review. Include all repos where you made changes.";
327
+ // Implement/review-phase Plannotator is driven entirely by the menu's per-repo
328
+ // interleaved loop (pp-menu runPlannotatorCursor); it never enters this path.
329
+ return "Plannotator code review runs per-repo from the /pp Review menu. Open /pp → Review → Review in Plannotator.";
322
330
  }
323
331
 
324
332
  const phase = orchestrator.active.state.phase;
@@ -411,6 +419,14 @@ export async function stopTask(orchestrator: Orchestrator): Promise<string> {
411
419
  return `Task "${desc}" stopped. Use /pp → Resume to continue.`;
412
420
  }
413
421
 
422
+ // A review is LIVE when its cycle exists and reviewers are still being awaited.
423
+ // Guards the menu/autonomous callers against re-spawning reviewers on top of a
424
+ // running cycle (the "6 agents" double-spawn). NOT "a cycle object exists": a
425
+ // completed cycle is finalized (and nulled) legitimately before the next pass.
426
+ export function isReviewCycleLive(task: ActiveTask): boolean {
427
+ return !!task.state.reviewCycle && task.state.reviewCycle.step === "await_reviewers";
428
+ }
429
+
414
430
  export function finalizeReviewCycle(task: ActiveTask): void {
415
431
  if (!task.state.reviewCycle) return;
416
432
  const kind = task.state.reviewCycle.kind;
@@ -448,14 +464,176 @@ export function finalizeReviewCycleAutonomous(task: ActiveTask): void {
448
464
  saveTask(task.dir, task.state);
449
465
  }
450
466
 
467
+ export function registerOrchestratorToolsForTest(orchestrator: Orchestrator): void {
468
+ registerOrchestratorTools(orchestrator);
469
+ }
470
+
451
471
  function registerOrchestratorTools(orchestrator: Orchestrator): void {
452
472
  registerRepoTool(orchestrator);
473
+ registerCheckoutPrHeadTool(orchestrator);
453
474
  registerPhaseCompleteTool(orchestrator);
454
475
  registerCommitTool(orchestrator);
455
- registerSpecifyReviewsTool(orchestrator);
456
476
  registerStateFileTools(orchestrator);
457
477
  }
458
478
 
479
+ // Extension-side PR-head checkout for the review phase. Performed here (never via the
480
+ // agent prompt) so REVIEW_READONLY_CONSTRAINT stays truthful — the agent never runs
481
+ // `git checkout` itself. Lands the repo on its PR head under strict safety rules (no stash,
482
+ // no --force, no branch switch, no worktree, no detached checkout): when the tree is clean
483
+ // and on the PR head's branch but merely behind, it fast-forwards the branch to the head
484
+ // (`git merge --ff-only`, which never detaches, never rewrites, and refuses a non-ff move).
485
+ // A dirty tree, a different branch, or a non-fast-forwardable divergence all HALT with a
486
+ // message for the user. Checking HEAD's SHA first makes this re-entrant and detached-safe.
487
+ export async function checkoutPrHead(
488
+ orchestrator: Orchestrator,
489
+ repoPath: string,
490
+ headRefName: string,
491
+ headRefOid: string,
492
+ ): Promise<{ ok: boolean; message: string }> {
493
+ const pi = orchestrator.pi;
494
+ const name = (headRefName ?? "").trim();
495
+ const oid = (headRefOid ?? "").trim();
496
+ if (!name && !oid) {
497
+ return { ok: true, message: `No PR head provided for ${repoPath}; skipping (non-PR review scope).` };
498
+ }
499
+
500
+ const run = (args: string[]) => pi.exec("git", args, { cwd: repoPath, timeout: 15000 });
501
+ const target = `${name || oid}${name && oid ? ` @ ${oid}` : ""}`;
502
+
503
+ let status;
504
+ try {
505
+ status = await run(["status", "--porcelain"]);
506
+ } catch (err: any) {
507
+ return { ok: false, message: `Cannot inspect ${repoPath}: ${err?.message ?? "git status failed"}` };
508
+ }
509
+ if (status.code !== 0) {
510
+ return { ok: false, message: `Cannot inspect ${repoPath}: ${status.stderr?.trim() || status.stdout?.trim() || "git status failed"}` };
511
+ }
512
+ if (status.stdout.trim()) {
513
+ return {
514
+ ok: false,
515
+ message:
516
+ `HALT: ${repoPath} has uncommitted changes. Commit or stash them, then bring the repo to its PR head (${target}) and ask me to continue.\n\n${status.stdout.trim()}`,
517
+ };
518
+ }
519
+
520
+ if (!oid) {
521
+ return { ok: true, message: `${repoPath}: no PR head commit provided; left as-is (clean tree).` };
522
+ }
523
+
524
+ let head;
525
+ try {
526
+ head = await run(["rev-parse", "HEAD"]);
527
+ } catch (err: any) {
528
+ return { ok: false, message: `Cannot read HEAD of ${repoPath}: ${err?.message ?? "git rev-parse failed"}` };
529
+ }
530
+ if (head.stdout.trim() === oid) {
531
+ return { ok: true, message: `${repoPath} is on PR head ${oid}.` };
532
+ }
533
+
534
+ let branch;
535
+ try {
536
+ branch = await run(["rev-parse", "--abbrev-ref", "HEAD"]);
537
+ } catch (err: any) {
538
+ return { ok: false, message: `Cannot read current branch of ${repoPath}: ${err?.message ?? "git rev-parse failed"}` };
539
+ }
540
+ const currentBranch = branch.stdout.trim();
541
+ if (currentBranch === "HEAD") {
542
+ return {
543
+ ok: false,
544
+ message:
545
+ `HALT: ${repoPath} has a detached HEAD, not at the PR head commit ${oid}. ` +
546
+ `Check out the PR head branch${name ? ` "${name}"` : ""} at ${oid}, then ask me to continue. I will not move HEAD for you.`,
547
+ };
548
+ }
549
+ if (name && currentBranch !== name) {
550
+ return {
551
+ ok: false,
552
+ message:
553
+ `HALT: ${repoPath} is on "${currentBranch}", not the PR head branch "${name}" (target commit ${oid}). ` +
554
+ "Check out that branch at its PR head, then ask me to continue. I will not switch branches for you.",
555
+ };
556
+ }
557
+
558
+ // Clean tree, on the PR head's branch, but behind: advance the branch to the head with a
559
+ // fast-forward-only merge. This moves the branch ref without detaching, forcing, or stashing,
560
+ // and fails safely if the branch has diverged from the PR head.
561
+ let ff;
562
+ try {
563
+ ff = await run(["merge", "--ff-only", oid]);
564
+ } catch (err: any) {
565
+ return { ok: false, message: `Fast-forward of ${repoPath} to PR head ${oid} failed: ${err?.message ?? "git merge failed"}` };
566
+ }
567
+ if (ff.code !== 0) {
568
+ return {
569
+ ok: false,
570
+ message:
571
+ `HALT: ${repoPath} (branch "${currentBranch}") cannot fast-forward to PR head ${oid} — it has diverged. ` +
572
+ `Reconcile it with the PR head, then ask me to continue. I will not force-move or rebase it for you.` +
573
+ `${ff.stderr?.trim() ? `\n\n${ff.stderr.trim()}` : ""}`,
574
+ };
575
+ }
576
+
577
+ // `git merge --ff-only <ancestor>` returns exit 0 ("Already up to date") WITHOUT moving HEAD
578
+ // when the branch is already ahead of the PR head, so a zero exit is not proof we landed on it.
579
+ // Re-read HEAD and HALT if the branch carries commits beyond the PR head.
580
+ let after;
581
+ try {
582
+ after = await run(["rev-parse", "HEAD"]);
583
+ } catch (err: any) {
584
+ return { ok: false, message: `Cannot confirm HEAD of ${repoPath} after fast-forward: ${err?.message ?? "git rev-parse failed"}` };
585
+ }
586
+ if (after.stdout.trim() !== oid) {
587
+ return {
588
+ ok: false,
589
+ message:
590
+ `HALT: ${repoPath} (branch "${currentBranch}") is ahead of the PR head ${oid} — it has commits the PR head does not. ` +
591
+ "Push them to the PR (or check out the exact PR head), then ask me to continue. I will not move HEAD backwards for you.",
592
+ };
593
+ }
594
+ return { ok: true, message: `${repoPath} fast-forwarded to PR head ${oid}.` };
595
+ }
596
+
597
+ function registerCheckoutPrHeadTool(orchestrator: Orchestrator): void {
598
+ const pi = orchestrator.pi;
599
+
600
+ pi.registerTool({
601
+ name: "pp_checkout_pr_head",
602
+ label: "pi-pi",
603
+ description:
604
+ "Land a registered repository on its PR head commit before reviewing it. " +
605
+ "Call this ONLY for a PR-scoped review, once per repo, AFTER you have resolved the PR " +
606
+ "(e.g. via `gh pr view --json headRefName,headRefOid`). Do NOT call it for a branch, " +
607
+ "commit-range, or uncommitted-changes review. The extension does this safely (no worktree, " +
608
+ "no stash, no --force, no branch switch, no detached checkout): a clean repo already on the " +
609
+ "PR head succeeds; a clean repo on the PR head's branch but behind is fast-forwarded to the " +
610
+ "head; a dirty tree, a different branch, or a diverged branch HALTS with a message for you to " +
611
+ "relay to the user. You never run `git checkout` yourself.",
612
+ parameters: Type.Object({
613
+ repoPath: Type.String({ description: "Absolute path to the registered repository" }),
614
+ headRefName: Type.String({ description: "PR head branch name (headRefName)" }),
615
+ headRefOid: Type.String({ description: "PR head commit SHA (headRefOid)" }),
616
+ }),
617
+ async execute(_toolCallId, params: any) {
618
+ if (!orchestrator.active) {
619
+ return { content: [{ type: "text" as const, text: "No active task." }], isError: true as const, details: {} };
620
+ }
621
+ const pathInput = typeof params.repoPath === "string" ? params.repoPath.trim() : "";
622
+ if (!pathInput) {
623
+ return { content: [{ type: "text" as const, text: "Missing repoPath." }], isError: true as const, details: {} };
624
+ }
625
+ const normalized = normalizeRepoPath(pathInput);
626
+ const repos = orchestrator.active.state.repos ?? [];
627
+ const registered = repos.find((repo) => repo.path === normalized);
628
+ if (!registered) {
629
+ return { content: [{ type: "text" as const, text: `Repository is not registered: ${params.repoPath}` }], isError: true as const, details: {} };
630
+ }
631
+ const result = await checkoutPrHead(orchestrator, registered.path, params.headRefName ?? "", params.headRefOid ?? "");
632
+ return { content: [{ type: "text" as const, text: result.message }], isError: result.ok ? undefined : (true as const), details: {} };
633
+ },
634
+ });
635
+ }
636
+
459
637
  function registerRepoTool(orchestrator: Orchestrator): void {
460
638
  const pi = orchestrator.pi;
461
639
 
@@ -595,190 +773,6 @@ function registerRepoTool(orchestrator: Orchestrator): void {
595
773
  });
596
774
  }
597
775
 
598
- async function openCodeReviewDirect(
599
- orchestrator: Orchestrator,
600
- payload: Record<string, unknown>,
601
- ): Promise<{ approved: boolean; feedback?: string } | { error: string }> {
602
- const { opened, reviewId } = await openPlannotator(orchestrator.pi, "code-review", payload);
603
- if (!opened) {
604
- return { error: "Plannotator not available" };
605
- }
606
- let result: { approved: boolean; feedback?: string; error?: string };
607
- try {
608
- result = await waitForPlannotatorResult(orchestrator, reviewId, null);
609
- } catch (err) {
610
- return { error: err instanceof Error ? err.message : "Plannotator review failed" };
611
- }
612
- if (result.error) {
613
- return { error: result.error };
614
- }
615
- return { approved: result.approved, feedback: result.feedback };
616
- }
617
-
618
- function registerSpecifyReviewsTool(orchestrator: Orchestrator): void {
619
- const pi = orchestrator.pi;
620
-
621
- pi.registerTool({
622
- name: "pp_specify_reviews",
623
- label: "pi-pi",
624
- description:
625
- "Specify which repositories and commit ranges to open in Plannotator for code review. " +
626
- "Called when the user requests a Plannotator code review. " +
627
- "Plannotator will open sequentially for each entry. Results are returned after all reviews complete. " +
628
- "Range supports both 'base..HEAD' and 'base..target' (non-HEAD target is reviewed via temporary worktree checkout).",
629
- parameters: Type.Object({
630
- reviews: Type.Array(Type.Object({
631
- cwd: Type.String({ description: "Absolute path to the git repository" }),
632
- range: Type.String({ description: "Git commit range, e.g. 'origin/main..HEAD', 'abc123..def456', 'HEAD~3..HEAD'" }),
633
- })),
634
- }),
635
- async execute(_toolCallId, params: any, _signal, _onUpdate, ctx) {
636
- if (orchestrator.active && getEffectiveMode(orchestrator.active.state) === "autonomous") {
637
- return { content: [{ type: "text" as const, text: "Plannotator review is not available in autonomous mode. Continue with automated reviews via pp_phase_complete." }], isError: true as const, details: {} };
638
- }
639
- if (!params.reviews || params.reviews.length === 0) {
640
- return { content: [{ type: "text" as const, text: "No reviews specified." }], isError: true as const, details: {} };
641
- }
642
-
643
- const results: string[] = [];
644
- let hasNeedsChanges = false;
645
- for (const review of params.reviews) {
646
- ctx.ui?.setWorkingMessage?.(`Waiting for Plannotator review: ${review.range}…`);
647
- const range = String(review.range ?? "").trim();
648
- let compareBase = range;
649
- let compareTarget = "HEAD";
650
- const rangeSeparatorIdx = range.indexOf("..");
651
- if (rangeSeparatorIdx >= 0) {
652
- compareBase = range.slice(0, rangeSeparatorIdx).trim();
653
- compareTarget = range.slice(rangeSeparatorIdx + 2).trim() || "HEAD";
654
- }
655
-
656
- if (!compareBase) {
657
- results.push(`${review.cwd} (${review.range}): Invalid range (missing base).`);
658
- continue;
659
- }
660
-
661
- let reviewCwd = review.cwd;
662
- let tempWorktreePath: string | null = null;
663
- let tempWorktreeParent: string | null = null;
664
- let setupError: string | null = null;
665
-
666
- if (compareTarget !== "HEAD") {
667
- tempWorktreeParent = mkdtempSync(join(tmpdir(), "pi-pi-review-worktree-"));
668
- tempWorktreePath = join(tempWorktreeParent, "checkout");
669
- try {
670
- const addResult = await pi.exec("git", ["worktree", "add", "--detach", tempWorktreePath, compareTarget], {
671
- cwd: review.cwd,
672
- timeout: 20000,
673
- });
674
- if (addResult.code !== 0) {
675
- setupError = addResult.stderr?.trim() || addResult.stdout?.trim() || "Failed to prepare temporary worktree";
676
- } else {
677
- reviewCwd = tempWorktreePath;
678
- }
679
- } catch (error: any) {
680
- setupError = error?.message ?? String(error);
681
- }
682
- }
683
-
684
- if (setupError) {
685
- results.push(`${review.cwd} (${review.range}): ${setupError}`);
686
- if (tempWorktreePath) {
687
- try {
688
- await pi.exec("git", ["worktree", "remove", "--force", tempWorktreePath], {
689
- cwd: review.cwd,
690
- timeout: 20000,
691
- });
692
- } catch {}
693
- }
694
- if (tempWorktreePath) {
695
- try {
696
- rmSync(tempWorktreePath, { recursive: true, force: true });
697
- } catch {}
698
- }
699
- if (tempWorktreeParent) {
700
- try {
701
- rmSync(tempWorktreeParent, { recursive: true, force: true });
702
- } catch {}
703
- }
704
- continue;
705
- }
706
-
707
- const result = await openCodeReviewDirect(orchestrator, {
708
- cwd: reviewCwd,
709
- diffType: "branch",
710
- defaultBranch: compareBase,
711
- });
712
-
713
- if (tempWorktreePath) {
714
- try {
715
- await pi.exec("git", ["worktree", "remove", "--force", tempWorktreePath], {
716
- cwd: review.cwd,
717
- timeout: 20000,
718
- });
719
- } catch {}
720
- try {
721
- rmSync(tempWorktreePath, { recursive: true, force: true });
722
- } catch {}
723
- if (tempWorktreeParent) {
724
- try {
725
- rmSync(tempWorktreeParent, { recursive: true, force: true });
726
- } catch {}
727
- }
728
- }
729
-
730
- if ("error" in result) {
731
- results.push(`${review.cwd} (${review.range}): ${result.error}`);
732
- } else {
733
- const status = result.approved ? "APPROVED" : "NEEDS_CHANGES";
734
- if (!result.approved) hasNeedsChanges = true;
735
- const feedback = result.feedback ? `\nFeedback: ${result.feedback}` : "";
736
- results.push(`${review.cwd} (${review.range}): ${status}${feedback}`);
737
- }
738
- }
739
- ctx.ui?.setWorkingMessage?.();
740
-
741
- const summary = results.join("\n\n");
742
-
743
- if (hasNeedsChanges) {
744
- if (orchestrator.active) {
745
- orchestrator.active.state.step = "llm_work";
746
- saveTask(orchestrator.active.dir, orchestrator.active.state);
747
- }
748
- return {
749
- content: [{ type: "text" as const, text: `Plannotator review complete.\n\n${summary}\n\nAddress the user's feedback. If the feedback contains questions, answer them. If it requests changes, make the changes. Then call pp_phase_complete when done.` }],
750
- details: {},
751
- };
752
- }
753
-
754
- ctx.ui?.setWorkingMessage?.("Waiting for user input…");
755
- try {
756
- const { showActiveTaskMenu, USER_CANCELLED } = await import("./pp-menu.js");
757
- const text = await showActiveTaskMenu(orchestrator, ctx, `Plannotator review complete.\n\n${summary}`, "tool");
758
- // Deliberate user ESC: stop the turn cleanly (mirror ask_user), no
759
- // reminder text that would start a new LLM turn.
760
- if (text === USER_CANCELLED) {
761
- ctx.abort?.();
762
- return { content: [{ type: "text" as const, text: "" }], details: {} };
763
- }
764
- // A transition may have started while the menu was open. The controller
765
- // is the source of truth; abort the agent's pending turn so it doesn't
766
- // race the transition. (Interactive-UX abort — stays local, not routed.)
767
- if (!orchestrator.transitionController.isRunning()) {
768
- ctx.abort?.();
769
- return { content: [{ type: "text" as const, text: "" }], details: {} };
770
- }
771
- if (!text) {
772
- return { content: [{ type: "text" as const, text: "User dismissed the menu. Wait for the user's next message. When you resume work, update USER_REQUEST.md and RESEARCH.md with any new findings before calling pp_phase_complete." }], details: {} };
773
- }
774
- return { content: [{ type: "text" as const, text }], details: {} };
775
- } finally {
776
- ctx.ui?.setWorkingMessage?.();
777
- }
778
- },
779
- });
780
- }
781
-
782
776
  function loadPhaseReviewOutputs(taskDir: string, phase: string, pass: number): { name: string; content: string }[] {
783
777
  if (phase === "brainstorm") return loadBrainstormReviewOutputs(taskDir, pass);
784
778
  if (phase === "plan") return loadPlanReviewOutputs(taskDir, pass);
@@ -948,6 +942,33 @@ function registerPhaseCompleteTool(orchestrator: Orchestrator): void {
948
942
  }
949
943
  }
950
944
 
945
+ // Terminal handoff (#1): an autonomous IMPLEMENT phase whose next phase is
946
+ // "done" should NOT auto-complete. Run afterImplement (transitionToNextPhase
947
+ // is skipped here, so run it explicitly — once), then open the GUIDED
948
+ // implement menu and wait, instead of tearing the task down.
949
+ if (phase === "implement" && nextPhase(orchestrator.active.type, phase) === "done") {
950
+ if (!orchestrator.active.state.afterImplementRan) {
951
+ const afterError = runAfterImplementForActive(orchestrator);
952
+ if (afterError) {
953
+ return { content: [{ type: "text" as const, text: `${afterError}\n\nDo NOT wait for the user — fix these and call pp_phase_complete again.` }], details: {} };
954
+ }
955
+ orchestrator.active.state.afterImplementRan = true;
956
+ saveTask(orchestrator.active.dir, orchestrator.active.state);
957
+ }
958
+ ctx.ui.setWorkingMessage?.("Waiting for user approval…");
959
+ try {
960
+ const { showActiveTaskMenu, USER_CANCELLED } = await import("./pp-menu.js");
961
+ const text = await showActiveTaskMenu(orchestrator, ctx, params.summary, "tool", true);
962
+ if (text === USER_CANCELLED) {
963
+ ctx.abort?.();
964
+ return { content: [{ type: "text" as const, text: "" }], details: {} };
965
+ }
966
+ return { content: [{ type: "text" as const, text: text ?? "" }], details: {} };
967
+ } finally {
968
+ ctx.ui.setWorkingMessage?.();
969
+ }
970
+ }
971
+
951
972
  const plannerPreset = orchestrator.active.state.autonomousConfig?.phases?.plan?.plannerPreset;
952
973
  const result = await orchestrator.transitionToNextPhase(ctx, plannerPreset);
953
974
  if (!result.ok) {
@@ -1022,31 +1043,49 @@ function registerMainTraceHooks(orchestrator: Orchestrator): void {
1022
1043
  tracer?.traceMain("turn_end", { turnIndex: event.turnIndex, message: event.message, toolResults: event.toolResults });
1023
1044
  });
1024
1045
  pi.on("message_start", async (event) => {
1046
+ markMainTurnActivity(orchestrator);
1025
1047
  const tracer = getTracer();
1026
1048
  tracer?.traceMain("message_start", { turnIndex: tracer.turnIndex, message: event.message });
1027
1049
  });
1028
1050
  pi.on("message_update", async (event) => {
1051
+ markMainTurnActivity(orchestrator);
1029
1052
  const tracer = getTracer();
1030
1053
  tracer?.traceMain("message_update", { turnIndex: tracer.turnIndex, assistantMessageEvent: event.assistantMessageEvent });
1031
1054
  });
1032
1055
  pi.on("message_end", async (event) => {
1056
+ markMainTurnActivity(orchestrator);
1033
1057
  const tracer = getTracer();
1034
1058
  tracer?.traceMain("message_end", { turnIndex: tracer.turnIndex, message: event.message });
1035
1059
  });
1036
1060
  pi.on("tool_execution_start", async (event) => {
1061
+ markMainTurnActivity(orchestrator);
1037
1062
  const tracer = getTracer();
1038
1063
  tracer?.traceMain("tool_execution_start", { turnIndex: tracer.turnIndex, toolCallId: event.toolCallId, toolName: event.toolName, args: event.args });
1039
1064
  });
1040
1065
  pi.on("tool_execution_update", async (event) => {
1066
+ markMainTurnActivity(orchestrator);
1041
1067
  const tracer = getTracer();
1042
1068
  tracer?.traceMain("tool_execution_update", { turnIndex: tracer.turnIndex, toolCallId: event.toolCallId, toolName: event.toolName, args: event.args, partialResult: event.partialResult });
1043
1069
  });
1044
1070
  pi.on("tool_execution_end", async (event) => {
1071
+ markMainTurnActivity(orchestrator);
1045
1072
  const tracer = getTracer();
1046
1073
  tracer?.traceMain("tool_execution_end", { turnIndex: tracer.turnIndex, toolCallId: event.toolCallId, toolName: event.toolName, result: event.result, isError: event.isError });
1047
1074
  });
1048
1075
  }
1049
1076
 
1077
+ // Main-turn watchdog activity helpers (BUG-2). Kept module-level so they can be
1078
+ // called from the several existing main-session handlers without registering a
1079
+ // second handler per event.
1080
+ function markMainTurnActivity(orchestrator: Orchestrator): void {
1081
+ orchestrator.mainTurnLastActivity = Date.now();
1082
+ }
1083
+
1084
+ function endMainTurn(orchestrator: Orchestrator): void {
1085
+ orchestrator.mainTurnInFlight = false;
1086
+ orchestrator.mainTurnRecovering = false;
1087
+ }
1088
+
1050
1089
  export function registerEventHandlers(orchestrator: Orchestrator): void {
1051
1090
  const pi = orchestrator.pi;
1052
1091
 
@@ -1077,6 +1116,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1077
1116
  // the subagent branch, so these only ever see root-session events.
1078
1117
  pi.on("agent_end", async (_event, ctx) => {
1079
1118
  if (ctx) orchestrator.lastCtx = ctx;
1119
+ endMainTurn(orchestrator);
1080
1120
  orchestrator.transitionController.onAgentEnd();
1081
1121
  });
1082
1122
  pi.on("session_compact", async (_event, ctx) => {
@@ -1084,6 +1124,65 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1084
1124
  orchestrator.transitionController.onSessionCompact();
1085
1125
  });
1086
1126
 
1127
+ // BUG-2: main-turn stall watchdog. A main turn can start (turn_start / manual
1128
+ // "continue" / SDK auto-retry) and then wedge with no terminal turn_end, so the
1129
+ // session sits "Working…" forever with nothing to recover it (the SDK-retryable
1130
+ // deferral below arms nothing, and startStaleAgentWatchdog only watches
1131
+ // subagents). Activity is marked from the existing main-session handlers
1132
+ // (turn_start below, tool_call, tool_result, turn_end) so we do NOT register a
1133
+ // second handler per event (the runtime allows it, but keeping one owner per
1134
+ // event is simpler and test-mock-friendly). When a turn is in flight with NO
1135
+ // activity beyond the configured threshold, abort + recover via the idle-gated
1136
+ // single-send path so recovery can't itself race into "Agent is already
1137
+ // processing".
1138
+ const startMainTurnWatchdog = () => {
1139
+ if (orchestrator.mainTurnTimer) return;
1140
+ orchestrator.mainTurnTimer = setInterval(() => {
1141
+ if (!orchestrator.active) {
1142
+ clearInterval(orchestrator.mainTurnTimer!);
1143
+ orchestrator.mainTurnTimer = null;
1144
+ return;
1145
+ }
1146
+ if (!orchestrator.mainTurnInFlight || orchestrator.mainTurnRecovering) return;
1147
+ // Only a genuinely stuck turn is a target: an idle session (turn already
1148
+ // settled), an in-progress transition, or an await_* step is not a stall.
1149
+ if (orchestrator.transitionController.isTransitioning()) return;
1150
+ const step = orchestrator.active.state.step;
1151
+ if (step === "await_planners" || step === "await_reviewers") return;
1152
+ // A turn parked on an open user-facing dialogue (ask_user / the /pp menu /
1153
+ // any interactive selectOption / the rate-limit fallback switch) is
1154
+ // user-gated, not wedged — do not abort it out from under the user.
1155
+ if (orchestrator.interactivePromptOpen || orchestrator.subFallbackDialogPending) return;
1156
+ const staleMs = orchestrator.config.performance.internals.mainTurnStale;
1157
+ if (Date.now() - orchestrator.mainTurnLastActivity <= staleMs) return;
1158
+
1159
+ orchestrator.mainTurnRecovering = true;
1160
+ const taskToken = orchestrator.activeTaskToken;
1161
+ const phase = orchestrator.active.state.phase;
1162
+ getLogger().warn({ s: "watchdog", phase, staleMs }, "main turn wedged — aborting and recovering");
1163
+ orchestrator.lastCtx?.ui?.notify?.(
1164
+ `Main turn stalled with no activity for ${Math.round(staleMs / 1000)}s — recovering.`,
1165
+ "warning",
1166
+ );
1167
+ try {
1168
+ orchestrator.transitionController.abortMainAgent(orchestrator.lastCtx?.abort?.bind(orchestrator.lastCtx));
1169
+ } catch {}
1170
+ orchestrator.mainTurnInFlight = false;
1171
+ orchestrator.sendUserMessageWhenIdle(
1172
+ `[PI-PI] The previous turn stalled without completing. Continue working on the current phase (${phase}).`,
1173
+ taskToken,
1174
+ );
1175
+ }, 30000);
1176
+ };
1177
+
1178
+ pi.on("turn_start", async (_event, ctx) => {
1179
+ if (ctx) orchestrator.lastCtx = ctx;
1180
+ orchestrator.mainTurnInFlight = true;
1181
+ orchestrator.mainTurnRecovering = false;
1182
+ markMainTurnActivity(orchestrator);
1183
+ startMainTurnWatchdog();
1184
+ });
1185
+
1087
1186
  // Expose the event-driven planner-completion check so initial plan-entry
1088
1187
  // spawns (in command-handlers / orchestrator) can wire it as their onSettled
1089
1188
  // safety net (the deleted poller's former role). checkPlannerCompletion is a
@@ -1203,6 +1302,10 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1203
1302
  }
1204
1303
 
1205
1304
  trackSubagentEvent(data, "created");
1305
+ // Nudge acceptance/suppression is driven from the root `Agent` tool-call observation
1306
+ // (main-initiated by construction), NOT here — this event also fires for lineage-less
1307
+ // nested spawns, which must not count as the main agent following a nudge.
1308
+
1206
1309
  startStaleAgentWatchdog();
1207
1310
  const mgr = (globalThis as any)[Symbol.for("pi-subagents:manager")];
1208
1311
  mgr?.refreshWidget?.(orchestrator.lastCtx?.ui);
@@ -1789,6 +1892,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1789
1892
  (globalThis as any)[USAGE_TRACKER_KEY] = tracker;
1790
1893
  setFooterContext(ctx);
1791
1894
  setFooterTracker(tracker);
1895
+ setFooterOrchestrator(orchestrator);
1792
1896
  ctx.ui.setFooter(createCustomFooter);
1793
1897
  }
1794
1898
 
@@ -1874,6 +1978,30 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1874
1978
  if (orchestrator.transitionController.gateAgentStart(() => ctx.abort())) {
1875
1979
  return;
1876
1980
  }
1981
+
1982
+ // Stale continuation-nudge guard. A `[PI-PI] Continue the <phase> phase…`
1983
+ // nudge is a followUp queued at turn_end; by the time it is delivered the
1984
+ // phase may have advanced (plan→implement) or the task may have changed
1985
+ // (old plan-task → new plan-task). Re-validate against the phase/token
1986
+ // captured at generation time and abort the turn on any mismatch — a stale
1987
+ // nudge is not genuine user re-engagement, so this runs BEFORE the no-active
1988
+ // early return and does NOT clear the nudge/error guards. Checked only for
1989
+ // the tracked "Continue the <phase> phase" shape; other [PI-PI] handoffs are
1990
+ // untouched.
1991
+ const nudgeMeta = orchestrator.pendingNudges.get(event.prompt ?? "");
1992
+ if (nudgeMeta !== undefined) {
1993
+ orchestrator.pendingNudges.delete(event.prompt ?? "");
1994
+ const livePhase = orchestrator.active?.state.phase;
1995
+ if (!orchestrator.active || nudgeMeta.phase !== livePhase || nudgeMeta.taskToken !== orchestrator.activeTaskToken) {
1996
+ getLogger().debug(
1997
+ { s: "hook", hook: "before_agent_start", nudgePhase: nudgeMeta.phase, livePhase, nudgeToken: nudgeMeta.taskToken, liveToken: orchestrator.activeTaskToken },
1998
+ "dropping stale continuation nudge",
1999
+ );
2000
+ ctx.abort();
2001
+ return;
2002
+ }
2003
+ }
2004
+
1877
2005
  if (!orchestrator.active || orchestrator.active.state.phase === "done") return;
1878
2006
 
1879
2007
  // Clear the nudge guard ONLY on a genuine user re-engagement. Controller-
@@ -1907,6 +2035,11 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1907
2035
  const projectContext = systemSnippets
1908
2036
  ? ["<project_context>", systemSnippets, "</project_context>"].join("\n")
1909
2037
  : "";
2038
+ const agentsMdPath = join(orchestrator.cwd, "AGENTS.md");
2039
+ const agentsMd =
2040
+ orchestrator.config.general.injectAgentsMd && existsSync(agentsMdPath)
2041
+ ? [`<agents_md source="${agentsMdPath}">`, readFileSync(agentsMdPath, "utf-8"), "</agents_md>"].join("\n")
2042
+ : "";
1910
2043
  const checklistLine =
1911
2044
  phase === "implement" ? "Keep the plan checklist current: mark each item done (- [ ] → - [x]) as you complete it." : "";
1912
2045
  const taskBlock = [
@@ -1925,6 +2058,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1925
2058
  TOOLS_BLOCK,
1926
2059
  DELEGATION_BLOCK,
1927
2060
  projectContext,
2061
+ agentsMd,
1928
2062
  taskBlock,
1929
2063
  ]
1930
2064
  .filter(Boolean)
@@ -1936,6 +2070,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1936
2070
  });
1937
2071
 
1938
2072
  pi.on("tool_call", async (event, _ctx) => {
2073
+ markMainTurnActivity(orchestrator);
1939
2074
  getLogger().debug({ s: "hook", hook: "tool_call", tool: event.toolName }, "tool call");
1940
2075
  if (event.toolName === "ask_user" && orchestrator.active) {
1941
2076
  if (getEffectivePhaseMode(orchestrator.active.state) === "autonomous") {
@@ -1993,11 +2128,49 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1993
2128
  if (fileName === "config.json" && isPathInside(ppDir, resolvedPath)) {
1994
2129
  return { block: true, reason: "config.json is managed by the user, not the LLM" };
1995
2130
  }
2131
+
2132
+ // Review-phase read-only exception is scoped to AI_COMMENT markers: the main
2133
+ // agent may only insert/remove `AI_COMMENT:` markers in source files. Enforce
2134
+ // it at the gate (the constraint is otherwise prompt-level only): a source
2135
+ // write/edit is allowed only when it changes nothing but AI_COMMENT markers.
2136
+ // Publishing findings as file comments goes through this path.
2137
+ if (
2138
+ orchestrator.active?.state.phase === "review" &&
2139
+ !isPathInside(ppDir, resolvedPath)
2140
+ ) {
2141
+ const denial = {
2142
+ block: true as const,
2143
+ reason: "Review phase is read-only except for inserting/removing AI_COMMENT: markers. This edit changes non-AI_COMMENT content — record the finding instead of applying a fix.",
2144
+ };
2145
+ if (event.toolName === "edit") {
2146
+ const edits = Array.isArray((event.input as any)?.edits) ? (event.input as any).edits : [];
2147
+ const legacy = event.input as any;
2148
+ const allEdits = [
2149
+ ...edits,
2150
+ ...(typeof legacy?.oldText === "string" && typeof legacy?.newText === "string" ? [{ oldText: legacy.oldText, newText: legacy.newText }] : []),
2151
+ ];
2152
+ for (const e of allEdits) {
2153
+ if (typeof e?.oldText !== "string" || typeof e?.newText !== "string") continue;
2154
+ if (!isAiCommentOnlyChange(e.oldText, e.newText)) return denial;
2155
+ }
2156
+ } else {
2157
+ // Anchoring inserts markers into EXISTING code; creating a new source
2158
+ // file is never a valid AI_COMMENT-only operation.
2159
+ if (!existsSync(resolvedPath)) return denial;
2160
+ const newContent = typeof (event.input as any)?.content === "string" ? (event.input as any).content : "";
2161
+ let before = "";
2162
+ try {
2163
+ before = readFileSync(resolvedPath, "utf-8");
2164
+ } catch {}
2165
+ if (!isAiCommentOnlyChange(before, newContent)) return denial;
2166
+ }
2167
+ }
1996
2168
  }
1997
2169
  return;
1998
2170
  });
1999
2171
 
2000
2172
  pi.on("tool_result", async (event, ctx) => {
2173
+ markMainTurnActivity(orchestrator);
2001
2174
  // ESC in an ask_user dialogue must stop the LLM's turn (treat ESC as
2002
2175
  // "stop, I want to type"). Only a deliberate user cancel aborts — timeout
2003
2176
  // and programmatic signal-aborts carry a non-"user" reason and must not.
@@ -2066,6 +2239,10 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
2066
2239
  orchestrator.active.modifiedFiles.add(resolvedWrite);
2067
2240
  orchestrator.active.state.modifiedFiles = [...orchestrator.active.modifiedFiles];
2068
2241
  orchestrator.active.state.reviewApprovedClean = false;
2242
+ // New implement edits invalidate a prior afterImplement run (e.g. fixes made
2243
+ // via the guided menu after the autonomous terminal handoff), so the hooks
2244
+ // re-run and validate the final code on the next completion.
2245
+ orchestrator.active.state.afterImplementRan = false;
2069
2246
  try { saveTask(orchestrator.active.dir, orchestrator.active.state); } catch {}
2070
2247
 
2071
2248
  const repos = orchestrator.active.state.repos ?? [];
@@ -2126,10 +2303,21 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
2126
2303
  // so this works whether or not `active` is still set (done cleanup nulls it).
2127
2304
  if (transitioning) {
2128
2305
  const summary = orchestrator.transitionController.currentSummary() || "Phase transition in progress.";
2306
+ // Task-boundary discard: keep NO verbatim transcript beyond the summary so
2307
+ // the finished/replaced task cannot leak into the next task. Point
2308
+ // firstKeptEntryId at the newest branch entry (nothing before it is kept
2309
+ // verbatim). Phase transitions keep the default recent window so
2310
+ // legitimate context carries forward.
2311
+ const branchEntries = (event as any).branchEntries as Array<{ id: string }> | undefined;
2312
+ const lastEntryId = branchEntries && branchEntries.length > 0 ? branchEntries[branchEntries.length - 1].id : undefined;
2313
+ const firstKeptEntryId =
2314
+ orchestrator.transitionController.isDiscardTransition() && lastEntryId
2315
+ ? lastEntryId
2316
+ : event.preparation.firstKeptEntryId;
2129
2317
  return {
2130
2318
  compaction: {
2131
2319
  summary,
2132
- firstKeptEntryId: event.preparation.firstKeptEntryId,
2320
+ firstKeptEntryId,
2133
2321
  tokensBefore: event.preparation.tokensBefore,
2134
2322
  },
2135
2323
  };
@@ -2159,6 +2347,10 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
2159
2347
  });
2160
2348
 
2161
2349
  pi.on("turn_end", async (event, ctx) => {
2350
+ // The turn produced a terminal event — it is no longer wedged. If the SDK
2351
+ // auto-retries a retryable error it will emit a fresh turn_start, which
2352
+ // re-arms the watchdog.
2353
+ endMainTurn(orchestrator);
2162
2354
  const msg = event.message as any;
2163
2355
  const usageTracker = getUsageTracker();
2164
2356
  if (usageTracker && msg?.usage) {
@@ -2362,6 +2554,10 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
2362
2554
  const nudge = isAutonomous
2363
2555
  ? `[PI-PI] Continue the ${phase} phase. ${phaseConstraint(phase as Phase)} If the phase's objectives are met, call pp_phase_complete now; otherwise call the next tool. Do NOT apologize or reply with text only — respond with a tool call.`
2364
2556
  : `[PI-PI] Continue the ${phase} phase where you left off.`;
2557
+ // Record the nudge's phase/task at generation time so a delivery that lands
2558
+ // after the phase advanced or the task changed can be dropped (the queued
2559
+ // followUp string itself carries no token).
2560
+ orchestrator.pendingNudges.set(nudge, { phase: phase as Phase, taskToken: orchestrator.activeTaskToken });
2365
2561
  orchestrator.safeSendUserMessage(nudge);
2366
2562
  });
2367
2563