@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
@@ -0,0 +1,282 @@
1
+ import { compareModelVersion } from "./model-version.js";
2
+
3
+ export type Vendor = "anthropic" | "openai" | "google" | "deepseek" | "xai" | "qwen" | "unknown";
4
+ export type Family = "opus" | "sonnet" | "haiku" | "gpt" | "gpt-mini" | "gemini-pro" | "gemini-flash" | "deepseek" | "grok" | "qwen" | "unknown";
5
+ export type Tier = "stupid" | "regular" | "smart" | "xsmart" | "unknown";
6
+
7
+ export interface ModelInfo {
8
+ vendor: Vendor;
9
+ family: Family;
10
+ tier: Tier;
11
+ displayName: string;
12
+ }
13
+
14
+ type ProviderPrefix = "anthropic" | "openai" | "google" | "deepseek" | "x-ai" | "qwen" | "pp-flant-anthropic" | "pp-flant-openai";
15
+ type KnownVendor = "anthropic" | "openai" | "google" | "deepseek" | "xai" | "qwen";
16
+ type KnownFamily = "opus" | "sonnet" | "haiku" | "gpt" | "gpt-mini" | "gemini-pro" | "gemini-flash" | "deepseek" | "grok" | "qwen";
17
+ type KnownTier = "stupid" | "regular" | "smart" | "xsmart";
18
+
19
+ export interface ModelFamilyDefinition {
20
+ vendor: KnownVendor;
21
+ family: KnownFamily;
22
+ tier: KnownTier;
23
+ displayName: string;
24
+ patterns: RegExp[];
25
+ aliasTemplate: string;
26
+ providers: ProviderPrefix[];
27
+ }
28
+
29
+ export interface ModelFamilyInfo {
30
+ vendor: KnownVendor;
31
+ family: KnownFamily;
32
+ tier: KnownTier;
33
+ displayName: string;
34
+ aliasTemplate: string;
35
+ aliases: string[];
36
+ }
37
+
38
+ export const MODEL_FAMILIES: ModelFamilyDefinition[] = [
39
+ {
40
+ vendor: "anthropic",
41
+ family: "opus",
42
+ tier: "smart",
43
+ displayName: "Claude Opus",
44
+ patterns: [/^(anthropic|pp-flant-anthropic)\/claude-opus-[a-z0-9.-]+$/],
45
+ aliasTemplate: "claude-opus-latest",
46
+ providers: ["anthropic", "pp-flant-anthropic"],
47
+ },
48
+ {
49
+ vendor: "anthropic",
50
+ family: "sonnet",
51
+ tier: "regular",
52
+ displayName: "Claude Sonnet",
53
+ patterns: [/^(anthropic|pp-flant-anthropic)\/claude-sonnet-[a-z0-9.-]+$/],
54
+ aliasTemplate: "claude-sonnet-latest",
55
+ providers: ["anthropic", "pp-flant-anthropic"],
56
+ },
57
+ {
58
+ vendor: "anthropic",
59
+ family: "haiku",
60
+ tier: "stupid",
61
+ displayName: "Claude Haiku",
62
+ patterns: [/^(anthropic|pp-flant-anthropic)\/claude-haiku-[a-z0-9.-]+$/],
63
+ aliasTemplate: "claude-haiku-latest",
64
+ providers: ["anthropic", "pp-flant-anthropic"],
65
+ },
66
+ {
67
+ vendor: "openai",
68
+ family: "gpt",
69
+ tier: "regular",
70
+ displayName: "GPT",
71
+ patterns: [/^(openai|pp-flant-openai)\/gpt-(?!mini-)(?!.*-mini(?:$|[-.]))[a-z0-9.-]+$/],
72
+ aliasTemplate: "gpt-latest",
73
+ providers: ["openai", "pp-flant-openai"],
74
+ },
75
+ {
76
+ vendor: "openai",
77
+ family: "gpt-mini",
78
+ tier: "stupid",
79
+ displayName: "GPT Mini",
80
+ patterns: [
81
+ /^(openai|pp-flant-openai)\/gpt-mini-[a-z0-9.-]+$/,
82
+ /^(openai|pp-flant-openai)\/gpt-[a-z0-9.]+-mini(?:-[a-z0-9.-]+)?$/,
83
+ ],
84
+ aliasTemplate: "gpt-mini-latest",
85
+ providers: ["openai", "pp-flant-openai"],
86
+ },
87
+ {
88
+ vendor: "google",
89
+ family: "gemini-pro",
90
+ tier: "regular",
91
+ displayName: "Gemini Pro",
92
+ patterns: [
93
+ /^(google|pp-flant-openai)\/gemini-pro-[a-z0-9.-]+$/,
94
+ /^(google|pp-flant-openai)\/gemini-[a-z0-9.-]+-pro(?:-[a-z0-9.-]+)?$/,
95
+ ],
96
+ aliasTemplate: "gemini-pro-latest",
97
+ providers: ["google", "pp-flant-openai"],
98
+ },
99
+ {
100
+ vendor: "google",
101
+ family: "gemini-flash",
102
+ tier: "stupid",
103
+ displayName: "Gemini Flash",
104
+ patterns: [
105
+ /^(google|pp-flant-openai)\/gemini-flash-[a-z0-9.-]+$/,
106
+ /^(google|pp-flant-openai)\/gemini-[a-z0-9.-]+-flash(?:-[a-z0-9.-]+)?$/,
107
+ ],
108
+ aliasTemplate: "gemini-flash-latest",
109
+ providers: ["google", "pp-flant-openai"],
110
+ },
111
+ {
112
+ vendor: "deepseek",
113
+ family: "deepseek",
114
+ tier: "regular",
115
+ displayName: "DeepSeek",
116
+ patterns: [/^(deepseek|pp-flant-openai)\/deepseek-/],
117
+ aliasTemplate: "deepseek-latest",
118
+ providers: ["deepseek", "pp-flant-openai"],
119
+ },
120
+ {
121
+ vendor: "xai",
122
+ family: "grok",
123
+ tier: "regular",
124
+ displayName: "Grok",
125
+ patterns: [/^(x-ai|pp-flant-openai)\/grok-/],
126
+ aliasTemplate: "grok-latest",
127
+ providers: ["x-ai", "pp-flant-openai"],
128
+ },
129
+ {
130
+ vendor: "qwen",
131
+ family: "qwen",
132
+ tier: "regular",
133
+ displayName: "Qwen",
134
+ patterns: [/^(qwen|pp-flant-openai)\/qwen-/],
135
+ aliasTemplate: "qwen-latest",
136
+ providers: ["qwen", "pp-flant-openai"],
137
+ },
138
+ ];
139
+
140
+ const DEFAULT_ALIAS_MAP: Record<string, string> = {
141
+ "anthropic/claude-opus-latest": "anthropic/claude-opus-4-6",
142
+ "anthropic/claude-sonnet-latest": "anthropic/claude-sonnet-4-6",
143
+ "anthropic/claude-haiku-latest": "anthropic/claude-haiku-3-5",
144
+ "openai/gpt-latest": "openai/gpt-5.4",
145
+ "openai/gpt-mini-latest": "openai/gpt-5.4-mini",
146
+ "google/gemini-pro-latest": "google/gemini-3.1-pro",
147
+ "google/gemini-flash-latest": "google/gemini-3.1-flash",
148
+ "deepseek/deepseek-latest": "deepseek/deepseek-v3",
149
+ "x-ai/grok-latest": "x-ai/grok-4",
150
+ "qwen/qwen-latest": "qwen/qwen3-coder",
151
+ "pp-flant-anthropic/claude-opus-latest": "pp-flant-anthropic/claude-opus-4-6",
152
+ "pp-flant-anthropic/claude-sonnet-latest": "pp-flant-anthropic/claude-sonnet-4-6",
153
+ "pp-flant-anthropic/claude-haiku-latest": "pp-flant-anthropic/claude-haiku-3-5",
154
+ "pp-flant-openai/gpt-latest": "pp-flant-openai/gpt-5.4",
155
+ "pp-flant-openai/gpt-mini-latest": "pp-flant-openai/gpt-5.4-mini",
156
+ "pp-flant-openai/gemini-pro-latest": "pp-flant-openai/gemini-3.1-pro",
157
+ "pp-flant-openai/gemini-flash-latest": "pp-flant-openai/gemini-3.1-flash",
158
+ "pp-flant-openai/deepseek-latest": "pp-flant-openai/deepseek-v3",
159
+ "pp-flant-openai/grok-latest": "pp-flant-openai/grok-4",
160
+ "pp-flant-openai/qwen-latest": "pp-flant-openai/qwen3-coder",
161
+ };
162
+
163
+ let aliasMap: Record<string, string> = { ...DEFAULT_ALIAS_MAP };
164
+
165
+ function pickLatest(models: string[]): string | null {
166
+ if (models.length === 0) return null;
167
+ return models
168
+ .slice()
169
+ .sort((a, b) => compareModelVersion(b, a))[0] ?? null;
170
+ }
171
+
172
+ function toAlias(provider: ProviderPrefix, aliasTemplate: string): string {
173
+ return `${provider}/${aliasTemplate}`;
174
+ }
175
+
176
+ function normalizeAvailableModelIds(modelId: string): string[] {
177
+ const value = modelId.trim();
178
+ if (!value) return [];
179
+ if (value.includes("/")) return [value];
180
+ if (value.startsWith("claude-")) return [`pp-flant-anthropic/${value}`];
181
+ if (
182
+ value.startsWith("gpt-") ||
183
+ value.startsWith("gemini-") ||
184
+ value.startsWith("deepseek-") ||
185
+ value.startsWith("grok-") ||
186
+ value.startsWith("qwen")
187
+ ) {
188
+ return [`pp-flant-openai/${value}`];
189
+ }
190
+ return [];
191
+ }
192
+
193
+ function collectNormalizedModels(availableModels: string[]): string[] {
194
+ const out: string[] = [];
195
+ const seen = new Set<string>();
196
+ for (const modelId of availableModels) {
197
+ for (const normalized of normalizeAvailableModelIds(modelId)) {
198
+ if (seen.has(normalized)) continue;
199
+ seen.add(normalized);
200
+ out.push(normalized);
201
+ }
202
+ }
203
+ return out;
204
+ }
205
+
206
+ function findFamily(modelId: string): ModelFamilyDefinition | null {
207
+ for (const family of MODEL_FAMILIES) {
208
+ for (const pattern of family.patterns) {
209
+ if (pattern.test(modelId)) return family;
210
+ }
211
+ }
212
+ return null;
213
+ }
214
+
215
+ import { getLogger } from "./log.js";
216
+
217
+ export function resolveModel(aliasOrId: string): string {
218
+ const resolved = aliasMap[aliasOrId] ?? aliasOrId;
219
+ if (resolved !== aliasOrId) {
220
+ getLogger().debug({ s: "model", alias: aliasOrId, resolved }, "resolved model alias");
221
+ }
222
+ return resolved;
223
+ }
224
+
225
+ export function getModelInfo(modelId: string): ModelInfo {
226
+ const resolved = resolveModel(modelId);
227
+ const family = findFamily(resolved) ?? findFamily(modelId);
228
+ if (!family) {
229
+ return {
230
+ vendor: "unknown",
231
+ family: "unknown",
232
+ tier: "unknown",
233
+ displayName: modelId,
234
+ };
235
+ }
236
+ return {
237
+ vendor: family.vendor,
238
+ family: family.family,
239
+ tier: family.tier,
240
+ displayName: family.displayName,
241
+ };
242
+ }
243
+
244
+ export function updateRegistryFromAvailableModels(availableModels: string[]): void {
245
+ const log = getLogger();
246
+ const normalizedModels = collectNormalizedModels(availableModels).filter((modelId) => !modelId.endsWith("-latest"));
247
+ const nextAliasMap: Record<string, string> = { ...DEFAULT_ALIAS_MAP };
248
+
249
+ let updatedCount = 0;
250
+ for (const family of MODEL_FAMILIES) {
251
+ for (const provider of family.providers) {
252
+ const alias = toAlias(provider, family.aliasTemplate);
253
+ const candidates = normalizedModels.filter((modelId) => {
254
+ if (!modelId.startsWith(`${provider}/`)) return false;
255
+ return family.patterns.some((pattern) => pattern.test(modelId));
256
+ });
257
+ const latest = pickLatest(candidates);
258
+ if (latest) {
259
+ nextAliasMap[alias] = latest;
260
+ if (latest !== DEFAULT_ALIAS_MAP[alias]) updatedCount++;
261
+ }
262
+ }
263
+ }
264
+
265
+ aliasMap = nextAliasMap;
266
+ log.debug({ s: "model", availableCount: availableModels.length, normalizedCount: normalizedModels.length, updatedAliases: updatedCount }, "registry updated from available models");
267
+ }
268
+
269
+ export function getAllAliases(): Record<string, string> {
270
+ return { ...aliasMap };
271
+ }
272
+
273
+ export function getModelFamilies(): ModelFamilyInfo[] {
274
+ return MODEL_FAMILIES.map((family) => ({
275
+ vendor: family.vendor,
276
+ family: family.family,
277
+ tier: family.tier,
278
+ displayName: family.displayName,
279
+ aliasTemplate: family.aliasTemplate,
280
+ aliases: family.providers.map((provider) => toAlias(provider, family.aliasTemplate)),
281
+ }));
282
+ }
@@ -0,0 +1,27 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { compareModelVersion } from "./model-version.js";
3
+
4
+ function latest(models: string[]): string {
5
+ return models.slice().sort((a, b) => compareModelVersion(b, a))[0];
6
+ }
7
+
8
+ describe("compareModelVersion", () => {
9
+ it("ranks a higher semantic minor above a dated snapshot in the same family", () => {
10
+ expect(compareModelVersion("claude-opus-4-6", "claude-opus-4-20250101")).toBeGreaterThan(0);
11
+ expect(latest(["claude-opus-4-6", "claude-opus-4-20250101", "claude-opus-4-5"])).toBe("claude-opus-4-6");
12
+ });
13
+
14
+ it("orders ordinary numeric versions normally", () => {
15
+ expect(compareModelVersion("gpt-4-2", "gpt-4-10")).toBeLessThan(0);
16
+ expect(latest(["gpt-4-2", "gpt-4-10", "gpt-4-1"])).toBe("gpt-4-10");
17
+ });
18
+
19
+ it("orders two dated snapshots by date", () => {
20
+ expect(compareModelVersion("claude-opus-4-20250201", "claude-opus-4-20250101")).toBeGreaterThan(0);
21
+ expect(latest(["claude-opus-4-20250101", "claude-opus-4-20250201"])).toBe("claude-opus-4-20250201");
22
+ });
23
+
24
+ it("falls back to lexical comparison when numeric parts are equal", () => {
25
+ expect(compareModelVersion("model-a", "model-b")).toBeLessThan(0);
26
+ });
27
+ });
@@ -0,0 +1,19 @@
1
+ function isDateSegment(n: number): boolean {
2
+ return n >= 19700101 && n <= 99991231 && String(n).length === 8;
3
+ }
4
+
5
+ export function compareModelVersion(a: string, b: string): number {
6
+ const aParts = (a.match(/\d+/g) ?? []).map(Number);
7
+ const bParts = (b.match(/\d+/g) ?? []).map(Number);
8
+ const len = Math.max(aParts.length, bParts.length);
9
+ for (let i = 0; i < len; i++) {
10
+ const ai = aParts[i] ?? 0;
11
+ const bi = bParts[i] ?? 0;
12
+ if (ai === bi) continue;
13
+ const aDate = isDateSegment(ai);
14
+ const bDate = isDateSegment(bi);
15
+ if (aDate !== bDate) return aDate ? -1 : 1;
16
+ return ai - bi;
17
+ }
18
+ return a.localeCompare(b);
19
+ }
@@ -2,7 +2,8 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
2
2
  import { tmpdir } from "os";
