@llm4ts/flow 0.15.1 → 0.16.1

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 (54) hide show
  1. package/dist/BenchReport.d.ts +2 -2
  2. package/dist/BenchReport.d.ts.map +1 -1
  3. package/dist/BoardSync.d.ts.map +1 -1
  4. package/dist/BoardSync.js +9 -4
  5. package/dist/BoardSync.js.map +1 -1
  6. package/dist/CostLedger.d.ts +2 -2
  7. package/dist/CostLedger.d.ts.map +1 -1
  8. package/dist/Equiv.d.ts +3 -3
  9. package/dist/Equiv.d.ts.map +1 -1
  10. package/dist/Flow.d.ts +1 -1
  11. package/dist/Flow.d.ts.map +1 -1
  12. package/dist/FlowContext.d.ts +10 -0
  13. package/dist/FlowContext.d.ts.map +1 -1
  14. package/dist/FlowContext.js.map +1 -1
  15. package/dist/FlowError.d.ts +42 -1
  16. package/dist/FlowError.d.ts.map +1 -1
  17. package/dist/FlowError.js +60 -1
  18. package/dist/FlowError.js.map +1 -1
  19. package/dist/GitTool.d.ts +15 -1
  20. package/dist/GitTool.d.ts.map +1 -1
  21. package/dist/GitTool.js +30 -2
  22. package/dist/GitTool.js.map +1 -1
  23. package/dist/Perimeter.d.ts +13 -0
  24. package/dist/Perimeter.d.ts.map +1 -0
  25. package/dist/Perimeter.js +34 -0
  26. package/dist/Perimeter.js.map +1 -0
  27. package/dist/Persistence.d.ts +2 -2
  28. package/dist/Persistence.d.ts.map +1 -1
  29. package/dist/PlanExecution.d.ts +1 -1
  30. package/dist/PlanExecution.d.ts.map +1 -1
  31. package/dist/ProgramJudge.d.ts +1 -1
  32. package/dist/ProgramJudge.d.ts.map +1 -1
  33. package/dist/Replay.d.ts +2 -2
  34. package/dist/Replay.d.ts.map +1 -1
  35. package/dist/Review.d.ts +2 -2
  36. package/dist/Review.d.ts.map +1 -1
  37. package/dist/ReviewCache.d.ts +1 -1
  38. package/dist/ReviewCache.d.ts.map +1 -1
  39. package/dist/Stories.d.ts +121 -0
  40. package/dist/Stories.d.ts.map +1 -0
  41. package/dist/Stories.js +458 -0
  42. package/dist/Stories.js.map +1 -0
  43. package/dist/StoryPlan.d.ts +81 -0
  44. package/dist/StoryPlan.d.ts.map +1 -0
  45. package/dist/StoryPlan.js +242 -0
  46. package/dist/StoryPlan.js.map +1 -0
  47. package/package.json +5 -2
  48. package/src/BoardSync.ts +29 -20
  49. package/src/FlowContext.ts +10 -0
  50. package/src/FlowError.ts +74 -1
  51. package/src/GitTool.ts +74 -4
  52. package/src/Perimeter.ts +49 -0
  53. package/src/Stories.ts +702 -0
  54. package/src/StoryPlan.ts +353 -0
