@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
@@ -1,8 +1,8 @@
1
1
  import { afterEach, describe, expect, it } from "vitest";
2
2
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
3
- import { join } from "path";
3
+ import { dirname, join } from "path";
4
4
  import { tmpdir } from "os";
5
- import { deepMerge, loadConfig, validateConfig } from "./config.js";
5
+ import { deepMerge, getDefaultConfig, loadConfig, readRawConfig, removeConfigValue, resolvePreset, validateConfig, writeConfigValue } from "./config.js";
6
6
 
7
7
  const tempDirs: string[] = [];
8
8
 
@@ -46,63 +46,91 @@ describe("deepMerge", () => {
46
46
  });
47
47
 
48
48
  describe("validateConfig", () => {
49
- it("throws for empty main model string", () => {
50
- expect(() => validateConfig({ mainModel: { implement: { model: "" } } })).toThrow(
51
- "config.mainModel.implement.model must be non-empty",
52
- );
49
+ it("throws for empty orchestrator model string", () => {
50
+ expect(() =>
51
+ validateConfig({
52
+ agents: {
53
+ orchestrators: {
54
+ implement: { model: "", thinking: "high" },
55
+ },
56
+ },
57
+ }),
58
+ ).toThrow("config.agents.orchestrators.implement.model must be a non-empty string");
53
59
  });
54
60
 
55
- it("throws for enabled variant without model", () => {
56
- expect(() => validateConfig({ planners: { broken: { enabled: true, model: "" } } })).toThrow(
57
- "config.planners.broken is enabled but has no model",
58
- );
61
+ it("throws for invalid preset names", () => {
62
+ expect(() =>
63
+ validateConfig({
64
+ agents: {
65
+ subagents: {
66
+ presetGroups: {
67
+ planners: {
68
+ presets: {
69
+ "bad name": { agents: {} },
70
+ },
71
+ },
72
+ },
73
+ },
74
+ },
75
+ }),
76
+ ).toThrow("config.agents.subagents.presetGroups.planners.presets.bad name has invalid name");
59
77
  });
60
78
 
61
- it("throws when commands.afterEdit is not an array", () => {
62
- expect(() => validateConfig({ commands: { afterEdit: { run: "npm test" } } })).toThrow(
63
- "config.commands.afterEdit must be an array",
79
+ it("throws for invalid variant names", () => {
80
+ expect(() =>
81
+ validateConfig({
82
+ agents: {
83
+ subagents: {
84
+ presetGroups: {
85
+ planners: {
86
+ presets: {
87
+ regular: {
88
+ agents: {
89
+ "bad name": { enabled: true, model: "provider/model", thinking: "high" },
90
+ },
91
+ },
92
+ },
93
+ },
94
+ },
95
+ },
96
+ },
97
+ }),
98
+ ).toThrow("config.agents.subagents.presetGroups.planners.presets.regular.agents.bad name has invalid name");
99
+ });
100
+
101
+ it("throws when commands.afterEdit is not an object", () => {
102
+ expect(() => validateConfig({ commands: { afterEdit: [] } as any })).toThrow(
103
+ "config.commands.afterEdit must be an object",
64
104
  );
65
105
  });
66
106
 
67
107
  it("throws when commands.afterEdit entry has no run", () => {
68
- expect(() => validateConfig({ commands: { afterEdit: [{ glob: ["*.ts"] }] } })).toThrow(
69
- "config.commands.afterEdit[0] must have a 'run' field",
108
+ expect(() => validateConfig({ commands: { afterEdit: { cmd: { run: "", globs: ["*.ts"] } } } })).toThrow(
109
+ "config.commands.afterEdit.cmd.run must be a non-empty string",
70
110
  );
71
111
  });
72
112
 
73
113
  it("throws when commands.afterImplement entry has no run", () => {
74
- expect(() => validateConfig({ commands: { afterImplement: [{}] } })).toThrow(
75
- "config.commands.afterImplement[0] must have a 'run' field",
114
+ expect(() => validateConfig({ commands: { afterImplement: { cmd: { run: "" } } } })).toThrow(
115
+ "config.commands.afterImplement.cmd.run must be a non-empty string",
76
116
  );
77
117
  });
78
118
 
79
- it("throws for negative timeout values", () => {
80
- expect(() => validateConfig({ timeouts: { afterEdit: -1 } })).toThrow(
81
- "config.timeouts.afterEdit must be a non-negative number",
119
+ it("throws for invalid duration values", () => {
120
+ expect(() => validateConfig({ performance: { commands: { afterEdit: -1 } } })).toThrow(
121
+ "config.performance.commands.afterEdit must be a valid duration",
82
122
  );
83
123
  });
84
124
 
85
- it("accepts valid config", () => {
125
+ it("accepts valid partial config", () => {
86
126
  expect(() =>
87
127
  validateConfig({
88
- mainModel: {
89
- implement: { model: "provider/model-1" },
90
- debug: { model: "provider/model-2" },
91
- brainstorm: { model: "provider/model-3" },
92
- review: { model: "provider/model-4" },
93
- },
94
- planners: {
95
- good: { enabled: true, model: "provider/model-4" },
96
- disabled: { enabled: false },
97
- },
98
- brainstormReviewers: {
99
- good: { enabled: true, model: "provider/model-5" },
100
- },
128
+ general: { autoCommit: false },
101
129
  commands: {
102
- afterEdit: [{ run: "npm test", glob: ["*.ts"] }],
103
- afterImplement: [{ run: "npm run build" }],
130
+ afterEdit: { fmt: { run: "npm run fmt", globs: ["**/*.ts"] } },
131
+ afterImplement: { test: { run: "npm test" } },
104
132
  },
105
- timeouts: { afterEdit: 0, afterImplement: 1 },
133
+ performance: { commands: { afterEdit: "30s" } },
106
134
  }),
107
135
  ).not.toThrow();
108
136
  });
@@ -118,34 +146,52 @@ describe("loadConfig", () => {
118
146
  writeFileSync(
119
147
  configPath,
120
148
  JSON.stringify({
121
- mainModel: {
122
- implement: { model: "custom/implement", thinking: "low" },
123
- },
124
- planners: {
125
- opus: { enabled: false },
149
+ agents: {
150
+ orchestrators: {
151
+ implement: { model: "custom/implement", thinking: "low" },
152
+ },
153
+ subagents: {
154
+ presetGroups: {
155
+ planners: {
156
+ presets: {
157
+ regular: {
158
+ agents: {
159
+ opus: { enabled: false, model: "anthropic/claude-opus-latest", thinking: "high" },
160
+ },
161
+ },
162
+ },
163
+ },
164
+ },
165
+ },
126
166
  },
127
167
  commands: {
128
- afterImplement: [{ run: "npm run lint" }],
168
+ afterImplement: { lint: { run: "npm run lint" } },
129
169
  },
130
- timeouts: {
131
- afterEdit: 1234,
170
+ performance: {
171
+ commands: {
172
+ afterEdit: 1234,
173
+ },
174
+ },
175
+ general: {
176
+ autoCommit: false,
132
177
  },
133
- autoCommit: false,
134
178
  }),
135
179
  "utf-8",
136
180
  );
137
181
 
138
182
  const config = loadConfig(cwd, "/nonexistent/global/config.json");
139
183
 
140
- expect(config.mainModel.implement.model).toBe("custom/implement");
141
- expect(config.mainModel.debug.model).toBe("openai/gpt-5.4");
142
- expect(config.planners.opus.enabled).toBe(false);
143
- expect(config.planners.opus.model).toBe("anthropic/claude-opus-4-6");
144
- expect(config.commands.afterEdit).toEqual([]);
145
- expect(config.commands.afterImplement).toEqual([{ run: "npm run lint" }]);
146
- expect(config.timeouts.afterEdit).toBe(1234);
147
- expect(config.timeouts.afterImplement).toBe(300000);
148
- expect(config.autoCommit).toBe(false);
184
+ expect(config.agents.orchestrators.implement.model).toBe("custom/implement");
185
+ expect(config.agents.orchestrators.plan.model).toBe("anthropic/claude-opus-latest");
186
+ expect(config.agents.orchestrators.debug.model).toBe("openai/gpt-latest");
187
+ const planners = resolvePreset(config, "planners");
188
+ expect(planners.opus.enabled).toBe(false);
189
+ expect(planners.opus.model).toBe("anthropic/claude-opus-latest");
190
+ expect(config.commands.afterEdit).toEqual({});
191
+ expect(config.commands.afterImplement).toEqual({ lint: { run: "npm run lint" } });
192
+ expect(config.performance.commands.afterEdit).toBe(1234);
193
+ expect(config.performance.commands.afterImplement).toBe(300000);
194
+ expect(config.general.autoCommit).toBe(false);
149
195
  });
150
196
 
151
197
  it("creates default config when config.json does not exist", () => {
@@ -154,10 +200,9 @@ describe("loadConfig", () => {
154
200
 
155
201
  const config = loadConfig(cwd, "/nonexistent/global/config.json");
156
202
 
157
- expect(existsSync(configPath)).toBe(true);
158
- const written = JSON.parse(readFileSync(configPath, "utf-8"));
159
- expect(written.mainModel.implement.model).toBe("anthropic/claude-opus-4-6");
160
- expect(config.mainModel.implement.model).toBe("anthropic/claude-opus-4-6");
203
+ expect(existsSync(configPath)).toBe(false);
204
+ expect(config.agents.orchestrators.implement.model).toBe("anthropic/claude-opus-latest");
205
+ expect(config.agents.orchestrators.plan.model).toBe("anthropic/claude-opus-latest");
161
206
  });
162
207
 
163
208
  it("throws parse errors with config file path", () => {
@@ -177,9 +222,110 @@ describe("loadConfig", () => {
177
222
  const configPath = join(ppDir, "config.json");
178
223
 
179
224
  mkdirSync(ppDir, { recursive: true });
180
- writeFileSync(configPath, JSON.stringify({ timeouts: { afterEdit: -1 } }), "utf-8");
225
+ writeFileSync(configPath, JSON.stringify({ performance: { commands: { afterEdit: -1 } } }), "utf-8");
226
+
227
+ expect(() => loadConfig(cwd, "/nonexistent/global/config.json")).toThrow("config.performance.commands.afterEdit must be a valid duration");
228
+ });
229
+
230
+ it("allows project default preset that exists in global presets", () => {
231
+ const cwd = makeTempDir();
232
+ const ppDir = join(cwd, ".pp");
233
+ const projectConfigPath = join(ppDir, "config.json");
234
+ const globalConfigPath = join(cwd, "global-config.json");
235
+
236
+ mkdirSync(ppDir, { recursive: true });
237
+ writeFileSync(
238
+ globalConfigPath,
239
+ JSON.stringify({
240
+ agents: {
241
+ subagents: {
242
+ presetGroups: {
243
+ planners: {
244
+ presets: {
245
+ deep: {
246
+ enabled: true,
247
+ agents: {
248
+ custom: { enabled: true, model: "provider/model-deep", thinking: "high" },
249
+ },
250
+ },
251
+ },
252
+ },
253
+ },
254
+ },
255
+ },
256
+ }),
257
+ "utf-8",
258
+ );
259
+ writeFileSync(
260
+ projectConfigPath,
261
+ JSON.stringify({
262
+ agents: {
263
+ subagents: {
264
+ presetGroups: {
265
+ planners: {
266
+ default: "deep",
267
+ },
268
+ },
269
+ },
270
+ },
271
+ }),
272
+ "utf-8",
273
+ );
274
+
275
+ const config = loadConfig(cwd, globalConfigPath);
181
276
 
182
- expect(() => loadConfig(cwd, "/nonexistent/global/config.json")).toThrow("config.timeouts.afterEdit must be a non-negative number");
277
+ expect(config.agents.subagents.presetGroups.planners.default).toBe("deep");
278
+ expect(resolvePreset(config, "planners")).toEqual(config.agents.subagents.presetGroups.planners.presets.deep.agents);
279
+ });
280
+
281
+ it("throws when merged default preset points to missing preset", () => {
282
+ const cwd = makeTempDir();
283
+ const ppDir = join(cwd, ".pp");
284
+ const projectConfigPath = join(ppDir, "config.json");
285
+ const globalConfigPath = join(cwd, "global-config.json");
286
+
287
+ mkdirSync(ppDir, { recursive: true });
288
+ writeFileSync(
289
+ globalConfigPath,
290
+ JSON.stringify({
291
+ agents: {
292
+ subagents: {
293
+ presetGroups: {
294
+ planners: {
295
+ presets: {
296
+ regular: {
297
+ enabled: true,
298
+ agents: {
299
+ custom: { enabled: true, model: "provider/model-regular", thinking: "high" },
300
+ },
301
+ },
302
+ },
303
+ },
304
+ },
305
+ },
306
+ },
307
+ }),
308
+ "utf-8",
309
+ );
310
+ writeFileSync(
311
+ projectConfigPath,
312
+ JSON.stringify({
313
+ agents: {
314
+ subagents: {
315
+ presetGroups: {
316
+ planners: {
317
+ default: "missing",
318
+ },
319
+ },
320
+ },
321
+ },
322
+ }),
323
+ "utf-8",
324
+ );
325
+
326
+ expect(() => loadConfig(cwd, globalConfigPath)).toThrow(
327
+ 'config.agents.subagents.presetGroups.planners.default "missing" does not exist',
328
+ );
183
329
  });
184
330
  });
185
331
 
@@ -204,19 +350,94 @@ describe("config regressions", () => {
204
350
  expect(merged.items[0].a).toBe(1);
205
351
  });
206
352
 
207
- it("rejects empty agents model and accepts valid agents config", () => {
208
- expect(() => validateConfig({ agents: { explore: { model: "", thinking: "low" } } })).toThrow(
209
- "config.agents.explore.model must be a non-empty string",
210
- );
353
+ it("rejects empty simple subagent model and accepts valid simple subagent config", () => {
354
+ expect(() =>
355
+ validateConfig({
356
+ agents: {
357
+ subagents: {
358
+ simple: {
359
+ explore: { model: "", thinking: "low" },
360
+ },
361
+ },
362
+ },
363
+ }),
364
+ ).toThrow("config.agents.subagents.simple.explore.model must be a non-empty string");
211
365
 
212
366
  expect(() =>
213
367
  validateConfig({
214
368
  agents: {
215
- explore: { model: "google/gemini-3.1-flash", thinking: "low" },
216
- librarian: { model: "google/gemini-3.1-flash", thinking: "medium" },
217
- task: { model: "anthropic/claude-opus-4-6", thinking: "medium" },
369
+ subagents: {
370
+ simple: {
371
+ explore: { model: "google/gemini-flash-latest", thinking: "low" },
372
+ librarian: { model: "google/gemini-flash-latest", thinking: "medium" },
373
+ task: { model: "anthropic/claude-opus-latest", thinking: "medium" },
374
+ },
375
+ },
218
376
  },
219
377
  }),
220
378
  ).not.toThrow();
221
379
  });
222
380
  });
381
+
382
+ describe("config write helpers", () => {
383
+ it("getDefaultConfig returns deep clones", () => {
384
+ const first = getDefaultConfig();
385
+ const second = getDefaultConfig();
386
+
387
+ first.agents.orchestrators.implement.model = "custom/model";
388
+ first.commands.afterEdit["cmd-1"] = { run: "echo test", globs: ["*.ts"] };
389
+
390
+ expect(second.agents.orchestrators.implement.model).toBe("anthropic/claude-opus-latest");
391
+ expect(second.commands.afterEdit).toEqual({});
392
+ });
393
+
394
+ it("readRawConfig returns empty object when file does not exist", () => {
395
+ const filePath = join(makeTempDir(), ".pp", "config.json");
396
+ expect(readRawConfig(filePath)).toEqual({});
397
+ });
398
+
399
+ it("writeConfigValue creates parent dirs and writes nested key", () => {
400
+ const filePath = join(makeTempDir(), ".pp", "config.json");
401
+ writeConfigValue(filePath, ["agents", "subagents", "presetGroups", "planners", "presets", "regular", "agents", "custom"], {
402
+ enabled: true,
403
+ model: "x/y",
404
+ thinking: "high",
405
+ });
406
+ const raw = JSON.parse(readFileSync(filePath, "utf-8"));
407
+ expect(raw.agents.subagents.presetGroups.planners.presets.regular.agents.custom).toEqual({
408
+ enabled: true,
409
+ model: "x/y",
410
+ thinking: "high",
411
+ });
412
+ });
413
+
414
+ it("removeConfigValue removes nested key, prunes empty parents, and keeps file", () => {
415
+ const filePath = join(makeTempDir(), ".pp", "config.json");
416
+ mkdirSync(dirname(filePath), { recursive: true });
417
+ writeFileSync(
418
+ filePath,
419
+ JSON.stringify({
420
+ agents: {
421
+ subagents: {
422
+ presetGroups: {
423
+ planners: {
424
+ presets: {
425
+ regular: {
426
+ agents: {
427
+ a: { enabled: true },
428
+ },
429
+ },
430
+ },
431
+ },
432
+ },
433
+ },
434
+ },
435
+ }),
436
+ "utf-8",
437
+ );
438
+ removeConfigValue(filePath, ["agents", "subagents", "presetGroups", "planners", "presets", "regular", "agents", "a"]);
439
+ const raw = JSON.parse(readFileSync(filePath, "utf-8"));
440
+ expect(raw).toEqual({});
441
+ expect(existsSync(filePath)).toBe(true);
442
+ });
443
+ });