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/guard.d.ts
CHANGED
|
@@ -156,6 +156,10 @@ export interface Verdict {
|
|
|
156
156
|
plan?: string;
|
|
157
157
|
/** True when Jev finds the call at odds with the agent's stated plan and the call can change something; the agent is told. */
|
|
158
158
|
intentMismatch?: boolean;
|
|
159
|
+
/** The intent mismatch is recorded in the trace but the agent is not told (`action.intentTraceOnly`). */
|
|
160
|
+
intentTraceOnly?: boolean;
|
|
161
|
+
/** Index of the trace-only intent-mismatch reason; any prepend must adjust this index. */
|
|
162
|
+
intentTraceOnlyReasonIndex?: number;
|
|
159
163
|
/** True when Jev finds the call unrelated to the request on a call that can change something. Still steered in the reason log, but the steer message is suppressed until AUC improves above 0.51. */
|
|
160
164
|
offTaskSteer?: boolean;
|
|
161
165
|
/** True when should_proceed is below the hold threshold; the agent is told to pause and ask. */
|
package/dist/guard.js
CHANGED
|
@@ -184,6 +184,99 @@ function isDataSegment(segment) {
|
|
|
184
184
|
return /\s--(?:body|title|notes)\b/.test(segment) || /\s-[bt]\s/.test(segment);
|
|
185
185
|
return DATA_HEADS.has(head);
|
|
186
186
|
}
|
|
187
|
+
const GH_MESSAGE_FLAGS = new Set(["--body", "-b", "--title", "-t", "--notes"]);
|
|
188
|
+
const GH_MESSAGE_OBJECTS = new Set(["pr", "issue", "release"]);
|
|
189
|
+
const GH_MESSAGE_VERBS = new Set(["create", "edit", "comment"]);
|
|
190
|
+
const GIT_FLAG_SUBCOMMANDS = new Set(["commit", "tag"]);
|
|
191
|
+
/** Whether `flag`, read after `words` of one simple command, takes a message as its value. */
|
|
192
|
+
function isMessageFlag(words, flag) {
|
|
193
|
+
let index = 0;
|
|
194
|
+
while (index < words.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[index]) || WRAPPERS.has(words[index])))
|
|
195
|
+
index++;
|
|
196
|
+
const head = words[index]?.replace(/^.*\//, "");
|
|
197
|
+
const rest = words.slice(index + 1);
|
|
198
|
+
if (head === "gh")
|
|
199
|
+
return GH_MESSAGE_OBJECTS.has(rest[0] ?? "") && GH_MESSAGE_VERBS.has(rest[1] ?? "") && GH_MESSAGE_FLAGS.has(flag);
|
|
200
|
+
if (head !== "git")
|
|
201
|
+
return false;
|
|
202
|
+
// `git -c alias.x=!cmd` runs a shell command, so a config override keeps the whole command in scope.
|
|
203
|
+
if (rest.some(word => word === "-c" || word.startsWith("--config")))
|
|
204
|
+
return false;
|
|
205
|
+
let sub = 0;
|
|
206
|
+
while (sub < rest.length && rest[sub].startsWith("-"))
|
|
207
|
+
sub += rest[sub] === "-C" ? 2 : 1;
|
|
208
|
+
return GIT_FLAG_SUBCOMMANDS.has(rest[sub] ?? "") && sub < rest.length && (flag === "--message" || /^-[a-zA-Z]*m$/.test(flag));
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Blanks the quoted values of message flags (`gh pr create --body`, `git commit -m`, and their `=` forms) across the
|
|
212
|
+
* whole command. It reads quotes before separators, so a body that holds `;`, `&&`, or new lines stays one value;
|
|
213
|
+
* `splitShell` alone would cut it into segments that look like commands. A value with `$(`, backticks, or an unclosed
|
|
214
|
+
* quote is kept, because the shell runs or re-reads it.
|
|
215
|
+
*/
|
|
216
|
+
function blankMessageFlags(command) {
|
|
217
|
+
let out = "";
|
|
218
|
+
let words = [];
|
|
219
|
+
let word = "";
|
|
220
|
+
let pending = false;
|
|
221
|
+
const flush = () => {
|
|
222
|
+
if (!word)
|
|
223
|
+
return;
|
|
224
|
+
pending = isMessageFlag(words, word);
|
|
225
|
+
words.push(word);
|
|
226
|
+
word = "";
|
|
227
|
+
};
|
|
228
|
+
for (let index = 0; index < command.length;) {
|
|
229
|
+
const char = command[index];
|
|
230
|
+
if (/[\n;&|]/.test(char)) {
|
|
231
|
+
flush();
|
|
232
|
+
words = [];
|
|
233
|
+
pending = false;
|
|
234
|
+
out += char;
|
|
235
|
+
index++;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (/\s/.test(char)) {
|
|
239
|
+
flush();
|
|
240
|
+
out += char;
|
|
241
|
+
index++;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (char === "\\") {
|
|
245
|
+
word += command.slice(index, index + 2);
|
|
246
|
+
out += command.slice(index, index + 2);
|
|
247
|
+
index += 2;
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
const ansi = char === "$" && command[index + 1] === "'";
|
|
251
|
+
if (char !== "'" && char !== "\"" && !ansi) {
|
|
252
|
+
word += char;
|
|
253
|
+
out += char;
|
|
254
|
+
index++;
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
const open = ansi ? index + 1 : index;
|
|
258
|
+
const quote = command[open];
|
|
259
|
+
let end = open + 1;
|
|
260
|
+
while (end < command.length && command[end] !== quote)
|
|
261
|
+
end += quote !== "'" || ansi ? (command[end] === "\\" ? 2 : 1) : 1;
|
|
262
|
+
if (end >= command.length)
|
|
263
|
+
return out + command.slice(index);
|
|
264
|
+
const raw = command.slice(index, end + 1);
|
|
265
|
+
const inner = command.slice(open + 1, end);
|
|
266
|
+
const flagValue = (word === "" && pending) || (word.endsWith("=") && isMessageFlag(words, word.slice(0, -1)));
|
|
267
|
+
if (flagValue && !SUBSTITUTION.test(inner)) {
|
|
268
|
+
out += `${quote}[text]${quote}`;
|
|
269
|
+
word += `${quote}[text]${quote}`;
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
out += raw;
|
|
273
|
+
word += raw;
|
|
274
|
+
}
|
|
275
|
+
pending = false;
|
|
276
|
+
index = end + 1;
|
|
277
|
+
}
|
|
278
|
+
return out;
|
|
279
|
+
}
|
|
187
280
|
/**
|
|
188
281
|
* Removes heredoc bodies that are not fed to a shell and quoted arguments of data commands. Interpreter heredocs
|
|
189
282
|
* (`python3 - <<EOF`) are kept when the script calls out to a shell or process API.
|
|
@@ -222,7 +315,8 @@ export function stripDataText(command) {
|
|
|
222
315
|
out.push(lines[close]);
|
|
223
316
|
index = close;
|
|
224
317
|
}
|
|
225
|
-
const
|
|
318
|
+
const scanned = out.join("\n");
|
|
319
|
+
const joined = blankMessageFlags(scanned);
|
|
226
320
|
const segments = splitShell(joined);
|
|
227
321
|
// A shell sink anywhere may run text written earlier in the same command (`cat <<EOF > run.sh` then `bash run.sh`), so nothing is treated as data.
|
|
228
322
|
if (segments.some(segment => { const head = headOf(segment); return head !== undefined && SHELL_SINKS.has(head); }))
|
|
@@ -230,6 +324,8 @@ export function stripDataText(command) {
|
|
|
230
324
|
if (/\b(?:ba|z|da|k)?sh\s+-[a-zA-Z]*c\b/.test(joined))
|
|
231
325
|
return { text: command, stripped: false };
|
|
232
326
|
let text = joined;
|
|
327
|
+
if (joined !== scanned)
|
|
328
|
+
stripped = true;
|
|
233
329
|
for (const segment of segments) {
|
|
234
330
|
if (!isDataSegment(segment) || !/["']/.test(segment))
|
|
235
331
|
continue;
|
|
@@ -243,13 +339,13 @@ export function stripDataText(command) {
|
|
|
243
339
|
}
|
|
244
340
|
/**
|
|
245
341
|
* rm with both recursive and force flags. Absolute, home, variable, or wildcard targets are destructive; relative ones are
|
|
246
|
-
* risky. A quote or
|
|
342
|
+
* risky. A quote, parenthesis, or backtick before `rm` is allowed so a quoted or substituted command is read; data quotes were blanked before this runs.
|
|
247
343
|
*/
|
|
248
344
|
function classifyRm(segment, cwd, scratch) {
|
|
249
|
-
const match = /(?:^|[\s"'(])rm\s+(.*)$/.exec(segment);
|
|
345
|
+
const match = /(?:^|[\s"'(`])rm\s+(.*)$/.exec(segment);
|
|
250
346
|
if (!match)
|
|
251
347
|
return undefined;
|
|
252
|
-
const tokens = match[1].split(/\s+/).filter(Boolean).map(token => token.replace(/["')]+$/, ""));
|
|
348
|
+
const tokens = match[1].split(/\s+/).filter(Boolean).map(token => token.replace(/["')`]+$/, ""));
|
|
253
349
|
const flags = tokens.filter(token => token.startsWith("-"));
|
|
254
350
|
const targets = tokens.filter(token => !token.startsWith("-"));
|
|
255
351
|
const recursive = flags.some(flag => flag === "--recursive" || (/^-[a-zA-Z]+$/.test(flag) && /[rR]/.test(flag)));
|
|
@@ -414,6 +510,15 @@ export function tempRootOf(real, roots = tempRoots()) {
|
|
|
414
510
|
}
|
|
415
511
|
/** A privilege-raising command word anywhere in a command. */
|
|
416
512
|
const PRIVILEGED = /(?:^|[\s;&|("'`])(?:sudo|doas|su|pkexec|run0)(?=\s|$)/m;
|
|
513
|
+
/**
|
|
514
|
+
* Command words that can put existing data under a temp path before a later rm deletes it: a move, a link (`rm -rf
|
|
515
|
+
* link/` follows it), a copy or extract that can carry links (`cp -R`, `tar -x`, `git clone`), a sync that removes its
|
|
516
|
+
* source, a mount. The birth-time walk runs before the command, so it cannot see what the command itself moves in. Any
|
|
517
|
+
* `cp` or `tar` counts, whatever its flags. Read on `unquoted` text, so `\mv`, `"ln"`, and `l''n` count too.
|
|
518
|
+
*/
|
|
519
|
+
const MOVES_IN = /(?:^|[\s;&|(`/])(?:(?:g|bsd)?(?:mv|ln|cp|tar)|rsync|mount|hdiutil|bindfs|git(?=\s)[^;&|\n]*\sclone)(?=[\s;&|)`]|$)/m;
|
|
520
|
+
/** A command with its quotes and backslashes removed, so a quoted or escaped command word reads as the word it runs. */
|
|
521
|
+
const unquoted = (command) => command.replace(/\$(?=['"])|['"\\]/g, "");
|
|
417
522
|
/** A literal absolute path: no quotes left inside, no glob, brace, tilde, variable, escape, or substitution. */
|
|
418
523
|
const LITERAL_PATH = /^\/[^*?[\]{}$`~\\"'\s]*$/;
|
|
419
524
|
/**
|
|
@@ -1123,8 +1228,10 @@ export function matchPatterns(tool, input, cwd, options) {
|
|
|
1123
1228
|
add({ id: rule.id, severity: rule.severity, label: rule.label });
|
|
1124
1229
|
applySqlTargets(raw, hits, exempt);
|
|
1125
1230
|
applyGitState(raw, hits, cwd);
|
|
1126
|
-
// A command that raises privileges anywhere (`sudo`, `doas`, `su -c`, a heredoc fed to `sudo bash`) deletes as someone else
|
|
1127
|
-
|
|
1231
|
+
// A command that raises privileges anywhere (`sudo`, `doas`, `su -c`, a heredoc fed to `sudo bash`) deletes as someone else,
|
|
1232
|
+
// and one that moves, links, copies, or extracts data can fill a path after the birth-time walk: no scratch.
|
|
1233
|
+
const movesIn = MOVES_IN.test(unquoted(raw)) || MOVES_IN.test(unquoted(command));
|
|
1234
|
+
const scratch = PRIVILEGED.test(command) || movesIn || !scratchPlatform(options?.platform) ? undefined : options?.scratch;
|
|
1128
1235
|
for (const segment of splitShell(command)) {
|
|
1129
1236
|
const hit = classifyRm(segment, cwd, scratch);
|
|
1130
1237
|
// classifyRm derives ids (rm-recursive, rm-rf, rm-recursive-dangerous-target); they are exemptable like any built-in.
|
|
@@ -1568,7 +1675,7 @@ export function describeAction(tool, input, cwd) {
|
|
|
1568
1675
|
summary.command = redact(truncate(view.command, COMMAND_LIMIT));
|
|
1569
1676
|
// Jev sees the full text; this names the part of it that is written or printed rather than executed.
|
|
1570
1677
|
if (stripDataText(view.command).stripped)
|
|
1571
|
-
summary.dataText = "heredoc bodies and quoted arguments of echo/printf/grep
|
|
1678
|
+
summary.dataText = "heredoc bodies and quoted arguments of echo/printf/grep, git commit messages, and gh message flags in this command are text that is written, printed, searched, or recorded, not executed";
|
|
1572
1679
|
const written = tool === "bash" ? mergeWrites(shellWrites(view.command, { home: homedir() }).writes) : [];
|
|
1573
1680
|
if (written.length) {
|
|
1574
1681
|
summary.writes = written.map(write => `${write.append ? "appends to" : "writes"} ${displayPath(write.path, cwd).path}`);
|
|
@@ -2044,11 +2151,21 @@ export async function evaluateAction(action, options) {
|
|
|
2044
2151
|
// object to on recorded sessions, a plan-drifting file edit far less so.
|
|
2045
2152
|
const visibleDrift = judgment.intentMismatch !== undefined && (judgment.visible ?? 0) >= VISIBLE_THRESHOLD && judgment.intentMismatch >= config.visibleMismatch;
|
|
2046
2153
|
const mismatch = judgment.intentMismatch !== undefined && canChange && (judgment.intentMismatch >= config.intentMismatch || visibleDrift);
|
|
2154
|
+
// The steer reaches the agent after the call ran (275 of 275 recorded steers), and a strict course change followed 8% of
|
|
2155
|
+
// them. A call with no visible effect keeps the finding in the trace only; a visible one still tells the agent. Visible
|
|
2156
|
+
// is either rule: the code's (commit, push, merge, tag, reset, pull request, release, publish) or the judge's `visible`
|
|
2157
|
+
// score at 0.8, which also covers an install, a launched program, or a message sent from a script.
|
|
2158
|
+
const visibleEffect = (view?.shell === true && isVisibleCommand(view.command)) || (judgment.visible ?? 0) >= VISIBLE_THRESHOLD;
|
|
2159
|
+
const intentTraceOnly = mismatch && (config.intentTraceOnly === "all" || (config.intentTraceOnly === "invisible" && !visibleEffect));
|
|
2160
|
+
let intentTraceOnlyReasonIndex;
|
|
2047
2161
|
if (mismatch) {
|
|
2048
2162
|
level = higher(level, "warn");
|
|
2163
|
+
if (intentTraceOnly)
|
|
2164
|
+
intentTraceOnlyReasonIndex = reasons.length;
|
|
2165
|
+
const traceOnly = intentTraceOnly ? `; trace-only${config.intentTraceOnly === "invisible" ? ", no visible effect" : ""}` : "";
|
|
2049
2166
|
reasons.push(visibleDrift && judgment.intentMismatch < config.intentMismatch
|
|
2050
|
-
? `intent mismatch ${percent(judgment.intentMismatch)} on a visible action (${percent(judgment.visible)}; a commit, push, merge, publish, or launch the plan did not describe)`
|
|
2051
|
-
: `intent mismatch ${percent(judgment.intentMismatch)} (the call differs from the agent's stated plan)`);
|
|
2167
|
+
? `intent mismatch ${percent(judgment.intentMismatch)} on a visible action (${percent(judgment.visible)}; a commit, push, merge, publish, or launch the plan did not describe${traceOnly})`
|
|
2168
|
+
: `intent mismatch ${percent(judgment.intentMismatch)} (the call differs from the agent's stated plan${traceOnly})`);
|
|
2052
2169
|
}
|
|
2053
2170
|
// Poor calibration makes this diagnostic-only unless the user opts into steers.
|
|
2054
2171
|
let shouldProceedSteer = false;
|
|
@@ -2066,6 +2183,10 @@ export async function evaluateAction(action, options) {
|
|
|
2066
2183
|
const verdict = withPlan({ level, source: "typesafe", summary, patterns, reasons, judgment });
|
|
2067
2184
|
if (mismatch)
|
|
2068
2185
|
verdict.intentMismatch = true;
|
|
2186
|
+
if (intentTraceOnlyReasonIndex !== undefined) {
|
|
2187
|
+
verdict.intentTraceOnly = true;
|
|
2188
|
+
verdict.intentTraceOnlyReasonIndex = intentTraceOnlyReasonIndex;
|
|
2189
|
+
}
|
|
2069
2190
|
if (offTaskSteer)
|
|
2070
2191
|
verdict.offTaskSteer = true;
|
|
2071
2192
|
if (offTaskTraceOnly)
|
|
@@ -2150,6 +2271,8 @@ export async function evaluateAction(action, options) {
|
|
|
2150
2271
|
verdict.offTaskTraceOnlyReasonIndex++;
|
|
2151
2272
|
if (verdict.shouldProceedTraceOnlyReasonIndex !== undefined)
|
|
2152
2273
|
verdict.shouldProceedTraceOnlyReasonIndex++;
|
|
2274
|
+
if (verdict.intentTraceOnlyReasonIndex !== undefined)
|
|
2275
|
+
verdict.intentTraceOnlyReasonIndex++;
|
|
2153
2276
|
}
|
|
2154
2277
|
return verdict;
|
|
2155
2278
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -6,7 +6,9 @@ export type { Conversation, InspectOptions, ToolCallRef } from "./action-guard.j
|
|
|
6
6
|
export { evaluateProse, buildProseRequest, proseQuestions, proseNudge, describeAudience, ProseTrend, PROSE_SYMPTOMS, PROSE_LABELS } from "./prose.js";
|
|
7
7
|
export type { ProseSymptom, ProseVerdict, ProseOptions } from "./prose.js";
|
|
8
8
|
export { AttemptWindow, makeAttempt, resultFailed, resultText, evaluateStuck, buildStuckRequest, stuckQuestions, stuckNudge, formatStuck } from "./stuck.js";
|
|
9
|
-
export type { Attempt, StuckJudgment, StuckVerdict, StuckOptions } from "./stuck.js";
|
|
9
|
+
export type { Attempt, AttemptChange, StuckJudgment, StuckVerdict, StuckOptions, StuckRequestOptions, StuckRequestState } from "./stuck.js";
|
|
10
|
+
export { parseFailure, failureSignature, normaliseFailureText, editPairs, lineDelta, editDiff, buildStuckEvidence, EVIDENCE_LIMIT } from "./evidence.js";
|
|
11
|
+
export type { ParsedFailure, EditDiff, EvidenceRun, EvidenceEdit, EvidenceDigest, StuckEvidence } from "./evidence.js";
|
|
10
12
|
export { classifyToolResult, recordOutcome, recordUi, isUiFile, isVisualCheck, emptyEvidence, freshChecks, needsDoneCheck, finalAssistantText, evaluateDone, buildDoneRequest, doneQuestions, doneNudge, formatDone } from "./done.js";
|
|
11
13
|
export type { ToolOutcome, RunEvidence, DoneJudgment, DoneVerdict, DoneOptions } from "./done.js";
|
|
12
14
|
export { evaluateOutput, buildOutputRequest, outputQuestions, compressOutput, saveOutput, securityNotice, outputKey, duplicateNote } from "./output.js";
|
|
@@ -36,8 +38,14 @@ export { renderTemplate, actionTokens, stuckTokens, doneTokens, proseTokens, run
|
|
|
36
38
|
export type { WidgetConfig, WidgetPlacement, WidgetBarMode, Tokens } from "./widget.js";
|
|
37
39
|
export { Trace, actionDetails, stuckDetails, doneDetails, proseDetails, runawayDetails, rulesDetails } from "./trace.js";
|
|
38
40
|
export type { TraceEntry, GuardName } from "./trace.js";
|
|
39
|
-
export { parseRules, condense, isRuleShaped, globToRegExp, matchGlob, projectPath, RuleStore, rulesFor, describeRuleSet, describeTarget, buildRulesRequest, ruleQuestion, evaluateRules, skipReason, rulesSteer, formatRules, pathNotes, pathNoteSteer, RulesGuard, RULES_FILE, FALLBACK_FILES, MAX_RULES, AGGREGATE_QUESTION, LOCATOR_QUESTION } from "./rules.js";
|
|
41
|
+
export { parseRules, condense, isRuleShaped, globToRegExp, matchGlob, projectPath, RuleStore, rulesFor, describeRuleSet, formatRuleSetDetails, describeTarget, buildRulesRequest, ruleQuestion, evaluateRules, skipReason, rulesSteer, formatRules, pathNotes, pathNoteSteer, RulesGuard, RULES_FILE, FALLBACK_FILES, MAX_RULES, AGGREGATE_QUESTION, LOCATOR_QUESTION } from "./rules.js";
|
|
40
42
|
export type { Rule, RuleSet, RuleOutcome, RuleScore, RuleFinding, RulesVerdict, RulesOptions, RulesTarget, EditView, PathNote, RulesCallRef, RulesTier, RulesSourceConfig } from "./rules.js";
|
|
43
|
+
export { checkRules, formatRulesCheck, formatRuleCheck, buildRulesCheckRequest, ruleCheckView, judgeableQuestion, mechanicalQuestion, MECHANICAL_CUTOFF, CHECK_ABOUT, JUDGEABILITY_REASONS, JUDGEABLE_PREFIX, MECHANICAL_PREFIX } from "./rules-lint.js";
|
|
44
|
+
export type { RuleCheck, RuleCheckView, RuleJudgeability, RulesCheckOptions, RulesCheckRequest, RulesCheckResult, RulesCheckSource } from "./rules-lint.js";
|
|
45
|
+
export { rulesLogPath, readRulesLog, trimRecords, RuleClearTracker, RulesLog, RULES_LOG_MAX_RECORDS } from "./rules-log.js";
|
|
46
|
+
export type { RuleRecord, RuleObservation, RulesLogOptions } from "./rules-log.js";
|
|
47
|
+
export { buildRulesReport, formatRulesReport, compareRows, REPORT_DEFAULT_DAYS, NEVER_FIRES_MIN, FIRES_RATE_MAX, UNDECIDED_LOW, UNDECIDED_HIGH } from "./rules-report.js";
|
|
48
|
+
export type { RuleFlag, RuleReportRow, RulesReport } from "./rules-report.js";
|
|
41
49
|
export { resolveRulesFile, extractRules, checkPiWardenMissing } from "./rules-file.js";
|
|
42
50
|
export type { ResolvedRulesFile } from "./rules-file.js";
|
|
43
51
|
export { writeStarterRules, generateStarterRules, detectProjectType, buildProjectContext, buildInitPrompt } from "./init.js";
|
package/dist/index.js
CHANGED
|
@@ -3,6 +3,7 @@ export { SLOP_SYMPTOMS, SLOP_LABELS } from "./guard.js";
|
|
|
3
3
|
export { ActionGuard } from "./action-guard.js";
|
|
4
4
|
export { evaluateProse, buildProseRequest, proseQuestions, proseNudge, describeAudience, ProseTrend, PROSE_SYMPTOMS, PROSE_LABELS } from "./prose.js";
|
|
5
5
|
export { AttemptWindow, makeAttempt, resultFailed, resultText, evaluateStuck, buildStuckRequest, stuckQuestions, stuckNudge, formatStuck } from "./stuck.js";
|
|
6
|
+
export { parseFailure, failureSignature, normaliseFailureText, editPairs, lineDelta, editDiff, buildStuckEvidence, EVIDENCE_LIMIT } from "./evidence.js";
|
|
6
7
|
export { classifyToolResult, recordOutcome, recordUi, isUiFile, isVisualCheck, emptyEvidence, freshChecks, needsDoneCheck, finalAssistantText, evaluateDone, buildDoneRequest, doneQuestions, doneNudge, formatDone } from "./done.js";
|
|
7
8
|
export { evaluateOutput, buildOutputRequest, outputQuestions, compressOutput, saveOutput, securityNotice, outputKey, duplicateNote } from "./output.js";
|
|
8
9
|
export { formatExcerpt, formatQuestion } from "./excerpt.js";
|
|
@@ -18,7 +19,10 @@ export { CONFIG_SCHEMA, defaultConfig, loadConfig, applyUserOverrides, applyProj
|
|
|
18
19
|
export { commandOf, outputReportsFailure, COMMAND_TOOLS } from "./tools.js";
|
|
19
20
|
export { renderTemplate, actionTokens, stuckTokens, doneTokens, proseTokens, runawayTokens, rulesTokens, defaultWidgetConfig, DEFAULT_TEMPLATES, SENTENCE_TEMPLATES, pickSentenceTemplate, TOKEN_NAMES } from "./widget.js";
|
|
20
21
|
export { Trace, actionDetails, stuckDetails, doneDetails, proseDetails, runawayDetails, rulesDetails } from "./trace.js";
|
|
21
|
-
export { parseRules, condense, isRuleShaped, globToRegExp, matchGlob, projectPath, RuleStore, rulesFor, describeRuleSet, describeTarget, buildRulesRequest, ruleQuestion, evaluateRules, skipReason, rulesSteer, formatRules, pathNotes, pathNoteSteer, RulesGuard, RULES_FILE, FALLBACK_FILES, MAX_RULES, AGGREGATE_QUESTION, LOCATOR_QUESTION } from "./rules.js";
|
|
22
|
+
export { parseRules, condense, isRuleShaped, globToRegExp, matchGlob, projectPath, RuleStore, rulesFor, describeRuleSet, formatRuleSetDetails, describeTarget, buildRulesRequest, ruleQuestion, evaluateRules, skipReason, rulesSteer, formatRules, pathNotes, pathNoteSteer, RulesGuard, RULES_FILE, FALLBACK_FILES, MAX_RULES, AGGREGATE_QUESTION, LOCATOR_QUESTION } from "./rules.js";
|
|
23
|
+
export { checkRules, formatRulesCheck, formatRuleCheck, buildRulesCheckRequest, ruleCheckView, judgeableQuestion, mechanicalQuestion, MECHANICAL_CUTOFF, CHECK_ABOUT, JUDGEABILITY_REASONS, JUDGEABLE_PREFIX, MECHANICAL_PREFIX } from "./rules-lint.js";
|
|
24
|
+
export { rulesLogPath, readRulesLog, trimRecords, RuleClearTracker, RulesLog, RULES_LOG_MAX_RECORDS } from "./rules-log.js";
|
|
25
|
+
export { buildRulesReport, formatRulesReport, compareRows, REPORT_DEFAULT_DAYS, NEVER_FIRES_MIN, FIRES_RATE_MAX, UNDECIDED_LOW, UNDECIDED_HIGH } from "./rules-report.js";
|
|
22
26
|
export { resolveRulesFile, extractRules, checkPiWardenMissing } from "./rules-file.js";
|
|
23
27
|
export { writeStarterRules, generateStarterRules, detectProjectType, buildProjectContext, buildInitPrompt } from "./init.js";
|
|
24
28
|
export { initSchema, recordHold, recordOutcome as recordHoldOutcome, querySmartHistory, calculateSmartConfidence, shouldSkipHold, analyzeThresholds, analyzePatterns, generateRecommendations, analyzeSteerEffectivenessReport } from "./learning.js";
|
package/dist/loops.d.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export declare const LOOP_CHARS = 160;
|
|
2
|
+
/** Open loops shown in the appendix, the reminder, and the resume message. */
|
|
3
|
+
export declare const LOOPS_SHOWN = 8;
|
|
4
|
+
export declare const LOOPS_CHARS = 600;
|
|
5
|
+
export type LoopAction = "add" | "done" | "drop" | "list";
|
|
6
|
+
export declare const LOOP_ACTIONS: readonly LoopAction[];
|
|
7
|
+
export interface Loop {
|
|
8
|
+
id: number;
|
|
9
|
+
/** Redacted, at most LOOP_CHARS characters. */
|
|
10
|
+
text: string;
|
|
11
|
+
/** The condition that makes it due, such as "after CI passes"; redacted. */
|
|
12
|
+
when?: string;
|
|
13
|
+
status: "open" | "done" | "dropped";
|
|
14
|
+
/** Why a dropped loop was dropped; redacted. */
|
|
15
|
+
reason?: string;
|
|
16
|
+
at: number;
|
|
17
|
+
closedAt?: number;
|
|
18
|
+
}
|
|
19
|
+
export interface LoopStore {
|
|
20
|
+
next: number;
|
|
21
|
+
loops: Loop[];
|
|
22
|
+
}
|
|
23
|
+
export declare function emptyLoopStore(): LoopStore;
|
|
24
|
+
/**
|
|
25
|
+
* One file per session, in a folder per project: the working directory's hash keeps another project's session with the
|
|
26
|
+
* same id apart, and the id keeps another session of this project apart.
|
|
27
|
+
*/
|
|
28
|
+
export declare function loopsPath(cwd: string, session: string): string;
|
|
29
|
+
/** A missing file is an empty store; an unreadable or malformed one throws, so a write never replaces loops it could not read. */
|
|
30
|
+
export declare function readLoops(path: string): Promise<LoopStore>;
|
|
31
|
+
/** Written whole to a temporary file and renamed, so a crash never leaves half a file. */
|
|
32
|
+
export declare function writeLoops(path: string, store: LoopStore): Promise<void>;
|
|
33
|
+
/** One change to the store: the reply for the agent, and the new store when it changed. */
|
|
34
|
+
export type LoopChange = (store: LoopStore) => {
|
|
35
|
+
reply: string;
|
|
36
|
+
store?: LoopStore;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Reads, applies, and writes one change under the file's queue, so two `warden_loops` calls in one process run in turn
|
|
40
|
+
* and cannot lose each other's update.
|
|
41
|
+
*/
|
|
42
|
+
export declare function updateLoops(path: string, change: LoopChange): Promise<string>;
|
|
43
|
+
export declare const openLoops: (store: LoopStore) => Loop[];
|
|
44
|
+
/**
|
|
45
|
+
* The open loops, oldest first, at most LOOPS_SHOWN lines and LOOPS_CHARS characters; whole lines only, with a count of
|
|
46
|
+
* the ones left out. Empty when none is open.
|
|
47
|
+
*/
|
|
48
|
+
export declare function formatOpenLoops(loops: readonly Loop[]): string;
|
|
49
|
+
/** The open list as it stands: a reminder already sent for the same list is not sent again. */
|
|
50
|
+
export declare function loopsFingerprint(loops: readonly Loop[]): string;
|
|
51
|
+
export interface LoopInput {
|
|
52
|
+
action?: unknown;
|
|
53
|
+
text?: unknown;
|
|
54
|
+
when?: unknown;
|
|
55
|
+
id?: unknown;
|
|
56
|
+
reason?: unknown;
|
|
57
|
+
}
|
|
58
|
+
/** Applies one `warden_loops` call. `store` is undefined when nothing changes. */
|
|
59
|
+
export declare function applyLoopAction(store: LoopStore, input: LoopInput, now: number): {
|
|
60
|
+
reply: string;
|
|
61
|
+
store?: LoopStore;
|
|
62
|
+
};
|
|
63
|
+
/** `/warden loops`: every loop of the session, open first, with how each closed. */
|
|
64
|
+
export declare function formatLoopsForUser(store: LoopStore): string;
|
package/dist/loops.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open loops: things the agent promised to do later in this session ("after CI passes, bump the version"). The agent
|
|
3
|
+
* adds and closes them with the `warden_loops` tool; warden keeps them in one small file per session under its data
|
|
4
|
+
* folder, so they survive compaction and resume, and shows the open ones in the compaction appendix, in one reminder at
|
|
5
|
+
* the end of a run, and in one message on resume. Code only, no Jev request. A loop never reaches another session or
|
|
6
|
+
* another project: the file is keyed by both.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { readFile } from "node:fs/promises";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
import { serialize, writeFileAtomic } from "./atomic.js";
|
|
12
|
+
import { userConfigPath } from "./config.js";
|
|
13
|
+
import { redact } from "./redact.js";
|
|
14
|
+
export const LOOP_CHARS = 160;
|
|
15
|
+
/** Open loops shown in the appendix, the reminder, and the resume message. */
|
|
16
|
+
export const LOOPS_SHOWN = 8;
|
|
17
|
+
export const LOOPS_CHARS = 600;
|
|
18
|
+
/** A session with this many open loops is tracking tasks, not promises; one more is refused until some close. */
|
|
19
|
+
const MAX_OPEN = 30;
|
|
20
|
+
export const LOOP_ACTIONS = ["add", "done", "drop", "list"];
|
|
21
|
+
export function emptyLoopStore() {
|
|
22
|
+
return { next: 1, loops: [] };
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* One file per session, in a folder per project: the working directory's hash keeps another project's session with the
|
|
26
|
+
* same id apart, and the id keeps another session of this project apart.
|
|
27
|
+
*/
|
|
28
|
+
export function loopsPath(cwd, session) {
|
|
29
|
+
const project = createHash("sha256").update(cwd).digest("hex").slice(0, 12);
|
|
30
|
+
return join(dirname(userConfigPath()), "loops", project, `${session.replace(/[^\w.-]/g, "_")}.json`);
|
|
31
|
+
}
|
|
32
|
+
const isLoop = (value) => {
|
|
33
|
+
const loop = value;
|
|
34
|
+
return typeof loop === "object" && loop !== null && typeof loop.id === "number" && typeof loop.text === "string" && typeof loop.at === "number"
|
|
35
|
+
&& (loop.status === "open" || loop.status === "done" || loop.status === "dropped");
|
|
36
|
+
};
|
|
37
|
+
/** A missing file is an empty store; an unreadable or malformed one throws, so a write never replaces loops it could not read. */
|
|
38
|
+
export async function readLoops(path) {
|
|
39
|
+
let text;
|
|
40
|
+
try {
|
|
41
|
+
text = await readFile(path, "utf8");
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (error.code === "ENOENT")
|
|
45
|
+
return emptyLoopStore();
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
const raw = JSON.parse(text);
|
|
49
|
+
if (typeof raw !== "object" || raw === null || !Array.isArray(raw.loops))
|
|
50
|
+
throw new Error("the loops file has no loops list");
|
|
51
|
+
const loops = raw.loops.filter(isLoop);
|
|
52
|
+
const next = typeof raw.next === "number" ? raw.next : Math.max(0, ...loops.map(loop => loop.id)) + 1;
|
|
53
|
+
return { next, loops };
|
|
54
|
+
}
|
|
55
|
+
/** Written whole to a temporary file and renamed, so a crash never leaves half a file. */
|
|
56
|
+
export async function writeLoops(path, store) {
|
|
57
|
+
await writeFileAtomic(path, `${JSON.stringify(store, null, 2)}\n`);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Reads, applies, and writes one change under the file's queue, so two `warden_loops` calls in one process run in turn
|
|
61
|
+
* and cannot lose each other's update.
|
|
62
|
+
*/
|
|
63
|
+
export async function updateLoops(path, change) {
|
|
64
|
+
return serialize(path, async () => {
|
|
65
|
+
const result = change(await readLoops(path));
|
|
66
|
+
if (result.store)
|
|
67
|
+
await writeLoops(path, result.store);
|
|
68
|
+
return result.reply;
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
export const openLoops = (store) => store.loops.filter(loop => loop.status === "open");
|
|
72
|
+
const oneLine = (text) => redact(text.replace(/\s+/g, " ").trim());
|
|
73
|
+
const loopLine = (loop) => `- #${loop.id} ${loop.text}${loop.when ? ` (when: ${loop.when})` : ""}`;
|
|
74
|
+
/**
|
|
75
|
+
* The open loops, oldest first, at most LOOPS_SHOWN lines and LOOPS_CHARS characters; whole lines only, with a count of
|
|
76
|
+
* the ones left out. Empty when none is open.
|
|
77
|
+
*/
|
|
78
|
+
export function formatOpenLoops(loops) {
|
|
79
|
+
const open = loops.filter(loop => loop.status === "open");
|
|
80
|
+
const lines = [];
|
|
81
|
+
let length = 0;
|
|
82
|
+
for (const loop of open.slice(0, LOOPS_SHOWN)) {
|
|
83
|
+
const line = loopLine(loop);
|
|
84
|
+
// Room for the count line of the ones left out.
|
|
85
|
+
if (length + line.length + 1 > LOOPS_CHARS - 40)
|
|
86
|
+
break;
|
|
87
|
+
lines.push(line);
|
|
88
|
+
length += line.length + 1;
|
|
89
|
+
}
|
|
90
|
+
if (lines.length < open.length)
|
|
91
|
+
lines.push(`- … and ${open.length - lines.length} more (warden_loops list)`);
|
|
92
|
+
return lines.join("\n");
|
|
93
|
+
}
|
|
94
|
+
/** The open list as it stands: a reminder already sent for the same list is not sent again. */
|
|
95
|
+
export function loopsFingerprint(loops) {
|
|
96
|
+
return loops.filter(loop => loop.status === "open").map(loop => `${loop.id}:${loop.text}:${loop.when ?? ""}`).join("\n");
|
|
97
|
+
}
|
|
98
|
+
/** Applies one `warden_loops` call. `store` is undefined when nothing changes. */
|
|
99
|
+
export function applyLoopAction(store, input, now) {
|
|
100
|
+
const action = typeof input.action === "string" ? input.action : "";
|
|
101
|
+
if (action === "list") {
|
|
102
|
+
const open = openLoops(store);
|
|
103
|
+
const closed = store.loops.length - open.length;
|
|
104
|
+
return { reply: open.length ? `Open loops:\n${formatOpenLoops(open)}${closed ? `\n(${closed} closed)` : ""}` : `No open loops${closed ? ` (${closed} closed)` : ""}.` };
|
|
105
|
+
}
|
|
106
|
+
if (action === "add") {
|
|
107
|
+
const text = typeof input.text === "string" ? oneLine(input.text) : "";
|
|
108
|
+
const when = typeof input.when === "string" ? oneLine(input.when) : "";
|
|
109
|
+
if (!text)
|
|
110
|
+
return { reply: "not added: give the loop's text" };
|
|
111
|
+
if (text.length > LOOP_CHARS || when.length > LOOP_CHARS)
|
|
112
|
+
return { reply: `not added: the text and the condition are at most ${LOOP_CHARS} characters each` };
|
|
113
|
+
if (openLoops(store).length >= MAX_OPEN)
|
|
114
|
+
return { reply: `not added: ${MAX_OPEN} loops are open; close the finished ones with done or drop first` };
|
|
115
|
+
const loop = { id: store.next, text, ...(when ? { when } : {}), status: "open", at: now };
|
|
116
|
+
return { reply: `added #${loop.id}: ${text}${when ? ` (when: ${when})` : ""}`, store: { next: store.next + 1, loops: [...store.loops, loop] } };
|
|
117
|
+
}
|
|
118
|
+
if (action === "done" || action === "drop") {
|
|
119
|
+
const id = typeof input.id === "number" ? input.id : typeof input.id === "string" ? Number(input.id.replace(/^#/, "")) : NaN;
|
|
120
|
+
const loop = store.loops.find(entry => entry.id === id);
|
|
121
|
+
if (!loop)
|
|
122
|
+
return { reply: `no loop #${Number.isFinite(id) ? id : "?"} in this session` };
|
|
123
|
+
if (loop.status !== "open")
|
|
124
|
+
return { reply: `#${loop.id} is already ${loop.status}` };
|
|
125
|
+
const reason = typeof input.reason === "string" ? oneLine(input.reason).slice(0, LOOP_CHARS) : "";
|
|
126
|
+
if (action === "drop" && !reason)
|
|
127
|
+
return { reply: `not dropped: say why #${loop.id} is no longer needed` };
|
|
128
|
+
const closed = { ...loop, status: action === "done" ? "done" : "dropped", ...(reason && action === "drop" ? { reason } : {}), closedAt: now };
|
|
129
|
+
return {
|
|
130
|
+
reply: `${action === "done" ? "done" : "dropped"} #${loop.id}: ${loop.text}`,
|
|
131
|
+
store: { ...store, loops: store.loops.map(entry => entry.id === loop.id ? closed : entry) },
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
return { reply: `unknown action; use one of ${LOOP_ACTIONS.join(", ")}` };
|
|
135
|
+
}
|
|
136
|
+
/** `/warden loops`: every loop of the session, open first, with how each closed. */
|
|
137
|
+
export function formatLoopsForUser(store) {
|
|
138
|
+
if (!store.loops.length)
|
|
139
|
+
return "No loops in this session. The agent adds one with warden_loops when it promises to do something later.";
|
|
140
|
+
const open = openLoops(store);
|
|
141
|
+
const closed = store.loops.filter(loop => loop.status !== "open");
|
|
142
|
+
return [
|
|
143
|
+
open.length ? `Open loops (${open.length}):` : "No open loops.",
|
|
144
|
+
...open.map(loopLine),
|
|
145
|
+
...(closed.length ? [`Closed (${closed.length}):`, ...closed.map(loop => `${loopLine(loop)}: ${loop.status}${loop.reason ? `, ${loop.reason}` : ""}`)] : []),
|
|
146
|
+
].join("\n");
|
|
147
|
+
}
|