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