@ilya-lesikov/pi-pi 0.7.0 → 0.8.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 (59) hide show
  1. package/3p/pi-plannotator/apps/pi-extension/README.md +6 -5
  2. package/3p/pi-plannotator/apps/pi-extension/plannotator-browser.ts +25 -10
  3. package/3p/pi-plannotator/apps/pi-extension/plannotator-events.code-review.test.ts +172 -0
  4. package/3p/pi-plannotator/apps/pi-extension/plannotator-events.ts +50 -12
  5. package/3p/pi-plannotator/apps/pi-extension/server/project.test.ts +45 -0
  6. package/3p/pi-plannotator/apps/pi-extension/server/project.ts +7 -6
  7. package/3p/pi-plannotator/apps/pi-extension/server/serverReview.ts +41 -25
  8. package/3p/pi-subagents/src/agent-manager.ts +34 -1
  9. package/3p/pi-subagents/src/agent-runner.ts +66 -33
  10. package/3p/pi-subagents/src/index.ts +3 -38
  11. package/3p/pi-subagents/src/types.ts +4 -0
  12. package/extensions/orchestrator/agents/advisor.ts +35 -0
  13. package/extensions/orchestrator/agents/brainstorm-reviewer.ts +7 -7
  14. package/extensions/orchestrator/agents/code-reviewer.ts +7 -7
  15. package/extensions/orchestrator/agents/constraints.test.ts +44 -0
  16. package/extensions/orchestrator/agents/constraints.ts +3 -0
  17. package/extensions/orchestrator/agents/deep-debugger.ts +35 -0
  18. package/extensions/orchestrator/agents/plan-reviewer.ts +7 -7
  19. package/extensions/orchestrator/agents/planner.ts +9 -8
  20. package/extensions/orchestrator/agents/prompts.test.ts +120 -0
  21. package/extensions/orchestrator/agents/reviewer.ts +48 -0
  22. package/extensions/orchestrator/agents/task.ts +6 -20
  23. package/extensions/orchestrator/agents/tool-routing.ts +23 -1
  24. package/extensions/orchestrator/command-handlers.ts +1 -1
  25. package/extensions/orchestrator/config.ts +5 -2
  26. package/extensions/orchestrator/context.test.ts +54 -0
  27. package/extensions/orchestrator/context.ts +65 -2
  28. package/extensions/orchestrator/event-handlers.test.ts +97 -1
  29. package/extensions/orchestrator/event-handlers.ts +176 -43
  30. package/extensions/orchestrator/flant-infra.test.ts +25 -0
  31. package/extensions/orchestrator/flant-infra.ts +91 -0
  32. package/extensions/orchestrator/index.ts +1 -1
  33. package/extensions/orchestrator/integration.test.ts +107 -12
  34. package/extensions/orchestrator/messages.test.ts +30 -0
  35. package/extensions/orchestrator/messages.ts +6 -0
  36. package/extensions/orchestrator/model-registry.test.ts +48 -1
  37. package/extensions/orchestrator/model-registry.ts +43 -1
  38. package/extensions/orchestrator/orchestrator.test.ts +113 -0
  39. package/extensions/orchestrator/orchestrator.ts +151 -2
  40. package/extensions/orchestrator/phases/brainstorm.ts +9 -20
  41. package/extensions/orchestrator/phases/implementation.test.ts +11 -0
  42. package/extensions/orchestrator/phases/implementation.ts +4 -6
  43. package/extensions/orchestrator/phases/planning.test.ts +16 -0
  44. package/extensions/orchestrator/phases/planning.ts +11 -4
  45. package/extensions/orchestrator/phases/review-task.ts +1 -4
  46. package/extensions/orchestrator/phases/review.test.ts +8 -0
  47. package/extensions/orchestrator/phases/review.ts +4 -3
  48. package/extensions/orchestrator/plannotator.ts +9 -6
  49. package/extensions/orchestrator/pp-menu.ts +168 -54
  50. package/extensions/orchestrator/pp-state-tools.test.ts +192 -0
  51. package/extensions/orchestrator/pp-state-tools.ts +249 -0
  52. package/extensions/orchestrator/rate-limit-fallback-flow.test.ts +128 -0
  53. package/extensions/orchestrator/rate-limit-fallback.test.ts +98 -0
  54. package/extensions/orchestrator/rate-limit-fallback.ts +243 -0
  55. package/extensions/orchestrator/test-helpers.ts +4 -1
  56. package/extensions/orchestrator/usage-tracker.ts +5 -1
  57. package/extensions/orchestrator/validate-artifacts.test.ts +20 -0
  58. package/extensions/orchestrator/validate-artifacts.ts +2 -2
  59. package/package.json +1 -1
