@llm4ts/shell 2.47.2 → 2.49.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.
@@ -0,0 +1,17 @@
1
+ // Working code first, tests and validation later: the planner cuts stories without tests, coders build under one review lens and the build gates, and what was deferred is written as debt that --harden plans under the full bar (ADR 0043).
2
+ //
3
+ // llm4ts run epic-draft --repo ~/demo/portal "Add the current account and wire transfers"
4
+ // llm4ts run epic-draft --repo ~/demo/portal -- --plan-only "…" # write the draft plan and stop
5
+ // llm4ts run epic-draft --repo ~/demo/portal -- --harden # write the tests owed, close findings
6
+ // llm4ts run epic-draft --repo ~/demo/portal -- --land # land, hardened or not
7
+ //
8
+ // Same seats, flags and environment as epic-stories, plus --with-tests to
9
+ // keep the test gate while drafting. After a draft run, debt.md beside the
10
+ // plan lists each merged story's tests owed, its deferred findings and the
11
+ // test gate's failures on the epic checkout; a settled --harden round moves
12
+ // what it paid to debt.paid.md.
13
+ import { runFlowMain } from "@llm4ts/runner";
14
+ import { epicDraftOptions, parseDraftFlags } from "./lib/epic-draft.js";
15
+ import { runEpicStories } from "./lib/epic-stories.js";
16
+ const flags = parseDraftFlags(process.argv.slice(2));
17
+ runFlowMain(runEpicStories({ ...epicDraftOptions(flags), argv: flags.argv }));
@@ -0,0 +1,171 @@
1
+ // epic-draft (ADR 0043): the epic-stories spine with tests and validation
2
+ // deferred, not dropped. A draft run plans and builds without tests, under
3
+ // one review lens and the build gates; what it deferred is written to
4
+ // debt.md, and `--harden` plans that debt as a round under the full bar.
5
+ import { join } from "node:path";
6
+ import * as Effect from "effect/Effect";
7
+ import { Info } from "@llm4ts/flow/FlowEvents";
8
+ import { failingLinesOf } from "@llm4ts/flow/GateTriage";
9
+ import { countNotPlanned } from "@llm4ts/flow/RefineRound";
10
+ import { correctnessReviewer } from "@llm4ts/flow/Review";
11
+ import { deferredPath, EpicReport } from "@llm4ts/flow/Stories";
12
+ import { StoryPlan } from "@llm4ts/flow/StoryPlan";
13
+ import { structuredAndPublish } from "@llm4ts/flow/Usage";
14
+ import { loadRounds, rubricStoryJudge, storyPlanInstructions, storyPlanJsonSchema, unitStateDirs, withoutTestGate } from "./epic-stories.js";
15
+ export const parseDraftFlags = (argv) => {
16
+ const harden = argv.includes("--harden");
17
+ const withTests = argv.includes("--with-tests");
18
+ const rest = argv.filter((arg) => arg !== "--harden" && arg !== "--with-tests");
19
+ return { harden, withTests, argv: harden ? ["--refine", ...rest] : rest };
20
+ };
21
+ // ---- The draft plan ---------------------------------------------------------
22
+ /** The epic-stories planner's rules, then the draft cut on top. */
23
+ export const draftPlanInstructions = (epicId, guidance, orientation) => [
24
+ storyPlanInstructions(epicId, guidance, orientation),
25
+ "",
26
+ "Draft cut (this run is epic-draft: working code first, tests and validation in a later",
27
+ "hardening round). These rules override the ones above where they differ:",
28
+ "- No story writes or owns test files now; leave them out of `owned`.",
29
+ "- `acceptance` still lists two to six outcomes per story: each one is a test the hardening",
30
+ " round will write, so make each checkable by an automated test.",
31
+ "- Prefer fewer, thinner stories: the shortest path to working code.",
32
+ "- Leave `testsDeferred` out: every outcome's test is owed and planned later."
33
+ ].join("\n");
34
+ /** The draft planner: the epic-stories plan under the draft cut. */
35
+ export const generateDraftPlan = (inputs) => structuredAndPublish(inputs.reasoning, inputs.events, `${draftPlanInstructions(inputs.epicId, inputs.guidance, inputs.orientation)}\n\nEpic:\n${inputs.brief ?? inputs.epic}`, StoryPlan, storyPlanJsonSchema).pipe(
36
+ // The id and the epic text are ours, whatever the model echoed back.
37
+ Effect.map((plan) => StoryPlan.make({ ...plan, epicId: inputs.epicId, epic: inputs.epic })));
38
+ // ---- 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");
43
+ const merged = (plan, report) => plan.stories.filter((story) => report.stories.some((outcome) => outcome.id === story.id && outcome.status === "done"));
44
+ export const renderDebt = (plan, report, deferred, testFailures) => [
45
+ `# Debt — ${plan.epicId}`,
46
+ "",
47
+ ...merged(plan, report).flatMap((story) => [
48
+ `## ${story.id}`,
49
+ "",
50
+ ...story.acceptance.map((outcome) => `- [ ] test: ${outcome}`),
51
+ ""
52
+ ]),
53
+ ...(deferred === undefined ? [] : ["## Deferred findings", "", deferred, ""]),
54
+ ...(testFailures === undefined
55
+ ? []
56
+ : ["## Test gate on the epic checkout (skipped while drafting)", "", testFailures, ""])
57
+ ].join("\n");
58
+ const counted = (count, one, many) => `${count} ${count === 1 ? one : many}`;
59
+ /**
60
+ * After a draft unit: `debt.md` with the merged stories' owed tests, their
61
+ * deferred findings and, when the draft skipped it, one run of the test gate
62
+ * on the epic checkout — gates with a memory would otherwise call those
63
+ * failures inherited and never charge a hardening story with them.
64
+ */
65
+ export const draftAfterUnit = (withTests) => (unit, report) => Effect.gen(function* () {
66
+ // A story whose debt a hardening round already paid owes nothing again.
67
+ const paid = new Set(((yield* unit.files.read(paidPath(unit.stateDir))) ?? "")
68
+ .split("\n")
69
+ .flatMap((line) => (line.startsWith("## ") ? [line.slice(3).trim()] : [])));
70
+ const stories = merged(unit.plan, report).filter((story) => !paid.has(story.id));
71
+ const findings = [];
72
+ for (const story of stories) {
73
+ const text = yield* unit.files.read(deferredPath(unit.stateDir, story.id));
74
+ findings.push(...(text ?? "").split("\n").filter((line) => line.startsWith("- [")));
75
+ }
76
+ const gate = withTests || unit.testGate === undefined
77
+ ? undefined
78
+ : yield* unit.testGate(unit.workDir, unit.events);
79
+ // The failing lines alone: a test run's whole output would crowd the planner's prompt.
80
+ const failing = gate === undefined || gate.issues.length === 0
81
+ ? []
82
+ : failingLinesOf(gate, [unit.workDir]).map((line) => `- ${line}`);
83
+ const testFailures = gate === undefined || gate.issues.length === 0
84
+ ? undefined
85
+ : (failing.length > 0 ? failing : gate.issues.map((issue) => `- ${issue.title}`)).join("\n");
86
+ const owed = stories.reduce((sum, story) => sum + story.acceptance.length, 0);
87
+ if (owed === 0 && findings.length === 0 && testFailures === undefined) {
88
+ return;
89
+ }
90
+ const path = debtPath(unit.stateDir);
91
+ yield* unit.files.writeAtomic(path, renderDebt(unit.plan, EpicReport.make({
92
+ ...report,
93
+ stories: report.stories.filter((outcome) => !paid.has(outcome.id))
94
+ }), findings.length === 0 ? undefined : findings.join("\n"), testFailures));
95
+ yield* unit.events.publish(Info.make({
96
+ message: `debt: ${counted(owed, "test owed", "tests owed")}, ${findings.length === 0
97
+ ? "no deferred findings"
98
+ : counted(findings.length, "deferred finding", "deferred findings")}${testFailures === undefined ? "" : ", test gate red"} — run epic-draft --harden (${path})`
99
+ }));
100
+ });
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
+ // ---- The policy -------------------------------------------------------------
152
+ /**
153
+ * The epic-stories spine with epic-draft's policy (ADR 0043). A draft defers
154
+ * tests and validation; `--harden` runs epic-stories' own bar over the debt.
155
+ */
156
+ export const epicDraftOptions = (flags) => flags.harden
157
+ ? {
158
+ storyJudge: rubricStoryJudge,
159
+ refineFeedback: hardenFeedback,
160
+ afterUnit: hardenAfterUnit
161
+ }
162
+ : {
163
+ generatePlan: generateDraftPlan,
164
+ stories: () => ({
165
+ reviewers: [correctnessReviewer],
166
+ maxRounds: 1,
167
+ deferNonBlocking: true
168
+ }),
169
+ ...(flags.withTests ? {} : { selectGates: withoutTestGate }),
170
+ afterUnit: draftAfterUnit(flags.withTests)
171
+ };
@@ -21,7 +21,7 @@ import { Story, StoryPlan } from "@llm4ts/flow/StoryPlan";
21
21
  import { commandFor, criterionTags, evidenceSourceFromEnvironment, parseJUnit, statusFromReport, testPaths } from "@llm4ts/flow/TestEvidence";