3
3
  import { join } from "path";
4
4
  import { afterEach, describe, expect, it, vi } from "vitest";
5
- import { Orchestrator, deepReviewConfig, ensureGitignore, type ActiveTask } from "./orchestrator.js";
5
+ import { Orchestrator, ensureGitignore, type ActiveTask } from "./orchestrator.js";
6
+ import { getDefaultConfig, resolvePreset } from "./config.js";
6
7
 
7
8
  const tempDirs: string[] = [];
8
9
 
@@ -84,6 +85,76 @@ describe("Orchestrator.truncateResult", () => {
84
85
  });
85
86
  });
86
87
 
88
+ describe("Orchestrator.safeSendUserMessage", () => {
89
+ it("notifies the user once after exhausting retries, not during them", async () => {
90
+ vi.useFakeTimers();
91
+ const notify = vi.fn();
92
+ const pi = makePi({
93
+ sendUserMessage: vi.fn(() => {
94
+ throw new Error("not ready");
95
+ }),
96
+ });
97
+ const orchestrator = new Orchestrator(pi);
98
+ orchestrator.lastCtx = { ui: { notify } };
99
+
100
+ orchestrator.safeSendUserMessage("[PI-PI] Entered plan phase. Begin working.");
101
+ await vi.advanceTimersByTimeAsync(5000);
102
+ expect(notify).not.toHaveBeenCalled();
103
+
104
+ await vi.advanceTimersByTimeAsync(30000);
105
+ expect(notify).toHaveBeenCalledTimes(1);
106
+ expect(notify.mock.calls[0][1]).toBe("error");
107
+ vi.useRealTimers();
108
+ });
109
+
110
+ it("queues as a follow-up so it never throws 'Agent is already processing'", () => {
111
+ // Regression: during an autonomous phase transition pp_phase_complete is
112
+ // still in-flight when the post-compaction 'Begin working' message is sent.
113
+ // Without deliverAs the runtime throws and the implement phase never starts.
114
+ const sendUserMessage = vi.fn();
115
+ const orchestrator = new Orchestrator(makePi({ sendUserMessage }));
116
+
117
+ orchestrator.safeSendUserMessage("[PI-PI] Entered implement phase. Begin working.");
118
+
119
+ expect(sendUserMessage).toHaveBeenCalledWith(
120
+ "[PI-PI] Entered implement phase. Begin working.",
121
+ { deliverAs: "followUp" },
122
+ );
123
+ });
124
+ });
125
+
126
+ describe("Orchestrator.switchModel thinking level", () => {
127
+ function makeCtx() {
128
+ return {
129
+ modelRegistry: {
130
+ getAvailable: () => [{ provider: "anthropic", id: "claude-test-model" }],
131
+ },
132
+ } as any;
133
+ }
134
+
135
+ it("honors xhigh on the main agent (no downgrade)", async () => {
136
+ const pi = makePi({ setModel: vi.fn().mockResolvedValue(true) });
137
+ const orchestrator = new Orchestrator(pi);
138
+ const ok = await orchestrator.switchModel(makeCtx(), "anthropic/claude-test-model", "xhigh");
139
+ expect(ok).toBe(true);
140
+ expect(pi.setThinkingLevel).toHaveBeenCalledWith("xhigh");
141
+ });
142
+
143
+ it("honors minimal on the main agent", async () => {
144
+ const pi = makePi({ setModel: vi.fn().mockResolvedValue(true) });
145
+ const orchestrator = new Orchestrator(pi);
146
+ await orchestrator.switchModel(makeCtx(), "anthropic/claude-test-model", "minimal");
147
+ expect(pi.setThinkingLevel).toHaveBeenCalledWith("minimal");
148
+ });
149
+
150
+ it("falls back to high for an invalid thinking level", async () => {
151
+ const pi = makePi({ setModel: vi.fn().mockResolvedValue(true) });
152
+ const orchestrator = new Orchestrator(pi);
153
+ await orchestrator.switchModel(makeCtx(), "anthropic/claude-test-model", "bogus");
154
+ expect(pi.setThinkingLevel).toHaveBeenCalledWith("high");
155
+ });
156
+ });
157
+
87
158
  describe("Orchestrator.taskIdFromDir", () => {
88
159
  it("extracts numeric prefix from directory basename", () => {
89
160
  const orchestrator = new Orchestrator(makePi());
@@ -91,65 +162,75 @@ describe("Orchestrator.taskIdFromDir", () => {
91
162
  });
92
163
  });
93
164
 
94
- describe("deepReviewConfig", () => {
95
- it("upgrades reviewer thinking levels", () => {
96
- const config = {
97
- mainModel: {
98
- implement: { model: "a/impl", thinking: "high" },
99
- debug: { model: "a/debug", thinking: "high" },
100
- brainstorm: { model: "a/brain", thinking: "high" },
101
- review: { model: "a/review", thinking: "high" },
165
+ describe("resolvePreset", () => {
166
+ it("resolves deep reviewer preset", () => {
167
+ const config = getDefaultConfig();
168
+ config.agents.subagents.presetGroups.planReviewers = {
169
+ default: "regular",
170
+ presets: {
171
+ regular: {
172
+ enabled: true,
173
+ agents: {
174
+ low: { enabled: true, model: "x/p1", thinking: "low" },
175
+ },
176
+ },
177
+ deep: {
178
+ enabled: true,
179
+ agents: {
180
+ low: { enabled: true, model: "x/p1", thinking: "xhigh" },
181
+ },
182
+ },
102
183
  },
103
- planners: {},
104
- planReviewers: {
105
- low: { enabled: true, model: "x/p1", thinking: "low" },
106
- medium: { enabled: true, model: "x/p2", thinking: "medium" },
107
- high: { enabled: true, model: "x/p3", thinking: "high" },
108
- other: { enabled: true, model: "x/p4", thinking: "off" },
109
- },
110
- brainstormReviewers: {
111
- low: { enabled: true, model: "x/b1", thinking: "low" },
112
- medium: { enabled: true, model: "x/b2", thinking: "medium" },
113
- high: { enabled: true, model: "x/b3", thinking: "high" },
114
- other: { enabled: true, model: "x/b4", thinking: "off" },
115
- },
116
- codeReviewers: {
117
- low: { enabled: true, model: "x/1", thinking: "low" },
118
- medium: { enabled: true, model: "x/2", thinking: "medium" },
119
- high: { enabled: true, model: "x/3", thinking: "high" },
120
- other: { enabled: true, model: "x/4", thinking: "off" },
121
- },
122
- agents: {
123
- explore: { model: "x/e", thinking: "low" },
124
- librarian: { model: "x/l", thinking: "medium" },
125
- task: { model: "x/t", thinking: "medium" },
184
+ };
185
+ config.agents.subagents.presetGroups.brainstormReviewers = {
186
+ default: "regular",
187
+ presets: {
188
+ regular: {
189
+ enabled: true,
190
+ agents: {
191
+ low: { enabled: true, model: "x/b1", thinking: "low" },
192
+ },
193
+ },
194
+ deep: {
195
+ enabled: true,
196
+ agents: {
197
+ low: { enabled: true, model: "x/b1", thinking: "xhigh" },
198
+ },
199
+ },
126
200
  },
127
- commands: { afterEdit: [], afterImplement: [] },
128
- timeouts: {
129
- afterEdit: 1,
130
- afterImplement: 1,
131
- agentSpawn: 1,
132
- agentReadyPing: 1,
133
- lockStale: 1,
134
- lockUpdate: 1,
201
+ };
202
+ config.agents.subagents.presetGroups.codeReviewers = {
203
+ default: "regular",
204
+ presets: {
205
+ regular: {
206
+ enabled: true,
207
+ agents: {
208
+ low: { enabled: true, model: "x/1", thinking: "low" },
209
+ medium: { enabled: true, model: "x/2", thinking: "medium" },
210
+ high: { enabled: true, model: "x/3", thinking: "high" },
211
+ other: { enabled: true, model: "x/4", thinking: "off" },
212
+ },
213
+ },
214
+ deep: {
215
+ enabled: true,
216
+ agents: {
217
+ low: { enabled: true, model: "x/1", thinking: "xhigh" },
218
+ medium: { enabled: true, model: "x/2", thinking: "xhigh" },
219
+ high: { enabled: true, model: "x/3", thinking: "xhigh" },
220
+ other: { enabled: true, model: "x/4", thinking: "xhigh" },
221
+ },
222
+ },
135
223
  },
136
- autoCommit: true,
137
224
  };
225
+ config.commands.afterEdit = {};
226
+ config.commands.afterImplement = {};
227
+
228
+ const upgraded = resolvePreset(config as any, "codeReviewers", "deep");
138
229
 
139
- const upgraded = deepReviewConfig(config as any);
140
-
141
- expect(upgraded.codeReviewers.low.thinking).toBe("medium");
142
- expect(upgraded.codeReviewers.medium.thinking).toBe("high");
143
- expect(upgraded.codeReviewers.high.thinking).toBe("xhigh");
144
- expect(upgraded.codeReviewers.other.thinking).toBe("high");
145
- expect(upgraded.brainstormReviewers.low.thinking).toBe("medium");
146
- expect(upgraded.brainstormReviewers.medium.thinking).toBe("high");
147
- expect(upgraded.brainstormReviewers.high.thinking).toBe("xhigh");
148
- expect(upgraded.brainstormReviewers.other.thinking).toBe("high");
149
- expect(upgraded.planReviewers.low.thinking).toBe("medium");
150
- expect(upgraded.planReviewers.medium.thinking).toBe("high");
151
- expect(upgraded.planReviewers.high.thinking).toBe("xhigh");
152
- expect(upgraded.planReviewers.other.thinking).toBe("high");
230
+ expect(upgraded.low.thinking).toBe("xhigh");
231
+ expect(upgraded.medium.thinking).toBe("xhigh");
232
+ expect(upgraded.high.thinking).toBe("xhigh");
233
+ expect(upgraded.other.thinking).toBe("xhigh");
153
234
  });
154
235
  });
155
236
 
@@ -161,7 +242,7 @@ describe("ensureGitignore", () => {
161
242
 
162
243
  const gitignorePath = join(cwd, ".pp", ".gitignore");
163
244
  expect(existsSync(gitignorePath)).toBe(true);
164
- expect(readFileSync(gitignorePath, "utf-8")).toBe("state/\nconfig.json\n");
245
+ expect(readFileSync(gitignorePath, "utf-8")).toBe("state/\nconfig.json\nlogs/\n");
165
246
  });
166
247
 
167
248
  it("adds missing entries and does not duplicate existing ones", () => {
@@ -251,3 +332,72 @@ describe("Orchestrator.cleanupActive", () => {
251
332
  expect(orchestrator.active).toBeNull();
252
333
  });
253
334
  });
335
+
336
+ describe("Orchestrator.getPlanStartState", () => {
337
+ function makePlannerConfig() {
338
+ const config = getDefaultConfig();
339
+ config.general.autoCommit = false;
340
+ config.general.loadExtraRepoConfigs = true;
341
+ config.general.logLevel = "info";
342
+ config.agents.subagents.presetGroups.planners = {
343
+ default: "regular",
344
+ presets: {
345
+ regular: {
346
+ enabled: true,
347
+ agents: {
348
+ alpha: { enabled: true, model: "x/a", thinking: "low" },
349
+ beta: { enabled: true, model: "x/b", thinking: "low" },
350
+ },
351
+ },
352
+ },
353
+ };
354
+ config.commands.afterEdit = {};
355
+ config.commands.afterImplement = {};
356
+ config.performance.commands.afterEdit = 1;
357
+ config.performance.commands.afterImplement = 1;
358
+ config.performance.internals.subagentStale = 1;
359
+ config.performance.internals.taskLockStale = 1;
360
+ config.performance.internals.taskLockRefresh = 1;
361
+ return config as any;
362
+ }
363
+
364
+ it("returns synthesize when all enabled planner variants have outputs", () => {
365
+ const orchestrator = new Orchestrator(makePi());
366
+ orchestrator.config = makePlannerConfig();
367
+ const taskDir = makeTempDir();
368
+ const plansDir = join(taskDir, "plans");
369
+ mkdirSync(plansDir, { recursive: true });
370
+ writeFileSync(join(plansDir, `${Math.floor(Date.now() / 1000)}_alpha.md`), "# Plan\n", "utf-8");
371
+ writeFileSync(join(plansDir, `${Math.floor(Date.now() / 1000) + 1}_beta.md`), "# Plan\n", "utf-8");
372
+
373
+ const state = orchestrator.getPlanStartState(taskDir, "regular");
374
+
375
+ expect(state).toEqual({ step: "synthesize", shouldSpawnPlanners: false });
376
+ });
377
+
378
+ it("returns await_planners when required planner outputs are missing", () => {
379
+ const orchestrator = new Orchestrator(makePi());
380
+ orchestrator.config = makePlannerConfig();
381
+ const taskDir = makeTempDir();
382
+ const plansDir = join(taskDir, "plans");
383
+ mkdirSync(plansDir, { recursive: true });
384
+ writeFileSync(join(plansDir, `${Math.floor(Date.now() / 1000)}_alpha.md`), "# Plan\n", "utf-8");
385
+
386
+ const state = orchestrator.getPlanStartState(taskDir, "regular");
387
+
388
+ expect(state).toEqual({ step: "await_planners", shouldSpawnPlanners: true });
389
+ });
390
+
391
+ it("returns synthesize when synthesized plan already exists", () => {
392
+ const orchestrator = new Orchestrator(makePi());
393
+ orchestrator.config = makePlannerConfig();
394
+ const taskDir = makeTempDir();
395
+ const plansDir = join(taskDir, "plans");
396
+ mkdirSync(plansDir, { recursive: true });
397
+ writeFileSync(join(plansDir, `${Math.floor(Date.now() / 1000)}_synthesized.md`), "# Plan\n", "utf-8");
398
+
399
+ const state = orchestrator.getPlanStartState(taskDir, "regular");
400
+
401
+ expect(state).toEqual({ step: "synthesize", shouldSpawnPlanners: false });
402
+ });
403
+ });