@ilya-lesikov/pi-pi 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/extensions/orchestrator/agents/code-reviewer.ts +21 -5
  2. package/extensions/orchestrator/agents/constraints.test.ts +39 -1
  3. package/extensions/orchestrator/agents/constraints.ts +63 -2
  4. package/extensions/orchestrator/agents/tool-routing.ts +30 -14
  5. package/extensions/orchestrator/ai-comment-cleanup.test.ts +89 -0
  6. package/extensions/orchestrator/ai-comment-cleanup.ts +73 -0
  7. package/extensions/orchestrator/command-handlers.test.ts +47 -0
  8. package/extensions/orchestrator/command-handlers.ts +43 -27
  9. package/extensions/orchestrator/config.test.ts +40 -1
  10. package/extensions/orchestrator/config.ts +13 -0
  11. package/extensions/orchestrator/context.ts +16 -0
  12. package/extensions/orchestrator/custom-footer.test.ts +91 -0
  13. package/extensions/orchestrator/custom-footer.ts +20 -20
  14. package/extensions/orchestrator/event-handlers.test.ts +315 -1
  15. package/extensions/orchestrator/event-handlers.ts +397 -201
  16. package/extensions/orchestrator/integration.test.ts +305 -9
  17. package/extensions/orchestrator/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  18. package/extensions/orchestrator/orchestrator.ts +46 -58
  19. package/extensions/orchestrator/phases/brainstorm.ts +11 -7
  20. package/extensions/orchestrator/phases/machine.test.ts +36 -0
  21. package/extensions/orchestrator/phases/machine.ts +11 -1
  22. package/extensions/orchestrator/phases/review-task.test.ts +20 -0
  23. package/extensions/orchestrator/phases/review-task.ts +44 -16
  24. package/extensions/orchestrator/phases/review.test.ts +26 -0
  25. package/extensions/orchestrator/phases/review.ts +40 -3
  26. package/extensions/orchestrator/pp-menu.test.ts +207 -1
  27. package/extensions/orchestrator/pp-menu.ts +376 -378
  28. package/extensions/orchestrator/state.test.ts +9 -0
  29. package/extensions/orchestrator/state.ts +15 -0
  30. package/extensions/orchestrator/transition-controller.test.ts +100 -0
  31. package/extensions/orchestrator/transition-controller.ts +61 -3
  32. package/package.json +1 -1
  33. package/AGENTS.md +0 -28
@@ -1,4 +1,4 @@
1
- import { existsSync, readdirSync, writeFileSync } from "fs";
1
+ import { existsSync, readdirSync, readFileSync } from "fs";
2
2
  import { join, relative, basename } from "path";
3
3
  import { isDeepStrictEqual } from "util";
4
4
  import { askUser, isCancel, type CancelReason } from "../../3p/pi-ask-user/index.js";
@@ -24,8 +24,9 @@ import {
24
24
  loadBrainstormReviewOutputs,
25
25
  loadCodeReviewOutputs,
26
26
  loadPlanReviewOutputs,
27
+ hasFinalPassAnchors,
27
28
  } from "./context.js";
28
- import { detectDefaultBranch, enterReviewCycle, finalizeReviewCycle } from "./event-handlers.js";
29
+ import { detectDefaultBranch, enterReviewCycle, finalizeReviewCycle, isReviewCycleLive } from "./event-handlers.js";
29
30
  import { Orchestrator } from "./orchestrator.js";
30
31
  import { cancelPendingPlannotatorWait, openPlannotator, waitForPlannotatorResult } from "./plannotator.js";
31
32
  import { advanceBanner } from "./messages.js";
@@ -85,6 +86,7 @@ type TimeoutKey =
85
86
  | "performance.commands.afterEdit"
86
87
  | "performance.commands.afterImplement"
87
88
  | "performance.internals.subagentStale"
89
+ | "performance.internals.mainTurnStale"
88
90
  | "performance.internals.taskLockStale"
89
91
  | "performance.internals.taskLockRefresh";
90
92
 
@@ -105,16 +107,22 @@ async function selectOptionCancelable(
105
107
  question: string,
106
108
  options: OptionInput[],
107
109
  ): 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] };
110
+ const orchestrator = Orchestrator.current;
111
+ if (orchestrator) orchestrator.interactivePromptOpen = true;
112
+ try {
113
+ const result = await askUser(ctx, {
114
+ question,
115
+ options,
116
+ allowFreeform: false,
117
+ allowComment: false,
118
+ allowMultiple: false,
119
+ });
120
+ if (result && isCancel(result)) return { cancelReason: result.reason };
121
+ if (!result || result.kind !== "selection") return {};
122
+ return { choice: result.selections[0] };
123
+ } finally {
124
+ if (orchestrator) orchestrator.interactivePromptOpen = false;
125
+ }
118
126
  }
119
127
 
