pi-goal-list-loop-audit 0.24.9 → 0.25.1

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/README.md CHANGED
@@ -200,7 +200,10 @@ No external watchdog plugin needed.
200
200
  /glla tokenlimit=0 # explicitly no cap (the default)
201
201
  /glla wedgealert=30 # hung-command alert minutes (default: 30, 0 = off)
202
202
  /glla autoresume=on # held goals/loops auto-resume in fresh sessions (unattended rigs)
203
- /glla auditcap=5 # pause the goal after N consecutive auditor disapprovals (default 3, 0 = unlimited)
203
+ /glla auditcap=5 # pause the goal after N consecutive auditor disapprovals (default 5, 0 = unlimited)
204
+ /glla aggressivemode=on # keep-going defaults: autoResume, cap 10, stuck 10, wedge off, quota auto-retry, cap→TODOs
205
+ /glla quotaretryminutes=60 # minutes before auto-retrying a quota-exhausted auditor
206
+ /glla stuckmax=10 # consecutive stuck interventions before a loop stops (default 5)
204
207
  /glla auditfeedbackchars=800 # cap the executor-visible auditor report (default 0 = full report)
205
208
  /glla autoaccept=on # drafts ACTIVATE without the Confirm dialog (unattended rigs)
206
209
  /glla project tokenlimit=500 # rare per-project override
