@llm4ts/flow 2.7.0 → 2.8.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 (81) hide show
  1. package/dist/Approval.d.ts +1 -1
  2. package/dist/Approval.d.ts.map +1 -1
  3. package/dist/Artifacts.d.ts +2 -2
  4. package/dist/Artifacts.d.ts.map +1 -1
  5. package/dist/BenchReport.d.ts +2 -2
  6. package/dist/BenchReport.d.ts.map +1 -1
  7. package/dist/Chat.d.ts.map +1 -1
  8. package/dist/Chat.js +35 -4
  9. package/dist/Chat.js.map +1 -1
  10. package/dist/CostLedger.d.ts +2 -2
  11. package/dist/CostLedger.d.ts.map +1 -1
  12. package/dist/CostTracker.d.ts +4 -2
  13. package/dist/CostTracker.d.ts.map +1 -1
  14. package/dist/CostTracker.js +2 -5
  15. package/dist/CostTracker.js.map +1 -1
  16. package/dist/Equiv.d.ts +3 -3
  17. package/dist/Equiv.d.ts.map +1 -1
  18. package/dist/Flow.d.ts +1 -1
  19. package/dist/Flow.d.ts.map +1 -1
  20. package/dist/FlowError.d.ts +17 -1
  21. package/dist/FlowError.d.ts.map +1 -1
  22. package/dist/FlowError.js +29 -3
  23. package/dist/FlowError.js.map +1 -1
  24. package/dist/FlowEvents.d.ts +16 -0
  25. package/dist/FlowEvents.d.ts.map +1 -1
  26. package/dist/FlowEvents.js +25 -1
  27. package/dist/FlowEvents.js.map +1 -1
  28. package/dist/FlowRecorder.d.ts +4 -2
  29. package/dist/FlowRecorder.d.ts.map +1 -1
  30. package/dist/FlowRecorder.js +2 -5
  31. package/dist/FlowRecorder.js.map +1 -1
  32. package/dist/GitTool.d.ts +26 -2
  33. package/dist/GitTool.d.ts.map +1 -1
  34. package/dist/GitTool.js +54 -3
  35. package/dist/GitTool.js.map +1 -1
  36. package/dist/Judgment.d.ts +2 -2
  37. package/dist/Judgment.d.ts.map +1 -1
  38. package/dist/JudgmentLog.d.ts +3 -1
  39. package/dist/JudgmentLog.d.ts.map +1 -1
  40. package/dist/JudgmentLog.js +2 -6
  41. package/dist/JudgmentLog.js.map +1 -1
  42. package/dist/Perimeter.d.ts +22 -1
  43. package/dist/Perimeter.d.ts.map +1 -1
  44. package/dist/Perimeter.js +57 -1
  45. package/dist/Perimeter.js.map +1 -1
  46. package/dist/Persistence.d.ts +2 -2
  47. package/dist/Persistence.d.ts.map +1 -1
  48. package/dist/PlanExecution.d.ts +1 -1
  49. package/dist/PlanExecution.d.ts.map +1 -1
  50. package/dist/ProgramJudge.d.ts +1 -1
  51. package/dist/ProgramJudge.d.ts.map +1 -1
  52. package/dist/Replay.d.ts +2 -2
  53. package/dist/Replay.d.ts.map +1 -1
  54. package/dist/Review.d.ts +3 -3
  55. package/dist/Review.d.ts.map +1 -1
  56. package/dist/ReviewCache.d.ts +1 -1
  57. package/dist/ReviewCache.d.ts.map +1 -1
  58. package/dist/Stories.d.ts +47 -5
  59. package/dist/Stories.d.ts.map +1 -1
  60. package/dist/Stories.js +347 -45
  61. package/dist/Stories.js.map +1 -1
  62. package/dist/StoryPlan.d.ts +15 -0
  63. package/dist/StoryPlan.d.ts.map +1 -1
  64. package/dist/StoryPlan.js +58 -0
  65. package/dist/StoryPlan.js.map +1 -1
  66. package/dist/TransientRetry.d.ts +9 -0
  67. package/dist/TransientRetry.d.ts.map +1 -1
  68. package/dist/TransientRetry.js +43 -8
  69. package/dist/TransientRetry.js.map +1 -1
  70. package/package.json +2 -2
  71. package/src/Chat.ts +45 -6
  72. package/src/CostTracker.ts +5 -14
  73. package/src/FlowError.ts +33 -3
  74. package/src/FlowEvents.ts +42 -2
  75. package/src/FlowRecorder.ts +5 -14
  76. package/src/GitTool.ts +99 -5
  77. package/src/JudgmentLog.ts +6 -14
  78. package/src/Perimeter.ts +85 -1
  79. package/src/Stories.ts +495 -78
  80. package/src/StoryPlan.ts +73 -0
  81. package/src/TransientRetry.ts +97 -8
package/src/Stories.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  // target's gates run on the epic branch after every merge. Scheduling,
6
6
  // gating, resume and failure policy live here; seats come from `contextFor`.