120
128
  function opt(title: string, description: string): OptionInput {
@@ -151,51 +159,7 @@ function formatRepoList(repos: RepoInfo[]): string {
151
159
  .join("\n");
152
160
  }
153
161
 
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
162
 
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
163
 
200
164
  async function pickCommitForRepo(orchestrator: Orchestrator, ctx: any, repo: RepoInfo): Promise<string | null> {
201
165
  let commits: Array<{ hash: string; message: string; age: string }> = [];
@@ -276,6 +240,84 @@ function setStep(orchestrator: Orchestrator, step: string): void {
276
240
  saveTask(orchestrator.active.dir, orchestrator.active.state);
277
241
  }
278
242
 
243
+ // Publish the latest synthesized review findings to a target. The agent (not the
244
+ // extension) performs the writes: file comments = insert `AI_COMMENT:` markers at
245
+ // each finding's line; PR comments = run `gh` to post line-anchored comments from
246
+ // the user's own account. Both are idempotent — the agent checks for markers/
247
+ // comments it already published and skips duplicates, so re-publishing is safe.
248
+ const PRIVACY_INSTRUCTION =
249
+ "PRIVACY: comment bodies MUST be self-contained observations about the code itself. Do NOT reference " +
250
+ "private or internal details, \"the ticket\", issue trackers, or internal design docs. Say what is wrong " +
251
+ "in the code, not that it \"violates the design goal\" of some private document.";
252
+
253
+ export function publishFileCommentsBanner(taskDir: string): string {
254
+ const reviewsDir = join(taskDir, "code-reviews");
255
+ return advanceBanner(
256
+ "[PI-PI] Publish the synthesized review findings as FILE COMMENTS now.\n\n" +
257
+ `Use the \`ANCHORS:\` block in the latest \`${reviewsDir}/*_final_pass-*.md\` as the file:line source — do NOT invent locations.\n\n` +
258
+ "For each accepted finding, insert an `AI_COMMENT:` marker (inside each file's native comment syntax, e.g. `// AI_COMMENT: ...`, `# AI_COMMENT: ...`, `<!-- AI_COMMENT: ... -->`) on or immediately above the cited line, briefly stating the finding. " +
259
+ "This is the ONLY source edit permitted — no fixes, no other changes.\n\n" +
260
+ PRIVACY_INSTRUCTION + "\n\n" +
261
+ "IDEMPOTENT: before inserting, check whether an equivalent `AI_COMMENT:` marker for that finding is already present at that location (e.g. from an earlier publish). If so, skip it — do NOT add a duplicate.\n\n" +
262
+ "When done, report how many markers you inserted and how many you skipped as already-present, then end your turn.",
263
+ );
264
+ }
265
+
266
+ export function publishPrCommentsBanner(taskDir: string): string {
267
+ const reviewsDir = join(taskDir, "code-reviews");
268
+ return advanceBanner(
269
+ "[PI-PI] Publish the synthesized review findings as GITHUB PR COMMENTS now, from the user's own `gh`-authenticated account.\n\n" +
270
+ `Use the \`ANCHORS:\` block in the latest \`${reviewsDir}/*_final_pass-*.md\` as the file:line source — do NOT invent locations.\n\n` +
271
+ "For each registered repo, resolve the branch's PR with `gh pr view --json number,headRefName,headRefOid,url` (skip the repo if there is no PR or `gh auth status` fails). Derive owner/repo from the PR `url` (the base repo).\n\n" +
272
+ "PRE-VALIDATE each anchor against the PR diff BEFORE building the review: fetch the diff (e.g. `gh pr diff <number>`) and keep, in a `comments` array, ONLY findings whose path+line are part of that diff. A single GitHub review is all-or-nothing — one invalid line comment rejects the WHOLE review — so an unvalidated anchor must NEVER go into `comments`.\n\n" +
273
+ "Post ONE bundled review per repo via `gh api --method POST repos/<owner>/<repo>/pulls/<number>/reviews` with `commit_id=<headRefOid>`, `event=COMMENT` (neutral — never APPROVE or REQUEST_CHANGES), a summary `body`, and a `comments` array of `{path, line, side: RIGHT, body}` for the validated findings.\n\n" +
274
+ "NON-DIFF findings (anchors not in the diff) are NEVER dropped: list them in the review `body` under a `Findings not anchorable to the diff:` heading, one per line ending with the exact ` (generated by pi-pi)` footer, using `file:—` (never an invented line). Do NOT rely on GitHub rejecting them.\n\n" +
275
+ "The LAST line of every finding's comment body MUST be exactly:\n(generated by pi-pi)\n\n" +
276
+ PRIVACY_INSTRUCTION + "\n\n" +
277
+ "IDEMPOTENT (line comments): first list existing PR comments (`gh api repos/<owner>/<repo>/pulls/<number>/comments`, which also returns comments from earlier per-comment publishes) and do NOT re-post a finding whose path, line, `(generated by pi-pi)` footer, AND body text all match one already present. Two distinct findings on the same line are NOT duplicates. Skip only true duplicates.\n\n" +
278
+ "IDEMPOTENT (body findings): also list existing reviews (`gh api repos/<owner>/<repo>/pulls/<number>/reviews`) and read their `body`. A GitHub review body cannot be deduped per-line, so before creating a new review, drop any non-diff finding whose `<file:—> — <text> (generated by pi-pi)` body line already appears in a prior pi-pi review body. If, after that, there are NO new line comments AND NO new body findings for a repo, do NOT create an empty duplicate review — report it as fully-published instead.\n\n" +
279
+ "When done, report per repo how many comments you posted, how many you skipped as duplicates, and how many were unanchorable (in the body), then end your turn.",
280
+ );
281
+ }
282
+
283
+ // Guard for the Publish menu: both banners consume the `ANCHORS:` block from the
284
+ // latest `code-reviews/*_final_pass-*.md`. If no such file exists, or the newest one
285
+ // carries no `ANCHORS:` block, publishing would spawn an agent that immediately fails.
286
+ // Returns a user-facing message to show instead, or undefined when publishing can proceed.
287
+ export const MISSING_FINAL_PASS_ANCHORS =
288
+ "No ANCHORS-bearing final review file exists yet. Run or finish a review pass first " +
289
+ "(/pp → Review) so the findings are synthesized into `code-reviews/*_final_pass-*.md`.";
290
+
291
+ export function publishGuard(taskDir: string): string | undefined {
292
+ return hasFinalPassAnchors(taskDir) ? undefined : MISSING_FINAL_PASS_ANCHORS;
293
+ }
294
+
295
+ const AI_REVIEW_MARKER_SYNTAX =
296
+ "(inside each file's native comment syntax, e.g. `// AI_REVIEW: ...`, `# AI_REVIEW: ...`, `<!-- AI_REVIEW: ... -->`)";
297
+
298
+ const AI_REVIEW_MARKER_LOOP =
299
+ "For each marker: address the request, then remove that marker in the SAME edit. After a pass, re-scan the target files and " +
300
+ "repeat until no `AI_REVIEW:` markers remain. Then verify your work and report what you changed per marker. When complete, " +
301
+ "call pp_phase_complete.";
302
+
303
+ // Read-only phases (brainstorm/debug/review) and plan produce markdown state files rather than
304
+ // source changes, so their AI_REVIEW scan targets those artifacts. Missing targets are skipped,
305
+ // not errors. Kept as literal-token guidance (no regexp/parser), mirroring the implement banner.
306
+ function readOnlyReviewBanner(phase: string, taskDir: string): string {
307
+ const targets =
308
+ phase === "plan"
309
+ ? "the synthesized plan(s) at `" + taskDir + "/plans/*_synthesized.md` ONLY (ignore raw planner outputs, " +
310
+ "`review_*` files, and the `plan-reviews/`, `brainstorm-reviews/`, `code-reviews/` directories)"
311
+ : "`" + taskDir + "/USER_REQUEST.md`, `" + taskDir + "/RESEARCH.md`, and `" + taskDir + "/artifacts/*.md`";
312
+ return advanceBanner(
313
+ `[PI-PI] The user reviewed this phase's state files in their editor and left inline \`AI_REVIEW:\` markers ` +
314
+ `${AI_REVIEW_MARKER_SYNTAX}.\n\n` +
315
+ `Search WITHIN ${targets} for \`AI_REVIEW:\`. Only scan those files — skip any target that does not exist ` +
316
+ `(a missing \`artifacts/*.md\` or a not-yet-produced state file is fine, not an error).\n\n` +
317
+ AI_REVIEW_MARKER_LOOP,
318
+ );
319
+ }
320
+
279
321
  function showStatus(orchestrator: Orchestrator, ctx: any): void {
280
322
  if (!orchestrator.active) {
281
323
  ctx.ui.notify("No active task.", "info");
@@ -316,6 +358,9 @@ async function pauseTask(orchestrator: Orchestrator, ctx: any): Promise<string>
316
358
  const type = orchestrator.active.type;
317
359
 
318
360
  orchestrator.active.state.reviewCycle = null;
361
+ // Drop any in-progress per-repo Plannotator cursor so a resumed task does not
362
+ // auto-reopen Plannotator; the user re-enters Review explicitly.
363
+ orchestrator.active.state.plannotatorCursor = undefined;
319
364
  saveTask(orchestrator.active.dir, orchestrator.active.state);
320
365
  unregisterAgentDefinitions(orchestrator.pi);
321
366
  await orchestrator.cleanupActive();
@@ -340,6 +385,13 @@ async function pauseTask(orchestrator: Orchestrator, ctx: any): Promise<string>
340
385
  async function finishTask(orchestrator: Orchestrator, ctx: any): Promise<string> {
341
386
  if (!orchestrator.active) return "No active task.";
342
387
 
388
+ // The review phase must produce the ANCHORS-bearing final_pass file Publish
389
+ // consumes; block the direct Complete path too (it bypasses validateExitCriteria).
390
+ if (orchestrator.active.state.phase === "review" && !hasFinalPassAnchors(orchestrator.active.dir)) {
391
+ ctx.ui.notify(MISSING_FINAL_PASS_ANCHORS, "warning");
392
+ return MISSING_FINAL_PASS_ANCHORS;
393
+ }
394
+
343
395
  cancelPendingPlannotatorWait(orchestrator);
344
396
  orchestrator.abortAllSubagents();
345
397
  orchestrator.transitionController.abortMainAgent(ctx.abort?.bind(ctx));
@@ -366,7 +418,8 @@ async function finishTask(orchestrator: Orchestrator, ctx: any): Promise<string>
366
418
  // is over, nothing awaits this compaction).
367
419
  void orchestrator.transitionController.requestTransition({
368
420
  kind: "done",
369
- summary: `Task "${name}" (${type}) completed.`,
421
+ discard: true,
422
+ summary: `Task "${name}" (${type}) is finished — DISCARD its entire conversation. Do NOT carry forward, reference, or act on any of this task's messages, phase, plan, or aborted turns; the next task starts from a clean slate.`,
370
423
  });
371
424
 
372
425
  const urExists = existsSync(join(dir, "USER_REQUEST.md"));
@@ -1286,6 +1339,7 @@ const TIMEOUT_LABELS: Record<TimeoutKey, string> = {
1286
1339
  "performance.commands.afterEdit": "Command after file edit",
1287
1340
  "performance.commands.afterImplement": "Command after implementation",
1288
1341
  "performance.internals.subagentStale": "Subagent stale",
1342
+ "performance.internals.mainTurnStale": "Main turn stale",
1289
1343
  "performance.internals.taskLockStale": "Lock stale",
1290
1344
  "performance.internals.taskLockRefresh": "Lock update",
1291
1345
  };
@@ -1827,8 +1881,8 @@ async function showOrchestratorEditor(
1827
1881
  const current = orchestrator.config.agents.orchestrators[role];
1828
1882
  const basePath = ["agents", "orchestrators", role];
1829
1883
  const choice = await selectOption(ctx, label, [
1830
- opt(`Model: ${current.model}`, "Select model"),
1831
- opt(`Thinking: ${thinkingLabel(current.thinking)}`, "Select thinking level"),
1884
+ opt(`Model: ${current.model}`, "Choose the model for this agent"),
1885
+ opt(`Thinking: ${thinkingLabel(current.thinking)}`, "Choose how much this agent thinks before acting"),
1832
1886
  ...buildResetOptions(orchestrator, basePath),
1833
1887
  opt("Back", "Return to the previous menu"),
1834
1888
  ]);
@@ -1874,8 +1928,8 @@ async function showSimpleSubagentEditor(
1874
1928
  const current = orchestrator.config.agents.subagents.simple[role];
1875
1929
  const basePath = ["agents", "subagents", "simple", role];
1876
1930
  const choice = await selectOption(ctx, label, [
1877
- opt(`Model: ${current.model}`, "Select model"),
1878
- opt(`Thinking: ${thinkingLabel(current.thinking)}`, "Select thinking level"),
1931
+ opt(`Model: ${current.model}`, "Choose the model for this agent"),
1932
+ opt(`Thinking: ${thinkingLabel(current.thinking)}`, "Choose how much this agent thinks before acting"),
1879
1933
  ...buildResetOptions(orchestrator, basePath),
1880
1934
  opt("Back", "Return to the previous menu"),
1881
1935
  ]);
@@ -1976,8 +2030,8 @@ async function showPresetVariantEditor(
1976
2030
  const variantPath = ["agents", "subagents", "presetGroups", group, "presets", presetName, "agents", variantName];
1977
2031
  const options: OptionInput[] = [
1978
2032
  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"),
2033
+ opt(`Model: ${variant.model}`, "Choose the model for this agent"),
2034
+ opt(`Thinking: ${thinkingLabel(variant.thinking)}`, "Choose how much this agent thinks before acting"),
1981
2035
  ];
1982
2036
  if (getOwnedScopes(orchestrator, variantPath).length > 0) {
1983
2037
  options.push(opt("Delete", "Delete this agent override"));
@@ -2029,7 +2083,7 @@ async function showPresetAgentsMenu(
2029
2083
  options.push(opt(title, `${variant.model} / ${thinkingLabel(variant.thinking)}`));
2030
2084
  byTitle.set(title, variantName);
2031
2085
  }
2032
- options.push(opt("New agent", "Add a new agent"));
2086
+ options.push(opt("New agent", "Add an agent variant to this preset"));
2033
2087
  options.push(opt("Back", "Return to the previous menu"));
2034
2088
  const choice = await selectOption(ctx, "Agents", options);
2035
2089
  if (!choice || choice === "Back") return BACK;
@@ -2198,7 +2252,7 @@ async function showPresetSettings(
2198
2252
  options.push(opt(optionTitle, enabledPresetSummary(preset.agents)));
2199
2253
  byTitle.set(optionTitle, presetName);
2200
2254
  }
2201
- options.push(opt("New preset", "Create preset"));
2255
+ options.push(opt("New preset", "Create a preset in this group"));
2202
2256
  options.push(opt("Back", "Return to the previous menu"));
2203
2257
  const choice = await selectOption(ctx, title, options);
2204
2258
  if (!choice || choice === "Back") return BACK;
@@ -2272,7 +2326,7 @@ async function showAfterEditCommands(orchestrator: Orchestrator, ctx: any): Prom
2272
2326
  options.push(opt(`${title}${enabledTag}`, `${globsCount} patterns — ${cmd.run}`));
2273
2327
  byTitle.set(`${title}${enabledTag}`, id);
2274
2328
  }
2275
- options.push(opt("New command", "Add command"));
2329
+ options.push(opt("New command", "Add a command to run after a file edit"));
2276
2330
  options.push(...buildResetOptions(orchestrator, ["commands", "afterEdit"]));
2277
2331
  options.push(opt("Back", "Return to the previous menu"));
2278
2332
  const choice = await selectOption(ctx, `After file edit: ${entries.length} commands`, options);
@@ -2401,7 +2455,7 @@ async function showAfterImplementCommands(orchestrator: Orchestrator, ctx: any):
2401
2455
  options.push(opt(title, cmd.run));
2402
2456
  byTitle.set(title, id);
2403
2457
  }
2404
- options.push(opt("New command", "Add command"));
2458
+ options.push(opt("New command", "Add a command to run after implementation"));
2405
2459
  options.push(...buildResetOptions(orchestrator, ["commands", "afterImplement"]));
2406
2460
  options.push(opt("Back", "Return to the previous menu"));
2407
2461
  const choice = await selectOption(ctx, `After implementation: ${entries.length} commands`, options);
@@ -2461,11 +2515,11 @@ async function showCommandsSettings(orchestrator: Orchestrator, ctx: any): Promi
2461
2515
  const choice = await selectOption(ctx, "Commands", [
2462
2516
  opt(
2463
2517
  `After file edit: ${afterEditCount} commands`,
2464
- `${afterEditCount} commands`,
2518
+ "Commands run when a matching file is edited (e.g. format, lint)",
2465
2519
  ),
2466
2520
  opt(
2467
2521
  `After implementation: ${afterImplementCount} commands`,
2468
- `${afterImplementCount} commands`,
2522
+ "Commands run once the implement phase completes (e.g. build, test)",
2469
2523
  ),
2470
2524
  opt("Back", "Return to the previous menu"),
2471
2525
  ]);
@@ -2496,6 +2550,11 @@ async function showTimeoutsSettings(orchestrator: Orchestrator, ctx: any): Promi
2496
2550
  path: ["performance", "internals", "subagentStale"],
2497
2551
  value: orchestrator.config.performance.internals.subagentStale,
2498
2552
  },
2553
+ {
2554
+ key: "performance.internals.mainTurnStale",
2555
+ path: ["performance", "internals", "mainTurnStale"],
2556
+ value: orchestrator.config.performance.internals.mainTurnStale,
2557
+ },
2499
2558
  {
2500
2559
  key: "performance.internals.taskLockStale",
2501
2560
  path: ["performance", "internals", "taskLockStale"],
@@ -2510,7 +2569,7 @@ async function showTimeoutsSettings(orchestrator: Orchestrator, ctx: any): Promi
2510
2569
  const options: OptionInput[] = timeoutEntries.map((entry) => {
2511
2570
  const value = entry.value;
2512
2571
  const key = entry.key;
2513
- return opt(`${TIMEOUT_LABELS[key]}: ${formatDuration(value)}`, "Edit timeout");
2572
+ return opt(`${TIMEOUT_LABELS[key]}: ${formatDuration(value)}`, "Change this time limit");
2514
2573
  });
2515
2574
  options.push(opt("Back", "Return to the previous menu"));
2516
2575
  const choice = await selectOption(ctx, "Timeouts", options);
@@ -2548,7 +2607,7 @@ async function showTimeoutsSettings(orchestrator: Orchestrator, ctx: any): Promi
2548
2607
  async function showPerformanceSettings(orchestrator: Orchestrator, ctx: any): Promise<typeof BACK> {
2549
2608
  while (true) {
2550
2609
  const choice = await selectOption(ctx, "Performance", [
2551
- opt("Timeouts", "Timeout configuration"),
2610
+ opt("Timeouts", "Adjust per-operation time limits"),
2552
2611
  opt("Back", "Return to the previous menu"),
2553
2612
  ]);
2554
2613
  if (!choice || choice === "Back") return BACK;
@@ -2649,6 +2708,7 @@ async function showGeneralSettings(orchestrator: Orchestrator, ctx: any): Promis
2649
2708
  while (true) {
2650
2709
  const choice = await selectOption(ctx, "General", [
2651
2710
  opt(`Commit automatically: ${orchestrator.config.general.autoCommit ? "Yes" : "No"}`, "Enable or disable auto commits"),
2711
+ opt(`Inject root AGENTS.md: ${orchestrator.config.general.injectAgentsMd ? "Yes" : "No"}`, "Inject the working repo's root AGENTS.md into the agent system prompt"),
2652
2712
  opt(`Ignore configs from other repos: ${orchestrator.config.general.loadExtraRepoConfigs ? "No" : "Yes"}`, "Load only root repo config"),
2653
2713
  opt(`Log level: ${logLevelLabel(orchestrator.config.general.logLevel)}`, "Logging verbosity"),
2654
2714
  opt(`Tracing: ${orchestrator.config.general.tracing ? "Yes" : "No"}`, "Capture full session traces to .pp/logs/traces/"),
@@ -2660,6 +2720,10 @@ async function showGeneralSettings(orchestrator: Orchestrator, ctx: any): Promis
2660
2720
  await showBooleanSetting(orchestrator, ctx, "Commit automatically", ["general", "autoCommit"], "Commit changes automatically as work progresses", "Leave committing to you");
2661
2721
  continue;
2662
2722
  }
2723
+ if (choice.startsWith("Inject root AGENTS.md:")) {
2724
+ await showBooleanSetting(orchestrator, ctx, "Inject root AGENTS.md", ["general", "injectAgentsMd"], "Inject the working repo's root AGENTS.md into the agent system prompt", "Do not inject AGENTS.md");
2725
+ continue;
2726
+ }
2663
2727
  if (choice.startsWith("Ignore configs from other repos:")) {
2664
2728
  await showInvertedBooleanSetting(orchestrator, ctx, "Ignore configs from other repos", ["general", "loadExtraRepoConfigs"], "Use only the root repo config and ignore configs from other registered repos", "Also load configs from the other registered repos");
2665
2729
  continue;
@@ -2763,7 +2827,7 @@ async function showSettingsMenu(orchestrator: Orchestrator, ctx: any): Promise<t
2763
2827
  opt("General", "Commit, log level, Flant AI, repos"),
2764
2828
  opt("Agents", "Orchestrator and subagent configuration"),
2765
2829
  opt("Commands", "After file edit and after implementation"),
2766
- opt("Performance", "Timeout configuration"),
2830
+ opt("Performance", "Per-operation timeout limits"),
2767
2831
  opt("LSP", "Language server controls"),
2768
2832
  opt("Back", "Return to the previous menu"),
2769
2833
  ];
@@ -2855,10 +2919,10 @@ async function showAutonomousPhaseSettings(
2855
2919
  const reviewPreset = phaseConfig.reviewPreset ?? defaultAutonomousReviewPreset(type, phase);
2856
2920
  const maxReview = phaseConfig.maxReviewPasses >= 999 ? "No limit" : String(phaseConfig.maxReviewPasses);
2857
2921
  const options: OptionInput[] = [
2858
- opt(`Review preset: ${reviewPreset}`, "Select review preset"),
2922
+ opt(`Review preset: ${reviewPreset}`, "Pick which reviewer group runs in this phase"),
2859
2923
  ];
2860
2924
  if (phase === "plan") {
2861
- options.push(opt(`Planner preset: ${phaseConfig.plannerPreset ?? "regular"}`, "Select planner preset"));
2925
+ options.push(opt(`Planner preset: ${phaseConfig.plannerPreset ?? "regular"}`, "Pick which planner group runs in this phase"));
2862
2926
  }
2863
2927
  options.push(opt(`Max review passes: ${maxReview}`, "Safety cap for autonomous review loops"));
2864
2928
  options.push(opt("Back", "Return to autonomous settings"));
@@ -3154,175 +3218,118 @@ async function openCodeReviewInPlannotator(
3154
3218
  return { status: result.approved ? "approved" : "needs_changes", feedback };
3155
3219
  }
3156
3220
 
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");
3221
+ // Per-repo interleaved Plannotator loop (#3a). Runs from the persisted cursor: for
3222
+ // each repo, ask the diff scope, open Plannotator and WAIT (dialogue closed). On
3223
+ // NEEDS_CHANGES, persist the cursor advanced past this repo and return a work
3224
+ // instruction (mirrors the plan path: answer questions + apply changes) so the
3225
+ // agent fixes THIS repo before the next opens; the next /pp resumes the loop. On
3226
+ // approved/error/skip, advance and continue in-loop. When the cursor is exhausted
3227
+ // or the user stops, clear the cursor and return null (fall back to the menu).
3228
+ async function runPlannotatorCursor(orchestrator: Orchestrator, ctx: any): Promise<string | null> {
3229
+ const task = orchestrator.active;
3230
+ if (!task) return null;
3231
+ const cursor = task.state.plannotatorCursor;
3232
+ if (!cursor) return null;
3233
+
3234
+ while (task.state.plannotatorCursor && task.state.plannotatorCursor.index < task.state.plannotatorCursor.repoPaths.length) {
3235
+ const cur = task.state.plannotatorCursor;
3236
+ const repoPath = cur.repoPaths[cur.index];
3237
+ const repo = getRegisteredRepos(orchestrator).find((r) => r.path === repoPath) ?? { path: repoPath, isRoot: false };
3238
+
3239
+ const diffChoice = await selectOption(ctx, `Review: ${formatRepoLabel(repo)}`, [
3240
+ opt("All branch changes", "Committed changes vs base branch"),
3241
+ opt("Last commit", "Changes in the most recent commit"),
3242
+ opt("Since commit", "Review all changes since a specific commit"),
3243
+ opt("Uncommitted changes", "Working directory changes"),
3244
+ opt("Skip this repo", "Move to the next repository"),
3245
+ opt("Done (stop reviewing)", "Stop iterating repositories"),
3246
+ ]);
3247
+
3248
+ if (!diffChoice || diffChoice === "Done (stop reviewing)") {
3249
+ task.state.plannotatorCursor = undefined;
3250
+ saveTask(task.dir, task.state);
3251
+ return null;
3252
+ }
3253
+ if (diffChoice === "Skip this repo") {
3254
+ cur.index += 1;
3255
+ saveTask(task.dir, task.state);
3256
+ continue;
3257
+ }
3258
+
3259
+ let diffType: string;
3260
+ let defaultBranch: string | undefined;
3261
+ if (diffChoice === "All branch changes") {
3262
+ diffType = "branch";
3263
+ defaultBranch = await detectDefaultBranch(orchestrator, getRegisteredRepos(orchestrator), repo.path);
3264
+ } else if (diffChoice === "Last commit") {
3265
+ diffType = "last-commit";
3266
+ } else if (diffChoice === "Since commit") {
3267
+ const pickedHash = await pickCommitForRepo(orchestrator, ctx, repo);
3268
+ if (!pickedHash) continue;
3269
+ diffType = "branch";
3270
+ defaultBranch = pickedHash;
3271
+ } else {
3272
+ diffType = "uncommitted";
3273
+ }
3274
+
3275
+ const result = await openCodeReviewInPlannotator(orchestrator, { cwd: repo.path, diffType, defaultBranch });
3276
+
3277
+ // Advance past this repo regardless of outcome; on needs_changes the agent
3278
+ // fixes it during the turn started by the returned instruction, then the next
3279
+ // /pp resumes at the following repo.
3280
+ cur.index += 1;
3281
+ const exhausted = cur.index >= cur.repoPaths.length;
3282
+
3283
+ if (result.status === "needs_changes") {
3284
+ if (exhausted) task.state.plannotatorCursor = undefined;
3285
+ setStep(orchestrator, "llm_work");
3286
+ saveTask(task.dir, task.state);
3287
+ const feedback = result.feedback ? `\n\nFeedback:\n${result.feedback}` : "";
3288
+ const more = exhausted
3289
+ ? "This was the last repo to review."
3290
+ : "After you finish, run /pp to continue Plannotator review of the remaining repositories.";
3291
+ return advanceBanner(
3292
+ `[PI-PI] Plannotator requested changes for ${formatRepoLabel(repo)}.${feedback}\n\n` +
3293
+ "Address the user's feedback. If the feedback contains questions, answer them. If it requests changes, " +
3294
+ `make the changes. Then call pp_phase_complete when done.\n\n${more}`,
3295
+ );
3296
+ }
3297
+
3298
+ if (result.status === "error") {
3299
+ ctx.ui.notify(`${formatRepoLabel(repo)}: ERROR${result.error ? ` — ${result.error}` : ""}`, "warning");
3300
+ } else {
3301
+ ctx.ui.notify(`${formatRepoLabel(repo)}: APPROVED`, "info");
3302
+ }
3303
+ saveTask(task.dir, task.state);
3175
3304
  }
3176
- return "started";
3305
+
3306
+ // Cursor exhausted with no outstanding changes: clear it and fall back to /pp.
3307
+ task.state.plannotatorCursor = undefined;
3308
+ saveTask(task.dir, task.state);
3309
+ return null;
3177
3310
  }
3178
3311
 
3179
3312
  async function showReviewMenu(orchestrator: Orchestrator, ctx: any): Promise<typeof BACK | "started"> {
3180
3313
  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" },
3314
+ const choice = await selectOption(ctx, "Review", [
3315
+ { title: "New", description: "Start a new review — type what to review (a branch, commit range, uncommitted changes, or a PR URL) as your first chat message" },
3187
3316
  { title: "Resume", description: "Resume a previously unfinished review" },
3188
3317
  { title: "Back", description: "Return to the previous menu" },
3189
- ];
3190
- const choice = await selectOption(ctx, "Review", options);
3318
+ ]);
3191
3319
  if (!choice || choice === "Back") return BACK;
3192
3320
 
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");
3321
+ if (choice === "New") {
3312
3322
  const modeSelection = await pickModeForTaskStart(orchestrator, ctx, "review");
3313
3323
  if (!modeSelection) continue;
3314
- return startReviewTask(orchestrator, ctx, urContent, null, "review-uncommitted", modeSelection.mode, modeSelection.autonomousConfig);
3324
+ await orchestrator.startTask(ctx, "review", "review", undefined, undefined, modeSelection.mode);
3325
+ if (!orchestrator.active || orchestrator.active.type !== "review") return BACK;
3326
+ orchestrator.active.state.autonomousConfig = modeSelection.autonomousConfig;
3327
+ saveTask(orchestrator.active.dir, orchestrator.active.state);
3328
+ return "started";
3315
3329
  }
3316
3330
 
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);
3331
+ const result = await showResumeMenu(orchestrator, ctx, "review", "No paused review tasks found.");
3332
+ if (result === "started") return result;
3326
3333
  }
3327
3334
  }
3328
3335
 
@@ -3416,7 +3423,7 @@ async function showNoActiveMenu(orchestrator: Orchestrator, ctx: any): Promise<s
3416
3423
  const choice = await selectOption(ctx, "/pp", [
3417
3424
  { title: "Task", description: "Start a new task or resume a paused one" },
3418
3425
  { title: "Info", description: "Subagents, usage, and task status" },
3419
- { title: "Settings", description: "Flant AI and other configuration" },
3426
+ { title: "Settings", description: "Models, agents, commands, and other configuration" },
3420
3427
  { title: "Back", description: "Close this menu" },
3421
3428
  ]);
3422
3429
  if (!choice || choice === "Back") return undefined;
@@ -3477,7 +3484,7 @@ async function showQuickTaskMenu(
3477
3484
  opt("Complete", "Mark task as done and clean up"),
3478
3485
  opt("Pause", "Suspend task to resume later"),
3479
3486
  opt("Info", "Subagents, usage, and task status"),
3480
- opt("Settings", "Flant AI and other configuration"),
3487
+ opt("Settings", "Models, agents, commands, and other configuration"),
3481
3488
  opt("Back", "Return to the prompt and keep working"),
3482
3489
  ]);
3483
3490
  // A deliberate ESC in tool mode must stop the turn cleanly (mirror the
@@ -3506,6 +3513,11 @@ export async function showActiveTaskMenu(
3506
3513
  ctx: any,
3507
3514
  summary: string,
3508
3515
  mode: MenuMode = "command",
3516
+ // Display-only override for the autonomous terminal implement handoff (#1):
3517
+ // render the guided Next/Review menu even though the task is autonomous, WITHOUT
3518
+ // mutating task.state.mode. Never persisted; does not affect the footer indicator
3519
+ // or getEffectivePhaseMode.
3520
+ forceGuided = false,
3509
3521
  ): Promise<string> {
3510
3522
  const continueMessage = advanceBanner("[PI-PI] User wants to continue. Run /pp when ready to advance.");
3511
3523
 
@@ -3516,6 +3528,15 @@ export async function showActiveTaskMenu(
3516
3528
  if (task.type === "quick") {
3517
3529
  return showQuickTaskMenu(orchestrator, ctx, summary, mode);
3518
3530
  }
3531
+ // Auto-resume an in-progress per-repo Plannotator review (#3a): if the agent
3532
+ // just finished fixing one repo's feedback and ran /pp, continue the loop at
3533
+ // the next repo instead of showing the top-level menu. On another
3534
+ // needs_changes this returns a fresh work instruction; when exhausted it
3535
+ // clears the cursor and falls through to the normal menu.
3536
+ if (task.state.plannotatorCursor) {
3537
+ const resumeText = await runPlannotatorCursor(orchestrator, ctx);
3538
+ if (resumeText) return resumeText;
3539
+ }
3519
3540
  const phase = task.state.phase;
3520
3541
  const step = task.state.step;
3521
3542
  const effectiveMode = getEffectivePhaseMode(task.state);
@@ -3527,15 +3548,21 @@ export async function showActiveTaskMenu(
3527
3548
  const { autoLabel } = getReviewLabels(orchestrator);
3528
3549
  const isReviewPhase = phase === "review";
3529
3550
  const hasPlannotator = phase === "plan" || phase === "implement" || isReviewPhase;
3551
+ // The artifact the automated reviewers scan, mirroring getReviewPresetGroup: brainstorm →
3552
+ // brainstormReviewers (state files), plan → planReviewers (synthesized plan), every other
3553
+ // phase → codeReviewers (code changes). This is the primary "Review" target named at the
3554
+ // top level; the per-phase "Review on my own" description names the manual editor pass's
3555
+ // target separately (it can differ, e.g. state files in a debug/review phase).
3556
+ const reviewTarget = phase === "plan" ? "the synthesized plan" : phase === "brainstorm" ? "this phase's state files" : "the code changes";
3530
3557
 
3531
3558
  const opt = (title: string, description: string): OptionInput => ({ title, description });
3532
3559
 
3533
- if (effectiveMode === "autonomous") {
3560
+ if (effectiveMode === "autonomous" && !forceGuided) {
3534
3561
  const { choice: autoChoice, cancelReason } = await selectOptionCancelable(ctx, `/pp\n\nTask: ${task.type}\nPhase: ${phase}${summary !== "/pp" ? `\n\n${summary}` : ""}`, [
3535
3562
  opt("Complete task", "Mark task as done and clean up"),
3536
3563
  opt("Pause task", "Suspend task to resume later"),
3537
3564
  opt("Info", "Subagents, usage, and task status"),
3538
- opt("Settings", "Flant AI and other configuration"),
3565
+ opt("Settings", "Models, agents, commands, and other configuration"),
3539
3566
  opt("Back", "Return to the prompt and keep working"),
3540
3567
  ]);
3541
3568
  // A deliberate ESC only needs distinct handling in tool mode (so
@@ -3562,10 +3589,10 @@ export async function showActiveTaskMenu(
3562
3589
  const options: OptionInput[] = [];
3563
3590
  options.push(opt("Next", "Complete, pause, or continue to next phase"));
3564
3591
  if (!waiting) {
3565
- options.push(opt("Review", "Auto review, Plannotator, or manual review"));
3592
+ options.push(opt("Review", `Review ${reviewTarget}: automated reviewers${hasPlannotator ? ", Plannotator, or" : " or"} your own editor pass`));
3566
3593
  }
3567
3594
  options.push(opt("Info", "Subagents, usage, and task status"));
3568
- options.push(opt("Settings", "Flant AI and other configuration"));
3595
+ options.push(opt("Settings", "Models, agents, commands, and other configuration"));
3569
3596
  options.push(opt("Back", "Return to the prompt and keep working"));
3570
3597
 
3571
3598
  const headerLines = [`/pp\n\nTask: ${task.type}\nPhase: ${phase}`];
@@ -3590,70 +3617,109 @@ export async function showActiveTaskMenu(
3590
3617
  }
3591
3618
 
3592
3619
  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
- }
3620
+ // The Next submenu runs its own loop so that a deeper submenu's "Back"
3621
+ // (e.g. Publish) returns here rather than overshooting to the top-level menu.
3622
+ while (true) {
3623
+ const canContinue = phase !== "implement" && !waiting;
3624
+ const continueLabel = phase === "plan" ? "Continue to implement" : "Continue to plan & implement";
3625
+ const finishOptions: OptionInput[] = [];
3626
+ if (phase === "review") {
3627
+ finishOptions.push(opt("Publish", "Publish the synthesized review findings as file comments or GitHub PR comments"));
3628
+ }
3629
+ if (canContinue) {
3630
+ finishOptions.push(opt(continueLabel, "Approve and advance to the next phase"));
3631
+ }
3632
+ finishOptions.push(opt("Complete", "Mark task as done and clean up"));
3633
+ finishOptions.push(opt("Pause", "Suspend task to resume later"));
3634
+ finishOptions.push(opt("Back", "Return to the previous menu"));
3635
+
3636
+ const finishChoice = await selectOption(ctx, "Next", finishOptions);
3637
+ if (!finishChoice || finishChoice === "Back") break;
3638
+ if (finishChoice === "Publish") {
3639
+ const target = await selectOption(ctx, "Publish", [
3640
+ opt("As file comments", "Insert AI_COMMENT: markers at each finding's location in the source"),
3641
+ opt("As GitHub PR comments", "Post line-anchored comments to the branch's PR from your GitHub account"),
3642
+ opt("Back", "Return to the previous menu"),
3643
+ ]);
3644
+ if (!target || target === "Back") continue;
3645
+ const guardMessage = publishGuard(task.dir);
3646
+ if (guardMessage) {
3647
+ ctx.ui.notify(guardMessage, "info");
3648
+ continue;
3649
+ }
3650
+ return target === "As file comments"
3651
+ ? publishFileCommentsBanner(task.dir)
3652
+ : publishPrCommentsBanner(task.dir);
3653
+ }
3654
+ if (finishChoice === "Pause") {
3655
+ const text = await pauseTask(orchestrator, ctx);
3656
+ return mode === "tool" ? text : "";
3657
+ }
3658
+ if (finishChoice === "Complete") {
3659
+ const text = await finishTask(orchestrator, ctx);
3660
+ return mode === "tool" ? text : "";
3661
+ }
3662
+ const next = nextPhase(task.type, phase);
3663
+
3664
+ if (
3665
+ next === "plan" &&
3666
+ task.type === "brainstorm"
3667
+ ) {
3668
+ const modeSelection = await pickModeForTaskStart(orchestrator, ctx, "implement");
3669
+ if (!modeSelection) continue;
3670
+ task.state.mode = modeSelection.mode;
3671
+ task.state.effectiveMode = undefined;
3672
+ task.state.autonomousConfig = modeSelection.autonomousConfig;
3673
+ saveTask(task.dir, task.state);
3674
+ }
3626
3675
 
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;
3676
+ let plannerPreset: string | undefined;
3677
+ if (next === "plan") {
3678
+ if (getEffectiveMode(task.state) !== "autonomous") {
3679
+ const pickedPlannerPreset = await pickPreset(ctx, orchestrator, "planners", "Planner preset");
3680
+ if (!pickedPlannerPreset) continue;
3681
+ plannerPreset = pickedPlannerPreset;
3682
+ }
3633
3683
  }
3684
+ finalizeReviewCycle(task);
3685
+ const result = await orchestrator.transitionToNextPhase(ctx, plannerPreset);
3686
+ if (!result.ok) return `Transition blocked: ${result.error}`;
3687
+ // Transition is now owned by the controller (state ≠ running) or we're
3688
+ // awaiting subagents; either way the menu returns empty.
3689
+ return "";
3634
3690
  }
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 "";
3691
+ continue;
3641
3692
  }
3642
3693
 
3643
3694
  if (choice === "Review") {
3695
+ // The Review submenu runs its own loop so a recoverable choice (no reviewers
3696
+ // enabled, preset cancelled, "Review on my own" Back, a plannotator early
3697
+ // exit) returns HERE rather than overshooting to the top-level /pp menu.
3698
+ // Only an explicit "Back" breaks out; start/wait/instruction paths return.
3699
+ while (true) {
3700
+ // A review already running: block re-entry BEFORE any finalizeReviewCycle
3701
+ // (which would null the live cycle and hide it), so we never double-spawn.
3702
+ // Notify and break back to the top-level menu with the live cycle intact
3703
+ // rather than exiting /pp.
3704
+ if (isReviewCycleLive(task)) {
3705
+ ctx.ui.notify("A review is already running", "info");
3706
+ break;
3707
+ }
3644
3708
  const reviewOptions: OptionInput[] = [
3645
- opt(autoLabel, "Run automated review with configured reviewers"),
3709
+ opt(autoLabel, `Run configured reviewers over ${reviewTarget}`),
3646
3710
  ];
3647
3711
  if (hasPlannotator) {
3648
3712
  reviewOptions.push(opt("Review in Plannotator", phase === "plan" ? "Open plan review in browser" : "Open code diff review in browser"));
3649
3713
  }
3650
3714
  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"));
3715
+ ? "Mark spots in the changed files with AI_REVIEW: comments; the agent then addresses each and removes the marker"
3716
+ : phase === "plan"
3717
+ ? "Mark spots in the synthesized plan with AI_REVIEW: comments; the agent then addresses each and removes the marker"
3718
+ : "Mark spots in USER_REQUEST.md, RESEARCH.md, and artifacts/*.md with AI_REVIEW: comments; the agent then addresses each and removes the marker"));
3653
3719
  reviewOptions.push(opt("Back", "Return to the previous menu"));
3654
3720
 
3655
3721
  const reviewChoice = await selectOption(ctx, "Review", reviewOptions);
3656
- if (!reviewChoice || reviewChoice === "Back") continue;
3722
+ if (!reviewChoice || reviewChoice === "Back") break;
3657
3723
 
3658
3724
  if (reviewChoice === "Review in Plannotator") {
3659
3725
  if (phase === "plan") {
@@ -3666,119 +3732,50 @@ export async function showActiveTaskMenu(
3666
3732
  return handled.text ?? text;
3667
3733
  }
3668
3734
  const allRepos = getRegisteredRepos(orchestrator);
3669
- const summaries: string[] = [];
3670
- const repos: RepoInfo[] = [];
3735
+ const eligible: string[] = [];
3671
3736
  for (const repo of allRepos) {
3672
3737
  const base = await detectDefaultBranch(orchestrator, allRepos, repo.path);
3673
3738
  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
- }
3739
+ if (error || changed) eligible.push(repo.path);
3680
3740
  }
3681
- if (repos.length === 0) {
3741
+ if (eligible.length === 0) {
3682
3742
  ctx.ui.notify("No registered repositories have changes to review.", "info");
3683
3743
  continue;
3684
3744
  }
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
- }
3745
+ // Start (or restart) the interleaved per-repo cursor and run it. On
3746
+ // needs_changes runPlannotatorCursor persists the cursor and returns a
3747
+ // work instruction (exits the menu to start the agent turn); the next /pp
3748
+ // resumes at the following repo.
3749
+ task.state.plannotatorCursor = { repoPaths: eligible, index: 0 };
3750
+ saveTask(task.dir, task.state);
3751
+ const cursorText = await runPlannotatorCursor(orchestrator, ctx);
3752
+ if (cursorText) return cursorText;
3747
3753
  continue;
3748
3754
  }
3749
3755
 
3750
3756
  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
3757
  const gate = await selectOption(ctx, "Editor review", [
3761
3758
  opt("Done", "I've added AI_REVIEW: markers and saved my files"),
3762
3759
  opt("Skip markers", "Continue without the marker workflow"),
3763
3760
  opt("Back", "Return to the review menu"),
3764
3761
  ]);
3765
3762
  if (!gate || gate === "Back") continue;
3766
- setStep(orchestrator, "llm_work");
3763
+ setStep(orchestrator, phase === "plan" ? "synthesize" : "llm_work");
3767
3764
  if (gate === "Skip markers") {
3768
3765
  return continueMessage;
3769
3766
  }
3770
- 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.",
3781
- );
3767
+ if (phase === "implement") {
3768
+ return advanceBanner(
3769
+ "[PI-PI] The user reviewed the changes in their editor and left inline `AI_REVIEW:` markers " +
3770
+ `${AI_REVIEW_MARKER_SYNTAX}.\n\n` +
3771
+ "For each registered repo, enumerate the CHANGED files only — union of `git diff --name-only <base>...HEAD`, " +
3772
+ "`git diff --name-only`, `git diff --cached --name-only`, and untracked-non-ignored files from `git status --porcelain` " +
3773
+ "(base = each repo's configured base branch) — and search WITHIN those files for `AI_REVIEW:`. Do NOT grep the whole " +
3774
+ "worktree (avoid vendored/generated/node_modules and historical markers).\n\n" +
3775
+ AI_REVIEW_MARKER_LOOP,
3776
+ );
3777
+ }
3778
+ return readOnlyReviewBanner(phase, task.dir);
3782
3779
  }
3783
3780
 
3784
3781
  const reviewPreset = await pickPreset(ctx, orchestrator, getReviewPresetGroup(phase), "Review preset");
@@ -3795,6 +3792,7 @@ export async function showActiveTaskMenu(
3795
3792
  const handled = handleReviewResult(ctx, text);
3796
3793
  if (handled.continueLoop) continue;
3797
3794
  return handled.text ?? text;
3795
+ }
3798
3796
  }
3799
3797
  }
3800
3798
  }