@lchase/kiln 0.8.0 → 0.10.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/events.js ADDED
@@ -0,0 +1,393 @@
1
+ import { randomBytes, randomUUID } from "node:crypto";
2
+ import { closeSync, existsSync, linkSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync, writeSync, } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { z } from "zod";
5
+ import { redactDeep } from "./redact.js";
6
+ /**
7
+ * Append-only event log at `<kilnDir>/events.jsonl` (`~/.kiln/events.jsonl`
8
+ * when that directory is the kiln home). One module writes it. One reader
9
+ * parses it. A write failure is reported and does not escape to the caller.
10
+ *
11
+ * `run_id` and `step` are null when the moment has none. `job_id` is null
12
+ * for `config.reloaded`, `kiln.paused`, `kiln.resumed`, and `budget.blocked`,
13
+ * which are not a job.
14
+ */
15
+ export const EVENT_NAMES = [
16
+ "job.added",
17
+ "job.retried",
18
+ "run.started",
19
+ "phase.started",
20
+ "phase.finished",
21
+ "gate.opened",
22
+ "gate.resolved",
23
+ "run.failed",
24
+ "run.finished",
25
+ "pr.opened",
26
+ "pr.feedback",
27
+ "worktree.cleaned",
28
+ "worktree.kept",
29
+ "config.reloaded",
30
+ "needs-human",
31
+ "kiln.paused",
32
+ "kiln.resumed",
33
+ "budget.blocked",
34
+ ];
35
+ const ISO_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
36
+ const once = z.string().min(1).optional();
37
+ const JobAdded = z.object({
38
+ repo: z.string().min(1),
39
+ workflow: z.string().min(1),
40
+ title: z.string().min(1),
41
+ source_kind: z.string().min(1).optional(),
42
+ once,
43
+ });
44
+ const JobRetried = z.object({
45
+ by: z.literal("human"),
46
+ repo: z.string().min(1),
47
+ once,
48
+ });
49
+ const RunStarted = z.object({
50
+ kiln_version: z.string().min(1),
51
+ yak_version: z.string().min(1),
52
+ workflow: z.string().min(1),
53
+ prompts_hash: z.string().min(1),
54
+ repo: z.string().min(1),
55
+ once,
56
+ });
57
+ const PhaseStarted = z.object({
58
+ iteration: z.number().int().nonnegative().optional(),
59
+ once,
60
+ });
61
+ const PhaseFinished = z.object({
62
+ outcome: z.enum(["completed", "failed"]),
63
+ iteration: z.number().int().nonnegative().optional(),
64
+ cached: z.boolean().optional(),
65
+ skipped: z.boolean().optional(),
66
+ reason: z.string().min(1).optional(),
67
+ detail: z.string().min(1).optional(),
68
+ verdict: z.string().min(1).optional(),
69
+ test_command: z.string().min(1).optional(),
70
+ exit_code: z.number().int().optional(),
71
+ once,
72
+ });
73
+ const GateOpened = z.object({
74
+ prompt: z.string().min(1).optional(),
75
+ note: z.string().min(1).optional(),
76
+ edit: z.string().min(1).optional(),
77
+ once,
78
+ });
79
+ const GateResolved = z.object({
80
+ decision: z.string().min(1),
81
+ by: z.string().min(1),
82
+ repo: z.string().min(1).optional(),
83
+ note: z.string().min(1).optional(),
84
+ edit: z.string().min(1).optional(),
85
+ once,
86
+ });
87
+ const costFields = {
88
+ /** Sum of yak `budget.consumed` tokens. Null when the journal did not report any. */
89
+ tokens: z.number().nonnegative().nullable().optional(),
90
+ /** Sum of reported dollars. Null when the journal did not report any. Never invented. */
91
+ usd: z.number().nonnegative().nullable().optional(),
92
+ };
93
+ const RunFailed = z.object({
94
+ reason: z.string().min(1).optional(),
95
+ detail: z.string().min(1).optional(),
96
+ status: z.string().min(1).optional(),
97
+ class: z.enum(["transient", "deterministic", "budget"]).optional(),
98
+ fingerprint: z.string().min(1).optional(),
99
+ ...costFields,
100
+ once,
101
+ });
102
+ const RunFinished = z.object({
103
+ status: z.literal("ok"),
104
+ ...costFields,
105
+ once,
106
+ });
107
+ const PrOpened = z.object({
108
+ number: z.number().int().positive(),
109
+ url: z.string().min(1),
110
+ branch: z.string().min(1),
111
+ base: z.string().min(1),
112
+ once,
113
+ });
114
+ const PrFeedback = z.object({
115
+ round: z.number().int().positive(),
116
+ trigger: z.enum(["ci", "review"]),
117
+ outcome: z.enum(["fixed", "pushed"]).optional(),
118
+ items: z.number().int().nonnegative().optional(),
119
+ once,
120
+ });
121
+ const Worktree = z.object({ once });
122
+ const ConfigReloaded = z.object({ once });
123
+ const NeedsHuman = z.object({
124
+ reason: z.string().min(1),
125
+ once,
126
+ });
127
+ const FactoryToggle = z.object({ once });
128
+ const BudgetBlocked = z.object({
129
+ day: z.string().min(1),
130
+ usd: z.number().nonnegative(),
131
+ cap: z.number().nonnegative(),
132
+ once,
133
+ });
134
+ const envelope = {
135
+ id: z.string().min(1),
136
+ ts: z.string().regex(ISO_UTC, "ts must be ISO-8601 UTC"),
137
+ run_id: z.string().min(1).nullable(),
138
+ step: z.string().min(1).nullable(),
139
+ };
140
+ export const EventSchema = z.discriminatedUnion("event", [
141
+ z.object({ ...envelope, event: z.literal("job.added"), job_id: z.string().min(1), properties: JobAdded }),
142
+ z.object({ ...envelope, event: z.literal("job.retried"), job_id: z.string().min(1), properties: JobRetried }),
143
+ z.object({ ...envelope, event: z.literal("run.started"), job_id: z.string().min(1), properties: RunStarted }),
144
+ z.object({ ...envelope, event: z.literal("phase.started"), job_id: z.string().min(1), properties: PhaseStarted }),
145
+ z.object({ ...envelope, event: z.literal("phase.finished"), job_id: z.string().min(1), properties: PhaseFinished }),
146
+ z.object({ ...envelope, event: z.literal("gate.opened"), job_id: z.string().min(1), properties: GateOpened }),
147
+ z.object({ ...envelope, event: z.literal("gate.resolved"), job_id: z.string().min(1), properties: GateResolved }),
148
+ z.object({ ...envelope, event: z.literal("run.failed"), job_id: z.string().min(1), properties: RunFailed }),
149
+ z.object({ ...envelope, event: z.literal("run.finished"), job_id: z.string().min(1), properties: RunFinished }),
150
+ z.object({ ...envelope, event: z.literal("pr.opened"), job_id: z.string().min(1), properties: PrOpened }),
151
+ z.object({ ...envelope, event: z.literal("pr.feedback"), job_id: z.string().min(1), properties: PrFeedback }),
152
+ z.object({ ...envelope, event: z.literal("worktree.cleaned"), job_id: z.string().min(1), properties: Worktree }),
153
+ z.object({ ...envelope, event: z.literal("worktree.kept"), job_id: z.string().min(1), properties: Worktree }),
154
+ z.object({
155
+ ...envelope,
156
+ event: z.literal("config.reloaded"),
157
+ job_id: z.string().min(1).nullable(),
158
+ properties: ConfigReloaded,
159
+ }),
160
+ z.object({ ...envelope, event: z.literal("needs-human"), job_id: z.string().min(1), properties: NeedsHuman }),
161
+ z.object({
162
+ ...envelope,
163
+ event: z.literal("kiln.paused"),
164
+ job_id: z.string().min(1).nullable(),
165
+ properties: FactoryToggle,
166
+ }),
167
+ z.object({
168
+ ...envelope,
169
+ event: z.literal("kiln.resumed"),
170
+ job_id: z.string().min(1).nullable(),
171
+ properties: FactoryToggle,
172
+ }),
173
+ z.object({
174
+ ...envelope,
175
+ event: z.literal("budget.blocked"),
176
+ job_id: z.string().min(1).nullable(),
177
+ properties: BudgetBlocked,
178
+ }),
179
+ ]);
180
+ const LOCK_WAIT_MS = 5_000;
181
+ const pauseBuf = new Int32Array(new SharedArrayBuffer(4));
182
+ export function eventsPath(kilnDir) {
183
+ return join(kilnDir, "events.jsonl");
184
+ }
185
+ /**
186
+ * Append one JSON line. Concurrent callers in this process take a file lock
187
+ * and each lock holder performs one `write` of the full line, so lines do
188
+ * not interleave. A failure is printed and swallowed.
189
+ *
190
+ * `properties.once` is an idempotency key. A later append with the same
191
+ * job, run, event, step, and `once` is a no-op. Moments that should repeat
192
+ * (a second manual retry, a second config reload) omit it.
193
+ */
194
+ export function appendEvent(kilnDir, draft) {
195
+ let line;
196
+ let event;
197
+ try {
198
+ event = buildEvent(draft);
199
+ line = `${JSON.stringify(event)}\n`;
200
+ }
201
+ catch (error) {
202
+ report(error);
203
+ return;
204
+ }
205
+ try {
206
+ withEventsLock(kilnDir, () => {
207
+ if (alreadyRecorded(kilnDir, event))
208
+ return;
209
+ writeLine(eventsPath(kilnDir), line);
210
+ });
211
+ }
212
+ catch (error) {
213
+ report(error);
214
+ }
215
+ }
216
+ /** Parse the log. A blank line is ignored. Any other bad line is skipped. */
217
+ export function readEvents(kilnDir, options = {}) {
218
+ const warn = options.warn ?? ((message) => console.error(message));
219
+ const path = eventsPath(kilnDir);
220
+ if (!existsSync(path))
221
+ return [];
222
+ let raw;
223
+ try {
224
+ raw = readFileSync(path, "utf8");
225
+ }
226
+ catch (error) {
227
+ warn(`kiln: event log unreadable: ${error instanceof Error ? error.message : String(error)}`);
228
+ return [];
229
+ }
230
+ const events = [];
231
+ const lines = raw.split("\n");
232
+ for (let index = 0; index < lines.length; index += 1) {
233
+ const line = lines[index];
234
+ if (line === undefined || line.trim() === "")
235
+ continue;
236
+ const parsed = parseLine(line);
237
+ if (!parsed) {
238
+ warn(`kiln: skipped event log line ${index + 1}`);
239
+ continue;
240
+ }
241
+ events.push(parsed);
242
+ }
243
+ return events;
244
+ }
245
+ export function eventsForJob(kilnDir, jobId, options) {
246
+ return readEvents(kilnDir, options).filter((event) => event.job_id === jobId);
247
+ }
248
+ function buildEvent(draft) {
249
+ const properties = redactDeep(draft.properties);
250
+ const candidate = {
251
+ id: draft.id ?? randomUUID(),
252
+ ts: draft.ts ?? new Date().toISOString(),
253
+ job_id: draft.job_id,
254
+ run_id: draft.run_id ?? null,
255
+ event: draft.event,
256
+ step: draft.step ?? null,
257
+ properties,
258
+ };
259
+ const parsed = EventSchema.safeParse(candidate);
260
+ if (!parsed.success) {
261
+ throw new Error(`refused ${draft.event}: ${parsed.error.issues.map((issue) => issue.message).join("; ")}`);
262
+ }
263
+ return parsed.data;
264
+ }
265
+ function parseLine(line) {
266
+ try {
267
+ const parsed = EventSchema.safeParse(JSON.parse(line));
268
+ return parsed.success ? parsed.data : undefined;
269
+ }
270
+ catch {
271
+ return undefined;
272
+ }
273
+ }
274
+ function alreadyRecorded(kilnDir, event) {
275
+ const key = event.properties.once;
276
+ if (!key)
277
+ return false;
278
+ const path = eventsPath(kilnDir);
279
+ if (!existsSync(path))
280
+ return false;
281
+ let raw;
282
+ try {
283
+ raw = readFileSync(path, "utf8");
284
+ }
285
+ catch {
286
+ return false;
287
+ }
288
+ for (const line of raw.split("\n")) {
289
+ if (!line.trim())
290
+ continue;
291
+ try {
292
+ const parsed = JSON.parse(line);
293
+ if (parsed.job_id === event.job_id &&
294
+ parsed.run_id === event.run_id &&
295
+ parsed.event === event.event &&
296
+ parsed.step === event.step &&
297
+ parsed.properties?.once === key) {
298
+ return true;
299
+ }
300
+ }
301
+ catch {
302
+ // A corrupt line does not count as a recorded event.
303
+ }
304
+ }
305
+ return false;
306
+ }
307
+ /** One `write` of the whole line, including the newline. */
308
+ function writeLine(path, line) {
309
+ const fd = openSync(path, "a");
310
+ try {
311
+ const buf = Buffer.from(line);
312
+ const wrote = writeSync(fd, buf, 0, buf.length);
313
+ if (wrote !== buf.length) {
314
+ throw new Error(`short write (${wrote} of ${buf.length})`);
315
+ }
316
+ }
317
+ finally {
318
+ closeSync(fd);
319
+ }
320
+ }
321
+ /**
322
+ * Exclusive append across threads in this process. The lock file holds the
323
+ * pid. Another thread in the same process shares that pid and waits. A
324
+ * later process may remove a lock whose pid is gone.
325
+ */
326
+ function withEventsLock(kilnDir, fn) {
327
+ mkdirSync(kilnDir, { recursive: true });
328
+ const lockPath = join(kilnDir, "events.lock");
329
+ const tmp = `${lockPath}.${process.pid}.${randomBytes(4).toString("hex")}`;
330
+ writeFileSync(tmp, String(process.pid));
331
+ const deadline = Date.now() + LOCK_WAIT_MS;
332
+ let held = false;
333
+ try {
334
+ while (!held) {
335
+ try {
336
+ linkSync(tmp, lockPath);
337
+ held = true;
338
+ }
339
+ catch (error) {
340
+ if (!isErrno(error, "EEXIST"))
341
+ throw error;
342
+ if (lockHolderDead(lockPath)) {
343
+ rmSync(lockPath, { force: true });
344
+ continue;
345
+ }
346
+ if (Date.now() > deadline)
347
+ throw new Error("timed out waiting for the event log lock");
348
+ Atomics.wait(pauseBuf, 0, 0, 5);
349
+ }
350
+ }
351
+ fn();
352
+ }
353
+ finally {
354
+ if (held)
355
+ rmSync(lockPath, { force: true });
356
+ rmSync(tmp, { force: true });
357
+ }
358
+ }
359
+ function lockHolderDead(lockPath) {
360
+ let holder = Number.NaN;
361
+ try {
362
+ holder = Number(readFileSync(lockPath, "utf8").trim());
363
+ }
364
+ catch {
365
+ return true;
366
+ }
367
+ if (holder === process.pid)
368
+ return false;
369
+ return !pidAlive(holder);
370
+ }
371
+ function pidAlive(pid) {
372
+ if (!Number.isInteger(pid) || pid <= 0)
373
+ return false;
374
+ try {
375
+ process.kill(pid, 0);
376
+ return true;
377
+ }
378
+ catch {
379
+ return false;
380
+ }
381
+ }
382
+ function isErrno(error, code) {
383
+ return typeof error === "object" && error !== null && "code" in error && error.code === code;
384
+ }
385
+ function report(error) {
386
+ const message = error instanceof Error ? error.message : String(error);
387
+ try {
388
+ console.error(`kiln: event log: ${message}`);
389
+ }
390
+ catch {
391
+ // The caller still continues when stderr itself fails.
392
+ }
393
+ }
@@ -0,0 +1,140 @@
1
+ import { readEvents } from "./events.js";
2
+ import { redact } from "./redact.js";
3
+ const REVIEW_STEPS = ["security", "ux", "ui", "code"];
4
+ /**
5
+ * Short factual Evidence section for a pull request body, built from the
6
+ * job's events. The whole section goes through `redact`.
7
+ */
8
+ export function evidenceSection(events, jobId) {
9
+ const rows = events.filter((event) => event.job_id === jobId);
10
+ const lines = ["## Evidence", ""];
11
+ const version = versionLine(rows);
12
+ if (version)
13
+ lines.push(version);
14
+ const phases = phaseLine(rows);
15
+ if (phases)
16
+ lines.push(phases);
17
+ const test = testLine(rows);
18
+ if (test)
19
+ lines.push(test);
20
+ const reviews = reviewLine(rows);
21
+ if (reviews)
22
+ lines.push(reviews);
23
+ const feedback = feedbackLine(rows);
24
+ if (feedback)
25
+ lines.push(feedback);
26
+ const gates = gateLine(rows);
27
+ if (gates)
28
+ lines.push(gates);
29
+ if (lines.length === 2)
30
+ lines.push("- (no events)");
31
+ return redact(`${lines.join("\n")}\n`);
32
+ }
33
+ export function evidenceSectionFor(kilnDir, jobId) {
34
+ return evidenceSection(readEvents(kilnDir), jobId);
35
+ }
36
+ function versionLine(events) {
37
+ const started = [...events].reverse().find((event) => event.event === "run.started");
38
+ if (!started || started.event !== "run.started")
39
+ return undefined;
40
+ const props = started.properties;
41
+ const hash = props.prompts_hash.length > 12 ? props.prompts_hash.slice(0, 12) : props.prompts_hash;
42
+ return `- versions: kiln ${props.kiln_version}, yak ${props.yak_version}, workflow ${props.workflow}, prompts ${hash}`;
43
+ }
44
+ function phaseLine(events) {
45
+ const order = [];
46
+ const failed = new Set();
47
+ for (const event of events) {
48
+ if (!event.step)
49
+ continue;
50
+ if ((event.event === "phase.started" || event.event === "phase.finished") && !order.includes(event.step)) {
51
+ order.push(event.step);
52
+ }
53
+ if (event.event === "phase.finished" && event.properties.outcome === "failed")
54
+ failed.add(event.step);
55
+ }
56
+ if (order.length === 0)
57
+ return undefined;
58
+ const shown = order.map((step) => (failed.has(step) ? `${step} failed` : step));
59
+ return `- phases: ${shown.join(", ")}`;
60
+ }
61
+ function testLine(events) {
62
+ const verify = [...events].reverse().find((event) => event.event === "phase.finished" && event.step === "verify");
63
+ if (!verify || verify.event !== "phase.finished")
64
+ return undefined;
65
+ const command = verify.properties.test_command;
66
+ const exit = verify.properties.exit_code;
67
+ if (!command && exit === undefined)
68
+ return undefined;
69
+ const commandText = command ? `\`${command}\`` : "(command not recorded)";
70
+ const result = exit === undefined ? "result unknown" : `exit ${exit}`;
71
+ return `- test: ${commandText} ${result}`;
72
+ }
73
+ function reviewLine(events) {
74
+ const latest = new Map();
75
+ for (const event of events) {
76
+ if (event.event !== "phase.finished" || !event.step || !REVIEW_STEPS.includes(event.step)) {
77
+ continue;
78
+ }
79
+ if (event.properties.verdict)
80
+ latest.set(event.step, event.properties.verdict);
81
+ }
82
+ const parts = REVIEW_STEPS.flatMap((step) => {
83
+ const verdict = latest.get(step);
84
+ return verdict ? [`${step} ${verdict}`] : [];
85
+ });
86
+ if (parts.length === 0)
87
+ return undefined;
88
+ return `- reviews: ${parts.join(", ")}`;
89
+ }
90
+ function feedbackLine(events) {
91
+ const rounds = new Map();
92
+ for (const event of events) {
93
+ if (event.event !== "pr.feedback")
94
+ continue;
95
+ const previous = rounds.get(event.properties.round);
96
+ rounds.set(event.properties.round, {
97
+ trigger: event.properties.trigger,
98
+ items: event.properties.items ?? previous?.items,
99
+ outcome: event.properties.outcome ?? previous?.outcome,
100
+ });
101
+ }
102
+ if (rounds.size === 0)
103
+ return undefined;
104
+ const parts = [...rounds.entries()]
105
+ .sort((left, right) => left[0] - right[0])
106
+ .map(([round, info]) => {
107
+ const items = info.items === undefined ? "" : ` (${info.items} ${info.items === 1 ? "item" : "items"})`;
108
+ const outcome = info.outcome ? ` ${info.outcome}` : "";
109
+ return `round ${round} ${info.trigger}${items}${outcome}`;
110
+ });
111
+ return `- feedback: ${parts.join("; ")}`;
112
+ }
113
+ function gateLine(events) {
114
+ const opened = events.filter((event) => event.event === "gate.opened");
115
+ const resolved = new Map();
116
+ for (const event of events) {
117
+ if (event.event === "gate.resolved" && event.step)
118
+ resolved.set(event.step, event);
119
+ }
120
+ const steps = new Set();
121
+ for (const event of opened)
122
+ if (event.step)
123
+ steps.add(event.step);
124
+ for (const step of resolved.keys())
125
+ steps.add(step);
126
+ if (steps.size === 0)
127
+ return undefined;
128
+ const parts = [];
129
+ for (const step of steps) {
130
+ const answer = resolved.get(step);
131
+ if (!answer) {
132
+ parts.push(`${step} open`);
133
+ continue;
134
+ }
135
+ const edit = answer.properties.edit || answer.properties.note;
136
+ const noted = edit ? ", edit noted" : "";
137
+ parts.push(`${step} by ${answer.properties.by}: ${answer.properties.decision}${noted}`);
138
+ }
139
+ return `- gates: ${parts.join("; ")}`;
140
+ }
@@ -0,0 +1,140 @@
1
+ import { createHash } from "node:crypto";
2
+ import { redact } from "./redact.js";
3
+ /**
4
+ * One failed run is transient, deterministic, or budget.
5
+ * Transient may retry with backoff inside the attempt cap.
6
+ * Deterministic does not. A cap hit is budget and stops in Needs human.
7
+ */
8
+ export const FAILURE_CLASSES = ["transient", "deterministic", "budget"];
9
+ const TRANSIENT = [
10
+ /\b429\b/,
11
+ /rate limit/i,
12
+ /too many requests/i,
13
+ /\b5\d\d\b/,
14
+ /internal server error/i,
15
+ /bad gateway/i,
16
+ /service unavailable/i,
17
+ /gateway timeout/i,
18
+ /ECONNRESET|ECONNREFUSED|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|EPIPE/i,
19
+ /socket hang up/i,
20
+ /network (?:error|unreachable|is down)/i,
21
+ /fetch failed/i,
22
+ /connection (?:reset|refused|timed out|closed)/i,
23
+ /container never started/i,
24
+ /failed to start (?:the )?(?:container|sandbox)/i,
25
+ /docker daemon/i,
26
+ /cannot connect to the docker/i,
27
+ /OCI runtime/i,
28
+ /error creating container/i,
29
+ /sandbox (?:start|flake)/i,
30
+ ];
31
+ const DETERMINISTIC = [
32
+ /\berror TS\d+\b/,
33
+ /type error/i,
34
+ /cannot find name/i,
35
+ /is not assignable/i,
36
+ /syntax error/i,
37
+ /\bAssertionError\b/,
38
+ /\b(?:tests?|assertion) failed\b/i,
39
+ /\bfailing tests?\b/i,
40
+ /\bbuild failed\b/i,
41
+ /\bcompilation failed\b/i,
42
+ /\bcompile error\b/i,
43
+ /npm ERR!/i,
44
+ ];
45
+ /** A container or sandbox that did not start. Those are transient. */
46
+ export function isSandboxStartFlake(text) {
47
+ return /container never started|failed to start (?:the )?(?:container|sandbox)|docker daemon|cannot connect to the docker|OCI runtime|error creating container|sandbox (?:start|flake)/i.test(text);
48
+ }
49
+ /**
50
+ * Strip timestamps, temp and worktree paths, hex ids, and line numbers so
51
+ * the same failure hashes the same way across runs.
52
+ */
53
+ export function normalizeFailureText(text) {
54
+ let out = redact(text).replace(/\r\n/g, "\n").trim();
55
+ out = out.replace(/\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?/g, "<ts>");
56
+ out = out.replace(/\b\d{2}:\d{2}:\d{2}(?:\.\d+)?\b/g, "<ts>");
57
+ out = out.replace(/(?:\/private)?\/(?:tmp|var\/folders)\/[^\s'")\]:]+/g, "<tmp>");
58
+ out = out.replace(/[^\s'")\]]*[/\\]\.yak[/\\]worktrees[/\\][^\s'")\]:]+/g, "<worktree>");
59
+ out = out.replace(/[^\s'")\]]+[/\\]worktrees[/\\][^\s'")\]:]+/g, "<worktree>");
60
+ out = out.replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, "<id>");
61
+ out = out.replace(/\b[0-9a-f]{8,}\b/gi, "<id>");
62
+ out = out.replace(/:(\d+)(?::\d+)?\b/g, ":<line>");
63
+ out = out.replace(/\bline\s+\d+\b/gi, "line <line>");
64
+ out = out.replace(/[ \t]+/g, " ");
65
+ out = out.replace(/\n{3,}/g, "\n\n");
66
+ return out.toLowerCase();
67
+ }
68
+ /** Stable id for one normalized failure. */
69
+ export function failureFingerprint(text) {
70
+ return createHash("sha256").update(normalizeFailureText(text)).digest("hex");
71
+ }
72
+ /**
73
+ * Transient when the text is a network, rate-limit, 5xx, or start flake.
74
+ * A test, build, or type error is deterministic and wins over a transient
75
+ * pattern in the same text. Anything else is deterministic.
76
+ */
77
+ export function classifyFailureText(text) {
78
+ if (DETERMINISTIC.some((pattern) => pattern.test(text)))
79
+ return "deterministic";
80
+ if (TRANSIENT.some((pattern) => pattern.test(text)))
81
+ return "transient";
82
+ return "deterministic";
83
+ }
84
+ /**
85
+ * The only decision for what a recorded failure does next.
86
+ * Attempt cap wins over the fingerprint breaker when both trip on the same run.
87
+ */
88
+ export function decideFailure(input) {
89
+ const fingerprint = failureFingerprint(input.text);
90
+ const deterministicHit = DETERMINISTIC.some((pattern) => pattern.test(input.text));
91
+ const transientHit = TRANSIENT.some((pattern) => pattern.test(input.text));
92
+ // Stall, crash, orphan, and a bare non-zero exit stay transient unless the
93
+ // text is a test, build, or type error, or the failure is blocked.
94
+ let failureClass = deterministicHit
95
+ ? "deterministic"
96
+ : transientHit || (input.operational && !input.blocked)
97
+ ? "transient"
98
+ : "deterministic";
99
+ let retry = failureClass === "transient" && !input.blocked;
100
+ let needsHuman;
101
+ if (input.attempt >= input.maxAttempts) {
102
+ failureClass = "budget";
103
+ retry = false;
104
+ needsHuman = "attempt-cap";
105
+ }
106
+ else if (input.sameFingerprint >= input.maxSameFingerprint) {
107
+ retry = false;
108
+ needsHuman = "fingerprint";
109
+ }
110
+ else if (input.blocked || failureClass === "deterministic") {
111
+ retry = false;
112
+ }
113
+ return { class: failureClass, fingerprint, retry, ...(needsHuman ? { needsHuman } : {}) };
114
+ }
115
+ /** Sum yak `budget.consumed` lines. Missing numbers stay null. Never invents a figure. */
116
+ export function costFromJournal(events) {
117
+ let tokens = 0;
118
+ let usd = 0;
119
+ let sawTokens = false;
120
+ let sawUsd = false;
121
+ for (const event of events) {
122
+ if (!event || typeof event !== "object")
123
+ continue;
124
+ const record = event;
125
+ if (record.t !== "budget.consumed")
126
+ continue;
127
+ if (typeof record.tokens === "number" && Number.isFinite(record.tokens) && record.tokens >= 0) {
128
+ tokens += record.tokens;
129
+ sawTokens = true;
130
+ }
131
+ if (typeof record.usd === "number" && Number.isFinite(record.usd) && record.usd >= 0) {
132
+ usd += record.usd;
133
+ sawUsd = true;
134
+ }
135
+ }
136
+ return {
137
+ tokens: sawTokens ? tokens : null,
138
+ usd: sawUsd ? usd : null,
139
+ };
140
+ }