@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
@@ -0,0 +1,54 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { reviewSystemPrompt } from "./review.js";
3
+
4
+ describe("reviewSystemPrompt apply_feedback wording", () => {
5
+ it("autonomous plan/implement mandates re-calling pp_phase_complete and does not tell the agent to wait for the user", () => {
6
+ const prompt = reviewSystemPrompt("/tmp/task", 1, "plan", "autonomous");
7
+ expect(prompt).toContain("pp_phase_complete");
8
+ expect(prompt).not.toContain("Present the synthesis to the user");
9
+ expect(prompt).not.toContain("A new review pass will begin");
10
+ });
11
+
12
+ it("guided plan/implement keeps the user-facing synthesis behavior", () => {
13
+ const prompt = reviewSystemPrompt("/tmp/task", 1, "plan", "guided");
14
+ expect(prompt).toContain("Present the synthesis to the user");
15
+ expect(prompt).not.toContain("Call pp_phase_complete again to finalize");
16
+ });
17
+
18
+ it("brainstorm prompt is unaffected by mode (never instructs pp_phase_complete)", () => {
19
+ const auto = reviewSystemPrompt("/tmp/task", 1, "brainstorm", "autonomous");
20
+ const guided = reviewSystemPrompt("/tmp/task", 1, "brainstorm", "guided");
21
+ expect(auto).toBe(guided);
22
+ expect(auto).toContain("BRAINSTORM REVIEW CYCLE");
23
+ expect(auto).not.toContain("pp_phase_complete");
24
+ });
25
+
26
+ it("autonomous plan feedback folds into a new synthesized plan, not a separate fix-plan file", () => {
27
+ // Re-review and the transition read only the latest `*synthesized*` plan, so
28
+ // plan-phase feedback must land there.
29
+ const plan = reviewSystemPrompt("/tmp/task", 1, "plan", "autonomous");
30
+ expect(plan).toContain("synthesized.md");
31
+ expect(plan).not.toContain("do NOT modify the original synthesized plan");
32
+
33
+ // The implement phase keeps the fix-plan/implement pattern (synthesized plan
34
+ // is code guidance there, not the reviewed artifact).
35
+ const impl = reviewSystemPrompt("/tmp/task", 1, "implement", "autonomous");
36
+ expect(impl).toContain("Create a fix plan");
37
+ expect(impl).toContain("Implement the fixes");
38
+ });
39
+
40
+ it("points each phase at the directory its reviewers actually write to", () => {
41
+ // plan reviewers write to plan-reviews (planning.ts) and outputs load from
42
+ // plan-reviews (context.ts); the prompt must match, not code-reviews.
43
+ const plan = reviewSystemPrompt("/tmp/task", 1, "plan", "autonomous");
44
+ expect(plan).toContain("plan-reviews/");
45
+ expect(plan).not.toContain("code-reviews/");
46
+
47
+ const impl = reviewSystemPrompt("/tmp/task", 1, "implement", "autonomous");
48
+ expect(impl).toContain("code-reviews/");
49
+ expect(impl).not.toContain("plan-reviews/");
50
+
51
+ const brainstorm = reviewSystemPrompt("/tmp/task", 1, "brainstorm", "guided");
52
+ expect(brainstorm).toContain("brainstorm-reviews/");
53
+ });
54
+ });
@@ -1,13 +1,25 @@
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 {
10
- const reviewsDir = phase === "brainstorm" ? join(taskDir, "brainstorm-reviews") : join(taskDir, "code-reviews");
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, mode?: "guided" | "autonomous"): string {
16
+ // Each phase writes/loads its review outputs in a distinct directory:
17
+ // brainstorm -> brainstorm-reviews, plan -> plan-reviews, everything else
18
+ // (implement/review) -> code-reviews. The apply_feedback prompt must point the
19
+ // agent at the SAME directory the reviewers wrote to (see planning.ts /
20
+ // context.ts), otherwise it synthesizes against the wrong (empty) directory.
21
+ const reviewsDirName = phase === "brainstorm" ? "brainstorm-reviews" : phase === "plan" ? "plan-reviews" : "code-reviews";
22
+ const reviewsDir = join(taskDir, reviewsDirName);
11
23
  const plansDir = join(taskDir, "plans");
12
24
 
13
25
  if (phase === "brainstorm") {
@@ -33,51 +45,65 @@ export function reviewSystemPrompt(taskDir: string, pass: number, manualReview =
33
45
  "USER_REQUEST.md MUST keep exactly: # User Request, ## Problem, ## Constraints",
34
46
  "RESEARCH.md MUST keep exactly: ## Affected Code, ## Architecture Context, ## Constraints & Edge Cases, ## Open Questions (optional)",
35
47
  "Any other sections will fail validation.",
36
- "",
37
- "When done (or no changes needed), call pp_phase_complete with a brief summary.",
38
48
  ].join("\n");
39
49
  }
40
50
 
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
- ].join("\n");
56
- }
51
+ // In autonomous plan/implement the phase does NOT complete until the agent
52
+ // re-calls pp_phase_complete: that is what finalizes the pass and advances the
53
+ // phase. Telling it to "present to the user" and wait causes the plan phase to
54
+ // stall after applying feedback. Guided mode keeps the user-facing behavior.
55
+ const finalStep =
56
+ mode === "autonomous"
57
+ ? "3. Call pp_phase_complete again to finalize this review pass. The phase is NOT complete until you do — do NOT stop or wait for the user"
58
+ : "3. Present the synthesis to the user";
59
+
60
+ // In the plan phase the synthesized plan IS the reviewed artifact: re-review
61
+ // and the phase transition both read only the LATEST `*synthesized*` file (see
62
+ // getLatestSynthesizedPlan). So autonomous plan feedback must be folded into a
63
+ // new synthesized plan, NOT a separate fix-plan file (which those readers
64
+ // ignore). In the implement phase the synthesized plan is code guidance, so
65
+ // the fix-plan/implement/afterImplement pattern is correct there.
66
+ const tail =
67
+ mode === "autonomous"
68
+ ? phase === "plan"
69
+ ? [
70
+ "",
71
+ "If the reviewers require changes:",
72
+ `1. Fold the required changes into a NEW synthesized plan at ${plansDir}/<timestamp>_synthesized.md (re-review and the phase transition read only the latest \`*synthesized*\` plan, so the fixes MUST land there — do not write them to a separate fix-plan file)`,
73
+ "2. Then call pp_phase_complete again — the extension will start a new review pass or advance the phase as appropriate. Do NOT wait for the user.",
74
+ ]
75
+ : [
76
+ "",
77
+ "If changes are needed:",
78
+ `1. Create a fix plan at ${plansDir}/<timestamp>_<description>.md (do NOT modify the original synthesized plan)`,
79
+ "2. Implement the fixes",
80
+ "3. Run afterImplement commands",
81
+ "4. Then call pp_phase_complete again — the extension will start a new review pass or advance the phase as appropriate. Do NOT wait for the user.",
82
+ ]
83
+ : [
84
+ "",
85
+ "If changes are needed:",
86
+ `1. Create a fix plan at ${plansDir}/<timestamp>_<description>.md (do NOT modify the original synthesized plan)`,
87
+ "2. Implement the fixes",
88
+ "3. Run afterImplement commands",
89
+ "4. A new review pass will begin",
90
+ ];
57
91
 