@@ -107,6 +107,11 @@ function buildGoalAuditorPrompt(goal: Goal, completionSummary: string | null | u
107
107
  "Use read/grep/find/ls/bash as needed to inspect real artifacts. Do not mutate files or run destructive commands.",
108
108
  "If the work is only an alpha scaffold, generated template, shallow draft, proxy milestone, or lacks the user-facing value requested, disapprove.",
109
109
  "If any explicit requirement is missing, weakly verified, contradicted, or not inspectable with the available evidence, disapprove.",
110
+ "Objective shift: if the executor's <completion_summary> explicitly states that the work has shifted to other items",
111
+ "(different from the original <goal> objective), and the shift is justified — e.g. the original objective was blocked,",
112
+ "the agent pivoted to higher-ROI items, and the new items are independently verified — accept the shift as evidence",
113
+ "of reasonable engineering judgment. Do NOT rigidly disapprove because the original objective is not literally shipped;",
114
+ "audit the shifted work on its merits and say so in the report.",
110
115
  "Return a concise audit report. The final line MUST be exactly one of:",
111
116
  "<approved/>",
112
117
  "<disapproved/>",
@@ -9,6 +9,7 @@
9
9
 
10
10
  import * as fs from "node:fs";
11
11
  import * as path from "node:path";
12
+ import { execSync } from "node:child_process";
12
13
 
13
14
  // =================================================================
14
15
  // Types
@@ -144,6 +145,10 @@ export interface Goal {
144
145
  stopReason?: string;
145
146
  pauseReason?: string;
146
147
  pauseSuggestedAction?: string;
148
+ /** v0.25.0 (contract item 22): auditor objections extracted as TODOs when
149
+ * aggressiveMode keeps the goal active past the disapproval cap. Rendered
150
+ * into every continuation prompt until the next audit clears them. */
151
+ pendingTasks?: string[];
147
152
  activePath?: string;
148
153
  archivedPath?: string;
149
154
  usage: {
@@ -761,3 +766,155 @@ export function missingGllaTools(activeNames: readonly string[]): readonly GllaT
761
766
  const active = new Set(activeNames);
762
767
  return GLLA_TOOL_NAMES.filter((n) => !active.has(n));
763
768
  }
769
+
770
+ // -----------------------------------------------------------------
771
+ // v0.25.0 — eager-continuation contract helpers
772
+ // -----------------------------------------------------------------
773
+
774
+ /** Base defaults (aggressiveMode OFF). auditCap base raised 3 → 5 in
775
+ * v0.25.0 (contract item 7 — the "fairly eager" baseline). */
776
+ export const BASE_AUDIT_CAP = 5;
777
+ export const BASE_STUCK_MAX_INTERVENTIONS = 5;
778
+ /** aggressiveMode defaults (contract item 5). Explicit per-key settings
779
+ * always win over these — aggressiveMode flips DEFAULTS, not user choices. */
780
+ export const AGGRESSIVE_AUDIT_CAP = 10;
781
+ export const AGGRESSIVE_STUCK_MAX_INTERVENTIONS = 10;
782
+ export const DEFAULT_QUOTA_RETRY_MINUTES = 60;
783
+
784
+ export interface EffectiveAggressiveSettings {
785
+ auditCap: number;
786
+ stuckMaxInterventions: number;
787
+ /** 0 = wedge alerts off. */
788
+ wedgeAlertMinutes: number;
789
+ autoResume: boolean;
790
+ aggressiveMode: boolean;
791
+ }
792
+
793
+ /** Layered resolution: explicit per-key value > aggressiveMode default >
794
+ * base default (contract items 5+7). Pure so tests can assert the matrix
795
+ * without a settings file. */
796
+ export function resolveEffectiveAggressiveSettings(s: {
797
+ aggressiveMode?: boolean;
798
+ auditCap?: number;
799
+ stuckMaxInterventions?: number;
800
+ wedgeAlertMinutes?: number;
801
+ autoResume?: boolean;
802
+ }): EffectiveAggressiveSettings {
803
+ const aggressiveMode = s.aggressiveMode === true;
804
+ return {
805
+ aggressiveMode,
806
+ auditCap: s.auditCap ?? (aggressiveMode ? AGGRESSIVE_AUDIT_CAP : BASE_AUDIT_CAP),
807
+ stuckMaxInterventions:
808
+ s.stuckMaxInterventions ?? (aggressiveMode ? AGGRESSIVE_STUCK_MAX_INTERVENTIONS : BASE_STUCK_MAX_INTERVENTIONS),
809
+ wedgeAlertMinutes: s.wedgeAlertMinutes ?? (aggressiveMode ? 0 : 30),
810
+ autoResume: s.autoResume ?? aggressiveMode,
811
+ };
812
+ }
813
+
814
+ /** Extract up to `cap` actionable objection lines from an auditor report
815
+ * (contract item 22): numbered/bulleted lines, most recent last-report
816
+ * wins, longest tails trimmed. Pure — the audit-cap branch and its test
817
+ * share this. */
818
+ export function extractPendingTasks(report: string, cap = 5): string[] {
819
+ const out: string[] = [];
820
+ for (const raw of report.split("\n")) {
821
+ const line = raw.trim();
822
+ const m = line.match(/^(?:[-*•]|\d+[.)])\s+(.{8,200})$/);
823
+ if (!m) continue;
824
+ const text = m[1]!.trim();
825
+ // Skip pure-evidence bullets ("file X exists", "tests pass") — we want
826
+ // OBJECTIONS: missing/failing/not-done language.
827
+ if (!/miss|fail|not |no |lack|absent|doesn|didn|won|can'?t|remain|todo|fix|requir|incomplete|unverified/i.test(text)) continue;
828
+ if (!out.includes(text)) out.push(text);
829
+ if (out.length >= cap) break;
830
+ }
831
+ return out;
832
+ }
833
+
834
+ /** Contract item 23: is the auditor's IMPOSSIBLE reason about the WHOLE
835
+ * goal or only part of it? Default "full" (safe — keeps the pause);
836
+ * partial only on explicit subset language. */
837
+ export function classifyImpossibleReason(reason: string): "partial" | "full" {
838
+ if (/\b(partial|some items|subset|remaining items|narrow|only .{0,30}(item|part|section)|the rest|rest of)\b/i.test(reason)) {
839
+ return "partial";
840
+ }
841
+ return "full";
842
+ }
843
+
844
+ /** Contract items 25/28: does this objective read as a full-audit /
845
+ * survey pivot? */
846
+ export function isFullAuditObjective(objective: string): boolean {
847
+ return /full audit|survey|find all|task ?list|enumerate|audit the (whole |entire )?project/i.test(objective);
848
+ }
849
+
850
+ // --- Auto-committer daemon sentinel (contract item 31) ---
851
+
852
+ /** Sentinel the auto-committer (dracon-sync) filter checks: when present,
853
+ * the daemon must not rewrite/commit in this repo. The agent writes it
854
+ * after detecting filter-branch damage (see DETACHED COMMIT DETECTION in
855
+ * the continuation prompt). */
856
+ export const PAUSE_AUTO_COMMIT_SENTINEL = ".pause-auto-commit";
857
+
858
+ export function pauseAutoCommit(cwd: string, reason: string): string {
859
+ const dir = path.join(cwd, ".pi-glla");
860
+ fs.mkdirSync(dir, { recursive: true });
861
+ const file = path.join(dir, PAUSE_AUTO_COMMIT_SENTINEL);
862
+ fs.writeFileSync(file, `pausedAt: ${nowIso()}\nreason: ${reason}\n`, "utf-8");
863
+ return file;
864
+ }
865
+
866
+ export function resumeAutoCommit(cwd: string): boolean {
867
+ const file = path.join(cwd, ".pi-glla", PAUSE_AUTO_COMMIT_SENTINEL);
868
+ try {
869
+ fs.unlinkSync(file);
870
+ return true;
871
+ } catch {
872
+ return false;
873
+ }
874
+ }
875
+
876
+ export function isAutoCommitPaused(cwd: string): boolean {
877
+ try {
878
+ fs.accessSync(path.join(cwd, ".pi-glla", PAUSE_AUTO_COMMIT_SENTINEL));
879
+ return true;
880
+ } catch {
881
+ return false;
882
+ }
883
+ }
884
+
885
+ // --- Heartbeat ship-suppression (contract item 27) ---
886
+
887
+ /** Suppress the heartbeat when work shipped very recently — a session
888
+ * that just committed is transitioning, not stalled. Pure; the tick
889
+ * gathers the timestamps. */
890
+ export function shouldSuppressHeartbeatForRecentShip(args: {
891
+ nowMs: number;
892
+ lastShippedAtMs: number | null;
893
+ windowMs?: number;
894
+ }): boolean {
895
+ const windowMs = args.windowMs ?? 5 * 60_000;
896
+ if (args.lastShippedAtMs === null) return false;
897
+ return args.nowMs - args.lastShippedAtMs < windowMs;
898
+ }
899
+
900
+ /** Best-effort "when did work last ship" for a repo: newest of the HEAD
901
+ * commit time and the .pi-glla state file mtime. Null when unknown. */
902
+ export function lastShippedAtMs(cwd: string): number | null {
903
+ let best: number | null = null;
904
+ try {
905
+ const out = execSync("git log -1 --format=%ct", { cwd, stdio: ["ignore", "pipe", "ignore"] })
906
+ .toString()
907
+ .trim();
908
+ const sec = Number(out);
909
+ if (Number.isFinite(sec) && sec > 0) best = sec * 1000;
910
+ } catch {
911
+ /* not a git repo or no commits */
912
+ }
913
+ try {
914
+ const mtime = fs.statSync(path.join(cwd, ".pi-glla", "active.jsonl")).mtimeMs;
915
+ if (best === null || mtime > best) best = mtime;
916
+ } catch {
917
+ /* no state file yet */
918
+ }
919
+ return best;
920
+ }
@@ -75,6 +75,33 @@ export interface LoopState {
75
75
  consecutiveStuck?: number;
76
76
  /** v0.24.0: the last stuck reason (for the intervention directive + ledger). */
77
77
  lastStuckReason?: string;
78
+ /** v0.25.1: /loop start toolsamerepeat=N — legacy same-tool-same-result
79
+ * check window. 0 disables it (multi-signal detector only). */
80
+ toolSameRepeat?: number;
81
+ /** v0.25.1: per-iteration progress-signal accumulators for the
82
+ * multi-signal stuck gate. fileWrites bumps on write/edit tool results;
83
+ * iterationStartHead/At snapshot when the iteration BEGAN so the tick can
84
+ * count commits and spec_item_progress events produced during it. */
85
+ iterMetrics?: {
86
+ fileWrites: number;
87
+ iterationStartHead?: string;
88
+ iterationStartAt?: string;
89
+ };
90
+ }
91
+
92
+ /** v0.25.1: stop reason for /loop finish — a clean "completed" end,
93
+ * distinct from stuck/plateau/stopped-by-user. */
94
+ export function loopFinishStopReason(reason?: string): string {
95
+ const r = (reason ?? "").trim();
96
+ return `completed: ${r || "finished by user"}`;
97
+ }
98
+
99
+ /** v0.25.1: tool names that count as file-write progress signals for the
100
+ * multi-signal stuck gate (item 3). */
101
+ export const LOOP_WRITE_TOOLS = ["write", "edit", "multi_edit", "write_file"] as const;
102
+
103
+ export function isLoopWriteTool(toolName: string): boolean {
104
+ return (LOOP_WRITE_TOOLS as readonly string[]).includes(toolName);
78
105
  }
79
106
 
80
107
  /** Scratch-branch name for branch=1 mode. Format pinned by tests. */
@@ -229,6 +256,7 @@ export function parseLoopStartArgs(raw: string): {
229
256
  force: boolean;
230
257
  timeLimitHours?: number;
231
258
  tokenBudget?: number;
259
+ toolSameRepeat?: number;
232
260
  } {
233
261
  // Key=value pairs first (measure= and direction= may hold quoted values),
234
262
  // the remaining text is the target.
@@ -294,6 +322,12 @@ export function parseLoopStartArgs(raw: string): {
294
322
  force: forceRaw === "1" || forceRaw === "true" || forceRaw === "yes",
295
323
  timeLimitHours: Number.isFinite(timeRaw) && timeRaw > 0 ? timeRaw : undefined,
296
324
  tokenBudget: Number.isFinite(tokensRaw) && tokensRaw > 0 ? tokensRaw : undefined,
325
+ toolSameRepeat: (() => {
326
+ const raw = (kv.get("toolsamerepeat") ?? "").trim();
327
+ if (!raw) return undefined;
328
+ const n = Number.parseInt(raw, 10);
329
+ return Number.isInteger(n) && n >= 0 ? n : undefined;
330
+ })(),
297
331
  };
298
332
  }
299
333
 
@@ -142,6 +142,59 @@ export interface LoopStuckInput {
142
142
  recentToolResults: ToolResultPrint[];
143
143
  /** Consecutive iterations with zero tool calls, including this one. */
144
144
  toollessStreak: number;
145
+ /** v0.25.1 progress signals (multi-signal stuck model): file writes,
146
+ * git commits, and spec_item_progress events in the FINISHED iteration.
147
+ * Optional for backward compat — absent = 0. */
148
+ fileWriteCount?: number;
149
+ gitCommitCount?: number;
150
+ specItemProgressCount?: number;
151
+ }
152
+
153
+ /**
154
+ * v0.25.1: forward-transition marker — the agent explicitly declares it is
155
+ * moving on ("Next step (iter-222, implement branch)"). Conservative word
156
+ * list: if the agent isn't saying "moving on", it's probably spinning.
157
+ */
158
+ export function forwardTransitionMarker(text: string): boolean {
159
+ if (
160
+ /\b(next|next step|next phantom|next iter|iter[ -]\d+|pivot|moving on|implement next|switch to|now (do|implement|fix|add)|then (do|implement|fix|add))\b/i.test(
161
+ text,
162
+ )
163
+ ) {
164
+ return true;
165
+ }
166
+ // Line-start "Next:" / "Next step" — the phrasing the wild-caught
167
+ // transcripts used.
168
+ return /^\s*next\s*(step)?\s*:/im.test(text);
169
+ }
170
+
171
+ /** A forward marker only counts PAIRED with a real write/commit in the
172
+ * same iteration — pure narration ("next: implement X" × N, shipping
173
+ * nothing) is the narrate-but-don't-ship loop and IS stuck. */
174
+ export function forwardTransitionPaired(input: LoopStuckInput): boolean {
175
+ if (!forwardTransitionMarker(input.assistantText)) return false;
176
+ return (input.fileWriteCount ?? 0) > 0 || (input.gitCommitCount ?? 0) > 0;
177
+ }
178
+
179
+ /**
180
+ * v0.25.1 multi-signal stuck gate (progress-signals model): an iteration
181
+ * is stuck ONLY when every progress signal is zero — file writes, git
182
+ * commits, spec_item_progress events, and a PAIRED forward transition —
183
+ * AND the legacy single-signal detector also fires. Stable verification
184
+ * output from a loop that is still shipping is the GOAL state of a
185
+ * metricless loop, not the stuck state (the two wild-caught transcripts
186
+ * that motivated this rework both died in exactly that false positive).
187
+ *
188
+ * `toolSameRepeat` (the /loop start toolsamerepeat=N kwarg) forwards to
189
+ * the legacy check: 0 disables the same-tool-same-result branch entirely
190
+ * (new detector only), undefined = REPETITION.toolResultRepeat.
191
+ */
192
+ export function isActuallyStuck(input: LoopStuckInput, toolSameRepeat?: number): string | undefined {
193
+ if ((input.fileWriteCount ?? 0) > 0) return undefined;
194
+ if ((input.gitCommitCount ?? 0) > 0) return undefined;
195
+ if ((input.specItemProgressCount ?? 0) > 0) return undefined;
196
+ if (forwardTransitionPaired(input)) return undefined;
197
+ return detectLoopStuck(input, toolSameRepeat);
145
198
  }
146
199
 
147
200
  function clip(text: string, n: number): string {
@@ -154,7 +207,7 @@ function clip(text: string, n: number): string {
154
207
  * or undefined when it's working normally. Checks run cheapest-and-most-
155
208
  * certain first; the first hit wins so the reason stays specific.
156
209
  */
157
- export function detectLoopStuck(input: LoopStuckInput): string | undefined {
210
+ export function detectLoopStuck(input: LoopStuckInput, toolResultRepeatOverride?: number): string | undefined {
158
211
  const { assistantText, recentPrints, previousText, recentToolResults, toollessStreak } = input;
159
212
 
160
213
  // Narration only: iterations that never touch tools produce nothing inspectable.
@@ -189,14 +242,17 @@ export function detectLoopStuck(input: LoopStuckInput): string | undefined {
189
242
  }
190
243
 
191
244
  // Same tool, same output, three times running: the loop is re-reading a result it already has.
192
- const recentTools = recentToolResults.slice(-REPETITION.toolResultRepeat);
245
+ // v0.25.1: toolsamerepeat=0 disables this branch entirely (new detector only).
246
+ const toolResultRepeat = toolResultRepeatOverride ?? REPETITION.toolResultRepeat;
247
+ const recentTools = toolResultRepeat > 0 ? recentToolResults.slice(-toolResultRepeat) : [];
193
248
  if (
194
- recentTools.length === REPETITION.toolResultRepeat &&
249
+ toolResultRepeat > 0 &&
250
+ recentTools.length === toolResultRepeat &&
195
251
  recentTools.every((r) => r.tool === recentTools[0]!.tool && r.hash === recentTools[0]!.hash)
196
252
  ) {
197
253
  return recentTools.every((r) => r.isError)
198
- ? `same ${recentTools[0]!.tool} error ${REPETITION.toolResultRepeat}× in a row`
199
- : `same ${recentTools[0]!.tool} result ${REPETITION.toolResultRepeat}× in a row (no new information)`;
254
+ ? `same ${recentTools[0]!.tool} error ${toolResultRepeat}× in a row`
255
+ : `same ${recentTools[0]!.tool} result ${toolResultRepeat}× in a row (no new information)`;
200
256
  }
201
257
 
202
258
  return undefined;
@@ -0,0 +1,153 @@
1
+ // pi-goal-list-loop-audit — v0.25.0
2
+ // extensions/goal-settings.ts
3
+ //
4
+ // The settings layer, extracted from loops/goal.ts so tests can drive it
5
+ // without importing the whole extension. Two-tier config (v0.7.0): GLOBAL
6
+ // is the normal home, PROJECT the rare local override. Resolution:
7
+ // project > global > defaults (per key).
8
+
9
+ import * as fs from "node:fs";
10
+ import * as os from "node:os";
11
+ import * as path from "node:path";
12
+
13
+ import {
14
+ DEFAULT_AUDIT_FEEDBACK_CHARS,
15
+ DEFAULT_QUOTA_RETRY_MINUTES,
16
+ mergeSettings,
17
+ piGlaDir,
18
+ } from "./goal-loop-core.ts";
19
+ import type { SubagentModelStrategy } from "./goal-loop-subagents.js";
20
+
21
+ export interface Settings {
22
+ /** "provider/model-id" or bare "model-id". Unset → session model. */
23
+ auditorModel?: string;
24
+ auditorThinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
25
+ /** Shell command run on goal complete / goal pause / loop stop; message passed as $1. */
26
+ notifyCmd?: string;
27
+ /** Per-goal token budget; crossing it pauses the goal. Off by default
28
+ * (opt-in guard, v0.12.0): unset/0 = no budget. */
29
+ tokenLimit?: number;
30
+ /** v0.23.2: minutes of busy-but-silent before the wedge alert fires
31
+ * (hung-command detector). Unset = 45; 0 = off. */
32
+ wedgeAlertMinutes?: number;
33
+ /** on → restored goals/loops/lists auto-resume even in fresh sessions
34
+ * (unattended rigs). Default off: restore holds until /goal resume. */
35
+ autoResume?: boolean;
36
+ /** v0.24.2: pause the goal after N consecutive auditor disapprovals (0 = unlimited).
37
+ * Default 5 (raised from 3 in v0.25.0, contract item 7). */
38
+ auditCap?: number;
39
+ /** Maximum auditor-report characters returned to the executor after a
40
+ * disapproval (0 = full report). Default 0 (full report). */
41
+ auditFeedbackChars?: number;
42
+ /** v0.25.0: flip the continuation defaults toward keep-going
43
+ * (contract item 5): autoResume on, auditCap 10, stuckMax 10, wedge off,
44
+ * quota errors auto-retry silently. Explicit per-key settings still win. */
45
+ aggressiveMode?: boolean;
46
+ /** Minutes to wait before auto-retrying a quota-exhausted auditor when
47
+ * the upstream gave no Retry-After hint (contract item 11). Default 60. */
48
+ quotaRetryMinutes?: number;
49
+ /** Consecutive stuck interventions before a loop stops (default 5,
50
+ * 10 under aggressiveMode). */
51
+ stuckMaxInterventions?: number;
52
+ /** on → propose_* drafts activate WITHOUT the Confirm dialog and the
53
+ * interview floor is skipped — the seed carries the intent (unattended
54
+ * rigs). Default off: nothing activates before the user confirms. */
55
+ autoAcceptDrafts?: boolean;
56
+ /** v0.24.6: subagent model strategy for pi-subagents default agents that
57
+ * pin a model (Explore pins claude-haiku-4-5, which silently routes
58
+ * subagents to a different provider/quota pool than the session).
59
+ * "inherit-parent" (default) writes a managed ~/.pi/agent/agents/Explore.md
60
+ * override without the model pin so subagents share the session model and
61
+ * its quota; "agent-default" restores upstream behavior. Applies to NEW
62
+ * sessions (pi-subagents registers agents at session start). */
63
+ subagentModelStrategy?: SubagentModelStrategy;
64
+ /** v0.24.6: per-agent-type model pin, e.g. { "Explore": "minimax/MiniMax-M3" }.
65
+ * Always wins over subagentModelStrategy — the managed override is written
66
+ * WITH this pin regardless of strategy. */
67
+ subagentModelOverrides?: Record<string, string>;
68
+ }
69
+
70
+ export const DEFAULT_SETTINGS: Settings = {
71
+ // Unset = follow the pi session thinking level (user selects thinking in
72
+ // pi, auditor follows), floor "high" — the auditor is the verification
73
+ // gate, depth is worth more there than speed. /glla thinking= overrides.
74
+ auditorThinkingLevel: undefined,
75
+ // v0.24.6: subagents inherit the session model by default — one quota
76
+ // pool, no surprise 403s from a pinned default agent's provider.
77
+ subagentModelStrategy: "inherit-parent",
78
+ auditFeedbackChars: DEFAULT_AUDIT_FEEDBACK_CHARS,
79
+ // v0.25.0 (contract Section B): keep-going is opt-in via aggressiveMode;
80
+ // the dial flips DEFAULTS, never explicit per-key user settings.
81
+ aggressiveMode: false,
82
+ quotaRetryMinutes: DEFAULT_QUOTA_RETRY_MINUTES,
83
+ };
84
+
85
+ export function globalSettingsPath(): string {
86
+ return path.join(os.homedir(), ".pi", "agent", "pi-goal-list-loop-audit.settings.json");
87
+ }
88
+
89
+ export function projectSettingsPath(cwd: string): string {
90
+ return path.join(piGlaDir(cwd), "settings.json");
91
+ }
92
+
93
+ export function readSettingsFile(file: string): Partial<Settings> {
94
+ try {
95
+ if (!fs.existsSync(file)) return {};
96
+ const parsed = JSON.parse(fs.readFileSync(file, "utf-8"));
97
+ return typeof parsed === "object" && parsed !== null ? parsed as Partial<Settings> : {};
98
+ } catch {
99
+ return {};
100
+ }
101
+ }
102
+
103
+ export function loadSettings(cwd: string): Settings {
104
+ return mergeSettings(
105
+ DEFAULT_SETTINGS as unknown as Record<string, unknown>,
106
+ readSettingsFile(globalSettingsPath()) as Record<string, unknown>,
107
+ readSettingsFile(projectSettingsPath(cwd)) as Record<string, unknown>,
108
+ ) as unknown as Settings;
109
+ }
110
+
111
+ /** Every provenance-tracked key (the /glla headless display + UI). */
112
+ export const SETTINGS_KEYS: Array<keyof Settings> = [
113
+ "auditorModel",
114
+ "auditorThinkingLevel",
115
+ "notifyCmd",
116
+ "tokenLimit",
117
+ "wedgeAlertMinutes",
118
+ "autoResume",
119
+ "autoAcceptDrafts",
120
+ "auditCap",
121
+ "auditFeedbackChars",
122
+ "subagentModelStrategy",
123
+ "subagentModelOverrides",
124
+ "aggressiveMode",
125
+ "quotaRetryMinutes",
126
+ "stuckMaxInterventions",
127
+ ];
128
+
129
+ /** Where each effective setting comes from (for the /glla display). */
130
+ export function settingsProvenance(cwd: string): Record<keyof Settings, { value: unknown; source: "project" | "global" | "default" }> {
131
+ const proj = readSettingsFile(projectSettingsPath(cwd));
132
+ const glob = readSettingsFile(globalSettingsPath());
133
+ const effective = loadSettings(cwd);
134
+ const out: Record<string, { value: unknown; source: "project" | "global" | "default" }> = {};
135
+ for (const k of SETTINGS_KEYS) {
136
+ if ((proj as Record<string, unknown>)[k] !== undefined) out[k] = { value: (proj as any)[k], source: "project" };
137
+ else if ((glob as Record<string, unknown>)[k] !== undefined) out[k] = { value: (glob as any)[k], source: "global" };
138
+ else out[k] = { value: (effective as any)[k], source: "default" };
139
+ }
140
+ return out as Record<keyof Settings, { value: unknown; source: "project" | "global" | "default" }>;
141
+ }
142
+
143
+ export function saveSettings(scope: "global" | "project", cwd: string, patch: Partial<Settings>): void {
144
+ const file = scope === "global" ? globalSettingsPath() : projectSettingsPath(cwd);
145
+ const current = readSettingsFile(file);
146
+ const next: Record<string, unknown> = { ...current };
147
+ for (const [k, v] of Object.entries(patch)) {
148
+ if (v === undefined) delete next[k]; // key=unset removes the key
149
+ else next[k] = v;
150
+ }
151
+ fs.mkdirSync(path.dirname(file), { recursive: true });
152
+ fs.writeFileSync(file, JSON.stringify(next, null, 2));
153
+ }