@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.
Files changed (67) hide show
  1. package/dist/Cli.d.ts +3 -7
  2. package/dist/Cli.d.ts.map +1 -1
  3. package/dist/Cli.js +20 -4
  4. package/dist/Cli.js.map +1 -1
  5. package/dist/FlowLaunch.d.ts +2 -16
  6. package/dist/FlowLaunch.d.ts.map +1 -1
  7. package/dist/FlowLaunch.js +20 -66
  8. package/dist/FlowLaunch.js.map +1 -1
  9. package/dist/FlowProcess.d.ts +67 -0
  10. package/dist/FlowProcess.d.ts.map +1 -0
  11. package/dist/FlowProcess.js +175 -0
  12. package/dist/FlowProcess.js.map +1 -0
  13. package/dist/Menu.d.ts +1 -1
  14. package/dist/Menu.d.ts.map +1 -1
  15. package/dist/OperatorActTools.d.ts +170 -0
  16. package/dist/OperatorActTools.d.ts.map +1 -0
  17. package/dist/OperatorActTools.js +332 -0
  18. package/dist/OperatorActTools.js.map +1 -0
  19. package/dist/OperatorConfig.d.ts +44 -0
  20. package/dist/OperatorConfig.d.ts.map +1 -0
  21. package/dist/OperatorConfig.js +88 -0
  22. package/dist/OperatorConfig.js.map +1 -0
  23. package/dist/OperatorNode.d.ts +45 -0
  24. package/dist/OperatorNode.d.ts.map +1 -0
  25. package/dist/OperatorNode.js +103 -0
  26. package/dist/OperatorNode.js.map +1 -0
  27. package/dist/OperatorRuns.d.ts +76 -0
  28. package/dist/OperatorRuns.d.ts.map +1 -0
  29. package/dist/OperatorRuns.js +146 -0
  30. package/dist/OperatorRuns.js.map +1 -0
  31. package/dist/OperatorServer.d.ts +160 -0
  32. package/dist/OperatorServer.d.ts.map +1 -0
  33. package/dist/OperatorServer.js +123 -0
  34. package/dist/OperatorServer.js.map +1 -0
  35. package/dist/OperatorTools.d.ts +558 -0
  36. package/dist/OperatorTools.d.ts.map +1 -0
  37. package/dist/OperatorTools.js +398 -0
  38. package/dist/OperatorTools.js.map +1 -0
  39. package/dist/Refine.d.ts +2 -2
  40. package/dist/Refine.d.ts.map +1 -1
  41. package/dist/Refine.js +3 -5
  42. package/dist/Refine.js.map +1 -1
  43. package/dist/RunSupervisor.d.ts +51 -0
  44. package/dist/RunSupervisor.d.ts.map +1 -0
  45. package/dist/RunSupervisor.js +282 -0
  46. package/dist/RunSupervisor.js.map +1 -0
  47. package/dist/ShellErrors.d.ts +9 -0
  48. package/dist/ShellErrors.d.ts.map +1 -0
  49. package/dist/ShellErrors.js +7 -0
  50. package/dist/ShellErrors.js.map +1 -0
  51. package/flows/epic-mvp.js +21 -0
  52. package/flows/lib/epic-mvp.js +451 -0
  53. package/flows/lib/epic-stories.js +88 -48
  54. package/flows/port-guide.js +4 -1
  55. package/package.json +13 -4
  56. package/src/Cli.ts +64 -3
  57. package/src/FlowLaunch.ts +20 -66
  58. package/src/FlowProcess.ts +239 -0
  59. package/src/OperatorActTools.ts +407 -0
  60. package/src/OperatorConfig.ts +148 -0
  61. package/src/OperatorNode.ts +147 -0
  62. package/src/OperatorRuns.ts +270 -0
  63. package/src/OperatorServer.ts +192 -0
  64. package/src/OperatorTools.ts +610 -0
  65. package/src/Refine.ts +4 -7
  66. package/src/RunSupervisor.ts +403 -0
  67. 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
- const feedback = flags.refine ? given.prompt.trim() : "";
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, generateStoryPlan(reasoningMeter.service, events, input.prompt, epicId, guidance, brief, orientation).pipe(Effect.flatMap((generated) => pruneUnknownAnchors(generated, context.git, events))))
1529
- .pipe(Effect.flatMap(validateStoryPlan)));
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
- ...(testCommand === undefined
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
- judge: options.storyJudge({
1755
- plan: work.plan,
1756
- budget: contextBudget,
1757
- reasoning: reasoningMeter.service,
1758
- events,
1759
- files,
1760
- houseRules: guidance
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 }));