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