@ilya-lesikov/pi-pi 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/3p/pi-plannotator/apps/pi-extension/README.md +6 -5
  2. package/3p/pi-plannotator/apps/pi-extension/plannotator-browser.ts +25 -10
  3. package/3p/pi-plannotator/apps/pi-extension/plannotator-events.code-review.test.ts +172 -0
  4. package/3p/pi-plannotator/apps/pi-extension/plannotator-events.ts +50 -12
  5. package/3p/pi-plannotator/apps/pi-extension/server/project.test.ts +45 -0
  6. package/3p/pi-plannotator/apps/pi-extension/server/project.ts +7 -6
  7. package/3p/pi-plannotator/apps/pi-extension/server/serverReview.ts +41 -25
  8. package/3p/pi-subagents/src/agent-manager.ts +34 -1
  9. package/3p/pi-subagents/src/agent-runner.ts +66 -33
  10. package/3p/pi-subagents/src/index.ts +3 -38
  11. package/3p/pi-subagents/src/types.ts +4 -0
  12. package/extensions/orchestrator/agents/advisor.ts +35 -0
  13. package/extensions/orchestrator/agents/brainstorm-reviewer.ts +7 -7
  14. package/extensions/orchestrator/agents/code-reviewer.ts +27 -11
  15. package/extensions/orchestrator/agents/constraints.test.ts +82 -0
  16. package/extensions/orchestrator/agents/constraints.ts +66 -2
  17. package/extensions/orchestrator/agents/deep-debugger.ts +35 -0
  18. package/extensions/orchestrator/agents/plan-reviewer.ts +7 -7
  19. package/extensions/orchestrator/agents/planner.ts +9 -8
  20. package/extensions/orchestrator/agents/prompts.test.ts +120 -0
  21. package/extensions/orchestrator/agents/reviewer.ts +48 -0
  22. package/extensions/orchestrator/agents/task.ts +6 -20
  23. package/extensions/orchestrator/agents/tool-routing.ts +39 -1
  24. package/extensions/orchestrator/ai-comment-cleanup.test.ts +89 -0
  25. package/extensions/orchestrator/ai-comment-cleanup.ts +73 -0
  26. package/extensions/orchestrator/command-handlers.test.ts +47 -0
  27. package/extensions/orchestrator/command-handlers.ts +44 -28
  28. package/extensions/orchestrator/config.test.ts +40 -1
  29. package/extensions/orchestrator/config.ts +18 -2
  30. package/extensions/orchestrator/context.test.ts +54 -0
  31. package/extensions/orchestrator/context.ts +81 -2
  32. package/extensions/orchestrator/custom-footer.test.ts +91 -0
  33. package/extensions/orchestrator/custom-footer.ts +20 -20
  34. package/extensions/orchestrator/event-handlers.test.ts +412 -2
  35. package/extensions/orchestrator/event-handlers.ts +556 -227
  36. package/extensions/orchestrator/flant-infra.test.ts +25 -0
  37. package/extensions/orchestrator/flant-infra.ts +91 -0
  38. package/extensions/orchestrator/index.ts +1 -1
  39. package/extensions/orchestrator/integration.test.ts +411 -20
  40. package/extensions/orchestrator/messages.test.ts +30 -0
  41. package/extensions/orchestrator/messages.ts +6 -0
  42. package/extensions/orchestrator/model-registry.test.ts +48 -1
  43. package/extensions/orchestrator/model-registry.ts +43 -1
  44. package/extensions/orchestrator/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  45. package/extensions/orchestrator/orchestrator.test.ts +113 -0
  46. package/extensions/orchestrator/orchestrator.ts +197 -60
  47. package/extensions/orchestrator/phases/brainstorm.ts +20 -27
  48. package/extensions/orchestrator/phases/implementation.test.ts +11 -0
  49. package/extensions/orchestrator/phases/implementation.ts +4 -6
  50. package/extensions/orchestrator/phases/machine.test.ts +36 -0
  51. package/extensions/orchestrator/phases/machine.ts +11 -1
  52. package/extensions/orchestrator/phases/planning.test.ts +16 -0
  53. package/extensions/orchestrator/phases/planning.ts +11 -4
  54. package/extensions/orchestrator/phases/review-task.test.ts +20 -0
  55. package/extensions/orchestrator/phases/review-task.ts +47 -22
  56. package/extensions/orchestrator/phases/review.test.ts +34 -0
  57. package/extensions/orchestrator/phases/review.ts +44 -6
  58. package/extensions/orchestrator/plannotator.ts +9 -6
  59. package/extensions/orchestrator/pp-menu.test.ts +207 -1
  60. package/extensions/orchestrator/pp-menu.ts +514 -402
  61. package/extensions/orchestrator/pp-state-tools.test.ts +192 -0
  62. package/extensions/orchestrator/pp-state-tools.ts +249 -0
  63. package/extensions/orchestrator/rate-limit-fallback-flow.test.ts +128 -0
  64. package/extensions/orchestrator/rate-limit-fallback.test.ts +98 -0
  65. package/extensions/orchestrator/rate-limit-fallback.ts +243 -0
  66. package/extensions/orchestrator/state.test.ts +9 -0
  67. package/extensions/orchestrator/state.ts +15 -0
  68. package/extensions/orchestrator/test-helpers.ts +4 -1
  69. package/extensions/orchestrator/transition-controller.test.ts +100 -0
  70. package/extensions/orchestrator/transition-controller.ts +61 -3
  71. package/extensions/orchestrator/usage-tracker.ts +5 -1
  72. package/extensions/orchestrator/validate-artifacts.test.ts +20 -0
  73. package/extensions/orchestrator/validate-artifacts.ts +2 -2
  74. package/package.json +1 -1
  75. package/AGENTS.md +0 -28
