@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/dist/Stories.js 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";
@@ -13,13 +14,17 @@ import * as Stream from "effect/Stream";
13
14
  import { BoardItem } from "./BoardSync.js";
14
15
  import { makeChat } from "./Chat.js";
15
16
  import { implementPlanFlow } from "./Flow.js";
16
- import { describeFlowError, MissingDependency, StoryFailed } from "./FlowError.js";
17
+ import { describeFlowError, rosterExhaustedPrefix, EpicCheckoutDirty, MissingDependency, PerimeterViolation, StoryFailed } from "./FlowError.js";
17
18
  import { Info } from "./FlowEvents.js";
18
- import { enforcePerimeter } from "./Perimeter.js";
19
+ import { statusPaths } from "./GitTool.js";
20
+ import { checkPerimeter, enforcePerimeter, isWithinPerimeter, perimeterGate, strayTasks } from "./Perimeter.js";
19
21
  import { loadVersioned, makePlanStore, saveVersioned } from "./Persistence.js";
22
+ import { Plan } from "./Plan.js";
20
23
  import { stage } from "./PlanExecution.js";
21
24
  import { planFrom } from "./Planner.js";
22
- import { dependentsOf, readyStories, storyHash, topologicalWaves, validateStoryPlan } from "./StoryPlan.js";
25
+ import { ReviewResult } from "./Review.js";
26
+ import { dependentsOf, ownerOf, pathsNamedIn, readyStories, relationOf, storyHash, topologicalWaves, validateStoryPlan } from "./StoryPlan.js";
27
+ import { isOutageMessage } from "./TransientRetry.js";
23
28
  const join = (root, path) => `${root.replace(/[\\/]+$/, "")}/${path.replace(/^[\\/]+/, "")}`;
24
29
  // ---- Durable state ----------------------------------------------------------
25
30
  export const StoryStateVersion = 1;
@@ -31,7 +36,9 @@ export class StoryState extends Schema.Class("StoryState")({
31
36
  hash: Schema.String,
32
37
  branch: Schema.String,
33
38
  worktree: Schema.String,
34
- status: StoryStatus
39
+ status: StoryStatus,
40
+ /** The roster executor that last held the story's coder (ADR 0019); preferred on resume. */
41
+ executor: Schema.optionalKey(Schema.String)
35
42
  }) {
36
43
  }
37
44
  export const EpicReportVersion = 1;