7
7
  import * as Effect from "effect/Effect"
8
+ import * as Exit from "effect/Exit"
8
9
  import * as Queue from "effect/Queue"
9
10
  import * as Ref from "effect/Ref"
10
11
  import * as Schema from "effect/Schema"
@@ -18,29 +19,49 @@ import { BoardItem, type BoardSyncShape } from "./BoardSync.ts"
18
19
  import { makeChat } from "./Chat.ts"
19
20
  import { implementPlanFlow } from "./Flow.ts"
20
21
  import type { FlowContextShape } from "./FlowContext.ts"
21
- import { describeFlowError, MissingDependency, StoryFailed, type FlowError } from "./FlowError.ts"
22
+ import {
23
+ describeFlowError,
24
+ EpicCheckoutDirty,
25
+ MissingDependency,
26
+ PerimeterViolation,
27
+ StoryFailed,
28
+ type FlowError
29
+ } from "./FlowError.ts"
22
30
  import { Info } from "./FlowEvents.ts"
23
- import { enforcePerimeter } from "./Perimeter.ts"
31
+ import { statusPaths, type GitToolShape } from "./GitTool.ts"
32
+ import {
33
+ checkPerimeter,
34
+ enforcePerimeter,
35
+ isWithinPerimeter,
36
+ perimeterGate,
37
+ strayTasks,
38
+ type PerimeterCheck
39
+ } from "./Perimeter.ts"
24
40
  import {
25
41
  loadVersioned,
26
42
  makePlanStore,
27
43
  saveVersioned,
28
44
  type PlainFileStoreShape
29
45
  } from "./Persistence.ts"
30
- import type { Plan } from "./Plan.ts"
46
+ import { Plan } from "./Plan.ts"
31
47
  import { stage } from "./PlanExecution.ts"
32
48
  import { planFrom } from "./Planner.ts"
33
- import type { ReviewResult } from "./Review.ts"
49
+ import { ReviewResult } from "./Review.ts"
34
50
  import type { Reviewer } from "./Reviewer.ts"
35
51
  import {
36
52
  dependentsOf,
53
+ ownerOf,
54
+ pathsNamedIn,
37
55
  readyStories,
56
+ relationOf,
38
57
  storyHash,
39
58
  topologicalWaves,
40
59
  validateStoryPlan,
41
60
  type Story,
42
- type StoryPlan
61
+ type StoryPlan,
62
+ type StoryRelation
43
63
  } from "./StoryPlan.ts"
64
+ import { isOutageMessage } from "./TransientRetry.ts"
44
65
 
45
66
  const join = (root: string, path: string): string =>
46
67
  `${root.replace(/[\\/]+$/, "")}/${path.replace(/^[\\/]+/, "")}`
