pi-warden 0.60.0 → 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.
- package/CHANGELOG.md +140 -0
- package/README.md +49 -93
- package/dist/adaptive.d.ts +132 -0
- package/dist/adaptive.js +274 -0
- package/dist/atomic.d.ts +10 -0
- package/dist/atomic.js +51 -0
- package/dist/compact.d.ts +9 -0
- package/dist/compact.js +34 -10
- package/dist/config.d.ts +47 -2
- package/dist/config.js +43 -11
- package/dist/dedupe.d.ts +41 -0
- package/dist/dedupe.js +162 -0
- package/dist/evidence.d.ts +93 -0
- package/dist/evidence.js +409 -0
- package/dist/extension.d.ts +1 -1
- package/dist/extension.js +460 -62
- package/dist/extension.js.bak +668 -102
- package/dist/guard.d.ts +4 -0
- package/dist/guard.js +132 -9
- package/dist/index.d.ts +10 -2
- package/dist/index.js +5 -1
- package/dist/loops.d.ts +64 -0
- package/dist/loops.js +147 -0
- package/dist/prefs.d.ts +161 -10
- package/dist/prefs.js +371 -48
- package/dist/rules-lint.d.ts +84 -0
- package/dist/rules-lint.js +191 -0
- package/dist/rules-log.d.ts +98 -0
- package/dist/rules-log.js +190 -0
- package/dist/rules-report.d.ts +52 -0
- package/dist/rules-report.js +83 -0
- package/dist/rules.d.ts +24 -2
- package/dist/rules.js +154 -33
- package/dist/saver.d.ts +9 -1
- package/dist/saver.js +14 -4
- package/dist/shape.d.ts +1 -1
- package/dist/shape.js +23 -5
- package/dist/stuck.d.ts +34 -11
- package/dist/stuck.js +20 -8
- package/dist/trace.d.ts +1 -1
- package/dist/waste.d.ts +98 -0
- package/dist/waste.js +582 -0
- package/package.json +4 -2
package/dist/evidence.js
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
// Structured evidence for the stuck judge: what a run failed on, and what an edit changed, in a shape a judge can
|
|
2
|
+
// compare across attempts. The parser is generic: it knows the shapes of common runners and compilers (node test/TAP,
|
|
3
|
+
// jest, vitest, pytest, tsc, eslint, cargo, go, a `make test` script, Playwright, Python and Node tracebacks) and
|
|
4
|
+
// falls back to an error line (`generic`) or to the head and tail of the raw output (`unparsed`).
|
|
5
|
+
//
|
|
6
|
+
// Nothing here depends on the Pi runtime, and nothing here redacts: the caller redacts what it keeps, and every string
|
|
7
|
+
// in the built evidence is redacted before it enters a request.
|
|
8
|
+
import { redact } from "./redact.js";
|
|
9
|
+
const MAX_ITEMS = 6;
|
|
10
|
+
const DIFF_CAP = 600;
|
|
11
|
+
const FALLBACK_HEAD = 300;
|
|
12
|
+
const FALLBACK_TAIL = 300;
|
|
13
|
+
/** Byte cap for the whole `evidence` object; over it, the oldest runs go first, then the oldest edits. */
|
|
14
|
+
export const EVIDENCE_LIMIT = 4096;
|
|
15
|
+
/** Test, build, lint and type-check runners, and the ad-hoc scripts a project uses as checks. */
|
|
16
|
+
const RUNNER = /\b(?:(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:test|check|lint|typecheck|build|e2e)|(?:npx|pnpm|bunx)\s+(?:tsc|jest|vitest|eslint|playwright)|node\s+--test|pytest|jest|vitest|tsc|eslint|cargo\s+(?:test|check|build|clippy)|go\s+(?:test|vet|build)|make\s+\w+|sbcl\s+--script)\b/;
|
|
17
|
+
const EDIT_TOOLS = new Set(["edit", "write"]);
|
|
18
|
+
function clip(value, limit = 200) {
|
|
19
|
+
return (value ?? "").replace(/\s+/g, " ").trim().slice(0, limit);
|
|
20
|
+
}
|
|
21
|
+
function uniq(values) {
|
|
22
|
+
return [...new Set(values.map(value => clip(value, 160)).filter(Boolean))];
|
|
23
|
+
}
|
|
24
|
+
function headTail(text, head, tail) {
|
|
25
|
+
return text.length <= head + tail ? text : `${text.slice(0, head)}… [${text.length - head - tail} middle chars] …${text.slice(-tail)}`;
|
|
26
|
+
}
|
|
27
|
+
function tailOf(text, limit) {
|
|
28
|
+
return text.length <= limit ? text : `[${text.length - limit} earlier chars] …${text.slice(-limit)}`;
|
|
29
|
+
}
|
|
30
|
+
function exitCode(text) {
|
|
31
|
+
const matches = [...text.matchAll(/(?:exited with code|exit code:?|exit status|Error)\s+(\d+)\s*$/gim)];
|
|
32
|
+
return matches.length ? Number(matches.at(-1)[1]) : undefined;
|
|
33
|
+
}
|
|
34
|
+
function result(format, fields, exit) {
|
|
35
|
+
const failing = uniq(fields.failing ?? []);
|
|
36
|
+
const errors = uniq(fields.errors ?? []);
|
|
37
|
+
return {
|
|
38
|
+
format,
|
|
39
|
+
failing: failing.slice(0, MAX_ITEMS),
|
|
40
|
+
...(failing.length > MAX_ITEMS ? { failing_more: failing.length - MAX_ITEMS } : {}),
|
|
41
|
+
errors: errors.slice(0, MAX_ITEMS),
|
|
42
|
+
...(fields.location ? { location: clip(fields.location, 160) } : {}),
|
|
43
|
+
...(fields.summary ? { summary: clip(fields.summary, 160) } : {}),
|
|
44
|
+
...(exit !== undefined ? { exit } : {}),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** Lines after index `from`, up to (not including) the first line that matches `stop`. */
|
|
48
|
+
function blockAfter(lines, from, stop) {
|
|
49
|
+
const out = [];
|
|
50
|
+
for (let i = from + 1; i < lines.length && !stop.test(lines[i]); i++)
|
|
51
|
+
out.push(lines[i]);
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
const PARSERS = [
|
|
55
|
+
["tsc", text => {
|
|
56
|
+
const matches = [...text.matchAll(/^(\S+?)\((\d+),(\d+)\): error (TS\d+): (.+)$/gm)];
|
|
57
|
+
if (!matches.length)
|
|
58
|
+
return undefined;
|
|
59
|
+
return { errors: matches.map(m => `${m[4]} ${m[1]}: ${m[5]}`), location: `${matches[0][1]}:${matches[0][2]}:${matches[0][3]}`, summary: `${matches.length} type error${matches.length === 1 ? "" : "s"}` };
|
|
60
|
+
}],
|
|
61
|
+
["cargo", text => {
|
|
62
|
+
const tests = [...text.matchAll(/^test (\S+) \.\.\. FAILED$/gm)].map(m => m[1]);
|
|
63
|
+
const compile = [...text.matchAll(/^error(\[E\d+\])?: (.+)\n\s*--> (\S+)/gm)];
|
|
64
|
+
if (!tests.length && !compile.length)
|
|
65
|
+
return undefined;
|
|
66
|
+
const panics = [...text.matchAll(/panicked at (\S+?):\n((?:.+\n?){1,3})/g)].map(m => ({ at: m[1], msg: m[2].split("\n").filter(line => line.trim() && !/^note:/.test(line)).join(" ") }));
|
|
67
|
+
const summary = /^test result: FAILED\. (.+?); finished/m.exec(text)?.[1];
|
|
68
|
+
return {
|
|
69
|
+
failing: tests,
|
|
70
|
+
errors: [...compile.map(m => `${m[1] ?? ""}${m[2]}`), ...panics.map(p => p.msg)],
|
|
71
|
+
location: compile[0]?.[3] ?? panics[0]?.at,
|
|
72
|
+
summary: summary ?? (compile.length ? "build failed" : undefined),
|
|
73
|
+
};
|
|
74
|
+
}],
|
|
75
|
+
["go", text => {
|
|
76
|
+
const fails = [...text.matchAll(/^\s*--- FAIL: (\S+)/gm)].map(m => m[1]);
|
|
77
|
+
const build = [...text.matchAll(/^(\S+\.go):(\d+):(\d+): (.+)$/gm)];
|
|
78
|
+
if (!fails.length && !build.length)
|
|
79
|
+
return undefined;
|
|
80
|
+
const details = [...text.matchAll(/^\s+(\S+_test\.go:\d+): (.+)$/gm)];
|
|
81
|
+
const packages = [...text.matchAll(/^FAIL\t(\S+)(?:\t| \[)/gm)].map(m => m[1]);
|
|
82
|
+
return {
|
|
83
|
+
failing: fails,
|
|
84
|
+
errors: details.length ? details.map(m => m[2]) : build.map(m => `${m[1]}: ${m[4]}`),
|
|
85
|
+
location: details[0]?.[1] ?? (build.length ? `${build[0][1]}:${build[0][2]}` : undefined),
|
|
86
|
+
summary: packages.length ? `FAIL ${[...new Set(packages)].join(", ")}${/\[build failed\]/.test(text) ? " [build failed]" : ""}` : undefined,
|
|
87
|
+
};
|
|
88
|
+
}],
|
|
89
|
+
["pytest", text => {
|
|
90
|
+
const failed = [...text.matchAll(/^(?:FAILED|ERROR) (\S+)(?: - (.+))?$/gm)];
|
|
91
|
+
const summary = /(\d+ failed(?:, \d+ passed)?(?:, \d+ errors?)?) in [\d.]+s/.exec(text)?.[1];
|
|
92
|
+
if (!failed.length && !summary)
|
|
93
|
+
return undefined;
|
|
94
|
+
// The short summary carries one message per failure; the `E` lines are the fallback when it is off (-rN).
|
|
95
|
+
const messages = failed.map(m => m[2]).filter((message) => Boolean(message));
|
|
96
|
+
const eLines = [...text.matchAll(/^E[ \t]+(\S.*)$/gm)].map(m => m[1]);
|
|
97
|
+
const loc = /^(\S+\.py):(\d+): \w+/m.exec(text);
|
|
98
|
+
return { failing: failed.map(m => m[1]), errors: messages.length ? messages : eLines.slice(0, 3), location: loc ? `${loc[1]}:${loc[2]}` : undefined, summary };
|
|
99
|
+
}],
|
|
100
|
+
["eslint", text => {
|
|
101
|
+
const lines = text.split("\n");
|
|
102
|
+
const errors = [];
|
|
103
|
+
let file;
|
|
104
|
+
for (const line of lines) {
|
|
105
|
+
if (/^\/?\S+\.\w+$/.test(line.trim()) && !/^\s/.test(line))
|
|
106
|
+
file = line.trim();
|
|
107
|
+
const m = /^\s+(\d+):(\d+)\s+(error|warning)\s+(.+?)\s{2,}(\S+)$/.exec(line);
|
|
108
|
+
if (m && m[3] === "error")
|
|
109
|
+
errors.push(`${file ? `${file.split("/").slice(-2).join("/")}:${m[1]}` : m[1]} ${m[5]}: ${m[4]}`);
|
|
110
|
+
}
|
|
111
|
+
if (!errors.length)
|
|
112
|
+
return undefined;
|
|
113
|
+
return { errors, summary: /✖ (\d+ problems? \(.+?\))/.exec(text)?.[1] };
|
|
114
|
+
}],
|
|
115
|
+
["tap", text => {
|
|
116
|
+
const lines = text.split("\n");
|
|
117
|
+
const failing = [];
|
|
118
|
+
const errors = [];
|
|
119
|
+
let location;
|
|
120
|
+
lines.forEach((line, i) => {
|
|
121
|
+
const m = /^\s*not ok \d+ - (.+?)(?:\s+#.*)?$/.exec(line);
|
|
122
|
+
if (!m)
|
|
123
|
+
return;
|
|
124
|
+
failing.push(m[1]);
|
|
125
|
+
const block = blockAfter(lines, i, /^\s*(?:\.\.\.|ok \d+|not ok \d+|# Subtest)/);
|
|
126
|
+
const errorAt = block.findIndex(candidate => /^\s+error:/.test(candidate));
|
|
127
|
+
if (errorAt >= 0) {
|
|
128
|
+
const inline = /^\s+error:\s*(?:\|-?)?\s*(.*)$/.exec(block[errorAt])[1];
|
|
129
|
+
const message = inline && !/^\|/.test(inline) ? inline.replace(/^['"]|['"]$/g, "") : block.slice(errorAt + 1).map(candidate => candidate.trim()).filter(candidate => candidate && !/^[+-] (?:actual|expected)|^\^$/.test(candidate)).slice(0, 3).join(" ");
|
|
130
|
+
errors.push(message);
|
|
131
|
+
}
|
|
132
|
+
location ??= /^\s+location:\s*'?(.+?)'?$/m.exec(block.join("\n"))?.[1];
|
|
133
|
+
});
|
|
134
|
+
const spec = [...text.matchAll(/^\s*✖ (.+?)(?: \([\d.]+m?s\))?$/gm)].map(m => m[1]).filter(name => !/^failing tests:?$|^\d+ problems? \(/i.test(name));
|
|
135
|
+
if (!failing.length && !spec.length)
|
|
136
|
+
return undefined;
|
|
137
|
+
const fail = /^# fail (\d+)$/m.exec(text)?.[1];
|
|
138
|
+
const tests = /^# tests (\d+)$/m.exec(text)?.[1];
|
|
139
|
+
return { failing: failing.length ? failing : spec, errors, location, summary: fail ? `${fail} of ${tests} tests failed` : undefined };
|
|
140
|
+
}],
|
|
141
|
+
["playwright", text => {
|
|
142
|
+
const heads = [...text.matchAll(/^\s+\d+\) \[([^\]]+)\] › (.+?) ─+$/gm)];
|
|
143
|
+
if (!heads.length)
|
|
144
|
+
return undefined;
|
|
145
|
+
const lines = text.split("\n");
|
|
146
|
+
const errors = [];
|
|
147
|
+
lines.forEach((line, i) => {
|
|
148
|
+
if (!/^\s+\d+\) \[[^\]]+\] › /.test(line))
|
|
149
|
+
return;
|
|
150
|
+
const block = blockAfter(lines, i, /^\s+\d+\) \[|^\s+\d+ failed$/);
|
|
151
|
+
const error = block.find(candidate => /^\s+Error: /.test(candidate));
|
|
152
|
+
const detail = block.filter(candidate => /^\s+(?:Expected|Received)(?: string)?:/.test(candidate)).map(candidate => candidate.trim());
|
|
153
|
+
if (error)
|
|
154
|
+
errors.push([error.trim(), ...detail].join(" "));
|
|
155
|
+
});
|
|
156
|
+
const loc = /^\s+at (\S+:\d+:\d+)$/m.exec(text)?.[1];
|
|
157
|
+
const failed = /^\s+(\d+) failed$/m.exec(text)?.[1];
|
|
158
|
+
const passed = /^\s+(\d+) passed/m.exec(text)?.[1];
|
|
159
|
+
return { failing: heads.map(m => `[${m[1]}] ${m[2]}`), errors, location: loc, summary: failed ? `${failed} failed${passed ? `, ${passed} passed` : ""}` : undefined };
|
|
160
|
+
}],
|
|
161
|
+
["jest", text => {
|
|
162
|
+
const bullets = [...text.matchAll(/^\s*● (.+)$/gm)].map(m => m[1].trim()).filter(name => name !== "Console");
|
|
163
|
+
if (!bullets.length)
|
|
164
|
+
return undefined;
|
|
165
|
+
const lines = text.split("\n");
|
|
166
|
+
const errors = [];
|
|
167
|
+
lines.forEach((line, i) => {
|
|
168
|
+
if (!/^\s*● /.test(line) || /● Console/.test(line))
|
|
169
|
+
return;
|
|
170
|
+
const block = blockAfter(lines, i, /^\s*● |^(?:PASS|FAIL) |^Test Suites:/).map(candidate => candidate.trim()).filter(Boolean);
|
|
171
|
+
const first = block.find(candidate => !/^\d+ \||^>|^\|/.test(candidate));
|
|
172
|
+
const detail = block.filter(candidate => /^(?:Expected|Received):|^[-+] {2,}"/.test(candidate)).slice(0, 4);
|
|
173
|
+
if (first)
|
|
174
|
+
errors.push([first, ...detail].join(" "));
|
|
175
|
+
});
|
|
176
|
+
const suiteFail = [...text.matchAll(/^FAIL (\S+)/gm)].map(m => m[1]);
|
|
177
|
+
const failing = bullets.map(bullet => (bullet === "Test suite failed to run" ? `${suiteFail[0] ?? "suite"} (suite failed to run)` : bullet));
|
|
178
|
+
const loc = /at .*?\(?((?:[\w.\-~]+\/)*[\w.\-]+\.[cm]?[jt]sx?:\d+:\d+)\)?/.exec(text)?.[1];
|
|
179
|
+
const summary = /^Tests:\s+(.+)$/m.exec(text)?.[1];
|
|
180
|
+
return { failing, errors, location: loc, summary };
|
|
181
|
+
}],
|
|
182
|
+
["vitest", text => {
|
|
183
|
+
const fails = [...text.matchAll(/^ FAIL {1,2}(\S+ > .+)$/gm)].map(m => m[1]);
|
|
184
|
+
const crosses = [...text.matchAll(/^\s+× (.+?)(?: \d+m?s)?$/gm)].map(m => m[1]);
|
|
185
|
+
if (!fails.length && !crosses.length)
|
|
186
|
+
return undefined;
|
|
187
|
+
const errors = [...text.matchAll(/^(\w*Error: .+)$/gm)].map(m => m[1]);
|
|
188
|
+
const loc = /❯ (\S+:\d+:\d+)/.exec(text)?.[1];
|
|
189
|
+
const summary = /^\s+Tests\s+(.+)$/m.exec(text)?.[1];
|
|
190
|
+
return { failing: fails.length ? fails : crosses, errors, location: loc, summary };
|
|
191
|
+
}],
|
|
192
|
+
["test-script", text => {
|
|
193
|
+
const fails = [...text.matchAll(/^FAIL (\S+?): (.+)$/gm)];
|
|
194
|
+
if (!fails.length)
|
|
195
|
+
return undefined;
|
|
196
|
+
return { failing: fails.map(m => m[1]), errors: fails.map(m => m[2]), summary: /^(\d+ passed, \d+ failed)$/m.exec(text)?.[1] };
|
|
197
|
+
}],
|
|
198
|
+
["python-traceback", text => {
|
|
199
|
+
if (!/^Traceback \(most recent call last\):/m.test(text))
|
|
200
|
+
return undefined;
|
|
201
|
+
const last = [...text.matchAll(/^([\w.]*(?:Error|Exception|Exit|Interrupt)\w*): (.*)$/gm)].at(-1);
|
|
202
|
+
const frames = [...text.matchAll(/^\s+File "(.+?)", line (\d+)/gm)];
|
|
203
|
+
return { errors: last ? [`${last[1]}: ${last[2]}`] : [], location: frames.length ? `${frames.at(-1)[1]}:${frames.at(-1)[2]}` : undefined };
|
|
204
|
+
}],
|
|
205
|
+
["lisp-condition", text => {
|
|
206
|
+
const m = /^Unhandled (\S+)(?: in thread[^\n]*)?\n(?:\s+\{[^}]*\}>:\n)?\s*(.+)$/m.exec(text);
|
|
207
|
+
if (!m)
|
|
208
|
+
return undefined;
|
|
209
|
+
return { errors: [`${m[1]}: ${m[2]}`] };
|
|
210
|
+
}],
|
|
211
|
+
["node-error", text => {
|
|
212
|
+
const m = /^(\w*Error)(?: \[\w+\])?: (.+)$/m.exec(text);
|
|
213
|
+
if (!m || !/^\s+at /m.test(text))
|
|
214
|
+
return undefined;
|
|
215
|
+
const loc = /^\s+at (?:.*?\()?((?:file:\/\/)?\S+?:\d+:\d+)\)?$/m.exec(text.split("\n").filter(line => !/node:/.test(line)).join("\n"))?.[1];
|
|
216
|
+
return { errors: [`${m[1]}: ${m[2]}`], location: loc };
|
|
217
|
+
}],
|
|
218
|
+
];
|
|
219
|
+
/** Parses a failed or check output into failing tests, error lines, a location, a summary and an exit code. */
|
|
220
|
+
export function parseFailure(text) {
|
|
221
|
+
const t = text.replace(/\r/g, "");
|
|
222
|
+
const exit = exitCode(t);
|
|
223
|
+
for (const [format, parse] of PARSERS) {
|
|
224
|
+
const found = parse(t);
|
|
225
|
+
if (found)
|
|
226
|
+
return result(format, found, exit);
|
|
227
|
+
}
|
|
228
|
+
const line = t.split("\n").map(candidate => candidate.trim()).find(candidate => /\b(?:\w*Error|ERR!|error|FAIL(?:ED)?|Exception|panic|fatal|cannot|not found|No such file|denied)\b/.test(candidate) && !/^Command exited with code/.test(candidate));
|
|
229
|
+
if (line)
|
|
230
|
+
return result("generic", { errors: [line] }, exit);
|
|
231
|
+
return result("unparsed", {}, exit);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Normalises what changes between identical runs: durations, clock times and dates, addresses, long numbers, thread
|
|
235
|
+
* ids, temp paths, and line:column positions. Other digits stay, so "expected 3" and "expected 4" still differ.
|
|
236
|
+
*/
|
|
237
|
+
export function normaliseFailureText(value) {
|
|
238
|
+
return (value ?? "")
|
|
239
|
+
.replace(/(?:\/private)?\/(?:tmp|var\/folders)\/[^\s'"):]+/g, "<tmp>")
|
|
240
|
+
.replace(/\bpytest-\d+\b/g, "pytest-#")
|
|
241
|
+
.replace(/\.tmp[0-9a-zA-Z]+/g, ".tmp#")
|
|
242
|
+
.replace(/\b\d{4}[-/]\d{2}[-/]\d{2}[T ]\d{2}:\d{2}(?::\d{2}(?:[.,]\d+)?)?Z?/g, "<time>")
|
|
243
|
+
.replace(/\b\d{2}:\d{2}:\d{2}\b/g, "<time>")
|
|
244
|
+
.replace(/\brun=[0-9a-f]{6,}\b/g, "run=#")
|
|
245
|
+
.replace(/\b\d+(?:\.\d+)?\s*(?:ms|s|m|h|µs|us|ns)\b/g, "#t")
|
|
246
|
+
.replace(/0x[0-9a-fA-F]+/g, "0x#")
|
|
247
|
+
.replace(/\(\d+\)/g, "(#)")
|
|
248
|
+
.replace(/\d{5,}/g, "#")
|
|
249
|
+
.replace(/:\d+(?::\d+)?\b/g, ":#")
|
|
250
|
+
.replace(/\(\d+,\d+\)/g, "(#)")
|
|
251
|
+
.replace(/\s+/g, " ")
|
|
252
|
+
.trim();
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Order-free signature of a failure: which tests failed and with which errors, both normalised. Pass counts are left
|
|
256
|
+
* out, so a run narrowed by a flag (`-x`, a test filter) still matches the full run that failed the same way.
|
|
257
|
+
*/
|
|
258
|
+
export function failureSignature(parsed, raw = "") {
|
|
259
|
+
if (parsed.format === "unparsed")
|
|
260
|
+
return JSON.stringify(["unparsed", normaliseFailureText(raw.slice(-400))]);
|
|
261
|
+
return JSON.stringify([[...parsed.failing].map(normaliseFailureText).sort(), [...parsed.errors].map(normaliseFailureText).sort()]);
|
|
262
|
+
}
|
|
263
|
+
/** The edit pairs of an `edit` input, as `[oldText, newText]`: the `edits` array, or one top-level pair. */
|
|
264
|
+
export function editPairs(input) {
|
|
265
|
+
if (Array.isArray(input.edits))
|
|
266
|
+
return input.edits.map(edit => {
|
|
267
|
+
const pair = (edit ?? {});
|
|
268
|
+
return [String(pair.oldText ?? ""), String(pair.newText ?? "")];
|
|
269
|
+
});
|
|
270
|
+
if (input.oldText !== undefined || input.newText !== undefined)
|
|
271
|
+
return [[String(input.oldText ?? ""), String(input.newText ?? "")]];
|
|
272
|
+
return [];
|
|
273
|
+
}
|
|
274
|
+
/** Removed and added lines of one edit, as a multiset difference, so unchanged context lines drop out. */
|
|
275
|
+
export function lineDelta(oldText, newText) {
|
|
276
|
+
const count = new Map();
|
|
277
|
+
for (const line of oldText.split("\n"))
|
|
278
|
+
count.set(line, (count.get(line) ?? 0) + 1);
|
|
279
|
+
const added = [];
|
|
280
|
+
for (const line of newText.split("\n")) {
|
|
281
|
+
const n = count.get(line) ?? 0;
|
|
282
|
+
if (n > 0)
|
|
283
|
+
count.set(line, n - 1);
|
|
284
|
+
else
|
|
285
|
+
added.push(line);
|
|
286
|
+
}
|
|
287
|
+
const removed = [];
|
|
288
|
+
for (const [line, n] of count)
|
|
289
|
+
for (let i = 0; i < n; i++)
|
|
290
|
+
removed.push(line);
|
|
291
|
+
return { removed, added };
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* The change an `edit` or `write` made. Every string is as the agent wrote it: the caller redacts what leaves the
|
|
295
|
+
* machine. The diff is the whole added and removed text, not a line-by-line patch, because the judge compares one
|
|
296
|
+
* change against the next.
|
|
297
|
+
*/
|
|
298
|
+
export function editDiff(tool, input) {
|
|
299
|
+
const path = String(input.path ?? "?");
|
|
300
|
+
if (tool === "write") {
|
|
301
|
+
const lines = String(input.content ?? "").split("\n");
|
|
302
|
+
const text = lines.map(line => `+${line}`).join("\n");
|
|
303
|
+
return { path, added: lines.length, removed: 0, removedLines: [], addedLines: lines, diff: text.length > DIFF_CAP ? `${text.slice(0, DIFF_CAP)}… [diff capped]` : text };
|
|
304
|
+
}
|
|
305
|
+
const removedLines = [];
|
|
306
|
+
const addedLines = [];
|
|
307
|
+
for (const [oldText, newText] of editPairs(input)) {
|
|
308
|
+
const delta = lineDelta(oldText, newText);
|
|
309
|
+
removedLines.push(...delta.removed);
|
|
310
|
+
addedLines.push(...delta.added);
|
|
311
|
+
}
|
|
312
|
+
const text = [...removedLines.map(line => `-${line}`), ...addedLines.map(line => `+${line}`)].join("\n");
|
|
313
|
+
return { path, added: addedLines.length, removed: removedLines.length, removedLines, addedLines, diff: text.length > DIFF_CAP ? `${text.slice(0, DIFF_CAP)}… [diff capped]` : text };
|
|
314
|
+
}
|
|
315
|
+
/** The parsed view of one failed run, keyed as the judge reads it. Every string is redacted. */
|
|
316
|
+
function failureView(output, parsed) {
|
|
317
|
+
if (parsed.format === "unparsed")
|
|
318
|
+
return { output_head_tail: redact(headTail(output.trim(), FALLBACK_HEAD, FALLBACK_TAIL)) };
|
|
319
|
+
const view = { parser: parsed.format };
|
|
320
|
+
if (parsed.failing.length)
|
|
321
|
+
view.failing_tests = parsed.failing.map(redact);
|
|
322
|
+
if (parsed.failing_more)
|
|
323
|
+
view.failing_tests_more = parsed.failing_more;
|
|
324
|
+
if (parsed.errors.length)
|
|
325
|
+
view.errors = parsed.errors.map(redact);
|
|
326
|
+
if (parsed.location)
|
|
327
|
+
view.location = redact(parsed.location);
|
|
328
|
+
if (parsed.summary)
|
|
329
|
+
view.summary = redact(parsed.summary);
|
|
330
|
+
if (parsed.format === "generic")
|
|
331
|
+
view.output_tail = redact(tailOf(output.trim(), FALLBACK_TAIL));
|
|
332
|
+
return view;
|
|
333
|
+
}
|
|
334
|
+
function byteLength(value) {
|
|
335
|
+
return Buffer.byteLength(JSON.stringify(value));
|
|
336
|
+
}
|
|
337
|
+
/** The digest over the runs and edits that are still in the evidence. */
|
|
338
|
+
function digestOf(runs, edits, informationCalls) {
|
|
339
|
+
const failures = runs.filter(run => run.signature !== undefined).map(run => ({ n: run.view.n, signature: run.signature }));
|
|
340
|
+
const first = failures[0]?.n ?? 0;
|
|
341
|
+
const last = failures.at(-1)?.n ?? 0;
|
|
342
|
+
const latest = failures.at(-1)?.signature;
|
|
343
|
+
return {
|
|
344
|
+
failed_runs: failures.length,
|
|
345
|
+
distinct_failures: new Set(failures.map(failure => failure.signature)).size,
|
|
346
|
+
same_command_runs: runs.filter(run => run.repeatCommand).map(run => run.view.n),
|
|
347
|
+
latest_failure_seen_before: failures.length > 1 && failures.slice(0, -1).some(failure => failure.signature === latest),
|
|
348
|
+
edits_between_failed_runs: edits.filter(edit => edit.n > first && edit.n < last).length,
|
|
349
|
+
information_calls_between_failed_runs: failures.length ? informationCalls.filter(n => n > first).length : 0,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Builds the evidence for one stuck window: a run per failed call and per passing check command, an edit per
|
|
354
|
+
* `edit`/`write`, and a digest. The oldest runs are dropped first when the whole object is over `EVIDENCE_LIMIT`
|
|
355
|
+
* bytes, then the oldest edits; the digest is then recomputed over what is left, and a run that pointed at a dropped
|
|
356
|
+
* run points at nothing. The floor is one run plus the digest, which is far under the cap.
|
|
357
|
+
*/
|
|
358
|
+
export function buildStuckEvidence(attempts, limit = EVIDENCE_LIMIT) {
|
|
359
|
+
let runs = [];
|
|
360
|
+
let edits = [];
|
|
361
|
+
const informationCalls = [];
|
|
362
|
+
const firstCommandRun = new Map();
|
|
363
|
+
attempts.forEach((attempt, index) => {
|
|
364
|
+
const n = index + 1;
|
|
365
|
+
if (EDIT_TOOLS.has(attempt.tool)) {
|
|
366
|
+
const change = attempt.change;
|
|
367
|
+
if (change)
|
|
368
|
+
edits.push({ n, path: change.path, added_lines: change.added, removed_lines: change.removed, diff: change.diff });
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
if (!attempt.failed && !RUNNER.test(attempt.call)) {
|
|
372
|
+
if (runs.some(run => run.signature !== undefined))
|
|
373
|
+
informationCalls.push(n);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
const command = normaliseFailureText(attempt.call);
|
|
377
|
+
const seen = firstCommandRun.get(command);
|
|
378
|
+
const run = { view: { n, outcome: attempt.failed ? "failed" : "ok" }, repeatCommand: seen !== undefined };
|
|
379
|
+
if (seen === undefined)
|
|
380
|
+
firstCommandRun.set(command, n);
|
|
381
|
+
if (attempt.failed) {
|
|
382
|
+
const parsed = parseFailure(attempt.text);
|
|
383
|
+
const signature = failureSignature(parsed, attempt.text);
|
|
384
|
+
const earlier = runs.find(candidate => candidate.signature === signature);
|
|
385
|
+
Object.assign(run.view, failureView(attempt.text, parsed), { same_failure_as_run: earlier?.view.n ?? null });
|
|
386
|
+
if (parsed.exit !== undefined)
|
|
387
|
+
run.view.exit_code = parsed.exit;
|
|
388
|
+
run.signature = signature;
|
|
389
|
+
}
|
|
390
|
+
runs.push(run);
|
|
391
|
+
});
|
|
392
|
+
for (;;) {
|
|
393
|
+
const present = new Set(runs.map(run => run.view.n));
|
|
394
|
+
const kept = {
|
|
395
|
+
runs: runs.map(run => {
|
|
396
|
+
const reference = run.view.same_failure_as_run;
|
|
397
|
+
return reference !== undefined && reference !== null && !present.has(reference) ? { ...run.view, same_failure_as_run: null } : run.view;
|
|
398
|
+
}),
|
|
399
|
+
edits,
|
|
400
|
+
digest: digestOf(runs, edits, informationCalls),
|
|
401
|
+
};
|
|
402
|
+
if (byteLength(kept) <= limit || (runs.length <= 1 && edits.length <= 1))
|
|
403
|
+
return kept;
|
|
404
|
+
if (runs.length > 1)
|
|
405
|
+
runs = runs.slice(1);
|
|
406
|
+
else
|
|
407
|
+
edits = edits.slice(1);
|
|
408
|
+
}
|
|
409
|
+
}
|
package/dist/extension.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type { SlopSymptom, Verdict } from "./guard.js";
|
|
|
5
5
|
import type { ShapeResult } from "./shape.js";
|
|
6
6
|
export declare const disclosure = "With TypeSafe judgments enabled, pi-warden sends to api.typesafe.ai: your latest request, the task spine it is judged against (the first request of the thread and up to four redacted earlier requests), and up to eight redacted prior user/assistant text messages for task context, plus a redacted, truncated summary of each guarded bash, write, or edit call before it runs, with the agent's own words from the message that makes the call (its stated plan); the resolved active rules file content (pi-warden.md, the configured files, or README/CLAUDE/AGENTS as fallback, token-aware truncated at ~4000 tokens) sent with every action request unless the rules guard is off (`rules.enabled: false`), which keeps that content on this machine; for a write or edit (or a bash command that writes a file with its content in the command) in a project with a rules file (pi-warden.md, the configured files, or README/CLAUDE/AGENTS as fallback), a larger redacted sample of the written content with the current file around each edit and the rule text; the last few tool calls and output tails when the agent keeps failing; the agent's final message when it reports completion without running checks; redacted tool-output samples for security and context saving (retention and output format); a redacted sample of an async subagent report that names a failure, a stop, or a question, with your latest request, when warden decides whether that report should wake the agent; and, on the first guarded call after your reply, the redacted summaries of the calls allowed in the previous turn, so Jev can say whether your reply regrets one of them. For the conscience coach (recommend mode): your current request (2000 redacted characters), the same task spine (the first request of the thread and up to four redacted earlier requests), up to four recent user/assistant text messages (500 redacted characters each with roles), and sanitized candidate metadata (skill/tool name, role, lead, useWhen, examples when an index entry matches; bare description otherwise; full skill instructions never go to Jev). The index is built locally by the session model; only sanitized entries reach Jev; advertised locations never do. Compression and duplicate notes store an exact, owner-only copy in a temporary file on this machine; the hold feedback log stores tool names, pattern ids, scores, and outcomes (never commands) in an owner-only file under Pi's agent directory; an owner-only SQLite database under Pi's agent directory stores redacted hold context (plan, summary, redacted command preview, outcomes) for held and judged-allowed calls, for learning and retention (configurable, default 365 days). Requests may incur charges. Secret redaction is best-effort. Results are model judgments, not proof or authorization; offline pattern checks stay active either way.";
|
|
7
7
|
/** Which guard spent the user's attention. The status line reports one count per guard. */
|
|
8
|
-
export type SteerGuard = "action" | "rules" | "security" | "stuck" | "repeat" | "done" | "prose" | "runaway" | "subagent" | "conscience";
|
|
8
|
+
export type SteerGuard = "action" | "rules" | "security" | "stuck" | "repeat" | "done" | "prose" | "runaway" | "subagent" | "conscience" | "loops";
|
|
9
9
|
/**
|
|
10
10
|
* One steer message can carry notes from more than one guard, so the per-guard numbers may add up to more than the
|
|
11
11
|
* message count; the line says so instead of hiding it. Worst offender first: that is the number worth acting on.
|