@llm4ts/flow 1.0.0 → 2.1.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.
package/src/BoardSync.ts CHANGED
@@ -7,7 +7,7 @@ import { FlowAborted, type FlowError } from "./FlowError.ts"
7
7
  import { loadVersioned, saveVersioned, type PlainFileStoreShape } from "./Persistence.ts"
8
8
 
9
9
  // The progress board of the conversion scenario: a port with the work-item
10
- // lifecycle (plan → start → complete/fail/skip) and two adapters — a local
10
+ // lifecycle (plan → start → complete/fail/wait/skip) and two adapters — a local
11
11
  // file board (the default, fully offline: board.json + rendered board.md)
12
12
  // and an Azure DevOps adapter (the stretch goal) mapping the same lifecycle
13
13
  // onto work items. Flows depend only on the port; which board the client
@@ -15,7 +15,14 @@ import { loadVersioned, saveVersioned, type PlainFileStoreShape } from "./Persis
15
15
 
16
16
  export const BoardVersion = 1
17
17
 
18
- export const BoardStatus = Schema.Literals(["planned", "active", "done", "failed", "skipped"])
18
+ export const BoardStatus = Schema.Literals([
19
+ "planned",
20
+ "active",
21
+ "waiting",
22
+ "done",
23
+ "failed",
24
+ "skipped"
25
+ ])
19
26
  export type BoardStatus = typeof BoardStatus.Type
20
27
 
