@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.
@@ -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 { planOnly, failFast, concurrency, land, keepWorktrees, list, epic, refine, rest };
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: "Critical",
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
- const report = yield* implementStoriesFlow({ ...context, reasoning: reasoningMeter.service }, {
1513
- plan: unit.plan,
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: unit.stateDir,
1621
+ stateDir: work.stateDir,
1516
1622
  epicBranch,
1517
1623
  worktreeRoot: worktreeRootFor(input.workDir, plan.epicId, process.env),
1518
- board: makeLocalBoardSync(files, unit.stateDir, unit.label),
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: unit.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, unit.plan)(story, need, workDir),
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
- yield* events.publish(Info.make({
1589
- message: `${unit.label.replace("Epic: ", "epic ")}: ${report.count("done")} done, ${report.count("failed")} failed, ${report.count("waiting")} waiting — report at ${join(unit.stateDir, "report.md")} (usage figures estimated)`
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.38.0",
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.38.0",
56
- "@llm4ts/runner": "2.38.0",
57
- "@llm4ts/flow": "2.38.0"
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"