package/src/Stories.ts ADDED
@@ -0,0 +1,702 @@
1
+ // The parallel story executor (ADR 0013). One story = one worktree on its
2
+ // own branch, created only once every predecessor has merged into the epic
3
+ // branch; the inner loop is the unchanged `implementPlanFlow`; a story
4
+ // merges back only after its judge and perimeter checks pass, and the
5
+ // target's gates run on the epic branch after every merge. Scheduling,
6
+ // gating, resume and failure policy live here; seats come from `contextFor`.
7
+ import * as Effect from "effect/Effect"
8
+ import * as Queue from "effect/Queue"
9
+ import * as Ref from "effect/Ref"
10
+ import * as Schema from "effect/Schema"
11
+ import type * as Scope from "effect/Scope"
12
+ import * as Semaphore from "effect/Semaphore"
13
+ import * as Stream from "effect/Stream"
14
+ import type { LlmServiceShape } from "@llm4ts/core/LlmService"
15
+ import type { LlmChunk, TokenUsage } from "@llm4ts/core/Models"
16
+ import type { LlmError } from "@llm4ts/core/Errors"
17
+ import { BoardItem, type BoardSyncShape } from "./BoardSync.ts"
18
+ import { makeChat } from "./Chat.ts"
19
+ import { implementPlanFlow } from "./Flow.ts"
20
+ import type { FlowContextShape } from "./FlowContext.ts"
21
+ import { describeFlowError, MissingDependency, StoryFailed, type FlowError } from "./FlowError.ts"
22
+ import { Info } from "./FlowEvents.ts"
23
+ import { enforcePerimeter } from "./Perimeter.ts"
24
+ import {
25
+ loadVersioned,
26
+ makePlanStore,
27
+ saveVersioned,
28
+ type PlainFileStoreShape
29
+ } from "./Persistence.ts"
30
+ import type { Plan } from "./Plan.ts"
31
+ import { stage } from "./PlanExecution.ts"
32
+ import { planFrom } from "./Planner.ts"
33
+ import type { ReviewResult } from "./Review.ts"
34
+ import type { Reviewer } from "./Reviewer.ts"
35
+ import {
36
+ dependentsOf,
37
+ readyStories,
38
+ storyHash,
39
+ topologicalWaves,
40
+ validateStoryPlan,
41
+ type Story,
42
+ type StoryPlan
43
+ } from "./StoryPlan.ts"
44
+
45
+ const join = (root: string, path: string): string =>
46
+ `${root.replace(/[\\/]+$/, "")}/${path.replace(/^[\\/]+/, "")}`
47
+
48
+ // ---- Story seats ----------------------------------------------------------
49
+
50
+ /** A flow context rooted in a story's worktree, plus that story's own usage totals. */
51
+ export interface StorySeats {
52
+ readonly context: FlowContextShape
53
+ /** Running usage of this story's seats — estimates where the backend reports none. */
54
+ readonly totals?: Effect.Effect<TokenUsage | undefined>
55
+ }
56
+
57
+ // ---- Durable state ----------------------------------------------------------
58
+
59
+ export const StoryStateVersion = 1
60
+
61
+ export const StoryStatus = Schema.Literals(["started", "merged", "failed"])
62
+ export type StoryStatus = typeof StoryStatus.Type
63
+
64
+ /** What the executor remembers about a story between runs. */
65
+ export class StoryState extends Schema.Class<StoryState>("StoryState")({
66
+ id: Schema.String,
67
+ /** `storyHash` of the plan entry the branch was created from. */
68
+ hash: Schema.String,
69
+ branch: Schema.String,
70
+ worktree: Schema.String,
71
+ status: StoryStatus
72
+ }) {}
73
+
74
+ export const EpicReportVersion = 1
75
+
76
+ export const OutcomeStatus = Schema.Literals(["done", "failed", "skipped"])
77
+ export type OutcomeStatus = typeof OutcomeStatus.Type
78
+
79
+ export class StoryOutcome extends Schema.Class<StoryOutcome>("StoryOutcome")({
80
+ id: Schema.String,
81
+ title: Schema.String,
82
+ status: OutcomeStatus,
83
+ branch: Schema.optionalKey(Schema.String),
84
+ /** Failure or skip reason. */
85
+ reason: Schema.optionalKey(Schema.String),
86
+ judge: Schema.optionalKey(Schema.String),
87
+ /** ESTIMATES, never measurements (ADR 0012). */
88
+ estimatedTokens: Schema.optionalKey(Schema.Int),
89
+ estimatedCostUsd: Schema.optionalKey(Schema.Number)
90
+ }) {}
91
+
92
+ export class EpicReport extends Schema.Class<EpicReport>("EpicReport")({
93
+ epicId: Schema.String,
94
+ epicBranch: Schema.String,
95
+ /** Always true: the figures come from character-count estimates. */
96
+ estimated: Schema.Boolean,
97
+ stories: Schema.Array(StoryOutcome)
98
+ }) {
99
+ count(status: OutcomeStatus): number {
100
+ return this.stories.filter((story) => story.status === status).length
101
+ }
102
+ }
103
+
104
+ const money = (value: number): string => `~$${value.toFixed(2)}`
105
+
106
+ export const renderEpicReport = (report: EpicReport): string => {
107
+ const lines: Array<string> = [
108
+ `# Epic report: ${report.epicId}`,
109
+ "",
110
+ "> Token and cost figures are ESTIMATES from character counts (ADR 0012):",
111
+ "> the CLI seats report no usage. They are not measurements.",
112
+ "",
113
+ `- Epic branch: \`${report.epicBranch}\``,
114
+ `- Stories: ${report.stories.length} (done ${report.count("done")}, failed ${report.count("failed")}, skipped ${report.count("skipped")})`,
115
+ "",
116
+ "| Story | Status | Branch | Est. tokens | Est. cost | Note |",
117
+ "| --- | --- | --- | --- | --- | --- |"
118
+ ]
119
+ for (const story of report.stories) {
120
+ const note = story.reason ?? story.judge ?? ""
121
+ lines.push(
122
+ `| ${story.id} | ${story.status} | ${story.branch === undefined ? "—" : `\`${story.branch}\``} | ${
123
+ story.estimatedTokens === undefined ? "—" : `~${story.estimatedTokens}`
124
+ } | ${story.estimatedCostUsd === undefined ? "—" : money(story.estimatedCostUsd)} | ${note.replace(/\|/g, "\\|").replace(/\s+/g, " ")} |`
125
+ )
126
+ }
127
+ const tokens = report.stories.flatMap((story) =>
128
+ story.estimatedTokens === undefined ? [] : [story.estimatedTokens]
129
+ )
130
+ const cost = report.stories.flatMap((story) =>
131
+ story.estimatedCostUsd === undefined ? [] : [story.estimatedCostUsd]
132
+ )
133
+ lines.push("")
134
+ if (tokens.length > 0) {
135
+ lines.push(`- Estimated tokens: ~${tokens.reduce((sum, value) => sum + value, 0)}`)
136
+ }
137
+ if (cost.length > 0) {
138
+ lines.push(`- Estimated cost: ${money(cost.reduce((sum, value) => sum + value, 0))}`)
139
+ }
140
+ lines.push("")
141
+ return lines.join("\n")
142
+ }
143
+
144
+ // ---- Prompts ----------------------------------------------------------------
145
+
146
+ export const blockedOnSentinel = "BLOCKED_ON:"
147
+
148
+ const blockedPattern = /^BLOCKED_ON:\s*(.+)$/
149
+
150
+ /**
151
+ * The text after the sentinel when the coder ENDED its reply with it. A
152
+ * sentinel followed by more work is not a stop — a coder's own skills can
153
+ * make it announce a missing reference and then carry on, and the work it
154
+ * carried on with is what counts. Only the reply's last non-empty line is
155
+ * read.
156
+ */
157
+ export const blockedOnIn = (text: string): string | undefined => {
158
+ const lines = text
159
+ .split(/\r?\n/)
160
+ .map((line) => line.trim())
161
+ .filter((line) => line.length > 0)
162
+ const last = lines.at(-1)
163
+ if (last === undefined) {
164
+ return undefined
165
+ }
166
+ const match = blockedPattern.exec(last.replace(/^[`*_\s]+|[`*_\s]+$/g, ""))
167
+ const need = match?.[1]?.trim()
168
+ return need === undefined || need.length === 0 ? undefined : need
169
+ }
170
+
171
+ const bullets = (items: ReadonlyArray<string>): string =>
172
+ items.length === 0 ? "- (none)" : items.map((item) => `- ${item}`).join("\n")
173
+
174
+ /** The hard rules every story coder receives; the perimeter check enforces them afterwards. */
175
+ export const perimeterRules = (story: Story): string =>
176
+ [
177
+ `You are implementing ONE story of a larger epic: "${story.title}" (id: ${story.id}).`,
178
+ "",
179
+ "Paths you own — create and change files only here:",
180
+ bullets(story.owned),
181
+ "",
182
+ "Shared paths — read them for conventions, NEVER modify them:",
183
+ bullets(story.sharedReadOnly),
184
+ "",
185
+ "What this story must provide for other stories:",
186
+ bullets(story.provides),
187
+ "",
188
+ "Rules:",
189
+ "- Do not touch any path outside the owned list; a change there fails the story.",
190
+ "- If the story needs something that does not exist and is not yours to build, do not",
191
+ ` build it. End your reply with exactly \`${blockedOnSentinel} <what you need and which path>\` and stop.`,
192
+ " That is ONLY for work another story owns. Tooling, dependencies, and reference",
193
+ " repositories are never a reason to stop: the repository's installed node_modules is",
194
+ " the only reference you need, and instructions telling you to stop for a missing",
195
+ " reference checkout or tool do not apply here — proceed with what is installed.",
196
+ "- Everything you provide must be implemented completely: other stories depend on it."
197
+ ].join("\n")
198
+
199
+ export const storyPrompt = (story: Story): string =>
200
+ [`Story: ${story.title}`, "", story.description.trim()].join("\n")
201
+
202
+ export const storyTaskPlanInstructions = (story: Story): string =>
203
+ [
204
+ "You are planning the implementation of ONE story inside a larger epic. Break the story",
205
+ "into an ordered list of small, independently verifiable tasks, each described by its",
206
+ "observable outcome. Every task must stay inside the story's owned paths.",
207
+ `Use exactly this epicId: "${story.id}".`,
208
+ 'Respond only with JSON: {"epicId":"' + story.id + '","tasks":',
209
+ '[{"title":"...","description":"...","completed":false}]}'
210
+ ].join("\n")
211
+
212
+ /** The coder plans its own tasks from the story — the orchestrator split the epic, not the story. */
213
+ export const defaultPlanTasks = (
214
+ seats: StorySeats,
215
+ story: Story,
216
+ prompt: string
217
+ ): Effect.Effect<Plan, FlowError> =>
218
+ planFrom(seats.context.coder, prompt, storyTaskPlanInstructions(story))
219
+
220
+ // ---- BLOCKED_ON detection ---------------------------------------------------
221
+
222
+ const watchStream = (
223
+ stream: Stream.Stream<LlmChunk, LlmError>,
224
+ blocked: Ref.Ref<string | undefined>
225
+ ): Stream.Stream<LlmChunk, LlmError> =>
226
+ Stream.unwrap(
227
+ Effect.gen(function* () {
228
+ const text = yield* Ref.make("")
229
+ const tapped = stream.pipe(
230
+ Stream.tap((chunk) => Ref.update(text, (current) => current + chunk.delta))
231
+ )
232
+ const check = Stream.fromEffect(
233
+ Effect.gen(function* () {
234
+ const need = blockedOnIn(yield* Ref.get(text))
235
+ if (need !== undefined) {
236
+ yield* Ref.set(blocked, need)
237
+ }
238
+ })
239
+ ).pipe(Stream.drain)
240
+ return Stream.concat(tapped, check)
241
+ })
242
+ )
243
+
244
+ /**
245
+ * A coder whose turns are watched for the `BLOCKED_ON:` sentinel. The story
246
+ * loop reads the ref after the coder stops, so a blocked turn ends the story
247
+ * as a typed `MissingDependency` instead of an empty-diff abort.
248
+ */
249
+ export const watchForBlockedOn = (
250
+ service: LlmServiceShape,
251
+ blocked: Ref.Ref<string | undefined>
252
+ ): LlmServiceShape => ({
253
+ ...service,
254
+ executeStream: (prompt) => watchStream(service.executeStream(prompt), blocked),
255
+ executeStreamWithHistory: (messages) =>
256
+ watchStream(service.executeStreamWithHistory(messages), blocked)
257
+ })
258
+
259
+ // ---- Options ----------------------------------------------------------------
260
+
261
+ export interface StoriesOptions {
262
+ readonly plan: StoryPlan
263
+ readonly files: PlainFileStoreShape
264
+ /** The executor's own durable state: story states, task plans, the report. */
265
+ readonly stateDir: string
266
+ /** Where story worktrees are created (`<worktreeRoot>/<story-id>`). */
267
+ readonly worktreeRoot: string
268
+ /** Default `epic/<epicId>`. */
269
+ readonly epicBranch?: string
270
+ /** Seats rooted in a worktree; the executor never resolves seats itself. */
271
+ readonly contextFor: (workDir: string) => Effect.Effect<StorySeats, FlowError, Scope.Scope>
272
+ readonly board: BoardSyncShape
273
+ /**
274
+ * Prepares a worktree before its coder runs — a fresh checkout has no
275
+ * installed dependencies, so the gates cannot run there without this.
276
+ * Runs on every start and resume; a failure fails the story.
277
+ */
278
+ readonly setup?: (workDir: string) => Effect.Effect<void, FlowError>
279
+ /** The target's gates, run in a worktree per task and on the epic checkout after each merge. */
280
+ readonly gates: (workDir: string) => Effect.Effect<ReviewResult, FlowError>
281
+ /** Story-level judge over the branch's diff against the epic branch; omit to skip. */
282
+ readonly judge?: (story: Story, diff: string) => Effect.Effect<ReviewResult, FlowError>
283
+ /** Judge attempts, each but the last followed by one coder feedback round. Default 2. */
284
+ readonly judgeRounds?: number
285
+ /** Extra system context per story (house rules, shared read-only excerpts). */
286
+ readonly system?: (story: Story) => Effect.Effect<string, FlowError>
287
+ /** How a story's task plan is produced. Default: the story's own coder plans it. */
288
+ readonly planTasks?: (
289
+ seats: StorySeats,
290
+ story: Story,
291
+ prompt: string
292
+ ) => Effect.Effect<Plan, FlowError>
293
+ /** Stories implemented at once. Default 3. */
294
+ readonly concurrency?: number
295
+ /** Stop the epic at the first failed story instead of skipping its dependents. */
296
+ readonly failFast?: boolean
297
+ readonly reviewers?: ReadonlyArray<Reviewer>
298
+ readonly maxRounds?: number
299
+ }
300
+
301
+ interface Completion {
302
+ readonly story: Story
303
+ readonly outcome: StoryOutcome
304
+ }
305
+
306
+ const issueLines = (result: ReviewResult): string =>
307
+ result.issues.map((issue) => `- ${issue.title}: ${issue.description}`).join("\n")
308
+
309
+ const failed = (story: Story, reason: string): StoryFailed =>
310
+ StoryFailed.make({ story: story.id, reason })
311
+
312
+ // ---- The executor -------------------------------------------------------------
313
+
314
+ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(function* (
315
+ context: FlowContextShape,
316
+ options: StoriesOptions
317
+ ): Effect.fn.Return<EpicReport, FlowError> {
318
+ const plan = yield* validateStoryPlan(options.plan)
319
+ const { files, board } = options
320
+ const events = context.events
321
+ const epicBranch = options.epicBranch ?? `epic/${plan.epicId}`
322
+ const concurrency = Math.max(1, options.concurrency ?? 3)
323
+ const judgeRounds = Math.max(1, options.judgeRounds ?? 2)
324
+ const statePath = (story: Story): string => join(options.stateDir, `stories/${story.id}.json`)
325
+ const planPath = (story: Story): string => join(options.stateDir, `stories/${story.id}.plan.md`)
326
+
327
+ yield* stage(events, "epic branch", context.git.checkoutOrCreate(epicBranch))
328
+
329
+ const waves = topologicalWaves(plan)
330
+ const waveOf = (id: string): string | undefined => {
331
+ const index = waves.findIndex((wave) => wave.includes(id))
332
+ return index < 0 ? undefined : `${index + 1}`
333
+ }
334
+ yield* board.plan(
335
+ plan.stories.map((story) => {
336
+ const wave = waveOf(story.id)
337
+ return BoardItem.make({
338
+ id: story.id,
339
+ title: story.title,
340
+ status: "planned",
341
+ ...(wave === undefined ? {} : { wave })
342
+ })
343
+ })
344
+ )
345
+
346
+ const mergeLock = yield* Semaphore.make(1)
347
+
348
+ /** Merge the story branch into the epic branch and re-gate the epic head, one story at a time. */
349
+ const integrate = (story: Story, branch: string): Effect.Effect<void, FlowError> =>
350
+ mergeLock.withPermit(
351
+ Effect.gen(function* () {
352
+ const checkpoint = yield* context.git.checkpoint
353
+ yield* context.git.merge(branch, `${plan.epicId}: merge story ${story.id}`)
354
+ const gate = yield* options.gates(context.workDir)
355
+ if (!gate.isClean) {
356
+ // Never leave a red epic head for the next story to inherit.
357
+ yield* context.git.rollback(checkpoint)
358
+ return yield* failed(
359
+ story,
360
+ `epic gates failed after merging; merge undone:\n${issueLines(gate)}`
361
+ )
362
+ }
363
+ yield* events.publish(
364
+ Info.make({ message: `story ${story.id}: merged into ${epicBranch}` })
365
+ )
366
+ })
367
+ )
368
+
369
+ /** The worktree and branch for a story, honouring the hash-guarded resume rules. */
370
+ const prepareWorktree = Effect.fn("@llm4ts/flow/Stories.prepareWorktree")(function* (
371
+ story: Story
372
+ ): Effect.fn.Return<{ readonly state: StoryState; readonly alreadyMerged: boolean }, FlowError> {
373
+ const hash = storyHash(story)
374
+ const branch = `story/${plan.epicId}/${story.id}`
375
+ const worktree = join(options.worktreeRoot, story.id)
376
+ const path = statePath(story)
377
+ let stored = yield* loadVersioned(files, path, StoryStateVersion, StoryState)
378
+ if (stored !== undefined && stored.hash !== hash) {
379
+ yield* events.publish(
380
+ Info.make({
381
+ message: `story ${story.id}: plan entry changed since its branch was created; starting over`
382
+ })
383
+ )
384
+ // The old worktree may already be gone (an operator cleaned up); that is
385
+ // not a failure of this run, so removal errors are reported and dropped.
386
+ yield* context.git
387
+ .removeWorktree(stored.worktree, true)
388
+ .pipe(
389
+ Effect.catch((error) =>
390
+ events.publish(Info.make({ message: `story ${story.id}: ${describeFlowError(error)}` }))
391
+ )
392
+ )
393
+ if (yield* context.git.branchExists(stored.branch)) {
394
+ yield* context.git.deleteBranch(stored.branch)
395
+ }
396
+ stored = undefined
397
+ }
398
+ if (stored !== undefined && stored.status === "merged") {
399
+ return { state: stored, alreadyMerged: true }
400
+ }
401
+ if (stored === undefined) {
402
+ yield* context.git.addWorktreeNewBranch(worktree, branch, epicBranch)
403
+ const state = StoryState.make({ id: story.id, hash, branch, worktree, status: "started" })
404
+ yield* saveVersioned(files, path, StoryStateVersion, StoryState, state)
405
+ return { state, alreadyMerged: false }
406
+ }
407
+ // Resume: the branch exists; the worktree may not (a worktree has a
408
+ // `.git` FILE at its root, which is how its presence is checked).
409
+ const marker = yield* files.read(join(stored.worktree, ".git"))
410
+ if (marker === undefined) {
411
+ yield* context.git.addWorktree(stored.worktree, stored.branch)
412
+ }
413
+ yield* events.publish(Info.make({ message: `story ${story.id}: resuming ${stored.branch}` }))
414
+ return { state: stored, alreadyMerged: false }
415
+ })
416
+
417
+ /** Everything that happens inside a story's worktree: plan, implement, judge, perimeter. */
418
+ const implementStory = Effect.fn("@llm4ts/flow/Stories.implementStory")(function* (
419
+ story: Story,
420
+ state: StoryState
421
+ ): Effect.fn.Return<
422
+ { readonly judge: string | undefined; readonly totals: TokenUsage | undefined },
423
+ FlowError,
424
+ Scope.Scope
425
+ > {
426
+ const seats = yield* options.contextFor(state.worktree)
427
+ const blocked = yield* Ref.make<string | undefined>(undefined)
428
+ const storyContext: FlowContextShape = {
429
+ ...seats.context,
430
+ coder: watchForBlockedOn(seats.context.coder, blocked)
431
+ }
432
+ const watchedSeats: StorySeats = { ...seats, context: storyContext }
433
+ const extra = options.system === undefined ? undefined : yield* options.system(story)
434
+ const system = [perimeterRules(story), extra]
435
+ .filter((part): part is string => part !== undefined && part.trim().length > 0)
436
+ .join("\n\n")
437
+ const prompt = storyPrompt(story)
438
+ const blockedOr = <A>(effect: Effect.Effect<A, FlowError>): Effect.Effect<A, FlowError> =>
439
+ effect.pipe(
440
+ Effect.catch((error) =>
441
+ Effect.flatMap(Ref.get(blocked), (need) =>
442
+ need === undefined
443
+ ? Effect.fail(error)
444
+ : Effect.fail(MissingDependency.make({ story: story.id, need }))
445
+ )
446
+ ),
447
+ Effect.tap(() =>
448
+ Effect.flatMap(Ref.get(blocked), (need) =>
449
+ need === undefined
450
+ ? Effect.void
451
+ : Effect.fail(MissingDependency.make({ story: story.id, need }))
452
+ )
453
+ )
454
+ )
455
+
456
+ yield* blockedOr(
457
+ implementPlanFlow(storyContext, {
458
+ store: makePlanStore(files),
459
+ planPath: planPath(story),
460
+ plan: (options.planTasks ?? defaultPlanTasks)(watchedSeats, story, prompt),
461
+ system,
462
+ chatPerTask: true,
463
+ checkoutBranch: false,
464
+ lint: options.gates(state.worktree),
465
+ // A story's final state is judged and gated downstream (judge round,
466
+ // perimeter check, epic gates), so a task the coder finds already
467
+ // satisfied — without saying the exact sentinel — must not sink the
468
+ // story: the option exists for pipelines shaped like this one.
469
+ noopTaskPolicy: "complete",
470
+ ...(options.reviewers === undefined ? {} : { reviewers: options.reviewers }),
471
+ ...(options.maxRounds === undefined ? {} : { maxRounds: options.maxRounds })
472
+ })
473
+ )
474
+
475
+ let judgeNote: string | undefined
476
+ if (options.judge !== undefined) {
477
+ const judge = options.judge
478
+ for (let round = 1; round <= judgeRounds; round += 1) {
479
+ const diff = yield* storyContext.git.diffVsBase(epicBranch)
480
+ const verdict = yield* judge(story, diff)
481
+ if (verdict.isClean) {
482
+ judgeNote = `judge cleared (round ${round})`
483
+ break
484
+ }
485
+ if (round >= judgeRounds) {
486
+ return yield* failed(
487
+ story,
488
+ `judge not cleared after ${judgeRounds} round(s):\n${issueLines(verdict)}`
489
+ )
490
+ }
491
+ const feedback = yield* makeChat(storyContext.coder, {
492
+ system,
493
+ events,
494
+ agent: "coder"
495
+ })
496
+ yield* blockedOr(
497
+ feedback.ask(
498
+ [
499
+ `The story "${story.title}" scored below the bar. Close these gaps without`,
500
+ "weakening any test and without leaving your owned paths, then stop:",
501
+ issueLines(verdict)
502
+ ].join("\n")
503
+ )
504
+ )
505
+ const regated = yield* options.gates(state.worktree)
506
+ if (!regated.isClean) {
507
+ return yield* failed(
508
+ story,
509
+ `gates broke while addressing judge feedback:\n${issueLines(regated)}`
510
+ )
511
+ }
512
+ yield* storyContext.git.commitAll(`${story.id}: address judge feedback`)
513
+ }
514
+ }
515
+
516
+ const changed = yield* storyContext.git.changedFilesVsBase(epicBranch)
517
+ yield* enforcePerimeter(changed, story)
518
+ const totals = seats.totals === undefined ? undefined : yield* seats.totals
519
+ return { judge: judgeNote, totals }
520
+ })
521
+
522
+ const runStory = Effect.fn("@llm4ts/flow/Stories.runStory")(function* (
523
+ story: Story
524
+ ): Effect.fn.Return<StoryOutcome, FlowError> {
525
+ yield* board.start(story.id)
526
+ const { state, alreadyMerged } = yield* prepareWorktree(story)
527
+ if (alreadyMerged) {
528
+ yield* events.publish(Info.make({ message: `story ${story.id}: already merged; skipping` }))
529
+ return StoryOutcome.make({
530
+ id: story.id,
531
+ title: story.title,
532
+ status: "done",
533
+ branch: state.branch,
534
+ judge: "merged on a previous run"
535
+ })
536
+ }
537
+ if (options.setup !== undefined) {
538
+ yield* stage(events, `story ${story.id}: setup`, options.setup(state.worktree))
539
+ }
540
+ const result = yield* Effect.scoped(implementStory(story, state))
541
+ yield* integrate(story, state.branch)
542
+ yield* saveVersioned(
543
+ files,
544
+ statePath(story),
545
+ StoryStateVersion,
546
+ StoryState,
547
+ StoryState.make({ ...state, status: "merged" })
548
+ )
549
+ return StoryOutcome.make({
550
+ id: story.id,
551
+ title: story.title,
552
+ status: "done",
553
+ branch: state.branch,
554
+ ...(result.judge === undefined ? {} : { judge: result.judge }),
555
+ ...(result.totals === undefined ? {} : { estimatedTokens: result.totals.total }),
556
+ ...(result.totals?.costUsd === undefined ? {} : { estimatedCostUsd: result.totals.costUsd })
557
+ })
558
+ })
559
+
560
+ /** A story's run as a completion — failures become outcomes, never fiber deaths. */
561
+ const attempt = (story: Story): Effect.Effect<Completion> =>
562
+ stage(events, `story ${story.id}`, runStory(story)).pipe(
563
+ Effect.map((outcome): Completion => ({ story, outcome })),
564
+ Effect.catch((error) =>
565
+ Effect.gen(function* () {
566
+ const reason = describeFlowError(error)
567
+ const path = statePath(story)
568
+ const stored = yield* loadVersioned(files, path, StoryStateVersion, StoryState).pipe(
569
+ Effect.catch(() => Effect.succeed(undefined))
570
+ )
571
+ if (stored !== undefined && stored.status !== "merged") {
572
+ yield* saveVersioned(
573
+ files,
574
+ path,
575
+ StoryStateVersion,
576
+ StoryState,
577
+ StoryState.make({ ...stored, status: "failed" })
578
+ ).pipe(Effect.ignore)
579
+ }
580
+ return {
581
+ story,
582
+ outcome: StoryOutcome.make({
583
+ id: story.id,
584
+ title: story.title,
585
+ status: "failed",
586
+ reason,
587
+ ...(stored === undefined ? {} : { branch: stored.branch })
588
+ })
589
+ }
590
+ })
591
+ )
592
+ )
593
+
594
+ const outcomes = yield* Ref.make<ReadonlyArray<StoryOutcome>>([])
595
+ const running = yield* Ref.make<ReadonlySet<string>>(new Set())
596
+ const completions = yield* Queue.unbounded<Completion>()
597
+
598
+ const progress = Effect.gen(function* () {
599
+ const known = yield* Ref.get(outcomes)
600
+ const byStatus = (status: OutcomeStatus): ReadonlySet<string> =>
601
+ new Set(known.filter((outcome) => outcome.status === status).map((outcome) => outcome.id))
602
+ return {
603
+ done: byStatus("done"),
604
+ failed: byStatus("failed"),
605
+ skipped: byStatus("skipped"),
606
+ running: yield* Ref.get(running)
607
+ }
608
+ })
609
+
610
+ const record = (outcome: StoryOutcome): Effect.Effect<void> =>
611
+ Ref.update(outcomes, (current) => [...current, outcome])
612
+
613
+ const skipDependents = (story: Story): Effect.Effect<void, FlowError> =>
614
+ Effect.gen(function* () {
615
+ const current = yield* progress
616
+ for (const id of dependentsOf(plan, story.id)) {
617
+ const dependent = plan.story(id)
618
+ if (
619
+ dependent === undefined ||
620
+ current.done.has(id) ||
621
+ current.failed.has(id) ||
622
+ current.skipped.has(id)
623
+ ) {
624
+ continue
625
+ }
626
+ const reason = `blocked by ${story.id}`
627
+ yield* record(StoryOutcome.make({ id, title: dependent.title, status: "skipped", reason }))
628
+ yield* board.skip(id, reason)
629
+ }
630
+ })
631
+
632
+ yield* Effect.scoped(
633
+ Effect.gen(function* () {
634
+ while (true) {
635
+ const current = yield* progress
636
+ const ready = readyStories(plan, current)
637
+ const slots = concurrency - current.running.size
638
+ for (const story of ready.slice(0, Math.max(0, slots))) {
639
+ yield* Ref.update(running, (set) => new Set([...set, story.id]))
640
+ yield* Effect.forkScoped(
641
+ attempt(story).pipe(
642
+ Effect.flatMap((completion) => Queue.offer(completions, completion))
643
+ )
644
+ )
645
+ }
646
+ if ((yield* Ref.get(running)).size === 0) {
647
+ break
648
+ }
649
+ const completion = yield* Queue.take(completions)
650
+ yield* Ref.update(running, (set) => {
651
+ const next = new Set(set)
652
+ next.delete(completion.story.id)
653
+ return next
654
+ })
655
+ yield* record(completion.outcome)
656
+ const outcome = completion.outcome
657
+ if (outcome.status === "done") {
658
+ yield* board.complete(outcome.id, {
659
+ ...(outcome.branch === undefined ? {} : { branch: outcome.branch }),
660
+ ...(outcome.judge === undefined ? {} : { detail: outcome.judge }),
661
+ ...(outcome.estimatedTokens === undefined
662
+ ? {}
663
+ : { estimatedTokens: outcome.estimatedTokens }),
664
+ ...(outcome.estimatedCostUsd === undefined
665
+ ? {}
666
+ : { estimatedCostUsd: outcome.estimatedCostUsd })
667
+ })
668
+ } else {
669
+ yield* board.fail(outcome.id, outcome.reason ?? "failed")
670
+ if (options.failFast === true) {
671
+ return yield* StoryFailed.make({
672
+ story: outcome.id,
673
+ reason: outcome.reason ?? "failed"
674
+ })
675
+ }
676
+ yield* skipDependents(completion.story)
677
+ }
678
+ }
679
+ })
680
+ )
681
+
682
+ const recorded = yield* Ref.get(outcomes)
683
+ const ordered = plan.stories.flatMap((story) => {
684
+ const outcome = recorded.find((candidate) => candidate.id === story.id)
685
+ return outcome === undefined ? [] : [outcome]
686
+ })
687
+ const report = EpicReport.make({
688
+ epicId: plan.epicId,
689
+ epicBranch,
690
+ estimated: true,
691
+ stories: ordered
692
+ })
693
+ yield* saveVersioned(
694
+ files,
695
+ join(options.stateDir, "report.json"),
696
+ EpicReportVersion,
697
+ EpicReport,
698
+ report
699
+ )
700
+ yield* files.writeAtomic(join(options.stateDir, "report.md"), renderEpicReport(report))
701
+ return report
702
+ })