@ilya-lesikov/pi-pi 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/3p/pi-ask-user/index.ts +65 -49
  2. package/3p/pi-subagents/src/agent-manager.ts +8 -0
  3. package/3p/pi-subagents/src/agent-runner.ts +112 -19
  4. package/3p/pi-subagents/src/index.ts +3 -0
  5. package/extensions/orchestrator/agents/brainstorm-reviewer.ts +32 -19
  6. package/extensions/orchestrator/agents/code-reviewer.ts +31 -17
  7. package/extensions/orchestrator/agents/constraints.ts +55 -0
  8. package/extensions/orchestrator/agents/explore.ts +13 -13
  9. package/extensions/orchestrator/agents/librarian.ts +12 -9
  10. package/extensions/orchestrator/agents/plan-reviewer.ts +31 -19
  11. package/extensions/orchestrator/agents/planner.ts +28 -23
  12. package/extensions/orchestrator/agents/registry.ts +2 -1
  13. package/extensions/orchestrator/agents/repo-context.ts +11 -0
  14. package/extensions/orchestrator/agents/task.ts +14 -11
  15. package/extensions/orchestrator/agents/tool-routing.ts +17 -32
  16. package/extensions/orchestrator/ast-search.ts +2 -1
  17. package/extensions/orchestrator/cbm.test.ts +35 -0
  18. package/extensions/orchestrator/cbm.ts +43 -13
  19. package/extensions/orchestrator/command-handlers.test.ts +390 -19
  20. package/extensions/orchestrator/command-handlers.ts +68 -28
  21. package/extensions/orchestrator/commands.test.ts +255 -2
  22. package/extensions/orchestrator/commands.ts +108 -10
  23. package/extensions/orchestrator/config.test.ts +289 -68
  24. package/extensions/orchestrator/config.ts +630 -121
  25. package/extensions/orchestrator/context.test.ts +177 -10
  26. package/extensions/orchestrator/context.ts +115 -14
  27. package/extensions/orchestrator/custom-footer.ts +2 -1
  28. package/extensions/orchestrator/doctor.test.ts +559 -0
  29. package/extensions/orchestrator/doctor.ts +664 -0
  30. package/extensions/orchestrator/event-handlers.test.ts +84 -22
  31. package/extensions/orchestrator/event-handlers.ts +1177 -341
  32. package/extensions/orchestrator/exa.test.ts +46 -0
  33. package/extensions/orchestrator/exa.ts +16 -10
  34. package/extensions/orchestrator/flant-infra.test.ts +224 -0
  35. package/extensions/orchestrator/flant-infra.ts +110 -41
  36. package/extensions/orchestrator/index.ts +13 -2
  37. package/extensions/orchestrator/integration.test.ts +2866 -118
  38. package/extensions/orchestrator/log.test.ts +219 -0
  39. package/extensions/orchestrator/log.ts +153 -0
  40. package/extensions/orchestrator/model-registry.test.ts +238 -0
  41. package/extensions/orchestrator/model-registry.ts +282 -0
  42. package/extensions/orchestrator/model-version.test.ts +27 -0
  43. package/extensions/orchestrator/model-version.ts +19 -0
  44. package/extensions/orchestrator/orchestrator.test.ts +206 -56
  45. package/extensions/orchestrator/orchestrator.ts +287 -140
  46. package/extensions/orchestrator/phases/brainstorm.test.ts +10 -7
  47. package/extensions/orchestrator/phases/brainstorm.ts +41 -36
  48. package/extensions/orchestrator/phases/implementation.ts +7 -11
  49. package/extensions/orchestrator/phases/machine.test.ts +27 -8
  50. package/extensions/orchestrator/phases/machine.ts +13 -0
  51. package/extensions/orchestrator/phases/planning.ts +57 -31
  52. package/extensions/orchestrator/phases/review-task.ts +3 -7
  53. package/extensions/orchestrator/phases/review.ts +38 -39
  54. package/extensions/orchestrator/phases/spawn-blocking.test.ts +69 -0
  55. package/extensions/orchestrator/phases/verdict.test.ts +139 -0
  56. package/extensions/orchestrator/phases/verdict.ts +82 -0
  57. package/extensions/orchestrator/plannotator.test.ts +85 -0
  58. package/extensions/orchestrator/plannotator.ts +24 -6
  59. package/extensions/orchestrator/pp-menu.test.ts +134 -0
  60. package/extensions/orchestrator/pp-menu.ts +2557 -347
  61. package/extensions/orchestrator/repo-utils.test.ts +151 -0
  62. package/extensions/orchestrator/repo-utils.ts +67 -0
  63. package/extensions/orchestrator/spawn-cleanup.test.ts +57 -0
  64. package/extensions/orchestrator/spawn-cleanup.ts +35 -0
  65. package/extensions/orchestrator/state.test.ts +76 -6
  66. package/extensions/orchestrator/state.ts +89 -26
  67. package/extensions/orchestrator/subagent-session-marker.test.ts +36 -0
  68. package/extensions/orchestrator/test-helpers.ts +217 -0
  69. package/extensions/orchestrator/tracer.test.ts +132 -0
  70. package/extensions/orchestrator/tracer.ts +127 -0
  71. package/extensions/orchestrator/transition-controller.test.ts +207 -0
  72. package/extensions/orchestrator/transition-controller.ts +259 -0
  73. package/extensions/orchestrator/usage-tracker.test.ts +435 -0
  74. package/extensions/orchestrator/usage-tracker.ts +23 -2
  75. package/extensions/orchestrator/validate-artifacts.test.ts +83 -1
  76. package/package.json +2 -1
