@ilya-lesikov/pi-pi 0.7.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 (75) hide show
  1. package/3p/pi-plannotator/apps/pi-extension/README.md +6 -5
  2. package/3p/pi-plannotator/apps/pi-extension/plannotator-browser.ts +25 -10
  3. package/3p/pi-plannotator/apps/pi-extension/plannotator-events.code-review.test.ts +172 -0
  4. package/3p/pi-plannotator/apps/pi-extension/plannotator-events.ts +50 -12
  5. package/3p/pi-plannotator/apps/pi-extension/server/project.test.ts +45 -0
  6. package/3p/pi-plannotator/apps/pi-extension/server/project.ts +7 -6
  7. package/3p/pi-plannotator/apps/pi-extension/server/serverReview.ts +41 -25
  8. package/3p/pi-subagents/src/agent-manager.ts +34 -1
  9. package/3p/pi-subagents/src/agent-runner.ts +66 -33
  10. package/3p/pi-subagents/src/index.ts +3 -38
  11. package/3p/pi-subagents/src/types.ts +4 -0
  12. package/extensions/orchestrator/agents/advisor.ts +35 -0
  13. package/extensions/orchestrator/agents/brainstorm-reviewer.ts +7 -7
  14. package/extensions/orchestrator/agents/code-reviewer.ts +27 -11
  15. package/extensions/orchestrator/agents/constraints.test.ts +82 -0
  16. package/extensions/orchestrator/agents/constraints.ts +66 -2
  17. package/extensions/orchestrator/agents/deep-debugger.ts +35 -0
  18. package/extensions/orchestrator/agents/plan-reviewer.ts +7 -7
  19. package/extensions/orchestrator/agents/planner.ts +9 -8
  20. package/extensions/orchestrator/agents/prompts.test.ts +120 -0
  21. package/extensions/orchestrator/agents/reviewer.ts +48 -0
  22. package/extensions/orchestrator/agents/task.ts +6 -20
  23. package/extensions/orchestrator/agents/tool-routing.ts +39 -1
  24. package/extensions/orchestrator/ai-comment-cleanup.test.ts +89 -0
  25. package/extensions/orchestrator/ai-comment-cleanup.ts +73 -0
  26. package/extensions/orchestrator/command-handlers.test.ts +47 -0
  27. package/extensions/orchestrator/command-handlers.ts +44 -28
  28. package/extensions/orchestrator/config.test.ts +40 -1
  29. package/extensions/orchestrator/config.ts +18 -2
  30. package/extensions/orchestrator/context.test.ts +54 -0
  31. package/extensions/orchestrator/context.ts +81 -2
  32. package/extensions/orchestrator/custom-footer.test.ts +91 -0
  33. package/extensions/orchestrator/custom-footer.ts +20 -20
  34. package/extensions/orchestrator/event-handlers.test.ts +412 -2
  35. package/extensions/orchestrator/event-handlers.ts +556 -227
  36. package/extensions/orchestrator/flant-infra.test.ts +25 -0
  37. package/extensions/orchestrator/flant-infra.ts +91 -0
  38. package/extensions/orchestrator/index.ts +1 -1
  39. package/extensions/orchestrator/integration.test.ts +411 -20
  40. package/extensions/orchestrator/messages.test.ts +30 -0
  41. package/extensions/orchestrator/messages.ts +6 -0
  42. package/extensions/orchestrator/model-registry.test.ts +48 -1
  43. package/extensions/orchestrator/model-registry.ts +43 -1
  44. package/extensions/orchestrator/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  45. package/extensions/orchestrator/orchestrator.test.ts +113 -0
  46. package/extensions/orchestrator/orchestrator.ts +197 -60
  47. package/extensions/orchestrator/phases/brainstorm.ts +20 -27
  48. package/extensions/orchestrator/phases/implementation.test.ts +11 -0
  49. package/extensions/orchestrator/phases/implementation.ts +4 -6
  50. package/extensions/orchestrator/phases/machine.test.ts +36 -0
  51. package/extensions/orchestrator/phases/machine.ts +11 -1
  52. package/extensions/orchestrator/phases/planning.test.ts +16 -0
  53. package/extensions/orchestrator/phases/planning.ts +11 -4
  54. package/extensions/orchestrator/phases/review-task.test.ts +20 -0
  55. package/extensions/orchestrator/phases/review-task.ts +47 -22
  56. package/extensions/orchestrator/phases/review.test.ts +34 -0
  57. package/extensions/orchestrator/phases/review.ts +44 -6
  58. package/extensions/orchestrator/plannotator.ts +9 -6
  59. package/extensions/orchestrator/pp-menu.test.ts +207 -1
  60. package/extensions/orchestrator/pp-menu.ts +514 -402
  61. package/extensions/orchestrator/pp-state-tools.test.ts +192 -0
  62. package/extensions/orchestrator/pp-state-tools.ts +249 -0
  63. package/extensions/orchestrator/rate-limit-fallback-flow.test.ts +128 -0
  64. package/extensions/orchestrator/rate-limit-fallback.test.ts +98 -0
  65. package/extensions/orchestrator/rate-limit-fallback.ts +243 -0
  66. package/extensions/orchestrator/state.test.ts +9 -0
  67. package/extensions/orchestrator/state.ts +15 -0
  68. package/extensions/orchestrator/test-helpers.ts +4 -1
  69. package/extensions/orchestrator/transition-controller.test.ts +100 -0
  70. package/extensions/orchestrator/transition-controller.ts +61 -3
  71. package/extensions/orchestrator/usage-tracker.ts +5 -1
  72. package/extensions/orchestrator/validate-artifacts.test.ts +20 -0
  73. package/extensions/orchestrator/validate-artifacts.ts +2 -2
  74. package/package.json +1 -1
  75. package/AGENTS.md +0 -28
@@ -1,4 +1,4 @@
1
- import { existsSync, readdirSync, writeFileSync } 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";
@@ -24,10 +24,12 @@ import {
24
24
  loadBrainstormReviewOutputs,
25
25
  loadCodeReviewOutputs,
26
26
  loadPlanReviewOutputs,
27
+ hasFinalPassAnchors,
27
28
  } from "./context.js";
28
- import { detectDefaultBranch, enterReviewCycle, finalizeReviewCycle } from "./event-handlers.js";
29
+ import { detectDefaultBranch, enterReviewCycle, finalizeReviewCycle, isReviewCycleLive } from "./event-handlers.js";
29
30
  import { Orchestrator } from "./orchestrator.js";
30
- import { cancelPendingPlannotatorWait } from "./plannotator.js";
31
+ import { cancelPendingPlannotatorWait, openPlannotator, waitForPlannotatorResult } from "./plannotator.js";
32
+ import { advanceBanner } from "./messages.js";
31
33
  import { spawnPlanners, spawnPlanReviewers } from "./phases/planning.js";
32
34
  import { spawnCodeReviewers } from "./phases/review.js";
33
35
  import { spawnBrainstormReviewers } from "./phases/brainstorm.js";
@@ -84,6 +86,7 @@ type TimeoutKey =
84
86
  | "performance.commands.afterEdit"
85
87
  | "performance.commands.afterImplement"
86
88
  | "performance.internals.subagentStale"
89
+ | "performance.internals.mainTurnStale"
87
90
  | "performance.internals.taskLockStale"
88
91
  | "performance.internals.taskLockRefresh";
89
92
 
@@ -104,16 +107,22 @@ async function selectOptionCancelable(
104
107
  question: string,
105
108
  options: OptionInput[],
106
109
  ): Promise<{ choice?: string; cancelReason?: CancelReason }> {
107
- const result = await askUser(ctx, {
108
- question,
109
- options,
110
- allowFreeform: false,
111
- allowComment: false,
112
- allowMultiple: false,
113
- });
114
- if (result && isCancel(result)) return { cancelReason: result.reason };
115
- if (!result || result.kind !== "selection") return {};
116
- return { choice: result.selections[0] };
110
+ const orchestrator = Orchestrator.current;
111
+ if (orchestrator) orchestrator.interactivePromptOpen = true;
112
+ try {
113
+ const result = await askUser(ctx, {
114
+ question,
115
+ options,
116
+ allowFreeform: false,
117
+ allowComment: false,
118
+ allowMultiple: false,
119
+ });
120
+ if (result && isCancel(result)) return { cancelReason: result.reason };
121
+ if (!result || result.kind !== "selection") return {};
122
+ return { choice: result.selections[0] };
123
+ } finally {
124
+ if (orchestrator) orchestrator.interactivePromptOpen = false;
125
+ }
117
126
  }
118
127
 