21
28
  export class BoardItem extends Schema.Class<BoardItem>("BoardItem")({
@@ -25,7 +32,7 @@ export class BoardItem extends Schema.Class<BoardItem>("BoardItem")({
25
32
  status: BoardStatus,
26
33
  branch: Schema.optionalKey(Schema.String),
27
34
  reportPath: Schema.optionalKey(Schema.String),
28
- /** Free-form note: a failure reason, a skip rationale, a triage disposition. */
35
+ /** Free-form note: a failure reason, what an item waits for, a triage disposition. */
29
36
  detail: Schema.optionalKey(Schema.String),
30
37
  /** ESTIMATES, never measurements — see EstimatedUsage (ADR 0012). */
31
38
  estimatedTokens: Schema.optionalKey(Schema.Int),
@@ -51,13 +58,20 @@ export interface BoardSyncShape {
51
58
  readonly start: (id: string) => Effect.Effect<void, FlowError>
52
59
  readonly complete: (id: string, result: BoardItemResult) => Effect.Effect<void, FlowError>
53
60
  readonly fail: (id: string, reason: string) => Effect.Effect<void, FlowError>
61
+ /** Final: the item is deliberately left out (a page triaged as dead, say). */
54
62
  readonly skip: (id: string, reason: string) => Effect.Effect<void, FlowError>
63
+ /**
64
+ * On hold: the item waits for something that failed (a predecessor story)
65
+ * and runs once that is fixed — unlike `skip`, it is not final.
66
+ */
67
+ readonly wait: (id: string, reason: string) => Effect.Effect<void, FlowError>
55
68
  readonly snapshot: Effect.Effect<Board, FlowError>
56
69
  }
57
70
 
58
71
  const sectionOrder: ReadonlyArray<readonly [BoardStatus, string]> = [
59
72
  ["active", "Active"],
60
73
  ["planned", "Planned"],
74
+ ["waiting", "Waiting"],
61
75
  ["done", "Done"],
62
76
  ["failed", "Failed"],
63
77
  ["skipped", "Skipped"]
@@ -193,6 +207,8 @@ export const makeLocalBoardSync = (
193
207
  update(id, (item) => BoardItem.make({ ...item, status: "failed", detail: reason })),
194
208
  skip: (id, reason) =>
195
209
  update(id, (item) => BoardItem.make({ ...item, status: "skipped", detail: reason })),
210
+ wait: (id, reason) =>
211
+ update(id, (item) => BoardItem.make({ ...item, status: "waiting", detail: reason })),
196
212
  snapshot: load
197
213
  }
198
214
  }
@@ -213,6 +229,7 @@ export const composeBoardSync = (boards: ReadonlyArray<BoardSyncShape>): BoardSy
213
229
  complete: (id, result) => each((board) => board.complete(id, result)),
214
230
  fail: (id, reason) => each((board) => board.fail(id, reason)),
215
231
  skip: (id, reason) => each((board) => board.skip(id, reason)),
232
+ wait: (id, reason) => each((board) => board.wait(id, reason)),
216
233
  snapshot:
217
234
  first === undefined
218
235
  ? Effect.succeed(Board.make({ title: "empty", items: [] }))
@@ -242,8 +259,9 @@ const markerQuery = (tag: string, id: string): string =>
242
259
  * The stretch adapter (ADR 0012): the same lifecycle mapped onto Azure DevOps
243
260
  * work items through the az-CLI AzureDevOpsTool (ADR 0011). Idempotent by
244
261
  * title marker — `[<id>]` plus the board tag — so a re-run finds its items
245
- * instead of duplicating them. Failure and skip are tags plus a comment,
246
- * never invented states.
262
+ * instead of duplicating them. Failure, waiting and skip are tags plus a
263
+ * comment, never invented states; a waiting tag is lifted when the item is
264
+ * started again.
247
265
  */
248
266
  export const makeAdoBoardSync = Effect.fn("@llm4ts/flow/BoardSync.makeAdo")(function* (
249
267
  ado: AzureDevOpsToolShape,
@@ -334,6 +352,12 @@ export const makeAdoBoardSync = Effect.fn("@llm4ts/flow/BoardSync.makeAdo")(func
334
352
  yield* ado.editTags(workItem, [`${tag}-skipped`], [])
335
353
  yield* ado.writeComment(workItem, `SKIPPED: ${reason}`)
336
354
  }),
355
+ wait: (id, reason) =>
356
+ Effect.gen(function* () {
357
+ const workItem = yield* lookup(id)
358
+ yield* ado.editTags(workItem, [`${tag}-waiting`], [])
359
+ yield* ado.writeComment(workItem, `WAITING: ${reason}`)
360
+ }),
337
361
  snapshot: Effect.gen(function* () {
338
362
  const found = yield* ado.wiqlIds(
339
363
  `SELECT [System.Id] FROM WorkItems WHERE [System.Tags] CONTAINS ${quoteWiql(tag)}`
@@ -344,6 +368,9 @@ export const makeAdoBoardSync = Effect.fn("@llm4ts/flow/BoardSync.makeAdo")(func
344
368
  const match = /^\[([^\]]+)\] (.*)$/.exec(workItem.title)
345
369
  const failed = workItem.tags.includes(`${tag}-failed`)
346
370
  const skipped = workItem.tags.includes(`${tag}-skipped`)
371
+ const waiting = workItem.tags.includes(`${tag}-waiting`)
372
+ // A live state outranks a waiting tag: an item started again after
373
+ // its predecessor was fixed is active, whatever tag it still carries.
347
374
  const status: BoardStatus = failed
348
375
  ? "failed"
349
376
  : skipped
@@ -352,7 +379,9 @@ export const makeAdoBoardSync = Effect.fn("@llm4ts/flow/BoardSync.makeAdo")(func
352
379
  ? "done"
353
380
  : workItem.state === states.active
354
381
  ? "active"
355
- : "planned"
382
+ : waiting
383
+ ? "waiting"
384
+ : "planned"
356
385
  items.push(
357
386
  BoardItem.make({
358
387
  id: match?.[1] ?? String(workItemId),
package/src/Context.ts CHANGED
@@ -44,14 +44,11 @@ export const cap = (text: string, limit: number): CappedText => {
44
44
 
45
45
  export const defaultContextBudget = 400_000
46
46
 
47
- /**
48
- * The default character budget: `LLM4TS_CONTEXT_BUDGET`, else the deprecated
49
- * `LLM4TS_JUDGE_SOURCES_LIMIT`, else 400_000.
50
- */
47
+ /** The default character budget: `LLM4TS_CONTEXT_BUDGET`, else 400_000. */
51
48
  export const budget = (
52
49
  environment: Readonly<Record<string, string | undefined>> = process.env
53
50
  ): number => {
54
- const raw = environment["LLM4TS_CONTEXT_BUDGET"] ?? environment["LLM4TS_JUDGE_SOURCES_LIMIT"]
51
+ const raw = environment["LLM4TS_CONTEXT_BUDGET"]
55
52
  if (raw === undefined) {
56
53
  return defaultContextBudget
57
54
  }
package/src/FlowError.ts CHANGED
@@ -2,16 +2,16 @@ import * as Schema from "effect/Schema"
2
2
  import { Capability } from "@llm4ts/core/Capability"
3
3
  import { LlmError } from "@llm4ts/core/Errors"
4
4
 
5
- export class PersistenceError extends Schema.TaggedErrorClass<PersistenceError>()("Persistence", {
5
+ export class PersistenceError extends Schema.TaggedError<PersistenceError>()("Persistence", {
6
6
  message: Schema.String,
7
7
  cause: Schema.optionalKey(Schema.Defect())
8
8
  }) {}
9
9
 
10
- export class PlanParseError extends Schema.TaggedErrorClass<PlanParseError>()("PlanParse", {
10
+ export class PlanParseError extends Schema.TaggedError<PlanParseError>()("PlanParse", {
11
11
  message: Schema.String
12
12
  }) {}
13
13
 
14
- export class UnsupportedSchemaVersion extends Schema.TaggedErrorClass<UnsupportedSchemaVersion>()(
14
+ export class UnsupportedSchemaVersion extends Schema.TaggedError<UnsupportedSchemaVersion>()(
15
15
  "UnsupportedSchemaVersion",
16
16
  {
17
17
  path: Schema.String,
@@ -24,15 +24,12 @@ export class UnsupportedSchemaVersion extends Schema.TaggedErrorClass<Unsupporte
24
24
  }
25
25
  }
26
26
 
27
- export class WorkspacePathError extends Schema.TaggedErrorClass<WorkspacePathError>()(
28
- "WorkspacePath",
29
- {
30
- path: Schema.String,
31
- message: Schema.String
32
- }
33
- ) {}
27
+ export class WorkspacePathError extends Schema.TaggedError<WorkspacePathError>()("WorkspacePath", {
28
+ path: Schema.String,
29
+ message: Schema.String
30
+ }) {}
34
31
 
35
- export class WorkspaceLimitError extends Schema.TaggedErrorClass<WorkspaceLimitError>()(
32
+ export class WorkspaceLimitError extends Schema.TaggedError<WorkspaceLimitError>()(
36
33
  "WorkspaceLimit",
37
34
  {
38
35
  operation: Schema.String,
@@ -47,18 +44,18 @@ export class WorkspaceLimitError extends Schema.TaggedErrorClass<WorkspaceLimitE
47
44
  }
48
45
  }
49
46
 
50
- export class WorkspaceIoError extends Schema.TaggedErrorClass<WorkspaceIoError>()("WorkspaceIo", {
47
+ export class WorkspaceIoError extends Schema.TaggedError<WorkspaceIoError>()("WorkspaceIo", {
51
48
  operation: Schema.String,
52
49
  path: Schema.String,
53
50
  message: Schema.String,
54
51
  cause: Schema.optionalKey(Schema.Defect())
55
52
  }) {}
56
53
 
57
- export class FlowAborted extends Schema.TaggedErrorClass<FlowAborted>()("Aborted", {
54
+ export class FlowAborted extends Schema.TaggedError<FlowAborted>()("Aborted", {
58
55
  message: Schema.String
59
56
  }) {}
60
57
 
61
- export class ProcessError extends Schema.TaggedErrorClass<ProcessError>()("Process", {
58
+ export class ProcessError extends Schema.TaggedError<ProcessError>()("Process", {
62
59
  message: Schema.String,
63
60
  detail: Schema.String
64
61
  }) {}
@@ -80,7 +77,7 @@ export const describeFlowError = (error: unknown): string => {
80
77
  return detail.length === 0 || base.includes(detail) ? base : `${base}: ${detail}`
81
78
  }
82
79
 
83
- export class FlowLlmError extends Schema.TaggedErrorClass<FlowLlmError>()("Llm", {
80
+ export class FlowLlmError extends Schema.TaggedError<FlowLlmError>()("Llm", {
84
81
  message: Schema.String,
85
82
  cause: Schema.optionalKey(LlmError)
86
83
  }) {
@@ -91,7 +88,7 @@ export class FlowLlmError extends Schema.TaggedErrorClass<FlowLlmError>()("Llm",
91
88
  })
92
89
  }
93
90
 
94
- export class FlowCapabilityDenied extends Schema.TaggedErrorClass<FlowCapabilityDenied>()(
91
+ export class FlowCapabilityDenied extends Schema.TaggedError<FlowCapabilityDenied>()(
95
92
  "CapabilityDenied",
96
93
  {
97
94
  capability: Capability,
@@ -105,7 +102,7 @@ export class FlowCapabilityDenied extends Schema.TaggedErrorClass<FlowCapability
105
102
  }
106
103
  }
107
104
 
108
- export class ColumnNotFound extends Schema.TaggedErrorClass<ColumnNotFound>()("ColumnNotFound", {
105
+ export class ColumnNotFound extends Schema.TaggedError<ColumnNotFound>()("ColumnNotFound", {
109
106
  title: Schema.String,
110
107
  available: Schema.Array(Schema.String)
111
108
  }) {
@@ -114,7 +111,7 @@ export class ColumnNotFound extends Schema.TaggedErrorClass<ColumnNotFound>()("C
114
111
  }
115
112
  }
116
113
 
117
- export class BudgetExceeded extends Schema.TaggedErrorClass<BudgetExceeded>()("BudgetExceeded", {
114
+ export class BudgetExceeded extends Schema.TaggedError<BudgetExceeded>()("BudgetExceeded", {
118
115
  metric: Schema.Literals(["tokens", "costUsd"]),
119
116
  limit: Schema.Number,
120
117
  actual: Schema.Number
@@ -125,19 +122,16 @@ export class BudgetExceeded extends Schema.TaggedErrorClass<BudgetExceeded>()("B
125
122
  }
126
123
 
127
124
  /** A story plan that failed deterministic validation — every violation, not the first (ADR 0013). */
128
- export class StoryPlanInvalid extends Schema.TaggedErrorClass<StoryPlanInvalid>()(
129
- "StoryPlanInvalid",
130
- {
131
- violations: Schema.Array(Schema.String)
132
- }
133
- ) {
125
+ export class StoryPlanInvalid extends Schema.TaggedError<StoryPlanInvalid>()("StoryPlanInvalid", {
126
+ violations: Schema.Array(Schema.String)
127
+ }) {
134
128
  get message(): string {
135
129
  return `story plan invalid:\n${this.violations.map((violation) => `- ${violation}`).join("\n")}`
136
130
  }
137
131
  }
138
132
 
139
133
  /** A story branch changed paths outside the story's declared `owned` set. */
140
- export class PerimeterViolation extends Schema.TaggedErrorClass<PerimeterViolation>()(
134
+ export class PerimeterViolation extends Schema.TaggedError<PerimeterViolation>()(
141
135
  "PerimeterViolation",
142
136
  {
143
137
  story: Schema.String,
@@ -158,7 +152,7 @@ export class PerimeterViolation extends Schema.TaggedErrorClass<PerimeterViolati
158
152
  }
159
153
 
160
154
  /** The coder ended a story with `BLOCKED_ON:` — unplanned work belongs to another story. */
161
- export class MissingDependency extends Schema.TaggedErrorClass<MissingDependency>()(
155
+ export class MissingDependency extends Schema.TaggedError<MissingDependency>()(
162
156
  "MissingDependency",
163
157
  {
164
158
  story: Schema.String,
@@ -171,7 +165,7 @@ export class MissingDependency extends Schema.TaggedErrorClass<MissingDependency
171
165
  }
172
166
 
173
167
  /** A story branch did not merge cleanly into the epic branch; the merge was aborted. */
174
- export class MergeConflict extends Schema.TaggedErrorClass<MergeConflict>()("MergeConflict", {
168
+ export class MergeConflict extends Schema.TaggedError<MergeConflict>()("MergeConflict", {
175
169
  branch: Schema.String,
176
170
  into: Schema.String,
177
171
  paths: Schema.Array(Schema.String)
@@ -183,7 +177,7 @@ export class MergeConflict extends Schema.TaggedErrorClass<MergeConflict>()("Mer
183
177
  }
184
178
 
185
179
  /** One story failed; carries the story id so a fail-fast run names its cause. */
186
- export class StoryFailed extends Schema.TaggedErrorClass<StoryFailed>()("StoryFailed", {
180
+ export class StoryFailed extends Schema.TaggedError<StoryFailed>()("StoryFailed", {
187
181
  story: Schema.String,
188
182
  reason: Schema.String
189
183
  }) {
package/src/Pack.ts CHANGED
@@ -202,7 +202,14 @@ export const loadPack = Effect.fn("@llm4ts/flow/Pack.load")(function* (
202
202
  .sort(([left], [right]) => left.localeCompare(right))
203
203
  .map(([name, text]) => parseReviewer(name, text))
204
204
  const fields = manifest.fields
205
- const programFiles = fields["programFiles"]
205
+ if (fields["programFiles"] !== undefined) {
206
+ return yield* PlanParseError.make({
207
+ message:
208
+ "pack manifest 'programFiles:' was renamed 'program-files:' in llm4ts 2.0 — " +
209
+ "rename the field (the value is unchanged)"
210
+ })
211
+ }
212
+ const programFiles = fields["program-files"]
206
213
  // Validated at load so a mis-typed template fails the pack, not a later
207
214
  // phase; substitution cannot introduce invalid syntax because the fallback
208
215
  // probe uses an alphanumeric stand-in and real substitutions are escaped
package/src/PageSpec.ts CHANGED
@@ -68,7 +68,8 @@ export class PageApiCall extends Schema.Class<PageApiCall>("PageApiCall")({
68
68
  method: Schema.String,
69
69
  path: Schema.String,
70
70
  /** The ESB service behind the legacy endpoint, when known. */
71
- esbService: Schema.optionalKey(Schema.String),
71
+ /** An ESB service identifier (`ESB_ACCT_LIST`), never prose: unknown means omit. */
72
+ esbService: Schema.optionalKey(Schema.String.check(Schema.isPattern(/^[A-Za-z0-9_.:/-]+$/))),
72
73
  request: Schema.Array(FieldMapping).pipe(
73
74
  Schema.withConstructorDefault(Effect.succeed(emptyMappings)),
74
75
  Schema.withDecodingDefaultKey(Effect.succeed(emptyMappings))
@@ -154,7 +155,7 @@ export const pageSpecShapeHint =
154
155
  "The block must be exactly: { page, route, title, complexity: low|medium|high, " +
155
156
  "forms: [{ name, action, fields: [{ name, label, type, required?, validations: [{ rule, message?, enforcedAt: client|server|both }] }] }], " +
156
157
  "dtos: [{ legacyName, domainName, fields: [{ legacyName, domainName, type }] }], " +
157
- "apiCalls: [{ operation, method, path, esbService (the ESB service the legacy call goes through — omit only when there is none), request: [{ legacyName, domainName, type }], response: [{ legacyName, domainName, type }] for an ad-hoc object, or responseDto: <domainName of one of the dtos> with responseShape: single|list when the endpoint returns that DTO or a list of it (a table screen is a list) }], " +
158
+ "apiCalls: [{ operation, method, path, esbService (the identifier of the ESB service the legacy call goes through, such as ESB_ACCT_LIST — omit it when unknown or absent; never a sentence), request: [{ legacyName, domainName, type }], response: [{ legacyName, domainName, type }] for an ad-hoc object, or responseDto: <domainName of one of the dtos> with responseShape: single|list when the endpoint returns that DTO or a list of it (a table screen is a list) }], " +
158
159
  "navigation: { inbound: [string], outbound: [string], steps: [string] }, sessionState: [string], openQuestions: [string] }. " +
159
160
  "No other keys (no id, url, queryParams, esbCall, trigger, serverController); every apiCalls entry is an object with operation/method/path; " +
160
161
  "put anything that does not fit into the prose sections or openQuestions."
@@ -101,7 +101,7 @@ const issues = (
101
101
  * A spec'd program with NO matching changed file is a deterministic gate
102
102
  * failure, not a silent pass. Skipping it would let the branch clear a bar the
103
103
  * old whole-branch judge would have failed. It also surfaces a mis-set
104
- * `programFiles:` immediately — the top documented risk of the per-program
104
+ * `program-files:` immediately — the top documented risk of the per-program
105
105
  * design — instead of quietly degrading coverage.
106
106
  */
107
107
  export const unimplemented = (programs: ReadonlyArray<string>): ReviewResult =>
@@ -112,8 +112,8 @@ export const unimplemented = (programs: ReadonlyArray<string>): ReviewResult =>
112
112
  title: `judge[${program}]: spec'd but no implementation files changed`,
113
113
  description:
114
114
  `${program} has a committed spec but no file on this branch matches the pack's ` +
115
- "programFiles regex for it. Either the program is unimplemented, or the pack's " +
116
- "`programFiles:` template does not match this repo's layout — check that before " +
115
+ "program-files regex for it. Either the program is unimplemented, or the pack's " +
116
+ "`program-files:` template does not match this repo's layout — check that before " +
117
117
  "assuming the former."
118
118
  })
119
119
  ),
package/src/Stories.ts CHANGED
@@ -73,7 +73,8 @@ export class StoryState extends Schema.Class<StoryState>("StoryState")({
73
73
 
74
74
  export const EpicReportVersion = 1
75
75
 
76
- export const OutcomeStatus = Schema.Literals(["done", "failed", "skipped"])
76
+ /** `waiting`: on hold behind a failed predecessor, runs once that is fixed. */
77
+ export const OutcomeStatus = Schema.Literals(["done", "failed", "waiting"])
77
78
  export type OutcomeStatus = typeof OutcomeStatus.Type
78
79
 
79
80
  export class StoryOutcome extends Schema.Class<StoryOutcome>("StoryOutcome")({
@@ -81,7 +82,7 @@ export class StoryOutcome extends Schema.Class<StoryOutcome>("StoryOutcome")({
81
82
  title: Schema.String,
82
83
  status: OutcomeStatus,
83
84
  branch: Schema.optionalKey(Schema.String),
84
- /** Failure or skip reason. */
85
+ /** Failure reason, or what a waiting story waits for. */
85
86
  reason: Schema.optionalKey(Schema.String),
86
87
  judge: Schema.optionalKey(Schema.String),
87
88
  /** ESTIMATES, never measurements (ADR 0012). */
@@ -111,7 +112,7 @@ export const renderEpicReport = (report: EpicReport): string => {
111
112
  "> the CLI seats report no usage. They are not measurements.",
112
113
  "",
113
114
  `- Epic branch: \`${report.epicBranch}\``,
114
- `- Stories: ${report.stories.length} (done ${report.count("done")}, failed ${report.count("failed")}, skipped ${report.count("skipped")})`,
115
+ `- Stories: ${report.stories.length} (done ${report.count("done")}, failed ${report.count("failed")}, waiting ${report.count("waiting")})`,
115
116
  "",
116
117
  "| Story | Status | Branch | Est. tokens | Est. cost | Note |",
117
118
  "| --- | --- | --- | --- | --- | --- |"
@@ -393,6 +394,9 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
393
394
  if (yield* context.git.branchExists(stored.branch)) {
394
395
  yield* context.git.deleteBranch(stored.branch)
395
396
  }
397
+ // The task checkpoint belongs to the old branch: left in place, the
398
+ // fresh branch would inherit "every task complete" and skip the coder.
399
+ yield* files.remove(planPath(story))
396
400
  stored = undefined
397
401
  }
398
402
  if (stored !== undefined && stored.status === "merged") {
@@ -477,6 +481,11 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
477
481
  const judge = options.judge
478
482
  for (let round = 1; round <= judgeRounds; round += 1) {
479
483
  const diff = yield* storyContext.git.diffVsBase(epicBranch)
484
+ if (diff.trim().length === 0) {
485
+ // Nothing to judge is a deterministic failure, not a model call:
486
+ // a model asked to score an empty diff scores the prompt instead.
487
+ return yield* failed(story, "the story branch has no changes against the epic branch")
488
+ }
480
489
  const verdict = yield* judge(story, diff)
481
490
  if (verdict.isClean) {
482
491
  judgeNote = `judge cleared (round ${round})`
@@ -602,7 +611,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
602
611
  return {
603
612
  done: byStatus("done"),
604
613
  failed: byStatus("failed"),
605
- skipped: byStatus("skipped"),
614
+ waiting: byStatus("waiting"),
606
615
  running: yield* Ref.get(running)
607
616
  }
608
617
  })
@@ -610,7 +619,8 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
610
619
  const record = (outcome: StoryOutcome): Effect.Effect<void> =>
611
620
  Ref.update(outcomes, (current) => [...current, outcome])
612
621
 
613
- const skipDependents = (story: Story): Effect.Effect<void, FlowError> =>
622
+ /** Dependents of a failed story go on hold: they run once it is fixed and rerun. */
623
+ const holdDependents = (story: Story): Effect.Effect<void, FlowError> =>
614
624
  Effect.gen(function* () {
615
625
  const current = yield* progress
616
626
  for (const id of dependentsOf(plan, story.id)) {
@@ -619,13 +629,13 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
619
629
  dependent === undefined ||
620
630
  current.done.has(id) ||
621
631
  current.failed.has(id) ||
622
- current.skipped.has(id)
632
+ current.waiting.has(id)
623
633
  ) {
624
634
  continue
625
635
  }
626
- const reason = `blocked by ${story.id}`
627
- yield* record(StoryOutcome.make({ id, title: dependent.title, status: "skipped", reason }))
628
- yield* board.skip(id, reason)
636
+ const reason = `waiting for ${story.id}`
637
+ yield* record(StoryOutcome.make({ id, title: dependent.title, status: "waiting", reason }))
638
+ yield* board.wait(id, reason)
629
639
  }
630
640
  })
631
641
 
@@ -673,7 +683,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
673
683
  reason: outcome.reason ?? "failed"
674
684
  })
675
685
  }
676
- yield* skipDependents(completion.story)
686
+ yield* holdDependents(completion.story)
677
687
  }
678
688
  }
679
689
  })
package/src/StoryPlan.ts CHANGED
@@ -182,7 +182,8 @@ export const topologicalWaves = (plan: StoryPlan): ReadonlyArray<ReadonlyArray<s
182
182
  export interface StoryProgress {
183
183
  readonly done: ReadonlySet<string>
184
184
  readonly failed: ReadonlySet<string>
185
- readonly skipped: ReadonlySet<string>
185
+ /** On hold behind a failed predecessor. */
186
+ readonly waiting: ReadonlySet<string>
186
187
  readonly running: ReadonlySet<string>
187
188
  }
188
189
 
@@ -192,7 +193,7 @@ export const readyStories = (plan: StoryPlan, progress: StoryProgress): Readonly
192
193
  (story) =>
193
194
  !progress.done.has(story.id) &&
194
195
  !progress.failed.has(story.id) &&
195
- !progress.skipped.has(story.id) &&
196
+ !progress.waiting.has(story.id) &&
196
197
  !progress.running.has(story.id) &&
197
198
  story.dependsOn.every((dependency) => progress.done.has(dependency))
198
199
  )