playthrough 0.1.0 → 0.3.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.
@@ -0,0 +1,919 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { z } from "zod";
3
+ import { join, relative } from "node:path";
4
+ import { createHash } from "node:crypto";
5
+ // ../review/src/model.ts
6
+ const text = (error) => z.string({ error }).refine((value) => value.trim() !== "", { error });
7
+ const seconds$1 = (error) => z.number({ error }).min(0, { error });
8
+ const share = (error) => z.number({ error }).min(0, { error }).max(1, { error });
9
+ const fileName = (error) => z.string({ error }).regex(/^[\w.-]+(\/[\w.-]+)*$/, { error }).refine((name) => !name.split("/").some((part) => part === "." || part === ".."), { error });
10
+ const Element = z.strictObject({
11
+ role: text("needs its role").describe("As getByRole takes it, or text, row or box for something with no role of its own"),
12
+ name: text("needs its name"),
13
+ css: z.string().optional(),
14
+ node: z.number().int().optional().describe("The element in that round's recording")
15
+ });
16
+ const onScreen = "isn't on the screen. Boxes and points are shares of the screen, from 0 to 1";
17
+ const Box = z.strictObject({
18
+ x: share(onScreen),
19
+ y: share(onScreen),
20
+ width: share(onScreen),
21
+ height: share(onScreen)
22
+ }).refine((box) => box.x + box.width <= 1 && box.y + box.height <= 1, { error: onScreen });
23
+ const Point = z.strictObject({
24
+ x: share(onScreen),
25
+ y: share(onScreen)
26
+ });
27
+ const Did = z.strictObject({
28
+ does: text("needs what it did"),
29
+ on: text("needs the path to what it was done on"),
30
+ with: z.union([
31
+ z.string(),
32
+ z.boolean(),
33
+ z.array(z.string())
34
+ ]).optional(),
35
+ what: z.string().optional().describe("The words of what it was done on")
36
+ });
37
+ const Place = z.strictObject({
38
+ flow: text("needs the flow it's on"),
39
+ at: seconds$1("needs its moment, in seconds").optional().describe("Without it, the whole flow"),
40
+ until: seconds$1("needs its end, in seconds").optional().describe("The end of a stretch"),
41
+ element: Element.optional(),
42
+ alike: z.array(Element).optional().describe("The other things changed alike with its element, each where it is"),
43
+ box: Box.optional().describe("Something with no words of its own, or where only the pixels changed"),
44
+ point: Point.optional().describe("Empty space"),
45
+ app: z.strictObject({
46
+ from: z.strictObject({
47
+ flow: text("needs the flow it was taken over in"),
48
+ at: seconds$1("needs the moment it was taken over at, in seconds")
49
+ }),
50
+ did: z.array(Did).describe("What the person did there, in order"),
51
+ recording: fileName("needs its recording: the name of a file in the round").describe("The flow up to where it was taken over, then the person's own steps"),
52
+ length: z.number({ error: "needs its length, in seconds" }).positive({ error: "needs its length, in seconds" })
53
+ }).optional().describe("In the app: on the person's own recording, which branches off a flow where they took it over. Its flow names the branch")
54
+ }).refine((place) => [
55
+ place.element,
56
+ place.box,
57
+ place.point
58
+ ].filter((thing) => thing).length <= 1, { error: "is on more than one thing. Choose an element, a box or a point" }).refine((place) => place.at !== void 0 || !(place.element || place.box || place.point), { error: "is on something but at no moment. Give it one" }).refine((place) => place.until === void 0 || place.at !== void 0 && place.until > place.at, { error: "ends before it starts. A stretch ends after its moment" });
59
+ const Step = z.strictObject({
60
+ title: text("needs a title").describe("Steps are matched across rounds by their title"),
61
+ at: seconds$1("needs the moment it starts, in seconds"),
62
+ actions: z.array(seconds$1("needs the moment each of its actions was done, in seconds")).optional().describe("When each of its actions was done: between them the page rests, and a person can take the flow over there"),
63
+ person: text("needs the name of who does it, or no person").optional().describe("Who does it, once the flow has changed hands: someone other than who the flow is done as")
64
+ });
65
+ const Flow = z.strictObject({
66
+ name: text("needs a name").describe("Its identity: a new name is a new flow"),
67
+ length: z.number({ error: "needs its length, in seconds" }).positive({ error: "needs its length, in seconds" }),
68
+ steps: z.array(Step),
69
+ recording: fileName("needs its recording: the name of a file in the round"),
70
+ line: text("needs words in its line, or no line").optional().describe("The agent's, at the flow's start: what it starts from, or the choice it shows"),
71
+ stopped: text("needs the step it stopped at").optional().describe("On Before, the step the flow couldn't take there: it's kept as far as that, and tried again by playthrough start"),
72
+ person: text("needs the name of who it acts as, or no person").optional().describe("Who the flow is done as, as its file or the app's setup names them: the name on the recorded pointer, handed to you when you take over"),
73
+ unsteady: z.array(text("needs the ids of the changes it makes by itself")).optional().describe("Changes found between two recordings of the same app, like a time the server stamps: left out of every comparison, in every flow")
74
+ });
75
+ const Change = z.strictObject({
76
+ id: text("needs an id").describe("Its thing and its kind of change, so it keeps its id in every round while it's the same change"),
77
+ words: text("needs its words").describe("What changed, for the agent: “Download CSV and Cancel have swapped places”"),
78
+ seen: z.array(Place, { error: "needs where it's seen, a list" }).min(1, { error: "needs where it's seen: each flow, at its moment there" }).describe("Each flow it's seen in, at its moment there; a flow that's gone, with no moment")
79
+ });
80
+ const Comparison = z.strictObject({
81
+ round: z.number({ error: "needs its round" }).int().min(1, { error: "needs its round, after Before" }),
82
+ since: z.number({ error: "needs the round it's compared with" }).int().min(0).describe("0 is Before"),
83
+ changes: z.array(Change, { error: "needs its changes, a list, even if it's empty" })
84
+ });
85
+ const Account = z.union([
86
+ z.strictObject({ line: text("needs words in its line") }).describe("Why it's there, in the agent's voice: what it meant or chose"),
87
+ z.strictObject({ note: text("needs the note it was made for") }).describe("Made for that note: the reply on the note says why"),
88
+ z.strictObject({ notReal: text("needs to say why nothing really changed") }).describe("Nothing really changed; nobody is shown it")
89
+ ], { error: "says one of { line }, { note } or { notReal }" });
90
+ const Reply = z.strictObject({
91
+ note: text("needs the note it answers"),
92
+ words: z.string({ error: "needs its words, even if empty until it's answered" }),
93
+ on: Place.optional().describe("Where it shows on this round's recording; without it, the note's place was lost"),
94
+ watch: text("needs a step's title").optional().describe("For an answer that moves: the step of its flow it's seen in, which the note then plays")
95
+ });
96
+ const Note = z.strictObject({
97
+ id: text("needs an id"),
98
+ on: Place.optional().describe("Without it, about the work in general"),
99
+ words: text("needs words")
100
+ });
101
+ const Round = z.strictObject({
102
+ number: z.number().int().min(0).describe("Round 0 is Before: the app as it was"),
103
+ by: text("needs to say which agent recorded it").describe("By the name it goes by, “Claude Code” or “Codex”: one review can pass between agents"),
104
+ flows: z.array(Flow),
105
+ replies: z.array(Reply).describe("The agent's answer to each note that asks for one"),
106
+ accounts: z.record(z.string(), Account).describe("What the agent says about each change new since the round before, by its id"),
107
+ commit: z.string().optional().describe("What it was recorded from"),
108
+ cantStart: z.array(text("needs the flow's name")).optional().describe("Before: the flows the app as it was can't start, so they have no Before"),
109
+ finished: z.iso.datetime().optional()
110
+ });
111
+ const Verdict = z.strictObject({
112
+ note: text("needs the note it's on"),
113
+ verdict: z.enum(["looks good", "not quite"], { error: "is “looks good” or “not quite”" }),
114
+ words: z.string().optional()
115
+ }).refine((verdict) => verdict.verdict !== "not quite" || verdict.words?.trim(), { error: "is not quite, so it needs words saying what's still wrong" });
116
+ const Send = z.strictObject({
117
+ id: text("needs an id").describe("Chosen by the sender, so sending twice sends once"),
118
+ round: z.number().int().min(0),
119
+ by: text("needs to say who sent it"),
120
+ sent: z.iso.datetime({ error: "needs to say when it was sent, as an ISO date" }),
121
+ notes: z.array(Note).describe("New ones"),
122
+ verdicts: z.array(Verdict),
123
+ looksGood: z.array(text("needs the flow's name")).describe("Flows that look good as a whole; one that isn't quite right gets a note"),
124
+ takenBack: z.array(text("needs the flow's name")).optional().describe("Flows whose Looks good, said before, is taken back: they ask again")
125
+ });
126
+ const Taken = z.strictObject({
127
+ send: z.string(),
128
+ round: z.number().int(),
129
+ at: z.iso.datetime()
130
+ });
131
+ const Accepted = z.strictObject({
132
+ by: z.string(),
133
+ at: z.iso.datetime()
134
+ });
135
+ z.strictObject({
136
+ id: z.string(),
137
+ title: z.string().describe("A few words for the change, as a pull request's title"),
138
+ asked: z.string().optional().describe("What was asked, in the person's words"),
139
+ started: z.iso.datetime(),
140
+ rounds: z.array(Round),
141
+ comparisons: z.array(Comparison),
142
+ sends: z.array(Send),
143
+ taken: z.array(Taken),
144
+ accepted: Accepted.optional()
145
+ });
146
+ // ../review/src/words.ts
147
+ const list = (things) => things.length > 1 ? `${things.slice(0, -1).join(", ")} and ${things.at(-1)}` : things[0] ?? "";
148
+ const time = (seconds) => `${Math.floor(seconds / 60)}:${String(Math.floor(seconds % 60)).padStart(2, "0")}`;
149
+ const quote = (name) => `“${name}”`;
150
+ // ../review/src/rules.ts
151
+ function roundInProgress(review) {
152
+ const last = review.rounds.at(-1);
153
+ if (!last) return 0;
154
+ return last.finished ? last.number + 1 : last.number;
155
+ }
156
+ function allNotes(review) {
157
+ const notes = /* @__PURE__ */ new Map();
158
+ for (const send of review.sends) for (const note of send.notes) notes.set(note.id, {
159
+ note,
160
+ round: send.round,
161
+ by: send.by
162
+ });
163
+ return notes;
164
+ }
165
+ function lastSeen(review, id, before) {
166
+ const started = allNotes(review).get(id);
167
+ if (!started?.note.on) return void 0;
168
+ const placed = review.rounds.findLast((round) => round.number > started.round && round.number < before && round.replies.some((reply) => reply.note === id && reply.on));
169
+ const on = placed?.replies.find((reply) => reply.note === id)?.on;
170
+ return placed && on ? {
171
+ round: placed.number,
172
+ on
173
+ } : {
174
+ round: started.round,
175
+ on: started.note.on
176
+ };
177
+ }
178
+ function conversation(review, id) {
179
+ const started = allNotes(review).get(id);
180
+ if (!started) return [];
181
+ const turns = [{
182
+ turn: {
183
+ by: started.by,
184
+ agent: false,
185
+ round: started.round,
186
+ words: started.note.words
187
+ },
188
+ order: 0
189
+ }];
190
+ for (const round of review.rounds.filter((round) => round.number > started.round)) {
191
+ const reply = round.replies.find((reply) => reply.note === id);
192
+ if (reply?.words.trim()) turns.push({
193
+ turn: {
194
+ by: round.by,
195
+ agent: true,
196
+ round: round.number,
197
+ words: reply.words
198
+ },
199
+ order: 1
200
+ });
201
+ }
202
+ for (const send of review.sends) for (const verdict of send.verdicts.filter((verdict) => verdict.note === id)) turns.push({
203
+ turn: {
204
+ by: send.by,
205
+ agent: false,
206
+ round: send.round,
207
+ words: verdict.words ?? "",
208
+ verdict: verdict.verdict
209
+ },
210
+ order: 2
211
+ });
212
+ return turns.sort((a, b) => a.turn.round - b.turn.round || a.order - b.order).map(({ turn }) => turn);
213
+ }
214
+ function notesToAnswer(review, round) {
215
+ return asking(review.sends.filter((send) => review.taken.some((taken) => taken.send === send.id && taken.round === round)));
216
+ }
217
+ function waitingForTheAgent(review) {
218
+ const answered = (send) => {
219
+ const taken = review.taken.find((taken) => taken.send === send.id);
220
+ return !!taken && review.rounds.some((round) => round.number === taken.round && round.finished);
221
+ };
222
+ return asking(review.sends.filter((send) => !answered(send)));
223
+ }
224
+ function asking(sends) {
225
+ const asks = /* @__PURE__ */ new Set();
226
+ const last = /* @__PURE__ */ new Map();
227
+ for (const send of sends) {
228
+ for (const note of send.notes) asks.add(note.id);
229
+ for (const verdict of send.verdicts) last.set(verdict.note, verdict.verdict);
230
+ }
231
+ for (const [note, verdict] of last) if (verdict === "not quite") asks.add(note);
232
+ return [...asks];
233
+ }
234
+ function problemsWithId(id) {
235
+ return typeof id === "string" && /^[a-z0-9]+(-[a-z0-9]+)*$/.test(id) ? [] : [`A review's id is lowercase words joined by dashes, like “export-a-quarter”, not “${id}”.`];
236
+ }
237
+ function problemsStarting(id, title, asked) {
238
+ const problems = problemsWithId(id);
239
+ if (!isText(title)) problems.push("A review needs a title: a few words for the change, as a pull request's title.");
240
+ if (asked !== void 0 && !isText(asked)) problems.push("What was asked is the person's words, as they put it.");
241
+ return problems;
242
+ }
243
+ function problemsWithRound(review, round) {
244
+ const shape = problemsWithShape(Round, round, Number.isInteger(round?.number) ? `Round ${round.number}` : "This round");
245
+ if (shape.length) return shape;
246
+ if (review.accepted) return ["This review is accepted, so nothing more can be recorded."];
247
+ const due = roundInProgress(review);
248
+ if (round.number !== due && !(review.rounds.length === 0 && round.number === 1)) {
249
+ if (review.rounds.length === 0) return ["The first round is 0, Before, or 1 when the app is new and there's no Before."];
250
+ if (round.number < due) return [`Round ${round.number} is finished, so it can't change.`];
251
+ return [`Round ${round.number} can't be recorded yet: the round being worked on is ${due}.`];
252
+ }
253
+ const at = `Round ${round.number}`;
254
+ const problems = problemsWithFlows(round, at);
255
+ const notes = allNotes(review);
256
+ problems.push(...duplicates(round.replies.map((reply) => reply.note), (id) => `${at} has two replies to note ${id}. Say it all in one.`));
257
+ for (const reply of round.replies) {
258
+ if (!notes.has(reply.note)) problems.push(`${at} replies to note ${reply.note}, which doesn't exist.`);
259
+ if (reply.on) problems.push(...problemsWithPlace(reply.on.app ? { flow: reply.on.app.from.flow } : reply.on, round.flows, `${at}: the reply to note ${reply.note}`));
260
+ if (reply.watch) problems.push(...problemsWatching(reply, round, at));
261
+ }
262
+ return problems;
263
+ }
264
+ function problemsWatching(reply, round, at) {
265
+ const flow = reply.on && !reply.on.app ? round.flows.find((flow) => flow.name === reply.on.flow) : void 0;
266
+ if (!flow) return [`${at}: the reply to note ${reply.note} watches ${quote(reply.watch)}, but it isn't on one of this round's flows. Take "watch" out.`];
267
+ if (flow.steps.some((step) => step.title === reply.watch)) return [];
268
+ return [`${at}: the reply to note ${reply.note} watches ${quote(reply.watch)}, which isn't a step of ${quote(flow.name)}. Name one of its steps: ${list(flow.steps.map((step) => quote(step.title)))}.`];
269
+ }
270
+ function problemsAddingToBefore(review, flows, cantStart) {
271
+ if (review.accepted) return ["This review is accepted, so nothing more can be recorded."];
272
+ const before = review.rounds.find((round) => round.number === 0);
273
+ if (!before) return ["There's no Before to add a flow to: the app was new."];
274
+ const after = withInBefore(before, flows, cantStart);
275
+ const shape = problemsWithShape(Round, after, "Before");
276
+ return shape.length ? shape : problemsWithFlows(after, "Before");
277
+ }
278
+ function withInBefore(before, flows, cantStart, gone = []) {
279
+ const names = /* @__PURE__ */ new Set([
280
+ ...flows.map((flow) => flow.name),
281
+ ...cantStart,
282
+ ...gone
283
+ ]);
284
+ const again = new Map(flows.map((flow) => [flow.name, flow]));
285
+ return {
286
+ ...before,
287
+ flows: [...before.flows.flatMap((flow) => again.has(flow.name) ? [again.get(flow.name)] : names.has(flow.name) ? [] : [flow]), ...flows.filter((flow) => !before.flows.some((kept) => kept.name === flow.name))],
288
+ cantStart: [...(before.cantStart ?? []).filter((name) => !names.has(name)), ...cantStart]
289
+ };
290
+ }
291
+ function problemsWithFlows(round, at) {
292
+ const problems = duplicates([...round.flows.map((flow) => flow.name), ...round.cantStart ?? []], (name) => `${at} has two flows called “${name}”.`);
293
+ for (const flow of round.flows) {
294
+ const steps = flow.steps.map((step) => step.title);
295
+ problems.push(...duplicates(steps, (title) => `${at}: the flow “${flow.name}” has two steps called “${title}”. Steps are matched by title.`));
296
+ for (const step of flow.steps.filter((step) => step.at > flow.length)) problems.push(`${at}: the step “${step.title}” in the flow “${flow.name}” starts after the flow ends.`);
297
+ }
298
+ return problems;
299
+ }
300
+ function problemsWithComparison(review, comparison) {
301
+ const shape = problemsWithShape(Comparison, comparison, "This comparison");
302
+ if (shape.length) return shape;
303
+ if (review.accepted) return ["This review is accepted, so nothing more can be recorded."];
304
+ const at = `Round ${comparison.round} against ${roundName(comparison.since)}`;
305
+ const round = review.rounds.find((kept) => kept.number === comparison.round);
306
+ const earlier = review.rounds.find((kept) => kept.number === comparison.since);
307
+ if (!round) return [`Round ${comparison.round} hasn't been recorded yet, so it can't be compared.`];
308
+ if (round.finished) return [`Round ${comparison.round} is finished, so what it's compared with can't change.`];
309
+ if (comparison.since >= comparison.round) return [`${at}: a round is only compared with a round before it.`];
310
+ if (!earlier) return [`${at}: there's no ${roundName(comparison.since)} to compare it with.`];
311
+ const problems = duplicates(comparison.changes.map((change) => change.id), (id) => `${at} has two changes with the id ${id}.`);
312
+ for (const change of comparison.changes) for (const seen of change.seen) {
313
+ const gone = seen.at === void 0 && !round.flows.some((flow) => flow.name === seen.flow);
314
+ problems.push(...problemsWithPlace(seen, gone ? earlier.flows : round.flows, `${at}: ${describe(change)}`));
315
+ }
316
+ return problems;
317
+ }
318
+ function problemsFinishing(review, number) {
319
+ if (review.accepted) return ["This review is accepted, so nothing more can be recorded."];
320
+ const round = review.rounds.find((round) => round.number === number);
321
+ if (!round) return [`Round ${number} hasn't been recorded yet.`];
322
+ const at = `Round ${number}`;
323
+ const shape = problemsWithShape(Round, round, at);
324
+ if (shape.length) return shape;
325
+ if (!round.flows.length) return [`${at} has no flows yet. Record them first.`];
326
+ const problems = [];
327
+ const notes = allNotes(review);
328
+ for (const id of notesToAnswer(review, number)) {
329
+ const reply = round.replies.find((reply) => reply.note === id);
330
+ const note = notes.get(id)?.note;
331
+ if (!isText(reply?.words)) problems.push(`${at} needs an answer to note ${id}${note ? `, “${cut(note.words)}”` : ""}: its reply's “words”, under “replies”.`);
332
+ else if (reply?.on && note?.on && reply.on.flow !== note.on.flow) problems.push(`${at}: the reply to note ${id} is on “${reply.on.flow}”, but the note is on “${note.on.flow}”. A reply goes in its note's flow.`);
333
+ }
334
+ for (const flow of round.flows) if (flow.line && flow.line.length > 140) problems.push(`${at}: the line on “${flow.name}” is ${flow.line.length} characters. Say it in a sentence of up to 140: what it starts from, or the choice it shows.`);
335
+ const comparisons = review.comparisons.filter((comparison) => comparison.round === number);
336
+ if (comparisons.length) for (const earlier of review.rounds.filter((earlier) => earlier.number < number && !comparisons.some((comparison) => comparison.since === earlier.number))) problems.push(`${at} isn't compared with ${roundName(earlier.number)} yet, so it can't be handed over. Record it again.`);
337
+ const changes = new Map(comparisons.flatMap((comparison) => comparison.changes.map((change) => [change.id, change])));
338
+ for (const change of comparisons.find((comparison) => comparison.since === number - 1)?.changes ?? []) if (!round.accounts[change.id]) problems.push(`${at}: say why ${change.id} is there (${change.words}${where(change)}): its line, the note it was made for, or why nothing really changed.`);
339
+ for (const [id, account] of Object.entries(round.accounts)) {
340
+ const change = changes.get(id);
341
+ if (!change) problems.push(`${at} says something about the change ${id}, which isn't one of this round's changes.`);
342
+ else if ("note" in account && !notes.has(account.note)) problems.push(`${at}: ${describe(change)} was made for note ${account.note}, which doesn't exist.`);
343
+ }
344
+ return problems;
345
+ }
346
+ function problemsWithFile(review, round, name) {
347
+ if (review.accepted) return ["This review is accepted, so nothing more can be recorded."];
348
+ const due = roundInProgress(review);
349
+ if (round !== due && !(review.rounds.length === 0 && round === 1)) return [`Files go in the round being worked on, ${due}, not round ${round}.`];
350
+ return problemsWithFileName(name);
351
+ }
352
+ function problemsWithFileName(name) {
353
+ return fileName("").safeParse(name).success ? [] : [`“${name}” can't be a file's name: use letters, digits, dots, dashes and underscores, and slashes between folders.`];
354
+ }
355
+ function problemsWithSend(review, send) {
356
+ const shape = problemsWithShape(Send, send, "This send");
357
+ if (shape.length) return shape;
358
+ if (review.accepted) return ["This review is accepted, so nothing more can be sent."];
359
+ if (review.sends.some((other) => other.id === send.id)) return [`Something else was already sent as ${send.id}. Send this with a new id.`];
360
+ const takenBack = send.takenBack ?? [];
361
+ if (!send.notes.length && !send.verdicts.length && !send.looksGood.length && !takenBack.length) return ["There's nothing in this send."];
362
+ const round = review.rounds.find((round) => round.number === send.round);
363
+ if (send.round === 0) return ["Round 0 is Before: notes go on the rounds after it."];
364
+ if (!round?.finished) return [`Round ${send.round} isn't finished, so it can't take notes yet.`];
365
+ const problems = [];
366
+ const notes = allNotes(review);
367
+ problems.push(...duplicates(send.notes.map((note) => note.id), (id) => `This send has two notes with the id ${id}.`));
368
+ for (const note of send.notes) {
369
+ if (notes.has(note.id)) problems.push(`The id ${note.id} is already a note's. Make a new one.`);
370
+ if (note.on) problems.push(...problemsWithPlace(note.on.app ? { flow: note.on.app.from.flow } : note.on, round.flows, `Note ${note.id}`));
371
+ }
372
+ problems.push(...duplicates(send.verdicts.map((verdict) => verdict.note), (id) => `This send has two verdicts on note ${id}.`));
373
+ for (const verdict of send.verdicts) {
374
+ const known = notes.get(verdict.note);
375
+ if (send.notes.some((note) => note.id === verdict.note)) problems.push(`Note ${verdict.note} is new: it needs words, not a verdict.`);
376
+ else if (!known || known.round > send.round) problems.push(`There's no note ${verdict.note} on round ${send.round} to judge.`);
377
+ }
378
+ problems.push(...duplicates(send.looksGood, (flow) => `This send says “${flow}” looks good twice.`));
379
+ problems.push(...duplicates(takenBack, (flow) => `This send takes back Looks good on “${flow}” twice.`));
380
+ for (const flow of send.looksGood.filter((flow) => takenBack.includes(flow))) problems.push(`This send says “${flow}” looks good and takes that back: say one.`);
381
+ for (const flow of [...send.looksGood, ...takenBack].filter((flow) => !round.flows.some((recorded) => recorded.name === flow))) problems.push(`Round ${send.round} has no flow called “${flow}”.`);
382
+ return problems;
383
+ }
384
+ function problemsAccepting(review, by) {
385
+ if (!isText(by)) return ["Accepting needs to say who accepted."];
386
+ if (!review.rounds.some((round) => round.finished && round.number > 0)) return ["There's no finished round to accept yet."];
387
+ const notes = allNotes(review);
388
+ return waitingForTheAgent(review).map((id) => {
389
+ const note = notes.get(id);
390
+ return `Note ${id}${note ? `, “${cut(note.note.words)}”,` : ""} still waits for the agent's answer, so the work can't be accepted yet.`;
391
+ });
392
+ }
393
+ function problemsWithShape(schema, value, subject) {
394
+ const result = schema.safeParse(value, { error: plainly });
395
+ if (result.success) return [];
396
+ return result.error.issues.map((issue) => `${whose(subject, issue.path, value)} ${issue.message}.`);
397
+ }
398
+ const plainly = (issue) => {
399
+ const field = issue.path?.findLast((key) => typeof key === "string");
400
+ if (issue.code === "unrecognized_keys") return `has ${issue.keys.map((key) => `“${key}”`).join(" and ")}, which ${issue.keys.length > 1 ? "aren't" : "isn't"} one of its fields`;
401
+ if (issue.code === "invalid_type") {
402
+ if (field === void 0) return `isn't ${KINDS$1[issue.expected] ?? issue.expected}`;
403
+ if (issue.input === void 0) return issue.expected === "array" ? `needs its ${field}, a list, even if it's empty` : `needs its ${field}`;
404
+ return `has a ${field} that isn't ${KINDS$1[issue.expected] ?? issue.expected}`;
405
+ }
406
+ return field === void 0 ? "isn't right" : `has a ${field} that isn't right`;
407
+ };
408
+ const KINDS$1 = {
409
+ string: "text",
410
+ number: "a number",
411
+ array: "a list",
412
+ object: "an object",
413
+ boolean: "true or false"
414
+ };
415
+ function whose(subject, path, value) {
416
+ const things = [];
417
+ let here = value;
418
+ for (const [i, key] of path.entries()) {
419
+ here = here && typeof here === "object" ? here[key] : void 0;
420
+ if (typeof key === "number") things.unshift(nameOf(String(path[i - 1]), here, key));
421
+ else if (path[i - 1] === "accounts") things.unshift(`what it says about the change ${String(key)}`);
422
+ }
423
+ return things.length ? `${subject}: ${things.join(" in ")}` : subject;
424
+ }
425
+ function nameOf(list, item, index) {
426
+ const { name, title, words, id, note } = item ?? {};
427
+ return {
428
+ flows: isText(name) && `the flow “${name}”`,
429
+ steps: isText(title) && `the step “${title}”`,
430
+ changes: isText(words) && `the change “${cut(words)}”` || isText(id) && `the change ${id}`,
431
+ replies: isText(note) && `the reply to note ${note}`,
432
+ notes: isText(id) && `note ${id}`,
433
+ verdicts: isText(note) && `the verdict on note ${note}`
434
+ }[list] || `${{
435
+ flows: "flow",
436
+ steps: "step",
437
+ changes: "change",
438
+ seen: "place it's seen",
439
+ replies: "reply",
440
+ notes: "note",
441
+ verdicts: "verdict",
442
+ looksGood: "flow said to look good"
443
+ }[list] ?? list} ${index + 1}`;
444
+ }
445
+ function problemsWithPlace(place, flows, what) {
446
+ const flow = flows.find((flow) => flow.name === place.flow);
447
+ if (!flow) return [`${what} is on the flow “${place.flow}”, which isn't there.`];
448
+ const end = Math.max(place.at ?? 0, place.until ?? 0);
449
+ return end <= flow.length ? [] : [`${what} is at ${end}s, but “${flow.name}” is ${flow.length}s long.`];
450
+ }
451
+ function duplicates(values, problem) {
452
+ const seen = /* @__PURE__ */ new Set();
453
+ return [...new Set(values.filter((value) => seen.has(value) || !seen.add(value)))].map(problem);
454
+ }
455
+ const roundName = (number) => number === 0 ? "Before" : `round ${number}`;
456
+ const describe = (change) => `the change ${change.id} (${cut(change.words)})`;
457
+ const where = ({ seen: [first] }) => first ? `, in ${quote(first.flow)}${first.at === void 0 ? "" : ` at ${time(first.at)}`}` : "";
458
+ const cut = (words) => words.length > 48 ? `${words.slice(0, 47).trimEnd()}…` : words;
459
+ const isText = (value) => typeof value === "string" && value.trim() !== "";
460
+ // ../review/src/store.ts
461
+ var Refused = class extends Error {
462
+ problems;
463
+ constructor(problems) {
464
+ super(problems.join("\n"));
465
+ this.name = "Refused";
466
+ this.problems = problems;
467
+ }
468
+ };
469
+ var Reviews = class {
470
+ storage;
471
+ #queues = /* @__PURE__ */ new Map();
472
+ constructor(storage) {
473
+ this.storage = storage;
474
+ }
475
+ async list() {
476
+ const ids = (await this.storage.list("")).filter((id) => !problemsWithId(id).length);
477
+ const started = await Promise.all(ids.map(async (id) => await this.storage.read(`${id}/review.json`) !== void 0));
478
+ return ids.filter((_, i) => started[i]).sort();
479
+ }
480
+ async get(id) {
481
+ if (problemsWithId(id).length) throw new Refused(problemsWithId(id));
482
+ const about = await this.#readJson(`${id}/review.json`);
483
+ if (!about) throw new Refused([`There's no review called “${id}”.`]);
484
+ const numbers = (await this.storage.list(`${id}/rounds`)).map(Number).filter(Number.isInteger).sort((a, b) => a - b);
485
+ const rounds = await Promise.all(numbers.map((n) => this.#readJson(`${id}/rounds/${n}/round.json`)));
486
+ const comparisons = await Promise.all(numbers.map(async (n) => {
487
+ const names = (await this.storage.list(`${id}/rounds/${n}/since`)).filter((name) => name.endsWith(".json"));
488
+ return Promise.all(names.map((name) => this.#readJson(`${id}/rounds/${n}/since/${name}`)));
489
+ }));
490
+ const names = (await this.storage.list(`${id}/sends`)).filter((name) => name.endsWith(".json"));
491
+ const sends = await Promise.all(names.map((name) => this.#readJson(`${id}/sends/${name}`)));
492
+ const accepted = await this.#readJson(`${id}/accepted.json`);
493
+ return {
494
+ id,
495
+ title: about.title,
496
+ ...about.asked && { asked: about.asked },
497
+ started: about.started,
498
+ rounds: rounds.filter((round) => round !== void 0),
499
+ comparisons: comparisons.flat().filter((comparison) => comparison !== void 0).sort((a, b) => a.round - b.round || a.since - b.since),
500
+ sends: sends.filter((send) => send !== void 0).sort((a, b) => a.sent.localeCompare(b.sent) || a.id.localeCompare(b.id)),
501
+ taken: await this.#readJson(`${id}/taken.json`) ?? [],
502
+ ...accepted && { accepted }
503
+ };
504
+ }
505
+ async start(id, title, asked) {
506
+ return this.#one(id, async () => {
507
+ const problems = problemsStarting(id, title, asked);
508
+ if (problems.length) throw new Refused(problems);
509
+ if (await this.storage.read(`${id}/review.json`)) throw new Refused([`There's already a review called “${id}”.`]);
510
+ await this.#writeJson(`${id}/review.json`, {
511
+ format: 1,
512
+ id,
513
+ title,
514
+ ...asked && { asked },
515
+ started: now()
516
+ });
517
+ return this.get(id);
518
+ });
519
+ }
520
+ async saveRound(id, round) {
521
+ return this.#one(id, async () => {
522
+ const review = await this.get(id);
523
+ const kept = {
524
+ ...round,
525
+ finished: void 0
526
+ };
527
+ const problems = problemsWithRound(review, kept);
528
+ if (problems.length) throw new Refused(problems);
529
+ await this.#writeJson(`${id}/rounds/${round.number}/round.json`, kept);
530
+ });
531
+ }
532
+ async saveFile(id, round, name, bytes) {
533
+ return this.#one(id, async () => {
534
+ const problems = problemsWithFile(await this.get(id), round, name);
535
+ if (problems.length) throw new Refused(problems);
536
+ await this.storage.write(`${id}/rounds/${round}/files/${name}`, bytes);
537
+ });
538
+ }
539
+ async saveBranch(id, name, bytes) {
540
+ const problems = [...problemsWithId(id), ...problemsWithFileName(name)];
541
+ if (problems.length) throw new Refused(problems);
542
+ await this.storage.write(`${id}/branches/${name}`, bytes);
543
+ }
544
+ async branch(id, name) {
545
+ const problems = [...problemsWithId(id), ...problemsWithFileName(name)];
546
+ if (problems.length) throw new Refused(problems);
547
+ return this.storage.read(`${id}/branches/${name}`);
548
+ }
549
+ async take(id) {
550
+ return this.#one(id, async () => {
551
+ const review = await this.get(id);
552
+ if (review.accepted) return [];
553
+ const fresh = review.sends.filter((send) => !review.taken.some((taken) => taken.send === send.id));
554
+ if (!fresh.length) return [];
555
+ const round = roundInProgress(review);
556
+ const at = now();
557
+ await this.#writeJson(`${id}/taken.json`, [...review.taken, ...fresh.map((send) => ({
558
+ send: send.id,
559
+ round,
560
+ at
561
+ }))]);
562
+ return fresh;
563
+ });
564
+ }
565
+ async finish(id, round) {
566
+ return this.#one(id, async () => {
567
+ const review = await this.get(id);
568
+ const saved = review.rounds.find((saved) => saved.number === round);
569
+ if (saved?.finished) return;
570
+ const problems = problemsFinishing(review, round);
571
+ if (problems.length || !saved) throw new Refused(problems);
572
+ await this.#writeJson(`${id}/rounds/${round}/round.json`, {
573
+ ...saved,
574
+ finished: now()
575
+ });
576
+ });
577
+ }
578
+ async addToBefore(id, added) {
579
+ return this.#one(id, async () => {
580
+ const review = await this.get(id);
581
+ const problems = problemsAddingToBefore(review, added.flows, added.cantStart);
582
+ for (const { name, bytes } of added.files) {
583
+ const named = problemsWithFileName(name);
584
+ problems.push(...named);
585
+ const there = named.length ? void 0 : await this.storage.read(`${id}/rounds/0/files/${name}`);
586
+ if (there && Buffer.compare(there, bytes)) problems.push(`Before has a file called “${name}” already, with something else in it.`);
587
+ }
588
+ if (problems.length) throw new Refused(problems);
589
+ const before = review.rounds.find((round) => round.number === 0);
590
+ for (const { name, bytes } of added.files) await this.storage.write(`${id}/rounds/0/files/${name}`, bytes);
591
+ await this.#writeJson(`${id}/rounds/0/round.json`, {
592
+ ...withInBefore(before, added.flows, added.cantStart, added.gone),
593
+ ...added.commit && { commit: added.commit }
594
+ });
595
+ });
596
+ }
597
+ async saveComparison(id, comparison) {
598
+ return this.#one(id, async () => {
599
+ const problems = problemsWithComparison(await this.get(id), comparison);
600
+ if (problems.length) throw new Refused(problems);
601
+ await this.#writeJson(`${id}/rounds/${comparison.round}/since/${comparison.since}.json`, comparison);
602
+ });
603
+ }
604
+ async send(id, send) {
605
+ return this.#one(id, async () => {
606
+ const review = await this.get(id);
607
+ const already = review.sends.find((sent) => sent.id === send.id);
608
+ if (already && JSON.stringify(already) === JSON.stringify(send)) return;
609
+ const problems = problemsWithSend(review, send);
610
+ if (problems.length) throw new Refused(problems);
611
+ await this.#writeJson(`${id}/sends/${send.id}.json`, send);
612
+ });
613
+ }
614
+ async accept(id, by) {
615
+ return this.#one(id, async () => {
616
+ const review = await this.get(id);
617
+ if (review.accepted) return;
618
+ const problems = problemsAccepting(review, by);
619
+ if (problems.length) throw new Refused(problems);
620
+ await this.#writeJson(`${id}/accepted.json`, {
621
+ by,
622
+ at: now()
623
+ });
624
+ });
625
+ }
626
+ async file(id, round, name) {
627
+ const problems = [...problemsWithId(id), ...problemsWithFileName(name)];
628
+ if (problems.length || !Number.isInteger(round)) throw new Refused(problems.length ? problems : [`There's no round ${round}.`]);
629
+ return this.storage.read(`${id}/rounds/${round}/files/${name}`);
630
+ }
631
+ async files(id, round) {
632
+ const problems = problemsWithId(id);
633
+ if (problems.length || !Number.isInteger(round)) throw new Refused(problems.length ? problems : [`There's no round ${round}.`]);
634
+ return this.storage.list(`${id}/rounds/${round}/files`);
635
+ }
636
+ async wait(id, until, signal) {
637
+ signal?.throwIfAborted();
638
+ return new Promise((resolve, reject) => {
639
+ let looking = false;
640
+ let again = false;
641
+ const done = (end) => {
642
+ stop();
643
+ signal?.removeEventListener("abort", aborted);
644
+ end();
645
+ };
646
+ const look = async () => {
647
+ if (looking) return void (again = true);
648
+ looking = true;
649
+ try {
650
+ do {
651
+ again = false;
652
+ const review = await this.get(id);
653
+ if (until(review)) return done(() => resolve(review));
654
+ } while (again);
655
+ } catch (error) {
656
+ done(() => reject(error));
657
+ } finally {
658
+ looking = false;
659
+ }
660
+ };
661
+ const aborted = () => done(() => reject(signal?.reason));
662
+ const stop = this.storage.watch(id, look);
663
+ signal?.addEventListener("abort", aborted, { once: true });
664
+ look();
665
+ });
666
+ }
667
+ #one(id, step) {
668
+ const next = (this.#queues.get(id) ?? Promise.resolve()).catch(() => {}).then(step);
669
+ this.#queues.set(id, next);
670
+ return next;
671
+ }
672
+ async #readJson(path) {
673
+ const bytes = await this.storage.read(path);
674
+ return bytes && JSON.parse(new TextDecoder().decode(bytes));
675
+ }
676
+ #writeJson(path, value) {
677
+ return this.storage.write(path, new TextEncoder().encode(`${JSON.stringify(value, null, 2)}\n`));
678
+ }
679
+ };
680
+ const now = () => (/* @__PURE__ */ new Date()).toISOString();
681
+ // src/assets.ts
682
+ const assetsOf = (recording) => `assets/${recording}`;
683
+ const KINDS = /* @__PURE__ */ new Set([
684
+ "font",
685
+ "image",
686
+ "stylesheet"
687
+ ]);
688
+ const ENDINGS = {
689
+ "font/woff2": ".woff2",
690
+ "font/woff": ".woff",
691
+ "font/ttf": ".ttf",
692
+ "font/otf": ".otf",
693
+ "image/png": ".png",
694
+ "image/jpeg": ".jpg",
695
+ "image/gif": ".gif",
696
+ "image/webp": ".webp",
697
+ "image/avif": ".avif",
698
+ "image/svg+xml": ".svg",
699
+ "image/x-icon": ".ico",
700
+ "image/vnd.microsoft.icon": ".ico",
701
+ "text/css": ".css"
702
+ };
703
+ function keepAssets(page, folder) {
704
+ const kept = {};
705
+ const keeping = [];
706
+ mkdirSync(join(folder, "assets"), { recursive: true });
707
+ page.on("response", (response) => {
708
+ const address = response.url();
709
+ if (!KINDS.has(response.request().resourceType()) || response.status() !== 200 || address in kept) return;
710
+ kept[address] = "";
711
+ keeping.push(response.body().then((bytes) => {
712
+ const type = response.headers()["content-type"]?.split(";")[0]?.trim() ?? "";
713
+ const file = `assets/${createHash("sha256").update(bytes).digest("hex").slice(0, 16)}${ENDINGS[type] ?? ""}`;
714
+ writeFileSync(join(folder, file), bytes);
715
+ kept[address] = file;
716
+ }).catch(() => void delete kept[address]));
717
+ });
718
+ return async () => {
719
+ await Promise.all(keeping);
720
+ return Object.fromEntries(Object.entries(kept).filter(([, file]) => file));
721
+ };
722
+ }
723
+ // src/reporter.ts
724
+ const RECORDING = "playthrough recording";
725
+ const READINGS = "playthrough readings";
726
+ const GLIMPSES = "playthrough glimpses";
727
+ const PICTURE = "playthrough picture";
728
+ const ASSETS = "playthrough assets";
729
+ const PERSON = "playthrough person";
730
+ const HANDS = "playthrough hands";
731
+ const ACTIONS = "playthrough actions";
732
+ const WROTE = "playthrough wrote";
733
+ const UNMET = "playthrough unmet";
734
+ const HOLD = 1500;
735
+ const readingsOf = (flow) => `readings/${flow.recording}`;
736
+ async function readingsOn(reviews, review, round, flow) {
737
+ const bytes = await reviews.file(review, round, readingsOf(flow));
738
+ return bytes ? JSON.parse(Buffer.from(bytes).toString()) : [];
739
+ }
740
+ const picturesOf = (flow) => `readings/${flow.recording.replace(/\.json$/, "")}`;
741
+ const glimpsesOf = (flow) => `${picturesOf(flow)}.glimpses.json`;
742
+ function firstLine(said) {
743
+ const lines = said.replace(/\x1b\[[0-9;]*m/g, "").split("\n");
744
+ let line = lines[0]?.trim().replace(/,$/, "") ?? "";
745
+ const waited = /Timeout \d+ms exceeded/.test(line) && lines.map((under) => under.match(/^\s*- (waiting for .+)$/)?.[1]).find(Boolean);
746
+ if (waited) line = `${line.replace(/\.$/, "")}, ${waited}`;
747
+ const found = line.endsWith(":") ? lines.flatMap((under) => under.match(/^\s*\d+\) .* aka (.+)$/)?.[1] ?? []) : [];
748
+ if (found.length) line = `${line} ${found.slice(0, 2).join(", ")}${found.length > 2 ? ", …" : ""}`;
749
+ return line.length > 300 ? `${line.slice(0, 300)}…` : line;
750
+ }
751
+ const BROKEN = /Page crashed|Target crashed|Target page, context or browser has been closed|net::ERR_CONNECTION_REFUSED/;
752
+ const hashOf = (file) => {
753
+ try {
754
+ return createHash("sha256").update(readFileSync(file)).digest("hex");
755
+ } catch {
756
+ return;
757
+ }
758
+ };
759
+ var Flows = class {
760
+ #folder;
761
+ #flows;
762
+ #before;
763
+ #taken;
764
+ #ran = [];
765
+ constructor({ folder, flows, before, taken }) {
766
+ this.#folder = folder;
767
+ this.#flows = flows;
768
+ this.#before = before;
769
+ this.#taken = taken;
770
+ }
771
+ onTestEnd(test, result) {
772
+ if (result.status === "skipped") return;
773
+ const where = {
774
+ file: test.location.file,
775
+ line: test.location.line
776
+ };
777
+ const recording = result.attachments.find((attachment) => attachment.name === RECORDING)?.body;
778
+ const read = {
779
+ readings: JSON.parse(result.attachments.find((attachment) => attachment.name === "playthrough readings")?.body?.toString() ?? "[]"),
780
+ glimpses: JSON.parse(result.attachments.find((attachment) => attachment.name === "playthrough glimpses")?.body?.toString() ?? "[]"),
781
+ pictures: result.attachments.filter((attachment) => attachment.name === PICTURE).map((attachment) => attachment.body),
782
+ assets: JSON.parse(result.attachments.find((attachment) => attachment.name === "playthrough assets")?.body?.toString() ?? "{}"),
783
+ actions: JSON.parse(result.attachments.find((attachment) => attachment.name === "playthrough actions")?.body?.toString() ?? "[]"),
784
+ hands: JSON.parse(result.attachments.find((attachment) => attachment.name === "playthrough hands")?.body?.toString() ?? "[]")
785
+ };
786
+ const steps = result.steps.filter((step) => step.category === "test.step");
787
+ const person = result.attachments.find((attachment) => attachment.name === PERSON)?.body?.toString();
788
+ const wrote = result.attachments.find((attachment) => attachment.name === WROTE)?.body?.toString();
789
+ const as = (made) => {
790
+ if (person) made.flow.person = person;
791
+ for (const step of made.flow.steps) if (step.person === made.flow.person) delete step.person;
792
+ if (wrote) made.wrote = wrote;
793
+ return made;
794
+ };
795
+ const unmet = Number(result.attachments.find((attachment) => attachment.name === "playthrough unmet")?.body?.toString() ?? 0);
796
+ const yet = unmet ? `, though ${unmet > 1 ? `${unmet} things it expects aren't` : "1 thing it expects isn't"} so yet` : "";
797
+ const went = (how) => console.log(` ${quote(test.title)}: ${how}`);
798
+ if (result.status === "passed") {
799
+ if (!recording) return;
800
+ const recorded = as(this.#flow(test.title, recording, read, steps));
801
+ this.#ran.push({
802
+ ...where,
803
+ recorded
804
+ });
805
+ return went(`to its end, ${Math.round(recorded.flow.length)}s${yet}`);
806
+ }
807
+ const stop = steps.at(-1);
808
+ const kept = stop?.error ? steps.slice(0, -1) : steps;
809
+ const said = (stop?.error ?? result.error)?.message ?? "";
810
+ const broken = BROKEN.test(said);
811
+ const why = firstLine(said);
812
+ const failed = {
813
+ flow: test.title,
814
+ ...stop?.error && { at: stop.title },
815
+ ...why && { why },
816
+ ...broken && { broken }
817
+ };
818
+ if (!this.#before || broken || !recording || !stop || !kept.length) {
819
+ this.#ran.push({
820
+ ...where,
821
+ failed
822
+ });
823
+ return went(broken ? "the browser stopped it" : this.#before ? "can't start" : failed.at ? `failed at ${quote(failed.at)}` : "failed before its first step");
824
+ }
825
+ const until = stop.startTime.getTime() + (stop.error ? 0 : stop.duration);
826
+ const recorded = as(this.#flow(test.title, recording, read, kept, until));
827
+ if (stop.error) recorded.flow.stopped = stop.title;
828
+ this.#ran.push({
829
+ ...where,
830
+ failed,
831
+ recorded
832
+ });
833
+ went(`${stop.error ? `stops at ${quote(stop.title)}` : "stops after its last step"}${yet}`);
834
+ }
835
+ onError(error) {
836
+ console.log(`\n${error.message ?? error.value}`);
837
+ }
838
+ #flow(name, recording, read, steps, until) {
839
+ let events = JSON.parse(recording.toString());
840
+ if (until !== void 0) events = [...events.filter((event) => event.timestamp < until), {
841
+ type: 5,
842
+ data: {
843
+ tag: "end",
844
+ payload: {}
845
+ },
846
+ timestamp: until + HOLD
847
+ }];
848
+ const start = events[0].timestamp;
849
+ const length = seconds(events.at(-1).timestamp - start);
850
+ const actionsIn = (step, next) => read.actions.filter((done) => done >= step.startTime.getTime() && done < (next?.startTime.getTime() ?? until ?? Infinity)).map((done) => Math.min(length, seconds(done - start)));
851
+ const flow = {
852
+ name,
853
+ length,
854
+ steps: steps.map((step, i) => {
855
+ const actions = actionsIn(step, steps[i + 1]);
856
+ const person = read.hands.filter((hand) => hand.at <= step.startTime.getTime()).at(-1)?.person;
857
+ return {
858
+ title: step.title,
859
+ at: Math.min(length, seconds(step.startTime.getTime() - start)),
860
+ ...actions.length && { actions },
861
+ ...person && { person }
862
+ };
863
+ }),
864
+ recording: this.#fileFor(name)
865
+ };
866
+ const readings = read.readings.map((reading, i) => ({
867
+ ...reading,
868
+ picture: `${picturesOf(flow)}/${i + 1}.png`
869
+ }));
870
+ const glimpses = read.glimpses.filter((glimpse) => glimpse.at <= length);
871
+ const files = [
872
+ flow.recording,
873
+ readingsOf(flow),
874
+ glimpsesOf(flow),
875
+ ...readings.map((reading) => reading.picture),
876
+ assetsOf(flow.recording),
877
+ ...new Set(Object.values(read.assets))
878
+ ];
879
+ for (const folder of [picturesOf(flow), "assets"]) mkdirSync(join(this.#folder, folder), { recursive: true });
880
+ writeFileSync(join(this.#folder, flow.recording), JSON.stringify(events));
881
+ writeFileSync(join(this.#folder, readingsOf(flow)), JSON.stringify(readings));
882
+ writeFileSync(join(this.#folder, glimpsesOf(flow)), JSON.stringify(glimpses));
883
+ writeFileSync(join(this.#folder, assetsOf(flow.recording)), JSON.stringify(read.assets));
884
+ read.pictures.forEach((picture, i) => writeFileSync(join(this.#folder, readings[i].picture), picture));
885
+ return {
886
+ flow,
887
+ files
888
+ };
889
+ }
890
+ onEnd() {
891
+ const ran = this.#ran.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
892
+ const written = {};
893
+ for (const { file, recorded, failed } of ran) {
894
+ const hash = hashOf(file);
895
+ if (hash) written[recorded?.flow.name ?? failed.flow] = {
896
+ file: relative(this.#flows, file),
897
+ hash
898
+ };
899
+ }
900
+ writeFileSync(join(this.#folder, "flows.json"), JSON.stringify({
901
+ recorded: ran.flatMap((run) => run.recorded ?? []),
902
+ failed: ran.flatMap((run) => run.failed ?? []),
903
+ written
904
+ }));
905
+ }
906
+ printsToStdio() {
907
+ return true;
908
+ }
909
+ #fileFor(name) {
910
+ const base = name.toLowerCase().normalize("NFKD").replace(/['’]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "flow";
911
+ const taken = /* @__PURE__ */ new Set([...this.#taken, ...this.#ran.map((run) => run.recorded?.flow.recording)]);
912
+ for (let n = 1;; n++) {
913
+ const file = n === 1 ? `${base}.json` : `${base}-${n}.json`;
914
+ if (!taken.has(file)) return file;
915
+ }
916
+ }
917
+ };
918
+ const seconds = (ms) => Math.round(Math.max(0, ms)) / 1e3;
919
+ export { time as A, conversation as C, roundInProgress as D, problemsFinishing as E, list as O, allNotes as S, notesToAnswer as T, readingsOn as _, HANDS as a, Refused as b, PICTURE as c, UNMET as d, WROTE as f, readingsOf as g, hashOf as h, GLIMPSES as i, quote as k, READINGS as l, glimpsesOf as m, ASSETS as n, HOLD as o, firstLine as p, Flows as r, PERSON as s, ACTIONS as t, RECORDING as u, seconds as v, lastSeen as w, Reviews as x, keepAssets as y };