@llm4ts/shell 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/flows/epic-stories.js +8 -2
- package/flows/lib/debt.js +132 -0
- package/flows/lib/epic-draft.js +5 -63
- package/flows/lib/epic-stories.js +44 -16
- package/flows/lib/roundtable-personas.js +124 -0
- package/flows/lib/roundtable.js +600 -0
- package/flows/roundtable.js +163 -0
- package/package.json +4 -4
package/flows/epic-stories.js
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
// llm4ts run epic-stories --repo ~/demo/portal -- --land # land the finished epic on main
|
|
6
6
|
// llm4ts run epic-stories --repo ~/demo/portal -- --list # the repository's epics
|
|
7
7
|
// llm4ts run epic-stories --repo ~/demo/portal -- --epic <id> # resume one, no text needed
|
|
8
|
+
// llm4ts run epic-stories --repo ~/demo/portal -- --defer-tests "…" # missing tests become debt
|
|
9
|
+
// llm4ts run epic-stories --repo ~/demo/portal -- --harden --epic <id> # plan that debt as a round
|
|
8
10
|
//
|
|
9
11
|
// The reasoning seat (LLM4TS_REASONER, default claude) splits the epic,
|
|
10
12
|
// reviews every task and judges every story; the coder seat (LLM4TS_CODER,
|
|
@@ -21,6 +23,10 @@
|
|
|
21
23
|
// Transcripts (what each seat was told and answered) are ON by default for
|
|
22
24
|
// this flow — LLM4TS_TRANSCRIPT=off turns them off — and --land compacts them
|
|
23
25
|
// (shape kept, content removed) rather than deleting them (ADR 0025).
|
|
26
|
+
// With --defer-tests the judge's tests score is never a finding and what it
|
|
27
|
+
// excused is written to debt.md; --harden plans that debt (ADR 0045).
|
|
28
|
+
import * as Effect from "effect/Effect";
|
|
24
29
|
import { runFlowMain } from "@llm4ts/runner";
|
|
25
|
-
import {
|
|
26
|
-
|
|
30
|
+
import { epicStoriesOptions, parseStoriesFlags } from "./lib/debt.js";
|
|
31
|
+
import { runEpicStories } from "./lib/epic-stories.js";
|
|
32
|
+
runFlowMain(Effect.flatMap(parseStoriesFlags(process.argv.slice(2), process.env), (flags) => runEpicStories({ ...epicStoriesOptions(flags), argv: flags.argv })));
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Debt an epic run deferred and a later round pays (ADR 0043, ADR 0045):
|
|
2
|
+
// `debt.md` per unit, `debt.paid.md` once a hardening round settles, and
|
|
3
|
+
// the `--harden` policy both epic-draft and epic-stories use.
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import * as Effect from "effect/Effect";
|
|
6
|
+
import { Info } from "@llm4ts/flow/FlowEvents";
|
|
7
|
+
import { countNotPlanned } from "@llm4ts/flow/RefineRound";
|
|
8
|
+
import { lastStoryVerdict } from "@llm4ts/flow/Stories";
|
|
9
|
+
import { ScriptUsage } from "@llm4ts/runner";
|
|
10
|
+
import { deferringTestsJudge, epicUsage, loadRounds, rubricStoryJudge, unitStateDirs } from "./epic-stories.js";
|
|
11
|
+
// ---- The debt ---------------------------------------------------------------
|
|
12
|
+
export const counted = (count, one, many) => `${count} ${count === 1 ? one : many}`;
|
|
13
|
+
/** What a unit deferred: the tests owed, the findings, the test gate it skipped. */
|
|
14
|
+
export const debtPath = (stateDir) => join(stateDir, "debt.md");
|
|
15
|
+
/** Debt a settled hardening round consumed. */
|
|
16
|
+
export const paidPath = (stateDir) => join(stateDir, "debt.paid.md");
|
|
17
|
+
// ---- Tests the judge excused (ADR 0045) -------------------------------------
|
|
18
|
+
/**
|
|
19
|
+
* After an epic-stories unit run with `--defer-tests`: `debt.md` with one
|
|
20
|
+
* section per merged story whose last verdict scored `tests` below the bar,
|
|
21
|
+
* each acceptance outcome a test owed. A story the plan exempts from tests,
|
|
22
|
+
* or one a hardening round paid, owes nothing. Deferred findings are left
|
|
23
|
+
* out: `--defer-findings` already plans them as their own round.
|
|
24
|
+
*/
|
|
25
|
+
export const testsDebtAfterUnit = (unit, report) => Effect.gen(function* () {
|
|
26
|
+
const paid = new Set(((yield* unit.files.read(paidPath(unit.stateDir))) ?? "")
|
|
27
|
+
.split("\n")
|
|
28
|
+
.flatMap((line) => (line.startsWith("## ") ? [line.slice(3).trim()] : [])));
|
|
29
|
+
const sections = [];
|
|
30
|
+
let owed = 0;
|
|
31
|
+
let stories = 0;
|
|
32
|
+
for (const story of unit.plan.stories) {
|
|
33
|
+
const merged = report.stories.some((outcome) => outcome.id === story.id && outcome.status === "done");
|
|
34
|
+
if (!merged || story.testsDeferred !== undefined || paid.has(story.id)) {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const verdict = yield* lastStoryVerdict(unit.files, unit.stateDir, story.id);
|
|
38
|
+
const tests = verdict?.excused?.find((dimension) => dimension.id === "tests");
|
|
39
|
+
if (tests === undefined) {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
owed += story.acceptance.length;
|
|
43
|
+
stories += 1;
|
|
44
|
+
sections.push(`## ${story.id}`, "", `judge scored tests ${tests.score}/${tests.max}: ${tests.reasoning}`, "", ...story.acceptance.map((outcome) => `- [ ] test: ${outcome}`), "");
|
|
45
|
+
}
|
|
46
|
+
if (stories === 0) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const path = debtPath(unit.stateDir);
|
|
50
|
+
yield* unit.files.writeAtomic(path, [`# Debt — ${unit.plan.epicId}`, "", ...sections].join("\n"));
|
|
51
|
+
yield* unit.events.publish(Info.make({
|
|
52
|
+
message: `debt: ${counted(owed, "test owed", "tests owed")} by ${counted(stories, "story", "stories")} — read it, then run epic-stories --harden (${path})`
|
|
53
|
+
}));
|
|
54
|
+
});
|
|
55
|
+
// ---- Hardening --------------------------------------------------------------
|
|
56
|
+
/** The open debt lists of an epic, by state folder; empty ones are paid. */
|
|
57
|
+
export const openDebt = (files, epicStateDir) => Effect.gen(function* () {
|
|
58
|
+
const open = [];
|
|
59
|
+
for (const dir of yield* unitStateDirs(files, epicStateDir)) {
|
|
60
|
+
const text = (yield* files.read(debtPath(dir)))?.trim() ?? "";
|
|
61
|
+
if (text.length > 0) {
|
|
62
|
+
open.push({ dir, text });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return open;
|
|
66
|
+
});
|
|
67
|
+
/** `--harden`: the text given, then every open debt list, as the round to plan. */
|
|
68
|
+
export const hardenFeedback = (epicStateDir, text, files) => Effect.gen(function* () {
|
|
69
|
+
// An open round finishes before another is planned: with text, runAction would refuse it.
|
|
70
|
+
const rounds = yield* loadRounds(files, epicStateDir).pipe(Effect.catch(() => Effect.succeed([])));
|
|
71
|
+
const last = rounds.at(-1);
|
|
72
|
+
if (last?.plan !== undefined && last.merged < last.plan.stories.length) {
|
|
73
|
+
return text;
|
|
74
|
+
}
|
|
75
|
+
const open = yield* openDebt(files, epicStateDir);
|
|
76
|
+
return open.length === 0
|
|
77
|
+
? text
|
|
78
|
+
: [
|
|
79
|
+
"Pay this epic's debt: write the tests owed as deterministic tests in the house",
|
|
80
|
+
"style, make the test gate green, and close the findings that still apply.",
|
|
81
|
+
...(text.length === 0 ? [] : ["", text]),
|
|
82
|
+
"",
|
|
83
|
+
...open.flatMap((debt) => [debt.text, ""])
|
|
84
|
+
].join("\n");
|
|
85
|
+
});
|
|
86
|
+
/** After a hardening round that settled (nothing failed or waiting), its debt is paid. */
|
|
87
|
+
export const hardenAfterUnit = (unit, report) => Effect.gen(function* () {
|
|
88
|
+
if (report.count("failed") > 0 || report.count("waiting") > 0) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
for (const debt of yield* openDebt(unit.files, unit.epicStateDir)) {
|
|
92
|
+
const paid = (yield* unit.files.read(paidPath(debt.dir))) ?? "";
|
|
93
|
+
yield* unit.files.writeAtomic(paidPath(debt.dir), `${paid}${debt.text}\n`);
|
|
94
|
+
yield* unit.files.writeAtomic(debtPath(debt.dir), "");
|
|
95
|
+
}
|
|
96
|
+
// What the round's planner left out is listed, never carried (ADR 0031 decision 6).
|
|
97
|
+
const notPlannedPath = join(unit.stateDir, "not-planned.md");
|
|
98
|
+
const left = countNotPlanned((yield* unit.files.read(notPlannedPath)) ?? "");
|
|
99
|
+
if (left > 0) {
|
|
100
|
+
yield* unit.events.publish(Info.make({
|
|
101
|
+
message: `harden: ${counted(left, "debt item was", "debt items were")} not planned (${notPlannedPath}); pass them to --harden as text if they still matter`
|
|
102
|
+
}));
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
/** `--harden`: the open debt planned as a round under epic-stories' full bar. */
|
|
106
|
+
export const hardenOptions = {
|
|
107
|
+
storyJudge: rubricStoryJudge,
|
|
108
|
+
refineFeedback: hardenFeedback,
|
|
109
|
+
afterUnit: hardenAfterUnit
|
|
110
|
+
};
|
|
111
|
+
export const parseStoriesFlags = (argv, environment) => Effect.gen(function* () {
|
|
112
|
+
const deferTests = argv.includes("--defer-tests") ||
|
|
113
|
+
/^(1|true|on|yes)$/i.test(environment.LLM4TS_DEFER_TESTS?.trim() ?? "");
|
|
114
|
+
const harden = argv.includes("--harden");
|
|
115
|
+
if (deferTests && harden) {
|
|
116
|
+
return yield* ScriptUsage.make({
|
|
117
|
+
message: `--harden pays the debt under the full bar; it does not take --defer-tests (or LLM4TS_DEFER_TESTS)\n${epicUsage}`
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
const rest = argv.filter((arg) => arg !== "--defer-tests" && arg !== "--harden");
|
|
121
|
+
return { deferTests, harden, argv: harden ? ["--refine", ...rest] : rest };
|
|
122
|
+
});
|
|
123
|
+
/** epic-stories' policy for its flags: the rubric judge, excusing tests, or hardening. */
|
|
124
|
+
export const epicStoriesOptions = (flags) => flags.harden
|
|
125
|
+
? hardenOptions
|
|
126
|
+
: flags.deferTests
|
|
127
|
+
? {
|
|
128
|
+
storyJudge: deferringTestsJudge,
|
|
129
|
+
stories: () => ({ judgeBar: "defer-tests" }),
|
|
130
|
+
afterUnit: testsDebtAfterUnit
|
|
131
|
+
}
|
|
132
|
+
: { storyJudge: rubricStoryJudge };
|
package/flows/lib/epic-draft.js
CHANGED
|
@@ -2,16 +2,15 @@
|
|
|
2
2
|
// deferred, not dropped. A draft run plans and builds without tests, under
|
|
3
3
|
// one review lens and the build gates; what it deferred is written to
|
|
4
4
|
// debt.md, and `--harden` plans that debt as a round under the full bar.
|
|
5
|
-
import { join } from "node:path";
|
|
6
5
|
import * as Effect from "effect/Effect";
|
|
7
6
|
import { Info } from "@llm4ts/flow/FlowEvents";
|
|
8
7
|
import { failingLinesOf } from "@llm4ts/flow/GateTriage";
|
|
9
|
-
import { countNotPlanned } from "@llm4ts/flow/RefineRound";
|
|
10
8
|
import { correctnessReviewer } from "@llm4ts/flow/Review";
|
|
11
9
|
import { deferredPath, EpicReport } from "@llm4ts/flow/Stories";
|
|
12
10
|
import { StoryPlan } from "@llm4ts/flow/StoryPlan";
|
|
13
11
|
import { structuredAndPublish } from "@llm4ts/flow/Usage";
|
|
14
|
-
import {
|
|
12
|
+
import { counted, debtPath, hardenOptions, paidPath } from "./debt.js";
|
|
13
|
+
import { storyPlanInstructions, storyPlanJsonSchema, withoutTestGate } from "./epic-stories.js";
|
|
15
14
|
export const parseDraftFlags = (argv) => {
|
|
16
15
|
const harden = argv.includes("--harden");
|
|
17
16
|
const withTests = argv.includes("--with-tests");
|
|
@@ -36,10 +35,8 @@ export const generateDraftPlan = (inputs) => structuredAndPublish(inputs.reasoni
|
|
|
36
35
|
// The id and the epic text are ours, whatever the model echoed back.
|
|
37
36
|
Effect.map((plan) => StoryPlan.make({ ...plan, epicId: inputs.epicId, epic: inputs.epic })));
|
|
38
37
|
// ---- The debt ---------------------------------------------------------------
|
|
39
|
-
|
|
40
|
-
export
|
|
41
|
-
/** Debt a settled hardening round consumed. */
|
|
42
|
-
export const paidPath = (stateDir) => join(stateDir, "debt.paid.md");
|
|
38
|
+
// The debt files and `--harden` live in debt.ts, shared with epic-stories (ADR 0045).
|
|
39
|
+
export { debtPath, hardenAfterUnit, hardenFeedback, paidPath } from "./debt.js";
|
|
43
40
|
const merged = (plan, report) => plan.stories.filter((story) => report.stories.some((outcome) => outcome.id === story.id && outcome.status === "done"));
|
|
44
41
|
export const renderDebt = (plan, report, deferred, testFailures) => [
|
|
45
42
|
`# Debt — ${plan.epicId}`,
|
|
@@ -55,7 +52,6 @@ export const renderDebt = (plan, report, deferred, testFailures) => [
|
|
|
55
52
|
? []
|
|
56
53
|
: ["## Test gate on the epic checkout (skipped while drafting)", "", testFailures, ""])
|
|
57
54
|
].join("\n");
|
|
58
|
-
const counted = (count, one, many) => `${count} ${count === 1 ? one : many}`;
|
|
59
55
|
/**
|
|
60
56
|
* After a draft unit: `debt.md` with the merged stories' owed tests, their
|
|
61
57
|
* deferred findings and, when the draft skipped it, one run of the test gate
|
|
@@ -98,67 +94,13 @@ export const draftAfterUnit = (withTests) => (unit, report) => Effect.gen(functi
|
|
|
98
94
|
: counted(findings.length, "deferred finding", "deferred findings")}${testFailures === undefined ? "" : ", test gate red"} — run epic-draft --harden (${path})`
|
|
99
95
|
}));
|
|
100
96
|
});
|
|
101
|
-
// ---- Hardening --------------------------------------------------------------
|
|
102
|
-
/** The open debt lists of an epic, by state folder; empty ones are paid. */
|
|
103
|
-
const openDebt = (files, epicStateDir) => Effect.gen(function* () {
|
|
104
|
-
const open = [];
|
|
105
|
-
for (const dir of yield* unitStateDirs(files, epicStateDir)) {
|
|
106
|
-
const text = (yield* files.read(debtPath(dir)))?.trim() ?? "";
|
|
107
|
-
if (text.length > 0) {
|
|
108
|
-
open.push({ dir, text });
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
return open;
|
|
112
|
-
});
|
|
113
|
-
/** `--harden`: the text given, then every open debt list, as the round to plan. */
|
|
114
|
-
export const hardenFeedback = (epicStateDir, text, files) => Effect.gen(function* () {
|
|
115
|
-
// An open round finishes before another is planned: with text, runAction would refuse it.
|
|
116
|
-
const rounds = yield* loadRounds(files, epicStateDir).pipe(Effect.catch(() => Effect.succeed([])));
|
|
117
|
-
const last = rounds.at(-1);
|
|
118
|
-
if (last?.plan !== undefined && last.merged < last.plan.stories.length) {
|
|
119
|
-
return text;
|
|
120
|
-
}
|
|
121
|
-
const open = yield* openDebt(files, epicStateDir);
|
|
122
|
-
return open.length === 0
|
|
123
|
-
? text
|
|
124
|
-
: [
|
|
125
|
-
"Pay this epic's draft debt: write the tests owed as deterministic tests in the house",
|
|
126
|
-
"style, make the test gate green, and close the findings that still apply.",
|
|
127
|
-
...(text.length === 0 ? [] : ["", text]),
|
|
128
|
-
"",
|
|
129
|
-
...open.flatMap((debt) => [debt.text, ""])
|
|
130
|
-
].join("\n");
|
|
131
|
-
});
|
|
132
|
-
/** After a hardening round that settled (nothing failed or waiting), its debt is paid. */
|
|
133
|
-
export const hardenAfterUnit = (unit, report) => Effect.gen(function* () {
|
|
134
|
-
if (report.count("failed") > 0 || report.count("waiting") > 0) {
|
|
135
|
-
return;
|
|
136
|
-
}
|
|
137
|
-
for (const debt of yield* openDebt(unit.files, unit.epicStateDir)) {
|
|
138
|
-
const paid = (yield* unit.files.read(paidPath(debt.dir))) ?? "";
|
|
139
|
-
yield* unit.files.writeAtomic(paidPath(debt.dir), `${paid}${debt.text}\n`);
|
|
140
|
-
yield* unit.files.writeAtomic(debtPath(debt.dir), "");
|
|
141
|
-
}
|
|
142
|
-
// What the round's planner left out is listed, never carried (ADR 0031 decision 6).
|
|
143
|
-
const notPlannedPath = join(unit.stateDir, "not-planned.md");
|
|
144
|
-
const left = countNotPlanned((yield* unit.files.read(notPlannedPath)) ?? "");
|
|
145
|
-
if (left > 0) {
|
|
146
|
-
yield* unit.events.publish(Info.make({
|
|
147
|
-
message: `harden: ${counted(left, "debt item was", "debt items were")} not planned (${notPlannedPath}); pass them to --harden as text if they still matter`
|
|
148
|
-
}));
|
|
149
|
-
}
|
|
150
|
-
});
|
|
151
97
|
// ---- The policy -------------------------------------------------------------
|
|
152
98
|
/**
|
|
153
99
|
* The epic-stories spine with epic-draft's policy (ADR 0043). A draft defers
|
|
154
100
|
* tests and validation; `--harden` runs epic-stories' own bar over the debt.
|
|
155
101
|
*/
|
|
156
102
|
export const epicDraftOptions = (flags) => flags.harden
|
|
157
|
-
?
|
|
158
|
-
storyJudge: rubricStoryJudge,
|
|
159
|
-
refineFeedback: hardenFeedback,
|
|
160
|
-
afterUnit: hardenAfterUnit
|
|
161
|
-
}
|
|
103
|
+
? hardenOptions
|
|
162
104
|
: {
|
|
163
105
|
generatePlan: generateDraftPlan,
|
|
164
106
|
stories: () => ({
|
|
@@ -73,6 +73,10 @@ export const epicUsage = [
|
|
|
73
73
|
" settle, the judge clears without them); the rest are deferred and, once",
|
|
74
74
|
" every story is merged, planned and run as one refine round.",
|
|
75
75
|
" LLM4TS_DEFER_FINDINGS=1 sets it too",
|
|
76
|
+
" --defer-tests the judge's tests score, even 0, never fails a story: the tests it",
|
|
77
|
+
" excused are written to debt.md (the coder is still asked for tests;",
|
|
78
|
+
" a red test gate still fails). LLM4TS_DEFER_TESTS=1 sets it too",
|
|
79
|
+
" --harden plan the epic's open debt as a refine round under the full bar",
|
|
76
80
|
"Seats: LLM4TS_REASONER (claude|gemini|…, default claude) splits, reviews, judges;",
|
|
77
81
|
" LLM4TS_CODER (default pi) implements; LLM4TS_REASONING_MODEL / LLM4TS_CODER_MODEL",
|
|
78
82
|
" pick their models (pi: provider/model); LLM4TS_CODER_FLAGS / LLM4TS_REASONING_FLAGS",
|
|
@@ -1038,15 +1042,18 @@ export const storyDimensions = [
|
|
|
1038
1042
|
* a partial score elsewhere is a Warning a follow-up round can close.
|
|
1039
1043
|
*/
|
|
1040
1044
|
export const judgeSeverity = (name, score) => name === "provides" || score <= 0 ? "Critical" : "Warning";
|
|
1041
|
-
/**
|
|
1042
|
-
|
|
1045
|
+
/**
|
|
1046
|
+
* The dimensions a story is held to: all of them, less `tests` when the plan
|
|
1047
|
+
* deferred them or the run's bar does.
|
|
1048
|
+
*/
|
|
1049
|
+
export const judgedDimensionsOf = (story, bar = {}) => storyDimensions
|
|
1043
1050
|
.map((dimension) => dimension.name)
|
|
1044
|
-
.filter((name) => story.testsDeferred === undefined
|
|
1045
|
-
const
|
|
1051
|
+
.filter((name) => name !== "tests" || (story.testsDeferred === undefined && bar.deferTests !== true));
|
|
1052
|
+
const maxScoreOf = (name) => storyDimensions.find((dimension) => dimension.name === name)?.maxScore ?? 2;
|
|
1053
|
+
const subBar = (scored, story, bar) => ReviewResult.make({
|
|
1046
1054
|
issues: scored.scores
|
|
1047
|
-
.filter((score) => judgedDimensionsOf(story).includes(score.name) &&
|
|
1048
|
-
score.score <
|
|
1049
|
-
(storyDimensions.find((dimension) => dimension.name === score.name)?.maxScore ?? 2))
|
|
1055
|
+
.filter((score) => judgedDimensionsOf(story, bar).includes(score.name) &&
|
|
1056
|
+
score.score < maxScoreOf(score.name))
|
|
1050
1057
|
.map((score) => ReviewIssue.make({
|
|
1051
1058
|
severity: judgeSeverity(score.name, score.score),
|
|
1052
1059
|
title: `judge[${story.id}]: ${score.name} scored ${score.score}`,
|
|
@@ -1061,6 +1068,11 @@ export const testsDeferredLines = (reason) => reason === undefined
|
|
|
1061
1068
|
`Tests deferred by the plan: ${reason}`,
|
|
1062
1069
|
"No test file is expected; do not mark any dimension down for its absence."
|
|
1063
1070
|
];
|
|
1071
|
+
/** What a judge is told under `--defer-tests` (ADR 0045): score tests, hold nothing else to them. */
|
|
1072
|
+
export const deferTestsLines = [
|
|
1073
|
+
"Tests may be deferred to a later round: score `tests` on what the diff shows,",
|
|
1074
|
+
"but do not mark any other dimension down because tests are missing."
|
|
1075
|
+
];
|
|
1064
1076
|
/**
|
|
1065
1077
|
* What the story judge reads beside the diff: the story, and what every
|
|
1066
1078
|
* story it depends on provides. Without the dependencies the judge cannot
|
|
@@ -1068,7 +1080,7 @@ export const testsDeferredLines = (reason) => reason === undefined
|
|
|
1068
1080
|
* importing `paymentsDomain` from `payments.fake.ts`, exactly where the
|
|
1069
1081
|
* payments contract story declared it.
|
|
1070
1082
|
*/
|
|
1071
|
-
export const storyJudgeQuery = (story, plan, subject = "diff") => {
|
|
1083
|
+
export const storyJudgeQuery = (story, plan, subject = "diff", bar = {}) => {
|
|
1072
1084
|
const dependencies = plan === undefined
|
|
1073
1085
|
? []
|
|
1074
1086
|
: dependenciesOf(plan, story.id).flatMap((id) => {
|
|
@@ -1092,6 +1104,7 @@ export const storyJudgeQuery = (story, plan, subject = "diff") => {
|
|
|
1092
1104
|
`Owned paths: ${story.owned.join(", ")}`,
|
|
1093
1105
|
`Shared read-only: ${story.sharedReadOnly.join(", ") || "(none)"}`,
|
|
1094
1106
|
...testsDeferredLines(story.testsDeferred),
|
|
1107
|
+
...(story.testsDeferred === undefined && bar.deferTests === true ? deferTestsLines : []),
|
|
1095
1108
|
...(dependencies.length === 0
|
|
1096
1109
|
? []
|
|
1097
1110
|
: [
|
|
@@ -1117,19 +1130,32 @@ export const storyJudgeQuery = (story, plan, subject = "diff") => {
|
|
|
1117
1130
|
].join("\n");
|
|
1118
1131
|
};
|
|
1119
1132
|
/** The story-level judge over the branch diff, bounded by the character budget. */
|
|
1120
|
-
export const judgeStory = (reasoning, story, diff, budget, plan, subject = "diff") => judge(reasoning, storyDimensions)
|
|
1133
|
+
export const judgeStory = (reasoning, story, diff, budget, plan, subject = "diff", bar = {}) => judge(reasoning, storyDimensions)
|
|
1121
1134
|
.evaluate(Sample.make({
|
|
1122
|
-
query: storyJudgeQuery(story, plan, subject),
|
|
1135
|
+
query: storyJudgeQuery(story, plan, subject, bar),
|
|
1123
1136
|
response: cap(diff, budget).text
|
|
1124
1137
|
}))
|
|
1125
|
-
.pipe(Effect.mapError(FlowLlmError.from), Effect.map((scored) =>
|
|
1126
|
-
|
|
1127
|
-
|
|
1138
|
+
.pipe(Effect.mapError(FlowLlmError.from), Effect.map((scored) => {
|
|
1139
|
+
// Below the bar but not held to it: kept, with why, for the debt (ADR 0045).
|
|
1140
|
+
const excused = scored.scores
|
|
1141
|
+
.filter((score) => !judgedDimensionsOf(story, bar).includes(score.name) &&
|
|
1142
|
+
score.score < maxScoreOf(score.name))
|
|
1143
|
+
.map((score) => ({
|
|
1128
1144
|
id: score.name,
|
|
1129
1145
|
score: score.score,
|
|
1130
|
-
max:
|
|
1131
|
-
|
|
1132
|
-
}))
|
|
1146
|
+
max: maxScoreOf(score.name),
|
|
1147
|
+
reasoning: score.reasoning
|
|
1148
|
+
}));
|
|
1149
|
+
return StoryVerdict.make({
|
|
1150
|
+
...subBar(scored, story, bar),
|
|
1151
|
+
dimensions: scored.scores.map((score) => ({
|
|
1152
|
+
id: score.name,
|
|
1153
|
+
score: score.score,
|
|
1154
|
+
max: maxScoreOf(score.name)
|
|
1155
|
+
})),
|
|
1156
|
+
...(excused.length === 0 ? {} : { excused })
|
|
1157
|
+
});
|
|
1158
|
+
}));
|
|
1133
1159
|
// ---- App directory ----------------------------------------------------------------
|
|
1134
1160
|
/** Folders never taken for the application: dependencies, build output, llm4ts state. */
|
|
1135
1161
|
const ignoredAppDirs = new Set(["node_modules", "dist", "build", "out", "coverage"]);
|
|
@@ -1450,6 +1476,8 @@ export const rubricStoryJudge = (context) => (story, diff, seats, subject) => ju
|
|
|
1450
1476
|
// The story's own seat, not the run's: its time counts for the story.
|
|
1451
1477
|
// And the judge seat, never the writing reasoner: a judge reads.
|
|
1452
1478
|
seats.context.roster?.forRole("judge") ?? seats.context.judge ?? seats.context.reasoning, story, diff, context.budget, context.plan, subject);
|
|
1479
|
+
/** The rubric judge under `--defer-tests` (ADR 0045): `tests` is scored, never a finding. */
|
|
1480
|
+
export const deferringTestsJudge = (context) => (story, diff, seats, subject) => judgeStory(seats.context.roster?.forRole("judge") ?? seats.context.judge ?? seats.context.reasoning, story, diff, context.budget, context.plan, subject, { deferTests: true });
|
|
1453
1481
|
/** A unit's state folders, the epic's and then each planned round's (ADR 0021). */
|
|
1454
1482
|
export const unitStateDirs = (files, epicStateDir) => Effect.gen(function* () {
|
|
1455
1483
|
const dirs = [epicStateDir];
|
|
@@ -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.
|
|
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/
|
|
65
|
-
"@llm4ts/
|
|
66
|
-
"@llm4ts/runner": "2.
|
|
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"
|