@ilya-lesikov/pi-pi 0.8.0 → 0.10.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 (114) hide show
  1. package/3p/pi-ask-user/index.ts +71 -124
  2. package/3p/pi-ask-user/single-select-layout.ts +3 -14
  3. package/3p/pi-subagents/package.json +13 -7
  4. package/3p/pi-subagents/src/agent-manager.ts +243 -79
  5. package/3p/pi-subagents/src/agent-runner.ts +501 -365
  6. package/3p/pi-subagents/src/agent-types.ts +46 -57
  7. package/3p/pi-subagents/src/cross-extension-rpc.ts +52 -46
  8. package/3p/pi-subagents/src/custom-agents.ts +30 -6
  9. package/3p/pi-subagents/src/default-agents.ts +25 -43
  10. package/3p/pi-subagents/src/enabled-models.ts +180 -0
  11. package/3p/pi-subagents/src/index.ts +599 -839
  12. package/3p/pi-subagents/src/model-resolver.ts +26 -7
  13. package/3p/pi-subagents/src/output-file.ts +21 -2
  14. package/3p/pi-subagents/src/prompts.ts +17 -3
  15. package/3p/pi-subagents/src/schedule-store.ts +153 -0
  16. package/3p/pi-subagents/src/schedule.ts +365 -0
  17. package/3p/pi-subagents/src/settings.ts +261 -0
  18. package/3p/pi-subagents/src/skill-loader.ts +77 -54
  19. package/3p/pi-subagents/src/status-note.ts +25 -0
  20. package/3p/pi-subagents/src/types.ts +97 -6
  21. package/3p/pi-subagents/src/ui/agent-widget.ts +94 -21
  22. package/3p/pi-subagents/src/ui/conversation-viewer.ts +147 -35
  23. package/3p/pi-subagents/src/ui/schedule-menu.ts +104 -0
  24. package/3p/pi-subagents/src/ui/viewer-keys.ts +39 -0
  25. package/3p/pi-subagents/src/usage.ts +60 -0
  26. package/3p/pi-subagents/src/worktree.ts +49 -20
  27. package/extensions/orchestrator/agents/advisor.ts +13 -8
  28. package/extensions/orchestrator/agents/brainstorm-reviewer.ts +8 -3
  29. package/extensions/orchestrator/agents/code-reviewer.ts +29 -8
  30. package/extensions/orchestrator/agents/constraints.test.ts +65 -1
  31. package/extensions/orchestrator/agents/constraints.ts +73 -2
  32. package/extensions/orchestrator/agents/deep-debugger.ts +13 -8
  33. package/extensions/orchestrator/agents/explore.ts +12 -6
  34. package/extensions/orchestrator/agents/librarian.ts +13 -5
  35. package/extensions/orchestrator/agents/plan-reviewer.ts +8 -3
  36. package/extensions/orchestrator/agents/planner.ts +8 -3
  37. package/extensions/orchestrator/agents/pools.test.ts +56 -0
  38. package/extensions/orchestrator/agents/prompts.test.ts +52 -10
  39. package/extensions/orchestrator/agents/registry.test.ts +245 -0
  40. package/extensions/orchestrator/agents/registry.ts +145 -10
  41. package/extensions/orchestrator/agents/reviewer.ts +14 -8
  42. package/extensions/orchestrator/agents/task.ts +12 -6
  43. package/extensions/orchestrator/agents/tool-routing.ts +213 -69
  44. package/extensions/orchestrator/agents/wait-for-completion.test.ts +171 -0
  45. package/extensions/orchestrator/ai-comment-cleanup.test.ts +89 -0
  46. package/extensions/orchestrator/ai-comment-cleanup.ts +73 -0
  47. package/extensions/orchestrator/ast-search.test.ts +124 -0
  48. package/extensions/orchestrator/billing-spoof.test.ts +67 -0
  49. package/extensions/orchestrator/billing-spoof.ts +95 -0
  50. package/extensions/orchestrator/cbm.more.test.ts +358 -0
  51. package/extensions/orchestrator/command-handlers.test.ts +47 -0
  52. package/extensions/orchestrator/command-handlers.ts +49 -27
  53. package/extensions/orchestrator/commands.test.ts +15 -2
  54. package/extensions/orchestrator/commands.ts +1 -1
  55. package/extensions/orchestrator/config.test.ts +129 -2
  56. package/extensions/orchestrator/config.ts +115 -19
  57. package/extensions/orchestrator/context.test.ts +46 -0
  58. package/extensions/orchestrator/context.ts +34 -5
  59. package/extensions/orchestrator/custom-footer.test.ts +105 -0
  60. package/extensions/orchestrator/custom-footer.ts +22 -20
  61. package/extensions/orchestrator/doctor.more.test.ts +315 -0
  62. package/extensions/orchestrator/doctor.ts +6 -2
  63. package/extensions/orchestrator/event-handlers.more.test.ts +561 -0
  64. package/extensions/orchestrator/event-handlers.test.ts +411 -10
  65. package/extensions/orchestrator/event-handlers.ts +738 -349
  66. package/extensions/orchestrator/exa.more.test.ts +118 -0
  67. package/extensions/orchestrator/flant-infra.more.test.ts +326 -0
  68. package/extensions/orchestrator/flant-infra.test.ts +127 -0
  69. package/extensions/orchestrator/flant-infra.ts +113 -39
  70. package/extensions/orchestrator/index.test.ts +76 -0
  71. package/extensions/orchestrator/index.ts +2 -0
  72. package/extensions/orchestrator/integration.test.ts +488 -74
  73. package/extensions/orchestrator/model-registry.test.ts +2 -1
  74. package/extensions/orchestrator/model-registry.ts +12 -2
  75. package/extensions/orchestrator/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  76. package/extensions/orchestrator/orchestrator.test.ts +67 -0
  77. package/extensions/orchestrator/orchestrator.ts +165 -85
  78. package/extensions/orchestrator/phases/brainstorm.test.ts +30 -1
  79. package/extensions/orchestrator/phases/brainstorm.ts +53 -12
  80. package/extensions/orchestrator/phases/implementation.ts +2 -0
  81. package/extensions/orchestrator/phases/machine.test.ts +53 -1
  82. package/extensions/orchestrator/phases/machine.ts +15 -2
  83. package/extensions/orchestrator/phases/planning.test.ts +47 -1
  84. package/extensions/orchestrator/phases/planning.ts +18 -3
  85. package/extensions/orchestrator/phases/review-task.test.ts +20 -0
  86. package/extensions/orchestrator/phases/review-task.ts +47 -14
  87. package/extensions/orchestrator/phases/review.test.ts +36 -0
  88. package/extensions/orchestrator/phases/review.ts +62 -10
  89. package/extensions/orchestrator/phases/spawn-blocking.test.ts +1 -0
  90. package/extensions/orchestrator/phases/verdict.ts +6 -5
  91. package/extensions/orchestrator/plannotator-open-failure.test.ts +67 -0
  92. package/extensions/orchestrator/plannotator.test.ts +38 -1
  93. package/extensions/orchestrator/plannotator.ts +50 -3
  94. package/extensions/orchestrator/pp-menu.leaves.test.ts +590 -0
  95. package/extensions/orchestrator/pp-menu.more.test.ts +524 -0
  96. package/extensions/orchestrator/pp-menu.test.ts +314 -1
  97. package/extensions/orchestrator/pp-menu.ts +937 -450
  98. package/extensions/orchestrator/pp-state-tools.test.ts +80 -0
  99. package/extensions/orchestrator/pp-state-tools.ts +65 -0
  100. package/extensions/orchestrator/rate-limit-fallback.more.test.ts +241 -0
  101. package/extensions/orchestrator/rate-limit-fallback.test.ts +20 -1
  102. package/extensions/orchestrator/rate-limit-fallback.ts +12 -0
  103. package/extensions/orchestrator/review-files.test.ts +26 -0
  104. package/extensions/orchestrator/review-files.ts +3 -0
  105. package/extensions/orchestrator/state.test.ts +82 -1
  106. package/extensions/orchestrator/state.ts +110 -23
  107. package/extensions/orchestrator/suppress-pierre-theme-spam.test.ts +56 -0
  108. package/extensions/orchestrator/suppress-pierre-theme-spam.ts +45 -0
  109. package/extensions/orchestrator/transition-controller.test.ts +100 -0
  110. package/extensions/orchestrator/transition-controller.ts +61 -3
  111. package/extensions/orchestrator/validate-artifacts.ts +1 -1
  112. package/package.json +6 -2
  113. package/scripts/test-3p.sh +52 -0
  114. package/AGENTS.md +0 -28
@@ -1,4 +1,4 @@
1
- import { existsSync, readdirSync, writeFileSync } from "fs";
1
+ import { existsSync, readdirSync } 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";
@@ -14,20 +14,23 @@ import {
14
14
  readRawConfig,
15
15
  removeConfigValue,
16
16
  resolvePreset,
17
+ reviewPresetGroupForPhase,
17
18
  writeConfigValue,
19
+ MAX_CONCURRENT_SUBAGENTS_CEILING,
18
20
  type PiPiConfig,
19
21
  type PresetGroup,
22
+ type PoolKey,
23
+ type PoolEntry,
20
24
  type OrchestratorRole,
21
25
  type VariantConfig,
22
26
  } from "./config.js";
23
27
  import {
24
- loadBrainstormReviewOutputs,
25
- loadCodeReviewOutputs,
26
- loadPlanReviewOutputs,
28
+ loadPhaseReviewOutputs,
29
+ hasFinalPassAnchors,
27
30
  } from "./context.js";
28
- import { detectDefaultBranch, enterReviewCycle, finalizeReviewCycle } from "./event-handlers.js";
31
+ import { detectDefaultBranch, enterReviewCycle, finalizeReviewCycle, isReviewCycleLive, registerFeatureToolsAndAgents } from "./event-handlers.js";
29
32
  import { Orchestrator } from "./orchestrator.js";
30
- import { cancelPendingPlannotatorWait, openPlannotator, waitForPlannotatorResult } from "./plannotator.js";
33
+ import { cancelPendingPlannotatorWait, openPlannotator, openAnnotateReview, waitForPlannotatorResult } from "./plannotator.js";
31
34
  import { advanceBanner } from "./messages.js";
32
35
  import { spawnPlanners, spawnPlanReviewers } from "./phases/planning.js";
33
36
  import { spawnCodeReviewers } from "./phases/review.js";
@@ -46,13 +49,17 @@ import {
46
49
  taskAge,
47
50
  taskName,
48
51
  taskNameFromState,
52
+ taskFullName,
49
53
  taskShortId,
50
54
  type AutonomousConfig,
51
55
  type TaskMode,
52
56
  type TaskInfo,
53
57
  type TaskType,
58
+ type TaskState,
59
+ type Phase,
54
60
  } from "./state.js";
