@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.
- 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 +68 -28
- 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 +631 -121
- package/extensions/orchestrator/context.test.ts +177 -10
- package/extensions/orchestrator/context.ts +115 -14
- package/extensions/orchestrator/custom-footer.ts +7 -3
- package/extensions/orchestrator/doctor.test.ts +561 -0
- package/extensions/orchestrator/doctor.ts +702 -0
- package/extensions/orchestrator/event-handlers.test.ts +84 -22
- package/extensions/orchestrator/event-handlers.ts +1220 -343
- package/extensions/orchestrator/exa.test.ts +46 -0
- package/extensions/orchestrator/exa.ts +16 -10
- package/extensions/orchestrator/flant-infra.test.ts +511 -0
- package/extensions/orchestrator/flant-infra.ts +390 -49
- package/extensions/orchestrator/index.ts +13 -2
- package/extensions/orchestrator/integration.test.ts +3065 -118
- package/extensions/orchestrator/log.test.ts +219 -0
- package/extensions/orchestrator/log.ts +153 -0
- package/extensions/orchestrator/model-registry.test.ts +302 -0
- package/extensions/orchestrator/model-registry.ts +298 -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 +298 -140
- package/extensions/orchestrator/phases/brainstorm.test.ts +10 -7
- package/extensions/orchestrator/phases/brainstorm.ts +41 -36
- 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 -31
- package/extensions/orchestrator/phases/review-task.ts +3 -7
- package/extensions/orchestrator/phases/review.test.ts +54 -0
- package/extensions/orchestrator/phases/review.ts +89 -48
- 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 +207 -0
- package/extensions/orchestrator/pp-menu.ts +2741 -373
- 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 +128 -44
- package/extensions/orchestrator/subagent-session-marker.test.ts +36 -0
- package/extensions/orchestrator/test-helpers.ts +229 -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 +563 -0
- package/extensions/orchestrator/usage-tracker.ts +96 -12
- package/extensions/orchestrator/validate-artifacts.test.ts +83 -1
- 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
|
|
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
|
|
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", "quick"];
|
|
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
|
+
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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.
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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.
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
const
|
|
212
|
-
|
|
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
|
-
|
|
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
|
}
|