@ilya-lesikov/pi-pi 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/extensions/orchestrator/agents/code-reviewer.ts +21 -5
  2. package/extensions/orchestrator/agents/constraints.test.ts +39 -1
  3. package/extensions/orchestrator/agents/constraints.ts +63 -2
  4. package/extensions/orchestrator/agents/tool-routing.ts +30 -14
  5. package/extensions/orchestrator/ai-comment-cleanup.test.ts +89 -0
  6. package/extensions/orchestrator/ai-comment-cleanup.ts +73 -0
  7. package/extensions/orchestrator/command-handlers.test.ts +47 -0
  8. package/extensions/orchestrator/command-handlers.ts +43 -27
  9. package/extensions/orchestrator/config.test.ts +40 -1
  10. package/extensions/orchestrator/config.ts +13 -0
  11. package/extensions/orchestrator/context.ts +16 -0
  12. package/extensions/orchestrator/custom-footer.test.ts +91 -0
  13. package/extensions/orchestrator/custom-footer.ts +20 -20
  14. package/extensions/orchestrator/event-handlers.test.ts +315 -1
  15. package/extensions/orchestrator/event-handlers.ts +397 -201
  16. package/extensions/orchestrator/integration.test.ts +305 -9
  17. package/extensions/orchestrator/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  18. package/extensions/orchestrator/orchestrator.ts +46 -58
  19. package/extensions/orchestrator/phases/brainstorm.ts +11 -7
  20. package/extensions/orchestrator/phases/machine.test.ts +36 -0
  21. package/extensions/orchestrator/phases/machine.ts +11 -1
  22. package/extensions/orchestrator/phases/review-task.test.ts +20 -0
  23. package/extensions/orchestrator/phases/review-task.ts +44 -16
  24. package/extensions/orchestrator/phases/review.test.ts +26 -0
  25. package/extensions/orchestrator/phases/review.ts +40 -3
  26. package/extensions/orchestrator/pp-menu.test.ts +207 -1
  27. package/extensions/orchestrator/pp-menu.ts +376 -378
  28. package/extensions/orchestrator/state.test.ts +9 -0
  29. package/extensions/orchestrator/state.ts +15 -0
  30. package/extensions/orchestrator/transition-controller.test.ts +100 -0
  31. package/extensions/orchestrator/transition-controller.ts +61 -3
  32. package/package.json +1 -1
  33. package/AGENTS.md +0 -28
