@ilya-lesikov/pi-pi 0.4.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.
- package/3p/pi-ask-user/index.ts +65 -49
- package/3p/pi-subagents/src/agent-manager.ts +8 -0
- package/3p/pi-subagents/src/agent-runner.ts +112 -19
- package/3p/pi-subagents/src/index.ts +3 -0
- package/extensions/orchestrator/agents/brainstorm-reviewer.ts +32 -19
- package/extensions/orchestrator/agents/code-reviewer.ts +31 -17
- package/extensions/orchestrator/agents/constraints.ts +55 -0
- package/extensions/orchestrator/agents/explore.ts +13 -13
- package/extensions/orchestrator/agents/librarian.ts +12 -9
- package/extensions/orchestrator/agents/plan-reviewer.ts +31 -19
- package/extensions/orchestrator/agents/planner.ts +28 -23
- package/extensions/orchestrator/agents/registry.ts +2 -1
- package/extensions/orchestrator/agents/repo-context.ts +11 -0
- package/extensions/orchestrator/agents/task.ts +14 -11
- package/extensions/orchestrator/agents/tool-routing.ts +17 -32
- package/extensions/orchestrator/ast-search.ts +2 -1
- package/extensions/orchestrator/cbm.test.ts +35 -0
- package/extensions/orchestrator/cbm.ts +43 -13
- package/extensions/orchestrator/command-handlers.test.ts +390 -19
- package/extensions/orchestrator/command-handlers.ts +73 -31
- package/extensions/orchestrator/commands.test.ts +255 -2
- package/extensions/orchestrator/commands.ts +108 -10
- package/extensions/orchestrator/config.test.ts +289 -68
- package/extensions/orchestrator/config.ts +630 -121
- package/extensions/orchestrator/context.test.ts +177 -10
- package/extensions/orchestrator/context.ts +115 -14
- package/extensions/orchestrator/custom-footer.ts +2 -1
- package/extensions/orchestrator/doctor.test.ts +559 -0
- package/extensions/orchestrator/doctor.ts +664 -0
- package/extensions/orchestrator/event-handlers.test.ts +84 -22
- package/extensions/orchestrator/event-handlers.ts +1191 -360
- package/extensions/orchestrator/exa.test.ts +46 -0
- package/extensions/orchestrator/exa.ts +16 -10
- package/extensions/orchestrator/flant-infra.test.ts +224 -0
- package/extensions/orchestrator/flant-infra.ts +110 -41
- package/extensions/orchestrator/index.ts +13 -2
- package/extensions/orchestrator/integration.test.ts +2866 -118
- package/extensions/orchestrator/log.test.ts +219 -0
- package/extensions/orchestrator/log.ts +153 -0
- package/extensions/orchestrator/model-registry.test.ts +238 -0
- package/extensions/orchestrator/model-registry.ts +282 -0
- package/extensions/orchestrator/model-version.test.ts +27 -0
- package/extensions/orchestrator/model-version.ts +19 -0
- package/extensions/orchestrator/orchestrator.test.ts +206 -56
- package/extensions/orchestrator/orchestrator.ts +295 -148
- package/extensions/orchestrator/phases/brainstorm.test.ts +10 -7
- package/extensions/orchestrator/phases/brainstorm.ts +41 -35
- package/extensions/orchestrator/phases/implementation.ts +7 -11
- package/extensions/orchestrator/phases/machine.test.ts +27 -8
- package/extensions/orchestrator/phases/machine.ts +13 -0
- package/extensions/orchestrator/phases/planning.ts +57 -29
- package/extensions/orchestrator/phases/review-task.ts +3 -3
- package/extensions/orchestrator/phases/review.ts +38 -39
- package/extensions/orchestrator/phases/spawn-blocking.test.ts +69 -0
- package/extensions/orchestrator/phases/verdict.test.ts +139 -0
- package/extensions/orchestrator/phases/verdict.ts +82 -0
- package/extensions/orchestrator/plannotator.test.ts +85 -0
- package/extensions/orchestrator/plannotator.ts +24 -6
- package/extensions/orchestrator/pp-menu.test.ts +134 -0
- package/extensions/orchestrator/pp-menu.ts +2631 -392
- package/extensions/orchestrator/repo-utils.test.ts +151 -0
- package/extensions/orchestrator/repo-utils.ts +67 -0
- package/extensions/orchestrator/spawn-cleanup.test.ts +57 -0
- package/extensions/orchestrator/spawn-cleanup.ts +35 -0
- package/extensions/orchestrator/state.test.ts +76 -6
- package/extensions/orchestrator/state.ts +89 -26
- package/extensions/orchestrator/subagent-session-marker.test.ts +36 -0
- package/extensions/orchestrator/test-helpers.ts +217 -0
- package/extensions/orchestrator/tracer.test.ts +132 -0
- package/extensions/orchestrator/tracer.ts +127 -0
- package/extensions/orchestrator/transition-controller.test.ts +207 -0
- package/extensions/orchestrator/transition-controller.ts +259 -0
- package/extensions/orchestrator/usage-tracker.test.ts +435 -0
- package/extensions/orchestrator/usage-tracker.ts +23 -2
- package/extensions/orchestrator/validate-artifacts.test.ts +83 -1
- 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
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
94
|
+
send(
|
|
107
95
|
{ customType: "pp-code-reviews-error", content: "Cannot start code review: no synthesized plan found.", display: true },
|
|
108
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
175
|
+
"context",
|
|
177
176
|
);
|
|
178
177
|
} else {
|
|
179
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|