@sigrun/warden 0.0.0-stage → 0.1.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/live.d.ts ADDED
@@ -0,0 +1,31 @@
1
+ import type { Judge } from "pi-typesafe";
2
+ import type { Classification } from "./classify.js";
3
+ import type { Cooldown } from "./judge.js";
4
+ import type { RouteInput } from "./route.js";
5
+ export interface LiveInput extends RouteInput {
6
+ /** The decisions judge; `undefined` when disabled or in cooldown. */
7
+ judge: Judge | undefined;
8
+ cooldown: Cooldown;
9
+ }
10
+ export type LiveOutcome = {
11
+ action: "run";
12
+ } | {
13
+ action: "steer";
14
+ text: string;
15
+ } | {
16
+ action: "ask-person";
17
+ };
18
+ /**
19
+ * The bash-unknown judgment (warden spec §3.2): the ask gate decides
20
+ * whether any answer could change the outcome; the irreversible
21
+ * question classifies the rest. Returns the resolved classification —
22
+ * "allow" (with the log record written for the 0.5–0.9 band) or
23
+ * "destructive" (the flow continues as destructive).
24
+ */
25
+ export declare function judgeBashUnknown(input: LiveInput): Promise<Classification>;
26
+ /**
27
+ * The live flow for a destructive call (spec §3.2 steps 1–5). The
28
+ * caller guarantees a live presence; bash-unknown calls pass through
29
+ * `judgeBashUnknown` first.
30
+ */
31
+ export declare function evaluateLive(input: LiveInput): Promise<LiveOutcome>;
package/dist/live.js ADDED
@@ -0,0 +1,311 @@
1
+ /**
2
+ * The live-path decision flow (warden spec §3.2, Task 12): the
3
+ * destructive-call judgment — clear ask → run, vague ask → steer,
4
+ * approval on reply, ignored steer → ask the person — and the
5
+ * bash-unknown irreversible judgment. Everything here returns an
6
+ * outcome; `route.ts` maps it onto block text / the interaction hold.
7
+ */
8
+ import { APPROVAL_THRESHOLD, actionAskGate, askApproval, describeAction, questions, redact, stripDataText, } from "pi-warden";
9
+ import { clearAskQuestion } from "./questions.js";
10
+ import { callFingerprint } from "./state.js";
11
+ /** The approval request's own deadline (pi-warden's default shape). */
12
+ const APPROVAL_TIMEOUT_MS = 30_000;
13
+ /** The steer wording is unconditional: a hook can't see the tool list. */
14
+ function vagueSteerText(tool) {
15
+ return `Your request didn't name these targets. Say why you picked them, or confirm with the person before running ${tool} (with ask_user_question if you have it).`;
16
+ }
17
+ /** The non-approval steer: the reply did not cover this call. */
18
+ function notApprovedSteerText(tool) {
19
+ return `The person's reply doesn't approve ${tool} on these targets; ask again or drop it.`;
20
+ }
21
+ /** The visible text of one reply entry (person message or ask tool result). */
22
+ function replyText(entry) {
23
+ const parts = [];
24
+ for (const message of entry.model ?? []) {
25
+ if (message.role !== "user" && message.role !== "toolResult")
26
+ continue;
27
+ const content = message.content;
28
+ if (typeof content === "string") {
29
+ parts.push(content);
30
+ }
31
+ else if (Array.isArray(content)) {
32
+ for (const block of content) {
33
+ if (typeof block === "object" &&
34
+ block !== null &&
35
+ block.type === "text" &&
36
+ typeof block.text === "string") {
37
+ parts.push(block.text);
38
+ }
39
+ }
40
+ }
41
+ }
42
+ return parts.join("\n");
43
+ }
44
+ /**
45
+ * A reply entry is a person's input entry (`pi.user`, this run or a
46
+ * later one — a later person's message IS this run's start, so the run
47
+ * window covers both) or a tool result of a tool named
48
+ * `ask_user_question`. Only entries AFTER the block (the blocked
49
+ * record's `atEntry`) count.
50
+ */
51
+ function findReply(entries, atEntry, released, fingerprint) {
52
+ const after = atEntry === undefined ? -1 : Number(atEntry);
53
+ for (const entry of entries) {
54
+ const id = Number(entry.id);
55
+ if (Number.isFinite(after) && id <= after)
56
+ continue;
57
+ // A reply already consumed by one release never releases another call.
58
+ if (released.some((rel) => rel.fingerprint === fingerprint && rel.replyEntry === String(entry.id))) {
59
+ continue;
60
+ }
61
+ if (entry.kind === "pi.user") {
62
+ return { id: String(entry.id), text: replyText(entry) };
63
+ }
64
+ if (entry.kind === "pi.tool-result") {
65
+ const message = (entry.model ?? [])[0];
66
+ if (message?.toolName === "ask_user_question") {
67
+ return { id: String(entry.id), text: replyText(entry) };
68
+ }
69
+ }
70
+ }
71
+ return undefined;
72
+ }
73
+ /**
74
+ * The bash-unknown judgment (warden spec §3.2): the ask gate decides
75
+ * whether any answer could change the outcome; the irreversible
76
+ * question classifies the rest. Returns the resolved classification —
77
+ * "allow" (with the log record written for the 0.5–0.9 band) or
78
+ * "destructive" (the flow continues as destructive).
79
+ */
80
+ export async function judgeBashUnknown(input) {
81
+ const { classification } = input;
82
+ if (classification.kind !== "bash-unknown")
83
+ return classification;
84
+ const command = classification.command;
85
+ const gate = actionAskGate(input.call.name, input.call.arguments, stripDataText(command).text, "/");
86
+ if (!gate.ask)
87
+ return { kind: "allow", reason: gate.why };
88
+ const destructive = {
89
+ kind: "destructive",
90
+ reasons: [
91
+ `unrecognized shell command: ${command}`,
92
+ "the decision model judged it irreversible",
93
+ ],
94
+ };
95
+ // No judge: live allows (pi-warden's offline behaviour — the floor
96
+ // caught the patterns); a job fails closed into the destructive path.
97
+ if (input.judge === undefined) {
98
+ if (input.presence.kind === "job")
99
+ return destructive;
100
+ return { kind: "allow", reason: "no decision model; judged offline" };
101
+ }
102
+ let score;
103
+ try {
104
+ const task = input.run.asked.trim();
105
+ const result = await input.judge.evaluate({
106
+ state: {
107
+ task: task === "" ? "(no user request recorded in this session)" : redact(task),
108
+ action: {
109
+ tool: input.call.name,
110
+ command: stripDataText(command).text,
111
+ },
112
+ context: [],
113
+ floor_hits: [],
114
+ },
115
+ questions: { irreversible: questions.irreversible },
116
+ });
117
+ const answer = result.answers.irreversible;
118
+ if (answer?.type === "noul")
119
+ score = answer.noul;
120
+ }
121
+ catch {
122
+ score = undefined;
123
+ }
124
+ if (score === undefined) {
125
+ // A judge failure: live asks the person via the destructive flow;
126
+ // a job fails closed.
127
+ if (input.presence.kind === "job")
128
+ return destructive;
129
+ return {
130
+ kind: "destructive",
131
+ reasons: [
132
+ `unrecognized shell command: ${command}`,
133
+ "the irreversibility judgment failed",
134
+ ],
135
+ };
136
+ }
137
+ if (score >= input.settings.thresholds.irreversible)
138
+ return destructive;
139
+ if (score >= 0.5) {
140
+ await input.log.append({
141
+ conversationId: input.conversationId,
142
+ tool: input.call.name,
143
+ call: redact(JSON.stringify(input.call.arguments)),
144
+ ...("owner" in input.presence && input.presence.owner !== undefined
145
+ ? { owner: input.presence.owner }
146
+ : {}),
147
+ at: new Date().toISOString(),
148
+ action: "allow-judged",
149
+ scores: { irreversible: score },
150
+ });
151
+ }
152
+ return { kind: "allow", reason: "the irreversibility judgment is below the hold band" };
153
+ }
154
+ /**
155
+ * The live flow for a destructive call (spec §3.2 steps 1–5). The
156
+ * caller guarantees a live presence; bash-unknown calls pass through
157
+ * `judgeBashUnknown` first.
158
+ */
159
+ export async function evaluateLive(input) {
160
+ let classification = input.classification;
161
+ if (classification.kind === "bash-unknown") {
162
+ const resolved = await judgeBashUnknown(input);
163
+ if (resolved.kind === "allow")
164
+ return { action: "run" };
165
+ classification = resolved;
166
+ }
167
+ if (classification.kind !== "destructive")
168
+ return { action: "run" };
169
+ const fp = callFingerprint(input.call);
170
+ const reasons = classification.reasons;
171
+ const tool = input.call.name;
172
+ // Run boundary FIRST: the taint gate below consumes state.tainted, so
173
+ // the reset must land before any read of it — otherwise the first
174
+ // destructive call of a new run steers on the PREVIOUS run's taint.
175
+ // (Matches the job path, which resets at the top of jobRoute.)
176
+ const state = await input.store.update(input.conversationId, (s) => {
177
+ resetRun(s, input);
178
+ return s;
179
+ });
180
+ // Step 1–2: this call was held before (the fingerprint is in
181
+ // state.blocked). Read the record first; the reply search and the
182
+ // release's store.update follow.
183
+ const record = state.blocked.find((entry) => entry.fingerprint === fp);
184
+ if (record !== undefined) {
185
+ const reply = findReply(input.run.entries, record.atEntry, state.released, fp);
186
+ if (reply === undefined) {
187
+ // Step 2: the ignored steer — the warden asks the person itself.
188
+ return { action: "ask-person" };
189
+ }
190
+ // Step 1: approval on reply.
191
+ const answer = await askApproval(input.judge ?? undefinedJudge, describeAction(tool, input.call.arguments, "/"), redact(reply.text).slice(-3000), redact(vagueSteerText(tool)).slice(-3000), reasons, { timeoutMs: APPROVAL_TIMEOUT_MS });
192
+ if (!answer.ok)
193
+ return { action: "ask-person" };
194
+ if (answer.approved >= APPROVAL_THRESHOLD &&
195
+ answer.pointsAtAction >= APPROVAL_THRESHOLD) {
196
+ await input.store.update(input.conversationId, (s) => {
197
+ s.blocked = s.blocked.filter((entry) => entry.fingerprint !== fp);
198
+ s.released.push({ fingerprint: fp, replyEntry: reply.id });
199
+ });
200
+ await input.log.append({
201
+ conversationId: input.conversationId,
202
+ tool,
203
+ call: redact(JSON.stringify(input.call.arguments)),
204
+ ...("owner" in input.presence && input.presence.owner !== undefined
205
+ ? { owner: input.presence.owner }
206
+ : {}),
207
+ at: new Date().toISOString(),
208
+ action: "release",
209
+ outcome: "approved",
210
+ });
211
+ return { action: "run" };
212
+ }
213
+ return await steer(input, fp, notApprovedSteerText(tool));
214
+ }
215
+ // Step 4: a judge failure or an undefined judge asks the person; a
216
+ // tainted run (Task 15 sets the flag) skips the clear-ask judgment and
217
+ // steers.
218
+ if (state.tainted === true) {
219
+ return await steer(input, fp, vagueSteerText(tool));
220
+ }
221
+ if (input.judge === undefined)
222
+ return { action: "ask-person" };
223
+ // Step 3: the clear-ask judgment.
224
+ let clear;
225
+ try {
226
+ const result = await input.judge.evaluate({
227
+ state: {
228
+ request: redact(input.run.asked),
229
+ call: redact(JSON.stringify(input.call.arguments)),
230
+ },
231
+ questions: { clear_ask: clearAskQuestion },
232
+ });
233
+ const answer = result.answers.clear_ask;
234
+ if (answer?.type === "noul")
235
+ clear = answer.noul;
236
+ }
237
+ catch {
238
+ clear = undefined;
239
+ }
240
+ if (clear === undefined)
241
+ return { action: "ask-person" };
242
+ if (clear >= input.settings.thresholds.clearAsk) {
243
+ await input.log.append({
244
+ conversationId: input.conversationId,
245
+ tool,
246
+ call: redact(JSON.stringify(input.call.arguments)),
247
+ ...("owner" in input.presence && input.presence.owner !== undefined
248
+ ? { owner: input.presence.owner }
249
+ : {}),
250
+ at: new Date().toISOString(),
251
+ action: "allow-judged",
252
+ scores: { clear_ask: clear },
253
+ });
254
+ return { action: "run" };
255
+ }
256
+ return await steer(input, fp, vagueSteerText(tool));
257
+ }
258
+ /**
259
+ * Record the hold (fingerprint + the entry the block sits after) and
260
+ * count the steer against the run's steer budget. Over budget the warden
261
+ * asks the person itself ("the rest to the log").
262
+ */
263
+ async function steer(input, fp, text) {
264
+ const maxEntry = input.run.entries.reduce((max, entry) => Math.max(max, Number(entry.id)), input.run.runStart === undefined ? -1 : Number(input.run.runStart));
265
+ const budgeted = await input.store.update(input.conversationId, (s) => {
266
+ resetRun(s, input);
267
+ const record = s.blocked.find((entry) => entry.fingerprint === fp);
268
+ if (record === undefined) {
269
+ s.blocked.push({
270
+ fingerprint: fp,
271
+ at: new Date().toISOString(),
272
+ atEntry: maxEntry >= 0 ? String(maxEntry) : undefined,
273
+ status: "pending",
274
+ });
275
+ }
276
+ if (s.steers >= input.settings.steerBudget)
277
+ return false;
278
+ s.steers += 1;
279
+ return true;
280
+ });
281
+ await input.log.append({
282
+ conversationId: input.conversationId,
283
+ tool: input.call.name,
284
+ call: redact(JSON.stringify(input.call.arguments)),
285
+ ...("owner" in input.presence && input.presence.owner !== undefined
286
+ ? { owner: input.presence.owner }
287
+ : {}),
288
+ at: new Date().toISOString(),
289
+ action: "steer",
290
+ });
291
+ if (!budgeted)
292
+ return { action: "ask-person" };
293
+ return { action: "steer", text };
294
+ }
295
+ /** A new run resets the run-scoped fields (mirrors routeOffline). */
296
+ function resetRun(state, input) {
297
+ if (state.runStart !== input.run.runStart) {
298
+ state.runStart = input.run.runStart;
299
+ state.steers = 0;
300
+ state.tainted = undefined;
301
+ }
302
+ }
303
+ /**
304
+ * The approval path always has a real judge in practice (route.ts only
305
+ * calls evaluateLive's destructive flow with one), but the typing keeps
306
+ * askApproval total: an absent judge answers ok:false, which maps to
307
+ * ask-person.
308
+ */
309
+ const undefinedJudge = {
310
+ evaluate: () => Promise.reject(new Error("not_configured: no decision judge")),
311
+ };
package/dist/log.d.ts ADDED
@@ -0,0 +1,28 @@
1
+ export interface HoldRecord {
2
+ /** ISO timestamp the record was stamped. */
3
+ at: string;
4
+ conversationId: number;
5
+ owner?: string;
6
+ jobId?: string;
7
+ tool: string;
8
+ /** Redacted JSON of the tool call. */
9
+ call: string;
10
+ action: "steer" | "hold" | "block" | "escalate" | "release" | "allow-judged" | "warn";
11
+ scores?: Record<string, number>;
12
+ outcome?: "approved" | "declined" | "replanned" | "escalated" | "abandoned";
13
+ }
14
+ export declare class HoldLog {
15
+ #private;
16
+ constructor(path: string, now: () => Date);
17
+ append(record: HoldRecord): Promise<void>;
18
+ /** The newest `n` records, oldest-first within the window. */
19
+ recent(n: number): Promise<HoldRecord[]>;
20
+ /**
21
+ * Drop records older than their action's retention: hold/block/escalate
22
+ * keep for `retention.holds` days, everything else for `retention.calls`.
23
+ */
24
+ prune(retention: {
25
+ calls: number;
26
+ holds: number;
27
+ }): Promise<void>;
28
+ }
package/dist/log.js ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * The warden hold log: one JSONL file at `paths.state/holds.jsonl`.
3
+ * `prune` keeps hold/block/escalate records for `holds` days and every
4
+ * other action for `calls` days.
5
+ */
6
+ import { mkdir, open, readFile, rename, writeFile } from "node:fs/promises";
7
+ import { dirname } from "node:path";
8
+ /** Actions pruned on the longer `holds` retention; the rest ride `calls`. */
9
+ const LONG_RETENTION_ACTIONS = new Set([
10
+ "hold",
11
+ "block",
12
+ "escalate",
13
+ ]);
14
+ export class HoldLog {
15
+ #path;
16
+ #now;
17
+ constructor(path, now) {
18
+ this.#path = path;
19
+ this.#now = now;
20
+ }
21
+ async #read() {
22
+ let text;
23
+ try {
24
+ text = await readFile(this.#path, "utf8");
25
+ }
26
+ catch (error) {
27
+ if (error.code === "ENOENT")
28
+ return [];
29
+ throw error;
30
+ }
31
+ const records = [];
32
+ const lines = text.split("\n");
33
+ for (let index = 0; index < lines.length; index += 1) {
34
+ const trimmed = lines[index].trim();
35
+ if (trimmed === "")
36
+ continue;
37
+ try {
38
+ records.push(JSON.parse(trimmed));
39
+ }
40
+ catch {
41
+ // A torn trailing line after a crash must not brick the log.
42
+ console.warn(`holds log: skipping unparseable line ${index + 1}`);
43
+ }
44
+ }
45
+ return records;
46
+ }
47
+ async append(record) {
48
+ await mkdir(dirname(this.#path), { recursive: true });
49
+ // A crash can leave a torn trailing line without its newline; prefix
50
+ // one so the new record does not merge into the torn fragment.
51
+ const handle = await open(this.#path, "a+");
52
+ try {
53
+ let prefix = "";
54
+ const { size } = await handle.stat();
55
+ if (size > 0) {
56
+ const last = Buffer.alloc(1);
57
+ await handle.read(last, 0, 1, size - 1);
58
+ if (last[0] !== 0x0a)
59
+ prefix = "\n";
60
+ }
61
+ await handle.appendFile(`${prefix}${JSON.stringify(record)}\n`, "utf8");
62
+ }
63
+ finally {
64
+ await handle.close();
65
+ }
66
+ }
67
+ /** The newest `n` records, oldest-first within the window. */
68
+ async recent(n) {
69
+ if (n <= 0)
70
+ return [];
71
+ const records = await this.#read();
72
+ return records.slice(-n);
73
+ }
74
+ /**
75
+ * Drop records older than their action's retention: hold/block/escalate
76
+ * keep for `retention.holds` days, everything else for `retention.calls`.
77
+ */
78
+ async prune(retention) {
79
+ const nowMs = this.#now().getTime();
80
+ const callsCutoff = nowMs - retention.calls * 24 * 60 * 60 * 1000;
81
+ const holdsCutoff = nowMs - retention.holds * 24 * 60 * 60 * 1000;
82
+ const records = await this.#read();
83
+ const kept = records.filter((record) => {
84
+ const at = Date.parse(record.at);
85
+ if (!Number.isFinite(at))
86
+ return true;
87
+ const cutoff = LONG_RETENTION_ACTIONS.has(record.action)
88
+ ? holdsCutoff
89
+ : callsCutoff;
90
+ return at >= cutoff;
91
+ });
92
+ if (kept.length !== records.length)
93
+ await this.#write(kept);
94
+ }
95
+ /**
96
+ * Rewrite the whole log (prune only). Written to a temp file and
97
+ * renamed over the log. A concurrent `append` may still lose the
98
+ * rename race; this is accepted silently because `append` reopens
99
+ * by path, so only a same-instant append can be lost, and prune is
100
+ * boot-scoped by design (Task 11).
101
+ */
102
+ async #write(records) {
103
+ await mkdir(dirname(this.#path), { recursive: true });
104
+ const text = records.map((record) => JSON.stringify(record)).join("\n");
105
+ const tmp = `${this.#path}.tmp`;
106
+ await writeFile(tmp, text === "" ? "" : `${text}\n`, "utf8");
107
+ await rename(tmp, this.#path);
108
+ }
109
+ }
@@ -0,0 +1,7 @@
1
+ import type { NoulQuestion } from "pi-typesafe";
2
+ /**
3
+ * Over state `{request, call}`: "Did the person ask for this action on
4
+ * exactly the targets in `call`?" A yes runs the call with no hold; a
5
+ * no steers the agent to justify or confirm first.
6
+ */
7
+ export declare const clearAskQuestion: NoulQuestion;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The warden's own decision questions (Task 12). pi-warden's question
3
+ * set lives in pi-warden's `questions` export; these are the ones the
4
+ * sigrun live path adds. `clearAskQuestion` is asked over the state
5
+ * `{request, call}` where `request` is the person's most recent request
6
+ * (redacted, last 3000 chars) and `call` is the redacted JSON of the
7
+ * tool arguments.
8
+ */
9
+ import { noul } from "pi-typesafe";
10
+ /**
11
+ * Over state `{request, call}`: "Did the person ask for this action on
12
+ * exactly the targets in `call`?" A yes runs the call with no hold; a
13
+ * no steers the agent to justify or confirm first.
14
+ */
15
+ export const clearAskQuestion = noul("Did the person ask for this action on exactly the targets in `call`? `request` is the person's most recent message; `call` is the tool call about to run. The call must do exactly what the request names — the same action on the same targets. A request that names different targets, a broader or narrower set, or only a vague goal without these targets does not count.", {
16
+ true: "Yes: the request explicitly asks for this action on exactly the targets in `call` (the targets named in the request match the call's targets).",
17
+ false: "No: the request names different targets, only gestures at a goal without naming these targets, or the call goes beyond or short of what was asked.",
18
+ });
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Routing (warden spec §3.2): deny blocks in every presence; internal
3
+ * blocks curators outright; live judges then holds on an interaction
4
+ * question; job checks grants first, then clearances — a cleared call
5
+ * runs only after the per-target card check, everything else blocks +
6
+ * escalates with one-shot grants.
7
+ */
8
+ import type { Context } from "@earendil-works/chord";
9
+ import type { ToolHookCall } from "@earendil-works/pi-durable";
10
+ import type { SigrunHost } from "@sigrun/sigrun/extension";
11
+ import type { ConversationId } from "@earendil-works/pi-durable";
12
+ import type { Classification } from "./classify.js";
13
+ import type { Presence, RunInfo } from "./context.js";
14
+ import type { StateStore } from "./state.js";
15
+ import type { HoldLog } from "./log.js";
16
+ import type { WardenSettings } from "./settings.js";
17
+ import type { Judge } from "pi-typesafe";
18
+ import { Cooldown } from "./judge.js";
19
+ export interface RouteInput {
20
+ host: SigrunHost;
21
+ call: ToolHookCall;
22
+ conversationId: ConversationId;
23
+ classification: Classification;
24
+ presence: Presence;
25
+ run: RunInfo;
26
+ settings: WardenSettings;
27
+ store: StateStore;
28
+ log: HoldLog;
29
+ /** The hook's own chord context (the interaction poll cancels with it). */
30
+ context: Context;
31
+ /**
32
+ * The decisions judge (Task 12), set by the factory; when present the
33
+ * live path judges destructive calls. `routeOffline` ignores it.
34
+ */
35
+ judge?: Judge;
36
+ cooldown?: Cooldown;
37
+ }
38
+ /**
39
+ * Route one classified call. Returns `{block}` to block, undefined to
40
+ * allow. Nested calls (`call.parent` set — codemode/executeTool) route
41
+ * EXACTLY like model-issued calls: no special-casing.
42
+ */
43
+ export declare function routeOffline(input: RouteInput): Promise<{
44
+ block?: string;
45
+ } | undefined>;
46
+ export interface AfterGrantContext {
47
+ host: SigrunHost;
48
+ call: ToolHookCall;
49
+ conversationId: ConversationId;
50
+ result: {
51
+ isError?: boolean;
52
+ };
53
+ store: StateStore;
54
+ }
55
+ /**
56
+ * The Task-11 slice of afterTool: resolve the grant this call's
57
+ * reservation names and drop the reservation. The planted-instructions /
58
+ * masking / stuck guards are Task 15 and are omitted here entirely.
59
+ */
60
+ export declare function resolveReservedGrant(input: AfterGrantContext): Promise<void>;
61
+ /** Test hook: reset the once-per-process prune guard. */
62
+ export declare function resetPruneGuardForTests(): void;
63
+ /** Kick `log.prune` exactly once per process; failures go to console.warn. */
64
+ export declare function startPruneOnce(log: HoldLog, retentionDays: WardenSettings["retentionDays"]): void;