@@ -172,8 +193,43 @@ export const blockedOnIn = (text: string): string | undefined => {
172
193
  const bullets = (items: ReadonlyArray<string>): string =>
173
194
  items.length === 0 ? "- (none)" : items.map((item) => `- ${item}`).join("\n")
174
195
 
196
+ /** Where a story runs, which is what lets its rules name the other stories and checkouts. */
197
+ export interface StoryWhereabouts {
198
+ readonly plan: StoryPlan
199
+ /** The story's own worktree — the coder's working directory. */
200
+ readonly worktree: string
201
+ /** The epic branch's checkout, which no coder may touch. */
202
+ readonly epicCheckout: string
203
+ }
204
+
205
+ const relationNotes: Readonly<Record<Exclude<StoryRelation, "self">, string>> = {
206
+ dependency: "merged into the epic before you started — it is in your working tree; read it",
207
+ dependent:
208
+ "built AFTER you, on top of your work — leave it alone (wiring your work in is that story's job)",
209
+ parallel: "built at the same time by another coder — leave it alone"
210
+ }
211
+
212
+ const whereaboutsRules = (story: Story, where: StoryWhereabouts): ReadonlyArray<string> => {
213
+ const others = where.plan.stories
214
+ .filter((other) => other.id !== story.id)
215
+ .map((other) => {
216
+ const relation = relationOf(where.plan, story, other)
217
+ const note = relation === "self" ? "" : relationNotes[relation]
218
+ return `${other.owned.join(", ")} — story ${other.id}: ${note}`
219
+ })
220
+ return [
221
+ "",
222
+ `Your working directory is ${where.worktree}. Run every command there. Never cd into, read`,
223
+ `from, or write to any other checkout — above all not ${where.epicCheckout}, the epic`,
224
+ "branch's checkout: a change there breaks every other story's merge.",
225
+ "",
226
+ "Paths other stories own — never create or change them, and none of them is a reason to stop:",
227
+ bullets(others)
228
+ ]
229
+ }
230
+
175
231
  /** The hard rules every story coder receives; the perimeter check enforces them afterwards. */
176
- export const perimeterRules = (story: Story): string =>
232
+ export const perimeterRules = (story: Story, where?: StoryWhereabouts): string =>
177
233
  [
178
234
  `You are implementing ONE story of a larger epic: "${story.title}" (id: ${story.id}).`,
179
235
  "",
@@ -185,6 +241,7 @@ export const perimeterRules = (story: Story): string =>
185
241
  "",
186
242
  "What this story must provide for other stories:",
187
243
  bullets(story.provides),
244
+ ...(where === undefined ? [] : whereaboutsRules(story, where)),
188
245
  "",
189
246
  "Rules:",
190
247
  "- Do not touch any path outside the owned list; a change there fails the story.",
@@ -204,7 +261,10 @@ export const storyTaskPlanInstructions = (story: Story): string =>
204
261
  [
205
262
  "You are planning the implementation of ONE story inside a larger epic. Break the story",
206
263
  "into an ordered list of small, independently verifiable tasks, each described by its",
207
- "observable outcome. Every task must stay inside the story's owned paths.",
264
+ "observable outcome. Every task must stay inside the story's owned paths:",
265
+ bullets(story.owned),
266
+ "Never plan a task that creates or changes anything outside them — registering or wiring",
267
+ "the story in elsewhere (the app's composition point, a shared kit file) is another story's job.",
208
268
  `Use exactly this epicId: "${story.id}".`,
209
269
  'Respond only with JSON: {"epicId":"' + story.id + '","tasks":',
210
270
  '[{"title":"...","description":"...","completed":false}]}'
@@ -218,6 +278,100 @@ export const defaultPlanTasks = (
218
278
  ): Effect.Effect<Plan, FlowError> =>
219
279
  planFrom(seats.context.coder, prompt, storyTaskPlanInstructions(story))
220
280
 
281
+ /**
282
+ * Asks the planner again, once, when its tasks name paths outside the story;
283
+ * tasks that still name ANOTHER story's paths are then dropped (that work is
284
+ * planned elsewhere) and any remaining stray is left to the perimeter gate.
285
+ */
286
+ export const planWithinPerimeter = (
287
+ plan: StoryPlan,
288
+ story: Story,
289
+ planTasks: (prompt: string) => Effect.Effect<Plan, FlowError>,
290
+ prompt: string,
291
+ events: FlowContextShape["events"]
292
+ ): Effect.Effect<Plan, FlowError> =>
293
+ Effect.gen(function* () {
294
+ const first = yield* planTasks(prompt)
295
+ const strays = strayTasks(plan, story, first.tasks)
296
+ if (strays.length === 0) {
297
+ return first
298
+ }
299
+ const describe = strays.map((stray) => `- "${stray.task.title}": ${stray.paths.join(", ")}`)
300
+ yield* events.publish(
301
+ Info.make({
302
+ message: `story ${story.id}: ${strays.length} planned task(s) name paths outside the story; re-planning:\n${describe.join("\n")}`
303
+ })
304
+ )
305
+ const second = yield* planTasks(
306
+ [
307
+ prompt,
308
+ "",
309
+ "Your previous task list named paths outside this story's owned paths:",
310
+ ...describe,
311
+ "Plan again: only work inside the owned paths. Work on any other path is not this story's."
312
+ ].join("\n")
313
+ )
314
+ const remaining = strayTasks(plan, story, second.tasks)
315
+ const foreign = new Set(remaining.filter((stray) => stray.foreign).map((stray) => stray.task))
316
+ const kept = second.tasks.filter((task) => !foreign.has(task))
317
+ if (remaining.length > 0) {
318
+ yield* events.publish(
319
+ Info.make({
320
+ message:
321
+ `story ${story.id}: the re-plan still names paths outside the story` +
322
+ (foreign.size > 0
323
+ ? `; dropped ${foreign.size} task(s) that belong to other stories`
324
+ : "")
325
+ })
326
+ )
327
+ }
328
+ return kept.length === 0 ? second : Plan.make({ ...second, tasks: kept })
329
+ })
330
+
331
+ /**
332
+ * Why a BLOCKED_ON claim is wrong, when the plan alone can tell: the named
333
+ * path is the story's own, a dependency's (already merged into the worktree),
334
+ * or a dependent's (built later on top of this story). Undefined when the
335
+ * plan cannot settle it — a path no story owns, or a parallel story's.
336
+ */
337
+ export const blockedRebuttal = (
338
+ plan: StoryPlan,
339
+ story: Story,
340
+ need: string
341
+ ): string | undefined => {
342
+ for (const path of pathsNamedIn(plan, need)) {
343
+ const owner = ownerOf(plan, path)
344
+ if (owner === undefined) {
345
+ continue
346
+ }
347
+ switch (relationOf(plan, story, owner)) {
348
+ case "self":
349
+ return `${path} is under your own owned paths: building it is your job, not another story's.`
350
+ case "dependent":
351
+ return (
352
+ `${path} belongs to story '${owner.id}', which runs AFTER you and builds on your work. ` +
353
+ "Leave it alone: it is neither missing nor a blocker for this story."
354
+ )
355
+ case "dependency":
356
+ return (
357
+ `${path} belongs to story '${owner.id}', merged into the epic branch before you started, ` +
358
+ `so it is already in your working tree: read it there. That story provides: ${owner.provides.join("; ")}.`
359
+ )
360
+ case "parallel":
361
+ continue
362
+ }
363
+ }
364
+ return undefined
365
+ }
366
+
367
+ /** A second opinion on a BLOCKED_ON claim the plan cannot settle. */
368
+ export class BlockedVerdict extends Schema.Class<BlockedVerdict>("BlockedVerdict")({
369
+ /** True: the work is genuinely missing and owned by no dependency. */
370
+ real: Schema.Boolean,
371
+ /** Why — shown to the coder when the claim is rejected. */
372
+ reason: Schema.String
373
+ }) {}
374
+
221
375
  // ---- BLOCKED_ON detection ---------------------------------------------------
222
376
 
223
377
  const watchStream = (
@@ -297,13 +451,45 @@ export interface StoriesOptions {
297
451
  readonly failFast?: boolean
298
452
  readonly reviewers?: ReadonlyArray<Reviewer>
299
453
  readonly maxRounds?: number
454
+ /**
455
+ * Second opinion on a BLOCKED_ON claim the plan cannot settle (a path no
456
+ * story owns, or a parallel story's). `real: false` sends the coder back
457
+ * once with the reason. Omit to accept every such claim.
458
+ */
459
+ readonly verifyBlocked?: (
460
+ story: Story,
461
+ need: string,
462
+ workDir: string
463
+ ) => Effect.Effect<BlockedVerdict, FlowError>
464
+ /**
465
+ * Waits until a serving engine that went down (`isOutageMessage`) is back;
466
+ * the story it interrupted is then retried once. Omit, or fail it, and the
467
+ * executor stops launching stories instead of failing each in turn.
468
+ */
469
+ readonly awaitRecovery?: (reason: string) => Effect.Effect<void, FlowError>
300
470
  }
301
471
 
472
+ /**
473
+ * A failure that says nothing about the story: the serving engine went down,
474
+ * or the epic checkout was dirtied. Launching more stories would only fail
475
+ * them the same way.
476
+ */
477
+ type Interruption = "outage" | "halt"
478
+
302
479
  interface Completion {
303
480
  readonly story: Story
304
481
  readonly outcome: StoryOutcome
482
+ readonly interruption?: Interruption
305
483
  }
306
484
 
485
+ const combined = (first: ReviewResult, second: ReviewResult): ReviewResult =>
486
+ first.isClean && second.isClean
487
+ ? first
488
+ : ReviewResult.make({
489
+ issues: [...first.issues, ...second.issues],
490
+ summary: [first.summary, second.summary].filter((part) => part.length > 0).join("; ")
491
+ })
492
+
307
493
  const issueLines = (result: ReviewResult): string =>
308
494
  result.issues.map((issue) => `- ${issue.title}: ${issue.description}`).join("\n")
309
495
 
@@ -325,6 +511,24 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
325
511
  const statePath = (story: Story): string => join(options.stateDir, `stories/${story.id}.json`)
326
512
  const planPath = (story: Story): string => join(options.stateDir, `stories/${story.id}.plan.md`)
327
513
 
514
+ /**
515
+ * The epic checkout must hold nothing uncommitted: every merge and epic
516
+ * gate runs there, and git refuses a merge that would overwrite a stray
517
+ * file. A dirty checkout means a coder escaped its worktree.
518
+ */
519
+ const epicCheckoutClean = (story?: Story): Effect.Effect<void, FlowError> =>
520
+ Effect.gen(function* () {
521
+ const paths = statusPaths(yield* context.git.status)
522
+ if (paths.length > 0) {
523
+ return yield* EpicCheckoutDirty.make({
524
+ checkout: context.workDir,
525
+ paths,
526
+ ...(story === undefined ? {} : { story: story.id })
527
+ })
528
+ }
529
+ })
530
+
531
+ yield* epicCheckoutClean()
328
532
  yield* stage(events, "epic branch", context.git.checkoutOrCreate(epicBranch))
329
533
 
330
534
  const waves = topologicalWaves(plan)
@@ -350,6 +554,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
350
554
  const integrate = (story: Story, branch: string): Effect.Effect<void, FlowError> =>
351
555
  mergeLock.withPermit(
352
556
  Effect.gen(function* () {
557
+ yield* epicCheckoutClean(story)
353
558
  const checkpoint = yield* context.git.checkpoint
354
559
  yield* context.git.merge(branch, `${plan.epicId}: merge story ${story.id}`)
355
560
  const gate = yield* options.gates(context.workDir)
@@ -409,15 +614,62 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
409
614
  return { state, alreadyMerged: false }
410
615
  }
411
616
  // Resume: the branch exists; the worktree may not (a worktree has a
412
- // `.git` FILE at its root, which is how its presence is checked).
413
- const marker = yield* files.read(join(stored.worktree, ".git"))
617
+ // `.git` FILE at its root, which is how its presence is checked). A
618
+ // worktree left under an older root moves to the current one, work and
619
+ // all, so every story of the epic runs under the same root.
620
+ let state = stored
621
+ if (stored.worktree !== worktree) {
622
+ if ((yield* files.read(join(stored.worktree, ".git"))) !== undefined) {
623
+ yield* context.git.moveWorktree(stored.worktree, worktree)
624
+ yield* events.publish(
625
+ Info.make({ message: `story ${story.id}: moved its worktree to ${worktree}` })
626
+ )
627
+ }
628
+ state = StoryState.make({ ...stored, worktree })
629
+ yield* saveVersioned(files, path, StoryStateVersion, StoryState, state)
630
+ }
631
+ const marker = yield* files.read(join(state.worktree, ".git"))
414
632
  if (marker === undefined) {
415
- yield* context.git.addWorktree(stored.worktree, stored.branch)
633
+ yield* context.git.addWorktree(state.worktree, state.branch)
416
634
  }
417
- yield* events.publish(Info.make({ message: `story ${story.id}: resuming ${stored.branch}` }))
418
- return { state: stored, alreadyMerged: false }
635
+ yield* events.publish(Info.make({ message: `story ${story.id}: resuming ${state.branch}` }))
636
+ return { state, alreadyMerged: false }
419
637
  })
420
638
 
639
+ /**
640
+ * Brings a story branch up to date with the epic branch. A story that
641
+ * started before a dependency was re-merged (or that resumes after other
642
+ * stories merged) would otherwise build on, and be judged against, code
643
+ * the epic no longer has. Conflicting hunks take the epic's side: the epic
644
+ * holds only other stories' merged work, and those paths are theirs.
645
+ */
646
+ const catchUp = (story: Story, git: GitToolShape): Effect.Effect<void, FlowError> =>
647
+ Effect.gen(function* () {
648
+ if (yield* git.isAncestor(epicBranch, "HEAD")) {
649
+ return
650
+ }
651
+ if ((yield* git.uncommittedFiles).length > 0) {
652
+ yield* git.commitAll(`${story.id}: work in progress`)
653
+ }
654
+ yield* git.merge(epicBranch, `${story.id}: catch up with ${epicBranch}`, {
655
+ preferIncoming: true
656
+ })
657
+ yield* events.publish(
658
+ Info.make({ message: `story ${story.id}: caught up with ${epicBranch}` })
659
+ )
660
+ })
661
+
662
+ /** The perimeter over everything the branch changed, committed or not. */
663
+ const perimeterNow = (
664
+ story: Story,
665
+ git: GitToolShape
666
+ ): Effect.Effect<PerimeterCheck, FlowError> =>
667
+ Effect.gen(function* () {
668
+ const committed = yield* git.changedFilesVsBase(epicBranch)
669
+ const uncommitted = yield* git.uncommittedFiles
670
+ return checkPerimeter([...new Set([...committed, ...uncommitted])], story)
671
+ })
672
+
421
673
  /** Everything that happens inside a story's worktree: plan, implement, judge, perimeter. */
422
674
  const implementStory = Effect.fn("@llm4ts/flow/Stories.implementStory")(function* (
423
675
  story: Story,
@@ -429,58 +681,181 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
429
681
  > {
430
682
  const seats = yield* options.contextFor(state.worktree)
431
683
  const blocked = yield* Ref.make<string | undefined>(undefined)
684
+ const pushbacks = yield* Ref.make(0)
432
685
  const storyContext: FlowContextShape = {
433
686
  ...seats.context,
434
687
  coder: watchForBlockedOn(seats.context.coder, blocked)
435
688
  }
689
+ const git = storyContext.git
436
690
  const watchedSeats: StorySeats = { ...seats, context: storyContext }
437
691
  const extra = options.system === undefined ? undefined : yield* options.system(story)
438
- const system = [perimeterRules(story), extra]
692
+ const system = [
693
+ perimeterRules(story, { plan, worktree: state.worktree, epicCheckout: context.workDir }),
694
+ extra
695
+ ]
439
696
  .filter((part): part is string => part !== undefined && part.trim().length > 0)
440
697
  .join("\n\n")
441
698
  const prompt = storyPrompt(story)
442
- const blockedOr = <A>(effect: Effect.Effect<A, FlowError>): Effect.Effect<A, FlowError> =>
443
- effect.pipe(
444
- Effect.catch((error) =>
445
- Effect.flatMap(Ref.get(blocked), (need) =>
446
- need === undefined
447
- ? Effect.fail(error)
448
- : Effect.fail(MissingDependency.make({ story: story.id, need }))
449
- )
450
- ),
451
- Effect.tap(() =>
452
- Effect.flatMap(Ref.get(blocked), (need) =>
453
- need === undefined
454
- ? Effect.void
455
- : Effect.fail(MissingDependency.make({ story: story.id, need }))
456
- )
457
- )
699
+
700
+ // Catch up before setup: the epic may have changed the manifest too.
701
+ yield* catchUp(story, git)
702
+ if (options.setup !== undefined) {
703
+ yield* stage(events, `story ${story.id}: setup`, options.setup(state.worktree))
704
+ }
705
+
706
+ // The target's gates plus the perimeter: a stray path is a gate failure
707
+ // the task review loop hands back to the coder before anything commits.
708
+ const gates: Effect.Effect<ReviewResult, FlowError> = Effect.gen(function* () {
709
+ const target = yield* options.gates(state.worktree)
710
+ const changed = yield* perimeterNow(story, git)
711
+ return combined(target, perimeterGate([...changed.sharedReadOnly, ...changed.outside], story))
712
+ })
713
+
714
+ const coderTurn = (text: string): Effect.Effect<string, FlowError> =>
715
+ Effect.flatMap(makeChat(storyContext.coder, { system, events, agent: "coder" }), (chat) =>
716
+ chat.ask(text)
458
717
  )
459
718
 
460
- yield* blockedOr(
461
- implementPlanFlow(storyContext, {
462
- store: makePlanStore(files),
463
- planPath: planPath(story),
464
- plan: (options.planTasks ?? defaultPlanTasks)(watchedSeats, story, prompt),
465
- system,
466
- chatPerTask: true,
467
- checkoutBranch: false,
468
- lint: options.gates(state.worktree),
469
- // A story's final state is judged and gated downstream (judge round,
470
- // perimeter check, epic gates), so a task the coder finds already
471
- // satisfied — without saying the exact sentinel — must not sink the
472
- // story: the option exists for pipelines shaped like this one.
473
- noopTaskPolicy: "complete",
474
- ...(options.reviewers === undefined ? {} : { reviewers: options.reviewers }),
475
- ...(options.maxRounds === undefined ? {} : { maxRounds: options.maxRounds })
719
+ const commitGreen = (message: string, why: string): Effect.Effect<void, FlowError> =>
720
+ Effect.gen(function* () {
721
+ const regated = yield* gates
722
+ if (!regated.isClean) {
723
+ return yield* failed(story, `gates broke ${why}:\n${issueLines(regated)}`)
724
+ }
725
+ yield* git.commitAll(`${story.id}: ${message}`)
476
726
  })
477
- )
727
+
728
+ /**
729
+ * Runs `effect`; a BLOCKED_ON the plan or the verifier refutes sends the
730
+ * coder back once with the reason, then `resume` runs. Any other claim —
731
+ * or a second one — ends the story as a typed MissingDependency.
732
+ */
733
+ const guarded = <A>(
734
+ effect: Effect.Effect<A, FlowError>,
735
+ resume: Effect.Effect<void, FlowError>
736
+ ): Effect.Effect<void, FlowError> =>
737
+ Effect.gen(function* () {
738
+ const exit = yield* Effect.exit(effect)
739
+ const need = yield* Ref.get(blocked)
740
+ if (need === undefined) {
741
+ if (Exit.isFailure(exit)) {
742
+ return yield* Effect.failCause(exit.cause)
743
+ }
744
+ return
745
+ }
746
+ const missing = MissingDependency.make({ story: story.id, need })
747
+ if ((yield* Ref.get(pushbacks)) > 0) {
748
+ return yield* missing
749
+ }
750
+ let rebuttal = blockedRebuttal(plan, story, need)
751
+ if (rebuttal === undefined && options.verifyBlocked !== undefined) {
752
+ const verdict = yield* options
753
+ .verifyBlocked(story, need, state.worktree)
754
+ .pipe(Effect.catch(() => Effect.succeed(undefined)))
755
+ rebuttal = verdict === undefined || verdict.real ? undefined : verdict.reason
756
+ }
757
+ if (rebuttal === undefined) {
758
+ return yield* missing
759
+ }
760
+ yield* Ref.set(blocked, undefined)
761
+ yield* Ref.update(pushbacks, (count) => count + 1)
762
+ yield* events.publish(
763
+ Info.make({ message: `story ${story.id}: BLOCKED_ON rejected — ${rebuttal}` })
764
+ )
765
+ yield* coderTurn(
766
+ [
767
+ `You stopped with "${blockedOnSentinel} ${need}".`,
768
+ "That claim was checked against the epic's plan and rejected:",
769
+ rebuttal,
770
+ "",
771
+ "It is not a missing dependency. Continue the story inside your owned paths until it is",
772
+ `complete, then stop. Use ${blockedOnSentinel} only for work another story owns that is`,
773
+ "absent from your working tree."
774
+ ].join("\n")
775
+ )
776
+ const again = yield* Ref.get(blocked)
777
+ if (again !== undefined) {
778
+ return yield* MissingDependency.make({ story: story.id, need: again })
779
+ }
780
+ yield* commitGreen("continue after a rejected BLOCKED_ON", "after a rejected BLOCKED_ON")
781
+ yield* guarded(resume, Effect.void)
782
+ })
783
+
784
+ /**
785
+ * Stray paths go back before a judge sees the branch: first the coder is
786
+ * asked to move or revert them, then whatever is left is restored from
787
+ * the epic branch — a deterministic rule never needs a judge round.
788
+ */
789
+ const repairPerimeter: Effect.Effect<void, FlowError> = Effect.gen(function* () {
790
+ const first = yield* perimeterNow(story, git)
791
+ if (isWithinPerimeter(first)) {
792
+ return
793
+ }
794
+ const violation = PerimeterViolation.make({
795
+ story: story.id,
796
+ outside: first.outside,
797
+ sharedReadOnly: first.sharedReadOnly
798
+ })
799
+ yield* guarded(
800
+ coderTurn(
801
+ [
802
+ violation.message,
803
+ "",
804
+ "Put each of these paths back as it is on the epic branch (restore a changed file, delete",
805
+ "a file you created there). Work that belongs to this story moves into your owned paths.",
806
+ "Then stop."
807
+ ].join("\n")
808
+ ),
809
+ Effect.void
810
+ )
811
+ const second = yield* perimeterNow(story, git)
812
+ if (!isWithinPerimeter(second)) {
813
+ const stray = [...second.sharedReadOnly, ...second.outside]
814
+ yield* git.restorePaths(epicBranch, stray)
815
+ yield* events.publish(
816
+ Info.make({
817
+ message: `story ${story.id}: restored ${stray.length} path(s) outside its perimeter from ${epicBranch}: ${stray.join(", ")}`
818
+ })
819
+ )
820
+ }
821
+ yield* git.commitAll(`${story.id}: put paths outside the perimeter back`)
822
+ })
823
+
824
+ const planTasks = options.planTasks ?? defaultPlanTasks
825
+ const implementTasks = implementPlanFlow(storyContext, {
826
+ store: makePlanStore(files),
827
+ planPath: planPath(story),
828
+ plan: planWithinPerimeter(
829
+ plan,
830
+ story,
831
+ (text) => planTasks(watchedSeats, story, text),
832
+ prompt,
833
+ events
834
+ ),
835
+ system,
836
+ chatPerTask: true,
837
+ checkoutBranch: false,
838
+ lint: gates,
839
+ // A story's final state is judged and gated downstream (judge round,
840
+ // perimeter check, epic gates), so a task the coder finds already
841
+ // satisfied — without saying the exact sentinel — must not sink the
842
+ // story: the option exists for pipelines shaped like this one.
843
+ noopTaskPolicy: "complete",
844
+ ...(options.reviewers === undefined ? {} : { reviewers: options.reviewers }),
845
+ ...(options.maxRounds === undefined ? {} : { maxRounds: options.maxRounds })
846
+ })
847
+ yield* guarded(implementTasks, implementTasks)
848
+
849
+ // Other stories may have merged while this one ran: judge the branch
850
+ // against the epic as it is now.
851
+ yield* catchUp(story, git)
852
+ yield* repairPerimeter
478
853
 
479
854
  let judgeNote: string | undefined
480
855
  if (options.judge !== undefined) {
481
856
  const judge = options.judge
482
857
  for (let round = 1; round <= judgeRounds; round += 1) {
483
- const diff = yield* storyContext.git.diffVsBase(epicBranch)
858
+ const diff = yield* git.diffVsBase(epicBranch)
484
859
  if (diff.trim().length === 0) {
485
860
  // Nothing to judge is a deterministic failure, not a model call:
486
861
  // a model asked to score an empty diff scores the prompt instead.
@@ -497,32 +872,22 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
497
872
  `judge not cleared after ${judgeRounds} round(s):\n${issueLines(verdict)}`
498
873
  )
499
874
  }
500
- const feedback = yield* makeChat(storyContext.coder, {
501
- system,
502
- events,
503
- agent: "coder"
504
- })
505
- yield* blockedOr(
506
- feedback.ask(
875
+ yield* guarded(
876
+ coderTurn(
507
877
  [
508
878
  `The story "${story.title}" scored below the bar. Close these gaps without`,
509
879
  "weakening any test and without leaving your owned paths, then stop:",
510
880
  issueLines(verdict)
511
881
  ].join("\n")
512
- )
882
+ ),
883
+ Effect.void
513
884
  )
514
- const regated = yield* options.gates(state.worktree)
515
- if (!regated.isClean) {
516
- return yield* failed(
517
- story,
518
- `gates broke while addressing judge feedback:\n${issueLines(regated)}`
519
- )
520
- }
521
- yield* storyContext.git.commitAll(`${story.id}: address judge feedback`)
885
+ yield* repairPerimeter
886
+ yield* commitGreen("address judge feedback", "while addressing judge feedback")
522
887
  }
523
888
  }
524
889
 
525
- const changed = yield* storyContext.git.changedFilesVsBase(epicBranch)
890
+ const changed = yield* git.changedFilesVsBase(epicBranch)
526
891
  yield* enforcePerimeter(changed, story)
527
892
  const totals = seats.totals === undefined ? undefined : yield* seats.totals
528
893
  return { judge: judgeNote, totals }
@@ -543,9 +908,6 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
543
908
  judge: "merged on a previous run"
544
909
  })
545
910
  }
546
- if (options.setup !== undefined) {
547
- yield* stage(events, `story ${story.id}: setup`, options.setup(state.worktree))
548
- }
549
911
  const result = yield* Effect.scoped(implementStory(story, state))
550
912
  yield* integrate(story, state.branch)
551
913
  yield* saveVersioned(
@@ -586,7 +948,13 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
586
948
  StoryState.make({ ...stored, status: "failed" })
587
949
  ).pipe(Effect.ignore)
588
950
  }
589
- return {
951
+ const interruption: Interruption | undefined =
952
+ error._tag === "EpicCheckoutDirty"
953
+ ? "halt"
954
+ : isOutageMessage(reason)
955
+ ? "outage"
956
+ : undefined
957
+ const completion: Completion = {
590
958
  story,
591
959
  outcome: StoryOutcome.make({
592
960
  id: story.id,
@@ -594,8 +962,10 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
594
962
  status: "failed",
595
963
  reason,
596
964
  ...(stored === undefined ? {} : { branch: stored.branch })
597
- })
965
+ }),
966
+ ...(interruption === undefined ? {} : { interruption })
598
967
  }
968
+ return completion
599
969
  })
600
970
  )
601
971
  )
@@ -639,19 +1009,26 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
639
1009
  }
