@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
|
@@ -2,6 +2,7 @@ import { spawn, execFileSync, type ChildProcess } from "child_process";
|
|
|
2
2
|
import { createInterface, type Interface as ReadlineInterface } from "readline";
|
|
3
3
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { Type } from "@sinclair/typebox";
|
|
5
|
+
import { getLogger } from "./log.js";
|
|
5
6
|
|
|
6
7
|
function findCbmBin(): string | null {
|
|
7
8
|
try {
|
|
@@ -34,7 +35,7 @@ function setGlobalDaemon(daemon: CbmDaemon): void {
|
|
|
34
35
|
(globalThis as any)[CBM_DAEMON_KEY] = daemon;
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
class CbmDaemon {
|
|
38
|
+
export class CbmDaemon {
|
|
38
39
|
private proc: ChildProcess | null = null;
|
|
39
40
|
private rl: ReadlineInterface | null = null;
|
|
40
41
|
private nextId = 1;
|
|
@@ -46,24 +47,30 @@ class CbmDaemon {
|
|
|
46
47
|
start(): void {
|
|
47
48
|
if (this.proc) return;
|
|
48
49
|
|
|
49
|
-
this.proc = spawn(getCbmBin()!, [], { stdio: ["pipe", "pipe", "
|
|
50
|
+
this.proc = spawn(getCbmBin()!, [], { stdio: ["pipe", "pipe", "pipe"] });
|
|
50
51
|
this.proc.unref();
|
|
51
52
|
(this.proc.stdout as any)?.unref?.();
|
|
52
53
|
(this.proc.stdin as any)?.unref?.();
|
|
54
|
+
(this.proc.stderr as any)?.unref?.();
|
|
53
55
|
|
|
54
56
|
this.rl = createInterface({ input: this.proc.stdout! });
|
|
55
57
|
this.rl.on("line", (line) => this.handleLine(line));
|
|
56
58
|
|
|
59
|
+
this.proc.stderr?.on("data", (chunk) => {
|
|
60
|
+
const text = chunk.toString().trim();
|
|
61
|
+
if (text) getLogger().error({ s: "cbm", stderr: text }, "CBM daemon stderr");
|
|
62
|
+
});
|
|
63
|
+
|
|
57
64
|
this.proc.on("exit", () => this.cleanup());
|
|
58
65
|
this.proc.on("error", (err) => {
|
|
59
|
-
|
|
66
|
+
getLogger().error({ s: "cbm", err: err.message }, "CBM daemon error");
|
|
60
67
|
this.cleanup();
|
|
61
68
|
});
|
|
62
69
|
|
|
63
70
|
this.initPromise = this.initialize();
|
|
64
71
|
}
|
|
65
72
|
|
|
66
|
-
|
|
73
|
+
cleanup(): void {
|
|
67
74
|
for (const [, entry] of this.pending) {
|
|
68
75
|
clearTimeout(entry.timer);
|
|
69
76
|
entry.reject(new Error("CBM daemon exited"));
|
|
@@ -74,11 +81,14 @@ class CbmDaemon {
|
|
|
74
81
|
this.proc = null;
|
|
75
82
|
this.initialized = false;
|
|
76
83
|
this.initPromise = null;
|
|
84
|
+
this.indexedProjects.clear();
|
|
77
85
|
}
|
|
78
86
|
|
|
79
87
|
stop(): void {
|
|
80
88
|
if (!this.proc) return;
|
|
81
|
-
try { this.proc.kill(); } catch {
|
|
89
|
+
try { this.proc.kill(); } catch (err: any) {
|
|
90
|
+
getLogger().debug({ s: "cbm", err: err?.message }, "CBM daemon kill failed");
|
|
91
|
+
}
|
|
82
92
|
this.cleanup();
|
|
83
93
|
}
|
|
84
94
|
|
|
@@ -88,6 +98,9 @@ class CbmDaemon {
|
|
|
88
98
|
capabilities: {},
|
|
89
99
|
clientInfo: { name: "pi-pi", version: "1.0" },
|
|
90
100
|
});
|
|
101
|
+
if (this.proc?.stdin?.writable) {
|
|
102
|
+
this.proc.stdin.write(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized", params: {} }) + "\n");
|
|
103
|
+
}
|
|
91
104
|
this.initialized = true;
|
|
92
105
|
}
|
|
93
106
|
|
|
@@ -147,6 +160,10 @@ class CbmDaemon {
|
|
|
147
160
|
}
|
|
148
161
|
}
|
|
149
162
|
|
|
163
|
+
hasIndexed(cwd: string): boolean {
|
|
164
|
+
return this.indexedProjects.has(projectName(cwd));
|
|
165
|
+
}
|
|
166
|
+
|
|
150
167
|
async ensureIndexed(cwd: string): Promise<string> {
|
|
151
168
|
const name = projectName(cwd);
|
|
152
169
|
if (this.indexedProjects.has(name)) return name;
|
|
@@ -200,10 +217,12 @@ export function registerCbmTools(pi: ExtensionAPI, cwd: string): boolean {
|
|
|
200
217
|
semantic_query: Type.Optional(Type.Array(Type.String(), { description: "Array of keywords for vector similarity search" })),
|
|
201
218
|
label: Type.Optional(Type.String({ description: "Filter by node type: Function, Method, Interface, Class, Type, Route" })),
|
|
202
219
|
limit: Type.Optional(Type.Number({ description: "Max results (default: 20)" })),
|
|
220
|
+
project_path: Type.Optional(Type.String({ description: "Absolute path to the repo to search. Defaults to root project." })),
|
|
203
221
|
}),
|
|
204
222
|
async execute(_toolCallId, params: any) {
|
|
205
223
|
try {
|
|
206
|
-
const
|
|
224
|
+
const targetCwd = params.project_path ?? cwd;
|
|
225
|
+
const project = await daemon.ensureIndexed(targetCwd);
|
|
207
226
|
const p: Record<string, unknown> = { project, limit: params.limit ?? 20 };
|
|
208
227
|
if (params.query) p.query = params.query;
|
|
209
228
|
if (params.name_pattern) p.name_pattern = params.name_pattern;
|
|
@@ -228,10 +247,12 @@ export function registerCbmTools(pi: ExtensionAPI, cwd: string): boolean {
|
|
|
228
247
|
file_pattern: Type.Optional(Type.String({ description: "Glob filter (e.g. '*.go', '*.ts')" })),
|
|
229
248
|
path_filter: Type.Optional(Type.String({ description: "Regex filter on file paths (e.g. '^pkg/')" })),
|
|
230
249
|
limit: Type.Optional(Type.Number({ description: "Max results (default: 20)" })),
|
|
250
|
+
project_path: Type.Optional(Type.String({ description: "Absolute path to the repo to search. Defaults to root project." })),
|
|
231
251
|
}),
|
|
232
252
|
async execute(_toolCallId, params: any) {
|
|
233
253
|
try {
|
|
234
|
-
const
|
|
254
|
+
const targetCwd = params.project_path ?? cwd;
|
|
255
|
+
const project = await daemon.ensureIndexed(targetCwd);
|
|
235
256
|
const p: Record<string, unknown> = { project, pattern: params.pattern, limit: params.limit ?? 20 };
|
|
236
257
|
if (params.file_pattern) p.file_pattern = params.file_pattern;
|
|
237
258
|
if (params.path_filter) p.path_filter = params.path_filter;
|
|
@@ -252,10 +273,12 @@ export function registerCbmTools(pi: ExtensionAPI, cwd: string): boolean {
|
|
|
252
273
|
function_name: Type.String({ description: "Function name to trace" }),
|
|
253
274
|
direction: Type.Optional(Type.String({ description: "inbound, outbound, or both (default: both)" })),
|
|
254
275
|
depth: Type.Optional(Type.Number({ description: "Max traversal depth (default: 3)" })),
|
|
276
|
+
project_path: Type.Optional(Type.String({ description: "Absolute path to the repo to search. Defaults to root project." })),
|
|
255
277
|
}),
|
|
256
278
|
async execute(_toolCallId, params: any) {
|
|
257
279
|
try {
|
|
258
|
-
const
|
|
280
|
+
const targetCwd = params.project_path ?? cwd;
|
|
281
|
+
const project = await daemon.ensureIndexed(targetCwd);
|
|
259
282
|
const p: Record<string, unknown> = {
|
|
260
283
|
project,
|
|
261
284
|
function_name: params.function_name,
|
|
@@ -278,10 +301,12 @@ export function registerCbmTools(pi: ExtensionAPI, cwd: string): boolean {
|
|
|
278
301
|
parameters: Type.Object({
|
|
279
302
|
base_branch: Type.Optional(Type.String({ description: "Compare against this branch (default: main)" })),
|
|
280
303
|
since: Type.Optional(Type.String({ description: "Git ref or date (e.g. HEAD~5, v0.5.0)" })),
|
|
304
|
+
project_path: Type.Optional(Type.String({ description: "Absolute path to the repo to search. Defaults to root project." })),
|
|
281
305
|
}),
|
|
282
306
|
async execute(_toolCallId, params: any) {
|
|
283
307
|
try {
|
|
284
|
-
const
|
|
308
|
+
const targetCwd = params.project_path ?? cwd;
|
|
309
|
+
const project = await daemon.ensureIndexed(targetCwd);
|
|
285
310
|
const p: Record<string, unknown> = { project };
|
|
286
311
|
if (params.base_branch) p.base_branch = params.base_branch;
|
|
287
312
|
if (params.since) p.since = params.since;
|
|
@@ -300,10 +325,12 @@ export function registerCbmTools(pi: ExtensionAPI, cwd: string): boolean {
|
|
|
300
325
|
"Example: MATCH (f:Function)-[:CALLS]->(g:Function) WHERE f.name = 'main' RETURN g.name, g.file_path LIMIT 10",
|
|
301
326
|
parameters: Type.Object({
|
|
302
327
|
query: Type.String({ description: "Cypher query string" }),
|
|
328
|
+
project_path: Type.Optional(Type.String({ description: "Absolute path to the repo to search. Defaults to root project." })),
|
|
303
329
|
}),
|
|
304
330
|
async execute(_toolCallId, params: any) {
|
|
305
331
|
try {
|
|
306
|
-
const
|
|
332
|
+
const targetCwd = params.project_path ?? cwd;
|
|
333
|
+
const project = await daemon.ensureIndexed(targetCwd);
|
|
307
334
|
return ok(JSON.stringify(await daemon.callTool("query_graph", { project, query: params.query }), null, 2));
|
|
308
335
|
} catch (e: any) {
|
|
309
336
|
return fail(`cbm_query error: ${e.message}`);
|
|
@@ -315,10 +342,13 @@ export function registerCbmTools(pi: ExtensionAPI, cwd: string): boolean {
|
|
|
315
342
|
name: "cbm_architecture",
|
|
316
343
|
label: "CBM",
|
|
317
344
|
description: "Get high-level architecture overview of the indexed codebase — node/edge counts, schema, structure.",
|
|
318
|
-
parameters: Type.Object({
|
|
319
|
-
|
|
345
|
+
parameters: Type.Object({
|
|
346
|
+
project_path: Type.Optional(Type.String({ description: "Absolute path to the repo to inspect. Defaults to root project." })),
|
|
347
|
+
}),
|
|
348
|
+
async execute(_toolCallId, params: any) {
|
|
320
349
|
try {
|
|
321
|
-
const
|
|
350
|
+
const targetCwd = params.project_path ?? cwd;
|
|
351
|
+
const project = await daemon.ensureIndexed(targetCwd);
|
|
322
352
|
return ok(JSON.stringify(await daemon.callTool("get_architecture", { project }), null, 2));
|
|
323
353
|
} catch (e: any) {
|
|
324
354
|
return fail(`cbm_architecture error: ${e.message}`);
|
|
@@ -2,8 +2,12 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "fs";
|
|
|
2
2
|
import { tmpdir } from "os";
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
-
import { registerCommandHandlers } from "./command-handlers.js";
|
|
5
|
+
import { registerCommandHandlers, transitionToNextPhase } from "./command-handlers.js";
|
|
6
6
|
import { Orchestrator, type ActiveTask } from "./orchestrator.js";
|
|
7
|
+
import { getDefaultConfig } from "./config.js";
|
|
8
|
+
import * as machineModule from "./phases/machine.js";
|
|
9
|
+
import * as commandsModule from "./commands.js";
|
|
10
|
+
import * as stateModule from "./state.js";
|
|
7
11
|
|
|
8
12
|
vi.mock("./pp-menu.js", () => ({
|
|
9
13
|
showPpMenu: vi.fn().mockResolvedValue(undefined),
|
|
@@ -22,6 +26,7 @@ function makeTempDir(): string {
|
|
|
22
26
|
}
|
|
23
27
|
|
|
24
28
|
afterEach(() => {
|
|
29
|
+
vi.restoreAllMocks();
|
|
25
30
|
vi.mocked(showPpMenu).mockClear();
|
|
26
31
|
while (tempDirs.length > 0) {
|
|
27
32
|
const dir = tempDirs.pop();
|
|
@@ -48,26 +53,38 @@ function makePi() {
|
|
|
48
53
|
}
|
|
49
54
|
|
|
50
55
|
function makeConfig() {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
56
|
+
const config = getDefaultConfig();
|
|
57
|
+
config.general.autoCommit = false;
|
|
58
|
+
config.agents.subagents.presetGroups.codeReviewers = {
|
|
59
|
+
default: "regular",
|
|
60
|
+
presets: {
|
|
61
|
+
regular: {
|
|
62
|
+
enabled: true,
|
|
63
|
+
agents: {
|
|
64
|
+
variant1: { enabled: true, model: "x/1", thinking: "low" },
|
|
65
|
+
},
|
|
66
|
+
},
|
|
57
67
|
},
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
68
|
+
};
|
|
69
|
+
config.agents.subagents.presetGroups.brainstormReviewers = {
|
|
70
|
+
default: "regular",
|
|
71
|
+
presets: {
|
|
72
|
+
regular: {
|
|
73
|
+
enabled: true,
|
|
74
|
+
agents: {
|
|
75
|
+
variant1: { enabled: true, model: "x/1", thinking: "low" },
|
|
76
|
+
},
|
|
77
|
+
},
|
|
66
78
|
},
|
|
67
|
-
commands: { afterEdit: [], afterImplement: [] },
|
|
68
|
-
timeouts: { afterEdit: 1, afterImplement: 1, agentSpawn: 1, agentReadyPing: 1, lockStale: 1, lockUpdate: 1 },
|
|
69
|
-
autoCommit: false,
|
|
70
79
|
};
|
|
80
|
+
config.commands.afterEdit = {};
|
|
81
|
+
config.commands.afterImplement = {};
|
|
82
|
+
config.performance.commands.afterEdit = 1;
|
|
83
|
+
config.performance.commands.afterImplement = 1;
|
|
84
|
+
config.performance.internals.subagentStale = 1;
|
|
85
|
+
config.performance.internals.taskLockStale = 1;
|
|
86
|
+
config.performance.internals.taskLockRefresh = 1;
|
|
87
|
+
return config;
|
|
71
88
|
}
|
|
72
89
|
|
|
73
90
|
function makeActiveTask(taskDir: string): ActiveTask {
|
|
@@ -143,7 +160,7 @@ describe("pp command", () => {
|
|
|
143
160
|
const pp = pi._commands.get("pp");
|
|
144
161
|
expect(pp).toBeTruthy();
|
|
145
162
|
await pp!.handler(undefined, ctx);
|
|
146
|
-
expect(pi.sendUserMessage).toHaveBeenCalledWith("[PI-PI] User wants to continue. Run /pp when ready to advance.");
|
|
163
|
+
expect(pi.sendUserMessage).toHaveBeenCalledWith("[PI-PI] User wants to continue. Run /pp when ready to advance.", { deliverAs: "followUp" });
|
|
147
164
|
});
|
|
148
165
|
|
|
149
166
|
it("registers pp command", () => {
|
|
@@ -158,3 +175,357 @@ describe("pp command", () => {
|
|
|
158
175
|
expect(pi._commands.has("pp")).toBe(true);
|
|
159
176
|
});
|
|
160
177
|
});
|
|
178
|
+
|
|
179
|
+
describe("transitionToNextPhase", () => {
|
|
180
|
+
function makeTransitionCtx() {
|
|
181
|
+
return {
|
|
182
|
+
ui: {
|
|
183
|
+
notify: vi.fn(),
|
|
184
|
+
setStatus: vi.fn(),
|
|
185
|
+
},
|
|
186
|
+
isIdle: vi.fn().mockReturnValue(true),
|
|
187
|
+
compact: vi.fn(),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function makeTransitionTask(taskDir: string, phase: "brainstorm" | "implement" | "quick" = "brainstorm"): ActiveTask {
|
|
192
|
+
if (phase === "quick") {
|
|
193
|
+
return {
|
|
194
|
+
dir: taskDir,
|
|
195
|
+
type: "quick",
|
|
196
|
+
state: {
|
|
197
|
+
phase: "quick",
|
|
198
|
+
step: "llm_work",
|
|
199
|
+
reviewCycle: null,
|
|
200
|
+
reviewPass: 0,
|
|
201
|
+
reviewPassByKind: {},
|
|
202
|
+
from: null,
|
|
203
|
+
description: "Quick",
|
|
204
|
+
startedAt: new Date().toISOString(),
|
|
205
|
+
repos: [{ path: taskDir, isRoot: true }],
|
|
206
|
+
},
|
|
207
|
+
release: null,
|
|
208
|
+
taskId: "quick-1",
|
|
209
|
+
modifiedFiles: new Set(),
|
|
210
|
+
reviewPass: 0,
|
|
211
|
+
description: "Quick",
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
dir: taskDir,
|
|
217
|
+
type: "implement",
|
|
218
|
+
state: {
|
|
219
|
+
phase,
|
|
220
|
+
step: "llm_work",
|
|
221
|
+
reviewCycle: null,
|
|
222
|
+
reviewPass: 0,
|
|
223
|
+
reviewPassByKind: {},
|
|
224
|
+
from: null,
|
|
225
|
+
description: "Task",
|
|
226
|
+
startedAt: new Date().toISOString(),
|
|
227
|
+
repos: [{ path: taskDir, isRoot: true }],
|
|
228
|
+
},
|
|
229
|
+
release: null,
|
|
230
|
+
taskId: "impl-1",
|
|
231
|
+
modifiedFiles: new Set([join(taskDir, "src", "a.ts")]),
|
|
232
|
+
reviewPass: 0,
|
|
233
|
+
description: "Task",
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
it("blocks transition when exit criteria fail", async () => {
|
|
238
|
+
const pi = makePi();
|
|
239
|
+
const orchestrator = new Orchestrator(pi as any);
|
|
240
|
+
const taskDir = makeTempDir();
|
|
241
|
+
orchestrator.cwd = taskDir;
|
|
242
|
+
orchestrator.config = makeConfig() as any;
|
|
243
|
+
orchestrator.active = makeTransitionTask(taskDir, "brainstorm");
|
|
244
|
+
const ctx = makeTransitionCtx();
|
|
245
|
+
const validateSpy = vi.spyOn(machineModule, "validateExitCriteria").mockReturnValue({ ok: false, reason: "no artifacts" });
|
|
246
|
+
|
|
247
|
+
const result = await transitionToNextPhase(orchestrator, ctx);
|
|
248
|
+
|
|
249
|
+
expect(result).toEqual({ ok: false, error: "no artifacts" });
|
|
250
|
+
expect(orchestrator.active?.state.phase).toBe("brainstorm");
|
|
251
|
+
validateSpy.mockRestore();
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("runs afterImplement when transitioning from implement", async () => {
|
|
255
|
+
const pi = makePi();
|
|
256
|
+
const orchestrator = new Orchestrator(pi as any);
|
|
257
|
+
const taskDir = makeTempDir();
|
|
258
|
+
orchestrator.cwd = taskDir;
|
|
259
|
+
orchestrator.config = makeConfig() as any;
|
|
260
|
+
orchestrator.active = makeTransitionTask(taskDir, "implement");
|
|
261
|
+
const ctx = makeTransitionCtx();
|
|
262
|
+
const validateSpy = vi.spyOn(machineModule, "validateExitCriteria").mockReturnValue({ ok: true });
|
|
263
|
+
const groupSpy = vi.spyOn(await import("./repo-utils.js"), "groupFilesByRepo").mockReturnValue(new Map([[taskDir, [join(taskDir, "src", "a.ts")]]]) as any);
|
|
264
|
+
const runSpy = vi.spyOn(commandsModule, "runAfterImplement").mockReturnValue([{ ok: true, command: "npm test", output: "ok" }]);
|
|
265
|
+
const cleanupSpy = vi.spyOn(orchestrator, "cleanupActive").mockImplementation(async () => {
|
|
266
|
+
orchestrator.active = null;
|
|
267
|
+
});
|
|
268
|
+
const abortSpy = vi.spyOn(orchestrator, "abortAllSubagents").mockImplementation(() => undefined);
|
|
269
|
+
const saveSpy = vi.spyOn(stateModule, "saveTask").mockImplementation(() => undefined);
|
|
270
|
+
|
|
271
|
+
const result = await transitionToNextPhase(orchestrator, ctx);
|
|
272
|
+
|
|
273
|
+
expect(result.ok).toBe(true);
|
|
274
|
+
expect(runSpy).toHaveBeenCalledTimes(1);
|
|
275
|
+
expect(cleanupSpy).toHaveBeenCalledTimes(1);
|
|
276
|
+
expect(abortSpy).toHaveBeenCalledTimes(1);
|
|
277
|
+
expect(saveSpy).toHaveBeenCalled();
|
|
278
|
+
validateSpy.mockRestore();
|
|
279
|
+
groupSpy.mockRestore();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("runs afterImplement for root repo during implement to done transition", async () => {
|
|
283
|
+
const pi = makePi();
|
|
284
|
+
const orchestrator = new Orchestrator(pi as any);
|
|
285
|
+
const taskDir = makeTempDir();
|
|
286
|
+
orchestrator.cwd = taskDir;
|
|
287
|
+
orchestrator.config = makeConfig() as any;
|
|
288
|
+
orchestrator.active = makeTransitionTask(taskDir, "implement");
|
|
289
|
+
orchestrator.active.state.repos = [{ path: taskDir, isRoot: true }];
|
|
290
|
+
orchestrator.active.modifiedFiles = new Set([join(taskDir, "src", "root.ts")]);
|
|
291
|
+
const ctx = makeTransitionCtx();
|
|
292
|
+
vi.spyOn(machineModule, "validateExitCriteria").mockReturnValue({ ok: true });
|
|
293
|
+
vi.spyOn(await import("./repo-utils.js"), "groupFilesByRepo").mockReturnValue(
|
|
294
|
+
new Map([[taskDir, [join(taskDir, "src", "root.ts")]]]) as any,
|
|
295
|
+
);
|
|
296
|
+
const runSpy = vi.spyOn(commandsModule, "runAfterImplement").mockReturnValue([{ ok: true, command: "npm test", output: "ok" }]);
|
|
297
|
+
const cleanupSpy = vi.spyOn(orchestrator, "cleanupActive").mockImplementation(async () => {
|
|
298
|
+
orchestrator.active = null;
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
const result = await transitionToNextPhase(orchestrator, ctx);
|
|
302
|
+
|
|
303
|
+
expect(result.ok).toBe(true);
|
|
304
|
+
expect(runSpy).toHaveBeenCalledWith(
|
|
305
|
+
orchestrator.config.commands.afterImplement,
|
|
306
|
+
orchestrator.config.performance.commands.afterImplement,
|
|
307
|
+
orchestrator.cwd,
|
|
308
|
+
);
|
|
309
|
+
expect(cleanupSpy).toHaveBeenCalledTimes(1);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("clears reviewCycle and review bookkeeping on the done transition (Issue 4)", async () => {
|
|
313
|
+
const pi = makePi();
|
|
314
|
+
const orchestrator = new Orchestrator(pi as any);
|
|
315
|
+
const taskDir = makeTempDir();
|
|
316
|
+
orchestrator.cwd = taskDir;
|
|
317
|
+
orchestrator.config = makeConfig() as any;
|
|
318
|
+
orchestrator.active = makeTransitionTask(taskDir, "implement");
|
|
319
|
+
orchestrator.active.state.repos = [{ path: taskDir, isRoot: true }];
|
|
320
|
+
orchestrator.active.state.reviewCycle = { kind: "auto", step: "apply_feedback", pass: 2 };
|
|
321
|
+
orchestrator.active.state.reviewPass = 2;
|
|
322
|
+
orchestrator.active.state.reviewApprovedClean = true;
|
|
323
|
+
const ctx = makeTransitionCtx();
|
|
324
|
+
vi.spyOn(machineModule, "validateExitCriteria").mockReturnValue({ ok: true });
|
|
325
|
+
vi.spyOn(commandsModule, "runAfterImplement").mockReturnValue([{ ok: true, command: "npm test", output: "ok" }]);
|
|
326
|
+
let savedState: any = null;
|
|
327
|
+
vi.spyOn(orchestrator, "cleanupActive").mockImplementation(async () => {
|
|
328
|
+
savedState = orchestrator.active?.state;
|
|
329
|
+
orchestrator.active = null;
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
const result = await transitionToNextPhase(orchestrator, ctx);
|
|
333
|
+
|
|
334
|
+
expect(result.ok).toBe(true);
|
|
335
|
+
expect(savedState.phase).toBe("done");
|
|
336
|
+
expect(savedState.reviewCycle).toBeNull();
|
|
337
|
+
expect(savedState.reviewPass).toBe(0);
|
|
338
|
+
expect(savedState.reviewApprovedClean).toBe(false);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("runs afterImplement for extra repo when extra repo configs are enabled", async () => {
|
|
342
|
+
const pi = makePi();
|
|
343
|
+
const orchestrator = new Orchestrator(pi as any);
|
|
344
|
+
const taskDir = makeTempDir();
|
|
345
|
+
const extraRepo = join(taskDir, "extra");
|
|
346
|
+
mkdirSync(extraRepo, { recursive: true });
|
|
347
|
+
orchestrator.cwd = taskDir;
|
|
348
|
+
orchestrator.config = {
|
|
349
|
+
...makeConfig(),
|
|
350
|
+
general: { ...makeConfig().general, loadExtraRepoConfigs: true },
|
|
351
|
+
} as any;
|
|
352
|
+
orchestrator.active = makeTransitionTask(taskDir, "implement");
|
|
353
|
+
orchestrator.active.state.repos = [
|
|
354
|
+
{ path: taskDir, isRoot: true },
|
|
355
|
+
{ path: extraRepo, isRoot: false },
|
|
356
|
+
];
|
|
357
|
+
orchestrator.active.modifiedFiles = new Set([join(extraRepo, "src", "extra.ts")]);
|
|
358
|
+
const ctx = makeTransitionCtx();
|
|
359
|
+
vi.spyOn(machineModule, "validateExitCriteria").mockReturnValue({ ok: true });
|
|
360
|
+
vi.spyOn(await import("./repo-utils.js"), "groupFilesByRepo").mockReturnValue(
|
|
361
|
+
new Map([[extraRepo, [join(extraRepo, "src", "extra.ts")]]]) as any,
|
|
362
|
+
);
|
|
363
|
+
vi.spyOn(commandsModule, "loadRepoAfterImplementCommands").mockReturnValue({ "cmd-1": { run: "npm run lint" } });
|
|
364
|
+
const runSpy = vi.spyOn(commandsModule, "runAfterImplement").mockReturnValue([{ ok: true, command: "npm run lint", output: "ok" }]);
|
|
365
|
+
vi.spyOn(orchestrator, "cleanupActive").mockImplementation(async () => {
|
|
366
|
+
orchestrator.active = null;
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
const result = await transitionToNextPhase(orchestrator, ctx);
|
|
370
|
+
|
|
371
|
+
expect(result.ok).toBe(true);
|
|
372
|
+
expect(runSpy).toHaveBeenCalledWith({ "cmd-1": { run: "npm run lint" } }, orchestrator.config.performance.commands.afterImplement, extraRepo);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("blocks transition when afterImplement fails in extra repo", async () => {
|
|
376
|
+
const pi = makePi();
|
|
377
|
+
const orchestrator = new Orchestrator(pi as any);
|
|
378
|
+
const taskDir = makeTempDir();
|
|
379
|
+
const extraRepo = join(taskDir, "extra");
|
|
380
|
+
mkdirSync(extraRepo, { recursive: true });
|
|
381
|
+
orchestrator.cwd = taskDir;
|
|
382
|
+
orchestrator.config = {
|
|
383
|
+
...makeConfig(),
|
|
384
|
+
general: { ...makeConfig().general, loadExtraRepoConfigs: true },
|
|
385
|
+
} as any;
|
|
386
|
+
orchestrator.active = makeTransitionTask(taskDir, "implement");
|
|
387
|
+
orchestrator.active.state.repos = [
|
|
388
|
+
{ path: taskDir, isRoot: true },
|
|
389
|
+
{ path: extraRepo, isRoot: false },
|
|
390
|
+
];
|
|
391
|
+
orchestrator.active.modifiedFiles = new Set([join(extraRepo, "src", "extra.ts")]);
|
|
392
|
+
const ctx = makeTransitionCtx();
|
|
393
|
+
vi.spyOn(machineModule, "validateExitCriteria").mockReturnValue({ ok: true });
|
|
394
|
+
vi.spyOn(await import("./repo-utils.js"), "groupFilesByRepo").mockReturnValue(
|
|
395
|
+
new Map([[extraRepo, [join(extraRepo, "src", "extra.ts")]]]) as any,
|
|
396
|
+
);
|
|
397
|
+
vi.spyOn(commandsModule, "loadRepoAfterImplementCommands").mockReturnValue({ "cmd-1": { run: "npm run lint" } });
|
|
398
|
+
vi.spyOn(commandsModule, "runAfterImplement").mockReturnValue([{ ok: false, command: "npm run lint", output: "lint failed" }]);
|
|
399
|
+
const cleanupSpy = vi.spyOn(orchestrator, "cleanupActive");
|
|
400
|
+
|
|
401
|
+
const result = await transitionToNextPhase(orchestrator, ctx);
|
|
402
|
+
|
|
403
|
+
expect(result.ok).toBe(false);
|
|
404
|
+
expect(result.error).toContain("afterImplement commands failed");
|
|
405
|
+
expect(result.error).toContain("npm run lint: lint failed");
|
|
406
|
+
expect(cleanupSpy).not.toHaveBeenCalled();
|
|
407
|
+
expect(orchestrator.active?.state.phase).toBe("implement");
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it("skips afterImplement for extra repo when ignoreExtraRepoConfigs is true", async () => {
|
|
411
|
+
const pi = makePi();
|
|
412
|
+
const orchestrator = new Orchestrator(pi as any);
|
|
413
|
+
const taskDir = makeTempDir();
|
|
414
|
+
const extraRepo = join(taskDir, "extra");
|
|
415
|
+
mkdirSync(extraRepo, { recursive: true });
|
|
416
|
+
orchestrator.cwd = taskDir;
|
|
417
|
+
orchestrator.config = {
|
|
418
|
+
...makeConfig(),
|
|
419
|
+
general: { ...makeConfig().general, loadExtraRepoConfigs: false },
|
|
420
|
+
} as any;
|
|
421
|
+
orchestrator.active = makeTransitionTask(taskDir, "implement");
|
|
422
|
+
orchestrator.active.state.repos = [
|
|
423
|
+
{ path: taskDir, isRoot: true },
|
|
424
|
+
{ path: extraRepo, isRoot: false },
|
|
425
|
+
];
|
|
426
|
+
orchestrator.active.modifiedFiles = new Set([join(extraRepo, "src", "extra.ts")]);
|
|
427
|
+
const ctx = makeTransitionCtx();
|
|
428
|
+
vi.spyOn(machineModule, "validateExitCriteria").mockReturnValue({ ok: true });
|
|
429
|
+
vi.spyOn(await import("./repo-utils.js"), "groupFilesByRepo").mockReturnValue(
|
|
430
|
+
new Map([[extraRepo, [join(extraRepo, "src", "extra.ts")]]]) as any,
|
|
431
|
+
);
|
|
432
|
+
const loadExtraSpy = vi.spyOn(commandsModule, "loadRepoAfterImplementCommands");
|
|
433
|
+
const runSpy = vi.spyOn(commandsModule, "runAfterImplement").mockReturnValue([{ ok: true, command: "npm test", output: "ok" }]);
|
|
434
|
+
vi.spyOn(orchestrator, "cleanupActive").mockImplementation(async () => {
|
|
435
|
+
orchestrator.active = null;
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
const result = await transitionToNextPhase(orchestrator, ctx);
|
|
439
|
+
|
|
440
|
+
expect(result.ok).toBe(true);
|
|
441
|
+
expect(loadExtraSpy).not.toHaveBeenCalled();
|
|
442
|
+
expect(runSpy).not.toHaveBeenCalled();
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it("transition to done cleans up active task", async () => {
|
|
446
|
+
const pi = makePi();
|
|
447
|
+
const orchestrator = new Orchestrator(pi as any);
|
|
448
|
+
const taskDir = makeTempDir();
|
|
449
|
+
orchestrator.cwd = taskDir;
|
|
450
|
+
orchestrator.config = makeConfig() as any;
|
|
451
|
+
orchestrator.active = makeTransitionTask(taskDir, "implement");
|
|
452
|
+
const ctx = makeTransitionCtx();
|
|
453
|
+
vi.spyOn(machineModule, "validateExitCriteria").mockReturnValue({ ok: true });
|
|
454
|
+
vi.spyOn(await import("./repo-utils.js"), "groupFilesByRepo").mockReturnValue(new Map([[taskDir, [join(taskDir, "src", "a.ts")]]]) as any);
|
|
455
|
+
vi.spyOn(commandsModule, "runAfterImplement").mockReturnValue([{ ok: true, command: "npm test", output: "ok" }]);
|
|
456
|
+
const cleanupSpy = vi.spyOn(orchestrator, "cleanupActive").mockImplementation(async () => {
|
|
457
|
+
orchestrator.active = null;
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
const result = await transitionToNextPhase(orchestrator, ctx);
|
|
461
|
+
|
|
462
|
+
expect(result).toEqual({ ok: true });
|
|
463
|
+
expect(cleanupSpy).toHaveBeenCalledTimes(1);
|
|
464
|
+
// Done transition routes through the controller; idle ctx -> compacts now.
|
|
465
|
+
expect(ctx.compact).toHaveBeenCalledTimes(1);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it("transition to plan sets await_planners step", async () => {
|
|
469
|
+
const pi = makePi();
|
|
470
|
+
const orchestrator = new Orchestrator(pi as any);
|
|
471
|
+
const taskDir = makeTempDir();
|
|
472
|
+
orchestrator.cwd = taskDir;
|
|
473
|
+
orchestrator.config = makeConfig() as any;
|
|
474
|
+
orchestrator.active = makeTransitionTask(taskDir, "brainstorm");
|
|
475
|
+
const ctx = makeTransitionCtx();
|
|
476
|
+
const validateSpy = vi.spyOn(machineModule, "validateExitCriteria").mockReturnValue({ ok: true });
|
|
477
|
+
const compactSpy = vi.spyOn(orchestrator, "compactAndTransition").mockImplementation(() => undefined);
|
|
478
|
+
const saveSpy = vi.spyOn(stateModule, "saveTask").mockImplementation(() => undefined);
|
|
479
|
+
|
|
480
|
+
const result = await transitionToNextPhase(orchestrator, ctx, "regular");
|
|
481
|
+
|
|
482
|
+
expect(result).toEqual({ ok: true });
|
|
483
|
+
expect(orchestrator.active?.state.phase).toBe("plan");
|
|
484
|
+
expect(orchestrator.active?.state.step).toBe("await_planners");
|
|
485
|
+
expect(orchestrator.active?.state.activePlannerPreset).toBe("regular");
|
|
486
|
+
expect(compactSpy).toHaveBeenCalledTimes(1);
|
|
487
|
+
expect(saveSpy).toHaveBeenCalled();
|
|
488
|
+
validateSpy.mockRestore();
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it("skips afterImplement for quick tasks", async () => {
|
|
492
|
+
const pi = makePi();
|
|
493
|
+
const orchestrator = new Orchestrator(pi as any);
|
|
494
|
+
const taskDir = makeTempDir();
|
|
495
|
+
orchestrator.cwd = taskDir;
|
|
496
|
+
orchestrator.config = makeConfig() as any;
|
|
497
|
+
orchestrator.active = makeTransitionTask(taskDir, "quick");
|
|
498
|
+
const ctx = makeTransitionCtx();
|
|
499
|
+
vi.spyOn(machineModule, "validateExitCriteria").mockReturnValue({ ok: true });
|
|
500
|
+
const runSpy = vi.spyOn(commandsModule, "runAfterImplement");
|
|
501
|
+
const cleanupSpy = vi.spyOn(orchestrator, "cleanupActive").mockImplementation(async () => {
|
|
502
|
+
orchestrator.active = null;
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
const result = await transitionToNextPhase(orchestrator, ctx);
|
|
506
|
+
|
|
507
|
+
expect(result).toEqual({ ok: true });
|
|
508
|
+
expect(runSpy).not.toHaveBeenCalled();
|
|
509
|
+
expect(cleanupSpy).toHaveBeenCalledTimes(1);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it("skips afterImplement when phase is not implement", async () => {
|
|
513
|
+
const pi = makePi();
|
|
514
|
+
const orchestrator = new Orchestrator(pi as any);
|
|
515
|
+
const taskDir = makeTempDir();
|
|
516
|
+
orchestrator.cwd = taskDir;
|
|
517
|
+
orchestrator.config = makeConfig() as any;
|
|
518
|
+
orchestrator.active = makeTransitionTask(taskDir, "brainstorm");
|
|
519
|
+
const ctx = makeTransitionCtx();
|
|
520
|
+
vi.spyOn(machineModule, "validateExitCriteria").mockReturnValue({ ok: true });
|
|
521
|
+
const runSpy = vi.spyOn(commandsModule, "runAfterImplement");
|
|
522
|
+
const compactSpy = vi.spyOn(orchestrator, "compactAndTransition").mockImplementation(() => undefined);
|
|
523
|
+
|
|
524
|
+
const result = await transitionToNextPhase(orchestrator, ctx);
|
|
525
|
+
|
|
526
|
+
expect(result).toEqual({ ok: true });
|
|
527
|
+
expect(runSpy).not.toHaveBeenCalled();
|
|
528
|
+
expect(orchestrator.active?.state.phase).toBe("plan");
|
|
529
|
+
expect(compactSpy).toHaveBeenCalledTimes(1);
|
|
530
|
+
});
|
|
531
|
+
});
|