@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/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, 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;
@@ -115,8 +120,31 @@ export const blockedOnIn = (text) => {
115
120
  return need === undefined || need.length === 0 ? undefined : need;
116
121
  };
117
122
  const bullets = (items) => items.length === 0 ? "- (none)" : items.map((item) => `- ${item}`).join("\n");
123
+ const relationNotes = {
124
+ dependency: "merged into the epic before you started — it is in your working tree; read it",
125
+ dependent: "built AFTER you, on top of your work — leave it alone (wiring your work in is that story's job)",
126
+ parallel: "built at the same time by another coder — leave it alone"
127
+ };
128
+ const whereaboutsRules = (story, where) => {
129
+ const others = where.plan.stories
130
+ .filter((other) => other.id !== story.id)
131
+ .map((other) => {
132
+ const relation = relationOf(where.plan, story, other);
133
+ const note = relation === "self" ? "" : relationNotes[relation];
134
+ return `${other.owned.join(", ")} — story ${other.id}: ${note}`;
135
+ });
136
+ return [
137
+ "",
138
+ `Your working directory is ${where.worktree}. Run every command there. Never cd into, read`,
139
+ `from, or write to any other checkout — above all not ${where.epicCheckout}, the epic`,
140
+ "branch's checkout: a change there breaks every other story's merge.",
141
+ "",
142
+ "Paths other stories own — never create or change them, and none of them is a reason to stop:",
143
+ bullets(others)
144
+ ];
145
+ };
118
146
  /** The hard rules every story coder receives; the perimeter check enforces them afterwards. */
