pi-warden 0.64.1 → 0.71.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.
@@ -0,0 +1,274 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { writeFileAtomicSync } from "./atomic.js";
4
+ import { userConfigPath } from "./config.js";
5
+ export const STEER_KINDS = [
6
+ "intent-mismatch", "off-task", "should-proceed", "warn-headless", "large-output", "slop", "rules", "sensitive-path",
7
+ "security-write", "stuck", "repeat", "done", "prose", "conscience", "loops", "runaway", "subagent",
8
+ "hold", "confirm", "deny", "masking", "credential-notice",
9
+ ];
10
+ /**
11
+ * Never trace-only, whatever a model does with them: holds, confirm and deny verdicts, security masking, the credential
12
+ * notice on masked output, and the done-check. Runaway recovery and subagent wakes start a turn of their own; muting
13
+ * them would change what the run does, not only what the agent is told.
14
+ */
15
+ export const NEVER_MUTED = new Set([
16
+ "hold", "confirm", "deny", "masking", "credential-notice", "done", "runaway", "subagent",
17
+ ]);
18
+ /** Where the counts live: next to the user config, in pi-warden's data folder. */
19
+ export function steerStatsPath() {
20
+ return process.env.PI_WARDEN_STEER_STATS ?? join(dirname(userConfigPath()), "steer-stats.json");
21
+ }
22
+ export const DEFAULT_STEERS = { adaptive: true, minSteers: 30, minFollowed: 0.2, maxDisputed: 0.4, recheckEvery: 30, probeEvery: 5 };
23
+ const zero = () => ({ sent: 0, followed: 0, disputed: 0 });
24
+ const fresh = () => ({ ...zero(), muted: false, asked: 0, probe: zero() });
25
+ /** Kinds whose "followed" can be read from the next calls or text; the rest are judged by disputes alone. */
26
+ const FOLLOW_MEASURED = new Set([
27
+ "intent-mismatch", "off-task", "should-proceed", "warn-headless", "large-output", "slop", "rules", "security-write",
28
+ "stuck", "repeat", "done", "conscience", "loops",
29
+ ]);
30
+ /** True when the counts put the pair under the follow floor or over the dispute ceiling. */
31
+ export function failing(kind, counts, config) {
32
+ if (!counts.sent)
33
+ return false;
34
+ return (FOLLOW_MEASURED.has(kind) && counts.followed / counts.sent < config.minFollowed) || counts.disputed / counts.sent > config.maxDisputed;
35
+ }
36
+ /** The counts per (model, kind) pair, read once and written after every change. */
37
+ export class SteerStats {
38
+ path;
39
+ data;
40
+ constructor(path = steerStatsPath()) {
41
+ this.path = path;
42
+ }
43
+ load() {
44
+ if (this.data)
45
+ return this.data;
46
+ try {
47
+ const raw = JSON.parse(readFileSync(this.path, "utf8"));
48
+ this.data = raw?.version === 1 && typeof raw.models === "object" && raw.models ? raw : { version: 1, models: {} };
49
+ }
50
+ catch {
51
+ this.data = { version: 1, models: {} };
52
+ }
53
+ return this.data;
54
+ }
55
+ save() {
56
+ try {
57
+ writeFileAtomicSync(this.path, `${JSON.stringify(this.data)}\n`);
58
+ }
59
+ catch (error) {
60
+ console.warn("pi-warden: steer stats write failed:", error instanceof Error ? error.message : String(error));
61
+ }
62
+ }
63
+ pair(model, kind) {
64
+ return this.load().models[model]?.[kind];
65
+ }
66
+ ensure(model, kind) {
67
+ const models = this.load().models;
68
+ const kinds = models[model] ??= {};
69
+ return kinds[kind] ??= fresh();
70
+ }
71
+ /**
72
+ * Called before a steer of this kind goes out. A trace-only pair still sends the first of every `probeEvery` steers;
73
+ * after `recheckEvery` steers the probes decide: rates within the thresholds end trace-only, and the probe counts
74
+ * become the pair's new counts, so a fresh `minSteers` must pass before it can be muted again.
75
+ */
76
+ request(model, kind, config) {
77
+ if (!config.adaptive || NEVER_MUTED.has(kind))
78
+ return "send";
79
+ const pair = this.pair(model, kind);
80
+ if (!pair?.muted)
81
+ return "send";
82
+ const probe = pair.asked % Math.max(1, config.probeEvery) === 0;
83
+ pair.asked++;
84
+ if (pair.asked >= config.recheckEvery) {
85
+ if (pair.probe.sent > 0 && !failing(kind, pair.probe, config))
86
+ Object.assign(pair, { ...pair.probe, muted: false, asked: 0, probe: zero() });
87
+ else
88
+ Object.assign(pair, { asked: 0, probe: zero() });
89
+ }
90
+ this.save();
91
+ return probe ? "probe" : "trace-only";
92
+ }
93
+ /**
94
+ * The outcome of one sent steer. Crossing the thresholds at `minSteers` or later makes the pair trace-only. Kinds in
95
+ * NEVER_MUTED are counted, for the status, and never muted.
96
+ */
97
+ observe(model, kind, outcome, config) {
98
+ if (!config.adaptive)
99
+ return;
100
+ const pair = this.ensure(model, kind);
101
+ const add = (counts) => {
102
+ counts.sent++;
103
+ if (outcome.followed)
104
+ counts.followed++;
105
+ if (outcome.disputed)
106
+ counts.disputed++;
107
+ };
108
+ add(pair);
109
+ if (pair.muted)
110
+ add(pair.probe);
111
+ else if (!NEVER_MUTED.has(kind) && pair.sent >= config.minSteers && failing(kind, pair, config))
112
+ Object.assign(pair, { muted: true, asked: 0, probe: zero() });
113
+ this.save();
114
+ }
115
+ /** Drops the pair's counts; its steers are sent again. True when there was something to reset. */
116
+ unmute(model, kind) {
117
+ const kinds = this.load().models[model];
118
+ if (!kinds?.[kind])
119
+ return false;
120
+ delete kinds[kind];
121
+ if (!Object.keys(kinds).length)
122
+ delete this.load().models[model];
123
+ this.save();
124
+ return true;
125
+ }
126
+ /** Every trace-only pair, per model. */
127
+ muted() {
128
+ const out = [];
129
+ for (const [model, kinds] of Object.entries(this.load().models)) {
130
+ for (const [kind, pair] of Object.entries(kinds)) {
131
+ if (pair.muted)
132
+ out.push({ model, kind, sent: pair.sent, followed: pair.followed, disputed: pair.disputed });
133
+ }
134
+ }
135
+ return out.sort((a, b) => a.model.localeCompare(b.model) || a.kind.localeCompare(b.kind));
136
+ }
137
+ /** Models with a pair of this kind, for `/warden unmute` without a model. */
138
+ modelsWith(kind) {
139
+ return Object.entries(this.load().models).filter(([, kinds]) => kinds[kind]).map(([model]) => model);
140
+ }
141
+ }
142
+ const percent = (part, whole) => `${whole ? Math.round((part / whole) * 100) : 0}%`;
143
+ export function formatMuted(pairs, config) {
144
+ if (!config.adaptive)
145
+ return "Adaptive steers: off.";
146
+ if (!pairs.length)
147
+ return "Adaptive steers: no steer kind is trace-only for any model.";
148
+ const byModel = new Map();
149
+ for (const pair of pairs)
150
+ byModel.set(pair.model, [...(byModel.get(pair.model) ?? []), pair]);
151
+ const models = [...byModel].map(([model, list]) => `${model}: ${list.map(pair => `${pair.kind} (${pair.sent} steers, ${percent(pair.followed, pair.sent)} followed, ${percent(pair.disputed, pair.sent)} disputed)`).join(", ")}`);
152
+ return `Adaptive steers, trace-only per model: ${models.join("; ")}. /warden unmute <kind> resets a pair.`;
153
+ }
154
+ /** Agent replies that argue with a note instead of acting on it. The same pattern the field replays used. */
155
+ export const DISPUTE = /false positive|false alarm|no (real )?(credentials|secrets)|not (a |real )?(secret|credential|token)s?|(only|just) (placeholder|fixture|test|dummy|fake)|as (I )?(said|planned|stated)|matches (what I said|the plan)|this is expected|warden (is wrong|misfired|misread)|misfire|spurious|harmless|benign|nothing sensitive|not sensitive|was intended|intended/i;
156
+ const CHECK = /\b(test|tests|vitest|jest|pytest|build|lint|tsc|check|make|cargo|go test|flutter (test|analyze)|npm run|eslint|typecheck)\b/i;
157
+ const UNVERIFIED = /not (yet )?(verified|tested)|unverified|did not run|didn't run/i;
158
+ const REPLAN = /\b(instead|re-?plan(ned|ning)?|ask(ing)? (you|the user|first|before)|should I|do you want)\b/i;
159
+ const commandText = (input) => typeof input.command === "string" ? input.command : typeof input.path === "string" ? input.path : JSON.stringify(input);
160
+ /**
161
+ * Whether the next assistant messages (at most two) changed course after a steer of this kind, and whether they
162
+ * disputed it. A port of the heuristic the field replays used; it is a proxy and does not prove the note caused the change.
163
+ */
164
+ export function steerOutcome(kind, subject, views) {
165
+ const text = views.map(view => view.text).join("\n");
166
+ const calls = views.flatMap(view => view.calls);
167
+ const disputed = DISPUTE.test(text);
168
+ let followed = false;
169
+ switch (kind) {
170
+ case "done":
171
+ followed = calls.some(call => call.name === "bash" && CHECK.test(commandText(call.input))) || UNVERIFIED.test(text);
172
+ break;
173
+ case "rules":
174
+ case "slop":
175
+ case "security-write": {
176
+ const path = subject.path;
177
+ followed = !!path && calls.some(call => /edit|write/i.test(call.name) ? commandText(call.input).endsWith(path) : call.name === "bash" && commandText(call.input).includes(path));
178
+ break;
179
+ }
180
+ case "stuck":
181
+ case "repeat": {
182
+ const next = calls[0];
183
+ followed = !!next && (!subject.call || next.name !== subject.call.name || commandText(next.input) !== commandText(subject.call.input));
184
+ break;
185
+ }
186
+ case "intent-mismatch":
187
+ case "off-task":
188
+ case "should-proceed":
189
+ case "warn-headless":
190
+ case "large-output":
191
+ followed = (!calls.length && /\?\s*$/.test(views[0]?.text ?? "")) || REPLAN.test(text);
192
+ break;
193
+ case "conscience": {
194
+ const target = subject.capability;
195
+ followed = !!target && calls.some(call => target.kind === "tool" ? call.name === target.id || call.name.endsWith(target.id) : commandText(call.input).includes(`/${target.id}/`));
196
+ break;
197
+ }
198
+ case "loops":
199
+ followed = calls.some(call => call.name === "warden_loops");
200
+ break;
201
+ default:
202
+ followed = false;
203
+ }
204
+ return { followed, disputed };
205
+ }
206
+ /**
207
+ * Sent steers waiting for the agent's next two messages. A steer queued for the next turn waits past the next user
208
+ * message; any other steer that meets a user message first is settled on what it saw, or dropped when it saw nothing.
209
+ */
210
+ export class SteerWatch {
211
+ pending = [];
212
+ add(model, kinds, subject = {}, options = {}) {
213
+ if (!kinds.length)
214
+ return;
215
+ this.pending.push({ model, kinds: [...kinds], subject, views: [], waitUser: options.nextTurn === true });
216
+ if (this.pending.length > 50)
217
+ this.pending.shift();
218
+ }
219
+ /** Feeds one assistant message; returns the steers it settled. */
220
+ assistant(view) {
221
+ const settled = [];
222
+ const keep = [];
223
+ for (const item of this.pending) {
224
+ if (item.waitUser) {
225
+ keep.push(item);
226
+ continue;
227
+ }
228
+ item.views.push(view);
229
+ const outcomes = item.kinds.map(kind => ({ kind, ...steerOutcome(kind, item.subject, item.views) }));
230
+ if (item.views.length >= 2 || outcomes.every(outcome => outcome.followed || outcome.disputed)) {
231
+ for (const outcome of outcomes)
232
+ settled.push({ model: item.model, ...outcome });
233
+ }
234
+ else
235
+ keep.push(item);
236
+ }
237
+ this.pending = keep;
238
+ return settled;
239
+ }
240
+ /** A user message arrived. */
241
+ user() {
242
+ const settled = [];
243
+ const keep = [];
244
+ for (const item of this.pending) {
245
+ if (item.waitUser) {
246
+ item.waitUser = false;
247
+ keep.push(item);
248
+ continue;
249
+ }
250
+ if (item.views.length)
251
+ for (const kind of item.kinds)
252
+ settled.push({ model: item.model, kind, ...steerOutcome(kind, item.subject, item.views) });
253
+ }
254
+ this.pending = keep;
255
+ return settled;
256
+ }
257
+ clear() { this.pending = []; }
258
+ }
259
+ /** The text and tool calls of an assistant message as Pi stores it. */
260
+ export function assistantView(content) {
261
+ if (typeof content === "string")
262
+ return { text: content, calls: [] };
263
+ if (!Array.isArray(content))
264
+ return { text: "", calls: [] };
265
+ const text = [];
266
+ const calls = [];
267
+ for (const part of content) {
268
+ if (part?.type === "text" && typeof part.text === "string")
269
+ text.push(part.text);
270
+ else if (part?.type === "toolCall" && typeof part.name === "string")
271
+ calls.push({ name: part.name, input: (part.arguments && typeof part.arguments === "object" ? part.arguments : {}) });
272
+ }
273
+ return { text: text.join("\n"), calls };
274
+ }
@@ -0,0 +1,10 @@
1
+ /** Writes `text` to `path` through a temporary file and a rename, so a crash or a reader never sees half a file. */
2
+ export declare function writeFileAtomic(path: string, text: string): Promise<void>;
3
+ /** `writeFileAtomic` for the synchronous stores, which cannot interleave with each other inside one process. */
4
+ export declare function writeFileAtomicSync(path: string, text: string): void;
5
+ /**
6
+ * Runs `task` after every earlier task for `key`, so the read-modify-write sequences that share a store file run one
7
+ * after another instead of all reading the same old store and then writing over each other's change. A task's rejection
8
+ * reaches its own caller and does not stop the queue.
9
+ */
10
+ export declare function serialize<T>(key: string, task: () => Promise<T> | T): Promise<T>;
package/dist/atomic.js ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Whole-file writes and per-file queues for pi-warden's small JSON stores. Two operations on one store in one process
3
+ * share no temporary name and do not interleave: every write goes to a temporary file that belongs to this process and
4
+ * this write, then a rename puts it in place, and every read-modify-write of one file runs through a queue keyed by that
5
+ * path. Cross-process locking is out of scope: a second process writing the same store can still lose this process's
6
+ * update, and nothing here stops it.
7
+ */
8
+ import { chmodSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
9
+ import { chmod, mkdir, rename, writeFile } from "node:fs/promises";
10
+ import { dirname } from "node:path";
11
+ let writes = 0;
12
+ /** The temporary file one write lands in: the pid keeps two processes apart, the counter two writes within one process. */
13
+ function temporaryPath(path) {
14
+ writes += 1;
15
+ return `${path}.${process.pid}.${writes}.tmp`;
16
+ }
17
+ /** Writes `text` to `path` through a temporary file and a rename, so a crash or a reader never sees half a file. */
18
+ export async function writeFileAtomic(path, text) {
19
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
20
+ const temporary = temporaryPath(path);
21
+ await writeFile(temporary, text, { mode: 0o600 });
22
+ // The mode applies only when the temporary file is created; force it so a leftover file cannot change the store's permissions.
23
+ await chmod(temporary, 0o600);
24
+ await rename(temporary, path);
25
+ }
26
+ /** `writeFileAtomic` for the synchronous stores, which cannot interleave with each other inside one process. */
27
+ export function writeFileAtomicSync(path, text) {
28
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
29
+ const temporary = temporaryPath(path);
30
+ writeFileSync(temporary, text, { mode: 0o600 });
31
+ chmodSync(temporary, 0o600);
32
+ renameSync(temporary, path);
33
+ }
34
+ /** The tail of each path's queue: a task starts once the previous task for the same path has settled. */
35
+ const queues = new Map();
36
+ /**
37
+ * Runs `task` after every earlier task for `key`, so the read-modify-write sequences that share a store file run one
38
+ * after another instead of all reading the same old store and then writing over each other's change. A task's rejection
39
+ * reaches its own caller and does not stop the queue.
40
+ */
41
+ export function serialize(key, task) {
42
+ const previous = queues.get(key) ?? Promise.resolve();
43
+ const run = previous.then(task);
44
+ const settled = run.then(() => undefined, () => undefined);
45
+ queues.set(key, settled);
46
+ void settled.then(() => {
47
+ if (queues.get(key) === settled)
48
+ queues.delete(key);
49
+ });
50
+ return run;
51
+ }
package/dist/config.d.ts CHANGED
@@ -131,6 +131,8 @@ export interface StuckGuardConfig {
131
131
  nudge: boolean;
132
132
  /** Steer on the 2nd identical call when nothing changed between and it failed the same way or re-read the same output. Code only. */
133
133
  repeatSteer: boolean;
134
+ /** Send the judge a compact structured `evidence` section: the parsed failure per run, the edit diffs, and a digest. */
135
+ evidence: boolean;
134
136
  /** Maximum characters of the unified line diff in a stuck-loop diff note. */
135
137
  diffLimit: number;
136
138
  /** Characters of the current output tail shown after the diff in a stuck-loop diff note. */
@@ -190,6 +192,8 @@ export interface RulesConfig {
190
192
  enabled: boolean;
191
193
  /** P(violation) at or above this names the rule to the agent. */
192
194
  threshold: number;
195
+ /** A score from here up to the rule's cutoff becomes a soft "please double-check" steer; `0` turns the tier off. */
196
+ softThreshold: number;
193
197
  /** Project-relative Markdown rule files, used when the root pi-warden.md is absent. All are sent in one request. */
194
198
  files: string[];
195
199
  /** With no rules file, README.md, CLAUDE.md, or AGENTS.md (first found) is judged as one document. */
@@ -270,12 +274,43 @@ export interface SubagentConfig {
270
274
  export type RecallTool = "auto" | "rg" | "ag" | "ugrep" | "git-grep" | "grep" | "select-string" | "findstr" | "none";
271
275
  export declare function isRecallTool(value: unknown): value is RecallTool;
272
276
  export type WardenMode = "steer" | "confirm" | "advise";
277
+ export interface WasteConfig {
278
+ /** Master switch for the call-waste notes and the session tip. */
279
+ enabled: boolean;
280
+ /** Append the session tip to the prompt once per session. */
281
+ tip: boolean;
282
+ /** Tool calls between two notes from the same detector. */
283
+ every: number;
284
+ /** Note on repeated poll calls (`sleep`). */
285
+ sleep: boolean;
286
+ /** Note on adjacent range reads of one file. */
287
+ paging: boolean;
288
+ /** Note on repeated searches of one file with an overlapping pattern. */
289
+ search: boolean;
290
+ /** Note on a filtered check re-run. */
291
+ recheck: boolean;
292
+ }
273
293
  export interface PrefsConfig {
274
294
  /** Read this project's earlier session files for preferences the user repeated (`/warden prefs`). Local, code only. */
275
295
  enabled: boolean;
276
296
  /** At session start, send the standing preferences that pass every injection rule to the agent as one context message. */
277
297
  inject: boolean;
278
298
  }
299
+ /** Per-model steer adaptation: a steer kind a model rarely follows or often disputes becomes trace-only for it. */
300
+ export interface SteersConfig {
301
+ /** Make a steer kind trace-only for a model that rarely follows or often disputes it. */
302
+ adaptive: boolean;
303
+ /** Steers observed for a (model, kind) pair before it can become trace-only. */
304
+ minSteers: number;
305
+ /** Trace-only when the followed share is under this. Kinds with no follow measure are judged by disputes alone. */
306
+ minFollowed: number;
307
+ /** Trace-only when the disputed share is over this. */
308
+ maxDisputed: number;
309
+ /** A trace-only pair is re-checked after this many further steers. */
310
+ recheckEvery: number;
311
+ /** While trace-only, 1 in this many steers is still sent, so the re-check has fresh data. */
312
+ probeEvery: number;
313
+ }
279
314
  export interface LearningConfig {
280
315
  /** Enable adaptive thresholds based on learning data. */
281
316
  adaptiveThresholds: boolean;
@@ -366,16 +401,20 @@ export interface WardenConfig {
366
401
  * steer costs at least one LLM turn, and a closing run that collects six notices collects six restatements of the final
367
402
  * status. 0 disables the budget. Critical guards (stuck, done, runaway, subagent wake) always deliver. */
368
403
  steerBudget: number;
404
+ /** Per-model steer adaptation. */
405
+ steers: SteersConfig;
369
406
  /** Learning and adaptation settings. */
370
407
  learning: LearningConfig;
371
408
  /** Broad-consideration coach: recommends or loads skills and tools before the agent acts. */
372
409
  conscience: ConscienceConfig;
373
410
  /** Standing preferences: corrections the user repeated in earlier sessions of this project. */
374
411
  prefs: PrefsConfig;
412
+ /** Call-waste notes: advisory sentences attached to the tool result that triggers them. */
413
+ waste: WasteConfig;
375
414
  }
376
415
  export declare const PACKAGE_NAME = "pi-warden";
377
416
  /** Bumped when WardenConfig gains a section; extension.ts checks it so a half-updated module graph is reported, not crashed on. */
378
- export declare const CONFIG_SCHEMA = 8;
417
+ export declare const CONFIG_SCHEMA = 10;
379
418
  export declare const PROJECT_CONFIG_FILE = "pi-warden.json";
380
419
  export declare function defaultConfig(): WardenConfig;
381
420
  /** Mirrors Pi's agent directory rule so the file sits next to pi-typesafe's auth.json. */
package/dist/config.js CHANGED
@@ -1,6 +1,7 @@
1
- import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
1
+ import { readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
+ import { writeFileAtomicSync } from "./atomic.js";
4
5
  import { resolveBackend } from "./backend.js";
5
6
  import { COMMAND_TOOLS } from "./tools.js";
6
7
  import { defaultWidgetConfig } from "./widget.js";
@@ -23,7 +24,7 @@ export function isRecallTool(value) {
23
24
  }
24
25
  export const PACKAGE_NAME = "pi-warden";
25
26
  /** Bumped when WardenConfig gains a section; extension.ts checks it so a half-updated module graph is reported, not crashed on. */
26
- export const CONFIG_SCHEMA = 8;
27
+ export const CONFIG_SCHEMA = 10;
27
28
  export const PROJECT_CONFIG_FILE = `${PACKAGE_NAME}.json`;
28
29
  export function defaultConfig() {
29
30
  return {
@@ -53,11 +54,11 @@ export function defaultConfig() {
53
54
  escalationThreshold: 0.85,
54
55
  floor: "evidence",
55
56
  },
56
- stuck: { enabled: true, window: 12, minFailures: 3, cooldown: 3, sameStrategy: 0.7, churnThreshold: 5, nudge: true, repeatSteer: true, diffLimit: 3000, tailLimit: 1000 },
57
+ stuck: { enabled: true, window: 12, minFailures: 3, cooldown: 3, sameStrategy: 0.7, churnThreshold: 5, nudge: true, repeatSteer: true, evidence: true, diffLimit: 3000, tailLimit: 1000 },
57
58
  done: { enabled: true, claimsDone: 0.7, nudge: true, uiProof: true, uiFiles: [...DEFAULT_UI_FILES], visualTools: defaultVisualTools() },
58
59
  slop: { enabled: true, threshold: 0.7, prose: { enabled: true, audience: "technical", threshold: 0.7, trend: 2, minChars: 200 } },
59
60
  security: { enabled: true, threshold: 0.7, maskOutput: true },
60
- rules: { enabled: true, threshold: 0.7, files: [], fallback: true, maxChars: 8000, exclude: [], skip: [], sensitivePaths: {} },
61
+ rules: { enabled: true, threshold: 0.7, softThreshold: 0, files: [], fallback: true, maxChars: 8000, exclude: [], skip: [], sensitivePaths: {} },
61
62
  context: { enabled: true, tailMinChars: 12000, confidence: 0.8, duplicateMinChars: 2000, recallTool: "auto", formatConfidence: 0.7, compactAppendix: true, dedupeRuns: true, dedupeMessages: false, largeOutput: { enabled: true, threshold: 0.85 } },
62
63
  runaway: { enabled: true, repeats: 4, thinkingRepeats: 10, minChars: 400, recover: true },
63
64
  notify: { enabled: false, cooldownMs: 10000, command: [] },
@@ -67,6 +68,7 @@ export function defaultConfig() {
67
68
  steerVisible: false,
68
69
  notices: false,
69
70
  steerBudget: 3,
71
+ steers: { adaptive: true, minSteers: 30, minFollowed: 0.2, maxDisputed: 0.4, recheckEvery: 30, probeEvery: 5 },
70
72
  learning: { adaptiveThresholds: true, patternAnalysis: true, minHoldsForAdaptive: 20, adaptationRate: 0.1, retentionDays: 365 },
71
73
  conscience: {
72
74
  enabled: false,
@@ -84,6 +86,7 @@ export function defaultConfig() {
84
86
  loadThreshold: 1.0,
85
87
  },
86
88
  prefs: { enabled: true, inject: true },
89
+ waste: { enabled: true, tip: false, every: 20, sleep: true, paging: true, search: true, recheck: true },
87
90
  };
88
91
  }
89
92
  /** Mirrors Pi's agent directory rule so the file sits next to pi-typesafe's auth.json. */
@@ -317,6 +320,7 @@ function applyStuck(base, raw) {
317
320
  churnThreshold: Math.min(window, positiveInteger(raw.churnThreshold, base.churnThreshold)),
318
321
  nudge: boolean(raw.nudge, base.nudge),
319
322
  repeatSteer: boolean(raw.repeatSteer, base.repeatSteer),
323
+ evidence: boolean(raw.evidence, base.evidence),
320
324
  diffLimit: positiveInteger(raw.diffLimit, base.diffLimit),
321
325
  tailLimit: positiveInteger(raw.tailLimit, base.tailLimit),
322
326
  };
@@ -402,6 +406,7 @@ function applyRules(base, raw) {
402
406
  return {
403
407
  enabled: boolean(raw.enabled, base.enabled),
404
408
  threshold: probability(raw.threshold, base.threshold),
409
+ softThreshold: probability(raw.softThreshold, base.softThreshold),
405
410
  files: globList(raw.files, base.files),
406
411
  fallback: boolean(raw.fallback, base.fallback),
407
412
  maxChars: Math.max(500, positiveInteger(raw.maxChars, base.maxChars)),
@@ -442,6 +447,7 @@ function applyShared(base, raw) {
442
447
  }
443
448
  function applyGuards(base, raw, timeoutMs, source) {
444
449
  return {
450
+ waste: applyWaste(base.waste, raw.waste),
445
451
  rules: applyRules(base.rules, raw.rules),
446
452
  runaway: applyRunaway(base.runaway, raw.runaway),
447
453
  subagent: applySubagent(base.subagent, raw.subagent),
@@ -491,16 +497,42 @@ export function applyUserOverrides(base, raw) {
491
497
  steerVisible: boolean(raw.steerVisible, base.steerVisible),
492
498
  notices: boolean(raw.notices, base.notices),
493
499
  steerBudget: typeof raw.steerBudget === "number" && Number.isInteger(raw.steerBudget) && raw.steerBudget >= 0 ? raw.steerBudget : base.steerBudget,
500
+ steers: applySteers(base.steers, raw.steers),
494
501
  learning: applyLearning(base.learning, raw.learning),
495
502
  conscience: applyConscience(base.conscience, raw.conscience),
496
503
  prefs: applyPrefs(base.prefs, raw.prefs),
497
504
  };
498
505
  }
506
+ function applyWaste(base, raw) {
507
+ if (!isObject(raw))
508
+ return base;
509
+ return {
510
+ enabled: boolean(raw.enabled, base.enabled),
511
+ tip: boolean(raw.tip, base.tip),
512
+ every: Math.max(2, positiveInteger(raw.every, base.every)),
513
+ sleep: boolean(raw.sleep, base.sleep),
514
+ paging: boolean(raw.paging, base.paging),
515
+ search: boolean(raw.search, base.search),
516
+ recheck: boolean(raw.recheck, base.recheck),
517
+ };
518
+ }
499
519
  function applyPrefs(base, raw) {
500
520
  if (!isObject(raw))
501
521
  return base;
502
522
  return { enabled: boolean(raw.enabled, base.enabled), inject: boolean(raw.inject, base.inject) };
503
523
  }
524
+ function applySteers(base, raw) {
525
+ if (!isObject(raw))
526
+ return base;
527
+ return {
528
+ adaptive: boolean(raw.adaptive, base.adaptive),
529
+ minSteers: positiveInteger(raw.minSteers, base.minSteers),
530
+ minFollowed: probability(raw.minFollowed, base.minFollowed),
531
+ maxDisputed: probability(raw.maxDisputed, base.maxDisputed),
532
+ recheckEvery: positiveInteger(raw.recheckEvery, base.recheckEvery),
533
+ probeEvery: positiveInteger(raw.probeEvery, base.probeEvery),
534
+ };
535
+ }
504
536
  function applyLearning(base, raw) {
505
537
  if (!isObject(raw))
506
538
  return base;
@@ -557,11 +589,7 @@ export function readUserConfig() {
557
589
  }
558
590
  export function writeUserConfig(raw) {
559
591
  const path = userConfigPath();
560
- mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
561
- const temporary = `${path}.${process.pid}.tmp`;
562
- writeFileSync(temporary, `${JSON.stringify(raw, null, 2)}\n`, { mode: 0o600, flag: "w" });
563
- chmodSync(temporary, 0o600);
564
- renameSync(temporary, path);
592
+ writeFileAtomicSync(path, `${JSON.stringify(raw, null, 2)}\n`);
565
593
  return path;
566
594
  }
567
595
  /** Persists one top-level user setting without disturbing the rest of the file. */
@@ -0,0 +1,93 @@
1
+ import type { Attempt } from "./stuck.js";
2
+ /** Byte cap for the whole `evidence` object; over it, the oldest runs go first, then the oldest edits. */
3
+ export declare const EVIDENCE_LIMIT = 4096;
4
+ /** A failed or check output parsed into failing tests, error lines, a location, a summary and an exit code. */
5
+ export type ParsedFailure = {
6
+ format: string;
7
+ failing: string[];
8
+ failing_more?: number;
9
+ errors: string[];
10
+ location?: string;
11
+ summary?: string;
12
+ exit?: number;
13
+ };
14
+ /** Parses a failed or check output into failing tests, error lines, a location, a summary and an exit code. */
15
+ export declare function parseFailure(text: string): ParsedFailure;
16
+ /**
17
+ * Normalises what changes between identical runs: durations, clock times and dates, addresses, long numbers, thread
18
+ * ids, temp paths, and line:column positions. Other digits stay, so "expected 3" and "expected 4" still differ.
19
+ */
20
+ export declare function normaliseFailureText(value: string | undefined): string;
21
+ /**
22
+ * Order-free signature of a failure: which tests failed and with which errors, both normalised. Pass counts are left
23
+ * out, so a run narrowed by a flag (`-x`, a test filter) still matches the full run that failed the same way.
24
+ */
25
+ export declare function failureSignature(parsed: ParsedFailure, raw?: string): string;
26
+ /** The edit pairs of an `edit` input, as `[oldText, newText]`: the `edits` array, or one top-level pair. */
27
+ export declare function editPairs(input: Record<string, unknown>): Array<[string, string]>;
28
+ /** Removed and added lines of one edit, as a multiset difference, so unchanged context lines drop out. */
29
+ export declare function lineDelta(oldText: string, newText: string): {
30
+ removed: string[];
31
+ added: string[];
32
+ };
33
+ /** What an `edit` or `write` changed: the path, the line counts, and a diff capped at `DIFF_CAP` characters. */
34
+ export type EditDiff = {
35
+ path: string;
36
+ added: number;
37
+ removed: number;
38
+ removedLines: string[];
39
+ addedLines: string[];
40
+ diff: string;
41
+ };
42
+ /**
43
+ * The change an `edit` or `write` made. Every string is as the agent wrote it: the caller redacts what leaves the
44
+ * machine. The diff is the whole added and removed text, not a line-by-line patch, because the judge compares one
45
+ * change against the next.
46
+ */
47
+ export declare function editDiff(tool: string, input: Record<string, unknown>): EditDiff;
48
+ /** One run in the evidence: a failed call, or a call that ran a test, build or lint command and passed. */
49
+ export type EvidenceRun = {
50
+ n: number;
51
+ outcome: "failed" | "ok";
52
+ parser?: string;
53
+ failing_tests?: string[];
54
+ failing_tests_more?: number;
55
+ errors?: string[];
56
+ location?: string;
57
+ summary?: string;
58
+ output_tail?: string;
59
+ output_head_tail?: string;
60
+ /** The run number of the earlier run whose failure matches, after normalising times, temp paths and line numbers. */
61
+ same_failure_as_run?: number | null;
62
+ exit_code?: number;
63
+ };
64
+ /** One `edit`/`write` call in the evidence. */
65
+ export type EvidenceEdit = {
66
+ n: number;
67
+ path: string;
68
+ added_lines: number;
69
+ removed_lines: number;
70
+ diff: string;
71
+ };
72
+ /** The counts a judge can weigh without reading every run. */
73
+ export type EvidenceDigest = {
74
+ failed_runs: number;
75
+ distinct_failures: number;
76
+ /** Runs whose command repeats an earlier run's command, whatever the outcome. */
77
+ same_command_runs: number[];
78
+ latest_failure_seen_before: boolean;
79
+ edits_between_failed_runs: number;
80
+ information_calls_between_failed_runs: number;
81
+ };
82
+ export type StuckEvidence = {
83
+ runs: EvidenceRun[];
84
+ edits: EvidenceEdit[];
85
+ digest: EvidenceDigest;
86
+ };
87
+ /**
88
+ * Builds the evidence for one stuck window: a run per failed call and per passing check command, an edit per
89
+ * `edit`/`write`, and a digest. The oldest runs are dropped first when the whole object is over `EVIDENCE_LIMIT`
90
+ * bytes, then the oldest edits; the digest is then recomputed over what is left, and a run that pointed at a dropped
91
+ * run points at nothing. The floor is one run plus the digest, which is far under the cap.
92
+ */
93
+ export declare function buildStuckEvidence(attempts: readonly Attempt[], limit?: number): StuckEvidence;