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 +4 -1
- package/extensions/goal-loop-auditor.ts +5 -0
- package/extensions/goal-loop-core.ts +157 -0
- package/extensions/goal-loop-forever.ts +34 -0
- package/extensions/goal-loop-repetition.ts +61 -5
- package/extensions/goal-settings.ts +153 -0
- package/extensions/loops/goal.ts +324 -118
- package/extensions/quota-retry.ts +90 -0
- package/package.json +1 -1
- package/prompts/goal-loop-continuation.md +59 -3
- package/prompts/goal-loop-draft.md +5 -0
- package/prompts/goal-loop-forever-metricless.md +6 -0
- package/prompts/goal-loop-forever.md +6 -0
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
|
|
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
|
-
|
|
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
|
-
|
|
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 ${
|
|
199
|
-
: `same ${recentTools[0]!.tool} result ${
|
|
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
|
+
}
|