@llm4ts/flow 2.51.0 → 2.53.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.
Files changed (63) hide show
  1. package/dist/Approval.d.ts +1 -1
  2. package/dist/Approval.d.ts.map +1 -1
  3. package/dist/Artifacts.d.ts +2 -2
  4. package/dist/Artifacts.d.ts.map +1 -1
  5. package/dist/BenchReport.d.ts +2 -2
  6. package/dist/BenchReport.d.ts.map +1 -1
  7. package/dist/CostLedger.d.ts +2 -2
  8. package/dist/CostLedger.d.ts.map +1 -1
  9. package/dist/Equiv.d.ts +3 -3
  10. package/dist/Equiv.d.ts.map +1 -1
  11. package/dist/Flow.d.ts +1 -1
  12. package/dist/Flow.d.ts.map +1 -1
  13. package/dist/FlowError.d.ts +33 -1
  14. package/dist/FlowError.d.ts.map +1 -1
  15. package/dist/FlowError.js +43 -1
  16. package/dist/FlowError.js.map +1 -1
  17. package/dist/FlowEvents.d.ts +23 -1
  18. package/dist/FlowEvents.d.ts.map +1 -1
  19. package/dist/FlowEvents.js +28 -0
  20. package/dist/FlowEvents.js.map +1 -1
  21. package/dist/Gates.d.ts +1 -1
  22. package/dist/Gates.d.ts.map +1 -1
  23. package/dist/GraphCache.d.ts +1 -1
  24. package/dist/GraphCache.d.ts.map +1 -1
  25. package/dist/Judgment.d.ts +2 -2
  26. package/dist/Judgment.d.ts.map +1 -1
  27. package/dist/Landing.d.ts +1 -1
  28. package/dist/Landing.d.ts.map +1 -1
  29. package/dist/NodePreflight.d.ts +1 -1
  30. package/dist/NodePreflight.d.ts.map +1 -1
  31. package/dist/Persistence.d.ts +2 -2
  32. package/dist/Persistence.d.ts.map +1 -1
  33. package/dist/PlanExecution.d.ts +2 -2
  34. package/dist/PlanExecution.d.ts.map +1 -1
  35. package/dist/ProgramJudge.d.ts +1 -1
  36. package/dist/ProgramJudge.d.ts.map +1 -1
  37. package/dist/Replay.d.ts +2 -2
  38. package/dist/Replay.d.ts.map +1 -1
  39. package/dist/Retro.d.ts +3 -3
  40. package/dist/Retro.d.ts.map +1 -1
  41. package/dist/Review.d.ts +5 -5
  42. package/dist/Review.d.ts.map +1 -1
  43. package/dist/ReviewCache.d.ts +1 -1
  44. package/dist/ReviewCache.d.ts.map +1 -1
  45. package/dist/Roster.d.ts +1 -1
  46. package/dist/Roster.d.ts.map +1 -1
  47. package/dist/RosterSeats.d.ts +1 -1
  48. package/dist/RosterSeats.d.ts.map +1 -1
  49. package/dist/Roundtable.d.ts +251 -0
  50. package/dist/Roundtable.d.ts.map +1 -0
  51. package/dist/Roundtable.js +845 -0
  52. package/dist/Roundtable.js.map +1 -0
  53. package/dist/Stories.d.ts +27 -3
  54. package/dist/Stories.d.ts.map +1 -1
  55. package/dist/Stories.js +19 -3
  56. package/dist/Stories.js.map +1 -1
  57. package/dist/WorkQueue.d.ts +1 -1
  58. package/dist/WorkQueue.d.ts.map +1 -1
  59. package/package.json +3 -2
  60. package/src/FlowError.ts +47 -1
  61. package/src/FlowEvents.ts +30 -0
  62. package/src/Roundtable.ts +1107 -0
  63. package/src/Stories.ts +42 -4