119
- export const perimeterRules = (story) => [
147
+ export const perimeterRules = (story, where) => [
120
148
  `You are implementing ONE story of a larger epic: "${story.title}" (id: ${story.id}).`,
121
149
  "",
122
150
  "Paths you own — create and change files only here:",
@@ -127,6 +155,7 @@ export const perimeterRules = (story) => [
127
155
  "",
128
156
  "What this story must provide for other stories:",
129
157
  bullets(story.provides),
158
+ ...(where === undefined ? [] : whereaboutsRules(story, where)),
130
159
  "",
131
160
  "Rules:",
132
161
  "- Do not touch any path outside the owned list; a change there fails the story.",
@@ -142,13 +171,86 @@ export const storyPrompt = (story) => [`Story: ${story.title}`, "", story.descri
142
171
  export const storyTaskPlanInstructions = (story) => [
143
172
  "You are planning the implementation of ONE story inside a larger epic. Break the story",
144
173
  "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.",
174
+ "observable outcome. Every task must stay inside the story's owned paths:",
175
+ bullets(story.owned),
176
+ "Never plan a task that creates or changes anything outside them — registering or wiring",
177
+ "the story in elsewhere (the app's composition point, a shared kit file) is another story's job.",
146
178
  `Use exactly this epicId: "${story.id}".`,
147
179
  'Respond only with JSON: {"epicId":"' + story.id + '","tasks":',
148
180
  '[{"title":"...","description":"...","completed":false}]}'
149
181
  ].join("\n");
150
182
  /** The coder plans its own tasks from the story — the orchestrator split the epic, not the story. */
151
183
  export const defaultPlanTasks = (seats, story, prompt) => planFrom(seats.context.coder, prompt, storyTaskPlanInstructions(story));
184
+ /**
185
+ * Asks the planner again, once, when its tasks name paths outside the story;
186
+ * tasks that still name ANOTHER story's paths are then dropped (that work is
187
+ * planned elsewhere) and any remaining stray is left to the perimeter gate.
188
+ */
189
+ export const planWithinPerimeter = (plan, story, planTasks, prompt, events) => Effect.gen(function* () {
190
+ const first = yield* planTasks(prompt);
191
+ const strays = strayTasks(plan, story, first.tasks);
192
+ if (strays.length === 0) {
193
+ return first;
194
+ }
195
+ const describe = strays.map((stray) => `- "${stray.task.title}": ${stray.paths.join(", ")}`);
196
+ yield* events.publish(Info.make({
197
+ message: `story ${story.id}: ${strays.length} planned task(s) name paths outside the story; re-planning:\n${describe.join("\n")}`
198
+ }));
199
+ const second = yield* planTasks([
200
+ prompt,
201
+ "",
202
+ "Your previous task list named paths outside this story's owned paths:",
203
+ ...describe,
204
+ "Plan again: only work inside the owned paths. Work on any other path is not this story's."
205
+ ].join("\n"));
206
+ const remaining = strayTasks(plan, story, second.tasks);
207
+ const foreign = new Set(remaining.filter((stray) => stray.foreign).map((stray) => stray.task));
208
+ const kept = second.tasks.filter((task) => !foreign.has(task));
209
+ if (remaining.length > 0) {
210
+ yield* events.publish(Info.make({
211
+ message: `story ${story.id}: the re-plan still names paths outside the story` +
212
+ (foreign.size > 0
213
+ ? `; dropped ${foreign.size} task(s) that belong to other stories`
214
+ : "")
215
+ }));
216
+ }
217
+ return kept.length === 0 ? second : Plan.make({ ...second, tasks: kept });
218
+ });
219
+ /**
220
+ * Why a BLOCKED_ON claim is wrong, when the plan alone can tell: the named
221
+ * path is the story's own, a dependency's (already merged into the worktree),
222
+ * or a dependent's (built later on top of this story). Undefined when the
223
+ * plan cannot settle it — a path no story owns, or a parallel story's.
224
+ */
225
+ export const blockedRebuttal = (plan, story, need) => {
226
+ for (const path of pathsNamedIn(plan, need)) {
227
+ const owner = ownerOf(plan, path);
228
+ if (owner === undefined) {
229
+ continue;
230
+ }
231
+ switch (relationOf(plan, story, owner)) {
232
+ case "self":
233
+ return `${path} is under your own owned paths: building it is your job, not another story's.`;
234
+ case "dependent":
235
+ return (`${path} belongs to story '${owner.id}', which runs AFTER you and builds on your work. ` +
236
+ "Leave it alone: it is neither missing nor a blocker for this story.");
237
+ case "dependency":
238
+ return (`${path} belongs to story '${owner.id}', merged into the epic branch before you started, ` +
239
+ `so it is already in your working tree: read it there. That story provides: ${owner.provides.join("; ")}.`);
240
+ case "parallel":
241
+ continue;
242
+ }
243
+ }
244
+ return undefined;
245
+ };
246
+ /** A second opinion on a BLOCKED_ON claim the plan cannot settle. */
247
+ export class BlockedVerdict extends Schema.Class("BlockedVerdict")({
248
+ /** True: the work is genuinely missing and owned by no dependency. */
249
+ real: Schema.Boolean,
250
+ /** Why — shown to the coder when the claim is rejected. */
251
+ reason: Schema.String
252
+ }) {
253
+ }
152
254
  // ---- BLOCKED_ON detection ---------------------------------------------------
153
255
  const watchStream = (stream, blocked) => Stream.unwrap(Effect.gen(function* () {
154
256
  const text = yield* Ref.make("");
@@ -171,6 +273,12 @@ export const watchForBlockedOn = (service, blocked) => ({
171
273
  executeStream: (prompt) => watchStream(service.executeStream(prompt), blocked),
172
274
  executeStreamWithHistory: (messages) => watchStream(service.executeStreamWithHistory(messages), blocked)
173
275
  });
276
+ const combined = (first, second) => first.isClean && second.isClean
277
+ ? first
278
+ : ReviewResult.make({
279
+ issues: [...first.issues, ...second.issues],
280
+ summary: [first.summary, second.summary].filter((part) => part.length > 0).join("; ")
281
+ });
174
282
  const issueLines = (result) => result.issues.map((issue) => `- ${issue.title}: ${issue.description}`).join("\n");
175
283
  const failed = (story, reason) => StoryFailed.make({ story: story.id, reason });
176
284
  // ---- The executor -------------------------------------------------------------
@@ -183,6 +291,22 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
183
291
  const judgeRounds = Math.max(1, options.judgeRounds ?? 2);
184
292
  const statePath = (story) => join(options.stateDir, `stories/${story.id}.json`);
185
293
  const planPath = (story) => join(options.stateDir, `stories/${story.id}.plan.md`);
294
+ /**
295
+ * The epic checkout must hold nothing uncommitted: every merge and epic
296
+ * gate runs there, and git refuses a merge that would overwrite a stray
297
+ * file. A dirty checkout means a coder escaped its worktree.
298
+ */
299
+ const epicCheckoutClean = (story) => Effect.gen(function* () {
300
+ const paths = statusPaths(yield* context.git.status);
301
+ if (paths.length > 0) {
302
+ return yield* EpicCheckoutDirty.make({
303
+ checkout: context.workDir,
304
+ paths,
305
+ ...(story === undefined ? {} : { story: story.id })
306
+ });
307
+ }
308
+ });
309
+ yield* epicCheckoutClean();
186
310
  yield* stage(events, "epic branch", context.git.checkoutOrCreate(epicBranch));
187
311
  const waves = topologicalWaves(plan);
188
312
  const waveOf = (id) => {
@@ -201,6 +325,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
201
325
  const mergeLock = yield* Semaphore.make(1);
202
326
  /** Merge the story branch into the epic branch and re-gate the epic head, one story at a time. */
203
327
  const integrate = (story, branch) => mergeLock.withPermit(Effect.gen(function* () {
328
+ yield* epicCheckoutClean(story);
204
329
  const checkpoint = yield* context.git.checkpoint;
205
330
  yield* context.git.merge(branch, `${plan.epicId}: merge story ${story.id}`);
206
331
  const gate = yield* options.gates(context.workDir);
@@ -245,41 +370,177 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
245
370
  return { state, alreadyMerged: false };
246
371
  }
247
372
  // 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"));
373
+ // `.git` FILE at its root, which is how its presence is checked). A
374
+ // worktree left under an older root moves to the current one, work and
375
+ // all, so every story of the epic runs under the same root.
376
+ let state = stored;
377
+ if (stored.worktree !== worktree) {
378
+ if ((yield* files.read(join(stored.worktree, ".git"))) !== undefined) {
379
+ yield* context.git.moveWorktree(stored.worktree, worktree);
380
+ yield* events.publish(Info.make({ message: `story ${story.id}: moved its worktree to ${worktree}` }));
381
+ }
382
+ state = StoryState.make({ ...stored, worktree });
383
+ yield* saveVersioned(files, path, StoryStateVersion, StoryState, state);
384
+ }
385
+ const marker = yield* files.read(join(state.worktree, ".git"));
250
386
  if (marker === undefined) {
251
- yield* context.git.addWorktree(stored.worktree, stored.branch);
387
+ yield* context.git.addWorktree(state.worktree, state.branch);
252
388
  }
253
- yield* events.publish(Info.make({ message: `story ${story.id}: resuming ${stored.branch}` }));
254
- return { state: stored, alreadyMerged: false };
389
+ yield* events.publish(Info.make({ message: `story ${story.id}: resuming ${state.branch}` }));
390
+ return { state, alreadyMerged: false };
391
+ });
392
+ /**
393
+ * Brings a story branch up to date with the epic branch. A story that
394
+ * started before a dependency was re-merged (or that resumes after other
395
+ * stories merged) would otherwise build on, and be judged against, code
396
+ * the epic no longer has. Conflicting hunks take the epic's side: the epic
397
+ * holds only other stories' merged work, and those paths are theirs.
398
+ */
399
+ const catchUp = (story, git) => Effect.gen(function* () {
400
+ if (yield* git.isAncestor(epicBranch, "HEAD")) {
401
+ return;
402
+ }
403
+ if ((yield* git.uncommittedFiles).length > 0) {
404
+ yield* git.commitAll(`${story.id}: work in progress`);
405
+ }
406
+ yield* git.merge(epicBranch, `${story.id}: catch up with ${epicBranch}`, {
407
+ preferIncoming: true
408
+ });
409
+ yield* events.publish(Info.make({ message: `story ${story.id}: caught up with ${epicBranch}` }));
410
+ });
411
+ /** The perimeter over everything the branch changed, committed or not. */
412
+ const perimeterNow = (story, git) => Effect.gen(function* () {
413
+ const committed = yield* git.changedFilesVsBase(epicBranch);
414
+ const uncommitted = yield* git.uncommittedFiles;
415
+ return checkPerimeter([...new Set([...committed, ...uncommitted])], story);
255
416
  });
256
417
  /** Everything that happens inside a story's worktree: plan, implement, judge, perimeter. */
257
418
  const implementStory = Effect.fn("@llm4ts/flow/Stories.implementStory")(function* (story, state) {
258
419
  const seats = yield* options.contextFor(state.worktree);
259
420
  const blocked = yield* Ref.make(undefined);
421
+ const pushbacks = yield* Ref.make(0);
260
422
  const storyContext = {
261
423
  ...seats.context,
262
424
  coder: watchForBlockedOn(seats.context.coder, blocked)
263
425
  };
426
+ const git = storyContext.git;
264
427
  const watchedSeats = { ...seats, context: storyContext };
265
428
  const extra = options.system === undefined ? undefined : yield* options.system(story);
266
- const system = [perimeterRules(story), extra]
429
+ const system = [
430
+ perimeterRules(story, { plan, worktree: state.worktree, epicCheckout: context.workDir }),
431
+ extra
432
+ ]
267
433
  .filter((part) => part !== undefined && part.trim().length > 0)
268
434
  .join("\n\n");
269
435
  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, {
436
+ // Catch up before setup: the epic may have changed the manifest too.
437
+ yield* catchUp(story, git);
438
+ if (options.setup !== undefined) {
439
+ yield* stage(events, `story ${story.id}: setup`, options.setup(state.worktree));
440
+ }
441
+ // The target's gates plus the perimeter: a stray path is a gate failure
442
+ // the task review loop hands back to the coder before anything commits.
443
+ const gates = Effect.gen(function* () {
444
+ const target = yield* options.gates(state.worktree);
445
+ const changed = yield* perimeterNow(story, git);
446
+ return combined(target, perimeterGate([...changed.sharedReadOnly, ...changed.outside], story));
447
+ });
448
+ const coderTurn = (text) => Effect.flatMap(makeChat(storyContext.coder, { system, events, agent: "coder" }), (chat) => chat.ask(text));
449
+ const commitGreen = (message, why) => Effect.gen(function* () {
450
+ const regated = yield* gates;
451
+ if (!regated.isClean) {
452
+ return yield* failed(story, `gates broke ${why}:\n${issueLines(regated)}`);
453
+ }
454
+ yield* git.commitAll(`${story.id}: ${message}`);
455
+ });
456
+ /**
457
+ * Runs `effect`; a BLOCKED_ON the plan or the verifier refutes sends the
458
+ * coder back once with the reason, then `resume` runs. Any other claim —
459
+ * or a second one — ends the story as a typed MissingDependency.
460
+ */
461
+ const guarded = (effect, resume) => Effect.gen(function* () {
462
+ const exit = yield* Effect.exit(effect);
463
+ const need = yield* Ref.get(blocked);
464
+ if (need === undefined) {
465
+ if (Exit.isFailure(exit)) {
466
+ return yield* Effect.failCause(exit.cause);
467
+ }
468
+ return;
469
+ }
470
+ const missing = MissingDependency.make({ story: story.id, need });
471
+ if ((yield* Ref.get(pushbacks)) > 0) {
472
+ return yield* missing;
473
+ }
474
+ let rebuttal = blockedRebuttal(plan, story, need);
475
+ if (rebuttal === undefined && options.verifyBlocked !== undefined) {
476
+ const verdict = yield* options
477
+ .verifyBlocked(story, need, state.worktree)
478
+ .pipe(Effect.catch(() => Effect.succeed(undefined)));
479
+ rebuttal = verdict === undefined || verdict.real ? undefined : verdict.reason;
480
+ }
481
+ if (rebuttal === undefined) {
482
+ return yield* missing;
483
+ }
484
+ yield* Ref.set(blocked, undefined);
485
+ yield* Ref.update(pushbacks, (count) => count + 1);
486
+ yield* events.publish(Info.make({ message: `story ${story.id}: BLOCKED_ON rejected — ${rebuttal}` }));
487
+ yield* coderTurn([
488
+ `You stopped with "${blockedOnSentinel} ${need}".`,
489
+ "That claim was checked against the epic's plan and rejected:",
490
+ rebuttal,
491
+ "",
492
+ "It is not a missing dependency. Continue the story inside your owned paths until it is",
493
+ `complete, then stop. Use ${blockedOnSentinel} only for work another story owns that is`,
494
+ "absent from your working tree."
495
+ ].join("\n"));
496
+ const again = yield* Ref.get(blocked);
497
+ if (again !== undefined) {
498
+ return yield* MissingDependency.make({ story: story.id, need: again });
499
+ }
500
+ yield* commitGreen("continue after a rejected BLOCKED_ON", "after a rejected BLOCKED_ON");
501
+ yield* guarded(resume, Effect.void);
502
+ });
503
+ /**
504
+ * Stray paths go back before a judge sees the branch: first the coder is
505
+ * asked to move or revert them, then whatever is left is restored from
506
+ * the epic branch — a deterministic rule never needs a judge round.
507
+ */
508
+ const repairPerimeter = Effect.gen(function* () {
509
+ const first = yield* perimeterNow(story, git);
510
+ if (isWithinPerimeter(first)) {
511
+ return;
512
+ }
513
+ const violation = PerimeterViolation.make({
514
+ story: story.id,
515
+ outside: first.outside,
516
+ sharedReadOnly: first.sharedReadOnly
517
+ });
518
+ yield* guarded(coderTurn([
519
+ violation.message,
520
+ "",
521
+ "Put each of these paths back as it is on the epic branch (restore a changed file, delete",
522
+ "a file you created there). Work that belongs to this story moves into your owned paths.",
523
+ "Then stop."
524
+ ].join("\n")), Effect.void);
525
+ const second = yield* perimeterNow(story, git);
526
+ if (!isWithinPerimeter(second)) {
527
+ const stray = [...second.sharedReadOnly, ...second.outside];
528
+ yield* git.restorePaths(epicBranch, stray);
529
+ yield* events.publish(Info.make({
530
+ message: `story ${story.id}: restored ${stray.length} path(s) outside its perimeter from ${epicBranch}: ${stray.join(", ")}`
531
+ }));
532
+ }
533
+ yield* git.commitAll(`${story.id}: put paths outside the perimeter back`);
534
+ });
535
+ const planTasks = options.planTasks ?? defaultPlanTasks;
536
+ const implementTasks = implementPlanFlow(storyContext, {
276
537
  store: makePlanStore(files),
277
538
  planPath: planPath(story),
278
- plan: (options.planTasks ?? defaultPlanTasks)(watchedSeats, story, prompt),
539
+ plan: planWithinPerimeter(plan, story, (text) => planTasks(watchedSeats, story, text), prompt, events),
279
540
  system,
280
541
  chatPerTask: true,
281
542
  checkoutBranch: false,
282
- lint: options.gates(state.worktree),
543
+ lint: gates,
283
544
  // A story's final state is judged and gated downstream (judge round,
284
545
  // perimeter check, epic gates), so a task the coder finds already
285
546
  // satisfied — without saying the exact sentinel — must not sink the
@@ -287,12 +548,17 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
287
548
  noopTaskPolicy: "complete",
288
549
  ...(options.reviewers === undefined ? {} : { reviewers: options.reviewers }),
289
550
  ...(options.maxRounds === undefined ? {} : { maxRounds: options.maxRounds })
290
- }));
551
+ });
552
+ yield* guarded(implementTasks, implementTasks);
553
+ // Other stories may have merged while this one ran: judge the branch
554
+ // against the epic as it is now.
555
+ yield* catchUp(story, git);
556
+ yield* repairPerimeter;
291
557
  let judgeNote;
292
558
  if (options.judge !== undefined) {
293
559
  const judge = options.judge;
294
560
  for (let round = 1; round <= judgeRounds; round += 1) {
295
- const diff = yield* storyContext.git.diffVsBase(epicBranch);
561
+ const diff = yield* git.diffVsBase(epicBranch);
296
562
  if (diff.trim().length === 0) {
297
563
  // Nothing to judge is a deterministic failure, not a model call:
298
564
  // a model asked to score an empty diff scores the prompt instead.
@@ -306,24 +572,16 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
306
572
  if (round >= judgeRounds) {
307
573
  return yield* failed(story, `judge not cleared after ${judgeRounds} round(s):\n${issueLines(verdict)}`);
308
574
  }
309
- const feedback = yield* makeChat(storyContext.coder, {
310
- system,
311
- events,
312
- agent: "coder"
313
- });
314
- yield* blockedOr(feedback.ask([
575
+ yield* guarded(coderTurn([
315
576
  `The story "${story.title}" scored below the bar. Close these gaps without`,
316
577
  "weakening any test and without leaving your owned paths, then stop:",
317
578
  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`);
579
+ ].join("\n")), Effect.void);
580
+ yield* repairPerimeter;
581
+ yield* commitGreen("address judge feedback", "while addressing judge feedback");
324
582
  }
325
583
  }
326
- const changed = yield* storyContext.git.changedFilesVsBase(epicBranch);
584
+ const changed = yield* git.changedFilesVsBase(epicBranch);
327
585
  yield* enforcePerimeter(changed, story);
328
586
  const totals = seats.totals === undefined ? undefined : yield* seats.totals;
329
587
  return { judge: judgeNote, totals };
@@ -341,9 +599,6 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
341
599
  judge: "merged on a previous run"
342
600
  });
343
601
  }
344
- if (options.setup !== undefined) {
345
- yield* stage(events, `story ${story.id}: setup`, options.setup(state.worktree));
346
- }
347
602
  const result = yield* Effect.scoped(implementStory(story, state));
348
603
  yield* integrate(story, state.branch);
349
604
  yield* saveVersioned(files, statePath(story), StoryStateVersion, StoryState, StoryState.make({ ...state, status: "merged" }));
@@ -365,7 +620,12 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
365
620
  if (stored !== undefined && stored.status !== "merged") {
366
621
  yield* saveVersioned(files, path, StoryStateVersion, StoryState, StoryState.make({ ...stored, status: "failed" })).pipe(Effect.ignore);
367
622
  }
368
- return {
623
+ const interruption = error._tag === "EpicCheckoutDirty"
624
+ ? "halt"
625
+ : isOutageMessage(reason)
626
+ ? "outage"
627
+ : undefined;
628
+ const completion = {
369
629
  story,
370
630
  outcome: StoryOutcome.make({
371
631
  id: story.id,
@@ -373,8 +633,10 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
373
633
  status: "failed",
374
634
  reason,
375
635
  ...(stored === undefined ? {} : { branch: stored.branch })
376
- })
636
+ }),
637
+ ...(interruption === undefined ? {} : { interruption })
377
638
  };
639
+ return completion;
378
640
  })));
379
641
  const outcomes = yield* Ref.make([]);
380
642
  const running = yield* Ref.make(new Set());
@@ -406,14 +668,20 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
406
668
  yield* board.wait(id, reason);
407
669
  }
408
670
  });
671
+ /** Why no further story may start in this run; stories already running finish. */
672
+ let halted;
673
+ /** Stories retried once after the serving engine recovered. */
674
+ const recovered = new Set();
409
675
  yield* Effect.scoped(Effect.gen(function* () {
410
676
  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))));
677
+ if (halted === undefined) {
678
+ const current = yield* progress;
679
+ const ready = readyStories(plan, current);
680
+ const slots = concurrency - current.running.size;
681
+ for (const story of ready.slice(0, Math.max(0, slots))) {
682
+ yield* Ref.update(running, (set) => new Set([...set, story.id]));
683
+ yield* Effect.forkScoped(attempt(story).pipe(Effect.flatMap((completion) => Queue.offer(completions, completion))));
684
+ }
417
685
  }
418
686
  if ((yield* Ref.get(running)).size === 0) {
419
687
  break;
@@ -424,6 +692,27 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
424
692
  next.delete(completion.story.id);
425
693
  return next;
426
694
  });
695
+ const reason = completion.outcome.reason ?? "failed";
696
+ if (completion.interruption === "outage" && halted === undefined) {
697
+ if (options.awaitRecovery !== undefined && !recovered.has(completion.story.id)) {
698
+ recovered.add(completion.story.id);
699
+ yield* events.publish(Info.make({
700
+ message: `story ${completion.story.id}: the serving engine is down; waiting for it to recover, then retrying the story`
701
+ }));
702
+ const back = yield* Effect.result(options.awaitRecovery(reason));
703
+ if (back._tag === "Success") {
704
+ // Not recorded: the story is ready again and relaunches.
705
+ continue;
706
+ }
707
+ halted = `the serving engine did not recover (${describeFlowError(back.failure)})`;
708
+ }
709
+ else {
710
+ halted = `the serving engine is down: ${reason}`;
711
+ }
712
+ }
713
+ if (completion.interruption === "halt" && halted === undefined) {
714
+ halted = reason;
715
+ }
427
716
  yield* record(completion.outcome);
428
717
  const outcome = completion.outcome;
429
718
  if (outcome.status === "done") {
@@ -450,6 +739,19 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
450
739
  }
451
740
  }
452
741
  }));
742
+ if (halted !== undefined) {
743
+ const stopped = halted;
744
+ yield* events.publish(Info.make({ message: `epic ${plan.epicId}: stopped launching stories — ${stopped}` }));
745
+ const known = new Set((yield* Ref.get(outcomes)).map((outcome) => outcome.id));
746
+ for (const story of plan.stories) {
747
+ if (known.has(story.id)) {
748
+ continue;
749
+ }
750
+ const reason = `not started: ${stopped}`;
751
+ yield* record(StoryOutcome.make({ id: story.id, title: story.title, status: "waiting", reason }));
752
+ yield* board.wait(story.id, reason);
753
+ }
754
+ }
453
755
  const recorded = yield* Ref.get(outcomes);
454
756
  const ordered = plan.stories.flatMap((story) => {
455
757
  const outcome = recorded.find((candidate) => candidate.id === story.id);