@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,111 +1,262 @@
1
1
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
2
- import { join } from "path";
2
+ import { dirname, join } from "path";
3
+ import lockfile from "proper-lockfile";
3
4
 
4
5
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
6
+ import { isValidLogLevel, getLogger, type LogLevel } from "./log.js";
5
7
 
6
- export interface ModelConfig {
8
+ export type DurationValue = string | number;
9
+ export type OrchestratorRole = "implement" | "plan" | "debug" | "brainstorm" | "review";
10
+ export type SimpleSubagentRole = "explore" | "librarian" | "task";
11
+ export type PresetGroupKey = "planners" | "codeReviewers" | "planReviewers" | "brainstormReviewers";
12
+
13
+ export interface AgentConfig {
7
14
  model: string;
8
15
  thinking: string;
9
- maxTurns?: number;
10
16
  }
11
17
 
12
- export interface VariantConfig extends ModelConfig {
13
- enabled: boolean;
18
+ export interface PresetAgentConfig extends AgentConfig {
19
+ enabled?: boolean;
14
20
  }
15
21
 
16
- export interface AfterEditCommand {
17
- run: string;
18
- glob: string[];
22
+ export interface PresetConfig {
23
+ enabled?: boolean;
24
+ agents: Record<string, PresetAgentConfig>;
25
+ }
26
+
27
+ export interface PresetGroupConfig {
28
+ default: string;
29
+ presets: Record<string, PresetConfig>;
19
30
  }
20
31
 
21
- export interface AfterImplementCommand {
32
+ export interface AfterEditCommandConfig {
22
33
  run: string;
34
+ globs?: string[];
35
+ enabled?: boolean;
23
36
  }
24
37
 
25
- export interface TimeoutConfig {
26
- afterEdit: number;
27
- afterImplement: number;
28
- agentSpawn: number;
29
- agentReadyPing: number;
30
- agentStale: number;
31
- lockStale: number;
32
- lockUpdate: number;
38
+ export interface AfterImplementCommandConfig {
39
+ run: string;
40
+ enabled?: boolean;
33
41
  }
34
42
 
35
43
  export interface PiPiConfig {
36
- mainModel: {
37
- implement: ModelConfig;
38
- debug: ModelConfig;
39
- brainstorm: ModelConfig;
40
- review: ModelConfig;
44
+ general: {
45
+ autoCommit: boolean;
46
+ loadExtraRepoConfigs: boolean;
47
+ logLevel: LogLevel;
48
+ tracing: boolean;
41
49
  };
42
- planners: Record<string, VariantConfig>;
43
- planReviewers: Record<string, VariantConfig>;
44
- codeReviewers: Record<string, VariantConfig>;
45
- brainstormReviewers: Record<string, VariantConfig>;
46
50
  agents: {
47
- explore: ModelConfig;
48
- librarian: ModelConfig;
49
- task: ModelConfig;
51
+ orchestrators: Record<OrchestratorRole, AgentConfig>;
52
+ subagents: {
53
+ simple: Record<SimpleSubagentRole, AgentConfig>;
54
+ presetGroups: Record<PresetGroupKey, PresetGroupConfig>;
55
+ };
50
56
  };
51
57
  commands: {
52
- afterEdit: AfterEditCommand[];
53
- afterImplement: AfterImplementCommand[];
58
+ afterEdit: Record<string, AfterEditCommandConfig>;
59
+ afterImplement: Record<string, AfterImplementCommandConfig>;
60
+ };
61
+ performance: {
62
+ commands: {
63
+ afterEdit: DurationValue;
64
+ afterImplement: DurationValue;
65
+ };
66
+ internals: {
67
+ subagentStale: DurationValue;
68
+ taskLockStale: DurationValue;
69
+ taskLockRefresh: DurationValue;
70
+ };
54
71
  };
55
- timeouts: TimeoutConfig;
56
- autoCommit: boolean;
57
- diffBaseBranch?: string;
58
72
  }
59
73
 
74
+ export interface NormalizedPiPiConfig extends PiPiConfig {
75
+ performance: {
76
+ commands: {
77
+ afterEdit: number;
78
+ afterImplement: number;
79
+ };
80
+ internals: {
81
+ subagentStale: number;
82
+ taskLockStale: number;
83
+ taskLockRefresh: number;
84
+ };
85
+ };
86
+ }
87
+
88
+ export type PresetGroup = PresetGroupKey;
89
+ export type VariantConfig = PresetAgentConfig;
90
+ export type TimeoutConfig = NormalizedPiPiConfig["performance"]["internals"];
91
+
92
+ export const PRESET_GROUPS = ["planners", "codeReviewers", "planReviewers", "brainstormReviewers"] as const;
93
+
94
+ const ORCHESTRATOR_ROLES: OrchestratorRole[] = ["implement", "plan", "debug", "brainstorm", "review"];
95
+ const SIMPLE_SUBAGENT_ROLES: SimpleSubagentRole[] = ["explore", "librarian", "task"];
96
+
60
97
  const DEFAULT_CONFIG: PiPiConfig = {
61
- mainModel: {
62
- implement: { model: "anthropic/claude-opus-4-6", thinking: "high" },
63
- debug: { model: "openai/gpt-5.4", thinking: "high" },
64
- brainstorm: { model: "anthropic/claude-opus-4-6", thinking: "high" },
65
- review: { model: "anthropic/claude-opus-4-6", thinking: "high" },
66
- },
67
- planners: {
68
- opus: { enabled: true, model: "anthropic/claude-opus-4-6", thinking: "high" },
69
- gpt: { enabled: true, model: "openai/gpt-5.4", thinking: "high" },
70
- gemini: { enabled: true, model: "google/gemini-3.1-pro", thinking: "high" },
71
- },
72
- planReviewers: {
73
- opus: { enabled: true, model: "anthropic/claude-opus-4-6", thinking: "high" },
74
- gpt: { enabled: true, model: "openai/gpt-5.4", thinking: "high" },
75
- gemini: { enabled: true, model: "google/gemini-3.1-pro", thinking: "xhigh" },
76
- },
77
- codeReviewers: {
78
- opus: { enabled: true, model: "anthropic/claude-opus-4-6", thinking: "high" },
79
- gpt: { enabled: true, model: "openai/gpt-5.4", thinking: "high" },
80
- gemini: { enabled: true, model: "google/gemini-3.1-pro", thinking: "xhigh" },
81
- },
82
- brainstormReviewers: {
83
- opus: { enabled: true, model: "anthropic/claude-opus-4-6", thinking: "high" },
84
- gpt: { enabled: true, model: "openai/gpt-5.4", thinking: "high" },
85
- gemini: { enabled: true, model: "google/gemini-3.1-pro", thinking: "xhigh" },
98
+ general: {
99
+ autoCommit: true,
100
+ loadExtraRepoConfigs: true,
101
+ logLevel: "info",
102
+ tracing: false,
86
103
  },
87
104
  agents: {
88
- explore: { model: "google/gemini-3.1-flash", thinking: "low" },
89
- librarian: { model: "google/gemini-3.1-flash", thinking: "medium" },
90
- task: { model: "anthropic/claude-opus-4-6", thinking: "medium" },
105
+ orchestrators: {
106
+ implement: { model: "anthropic/claude-opus-latest", thinking: "high" },
107
+ plan: { model: "anthropic/claude-opus-latest", thinking: "high" },
108
+ debug: { model: "openai/gpt-latest", thinking: "high" },
109
+ brainstorm: { model: "anthropic/claude-opus-latest", thinking: "high" },
110
+ review: { model: "anthropic/claude-opus-latest", thinking: "high" },
111
+ },
112
+ subagents: {
113
+ simple: {
114
+ explore: { model: "google/gemini-flash-latest", thinking: "low" },
115
+ librarian: { model: "google/gemini-flash-latest", thinking: "medium" },
116
+ task: { model: "anthropic/claude-opus-latest", thinking: "medium" },
117
+ },
118
+ presetGroups: {
119
+ planners: {
120
+ default: "regular",
121
+ presets: {
122
+ regular: {
123
+ agents: {
124
+ opus: { enabled: true, model: "anthropic/claude-opus-latest", thinking: "high" },
125
+ gpt: { enabled: true, model: "openai/gpt-latest", thinking: "high" },
126
+ gemini: { enabled: true, model: "google/gemini-pro-latest", thinking: "high" },
127
+ },
128
+ },
129
+ },
130
+ },
131
+ codeReviewers: {
132
+ default: "regular",
133
+ presets: {
134
+ regular: {
135
+ agents: {
136
+ opus: { enabled: true, model: "anthropic/claude-opus-latest", thinking: "high" },
137
+ gpt: { enabled: true, model: "openai/gpt-latest", thinking: "high" },
138
+ gemini: { enabled: true, model: "google/gemini-pro-latest", thinking: "xhigh" },
139
+ },
140
+ },
141
+ deep: {
142
+ agents: {
143
+ opus: { enabled: true, model: "anthropic/claude-opus-latest", thinking: "xhigh" },
144
+ gpt: { enabled: true, model: "openai/gpt-latest", thinking: "xhigh" },
145
+ gemini: { enabled: true, model: "google/gemini-pro-latest", thinking: "xhigh" },
146
+ },
147
+ },
148
+ },
149
+ },
150
+ planReviewers: {
151
+ default: "regular",
152
+ presets: {
153
+ regular: {
154
+ agents: {
155
+ opus: { enabled: true, model: "anthropic/claude-opus-latest", thinking: "high" },
156
+ gpt: { enabled: true, model: "openai/gpt-latest", thinking: "high" },
157
+ gemini: { enabled: true, model: "google/gemini-pro-latest", thinking: "xhigh" },
158
+ },
159
+ },
160
+ deep: {
161
+ agents: {
162
+ opus: { enabled: true, model: "anthropic/claude-opus-latest", thinking: "xhigh" },
163
+ gpt: { enabled: true, model: "openai/gpt-latest", thinking: "xhigh" },
164
+ gemini: { enabled: true, model: "google/gemini-pro-latest", thinking: "xhigh" },
165
+ },
166
+ },
167
+ },
168
+ },
169
+ brainstormReviewers: {
170
+ default: "regular",
171
+ presets: {
172
+ regular: {
173
+ agents: {
174
+ opus: { enabled: true, model: "anthropic/claude-opus-latest", thinking: "high" },
175
+ gpt: { enabled: true, model: "openai/gpt-latest", thinking: "high" },
176
+ gemini: { enabled: true, model: "google/gemini-pro-latest", thinking: "xhigh" },
177
+ },
178
+ },
179
+ deep: {
180
+ agents: {
181
+ opus: { enabled: true, model: "anthropic/claude-opus-latest", thinking: "xhigh" },
182
+ gpt: { enabled: true, model: "openai/gpt-latest", thinking: "xhigh" },
183
+ gemini: { enabled: true, model: "google/gemini-pro-latest", thinking: "xhigh" },
184
+ },
185
+ },
186
+ },
187
+ },
188
+ },
189
+ },
91
190
  },
92
191
  commands: {
93
- afterEdit: [],
94
- afterImplement: [],
192
+ afterEdit: {},
193
+ afterImplement: {},
95
194
  },
96
- timeouts: {
97
- afterEdit: 30000,
98
- afterImplement: 300000,
99
- agentSpawn: 30000,
100
- agentReadyPing: 5000,
101
- agentStale: 300000,
102
- lockStale: 60000,
103
- lockUpdate: 30000,
195
+ performance: {
196
+ commands: {
197
+ afterEdit: "30s",
198
+ afterImplement: "5m",
199
+ },
200
+ internals: {
201
+ subagentStale: "5m",
202
+ taskLockStale: "1m",
203
+ taskLockRefresh: "30s",
204
+ },
104
205
  },
105
- autoCommit: true,
106
206
  };
107
207
 
108
208
  const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]);
209
+ const VALID_NAME_RE = /^[A-Za-z0-9-]+$/;
210
+
211
+ function isObject(value: unknown): value is Record<string, unknown> {
212
+ return !!value && typeof value === "object" && !Array.isArray(value);
213
+ }
214
+
215
+ function requireObject(value: unknown, path: string): Record<string, unknown> {
216
+ if (!isObject(value)) throw new Error(`${path} must be an object`);
217
+ return value;
218
+ }
219
+
220
+ function ensureBool(value: unknown, path: string): void {
221
+ if (value !== undefined && typeof value !== "boolean") {
222
+ throw new Error(`${path} must be a boolean`);
223
+ }
224
+ }
225
+
226
+ function ensureString(value: unknown, path: string): void {
227
+ if (value !== undefined && (typeof value !== "string" || value.length === 0)) {
228
+ throw new Error(`${path} must be a non-empty string`);
229
+ }
230
+ }
231
+
232
+ function ensureDuration(value: unknown, path: string): void {
233
+ if (value === undefined) return;
234
+ if (parseDuration(value as DurationValue) === null) {
235
+ throw new Error(`${path} must be a valid duration (number or string like 30s, 5m, 1h)`);
236
+ }
237
+ }
238
+
239
+ export function parseDuration(input: DurationValue): number | null {
240
+ if (typeof input === "number") {
241
+ if (!Number.isFinite(input) || input < 0) return null;
242
+ return input;
243
+ }
244
+ if (typeof input !== "string") return null;
245
+ const match = /^\s*(\d+)\s*(ms|s|m|h)?\s*$/i.exec(input);
246
+ if (!match) return null;
247
+ const value = Number(match[1]);
248
+ const unit = (match[2] ?? "ms").toLowerCase();
249
+ if (!Number.isFinite(value) || value < 0) return null;
250
+ if (unit === "ms") return value;
251
+ if (unit === "s") return value * 1000;
252
+ if (unit === "m") return value * 60000;
253
+ if (unit === "h") return value * 3600000;
254
+ return null;
255
+ }
256
+
257
+ export function getDefaultConfig(): PiPiConfig {
258
+ return structuredClone(DEFAULT_CONFIG);
259
+ }
109
260
 
110
261
  export function deepMerge(target: Record<string, any>, source: Record<string, any>): Record<string, any> {
111
262
  const result = { ...target };
@@ -116,6 +267,7 @@ export function deepMerge(target: Record<string, any>, source: Record<string, an
116
267
  typeof source[key] === "object" &&
117
268
  !Array.isArray(source[key]) &&
118
269
  typeof target[key] === "object" &&
270
+ target[key] !== null &&
119
271
  !Array.isArray(target[key])
120
272
  ) {
121
273
  result[key] = deepMerge(target[key], source[key]);
@@ -128,65 +280,281 @@ export function deepMerge(target: Record<string, any>, source: Record<string, an
128
280
  return result;
129
281
  }
130
282
 
283
+ function validateAgentPartial(value: unknown, path: string): void {
284
+ const agent = requireObject(value, path);
285
+ ensureString(agent.model, `${path}.model`);
286
+ ensureString(agent.thinking, `${path}.thinking`);
287
+ }
288
+
289
+ function validatePresetAgentPartial(value: unknown, path: string): void {
290
+ const agent = requireObject(value, path);
291
+ ensureBool(agent.enabled, `${path}.enabled`);
292
+ ensureString(agent.model, `${path}.model`);
293
+ ensureString(agent.thinking, `${path}.thinking`);
294
+ }
295
+
296
+ function validateCommandAfterEditPartial(value: unknown, path: string): void {
297
+ const command = requireObject(value, path);
298
+ ensureBool(command.enabled, `${path}.enabled`);
299
+ ensureString(command.run, `${path}.run`);
300
+ if (command.globs !== undefined) {
301
+ if (!Array.isArray(command.globs) || command.globs.some((g) => typeof g !== "string" || g.length === 0)) {
302
+ throw new Error(`${path}.globs must be an array of non-empty strings`);
303
+ }
304
+ }
305
+ }
306
+
307
+ function validateCommandAfterImplementPartial(value: unknown, path: string): void {
308
+ const command = requireObject(value, path);
309
+ ensureBool(command.enabled, `${path}.enabled`);
310
+ ensureString(command.run, `${path}.run`);
311
+ }
312
+
313
+ function validatePresetPartial(value: unknown, path: string): void {
314
+ const preset = requireObject(value, path);
315
+ ensureBool(preset.enabled, `${path}.enabled`);
316
+ if (preset.agents !== undefined) {
317
+ const agents = requireObject(preset.agents, `${path}.agents`);
318
+ for (const [agentName, agentCfg] of Object.entries(agents)) {
319
+ if (!VALID_NAME_RE.test(agentName)) throw new Error(`${path}.agents.${agentName} has invalid name`);
320
+ validatePresetAgentPartial(agentCfg, `${path}.agents.${agentName}`);
321
+ }
322
+ }
323
+ }
324
+
325
+ function validatePresetGroupPartial(value: unknown, path: string): void {
326
+ const group = requireObject(value, path);
327
+ if (group.default !== undefined) {
328
+ ensureString(group.default, `${path}.default`);
329
+ if (typeof group.default === "string" && !VALID_NAME_RE.test(group.default)) {
330
+ throw new Error(`${path}.default has invalid name`);
331
+ }
332
+ }
333
+ if (group.presets !== undefined) {
334
+ const presets = requireObject(group.presets, `${path}.presets`);
335
+ for (const [presetName, presetCfg] of Object.entries(presets)) {
336
+ if (!VALID_NAME_RE.test(presetName)) throw new Error(`${path}.presets.${presetName} has invalid name`);
337
+ validatePresetPartial(presetCfg, `${path}.presets.${presetName}`);
338
+ }
339
+ }
340
+ }
341
+
131
342
  export function validateConfig(config: Record<string, any>): void {
132
- if (config.mainModel) {
133
- for (const key of ["implement", "debug", "brainstorm", "review"]) {
134
- const mc = config.mainModel[key];
135
- if (mc && typeof mc.model === "string" && mc.model.length === 0) {
136
- throw new Error(`config.mainModel.${key}.model must be non-empty`);
137
- }
343
+ if (config.general !== undefined) {
344
+ const general = requireObject(config.general, "config.general");
345
+ ensureBool(general.autoCommit, "config.general.autoCommit");
346
+ ensureBool(general.loadExtraRepoConfigs, "config.general.loadExtraRepoConfigs");
347
+ ensureBool(general.tracing, "config.general.tracing");
348
+ if (general.logLevel !== undefined && !isValidLogLevel(general.logLevel)) {
349
+ throw new Error("config.general.logLevel must be one of: debug, info, warn, error");
138
350
  }
139
351
  }
140
352
 
141
- for (const group of ["planners", "planReviewers", "codeReviewers", "brainstormReviewers"]) {
142
- const variants = config[group];
143
- if (!variants || typeof variants !== "object") continue;
144
- for (const [name, v] of Object.entries(variants)) {
145
- const variant = v as Record<string, any>;
146
- if (variant.enabled && (!variant.model || typeof variant.model !== "string" || variant.model.length === 0)) {
147
- throw new Error(`config.${group}.${name} is enabled but has no model`);
353
+ if (config.agents !== undefined) {
354
+ const agents = requireObject(config.agents, "config.agents");
355
+
356
+ if (agents.orchestrators !== undefined) {
357
+ const orchestrators = requireObject(agents.orchestrators, "config.agents.orchestrators");
358
+ for (const role of ORCHESTRATOR_ROLES) {
359
+ if (orchestrators[role] !== undefined) {
360
+ validateAgentPartial(orchestrators[role], `config.agents.orchestrators.${role}`);
361
+ }
362
+ }
363
+ }
364
+
365
+ if (agents.subagents !== undefined) {
366
+ const subagents = requireObject(agents.subagents, "config.agents.subagents");
367
+
368
+ if (subagents.simple !== undefined) {
369
+ const simple = requireObject(subagents.simple, "config.agents.subagents.simple");
370
+ for (const role of SIMPLE_SUBAGENT_ROLES) {
371
+ if (simple[role] !== undefined) {
372
+ validateAgentPartial(simple[role], `config.agents.subagents.simple.${role}`);
373
+ }
374
+ }
375
+ }
376
+
377
+ if (subagents.presetGroups !== undefined) {
378
+ const presetGroups = requireObject(subagents.presetGroups, "config.agents.subagents.presetGroups");
379
+ for (const groupName of PRESET_GROUPS) {
380
+ if (presetGroups[groupName] !== undefined) {
381
+ validatePresetGroupPartial(presetGroups[groupName], `config.agents.subagents.presetGroups.${groupName}`);
382
+ }
383
+ }
148
384
  }
149
385
  }
150
386
  }
151
387
 
152
- if (config.commands?.afterEdit) {
153
- if (!Array.isArray(config.commands.afterEdit)) {
154
- throw new Error("config.commands.afterEdit must be an array");
388
+ if (config.commands !== undefined) {
389
+ const commands = requireObject(config.commands, "config.commands");
390
+
391
+ if (commands.afterEdit !== undefined) {
392
+ const afterEdit = requireObject(commands.afterEdit, "config.commands.afterEdit");
393
+ for (const [commandId, commandCfg] of Object.entries(afterEdit)) {
394
+ validateCommandAfterEditPartial(commandCfg, `config.commands.afterEdit.${commandId}`);
395
+ }
155
396
  }
156
- for (const [i, cmd] of config.commands.afterEdit.entries()) {
157
- if (!cmd.run || typeof cmd.run !== "string") {
158
- throw new Error(`config.commands.afterEdit[${i}] must have a 'run' field`);
397
+
398
+ if (commands.afterImplement !== undefined) {
399
+ const afterImplement = requireObject(commands.afterImplement, "config.commands.afterImplement");
400
+ for (const [commandId, commandCfg] of Object.entries(afterImplement)) {
401
+ validateCommandAfterImplementPartial(commandCfg, `config.commands.afterImplement.${commandId}`);
159
402
  }
160
403
  }
161
404
  }
162
405
 
163
- if (config.commands?.afterImplement) {
164
- if (!Array.isArray(config.commands.afterImplement)) {
165
- throw new Error("config.commands.afterImplement must be an array");
406
+ if (config.performance !== undefined) {
407
+ const performance = requireObject(config.performance, "config.performance");
408
+ if (performance.commands !== undefined) {
409
+ const cmdPerf = requireObject(performance.commands, "config.performance.commands");
410
+ ensureDuration(cmdPerf.afterEdit, "config.performance.commands.afterEdit");
411
+ ensureDuration(cmdPerf.afterImplement, "config.performance.commands.afterImplement");
166
412
  }
167
- for (const [i, cmd] of config.commands.afterImplement.entries()) {
168
- if (!cmd.run || typeof cmd.run !== "string") {
169
- throw new Error(`config.commands.afterImplement[${i}] must have a 'run' field`);
170
- }
413
+ if (performance.internals !== undefined) {
414
+ const internals = requireObject(performance.internals, "config.performance.internals");
415
+ ensureDuration(internals.subagentStale, "config.performance.internals.subagentStale");
416
+ ensureDuration(internals.taskLockStale, "config.performance.internals.taskLockStale");
417
+ ensureDuration(internals.taskLockRefresh, "config.performance.internals.taskLockRefresh");
171
418
  }
172
419
  }
420
+ }
421
+
422
+ function ensureMergedAgent(agent: AgentConfig, path: string): void {
423
+ if (typeof agent.model !== "string" || agent.model.length === 0) {
424
+ throw new Error(`${path}.model must be a non-empty string`);
425
+ }
426
+ if (typeof agent.thinking !== "string" || agent.thinking.length === 0) {
427
+ throw new Error(`${path}.thinking must be a non-empty string`);
428
+ }
429
+ }
430
+
431
+ function isEnabled(value: { enabled?: boolean } | undefined): boolean {
432
+ return value?.enabled !== false;
433
+ }
434
+
435
+ export function validateMergedConfig(config: Record<string, any>): void {
436
+ const typed = config as PiPiConfig;
437
+
438
+ if (!typed.general || !isValidLogLevel(typed.general.logLevel)) {
439
+ throw new Error("config.general.logLevel must be one of: debug, info, warn, error");
440
+ }
441
+
442
+ for (const role of ORCHESTRATOR_ROLES) {
443
+ ensureMergedAgent(typed.agents.orchestrators[role], `config.agents.orchestrators.${role}`);
444
+ }
445
+
446
+ for (const role of SIMPLE_SUBAGENT_ROLES) {
447
+ ensureMergedAgent(typed.agents.subagents.simple[role], `config.agents.subagents.simple.${role}`);
448
+ }
449
+
450
+ for (const groupName of PRESET_GROUPS) {
451
+ const group = typed.agents.subagents.presetGroups[groupName];
452
+ if (!group || typeof group !== "object") {
453
+ throw new Error(`config.agents.subagents.presetGroups.${groupName} must be an object`);
454
+ }
455
+
456
+ if (typeof group.default !== "string" || group.default.length === 0) {
457
+ throw new Error(`config.agents.subagents.presetGroups.${groupName}.default must be a non-empty string`);
458
+ }
459
+
460
+ const defaultPreset = group.presets[group.default];
461
+ if (!defaultPreset) {
462
+ throw new Error(`config.agents.subagents.presetGroups.${groupName}.default "${group.default}" does not exist`);
463
+ }
464
+ if (!isEnabled(defaultPreset)) {
465
+ throw new Error(`config.agents.subagents.presetGroups.${groupName}.default "${group.default}" is disabled`);
466
+ }
467
+
468
+ for (const [presetName, preset] of Object.entries(group.presets)) {
469
+ if (!VALID_NAME_RE.test(presetName)) {
470
+ throw new Error(`config.agents.subagents.presetGroups.${groupName}.presets.${presetName} has invalid name`);
471
+ }
472
+ if (!preset || typeof preset !== "object" || Array.isArray(preset)) {
473
+ throw new Error(`config.agents.subagents.presetGroups.${groupName}.presets.${presetName} must be an object`);
474
+ }
475
+
476
+ const agents = preset.agents;
477
+ if (!agents || typeof agents !== "object" || Array.isArray(agents)) {
478
+ throw new Error(`config.agents.subagents.presetGroups.${groupName}.presets.${presetName}.agents must be an object`);
479
+ }
480
+
481
+ let enabledAgents = 0;
482
+ for (const [agentName, agentCfg] of Object.entries(agents)) {
483
+ if (!VALID_NAME_RE.test(agentName)) {
484
+ throw new Error(`config.agents.subagents.presetGroups.${groupName}.presets.${presetName}.agents.${agentName} has invalid name`);
485
+ }
486
+ if (!agentCfg || typeof agentCfg !== "object" || Array.isArray(agentCfg)) {
487
+ throw new Error(`config.agents.subagents.presetGroups.${groupName}.presets.${presetName}.agents.${agentName} must be an object`);
488
+ }
489
+ if (isEnabled(agentCfg)) enabledAgents += 1;
490
+ ensureMergedAgent(agentCfg as AgentConfig, `config.agents.subagents.presetGroups.${groupName}.presets.${presetName}.agents.${agentName}`);
491
+ }
173
492
 
174
- if (config.timeouts) {
175
- for (const [key, val] of Object.entries(config.timeouts)) {
176
- if (typeof val !== "number" || val < 0) {
177
- throw new Error(`config.timeouts.${key} must be a non-negative number`);
493
+ if (isEnabled(preset) && enabledAgents === 0) {
494
+ throw new Error(`config.agents.subagents.presetGroups.${groupName}.presets.${presetName} has no enabled agents`);
178
495
  }
179
496
  }
180
497
  }
181
498
 
182
- if (config.agents) {
183
- for (const [name, agent] of Object.entries(config.agents)) {
184
- const a = agent as Record<string, any>;
185
- if (a.model !== undefined && (typeof a.model !== "string" || a.model.length === 0)) {
186
- throw new Error(`config.agents.${name}.model must be a non-empty string`);
187
- }
499
+ for (const [id, cmd] of Object.entries(typed.commands.afterEdit)) {
500
+ if (!cmd || typeof cmd !== "object") throw new Error(`config.commands.afterEdit.${id} must be an object`);
501
+ if (isEnabled(cmd) && (typeof cmd.run !== "string" || cmd.run.length === 0)) {
502
+ throw new Error(`config.commands.afterEdit.${id}.run must be a non-empty string`);
503
+ }
504
+ if (cmd.globs !== undefined && (!Array.isArray(cmd.globs) || cmd.globs.some((g) => typeof g !== "string" || g.length === 0))) {
505
+ throw new Error(`config.commands.afterEdit.${id}.globs must be an array of non-empty strings`);
506
+ }
507
+ }
508
+
509
+ for (const [id, cmd] of Object.entries(typed.commands.afterImplement)) {
510
+ if (!cmd || typeof cmd !== "object") throw new Error(`config.commands.afterImplement.${id} must be an object`);
511
+ if (isEnabled(cmd) && (typeof cmd.run !== "string" || cmd.run.length === 0)) {
512
+ throw new Error(`config.commands.afterImplement.${id}.run must be a non-empty string`);
188
513
  }
189
514
  }
515
+
516
+ if (parseDuration(typed.performance.commands.afterEdit) === null) {
517
+ throw new Error("config.performance.commands.afterEdit must be a valid duration");
518
+ }
519
+ if (parseDuration(typed.performance.commands.afterImplement) === null) {
520
+ throw new Error("config.performance.commands.afterImplement must be a valid duration");
521
+ }
522
+ if (parseDuration(typed.performance.internals.subagentStale) === null) {
523
+ throw new Error("config.performance.internals.subagentStale must be a valid duration");
524
+ }
525
+ if (parseDuration(typed.performance.internals.taskLockStale) === null) {
526
+ throw new Error("config.performance.internals.taskLockStale must be a valid duration");
527
+ }
528
+ if (parseDuration(typed.performance.internals.taskLockRefresh) === null) {
529
+ throw new Error("config.performance.internals.taskLockRefresh must be a valid duration");
530
+ }
531
+ }
532
+
533
+ export function normalizeConfigDurations(config: PiPiConfig): NormalizedPiPiConfig {
534
+ const next = structuredClone(config) as NormalizedPiPiConfig;
535
+
536
+ const afterEdit = parseDuration(next.performance.commands.afterEdit);
537
+ const afterImplement = parseDuration(next.performance.commands.afterImplement);
538
+ const subagentStale = parseDuration(next.performance.internals.subagentStale);
539
+ const taskLockStale = parseDuration(next.performance.internals.taskLockStale);
540
+ const taskLockRefresh = parseDuration(next.performance.internals.taskLockRefresh);
541
+
542
+ if (
543
+ afterEdit === null ||
544
+ afterImplement === null ||
545
+ subagentStale === null ||
546
+ taskLockStale === null ||
547
+ taskLockRefresh === null
548
+ ) {
549
+ throw new Error("Failed to normalize config durations");
550
+ }
551
+
552
+ next.performance.commands.afterEdit = afterEdit;
553
+ next.performance.commands.afterImplement = afterImplement;
554
+ next.performance.internals.subagentStale = subagentStale;
555
+ next.performance.internals.taskLockStale = taskLockStale;
556
+ next.performance.internals.taskLockRefresh = taskLockRefresh;
557
+ return next;
190
558
  }
191
559
 
192
560
  function loadJsonFile(path: string): Record<string, any> | null {
@@ -200,33 +568,174 @@ function loadJsonFile(path: string): Record<string, any> | null {
200
568
  }
201
569
 
202
570
  export const GLOBAL_CONFIG_PATH = join(getAgentDir(), "extensions", "pp", "config.json");
203
- export function loadConfig(cwd: string, globalConfigPath = GLOBAL_CONFIG_PATH): PiPiConfig {
204
- const ppDir = join(cwd, ".pp");
205
- const projectConfigPath = join(ppDir, "config.json");
206
571
 
207
- if (!existsSync(ppDir)) {
208
- mkdirSync(ppDir, { recursive: true });
209
- }
210
-
211
- const globalConfig = loadJsonFile(globalConfigPath);
212
- const projectConfig = loadJsonFile(projectConfigPath);
213
-
214
- let merged = { ...DEFAULT_CONFIG } as Record<string, any>;
572
+ export function mergeConfigLayers(
573
+ globalConfig: Record<string, any> | null,
574
+ projectConfig: Record<string, any> | null,
575
+ ): NormalizedPiPiConfig {
576
+ const log = getLogger();
577
+ let merged = getDefaultConfig() as Record<string, any>;
215
578
 
216
579
  const getFlantConfig = (globalThis as any)[Symbol.for("pi-pi:flant-config")] as (() => Partial<PiPiConfig> | null) | undefined;
217
580
  const flantConfig = getFlantConfig?.();
218
581
  if (flantConfig) {
582
+ validateConfig(flantConfig as Record<string, any>);
219
583
  merged = deepMerge(merged, flantConfig as Record<string, any>);
584
+ log.debug({ s: "config", layer: "flant" }, "merged flant config layer");
220
585
  }
221
586
 
222
587
  if (globalConfig) {
223
588
  validateConfig(globalConfig);
224
589
  merged = deepMerge(merged, globalConfig);
590
+ log.debug({ s: "config", layer: "global" }, "merged global config layer");
225
591
  }
592
+
226
593
  if (projectConfig) {
227
594
  validateConfig(projectConfig);
228
595
  merged = deepMerge(merged, projectConfig);
596
+ log.debug({ s: "config", layer: "project" }, "merged project config layer");
229
597
  }
230
598
 
231
- return merged as unknown as PiPiConfig;
599
+ validateMergedConfig(merged);
600
+ const normalized = normalizeConfigDurations(merged as PiPiConfig);
601
+ log.debug(
602
+ {
603
+ s: "config",
604
+ logLevel: normalized.general.logLevel,
605
+ autoCommit: normalized.general.autoCommit,
606
+ loadExtraRepoConfigs: normalized.general.loadExtraRepoConfigs,
607
+ },
608
+ "config merge complete",
609
+ );
610
+ return normalized;
611
+ }
612
+
613
+ export function resolvePreset(
614
+ config: PiPiConfig,
615
+ group: PresetGroup,
616
+ presetName?: string,
617
+ ): Record<string, PresetAgentConfig> {
618
+ const log = getLogger();
619
+ const groupConfig = config.agents.subagents.presetGroups[group];
620
+ const requestedName = presetName ?? groupConfig.default;
621
+
622
+ const isPresetEnabled = (p: PresetConfig | undefined): boolean => !!p && p.enabled !== false;
623
+ const normalizeAgents = (preset: PresetConfig): Record<string, PresetAgentConfig> => {
624
+ const out: Record<string, PresetAgentConfig> = {};
625
+ for (const [name, agent] of Object.entries(preset.agents)) {
626
+ out[name] = { ...agent, enabled: agent.enabled !== false };
627
+ }
628
+ return out;
629
+ };
630
+
631
+ const direct = groupConfig.presets[requestedName];
632
+ if (isPresetEnabled(direct)) {
633
+ log.debug({ s: "preset", group, name: requestedName, variants: Object.keys(direct!.agents) }, "preset resolved");
634
+ return normalizeAgents(direct!);
635
+ }
636
+
637
+ const fallbackName = Object.keys(groupConfig.presets).find((name) => isPresetEnabled(groupConfig.presets[name]));
638
+ if (fallbackName) {
639
+ const fallback = groupConfig.presets[fallbackName]!;
640
+ log.debug({ s: "preset", group, requested: requestedName, resolved: fallbackName, fallback: true }, "preset fallback");
641
+ return normalizeAgents(fallback);
642
+ }
643
+
644
+ log.debug({ s: "preset", group, requested: requestedName, resolved: null, fallback: true }, "preset fallback empty");
645
+ return {};
646
+ }
647
+
648
+ export function readRawConfig(path: string): Record<string, any> {
649
+ return loadJsonFile(path) ?? {};
650
+ }
651
+
652
+ function ensureConfigDir(configPath: string): void {
653
+ const dir = dirname(configPath);
654
+ if (!existsSync(dir)) {
655
+ mkdirSync(dir, { recursive: true });
656
+ }
657
+ }
658
+
659
+ export function writeConfigValue(configPath: string, keyPath: string[], value: any): void {
660
+ getLogger().debug({ s: "config", configPath, keyPath, value }, "writeConfigValue");
661
+ ensureConfigDir(configPath);
662
+ if (!existsSync(configPath)) writeFileSync(configPath, "{}\n", "utf-8");
663
+ const release = lockfile.lockSync(configPath, { stale: 10000 });
664
+ try {
665
+ if (keyPath.length === 0) {
666
+ writeFileSync(configPath, JSON.stringify(value ?? {}, null, 2) + "\n", "utf-8");
667
+ return;
668
+ }
669
+
670
+ const raw = readRawConfig(configPath);
671
+ let cursor: Record<string, any> = raw;
672
+ for (let i = 0; i < keyPath.length - 1; i++) {
673
+ const key = keyPath[i];
674
+ if (DANGEROUS_KEYS.has(key)) return;
675
+ const current = cursor[key];
676
+ if (!current || typeof current !== "object" || Array.isArray(current)) {
677
+ cursor[key] = {};
678
+ }
679
+ cursor = cursor[key];
680
+ }
681
+
682
+ const leaf = keyPath[keyPath.length - 1];
683
+ if (DANGEROUS_KEYS.has(leaf)) return;
684
+ cursor[leaf] = value;
685
+ writeFileSync(configPath, JSON.stringify(raw, null, 2) + "\n", "utf-8");
686
+ } finally {
687
+ release();
688
+ }
689
+ }
690
+
691
+ export function removeConfigValue(configPath: string, keyPath: string[]): void {
692
+ ensureConfigDir(configPath);
693
+ if (!existsSync(configPath)) writeFileSync(configPath, "{}\n", "utf-8");
694
+ const release = lockfile.lockSync(configPath, { stale: 10000 });
695
+ try {
696
+ if (keyPath.length === 0) {
697
+ writeFileSync(configPath, JSON.stringify({}, null, 2) + "\n", "utf-8");
698
+ return;
699
+ }
700
+
701
+ const raw = readRawConfig(configPath);
702
+ let cursor: Record<string, any> = raw;
703
+ const parents: Array<{ container: Record<string, any>; key: string }> = [];
704
+ for (let i = 0; i < keyPath.length - 1; i++) {
705
+ const key = keyPath[i];
706
+ if (DANGEROUS_KEYS.has(key)) return;
707
+ const current = cursor[key];
708
+ if (!current || typeof current !== "object" || Array.isArray(current)) {
709
+ return;
710
+ }
711
+ parents.push({ container: cursor, key });
712
+ cursor = current;
713
+ }
714
+
715
+ const leaf = keyPath[keyPath.length - 1];
716
+ if (DANGEROUS_KEYS.has(leaf)) return;
717
+ delete cursor[leaf];
718
+ for (let i = parents.length - 1; i >= 0; i -= 1) {
719
+ const parent = parents[i]!;
720
+ const current = parent.container[parent.key];
721
+ if (!isObject(current) || Object.keys(current).length > 0) break;
722
+ delete parent.container[parent.key];
723
+ }
724
+ writeFileSync(configPath, JSON.stringify(raw, null, 2) + "\n", "utf-8");
725
+ } finally {
726
+ release();
727
+ }
728
+ }
729
+
730
+ export function loadConfig(cwd: string, globalConfigPath = GLOBAL_CONFIG_PATH): NormalizedPiPiConfig {
731
+ const ppDir = join(cwd, ".pp");
732
+ const projectConfigPath = join(ppDir, "config.json");
733
+
734
+ if (!existsSync(ppDir)) {
735
+ mkdirSync(ppDir, { recursive: true });
736
+ }
737
+
738
+ const globalConfig = loadJsonFile(globalConfigPath);
739
+ const projectConfig = loadJsonFile(projectConfigPath);
740
+ return mergeConfigLayers(globalConfig, projectConfig);
232
741
  }