@@ -53,6 +53,7 @@ vi.mock("./config.js", async (importOriginal) => {
53
53
  return { ...original, loadConfig: () => ({
54
54
  general: {
55
55
  autoCommit: false,
56
+ injectAgentsMd: true,
56
57
  loadExtraRepoConfigs: true,
57
58
  logLevel: "info",
58
59
  },
@@ -70,6 +71,9 @@ vi.mock("./config.js", async (importOriginal) => {
70
71
  explore: { model: "test/explore", thinking: "low" },
71
72
  librarian: { model: "test/librarian", thinking: "medium" },
72
73
  task: { model: "test/task", thinking: "medium" },
74
+ advisor: { model: "test/advisor", thinking: "high" },
75
+ "deep-debugger": { model: "test/deep-debugger", thinking: "high" },
76
+ reviewer: { model: "test/reviewer", thinking: "high" },
73
77
  },
74
78
  presetGroups: {
75
79
  planners: {
@@ -206,6 +210,7 @@ function makeConfig() {
206
210
  return {
207
211
  general: {
208
212
  autoCommit: false,
213
+ injectAgentsMd: true,
209
214
  loadExtraRepoConfigs: true,
210
215
  logLevel: "info",
211
216
  },
@@ -223,6 +228,9 @@ function makeConfig() {
223
228
  explore: { model: "test/explore", thinking: "low" },
224
229
  librarian: { model: "test/librarian", thinking: "medium" },
225
230
  task: { model: "test/task", thinking: "medium" },
231
+ advisor: { model: "test/advisor", thinking: "high" },
232
+ "deep-debugger": { model: "test/deep-debugger", thinking: "high" },
233
+ reviewer: { model: "test/reviewer", thinking: "high" },
226
234
  },
227
235
  presetGroups: {
228
236
  planners: {
@@ -671,8 +679,12 @@ describe("review cycle lifecycle", () => {
671
679
  await ppPhaseComplete.execute("call-2", { summary: "plan done" }, undefined, undefined, ctx);
672
680
  await new Promise((r) => setTimeout(r, 10));
673
681
 
682
+ // Pick the auto preset first: with zero enabled reviewers it notifies and
683
+ // (#3d) loops back to the Review submenu — NOT the top-level /pp menu — so the
684
+ // next expectation is the Review submenu directly, without re-selecting "Review".
674
685
  expectReviewAuto(menu);
675
- expectReviewOnMyOwn(menu);
686
+ menu.expect({ question: "Review", options: { include: ["Review on my own"] }, choose: "Review on my own" });
687
+ menu.expect({ question: "Editor review", options: { include: ["Skip markers"] }, choose: "Skip markers" });
676
688
  const result = await ppPhaseComplete.execute("call-3", { summary: "implemented" }, undefined, undefined, ctx);
677
689
 
678
690
  expect(result.content[0].text).toContain("continue");
@@ -1312,7 +1324,7 @@ describe("edge cases and regressions", () => {
1312
1324
 
1313
1325
  await orchestrator.startTask(ctx as any, "implement", "Continue test");
1314
1326
 
1315
- expectReviewOnMyOwn(menu);
1327
+ expectReviewOnMyOwn(menu, "Skip markers");
1316
1328
  const ppPhaseComplete = getTool(pi, "pp_phase_complete");
1317
1329
  const result = await ppPhaseComplete.execute("call-1", { summary: "not done yet" }, undefined, undefined, ctx);
1318
1330
 
@@ -1355,7 +1367,7 @@ describe("edge cases and regressions", () => {
1355
1367
  await ppPhaseComplete.execute("call-2", { summary: "plan done" }, undefined, undefined, ctx);
1356
1368
  await new Promise((r) => setTimeout(r, 10));
1357
1369
 
1358
- expectReviewOnMyOwn(menu);
1370
+ expectReviewOnMyOwn(menu, "Skip markers");
1359
1371
  const result = await ppPhaseComplete.execute("call-3", { summary: "partial work" }, undefined, undefined, ctx);
1360
1372
 
1361
1373
  expect(result.content[0].text).toContain("continue");
@@ -1394,7 +1406,7 @@ describe("edge cases and regressions", () => {
1394
1406
  "utf-8",
1395
1407
  );
1396
1408
 
1397
- expectReviewOnMyOwn(menu);
1409
+ expectReviewOnMyOwn(menu, "Skip markers");
1398
1410
  const result = await ppPhaseComplete.execute("call-2", { summary: "plan ready" }, undefined, undefined, ctx);
1399
1411
 
1400
1412
  expect(result.content[0].text).toContain("continue");
@@ -1402,6 +1414,124 @@ describe("edge cases and regressions", () => {
1402
1414
  expect(orchestrator.active!.state.step).toBe("synthesize");
1403
1415
  });
1404
1416
 
1417
+ it("editor-review Done returns AI_REVIEW marker instructions in implement phase", async () => {
1418
+ const cwd = makeTempDir();
1419
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
1420
+ const ctx = makeCtx();
1421
+
1422
+ await orchestrator.startTask(ctx as any, "implement", "Editor review test");
1423
+ const taskDir = orchestrator.active!.dir;
1424
+ writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
1425
+ writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
1426
+ expectBrainstormToPlan(menu);
1427
+ const ppPhaseComplete = getTool(pi, "pp_phase_complete");
1428
+ await ppPhaseComplete.execute("call-1", { summary: "done" }, undefined, undefined, ctx);
1429
+ await new Promise((r) => setTimeout(r, 10));
1430
+
1431
+ emitSubagentCreated(pi, "planner-1", "Planner (test)");
1432
+ const plansDir = join(taskDir, "plans");
1433
+ mkdirSync(plansDir, { recursive: true });
1434
+ writeFileSync(
1435
+ join(plansDir, `${Math.floor(Date.now() / 1000)}_test.md`),
1436
+ makeValidPlan(["- [ ] P1. Planner draft item — Done when: planner output exists"]),
1437
+ "utf-8",
1438
+ );
1439
+ emitSubagentCompleted(pi, "planner-1", "Planner (test)");
1440
+ writeFileSync(
1441
+ join(plansDir, `${Math.floor(Date.now() / 1000) + 1}_synthesized.md`),
1442
+ makeValidPlan(["- [ ] P1. Todo item — Done when: item intentionally remains unchecked"]),
1443
+ "utf-8",
1444
+ );
1445
+ expectPlanToImplement(menu);
1446
+ await ppPhaseComplete.execute("call-2", { summary: "plan done" }, undefined, undefined, ctx);
1447
+ await new Promise((r) => setTimeout(r, 10));
1448
+
1449
+ expectReviewOnMyOwn(menu, "Done");
1450
+ const result = await ppPhaseComplete.execute("call-3", { summary: "implemented" }, undefined, undefined, ctx);
1451
+
1452
+ expect(result.content[0].text).toContain("AI_REVIEW:");
1453
+ expect(result.content[0].text).toContain("CHANGED files");
1454
+ expect(orchestrator.active!.state.phase).toBe("implement");
1455
+ expect(orchestrator.active!.state.step).toBe("llm_work");
1456
+ });
1457
+
1458
+ it("editor-review Done returns AI_REVIEW state-file instructions in brainstorm phase", async () => {
1459
+ const cwd = makeTempDir();
1460
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
1461
+ const ctx = makeCtx();
1462
+
1463
+ await orchestrator.startTask(ctx as any, "brainstorm", "Brainstorm editor review");
1464
+
1465
+ expectReviewOnMyOwn(menu, "Done");
1466
+ const ppPhaseComplete = getTool(pi, "pp_phase_complete");
1467
+ const result = await ppPhaseComplete.execute("call-1", { summary: "researched" }, undefined, undefined, ctx);
1468
+
1469
+ expect(result.content[0].text).toContain("AI_REVIEW:");
1470
+ expect(result.content[0].text).toContain("USER_REQUEST.md");
1471
+ expect(result.content[0].text).toContain("RESEARCH.md");
1472
+ expect(result.content[0].text).toContain("artifacts/*.md");
1473
+ expect(result.content[0].text).not.toContain("CHANGED files");
1474
+ expect(orchestrator.active!.state.phase).toBe("brainstorm");
1475
+ expect(orchestrator.active!.state.step).toBe("llm_work");
1476
+ });
1477
+
1478
+ it("editor-review Done returns AI_REVIEW state-file instructions in debug phase", async () => {
1479
+ const cwd = makeTempDir();
1480
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
1481
+ const ctx = makeCtx();
1482
+
1483
+ await orchestrator.startTask(ctx as any, "debug", "Debug editor review");
1484
+
1485
+ expectReviewOnMyOwn(menu, "Done");
1486
+ const ppPhaseComplete = getTool(pi, "pp_phase_complete");
1487
+ const result = await ppPhaseComplete.execute("call-1", { summary: "diagnosed" }, undefined, undefined, ctx);
1488
+
1489
+ expect(result.content[0].text).toContain("AI_REVIEW:");
1490
+ expect(result.content[0].text).toContain("USER_REQUEST.md");
1491
+ expect(result.content[0].text).not.toContain("CHANGED files");
1492
+ expect(orchestrator.active!.state.phase).toBe("debug");
1493
+ expect(orchestrator.active!.state.step).toBe("llm_work");
1494
+ });
1495
+
1496
+ it("editor-review Done targets synthesized plan in plan phase", async () => {
1497
+ const cwd = makeTempDir();
1498
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
1499
+ const ctx = makeCtx();
1500
+
1501
+ await orchestrator.startTask(ctx as any, "implement", "Plan editor review");
1502
+ const taskDir = orchestrator.active!.dir;
1503
+ writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
1504
+ writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
1505
+ expectBrainstormToPlan(menu);
1506
+ const ppPhaseComplete = getTool(pi, "pp_phase_complete");
1507
+ await ppPhaseComplete.execute("call-1", { summary: "done" }, undefined, undefined, ctx);
1508
+ await new Promise((r) => setTimeout(r, 10));
1509
+
1510
+ emitSubagentCreated(pi, "planner-1", "Planner (test)");
1511
+ const plansDir = join(taskDir, "plans");
1512
+ mkdirSync(plansDir, { recursive: true });
1513
+ writeFileSync(
1514
+ join(plansDir, `${Math.floor(Date.now() / 1000)}_test.md`),
1515
+ makeValidPlan(["- [ ] P1. Planner draft item — Done when: planner output exists"]),
1516
+ "utf-8",
1517
+ );
1518
+ emitSubagentCompleted(pi, "planner-1", "Planner (test)");
1519
+ writeFileSync(
1520
+ join(plansDir, `${Math.floor(Date.now() / 1000) + 1}_synthesized.md`),
1521
+ makeValidPlan(["- [ ] P1. Todo item — Done when: item intentionally remains unchecked"]),
1522
+ "utf-8",
1523
+ );
1524
+
1525
+ expectReviewOnMyOwn(menu, "Done");
1526
+ const result = await ppPhaseComplete.execute("call-2", { summary: "plan ready" }, undefined, undefined, ctx);
1527
+
1528
+ expect(result.content[0].text).toContain("AI_REVIEW:");
1529
+ expect(result.content[0].text).toContain("_synthesized.md");
1530
+ expect(result.content[0].text).not.toContain("CHANGED files");
1531
+ expect(orchestrator.active!.state.phase).toBe("plan");
1532
+ expect(orchestrator.active!.state.step).toBe("synthesize");
1533
+ });
1534
+
1405
1535
  it("generic description task does not auto-trigger agent", async () => {
1406
1536
  const cwd = makeTempDir();
1407
1537
  const { pi, orchestrator } = await setupOrchestrator(cwd);
@@ -1741,8 +1871,11 @@ describe("task modes and quick task", () => {
1741
1871
  emitSubagentCreated(pi, "reviewer-2", "Code reviewer (test)");
1742
1872
  emitSubagentCompleted(pi, "reviewer-2", "Code reviewer (test)");
1743
1873
 
1874
+ // Terminal handoff (#1): at the cap the autonomous implement no longer auto-
1875
+ // completes — it opens the guided menu; the user drives Next→Complete to finish.
1876
+ expectImplementToDone(menu);
1744
1877
  const fourth = await ppPhaseComplete.execute("call-4", { summary: "applied 2" }, undefined, undefined, ctx);
1745
- expect(fourth.content[0].text).toBe("");
1878
+ expect(fourth.content[0].text).toMatch(/completed/);
1746
1879
  expect(orchestrator.active).toBeNull();
1747
1880
  expect(loadTask(taskDir).phase).toBe("done");
1748
1881
  });
@@ -1784,8 +1917,11 @@ describe("task modes and quick task", () => {
1784
1917
  emitSubagentCreated(pi, "reviewer-1", "Code reviewer (test)");
1785
1918
  emitSubagentCompleted(pi, "reviewer-1", "Code reviewer (test)");
1786
1919
 
1920
+ // Terminal handoff (#1): instead of auto-completing, the guided menu opens
1921
+ // and the user drives Next→Complete to finish.
1922
+ expectImplementToDone(menu);
1787
1923
  const second = await ppPhaseComplete.execute("call-2", { summary: "applied" }, undefined, undefined, ctx);
1788
- expect(second.content[0].text).toBe("");
1924
+ expect(second.content[0].text).toMatch(/completed/);
1789
1925
  expect(orchestrator.active).toBeNull();
1790
1926
  const finalState = loadTask(taskDir);
1791
1927
  expect(finalState.phase).toBe("done");
@@ -1865,8 +2001,11 @@ describe("task modes and quick task", () => {
1865
2001
  );
1866
2002
 
1867
2003
  const ppPhaseComplete = getTool(pi, "pp_phase_complete");
2004
+ // Terminal handoff (#1): clean-approved re-entry opens the guided menu rather
2005
+ // than auto-completing; the user drives Next→Complete to finish.
2006
+ expectImplementToDone(menu);
1868
2007
  const result = await ppPhaseComplete.execute("call-1", { summary: "checklist repaired" }, undefined, undefined, ctx);
1869
- expect(result.content[0].text).toBe("");
2008
+ expect(result.content[0].text).toMatch(/completed/);
1870
2009
  expect(orchestrator.active).toBeNull();
1871
2010
  expect(loadTask(taskDir).phase).toBe("done");
1872
2011
  });
@@ -2079,7 +2218,7 @@ describe("task modes and quick task", () => {
2079
2218
  expect(prompt).toContain("<principles>");
2080
2219
  expect(prompt).toContain("<tools>");
2081
2220
  expect(prompt).toContain("<task>");
2082
- expect(prompt).toContain("let the user review and advance it via the /pp menu");
2221
+ expect(prompt).toContain("call pp_phase_complete");
2083
2222
  expect(prompt).not.toContain("HARNESS_BASE_PROMPT");
2084
2223
  expect(prompt).toContain(`Working directory: ${cwd}.`);
2085
2224
  expect(prompt).toMatch(/Current month: \d{4}-\d{2}\./);
@@ -2126,6 +2265,66 @@ describe("task modes and quick task", () => {
2126
2265
  expect(prompt).not.toContain("advance it via the /pp menu");
2127
2266
  });
2128
2267
 
2268
+ it("injects root AGENTS.md into the system prompt when enabled, ignoring nested/extra-repo files", async () => {
2269
+ const cwd = makeTempDir();
2270
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
2271
+ const ctx = makeCtx({ cwd });
2272
+
2273
+ writeFileSync(join(cwd, "AGENTS.md"), "ROOT_AGENTS_CONTENT", "utf-8");
2274
+ mkdirSync(join(cwd, "nested"), { recursive: true });
2275
+ writeFileSync(join(cwd, "nested", "AGENTS.md"), "NESTED_AGENTS_CONTENT", "utf-8");
2276
+
2277
+ await orchestrator.startTask({ ...ctx, cwd } as any, "implement", "implement", undefined, undefined, "guided");
2278
+ orchestrator.active!.state.phase = "plan";
2279
+ orchestrator.active!.state.step = "synthesize";
2280
+
2281
+ const beforeStart = pi._handlers.get("before_agent_start")!;
2282
+ const prompt = (await beforeStart({ systemPrompt: "base" }, ctx))?.systemPrompt ?? "";
2283
+ expect(prompt).toContain(`<agents_md source="${join(cwd, "AGENTS.md")}">`);
2284
+ expect(prompt).toContain("ROOT_AGENTS_CONTENT");
2285
+ expect(prompt).not.toContain("NESTED_AGENTS_CONTENT");
2286
+ });
2287
+
2288
+ it("omits AGENTS.md block when disabled or file absent", async () => {
2289
+ const cwd = makeTempDir();
2290
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
2291
+ const ctx = makeCtx({ cwd });
2292
+
2293
+ await orchestrator.startTask({ ...ctx, cwd } as any, "implement", "implement", undefined, undefined, "guided");
2294
+ orchestrator.active!.state.phase = "plan";
2295
+ orchestrator.active!.state.step = "synthesize";
2296
+
2297
+ const beforeStart = pi._handlers.get("before_agent_start")!;
2298
+ const absent = (await beforeStart({ systemPrompt: "base" }, ctx))?.systemPrompt ?? "";
2299
+ expect(absent).not.toContain("<agents_md");
2300
+
2301
+ writeFileSync(join(cwd, "AGENTS.md"), "ROOT_AGENTS_CONTENT", "utf-8");
2302
+ orchestrator.config.general.injectAgentsMd = false;
2303
+ const disabled = (await beforeStart({ systemPrompt: "base" }, ctx))?.systemPrompt ?? "";
2304
+ expect(disabled).not.toContain("<agents_md");
2305
+ expect(disabled).not.toContain("ROOT_AGENTS_CONTENT");
2306
+ });
2307
+
2308
+ it("auto-refreshes AGENTS.md content on the next turn without restart", async () => {
2309
+ const cwd = makeTempDir();
2310
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
2311
+ const ctx = makeCtx({ cwd });
2312
+
2313
+ writeFileSync(join(cwd, "AGENTS.md"), "FIRST_VERSION", "utf-8");
2314
+ await orchestrator.startTask({ ...ctx, cwd } as any, "implement", "implement", undefined, undefined, "guided");
2315
+ orchestrator.active!.state.phase = "plan";
2316
+ orchestrator.active!.state.step = "synthesize";
2317
+
2318
+ const beforeStart = pi._handlers.get("before_agent_start")!;
2319
+ const first = (await beforeStart({ systemPrompt: "base" }, ctx))?.systemPrompt ?? "";
2320
+ expect(first).toContain("FIRST_VERSION");
2321
+
2322
+ writeFileSync(join(cwd, "AGENTS.md"), "SECOND_VERSION", "utf-8");
2323
+ const second = (await beforeStart({ systemPrompt: "base" }, ctx))?.systemPrompt ?? "";
2324
+ expect(second).toContain("SECOND_VERSION");
2325
+ expect(second).not.toContain("FIRST_VERSION");
2326
+ });
2327
+
2129
2328
  it("autonomous plan-phase prompt body contains no /pp guidance", async () => {
2130
2329
  const cwd = makeTempDir();
2131
2330
  const { pi, orchestrator } = await setupOrchestrator(cwd);
@@ -2300,8 +2499,11 @@ describe("task modes and quick task", () => {
2300
2499
  emitSubagentCreated(pi, "reviewer-auto-1", "Code reviewer (test)");
2301
2500
  emitSubagentCompleted(pi, "reviewer-auto-1", "Code reviewer (test)");
2302
2501
 
2502
+ // Terminal handoff (#1): the clean second pass opens the guided menu; the user
2503
+ // drives Next→Complete to finish rather than auto-completing.
2504
+ expectImplementToDone(menu);
2303
2505
  const second = await ppPhaseComplete.execute("call-autonomous-review-2", { summary: "applied" }, undefined, undefined, ctx);
2304
- expect(second.content[0].text).toBe("");
2506
+ expect(second.content[0].text).toMatch(/completed/);
2305
2507
  expect(orchestrator.active).toBeNull();
2306
2508
  });
2307
2509
 
@@ -2346,10 +2548,9 @@ describe("task modes and quick task", () => {
2346
2548
  menu
2347
2549
  .expect({ question: "/pp", options: { include: ["Task"] }, choose: "Task" })
2348
2550
  .expect({ question: "Task", options: { include: ["Review"] }, choose: "Review" })
2349
- .expect({ question: "Review", options: { include: ["Describe"] }, choose: "Describe" })
2551
+ .expect({ question: "Review", options: { exact: ["New", "Resume", "Back"] }, choose: "New" })
2350
2552
  .expect({ question: "Mode", options: { include: ["Autonomous"] }, choose: "Autonomous" })
2351
2553
  .expect({ question: "Autonomous", options: { include: ["Start"] }, choose: "Start" });
2352
- ctx.ui.input.mockResolvedValueOnce("Review current branch changes");
2353
2554
  const pp = getCommand(pi, "pp");
2354
2555
  await pp(undefined, ctx);
2355
2556
 
@@ -2419,6 +2620,11 @@ describe("review task lifecycle", () => {
2419
2620
  await orchestrator.startTask(ctx as any, "review", "Review flow");
2420
2621
  const taskDir = orchestrator.active!.dir;
2421
2622
 
2623
+ // #10: the review phase cannot complete without an ANCHORS-bearing final_pass file.
2624
+ const reviewsDir = join(taskDir, "code-reviews");
2625
+ mkdirSync(reviewsDir, { recursive: true });
2626
+ writeFileSync(join(reviewsDir, "1_final_pass-1.md"), "# Review\nANCHORS: (none)\n", "utf-8");
2627
+
2422
2628
  await moveTaskToImplementPhase(pi, orchestrator, ctx, "call-review-to-plan", "call-plan-to-implement");
2423
2629
  expect(orchestrator.active!.state.phase).toBe("implement");
2424
2630
 
@@ -2510,6 +2716,22 @@ describe("modified file tracking", () => {
2510
2716
  expect(orchestrator.active!.state.reviewApprovedClean).toBe(false);
2511
2717
  });
2512
2718
 
2719
+ it("source write clears afterImplementRan so post-handoff edits re-run afterImplement (#1)", async () => {
2720
+ const cwd = makeTempDir();
2721
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
2722
+ const ctx = makeCtx();
2723
+
2724
+ await orchestrator.startTask(ctx as any, "implement", "Rerun hooks");
2725
+ await moveTaskToImplementPhase(pi, orchestrator, ctx, "call-rerun-brainstorm", "call-rerun-plan");
2726
+ // Simulate the terminal handoff having already run afterImplement.
2727
+ orchestrator.active!.state.afterImplementRan = true;
2728
+
2729
+ const toolResult = pi._handlers.get("tool_result")!;
2730
+ await toolResult({ toolName: "write", input: { path: "src/d.ts" }, isError: false, content: [] }, {});
2731
+
2732
+ expect(orchestrator.active!.state.afterImplementRan).toBe(false);
2733
+ });
2734
+
2513
2735
  it("tool_result ignores writes inside .pp directory", async () => {
2514
2736
  const cwd = makeTempDir();
2515
2737
  const { pi, orchestrator } = await setupOrchestrator(cwd);
@@ -3240,7 +3462,11 @@ describe("tool blocking", () => {
3240
3462
  });
3241
3463
 
3242
3464
  describe("error retry", () => {
3243
- it("turn_end with error retries with exponential backoff", async () => {
3465
+ // Non-SDK-retryable error messages exercise pi-pi's OWN retry path. SDK-
3466
+ // retryable errors (rate limit, overloaded, 5xx, stream-ended, timeout, etc.)
3467
+ // are now deferred to the SDK's own auto-retry (see the defer test below), so
3468
+ // these use a message the SDK does NOT retry.
3469
+ it("turn_end with a non-SDK-retryable error uses pi-pi's idle-gated retry", async () => {
3244
3470
  vi.useFakeTimers();
3245
3471
  const cwd = makeTempDir();
3246
3472
  const { pi, orchestrator } = await setupOrchestrator(cwd);
@@ -3249,7 +3475,7 @@ describe("error retry", () => {
3249
3475
  await orchestrator.startTask(ctx as any, "implement", "retry test");
3250
3476
  const turnEnd = pi._handlers.get("turn_end")!;
3251
3477
 
3252
- await turnEnd({ message: { stopReason: "error", errorMessage: "rate limited", content: [] } }, ctx);
3478
+ await turnEnd({ message: { stopReason: "error", errorMessage: "invalid tool arguments", content: [] } }, ctx);
3253
3479
 
3254
3480
  expect(orchestrator.errorRetryCount).toBe(1);
3255
3481
  expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("Retrying in 2s"), "warning");
@@ -3259,7 +3485,24 @@ describe("error retry", () => {
3259
3485
  vi.useRealTimers();
3260
3486
  });
3261
3487
 
3262
- it("turn_end stops retrying after max retries", async () => {
3488
+ it("turn_end defers SDK-retryable errors to the SDK (no pi-pi retry)", async () => {
3489
+ const cwd = makeTempDir();
3490
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
3491
+ const ctx = makeCtx();
3492
+
3493
+ await orchestrator.startTask(ctx as any, "implement", "sdk defer test");
3494
+ const turnEnd = pi._handlers.get("turn_end")!;
3495
+
3496
+ // "stream ended before message_stop" is SDK-retryable — pi-pi must NOT run
3497
+ // its own retry (which would race the SDK's continue()).
3498
+ await turnEnd({ message: { stopReason: "error", errorMessage: "Anthropic stream ended before message_stop", content: [] } }, ctx);
3499
+
3500
+ expect(orchestrator.errorRetryCount).toBe(0);
3501
+ expect(orchestrator.pendingRetryTimer).toBeNull();
3502
+ expect(ctx.ui.notify).not.toHaveBeenCalledWith(expect.stringContaining("Retrying in"), "warning");
3503
+ });
3504
+
3505
+ it("turn_end stops retrying after max retries (non-SDK-retryable)", async () => {
3263
3506
  vi.useFakeTimers();
3264
3507
  const cwd = makeTempDir();
3265
3508
  const { pi, orchestrator } = await setupOrchestrator(cwd);
@@ -3269,27 +3512,54 @@ describe("error retry", () => {
3269
3512
  const turnEnd = pi._handlers.get("turn_end")!;
3270
3513
 
3271
3514
  for (let i = 0; i < 6; i++) {
3272
- await turnEnd({ message: { stopReason: "error", errorMessage: "api down", content: [] } }, ctx);
3515
+ await turnEnd({ message: { stopReason: "error", errorMessage: "invalid tool arguments", content: [] } }, ctx);
3273
3516
  }
3274
3517
 
3275
- expect(orchestrator.errorRetryCount).toBe(0);
3276
3518
  expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("Stopping auto-retry"), "error");
3519
+ expect(orchestrator.errorNudgeHalted).toBe(true);
3520
+
3521
+ // Once halted, further error turns must NOT arm another retry (this is the
3522
+ // guard against the unbounded-nudge bug).
3523
+ ctx.ui.notify.mockClear();
3524
+ await turnEnd({ message: { stopReason: "error", errorMessage: "invalid tool arguments", content: [] } }, ctx);
3525
+ expect(ctx.ui.notify).not.toHaveBeenCalledWith(expect.stringContaining("Retrying in"), "warning");
3277
3526
  vi.useRealTimers();
3278
3527
  });
3279
3528
 
3280
- it("successful turn resets error count", async () => {
3529
+ it("a benign turn does NOT reset the error count (cap must accumulate)", async () => {
3281
3530
  const cwd = makeTempDir();
3282
3531
  const { pi, orchestrator } = await setupOrchestrator(cwd);
3283
3532
  const ctx = makeCtx();
3284
3533
 
3285
- await orchestrator.startTask(ctx as any, "implement", "retry reset test");
3534
+ await orchestrator.startTask(ctx as any, "implement", "retry no-reset test");
3286
3535
  const turnEnd = pi._handlers.get("turn_end")!;
3287
3536
 
3288
- await turnEnd({ message: { stopReason: "error", errorMessage: "once", content: [] } }, ctx);
3537
+ await turnEnd({ message: { stopReason: "error", errorMessage: "invalid tool arguments", content: [] } }, ctx);
3289
3538
  expect(orchestrator.errorRetryCount).toBe(1);
3290
3539
 
3540
+ // A benign (non-error) turn must NOT zero the counter — otherwise a harmless
3541
+ // nudge-induced reply would defeat the cap and allow unbounded error nudges.
3291
3542
  await turnEnd({ message: { stopReason: "stop", content: [] } }, ctx);
3292
- expect(orchestrator.errorRetryCount).toBe(0);
3543
+ expect(orchestrator.errorRetryCount).toBe(1);
3544
+ });
3545
+
3546
+ it("genuine user re-engagement resets the error count and halt", async () => {
3547
+ const cwd = makeTempDir();
3548
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
3549
+ const ctx = makeCtx();
3550
+
3551
+ await orchestrator.startTask(ctx as any, "implement", "retry reengage test");
3552
+ const turnEnd = pi._handlers.get("turn_end")!;
3553
+
3554
+ await turnEnd({ message: { stopReason: "error", errorMessage: "invalid tool arguments", content: [] } }, ctx);
3555
+ orchestrator.errorNudgeHalted = true;
3556
+
3557
+ const beforeStart = pi._handlers.get("before_agent_start");
3558
+ if (beforeStart) {
3559
+ await beforeStart({ prompt: "user typed something new" }, ctx);
3560
+ expect(orchestrator.errorRetryCount).toBe(0);
3561
+ expect(orchestrator.errorNudgeHalted).toBe(false);
3562
+ }
3293
3563
  });
3294
3564
 
3295
3565
  it("empty turn triggers continuation nudge", async () => {
@@ -3308,6 +3578,76 @@ describe("error retry", () => {
3308
3578
  expect(pi.sendUserMessage).toHaveBeenCalledWith(expect.stringContaining("Continue the implement phase"), { deliverAs: "followUp" });
3309
3579
  });
3310
3580
 
3581
+ it("drops a continuation nudge whose phase no longer matches at delivery", async () => {
3582
+ const cwd = makeTempDir();
3583
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
3584
+ const ctx = makeCtx();
3585
+
3586
+ await orchestrator.startTask(ctx as any, "implement", "stale phase nudge");
3587
+ orchestrator.active!.state.phase = "plan";
3588
+ orchestrator.active!.state.step = "llm_work";
3589
+ const turnEnd = pi._handlers.get("turn_end")!;
3590
+ await turnEnd({ message: { stopReason: "stop", content: [] }, toolResults: [] }, ctx);
3591
+ const nudge = (pi.sendUserMessage as any).mock.calls.map((c: any[]) => c[0]).find((t: string) => t.includes("Continue the plan phase"));
3592
+ expect(nudge).toBeDefined();
3593
+ expect(orchestrator.pendingNudges.has(nudge)).toBe(true);
3594
+
3595
+ // Phase advances before the queued nudge is delivered.
3596
+ orchestrator.active!.state.phase = "implement";
3597
+ const beforeStart = pi._handlers.get("before_agent_start")!;
3598
+ const abort = vi.fn();
3599
+ await beforeStart({ systemPrompt: "base", prompt: nudge }, makeCtx({ abort }));
3600
+
3601
+ expect(abort).toHaveBeenCalledOnce();
3602
+ expect(orchestrator.pendingNudges.has(nudge)).toBe(false);
3603
+ });
3604
+
3605
+ it("drops a continuation nudge whose task token changed at delivery (same phase)", async () => {
3606
+ const cwd = makeTempDir();
3607
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
3608
+ const ctx = makeCtx();
3609
+
3610
+ await orchestrator.startTask(ctx as any, "implement", "stale token nudge");
3611
+ orchestrator.active!.state.phase = "plan";
3612
+ orchestrator.active!.state.step = "llm_work";
3613
+ const turnEnd = pi._handlers.get("turn_end")!;
3614
+ await turnEnd({ message: { stopReason: "stop", content: [] }, toolResults: [] }, ctx);
3615
+ const nudge = (pi.sendUserMessage as any).mock.calls.map((c: any[]) => c[0]).find((t: string) => t.includes("Continue the plan phase"));
3616
+ expect(nudge).toBeDefined();
3617
+
3618
+ // A new task starts (token bumped), also at the plan phase — the old nudge
3619
+ // must not drive a turn in the new task even though the phase name matches.
3620
+ orchestrator.activeTaskToken++;
3621
+ orchestrator.active!.state.phase = "plan";
3622
+ const beforeStart = pi._handlers.get("before_agent_start")!;
3623
+ const abort = vi.fn();
3624
+ await beforeStart({ systemPrompt: "base", prompt: nudge }, makeCtx({ abort }));
3625
+
3626
+ expect(abort).toHaveBeenCalledOnce();
3627
+ });
3628
+
3629
+ it("delivers a continuation nudge whose phase and token still match", async () => {
3630
+ const cwd = makeTempDir();
3631
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
3632
+ const ctx = makeCtx();
3633
+
3634
+ await orchestrator.startTask(ctx as any, "implement", "fresh nudge");
3635
+ orchestrator.active!.state.phase = "implement";
3636
+ orchestrator.active!.state.step = "llm_work";
3637
+ const turnEnd = pi._handlers.get("turn_end")!;
3638
+ await turnEnd({ message: { stopReason: "stop", content: [] }, toolResults: [] }, ctx);
3639
+ const nudge = (pi.sendUserMessage as any).mock.calls.map((c: any[]) => c[0]).find((t: string) => t.includes("Continue the implement phase"));
3640
+ expect(nudge).toBeDefined();
3641
+
3642
+ const beforeStart = pi._handlers.get("before_agent_start")!;
3643
+ const abort = vi.fn();
3644
+ const result = await beforeStart({ systemPrompt: "base", prompt: nudge }, makeCtx({ abort }));
3645
+
3646
+ expect(abort).not.toHaveBeenCalled();
3647
+ expect(result?.systemPrompt).toContain("<task>");
3648
+ expect(orchestrator.pendingNudges.has(nudge)).toBe(false);
3649
+ });
3650
+
3311
3651
  it("suppresses nudges while the controller is transitioning (not running)", async () => {
3312
3652
  const cwd = makeTempDir();
3313
3653
  const { pi, orchestrator } = await setupOrchestrator(cwd);
@@ -3539,6 +3879,54 @@ describe("compaction", () => {
3539
3879
  expect(result.compaction.summary).toBe("Task done summary");
3540
3880
  });
3541
3881
 
3882
+ it("task-boundary discard uses distinct discard wording and keeps no verbatim transcript", async () => {
3883
+ const cwd = makeTempDir();
3884
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
3885
+ orchestrator.lastCtx = makeCtx({ isIdle: vi.fn().mockReturnValue(false) });
3886
+
3887
+ void orchestrator.transitionController.requestTransition({
3888
+ kind: "done",
3889
+ discard: true,
3890
+ summary: "Task \"x\" (implement) is finished — DISCARD its entire conversation. Do NOT carry forward.",
3891
+ });
3892
+ orchestrator.transitionController.onAgentEnd();
3893
+ const beforeCompact = pi._handlers.get("session_before_compact")!;
3894
+ const result = await beforeCompact(
3895
+ {
3896
+ preparation: { firstKeptEntryId: "old-kept", tokensBefore: 999 },
3897
+ branchEntries: [{ id: "e-a" }, { id: "e-b" }, { id: "newest" }],
3898
+ },
3899
+ {},
3900
+ );
3901
+
3902
+ // Distinct from the phase-transition summary, and instructs discard.
3903
+ expect(result.compaction.summary).not.toBe("Phase transition in progress.");
3904
+ expect(result.compaction.summary).toContain("DISCARD");
3905
+ // Verbatim window collapsed to the newest entry — nothing prior kept.
3906
+ expect(result.compaction.firstKeptEntryId).toBe("newest");
3907
+ });
3908
+
3909
+ it("phase transition keeps the default recent verbatim window (no discard)", async () => {
3910
+ const cwd = makeTempDir();
3911
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
3912
+ orchestrator.lastCtx = makeCtx({ isIdle: vi.fn().mockReturnValue(false) });
3913
+
3914
+ void orchestrator.transitionController.requestTransition({ kind: "phase", summary: "Phase summary text" });
3915
+ orchestrator.transitionController.onAgentEnd();
3916
+ const beforeCompact = pi._handlers.get("session_before_compact")!;
3917
+ const result = await beforeCompact(
3918
+ {
3919
+ preparation: { firstKeptEntryId: "old-kept", tokensBefore: 111 },
3920
+ branchEntries: [{ id: "e-a" }, { id: "newest" }],
3921
+ },
3922
+ {},
3923
+ );
3924
+
3925
+ expect(result.compaction.summary).toBe("Phase summary text");
3926
+ expect(result.compaction.firstKeptEntryId).toBe("old-kept");
3927
+ });
3928
+
3929
+
3542
3930
  it("session_before_compact re-injects artifacts after natural compaction", async () => {
3543
3931
  const cwd = makeTempDir();
3544
3932
  const { pi, orchestrator } = await setupOrchestrator(cwd);
@@ -4139,6 +4527,9 @@ describe("full user flows", () => {
4139
4527
  emitSubagentCreated(pi, "flow-auto-reviewer", "Code reviewer (test)");
4140
4528
  emitSubagentCompleted(pi, "flow-auto-reviewer", "Code reviewer (test)");
4141
4529
 
4530
+ // Terminal handoff (#1): autonomous implement does NOT auto-complete. It opens
4531
+ // the GUIDED implement menu and waits; the user drives Next→Complete to finish.
4532
+ expectImplementToDone(menu);
4142
4533
  await ppPhaseComplete.execute("flow-auto-4", { summary: "feedback applied" }, undefined, undefined, ctx);
4143
4534
 
4144
4535
  expect(orchestrator.active).toBeNull();
@@ -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
+ }