@ilya-lesikov/pi-pi 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/extensions/orchestrator/agents/code-reviewer.ts +21 -5
  2. package/extensions/orchestrator/agents/constraints.test.ts +39 -1
  3. package/extensions/orchestrator/agents/constraints.ts +63 -2
  4. package/extensions/orchestrator/agents/tool-routing.ts +30 -14
  5. package/extensions/orchestrator/ai-comment-cleanup.test.ts +89 -0
  6. package/extensions/orchestrator/ai-comment-cleanup.ts +73 -0
  7. package/extensions/orchestrator/command-handlers.test.ts +47 -0
  8. package/extensions/orchestrator/command-handlers.ts +43 -27
  9. package/extensions/orchestrator/config.test.ts +40 -1
  10. package/extensions/orchestrator/config.ts +13 -0
  11. package/extensions/orchestrator/context.ts +16 -0
  12. package/extensions/orchestrator/custom-footer.test.ts +91 -0
  13. package/extensions/orchestrator/custom-footer.ts +20 -20
  14. package/extensions/orchestrator/event-handlers.test.ts +315 -1
  15. package/extensions/orchestrator/event-handlers.ts +397 -201
  16. package/extensions/orchestrator/integration.test.ts +305 -9
  17. package/extensions/orchestrator/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  18. package/extensions/orchestrator/orchestrator.ts +46 -58
  19. package/extensions/orchestrator/phases/brainstorm.ts +11 -7
  20. package/extensions/orchestrator/phases/machine.test.ts +36 -0
  21. package/extensions/orchestrator/phases/machine.ts +11 -1
  22. package/extensions/orchestrator/phases/review-task.test.ts +20 -0
  23. package/extensions/orchestrator/phases/review-task.ts +44 -16
  24. package/extensions/orchestrator/phases/review.test.ts +26 -0
  25. package/extensions/orchestrator/phases/review.ts +40 -3
  26. package/extensions/orchestrator/pp-menu.test.ts +207 -1
  27. package/extensions/orchestrator/pp-menu.ts +376 -378
  28. package/extensions/orchestrator/state.test.ts +9 -0
  29. package/extensions/orchestrator/state.ts +15 -0
  30. package/extensions/orchestrator/transition-controller.test.ts +100 -0
  31. package/extensions/orchestrator/transition-controller.ts +61 -3
  32. package/package.json +1 -1
  33. package/AGENTS.md +0 -28
@@ -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
  },
