@llm4ts/flow 2.7.2 → 2.9.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 (75) 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/Equiv.d.ts +3 -3
  13. package/dist/Equiv.d.ts.map +1 -1
  14. package/dist/Flow.d.ts +1 -1
  15. package/dist/Flow.d.ts.map +1 -1
  16. package/dist/FlowContext.d.ts +15 -1
  17. package/dist/FlowContext.d.ts.map +1 -1
  18. package/dist/FlowContext.js.map +1 -1
  19. package/dist/FlowError.d.ts +40 -1
  20. package/dist/FlowError.d.ts.map +1 -1
  21. package/dist/FlowError.js +58 -3
  22. package/dist/FlowError.js.map +1 -1
  23. package/dist/GitTool.d.ts +26 -2
  24. package/dist/GitTool.d.ts.map +1 -1
  25. package/dist/GitTool.js +54 -3
  26. package/dist/GitTool.js.map +1 -1
  27. package/dist/Judgment.d.ts +2 -2
  28. package/dist/Judgment.d.ts.map +1 -1
  29. package/dist/Perimeter.d.ts +22 -1
  30. package/dist/Perimeter.d.ts.map +1 -1
  31. package/dist/Perimeter.js +57 -1
  32. package/dist/Perimeter.js.map +1 -1
  33. package/dist/Persistence.d.ts +2 -2
  34. package/dist/Persistence.d.ts.map +1 -1
  35. package/dist/PlanExecution.d.ts +1 -1
  36. package/dist/PlanExecution.d.ts.map +1 -1
  37. package/dist/ProgramJudge.d.ts +1 -1
  38. package/dist/ProgramJudge.d.ts.map +1 -1
  39. package/dist/Replay.d.ts +2 -2
  40. package/dist/Replay.d.ts.map +1 -1
  41. package/dist/Review.d.ts +3 -3
  42. package/dist/Review.d.ts.map +1 -1
  43. package/dist/ReviewCache.d.ts +1 -1
  44. package/dist/ReviewCache.d.ts.map +1 -1
  45. package/dist/Roster.d.ts +163 -0
  46. package/dist/Roster.d.ts.map +1 -0
  47. package/dist/Roster.js +560 -0
  48. package/dist/Roster.js.map +1 -0
  49. package/dist/RosterSeats.d.ts +71 -0
  50. package/dist/RosterSeats.d.ts.map +1 -0
  51. package/dist/RosterSeats.js +144 -0
  52. package/dist/RosterSeats.js.map +1 -0
  53. package/dist/Stories.d.ts +54 -8
  54. package/dist/Stories.d.ts.map +1 -1
  55. package/dist/Stories.js +413 -51
  56. package/dist/Stories.js.map +1 -1
  57. package/dist/StoryPlan.d.ts +15 -0
  58. package/dist/StoryPlan.d.ts.map +1 -1
  59. package/dist/StoryPlan.js +58 -0
  60. package/dist/StoryPlan.js.map +1 -1
  61. package/dist/TransientRetry.d.ts +9 -0
  62. package/dist/TransientRetry.d.ts.map +1 -1
  63. package/dist/TransientRetry.js +43 -8
  64. package/dist/TransientRetry.js.map +1 -1
  65. package/package.json +4 -2
  66. package/src/Chat.ts +45 -6
  67. package/src/FlowContext.ts +19 -1
  68. package/src/FlowError.ts +65 -3
  69. package/src/GitTool.ts +99 -5
  70. package/src/Perimeter.ts +85 -1
  71. package/src/Roster.ts +824 -0
  72. package/src/RosterSeats.ts +306 -0
  73. package/src/Stories.ts +588 -88
  74. package/src/StoryPlan.ts +73 -0
  75. 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"
@@ -17,30 +18,51 @@ import type { LlmError } from "@llm4ts/core/Errors"
17
18
  import { BoardItem, type BoardSyncShape } from "./BoardSync.ts"
18
19
  import { makeChat } from "./Chat.ts"
19
20
  import { implementPlanFlow } from "./Flow.ts"
20
- import type { FlowContextShape } from "./FlowContext.ts"
21
- import { describeFlowError, MissingDependency, StoryFailed, type FlowError } from "./FlowError.ts"
21
+ import type { ContextOptions, FlowContextShape } from "./FlowContext.ts"
22
+ import {
23
+ describeFlowError,
24
+ rosterExhaustedPrefix,
25
+ EpicCheckoutDirty,
26
+ MissingDependency,
27
+ PerimeterViolation,
28
+ StoryFailed,
29
+ type FlowError
30
+ } from "./FlowError.ts"
22
31
  import { Info } from "./FlowEvents.ts"
23
- import { enforcePerimeter } from "./Perimeter.ts"
32
+ import { statusPaths, type GitToolShape } from "./GitTool.ts"
33
+ import {
34
+ checkPerimeter,
35
+ enforcePerimeter,
36
+ isWithinPerimeter,
37
+ perimeterGate,
38
+ strayTasks,
39
+ type PerimeterCheck
40
+ } from "./Perimeter.ts"
24
41
  import {
25
42
  loadVersioned,
26
43
  makePlanStore,
27
44
  saveVersioned,
28
45
  type PlainFileStoreShape
29
46
  } from "./Persistence.ts"
30
- import type { Plan } from "./Plan.ts"
47
+ import { Plan } from "./Plan.ts"
31
48
  import { stage } from "./PlanExecution.ts"
32
49
  import { planFrom } from "./Planner.ts"
33
- import type { ReviewResult } from "./Review.ts"
50
+ import { ReviewResult } from "./Review.ts"
34
51
  import type { Reviewer } from "./Reviewer.ts"
