@llm4ts/shell 2.38.0 → 2.40.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/lib/epic-stories.js +188 -11
- package/package.json +4 -4
|
@@ -38,7 +38,7 @@ import { budget } from "@llm4ts/flow/Context";
|
|
|
38
38
|
import { makeLocalBoardSync } from "@llm4ts/flow/BoardSync";
|
|
39
39
|
import { estimatedUsageOptionsFromEnv, makeEstimatedUsageMeter } from "@llm4ts/flow/EstimatedUsage";
|
|
40
40
|
import { landEpic } from "@llm4ts/flow/Landing";
|
|
41
|
-
import { implementStoriesFlow } from "@llm4ts/flow/Stories";
|
|
41
|
+
import { deferredPath, implementStoriesFlow } from "@llm4ts/flow/Stories";
|
|
42
42
|
import { validateStoryPlan } from "@llm4ts/flow/StoryPlan";
|
|
43
43
|
import { asReadOnly, nodePlainFileStore, nodeProcessExecutor, resolveFlowInput, runNode, stage, withModel } from "@llm4ts/runner";
|
|
44
44
|
import { claude, coderIds, judgmentConnectorFromEnvironment, pi } from "@llm4ts/runner/Connectors";
|
|
@@ -58,6 +58,15 @@ export const epicUsage = [
|
|
|
58
58
|
" A plain rerun finishes an open round; --plan-only stops after planning",
|
|
59
59
|
" --concurrency <n> stories implemented at once (default 3)",
|
|
60
60
|
" --fail-fast stop the epic at the first failed story",
|
|
61
|
+
" --judge-rounds <n> judge attempts per story (default 2 = one revision); a story the judge",
|
|
62
|
+
" has not cleared after n attempts fails. LLM4TS_JUDGE_ROUNDS sets it too",
|
|
63
|
+
" --merge-revisions <n> times a story whose merge turns the epic gates red goes back to",
|
|
64
|
+
" catch up and fix it before failing (default 1; 0 fails at once).",
|
|
65
|
+
" LLM4TS_MERGE_REVISIONS sets it too",
|
|
66
|
+
" --defer-findings lighter review: only Critical findings are fixed in place (reviews",
|
|
67
|
+
" settle, the judge clears without them); the rest are deferred and, once",
|
|
68
|
+
" every story is merged, planned and run as one refine round.",
|
|
69
|
+
" LLM4TS_DEFER_FINDINGS=1 sets it too",
|
|
61
70
|
"Seats: LLM4TS_REASONER (claude|gemini|…, default claude) splits, reviews, judges;",
|
|
62
71
|
" LLM4TS_CODER (default pi) implements; LLM4TS_REASONING_MODEL / LLM4TS_CODER_MODEL",
|
|
63
72
|
" pick their models (pi: provider/model); LLM4TS_CODER_FLAGS / LLM4TS_REASONING_FLAGS",
|
|
@@ -75,6 +84,9 @@ export const parseEpicArgs = (argv) => Effect.gen(function* () {
|
|
|
75
84
|
let planOnly = false;
|
|
76
85
|
let failFast = false;
|
|
77
86
|
let concurrency;
|
|
87
|
+
let judgeRounds;
|
|
88
|
+
let deferFindings = false;
|
|
89
|
+
let mergeRevisions;
|
|
78
90
|
let land;
|
|
79
91
|
let keepWorktrees = false;
|
|
80
92
|
let list = false;
|
|
@@ -89,6 +101,9 @@ export const parseEpicArgs = (argv) => Effect.gen(function* () {
|
|
|
89
101
|
else if (argument === "--fail-fast") {
|
|
90
102
|
failFast = true;
|
|
91
103
|
}
|
|
104
|
+
else if (argument === "--defer-findings") {
|
|
105
|
+
deferFindings = true;
|
|
106
|
+
}
|
|
92
107
|
else if (argument === "--keep-worktrees") {
|
|
93
108
|
keepWorktrees = true;
|
|
94
109
|
}
|
|
@@ -131,12 +146,95 @@ export const parseEpicArgs = (argv) => Effect.gen(function* () {
|
|
|
131
146
|
}
|
|
132
147
|
concurrency = parsed;
|
|
133
148
|
}
|
|
149
|
+
else if (argument === "--merge-revisions" || argument.startsWith("--merge-revisions=")) {
|
|
150
|
+
const raw = argument.includes("=")
|
|
151
|
+
? argument.slice("--merge-revisions=".length)
|
|
152
|
+
: argv[index + 1];
|
|
153
|
+
if (!argument.includes("=")) {
|
|
154
|
+
index += 1;
|
|
155
|
+
}
|
|
156
|
+
const parsed = Number.parseInt(raw ?? "", 10);
|
|
157
|
+
if (!Number.isInteger(parsed) || parsed < 0 || String(parsed) !== (raw ?? "").trim()) {
|
|
158
|
+
return yield* ScriptUsage.make({
|
|
159
|
+
message: `--merge-revisions requires a non-negative integer\n${epicUsage}`
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
mergeRevisions = parsed;
|
|
163
|
+
}
|
|
164
|
+
else if (argument === "--judge-rounds" || argument.startsWith("--judge-rounds=")) {
|
|
165
|
+
const raw = argument.includes("=")
|
|
166
|
+
? argument.slice("--judge-rounds=".length)
|
|
167
|
+
: argv[index + 1];
|
|
168
|
+
if (!argument.includes("=")) {
|
|
169
|
+
index += 1;
|
|
170
|
+
}
|
|
171
|
+
const parsed = Number.parseInt(raw ?? "", 10);
|
|
172
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
173
|
+
return yield* ScriptUsage.make({
|
|
174
|
+
message: `--judge-rounds requires a positive integer\n${epicUsage}`
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
judgeRounds = parsed;
|
|
178
|
+
}
|
|
134
179
|
else {
|
|
135
180
|
rest.push(argument);
|
|
136
181
|
}
|
|
137
182
|
}
|
|
138
|
-
return {
|
|
183
|
+
return {
|
|
184
|
+
planOnly,
|
|
185
|
+
failFast,
|
|
186
|
+
concurrency,
|
|
187
|
+
judgeRounds,
|
|
188
|
+
deferFindings,
|
|
189
|
+
mergeRevisions,
|
|
190
|
+
land,
|
|
191
|
+
keepWorktrees,
|
|
192
|
+
list,
|
|
193
|
+
epic,
|
|
194
|
+
refine,
|
|
195
|
+
rest
|
|
196
|
+
};
|
|
197
|
+
});
|
|
198
|
+
/**
|
|
199
|
+
* Merge revisions per story: `--merge-revisions`, else `LLM4TS_MERGE_REVISIONS`,
|
|
200
|
+
* else the executor's default. An unusable environment value falls back.
|
|
201
|
+
*/
|
|
202
|
+
export const mergeRevisionsOption = (flag, environment) => {
|
|
203
|
+
if (flag !== undefined) {
|
|
204
|
+
return { mergeRevisions: flag };
|
|
205
|
+
}
|
|
206
|
+
const raw = environment.LLM4TS_MERGE_REVISIONS?.trim() ?? "";
|
|
207
|
+
return /^\d+$/u.test(raw) ? { mergeRevisions: Number.parseInt(raw, 10) } : {};
|
|
208
|
+
};
|
|
209
|
+
/** `--defer-findings`, or `LLM4TS_DEFER_FINDINGS` set to 1/true/on/yes (ADR 0031). */
|
|
210
|
+
export const deferFindingsEnabled = (flag, environment) => flag || /^(1|true|on|yes)$/i.test(environment.LLM4TS_DEFER_FINDINGS?.trim() ?? "");
|
|
211
|
+
/**
|
|
212
|
+
* The feedback a follow-up round is planned from (ADR 0031): every story's
|
|
213
|
+
* deferred findings, in plan order. `undefined` when nothing was deferred.
|
|
214
|
+
*/
|
|
215
|
+
export const deferredFeedback = (files, stateDir, plan) => Effect.map(Effect.forEach(plan.stories, (story) => files.read(deferredPath(stateDir, story.id))), (texts) => {
|
|
216
|
+
const kept = texts.flatMap((text) => text === undefined || text.trim().length === 0 ? [] : [text.trim()]);
|
|
217
|
+
return kept.length === 0
|
|
218
|
+
? undefined
|
|
219
|
+
: [
|
|
220
|
+
"Findings the review and the judge deferred while the epic's stories ran (non-blocking:",
|
|
221
|
+
"each story was merged with them open). Close the ones that still apply; a finding the",
|
|
222
|
+
"code already answers, or that would undo a deliberate choice, goes to not-planned.",
|
|
223
|
+
"",
|
|
224
|
+
...kept.flatMap((text) => [text, ""])
|
|
225
|
+
].join("\n");
|
|
139
226
|
});
|
|
227
|
+
/**
|
|
228
|
+
* Judge attempts per story: `--judge-rounds`, else `LLM4TS_JUDGE_ROUNDS`, else
|
|
229
|
+
* the flow's default. An unusable environment value falls back, never fails.
|
|
230
|
+
*/
|
|
231
|
+
export const judgeRoundsOption = (flag, environment) => {
|
|
232
|
+
if (flag !== undefined) {
|
|
233
|
+
return { judgeRounds: flag };
|
|
234
|
+
}
|
|
235
|
+
const raw = Number.parseInt(environment.LLM4TS_JUDGE_ROUNDS ?? "", 10);
|
|
236
|
+
return Number.isInteger(raw) && raw > 0 ? { judgeRounds: raw } : {};
|
|
237
|
+
};
|
|
140
238
|
export const roundDir = (stateDir, round) => join(stateDir, "rounds", String(round));
|
|
141
239
|
const mergedStories = (files, stateDir, plan) => Effect.gen(function* () {
|
|
142
240
|
let merged = 0;
|
|
@@ -861,12 +959,18 @@ export const storyDimensions = [
|
|
|
861
959
|
rubric: "The story ships deterministic tests in the house style covering its screens or contract. 2 = yes; 1 = thin; 0 = none."
|
|
862
960
|
})
|
|
863
961
|
];
|
|
962
|
+
/**
|
|
963
|
+
* How much a dimension below the bar blocks (ADR 0031): `provides` short of
|
|
964
|
+
* full always does, since dependents build on it; any dimension at 0 does;
|
|
965
|
+
* a partial score elsewhere is a Warning a follow-up round can close.
|
|
966
|
+
*/
|
|
967
|
+
export const judgeSeverity = (name, score) => name === "provides" || score <= 0 ? "Critical" : "Warning";
|
|
864
968
|
const subBar = (scored, story) => ReviewResult.make({
|
|
865
969
|
issues: scored.scores
|
|
866
970
|
.filter((score) => score.score <
|
|
867
971
|
(storyDimensions.find((dimension) => dimension.name === score.name)?.maxScore ?? 2))
|
|
868
972
|
.map((score) => ReviewIssue.make({
|
|
869
|
-
severity:
|
|
973
|
+
severity: judgeSeverity(score.name, score.score),
|
|
870
974
|
title: `judge[${story.id}]: ${score.name} scored ${score.score}`,
|
|
871
975
|
description: score.reasoning
|
|
872
976
|
})),
|
|
@@ -1484,6 +1588,7 @@ export const runEpicStories = (options) => Effect.gen(function* () {
|
|
|
1484
1588
|
}));
|
|
1485
1589
|
return;
|
|
1486
1590
|
}
|
|
1591
|
+
const deferring = deferFindingsEnabled(flags.deferFindings, process.env);
|
|
1487
1592
|
// With a roster, the default is every coder slot it has; the flag caps it.
|
|
1488
1593
|
const concurrency = flags.concurrency ?? context.roster?.slots("coder") ?? 3;
|
|
1489
1594
|
if (context.roster === undefined && localServer !== undefined && concurrency > 1) {
|
|
@@ -1509,13 +1614,14 @@ export const runEpicStories = (options) => Effect.gen(function* () {
|
|
|
1509
1614
|
const testCommand = testGateCommand(commands);
|
|
1510
1615
|
const setupCommand = worktreeSetupCommand(process.env);
|
|
1511
1616
|
const healthUrl = serverHealthUrl(localServer, process.env);
|
|
1512
|
-
|
|
1513
|
-
|
|
1617
|
+
// The executor over one unit: the epic's plan or one refine round.
|
|
1618
|
+
const runUnit = (work) => implementStoriesFlow({ ...context, reasoning: reasoningMeter.service }, {
|
|
1619
|
+
plan: work.plan,
|
|
1514
1620
|
files,
|
|
1515
|
-
stateDir:
|
|
1621
|
+
stateDir: work.stateDir,
|
|
1516
1622
|
epicBranch,
|
|
1517
1623
|
worktreeRoot: worktreeRootFor(input.workDir, plan.epicId, process.env),
|
|
1518
|
-
board: makeLocalBoardSync(files,
|
|
1624
|
+
board: makeLocalBoardSync(files, work.stateDir, work.label),
|
|
1519
1625
|
contextFor: (workDir, contextOptions) => Effect.gen(function* () {
|
|
1520
1626
|
const rebound = yield* contextFor(workDir, contextOptions);
|
|
1521
1627
|
const coderMeter = yield* makeEstimatedUsageMeter(rebound.coder, estimateOptions);
|
|
@@ -1561,14 +1667,14 @@ export const runEpicStories = (options) => Effect.gen(function* () {
|
|
|
1561
1667
|
// away from the executor coding it (ADR 0019).
|
|
1562
1668
|
...storyContextChars(process.env),
|
|
1563
1669
|
judge: options.storyJudge({
|
|
1564
|
-
plan:
|
|
1670
|
+
plan: work.plan,
|
|
1565
1671
|
budget: contextBudget,
|
|
1566
1672
|
reasoning: reasoningMeter.service,
|
|
1567
1673
|
events,
|
|
1568
1674
|
files,
|
|
1569
1675
|
houseRules: guidance
|
|
1570
1676
|
}),
|
|
1571
|
-
verifyBlocked: (story, need, workDir, seats) => verifyBlockedOn(seats.context.roster?.forRole("verifier") ?? seats.context.reasoning, events, files,
|
|
1677
|
+
verifyBlocked: (story, need, workDir, seats) => verifyBlockedOn(seats.context.roster?.forRole("verifier") ?? seats.context.reasoning, events, files, work.plan)(story, need, workDir),
|
|
1572
1678
|
// A roster waits for its own executors (the story's next lease
|
|
1573
1679
|
// does); without one, poll the single coder's engine.
|
|
1574
1680
|
awaitRecovery: context.roster === undefined
|
|
@@ -1582,16 +1688,87 @@ export const runEpicStories = (options) => Effect.gen(function* () {
|
|
|
1582
1688
|
...appDirNote,
|
|
1583
1689
|
...(orientation === undefined ? [] : ["", orientation])
|
|
1584
1690
|
].join("\n")),
|
|
1691
|
+
...judgeRoundsOption(flags.judgeRounds, process.env),
|
|
1692
|
+
...mergeRevisionsOption(flags.mergeRevisions, process.env),
|
|
1585
1693
|
concurrency,
|
|
1694
|
+
...(deferring ? { deferNonBlocking: true } : {}),
|
|
1586
1695
|
failFast: flags.failFast
|
|
1587
1696
|
});
|
|
1588
|
-
|
|
1589
|
-
message: `${
|
|
1697
|
+
const summarize = (work, report) => events.publish(Info.make({
|
|
1698
|
+
message: `${work.label.replace("Epic: ", "epic ")}: ${report.count("done")} done, ${report.count("failed")} failed, ${report.count("waiting")} waiting — report at ${join(work.stateDir, "report.md")} (usage figures estimated)`
|
|
1590
1699
|
}));
|
|
1700
|
+
const report = yield* runUnit(unit);
|
|
1701
|
+
yield* summarize(unit, report);
|
|
1591
1702
|
const hint = retroHint(report, input.workDir, epicId);
|
|
1592
1703
|
if (hint !== undefined) {
|
|
1593
1704
|
yield* events.publish(Info.make({ message: hint }));
|
|
1594
1705
|
}
|
|
1706
|
+
if (!deferring) {
|
|
1707
|
+
return;
|
|
1708
|
+
}
|
|
1709
|
+
// Deferred findings (ADR 0031) become one refine round once the
|
|
1710
|
+
// epic's own stories are all merged. A round's own deferrals are
|
|
1711
|
+
// listed, never planned again, so the follow-up cannot loop.
|
|
1712
|
+
const feedbackText = yield* deferredFeedback(files, unit.stateDir, unit.plan);
|
|
1713
|
+
if (feedbackText === undefined) {
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1716
|
+
const settledPlan = report.count("failed") === 0 && report.count("waiting") === 0;
|
|
1717
|
+
if (unit.stateDir !== stateDir || !settledPlan) {
|
|
1718
|
+
yield* events.publish(Info.make({
|
|
1719
|
+
message: `${unit.label.replace("Epic: ", "epic ")}: findings deferred in ${join(unit.stateDir, "stories")}/*.deferred.md${unit.stateDir === stateDir
|
|
1720
|
+
? "; they become a refine round once every story is merged"
|
|
1721
|
+
: "; pass them to --refine if they still matter"}`
|
|
1722
|
+
}));
|
|
1723
|
+
return;
|
|
1724
|
+
}
|
|
1725
|
+
const stray = statusPaths(yield* context.git.status);
|
|
1726
|
+
if (stray.length > 0) {
|
|
1727
|
+
yield* events.publish(Info.make({
|
|
1728
|
+
message: `deferred findings not planned: the epic checkout has uncommitted changes (${stray.join(", ")}); commit or discard them, then pass ${join(stateDir, "stories")}/*.deferred.md to --refine`
|
|
1729
|
+
}));
|
|
1730
|
+
return;
|
|
1731
|
+
}
|
|
1732
|
+
yield* stage(events, "epic branch", context.git.checkoutOrCreate(epicBranch));
|
|
1733
|
+
const followUp = rounds.length + 1;
|
|
1734
|
+
const roundBrief = yield* files.read(join(stateDir, "brief.md"));
|
|
1735
|
+
const planned = yield* stage(events, `refine round ${followUp} plan (deferred findings)`, planRound({
|
|
1736
|
+
files,
|
|
1737
|
+
reasoning: reasoningMeter.service,
|
|
1738
|
+
events,
|
|
1739
|
+
stateDir,
|
|
1740
|
+
epicId: plan.epicId,
|
|
1741
|
+
round: followUp,
|
|
1742
|
+
feedback: feedbackText,
|
|
1743
|
+
guidance,
|
|
1744
|
+
plans: [unit.plan],
|
|
1745
|
+
...(roundBrief === undefined ? {} : { brief: roundBrief }),
|
|
1746
|
+
...(orientation === undefined ? {} : { orientation }),
|
|
1747
|
+
git: context.git
|
|
1748
|
+
}));
|
|
1749
|
+
for (const left of planned.notPlanned) {
|
|
1750
|
+
yield* events.publish(Info.make({ message: `not planned: ${left.item} — ${left.reason}` }));
|
|
1751
|
+
}
|
|
1752
|
+
if (planned.plan === undefined) {
|
|
1753
|
+
yield* events.publish(Info.make({
|
|
1754
|
+
message: `refine round ${followUp}: no deferred finding needed a story; nothing to run`
|
|
1755
|
+
}));
|
|
1756
|
+
return;
|
|
1757
|
+
}
|
|
1758
|
+
const roundUnit = {
|
|
1759
|
+
plan: planned.plan,
|
|
1760
|
+
stateDir: roundDir(stateDir, followUp),
|
|
1761
|
+
label: `Epic: ${plan.epicId} · round ${followUp}`
|
|
1762
|
+
};
|
|
1763
|
+
yield* events.publish(Info.make({
|
|
1764
|
+
message: `refine round ${followUp}: ${planned.plan.stories.length} follow-up stories from deferred findings at ${join(roundUnit.stateDir, "plan.md")}`
|
|
1765
|
+
}));
|
|
1766
|
+
const roundReport = yield* runUnit(roundUnit);
|
|
1767
|
+
yield* summarize(roundUnit, roundReport);
|
|
1768
|
+
const roundHint = retroHint(roundReport, input.workDir, epicId);
|
|
1769
|
+
if (roundHint !== undefined) {
|
|
1770
|
+
yield* events.publish(Info.make({ message: roundHint }));
|
|
1771
|
+
}
|
|
1595
1772
|
}));
|
|
1596
1773
|
});
|
|
1597
1774
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@llm4ts/shell",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.40.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",
|
|
@@ -52,9 +52,9 @@
|
|
|
52
52
|
"dependencies": {
|
|
53
53
|
"@effect/platform-node": "4.0.0",
|
|
54
54
|
"@effect/platform-node-shared": "4.0.0",
|
|
55
|
-
"@llm4ts/core": "2.
|
|
56
|
-
"@llm4ts/
|
|
57
|
-
"@llm4ts/
|
|
55
|
+
"@llm4ts/core": "2.40.0",
|
|
56
|
+
"@llm4ts/flow": "2.40.0",
|
|
57
|
+
"@llm4ts/runner": "2.40.0"
|
|
58
58
|
},
|
|
59
59
|
"peerDependencies": {
|
|
60
60
|
"effect": "4.0.0"
|