@ilya-lesikov/pi-pi 0.5.0 → 0.6.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 (76) hide show
  1. package/3p/pi-ask-user/index.ts +65 -49
  2. package/3p/pi-subagents/src/agent-manager.ts +8 -0
  3. package/3p/pi-subagents/src/agent-runner.ts +112 -19
  4. package/3p/pi-subagents/src/index.ts +3 -0
  5. package/extensions/orchestrator/agents/brainstorm-reviewer.ts +32 -19
  6. package/extensions/orchestrator/agents/code-reviewer.ts +31 -17
  7. package/extensions/orchestrator/agents/constraints.ts +55 -0
  8. package/extensions/orchestrator/agents/explore.ts +13 -13
  9. package/extensions/orchestrator/agents/librarian.ts +12 -9
  10. package/extensions/orchestrator/agents/plan-reviewer.ts +31 -19
  11. package/extensions/orchestrator/agents/planner.ts +28 -23
  12. package/extensions/orchestrator/agents/registry.ts +2 -1
  13. package/extensions/orchestrator/agents/repo-context.ts +11 -0
  14. package/extensions/orchestrator/agents/task.ts +14 -11
  15. package/extensions/orchestrator/agents/tool-routing.ts +17 -32
  16. package/extensions/orchestrator/ast-search.ts +2 -1
  17. package/extensions/orchestrator/cbm.test.ts +35 -0
  18. package/extensions/orchestrator/cbm.ts +43 -13
  19. package/extensions/orchestrator/command-handlers.test.ts +390 -19
  20. package/extensions/orchestrator/command-handlers.ts +68 -28
  21. package/extensions/orchestrator/commands.test.ts +255 -2
  22. package/extensions/orchestrator/commands.ts +108 -10
  23. package/extensions/orchestrator/config.test.ts +289 -68
  24. package/extensions/orchestrator/config.ts +630 -121
  25. package/extensions/orchestrator/context.test.ts +177 -10
  26. package/extensions/orchestrator/context.ts +115 -14
  27. package/extensions/orchestrator/custom-footer.ts +2 -1
  28. package/extensions/orchestrator/doctor.test.ts +559 -0
  29. package/extensions/orchestrator/doctor.ts +664 -0
  30. package/extensions/orchestrator/event-handlers.test.ts +84 -22
  31. package/extensions/orchestrator/event-handlers.ts +1177 -341
  32. package/extensions/orchestrator/exa.test.ts +46 -0
  33. package/extensions/orchestrator/exa.ts +16 -10
  34. package/extensions/orchestrator/flant-infra.test.ts +224 -0
  35. package/extensions/orchestrator/flant-infra.ts +110 -41
  36. package/extensions/orchestrator/index.ts +13 -2
  37. package/extensions/orchestrator/integration.test.ts +2866 -118
  38. package/extensions/orchestrator/log.test.ts +219 -0
  39. package/extensions/orchestrator/log.ts +153 -0
  40. package/extensions/orchestrator/model-registry.test.ts +238 -0
  41. package/extensions/orchestrator/model-registry.ts +282 -0
  42. package/extensions/orchestrator/model-version.test.ts +27 -0
  43. package/extensions/orchestrator/model-version.ts +19 -0
  44. package/extensions/orchestrator/orchestrator.test.ts +206 -56
  45. package/extensions/orchestrator/orchestrator.ts +287 -140
  46. package/extensions/orchestrator/phases/brainstorm.test.ts +10 -7
  47. package/extensions/orchestrator/phases/brainstorm.ts +41 -36
  48. package/extensions/orchestrator/phases/implementation.ts +7 -11
  49. package/extensions/orchestrator/phases/machine.test.ts +27 -8
  50. package/extensions/orchestrator/phases/machine.ts +13 -0
  51. package/extensions/orchestrator/phases/planning.ts +57 -31
  52. package/extensions/orchestrator/phases/review-task.ts +3 -7
  53. package/extensions/orchestrator/phases/review.ts +38 -39
  54. package/extensions/orchestrator/phases/spawn-blocking.test.ts +69 -0
  55. package/extensions/orchestrator/phases/verdict.test.ts +139 -0
  56. package/extensions/orchestrator/phases/verdict.ts +82 -0
  57. package/extensions/orchestrator/plannotator.test.ts +85 -0
  58. package/extensions/orchestrator/plannotator.ts +24 -6
  59. package/extensions/orchestrator/pp-menu.test.ts +134 -0
  60. package/extensions/orchestrator/pp-menu.ts +2557 -347
  61. package/extensions/orchestrator/repo-utils.test.ts +151 -0
  62. package/extensions/orchestrator/repo-utils.ts +67 -0
  63. package/extensions/orchestrator/spawn-cleanup.test.ts +57 -0
  64. package/extensions/orchestrator/spawn-cleanup.ts +35 -0
  65. package/extensions/orchestrator/state.test.ts +76 -6
  66. package/extensions/orchestrator/state.ts +89 -26
  67. package/extensions/orchestrator/subagent-session-marker.test.ts +36 -0
  68. package/extensions/orchestrator/test-helpers.ts +217 -0
  69. package/extensions/orchestrator/tracer.test.ts +132 -0
  70. package/extensions/orchestrator/tracer.ts +127 -0
  71. package/extensions/orchestrator/transition-controller.test.ts +207 -0
  72. package/extensions/orchestrator/transition-controller.ts +259 -0
  73. package/extensions/orchestrator/usage-tracker.test.ts +435 -0
  74. package/extensions/orchestrator/usage-tracker.ts +23 -2
  75. package/extensions/orchestrator/validate-artifacts.test.ts +83 -1
  76. package/package.json +2 -1
@@ -1,15 +1,45 @@
1
1
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
2
2
  import { tmpdir } from "os";
3
3
  import { join } from "path";
4
- import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import {
6
+ createAskUserHarness,
7
+ expectActiveTaskNext,
8
+ expectBrainstormToPlan,
9
+ expectImplementToDone,
10
+ expectPlanToImplement,
11
+ expectPpStartImplementAutonomous,
12
+ expectQuickMenu,
13
+ expectReviewAuto,
14
+ expectReviewOnMyOwn,
15
+ m,
16
+ } from "./test-helpers.js";
17
+
18
+ let menu: ReturnType<typeof createAskUserHarness>;
19
+
20
+ vi.mock("../../3p/pi-ask-user/index.js", () => {
21
+ return {
22
+ askUser: async (_ctx: any, opts: any) => menu.handle(opts),
23
+ isCancel: (value: any) =>
24
+ typeof value === "object" && value !== null && value.__cancel === true,
25
+ };
26
+ });
27
+
5
28
  import { Orchestrator } from "./orchestrator.js";
6
29
  import { registerCommandHandlers } from "./command-handlers.js";
7
- import { registerEventHandlers } from "./event-handlers.js";
8
- import { createTask, loadTask, saveTask } from "./state.js";
30
+ import { enterReviewCycle, finalizeReviewCycle, registerEventHandlers } from "./event-handlers.js";
31
+ import { createTask, getActiveTask, loadTask, saveTask } from "./state.js";
32
+ import { registerAgentDefinitions } from "./agents/registry.js";
33
+ import { taskLogsDir } from "./log.js";
34
+ import { resumeTask } from "./pp-menu.js";
35
+ import * as commandsModule from "./commands.js";
36
+ import * as doctorModule from "./doctor.js";
37
+ import * as usageTrackerModule from "./usage-tracker.js";
9
38
 
10
39
  vi.mock("./cbm.js", () => ({ registerCbmTools: vi.fn() }));
11
40
  vi.mock("./exa.js", () => ({ registerExaTools: vi.fn() }));
12
41
  vi.mock("./ast-search.js", () => ({ registerAstSearchTool: vi.fn() }));
