@quantmind-br/pi-goal-x 0.0.0-stage → 0.35.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/LICENSE +21 -0
- package/README.md +131 -2
- package/assets/badge-dark.svg +18 -0
- package/assets/badge-light.svg +18 -0
- package/docs/advanced-usage.md +106 -0
- package/docs/agent-flow-design.md +306 -0
- package/docs/agentic-runtime-prd.md +773 -0
- package/docs/architecture.md +368 -0
- package/docs/controls.md +140 -0
- package/docs/pi-autoresearch-survey.md +50 -0
- package/docs/pi-goal-x-unified-dashboard-plan.md +1667 -0
- package/docs/prompt-caching.md +31 -0
- package/docs/unified-dashboard.md +286 -0
- package/extensions/auditor-selector.ts +74 -0
- package/extensions/goal-accounting.ts +107 -0
- package/extensions/goal-activity.ts +202 -0
- package/extensions/goal-auditor.ts +480 -0
- package/extensions/goal-commands.ts +866 -0
- package/extensions/goal-compaction.ts +197 -0
- package/extensions/goal-completion.ts +173 -0
- package/extensions/goal-context.ts +12 -0
- package/extensions/goal-contract.ts +58 -0
- package/extensions/goal-controls.ts +202 -0
- package/extensions/goal-core-tools.ts +549 -0
- package/extensions/goal-core.ts +78 -0
- package/extensions/goal-detail.ts +65 -0
- package/extensions/goal-dialog-controls.ts +31 -0
- package/extensions/goal-draft.ts +244 -0
- package/extensions/goal-drafting.ts +535 -0
- package/extensions/goal-events.ts +692 -0
- package/extensions/goal-format.ts +376 -0
- package/extensions/goal-interruption.ts +53 -0
- package/extensions/goal-ledger-index.ts +68 -0
- package/extensions/goal-ledger.ts +1007 -0
- package/extensions/goal-live-retention.ts +219 -0
- package/extensions/goal-oracle.ts +346 -0
- package/extensions/goal-policy.ts +370 -0
- package/extensions/goal-pool.ts +103 -0
- package/extensions/goal-prompt-cache.ts +80 -0
- package/extensions/goal-questionnaire.ts +1073 -0
- package/extensions/goal-record.ts +388 -0
- package/extensions/goal-recovery.ts +256 -0
- package/extensions/goal-runtime.ts +277 -0
- package/extensions/goal-scheduler-state.ts +100 -0
- package/extensions/goal-scheduler.ts +436 -0
- package/extensions/goal-service.ts +843 -0
- package/extensions/goal-session-health.ts +130 -0
- package/extensions/goal-session-safety.ts +65 -0
- package/extensions/goal-settings.ts +1370 -0
- package/extensions/goal-state.ts +1069 -0
- package/extensions/goal-status.ts +305 -0
- package/extensions/goal-task-confirmation.ts +174 -0
- package/extensions/goal-task-count.ts +59 -0
- package/extensions/goal-task-derive.ts +46 -0
- package/extensions/goal-task-index.ts +57 -0
- package/extensions/goal-task-tools.ts +595 -0
- package/extensions/goal-tool-inputs.ts +102 -0
- package/extensions/goal-tool-names.ts +73 -0
- package/extensions/goal-tools.ts +16 -0
- package/extensions/goal-widget.ts +455 -0
- package/extensions/goal.ts +42 -0
- package/extensions/network-error-backoff.ts +55 -0
- package/extensions/prompts/goal-prompts.ts +321 -0
- package/extensions/storage/atomic-rename.ts +52 -0
- package/extensions/storage/goal-files.ts +695 -0
- package/extensions/storage/goal-lock.ts +131 -0
- package/extensions/storage/goal-root.ts +95 -0
- package/extensions/widgets/auditor-dashboard-model.ts +174 -0
- package/extensions/widgets/dialog-scaffold.ts +26 -0
- package/extensions/widgets/goal-controls-dialog.ts +329 -0
- package/extensions/widgets/goal-dashboard-model.ts +530 -0
- package/extensions/widgets/goal-dashboard-renderer.ts +674 -0
- package/extensions/widgets/goal-escape-dialog.ts +148 -0
- package/extensions/widgets/goal-notifications.ts +9 -0
- package/extensions/widgets/goal-widget.ts +723 -0
- package/extensions/widgets/task-list-overlay.ts +409 -0
- package/extensions/widgets/text-cache.ts +30 -0
- package/package.json +117 -4
- package/scripts/recover-session-checkpoints.mjs +289 -0
|
@@ -0,0 +1,1370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layered global goal settings (clean rewrite of PR #27).
|
|
3
|
+
*
|
|
4
|
+
* Resolution order (per leaf):
|
|
5
|
+
*
|
|
6
|
+
* environment > project layer > global layer > defaults
|
|
7
|
+
*
|
|
8
|
+
* Files:
|
|
9
|
+
*
|
|
10
|
+
* global: ${PI_CODING_AGENT_DIR:-~/.pi/agent}/pi-goal-x-settings.json
|
|
11
|
+
* project: <cwd>/.pi/pi-goal-x-settings.json
|
|
12
|
+
*
|
|
13
|
+
* Overrides: PI_GOAL_GLOBAL_SETTINGS_FILE, PI_GOAL_SETTINGS_FILE, and the
|
|
14
|
+
* per-setting PI_GOAL_* variables.
|
|
15
|
+
*
|
|
16
|
+
* Two distinct types (never one interface for both): GoalSettingsLayer is the
|
|
17
|
+
* SPARSE file content (booleans tri-state, 0 meaningful); GoalSettings is the
|
|
18
|
+
* RESOLVED runtime value with concrete defaults. Layer files are parsed into
|
|
19
|
+
* diagnostics rather than rejected wholesale — unknown keys are reported, and
|
|
20
|
+
* every valid known key still applies.
|
|
21
|
+
*
|
|
22
|
+
* Mutations go through mutateSettingsLayer(): fresh re-read under a filesystem
|
|
23
|
+
* path lock, structured-clone apply, atomic same-dir temp+rename write with
|
|
24
|
+
* mode preservation — concurrent processes never lose each other's keys.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import * as fs from "node:fs";
|
|
28
|
+
import * as os from "node:os";
|
|
29
|
+
import * as path from "node:path";
|
|
30
|
+
import { renameAtomicSync } from "./storage/atomic-rename.ts";
|
|
31
|
+
import { GOAL_COMMANDS, GOAL_KEY_ACTIONS, PAUSE_ORDERS, parseCommandPaths, parseGoalKeybinding, type GoalCommands, type GoalKeybinding, type PauseOrder } from "./goal-controls.ts";
|
|
32
|
+
|
|
33
|
+
export const GOAL_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
34
|
+
export type ThinkingLevel = typeof GOAL_THINKING_LEVELS[number];
|
|
35
|
+
|
|
36
|
+
// ── sparse keybinding layers ────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
export interface GoalDashboardKeybindings {
|
|
39
|
+
toggleExpand: GoalKeybinding;
|
|
40
|
+
scrollUp: GoalKeybinding;
|
|
41
|
+
scrollDown: GoalKeybinding;
|
|
42
|
+
scrollPageUp?: GoalKeybinding;
|
|
43
|
+
scrollPageDown?: GoalKeybinding;
|
|
44
|
+
scrollFirst?: GoalKeybinding;
|
|
45
|
+
scrollLast?: GoalKeybinding;
|
|
46
|
+
collapse?: GoalKeybinding;
|
|
47
|
+
up?: GoalKeybinding;
|
|
48
|
+
down?: GoalKeybinding;
|
|
49
|
+
pageUp?: GoalKeybinding;
|
|
50
|
+
pageDown?: GoalKeybinding;
|
|
51
|
+
first?: GoalKeybinding;
|
|
52
|
+
last?: GoalKeybinding;
|
|
53
|
+
}
|
|
54
|
+
export type GoalDashboardKeybindingsLayer = Partial<GoalDashboardKeybindings>;
|
|
55
|
+
export interface GoalKeybindings {
|
|
56
|
+
dashboard: GoalDashboardKeybindings;
|
|
57
|
+
pause?: GoalKeybinding;
|
|
58
|
+
toggleAuditor?: GoalKeybinding;
|
|
59
|
+
interruptAudit?: GoalKeybinding;
|
|
60
|
+
dialog?: Partial<Record<import("./goal-controls.ts").GoalDialogAction, GoalKeybinding>>;
|
|
61
|
+
commands?: Partial<Record<import("./goal-controls.ts").GoalCommandId, GoalKeybinding>>;
|
|
62
|
+
}
|
|
63
|
+
export interface GoalKeybindingsLayer extends Omit<GoalKeybindings, "dashboard"> {
|
|
64
|
+
dashboard?: GoalDashboardKeybindingsLayer;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const DEFAULT_GOAL_KEYBINDINGS: GoalKeybindings = {
|
|
68
|
+
dashboard: {
|
|
69
|
+
toggleExpand: "ctrl+shift+t",
|
|
70
|
+
scrollUp: "ctrl+shift+up",
|
|
71
|
+
scrollDown: "ctrl+shift+down",
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Default ceiling for a single recovery delay; the escalation ladder
|
|
77
|
+
* plateaus here. Configurable via networkRecovery.maxDelayMs.
|
|
78
|
+
*/
|
|
79
|
+
export const DEFAULT_NETWORK_RECOVERY_MAX_DELAY_MS = 80_000;
|
|
80
|
+
|
|
81
|
+
export function formatGoalKeybinding(key: GoalKeybinding | undefined): string {
|
|
82
|
+
if (Array.isArray(key)) return key.length ? key.map(k => formatGoalKeybinding(k)).join(" / ") : "Unbound";
|
|
83
|
+
if (!key) return "Unbound";
|
|
84
|
+
return key.split("+").map((part) => ({
|
|
85
|
+
ctrl: "Ctrl",
|
|
86
|
+
control: "Ctrl",
|
|
87
|
+
shift: "Shift",
|
|
88
|
+
alt: "Alt",
|
|
89
|
+
up: "↑",
|
|
90
|
+
down: "↓",
|
|
91
|
+
left: "←",
|
|
92
|
+
right: "→",
|
|
93
|
+
pageup: "PageUp",
|
|
94
|
+
pagedown: "PageDown",
|
|
95
|
+
home: "Home",
|
|
96
|
+
end: "End",
|
|
97
|
+
enter: "Enter",
|
|
98
|
+
escape: "Esc",
|
|
99
|
+
}[part.toLowerCase()] ?? part.toUpperCase())).join("+");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── resolved settings (runtime shape) ───────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
export interface GoalSettingsResolvedShape {
|
|
105
|
+
disableTasks?: boolean;
|
|
106
|
+
disableContracts?: boolean;
|
|
107
|
+
subtaskDepth?: number;
|
|
108
|
+
provider?: string;
|
|
109
|
+
model?: string;
|
|
110
|
+
thinkingLevel?: ThinkingLevel;
|
|
111
|
+
disabled?: boolean;
|
|
112
|
+
autoSelectSingleGoal?: boolean;
|
|
113
|
+
/** E3: load the project's own skills/extensions into auditor sessions (off by default = isolation). */
|
|
114
|
+
auditorProjectResources?: boolean;
|
|
115
|
+
/** F5: stall detector timeout in minutes (0 = off). */
|
|
116
|
+
stallTimeoutMinutes?: number;
|
|
117
|
+
/** Optional extension-run limit per creation/resume; zero disables, absent inherits (default unlimited). */
|
|
118
|
+
maxAutonomousRuns?: number;
|
|
119
|
+
/**
|
|
120
|
+
* Report the "Autonomous runs: used/limit" line for a finite run allowance
|
|
121
|
+
* (default true). The line reports a limit, so it appears exactly when a
|
|
122
|
+
* finite allowance exists. Display only; enforcement is unaffected.
|
|
123
|
+
*/
|
|
124
|
+
showAutonomousRuns?: boolean;
|
|
125
|
+
/** Include context-window occupancy in new prompt snapshots (off by default). */
|
|
126
|
+
showContextUsage?: boolean;
|
|
127
|
+
strictExecutionContract?: boolean;
|
|
128
|
+
/** Delay repeated executions without recognized work (off by default). */
|
|
129
|
+
idleContinuationBackoff?: boolean;
|
|
130
|
+
/**
|
|
131
|
+
* Maximum objective length in characters (0/unset = no limit, the
|
|
132
|
+
* default; >0 caps objectives in create_goal, propose_goal_draft, and
|
|
133
|
+
* /goal-tweak).
|
|
134
|
+
*/
|
|
135
|
+
objectiveMaxChars?: number;
|
|
136
|
+
/** Keyboard shortcuts for the compact task list and dashboard expansion. */
|
|
137
|
+
keybindings?: GoalKeybindings;
|
|
138
|
+
commands?: GoalCommands;
|
|
139
|
+
pauseOrder?: PauseOrder;
|
|
140
|
+
/** PR #29: suppress the unfocused goal widget + status hint (default false). */
|
|
141
|
+
hideUnfocusedBanner?: boolean;
|
|
142
|
+
/** Issue #72: suppress the model-facing [PI GOAL UNFOCUSED] prompt (default false). */
|
|
143
|
+
hideUnfocusedPrompt?: boolean;
|
|
144
|
+
goalsRoot?: string;
|
|
145
|
+
/** Issue #26: opt-in read-only blocker Oracle configuration (sparse). */
|
|
146
|
+
oracle?: GoalOracleSettingsLayer;
|
|
147
|
+
/**
|
|
148
|
+
* Goal-level provider-error recovery backoff (sparse). maxAttempts 0 or
|
|
149
|
+
* unset = unbounded retry (default); maxDelayMs caps the delay plateau.
|
|
150
|
+
*/
|
|
151
|
+
networkRecovery?: GoalNetworkRecoverySettingsLayer;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Resolved Oracle settings are attached to the resolved runtime shape below
|
|
155
|
+
// (see ResolvedGoalOracleSettings).
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Issue #26: sparse per-leaf Oracle settings. Every leaf inherits
|
|
159
|
+
* independently (project > global > default); enabled defaults false.
|
|
160
|
+
*/
|
|
161
|
+
export interface GoalOracleSettingsLayer {
|
|
162
|
+
enabled?: boolean;
|
|
163
|
+
provider?: string;
|
|
164
|
+
model?: string;
|
|
165
|
+
thinkingLevel?: ThinkingLevel;
|
|
166
|
+
projectResources?: boolean;
|
|
167
|
+
maxFailedAttemptsPerBlocker?: number;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export interface ResolvedGoalOracleSettings {
|
|
171
|
+
enabled: boolean;
|
|
172
|
+
provider?: string;
|
|
173
|
+
model?: string;
|
|
174
|
+
thinkingLevel?: ThinkingLevel;
|
|
175
|
+
projectResources: boolean;
|
|
176
|
+
maxFailedAttemptsPerBlocker: number;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Sparse per-leaf network-recovery settings (maxAttempts 0 = unbounded). */
|
|
180
|
+
export interface GoalNetworkRecoverySettingsLayer {
|
|
181
|
+
maxAttempts?: number;
|
|
182
|
+
maxDelayMs?: number;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Resolved network-recovery settings (concrete defaults). */
|
|
186
|
+
export interface ResolvedGoalNetworkRecoverySettings {
|
|
187
|
+
maxAttempts: number;
|
|
188
|
+
maxDelayMs: number;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Sparse settings layer: exactly what a settings FILE may contain. Every
|
|
193
|
+
* field optional; explicit false/0 preserved so lower layers can override
|
|
194
|
+
* higher ones. Never passed to prompt/runtime code — that consumes the
|
|
195
|
+
* resolved GoalSettings below.
|
|
196
|
+
*/
|
|
197
|
+
export interface GoalSettingsLayer extends GoalSettingsResolvedShape {}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Fully resolved runtime settings (defaults filled). Exported as an alias for
|
|
201
|
+
* compatibility: all existing consumers keep importing GoalSettings.
|
|
202
|
+
*/
|
|
203
|
+
export interface ResolvedGoalSettingsShape extends GoalSettingsResolvedShape {
|
|
204
|
+
/** Issue #26: resolved opt-in blocker Oracle configuration. */
|
|
205
|
+
oracle?: ResolvedGoalOracleSettings;
|
|
206
|
+
/** Resolved goal-level provider-error recovery configuration. */
|
|
207
|
+
networkRecovery?: ResolvedGoalNetworkRecoverySettings;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export type ResolvedGoalSettings = ResolvedGoalSettingsShape;
|
|
211
|
+
|
|
212
|
+
/** Compatibility alias: runtime code keeps importing GoalSettings. */
|
|
213
|
+
export type GoalSettings = ResolvedGoalSettings;
|
|
214
|
+
|
|
215
|
+
export const PI_GOAL_SETTINGS_FILE_ENV = "PI_GOAL_SETTINGS_FILE";
|
|
216
|
+
export const PI_GOAL_GLOBAL_SETTINGS_FILE_ENV = "PI_GOAL_GLOBAL_SETTINGS_FILE";
|
|
217
|
+
|
|
218
|
+
const THINKING_LEVELS = new Set<string>(GOAL_THINKING_LEVELS);
|
|
219
|
+
|
|
220
|
+
// ── pure path resolution ────────────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
function asNonEmptyString(value: unknown): string | undefined {
|
|
223
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Resolve the pi agent dir honoring PI_CODING_AGENT_DIR (absolute or ~-relative to homeDir). */
|
|
227
|
+
export function resolveAgentDir(env: NodeJS.ProcessEnv = process.env, homeDir: string = os.homedir()): string {
|
|
228
|
+
const override = asNonEmptyString(env.PI_CODING_AGENT_DIR);
|
|
229
|
+
if (override) {
|
|
230
|
+
return path.isAbsolute(override) ? path.normalize(override) : path.resolve(homeDir, override);
|
|
231
|
+
}
|
|
232
|
+
return path.join(homeDir, ".pi", "agent");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Global settings path: PI_GOAL_GLOBAL_SETTINGS_FILE if set (absolute or
|
|
237
|
+
* relative to homeDir), else <agentDir>/pi-goal-x-settings.json.
|
|
238
|
+
*/
|
|
239
|
+
export function goalGlobalSettingsPath(env: NodeJS.ProcessEnv = process.env, homeDir: string = os.homedir()): string {
|
|
240
|
+
const override = asNonEmptyString(env[PI_GOAL_GLOBAL_SETTINGS_FILE_ENV]);
|
|
241
|
+
if (override) {
|
|
242
|
+
return path.isAbsolute(override) ? path.normalize(override) : path.resolve(homeDir, override);
|
|
243
|
+
}
|
|
244
|
+
return path.join(resolveAgentDir(env, homeDir), "pi-goal-x-settings.json");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Project settings path: PI_GOAL_SETTINGS_FILE if set (absolute or relative
|
|
249
|
+
* to cwd), else <cwd>/.pi/pi-goal-x-settings.json.
|
|
250
|
+
*/
|
|
251
|
+
export function goalSettingsPath(cwd: string, env: NodeJS.ProcessEnv = process.env): string {
|
|
252
|
+
const override = asNonEmptyString(env[PI_GOAL_SETTINGS_FILE_ENV]);
|
|
253
|
+
if (override) {
|
|
254
|
+
return path.isAbsolute(override) ? override : path.join(cwd, override);
|
|
255
|
+
}
|
|
256
|
+
return path.join(cwd, ".pi", "pi-goal-x-settings.json");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ── diagnostics ─────────────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
export type SettingsScope = "global" | "project";
|
|
262
|
+
|
|
263
|
+
export type SettingsDiagnosticCode =
|
|
264
|
+
| "invalid_json"
|
|
265
|
+
| "not_object"
|
|
266
|
+
| "unknown_key"
|
|
267
|
+
| "invalid_value"
|
|
268
|
+
| "invalid_nested_key";
|
|
269
|
+
|
|
270
|
+
export interface SettingsDiagnostic {
|
|
271
|
+
scope: SettingsScope;
|
|
272
|
+
path: string;
|
|
273
|
+
settingPath?: string;
|
|
274
|
+
code: SettingsDiagnosticCode;
|
|
275
|
+
message: string;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export interface SettingsLayerRead {
|
|
279
|
+
scope: SettingsScope;
|
|
280
|
+
path: string;
|
|
281
|
+
status: "ok" | "missing" | "invalid";
|
|
282
|
+
layer: GoalSettingsLayer;
|
|
283
|
+
diagnostics: SettingsDiagnostic[];
|
|
284
|
+
fingerprint: string;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ── zero-op cache ───────────────────────────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
interface SettingsFileCacheEntry {
|
|
290
|
+
/** Missing/malformed file: cached so repeated loads are zero-op too. */
|
|
291
|
+
missing?: boolean;
|
|
292
|
+
read?: SettingsLayerRead;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const settingsFileCache = new Map<string, SettingsFileCacheEntry>();
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Session boundary (session_start / resume): drop the zero-op settings cache
|
|
299
|
+
* so a new session always reads both layers fresh from disk.
|
|
300
|
+
*/
|
|
301
|
+
export function invalidateGoalSettingsCache(): void {
|
|
302
|
+
settingsFileCache.clear();
|
|
303
|
+
resolvedSettingsCache.length = 0;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function invalidateSettingsCachePath(target: string): void {
|
|
307
|
+
settingsFileCache.delete(target);
|
|
308
|
+
resolvedSettingsCache.length = 0;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ── leaf parsers (diagnostic-producing, never throwing) ─────────────────────
|
|
312
|
+
|
|
313
|
+
function asBool(value: unknown): boolean | undefined {
|
|
314
|
+
if (value === true || value === "true") return true;
|
|
315
|
+
if (value === false || value === "false") return false;
|
|
316
|
+
return undefined;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function asPositiveInt(value: unknown): number | undefined {
|
|
320
|
+
if (typeof value === "number" && Number.isInteger(value) && value >= 1) return value;
|
|
321
|
+
if (typeof value === "string") {
|
|
322
|
+
const n = parseInt(value, 10);
|
|
323
|
+
if (!isNaN(n) && n >= 1) return n;
|
|
324
|
+
}
|
|
325
|
+
return undefined;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Positive-integer-or-zero parser (for settings where 0 = off / no limit). */
|
|
329
|
+
function asNonNegativeInt(value: unknown): number | undefined {
|
|
330
|
+
if (typeof value === "number" && Number.isInteger(value) && value >= 0) return value;
|
|
331
|
+
if (typeof value === "string") {
|
|
332
|
+
const n = parseInt(value, 10);
|
|
333
|
+
if (!isNaN(n) && n >= 0) return n;
|
|
334
|
+
}
|
|
335
|
+
return undefined;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function asThinkingLevel(value: unknown): ThinkingLevel | undefined {
|
|
339
|
+
const text = asNonEmptyString(value);
|
|
340
|
+
return text && THINKING_LEVELS.has(text) ? text as ThinkingLevel : undefined;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const ALLOWED_SETTINGS_KEYS = new Set([
|
|
344
|
+
"disableTasks",
|
|
345
|
+
"disableContracts",
|
|
346
|
+
"subtaskDepth",
|
|
347
|
+
"provider",
|
|
348
|
+
"model",
|
|
349
|
+
"thinkingLevel",
|
|
350
|
+
"thinking_level",
|
|
351
|
+
"disabled",
|
|
352
|
+
"autoSelectSingleGoal",
|
|
353
|
+
"auditorProjectResources",
|
|
354
|
+
"stallTimeoutMinutes",
|
|
355
|
+
"maxAutonomousRuns",
|
|
356
|
+
"strictExecutionContract",
|
|
357
|
+
"idleContinuationBackoff",
|
|
358
|
+
"objectiveMaxChars",
|
|
359
|
+
"keybindings",
|
|
360
|
+
"commands",
|
|
361
|
+
"pauseOrder",
|
|
362
|
+
"hideUnfocusedBanner",
|
|
363
|
+
"showAutonomousRuns",
|
|
364
|
+
"showContextUsage",
|
|
365
|
+
"hideUnfocusedPrompt",
|
|
366
|
+
"goalsRoot",
|
|
367
|
+
"oracle",
|
|
368
|
+
"networkRecovery",
|
|
369
|
+
]);
|
|
370
|
+
|
|
371
|
+
const ALLOWED_NETWORK_RECOVERY_KEYS = new Set(["maxAttempts", "maxDelayMs"]);
|
|
372
|
+
|
|
373
|
+
const ALLOWED_ORACLE_KEYS = new Set([
|
|
374
|
+
"enabled",
|
|
375
|
+
"provider",
|
|
376
|
+
"model",
|
|
377
|
+
"thinkingLevel",
|
|
378
|
+
"thinking_level",
|
|
379
|
+
"projectResources",
|
|
380
|
+
"maxFailedAttemptsPerBlocker",
|
|
381
|
+
]);
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Parse raw JSON content into a sparse layer plus diagnostics. Unknown keys
|
|
385
|
+
* and invalid values produce diagnostics; they never erase valid known keys
|
|
386
|
+
* in the same file, and this never throws for content problems.
|
|
387
|
+
*/
|
|
388
|
+
export function parseSettingsLayer(
|
|
389
|
+
raw: unknown,
|
|
390
|
+
scope: SettingsScope,
|
|
391
|
+
filePath: string,
|
|
392
|
+
): { layer: GoalSettingsLayer; diagnostics: SettingsDiagnostic[] } {
|
|
393
|
+
const diagnostics: SettingsDiagnostic[] = [];
|
|
394
|
+
const diagnostic = (
|
|
395
|
+
code: SettingsDiagnosticCode,
|
|
396
|
+
message: string,
|
|
397
|
+
settingPath?: string,
|
|
398
|
+
): SettingsDiagnostic => ({ scope, path: filePath, settingPath, code, message });
|
|
399
|
+
|
|
400
|
+
if (raw === null || raw === undefined) return { layer: {}, diagnostics };
|
|
401
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
402
|
+
diagnostics.push(diagnostic("not_object", "settings file must contain a JSON object"));
|
|
403
|
+
return { layer: {}, diagnostics };
|
|
404
|
+
}
|
|
405
|
+
const record = raw as Record<string, unknown>;
|
|
406
|
+
const layer: GoalSettingsLayer = {};
|
|
407
|
+
|
|
408
|
+
for (const [key, value] of Object.entries(record)) {
|
|
409
|
+
switch (key) {
|
|
410
|
+
case "idleContinuationBackoff":
|
|
411
|
+
case "strictExecutionContract":
|
|
412
|
+
case "disableTasks":
|
|
413
|
+
case "disableContracts":
|
|
414
|
+
case "disabled":
|
|
415
|
+
case "autoSelectSingleGoal":
|
|
416
|
+
case "auditorProjectResources":
|
|
417
|
+
case "hideUnfocusedBanner":
|
|
418
|
+
case "showAutonomousRuns":
|
|
419
|
+
case "showContextUsage":
|
|
420
|
+
case "hideUnfocusedPrompt": {
|
|
421
|
+
const parsed = asBool(value);
|
|
422
|
+
if (parsed === undefined) {
|
|
423
|
+
if (value !== undefined) diagnostics.push(diagnostic("invalid_value", `${key} must be true or false`, key));
|
|
424
|
+
} else {
|
|
425
|
+
layer[key] = parsed;
|
|
426
|
+
}
|
|
427
|
+
break;
|
|
428
|
+
}
|
|
429
|
+
case "subtaskDepth": {
|
|
430
|
+
const parsed = asPositiveInt(value);
|
|
431
|
+
if (parsed === undefined) diagnostics.push(diagnostic("invalid_value", `${key} must be an integer >= 1`, key));
|
|
432
|
+
else layer.subtaskDepth = parsed;
|
|
433
|
+
break;
|
|
434
|
+
}
|
|
435
|
+
case "maxAutonomousRuns": {
|
|
436
|
+
const n = typeof value === "number" ? value : typeof value === "string" && /^[0-9]+$/.test(value.trim()) ? Number(value) : NaN;
|
|
437
|
+
if (!Number.isSafeInteger(n) || n < 0) diagnostics.push(diagnostic("invalid_value", "maxAutonomousRuns must be a nonnegative safe integer (0 disables automatic continuation)", key));
|
|
438
|
+
else layer.maxAutonomousRuns = n;
|
|
439
|
+
break;
|
|
440
|
+
}
|
|
441
|
+
case "stallTimeoutMinutes":
|
|
442
|
+
case "objectiveMaxChars": {
|
|
443
|
+
const parsed = asNonNegativeInt(value);
|
|
444
|
+
if (parsed === undefined) diagnostics.push(diagnostic("invalid_value", `${key} must be an integer >= 0`, key));
|
|
445
|
+
else layer[key] = parsed;
|
|
446
|
+
break;
|
|
447
|
+
}
|
|
448
|
+
case "goalsRoot":
|
|
449
|
+
case "provider":
|
|
450
|
+
case "model": {
|
|
451
|
+
const parsed = asNonEmptyString(value);
|
|
452
|
+
if (parsed === undefined) diagnostics.push(diagnostic("invalid_value", `${key} must be a non-empty string`, key));
|
|
453
|
+
else layer[key] = parsed;
|
|
454
|
+
break;
|
|
455
|
+
}
|
|
456
|
+
case "thinkingLevel":
|
|
457
|
+
case "thinking_level": {
|
|
458
|
+
const parsed = asThinkingLevel(value);
|
|
459
|
+
if (parsed === undefined) {
|
|
460
|
+
diagnostics.push(diagnostic("invalid_value", `${key} must be one of: ${[...THINKING_LEVELS].join(", ")}`, key));
|
|
461
|
+
} else {
|
|
462
|
+
layer.thinkingLevel = parsed;
|
|
463
|
+
}
|
|
464
|
+
break;
|
|
465
|
+
}
|
|
466
|
+
case "pauseOrder": {
|
|
467
|
+
if (!PAUSE_ORDERS.some(order => order.id === value)) diagnostics.push(diagnostic("invalid_value", "pauseOrder must be together, goal-first or turn-first", key));
|
|
468
|
+
else layer.pauseOrder = value as PauseOrder;
|
|
469
|
+
break;
|
|
470
|
+
}
|
|
471
|
+
case "commands": {
|
|
472
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
473
|
+
diagnostics.push(diagnostic("invalid_nested_key", "commands must be an object", key)); break;
|
|
474
|
+
}
|
|
475
|
+
const commands: GoalCommands = {};
|
|
476
|
+
for (const [id, routes] of Object.entries(value)) {
|
|
477
|
+
if (!GOAL_COMMANDS.some(([action]) => action === id)) { diagnostics.push(diagnostic("unknown_key", `unknown command action: ${id}`, `commands.${id}`)); continue; }
|
|
478
|
+
const parsed = parseCommandPaths(routes);
|
|
479
|
+
if (!parsed) diagnostics.push(diagnostic("invalid_value", "Expected an array of command paths, or [] to disable", `commands.${id}`));
|
|
480
|
+
else commands[id as keyof GoalCommands] = parsed;
|
|
481
|
+
}
|
|
482
|
+
layer.commands = commands;
|
|
483
|
+
break;
|
|
484
|
+
}
|
|
485
|
+
case "keybindings": {
|
|
486
|
+
const sparse: Record<string, unknown> = {};
|
|
487
|
+
const visit = (record: unknown, prefix: string[], target: Record<string, unknown>): void => {
|
|
488
|
+
if (!record || typeof record !== "object" || Array.isArray(record)) { diagnostics.push(diagnostic("invalid_nested_key", `${prefix.join(".")} must be an object`, prefix.join("."))); return; }
|
|
489
|
+
for (const [name, binding] of Object.entries(record)) {
|
|
490
|
+
const path = [...prefix, name];
|
|
491
|
+
const exact = GOAL_KEY_ACTIONS.some(action => action.path.join(".") === path.join("."));
|
|
492
|
+
if (exact) {
|
|
493
|
+
const parsed = parseGoalKeybinding(binding);
|
|
494
|
+
if (parsed === undefined) diagnostics.push(diagnostic("invalid_value", "Expected a Pi key name, key list, or [] to disable", path.join(".")));
|
|
495
|
+
else target[name] = parsed;
|
|
496
|
+
} else if (GOAL_KEY_ACTIONS.some(action => action.path.slice(0, path.length).join(".") === path.join("."))) {
|
|
497
|
+
const nested: Record<string, unknown> = {}; visit(binding, path, nested); target[name] = nested;
|
|
498
|
+
} else diagnostics.push(diagnostic("unknown_key", `unknown keybinding: ${path.join(".")}`, path.join(".")));
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
visit(value, ["keybindings"], sparse);
|
|
502
|
+
layer.keybindings = sparse as unknown as GoalKeybindings;
|
|
503
|
+
break;
|
|
504
|
+
}
|
|
505
|
+
case "oracle": {
|
|
506
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
507
|
+
diagnostics.push(diagnostic("invalid_nested_key", "oracle must be an object", key));
|
|
508
|
+
break;
|
|
509
|
+
}
|
|
510
|
+
const oracleRecord = value as Record<string, unknown>;
|
|
511
|
+
const oracle: GoalOracleSettingsLayer = {};
|
|
512
|
+
for (const [oKey, oValue] of Object.entries(oracleRecord)) {
|
|
513
|
+
if (!ALLOWED_ORACLE_KEYS.has(oKey)) {
|
|
514
|
+
diagnostics.push(diagnostic("unknown_key", `unknown oracle key: ${oKey}`, `oracle.${oKey}`));
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
switch (oKey) {
|
|
518
|
+
case "enabled":
|
|
519
|
+
case "projectResources": {
|
|
520
|
+
const parsed = asBool(oValue);
|
|
521
|
+
if (parsed === undefined) diagnostics.push(diagnostic("invalid_value", `oracle.${oKey} must be true or false`, `oracle.${oKey}`));
|
|
522
|
+
else oracle[oKey] = parsed;
|
|
523
|
+
break;
|
|
524
|
+
}
|
|
525
|
+
case "provider":
|
|
526
|
+
case "model": {
|
|
527
|
+
const parsed = asNonEmptyString(oValue);
|
|
528
|
+
if (parsed === undefined) diagnostics.push(diagnostic("invalid_value", `oracle.${oKey} must be a non-empty string`, `oracle.${oKey}`));
|
|
529
|
+
else oracle[oKey] = parsed;
|
|
530
|
+
break;
|
|
531
|
+
}
|
|
532
|
+
case "thinkingLevel":
|
|
533
|
+
case "thinking_level": {
|
|
534
|
+
const parsed = asThinkingLevel(oValue);
|
|
535
|
+
if (parsed === undefined) diagnostics.push(diagnostic("invalid_value", `oracle.${oKey} must be one of: ${[...THINKING_LEVELS].join(", ")}`, `oracle.${oKey}`));
|
|
536
|
+
else oracle.thinkingLevel = parsed;
|
|
537
|
+
break;
|
|
538
|
+
}
|
|
539
|
+
case "maxFailedAttemptsPerBlocker": {
|
|
540
|
+
const parsed = asPositiveInt(oValue);
|
|
541
|
+
if (parsed === undefined || parsed > 3) {
|
|
542
|
+
diagnostics.push(diagnostic("invalid_value", "oracle.maxFailedAttemptsPerBlocker must be an integer between 1 and 3", `oracle.${oKey}`));
|
|
543
|
+
} else {
|
|
544
|
+
oracle.maxFailedAttemptsPerBlocker = parsed;
|
|
545
|
+
}
|
|
546
|
+
break;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
layer.oracle = oracle;
|
|
551
|
+
break;
|
|
552
|
+
}
|
|
553
|
+
case "networkRecovery": {
|
|
554
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
555
|
+
diagnostics.push(diagnostic("invalid_nested_key", "networkRecovery must be an object", key));
|
|
556
|
+
break;
|
|
557
|
+
}
|
|
558
|
+
const nrRecord = value as Record<string, unknown>;
|
|
559
|
+
const nr: GoalNetworkRecoverySettingsLayer = {};
|
|
560
|
+
for (const [nKey, nValue] of Object.entries(nrRecord)) {
|
|
561
|
+
if (!ALLOWED_NETWORK_RECOVERY_KEYS.has(nKey)) {
|
|
562
|
+
diagnostics.push(diagnostic("unknown_key", `unknown networkRecovery key: ${nKey}`, `networkRecovery.${nKey}`));
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
if (nKey === "maxAttempts") {
|
|
566
|
+
const parsed = asNonNegativeInt(nValue);
|
|
567
|
+
if (parsed === undefined) diagnostics.push(diagnostic("invalid_value", "networkRecovery.maxAttempts must be an integer >= 0 (0 = unbounded)", `networkRecovery.${nKey}`));
|
|
568
|
+
else nr.maxAttempts = parsed;
|
|
569
|
+
} else {
|
|
570
|
+
const parsed = asNonNegativeInt(nValue);
|
|
571
|
+
if (parsed === undefined || parsed < 1_000) diagnostics.push(diagnostic("invalid_value", "networkRecovery.maxDelayMs must be an integer >= 1000", `networkRecovery.${nKey}`));
|
|
572
|
+
else nr.maxDelayMs = parsed;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
if (Object.keys(nr).length > 0) layer.networkRecovery = nr;
|
|
576
|
+
break;
|
|
577
|
+
}
|
|
578
|
+
default:
|
|
579
|
+
diagnostics.push(diagnostic("unknown_key", `unknown settings key: ${key}`, key));
|
|
580
|
+
break;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
return { layer, diagnostics };
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Legacy strict parse: throws on unknown keys. Kept for callers that want
|
|
588
|
+
* fail-closed parsing of a whole known-shape object (e.g. tests); layered
|
|
589
|
+
* loading uses parseSettingsLayer instead.
|
|
590
|
+
*/
|
|
591
|
+
export function parseGoalSettings(raw: unknown): GoalSettings {
|
|
592
|
+
const { layer, diagnostics } = parseSettingsLayer(raw, "project", "(inline)");
|
|
593
|
+
const unknown = diagnostics
|
|
594
|
+
.filter((d) => d.code === "unknown_key" && d.settingPath && !d.settingPath.includes("."))
|
|
595
|
+
.map((d) => d.settingPath!);
|
|
596
|
+
if (unknown.length > 0) throw new Error(`Unknown pi-goal-x-settings.json key(s): ${unknown.join(", ")}`);
|
|
597
|
+
return layer as GoalSettings;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// ── layer reads (cache-served, zero-op steady state) ───────────────────────
|
|
601
|
+
|
|
602
|
+
function fingerprintOf(text: string): string {
|
|
603
|
+
// Cheap stable fingerprint: length + simple rolling sum (not security).
|
|
604
|
+
let hash = 2166136261;
|
|
605
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
606
|
+
hash ^= text.charCodeAt(i);
|
|
607
|
+
hash = Math.imul(hash, 16777619);
|
|
608
|
+
}
|
|
609
|
+
return `${text.length}:${(hash >>> 0).toString(16)}`;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function statusFor(diagnostics: SettingsDiagnostic[], hadContent: boolean): "ok" | "missing" | "invalid" {
|
|
613
|
+
if (diagnostics.some((d) => d.code === "invalid_json" || d.code === "not_object")) return "invalid";
|
|
614
|
+
if (!hadContent) return "missing";
|
|
615
|
+
return diagnostics.length > 0 ? "invalid" : "ok";
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function readSettingsLayerFresh(target: string, scope: SettingsScope): SettingsLayerRead {
|
|
619
|
+
let rawText: string | undefined;
|
|
620
|
+
try {
|
|
621
|
+
rawText = fs.readFileSync(target, "utf8");
|
|
622
|
+
} catch {
|
|
623
|
+
const read: SettingsLayerRead = {
|
|
624
|
+
scope,
|
|
625
|
+
path: target,
|
|
626
|
+
status: "missing",
|
|
627
|
+
layer: {},
|
|
628
|
+
diagnostics: [],
|
|
629
|
+
fingerprint: "missing",
|
|
630
|
+
};
|
|
631
|
+
settingsFileCache.set(target, { read });
|
|
632
|
+
return read;
|
|
633
|
+
}
|
|
634
|
+
let parsed: unknown;
|
|
635
|
+
let diagnostics: SettingsDiagnostic[] = [];
|
|
636
|
+
let layer: GoalSettingsLayer = {};
|
|
637
|
+
try {
|
|
638
|
+
parsed = JSON.parse(rawText);
|
|
639
|
+
} catch {
|
|
640
|
+
const read: SettingsLayerRead = {
|
|
641
|
+
scope,
|
|
642
|
+
path: target,
|
|
643
|
+
status: "invalid",
|
|
644
|
+
layer: {},
|
|
645
|
+
diagnostics: [{ scope, path: target, code: "invalid_json", message: "settings file is not valid JSON" }],
|
|
646
|
+
fingerprint: fingerprintOf(rawText),
|
|
647
|
+
};
|
|
648
|
+
settingsFileCache.set(target, { read });
|
|
649
|
+
return read;
|
|
650
|
+
}
|
|
651
|
+
const result = parseSettingsLayer(parsed, scope, target);
|
|
652
|
+
layer = result.layer;
|
|
653
|
+
diagnostics = result.diagnostics;
|
|
654
|
+
const read: SettingsLayerRead = {
|
|
655
|
+
scope,
|
|
656
|
+
path: target,
|
|
657
|
+
status: statusFor(diagnostics, rawText.trim().length > 0),
|
|
658
|
+
layer,
|
|
659
|
+
diagnostics,
|
|
660
|
+
fingerprint: fingerprintOf(rawText),
|
|
661
|
+
};
|
|
662
|
+
settingsFileCache.set(target, { read });
|
|
663
|
+
return read;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/** Cache-served layer read (zero-op in steady state). */
|
|
667
|
+
export function readSettingsLayer(target: string, scope: SettingsScope): SettingsLayerRead {
|
|
668
|
+
const cached = settingsFileCache.get(target);
|
|
669
|
+
if (cached?.read) return cached.read;
|
|
670
|
+
if (cached?.missing) {
|
|
671
|
+
return { scope, path: target, status: "missing", layer: {}, diagnostics: [], fingerprint: "missing" };
|
|
672
|
+
}
|
|
673
|
+
return readSettingsLayerFresh(target, scope);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/** Load just one layer's sparse config (compat helper over readSettingsLayer). */
|
|
677
|
+
export function loadGoalSettingsFileConfig(cwd: string, env: NodeJS.ProcessEnv = process.env): GoalSettings {
|
|
678
|
+
return readSettingsLayer(goalSettingsPath(cwd, env), "project").layer as GoalSettings;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// ── resolution with provenance ──────────────────────────────────────────────
|
|
682
|
+
|
|
683
|
+
export type SettingsSource = "environment" | "project" | "global" | "default";
|
|
684
|
+
|
|
685
|
+
export interface ResolvedSetting<T> {
|
|
686
|
+
value: T;
|
|
687
|
+
source: SettingsSource;
|
|
688
|
+
envVar?: string;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
export interface SettingsSnapshot {
|
|
692
|
+
global: SettingsLayerRead;
|
|
693
|
+
project: SettingsLayerRead;
|
|
694
|
+
value: ResolvedGoalSettings;
|
|
695
|
+
provenance: Map<string, ResolvedSetting<unknown>>;
|
|
696
|
+
diagnostics: SettingsDiagnostic[];
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function resolveLeaf<T>(args: {
|
|
700
|
+
envValue?: T;
|
|
701
|
+
projectValue?: T;
|
|
702
|
+
globalValue?: T;
|
|
703
|
+
defaultValue: T;
|
|
704
|
+
envVar?: string;
|
|
705
|
+
}): ResolvedSetting<T> {
|
|
706
|
+
if (args.envValue !== undefined) {
|
|
707
|
+
return { value: args.envValue, source: "environment", envVar: args.envVar };
|
|
708
|
+
}
|
|
709
|
+
if (args.projectValue !== undefined) return { value: args.projectValue, source: "project" };
|
|
710
|
+
if (args.globalValue !== undefined) return { value: args.globalValue, source: "global" };
|
|
711
|
+
return { value: args.defaultValue, source: "default" };
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function pathKey(...parts: Array<string | undefined>): string {
|
|
715
|
+
return parts.filter((p) => p !== undefined).join(".");
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Resolve both layers + env into one snapshot with per-leaf provenance.
|
|
720
|
+
* Nested keybindings resolve leaf-by-leaf from the SPARSE layers.
|
|
721
|
+
*/
|
|
722
|
+
const resolutionEnvKeys = ["PI_GOAL_ROOT", "PI_GOAL_DISABLE_TASKS", "PI_GOAL_DISABLE_CONTRACTS", "PI_GOAL_OBJECTIVE_MAX_CHARS", "PI_GOAL_NETWORK_RECOVERY_MAX_ATTEMPTS", "PI_GOAL_NETWORK_RECOVERY_MAX_DELAY_MS"] as const;
|
|
723
|
+
const resolvedSettingsCache: Array<{global: SettingsLayerRead; project: SettingsLayerRead; environment: Array<string | undefined>; snapshot: SettingsSnapshot}> = [];
|
|
724
|
+
|
|
725
|
+
function resolvedSettingsSnapshot(cwd: string, env: NodeJS.ProcessEnv): SettingsSnapshot {
|
|
726
|
+
const globalPath = goalGlobalSettingsPath(env);
|
|
727
|
+
const projectPath = goalSettingsPath(cwd, env);
|
|
728
|
+
const global = readSettingsLayer(globalPath, "global");
|
|
729
|
+
const project = readSettingsLayer(projectPath, "project");
|
|
730
|
+
for (const cached of resolvedSettingsCache) {
|
|
731
|
+
if (cached.global === global && cached.project === project && resolutionEnvKeys.every((key, i) => env[key] === cached.environment[i])) return cached.snapshot;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const provenance = new Map<string, ResolvedSetting<unknown>>();
|
|
735
|
+
const track = <T>(key: string, resolved: ResolvedSetting<T>): T => {
|
|
736
|
+
provenance.set(key, resolved as ResolvedSetting<unknown>);
|
|
737
|
+
return resolved.value;
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
const envBool = (name: string): boolean | undefined =>
|
|
741
|
+
asBool(env[name]);
|
|
742
|
+
const envInt = (name: string): number | undefined =>
|
|
743
|
+
asNonNegativeInt(env[name]);
|
|
744
|
+
|
|
745
|
+
const disableTasks = track("disableTasks", resolveLeaf<boolean>({
|
|
746
|
+
envValue: envBool("PI_GOAL_DISABLE_TASKS"),
|
|
747
|
+
projectValue: project.layer.disableTasks,
|
|
748
|
+
globalValue: global.layer.disableTasks,
|
|
749
|
+
defaultValue: false,
|
|
750
|
+
envVar: "PI_GOAL_DISABLE_TASKS",
|
|
751
|
+
}));
|
|
752
|
+
const disableContracts = track("disableContracts", resolveLeaf<boolean>({
|
|
753
|
+
envValue: envBool("PI_GOAL_DISABLE_CONTRACTS"),
|
|
754
|
+
projectValue: project.layer.disableContracts,
|
|
755
|
+
globalValue: global.layer.disableContracts,
|
|
756
|
+
defaultValue: false,
|
|
757
|
+
envVar: "PI_GOAL_DISABLE_CONTRACTS",
|
|
758
|
+
}));
|
|
759
|
+
const subtaskDepth = track("subtaskDepth", resolveLeaf<number>({
|
|
760
|
+
projectValue: project.layer.subtaskDepth,
|
|
761
|
+
globalValue: global.layer.subtaskDepth,
|
|
762
|
+
defaultValue: 1,
|
|
763
|
+
}));
|
|
764
|
+
const provider = track("provider", resolveLeaf<string>({
|
|
765
|
+
projectValue: project.layer.provider,
|
|
766
|
+
globalValue: global.layer.provider,
|
|
767
|
+
defaultValue: undefined as unknown as string,
|
|
768
|
+
}));
|
|
769
|
+
const model = track("model", resolveLeaf<string>({
|
|
770
|
+
projectValue: project.layer.model,
|
|
771
|
+
globalValue: global.layer.model,
|
|
772
|
+
defaultValue: undefined as unknown as string,
|
|
773
|
+
}));
|
|
774
|
+
const thinkingLevel = track("thinkingLevel", resolveLeaf<ThinkingLevel>({
|
|
775
|
+
projectValue: project.layer.thinkingLevel,
|
|
776
|
+
globalValue: global.layer.thinkingLevel,
|
|
777
|
+
defaultValue: undefined as unknown as ThinkingLevel,
|
|
778
|
+
}));
|
|
779
|
+
const disabled = track("disabled", resolveLeaf<boolean>({
|
|
780
|
+
projectValue: project.layer.disabled,
|
|
781
|
+
globalValue: global.layer.disabled,
|
|
782
|
+
defaultValue: false,
|
|
783
|
+
}));
|
|
784
|
+
const autoSelectSingleGoal = track("autoSelectSingleGoal", resolveLeaf<boolean>({
|
|
785
|
+
projectValue: project.layer.autoSelectSingleGoal,
|
|
786
|
+
globalValue: global.layer.autoSelectSingleGoal,
|
|
787
|
+
defaultValue: false,
|
|
788
|
+
}));
|
|
789
|
+
const auditorProjectResources = track("auditorProjectResources", resolveLeaf<boolean>({
|
|
790
|
+
projectValue: project.layer.auditorProjectResources,
|
|
791
|
+
globalValue: global.layer.auditorProjectResources,
|
|
792
|
+
defaultValue: false,
|
|
793
|
+
}));
|
|
794
|
+
const hideUnfocusedBanner = track("hideUnfocusedBanner", resolveLeaf<boolean>({
|
|
795
|
+
projectValue: project.layer.hideUnfocusedBanner,
|
|
796
|
+
globalValue: global.layer.hideUnfocusedBanner,
|
|
797
|
+
defaultValue: false,
|
|
798
|
+
}));
|
|
799
|
+
const showAutonomousRuns = track("showAutonomousRuns", resolveLeaf<boolean>({
|
|
800
|
+
projectValue: project.layer.showAutonomousRuns,
|
|
801
|
+
globalValue: global.layer.showAutonomousRuns,
|
|
802
|
+
defaultValue: true,
|
|
803
|
+
}));
|
|
804
|
+
const showContextUsage = track("showContextUsage", resolveLeaf<boolean>({
|
|
805
|
+
projectValue: project.layer.showContextUsage,
|
|
806
|
+
globalValue: global.layer.showContextUsage,
|
|
807
|
+
defaultValue: false,
|
|
808
|
+
}));
|
|
809
|
+
const hideUnfocusedPrompt = track("hideUnfocusedPrompt", resolveLeaf<boolean>({
|
|
810
|
+
projectValue: project.layer.hideUnfocusedPrompt,
|
|
811
|
+
globalValue: global.layer.hideUnfocusedPrompt,
|
|
812
|
+
defaultValue: false,
|
|
813
|
+
}));
|
|
814
|
+
const goalsRoot = track("goalsRoot", resolveLeaf<string | undefined>({
|
|
815
|
+
envValue: env.PI_GOAL_ROOT, envVar: "PI_GOAL_ROOT",
|
|
816
|
+
projectValue: project.layer.goalsRoot, globalValue: global.layer.goalsRoot, defaultValue: undefined,
|
|
817
|
+
}));
|
|
818
|
+
// Issue #26: Oracle leaves resolve per leaf like every other setting.
|
|
819
|
+
const oracleEnabled = track("oracle.enabled", resolveLeaf<boolean>({
|
|
820
|
+
projectValue: project.layer.oracle?.enabled,
|
|
821
|
+
globalValue: global.layer.oracle?.enabled,
|
|
822
|
+
defaultValue: false,
|
|
823
|
+
}));
|
|
824
|
+
const oracleProvider = track("oracle.provider", resolveLeaf<string>({
|
|
825
|
+
projectValue: project.layer.oracle?.provider,
|
|
826
|
+
globalValue: global.layer.oracle?.provider,
|
|
827
|
+
defaultValue: undefined as unknown as string,
|
|
828
|
+
}));
|
|
829
|
+
const oracleModel = track("oracle.model", resolveLeaf<string>({
|
|
830
|
+
projectValue: project.layer.oracle?.model,
|
|
831
|
+
globalValue: global.layer.oracle?.model,
|
|
832
|
+
defaultValue: undefined as unknown as string,
|
|
833
|
+
}));
|
|
834
|
+
const oracleThinkingLevel = track("oracle.thinkingLevel", resolveLeaf<ThinkingLevel>({
|
|
835
|
+
projectValue: project.layer.oracle?.thinkingLevel,
|
|
836
|
+
globalValue: global.layer.oracle?.thinkingLevel,
|
|
837
|
+
defaultValue: undefined as unknown as ThinkingLevel,
|
|
838
|
+
}));
|
|
839
|
+
const oracleProjectResources = track("oracle.projectResources", resolveLeaf<boolean>({
|
|
840
|
+
projectValue: project.layer.oracle?.projectResources,
|
|
841
|
+
globalValue: global.layer.oracle?.projectResources,
|
|
842
|
+
defaultValue: false,
|
|
843
|
+
}));
|
|
844
|
+
const oracleMaxFailedAttemptsPerBlocker = track("oracle.maxFailedAttemptsPerBlocker", resolveLeaf<number>({
|
|
845
|
+
projectValue: project.layer.oracle?.maxFailedAttemptsPerBlocker,
|
|
846
|
+
globalValue: global.layer.oracle?.maxFailedAttemptsPerBlocker,
|
|
847
|
+
defaultValue: 2,
|
|
848
|
+
}));
|
|
849
|
+
const idleContinuationBackoff = track("idleContinuationBackoff", resolveLeaf<boolean>({projectValue: project.layer.idleContinuationBackoff, globalValue: global.layer.idleContinuationBackoff, defaultValue: false}));
|
|
850
|
+
const strictExecutionContract = track("strictExecutionContract", resolveLeaf<boolean>({
|
|
851
|
+
projectValue: project.layer.strictExecutionContract,
|
|
852
|
+
globalValue: global.layer.strictExecutionContract,
|
|
853
|
+
defaultValue: false,
|
|
854
|
+
}));
|
|
855
|
+
const maxAutonomousRuns = track("maxAutonomousRuns", resolveLeaf<number | undefined>({
|
|
856
|
+
projectValue: project.layer.maxAutonomousRuns,
|
|
857
|
+
globalValue: global.layer.maxAutonomousRuns,
|
|
858
|
+
defaultValue: undefined,
|
|
859
|
+
}));
|
|
860
|
+
const stallTimeoutMinutes = track("stallTimeoutMinutes", resolveLeaf<number>({
|
|
861
|
+
projectValue: project.layer.stallTimeoutMinutes,
|
|
862
|
+
globalValue: global.layer.stallTimeoutMinutes,
|
|
863
|
+
defaultValue: 0,
|
|
864
|
+
}));
|
|
865
|
+
const objectiveMaxChars = track("objectiveMaxChars", resolveLeaf<number>({
|
|
866
|
+
envValue: envInt("PI_GOAL_OBJECTIVE_MAX_CHARS"),
|
|
867
|
+
projectValue: project.layer.objectiveMaxChars,
|
|
868
|
+
globalValue: global.layer.objectiveMaxChars,
|
|
869
|
+
defaultValue: 0,
|
|
870
|
+
envVar: "PI_GOAL_OBJECTIVE_MAX_CHARS",
|
|
871
|
+
}));
|
|
872
|
+
const networkRecoveryMaxAttempts = track("networkRecovery.maxAttempts", resolveLeaf<number>({
|
|
873
|
+
envValue: envInt("PI_GOAL_NETWORK_RECOVERY_MAX_ATTEMPTS"),
|
|
874
|
+
projectValue: project.layer.networkRecovery?.maxAttempts,
|
|
875
|
+
globalValue: global.layer.networkRecovery?.maxAttempts,
|
|
876
|
+
defaultValue: 0,
|
|
877
|
+
envVar: "PI_GOAL_NETWORK_RECOVERY_MAX_ATTEMPTS",
|
|
878
|
+
}));
|
|
879
|
+
const networkRecoveryMaxDelayMs = track("networkRecovery.maxDelayMs", resolveLeaf<number>({
|
|
880
|
+
envValue: envInt("PI_GOAL_NETWORK_RECOVERY_MAX_DELAY_MS"),
|
|
881
|
+
projectValue: project.layer.networkRecovery?.maxDelayMs,
|
|
882
|
+
globalValue: global.layer.networkRecovery?.maxDelayMs,
|
|
883
|
+
defaultValue: DEFAULT_NETWORK_RECOVERY_MAX_DELAY_MS,
|
|
884
|
+
envVar: "PI_GOAL_NETWORK_RECOVERY_MAX_DELAY_MS",
|
|
885
|
+
}));
|
|
886
|
+
|
|
887
|
+
const keybindings = { dashboard: {} } as unknown as GoalKeybindings;
|
|
888
|
+
for (const action of GOAL_KEY_ACTIONS) {
|
|
889
|
+
const get = (layer: GoalSettingsLayer): GoalKeybinding | undefined => {
|
|
890
|
+
let cursor: unknown = layer;
|
|
891
|
+
for (const key of action.path) cursor = (cursor as Record<string, unknown> | undefined)?.[key];
|
|
892
|
+
return cursor as GoalKeybinding | undefined;
|
|
893
|
+
};
|
|
894
|
+
const resolved = track(action.path.join("."), resolveLeaf<GoalKeybinding>({ projectValue: get(project.layer), globalValue: get(global.layer), defaultValue: action.defaults }));
|
|
895
|
+
let target = keybindings as unknown as Record<string, unknown>;
|
|
896
|
+
const configured = get(project.layer) !== undefined || get(global.layer) !== undefined;
|
|
897
|
+
// Keep the ordinary, uncustomized settings fast path as small as before.
|
|
898
|
+
if (!configured && !(action.path[1] === "dashboard" && ["toggleExpand", "scrollUp", "scrollDown"].includes(action.path[2]!))) continue;
|
|
899
|
+
for (const segment of action.path.slice(1, -1)) target = (target[segment] ??= {}) as Record<string, unknown>;
|
|
900
|
+
target[action.path.at(-1)!] = resolved;
|
|
901
|
+
}
|
|
902
|
+
const commands: GoalCommands = {};
|
|
903
|
+
for (const [id] of GOAL_COMMANDS) commands[id] = track(`commands.${id}`, resolveLeaf<string[]>({ projectValue: project.layer.commands?.[id], globalValue: global.layer.commands?.[id], defaultValue: [id] }));
|
|
904
|
+
const pauseOrder = track("pauseOrder", resolveLeaf<PauseOrder>({ projectValue: project.layer.pauseOrder, globalValue: global.layer.pauseOrder, defaultValue: "together" }));
|
|
905
|
+
|
|
906
|
+
const value: ResolvedGoalSettings = {
|
|
907
|
+
disableTasks,
|
|
908
|
+
disableContracts,
|
|
909
|
+
subtaskDepth,
|
|
910
|
+
...(provider ? { provider } : {}),
|
|
911
|
+
...(model ? { model } : {}),
|
|
912
|
+
...(thinkingLevel ? { thinkingLevel } : {}),
|
|
913
|
+
disabled,
|
|
914
|
+
autoSelectSingleGoal,
|
|
915
|
+
auditorProjectResources,
|
|
916
|
+
hideUnfocusedBanner,
|
|
917
|
+
showAutonomousRuns,
|
|
918
|
+
showContextUsage,
|
|
919
|
+
hideUnfocusedPrompt,
|
|
920
|
+
goalsRoot,
|
|
921
|
+
stallTimeoutMinutes,
|
|
922
|
+
maxAutonomousRuns,
|
|
923
|
+
idleContinuationBackoff,
|
|
924
|
+
strictExecutionContract,
|
|
925
|
+
objectiveMaxChars,
|
|
926
|
+
keybindings,
|
|
927
|
+
...((Object.keys(global.layer.commands ?? {}).length || Object.keys(project.layer.commands ?? {}).length) ? { commands } : {}),
|
|
928
|
+
pauseOrder,
|
|
929
|
+
networkRecovery: {
|
|
930
|
+
maxAttempts: networkRecoveryMaxAttempts,
|
|
931
|
+
maxDelayMs: networkRecoveryMaxDelayMs,
|
|
932
|
+
},
|
|
933
|
+
oracle: {
|
|
934
|
+
enabled: oracleEnabled,
|
|
935
|
+
...(oracleProvider ? { provider: oracleProvider } : {}),
|
|
936
|
+
...(oracleModel ? { model: oracleModel } : {}),
|
|
937
|
+
...(oracleThinkingLevel ? { thinkingLevel: oracleThinkingLevel } : {}),
|
|
938
|
+
projectResources: oracleProjectResources,
|
|
939
|
+
maxFailedAttemptsPerBlocker: oracleMaxFailedAttemptsPerBlocker,
|
|
940
|
+
},
|
|
941
|
+
};
|
|
942
|
+
|
|
943
|
+
const snapshot = {
|
|
944
|
+
global,
|
|
945
|
+
project,
|
|
946
|
+
value,
|
|
947
|
+
provenance,
|
|
948
|
+
diagnostics: [...global.diagnostics, ...project.diagnostics],
|
|
949
|
+
};
|
|
950
|
+
if (resolvedSettingsCache.length >= 16) resolvedSettingsCache.shift();
|
|
951
|
+
resolvedSettingsCache.push({global, project, environment: resolutionEnvKeys.map(key => env[key]), snapshot});
|
|
952
|
+
return snapshot;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
function copyResolvedSettings(value: ResolvedGoalSettings): ResolvedGoalSettings {
|
|
956
|
+
return {...value,
|
|
957
|
+
...(value.keybindings ? {keybindings: Object.keys(value.keybindings).length === 1 && Object.values(value.keybindings.dashboard).every(binding => typeof binding === "string")
|
|
958
|
+
? { dashboard: { ...value.keybindings.dashboard } } : structuredClone(value.keybindings)} : {}),
|
|
959
|
+
...(value.commands ? {commands: structuredClone(value.commands)} : {}),
|
|
960
|
+
...(value.networkRecovery ? {networkRecovery: {...value.networkRecovery}} : {}),
|
|
961
|
+
...(value.oracle ? {oracle: {...value.oracle}} : {}),
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/** Return caller-owned resolved values and provenance; cached layer reads keep their existing contract. */
|
|
966
|
+
export function loadSettingsSnapshot(cwd: string, env: NodeJS.ProcessEnv = process.env): SettingsSnapshot {
|
|
967
|
+
const snapshot = resolvedSettingsSnapshot(cwd, env);
|
|
968
|
+
return {...snapshot, value: copyResolvedSettings(snapshot.value), provenance: new Map(Array.from(snapshot.provenance, ([key, value]) => [key, {...value, value: Array.isArray(value.value) ? [...value.value] : value.value}])), diagnostics: [...snapshot.diagnostics]};
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* Load settings with layered resolution:
|
|
973
|
+
* environment > project > global > defaults.
|
|
974
|
+
*/
|
|
975
|
+
export function loadGoalSettings(cwd: string, env: NodeJS.ProcessEnv = process.env): GoalSettings {
|
|
976
|
+
return copyResolvedSettings(resolvedSettingsSnapshot(cwd, env).value);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// ── conflict-safe scoped mutation ───────────────────────────────────────────
|
|
980
|
+
|
|
981
|
+
export type SettingsMutation =
|
|
982
|
+
| { op: "set"; path: readonly string[]; value: unknown }
|
|
983
|
+
| { op: "unset"; path: readonly string[] };
|
|
984
|
+
|
|
985
|
+
export interface MutateSettingsInput {
|
|
986
|
+
scope: SettingsScope;
|
|
987
|
+
cwd: string;
|
|
988
|
+
env?: NodeJS.ProcessEnv;
|
|
989
|
+
mutation: SettingsMutation;
|
|
990
|
+
/** Optional UI validation, run against the fresh layer while its write lock is held. */
|
|
991
|
+
validate?: (next: GoalSettingsLayer, current: GoalSettingsLayer) => void;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
export class SettingsMutationError extends Error {
|
|
995
|
+
readonly diagnostics?: SettingsDiagnostic[];
|
|
996
|
+
|
|
997
|
+
constructor(message: string, diagnostics?: SettingsDiagnostic[]) {
|
|
998
|
+
super(message);
|
|
999
|
+
this.name = "SettingsMutationError";
|
|
1000
|
+
this.diagnostics = diagnostics;
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function sleepMs(ms: number): void {
|
|
1005
|
+
const buffer = new Int32Array(new SharedArrayBuffer(4));
|
|
1006
|
+
Atomics.wait(buffer, 0, 0, ms);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
function pidAlive(pid: number): boolean {
|
|
1010
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
1011
|
+
try {
|
|
1012
|
+
process.kill(pid, 0);
|
|
1013
|
+
return true;
|
|
1014
|
+
} catch (err) {
|
|
1015
|
+
return (err as NodeJS.ErrnoException).code === "EPERM";
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
interface PathLock {
|
|
1020
|
+
release(): void;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/**
|
|
1024
|
+
* Generic filesystem path lock (same discipline as the goal lock): atomic
|
|
1025
|
+
* create, bounded retries, stale-TTL/dead-pid recovery. Multiple Pi processes
|
|
1026
|
+
* may edit the same global file concurrently.
|
|
1027
|
+
*/
|
|
1028
|
+
export function acquirePathLock(
|
|
1029
|
+
lockPath: string,
|
|
1030
|
+
opts: { attempts?: number; retryMs?: number; staleTtlMs?: number } = {},
|
|
1031
|
+
): PathLock {
|
|
1032
|
+
const attempts = opts.attempts ?? 200;
|
|
1033
|
+
const retryMs = opts.retryMs ?? 5;
|
|
1034
|
+
const ttlMs = opts.staleTtlMs ?? 30_000;
|
|
1035
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
1036
|
+
const payload = JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() });
|
|
1037
|
+
|
|
1038
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
1039
|
+
try {
|
|
1040
|
+
fs.writeFileSync(lockPath, payload, { flag: "wx" });
|
|
1041
|
+
let released = false;
|
|
1042
|
+
return {
|
|
1043
|
+
release(): void {
|
|
1044
|
+
if (released) return;
|
|
1045
|
+
released = true;
|
|
1046
|
+
try {
|
|
1047
|
+
fs.unlinkSync(lockPath);
|
|
1048
|
+
} catch {
|
|
1049
|
+
// Already removed by stale recovery elsewhere.
|
|
1050
|
+
}
|
|
1051
|
+
},
|
|
1052
|
+
};
|
|
1053
|
+
} catch (err) {
|
|
1054
|
+
if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
|
|
1055
|
+
try {
|
|
1056
|
+
const stat = fs.statSync(lockPath);
|
|
1057
|
+
let pid: number | undefined;
|
|
1058
|
+
try {
|
|
1059
|
+
pid = (JSON.parse(fs.readFileSync(lockPath, "utf8")) as Record<string, unknown>).pid as number | undefined;
|
|
1060
|
+
} catch {
|
|
1061
|
+
pid = undefined;
|
|
1062
|
+
}
|
|
1063
|
+
const stale = Date.now() - stat.mtimeMs > ttlMs || (typeof pid === "number" && !pidAlive(pid));
|
|
1064
|
+
if (stale) {
|
|
1065
|
+
try {
|
|
1066
|
+
fs.unlinkSync(lockPath);
|
|
1067
|
+
} catch {
|
|
1068
|
+
// Someone else recovered it first; retry.
|
|
1069
|
+
}
|
|
1070
|
+
continue;
|
|
1071
|
+
}
|
|
1072
|
+
} catch {
|
|
1073
|
+
// Lock vanished between EEXIST and stat (holder released): brief
|
|
1074
|
+
// backoff so we never busy-spin against the next holder.
|
|
1075
|
+
sleepMs(retryMs);
|
|
1076
|
+
continue;
|
|
1077
|
+
}
|
|
1078
|
+
sleepMs(retryMs);
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
throw new SettingsMutationError(`Timed out acquiring the settings lock at ${lockPath}. Another writer may hold it.`);
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
function refuseSymlinkTarget(target: string): void {
|
|
1085
|
+
try {
|
|
1086
|
+
if (fs.lstatSync(target).isSymbolicLink()) {
|
|
1087
|
+
throw new SettingsMutationError(`refusing symlink settings target: ${target}`);
|
|
1088
|
+
}
|
|
1089
|
+
} catch (err) {
|
|
1090
|
+
if (err instanceof SettingsMutationError) throw err;
|
|
1091
|
+
// Target does not exist yet — fine.
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
function atomicWriteJson(target: string, value: unknown, options: { defaultMode: number }): void {
|
|
1096
|
+
const parent = path.dirname(target);
|
|
1097
|
+
fs.mkdirSync(parent, { recursive: true });
|
|
1098
|
+
refuseSymlinkTarget(target);
|
|
1099
|
+
|
|
1100
|
+
let mode = options.defaultMode;
|
|
1101
|
+
try {
|
|
1102
|
+
mode = fs.statSync(target).mode & 0o777;
|
|
1103
|
+
} catch {
|
|
1104
|
+
// New file: keep default mode.
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
const temp = path.join(parent, `.${path.basename(target)}.${process.pid}.${Date.now()}.tmp`);
|
|
1108
|
+
let fd: number | undefined;
|
|
1109
|
+
try {
|
|
1110
|
+
fd = fs.openSync(temp, "wx", mode);
|
|
1111
|
+
fs.writeFileSync(fd, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
1112
|
+
fs.fsyncSync(fd);
|
|
1113
|
+
fs.closeSync(fd);
|
|
1114
|
+
fd = undefined;
|
|
1115
|
+
renameAtomicSync(temp, target);
|
|
1116
|
+
} catch (error) {
|
|
1117
|
+
if (fd !== undefined) {
|
|
1118
|
+
try {
|
|
1119
|
+
fs.closeSync(fd);
|
|
1120
|
+
} catch { /* best effort */ }
|
|
1121
|
+
}
|
|
1122
|
+
try {
|
|
1123
|
+
fs.unlinkSync(temp);
|
|
1124
|
+
} catch { /* best effort */ }
|
|
1125
|
+
throw error;
|
|
1126
|
+
}
|
|
1127
|
+
try {
|
|
1128
|
+
fs.fsyncSync(fs.openSync(parent, "r"));
|
|
1129
|
+
} catch { /* directory fsync is platform-dependent; best effort */ }
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
function applyPathMutation(layer: GoalSettingsLayer, mutation: SettingsMutation): void {
|
|
1133
|
+
if (mutation.path.length === 0) throw new SettingsMutationError("empty settings path");
|
|
1134
|
+
let container: Record<string, unknown> = layer as unknown as Record<string, unknown>;
|
|
1135
|
+
for (let i = 0; i < mutation.path.length - 1; i += 1) {
|
|
1136
|
+
const key = mutation.path[i]!;
|
|
1137
|
+
const next = container[key];
|
|
1138
|
+
if (!next || typeof next !== "object") {
|
|
1139
|
+
if (mutation.op === "unset") return; // nothing to unset
|
|
1140
|
+
container[key] = {};
|
|
1141
|
+
}
|
|
1142
|
+
container = container[key] as Record<string, unknown>;
|
|
1143
|
+
}
|
|
1144
|
+
const last = mutation.path[mutation.path.length - 1]!;
|
|
1145
|
+
// thinkingLevel/thinking_level are accepted aliases on read; writes operate
|
|
1146
|
+
// on BOTH spellings so unset clears hand-edited variants too.
|
|
1147
|
+
if (last === "thinkingLevel" || last === "thinking_level") {
|
|
1148
|
+
if (mutation.op === "set") container.thinkingLevel = mutation.value;
|
|
1149
|
+
else {
|
|
1150
|
+
delete container.thinkingLevel;
|
|
1151
|
+
delete container.thinking_level;
|
|
1152
|
+
}
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
if (mutation.op === "set") container[last] = mutation.value;
|
|
1156
|
+
else delete container[last];
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/** Canonicalize the persisted spelling of aliased keys before writing. */
|
|
1160
|
+
function canonicalizeAliases(layer: Record<string, unknown>): void {
|
|
1161
|
+
if (layer.thinkingLevel !== undefined) {
|
|
1162
|
+
layer.thinking_level = layer.thinkingLevel;
|
|
1163
|
+
delete layer.thinkingLevel;
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
function pruneEmptyObjects(value: unknown): void {
|
|
1168
|
+
for (const key of Object.keys(value as Record<string, unknown>)) {
|
|
1169
|
+
const child = (value as Record<string, unknown>)[key];
|
|
1170
|
+
if (child && typeof child === "object" && !Array.isArray(child)) {
|
|
1171
|
+
pruneEmptyObjects(child);
|
|
1172
|
+
if (Object.keys(child as Record<string, unknown>).length === 0) delete (value as Record<string, unknown>)[key];
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
/**
|
|
1178
|
+
* Apply a scoped mutation under the target's path lock and return a fresh
|
|
1179
|
+
* snapshot. Refuses to overwrite an invalid layer; writes atomically;
|
|
1180
|
+
* invalidates only the affected cache entry.
|
|
1181
|
+
*/
|
|
1182
|
+
export function mutateSettingsLayer(input: MutateSettingsInput): SettingsSnapshot {
|
|
1183
|
+
const env = input.env ?? process.env;
|
|
1184
|
+
const target = input.scope === "global"
|
|
1185
|
+
? goalGlobalSettingsPath(env)
|
|
1186
|
+
: goalSettingsPath(input.cwd, env);
|
|
1187
|
+
const lock = acquirePathLock(`${target}.lock`);
|
|
1188
|
+
|
|
1189
|
+
try {
|
|
1190
|
+
const current = readSettingsLayerFresh(target, input.scope);
|
|
1191
|
+
if (current.status === "invalid") {
|
|
1192
|
+
throw new SettingsMutationError(
|
|
1193
|
+
`Refusing to overwrite invalid ${input.scope} settings at ${target}`,
|
|
1194
|
+
current.diagnostics,
|
|
1195
|
+
);
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
const next = structuredClone(current.layer) as Record<string, unknown>;
|
|
1199
|
+
applyPathMutation(next as unknown as GoalSettingsLayer, input.mutation);
|
|
1200
|
+
pruneEmptyObjects(next);
|
|
1201
|
+
canonicalizeAliases(next);
|
|
1202
|
+
|
|
1203
|
+
const reparsed = parseSettingsLayer(JSON.parse(JSON.stringify(next)), input.scope, target);
|
|
1204
|
+
if (reparsed.diagnostics.some((d) => d.code === "invalid_value" || d.code === "invalid_nested_key")) {
|
|
1205
|
+
throw new SettingsMutationError(`Mutation produced invalid ${input.scope} settings`, reparsed.diagnostics);
|
|
1206
|
+
}
|
|
1207
|
+
input.validate?.(reparsed.layer, current.layer);
|
|
1208
|
+
|
|
1209
|
+
atomicWriteJson(target, next, { defaultMode: 0o600 });
|
|
1210
|
+
invalidateSettingsCachePath(target);
|
|
1211
|
+
return loadSettingsSnapshot(input.cwd, env);
|
|
1212
|
+
} finally {
|
|
1213
|
+
lock.release();
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
// ── legacy whole-file save (routed through the locked mutation path) ────────
|
|
1218
|
+
|
|
1219
|
+
/**
|
|
1220
|
+
* Save settings to the PROJECT file. Kept for compatibility; internally uses
|
|
1221
|
+
* the conflict-safe locked mutation path (replace semantics via unset+set is
|
|
1222
|
+
* not needed here: the whole-object replace happens under the same lock with
|
|
1223
|
+
* a fresh re-read, so no cached stale object is ever written).
|
|
1224
|
+
*/
|
|
1225
|
+
export function saveGoalSettingsFileConfig(cwd: string, settings: GoalSettings): GoalSettings {
|
|
1226
|
+
const target = goalSettingsPath(cwd);
|
|
1227
|
+
const lock = acquirePathLock(`${target}.lock`);
|
|
1228
|
+
try {
|
|
1229
|
+
// Fresh re-read under the lock: never persist a cached whole object.
|
|
1230
|
+
const current = readSettingsLayerFresh(target, "project");
|
|
1231
|
+
if (current.status === "invalid") {
|
|
1232
|
+
throw new SettingsMutationError(`Refusing to overwrite invalid project settings at ${target}`, current.diagnostics);
|
|
1233
|
+
}
|
|
1234
|
+
const clean = buildPersistedLayer(settings);
|
|
1235
|
+
atomicWriteJson(target, clean, { defaultMode: 0o600 });
|
|
1236
|
+
invalidateSettingsCachePath(target);
|
|
1237
|
+
// Return the normalized runtime shape (thinkingLevel), not the
|
|
1238
|
+
// canonical persisted key (thinking_level).
|
|
1239
|
+
const returned = { ...clean };
|
|
1240
|
+
if (typeof returned.thinking_level === "string") {
|
|
1241
|
+
returned.thinkingLevel = returned.thinking_level as ThinkingLevel;
|
|
1242
|
+
delete returned.thinking_level;
|
|
1243
|
+
}
|
|
1244
|
+
return returned as unknown as GoalSettings;
|
|
1245
|
+
} finally {
|
|
1246
|
+
lock.release();
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
/** Canonical persisted form of a resolved/sparse settings object. */
|
|
1251
|
+
function buildPersistedLayer(settings: GoalSettings): Record<string, unknown> {
|
|
1252
|
+
const persisted: Record<string, unknown> = {};
|
|
1253
|
+
if (settings.provider) persisted.provider = settings.provider;
|
|
1254
|
+
if (settings.model) persisted.model = settings.model;
|
|
1255
|
+
if (settings.thinkingLevel) persisted.thinking_level = settings.thinkingLevel;
|
|
1256
|
+
if (settings.disabled !== undefined) persisted.disabled = settings.disabled;
|
|
1257
|
+
if (settings.disableTasks !== undefined) persisted.disableTasks = settings.disableTasks;
|
|
1258
|
+
if (settings.disableContracts !== undefined) persisted.disableContracts = settings.disableContracts;
|
|
1259
|
+
if (settings.subtaskDepth !== undefined) persisted.subtaskDepth = settings.subtaskDepth;
|
|
1260
|
+
if (settings.autoSelectSingleGoal !== undefined) persisted.autoSelectSingleGoal = settings.autoSelectSingleGoal;
|
|
1261
|
+
if (settings.auditorProjectResources !== undefined) persisted.auditorProjectResources = settings.auditorProjectResources;
|
|
1262
|
+
if (settings.hideUnfocusedBanner !== undefined) persisted.hideUnfocusedBanner = settings.hideUnfocusedBanner;
|
|
1263
|
+
if (settings.showAutonomousRuns !== undefined) persisted.showAutonomousRuns = settings.showAutonomousRuns;
|
|
1264
|
+
if (settings.showContextUsage !== undefined) persisted.showContextUsage = settings.showContextUsage;
|
|
1265
|
+
if (settings.goalsRoot !== undefined) persisted.goalsRoot = settings.goalsRoot;
|
|
1266
|
+
if (settings.hideUnfocusedPrompt !== undefined) persisted.hideUnfocusedPrompt = settings.hideUnfocusedPrompt;
|
|
1267
|
+
if ((settings as { networkRecovery?: ResolvedGoalNetworkRecoverySettings }).networkRecovery) {
|
|
1268
|
+
const nr = (settings as { networkRecovery?: ResolvedGoalNetworkRecoverySettings }).networkRecovery!;
|
|
1269
|
+
const o: Record<string, unknown> = {};
|
|
1270
|
+
if (nr.maxAttempts !== undefined) o.maxAttempts = nr.maxAttempts;
|
|
1271
|
+
if (nr.maxDelayMs !== undefined) o.maxDelayMs = nr.maxDelayMs;
|
|
1272
|
+
if (Object.keys(o).length > 0) persisted.networkRecovery = o;
|
|
1273
|
+
}
|
|
1274
|
+
if ((settings as { oracle?: ResolvedGoalOracleSettings }).oracle) {
|
|
1275
|
+
const o: Record<string, unknown> = {};
|
|
1276
|
+
const so = (settings as { oracle?: ResolvedGoalOracleSettings }).oracle!;
|
|
1277
|
+
if (so.enabled !== undefined) o.enabled = so.enabled;
|
|
1278
|
+
if (so.provider !== undefined) o.provider = so.provider;
|
|
1279
|
+
if (so.model !== undefined) o.model = so.model;
|
|
1280
|
+
if (so.thinkingLevel !== undefined) o.thinking_level = so.thinkingLevel;
|
|
1281
|
+
if (so.projectResources !== undefined) o.projectResources = so.projectResources;
|
|
1282
|
+
if (so.maxFailedAttemptsPerBlocker !== undefined) o.maxFailedAttemptsPerBlocker = so.maxFailedAttemptsPerBlocker;
|
|
1283
|
+
if (Object.keys(o).length > 0) persisted.oracle = o;
|
|
1284
|
+
}
|
|
1285
|
+
if (settings.idleContinuationBackoff !== undefined) persisted.idleContinuationBackoff = settings.idleContinuationBackoff;
|
|
1286
|
+
if (settings.strictExecutionContract !== undefined) persisted.strictExecutionContract = settings.strictExecutionContract;
|
|
1287
|
+
if (settings.maxAutonomousRuns !== undefined) persisted.maxAutonomousRuns = settings.maxAutonomousRuns;
|
|
1288
|
+
if (settings.stallTimeoutMinutes !== undefined) persisted.stallTimeoutMinutes = settings.stallTimeoutMinutes;
|
|
1289
|
+
if (settings.objectiveMaxChars !== undefined) persisted.objectiveMaxChars = settings.objectiveMaxChars;
|
|
1290
|
+
if (settings.keybindings) persisted.keybindings = structuredClone(settings.keybindings);
|
|
1291
|
+
if (settings.commands) persisted.commands = structuredClone(settings.commands);
|
|
1292
|
+
if (settings.pauseOrder) persisted.pauseOrder = settings.pauseOrder;
|
|
1293
|
+
return persisted;
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
// ── reporting ───────────────────────────────────────────────────────────────
|
|
1297
|
+
|
|
1298
|
+
/**
|
|
1299
|
+
* E2: which env var (if any) overrides a settings key's effective value.
|
|
1300
|
+
*/
|
|
1301
|
+
export function envOverrideFor(key: keyof GoalSettings | "settingsFile", env: NodeJS.ProcessEnv = process.env): string | null {
|
|
1302
|
+
if (key === "goalsRoot" && env.PI_GOAL_ROOT !== undefined) return "PI_GOAL_ROOT";
|
|
1303
|
+
if (key === "disableTasks" && env.PI_GOAL_DISABLE_TASKS !== undefined) return "PI_GOAL_DISABLE_TASKS";
|
|
1304
|
+
if (key === "disableContracts" && env.PI_GOAL_DISABLE_CONTRACTS !== undefined) return "PI_GOAL_DISABLE_CONTRACTS";
|
|
1305
|
+
if (key === "objectiveMaxChars" && env.PI_GOAL_OBJECTIVE_MAX_CHARS !== undefined) return "PI_GOAL_OBJECTIVE_MAX_CHARS";
|
|
1306
|
+
if (key === "networkRecovery") {
|
|
1307
|
+
if (env.PI_GOAL_NETWORK_RECOVERY_MAX_ATTEMPTS !== undefined) return "PI_GOAL_NETWORK_RECOVERY_MAX_ATTEMPTS";
|
|
1308
|
+
if (env.PI_GOAL_NETWORK_RECOVERY_MAX_DELAY_MS !== undefined) return "PI_GOAL_NETWORK_RECOVERY_MAX_DELAY_MS";
|
|
1309
|
+
}
|
|
1310
|
+
if (key === "settingsFile" && env[PI_GOAL_SETTINGS_FILE_ENV] !== undefined) return PI_GOAL_SETTINGS_FILE_ENV;
|
|
1311
|
+
return null;
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
/**
|
|
1315
|
+
* E2: effective-settings report with per-leaf provenance
|
|
1316
|
+
* (environment > project > global > default), surfaced by /goal-status.
|
|
1317
|
+
*/
|
|
1318
|
+
export function effectiveSettingsReport(cwd: string, env: NodeJS.ProcessEnv = process.env): string[] {
|
|
1319
|
+
const snapshot = loadSettingsSnapshot(cwd, env);
|
|
1320
|
+
const lines = ["Settings (provenance):"];
|
|
1321
|
+
const rows: Array<{ key: string; label: string; format: () => string }> = [
|
|
1322
|
+
{ key: "autoSelectSingleGoal", label: "autoSelectSingleGoal", format: () => String(snapshot.value.autoSelectSingleGoal) },
|
|
1323
|
+
{ key: "disableContracts", label: "disableContracts", format: () => String(snapshot.value.disableContracts) },
|
|
1324
|
+
{ key: "disableTasks", label: "disableTasks", format: () => String(snapshot.value.disableTasks) },
|
|
1325
|
+
{ key: "subtaskDepth", label: "subtaskDepth", format: () => String(snapshot.value.subtaskDepth) },
|
|
1326
|
+
{ key: "disabled", label: "auditor disabled", format: () => String(snapshot.value.disabled) },
|
|
1327
|
+
{ key: "provider", label: "provider", format: () => snapshot.value.provider ?? "(default)" },
|
|
1328
|
+
{ key: "model", label: "model", format: () => snapshot.value.model ?? "(default)" },
|
|
1329
|
+
{ key: "thinkingLevel", label: "thinking_level", format: () => snapshot.value.thinkingLevel ?? "(default)" },
|
|
1330
|
+
{ key: "auditorProjectResources", label: "auditor project resources", format: () => String(snapshot.value.auditorProjectResources) },
|
|
1331
|
+
{ key: "hideUnfocusedBanner", label: "hide unfocused banner", format: () => String(snapshot.value.hideUnfocusedBanner) },
|
|
1332
|
+
{ key: "showAutonomousRuns", label: "show autonomous runs line", format: () => String(snapshot.value.showAutonomousRuns) },
|
|
1333
|
+
{ key: "showContextUsage", label: "show context usage line", format: () => String(snapshot.value.showContextUsage) },
|
|
1334
|
+
{ key: "goalsRoot", label: "goals root (reload to change)", format: () => snapshot.value.goalsRoot ?? ".pi/goals" },
|
|
1335
|
+
{ key: "hideUnfocusedPrompt", label: "hide unfocused prompt", format: () => String(snapshot.value.hideUnfocusedPrompt) },
|
|
1336
|
+
{ key: "idleContinuationBackoff", label: "slow repeated idle replies (opt-in)", format: () => String(snapshot.value.idleContinuationBackoff) },
|
|
1337
|
+
{ key: "strictExecutionContract", label: "explicit execution contracts (opt-in)", format: () => String(snapshot.value.strictExecutionContract) },
|
|
1338
|
+
{ key: "maxAutonomousRuns", label: "autonomous run allowance", format: () => snapshot.value.maxAutonomousRuns === 0 ? "0 (disabled)" : String(snapshot.value.maxAutonomousRuns ?? "unlimited (default)") },
|
|
1339
|
+
{ key: "stallTimeoutMinutes", label: "stall timeout (minutes)", format: () => String(snapshot.value.stallTimeoutMinutes) },
|
|
1340
|
+
{ key: "objectiveMaxChars", label: "max objective length (0 = none)", format: () => String(snapshot.value.objectiveMaxChars) },
|
|
1341
|
+
{ key: "networkRecovery", label: "network recovery attempts (0 = unbounded)", format: () => String(snapshot.value.networkRecovery?.maxAttempts ?? 0) },
|
|
1342
|
+
{ key: "networkRecovery", label: "network recovery max delay (ms)", format: () => String(snapshot.value.networkRecovery?.maxDelayMs ?? DEFAULT_NETWORK_RECOVERY_MAX_DELAY_MS) },
|
|
1343
|
+
{ key: "pauseOrder", label: "pause order", format: () => snapshot.value.pauseOrder ?? "together" },
|
|
1344
|
+
{ key: "keybindings", label: "dashboard keybindings", format: () => `${snapshot.value.keybindings!.dashboard.toggleExpand}, ${snapshot.value.keybindings!.dashboard.scrollUp}, ${snapshot.value.keybindings!.dashboard.scrollDown}` },
|
|
1345
|
+
];
|
|
1346
|
+
for (const row of rows) {
|
|
1347
|
+
const resolved = snapshot.provenance.get(row.key);
|
|
1348
|
+
let source: string;
|
|
1349
|
+
if (resolved?.source === "environment") source = `env (${resolved.envVar})`;
|
|
1350
|
+
else source = resolved?.source ?? "default";
|
|
1351
|
+
lines.push(` ${row.label}: ${row.format()} (${source})`);
|
|
1352
|
+
}
|
|
1353
|
+
for (const [id] of GOAL_COMMANDS) {
|
|
1354
|
+
const source = snapshot.provenance.get(`commands.${id}`)?.source ?? "default";
|
|
1355
|
+
lines.push(` ${id}: ${(snapshot.provenance.get(`commands.${id}`)?.value as string[]).map(route => `/${route}`).join(", ") || "disabled"} (${source})`);
|
|
1356
|
+
}
|
|
1357
|
+
for (const action of GOAL_KEY_ACTIONS) {
|
|
1358
|
+
const source = snapshot.provenance.get(action.path.join("."));
|
|
1359
|
+
lines.push(` ${action.label}: ${formatGoalKeybinding(source?.value as GoalKeybinding)} (${source?.source ?? "default"})`);
|
|
1360
|
+
}
|
|
1361
|
+
lines.push(` project settings file: ${snapshot.project.path}`);
|
|
1362
|
+
lines.push(` global settings file: ${snapshot.global.path}`);
|
|
1363
|
+
const fileOverride = envOverrideFor("settingsFile", env);
|
|
1364
|
+
if (fileOverride) lines.push(` (settings file overridden by ${fileOverride})`);
|
|
1365
|
+
return lines;
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
export function isAuditorEnabledByDefault(settings: GoalSettings): boolean {
|
|
1369
|
+
return settings.disabled !== true;
|
|
1370
|
+
}
|