@llm4ts/shell 2.50.0 → 2.52.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.
@@ -235,6 +235,7 @@ export declare const profile: Tool.Tool<"profile", {
235
235
  readonly exploreBeforeEdit: Schema.Int;
236
236
  readonly unverifiedClaims: Schema.Int;
237
237
  readonly lowConfidenceTasks: Schema.Int;
238
+ readonly reviewDiffLines: Schema.optionalKey<Schema.Int>;
238
239
  }>>;
239
240
  readonly skipped: Schema.$Array<Schema.String>;
240
241
  readonly totals: Schema.Struct<{
@@ -484,6 +485,7 @@ export declare const readToolkit: Toolkit.Toolkit<{
484
485
  readonly exploreBeforeEdit: Schema.Int;
485
486
  readonly unverifiedClaims: Schema.Int;
486
487
  readonly lowConfidenceTasks: Schema.Int;
488
+ readonly reviewDiffLines: Schema.optionalKey<Schema.Int>;
487
489
  }>>;
488
490
  readonly skipped: Schema.$Array<Schema.String>;
489
491
  readonly totals: Schema.Struct<{
@@ -1 +1 @@
1
- {"version":3,"file":"OperatorTools.d.ts","sourceRoot":"","sources":["../src/OperatorTools.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"OperatorTools.d.ts","sourceRoot":"","sources":["../src/OperatorTools.ts"],"names":[],"mappings":"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"}
@@ -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 { rubricStoryJudge, runEpicStories } from "./lib/epic-stories.js";
26
- runFlowMain(runEpicStories({ storyJudge: rubricStoryJudge }));
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 };
@@ -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 { loadRounds, rubricStoryJudge, storyPlanInstructions, storyPlanJsonSchema, unitStateDirs, withoutTestGate } from "./epic-stories.js";
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
- /** What a draft unit deferred: the tests owed, the findings, the skipped test gate. */
40
- export const debtPath = (stateDir) => join(stateDir, "debt.md");
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,72 +94,20 @@ 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: () => ({
165
107
  reviewers: [correctnessReviewer],
108
+ // One round, whether the lens reviews each task or the story (ADR 0044).
166
109
  maxRounds: 1,
110
+ reviewRounds: 1,
167
111
  deferNonBlocking: true
168
112
  }),
169
113
  ...(flags.withTests ? {} : { selectGates: withoutTestGate }),
@@ -58,8 +58,10 @@ 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",
61
+ " --review-at <task|story> where the review lenses run (default story: once per story,",
62
+ " beside the judge; task: every task, as before 2.51)",
63
+ " --review-rounds <n> rounds of the story review (default 3); in task mode, judge attempts",
64
+ " (default 2). --judge-rounds and LLM4TS_JUDGE_ROUNDS set it too",
63
65
  " --task-concurrency <n> coders one story may hold at once (default 1: every task in order).",
64
66
  " Above 1, a task the plan says needs nothing still running, and whose",
65
67
  " Owns: paths overlap no running task's, goes to a free coder in its own",
@@ -71,6 +73,10 @@ export const epicUsage = [
71
73
  " settle, the judge clears without them); the rest are deferred and, once",
72
74
  " every story is merged, planned and run as one refine round.",
73
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",
74
80
  "Seats: LLM4TS_REASONER (claude|gemini|…, default claude) splits, reviews, judges;",
75
81
  " LLM4TS_CODER (default pi) implements; LLM4TS_REASONING_MODEL / LLM4TS_CODER_MODEL",
76
82
  " pick their models (pi: provider/model); LLM4TS_CODER_FLAGS / LLM4TS_REASONING_FLAGS",
@@ -91,6 +97,7 @@ export const parseEpicArgs = (argv) => Effect.gen(function* () {
91
97
  let failFast = false;
92
98
  let concurrency;
93
99
  let judgeRounds;
100
+ let reviewAt;
94
101
  let deferFindings = false;
95
102
  let mergeRevisions;
96
103
  let taskConcurrency;
@@ -183,17 +190,31 @@ export const parseEpicArgs = (argv) => Effect.gen(function* () {
183
190
  }
184
191
  mergeRevisions = parsed;
185
192
  }
186
- else if (argument === "--judge-rounds" || argument.startsWith("--judge-rounds=")) {
187
- const raw = argument.includes("=")
188
- ? argument.slice("--judge-rounds=".length)
189
- : argv[index + 1];
193
+ else if (argument === "--review-at" || argument.startsWith("--review-at=")) {
194
+ const raw = argument.includes("=") ? argument.slice("--review-at=".length) : argv[index + 1];
195
+ if (!argument.includes("=")) {
196
+ index += 1;
197
+ }
198
+ if (raw !== "task" && raw !== "story") {
199
+ return yield* ScriptUsage.make({
200
+ message: `--review-at takes task or story\n${epicUsage}`
201
+ });
202
+ }
203
+ reviewAt = raw;
204
+ }
205
+ else if (argument === "--judge-rounds" ||
206
+ argument.startsWith("--judge-rounds=") ||
207
+ argument === "--review-rounds" ||
208
+ argument.startsWith("--review-rounds=")) {
209
+ const name = argument.split("=")[0] ?? argument;
210
+ const raw = argument.includes("=") ? argument.slice(name.length + 1) : argv[index + 1];
190
211
  if (!argument.includes("=")) {
191
212
  index += 1;
192
213
  }
193
214
  const parsed = Number.parseInt(raw ?? "", 10);
194
215
  if (!Number.isInteger(parsed) || parsed < 1) {
195
216
  return yield* ScriptUsage.make({
196
- message: `--judge-rounds requires a positive integer\n${epicUsage}`
217
+ message: `${name} requires a positive integer\n${epicUsage}`
197
218
  });
198
219
  }
199
220
  judgeRounds = parsed;
@@ -207,6 +228,8 @@ export const parseEpicArgs = (argv) => Effect.gen(function* () {
207
228
  failFast,
208
229
  concurrency,
209
230
  judgeRounds,
231
+ reviewAt,
232
+ reviewRounds: judgeRounds,
210
233
  deferFindings,
211
234
  mergeRevisions,
212
235
  taskConcurrency,
@@ -270,6 +293,21 @@ export const judgeRoundsOption = (flag, environment) => {
270
293
  const raw = Number.parseInt(environment.LLM4TS_JUDGE_ROUNDS ?? "", 10);
271
294
  return Number.isInteger(raw) && raw > 0 ? { judgeRounds: raw } : {};
272
295
  };
296
+ /**
297
+ * Where the lenses run and for how many rounds (ADR 0044): per story unless
298
+ * `--review-at task`. The rounds (`--review-rounds`, `--judge-rounds`, or
299
+ * `LLM4TS_JUDGE_ROUNDS`) bound the story review, or in task mode count judge
300
+ * attempts as before.
301
+ */
302
+ export const reviewOptions = (reviewAt, rounds, environment) => {
303
+ const at = reviewAt ?? "story";
304
+ const { judgeRounds } = judgeRoundsOption(rounds, environment);
305
+ return judgeRounds === undefined
306
+ ? { reviewAt: at }
307
+ : at === "story"
308
+ ? { reviewAt: at, reviewRounds: judgeRounds }
309
+ : { reviewAt: at, judgeRounds };
310
+ };
273
311
  export const roundDir = (stateDir, round) => join(stateDir, "rounds", String(round));
274
312
  const mergedStories = (files, stateDir, plan) => Effect.gen(function* () {
275
313
  let merged = 0;
@@ -1004,15 +1042,18 @@ export const storyDimensions = [
1004
1042
  * a partial score elsewhere is a Warning a follow-up round can close.
1005
1043
  */
1006
1044
  export const judgeSeverity = (name, score) => name === "provides" || score <= 0 ? "Critical" : "Warning";
1007
- /** The dimensions a story is held to: all of them, less `tests` when the plan deferred them. */
1008
- export const judgedDimensionsOf = (story) => storyDimensions
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
1009
1050
  .map((dimension) => dimension.name)
1010
- .filter((name) => story.testsDeferred === undefined || name !== "tests");
1011
- const subBar = (scored, story) => ReviewResult.make({
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({
1012
1054
  issues: scored.scores
1013
- .filter((score) => judgedDimensionsOf(story).includes(score.name) &&
1014
- score.score <
1015
- (storyDimensions.find((dimension) => dimension.name === score.name)?.maxScore ?? 2))
1055
+ .filter((score) => judgedDimensionsOf(story, bar).includes(score.name) &&
1056
+ score.score < maxScoreOf(score.name))
1016
1057
  .map((score) => ReviewIssue.make({
1017
1058
  severity: judgeSeverity(score.name, score.score),
1018
1059
  title: `judge[${story.id}]: ${score.name} scored ${score.score}`,
@@ -1027,6 +1068,11 @@ export const testsDeferredLines = (reason) => reason === undefined
1027
1068
  `Tests deferred by the plan: ${reason}`,
1028
1069
  "No test file is expected; do not mark any dimension down for its absence."
1029
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
+ ];
1030
1076
  /**
1031
1077
  * What the story judge reads beside the diff: the story, and what every
1032
1078
  * story it depends on provides. Without the dependencies the judge cannot
@@ -1034,7 +1080,7 @@ export const testsDeferredLines = (reason) => reason === undefined
1034
1080
  * importing `paymentsDomain` from `payments.fake.ts`, exactly where the
1035
1081
  * payments contract story declared it.
1036
1082
  */
1037
- export const storyJudgeQuery = (story, plan, subject = "diff") => {
1083
+ export const storyJudgeQuery = (story, plan, subject = "diff", bar = {}) => {
1038
1084
  const dependencies = plan === undefined
1039
1085
  ? []
1040
1086
  : dependenciesOf(plan, story.id).flatMap((id) => {
@@ -1058,6 +1104,7 @@ export const storyJudgeQuery = (story, plan, subject = "diff") => {
1058
1104
  `Owned paths: ${story.owned.join(", ")}`,
1059
1105
  `Shared read-only: ${story.sharedReadOnly.join(", ") || "(none)"}`,
1060
1106
  ...testsDeferredLines(story.testsDeferred),
1107
+ ...(story.testsDeferred === undefined && bar.deferTests === true ? deferTestsLines : []),
1061
1108
  ...(dependencies.length === 0
1062
1109
  ? []
1063
1110
  : [
@@ -1083,19 +1130,32 @@ export const storyJudgeQuery = (story, plan, subject = "diff") => {
1083
1130
  ].join("\n");
1084
1131
  };
1085
1132
  /** The story-level judge over the branch diff, bounded by the character budget. */
1086
- 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)
1087
1134
  .evaluate(Sample.make({
1088
- query: storyJudgeQuery(story, plan, subject),
1135
+ query: storyJudgeQuery(story, plan, subject, bar),
1089
1136
  response: cap(diff, budget).text
1090
1137
  }))
1091
- .pipe(Effect.mapError(FlowLlmError.from), Effect.map((scored) => StoryVerdict.make({
1092
- ...subBar(scored, story),
1093
- dimensions: scored.scores.map((score) => ({
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) => ({
1094
1144
  id: score.name,
1095
1145
  score: score.score,
1096
- max: storyDimensions.find((dimension) => dimension.name === score.name)?.maxScore ?? 2
1097
- }))
1098
- })));
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
+ }));
1099
1159
  // ---- App directory ----------------------------------------------------------------
1100
1160
  /** Folders never taken for the application: dependencies, build output, llm4ts state. */
1101
1161
  const ignoredAppDirs = new Set(["node_modules", "dist", "build", "out", "coverage"]);
@@ -1416,6 +1476,8 @@ export const rubricStoryJudge = (context) => (story, diff, seats, subject) => ju
1416
1476
  // The story's own seat, not the run's: its time counts for the story.
1417
1477
  // And the judge seat, never the writing reasoner: a judge reads.
1418
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 });
1419
1481
  /** A unit's state folders, the epic's and then each planned round's (ADR 0021). */
1420
1482
  export const unitStateDirs = (files, epicStateDir) => Effect.gen(function* () {
1421
1483
  const dirs = [epicStateDir];
@@ -1857,7 +1919,7 @@ export const runEpicStories = (options) => Effect.gen(function* () {
1857
1919
  ...appDirNote,
1858
1920
  ...(orientation === undefined ? [] : ["", orientation])
1859
1921
  ].join("\n")),
1860
- ...judgeRoundsOption(flags.judgeRounds, process.env),
1922
+ ...reviewOptions(flags.reviewAt, flags.reviewRounds, process.env),
1861
1923
  ...mergeRevisionsOption(flags.mergeRevisions, process.env),
1862
1924
  ...taskConcurrencyOption(flags.taskConcurrency, process.env),
1863
1925
  concurrency,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llm4ts/shell",
3
- "version": "2.50.0",
3
+ "version": "2.52.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/flow": "2.50.0",
65
- "@llm4ts/runner": "2.50.0",
66
- "@llm4ts/core": "2.50.0"
64
+ "@llm4ts/core": "2.52.0",
65
+ "@llm4ts/flow": "2.52.0",
66
+ "@llm4ts/runner": "2.52.0"
67
67
  },
68
68
  "peerDependencies": {
69
69
  "effect": "4.0.0"