119
128
  function opt(title: string, description: string): OptionInput {
@@ -150,51 +159,7 @@ function formatRepoList(repos: RepoInfo[]): string {
150
159
  .join("\n");
151
160
  }
152
161
 
153
- function appendSection(content: string, heading: string, body: string): string {
154
- const normalized = content.trimEnd();
155
- if (normalized.includes(`${heading}\n`)) return normalized + "\n";
156
- return `${normalized}\n\n${heading}\n${body}\n`;
157
- }
158
-
159
- function appendToSection(content: string, heading: string, body: string): string {
160
- const normalized = content.trimEnd();
161
- if (normalized.includes(body)) return normalized + "\n";
162
-
163
- const lines = normalized.split("\n");
164
- const sectionIndex = lines.findIndex((line) => line.trim() === heading);
165
- if (sectionIndex === -1) {
166
- return appendSection(normalized, heading, body);
167
- }
168
162
 
169
- let insertIndex = lines.length;
170
- for (let i = sectionIndex + 1; i < lines.length; i += 1) {
171
- if (lines[i]?.startsWith("## ")) {
172
- insertIndex = i;
173
- break;
174
- }
175
- }
176
-
177
- const insertLines = body.split("\n");
178
- if (insertIndex > sectionIndex + 1 && lines[insertIndex - 1]?.trim() !== "") {
179
- insertLines.unshift("");
180
- }
181
- lines.splice(insertIndex, 0, ...insertLines);
182
- return lines.join("\n") + "\n";
183
- }
184
-
185
- function appendRepoContext(content: string, repos: RepoInfo[]): string {
186
- if (repos.length === 0) return content;
187
- const lines = repos.map((repo) => `- ${formatRepoLabel(repo)}${repo.baseBranch ? ` (base: ${repo.baseBranch})` : ""}`);
188
- return appendToSection(content, "## Constraints", `Registered repositories:\n${lines.join("\n")}`);
189
- }
190
-
191
- function appendResearchOpenQuestions(content: string, text: string): string {
192
- const normalized = content.trimEnd();
193
- if (normalized.includes("## Open Questions\n")) {
194
- return `${normalized}\n${text}\n`;
195
- }
196
- return `${normalized}\n\n## Open Questions\n${text}\n`;
197
- }
198
163
 
199
164
  async function pickCommitForRepo(orchestrator: Orchestrator, ctx: any, repo: RepoInfo): Promise<string | null> {
200
165
  let commits: Array<{ hash: string; message: string; age: string }> = [];
@@ -225,6 +190,44 @@ async function pickCommitForRepo(orchestrator: Orchestrator, ctx: any, repo: Rep
225
190
  return pickedHash || null;
226
191
  }
227
192
 
193
+ async function repoHasReviewableChanges(
194
+ orchestrator: Orchestrator,
195
+ repo: RepoInfo,
196
+ base: string,
197
+ ): Promise<{ changed: boolean; error?: string }> {
198
+ const run = async (args: string[]): Promise<{ out: string; failed: boolean }> => {
199
+ try {
200
+ const res = await orchestrator.pi.exec("git", args, { cwd: repo.path, timeout: 5000 });
201
+ if (res.code !== 0) return { out: "", failed: true };
202
+ return { out: res.stdout, failed: false };
203
+ } catch {
204
+ return { out: "", failed: true };
205
+ }
206
+ };
207
+
208
+ const committed = await run(["diff", "--name-only", `${base}...HEAD`]);
209
+ const unstaged = await run(["diff", "--name-only"]);
210
+ const staged = await run(["diff", "--cached", "--name-only"]);
211
+ const status = await run(["status", "--porcelain"]);
212
+
213
+ const hasCommitted = committed.out.trim().length > 0;
214
+ const hasUnstaged = unstaged.out.trim().length > 0;
215
+ const hasStaged = staged.out.trim().length > 0;
216
+ const hasUntracked = status.out.split("\n").some((line) => line.startsWith("??"));
217
+ const changed = hasCommitted || hasUnstaged || hasStaged || hasUntracked;
218
+
219
+ if (changed) return { changed: true };
220
+
221
+ // No change detected — but a failed probe (e.g. a missing/invalid base ref)
222
+ // could be masking real changes, so surface it rather than silently hiding
223
+ // the repo.
224
+ if (committed.failed || unstaged.failed || staged.failed || status.failed) {
225
+ return { changed: false, error: "git status/diff failed" };
226
+ }
227
+
228
+ return { changed: false };
229
+ }
230
+
228
231
  interface RepoPrContext {
229
232
  repoPath: string;
230
233
  prUrl: string | null;
@@ -237,6 +240,84 @@ function setStep(orchestrator: Orchestrator, step: string): void {
237
240
  saveTask(orchestrator.active.dir, orchestrator.active.state);
238
241
  }
239
242
 
243
+ // Publish the latest synthesized review findings to a target. The agent (not the
244
+ // extension) performs the writes: file comments = insert `AI_COMMENT:` markers at
245
+ // each finding's line; PR comments = run `gh` to post line-anchored comments from
246
+ // the user's own account. Both are idempotent — the agent checks for markers/
247
+ // comments it already published and skips duplicates, so re-publishing is safe.
248
+ const PRIVACY_INSTRUCTION =
249
+ "PRIVACY: comment bodies MUST be self-contained observations about the code itself. Do NOT reference " +
250
+ "private or internal details, \"the ticket\", issue trackers, or internal design docs. Say what is wrong " +
251
+ "in the code, not that it \"violates the design goal\" of some private document.";
252
+
253
+ export function publishFileCommentsBanner(taskDir: string): string {
254
+ const reviewsDir = join(taskDir, "code-reviews");
255
+ return advanceBanner(
256
+ "[PI-PI] Publish the synthesized review findings as FILE COMMENTS now.\n\n" +
257
+ `Use the \`ANCHORS:\` block in the latest \`${reviewsDir}/*_final_pass-*.md\` as the file:line source — do NOT invent locations.\n\n` +
258
+ "For each accepted finding, insert an `AI_COMMENT:` marker (inside each file's native comment syntax, e.g. `// AI_COMMENT: ...`, `# AI_COMMENT: ...`, `<!-- AI_COMMENT: ... -->`) on or immediately above the cited line, briefly stating the finding. " +
259
+ "This is the ONLY source edit permitted — no fixes, no other changes.\n\n" +
260
+ PRIVACY_INSTRUCTION + "\n\n" +
261
+ "IDEMPOTENT: before inserting, check whether an equivalent `AI_COMMENT:` marker for that finding is already present at that location (e.g. from an earlier publish). If so, skip it — do NOT add a duplicate.\n\n" +
262
+ "When done, report how many markers you inserted and how many you skipped as already-present, then end your turn.",
263
+ );
264
+ }
265
+
266
+ export function publishPrCommentsBanner(taskDir: string): string {
267
+ const reviewsDir = join(taskDir, "code-reviews");
268
+ return advanceBanner(
269
+ "[PI-PI] Publish the synthesized review findings as GITHUB PR COMMENTS now, from the user's own `gh`-authenticated account.\n\n" +
270
+ `Use the \`ANCHORS:\` block in the latest \`${reviewsDir}/*_final_pass-*.md\` as the file:line source — do NOT invent locations.\n\n` +
271
+ "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" +
272
+ "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" +
273
+ "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" +
274
+ "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" +
275
+ "The LAST line of every finding's comment body MUST be exactly:\n(generated by pi-pi)\n\n" +
276
+ PRIVACY_INSTRUCTION + "\n\n" +
277
+ "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" +
278
+ "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" +
279
+ "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.",
280
+ );
281
+ }
282
+
283
+ // Guard for the Publish menu: both banners consume the `ANCHORS:` block from the
284
+ // latest `code-reviews/*_final_pass-*.md`. If no such file exists, or the newest one
285
+ // carries no `ANCHORS:` block, publishing would spawn an agent that immediately fails.
286
+ // Returns a user-facing message to show instead, or undefined when publishing can proceed.
287
+ export const MISSING_FINAL_PASS_ANCHORS =
288
+ "No ANCHORS-bearing final review file exists yet. Run or finish a review pass first " +
289
+ "(/pp → Review) so the findings are synthesized into `code-reviews/*_final_pass-*.md`.";
290
+
291
+ export function publishGuard(taskDir: string): string | undefined {
292
+ return hasFinalPassAnchors(taskDir) ? undefined : MISSING_FINAL_PASS_ANCHORS;
293
+ }
294
+
295
+ const AI_REVIEW_MARKER_SYNTAX =
296
+ "(inside each file's native comment syntax, e.g. `// AI_REVIEW: ...`, `# AI_REVIEW: ...`, `<!-- AI_REVIEW: ... -->`)";
297
+
298
+ const AI_REVIEW_MARKER_LOOP =
299
+ "For each marker: address the request, then remove that marker in the SAME edit. After a pass, re-scan the target files and " +
300
+ "repeat until no `AI_REVIEW:` markers remain. Then verify your work and report what you changed per marker. When complete, " +
301
+ "call pp_phase_complete.";
302
+
303
+ // Read-only phases (brainstorm/debug/review) and plan produce markdown state files rather than
304
+ // source changes, so their AI_REVIEW scan targets those artifacts. Missing targets are skipped,
305
+ // not errors. Kept as literal-token guidance (no regexp/parser), mirroring the implement banner.
306
+ function readOnlyReviewBanner(phase: string, taskDir: string): string {
307
+ const targets =
308
+ phase === "plan"
309
+ ? "the synthesized plan(s) at `" + taskDir + "/plans/*_synthesized.md` ONLY (ignore raw planner outputs, " +
310
+ "`review_*` files, and the `plan-reviews/`, `brainstorm-reviews/`, `code-reviews/` directories)"
311
+ : "`" + taskDir + "/USER_REQUEST.md`, `" + taskDir + "/RESEARCH.md`, and `" + taskDir + "/artifacts/*.md`";
312
+ return advanceBanner(
313
+ `[PI-PI] The user reviewed this phase's state files in their editor and left inline \`AI_REVIEW:\` markers ` +
314
+ `${AI_REVIEW_MARKER_SYNTAX}.\n\n` +
315
+ `Search WITHIN ${targets} for \`AI_REVIEW:\`. Only scan those files — skip any target that does not exist ` +
316
+ `(a missing \`artifacts/*.md\` or a not-yet-produced state file is fine, not an error).\n\n` +
317
+ AI_REVIEW_MARKER_LOOP,
318
+ );
319
+ }
320
+
240
321
  function showStatus(orchestrator: Orchestrator, ctx: any): void {
241
322
  if (!orchestrator.active) {
242
323
  ctx.ui.notify("No active task.", "info");
@@ -252,6 +333,11 @@ function showStatus(orchestrator: Orchestrator, ctx: any): void {
252
333
  }
253
334
 
254
335
  async function abortCurrentWork(orchestrator: Orchestrator, ctx: any): Promise<void> {
336
+ // Clear pi-pi's own delayed post-error retry (timer + ESC interrupt). Unlike
337
+ // pause/finish, abortCurrentWork does NOT go through cleanupActive/
338
+ // resetTaskScopedState, so without this a scheduled retry would survive the
339
+ // abort and re-nudge seconds later.
340
+ orchestrator.cancelPendingRetry();
255
341
  orchestrator.abortAllSubagents();
256
342
  orchestrator.transitionController.abortMainAgent(ctx.abort?.bind(ctx));
257
343
  await ctx.waitForIdle?.();
@@ -272,6 +358,9 @@ async function pauseTask(orchestrator: Orchestrator, ctx: any): Promise<string>
272
358
  const type = orchestrator.active.type;
273
359
 
274
360
  orchestrator.active.state.reviewCycle = null;
361
+ // Drop any in-progress per-repo Plannotator cursor so a resumed task does not
362
+ // auto-reopen Plannotator; the user re-enters Review explicitly.
363
+ orchestrator.active.state.plannotatorCursor = undefined;
275
364
  saveTask(orchestrator.active.dir, orchestrator.active.state);
276
365
  unregisterAgentDefinitions(orchestrator.pi);
277
366
  await orchestrator.cleanupActive();
@@ -296,6 +385,13 @@ async function pauseTask(orchestrator: Orchestrator, ctx: any): Promise<string>
296
385
  async function finishTask(orchestrator: Orchestrator, ctx: any): Promise<string> {
297
386
  if (!orchestrator.active) return "No active task.";
298
387
 
388
+ // The review phase must produce the ANCHORS-bearing final_pass file Publish
389
+ // consumes; block the direct Complete path too (it bypasses validateExitCriteria).
390
+ if (orchestrator.active.state.phase === "review" && !hasFinalPassAnchors(orchestrator.active.dir)) {
391
+ ctx.ui.notify(MISSING_FINAL_PASS_ANCHORS, "warning");
392
+ return MISSING_FINAL_PASS_ANCHORS;
393
+ }
394
+
299
395
  cancelPendingPlannotatorWait(orchestrator);
300
396
  orchestrator.abortAllSubagents();
301
397
  orchestrator.transitionController.abortMainAgent(ctx.abort?.bind(ctx));
@@ -322,7 +418,8 @@ async function finishTask(orchestrator: Orchestrator, ctx: any): Promise<string>
322
418
  // is over, nothing awaits this compaction).
323
419
  void orchestrator.transitionController.requestTransition({
324
420
  kind: "done",
325
- summary: `Task "${name}" (${type}) completed.`,
421
+ discard: true,
422
+ 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.`,
326
423
  });
327
424
 
328
425
  const urExists = existsSync(join(dir, "USER_REQUEST.md"));
@@ -795,6 +892,9 @@ function collectRoleAssignments(config: Partial<PiPiConfig> | null): string[] {
795
892
  add("agents.subagents.simple.explore", config.agents?.subagents?.simple?.explore?.model);
796
893
  add("agents.subagents.simple.librarian", config.agents?.subagents?.simple?.librarian?.model);
797
894
  add("agents.subagents.simple.task", config.agents?.subagents?.simple?.task?.model);
895
+ add("agents.subagents.simple.advisor", config.agents?.subagents?.simple?.advisor?.model);
896
+ add("agents.subagents.simple.deep-debugger", config.agents?.subagents?.simple?.["deep-debugger"]?.model);
897
+ add("agents.subagents.simple.reviewer", config.agents?.subagents?.simple?.reviewer?.model);
798
898
  return out;
799
899
  }
800
900
 
@@ -814,6 +914,7 @@ function flantStatusText(settings: FlantSettings): string {
814
914
  lines.push(
815
915
  `Personal subscription: on (${subActive ? `active — pp-flant-anthropic-sub, ${providers.anthropic} models` : "inactive"})`,
816
916
  );
917
+ lines.push(`Rate-limit switch-back check: every ${settings.switchBackIntervalMinutes} min`);
817
918
  if (!subActive) {
818
919
  if (!hasOAuth) lines.push(" - missing Claude OAuth token (run pi /login for Anthropic)");
819
920
  if (!hasGatewayKey) lines.push(" - missing gateway key (set LLM_API_KEY or FLANT_API_KEY)");
@@ -849,15 +950,22 @@ async function showFlantInfraMenu(orchestrator: Orchestrator, ctx: any): Promise
849
950
  while (true) {
850
951
  const settings = loadFlantSettings();
851
952
  const enableLabel = `Enable: ${settings.enabled ? "ON" : "OFF"}`;
852
- const options: OptionInput[] = [enableLabel];
953
+ const options: OptionInput[] = [
954
+ { title: enableLabel, description: "Turn the Flant AI model providers on or off" },
955
+ ];
853
956
  const subscriptionLabel = `Personal Claude subscription: ${settings.subscription ? "ON" : "OFF"}`;
854
957
  if (settings.enabled) {
855
958
  options.push(
856
- `Auto-update on startup: ${settings.autoUpdate ? "ON" : "OFF"}`,
857
- `Cache period: ${settings.cacheTTLDays} ${settings.cacheTTLDays === 1 ? "day" : "days"}`,
858
- subscriptionLabel,
859
- "Update now",
860
- "Current status",
959
+ { title: subscriptionLabel, description: "Route Claude roles through your personal Claude subscription instead of the gateway" },
960
+ { title: `Auto-update on startup: ${settings.autoUpdate ? "ON" : "OFF"}`, description: "Refresh the available model list automatically each time pi starts" },
961
+ { title: `Cache period: ${settings.cacheTTLDays} ${settings.cacheTTLDays === 1 ? "day" : "days"}`, description: "How long the fetched model list is reused before it is refreshed" },
962
+ );
963
+ if (settings.subscription) {
964
+ options.push({ title: `Rate-limit switch-back check: every ${settings.switchBackIntervalMinutes} min`, description: "How often to retry your subscription after it was rate-limited and traffic fell back to the gateway" });
965
+ }
966
+ options.push(
967
+ { title: "Update now", description: "Fetch the latest model list from Flant right away" },
968
+ { title: "Current status", description: "Show the current Flant configuration, providers, and model counts" },
861
969
  );
862
970
  }
863
971
  options.push({ title: "Back", description: "Return to the previous menu" });
@@ -920,6 +1028,22 @@ async function showFlantInfraMenu(orchestrator: Orchestrator, ctx: any): Promise
920
1028
  continue;
921
1029
  }
922
1030
 
1031
+ if (choice.startsWith("Rate-limit switch-back check:")) {
1032
+ const selected = await selectOption(ctx, "Switch-back check interval", [
1033
+ { title: "15 min", description: "Probe the subscription limit every 15 minutes" },
1034
+ { title: "30 min", description: "Default — probe every 30 minutes" },
1035
+ { title: "60 min", description: "Probe hourly" },
1036
+ { title: "120 min", description: "Probe every two hours" },
1037
+ { title: "Back", description: "Return to the previous menu" },
1038
+ ]);
1039
+ if (!selected || selected === "Back") continue;
1040
+ const mins = Number(selected.split(" ")[0]);
1041
+ if (!Number.isFinite(mins) || mins <= 0) continue;
1042
+ saveFlantSettings({ ...settings, switchBackIntervalMinutes: mins });
1043
+ ctx.ui.notify(`Switch-back check interval set to ${mins} min.`, "info");
1044
+ continue;
1045
+ }
1046
+
923
1047
  if (choice.startsWith("Cache period:")) {
924
1048
  const selected = await selectOption(ctx, "Cache period", [
925
1049
  { title: "1 day", description: "Refresh model metadata daily" },
@@ -1199,6 +1323,9 @@ const SUBAGENT_ROLES: Array<{ role: AgentRole; label: string; description: strin
1199
1323
  { role: "explore", label: "Explore", description: "agents.subagents.simple.explore" },
1200
1324
  { role: "librarian", label: "Librarian", description: "agents.subagents.simple.librarian" },
1201
1325
  { role: "task", label: "Task", description: "agents.subagents.simple.task" },
1326
+ { role: "advisor", label: "Advisor", description: "agents.subagents.simple.advisor" },
1327
+ { role: "deep-debugger", label: "Deep debugger", description: "agents.subagents.simple.deep-debugger" },
1328
+ { role: "reviewer", label: "Reviewer", description: "agents.subagents.simple.reviewer" },
1202
1329
  ];
1203
1330
 
1204
1331
  const PRESET_GROUP_ITEMS: Array<{ group: PresetGroup; label: string }> = [
@@ -1212,6 +1339,7 @@ const TIMEOUT_LABELS: Record<TimeoutKey, string> = {
1212
1339
  "performance.commands.afterEdit": "Command after file edit",
1213
1340
  "performance.commands.afterImplement": "Command after implementation",
1214
1341
  "performance.internals.subagentStale": "Subagent stale",
1342
+ "performance.internals.mainTurnStale": "Main turn stale",
1215
1343
  "performance.internals.taskLockStale": "Lock stale",
1216
1344
  "performance.internals.taskLockRefresh": "Lock update",
1217
1345
  };
@@ -1753,8 +1881,8 @@ async function showOrchestratorEditor(
1753
1881
  const current = orchestrator.config.agents.orchestrators[role];
1754
1882
  const basePath = ["agents", "orchestrators", role];
1755
1883
  const choice = await selectOption(ctx, label, [
1756
- opt(`Model: ${current.model}`, "Select model"),
1757
- opt(`Thinking: ${thinkingLabel(current.thinking)}`, "Select thinking level"),
1884
+ opt(`Model: ${current.model}`, "Choose the model for this agent"),
1885
+ opt(`Thinking: ${thinkingLabel(current.thinking)}`, "Choose how much this agent thinks before acting"),
1758
1886
  ...buildResetOptions(orchestrator, basePath),
1759
1887
  opt("Back", "Return to the previous menu"),
1760
1888
  ]);
@@ -1800,8 +1928,8 @@ async function showSimpleSubagentEditor(
1800
1928
  const current = orchestrator.config.agents.subagents.simple[role];
1801
1929
  const basePath = ["agents", "subagents", "simple", role];
1802
1930
  const choice = await selectOption(ctx, label, [
1803
- opt(`Model: ${current.model}`, "Select model"),
1804
- opt(`Thinking: ${thinkingLabel(current.thinking)}`, "Select thinking level"),
1931
+ opt(`Model: ${current.model}`, "Choose the model for this agent"),
1932
+ opt(`Thinking: ${thinkingLabel(current.thinking)}`, "Choose how much this agent thinks before acting"),
1805
1933
  ...buildResetOptions(orchestrator, basePath),
1806
1934
  opt("Back", "Return to the previous menu"),
1807
1935
  ]);
@@ -1902,8 +2030,8 @@ async function showPresetVariantEditor(
1902
2030
  const variantPath = ["agents", "subagents", "presetGroups", group, "presets", presetName, "agents", variantName];
1903
2031
  const options: OptionInput[] = [
1904
2032
  opt(`Enabled: ${isEnabled(variant) ? "Yes" : "No"}`, "Toggle enabled state"),
1905
- opt(`Model: ${variant.model}`, "Select model"),
1906
- opt(`Thinking: ${thinkingLabel(variant.thinking)}`, "Select thinking level"),
2033
+ opt(`Model: ${variant.model}`, "Choose the model for this agent"),
2034
+ opt(`Thinking: ${thinkingLabel(variant.thinking)}`, "Choose how much this agent thinks before acting"),
1907
2035
  ];
1908
2036
  if (getOwnedScopes(orchestrator, variantPath).length > 0) {
1909
2037
  options.push(opt("Delete", "Delete this agent override"));
@@ -1924,7 +2052,7 @@ async function showPresetVariantEditor(
1924
2052
  continue;
1925
2053
  }
1926
2054
  if (choice.startsWith("Enabled:")) {
1927
- await showBooleanSetting(orchestrator, ctx, "Enabled", [...variantPath, "enabled"]);
2055
+ await showBooleanSetting(orchestrator, ctx, "Enabled", [...variantPath, "enabled"], "Make this agent available for use", "Disable this agent so it is not used");
1928
2056
  continue;
1929
2057
  }
1930
2058
  const confirm = await selectOption(ctx, "Confirm delete?", [
@@ -1955,7 +2083,7 @@ async function showPresetAgentsMenu(
1955
2083
  options.push(opt(title, `${variant.model} / ${thinkingLabel(variant.thinking)}`));
1956
2084
  byTitle.set(title, variantName);
1957
2085
  }
1958
- options.push(opt("New agent", "Add a new agent"));
2086
+ options.push(opt("New agent", "Add an agent variant to this preset"));
1959
2087
  options.push(opt("Back", "Return to the previous menu"));
1960
2088
  const choice = await selectOption(ctx, "Agents", options);
1961
2089
  if (!choice || choice === "Back") return BACK;
@@ -2065,8 +2193,8 @@ async function showPresetEnabledSetting(
2065
2193
  const yesTitle = withTags("Yes", formatSourceTags(true, info));
2066
2194
  const noTitle = withTags("No", formatSourceTags(false, info));
2067
2195
  const choice = await selectOption(ctx, "Enabled", [
2068
- yesTitle,
2069
- noTitle,
2196
+ { title: yesTitle, description: "Make this preset available for selection" },
2197
+ { title: noTitle, description: "Hide this preset so it can no longer be selected" },
2070
2198
  opt("Back", "Return to the previous menu"),
2071
2199
  ]);
2072
2200
  if (!choice || choice === "Back") return;
@@ -2124,7 +2252,7 @@ async function showPresetSettings(
2124
2252
  options.push(opt(optionTitle, enabledPresetSummary(preset.agents)));
2125
2253
  byTitle.set(optionTitle, presetName);
2126
2254
  }
2127
- options.push(opt("New preset", "Create preset"));
2255
+ options.push(opt("New preset", "Create a preset in this group"));
2128
2256
  options.push(opt("Back", "Return to the previous menu"));
2129
2257
  const choice = await selectOption(ctx, title, options);
2130
2258
  if (!choice || choice === "Back") return BACK;
@@ -2198,7 +2326,7 @@ async function showAfterEditCommands(orchestrator: Orchestrator, ctx: any): Prom
2198
2326
  options.push(opt(`${title}${enabledTag}`, `${globsCount} patterns — ${cmd.run}`));
2199
2327
  byTitle.set(`${title}${enabledTag}`, id);
2200
2328
  }
2201
- options.push(opt("New command", "Add command"));
2329
+ options.push(opt("New command", "Add a command to run after a file edit"));
2202
2330
  options.push(...buildResetOptions(orchestrator, ["commands", "afterEdit"]));
2203
2331
  options.push(opt("Back", "Return to the previous menu"));
2204
2332
  const choice = await selectOption(ctx, `After file edit: ${entries.length} commands`, options);
@@ -2247,7 +2375,7 @@ async function showAfterEditCommands(orchestrator: Orchestrator, ctx: any): Prom
2247
2375
  continue;
2248
2376
  }
2249
2377
  if (commandChoice.startsWith("Enabled:")) {
2250
- await showBooleanSetting(orchestrator, ctx, "Enabled", [...commandPath, "enabled"]);
2378
+ await showBooleanSetting(orchestrator, ctx, "Enabled", [...commandPath, "enabled"], "Run this command when its triggers fire", "Keep this command configured but stop running it");
2251
2379
  continue;
2252
2380
  }
2253
2381
  if (commandChoice === "Triggers") {
@@ -2327,7 +2455,7 @@ async function showAfterImplementCommands(orchestrator: Orchestrator, ctx: any):
2327
2455
  options.push(opt(title, cmd.run));
2328
2456
  byTitle.set(title, id);
2329
2457
  }
2330
- options.push(opt("New command", "Add command"));
2458
+ options.push(opt("New command", "Add a command to run after implementation"));
2331
2459
  options.push(...buildResetOptions(orchestrator, ["commands", "afterImplement"]));
2332
2460
  options.push(opt("Back", "Return to the previous menu"));
2333
2461
  const choice = await selectOption(ctx, `After implementation: ${entries.length} commands`, options);
@@ -2364,7 +2492,7 @@ async function showAfterImplementCommands(orchestrator: Orchestrator, ctx: any):
2364
2492
  continue;
2365
2493
  }
2366
2494
  if (commandChoice.startsWith("Enabled:")) {
2367
- await showBooleanSetting(orchestrator, ctx, "Enabled", [...commandPath, "enabled"]);
2495
+ await showBooleanSetting(orchestrator, ctx, "Enabled", [...commandPath, "enabled"], "Run this command when its triggers fire", "Keep this command configured but stop running it");
2368
2496
  continue;
2369
2497
  }
2370
2498
  const confirm = await selectOption(ctx, "Confirm delete?", [
@@ -2387,11 +2515,11 @@ async function showCommandsSettings(orchestrator: Orchestrator, ctx: any): Promi
2387
2515
  const choice = await selectOption(ctx, "Commands", [
2388
2516
  opt(
2389
2517
  `After file edit: ${afterEditCount} commands`,
2390
- `${afterEditCount} commands`,
2518
+ "Commands run when a matching file is edited (e.g. format, lint)",
2391
2519
  ),
2392
2520
  opt(
2393
2521
  `After implementation: ${afterImplementCount} commands`,
2394
- `${afterImplementCount} commands`,
2522
+ "Commands run once the implement phase completes (e.g. build, test)",
2395
2523
  ),
2396
2524
  opt("Back", "Return to the previous menu"),
2397
2525
  ]);
@@ -2422,6 +2550,11 @@ async function showTimeoutsSettings(orchestrator: Orchestrator, ctx: any): Promi
2422
2550
  path: ["performance", "internals", "subagentStale"],
2423
2551
  value: orchestrator.config.performance.internals.subagentStale,
2424
2552
  },
2553
+ {
2554
+ key: "performance.internals.mainTurnStale",
2555
+ path: ["performance", "internals", "mainTurnStale"],
2556
+ value: orchestrator.config.performance.internals.mainTurnStale,
2557
+ },
2425
2558
  {
2426
2559
  key: "performance.internals.taskLockStale",
2427
2560
  path: ["performance", "internals", "taskLockStale"],
@@ -2436,7 +2569,7 @@ async function showTimeoutsSettings(orchestrator: Orchestrator, ctx: any): Promi
2436
2569
  const options: OptionInput[] = timeoutEntries.map((entry) => {
2437
2570
  const value = entry.value;
2438
2571
  const key = entry.key;
2439
- return opt(`${TIMEOUT_LABELS[key]}: ${formatDuration(value)}`, "Edit timeout");
2572
+ return opt(`${TIMEOUT_LABELS[key]}: ${formatDuration(value)}`, "Change this time limit");
2440
2573
  });
2441
2574
  options.push(opt("Back", "Return to the previous menu"));
2442
2575
  const choice = await selectOption(ctx, "Timeouts", options);
@@ -2474,7 +2607,7 @@ async function showTimeoutsSettings(orchestrator: Orchestrator, ctx: any): Promi
2474
2607
  async function showPerformanceSettings(orchestrator: Orchestrator, ctx: any): Promise<typeof BACK> {
2475
2608
  while (true) {
2476
2609
  const choice = await selectOption(ctx, "Performance", [
2477
- opt("Timeouts", "Timeout configuration"),
2610
+ opt("Timeouts", "Adjust per-operation time limits"),
2478
2611
  opt("Back", "Return to the previous menu"),
2479
2612
  ]);
2480
2613
  if (!choice || choice === "Back") return BACK;
@@ -2487,14 +2620,16 @@ async function showBooleanSetting(
2487
2620
  ctx: any,
2488
2621
  title: string,
2489
2622
  keyPath: string[],
2623
+ yesDescription = "Turn this setting on",
2624
+ noDescription = "Turn this setting off",
2490
2625
  ): Promise<void> {
2491
2626
  while (true) {
2492
2627
  const info = getConfigSourceInfo(orchestrator, keyPath);
2493
2628
  const yesTitle = withTags("Yes", formatSourceTags(true, info));
2494
2629
  const noTitle = withTags("No", formatSourceTags(false, info));
2495
2630
  const choice = await selectOption(ctx, title, [
2496
- yesTitle,
2497
- noTitle,
2631
+ { title: yesTitle, description: yesDescription },
2632
+ { title: noTitle, description: noDescription },
2498
2633
  ...buildResetOptions(orchestrator, keyPath),
2499
2634
  opt("Back", "Return to the previous menu"),
2500
2635
  ]);
@@ -2516,14 +2651,16 @@ async function showInvertedBooleanSetting(
2516
2651
  ctx: any,
2517
2652
  title: string,
2518
2653
  keyPath: string[],
2654
+ yesDescription = "Turn this setting on",
2655
+ noDescription = "Turn this setting off",
2519
2656
  ): Promise<void> {
2520
2657
  while (true) {
2521
2658
  const info = getConfigSourceInfo(orchestrator, keyPath);
2522
2659
  const yesTitle = withTags("Yes", formatSourceTags(false, info));
2523
2660
  const noTitle = withTags("No", formatSourceTags(true, info));
2524
2661
  const choice = await selectOption(ctx, title, [
2525
- yesTitle,
2526
- noTitle,
2662
+ { title: yesTitle, description: yesDescription },
2663
+ { title: noTitle, description: noDescription },
2527
2664
  ...buildResetOptions(orchestrator, keyPath),
2528
2665
  opt("Back", "Return to the previous menu"),
2529
2666
  ]);
@@ -2541,15 +2678,15 @@ async function showInvertedBooleanSetting(
2541
2678
  }
2542
2679
 
2543
2680
  async function showLogLevelSetting(orchestrator: Orchestrator, ctx: any): Promise<void> {
2544
- const levels: Array<{ value: PiPiConfig["general"]["logLevel"]; label: string }> = [
2545
- { value: "debug", label: "Debug" },
2546
- { value: "info", label: "Info" },
2547
- { value: "warn", label: "Warning" },
2548
- { value: "error", label: "Error" },
2681
+ const levels: Array<{ value: PiPiConfig["general"]["logLevel"]; label: string; description: string }> = [
2682
+ { value: "debug", label: "Debug", description: "Log everything, including detailed diagnostics for troubleshooting" },
2683
+ { value: "info", label: "Info", description: "Log normal activity plus warnings and errors" },
2684
+ { value: "warn", label: "Warning", description: "Log only warnings and errors" },
2685
+ { value: "error", label: "Error", description: "Log only errors" },
2549
2686
  ];
2550
2687
  while (true) {
2551
2688
  const info = getConfigSourceInfo(orchestrator, ["general", "logLevel"]);
2552
- const options: OptionInput[] = levels.map((entry) => withTags(entry.label, formatSourceTags(entry.value, info)));
2689
+ const options: OptionInput[] = levels.map((entry) => ({ title: withTags(entry.label, formatSourceTags(entry.value, info)), description: entry.description }));
2553
2690
  options.push(...buildResetOptions(orchestrator, ["general", "logLevel"]));
2554
2691
  options.push(opt("Back", "Return to the previous menu"));
2555
2692
  const choice = await selectOption(ctx, "Log level", options);
@@ -2571,6 +2708,7 @@ async function showGeneralSettings(orchestrator: Orchestrator, ctx: any): Promis
2571
2708
  while (true) {
2572
2709
  const choice = await selectOption(ctx, "General", [
2573
2710
  opt(`Commit automatically: ${orchestrator.config.general.autoCommit ? "Yes" : "No"}`, "Enable or disable auto commits"),
2711
+ opt(`Inject root AGENTS.md: ${orchestrator.config.general.injectAgentsMd ? "Yes" : "No"}`, "Inject the working repo's root AGENTS.md into the agent system prompt"),
2574
2712
  opt(`Ignore configs from other repos: ${orchestrator.config.general.loadExtraRepoConfigs ? "No" : "Yes"}`, "Load only root repo config"),
2575
2713
  opt(`Log level: ${logLevelLabel(orchestrator.config.general.logLevel)}`, "Logging verbosity"),
2576
2714
  opt(`Tracing: ${orchestrator.config.general.tracing ? "Yes" : "No"}`, "Capture full session traces to .pp/logs/traces/"),
@@ -2579,11 +2717,15 @@ async function showGeneralSettings(orchestrator: Orchestrator, ctx: any): Promis
2579
2717
  ]);
2580
2718
  if (!choice || choice === "Back") return BACK;
2581
2719
  if (choice.startsWith("Commit automatically:")) {
2582
- await showBooleanSetting(orchestrator, ctx, "Commit automatically", ["general", "autoCommit"]);
2720
+ await showBooleanSetting(orchestrator, ctx, "Commit automatically", ["general", "autoCommit"], "Commit changes automatically as work progresses", "Leave committing to you");
2721
+ continue;
2722
+ }
2723
+ if (choice.startsWith("Inject root AGENTS.md:")) {
2724
+ await showBooleanSetting(orchestrator, ctx, "Inject root AGENTS.md", ["general", "injectAgentsMd"], "Inject the working repo's root AGENTS.md into the agent system prompt", "Do not inject AGENTS.md");
2583
2725
  continue;
2584
2726
  }
2585
2727
  if (choice.startsWith("Ignore configs from other repos:")) {
2586
- await showInvertedBooleanSetting(orchestrator, ctx, "Ignore configs from other repos", ["general", "loadExtraRepoConfigs"]);
2728
+ await showInvertedBooleanSetting(orchestrator, ctx, "Ignore configs from other repos", ["general", "loadExtraRepoConfigs"], "Use only the root repo config and ignore configs from other registered repos", "Also load configs from the other registered repos");
2587
2729
  continue;
2588
2730
  }
2589
2731
  if (choice.startsWith("Log level:")) {
@@ -2591,7 +2733,7 @@ async function showGeneralSettings(orchestrator: Orchestrator, ctx: any): Promis
2591
2733
  continue;
2592
2734
  }
2593
2735
  if (choice.startsWith("Tracing:")) {
2594
- await showBooleanSetting(orchestrator, ctx, "Tracing", ["general", "tracing"]);
2736
+ await showBooleanSetting(orchestrator, ctx, "Tracing", ["general", "tracing"], "Capture full session traces to .pp/logs/traces/", "Do not record session traces");
2595
2737
  continue;
2596
2738
  }
2597
2739
  await showFlantInfraMenu(orchestrator, ctx);
@@ -2685,7 +2827,7 @@ async function showSettingsMenu(orchestrator: Orchestrator, ctx: any): Promise<t
2685
2827
  opt("General", "Commit, log level, Flant AI, repos"),
2686
2828
  opt("Agents", "Orchestrator and subagent configuration"),
2687
2829
  opt("Commands", "After file edit and after implementation"),
2688
- opt("Performance", "Timeout configuration"),
2830
+ opt("Performance", "Per-operation timeout limits"),
2689
2831
  opt("LSP", "Language server controls"),
2690
2832
  opt("Back", "Return to the previous menu"),
2691
2833
  ];
@@ -2777,10 +2919,10 @@ async function showAutonomousPhaseSettings(
2777
2919
  const reviewPreset = phaseConfig.reviewPreset ?? defaultAutonomousReviewPreset(type, phase);
2778
2920
  const maxReview = phaseConfig.maxReviewPasses >= 999 ? "No limit" : String(phaseConfig.maxReviewPasses);
2779
2921
  const options: OptionInput[] = [
2780
- opt(`Review preset: ${reviewPreset}`, "Select review preset"),
2922
+ opt(`Review preset: ${reviewPreset}`, "Pick which reviewer group runs in this phase"),
2781
2923
  ];
2782
2924
  if (phase === "plan") {
2783
- options.push(opt(`Planner preset: ${phaseConfig.plannerPreset ?? "regular"}`, "Select planner preset"));
2925
+ options.push(opt(`Planner preset: ${phaseConfig.plannerPreset ?? "regular"}`, "Pick which planner group runs in this phase"));
2784
2926
  }
2785
2927
  options.push(opt(`Max review passes: ${maxReview}`, "Safety cap for autonomous review loops"));
2786
2928
  options.push(opt("Back", "Return to autonomous settings"));
@@ -3056,201 +3198,138 @@ async function openCodeReviewInPlannotator(
3056
3198
  if (payload.diffType) requestPayload.diffType = payload.diffType;
3057
3199
  if (payload.defaultBranch) requestPayload.defaultBranch = payload.defaultBranch;
3058
3200
 
3059
- return await new Promise((resolve) => {
3060
- let handled = false;
3061
- const timer = setTimeout(() => {
3062
- if (!handled) resolve({ status: "error", error: "Plannotator is not available." });
3063
- }, 30000);
3064
- orchestrator.pi.events.emit("plannotator:request", {
3065
- requestId: crypto.randomUUID(),
3066
- action: "code-review",
3067
- payload: requestPayload,
3068
- respond: (response: any) => {
3069
- handled = true;
3070
- clearTimeout(timer);
3071
- if (response?.status !== "handled") {
3072
- resolve({ status: "error", error: response?.error || "Plannotator is not available." });
3073
- return;
3074
- }
3075
- const approved = !!response?.result?.approved;
3076
- const feedback = typeof response?.result?.feedback === "string" && response.result.feedback.trim().length > 0
3077
- ? response.result.feedback
3078
- : undefined;
3079
- resolve({ status: approved ? "approved" : "needs_changes", feedback });
3080
- },
3081
- });
3082
- });
3083
- }
3201
+ const { opened, reviewId } = await openPlannotator(orchestrator.pi, "code-review", requestPayload);
3202
+ if (!opened) {
3203
+ return { status: "error", error: "Plannotator is not available." };
3204
+ }
3084
3205
 
3085
- async function startReviewTask(
3086
- orchestrator: Orchestrator,
3087
- ctx: any,
3088
- userRequestContent: string,
3089
- researchContent: string | null,
3090
- description: string,
3091
- mode?: TaskMode,
3092
- autonomousConfig?: AutonomousConfig,
3093
- ): Promise<"started" | typeof BACK> {
3094
- await orchestrator.startTask(ctx, "review", description, undefined, undefined, mode);
3095
- if (!orchestrator.active || orchestrator.active.type !== "review") return BACK;
3096
- orchestrator.active.state.autonomousConfig = autonomousConfig;
3097
- saveTask(orchestrator.active.dir, orchestrator.active.state);
3098
- const repos = getRegisteredRepos(orchestrator);
3099
- const userRequestWithRepos = appendRepoContext(userRequestContent, repos);
3100
- writeFileSync(join(orchestrator.active.dir, "USER_REQUEST.md"), userRequestWithRepos, "utf-8");
3101
- if (researchContent) {
3102
- writeFileSync(join(orchestrator.active.dir, "RESEARCH.md"), researchContent, "utf-8");
3206
+ let result: { approved: boolean; feedback?: string; error?: string };
3207
+ try {
3208
+ result = await waitForPlannotatorResult(orchestrator, reviewId, null);
3209
+ } catch (err) {
3210
+ return { status: "error", error: err instanceof Error ? err.message : "Plannotator review failed." };
3211
+ }
3212
+ if (result.error) {
3213
+ return { status: "error", error: result.error };
3214
+ }
3215
+ const feedback = typeof result.feedback === "string" && result.feedback.trim().length > 0
3216
+ ? result.feedback
3217
+ : undefined;
3218
+ return { status: result.approved ? "approved" : "needs_changes", feedback };
3219
+ }
3220
+
3221
+ // Per-repo interleaved Plannotator loop (#3a). Runs from the persisted cursor: for
3222
+ // each repo, ask the diff scope, open Plannotator and WAIT (dialogue closed). On
3223
+ // NEEDS_CHANGES, persist the cursor advanced past this repo and return a work
3224
+ // instruction (mirrors the plan path: answer questions + apply changes) so the
3225
+ // agent fixes THIS repo before the next opens; the next /pp resumes the loop. On
3226
+ // approved/error/skip, advance and continue in-loop. When the cursor is exhausted
3227
+ // or the user stops, clear the cursor and return null (fall back to the menu).
3228
+ async function runPlannotatorCursor(orchestrator: Orchestrator, ctx: any): Promise<string | null> {
3229
+ const task = orchestrator.active;
3230
+ if (!task) return null;
3231
+ const cursor = task.state.plannotatorCursor;
3232
+ if (!cursor) return null;
3233
+
3234
+ while (task.state.plannotatorCursor && task.state.plannotatorCursor.index < task.state.plannotatorCursor.repoPaths.length) {
3235
+ const cur = task.state.plannotatorCursor;
3236
+ const repoPath = cur.repoPaths[cur.index];
3237
+ const repo = getRegisteredRepos(orchestrator).find((r) => r.path === repoPath) ?? { path: repoPath, isRoot: false };
3238
+
3239
+ const diffChoice = await selectOption(ctx, `Review: ${formatRepoLabel(repo)}`, [
3240
+ opt("All branch changes", "Committed changes vs base branch"),
3241
+ opt("Last commit", "Changes in the most recent commit"),
3242
+ opt("Since commit", "Review all changes since a specific commit"),
3243
+ opt("Uncommitted changes", "Working directory changes"),
3244
+ opt("Skip this repo", "Move to the next repository"),
3245
+ opt("Done (stop reviewing)", "Stop iterating repositories"),
3246
+ ]);
3247
+
3248
+ if (!diffChoice || diffChoice === "Done (stop reviewing)") {
3249
+ task.state.plannotatorCursor = undefined;
3250
+ saveTask(task.dir, task.state);
3251
+ return null;
3252
+ }
3253
+ if (diffChoice === "Skip this repo") {
3254
+ cur.index += 1;
3255
+ saveTask(task.dir, task.state);
3256
+ continue;
3257
+ }
3258
+
3259
+ let diffType: string;
3260
+ let defaultBranch: string | undefined;
3261
+ if (diffChoice === "All branch changes") {
3262
+ diffType = "branch";
3263
+ defaultBranch = await detectDefaultBranch(orchestrator, getRegisteredRepos(orchestrator), repo.path);
3264
+ } else if (diffChoice === "Last commit") {
3265
+ diffType = "last-commit";
3266
+ } else if (diffChoice === "Since commit") {
3267
+ const pickedHash = await pickCommitForRepo(orchestrator, ctx, repo);
3268
+ if (!pickedHash) continue;
3269
+ diffType = "branch";
3270
+ defaultBranch = pickedHash;
3271
+ } else {
3272
+ diffType = "uncommitted";
3273
+ }
3274
+
3275
+ const result = await openCodeReviewInPlannotator(orchestrator, { cwd: repo.path, diffType, defaultBranch });
3276
+
3277
+ // Advance past this repo regardless of outcome; on needs_changes the agent
3278
+ // fixes it during the turn started by the returned instruction, then the next
3279
+ // /pp resumes at the following repo.
3280
+ cur.index += 1;
3281
+ const exhausted = cur.index >= cur.repoPaths.length;
3282
+
3283
+ if (result.status === "needs_changes") {
3284
+ if (exhausted) task.state.plannotatorCursor = undefined;
3285
+ setStep(orchestrator, "llm_work");
3286
+ saveTask(task.dir, task.state);
3287
+ const feedback = result.feedback ? `\n\nFeedback:\n${result.feedback}` : "";
3288
+ const more = exhausted
3289
+ ? "This was the last repo to review."
3290
+ : "After you finish, run /pp to continue Plannotator review of the remaining repositories.";
3291
+ return advanceBanner(
3292
+ `[PI-PI] Plannotator requested changes for ${formatRepoLabel(repo)}.${feedback}\n\n` +
3293
+ "Address the user's feedback. If the feedback contains questions, answer them. If it requests changes, " +
3294
+ `make the changes. Then call pp_phase_complete when done.\n\n${more}`,
3295
+ );
3296
+ }
3297
+
3298
+ if (result.status === "error") {
3299
+ ctx.ui.notify(`${formatRepoLabel(repo)}: ERROR${result.error ? ` — ${result.error}` : ""}`, "warning");
3300
+ } else {
3301
+ ctx.ui.notify(`${formatRepoLabel(repo)}: APPROVED`, "info");
3302
+ }
3303
+ saveTask(task.dir, task.state);
3103
3304
  }
3104
- return "started";
3305
+
3306
+ // Cursor exhausted with no outstanding changes: clear it and fall back to /pp.
3307
+ task.state.plannotatorCursor = undefined;
3308
+ saveTask(task.dir, task.state);
3309
+ return null;
3105
3310
  }
3106
3311
 
3107
3312
  async function showReviewMenu(orchestrator: Orchestrator, ctx: any): Promise<typeof BACK | "started"> {
3108
3313
  while (true) {
3109
- const options: OptionInput[] = [
3110
- { title: "Current branch", description: "Review changes on current branch vs base" },
3111
- { title: "Last commit", description: "Review changes in the most recent commit" },
3112
- { title: "Since commit", description: "Review all changes since a specific commit" },
3113
- { title: "Uncommitted changes", description: "Review working directory changes" },
3114
- { title: "Describe", description: "Describe what to review and let the agent figure it out" },
3314
+ const choice = await selectOption(ctx, "Review", [
3315
+ { title: "New", description: "Start a new review — type what to review (a branch, commit range, uncommitted changes, or a PR URL) as your first chat message" },
3115
3316
  { title: "Resume", description: "Resume a previously unfinished review" },
3116
3317
  { title: "Back", description: "Return to the previous menu" },
3117
- ];
3118
- const choice = await selectOption(ctx, "Review", options);
3318
+ ]);
3119
3319
  if (!choice || choice === "Back") return BACK;
3120
3320
 
3121
- if (choice === "Resume") {
3122
- const result = await showResumeMenu(orchestrator, ctx, "review", "No paused review tasks found.");
3123
- if (result === "started") return result;
3124
- continue;
3125
- }
3126
-
3127
- if (choice === "Current branch") {
3128
- const repos = getRegisteredRepos(orchestrator);
3129
- const repoRanges = await Promise.all(
3130
- repos.map(async (repo) => `- ${formatRepoLabel(repo)}: ${await detectDefaultBranch(orchestrator, repos, repo.path)}..HEAD`),
3131
- );
3132
- const prContexts = await detectCurrentPrContext(orchestrator, repos);
3133
-
3134
- const urLines = [
3135
- "# User Request",
3136
- "Review current branch changes across registered repositories.",
3137
- "",
3138
- "Diff ranges:",
3139
- ...repoRanges,
3140
- ];
3141
- const prUrls = prContexts
3142
- .filter((pr) => pr.prUrl)
3143
- .map((pr) => `- ${pr.repoPath}: ${pr.prUrl}`);
3144
- if (prUrls.length > 0) {
3145
- urLines.push("", "Open PRs:", ...prUrls);
3146
- }
3147
- urLines.push("", "## Problem", "Review and identify issues in the code changes.", "", "## Constraints", "Focus on correctness, edge cases, style, missing tests, potential bugs.");
3148
- const urContent = urLines.join("\n") + "\n";
3149
-
3150
- let resContent = [
3151
- "## Affected Code",
3152
- "(to be filled during review)",
3153
- "",
3154
- "## Architecture Context",
3155
- "(to be filled during review)",
3156
- "",
3157
- "## Constraints & Edge Cases",
3158
- "- MUST: Review all changed code across the registered repositories",
3159
- "- RISK: Issues can span repository boundaries",
3160
- ].join("\n") + "\n";
3161
- if (prContexts.length > 0) {
3162
- const prContextBlocks = prContexts
3163
- .map((pr) => {
3164
- const lines = [`${pr.repoPath}`];
3165
- if (pr.prUrl) lines.push(`URL: ${pr.prUrl}`);
3166
- if (pr.prContext) lines.push(pr.prContext);
3167
- return lines.join("\n");
3168
- })
3169
- .join("\n\n");
3170
- resContent = appendResearchOpenQuestions(resContent, `PR context:\n${prContextBlocks}`);
3171
- }
3172
-
3173
- const modeSelection = await pickModeForTaskStart(orchestrator, ctx, "review");
3174
- if (!modeSelection) continue;
3175
- const description = "review-current-branch";
3176
- return startReviewTask(orchestrator, ctx, urContent, resContent, description, modeSelection.mode, modeSelection.autonomousConfig);
3177
- }
3178
-
3179
- if (choice === "Last commit") {
3180
- const repos = getRegisteredRepos(orchestrator);
3181
- const urContent = [
3182
- "# User Request",
3183
- "Review last commit changes across registered repositories",
3184
- "",
3185
- "## Problem",
3186
- "Review and identify issues in the most recent commit.",
3187
- "",
3188
- "## Constraints",
3189
- "Focus on correctness, edge cases, style, missing tests, potential bugs.",
3190
- "",
3191
- ].join("\n");
3192
- const modeSelection = await pickModeForTaskStart(orchestrator, ctx, "review");
3193
- if (!modeSelection) continue;
3194
- return startReviewTask(orchestrator, ctx, urContent, null, "review-last-commit", modeSelection.mode, modeSelection.autonomousConfig);
3195
- }
3196
-
3197
- if (choice === "Since commit") {
3198
- const repos = getRegisteredRepos(orchestrator);
3199
- const repoOptions: OptionInput[] = repos.map((repo) => ({
3200
- title: formatRepoLabel(repo),
3201
- description: "Choose repository for commit range",
3202
- }));
3203
- repoOptions.push({ title: "Back", description: "Return to the previous menu" });
3204
- const repoChoice = await selectOption(ctx, "Select repository", repoOptions);
3205
- if (!repoChoice || repoChoice === "Back") continue;
3206
- const selectedRepo = repos.find((repo) => formatRepoLabel(repo) === repoChoice);
3207
- if (!selectedRepo) continue;
3208
- const pickedHash = await pickCommitForRepo(orchestrator, ctx, selectedRepo);
3209
- if (!pickedHash) continue;
3210
-
3211
- const urContent = [
3212
- "# User Request",
3213
- `Review changes in ${selectedRepo.path} since commit ${pickedHash}`,
3214
- "",
3215
- "## Problem",
3216
- `Review and identify issues in all changes in ${selectedRepo.path} since ${pickedHash}.`,
3217
- "",
3218
- "## Constraints",
3219
- "Focus on correctness, edge cases, style, missing tests, potential bugs.",
3220
- "",
3221
- ].join("\n");
3222
- const modeSelection = await pickModeForTaskStart(orchestrator, ctx, "review");
3223
- if (!modeSelection) continue;
3224
- return startReviewTask(orchestrator, ctx, urContent, null, "review-since-commit", modeSelection.mode, modeSelection.autonomousConfig);
3225
- }
3226
-
3227
- if (choice === "Uncommitted changes") {
3228
- const repos = getRegisteredRepos(orchestrator);
3229
- const urContent = [
3230
- "# User Request",
3231
- "Review uncommitted changes across registered repositories",
3232
- "",
3233
- "## Problem",
3234
- "Review and identify issues in uncommitted working directory changes.",
3235
- "",
3236
- "## Constraints",
3237
- "Focus on correctness, edge cases, style, missing tests, potential bugs.",
3238
- "",
3239
- ].join("\n");
3321
+ if (choice === "New") {
3240
3322
  const modeSelection = await pickModeForTaskStart(orchestrator, ctx, "review");
3241
3323
  if (!modeSelection) continue;
3242
- return startReviewTask(orchestrator, ctx, urContent, null, "review-uncommitted", modeSelection.mode, modeSelection.autonomousConfig);
3324
+ await orchestrator.startTask(ctx, "review", "review", undefined, undefined, modeSelection.mode);
3325
+ if (!orchestrator.active || orchestrator.active.type !== "review") return BACK;
3326
+ orchestrator.active.state.autonomousConfig = modeSelection.autonomousConfig;
3327
+ saveTask(orchestrator.active.dir, orchestrator.active.state);
3328
+ return "started";
3243
3329
  }
3244
3330
 
3245
- const input = await ctx.ui.input("Describe what to review");
3246
- if (input === undefined || input === null) continue;
3247
- const trimmed = String(input).trim();
3248
- const description = trimmed || "review";
3249
-
3250
- const urContent = `# User Request\n${description}\n\n## Problem\n${description}\n\n## Constraints\nFocus on correctness, edge cases, style, missing tests, potential bugs.\n`;
3251
- const modeSelection = await pickModeForTaskStart(orchestrator, ctx, "review");
3252
- if (!modeSelection) continue;
3253
- return startReviewTask(orchestrator, ctx, urContent, null, description, modeSelection.mode, modeSelection.autonomousConfig);
3331
+ const result = await showResumeMenu(orchestrator, ctx, "review", "No paused review tasks found.");
3332
+ if (result === "started") return result;
3254
3333
  }
3255
3334
  }
3256
3335
 
@@ -3344,7 +3423,7 @@ async function showNoActiveMenu(orchestrator: Orchestrator, ctx: any): Promise<s
3344
3423
  const choice = await selectOption(ctx, "/pp", [
3345
3424
  { title: "Task", description: "Start a new task or resume a paused one" },
3346
3425
  { title: "Info", description: "Subagents, usage, and task status" },
3347
- { title: "Settings", description: "Flant AI and other configuration" },
3426
+ { title: "Settings", description: "Models, agents, commands, and other configuration" },
3348
3427
  { title: "Back", description: "Close this menu" },
3349
3428
  ]);
3350
3429
  if (!choice || choice === "Back") return undefined;
@@ -3405,7 +3484,7 @@ async function showQuickTaskMenu(
3405
3484
  opt("Complete", "Mark task as done and clean up"),
3406
3485
  opt("Pause", "Suspend task to resume later"),
3407
3486
  opt("Info", "Subagents, usage, and task status"),
3408
- opt("Settings", "Flant AI and other configuration"),
3487
+ opt("Settings", "Models, agents, commands, and other configuration"),
3409
3488
  opt("Back", "Return to the prompt and keep working"),
3410
3489
  ]);
3411
3490
  // A deliberate ESC in tool mode must stop the turn cleanly (mirror the
@@ -3434,8 +3513,13 @@ export async function showActiveTaskMenu(
3434
3513
  ctx: any,
3435
3514
  summary: string,
3436
3515
  mode: MenuMode = "command",
3516
+ // Display-only override for the autonomous terminal implement handoff (#1):
3517
+ // render the guided Next/Review menu even though the task is autonomous, WITHOUT
3518
+ // mutating task.state.mode. Never persisted; does not affect the footer indicator
3519
+ // or getEffectivePhaseMode.
3520
+ forceGuided = false,
3437
3521
  ): Promise<string> {
3438
- const continueMessage = "User wants to continue. Run /pp when ready to advance.";
3522
+ const continueMessage = advanceBanner("[PI-PI] User wants to continue. Run /pp when ready to advance.");
3439
3523
 
3440
3524
  while (true) {
3441
3525
  if (!orchestrator.active) return "No active task.";
@@ -3444,6 +3528,15 @@ export async function showActiveTaskMenu(
3444
3528
  if (task.type === "quick") {
3445
3529
  return showQuickTaskMenu(orchestrator, ctx, summary, mode);
3446
3530
  }
3531
+ // Auto-resume an in-progress per-repo Plannotator review (#3a): if the agent
3532
+ // just finished fixing one repo's feedback and ran /pp, continue the loop at
3533
+ // the next repo instead of showing the top-level menu. On another
3534
+ // needs_changes this returns a fresh work instruction; when exhausted it
3535
+ // clears the cursor and falls through to the normal menu.
3536
+ if (task.state.plannotatorCursor) {
3537
+ const resumeText = await runPlannotatorCursor(orchestrator, ctx);
3538
+ if (resumeText) return resumeText;
3539
+ }
3447
3540
  const phase = task.state.phase;
3448
3541
  const step = task.state.step;
3449
3542
  const effectiveMode = getEffectivePhaseMode(task.state);
@@ -3455,15 +3548,21 @@ export async function showActiveTaskMenu(
3455
3548
  const { autoLabel } = getReviewLabels(orchestrator);
3456
3549
  const isReviewPhase = phase === "review";
3457
3550
  const hasPlannotator = phase === "plan" || phase === "implement" || isReviewPhase;
3551
+ // The artifact the automated reviewers scan, mirroring getReviewPresetGroup: brainstorm →
3552
+ // brainstormReviewers (state files), plan → planReviewers (synthesized plan), every other
3553
+ // phase → codeReviewers (code changes). This is the primary "Review" target named at the
3554
+ // top level; the per-phase "Review on my own" description names the manual editor pass's
3555
+ // target separately (it can differ, e.g. state files in a debug/review phase).
3556
+ const reviewTarget = phase === "plan" ? "the synthesized plan" : phase === "brainstorm" ? "this phase's state files" : "the code changes";
3458
3557
 
3459
3558
  const opt = (title: string, description: string): OptionInput => ({ title, description });
3460
3559
 
3461
- if (effectiveMode === "autonomous") {
3560
+ if (effectiveMode === "autonomous" && !forceGuided) {
3462
3561
  const { choice: autoChoice, cancelReason } = await selectOptionCancelable(ctx, `/pp\n\nTask: ${task.type}\nPhase: ${phase}${summary !== "/pp" ? `\n\n${summary}` : ""}`, [
3463
3562
  opt("Complete task", "Mark task as done and clean up"),
3464
3563
  opt("Pause task", "Suspend task to resume later"),
3465
3564
  opt("Info", "Subagents, usage, and task status"),
3466
- opt("Settings", "Flant AI and other configuration"),
3565
+ opt("Settings", "Models, agents, commands, and other configuration"),
3467
3566
  opt("Back", "Return to the prompt and keep working"),
3468
3567
  ]);
3469
3568
  // A deliberate ESC only needs distinct handling in tool mode (so
@@ -3490,10 +3589,10 @@ export async function showActiveTaskMenu(
3490
3589
  const options: OptionInput[] = [];
3491
3590
  options.push(opt("Next", "Complete, pause, or continue to next phase"));
3492
3591
  if (!waiting) {
3493
- options.push(opt("Review", "Auto review, Plannotator, or manual review"));
3592
+ options.push(opt("Review", `Review ${reviewTarget}: automated reviewers${hasPlannotator ? ", Plannotator, or" : " or"} your own editor pass`));
3494
3593
  }
3495
3594
  options.push(opt("Info", "Subagents, usage, and task status"));
3496
- options.push(opt("Settings", "Flant AI and other configuration"));
3595
+ options.push(opt("Settings", "Models, agents, commands, and other configuration"));
3497
3596
  options.push(opt("Back", "Return to the prompt and keep working"));
3498
3597
 
3499
3598
  const headerLines = [`/pp\n\nTask: ${task.type}\nPhase: ${phase}`];
@@ -3518,68 +3617,109 @@ export async function showActiveTaskMenu(
3518
3617
  }
3519
3618
 
3520
3619
  if (choice === "Next") {
3521
- const canContinue = phase !== "implement" && !waiting;
3522
- const continueLabel = phase === "plan" ? "Continue to implement" : "Continue to plan & implement";
3523
- const finishOptions: OptionInput[] = [];
3524
- if (canContinue) {
3525
- finishOptions.push(opt(continueLabel, "Approve and advance to the next phase"));
3526
- }
3527
- finishOptions.push(opt("Complete", "Mark task as done and clean up"));
3528
- finishOptions.push(opt("Pause", "Suspend task to resume later"));
3529
- finishOptions.push(opt("Back", "Return to the previous menu"));
3530
-
3531
- const finishChoice = await selectOption(ctx, "Next", finishOptions);
3532
- if (!finishChoice || finishChoice === "Back") continue;
3533
- if (finishChoice === "Pause") {
3534
- const text = await pauseTask(orchestrator, ctx);
3535
- return mode === "tool" ? text : "";
3536
- }
3537
- if (finishChoice === "Complete") {
3538
- const text = await finishTask(orchestrator, ctx);
3539
- return mode === "tool" ? text : "";
3540
- }
3541
- const next = nextPhase(task.type, phase);
3542
-
3543
- if (
3544
- next === "plan" &&
3545
- task.type === "brainstorm"
3546
- ) {
3547
- const modeSelection = await pickModeForTaskStart(orchestrator, ctx, "implement");
3548
- if (!modeSelection) continue;
3549
- task.state.mode = modeSelection.mode;
3550
- task.state.effectiveMode = undefined;
3551
- task.state.autonomousConfig = modeSelection.autonomousConfig;
3552
- saveTask(task.dir, task.state);
3553
- }
3620
+ // The Next submenu runs its own loop so that a deeper submenu's "Back"
3621
+ // (e.g. Publish) returns here rather than overshooting to the top-level menu.
3622
+ while (true) {
3623
+ const canContinue = phase !== "implement" && !waiting;
3624
+ const continueLabel = phase === "plan" ? "Continue to implement" : "Continue to plan & implement";
3625
+ const finishOptions: OptionInput[] = [];
3626
+ if (phase === "review") {
3627
+ finishOptions.push(opt("Publish", "Publish the synthesized review findings as file comments or GitHub PR comments"));
3628
+ }
3629
+ if (canContinue) {
3630
+ finishOptions.push(opt(continueLabel, "Approve and advance to the next phase"));
3631
+ }
3632
+ finishOptions.push(opt("Complete", "Mark task as done and clean up"));
3633
+ finishOptions.push(opt("Pause", "Suspend task to resume later"));
3634
+ finishOptions.push(opt("Back", "Return to the previous menu"));
3635
+
3636
+ const finishChoice = await selectOption(ctx, "Next", finishOptions);
3637
+ if (!finishChoice || finishChoice === "Back") break;
3638
+ if (finishChoice === "Publish") {
3639
+ const target = await selectOption(ctx, "Publish", [
3640
+ opt("As file comments", "Insert AI_COMMENT: markers at each finding's location in the source"),
3641
+ opt("As GitHub PR comments", "Post line-anchored comments to the branch's PR from your GitHub account"),
3642
+ opt("Back", "Return to the previous menu"),
3643
+ ]);
3644
+ if (!target || target === "Back") continue;
3645
+ const guardMessage = publishGuard(task.dir);
3646
+ if (guardMessage) {
3647
+ ctx.ui.notify(guardMessage, "info");
3648
+ continue;
3649
+ }
3650
+ return target === "As file comments"
3651
+ ? publishFileCommentsBanner(task.dir)
3652
+ : publishPrCommentsBanner(task.dir);
3653
+ }
3654
+ if (finishChoice === "Pause") {
3655
+ const text = await pauseTask(orchestrator, ctx);
3656
+ return mode === "tool" ? text : "";
3657
+ }
3658
+ if (finishChoice === "Complete") {
3659
+ const text = await finishTask(orchestrator, ctx);
3660
+ return mode === "tool" ? text : "";
3661
+ }
3662
+ const next = nextPhase(task.type, phase);
3663
+
3664
+ if (
3665
+ next === "plan" &&
3666
+ task.type === "brainstorm"
3667
+ ) {
3668
+ const modeSelection = await pickModeForTaskStart(orchestrator, ctx, "implement");
3669
+ if (!modeSelection) continue;
3670
+ task.state.mode = modeSelection.mode;
3671
+ task.state.effectiveMode = undefined;
3672
+ task.state.autonomousConfig = modeSelection.autonomousConfig;
3673
+ saveTask(task.dir, task.state);
3674
+ }
3554
3675
 
3555
- let plannerPreset: string | undefined;
3556
- if (next === "plan") {
3557
- if (getEffectiveMode(task.state) !== "autonomous") {
3558
- const pickedPlannerPreset = await pickPreset(ctx, orchestrator, "planners", "Planner preset");
3559
- if (!pickedPlannerPreset) continue;
3560
- plannerPreset = pickedPlannerPreset;
3676
+ let plannerPreset: string | undefined;
3677
+ if (next === "plan") {
3678
+ if (getEffectiveMode(task.state) !== "autonomous") {
3679
+ const pickedPlannerPreset = await pickPreset(ctx, orchestrator, "planners", "Planner preset");
3680
+ if (!pickedPlannerPreset) continue;
3681
+ plannerPreset = pickedPlannerPreset;
3682
+ }
3561
3683
  }
3684
+ finalizeReviewCycle(task);
3685
+ const result = await orchestrator.transitionToNextPhase(ctx, plannerPreset);
3686
+ if (!result.ok) return `Transition blocked: ${result.error}`;
3687
+ // Transition is now owned by the controller (state ≠ running) or we're
3688
+ // awaiting subagents; either way the menu returns empty.
3689
+ return "";
3562
3690
  }
3563
- finalizeReviewCycle(task);
3564
- const result = await orchestrator.transitionToNextPhase(ctx, plannerPreset);
3565
- if (!result.ok) return `Transition blocked: ${result.error}`;
3566
- // Transition is now owned by the controller (state ≠ running) or we're
3567
- // awaiting subagents; either way the menu returns empty.
3568
- return "";
3691
+ continue;
3569
3692
  }
3570
3693
 
3571
3694
  if (choice === "Review") {
3695
+ // The Review submenu runs its own loop so a recoverable choice (no reviewers
3696
+ // enabled, preset cancelled, "Review on my own" Back, a plannotator early
3697
+ // exit) returns HERE rather than overshooting to the top-level /pp menu.
3698
+ // Only an explicit "Back" breaks out; start/wait/instruction paths return.
3699
+ while (true) {
3700
+ // A review already running: block re-entry BEFORE any finalizeReviewCycle
3701
+ // (which would null the live cycle and hide it), so we never double-spawn.
3702
+ // Notify and break back to the top-level menu with the live cycle intact
3703
+ // rather than exiting /pp.
3704
+ if (isReviewCycleLive(task)) {
3705
+ ctx.ui.notify("A review is already running", "info");
3706
+ break;
3707
+ }
3572
3708
  const reviewOptions: OptionInput[] = [
3573
- opt(autoLabel, "Run automated review with configured reviewers"),
3709
+ opt(autoLabel, `Run configured reviewers over ${reviewTarget}`),
3574
3710
  ];
3575
3711
  if (hasPlannotator) {
3576
3712
  reviewOptions.push(opt("Review in Plannotator", phase === "plan" ? "Open plan review in browser" : "Open code diff review in browser"));
3577
3713
  }
3578
- reviewOptions.push(opt("Review on my own", "Review manually, then continue"));
3714
+ reviewOptions.push(opt("Review on my own", phase === "implement"
3715
+ ? "Mark spots in the changed files with AI_REVIEW: comments; the agent then addresses each and removes the marker"
3716
+ : phase === "plan"
3717
+ ? "Mark spots in the synthesized plan with AI_REVIEW: comments; the agent then addresses each and removes the marker"
3718
+ : "Mark spots in USER_REQUEST.md, RESEARCH.md, and artifacts/*.md with AI_REVIEW: comments; the agent then addresses each and removes the marker"));
3579
3719
  reviewOptions.push(opt("Back", "Return to the previous menu"));
3580
3720
 
3581
3721
  const reviewChoice = await selectOption(ctx, "Review", reviewOptions);
3582
- if (!reviewChoice || reviewChoice === "Back") continue;
3722
+ if (!reviewChoice || reviewChoice === "Back") break;
3583
3723
 
3584
3724
  if (reviewChoice === "Review in Plannotator") {
3585
3725
  if (phase === "plan") {
@@ -3591,80 +3731,51 @@ export async function showActiveTaskMenu(
3591
3731
  if (handled.continueLoop) continue;
3592
3732
  return handled.text ?? text;
3593
3733
  }
3594
- const repos = getRegisteredRepos(orchestrator);
3595
- const summaries: string[] = [];
3596
- let stopReviewing = false;
3597
-
3598
- for (const repo of repos) {
3599
- if (stopReviewing) break;
3600
-
3601
- while (true) {
3602
- const diffChoice = await selectOption(ctx, `Review: ${formatRepoLabel(repo)}`, [
3603
- opt("All branch changes", "Committed changes vs base branch"),
3604
- opt("Last commit", "Changes in the most recent commit"),
3605
- opt("Since commit", "Review all changes since a specific commit"),
3606
- opt("Uncommitted changes", "Working directory changes"),
3607
- opt("Skip this repo", "Move to the next repository"),
3608
- opt("Done (stop reviewing)", "Stop iterating repositories"),
3609
- ]);
3610
-
3611
- if (!diffChoice || diffChoice === "Done (stop reviewing)") {
3612
- stopReviewing = true;
3613
- break;
3614
- }
3615
- if (diffChoice === "Skip this repo") {
3616
- summaries.push(`${formatRepoLabel(repo)}: SKIPPED`);
3617
- break;
3618
- }
3619
-
3620
- let diffType: string | undefined;
3621
- let defaultBranch: string | undefined;
3622
-
3623
- if (diffChoice === "All branch changes") {
3624
- diffType = "branch";
3625
- defaultBranch = await detectDefaultBranch(orchestrator, repos, repo.path);
3626
- } else if (diffChoice === "Last commit") {
3627
- diffType = "last-commit";
3628
- } else if (diffChoice === "Since commit") {
3629
- const pickedHash = await pickCommitForRepo(orchestrator, ctx, repo);
3630
- if (!pickedHash) continue;
3631
- diffType = "branch";
3632
- defaultBranch = pickedHash;
3633
- } else {
3634
- diffType = "uncommitted";
3635
- }
3636
-
3637
- const result = await openCodeReviewInPlannotator(orchestrator, {
3638
- cwd: repo.path,
3639
- diffType,
3640
- defaultBranch,
3641
- });
3642
- if (result.status === "error") {
3643
- summaries.push(`${formatRepoLabel(repo)}: ERROR${result.error ? ` — ${result.error}` : ""}`);
3644
- } else if (result.status === "approved") {
3645
- summaries.push(`${formatRepoLabel(repo)}: APPROVED`);
3646
- } else {
3647
- summaries.push(`${formatRepoLabel(repo)}: NEEDS_CHANGES${result.feedback ? `\nFeedback: ${result.feedback}` : ""}`);
3648
- }
3649
- break;
3650
- }
3734
+ const allRepos = getRegisteredRepos(orchestrator);
3735
+ const eligible: string[] = [];
3736
+ for (const repo of allRepos) {
3737
+ const base = await detectDefaultBranch(orchestrator, allRepos, repo.path);
3738
+ const { changed, error } = await repoHasReviewableChanges(orchestrator, repo, base);
3739
+ if (error || changed) eligible.push(repo.path);
3651
3740
  }
3652
-
3653
- if (summaries.length > 0) {
3654
- ctx.ui.notify(`Plannotator review summary:\n\n${summaries.join("\n\n")}`, "info");
3655
- } else {
3656
- ctx.ui.notify("No repositories were reviewed in Plannotator.", "info");
3741
+ if (eligible.length === 0) {
3742
+ ctx.ui.notify("No registered repositories have changes to review.", "info");
3743
+ continue;
3657
3744
  }
3745
+ // Start (or restart) the interleaved per-repo cursor and run it. On
3746
+ // needs_changes runPlannotatorCursor persists the cursor and returns a
3747
+ // work instruction (exits the menu to start the agent turn); the next /pp
3748
+ // resumes at the following repo.
3749
+ task.state.plannotatorCursor = { repoPaths: eligible, index: 0 };
3750
+ saveTask(task.dir, task.state);
3751
+ const cursorText = await runPlannotatorCursor(orchestrator, ctx);
3752
+ if (cursorText) return cursorText;
3658
3753
  continue;
3659
3754
  }
3660
3755
 
3661
3756
  if (reviewChoice === "Review on my own") {
3662
- if (phase === "plan") {
3663
- setStep(orchestrator, "synthesize");
3664
- } else {
3665
- setStep(orchestrator, "llm_work");
3757
+ const gate = await selectOption(ctx, "Editor review", [
3758
+ opt("Done", "I've added AI_REVIEW: markers and saved my files"),
3759
+ opt("Skip markers", "Continue without the marker workflow"),
3760
+ opt("Back", "Return to the review menu"),
3761
+ ]);
3762
+ if (!gate || gate === "Back") continue;
3763
+ setStep(orchestrator, phase === "plan" ? "synthesize" : "llm_work");
3764
+ if (gate === "Skip markers") {
3765
+ return continueMessage;
3766
+ }
3767
+ if (phase === "implement") {
3768
+ return advanceBanner(
3769
+ "[PI-PI] The user reviewed the changes in their editor and left inline `AI_REVIEW:` markers " +
3770
+ `${AI_REVIEW_MARKER_SYNTAX}.\n\n` +
3771
+ "For each registered repo, enumerate the CHANGED files only — union of `git diff --name-only <base>...HEAD`, " +
3772
+ "`git diff --name-only`, `git diff --cached --name-only`, and untracked-non-ignored files from `git status --porcelain` " +
3773
+ "(base = each repo's configured base branch) — and search WITHIN those files for `AI_REVIEW:`. Do NOT grep the whole " +
3774
+ "worktree (avoid vendored/generated/node_modules and historical markers).\n\n" +
3775
+ AI_REVIEW_MARKER_LOOP,
3776
+ );
3666
3777
  }
3667
- return continueMessage;
3778
+ return readOnlyReviewBanner(phase, task.dir);
3668
3779
  }
3669
3780
 
3670
3781
  const reviewPreset = await pickPreset(ctx, orchestrator, getReviewPresetGroup(phase), "Review preset");
@@ -3681,6 +3792,7 @@ export async function showActiveTaskMenu(
3681
3792
  const handled = handleReviewResult(ctx, text);
3682
3793
  if (handled.continueLoop) continue;
3683
3794
  return handled.text ?? text;
3795
+ }
3684
3796
  }
3685
3797
  }
3686
3798
  }