@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.
- package/flows/epic-draft.js +17 -0
- package/flows/lib/epic-draft.js +171 -0
- package/flows/lib/epic-mvp.js +4 -14
- package/flows/lib/epic-stories.js +60 -10
- package/flows/lib/story-board.js +22 -9
- package/package.json +4 -4
|
@@ -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
|
+
};
|
package/flows/lib/epic-mvp.js
CHANGED
|
@@ -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*
|
|
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*
|
|
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.
|
|
1004
|
-
|
|
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
|
|
1649
|
-
|
|
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
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
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);
|
package/flows/lib/story-board.js
CHANGED
|
@@ -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
|
|
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,
|
|
144
|
+
condition: all(storyBrief, provides.answer, scope.answer, houseStyle.answer, tests.answer, decisions),
|
|
134
145
|
produces: [mergeable, issues],
|
|
135
|
-
derive: ([a1, a2, a3, a4,
|
|
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, ...
|
|
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.
|
|
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/
|
|
65
|
-
"@llm4ts/
|
|
66
|
-
"@llm4ts/runner": "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"
|