@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.
- package/3p/pi-ask-user/index.ts +65 -49
- package/3p/pi-subagents/src/agent-manager.ts +8 -0
- package/3p/pi-subagents/src/agent-runner.ts +112 -19
- package/3p/pi-subagents/src/index.ts +3 -0
- package/extensions/orchestrator/agents/brainstorm-reviewer.ts +32 -19
- package/extensions/orchestrator/agents/code-reviewer.ts +31 -17
- package/extensions/orchestrator/agents/constraints.ts +55 -0
- package/extensions/orchestrator/agents/explore.ts +13 -13
- package/extensions/orchestrator/agents/librarian.ts +12 -9
- package/extensions/orchestrator/agents/plan-reviewer.ts +31 -19
- package/extensions/orchestrator/agents/planner.ts +28 -23
- package/extensions/orchestrator/agents/registry.ts +2 -1
- package/extensions/orchestrator/agents/repo-context.ts +11 -0
- package/extensions/orchestrator/agents/task.ts +14 -11
- package/extensions/orchestrator/agents/tool-routing.ts +17 -32
- package/extensions/orchestrator/ast-search.ts +2 -1
- package/extensions/orchestrator/cbm.test.ts +35 -0
- package/extensions/orchestrator/cbm.ts +43 -13
- package/extensions/orchestrator/command-handlers.test.ts +390 -19
- package/extensions/orchestrator/command-handlers.ts +73 -31
- package/extensions/orchestrator/commands.test.ts +255 -2
- package/extensions/orchestrator/commands.ts +108 -10
- package/extensions/orchestrator/config.test.ts +289 -68
- package/extensions/orchestrator/config.ts +630 -121
- package/extensions/orchestrator/context.test.ts +177 -10
- package/extensions/orchestrator/context.ts +115 -14
- package/extensions/orchestrator/custom-footer.ts +2 -1
- package/extensions/orchestrator/doctor.test.ts +559 -0
- package/extensions/orchestrator/doctor.ts +664 -0
- package/extensions/orchestrator/event-handlers.test.ts +84 -22
- package/extensions/orchestrator/event-handlers.ts +1191 -360
- package/extensions/orchestrator/exa.test.ts +46 -0
- package/extensions/orchestrator/exa.ts +16 -10
- package/extensions/orchestrator/flant-infra.test.ts +224 -0
- package/extensions/orchestrator/flant-infra.ts +110 -41
- package/extensions/orchestrator/index.ts +13 -2
- package/extensions/orchestrator/integration.test.ts +2866 -118
- package/extensions/orchestrator/log.test.ts +219 -0
- package/extensions/orchestrator/log.ts +153 -0
- package/extensions/orchestrator/model-registry.test.ts +238 -0
- package/extensions/orchestrator/model-registry.ts +282 -0
- package/extensions/orchestrator/model-version.test.ts +27 -0
- package/extensions/orchestrator/model-version.ts +19 -0
- package/extensions/orchestrator/orchestrator.test.ts +206 -56
- package/extensions/orchestrator/orchestrator.ts +295 -148
- package/extensions/orchestrator/phases/brainstorm.test.ts +10 -7
- package/extensions/orchestrator/phases/brainstorm.ts +41 -35
- package/extensions/orchestrator/phases/implementation.ts +7 -11
- package/extensions/orchestrator/phases/machine.test.ts +27 -8
- package/extensions/orchestrator/phases/machine.ts +13 -0
- package/extensions/orchestrator/phases/planning.ts +57 -29
- package/extensions/orchestrator/phases/review-task.ts +3 -3
- package/extensions/orchestrator/phases/review.ts +38 -39
- package/extensions/orchestrator/phases/spawn-blocking.test.ts +69 -0
- package/extensions/orchestrator/phases/verdict.test.ts +139 -0
- package/extensions/orchestrator/phases/verdict.ts +82 -0
- package/extensions/orchestrator/plannotator.test.ts +85 -0
- package/extensions/orchestrator/plannotator.ts +24 -6
- package/extensions/orchestrator/pp-menu.test.ts +134 -0
- package/extensions/orchestrator/pp-menu.ts +2631 -392
- package/extensions/orchestrator/repo-utils.test.ts +151 -0
- package/extensions/orchestrator/repo-utils.ts +67 -0
- package/extensions/orchestrator/spawn-cleanup.test.ts +57 -0
- package/extensions/orchestrator/spawn-cleanup.ts +35 -0
- package/extensions/orchestrator/state.test.ts +76 -6
- package/extensions/orchestrator/state.ts +89 -26
- package/extensions/orchestrator/subagent-session-marker.test.ts +36 -0
- package/extensions/orchestrator/test-helpers.ts +217 -0
- package/extensions/orchestrator/tracer.test.ts +132 -0
- package/extensions/orchestrator/tracer.ts +127 -0
- package/extensions/orchestrator/transition-controller.test.ts +207 -0
- package/extensions/orchestrator/transition-controller.ts +259 -0
- package/extensions/orchestrator/usage-tracker.test.ts +435 -0
- package/extensions/orchestrator/usage-tracker.ts +23 -2
- package/extensions/orchestrator/validate-artifacts.test.ts +83 -1
- 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
|
|
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
|
|
13
|
-
enabled
|
|
18
|
+
export interface PresetAgentConfig extends AgentConfig {
|
|
19
|
+
enabled?: boolean;
|
|
14
20
|
}
|
|
15
21
|
|
|
16
|
-
export interface
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
32
|
+
export interface AfterEditCommandConfig {
|
|
22
33
|
run: string;
|
|
34
|
+
globs?: string[];
|
|
35
|
+
enabled?: boolean;
|
|
23
36
|
}
|
|
24
37
|
|
|
25
|
-
export interface
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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:
|
|
53
|
-
afterImplement:
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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.
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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.
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
const
|
|
212
|
-
|
|
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
|
-
|
|
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
|
}
|