58
92
  return [
59
93
  `[PI-PI — REVIEW CYCLE (pass ${pass})]`,
60
94
  "",
61
- "Code reviewer outputs are ready.",
95
+ "Reviewer outputs are ready.",
62
96
  `Read them from ${reviewsDir}/, synthesize feedback, and implement fixes if needed.`,
63
97
  "",
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.",
66
- "- 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.",
98
+ "You are a SYNTHESIZER: merge the reviewer outputs. Do NOT write your own review from scratch.",
99
+ `- Do NOT create the ${reviewsDirName}/ directory yourself the extension manages it.`,
100
+ "- Do NOT call plannotator_submit_plan.",
68
101
  "",
69
102
  "# Your job (in this order):",
70
103
  `1. Read ALL reviewer outputs from ${reviewsDir}/`,
71
104
  `2. Synthesize into ${reviewsDir}/<timestamp>_final_pass-${pass}.md`,
72
- "3. Present the synthesis to the user",
73
- "",
74
- "If changes are needed:",
75
- `1. Create a fix plan at ${plansDir}/<timestamp>_<description>.md (do NOT modify the original synthesized plan)`,
76
- "2. Implement the fixes",
77
- "3. Run afterImplement commands",
78
- "4. A new review pass will begin",
79
- "",
80
- "When the synthesized review is ready, call pp_phase_complete with a brief summary.",
105
+ finalStep,
106
+ ...tail,
81
107
  ].join("\n");
82
108
  }
83
109
 
