@ilya-lesikov/pi-pi 0.6.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 (66) hide show
  1. package/3p/pi-ask-user/index.ts +1 -1
  2. package/3p/pi-plannotator/apps/pi-extension/README.md +6 -5
  3. package/3p/pi-plannotator/apps/pi-extension/plannotator-browser.ts +25 -10
  4. package/3p/pi-plannotator/apps/pi-extension/plannotator-events.code-review.test.ts +172 -0
  5. package/3p/pi-plannotator/apps/pi-extension/plannotator-events.ts +50 -12
  6. package/3p/pi-plannotator/apps/pi-extension/server/project.test.ts +45 -0
  7. package/3p/pi-plannotator/apps/pi-extension/server/project.ts +7 -6
  8. package/3p/pi-plannotator/apps/pi-extension/server/serverReview.ts +41 -25
  9. package/3p/pi-subagents/src/agent-manager.ts +34 -1
  10. package/3p/pi-subagents/src/agent-runner.ts +66 -33
  11. package/3p/pi-subagents/src/index.ts +3 -38
  12. package/3p/pi-subagents/src/types.ts +4 -0
  13. package/extensions/orchestrator/agents/advisor.ts +35 -0
  14. package/extensions/orchestrator/agents/brainstorm-reviewer.ts +7 -7
  15. package/extensions/orchestrator/agents/code-reviewer.ts +7 -7
  16. package/extensions/orchestrator/agents/constraints.test.ts +44 -0
  17. package/extensions/orchestrator/agents/constraints.ts +3 -0
  18. package/extensions/orchestrator/agents/deep-debugger.ts +35 -0
  19. package/extensions/orchestrator/agents/plan-reviewer.ts +7 -7
  20. package/extensions/orchestrator/agents/planner.ts +9 -8
  21. package/extensions/orchestrator/agents/prompts.test.ts +120 -0
  22. package/extensions/orchestrator/agents/reviewer.ts +48 -0
  23. package/extensions/orchestrator/agents/task.ts +6 -20
  24. package/extensions/orchestrator/agents/tool-routing.ts +23 -1
  25. package/extensions/orchestrator/command-handlers.ts +1 -1
  26. package/extensions/orchestrator/config.ts +8 -4
  27. package/extensions/orchestrator/context.test.ts +54 -0
  28. package/extensions/orchestrator/context.ts +65 -2
  29. package/extensions/orchestrator/custom-footer.ts +5 -2
  30. package/extensions/orchestrator/doctor.test.ts +3 -1
  31. package/extensions/orchestrator/doctor.ts +40 -2
  32. package/extensions/orchestrator/event-handlers.test.ts +97 -1
  33. package/extensions/orchestrator/event-handlers.ts +222 -48
  34. package/extensions/orchestrator/flant-infra.test.ts +312 -0
  35. package/extensions/orchestrator/flant-infra.ts +407 -44
  36. package/extensions/orchestrator/index.ts +1 -1
  37. package/extensions/orchestrator/integration.test.ts +312 -18
  38. package/extensions/orchestrator/messages.test.ts +30 -0
  39. package/extensions/orchestrator/messages.ts +6 -0
  40. package/extensions/orchestrator/model-registry.test.ts +124 -13
  41. package/extensions/orchestrator/model-registry.ts +91 -33
  42. package/extensions/orchestrator/orchestrator.test.ts +113 -0
  43. package/extensions/orchestrator/orchestrator.ts +163 -3
  44. package/extensions/orchestrator/phases/brainstorm.ts +9 -20
  45. package/extensions/orchestrator/phases/implementation.test.ts +11 -0
  46. package/extensions/orchestrator/phases/implementation.ts +4 -6
  47. package/extensions/orchestrator/phases/planning.test.ts +16 -0
  48. package/extensions/orchestrator/phases/planning.ts +11 -4
  49. package/extensions/orchestrator/phases/review-task.ts +1 -4
  50. package/extensions/orchestrator/phases/review.test.ts +62 -0
  51. package/extensions/orchestrator/phases/review.ts +58 -15
  52. package/extensions/orchestrator/plannotator.ts +9 -6
  53. package/extensions/orchestrator/pp-menu.test.ts +74 -1
  54. package/extensions/orchestrator/pp-menu.ts +366 -94
  55. package/extensions/orchestrator/pp-state-tools.test.ts +192 -0
  56. package/extensions/orchestrator/pp-state-tools.ts +249 -0
  57. package/extensions/orchestrator/rate-limit-fallback-flow.test.ts +128 -0
  58. package/extensions/orchestrator/rate-limit-fallback.test.ts +98 -0
  59. package/extensions/orchestrator/rate-limit-fallback.ts +243 -0
  60. package/extensions/orchestrator/state.ts +41 -20
  61. package/extensions/orchestrator/test-helpers.ts +18 -3
  62. package/extensions/orchestrator/usage-tracker.test.ts +131 -3
  63. package/extensions/orchestrator/usage-tracker.ts +78 -11
  64. package/extensions/orchestrator/validate-artifacts.test.ts +20 -0
  65. package/extensions/orchestrator/validate-artifacts.ts +2 -2
  66. package/package.json +1 -1
