@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
@@ -1,4 +1,4 @@
1
- import { existsSync, readdirSync } from "fs";
1
+ import { existsSync, readdirSync, readFileSync } from "fs";
2
2
  import { join, relative, basename } from "path";
3
3
  import { isDeepStrictEqual } from "util";
4
4
  import { askUser, isCancel, type CancelReason } from "../../3p/pi-ask-user/index.js";
@@ -32,6 +32,7 @@ import { detectDefaultBranch, enterReviewCycle, finalizeReviewCycle, isReviewCyc
32
32
  import { Orchestrator } from "./orchestrator.js";
33
33
  import { cancelPendingPlannotatorWait, openPlannotator, openAnnotateReview, waitForPlannotatorResult } from "./plannotator.js";
34
34
  import { advanceBanner } from "./messages.js";
35
+ import { openAssumptionsBanner, terminalAssumptionsSummary } from "./assumptions.js";
35
36
  import { spawnPlanners, spawnPlanReviewers } from "./phases/planning.js";
36
37
  import { spawnCodeReviewers } from "./phases/review.js";
37
38
  import { spawnBrainstormReviewers } from "./phases/brainstorm.js";
@@ -246,6 +247,53 @@ function setStep(orchestrator: Orchestrator, step: string): void {
246
247
  saveTask(orchestrator.active.dir, orchestrator.active.state);
247
248
  }
248
249
 
250
+ // Pause/finish abort the agent, so they cannot reconcile in-line; flag pending so
251
+ // the next resume re-prompts. Set unconditionally (not gated on reconciledPhase):
252
+ // work done AFTER a phase was reconciled would otherwise resume from a stale stamp.
253
+ function flagReconcilePending(state: { phase: string; reconcilePending?: boolean }): void {
254
+ if (state.phase !== "quick") {
255
+ state.reconcilePending = true;
256
+ }
257
+ }
258
+
259
+ // Manual /pp → Review launches reviewer subagents while the main agent is idle, so
260
+ // the pp_phase_complete reconcile gate never ran and cannot run (no live turn to
261
+ // prompt). Reviewers read the on-disk state files at spawn, which may lag the
262
+ // agent's latest work. Guard just those synchronous automated-review launches with
263
+ // a confirm; the guided flow reaches Review right after pp_phase_complete
264
+ // reconciled (reconciledPhase === phase, not pending), so it never prompts.
265
+ async function confirmReviewDespiteStaleState(orchestrator: Orchestrator, ctx: any): Promise<boolean> {
266
+ const state = orchestrator.active?.state;
267
+ if (!state) return true;
268
+ const stale = state.reconcilePending || state.reconciledPhase !== state.phase;
269
+ if (!stale) return true;
270
+ const choice = await selectOption(ctx, "Reviewers will use the current on-disk state files, which may not include the agent's latest findings. Continue?", [
271
+ opt("Review anyway", "Spawn reviewers against the on-disk state files"),
272
+ opt("Cancel", "Return to the menu without reviewing"),
273
+ ]);
274
+ if (choice !== "Review anyway") return false;
275
+ // The manual bypass must not count as a fresh reconciliation: keep it pending so
276
+ // the next live-agent pp_phase_complete re-prompts.
277
+ state.reconcilePending = true;
278
+ saveTask(orchestrator.active!.dir, state);
279
+ return true;
280
+ }
281
+
282
+ // Leaving a Plannotator/review flow (stop, exhaust, or cancel) must return the
283
+ // task's step to an interactive value. Otherwise a step left at "await_reviewers"
284
+ // keeps transitionController.isRunning() false, which hides the Review/Continue
285
+ // actions in /pp and starves the idle watchdog until it stalls (the reported
286
+ // post-"Stop reviewing" deadlock). Also drop any pending Plannotator wait so a
287
+ // stale reject/timeout can't fire after the user has moved on. The caller owns
288
+ // the subsequent saveTask.
289
+ export function restoreMenuStepAfterReview(orchestrator: Orchestrator, task: { state: { phase: string; step: string | null } }): void {
290
+ cancelPendingPlannotatorWait(orchestrator);
291
+ const step = task.state.step;
292
+ if (step === "await_reviewers" || step === "await_planners" || step === null) {
293
+ task.state.step = task.state.phase === "plan" ? "synthesize" : "llm_work";
294
+ }
295
+ }
296
+
249
297
  // Publish the latest synthesized review findings to a target. The agent (not the
250
298
  // extension) performs the writes: file comments = insert `AI_COMMENT:` markers at
251
299
  // each finding's line; PR comments = run `gh` to post line-anchored comments from
@@ -277,11 +325,11 @@ export function publishPrCommentsBanner(taskDir: string): string {
277
325
  "For each registered repo, resolve the branch's PR with `gh pr view --json number,headRefName,headRefOid,url` (skip the repo if there is no PR or `gh auth status` fails). Derive owner/repo from the PR `url` (the base repo).\n\n" +
278
326
  "PRE-VALIDATE each anchor against the PR diff BEFORE building the review: fetch the diff (e.g. `gh pr diff <number>`) and keep, in a `comments` array, ONLY findings whose path+line are part of that diff. A single GitHub review is all-or-nothing — one invalid line comment rejects the WHOLE review — so an unvalidated anchor must NEVER go into `comments`.\n\n" +
279
327
  "Post ONE bundled review per repo via `gh api --method POST repos/<owner>/<repo>/pulls/<number>/reviews` with `commit_id=<headRefOid>`, `event=COMMENT` (neutral — never APPROVE or REQUEST_CHANGES), a summary `body`, and a `comments` array of `{path, line, side: RIGHT, body}` for the validated findings.\n\n" +
280
- "NON-DIFF findings (anchors not in the diff) are NEVER dropped: list them in the review `body` under a `Findings not anchorable to the diff:` heading, one per line ending with the exact ` (generated by pi-pi)` footer, using `file:—` (never an invented line). Do NOT rely on GitHub rejecting them.\n\n" +
281
- "The LAST line of every finding's comment body MUST be exactly:\n(generated by pi-pi)\n\n" +
328
+ "NON-DIFF findings (anchors not in the diff) are NEVER dropped: list them in the review `body` under a `Findings not anchorable to the diff:` heading, each finding as its own block that ends with a blank line then the exact `(generated by pi-pi)` footer on its own line, using `file:—` (never an invented line). Do NOT rely on GitHub rejecting them.\n\n" +
329
+ "The comment body of every finding MUST end with a blank line followed by exactly:\n(generated by pi-pi)\n(i.e. there is always an empty line immediately before that footer, separating it from the finding text).\n\n" +
282
330
  PRIVACY_INSTRUCTION + "\n\n" +
283
331
  "IDEMPOTENT (line comments): first list existing PR comments (`gh api repos/<owner>/<repo>/pulls/<number>/comments`, which also returns comments from earlier per-comment publishes) and do NOT re-post a finding whose path, line, `(generated by pi-pi)` footer, AND body text all match one already present. Two distinct findings on the same line are NOT duplicates. Skip only true duplicates.\n\n" +
284
- "IDEMPOTENT (body findings): also list existing reviews (`gh api repos/<owner>/<repo>/pulls/<number>/reviews`) and read their `body`. A GitHub review body cannot be deduped per-line, so before creating a new review, drop any non-diff finding whose `<file:—> — <text> (generated by pi-pi)` body line already appears in a prior pi-pi review body. If, after that, there are NO new line comments AND NO new body findings for a repo, do NOT create an empty duplicate review — report it as fully-published instead.\n\n" +
332
+ "IDEMPOTENT (body findings): also list existing reviews (`gh api repos/<owner>/<repo>/pulls/<number>/reviews`) and read their `body`. A GitHub review body cannot be deduped per-line, so before creating a new review, drop any non-diff finding whose `<file:—> — <text>` block (matched by its file, text, and `(generated by pi-pi)` footer, ignoring blank-line spacing) already appears in a prior pi-pi review body. If, after that, there are NO new line comments AND NO new body findings for a repo, do NOT create an empty duplicate review — report it as fully-published instead.\n\n" +
285
333
  "When done, report per repo how many comments you posted, how many you skipped as duplicates, and how many were unanchorable (in the body), then end your turn.",
286
334
  );
287
335
  }