@@ -88,13 +114,17 @@ export async function spawnCodeReviewers(
88
114
  taskId: string,
89
115
  config: PiPiConfig,
90
116
  round: number,
117
+ phase: string,
118
+ send: PhaseSend,
119
+ variants?: Record<string, VariantConfig>,
120
+ repos: RepoInfo[] = [],
91
121
  ): Promise<{ spawned: number; agentIds: string[]; failedVariants: string[] }> {
92
122
  const urPath = join(taskDir, "USER_REQUEST.md");
93
123
  const resPath = join(taskDir, "RESEARCH.md");
94
124
  if (!existsSync(urPath) || !existsSync(resPath)) {
95
- pi.sendMessage(
125
+ send(
96
126
  { customType: "pp-code-reviews-error", content: "Cannot start code review: USER_REQUEST.md or RESEARCH.md is missing.", display: true },
97
- { deliverAs: "steer" },
127
+ "context",
98
128
  );
99
129
  return { spawned: 0, agentIds: [], failedVariants: [] };
100
130
  }
@@ -103,9 +133,9 @@ export async function spawnCodeReviewers(
103
133
  const research = readFileSync(resPath, "utf-8");
104
134
  const synthesizedPlan = getLatestSynthesizedPlan(taskDir);
105
135
  if (!synthesizedPlan) {
106
- pi.sendMessage(
136
+ send(
107
137
  { customType: "pp-code-reviews-error", content: "Cannot start code review: no synthesized plan found.", display: true },
108
- { deliverAs: "steer" },
138
+ "context",
109
139
  );
110
140
  return { spawned: 0, agentIds: [], failedVariants: [] };
111
141
  }
@@ -116,14 +146,25 @@ export async function spawnCodeReviewers(
116
146
  }
117
147
 
118
148
  const timestamp = Math.floor(Date.now() / 1000);
119
- const enabledVariants = Object.entries(config.codeReviewers).filter(([, v]) => v.enabled);
149
+ const reviewerVariants = variants ?? resolvePreset(config, "codeReviewers");
150
+ const enabledVariants = Object.entries(reviewerVariants).filter(([, v]) => isEnabled(v));
151
+ const contextDirs = getContextDirs(cwd, repos, config.general.loadExtraRepoConfigs);
120
152
  const agentIds: string[] = [];
121
153
  const failedVariants: string[] = [];
122
154
  const results: Promise<void>[] = [];
123
155
 
124
156
  for (const [variant] of enabledVariants) {
125
157
  const outputPath = join(reviewsDir, `${timestamp}_${variant}_round-${round}.md`);
126
- const agent = createCodeReviewerAgent(variant, config, { userRequest, research, synthesizedPlan }, outputPath);
158
+ const reviewerPhase = phase === "review" ? "review" : "implement";
159
+ const agent = createCodeReviewerAgent(
160
+ variant,
161
+ reviewerVariants,
162
+ { userRequest, research, synthesizedPlan },
163
+ outputPath,
164
+ contextDirs,
165
+ reviewerPhase,
166
+ repos,
167
+ );
127
168
 
128
169
  registerAgentDefinitions(pi, [{ type: "code_reviewer", variant, ...agent }]);
129
170
 
@@ -142,13 +183,13 @@ export async function spawnCodeReviewers(
142
183
  await waitForCompletion(pi, id);
143
184
  } catch (err: any) {
144
185
  failedVariants.push(variant);
145
- pi.sendMessage(
186
+ send(
146
187
  {
147
188
  customType: "pp-code-reviewer-error",
148
189
  content: `Code reviewer variant "${variant}" failed: ${err.message}`,
149
190
  display: true,
150
191
  },
151
- { deliverAs: "steer" },
192
+ "context",
152
193
  );
153
194
  }
154
195
  })(),
@@ -162,7 +203,7 @@ export async function spawnCodeReviewers(
162
203
  : [];
163
204
 
164
205
  if (reviewFiles.length > 0) {
165
- pi.sendMessage(
206
+ send(
166
207
  {
167
208
  customType: "pp-code-reviews-done",
168
209
  content: [
@@ -173,10 +214,10 @@ export async function spawnCodeReviewers(
173
214
  ].join("\n"),
174
215
  display: true,
175
216
  },
176
- { deliverAs: "steer" },
217
+ "context",
177
218
  );
178
219
  } else {
179
- pi.sendMessage(
220
+ send(
180
221
  {
181
222
  customType: "pp-code-reviews-error",
182
223
  content: [
@@ -185,7 +226,7 @@ export async function spawnCodeReviewers(
185
226
  ].join("\n"),
186
227
  display: true,
187
228
  },
188
- { deliverAs: "steer" },
229
+ "context",
189
230
  );
190
231
  }
191
232
 
@@ -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
+ });