@@ -1,12 +1,18 @@
1
1
  import { readFileSync, existsSync, mkdirSync, readdirSync, statSync } from "fs";
2
2
  import { join } from "path";
3
3
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
- import type { PiPiConfig } from "../config.js";
4
+ import { resolvePreset, type PiPiConfig, type VariantConfig } from "../config.js";
5
5
  import { registerAgentDefinitions, spawnViaRpc, waitForCompletion } from "../agents/registry.js";
6
6
  import { createCodeReviewerAgent } from "../agents/code-reviewer.js";
7
- import { getLatestSynthesizedPlan } from "../context.js";
7
+ import { getContextDirs, getLatestSynthesizedPlan } from "../context.js";
8
+ import type { RepoInfo } from "../repo-utils.js";
9
+ import type { PhaseSend } from "../transition-controller.js";
8
10
 
9
- export function reviewSystemPrompt(taskDir: string, pass: number, manualReview = false, phase?: string): string {
11
+ function isEnabled(value: { enabled?: boolean } | undefined): boolean {
12
+ return value?.enabled !== false;
13
+ }
14
+
15
+ export function reviewSystemPrompt(taskDir: string, pass: number, phase?: string): string {
10
16
  const reviewsDir = phase === "brainstorm" ? join(taskDir, "brainstorm-reviews") : join(taskDir, "code-reviews");
11
17
  const plansDir = join(taskDir, "plans");
12
18
 
@@ -33,25 +39,6 @@ export function reviewSystemPrompt(taskDir: string, pass: number, manualReview =
33
39
  "USER_REQUEST.md MUST keep exactly: # User Request, ## Problem, ## Constraints",
34
40
  "RESEARCH.md MUST keep exactly: ## Affected Code, ## Architecture Context, ## Constraints & Edge Cases, ## Open Questions (optional)",
35
41
  "Any other sections will fail validation.",
36
- "",
37
- "When done (or no changes needed), call pp_phase_complete with a brief summary.",
38
- ].join("\n");
39
- }
40
-
41
- if (manualReview) {
42
- return [
43
- `[PI-PI — REVIEW CYCLE (pass ${pass}, manual)]`,
44
- "",
45
- "Review the implementation yourself using the available tools.",
46
- "",
47
- `Write your review to ${reviewsDir}/<timestamp>_final_pass-${pass}.md`,
48
- "",
49
- "If changes are needed:",
50
- `1. Create a fix plan at ${plansDir}/<timestamp>_<description>.md`,
51
- "2. Implement the fixes",
52
- "3. Run afterImplement commands",
53
- "",
54
- "When done, call pp_phase_complete with a brief summary.",
55
42
  ].join("\n");
56
43
  }
57
44
 
@@ -61,10 +48,9 @@ export function reviewSystemPrompt(taskDir: string, pass: number, manualReview =
61
48
  "Code reviewer outputs are ready.",
62
49
  `Read them from ${reviewsDir}/, synthesize feedback, and implement fixes if needed.`,
63
50
  "",
64
- "# FORBIDDEN do NOT do any of these:",
65
- "- Do NOT write your own code review from scratch. You are a SYNTHESIZER, not a reviewer.",
51
+ "You are a SYNTHESIZER: merge the reviewer outputs. Do NOT write your own code review from scratch.",
66
52
  "- Do NOT create the code-reviews/ directory yourself — the extension manages it.",
67
- "- Do NOT call plannotator_submit_plan — code review is handled by the user via /pp menu.",
53
+ "- Do NOT call plannotator_submit_plan.",
68
54
  "",
69
55
  "# Your job (in this order):",
70
56
  `1. Read ALL reviewer outputs from ${reviewsDir}/`,
@@ -76,8 +62,6 @@ export function reviewSystemPrompt(taskDir: string, pass: number, manualReview =
76
62
  "2. Implement the fixes",
77
63
  "3. Run afterImplement commands",
78
64
  "4. A new review pass will begin",
79
- "",
80
- "When the synthesized review is ready, call pp_phase_complete with a brief summary.",
81
65
  ].join("\n");
82
66
  }
83
67
 
@@ -88,13 +72,17 @@ export async function spawnCodeReviewers(
88
72
  taskId: string,
89
73
  config: PiPiConfig,
90
74
  round: number,
75
+ phase: string,
76
+ send: PhaseSend,
77
+ variants?: Record<string, VariantConfig>,
78
+ repos: RepoInfo[] = [],
91
79
  ): Promise<{ spawned: number; agentIds: string[]; failedVariants: string[] }> {
92
80
  const urPath = join(taskDir, "USER_REQUEST.md");
93
81
  const resPath = join(taskDir, "RESEARCH.md");
94
82
  if (!existsSync(urPath) || !existsSync(resPath)) {
95
- pi.sendMessage(
83
+ send(
96
84
  { customType: "pp-code-reviews-error", content: "Cannot start code review: USER_REQUEST.md or RESEARCH.md is missing.", display: true },
97
- { deliverAs: "steer" },
85
+ "context",
98
86
  );
99
87
  return { spawned: 0, agentIds: [], failedVariants: [] };
100
88
  }
@@ -103,9 +91,9 @@ export async function spawnCodeReviewers(
103
91
  const research = readFileSync(resPath, "utf-8");
104
92
  const synthesizedPlan = getLatestSynthesizedPlan(taskDir);
105
93
  if (!synthesizedPlan) {
106
- pi.sendMessage(
94
+ send(
107
95
  { customType: "pp-code-reviews-error", content: "Cannot start code review: no synthesized plan found.", display: true },
108
- { deliverAs: "steer" },
96
+ "context",
109
97
  );
110
98
  return { spawned: 0, agentIds: [], failedVariants: [] };
111
99
  }
@@ -116,14 +104,25 @@ export async function spawnCodeReviewers(
116
104
  }
117
105
 
118
106
  const timestamp = Math.floor(Date.now() / 1000);
119
- const enabledVariants = Object.entries(config.codeReviewers).filter(([, v]) => v.enabled);
107
+ const reviewerVariants = variants ?? resolvePreset(config, "codeReviewers");
108
+ const enabledVariants = Object.entries(reviewerVariants).filter(([, v]) => isEnabled(v));
109
+ const contextDirs = getContextDirs(cwd, repos, config.general.loadExtraRepoConfigs);
120
110
  const agentIds: string[] = [];
121
111
  const failedVariants: string[] = [];
122
112
  const results: Promise<void>[] = [];
123
113
 
124
114
  for (const [variant] of enabledVariants) {
125
115
  const outputPath = join(reviewsDir, `${timestamp}_${variant}_round-${round}.md`);
126
- const agent = createCodeReviewerAgent(variant, config, { userRequest, research, synthesizedPlan }, outputPath);
116
+ const reviewerPhase = phase === "review" ? "review" : "implement";
117
+ const agent = createCodeReviewerAgent(
118
+ variant,
119
+ reviewerVariants,
120
+ { userRequest, research, synthesizedPlan },
121
+ outputPath,
122
+ contextDirs,
123
+ reviewerPhase,
124
+ repos,
125
+ );
127
126
 
128
127
  registerAgentDefinitions(pi, [{ type: "code_reviewer", variant, ...agent }]);
129
128
 
@@ -142,13 +141,13 @@ export async function spawnCodeReviewers(
142
141
  await waitForCompletion(pi, id);
143
142
  } catch (err: any) {
144
143
  failedVariants.push(variant);
145
- pi.sendMessage(
144
+ send(
146
145
  {
147
146
  customType: "pp-code-reviewer-error",
148
147
  content: `Code reviewer variant "${variant}" failed: ${err.message}`,
149
148
  display: true,
150
149
  },
151
- { deliverAs: "steer" },
150
+ "context",
152
151
  );
153
152
  }
154
153
  })(),
@@ -162,7 +161,7 @@ export async function spawnCodeReviewers(
162
161
  : [];
163
162
 
164
163
  if (reviewFiles.length > 0) {
165
- pi.sendMessage(
164
+ send(
166
165
  {
167
166
  customType: "pp-code-reviews-done",
168
167
  content: [
@@ -173,10 +172,10 @@ export async function spawnCodeReviewers(
173
172
  ].join("\n"),
174
173
  display: true,
175
174
  },
176
- { deliverAs: "steer" },
175
+ "context",
177
176
  );
178
177
  } else {
179
- pi.sendMessage(
178
+ send(
180
179
  {
181
180
  customType: "pp-code-reviews-error",
182
181
  content: [
@@ -185,7 +184,7 @@ export async function spawnCodeReviewers(
185
184
  ].join("\n"),
186
185
  display: true,
187
186
  },
188
- { deliverAs: "steer" },
187
+ "context",
189
188
  );
190
189
  }
191
190
 
@@ -0,0 +1,69 @@
1
+ import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "fs";
2
+ import { tmpdir } from "os";
3
+ import { join } from "path";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+
6
+ let resolveWait: (() => void) | null = null;
7
+ let waitStarted = false;
8
+
9
+ vi.mock("../agents/registry.js", () => ({
10
+ registerAgentDefinitions: vi.fn(),
11
+ unregisterAgentDefinitions: vi.fn(),
12
+ spawnViaRpc: vi.fn(async () => ({ id: `agent-${Math.random().toString(36).slice(2)}` })),
13
+ waitForCompletion: vi.fn(
14
+ () =>
15
+ new Promise<void>((resolve) => {
16
+ waitStarted = true;
17
+ resolveWait = resolve;
18
+ }),
19
+ ),
20
+ }));
21
+
22
+ import { spawnPlanners } from "./planning.js";
23
+ import { getDefaultConfig } from "../config.js";
24
+
25
+ const tempDirs: string[] = [];
26
+
27
+ function makeTaskDir(): string {
28
+ const dir = mkdtempSync(join(tmpdir(), "pi-pi-spawn-block-"));
29
+ tempDirs.push(dir);
30
+ writeFileSync(join(dir, "USER_REQUEST.md"), "# User Request\n\n## Problem\n\nx\n\n## Constraints\n\n- y\n");
31
+ writeFileSync(join(dir, "RESEARCH.md"), "## Affected Code\n\nx\n\n## Architecture Context\n\nx\n\n## Constraints & Edge Cases\n\nx\n\n## Open Questions\n\nx\n");
32
+ mkdirSync(join(dir, "plans"), { recursive: true });
33
+ return dir;
34
+ }
35
+
36
+ function makePi(): any {
37
+ return { sendMessage: vi.fn(), events: { emit: vi.fn(), on: vi.fn() } };
38
+ }
39
+
40
+ afterEach(() => {
41
+ resolveWait = null;
42
+ waitStarted = false;
43
+ for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
44
+ vi.clearAllMocks();
45
+ });
46
+
47
+ describe("spawn blocks until completion (review-cycle invariant)", () => {
48
+ it("spawnPlanners does not resolve until waitForCompletion resolves", async () => {
49
+ const taskDir = makeTaskDir();
50
+ const config = getDefaultConfig();
51
+ const variants = { only: { model: "anthropic/claude-test", enabled: true } } as any;
52
+
53
+ const send = vi.fn();
54
+ const promise = spawnPlanners(makePi(), taskDir, taskDir, "1", config, send, variants);
55
+
56
+ let settled = false;
57
+ promise.then(() => {
58
+ settled = true;
59
+ });
60
+
61
+ await vi.waitFor(() => expect(waitStarted).toBe(true));
62
+ await Promise.resolve();
63
+ expect(settled).toBe(false);
64
+
65
+ resolveWait!();
66
+ await promise;
67
+ expect(settled).toBe(true);
68
+ });
69
+ });
@@ -0,0 +1,139 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "fs";
3
+ import { tmpdir } from "os";
4
+ import { join } from "path";
5
+ import { parseVerdict, hasActionableFindings, reviewPassUnanimousApprove } from "./verdict.js";
6
+
7
+ describe("parseVerdict", () => {
8
+ it("parses inline colon form", () => {
9
+ expect(parseVerdict("- VERDICT: APPROVE")).toBe("approve");
10
+ });
11
+
12
+ it("parses the header form with token on a later line (opus)", () => {
13
+ expect(parseVerdict("## Findings\nNone.\n\n## VERDICT\n\nAPPROVE")).toBe("approve");
14
+ });
15
+
16
+ it("parses bold form", () => {
17
+ expect(parseVerdict("**VERDICT:** APPROVE")).toBe("approve");
18
+ });
19
+
20
+ it("parses all negative tokens as changes", () => {
21
+ expect(parseVerdict("VERDICT: NEEDS_CHANGES")).toBe("changes");
22
+ expect(parseVerdict("VERDICT: NEEDS_WORK")).toBe("changes");
23
+ expect(parseVerdict("VERDICT: REJECT")).toBe("changes");
24
+ expect(parseVerdict("## VERDICT\nNEEDS_CHANGES")).toBe("changes");
25
+ });
26
+
27
+ it("is case-insensitive on the label", () => {
28
+ expect(parseVerdict("verdict: approve")).toBe("approve");
29
+ });
30
+
31
+ it("returns unknown when no verdict line", () => {
32
+ expect(parseVerdict("LGTM, looks fine")).toBe("unknown");
33
+ });
34
+
35
+ it("first-line verdict wins over a later VERDICT-like word in prose", () => {
36
+ const review = [
37
+ "VERDICT: NEEDS_CHANGES",
38
+ "- CRITICAL: the prose below mentions the word VERDICT: APPROVE but must not win",
39
+ ].join("\n");
40
+ expect(parseVerdict(review)).toBe("changes");
41
+ });
42
+ });
43
+
44
+ describe("hasActionableFindings", () => {
45
+ it("treats inline 'none' as no findings", () => {
46
+ expect(hasActionableFindings("- CRITICAL: none\n- MAJOR: (none)")).toBe(false);
47
+ });
48
+
49
+ it("detects a real inline CRITICAL finding", () => {
50
+ expect(hasActionableFindings("- CRITICAL: null deref at foo.ts:10")).toBe(true);
51
+ });
52
+
53
+ it("ignores MINOR findings", () => {
54
+ expect(hasActionableFindings("- MINOR: rename a variable")).toBe(false);
55
+ });
56
+
57
+ it("treats header form with 'None.' body as no findings (opus)", () => {
58
+ expect(hasActionableFindings("## Findings\n\n### CRITICAL\nNone.\n\n### MAJOR\nNone.\n")).toBe(false);
59
+ });
60
+
61
+ it("detects a real header-form CRITICAL finding", () => {
62
+ expect(hasActionableFindings("### CRITICAL\nnull deref at x.ts:1\n\n### MAJOR\nNone.")).toBe(true);
63
+ });
64
+ });
65
+
66
+ describe("reviewPassUnanimousApprove", () => {
67
+ let dir: string;
68
+ beforeEach(() => {
69
+ dir = mkdtempSync(join(tmpdir(), "verdict-test-"));
70
+ });
71
+ afterEach(() => {
72
+ rmSync(dir, { recursive: true, force: true });
73
+ });
74
+
75
+ it("returns true when all reviewer files approve with no findings", () => {
76
+ const rd = join(dir, "code-reviews");
77
+ mkdirSync(rd, { recursive: true });
78
+ writeFileSync(join(rd, "1_a_round-1.md"), "- CRITICAL: none\n- VERDICT: APPROVE");
79
+ writeFileSync(join(rd, "1_b_round-1.md"), "- MAJOR: none\n- VERDICT: APPROVE");
80
+ expect(reviewPassUnanimousApprove(dir, "implement", 1, 2)).toBe(true);
81
+ });
82
+
83
+ it("returns true for the real opus header-form approval", () => {
84
+ const rd = join(dir, "code-reviews");
85
+ mkdirSync(rd, { recursive: true });
86
+ writeFileSync(
87
+ join(rd, "1_opus_round-1.md"),
88
+ "## Findings\n\n### CRITICAL\nNone.\n\n### MAJOR\nNone.\n\n## VERDICT\n\nAPPROVE\n",
89
+ );
90
+ expect(reviewPassUnanimousApprove(dir, "implement", 1, 1)).toBe(true);
91
+ });
92
+
93
+ it("returns false when one reviewer needs changes", () => {
94
+ const rd = join(dir, "code-reviews");
95
+ mkdirSync(rd, { recursive: true });
96
+ writeFileSync(join(rd, "1_a_round-1.md"), "- VERDICT: APPROVE");
97
+ writeFileSync(join(rd, "1_b_round-1.md"), "- VERDICT: NEEDS_CHANGES");
98
+ expect(reviewPassUnanimousApprove(dir, "implement", 1, 2)).toBe(false);
99
+ });
100
+
101
+ it("returns false when approved but with a CRITICAL finding", () => {
102
+ const rd = join(dir, "code-reviews");
103
+ mkdirSync(rd, { recursive: true });
104
+ writeFileSync(join(rd, "1_a_round-1.md"), "- CRITICAL: bug at x.ts:1\n- VERDICT: APPROVE");
105
+ expect(reviewPassUnanimousApprove(dir, "implement", 1, 1)).toBe(false);
106
+ });
107
+
108
+ it("returns false when fewer files than enabled reviewers (some failed)", () => {
109
+ const rd = join(dir, "code-reviews");
110
+ mkdirSync(rd, { recursive: true });
111
+ writeFileSync(join(rd, "1_a_round-1.md"), "- VERDICT: APPROVE");
112
+ expect(reviewPassUnanimousApprove(dir, "implement", 1, 3)).toBe(false);
113
+ });
114
+
115
+ it("returns false when a verdict is unparseable (fail-safe)", () => {
116
+ const rd = join(dir, "code-reviews");
117
+ mkdirSync(rd, { recursive: true });
118
+ writeFileSync(join(rd, "1_a_round-1.md"), "LGTM");
119
+ expect(reviewPassUnanimousApprove(dir, "implement", 1, 1)).toBe(false);
120
+ });
121
+
122
+ it("returns false when no reviewer files exist", () => {
123
+ expect(reviewPassUnanimousApprove(dir, "implement", 1, 1)).toBe(false);
124
+ });
125
+
126
+ it("uses brainstorm-reviews dir for brainstorm phase", () => {
127
+ const rd = join(dir, "brainstorm-reviews");
128
+ mkdirSync(rd, { recursive: true });
129
+ writeFileSync(join(rd, "1_a_round-1.md"), "- VERDICT: APPROVE");
130
+ expect(reviewPassUnanimousApprove(dir, "brainstorm", 1, 1)).toBe(true);
131
+ });
132
+
133
+ it("uses plan-reviews dir for plan phase", () => {
134
+ const rd = join(dir, "plan-reviews");
135
+ mkdirSync(rd, { recursive: true });
136
+ writeFileSync(join(rd, "1_a_round-1.md"), "- VERDICT: APPROVE");
137
+ expect(reviewPassUnanimousApprove(dir, "plan", 1, 1)).toBe(true);
138
+ });
139
+ });
@@ -0,0 +1,82 @@
1
+ import { readFileSync, existsSync, readdirSync } from "fs";
2
+ import { join } from "path";
3
+ import type { Phase } from "../state.js";
4
+
5
+ export type ReviewVerdict = "approve" | "changes" | "unknown";
6
+
7
+ const CHANGES_TOKENS = ["NEEDS_CHANGES", "NEEDS_WORK", "REJECT"];
8
+
9
+ export function parseVerdict(reviewContent: string): ReviewVerdict {
10
+ const match = reviewContent.match(/VERDICT\**\s*:?\**\s*\n*\s*([A-Z_]+)/i);
11
+ if (!match) return "unknown";
12
+ const token = match[1].toUpperCase();
13
+ if (CHANGES_TOKENS.includes(token)) return "changes";
14
+ if (token === "APPROVE") return "approve";
15
+ return "unknown";
16
+ }
17
+
18
+ function isNoneBody(text: string): boolean {
19
+ const cleaned = text.trim().replace(/[().*]/g, "").toLowerCase();
20
+ return cleaned === "" || /^none\b/.test(cleaned);
21
+ }
22
+
23
+ export function hasActionableFindings(reviewContent: string): boolean {
24
+ const lines = reviewContent.split("\n");
25
+ for (let i = 0; i < lines.length; i++) {
26
+ const raw = lines[i].trim();
27
+
28
+ const inline = raw.replace(/^[-*]\s*/, "").match(/^(CRITICAL|MAJOR)\b\s*:?(.*)$/i);
29
+ if (inline && !raw.startsWith("#")) {
30
+ if (!isNoneBody(inline[2])) return true;
31
+ continue;
32
+ }
33
+
34
+ const header = raw.match(/^#{1,4}\s*(CRITICAL|MAJOR)\b\s*:?(.*)$/i);
35
+ if (header) {
36
+ if (header[2].trim() !== "" && !isNoneBody(header[2])) return true;
37
+ let body = "";
38
+ for (let j = i + 1; j < lines.length; j++) {
39
+ if (/^#{1,4}\s/.test(lines[j].trim())) break;
40
+ body += lines[j] + "\n";
41
+ }
42
+ const meaningful = body
43
+ .split("\n")
44
+ .map((l) => l.trim().replace(/^[-*]\s*/, ""))
45
+ .filter((l) => l.length > 0);
46
+ if (meaningful.length > 0 && !meaningful.every((l) => isNoneBody(l))) return true;
47
+ }
48
+ }
49
+ return false;
50
+ }
51
+
52
+ function reviewsDirForPhase(taskDir: string, phase: Phase): string {
53
+ if (phase === "brainstorm") return join(taskDir, "brainstorm-reviews");
54
+ if (phase === "plan") return join(taskDir, "plan-reviews");
55
+ return join(taskDir, "code-reviews");
56
+ }
57
+
58
+ export function reviewPassUnanimousApprove(
59
+ taskDir: string,
60
+ phase: Phase,
61
+ round: number,
62
+ expectedReviewerCount: number,
63
+ ): boolean {
64
+ if (expectedReviewerCount <= 0) return false;
65
+ const dir = reviewsDirForPhase(taskDir, phase);
66
+ if (!existsSync(dir)) return false;
67
+ const files = readdirSync(dir).filter(
68
+ (f) => f.includes(`round-${round}`) && !f.includes("final") && f.endsWith(".md"),
69
+ );
70
+ if (files.length < expectedReviewerCount) return false;
71
+ for (const f of files) {
72
+ let content: string;
73
+ try {
74
+ content = readFileSync(join(dir, f), "utf-8");
75
+ } catch {
76
+ return false;
77
+ }
78
+ if (parseVerdict(content) !== "approve") return false;
79
+ if (hasActionableFindings(content)) return false;
80
+ }
81
+ return true;
82
+ }
@@ -0,0 +1,85 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ cancelPendingPlannotatorWait,
4
+ openPlannotator,
5
+ waitForPlannotatorResult,
6
+ } from "./plannotator.js";
7
+ import type { Orchestrator } from "./orchestrator.js";
8
+
9
+ type Handler = (data: any) => void;
10
+
11
+ function makeEvents() {
12
+ const handlers = new Map<string, Set<Handler>>();
13
+ return {
14
+ handlers,
15
+ on(name: string, fn: Handler) {
16
+ if (!handlers.has(name)) handlers.set(name, new Set());
17
+ handlers.get(name)!.add(fn);
18
+ return () => handlers.get(name)!.delete(fn);
19
+ },
20
+ emit(name: string, data: any) {
21
+ for (const fn of handlers.get(name) ?? []) fn(data);
22
+ },
23
+ };
24
+ }
25
+
26
+ function makeOrchestrator(events: ReturnType<typeof makeEvents>): Orchestrator {
27
+ return {
28
+ pi: { events },
29
+ plannotatorReject: null,
30
+ plannotatorUnsub: null,
31
+ plannotatorTimer: null,
32
+ } as unknown as Orchestrator;
33
+ }
34
+
35
+ afterEach(() => {
36
+ vi.useRealTimers();
37
+ });
38
+
39
+ describe("waitForPlannotatorResult", () => {
40
+ it("resolves when a matching review-result arrives", async () => {
41
+ const events = makeEvents();
42
+ const orch = makeOrchestrator(events);
43
+ const p = waitForPlannotatorResult(orch, "rev-1");
44
+ events.emit("plannotator:review-result", { reviewId: "rev-1", approved: true, feedback: "ok" });
45
+ await expect(p).resolves.toEqual({ approved: true, feedback: "ok" });
46
+ expect(orch.plannotatorTimer).toBeNull();
47
+ });
48
+
49
+ it("rejects on timeout instead of hanging forever", async () => {
50
+ vi.useFakeTimers();
51
+ const events = makeEvents();
52
+ const orch = makeOrchestrator(events);
53
+ const p = waitForPlannotatorResult(orch, "rev-1");
54
+ const assertion = expect(p).rejects.toThrow("timed out");
55
+ await vi.advanceTimersByTimeAsync(30 * 60 * 1000 + 1);
56
+ await assertion;
57
+ expect(orch.plannotatorTimer).toBeNull();
58
+ });
59
+
60
+ it("can be cancelled on task switch/done", async () => {
61
+ const events = makeEvents();
62
+ const orch = makeOrchestrator(events);
63
+ const p = waitForPlannotatorResult(orch, "rev-1");
64
+ const assertion = expect(p).rejects.toThrow("cancelled");
65
+ cancelPendingPlannotatorWait(orch);
66
+ await assertion;
67
+ expect(orch.plannotatorTimer).toBeNull();
68
+ expect(orch.plannotatorUnsub).toBeNull();
69
+ });
70
+ });
71
+
72
+ describe("openPlannotator", () => {
73
+ it("clears its timeout after respond so no late side effect fires", async () => {
74
+ vi.useFakeTimers();
75
+ const clearSpy = vi.spyOn(globalThis, "clearTimeout");
76
+ const events = makeEvents();
77
+ events.on("plannotator:request", (req: any) => {
78
+ req.respond({ status: "handled", result: { reviewId: "rev-9" } });
79
+ });
80
+ const pi = { events } as any;
81
+ const result = await openPlannotator(pi, "open", {});
82
+ expect(result).toEqual({ opened: true, reviewId: "rev-9" });
83
+ expect(clearSpy).toHaveBeenCalled();
84
+ });
85
+ });
@@ -9,23 +9,30 @@ export function openPlannotator(
9
9
  const requestId = crypto.randomUUID();
10
10
  return new Promise((resolve) => {
11
11
  let handled = false;
12
+ const timer = setTimeout(() => {
13
+ if (!handled) resolve({ opened: false, reviewId: null });
14
+ }, 30000);
12
15
  pi.events.emit("plannotator:request", {
13
16
  requestId,
14
17
  action,
15
18
  payload,
16
19
  respond: (response: any) => {
17
20
  handled = true;
21
+ clearTimeout(timer);
18
22
  const reviewId = response?.result?.reviewId ?? null;
19
23
  resolve({ opened: response.status === "handled", reviewId });
20
24
  },
21
25
  });
22
- setTimeout(() => {
23
- if (!handled) resolve({ opened: false, reviewId: null });
24
- }, 30000);
25
26
  });
26
27
  }
27
28
 
29
+ const PLANNOTATOR_RESULT_TIMEOUT_MS = 30 * 60 * 1000;
30
+
28
31
  export function cancelPendingPlannotatorWait(orchestrator: Orchestrator): void {
32
+ if (orchestrator.plannotatorTimer) {
33
+ clearTimeout(orchestrator.plannotatorTimer);
34
+ orchestrator.plannotatorTimer = null;
35
+ }
29
36
  if (orchestrator.plannotatorUnsub) {
30
37
  orchestrator.plannotatorUnsub();
31
38
  orchestrator.plannotatorUnsub = null;
@@ -46,11 +53,22 @@ export function waitForPlannotatorResult(
46
53
  orchestrator.plannotatorReject = reject;
47
54
  const unsub = pi.events.on("plannotator:review-result", (data: any) => {
48
55
  if (reviewId && data?.reviewId && data.reviewId !== reviewId) return;
49
- unsub();
50
- orchestrator.plannotatorUnsub = null;
51
- orchestrator.plannotatorReject = null;
56
+ cleanup();
52
57
  resolve({ approved: !!data?.approved, feedback: data?.feedback });
53
58
  });
54
59
  orchestrator.plannotatorUnsub = unsub;
60
+ function cleanup() {
61
+ unsub();
62
+ orchestrator.plannotatorUnsub = null;
63
+ orchestrator.plannotatorReject = null;
64
+ if (orchestrator.plannotatorTimer) {
65
+ clearTimeout(orchestrator.plannotatorTimer);
66
+ orchestrator.plannotatorTimer = null;
67
+ }
68
+ }
69
+ orchestrator.plannotatorTimer = setTimeout(() => {
70
+ cleanup();
71
+ reject(new Error("Plannotator review timed out"));
72
+ }, PLANNOTATOR_RESULT_TIMEOUT_MS);
55
73
  });
56
74
  }