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/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 joined = out.join("\n");
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 parenthesis before `rm` is allowed so a quoted or substituted command is read; data quotes were blanked before this runs.
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: no scratch.
1127
- const scratch = PRIVILEGED.test(command) || !scratchPlatform(options?.platform) ? undefined : options?.scratch;
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/git commit in this command are text that is written, printed, searched, or recorded, not executed";
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";
@@ -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
+ }