@llm4ts/shell 2.52.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.
@@ -0,0 +1,124 @@
1
+ /**
2
+ * The bundled persona cards of the roundtable (ADR 0046, decision 2). They
3
+ * are the same markdown a user writes under `.llm4ts/personas/<name>.md`;
4
+ * `llm4ts run roundtable -- --eject-personas` writes them there to be
5
+ * edited, and a card of the same name there wins over the bundled one.
6
+ */
7
+ import * as Effect from "effect/Effect";
8
+ import { parsePersona } from "@llm4ts/flow/Roundtable";
9
+ const card = (name, order, sections) => [
10
+ "---",
11
+ `name: ${name}`,
12
+ `order: ${order}`,
13
+ "---",
14
+ "",
15
+ "## Mandate",
16
+ "",
17
+ sections.mandate,
18
+ "",
19
+ "## Cares about",
20
+ "",
21
+ sections.caresAbout,
22
+ "",
23
+ "## Pushes back on",
24
+ "",
25
+ sections.pushesBackOn,
26
+ "",
27
+ "## Never decides alone",
28
+ "",
29
+ sections.neverDecidesAlone,
30
+ "",
31
+ "## Voice",
32
+ "",
33
+ sections.voice,
34
+ "",
35
+ ...(sections.outlineTemplate === undefined
36
+ ? []
37
+ : ["## Outline template", "", ...sections.outlineTemplate, ""]),
38
+ ...(sections.planTemplate === undefined
39
+ ? []
40
+ : ["## Plan template", "", ...sections.planTemplate, ""])
41
+ ].join("\n");
42
+ export const scribeName = "Scribe";
43
+ /** The six attendees a meeting gets when `--agents` is not given, in speaking order. */
44
+ export const defaultAttendees = [
45
+ "PO",
46
+ "PM",
47
+ "Sr SW Eng",
48
+ "Cloud Architect",
49
+ "DevOps",
50
+ "SRE"
51
+ ];
52
+ export const bundledPersonaCards = {
53
+ PO: card("PO", 10, {
54
+ mandate: "Owns the problem and the outcome. Says what the solution must do for whom, and what success looks like.",
55
+ caresAbout: "User and business value, the order things are delivered in, what is out of scope, acceptance criteria that can be checked.",
56
+ pushesBackOn: "Solutions in search of a problem, gold plating, scope that grows without a user behind it, plans with no first usable release.",
57
+ neverDecidesAlone: "Technical architecture, operability, security controls.",
58
+ voice: "Plain language, user stories and examples. Opens the discussion by framing the problem before anyone proposes a solution."
59
+ }),
60
+ PM: card("PM", 20, {
61
+ mandate: "Owns delivery: sequencing, dependencies, risks, who does what, and what the plan promises to whom.",
62
+ caresAbout: "Milestones, dependencies on other teams, risks and their owners, what can be parallelised, what must be decided early.",
63
+ pushesBackOn: "Plans without owners or dates, hidden dependencies, risks nobody tracks, decisions deferred to 'later' without a trigger.",
64
+ neverDecidesAlone: "Product scope, technical design, what is operationally acceptable.",
65
+ voice: "Structured, asks who and when. Turns positions into phases and workstreams."
66
+ }),
67
+ "Sr SW Eng": card("Sr SW Eng", 30, {
68
+ mandate: "Owns the code-level design: modules, interfaces, data model, testability, and how the team will actually build it.",
69
+ caresAbout: "Clear module boundaries, typed contracts, testability, incremental delivery, avoiding rewrites, the developer experience.",
70
+ pushesBackOn: "Big-bang designs, abstractions with one caller, untestable components, hand-waved data models, hidden coupling.",
71
+ neverDecidesAlone: "Product scope, infrastructure choices, release sequencing.",
72
+ voice: "Concrete, names modules and interfaces, gives an example. Asks how it would be tested."
73
+ }),
74
+ "Cloud Architect": card("Cloud Architect", 40, {
75
+ mandate: "Owns the system shape: components, integration style, data flows, platform services, and the architecture's fit with the company standards.",
76
+ caresAbout: "Boundaries between systems, synchronous versus event-driven integration, data ownership, security architecture, standards and reference architectures, cost of the platform.",
77
+ pushesBackOn: "Point-to-point integration that will not scale, designs that ignore an existing platform service, undocumented data ownership, deviations from a standard without a recorded reason.",
78
+ neverDecidesAlone: "Product scope, team capacity, on-call acceptance.",
79
+ voice: "Draws the picture in words: components and arrows. Cites the standard being applied or deviated from, and proposes decisions explicitly."
80
+ }),
81
+ DevOps: card("DevOps", 50, {
82
+ mandate: "Owns how the solution is built, tested, deployed and configured: pipelines, environments, infrastructure as code, secrets, release mechanics.",
83
+ caresAbout: "Repeatable builds, environment parity, deployment safety (canary, rollback), configuration and secrets handling, the path from a commit to production.",
84
+ pushesBackOn: "Manual steps, snowflake environments, secrets in config, releases that cannot be rolled back, infrastructure nobody can recreate.",
85
+ neverDecidesAlone: "Product scope, application architecture, SLOs.",
86
+ voice: "Practical, step by step. Asks how it gets to production and how it gets back out."
87
+ }),
88
+ SRE: card("SRE", 60, {
89
+ mandate: "Keeps the solution operable: SLOs, failure modes, capacity, observability, incident response and the on-call reality.",
90
+ caresAbout: "What breaks and how it is seen, blast radius, degradation over outage, alerting that means something, capacity and latency budgets, runbooks.",
91
+ pushesBackOn: "Anything without a failure story, dashboards without alerts, alerts without runbooks, single points of failure, retries without limits.",
92
+ neverDecidesAlone: "Product scope, feature priorities.",
93
+ voice: "Short, concrete, asks for numbers. Speaks last and names what the others missed."
94
+ }),
95
+ [scribeName]: card(scribeName, 90, {
96
+ mandate: "Writes the deliverables from the whole transcript: the solution outline and the plan, in one voice, saying what the room settled and marking what depends on an unanswered question.",
97
+ caresAbout: "Fidelity to the decisions as recorded, their dissent kept visible, assumptions stated where the owner has not answered, documents a reader can act on without the transcript.",
98
+ pushesBackOn: "Inventing decisions the room did not take, smoothing over dissent, silently resolving a parked question.",
99
+ neverDecidesAlone: "Everything: the Scribe records, it does not decide.",
100
+ voice: "Clear, structured prose under the template headings. No meeting narration.",
101
+ outlineTemplate: [
102
+ "## Context",
103
+ "## Goal and non-goals",
104
+ "## Proposed solution",
105
+ "## Components and integration",
106
+ "## Data",
107
+ "## Security, reliability and operations",
108
+ "## Standards applied and deviations",
109
+ "## Alternatives considered",
110
+ "## Risks",
111
+ "## Open questions and assumptions"
112
+ ],
113
+ planTemplate: [
114
+ "## Phases",
115
+ "## Workstreams and owners",
116
+ "## Dependencies",
117
+ "## Milestones",
118
+ "## Risks and mitigations",
119
+ "## First usable release"
120
+ ]
121
+ })
122
+ };
123
+ /** The bundled cards, parsed. A card that does not parse is a defect in this file. */
124
+ export const bundledPersonas = Effect.forEach(Object.entries(bundledPersonaCards), ([name, text]) => parsePersona(name, text));
@@ -0,0 +1,600 @@
1
+ /**
2
+ * roundtable (ADR 0046): the flow half of the meeting. The protocol — what
3
+ * settles an item, who speaks next, when to judge, draft and finish — is
4
+ * `@llm4ts/flow/Roundtable`; this module reads and writes the meeting's
5
+ * files, builds the prompts, drives one read-only seat and the judgment
6
+ * service, and reports to the person.
7
+ */
8
+ import { join } from "node:path";
9
+ import * as Effect from "effect/Effect";
10
+ import * as Schema from "effect/Schema";
11
+ import { truthOf } from "@llm4ts/core/judgment/Judgment";
12
+ import { truth } from "@llm4ts/core/judgment/Schemas";
13
+ import { cap } from "@llm4ts/flow/Context";
14
+ import { Info, RoundtableTurn, StageCompleted, StageStarted } from "@llm4ts/flow/FlowEvents";
15
+ import { AgendaPending, FlowAborted } from "@llm4ts/flow/FlowError";
16
+ import { decide, judgeOrEscalate } from "@llm4ts/flow/Judgment";
17
+ import { AgendaProposal, agendaProposalJsonSchema, assembleAgenda, assembleTurn, briefingFor, chairSpeaker, closingItem, decodeTurns, encodeTurn, itemOf, itemState, maxAgendaItems, Meeting, minAgendaItems, nextAction, parseMeeting, parsePersona, recordOwner, renderAgendaItem, renderDecisions, renderDiscussion, renderMeeting, renderOpenQuestions, renderTurn, reviewItem, syncAnswers, Turn, TurnProposal, turnProposalJsonSchema } from "@llm4ts/flow/Roundtable";
18
+ import { structuredAndPublish } from "@llm4ts/flow/Usage";
19
+ import { ScriptUsage } from "@llm4ts/runner/FlowArgs";
20
+ import { terminalSafe } from "@llm4ts/runner/Terminal";
21
+ import { bundledPersonaCards, scribeName } from "./roundtable-personas.js";
22
+ // ---- Flags ----------------------------------------------------------------------
23
+ export const roundtableUsage = [
24
+ 'usage: llm4ts run roundtable --repo <dir> "<topic>" -- --goal "<goal>" [--agents "PO,PM,..."] [--meeting <id>]',
25
+ " [--context <dir>] [--rounds <n>] [--max-turns <n>] [--no-pause]",
26
+ " llm4ts run roundtable --repo <dir> -- --meeting <id> # continue or finish a meeting",
27
+ " llm4ts run roundtable --repo <dir> -- --eject-personas # write the bundled cards to .llm4ts/personas/",
28
+ " llm4ts run roundtable --repo <dir> -- --list # the repository's meetings",
29
+ " --goal <text> what the meeting must reach (required the first time)",
30
+ " --agents <names> attendees by persona name, in any order; default: PO,PM,Sr SW Eng,Cloud Architect,DevOps,SRE",
31
+ " --meeting <id> the meeting folder under .llm4ts/meetings/; default: a slug of the topic",
32
+ " --context <dir> documents every attendee reads (standards, architecture notes)",
33
+ " --rounds <n> complete rounds an item gets before it is left open (default 3)",
34
+ " --max-turns <n> turns in the whole discussion before drafting starts (default 40)",
35
+ " --no-pause do not stop after the agenda is proposed",
36
+ "The meeting file is the state: .llm4ts/meetings/<id>/meeting.md. Answer the questions under",
37
+ "'## Answers', write under '## Feedback', and rerun."
38
+ ].join("\n");
39
+ export const parseRoundtableArgs = (argv) => Effect.gen(function* () {
40
+ let goal;
41
+ let agents;
42
+ let meeting;
43
+ let context;
44
+ let rounds;
45
+ let maxTurns;
46
+ let noPause = false;
47
+ let ejectPersonas = false;
48
+ let list = false;
49
+ const rest = [];
50
+ for (let index = 0; index < argv.length; index += 1) {
51
+ const argument = argv[index] ?? "";
52
+ const is = (name) => argument === name || argument.startsWith(`${name}=`);
53
+ const valued = (name) => {
54
+ const value = argument.includes("=") ? argument.slice(name.length + 1) : argv[index + 1];
55
+ if (!argument.includes("="))
56
+ index += 1;
57
+ return value === undefined || value.trim().length === 0 || value.startsWith("--")
58
+ ? ScriptUsage.make({ message: `${name} needs a value\n${roundtableUsage}` })
59
+ : Effect.succeed(value.trim());
60
+ };
61
+ const counted = (name) => Effect.flatMap(valued(name), (value) => /^\d+$/.test(value) && Number.parseInt(value, 10) > 0
62
+ ? Effect.succeed(Number.parseInt(value, 10))
63
+ : ScriptUsage.make({ message: `${name} needs a positive number, got: ${value}` }));
64
+ if (is("--goal"))
65
+ goal = yield* valued("--goal");
66
+ else if (is("--agents"))
67
+ agents = (yield* valued("--agents"))
68
+ .split(",")
69
+ .map((name) => name.trim())
70
+ .filter((name) => name.length > 0);
71
+ else if (is("--meeting"))
72
+ meeting = yield* valued("--meeting");
73
+ else if (is("--context"))
74
+ context = yield* valued("--context");
75
+ else if (is("--rounds"))
76
+ rounds = yield* counted("--rounds");
77
+ else if (is("--max-turns"))
78
+ maxTurns = yield* counted("--max-turns");
79
+ else if (argument === "--no-pause")
80
+ noPause = true;
81
+ else if (argument === "--eject-personas")
82
+ ejectPersonas = true;
83
+ else if (argument === "--list")
84
+ list = true;
85
+ else
86
+ rest.push(argument);
87
+ }
88
+ return { goal, agents, meeting, context, rounds, maxTurns, noPause, ejectPersonas, list, rest };
89
+ });
90
+ // ---- Places -------------------------------------------------------------------
91
+ export const meetingsDir = (workDir) => join(workDir, ".llm4ts", "meetings");
92
+ export const meetingDir = (workDir, id) => join(meetingsDir(workDir), id);
93
+ export const personasDir = (workDir) => join(workDir, ".llm4ts", "personas");
94
+ /** The folder name a topic gets: lower case, words joined by dashes, at most 60 characters. */
95
+ export const meetingSlug = (topic) => {
96
+ const slug = topic
97
+ .toLowerCase()
98
+ .replace(/[^a-z0-9]+/g, "-")
99
+ .replace(/^-+|-+$/g, "")
100
+ .slice(0, 60)
101
+ .replace(/-+$/g, "");
102
+ return slug.length === 0 ? "meeting" : slug;
103
+ };
104
+ const meetingFiles = (dir) => ({
105
+ meeting: join(dir, "meeting.md"),
106
+ turns: join(dir, "turns.jsonl"),
107
+ discussion: join(dir, "discussion.md"),
108
+ outline: join(dir, "outline.md"),
109
+ plan: join(dir, "plan.md"),
110
+ decisions: join(dir, "decisions.md"),
111
+ openQuestions: join(dir, "open-questions.md")
112
+ });
113
+ /**
114
+ * The cards the meeting uses: bundled ones, each replaced by a custom card
115
+ * of the same name (`.llm4ts/personas/<name>.md`, read by the caller).
116
+ * Attendees are named; the Scribe is always present.
117
+ */
118
+ export const resolvePersonas = Effect.fn("roundtable.resolvePersonas")(function* (custom, attendees) {
119
+ const cards = { ...bundledPersonaCards, ...custom };
120
+ const personas = new Map();
121
+ for (const [name, text] of Object.entries(cards)) {
122
+ const persona = yield* parsePersona(name, text);
123
+ personas.set(name, persona);
124
+ }
125
+ const known = [...personas.keys()].filter((name) => name !== scribeName);
126
+ const missing = attendees.filter((name) => !personas.has(name) || name === scribeName);
127
+ if (missing.length > 0) {
128
+ return yield* ScriptUsage.make({
129
+ message: `unknown persona(s): ${missing.join(", ")}; known: ${known.join(", ")} — add a card under .llm4ts/personas/<name>.md`
130
+ });
131
+ }
132
+ if (attendees.length === 0) {
133
+ return yield* ScriptUsage.make({ message: `--agents names at least one persona` });
134
+ }
135
+ const scribe = personas.get(scribeName);
136
+ if (scribe === undefined) {
137
+ return yield* ScriptUsage.make({ message: `no ${scribeName} card` });
138
+ }
139
+ const chosen = [...new Set(attendees)]
140
+ .flatMap((name) => {
141
+ const persona = personas.get(name);
142
+ return persona === undefined ? [] : [persona];
143
+ })
144
+ .sort((left, right) => left.order - right.order || left.name.localeCompare(right.name));
145
+ return { attendees: chosen, scribe };
146
+ });
147
+ /** Writes every bundled card the folder lacks; returns the paths written. */
148
+ export const ejectPersonas = Effect.fn("roundtable.ejectPersonas")(function* (files, workDir) {
149
+ const written = [];
150
+ for (const [name, text] of Object.entries(bundledPersonaCards)) {
151
+ const path = join(personasDir(workDir), `${name}.md`);
152
+ const existing = yield* files.read(path);
153
+ if (existing !== undefined)
154
+ continue;
155
+ yield* files.writeAtomic(path, text);
156
+ written.push(path);
157
+ }
158
+ return written;
159
+ });
160
+ // ---- The Scribe's output ------------------------------------------------------
161
+ export class DraftProposal extends Schema.Class("DraftProposal")({
162
+ outline: Schema.String,
163
+ plan: Schema.String
164
+ }) {
165
+ }
166
+ export const draftProposalJsonSchema = {
167
+ type: "object",
168
+ properties: { outline: { type: "string" }, plan: { type: "string" } },
169
+ required: ["outline", "plan"]
170
+ };
171
+ const personaBrief = (persona) => [
172
+ `You are the ${persona.name} in a roundtable of specialists.`,
173
+ "",
174
+ `Mandate: ${persona.mandate}`,
175
+ ...(persona.caresAbout.length === 0 ? [] : [`You care about: ${persona.caresAbout}`]),
176
+ ...(persona.pushesBackOn.length === 0 ? [] : [`You push back on: ${persona.pushesBackOn}`]),
177
+ ...(persona.neverDecidesAlone.length === 0
178
+ ? []
179
+ : [`You never decide alone: ${persona.neverDecidesAlone}`]),
180
+ ...(persona.voice.length === 0 ? [] : [`Voice: ${persona.voice}`])
181
+ ].join("\n");
182
+ const meetingBrief = (meeting) => [
183
+ `Topic: ${meeting.topic}`,
184
+ `Goal: ${meeting.goal}`,
185
+ `Attendees: ${meeting.attendees.join(", ")}`
186
+ ].join("\n");
187
+ const contextBrief = (docs, budget) => {
188
+ if (docs.length === 0)
189
+ return "";
190
+ const each = Math.max(1_000, Math.floor(budget / 2 / docs.length));
191
+ return [
192
+ "",
193
+ "Context documents (standards and notes the room works within):",
194
+ ...docs.flatMap((doc) => ["", `--- ${doc.path} ---`, cap(doc.text, each).text])
195
+ ].join("\n");
196
+ };
197
+ const agendaPrompt = (persona, meeting, docs, budget) => [
198
+ personaBrief(persona),
199
+ "",
200
+ meetingBrief(meeting),
201
+ contextBrief(docs, budget),
202
+ "",
203
+ `Propose the agenda: between ${minAgendaItems} and ${maxAgendaItems} items, each a short title and the one question the room must settle under it. Order them problem first, then solution, then how it is delivered and run. Do not add a goal check: the chair closes the meeting with one.`,
204
+ "",
205
+ 'Reply with JSON: {"items": [{"title": "...", "question": "..."}]}'
206
+ ].join("\n");
207
+ const kindsGuide = [
208
+ "Kinds of turn:",
209
+ "- position: your view on the question, with reasons.",
210
+ "- proposal: a concrete decision you want the room to take; write it so it can be recorded as is.",
211
+ "- objection: against an earlier turn (name it in answers). blocking=true only when the goal cannot be reached unless it is addressed; otherwise it is recorded as dissent.",
212
+ "- question: for the room, or for the owner (needsHuman=true) when only the person who called the meeting can answer; then state the assumption you proceed on.",
213
+ "- agreement: you accept an earlier turn (name it in answers). Close your own blocking objection this way when it has been answered.",
214
+ "- pass: nothing to add this round."
215
+ ].join("\n");
216
+ const turnPrompt = (options) => {
217
+ const { persona, meeting, item, round, caps, turns, addressed, docs, budget } = options;
218
+ const briefing = briefingFor(meeting, turns, item.number, caps);
219
+ const isReview = item.number === reviewItem(meeting).number;
220
+ return [
221
+ personaBrief(persona),
222
+ "",
223
+ meetingBrief(meeting),
224
+ contextBrief(docs, budget),
225
+ "",
226
+ "Decisions so far:",
227
+ ...(briefing.decisions.length === 0
228
+ ? ["- none yet"]
229
+ : briefing.decisions.map((decision) => `- item ${decision.item.number} (${decision.item.title}): ${decision.proposal.body} — proposed by ${decision.proposal.speaker}${decision.dissent.length === 0 ? "" : `; dissent from ${decision.dissent.map((turn) => turn.speaker).join(", ")}`}`)),
230
+ "",
231
+ "Questions parked for the owner (proceed on the stated assumption):",
232
+ ...(briefing.parked.length === 0
233
+ ? ["- none"]
234
+ : briefing.parked.map((turn) => `- #${turn.id} ${turn.speaker}: ${turn.body}${turn.assumption === undefined ? "" : ` (assuming: ${turn.assumption})`}`)),
235
+ "",
236
+ `Agenda item ${renderAgendaItem(item)}`,
237
+ `Round ${round} of at most ${caps.roundsPerItem}.`,
238
+ ...(isReview
239
+ ? [
240
+ "",
241
+ "The Scribe has written outline.md and plan.md from the discussion. Review them from your mandate: object (blocking only for what makes them wrong or unusable), agree, or pass."
242
+ ]
243
+ : []),
244
+ "",
245
+ "The discussion on this item so far:",
246
+ ...(briefing.current.length === 0
247
+ ? ["(nobody has spoken yet)"]
248
+ : briefing.current.map(renderTurn)),
249
+ "",
250
+ ...(addressed.length === 0
251
+ ? []
252
+ : ["Aimed at your earlier turns, still open:", ...addressed.map(renderTurn), ""]),
253
+ kindsGuide,
254
+ "",
255
+ `Speak as the ${persona.name}, in your voice, once. Answer turns by their #id on this item only. Keep the body to a short paragraph.`,
256
+ "",
257
+ 'Reply with JSON: {"kind": "position|proposal|objection|question|agreement|pass", "answers": [ids], "blocking": false, "needsHuman": false, "assumption": "optional", "body": "..."}'
258
+ ].join("\n");
259
+ };
260
+ const draftPrompt = (options) => {
261
+ const { scribe, meeting, docs, budget, previous } = options;
262
+ return [
263
+ personaBrief(scribe),
264
+ "",
265
+ meetingBrief(meeting),
266
+ contextBrief(docs, budget),
267
+ "",
268
+ previous === undefined
269
+ ? "Write the two deliverables from the transcript below. Say what the room settled; keep every decision's dissent visible; where a question is parked for the owner, state the assumption the document rests on. Do not invent decisions the room did not take."
270
+ : "Revise the two deliverables below so that every blocking objection from the review is addressed. Change nothing the objections do not touch.",
271
+ "",
272
+ ...(previous === undefined
273
+ ? []
274
+ : [
275
+ "Blocking objections from the review:",
276
+ ...previous.objections.map(renderTurn),
277
+ "",
278
+ "Current outline.md:",
279
+ cap(previous.outline, Math.floor(budget / 6)).text,
280
+ "",
281
+ "Current plan.md:",
282
+ cap(previous.plan, Math.floor(budget / 6)).text,
283
+ ""
284
+ ]),
285
+ "Decisions (as recorded):",
286
+ cap(options.decisions, Math.floor(budget / 6)).text,
287
+ "",
288
+ "Open questions (as recorded):",
289
+ cap(options.openQuestions, Math.floor(budget / 8)).text,
290
+ "",
291
+ "Transcript:",
292
+ cap(options.discussion, Math.floor(budget / 2)).text,
293
+ "",
294
+ "Outline sections:",
295
+ scribe.outlineTemplate ??
296
+ "## Context\n## Proposed solution\n## Risks\n## Open questions and assumptions",
297
+ "",
298
+ "Plan sections:",
299
+ scribe.planTemplate ?? "## Phases\n## Workstreams and owners\n## Risks and mitigations",
300
+ "",
301
+ 'Reply with JSON: {"outline": "<complete markdown document>", "plan": "<complete markdown document>"}'
302
+ ].join("\n");
303
+ };
304
+ /** The meeting, driven from its files until it halts for the person or is done. */
305
+ export const runRoundtable = Effect.fn("roundtable.run")(function* (deps) {
306
+ const { files, events, caps } = deps;
307
+ const dir = meetingDir(deps.workDir, deps.meetingId);
308
+ const paths = meetingFiles(dir);
309
+ const say = (message) => events.publish(Info.make({ message }));
310
+ const save = (meeting) => files.writeAtomic(paths.meeting, renderMeeting(meeting));
311
+ const loaded = yield* loadMeeting(files, paths.meeting);
312
+ let meeting;
313
+ if (loaded === undefined) {
314
+ const topic = (deps.topic ?? "").trim();
315
+ const goal = (deps.goal ?? "").trim();
316
+ if (topic.length === 0 || goal.length === 0) {
317
+ return yield* ScriptUsage.make({
318
+ message: `no meeting at ${paths.meeting}: give the topic and --goal to start one\n${roundtableUsage}`
319
+ });
320
+ }
321
+ meeting = Meeting.make({
322
+ id: deps.meetingId,
323
+ status: "agenda-proposed",
324
+ topic,
325
+ goal,
326
+ attendees: deps.attendees.map((persona) => persona.name),
327
+ agenda: [],
328
+ answers: [],
329
+ feedback: "",
330
+ drafts: 0
331
+ });
332
+ yield* save(meeting);
333
+ yield* say(`roundtable: ${paths.meeting} opened — ${meeting.attendees.join(", ")}`);
334
+ }
335
+ else {
336
+ meeting = loaded;
337
+ }
338
+ // Every attendee is a lane in the dashboard, from the first event of the run (ADR 0046).
339
+ for (const name of meeting.attendees) {
340
+ yield* events.publish(StageStarted.make({ stage: `attendee ${name}`, lane: name }));
341
+ }
342
+ const personaOf = (name) => deps.attendees.find((persona) => persona.name === name);
343
+ for (const name of meeting.attendees) {
344
+ if (personaOf(name) === undefined) {
345
+ return yield* ScriptUsage.make({
346
+ message: `the meeting lists attendee ${name} but no such persona is known; pass --agents with the meeting's attendees, or add .llm4ts/personas/${name}.md`
347
+ });
348
+ }
349
+ }
350
+ let turns = yield* decodeTurns((yield* files.read(paths.turns)) ?? "", paths.turns);
351
+ const appendTurn = (turn) => Effect.gen(function* () {
352
+ yield* files.append(paths.turns, `${encodeTurn(turn)}\n`);
353
+ turns = [...turns, turn];
354
+ yield* files.writeAtomic(paths.discussion, renderDiscussion(meeting, turns, caps));
355
+ const synced = syncAnswers(meeting, turns);
356
+ if (synced.answers.length !== meeting.answers.length) {
357
+ meeting = synced;
358
+ yield* save(meeting);
359
+ }
360
+ yield* events.publish(RoundtableTurn.make({
361
+ lane: turn.speaker,
362
+ meeting: meeting.id,
363
+ item: turn.item,
364
+ itemTitle: itemOf(meeting, turn.item)?.title ?? "",
365
+ round: turn.round,
366
+ turn: turn.id,
367
+ kind: turn.kind,
368
+ blocking: turn.blocking,
369
+ answers: turn.answers,
370
+ summary: firstLine(turn.body)
371
+ }));
372
+ });
373
+ // The item under discussion is the run's open stage.
374
+ let openItem;
375
+ const itemStage = (item) => `item ${item.number}: ${item.title}`;
376
+ const leaveItem = () => Effect.gen(function* () {
377
+ if (openItem === undefined)
378
+ return;
379
+ yield* events.publish(StageCompleted.make({ stage: itemStage(openItem) }));
380
+ openItem = undefined;
381
+ });
382
+ const enterItem = (item) => Effect.gen(function* () {
383
+ if (openItem?.number === item.number)
384
+ return;
385
+ yield* leaveItem();
386
+ yield* events.publish(StageStarted.make({ stage: itemStage(item) }));
387
+ openItem = item;
388
+ });
389
+ const writeRecords = () => Effect.gen(function* () {
390
+ yield* files.writeAtomic(paths.decisions, renderDecisions(meeting, turns, caps));
391
+ yield* files.writeAtomic(paths.openQuestions, renderOpenQuestions(meeting, turns, caps));
392
+ });
393
+ const askScribe = (previous) => Effect.gen(function* () {
394
+ const proposal = yield* structuredAndPublish(deps.seat, events, draftPrompt({
395
+ scribe: deps.scribe,
396
+ meeting,
397
+ discussion: renderDiscussion(meeting, turns, caps),
398
+ decisions: renderDecisions(meeting, turns, caps),
399
+ openQuestions: renderOpenQuestions(meeting, turns, caps),
400
+ docs: deps.context,
401
+ budget: deps.budget,
402
+ ...(previous === undefined ? {} : { previous })
403
+ }), DraftProposal, draftProposalJsonSchema, "scribe");
404
+ yield* files.writeAtomic(paths.outline, `${proposal.outline.trim()}\n`);
405
+ yield* files.writeAtomic(paths.plan, `${proposal.plan.trim()}\n`);
406
+ });
407
+ let guard = 0;
408
+ for (;;) {
409
+ guard += 1;
410
+ if (guard > caps.maxTurns * 4 + 64) {
411
+ return yield* FlowAborted.make({
412
+ message: `roundtable ${deps.meetingId}: the chair did not reach an end after ${guard} steps; the files under ${dir} are intact`
413
+ });
414
+ }
415
+ const action = nextAction(meeting, turns, caps);
416
+ switch (action._tag) {
417
+ case "Refused":
418
+ return yield* ScriptUsage.make({
419
+ message: `roundtable ${deps.meetingId}: ${action.reason}`
420
+ });
421
+ case "Done":
422
+ return {
423
+ _tag: "NothingToDo",
424
+ message: `meeting ${deps.meetingId} is done; answer a question under '## Answers' or write '## Feedback' in ${paths.meeting} to reopen it`
425
+ };
426
+ case "ProposeAgenda": {
427
+ const persona = personaOf(action.speaker) ?? deps.attendees[0];
428
+ if (persona === undefined) {
429
+ return yield* ScriptUsage.make({ message: "the meeting has no attendees" });
430
+ }
431
+ const proposal = yield* structuredAndPublish(deps.seat, events, agendaPrompt(persona, meeting, deps.context, deps.budget), AgendaProposal, agendaProposalJsonSchema, persona.name);
432
+ const items = assembleAgenda(proposal);
433
+ if (items.length === 0) {
434
+ return yield* FlowAborted.make({
435
+ message: `${persona.name} proposed no agenda item; rerun`
436
+ });
437
+ }
438
+ meeting = Meeting.make({ ...meeting, agenda: items });
439
+ yield* save(meeting);
440
+ yield* say(`roundtable: ${persona.name} proposed ${items.length} agenda item(s):\n${items.map((item) => ` ${renderAgendaItem(item)}`).join("\n")}`);
441
+ if (!deps.noPause) {
442
+ return yield* AgendaPending.make({ path: paths.meeting, items: items.length });
443
+ }
444
+ continue;
445
+ }
446
+ case "OpenDiscussion":
447
+ meeting = Meeting.make({ ...meeting, status: "discussing" });
448
+ yield* save(meeting);
449
+ yield* say(`roundtable: discussing ${meeting.agenda.length} item(s) plus the goal check, ${meeting.attendees.length} attendees, up to ${caps.roundsPerItem} rounds each`);
450
+ continue;
451
+ case "RecordOwner": {
452
+ const recorded = recordOwner(meeting, turns, action);
453
+ for (const turn of recorded) {
454
+ yield* appendTurn(turn);
455
+ yield* say(renderTurn(turn));
456
+ }
457
+ meeting = Meeting.make({ ...meeting, status: "discussing", feedback: "" });
458
+ yield* save(meeting);
459
+ continue;
460
+ }
461
+ case "Judge": {
462
+ yield* enterItem(action.item);
463
+ const result = yield* judgeOrEscalate({
464
+ judgment: deps.judgment,
465
+ reasoning: deps.seat,
466
+ events,
467
+ request: {
468
+ state: {
469
+ question: action.item.question,
470
+ objection: `${action.objection.speaker}: ${action.objection.body}`,
471
+ reply: `${action.reply.speaker}: ${action.reply.body}`
472
+ },
473
+ questions: {
474
+ resolved: truth("The reply answers the objection well enough that a reasonable objector would withdraw it.")
475
+ }
476
+ }
477
+ });
478
+ const answer = yield* truthOf(result, "resolved").pipe(Effect.option);
479
+ const resolved = answer._tag === "Some" && decide(answer.value) !== "hold" && answer.value.truth >= 0.5;
480
+ const probability = answer._tag === "Some" ? answer.value.truth.toFixed(2) : "n/a";
481
+ const chair = Turn.make({
482
+ id: turns.length + 1,
483
+ round: action.reply.round,
484
+ item: action.item.number,
485
+ speaker: chairSpeaker,
486
+ kind: resolved ? "agreement" : "position",
487
+ answers: [action.objection.id],
488
+ blocking: false,
489
+ needsHuman: false,
490
+ body: resolved
491
+ ? `Judged resolved by #${action.reply.id} (p=${probability}).`
492
+ : `Judged still open after #${action.reply.id} (p=${probability}); ${action.objection.speaker} may close it or the item is left open at the cap.`
493
+ });
494
+ yield* appendTurn(chair);
495
+ yield* say(renderTurn(chair));
496
+ continue;
497
+ }
498
+ case "Speak": {
499
+ yield* enterItem(action.item);
500
+ const persona = personaOf(action.speaker);
501
+ if (persona === undefined) {
502
+ return yield* ScriptUsage.make({ message: `no persona for ${action.speaker}` });
503
+ }
504
+ const proposal = yield* structuredAndPublish(deps.seat, events, turnPrompt({
505
+ persona,
506
+ meeting,
507
+ item: action.item,
508
+ round: action.round,
509
+ caps,
510
+ turns,
511
+ addressed: action.addressed,
512
+ docs: deps.context,
513
+ budget: deps.budget
514
+ }), TurnProposal, turnProposalJsonSchema, persona.name);
515
+ const turn = assembleTurn(proposal, {
516
+ id: turns.length + 1,
517
+ round: action.round,
518
+ item: action.item.number,
519
+ speaker: persona.name,
520
+ existing: turns
521
+ });
522
+ yield* appendTurn(turn);
523
+ yield* say(`[item ${action.item.number}, round ${action.round}] ${renderTurn(turn)}`);
524
+ const state = itemState(meeting, turns, action.item.number, caps);
525
+ if (state.settled)
526
+ yield* say(`item ${action.item.number} settled.`);
527
+ else if (state.capped)
528
+ yield* say(`item ${action.item.number} left open at the cap.`);
529
+ continue;
530
+ }
531
+ case "Draft": {
532
+ yield* leaveItem();
533
+ if (meeting.status !== "drafting") {
534
+ meeting = Meeting.make({ ...meeting, status: "drafting" });
535
+ yield* save(meeting);
536
+ }
537
+ yield* writeRecords();
538
+ yield* askScribe();
539
+ meeting = Meeting.make({ ...meeting, status: "reviewing", drafts: meeting.drafts + 1 });
540
+ yield* save(meeting);
541
+ yield* say(`scribe: draft ${meeting.drafts} written — ${paths.outline}, ${paths.plan}, ${paths.decisions}, ${paths.openQuestions}`);
542
+ continue;
543
+ }
544
+ case "Finalize": {
545
+ yield* leaveItem();
546
+ if (action.objections.length > 0) {
547
+ const outline = (yield* files.read(paths.outline)) ?? "";
548
+ const plan = (yield* files.read(paths.plan)) ?? "";
549
+ yield* askScribe({ outline, plan, objections: action.objections });
550
+ yield* say(`scribe: fix pass for ${action.objections.length} blocking objection(s)`);
551
+ }
552
+ yield* writeRecords();
553
+ meeting = Meeting.make({ ...meeting, status: "done" });
554
+ yield* save(meeting);
555
+ const parked = meeting.answers.filter((point) => (point.answer ?? "").trim().length === 0);
556
+ const closing = closingItem(meeting);
557
+ const open = [...meeting.agenda, closing].filter((item) => !itemState(meeting, turns, item.number, caps).settled);
558
+ const summary = [
559
+ `roundtable ${deps.meetingId} done after ${turns.length} turns and ${meeting.drafts} draft(s)`,
560
+ `outline: ${paths.outline}`,
561
+ `plan: ${paths.plan}`,
562
+ `decisions: ${paths.decisions}`,
563
+ `open questions: ${paths.openQuestions}${open.length === 0 ? "" : ` (${open.length} item(s) left open)`}`,
564
+ ...(parked.length === 0
565
+ ? []
566
+ : [
567
+ `${parked.length} question(s) for you under '## Answers' in ${paths.meeting}; answer and rerun to fold them in`
568
+ ])
569
+ ];
570
+ for (const line of summary)
571
+ yield* say(line);
572
+ for (const name of meeting.attendees) {
573
+ yield* events.publish(StageCompleted.make({ stage: `attendee ${name}`, lane: name }));
574
+ }
575
+ return { _tag: "Finished", summary };
576
+ }
577
+ }
578
+ }
579
+ });
580
+ /**
581
+ * The first line of a turn's body, cut short: what the dashboard shows on
582
+ * the lane. A model's reply is untrusted text on its way to a terminal:
583
+ * escape sequences and control characters are dropped, not painted.
584
+ */
585
+ const firstLine = (text) => {
586
+ const line = terminalSafe(text.split(/\r?\n/)[0] ?? "")
587
+ // eslint-disable-next-line no-control-regex
588
+ .replace(/[\u0000-\u001f\u007f-\u009f]/g, "")
589
+ .replace(/\s+/g, " ")
590
+ .trim();
591
+ return line.length > 80 ? `${line.slice(0, 77)}...` : line;
592
+ };
593
+ const loadMeeting = Effect.fn("roundtable.loadMeeting")(function* (files, path) {
594
+ const text = yield* files.read(path);
595
+ if (text === undefined)
596
+ return undefined;
597
+ return yield* parseMeeting(text, path);
598
+ });
599
+ /** Markdown of the bundled and custom cards, for `--list` and for the docs. */
600
+ export const describePersonas = (personas) => personas.map((persona) => `- ${persona.name}: ${persona.mandate}`).join("\n");
@@ -0,0 +1,163 @@
1
+ // Run a meeting of personas on a topic toward a goal: they discuss item by item, a Scribe writes the solution outline and the plan, you steer from the meeting file.
2
+ //
3
+ // llm4ts run roundtable --repo ~/work/portal "Architecture outline for the payments hub" \
4
+ // -- --goal "An outline and a plan the committee can approve, within our standards" \
5
+ // --agents "PO,PM,Sr SW Eng,Cloud Architect,DevOps,SRE" --context docs/standards
6
+ // llm4ts run roundtable --repo ~/work/portal -- --meeting payments-hub # continue, or fold in your answers
7
+ // llm4ts run roundtable --repo ~/work/portal -- --eject-personas # the bundled cards, to edit
8
+ // llm4ts run roundtable --repo ~/work/portal -- --list # meetings and personas
9
+ //
10
+ // Code chairs the meeting (ADR 0046). The first attendee proposes the agenda;
11
+ // the run stops for you to edit it unless --no-pause. Then every attendee is
12
+ // asked once per round, in persona order, on each item: a position, a
13
+ // proposal, an objection (blocking or not), a question (for the room, or
14
+ // parked for you), an agreement, or a pass. An item settles after a complete
15
+ // round with no open blocking objection and no open question; silence is
16
+ // consent. Three rounds per item (--rounds), forty turns in all (--max-turns);
17
+ // what is still open is written down, never forced. Then the Scribe writes
18
+ // outline.md and plan.md, code renders decisions.md (with the dissent) and
19
+ // open-questions.md, every attendee reviews once, the Scribe fixes what
20
+ // blocks, and the meeting is done.
21
+ //
22
+ // The FILE is the state: <repo>/.llm4ts/meetings/<id>/meeting.md. Answer a
23
+ // parked question under `## Answers`, or write under `## Feedback`, and
24
+ // rerun: your words become turns, the item reopens for one more round and the
25
+ // drafts are rewritten. Personas are cards: bundled ones, replaced by
26
+ // .llm4ts/personas/<name>.md of the same name. Every persona borrows one
27
+ // read-only, toolless seat: LLM4TS_REASONER (default claude). --context
28
+ // gives every attendee the same documents, under LLM4TS_CONTEXT_BUDGET.
29
+ // Nothing in the repository's git is touched.
30
+ import { readdir } from "node:fs/promises";
31
+ import { join, resolve } from "node:path";
32
+ import * as Console from "effect/Console";
33
+ import * as Effect from "effect/Effect";
34
+ import { budget } from "@llm4ts/flow/Context";
35
+ import { judgmentOf } from "@llm4ts/flow/Judgment";
36
+ import { parseMeeting } from "@llm4ts/flow/Roundtable";
37
+ import { ScriptUsage, asToolless, nodePlainFileStore, resolveFlowInput, runFlowMain, runNode, stage } from "@llm4ts/runner";
38
+ import { reasonerFromEnvironment } from "./lib/epic-stories.js";
39
+ import { describePersonas, ejectPersonas, meetingDir, meetingSlug, meetingsDir, parseRoundtableArgs, personasDir, resolvePersonas, runRoundtable } from "./lib/roundtable.js";
40
+ import { defaultAttendees } from "./lib/roundtable-personas.js";
41
+ const files = nodePlainFileStore;
42
+ const namesIn = (dir, suffix) => Effect.tryPromise(() => readdir(dir, { withFileTypes: true })).pipe(Effect.map((entries) => entries
43
+ .filter((entry) => entry.isFile() && entry.name.endsWith(suffix))
44
+ .map((entry) => entry.name.slice(0, -suffix.length))
45
+ .sort()), Effect.catch(() => Effect.succeed([])));
46
+ const foldersIn = (dir) => Effect.tryPromise(() => readdir(dir, { withFileTypes: true })).pipe(Effect.map((entries) => entries
47
+ .filter((entry) => entry.isDirectory())
48
+ .map((entry) => entry.name)
49
+ .sort()), Effect.catch(() => Effect.succeed([])));
50
+ /** `.llm4ts/personas/<name>.md`, keyed by name. */
51
+ const customCards = Effect.fn("roundtable.customCards")(function* (workDir) {
52
+ const dir = personasDir(workDir);
53
+ const names = yield* namesIn(dir, ".md");
54
+ const cards = {};
55
+ for (const name of names) {
56
+ const text = yield* files.read(join(dir, `${name}.md`));
57
+ if (text !== undefined)
58
+ cards[name] = text;
59
+ }
60
+ return cards;
61
+ });
62
+ const textLike = /\.(md|markdown|txt|adoc|rst)$/i;
63
+ /** Every text document under `--context`, recursively, in path order. */
64
+ const contextDocs = Effect.fn("roundtable.contextDocs")(function* (dir) {
65
+ if (dir === undefined)
66
+ return [];
67
+ const entries = yield* Effect.tryPromise(() => readdir(dir, { withFileTypes: true, recursive: true })).pipe(Effect.catch(() => ScriptUsage.make({ message: `--context ${dir} is not a readable directory` })));
68
+ const paths = entries
69
+ .filter((entry) => entry.isFile() && textLike.test(entry.name))
70
+ .map((entry) => join(entry.parentPath, entry.name))
71
+ .sort();
72
+ const docs = [];
73
+ for (const path of paths) {
74
+ const text = yield* files.read(path);
75
+ if (text !== undefined)
76
+ docs.push({ path: path.slice(dir.length + 1), text });
77
+ }
78
+ return docs;
79
+ });
80
+ const program = Effect.gen(function* () {
81
+ const flags = yield* parseRoundtableArgs(process.argv.slice(2));
82
+ const given = yield* resolveFlowInput("", flags.rest);
83
+ const workDir = given.workDir;
84
+ if (flags.ejectPersonas) {
85
+ const written = yield* ejectPersonas(files, workDir);
86
+ yield* Console.log(written.length === 0
87
+ ? `every bundled card is already under ${personasDir(workDir)}`
88
+ : `wrote ${written.length} card(s):\n${written.map((path) => ` ${path}`).join("\n")}`);
89
+ return;
90
+ }
91
+ const custom = yield* customCards(workDir);
92
+ if (flags.list) {
93
+ const ids = yield* foldersIn(meetingsDir(workDir));
94
+ const lines = [];
95
+ for (const id of ids) {
96
+ const text = yield* files.read(join(meetingDir(workDir, id), "meeting.md"));
97
+ const meeting = text === undefined ? undefined : yield* parseMeeting(text).pipe(Effect.option);
98
+ lines.push(meeting === undefined || meeting._tag === "None"
99
+ ? ` ${id} (no readable meeting.md)`
100
+ : ` ${id} ${meeting.value.status} ${meeting.value.topic}`);
101
+ }
102
+ const personas = yield* resolvePersonas(custom, defaultAttendees);
103
+ yield* Console.log([
104
+ lines.length === 0 ? `no meetings under ${meetingsDir(workDir)}` : "meetings:",
105
+ ...lines,
106
+ "",
107
+ `personas (bundled${Object.keys(custom).length === 0 ? "" : `, ${Object.keys(custom).length} custom`}):`,
108
+ describePersonas([...personas.attendees, personas.scribe])
109
+ ].join("\n"));
110
+ return;
111
+ }
112
+ const topic = given.prompt.trim();
113
+ const meetingId = flags.meeting ?? (topic.length === 0 ? undefined : meetingSlug(topic));
114
+ if (meetingId === undefined) {
115
+ return yield* ScriptUsage.make({
116
+ message: `give the topic to start a meeting, or --meeting <id> to continue one`
117
+ });
118
+ }
119
+ // An existing meeting's attendees are the file's; --agents only seats a new one.
120
+ const existing = yield* files.read(join(meetingDir(workDir, meetingId), "meeting.md"));
121
+ const attendeeNames = existing === undefined
122
+ ? (flags.agents ?? defaultAttendees)
123
+ : (yield* parseMeeting(existing, join(meetingDir(workDir, meetingId), "meeting.md")))
124
+ .attendees;
125
+ const personas = yield* resolvePersonas(custom, attendeeNames);
126
+ // Relative to the repository: the standards usually live beside the code.
127
+ const docs = yield* contextDocs(flags.context === undefined ? undefined : resolve(workDir, flags.context));
128
+ const reasoning = asToolless(yield* reasonerFromEnvironment(process.env));
129
+ const outcome = yield* runNode({
130
+ workDir,
131
+ workspace: given.workspace,
132
+ userPrompt: topic.length === 0 ? `roundtable ${meetingId}` : topic,
133
+ // No coder turn is ever taken: the toolless reasoning seat fills both.
134
+ coder: reasoning,
135
+ reasoning,
136
+ environment: process.env
137
+ }, (context) => stage(context.events, "roundtable", runRoundtable({
138
+ files,
139
+ events: context.events,
140
+ seat: context.reasoning,
141
+ judgment: judgmentOf(context),
142
+ workDir,
143
+ meetingId,
144
+ topic,
145
+ goal: flags.goal,
146
+ attendees: personas.attendees,
147
+ scribe: personas.scribe,
148
+ context: docs,
149
+ caps: {
150
+ roundsPerItem: flags.rounds ?? 3,
151
+ maxTurns: flags.maxTurns ?? 40
152
+ },
153
+ noPause: flags.noPause,
154
+ budget: budget(process.env)
155
+ })));
156
+ if (outcome._tag === "NothingToDo") {
157
+ yield* Console.log(outcome.message);
158
+ }
159
+ else {
160
+ yield* Console.log(outcome.summary.join("\n"));
161
+ }
162
+ });
163
+ runFlowMain(program);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llm4ts/shell",
3
- "version": "2.52.0",
3
+ "version": "2.53.0",
4
4
  "description": "Interactive shell and CLI for llm4ts: flow discovery, run-a-flow, and view",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -61,9 +61,9 @@
61
61
  "dependencies": {
62
62
  "@effect/platform-node": "4.0.0",
63
63
  "@effect/platform-node-shared": "4.0.0",
64
- "@llm4ts/core": "2.52.0",
65
- "@llm4ts/flow": "2.52.0",
66
- "@llm4ts/runner": "2.52.0"
64
+ "@llm4ts/core": "2.53.0",
65
+ "@llm4ts/flow": "2.53.0",
66
+ "@llm4ts/runner": "2.53.0"
67
67
  },
68
68
  "peerDependencies": {
69
69
  "effect": "4.0.0"