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 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.
@@ -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 it was cut at the probe limit. */
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
  /**
@@ -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 and holds, byte for byte, its content in a commit the end HEAD reaches, the start
111
- * HEAD does not reach, and that was committed before the run began. A file the agent edited, changed with an
112
- * uncommitted command (`git checkout <old> -- file` included), or committed in the run is never in this set.
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 note, and the caller judges every file.
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
- let note;
175
+ return { brought, notes };
176
+ const startHead = start.head;
122
177
  try {
123
178
  const endHead = await commitOf("HEAD", cwd);
124
- if (!endHead || endHead === start.head)
125
- return { brought };
126
- // `--before` keeps a commit whose committer date is at or before the stamp; one second earlier makes it strict.
127
- const before = Math.floor(start.startedAt / 1000) - 1;
128
- let older = (await git(["rev-list", endHead, `^${start.head}`, `--before=${before} +0000`], cwd)).split("\n").filter(Boolean);
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
- const fit = Math.floor(BROUGHT_PROBE_LIMIT / clean.length);
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
- note = `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`;
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, note };
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 + 1;
211
+ const stride = 1 + older.length + makerProbes;
145
212
  clean.forEach((path, row) => {
146
- const atEnd = answers[row * stride];
213
+ const base = row * stride;
214
+ const atEnd = answers[base];
147
215
  if (!atEnd)
148
216
  return;
149
- if (older.some((_, column) => answers[row * stride + 1 + column] === atEnd))
150
- brought.add(path);
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(), note: `git-brought filter failed: ${(error instanceof Error ? error.message : String(error)).replace(/\s+/g, " ").trim()}; every changed file was judged` };
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 note ? { brought, note } : { brought };
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, note } = start ? await broughtByGit(cwd, start, endTree, candidates) : { brought: new Set(), note: undefined };
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: note ? [note] : [] };
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.0.0",
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.8.0"
91
+ "pi-typesafe": "^0.9.0"
92
92
  }
93
93
  }