@@ -45,6 +52,8 @@ export class StoryOutcome extends Schema.Class("StoryOutcome")({
45
52
  /** Failure reason, or what a waiting story waits for. */
46
53
  reason: Schema.optionalKey(Schema.String),
47
54
  judge: Schema.optionalKey(Schema.String),
55
+ /** The roster executor(s) that coded the story, in order ("codex → claude"). */
56
+ executor: Schema.optionalKey(Schema.String),
48
57
  /** ESTIMATES, never measurements (ADR 0012). */
49
58
  estimatedTokens: Schema.optionalKey(Schema.Int),
50
59
  estimatedCostUsd: Schema.optionalKey(Schema.Number)
@@ -88,6 +97,25 @@ export const renderEpicReport = (report) => {
88
97
  if (cost.length > 0) {
89
98
  lines.push(`- Estimated cost: ${money(cost.reduce((sum, value) => sum + value, 0))}`);
90
99
  }
100
+ const coded = report.stories.filter((story) => story.executor !== undefined);
101
+ if (coded.length > 0) {
102
+ // A story is counted for the executor that finished it; a handover chain
103
+ // is shown in full next to the story.
104
+ const byExecutor = new Map();
105
+ for (const story of coded) {
106
+ const chain = story.executor ?? "";
107
+ const last = chain.split(" → ").at(-1) ?? chain;
108
+ const entry = byExecutor.get(last) ?? { stories: [], tokens: 0, cost: 0 };
109
+ entry.stories.push(chain === last ? story.id : `${story.id} (${chain})`);
110
+ entry.tokens += story.estimatedTokens ?? 0;
111
+ entry.cost += story.estimatedCostUsd ?? 0;
112
+ byExecutor.set(last, entry);
113
+ }
114
+ lines.push("", "## By executor", "");
115
+ for (const [executor, entry] of byExecutor) {
116
+ lines.push(`- ${executor}: ${entry.stories.join(", ")} — ~${entry.tokens} tokens, ${money(entry.cost)} (estimated)`);
117
+ }
118
+ }
91
119
  lines.push("");
92
120
  return lines.join("\n");
93
121
  };
@@ -115,8 +143,31 @@ export const blockedOnIn = (text) => {
115
143
  return need === undefined || need.length === 0 ? undefined : need;
116
144
  };
117
145
  const bullets = (items) => items.length === 0 ? "- (none)" : items.map((item) => `- ${item}`).join("\n");
146
+ const relationNotes = {
147
+ dependency: "merged into the epic before you started — it is in your working tree; read it",
148
+ dependent: "built AFTER you, on top of your work — leave it alone (wiring your work in is that story's job)",
149
+ parallel: "built at the same time by another coder — leave it alone"
150
+ };
151
+ const whereaboutsRules = (story, where) => {
152
+ const others = where.plan.stories
153
+ .filter((other) => other.id !== story.id)
154
+ .map((other) => {
155
+ const relation = relationOf(where.plan, story, other);
156
+ const note = relation === "self" ? "" : relationNotes[relation];
157
+ return `${other.owned.join(", ")} — story ${other.id}: ${note}`;
158
+ });
159
+ return [
160
+ "",
161
+ `Your working directory is ${where.worktree}. Run every command there. Never cd into, read`,
162
+ `from, or write to any other checkout — above all not ${where.epicCheckout}, the epic`,
163
+ "branch's checkout: a change there breaks every other story's merge.",
164
+ "",
165
+ "Paths other stories own — never create or change them, and none of them is a reason to stop:",
166
+ bullets(others)
167
+ ];
168
+ };
118
169
  /** The hard rules every story coder receives; the perimeter check enforces them afterwards. */
119
- export const perimeterRules = (story) => [
170
+ export const perimeterRules = (story, where) => [
120
171
  `You are implementing ONE story of a larger epic: "${story.title}" (id: ${story.id}).`,
121
172
  "",
122
173
  "Paths you own — create and change files only here:",
@@ -127,6 +178,7 @@ export const perimeterRules = (story) => [
127
178
  "",
128
179
  "What this story must provide for other stories:",
129
180
  bullets(story.provides),
181
+ ...(where === undefined ? [] : whereaboutsRules(story, where)),
130
182
  "",
131
183
  "Rules:",
132
184
  "- Do not touch any path outside the owned list; a change there fails the story.",
@@ -142,13 +194,86 @@ export const storyPrompt = (story) => [`Story: ${story.title}`, "", story.descri
142
194
  export const storyTaskPlanInstructions = (story) => [
143
195
  "You are planning the implementation of ONE story inside a larger epic. Break the story",
144
196
  "into an ordered list of small, independently verifiable tasks, each described by its",
145
- "observable outcome. Every task must stay inside the story's owned paths.",
197
+ "observable outcome. Every task must stay inside the story's owned paths:",
198
+ bullets(story.owned),
199
+ "Never plan a task that creates or changes anything outside them — registering or wiring",
200
+ "the story in elsewhere (the app's composition point, a shared kit file) is another story's job.",
146
201
  `Use exactly this epicId: "${story.id}".`,
147
202
  'Respond only with JSON: {"epicId":"' + story.id + '","tasks":',
148
203
  '[{"title":"...","description":"...","completed":false}]}'
149
204
  ].join("\n");
150
205
  /** The coder plans its own tasks from the story — the orchestrator split the epic, not the story. */
151
206
  export const defaultPlanTasks = (seats, story, prompt) => planFrom(seats.context.coder, prompt, storyTaskPlanInstructions(story));
207
+ /**
208
+ * Asks the planner again, once, when its tasks name paths outside the story;
209
+ * tasks that still name ANOTHER story's paths are then dropped (that work is
210
+ * planned elsewhere) and any remaining stray is left to the perimeter gate.
211
+ */
212
+ export const planWithinPerimeter = (plan, story, planTasks, prompt, events) => Effect.gen(function* () {
213
+ const first = yield* planTasks(prompt);
214
+ const strays = strayTasks(plan, story, first.tasks);
215
+ if (strays.length === 0) {
216
+ return first;
217
+ }
218
+ const describe = strays.map((stray) => `- "${stray.task.title}": ${stray.paths.join(", ")}`);
219
+ yield* events.publish(Info.make({
220
+ message: `story ${story.id}: ${strays.length} planned task(s) name paths outside the story; re-planning:\n${describe.join("\n")}`
221
+ }));
222
+ const second = yield* planTasks([
223
+ prompt,
224
+ "",
225
+ "Your previous task list named paths outside this story's owned paths:",
226
+ ...describe,
227
+ "Plan again: only work inside the owned paths. Work on any other path is not this story's."
228
+ ].join("\n"));
229
+ const remaining = strayTasks(plan, story, second.tasks);
230
+ const foreign = new Set(remaining.filter((stray) => stray.foreign).map((stray) => stray.task));
231
+ const kept = second.tasks.filter((task) => !foreign.has(task));
232
+ if (remaining.length > 0) {
233
+ yield* events.publish(Info.make({
234
+ message: `story ${story.id}: the re-plan still names paths outside the story` +
235
+ (foreign.size > 0
236
+ ? `; dropped ${foreign.size} task(s) that belong to other stories`
237
+ : "")
238
+ }));
239
+ }
240
+ return kept.length === 0 ? second : Plan.make({ ...second, tasks: kept });
241
+ });
242
+ /**
243
+ * Why a BLOCKED_ON claim is wrong, when the plan alone can tell: the named
244
+ * path is the story's own, a dependency's (already merged into the worktree),
245
+ * or a dependent's (built later on top of this story). Undefined when the
246
+ * plan cannot settle it — a path no story owns, or a parallel story's.
247
+ */
248
+ export const blockedRebuttal = (plan, story, need) => {
249
+ for (const path of pathsNamedIn(plan, need)) {
250
+ const owner = ownerOf(plan, path);
251
+ if (owner === undefined) {
252
+ continue;
253
+ }
254
+ switch (relationOf(plan, story, owner)) {
255
+ case "self":
256
+ return `${path} is under your own owned paths: building it is your job, not another story's.`;
257
+ case "dependent":
258
+ return (`${path} belongs to story '${owner.id}', which runs AFTER you and builds on your work. ` +
259
+ "Leave it alone: it is neither missing nor a blocker for this story.");
260
+ case "dependency":
261
+ return (`${path} belongs to story '${owner.id}', merged into the epic branch before you started, ` +
262
+ `so it is already in your working tree: read it there. That story provides: ${owner.provides.join("; ")}.`);
263
+ case "parallel":
264
+ continue;
265
+ }
266
+ }
267
+ return undefined;
268
+ };
269
+ /** A second opinion on a BLOCKED_ON claim the plan cannot settle. */
270
+ export class BlockedVerdict extends Schema.Class("BlockedVerdict")({
271
+ /** True: the work is genuinely missing and owned by no dependency. */
272
+ real: Schema.Boolean,
273
+ /** Why — shown to the coder when the claim is rejected. */
274
+ reason: Schema.String
275
+ }) {
276
+ }
152
277
  // ---- BLOCKED_ON detection ---------------------------------------------------
153
278
  const watchStream = (stream, blocked) => Stream.unwrap(Effect.gen(function* () {
154
279
  const text = yield* Ref.make("");
@@ -171,6 +296,12 @@ export const watchForBlockedOn = (service, blocked) => ({
171
296
  executeStream: (prompt) => watchStream(service.executeStream(prompt), blocked),
172
297
  executeStreamWithHistory: (messages) => watchStream(service.executeStreamWithHistory(messages), blocked)
173
298
  });
299
+ const combined = (first, second) => first.isClean && second.isClean
300
+ ? first
301
+ : ReviewResult.make({
302
+ issues: [...first.issues, ...second.issues],
303
+ summary: [first.summary, second.summary].filter((part) => part.length > 0).join("; ")
304
+ });
174
305
  const issueLines = (result) => result.issues.map((issue) => `- ${issue.title}: ${issue.description}`).join("\n");
175
306
  const failed = (story, reason) => StoryFailed.make({ story: story.id, reason });
176
307
  // ---- The executor -------------------------------------------------------------
@@ -183,6 +314,22 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
183
314
  const judgeRounds = Math.max(1, options.judgeRounds ?? 2);
184
315
  const statePath = (story) => join(options.stateDir, `stories/${story.id}.json`);
185
316
  const planPath = (story) => join(options.stateDir, `stories/${story.id}.plan.md`);
317
+ /**
318
+ * The epic checkout must hold nothing uncommitted: every merge and epic
319
+ * gate runs there, and git refuses a merge that would overwrite a stray
320
+ * file. A dirty checkout means a coder escaped its worktree.
321
+ */
322
+ const epicCheckoutClean = (story) => Effect.gen(function* () {
323
+ const paths = statusPaths(yield* context.git.status);
324
+ if (paths.length > 0) {
325
+ return yield* EpicCheckoutDirty.make({
326
+ checkout: context.workDir,
327
+ paths,
328
+ ...(story === undefined ? {} : { story: story.id })
329
+ });
330
+ }
331
+ });
332
+ yield* epicCheckoutClean();
186
333
  yield* stage(events, "epic branch", context.git.checkoutOrCreate(epicBranch));
187
334
  const waves = topologicalWaves(plan);
188
335
  const waveOf = (id) => {
@@ -201,6 +348,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
201
348
  const mergeLock = yield* Semaphore.make(1);
202
349
  /** Merge the story branch into the epic branch and re-gate the epic head, one story at a time. */
203
350
  const integrate = (story, branch) => mergeLock.withPermit(Effect.gen(function* () {
351
+ yield* epicCheckoutClean(story);
204
352
  const checkpoint = yield* context.git.checkpoint;
205
353
  yield* context.git.merge(branch, `${plan.epicId}: merge story ${story.id}`);
206
354
  const gate = yield* options.gates(context.workDir);
@@ -245,41 +393,185 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
245
393
  return { state, alreadyMerged: false };
246
394
  }
247
395
  // Resume: the branch exists; the worktree may not (a worktree has a
248
- // `.git` FILE at its root, which is how its presence is checked).
249
- const marker = yield* files.read(join(stored.worktree, ".git"));
396
+ // `.git` FILE at its root, which is how its presence is checked). A
397
+ // worktree left under an older root moves to the current one, work and
398
+ // all, so every story of the epic runs under the same root.
399
+ let state = stored;
400
+ if (stored.worktree !== worktree) {
401
+ if ((yield* files.read(join(stored.worktree, ".git"))) !== undefined) {
402
+ yield* context.git.moveWorktree(stored.worktree, worktree);
403
+ yield* events.publish(Info.make({ message: `story ${story.id}: moved its worktree to ${worktree}` }));
404
+ }
405
+ state = StoryState.make({ ...stored, worktree });
406
+ yield* saveVersioned(files, path, StoryStateVersion, StoryState, state);
407
+ }
408
+ const marker = yield* files.read(join(state.worktree, ".git"));
250
409
  if (marker === undefined) {
251
- yield* context.git.addWorktree(stored.worktree, stored.branch);
410
+ yield* context.git.addWorktree(state.worktree, state.branch);
411
+ }
412
+ yield* events.publish(Info.make({ message: `story ${story.id}: resuming ${state.branch}` }));
413
+ return { state, alreadyMerged: false };
414
+ });
415
+ /**
416
+ * Brings a story branch up to date with the epic branch. A story that
417
+ * started before a dependency was re-merged (or that resumes after other
418
+ * stories merged) would otherwise build on, and be judged against, code
419
+ * the epic no longer has. Conflicting hunks take the epic's side: the epic
420
+ * holds only other stories' merged work, and those paths are theirs.
421
+ */
422
+ const catchUp = (story, git) => Effect.gen(function* () {
423
+ if (yield* git.isAncestor(epicBranch, "HEAD")) {
424
+ return;
425
+ }
426
+ if ((yield* git.uncommittedFiles).length > 0) {
427
+ yield* git.commitAll(`${story.id}: work in progress`);
252
428
  }
253
- yield* events.publish(Info.make({ message: `story ${story.id}: resuming ${stored.branch}` }));
254
- return { state: stored, alreadyMerged: false };
429
+ yield* git.merge(epicBranch, `${story.id}: catch up with ${epicBranch}`, {
430
+ preferIncoming: true
431
+ });
432
+ yield* events.publish(Info.make({ message: `story ${story.id}: caught up with ${epicBranch}` }));
433
+ });
434
+ /** The perimeter over everything the branch changed, committed or not. */
435
+ const perimeterNow = (story, git) => Effect.gen(function* () {
436
+ const committed = yield* git.changedFilesVsBase(epicBranch);
437
+ const uncommitted = yield* git.uncommittedFiles;
438
+ return checkPerimeter([...new Set([...committed, ...uncommitted])], story);
255
439
  });
256
440
  /** Everything that happens inside a story's worktree: plan, implement, judge, perimeter. */
257
441
  const implementStory = Effect.fn("@llm4ts/flow/Stories.implementStory")(function* (story, state) {
258
- const seats = yield* options.contextFor(state.worktree);
442
+ const seats = yield* options.contextFor(state.worktree, {
443
+ label: `story ${story.id}`,
444
+ ...(state.executor === undefined ? {} : { prefer: state.executor })
445
+ });
446
+ const roster = seats.context.roster;
447
+ const coderExecutor = roster === undefined ? undefined : yield* roster.executor;
448
+ if (coderExecutor !== undefined && coderExecutor !== state.executor) {
449
+ yield* saveVersioned(files, statePath(story), StoryStateVersion, StoryState, StoryState.make({ ...state, executor: coderExecutor }));
450
+ }
259
451
  const blocked = yield* Ref.make(undefined);
452
+ const pushbacks = yield* Ref.make(0);
260
453
  const storyContext = {
261
454
  ...seats.context,
262
455
  coder: watchForBlockedOn(seats.context.coder, blocked)
263
456
  };
457
+ const git = storyContext.git;
264
458
  const watchedSeats = { ...seats, context: storyContext };
265
459
  const extra = options.system === undefined ? undefined : yield* options.system(story);
266
- const system = [perimeterRules(story), extra]
460
+ const system = [
461
+ perimeterRules(story, { plan, worktree: state.worktree, epicCheckout: context.workDir }),
462
+ extra
463
+ ]
267
464
  .filter((part) => part !== undefined && part.trim().length > 0)
268
465
  .join("\n\n");
269
466
  const prompt = storyPrompt(story);
270
- const blockedOr = (effect) => effect.pipe(Effect.catch((error) => Effect.flatMap(Ref.get(blocked), (need) => need === undefined
271
- ? Effect.fail(error)
272
- : Effect.fail(MissingDependency.make({ story: story.id, need })))), Effect.tap(() => Effect.flatMap(Ref.get(blocked), (need) => need === undefined
273
- ? Effect.void
274
- : Effect.fail(MissingDependency.make({ story: story.id, need })))));
275
- yield* blockedOr(implementPlanFlow(storyContext, {
467
+ // Catch up before setup: the epic may have changed the manifest too.
468
+ yield* catchUp(story, git);
469
+ if (options.setup !== undefined) {
470
+ yield* stage(events, `story ${story.id}: setup`, options.setup(state.worktree));
471
+ }
472
+ // The target's gates plus the perimeter: a stray path is a gate failure
473
+ // the task review loop hands back to the coder before anything commits.
474
+ const gates = Effect.gen(function* () {
475
+ const target = yield* options.gates(state.worktree);
476
+ const changed = yield* perimeterNow(story, git);
477
+ return combined(target, perimeterGate([...changed.sharedReadOnly, ...changed.outside], story));
478
+ });
479
+ const coderTurn = (text) => Effect.flatMap(makeChat(storyContext.coder, { system, events, agent: "coder" }), (chat) => chat.ask(text));
480
+ const commitGreen = (message, why) => Effect.gen(function* () {
481
+ const regated = yield* gates;
482
+ if (!regated.isClean) {
483
+ return yield* failed(story, `gates broke ${why}:\n${issueLines(regated)}`);
484
+ }
485
+ yield* git.commitAll(`${story.id}: ${message}`);
486
+ });
487
+ /**
488
+ * Runs `effect`; a BLOCKED_ON the plan or the verifier refutes sends the
489
+ * coder back once with the reason, then `resume` runs. Any other claim —
490
+ * or a second one — ends the story as a typed MissingDependency.
491
+ */
492
+ const guarded = (effect, resume) => Effect.gen(function* () {
493
+ const exit = yield* Effect.exit(effect);
494
+ const need = yield* Ref.get(blocked);
495
+ if (need === undefined) {
496
+ if (Exit.isFailure(exit)) {
497
+ return yield* Effect.failCause(exit.cause);
498
+ }
499
+ return;
500
+ }
501
+ const missing = MissingDependency.make({ story: story.id, need });
502
+ if ((yield* Ref.get(pushbacks)) > 0) {
503
+ return yield* missing;
504
+ }
505
+ let rebuttal = blockedRebuttal(plan, story, need);
506
+ if (rebuttal === undefined && options.verifyBlocked !== undefined) {
507
+ const verdict = yield* options
508
+ .verifyBlocked(story, need, state.worktree, watchedSeats)
509
+ .pipe(Effect.catch(() => Effect.succeed(undefined)));
510
+ rebuttal = verdict === undefined || verdict.real ? undefined : verdict.reason;
511
+ }
512
+ if (rebuttal === undefined) {
513
+ return yield* missing;
514
+ }
515
+ yield* Ref.set(blocked, undefined);
516
+ yield* Ref.update(pushbacks, (count) => count + 1);
517
+ yield* events.publish(Info.make({ message: `story ${story.id}: BLOCKED_ON rejected — ${rebuttal}` }));
518
+ yield* coderTurn([
519
+ `You stopped with "${blockedOnSentinel} ${need}".`,
520
+ "That claim was checked against the epic's plan and rejected:",
521
+ rebuttal,
522
+ "",
523
+ "It is not a missing dependency. Continue the story inside your owned paths until it is",
524
+ `complete, then stop. Use ${blockedOnSentinel} only for work another story owns that is`,
525
+ "absent from your working tree."
526
+ ].join("\n"));
527
+ const again = yield* Ref.get(blocked);
528
+ if (again !== undefined) {
529
+ return yield* MissingDependency.make({ story: story.id, need: again });
530
+ }
531
+ yield* commitGreen("continue after a rejected BLOCKED_ON", "after a rejected BLOCKED_ON");
532
+ yield* guarded(resume, Effect.void);
533
+ });
534
+ /**
535
+ * Stray paths go back before a judge sees the branch: first the coder is
536
+ * asked to move or revert them, then whatever is left is restored from
537
+ * the epic branch — a deterministic rule never needs a judge round.
538
+ */
539
+ const repairPerimeter = Effect.gen(function* () {
540
+ const first = yield* perimeterNow(story, git);
541
+ if (isWithinPerimeter(first)) {
542
+ return;
543
+ }
544
+ const violation = PerimeterViolation.make({
545
+ story: story.id,
546
+ outside: first.outside,
547
+ sharedReadOnly: first.sharedReadOnly
548
+ });
549
+ yield* guarded(coderTurn([
550
+ violation.message,
551
+ "",
552
+ "Put each of these paths back as it is on the epic branch (restore a changed file, delete",
553
+ "a file you created there). Work that belongs to this story moves into your owned paths.",
554
+ "Then stop."
555
+ ].join("\n")), Effect.void);
556
+ const second = yield* perimeterNow(story, git);
557
+ if (!isWithinPerimeter(second)) {
558
+ const stray = [...second.sharedReadOnly, ...second.outside];
559
+ yield* git.restorePaths(epicBranch, stray);
560
+ yield* events.publish(Info.make({
561
+ message: `story ${story.id}: restored ${stray.length} path(s) outside its perimeter from ${epicBranch}: ${stray.join(", ")}`
562
+ }));
563
+ }
564
+ yield* git.commitAll(`${story.id}: put paths outside the perimeter back`);
565
+ });
566
+ const planTasks = options.planTasks ?? defaultPlanTasks;
567
+ const implementTasks = implementPlanFlow(storyContext, {
276
568
  store: makePlanStore(files),
277
569
  planPath: planPath(story),
278
- plan: (options.planTasks ?? defaultPlanTasks)(watchedSeats, story, prompt),
570
+ plan: planWithinPerimeter(plan, story, (text) => planTasks(watchedSeats, story, text), prompt, events),
279
571
  system,
280
572
  chatPerTask: true,
281
573
  checkoutBranch: false,
282
- lint: options.gates(state.worktree),
574
+ lint: gates,
283
575
  // A story's final state is judged and gated downstream (judge round,
284
576
  // perimeter check, epic gates), so a task the coder finds already
285
577
  // satisfied — without saying the exact sentinel — must not sink the
@@ -287,18 +579,23 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
287
579
  noopTaskPolicy: "complete",
288
580
  ...(options.reviewers === undefined ? {} : { reviewers: options.reviewers }),
289
581
  ...(options.maxRounds === undefined ? {} : { maxRounds: options.maxRounds })
290
- }));
582
+ });
583
+ yield* guarded(implementTasks, implementTasks);
584
+ // Other stories may have merged while this one ran: judge the branch
585
+ // against the epic as it is now.
586
+ yield* catchUp(story, git);
587
+ yield* repairPerimeter;
291
588
  let judgeNote;
292
589
  if (options.judge !== undefined) {
293
590
  const judge = options.judge;
294
591
  for (let round = 1; round <= judgeRounds; round += 1) {
295
- const diff = yield* storyContext.git.diffVsBase(epicBranch);
592
+ const diff = yield* git.diffVsBase(epicBranch);
296
593
  if (diff.trim().length === 0) {
297
594
  // Nothing to judge is a deterministic failure, not a model call:
298
595
  // a model asked to score an empty diff scores the prompt instead.
299
596
  return yield* failed(story, "the story branch has no changes against the epic branch");
300
597
  }
301
- const verdict = yield* judge(story, diff);
598
+ const verdict = yield* judge(story, diff, watchedSeats);
302
599
  if (verdict.isClean) {
303
600
  judgeNote = `judge cleared (round ${round})`;
304
601
  break;
@@ -306,27 +603,24 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
306
603
  if (round >= judgeRounds) {
307
604
  return yield* failed(story, `judge not cleared after ${judgeRounds} round(s):\n${issueLines(verdict)}`);
308
605
  }
309
- const feedback = yield* makeChat(storyContext.coder, {
310
- system,
311
- events,
312
- agent: "coder"
313
- });
314
- yield* blockedOr(feedback.ask([
606
+ yield* guarded(coderTurn([
315
607
  `The story "${story.title}" scored below the bar. Close these gaps without`,
316
608
  "weakening any test and without leaving your owned paths, then stop:",
317
609
  issueLines(verdict)
318
- ].join("\n")));
319
- const regated = yield* options.gates(state.worktree);
320
- if (!regated.isClean) {
321
- return yield* failed(story, `gates broke while addressing judge feedback:\n${issueLines(regated)}`);
322
- }
323
- yield* storyContext.git.commitAll(`${story.id}: address judge feedback`);
610
+ ].join("\n")), Effect.void);
611
+ yield* repairPerimeter;
612
+ yield* commitGreen("address judge feedback", "while addressing judge feedback");
324
613
  }
325
614
  }
326
- const changed = yield* storyContext.git.changedFilesVsBase(epicBranch);
615
+ const changed = yield* git.changedFilesVsBase(epicBranch);
327
616
  yield* enforcePerimeter(changed, story);
328
617
  const totals = seats.totals === undefined ? undefined : yield* seats.totals;
329
- return { judge: judgeNote, totals };
618
+ const history = roster === undefined ? [] : yield* roster.history;
619
+ return {
620
+ judge: judgeNote,
621
+ totals,
622
+ executor: history.length === 0 ? undefined : history.join(" → ")
623
+ };
330
624
  });
331
625
  const runStory = Effect.fn("@llm4ts/flow/Stories.runStory")(function* (story) {
332
626
  yield* board.start(story.id);
@@ -341,18 +635,21 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
341
635
  judge: "merged on a previous run"
342
636
  });
343
637
  }
344
- if (options.setup !== undefined) {
345
- yield* stage(events, `story ${story.id}: setup`, options.setup(state.worktree));
346
- }
347
638
  const result = yield* Effect.scoped(implementStory(story, state));
348
639
  yield* integrate(story, state.branch);
349
- yield* saveVersioned(files, statePath(story), StoryStateVersion, StoryState, StoryState.make({ ...state, status: "merged" }));
640
+ const last = result.executor?.split(" → ").at(-1);
641
+ yield* saveVersioned(files, statePath(story), StoryStateVersion, StoryState, StoryState.make({
642
+ ...state,
643
+ status: "merged",
644
+ ...(last === undefined ? {} : { executor: last })
645
+ }));
350
646
  return StoryOutcome.make({
351
647
  id: story.id,
352
648
  title: story.title,
353
649
  status: "done",
354
650
  branch: state.branch,
355
651
  ...(result.judge === undefined ? {} : { judge: result.judge }),
652
+ ...(result.executor === undefined ? {} : { executor: result.executor }),
356
653
  ...(result.totals === undefined ? {} : { estimatedTokens: result.totals.total }),
357
654
  ...(result.totals?.costUsd === undefined ? {} : { estimatedCostUsd: result.totals.costUsd })
358
655
  });
@@ -365,7 +662,14 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
365
662
  if (stored !== undefined && stored.status !== "merged") {
366
663
  yield* saveVersioned(files, path, StoryStateVersion, StoryState, StoryState.make({ ...stored, status: "failed" })).pipe(Effect.ignore);
367
664
  }
368
- return {
665
+ const interruption = error._tag === "EpicCheckoutDirty" ||
666
+ error._tag === "RosterExhausted" ||
667
+ reason.includes(rosterExhaustedPrefix)
668
+ ? "halt"
669
+ : isOutageMessage(reason)
670
+ ? "outage"
671
+ : undefined;
672
+ const completion = {
369
673
  story,
370
674
  outcome: StoryOutcome.make({
371
675
  id: story.id,
@@ -373,8 +677,10 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
373
677
  status: "failed",
374
678
  reason,
375
679
  ...(stored === undefined ? {} : { branch: stored.branch })
376
- })
680
+ }),
681
+ ...(interruption === undefined ? {} : { interruption })
377
682
  };
683
+ return completion;
378
684
  })));
379
685
  const outcomes = yield* Ref.make([]);
380
686
  const running = yield* Ref.make(new Set());
@@ -406,14 +712,27 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
406
712
  yield* board.wait(id, reason);
407
713
  }
408
714
  });
715
+ /** Why no further story may start in this run; stories already running finish. */
716
+ let halted;
717
+ /** Stories retried once after the serving engine recovered. */
718
+ const recovered = new Set();
409
719
  yield* Effect.scoped(Effect.gen(function* () {
410
720
  while (true) {
411
- const current = yield* progress;
412
- const ready = readyStories(plan, current);
413
- const slots = concurrency - current.running.size;
414
- for (const story of ready.slice(0, Math.max(0, slots))) {
415
- yield* Ref.update(running, (set) => new Set([...set, story.id]));
416
- yield* Effect.forkScoped(attempt(story).pipe(Effect.flatMap((completion) => Queue.offer(completions, completion))));
721
+ if (halted === undefined) {
722
+ const current = yield* progress;
723
+ const ready = readyStories(plan, current);
724
+ // With a roster, never launch more stories than there are free
725
+ // coders; with nothing running, launch one anyway — its lease waits
726
+ // for the first executor to come back (ADR 0019).
727
+ const free = context.roster === undefined
728
+ ? Number.POSITIVE_INFINITY
729
+ : yield* context.roster.available("coder");
730
+ const budget = Math.min(concurrency - current.running.size, free);
731
+ const slots = current.running.size === 0 && ready.length > 0 ? Math.max(1, budget) : budget;
732
+ for (const story of ready.slice(0, Math.max(0, slots))) {
733
+ yield* Ref.update(running, (set) => new Set([...set, story.id]));
734
+ yield* Effect.forkScoped(attempt(story).pipe(Effect.flatMap((completion) => Queue.offer(completions, completion))));
735
+ }
417
736
  }
418
737
  if ((yield* Ref.get(running)).size === 0) {
419
738
  break;
@@ -424,12 +743,42 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
424
743
  next.delete(completion.story.id);
425
744
  return next;
426
745
  });
746
+ const reason = completion.outcome.reason ?? "failed";
747
+ if (completion.interruption === "outage" && halted === undefined) {
748
+ if (options.awaitRecovery !== undefined && !recovered.has(completion.story.id)) {
749
+ recovered.add(completion.story.id);
750
+ yield* events.publish(Info.make({
751
+ message: `story ${completion.story.id}: the serving engine is down; waiting for it to recover, then retrying the story`
752
+ }));
753
+ const back = yield* Effect.result(options.awaitRecovery(reason));
754
+ if (back._tag === "Success") {
755
+ // Not recorded: the story is ready again and relaunches.
756
+ continue;
757
+ }
758
+ halted = `the serving engine did not recover (${describeFlowError(back.failure)})`;
759
+ }
760
+ else {
761
+ halted = `the serving engine is down: ${reason}`;
762
+ }
763
+ }
764
+ if (completion.interruption === "halt" && halted === undefined) {
765
+ halted = reason;
766
+ }
427
767
  yield* record(completion.outcome);
428
768
  const outcome = completion.outcome;
429
769
  if (outcome.status === "done") {
430
770
  yield* board.complete(outcome.id, {
431
771
  ...(outcome.branch === undefined ? {} : { branch: outcome.branch }),
432
- ...(outcome.judge === undefined ? {} : { detail: outcome.judge }),
772
+ ...(outcome.judge === undefined && outcome.executor === undefined
773
+ ? {}
774
+ : {
775
+ detail: [
776
+ outcome.judge,
777
+ outcome.executor === undefined ? undefined : `coder ${outcome.executor}`
778
+ ]
779
+ .filter((part) => part !== undefined)
780
+ .join(" · ")
781
+ }),
433
782
  ...(outcome.estimatedTokens === undefined
434
783
  ? {}
435
784
  : { estimatedTokens: outcome.estimatedTokens }),
@@ -450,6 +799,19 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
450
799
  }
451
800
  }
452
801
  }));
802
+ if (halted !== undefined) {
803
+ const stopped = halted;
804
+ yield* events.publish(Info.make({ message: `epic ${plan.epicId}: stopped launching stories — ${stopped}` }));
805
+ const known = new Set((yield* Ref.get(outcomes)).map((outcome) => outcome.id));
806
+ for (const story of plan.stories) {
807
+ if (known.has(story.id)) {
808
+ continue;
809
+ }
810
+ const reason = `not started: ${stopped}`;
811
+ yield* record(StoryOutcome.make({ id: story.id, title: story.title, status: "waiting", reason }));
812
+ yield* board.wait(story.id, reason);
813
+ }
814
+ }
453
815
  const recorded = yield* Ref.get(outcomes);
454
816
  const ordered = plan.stories.flatMap((story) => {
455
817
  const outcome = recorded.find((candidate) => candidate.id === story.id);