@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.
@@ -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 { planOnly, failFast, concurrency, land, keepWorktrees, list, epic, refine, rest };
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: "Critical",
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
- const report = yield* implementStoriesFlow({ ...context, reasoning: reasoningMeter.service }, {
1513
- plan: unit.plan,
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: unit.stateDir,
1590
+ stateDir: work.stateDir,
1516
1591
  epicBranch,
1517
1592
  worktreeRoot: worktreeRootFor(input.workDir, plan.epicId, process.env),
1518
- board: makeLocalBoardSync(files, unit.stateDir, unit.label),
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: unit.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, unit.plan)(story, need, workDir),
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
- 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)`
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.38.0",
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.38.0",
56
- "@llm4ts/runner": "2.38.0",
57
- "@llm4ts/flow": "2.38.0"
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"