@llm4ts/shell 2.45.0 → 2.47.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/dist/Cli.d.ts +3 -7
- package/dist/Cli.d.ts.map +1 -1
- package/dist/Cli.js +20 -4
- package/dist/Cli.js.map +1 -1
- package/dist/FlowLaunch.d.ts +2 -16
- package/dist/FlowLaunch.d.ts.map +1 -1
- package/dist/FlowLaunch.js +20 -66
- package/dist/FlowLaunch.js.map +1 -1
- package/dist/FlowProcess.d.ts +67 -0
- package/dist/FlowProcess.d.ts.map +1 -0
- package/dist/FlowProcess.js +175 -0
- package/dist/FlowProcess.js.map +1 -0
- package/dist/Menu.d.ts +1 -1
- package/dist/Menu.d.ts.map +1 -1
- package/dist/OperatorActTools.d.ts +170 -0
- package/dist/OperatorActTools.d.ts.map +1 -0
- package/dist/OperatorActTools.js +332 -0
- package/dist/OperatorActTools.js.map +1 -0
- package/dist/OperatorConfig.d.ts +44 -0
- package/dist/OperatorConfig.d.ts.map +1 -0
- package/dist/OperatorConfig.js +88 -0
- package/dist/OperatorConfig.js.map +1 -0
- package/dist/OperatorNode.d.ts +45 -0
- package/dist/OperatorNode.d.ts.map +1 -0
- package/dist/OperatorNode.js +103 -0
- package/dist/OperatorNode.js.map +1 -0
- package/dist/OperatorRuns.d.ts +76 -0
- package/dist/OperatorRuns.d.ts.map +1 -0
- package/dist/OperatorRuns.js +146 -0
- package/dist/OperatorRuns.js.map +1 -0
- package/dist/OperatorServer.d.ts +160 -0
- package/dist/OperatorServer.d.ts.map +1 -0
- package/dist/OperatorServer.js +123 -0
- package/dist/OperatorServer.js.map +1 -0
- package/dist/OperatorTools.d.ts +558 -0
- package/dist/OperatorTools.d.ts.map +1 -0
- package/dist/OperatorTools.js +398 -0
- package/dist/OperatorTools.js.map +1 -0
- package/dist/Refine.d.ts +2 -2
- package/dist/Refine.d.ts.map +1 -1
- package/dist/Refine.js +3 -5
- package/dist/Refine.js.map +1 -1
- package/dist/RunSupervisor.d.ts +51 -0
- package/dist/RunSupervisor.d.ts.map +1 -0
- package/dist/RunSupervisor.js +282 -0
- package/dist/RunSupervisor.js.map +1 -0
- package/dist/ShellErrors.d.ts +9 -0
- package/dist/ShellErrors.d.ts.map +1 -0
- package/dist/ShellErrors.js +7 -0
- package/dist/ShellErrors.js.map +1 -0
- package/flows/epic-mvp.js +21 -0
- package/flows/lib/epic-mvp.js +451 -0
- package/flows/lib/epic-stories.js +88 -48
- package/flows/port-guide.js +4 -1
- package/package.json +13 -4
- package/src/Cli.ts +64 -3
- package/src/FlowLaunch.ts +20 -66
- package/src/FlowProcess.ts +239 -0
- package/src/OperatorActTools.ts +407 -0
- package/src/OperatorConfig.ts +148 -0
- package/src/OperatorNode.ts +147 -0
- package/src/OperatorRuns.ts +270 -0
- package/src/OperatorServer.ts +192 -0
- package/src/OperatorTools.ts +610 -0
- package/src/Refine.ts +4 -7
- package/src/RunSupervisor.ts +403 -0
- package/src/ShellErrors.ts +6 -0
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
// epic-mvp (ADR 0042): epic-stories' spine with a test-first policy that
|
|
2
|
+
// reaches an MVP sooner. The planner cuts every story to its MVP outcomes
|
|
3
|
+
// and parks the rest; each story writes its red tests first (one short
|
|
4
|
+
// review of the tests, then frozen), and green tests replace the review
|
|
5
|
+
// lenses and the story judge. The run ends MVP reached — every MVP outcome's
|
|
6
|
+
// tests pass on the epic branch — or MVP not reached, with parked.md saying
|
|
7
|
+
// what is left. `--iterate` plans the parked items as the next round.
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import * as Effect from "effect/Effect";
|
|
10
|
+
import * as Option from "effect/Option";
|
|
11
|
+
import * as Schema from "effect/Schema";
|
|
12
|
+
import { makeChat } from "@llm4ts/flow/Chat";
|
|
13
|
+
import { FlowAborted, StoryFailed, StoryPlanInvalid } from "@llm4ts/flow/FlowError";
|
|
14
|
+
import { CriterionChecked, Info, Parked } from "@llm4ts/flow/FlowEvents";
|
|
15
|
+
import { loadVersioned } from "@llm4ts/flow/Persistence";
|
|
16
|
+
import { Plan, Task } from "@llm4ts/flow/Plan";
|
|
17
|
+
import { correctnessReviewer, lintCommand, reviewWith } from "@llm4ts/flow/Review";
|
|
18
|
+
import { Reviewer } from "@llm4ts/flow/Reviewer";
|
|
19
|
+
import { RedTests, RedTestsVersion, redTestsPath } from "@llm4ts/flow/Stories";
|
|
20
|
+
import { Story, StoryPlan } from "@llm4ts/flow/StoryPlan";
|
|
21
|
+
import { commandFor, criterionTags, evidenceSourceFromEnvironment, parseJUnit, statusFromReport, testPaths } from "@llm4ts/flow/TestEvidence";
|
|
22
|
+
import { structuredAndPublish } from "@llm4ts/flow/Usage";
|
|
23
|
+
import { nodeProcessExecutor, ScriptUsage } from "@llm4ts/runner";
|
|
24
|
+
import { storyJsonSchema, storyPlanInstructions } from "./epic-stories.js";
|
|
25
|
+
export const parseMvpFlags = (argv) => {
|
|
26
|
+
const iterate = argv.includes("--iterate");
|
|
27
|
+
const review = argv.includes("--review");
|
|
28
|
+
const rest = argv.filter((arg) => arg !== "--iterate" && arg !== "--review");
|
|
29
|
+
return { iterate, review, argv: iterate ? ["--refine", ...rest] : rest };
|
|
30
|
+
};
|
|
31
|
+
// ---- The MVP cut ------------------------------------------------------------
|
|
32
|
+
/** A whole story the planner cut from this iteration. */
|
|
33
|
+
export class ParkedStory extends Schema.Class("ParkedStory")({
|
|
34
|
+
title: Schema.String,
|
|
35
|
+
why: Schema.String
|
|
36
|
+
}) {
|
|
37
|
+
}
|
|
38
|
+
/** What the MVP planner answers: the stories to build now and the ones parked whole. */
|
|
39
|
+
export class MvpPlan extends Schema.Class("MvpPlan")({
|
|
40
|
+
epicId: Schema.String,
|
|
41
|
+
epic: Schema.String,
|
|
42
|
+
stories: Schema.Array(Story),
|
|
43
|
+
parked: Schema.Array(ParkedStory).pipe(Schema.withDecodingDefaultKey(Effect.succeed([])))
|
|
44
|
+
}) {
|
|
45
|
+
}
|
|
46
|
+
export const mvpPlanJsonSchema = {
|
|
47
|
+
type: "object",
|
|
48
|
+
properties: {
|
|
49
|
+
epicId: { type: "string" },
|
|
50
|
+
epic: { type: "string" },
|
|
51
|
+
stories: {
|
|
52
|
+
type: "array",
|
|
53
|
+
items: {
|
|
54
|
+
...storyJsonSchema,
|
|
55
|
+
properties: {
|
|
56
|
+
...storyJsonSchema.properties,
|
|
57
|
+
later: { type: "array", items: { type: "string" } }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
parked: {
|
|
62
|
+
type: "array",
|
|
63
|
+
items: {
|
|
64
|
+
type: "object",
|
|
65
|
+
properties: { title: { type: "string" }, why: { type: "string" } },
|
|
66
|
+
required: ["title", "why"]
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
required: ["epicId", "epic", "stories", "parked"]
|
|
71
|
+
};
|
|
72
|
+
/** The epic-stories planner's rules, then the MVP cut on top. */
|
|
73
|
+
export const mvpPlanInstructions = (epicId, guidance, orientation) => [
|
|
74
|
+
storyPlanInstructions(epicId, guidance, orientation),
|
|
75
|
+
"",
|
|
76
|
+
"MVP cut (this run is epic-mvp: reach a working MVP first, park the rest for a later iteration):",
|
|
77
|
+
"- `acceptance` holds ONLY the outcomes the MVP cannot ship without, one to four per story, each",
|
|
78
|
+
" checkable by an automated test (a test will be written for it before any code).",
|
|
79
|
+
"- `later` holds the story's other outcomes: polish, edge cases beyond the main path, extra",
|
|
80
|
+
" options. They are shown to the operator and not built now.",
|
|
81
|
+
"- A whole story the MVP does not need goes to the top-level `parked` list as",
|
|
82
|
+
' {"title":"…","why":"…"} instead of `stories`. Every story in `stories` has at least one',
|
|
83
|
+
" `acceptance` outcome, and none depends on a parked one.",
|
|
84
|
+
"- Prefer fewer, thinner stories: the thinnest end-to-end path that works.",
|
|
85
|
+
"- Items an approved brief marks deferred or dropped are parked, never planned.",
|
|
86
|
+
'Add `"later":[…]` to each story and `"parked":[…]` beside `"stories"` in the JSON.'
|
|
87
|
+
].join("\n");
|
|
88
|
+
/** The MVP planner: writes `mvp-cut.json` beside the plan with the stories parked whole. */
|
|
89
|
+
export const generateMvpPlan = (inputs) => Effect.gen(function* () {
|
|
90
|
+
const answer = yield* structuredAndPublish(inputs.reasoning, inputs.events, `${mvpPlanInstructions(inputs.epicId, inputs.guidance, inputs.orientation)}\n\nEpic:\n${inputs.brief ?? inputs.epic}`, MvpPlan, mvpPlanJsonSchema);
|
|
91
|
+
yield* inputs.files.writeAtomic(cutPath(inputs.stateDir), encodeCut(answer.parked.map((story) => ParkedStory.make(story))));
|
|
92
|
+
// The id and the epic text are ours, whatever the model echoed back.
|
|
93
|
+
return StoryPlan.make({ epicId: inputs.epicId, epic: inputs.epic, stories: answer.stories });
|
|
94
|
+
});
|
|
95
|
+
const ParkedStories = Schema.fromJsonString(Schema.Array(ParkedStory));
|
|
96
|
+
const encodeCut = Schema.encodeSync(ParkedStories);
|
|
97
|
+
const decodeCut = Schema.decodeUnknownOption(ParkedStories);
|
|
98
|
+
export const cutPath = (stateDir) => join(stateDir, "mvp-cut.json");
|
|
99
|
+
/** Every story keeps an MVP outcome; one with none belongs in the parked list. */
|
|
100
|
+
export const checkMvpPlan = (plan) => {
|
|
101
|
+
const violations = plan.stories
|
|
102
|
+
.filter((story) => story.acceptance.length === 0)
|
|
103
|
+
.map((story) => `story '${story.id}' has no MVP outcome in \`acceptance\`: give it one, or remove it and list it in mvp-cut.json`);
|
|
104
|
+
return violations.length === 0
|
|
105
|
+
? Effect.succeed(plan)
|
|
106
|
+
: Effect.fail(StoryPlanInvalid.make({ violations }));
|
|
107
|
+
};
|
|
108
|
+
// ---- Evidence ---------------------------------------------------------------
|
|
109
|
+
export const evidenceRequired = [
|
|
110
|
+
"epic-mvp needs per-test evidence for each MVP outcome; set one of:",
|
|
111
|
+
" LLM4TS_TEST_REPORT=<path> the JUnit XML report the test gate writes, relative to the app",
|
|
112
|
+
" directory (vitest --reporter=junit --outputFile=…, jest-junit,",
|
|
113
|
+
" pytest --junitxml=…, Surefire, cargo nextest)",
|
|
114
|
+
" LLM4TS_TEST_ONE=<command> one test run per outcome, {tag} or {tagRegex} replaced by its tag",
|
|
115
|
+
" (e.g. pnpm vitest run -t {tagRegex}); green when it exits 0"
|
|
116
|
+
].join("\n");
|
|
117
|
+
export const mvpPreflight = (environment) => evidenceSourceFromEnvironment(environment) === undefined
|
|
118
|
+
? Effect.fail(ScriptUsage.make({ message: evidenceRequired }))
|
|
119
|
+
: Effect.void;
|
|
120
|
+
/**
|
|
121
|
+
* The status of each tag in a checkout. `written(tag)` says the test sources
|
|
122
|
+
* carry it, so a tag a report does not show (a suite that cannot load yet)
|
|
123
|
+
* reads red rather than missing.
|
|
124
|
+
*/
|
|
125
|
+
export const evidenceAt = (source, unit, workDir, tags, written, events,
|
|
126
|
+
/** The checkout's uncommitted paths, to refuse a report git would commit. */
|
|
127
|
+
uncommitted) => Effect.gen(function* () {
|
|
128
|
+
const appRoot = join(workDir, unit.appDir);
|
|
129
|
+
const statuses = new Map();
|
|
130
|
+
if (source._tag === "Report") {
|
|
131
|
+
if (unit.testGate === undefined) {
|
|
132
|
+
return yield* FlowAborted.make({
|
|
133
|
+
message: `LLM4TS_TEST_REPORT is set but the gates name no test command to write it`
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
const report = join(appRoot, source.path);
|
|
137
|
+
yield* unit.files.remove(report);
|
|
138
|
+
// Red is the point of a red phase: the gate's verdict is read from the report.
|
|
139
|
+
yield* unit.testGate(workDir, events);
|
|
140
|
+
const cases = parseJUnit((yield* unit.files.read(report)) ?? "");
|
|
141
|
+
// A report git can see would be committed by a story and dirty the
|
|
142
|
+
// epic checkout: it must be ignored, and it never outlives the read.
|
|
143
|
+
const relative = join(unit.appDir, source.path);
|
|
144
|
+
const visible = uncommitted === undefined ? false : (yield* uncommitted).includes(relative);
|
|
145
|
+
yield* unit.files.remove(report);
|
|
146
|
+
if (visible) {
|
|
147
|
+
return yield* FlowAborted.make({
|
|
148
|
+
message: `the test report ${relative} is not git-ignored: add it to .gitignore, then rerun`
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
for (const tag of tags) {
|
|
152
|
+
statuses.set(tag, statusFromReport(tag, cases, written(tag)));
|
|
153
|
+
}
|
|
154
|
+
return statuses;
|
|
155
|
+
}
|
|
156
|
+
for (const tag of tags) {
|
|
157
|
+
const result = yield* lintCommand(nodeProcessExecutor, events, commandFor(source.argv, tag), appRoot);
|
|
158
|
+
statuses.set(tag, result.isClean ? "green" : written(tag) ? "red" : "missing");
|
|
159
|
+
}
|
|
160
|
+
return statuses;
|
|
161
|
+
});
|
|
162
|
+
// ---- The red phase ----------------------------------------------------------
|
|
163
|
+
export const redTestsPrompt = (story, tags) => [
|
|
164
|
+
`Red phase of the story "${story.title}" (test-first). Write ONLY tests now — no production code.`,
|
|
165
|
+
"",
|
|
166
|
+
"For each outcome below, write one or more tests whose NAME contains its tag exactly, for",
|
|
167
|
+
'example it("[story-id#1] shows the balance", …). The tests assert the observable outcome and',
|
|
168
|
+
"MUST FAIL now, because the behaviour does not exist yet; importing a module the story will",
|
|
169
|
+
"create is fine. Put them in the story's owned test paths, beside the code they cover, in the",
|
|
170
|
+
"repository's test style. Do not commit. Once written, these tests are frozen: the",
|
|
171
|
+
"implementation will be judged by them alone.",
|
|
172
|
+
"",
|
|
173
|
+
...story.acceptance.map((outcome, index) => `${tags[index] ?? ""} ${outcome}`)
|
|
174
|
+
].join("\n");
|
|
175
|
+
/** The one lens on the red tests: do they pin down the outcomes, and only them. */
|
|
176
|
+
export const redTestsLens = Reviewer.make({
|
|
177
|
+
name: "red-tests",
|
|
178
|
+
systemPrompt: [
|
|
179
|
+
"You review tests written BEFORE the implementation, which will be the only check of it.",
|
|
180
|
+
"For each tagged outcome, check that its tests assert the outcome itself (not an",
|
|
181
|
+
"implementation detail), would fail on a wrong implementation, and cannot pass vacuously",
|
|
182
|
+
"(no assertion, a mocked subject, a hard-coded expectation). Report only those gaps, one",
|
|
183
|
+
"finding per gap, naming the tag. Production code is absent on purpose; never report that."
|
|
184
|
+
].join("\n"),
|
|
185
|
+
screen: "The tests leave an outcome unasserted, assert a detail, or could pass vacuously."
|
|
186
|
+
});
|
|
187
|
+
const tagsWritten = (files, workDir, paths) => Effect.map(Effect.forEach(paths, (path) => files.read(join(workDir, path))), (texts) => texts.filter((text) => text !== undefined).join("\n"));
|
|
188
|
+
/**
|
|
189
|
+
* The story's red phase: the coder writes tagged tests, one review round
|
|
190
|
+
* reads them, one fix turn answers it; every outcome must then be red. An
|
|
191
|
+
* outcome with no tagged test after one more turn fails the story; one
|
|
192
|
+
* whose tests already pass is parked as "check the outcome".
|
|
193
|
+
*/
|
|
194
|
+
export const mvpRedPhase = (source, unit) => (story, seats, workDir, system, events) => Effect.gen(function* () {
|
|
195
|
+
const git = seats.context.git;
|
|
196
|
+
const tags = criterionTags(story.id, story.acceptance);
|
|
197
|
+
const chat = yield* makeChat(seats.context.coder, { system, events, agent: "coder" });
|
|
198
|
+
yield* chat.ask(redTestsPrompt(story, tags));
|
|
199
|
+
const reviewer = seats.context.roster?.forRole("reviewer") ?? seats.context.reasoning;
|
|
200
|
+
const review = yield* reviewWith(reviewer, events, redTestsLens, `red tests: ${story.title}`, yield* git.diffAll);
|
|
201
|
+
if (!review.isClean) {
|
|
202
|
+
yield* chat.ask([
|
|
203
|
+
"A reviewer read your tests. Fix these gaps in the tests (still no production code):",
|
|
204
|
+
...review.issues.map((issue) => `- ${issue.title}: ${issue.description}`)
|
|
205
|
+
].join("\n"));
|
|
206
|
+
}
|
|
207
|
+
const check = Effect.gen(function* () {
|
|
208
|
+
const changed = testPaths(yield* git.uncommittedFiles);
|
|
209
|
+
const text = yield* tagsWritten(unit.files, workDir, changed);
|
|
210
|
+
const statuses = yield* evidenceAt(source, unit, workDir, tags, (tag) => text.includes(tag), events, git.uncommittedFiles);
|
|
211
|
+
return { changed, statuses };
|
|
212
|
+
});
|
|
213
|
+
let seen = yield* check;
|
|
214
|
+
const missing = () => tags.filter((tag) => seen.statuses.get(tag) === "missing");
|
|
215
|
+
if (missing().length > 0) {
|
|
216
|
+
yield* chat.ask(`No test names these outcomes yet: ${missing().join(", ")}. Write them, with the tag in the test name.`);
|
|
217
|
+
seen = yield* check;
|
|
218
|
+
}
|
|
219
|
+
if (missing().length > 0) {
|
|
220
|
+
return yield* StoryFailed.make({
|
|
221
|
+
story: story.id,
|
|
222
|
+
reason: `red phase: no test names ${missing().join(", ")}`
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
const parked = [];
|
|
226
|
+
for (const [index, tag] of tags.entries()) {
|
|
227
|
+
const status = seen.statuses.get(tag) ?? "missing";
|
|
228
|
+
yield* events.publish(CriterionChecked.make({ story: story.id, tag, status }));
|
|
229
|
+
if (status === "green") {
|
|
230
|
+
const item = `${tag} ${story.acceptance[index] ?? ""}`;
|
|
231
|
+
const reason = "its tests pass before implementation: check the outcome or its test";
|
|
232
|
+
parked.push(`${item} — ${reason}`);
|
|
233
|
+
yield* events.publish(Parked.make({ story: story.id, item, reason, mvp: false }));
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return { frozen: seen.changed, parked };
|
|
237
|
+
});
|
|
238
|
+
/** One green task per story: the tests are the plan. */
|
|
239
|
+
export const greenTaskPlan = (_seats, story, _prompt) => {
|
|
240
|
+
const tags = criterionTags(story.id, story.acceptance);
|
|
241
|
+
return Effect.succeed(Plan.make({
|
|
242
|
+
epicId: story.id,
|
|
243
|
+
tasks: [
|
|
244
|
+
Task.make({
|
|
245
|
+
title: `Make the red tests of ${story.id} pass`,
|
|
246
|
+
description: [
|
|
247
|
+
"The tests tagged below are committed and frozen: make every one pass by writing the",
|
|
248
|
+
"production code, inside your owned paths. Never change, skip or delete a frozen test.",
|
|
249
|
+
...story.acceptance.map((outcome, index) => `${tags[index] ?? ""} ${outcome}`),
|
|
250
|
+
`Satisfies: ${story.acceptance.map((_, index) => index + 1).join(", ")}`
|
|
251
|
+
].join("\n")
|
|
252
|
+
})
|
|
253
|
+
]
|
|
254
|
+
}));
|
|
255
|
+
};
|
|
256
|
+
export class MvpVerdict extends Schema.Class("MvpVerdict")({
|
|
257
|
+
reached: Schema.Boolean,
|
|
258
|
+
green: Schema.Array(Schema.String),
|
|
259
|
+
blocking: Schema.Array(Schema.String)
|
|
260
|
+
}) {
|
|
261
|
+
}
|
|
262
|
+
export const MvpVerdictVersion = 1;
|
|
263
|
+
export const verdictPath = (stateDir) => join(stateDir, "mvp.json");
|
|
264
|
+
export const parkedPath = (stateDir) => join(stateDir, "parked.md");
|
|
265
|
+
export const renderParked = (epicId, entries) => {
|
|
266
|
+
const blocking = entries.filter((entry) => entry.mvp);
|
|
267
|
+
const later = entries.filter((entry) => !entry.mvp);
|
|
268
|
+
const line = (entry) => `- **${entry.story}** — ${entry.item} _(${entry.reason})_`;
|
|
269
|
+
return [
|
|
270
|
+
`# Parked: ${epicId}`,
|
|
271
|
+
"",
|
|
272
|
+
"What this iteration did not build. `--iterate` plans these as the next round;",
|
|
273
|
+
"the text given with it picks which (none: all of them).",
|
|
274
|
+
"",
|
|
275
|
+
"## Blocking the MVP",
|
|
276
|
+
"",
|
|
277
|
+
...(blocking.length === 0 ? ["(none)"] : blocking.map(line)),
|
|
278
|
+
"",
|
|
279
|
+
"## Later",
|
|
280
|
+
"",
|
|
281
|
+
...(later.length === 0 ? ["(none)"] : later.map(line)),
|
|
282
|
+
""
|
|
283
|
+
].join("\n");
|
|
284
|
+
};
|
|
285
|
+
/**
|
|
286
|
+
* What the unit left parked before evidence: stories cut whole, `later`
|
|
287
|
+
* outcomes, outcomes the red phase found already green, and every MVP
|
|
288
|
+
* outcome of a story that did not finish.
|
|
289
|
+
*/
|
|
290
|
+
export const parkedBeforeEvidence = (plan, report, cut, red) => [
|
|
291
|
+
...cut.map((story) => ({
|
|
292
|
+
story: story.title,
|
|
293
|
+
item: "the whole story",
|
|
294
|
+
reason: story.why,
|
|
295
|
+
mvp: false
|
|
296
|
+
})),
|
|
297
|
+
...plan.stories.flatMap((story) => {
|
|
298
|
+
const outcome = report.stories.find((entry) => entry.id === story.id);
|
|
299
|
+
const later = (story.later ?? []).map((item) => ({
|
|
300
|
+
story: story.id,
|
|
301
|
+
item,
|
|
302
|
+
reason: "cut from this iteration",
|
|
303
|
+
mvp: false
|
|
304
|
+
}));
|
|
305
|
+
const flagged = (red.get(story.id)?.parked ?? []).map((item) => ({
|
|
306
|
+
story: story.id,
|
|
307
|
+
item,
|
|
308
|
+
reason: "already green",
|
|
309
|
+
mvp: false
|
|
310
|
+
}));
|
|
311
|
+
const unfinished = outcome === undefined || outcome.status === "done"
|
|
312
|
+
? []
|
|
313
|
+
: criterionTags(story.id, story.acceptance).map((tag, index) => ({
|
|
314
|
+
story: story.id,
|
|
315
|
+
item: `${tag} ${story.acceptance[index] ?? ""}`,
|
|
316
|
+
reason: `story ${outcome.status}${outcome.reason === undefined ? "" : `: ${outcome.reason.split("\n")[0] ?? ""}`}`,
|
|
317
|
+
mvp: true
|
|
318
|
+
}));
|
|
319
|
+
return [...later, ...flagged, ...unfinished];
|
|
320
|
+
})
|
|
321
|
+
];
|
|
322
|
+
/**
|
|
323
|
+
* After a unit: every finished story's MVP outcomes are checked on the epic
|
|
324
|
+
* checkout. Reached when nothing MVP is parked and every one is green.
|
|
325
|
+
*/
|
|
326
|
+
export const mvpAfterUnit = (source) => (unit, report) => Effect.gen(function* () {
|
|
327
|
+
const { files, events, plan } = unit;
|
|
328
|
+
const cut = unit.stateDir === unit.epicStateDir
|
|
329
|
+
? Option.getOrElse(decodeCut((yield* files.read(cutPath(unit.stateDir))) ?? "[]"), () => [])
|
|
330
|
+
: [];
|
|
331
|
+
const red = new Map();
|
|
332
|
+
for (const story of plan.stories) {
|
|
333
|
+
const tests = yield* loadVersioned(files, redTestsPath(unit.stateDir, story.id), RedTestsVersion, RedTests).pipe(Effect.orElseSucceed(() => undefined));
|
|
334
|
+
if (tests !== undefined) {
|
|
335
|
+
red.set(story.id, tests);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
const done = plan.stories.filter((story) => report.stories.find((entry) => entry.id === story.id)?.status === "done");
|
|
339
|
+
const tags = done.flatMap((story) => criterionTags(story.id, story.acceptance).map((tag, index) => ({
|
|
340
|
+
story,
|
|
341
|
+
tag,
|
|
342
|
+
outcome: story.acceptance[index] ?? ""
|
|
343
|
+
})));
|
|
344
|
+
const statuses = yield* evidenceAt(source, unit, unit.workDir, tags.map((entry) => entry.tag), () => false, events);
|
|
345
|
+
const notGreen = [];
|
|
346
|
+
const green = [];
|
|
347
|
+
for (const entry of tags) {
|
|
348
|
+
const status = statuses.get(entry.tag) ?? "missing";
|
|
349
|
+
yield* events.publish(CriterionChecked.make({ story: entry.story.id, tag: entry.tag, status }));
|
|
350
|
+
if (status === "green") {
|
|
351
|
+
green.push(entry.tag);
|
|
352
|
+
}
|
|
353
|
+
else {
|
|
354
|
+
notGreen.push({
|
|
355
|
+
story: entry.story.id,
|
|
356
|
+
item: `${entry.tag} ${entry.outcome}`,
|
|
357
|
+
reason: `${status} on the epic branch`,
|
|
358
|
+
mvp: true
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
const entries = [...parkedBeforeEvidence(plan, report, cut, red), ...notGreen];
|
|
363
|
+
for (const entry of entries) {
|
|
364
|
+
yield* events.publish(Parked.make(entry));
|
|
365
|
+
}
|
|
366
|
+
const blocking = entries.filter((entry) => entry.mvp);
|
|
367
|
+
const verdict = MvpVerdict.make({
|
|
368
|
+
reached: blocking.length === 0,
|
|
369
|
+
green,
|
|
370
|
+
blocking: blocking.map((entry) => `${entry.story}: ${entry.item}`)
|
|
371
|
+
});
|
|
372
|
+
yield* files.writeAtomic(parkedPath(unit.stateDir), renderParked(plan.epicId, entries));
|
|
373
|
+
yield* files.writeAtomic(verdictPath(unit.stateDir), encodeVerdict(verdict));
|
|
374
|
+
yield* events.publish(Info.make({
|
|
375
|
+
message: verdict.reached
|
|
376
|
+
? `MVP reached: ${green.length} outcome(s) green; ${entries.length} item(s) parked in ${parkedPath(unit.stateDir)} — --land when ready, --iterate for the next round`
|
|
377
|
+
: `MVP not reached: ${blocking.length} MVP outcome(s) open, listed in ${parkedPath(unit.stateDir)}; rerun to finish the stories, or edit the plan`
|
|
378
|
+
}));
|
|
379
|
+
});
|
|
380
|
+
const Verdict = Schema.fromJsonString(MvpVerdict);
|
|
381
|
+
const encodeVerdict = Schema.encodeSync(Verdict);
|
|
382
|
+
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
|
+
/** `--land` only once the MVP is reached, in the epic and in every round. */
|
|
395
|
+
export const mvpBeforeLand = (epicStateDir, files) => Effect.gen(function* () {
|
|
396
|
+
for (const dir of yield* verdictDirs(files, epicStateDir)) {
|
|
397
|
+
const text = yield* files.read(verdictPath(dir));
|
|
398
|
+
const verdict = text === undefined ? undefined : decodeVerdict(text);
|
|
399
|
+
if (verdict === undefined || Option.isNone(verdict)) {
|
|
400
|
+
return yield* FlowAborted.make({
|
|
401
|
+
message: `not landing: ${dir} has no MVP verdict yet; run the epic first`
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
if (!verdict.value.reached) {
|
|
405
|
+
return yield* FlowAborted.make({
|
|
406
|
+
message: [
|
|
407
|
+
`not landing: the MVP is not reached (${verdictPath(dir)}). Open MVP outcomes:`,
|
|
408
|
+
...verdict.value.blocking.map((item) => `- ${item}`)
|
|
409
|
+
].join("\n")
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
/** `--iterate`: the text given, then every parked list of the epic as what to plan. */
|
|
415
|
+
export const mvpIterateFeedback = (epicStateDir, text, files) => Effect.gen(function* () {
|
|
416
|
+
const parked = [];
|
|
417
|
+
for (const dir of yield* verdictDirs(files, epicStateDir)) {
|
|
418
|
+
const list = yield* files.read(parkedPath(dir));
|
|
419
|
+
if (list !== undefined) {
|
|
420
|
+
parked.push(list);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
const pick = text.length === 0
|
|
424
|
+
? "Plan every parked item below as this round's MVP."
|
|
425
|
+
: `Plan what the operator promotes:\n${text}\n\nThe parked items below are the context; plan only those the text names.`;
|
|
426
|
+
return parked.length === 0 ? text : [pick, "", ...parked].join("\n");
|
|
427
|
+
});
|
|
428
|
+
// ---- The policy -------------------------------------------------------------
|
|
429
|
+
/** The epic-stories spine with epic-mvp's policy (ADR 0042). */
|
|
430
|
+
export const epicMvpOptions = (flags, environment) => {
|
|
431
|
+
const source = evidenceSourceFromEnvironment(environment);
|
|
432
|
+
return {
|
|
433
|
+
preflight: mvpPreflight,
|
|
434
|
+
generatePlan: generateMvpPlan,
|
|
435
|
+
checkPlan: checkMvpPlan,
|
|
436
|
+
refineFeedback: mvpIterateFeedback,
|
|
437
|
+
beforeLand: mvpBeforeLand,
|
|
438
|
+
...(source === undefined
|
|
439
|
+
? {}
|
|
440
|
+
: {
|
|
441
|
+
stories: (unit) => ({
|
|
442
|
+
redPhase: mvpRedPhase(source, unit),
|
|
443
|
+
planTasks: greenTaskPlan,
|
|
444
|
+
// Green tests are the review; `--review` adds one lens back.
|
|
445
|
+
reviewers: flags.review ? [correctnessReviewer] : [],
|
|
446
|
+
taskConcurrency: 1
|
|
447
|
+
}),
|
|
448
|
+
afterUnit: mvpAfterUnit(source)
|
|
449
|
+
})
|
|
450
|
+
};
|
|
451
|
+
};
|
|
@@ -737,40 +737,39 @@ export const epicIdFor = (epic) => {
|
|
|
737
737
|
.join("-");
|
|
738
738
|
return `${slug.length === 0 ? "epic" : slug}-${stableHash(epic).slice(0, 6)}`;
|
|
739
739
|
};
|
|
740
|
+
/** One story of the plan as the planner writes it; flows extend its properties (ADR 0042). */
|
|
741
|
+
export const storyJsonSchema = {
|
|
742
|
+
type: "object",
|
|
743
|
+
properties: {
|
|
744
|
+
id: { type: "string" },
|
|
745
|
+
title: { type: "string" },
|
|
746
|
+
description: { type: "string" },
|
|
747
|
+
dependsOn: { type: "array", items: { type: "string" } },
|
|
748
|
+
owned: { type: "array", items: { type: "string" } },
|
|
749
|
+
sharedReadOnly: { type: "array", items: { type: "string" } },
|
|
750
|
+
readFirst: { type: "array", items: { type: "string" } },
|
|
751
|
+
acceptance: { type: "array", items: { type: "string" } },
|
|
752
|
+
provides: { type: "array", items: { type: "string" } },
|
|
753
|
+
testsChange: { type: "boolean" }
|
|
754
|
+
},
|
|
755
|
+
required: [
|
|
756
|
+
"id",
|
|
757
|
+
"title",
|
|
758
|
+
"description",
|
|
759
|
+
"dependsOn",
|
|
760
|
+
"owned",
|
|
761
|
+
"sharedReadOnly",
|
|
762
|
+
"readFirst",
|
|
763
|
+
"acceptance",
|
|
764
|
+
"provides"
|
|
765
|
+
]
|
|
766
|
+
};
|
|
740
767
|
export const storyPlanJsonSchema = {
|
|
741
768
|
type: "object",
|
|
742
769
|
properties: {
|
|
743
770
|
epicId: { type: "string" },
|
|
744
771
|
epic: { type: "string" },
|
|
745
|
-
stories: {
|
|
746
|
-
type: "array",
|
|
747
|
-
items: {
|
|
748
|
-
type: "object",
|
|
749
|
-
properties: {
|
|
750
|
-
id: { type: "string" },
|
|
751
|
-
title: { type: "string" },
|
|
752
|
-
description: { type: "string" },
|
|
753
|
-
dependsOn: { type: "array", items: { type: "string" } },
|
|
754
|
-
owned: { type: "array", items: { type: "string" } },
|
|
755
|
-
sharedReadOnly: { type: "array", items: { type: "string" } },
|
|
756
|
-
readFirst: { type: "array", items: { type: "string" } },
|
|
757
|
-
acceptance: { type: "array", items: { type: "string" } },
|
|
758
|
-
provides: { type: "array", items: { type: "string" } },
|
|
759
|
-
testsChange: { type: "boolean" }
|
|
760
|
-
},
|
|
761
|
-
required: [
|
|
762
|
-
"id",
|
|
763
|
-
"title",
|
|
764
|
-
"description",
|
|
765
|
-
"dependsOn",
|
|
766
|
-
"owned",
|
|
767
|
-
"sharedReadOnly",
|
|
768
|
-
"readFirst",
|
|
769
|
-
"acceptance",
|
|
770
|
-
"provides"
|
|
771
|
-
]
|
|
772
|
-
}
|
|
773
|
-
}
|
|
772
|
+
stories: { type: "array", items: storyJsonSchema }
|
|
774
773
|
},
|
|
775
774
|
required: ["epicId", "epic", "stories"]
|
|
776
775
|
};
|
|
@@ -1410,7 +1409,7 @@ const withExtraFlags = (config, flags) => Object.keys(flags).length === 0
|
|
|
1410
1409
|
export const defaultEpic = "Add the retail customer's current account (Conto) with balance and movements, and wire " +
|
|
1411
1410
|
"transfers (Bonifico) with beneficiary, review, SCA confirmation, and history.";
|
|
1412
1411
|
export const runEpicStories = (options) => Effect.gen(function* () {
|
|
1413
|
-
const flags = yield* parseEpicArgs(process.argv.slice(2));
|
|
1412
|
+
const flags = yield* parseEpicArgs(options.argv ?? process.argv.slice(2));
|
|
1414
1413
|
// The epic is chosen before any seat is resolved: the text given, `--epic`,
|
|
1415
1414
|
// or the one epic not landed yet (`chooseEpic`); `--list` only lists.
|
|
1416
1415
|
const given = yield* resolveFlowInput("", flags.rest);
|
|
@@ -1421,7 +1420,7 @@ export const runEpicStories = (options) => Effect.gen(function* () {
|
|
|
1421
1420
|
return;
|
|
1422
1421
|
}
|
|
1423
1422
|
// With `--refine` the text is feedback on a finished epic, never an epic's text.
|
|
1424
|
-
|
|
1423
|
+
let feedback = flags.refine ? given.prompt.trim() : "";
|
|
1425
1424
|
const choice = yield* chooseEpic({
|
|
1426
1425
|
text: flags.refine ? "" : given.prompt,
|
|
1427
1426
|
epic: flags.epic,
|
|
@@ -1430,7 +1429,10 @@ export const runEpicStories = (options) => Effect.gen(function* () {
|
|
|
1430
1429
|
defaultEpic
|
|
1431
1430
|
});
|
|
1432
1431
|
if (flags.refine) {
|
|
1433
|
-
yield* refineEpic(choice);
|
|
1432
|
+
const refined = yield* refineEpic(choice);
|
|
1433
|
+
if (options.refineFeedback !== undefined) {
|
|
1434
|
+
feedback = yield* options.refineFeedback(join(epicsDir(given.workDir), refined.dir), feedback, files);
|
|
1435
|
+
}
|
|
1434
1436
|
}
|
|
1435
1437
|
// What this run does (ADR 0021): the plan's stories, the open refine round,
|
|
1436
1438
|
// a new round, or the landing. Refusals come before any seat is resolved.
|
|
@@ -1467,6 +1469,9 @@ export const runEpicStories = (options) => Effect.gen(function* () {
|
|
|
1467
1469
|
? choice.request
|
|
1468
1470
|
: choice.prompt
|
|
1469
1471
|
};
|
|
1472
|
+
if (options.preflight !== undefined) {
|
|
1473
|
+
yield* options.preflight(process.env);
|
|
1474
|
+
}
|
|
1470
1475
|
// The pack whose `## Gates` the run uses; a wrong name fails here, not after planning.
|
|
1471
1476
|
const pack = yield* selectedPack({
|
|
1472
1477
|
environment: process.env,
|
|
@@ -1525,8 +1530,20 @@ export const runEpicStories = (options) => Effect.gen(function* () {
|
|
|
1525
1530
|
}
|
|
1526
1531
|
const store = makeStoryPlanStore(files);
|
|
1527
1532
|
const plan = yield* stage(events, "story plan", store
|
|
1528
|
-
.recoverOrCreate(planPath,
|
|
1529
|
-
|
|
1533
|
+
.recoverOrCreate(planPath, (options.generatePlan === undefined
|
|
1534
|
+
? generateStoryPlan(reasoningMeter.service, events, input.prompt, epicId, guidance, brief, orientation)
|
|
1535
|
+
: options.generatePlan({
|
|
1536
|
+
reasoning: reasoningMeter.service,
|
|
1537
|
+
events,
|
|
1538
|
+
epic: input.prompt,
|
|
1539
|
+
epicId,
|
|
1540
|
+
guidance,
|
|
1541
|
+
brief,
|
|
1542
|
+
orientation,
|
|
1543
|
+
files,
|
|
1544
|
+
stateDir
|
|
1545
|
+
})).pipe(Effect.flatMap((generated) => pruneUnknownAnchors(generated, context.git, events))))
|
|
1546
|
+
.pipe(Effect.flatMap(validateStoryPlan), Effect.flatMap((valid) => options.checkPlan === undefined ? Effect.succeed(valid) : options.checkPlan(valid))));
|
|
1530
1547
|
yield* events.publish(Info.make({
|
|
1531
1548
|
message: `story plan: ${plan.stories.length} stories at ${planPath} (edit and rerun to re-plan)`
|
|
1532
1549
|
}));
|
|
@@ -1637,6 +1654,9 @@ export const runEpicStories = (options) => Effect.gen(function* () {
|
|
|
1637
1654
|
yield* nodePreflight(nodeProcessExecutor, files, events, join(input.workDir, appDir), process.env);
|
|
1638
1655
|
}
|
|
1639
1656
|
if (flags.land !== undefined) {
|
|
1657
|
+
if (options.beforeLand !== undefined) {
|
|
1658
|
+
yield* options.beforeLand(stateDir, files);
|
|
1659
|
+
}
|
|
1640
1660
|
const landed = yield* landEpic(context, {
|
|
1641
1661
|
plan,
|
|
1642
1662
|
files,
|
|
@@ -1697,6 +1717,19 @@ export const runEpicStories = (options) => Effect.gen(function* () {
|
|
|
1697
1717
|
];
|
|
1698
1718
|
const gates = inAppDir(appDir, gatesIn(nodeProcessExecutor, events, commands, gateRunOptions(process.env)));
|
|
1699
1719
|
const testCommand = testGateCommand(commands);
|
|
1720
|
+
const testGate = testCommand === undefined
|
|
1721
|
+
? undefined
|
|
1722
|
+
: inAppDir(appDir, gatesIn(nodeProcessExecutor, events, [testCommand], gateRunOptions(process.env)));
|
|
1723
|
+
const unitContext = (work) => ({
|
|
1724
|
+
plan: work.plan,
|
|
1725
|
+
stateDir: work.stateDir,
|
|
1726
|
+
epicStateDir: stateDir,
|
|
1727
|
+
workDir: input.workDir,
|
|
1728
|
+
appDir,
|
|
1729
|
+
files,
|
|
1730
|
+
events,
|
|
1731
|
+
testGate
|
|
1732
|
+
});
|
|
1700
1733
|
const setupCommand = worktreeSetupCommand(process.env, pack);
|
|
1701
1734
|
const healthUrl = serverHealthUrl(localServer, process.env);
|
|
1702
1735
|
// The executor over one unit: the epic's plan or one refine round.
|
|
@@ -1732,11 +1765,7 @@ export const runEpicStories = (options) => Effect.gen(function* () {
|
|
|
1732
1765
|
// the failing lines it added to the epic head it started from.
|
|
1733
1766
|
gateCommands: commands,
|
|
1734
1767
|
appDir,
|
|
1735
|
-
...(
|
|
1736
|
-
? {}
|
|
1737
|
-
: {
|
|
1738
|
-
testGate: inAppDir(appDir, gatesIn(nodeProcessExecutor, events, [testCommand], gateRunOptions(process.env)))
|
|
1739
|
-
}),
|
|
1768
|
+
...(testGate === undefined ? {} : { testGate }),
|
|
1740
1769
|
fix: fixPromptOptions(process.env),
|
|
1741
1770
|
...reviewVotes(process.env),
|
|
1742
1771
|
...reviewFixer(process.env),
|
|
@@ -1751,14 +1780,18 @@ export const runEpicStories = (options) => Effect.gen(function* () {
|
|
|
1751
1780
|
// With a roster, the judge and the verifier are leased per story,
|
|
1752
1781
|
// away from the executor coding it (ADR 0019).
|
|
1753
1782
|
...storyContextChars(process.env),
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1783
|
+
...(options.storyJudge === undefined
|
|
1784
|
+
? {}
|
|
1785
|
+
: {
|
|
1786
|
+
judge: options.storyJudge({
|
|
1787
|
+
plan: work.plan,
|
|
1788
|
+
budget: contextBudget,
|
|
1789
|
+
reasoning: reasoningMeter.service,
|
|
1790
|
+
events,
|
|
1791
|
+
files,
|
|
1792
|
+
houseRules: guidance
|
|
1793
|
+
})
|
|
1794
|
+
}),
|
|
1762
1795
|
verifyBlocked: (story, need, workDir, seats) => verifyBlockedOn(seats.context.roster?.forRole("verifier") ?? seats.context.reasoning, events, files, work.plan)(story, need, workDir),
|
|
1763
1796
|
// A roster waits for its own executors (the story's next lease
|
|
1764
1797
|
// does); without one, poll the single coder's engine.
|
|
@@ -1778,13 +1811,17 @@ export const runEpicStories = (options) => Effect.gen(function* () {
|
|
|
1778
1811
|
...taskConcurrencyOption(flags.taskConcurrency, process.env),
|
|
1779
1812
|
concurrency,
|
|
1780
1813
|
...(deferring ? { deferNonBlocking: true } : {}),
|
|
1781
|
-
failFast: flags.failFast
|
|
1814
|
+
failFast: flags.failFast,
|
|
1815
|
+
...(options.stories === undefined ? {} : options.stories(unitContext(work)))
|
|
1782
1816
|
});
|
|
1783
1817
|
const summarize = (work, report) => events.publish(Info.make({
|
|
1784
1818
|
message: `${work.label.replace("Epic: ", "epic ")}: ${report.count("done")} done, ${report.count("failed")} failed, ${report.count("waiting")} waiting — report at ${join(work.stateDir, "report.md")} (usage figures estimated)`
|
|
1785
1819
|
}));
|
|
1786
1820
|
const report = yield* runUnit(unit);
|
|
1787
1821
|
yield* summarize(unit, report);
|
|
1822
|
+
if (options.afterUnit !== undefined) {
|
|
1823
|
+
yield* options.afterUnit(unitContext(unit), report);
|
|
1824
|
+
}
|
|
1788
1825
|
const hint = retroHint(report, input.workDir, epicId);
|
|
1789
1826
|
if (hint !== undefined) {
|
|
1790
1827
|
yield* events.publish(Info.make({ message: hint }));
|
|
@@ -1851,6 +1888,9 @@ export const runEpicStories = (options) => Effect.gen(function* () {
|
|
|
1851
1888
|
}));
|
|
1852
1889
|
const roundReport = yield* runUnit(roundUnit);
|
|
1853
1890
|
yield* summarize(roundUnit, roundReport);
|
|
1891
|
+
if (options.afterUnit !== undefined) {
|
|
1892
|
+
yield* options.afterUnit(unitContext(roundUnit), roundReport);
|
|
1893
|
+
}
|
|
1854
1894
|
const roundHint = retroHint(roundReport, input.workDir, epicId);
|
|
1855
1895
|
if (roundHint !== undefined) {
|
|
1856
1896
|
yield* events.publish(Info.make({ message: roundHint }));
|