@ilya-lesikov/pi-pi 0.1.0 → 0.2.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.
@@ -24,9 +24,13 @@ import { spawnCodeReviewers } from "./phases/review.js";
24
24
  import { spawnBrainstormReviewers } from "./phases/brainstorm.js";
25
25
  import { openPlannotator, waitForPlannotatorResult, cancelPendingPlannotatorWait } from "./plannotator.js";
26
26
  import { Orchestrator, deepReviewConfig, type ActiveTask } from "./orchestrator.js";
27
+ import { createCustomFooter, setFooterContext, setFooterTracker } from "./custom-footer.js";
28
+ import { createUsageTracker, dumpUsageSummary, loadUsageSummary, type UsageTracker } from "./usage-tracker.js";
27
29
  import { askUser } from "../../3p/pi-ask-user/index.js";
28
30
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
29
31
 
32
+ const USAGE_TRACKER_KEY = Symbol.for("pi-pi:usage-tracker");
33
+
30
34
  export async function detectDefaultBranch(pi: ExtensionAPI, cwd: string, config?: { diffBaseBranch?: string }): Promise<string> {
31
35
  if (config?.diffBaseBranch) return config.diffBaseBranch;
32
36
  try {
@@ -46,17 +50,6 @@ export async function detectDefaultBranch(pi: ExtensionAPI, cwd: string, config?
46
50
  return "main";
47
51
  }
48
52
 
49
- export async function detectRepoCwd(pi: ExtensionAPI, modifiedFiles: Set<string>, fallbackCwd: string): Promise<string> {
50
- if (modifiedFiles.size === 0) return fallbackCwd;
51
- const firstFile = [...modifiedFiles][0];
52
- const dir = resolve(firstFile, "..");
53
- try {
54
- const result = await pi.exec("git", ["rev-parse", "--show-toplevel"], { cwd: dir, timeout: 5000 });
55
- if (result.code === 0 && result.stdout.trim()) return result.stdout.trim();
56
- } catch {}
57
- return fallbackCwd;
58
- }
59
-
60
53
  export async function selectOption(ctx: any, question: string, options: string[]): Promise<string | undefined> {
61
54
  const result = await askUser(ctx, {
62
55
  question,
@@ -69,12 +62,6 @@ export async function selectOption(ctx: any, question: string, options: string[]
69
62
  return result.selections[0];
70
63
  }
71
64
 
72
- function setStep(orchestrator: Orchestrator, step: string): void {
73
- if (!orchestrator.active) return;
74
- orchestrator.active.state.step = step;
75
- saveTask(orchestrator.active.dir, orchestrator.active.state);
76
- }
77
-
78
65
  function tryCompleteReviewCycle(orchestrator: Orchestrator): void {
79
66
  if (
80
67
  !orchestrator.active?.state.reviewCycle ||
@@ -214,13 +201,13 @@ export async function enterReviewCycle(orchestrator: Orchestrator, ctx: any, kin
214
201
  return `Started review cycle pass ${pass} (${kind}). Awaiting reviewers.`;
215
202
  }
216
203
 
217
- export function stopTask(orchestrator: Orchestrator): string {
204
+ export async function stopTask(orchestrator: Orchestrator): Promise<string> {
218
205
  if (!orchestrator.active) return "No active task.";
219
206
  orchestrator.abortAllSubagents();
220
207
  orchestrator.active.state.phase = "done";
221
208
  saveTask(orchestrator.active.dir, orchestrator.active.state);
222
209
  const desc = orchestrator.active.description;
223
- orchestrator.cleanupActive();
210
+ await orchestrator.cleanupActive();
224
211
  const taskStore = (globalThis as any)[Symbol.for("pi-tasks:store")];
225
212
  taskStore?.clearAll?.();
226
213
  return `Task "${desc}" stopped.`;
@@ -238,128 +225,6 @@ export function finalizeReviewCycle(task: ActiveTask): void {
238
225
  saveTask(task.dir, task.state);
239
226
  }
240
227
 
241
- export async function runUserGateDialog(orchestrator: Orchestrator, ctx: any, summary: string): Promise<string> {
242
- if (!orchestrator.active) return "No active task.";
243
- if (orchestrator.userGatePending) return "A user-gate dialog is already in progress.";
244
- orchestrator.userGatePending = true;
245
-
246
- try {
247
- return await runUserGateDialogInner(orchestrator, ctx, summary);
248
- } finally {
249
- orchestrator.userGatePending = false;
250
- }
251
- }
252
-
253
- async function runUserGateDialogInner(orchestrator: Orchestrator, ctx: any, summary: string): Promise<string> {
254
- if (!orchestrator.active) return "No active task.";
255
- const pi = orchestrator.pi;
256
- const phase = orchestrator.active.state.phase;
257
- const task = orchestrator.active;
258
-
259
- if (orchestrator.spawnedAgentIds.size > 0 || orchestrator.pendingSubagentSpawns > 0) {
260
- const count = orchestrator.spawnedAgentIds.size + orchestrator.pendingSubagentSpawns;
261
- return `${count} subagent(s) still running or spawning.`;
262
- }
263
-
264
- const byKind = task.state.reviewPassByKind ?? {};
265
- const autoCount = byKind["auto"] ?? 0;
266
- const deepCount = byKind["auto-deep"] ?? 0;
267
- const autoLabel = autoCount > 0 ? `Review (pass ${autoCount + 1})` : "Review";
268
- const deepLabel = deepCount > 0 ? `Deep review (pass ${deepCount + 1})` : "Deep review";
269
-
270
- const continueMessage = "User wants to continue. Run /pp when ready to advance.";
271
-
272
- if (phase === "brainstorm" && task.type === "implement") {
273
- const choice = await selectOption(ctx, summary, ["Approve brainstorm", autoLabel, deepLabel, "Continue brainstorming", "Stop task"]);
274
- finalizeReviewCycle(task);
275
- if (choice === "Approve brainstorm") {
276
- const result = await orchestrator.transitionToNextPhase(ctx);
277
- return result.ok ? "Brainstorm approved. Transitioned to plan." : `Transition blocked: ${result.error}`;
278
- }
279
- if (choice === autoLabel) return enterReviewCycle(orchestrator, ctx, "auto");
280
- if (choice === deepLabel) return enterReviewCycle(orchestrator, ctx, "auto-deep");
281
- if (choice === "Stop task") return stopTask(orchestrator);
282
- setStep(orchestrator, "llm_work");
283
- return continueMessage;
284
- }
285
-
286
- if (phase === "brainstorm" && task.type === "brainstorm") {
287
- const choice = await selectOption(ctx, summary, ["Approve brainstorm", autoLabel, deepLabel, "Continue brainstorming", "Finish brainstorming", "Stop task"]);
288
- finalizeReviewCycle(task);
289
- if (choice === "Approve brainstorm") {
290
- const result = await orchestrator.transitionToNextPhase(ctx);
291
- return result.ok ? "Brainstorm approved. Transitioned to plan." : `Transition blocked: ${result.error}`;
292
- }
293
- if (choice === autoLabel) return enterReviewCycle(orchestrator, ctx, "auto");
294
- if (choice === deepLabel) return enterReviewCycle(orchestrator, ctx, "auto-deep");
295
- if (choice === "Finish brainstorming" || choice === "Stop task") return stopTask(orchestrator);
296
- setStep(orchestrator, "llm_work");
297
- return continueMessage;
298
- }
299
-
300
- if (phase === "debug") {
301
- const choice = await selectOption(ctx, summary, ["Approve debug", autoLabel, deepLabel, "Continue debugging", "Finish debugging", "Stop task"]);
302
- finalizeReviewCycle(task);
303
- if (choice === "Approve debug") {
304
- const result = await orchestrator.transitionToNextPhase(ctx);
305
- return result.ok ? "Debug approved. Transitioned to plan." : `Transition blocked: ${result.error}`;
306
- }
307
- if (choice === autoLabel) return enterReviewCycle(orchestrator, ctx, "auto");
308
- if (choice === deepLabel) return enterReviewCycle(orchestrator, ctx, "auto-deep");
309
- if (choice === "Finish debugging" || choice === "Stop task") return stopTask(orchestrator);
310
- setStep(orchestrator, "llm_work");
311
- return continueMessage;
312
- }
313
-
314
- if (phase === "plan") {
315
- const choice = await selectOption(ctx, summary, [
316
- "Approve plan",
317
- autoLabel,
318
- deepLabel,
319
- "Review in Plannotator",
320
- "Review on my own",
321
- "Continue planning",
322
- "Stop task",
323
- ]);
324
- finalizeReviewCycle(task);
325
- if (choice === "Approve plan") {
326
- const result = await orchestrator.transitionToNextPhase(ctx);
327
- return result.ok ? "Plan approved. Transitioned to implement." : `Transition blocked: ${result.error}`;
328
- }
329
- if (choice === autoLabel) return enterReviewCycle(orchestrator, ctx, "auto");
330
- if (choice === deepLabel) return enterReviewCycle(orchestrator, ctx, "auto-deep");
331
- if (choice === "Review in Plannotator") return enterReviewCycle(orchestrator, ctx, "plannotator");
332
- if (choice === "Stop task") return stopTask(orchestrator);
333
- setStep(orchestrator, "synthesize");
334
- return continueMessage;
335
- }
336
-
337
- if (phase === "implement") {
338
- const choice = await selectOption(ctx, summary, [
339
- "Approve implementation",
340
- autoLabel,
341
- deepLabel,
342
- "Review in Plannotator",
343
- "Review on my own",
344
- "Continue implementation",
345
- "Stop task",
346
- ]);
347
- finalizeReviewCycle(task);
348
- if (choice === "Approve implementation") {
349
- const result = await orchestrator.transitionToNextPhase(ctx);
350
- return result.ok ? "Implementation approved. Task completed." : `Transition blocked: ${result.error}`;
351
- }
352
- if (choice === autoLabel) return enterReviewCycle(orchestrator, ctx, "auto");
353
- if (choice === deepLabel) return enterReviewCycle(orchestrator, ctx, "auto-deep");
354
- if (choice === "Review in Plannotator") return enterReviewCycle(orchestrator, ctx, "plannotator");
355
- if (choice === "Stop task") return stopTask(orchestrator);
356
- setStep(orchestrator, "llm_work");
357
- return continueMessage;
358
- }
359
-
360
- return "No dialog available for this phase.";
361
- }
362
-
363
228
  function registerOrchestratorTools(orchestrator: Orchestrator): void {
364
229
  registerPhaseCompleteTool(orchestrator);
365
230
  registerCommitTool(orchestrator);
@@ -441,8 +306,15 @@ function registerSpecifyReviewsTool(orchestrator: Orchestrator): void {
441
306
 
442
307
  ctx.ui?.setWorkingMessage?.("Waiting for user input…");
443
308
  try {
444
- const gateResult = await runUserGateDialog(orchestrator, ctx, `Plannotator review complete.\n\n${summary}`);
445
- return { content: [{ type: "text" as const, text: gateResult }], details: {} };
309
+ const { showActiveTaskMenu } = await import("./pp-menu.js");
310
+ const text = await showActiveTaskMenu(orchestrator, ctx, `Plannotator review complete.\n\n${summary}`, "tool");
311
+ if (orchestrator.phaseCompactionPending || orchestrator.taskDoneCompactionPending) {
312
+ return { content: [{ type: "text" as const, text: "Phase transition in progress." }], details: {} };
313
+ }
314
+ if (!text) {
315
+ return { content: [{ type: "text" as const, text: "No action selected." }], details: {} };
316
+ }
317
+ return { content: [{ type: "text" as const, text }], details: {} };
446
318
  } finally {
447
319
  ctx.ui?.setWorkingMessage?.();
448
320
  }
@@ -539,6 +411,10 @@ function registerPhaseCompleteTool(orchestrator: Orchestrator): void {
539
411
  export function registerEventHandlers(orchestrator: Orchestrator): void {
540
412
  const pi = orchestrator.pi;
541
413
 
414
+ function getUsageTracker(): UsageTracker | undefined {
415
+ return (globalThis as any)[USAGE_TRACKER_KEY] as UsageTracker | undefined;
416
+ }
417
+
542
418
  function trackSubagentEvent(data: any, event: "created" | "started" | "first_tool" | "first_turn" | "completed" | "failed"): void {
543
419
  if (!orchestrator.active || !data?.id) return;
544
420
  const now = Date.now();
@@ -709,7 +585,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
709
585
  }
710
586
 
711
587
  if (choice === "Stop task") {
712
- pi.sendUserMessage(`[PI-PI] ${stopTask(orchestrator)}`, { deliverAs: "followUp" });
588
+ pi.sendUserMessage(`[PI-PI] ${await stopTask(orchestrator)}`, { deliverAs: "followUp" });
713
589
  return;
714
590
  }
715
591
 
@@ -821,7 +697,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
821
697
  }
822
698
 
823
699
  if (choice === "Stop task") {
824
- pi.sendUserMessage(`[PI-PI] ${stopTask(orchestrator)}`, { deliverAs: "followUp" });
700
+ pi.sendUserMessage(`[PI-PI] ${await stopTask(orchestrator)}`, { deliverAs: "followUp" });
825
701
  return;
826
702
  }
827
703
 
@@ -848,6 +724,17 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
848
724
  }
849
725
 
850
726
  pi.events.on("subagents:completed", (data: any) => {
727
+ const usageTracker = getUsageTracker();
728
+ if (usageTracker && data?.tokens) {
729
+ usageTracker.recordSubagentCompletion(data.tokens, undefined, {
730
+ description: data.description || data.type || data.id || "unknown",
731
+ modelId: data.modelId || "unknown",
732
+ durationMs: data.durationMs,
733
+ toolUses: data.toolUses,
734
+ });
735
+ (orchestrator.lastCtx?.ui as any)?.requestRender?.();
736
+ }
737
+
851
738
  if (!orchestrator.active || !data?.id) return;
852
739
  trackSubagentEvent(data, "completed");
853
740
  orchestrator.spawnedAgentIds.delete(data.id);
@@ -916,10 +803,36 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
916
803
  await orchestrator.cleanupActive();
917
804
  });
918
805
 
806
+ pi.on("session_shutdown", async (_event, ctx) => {
807
+ if ((globalThis as any)[SUBAGENT_SESSION_KEY]) return;
808
+ const tracker = getUsageTracker();
809
+ if (!tracker) return;
810
+ const sessionId = ctx.sessionManager?.getSessionId?.() || `session-${Date.now()}`;
811
+ try {
812
+ dumpUsageSummary(tracker, sessionId);
813
+ } catch (err: any) {
814
+ console.error(`[pi-pi] Failed to dump usage summary: ${err.message}`);
815
+ }
816
+ delete (globalThis as any)[USAGE_TRACKER_KEY];
817
+ });
818
+
919
819
  pi.on("session_start", async (_event, ctx) => {
920
820
  orchestrator.lastCtx = ctx;
921
821
  orchestrator.cwd = ctx.cwd;
922
822
 
823
+ if (!(globalThis as any)[SUBAGENT_SESSION_KEY]) {
824
+ const tracker = createUsageTracker();
825
+ const sessionId = ctx.sessionManager?.getSessionId?.() || "";
826
+ if (sessionId) {
827
+ const previous = loadUsageSummary(sessionId);
828
+ if (previous) tracker.loadFromSummary(previous);
829
+ }
830
+ (globalThis as any)[USAGE_TRACKER_KEY] = tracker;
831
+ setFooterContext(ctx);
832
+ setFooterTracker(tracker);
833
+ ctx.ui.setFooter(createCustomFooter);
834
+ }
835
+
923
836
  const subagentsMgr = (globalThis as any)[Symbol.for("pi-subagents:manager")];
924
837
  subagentsMgr?.refreshWidget?.(ctx.ui);
925
838
  const taskStore = (globalThis as any)[Symbol.for("pi-tasks:store")];
@@ -1196,6 +1109,20 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1196
1109
  });
1197
1110
 
1198
1111
  pi.on("turn_end", async (event, ctx) => {
1112
+ const msg = event.message as any;
1113
+ const usageTracker = getUsageTracker();
1114
+ if (usageTracker && msg?.usage) {
1115
+ const input = typeof msg.usage.input === "number" ? msg.usage.input : 0;
1116
+ const output = typeof msg.usage.output === "number" ? msg.usage.output : 0;
1117
+ const cacheRead = typeof msg.usage.cacheRead === "number" ? msg.usage.cacheRead : 0;
1118
+ const cacheWrite = typeof msg.usage.cacheWrite === "number" ? msg.usage.cacheWrite : 0;
1119
+ const cost = typeof msg.usage.cost?.total === "number" ? msg.usage.cost.total : 0;
1120
+ const modelId = (typeof msg.model === "string" && msg.model) || ctx.model?.id || "unknown-model";
1121
+ const provider = (typeof msg.provider === "string" && msg.provider) || ctx.model?.provider || "unknown";
1122
+ usageTracker.recordTurn(modelId, provider, input, output, cacheRead, cacheWrite, cost);
1123
+ (ctx.ui as any)?.requestRender?.();
1124
+ }
1125
+
1199
1126
  if (!orchestrator.active || orchestrator.active.state.phase === "done") return;
1200
1127
  orchestrator.updateStatus(ctx);
1201
1128
 
@@ -1218,7 +1145,6 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1218
1145
 
1219
1146
  const phase = orchestrator.active.state.phase;
1220
1147
 
1221
- const msg = event.message as any;
1222
1148
  if (msg?.stopReason === "aborted") return;
1223
1149
  if (msg?.stopReason === "error") {
1224
1150
  const errorMsg = msg.errorMessage || "unknown error";
@@ -1323,6 +1249,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
1323
1249
  }
1324
1250
 
1325
1251
  if (orchestrator.active.type === "brainstorm" && phase === "brainstorm") return;
1252
+ if (orchestrator.active.type === "review" && phase === "review") return;
1326
1253
 
1327
1254
  const contentParts = Array.isArray(msg?.content) ? msg.content : [];
1328
1255
  const hasText = contentParts.some((c: any) => c.type === "text" && c.text?.trim());
@@ -11,6 +11,8 @@ export interface OpenRouterModelData {
11
11
  pricing: {
12
12
  prompt: number;
13
13
  completion: number;
14
+ cacheRead: number;
15
+ cacheWrite: number;
14
16
  };
15
17
  modality: string;
16
18
  }
@@ -241,6 +243,8 @@ export async function fetchOpenRouterMetadata(modelIds: string[]): Promise<Recor
241
243
  pricing: {
242
244
  prompt: toNumber(pricing.prompt, 0),
243
245
  completion: toNumber(pricing.completion, 0),
246
+ cacheRead: toNumber(pricing.input_cache_read, 0),
247
+ cacheWrite: toNumber(pricing.input_cache_write, 0),
244
248
  },
245
249
  modality: typeof architecture.modality === "string" ? architecture.modality : "text",
246
250
  };
@@ -267,8 +271,8 @@ function buildProviderModelConfig(
267
271
  cost: {
268
272
  input: toNumber(modelMeta?.pricing.prompt, 0) * 1_000_000,
269
273
  output: toNumber(modelMeta?.pricing.completion, 0) * 1_000_000,
270
- cacheRead: 0,
271
- cacheWrite: 0,
274
+ cacheRead: toNumber(modelMeta?.pricing.cacheRead, 0) * 1_000_000,
275
+ cacheWrite: toNumber(modelMeta?.pricing.cacheWrite, 0) * 1_000_000,
272
276
  },
273
277
  contextWindow: toNumber(modelMeta?.context_length, 200000),
274
278
  maxTokens: toNumber(modelMeta?.max_completion_tokens, 32000),
@@ -358,6 +362,7 @@ export function generateFlantConfig(models: string[]): Partial<PiPiConfig> {
358
362
  implement: { model: modelSpec(implementModel), thinking: "high" },
359
363
  debug: { model: modelSpec(debugModel), thinking: "high" },
360
364
  brainstorm: { model: modelSpec(brainstormModel), thinking: "high" },
365
+ review: { model: modelSpec(implementModel), thinking: "high" },
361
366
  },
362
367
  planners: {
363
368
  opus: makeVariant(latestOpus, fallback),
@@ -4,7 +4,7 @@ import { join } from "path";
4
4
  import { afterEach, describe, expect, it, vi } from "vitest";
5
5
  import { Orchestrator } from "./orchestrator.js";
6
6
  import { registerCommandHandlers } from "./command-handlers.js";
7
- import { registerEventHandlers, runUserGateDialog } from "./event-handlers.js";
7
+ import { registerEventHandlers } from "./event-handlers.js";
8
8
  import { createTask, loadTask, saveTask } from "./state.js";
9
9
 
10
10
  vi.mock("./cbm.js", () => ({ registerCbmTools: vi.fn() }));
@@ -25,6 +25,7 @@ vi.mock("./config.js", async (importOriginal) => {
25
25
  implement: { model: "test/model", thinking: "high" },
26
26
  debug: { model: "test/model", thinking: "high" },
27
27
  brainstorm: { model: "test/model", thinking: "high" },
28
+ review: { model: "test/model", thinking: "high" },
28
29
  },
29
30
  planners: { test: { enabled: true, model: "test/planner", thinking: "low" } },
30
31
  planReviewers: {},
@@ -142,6 +143,7 @@ function makeConfig() {
142
143
  implement: { model: "test/model", thinking: "high" },
143
144
  debug: { model: "test/model", thinking: "high" },
144
145
  brainstorm: { model: "test/model", thinking: "high" },
146
+ review: { model: "test/model", thinking: "high" },
145
147
  },
146
148
  planners: {
147
149
  test: { enabled: true, model: "test/planner", thinking: "low" },
@@ -98,6 +98,7 @@ describe("deepReviewConfig", () => {
98
98
  implement: { model: "a/impl", thinking: "high" },
99
99
  debug: { model: "a/debug", thinking: "high" },
100
100
  brainstorm: { model: "a/brain", thinking: "high" },
101
+ review: { model: "a/review", thinking: "high" },
101
102
  },
102
103
  planners: {},
103
104
  planReviewers: {
@@ -16,7 +16,8 @@ import { loadContextFiles, getPhaseArtifacts, getLatestSynthesizedPlan } from ".
16
16
  import { brainstormSystemPrompt } from "./phases/brainstorm.js";
17
17
  import { planningSystemPrompt, spawnPlanners } from "./phases/planning.js";
18
18
  import { implementationSystemPrompt } from "./phases/implementation.js";
19
- import { reviewSystemPrompt } from "./phases/review.js";
19
+ import { reviewSystemPrompt as reviewCycleSystemPrompt } from "./phases/review.js";
20
+ import { reviewSystemPrompt as reviewTaskSystemPrompt } from "./phases/review-task.js";
20
21
  import { registerAgentDefinitions, unregisterAgentDefinitions } from "./agents/registry.js";
21
22
  import { createExploreAgent } from "./agents/explore.js";
22
23
  import { createLibrarianAgent } from "./agents/librarian.js";
@@ -207,7 +208,7 @@ export class Orchestrator {
207
208
  if (this.active.state.reviewCycle?.step === "apply_feedback") {
208
209
  const pass = this.active.state.reviewCycle.pass;
209
210
  const manualReview = this.active.state.reviewCycle.kind === "manual";
210
- return reviewSystemPrompt(this.active.dir, pass, manualReview, this.active.state.phase);
211
+ return reviewCycleSystemPrompt(this.active.dir, pass, manualReview, this.active.state.phase);
211
212
  }
212
213
 
213
214
  switch (this.active.state.phase) {
@@ -219,6 +220,8 @@ export class Orchestrator {
219
220
  return planningSystemPrompt(this.active.dir);
220
221
  case "implement":
221
222
  return implementationSystemPrompt(this.active.dir);
223
+ case "review":
224
+ return reviewTaskSystemPrompt(this.active.dir);
222
225
  default:
223
226
  return "";
224
227
  }
@@ -314,7 +317,12 @@ export class Orchestrator {
314
317
  description: state.description,
315
318
  };
316
319
 
317
- const modelConfig = this.config.mainModel[type === "debug" ? "debug" : type === "brainstorm" ? "brainstorm" : "implement"];
320
+ const modelConfig = this.config.mainModel[
321
+ type === "debug" ? "debug"
322
+ : type === "brainstorm" ? "brainstorm"
323
+ : type === "review" ? "review"
324
+ : "implement"
325
+ ];
318
326
  const modelOk = await this.switchModel(ctx, modelConfig.model, modelConfig.thinking);
319
327
  if (!modelOk) {
320
328
  ctx.ui.notify(`Model "${modelConfig.model}" not found — using current model`, "warning");
@@ -328,7 +336,7 @@ export class Orchestrator {
328
336
  this.injectContextAndArtifacts(this.active.dir, this.active.state.phase);
329
337
 
330
338
  this.phaseStartTime = Date.now();
331
- const isGenericDescription = ["implement", "debug", "brainstorm"].includes(this.active.description);
339
+ const isGenericDescription = ["implement", "debug", "brainstorm", "review"].includes(this.active.description);
332
340
  const hasInheritedTaskContext = Boolean(fromTaskDir && type === "implement");
333
341
  const isWaitingForPlanners = this.active.state.phase === "plan" && this.active.state.step === "await_planners";
334
342
  if (isGenericDescription && !hasInheritedTaskContext) {
@@ -67,6 +67,11 @@ const TRANSITIONS: Record<TaskType, Record<string, string[]>> = {
67
67
  plan: ["implement"],
68
68
  implement: ["done"],
69
69
  },
70
+ review: {
71
+ review: ["plan"],
72
+ plan: ["implement"],
73
+ implement: ["done"],
74
+ },
70
75
  };
71
76
 
72
77
  export function canTransition(taskType: TaskType, from: Phase, to: Phase): boolean {
@@ -116,6 +121,37 @@ export function validateExitCriteria(
116
121
  return { ok: true };
117
122
  }
118
123
 
124
+ case "review": {
125
+ const ur = join(taskDir, "USER_REQUEST.md");
126
+ const res = join(taskDir, "RESEARCH.md");
127
+ if (isMissingOrEmpty(ur)) {
128
+ return { ok: false, reason: "USER_REQUEST.md does not exist or is empty" };
129
+ }
130
+ if (isMissingOrEmpty(res)) {
131
+ return { ok: false, reason: "RESEARCH.md does not exist or is empty" };
132
+ }
133
+ const urContent = readFileSync(ur, "utf-8");
134
+ const resContent = readFileSync(res, "utf-8");
135
+
136
+ const userRequestValidation = validateUserRequest(urContent);
137
+ if (!userRequestValidation.ok) {
138
+ return {
139
+ ok: false,
140
+ reason: formatValidationErrors("USER_REQUEST.md", userRequestValidation.errors, USER_REQUEST_TEMPLATE),
141
+ };
142
+ }
143
+
144
+ const researchValidation = validateResearch(resContent);
145
+ if (!researchValidation.ok) {
146
+ return {
147
+ ok: false,
148
+ reason: formatValidationErrors("RESEARCH.md", researchValidation.errors, RESEARCH_TEMPLATE),
149
+ };
150
+ }
151
+
152
+ return { ok: true };
153
+ }
154
+
119
155
  case "plan": {
120
156
  const plan = getLatestSynthesizedPlan(taskDir);
121
157
  if (!plan) {
@@ -205,5 +241,7 @@ export function phasePipeline(taskType: TaskType): Phase[] {
205
241
  return ["debug", "plan", "implement", "done"];
206
242
  case "brainstorm":
207
243
  return ["brainstorm", "plan", "implement", "done"];
244
+ case "review":
245
+ return ["review", "plan", "implement", "done"];
208
246
  }
209
247
  }
@@ -0,0 +1,32 @@
1
+ export function reviewSystemPrompt(taskDir: string): string {
2
+ return [
3
+ "[PI-PI — REVIEW PHASE]",
4
+ "",
5
+ "You are reviewing code changes. USER_REQUEST.md describes what to review.",
6
+ "Read it first to understand the scope.",
7
+ "",
8
+ "Use available tools to analyze the changes:",
9
+ "- git diff, git log, git show for examining commits and diffs",
10
+ "- read, lsp, grep, find for understanding the code",
11
+ "- gh pr view for GitHub PR context if a PR URL is mentioned",
12
+ "",
13
+ "Write your findings:",
14
+ `- ${taskDir}/USER_REQUEST.md — update with a clear problem statement of issues found`,
15
+ `- ${taskDir}/RESEARCH.md — detailed technical analysis`,
16
+ "",
17
+ "USER_REQUEST.md format:",
18
+ "- # User Request",
19
+ "- ## Problem",
20
+ "- ## Constraints",
21
+ "",
22
+ "RESEARCH.md format:",
23
+ "- ## Affected Code",
24
+ "- ## Architecture Context",
25
+ "- ## Constraints & Edge Cases",
26
+ "- ## Open Questions (optional)",
27
+ "",
28
+ "Focus on: correctness, edge cases, style consistency, missing tests, potential bugs.",
29
+ "",
30
+ "When complete, call pp_phase_complete with a brief summary of findings.",
31
+ ].join("\n");
32
+ }