@@ -63,12 +63,16 @@ vi.mock("./config.js", async (importOriginal) => {
63
63
  debug: { model: "test/model", thinking: "high" },
64
64
  brainstorm: { model: "test/model", thinking: "high" },
65
65
  review: { model: "test/model", thinking: "high" },
66
+ quick: { model: "test/model", thinking: "high" },
66
67
  },
67
68
  subagents: {
68
69
  simple: {
69
70
  explore: { model: "test/explore", thinking: "low" },
70
71
  librarian: { model: "test/librarian", thinking: "medium" },
71
72
  task: { model: "test/task", thinking: "medium" },
73
+ advisor: { model: "test/advisor", thinking: "high" },
74
+ "deep-debugger": { model: "test/deep-debugger", thinking: "high" },
75
+ reviewer: { model: "test/reviewer", thinking: "high" },
72
76
  },
73
77
  presetGroups: {
74
78
  planners: {
@@ -215,12 +219,16 @@ function makeConfig() {
215
219
  debug: { model: "test/model", thinking: "high" },
216
220
  brainstorm: { model: "test/model", thinking: "high" },
217
221
  review: { model: "test/model", thinking: "high" },
222
+ quick: { model: "test/model", thinking: "high" },
218
223
  },
219
224
  subagents: {
220
225
  simple: {
221
226
  explore: { model: "test/explore", thinking: "low" },
222
227
  librarian: { model: "test/librarian", thinking: "medium" },
223
228
  task: { model: "test/task", thinking: "medium" },
229
+ advisor: { model: "test/advisor", thinking: "high" },
230
+ "deep-debugger": { model: "test/deep-debugger", thinking: "high" },
231
+ reviewer: { model: "test/reviewer", thinking: "high" },
224
232
  },
225
233
  presetGroups: {
226
234
  planners: {
@@ -670,7 +678,7 @@ describe("review cycle lifecycle", () => {
670
678
  await new Promise((r) => setTimeout(r, 10));
671
679
 
672
680
  expectReviewAuto(menu);
673
- expectReviewOnMyOwn(menu);
681
+ expectReviewOnMyOwn(menu, "Skip markers");
674
682
  const result = await ppPhaseComplete.execute("call-3", { summary: "implemented" }, undefined, undefined, ctx);
675
683
 
676
684
  expect(result.content[0].text).toContain("continue");
@@ -1353,7 +1361,7 @@ describe("edge cases and regressions", () => {
1353
1361
  await ppPhaseComplete.execute("call-2", { summary: "plan done" }, undefined, undefined, ctx);
1354
1362
  await new Promise((r) => setTimeout(r, 10));
1355
1363
 
1356
- expectReviewOnMyOwn(menu);
1364
+ expectReviewOnMyOwn(menu, "Skip markers");
1357
1365
  const result = await ppPhaseComplete.execute("call-3", { summary: "partial work" }, undefined, undefined, ctx);
1358
1366
 
1359
1367
  expect(result.content[0].text).toContain("continue");
@@ -1400,6 +1408,47 @@ describe("edge cases and regressions", () => {
1400
1408
  expect(orchestrator.active!.state.step).toBe("synthesize");
1401
1409
  });
1402
1410
 
1411
+ it("editor-review Done returns AI_REVIEW marker instructions in implement phase", async () => {
1412
+ const cwd = makeTempDir();
1413
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
1414
+ const ctx = makeCtx();
1415
+
1416
+ await orchestrator.startTask(ctx as any, "implement", "Editor review test");
1417
+ const taskDir = orchestrator.active!.dir;
1418
+ writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
1419
+ writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
1420
+ expectBrainstormToPlan(menu);
1421
+ const ppPhaseComplete = getTool(pi, "pp_phase_complete");
1422
+ await ppPhaseComplete.execute("call-1", { summary: "done" }, undefined, undefined, ctx);
1423
+ await new Promise((r) => setTimeout(r, 10));
1424
+
1425
+ emitSubagentCreated(pi, "planner-1", "Planner (test)");
1426
+ const plansDir = join(taskDir, "plans");
1427
+ mkdirSync(plansDir, { recursive: true });
1428
+ writeFileSync(
1429
+ join(plansDir, `${Math.floor(Date.now() / 1000)}_test.md`),
1430
+ makeValidPlan(["- [ ] P1. Planner draft item — Done when: planner output exists"]),
1431
+ "utf-8",
1432
+ );
1433
+ emitSubagentCompleted(pi, "planner-1", "Planner (test)");
1434
+ writeFileSync(
1435
+ join(plansDir, `${Math.floor(Date.now() / 1000) + 1}_synthesized.md`),
1436
+ makeValidPlan(["- [ ] P1. Todo item — Done when: item intentionally remains unchecked"]),
1437
+ "utf-8",
1438
+ );
1439
+ expectPlanToImplement(menu);
1440
+ await ppPhaseComplete.execute("call-2", { summary: "plan done" }, undefined, undefined, ctx);
1441
+ await new Promise((r) => setTimeout(r, 10));
1442
+
1443
+ expectReviewOnMyOwn(menu, "Done");
1444
+ const result = await ppPhaseComplete.execute("call-3", { summary: "implemented" }, undefined, undefined, ctx);
1445
+
1446
+ expect(result.content[0].text).toContain("AI_REVIEW:");
1447
+ expect(result.content[0].text).toContain("CHANGED files");
1448
+ expect(orchestrator.active!.state.phase).toBe("implement");
1449
+ expect(orchestrator.active!.state.step).toBe("llm_work");
1450
+ });
1451
+
1403
1452
  it("generic description task does not auto-trigger agent", async () => {
1404
1453
  const cwd = makeTempDir();
1405
1454
  const { pi, orchestrator } = await setupOrchestrator(cwd);
@@ -1565,7 +1614,7 @@ describe("task modes and quick task", () => {
1565
1614
  expect(orchestrator.active!.state.autonomousConfig).toBeDefined();
1566
1615
  });
1567
1616
 
1568
- it("autonomous pp_phase_complete auto-advances without opening menu", async () => {
1617
+ it("autonomous pp_phase_complete auto-advances (plan→implement) without opening menu", async () => {
1569
1618
  const cwd = makeTempDir();
1570
1619
  const { pi, orchestrator } = await setupOrchestrator(cwd);
1571
1620
  const ctx = makeCtx();
@@ -1581,16 +1630,115 @@ describe("task modes and quick task", () => {
1581
1630
  };
1582
1631
  writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
1583
1632
  writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
1633
+ // Move to an autonomous-capable phase (plan). brainstorm is always guided
1634
+ // even for an autonomous task, so auto-advance only applies from plan onward.
1635
+ orchestrator.active!.state.phase = "plan";
1636
+ orchestrator.active!.state.step = "llm_work";
1637
+ const plansDir = join(taskDir, "plans");
1638
+ mkdirSync(plansDir, { recursive: true });
1639
+ writeFileSync(
1640
+ join(plansDir, `${Math.floor(Date.now() / 1000)}_synthesized.md`),
1641
+ makeValidPlan([
1642
+ "- [ ] P1. Implement X — Done when: implementation for X is complete",
1643
+ ]),
1644
+ "utf-8",
1645
+ );
1584
1646
 
1585
1647
  const ppPhaseComplete = getTool(pi, "pp_phase_complete");
1586
1648
  const result = await ppPhaseComplete.execute("call-1", { summary: "done" }, undefined, undefined, ctx);
1587
1649
  await new Promise((r) => setTimeout(r, 10));
1588
1650
 
1589
1651
  expect(result.content[0].text).toBe("");
1590
- expect(orchestrator.active!.state.phase).toBe("plan");
1652
+ expect(orchestrator.active!.state.phase).toBe("implement");
1591
1653
  expect(menu.transcript.filter((entry) => entry.question.startsWith("/pp"))).toHaveLength(0);
1592
1654
  });
1593
1655
 
1656
+ it("autonomous task in brainstorm stays guided (pp_phase_complete opens the menu, does NOT auto-advance)", async () => {
1657
+ const cwd = makeTempDir();
1658
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
1659
+ const ctx = makeCtx();
1660
+
1661
+ await orchestrator.startTask(ctx as any, "implement", "implement", undefined, undefined, "autonomous");
1662
+ const taskDir = orchestrator.active!.dir;
1663
+ orchestrator.active!.state.autonomousConfig = {
1664
+ phases: {
1665
+ brainstorm: { reviewPreset: "regular", maxReviewPasses: 0 },
1666
+ plan: { plannerPreset: "regular", reviewPreset: "regular", maxReviewPasses: 0 },
1667
+ implement: { reviewPreset: "regular", maxReviewPasses: 0 },
1668
+ },
1669
+ };
1670
+ writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
1671
+ writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
1672
+ expect(orchestrator.active!.state.phase).toBe("brainstorm");
1673
+
1674
+ // brainstorm is user-driven: pp_phase_complete must fall through to the
1675
+ // guided menu rather than the autonomous auto-advance branch. (Autonomous
1676
+ // tasks skip the planner preset picker on transition, so only the guided
1677
+ // Next→Continue step is expected here.)
1678
+ expectActiveTaskNext(menu, "Continue to plan & implement");
1679
+ const ppPhaseComplete = getTool(pi, "pp_phase_complete");
1680
+ await ppPhaseComplete.execute("call-1", { summary: "done" }, undefined, undefined, ctx);
1681
+ await new Promise((r) => setTimeout(r, 10));
1682
+
1683
+ // The guided menu was shown (auto-advance did NOT bypass it).
1684
+ expect(menu.transcript.filter((entry) => entry.question.startsWith("/pp"))).not.toHaveLength(0);
1685
+ });
1686
+
1687
+ it("ESC on pp_phase_complete stops the turn cleanly (aborts, no reminder text)", async () => {
1688
+ const cwd = makeTempDir();
1689
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
1690
+ const ctx = makeCtx();
1691
+
1692
+ await orchestrator.startTask(ctx as any, "implement", "esc dismiss");
1693
+ // Guided task in brainstorm; transition controller is running so the
1694
+ // non-ESC dismiss path would normally return the reminder text.
1695
+ expect(orchestrator.transitionController.isRunning()).toBe(true);
1696
+
1697
+ // Simulate a deliberate user ESC on the top-level menu.
1698
+ menu.expect({ question: m.anyTaskMenu, cancel: "user" });
1699
+
1700
+ const ppPhaseComplete = getTool(pi, "pp_phase_complete");
1701
+ const result = await ppPhaseComplete.execute("esc-1", { summary: "done" }, undefined, undefined, ctx);
1702
+
1703
+ expect(ctx.abort).toHaveBeenCalled();
1704
+ expect(result.content[0].text).toBe("");
1705
+ });
1706
+
1707
+ it("Back on pp_phase_complete keeps the artifact-update reminder (does NOT abort)", async () => {
1708
+ const cwd = makeTempDir();
1709
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
1710
+ const ctx = makeCtx();
1711
+
1712
+ await orchestrator.startTask(ctx as any, "implement", "back dismiss");
1713
+ expect(orchestrator.transitionController.isRunning()).toBe(true);
1714
+
1715
+ // Explicit "Back" navigation (not an ESC): the reminder must be preserved.
1716
+ menu.expect({ question: m.anyTaskMenu, options: { include: ["Back"] }, choose: "Back" });
1717
+
1718
+ const ppPhaseComplete = getTool(pi, "pp_phase_complete");
1719
+ const result = await ppPhaseComplete.execute("back-1", { summary: "done" }, undefined, undefined, ctx);
1720
+
1721
+ expect(ctx.abort).not.toHaveBeenCalled();
1722
+ expect(result.content[0].text).toContain("update USER_REQUEST.md and RESEARCH.md");
1723
+ });
1724
+
1725
+ it("ESC on pp_phase_complete for a quick task also stops the turn cleanly", async () => {
1726
+ const cwd = makeTempDir();
1727
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
1728
+ const ctx = makeCtx();
1729
+
1730
+ await orchestrator.startTask(ctx as any, "quick", "quick esc");
1731
+ expect(orchestrator.active!.type).toBe("quick");
1732
+
1733
+ menu.expect({ question: m.anyTaskMenu, cancel: "user" });
1734
+
1735
+ const ppPhaseComplete = getTool(pi, "pp_phase_complete");
1736
+ const result = await ppPhaseComplete.execute("quick-esc-1", { summary: "done" }, undefined, undefined, ctx);
1737
+
1738
+ expect(ctx.abort).toHaveBeenCalled();
1739
+ expect(result.content[0].text).toBe("");
1740
+ });
1741
+
1594
1742
  it("autonomous review loop re-runs until cap then advances", async () => {
1595
1743
  const cwd = makeTempDir();
1596
1744
  const { pi, orchestrator } = await setupOrchestrator(cwd);
@@ -1694,6 +1842,52 @@ describe("task modes and quick task", () => {
1694
1842
  expect(finalState.reviewPassByKind?.implement?.auto).toBe(1);
1695
1843
  });
1696
1844
 
1845
+ it("autonomous PLAN review cycle: apply feedback (clean approve) advances to implement", async () => {
1846
+ const cwd = makeTempDir();
1847
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
1848
+ const ctx = makeCtx();
1849
+
1850
+ await orchestrator.startTask(ctx as any, "implement", "plan review advance", undefined, undefined, "autonomous");
1851
+ const taskDir = orchestrator.active!.dir;
1852
+ orchestrator.active!.state.phase = "plan";
1853
+ orchestrator.active!.state.step = "synthesize";
1854
+ orchestrator.active!.state.reviewPass = 0;
1855
+ orchestrator.active!.state.reviewPassByKind = {};
1856
+ orchestrator.active!.state.autonomousConfig = {
1857
+ phases: { plan: { plannerPreset: "regular", reviewPreset: "regular", maxReviewPasses: 3 } },
1858
+ };
1859
+ writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
1860
+ writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
1861
+ const plansDir = join(taskDir, "plans");
1862
+ mkdirSync(plansDir, { recursive: true });
1863
+ writeFileSync(
1864
+ join(plansDir, "1_synthesized.md"),
1865
+ makeValidPlan(["- [x] P1. Plan ready — Done when: synthesized plan exists"]),
1866
+ "utf-8",
1867
+ );
1868
+
1869
+ const ppPhaseComplete = getTool(pi, "pp_phase_complete");
1870
+ // First call: plan phase is autonomous, no clean approval yet → starts a review cycle.
1871
+ const first = await ppPhaseComplete.execute("plan-rev-1", { summary: "plan done" }, undefined, undefined, ctx);
1872
+ expect(first.content[0].text).toMatch(/Reviews are running|Started review cycle pass/);
1873
+
1874
+ // Plan reviewers write to plan-reviews/ (not code-reviews/).
1875
+ const planReviewsDir = join(taskDir, "plan-reviews");
1876
+ mkdirSync(planReviewsDir, { recursive: true });
1877
+ const round = orchestrator.active!.state.reviewCycle!.pass;
1878
+ for (const v of ["opus", "gpt", "gemini"]) {
1879
+ writeFileSync(join(planReviewsDir, `1_${v}_round-${round}.md`), "VERDICT: APPROVE\n- CRITICAL: none", "utf-8");
1880
+ }
1881
+ emitSubagentCreated(pi, "plan-reviewer-1", "Plan reviewer (test)");
1882
+ emitSubagentCompleted(pi, "plan-reviewer-1", "Plan reviewer (test)");
1883
+
1884
+ // Second call: finalize the clean-approved pass and advance to implement (single transition).
1885
+ const second = await ppPhaseComplete.execute("plan-rev-2", { summary: "feedback applied" }, undefined, undefined, ctx);
1886
+ expect(second.content[0].text).toBe("");
1887
+ expect(orchestrator.active!.state.phase).toBe("implement");
1888
+ expect(menu.transcript.filter((entry) => entry.question.startsWith("/pp"))).toHaveLength(0);
1889
+ });
1890
+
1697
1891
  it("clean-approved review is not re-run on a later checklist-repair re-entry", async () => {
1698
1892
  const cwd = makeTempDir();
1699
1893
  const { pi, orchestrator } = await setupOrchestrator(cwd);
@@ -1932,7 +2126,7 @@ describe("task modes and quick task", () => {
1932
2126
  expect(prompt).toContain("<principles>");
1933
2127
  expect(prompt).toContain("<tools>");
1934
2128
  expect(prompt).toContain("<task>");
1935
- expect(prompt).toContain("let the user review and advance it via the /pp menu");
2129
+ expect(prompt).toContain("call pp_phase_complete");
1936
2130
  expect(prompt).not.toContain("HARNESS_BASE_PROMPT");
1937
2131
  expect(prompt).toContain(`Working directory: ${cwd}.`);
1938
2132
  expect(prompt).toMatch(/Current month: \d{4}-\d{2}\./);
@@ -2041,10 +2235,11 @@ describe("task modes and quick task", () => {
2041
2235
 
2042
2236
  const title = "implement";
2043
2237
  const pp = getCommand(pi, "pp");
2238
+ const titleMatch = (t: string) => t.includes(title);
2044
2239
  menu
2045
2240
  .expect({ question: "/pp", options: { include: ["Task"] }, choose: "Task" })
2046
2241
  .expect({ question: "Task", options: { include: ["Resume"] }, choose: "Resume" })
2047
- .expect({ question: "Resume", options: { include: [title] }, choose: title });
2242
+ .expect({ question: "Resume", options: { include: [titleMatch] }, choose: titleMatch });
2048
2243
  await pp(undefined, ctx);
2049
2244
 
2050
2245
  expect(orchestrator.active).not.toBeNull();
@@ -2069,10 +2264,11 @@ describe("task modes and quick task", () => {
2069
2264
  await orchestrator.cleanupActive();
2070
2265
 
2071
2266
  const pp = getCommand(pi, "pp");
2267
+ const configTitleMatch = (t: string) => t.includes("resume autonomous config");
2072
2268
  menu
2073
2269
  .expect({ question: "/pp", options: { include: ["Task"] }, choose: "Task" })
2074
2270
  .expect({ question: "Task", options: { include: ["Resume"] }, choose: "Resume" })
2075
- .expect({ question: "Resume", options: { include: ["resume autonomous config"] }, choose: "resume autonomous config" });
2271
+ .expect({ question: "Resume", options: { include: [configTitleMatch] }, choose: configTitleMatch });
2076
2272
  await pp(undefined, ctx);
2077
2273
 
2078
2274
  expect(orchestrator.active).not.toBeNull();
@@ -2102,6 +2298,10 @@ describe("task modes and quick task", () => {
2102
2298
  writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
2103
2299
  writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
2104
2300
 
2301
+ // brainstorm is user-driven even for an autonomous task, so the transition
2302
+ // to plan goes through the guided menu (which auto-skips the planner preset
2303
+ // picker because the task is autonomous).
2304
+ expectActiveTaskNext(menu, "Continue to plan & implement");
2105
2305
  const ppPhaseComplete = getTool(pi, "pp_phase_complete");
2106
2306
  await ppPhaseComplete.execute("call-autonomous-skip-planner", { summary: "done" }, undefined, undefined, ctx);
2107
2307
  await new Promise((r) => setTimeout(r, 10));
@@ -2596,7 +2796,7 @@ describe("resume and recovery", () => {
2596
2796
  menu
2597
2797
  .expect({ question: "/pp", options: { include: ["Task"] }, choose: "Task" })
2598
2798
  .expect({ question: "Task", options: { include: ["Resume"] }, choose: "Resume" })
2599
- .expect({ question: "Resume", options: { include: ["resume phase state"] }, choose: "resume phase state" });
2799
+ .expect({ question: "Resume", options: { include: [(t: string) => t.includes("resume phase state")] }, choose: (t: string) => t.includes("resume phase state") });
2600
2800
  await pp(undefined, ctx);
2601
2801
 
2602
2802
  expect(orchestrator.active).not.toBeNull();
@@ -2625,7 +2825,7 @@ describe("resume and recovery", () => {
2625
2825
  menu
2626
2826
  .expect({ question: "/pp", options: { include: ["Task"] }, choose: "Task" })
2627
2827
  .expect({ question: "Task", options: { include: ["Resume"] }, choose: "Resume" })
2628
- .expect({ question: "Resume", options: { include: ["resume stale repos"] }, choose: "resume stale repos" });
2828
+ .expect({ question: "Resume", options: { include: [(t: string) => t.includes("resume stale repos")] }, choose: (t: string) => t.includes("resume stale repos") });
2629
2829
  await pp(undefined, ctx);
2630
2830
 
2631
2831
  expect(orchestrator.active!.state.repos).toEqual([{ path: cwd, isRoot: true }]);
@@ -2852,6 +3052,49 @@ describe("brainstorm and plan review cycles", () => {
2852
3052
  expect(readyMessages[readyMessages.length - 1]).toContain("artifacts/");
2853
3053
  });
2854
3054
 
3055
+ const runPlanReviewReady = async (mode?: "autonomous") => {
3056
+ const cwd = makeTempDir();
3057
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
3058
+ const ctx = makeCtx({ cwd });
3059
+
3060
+ await orchestrator.startTask(ctx as any, "implement", "plan review message", undefined, undefined, mode);
3061
+ const taskDir = orchestrator.active!.dir;
3062
+ writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
3063
+ writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
3064
+ mkdirSync(join(taskDir, "plan-reviews"), { recursive: true });
3065
+ writeFileSync(
3066
+ join(taskDir, "plan-reviews", `${Math.floor(Date.now() / 1000)}_test_round-1.md`),
3067
+ "Plan review feedback",
3068
+ "utf-8",
3069
+ );
3070
+ orchestrator.active!.state.phase = "plan";
3071
+ orchestrator.active!.state.step = "synthesize";
3072
+ saveTask(taskDir, orchestrator.active!.state);
3073
+
3074
+ await enterReviewCycle(orchestrator, ctx, "regular");
3075
+ await new Promise((r) => setTimeout(r, 10));
3076
+
3077
+ return (pi.sendUserMessage as any).mock.calls
3078
+ .map((c: any[]) => c[0] as string)
3079
+ .filter((text: string) => text.includes("ready for apply_feedback"));
3080
+ };
3081
+
3082
+ it("autonomous plan review-ready message mandates re-calling pp_phase_complete", async () => {
3083
+ const readyMessages = await runPlanReviewReady("autonomous");
3084
+ expect(readyMessages.length).toBeGreaterThan(0);
3085
+ const last = readyMessages[readyMessages.length - 1];
3086
+ expect(last).toContain("pp_phase_complete");
3087
+ expect(last).toContain("Do NOT stop or wait for the user");
3088
+ });
3089
+
3090
+ it("guided plan review-ready message does NOT tell the agent to re-call pp_phase_complete or auto-advance", async () => {
3091
+ const readyMessages = await runPlanReviewReady();
3092
+ expect(readyMessages.length).toBeGreaterThan(0);
3093
+ const last = readyMessages[readyMessages.length - 1];
3094
+ expect(last).not.toContain("pp_phase_complete");
3095
+ expect(last).not.toContain("Do NOT stop or wait for the user");
3096
+ });
3097
+
2855
3098
  it("plan review cycle reaches apply_feedback and finalizeReviewCycle returns to user_gate", async () => {
2856
3099
  const cwd = makeTempDir();
2857
3100
  const { pi, orchestrator } = await setupOrchestrator(cwd);
@@ -3044,7 +3287,11 @@ describe("tool blocking", () => {
3044
3287
  });
3045
3288
 
3046
3289
  describe("error retry", () => {
3047
- it("turn_end with error retries with exponential backoff", async () => {
3290
+ // Non-SDK-retryable error messages exercise pi-pi's OWN retry path. SDK-
3291
+ // retryable errors (rate limit, overloaded, 5xx, stream-ended, timeout, etc.)
3292
+ // are now deferred to the SDK's own auto-retry (see the defer test below), so
3293
+ // these use a message the SDK does NOT retry.
3294
+ it("turn_end with a non-SDK-retryable error uses pi-pi's idle-gated retry", async () => {
3048
3295
  vi.useFakeTimers();
3049
3296
  const cwd = makeTempDir();
3050
3297
  const { pi, orchestrator } = await setupOrchestrator(cwd);
@@ -3053,7 +3300,7 @@ describe("error retry", () => {
3053
3300
  await orchestrator.startTask(ctx as any, "implement", "retry test");
3054
3301
  const turnEnd = pi._handlers.get("turn_end")!;
3055
3302
 
3056
- await turnEnd({ message: { stopReason: "error", errorMessage: "rate limited", content: [] } }, ctx);
3303
+ await turnEnd({ message: { stopReason: "error", errorMessage: "invalid tool arguments", content: [] } }, ctx);
3057
3304
 
3058
3305
  expect(orchestrator.errorRetryCount).toBe(1);
3059
3306
  expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("Retrying in 2s"), "warning");
@@ -3063,7 +3310,24 @@ describe("error retry", () => {
3063
3310
  vi.useRealTimers();
3064
3311
  });
3065
3312
 
3066
- it("turn_end stops retrying after max retries", async () => {
3313
+ it("turn_end defers SDK-retryable errors to the SDK (no pi-pi retry)", async () => {
3314
+ const cwd = makeTempDir();
3315
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
3316
+ const ctx = makeCtx();
3317
+
3318
+ await orchestrator.startTask(ctx as any, "implement", "sdk defer test");
3319
+ const turnEnd = pi._handlers.get("turn_end")!;
3320
+
3321
+ // "stream ended before message_stop" is SDK-retryable — pi-pi must NOT run
3322
+ // its own retry (which would race the SDK's continue()).
3323
+ await turnEnd({ message: { stopReason: "error", errorMessage: "Anthropic stream ended before message_stop", content: [] } }, ctx);
3324
+
3325
+ expect(orchestrator.errorRetryCount).toBe(0);
3326
+ expect(orchestrator.pendingRetryTimer).toBeNull();
3327
+ expect(ctx.ui.notify).not.toHaveBeenCalledWith(expect.stringContaining("Retrying in"), "warning");
3328
+ });
3329
+
3330
+ it("turn_end stops retrying after max retries (non-SDK-retryable)", async () => {
3067
3331
  vi.useFakeTimers();
3068
3332
  const cwd = makeTempDir();
3069
3333
  const { pi, orchestrator } = await setupOrchestrator(cwd);
@@ -3073,27 +3337,54 @@ describe("error retry", () => {
3073
3337
  const turnEnd = pi._handlers.get("turn_end")!;
3074
3338
 
3075
3339
  for (let i = 0; i < 6; i++) {
3076
- await turnEnd({ message: { stopReason: "error", errorMessage: "api down", content: [] } }, ctx);
3340
+ await turnEnd({ message: { stopReason: "error", errorMessage: "invalid tool arguments", content: [] } }, ctx);
3077
3341
  }
3078
3342
 
3079
- expect(orchestrator.errorRetryCount).toBe(0);
3080
3343
  expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("Stopping auto-retry"), "error");
3344
+ expect(orchestrator.errorNudgeHalted).toBe(true);
3345
+
3346
+ // Once halted, further error turns must NOT arm another retry (this is the
3347
+ // guard against the unbounded-nudge bug).
3348
+ ctx.ui.notify.mockClear();
3349
+ await turnEnd({ message: { stopReason: "error", errorMessage: "invalid tool arguments", content: [] } }, ctx);
3350
+ expect(ctx.ui.notify).not.toHaveBeenCalledWith(expect.stringContaining("Retrying in"), "warning");
3081
3351
  vi.useRealTimers();
3082
3352
  });
3083
3353
 
3084
- it("successful turn resets error count", async () => {
3354
+ it("a benign turn does NOT reset the error count (cap must accumulate)", async () => {
3085
3355
  const cwd = makeTempDir();
3086
3356
  const { pi, orchestrator } = await setupOrchestrator(cwd);
3087
3357
  const ctx = makeCtx();
3088
3358
 
3089
- await orchestrator.startTask(ctx as any, "implement", "retry reset test");
3359
+ await orchestrator.startTask(ctx as any, "implement", "retry no-reset test");
3090
3360
  const turnEnd = pi._handlers.get("turn_end")!;
3091
3361
 
3092
- await turnEnd({ message: { stopReason: "error", errorMessage: "once", content: [] } }, ctx);
3362
+ await turnEnd({ message: { stopReason: "error", errorMessage: "invalid tool arguments", content: [] } }, ctx);
3093
3363
  expect(orchestrator.errorRetryCount).toBe(1);
3094
3364
 
3365
+ // A benign (non-error) turn must NOT zero the counter — otherwise a harmless
3366
+ // nudge-induced reply would defeat the cap and allow unbounded error nudges.
3095
3367
  await turnEnd({ message: { stopReason: "stop", content: [] } }, ctx);
3096
- expect(orchestrator.errorRetryCount).toBe(0);
3368
+ expect(orchestrator.errorRetryCount).toBe(1);
3369
+ });
3370
+
3371
+ it("genuine user re-engagement resets the error count and halt", async () => {
3372
+ const cwd = makeTempDir();
3373
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
3374
+ const ctx = makeCtx();
3375
+
3376
+ await orchestrator.startTask(ctx as any, "implement", "retry reengage test");
3377
+ const turnEnd = pi._handlers.get("turn_end")!;
3378
+
3379
+ await turnEnd({ message: { stopReason: "error", errorMessage: "invalid tool arguments", content: [] } }, ctx);
3380
+ orchestrator.errorNudgeHalted = true;
3381
+
3382
+ const beforeStart = pi._handlers.get("before_agent_start");
3383
+ if (beforeStart) {
3384
+ await beforeStart({ prompt: "user typed something new" }, ctx);
3385
+ expect(orchestrator.errorRetryCount).toBe(0);
3386
+ expect(orchestrator.errorNudgeHalted).toBe(false);
3387
+ }
3097
3388
  });
3098
3389
 
3099
3390
  it("empty turn triggers continuation nudge", async () => {
@@ -3917,6 +4208,9 @@ describe("full user flows", () => {
3917
4208
  writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
3918
4209
  writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
3919
4210
 
4211
+ // brainstorm is user-driven: the brainstorm→plan transition uses the guided
4212
+ // menu. plan→implement and implement→review below are autonomous auto-advance.
4213
+ expectActiveTaskNext(menu, "Continue to plan & implement");
3920
4214
  const ppPhaseComplete = getTool(pi, "pp_phase_complete");
3921
4215
  await ppPhaseComplete.execute("flow-auto-1", { summary: "brainstorm done" }, undefined, undefined, ctx);
3922
4216
  await new Promise((r) => setTimeout(r, 10));
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { advanceBanner } from "./messages.js";
3
+
4
+ describe("advanceBanner", () => {
5
+ it("starts with [PI-PI] so the controller-injection guard still matches", () => {
6
+ const out = advanceBanner("[PI-PI] User wants to continue. Run /pp when ready to advance.");
7
+ expect(out.startsWith("[PI-PI]")).toBe(true);
8
+ });
9
+
10
+ it("renders separators around the content for visual distinctness", () => {
11
+ const out = advanceBanner("[PI-PI] hello");
12
+ const lines = out.split("\n");
13
+ expect(lines[0]).toBe("[PI-PI]");
14
+ expect(lines[1]).toMatch(/^─+$/);
15
+ expect(lines[2]).toBe("hello");
16
+ expect(lines[3]).toMatch(/^─+$/);
17
+ });
18
+
19
+ it("does not double up the [PI-PI] prefix when the body already has one", () => {
20
+ const out = advanceBanner("[PI-PI] hello");
21
+ expect(out.match(/\[PI-PI\]/g)?.length).toBe(1);
22
+ expect(out).not.toContain("[PI-PI] [PI-PI]");
23
+ });
24
+
25
+ it("adds the prefix when the body lacks one", () => {
26
+ const out = advanceBanner("plain body");
27
+ expect(out.startsWith("[PI-PI]")).toBe(true);
28
+ expect(out).toContain("plain body");
29
+ });
30
+ });
@@ -0,0 +1,6 @@
1
+ const BANNER_SEPARATOR = "─".repeat(48);
2
+
3
+ export function advanceBanner(body: string): string {
4
+ const content = body.startsWith("[PI-PI]") ? body.slice("[PI-PI]".length).trimStart() : body;
5
+ return `[PI-PI]\n${BANNER_SEPARATOR}\n${content}\n${BANNER_SEPARATOR}`;
6
+ }