@@ -27,7 +27,8 @@ import {
27
27
  } from "./context.js";
28
28
  import { detectDefaultBranch, enterReviewCycle, finalizeReviewCycle } from "./event-handlers.js";
29
29
  import { Orchestrator } from "./orchestrator.js";
30
- import { cancelPendingPlannotatorWait } from "./plannotator.js";
30
+ import { cancelPendingPlannotatorWait, openPlannotator, waitForPlannotatorResult } from "./plannotator.js";
31
+ import { advanceBanner } from "./messages.js";
31
32
  import { spawnPlanners, spawnPlanReviewers } from "./phases/planning.js";
32
33
  import { spawnCodeReviewers } from "./phases/review.js";
33
34
  import { spawnBrainstormReviewers } from "./phases/brainstorm.js";
@@ -225,6 +226,44 @@ async function pickCommitForRepo(orchestrator: Orchestrator, ctx: any, repo: Rep
225
226
  return pickedHash || null;
226
227
  }
227
228
 
229
+ async function repoHasReviewableChanges(
230
+ orchestrator: Orchestrator,
231
+ repo: RepoInfo,
232
+ base: string,
233
+ ): Promise<{ changed: boolean; error?: string }> {
234
+ const run = async (args: string[]): Promise<{ out: string; failed: boolean }> => {
235
+ try {
236
+ const res = await orchestrator.pi.exec("git", args, { cwd: repo.path, timeout: 5000 });
237
+ if (res.code !== 0) return { out: "", failed: true };
238
+ return { out: res.stdout, failed: false };
239
+ } catch {
240
+ return { out: "", failed: true };
241
+ }
242
+ };
243
+
244
+ const committed = await run(["diff", "--name-only", `${base}...HEAD`]);
245
+ const unstaged = await run(["diff", "--name-only"]);
246
+ const staged = await run(["diff", "--cached", "--name-only"]);
247
+ const status = await run(["status", "--porcelain"]);
248
+
249
+ const hasCommitted = committed.out.trim().length > 0;
250
+ const hasUnstaged = unstaged.out.trim().length > 0;
251
+ const hasStaged = staged.out.trim().length > 0;
252
+ const hasUntracked = status.out.split("\n").some((line) => line.startsWith("??"));
253
+ const changed = hasCommitted || hasUnstaged || hasStaged || hasUntracked;
254
+
255
+ if (changed) return { changed: true };
256
+
257
+ // No change detected — but a failed probe (e.g. a missing/invalid base ref)
258
+ // could be masking real changes, so surface it rather than silently hiding
259
+ // the repo.
260
+ if (committed.failed || unstaged.failed || staged.failed || status.failed) {
261
+ return { changed: false, error: "git status/diff failed" };
262
+ }
263
+
264
+ return { changed: false };
265
+ }
266
+
228
267
  interface RepoPrContext {
229
268
  repoPath: string;
230
269
  prUrl: string | null;
@@ -252,6 +291,11 @@ function showStatus(orchestrator: Orchestrator, ctx: any): void {
252
291
  }
253
292
 
254
293
  async function abortCurrentWork(orchestrator: Orchestrator, ctx: any): Promise<void> {
294
+ // Clear pi-pi's own delayed post-error retry (timer + ESC interrupt). Unlike
295
+ // pause/finish, abortCurrentWork does NOT go through cleanupActive/
296
+ // resetTaskScopedState, so without this a scheduled retry would survive the
297
+ // abort and re-nudge seconds later.
298
+ orchestrator.cancelPendingRetry();
255
299
  orchestrator.abortAllSubagents();
256
300
  orchestrator.transitionController.abortMainAgent(ctx.abort?.bind(ctx));
257
301
  await ctx.waitForIdle?.();
@@ -795,6 +839,9 @@ function collectRoleAssignments(config: Partial<PiPiConfig> | null): string[] {
795
839
  add("agents.subagents.simple.explore", config.agents?.subagents?.simple?.explore?.model);
796
840
  add("agents.subagents.simple.librarian", config.agents?.subagents?.simple?.librarian?.model);
797
841
  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);
798
845
  return out;
799
846
  }
800
847
 
@@ -814,6 +861,7 @@ function flantStatusText(settings: FlantSettings): string {
814
861
  lines.push(
815
862
  `Personal subscription: on (${subActive ? `active — pp-flant-anthropic-sub, ${providers.anthropic} models` : "inactive"})`,
816
863
  );
864
+ lines.push(`Rate-limit switch-back check: every ${settings.switchBackIntervalMinutes} min`);
817
865
  if (!subActive) {
818
866
  if (!hasOAuth) lines.push(" - missing Claude OAuth token (run pi /login for Anthropic)");
819
867
  if (!hasGatewayKey) lines.push(" - missing gateway key (set LLM_API_KEY or FLANT_API_KEY)");
@@ -849,15 +897,22 @@ async function showFlantInfraMenu(orchestrator: Orchestrator, ctx: any): Promise
849
897
  while (true) {
850
898
  const settings = loadFlantSettings();
851
899
  const enableLabel = `Enable: ${settings.enabled ? "ON" : "OFF"}`;
852
- const options: OptionInput[] = [enableLabel];
900
+ const options: OptionInput[] = [
901
+ { title: enableLabel, description: "Turn the Flant AI model providers on or off" },
902
+ ];
853
903
  const subscriptionLabel = `Personal Claude subscription: ${settings.subscription ? "ON" : "OFF"}`;
854
904
  if (settings.enabled) {
855
905
  options.push(
856
- `Auto-update on startup: ${settings.autoUpdate ? "ON" : "OFF"}`,
857
- `Cache period: ${settings.cacheTTLDays} ${settings.cacheTTLDays === 1 ? "day" : "days"}`,
858
- subscriptionLabel,
859
- "Update now",
860
- "Current status",
906
+ { title: subscriptionLabel, description: "Route Claude roles through your personal Claude subscription instead of the gateway" },
907
+ { title: `Auto-update on startup: ${settings.autoUpdate ? "ON" : "OFF"}`, description: "Refresh the available model list automatically each time pi starts" },
908
+ { title: `Cache period: ${settings.cacheTTLDays} ${settings.cacheTTLDays === 1 ? "day" : "days"}`, description: "How long the fetched model list is reused before it is refreshed" },
909
+ );
910
+ if (settings.subscription) {
911
+ options.push({ title: `Rate-limit switch-back check: every ${settings.switchBackIntervalMinutes} min`, description: "How often to retry your subscription after it was rate-limited and traffic fell back to the gateway" });
912
+ }
913
+ options.push(
914
+ { title: "Update now", description: "Fetch the latest model list from Flant right away" },
915
+ { title: "Current status", description: "Show the current Flant configuration, providers, and model counts" },
861
916
  );
862
917
  }
863
918
  options.push({ title: "Back", description: "Return to the previous menu" });
@@ -920,6 +975,22 @@ async function showFlantInfraMenu(orchestrator: Orchestrator, ctx: any): Promise
920
975
  continue;
921
976
  }
922
977
 
978
+ if (choice.startsWith("Rate-limit switch-back check:")) {
979
+ const selected = await selectOption(ctx, "Switch-back check interval", [
980
+ { title: "15 min", description: "Probe the subscription limit every 15 minutes" },
981
+ { title: "30 min", description: "Default — probe every 30 minutes" },
982
+ { title: "60 min", description: "Probe hourly" },
983
+ { title: "120 min", description: "Probe every two hours" },
984
+ { title: "Back", description: "Return to the previous menu" },
985
+ ]);
986
+ if (!selected || selected === "Back") continue;
987
+ const mins = Number(selected.split(" ")[0]);
988
+ if (!Number.isFinite(mins) || mins <= 0) continue;
989
+ saveFlantSettings({ ...settings, switchBackIntervalMinutes: mins });
990
+ ctx.ui.notify(`Switch-back check interval set to ${mins} min.`, "info");
991
+ continue;
992
+ }
993
+
923
994
  if (choice.startsWith("Cache period:")) {
924
995
  const selected = await selectOption(ctx, "Cache period", [
925
996
  { title: "1 day", description: "Refresh model metadata daily" },
@@ -1199,6 +1270,9 @@ const SUBAGENT_ROLES: Array<{ role: AgentRole; label: string; description: strin
1199
1270
  { role: "explore", label: "Explore", description: "agents.subagents.simple.explore" },
1200
1271
  { role: "librarian", label: "Librarian", description: "agents.subagents.simple.librarian" },
1201
1272
  { 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" },
1202
1276
  ];
1203
1277
 
1204
1278
  const PRESET_GROUP_ITEMS: Array<{ group: PresetGroup; label: string }> = [
@@ -1924,7 +1998,7 @@ async function showPresetVariantEditor(
1924
1998
  continue;
1925
1999
  }
1926
2000
  if (choice.startsWith("Enabled:")) {
1927
- await showBooleanSetting(orchestrator, ctx, "Enabled", [...variantPath, "enabled"]);
2001
+ await showBooleanSetting(orchestrator, ctx, "Enabled", [...variantPath, "enabled"], "Make this agent available for use", "Disable this agent so it is not used");
1928
2002
  continue;
1929
2003
  }
1930
2004
  const confirm = await selectOption(ctx, "Confirm delete?", [
@@ -2065,8 +2139,8 @@ async function showPresetEnabledSetting(
2065
2139
  const yesTitle = withTags("Yes", formatSourceTags(true, info));
2066
2140
  const noTitle = withTags("No", formatSourceTags(false, info));
2067
2141
  const choice = await selectOption(ctx, "Enabled", [
2068
- yesTitle,
2069
- noTitle,
2142
+ { title: yesTitle, description: "Make this preset available for selection" },
2143
+ { title: noTitle, description: "Hide this preset so it can no longer be selected" },
2070
2144
  opt("Back", "Return to the previous menu"),
2071
2145
  ]);
2072
2146
  if (!choice || choice === "Back") return;
@@ -2247,7 +2321,7 @@ async function showAfterEditCommands(orchestrator: Orchestrator, ctx: any): Prom
2247
2321
  continue;
2248
2322
  }
2249
2323
  if (commandChoice.startsWith("Enabled:")) {
2250
- await showBooleanSetting(orchestrator, ctx, "Enabled", [...commandPath, "enabled"]);
2324
+ await showBooleanSetting(orchestrator, ctx, "Enabled", [...commandPath, "enabled"], "Run this command when its triggers fire", "Keep this command configured but stop running it");
2251
2325
  continue;
2252
2326
  }
2253
2327
  if (commandChoice === "Triggers") {
@@ -2364,7 +2438,7 @@ async function showAfterImplementCommands(orchestrator: Orchestrator, ctx: any):
2364
2438
  continue;
2365
2439
  }
2366
2440
  if (commandChoice.startsWith("Enabled:")) {
2367
- await showBooleanSetting(orchestrator, ctx, "Enabled", [...commandPath, "enabled"]);
2441
+ await showBooleanSetting(orchestrator, ctx, "Enabled", [...commandPath, "enabled"], "Run this command when its triggers fire", "Keep this command configured but stop running it");
2368
2442
  continue;
2369
2443
  }
2370
2444
  const confirm = await selectOption(ctx, "Confirm delete?", [
@@ -2487,14 +2561,16 @@ async function showBooleanSetting(
2487
2561
  ctx: any,
2488
2562
  title: string,
2489
2563
  keyPath: string[],
2564
+ yesDescription = "Turn this setting on",
2565
+ noDescription = "Turn this setting off",
2490
2566
  ): Promise<void> {
2491
2567
  while (true) {
2492
2568
  const info = getConfigSourceInfo(orchestrator, keyPath);
2493
2569
  const yesTitle = withTags("Yes", formatSourceTags(true, info));
2494
2570
  const noTitle = withTags("No", formatSourceTags(false, info));
2495
2571
  const choice = await selectOption(ctx, title, [
2496
- yesTitle,
2497
- noTitle,
2572
+ { title: yesTitle, description: yesDescription },
2573
+ { title: noTitle, description: noDescription },
2498
2574
  ...buildResetOptions(orchestrator, keyPath),
2499
2575
  opt("Back", "Return to the previous menu"),
2500
2576
  ]);
@@ -2516,14 +2592,16 @@ async function showInvertedBooleanSetting(
2516
2592
  ctx: any,
2517
2593
  title: string,
2518
2594
  keyPath: string[],
2595
+ yesDescription = "Turn this setting on",
2596
+ noDescription = "Turn this setting off",
2519
2597
  ): Promise<void> {
2520
2598
  while (true) {
2521
2599
  const info = getConfigSourceInfo(orchestrator, keyPath);
2522
2600
  const yesTitle = withTags("Yes", formatSourceTags(false, info));
2523
2601
  const noTitle = withTags("No", formatSourceTags(true, info));
2524
2602
  const choice = await selectOption(ctx, title, [
2525
- yesTitle,
2526
- noTitle,
2603
+ { title: yesTitle, description: yesDescription },
2604
+ { title: noTitle, description: noDescription },
2527
2605
  ...buildResetOptions(orchestrator, keyPath),
2528
2606
  opt("Back", "Return to the previous menu"),
2529
2607
  ]);
@@ -2541,15 +2619,15 @@ async function showInvertedBooleanSetting(
2541
2619
  }
2542
2620
 
2543
2621
  async function showLogLevelSetting(orchestrator: Orchestrator, ctx: any): Promise<void> {
2544
- const levels: Array<{ value: PiPiConfig["general"]["logLevel"]; label: string }> = [
2545
- { value: "debug", label: "Debug" },
2546
- { value: "info", label: "Info" },
2547
- { value: "warn", label: "Warning" },
2548
- { value: "error", label: "Error" },
2622
+ const levels: Array<{ value: PiPiConfig["general"]["logLevel"]; label: string; description: string }> = [
2623
+ { value: "debug", label: "Debug", description: "Log everything, including detailed diagnostics for troubleshooting" },
2624
+ { value: "info", label: "Info", description: "Log normal activity plus warnings and errors" },
2625
+ { value: "warn", label: "Warning", description: "Log only warnings and errors" },
2626
+ { value: "error", label: "Error", description: "Log only errors" },
2549
2627
  ];
2550
2628
  while (true) {
2551
2629
  const info = getConfigSourceInfo(orchestrator, ["general", "logLevel"]);
2552
- const options: OptionInput[] = levels.map((entry) => withTags(entry.label, formatSourceTags(entry.value, info)));
2630
+ const options: OptionInput[] = levels.map((entry) => ({ title: withTags(entry.label, formatSourceTags(entry.value, info)), description: entry.description }));
2553
2631
  options.push(...buildResetOptions(orchestrator, ["general", "logLevel"]));
2554
2632
  options.push(opt("Back", "Return to the previous menu"));
2555
2633
  const choice = await selectOption(ctx, "Log level", options);
@@ -2579,11 +2657,11 @@ async function showGeneralSettings(orchestrator: Orchestrator, ctx: any): Promis
2579
2657
  ]);
2580
2658
  if (!choice || choice === "Back") return BACK;
2581
2659
  if (choice.startsWith("Commit automatically:")) {
2582
- await showBooleanSetting(orchestrator, ctx, "Commit automatically", ["general", "autoCommit"]);
2660
+ await showBooleanSetting(orchestrator, ctx, "Commit automatically", ["general", "autoCommit"], "Commit changes automatically as work progresses", "Leave committing to you");
2583
2661
  continue;
2584
2662
  }
2585
2663
  if (choice.startsWith("Ignore configs from other repos:")) {
2586
- await showInvertedBooleanSetting(orchestrator, ctx, "Ignore configs from other repos", ["general", "loadExtraRepoConfigs"]);
2664
+ await showInvertedBooleanSetting(orchestrator, ctx, "Ignore configs from other repos", ["general", "loadExtraRepoConfigs"], "Use only the root repo config and ignore configs from other registered repos", "Also load configs from the other registered repos");
2587
2665
  continue;
2588
2666
  }
2589
2667
  if (choice.startsWith("Log level:")) {
@@ -2591,7 +2669,7 @@ async function showGeneralSettings(orchestrator: Orchestrator, ctx: any): Promis
2591
2669
  continue;
2592
2670
  }
2593
2671
  if (choice.startsWith("Tracing:")) {
2594
- await showBooleanSetting(orchestrator, ctx, "Tracing", ["general", "tracing"]);
2672
+ await showBooleanSetting(orchestrator, ctx, "Tracing", ["general", "tracing"], "Capture full session traces to .pp/logs/traces/", "Do not record session traces");
2595
2673
  continue;
2596
2674
  }
2597
2675
  await showFlantInfraMenu(orchestrator, ctx);
@@ -3056,30 +3134,24 @@ async function openCodeReviewInPlannotator(
3056
3134
  if (payload.diffType) requestPayload.diffType = payload.diffType;
3057
3135
  if (payload.defaultBranch) requestPayload.defaultBranch = payload.defaultBranch;
3058
3136
 
3059
- return await new Promise((resolve) => {
3060
- let handled = false;
3061
- const timer = setTimeout(() => {
3062
- if (!handled) resolve({ status: "error", error: "Plannotator is not available." });
3063
- }, 30000);
3064
- orchestrator.pi.events.emit("plannotator:request", {
3065
- requestId: crypto.randomUUID(),
3066
- action: "code-review",
3067
- payload: requestPayload,
3068
- respond: (response: any) => {
3069
- handled = true;
3070
- clearTimeout(timer);
3071
- if (response?.status !== "handled") {
3072
- resolve({ status: "error", error: response?.error || "Plannotator is not available." });
3073
- return;
3074
- }
3075
- const approved = !!response?.result?.approved;
3076
- const feedback = typeof response?.result?.feedback === "string" && response.result.feedback.trim().length > 0
3077
- ? response.result.feedback
3078
- : undefined;
3079
- resolve({ status: approved ? "approved" : "needs_changes", feedback });
3080
- },
3081
- });
3082
- });
3137
+ const { opened, reviewId } = await openPlannotator(orchestrator.pi, "code-review", requestPayload);
3138
+ if (!opened) {
3139
+ return { status: "error", error: "Plannotator is not available." };
3140
+ }
3141
+
3142
+ let result: { approved: boolean; feedback?: string; error?: string };
3143
+ try {
3144
+ result = await waitForPlannotatorResult(orchestrator, reviewId, null);
3145
+ } catch (err) {
3146
+ return { status: "error", error: err instanceof Error ? err.message : "Plannotator review failed." };
3147
+ }
3148
+ if (result.error) {
3149
+ return { status: "error", error: result.error };
3150
+ }
3151
+ const feedback = typeof result.feedback === "string" && result.feedback.trim().length > 0
3152
+ ? result.feedback
3153
+ : undefined;
3154
+ return { status: result.approved ? "approved" : "needs_changes", feedback };
3083
3155
  }
3084
3156
 
3085
3157
  async function startReviewTask(
@@ -3435,7 +3507,7 @@ export async function showActiveTaskMenu(
3435
3507
  summary: string,
3436
3508
  mode: MenuMode = "command",
3437
3509
  ): Promise<string> {
3438
- const continueMessage = "User wants to continue. Run /pp when ready to advance.";
3510
+ const continueMessage = advanceBanner("[PI-PI] User wants to continue. Run /pp when ready to advance.");
3439
3511
 
3440
3512
  while (true) {
3441
3513
  if (!orchestrator.active) return "No active task.";
@@ -3575,7 +3647,9 @@ export async function showActiveTaskMenu(
3575
3647
  if (hasPlannotator) {
3576
3648
  reviewOptions.push(opt("Review in Plannotator", phase === "plan" ? "Open plan review in browser" : "Open code diff review in browser"));
3577
3649
  }
3578
- reviewOptions.push(opt("Review on my own", "Review manually, then continue"));
3650
+ 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"));
3579
3653
  reviewOptions.push(opt("Back", "Return to the previous menu"));
3580
3654
 
3581
3655
  const reviewChoice = await selectOption(ctx, "Review", reviewOptions);
@@ -3591,8 +3665,23 @@ export async function showActiveTaskMenu(
3591
3665
  if (handled.continueLoop) continue;
3592
3666
  return handled.text ?? text;
3593
3667
  }
3594
- const repos = getRegisteredRepos(orchestrator);
3668
+ const allRepos = getRegisteredRepos(orchestrator);
3595
3669
  const summaries: string[] = [];
3670
+ const repos: RepoInfo[] = [];
3671
+ for (const repo of allRepos) {
3672
+ const base = await detectDefaultBranch(orchestrator, allRepos, repo.path);
3673
+ 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
+ }
3680
+ }
3681
+ if (repos.length === 0) {
3682
+ ctx.ui.notify("No registered repositories have changes to review.", "info");
3683
+ continue;
3684
+ }
3596
3685
  let stopReviewing = false;
3597
3686
 
3598
3687
  for (const repo of repos) {
@@ -3661,10 +3750,35 @@ export async function showActiveTaskMenu(
3661
3750
  if (reviewChoice === "Review on my own") {
3662
3751
  if (phase === "plan") {
3663
3752
  setStep(orchestrator, "synthesize");
3664
- } else {
3753
+ return continueMessage;
3754
+ }
3755
+ if (phase !== "implement") {
3665
3756
  setStep(orchestrator, "llm_work");
3757
+ return continueMessage;
3666
3758
  }
3667
- return continueMessage;
3759
+
3760
+ const gate = await selectOption(ctx, "Editor review", [
3761
+ opt("Done", "I've added AI_REVIEW: markers and saved my files"),
3762
+ opt("Skip markers", "Continue without the marker workflow"),
3763
+ opt("Back", "Return to the review menu"),
3764
+ ]);
3765
+ if (!gate || gate === "Back") continue;
3766
+ setStep(orchestrator, "llm_work");
3767
+ if (gate === "Skip markers") {
3768
+ return continueMessage;
3769
+ }
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
+ );
3668
3782
  }
3669
3783
 
3670
3784
  const reviewPreset = await pickPreset(ctx, orchestrator, getReviewPresetGroup(phase), "Review preset");
@@ -0,0 +1,192 @@
1
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+
6
+ vi.mock("./log.js", () => ({
7
+ getLogger: () => ({ debug: vi.fn(), warn: vi.fn(), error: vi.fn(), info: vi.fn() }),
8
+ }));
9
+
10
+ import { registerStateFileTools } from "./pp-state-tools.js";
11
+
12
+ const tempDirs: string[] = [];
13
+
14
+ function makeTempCwd(): string {
15
+ const dir = mkdtempSync(join(tmpdir(), "pi-pi-state-tools-"));
16
+ tempDirs.push(dir);
17
+ return dir;
18
+ }
19
+
20
+ afterEach(() => {
21
+ for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
22
+ });
23
+
24
+ interface RegisteredTool {
25
+ name: string;
26
+ execute: (id: string, params: any) => Promise<any>;
27
+ }
28
+
29
+ function setup() {
30
+ const cwd = makeTempCwd();
31
+ const taskDir = join(cwd, ".pp", "state", "brainstorm", "abc_brainstorm");
32
+ mkdirSync(join(taskDir, "artifacts"), { recursive: true });
33
+ mkdirSync(join(taskDir, "plans"), { recursive: true });
34
+
35
+ const tools = new Map<string, RegisteredTool>();
36
+ const orchestrator: any = {
37
+ cwd,
38
+ active: { dir: taskDir },
39
+ pi: { registerTool: (t: RegisteredTool) => tools.set(t.name, t) },
40
+ };
41
+ registerStateFileTools(orchestrator);
42
+ return { cwd, taskDir, orchestrator, tools };
43
+ }
44
+
45
+ function textOf(result: any): string {
46
+ return (result.content ?? []).map((c: any) => c.text).join("\n");
47
+ }
48
+
49
+ const VALID_RESEARCH = [
50
+ "## Affected Code",
51
+ "- foo.ts:bar — does a thing",
52
+ "## Architecture Context",
53
+ "- how it connects",
54
+ "## Constraints & Edge Cases",
55
+ "- MUST: keep it working",
56
+ ].join("\n");
57
+
58
+ describe("pp_write_state_file", () => {
59
+ it("creates a valid RESEARCH.md and returns compact output (no diff)", async () => {
60
+ const { taskDir, tools } = setup();
61
+ const res = await tools.get("pp_write_state_file")!.execute("1", { path: "RESEARCH.md", content: VALID_RESEARCH });
62
+ expect(res.isError).toBeFalsy();
63
+ expect(res.details).toEqual({});
64
+ expect(textOf(res)).toMatch(/^Created RESEARCH\.md \(\+\d+\/-\d+ lines\)$/);
65
+ expect(textOf(res)).not.toContain("Affected Code");
66
+ expect(readFileSync(join(taskDir, "RESEARCH.md"), "utf-8")).toBe(VALID_RESEARCH);
67
+ });
68
+
69
+ it("reports 'Updated' on overwrite", async () => {
70
+ const { tools } = setup();
71
+ await tools.get("pp_write_state_file")!.execute("1", { path: "RESEARCH.md", content: VALID_RESEARCH });
72
+ const res = await tools.get("pp_write_state_file")!.execute("2", { path: "RESEARCH.md", content: VALID_RESEARCH + "\n- more" });
73
+ expect(textOf(res)).toMatch(/^Updated RESEARCH\.md/);
74
+ });
75
+
76
+ it("rejects invalid RESEARCH.md structure without writing", async () => {
77
+ const { taskDir, tools } = setup();
78
+ const res = await tools.get("pp_write_state_file")!.execute("1", { path: "RESEARCH.md", content: "## Wrong Section\nx" });
79
+ expect(res.isError).toBe(true);
80
+ expect(textOf(res)).toContain("RESEARCH.md structure is invalid");
81
+ expect(() => readFileSync(join(taskDir, "RESEARCH.md"), "utf-8")).toThrow();
82
+ });
83
+
84
+ it("accepts an artifact starting with a top-level heading", async () => {
85
+ const { tools } = setup();
86
+ const res = await tools.get("pp_write_state_file")!.execute("1", { path: "artifacts/design.md", content: "# Design\n\nbody" });
87
+ expect(res.isError).toBeFalsy();
88
+ expect(textOf(res)).toMatch(/artifacts\/design\.md/);
89
+ });
90
+
91
+ it("rejects a path escaping the task dir", async () => {
92
+ const { tools } = setup();
93
+ const res = await tools.get("pp_write_state_file")!.execute("1", { path: "../../../evil.md", content: "# x" });
94
+ expect(res.isError).toBe(true);
95
+ expect(textOf(res)).toMatch(/escapes the active task directory/);
96
+ });
97
+
98
+ it("rejects a non-.md file", async () => {
99
+ const { tools } = setup();
100
+ const res = await tools.get("pp_write_state_file")!.execute("1", { path: "notes.txt", content: "x" });
101
+ expect(res.isError).toBe(true);
102
+ expect(textOf(res)).toMatch(/Only \.md/);
103
+ });
104
+
105
+ it("rejects managed review outputs and non-synthesized plans", async () => {
106
+ const { tools } = setup();
107
+ for (const p of [
108
+ "code-reviews/1_gpt.md",
109
+ "brainstorm-reviews/1_gemini.md",
110
+ "plan-reviews/1_opus.md",
111
+ "plans/1_gpt.md",
112
+ "scratch.md",
113
+ "artifacts/nested/deep.md",
114
+ ]) {
115
+ const res = await tools.get("pp_write_state_file")!.execute("1", { path: p, content: "# x" });
116
+ expect(res.isError, `expected ${p} to be rejected`).toBe(true);
117
+ expect(textOf(res)).toMatch(/Not an editable state file/);
118
+ }
119
+ });
120
+
121
+ it("allows the synthesized plan", async () => {
122
+ const { tools } = setup();
123
+ const plan = [
124
+ "# Plan",
125
+ "## Scope",
126
+ "Do the thing.",
127
+ "## Checklist",
128
+ "- [ ] item — Done when: it works",
129
+ ].join("\n");
130
+ const res = await tools.get("pp_write_state_file")!.execute("1", { path: "plans/123_synthesized.md", content: plan });
131
+ expect(res.isError).toBeFalsy();
132
+ expect(textOf(res)).toMatch(/plans\/123_synthesized\.md/);
133
+ });
134
+ });
135
+
136
+ describe("pp_edit_state_file", () => {
137
+ beforeEach(() => {});
138
+
139
+ it("replaces a unique span and returns compact output", async () => {
140
+ const { taskDir, tools } = setup();
141
+ writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
142
+ const res = await tools.get("pp_edit_state_file")!.execute("1", {
143
+ path: "RESEARCH.md",
144
+ oldText: "does a thing",
145
+ newText: "does a different thing",
146
+ });
147
+ expect(res.isError).toBeFalsy();
148
+ expect(textOf(res)).toMatch(/^Updated RESEARCH\.md \(\+\d+\/-\d+ lines\)$/);
149
+ expect(readFileSync(join(taskDir, "RESEARCH.md"), "utf-8")).toContain("does a different thing");
150
+ });
151
+
152
+ it("errors when oldText is not found", async () => {
153
+ const { taskDir, tools } = setup();
154
+ writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
155
+ const res = await tools.get("pp_edit_state_file")!.execute("1", { path: "RESEARCH.md", oldText: "nope", newText: "x" });
156
+ expect(res.isError).toBe(true);
157
+ expect(textOf(res)).toMatch(/not found/);
158
+ });
159
+
160
+ it("errors on ambiguous oldText unless replaceAll", async () => {
161
+ const { taskDir, tools } = setup();
162
+ // Use an artifact (an allowed, unstructured-body state file) with duplicate spans.
163
+ writeFileSync(join(taskDir, "artifacts", "dup.md"), "# Dup\nx\nx\n", "utf-8");
164
+ const ambiguous = await tools.get("pp_edit_state_file")!.execute("1", { path: "artifacts/dup.md", oldText: "x", newText: "y" });
165
+ expect(ambiguous.isError).toBe(true);
166
+ expect(textOf(ambiguous)).toMatch(/matches 2 locations/);
167
+
168
+ const all = await tools.get("pp_edit_state_file")!.execute("2", { path: "artifacts/dup.md", oldText: "x", newText: "y", replaceAll: true });
169
+ expect(all.isError).toBeFalsy();
170
+ expect(readFileSync(join(taskDir, "artifacts", "dup.md"), "utf-8")).toBe("# Dup\ny\ny\n");
171
+ });
172
+
173
+ it("errors when the file does not exist", async () => {
174
+ const { tools } = setup();
175
+ const res = await tools.get("pp_edit_state_file")!.execute("1", { path: "RESEARCH.md", oldText: "a", newText: "b" });
176
+ expect(res.isError).toBe(true);
177
+ expect(textOf(res)).toMatch(/does not exist/);
178
+ });
179
+
180
+ it("rejects an edit that breaks structure without writing", async () => {
181
+ const { taskDir, tools } = setup();
182
+ writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
183
+ const res = await tools.get("pp_edit_state_file")!.execute("1", {
184
+ path: "RESEARCH.md",
185
+ oldText: "## Affected Code",
186
+ newText: "## Renamed Section",
187
+ });
188
+ expect(res.isError).toBe(true);
189
+ expect(textOf(res)).toContain("RESEARCH.md structure is invalid");
190
+ expect(readFileSync(join(taskDir, "RESEARCH.md"), "utf-8")).toContain("## Affected Code");
191
+ });
192
+ });