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