55
61
  import {
62
+ SUB_MODEL_PREFIX,
56
63
  clearFlantGeneratedConfig,
57
64
  getFlantGeneratedConfig,
58
65
  loadFlantSettings,
@@ -85,6 +92,7 @@ type TimeoutKey =
85
92
  | "performance.commands.afterEdit"
86
93
  | "performance.commands.afterImplement"
87
94
  | "performance.internals.subagentStale"
95
+ | "performance.internals.mainTurnStale"
88
96
  | "performance.internals.taskLockStale"
89
97
  | "performance.internals.taskLockRefresh";
90
98
 
@@ -105,16 +113,22 @@ async function selectOptionCancelable(
105
113
  question: string,
106
114
  options: OptionInput[],
107
115
  ): Promise<{ choice?: string; cancelReason?: CancelReason }> {
108
- const result = await askUser(ctx, {
109
- question,
110
- options,
111
- allowFreeform: false,
112
- allowComment: false,
113
- allowMultiple: false,
114
- });
115
- if (result && isCancel(result)) return { cancelReason: result.reason };
116
- if (!result || result.kind !== "selection") return {};
117
- return { choice: result.selections[0] };
116
+ const orchestrator = Orchestrator.current;
117
+ if (orchestrator) orchestrator.interactivePromptOpen = true;
118
+ try {
119
+ const result = await askUser(ctx, {
120
+ question,
121
+ options,
122
+ allowFreeform: false,
123
+ allowComment: false,
124
+ allowMultiple: false,
125
+ });
126
+ if (result && isCancel(result)) return { cancelReason: result.reason };
127
+ if (!result || result.kind !== "selection") return {};
128
+ return { choice: result.selections[0] };
129
+ } finally {
130
+ if (orchestrator) orchestrator.interactivePromptOpen = false;
131
+ }
118
132
  }
119
133
 
120
134
  function opt(title: string, description: string): OptionInput {
@@ -151,51 +165,7 @@ function formatRepoList(repos: RepoInfo[]): string {
151
165
  .join("\n");
152
166
  }
153
167
 
154
- function appendSection(content: string, heading: string, body: string): string {
155
- const normalized = content.trimEnd();
156
- if (normalized.includes(`${heading}\n`)) return normalized + "\n";
157
- return `${normalized}\n\n${heading}\n${body}\n`;
158
- }
159
-
160
- function appendToSection(content: string, heading: string, body: string): string {
161
- const normalized = content.trimEnd();
162
- if (normalized.includes(body)) return normalized + "\n";
163
-
164
- const lines = normalized.split("\n");
165
- const sectionIndex = lines.findIndex((line) => line.trim() === heading);
166
- if (sectionIndex === -1) {
167
- return appendSection(normalized, heading, body);
168
- }
169
-
170
- let insertIndex = lines.length;
171
- for (let i = sectionIndex + 1; i < lines.length; i += 1) {
172
- if (lines[i]?.startsWith("## ")) {
173
- insertIndex = i;
174
- break;
175
- }
176
- }
177
-
178
- const insertLines = body.split("\n");
179
- if (insertIndex > sectionIndex + 1 && lines[insertIndex - 1]?.trim() !== "") {
180
- insertLines.unshift("");
181
- }
182
- lines.splice(insertIndex, 0, ...insertLines);
183
- return lines.join("\n") + "\n";
184
- }
185
-
186
- function appendRepoContext(content: string, repos: RepoInfo[]): string {
187
- if (repos.length === 0) return content;
188
- const lines = repos.map((repo) => `- ${formatRepoLabel(repo)}${repo.baseBranch ? ` (base: ${repo.baseBranch})` : ""}`);
189
- return appendToSection(content, "## Constraints", `Registered repositories:\n${lines.join("\n")}`);
190
- }
191
168
 
192
- function appendResearchOpenQuestions(content: string, text: string): string {
193
- const normalized = content.trimEnd();
194
- if (normalized.includes("## Open Questions\n")) {
195
- return `${normalized}\n${text}\n`;
196
- }
197
- return `${normalized}\n\n## Open Questions\n${text}\n`;
198
- }
199
169
 
200
170
  async function pickCommitForRepo(orchestrator: Orchestrator, ctx: any, repo: RepoInfo): Promise<string | null> {
201
171
  let commits: Array<{ hash: string; message: string; age: string }> = [];
@@ -276,6 +246,84 @@ function setStep(orchestrator: Orchestrator, step: string): void {
276
246
  saveTask(orchestrator.active.dir, orchestrator.active.state);
277
247
  }
278
248
 
249
+ // Publish the latest synthesized review findings to a target. The agent (not the
250
+ // extension) performs the writes: file comments = insert `AI_COMMENT:` markers at
251
+ // each finding's line; PR comments = run `gh` to post line-anchored comments from
252
+ // the user's own account. Both are idempotent — the agent checks for markers/
253
+ // comments it already published and skips duplicates, so re-publishing is safe.
254
+ const PRIVACY_INSTRUCTION =
255
+ "PRIVACY: comment bodies MUST be self-contained observations about the code itself. Do NOT reference " +
256
+ "private or internal details, \"the ticket\", issue trackers, or internal design docs. Say what is wrong " +
257
+ "in the code, not that it \"violates the design goal\" of some private document.";
258
+
259
+ export function publishFileCommentsBanner(taskDir: string): string {
260
+ const reviewsDir = join(taskDir, "code-reviews");
261
+ return advanceBanner(
262
+ "[PI-PI] Publish the synthesized review findings as FILE COMMENTS now.\n\n" +
263
+ `Use the \`ANCHORS:\` block in the latest \`${reviewsDir}/*_final_pass-*.md\` as the file:line source — do NOT invent locations.\n\n` +
264
+ "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. " +
265
+ "This is the ONLY source edit permitted — no fixes, no other changes.\n\n" +
266
+ PRIVACY_INSTRUCTION + "\n\n" +
267
+ "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" +
268
+ "When done, report how many markers you inserted and how many you skipped as already-present, then end your turn.",
269
+ );
270
+ }
271
+
272
+ export function publishPrCommentsBanner(taskDir: string): string {
273
+ const reviewsDir = join(taskDir, "code-reviews");
274
+ return advanceBanner(
275
+ "[PI-PI] Publish the synthesized review findings as GITHUB PR COMMENTS now, from the user's own `gh`-authenticated account.\n\n" +
276
+ `Use the \`ANCHORS:\` block in the latest \`${reviewsDir}/*_final_pass-*.md\` as the file:line source — do NOT invent locations.\n\n` +
277
+ "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
+ "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
+ "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" +
282
+ PRIVACY_INSTRUCTION + "\n\n" +
283
+ "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" +
285
+ "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
+ );
287
+ }
288
+
289
+ // Guard for the Publish menu: both banners consume the `ANCHORS:` block from the
290
+ // latest `code-reviews/*_final_pass-*.md`. If no such file exists, or the newest one
291
+ // carries no `ANCHORS:` block, publishing would spawn an agent that immediately fails.
292
+ // Returns a user-facing message to show instead, or undefined when publishing can proceed.
293
+ export const MISSING_FINAL_PASS_ANCHORS =
294
+ "No ANCHORS-bearing final review file exists yet. Run or finish a review pass first " +
295
+ "(/pp → Review) so the findings are synthesized into `code-reviews/*_final_pass-*.md`.";
296
+
297
+ export function publishGuard(taskDir: string): string | undefined {
298
+ return hasFinalPassAnchors(taskDir) ? undefined : MISSING_FINAL_PASS_ANCHORS;
299
+ }
300
+
301
+ const AI_REVIEW_MARKER_SYNTAX =
302
+ "(inside each file's native comment syntax, e.g. `// AI_REVIEW: ...`, `# AI_REVIEW: ...`, `<!-- AI_REVIEW: ... -->`)";
303
+
304
+ const AI_REVIEW_MARKER_LOOP =
305
+ "For each marker: address the request, then remove that marker in the SAME edit. After a pass, re-scan the target files and " +
306
+ "repeat until no `AI_REVIEW:` markers remain. Then verify your work and report what you changed per marker. When complete, " +
307
+ "call pp_phase_complete.";
308
+
309
+ // Read-only phases (brainstorm/debug/review) and plan produce markdown state files rather than
310
+ // source changes, so their AI_REVIEW scan targets those artifacts. Missing targets are skipped,
311
+ // not errors. Kept as literal-token guidance (no regexp/parser), mirroring the implement banner.
312
+ function readOnlyReviewBanner(phase: string, taskDir: string): string {
313
+ const targets =
314
+ phase === "plan"
315
+ ? "the synthesized plan(s) at `" + taskDir + "/plans/*_synthesized.md` ONLY (ignore raw planner outputs, " +
316
+ "`review_*` files, and the `plan-reviews/`, `brainstorm-reviews/`, `code-reviews/` directories)"
317
+ : "`" + taskDir + "/USER_REQUEST.md`, `" + taskDir + "/RESEARCH.md`, and `" + taskDir + "/artifacts/*.md`";
318
+ return advanceBanner(
319
+ `[PI-PI] The user reviewed this phase's state files in their editor and left inline \`AI_REVIEW:\` markers ` +
320
+ `${AI_REVIEW_MARKER_SYNTAX}.\n\n` +
321
+ `Search WITHIN ${targets} for \`AI_REVIEW:\`. Only scan those files — skip any target that does not exist ` +
322
+ `(a missing \`artifacts/*.md\` or a not-yet-produced state file is fine, not an error).\n\n` +
323
+ AI_REVIEW_MARKER_LOOP,
324
+ );
325
+ }
326
+
279
327
  function showStatus(orchestrator: Orchestrator, ctx: any): void {
280
328
  if (!orchestrator.active) {
281
329
  ctx.ui.notify("No active task.", "info");
@@ -316,6 +364,9 @@ async function pauseTask(orchestrator: Orchestrator, ctx: any): Promise<string>
316
364
  const type = orchestrator.active.type;
317
365
 
318
366
  orchestrator.active.state.reviewCycle = null;
367
+ // Drop any in-progress per-repo Plannotator cursor so a resumed task does not
368
+ // auto-reopen Plannotator; the user re-enters Review explicitly.
369
+ orchestrator.active.state.plannotatorCursor = undefined;
319
370
  saveTask(orchestrator.active.dir, orchestrator.active.state);
320
371
  unregisterAgentDefinitions(orchestrator.pi);
321
372
  await orchestrator.cleanupActive();
@@ -340,6 +391,13 @@ async function pauseTask(orchestrator: Orchestrator, ctx: any): Promise<string>
340
391
  async function finishTask(orchestrator: Orchestrator, ctx: any): Promise<string> {
341
392
  if (!orchestrator.active) return "No active task.";
342
393
 
394
+ // The review phase must produce the ANCHORS-bearing final_pass file Publish
395
+ // consumes; block the direct Complete path too (it bypasses validateExitCriteria).
396
+ if (orchestrator.active.state.phase === "review" && !hasFinalPassAnchors(orchestrator.active.dir)) {
397
+ ctx.ui.notify(MISSING_FINAL_PASS_ANCHORS, "warning");
398
+ return MISSING_FINAL_PASS_ANCHORS;
399
+ }
400
+
343
401
  cancelPendingPlannotatorWait(orchestrator);
344
402
  orchestrator.abortAllSubagents();
345
403
  orchestrator.transitionController.abortMainAgent(ctx.abort?.bind(ctx));
@@ -351,7 +409,11 @@ async function finishTask(orchestrator: Orchestrator, ctx: any): Promise<string>
351
409
 
352
410
  orchestrator.lastCtx = ctx;
353
411
 
412
+ // Record the phase we finished FROM so a later Resume can reopen the task at
413
+ // its real last working phase (done carries no phase history of its own).
414
+ orchestrator.active.state.completedFrom = orchestrator.active.state.phase;
354
415
  orchestrator.active.state.phase = "done";
416
+ orchestrator.active.state.step = null;
355
417
  orchestrator.active.state.reviewCycle = null;
356
418
  saveTask(orchestrator.active.dir, orchestrator.active.state);
357
419
  unregisterAgentDefinitions(orchestrator.pi);
@@ -366,7 +428,8 @@ async function finishTask(orchestrator: Orchestrator, ctx: any): Promise<string>
366
428
  // is over, nothing awaits this compaction).
367
429
  void orchestrator.transitionController.requestTransition({
368
430
  kind: "done",
369
- summary: `Task "${name}" (${type}) completed.`,
431
+ discard: true,
432
+ 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.`,
370
433
  });
371
434
 
372
435
  const urExists = existsSync(join(dir, "USER_REQUEST.md"));
@@ -385,16 +448,8 @@ async function finishTask(orchestrator: Orchestrator, ctx: any): Promise<string>
385
448
  return `Task "${name}" completed.`;
386
449
  }
387
450
 
388
- function loadPhaseReviewOutputs(taskDir: string, phase: string, pass: number): { name: string; content: string }[] {
389
- if (phase === "brainstorm") return loadBrainstormReviewOutputs(taskDir, pass);
390
- if (phase === "plan") return loadPlanReviewOutputs(taskDir, pass);
391
- return loadCodeReviewOutputs(taskDir, pass);
392
- }
393
-
394
451
  function getDefaultReviewPresetName(config: PiPiConfig, phase: string): string {
395
- if (phase === "brainstorm") return config.agents.subagents.presetGroups.brainstormReviewers.default;
396
- if (phase === "plan") return config.agents.subagents.presetGroups.planReviewers.default;
397
- return config.agents.subagents.presetGroups.codeReviewers.default;
452
+ return config.agents.subagents.presetGroups[reviewPresetGroupForPhase(phase)].default;
398
453
  }
399
454
 
400
455
  function isPresetEnabled(preset: { enabled?: boolean } | undefined): boolean {
@@ -402,9 +457,7 @@ function isPresetEnabled(preset: { enabled?: boolean } | undefined): boolean {
402
457
  }
403
458
 
404
459
  function getReviewPresetGroup(phase: string): PresetGroup {
405
- if (phase === "brainstorm") return "brainstormReviewers";
406
- if (phase === "plan") return "planReviewers";
407
- return "codeReviewers";
460
+ return reviewPresetGroupForPhase(phase);
408
461
  }
409
462
 
410
463
  export async function pickPreset(
@@ -440,6 +493,18 @@ export async function pickPreset(
440
493
  return byTitle.get(choice) ?? null;
441
494
  }
442
495
 
496
+ // The phase to restore when reopening a done task (#2). Prefer the recorded
497
+ // completedFrom; for legacy done tasks that lack it, fall back to the phase whose
498
+ // transition target is "done" (approximate — assumes the task ran to its terminal
499
+ // phase, which is wrong only for tasks completed early).
500
+ function reopenPhaseForDoneTask(type: TaskType, state: TaskState): Phase {
501
+ if (state.completedFrom) return state.completedFrom;
502
+ for (const phase of ["implement", "plan", "review", "debug", "brainstorm", "quick"] as Phase[]) {
503
+ if (nextPhase(type, phase) === "done") return phase;
504
+ }
505
+ return "implement" as Phase;
506
+ }
507
+
443
508
  export async function resumeTask(
444
509
  orchestrator: Orchestrator,
445
510
  ctx: any,
@@ -470,6 +535,19 @@ export async function resumeTask(
470
535
  orchestrator.resetTaskScopedState();
471
536
  orchestrator.activeTaskToken++;
472
537
 
538
+ // Reopen a done task (#2): now that the lock is held, reload under the lock and
539
+ // restore the phase the task was completed FROM so it re-enters at a sensible
540
+ // working phase (done leaves step=null). Lock-first, then mutate, then save.
541
+ if (task.state.phase === "done") {
542
+ task.state = loadTask(task.dir);
543
+ if (task.state.phase === "done") {
544
+ task.state.phase = reopenPhaseForDoneTask(task.type, task.state);
545
+ task.state.step = "llm_work";
546
+ task.state.completedFrom = undefined;
547
+ saveTask(task.dir, task.state);
548
+ }
549
+ }
550
+
473
551
  const normalizedRoot = normalizeRepoPath(orchestrator.cwd);
474
552
  if (!task.state.repos || task.state.repos.length === 0) {
475
553
  task.state.repos = [{ path: normalizedRoot, isRoot: true }];
@@ -612,11 +690,7 @@ export async function resumeTask(
612
690
  "warning",
613
691
  );
614
692
  }
615
- const reviewers = phase === "brainstorm"
616
- ? resolvePreset(orchestrator.config, "brainstormReviewers", presetName)
617
- : phase === "plan"
618
- ? resolvePreset(orchestrator.config, "planReviewers", presetName)
619
- : resolvePreset(orchestrator.config, "codeReviewers", presetName);
693
+ const reviewers = resolvePreset(orchestrator.config, group, presetName);
620
694
  const reviewerCount = Object.values(reviewers).filter((v) => isEnabled(v)).length;
621
695
 
622
696
  if (cycle.kind === "auto" && (cycle.step === "spawn_reviewers" || cycle.step === "await_reviewers")) {
@@ -665,7 +739,7 @@ export async function resumeTask(
665
739
  orchestrator.active.state.activeReviewPreset = presetName;
666
740
  saveTask(orchestrator.active.dir, orchestrator.active.state);
667
741
  orchestrator.pendingSubagentSpawns = missingVariants.length;
668
- const spawnFn = phase === "brainstorm"
742
+ const spawnFn = reviewPresetGroupForPhase(phase) === "brainstormReviewers"
669
743
  ? () => spawnBrainstormReviewers(
670
744
  pi,
671
745
  orchestrator.cwd,
@@ -677,7 +751,7 @@ export async function resumeTask(
677
751
  missingReviewerConfig,
678
752
  orchestrator.active?.state.repos ?? [],
679
753
  )
680
- : phase === "plan"
754
+ : reviewPresetGroupForPhase(phase) === "planReviewers"
681
755
  ? () => spawnPlanReviewers(
682
756
  pi,
683
757
  orchestrator.cwd,
@@ -788,18 +862,25 @@ function listCompletedFromTasks(cwd: string): TaskInfo[] {
788
862
  }
789
863
 
790
864
  async function showSubagentsMenu(ctx: any): Promise<void> {
791
- const api = (globalThis as any)[Symbol.for("pi-subagents:menu")] as { showMenu?: (menuCtx: any) => Promise<void> } | undefined;
792
- if (!api?.showMenu) {
865
+ const api = (globalThis as any)[Symbol.for("pi-subagents:menu")] as {
866
+ showFleet?: (menuCtx: any) => Promise<void>;
867
+ } | undefined;
868
+ // showFleet opens the navigable running-agents list (the FleetView replacement).
869
+ if (!api?.showFleet) {
793
870
  ctx.ui.notify("Subagents menu API is not available.", "warning");
794
871
  return;
795
872
  }
796
- await api.showMenu(ctx);
873
+ await api.showFleet(ctx);
797
874
  }
798
875
 
799
- function countFlantProviders(settings: FlantSettings): { anthropic: number; openai: number } {
876
+ function countFlantProviders(settings: FlantSettings): { anthropic: number; openai: number; sub: number } {
800
877
  const models = settings.cachedFlantModels ?? [];
801
878
  const anthropic = models.filter((m) => m.startsWith("claude-")).length;
802
- return { anthropic, openai: Math.max(0, models.length - anthropic) };
879
+ const subConfirmed = models.filter((m) => m.startsWith(SUB_MODEL_PREFIX)).length;
880
+ // Model lists cached before the gateway exposed sub/ ids have none — the
881
+ // sub provider then registers all claude models (see registerFlantProviders).
882
+ const sub = subConfirmed > 0 ? subConfirmed : anthropic;
883
+ return { anthropic, openai: Math.max(0, models.length - anthropic - subConfirmed), sub };
803
884
  }
804
885
 
805
886
  function collectRoleAssignments(config: Partial<PiPiConfig> | null): string[] {
@@ -839,9 +920,13 @@ function collectRoleAssignments(config: Partial<PiPiConfig> | null): string[] {
839
920
  add("agents.subagents.simple.explore", config.agents?.subagents?.simple?.explore?.model);
840
921
  add("agents.subagents.simple.librarian", config.agents?.subagents?.simple?.librarian?.model);
841
922
  add("agents.subagents.simple.task", config.agents?.subagents?.simple?.task?.model);
842
- add("agents.subagents.simple.advisor", config.agents?.subagents?.simple?.advisor?.model);
843
- add("agents.subagents.simple.deep-debugger", config.agents?.subagents?.simple?.["deep-debugger"]?.model);
844
- add("agents.subagents.simple.reviewer", config.agents?.subagents?.simple?.reviewer?.model);
923
+ for (const poolKey of ["advisors", "reviewers", "deepDebuggers"] as const) {
924
+ const pool = config.agents?.subagents?.pools?.[poolKey];
925
+ if (!Array.isArray(pool)) continue;
926
+ pool.forEach((entry: any, i: number) => {
927
+ if (entry?.enabled !== false) add(`agents.subagents.pools.${poolKey}[${i}]`, entry?.model);
928
+ });
929
+ }
845
930
  return out;
846
931
  }
847
932
 
@@ -859,7 +944,7 @@ function flantStatusText(settings: FlantSettings): string {
859
944
  const hasGatewayKey = !!readGatewayApiKey();
860
945
  const subActive = hasOAuth && hasGatewayKey;
861
946
  lines.push(
862
- `Personal subscription: on (${subActive ? `active — pp-flant-anthropic-sub, ${providers.anthropic} models` : "inactive"})`,
947
+ `Personal subscription: on (${subActive ? `active — pp-flant-anthropic-sub, ${providers.sub} models` : "inactive"})`,
863
948
  );
864
949
  lines.push(`Rate-limit switch-back check: every ${settings.switchBackIntervalMinutes} min`);
865
950
  if (!subActive) {
@@ -917,7 +1002,7 @@ async function showFlantInfraMenu(orchestrator: Orchestrator, ctx: any): Promise
917
1002
  }
918
1003
  options.push({ title: "Back", description: "Return to the previous menu" });
919
1004
 
920
- const choice = await selectOption(ctx, "Flant AI Infrastructure", options);
1005
+ const choice = await selectOption(ctx, "Flant", options);
921
1006
  if (!choice || choice === "Back") return BACK;
922
1007
 
923
1008
  if (choice === enableLabel) {
@@ -926,7 +1011,7 @@ async function showFlantInfraMenu(orchestrator: Orchestrator, ctx: any): Promise
926
1011
  saveFlantSettings(next);
927
1012
  unregisterFlantProviders(orchestrator.pi);
928
1013
  clearFlantGeneratedConfig();
929
- ctx.ui.notify("Flant AI Infrastructure disabled.", "info");
1014
+ ctx.ui.notify("Flant disabled.", "info");
930
1015
  } else {
931
1016
  if (!process.env.FLANT_API_KEY) {
932
1017
  ctx.ui.notify("Set FLANT_API_KEY environment variable first.", "warning");
@@ -1207,7 +1292,6 @@ export function showUsage(ctx: any): void {
1207
1292
  async function showInfoMenu(orchestrator: Orchestrator, ctx: any): Promise<typeof BACK> {
1208
1293
  while (true) {
1209
1294
  const options: OptionInput[] = [];
1210
- options.push({ title: "Subagents", description: "Manage running agents" });
1211
1295
  options.push({ title: "Usage", description: "Show session token usage and cost breakdown" });
1212
1296
  options.push({ title: "Doctor", description: "Run diagnostic checks" });
1213
1297
  if (orchestrator.active) {
@@ -1218,10 +1302,6 @@ async function showInfoMenu(orchestrator: Orchestrator, ctx: any): Promise<typeo
1218
1302
 
1219
1303
  const choice = await selectOption(ctx, "Info", options);
1220
1304
  if (!choice || choice === "Back") return BACK;
1221
- if (choice === "Subagents") {
1222
- await showSubagentsMenu(ctx);
1223
- continue;
1224
- }
1225
1305
  if (choice === "Usage") {
1226
1306
  showUsage(ctx);
1227
1307
  continue;
@@ -1270,9 +1350,12 @@ const SUBAGENT_ROLES: Array<{ role: AgentRole; label: string; description: strin
1270
1350
  { role: "explore", label: "Explore", description: "agents.subagents.simple.explore" },
1271
1351
  { role: "librarian", label: "Librarian", description: "agents.subagents.simple.librarian" },
1272
1352
  { role: "task", label: "Task", description: "agents.subagents.simple.task" },
1273
- { role: "advisor", label: "Advisor", description: "agents.subagents.simple.advisor" },
1274
- { role: "deep-debugger", label: "Deep debugger", description: "agents.subagents.simple.deep-debugger" },
1275
- { role: "reviewer", label: "Reviewer", description: "agents.subagents.simple.reviewer" },
1353
+ ];
1354
+
1355
+ const POOL_ITEMS: Array<{ pool: PoolKey; label: string }> = [
1356
+ { pool: "advisors", label: "Advisors" },
1357
+ { pool: "reviewers", label: "Reviewers" },
1358
+ { pool: "deepDebuggers", label: "Deep debuggers" },
1276
1359
  ];
1277
1360
 
1278
1361
  const PRESET_GROUP_ITEMS: Array<{ group: PresetGroup; label: string }> = [
@@ -1286,6 +1369,7 @@ const TIMEOUT_LABELS: Record<TimeoutKey, string> = {
1286
1369
  "performance.commands.afterEdit": "Command after file edit",
1287
1370
  "performance.commands.afterImplement": "Command after implementation",
1288
1371
  "performance.internals.subagentStale": "Subagent stale",
1372
+ "performance.internals.mainTurnStale": "Main turn stale",
1289
1373
  "performance.internals.taskLockStale": "Lock stale",
1290
1374
  "performance.internals.taskLockRefresh": "Lock update",
1291
1375
  };
@@ -1692,6 +1776,12 @@ function refreshSubagentDefinitions(orchestrator: Orchestrator, keyPath: string[
1692
1776
  orchestrator.registerAgents();
1693
1777
  }
1694
1778
 
1779
+ function applyConcurrencyIfChanged(orchestrator: Orchestrator, keyPath: string[]): void {
1780
+ if (keyPath[0] === "agents" && keyPath[1] === "maxConcurrentSubagents") {
1781
+ orchestrator.applySubagentConcurrency();
1782
+ }
1783
+ }
1784
+
1695
1785
  function applyConfigChange(orchestrator: Orchestrator, scope: Scope, keyPath: string[], value: any): void {
1696
1786
  const result = tryApplyConfigChange(orchestrator, scope, keyPath, value);
1697
1787
  if (!result.ok) {
@@ -1710,6 +1800,7 @@ function applyConfigChange(orchestrator: Orchestrator, scope: Scope, keyPath: st
1710
1800
  updateRegistryFromAvailableModels(modelIds);
1711
1801
  }
1712
1802
  refreshSubagentDefinitions(orchestrator, keyPath);
1803
+ applyConcurrencyIfChanged(orchestrator, keyPath);
1713
1804
  }
1714
1805
 
1715
1806
  function clearConfigOverride(orchestrator: Orchestrator, scope: Scope, keyPath: string[]): void {
@@ -1719,6 +1810,7 @@ function clearConfigOverride(orchestrator: Orchestrator, scope: Scope, keyPath:
1719
1810
  return;
1720
1811
  }
1721
1812
  refreshSubagentDefinitions(orchestrator, keyPath);
1813
+ applyConcurrencyIfChanged(orchestrator, keyPath);
1722
1814
  }
1723
1815
 
1724
1816
  function thinkingLabel(value: string): string {
@@ -1827,8 +1919,8 @@ async function showOrchestratorEditor(
1827
1919
  const current = orchestrator.config.agents.orchestrators[role];
1828
1920
  const basePath = ["agents", "orchestrators", role];
1829
1921
  const choice = await selectOption(ctx, label, [
1830
- opt(`Model: ${current.model}`, "Select model"),
1831
- opt(`Thinking: ${thinkingLabel(current.thinking)}`, "Select thinking level"),
1922
+ opt(`Model: ${current.model}`, "Choose the model for this agent"),
1923
+ opt(`Thinking: ${thinkingLabel(current.thinking)}`, "Choose how much this agent thinks before acting"),
1832
1924
  ...buildResetOptions(orchestrator, basePath),
1833
1925
  opt("Back", "Return to the previous menu"),
1834
1926
  ]);
@@ -1874,8 +1966,8 @@ async function showSimpleSubagentEditor(
1874
1966
  const current = orchestrator.config.agents.subagents.simple[role];
1875
1967
  const basePath = ["agents", "subagents", "simple", role];
1876
1968
  const choice = await selectOption(ctx, label, [
1877
- opt(`Model: ${current.model}`, "Select model"),
1878
- opt(`Thinking: ${thinkingLabel(current.thinking)}`, "Select thinking level"),
1969
+ opt(`Model: ${current.model}`, "Choose the model for this agent"),
1970
+ opt(`Thinking: ${thinkingLabel(current.thinking)}`, "Choose how much this agent thinks before acting"),
1879
1971
  ...buildResetOptions(orchestrator, basePath),
1880
1972
  opt("Back", "Return to the previous menu"),
1881
1973
  ]);
@@ -1976,8 +2068,8 @@ async function showPresetVariantEditor(
1976
2068
  const variantPath = ["agents", "subagents", "presetGroups", group, "presets", presetName, "agents", variantName];
1977
2069
  const options: OptionInput[] = [
1978
2070
  opt(`Enabled: ${isEnabled(variant) ? "Yes" : "No"}`, "Toggle enabled state"),
1979
- opt(`Model: ${variant.model}`, "Select model"),
1980
- opt(`Thinking: ${thinkingLabel(variant.thinking)}`, "Select thinking level"),
2071
+ opt(`Model: ${variant.model}`, "Choose the model for this agent"),
2072
+ opt(`Thinking: ${thinkingLabel(variant.thinking)}`, "Choose how much this agent thinks before acting"),
1981
2073
  ];
1982
2074
  if (getOwnedScopes(orchestrator, variantPath).length > 0) {
1983
2075
  options.push(opt("Delete", "Delete this agent override"));
@@ -2029,7 +2121,7 @@ async function showPresetAgentsMenu(
2029
2121
  options.push(opt(title, `${variant.model} / ${thinkingLabel(variant.thinking)}`));
2030
2122
  byTitle.set(title, variantName);
2031
2123
  }
2032
- options.push(opt("New agent", "Add a new agent"));
2124
+ options.push(opt("New agent", "Add an agent variant to this preset"));
2033
2125
  options.push(opt("Back", "Return to the previous menu"));
2034
2126
  const choice = await selectOption(ctx, "Agents", options);
2035
2127
  if (!choice || choice === "Back") return BACK;
@@ -2198,7 +2290,7 @@ async function showPresetSettings(
2198
2290
  options.push(opt(optionTitle, enabledPresetSummary(preset.agents)));
2199
2291
  byTitle.set(optionTitle, presetName);
2200
2292
  }
2201
- options.push(opt("New preset", "Create preset"));
2293
+ options.push(opt("New preset", "Create a preset in this group"));
2202
2294
  options.push(opt("Back", "Return to the previous menu"));
2203
2295
  const choice = await selectOption(ctx, title, options);
2204
2296
  if (!choice || choice === "Back") return BACK;
@@ -2212,12 +2304,118 @@ async function showPresetSettings(
2212
2304
  }
2213
2305
  }
2214
2306
 
2307
+ // The dynamic on-demand pools (advisors/reviewers/deepDebuggers) are arrays, not
2308
+ // keyed records. writeConfigValue treats arrays as opaque leaves, so every edit
2309
+ // rewrites the WHOLE array into the chosen scope (deepMerge replaces arrays).
2310
+ function poolModelLabel(pool: PoolKey): string {
2311
+ return pool === "deepDebuggers" ? "deep-debugger" : pool.replace(/s$/, "");
2312
+ }
2313
+
2314
+ async function writePool(orchestrator: Orchestrator, ctx: any, pool: PoolKey, next: PoolEntry[]): Promise<boolean> {
2315
+ const scope = await pickScope(ctx, orchestrator);
2316
+ if (!scope) return false;
2317
+ applyConfigChange(orchestrator, scope, ["agents", "subagents", "pools", pool], next);
2318
+ return true;
2319
+ }
2320
+
2321
+ async function showPoolEntryEditor(
2322
+ orchestrator: Orchestrator,
2323
+ ctx: any,
2324
+ pool: PoolKey,
2325
+ index: number,
2326
+ ): Promise<typeof BACK> {
2327
+ while (true) {
2328
+ const entries = orchestrator.config.agents.subagents.pools[pool] ?? [];
2329
+ const entry = entries[index];
2330
+ if (!entry) return BACK;
2331
+ const options: OptionInput[] = [
2332
+ opt(`Enabled: ${entry.enabled === false ? "No" : "Yes"}`, "Toggle whether this model registers as a subagent"),
2333
+ opt(`Model: ${entry.model}`, "Choose the model for this pool entry"),
2334
+ opt(`Thinking: ${thinkingLabel(entry.thinking)}`, "Choose how much this agent thinks before acting"),
2335
+ opt("Delete", "Remove this entry from the pool"),
2336
+ opt("Back", "Return to the previous menu"),
2337
+ ];
2338
+ const choice = await selectOption(ctx, `${poolModelLabel(pool)} entry`, options);
2339
+ if (!choice || choice === "Back") return BACK;
2340
+ if (choice.startsWith("Model:")) {
2341
+ const model = await pickModel(ctx, entry.model);
2342
+ if (!model) continue;
2343
+ const next = structuredClone(entries);
2344
+ next[index] = { ...next[index], model };
2345
+ await writePool(orchestrator, ctx, pool, next);
2346
+ continue;
2347
+ }
2348
+ if (choice.startsWith("Thinking:")) {
2349
+ const thinking = await pickThinking(ctx, true);
2350
+ if (!thinking) continue;
2351
+ const next = structuredClone(entries);
2352
+ next[index] = { ...next[index], thinking };
2353
+ await writePool(orchestrator, ctx, pool, next);
2354
+ continue;
2355
+ }
2356
+ if (choice.startsWith("Enabled:")) {
2357
+ const next = structuredClone(entries);
2358
+ next[index] = { ...next[index], enabled: entry.enabled === false };
2359
+ await writePool(orchestrator, ctx, pool, next);
2360
+ continue;
2361
+ }
2362
+ const confirm = await selectOption(ctx, "Confirm delete?", [
2363
+ opt("Yes, delete", "This cannot be undone"),
2364
+ opt("Back", "Cancel"),
2365
+ ]);
2366
+ if (confirm !== "Yes, delete") continue;
2367
+ const next = structuredClone(entries);
2368
+ next.splice(index, 1);
2369
+ if (await writePool(orchestrator, ctx, pool, next)) return BACK;
2370
+ }
2371
+ }
2372
+
2373
+ async function addPoolEntry(orchestrator: Orchestrator, ctx: any, pool: PoolKey): Promise<void> {
2374
+ const model = await pickModel(ctx);
2375
+ if (!model) return;
2376
+ const thinking = await pickThinking(ctx, true);
2377
+ if (!thinking) return;
2378
+ const next = structuredClone(orchestrator.config.agents.subagents.pools[pool] ?? []);
2379
+ next.push({ enabled: true, model, thinking });
2380
+ await writePool(orchestrator, ctx, pool, next);
2381
+ }
2382
+
2383
+ async function showPoolSettings(orchestrator: Orchestrator, ctx: any, pool: PoolKey, label: string): Promise<typeof BACK> {
2384
+ while (true) {
2385
+ const entries = orchestrator.config.agents.subagents.pools[pool] ?? [];
2386
+ const options: OptionInput[] = [];
2387
+ const byTitle = new Map<string, number>();
2388
+ entries.forEach((entry, i) => {
2389
+ const tag = entry.enabled === false ? " (disabled)" : "";
2390
+ const title = `${entry.model}${tag}`;
2391
+ options.push(opt(title, `thinking ${thinkingLabel(entry.thinking)}`));
2392
+ byTitle.set(title, i);
2393
+ });
2394
+ options.push(opt("New entry", `Add a model to the ${label.toLowerCase()} pool`));
2395
+ options.push(opt("Back", "Return to the previous menu"));
2396
+ const choice = await selectOption(ctx, label, options);
2397
+ if (!choice || choice === "Back") return BACK;
2398
+ if (choice === "New entry") {
2399
+ await addPoolEntry(orchestrator, ctx, pool);
2400
+ continue;
2401
+ }
2402
+ const idx = byTitle.get(choice);
2403
+ if (idx === undefined) continue;
2404
+ await showPoolEntryEditor(orchestrator, ctx, pool, idx);
2405
+ }
2406
+ }
2407
+
2215
2408
  async function showSubagentSettings(orchestrator: Orchestrator, ctx: any): Promise<typeof BACK> {
2216
2409
  while (true) {
2217
2410
  const options: OptionInput[] = SUBAGENT_ROLES.map(({ role, label, description }) => {
2218
2411
  const current = orchestrator.config.agents.subagents.simple[role];
2219
2412
  return opt(label, `${current.model} / ${thinkingLabel(current.thinking)} — ${description}`);
2220
2413
  });
2414
+ for (const item of POOL_ITEMS) {
2415
+ const pool = orchestrator.config.agents.subagents.pools[item.pool] ?? [];
2416
+ const enabled = pool.filter((e) => e.enabled !== false).length;
2417
+ options.push(opt(item.label, `${enabled}/${pool.length} enabled — on-demand model pool`));
2418
+ }
2221
2419
  for (const item of PRESET_GROUP_ITEMS) {
2222
2420
  options.push(opt(item.label, `${Object.keys(orchestrator.config.agents.subagents.presetGroups[item.group].presets ?? {}).length} presets`));
2223
2421
  }
@@ -2229,6 +2427,11 @@ async function showSubagentSettings(orchestrator: Orchestrator, ctx: any): Promi
2229
2427
  await showSimpleSubagentEditor(orchestrator, ctx, simple.role, simple.label);
2230
2428
  continue;
2231
2429
  }
2430
+ const pool = POOL_ITEMS.find((item) => item.label === choice);
2431
+ if (pool) {
2432
+ await showPoolSettings(orchestrator, ctx, pool.pool, pool.label);
2433
+ continue;
2434
+ }
2232
2435
  const group = PRESET_GROUP_ITEMS.find((item) => item.label === choice);
2233
2436
  if (!group) continue;
2234
2437
  await showPresetSettings(orchestrator, ctx, group.group, group.label);
@@ -2272,7 +2475,7 @@ async function showAfterEditCommands(orchestrator: Orchestrator, ctx: any): Prom
2272
2475
  options.push(opt(`${title}${enabledTag}`, `${globsCount} patterns — ${cmd.run}`));
2273
2476
  byTitle.set(`${title}${enabledTag}`, id);
2274
2477
  }
2275
- options.push(opt("New command", "Add command"));
2478
+ options.push(opt("New command", "Add a command to run after a file edit"));
2276
2479
  options.push(...buildResetOptions(orchestrator, ["commands", "afterEdit"]));
2277
2480
  options.push(opt("Back", "Return to the previous menu"));
2278
2481
  const choice = await selectOption(ctx, `After file edit: ${entries.length} commands`, options);
@@ -2401,7 +2604,7 @@ async function showAfterImplementCommands(orchestrator: Orchestrator, ctx: any):
2401
2604
  options.push(opt(title, cmd.run));
2402
2605
  byTitle.set(title, id);
2403
2606
  }
2404
- options.push(opt("New command", "Add command"));
2607
+ options.push(opt("New command", "Add a command to run after implementation"));
2405
2608
  options.push(...buildResetOptions(orchestrator, ["commands", "afterImplement"]));
2406
2609
  options.push(opt("Back", "Return to the previous menu"));
2407
2610
  const choice = await selectOption(ctx, `After implementation: ${entries.length} commands`, options);
@@ -2461,11 +2664,11 @@ async function showCommandsSettings(orchestrator: Orchestrator, ctx: any): Promi
2461
2664
  const choice = await selectOption(ctx, "Commands", [
2462
2665
  opt(
2463
2666
  `After file edit: ${afterEditCount} commands`,
2464
- `${afterEditCount} commands`,
2667
+ "Commands run when a matching file is edited (e.g. format, lint)",
2465
2668
  ),
2466
2669
  opt(
2467
2670
  `After implementation: ${afterImplementCount} commands`,
2468
- `${afterImplementCount} commands`,
2671
+ "Commands run once the implement phase completes (e.g. build, test)",
2469
2672
  ),
2470
2673
  opt("Back", "Return to the previous menu"),
2471
2674
  ]);
@@ -2496,6 +2699,11 @@ async function showTimeoutsSettings(orchestrator: Orchestrator, ctx: any): Promi
2496
2699
  path: ["performance", "internals", "subagentStale"],
2497
2700
  value: orchestrator.config.performance.internals.subagentStale,
2498
2701
  },
2702
+ {
2703
+ key: "performance.internals.mainTurnStale",
2704
+ path: ["performance", "internals", "mainTurnStale"],
2705
+ value: orchestrator.config.performance.internals.mainTurnStale,
2706
+ },
2499
2707
  {
2500
2708
  key: "performance.internals.taskLockStale",
2501
2709
  path: ["performance", "internals", "taskLockStale"],
@@ -2510,7 +2718,7 @@ async function showTimeoutsSettings(orchestrator: Orchestrator, ctx: any): Promi
2510
2718
  const options: OptionInput[] = timeoutEntries.map((entry) => {
2511
2719
  const value = entry.value;
2512
2720
  const key = entry.key;
2513
- return opt(`${TIMEOUT_LABELS[key]}: ${formatDuration(value)}`, "Edit timeout");
2721
+ return opt(`${TIMEOUT_LABELS[key]}: ${formatDuration(value)}`, "Change this time limit");
2514
2722
  });
2515
2723
  options.push(opt("Back", "Return to the previous menu"));
2516
2724
  const choice = await selectOption(ctx, "Timeouts", options);
@@ -2548,7 +2756,7 @@ async function showTimeoutsSettings(orchestrator: Orchestrator, ctx: any): Promi
2548
2756
  async function showPerformanceSettings(orchestrator: Orchestrator, ctx: any): Promise<typeof BACK> {
2549
2757
  while (true) {
2550
2758
  const choice = await selectOption(ctx, "Performance", [
2551
- opt("Timeouts", "Timeout configuration"),
2759
+ opt("Timeouts", "Adjust per-operation time limits"),
2552
2760
  opt("Back", "Return to the previous menu"),
2553
2761
  ]);
2554
2762
  if (!choice || choice === "Back") return BACK;
@@ -2649,10 +2857,10 @@ async function showGeneralSettings(orchestrator: Orchestrator, ctx: any): Promis
2649
2857
  while (true) {
2650
2858
  const choice = await selectOption(ctx, "General", [
2651
2859
  opt(`Commit automatically: ${orchestrator.config.general.autoCommit ? "Yes" : "No"}`, "Enable or disable auto commits"),
2860
+ opt(`Inject root AGENTS.md: ${orchestrator.config.general.injectAgentsMd ? "Yes" : "No"}`, "Inject the working repo's root AGENTS.md into the agent system prompt"),
2652
2861
  opt(`Ignore configs from other repos: ${orchestrator.config.general.loadExtraRepoConfigs ? "No" : "Yes"}`, "Load only root repo config"),
2653
2862
  opt(`Log level: ${logLevelLabel(orchestrator.config.general.logLevel)}`, "Logging verbosity"),
2654
2863
  opt(`Tracing: ${orchestrator.config.general.tracing ? "Yes" : "No"}`, "Capture full session traces to .pp/logs/traces/"),
2655
- opt("Flant AI Infrastructure", "Configure corporate AI model provider"),
2656
2864
  opt("Back", "Return to the previous menu"),
2657
2865
  ]);
2658
2866
  if (!choice || choice === "Back") return BACK;
@@ -2660,6 +2868,10 @@ async function showGeneralSettings(orchestrator: Orchestrator, ctx: any): Promis
2660
2868
  await showBooleanSetting(orchestrator, ctx, "Commit automatically", ["general", "autoCommit"], "Commit changes automatically as work progresses", "Leave committing to you");
2661
2869
  continue;
2662
2870
  }
2871
+ if (choice.startsWith("Inject root AGENTS.md:")) {
2872
+ 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");
2873
+ continue;
2874
+ }
2663
2875
  if (choice.startsWith("Ignore configs from other repos:")) {
2664
2876
  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");
2665
2877
  continue;
@@ -2672,15 +2884,19 @@ async function showGeneralSettings(orchestrator: Orchestrator, ctx: any): Promis
2672
2884
  await showBooleanSetting(orchestrator, ctx, "Tracing", ["general", "tracing"], "Capture full session traces to .pp/logs/traces/", "Do not record session traces");
2673
2885
  continue;
2674
2886
  }
2675
- await showFlantInfraMenu(orchestrator, ctx);
2676
2887
  }
2677
2888
  }
2678
2889
 
2890
+ const MAX_CONCURRENT_SUBAGENTS_LABEL = "Max concurrent subagents";
2891
+ const MAX_CONCURRENT_SUBAGENTS_PATH = ["agents", "maxConcurrentSubagents"];
2892
+
2679
2893
  async function showAgentsSettings(orchestrator: Orchestrator, ctx: any): Promise<typeof BACK> {
2680
2894
  while (true) {
2895
+ const limit = orchestrator.config.agents.maxConcurrentSubagents;
2681
2896
  const choice = await selectOption(ctx, "Agents", [
2682
2897
  opt("Orchestrators", "Orchestrator and subagent configuration"),
2683
2898
  opt("Subagents", "Simple subagents and preset groups"),
2899
+ opt(`${MAX_CONCURRENT_SUBAGENTS_LABEL}: ${limit}`, "Max background/orchestrated subagents run at once; extras queue"),
2684
2900
  opt("Back", "Return to the previous menu"),
2685
2901
  ]);
2686
2902
  if (!choice || choice === "Back") return BACK;
@@ -2688,10 +2904,56 @@ async function showAgentsSettings(orchestrator: Orchestrator, ctx: any): Promise
2688
2904
  await showOrchestratorsSettings(orchestrator, ctx);
2689
2905
  continue;
2690
2906
  }
2907
+ if (choice.startsWith(`${MAX_CONCURRENT_SUBAGENTS_LABEL}:`)) {
2908
+ await showMaxConcurrentSubagentsSetting(orchestrator, ctx);
2909
+ continue;
2910
+ }
2691
2911
  await showSubagentSettings(orchestrator, ctx);
2692
2912
  }
2693
2913
  }
2694
2914
 
2915
+ async function showMaxConcurrentSubagentsSetting(orchestrator: Orchestrator, ctx: any): Promise<typeof BACK> {
2916
+ while (true) {
2917
+ const current = getNestedValue(orchestrator.config, MAX_CONCURRENT_SUBAGENTS_PATH);
2918
+ if (typeof current !== "number") break;
2919
+ const action = await selectOption(ctx, `${MAX_CONCURRENT_SUBAGENTS_LABEL}: ${current}`, [
2920
+ opt("Edit", "Set the maximum number of concurrent background subagents"),
2921
+ ...buildResetOptions(orchestrator, MAX_CONCURRENT_SUBAGENTS_PATH),
2922
+ opt("Back", "Return to the previous menu"),
2923
+ ]);
2924
+ if (!action || action === "Back") break;
2925
+ if (action === "Edit") {
2926
+ const value = await pickMaxConcurrentSubagents(ctx, current);
2927
+ if (value === null) continue;
2928
+ applyScopeChoice(orchestrator, MAX_CONCURRENT_SUBAGENTS_PATH, value, await pickScope(ctx, orchestrator));
2929
+ continue;
2930
+ }
2931
+ await maybeHandleResetChoice(orchestrator, ctx, action, MAX_CONCURRENT_SUBAGENTS_PATH);
2932
+ }
2933
+ return BACK;
2934
+ }
2935
+
2936
+ async function pickMaxConcurrentSubagents(ctx: any, current: number): Promise<number | null> {
2937
+ while (true) {
2938
+ const input = await ctx.ui.input(
2939
+ `Max concurrent subagents (positive integer, 1-${MAX_CONCURRENT_SUBAGENTS_CEILING}) [${current}]`,
2940
+ );
2941
+ if (input === undefined || input === null) return null;
2942
+ const trimmed = String(input).trim();
2943
+ if (trimmed === "") return null;
2944
+ if (!/^\d+$/.test(trimmed)) {
2945
+ ctx.ui.notify(`Please enter a positive integer between 1 and ${MAX_CONCURRENT_SUBAGENTS_CEILING}.`, "warning");
2946
+ continue;
2947
+ }
2948
+ const parsed = Number.parseInt(trimmed, 10);
2949
+ if (parsed < 1 || parsed > MAX_CONCURRENT_SUBAGENTS_CEILING) {
2950
+ ctx.ui.notify(`Please enter a positive integer between 1 and ${MAX_CONCURRENT_SUBAGENTS_CEILING}.`, "warning");
2951
+ continue;
2952
+ }
2953
+ return parsed;
2954
+ }
2955
+ }
2956
+
2695
2957
  async function showReposSettings(orchestrator: Orchestrator, ctx: any): Promise<typeof BACK> {
2696
2958
  while (true) {
2697
2959
  const repos = orchestrator.active?.state.repos ?? [];
@@ -2760,11 +3022,13 @@ async function showLspSettings(ctx: any): Promise<typeof BACK> {
2760
3022
  async function showSettingsMenu(orchestrator: Orchestrator, ctx: any): Promise<typeof BACK> {
2761
3023
  while (true) {
2762
3024
  const options: OptionInput[] = [
2763
- opt("General", "Commit, log level, Flant AI, repos"),
3025
+ opt("General", "Commit, log level, repos"),
2764
3026
  opt("Agents", "Orchestrator and subagent configuration"),
2765
3027
  opt("Commands", "After file edit and after implementation"),
2766
- opt("Performance", "Timeout configuration"),
3028
+ opt("Performance", "Per-operation timeout limits"),
2767
3029
  opt("LSP", "Language server controls"),
3030
+ opt("Flant", "Configure corporate AI model provider"),
3031
+ opt("Info", "Usage and task status"),
2768
3032
  opt("Back", "Return to the previous menu"),
2769
3033
  ];
2770
3034
 
@@ -2776,12 +3040,14 @@ async function showSettingsMenu(orchestrator: Orchestrator, ctx: any): Promise<t
2776
3040
  else if (choice === "Commands") await showCommandsSettings(orchestrator, ctx);
2777
3041
  else if (choice === "Performance") await showPerformanceSettings(orchestrator, ctx);
2778
3042
  else if (choice === "LSP") await showLspSettings(ctx);
3043
+ else if (choice === "Flant") await showFlantInfraMenu(orchestrator, ctx);
3044
+ else if (choice === "Info") await showInfoMenu(orchestrator, ctx);
2779
3045
  }
2780
3046
  }
2781
3047
 
2782
3048
  // First phases (brainstorm/debug/review) are always interactive and can never run
2783
3049
  // autonomously — autonomous configs only ever cover plan/implement.
2784
- function autonomousPhasesForTask(type: TaskType): string[] {
3050
+ export function autonomousPhasesForTask(type: TaskType): string[] {
2785
3051
  if (type === "implement" || type === "debug" || type === "review") return ["plan", "implement"];
2786
3052
  return [];
2787
3053
  }
@@ -2855,10 +3121,10 @@ async function showAutonomousPhaseSettings(
2855
3121
  const reviewPreset = phaseConfig.reviewPreset ?? defaultAutonomousReviewPreset(type, phase);
2856
3122
  const maxReview = phaseConfig.maxReviewPasses >= 999 ? "No limit" : String(phaseConfig.maxReviewPasses);
2857
3123
  const options: OptionInput[] = [
2858
- opt(`Review preset: ${reviewPreset}`, "Select review preset"),
3124
+ opt(`Review preset: ${reviewPreset}`, "Pick which reviewer group runs in this phase"),
2859
3125
  ];
2860
3126
  if (phase === "plan") {
2861
- options.push(opt(`Planner preset: ${phaseConfig.plannerPreset ?? "regular"}`, "Select planner preset"));
3127
+ options.push(opt(`Planner preset: ${phaseConfig.plannerPreset ?? "regular"}`, "Pick which planner group runs in this phase"));
2862
3128
  }
2863
3129
  options.push(opt(`Max review passes: ${maxReview}`, "Safety cap for autonomous review loops"));
2864
3130
  options.push(opt("Back", "Return to autonomous settings"));
@@ -2927,7 +3193,8 @@ async function pickModeForTaskStart(
2927
3193
  // uniqueness across the menu (see buildResumeOptions) so the option->task
2928
3194
  // mapping stays stable.
2929
3195
  function resumeOptionTitle(t: TaskInfo): string {
2930
- return `${taskNameFromState(t.dir, t.state)} ${taskAge(t.state)}`;
3196
+ const marker = t.state.phase === "done" ? "✓ done · " : "";
3197
+ return `${marker}${taskNameFromState(t.dir, t.state)} — ${taskAge(t.state)}`;
2931
3198
  }
2932
3199
 
2933
3200
  // Description: rich per-entry detail sourced entirely from the already-loaded
@@ -2955,7 +3222,12 @@ function resumeOptionDescription(t: TaskInfo, cwd: string): string {
2955
3222
  }
2956
3223
 
2957
3224
  parts.push(`id ${taskShortId(t.dir)}`);
2958
- return parts.join(" · ");
3225
+
3226
+ // Metadata line first, then a blank line, then the full (untrimmed) intent, so
3227
+ // the right pane leads with phase/mode/id and still shows the real task below.
3228
+ const full = taskFullName(t.dir, s);
3229
+ const meta = parts.join(" · ");
3230
+ return full && full !== meta ? `${meta}\n\n${full}` : meta;
2959
3231
  }
2960
3232
 
2961
3233
  // Build menu options with a stable option->task index. Titles are made unique
@@ -2978,24 +3250,114 @@ function buildResumeOptions(tasks: TaskInfo[], cwd: string): { options: OptionIn
2978
3250
  return { options, byTitle };
2979
3251
  }
2980
3252
 
3253
+ type StatusFilter = "Active" | "Active + Done" | "Done only" | "All";
3254
+ interface ResumeFilters {
3255
+ status: StatusFilter;
3256
+ type: "All" | TaskType;
3257
+ mode: "All" | TaskMode;
3258
+ repo: string;
3259
+ }
3260
+
3261
+ const STATUS_CYCLE: StatusFilter[] = ["Active", "Active + Done", "Done only", "All"];
3262
+ const TYPE_CYCLE: Array<"All" | TaskType> = ["All", "implement", "debug", "brainstorm", "review", "quick"];
3263
+ const MODE_CYCLE: Array<"All" | TaskMode> = ["All", "guided", "autonomous"];
3264
+
3265
+ function defaultResumeFilters(): ResumeFilters {
3266
+ return { status: "Active", type: "All", mode: "All", repo: "All" };
3267
+ }
3268
+
3269
+ function filtersSummary(f: ResumeFilters): string {
3270
+ return `Status: ${f.status} · Type: ${f.type} · Mode: ${f.mode} · Repo: ${f.repo}`;
3271
+ }
3272
+
3273
+ function applyResumeFilters(tasks: TaskInfo[], f: ResumeFilters): TaskInfo[] {
3274
+ return tasks.filter((t) => {
3275
+ const isDone = t.state.phase === "done";
3276
+ if (f.status === "Active" && isDone) return false;
3277
+ if (f.status === "Done only" && !isDone) return false;
3278
+ if (f.type !== "All" && t.type !== f.type) return false;
3279
+ if (f.mode !== "All" && (t.state.effectiveMode ?? t.state.mode) !== f.mode) return false;
3280
+ if (f.repo !== "All") {
3281
+ const rootRepo = t.state.repos?.find((r) => r.isRoot) ?? t.state.repos?.[0];
3282
+ if (!rootRepo || basename(rootRepo.path) !== f.repo) return false;
3283
+ }
3284
+ return true;
3285
+ });
3286
+ }
3287
+
3288
+ function cycle<T>(values: readonly T[], current: T): T {
3289
+ const idx = values.indexOf(current);
3290
+ return values[(idx + 1) % values.length]!;
3291
+ }
3292
+
3293
+ async function showResumeFilters(
3294
+ orchestrator: Orchestrator,
3295
+ ctx: any,
3296
+ filters: ResumeFilters,
3297
+ lockToType: TaskType | undefined,
3298
+ ): Promise<void> {
3299
+ const repoNames = Array.from(
3300
+ new Set(
3301
+ listTasks(orchestrator.cwd, { type: lockToType, includeDone: true })
3302
+ .map((t) => {
3303
+ const rootRepo = t.state.repos?.find((r) => r.isRoot) ?? t.state.repos?.[0];
3304
+ return rootRepo ? basename(rootRepo.path) : null;
3305
+ })
3306
+ .filter((n): n is string => !!n),
3307
+ ),
3308
+ );
3309
+ const repoCycle = ["All", ...repoNames];
3310
+ while (true) {
3311
+ const options: OptionInput[] = [
3312
+ opt(`Status: ${filters.status}`, "Cycle: Active / Active + Done / Done only / All"),
3313
+ ];
3314
+ if (!lockToType) options.push(opt(`Type: ${filters.type}`, "Cycle task type"));
3315
+ options.push(opt(`Mode: ${filters.mode}`, "Cycle: All / guided / autonomous"));
3316
+ options.push(opt(`Repo: ${filters.repo}`, "Cycle registered repo"));
3317
+ options.push(opt("Clear filters", "Reset to defaults"));
3318
+ options.push(opt("Back", "Return to the Resume list"));
3319
+
3320
+ const choice = await selectOption(ctx, "Filters", options);
3321
+ if (!choice || choice === "Back") return;
3322
+ if (choice.startsWith("Status:")) filters.status = cycle(STATUS_CYCLE, filters.status);
3323
+ else if (choice.startsWith("Type:")) filters.type = cycle(TYPE_CYCLE, filters.type);
3324
+ else if (choice.startsWith("Mode:")) filters.mode = cycle(MODE_CYCLE, filters.mode);
3325
+ else if (choice.startsWith("Repo:")) filters.repo = cycle(repoCycle, filters.repo);
3326
+ else if (choice === "Clear filters") {
3327
+ const cleared = defaultResumeFilters();
3328
+ filters.status = cleared.status;
3329
+ filters.type = cleared.type;
3330
+ filters.mode = cleared.mode;
3331
+ filters.repo = cleared.repo;
3332
+ }
3333
+ }
3334
+ }
3335
+
2981
3336
  async function showResumeMenu(
2982
3337
  orchestrator: Orchestrator,
2983
3338
  ctx: any,
2984
3339
  type: TaskType | undefined,
2985
3340
  emptyMessage: string,
2986
3341
  ): Promise<typeof BACK | "started"> {
3342
+ const filters = defaultResumeFilters();
2987
3343
  while (true) {
2988
- const tasks = listTasks(orchestrator.cwd, type);
2989
- if (tasks.length === 0) {
3344
+ const all = listTasks(orchestrator.cwd, { type, includeDone: true });
3345
+ if (all.length === 0) {
2990
3346
  ctx.ui.notify(emptyMessage, "info");
2991
3347
  return BACK;
2992
3348
  }
3349
+ const tasks = applyResumeFilters(all, filters);
2993
3350
 
2994
3351
  const { options, byTitle } = buildResumeOptions(tasks, orchestrator.cwd);
3352
+ options.unshift({ title: "⚙ Filters", description: filtersSummary(filters) });
2995
3353
  options.push({ title: "Back", description: "Return to the previous menu" });
2996
3354
 
2997
3355
  const choice = await selectOption(ctx, "Resume", options);
2998
3356
  if (!choice || choice === "Back") return BACK;
3357
+ if (choice === "⚙ Filters") {
3358
+ await showResumeFilters(orchestrator, ctx, filters, type);
3359
+ continue;
3360
+ }
2999
3361
 
3000
3362
  const task = byTitle.get(choice);
3001
3363
  if (!task) continue;
@@ -3036,7 +3398,10 @@ async function showFromMenu(orchestrator: Orchestrator, ctx: any): Promise<typeo
3036
3398
 
3037
3399
  const modeSelection = await pickModeForTaskStart(orchestrator, ctx, "implement");
3038
3400
  if (!modeSelection) continue;
3039
- await orchestrator.startTask(ctx, "implement", "implement", selected.dir, true, modeSelection.mode);
3401
+ // Carry the source task's resolved name so the new implement task shows a
3402
+ // real name instead of the literal "implement" (#7).
3403
+ const inheritedName = taskFullName(selected.dir, selected.state);
3404
+ await orchestrator.startTask(ctx, "implement", inheritedName, selected.dir, true, modeSelection.mode);
3040
3405
  if (orchestrator.active) {
3041
3406
  orchestrator.active.state.autonomousConfig = modeSelection.autonomousConfig;
3042
3407
  saveTask(orchestrator.active.dir, orchestrator.active.state);
@@ -3134,9 +3499,14 @@ async function openCodeReviewInPlannotator(
3134
3499
  if (payload.diffType) requestPayload.diffType = payload.diffType;
3135
3500
  if (payload.defaultBranch) requestPayload.defaultBranch = payload.defaultBranch;
3136
3501
 
3137
- const { opened, reviewId } = await openPlannotator(orchestrator.pi, "code-review", requestPayload);
3502
+ const { opened, reviewId, outcome } = await openPlannotator(orchestrator.pi, "code-review", requestPayload);
3138
3503
  if (!opened) {
3139
- return { status: "error", error: "Plannotator is not available." };
3504
+ return {
3505
+ status: "error",
3506
+ error: outcome === "timeout"
3507
+ ? "Plannotator did not respond within 30s (is the browser extension running?)."
3508
+ : "Plannotator is not available (no handler responded — is the browser extension installed?).",
3509
+ };
3140
3510
  }
3141
3511
 
3142
3512
  let result: { approved: boolean; feedback?: string; error?: string };
@@ -3154,175 +3524,139 @@ async function openCodeReviewInPlannotator(
3154
3524
  return { status: result.approved ? "approved" : "needs_changes", feedback };
3155
3525
  }
3156
3526
 
3157
- async function startReviewTask(
3158
- orchestrator: Orchestrator,
3159
- ctx: any,
3160
- userRequestContent: string,
3161
- researchContent: string | null,
3162
- description: string,
3163
- mode?: TaskMode,
3164
- autonomousConfig?: AutonomousConfig,
3165
- ): Promise<"started" | typeof BACK> {
3166
- await orchestrator.startTask(ctx, "review", description, undefined, undefined, mode);
3167
- if (!orchestrator.active || orchestrator.active.type !== "review") return BACK;
3168
- orchestrator.active.state.autonomousConfig = autonomousConfig;
3169
- saveTask(orchestrator.active.dir, orchestrator.active.state);
3170
- const repos = getRegisteredRepos(orchestrator);
3171
- const userRequestWithRepos = appendRepoContext(userRequestContent, repos);
3172
- writeFileSync(join(orchestrator.active.dir, "USER_REQUEST.md"), userRequestWithRepos, "utf-8");
3173
- if (researchContent) {
3174
- writeFileSync(join(orchestrator.active.dir, "RESEARCH.md"), researchContent, "utf-8");
3527
+ // Per-repo interleaved Plannotator loop (#3a). Runs from the persisted cursor: for
3528
+ // each repo, ask the diff scope, open Plannotator and WAIT (dialogue closed). On
3529
+ // NEEDS_CHANGES, persist the cursor advanced past this repo and return a work
3530
+ // instruction (mirrors the plan path: answer questions + apply changes) so the
3531
+ // agent fixes THIS repo before the next opens; the next /pp resumes the loop. On
3532
+ // approved, advance and continue in-loop. On error the repo is left UNREVIEWED:
3533
+ // the cursor stays put and the user chooses Retry / Skip / Done. When the cursor
3534
+ // is exhausted or the user stops, clear the cursor and return null (fall back to
3535
+ // 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;
3541
+
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
+ ]);
3555
+
3556
+ if (!diffChoice || diffChoice === "Done (stop reviewing)") {
3557
+ task.state.plannotatorCursor = undefined;
3558
+ saveTask(task.dir, task.state);
3559
+ return null;
3560
+ }
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
+ if (result.status === "error") {
3589
+ ctx.ui.notify(`${formatRepoLabel(repo)}: ERROR${result.error ? ` — ${result.error}` : ""}`, "warning");
3590
+ const errorChoice = await selectOption(ctx, `Review failed: ${formatRepoLabel(repo)}`, [
3591
+ 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"),
3594
+ ]);
3595
+ if (!errorChoice || errorChoice === "Done (stop reviewing)") {
3596
+ task.state.plannotatorCursor = undefined;
3597
+ saveTask(task.dir, task.state);
3598
+ return null;
3599
+ }
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
+ continue;
3606
+ }
3607
+
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;
3613
+
3614
+ if (result.status === "needs_changes") {
3615
+ if (exhausted) task.state.plannotatorCursor = undefined;
3616
+ setStep(orchestrator, "llm_work");
3617
+ 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}`,
3626
+ );
3627
+ }
3628
+
3629
+ ctx.ui.notify(`${formatRepoLabel(repo)}: APPROVED`, "info");
3630
+ saveTask(task.dir, task.state);
3175
3631
  }
3176
- return "started";
3632
+
3633
+ // Cursor exhausted with no outstanding changes: clear it and fall back to /pp.
3634
+ task.state.plannotatorCursor = undefined;
3635
+ saveTask(task.dir, task.state);
3636
+ return null;
3177
3637
  }
3178
3638
 
3179
3639
  async function showReviewMenu(orchestrator: Orchestrator, ctx: any): Promise<typeof BACK | "started"> {
3180
3640
  while (true) {
3181
- const options: OptionInput[] = [
3182
- { title: "Current branch", description: "Review changes on current branch vs base" },
3183
- { title: "Last commit", description: "Review changes in the most recent commit" },
3184
- { title: "Since commit", description: "Review all changes since a specific commit" },
3185
- { title: "Uncommitted changes", description: "Review working directory changes" },
3186
- { title: "Describe", description: "Describe what to review and let the agent figure it out" },
3641
+ const choice = await selectOption(ctx, "Review", [
3642
+ { 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" },
3187
3643
  { title: "Resume", description: "Resume a previously unfinished review" },
3188
3644
  { title: "Back", description: "Return to the previous menu" },
3189
- ];
3190
- const choice = await selectOption(ctx, "Review", options);
3645
+ ]);
3191
3646
  if (!choice || choice === "Back") return BACK;
3192
3647
 
3193
- if (choice === "Resume") {
3194
- const result = await showResumeMenu(orchestrator, ctx, "review", "No paused review tasks found.");
3195
- if (result === "started") return result;
3196
- continue;
3197
- }
3198
-
3199
- if (choice === "Current branch") {
3200
- const repos = getRegisteredRepos(orchestrator);
3201
- const repoRanges = await Promise.all(
3202
- repos.map(async (repo) => `- ${formatRepoLabel(repo)}: ${await detectDefaultBranch(orchestrator, repos, repo.path)}..HEAD`),
3203
- );
3204
- const prContexts = await detectCurrentPrContext(orchestrator, repos);
3205
-
3206
- const urLines = [
3207
- "# User Request",
3208
- "Review current branch changes across registered repositories.",
3209
- "",
3210
- "Diff ranges:",
3211
- ...repoRanges,
3212
- ];
3213
- const prUrls = prContexts
3214
- .filter((pr) => pr.prUrl)
3215
- .map((pr) => `- ${pr.repoPath}: ${pr.prUrl}`);
3216
- if (prUrls.length > 0) {
3217
- urLines.push("", "Open PRs:", ...prUrls);
3218
- }
3219
- urLines.push("", "## Problem", "Review and identify issues in the code changes.", "", "## Constraints", "Focus on correctness, edge cases, style, missing tests, potential bugs.");
3220
- const urContent = urLines.join("\n") + "\n";
3221
-
3222
- let resContent = [
3223
- "## Affected Code",
3224
- "(to be filled during review)",
3225
- "",
3226
- "## Architecture Context",
3227
- "(to be filled during review)",
3228
- "",
3229
- "## Constraints & Edge Cases",
3230
- "- MUST: Review all changed code across the registered repositories",
3231
- "- RISK: Issues can span repository boundaries",
3232
- ].join("\n") + "\n";
3233
- if (prContexts.length > 0) {
3234
- const prContextBlocks = prContexts
3235
- .map((pr) => {
3236
- const lines = [`${pr.repoPath}`];
3237
- if (pr.prUrl) lines.push(`URL: ${pr.prUrl}`);
3238
- if (pr.prContext) lines.push(pr.prContext);
3239
- return lines.join("\n");
3240
- })
3241
- .join("\n\n");
3242
- resContent = appendResearchOpenQuestions(resContent, `PR context:\n${prContextBlocks}`);
3243
- }
3244
-
3245
- const modeSelection = await pickModeForTaskStart(orchestrator, ctx, "review");
3246
- if (!modeSelection) continue;
3247
- const description = "review-current-branch";
3248
- return startReviewTask(orchestrator, ctx, urContent, resContent, description, modeSelection.mode, modeSelection.autonomousConfig);
3249
- }
3250
-
3251
- if (choice === "Last commit") {
3252
- const repos = getRegisteredRepos(orchestrator);
3253
- const urContent = [
3254
- "# User Request",
3255
- "Review last commit changes across registered repositories",
3256
- "",
3257
- "## Problem",
3258
- "Review and identify issues in the most recent commit.",
3259
- "",
3260
- "## Constraints",
3261
- "Focus on correctness, edge cases, style, missing tests, potential bugs.",
3262
- "",
3263
- ].join("\n");
3264
- const modeSelection = await pickModeForTaskStart(orchestrator, ctx, "review");
3265
- if (!modeSelection) continue;
3266
- return startReviewTask(orchestrator, ctx, urContent, null, "review-last-commit", modeSelection.mode, modeSelection.autonomousConfig);
3267
- }
3268
-
3269
- if (choice === "Since commit") {
3270
- const repos = getRegisteredRepos(orchestrator);
3271
- const repoOptions: OptionInput[] = repos.map((repo) => ({
3272
- title: formatRepoLabel(repo),
3273
- description: "Choose repository for commit range",
3274
- }));
3275
- repoOptions.push({ title: "Back", description: "Return to the previous menu" });
3276
- const repoChoice = await selectOption(ctx, "Select repository", repoOptions);
3277
- if (!repoChoice || repoChoice === "Back") continue;
3278
- const selectedRepo = repos.find((repo) => formatRepoLabel(repo) === repoChoice);
3279
- if (!selectedRepo) continue;
3280
- const pickedHash = await pickCommitForRepo(orchestrator, ctx, selectedRepo);
3281
- if (!pickedHash) continue;
3282
-
3283
- const urContent = [
3284
- "# User Request",
3285
- `Review changes in ${selectedRepo.path} since commit ${pickedHash}`,
3286
- "",
3287
- "## Problem",
3288
- `Review and identify issues in all changes in ${selectedRepo.path} since ${pickedHash}.`,
3289
- "",
3290
- "## Constraints",
3291
- "Focus on correctness, edge cases, style, missing tests, potential bugs.",
3292
- "",
3293
- ].join("\n");
3294
- const modeSelection = await pickModeForTaskStart(orchestrator, ctx, "review");
3295
- if (!modeSelection) continue;
3296
- return startReviewTask(orchestrator, ctx, urContent, null, "review-since-commit", modeSelection.mode, modeSelection.autonomousConfig);
3297
- }
3298
-
3299
- if (choice === "Uncommitted changes") {
3300
- const repos = getRegisteredRepos(orchestrator);
3301
- const urContent = [
3302
- "# User Request",
3303
- "Review uncommitted changes across registered repositories",
3304
- "",
3305
- "## Problem",
3306
- "Review and identify issues in uncommitted working directory changes.",
3307
- "",
3308
- "## Constraints",
3309
- "Focus on correctness, edge cases, style, missing tests, potential bugs.",
3310
- "",
3311
- ].join("\n");
3648
+ if (choice === "New") {
3312
3649
  const modeSelection = await pickModeForTaskStart(orchestrator, ctx, "review");
3313
3650
  if (!modeSelection) continue;
3314
- return startReviewTask(orchestrator, ctx, urContent, null, "review-uncommitted", modeSelection.mode, modeSelection.autonomousConfig);
3651
+ await orchestrator.startTask(ctx, "review", "review", undefined, undefined, modeSelection.mode);
3652
+ if (!orchestrator.active || orchestrator.active.type !== "review") return BACK;
3653
+ orchestrator.active.state.autonomousConfig = modeSelection.autonomousConfig;
3654
+ saveTask(orchestrator.active.dir, orchestrator.active.state);
3655
+ return "started";
3315
3656
  }
3316
3657
 
3317
- const input = await ctx.ui.input("Describe what to review");
3318
- if (input === undefined || input === null) continue;
3319
- const trimmed = String(input).trim();
3320
- const description = trimmed || "review";
3321
-
3322
- 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`;
3323
- const modeSelection = await pickModeForTaskStart(orchestrator, ctx, "review");
3324
- if (!modeSelection) continue;
3325
- return startReviewTask(orchestrator, ctx, urContent, null, description, modeSelection.mode, modeSelection.autonomousConfig);
3658
+ const result = await showResumeMenu(orchestrator, ctx, "review", "No paused review tasks found.");
3659
+ if (result === "started") return result;
3326
3660
  }
3327
3661
  }
3328
3662
 
@@ -3411,15 +3745,56 @@ async function showTaskMenu(orchestrator: Orchestrator, ctx: any): Promise<typeo
3411
3745
  }
3412
3746
  }
3413
3747
 
3748
+ // Minimal read-only path shown when loadConfig threw on session_start. It must NOT
3749
+ // register or trigger normal task execution against the invalid config — it only
3750
+ // helps the user locate and fix the broken config, then re-check it.
3751
+ async function showConfigErrorMenu(orchestrator: Orchestrator, ctx: any): Promise<string | undefined> {
3752
+ const projectConfigPath = join(orchestrator.cwd, ".pp", "config.json");
3753
+ while (true) {
3754
+ const choice = await selectOption(
3755
+ ctx,
3756
+ `/pp — config error\n\n${orchestrator.configError}\n\nProject config: ${projectConfigPath}\nGlobal config: ${GLOBAL_CONFIG_PATH}`,
3757
+ [
3758
+ { title: "Re-check config", description: "Reload config after fixing it" },
3759
+ { title: "Doctor", description: "Run diagnostic checks" },
3760
+ { title: "Back", description: "Close this menu" },
3761
+ ],
3762
+ );
3763
+ if (!choice || choice === "Back") return undefined;
3764
+
3765
+ if (choice === "Re-check config") {
3766
+ try {
3767
+ orchestrator.config = loadConfig(orchestrator.cwd);
3768
+ orchestrator.configError = null;
3769
+ // session_start skipped feature/tool/agent registration when config
3770
+ // loading failed; register them now that the config is valid so the
3771
+ // orchestration tools exist, not just the menu.
3772
+ registerFeatureToolsAndAgents(orchestrator);
3773
+ ctx.ui.notify("Config loaded successfully. Run /pp again to continue.", "info");
3774
+ return undefined;
3775
+ } catch (err: any) {
3776
+ orchestrator.configError = err.message;
3777
+ ctx.ui.notify(`Config error: ${err.message}`, "error");
3778
+ continue;
3779
+ }
3780
+ }
3781
+
3782
+ if (choice === "Doctor") {
3783
+ await runDoctor(orchestrator, ctx);
3784
+ continue;
3785
+ }
3786
+ }
3787
+ }
3788
+
3414
3789
  async function showNoActiveMenu(orchestrator: Orchestrator, ctx: any): Promise<string | undefined> {
3415
3790
  while (true) {
3416
3791
  const choice = await selectOption(ctx, "/pp", [
3417
3792
  { title: "Task", description: "Start a new task or resume a paused one" },
3418
- { title: "Info", description: "Subagents, usage, and task status" },
3419
- { title: "Settings", description: "Flant AI and other configuration" },
3420
- { title: "Back", description: "Close this menu" },
3793
+ { title: "Subagents", description: "View and manage running subagents" },
3794
+ { title: "Settings", description: "Models, agents, commands, and other configuration" },
3795
+ { title: "Back to prompt", description: "Close this menu" },
3421
3796
  ]);
3422
- if (!choice || choice === "Back") return undefined;
3797
+ if (!choice || choice === "Back to prompt") return undefined;
3423
3798
 
3424
3799
  if (choice === "Task") {
3425
3800
  const result = await showTaskMenu(orchestrator, ctx);
@@ -3427,8 +3802,8 @@ async function showNoActiveMenu(orchestrator: Orchestrator, ctx: any): Promise<s
3427
3802
  continue;
3428
3803
  }
3429
3804
 
3430
- if (choice === "Info") {
3431
- await showInfoMenu(orchestrator, ctx);
3805
+ if (choice === "Subagents") {
3806
+ await showSubagentsMenu(ctx);
3432
3807
  continue;
3433
3808
  }
3434
3809
 
@@ -3476,16 +3851,16 @@ async function showQuickTaskMenu(
3476
3851
  const { choice, cancelReason } = await selectOptionCancelable(ctx, menuTitle, [
3477
3852
  opt("Complete", "Mark task as done and clean up"),
3478
3853
  opt("Pause", "Suspend task to resume later"),
3479
- opt("Info", "Subagents, usage, and task status"),
3480
- opt("Settings", "Flant AI and other configuration"),
3481
- opt("Back", "Return to the prompt and keep working"),
3854
+ opt("Subagents", "View and manage running subagents"),
3855
+ opt("Settings", "Models, agents, commands, and other configuration"),
3856
+ opt("Back to prompt", "Return to the prompt and keep working"),
3482
3857
  ]);
3483
3858
  // A deliberate ESC in tool mode must stop the turn cleanly (mirror the
3484
3859
  // guided/autonomous branches); in command mode ESC just closes the menu.
3485
3860
  if (cancelReason === "user" && mode === "tool") return USER_CANCELLED;
3486
- if (!choice || choice === "Back") return "";
3487
- if (choice === "Info") {
3488
- await showInfoMenu(orchestrator, ctx);
3861
+ if (!choice || choice === "Back to prompt") return "";
3862
+ if (choice === "Subagents") {
3863
+ await showSubagentsMenu(ctx);
3489
3864
  continue;
3490
3865
  }
3491
3866
  if (choice === "Settings") {
@@ -3506,6 +3881,11 @@ export async function showActiveTaskMenu(
3506
3881
  ctx: any,
3507
3882
  summary: string,
3508
3883
  mode: MenuMode = "command",
3884
+ // Display-only override for the autonomous terminal implement handoff (#1):
3885
+ // render the guided Next/Review menu even though the task is autonomous, WITHOUT
3886
+ // mutating task.state.mode. Never persisted; does not affect the footer indicator
3887
+ // or getEffectivePhaseMode.
3888
+ forceGuided = false,
3509
3889
  ): Promise<string> {
3510
3890
  const continueMessage = advanceBanner("[PI-PI] User wants to continue. Run /pp when ready to advance.");
3511
3891
 
@@ -3516,6 +3896,15 @@ export async function showActiveTaskMenu(
3516
3896
  if (task.type === "quick") {
3517
3897
  return showQuickTaskMenu(orchestrator, ctx, summary, mode);
3518
3898
  }
3899
+ // Auto-resume an in-progress per-repo Plannotator review (#3a): if the agent
3900
+ // just finished fixing one repo's feedback and ran /pp, continue the loop at
3901
+ // the next repo instead of showing the top-level menu. On another
3902
+ // needs_changes this returns a fresh work instruction; when exhausted it
3903
+ // clears the cursor and falls through to the normal menu.
3904
+ if (task.state.plannotatorCursor) {
3905
+ const resumeText = await runPlannotatorCursor(orchestrator, ctx);
3906
+ if (resumeText) return resumeText;
3907
+ }
3519
3908
  const phase = task.state.phase;
3520
3909
  const step = task.state.step;
3521
3910
  const effectiveMode = getEffectivePhaseMode(task.state);
@@ -3527,24 +3916,35 @@ export async function showActiveTaskMenu(
3527
3916
  const { autoLabel } = getReviewLabels(orchestrator);
3528
3917
  const isReviewPhase = phase === "review";
3529
3918
  const hasPlannotator = phase === "plan" || phase === "implement" || isReviewPhase;
3919
+ // The artifact the automated reviewers scan, mirroring reviewPresetGroupForPhase:
3920
+ // brainstorm/debug → brainstormReviewers (research artifacts), plan → planReviewers
3921
+ // (synthesized plan), implement/review → codeReviewers (code changes). This is the primary
3922
+ // "Review" target named at the top level; the per-phase "Review on my own" description names
3923
+ // the manual editor pass's target separately (it can differ).
3924
+ const reviewGroup = reviewPresetGroupForPhase(phase);
3925
+ const reviewTarget = reviewGroup === "planReviewers"
3926
+ ? "the synthesized plan"
3927
+ : reviewGroup === "brainstormReviewers"
3928
+ ? "this phase's research artifacts"
3929
+ : "the code changes";
3530
3930
 
3531
3931
  const opt = (title: string, description: string): OptionInput => ({ title, description });
3532
3932
 
3533
- if (effectiveMode === "autonomous") {
3933
+ if (effectiveMode === "autonomous" && !forceGuided) {
3534
3934
  const { choice: autoChoice, cancelReason } = await selectOptionCancelable(ctx, `/pp\n\nTask: ${task.type}\nPhase: ${phase}${summary !== "/pp" ? `\n\n${summary}` : ""}`, [
3535
3935
  opt("Complete task", "Mark task as done and clean up"),
3536
3936
  opt("Pause task", "Suspend task to resume later"),
3537
- opt("Info", "Subagents, usage, and task status"),
3538
- opt("Settings", "Flant AI and other configuration"),
3539
- opt("Back", "Return to the prompt and keep working"),
3937
+ opt("Subagents", "View and manage running subagents"),
3938
+ opt("Settings", "Models, agents, commands, and other configuration"),
3939
+ opt("Back to prompt", "Return to the prompt and keep working"),
3540
3940
  ]);
3541
3941
  // A deliberate ESC only needs distinct handling in tool mode (so
3542
3942
  // pp_phase_complete can stop the turn); in command mode ESC just closes
3543
3943
  // the menu like "Back".
3544
3944
  if (cancelReason === "user" && mode === "tool") return USER_CANCELLED;
3545
- if (!autoChoice || autoChoice === "Back") return "";
3546
- if (autoChoice === "Info") {
3547
- await showInfoMenu(orchestrator, ctx);
3945
+ if (!autoChoice || autoChoice === "Back to prompt") return "";
3946
+ if (autoChoice === "Subagents") {
3947
+ await showSubagentsMenu(ctx);
3548
3948
  continue;
3549
3949
  }
3550
3950
  if (autoChoice === "Settings") {
@@ -3562,23 +3962,23 @@ export async function showActiveTaskMenu(
3562
3962
  const options: OptionInput[] = [];
3563
3963
  options.push(opt("Next", "Complete, pause, or continue to next phase"));
3564
3964
  if (!waiting) {
3565
- options.push(opt("Review", "Auto review, Plannotator, or manual review"));
3965
+ options.push(opt("Review", `Review ${reviewTarget}: automated reviewers${hasPlannotator ? ", Plannotator, or" : " or"} your own editor pass`));
3566
3966
  }
3567
- options.push(opt("Info", "Subagents, usage, and task status"));
3568
- options.push(opt("Settings", "Flant AI and other configuration"));
3569
- options.push(opt("Back", "Return to the prompt and keep working"));
3967
+ options.push(opt("Subagents", "View and manage running subagents"));
3968
+ options.push(opt("Settings", "Models, agents, commands, and other configuration"));
3969
+ options.push(opt("Back to prompt", "Return to the prompt and keep working"));
3570
3970
 
3571
3971
  const headerLines = [`/pp\n\nTask: ${task.type}\nPhase: ${phase}`];
3572
3972
  if (summary !== "/pp") headerLines.push(`\n\n${summary}`);
3573
3973
  const menuTitle = headerLines.join("");
3574
3974
  const { choice, cancelReason } = await selectOptionCancelable(ctx, menuTitle, options);
3575
3975
  if (cancelReason === "user" && mode === "tool") return USER_CANCELLED;
3576
- if (!choice || choice === "Back") {
3976
+ if (!choice || choice === "Back to prompt") {
3577
3977
  return "";
3578
3978
  }
3579
3979
 
3580
- if (choice === "Info") {
3581
- await showInfoMenu(orchestrator, ctx);
3980
+ if (choice === "Subagents") {
3981
+ await showSubagentsMenu(ctx);
3582
3982
  continue;
3583
3983
  }
3584
3984
  if (choice === "Settings") {
@@ -3590,72 +3990,197 @@ export async function showActiveTaskMenu(
3590
3990
  }
3591
3991
 
3592
3992
  if (choice === "Next") {
3593
- const canContinue = phase !== "implement" && !waiting;
3594
- const continueLabel = phase === "plan" ? "Continue to implement" : "Continue to plan & implement";
3595
- const finishOptions: OptionInput[] = [];
3596
- if (canContinue) {
3597
- finishOptions.push(opt(continueLabel, "Approve and advance to the next phase"));
3598
- }
3599
- finishOptions.push(opt("Complete", "Mark task as done and clean up"));
3600
- finishOptions.push(opt("Pause", "Suspend task to resume later"));
3601
- finishOptions.push(opt("Back", "Return to the previous menu"));
3602
-
3603
- const finishChoice = await selectOption(ctx, "Next", finishOptions);
3604
- if (!finishChoice || finishChoice === "Back") continue;
3605
- if (finishChoice === "Pause") {
3606
- const text = await pauseTask(orchestrator, ctx);
3607
- return mode === "tool" ? text : "";
3608
- }
3609
- if (finishChoice === "Complete") {
3610
- const text = await finishTask(orchestrator, ctx);
3611
- return mode === "tool" ? text : "";
3612
- }
3613
- const next = nextPhase(task.type, phase);
3614
-
3615
- if (
3616
- next === "plan" &&
3617
- task.type === "brainstorm"
3618
- ) {
3619
- const modeSelection = await pickModeForTaskStart(orchestrator, ctx, "implement");
3620
- if (!modeSelection) continue;
3621
- task.state.mode = modeSelection.mode;
3622
- task.state.effectiveMode = undefined;
3623
- task.state.autonomousConfig = modeSelection.autonomousConfig;
3624
- saveTask(task.dir, task.state);
3625
- }
3993
+ // The Next submenu runs its own loop so that a deeper submenu's "Back"
3994
+ // (e.g. Publish) returns here rather than overshooting to the top-level menu.
3995
+ while (true) {
3996
+ const canContinue = phase !== "implement" && !waiting;
3997
+ const continueLabel = phase === "plan" ? "Continue to implement" : "Continue to plan & implement";
3998
+ const finishOptions: OptionInput[] = [];
3999
+ if (phase === "review") {
4000
+ finishOptions.push(opt("Publish", "Publish the synthesized review findings as file comments or GitHub PR comments"));
4001
+ }
4002
+ const nextPhaseName = phase === "plan" ? "implement" : "plan & implement";
4003
+ const autoThenContinueLabel = `Auto review, then continue to ${nextPhaseName}`;
4004
+ if (canContinue) {
4005
+ finishOptions.push(opt(continueLabel, "Approve and advance to the next phase"));
4006
+ finishOptions.push(opt(autoThenContinueLabel, "Loop reviewers over this phase until they approve (up to N passes), then advance"));
4007
+ }
4008
+ finishOptions.push(opt("Complete", "Mark task as done and clean up"));
4009
+ finishOptions.push(opt("Pause", "Suspend task to resume later"));
4010
+ finishOptions.push(opt("Back", "Return to the previous menu"));
4011
+
4012
+ const finishChoice = await selectOption(ctx, "Next", finishOptions);
4013
+ if (!finishChoice || finishChoice === "Back") break;
4014
+ if (finishChoice === autoThenContinueLabel) {
4015
+ const next = nextPhase(task.type, phase);
4016
+ // Collect the SAME advance inputs the plain-continue path would, NOW,
4017
+ // and stash them on the flag — the later headless pp_phase_complete
4018
+ // branch that advances cannot prompt the user.
4019
+ const deferred: { mode?: TaskMode; autonomousConfig?: AutonomousConfig; plannerPreset?: string } = {};
4020
+ if (next === "plan" && task.type === "brainstorm") {
4021
+ const modeSelection = await pickModeForTaskStart(orchestrator, ctx, "implement");
4022
+ if (!modeSelection) continue;
4023
+ deferred.mode = modeSelection.mode;
4024
+ deferred.autonomousConfig = modeSelection.autonomousConfig;
4025
+ }
4026
+ if (next === "plan" && getEffectiveMode(task.state) !== "autonomous") {
4027
+ const pickedPlannerPreset = await pickPreset(ctx, orchestrator, "planners", "Planner preset");
4028
+ if (!pickedPlannerPreset) continue;
4029
+ deferred.plannerPreset = pickedPlannerPreset;
4030
+ }
4031
+ // Already approved clean this phase: skip review, advance straight through.
4032
+ if (task.state.reviewApprovedClean) {
4033
+ if (deferred.mode) task.state.mode = deferred.mode;
4034
+ if (deferred.autonomousConfig) task.state.autonomousConfig = deferred.autonomousConfig;
4035
+ task.state.effectiveMode = undefined;
4036
+ finalizeReviewCycle(task);
4037
+ const advResult = await orchestrator.transitionToNextPhase(ctx, deferred.plannerPreset);
4038
+ if (!advResult.ok) return `Transition blocked: ${advResult.error}`;
4039
+ return "";
4040
+ }
4041
+ const loopPreset = await pickPreset(ctx, orchestrator, getReviewPresetGroup(phase), "Review preset");
4042
+ if (!loopPreset) continue;
4043
+ if (!hasEnabledReviewers(orchestrator, loopPreset)) {
4044
+ const rg = reviewPresetGroupForPhase(phase);
4045
+ const label = rg === "brainstormReviewers" ? "artifact" : rg === "planReviewers" ? "plan" : "code";
4046
+ ctx.ui.notify(`No ${label} reviewers enabled.`, "info");
4047
+ continue;
4048
+ }
4049
+ const passes = await pickMaxReviewPasses(ctx, 3);
4050
+ if (passes === null) continue;
4051
+ finalizeReviewCycle(task);
4052
+ if (task.state.reviewPassByKind?.[phase]) task.state.reviewPassByKind[phase].auto = 0;
4053
+ task.state.reviewApprovedClean = false;
4054
+ task.state.manualAutoReview = { phase, preset: loopPreset, maxPasses: passes, advanceOnComplete: true, deferredAdvance: deferred };
4055
+ saveTask(task.dir, task.state);
4056
+ return advanceBanner(
4057
+ `[PI-PI] Auto-review-then-continue started over ${reviewTarget} (up to ${passes >= 999 ? "unlimited" : passes} passes). ` +
4058
+ "Call pp_phase_complete now to run the first review pass; after each pass, apply the reviewers' feedback and call pp_phase_complete again. " +
4059
+ "Do NOT pause to ask for design or user approval while this loop runs — it supersedes the pre-finalization approval rule. " +
4060
+ `When reviewers unanimously approve or the pass cap is reached, the task advances to ${nextPhaseName} automatically.`,
4061
+ );
4062
+ }
4063
+ if (finishChoice === "Publish") {
4064
+ const target = await selectOption(ctx, "Publish", [
4065
+ opt("As file comments", "Insert AI_COMMENT: markers at each finding's location in the source"),
4066
+ opt("As GitHub PR comments", "Post line-anchored comments to the branch's PR from your GitHub account"),
4067
+ opt("Back", "Return to the previous menu"),
4068
+ ]);
4069
+ if (!target || target === "Back") continue;
4070
+ const guardMessage = publishGuard(task.dir);
4071
+ if (guardMessage) {
4072
+ ctx.ui.notify(guardMessage, "info");
4073
+ continue;
4074
+ }
4075
+ return target === "As file comments"
4076
+ ? publishFileCommentsBanner(task.dir)
4077
+ : publishPrCommentsBanner(task.dir);
4078
+ }
4079
+ if (finishChoice === "Pause") {
4080
+ const text = await pauseTask(orchestrator, ctx);
4081
+ return mode === "tool" ? text : "";
4082
+ }
4083
+ if (finishChoice === "Complete") {
4084
+ const text = await finishTask(orchestrator, ctx);
4085
+ return mode === "tool" ? text : "";
4086
+ }
4087
+ const next = nextPhase(task.type, phase);
4088
+
4089
+ if (
4090
+ next === "plan" &&
4091
+ task.type === "brainstorm"
4092
+ ) {
4093
+ const modeSelection = await pickModeForTaskStart(orchestrator, ctx, "implement");
4094
+ if (!modeSelection) continue;
4095
+ task.state.mode = modeSelection.mode;
4096
+ task.state.effectiveMode = undefined;
4097
+ task.state.autonomousConfig = modeSelection.autonomousConfig;
4098
+ saveTask(task.dir, task.state);
4099
+ }
3626
4100
 
3627
- let plannerPreset: string | undefined;
3628
- if (next === "plan") {
3629
- if (getEffectiveMode(task.state) !== "autonomous") {
3630
- const pickedPlannerPreset = await pickPreset(ctx, orchestrator, "planners", "Planner preset");
3631
- if (!pickedPlannerPreset) continue;
3632
- plannerPreset = pickedPlannerPreset;
4101
+ let plannerPreset: string | undefined;
4102
+ if (next === "plan") {
4103
+ if (getEffectiveMode(task.state) !== "autonomous") {
4104
+ const pickedPlannerPreset = await pickPreset(ctx, orchestrator, "planners", "Planner preset");
4105
+ if (!pickedPlannerPreset) continue;
4106
+ plannerPreset = pickedPlannerPreset;
4107
+ }
3633
4108
  }
4109
+ finalizeReviewCycle(task);
4110
+ const result = await orchestrator.transitionToNextPhase(ctx, plannerPreset);
4111
+ if (!result.ok) return `Transition blocked: ${result.error}`;
4112
+ // Transition is now owned by the controller (state ≠ running) or we're
4113
+ // awaiting subagents; either way the menu returns empty.
4114
+ return "";
3634
4115
  }
3635
- finalizeReviewCycle(task);
3636
- const result = await orchestrator.transitionToNextPhase(ctx, plannerPreset);
3637
- if (!result.ok) return `Transition blocked: ${result.error}`;
3638
- // Transition is now owned by the controller (state ≠ running) or we're
3639
- // awaiting subagents; either way the menu returns empty.
3640
- return "";
4116
+ continue;
3641
4117
  }
3642
4118
 
3643
4119
  if (choice === "Review") {
4120
+ // The Review submenu runs its own loop so a recoverable choice (no reviewers
4121
+ // enabled, preset cancelled, "Review on my own" Back, a plannotator early
4122
+ // exit) returns HERE rather than overshooting to the top-level /pp menu.
4123
+ // Only an explicit "Back" breaks out; start/wait/instruction paths return.
4124
+ while (true) {
4125
+ // A review already running: block re-entry BEFORE any finalizeReviewCycle
4126
+ // (which would null the live cycle and hide it), so we never double-spawn.
4127
+ // Notify and break back to the top-level menu with the live cycle intact
4128
+ // rather than exiting /pp.
4129
+ if (isReviewCycleLive(task)) {
4130
+ ctx.ui.notify("A review is already running", "info");
4131
+ break;
4132
+ }
3644
4133
  const reviewOptions: OptionInput[] = [
3645
- opt(autoLabel, "Run automated review with configured reviewers"),
4134
+ opt(autoLabel, `Run configured reviewers over ${reviewTarget}`),
4135
+ opt("Auto review until approved", `Loop reviewers over ${reviewTarget} until they unanimously approve, up to N passes`),
3646
4136
  ];
4137
+ const hasArtifactPlannotator = phase === "brainstorm" || phase === "debug";
3647
4138
  if (hasPlannotator) {
3648
4139
  reviewOptions.push(opt("Review in Plannotator", phase === "plan" ? "Open plan review in browser" : "Open code diff review in browser"));
4140
+ } else if (hasArtifactPlannotator) {
4141
+ reviewOptions.push(opt("Review in Plannotator", "Open USER_REQUEST.md, RESEARCH.md, and artifacts/*.md for review in browser"));
3649
4142
  }
3650
4143
  reviewOptions.push(opt("Review on my own", phase === "implement"
3651
- ? "Review in your editor: mark spots with AI_REVIEW: comments, then have the agent address them"
3652
- : "Review manually, then continue"));
4144
+ ? "Mark spots in the changed files with AI_REVIEW: comments; the agent then addresses each and removes the marker"
4145
+ : phase === "plan"
4146
+ ? "Mark spots in the synthesized plan with AI_REVIEW: comments; the agent then addresses each and removes the marker"
4147
+ : "Mark spots in USER_REQUEST.md, RESEARCH.md, and artifacts/*.md with AI_REVIEW: comments; the agent then addresses each and removes the marker"));
3653
4148
  reviewOptions.push(opt("Back", "Return to the previous menu"));
3654
4149
 
3655
4150
  const reviewChoice = await selectOption(ctx, "Review", reviewOptions);
3656
- if (!reviewChoice || reviewChoice === "Back") continue;
4151
+ if (!reviewChoice || reviewChoice === "Back") break;
3657
4152
 
3658
4153
  if (reviewChoice === "Review in Plannotator") {
4154
+ if (hasArtifactPlannotator) {
4155
+ // brainstorm/debug have no diff or plan file: review the whole artifact
4156
+ // set in one annotate-folder pass over the task dir (walks USER_REQUEST.md +
4157
+ // RESEARCH.md + artifacts/*.md recursively). The result comes back on the
4158
+ // synchronous annotate respond callback, NOT via waitForPlannotatorResult.
4159
+ const { opened, result } = await openAnnotateReview(orchestrator.pi, {
4160
+ filePath: task.dir,
4161
+ folderPath: task.dir,
4162
+ mode: "annotate-folder",
4163
+ gate: true,
4164
+ });
4165
+ if (!opened || !result) {
4166
+ ctx.ui.notify("Could not open Plannotator for review.", "error");
4167
+ continue;
4168
+ }
4169
+ if (result.approved) {
4170
+ ctx.ui.notify("Plannotator review approved. Choose next action.", "info");
4171
+ continue;
4172
+ }
4173
+ const feedback = result.feedback?.trim();
4174
+ if (feedback) {
4175
+ setStep(orchestrator, "llm_work");
4176
+ return advanceBanner(
4177
+ "[PI-PI] The user reviewed this phase's artifacts in Plannotator and left the following feedback. " +
4178
+ "Address each point, updating USER_REQUEST.md, RESEARCH.md, and artifacts/*.md as needed:\n\n" +
4179
+ feedback,
4180
+ );
4181
+ }
4182
+ continue;
4183
+ }
3659
4184
  if (phase === "plan") {
3660
4185
  finalizeReviewCycle(task);
3661
4186
  const text = await enterReviewCycle(orchestrator, ctx, "plannotator");
@@ -3666,118 +4191,75 @@ export async function showActiveTaskMenu(
3666
4191
  return handled.text ?? text;
3667
4192
  }
3668
4193
  const allRepos = getRegisteredRepos(orchestrator);
3669
- const summaries: string[] = [];
3670
- const repos: RepoInfo[] = [];
4194
+ const eligible: string[] = [];
3671
4195
  for (const repo of allRepos) {
3672
4196
  const base = await detectDefaultBranch(orchestrator, allRepos, repo.path);
3673
4197
  const { changed, error } = await repoHasReviewableChanges(orchestrator, repo, base);
3674
- if (error) {
3675
- summaries.push(`${formatRepoLabel(repo)}: ERROR — ${error}`);
3676
- repos.push(repo);
3677
- } else if (changed) {
3678
- repos.push(repo);
3679
- }
4198
+ if (error || changed) eligible.push(repo.path);
3680
4199
  }
3681
- if (repos.length === 0) {
4200
+ if (eligible.length === 0) {
3682
4201
  ctx.ui.notify("No registered repositories have changes to review.", "info");
3683
4202
  continue;
3684
4203
  }
3685
- let stopReviewing = false;
3686
-
3687
- for (const repo of repos) {
3688
- if (stopReviewing) break;
3689
-
3690
- while (true) {
3691
- const diffChoice = await selectOption(ctx, `Review: ${formatRepoLabel(repo)}`, [
3692
- opt("All branch changes", "Committed changes vs base branch"),
3693
- opt("Last commit", "Changes in the most recent commit"),
3694
- opt("Since commit", "Review all changes since a specific commit"),
3695
- opt("Uncommitted changes", "Working directory changes"),
3696
- opt("Skip this repo", "Move to the next repository"),
3697
- opt("Done (stop reviewing)", "Stop iterating repositories"),
3698
- ]);
3699
-
3700
- if (!diffChoice || diffChoice === "Done (stop reviewing)") {
3701
- stopReviewing = true;
3702
- break;
3703
- }
3704
- if (diffChoice === "Skip this repo") {
3705
- summaries.push(`${formatRepoLabel(repo)}: SKIPPED`);
3706
- break;
3707
- }
3708
-
3709
- let diffType: string | undefined;
3710
- let defaultBranch: string | undefined;
3711
-
3712
- if (diffChoice === "All branch changes") {
3713
- diffType = "branch";
3714
- defaultBranch = await detectDefaultBranch(orchestrator, repos, repo.path);
3715
- } else if (diffChoice === "Last commit") {
3716
- diffType = "last-commit";
3717
- } else if (diffChoice === "Since commit") {
3718
- const pickedHash = await pickCommitForRepo(orchestrator, ctx, repo);
3719
- if (!pickedHash) continue;
3720
- diffType = "branch";
3721
- defaultBranch = pickedHash;
3722
- } else {
3723
- diffType = "uncommitted";
3724
- }
3725
-
3726
- const result = await openCodeReviewInPlannotator(orchestrator, {
3727
- cwd: repo.path,
3728
- diffType,
3729
- defaultBranch,
3730
- });
3731
- if (result.status === "error") {
3732
- summaries.push(`${formatRepoLabel(repo)}: ERROR${result.error ? ` — ${result.error}` : ""}`);
3733
- } else if (result.status === "approved") {
3734
- summaries.push(`${formatRepoLabel(repo)}: APPROVED`);
3735
- } else {
3736
- summaries.push(`${formatRepoLabel(repo)}: NEEDS_CHANGES${result.feedback ? `\nFeedback: ${result.feedback}` : ""}`);
3737
- }
3738
- break;
3739
- }
3740
- }
3741
-
3742
- if (summaries.length > 0) {
3743
- ctx.ui.notify(`Plannotator review summary:\n\n${summaries.join("\n\n")}`, "info");
3744
- } else {
3745
- ctx.ui.notify("No repositories were reviewed in Plannotator.", "info");
3746
- }
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.
4208
+ task.state.plannotatorCursor = { repoPaths: eligible, index: 0 };
4209
+ saveTask(task.dir, task.state);
4210
+ const cursorText = await runPlannotatorCursor(orchestrator, ctx);
4211
+ if (cursorText) return cursorText;
3747
4212
  continue;
3748
4213
  }
3749
4214
 
3750
4215
  if (reviewChoice === "Review on my own") {
3751
- if (phase === "plan") {
3752
- setStep(orchestrator, "synthesize");
3753
- return continueMessage;
3754
- }
3755
- if (phase !== "implement") {
3756
- setStep(orchestrator, "llm_work");
3757
- return continueMessage;
3758
- }
3759
-
3760
4216
  const gate = await selectOption(ctx, "Editor review", [
3761
4217
  opt("Done", "I've added AI_REVIEW: markers and saved my files"),
3762
4218
  opt("Skip markers", "Continue without the marker workflow"),
3763
4219
  opt("Back", "Return to the review menu"),
3764
4220
  ]);
3765
4221
  if (!gate || gate === "Back") continue;
3766
- setStep(orchestrator, "llm_work");
4222
+ setStep(orchestrator, phase === "plan" ? "synthesize" : "llm_work");
3767
4223
  if (gate === "Skip markers") {
3768
4224
  return continueMessage;
3769
4225
  }
4226
+ if (phase === "implement") {
4227
+ return advanceBanner(
4228
+ "[PI-PI] The user reviewed the changes in their editor and left inline `AI_REVIEW:` markers " +
4229
+ `${AI_REVIEW_MARKER_SYNTAX}.\n\n` +
4230
+ "For each registered repo, enumerate the CHANGED files only — union of `git diff --name-only <base>...HEAD`, " +
4231
+ "`git diff --name-only`, `git diff --cached --name-only`, and untracked-non-ignored files from `git status --porcelain` " +
4232
+ "(base = each repo's configured base branch) — and search WITHIN those files for `AI_REVIEW:`. Do NOT grep the whole " +
4233
+ "worktree (avoid vendored/generated/node_modules and historical markers).\n\n" +
4234
+ AI_REVIEW_MARKER_LOOP,
4235
+ );
4236
+ }
4237
+ return readOnlyReviewBanner(phase, task.dir);
4238
+ }
4239
+
4240
+ if (reviewChoice === "Auto review until approved") {
4241
+ const loopPreset = await pickPreset(ctx, orchestrator, getReviewPresetGroup(phase), "Review preset");
4242
+ if (!loopPreset) continue;
4243
+ if (!hasEnabledReviewers(orchestrator, loopPreset)) {
4244
+ const rg = reviewPresetGroupForPhase(phase);
4245
+ const label = rg === "brainstormReviewers" ? "artifact" : rg === "planReviewers" ? "plan" : "code";
4246
+ ctx.ui.notify(`No ${label} reviewers enabled.`, "info");
4247
+ continue;
4248
+ }
4249
+ const passes = await pickMaxReviewPasses(ctx, 3);
4250
+ if (passes === null) continue;
4251
+ finalizeReviewCycle(task);
4252
+ // Item 5: stop-in-phase loop. Reset the per-phase auto-pass counter so the
4253
+ // cap counts THIS run's passes, and clear any stale clean flag.
4254
+ if (task.state.reviewPassByKind?.[phase]) task.state.reviewPassByKind[phase].auto = 0;
4255
+ task.state.reviewApprovedClean = false;
4256
+ task.state.manualAutoReview = { phase, preset: loopPreset, maxPasses: passes, advanceOnComplete: false };
4257
+ saveTask(task.dir, task.state);
3770
4258
  return advanceBanner(
3771
- "[PI-PI] The user reviewed the changes in their editor and left inline `AI_REVIEW:` markers " +
3772
- "(inside each file's native comment syntax, e.g. `// AI_REVIEW: ...`, `# AI_REVIEW: ...`, " +
3773
- "`<!-- AI_REVIEW: ... -->`).\n\n" +
3774
- "For each registered repo, enumerate the CHANGED files only union of `git diff --name-only <base>...HEAD`, " +
3775
- "`git diff --name-only`, `git diff --cached --name-only`, and untracked-non-ignored files from `git status --porcelain` " +
3776
- "(base = each repo's configured base branch) — and search WITHIN those files for `AI_REVIEW:`. Do NOT grep the whole " +
3777
- "worktree (avoid vendored/generated/node_modules and historical markers).\n\n" +
3778
- "For each marker: address the request, then remove that marker in the SAME edit. After a pass, re-scan the changed " +
3779
- "files and repeat until no `AI_REVIEW:` markers remain. Then verify your work and report what you changed per marker. " +
3780
- "When complete, call pp_phase_complete.",
4259
+ `[PI-PI] Auto-review-until-approved started over ${reviewTarget} (up to ${passes >= 999 ? "unlimited" : passes} passes). ` +
4260
+ "Call pp_phase_complete now to run the first review pass; after each pass, apply the reviewers' feedback and call pp_phase_complete again. " +
4261
+ "Do NOT pause to ask for design or user approval while this loop runs — it supersedes the pre-finalization approval rule. " +
4262
+ "The loop stops when reviewers unanimously approve or the pass cap is reached. Do NOT advance the phase yourself.",
3781
4263
  );
3782
4264
  }
3783
4265
 
@@ -3785,7 +4267,8 @@ export async function showActiveTaskMenu(
3785
4267
  if (!reviewPreset) continue;
3786
4268
  finalizeReviewCycle(task);
3787
4269
  if (!hasEnabledReviewers(orchestrator, reviewPreset)) {
3788
- const label = phase === "brainstorm" ? "brainstorm" : phase === "plan" ? "plan" : "code";
4270
+ const reviewGroup = reviewPresetGroupForPhase(phase);
4271
+ const label = reviewGroup === "brainstormReviewers" ? "artifact" : reviewGroup === "planReviewers" ? "plan" : "code";
3789
4272
  ctx.ui.notify(`No ${label} reviewers enabled.`, "info");
3790
4273
  continue;
3791
4274
  }
@@ -3795,11 +4278,15 @@ export async function showActiveTaskMenu(
3795
4278
  const handled = handleReviewResult(ctx, text);
3796
4279
  if (handled.continueLoop) continue;
3797
4280
  return handled.text ?? text;
4281
+ }
3798
4282
  }
3799
4283
  }
3800
4284
  }
3801
4285
 
3802
4286
  export async function showPpMenu(orchestrator: Orchestrator, ctx: any, mode: MenuMode = "command"): Promise<string | undefined> {
4287
+ if (orchestrator.configError) {
4288
+ return showConfigErrorMenu(orchestrator, ctx);
4289
+ }
3803
4290
  if (!orchestrator.active) {
3804
4291
  return showNoActiveMenu(orchestrator, ctx);
3805
4292
  }