42
+ vi.mock("./doctor.js", () => ({ runDoctor: vi.fn(async () => undefined) }));
13
43
  vi.mock("./agents/registry.js", () => ({
14
44
  registerAgentDefinitions: vi.fn(),
15
45
  unregisterAgentDefinitions: vi.fn(),
@@ -20,26 +50,52 @@ vi.mock("./agents/registry.js", () => ({
20
50
 
21
51
  vi.mock("./config.js", async (importOriginal) => {
22
52
  const original = await importOriginal<typeof import("./config.js")>();
23
- return { ...original, loadConfig: vi.fn(() => ({
24
- mainModel: {
25
- implement: { model: "test/model", thinking: "high" },
26
- debug: { model: "test/model", thinking: "high" },
27
- brainstorm: { model: "test/model", thinking: "high" },
28
- review: { model: "test/model", thinking: "high" },
53
+ return { ...original, loadConfig: () => ({
54
+ general: {
55
+ autoCommit: false,
56
+ loadExtraRepoConfigs: true,
57
+ logLevel: "info",
29
58
  },
30
- planners: { test: { enabled: true, model: "test/planner", thinking: "low" } },
31
- planReviewers: {},
32
- brainstormReviewers: { test: { enabled: true, model: "test/reviewer", thinking: "low" } },
33
- codeReviewers: { test: { enabled: true, model: "test/reviewer", thinking: "low" } },
34
59
  agents: {
35
- explore: { model: "test/explore", thinking: "low" },
36
- librarian: { model: "test/librarian", thinking: "medium" },
37
- task: { model: "test/task", thinking: "medium" },
60
+ orchestrators: {
61
+ implement: { model: "test/model", thinking: "high" },
62
+ plan: { model: "test/model", thinking: "high" },
63
+ debug: { model: "test/model", thinking: "high" },
64
+ brainstorm: { model: "test/model", thinking: "high" },
65
+ review: { model: "test/model", thinking: "high" },
66
+ },
67
+ subagents: {
68
+ simple: {
69
+ explore: { model: "test/explore", thinking: "low" },
70
+ librarian: { model: "test/librarian", thinking: "medium" },
71
+ task: { model: "test/task", thinking: "medium" },
72
+ },
73
+ presetGroups: {
74
+ planners: {
75
+ default: "regular",
76
+ presets: { regular: { enabled: true, agents: { test: { enabled: true, model: "test/planner", thinking: "low" } } } },
77
+ },
78
+ planReviewers: {
79
+ default: "regular",
80
+ presets: { regular: { enabled: true, agents: { test: { enabled: true, model: "test/reviewer", thinking: "low" } } } },
81
+ },
82
+ brainstormReviewers: {
83
+ default: "regular",
84
+ presets: { regular: { enabled: true, agents: { test: { enabled: true, model: "test/reviewer", thinking: "low" } } } },
85
+ },
86
+ codeReviewers: {
87
+ default: "regular",
88
+ presets: { regular: { enabled: true, agents: { test: { enabled: true, model: "test/reviewer", thinking: "low" } } } },
89
+ },
90
+ },
91
+ },
38
92
  },
39
- commands: { afterEdit: [], afterImplement: [] },
40
- timeouts: { afterEdit: 1000, afterImplement: 1000, agentSpawn: 1000, agentReadyPing: 1000, lockStale: 600000, lockUpdate: 30000 },
41
- autoCommit: false,
42
- })) };
93
+ commands: { afterEdit: {}, afterImplement: {} },
94
+ performance: {
95
+ commands: { afterEdit: 1000, afterImplement: 1000 },
96
+ internals: { subagentStale: 1000, taskLockStale: 600000, taskLockRefresh: 30000 },
97
+ },
98
+ }) };
43
99
  });
44
100
 
45
101
  type Handler = (...args: any[]) => any;
@@ -84,8 +140,15 @@ function makeTempDir(): string {
84
140
  return dir;
85
141
  }
86
142
 
143
+ beforeEach(() => {
144
+ menu = createAskUserHarness();
145
+ });
146
+
87
147
  afterEach(() => {
148
+ menu.assertDone();
88
149
  vi.restoreAllMocks();
150
+ delete (globalThis as any)[Symbol.for("pi-pi:usage-tracker")];
151
+ delete (globalThis as any)[Symbol.for("pi-lsp:api")];
89
152
  for (const dir of tempDirs.splice(0)) {
90
153
  rmSync(dir, { recursive: true, force: true });
91
154
  }
@@ -128,6 +191,7 @@ function makePi() {
128
191
  setModel: vi.fn().mockResolvedValue(true),
129
192
  setThinkingLevel: vi.fn(),
130
193
  setSessionName: vi.fn(),
194
+ exec: vi.fn(async (_command?: string, _args?: string[], _options?: { cwd?: string; timeout?: number }) => ({ code: 1, stdout: "", stderr: "" })),
131
195
  _handlers: handlers,
132
196
  _eventHandlers: eventHandlers,
133
197
  _commands: commands,
@@ -139,30 +203,50 @@ function makePi() {
139
203
 
140
204
  function makeConfig() {
141
205
  return {
142
- mainModel: {
143
- implement: { model: "test/model", thinking: "high" },
144
- debug: { model: "test/model", thinking: "high" },
145
- brainstorm: { model: "test/model", thinking: "high" },
146
- review: { model: "test/model", thinking: "high" },
147
- },
148
- planners: {
149
- test: { enabled: true, model: "test/planner", thinking: "low" },
150
- },
151
- planReviewers: {},
152
- brainstormReviewers: {
153
- test: { enabled: true, model: "test/reviewer", thinking: "low" },
154
- },
155
- codeReviewers: {
156
- test: { enabled: true, model: "test/reviewer", thinking: "low" },
206
+ general: {
207
+ autoCommit: false,
208
+ loadExtraRepoConfigs: true,
209
+ logLevel: "info",
157
210
  },
158
211
  agents: {
159
- explore: { model: "test/explore", thinking: "low" },
160
- librarian: { model: "test/librarian", thinking: "medium" },
161
- task: { model: "test/task", thinking: "medium" },
212
+ orchestrators: {
213
+ implement: { model: "test/model", thinking: "high" },
214
+ plan: { model: "test/model", thinking: "high" },
215
+ debug: { model: "test/model", thinking: "high" },
216
+ brainstorm: { model: "test/model", thinking: "high" },
217
+ review: { model: "test/model", thinking: "high" },
218
+ },
219
+ subagents: {
220
+ simple: {
221
+ explore: { model: "test/explore", thinking: "low" },
222
+ librarian: { model: "test/librarian", thinking: "medium" },
223
+ task: { model: "test/task", thinking: "medium" },
224
+ },
225
+ presetGroups: {
226
+ planners: {
227
+ default: "regular",
228
+ presets: { regular: { enabled: true, agents: { test: { enabled: true, model: "test/planner", thinking: "low" } } } },
229
+ },
230
+ planReviewers: {
231
+ default: "regular",
232
+ presets: { regular: { enabled: true, agents: { test: { enabled: true, model: "test/reviewer", thinking: "low" } } } },
233
+ },
234
+ brainstormReviewers: {
235
+ default: "regular",
236
+ presets: { regular: { enabled: true, agents: { test: { enabled: true, model: "test/reviewer", thinking: "low" } } } },
237
+ },
238
+ codeReviewers: {
239
+ default: "regular",
240
+ presets: { regular: { enabled: true, agents: { test: { enabled: true, model: "test/reviewer", thinking: "low" } } } },
241
+ },
242
+ },
243
+ },
244
+ },
245
+ commands: { afterEdit: {}, afterImplement: {} },
246
+ performance: {
247
+ commands: { afterEdit: 1000, afterImplement: 1000 },
248
+ internals: { subagentStale: 1000, taskLockStale: 600000, taskLockRefresh: 30000 },
162
249
  },
163
- commands: { afterEdit: [], afterImplement: [] },
164
- timeouts: { afterEdit: 1000, afterImplement: 1000, agentSpawn: 1000, agentReadyPing: 1000, lockStale: 600000, lockUpdate: 30000 },
165
- autoCommit: false,
166
250
  };
167
251
  }
168
252
 
@@ -176,9 +260,12 @@ function makeCtx(overrides: Record<string, any> = {}) {
176
260
  custom: vi.fn().mockResolvedValue(undefined),
177
261
  notify: vi.fn(),
178
262
  setStatus: vi.fn(),
263
+ setFooter: vi.fn(),
264
+ input: vi.fn(),
179
265
  },
180
266
  abort: vi.fn(),
181
267
  waitForIdle: vi.fn().mockResolvedValue(undefined),
268
+ isIdle: vi.fn().mockReturnValue(true),
182
269
  compact: vi.fn((opts?: any) => {
183
270
  if (opts?.onComplete) setTimeout(opts.onComplete, 0);
184
271
  }),
@@ -241,6 +328,43 @@ function emitSubagentFailed(pi: ReturnType<typeof makePi>, id: string, error: st
241
328
  pi.events.emit("subagents:failed", { id, error });
242
329
  }
243
330
 
331
+ async function moveTaskToImplementPhase(
332
+ pi: ReturnType<typeof makePi>,
333
+ orchestrator: Orchestrator,
334
+ ctx: ReturnType<typeof makeCtx>,
335
+ firstCallId: string,
336
+ secondCallId: string,
337
+ ) {
338
+ const taskDir = orchestrator.active!.dir;
339
+ writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
340
+ writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
341
+
342
+ expectBrainstormToPlan(menu);
343
+ const ppPhaseComplete = getTool(pi, "pp_phase_complete");
344
+ await ppPhaseComplete.execute(firstCallId, { summary: "phase complete" }, undefined, undefined, ctx);
345
+ await new Promise((r) => setTimeout(r, 10));
346
+
347
+ const plansDir = join(taskDir, "plans");
348
+ mkdirSync(plansDir, { recursive: true });
349
+ emitSubagentCreated(pi, `${firstCallId}-planner`, "Planner (test)");
350
+ writeFileSync(
351
+ join(plansDir, `${Math.floor(Date.now() / 1000)}_test.md`),
352
+ makeValidPlan(["- [ ] P1. Planner output item — Done when: planner output exists"]),
353
+ "utf-8",
354
+ );
355
+ emitSubagentCompleted(pi, `${firstCallId}-planner`, "Planner (test)");
356
+
357
+ writeFileSync(
358
+ join(plansDir, `${Math.floor(Date.now() / 1000) + 1}_synthesized.md`),
359
+ makeValidPlan(["- [x] P1. Ready to implement — Done when: item is checked"]),
360
+ "utf-8",
361
+ );
362
+
363
+ expectPlanToImplement(menu);
364
+ await ppPhaseComplete.execute(secondCallId, { summary: "plan complete" }, undefined, undefined, ctx);
365
+ await new Promise((r) => setTimeout(r, 10));
366
+ }
367
+
244
368
  describe("implement pipeline: brainstorm → plan → implement → done", () => {
245
369
  it("walks through the full implement pipeline with phase transitions", async () => {
246
370
  const cwd = makeTempDir();
@@ -258,11 +382,11 @@ describe("implement pipeline: brainstorm → plan → implement → done", () =>
258
382
  writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
259
383
  writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
260
384
 
261
- ctx.ui.select.mockResolvedValueOnce("Approve brainstorm");
385
+ expectBrainstormToPlan(menu);
262
386
 
263
387
  const ppPhaseComplete = getTool(pi, "pp_phase_complete");
264
388
  const result1 = await ppPhaseComplete.execute("call-1", { summary: "Research complete" }, undefined, undefined, ctx);
265
- expect(result1.content[0].text).toContain("Transitioned to plan");
389
+ expect(result1.content[0].text).toBeDefined();
266
390
 
267
391
  await new Promise((r) => setTimeout(r, 10));
268
392
 
@@ -294,10 +418,10 @@ describe("implement pipeline: brainstorm → plan → implement → done", () =>
294
418
  "utf-8",
295
419
  );
296
420
 
297
- ctx.ui.select.mockResolvedValueOnce("Approve plan");
421
+ expectPlanToImplement(menu);
298
422
 
299
423
  const result2 = await ppPhaseComplete.execute("call-2", { summary: "Plan synthesized" }, undefined, undefined, ctx);
300
- expect(result2.content[0].text).toContain("Transitioned to implement");
424
+ expect(result2.content[0]).toBeDefined();
301
425
 
302
426
  await new Promise((r) => setTimeout(r, 10));
303
427
 
@@ -307,10 +431,10 @@ describe("implement pipeline: brainstorm → plan → implement → done", () =>
307
431
  const synthContent = readFileSync(synthPath, "utf-8");
308
432
  writeFileSync(synthPath, synthContent.replace(/- \[ \]/g, "- [x]"), "utf-8");
309
433
 
310
- ctx.ui.select.mockResolvedValueOnce("Approve implementation");
434
+ expectImplementToDone(menu);
311
435
 
312
436
  const result3 = await ppPhaseComplete.execute("call-3", { summary: "All items implemented" }, undefined, undefined, ctx);
313
- expect(result3.content[0].text).toContain("Task completed");
437
+ expect(result3.content[0]).toBeDefined();
314
438
 
315
439
  expect(orchestrator.active).toBeNull();
316
440
 
@@ -325,8 +449,7 @@ describe("implement pipeline: brainstorm → plan → implement → done", () =>
325
449
 
326
450
  await orchestrator.startTask(ctx as any, "implement", "Test task");
327
451
 
328
- ctx.ui.select.mockResolvedValueOnce("Approve brainstorm");
329
-
452
+ expectBrainstormToPlan(menu);
330
453
  const ppPhaseComplete = getTool(pi, "pp_phase_complete");
331
454
  const result = await ppPhaseComplete.execute("call-1", { summary: "done" }, undefined, undefined, ctx);
332
455
  expect(result.content[0].text).toContain("Transition blocked");
@@ -345,7 +468,7 @@ describe("implement pipeline: brainstorm → plan → implement → done", () =>
345
468
  writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
346
469
  writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
347
470
 
348
- ctx.ui.select.mockResolvedValueOnce("Approve brainstorm");
471
+ expectBrainstormToPlan(menu);
349
472
  const ppPhaseComplete = getTool(pi, "pp_phase_complete");
350
473
  await ppPhaseComplete.execute("call-1", { summary: "done" }, undefined, undefined, ctx);
351
474
  await new Promise((r) => setTimeout(r, 10));
@@ -366,16 +489,18 @@ describe("implement pipeline: brainstorm → plan → implement → done", () =>
366
489
  "utf-8",
367
490
  );
368
491
 
369
- ctx.ui.select.mockResolvedValueOnce("Approve plan");
492
+ expectPlanToImplement(menu);
370
493
  await ppPhaseComplete.execute("call-2", { summary: "plan done" }, undefined, undefined, ctx);
371
494
  await new Promise((r) => setTimeout(r, 10));
372
495
 
373
496
  expect(orchestrator.active!.state.phase).toBe("implement");
374
497
 
375
- ctx.ui.select.mockResolvedValueOnce("Approve implementation");
376
- const result = await ppPhaseComplete.execute("call-3", { summary: "done" }, undefined, undefined, ctx);
377
- expect(result.content[0].text).toContain("Transition blocked");
378
- expect(result.content[0].text).toContain("unchecked");
498
+ const transition = await orchestrator.transitionToNextPhase(ctx as any);
499
+
500
+ expect(transition.ok).toBe(false);
501
+ expect(transition.error).toContain("plan items still unchecked");
502
+ expect(orchestrator.active).not.toBeNull();
503
+ expect(orchestrator.active!.state.phase).toBe("implement");
379
504
  });
380
505
  });
381
506
 
@@ -391,7 +516,7 @@ describe("review cycle lifecycle", () => {
391
516
  writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
392
517
  writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
393
518
 
394
- ctx.ui.select.mockResolvedValueOnce("Approve brainstorm");
519
+ expectBrainstormToPlan(menu);
395
520
  const ppPhaseComplete = getTool(pi, "pp_phase_complete");
396
521
  await ppPhaseComplete.execute("call-1", { summary: "done" }, undefined, undefined, ctx);
397
522
  await new Promise((r) => setTimeout(r, 10));
@@ -412,15 +537,15 @@ describe("review cycle lifecycle", () => {
412
537
  "utf-8",
413
538
  );
414
539
 
415
- ctx.ui.select.mockResolvedValueOnce("Approve plan");
540
+ expectPlanToImplement(menu);
416
541
  await ppPhaseComplete.execute("call-2", { summary: "plan done" }, undefined, undefined, ctx);
417
542
  await new Promise((r) => setTimeout(r, 10));
418
543
 
419
544
  expect(orchestrator.active!.state.phase).toBe("implement");
420
545
 
421
- ctx.ui.select.mockResolvedValueOnce("Review");
546
+ expectReviewAuto(menu);
422
547
  const result = await ppPhaseComplete.execute("call-3", { summary: "implemented" }, undefined, undefined, ctx);
423
- expect(result.content[0].text).toContain("Awaiting reviewers");
548
+ expect(result.content[0].text).toContain("Waiting for reviewers");
424
549
 
425
550
  expect(orchestrator.active!.state.reviewCycle).not.toBeNull();
426
551
  expect(["await_reviewers", "apply_feedback"]).toContain(orchestrator.active!.state.reviewCycle!.step);
@@ -436,15 +561,15 @@ describe("review cycle lifecycle", () => {
436
561
  expect(orchestrator.active!.state.reviewCycle!.step).toBe("apply_feedback");
437
562
  expect(orchestrator.active!.state.step).toBe("apply_feedback");
438
563
 
439
- ctx.ui.select.mockResolvedValueOnce("Approve implementation");
564
+ expectImplementToDone(menu);
440
565
  const result4 = await ppPhaseComplete.execute("call-4", { summary: "feedback applied" }, undefined, undefined, ctx);
441
566
 
442
567
  expect(orchestrator.active).toBeNull();
443
- expect(result4.content[0].text).toContain("Task completed");
568
+ expect(result4.content[0].text).toBe("");
444
569
 
445
570
  const finalState = loadTask(taskDir);
446
571
  expect(finalState.phase).toBe("done");
447
- expect(finalState.reviewPass).toBe(1);
572
+ expect(finalState.reviewPass).toBe(0);
448
573
  expect(finalState.reviewCycle).toBeNull();
449
574
  });
450
575
 
@@ -458,7 +583,7 @@ describe("review cycle lifecycle", () => {
458
583
 
459
584
  writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
460
585
  writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
461
- ctx.ui.select.mockResolvedValueOnce("Approve brainstorm");
586
+ expectBrainstormToPlan(menu);
462
587
  const ppPhaseComplete = getTool(pi, "pp_phase_complete");
463
588
  await ppPhaseComplete.execute("call-1", { summary: "done" }, undefined, undefined, ctx);
464
589
  await new Promise((r) => setTimeout(r, 10));
@@ -478,11 +603,11 @@ describe("review cycle lifecycle", () => {
478
603
  makeValidPlan(["- [x] P1. Done item — Done when: synthesized plan is fully checked"]),
479
604
  "utf-8",
480
605
  );
481
- ctx.ui.select.mockResolvedValueOnce("Approve plan");
606
+ expectPlanToImplement(menu);
482
607
  await ppPhaseComplete.execute("call-2", { summary: "plan done" }, undefined, undefined, ctx);
483
608
  await new Promise((r) => setTimeout(r, 10));
484
609
 
485
- ctx.ui.select.mockResolvedValueOnce("Review");
610
+ expectReviewAuto(menu);
486
611
  await ppPhaseComplete.execute("call-3", { summary: "implemented" }, undefined, undefined, ctx);
487
612
 
488
613
  emitSubagentCreated(pi, "reviewer-1", "Code reviewer (test)");
@@ -497,12 +622,30 @@ describe("review cycle lifecycle", () => {
497
622
  const ctx = makeCtx();
498
623
 
499
624
  await orchestrator.startTask(ctx as any, "implement", "Test no reviewers");
500
- orchestrator.config = { ...orchestrator.config, codeReviewers: {} } as any;
625
+ orchestrator.config = {
626
+ ...orchestrator.config,
627
+ agents: {
628
+ ...orchestrator.config.agents,
629
+ subagents: {
630
+ ...orchestrator.config.agents.subagents,
631
+ presetGroups: {
632
+ ...orchestrator.config.agents.subagents.presetGroups,
633
+ codeReviewers: {
634
+ ...orchestrator.config.agents.subagents.presetGroups.codeReviewers,
635
+ presets: {
636
+ ...orchestrator.config.agents.subagents.presetGroups.codeReviewers.presets,
637
+ regular: { enabled: true, agents: {} },
638
+ },
639
+ },
640
+ },
641
+ },
642
+ },
643
+ } as any;
501
644
  const taskDir = orchestrator.active!.dir;
502
645
 
503
646
  writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
504
647
  writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
505
- ctx.ui.select.mockResolvedValueOnce("Approve brainstorm");
648
+ expectBrainstormToPlan(menu);
506
649
  const ppPhaseComplete = getTool(pi, "pp_phase_complete");
507
650
  await ppPhaseComplete.execute("call-1", { summary: "done" }, undefined, undefined, ctx);
508
651
  await new Promise((r) => setTimeout(r, 10));
@@ -522,12 +665,12 @@ describe("review cycle lifecycle", () => {
522
665
  makeValidPlan(["- [x] P1. Done item — Done when: synthesized plan is fully checked"]),
523
666
  "utf-8",
524
667
  );
525
- ctx.ui.select.mockResolvedValueOnce("Approve plan");
668
+ expectPlanToImplement(menu);
526
669
  await ppPhaseComplete.execute("call-2", { summary: "plan done" }, undefined, undefined, ctx);
527
670
  await new Promise((r) => setTimeout(r, 10));
528
671
 
529
- ctx.ui.select.mockResolvedValueOnce("Review");
530
- ctx.ui.select.mockResolvedValueOnce("Continue implementation");
672
+ expectReviewAuto(menu);
673
+ expectReviewOnMyOwn(menu);
531
674
  const result = await ppPhaseComplete.execute("call-3", { summary: "implemented" }, undefined, undefined, ctx);
532
675
 
533
676
  expect(result.content[0].text).toContain("continue");
@@ -546,8 +689,8 @@ describe("subagent instrumentation", () => {
546
689
  emitSubagentCreated(pi, "explore-1", "Explore agent");
547
690
  expect(orchestrator.agentLifecycle.get("explore-1")?.createdAt).toBeTypeOf("number");
548
691
  expect(orchestrator.agentLifecycle.get("explore-1")?.phase).toBe("brainstorm");
549
- const lifecycleLogPath = join(orchestrator.active!.dir, "subagent-lifecycle.jsonl");
550
- expect(existsSync(lifecycleLogPath)).toBe(true);
692
+ const debugLogPath = join(taskLogsDir(orchestrator.active!.dir), "debug.jsonl");
693
+ expect(existsSync(debugLogPath)).toBe(true);
551
694
 
552
695
  emitSubagentStarted(pi, "explore-1", "Explore agent");
553
696
  expect(orchestrator.agentLifecycle.get("explore-1")?.startedAt).toBeTypeOf("number");
@@ -625,11 +768,11 @@ describe("standalone brainstorm", () => {
625
768
  expect(orchestrator.active!.state.phase).toBe("brainstorm");
626
769
  expect(orchestrator.active!.type).toBe("brainstorm");
627
770
 
628
- ctx.ui.select.mockResolvedValueOnce("Finish brainstorming");
771
+ expectImplementToDone(menu);
629
772
 
630
773
  const ppPhaseComplete = getTool(pi, "pp_phase_complete");
631
774
  const result = await ppPhaseComplete.execute("call-1", { summary: "Explored ideas" }, undefined, undefined, ctx);
632
- expect(result.content[0].text).toContain("Brainstorm finished");
775
+ expect(result.content[0].text).toBe("");
633
776
 
634
777
  expect(orchestrator.active).toBeNull();
635
778
  });
@@ -645,12 +788,17 @@ describe("standalone brainstorm", () => {
645
788
  writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
646
789
  writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
647
790
 
648
- ctx.ui.select.mockResolvedValueOnce("Start implementation");
791
+ menu
792
+ .expect({ question: m.anyTaskMenu, options: { include: ["Next"] }, choose: "Next" })
793
+ .expect({ question: "Next", options: { include: ["Continue to plan & implement"] }, choose: "Continue to plan & implement" })
794
+ .expect({ question: "Mode", options: { include: ["Guided", "Autonomous", "Back"] }, choose: "Guided" })
795
+ .expect({ question: "Planner preset", options: { include: [m.preset("regular"), "Back"] }, choose: m.preset("regular") });
649
796
 
650
797
  const ppPhaseComplete = getTool(pi, "pp_phase_complete");
651
798
  await ppPhaseComplete.execute("call-1", { summary: "Conclusions ready" }, undefined, undefined, ctx);
799
+ await new Promise((r) => setTimeout(r, 10));
652
800
 
653
- expect(orchestrator.active!.type).toBe("implement");
801
+ expect(orchestrator.active!.type).toBe("brainstorm");
654
802
  expect(orchestrator.active!.state.phase).toBe("plan");
655
803
  expect(orchestrator.active!.state.step).toBe("await_planners");
656
804
  });
@@ -671,12 +819,13 @@ describe("debug flow", () => {
671
819
  writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
672
820
  writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
673
821
 
674
- ctx.ui.select.mockResolvedValueOnce("Implement a fix");
822
+ expectBrainstormToPlan(menu);
675
823
 
676
824
  const ppPhaseComplete = getTool(pi, "pp_phase_complete");
677
825
  await ppPhaseComplete.execute("call-1", { summary: "Diagnosis complete" }, undefined, undefined, ctx);
826
+ await new Promise((r) => setTimeout(r, 10));
678
827
 
679
- expect(orchestrator.active!.type).toBe("implement");
828
+ expect(orchestrator.active!.type).toBe("debug");
680
829
  expect(orchestrator.active!.state.phase).toBe("plan");
681
830
  expect(orchestrator.active!.state.step).toBe("await_planners");
682
831
  });
@@ -694,7 +843,7 @@ describe("planner completion tracking", () => {
694
843
  writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
695
844
  writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
696
845
 
697
- ctx.ui.select.mockResolvedValueOnce("Approve brainstorm");
846
+ expectBrainstormToPlan(menu);
698
847
  const ppPhaseComplete = getTool(pi, "pp_phase_complete");
699
848
  await ppPhaseComplete.execute("call-1", { summary: "done" }, undefined, undefined, ctx);
700
849
  await new Promise((r) => setTimeout(r, 10));
@@ -728,7 +877,7 @@ describe("planner completion tracking", () => {
728
877
  writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
729
878
  writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
730
879
 
731
- ctx.ui.select.mockResolvedValueOnce("Approve brainstorm");
880
+ expectBrainstormToPlan(menu);
732
881
  const ppPhaseComplete = getTool(pi, "pp_phase_complete");
733
882
  await ppPhaseComplete.execute("call-1", { summary: "done" }, undefined, undefined, ctx);
734
883
  await new Promise((r) => setTimeout(r, 10));
@@ -739,6 +888,27 @@ describe("planner completion tracking", () => {
739
888
  expect(orchestrator.active!.state.step).toBe("synthesize");
740
889
  });
741
890
 
891
+ it("onSettled with zero spawned planners advances await_planners (poller-removal safety net)", async () => {
892
+ const cwd = makeTempDir();
893
+ const { orchestrator } = await setupOrchestrator(cwd);
894
+ const ctx = makeCtx();
895
+
896
+ await orchestrator.startTask(ctx as any, "implement", "Zero planners");
897
+ const taskDir = orchestrator.active!.dir;
898
+ writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
899
+ writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
900
+ orchestrator.active!.state.phase = "plan";
901
+ orchestrator.active!.state.step = "await_planners";
902
+ orchestrator.pendingSubagentSpawns = 0;
903
+ orchestrator.spawnedAgentIds.clear();
904
+
905
+ // With the poller gone, the spawn promise's onSettled(spawned=0) is the only
906
+ // thing that can advance when no subagents:completed/failed event ever fires.
907
+ orchestrator.checkPlannerCompletion();
908
+
909
+ expect(orchestrator.active!.state.step).toBe("synthesize");
910
+ });
911
+
742
912
  it("plan transition sets await_planners before compaction callback runs", async () => {
743
913
  const cwd = makeTempDir();
744
914
  const { pi, orchestrator } = await setupOrchestrator(cwd);
@@ -754,7 +924,7 @@ describe("planner completion tracking", () => {
754
924
  writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
755
925
  writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
756
926
 
757
- ctx.ui.select.mockResolvedValueOnce("Approve brainstorm");
927
+ expectBrainstormToPlan(menu);
758
928
  const ppPhaseComplete = getTool(pi, "pp_phase_complete");
759
929
  await ppPhaseComplete.execute("call-1", { summary: "done" }, undefined, undefined, ctx);
760
930
 
@@ -763,8 +933,8 @@ describe("planner completion tracking", () => {
763
933
  expect(compactCallbacks).toHaveLength(1);
764
934
 
765
935
  compactCallbacks[0]!();
936
+ await new Promise((r) => setTimeout(r, 10));
766
937
 
767
- expect(ctx.ui.notify).toHaveBeenCalledWith("Entered plan phase. Waiting for planners to complete before synthesis.", "info");
768
938
  const planBeginMessages = pi.sendUserMessage.mock.calls.filter(
769
939
  (c: any[]) => c[0] === "[PI-PI] Entered plan phase. Begin working.",
770
940
  );
@@ -782,7 +952,7 @@ describe("planner completion tracking", () => {
782
952
  writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
783
953
  writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
784
954
 
785
- ctx.ui.select.mockResolvedValueOnce("Approve brainstorm");
955
+ expectBrainstormToPlan(menu);
786
956
  const ppPhaseComplete = getTool(pi, "pp_phase_complete");
787
957
  await ppPhaseComplete.execute("call-1", { summary: "done" }, undefined, undefined, ctx);
788
958
  await new Promise((r) => setTimeout(r, 10));
@@ -798,7 +968,13 @@ describe("planner completion tracking", () => {
798
968
  orchestrator.pendingSubagentSpawns = 0;
799
969
  orchestrator.failedPlannerVariants = ["test"];
800
970
  orchestrator.lastCtx = ctx;
801
- ctx.ui.custom.mockResolvedValueOnce({ kind: "selection", selections: ["Work with available planner outputs"] });
971
+ menu.expect({
972
+ question: /Some planners failed:/,
973
+ options: {
974
+ exact: ["Retry failed planners", "Work with available planner outputs", "Stop task"],
975
+ },
976
+ choose: "Work with available planner outputs",
977
+ });
802
978
 
803
979
  emitSubagentCompleted(pi, "planner-1", "Planner (test)");
804
980
  await new Promise((r) => setTimeout(r, 10));
@@ -825,7 +1001,13 @@ describe("reviewer failure handling", () => {
825
1001
  orchestrator.pendingSubagentSpawns = 0;
826
1002
  orchestrator.failedReviewerVariants = ["test"];
827
1003
  orchestrator.lastCtx = ctx;
828
- ctx.ui.custom.mockResolvedValueOnce({ kind: "selection", selections: ["Continue without review"] });
1004
+ menu.expect({
1005
+ question: /Some reviewers failed:/,
1006
+ options: {
1007
+ exact: ["Retry failed reviewers", "Work with available reviewer outputs", "Continue without review", "Stop task"],
1008
+ },
1009
+ choose: "Continue without review",
1010
+ });
829
1011
 
830
1012
  emitSubagentCompleted(pi, "reviewer-1", "Code reviewer (test)");
831
1013
  await new Promise((r) => setTimeout(r, 10));
@@ -845,8 +1027,9 @@ describe("pp:done cancellation", () => {
845
1027
  await orchestrator.startTask(ctx as any, "implement", "Test done");
846
1028
  const taskDir = orchestrator.active!.dir;
847
1029
 
848
- const ppDone = getCommand(pi, "pp:done");
849
- await ppDone(undefined, ctx);
1030
+ expectImplementToDone(menu);
1031
+ const ppPhaseComplete = getTool(pi, "pp_phase_complete");
1032
+ await ppPhaseComplete.execute("call-1", { summary: "done" }, undefined, undefined, ctx);
850
1033
 
851
1034
  expect(orchestrator.active).toBeNull();
852
1035
  const state = loadTask(taskDir);
@@ -855,6 +1038,127 @@ describe("pp:done cancellation", () => {
855
1038
  });
856
1039
 
857
1040
  describe("edge cases and regressions", () => {
1041
+ it("pp_register_repo returns error for non-git path", async () => {
1042
+ const cwd = makeTempDir();
1043
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
1044
+ const ctx = makeCtx();
1045
+
1046
+ await orchestrator.startTask(ctx as any, "implement", "register non git");
1047
+
1048
+ const ppRegisterRepo = getTool(pi, "pp_register_repo");
1049
+ const result = await ppRegisterRepo.execute("call-non-git", { path: join(cwd, "not-a-repo") });
1050
+
1051
+ expect(result.isError).toBe(true);
1052
+ expect(result.content[0].text).toContain("Not a git repository");
1053
+ });
1054
+
1055
+ it("pp_register_repo resolves nested path to git root", async () => {
1056
+ const cwd = makeTempDir();
1057
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
1058
+ const ctx = makeCtx();
1059
+ const repoDir = join(cwd, "extra-repo");
1060
+ const nestedPath = join(repoDir, "src", "index.ts");
1061
+ mkdirSync(join(repoDir, "src"), { recursive: true });
1062
+ writeFileSync(nestedPath, "export const nested = true;\n", "utf-8");
1063
+
1064
+ await orchestrator.startTask(ctx as any, "implement", "register nested");
1065
+
1066
+ pi.exec.mockImplementation(async (command?: string, args?: string[]) => {
1067
+ if (command === "git" && args?.[0] === "rev-parse" && args?.[1] === "--show-toplevel") {
1068
+ return { code: 0, stdout: `${repoDir}\n`, stderr: "" };
1069
+ }
1070
+ return { code: 1, stdout: "", stderr: "unsupported" };
1071
+ });
1072
+
1073
+ const ppRegisterRepo = getTool(pi, "pp_register_repo");
1074
+ const result = await ppRegisterRepo.execute("call-nested", { path: nestedPath });
1075
+
1076
+ expect(result.content[0].text).toContain(`Registered repository: ${repoDir}`);
1077
+ expect(orchestrator.active!.state.repos?.some((repo) => repo.path === repoDir)).toBe(true);
1078
+ });
1079
+
1080
+ it("pp_register_repo updates root baseBranch", async () => {
1081
+ const cwd = makeTempDir();
1082
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
1083
+ const ctx = makeCtx();
1084
+
1085
+ await orchestrator.startTask(ctx as any, "implement", "register root");
1086
+
1087
+ pi.exec.mockImplementation(async (command?: string, args?: string[]) => {
1088
+ if (command === "git" && args?.[0] === "rev-parse" && args?.[1] === "--show-toplevel") {
1089
+ return { code: 0, stdout: `${cwd}\n`, stderr: "" };
1090
+ }
1091
+ return { code: 1, stdout: "", stderr: "unsupported" };
1092
+ });
1093
+
1094
+ const ppRegisterRepo = getTool(pi, "pp_register_repo");
1095
+ const result = await ppRegisterRepo.execute("call-root", { path: cwd, baseBranch: "origin/main" });
1096
+
1097
+ expect(result.content[0].text).toContain("Updated repository");
1098
+ expect(orchestrator.active!.state.repos?.find((repo) => repo.isRoot)?.baseBranch).toBe("origin/main");
1099
+ });
1100
+
1101
+ it("pp_register_repo adds extra repo and does not duplicate on repeat", async () => {
1102
+ const cwd = makeTempDir();
1103
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
1104
+ const ctx = makeCtx();
1105
+ const repoDir = join(cwd, "extra-repo");
1106
+ mkdirSync(repoDir, { recursive: true });
1107
+
1108
+ await orchestrator.startTask(ctx as any, "implement", "register dedupe");
1109
+
1110
+ pi.exec.mockImplementation(async (command?: string, args?: string[]) => {
1111
+ if (command === "git" && args?.[0] === "rev-parse" && args?.[1] === "--show-toplevel") {
1112
+ return { code: 0, stdout: `${repoDir}\n`, stderr: "" };
1113
+ }
1114
+ return { code: 1, stdout: "", stderr: "unsupported" };
1115
+ });
1116
+
1117
+ const ppRegisterRepo = getTool(pi, "pp_register_repo");
1118
+ await ppRegisterRepo.execute("call-extra-1", { path: repoDir, baseBranch: "origin/main" });
1119
+ const second = await ppRegisterRepo.execute("call-extra-2", { path: repoDir, baseBranch: "origin/main" });
1120
+
1121
+ const repos = orchestrator.active!.state.repos ?? [];
1122
+ expect(repos.filter((repo) => repo.path === repoDir)).toHaveLength(1);
1123
+ expect(second.content[0].text).toContain("Already registered repository");
1124
+ });
1125
+
1126
+ it("pp_register_repo deduplicates entries and updates baseBranch", async () => {
1127
+ const cwd = makeTempDir();
1128
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
1129
+ const ctx = makeCtx();
1130
+
1131
+ await orchestrator.startTask(ctx as any, "implement", "Repo registration test");
1132
+
1133
+ const repoDir = join(cwd, "extra-repo");
1134
+ mkdirSync(join(repoDir, "src"), { recursive: true });
1135
+ writeFileSync(join(repoDir, "src", "index.ts"), "export const x = 1;\n", "utf-8");
1136
+
1137
+ pi.exec.mockImplementation(async (command?: string, args?: string[], options?: { cwd?: string }) => {
1138
+ if (!command || !args) {
1139
+ return { code: 1, stdout: "", stderr: "unsupported" };
1140
+ }
1141
+ if (command === "git" && args[0] === "rev-parse" && args[1] === "--show-toplevel") {
1142
+ return { code: 0, stdout: `${repoDir}\n`, stderr: "" };
1143
+ }
1144
+ return { code: 1, stdout: "", stderr: "unsupported" };
1145
+ });
1146
+
1147
+ const { registerCbmTools } = await import("./cbm.js");
1148
+ const cbmCallsBefore = (registerCbmTools as any).mock.calls.length;
1149
+
1150
+ const ppRegisterRepo = getTool(pi, "pp_register_repo");
1151
+ await ppRegisterRepo.execute("call-1", { path: repoDir, baseBranch: "origin/main" });
1152
+ await ppRegisterRepo.execute("call-2", { path: join(repoDir, "src", "index.ts"), baseBranch: "origin/develop" });
1153
+
1154
+ const repos = orchestrator.active!.state.repos ?? [];
1155
+ const extraRepos = repos.filter((repo) => repo.path === repoDir);
1156
+
1157
+ expect(extraRepos).toHaveLength(1);
1158
+ expect(extraRepos[0]?.baseBranch).toBe("origin/develop");
1159
+ expect((registerCbmTools as any).mock.calls.length).toBe(cbmCallsBefore);
1160
+ });
1161
+
858
1162
  it("starting new task while another is active finishes the old one", async () => {
859
1163
  const cwd = makeTempDir();
860
1164
  const { pi, orchestrator } = await setupOrchestrator(cwd);
@@ -870,7 +1174,7 @@ describe("edge cases and regressions", () => {
870
1174
  expect(orchestrator.active!.description).toBe("Second task");
871
1175
 
872
1176
  const firstState = loadTask(firstDir);
873
- expect(firstState.phase).toBe("done");
1177
+ expect(firstState.phase).toBe("brainstorm");
874
1178
  });
875
1179
 
876
1180
  it("pp:done during review cycle cleans up properly", async () => {
@@ -883,7 +1187,7 @@ describe("edge cases and regressions", () => {
883
1187
 
884
1188
  writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
885
1189
  writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
886
- ctx.ui.select.mockResolvedValueOnce("Approve brainstorm");
1190
+ expectBrainstormToPlan(menu);
887
1191
  const ppPhaseComplete = getTool(pi, "pp_phase_complete");
888
1192
  await ppPhaseComplete.execute("call-1", { summary: "done" }, undefined, undefined, ctx);
889
1193
  await new Promise((r) => setTimeout(r, 10));
@@ -903,18 +1207,24 @@ describe("edge cases and regressions", () => {
903
1207
  makeValidPlan(["- [x] P1. Done item — Done when: synthesized plan is fully checked"]),
904
1208
  "utf-8",
905
1209
  );
906
- ctx.ui.select.mockResolvedValueOnce("Approve plan");
1210
+ expectPlanToImplement(menu);
907
1211
  await ppPhaseComplete.execute("call-2", { summary: "plan done" }, undefined, undefined, ctx);
908
1212
  await new Promise((r) => setTimeout(r, 10));
909
1213
 
910
- ctx.ui.select.mockResolvedValueOnce("Review");
1214
+ expectReviewAuto(menu);
911
1215
  await ppPhaseComplete.execute("call-3", { summary: "implemented" }, undefined, undefined, ctx);
912
1216
 
1217
+ const reviewsDir = join(taskDir, "code-reviews");
1218
+ mkdirSync(reviewsDir, { recursive: true });
1219
+ writeFileSync(join(reviewsDir, `${Math.floor(Date.now() / 1000)}_test_round-1.md`), "LGTM", "utf-8");
1220
+ emitSubagentCreated(pi, "reviewer-1", "Code reviewer (test)");
1221
+ emitSubagentCompleted(pi, "reviewer-1", "Code reviewer (test)");
1222
+
913
1223
  expect(orchestrator.active!.state.reviewCycle).not.toBeNull();
914
1224
  expect(["await_reviewers", "apply_feedback"]).toContain(orchestrator.active!.state.reviewCycle!.step);
915
1225
 
916
- const ppDone = getCommand(pi, "pp:done");
917
- await ppDone(undefined, ctx);
1226
+ expectImplementToDone(menu);
1227
+ await ppPhaseComplete.execute("call-4", { summary: "done" }, undefined, undefined, ctx);
918
1228
 
919
1229
  expect(orchestrator.active).toBeNull();
920
1230
  expect(orchestrator.spawnedAgentIds.size).toBe(0);
@@ -931,7 +1241,7 @@ describe("edge cases and regressions", () => {
931
1241
 
932
1242
  writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
933
1243
  writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
934
- ctx.ui.select.mockResolvedValueOnce("Approve brainstorm");
1244
+ expectBrainstormToPlan(menu);
935
1245
  const ppPhaseComplete = getTool(pi, "pp_phase_complete");
936
1246
  await ppPhaseComplete.execute("call-1", { summary: "done" }, undefined, undefined, ctx);
937
1247
  await new Promise((r) => setTimeout(r, 10));
@@ -951,13 +1261,13 @@ describe("edge cases and regressions", () => {
951
1261
  makeValidPlan(["- [x] P1. Done item — Done when: synthesized plan is fully checked"]),
952
1262
  "utf-8",
953
1263
  );
954
- ctx.ui.select.mockResolvedValueOnce("Approve plan");
1264
+ expectPlanToImplement(menu);
955
1265
  await ppPhaseComplete.execute("call-2", { summary: "plan done" }, undefined, undefined, ctx);
956
1266
  await new Promise((r) => setTimeout(r, 10));
957
1267
 
958
1268
  expect(orchestrator.active!.state.phase).toBe("implement");
959
1269
 
960
- ctx.ui.select.mockResolvedValueOnce("Review");
1270
+ expectReviewAuto(menu);
961
1271
  await ppPhaseComplete.execute("call-3", { summary: "implemented" }, undefined, undefined, ctx);
962
1272
 
963
1273
  const reviewsDir = join(taskDir, "code-reviews");
@@ -969,11 +1279,11 @@ describe("edge cases and regressions", () => {
969
1279
 
970
1280
  expect(orchestrator.active!.state.reviewCycle!.step).toBe("apply_feedback");
971
1281
 
972
- ctx.ui.select.mockResolvedValueOnce("Review");
1282
+ expectReviewAuto(menu);
973
1283
  await ppPhaseComplete.execute("call-4", { summary: "fixes applied" }, undefined, undefined, ctx);
974
1284
 
975
1285
  expect(orchestrator.active!.state.reviewPass).toBe(1);
976
- expect(orchestrator.active!.state.reviewPassByKind?.auto).toBe(1);
1286
+ expect(orchestrator.active!.state.reviewPassByKind?.implement?.auto).toBe(1);
977
1287
  expect(orchestrator.active!.state.reviewCycle).not.toBeNull();
978
1288
  expect(orchestrator.active!.state.reviewCycle!.pass).toBe(2);
979
1289
 
@@ -982,14 +1292,14 @@ describe("edge cases and regressions", () => {
982
1292
  emitSubagentCreated(pi, "reviewer-2", "Code reviewer (test)");
983
1293
  emitSubagentCompleted(pi, "reviewer-2", "Code reviewer (test)");
984
1294
 
985
- ctx.ui.select.mockResolvedValueOnce("Approve implementation");
1295
+ expectImplementToDone(menu);
986
1296
  const result = await ppPhaseComplete.execute("call-5", { summary: "all good" }, undefined, undefined, ctx);
987
1297
 
988
- expect(result.content[0].text).toContain("Task completed");
1298
+ expect(result.content[0].text).toBe("");
989
1299
  expect(orchestrator.active).toBeNull();
990
1300
 
991
1301
  const finalState = loadTask(taskDir);
992
- expect(finalState.reviewPass).toBe(2);
1302
+ expect(finalState.reviewPass).toBe(1);
993
1303
  expect(finalState.reviewCycle).toBeNull();
994
1304
  });
995
1305
 
@@ -1000,7 +1310,7 @@ describe("edge cases and regressions", () => {
1000
1310
 
1001
1311
  await orchestrator.startTask(ctx as any, "implement", "Continue test");
1002
1312
 
1003
- ctx.ui.select.mockResolvedValueOnce("Continue brainstorming");
1313
+ expectReviewOnMyOwn(menu);
1004
1314
  const ppPhaseComplete = getTool(pi, "pp_phase_complete");
1005
1315
  const result = await ppPhaseComplete.execute("call-1", { summary: "not done yet" }, undefined, undefined, ctx);
1006
1316
 
@@ -1019,7 +1329,7 @@ describe("edge cases and regressions", () => {
1019
1329
 
1020
1330
  writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
1021
1331
  writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
1022
- ctx.ui.select.mockResolvedValueOnce("Approve brainstorm");
1332
+ expectBrainstormToPlan(menu);
1023
1333
  const ppPhaseComplete = getTool(pi, "pp_phase_complete");
1024
1334
  await ppPhaseComplete.execute("call-1", { summary: "done" }, undefined, undefined, ctx);
1025
1335
  await new Promise((r) => setTimeout(r, 10));
@@ -1039,11 +1349,11 @@ describe("edge cases and regressions", () => {
1039
1349
  makeValidPlan(["- [ ] P1. Todo item — Done when: item intentionally remains unchecked"]),
1040
1350
  "utf-8",
1041
1351
  );
1042
- ctx.ui.select.mockResolvedValueOnce("Approve plan");
1352
+ expectPlanToImplement(menu);
1043
1353
  await ppPhaseComplete.execute("call-2", { summary: "plan done" }, undefined, undefined, ctx);
1044
1354
  await new Promise((r) => setTimeout(r, 10));
1045
1355
 
1046
- ctx.ui.select.mockResolvedValueOnce("Continue implementation");
1356
+ expectReviewOnMyOwn(menu);
1047
1357
  const result = await ppPhaseComplete.execute("call-3", { summary: "partial work" }, undefined, undefined, ctx);
1048
1358
 
1049
1359
  expect(result.content[0].text).toContain("continue");
@@ -1061,7 +1371,7 @@ describe("edge cases and regressions", () => {
1061
1371
 
1062
1372
  writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
1063
1373
  writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
1064
- ctx.ui.select.mockResolvedValueOnce("Approve brainstorm");
1374
+ expectBrainstormToPlan(menu);
1065
1375
  const ppPhaseComplete = getTool(pi, "pp_phase_complete");
1066
1376
  await ppPhaseComplete.execute("call-1", { summary: "done" }, undefined, undefined, ctx);
1067
1377
  await new Promise((r) => setTimeout(r, 10));
@@ -1082,7 +1392,7 @@ describe("edge cases and regressions", () => {
1082
1392
  "utf-8",
1083
1393
  );
1084
1394
 
1085
- ctx.ui.select.mockResolvedValueOnce("Review on my own");
1395
+ expectReviewOnMyOwn(menu);
1086
1396
  const result = await ppPhaseComplete.execute("call-2", { summary: "plan ready" }, undefined, undefined, ctx);
1087
1397
 
1088
1398
  expect(result.content[0].text).toContain("continue");
@@ -1141,7 +1451,7 @@ describe("edge cases and regressions", () => {
1141
1451
 
1142
1452
  it("implement --from brainstorm also skips brainstorm and enters plan deterministically", async () => {
1143
1453
  const cwd = makeTempDir();
1144
- const { pi, orchestrator } = await setupOrchestrator(cwd);
1454
+ const { orchestrator } = await setupOrchestrator(cwd);
1145
1455
  const ctx = makeCtx();
1146
1456
 
1147
1457
  await orchestrator.startTask(ctx as any, "brainstorm", "Explore ideas");
@@ -1149,8 +1459,7 @@ describe("edge cases and regressions", () => {
1149
1459
  writeFileSync(join(brainstormDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
1150
1460
  writeFileSync(join(brainstormDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
1151
1461
 
1152
- const ppImplement = getCommand(pi, "pp:implement");
1153
- await ppImplement(`--from brainstorm/${brainstormDir.split("/").pop()}`, ctx);
1462
+ await orchestrator.startTask(ctx as any, "implement", "implement", brainstormDir, true);
1154
1463
 
1155
1464
  expect(orchestrator.active!.state.phase).toBe("plan");
1156
1465
  expect(orchestrator.active!.state.step).toBe("await_planners");
@@ -1160,9 +1469,9 @@ describe("edge cases and regressions", () => {
1160
1469
  expect(existsSync(join(orchestrator.active!.dir, "RESEARCH.md"))).toBe(true);
1161
1470
  });
1162
1471
 
1163
- it("pp:implement parses --from=debug syntax and skips brainstorm", async () => {
1472
+ it("implement from debug task stores source path and skips brainstorm", async () => {
1164
1473
  const cwd = makeTempDir();
1165
- const { pi, orchestrator } = await setupOrchestrator(cwd);
1474
+ const { orchestrator } = await setupOrchestrator(cwd);
1166
1475
  const ctx = makeCtx();
1167
1476
 
1168
1477
  await orchestrator.startTask(ctx as any, "debug", "Find bug");
@@ -1170,8 +1479,7 @@ describe("edge cases and regressions", () => {
1170
1479
  writeFileSync(join(debugDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
1171
1480
  writeFileSync(join(debugDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
1172
1481
 
1173
- const ppImplement = getCommand(pi, "pp:implement");
1174
- await ppImplement(`--from=debug/${debugDir.split("/").pop()}`, ctx);
1482
+ await orchestrator.startTask(ctx as any, "implement", "implement", debugDir, true);
1175
1483
 
1176
1484
  expect(orchestrator.active!.type).toBe("implement");
1177
1485
  expect(orchestrator.active!.state.phase).toBe("plan");
@@ -1181,9 +1489,9 @@ describe("edge cases and regressions", () => {
1181
1489
  expect(ctx.ui.notify).toHaveBeenCalledWith("Entered plan phase. Waiting for planners to complete before synthesis.", "info");
1182
1490
  });
1183
1491
 
1184
- it("pp:implement parses --from=brainstorm syntax and skips brainstorm", async () => {
1492
+ it("implement from brainstorm task stores source path and skips brainstorm", async () => {
1185
1493
  const cwd = makeTempDir();
1186
- const { pi, orchestrator } = await setupOrchestrator(cwd);
1494
+ const { orchestrator } = await setupOrchestrator(cwd);
1187
1495
  const ctx = makeCtx();
1188
1496
 
1189
1497
  await orchestrator.startTask(ctx as any, "brainstorm", "Explore ideas");
@@ -1191,8 +1499,7 @@ describe("edge cases and regressions", () => {
1191
1499
  writeFileSync(join(brainstormDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
1192
1500
  writeFileSync(join(brainstormDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
1193
1501
 
1194
- const ppImplement = getCommand(pi, "pp:implement");
1195
- await ppImplement(`--from=brainstorm/${brainstormDir.split("/").pop()}`, ctx);
1502
+ await orchestrator.startTask(ctx as any, "implement", "implement", brainstormDir, true);
1196
1503
 
1197
1504
  expect(orchestrator.active!.type).toBe("implement");
1198
1505
  expect(orchestrator.active!.state.phase).toBe("plan");
@@ -1214,3 +1521,2444 @@ describe("edge cases and regressions", () => {
1214
1521
  expect(normalized.phase).toBe("plan");
1215
1522
  });
1216
1523
  });
1524
+
1525
+ describe("task modes and quick task", () => {
1526
+ it("quick task lifecycle completes via pp_phase_complete menu", async () => {
1527
+ const cwd = makeTempDir();
1528
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
1529
+ const ctx = makeCtx();
1530
+
1531
+ await orchestrator.startTask(ctx as any, "quick", "quick");
1532
+ const taskDir = orchestrator.active!.dir;
1533
+
1534
+ expectQuickMenu(menu, "Complete");
1535
+ const ppPhaseComplete = getTool(pi, "pp_phase_complete");
1536
+ const result = await ppPhaseComplete.execute("call-1", { summary: "done" }, undefined, undefined, ctx);
1537
+
1538
+ expect(result.content[0].text).toBeDefined();
1539
+ expect(orchestrator.active).toBeNull();
1540
+ expect(loadTask(taskDir).phase).toBe("done");
1541
+ });
1542
+
1543
+ it("quick task /pp menu shows quick options", async () => {
1544
+ const cwd = makeTempDir();
1545
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
1546
+ const ctx = makeCtx();
1547
+
1548
+ await orchestrator.startTask(ctx as any, "quick", "quick");
1549
+ menu.expect({ question: m.taskMenu("quick", "quick"), options: { include: ["Complete", "Pause"], exclude: ["Next", "Review"] }, choose: "Back" });
1550
+ const pp = getCommand(pi, "pp");
1551
+ await pp(undefined, ctx);
1552
+ });
1553
+
1554
+ it("mode picker stores autonomous mode in task state", async () => {
1555
+ const cwd = makeTempDir();
1556
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
1557
+ const ctx = makeCtx();
1558
+
1559
+ expectPpStartImplementAutonomous(menu);
1560
+ const pp = getCommand(pi, "pp");
1561
+ await pp(undefined, ctx);
1562
+
1563
+ expect(orchestrator.active).not.toBeNull();
1564
+ expect(orchestrator.active!.state.mode).toBe("autonomous");
1565
+ expect(orchestrator.active!.state.autonomousConfig).toBeDefined();
1566
+ });
1567
+
1568
+ it("autonomous pp_phase_complete auto-advances without opening menu", async () => {
1569
+ const cwd = makeTempDir();
1570
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
1571
+ const ctx = makeCtx();
1572
+
1573
+ await orchestrator.startTask(ctx as any, "implement", "implement", undefined, undefined, "autonomous");
1574
+ const taskDir = orchestrator.active!.dir;
1575
+ orchestrator.active!.state.autonomousConfig = {
1576
+ phases: {
1577
+ brainstorm: { reviewPreset: "regular", maxReviewPasses: 0 },
1578
+ plan: { plannerPreset: "regular", reviewPreset: "regular", maxReviewPasses: 0 },
1579
+ implement: { reviewPreset: "regular", maxReviewPasses: 0 },
1580
+ },
1581
+ };
1582
+ writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
1583
+ writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
1584
+
1585
+ const ppPhaseComplete = getTool(pi, "pp_phase_complete");
1586
+ const result = await ppPhaseComplete.execute("call-1", { summary: "done" }, undefined, undefined, ctx);
1587
+ await new Promise((r) => setTimeout(r, 10));
1588
+
1589
+ expect(result.content[0].text).toBe("");
1590
+ expect(orchestrator.active!.state.phase).toBe("plan");
1591
+ expect(menu.transcript.filter((entry) => entry.question.startsWith("/pp"))).toHaveLength(0);
1592
+ });
1593
+
1594
+ it("autonomous review loop re-runs until cap then advances", async () => {
1595
+ const cwd = makeTempDir();
1596
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
1597
+ const ctx = makeCtx();
1598
+
1599
+ await orchestrator.startTask(ctx as any, "implement", "implement", undefined, undefined, "autonomous");
1600
+ const taskDir = orchestrator.active!.dir;
1601
+ orchestrator.active!.state.phase = "implement";
1602
+ orchestrator.active!.state.step = "llm_work";
1603
+ orchestrator.active!.state.reviewPass = 0;
1604
+ orchestrator.active!.state.reviewPassByKind = {};
1605
+ orchestrator.active!.state.autonomousConfig = {
1606
+ phases: {
1607
+ implement: { reviewPreset: "regular", maxReviewPasses: 2 },
1608
+ },
1609
+ };
1610
+ writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
1611
+ writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
1612
+ const plansDir = join(taskDir, "plans");
1613
+ mkdirSync(plansDir, { recursive: true });
1614
+ writeFileSync(
1615
+ join(plansDir, "1_synthesized.md"),
1616
+ makeValidPlan(["- [x] P1. Done item — Done when: synthesized plan is fully checked"]),
1617
+ "utf-8",
1618
+ );
1619
+
1620
+ const ppPhaseComplete = getTool(pi, "pp_phase_complete");
1621
+ const reviewsDir = join(taskDir, "code-reviews");
1622
+ mkdirSync(reviewsDir, { recursive: true });
1623
+
1624
+ // Pass 1: non-clean review (no APPROVE) below cap — must force apply+re-review, NOT advance.
1625
+ const first = await ppPhaseComplete.execute("call-1", { summary: "done" }, undefined, undefined, ctx);
1626
+ expect(first.content[0].text).toMatch(/Reviews are running|Started review cycle pass/);
1627
+ writeFileSync(join(reviewsDir, `1_test_round-1.md`), "- CRITICAL: fix this\n- VERDICT: NEEDS_CHANGES", "utf-8");
1628
+ emitSubagentCreated(pi, "reviewer-1", "Code reviewer (test)");
1629
+ emitSubagentCompleted(pi, "reviewer-1", "Code reviewer (test)");
1630
+
1631
+ const second = await ppPhaseComplete.execute("call-2", { summary: "applied" }, undefined, undefined, ctx);
1632
+ expect(second.content[0].text).toMatch(/Apply the reviewers' required changes/);
1633
+ expect(orchestrator.active).not.toBeNull();
1634
+ expect(orchestrator.active!.state.phase).toBe("implement");
1635
+
1636
+ // Pass 2 (= cap): non-clean again, but at maxReviewPasses — cap honored, advances to done.
1637
+ const third = await ppPhaseComplete.execute("call-3", { summary: "redo" }, undefined, undefined, ctx);
1638
+ expect(third.content[0].text).toMatch(/Reviews are running|Started review cycle pass/);
1639
+ writeFileSync(join(reviewsDir, `2_test_round-2.md`), "- CRITICAL: still\n- VERDICT: NEEDS_CHANGES", "utf-8");
1640
+ emitSubagentCreated(pi, "reviewer-2", "Code reviewer (test)");
1641
+ emitSubagentCompleted(pi, "reviewer-2", "Code reviewer (test)");
1642
+
1643
+ const fourth = await ppPhaseComplete.execute("call-4", { summary: "applied 2" }, undefined, undefined, ctx);
1644
+ expect(fourth.content[0].text).toBe("");
1645
+ expect(orchestrator.active).toBeNull();
1646
+ expect(loadTask(taskDir).phase).toBe("done");
1647
+ });
1648
+
1649
+ it("autonomous review early-exits on unanimous approval before reaching the cap", async () => {
1650
+ const cwd = makeTempDir();
1651
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
1652
+ const ctx = makeCtx();
1653
+
1654
+ await orchestrator.startTask(ctx as any, "implement", "implement", undefined, undefined, "autonomous");
1655
+ const taskDir = orchestrator.active!.dir;
1656
+ orchestrator.active!.state.phase = "implement";
1657
+ orchestrator.active!.state.step = "llm_work";
1658
+ orchestrator.active!.state.reviewPass = 0;
1659
+ orchestrator.active!.state.reviewPassByKind = {};
1660
+ orchestrator.active!.state.autonomousConfig = {
1661
+ phases: { implement: { reviewPreset: "regular", maxReviewPasses: 3 } },
1662
+ };
1663
+ writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
1664
+ writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
1665
+ const plansDir = join(taskDir, "plans");
1666
+ mkdirSync(plansDir, { recursive: true });
1667
+ writeFileSync(
1668
+ join(plansDir, "1_synthesized.md"),
1669
+ makeValidPlan(["- [x] P1. Done item — Done when: synthesized plan is fully checked"]),
1670
+ "utf-8",
1671
+ );
1672
+
1673
+ const ppPhaseComplete = getTool(pi, "pp_phase_complete");
1674
+ const first = await ppPhaseComplete.execute("call-1", { summary: "done" }, undefined, undefined, ctx);
1675
+ expect(first.content[0].text).toMatch(/Reviews are running|Started review cycle pass/);
1676
+
1677
+ const reviewsDir = join(taskDir, "code-reviews");
1678
+ mkdirSync(reviewsDir, { recursive: true });
1679
+ const round = orchestrator.active!.state.reviewCycle!.pass;
1680
+ for (const v of ["opus", "gpt", "gemini"]) {
1681
+ writeFileSync(join(reviewsDir, `1_${v}_round-${round}.md`), "VERDICT: APPROVE\n- CRITICAL: none", "utf-8");
1682
+ }
1683
+ emitSubagentCreated(pi, "reviewer-1", "Code reviewer (test)");
1684
+ emitSubagentCompleted(pi, "reviewer-1", "Code reviewer (test)");
1685
+
1686
+ const second = await ppPhaseComplete.execute("call-2", { summary: "applied" }, undefined, undefined, ctx);
1687
+ expect(second.content[0].text).toBe("");
1688
+ expect(orchestrator.active).toBeNull();
1689
+ const finalState = loadTask(taskDir);
1690
+ expect(finalState.phase).toBe("done");
1691
+ // Issue 4: review bookkeeping is cleared on the transition to done.
1692
+ expect(finalState.reviewCycle).toBeNull();
1693
+ // Early exit: only one auto pass ran (cap was 3), proving the clean APPROVE short-circuited.
1694
+ expect(finalState.reviewPassByKind?.implement?.auto).toBe(1);
1695
+ });
1696
+
1697
+ it("clean-approved review is not re-run on a later checklist-repair re-entry", async () => {
1698
+ const cwd = makeTempDir();
1699
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
1700
+ const ctx = makeCtx();
1701
+
1702
+ await orchestrator.startTask(ctx as any, "implement", "implement", undefined, undefined, "autonomous");
1703
+ const taskDir = orchestrator.active!.dir;
1704
+ orchestrator.active!.state.phase = "implement";
1705
+ orchestrator.active!.state.step = "llm_work";
1706
+ orchestrator.active!.state.reviewPass = 1;
1707
+ orchestrator.active!.state.reviewPassByKind = { implement: { auto: 1 } };
1708
+ orchestrator.active!.state.reviewApprovedClean = true;
1709
+ orchestrator.active!.state.autonomousConfig = {
1710
+ phases: { implement: { reviewPreset: "regular", maxReviewPasses: 3 } },
1711
+ };
1712
+ const plansDir = join(taskDir, "plans");
1713
+ mkdirSync(plansDir, { recursive: true });
1714
+ writeFileSync(
1715
+ join(plansDir, "1_synthesized.md"),
1716
+ makeValidPlan(["- [x] P1. Done item — Done when: synthesized plan is fully checked"]),
1717
+ "utf-8",
1718
+ );
1719
+
1720
+ const ppPhaseComplete = getTool(pi, "pp_phase_complete");
1721
+ const result = await ppPhaseComplete.execute("call-1", { summary: "checklist repaired" }, undefined, undefined, ctx);
1722
+ expect(result.content[0].text).toBe("");
1723
+ expect(orchestrator.active).toBeNull();
1724
+ expect(loadTask(taskDir).phase).toBe("done");
1725
+ });
1726
+
1727
+ it("autonomous review runs another pass when a reviewer reports a CRITICAL finding", async () => {
1728
+ const cwd = makeTempDir();
1729
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
1730
+ const ctx = makeCtx();
1731
+
1732
+ await orchestrator.startTask(ctx as any, "implement", "implement", undefined, undefined, "autonomous");
1733
+ const taskDir = orchestrator.active!.dir;
1734
+ orchestrator.active!.state.phase = "implement";
1735
+ orchestrator.active!.state.step = "llm_work";
1736
+ orchestrator.active!.state.reviewPass = 1;
1737
+ orchestrator.active!.state.reviewPassByKind = { implement: { auto: 1 } };
1738
+ orchestrator.active!.state.autonomousConfig = {
1739
+ phases: { implement: { reviewPreset: "regular", maxReviewPasses: 3 } },
1740
+ };
1741
+ writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
1742
+ writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
1743
+ const plansDir = join(taskDir, "plans");
1744
+ mkdirSync(plansDir, { recursive: true });
1745
+ writeFileSync(
1746
+ join(plansDir, "1_synthesized.md"),
1747
+ makeValidPlan(["- [x] P1. Done item — Done when: synthesized plan is fully checked"]),
1748
+ "utf-8",
1749
+ );
1750
+
1751
+ const reviewsDir = join(taskDir, "code-reviews");
1752
+ mkdirSync(reviewsDir, { recursive: true });
1753
+ writeFileSync(join(reviewsDir, "1_a_round-1.md"), "- CRITICAL: bug at x.ts:1\n- VERDICT: NEEDS_CHANGES", "utf-8");
1754
+
1755
+ const ppPhaseComplete = getTool(pi, "pp_phase_complete");
1756
+ const next = await ppPhaseComplete.execute("call-1", { summary: "fixes applied, re-review" }, undefined, undefined, ctx);
1757
+ expect(orchestrator.active!.state.reviewApprovedClean).toBeFalsy();
1758
+ expect(next.content[0].text).toMatch(/Reviews are running|Started review cycle pass/);
1759
+ expect(orchestrator.active!.state.reviewCycle!.pass).toBe(2);
1760
+ });
1761
+
1762
+ it("autonomous implement blocks review until exit criteria pass", async () => {
1763
+ const cwd = makeTempDir();
1764
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
1765
+ const ctx = makeCtx();
1766
+
1767
+ await orchestrator.startTask(ctx as any, "implement", "implement", undefined, undefined, "autonomous");
1768
+ const taskDir = orchestrator.active!.dir;
1769
+ orchestrator.active!.state.phase = "implement";
1770
+ orchestrator.active!.state.step = "llm_work";
1771
+ orchestrator.active!.state.reviewPass = 0;
1772
+ orchestrator.active!.state.reviewPassByKind = {};
1773
+ orchestrator.active!.state.autonomousConfig = {
1774
+ phases: { implement: { reviewPreset: "regular", maxReviewPasses: 3 } },
1775
+ };
1776
+ const plansDir = join(taskDir, "plans");
1777
+ mkdirSync(plansDir, { recursive: true });
1778
+ writeFileSync(
1779
+ join(plansDir, "1_synthesized.md"),
1780
+ makeValidPlan(["- [ ] P1. Unchecked item — Done when: this is checked"]),
1781
+ "utf-8",
1782
+ );
1783
+
1784
+ const ppPhaseComplete = getTool(pi, "pp_phase_complete");
1785
+ const first = await ppPhaseComplete.execute("call-1", { summary: "done" }, undefined, undefined, ctx);
1786
+ expect(first.content[0].text).toMatch(/Cannot start review yet/);
1787
+ expect(orchestrator.active!.state.step).not.toBe("await_reviewers");
1788
+ expect(orchestrator.active!.state.phase).toBe("implement");
1789
+ });
1790
+
1791
+ it("autonomous planner retries failed variants once even with partial outputs", async () => {
1792
+ const cwd = makeTempDir();
1793
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
1794
+
1795
+ await orchestrator.startTask({ ...makeCtx(), cwd } as any, "implement", "implement", undefined, undefined, "autonomous");
1796
+ const taskDir = orchestrator.active!.dir;
1797
+ orchestrator.active!.state.phase = "plan";
1798
+ orchestrator.active!.state.step = "await_planners";
1799
+ orchestrator.active!.state.plannerFailureAutoRetried = false;
1800
+ saveTask(taskDir, orchestrator.active!.state);
1801
+ const plansDir = join(taskDir, "plans");
1802
+ mkdirSync(plansDir, { recursive: true });
1803
+ writeFileSync(join(plansDir, `${Math.floor(Date.now() / 1000)}_test.md`), "draft", "utf-8");
1804
+
1805
+ orchestrator.failedPlannerVariants = ["test"];
1806
+ orchestrator.pendingSubagentSpawns = 0;
1807
+ emitSubagentCompleted(pi, "planner-1", "Planner (test)");
1808
+
1809
+ expect(orchestrator.active!.state.plannerFailureAutoRetried).toBe(true);
1810
+ });
1811
+
1812
+ it("autonomous reviewer retries failed variants once even with partial outputs", async () => {
1813
+ const cwd = makeTempDir();
1814
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
1815
+
1816
+ await orchestrator.startTask({ ...makeCtx(), cwd } as any, "implement", "implement", undefined, undefined, "autonomous");
1817
+ const taskDir = orchestrator.active!.dir;
1818
+ orchestrator.active!.state.phase = "implement";
1819
+ orchestrator.active!.state.step = "await_reviewers";
1820
+ orchestrator.active!.state.reviewCycle = { kind: "auto", step: "await_reviewers", pass: 1 };
1821
+ orchestrator.active!.state.reviewerFailureAutoRetried = false;
1822
+ saveTask(taskDir, orchestrator.active!.state);
1823
+ const reviewsDir = join(taskDir, "code-reviews");
1824
+ mkdirSync(reviewsDir, { recursive: true });
1825
+ writeFileSync(join(reviewsDir, `${Math.floor(Date.now() / 1000)}_test_round-1.md`), "partial", "utf-8");
1826
+
1827
+ orchestrator.failedReviewerVariants = ["test"];
1828
+ orchestrator.pendingSubagentSpawns = 0;
1829
+ emitSubagentCompleted(pi, "reviewer-1", "Code reviewer (test)");
1830
+
1831
+ expect(orchestrator.active!.state.reviewerFailureAutoRetried).toBe(true);
1832
+ });
1833
+
1834
+ it("mode picker Back returns to previous menu and does not start guided task", async () => {
1835
+ const cwd = makeTempDir();
1836
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
1837
+ const ctx = makeCtx();
1838
+
1839
+ menu
1840
+ .expect({ question: "/pp", options: { include: ["Task"] }, choose: "Task" })
1841
+ .expect({ question: "Task", options: { include: ["Implement"] }, choose: "Implement" })
1842
+ .expect({ question: "Implement", options: { include: ["New"] }, choose: "New" })
1843
+ .expect({ question: "Mode", options: { include: ["Back"] }, choose: "Back" })
1844
+ .expect({ question: "Implement", options: { include: ["Resume", "Back"] }, choose: "Resume" })
1845
+ .expect({ question: "Implement", options: { include: ["Back"] }, choose: "Back" })
1846
+ .expect({ question: "Task", options: { include: ["Back"] }, choose: "Back" })
1847
+ .expect({ question: "/pp", options: { include: ["Back"] }, choose: "Back" });
1848
+ const pp = getCommand(pi, "pp");
1849
+ await pp(undefined, ctx);
1850
+
1851
+ expect(orchestrator.active).toBeNull();
1852
+ });
1853
+
1854
+ it("brainstorm continue uses implement autonomous phase defaults", async () => {
1855
+ const cwd = makeTempDir();
1856
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
1857
+ const ctx = makeCtx();
1858
+
1859
+ await orchestrator.startTask(ctx as any, "brainstorm", "brainstorm");
1860
+ const taskDir = orchestrator.active!.dir;
1861
+ writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
1862
+ writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
1863
+
1864
+ menu
1865
+ .expect({ question: m.anyTaskMenu, options: { include: ["Next"] }, choose: "Next" })
1866
+ .expect({ question: "Next", options: { include: ["Continue to plan & implement"] }, choose: "Continue to plan & implement" })
1867
+ .expect({ question: "Mode", options: { include: ["Autonomous"] }, choose: "Autonomous" })
1868
+ .expect({ question: "Autonomous", options: { include: ["Start"] }, choose: "Start" });
1869
+ const ppPhaseComplete = getTool(pi, "pp_phase_complete");
1870
+ await ppPhaseComplete.execute("call-1", { summary: "Conclusions ready" }, undefined, undefined, ctx);
1871
+
1872
+ expect(orchestrator.active!.state.autonomousConfig?.phases.plan).toBeDefined();
1873
+ expect(orchestrator.active!.state.autonomousConfig?.phases.implement).toBeDefined();
1874
+ expect(orchestrator.active!.state.autonomousConfig?.phases.brainstorm).toBeUndefined();
1875
+ });
1876
+
1877
+ it("from-task implement sets initialPhase plan and ask_user is blocked in autonomous plan", async () => {
1878
+ const cwd = makeTempDir();
1879
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
1880
+
1881
+ await orchestrator.startTask({ ...makeCtx(), cwd } as any, "debug", "Find bug");
1882
+ const debugDir = orchestrator.active!.dir;
1883
+ writeFileSync(join(debugDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
1884
+ writeFileSync(join(debugDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
1885
+
1886
+ await orchestrator.startTask({ ...makeCtx(), cwd } as any, "implement", "implement", debugDir, true, "autonomous");
1887
+ expect(orchestrator.active!.state.initialPhase).toBe("plan");
1888
+ expect(orchestrator.active!.state.phase).toBe("plan");
1889
+
1890
+ const toolCall = pi._handlers.get("tool_call")!;
1891
+ const result = await toolCall({ toolName: "ask_user", input: {} }, {});
1892
+ expect(result).toEqual({ block: true, reason: "Autonomous mode — make your best judgment based on available context." });
1893
+ });
1894
+
1895
+ it("autonomous prompt is full-replace with constraints first and mode-aware completion", async () => {
1896
+ const cwd = makeTempDir();
1897
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
1898
+ const ctx = makeCtx();
1899
+
1900
+ await orchestrator.startTask({ ...ctx, cwd } as any, "debug", "Find bug");
1901
+ const debugDir = orchestrator.active!.dir;
1902
+ writeFileSync(join(debugDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
1903
+ writeFileSync(join(debugDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
1904
+ await orchestrator.startTask({ ...ctx, cwd } as any, "implement", "implement", debugDir, true, "autonomous");
1905
+ orchestrator.active!.state.phase = "implement";
1906
+ orchestrator.active!.state.step = "llm_work";
1907
+
1908
+ const beforeStart = pi._handlers.get("before_agent_start")!;
1909
+ const result = await beforeStart({ systemPrompt: "HARNESS_BASE_PROMPT" }, ctx);
1910
+ const prompt = result?.systemPrompt ?? "";
1911
+ expect(prompt.startsWith("<constraints>")).toBe(true);
1912
+ expect(prompt).not.toContain("HARNESS_BASE_PROMPT");
1913
+ expect(prompt).toContain("The moment its work is complete, call pp_phase_complete");
1914
+ // No interactive '/pp menu' advance guidance in autonomous mode.
1915
+ expect(prompt).not.toContain("/pp menu");
1916
+ expect(prompt).not.toContain("advance it via");
1917
+ });
1918
+
1919
+ it("guided read-only phase prompt is XML macro-blocks with month/year + cwd and interactive completion", async () => {
1920
+ const cwd = makeTempDir();
1921
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
1922
+ const ctx = makeCtx();
1923
+
1924
+ await orchestrator.startTask({ ...ctx, cwd } as any, "implement", "implement", undefined, undefined, "guided");
1925
+ orchestrator.active!.state.phase = "plan";
1926
+ orchestrator.active!.state.step = "synthesize";
1927
+
1928
+ const beforeStart = pi._handlers.get("before_agent_start")!;
1929
+ const prompt = (await beforeStart({ systemPrompt: "HARNESS_BASE_PROMPT" }, ctx))?.systemPrompt ?? "";
1930
+ expect(prompt.startsWith("<constraints>")).toBe(true);
1931
+ expect(prompt).toContain("ACTIVE PHASE: plan (READ-ONLY)");
1932
+ expect(prompt).toContain("<principles>");
1933
+ expect(prompt).toContain("<tools>");
1934
+ expect(prompt).toContain("<task>");
1935
+ expect(prompt).toContain("let the user review and advance it via the /pp menu");
1936
+ expect(prompt).not.toContain("HARNESS_BASE_PROMPT");
1937
+ expect(prompt).toContain(`Working directory: ${cwd}.`);
1938
+ expect(prompt).toMatch(/Current month: \d{4}-\d{2}\./);
1939
+ });
1940
+
1941
+ it("first phase of an autonomous task stays interactive (ask_user allowed, interactive prompt)", async () => {
1942
+ const cwd = makeTempDir();
1943
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
1944
+ const ctx = makeCtx();
1945
+
1946
+ await orchestrator.startTask({ ...ctx, cwd } as any, "implement", "implement", undefined, undefined, "autonomous");
1947
+ // Task starts at its first phase (brainstorm) with task mode = autonomous.
1948
+ expect(orchestrator.active!.state.phase).toBe("brainstorm");
1949
+
1950
+ const toolCall = pi._handlers.get("tool_call")!;
1951
+ expect(await toolCall({ toolName: "ask_user", input: {} }, {})).toBeUndefined();
1952
+
1953
+ const beforeStart = pi._handlers.get("before_agent_start")!;
1954
+ const prompt = (await beforeStart({ systemPrompt: "base" }, ctx))?.systemPrompt ?? "";
1955
+ expect(prompt).not.toContain("There is no user driving this phase");
1956
+
1957
+ // Later autonomous phase (implement) is forcing and blocks ask_user.
1958
+ orchestrator.active!.state.phase = "implement";
1959
+ orchestrator.active!.state.step = "llm_work";
1960
+ expect(await toolCall({ toolName: "ask_user", input: {} }, {})).toEqual({
1961
+ block: true,
1962
+ reason: "Autonomous mode — make your best judgment based on available context.",
1963
+ });
1964
+ const implPrompt = (await beforeStart({ systemPrompt: "base" }, ctx))?.systemPrompt ?? "";
1965
+ expect(implPrompt).toContain("There is no user driving this phase");
1966
+ });
1967
+
1968
+ it("quick task completion line tells the agent to call pp_phase_complete", async () => {
1969
+ const cwd = makeTempDir();
1970
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
1971
+ const ctx = makeCtx();
1972
+
1973
+ await orchestrator.startTask({ ...ctx, cwd } as any, "quick", "quick");
1974
+ expect(orchestrator.active!.state.phase).toBe("quick");
1975
+
1976
+ const beforeStart = pi._handlers.get("before_agent_start")!;
1977
+ const prompt = (await beforeStart({ systemPrompt: "base" }, ctx))?.systemPrompt ?? "";
1978
+ expect(prompt).toContain("call pp_phase_complete");
1979
+ expect(prompt).not.toContain("advance it via the /pp menu");
1980
+ });
1981
+
1982
+ it("autonomous plan-phase prompt body contains no /pp guidance", async () => {
1983
+ const cwd = makeTempDir();
1984
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
1985
+ const ctx = makeCtx();
1986
+
1987
+ await orchestrator.startTask({ ...ctx, cwd } as any, "implement", "implement", undefined, undefined, "autonomous");
1988
+ orchestrator.active!.state.phase = "plan";
1989
+ orchestrator.active!.state.step = "synthesize";
1990
+
1991
+ const beforeStart = pi._handlers.get("before_agent_start")!;
1992
+ const prompt = (await beforeStart({ systemPrompt: "base" }, ctx))?.systemPrompt ?? "";
1993
+ expect(prompt).not.toContain("/pp");
1994
+ });
1995
+
1996
+ it("persists retry bookkeeping flags in task state", async () => {
1997
+ const cwd = makeTempDir();
1998
+ const { orchestrator } = await setupOrchestrator(cwd);
1999
+ const ctx = makeCtx();
2000
+
2001
+ await orchestrator.startTask(ctx as any, "implement", "implement", undefined, undefined, "autonomous");
2002
+ const taskDir = orchestrator.active!.dir;
2003
+ const state = loadTask(taskDir);
2004
+ expect(state.plannerFailureAutoRetried).toBe(false);
2005
+ expect(state.reviewerFailureAutoRetried).toBe(false);
2006
+ });
2007
+
2008
+ it("blocks ask_user in autonomous mode after first phase", async () => {
2009
+ const cwd = makeTempDir();
2010
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
2011
+
2012
+ await orchestrator.startTask({ ...makeCtx(), cwd } as any, "implement", "implement", undefined, undefined, "autonomous");
2013
+ orchestrator.active!.state.phase = "plan";
2014
+ const toolCall = pi._handlers.get("tool_call")!;
2015
+
2016
+ const result = await toolCall({ toolName: "ask_user", input: {} }, {});
2017
+ expect(result).toEqual({ block: true, reason: "Autonomous mode — make your best judgment based on available context." });
2018
+ });
2019
+
2020
+ it("blocks ask_user whenever effective mode is autonomous", async () => {
2021
+ const cwd = makeTempDir();
2022
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
2023
+
2024
+ await orchestrator.startTask({ ...makeCtx(), cwd } as any, "implement", "implement", undefined, undefined, "autonomous");
2025
+ orchestrator.active!.state.phase = "implement";
2026
+ const toolCall = pi._handlers.get("tool_call")!;
2027
+
2028
+ const result = await toolCall({ toolName: "ask_user", input: {} }, {});
2029
+ expect(result).toEqual({ block: true, reason: "Autonomous mode — make your best judgment based on available context." });
2030
+ });
2031
+
2032
+ it("resume preserves autonomous mode", async () => {
2033
+ const cwd = makeTempDir();
2034
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
2035
+ const ctx = makeCtx();
2036
+
2037
+ await orchestrator.startTask(ctx as any, "implement", "implement", undefined, undefined, "autonomous");
2038
+ const taskDir = orchestrator.active!.dir;
2039
+ saveTask(taskDir, orchestrator.active!.state);
2040
+ await orchestrator.cleanupActive();
2041
+
2042
+ const title = "implement";
2043
+ const pp = getCommand(pi, "pp");
2044
+ menu
2045
+ .expect({ question: "/pp", options: { include: ["Task"] }, choose: "Task" })
2046
+ .expect({ question: "Task", options: { include: ["Resume"] }, choose: "Resume" })
2047
+ .expect({ question: "Resume", options: { include: [title] }, choose: title });
2048
+ await pp(undefined, ctx);
2049
+
2050
+ expect(orchestrator.active).not.toBeNull();
2051
+ expect(orchestrator.active!.state.mode).toBe("autonomous");
2052
+ });
2053
+
2054
+ it("resume preserves autonomousConfig", async () => {
2055
+ const cwd = makeTempDir();
2056
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
2057
+ const ctx = makeCtx();
2058
+
2059
+ await orchestrator.startTask(ctx as any, "implement", "resume autonomous config", undefined, undefined, "autonomous");
2060
+ const taskDir = orchestrator.active!.dir;
2061
+ orchestrator.active!.state.autonomousConfig = {
2062
+ phases: {
2063
+ brainstorm: { reviewPreset: "regular", maxReviewPasses: 1 },
2064
+ plan: { plannerPreset: "regular", reviewPreset: "regular", maxReviewPasses: 2 },
2065
+ implement: { reviewPreset: "regular", maxReviewPasses: 3 },
2066
+ },
2067
+ };
2068
+ saveTask(taskDir, orchestrator.active!.state);
2069
+ await orchestrator.cleanupActive();
2070
+
2071
+ const pp = getCommand(pi, "pp");
2072
+ menu
2073
+ .expect({ question: "/pp", options: { include: ["Task"] }, choose: "Task" })
2074
+ .expect({ question: "Task", options: { include: ["Resume"] }, choose: "Resume" })
2075
+ .expect({ question: "Resume", options: { include: ["resume autonomous config"] }, choose: "resume autonomous config" });
2076
+ await pp(undefined, ctx);
2077
+
2078
+ expect(orchestrator.active).not.toBeNull();
2079
+ expect(orchestrator.active!.state.autonomousConfig).toEqual({
2080
+ phases: {
2081
+ brainstorm: { reviewPreset: "regular", maxReviewPasses: 1 },
2082
+ plan: { plannerPreset: "regular", reviewPreset: "regular", maxReviewPasses: 2 },
2083
+ implement: { reviewPreset: "regular", maxReviewPasses: 3 },
2084
+ },
2085
+ });
2086
+ });
2087
+
2088
+ it("autonomous mode skips planner preset picker during transition", async () => {
2089
+ const cwd = makeTempDir();
2090
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
2091
+ const ctx = makeCtx();
2092
+
2093
+ await orchestrator.startTask(ctx as any, "implement", "implement", undefined, undefined, "autonomous");
2094
+ const taskDir = orchestrator.active!.dir;
2095
+ orchestrator.active!.state.autonomousConfig = {
2096
+ phases: {
2097
+ brainstorm: { reviewPreset: "regular", maxReviewPasses: 0 },
2098
+ plan: { plannerPreset: "regular", reviewPreset: "regular", maxReviewPasses: 0 },
2099
+ implement: { reviewPreset: "regular", maxReviewPasses: 0 },
2100
+ },
2101
+ };
2102
+ writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
2103
+ writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
2104
+
2105
+ const ppPhaseComplete = getTool(pi, "pp_phase_complete");
2106
+ await ppPhaseComplete.execute("call-autonomous-skip-planner", { summary: "done" }, undefined, undefined, ctx);
2107
+ await new Promise((r) => setTimeout(r, 10));
2108
+
2109
+ expect(menu.transcript.filter((entry) => entry.question.includes("Planner preset"))).toHaveLength(0);
2110
+ });
2111
+
2112
+ it("autonomous first review triggers automatically and second step proceeds after reviewer output", async () => {
2113
+ const cwd = makeTempDir();
2114
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
2115
+ const ctx = makeCtx();
2116
+
2117
+ await orchestrator.startTask(ctx as any, "implement", "implement", undefined, undefined, "autonomous");
2118
+ const taskDir = orchestrator.active!.dir;
2119
+ orchestrator.active!.state.phase = "implement";
2120
+ orchestrator.active!.state.step = "llm_work";
2121
+ orchestrator.active!.state.autonomousConfig = {
2122
+ phases: {
2123
+ implement: { reviewPreset: "regular", maxReviewPasses: 2 },
2124
+ },
2125
+ };
2126
+ writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
2127
+ writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
2128
+ mkdirSync(join(taskDir, "plans"), { recursive: true });
2129
+ writeFileSync(
2130
+ join(taskDir, "plans", "1_synthesized.md"),
2131
+ makeValidPlan(["- [x] P1. Done item — Done when: synthesized plan is fully checked"]),
2132
+ "utf-8",
2133
+ );
2134
+
2135
+ const ppPhaseComplete = getTool(pi, "pp_phase_complete");
2136
+ const first = await ppPhaseComplete.execute("call-autonomous-review-1", { summary: "done" }, undefined, undefined, ctx);
2137
+ expect(first.content[0].text).toMatch(/Reviews are running|Started review cycle pass/);
2138
+ expect(orchestrator.active!.state.reviewCycle).not.toBeNull();
2139
+ expect(orchestrator.active!.state.reviewCycle?.pass).toBe(1);
2140
+ expect(["await_reviewers", "apply_feedback"]).toContain(orchestrator.active!.state.reviewCycle?.step);
2141
+
2142
+ const reviewsDir = join(taskDir, "code-reviews");
2143
+ mkdirSync(reviewsDir, { recursive: true });
2144
+ for (const v of ["opus", "gpt", "gemini"]) {
2145
+ writeFileSync(join(reviewsDir, `1_${v}_round-1.md`), "VERDICT: APPROVE\n- CRITICAL: none", "utf-8");
2146
+ }
2147
+ emitSubagentCreated(pi, "reviewer-auto-1", "Code reviewer (test)");
2148
+ emitSubagentCompleted(pi, "reviewer-auto-1", "Code reviewer (test)");
2149
+
2150
+ const second = await ppPhaseComplete.execute("call-autonomous-review-2", { summary: "applied" }, undefined, undefined, ctx);
2151
+ expect(second.content[0].text).toBe("");
2152
+ expect(orchestrator.active).toBeNull();
2153
+ });
2154
+
2155
+ it("switching to guided during await_reviewers shows reviewer failure dialog", async () => {
2156
+ const cwd = makeTempDir();
2157
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
2158
+ const ctx = makeCtx();
2159
+
2160
+ await orchestrator.startTask(ctx as any, "implement", "implement", undefined, undefined, "autonomous");
2161
+ orchestrator.active!.state.phase = "implement";
2162
+ orchestrator.active!.state.step = "await_reviewers";
2163
+ orchestrator.active!.state.reviewCycle = { kind: "auto", step: "await_reviewers", pass: 1 };
2164
+ orchestrator.active!.state.effectiveMode = "guided";
2165
+ orchestrator.failedReviewerVariants = ["test"];
2166
+ orchestrator.pendingSubagentSpawns = 0;
2167
+ saveTask(orchestrator.active!.dir, orchestrator.active!.state);
2168
+
2169
+ menu.expect({
2170
+ question: /Some reviewers failed:/,
2171
+ options: {
2172
+ exact: ["Retry failed reviewers", "Work with available reviewer outputs", "Continue without review", "Stop task"],
2173
+ },
2174
+ choose: "Continue without review",
2175
+ });
2176
+ emitSubagentCompleted(pi, "reviewer-guided-switch", "Code reviewer (test)");
2177
+ await new Promise((r) => setTimeout(r, 10));
2178
+
2179
+ expect(menu.transcript.some((entry) => entry.question.includes("Some reviewers failed:"))).toBe(true);
2180
+ });
2181
+
2182
+ it("review task autonomous config covers only plan/implement, not the review first phase", async () => {
2183
+ const cwd = makeTempDir();
2184
+ const pi = makePi();
2185
+ const orchestrator = new Orchestrator(pi as any);
2186
+ registerEventHandlers(orchestrator);
2187
+ registerCommandHandlers(orchestrator);
2188
+ const ctx = makeCtx({ cwd });
2189
+
2190
+ const sessionStartHandler = pi._handlers.get("session_start")!;
2191
+ await sessionStartHandler({}, ctx);
2192
+
2193
+ menu
2194
+ .expect({ question: "/pp", options: { include: ["Task"] }, choose: "Task" })
2195
+ .expect({ question: "Task", options: { include: ["Review"] }, choose: "Review" })
2196
+ .expect({ question: "Review", options: { include: ["Describe"] }, choose: "Describe" })
2197
+ .expect({ question: "Mode", options: { include: ["Autonomous"] }, choose: "Autonomous" })
2198
+ .expect({ question: "Autonomous", options: { include: ["Start"] }, choose: "Start" });
2199
+ ctx.ui.input.mockResolvedValueOnce("Review current branch changes");
2200
+ const pp = getCommand(pi, "pp");
2201
+ await pp(undefined, ctx);
2202
+
2203
+ expect(orchestrator.active!.type).toBe("review");
2204
+ expect(orchestrator.active!.state.autonomousConfig?.phases.review).toBeUndefined();
2205
+ expect(orchestrator.active!.state.autonomousConfig?.phases.implement?.reviewPreset).toBe("regular");
2206
+ });
2207
+
2208
+ it("quick task does not track modified files", async () => {
2209
+ const cwd = makeTempDir();
2210
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
2211
+
2212
+ await orchestrator.startTask(makeCtx() as any, "quick", "quick");
2213
+ const toolResult = pi._handlers.get("tool_result")!;
2214
+ await toolResult({ toolName: "write", input: { path: "src/quick.ts" }, isError: false, content: [] }, {});
2215
+
2216
+ expect(orchestrator.active!.modifiedFiles.size).toBe(0);
2217
+ expect(orchestrator.active!.state.modifiedFiles ?? []).toEqual([]);
2218
+ });
2219
+
2220
+ it("quick task does not run afterEdit", async () => {
2221
+ const cwd = makeTempDir();
2222
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
2223
+ const runAfterEditSpy = vi.spyOn(commandsModule, "runAfterEdit");
2224
+
2225
+ await orchestrator.startTask(makeCtx() as any, "quick", "quick");
2226
+ const toolResult = pi._handlers.get("tool_result")!;
2227
+ await toolResult({ toolName: "write", input: { path: "src/quick.ts" }, isError: false, content: [] }, {});
2228
+
2229
+ expect(runAfterEditSpy).not.toHaveBeenCalled();
2230
+ });
2231
+
2232
+ it("autonomous ask_user blocked in plan and implement", async () => {
2233
+ const cwd = makeTempDir();
2234
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
2235
+
2236
+ await orchestrator.startTask({ ...makeCtx(), cwd } as any, "implement", "implement", undefined, undefined, "autonomous");
2237
+ const toolCall = pi._handlers.get("tool_call")!;
2238
+ const blocked = { block: true, reason: "Autonomous mode — make your best judgment based on available context." };
2239
+
2240
+ orchestrator.active!.state.phase = "plan";
2241
+ expect(await toolCall({ toolName: "ask_user", input: {} }, {})).toEqual(blocked);
2242
+
2243
+ orchestrator.active!.state.phase = "implement";
2244
+ expect(await toolCall({ toolName: "ask_user", input: {} }, {})).toEqual(blocked);
2245
+ });
2246
+ });
2247
+
2248
+ describe("review task lifecycle", () => {
2249
+ it("review task starts in review phase", async () => {
2250
+ const cwd = makeTempDir();
2251
+ const { orchestrator } = await setupOrchestrator(cwd);
2252
+ const ctx = makeCtx();
2253
+
2254
+ await orchestrator.startTask(ctx as any, "review", "Review changes");
2255
+
2256
+ expect(orchestrator.active!.type).toBe("review");
2257
+ expect(orchestrator.active!.state.phase).toBe("review");
2258
+ expect(orchestrator.active!.state.step).toBe("llm_work");
2259
+ });
2260
+
2261
+ it("review task transitions review to plan to implement to done", async () => {
2262
+ const cwd = makeTempDir();
2263
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
2264
+ const ctx = makeCtx();
2265
+
2266
+ await orchestrator.startTask(ctx as any, "review", "Review flow");
2267
+ const taskDir = orchestrator.active!.dir;
2268
+
2269
+ await moveTaskToImplementPhase(pi, orchestrator, ctx, "call-review-to-plan", "call-plan-to-implement");
2270
+ expect(orchestrator.active!.state.phase).toBe("implement");
2271
+
2272
+ expectImplementToDone(menu);
2273
+ const ppPhaseComplete = getTool(pi, "pp_phase_complete");
2274
+ await ppPhaseComplete.execute("call-implement-to-done", { summary: "done" }, undefined, undefined, ctx);
2275
+
2276
+ expect(orchestrator.active).toBeNull();
2277
+ expect(loadTask(taskDir).phase).toBe("done");
2278
+ });
2279
+
2280
+ it("review phase exit criteria requires USER_REQUEST and RESEARCH", async () => {
2281
+ const cwd = makeTempDir();
2282
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
2283
+ const ctx = makeCtx();
2284
+
2285
+ await orchestrator.startTask(ctx as any, "review", "Review validation");
2286
+
2287
+ expectBrainstormToPlan(menu);
2288
+ const ppPhaseComplete = getTool(pi, "pp_phase_complete");
2289
+ const result = await ppPhaseComplete.execute("call-review-validation", { summary: "done" }, undefined, undefined, ctx);
2290
+
2291
+ expect(result.content[0].text).toContain("Transition blocked");
2292
+ expect(result.content[0].text).toContain("USER_REQUEST.md");
2293
+ expect(orchestrator.active!.state.phase).toBe("review");
2294
+ });
2295
+ });
2296
+
2297
+ describe("debug task lifecycle", () => {
2298
+ it("debug task starts in debug phase", async () => {
2299
+ const cwd = makeTempDir();
2300
+ const { orchestrator } = await setupOrchestrator(cwd);
2301
+
2302
+ await orchestrator.startTask(makeCtx() as any, "debug", "Debug issue");
2303
+
2304
+ expect(orchestrator.active!.type).toBe("debug");
2305
+ expect(orchestrator.active!.state.phase).toBe("debug");
2306
+ });
2307
+
2308
+ it("debug task transitions debug to plan to implement to done", async () => {
2309
+ const cwd = makeTempDir();
2310
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
2311
+ const ctx = makeCtx();
2312
+
2313
+ await orchestrator.startTask(ctx as any, "debug", "Debug flow");
2314
+ const taskDir = orchestrator.active!.dir;
2315
+
2316
+ await moveTaskToImplementPhase(pi, orchestrator, ctx, "call-debug-to-plan", "call-debug-plan-to-implement");
2317
+ expect(orchestrator.active!.state.phase).toBe("implement");
2318
+
2319
+ expectImplementToDone(menu);
2320
+ const ppPhaseComplete = getTool(pi, "pp_phase_complete");
2321
+ await ppPhaseComplete.execute("call-debug-implement-to-done", { summary: "done" }, undefined, undefined, ctx);
2322
+
2323
+ expect(orchestrator.active).toBeNull();
2324
+ expect(loadTask(taskDir).phase).toBe("done");
2325
+ });
2326
+ });
2327
+
2328
+ describe("modified file tracking", () => {
2329
+ it("tool_result tracks write and edit in implement phase", async () => {
2330
+ const cwd = makeTempDir();
2331
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
2332
+ const ctx = makeCtx();
2333
+
2334
+ await orchestrator.startTask(ctx as any, "implement", "Track writes");
2335
+ await moveTaskToImplementPhase(pi, orchestrator, ctx, "call-track-brainstorm", "call-track-plan");
2336
+
2337
+ const toolResult = pi._handlers.get("tool_result")!;
2338
+ await toolResult({ toolName: "write", input: { path: "src/a.ts" }, isError: false, content: [] }, {});
2339
+ await toolResult({ toolName: "edit", input: { path: "src/b.ts" }, isError: false, content: [] }, {});
2340
+
2341
+ expect(orchestrator.active!.modifiedFiles.has(join(cwd, "src", "a.ts"))).toBe(true);
2342
+ expect(orchestrator.active!.modifiedFiles.has(join(cwd, "src", "b.ts"))).toBe(true);
2343
+ });
2344
+
2345
+ it("source write clears reviewApprovedClean so post-approval edits get re-reviewed", async () => {
2346
+ const cwd = makeTempDir();
2347
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
2348
+ const ctx = makeCtx();
2349
+
2350
+ await orchestrator.startTask(ctx as any, "implement", "Stale flag");
2351
+ await moveTaskToImplementPhase(pi, orchestrator, ctx, "call-stale-brainstorm", "call-stale-plan");
2352
+ orchestrator.active!.state.reviewApprovedClean = true;
2353
+
2354
+ const toolResult = pi._handlers.get("tool_result")!;
2355
+ await toolResult({ toolName: "write", input: { path: "src/c.ts" }, isError: false, content: [] }, {});
2356
+
2357
+ expect(orchestrator.active!.state.reviewApprovedClean).toBe(false);
2358
+ });
2359
+
2360
+ it("tool_result ignores writes inside .pp directory", async () => {
2361
+ const cwd = makeTempDir();
2362
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
2363
+ const runAfterEditSpy = vi.spyOn(commandsModule, "runAfterEdit");
2364
+
2365
+ await orchestrator.startTask(makeCtx() as any, "implement", "Ignore .pp writes");
2366
+ orchestrator.active!.state.phase = "implement";
2367
+ const toolResult = pi._handlers.get("tool_result")!;
2368
+ await toolResult({ toolName: "write", input: { path: ".pp/state/implement/x/note.md" }, isError: false, content: [] }, {});
2369
+
2370
+ expect(orchestrator.active!.modifiedFiles.size).toBe(0);
2371
+ expect(runAfterEditSpy).not.toHaveBeenCalled();
2372
+ });
2373
+
2374
+ it("tool_result ignores writes outside implement phase", async () => {
2375
+ const cwd = makeTempDir();
2376
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
2377
+ const runAfterEditSpy = vi.spyOn(commandsModule, "runAfterEdit");
2378
+
2379
+ await orchestrator.startTask(makeCtx() as any, "implement", "Ignore non-implement writes");
2380
+ orchestrator.active!.state.phase = "brainstorm";
2381
+ const toolResult = pi._handlers.get("tool_result")!;
2382
+ await toolResult({ toolName: "write", input: { path: "src/not-tracked.ts" }, isError: false, content: [] }, {});
2383
+
2384
+ expect(orchestrator.active!.modifiedFiles.size).toBe(0);
2385
+ expect(runAfterEditSpy).not.toHaveBeenCalled();
2386
+ });
2387
+
2388
+ it("root repo edits trigger root afterEdit commands", async () => {
2389
+ const cwd = makeTempDir();
2390
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
2391
+ const runAfterEditSpy = vi.spyOn(commandsModule, "runAfterEdit");
2392
+ const loadRepoAfterEditCommandsSpy = vi.spyOn(commandsModule, "loadRepoAfterEditCommands");
2393
+
2394
+ await orchestrator.startTask(makeCtx() as any, "implement", "afterEdit root");
2395
+ orchestrator.active!.state.phase = "implement";
2396
+ orchestrator.active!.state.repos = [{ path: cwd, isRoot: true }];
2397
+ saveTask(orchestrator.active!.dir, orchestrator.active!.state);
2398
+
2399
+ const toolResult = pi._handlers.get("tool_result")!;
2400
+ await toolResult({ toolName: "write", input: { path: "src/root.ts" }, isError: false, content: [] }, {});
2401
+
2402
+ expect(runAfterEditSpy).toHaveBeenCalledTimes(1);
2403
+ expect(runAfterEditSpy.mock.calls[0]?.[0]).toBe("src/root.ts");
2404
+ expect(loadRepoAfterEditCommandsSpy).not.toHaveBeenCalled();
2405
+ });
2406
+
2407
+ it("extra repo edits trigger extra repo afterEdit when extra configs are enabled", async () => {
2408
+ const cwd = makeTempDir();
2409
+ const extraRepo = join(cwd, "extra-repo");
2410
+ mkdirSync(extraRepo, { recursive: true });
2411
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
2412
+ const runAfterEditSpy = vi.spyOn(commandsModule, "runAfterEdit");
2413
+ const loadRepoAfterEditCommandsSpy = vi
2414
+ .spyOn(commandsModule, "loadRepoAfterEditCommands")
2415
+ .mockReturnValue({ "cmd-1": { run: "npm run lint", globs: ["**/*.ts"] } });
2416
+
2417
+ await orchestrator.startTask(makeCtx() as any, "implement", "afterEdit extra");
2418
+ orchestrator.config = {
2419
+ ...orchestrator.config,
2420
+ general: { ...orchestrator.config.general, loadExtraRepoConfigs: true },
2421
+ } as any;
2422
+ orchestrator.active!.state.phase = "implement";
2423
+ orchestrator.active!.state.repos = [
2424
+ { path: cwd, isRoot: true },
2425
+ { path: extraRepo, isRoot: false },
2426
+ ];
2427
+ saveTask(orchestrator.active!.dir, orchestrator.active!.state);
2428
+
2429
+ const toolResult = pi._handlers.get("tool_result")!;
2430
+ await toolResult({ toolName: "write", input: { path: "extra-repo/src/extra.ts" }, isError: false, content: [] }, {});
2431
+
2432
+ expect(loadRepoAfterEditCommandsSpy).toHaveBeenCalledWith(extraRepo);
2433
+ expect(runAfterEditSpy).toHaveBeenCalledTimes(1);
2434
+ expect(runAfterEditSpy).toHaveBeenCalledWith(
2435
+ "src/extra.ts",
2436
+ { "cmd-1": { run: "npm run lint", globs: ["**/*.ts"] } },
2437
+ orchestrator.config.performance.commands.afterEdit,
2438
+ extraRepo,
2439
+ );
2440
+ });
2441
+
2442
+ it("extra repo edits are skipped when ignoreExtraRepoConfigs is true", async () => {
2443
+ const cwd = makeTempDir();
2444
+ const extraRepo = join(cwd, "extra-repo");
2445
+ mkdirSync(extraRepo, { recursive: true });
2446
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
2447
+ const runAfterEditSpy = vi.spyOn(commandsModule, "runAfterEdit");
2448
+ const loadRepoAfterEditCommandsSpy = vi.spyOn(commandsModule, "loadRepoAfterEditCommands");
2449
+
2450
+ await orchestrator.startTask(makeCtx() as any, "implement", "afterEdit extra skipped");
2451
+ orchestrator.config = {
2452
+ ...orchestrator.config,
2453
+ general: { ...orchestrator.config.general, loadExtraRepoConfigs: false },
2454
+ } as any;
2455
+ orchestrator.active!.state.phase = "implement";
2456
+ orchestrator.active!.state.repos = [
2457
+ { path: cwd, isRoot: true },
2458
+ { path: extraRepo, isRoot: false },
2459
+ ];
2460
+ saveTask(orchestrator.active!.dir, orchestrator.active!.state);
2461
+
2462
+ const toolResult = pi._handlers.get("tool_result")!;
2463
+ await toolResult({ toolName: "write", input: { path: "extra-repo/src/extra.ts" }, isError: false, content: [] }, {});
2464
+
2465
+ expect(loadRepoAfterEditCommandsSpy).not.toHaveBeenCalled();
2466
+ expect(runAfterEditSpy).not.toHaveBeenCalled();
2467
+ });
2468
+
2469
+ it("pp_commit with autoCommit disabled returns message", async () => {
2470
+ const cwd = makeTempDir();
2471
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
2472
+
2473
+ await orchestrator.startTask(makeCtx() as any, "implement", "commit disabled");
2474
+ orchestrator.config = {
2475
+ ...orchestrator.config,
2476
+ general: { ...orchestrator.config.general, autoCommit: false },
2477
+ } as any;
2478
+
2479
+ const ppCommit = getTool(pi, "pp_commit");
2480
+ const result = await ppCommit.execute("call-commit-disabled", { message: "msg" });
2481
+
2482
+ expect(result.content[0].text).toContain("autoCommit is disabled");
2483
+ });
2484
+
2485
+ it("pp_commit with no modified files returns message", async () => {
2486
+ const cwd = makeTempDir();
2487
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
2488
+
2489
+ await orchestrator.startTask(makeCtx() as any, "implement", "commit empty");
2490
+ orchestrator.config = {
2491
+ ...orchestrator.config,
2492
+ general: { ...orchestrator.config.general, autoCommit: true },
2493
+ } as any;
2494
+ pi.exec.mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" });
2495
+
2496
+ const ppCommit = getTool(pi, "pp_commit");
2497
+ const result = await ppCommit.execute("call-commit-empty", { message: "msg" });
2498
+
2499
+ expect(result.content[0].text).toContain("No modified files to commit");
2500
+ });
2501
+
2502
+ it("pp_commit with unregistered repo returns error", async () => {
2503
+ const cwd = makeTempDir();
2504
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
2505
+
2506
+ await orchestrator.startTask(makeCtx() as any, "implement", "commit invalid repo");
2507
+ orchestrator.config = {
2508
+ ...orchestrator.config,
2509
+ general: { ...orchestrator.config.general, autoCommit: true },
2510
+ } as any;
2511
+
2512
+ const ppCommit = getTool(pi, "pp_commit");
2513
+ const result = await ppCommit.execute("call-commit-unregistered", {
2514
+ message: "msg",
2515
+ repo: join(cwd, "unregistered"),
2516
+ });
2517
+
2518
+ expect(result.isError).toBe(true);
2519
+ expect(result.content[0].text).toContain("Repository is not registered");
2520
+ });
2521
+
2522
+ it("pp_commit clears modified files after success", async () => {
2523
+ const cwd = makeTempDir();
2524
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
2525
+ const autoCommitSpy = vi.spyOn(commandsModule, "autoCommit").mockReturnValue({ ok: true, commitHash: "abc123" });
2526
+ const ctx = makeCtx();
2527
+
2528
+ await orchestrator.startTask(ctx as any, "implement", "commit clear");
2529
+ orchestrator.config = {
2530
+ ...orchestrator.config,
2531
+ general: { ...orchestrator.config.general, autoCommit: true },
2532
+ } as any;
2533
+ orchestrator.active!.state.phase = "implement";
2534
+ orchestrator.active!.modifiedFiles.add(join(cwd, "src", "tracked.ts"));
2535
+ orchestrator.active!.state.modifiedFiles = [...orchestrator.active!.modifiedFiles];
2536
+ saveTask(orchestrator.active!.dir, orchestrator.active!.state);
2537
+
2538
+ pi.exec.mockResolvedValueOnce({
2539
+ code: 0,
2540
+ stdout: " M src/tracked.ts\n",
2541
+ stderr: "",
2542
+ });
2543
+
2544
+ const ppCommit = getTool(pi, "pp_commit");
2545
+ const result = await ppCommit.execute("call-pp-commit", { message: "commit files" });
2546
+
2547
+ expect(result.content[0].text).toContain("Committed 1 file");
2548
+ expect(orchestrator.active!.modifiedFiles.size).toBe(0);
2549
+ expect(orchestrator.active!.state.modifiedFiles).toEqual([]);
2550
+ expect(autoCommitSpy).toHaveBeenCalled();
2551
+ });
2552
+
2553
+ it("pp_commit parses renamed files and stages new path", async () => {
2554
+ const cwd = makeTempDir();
2555
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
2556
+ const autoCommitSpy = vi.spyOn(commandsModule, "autoCommit").mockReturnValue({ ok: true, commitHash: "abc123" });
2557
+
2558
+ await orchestrator.startTask(makeCtx() as any, "implement", "commit rename parse");
2559
+ orchestrator.config = {
2560
+ ...orchestrator.config,
2561
+ general: { ...orchestrator.config.general, autoCommit: true },
2562
+ } as any;
2563
+ orchestrator.active!.state.phase = "implement";
2564
+ orchestrator.active!.state.repos = [{ path: cwd, isRoot: true }];
2565
+ saveTask(orchestrator.active!.dir, orchestrator.active!.state);
2566
+
2567
+ pi.exec.mockResolvedValueOnce({
2568
+ code: 0,
2569
+ stdout: "R src/old.ts -> src/new.ts\n",
2570
+ stderr: "",
2571
+ });
2572
+
2573
+ const ppCommit = getTool(pi, "pp_commit");
2574
+ const result = await ppCommit.execute("call-pp-commit-rename", { message: "rename file" });
2575
+
2576
+ expect(result.content[0].text).toContain("Committed 1 file");
2577
+ expect(autoCommitSpy).toHaveBeenCalledWith(["src/new.ts"], "rename file", cwd);
2578
+ });
2579
+ });
2580
+
2581
+ describe("resume and recovery", () => {
2582
+ it("resume paused task restores task phase and step", async () => {
2583
+ const cwd = makeTempDir();
2584
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
2585
+ const ctx = makeCtx();
2586
+
2587
+ await orchestrator.startTask(ctx as any, "implement", "resume phase state");
2588
+ const taskDir = orchestrator.active!.dir;
2589
+ orchestrator.active!.state.phase = "plan";
2590
+ orchestrator.active!.state.step = "synthesize";
2591
+ orchestrator.active!.state.modifiedFiles = [join(cwd, "src", "restored.ts")];
2592
+ saveTask(taskDir, orchestrator.active!.state);
2593
+ await orchestrator.cleanupActive();
2594
+
2595
+ const pp = getCommand(pi, "pp");
2596
+ menu
2597
+ .expect({ question: "/pp", options: { include: ["Task"] }, choose: "Task" })
2598
+ .expect({ question: "Task", options: { include: ["Resume"] }, choose: "Resume" })
2599
+ .expect({ question: "Resume", options: { include: ["resume phase state"] }, choose: "resume phase state" });
2600
+ await pp(undefined, ctx);
2601
+
2602
+ expect(orchestrator.active).not.toBeNull();
2603
+ expect(orchestrator.active!.state.phase).toBe("plan");
2604
+ expect(orchestrator.active!.state.step).toBe("synthesize");
2605
+ expect(orchestrator.active!.modifiedFiles.has(join(cwd, "src", "restored.ts"))).toBe(true);
2606
+ });
2607
+
2608
+ it("resume prunes stale repos that no longer exist", async () => {
2609
+ const cwd = makeTempDir();
2610
+ mkdirSync(join(cwd, ".git"), { recursive: true });
2611
+ const staleRepo = join(cwd, "missing-repo");
2612
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
2613
+ const ctx = makeCtx();
2614
+
2615
+ await orchestrator.startTask(ctx as any, "implement", "resume stale repos");
2616
+ const taskDir = orchestrator.active!.dir;
2617
+ orchestrator.active!.state.repos = [
2618
+ { path: cwd, isRoot: true },
2619
+ { path: staleRepo, isRoot: false },
2620
+ ];
2621
+ saveTask(taskDir, orchestrator.active!.state);
2622
+ await orchestrator.cleanupActive();
2623
+
2624
+ const pp = getCommand(pi, "pp");
2625
+ menu
2626
+ .expect({ question: "/pp", options: { include: ["Task"] }, choose: "Task" })
2627
+ .expect({ question: "Task", options: { include: ["Resume"] }, choose: "Resume" })
2628
+ .expect({ question: "Resume", options: { include: ["resume stale repos"] }, choose: "resume stale repos" });
2629
+ await pp(undefined, ctx);
2630
+
2631
+ expect(orchestrator.active!.state.repos).toEqual([{ path: cwd, isRoot: true }]);
2632
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("Pruned 1 stale repo"), "warning");
2633
+ });
2634
+
2635
+ it("getActiveTask returns null when multiple unlocked tasks exist", () => {
2636
+ const cwd = makeTempDir();
2637
+ createTask(cwd, "implement", "first unlocked");
2638
+ createTask(cwd, "debug", "second unlocked");
2639
+
2640
+ const active = getActiveTask(cwd);
2641
+
2642
+ expect(active).toBeNull();
2643
+ });
2644
+
2645
+ it("getActiveTask returns the single unlocked task", () => {
2646
+ const cwd = makeTempDir();
2647
+ const taskDir = createTask(cwd, "implement", "single unlocked");
2648
+
2649
+ const active = getActiveTask(cwd);
2650
+
2651
+ expect(active?.dir).toBe(taskDir);
2652
+ });
2653
+ });
2654
+
2655
+ describe("crash resume", () => {
2656
+ it("resume in await_planners with partial plan outputs moves to synthesize when all enabled outputs exist", async () => {
2657
+ const cwd = makeTempDir();
2658
+ mkdirSync(join(cwd, ".git"), { recursive: true });
2659
+ const { orchestrator } = await setupOrchestrator(cwd);
2660
+ const ctx = makeCtx({ cwd });
2661
+
2662
+ await orchestrator.startTask(ctx as any, "implement", "resume planners complete");
2663
+ const taskDir = orchestrator.active!.dir;
2664
+ writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
2665
+ writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
2666
+ mkdirSync(join(taskDir, "plans"), { recursive: true });
2667
+ writeFileSync(
2668
+ join(taskDir, "plans", `${Math.floor(Date.now() / 1000)}_test.md`),
2669
+ makeValidPlan(["- [ ] P1. Planner output item — Done when: planner output exists"]),
2670
+ "utf-8",
2671
+ );
2672
+ orchestrator.active!.state.phase = "plan";
2673
+ orchestrator.active!.state.step = "await_planners";
2674
+ orchestrator.active!.state.activePlannerPreset = "regular";
2675
+ saveTask(taskDir, orchestrator.active!.state);
2676
+ await orchestrator.cleanupActive();
2677
+
2678
+ const result = await resumeTask(orchestrator, ctx, { dir: taskDir, state: loadTask(taskDir), type: "implement" });
2679
+
2680
+ expect(result.ok).toBe(true);
2681
+ expect(orchestrator.active!.state.step).toBe("synthesize");
2682
+ });
2683
+
2684
+ it("resume in await_planners with missing outputs keeps await_planners and attempts planner respawn", async () => {
2685
+ const cwd = makeTempDir();
2686
+ mkdirSync(join(cwd, ".git"), { recursive: true });
2687
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
2688
+ const ctx = makeCtx({ cwd });
2689
+
2690
+ await orchestrator.startTask(ctx as any, "implement", "resume planners missing");
2691
+ const taskDir = orchestrator.active!.dir;
2692
+ writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
2693
+ writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
2694
+ orchestrator.active!.state.phase = "plan";
2695
+ orchestrator.active!.state.step = "await_planners";
2696
+ orchestrator.active!.state.activePlannerPreset = "regular";
2697
+ saveTask(taskDir, orchestrator.active!.state);
2698
+ await orchestrator.cleanupActive();
2699
+
2700
+ const result = await resumeTask(orchestrator, ctx, { dir: taskDir, state: loadTask(taskDir), type: "implement" });
2701
+ await new Promise((r) => setTimeout(r, 10));
2702
+
2703
+ expect(result.ok).toBe(true);
2704
+ expect(orchestrator.active!.state.step).toBe("await_planners");
2705
+ expect(pi.sendMessage).toHaveBeenCalledWith(
2706
+ expect.objectContaining({ customType: "pp-planners-error" }),
2707
+ { deliverAs: "steer" },
2708
+ );
2709
+ });
2710
+
2711
+ it("resume in reviewCycle apply_feedback delivers review outputs and keeps cycle active", async () => {
2712
+ const cwd = makeTempDir();
2713
+ mkdirSync(join(cwd, ".git"), { recursive: true });
2714
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
2715
+ const ctx = makeCtx({ cwd });
2716
+
2717
+ await orchestrator.startTask(ctx as any, "implement", "resume apply feedback");
2718
+ const taskDir = orchestrator.active!.dir;
2719
+ mkdirSync(join(taskDir, "code-reviews"), { recursive: true });
2720
+ writeFileSync(join(taskDir, "code-reviews", `${Math.floor(Date.now() / 1000)}_test_round-1.md`), "Review note", "utf-8");
2721
+ orchestrator.active!.state.phase = "implement";
2722
+ orchestrator.active!.state.step = "apply_feedback";
2723
+ orchestrator.active!.state.reviewCycle = { kind: "auto", step: "apply_feedback", pass: 1 };
2724
+ saveTask(taskDir, orchestrator.active!.state);
2725
+ await orchestrator.cleanupActive();
2726
+
2727
+ const result = await resumeTask(orchestrator, ctx, { dir: taskDir, state: loadTask(taskDir), type: "implement" });
2728
+
2729
+ expect(result.ok).toBe(true);
2730
+ expect(orchestrator.active!.state.reviewCycle).toEqual({ kind: "auto", step: "apply_feedback", pass: 1 });
2731
+ expect(pi.sendMessage).toHaveBeenCalledWith(
2732
+ expect.objectContaining({
2733
+ customType: "pp-review-ready",
2734
+ content: expect.stringContaining("Review cycle is in apply_feedback step."),
2735
+ }),
2736
+ { deliverAs: "steer" },
2737
+ );
2738
+ });
2739
+
2740
+ it("resume prunes stale repos and sends warning notification", async () => {
2741
+ const cwd = makeTempDir();
2742
+ mkdirSync(join(cwd, ".git"), { recursive: true });
2743
+ const staleRepo = join(cwd, "gone");
2744
+ const { orchestrator } = await setupOrchestrator(cwd);
2745
+ const ctx = makeCtx({ cwd });
2746
+
2747
+ await orchestrator.startTask(ctx as any, "implement", "resume stale notify");
2748
+ const taskDir = orchestrator.active!.dir;
2749
+ orchestrator.active!.state.repos = [
2750
+ { path: cwd, isRoot: true },
2751
+ { path: staleRepo, isRoot: false },
2752
+ ];
2753
+ saveTask(taskDir, orchestrator.active!.state);
2754
+ await orchestrator.cleanupActive();
2755
+
2756
+ const result = await resumeTask(orchestrator, ctx, { dir: taskDir, state: loadTask(taskDir), type: "implement" });
2757
+
2758
+ expect(result.ok).toBe(true);
2759
+ expect(ctx.ui.notify).toHaveBeenCalledWith("Pruned 1 stale repo(s) that no longer exist.", "warning");
2760
+ });
2761
+
2762
+ it("resume with missing planner preset falls back to first available preset and warns", async () => {
2763
+ const cwd = makeTempDir();
2764
+ mkdirSync(join(cwd, ".git"), { recursive: true });
2765
+ const { orchestrator } = await setupOrchestrator(cwd);
2766
+ const ctx = makeCtx({ cwd });
2767
+
2768
+ await orchestrator.startTask(ctx as any, "implement", "resume missing preset");
2769
+ const taskDir = orchestrator.active!.dir;
2770
+ writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
2771
+ writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
2772
+ orchestrator.active!.state.phase = "plan";
2773
+ orchestrator.active!.state.step = "await_planners";
2774
+ orchestrator.active!.state.activePlannerPreset = "nonexistent";
2775
+ saveTask(taskDir, orchestrator.active!.state);
2776
+ await orchestrator.cleanupActive();
2777
+
2778
+ const result = await resumeTask(orchestrator, ctx, { dir: taskDir, state: loadTask(taskDir), type: "implement" });
2779
+
2780
+ expect(result.ok).toBe(true);
2781
+ expect(orchestrator.active!.state.activePlannerPreset).toBe("regular");
2782
+ expect(ctx.ui.notify).toHaveBeenCalledWith(
2783
+ 'Planner preset "nonexistent" not found. Falling back to "regular".',
2784
+ "warning",
2785
+ );
2786
+ });
2787
+ });
2788
+
2789
+ describe("brainstorm and plan review cycles", () => {
2790
+ it("brainstorm review cycle reaches apply_feedback and finalizeReviewCycle returns to user_gate", async () => {
2791
+ const cwd = makeTempDir();
2792
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
2793
+ const ctx = makeCtx({ cwd });
2794
+
2795
+ await orchestrator.startTask(ctx as any, "implement", "brainstorm review cycle");
2796
+ const taskDir = orchestrator.active!.dir;
2797
+ writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
2798
+ writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
2799
+ mkdirSync(join(taskDir, "brainstorm-reviews"), { recursive: true });
2800
+ writeFileSync(
2801
+ join(taskDir, "brainstorm-reviews", `${Math.floor(Date.now() / 1000)}_test_round-1.md`),
2802
+ "Brainstorm review feedback",
2803
+ "utf-8",
2804
+ );
2805
+
2806
+ const message = await enterReviewCycle(orchestrator, ctx, "regular");
2807
+ await new Promise((r) => setTimeout(r, 10));
2808
+
2809
+ expect(message).toContain("Started review cycle pass 1");
2810
+ expect(orchestrator.active!.state.reviewCycle?.step).toBe("apply_feedback");
2811
+ expect(orchestrator.active!.state.step).toBe("apply_feedback");
2812
+ expect(pi.sendMessage).toHaveBeenCalledWith(
2813
+ expect.objectContaining({
2814
+ customType: "pp-review-ready",
2815
+ content: expect.stringContaining("Brainstorm review feedback"),
2816
+ }),
2817
+ { deliverAs: "followUp" },
2818
+ );
2819
+
2820
+ finalizeReviewCycle(orchestrator.active!);
2821
+
2822
+ expect(orchestrator.active!.state.step).toBe("user_gate");
2823
+ expect(orchestrator.active!.state.reviewCycle).toBeNull();
2824
+ expect(orchestrator.active!.state.reviewPass).toBe(1);
2825
+ });
2826
+
2827
+ it("brainstorm review-ready message names the artifacts under review, not a code diff", async () => {
2828
+ const cwd = makeTempDir();
2829
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
2830
+ const ctx = makeCtx({ cwd });
2831
+
2832
+ await orchestrator.startTask(ctx as any, "implement", "brainstorm review message");
2833
+ const taskDir = orchestrator.active!.dir;
2834
+ writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
2835
+ writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
2836
+ mkdirSync(join(taskDir, "brainstorm-reviews"), { recursive: true });
2837
+ writeFileSync(
2838
+ join(taskDir, "brainstorm-reviews", `${Math.floor(Date.now() / 1000)}_test_round-1.md`),
2839
+ "Brainstorm review feedback",
2840
+ "utf-8",
2841
+ );
2842
+
2843
+ await enterReviewCycle(orchestrator, ctx, "regular");
2844
+ await new Promise((r) => setTimeout(r, 10));
2845
+
2846
+ const readyMessages = (pi.sendUserMessage as any).mock.calls
2847
+ .map((c: any[]) => c[0] as string)
2848
+ .filter((text: string) => text.includes("ready for apply_feedback"));
2849
+ expect(readyMessages.length).toBeGreaterThan(0);
2850
+ expect(readyMessages[readyMessages.length - 1]).toContain("USER_REQUEST.md");
2851
+ expect(readyMessages[readyMessages.length - 1]).toContain("RESEARCH.md");
2852
+ expect(readyMessages[readyMessages.length - 1]).toContain("artifacts/");
2853
+ });
2854
+
2855
+ it("plan review cycle reaches apply_feedback and finalizeReviewCycle returns to user_gate", async () => {
2856
+ const cwd = makeTempDir();
2857
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
2858
+ const ctx = makeCtx({ cwd });
2859
+
2860
+ await orchestrator.startTask(ctx as any, "implement", "plan review cycle");
2861
+ const taskDir = orchestrator.active!.dir;
2862
+ writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
2863
+ writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
2864
+ mkdirSync(join(taskDir, "plans"), { recursive: true });
2865
+ writeFileSync(
2866
+ join(taskDir, "plans", `${Math.floor(Date.now() / 1000)}_synthesized.md`),
2867
+ makeValidPlan(["- [x] P1. Plan ready — Done when: synthesized plan exists"]),
2868
+ "utf-8",
2869
+ );
2870
+ mkdirSync(join(taskDir, "plan-reviews"), { recursive: true });
2871
+ writeFileSync(
2872
+ join(taskDir, "plan-reviews", `${Math.floor(Date.now() / 1000)}_test_round-1.md`),
2873
+ "Plan review feedback",
2874
+ "utf-8",
2875
+ );
2876
+ orchestrator.active!.state.phase = "plan";
2877
+ orchestrator.active!.state.step = "synthesize";
2878
+ saveTask(taskDir, orchestrator.active!.state);
2879
+
2880
+ const message = await enterReviewCycle(orchestrator, ctx, "regular");
2881
+ await new Promise((r) => setTimeout(r, 10));
2882
+
2883
+ expect(message).toContain("Started review cycle pass 1");
2884
+ expect(orchestrator.active!.state.reviewCycle?.step).toBe("apply_feedback");
2885
+ expect(orchestrator.active!.state.step).toBe("apply_feedback");
2886
+ expect(pi.sendMessage).toHaveBeenCalledWith(
2887
+ expect.objectContaining({
2888
+ customType: "pp-review-ready",
2889
+ content: expect.stringContaining("Plan review feedback"),
2890
+ }),
2891
+ { deliverAs: "followUp" },
2892
+ );
2893
+
2894
+ finalizeReviewCycle(orchestrator.active!);
2895
+
2896
+ expect(orchestrator.active!.state.step).toBe("user_gate");
2897
+ expect(orchestrator.active!.state.reviewCycle).toBeNull();
2898
+ expect(orchestrator.active!.state.reviewPass).toBe(1);
2899
+ });
2900
+
2901
+ it("brainstorm review with no artifacts spawns nothing and clears the cycle", async () => {
2902
+ const cwd = makeTempDir();
2903
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
2904
+ const ctx = makeCtx({ cwd });
2905
+
2906
+ await orchestrator.startTask(ctx as any, "implement", "review no artifacts");
2907
+ const taskDir = orchestrator.active!.dir;
2908
+ orchestrator.active!.state.phase = "brainstorm";
2909
+ orchestrator.active!.state.step = "llm_work";
2910
+ saveTask(taskDir, orchestrator.active!.state);
2911
+
2912
+ const message = await enterReviewCycle(orchestrator, ctx, "regular");
2913
+ await new Promise((r) => setTimeout(r, 10));
2914
+
2915
+ expect(message).toContain("Started review cycle pass 1");
2916
+ expect(orchestrator.active!.state.reviewCycle).toBeNull();
2917
+ expect(orchestrator.active!.state.step).toBe("llm_work");
2918
+ expect(pi.sendMessage).not.toHaveBeenCalledWith(
2919
+ expect.objectContaining({ customType: "pp-review-ready" }),
2920
+ { deliverAs: "followUp" },
2921
+ );
2922
+ });
2923
+ });
2924
+
2925
+ describe("artifact validation enforcement", () => {
2926
+ it("writing invalid USER_REQUEST.md appends validation-error", async () => {
2927
+ const cwd = makeTempDir();
2928
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
2929
+
2930
+ await orchestrator.startTask(makeCtx() as any, "implement", "validate ur");
2931
+ writeFileSync(join(orchestrator.active!.dir, "USER_REQUEST.md"), "# Wrong\n\n## Nope\n", "utf-8");
2932
+
2933
+ const toolResult = pi._handlers.get("tool_result")!;
2934
+ const result = await toolResult({
2935
+ toolName: "write",
2936
+ input: { path: join(orchestrator.active!.dir, "USER_REQUEST.md") },
2937
+ isError: false,
2938
+ content: [{ type: "text", text: "written" }],
2939
+ }, {});
2940
+
2941
+ const last = result.content[result.content.length - 1];
2942
+ expect(last.text).toContain("<validation-error>");
2943
+ expect(last.text).toContain("USER_REQUEST.md structure is invalid");
2944
+ });
2945
+
2946
+ it("writing invalid RESEARCH.md appends validation-error", async () => {
2947
+ const cwd = makeTempDir();
2948
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
2949
+
2950
+ await orchestrator.startTask(makeCtx() as any, "implement", "validate research");
2951
+ writeFileSync(join(orchestrator.active!.dir, "RESEARCH.md"), "## Affected Code\n\n", "utf-8");
2952
+
2953
+ const toolResult = pi._handlers.get("tool_result")!;
2954
+ const result = await toolResult({
2955
+ toolName: "write",
2956
+ input: { path: join(orchestrator.active!.dir, "RESEARCH.md") },
2957
+ isError: false,
2958
+ content: [{ type: "text", text: "written" }],
2959
+ }, {});
2960
+
2961
+ const last = result.content[result.content.length - 1];
2962
+ expect(last.text).toContain("<validation-error>");
2963
+ expect(last.text).toContain("RESEARCH.md structure is invalid");
2964
+ });
2965
+
2966
+ it("writing valid USER_REQUEST.md does not append validation error", async () => {
2967
+ const cwd = makeTempDir();
2968
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
2969
+
2970
+ await orchestrator.startTask(makeCtx() as any, "implement", "validate ur valid");
2971
+ writeFileSync(join(orchestrator.active!.dir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
2972
+
2973
+ const toolResult = pi._handlers.get("tool_result")!;
2974
+ const result = await toolResult({
2975
+ toolName: "write",
2976
+ input: { path: join(orchestrator.active!.dir, "USER_REQUEST.md") },
2977
+ isError: false,
2978
+ content: [{ type: "text", text: "written" }],
2979
+ }, {});
2980
+
2981
+ expect(result).toBeUndefined();
2982
+ });
2983
+
2984
+ it("writing invalid artifact markdown appends validation-error", async () => {
2985
+ const cwd = makeTempDir();
2986
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
2987
+
2988
+ await orchestrator.startTask(makeCtx() as any, "implement", "validate artifact");
2989
+ const artifactDir = join(orchestrator.active!.dir, "artifacts");
2990
+ mkdirSync(artifactDir, { recursive: true });
2991
+ const artifactPath = join(artifactDir, "note.md");
2992
+ writeFileSync(artifactPath, "## Heading only\n", "utf-8");
2993
+
2994
+ const toolResult = pi._handlers.get("tool_result")!;
2995
+ const result = await toolResult({
2996
+ toolName: "write",
2997
+ input: { path: artifactPath },
2998
+ isError: false,
2999
+ content: [{ type: "text", text: "written" }],
3000
+ }, {});
3001
+
3002
+ const last = result.content[result.content.length - 1];
3003
+ expect(last.text).toContain("<validation-error>");
3004
+ expect(last.text).toContain("Artifact structure is invalid");
3005
+ });
3006
+ });
3007
+
3008
+ describe("tool blocking", () => {
3009
+ it("blocks write to .pp/state.json", async () => {
3010
+ const cwd = makeTempDir();
3011
+ const { pi } = await setupOrchestrator(cwd);
3012
+ const toolCall = pi._handlers.get("tool_call")!;
3013
+
3014
+ const result = await toolCall({ toolName: "write", input: { path: ".pp/state.json" } }, {});
3015
+ expect(result).toEqual({ block: true, reason: "state.json is managed by the extension" });
3016
+ });
3017
+
3018
+ it("blocks write to .pp/config.json", async () => {
3019
+ const cwd = makeTempDir();
3020
+ const { pi } = await setupOrchestrator(cwd);
3021
+ const toolCall = pi._handlers.get("tool_call")!;
3022
+
3023
+ const result = await toolCall({ toolName: "edit", input: { path: ".pp/config.json" } }, {});
3024
+ expect(result).toEqual({ block: true, reason: "config.json is managed by the user, not the LLM" });
3025
+ });
3026
+
3027
+ it("allows markdown writes in .pp/state", async () => {
3028
+ const cwd = makeTempDir();
3029
+ const { pi } = await setupOrchestrator(cwd);
3030
+ const toolCall = pi._handlers.get("tool_call")!;
3031
+
3032
+ const result = await toolCall({ toolName: "write", input: { path: ".pp/state/implement/123/notes.md" } }, {});
3033
+ expect(result).toBeUndefined();
3034
+ });
3035
+
3036
+ it("blocks non-markdown writes in .pp/state", async () => {
3037
+ const cwd = makeTempDir();
3038
+ const { pi } = await setupOrchestrator(cwd);
3039
+ const toolCall = pi._handlers.get("tool_call")!;
3040
+
3041
+ const result = await toolCall({ toolName: "write", input: { path: ".pp/state/implement/123/data.json" } }, {});
3042
+ expect(result).toEqual({ block: true, reason: "Cannot write non-.md files in .pp/state/" });
3043
+ });
3044
+ });
3045
+
3046
+ describe("error retry", () => {
3047
+ it("turn_end with error retries with exponential backoff", async () => {
3048
+ vi.useFakeTimers();
3049
+ const cwd = makeTempDir();
3050
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
3051
+ const ctx = makeCtx();
3052
+
3053
+ await orchestrator.startTask(ctx as any, "implement", "retry test");
3054
+ const turnEnd = pi._handlers.get("turn_end")!;
3055
+
3056
+ await turnEnd({ message: { stopReason: "error", errorMessage: "rate limited", content: [] } }, ctx);
3057
+
3058
+ expect(orchestrator.errorRetryCount).toBe(1);
3059
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("Retrying in 2s"), "warning");
3060
+
3061
+ await vi.advanceTimersByTimeAsync(2000);
3062
+ expect(pi.sendUserMessage).toHaveBeenCalledWith(expect.stringContaining("Previous request failed"), { deliverAs: "followUp" });
3063
+ vi.useRealTimers();
3064
+ });
3065
+
3066
+ it("turn_end stops retrying after max retries", async () => {
3067
+ vi.useFakeTimers();
3068
+ const cwd = makeTempDir();
3069
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
3070
+ const ctx = makeCtx();
3071
+
3072
+ await orchestrator.startTask(ctx as any, "implement", "retry max test");
3073
+ const turnEnd = pi._handlers.get("turn_end")!;
3074
+
3075
+ for (let i = 0; i < 6; i++) {
3076
+ await turnEnd({ message: { stopReason: "error", errorMessage: "api down", content: [] } }, ctx);
3077
+ }
3078
+
3079
+ expect(orchestrator.errorRetryCount).toBe(0);
3080
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("Stopping auto-retry"), "error");
3081
+ vi.useRealTimers();
3082
+ });
3083
+
3084
+ it("successful turn resets error count", async () => {
3085
+ const cwd = makeTempDir();
3086
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
3087
+ const ctx = makeCtx();
3088
+
3089
+ await orchestrator.startTask(ctx as any, "implement", "retry reset test");
3090
+ const turnEnd = pi._handlers.get("turn_end")!;
3091
+
3092
+ await turnEnd({ message: { stopReason: "error", errorMessage: "once", content: [] } }, ctx);
3093
+ expect(orchestrator.errorRetryCount).toBe(1);
3094
+
3095
+ await turnEnd({ message: { stopReason: "stop", content: [] } }, ctx);
3096
+ expect(orchestrator.errorRetryCount).toBe(0);
3097
+ });
3098
+
3099
+ it("empty turn triggers continuation nudge", async () => {
3100
+ const cwd = makeTempDir();
3101
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
3102
+ const ctx = makeCtx();
3103
+
3104
+ await orchestrator.startTask(ctx as any, "implement", "nudge test");
3105
+ orchestrator.active!.state.phase = "implement";
3106
+ orchestrator.active!.state.step = "llm_work";
3107
+ const turnEnd = pi._handlers.get("turn_end")!;
3108
+
3109
+ await turnEnd({ message: { stopReason: "stop", content: [] }, toolResults: [] }, ctx);
3110
+
3111
+ expect(orchestrator.consecutiveNudges).toBe(1);
3112
+ expect(pi.sendUserMessage).toHaveBeenCalledWith(expect.stringContaining("Continue the implement phase"), { deliverAs: "followUp" });
3113
+ });
3114
+
3115
+ it("suppresses nudges while the controller is transitioning (not running)", async () => {
3116
+ const cwd = makeTempDir();
3117
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
3118
+ const ctx = makeCtx();
3119
+
3120
+ await orchestrator.startTask(ctx as any, "implement", "nudge suppress test");
3121
+ orchestrator.active!.state.phase = "implement";
3122
+ orchestrator.active!.state.step = "llm_work";
3123
+ // Put the controller mid-transition (not running) with a non-idle ctx.
3124
+ orchestrator.lastCtx = makeCtx({ isIdle: vi.fn().mockReturnValue(false) });
3125
+ void orchestrator.transitionController.requestTransition({ kind: "phase", summary: "x" });
3126
+ expect(orchestrator.transitionController.isRunning()).toBe(false);
3127
+
3128
+ const before = (pi.sendUserMessage as any).mock.calls.length;
3129
+ const turnEnd = pi._handlers.get("turn_end")!;
3130
+ await turnEnd({ message: { stopReason: "stop", content: [] }, toolResults: [] }, ctx);
3131
+
3132
+ const nudged = (pi.sendUserMessage as any).mock.calls.slice(before).some((c: any[]) =>
3133
+ String(c[0]).includes("Continue the implement phase"),
3134
+ );
3135
+ expect(nudged).toBe(false);
3136
+ expect(orchestrator.consecutiveNudges).toBe(0);
3137
+ });
3138
+
3139
+ it("nudge halts after repeated interruptions", async () => {
3140
+ vi.useFakeTimers();
3141
+ const cwd = makeTempDir();
3142
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
3143
+ const ctx = makeCtx();
3144
+
3145
+ await orchestrator.startTask(ctx as any, "implement", "nudge halt");
3146
+ orchestrator.active!.state.phase = "implement";
3147
+ orchestrator.active!.state.step = "llm_work";
3148
+ const turnEnd = pi._handlers.get("turn_end")!;
3149
+
3150
+ for (let i = 0; i < 25; i += 1) {
3151
+ await turnEnd({ message: { stopReason: "stop", content: [] }, toolResults: [] }, ctx);
3152
+ }
3153
+
3154
+ expect(orchestrator.nudgeHalted).toBe(true);
3155
+ expect(pi.sendMessage).toHaveBeenCalledWith(
3156
+ expect.objectContaining({ customType: "pp-continuation-halted" }),
3157
+ { deliverAs: "steer" },
3158
+ );
3159
+ vi.useRealTimers();
3160
+ });
3161
+
3162
+ it("halt still fires when nudge-induced before_agent_start interleaves (production sequence)", async () => {
3163
+ const cwd = makeTempDir();
3164
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
3165
+ const ctx = makeCtx();
3166
+
3167
+ await orchestrator.startTask(ctx as any, "implement", "nudge halt prod", undefined, undefined, "autonomous");
3168
+ orchestrator.active!.state.phase = "implement";
3169
+ orchestrator.active!.state.step = "llm_work";
3170
+ const turnEnd = pi._handlers.get("turn_end")!;
3171
+ const beforeStart = pi._handlers.get("before_agent_start")!;
3172
+
3173
+ // Real production loop: each nudge restarts the agent with a [PI-PI] prompt,
3174
+ // firing before_agent_start. That controller-injected start must NOT reset
3175
+ // the consecutive-nudge guard, so the halt eventually fires.
3176
+ for (let i = 0; i < 10; i += 1) {
3177
+ await turnEnd({ message: { stopReason: "stop", content: [] }, toolResults: [] }, ctx);
3178
+ await beforeStart({ systemPrompt: "base", prompt: "[PI-PI] Continue the implement phase." }, ctx);
3179
+ }
3180
+
3181
+ expect(orchestrator.nudgeHalted).toBe(true);
3182
+ expect(pi.sendMessage).toHaveBeenCalledWith(
3183
+ expect.objectContaining({ customType: "pp-continuation-halted" }),
3184
+ { deliverAs: "steer" },
3185
+ );
3186
+ });
3187
+
3188
+ it("a genuine (non-[PI-PI]) user before_agent_start clears the nudge halt", async () => {
3189
+ const cwd = makeTempDir();
3190
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
3191
+ const ctx = makeCtx();
3192
+
3193
+ await orchestrator.startTask(ctx as any, "implement", "nudge clear", undefined, undefined, "autonomous");
3194
+ orchestrator.active!.state.phase = "implement";
3195
+ orchestrator.active!.state.step = "llm_work";
3196
+ orchestrator.nudgeHalted = true;
3197
+ orchestrator.consecutiveNudges = 6;
3198
+
3199
+ const beforeStart = pi._handlers.get("before_agent_start")!;
3200
+ await beforeStart({ systemPrompt: "base", prompt: "please continue, I have a new idea" }, ctx);
3201
+
3202
+ expect(orchestrator.nudgeHalted).toBe(false);
3203
+ expect(orchestrator.consecutiveNudges).toBe(0);
3204
+ });
3205
+
3206
+ it("repeated text-only stops re-nudge instead of latching once", async () => {
3207
+ const cwd = makeTempDir();
3208
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
3209
+ const ctx = makeCtx();
3210
+
3211
+ await orchestrator.startTask(ctx as any, "implement", "text stop test", undefined, undefined, "autonomous");
3212
+ orchestrator.active!.state.phase = "implement";
3213
+ orchestrator.active!.state.step = "llm_work";
3214
+ const turnEnd = pi._handlers.get("turn_end")!;
3215
+
3216
+ const textTurn = { message: { stopReason: "stop", content: [{ type: "text", text: "thinking out loud" }] }, toolResults: [] };
3217
+ for (let i = 0; i < 3; i += 1) {
3218
+ await turnEnd(textTurn, ctx);
3219
+ }
3220
+
3221
+ const reminderCalls = (pi.sendUserMessage as any).mock.calls.filter((c: any[]) =>
3222
+ String(c[0]).includes("Continue the implement phase"),
3223
+ );
3224
+ expect(reminderCalls.length).toBe(3);
3225
+ expect(reminderCalls[0][0]).toContain("Do NOT apologize");
3226
+ expect(orchestrator.nudgeHalted).toBe(false);
3227
+ });
3228
+
3229
+ it("repeated text-only stops eventually halt via the single consecutive guard", async () => {
3230
+ const cwd = makeTempDir();
3231
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
3232
+ const ctx = makeCtx();
3233
+
3234
+ await orchestrator.startTask(ctx as any, "implement", "text stop halt test", undefined, undefined, "autonomous");
3235
+ orchestrator.active!.state.phase = "implement";
3236
+ orchestrator.active!.state.step = "llm_work";
3237
+ const turnEnd = pi._handlers.get("turn_end")!;
3238
+
3239
+ const textTurn = { message: { stopReason: "stop", content: [{ type: "text", text: "still talking" }] }, toolResults: [] };
3240
+ for (let i = 0; i < 30; i += 1) {
3241
+ await turnEnd(textTurn, ctx);
3242
+ }
3243
+
3244
+ // The collapsed guard treats text-only stops like any other genuine stop:
3245
+ // after the consecutive cap it halts exactly once.
3246
+ expect(orchestrator.nudgeHalted).toBe(true);
3247
+ expect(pi.sendMessage).toHaveBeenCalledWith(
3248
+ expect.objectContaining({ customType: "pp-continuation-halted" }),
3249
+ { deliverAs: "steer" },
3250
+ );
3251
+ });
3252
+
3253
+ it("a tool-call turn resets the consecutive-nudge guard", async () => {
3254
+ const cwd = makeTempDir();
3255
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
3256
+ const ctx = makeCtx();
3257
+
3258
+ await orchestrator.startTask(ctx as any, "implement", "text stop reset test", undefined, undefined, "autonomous");
3259
+ orchestrator.active!.state.phase = "implement";
3260
+ orchestrator.active!.state.step = "llm_work";
3261
+ const turnEnd = pi._handlers.get("turn_end")!;
3262
+
3263
+ await turnEnd({ message: { stopReason: "stop", content: [{ type: "text", text: "a" }] }, toolResults: [] }, ctx);
3264
+ expect(orchestrator.consecutiveNudges).toBe(1);
3265
+ // A turn ending on a tool call is forward progress -> guard resets.
3266
+ await turnEnd({ message: { stopReason: "stop", content: [{ type: "toolCall" }] }, toolResults: [] }, ctx);
3267
+ expect(orchestrator.consecutiveNudges).toBe(0);
3268
+ });
3269
+ });
3270
+
3271
+ describe("compaction", () => {
3272
+ it("compactAndTransition calls ctx.compact for phase transition", async () => {
3273
+ const cwd = makeTempDir();
3274
+ const { orchestrator } = await setupOrchestrator(cwd);
3275
+ const compactSpy = vi.fn((opts?: any) => {
3276
+ if (opts?.onComplete) opts.onComplete();
3277
+ });
3278
+ const ctx = makeCtx({ compact: compactSpy });
3279
+
3280
+ await orchestrator.startTask(ctx as any, "implement", "compaction");
3281
+ orchestrator.compactAndTransition(ctx as any, orchestrator.active!.dir, "plan");
3282
+
3283
+ expect(compactSpy).toHaveBeenCalledWith(expect.objectContaining({ customInstructions: expect.stringContaining("Phase transition") }));
3284
+ });
3285
+
3286
+ it("defers compaction until agent_end when not idle, then delivers Begin working", async () => {
3287
+ const cwd = makeTempDir();
3288
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
3289
+ const compactCbs: Array<{ onComplete?: () => void; onError?: (e: Error) => void }> = [];
3290
+ // Agent is mid-turn (tool in-flight): not idle, and compaction is deferred.
3291
+ const compactSpy = vi.fn((opts?: any) => { compactCbs.push(opts); });
3292
+ const ctx = makeCtx({ isIdle: vi.fn().mockReturnValue(false), compact: compactSpy });
3293
+
3294
+ await orchestrator.startTask(ctx as any, "implement", "deferred compaction");
3295
+ orchestrator.compactAndTransition(ctx as any, orchestrator.active!.dir, "implement");
3296
+
3297
+ // Not idle -> no compaction yet; controller is pending.
3298
+ expect(compactSpy).not.toHaveBeenCalled();
3299
+
3300
+ // Agent goes idle: agent_end fires compaction.
3301
+ await pi._handlers.get("agent_end")!({ type: "agent_end", messages: [] }, ctx);
3302
+ expect(compactSpy).toHaveBeenCalledTimes(1);
3303
+
3304
+ // Compaction throws the "too small" no-op: must still resume + deliver.
3305
+ compactCbs[0]!.onError!(new Error("Nothing to compact (session too small)"));
3306
+
3307
+ await vi.waitFor(() => {
3308
+ const sentBeginWorking = pi.sendUserMessage.mock.calls.some(
3309
+ (c: any[]) => c[0] === "[PI-PI] Entered implement phase. Begin working." && c[1]?.deliverAs === "followUp",
3310
+ );
3311
+ expect(sentBeginWorking).toBe(true);
3312
+ });
3313
+ expect(orchestrator.transitionController.getState()).toBe("running");
3314
+ });
3315
+
3316
+ it("session_before_compact returns the controller's transition summary", async () => {
3317
+ const cwd = makeTempDir();
3318
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
3319
+ await orchestrator.startTask(makeCtx() as any, "implement", "phase compact test");
3320
+ orchestrator.lastCtx = makeCtx({ isIdle: vi.fn().mockReturnValue(false) });
3321
+
3322
+ // Put the controller mid-compaction (not idle -> pending -> agent_end -> compacting).
3323
+ void orchestrator.transitionController.requestTransition({ kind: "phase", summary: "Phase summary text" });
3324
+ orchestrator.transitionController.onAgentEnd();
3325
+ expect(orchestrator.transitionController.isTransitioning()).toBe(true);
3326
+
3327
+ const beforeCompact = pi._handlers.get("session_before_compact")!;
3328
+ const result = await beforeCompact({ preparation: { firstKeptEntryId: "e1", tokensBefore: 123 } }, {});
3329
+
3330
+ expect(result.compaction.summary).toBe("Phase summary text");
3331
+ });
3332
+
3333
+ it("session_before_compact returns task done summary when transitioning", async () => {
3334
+ const cwd = makeTempDir();
3335
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
3336
+ orchestrator.lastCtx = makeCtx({ isIdle: vi.fn().mockReturnValue(false) });
3337
+
3338
+ void orchestrator.transitionController.requestTransition({ kind: "done", summary: "Task done summary" });
3339
+ orchestrator.transitionController.onAgentEnd();
3340
+ const beforeCompact = pi._handlers.get("session_before_compact")!;
3341
+ const result = await beforeCompact({ preparation: { firstKeptEntryId: "e2", tokensBefore: 456 } }, {});
3342
+
3343
+ expect(result.compaction.summary).toBe("Task done summary");
3344
+ });
3345
+
3346
+ it("session_before_compact re-injects artifacts after natural compaction", async () => {
3347
+ const cwd = makeTempDir();
3348
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
3349
+ const ctx = makeCtx();
3350
+
3351
+ await orchestrator.startTask(ctx as any, "implement", "reinject artifacts");
3352
+ writeFileSync(join(orchestrator.active!.dir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
3353
+ writeFileSync(join(orchestrator.active!.dir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
3354
+
3355
+ const beforeCompact = pi._handlers.get("session_before_compact")!;
3356
+ const result = await beforeCompact({ preparation: { firstKeptEntryId: "e3", tokensBefore: 789 } }, {});
3357
+
3358
+ expect(result).toBeUndefined();
3359
+ expect(pi.sendMessage).toHaveBeenCalledWith(
3360
+ expect.objectContaining({ customType: "pp-artifact-reinject", content: expect.stringContaining("USER_REQUEST.md") }),
3361
+ { deliverAs: "steer" },
3362
+ );
3363
+ });
3364
+ });
3365
+
3366
+ describe("input blocking during await", () => {
3367
+ it("blocks user input during await_planners", async () => {
3368
+ const cwd = makeTempDir();
3369
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
3370
+ const ctx = makeCtx();
3371
+
3372
+ await orchestrator.startTask(ctx as any, "implement", "await planners");
3373
+ orchestrator.active!.state.step = "await_planners";
3374
+ const inputHandler = pi._handlers.get("input")!;
3375
+
3376
+ const result = await inputHandler({ source: "interactive" }, ctx);
3377
+ expect(result).toEqual({ action: "handled" });
3378
+ });
3379
+
3380
+ it("blocks user input during await_reviewers", async () => {
3381
+ const cwd = makeTempDir();
3382
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
3383
+ const ctx = makeCtx();
3384
+
3385
+ await orchestrator.startTask(ctx as any, "implement", "await reviewers");
3386
+ orchestrator.active!.state.step = "await_reviewers";
3387
+ const inputHandler = pi._handlers.get("input")!;
3388
+
3389
+ const result = await inputHandler({ source: "interactive" }, ctx);
3390
+ expect(result).toEqual({ action: "handled" });
3391
+ });
3392
+ });
3393
+
3394
+ describe("context injection", () => {
3395
+ it("injectContextAndArtifacts sends context files as steer messages", async () => {
3396
+ const cwd = makeTempDir();
3397
+ mkdirSync(join(cwd, ".pp", "context"), { recursive: true });
3398
+ writeFileSync(join(cwd, ".pp", "context", "main.md"), "---\ninject: context\nagents: [main]\n---\nContext body", "utf-8");
3399
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
3400
+ const ctx = makeCtx({ cwd });
3401
+
3402
+ await orchestrator.startTask(ctx as any, "implement", "inject context");
3403
+ pi.sendMessage.mockClear();
3404
+ orchestrator.injectContextAndArtifacts(orchestrator.active!.dir, orchestrator.active!.state.phase);
3405
+
3406
+ expect(pi.sendMessage).toHaveBeenCalledWith(
3407
+ expect.objectContaining({ customType: "pp-context", content: expect.stringContaining("Context body") }),
3408
+ { deliverAs: "steer" },
3409
+ );
3410
+ });
3411
+
3412
+ it("injectContextAndArtifacts sends phase artifacts", async () => {
3413
+ const cwd = makeTempDir();
3414
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
3415
+ const ctx = makeCtx();
3416
+
3417
+ await orchestrator.startTask(ctx as any, "implement", "inject artifacts");
3418
+ writeFileSync(join(orchestrator.active!.dir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
3419
+ writeFileSync(join(orchestrator.active!.dir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
3420
+ pi.sendMessage.mockClear();
3421
+ orchestrator.injectContextAndArtifacts(orchestrator.active!.dir, orchestrator.active!.state.phase);
3422
+
3423
+ const artifactCalls = pi.sendMessage.mock.calls.filter((c: any[]) => c[0]?.customType === "pp-artifact");
3424
+ expect(artifactCalls.length).toBeGreaterThan(0);
3425
+ expect(artifactCalls.some((c: any[]) => String(c[0].content).includes("USER_REQUEST.md"))).toBe(true);
3426
+ });
3427
+
3428
+ it("registerAgents appends context to agent prompts", async () => {
3429
+ const cwd = makeTempDir();
3430
+ mkdirSync(join(cwd, ".pp", "context"), { recursive: true });
3431
+ writeFileSync(join(cwd, ".pp", "context", "explore-system.md"), "---\ninject: system\nagents: [explore]\n---\nExplore context", "utf-8");
3432
+ const { orchestrator } = await setupOrchestrator(cwd);
3433
+ const ctx = makeCtx({ cwd });
3434
+
3435
+ await orchestrator.startTask(ctx as any, "implement", "register agents");
3436
+ (registerAgentDefinitions as any).mockClear();
3437
+ orchestrator.registerAgents();
3438
+
3439
+ const defs = (registerAgentDefinitions as any).mock.calls[0][1] as Array<{ type: string; prompt: string }>;
3440
+ const explore = defs.find((d) => d.type === "explore");
3441
+ expect(explore?.prompt).toContain("Explore context");
3442
+ });
3443
+ });
3444
+
3445
+ describe("session lifecycle", () => {
3446
+ it("session_start registers tools and commands", async () => {
3447
+ const cwd = makeTempDir();
3448
+ const { pi } = await setupOrchestrator(cwd);
3449
+
3450
+ expect(pi.registerCommand).toHaveBeenCalledWith("pp", expect.anything());
3451
+ const toolNames = [...pi._tools.keys()];
3452
+ expect(toolNames).toContain("pp_phase_complete");
3453
+ expect(toolNames).toContain("pp_commit");
3454
+ expect(toolNames).toContain("pp_register_repo");
3455
+ });
3456
+
3457
+ it("session_start detects paused tasks and notifies", async () => {
3458
+ const cwd = makeTempDir();
3459
+ createTask(cwd, "implement", "Paused task");
3460
+ const pi = makePi();
3461
+ const orchestrator = new Orchestrator(pi as any);
3462
+ registerEventHandlers(orchestrator);
3463
+ registerCommandHandlers(orchestrator);
3464
+ const ctx = makeCtx({ cwd });
3465
+
3466
+ const sessionStart = pi._handlers.get("session_start")!;
3467
+ await sessionStart({}, ctx);
3468
+
3469
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("Paused task"), "info");
3470
+ });
3471
+
3472
+ it("session_start loads config", async () => {
3473
+ const cwd = makeTempDir();
3474
+ const { orchestrator } = await setupOrchestrator(cwd);
3475
+
3476
+ expect(orchestrator.config).toBeDefined();
3477
+ expect(orchestrator.config.agents.orchestrators.implement.model).toBe("test/model");
3478
+ });
3479
+
3480
+ it("session_shutdown dumps usage summary", async () => {
3481
+ const cwd = makeTempDir();
3482
+ const { pi } = await setupOrchestrator(cwd);
3483
+ const dumpSpy = vi.spyOn(usageTrackerModule, "dumpUsageSummary").mockImplementation(() => undefined);
3484
+ const shutdown = pi._handlers.get("session_shutdown")!;
3485
+
3486
+ await shutdown({}, { sessionManager: { getSessionId: () => "session-id-1" } });
3487
+
3488
+ expect(dumpSpy).toHaveBeenCalledTimes(1);
3489
+ expect(dumpSpy).toHaveBeenCalledWith(expect.anything(), "session-id-1");
3490
+ });
3491
+ });
3492
+
3493
+ describe("menu contracts", () => {
3494
+ it("settings menu without active task has new top-level sections", async () => {
3495
+ const cwd = makeTempDir();
3496
+ const { pi } = await setupOrchestrator(cwd);
3497
+ const ctx = makeCtx();
3498
+
3499
+ menu
3500
+ .expect({ question: "/pp", options: { include: ["Settings"] }, choose: "Settings" })
3501
+ .expect({ question: "Settings", options: { exact: ["General", "Agents", "Commands", "Performance", "LSP", "Back"] }, choose: "Back" })
3502
+ .expect({ question: "/pp", options: { include: ["Back"] }, choose: "Back" });
3503
+
3504
+ const pp = getCommand(pi, "pp");
3505
+ await pp(undefined, ctx);
3506
+ });
3507
+
3508
+ it("info menu shows Doctor and hides LSP", async () => {
3509
+ const cwd = makeTempDir();
3510
+ const { pi } = await setupOrchestrator(cwd);
3511
+ const ctx = makeCtx();
3512
+
3513
+ menu
3514
+ .expect({ question: "/pp", options: { include: ["Info"] }, choose: "Info" })
3515
+ .expect({
3516
+ question: "Info",
3517
+ options: {
3518
+ include: ["Subagents", "Usage", "Doctor", "Back"],
3519
+ exclude: ["LSP"],
3520
+ },
3521
+ choose: "Back",
3522
+ })
3523
+ .expect({ question: "/pp", options: { include: ["Back"] }, choose: "Back" });
3524
+
3525
+ const pp = getCommand(pi, "pp");
3526
+ await pp(undefined, ctx);
3527
+ });
3528
+
3529
+ it("info doctor option calls runDoctor", async () => {
3530
+ const cwd = makeTempDir();
3531
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
3532
+ const ctx = makeCtx();
3533
+ const runDoctorSpy = vi.mocked(doctorModule.runDoctor);
3534
+
3535
+ menu
3536
+ .expect({ question: "/pp", options: { include: ["Info"] }, choose: "Info" })
3537
+ .expect({ question: "Info", options: { include: ["Doctor", "Back"] }, choose: "Doctor" })
3538
+ .expect({ question: "Info", options: { include: ["Back"] }, choose: "Back" })
3539
+ .expect({ question: "/pp", options: { include: ["Back"] }, choose: "Back" });
3540
+
3541
+ const pp = getCommand(pi, "pp");
3542
+ await pp(undefined, ctx);
3543
+
3544
+ expect(runDoctorSpy).toHaveBeenCalledTimes(1);
3545
+ expect(runDoctorSpy).toHaveBeenCalledWith(orchestrator, ctx);
3546
+ });
3547
+
3548
+ it("settings lsp menu restarts servers", async () => {
3549
+ const cwd = makeTempDir();
3550
+ const { pi } = await setupOrchestrator(cwd);
3551
+ const ctx = makeCtx();
3552
+ const restart = vi.fn(async () => undefined);
3553
+ (globalThis as any)[Symbol.for("pi-lsp:api")] = { restart };
3554
+
3555
+ menu
3556
+ .expect({ question: "/pp", options: { include: ["Settings"] }, choose: "Settings" })
3557
+ .expect({ question: "Settings", options: { include: ["LSP"] }, choose: "LSP" })
3558
+ .expect({ question: "LSP", options: { exact: ["Restart all servers", "Back"] }, choose: "Restart all servers" })
3559
+ .expect({ question: "LSP", options: { include: ["Back"] }, choose: "Back" })
3560
+ .expect({ question: "Settings", options: { include: ["Back"] }, choose: "Back" })
3561
+ .expect({ question: "/pp", options: { include: ["Back"] }, choose: "Back" });
3562
+
3563
+ const pp = getCommand(pi, "pp");
3564
+ await pp(undefined, ctx);
3565
+
3566
+ expect(restart).toHaveBeenCalledTimes(1);
3567
+ });
3568
+
3569
+ it("settings lsp restart warns when API is missing", async () => {
3570
+ const cwd = makeTempDir();
3571
+ const { pi } = await setupOrchestrator(cwd);
3572
+ const ctx = makeCtx();
3573
+ delete (globalThis as any)[Symbol.for("pi-lsp:api")];
3574
+
3575
+ menu
3576
+ .expect({ question: "/pp", options: { include: ["Settings"] }, choose: "Settings" })
3577
+ .expect({ question: "Settings", options: { include: ["LSP"] }, choose: "LSP" })
3578
+ .expect({ question: "LSP", options: { exact: ["Restart all servers", "Back"] }, choose: "Restart all servers" })
3579
+ .expect({ question: "LSP", options: { include: ["Back"] }, choose: "Back" })
3580
+ .expect({ question: "Settings", options: { include: ["Back"] }, choose: "Back" })
3581
+ .expect({ question: "/pp", options: { include: ["Back"] }, choose: "Back" });
3582
+
3583
+ const pp = getCommand(pi, "pp");
3584
+ await pp(undefined, ctx);
3585
+
3586
+ expect(ctx.ui.notify).toHaveBeenCalledWith("LSP API is not available.", "warning");
3587
+ });
3588
+
3589
+ it("settings agents submenu has orchestrators and subagents", async () => {
3590
+ const cwd = makeTempDir();
3591
+ const { pi } = await setupOrchestrator(cwd);
3592
+ const ctx = makeCtx();
3593
+
3594
+ menu
3595
+ .expect({ question: "/pp", options: { include: ["Settings"] }, choose: "Settings" })
3596
+ .expect({ question: "Settings", options: { include: ["Agents", "Back"] }, choose: "Agents" })
3597
+ .expect({ question: "Agents", options: { exact: ["Orchestrators", "Subagents", "Back"] }, choose: "Back" })
3598
+ .expect({ question: "Settings", options: { include: ["Back"] }, choose: "Back" })
3599
+ .expect({ question: "/pp", options: { include: ["Back"] }, choose: "Back" });
3600
+
3601
+ const pp = getCommand(pi, "pp");
3602
+ await pp(undefined, ctx);
3603
+ });
3604
+
3605
+ it("active task menu in guided implement phase has exact options", 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", "contract");
3611
+
3612
+ menu.expect({
3613
+ question: m.taskMenu("implement", "brainstorm"),
3614
+ options: {
3615
+ exact: ["Next", "Review", "Info", "Settings", "Back"],
3616
+ },
3617
+ choose: "Back",
3618
+ });
3619
+
3620
+ const pp = getCommand(pi, "pp");
3621
+ await pp(undefined, ctx);
3622
+ });
3623
+
3624
+ it("active task menu hides Review while waiting for planners", async () => {
3625
+ const cwd = makeTempDir();
3626
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
3627
+ const ctx = makeCtx();
3628
+
3629
+ await orchestrator.startTask(ctx as any, "implement", "contract waiting");
3630
+ orchestrator.active!.state.phase = "plan";
3631
+ orchestrator.active!.state.step = "await_planners";
3632
+ saveTask(orchestrator.active!.dir, orchestrator.active!.state);
3633
+
3634
+ menu.expect({
3635
+ question: m.taskMenu("implement", "plan"),
3636
+ options: {
3637
+ exact: ["Next", "Info", "Settings", "Back"],
3638
+ },
3639
+ choose: "Back",
3640
+ });
3641
+
3642
+ const pp = getCommand(pi, "pp");
3643
+ await pp(undefined, ctx);
3644
+ });
3645
+
3646
+ it("active task menu in autonomous mode has exact options", async () => {
3647
+ const cwd = makeTempDir();
3648
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
3649
+ const ctx = makeCtx();
3650
+
3651
+ await orchestrator.startTask(ctx as any, "implement", "contract auto", undefined, undefined, "autonomous");
3652
+
3653
+ // brainstorm is a forced-interactive phase, so even an autonomous task
3654
+ // shows the full interactive menu (Next/Review).
3655
+ menu.expect({
3656
+ question: m.taskMenu("implement", "brainstorm"),
3657
+ options: {
3658
+ exact: ["Next", "Review", "Info", "Settings", "Back"],
3659
+ },
3660
+ choose: "Back",
3661
+ });
3662
+
3663
+ const pp = getCommand(pi, "pp");
3664
+ await pp(undefined, ctx);
3665
+ });
3666
+
3667
+ it("autonomous debug task shows interactive menu in debug phase", async () => {
3668
+ const cwd = makeTempDir();
3669
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
3670
+ const ctx = makeCtx();
3671
+
3672
+ await orchestrator.startTask(ctx as any, "debug", "contract debug auto", undefined, undefined, "autonomous");
3673
+
3674
+ menu.expect({
3675
+ question: m.taskMenu("debug", "debug"),
3676
+ options: {
3677
+ exact: ["Next", "Review", "Info", "Settings", "Back"],
3678
+ },
3679
+ choose: "Back",
3680
+ });
3681
+
3682
+ const pp = getCommand(pi, "pp");
3683
+ await pp(undefined, ctx);
3684
+ });
3685
+
3686
+ it("autonomous review task shows interactive menu in review phase", async () => {
3687
+ const cwd = makeTempDir();
3688
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
3689
+ const ctx = makeCtx();
3690
+
3691
+ await orchestrator.startTask(ctx as any, "review", "contract review auto", undefined, undefined, "autonomous");
3692
+
3693
+ menu.expect({
3694
+ question: m.taskMenu("review", "review"),
3695
+ options: {
3696
+ exact: ["Next", "Review", "Info", "Settings", "Back"],
3697
+ },
3698
+ choose: "Back",
3699
+ });
3700
+
3701
+ const pp = getCommand(pi, "pp");
3702
+ await pp(undefined, ctx);
3703
+ });
3704
+
3705
+ it("autonomous menu 'Complete task' completes the task", async () => {
3706
+ const cwd = makeTempDir();
3707
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
3708
+ const ctx = makeCtx();
3709
+
3710
+ await orchestrator.startTask(ctx as any, "implement", "contract complete", undefined, undefined, "autonomous");
3711
+ const taskDir = orchestrator.active!.dir;
3712
+
3713
+ expectActiveTaskNext(menu, "Complete");
3714
+
3715
+ const pp = getCommand(pi, "pp");
3716
+ await pp(undefined, ctx);
3717
+
3718
+ expect(orchestrator.active).toBeNull();
3719
+ expect(loadTask(taskDir).phase).toBe("done");
3720
+ });
3721
+
3722
+ it("autonomous menu 'Pause task' pauses without completing", async () => {
3723
+ const cwd = makeTempDir();
3724
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
3725
+ const ctx = makeCtx();
3726
+
3727
+ await orchestrator.startTask(ctx as any, "implement", "contract pause", undefined, undefined, "autonomous");
3728
+ const taskDir = orchestrator.active!.dir;
3729
+
3730
+ expectActiveTaskNext(menu, "Pause");
3731
+
3732
+ const pp = getCommand(pi, "pp");
3733
+ await pp(undefined, ctx);
3734
+
3735
+ expect(orchestrator.active).toBeNull();
3736
+ expect(loadTask(taskDir).phase).not.toBe("done");
3737
+ });
3738
+
3739
+ it("completing a task mid-review clears reviewCycle without incrementing reviewPass", async () => {
3740
+ const cwd = makeTempDir();
3741
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
3742
+ const ctx = makeCtx();
3743
+
3744
+ await orchestrator.startTask(ctx as any, "implement", "complete mid review", undefined, undefined, "autonomous");
3745
+ const taskDir = orchestrator.active!.dir;
3746
+ orchestrator.active!.state.reviewCycle = { kind: "auto", step: "apply_feedback", pass: 1 };
3747
+ orchestrator.active!.state.step = "apply_feedback";
3748
+ orchestrator.active!.state.reviewPass = 0;
3749
+ orchestrator.active!.state.reviewPassByKind = {};
3750
+ saveTask(taskDir, orchestrator.active!.state);
3751
+
3752
+ expectActiveTaskNext(menu, "Complete");
3753
+
3754
+ const pp = getCommand(pi, "pp");
3755
+ await pp(undefined, ctx);
3756
+
3757
+ const finalState = loadTask(taskDir);
3758
+ expect(finalState.reviewCycle).toBeNull();
3759
+ expect(finalState.reviewPass).toBe(0);
3760
+ expect(finalState.reviewPassByKind?.implement?.auto ?? 0).toBe(0);
3761
+ });
3762
+
3763
+ it("pausing a task mid-review clears reviewCycle without incrementing reviewPass", async () => {
3764
+ const cwd = makeTempDir();
3765
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
3766
+ const ctx = makeCtx();
3767
+
3768
+ await orchestrator.startTask(ctx as any, "implement", "pause mid review", undefined, undefined, "autonomous");
3769
+ const taskDir = orchestrator.active!.dir;
3770
+ orchestrator.active!.state.reviewCycle = { kind: "auto", step: "apply_feedback", pass: 1 };
3771
+ orchestrator.active!.state.step = "apply_feedback";
3772
+ orchestrator.active!.state.reviewPass = 0;
3773
+ orchestrator.active!.state.reviewPassByKind = {};
3774
+ saveTask(taskDir, orchestrator.active!.state);
3775
+
3776
+ expectActiveTaskNext(menu, "Pause");
3777
+
3778
+ const pp = getCommand(pi, "pp");
3779
+ await pp(undefined, ctx);
3780
+
3781
+ const finalState = loadTask(taskDir);
3782
+ expect(finalState.reviewCycle).toBeNull();
3783
+ expect(finalState.reviewPass).toBe(0);
3784
+ expect(finalState.reviewPassByKind?.implement?.auto ?? 0).toBe(0);
3785
+ });
3786
+
3787
+ it("quick task menu has exact options", async () => {
3788
+ const cwd = makeTempDir();
3789
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
3790
+ const ctx = makeCtx();
3791
+
3792
+ await orchestrator.startTask(ctx as any, "quick", "contract quick");
3793
+
3794
+ menu.expect({
3795
+ question: m.taskMenu("quick", "quick"),
3796
+ options: {
3797
+ exact: ["Complete", "Pause", "Info", "Settings", "Back"],
3798
+ },
3799
+ choose: "Back",
3800
+ });
3801
+
3802
+ const pp = getCommand(pi, "pp");
3803
+ await pp(undefined, ctx);
3804
+ });
3805
+
3806
+ it("Next submenu options change when continue is unavailable", async () => {
3807
+ const cwd = makeTempDir();
3808
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
3809
+ const ctx = makeCtx();
3810
+
3811
+ await orchestrator.startTask(ctx as any, "implement", "contract next");
3812
+ menu
3813
+ .expect({ question: m.taskMenu("implement", "brainstorm"), options: { include: ["Next"] }, choose: "Next" })
3814
+ .expect({ question: "Next", options: { exact: ["Continue to plan & implement", "Complete", "Pause", "Back"] }, choose: "Back" })
3815
+ .expect({ question: m.taskMenu("implement", "brainstorm"), options: { include: ["Back"] }, choose: "Back" });
3816
+
3817
+ const pp = getCommand(pi, "pp");
3818
+ await pp(undefined, ctx);
3819
+
3820
+ orchestrator.active!.state.phase = "implement";
3821
+ orchestrator.active!.state.step = "llm_work";
3822
+ saveTask(orchestrator.active!.dir, orchestrator.active!.state);
3823
+
3824
+ menu
3825
+ .expect({ question: m.taskMenu("implement", "implement"), options: { include: ["Next"] }, choose: "Next" })
3826
+ .expect({ question: "Next", options: { exact: ["Complete", "Pause", "Back"] }, choose: "Back" })
3827
+ .expect({ question: m.taskMenu("implement", "implement"), options: { include: ["Back"] }, choose: "Back" });
3828
+
3829
+ await pp(undefined, ctx);
3830
+ });
3831
+
3832
+ it("Review submenu has exact options", async () => {
3833
+ const cwd = makeTempDir();
3834
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
3835
+ const ctx = makeCtx();
3836
+
3837
+ await orchestrator.startTask(ctx as any, "implement", "contract review");
3838
+
3839
+ menu
3840
+ .expect({ question: m.taskMenu("implement", "brainstorm"), options: { include: ["Review"] }, choose: "Review" })
3841
+ .expect({ question: "Review", options: { exact: [m.autoReview, "Review on my own", "Back"] }, choose: "Back" })
3842
+ .expect({ question: m.taskMenu("implement", "brainstorm"), options: { include: ["Back"] }, choose: "Back" });
3843
+
3844
+ const pp = getCommand(pi, "pp");
3845
+ await pp(undefined, ctx);
3846
+ });
3847
+
3848
+ it("mode picker options are exact", async () => {
3849
+ const cwd = makeTempDir();
3850
+ const { pi } = await setupOrchestrator(cwd);
3851
+ const ctx = makeCtx();
3852
+
3853
+ menu
3854
+ .expect({ question: "/pp", options: { include: ["Task"] }, choose: "Task" })
3855
+ .expect({ question: "Task", options: { include: ["Implement"] }, choose: "Implement" })
3856
+ .expect({ question: "Implement", options: { include: ["New"] }, choose: "New" })
3857
+ .expect({ question: "Mode", options: { exact: ["Guided", "Autonomous", "Back"] }, choose: "Back" })
3858
+ .expect({ question: "Implement", options: { include: ["Back"] }, choose: "Back" })
3859
+ .expect({ question: "Task", options: { include: ["Back"] }, choose: "Back" })
3860
+ .expect({ question: "/pp", options: { include: ["Back"] }, choose: "Back" });
3861
+
3862
+ const pp = getCommand(pi, "pp");
3863
+ await pp(undefined, ctx);
3864
+ });
3865
+ });
3866
+
3867
+ describe("full user flows", () => {
3868
+ it("guided implement happy path", async () => {
3869
+ const cwd = makeTempDir();
3870
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
3871
+ const ctx = makeCtx();
3872
+
3873
+ await orchestrator.startTask(ctx as any, "implement", "guided flow", undefined, undefined, "guided");
3874
+ const taskDir = orchestrator.active!.dir;
3875
+ writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
3876
+ writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
3877
+
3878
+ expectBrainstormToPlan(menu);
3879
+ const ppPhaseComplete = getTool(pi, "pp_phase_complete");
3880
+ await ppPhaseComplete.execute("flow-guided-1", { summary: "brainstorm done" }, undefined, undefined, ctx);
3881
+ await new Promise((r) => setTimeout(r, 10));
3882
+
3883
+ const plansDir = join(taskDir, "plans");
3884
+ mkdirSync(plansDir, { recursive: true });
3885
+ emitSubagentCreated(pi, "flow-guided-planner", "Planner (test)");
3886
+ writeFileSync(join(plansDir, `${Math.floor(Date.now() / 1000)}_test.md`), makeValidPlan(["- [ ] P1. Draft — Done when: draft exists"]), "utf-8");
3887
+ emitSubagentCompleted(pi, "flow-guided-planner", "Planner (test)");
3888
+ writeFileSync(join(plansDir, `${Math.floor(Date.now() / 1000) + 1}_synthesized.md`), makeValidPlan(["- [x] P1. Ready — Done when: checked"]), "utf-8");
3889
+
3890
+ expectPlanToImplement(menu);
3891
+ await ppPhaseComplete.execute("flow-guided-2", { summary: "plan done" }, undefined, undefined, ctx);
3892
+ await new Promise((r) => setTimeout(r, 10));
3893
+
3894
+ expectImplementToDone(menu);
3895
+ await ppPhaseComplete.execute("flow-guided-3", { summary: "implement done" }, undefined, undefined, ctx);
3896
+
3897
+ expect(orchestrator.active).toBeNull();
3898
+ expect(loadTask(taskDir).phase).toBe("done");
3899
+ });
3900
+
3901
+ it("autonomous implement happy path", async () => {
3902
+ const cwd = makeTempDir();
3903
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
3904
+ const ctx = makeCtx();
3905
+
3906
+ await orchestrator.startTask(ctx as any, "implement", "autonomous flow", undefined, undefined, "autonomous");
3907
+ const taskDir = orchestrator.active!.dir;
3908
+ orchestrator.active!.state.autonomousConfig = {
3909
+ phases: {
3910
+ brainstorm: { reviewPreset: "regular", maxReviewPasses: 0 },
3911
+ plan: { plannerPreset: "regular", reviewPreset: "regular", maxReviewPasses: 0 },
3912
+ implement: { reviewPreset: "regular", maxReviewPasses: 1 },
3913
+ },
3914
+ };
3915
+ saveTask(taskDir, orchestrator.active!.state);
3916
+
3917
+ writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
3918
+ writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
3919
+
3920
+ const ppPhaseComplete = getTool(pi, "pp_phase_complete");
3921
+ await ppPhaseComplete.execute("flow-auto-1", { summary: "brainstorm done" }, undefined, undefined, ctx);
3922
+ await new Promise((r) => setTimeout(r, 10));
3923
+
3924
+ const plansDir = join(taskDir, "plans");
3925
+ mkdirSync(plansDir, { recursive: true });
3926
+ emitSubagentCreated(pi, "flow-auto-planner", "Planner (test)");
3927
+ writeFileSync(join(plansDir, `${Math.floor(Date.now() / 1000)}_test.md`), makeValidPlan(["- [ ] P1. Draft — Done when: draft exists"]), "utf-8");
3928
+ emitSubagentCompleted(pi, "flow-auto-planner", "Planner (test)");
3929
+ writeFileSync(join(plansDir, `${Math.floor(Date.now() / 1000) + 1}_synthesized.md`), makeValidPlan(["- [x] P1. Ready — Done when: checked"]), "utf-8");
3930
+
3931
+ await ppPhaseComplete.execute("flow-auto-2", { summary: "plan done" }, undefined, undefined, ctx);
3932
+ await new Promise((r) => setTimeout(r, 10));
3933
+
3934
+ const firstReview = await ppPhaseComplete.execute("flow-auto-3", { summary: "implement done" }, undefined, undefined, ctx);
3935
+ expect(firstReview.content[0].text).toMatch(/Reviews are running|Started review cycle pass/);
3936
+
3937
+ const reviewsDir = join(taskDir, "code-reviews");
3938
+ mkdirSync(reviewsDir, { recursive: true });
3939
+ writeFileSync(join(reviewsDir, `${Math.floor(Date.now() / 1000)}_test_round-1.md`), "LGTM", "utf-8");
3940
+ emitSubagentCreated(pi, "flow-auto-reviewer", "Code reviewer (test)");
3941
+ emitSubagentCompleted(pi, "flow-auto-reviewer", "Code reviewer (test)");
3942
+
3943
+ await ppPhaseComplete.execute("flow-auto-4", { summary: "feedback applied" }, undefined, undefined, ctx);
3944
+
3945
+ expect(orchestrator.active).toBeNull();
3946
+ expect(loadTask(taskDir).phase).toBe("done");
3947
+ });
3948
+
3949
+ it("quick task happy path", async () => {
3950
+ const cwd = makeTempDir();
3951
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
3952
+ const ctx = makeCtx();
3953
+
3954
+ await orchestrator.startTask(ctx as any, "quick", "quick flow");
3955
+ const taskDir = orchestrator.active!.dir;
3956
+
3957
+ expectQuickMenu(menu, "Complete");
3958
+ const ppPhaseComplete = getTool(pi, "pp_phase_complete");
3959
+ await ppPhaseComplete.execute("flow-quick-1", { summary: "quick done" }, undefined, undefined, ctx);
3960
+
3961
+ expect(orchestrator.active).toBeNull();
3962
+ expect(loadTask(taskDir).phase).toBe("done");
3963
+ });
3964
+ });