@@ -306,7 +354,7 @@ const AI_REVIEW_MARKER_LOOP =
306
354
  "repeat until no `AI_REVIEW:` markers remain. Then verify your work and report what you changed per marker. When complete, " +
307
355
  "call pp_phase_complete.";
308
356
 
309
- // Read-only phases (brainstorm/debug/review) and plan produce markdown state files rather than
357
+ // Read-only phases (brainstorm/review) and plan produce markdown state files rather than
310
358
  // source changes, so their AI_REVIEW scan targets those artifacts. Missing targets are skipped,
311
359
  // not errors. Kept as literal-token guidance (no regexp/parser), mirroring the implement banner.
312
360
  function readOnlyReviewBanner(phase: string, taskDir: string): string {
@@ -355,6 +403,8 @@ async function abortCurrentWork(orchestrator: Orchestrator, ctx: any): Promise<v
355
403
  async function pauseTask(orchestrator: Orchestrator, ctx: any): Promise<string> {
356
404
  if (!orchestrator.active) return "No active task.";
357
405
 
406
+ flagReconcilePending(orchestrator.active.state);
407
+
358
408
  cancelPendingPlannotatorWait(orchestrator);
359
409
  orchestrator.abortAllSubagents();
360
410
  orchestrator.transitionController.abortMainAgent(ctx.abort?.bind(ctx));
@@ -398,6 +448,8 @@ async function finishTask(orchestrator: Orchestrator, ctx: any): Promise<string>
398
448
  return MISSING_FINAL_PASS_ANCHORS;
399
449
  }
400
450
 
451
+ flagReconcilePending(orchestrator.active.state);
452
+
401
453
  cancelPendingPlannotatorWait(orchestrator);
402
454
  orchestrator.abortAllSubagents();
403
455
  orchestrator.transitionController.abortMainAgent(ctx.abort?.bind(ctx));
@@ -406,6 +458,7 @@ async function finishTask(orchestrator: Orchestrator, ctx: any): Promise<string>
406
458
  const name = orchestrator.active.description;
407
459
  const type = orchestrator.active.type;
408
460
  const dir = orchestrator.active.dir;
461
+ const wasAutonomous = getEffectiveMode(orchestrator.active.state) === "autonomous";
409
462
 
410
463
  orchestrator.lastCtx = ctx;
411
464
 
@@ -432,10 +485,16 @@ async function finishTask(orchestrator: Orchestrator, ctx: any): Promise<string>
432
485
  summary: `Task "${name}" (${type}) is finished — DISCARD its entire conversation. Do NOT carry forward, reference, or act on any of this task's messages, phase, plan, or aborted turns; the next task starts from a clean slate.`,
433
486
  });
434
487
 
488
+ // Terminal assumptions summary (#B/e): mirror the automatic done transition so a
489
+ // /pp → Complete finish of an autonomous run also lists recorded assumptions.
490
+ if (wasAutonomous) {
491
+ ctx.ui.notify(terminalAssumptionsSummary(dir), "info");
492
+ }
493
+
435
494
  const urExists = existsSync(join(dir, "USER_REQUEST.md"));
436
495
  const resExists = existsSync(join(dir, "RESEARCH.md"));
437
496
 