@@ -43,6 +43,7 @@ export interface AfterImplementCommandConfig {
43
43
  export interface PiPiConfig {
44
44
  general: {
45
45
  autoCommit: boolean;
46
+ injectAgentsMd: boolean;
46
47
  loadExtraRepoConfigs: boolean;
47
48
  logLevel: LogLevel;
48
49
  tracing: boolean;
@@ -65,6 +66,7 @@ export interface PiPiConfig {
65
66
  };
66
67
  internals: {
67
68
  subagentStale: DurationValue;
69
+ mainTurnStale: DurationValue;
68
70
  taskLockStale: DurationValue;
69
71
  taskLockRefresh: DurationValue;
70
72
  };
@@ -79,6 +81,7 @@ export interface NormalizedPiPiConfig extends PiPiConfig {
79
81
  };
80
82
  internals: {
81
83
  subagentStale: number;
84
+ mainTurnStale: number;
82
85
  taskLockStale: number;
83
86
  taskLockRefresh: number;
84
87
  };
@@ -97,6 +100,7 @@ const SIMPLE_SUBAGENT_ROLES: SimpleSubagentRole[] = ["explore", "librarian", "ta
97
100
  const DEFAULT_CONFIG: PiPiConfig = {
98
101
  general: {
99
102
  autoCommit: true,
103
+ injectAgentsMd: true,
100
104
  loadExtraRepoConfigs: true,
101
105
  logLevel: "info",
102
106
  tracing: false,
@@ -203,6 +207,7 @@ const DEFAULT_CONFIG: PiPiConfig = {
203
207
  },
204
208
  internals: {
205
209
  subagentStale: "5m",
210
+ mainTurnStale: "10m",
206
211
  taskLockStale: "1m",
207
212
  taskLockRefresh: "30s",
208
213
  },
@@ -347,6 +352,7 @@ export function validateConfig(config: Record<string, any>): void {
347
352
  if (config.general !== undefined) {
348
353
  const general = requireObject(config.general, "config.general");
349
354
  ensureBool(general.autoCommit, "config.general.autoCommit");
355
+ ensureBool(general.injectAgentsMd, "config.general.injectAgentsMd");
350
356
  ensureBool(general.loadExtraRepoConfigs, "config.general.loadExtraRepoConfigs");
351
357
  ensureBool(general.tracing, "config.general.tracing");
352
358
  if (general.logLevel !== undefined && !isValidLogLevel(general.logLevel)) {
@@ -417,6 +423,7 @@ export function validateConfig(config: Record<string, any>): void {
417
423
  if (performance.internals !== undefined) {
418
424
  const internals = requireObject(performance.internals, "config.performance.internals");
419
425
  ensureDuration(internals.subagentStale, "config.performance.internals.subagentStale");
426
+ ensureDuration(internals.mainTurnStale, "config.performance.internals.mainTurnStale");
420
427
  ensureDuration(internals.taskLockStale, "config.performance.internals.taskLockStale");
421
428
  ensureDuration(internals.taskLockRefresh, "config.performance.internals.taskLockRefresh");
422
429
  }
@@ -526,6 +533,9 @@ export function validateMergedConfig(config: Record<string, any>): void {
526
533
  if (parseDuration(typed.performance.internals.subagentStale) === null) {
527
534
  throw new Error("config.performance.internals.subagentStale must be a valid duration");
528
535
  }
536
+ if (parseDuration(typed.performance.internals.mainTurnStale) === null) {
537
+ throw new Error("config.performance.internals.mainTurnStale must be a valid duration");
538
+ }
529
539
  if (parseDuration(typed.performance.internals.taskLockStale) === null) {
530
540
  throw new Error("config.performance.internals.taskLockStale must be a valid duration");
531
541
  }
@@ -540,6 +550,7 @@ export function normalizeConfigDurations(config: PiPiConfig): NormalizedPiPiConf
540
550
  const afterEdit = parseDuration(next.performance.commands.afterEdit);
541
551
  const afterImplement = parseDuration(next.performance.commands.afterImplement);
542
552
  const subagentStale = parseDuration(next.performance.internals.subagentStale);
553
+ const mainTurnStale = parseDuration(next.performance.internals.mainTurnStale);
543
554
  const taskLockStale = parseDuration(next.performance.internals.taskLockStale);
544
555
  const taskLockRefresh = parseDuration(next.performance.internals.taskLockRefresh);
545
556
 
@@ -547,6 +558,7 @@ export function normalizeConfigDurations(config: PiPiConfig): NormalizedPiPiConf
547
558
  afterEdit === null ||
548
559
  afterImplement === null ||
549
560
  subagentStale === null ||
561
+ mainTurnStale === null ||
550
562
  taskLockStale === null ||
551
563
  taskLockRefresh === null
552
564
  ) {
@@ -556,6 +568,7 @@ export function normalizeConfigDurations(config: PiPiConfig): NormalizedPiPiConf
556
568
  next.performance.commands.afterEdit = afterEdit;
557
569
  next.performance.commands.afterImplement = afterImplement;
558
570
  next.performance.internals.subagentStale = subagentStale;
571
+ next.performance.internals.mainTurnStale = mainTurnStale;
559
572
  next.performance.internals.taskLockStale = taskLockStale;
560
573
  next.performance.internals.taskLockRefresh = taskLockRefresh;
561
574
  return next;
@@ -279,6 +279,22 @@ export function getLatestSynthesizedPlan(taskDir: string): string | null {
279
279
  return readFileSync(join(plansDir, synthFiles[synthFiles.length - 1]), "utf-8");
280
280
  }
281
281
 
282
+ // True when the newest `code-reviews/*_final_pass-*.md` exists and carries an
283
+ // `ANCHORS:` block. Shared by publishGuard (Publish menu) and the review-phase
284
+ // completion gates so a review cannot finish without the deliverable Publish
285
+ // consumes. A zero-findings `ANCHORS: (none)` still satisfies this (the line exists).
286
+ export function hasFinalPassAnchors(taskDir: string): boolean {
287
+ const reviewsDir = join(taskDir, "code-reviews");
288
+ if (!existsSync(reviewsDir)) return false;
289
+ const finalPassFiles = readdirSync(reviewsDir)
290
+ .filter((f) => f.endsWith(".md") && f.includes("_final_pass-"))
291
+ .sort();
292
+ if (finalPassFiles.length === 0) return false;
293
+ const latest = finalPassFiles[finalPassFiles.length - 1];
294
+ const content = readFileSync(join(reviewsDir, latest), "utf8");
295
+ return /^ANCHORS:/m.test(content);
296
+ }
297
+
282
298
  function getLatestSynthesizedPlanPath(taskDir: string): string | null {
283
299
  const plansDir = join(taskDir, "plans");
284
300
  if (!existsSync(plansDir)) return null;
@@ -0,0 +1,91 @@
1
+ import { describe, expect, it, beforeEach } from "vitest";
2
+ import { createCustomFooter, setFooterContext, setFooterTracker, setFooterOrchestrator } from "./custom-footer.js";
3
+
4
+ const theme = { fg: (_color: string, text: string) => text } as any;
5
+ const footerData = { getGitBranch: () => "main" } as any;
6
+
7
+ function render(width = 200): string[] {
8
+ const footer = createCustomFooter({} as any, theme, footerData);
9
+ return footer.render(width);
10
+ }
11
+
12
+ function makeCtx(usage?: { tokens: number | null; contextWindow: number; percent: number | null }): any {
13
+ return {
14
+ cwd: "/tmp/project",
15
+ model: { id: "test-model", provider: "test" },
16
+ sessionManager: { getSessionName: () => undefined, getEntries: () => [] },
17
+ getContextUsage: () => usage,
18
+ };
19
+ }
20
+
21
+ describe("createCustomFooter", () => {
22
+ beforeEach(() => {
23
+ setFooterTracker(undefined as any);
24
+ setFooterOrchestrator(undefined as any);
25
+ });
26
+
27
+ it("renders exactly two lines (no status/LSP line)", () => {
28
+ setFooterContext(makeCtx());
29
+ const lines = render();
30
+ expect(lines).toHaveLength(2);
31
+ });
32
+
33
+ it("line 1 shows task/phase/mode when a task is active", () => {
34
+ setFooterContext(makeCtx());
35
+ setFooterOrchestrator({
36
+ active: { type: "implement", state: { phase: "plan", mode: "autonomous" } },
37
+ } as any);
38
+ const [line1] = render();
39
+ expect(line1).toContain("task: implement");
40
+ expect(line1).toContain("phase: plan");
41
+ expect(line1).toContain("mode: autonomous");
42
+ });
43
+
44
+ it("line 1 shows autonomous mode in a read-only phase for an autonomous task", () => {
45
+ setFooterContext(makeCtx());
46
+ setFooterOrchestrator({
47
+ active: { type: "implement", state: { phase: "brainstorm", mode: "autonomous" } },
48
+ } as any);
49
+ const [line1] = render();
50
+ expect(line1).toContain("phase: brainstorm");
51
+ expect(line1).toContain("mode: autonomous");
52
+ });
53
+
54
+ it("line 1 shows guided mode for a guided task", () => {
55
+ setFooterContext(makeCtx());
56
+ setFooterOrchestrator({
57
+ active: { type: "implement", state: { phase: "implement", mode: "guided" } },
58
+ } as any);
59
+ const [line1] = render();
60
+ expect(line1).toContain("mode: guided");
61
+ });
62
+
63
+ it("line 1 omits the mode segment for a quick task", () => {
64
+ setFooterContext(makeCtx());
65
+ setFooterOrchestrator({
66
+ active: { type: "quick", state: { phase: "quick", mode: "autonomous" } },
67
+ } as any);
68
+ const [line1] = render();
69
+ expect(line1).toContain("task: quick");
70
+ expect(line1).not.toContain("mode:");
71
+ });
72
+
73
+ it("line 1 omits task metadata when no task is active", () => {
74
+ setFooterContext(makeCtx());
75
+ const [line1] = render();
76
+ expect(line1).not.toContain("task:");
77
+ expect(line1).toContain("main");
78
+ });
79
+
80
+ it("context indicator shows percent/used/max", () => {
81
+ setFooterContext(makeCtx({ tokens: 38000, contextWindow: 1000000, percent: 3.8 }));
82
+ const [, line2] = render();
83
+ expect(line2).toContain("3.8%/38k/1.0M (auto)");
84
+ });
85
+
86
+ it("context indicator degrades gracefully when tokens are null", () => {
87
+ setFooterContext(makeCtx({ tokens: null, contextWindow: 1000000, percent: null }));
88
+ const [, line2] = render();
89
+ expect(line2).toContain("?%/?/1.0M (auto)");
90
+ });
91
+ });
@@ -2,9 +2,12 @@ import { homedir } from "node:os";
2
2
  import type { ExtensionContext, ReadonlyFooterDataProvider, Theme } from "@earendil-works/pi-coding-agent";
3
3
  import { truncateToWidth, visibleWidth, type Component, type TUI } from "@earendil-works/pi-tui";
4
4
  import type { UsageTracker } from "./usage-tracker.js";
5
+ import type { Orchestrator } from "./orchestrator.js";
6
+ import { formatModeIndicator } from "./state.js";
5
7
 
6
8
  let footerCtx: ExtensionContext | undefined;
7
9
  let footerTracker: UsageTracker | undefined;
10
+ let footerOrchestrator: Orchestrator | undefined;
8
11
 
9
12
  export function setFooterContext(ctx: ExtensionContext): void {
10
13
  footerCtx = ctx;
@@ -14,6 +17,10 @@ export function setFooterTracker(tracker: UsageTracker): void {
14
17
  footerTracker = tracker;
15
18
  }
16
19
 
20
+ export function setFooterOrchestrator(orchestrator: Orchestrator): void {
21
+ footerOrchestrator = orchestrator;
22
+ }
23
+
17
24
  function formatTokens(count: number): string {
18
25
  if (count < 1000) return count.toString();
19
26
  if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
@@ -22,10 +29,6 @@ function formatTokens(count: number): string {
22
29
  return `${Math.round(count / 1000000)}M`;
23
30
  }
24
31
 
25
- function sanitizeStatusText(text: string): string {
26
- return text.replace(/[\r\n\t]/g, " ").replace(/ +/g, " ").trim();
27
- }
28
-
29
32
  function formatPath(cwd: string): string {
30
33
  const home = homedir();
31
34
  return cwd.startsWith(home) ? `~${cwd.slice(home.length)}` : cwd;
@@ -47,8 +50,10 @@ function toContextUsagePart(ctx: ExtensionContext | undefined, theme: Theme): st
47
50
  const usage = ctx?.getContextUsage();
48
51
  const contextWindow = usage?.contextWindow ?? 0;
49
52
  const percentValue = usage?.percent ?? null;
53
+ const tokensValue = usage?.tokens ?? null;
50
54
  const percentText = percentValue === null ? "?" : percentValue.toFixed(1);
51
- const display = `${percentText}%/${formatTokens(contextWindow)} (auto)`;
55
+ const tokensText = tokensValue === null ? "?" : formatTokens(tokensValue);
56
+ const display = `${percentText}%/${tokensText}/${formatTokens(contextWindow)} (auto)`;
52
57
  if (percentValue !== null && percentValue > 90) return theme.fg("error", display);
53
58
  if (percentValue !== null && percentValue > 70) return theme.fg("warning", display);
54
59
  return display;
@@ -115,23 +120,19 @@ function renderPathLine(width: number, theme: Theme, footerData: ReadonlyFooterD
115
120
  const ctx = footerCtx;
116
121
  const path = formatPath(ctx?.cwd ?? process.cwd());
117
122
  const branch = footerData.getGitBranch();
118
- const sessionName = ctx?.sessionManager.getSessionName();
119
123
 
120
124
  let line = path;
121
125
  if (branch) line += ` (${branch})`;
122
- if (sessionName) line += ` • ${sessionName}`;
123
-
124
- return truncateToWidth(theme.fg("dim", line), width, theme.fg("dim", "..."));
125
- }
126
-
127
- function renderStatusLine(width: number, theme: Theme, footerData: ReadonlyFooterDataProvider): string | undefined {
128
- const statuses = footerData.getExtensionStatuses();
129
- if (statuses.size === 0) return undefined;
130
126
 
131
- const line = Array.from(statuses.entries())
132
- .sort(([a], [b]) => a.localeCompare(b))
133
- .map(([, text]) => sanitizeStatusText(text))
134
- .join(" ");
127
+ const task = footerOrchestrator?.active;
128
+ if (task && task.state.phase !== "done") {
129
+ line += ` • task: ${task.type} phase: ${task.state.phase}`;
130
+ const mode = formatModeIndicator(task.state, task.type);
131
+ if (mode) line += ` • mode: ${mode}`;
132
+ } else {
133
+ const sessionName = ctx?.sessionManager.getSessionName();
134
+ if (sessionName) line += ` • ${sessionName}`;
135
+ }
135
136
 
136
137
  return truncateToWidth(theme.fg("dim", line), width, theme.fg("dim", "..."));
137
138
  }
@@ -141,8 +142,7 @@ export function createCustomFooter(_tui: TUI, theme: Theme, footerData: Readonly
141
142
  render(width: number): string[] {
142
143
  const line1 = renderPathLine(width, theme, footerData);
143
144
  const line2 = renderStatsLine(width, theme);
144
- const line3 = renderStatusLine(width, theme, footerData) ?? theme.fg("dim", "");
145
- return [line1, line2, line3];
145
+ return [line1, line2];
146
146
  },
147
147
  invalidate(): void {},
148
148
  dispose(): void {},
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
2
2
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs";
3
3
  import { join } from "path";
4
4
  import { tmpdir } from "os";
5
- import { registerEventHandlers } from "./event-handlers.js";
5
+ import { registerEventHandlers, checkoutPrHead, isReviewCycleLive, finalizeReviewCycle } from "./event-handlers.js";
6
6
  import { Orchestrator, type ActiveTask } from "./orchestrator.js";
7
7
  import { getDefaultConfig } from "./config.js";
8
8
 
@@ -22,6 +22,7 @@ function makePi() {
22
22
  emit: vi.fn(),
23
23
  },
24
24
  getAllTools: vi.fn().mockReturnValue([{ name: "lsp" }]),
25
+ registerTool: vi.fn(),
25
26
  sendMessage: vi.fn(),
26
27
  sendUserMessage: vi.fn(),
27
28
  setModel: vi.fn(),
@@ -144,6 +145,55 @@ describe("tool_call write protection", () => {
144
145
  });
145
146
  });
146
147
 
148
+ describe("review-phase AI_COMMENT write guard", () => {
149
+ function reviewTask(): ActiveTask {
150
+ const task = makeActiveTask();
151
+ task.state.phase = "review";
152
+ return task;
153
+ }
154
+
155
+ it("allows an edit that only inserts an AI_COMMENT marker", async () => {
156
+ orchestrator.active = reviewTask();
157
+ const handler = getHandler("tool_call");
158
+ const result = await handler(
159
+ { toolName: "edit", input: { path: "src/a.ts", edits: [{ oldText: "const x = 1;", newText: "const x = 1;\n// AI_COMMENT: check" }] } },
160
+ {},
161
+ );
162
+ expect(result).toBeUndefined();
163
+ });
164
+
165
+ it("blocks an edit that changes real code during review", async () => {
166
+ orchestrator.active = reviewTask();
167
+ const handler = getHandler("tool_call");
168
+ const result = await handler(
169
+ { toolName: "edit", input: { path: "src/a.ts", edits: [{ oldText: "const x = 1;", newText: "const x = 2;" }] } },
170
+ {},
171
+ );
172
+ expect(result?.block).toBe(true);
173
+ expect(result?.reason).toContain("AI_COMMENT");
174
+ });
175
+
176
+ it("blocks a brand-new source file write during review", async () => {
177
+ orchestrator.active = reviewTask();
178
+ const handler = getHandler("tool_call");
179
+ const result = await handler(
180
+ { toolName: "write", input: { path: "src/new.ts", content: "// AI_COMMENT: note\n" } },
181
+ {},
182
+ );
183
+ expect(result?.block).toBe(true);
184
+ });
185
+
186
+ it("does not restrict source edits outside the review phase", async () => {
187
+ orchestrator.active = makeActiveTask(); // implement phase
188
+ const handler = getHandler("tool_call");
189
+ const result = await handler(
190
+ { toolName: "edit", input: { path: "src/a.ts", edits: [{ oldText: "const x = 1;", newText: "const x = 2;" }] } },
191
+ {},
192
+ );
193
+ expect(result).toBeUndefined();
194
+ });
195
+ });
196
+
147
197
  describe("tool_result implementation tracking", () => {
148
198
  it("adds file to modifiedFiles when input uses path field", async () => {
149
199
  orchestrator.active = makeActiveTask();
@@ -347,3 +397,267 @@ describe("tool_call Agent routing and spawn-time context injection", () => {
347
397
  expect(input.prompt).toBe("find X");
348
398
  });
349
399
  });
400
+
401
+ describe("main-turn stall watchdog (BUG-2)", () => {
402
+ beforeEach(() => {
403
+ vi.useFakeTimers();
404
+ });
405
+ afterEach(() => {
406
+ vi.useRealTimers();
407
+ });
408
+
409
+ it("recovers a wedged main turn via the idle-gated single-send path", async () => {
410
+ orchestrator.active = makeActiveTask();
411
+ orchestrator.config.performance.internals.mainTurnStale = 60000;
412
+ const sendSpy = vi.spyOn(orchestrator, "sendUserMessageWhenIdle").mockImplementation(() => {});
413
+ orchestrator.lastCtx = { isIdle: () => true } as any;
414
+
415
+ // A turn starts and then never terminates.
416
+ await getHandler("turn_start")({ type: "turn_start", turnIndex: 0 }, {});
417
+ expect(orchestrator.mainTurnInFlight).toBe(true);
418
+
419
+ // Before the threshold: no recovery.
420
+ vi.advanceTimersByTime(30000);
421
+ expect(sendSpy).not.toHaveBeenCalled();
422
+
423
+ // Past the threshold with no activity: watchdog fires once.
424
+ vi.advanceTimersByTime(61000);
425
+ expect(sendSpy).toHaveBeenCalledTimes(1);
426
+ expect(sendSpy.mock.calls[0][0]).toContain("stalled without completing");
427
+ expect(orchestrator.mainTurnRecovering).toBe(true);
428
+ });
429
+
430
+ it("does not fire while the turn keeps making activity", async () => {
431
+ orchestrator.active = makeActiveTask();
432
+ orchestrator.config.performance.internals.mainTurnStale = 60000;
433
+ const sendSpy = vi.spyOn(orchestrator, "sendUserMessageWhenIdle").mockImplementation(() => {});
434
+
435
+ await getHandler("turn_start")({ type: "turn_start", turnIndex: 0 }, {});
436
+ for (let i = 0; i < 5; i++) {
437
+ vi.advanceTimersByTime(30000);
438
+ await getHandler("tool_call")({ toolName: "read", input: { path: "a.ts" } }, {});
439
+ }
440
+ expect(sendSpy).not.toHaveBeenCalled();
441
+ });
442
+
443
+ it("does not fire after the turn terminates", async () => {
444
+ orchestrator.active = makeActiveTask();
445
+ orchestrator.config.performance.internals.mainTurnStale = 60000;
446
+ const sendSpy = vi.spyOn(orchestrator, "sendUserMessageWhenIdle").mockImplementation(() => {});
447
+
448
+ const ctx = { ui: { setStatus: vi.fn(), setWorkingMessage: vi.fn(), notify: vi.fn() }, isIdle: () => true } as any;
449
+ await getHandler("turn_start")({ type: "turn_start", turnIndex: 0 }, ctx);
450
+ await getHandler("turn_end")({ type: "turn_end", turnIndex: 0, message: {}, toolResults: [] }, ctx);
451
+ expect(orchestrator.mainTurnInFlight).toBe(false);
452
+ vi.advanceTimersByTime(120000);
453
+ expect(sendSpy).not.toHaveBeenCalled();
454
+ });
455
+
456
+ it("does not fire while an interactive dialogue is open (#11)", async () => {
457
+ orchestrator.active = makeActiveTask();
458
+ orchestrator.config.performance.internals.mainTurnStale = 60000;
459
+ const sendSpy = vi.spyOn(orchestrator, "sendUserMessageWhenIdle").mockImplementation(() => {});
460
+ orchestrator.lastCtx = { isIdle: () => true } as any;
461
+
462
+ await getHandler("turn_start")({ type: "turn_start", turnIndex: 0 }, {});
463
+ orchestrator.interactivePromptOpen = true;
464
+ // Well past the stale threshold: the user is parked on a prompt, not wedged.
465
+ vi.advanceTimersByTime(600000);
466
+ expect(sendSpy).not.toHaveBeenCalled();
467
+
468
+ // Once the dialogue closes, a genuinely stalled turn recovers again.
469
+ orchestrator.interactivePromptOpen = false;
470
+ vi.advanceTimersByTime(61000);
471
+ expect(sendSpy).toHaveBeenCalledTimes(1);
472
+ });
473
+
474
+ it("does not fire while the subscription fallback dialogue is open (#11)", async () => {
475
+ orchestrator.active = makeActiveTask();
476
+ orchestrator.config.performance.internals.mainTurnStale = 60000;
477
+ const sendSpy = vi.spyOn(orchestrator, "sendUserMessageWhenIdle").mockImplementation(() => {});
478
+ orchestrator.lastCtx = { isIdle: () => true } as any;
479
+
480
+ await getHandler("turn_start")({ type: "turn_start", turnIndex: 0 }, {});
481
+ orchestrator.subFallbackDialogPending = true;
482
+ vi.advanceTimersByTime(600000);
483
+ expect(sendSpy).not.toHaveBeenCalled();
484
+ orchestrator.subFallbackDialogPending = false;
485
+ });
486
+ });
487
+
488
+ describe("checkoutPrHead", () => {
489
+ type GitResult = { code: number; stdout?: string; stderr?: string };
490
+ // A key maps to a single result, or an array consumed in call order (last entry repeats).
491
+ function makeGitOrchestrator(script: Record<string, GitResult | GitResult[]>) {
492
+ const calls: string[][] = [];
493
+ const seen: Record<string, number> = {};
494
+ const exec = vi.fn(async (_cmd: string, args: string[]) => {
495
+ calls.push(args);
496
+ const key = args.join(" ");
497
+ const matched = Object.keys(script).find((k) => key.startsWith(k));
498
+ const entry = matched ? script[matched] : { code: 0, stdout: "", stderr: "" };
499
+ let res: GitResult;
500
+ if (Array.isArray(entry)) {
501
+ const i = Math.min(seen[matched!] ?? 0, entry.length - 1);
502
+ seen[matched!] = (seen[matched!] ?? 0) + 1;
503
+ res = entry[i];
504
+ } else {
505
+ res = entry;
506
+ }
507
+ return { code: res.code, stdout: res.stdout ?? "", stderr: res.stderr ?? "" };
508
+ });
509
+ return { orchestrator: { pi: { exec } } as any, calls };
510
+ }
511
+
512
+ it("is a no-op when no PR head is provided (non-PR review scope)", async () => {
513
+ const { orchestrator, calls } = makeGitOrchestrator({});
514
+ const result = await checkoutPrHead(orchestrator, "/repo", "", "");
515
+ expect(result.ok).toBe(true);
516
+ expect(result.message).toContain("non-PR review scope");
517
+ expect(calls).toHaveLength(0);
518
+ });
519
+
520
+ it("HALTs on a dirty working tree without checking out", async () => {
521
+ const { orchestrator, calls } = makeGitOrchestrator({
522
+ "status --porcelain": { code: 0, stdout: " M src/a.ts\n" },
523
+ });
524
+ const result = await checkoutPrHead(orchestrator, "/repo", "feature", "abc123");
525
+ expect(result.ok).toBe(false);
526
+ expect(result.message).toContain("HALT");
527
+ expect(result.message).toContain("uncommitted changes");
528
+ expect(calls.some((c) => c[0] === "checkout")).toBe(false);
529
+ });
530
+
531
+ it("HALTs when on a different branch, and never runs git checkout", async () => {
532
+ const { orchestrator, calls } = makeGitOrchestrator({
533
+ "status --porcelain": { code: 0, stdout: "" },
534
+ "rev-parse HEAD": { code: 0, stdout: "othersha\n" },
535
+ "rev-parse --abbrev-ref HEAD": { code: 0, stdout: "main\n" },
536
+ });
537
+ const result = await checkoutPrHead(orchestrator, "/repo", "feature", "abc123");
538
+ expect(result.ok).toBe(false);
539
+ expect(result.message).toContain("HALT");
540
+ expect(result.message).toContain("feature");
541
+ expect(calls.some((c) => c[0] === "checkout")).toBe(false);
542
+ expect(calls.some((c) => c[0] === "merge")).toBe(false);
543
+ });
544
+
545
+ it("fast-forwards a clean branch that is behind the PR head, without checkout or force", async () => {
546
+ const { orchestrator, calls } = makeGitOrchestrator({
547
+ "status --porcelain": { code: 0, stdout: "" },
548
+ "rev-parse HEAD": [{ code: 0, stdout: "oldsha\n" }, { code: 0, stdout: "abc123\n" }],
549
+ "rev-parse --abbrev-ref HEAD": { code: 0, stdout: "feature\n" },
550
+ "merge --ff-only abc123": { code: 0, stdout: "Updating oldsha..abc123\n" },
551
+ });
552
+ const result = await checkoutPrHead(orchestrator, "/repo", "feature", "abc123");
553
+ expect(result.ok).toBe(true);
554
+ expect(result.message).toContain("fast-forwarded");
555
+ expect(calls.some((c) => c[0] === "merge" && c[1] === "--ff-only" && c[2] === "abc123")).toBe(true);
556
+ expect(calls.some((c) => c[0] === "checkout")).toBe(false);
557
+ });
558
+
559
+ it("HALTs when the branch is AHEAD of the PR head (ff-only is a no-op that leaves HEAD ahead)", async () => {
560
+ const { orchestrator, calls } = makeGitOrchestrator({
561
+ "status --porcelain": { code: 0, stdout: "" },
562
+ // ff-only to an ancestor returns exit 0 ("Already up to date") without moving HEAD.
563
+ "rev-parse HEAD": { code: 0, stdout: "aheadsha\n" },
564
+ "rev-parse --abbrev-ref HEAD": { code: 0, stdout: "feature\n" },
565
+ "merge --ff-only abc123": { code: 0, stdout: "Already up to date.\n" },
566
+ });
567
+ const result = await checkoutPrHead(orchestrator, "/repo", "feature", "abc123");
568
+ expect(result.ok).toBe(false);
569
+ expect(result.message).toContain("HALT");
570
+ expect(result.message).toContain("ahead of the PR head");
571
+ expect(calls.some((c) => c[0] === "checkout")).toBe(false);
572
+ });
573
+
574
+ it("HALTs when the branch has diverged and cannot fast-forward", async () => {
575
+ const { orchestrator, calls } = makeGitOrchestrator({
576
+ "status --porcelain": { code: 0, stdout: "" },
577
+ "rev-parse HEAD": { code: 0, stdout: "oldsha\n" },
578
+ "rev-parse --abbrev-ref HEAD": { code: 0, stdout: "feature\n" },
579
+ "merge --ff-only abc123": { code: 1, stderr: "fatal: Not possible to fast-forward, aborting." },
580
+ });
581
+ const result = await checkoutPrHead(orchestrator, "/repo", "feature", "abc123");
582
+ expect(result.ok).toBe(false);
583
+ expect(result.message).toContain("HALT");
584
+ expect(result.message).toContain("diverged");
585
+ expect(calls.some((c) => c[0] === "checkout")).toBe(false);
586
+ });
587
+
588
+ it("succeeds without any checkout or merge when already on the PR head commit", async () => {
589
+ const { orchestrator, calls } = makeGitOrchestrator({
590
+ "status --porcelain": { code: 0, stdout: "" },
591
+ "rev-parse HEAD": { code: 0, stdout: "abc123\n" },
592
+ });
593
+ const result = await checkoutPrHead(orchestrator, "/repo", "feature", "abc123");
594
+ expect(result.ok).toBe(true);
595
+ expect(result.message).toContain("on PR head");
596
+ expect(calls.some((c) => c[0] === "checkout" || c[0] === "merge")).toBe(false);
597
+ });
598
+
599
+ it("succeeds on a detached HEAD already at the PR head (re-entrant)", async () => {
600
+ const { orchestrator, calls } = makeGitOrchestrator({
601
+ "status --porcelain": { code: 0, stdout: "" },
602
+ "rev-parse HEAD": { code: 0, stdout: "abc123\n" },
603
+ });
604
+ const result = await checkoutPrHead(orchestrator, "/repo", "feature", "abc123");
605
+ expect(result.ok).toBe(true);
606
+ expect(calls.some((c) => c[0] === "checkout" || c[0] === "merge")).toBe(false);
607
+ });
608
+
609
+ it("HALTs on a detached HEAD that is not at the PR head", async () => {
610
+ const { orchestrator, calls } = makeGitOrchestrator({
611
+ "status --porcelain": { code: 0, stdout: "" },
612
+ "rev-parse HEAD": { code: 0, stdout: "oldsha\n" },
613
+ "rev-parse --abbrev-ref HEAD": { code: 0, stdout: "HEAD\n" },
614
+ });
615
+ const result = await checkoutPrHead(orchestrator, "/repo", "feature", "abc123");
616
+ expect(result.ok).toBe(false);
617
+ expect(result.message).toContain("detached HEAD");
618
+ expect(calls.some((c) => c[0] === "checkout" || c[0] === "merge")).toBe(false);
619
+ });
620
+ });
621
+
622
+ describe("pp_checkout_pr_head tool registration", () => {
623
+ it("registers the checkout tool during orchestrator tool setup", async () => {
624
+ const { registerOrchestratorToolsForTest } = await import("./event-handlers.js");
625
+ orchestrator.active = makeActiveTask();
626
+ registerOrchestratorToolsForTest(orchestrator);
627
+ const names = (pi.registerTool as any).mock.calls.map((c: any[]) => c[0].name);
628
+ expect(names).toContain("pp_checkout_pr_head");
629
+ });
630
+ });
631
+
632
+ describe("isReviewCycleLive (#3b re-entrancy guard)", () => {
633
+ it("is true only for a cycle awaiting reviewers", () => {
634
+ const task = makeActiveTask();
635
+ task.state.reviewCycle = null;
636
+ expect(isReviewCycleLive(task)).toBe(false);
637
+ task.state.reviewCycle = { kind: "auto", step: "spawn_reviewers", pass: 1 };
638
+ expect(isReviewCycleLive(task)).toBe(false);
639
+ task.state.reviewCycle = { kind: "auto", step: "await_reviewers", pass: 1 };
640
+ expect(isReviewCycleLive(task)).toBe(true);
641
+ task.state.reviewCycle = { kind: "auto", step: "apply_feedback", pass: 1 };
642
+ expect(isReviewCycleLive(task)).toBe(false);
643
+ });
644
+
645
+ it("a live await_reviewers cycle is left intact when the guard blocks (no finalize)", () => {
646
+ const task = makeActiveTask();
647
+ task.state.reviewCycle = { kind: "auto", step: "await_reviewers", pass: 2 };
648
+ // The menu action returns early on isReviewCycleLive WITHOUT calling
649
+ // finalizeReviewCycle, so the running cycle survives a second selection.
650
+ expect(isReviewCycleLive(task)).toBe(true);
651
+ expect(task.state.reviewCycle).toEqual({ kind: "auto", step: "await_reviewers", pass: 2 });
652
+ });
653
+
654
+ it("finalizeReviewCycle only clears a completed (non-live) cycle for the next pass", () => {
655
+ const task = makeActiveTask();
656
+ task.dir = mkdtempSync(join(tmpdir(), "pp-review-"));
657
+ task.state.reviewCycle = { kind: "auto", step: "apply_feedback", pass: 1 };
658
+ expect(isReviewCycleLive(task)).toBe(false);
659
+ finalizeReviewCycle(task);
660
+ expect(task.state.reviewCycle).toBeNull();
661
+ rmSync(task.dir, { recursive: true, force: true });
662
+ });
663
+ });