pi-warden 1.0.0 → 1.1.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/CHANGELOG.md +23 -0
- package/dist/turn-rules.d.ts +1 -1
- package/dist/turn-rules.js +102 -25
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,29 @@ How to keep this current: add the entry in the same pull request as the change,
|
|
|
6
6
|
|
|
7
7
|
## Unreleased
|
|
8
8
|
|
|
9
|
+
## 1.1.1
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- `scripts/live-smoke.mjs` and `scripts/security-cases.mjs` no longer crash when a verdict has no off-task answer, which is now the usual case: the acting request does not ask `off_task`, only the trace sample does. The missing value prints as `-`.
|
|
14
|
+
- The three off-task cases in `scripts/security-cases.mjs` (side comment, take over previous work, new instruction overrides history) pass `traceSample: 1`, so every call asks the off-task question and the cases test its answer again.
|
|
15
|
+
|
|
16
|
+
## 1.1.0
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- `"typesafeBackend": "liquid"` sends judgments to Liquid AI (api.liquid.ai, key in `LIQUID_API_KEY`, default model `d1:free`) through pi-typesafe 0.9.0. `d1:free` is free but slow (median about 14 seconds a request against the 5-second default `timeoutMs`), and the paid `d1` counts the state once per question, so a 7-question action request costs several times what it costs on Jev; see [docs/configuration.md](docs/configuration.md#liquid-ai-backend).
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- pi-typesafe `^0.9.0` (was `^0.8.0`).
|
|
25
|
+
|
|
26
|
+
## 1.0.1
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- The run-end rules check no longer judges files that a `git pull` brought in when the pulled commit was made during the run, such as a squash commit from a pull request merged on the hosting service: a commit counts as brought by git when this worktree's HEAD reflog shows that the checkout did not create it, whatever its date. The commit-date rule stays as the fallback when the reflog is off.
|
|
31
|
+
|
|
9
32
|
## 1.0.0
|
|
10
33
|
|
|
11
34
|
The first stable release. From 1.0, pi-warden follows semver for the surface the README's Versioning section lists. Coming from 0.74.1, the last release on npm: [docs/upgrading.md](docs/upgrading.md) lists the removed settings, the new defaults, and what project files may still change. The changes since 0.74.1 are in the sections 0.75.0 to 0.90.0 below, and the README's "What it costs" table gives the measured effect of each 1.0 change.
|
package/dist/turn-rules.d.ts
CHANGED
|
@@ -40,7 +40,7 @@ export interface TurnDiff {
|
|
|
40
40
|
text: string;
|
|
41
41
|
/** What the caps cut, named per file, so the request and the trace say what the judge does not see. */
|
|
42
42
|
cuts: string[];
|
|
43
|
-
/** What the git-brought filter did beyond its plain work: it failed, or
|
|
43
|
+
/** What the git-brought filter did beyond its plain work: it failed, fell back to the date rule, or was cut at the probe limit. */
|
|
44
44
|
notes: string[];
|
|
45
45
|
}
|
|
46
46
|
/**
|
package/dist/turn-rules.js
CHANGED
|
@@ -105,56 +105,133 @@ function batchCheck(lines, cwd) {
|
|
|
105
105
|
child.stdin?.end(`${lines.join("\n")}\n`);
|
|
106
106
|
});
|
|
107
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* Reflog reasons for a commit this checkout created: `commit` (also `(amend)`, `(merge)`, `(initial)`), `cherry-pick`,
|
|
110
|
+
* `revert`, `am`, a rebase step that picks or rewrites a commit, and a `merge` or `pull` that made a merge commit. A fast-forward,
|
|
111
|
+
* a reset, a checkout, and the start and finish of a rebase only move HEAD to commits made elsewhere or earlier.
|
|
112
|
+
*/
|
|
113
|
+
const CREATING_REFLOG_SUBJECT = /^(?:(?:commit|cherry-pick|revert|am)\b|(?:merge|pull)\b[^:]*: Merge made by |[^:]*\((?:pick|reword|squash|fixup|continue|edit|merge)\):)/;
|
|
114
|
+
const REFLOG_OFF = new Set(["false", "no", "off", "0", "never"]);
|
|
115
|
+
/**
|
|
116
|
+
* The commits this checkout created since the run began, from the HEAD reflog of this worktree. Undefined when the
|
|
117
|
+
* reflog is turned off or cannot be read, or does not account for the move to `endHead`: the caller then uses the date rule.
|
|
118
|
+
* An entry counts as written during the run when its second is the start second or later. The reflog has whole seconds,
|
|
119
|
+
* so in the start second the oldest entry that names the start HEAD is taken as the move that put HEAD there, and it
|
|
120
|
+
* and everything older are not the run's.
|
|
121
|
+
*/
|
|
122
|
+
async function createdInRun(cwd, start, endHead) {
|
|
123
|
+
try {
|
|
124
|
+
let setting = "";
|
|
125
|
+
try {
|
|
126
|
+
setting = (await git(["config", "--get", "core.logAllRefUpdates"], cwd, 5000)).trim().toLowerCase();
|
|
127
|
+
}
|
|
128
|
+
catch { /* unset: the default keeps the reflog on */ }
|
|
129
|
+
if (REFLOG_OFF.has(setting))
|
|
130
|
+
return undefined;
|
|
131
|
+
const startSecond = Math.floor(start.startedAt / 1000);
|
|
132
|
+
const lines = (await git(["reflog", "show", "--date=unix", `--since=${startSecond}`, "--format=%H%x09%gd%x09%gs", "HEAD"], cwd)).split("\n").filter(Boolean);
|
|
133
|
+
const entries = [];
|
|
134
|
+
for (const line of lines) {
|
|
135
|
+
const match = /^([0-9a-f]{40,64})\t[^\t]*@\{(\d+)\}\t(.*)$/.exec(line);
|
|
136
|
+
if (!match)
|
|
137
|
+
return undefined;
|
|
138
|
+
const entry = { commit: match[1], at: Number(match[2]), subject: match[3] };
|
|
139
|
+
if (entry.at < startSecond)
|
|
140
|
+
break;
|
|
141
|
+
entries.push(entry);
|
|
142
|
+
}
|
|
143
|
+
for (let index = entries.length - 1; index >= 0; index--) {
|
|
144
|
+
if (entries[index].at === startSecond && entries[index].commit === start.head) {
|
|
145
|
+
entries.length = index;
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
// HEAD moved, so a reflog that is recording has the move as its newest entry.
|
|
150
|
+
if (entries[0]?.commit !== endHead)
|
|
151
|
+
return undefined;
|
|
152
|
+
return [...new Set(entries.filter(entry => CREATING_REFLOG_SUBJECT.test(entry.subject)).map(entry => entry.commit))];
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
108
158
|
/**
|
|
109
159
|
* The changed files that git itself brought in: HEAD moved during the run (a pull, a merge), and the file has no
|
|
110
|
-
* uncommitted change at the end
|
|
111
|
-
*
|
|
112
|
-
*
|
|
160
|
+
* uncommitted change at the end, holds, byte for byte, its content in a commit the end HEAD reaches and the start HEAD
|
|
161
|
+
* does not, that this checkout did not create during the run (the commit date does not matter: a squash commit the
|
|
162
|
+
* hosting service makes mid-run counts), and no commit this checkout created during the run introduced that content.
|
|
163
|
+
* A commit introduces a path's content when that content differs from the path's content in each of its parents.
|
|
164
|
+
* A file the agent edited, changed with an uncommitted command (`git checkout <old> -- file` included), or committed in
|
|
165
|
+
* the run is never in this set. When the HEAD reflog is off or unreadable, a commit counts only when it was committed
|
|
166
|
+
* before the run began (the 0.87.0 rule) and the trace says so.
|
|
113
167
|
* At most `BROUGHT_PROBE_LIMIT` file × commit pairs are probed: with more, only the newest commits that fit are probed,
|
|
114
168
|
* since the newest commit holding a path has the content of every path no later commit changed. A git failure is
|
|
115
|
-
* returned in the
|
|
169
|
+
* returned in the notes, and the caller judges every file.
|
|
116
170
|
*/
|
|
117
171
|
async function broughtByGit(cwd, start, endTree, paths) {
|
|
118
172
|
const brought = new Set();
|
|
173
|
+
const notes = [];
|
|
119
174
|
if (!start.head || !paths.length)
|
|
120
|
-
return { brought };
|
|
121
|
-
|
|
175
|
+
return { brought, notes };
|
|
176
|
+
const startHead = start.head;
|
|
122
177
|
try {
|
|
123
178
|
const endHead = await commitOf("HEAD", cwd);
|
|
124
|
-
if (!endHead || endHead ===
|
|
125
|
-
return { brought };
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
179
|
+
if (!endHead || endHead === startHead)
|
|
180
|
+
return { brought, notes };
|
|
181
|
+
const created = await createdInRun(cwd, { ...start, head: startHead }, endHead);
|
|
182
|
+
let older;
|
|
183
|
+
if (created) {
|
|
184
|
+
const made = new Set(created);
|
|
185
|
+
older = (await git(["rev-list", endHead, `^${startHead}`], cwd)).split("\n").filter(commit => commit && !made.has(commit));
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
notes.push("git-brought filter used the commit-date rule because the HEAD reflog is off or cannot be read: a commit made after the run began counts as the agent's");
|
|
189
|
+
// `--before` keeps a commit whose committer date is at or before the stamp; one second earlier makes it strict.
|
|
190
|
+
const before = Math.floor(start.startedAt / 1000) - 1;
|
|
191
|
+
older = (await git(["rev-list", endHead, `^${startHead}`, `--before=${before} +0000`], cwd)).split("\n").filter(Boolean);
|
|
192
|
+
}
|
|
129
193
|
if (!older.length)
|
|
130
|
-
return { brought };
|
|
194
|
+
return { brought, notes };
|
|
131
195
|
const uncommitted = new Set((await git(["diff", "--name-only", "--no-renames", "-z", endHead, endTree, "--"], cwd)).split("\0").filter(Boolean));
|
|
132
196
|
const clean = paths.filter(path => !uncommitted.has(path));
|
|
133
197
|
if (!clean.length)
|
|
134
|
-
return { brought };
|
|
135
|
-
|
|
198
|
+
return { brought, notes };
|
|
199
|
+
// Each created commit is probed in itself and in its parents; one line of `--parents` output is the commit then its parents.
|
|
200
|
+
const makers = created?.length ? (await git(["rev-list", "--no-walk", "--parents", ...created], cwd)).split("\n").filter(Boolean).map(line => line.split(" ")) : [];
|
|
201
|
+
const makerProbes = makers.reduce((sum, ids) => sum + ids.length, 0);
|
|
202
|
+
const fit = Math.max(0, Math.floor(BROUGHT_PROBE_LIMIT / clean.length) - makerProbes);
|
|
136
203
|
if (fit < older.length) {
|
|
137
|
-
|
|
204
|
+
notes.push(`git-brought filter cut: ${clean.length} files × ${older.length} older commits is over the ${BROUGHT_PROBE_LIMIT}-probe limit, so only the newest ${fit} commits were probed`);
|
|
138
205
|
older = older.slice(0, fit);
|
|
139
206
|
if (!older.length)
|
|
140
|
-
return { brought,
|
|
207
|
+
return { brought, notes };
|
|
141
208
|
}
|
|
142
|
-
const probes = clean.flatMap(path => [`${endHead}:${path}`, ...older.map(commit => `${commit}:${path}`)]);
|
|
209
|
+
const probes = clean.flatMap(path => [`${endHead}:${path}`, ...older.map(commit => `${commit}:${path}`), ...makers.flatMap(ids => ids.map(commit => `${commit}:${path}`))]);
|
|
143
210
|
const answers = await batchCheck(probes, cwd);
|
|
144
|
-
const stride = older.length +
|
|
211
|
+
const stride = 1 + older.length + makerProbes;
|
|
145
212
|
clean.forEach((path, row) => {
|
|
146
|
-
const
|
|
213
|
+
const base = row * stride;
|
|
214
|
+
const atEnd = answers[base];
|
|
147
215
|
if (!atEnd)
|
|
148
216
|
return;
|
|
149
|
-
if (older.some((_, column) => answers[
|
|
150
|
-
|
|
217
|
+
if (!older.some((_, column) => answers[base + 1 + column] === atEnd))
|
|
218
|
+
return;
|
|
219
|
+
let at = base + 1 + older.length;
|
|
220
|
+
for (const ids of makers) {
|
|
221
|
+
const inCommit = answers[at] === atEnd;
|
|
222
|
+
const inParents = ids.slice(1).some((_, parent) => answers[at + 1 + parent] === atEnd);
|
|
223
|
+
at += ids.length;
|
|
224
|
+
if (inCommit && !inParents)
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
brought.add(path);
|
|
151
228
|
});
|
|
152
229
|
}
|
|
153
230
|
catch (error) {
|
|
154
231
|
// Nothing is skipped on a git failure: the caller judges every file, and the trace says the filter failed.
|
|
155
|
-
return { brought: new Set(),
|
|
232
|
+
return { brought: new Set(), notes: [...notes, `git-brought filter failed: ${(error instanceof Error ? error.message : String(error)).replace(/\s+/g, " ").trim()}; every changed file was judged`] };
|
|
156
233
|
}
|
|
157
|
-
return
|
|
234
|
+
return { brought, notes };
|
|
158
235
|
}
|
|
159
236
|
/**
|
|
160
237
|
* The working-tree change since the snapshot: the files git reports and each one's unified diff, capped per file and
|
|
@@ -178,7 +255,7 @@ export async function diffSince(cwd, startTree, maxChars, start) {
|
|
|
178
255
|
for (let index = 0; index + 1 < names.length; index += 2)
|
|
179
256
|
if (names[index + 1])
|
|
180
257
|
candidates.push(names[index + 1]);
|
|
181
|
-
const { brought,
|
|
258
|
+
const { brought, notes } = start ? await broughtByGit(cwd, start, endTree, candidates) : { brought: new Set(), notes: [] };
|
|
182
259
|
for (let index = 0; index + 1 < names.length; index += 2) {
|
|
183
260
|
const status = names[index].slice(0, 1);
|
|
184
261
|
const path = names[index + 1];
|
|
@@ -217,7 +294,7 @@ export async function diffSince(cwd, startTree, maxChars, start) {
|
|
|
217
294
|
cuts.push(`${file.path}: file diff cut to ${room} chars to fit the ${maxChars}-char total; later files not sent`);
|
|
218
295
|
spent = true;
|
|
219
296
|
}
|
|
220
|
-
return { files, text: chunks.join("\n"), cuts, notes
|
|
297
|
+
return { files, text: chunks.join("\n"), cuts, notes };
|
|
221
298
|
}
|
|
222
299
|
// ---------------------------------------------------------------------------
|
|
223
300
|
// The turn question: one Choice per turn rule, against the whole diff and the task.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-warden",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "Makes the Pi agent follow your project's rules. Jev judges every write against your pi-warden.md and quotes the broken rule back to the agent, names slop, breaks stuck loops, calls out unverified done claims, compresses large tool output, and holds the rare destructive command. Built on pi-typesafe.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -88,6 +88,6 @@
|
|
|
88
88
|
"typescript": "^6.0.0"
|
|
89
89
|
},
|
|
90
90
|
"dependencies": {
|
|
91
|
-
"pi-typesafe": "^0.
|
|
91
|
+
"pi-typesafe": "^0.9.0"
|
|
92
92
|
}
|
|
93
93
|
}
|