22
22
  import { structuredAndPublish } from "@llm4ts/flow/Usage";
23
23
  import { nodeProcessExecutor, ScriptUsage } from "@llm4ts/runner";
24
- import { storyJsonSchema, storyPlanInstructions } from "./epic-stories.js";
24
+ import { storyJsonSchema, storyPlanInstructions, unitStateDirs } from "./epic-stories.js";
25
25
  export const parseMvpFlags = (argv) => {
26
26
  const iterate = argv.includes("--iterate");
27
27
  const review = argv.includes("--review");
@@ -83,6 +83,7 @@ export const mvpPlanInstructions = (epicId, guidance, orientation) => [
83
83
  " `acceptance` outcome, and none depends on a parked one.",
84
84
  "- Prefer fewer, thinner stories: the thinnest end-to-end path that works.",
85
85
  "- Items an approved brief marks deferred or dropped are parked, never planned.",
86
+ "- Leave `testsDeferred` out: every MVP story is written test-first.",
86
87
  'Add `"later":[…]` to each story and `"parked":[…]` beside `"stories"` in the JSON.'
87
88
  ].join("\n");
88
89
  /** The MVP planner: writes `mvp-cut.json` beside the plan with the stories parked whole. */
@@ -380,20 +381,9 @@ export const mvpAfterUnit = (source) => (unit, report) => Effect.gen(function* (
380
381
  const Verdict = Schema.fromJsonString(MvpVerdict);
381
382
  const encodeVerdict = Schema.encodeSync(Verdict);
382
383
  const decodeVerdict = Schema.decodeUnknownOption(Verdict);
383
- /** The state folders whose verdicts the landing reads: the epic's and each round's. */
384
- const verdictDirs = (files, epicStateDir) => Effect.gen(function* () {
385
- const dirs = [epicStateDir];
386
- for (let round = 1;; round += 1) {
387
- const dir = join(epicStateDir, "rounds", String(round));
388
- if ((yield* files.read(join(dir, "plan.md"))) === undefined) {
389
- return dirs;
390
- }
391
- dirs.push(dir);
392
- }
393
- });
394
384
  /** `--land` only once the MVP is reached, in the epic and in every round. */
395
385
  export const mvpBeforeLand = (epicStateDir, files) => Effect.gen(function* () {
396
- for (const dir of yield* verdictDirs(files, epicStateDir)) {
386
+ for (const dir of yield* unitStateDirs(files, epicStateDir)) {
397
387
  const text = yield* files.read(verdictPath(dir));
398
388
  const verdict = text === undefined ? undefined : decodeVerdict(text);
399
389
  if (verdict === undefined || Option.isNone(verdict)) {
@@ -414,7 +404,7 @@ export const mvpBeforeLand = (epicStateDir, files) => Effect.gen(function* () {
414
404
  /** `--iterate`: the text given, then every parked list of the epic as what to plan. */
415
405
  export const mvpIterateFeedback = (epicStateDir, text, files) => Effect.gen(function* () {
416
406
  const parked = [];
417
- for (const dir of yield* verdictDirs(files, epicStateDir)) {
407
+ for (const dir of yield* unitStateDirs(files, epicStateDir)) {
418
408
  const list = yield* files.read(parkedPath(dir));
419
409
  if (list !== undefined) {
420
410
  parked.push(list);
@@ -750,7 +750,8 @@ export const storyJsonSchema = {
750
750
  readFirst: { type: "array", items: { type: "string" } },
751
751
  acceptance: { type: "array", items: { type: "string" } },
752
752
  provides: { type: "array", items: { type: "string" } },
753
- testsChange: { type: "boolean" }
753
+ testsChange: { type: "boolean" },
754
+ testsDeferred: { type: "string" }
754
755
  },
755
756
  required: [
756
757
  "id",
@@ -797,6 +798,10 @@ orientation) => [
797
798
  "- `testsChange`: true only for a story whose job includes deleting, skipping or rewriting",
798
799
  " existing tests (a migration of a test suite, a removed feature). Every other story leaves",
799
800
  " it out: the gates fail a story that deletes a test file or adds a skip marker.",
801
+ "- `testsDeferred`: only for a story whose code runs nowhere but a live runtime the repository",
802
+ " cannot host in a test (IBM ACE message flows and ESQL, a mainframe transaction): one",
803
+ " sentence naming that runtime. The judge then does not ask it for tests. Never set it because",
804
+ " tests are hard to write; every other story leaves it out and owns its test files.",
800
805
  "- `owned` sets are pairwise DISJOINT: no path prefix appears under two stories.",
801
806
  "- Shared surfaces (the kit, the theme, house rules) are never edited by a feature story. A new",
802
807
  " shared component is its own story, and every story using it depends on it.",
@@ -998,10 +1003,15 @@ export const storyDimensions = [
998
1003
  * a partial score elsewhere is a Warning a follow-up round can close.
999
1004
  */
1000
1005
  export const judgeSeverity = (name, score) => name === "provides" || score <= 0 ? "Critical" : "Warning";
1006
+ /** The dimensions a story is held to: all of them, less `tests` when the plan deferred them. */
1007
+ export const judgedDimensionsOf = (story) => storyDimensions
1008
+ .map((dimension) => dimension.name)
1009
+ .filter((name) => story.testsDeferred === undefined || name !== "tests");
1001
1010
  const subBar = (scored, story) => ReviewResult.make({
1002
1011
  issues: scored.scores
1003
- .filter((score) => score.score <
1004
- (storyDimensions.find((dimension) => dimension.name === score.name)?.maxScore ?? 2))
1012
+ .filter((score) => judgedDimensionsOf(story).includes(score.name) &&
1013
+ score.score <
1014
+ (storyDimensions.find((dimension) => dimension.name === score.name)?.maxScore ?? 2))
1005
1015
  .map((score) => ReviewIssue.make({
1006
1016
  severity: judgeSeverity(score.name, score.score),
1007
1017
  title: `judge[${story.id}]: ${score.name} scored ${score.score}`,
@@ -1009,6 +1019,13 @@ const subBar = (scored, story) => ReviewResult.make({
1009
1019
  })),
1010
1020
  summary: `judge:${story.id}`
1011
1021
  });
1022
+ /** What a judge is told about a story the plan exempted from tests; nothing otherwise. */
1023
+ export const testsDeferredLines = (reason) => reason === undefined
1024
+ ? []
1025
+ : [
1026
+ `Tests deferred by the plan: ${reason}`,
1027
+ "No test file is expected; do not mark any dimension down for its absence."
1028
+ ];
1012
1029
  /**
1013
1030
  * What the story judge reads beside the diff: the story, and what every
1014
1031
  * story it depends on provides. Without the dependencies the judge cannot
@@ -1039,6 +1056,7 @@ export const storyJudgeQuery = (story, plan, subject = "diff") => {
1039
1056
  `Provides: ${story.provides.join(", ") || "(none)"}`,
1040
1057
  `Owned paths: ${story.owned.join(", ")}`,
1041
1058
  `Shared read-only: ${story.sharedReadOnly.join(", ") || "(none)"}`,
1059
+ ...testsDeferredLines(story.testsDeferred),
1042
1060
  ...(dependencies.length === 0
1043
1061
  ? []
1044
1062
  : [
@@ -1266,6 +1284,16 @@ export const stallOptions = (environment) => {
1266
1284
  export const gateRunOptions = (environment) => ({ timeout: Duration.seconds(gateTimeoutSeconds(environment)) });
1267
1285
  /** The gate command that runs the tests, for the one flaky rerun; undefined when none does. */
1268
1286
  export const testGateCommand = (commands) => commands.find((command) => command.some((part, index) => index > 0 && /(?:^|[^a-z])(?:test|vitest|jest)/iu.test(part)));
1287
+ /** The gates less the test command (ADR 0043): a draft defers its tests, not its build. */
1288
+ export const withoutTestGate = (commands) => {
1289
+ const test = testGateCommand(commands);
1290
+ return test === undefined
1291
+ ? { commands, note: "test gate: none recognised, every gate kept" }
1292
+ : {
1293
+ commands: commands.filter((command) => command !== test),
1294
+ note: `test gate deferred: ${test.join(" ")}`
1295
+ };
1296
+ };
1269
1297
  /**
1270
1298
  * How gate output reaches the coder: a capped tail, and the log's path. The
1271
1299
  * epic-stories coders are CLI harnesses that can open a file; an API coder
@@ -1387,6 +1415,17 @@ export const rubricStoryJudge = (context) => (story, diff, seats, subject) => ju
1387
1415
  // The story's own seat, not the run's: its time counts for the story.
1388
1416
  // And the judge seat, never the writing reasoner: a judge reads.
1389
1417
  seats.context.roster?.forRole("judge") ?? seats.context.judge ?? seats.context.reasoning, story, diff, context.budget, context.plan, subject);
1418
+ /** A unit's state folders, the epic's and then each planned round's (ADR 0021). */
1419
+ export const unitStateDirs = (files, epicStateDir) => Effect.gen(function* () {
1420
+ const dirs = [epicStateDir];
1421
+ for (let round = 1;; round += 1) {
1422
+ const dir = join(epicStateDir, "rounds", String(round));
1423
+ if ((yield* files.read(join(dir, "plan.md"))) === undefined) {
1424
+ return dirs;
1425
+ }
1426
+ dirs.push(dir);
1427
+ }
1428
+ });
1390
1429
  /** The runner options the two flags above add; empty when neither is set. */
1391
1430
  export const epicRunnerOptions = Effect.fn("epic-stories.runnerOptions")(function* (options, environment) {
1392
1431
  const judgment = options.judgmentFromEnvironment === true
@@ -1645,8 +1684,14 @@ export const runEpicStories = (options) => Effect.gen(function* () {
1645
1684
  scripts: yield* appScripts(files, join(input.workDir, appDir)),
1646
1685
  pack
1647
1686
  });
1648
- const commands = resolvedGates.commands;
1649
- yield* events.publish(Info.make({ message: gateListMessage(resolvedGates) }));
1687
+ const selected = options.selectGates === undefined
1688
+ ? undefined
1689
+ : options.selectGates(resolvedGates.commands);
1690
+ const commands = selected?.commands ?? resolvedGates.commands;
1691
+ yield* events.publish(Info.make({ message: gateListMessage({ ...resolvedGates, commands }) }));
1692
+ if (selected?.note !== undefined) {
1693
+ yield* events.publish(Info.make({ message: selected.note }));
1694
+ }
1650
1695
  // The Node the gates would run on is the one on PATH, not the one
1651
1696
  // the application pins; a mismatch is a red first story that says
1652
1697
  // nothing about Node. Ask now, and name both.
@@ -1716,10 +1761,15 @@ export const runEpicStories = (options) => Effect.gen(function* () {
1716
1761
  `The application lives in ${appDir}/ — its package.json, sources and tests; the gates run there.`
1717
1762
  ];
1718
1763
  const gates = inAppDir(appDir, gatesIn(nodeProcessExecutor, events, commands, gateRunOptions(process.env)));
1719
- const testCommand = testGateCommand(commands);
1720
- const testGate = testCommand === undefined
1721
- ? undefined
1722
- : inAppDir(appDir, gatesIn(nodeProcessExecutor, events, [testCommand], gateRunOptions(process.env)));
1764
+ const testGateOf = (from) => {
1765
+ const testCommand = testGateCommand(from);
1766
+ return testCommand === undefined
1767
+ ? undefined
1768
+ : inAppDir(appDir, gatesIn(nodeProcessExecutor, events, [testCommand], gateRunOptions(process.env)));
1769
+ };
1770
+ const testGate = testGateOf(commands);
1771
+ // A policy that deferred the test gate still gets it, to run once on its own terms.
1772
+ const unitTestGate = testGateOf(resolvedGates.commands);
1723
1773
  const unitContext = (work) => ({
1724
1774
  plan: work.plan,
1725
1775
  stateDir: work.stateDir,
@@ -1728,7 +1778,7 @@ export const runEpicStories = (options) => Effect.gen(function* () {
1728
1778
  appDir,
1729
1779
  files,
1730
1780
  events,
1731
- testGate
1781
+ testGate: unitTestGate
1732
1782
  });
1733
1783
  const setupCommand = worktreeSetupCommand(process.env, pack);
1734
1784
  const healthUrl = serverHealthUrl(localServer, process.env);
@@ -17,10 +17,10 @@ import { answerKey, decideRule, decisionKey, publishBlackboardRun, runErrorToFlo
17
17
  import { cap } from "@llm4ts/flow/Context";
18
18
  import { FlowLlmError } from "@llm4ts/flow/FlowError";
19
19
  import { JudgmentObserved, publishJudgmentObserved } from "@llm4ts/flow/FlowEvents";
20
- import { certaintyOf, decide, judgmentOf } from "@llm4ts/flow/Judgment";
20
+ import { certaintyOf, Decision, decide, judgmentOf } from "@llm4ts/flow/Judgment";
21
21
  import { ReviewIssue, ReviewResult } from "@llm4ts/flow/Review";
22
22
  import { dependenciesOf } from "@llm4ts/flow/StoryPlan";
23
- import { storyDimensions } from "./epic-stories.js";
23
+ import { storyDimensions, testsDeferredLines } from "./epic-stories.js";
24
24
  export class StoryBrief extends Schema.Class("StoryBrief")({
25
25
  id: Schema.String,
26
26
  title: Schema.String,
@@ -29,7 +29,8 @@ export class StoryBrief extends Schema.Class("StoryBrief")({
29
29
  provides: Schema.Array(Schema.String),
30
30
  owned: Schema.Array(Schema.String),
31
31
  sharedReadOnly: Schema.Array(Schema.String),
32
- dependencies: Schema.Array(Schema.Struct({ id: Schema.String, provides: Schema.Array(Schema.String) }))
32
+ dependencies: Schema.Array(Schema.Struct({ id: Schema.String, provides: Schema.Array(Schema.String) })),
33
+ testsDeferred: Schema.optionalKey(Schema.String)
33
34
  }) {
34
35
  }
35
36
  export const storyBriefOf = (story, plan) => StoryBrief.make({
@@ -45,7 +46,8 @@ export const storyBriefOf = (story, plan) => StoryBrief.make({
45
46
  : dependenciesOf(plan, story.id).flatMap((id) => {
46
47
  const dependency = plan.story(id);
47
48
  return dependency === undefined ? [] : [{ id, provides: dependency.provides }];
48
- })
49
+ }),
50
+ ...(story.testsDeferred === undefined ? {} : { testsDeferred: story.testsDeferred })
49
51
  });
50
52
  // ---- Facts ------------------------------------------------------------------
51
53
  export const storyBrief = makeKey("story.brief", StoryBrief);
@@ -53,6 +55,7 @@ export const storyDiff = makeKey("story.diff", Schema.String);
53
55
  export const houseRules = makeKey("story.houseRules", Schema.String);
54
56
  export const mergeable = makeKey("story.mergeable", Schema.Boolean);
55
57
  export const issues = makeKey("story.issues", Schema.Array(ReviewIssue));
58
+ const decisions = makeKey("story.decisions", Schema.Array(Decision));
56
59
  /** One judged answer and one decision per story dimension, keyed by the dimension's name. */
57
60
  const judged = (name) => answerKey(`judge.${name}`);
58
61
  const decided = (name) => decisionKey(`decision.${name}`);
@@ -86,6 +89,7 @@ export const stateOf = (brief, diff, rules) => [
86
89
  `Provides: ${brief.provides.join(", ") || "(none)"}`,
87
90
  `Owned paths: ${brief.owned.join(", ")}`,
88
91
  `Shared read-only: ${brief.sharedReadOnly.join(", ") || "(none)"}`,
92
+ ...testsDeferredLines(brief.testsDeferred),
89
93
  ...(brief.dependencies.length === 0
90
94
  ? []
91
95
  : [
@@ -122,25 +126,34 @@ export const makeStoryBoard = (policy) => {
122
126
  return answer?.type === "score" ? [d.answer.of(answer)] : [];
123
127
  })
124
128
  });
125
- const decisions = dimensions.map((d) => decideRule({
129
+ const deciders = dimensions.map((d) => decideRule({
126
130
  name: `decide-${d.dimension.name}`,
127
131
  answer: d.answer,
128
132
  decision: d.decision,
129
133
  ...(policy === undefined ? {} : { policy })
130
134
  }));
135
+ // The four decisions as one fact, so the bar can also read the brief.
136
+ const joined = derive({
137
+ name: "decisions",
138
+ condition: all(provides.decision, scope.decision, houseStyle.decision, tests.decision),
139
+ produces: [decisions],
140
+ derive: (verdicts) => [decisions.of(verdicts)]
141
+ });
131
142
  const bar = derive({
132
143
  name: "bar",
133
- condition: all(provides.answer, scope.answer, houseStyle.answer, tests.answer, provides.decision, scope.decision, houseStyle.decision, tests.decision),
144
+ condition: all(storyBrief, provides.answer, scope.answer, houseStyle.answer, tests.answer, decisions),
134
145
  produces: [mergeable, issues],
135
- derive: ([a1, a2, a3, a4, v1, v2, v3, v4]) => {
146
+ derive: ([brief, a1, a2, a3, a4, verdicts]) => {
136
147
  const found = [];
137
148
  const answers = [a1, a2, a3, a4];
138
- const verdicts = [v1, v2, v3, v4];
139
149
  dimensions.forEach((d, index) => {
140
150
  const answer = answers[index];
141
151
  const verdict = verdicts[index];
142
152
  if (answer === undefined || verdict === undefined || answer.type !== "score")
143
153
  return;
154
+ // The plan deferred this story's tests (it runs only in a live runtime).
155
+ if (d === tests && brief.testsDeferred !== undefined)
156
+ return;
144
157
  if (answer.score < passingScore) {
145
158
  found.push(ReviewIssue.make({
146
159
  severity: "Critical",
@@ -163,7 +176,7 @@ export const makeStoryBoard = (policy) => {
163
176
  name: "story-board",
164
177
  imports: [storyBrief, storyDiff, houseRules],
165
178
  exports: [mergeable, issues],
166
- rules: [storyJudge, ...decisions, bar]
179
+ rules: [storyJudge, ...deciders, joined, bar]
167
180
  });
168
181
  };
169
182
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llm4ts/shell",
3
- "version": "2.47.2",
3
+ "version": "2.49.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/core": "2.47.2",
65
- "@llm4ts/flow": "2.47.2",
66
- "@llm4ts/runner": "2.47.2"
64
+ "@llm4ts/flow": "2.49.0",
65
+ "@llm4ts/core": "2.49.0",
66
+ "@llm4ts/runner": "2.49.0"
67
67
  },
68
68
  "peerDependencies": {
69
69
  "effect": "4.0.0"