438
- if ((type === "brainstorm" || type === "debug") && urExists && resExists) {
497
+ if (type === "brainstorm" && urExists && resExists) {
439
498
  const taskRelPath = relative(join(orchestrator.cwd, ".pp", "state"), dir);
440
499
  ctx.ui.notify(
441
500
  `Task "${name}" completed. Artifacts saved.\nUse /pp → Implement → From and choose ${taskRelPath}`,
@@ -499,7 +558,7 @@ export async function pickPreset(
499
558
  // phase, which is wrong only for tasks completed early).
500
559
  function reopenPhaseForDoneTask(type: TaskType, state: TaskState): Phase {
501
560
  if (state.completedFrom) return state.completedFrom;
502
- for (const phase of ["implement", "plan", "review", "debug", "brainstorm", "quick"] as Phase[]) {
561
+ for (const phase of ["implement", "plan", "review", "brainstorm", "quick"] as Phase[]) {
503
562
  if (nextPhase(type, phase) === "done") return phase;
504
563
  }
505
564
  return "implement" as Phase;
@@ -597,8 +656,7 @@ export async function resumeTask(
597
656
  getLogger().info({ s: "task", dir: task.dir, type: task.type, phase: task.state.phase }, "task resumed");
598
657
 
599
658
  const modelConfig = orchestrator.config.agents.orchestrators[
600
- task.type === "debug" ? "debug"
601
- : task.type === "brainstorm" ? "brainstorm"
659
+ task.type === "brainstorm" ? "brainstorm"
602
660
  : task.type === "review" ? "review"
603
661
  : "implement"
604
662
  ];
@@ -826,15 +884,14 @@ export async function resumeTask(
826
884
  return { ok: true };
827
885
  }
828
886
 
829
- function listCompletedFromTasks(cwd: string): TaskInfo[] {
887
+ export function listCompletedFromTasks(cwd: string): TaskInfo[] {
830
888
  const paused = new Set<string>([
831
889
  ...listTasks(cwd, "brainstorm").map((t) => t.dir),
832
- ...listTasks(cwd, "debug").map((t) => t.dir),
833
890
  ...listTasks(cwd, "review").map((t) => t.dir),
834
891
  ]);
835
892
  const results: TaskInfo[] = [];
836
893
 
837
- for (const type of ["brainstorm", "debug", "review"] as TaskType[]) {
894
+ for (const type of ["brainstorm", "review"] as TaskType[]) {
838
895
  const typeDir = join(cwd, ".pp", "state", type);
839
896
  if (!existsSync(typeDir)) continue;
840
897
  for (const entry of readdirSync(typeDir, { withFileTypes: true })) {
@@ -852,6 +909,27 @@ function listCompletedFromTasks(cwd: string): TaskInfo[] {
852
909
  }
853
910
  }
854
911
 
912
+ // Legacy .pp/state/debug/ tasks predate the removal of the debug task type, so
913
+ // their state.json cannot pass loadTask (its phase is a now-unsupported value).
914
+ // Read state.json directly so done debug tasks with artifacts stay reachable.
915
+ const debugDir = join(cwd, ".pp", "state", "debug");
916
+ if (existsSync(debugDir)) {
917
+ for (const entry of readdirSync(debugDir, { withFileTypes: true })) {
918
+ if (!entry.isDirectory()) continue;
919
+ const dir = join(debugDir, entry.name);
920
+ const sp = join(dir, "state.json");
921
+ if (!existsSync(sp)) continue;
922
+ try {
923
+ const state = JSON.parse(readFileSync(sp, "utf-8")) as TaskState;
924
+ if (state.phase !== "done") continue;
925
+ if (!existsSync(join(dir, "USER_REQUEST.md")) || !existsSync(join(dir, "RESEARCH.md"))) continue;
926
+ results.push({ dir, state, type: "implement" });
927
+ } catch {
928
+ continue;
929
+ }
930
+ }
931
+ }
932
+
855
933
  results.sort((a, b) => {
856
934
  const aTime = a.state.startedAt ? new Date(a.state.startedAt).getTime() : 0;
857
935
  const bTime = b.state.startedAt ? new Date(b.state.startedAt).getTime() : 0;
@@ -892,7 +970,6 @@ function collectRoleAssignments(config: Partial<PiPiConfig> | null): string[] {
892
970
 
893
971
  add("agents.orchestrators.implement", config.agents?.orchestrators?.implement?.model);
894
972
  add("agents.orchestrators.plan", config.agents?.orchestrators?.plan?.model);
895
- add("agents.orchestrators.debug", config.agents?.orchestrators?.debug?.model);
896
973
  add("agents.orchestrators.brainstorm", config.agents?.orchestrators?.brainstorm?.model);
897
974
  add("agents.orchestrators.review", config.agents?.orchestrators?.review?.model);
898
975
 
@@ -1094,7 +1171,7 @@ async function showFlantInfraMenu(orchestrator: Orchestrator, ctx: any): Promise
1094
1171
  }
1095
1172
 
1096
1173
  if (choice === "Update now") {
1097
- const result = await updateFlantInfra(orchestrator.pi);
1174
+ const result = await updateFlantInfra(orchestrator.pi, { force: true });
1098
1175
  const message = describeUpdateResult(result);
1099
1176
  ctx.ui.notify(message.text, message.kind);
1100
1177
  continue;
@@ -1341,7 +1418,6 @@ const ORCHESTRATOR_ROLES: Array<{ role: MainModelRole; label: string; descriptio
1341
1418
  { role: "brainstorm", label: "Brainstormer", description: "agents.orchestrators.brainstorm" },
1342
1419
  { role: "implement", label: "Implementer", description: "agents.orchestrators.implement" },
1343
1420
  { role: "plan", label: "Planner", description: "agents.orchestrators.plan" },
1344
- { role: "debug", label: "Debugger", description: "agents.orchestrators.debug" },
1345
1421
  { role: "review", label: "Reviewer", description: "agents.orchestrators.review" },
1346
1422
  { role: "quick", label: "Quick", description: "agents.orchestrators.quick" },
1347
1423
  ];
@@ -2924,7 +3000,7 @@ async function showMaxConcurrentSubagentsSetting(orchestrator: Orchestrator, ctx
2924
3000
  if (!action || action === "Back") break;
2925
3001
  if (action === "Edit") {
2926
3002
  const value = await pickMaxConcurrentSubagents(ctx, current);
2927
- if (value === null) continue;
3003
+ if (value === null || value === current) continue;
2928
3004
  applyScopeChoice(orchestrator, MAX_CONCURRENT_SUBAGENTS_PATH, value, await pickScope(ctx, orchestrator));
2929
3005
  continue;
2930
3006
  }
@@ -2940,7 +3016,7 @@ async function pickMaxConcurrentSubagents(ctx: any, current: number): Promise<nu
2940
3016
  );
2941
3017
  if (input === undefined || input === null) return null;
2942
3018
  const trimmed = String(input).trim();
2943
- if (trimmed === "") return null;
3019
+ if (trimmed === "") return current;
2944
3020
  if (!/^\d+$/.test(trimmed)) {
2945
3021
  ctx.ui.notify(`Please enter a positive integer between 1 and ${MAX_CONCURRENT_SUBAGENTS_CEILING}.`, "warning");
2946
3022
  continue;
@@ -3045,10 +3121,10 @@ async function showSettingsMenu(orchestrator: Orchestrator, ctx: any): Promise<t
3045
3121
  }
3046
3122
  }
3047
3123
 
3048
- // First phases (brainstorm/debug/review) are always interactive and can never run
3124
+ // First phases (brainstorm/review) are always interactive and can never run
3049
3125
  // autonomously — autonomous configs only ever cover plan/implement.
3050
3126
  export function autonomousPhasesForTask(type: TaskType): string[] {
3051
- if (type === "implement" || type === "debug" || type === "review") return ["plan", "implement"];
3127
+ if (type === "implement" || type === "review") return ["plan", "implement"];
3052
3128
  return [];
3053
3129
  }
3054
3130
 
@@ -3081,25 +3157,20 @@ async function showTaskModePicker(ctx: any): Promise<TaskMode | "back"> {
3081
3157
  }
3082
3158
 
3083
3159
  export async function pickMaxReviewPasses(ctx: any, current: number): Promise<number | null> {
3084
- const currentLabel = current >= 999 ? "-" : String(current);
3160
+ const currentLabel = current >= 999 ? "-" : current === 0 ? "0 (disabled)" : String(current);
3085
3161
  while (true) {
3086
3162
  const input = await ctx.ui.input(
3087
- `Max review passes (enter a positive integer, or "-" for unlimited) [${currentLabel}]`,
3163
+ `Max review passes (0 to disable, a positive integer, or "-" for unlimited) [${currentLabel}]`,
3088
3164
  );
3089
3165
  if (input === undefined || input === null) return null;
3090
3166
  const trimmed = String(input).trim();
3091
- if (trimmed === "") return null;
3167
+ if (trimmed === "") return current;
3092
3168
  if (trimmed === "-") return 999;
3093
3169
  if (!/^\d+$/.test(trimmed)) {
3094
- ctx.ui.notify('Please enter a positive integer, or "-" for unlimited.', "warning");
3170
+ ctx.ui.notify('Please enter 0 (disable), a positive integer, or "-" for unlimited.', "warning");
3095
3171
  continue;
3096
3172
  }
3097
- const parsed = Number.parseInt(trimmed, 10);
3098
- if (parsed <= 0) {
3099
- ctx.ui.notify('Please enter a positive integer, or "-" for unlimited.', "warning");
3100
- continue;
3101
- }
3102
- return parsed;
3173
+ return Number.parseInt(trimmed, 10);
3103
3174
  }
3104
3175
  }
3105
3176
 
@@ -3119,7 +3190,7 @@ async function showAutonomousPhaseSettings(
3119
3190
 
3120
3191
  while (true) {
3121
3192
  const reviewPreset = phaseConfig.reviewPreset ?? defaultAutonomousReviewPreset(type, phase);
3122
- const maxReview = phaseConfig.maxReviewPasses >= 999 ? "No limit" : String(phaseConfig.maxReviewPasses);
3193
+ const maxReview = phaseConfig.maxReviewPasses >= 999 ? "No limit" : phaseConfig.maxReviewPasses === 0 ? "0 (disabled)" : String(phaseConfig.maxReviewPasses);
3123
3194
  const options: OptionInput[] = [
3124
3195
  opt(`Review preset: ${reviewPreset}`, "Pick which reviewer group runs in this phase"),
3125
3196
  ];
@@ -3259,7 +3330,7 @@ interface ResumeFilters {
3259
3330
  }
3260
3331
 
3261
3332
  const STATUS_CYCLE: StatusFilter[] = ["Active", "Active + Done", "Done only", "All"];
3262
- const TYPE_CYCLE: Array<"All" | TaskType> = ["All", "implement", "debug", "brainstorm", "review", "quick"];
3333
+ const TYPE_CYCLE: Array<"All" | TaskType> = ["All", "implement", "brainstorm", "review", "quick"];
3263
3334
  const MODE_CYCLE: Array<"All" | TaskMode> = ["All", "guided", "autonomous"];
3264
3335
 
3265
3336
  function defaultResumeFilters(): ResumeFilters {
@@ -3380,7 +3451,7 @@ async function showFromMenu(orchestrator: Orchestrator, ctx: any): Promise<typeo
3380
3451
  while (true) {
3381
3452
  const tasks = listCompletedFromTasks(orchestrator.cwd);
3382
3453
  if (tasks.length === 0) {
3383
- ctx.ui.notify("No completed brainstorm/debug/review tasks with artifacts found.", "info");
3454
+ ctx.ui.notify("No completed brainstorm/review tasks with artifacts found.", "info");
3384
3455
  return BACK;
3385
3456
  }
3386
3457
 
@@ -3414,7 +3485,7 @@ async function showImplementMenu(orchestrator: Orchestrator, ctx: any): Promise<
3414
3485
  while (true) {
3415
3486
  const choice = await selectOption(ctx, "Implement", [
3416
3487
  { title: "New", description: "Start a new implementation from scratch" },
3417
- { title: "From", description: "Continue from a completed brainstorm or debug task" },
3488
+ { title: "From", description: "Continue from a completed brainstorm task" },
3418
3489
  { title: "Resume", description: "Resume a paused implementation" },
3419
3490
  { title: "Back", description: "Return to the previous menu" },
3420
3491
  ]);
@@ -3533,109 +3604,196 @@ async function openCodeReviewInPlannotator(
3533
3604
  // the cursor stays put and the user chooses Retry / Skip / Done. When the cursor
3534
3605
  // is exhausted or the user stops, clear the cursor and return null (fall back to
3535
3606
  // the menu).
3536
- async function runPlannotatorCursor(orchestrator: Orchestrator, ctx: any): Promise<string | null> {
3537
- const task = orchestrator.active;
3538
- if (!task) return null;
3539
- const cursor = task.state.plannotatorCursor;
3540
- if (!cursor) return null;
3607
+ // Prompt for the diff scope of one repo and open Plannotator for it. Returns the
3608
+ // review outcome, or null when the user backed out of the scope prompt (caller
3609
+ // decides what that means in its flow).
3610
+ async function reviewOneRepoInPlannotator(
3611
+ orchestrator: Orchestrator,
3612
+ ctx: any,
3613
+ repo: { path: string; isRoot: boolean },
3614
+ ): Promise<{ status: "approved" | "needs_changes" | "error"; feedback?: string; error?: string } | null> {
3615
+ const diffChoice = await selectOption(ctx, `Review: ${formatRepoLabel(repo)}`, [
3616
+ opt("All branch changes", "Committed changes vs base branch"),
3617
+ opt("Last commit", "Changes in the most recent commit"),
3618
+ opt("Since commit", "Review all changes since a specific commit"),
3619
+ opt("Uncommitted changes", "Working directory changes"),
3620
+ opt("Back", "Return without reviewing this repo"),
3621
+ ]);
3622
+ if (!diffChoice || diffChoice === "Back") return null;
3623
+
3624
+ let diffType: string;
3625
+ let defaultBranch: string | undefined;
3626
+ if (diffChoice === "All branch changes") {
3627
+ diffType = "branch";
3628
+ defaultBranch = await detectDefaultBranch(orchestrator, getRegisteredRepos(orchestrator), repo.path);
3629
+ } else if (diffChoice === "Last commit") {
3630
+ diffType = "last-commit";
3631
+ } else if (diffChoice === "Since commit") {
3632
+ const pickedHash = await pickCommitForRepo(orchestrator, ctx, repo);
3633
+ if (!pickedHash) return null;
3634
+ diffType = "branch";
3635
+ defaultBranch = pickedHash;
3636
+ } else {
3637
+ diffType = "uncommitted";
3638
+ }
3541
3639
 
3542
- while (task.state.plannotatorCursor && task.state.plannotatorCursor.index < task.state.plannotatorCursor.repoPaths.length) {
3543
- const cur = task.state.plannotatorCursor;
3544
- const repoPath = cur.repoPaths[cur.index];
3545
- const repo = getRegisteredRepos(orchestrator).find((r) => r.path === repoPath) ?? { path: repoPath, isRoot: false };
3546
-
3547
- const diffChoice = await selectOption(ctx, `Review: ${formatRepoLabel(repo)}`, [
3548
- opt("All branch changes", "Committed changes vs base branch"),
3549
- opt("Last commit", "Changes in the most recent commit"),
3550
- opt("Since commit", "Review all changes since a specific commit"),
3551
- opt("Uncommitted changes", "Working directory changes"),
3552
- opt("Skip this repo", "Move to the next repository"),
3553
- opt("Done (stop reviewing)", "Stop iterating repositories"),
3554
- ]);
3640
+ return openCodeReviewInPlannotator(orchestrator, { cwd: repo.path, diffType, defaultBranch });
3641
+ }
3642
+
3643
+ function resolveRepo(orchestrator: Orchestrator, repoPath: string): { path: string; isRoot: boolean } {
3644
+ return getRegisteredRepos(orchestrator).find((r) => r.path === repoPath) ?? { path: repoPath, isRoot: false };
3645
+ }
3646
+
3647
+ function plannotatorFixBanner(repo: { path: string; isRoot: boolean }, feedback: string | undefined, more: string): string {
3648
+ const fb = feedback ? `\n\nFeedback:\n${feedback}` : "";
3649
+ return advanceBanner(
3650
+ `[PI-PI] Plannotator requested changes for ${formatRepoLabel(repo)}.${fb}\n\n` +
3651
+ "Address the user's feedback. If the feedback contains questions, answer them. If it requests changes, " +
3652
+ `make the changes. Then call pp_phase_complete when done.\n\n${more}`,
3653
+ );
3654
+ }
3555
3655
 
3556
- if (!diffChoice || diffChoice === "Done (stop reviewing)") {
3656
+ // Single-repo review keeps the streamlined behavior: review the one repo, and on
3657
+ // needs_changes return the fix instruction immediately (no picker). Multi-repo
3658
+ // review is user-directed via the status picker below.
3659
+ async function runSingleRepoCursor(orchestrator: Orchestrator, ctx: any, repoPath: string): Promise<string | null> {
3660
+ const task = orchestrator.active!;
3661
+ const repo = resolveRepo(orchestrator, repoPath);
3662
+ while (true) {
3663
+ const result = await reviewOneRepoInPlannotator(orchestrator, ctx, repo);
3664
+ if (!result) {
3557
3665
  task.state.plannotatorCursor = undefined;
3666
+ restoreMenuStepAfterReview(orchestrator, task);
3558
3667
  saveTask(task.dir, task.state);
3559
3668
  return null;
3560
3669
  }
3561
- if (diffChoice === "Skip this repo") {
3562
- cur.index += 1;
3563
- saveTask(task.dir, task.state);
3564
- continue;
3565
- }
3566
-
3567
- let diffType: string;
3568
- let defaultBranch: string | undefined;
3569
- if (diffChoice === "All branch changes") {
3570
- diffType = "branch";
3571
- defaultBranch = await detectDefaultBranch(orchestrator, getRegisteredRepos(orchestrator), repo.path);
3572
- } else if (diffChoice === "Last commit") {
3573
- diffType = "last-commit";
3574
- } else if (diffChoice === "Since commit") {
3575
- const pickedHash = await pickCommitForRepo(orchestrator, ctx, repo);
3576
- if (!pickedHash) continue;
3577
- diffType = "branch";
3578
- defaultBranch = pickedHash;
3579
- } else {
3580
- diffType = "uncommitted";
3581
- }
3582
-
3583
- const result = await openCodeReviewInPlannotator(orchestrator, { cwd: repo.path, diffType, defaultBranch });
3584
-
3585
- // On error the repo is UNREVIEWED: do not advance the cursor (that would
3586
- // silently drop it from the pass). Keep it as the current repo until the
3587
- // user retries, explicitly skips, or stops.
3588
3670
  if (result.status === "error") {
3589
3671
  ctx.ui.notify(`${formatRepoLabel(repo)}: ERROR${result.error ? ` — ${result.error}` : ""}`, "warning");
3590
3672
  const errorChoice = await selectOption(ctx, `Review failed: ${formatRepoLabel(repo)}`, [
3591
3673
  opt("Retry", "Try reviewing this repository again"),
3592
- opt("Skip this repo", "Leave this repository unreviewed and move on"),
3593
- opt("Done (stop reviewing)", "Stop iterating repositories"),
3674
+ opt("Done (stop reviewing)", "Stop reviewing"),
3594
3675
  ]);
3595
3676
  if (!errorChoice || errorChoice === "Done (stop reviewing)") {
3596
3677
  task.state.plannotatorCursor = undefined;
3678
+ restoreMenuStepAfterReview(orchestrator, task);
3597
3679
  saveTask(task.dir, task.state);
3598
3680
  return null;
3599
3681
  }
3600
- if (errorChoice === "Skip this repo") {
3601
- cur.index += 1;
3602
- saveTask(task.dir, task.state);
3603
- }
3604
- // Retry: leave cur.index unchanged so the loop re-reviews this repo.
3605
3682
  continue;
3606
3683
  }
3684
+ if (result.status === "needs_changes") {
3685
+ task.state.plannotatorCursor = undefined;
3686
+ setStep(orchestrator, "llm_work");
3687
+ saveTask(task.dir, task.state);
3688
+ return plannotatorFixBanner(repo, result.feedback, "This was the last repo to review.");
3689
+ }
3690
+ ctx.ui.notify(`${formatRepoLabel(repo)}: APPROVED`, "info");
3691
+ task.state.plannotatorCursor = undefined;
3692
+ restoreMenuStepAfterReview(orchestrator, task);
3693
+ saveTask(task.dir, task.state);
3694
+ return null;
3695
+ }
3696
+ }
3607
3697
 
3608
- // Advance past this repo on a resolved outcome; on needs_changes the agent
3609
- // fixes it during the turn started by the returned instruction, then the next
3610
- // /pp resumes at the following repo.
3611
- cur.index += 1;
3612
- const exhausted = cur.index >= cur.repoPaths.length;
3698
+ // Multi-repo review is user-directed (#3): after each repo's review completes
3699
+ // (approved OR changes-requested) the loop does NOT auto-advance to the next repo
3700
+ // or auto-apply fixes. Instead a status picker lets the user explicitly pick the
3701
+ // next repo to review, start applying a changes-requested repo's fixes, or stop.
3702
+ async function runMultiRepoCursor(orchestrator: Orchestrator, ctx: any): Promise<string | null> {
3703
+ const task = orchestrator.active!;
3704
+ while (task.state.plannotatorCursor) {
3705
+ const cur = task.state.plannotatorCursor;
3706
+ const status = cur.status ?? {};
3707
+ const feedbackMap = cur.feedback ?? {};
3708
+
3709
+ const options: OptionInput[] = cur.repoPaths.map((repoPath) => {
3710
+ const repo = resolveRepo(orchestrator, repoPath);
3711
+ const st = status[repoPath];
3712
+ const label = st === "approved" ? "approved"
3713
+ : st === "changes-requested" ? "changes requested — select to apply fixes"
3714
+ : st === "fixes-applied" ? "fixes applied — select to re-review"
3715
+ : "unreviewed — select to review";
3716
+ return opt(formatRepoLabel(repo), label);
3717
+ });
3718
+ options.push(opt("Done (stop reviewing)", "Stop reviewing the remaining repositories"));
3613
3719
 
3614
- if (result.status === "needs_changes") {
3615
- if (exhausted) task.state.plannotatorCursor = undefined;
3720
+ const choice = await selectOption(ctx, "Plannotator review — pick a repository", options);
3721
+ if (!choice || choice === "Done (stop reviewing)") {
3722
+ task.state.plannotatorCursor = undefined;
3723
+ restoreMenuStepAfterReview(orchestrator, task);
3724
+ saveTask(task.dir, task.state);
3725
+ return null;
3726
+ }
3727
+
3728
+ const repoPath = cur.repoPaths.find((p) => formatRepoLabel(resolveRepo(orchestrator, p)) === choice);
3729
+ if (!repoPath) continue;
3730
+ const repo = resolveRepo(orchestrator, repoPath);
3731
+
3732
+ // A changes-requested repo the user selects: hand off the fix instruction now
3733
+ // (the user explicitly starts applying fixes). Clear its pending feedback and
3734
+ // move it to "fixes-applied" so the picker next offers a re-review (reopening
3735
+ // Plannotator) rather than repeating the fix handoff. The repo stays in the
3736
+ // cursor so the user returns to the picker after the agent's turn.
3737
+ if (status[repoPath] === "changes-requested") {
3738
+ const feedback = feedbackMap[repoPath];
3739
+ delete feedbackMap[repoPath];
3740
+ cur.feedback = feedbackMap;
3741
+ status[repoPath] = "fixes-applied";
3742
+ cur.status = status;
3616
3743
  setStep(orchestrator, "llm_work");
3617
3744
  saveTask(task.dir, task.state);
3618
- const feedback = result.feedback ? `\n\nFeedback:\n${result.feedback}` : "";
3619
- const more = exhausted
3620
- ? "This was the last repo to review."
3621
- : "After you finish, run /pp to continue Plannotator review of the remaining repositories.";
3622
- return advanceBanner(
3623
- `[PI-PI] Plannotator requested changes for ${formatRepoLabel(repo)}.${feedback}\n\n` +
3624
- "Address the user's feedback. If the feedback contains questions, answer them. If it requests changes, " +
3625
- `make the changes. Then call pp_phase_complete when done.\n\n${more}`,
3745
+ return plannotatorFixBanner(
3746
+ repo,
3747
+ feedback,
3748
+ "After you finish, run /pp to return to the Plannotator repository picker.",
3626
3749
  );
3627
3750
  }
3628
3751
 
3629
- ctx.ui.notify(`${formatRepoLabel(repo)}: APPROVED`, "info");
3752
+ // Otherwise (unreviewed or re-review of an approved repo): open Plannotator.
3753
+ const result = await reviewOneRepoInPlannotator(orchestrator, ctx, repo);
3754
+ if (!result) continue;
3755
+ if (result.status === "error") {
3756
+ ctx.ui.notify(`${formatRepoLabel(repo)}: ERROR${result.error ? ` — ${result.error}` : ""}`, "warning");
3757
+ continue;
3758
+ }
3759
+ cur.status = status;
3760
+ if (result.status === "needs_changes") {
3761
+ status[repoPath] = "changes-requested";
3762
+ if (result.feedback) {
3763
+ feedbackMap[repoPath] = result.feedback;
3764
+ cur.feedback = feedbackMap;
3765
+ }
3766
+ ctx.ui.notify(`${formatRepoLabel(repo)}: CHANGES REQUESTED — select it again to apply fixes.`, "info");
3767
+ } else {
3768
+ status[repoPath] = "approved";
3769
+ ctx.ui.notify(`${formatRepoLabel(repo)}: APPROVED`, "info");
3770
+ }
3630
3771
  saveTask(task.dir, task.state);
3631
3772
  }
3632
-
3633
- // Cursor exhausted with no outstanding changes: clear it and fall back to /pp.
3634
- task.state.plannotatorCursor = undefined;
3773
+ restoreMenuStepAfterReview(orchestrator, task);
3635
3774
  saveTask(task.dir, task.state);
3636
3775
  return null;
3637
3776
  }
3638
3777
 
3778
+ async function runPlannotatorCursor(orchestrator: Orchestrator, ctx: any): Promise<string | null> {
3779
+ const task = orchestrator.active;
3780
+ if (!task) return null;
3781
+ const cursor = task.state.plannotatorCursor;
3782
+ if (!cursor) return null;
3783
+
3784
+ if (cursor.repoPaths.length <= 1) {
3785
+ const only = cursor.repoPaths[cursor.index] ?? cursor.repoPaths[0];
3786
+ if (!only) {
3787
+ task.state.plannotatorCursor = undefined;
3788
+ restoreMenuStepAfterReview(orchestrator, task);
3789
+ saveTask(task.dir, task.state);
3790
+ return null;
3791
+ }
3792
+ return runSingleRepoCursor(orchestrator, ctx, only);
3793
+ }
3794
+ return runMultiRepoCursor(orchestrator, ctx);
3795
+ }
3796
+
3639
3797
  async function showReviewMenu(orchestrator: Orchestrator, ctx: any): Promise<typeof BACK | "started"> {
3640
3798
  while (true) {
3641
3799
  const choice = await selectOption(ctx, "Review", [
@@ -3674,19 +3832,7 @@ async function showTaskTypeMenu(
3674
3832
  if (!choice || choice === "Back") return BACK;
3675
3833
 
3676
3834
  if (choice === "New") {
3677
- let mode: TaskMode | undefined;
3678
- let autonomousConfig: AutonomousConfig | undefined;
3679
- if (type === "debug") {
3680
- const modeSelection = await pickModeForTaskStart(orchestrator, ctx, type);
3681
- if (!modeSelection) continue;
3682
- mode = modeSelection.mode;
3683
- autonomousConfig = modeSelection.autonomousConfig;
3684
- }
3685
- await orchestrator.startTask(ctx, type, type, undefined, undefined, mode);
3686
- if (orchestrator.active) {
3687
- orchestrator.active.state.autonomousConfig = autonomousConfig;
3688
- saveTask(orchestrator.active.dir, orchestrator.active.state);
3689
- }
3835
+ await orchestrator.startTask(ctx, type, type, undefined, undefined, undefined);
3690
3836
  return "started";
3691
3837
  }
3692
3838
 
@@ -3699,7 +3845,6 @@ async function showTaskMenu(orchestrator: Orchestrator, ctx: any): Promise<typeo
3699
3845
  while (true) {
3700
3846
  const choice = await selectOption(ctx, "Task", [
3701
3847
  { title: "Implement", description: "Want to make some changes? Research any topic or a codebase, brainstorm solutions and implement the chosen one" },
3702
- { title: "Debug", description: "Something is broken? Investigate it. If there is an issue — brainstorm solutions and fix it" },
3703
3848
  { title: "Brainstorm", description: "No idea where to start? Research any topic or a codebase. If there is a problem to solve — brainstorm solutions and solve it" },
3704
3849
  { title: "Review", description: "Want to ensure that some commits or a GitHub PR are good to go? Review it. Even fix it yourself, if you want" },
3705
3850
  { title: "Quick", description: "Quick freeform task — no phases, no reviews, just work" },
@@ -3708,12 +3853,6 @@ async function showTaskMenu(orchestrator: Orchestrator, ctx: any): Promise<typeo
3708
3853
  ]);
3709
3854
  if (!choice || choice === "Back") return BACK;
3710
3855
 
3711
- if (choice === "Debug") {
3712
- const result = await showTaskTypeMenu(orchestrator, ctx, "debug");
3713
- if (result === "started") return "started";
3714
- continue;
3715
- }
3716
-
3717
3856
  if (choice === "Brainstorm") {
3718
3857
  const result = await showTaskTypeMenu(orchestrator, ctx, "brainstorm");
3719
3858
  if (result === "started") return "started";
@@ -3917,7 +4056,7 @@ export async function showActiveTaskMenu(
3917
4056
  const isReviewPhase = phase === "review";
3918
4057
  const hasPlannotator = phase === "plan" || phase === "implement" || isReviewPhase;
3919
4058
  // The artifact the automated reviewers scan, mirroring reviewPresetGroupForPhase:
3920
- // brainstorm/debug → brainstormReviewers (research artifacts), plan → planReviewers
4059
+ // brainstorm → brainstormReviewers (research artifacts), plan → planReviewers
3921
4060
  // (synthesized plan), implement/review → codeReviewers (code changes). This is the primary
3922
4061
  // "Review" target named at the top level; the per-phase "Review on my own" description names
3923
4062
  // the manual editor pass's target separately (it can differ).
@@ -3969,6 +4108,11 @@ export async function showActiveTaskMenu(
3969
4108
  options.push(opt("Back to prompt", "Return to the prompt and keep working"));
3970
4109
 
3971
4110
  const headerLines = [`/pp\n\nTask: ${task.type}\nPhase: ${phase}`];
4111
+ if (task.state.reconcilePending) {
4112
+ headerLines.push("\n\n⚠ State files may be stale — reconcile pending; they'll be refreshed before the next review/planner run.");
4113
+ }
4114
+ const assumptionsBanner = openAssumptionsBanner(task.dir);
4115
+ if (assumptionsBanner) headerLines.push(`\n\n${assumptionsBanner}`);
3972
4116
  if (summary !== "/pp") headerLines.push(`\n\n${summary}`);
3973
4117
  const menuTitle = headerLines.join("");
3974
4118
  const { choice, cancelReason } = await selectOptionCancelable(ctx, menuTitle, options);
@@ -4047,7 +4191,7 @@ export async function showActiveTaskMenu(
4047
4191
  continue;
4048
4192
  }
4049
4193
  const passes = await pickMaxReviewPasses(ctx, 3);
4050
- if (passes === null) continue;
4194
+ if (passes === null || passes === 0) continue;
4051
4195
  finalizeReviewCycle(task);
4052
4196
  if (task.state.reviewPassByKind?.[phase]) task.state.reviewPassByKind[phase].auto = 0;
4053
4197
  task.state.reviewApprovedClean = false;
@@ -4134,7 +4278,7 @@ export async function showActiveTaskMenu(
4134
4278
  opt(autoLabel, `Run configured reviewers over ${reviewTarget}`),
4135
4279
  opt("Auto review until approved", `Loop reviewers over ${reviewTarget} until they unanimously approve, up to N passes`),
4136
4280
  ];
4137
- const hasArtifactPlannotator = phase === "brainstorm" || phase === "debug";
4281
+ const hasArtifactPlannotator = phase === "brainstorm";
4138
4282
  if (hasPlannotator) {
4139
4283
  reviewOptions.push(opt("Review in Plannotator", phase === "plan" ? "Open plan review in browser" : "Open code diff review in browser"));
4140
4284
  } else if (hasArtifactPlannotator) {
@@ -4152,7 +4296,7 @@ export async function showActiveTaskMenu(
4152
4296
 
4153
4297
  if (reviewChoice === "Review in Plannotator") {
4154
4298
  if (hasArtifactPlannotator) {
4155
- // brainstorm/debug have no diff or plan file: review the whole artifact
4299
+ // brainstorm has no diff or plan file: review the whole artifact
4156
4300
  // set in one annotate-folder pass over the task dir (walks USER_REQUEST.md +
4157
4301
  // RESEARCH.md + artifacts/*.md recursively). The result comes back on the
4158
4302
  // synchronous annotate respond callback, NOT via waitForPlannotatorResult.
@@ -4201,10 +4345,11 @@ export async function showActiveTaskMenu(
4201
4345
  ctx.ui.notify("No registered repositories have changes to review.", "info");
4202
4346
  continue;
4203
4347
  }
4204
- // Start (or restart) the interleaved per-repo cursor and run it. On
4205
- // needs_changes runPlannotatorCursor persists the cursor and returns a
4206
- // work instruction (exits the menu to start the agent turn); the next /pp
4207
- // resumes at the following repo.
4348
+ // Start (or restart) the per-repo cursor and run it. A single eligible
4349
+ // repo runs the streamlined flow; multiple repos open the user-directed
4350
+ // status picker. On a changes-requested handoff runPlannotatorCursor
4351
+ // persists the cursor and returns a work instruction (exits the menu to
4352
+ // start the agent turn); the next /pp resumes the cursor.
4208
4353
  task.state.plannotatorCursor = { repoPaths: eligible, index: 0 };
4209
4354
  saveTask(task.dir, task.state);
4210
4355
  const cursorText = await runPlannotatorCursor(orchestrator, ctx);
@@ -4247,7 +4392,7 @@ export async function showActiveTaskMenu(
4247
4392
  continue;
4248
4393
  }
4249
4394
  const passes = await pickMaxReviewPasses(ctx, 3);
4250
- if (passes === null) continue;
4395
+ if (passes === null || passes === 0) continue;
4251
4396
  finalizeReviewCycle(task);
4252
4397
  // Item 5: stop-in-phase loop. Reset the per-phase auto-pass counter so the
4253
4398
  // cap counts THIS run's passes, and clear any stale clean flag.
@@ -4265,6 +4410,7 @@ export async function showActiveTaskMenu(
4265
4410
 
4266
4411
  const reviewPreset = await pickPreset(ctx, orchestrator, getReviewPresetGroup(phase), "Review preset");
4267
4412
  if (!reviewPreset) continue;
4413
+ if (!(await confirmReviewDespiteStaleState(orchestrator, ctx))) continue;
4268
4414
  finalizeReviewCycle(task);
4269
4415
  if (!hasEnabledReviewers(orchestrator, reviewPreset)) {
4270
4416
  const reviewGroup = reviewPresetGroupForPhase(phase);