@@ -209,6 +210,7 @@ function makeConfig() {
209
210
  return {
210
211
  general: {
211
212
  autoCommit: false,
213
+ injectAgentsMd: true,
212
214
  loadExtraRepoConfigs: true,
213
215
  logLevel: "info",
214
216
  },
@@ -677,8 +679,12 @@ describe("review cycle lifecycle", () => {
677
679
  await ppPhaseComplete.execute("call-2", { summary: "plan done" }, undefined, undefined, ctx);
678
680
  await new Promise((r) => setTimeout(r, 10));
679
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".
680
685
  expectReviewAuto(menu);
681
- expectReviewOnMyOwn(menu, "Skip markers");
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" });
682
688
  const result = await ppPhaseComplete.execute("call-3", { summary: "implemented" }, undefined, undefined, ctx);
683
689
 
684
690
  expect(result.content[0].text).toContain("continue");
@@ -1318,7 +1324,7 @@ describe("edge cases and regressions", () => {
1318
1324
 
1319
1325
  await orchestrator.startTask(ctx as any, "implement", "Continue test");
1320
1326
 
1321
- expectReviewOnMyOwn(menu);
1327
+ expectReviewOnMyOwn(menu, "Skip markers");
1322
1328
  const ppPhaseComplete = getTool(pi, "pp_phase_complete");
1323
1329
  const result = await ppPhaseComplete.execute("call-1", { summary: "not done yet" }, undefined, undefined, ctx);
1324
1330
 
@@ -1400,7 +1406,7 @@ describe("edge cases and regressions", () => {
1400
1406
  "utf-8",
1401
1407
  );
1402
1408
 
1403
- expectReviewOnMyOwn(menu);
1409
+ expectReviewOnMyOwn(menu, "Skip markers");
1404
1410
  const result = await ppPhaseComplete.execute("call-2", { summary: "plan ready" }, undefined, undefined, ctx);
1405
1411
 
1406
1412
  expect(result.content[0].text).toContain("continue");
@@ -1449,6 +1455,83 @@ describe("edge cases and regressions", () => {
1449
1455
  expect(orchestrator.active!.state.step).toBe("llm_work");
1450
1456
  });
1451
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
+
1452
1535
  it("generic description task does not auto-trigger agent", async () => {
1453
1536
  const cwd = makeTempDir();
1454
1537
  const { pi, orchestrator } = await setupOrchestrator(cwd);
@@ -1788,8 +1871,11 @@ describe("task modes and quick task", () => {
1788
1871
  emitSubagentCreated(pi, "reviewer-2", "Code reviewer (test)");
1789
1872
  emitSubagentCompleted(pi, "reviewer-2", "Code reviewer (test)");
1790
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);
1791
1877
  const fourth = await ppPhaseComplete.execute("call-4", { summary: "applied 2" }, undefined, undefined, ctx);
1792
- expect(fourth.content[0].text).toBe("");
1878
+ expect(fourth.content[0].text).toMatch(/completed/);
1793
1879
  expect(orchestrator.active).toBeNull();
1794
1880
  expect(loadTask(taskDir).phase).toBe("done");
1795
1881
  });
@@ -1831,8 +1917,11 @@ describe("task modes and quick task", () => {
1831
1917
  emitSubagentCreated(pi, "reviewer-1", "Code reviewer (test)");
1832
1918
  emitSubagentCompleted(pi, "reviewer-1", "Code reviewer (test)");
1833
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);
1834
1923
  const second = await ppPhaseComplete.execute("call-2", { summary: "applied" }, undefined, undefined, ctx);
1835
- expect(second.content[0].text).toBe("");
1924
+ expect(second.content[0].text).toMatch(/completed/);
1836
1925
  expect(orchestrator.active).toBeNull();
1837
1926
  const finalState = loadTask(taskDir);
1838
1927
  expect(finalState.phase).toBe("done");
@@ -1912,8 +2001,11 @@ describe("task modes and quick task", () => {
1912
2001
  );
1913
2002
 
1914
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);
1915
2007
  const result = await ppPhaseComplete.execute("call-1", { summary: "checklist repaired" }, undefined, undefined, ctx);
1916
- expect(result.content[0].text).toBe("");
2008
+ expect(result.content[0].text).toMatch(/completed/);
1917
2009
  expect(orchestrator.active).toBeNull();
1918
2010
  expect(loadTask(taskDir).phase).toBe("done");
1919
2011
  });
@@ -2173,6 +2265,66 @@ describe("task modes and quick task", () => {
2173
2265
  expect(prompt).not.toContain("advance it via the /pp menu");
2174
2266
  });
2175
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
+
2176
2328
  it("autonomous plan-phase prompt body contains no /pp guidance", async () => {
2177
2329
  const cwd = makeTempDir();
2178
2330
  const { pi, orchestrator } = await setupOrchestrator(cwd);
@@ -2347,8 +2499,11 @@ describe("task modes and quick task", () => {
2347
2499
  emitSubagentCreated(pi, "reviewer-auto-1", "Code reviewer (test)");
2348
2500
  emitSubagentCompleted(pi, "reviewer-auto-1", "Code reviewer (test)");
2349
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);
2350
2505
  const second = await ppPhaseComplete.execute("call-autonomous-review-2", { summary: "applied" }, undefined, undefined, ctx);
2351
- expect(second.content[0].text).toBe("");
2506
+ expect(second.content[0].text).toMatch(/completed/);
2352
2507
  expect(orchestrator.active).toBeNull();
2353
2508
  });
2354
2509
 
@@ -2393,10 +2548,9 @@ describe("task modes and quick task", () => {
2393
2548
  menu
2394
2549
  .expect({ question: "/pp", options: { include: ["Task"] }, choose: "Task" })
2395
2550
  .expect({ question: "Task", options: { include: ["Review"] }, choose: "Review" })
2396
- .expect({ question: "Review", options: { include: ["Describe"] }, choose: "Describe" })
2551
+ .expect({ question: "Review", options: { exact: ["New", "Resume", "Back"] }, choose: "New" })
2397
2552
  .expect({ question: "Mode", options: { include: ["Autonomous"] }, choose: "Autonomous" })
2398
2553
  .expect({ question: "Autonomous", options: { include: ["Start"] }, choose: "Start" });
2399
- ctx.ui.input.mockResolvedValueOnce("Review current branch changes");
2400
2554
  const pp = getCommand(pi, "pp");
2401
2555
  await pp(undefined, ctx);
2402
2556
 
@@ -2466,6 +2620,11 @@ describe("review task lifecycle", () => {
2466
2620
  await orchestrator.startTask(ctx as any, "review", "Review flow");
2467
2621
  const taskDir = orchestrator.active!.dir;
2468
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
+
2469
2628
  await moveTaskToImplementPhase(pi, orchestrator, ctx, "call-review-to-plan", "call-plan-to-implement");
2470
2629
  expect(orchestrator.active!.state.phase).toBe("implement");
2471
2630
 
@@ -2557,6 +2716,22 @@ describe("modified file tracking", () => {
2557
2716
  expect(orchestrator.active!.state.reviewApprovedClean).toBe(false);
2558
2717
  });
2559
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
+
2560
2735
  it("tool_result ignores writes inside .pp directory", async () => {
2561
2736
  const cwd = makeTempDir();
2562
2737
  const { pi, orchestrator } = await setupOrchestrator(cwd);
@@ -3403,6 +3578,76 @@ describe("error retry", () => {
3403
3578
  expect(pi.sendUserMessage).toHaveBeenCalledWith(expect.stringContaining("Continue the implement phase"), { deliverAs: "followUp" });
3404
3579
  });
3405
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
+
3406
3651
  it("suppresses nudges while the controller is transitioning (not running)", async () => {
3407
3652
  const cwd = makeTempDir();
3408
3653
  const { pi, orchestrator } = await setupOrchestrator(cwd);
@@ -3634,6 +3879,54 @@ describe("compaction", () => {
3634
3879
  expect(result.compaction.summary).toBe("Task done summary");
3635
3880
  });
3636
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
+
3637
3930
  it("session_before_compact re-injects artifacts after natural compaction", async () => {
3638
3931
  const cwd = makeTempDir();
3639
3932
  const { pi, orchestrator } = await setupOrchestrator(cwd);
@@ -4234,6 +4527,9 @@ describe("full user flows", () => {
4234
4527
  emitSubagentCreated(pi, "flow-auto-reviewer", "Code reviewer (test)");
4235
4528
  emitSubagentCompleted(pi, "flow-auto-reviewer", "Code reviewer (test)");
4236
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);
4237
4533
  await ppPhaseComplete.execute("flow-auto-4", { summary: "feedback applied" }, undefined, undefined, ctx);
4238
4534
 
4239
4535
  expect(orchestrator.active).toBeNull();
@@ -0,0 +1 @@
1
+ {"version":"4.1.4","results":[[":pp-menu.test.ts",{"duration":5.372961000000032,"failed":false}],[":integration.test.ts",{"duration":943.253143,"failed":false}],[":phases/review.test.ts",{"duration":2.5133239999999546,"failed":false}],[":pr-comments.test.ts",{"duration":4.420278999999994,"failed":false}],[":transition-controller.test.ts",{"duration":5.222491000000005,"failed":false}],[":event-handlers.test.ts",{"duration":10.928670000000011,"failed":false}],[":ai-comment-cleanup.test.ts",{"duration":3.3210659999999947,"failed":false}],[":flant-infra.test.ts",{"duration":40.368476,"failed":false}],[":command-handlers.test.ts",{"duration":13.727003000000025,"failed":false}],[":usage-tracker.test.ts",{"duration":7.265218000000004,"failed":false}],[":doctor.test.ts",{"duration":10.992058,"failed":false}],[":context.test.ts",{"duration":8.419984999999997,"failed":false}],[":orchestrator.test.ts",{"duration":12.065533000000016,"failed":false}],[":config.test.ts",{"duration":8.647639000000026,"failed":false}],[":model-registry.test.ts",{"duration":9.934792000000002,"failed":false}],[":state.test.ts",{"duration":13.755476000000002,"failed":false}],[":commands.test.ts",{"duration":270.744763,"failed":false}],[":phases/machine.test.ts",{"duration":8.854923999999983,"failed":false}],[":pp-state-tools.test.ts",{"duration":7.878408999999991,"failed":false}],[":log.test.ts",{"duration":884.438896,"failed":false}],[":repo-utils.test.ts",{"duration":3.7061610000000016,"failed":false}],[":phases/verdict.test.ts",{"duration":4.656940000000006,"failed":false}],[":rate-limit-fallback-flow.test.ts",{"duration":6.88740700000001,"failed":false}],[":agents/prompts.test.ts",{"duration":4.860277999999994,"failed":false}],[":tracer.test.ts",{"duration":3.9392250000000004,"failed":false}],[":rate-limit-fallback.test.ts",{"duration":3.124099000000001,"failed":false}],[":validate-artifacts.test.ts",{"duration":3.718504000000003,"failed":false}],[":plannotator.test.ts",{"duration":6.168539999999993,"failed":false}],[":agents/constraints.test.ts",{"duration":2.458521999999995,"failed":false}],[":custom-footer.test.ts",{"duration":3.8825789999999927,"failed":false}],[":phases/spawn-blocking.test.ts",{"duration":54.621959000000004,"failed":false}],[":exa.test.ts",{"duration":3.8611999999999966,"failed":false}],[":spawn-cleanup.test.ts",{"duration":2.2092779999999976,"failed":false}],[":subagent-session-marker.test.ts",{"duration":1.6192010000000039,"failed":false}],[":model-version.test.ts",{"duration":5.690410999999997,"failed":false}],[":messages.test.ts",{"duration":1.798244000000004,"failed":false}],[":cbm.test.ts",{"duration":2.306488999999999,"failed":false}],[":phases/brainstorm.test.ts",{"duration":1.6732620000000225,"failed":false}],[":phases/planning.test.ts",{"duration":1.6508400000000165,"failed":false}],[":phases/implementation.test.ts",{"duration":2.0228309999999965,"failed":false}]]}
@@ -13,7 +13,6 @@ import {
13
13
  type TaskState,
14
14
  type Phase,
15
15
  } from "./state.js";
16
- import { phasePipeline } from "./phases/machine.js";
17
16
  import { getContextDirs, loadAllContextFiles, getPhaseArtifacts, getLatestSynthesizedPlan } from "./context.js";
18
17
  import { brainstormSystemPrompt } from "./phases/brainstorm.js";
19
18
  import { planningSystemPrompt, spawnPlanners } from "./phases/planning.js";
@@ -31,6 +30,7 @@ import { resolveModel, getModelInfo, findLatestFamilyMatch, setSubscriptionFallb
31
30
  import { buildRepoContext } from "./agents/repo-context.js";
32
31
  import { getLogger, addTaskDestination, removeTaskDestination, setLogLevel } from "./log.js";
33
32
  import { handleSpawnResult } from "./spawn-cleanup.js";
33
+ import { getTracer } from "./tracer.js";
34
34
  import { TransitionController, type TransitionHost } from "./transition-controller.js";
35
35
 
36
36
  function isEnabled(value: { enabled?: boolean } | undefined): boolean {
@@ -73,6 +73,16 @@ export class Orchestrator {
73
73
  step?: string;
74
74
  }>();
75
75
  staleAgentTimer: ReturnType<typeof setInterval> | null = null;
76
+ // Main-turn stall watchdog (BUG-2). A main turn that starts but never emits a
77
+ // terminal turn_end/error can wedge the session ("Working…" forever). Unlike
78
+ // staleAgentTimer (subagents only), this watches the MAIN session: any main-
79
+ // session stream/tool/turn activity refreshes mainTurnLastActivity; when a turn
80
+ // is in flight with no activity beyond config.performance.internals.mainTurnStale,
81
+ // the watchdog recovers via the idle-gated single-send path.
82
+ mainTurnTimer: ReturnType<typeof setInterval> | null = null;
83
+ mainTurnLastActivity = 0;
84
+ mainTurnInFlight = false;
85
+ mainTurnRecovering = false;
76
86
  // Single consecutive-nudge guard (replaces the old multi-tier throttle). Reset
77
87
  // to 0 on any productive turn; once it reaches the cap the nudges halt with one
78
88
  // user notification.
@@ -96,6 +106,14 @@ export class Orchestrator {
96
106
  // without this ESC would not cancel it.
97
107
  pendingRetryEscUnsub: (() => void) | null = null;
98
108
  activeTaskToken = 0;
109
+ // Side-channel for stale-nudge re-validation. A continuation nudge is delivered
110
+ // as a followUp whose prompt STRING carries no phase/task token, and the SDK
111
+ // queue only surfaces the string in before_agent_start. So at nudge-generation
112
+ // time we record {phase, taskToken} keyed by the exact nudge string; at delivery
113
+ // we re-check both against the live phase/token and drop the nudge on mismatch
114
+ // (a nudge generated for an old phase/task must not drive a turn in the new one).
115
+ // Last-wins per distinct string; a stale entry only ever fails-closed to drop.
116
+ pendingNudges = new Map<string, { phase: Phase; taskToken: number }>();
99
117
  // Subscription rate-limit fallback (Issue 5). subFallbackActive mirrors the
100
118
  // model-registry override flag; subFallbackDialogPending guards against
101
119
  // opening more than one switch dialogue at a time (across main + subagents);
@@ -103,6 +121,11 @@ export class Orchestrator {
103
121
  // switch-back probe); subSwitchBackTimer is the fixed-interval probe timer.
104
122
  subFallbackActive = false;
105
123
  subFallbackDialogPending = false;
124
+ // True while a user-facing dialogue (ask_user / the /pp menu / any interactive
125
+ // selectOption) is open. The main-turn watchdog skips while set so a turn
126
+ // legitimately parked on a human is not aborted. Set on dialogue open, cleared
127
+ // in finally on every exit (resolve, ESC/cancel, error).
128
+ interactivePromptOpen = false;
106
129
  // Set SYNCHRONOUSLY the moment a sub-429 is detected (before any async dialog),
107
130
  // and cleared once the decision resolves. The autonomous planner/reviewer
108
131
  // auto-retry consults this to avoid re-spawning a failed variant on the still-
@@ -129,10 +152,16 @@ export class Orchestrator {
129
152
  checkPlannerCompletion: () => void = () => {};
130
153
  readonly transitionController: TransitionController;
131
154
 
155
+ // The single live instance, so module-level dialogue wrappers (selectOption in
156
+ // event-handlers/pp-menu) can toggle interactivePromptOpen without threading a
157
+ // reference through ~70 call sites.
158
+ static current: Orchestrator | null = null;
159
+
132
160
  constructor(readonly pi: ExtensionAPI) {
133
161
  // The controller calls pi (the main session) directly for sends, and uses the
134
162
  // host only for live-ctx-dependent bits (compact/isIdle/currentStep).
135
163
  this.transitionController = new TransitionController(this.makeTransitionHost(), this.pi);
164
+ Orchestrator.current = this;
136
165
  }
137
166
 
138
167
  // Live-session host the TransitionController uses for compaction/idle/step.
@@ -329,67 +358,17 @@ export class Orchestrator {
329
358
  return true;
330
359
  }
331
360
 
361
+ // The footer's phase/mode display (line 1) reads orchestrator state directly; this only
362
+ // sets a hidden "pp-phase" status whose value changes per transition so the host repaints
363
+ // the footer. Nothing renders this string (footer line 3 was removed), so it stays terse.
332
364
  updateStatus(ctx: ExtensionContext): void {
333
365
  if (!this.active || this.active.state.phase === "done") {
334
366
  ctx.ui.setStatus("pp-phase", undefined);
335
367
  return;
336
368
  }
337
-
338
- const type = this.active.type;
339
- const phase = this.active.state.phase;
340
- const step = this.active.state.step;
341
- const reviewCycle = this.active.state.reviewCycle;
342
- const effectiveMode = this.active.state.effectiveMode ?? this.active.state.mode;
343
- const modeLabel = effectiveMode === "autonomous" ? " [autonomous]" : "";
344
-
345
- if (type === "debug" || type === "brainstorm" || type === "quick") {
346
- const elapsed = this.phaseStartTime > 0 ? this.formatElapsed(this.phaseStartTime) : "";
347
- const suffix = elapsed ? ` (${elapsed})` : "";
348
- ctx.ui.setStatus("pp-phase", `pp: ${type}${modeLabel}${suffix}`);
349
- return;
350
- }
351
-
352
- const pipeline = phasePipeline(type).filter((p) => p !== "done");
353
- const currentIdx = pipeline.indexOf(phase as (typeof pipeline)[number]);
354
-
355
- const parts: string[] = [];
356
- for (let i = 0; i < pipeline.length; i++) {
357
- const p = pipeline[i];
358
- if (i < currentIdx) {
359
- parts.push(`✔ ${p}`);
360
- } else if (p === phase) {
361
- let detail = "";
362
- if (step === "await_planners") detail = "planners";
363
- else if (step === "await_reviewers") detail = "reviewers";
364
- else if (step === "synthesize") detail = "synthesize";
365
- else if (step === "apply_feedback") detail = "feedback";
366
- else if (step === "user_gate") detail = "review";
367
-
368
- if (reviewCycle) {
369
- const kind = reviewCycle.kind === "plannotator" ? "plannotator" : "review";
370
- detail = `${kind} #${reviewCycle.pass}`;
371
- }
372
-
373
- const elapsed = this.phaseStartTime > 0 ? this.formatElapsed(this.phaseStartTime) : "";
374
- const sub = [detail, elapsed].filter(Boolean).join(", ");
375
- parts.push(sub ? `${p} (${sub})` : p);
376
- } else {
377
- parts.push(p);
378
- }
379
- }
380
-
381
- ctx.ui.setStatus("pp-phase", `pp: ${parts.join(" → ")}${modeLabel}`);
382
- }
383
-
384
- private formatElapsed(startTime: number): string {
385
- const sec = Math.floor((Date.now() - startTime) / 1000);
386
- if (sec < 60) return `${sec}s`;
387
- const min = Math.floor(sec / 60);
388
- const remSec = sec % 60;
389
- if (min < 60) return remSec > 0 ? `${min}m ${remSec}s` : `${min}m`;
390
- const hr = Math.floor(min / 60);
391
- const remMin = min % 60;
392
- return remMin > 0 ? `${hr}h ${remMin}m` : `${hr}h`;
369
+ const s = this.active.state;
370
+ const cycle = s.reviewCycle ? `:${s.reviewCycle.kind}#${s.reviewCycle.pass}` : "";
371
+ ctx.ui.setStatus("pp-phase", `${this.active.type}:${s.phase}:${s.step}:${getEffectivePhaseMode(s)}${cycle}`);
393
372
  }
394
373
 
395
374
  getPlanStartState(taskDir: string, plannerPresetName?: string): { step: string; shouldSpawnPlanners: boolean } {
@@ -480,7 +459,8 @@ export class Orchestrator {
480
459
  this.lastCtx = ctx;
481
460
  await this.transitionController.requestTransition({
482
461
  kind: "done",
483
- summary: `Starting new ${type} task. Previous conversation discarded.`,
462
+ discard: true,
463
+ summary: `A new, unrelated ${type} task is starting. The previous task is finished — DISCARD its entire conversation. Do NOT carry forward, reference, or act on any prior task's messages, phase, plan, or aborted turns; treat the new task as a clean slate.`,
484
464
  });
485
465
  }
486
466
 
@@ -653,6 +633,7 @@ export class Orchestrator {
653
633
  this.commitReminderSent = false;
654
634
  this.consecutiveNudges = 0;
655
635
  this.nudgeHalted = false;
636
+ this.pendingNudges.clear();
656
637
  this.phaseStartTime = 0;
657
638
  this.userGatePending = false;
658
639
  this.failedPlannerVariants = [];
@@ -668,6 +649,12 @@ export class Orchestrator {
668
649
  clearInterval(this.staleAgentTimer);
669
650
  this.staleAgentTimer = null;
670
651
  }
652
+ if (this.mainTurnTimer) {
653
+ clearInterval(this.mainTurnTimer);
654
+ this.mainTurnTimer = null;
655
+ }
656
+ this.mainTurnInFlight = false;
657
+ this.mainTurnRecovering = false;
671
658
  this.clearSubscriptionFallback();
672
659
  }
673
660
 
@@ -681,6 +668,7 @@ export class Orchestrator {
681
668
  }
682
669
  this.subFallbackActive = false;
683
670
  this.subFallbackDialogPending = false;
671
+ this.interactivePromptOpen = false;
684
672
  this.subFallbackPendingDecision = false;
685
673
  this.subFallbackModelId = null;
686
674
  setSubscriptionFallbackActive(false);