640
1010
  })
641
1011
 
1012
+ /** Why no further story may start in this run; stories already running finish. */
1013
+ let halted: string | undefined
1014
+ /** Stories retried once after the serving engine recovered. */
1015
+ const recovered = new Set<string>()
1016
+
642
1017
  yield* Effect.scoped(
643
1018
  Effect.gen(function* () {
644
1019
  while (true) {
645
- const current = yield* progress
646
- const ready = readyStories(plan, current)
647
- const slots = concurrency - current.running.size
648
- for (const story of ready.slice(0, Math.max(0, slots))) {
649
- yield* Ref.update(running, (set) => new Set([...set, story.id]))
650
- yield* Effect.forkScoped(
651
- attempt(story).pipe(
652
- Effect.flatMap((completion) => Queue.offer(completions, completion))
1020
+ if (halted === undefined) {
1021
+ const current = yield* progress
1022
+ const ready = readyStories(plan, current)
1023
+ const slots = concurrency - current.running.size
1024
+ for (const story of ready.slice(0, Math.max(0, slots))) {
1025
+ yield* Ref.update(running, (set) => new Set([...set, story.id]))
1026
+ yield* Effect.forkScoped(
1027
+ attempt(story).pipe(
1028
+ Effect.flatMap((completion) => Queue.offer(completions, completion))
1029
+ )
653
1030
  )
654
- )
1031
+ }
655
1032
  }
656
1033
  if ((yield* Ref.get(running)).size === 0) {
657
1034
  break
@@ -662,6 +1039,28 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
662
1039
  next.delete(completion.story.id)
663
1040
  return next
664
1041
  })
1042
+ const reason = completion.outcome.reason ?? "failed"
1043
+ if (completion.interruption === "outage" && halted === undefined) {
1044
+ if (options.awaitRecovery !== undefined && !recovered.has(completion.story.id)) {
1045
+ recovered.add(completion.story.id)
1046
+ yield* events.publish(
1047
+ Info.make({
1048
+ message: `story ${completion.story.id}: the serving engine is down; waiting for it to recover, then retrying the story`
1049
+ })
1050
+ )
1051
+ const back = yield* Effect.result(options.awaitRecovery(reason))
1052
+ if (back._tag === "Success") {
1053
+ // Not recorded: the story is ready again and relaunches.
1054
+ continue
1055
+ }
1056
+ halted = `the serving engine did not recover (${describeFlowError(back.failure)})`
1057
+ } else {
1058
+ halted = `the serving engine is down: ${reason}`
1059
+ }
1060
+ }
1061
+ if (completion.interruption === "halt" && halted === undefined) {
1062
+ halted = reason
1063
+ }
665
1064
  yield* record(completion.outcome)
666
1065
  const outcome = completion.outcome
667
1066
  if (outcome.status === "done") {
@@ -689,6 +1088,24 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
689
1088
  })
690
1089
  )
691
1090
 
1091
+ if (halted !== undefined) {
1092
+ const stopped = halted
1093
+ yield* events.publish(
1094
+ Info.make({ message: `epic ${plan.epicId}: stopped launching stories — ${stopped}` })
1095
+ )
1096
+ const known = new Set((yield* Ref.get(outcomes)).map((outcome) => outcome.id))
1097
+ for (const story of plan.stories) {
1098
+ if (known.has(story.id)) {
1099
+ continue
1100
+ }
1101
+ const reason = `not started: ${stopped}`
1102
+ yield* record(
1103
+ StoryOutcome.make({ id: story.id, title: story.title, status: "waiting", reason })
1104
+ )
1105
+ yield* board.wait(story.id, reason)
1106
+ }
1107
+ }
1108
+
692
1109
  const recorded = yield* Ref.get(outcomes)
693
1110
  const ordered = plan.stories.flatMap((story) => {
694
1111
  const outcome = recorded.find((candidate) => candidate.id === story.id)