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