@@ -0,0 +1,1107 @@
1
+ import * as Effect from "effect/Effect"
2
+ import * as Schema from "effect/Schema"
3
+ import * as Option from "effect/Option"
4
+ import type { JsonSchema } from "@llm4ts/core/Models"
5
+ import { makeOpenPointsCollector, OpenPoint } from "./Decisions.ts"
6
+ import { MeetingInvalid, PersonaInvalid, TurnsInvalid } from "./FlowError.ts"
7
+ import { RoundtableTurnKind } from "./FlowEvents.ts"
8
+
9
+ /**
10
+ * The roundtable (ADR 0046): a meeting run by code. Chosen personas speak in
11
+ * turns about a topic toward a goal, agenda item by agenda item, until each
12
+ * item is settled or capped; then the Scribe writes the outline and the plan.
13
+ *
14
+ * This module is the pure half: the persona card, the meeting file, the turn
15
+ * record, what the turns imply (open objections, open questions, decisions,
16
+ * settled items) and the chair's `nextAction`. The flow wires seats, files
17
+ * and events around it.
18
+ */
19
+
20
+ // ---- Shared helpers -----------------------------------------------------------
21
+
22
+ const dash = " — "
23
+ const headerLine = /^([A-Za-z][A-Za-z ]*):\s*(.*)$/
24
+
25
+ const splitFirst = (text: string, separator: string): readonly [string, string | undefined] => {
26
+ const index = text.indexOf(separator)
27
+ return index < 0
28
+ ? [text.trim(), undefined]
29
+ : [text.slice(0, index).trim(), text.slice(index + separator.length).trim()]
30
+ }
31
+
32
+ const prose = (text: string): ReadonlyArray<string> =>
33
+ text.trim().length === 0 ? [] : text.trim().split("\n")
34
+
35
+ const sectionLines = (title: string, body: ReadonlyArray<string>): ReadonlyArray<string> => [
36
+ `## ${title}`,
37
+ "",
38
+ ...(body.length === 0 ? [] : [...body, ""])
39
+ ]
40
+
41
+ // ---- Personas -----------------------------------------------------------------
42
+
43
+ /**
44
+ * A persona card: what a speaker is. A role is a seat an executor takes; a
45
+ * persona is a voice. The order sorts attendees into speaking order.
46
+ */
47
+ export class Persona extends Schema.Class<Persona>("Persona")({
48
+ name: Schema.String,
49
+ order: Schema.Int,
50
+ mandate: Schema.String,
51
+ caresAbout: Schema.String,
52
+ pushesBackOn: Schema.String,
53
+ neverDecidesAlone: Schema.String,
54
+ voice: Schema.String,
55
+ /** The Scribe's section skeleton for `outline.md`; other cards have none. */
56
+ outlineTemplate: Schema.optionalKey(Schema.String),
57
+ /** The Scribe's section skeleton for `plan.md`. */
58
+ planTemplate: Schema.optionalKey(Schema.String)
59
+ }) {}
60
+
61
+ /** The order a card without one gets: after every bundled persona. */
62
+ export const defaultPersonaOrder = 100
63
+
64
+ const personaSections = {
65
+ mandate: "Mandate",
66
+ caresAbout: "Cares about",
67
+ pushesBackOn: "Pushes back on",
68
+ neverDecidesAlone: "Never decides alone",
69
+ voice: "Voice",
70
+ outlineTemplate: "Outline template",
71
+ planTemplate: "Plan template"
72
+ } as const
73
+ type PersonaSection = keyof typeof personaSections
74
+ const personaSectionKeys: ReadonlyArray<PersonaSection> = [
75
+ "mandate",
76
+ "caresAbout",
77
+ "pushesBackOn",
78
+ "neverDecidesAlone",
79
+ "voice",
80
+ "outlineTemplate",
81
+ "planTemplate"
82
+ ]
83
+ /** Sections whose body is a markdown skeleton: a `## ` line inside them is content, not a section. */
84
+ const templateSections: ReadonlyArray<PersonaSection> = ["outlineTemplate", "planTemplate"]
85
+
86
+ const personaSectionOf = (title: string): PersonaSection | undefined =>
87
+ personaSectionKeys.find((key) => personaSections[key].toLowerCase() === title.toLowerCase())
88
+
89
+ /**
90
+ * Reads a card: optional `---` frontmatter with `name:` and `order:`, then
91
+ * one `## ` section per field. `fallbackName` is the file's name, used when
92
+ * the frontmatter has none.
93
+ */
94
+ export const parsePersona = Effect.fn("@llm4ts/flow/Roundtable.parsePersona")(function* (
95
+ fallbackName: string,
96
+ markdown: string
97
+ ): Effect.fn.Return<Persona, PersonaInvalid> {
98
+ const violations: Array<string> = []
99
+ const lines = markdown.split(/\r?\n/)
100
+ let name = fallbackName
101
+ let order = defaultPersonaOrder
102
+ let index = 0
103
+ if ((lines[0] ?? "").trim() === "---") {
104
+ index = 1
105
+ while (index < lines.length && (lines[index] ?? "").trim() !== "---") {
106
+ const field = headerLine.exec((lines[index] ?? "").trim())
107
+ if (field?.[1] !== undefined) {
108
+ const key = field[1].toLowerCase()
109
+ const value = (field[2] ?? "").trim()
110
+ if (key === "name" && value.length > 0) name = value
111
+ if (key === "order") {
112
+ const parsed = /^\d+$/.test(value) ? Number.parseInt(value, 10) : undefined
113
+ if (parsed === undefined) {
114
+ violations.push(`line ${index + 1}: order is a whole number, got: ${value}`)
115
+ } else {
116
+ order = parsed
117
+ }
118
+ }
119
+ }
120
+ index += 1
121
+ }
122
+ if (index >= lines.length) {
123
+ violations.push("line 1: the frontmatter opened here is never closed")
124
+ }
125
+ index += 1
126
+ }
127
+ const bodies: Record<PersonaSection, Array<string>> = {
128
+ mandate: [],
129
+ caresAbout: [],
130
+ pushesBackOn: [],
131
+ neverDecidesAlone: [],
132
+ voice: [],
133
+ outlineTemplate: [],
134
+ planTemplate: []
135
+ }
136
+ let current: PersonaSection | "unknown" | "none" = "none"
137
+ let fenced = false
138
+ for (; index < lines.length; index += 1) {
139
+ const raw = lines[index] ?? ""
140
+ const trimmed = raw.trim()
141
+ if (trimmed.startsWith("```")) fenced = !fenced
142
+ if (!fenced && trimmed.startsWith("## ")) {
143
+ const title = trimmed.slice(3).trim()
144
+ const key = personaSectionOf(title)
145
+ if (key !== undefined) {
146
+ current = key
147
+ continue
148
+ }
149
+ // A template is a skeleton of headings: its `## ` lines are its body.
150
+ if (current !== "unknown" && current !== "none" && templateSections.includes(current)) {
151
+ bodies[current].push(raw)
152
+ continue
153
+ }
154
+ violations.push(`line ${index + 1}: unknown section "${title}"`)
155
+ current = "unknown"
156
+ continue
157
+ }
158
+ if (current !== "unknown" && current !== "none") bodies[current].push(raw)
159
+ }
160
+ const text = (key: PersonaSection): string => bodies[key].join("\n").trim()
161
+ if (text("mandate").length === 0) violations.push("missing the `## Mandate` section")
162
+ if (violations.length > 0) {
163
+ return yield* PersonaInvalid.make({ name: fallbackName, violations })
164
+ }
165
+ return Persona.make({
166
+ name,
167
+ order,
168
+ mandate: text("mandate"),
169
+ caresAbout: text("caresAbout"),
170
+ pushesBackOn: text("pushesBackOn"),
171
+ neverDecidesAlone: text("neverDecidesAlone"),
172
+ voice: text("voice"),
173
+ ...(bodies.outlineTemplate.length === 0 ? {} : { outlineTemplate: text("outlineTemplate") }),
174
+ ...(bodies.planTemplate.length === 0 ? {} : { planTemplate: text("planTemplate") })
175
+ })
176
+ })
177
+
178
+ export const renderPersona = (persona: Persona): string =>
179
+ [
180
+ "---",
181
+ `name: ${persona.name}`,
182
+ `order: ${persona.order}`,
183
+ "---",
184
+ "",
185
+ ...personaSectionKeys.flatMap((key) => {
186
+ const body = persona[key]
187
+ return body === undefined ? [] : sectionLines(personaSections[key], prose(body))
188
+ })
189
+ ].join("\n")
190
+
191
+ // ---- The meeting file ---------------------------------------------------------
192
+
193
+ export const MeetingStatus = Schema.Literals([
194
+ "agenda-proposed",
195
+ "discussing",
196
+ "drafting",
197
+ "reviewing",
198
+ "done"
199
+ ])
200
+ export type MeetingStatus = typeof MeetingStatus.Type
201
+
202
+ /** One question the meeting settles, with a title. Numbered as the agenda lists it. */
203
+ export class AgendaItem extends Schema.Class<AgendaItem>("AgendaItem")({
204
+ number: Schema.Int,
205
+ title: Schema.String,
206
+ question: Schema.String
207
+ }) {}
208
+
209
+ /**
210
+ * The meeting file, `meeting.md`: the state of a roundtable, and the owner's
211
+ * only seat at the table. Answers are keyed by the id of the question turn
212
+ * they answer; feedback is free text that becomes an Owner turn on the next
213
+ * run. `drafts` counts how many times the Scribe has written the documents:
214
+ * each draft opens a new review round.
215
+ */
216
+ export class Meeting extends Schema.Class<Meeting>("Meeting")({
217
+ id: Schema.String,
218
+ status: MeetingStatus,
219
+ topic: Schema.String,
220
+ goal: Schema.String,
221
+ attendees: Schema.Array(Schema.String),
222
+ agenda: Schema.Array(AgendaItem),
223
+ answers: Schema.Array(OpenPoint),
224
+ feedback: Schema.String,
225
+ drafts: Schema.Int
226
+ }) {}
227
+
228
+ const meetingSections = { agenda: "Agenda", answers: "Answers", feedback: "Feedback" } as const
229
+ type MeetingSection = keyof typeof meetingSections
230
+ const meetingSectionKeys: ReadonlyArray<MeetingSection> = ["agenda", "answers", "feedback"]
231
+
232
+ const meetingSectionOf = (title: string): MeetingSection | undefined =>
233
+ meetingSectionKeys.find((key) => meetingSections[key].toLowerCase() === title.toLowerCase())
234
+
235
+ const isMeetingStatus = (value: string): value is MeetingStatus =>
236
+ MeetingStatus.literals.some((literal) => literal === value)
237
+
238
+ export const renderAgendaItem = (item: AgendaItem): string =>
239
+ `${item.number}. ${item.title}${dash}${item.question}`
240
+
241
+ export const renderMeeting = (meeting: Meeting): string =>
242
+ [
243
+ `# Roundtable: ${meeting.id}`,
244
+ "",
245
+ `Status: ${meeting.status}`,
246
+ `Topic: ${meeting.topic}`,
247
+ `Goal: ${meeting.goal}`,
248
+ `Attendees: ${meeting.attendees.join(", ")}`,
249
+ `Drafts: ${meeting.drafts}`,
250
+ "",
251
+ ...sectionLines(meetingSections.agenda, meeting.agenda.map(renderAgendaItem)),
252
+ ...sectionLines(
253
+ meetingSections.answers,
254
+ meeting.answers.flatMap((point) => [
255
+ `${point.number}. ${point.question}`,
256
+ ` answer:${(point.answer ?? "").trim().length === 0 ? "" : ` ${(point.answer ?? "").trim()}`}`
257
+ ])
258
+ ),
259
+ ...sectionLines(meetingSections.feedback, prose(meeting.feedback))
260
+ ].join("\n")
261
+
262
+ /** An open point as the file holds it: an empty answer is no answer. */
263
+ const settledPoint = (point: OpenPoint): OpenPoint =>
264
+ (point.answer ?? "").trim().length === 0
265
+ ? OpenPoint.make({ number: point.number, question: point.question })
266
+ : OpenPoint.make({
267
+ number: point.number,
268
+ question: point.question,
269
+ answer: (point.answer ?? "").trim()
270
+ })
271
+
272
+ export const parseMeeting = Effect.fn("@llm4ts/flow/Roundtable.parseMeeting")(function* (
273
+ markdown: string,
274
+ path?: string
275
+ ): Effect.fn.Return<Meeting, MeetingInvalid> {
276
+ const violations: Array<string> = []
277
+ let id: string | undefined
278
+ let status: MeetingStatus | undefined
279
+ let drafts = 0
280
+ const header: Record<string, string> = {}
281
+ let attendees: Array<string> | undefined
282
+ const agenda: Array<{ title: string; question: string }> = []
283
+ const points = makeOpenPointsCollector()
284
+ const feedback: Array<string> = []
285
+ let current: MeetingSection | "header" | "unknown" = "header"
286
+ let fenced = false
287
+ const lines = markdown.split(/\r?\n/)
288
+ for (let index = 0; index < lines.length; index += 1) {
289
+ const number = index + 1
290
+ const raw = lines[index] ?? ""
291
+ const trimmed = raw.trim()
292
+ if (trimmed.startsWith("```")) {
293
+ fenced = !fenced
294
+ if (current === "feedback") feedback.push(raw)
295
+ continue
296
+ }
297
+ if (!fenced && trimmed.startsWith("## ")) {
298
+ const title = trimmed.slice(3).trim()
299
+ const key = meetingSectionOf(title)
300
+ if (key === undefined) {
301
+ violations.push(`line ${number}: unknown section "${title}"`)
302
+ current = "unknown"
303
+ } else {
304
+ current = key
305
+ }
306
+ continue
307
+ }
308
+ if (current === "feedback") {
309
+ feedback.push(raw)
310
+ continue
311
+ }
312
+ if (fenced || trimmed.length === 0 || current === "unknown") continue
313
+ if (current === "header") {
314
+ const title = /^#\s+Roundtable:\s*(.+)$/i.exec(trimmed)
315
+ if (title?.[1] !== undefined) {
316
+ id = title[1].trim()
317
+ continue
318
+ }
319
+ const field = headerLine.exec(trimmed)
320
+ if (field?.[1] === undefined) continue
321
+ const name = field[1].toLowerCase()
322
+ const value = (field[2] ?? "").trim()
323
+ if (name === "status") {
324
+ if (isMeetingStatus(value)) {
325
+ status = value
326
+ } else {
327
+ violations.push(
328
+ `line ${number}: Status is one of ${MeetingStatus.literals.join(", ")}, got: ${value}`
329
+ )
330
+ status = "discussing"
331
+ }
332
+ } else if (name === "attendees") {
333
+ attendees = []
334
+ for (const entry of value.split(",")) {
335
+ const persona = entry.trim()
336
+ if (persona.length === 0) continue
337
+ if (attendees.includes(persona)) {
338
+ violations.push(`line ${number}: attendee listed twice: ${persona}`)
339
+ } else {
340
+ attendees.push(persona)
341
+ }
342
+ }
343
+ } else if (name === "drafts") {
344
+ if (/^\d+$/.test(value)) {
345
+ drafts = Number.parseInt(value, 10)
346
+ } else {
347
+ violations.push(`line ${number}: Drafts is a whole number, got: ${value}`)
348
+ }
349
+ } else {
350
+ header[name] = value
351
+ }
352
+ continue
353
+ }
354
+ if (current === "answers") {
355
+ const problem = points.add(number, trimmed)
356
+ if (problem !== undefined) violations.push(problem)
357
+ continue
358
+ }
359
+ const item = /^(\d+)\.\s+(.*)$/.exec(trimmed)
360
+ if (item !== null) {
361
+ const [title, question] = splitFirst(item[2] ?? "", dash.trim())
362
+ if (question === undefined || title.length === 0) {
363
+ violations.push(
364
+ `line ${number}: an agenda item is \`1. <title>${dash}<question>\`, got: ${trimmed}`
365
+ )
366
+ } else {
367
+ agenda.push({ title, question })
368
+ }
369
+ continue
370
+ }
371
+ const last = agenda.at(-1)
372
+ if (/^\s/.test(raw) && last !== undefined) {
373
+ last.question = `${last.question} ${trimmed}`.trim()
374
+ continue
375
+ }
376
+ violations.push(
377
+ `line ${number}: an agenda item is \`1. <title>${dash}<question>\`, got: ${trimmed}`
378
+ )
379
+ }
380
+ if (id === undefined) violations.push("missing the `# Roundtable: <id>` title")
381
+ if (status === undefined) violations.push("missing the `Status:` line")
382
+ if (header.topic === undefined) violations.push("missing the `Topic:` line")
383
+ if (header.goal === undefined) violations.push("missing the `Goal:` line")
384
+ if (attendees === undefined) violations.push("missing the `Attendees:` line")
385
+ else if (attendees.length === 0) violations.push("the `Attendees:` line names no persona")
386
+ if (fenced) violations.push("a code fence in the feedback is never closed")
387
+ if (
388
+ violations.length > 0 ||
389
+ id === undefined ||
390
+ status === undefined ||
391
+ attendees === undefined
392
+ ) {
393
+ return yield* MeetingInvalid.make({ ...(path === undefined ? {} : { path }), violations })
394
+ }
395
+ return Meeting.make({
396
+ id,
397
+ status,
398
+ topic: header.topic ?? "",
399
+ goal: header.goal ?? "",
400
+ attendees,
401
+ agenda: agenda.map((entry, index) =>
402
+ AgendaItem.make({ number: index + 1, title: entry.title, question: entry.question })
403
+ ),
404
+ answers: points.points.map(settledPoint),
405
+ feedback: feedback.join("\n").trim(),
406
+ drafts
407
+ })
408
+ })
409
+
410
+ // ---- Turns --------------------------------------------------------------------
411
+
412
+ /** The person who called the meeting, present through the meeting file only. */
413
+ export const ownerSpeaker = "Owner"
414
+ /** The code that runs the meeting; it speaks only to record a judgment. */
415
+ export const chairSpeaker = "Chair"
416
+
417
+ /** The kinds of turn, shared with the `RoundtableTurn` event the dashboard reads. */
418
+ export const TurnKind = RoundtableTurnKind
419
+ export type TurnKind = RoundtableTurnKind
420
+
421
+ /**
422
+ * One contribution on one agenda item in one round. `answers` names the
423
+ * turns it responds to: what an objection objects to, what an agreement
424
+ * closes, what a reply replies to. `blocking` is read on objections only,
425
+ * `needsHuman` and `assumption` on questions only.
426
+ */
427
+ export class Turn extends Schema.Class<Turn>("Turn")({
428
+ id: Schema.Int,
429
+ round: Schema.Int,
430
+ item: Schema.Int,
431
+ speaker: Schema.String,
432
+ kind: TurnKind,
433
+ answers: Schema.Array(Schema.Int),
434
+ blocking: Schema.Boolean,
435
+ needsHuman: Schema.Boolean,
436
+ assumption: Schema.optionalKey(Schema.String),
437
+ body: Schema.String
438
+ }) {}
439
+
440
+ const turnLine = Schema.fromJsonString(Turn)
441
+ const encodeTurnLine = Schema.encodeSync(turnLine)
442
+ const decodeTurnLine = Schema.decodeUnknownOption(turnLine)
443
+
444
+ /** One line of `turns.jsonl`. */
445
+ export const encodeTurn = (turn: Turn): string => encodeTurnLine(turn)
446
+
447
+ /** `turns.jsonl` back into turns, ids checked to run 1, 2, 3 … in file order. */
448
+ export const decodeTurns = Effect.fn("@llm4ts/flow/Roundtable.decodeTurns")(function* (
449
+ text: string,
450
+ path?: string
451
+ ): Effect.fn.Return<ReadonlyArray<Turn>, TurnsInvalid> {
452
+ const violations: Array<string> = []
453
+ const turns: Array<Turn> = []
454
+ const lines = text.split(/\r?\n/)
455
+ for (let index = 0; index < lines.length; index += 1) {
456
+ const line = (lines[index] ?? "").trim()
457
+ if (line.length === 0) continue
458
+ const decoded = decodeTurnLine(line)
459
+ if (Option.isNone(decoded)) {
460
+ violations.push(`line ${index + 1}: not a JSON turn: ${line.slice(0, 80)}`)
461
+ continue
462
+ }
463
+ const expected = turns.length + 1
464
+ if (decoded.value.id !== expected) {
465
+ violations.push(
466
+ `line ${index + 1}: turn id ${decoded.value.id} where ${expected} was expected`
467
+ )
468
+ }
469
+ turns.push(decoded.value)
470
+ }
471
+ if (violations.length > 0) {
472
+ return yield* TurnsInvalid.make({ ...(path === undefined ? {} : { path }), violations })
473
+ }
474
+ return turns
475
+ })
476
+
477
+ /** What an attendee returns for a turn; the chair stamps id, round, item and speaker. */
478
+ export class TurnProposal extends Schema.Class<TurnProposal>("TurnProposal")({
479
+ kind: TurnKind,
480
+ answers: Schema.Array(Schema.Int),
481
+ blocking: Schema.Boolean,
482
+ needsHuman: Schema.Boolean,
483
+ assumption: Schema.optionalKey(Schema.String),
484
+ body: Schema.String
485
+ }) {}
486
+
487
+ export const turnProposalJsonSchema: JsonSchema = {
488
+ type: "object",
489
+ properties: {
490
+ kind: { type: "string", enum: [...TurnKind.literals] },
491
+ answers: { type: "array", items: { type: "integer" } },
492
+ blocking: { type: "boolean" },
493
+ needsHuman: { type: "boolean" },
494
+ assumption: { type: "string" },
495
+ body: { type: "string" }
496
+ },
497
+ required: ["kind", "answers", "blocking", "needsHuman", "body"]
498
+ }
499
+
500
+ export interface TurnAt {
501
+ readonly id: number
502
+ readonly round: number
503
+ readonly item: number
504
+ readonly speaker: string
505
+ readonly existing: ReadonlyArray<Turn>
506
+ }
507
+
508
+ /**
509
+ * A proposal as the record can hold it: only earlier turns of the same item
510
+ * can be answered, only an objection blocks, only a question needs the owner
511
+ * and carries an assumption.
512
+ */
513
+ export const assembleTurn = (proposal: TurnProposal, at: TurnAt): Turn => {
514
+ const known = new Set(
515
+ at.existing.filter((turn) => turn.item === at.item && turn.id < at.id).map((turn) => turn.id)
516
+ )
517
+ const answers = [...new Set(proposal.answers)].filter((id) => known.has(id)).sort((a, b) => a - b)
518
+ const needsHuman = proposal.kind === "question" && proposal.needsHuman
519
+ const assumption = needsHuman ? (proposal.assumption ?? "").trim() : ""
520
+ return Turn.make({
521
+ id: at.id,
522
+ round: at.round,
523
+ item: at.item,
524
+ speaker: at.speaker,
525
+ kind: proposal.kind,
526
+ answers,
527
+ blocking: proposal.kind === "objection" && proposal.blocking,
528
+ needsHuman,
529
+ ...(assumption.length === 0 ? {} : { assumption }),
530
+ body: proposal.body.trim()
531
+ })
532
+ }
533
+
534
+ // ---- The agenda ---------------------------------------------------------------
535
+
536
+ export class AgendaProposal extends Schema.Class<AgendaProposal>("AgendaProposal")({
537
+ items: Schema.Array(Schema.Struct({ title: Schema.String, question: Schema.String }))
538
+ }) {}
539
+
540
+ export const agendaProposalJsonSchema: JsonSchema = {
541
+ type: "object",
542
+ properties: {
543
+ items: {
544
+ type: "array",
545
+ items: {
546
+ type: "object",
547
+ properties: { title: { type: "string" }, question: { type: "string" } },
548
+ required: ["title", "question"]
549
+ }
550
+ }
551
+ },
552
+ required: ["items"]
553
+ }
554
+
555
+ export const minAgendaItems = 3
556
+ export const maxAgendaItems = 7
557
+
558
+ const oneLine = (text: string): string => text.replace(/\s+/g, " ").trim()
559
+
560
+ /** The proposed items as the file can hold them: one line each, the title free of the separator, at most the cap. */
561
+ export const assembleAgenda = (proposal: AgendaProposal): ReadonlyArray<AgendaItem> =>
562
+ proposal.items.slice(0, maxAgendaItems).map((item, index) =>
563
+ AgendaItem.make({
564
+ number: index + 1,
565
+ title: oneLine(item.title).replaceAll(dash.trim(), "-"),
566
+ question: oneLine(item.question)
567
+ })
568
+ )
569
+
570
+ /** The fixed item after the proposed ones: does what was settled reach the goal? */
571
+ export const closingItem = (meeting: Meeting): AgendaItem =>
572
+ AgendaItem.make({
573
+ number: meeting.agenda.length + 1,
574
+ title: "Goal check",
575
+ question: "Does the solution, as settled above, reach the goal?"
576
+ })
577
+
578
+ /** The synthetic item the drafts are reviewed under, once per draft. */
579
+ export const reviewItem = (meeting: Meeting): AgendaItem =>
580
+ AgendaItem.make({
581
+ number: meeting.agenda.length + 2,
582
+ title: "Review of the drafts",
583
+ question: "Do the outline and the plan say what the room settled, and reach the goal?"
584
+ })
585
+
586
+ /** The items the discussion goes through, in order: the agenda, then the goal check. */
587
+ export const discussionItems = (meeting: Meeting): ReadonlyArray<AgendaItem> => [
588
+ ...meeting.agenda,
589
+ closingItem(meeting)
590
+ ]
591
+
592
+ export const itemOf = (meeting: Meeting, number: number): AgendaItem | undefined =>
593
+ [...discussionItems(meeting), reviewItem(meeting)].find((item) => item.number === number)
594
+
595
+ // ---- What the turns imply -----------------------------------------------------
596
+
597
+ const onItem = (turns: ReadonlyArray<Turn>, item: number): ReadonlyArray<Turn> =>
598
+ turns.filter((turn) => turn.item === item)
599
+
600
+ /** The objector, or the chair, agreed later that the objection is answered. */
601
+ const isResolved = (turns: ReadonlyArray<Turn>, objection: Turn): boolean =>
602
+ turns.some(
603
+ (turn) =>
604
+ turn.id > objection.id &&
605
+ turn.kind === "agreement" &&
606
+ turn.answers.includes(objection.id) &&
607
+ (turn.speaker === objection.speaker || turn.speaker === chairSpeaker)
608
+ )
609
+
610
+ /** Blocking objections on the item nobody entitled to has closed. */
611
+ export const openObjections = (turns: ReadonlyArray<Turn>, item: number): ReadonlyArray<Turn> =>
612
+ onItem(turns, item).filter(
613
+ (turn) => turn.kind === "objection" && turn.blocking && !isResolved(turns, turn)
614
+ )
615
+
616
+ /** Objections to a proposal that do not block it: recorded with the decision, never resolved. */
617
+ export const dissent = (turns: ReadonlyArray<Turn>, proposal: Turn): ReadonlyArray<Turn> =>
618
+ turns.filter(
619
+ (turn) => turn.kind === "objection" && !turn.blocking && turn.answers.includes(proposal.id)
620
+ )
621
+
622
+ /** Questions for the room with no later turn answering them. */
623
+ export const openQuestions = (turns: ReadonlyArray<Turn>, item: number): ReadonlyArray<Turn> =>
624
+ onItem(turns, item).filter(
625
+ (turn) =>
626
+ turn.kind === "question" &&
627
+ !turn.needsHuman &&
628
+ !turns.some(
629
+ (reply) => reply.id > turn.id && reply.kind !== "pass" && reply.answers.includes(turn.id)
630
+ )
631
+ )
632
+
633
+ /** Questions for the owner the owner has not answered yet. */
634
+ export const parkedQuestions = (turns: ReadonlyArray<Turn>): ReadonlyArray<Turn> =>
635
+ turns.filter(
636
+ (turn) =>
637
+ turn.kind === "question" &&
638
+ turn.needsHuman &&
639
+ !turns.some((reply) => reply.speaker === ownerSpeaker && reply.answers.includes(turn.id))
640
+ )
641
+
642
+ const roundComplete = (
643
+ meeting: Meeting,
644
+ turns: ReadonlyArray<Turn>,
645
+ item: number,
646
+ round: number
647
+ ): boolean =>
648
+ meeting.attendees.every((attendee) =>
649
+ turns.some((turn) => turn.item === item && turn.round === round && turn.speaker === attendee)
650
+ )
651
+
652
+ const maxRound = (turns: ReadonlyArray<Turn>, item: number): number =>
653
+ onItem(turns, item).reduce((most, turn) => Math.max(most, turn.round), 0)
654
+
655
+ /** The round the next turn on the item belongs to: the open one, or a new one after a complete one. */
656
+ export const currentRound = (
657
+ meeting: Meeting,
658
+ turns: ReadonlyArray<Turn>,
659
+ item: number
660
+ ): number => {
661
+ const most = maxRound(turns, item)
662
+ return most > 0 && roundComplete(meeting, turns, item, most) ? most + 1 : Math.max(most, 1)
663
+ }
664
+
665
+ export interface Caps {
666
+ /** Complete rounds an item gets before it is left open; an owner turn grants one more. */
667
+ readonly roundsPerItem: number
668
+ /** Turns in the whole discussion before drafting starts whatever is open. */
669
+ readonly maxTurns: number
670
+ }
671
+
672
+ export const defaultCaps: Caps = { roundsPerItem: 3, maxTurns: 40 }
673
+
674
+ export interface ItemState {
675
+ /** Complete rounds so far. */
676
+ readonly rounds: number
677
+ /** The round the next turn belongs to. */
678
+ readonly round: number
679
+ /** A complete round ended with no open blocking objection and no open question. */
680
+ readonly settled: boolean
681
+ /** Not settled, and out of rounds. */
682
+ readonly capped: boolean
683
+ }
684
+
685
+ export const itemState = (
686
+ meeting: Meeting,
687
+ turns: ReadonlyArray<Turn>,
688
+ item: number,
689
+ caps: Caps
690
+ ): ItemState => {
691
+ const most = maxRound(turns, item)
692
+ let rounds = 0
693
+ for (let round = 1; round <= most; round += 1) {
694
+ if (roundComplete(meeting, turns, item, round)) rounds += 1
695
+ }
696
+ const latestComplete = most > 0 && roundComplete(meeting, turns, item, most)
697
+ const open = openObjections(turns, item).length > 0 || openQuestions(turns, item).length > 0
698
+ const settled = latestComplete && !open
699
+ const ownerTurns = onItem(turns, item).filter((turn) => turn.speaker === ownerSpeaker).length
700
+ return {
701
+ rounds,
702
+ round: currentRound(meeting, turns, item),
703
+ settled,
704
+ capped: !settled && latestComplete && rounds >= caps.roundsPerItem + ownerTurns
705
+ }
706
+ }
707
+
708
+ export interface Decision {
709
+ readonly item: AgendaItem
710
+ readonly proposal: Turn
711
+ readonly dissent: ReadonlyArray<Turn>
712
+ }
713
+
714
+ /**
715
+ * Silence is consent: on an item that is settled or capped, every proposal
716
+ * with no open blocking objection against it is a decision, and the soft
717
+ * objections to it are its dissent.
718
+ */
719
+ export const decisions = (
720
+ meeting: Meeting,
721
+ turns: ReadonlyArray<Turn>,
722
+ caps: Caps
723
+ ): ReadonlyArray<Decision> =>
724
+ discussionItems(meeting).flatMap((item) => {
725
+ const state = itemState(meeting, turns, item.number, caps)
726
+ if (!state.settled && !state.capped) return []
727
+ const blocking = openObjections(turns, item.number)
728
+ return onItem(turns, item.number)
729
+ .filter(
730
+ (turn) =>
731
+ turn.kind === "proposal" &&
732
+ !blocking.some((objection) => objection.answers.includes(turn.id))
733
+ )
734
+ .map((proposal) => ({ item, proposal, dissent: dissent(turns, proposal) }))
735
+ })
736
+
737
+ // ---- The chair ----------------------------------------------------------------
738
+
739
+ export interface OwnerAnswer {
740
+ readonly question: Turn
741
+ readonly answer: string
742
+ }
743
+
744
+ export type ChairAction =
745
+ | { readonly _tag: "ProposeAgenda"; readonly speaker: string }
746
+ | { readonly _tag: "OpenDiscussion" }
747
+ | {
748
+ readonly _tag: "RecordOwner"
749
+ readonly answers: ReadonlyArray<OwnerAnswer>
750
+ readonly feedback: string
751
+ }
752
+ | {
753
+ readonly _tag: "Judge"
754
+ readonly item: AgendaItem
755
+ readonly objection: Turn
756
+ readonly reply: Turn
757
+ }
758
+ | {
759
+ readonly _tag: "Speak"
760
+ readonly item: AgendaItem
761
+ readonly round: number
762
+ readonly speaker: string
763
+ /** Open objections and questions aimed at this speaker's earlier turns. */
764
+ readonly addressed: ReadonlyArray<Turn>
765
+ }
766
+ | { readonly _tag: "Draft" }
767
+ | { readonly _tag: "Finalize"; readonly objections: ReadonlyArray<Turn> }
768
+ | { readonly _tag: "Done" }
769
+ | { readonly _tag: "Refused"; readonly reason: string }
770
+
771
+ /** Answers written in the file for parked questions, and feedback, not yet recorded as Owner turns. */
772
+ const pendingOwner = (
773
+ meeting: Meeting,
774
+ turns: ReadonlyArray<Turn>
775
+ ): { answers: ReadonlyArray<OwnerAnswer>; feedback: string } => {
776
+ const parked = parkedQuestions(turns)
777
+ const answers = meeting.answers.flatMap((point) => {
778
+ const answer = (point.answer ?? "").trim()
779
+ const question = parked.find((turn) => turn.id === point.number)
780
+ return answer.length === 0 || question === undefined ? [] : [{ question, answer }]
781
+ })
782
+ return { answers, feedback: meeting.feedback.trim() }
783
+ }
784
+
785
+ /** An open objection the objector has spoken past without closing, since the last reply to it. */
786
+ const judgeNeeded = (
787
+ turns: ReadonlyArray<Turn>,
788
+ item: number
789
+ ): { objection: Turn; reply: Turn } | undefined => {
790
+ for (const objection of openObjections(turns, item)) {
791
+ const reply = turns
792
+ .filter(
793
+ (turn) =>
794
+ turn.id > objection.id &&
795
+ turn.kind !== "pass" &&
796
+ turn.answers.includes(objection.id) &&
797
+ turn.speaker !== objection.speaker &&
798
+ turn.speaker !== chairSpeaker
799
+ )
800
+ .at(-1)
801
+ if (reply === undefined) continue
802
+ const objectorSpoke = turns.some(
803
+ (turn) => turn.id > reply.id && turn.item === item && turn.speaker === objection.speaker
804
+ )
805
+ const chairRuled = turns.some(
806
+ (turn) =>
807
+ turn.id > reply.id && turn.speaker === chairSpeaker && turn.answers.includes(objection.id)
808
+ )
809
+ if (objectorSpoke && !chairRuled) return { objection, reply }
810
+ }
811
+ return undefined
812
+ }
813
+
814
+ const speakOn = (
815
+ meeting: Meeting,
816
+ turns: ReadonlyArray<Turn>,
817
+ item: AgendaItem,
818
+ round: number
819
+ ): ChairAction => {
820
+ const speaker =
821
+ meeting.attendees.find(
822
+ (attendee) =>
823
+ !turns.some(
824
+ (turn) => turn.item === item.number && turn.round === round && turn.speaker === attendee
825
+ )
826
+ ) ??
827
+ meeting.attendees[0] ??
828
+ ""
829
+ const mine = new Set(
830
+ onItem(turns, item.number)
831
+ .filter((turn) => turn.speaker === speaker)
832
+ .map((turn) => turn.id)
833
+ )
834
+ const addressed = [...openObjections(turns, item.number), ...openQuestions(turns, item.number)]
835
+ .filter((turn) => turn.answers.some((id) => mine.has(id)))
836
+ .sort((left, right) => left.id - right.id)
837
+ return { _tag: "Speak", item, round, speaker, addressed }
838
+ }
839
+
840
+ /**
841
+ * What the run does next, from the files alone. The owner's words come
842
+ * first; then the agenda, the discussion item by item (a judgment before a
843
+ * turn when a stalemate needs breaking), the drafting, one review round per
844
+ * draft, and the end.
845
+ */
846
+ export const nextAction = (
847
+ meeting: Meeting,
848
+ turns: ReadonlyArray<Turn>,
849
+ caps: Caps
850
+ ): ChairAction => {
851
+ if (meeting.attendees.length === 0) {
852
+ return { _tag: "Refused", reason: "the meeting has no attendees" }
853
+ }
854
+ if (meeting.status === "agenda-proposed") {
855
+ return meeting.agenda.length === 0
856
+ ? { _tag: "ProposeAgenda", speaker: meeting.attendees[0] ?? "" }
857
+ : { _tag: "OpenDiscussion" }
858
+ }
859
+ if (meeting.agenda.length === 0) {
860
+ return {
861
+ _tag: "Refused",
862
+ reason:
863
+ "the meeting is discussing but has no agenda: delete the folder or set Status: agenda-proposed"
864
+ }
865
+ }
866
+ const owner = pendingOwner(meeting, turns)
867
+ if (owner.answers.length > 0 || owner.feedback.length > 0) {
868
+ return { _tag: "RecordOwner", answers: owner.answers, feedback: owner.feedback }
869
+ }
870
+ if (meeting.status === "done") return { _tag: "Done" }
871
+ if (meeting.status === "drafting") return { _tag: "Draft" }
872
+ if (meeting.status === "reviewing") {
873
+ const review = reviewItem(meeting)
874
+ const state = itemState(meeting, turns, review.number, caps)
875
+ return state.rounds >= meeting.drafts
876
+ ? { _tag: "Finalize", objections: openObjections(turns, review.number) }
877
+ : speakOn(meeting, turns, review, state.round)
878
+ }
879
+ if (turns.length >= caps.maxTurns) return { _tag: "Draft" }
880
+ for (const item of discussionItems(meeting)) {
881
+ const state = itemState(meeting, turns, item.number, caps)
882
+ if (state.settled || state.capped) continue
883
+ const stalemate = judgeNeeded(turns, item.number)
884
+ if (stalemate !== undefined) return { _tag: "Judge", item, ...stalemate }
885
+ return speakOn(meeting, turns, item, state.round)
886
+ }
887
+ return { _tag: "Draft" }
888
+ }
889
+
890
+ /** The owner's answers and feedback as turns: an answer on its question's item, feedback on the review item. */
891
+ export const recordOwner = (
892
+ meeting: Meeting,
893
+ turns: ReadonlyArray<Turn>,
894
+ action: Extract<ChairAction, { _tag: "RecordOwner" }>
895
+ ): ReadonlyArray<Turn> => {
896
+ const recorded: Array<Turn> = []
897
+ const ownerTurn = (item: number, answers: ReadonlyArray<number>, body: string): Turn => {
898
+ const all = [...turns, ...recorded]
899
+ return Turn.make({
900
+ id: all.length + 1,
901
+ round: currentRound(meeting, all, item),
902
+ item,
903
+ speaker: ownerSpeaker,
904
+ kind: "position",
905
+ answers: [...answers],
906
+ blocking: false,
907
+ needsHuman: false,
908
+ body
909
+ })
910
+ }
911
+ for (const { question, answer } of action.answers) {
912
+ recorded.push(ownerTurn(question.item, [question.id], answer))
913
+ }
914
+ if (action.feedback.length > 0) {
915
+ recorded.push(ownerTurn(reviewItem(meeting).number, [], action.feedback))
916
+ }
917
+ return recorded
918
+ }
919
+
920
+ // ---- Views ----------------------------------------------------------------------
921
+
922
+ const refs = (ids: ReadonlyArray<number>): string => ids.map((id) => `#${id}`).join(", ")
923
+
924
+ const firstLine = (text: string): string => {
925
+ const line = oneLine(text.split(/\r?\n/)[0] ?? "")
926
+ return line.length > 80 ? `${line.slice(0, 77)}...` : line
927
+ }
928
+
929
+ /** One turn as the notes show it: `**PO** #4 objection to #2, blocking: …`. */
930
+ export const renderTurn = (turn: Turn): string => {
931
+ if (turn.kind === "pass") return `**${turn.speaker}** #${turn.id} passes.`
932
+ const owner = turn.needsHuman ? " for the owner" : ""
933
+ const assuming = turn.assumption === undefined ? "" : ` (assuming: ${turn.assumption})`
934
+ const linked =
935
+ turn.answers.length === 0
936
+ ? ""
937
+ : turn.kind === "objection"
938
+ ? ` to ${refs(turn.answers)}`
939
+ : turn.kind === "agreement"
940
+ ? ` with ${refs(turn.answers)}`
941
+ : ` answering ${refs(turn.answers)}`
942
+ const blocking = turn.kind === "objection" && turn.blocking ? ", blocking" : ""
943
+ return `**${turn.speaker}** #${turn.id} ${turn.kind}${owner}${assuming}${linked}${blocking}: ${turn.body}`
944
+ }
945
+
946
+ const itemFooter = (state: ItemState, turns: number): string =>
947
+ state.settled
948
+ ? "Settled."
949
+ : state.capped
950
+ ? `Left open after ${state.rounds} rounds.`
951
+ : turns === 0
952
+ ? "Not discussed yet."
953
+ : "In progress."
954
+
955
+ /** `discussion.md`: the record as meeting notes, item by item, round by round. */
956
+ export const renderDiscussion = (
957
+ meeting: Meeting,
958
+ turns: ReadonlyArray<Turn>,
959
+ caps: Caps
960
+ ): string => {
961
+ const review = reviewItem(meeting)
962
+ const items = [
963
+ ...discussionItems(meeting),
964
+ ...(onItem(turns, review.number).length === 0 ? [] : [review])
965
+ ]
966
+ return [
967
+ `# Discussion: ${meeting.topic}`,
968
+ "",
969
+ `Goal: ${meeting.goal}`,
970
+ `Attendees: ${meeting.attendees.join(", ")}`,
971
+ "",
972
+ ...items.flatMap((item) => {
973
+ const own = onItem(turns, item.number)
974
+ const rounds = [...new Set(own.map((turn) => turn.round))].sort((a, b) => a - b)
975
+ const state = itemState(meeting, turns, item.number, caps)
976
+ return [
977
+ `## ${renderAgendaItem(item)}`,
978
+ "",
979
+ ...rounds.flatMap((round) => [
980
+ `### Round ${round}`,
981
+ "",
982
+ ...own.filter((turn) => turn.round === round).flatMap((turn) => [renderTurn(turn), ""])
983
+ ]),
984
+ item.number === review.number
985
+ ? state.round > state.rounds
986
+ ? "In progress."
987
+ : "Reviewed."
988
+ : itemFooter(state, own.length),
989
+ ""
990
+ ]
991
+ })
992
+ ].join("\n")
993
+ }
994
+
995
+ /** `decisions.md`: one ADR-shaped entry per proposal that stood, with its dissent. */
996
+ export const renderDecisions = (
997
+ meeting: Meeting,
998
+ turns: ReadonlyArray<Turn>,
999
+ caps: Caps
1000
+ ): string => {
1001
+ const decided = decisions(meeting, turns, caps)
1002
+ return [
1003
+ `# Decisions: ${meeting.topic}`,
1004
+ "",
1005
+ ...(decided.length === 0
1006
+ ? ["No decision was reached.", ""]
1007
+ : decided.flatMap((decision, index) => [
1008
+ `## ${index + 1}. ${firstLine(decision.proposal.body)}`,
1009
+ "",
1010
+ `Item: ${renderAgendaItem(decision.item)}`,
1011
+ `Proposed by: ${decision.proposal.speaker} (#${decision.proposal.id}), round ${decision.proposal.round}`,
1012
+ "",
1013
+ ...prose(decision.proposal.body),
1014
+ "",
1015
+ ...(decision.dissent.length === 0
1016
+ ? ["No dissent."]
1017
+ : [
1018
+ "Dissent:",
1019
+ ...decision.dissent.map(
1020
+ (turn) => `- ${turn.speaker} (#${turn.id}): ${oneLine(turn.body)}`
1021
+ )
1022
+ ]),
1023
+ ""
1024
+ ]))
1025
+ ].join("\n")
1026
+ }
1027
+
1028
+ /** `open-questions.md`: what the owner owes the room, and what the room left open. */
1029
+ export const renderOpenQuestions = (
1030
+ meeting: Meeting,
1031
+ turns: ReadonlyArray<Turn>,
1032
+ caps: Caps
1033
+ ): string => {
1034
+ const parked = parkedQuestions(turns)
1035
+ const open = discussionItems(meeting).flatMap((item) => {
1036
+ const state = itemState(meeting, turns, item.number, caps)
1037
+ if (state.settled) return []
1038
+ const own = onItem(turns, item.number)
1039
+ const why = state.capped
1040
+ ? `left open after ${state.rounds} rounds`
1041
+ : own.length === 0
1042
+ ? "not discussed"
1043
+ : "in progress"
1044
+ return [
1045
+ `- ${renderAgendaItem(item)}: ${why}`,
1046
+ ...openObjections(turns, item.number).map(
1047
+ (turn) =>
1048
+ ` - #${turn.id} ${turn.speaker} objects${turn.answers.length === 0 ? "" : ` to ${refs(turn.answers)}`}, blocking: ${oneLine(turn.body)}`
1049
+ ),
1050
+ ...openQuestions(turns, item.number).map(
1051
+ (turn) => ` - #${turn.id} ${turn.speaker} asks: ${oneLine(turn.body)}`
1052
+ )
1053
+ ]
1054
+ })
1055
+ return [
1056
+ `# Open questions: ${meeting.topic}`,
1057
+ "",
1058
+ "## For the owner",
1059
+ "",
1060
+ ...(parked.length === 0
1061
+ ? ["None."]
1062
+ : parked.map(
1063
+ (turn) =>
1064
+ `- #${turn.id} ${turn.speaker}, item ${turn.item}: ${oneLine(turn.body)}${turn.assumption === undefined ? "" : ` (assuming: ${turn.assumption})`}`
1065
+ )),
1066
+ "",
1067
+ "## Left open by the room",
1068
+ "",
1069
+ ...(open.length === 0 ? ["Every item settled."] : open),
1070
+ ""
1071
+ ].join("\n")
1072
+ }
1073
+
1074
+ /** The meeting file with every parked question listed for the owner; answers already there are kept. */
1075
+ export const syncAnswers = (meeting: Meeting, turns: ReadonlyArray<Turn>): Meeting => {
1076
+ const listed = new Set(meeting.answers.map((point) => point.number))
1077
+ const missing = parkedQuestions(turns)
1078
+ .filter((turn) => !listed.has(turn.id))
1079
+ .map((turn) =>
1080
+ OpenPoint.make({ number: turn.id, question: `${oneLine(turn.body)} (${turn.speaker})` })
1081
+ )
1082
+ return Meeting.make({
1083
+ ...meeting,
1084
+ answers: [...meeting.answers, ...missing].sort((left, right) => left.number - right.number)
1085
+ })
1086
+ }
1087
+
1088
+ export interface Briefing {
1089
+ /** Decisions of the items before this one. */
1090
+ readonly decisions: ReadonlyArray<Decision>
1091
+ /** Questions parked for the owner so far, with their assumptions. */
1092
+ readonly parked: ReadonlyArray<Turn>
1093
+ /** Every turn on the item under discussion. */
1094
+ readonly current: ReadonlyArray<Turn>
1095
+ }
1096
+
1097
+ /** What an attendee reads before speaking on an item (ADR 0046, decision 8). */
1098
+ export const briefingFor = (
1099
+ meeting: Meeting,
1100
+ turns: ReadonlyArray<Turn>,
1101
+ item: number,
1102
+ caps: Caps
1103
+ ): Briefing => ({
1104
+ decisions: decisions(meeting, turns, caps).filter((decision) => decision.item.number < item),
1105
+ parked: parkedQuestions(turns),
1106
+ current: onItem(turns, item)
1107
+ })