35
52
  import {
36
53
  dependentsOf,
54
+ ownerOf,
55
+ pathsNamedIn,
37
56
  readyStories,
57
+ relationOf,
38
58
  storyHash,
39
59
  topologicalWaves,
40
60
  validateStoryPlan,
41
61
  type Story,
42
- type StoryPlan
62
+ type StoryPlan,
63
+ type StoryRelation
43
64
  } from "./StoryPlan.ts"
65
+ import { isOutageMessage } from "./TransientRetry.ts"
44
66
 
45
67
  const join = (root: string, path: string): string =>
46
68
  `${root.replace(/[\\/]+$/, "")}/${path.replace(/^[\\/]+/, "")}`
@@ -68,7 +90,9 @@ export class StoryState extends Schema.Class<StoryState>("StoryState")({
68
90
  hash: Schema.String,
69
91
  branch: Schema.String,
70
92
  worktree: Schema.String,
71
- status: StoryStatus
93
+ status: StoryStatus,
94
+ /** The roster executor that last held the story's coder (ADR 0019); preferred on resume. */
95
+ executor: Schema.optionalKey(Schema.String)
72
96
  }) {}
73
97
 
74
98
  export const EpicReportVersion = 1
@@ -85,6 +109,8 @@ export class StoryOutcome extends Schema.Class<StoryOutcome>("StoryOutcome")({
85
109
  /** Failure reason, or what a waiting story waits for. */
86
110
  reason: Schema.optionalKey(Schema.String),
87
111
  judge: Schema.optionalKey(Schema.String),
112
+ /** The roster executor(s) that coded the story, in order ("codex → claude"). */
113
+ executor: Schema.optionalKey(Schema.String),
88
114
  /** ESTIMATES, never measurements (ADR 0012). */
89
115
  estimatedTokens: Schema.optionalKey(Schema.Int),
90
116
  estimatedCostUsd: Schema.optionalKey(Schema.Number)
@@ -138,6 +164,27 @@ export const renderEpicReport = (report: EpicReport): string => {
138
164
  if (cost.length > 0) {
139
165
  lines.push(`- Estimated cost: ${money(cost.reduce((sum, value) => sum + value, 0))}`)
140
166
  }
167
+ const coded = report.stories.filter((story) => story.executor !== undefined)
168
+ if (coded.length > 0) {
169
+ // A story is counted for the executor that finished it; a handover chain
170
+ // is shown in full next to the story.
171
+ const byExecutor = new Map<string, { stories: Array<string>; tokens: number; cost: number }>()
172
+ for (const story of coded) {
173
+ const chain = story.executor ?? ""
174
+ const last = chain.split(" → ").at(-1) ?? chain
175
+ const entry = byExecutor.get(last) ?? { stories: [], tokens: 0, cost: 0 }
176
+ entry.stories.push(chain === last ? story.id : `${story.id} (${chain})`)
177
+ entry.tokens += story.estimatedTokens ?? 0
178
+ entry.cost += story.estimatedCostUsd ?? 0
179
+ byExecutor.set(last, entry)
180
+ }
181
+ lines.push("", "## By executor", "")
182
+ for (const [executor, entry] of byExecutor) {
183
+ lines.push(
184
+ `- ${executor}: ${entry.stories.join(", ")} — ~${entry.tokens} tokens, ${money(entry.cost)} (estimated)`
185
+ )
186
+ }
187
+ }
141
188
  lines.push("")
142
189
  return lines.join("\n")
143
190
  }
@@ -172,8 +219,43 @@ export const blockedOnIn = (text: string): string | undefined => {
172
219
  const bullets = (items: ReadonlyArray<string>): string =>
173
220
  items.length === 0 ? "- (none)" : items.map((item) => `- ${item}`).join("\n")
174
221
 
222
+ /** Where a story runs, which is what lets its rules name the other stories and checkouts. */
223
+ export interface StoryWhereabouts {
224
+ readonly plan: StoryPlan
225
+ /** The story's own worktree — the coder's working directory. */
226
+ readonly worktree: string
227
+ /** The epic branch's checkout, which no coder may touch. */
228
+ readonly epicCheckout: string
229
+ }
230
+
231
+ const relationNotes: Readonly<Record<Exclude<StoryRelation, "self">, string>> = {
232
+ dependency: "merged into the epic before you started — it is in your working tree; read it",
233
+ dependent:
234
+ "built AFTER you, on top of your work — leave it alone (wiring your work in is that story's job)",
235
+ parallel: "built at the same time by another coder — leave it alone"
236
+ }
237
+
238
+ const whereaboutsRules = (story: Story, where: StoryWhereabouts): ReadonlyArray<string> => {
239
+ const others = where.plan.stories
240
+ .filter((other) => other.id !== story.id)
241
+ .map((other) => {
242
+ const relation = relationOf(where.plan, story, other)
243
+ const note = relation === "self" ? "" : relationNotes[relation]
244
+ return `${other.owned.join(", ")} — story ${other.id}: ${note}`
245
+ })
246
+ return [
247
+ "",
248
+ `Your working directory is ${where.worktree}. Run every command there. Never cd into, read`,
249
+ `from, or write to any other checkout — above all not ${where.epicCheckout}, the epic`,
250
+ "branch's checkout: a change there breaks every other story's merge.",
251
+ "",
252
+ "Paths other stories own — never create or change them, and none of them is a reason to stop:",
253
+ bullets(others)
254
+ ]
255
+ }
256
+
175
257
  /** The hard rules every story coder receives; the perimeter check enforces them afterwards. */
176
- export const perimeterRules = (story: Story): string =>
258
+ export const perimeterRules = (story: Story, where?: StoryWhereabouts): string =>
177
259
  [
178
260
  `You are implementing ONE story of a larger epic: "${story.title}" (id: ${story.id}).`,
179
261
  "",
@@ -185,6 +267,7 @@ export const perimeterRules = (story: Story): string =>
185
267
  "",
186
268
  "What this story must provide for other stories:",
187
269
  bullets(story.provides),
270
+ ...(where === undefined ? [] : whereaboutsRules(story, where)),
188
271
  "",
189
272
  "Rules:",
190
273
  "- Do not touch any path outside the owned list; a change there fails the story.",
@@ -204,7 +287,10 @@ export const storyTaskPlanInstructions = (story: Story): string =>
204
287
  [
205
288
  "You are planning the implementation of ONE story inside a larger epic. Break the story",
206
289
  "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.",
290
+ "observable outcome. Every task must stay inside the story's owned paths:",
291
+ bullets(story.owned),
292
+ "Never plan a task that creates or changes anything outside them — registering or wiring",
293
+ "the story in elsewhere (the app's composition point, a shared kit file) is another story's job.",
208
294
  `Use exactly this epicId: "${story.id}".`,
209
295
  'Respond only with JSON: {"epicId":"' + story.id + '","tasks":',
210
296
  '[{"title":"...","description":"...","completed":false}]}'
@@ -218,6 +304,100 @@ export const defaultPlanTasks = (
218
304
  ): Effect.Effect<Plan, FlowError> =>
219
305
  planFrom(seats.context.coder, prompt, storyTaskPlanInstructions(story))
220
306
 
307
+ /**
308
+ * Asks the planner again, once, when its tasks name paths outside the story;
309
+ * tasks that still name ANOTHER story's paths are then dropped (that work is
310
+ * planned elsewhere) and any remaining stray is left to the perimeter gate.
311
+ */
312
+ export const planWithinPerimeter = (
313
+ plan: StoryPlan,
314
+ story: Story,
315
+ planTasks: (prompt: string) => Effect.Effect<Plan, FlowError>,
316
+ prompt: string,
317
+ events: FlowContextShape["events"]
318
+ ): Effect.Effect<Plan, FlowError> =>
319
+ Effect.gen(function* () {
320
+ const first = yield* planTasks(prompt)
321
+ const strays = strayTasks(plan, story, first.tasks)
322
+ if (strays.length === 0) {
323
+ return first
324
+ }
325
+ const describe = strays.map((stray) => `- "${stray.task.title}": ${stray.paths.join(", ")}`)
326
+ yield* events.publish(
327
+ Info.make({
328
+ message: `story ${story.id}: ${strays.length} planned task(s) name paths outside the story; re-planning:\n${describe.join("\n")}`
329
+ })
330
+ )
331
+ const second = yield* planTasks(
332
+ [
333
+ prompt,
334
+ "",
335
+ "Your previous task list named paths outside this story's owned paths:",
336
+ ...describe,
337
+ "Plan again: only work inside the owned paths. Work on any other path is not this story's."
338
+ ].join("\n")
339
+ )
340
+ const remaining = strayTasks(plan, story, second.tasks)
341
+ const foreign = new Set(remaining.filter((stray) => stray.foreign).map((stray) => stray.task))
342
+ const kept = second.tasks.filter((task) => !foreign.has(task))
343
+ if (remaining.length > 0) {
344
+ yield* events.publish(
345
+ Info.make({
346
+ message:
347
+ `story ${story.id}: the re-plan still names paths outside the story` +
348
+ (foreign.size > 0
349
+ ? `; dropped ${foreign.size} task(s) that belong to other stories`
350
+ : "")
351
+ })
352
+ )
353
+ }
354
+ return kept.length === 0 ? second : Plan.make({ ...second, tasks: kept })
355
+ })
356
+
357
+ /**
358
+ * Why a BLOCKED_ON claim is wrong, when the plan alone can tell: the named
359
+ * path is the story's own, a dependency's (already merged into the worktree),
360
+ * or a dependent's (built later on top of this story). Undefined when the
361
+ * plan cannot settle it — a path no story owns, or a parallel story's.
362
+ */
363
+ export const blockedRebuttal = (
364
+ plan: StoryPlan,
365
+ story: Story,
366
+ need: string
367
+ ): string | undefined => {
368
+ for (const path of pathsNamedIn(plan, need)) {
369
+ const owner = ownerOf(plan, path)
370
+ if (owner === undefined) {
371
+ continue
372
+ }
373
+ switch (relationOf(plan, story, owner)) {
374
+ case "self":
375
+ return `${path} is under your own owned paths: building it is your job, not another story's.`
376
+ case "dependent":
377
+ return (
378
+ `${path} belongs to story '${owner.id}', which runs AFTER you and builds on your work. ` +
379
+ "Leave it alone: it is neither missing nor a blocker for this story."
380
+ )
381
+ case "dependency":
382
+ return (
383
+ `${path} belongs to story '${owner.id}', merged into the epic branch before you started, ` +
384
+ `so it is already in your working tree: read it there. That story provides: ${owner.provides.join("; ")}.`
385
+ )
386
+ case "parallel":
387
+ continue
388
+ }
389
+ }
390
+ return undefined
391
+ }
392
+
393
+ /** A second opinion on a BLOCKED_ON claim the plan cannot settle. */
394
+ export class BlockedVerdict extends Schema.Class<BlockedVerdict>("BlockedVerdict")({
395
+ /** True: the work is genuinely missing and owned by no dependency. */
396
+ real: Schema.Boolean,
397
+ /** Why — shown to the coder when the claim is rejected. */
398
+ reason: Schema.String
399
+ }) {}
400
+
221
401
  // ---- BLOCKED_ON detection ---------------------------------------------------
222
402
 
223
403
  const watchStream = (
@@ -269,7 +449,10 @@ export interface StoriesOptions {
269
449
  /** Default `epic/<epicId>`. */
270
450
  readonly epicBranch?: string
271
451
  /** Seats rooted in a worktree; the executor never resolves seats itself. */
272
- readonly contextFor: (workDir: string) => Effect.Effect<StorySeats, FlowError, Scope.Scope>
452
+ readonly contextFor: (
453
+ workDir: string,
454
+ options?: ContextOptions
455
+ ) => Effect.Effect<StorySeats, FlowError, Scope.Scope>
273
456
  readonly board: BoardSyncShape
274
457
  /**
275
458
  * Prepares a worktree before its coder runs — a fresh checkout has no
@@ -280,7 +463,11 @@ export interface StoriesOptions {
280
463
  /** The target's gates, run in a worktree per task and on the epic checkout after each merge. */
281
464
  readonly gates: (workDir: string) => Effect.Effect<ReviewResult, FlowError>
282
465
  /** Story-level judge over the branch's diff against the epic branch; omit to skip. */
283
- readonly judge?: (story: Story, diff: string) => Effect.Effect<ReviewResult, FlowError>
466
+ readonly judge?: (
467
+ story: Story,
468
+ diff: string,
469
+ seats: StorySeats
470
+ ) => Effect.Effect<ReviewResult, FlowError>
284
471
  /** Judge attempts, each but the last followed by one coder feedback round. Default 2. */
285
472
  readonly judgeRounds?: number
286
473
  /** Extra system context per story (house rules, shared read-only excerpts). */
@@ -297,13 +484,46 @@ export interface StoriesOptions {
297
484
  readonly failFast?: boolean
298
485
  readonly reviewers?: ReadonlyArray<Reviewer>
299
486
  readonly maxRounds?: number
487
+ /**
488
+ * Second opinion on a BLOCKED_ON claim the plan cannot settle (a path no
489
+ * story owns, or a parallel story's). `real: false` sends the coder back
490
+ * once with the reason. Omit to accept every such claim.
491
+ */
492
+ readonly verifyBlocked?: (
493
+ story: Story,
494
+ need: string,
495
+ workDir: string,
496
+ seats: StorySeats
497
+ ) => Effect.Effect<BlockedVerdict, FlowError>
498
+ /**
499
+ * Waits until a serving engine that went down (`isOutageMessage`) is back;
500
+ * the story it interrupted is then retried once. Omit, or fail it, and the
501
+ * executor stops launching stories instead of failing each in turn.
502
+ */
503
+ readonly awaitRecovery?: (reason: string) => Effect.Effect<void, FlowError>
300
504
  }
301
505
 
506
+ /**
507
+ * A failure that says nothing about the story: the serving engine went down,
508
+ * or the epic checkout was dirtied. Launching more stories would only fail
509
+ * them the same way.
510
+ */
511
+ type Interruption = "outage" | "halt"
512
+
302
513
  interface Completion {
303
514
  readonly story: Story
304
515
  readonly outcome: StoryOutcome
516
+ readonly interruption?: Interruption
305
517
  }
306
518
 
519
+ const combined = (first: ReviewResult, second: ReviewResult): ReviewResult =>
520
+ first.isClean && second.isClean
521
+ ? first
522
+ : ReviewResult.make({
523
+ issues: [...first.issues, ...second.issues],
524
+ summary: [first.summary, second.summary].filter((part) => part.length > 0).join("; ")
525
+ })
526
+
307
527
  const issueLines = (result: ReviewResult): string =>
308
528
  result.issues.map((issue) => `- ${issue.title}: ${issue.description}`).join("\n")
309
529
 
@@ -325,6 +545,24 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
325
545
  const statePath = (story: Story): string => join(options.stateDir, `stories/${story.id}.json`)
326
546
  const planPath = (story: Story): string => join(options.stateDir, `stories/${story.id}.plan.md`)
327
547
 
548
+ /**
549
+ * The epic checkout must hold nothing uncommitted: every merge and epic
550
+ * gate runs there, and git refuses a merge that would overwrite a stray
551
+ * file. A dirty checkout means a coder escaped its worktree.
552
+ */
553
+ const epicCheckoutClean = (story?: Story): Effect.Effect<void, FlowError> =>
554
+ Effect.gen(function* () {
555
+ const paths = statusPaths(yield* context.git.status)
556
+ if (paths.length > 0) {
557
+ return yield* EpicCheckoutDirty.make({
558
+ checkout: context.workDir,
559
+ paths,
560
+ ...(story === undefined ? {} : { story: story.id })
561
+ })
562
+ }
563
+ })
564
+
565
+ yield* epicCheckoutClean()
328
566
  yield* stage(events, "epic branch", context.git.checkoutOrCreate(epicBranch))
329
567
 
330
568
  const waves = topologicalWaves(plan)
@@ -350,6 +588,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
350
588
  const integrate = (story: Story, branch: string): Effect.Effect<void, FlowError> =>
351
589
  mergeLock.withPermit(
352
590
  Effect.gen(function* () {
591
+ yield* epicCheckoutClean(story)
353
592
  const checkpoint = yield* context.git.checkpoint
354
593
  yield* context.git.merge(branch, `${plan.epicId}: merge story ${story.id}`)
355
594
  const gate = yield* options.gates(context.workDir)
@@ -409,84 +648,272 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
409
648
  return { state, alreadyMerged: false }
410
649
  }
411
650
  // 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"))
651
+ // `.git` FILE at its root, which is how its presence is checked). A
652
+ // worktree left under an older root moves to the current one, work and
653
+ // all, so every story of the epic runs under the same root.
654
+ let state = stored
655
+ if (stored.worktree !== worktree) {
656
+ if ((yield* files.read(join(stored.worktree, ".git"))) !== undefined) {
657
+ yield* context.git.moveWorktree(stored.worktree, worktree)
658
+ yield* events.publish(
659
+ Info.make({ message: `story ${story.id}: moved its worktree to ${worktree}` })
660
+ )
661
+ }
662
+ state = StoryState.make({ ...stored, worktree })
663
+ yield* saveVersioned(files, path, StoryStateVersion, StoryState, state)
664
+ }
665
+ const marker = yield* files.read(join(state.worktree, ".git"))
414
666
  if (marker === undefined) {
415
- yield* context.git.addWorktree(stored.worktree, stored.branch)
667
+ yield* context.git.addWorktree(state.worktree, state.branch)
416
668
  }
417
- yield* events.publish(Info.make({ message: `story ${story.id}: resuming ${stored.branch}` }))
418
- return { state: stored, alreadyMerged: false }
669
+ yield* events.publish(Info.make({ message: `story ${story.id}: resuming ${state.branch}` }))
670
+ return { state, alreadyMerged: false }
419
671
  })
420
672
 
673
+ /**
674
+ * Brings a story branch up to date with the epic branch. A story that
675
+ * started before a dependency was re-merged (or that resumes after other
676
+ * stories merged) would otherwise build on, and be judged against, code
677
+ * the epic no longer has. Conflicting hunks take the epic's side: the epic
678
+ * holds only other stories' merged work, and those paths are theirs.
679
+ */
680
+ const catchUp = (story: Story, git: GitToolShape): Effect.Effect<void, FlowError> =>
681
+ Effect.gen(function* () {
682
+ if (yield* git.isAncestor(epicBranch, "HEAD")) {
683
+ return
684
+ }
685
+ if ((yield* git.uncommittedFiles).length > 0) {
686
+ yield* git.commitAll(`${story.id}: work in progress`)
687
+ }
688
+ yield* git.merge(epicBranch, `${story.id}: catch up with ${epicBranch}`, {
689
+ preferIncoming: true
690
+ })
691
+ yield* events.publish(
692
+ Info.make({ message: `story ${story.id}: caught up with ${epicBranch}` })
693
+ )
694
+ })
695
+
696
+ /** The perimeter over everything the branch changed, committed or not. */
697
+ const perimeterNow = (
698
+ story: Story,
699
+ git: GitToolShape
700
+ ): Effect.Effect<PerimeterCheck, FlowError> =>
701
+ Effect.gen(function* () {
702
+ const committed = yield* git.changedFilesVsBase(epicBranch)
703
+ const uncommitted = yield* git.uncommittedFiles
704
+ return checkPerimeter([...new Set([...committed, ...uncommitted])], story)
705
+ })
706
+
421
707
  /** Everything that happens inside a story's worktree: plan, implement, judge, perimeter. */
422
708
  const implementStory = Effect.fn("@llm4ts/flow/Stories.implementStory")(function* (
423
709
  story: Story,
424
710
  state: StoryState
425
711
  ): Effect.fn.Return<
426
- { readonly judge: string | undefined; readonly totals: TokenUsage | undefined },
712
+ {
713
+ readonly judge: string | undefined
714
+ readonly totals: TokenUsage | undefined
715
+ readonly executor: string | undefined
716
+ },
427
717
  FlowError,
428
718
  Scope.Scope
429
719
  > {
430
- const seats = yield* options.contextFor(state.worktree)
720
+ const seats = yield* options.contextFor(state.worktree, {
721
+ label: `story ${story.id}`,
722
+ ...(state.executor === undefined ? {} : { prefer: state.executor })
723
+ })
724
+ const roster = seats.context.roster
725
+ const coderExecutor = roster === undefined ? undefined : yield* roster.executor
726
+ if (coderExecutor !== undefined && coderExecutor !== state.executor) {
727
+ yield* saveVersioned(
728
+ files,
729
+ statePath(story),
730
+ StoryStateVersion,
731
+ StoryState,
732
+ StoryState.make({ ...state, executor: coderExecutor })
733
+ )
734
+ }
431
735
  const blocked = yield* Ref.make<string | undefined>(undefined)
736
+ const pushbacks = yield* Ref.make(0)
432
737
  const storyContext: FlowContextShape = {
433
738
  ...seats.context,
434
739
  coder: watchForBlockedOn(seats.context.coder, blocked)
435
740
  }
741
+ const git = storyContext.git
436
742
  const watchedSeats: StorySeats = { ...seats, context: storyContext }
437
743
  const extra = options.system === undefined ? undefined : yield* options.system(story)
438
- const system = [perimeterRules(story), extra]
744
+ const system = [
745
+ perimeterRules(story, { plan, worktree: state.worktree, epicCheckout: context.workDir }),
746
+ extra
747
+ ]
439
748
  .filter((part): part is string => part !== undefined && part.trim().length > 0)
440
749
  .join("\n\n")
441
750
  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
- )
751
+
752
+ // Catch up before setup: the epic may have changed the manifest too.
753
+ yield* catchUp(story, git)
754
+ if (options.setup !== undefined) {
755
+ yield* stage(events, `story ${story.id}: setup`, options.setup(state.worktree))
756
+ }
757
+
758
+ // The target's gates plus the perimeter: a stray path is a gate failure
759
+ // the task review loop hands back to the coder before anything commits.
760
+ const gates: Effect.Effect<ReviewResult, FlowError> = Effect.gen(function* () {
761
+ const target = yield* options.gates(state.worktree)
762
+ const changed = yield* perimeterNow(story, git)
763
+ return combined(target, perimeterGate([...changed.sharedReadOnly, ...changed.outside], story))
764
+ })
765
+
766
+ const coderTurn = (text: string): Effect.Effect<string, FlowError> =>
767
+ Effect.flatMap(makeChat(storyContext.coder, { system, events, agent: "coder" }), (chat) =>
768
+ chat.ask(text)
458
769
  )
459
770
 
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 })
771
+ const commitGreen = (message: string, why: string): Effect.Effect<void, FlowError> =>
772
+ Effect.gen(function* () {
773
+ const regated = yield* gates
774
+ if (!regated.isClean) {
775
+ return yield* failed(story, `gates broke ${why}:\n${issueLines(regated)}`)
776
+ }
777
+ yield* git.commitAll(`${story.id}: ${message}`)
476
778
  })
477
- )
779
+
780
+ /**
781
+ * Runs `effect`; a BLOCKED_ON the plan or the verifier refutes sends the
782
+ * coder back once with the reason, then `resume` runs. Any other claim —
783
+ * or a second one — ends the story as a typed MissingDependency.
784
+ */
785
+ const guarded = <A>(
786
+ effect: Effect.Effect<A, FlowError>,
787
+ resume: Effect.Effect<void, FlowError>
788
+ ): Effect.Effect<void, FlowError> =>
789
+ Effect.gen(function* () {
790
+ const exit = yield* Effect.exit(effect)
791
+ const need = yield* Ref.get(blocked)
792
+ if (need === undefined) {
793
+ if (Exit.isFailure(exit)) {
794
+ return yield* Effect.failCause(exit.cause)
795
+ }
796
+ return
797
+ }
798
+ const missing = MissingDependency.make({ story: story.id, need })
799
+ if ((yield* Ref.get(pushbacks)) > 0) {
800
+ return yield* missing
801
+ }
802
+ let rebuttal = blockedRebuttal(plan, story, need)
803
+ if (rebuttal === undefined && options.verifyBlocked !== undefined) {
804
+ const verdict = yield* options
805
+ .verifyBlocked(story, need, state.worktree, watchedSeats)
806
+ .pipe(Effect.catch(() => Effect.succeed(undefined)))
807
+ rebuttal = verdict === undefined || verdict.real ? undefined : verdict.reason
808
+ }
809
+ if (rebuttal === undefined) {
810
+ return yield* missing
811
+ }
812
+ yield* Ref.set(blocked, undefined)
813
+ yield* Ref.update(pushbacks, (count) => count + 1)
814
+ yield* events.publish(
815
+ Info.make({ message: `story ${story.id}: BLOCKED_ON rejected — ${rebuttal}` })
816
+ )
817
+ yield* coderTurn(
818
+ [
819
+ `You stopped with "${blockedOnSentinel} ${need}".`,
820
+ "That claim was checked against the epic's plan and rejected:",
821
+ rebuttal,
822
+ "",
823
+ "It is not a missing dependency. Continue the story inside your owned paths until it is",
824
+ `complete, then stop. Use ${blockedOnSentinel} only for work another story owns that is`,
825
+ "absent from your working tree."
826
+ ].join("\n")
827
+ )
828
+ const again = yield* Ref.get(blocked)
829
+ if (again !== undefined) {
830
+ return yield* MissingDependency.make({ story: story.id, need: again })
831
+ }
832
+ yield* commitGreen("continue after a rejected BLOCKED_ON", "after a rejected BLOCKED_ON")
833
+ yield* guarded(resume, Effect.void)
834
+ })
835
+
836
+ /**
837
+ * Stray paths go back before a judge sees the branch: first the coder is
838
+ * asked to move or revert them, then whatever is left is restored from
839
+ * the epic branch — a deterministic rule never needs a judge round.
840
+ */
841
+ const repairPerimeter: Effect.Effect<void, FlowError> = Effect.gen(function* () {
842
+ const first = yield* perimeterNow(story, git)
843
+ if (isWithinPerimeter(first)) {
844
+ return
845
+ }
846
+ const violation = PerimeterViolation.make({
847
+ story: story.id,
848
+ outside: first.outside,
849
+ sharedReadOnly: first.sharedReadOnly
850
+ })
851
+ yield* guarded(
852
+ coderTurn(
853
+ [
854
+ violation.message,
855
+ "",
856
+ "Put each of these paths back as it is on the epic branch (restore a changed file, delete",
857
+ "a file you created there). Work that belongs to this story moves into your owned paths.",
858
+ "Then stop."
859
+ ].join("\n")
860
+ ),
861
+ Effect.void
862
+ )
863
+ const second = yield* perimeterNow(story, git)
864
+ if (!isWithinPerimeter(second)) {
865
+ const stray = [...second.sharedReadOnly, ...second.outside]
866
+ yield* git.restorePaths(epicBranch, stray)
867
+ yield* events.publish(
868
+ Info.make({
869
+ message: `story ${story.id}: restored ${stray.length} path(s) outside its perimeter from ${epicBranch}: ${stray.join(", ")}`
870
+ })
871
+ )
872
+ }
873
+ yield* git.commitAll(`${story.id}: put paths outside the perimeter back`)
874
+ })
875
+
876
+ const planTasks = options.planTasks ?? defaultPlanTasks
877
+ const implementTasks = implementPlanFlow(storyContext, {
878
+ store: makePlanStore(files),
879
+ planPath: planPath(story),
880
+ plan: planWithinPerimeter(
881
+ plan,
882
+ story,
883
+ (text) => planTasks(watchedSeats, story, text),
884
+ prompt,
885
+ events
886
+ ),
887
+ system,
888
+ chatPerTask: true,
889
+ checkoutBranch: false,
890
+ lint: gates,
891
+ // A story's final state is judged and gated downstream (judge round,
892
+ // perimeter check, epic gates), so a task the coder finds already
893
+ // satisfied — without saying the exact sentinel — must not sink the
894
+ // story: the option exists for pipelines shaped like this one.
895
+ noopTaskPolicy: "complete",
896
+ ...(options.reviewers === undefined ? {} : { reviewers: options.reviewers }),
897
+ ...(options.maxRounds === undefined ? {} : { maxRounds: options.maxRounds })
898
+ })
899
+ yield* guarded(implementTasks, implementTasks)
900
+
901
+ // Other stories may have merged while this one ran: judge the branch
902
+ // against the epic as it is now.
903
+ yield* catchUp(story, git)
904
+ yield* repairPerimeter
478
905
 
479
906
  let judgeNote: string | undefined
480
907
  if (options.judge !== undefined) {
481
908
  const judge = options.judge
482
909
  for (let round = 1; round <= judgeRounds; round += 1) {
483
- const diff = yield* storyContext.git.diffVsBase(epicBranch)
910
+ const diff = yield* git.diffVsBase(epicBranch)
484
911
  if (diff.trim().length === 0) {
485
912
  // Nothing to judge is a deterministic failure, not a model call:
486
913
  // a model asked to score an empty diff scores the prompt instead.
487
914
  return yield* failed(story, "the story branch has no changes against the epic branch")
488
915
  }
489
- const verdict = yield* judge(story, diff)
916
+ const verdict = yield* judge(story, diff, watchedSeats)
490
917
  if (verdict.isClean) {
491
918
  judgeNote = `judge cleared (round ${round})`
492
919
  break
@@ -497,35 +924,30 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
497
924
  `judge not cleared after ${judgeRounds} round(s):\n${issueLines(verdict)}`
498
925
  )
499
926
  }
500
- const feedback = yield* makeChat(storyContext.coder, {
501
- system,
502
- events,
503
- agent: "coder"
504
- })
505
- yield* blockedOr(
506
- feedback.ask(
927
+ yield* guarded(
928
+ coderTurn(
507
929
  [
508
930
  `The story "${story.title}" scored below the bar. Close these gaps without`,
509
931
  "weakening any test and without leaving your owned paths, then stop:",
510
932
  issueLines(verdict)
511
933
  ].join("\n")
512
- )
934
+ ),
935
+ Effect.void
513
936
  )
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`)
937
+ yield* repairPerimeter
938
+ yield* commitGreen("address judge feedback", "while addressing judge feedback")
522
939
  }
523
940
  }
524
941
 
525
- const changed = yield* storyContext.git.changedFilesVsBase(epicBranch)
942
+ const changed = yield* git.changedFilesVsBase(epicBranch)
526
943
  yield* enforcePerimeter(changed, story)
527
944
  const totals = seats.totals === undefined ? undefined : yield* seats.totals
528
- return { judge: judgeNote, totals }
945
+ const history = roster === undefined ? [] : yield* roster.history
946
+ return {
947
+ judge: judgeNote,
948
+ totals,
949
+ executor: history.length === 0 ? undefined : history.join(" → ")
950
+ }
529
951
  })
530
952
 
531
953
  const runStory = Effect.fn("@llm4ts/flow/Stories.runStory")(function* (
@@ -543,17 +965,19 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
543
965
  judge: "merged on a previous run"
544
966
  })
545
967
  }
546
- if (options.setup !== undefined) {
547
- yield* stage(events, `story ${story.id}: setup`, options.setup(state.worktree))
548
- }
549
968
  const result = yield* Effect.scoped(implementStory(story, state))
550
969
  yield* integrate(story, state.branch)
970
+ const last = result.executor?.split(" → ").at(-1)
551
971
  yield* saveVersioned(
552
972
  files,
553
973
  statePath(story),
554
974
  StoryStateVersion,
555
975
  StoryState,
556
- StoryState.make({ ...state, status: "merged" })
976
+ StoryState.make({
977
+ ...state,
978
+ status: "merged",
979
+ ...(last === undefined ? {} : { executor: last })
980
+ })
557
981
  )
558
982
  return StoryOutcome.make({
559
983
  id: story.id,
@@ -561,6 +985,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
561
985
  status: "done",
562
986
  branch: state.branch,
563
987
  ...(result.judge === undefined ? {} : { judge: result.judge }),
988
+ ...(result.executor === undefined ? {} : { executor: result.executor }),
564
989
  ...(result.totals === undefined ? {} : { estimatedTokens: result.totals.total }),
565
990
  ...(result.totals?.costUsd === undefined ? {} : { estimatedCostUsd: result.totals.costUsd })
566
991
  })
@@ -586,7 +1011,15 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
586
1011
  StoryState.make({ ...stored, status: "failed" })
587
1012
  ).pipe(Effect.ignore)
588
1013
  }
589
- return {
1014
+ const interruption: Interruption | undefined =
1015
+ error._tag === "EpicCheckoutDirty" ||
1016
+ error._tag === "RosterExhausted" ||
1017
+ reason.includes(rosterExhaustedPrefix)
1018
+ ? "halt"
1019
+ : isOutageMessage(reason)
1020
+ ? "outage"
1021
+ : undefined
1022
+ const completion: Completion = {
590
1023
  story,
591
1024
  outcome: StoryOutcome.make({
592
1025
  id: story.id,
@@ -594,8 +1027,10 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
594
1027
  status: "failed",
595
1028
  reason,
596
1029
  ...(stored === undefined ? {} : { branch: stored.branch })
597
- })
1030
+ }),
1031
+ ...(interruption === undefined ? {} : { interruption })
598
1032
  }
1033
+ return completion
599
1034
  })
600
1035
  )
601
1036
  )
@@ -639,19 +1074,35 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
639
1074
  }
640
1075
  })
641
1076
 
1077
+ /** Why no further story may start in this run; stories already running finish. */
1078
+ let halted: string | undefined
1079
+ /** Stories retried once after the serving engine recovered. */
1080
+ const recovered = new Set<string>()
1081
+
642
1082
  yield* Effect.scoped(
643
1083
  Effect.gen(function* () {
644
1084
  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))
1085
+ if (halted === undefined) {
1086
+ const current = yield* progress
1087
+ const ready = readyStories(plan, current)
1088
+ // With a roster, never launch more stories than there are free
1089
+ // coders; with nothing running, launch one anyway — its lease waits
1090
+ // for the first executor to come back (ADR 0019).
1091
+ const free =
1092
+ context.roster === undefined
1093
+ ? Number.POSITIVE_INFINITY
1094
+ : yield* context.roster.available("coder")
1095
+ const budget = Math.min(concurrency - current.running.size, free)
1096
+ const slots =
1097
+ current.running.size === 0 && ready.length > 0 ? Math.max(1, budget) : budget
1098
+ for (const story of ready.slice(0, Math.max(0, slots))) {
1099
+ yield* Ref.update(running, (set) => new Set([...set, story.id]))
1100
+ yield* Effect.forkScoped(
1101
+ attempt(story).pipe(
1102
+ Effect.flatMap((completion) => Queue.offer(completions, completion))
1103
+ )
653
1104
  )
654
- )
1105
+ }
655
1106
  }
656
1107
  if ((yield* Ref.get(running)).size === 0) {
657
1108
  break
@@ -662,12 +1113,43 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
662
1113
  next.delete(completion.story.id)
663
1114
  return next
664
1115
  })
1116
+ const reason = completion.outcome.reason ?? "failed"
1117
+ if (completion.interruption === "outage" && halted === undefined) {
1118
+ if (options.awaitRecovery !== undefined && !recovered.has(completion.story.id)) {
1119
+ recovered.add(completion.story.id)
1120
+ yield* events.publish(
1121
+ Info.make({
1122
+ message: `story ${completion.story.id}: the serving engine is down; waiting for it to recover, then retrying the story`
1123
+ })
1124
+ )
1125
+ const back = yield* Effect.result(options.awaitRecovery(reason))
1126
+ if (back._tag === "Success") {
1127
+ // Not recorded: the story is ready again and relaunches.
1128
+ continue
1129
+ }
1130
+ halted = `the serving engine did not recover (${describeFlowError(back.failure)})`
1131
+ } else {
1132
+ halted = `the serving engine is down: ${reason}`
1133
+ }
1134
+ }
1135
+ if (completion.interruption === "halt" && halted === undefined) {
1136
+ halted = reason
1137
+ }
665
1138
  yield* record(completion.outcome)
666
1139
  const outcome = completion.outcome
667
1140
  if (outcome.status === "done") {
668
1141
  yield* board.complete(outcome.id, {
669
1142
  ...(outcome.branch === undefined ? {} : { branch: outcome.branch }),
670
- ...(outcome.judge === undefined ? {} : { detail: outcome.judge }),
1143
+ ...(outcome.judge === undefined && outcome.executor === undefined
1144
+ ? {}
1145
+ : {
1146
+ detail: [
1147
+ outcome.judge,
1148
+ outcome.executor === undefined ? undefined : `coder ${outcome.executor}`
1149
+ ]
1150
+ .filter((part): part is string => part !== undefined)
1151
+ .join(" · ")
1152
+ }),
671
1153
  ...(outcome.estimatedTokens === undefined
672
1154
  ? {}
673
1155
  : { estimatedTokens: outcome.estimatedTokens }),
@@ -689,6 +1171,24 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
689
1171
  })
690
1172
  )
691
1173
 
1174
+ if (halted !== undefined) {
1175
+ const stopped = halted
1176
+ yield* events.publish(
1177
+ Info.make({ message: `epic ${plan.epicId}: stopped launching stories — ${stopped}` })
1178
+ )
1179
+ const known = new Set((yield* Ref.get(outcomes)).map((outcome) => outcome.id))
1180
+ for (const story of plan.stories) {
1181
+ if (known.has(story.id)) {
1182
+ continue
1183
+ }
1184
+ const reason = `not started: ${stopped}`
1185
+ yield* record(
1186
+ StoryOutcome.make({ id: story.id, title: story.title, status: "waiting", reason })
1187
+ )
1188
+ yield* board.wait(story.id, reason)
1189
+ }
1190
+ }
1191
+
692
1192
  const recorded = yield* Ref.get(outcomes)
693
1193
  const ordered = plan.stories.flatMap((story) => {
694
1194
  const outcome = recorded.find((candidate) => candidate.id === story.id)