@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.
Files changed (76) hide show
  1. package/3p/pi-ask-user/index.ts +65 -49
  2. package/3p/pi-subagents/src/agent-manager.ts +8 -0
  3. package/3p/pi-subagents/src/agent-runner.ts +112 -19
  4. package/3p/pi-subagents/src/index.ts +3 -0
  5. package/extensions/orchestrator/agents/brainstorm-reviewer.ts +32 -19
  6. package/extensions/orchestrator/agents/code-reviewer.ts +31 -17
  7. package/extensions/orchestrator/agents/constraints.ts +55 -0
  8. package/extensions/orchestrator/agents/explore.ts +13 -13
  9. package/extensions/orchestrator/agents/librarian.ts +12 -9
  10. package/extensions/orchestrator/agents/plan-reviewer.ts +31 -19
  11. package/extensions/orchestrator/agents/planner.ts +28 -23
  12. package/extensions/orchestrator/agents/registry.ts +2 -1
  13. package/extensions/orchestrator/agents/repo-context.ts +11 -0
  14. package/extensions/orchestrator/agents/task.ts +14 -11
  15. package/extensions/orchestrator/agents/tool-routing.ts +17 -32
  16. package/extensions/orchestrator/ast-search.ts +2 -1
  17. package/extensions/orchestrator/cbm.test.ts +35 -0
  18. package/extensions/orchestrator/cbm.ts +43 -13
  19. package/extensions/orchestrator/command-handlers.test.ts +390 -19
  20. package/extensions/orchestrator/command-handlers.ts +73 -31
  21. package/extensions/orchestrator/commands.test.ts +255 -2
  22. package/extensions/orchestrator/commands.ts +108 -10
  23. package/extensions/orchestrator/config.test.ts +289 -68
  24. package/extensions/orchestrator/config.ts +630 -121
  25. package/extensions/orchestrator/context.test.ts +177 -10
  26. package/extensions/orchestrator/context.ts +115 -14
  27. package/extensions/orchestrator/custom-footer.ts +2 -1
  28. package/extensions/orchestrator/doctor.test.ts +559 -0
  29. package/extensions/orchestrator/doctor.ts +664 -0
  30. package/extensions/orchestrator/event-handlers.test.ts +84 -22
  31. package/extensions/orchestrator/event-handlers.ts +1191 -360
  32. package/extensions/orchestrator/exa.test.ts +46 -0
  33. package/extensions/orchestrator/exa.ts +16 -10
  34. package/extensions/orchestrator/flant-infra.test.ts +224 -0
  35. package/extensions/orchestrator/flant-infra.ts +110 -41
  36. package/extensions/orchestrator/index.ts +13 -2
  37. package/extensions/orchestrator/integration.test.ts +2866 -118
  38. package/extensions/orchestrator/log.test.ts +219 -0
  39. package/extensions/orchestrator/log.ts +153 -0
  40. package/extensions/orchestrator/model-registry.test.ts +238 -0
  41. package/extensions/orchestrator/model-registry.ts +282 -0
  42. package/extensions/orchestrator/model-version.test.ts +27 -0
  43. package/extensions/orchestrator/model-version.ts +19 -0
  44. package/extensions/orchestrator/orchestrator.test.ts +206 -56
  45. package/extensions/orchestrator/orchestrator.ts +295 -148
  46. package/extensions/orchestrator/phases/brainstorm.test.ts +10 -7
  47. package/extensions/orchestrator/phases/brainstorm.ts +41 -35
  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 -29
  52. package/extensions/orchestrator/phases/review-task.ts +3 -3
  53. package/extensions/orchestrator/phases/review.ts +38 -39
  54. package/extensions/orchestrator/phases/spawn-blocking.test.ts +69 -0
  55. package/extensions/orchestrator/phases/verdict.test.ts +139 -0
  56. package/extensions/orchestrator/phases/verdict.ts +82 -0
  57. package/extensions/orchestrator/plannotator.test.ts +85 -0
  58. package/extensions/orchestrator/plannotator.ts +24 -6
  59. package/extensions/orchestrator/pp-menu.test.ts +134 -0
  60. package/extensions/orchestrator/pp-menu.ts +2631 -392
  61. package/extensions/orchestrator/repo-utils.test.ts +151 -0
  62. package/extensions/orchestrator/repo-utils.ts +67 -0
  63. package/extensions/orchestrator/spawn-cleanup.test.ts +57 -0
  64. package/extensions/orchestrator/spawn-cleanup.ts +35 -0
  65. package/extensions/orchestrator/state.test.ts +76 -6
  66. package/extensions/orchestrator/state.ts +89 -26
  67. package/extensions/orchestrator/subagent-session-marker.test.ts +36 -0
  68. package/extensions/orchestrator/test-helpers.ts +217 -0
  69. package/extensions/orchestrator/tracer.test.ts +132 -0
  70. package/extensions/orchestrator/tracer.ts +127 -0
  71. package/extensions/orchestrator/transition-controller.test.ts +207 -0
  72. package/extensions/orchestrator/transition-controller.ts +259 -0
  73. package/extensions/orchestrator/usage-tracker.test.ts +435 -0
  74. package/extensions/orchestrator/usage-tracker.ts +23 -2
  75. package/extensions/orchestrator/validate-artifacts.test.ts +83 -1
  76. package/package.json +2 -1
@@ -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", "ignore"] });
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
- console.error(`[pi-pi] CBM daemon error: ${err.message}`);
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
- private cleanup(): void {
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 project = await daemon.ensureIndexed(cwd);
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 project = await daemon.ensureIndexed(cwd);
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 project = await daemon.ensureIndexed(cwd);
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 project = await daemon.ensureIndexed(cwd);
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 project = await daemon.ensureIndexed(cwd);
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
- async execute() {
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 project = await daemon.ensureIndexed(cwd);
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
- return {
52
- mainModel: {
53
- implement: { model: "a/b", thinking: "high" },
54
- debug: { model: "a/b", thinking: "high" },
55
- brainstorm: { model: "a/b", thinking: "high" },
56
- review: { model: "a/b", thinking: "high" },
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
- planners: {},
59
- planReviewers: {},
60
- codeReviewers: { variant1: { enabled: true, model: "x/1", thinking: "low" } },
61
- brainstormReviewers: { variant1: { enabled: true, model: "x/1", thinking: "low" } },
62
- agents: {
63
- explore: { model: "x/e", thinking: "low" },
64
- librarian: { model: "x/l", thinking: "medium" },
65
- task: { model: "x/t", thinking: "medium" },
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
+ });