@llm4ts/shell 2.48.0 → 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");
@@ -381,20 +381,9 @@ export const mvpAfterUnit = (source) => (unit, report) => Effect.gen(function* (
381
381
  const Verdict = Schema.fromJsonString(MvpVerdict);
382
382
  const encodeVerdict = Schema.encodeSync(Verdict);
383
383
  const decodeVerdict = Schema.decodeUnknownOption(Verdict);
384
- /** The state folders whose verdicts the landing reads: the epic's and each round's. */
385
- const verdictDirs = (files, epicStateDir) => Effect.gen(function* () {
386
- const dirs = [epicStateDir];
387
- for (let round = 1;; round += 1) {
388
- const dir = join(epicStateDir, "rounds", String(round));
389
- if ((yield* files.read(join(dir, "plan.md"))) === undefined) {
390
- return dirs;
391
- }
392
- dirs.push(dir);
393
- }
394
- });
395
384
  /** `--land` only once the MVP is reached, in the epic and in every round. */
396
385
  export const mvpBeforeLand = (epicStateDir, files) => Effect.gen(function* () {
397
- for (const dir of yield* verdictDirs(files, epicStateDir)) {
386
+ for (const dir of yield* unitStateDirs(files, epicStateDir)) {
398
387
  const text = yield* files.read(verdictPath(dir));
399
388
  const verdict = text === undefined ? undefined : decodeVerdict(text);
400
389
  if (verdict === undefined || Option.isNone(verdict)) {
@@ -415,7 +404,7 @@ export const mvpBeforeLand = (epicStateDir, files) => Effect.gen(function* () {
415
404
  /** `--iterate`: the text given, then every parked list of the epic as what to plan. */
416
405
  export const mvpIterateFeedback = (epicStateDir, text, files) => Effect.gen(function* () {
417
406
  const parked = [];
418
- for (const dir of yield* verdictDirs(files, epicStateDir)) {
407
+ for (const dir of yield* unitStateDirs(files, epicStateDir)) {
419
408
  const list = yield* files.read(parkedPath(dir));
420
409
  if (list !== undefined) {
421
410
  parked.push(list);
@@ -1284,6 +1284,16 @@ export const stallOptions = (environment) => {
1284
1284
  export const gateRunOptions = (environment) => ({ timeout: Duration.seconds(gateTimeoutSeconds(environment)) });
1285
1285
  /** The gate command that runs the tests, for the one flaky rerun; undefined when none does. */
1286
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
+ };
1287
1297
  /**
1288
1298
  * How gate output reaches the coder: a capped tail, and the log's path. The
1289
1299
  * epic-stories coders are CLI harnesses that can open a file; an API coder
@@ -1405,6 +1415,17 @@ export const rubricStoryJudge = (context) => (story, diff, seats, subject) => ju
1405
1415
  // The story's own seat, not the run's: its time counts for the story.
1406
1416
  // And the judge seat, never the writing reasoner: a judge reads.
1407
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
+ });
1408
1429
  /** The runner options the two flags above add; empty when neither is set. */
1409
1430
  export const epicRunnerOptions = Effect.fn("epic-stories.runnerOptions")(function* (options, environment) {
1410
1431
  const judgment = options.judgmentFromEnvironment === true
@@ -1663,8 +1684,14 @@ export const runEpicStories = (options) => Effect.gen(function* () {
1663
1684
  scripts: yield* appScripts(files, join(input.workDir, appDir)),
1664
1685
  pack
1665
1686
  });
1666
- const commands = resolvedGates.commands;
1667
- 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
+ }
1668
1695
  // The Node the gates would run on is the one on PATH, not the one
1669
1696
  // the application pins; a mismatch is a red first story that says
1670
1697
  // nothing about Node. Ask now, and name both.
@@ -1734,10 +1761,15 @@ export const runEpicStories = (options) => Effect.gen(function* () {
1734
1761
  `The application lives in ${appDir}/ — its package.json, sources and tests; the gates run there.`
1735
1762
  ];
1736
1763
  const gates = inAppDir(appDir, gatesIn(nodeProcessExecutor, events, commands, gateRunOptions(process.env)));
1737
- const testCommand = testGateCommand(commands);
1738
- const testGate = testCommand === undefined
1739
- ? undefined
1740
- : 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);
1741
1773
  const unitContext = (work) => ({
1742
1774
  plan: work.plan,
1743
1775
  stateDir: work.stateDir,
@@ -1746,7 +1778,7 @@ export const runEpicStories = (options) => Effect.gen(function* () {
1746
1778
  appDir,
1747
1779
  files,
1748
1780
  events,
1749
- testGate
1781
+ testGate: unitTestGate
1750
1782
  });
1751
1783
  const setupCommand = worktreeSetupCommand(process.env, pack);
1752
1784
  const healthUrl = serverHealthUrl(localServer, process.env);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llm4ts/shell",
3
- "version": "2.48.0",
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/flow": "2.48.0",
65
- "@llm4ts/core": "2.48.0",
66
- "@llm4ts/runner": "2.48.0"
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"