@llm4ts/shell 2.38.0 → 2.39.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 +156 -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,12 @@ 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
|
+
" --defer-findings lighter review: only Critical findings are fixed in place (reviews",
|
|
64
|
+
" settle, the judge clears without them); the rest are deferred and, once",
|
|
65
|
+
" every story is merged, planned and run as one refine round.",
|
|
66
|
+
" LLM4TS_DEFER_FINDINGS=1 sets it too",
|
|
61
67
|
"Seats: LLM4TS_REASONER (claude|gemini|…, default claude) splits, reviews, judges;",
|
|
62
68
|
" LLM4TS_CODER (default pi) implements; LLM4TS_REASONING_MODEL / LLM4TS_CODER_MODEL",
|
|
63
69
|
" pick their models (pi: provider/model); LLM4TS_CODER_FLAGS / LLM4TS_REASONING_FLAGS",
|
|
@@ -75,6 +81,8 @@ export const parseEpicArgs = (argv) => Effect.gen(function* () {
|
|
|
75
81
|
let planOnly = false;
|
|
76
82
|
let failFast = false;
|
|
77
83
|
let concurrency;
|
|
84
|
+
let judgeRounds;
|
|
85
|
+
let deferFindings = false;
|
|
78
86
|
let land;
|
|
79
87
|
let keepWorktrees = false;
|
|
80
88
|
let list = false;
|
|
@@ -89,6 +97,9 @@ export const parseEpicArgs = (argv) => Effect.gen(function* () {
|
|
|
89
97
|
else if (argument === "--fail-fast") {
|
|
90
98
|
failFast = true;
|
|
91
99
|
}
|
|
100
|
+
else if (argument === "--defer-findings") {
|
|
101
|
+
deferFindings = true;
|
|
102
|
+
}
|
|
92
103
|
else if (argument === "--keep-worktrees") {
|
|
93
104
|
keepWorktrees = true;
|
|
94
105
|
}
|
|
@@ -131,12 +142,68 @@ export const parseEpicArgs = (argv) => Effect.gen(function* () {
|
|
|
131
142
|
}
|
|
132
143
|
concurrency = parsed;
|
|
133
144
|
}
|
|
145
|
+
else if (argument === "--judge-rounds" || argument.startsWith("--judge-rounds=")) {
|
|
146
|
+
const raw = argument.includes("=")
|
|
147
|
+
? argument.slice("--judge-rounds=".length)
|
|
148
|
+
: argv[index + 1];
|
|
149
|
+
if (!argument.includes("=")) {
|
|
150
|
+
index += 1;
|
|
151
|
+
}
|
|
152
|
+
const parsed = Number.parseInt(raw ?? "", 10);
|
|
153
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
154
|
+
return yield* ScriptUsage.make({
|
|
155
|
+
message: `--judge-rounds requires a positive integer\n${epicUsage}`
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
judgeRounds = parsed;
|
|
159
|
+
}
|
|
134
160
|
else {
|
|
135
161
|
rest.push(argument);
|
|
136
162
|
}
|
|
137
163
|
}
|
|
138
|
-
return {
|
|
164
|
+
return {
|
|
165
|
+
planOnly,
|
|
166
|
+
failFast,
|
|
167
|
+
concurrency,
|
|
168
|
+
judgeRounds,
|
|
169
|
+
deferFindings,
|
|
170
|
+
land,
|
|
171
|
+
keepWorktrees,
|
|
172
|
+
list,
|
|
173
|
+
epic,
|
|
174
|
+
refine,
|
|
175
|
+
rest
|
|
176
|
+
};
|
|
139
177
|
});
|
|
178
|
+
/** `--defer-findings`, or `LLM4TS_DEFER_FINDINGS` set to 1/true/on/yes (ADR 0031). */
|
|
179
|
+
export const deferFindingsEnabled = (flag, environment) => flag || /^(1|true|on|yes)$/i.test(environment.LLM4TS_DEFER_FINDINGS?.trim() ?? "");
|
|
180
|
+
/**
|
|
181
|
+
* The feedback a follow-up round is planned from (ADR 0031): every story's
|
|
182
|
+
* deferred findings, in plan order. `undefined` when nothing was deferred.
|
|
183
|
+
*/
|
|
184
|
+
export const deferredFeedback = (files, stateDir, plan) => Effect.map(Effect.forEach(plan.stories, (story) => files.read(deferredPath(stateDir, story.id))), (texts) => {
|
|
185
|
+
const kept = texts.flatMap((text) => text === undefined || text.trim().length === 0 ? [] : [text.trim()]);
|
|
186
|
+
return kept.length === 0
|
|
187
|
+
? undefined
|
|
188
|
+
: [
|
|
189
|
+
"Findings the review and the judge deferred while the epic's stories ran (non-blocking:",
|
|
190
|
+
"each story was merged with them open). Close the ones that still apply; a finding the",
|
|
191
|
+
"code already answers, or that would undo a deliberate choice, goes to not-planned.",
|
|
192
|
+
"",
|
|
193
|
+
...kept.flatMap((text) => [text, ""])
|
|
194
|
+
].join("\n");
|
|
195
|
+
});
|
|
196
|
+
/**
|
|
197
|
+
* Judge attempts per story: `--judge-rounds`, else `LLM4TS_JUDGE_ROUNDS`, else
|
|
198
|
+
* the flow's default. An unusable environment value falls back, never fails.
|
|
199
|
+
*/
|
|
200
|
+
export const judgeRoundsOption = (flag, environment) => {
|
|
201
|
+
if (flag !== undefined) {
|
|
202
|
+
return { judgeRounds: flag };
|
|
203
|
+
}
|
|
204
|
+
const raw = Number.parseInt(environment.LLM4TS_JUDGE_ROUNDS ?? "", 10);
|
|
205
|
+
return Number.isInteger(raw) && raw > 0 ? { judgeRounds: raw } : {};
|
|
206
|
+
};
|
|
140
207
|
export const roundDir = (stateDir, round) => join(stateDir, "rounds", String(round));
|
|
141
208
|
const mergedStories = (files, stateDir, plan) => Effect.gen(function* () {
|
|
142
209
|
let merged = 0;
|
|
@@ -861,12 +928,18 @@ export const storyDimensions = [
|
|
|
861
928
|
rubric: "The story ships deterministic tests in the house style covering its screens or contract. 2 = yes; 1 = thin; 0 = none."
|
|
862
929
|
})
|
|
863
930
|
];
|
|
931
|
+
/**
|
|
932
|
+
* How much a dimension below the bar blocks (ADR 0031): `provides` short of
|
|
933
|
+
* full always does, since dependents build on it; any dimension at 0 does;
|
|
934
|
+
* a partial score elsewhere is a Warning a follow-up round can close.
|
|
935
|
+
*/
|
|
936
|
+
export const judgeSeverity = (name, score) => name === "provides" || score <= 0 ? "Critical" : "Warning";
|
|
864
937
|
const subBar = (scored, story) => ReviewResult.make({
|
|
865
938
|
issues: scored.scores
|
|
866
939
|
.filter((score) => score.score <
|
|
867
940
|
(storyDimensions.find((dimension) => dimension.name === score.name)?.maxScore ?? 2))
|
|
868
941
|
.map((score) => ReviewIssue.make({
|
|
869
|
-
severity:
|
|
942
|
+
severity: judgeSeverity(score.name, score.score),
|
|
870
943
|
title: `judge[${story.id}]: ${score.name} scored ${score.score}`,
|
|
871
944
|
description: score.reasoning
|
|
872
945
|
})),
|
|
@@ -1484,6 +1557,7 @@ export const runEpicStories = (options) => Effect.gen(function* () {
|
|
|
1484
1557
|
}));
|
|
1485
1558
|
return;
|
|
1486
1559
|
}
|
|
1560
|
+
const deferring = deferFindingsEnabled(flags.deferFindings, process.env);
|
|
1487
1561
|
// With a roster, the default is every coder slot it has; the flag caps it.
|
|
1488
1562
|
const concurrency = flags.concurrency ?? context.roster?.slots("coder") ?? 3;
|
|
1489
1563
|
if (context.roster === undefined && localServer !== undefined && concurrency > 1) {
|
|
@@ -1509,13 +1583,14 @@ export const runEpicStories = (options) => Effect.gen(function* () {
|
|
|
1509
1583
|
const testCommand = testGateCommand(commands);
|
|
1510
1584
|
const setupCommand = worktreeSetupCommand(process.env);
|
|
1511
1585
|
const healthUrl = serverHealthUrl(localServer, process.env);
|
|
1512
|
-
|
|
1513
|
-
|
|
1586
|
+
// The executor over one unit: the epic's plan or one refine round.
|
|
1587
|
+
const runUnit = (work) => implementStoriesFlow({ ...context, reasoning: reasoningMeter.service }, {
|
|
1588
|
+
plan: work.plan,
|
|
1514
1589
|
files,
|
|
1515
|
-
stateDir:
|
|
1590
|
+
stateDir: work.stateDir,
|
|
1516
1591
|
epicBranch,
|
|
1517
1592
|
worktreeRoot: worktreeRootFor(input.workDir, plan.epicId, process.env),
|
|
1518
|
-
board: makeLocalBoardSync(files,
|
|
1593
|
+
board: makeLocalBoardSync(files, work.stateDir, work.label),
|
|
1519
1594
|
contextFor: (workDir, contextOptions) => Effect.gen(function* () {
|
|
1520
1595
|
const rebound = yield* contextFor(workDir, contextOptions);
|
|
1521
1596
|
const coderMeter = yield* makeEstimatedUsageMeter(rebound.coder, estimateOptions);
|
|
@@ -1561,14 +1636,14 @@ export const runEpicStories = (options) => Effect.gen(function* () {
|
|
|
1561
1636
|
// away from the executor coding it (ADR 0019).
|
|
1562
1637
|
...storyContextChars(process.env),
|
|
1563
1638
|
judge: options.storyJudge({
|
|
1564
|
-
plan:
|
|
1639
|
+
plan: work.plan,
|
|
1565
1640
|
budget: contextBudget,
|
|
1566
1641
|
reasoning: reasoningMeter.service,
|
|
1567
1642
|
events,
|
|
1568
1643
|
files,
|
|
1569
1644
|
houseRules: guidance
|
|
1570
1645
|
}),
|
|
1571
|
-
verifyBlocked: (story, need, workDir, seats) => verifyBlockedOn(seats.context.roster?.forRole("verifier") ?? seats.context.reasoning, events, files,
|
|
1646
|
+
verifyBlocked: (story, need, workDir, seats) => verifyBlockedOn(seats.context.roster?.forRole("verifier") ?? seats.context.reasoning, events, files, work.plan)(story, need, workDir),
|
|
1572
1647
|
// A roster waits for its own executors (the story's next lease
|
|
1573
1648
|
// does); without one, poll the single coder's engine.
|
|
1574
1649
|
awaitRecovery: context.roster === undefined
|
|
@@ -1582,16 +1657,86 @@ export const runEpicStories = (options) => Effect.gen(function* () {
|
|
|
1582
1657
|
...appDirNote,
|
|
1583
1658
|
...(orientation === undefined ? [] : ["", orientation])
|
|
1584
1659
|
].join("\n")),
|
|
1660
|
+
...judgeRoundsOption(flags.judgeRounds, process.env),
|
|
1585
1661
|
concurrency,
|
|
1662
|
+
...(deferring ? { deferNonBlocking: true } : {}),
|
|
1586
1663
|
failFast: flags.failFast
|
|
1587
1664
|
});
|
|
1588
|
-
|
|
1589
|
-
message: `${
|
|
1665
|
+
const summarize = (work, report) => events.publish(Info.make({
|
|
1666
|
+
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
1667
|
}));
|
|
1668
|
+
const report = yield* runUnit(unit);
|
|
1669
|
+
yield* summarize(unit, report);
|
|
1591
1670
|
const hint = retroHint(report, input.workDir, epicId);
|
|
1592
1671
|
if (hint !== undefined) {
|
|
1593
1672
|
yield* events.publish(Info.make({ message: hint }));
|
|
1594
1673
|
}
|
|
1674
|
+
if (!deferring) {
|
|
1675
|
+
return;
|
|
1676
|
+
}
|
|
1677
|
+
// Deferred findings (ADR 0031) become one refine round once the
|
|
1678
|
+
// epic's own stories are all merged. A round's own deferrals are
|
|
1679
|
+
// listed, never planned again, so the follow-up cannot loop.
|
|
1680
|
+
const feedbackText = yield* deferredFeedback(files, unit.stateDir, unit.plan);
|
|
1681
|
+
if (feedbackText === undefined) {
|
|
1682
|
+
return;
|
|
1683
|
+
}
|
|
1684
|
+
const settledPlan = report.count("failed") === 0 && report.count("waiting") === 0;
|
|
1685
|
+
if (unit.stateDir !== stateDir || !settledPlan) {
|
|
1686
|
+
yield* events.publish(Info.make({
|
|
1687
|
+
message: `${unit.label.replace("Epic: ", "epic ")}: findings deferred in ${join(unit.stateDir, "stories")}/*.deferred.md${unit.stateDir === stateDir
|
|
1688
|
+
? "; they become a refine round once every story is merged"
|
|
1689
|
+
: "; pass them to --refine if they still matter"}`
|
|
1690
|
+
}));
|
|
1691
|
+
return;
|
|
1692
|
+
}
|
|
1693
|
+
const stray = statusPaths(yield* context.git.status);
|
|
1694
|
+
if (stray.length > 0) {
|
|
1695
|
+
yield* events.publish(Info.make({
|
|
1696
|
+
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`
|
|
1697
|
+
}));
|
|
1698
|
+
return;
|
|
1699
|
+
}
|
|
1700
|
+
yield* stage(events, "epic branch", context.git.checkoutOrCreate(epicBranch));
|
|
1701
|
+
const followUp = rounds.length + 1;
|
|
1702
|
+
const roundBrief = yield* files.read(join(stateDir, "brief.md"));
|
|
1703
|
+
const planned = yield* stage(events, `refine round ${followUp} plan (deferred findings)`, planRound({
|
|
1704
|
+
files,
|
|
1705
|
+
reasoning: reasoningMeter.service,
|
|
1706
|
+
events,
|
|
1707
|
+
stateDir,
|
|
1708
|
+
epicId: plan.epicId,
|
|
1709
|
+
round: followUp,
|
|
1710
|
+
feedback: feedbackText,
|
|
1711
|
+
guidance,
|
|
1712
|
+
plans: [unit.plan],
|
|
1713
|
+
...(roundBrief === undefined ? {} : { brief: roundBrief }),
|
|
1714
|
+
...(orientation === undefined ? {} : { orientation }),
|
|
1715
|
+
git: context.git
|
|
1716
|
+
}));
|
|
1717
|
+
for (const left of planned.notPlanned) {
|
|
1718
|
+
yield* events.publish(Info.make({ message: `not planned: ${left.item} — ${left.reason}` }));
|
|
1719
|
+
}
|
|
1720
|
+
if (planned.plan === undefined) {
|
|
1721
|
+
yield* events.publish(Info.make({
|
|
1722
|
+
message: `refine round ${followUp}: no deferred finding needed a story; nothing to run`
|
|
1723
|
+
}));
|
|
1724
|
+
return;
|
|
1725
|
+
}
|
|
1726
|
+
const roundUnit = {
|
|
1727
|
+
plan: planned.plan,
|
|
1728
|
+
stateDir: roundDir(stateDir, followUp),
|
|
1729
|
+
label: `Epic: ${plan.epicId} · round ${followUp}`
|
|
1730
|
+
};
|
|
1731
|
+
yield* events.publish(Info.make({
|
|
1732
|
+
message: `refine round ${followUp}: ${planned.plan.stories.length} follow-up stories from deferred findings at ${join(roundUnit.stateDir, "plan.md")}`
|
|
1733
|
+
}));
|
|
1734
|
+
const roundReport = yield* runUnit(roundUnit);
|
|
1735
|
+
yield* summarize(roundUnit, roundReport);
|
|
1736
|
+
const roundHint = retroHint(roundReport, input.workDir, epicId);
|
|
1737
|
+
if (roundHint !== undefined) {
|
|
1738
|
+
yield* events.publish(Info.make({ message: roundHint }));
|
|
1739
|
+
}
|
|
1595
1740
|
}));
|
|
1596
1741
|
});
|
|
1597
1742
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@llm4ts/shell",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.39.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.39.0",
|
|
56
|
+
"@llm4ts/flow": "2.39.0",
|
|
57
|
+
"@llm4ts/runner": "2.39.0"
|
|
58
58
|
},
|
|
59
59
|
"peerDependencies": {
|
|
60
60
|
"effect": "4.0.0"
|