@llm4ts/flow 2.49.0 → 2.51.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/dist/FlowEvents.d.ts +2 -0
- package/dist/FlowEvents.d.ts.map +1 -1
- package/dist/FlowEvents.js +3 -1
- package/dist/FlowEvents.js.map +1 -1
- package/dist/PlanExecution.d.ts +7 -0
- package/dist/PlanExecution.d.ts.map +1 -1
- package/dist/PlanExecution.js +14 -0
- package/dist/PlanExecution.js.map +1 -1
- package/dist/Recheck.d.ts +69 -0
- package/dist/Recheck.d.ts.map +1 -0
- package/dist/Recheck.js +111 -0
- package/dist/Recheck.js.map +1 -0
- package/dist/Review.d.ts +32 -33
- package/dist/Review.d.ts.map +1 -1
- package/dist/Review.js +104 -45
- package/dist/Review.js.map +1 -1
- package/dist/ReviewCache.d.ts +1 -1
- package/dist/ReviewCache.d.ts.map +1 -1
- package/dist/ReviewCache.js +1 -1
- package/dist/ReviewCache.js.map +1 -1
- package/dist/ReviewModel.d.ts +36 -0
- package/dist/ReviewModel.d.ts.map +1 -0
- package/dist/ReviewModel.js +50 -0
- package/dist/ReviewModel.js.map +1 -0
- package/dist/Stories.d.ts +13 -2
- package/dist/Stories.d.ts.map +1 -1
- package/dist/Stories.js +239 -89
- package/dist/Stories.js.map +1 -1
- package/package.json +4 -2
- package/src/FlowEvents.ts +3 -1
- package/src/PlanExecution.ts +21 -0
- package/src/Recheck.ts +160 -0
- package/src/Review.ts +178 -58
- package/src/ReviewCache.ts +1 -1
- package/src/ReviewModel.ts +63 -0
- package/src/Stories.ts +233 -17
package/src/Stories.ts
CHANGED
|
@@ -20,7 +20,7 @@ import type { LlmChunk, TokenUsage } from "@llm4ts/core/Models"
|
|
|
20
20
|
import type { LlmError } from "@llm4ts/core/Errors"
|
|
21
21
|
import { BoardItem, eventedBoard, type BoardSyncShape } from "./BoardSync.ts"
|
|
22
22
|
import { makeChat } from "./Chat.ts"
|
|
23
|
-
import { implementPlanFlow, type TaskOverrides } from "./Flow.ts"
|
|
23
|
+
import { flowReviewer, implementPlanFlow, type TaskOverrides } from "./Flow.ts"
|
|
24
24
|
import {
|
|
25
25
|
GateBaseline,
|
|
26
26
|
baselineKey,
|
|
@@ -77,6 +77,8 @@ import {
|
|
|
77
77
|
applyTriage,
|
|
78
78
|
isBlocking,
|
|
79
79
|
loadRepoReviewRules,
|
|
80
|
+
minimalReviewers,
|
|
81
|
+
reviewAndFixLoop,
|
|
80
82
|
nonBlockingIssues,
|
|
81
83
|
type FixPromptOptions,
|
|
82
84
|
type GateTriageOptions
|
|
@@ -87,6 +89,7 @@ import {
|
|
|
87
89
|
defaultOracleRules,
|
|
88
90
|
parseUnifiedDiff
|
|
89
91
|
} from "./OracleGuard.ts"
|
|
92
|
+
import { ReviewMemory } from "./Recheck.ts"
|
|
90
93
|
import { cachedValue, fingerprintOf } from "./ReviewCache.ts"
|
|
91
94
|
import { withContract, type ContractProfile } from "./AutonomyContract.ts"
|
|
92
95
|
import { fabricatedStatusIssues, unverifiedClaims } from "./Evidence.ts"
|
|
@@ -752,6 +755,14 @@ export interface StoriesOptions {
|
|
|
752
755
|
* story's plan (coder, review, gates, commit). Default 2: one revision.
|
|
753
756
|
*/
|
|
754
757
|
readonly judgeRounds?: number
|
|
758
|
+
/**
|
|
759
|
+
* Where the review lenses run (ADR 0044): each task (default), or once per
|
|
760
|
+
* story, beside the judge, with incremental re-checks and one fix turn per
|
|
761
|
+
* round.
|
|
762
|
+
*/
|
|
763
|
+
readonly reviewAt?: "task" | "story"
|
|
764
|
+
/** Story mode: rounds of the merged story review. Default 3. Task mode ignores it. */
|
|
765
|
+
readonly reviewRounds?: number
|
|
755
766
|
/**
|
|
756
767
|
* When the epic gates are red after a story merges (the merge is undone),
|
|
757
768
|
* the story goes back to its worktree this many times: it catches up with
|
|
@@ -792,8 +803,9 @@ export interface StoriesOptions {
|
|
|
792
803
|
/**
|
|
793
804
|
* Coders one story may hold at once, its own included (ADR 0034): a
|
|
794
805
|
* ready task whose plan says it may runs beside the held coder, in its
|
|
795
|
-
* own worktree, on a coder that is free now
|
|
796
|
-
*
|
|
806
|
+
* own worktree, on a coder that is free now, when its `Owns:` paths are
|
|
807
|
+
* declared and overlap no running task's. Default 1: every task in order on
|
|
808
|
+
* the story's coder.
|
|
797
809
|
*/
|
|
798
810
|
readonly taskConcurrency?: number
|
|
799
811
|
/** Stop the epic at the first failed story instead of skipping its dependents. */
|
|
@@ -856,6 +868,10 @@ const combined = (first: ReviewResult, second: ReviewResult): ReviewResult =>
|
|
|
856
868
|
export const deferredPath = (stateDir: string, storyId: string): string =>
|
|
857
869
|
join(stateDir, `stories/${storyId}.deferred.md`)
|
|
858
870
|
|
|
871
|
+
/** Where a story review keeps its reviewers' memory between rounds and runs (ADR 0044). */
|
|
872
|
+
export const reviewStatePath = (stateDir: string, storyId: string): string =>
|
|
873
|
+
join(stateDir, `stories/${storyId}.review-state.json`)
|
|
874
|
+
|
|
859
875
|
/** A deferred finding as one Markdown bullet, prefixed with where it was found. */
|
|
860
876
|
export const deferredLine = (source: string, issue: ReviewIssue): string => {
|
|
861
877
|
const where =
|
|
@@ -950,8 +966,11 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
950
966
|
const concurrency = Math.max(1, options.concurrency ?? 3)
|
|
951
967
|
const judgeRounds = Math.max(1, options.judgeRounds ?? 2)
|
|
952
968
|
const mergeRevisions = Math.max(0, options.mergeRevisions ?? 1)
|
|
953
|
-
|
|
969
|
+
// Parallel tasks are opt-in (ADR 0034 amendment): one coder per story unless asked.
|
|
970
|
+
const taskConcurrency = Math.max(1, options.taskConcurrency ?? 1)
|
|
954
971
|
const deferring = options.deferNonBlocking === true
|
|
972
|
+
const reviewAtStory = options.reviewAt === "story"
|
|
973
|
+
const reviewRounds = Math.max(1, options.reviewRounds ?? 3)
|
|
955
974
|
const statePath = (story: Story): string => join(options.stateDir, `stories/${story.id}.json`)
|
|
956
975
|
const planPath = (story: Story): string => join(options.stateDir, `stories/${story.id}.plan.md`)
|
|
957
976
|
/** The judge's last verdict beside a fingerprint of what it judged. */
|
|
@@ -1898,7 +1917,10 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
1898
1917
|
// A lens's answer is kept beside the task and diff it answered, so a
|
|
1899
1918
|
// rerun over the same change asks nothing; every round is written to
|
|
1900
1919
|
// the story's findings log for people to read.
|
|
1901
|
-
|
|
1920
|
+
// Reviewing per story (ADR 0044), a task runs on its gates alone.
|
|
1921
|
+
...(reviewAtStory
|
|
1922
|
+
? { reviewers: [] }
|
|
1923
|
+
: { reviewCache: { files, dir: reviewCacheDir(story) } }),
|
|
1902
1924
|
onReview: (task, round, result, settled) =>
|
|
1903
1925
|
Effect.gen(function* () {
|
|
1904
1926
|
// A settled round's leftovers are deferred, not dropped: when the
|
|
@@ -1918,15 +1940,17 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
1918
1940
|
onTaskReply: (task, _reply, trailer, startedAt) => checkEvidence(task, trailer, startedAt),
|
|
1919
1941
|
// The repository's own review rules ride along as one extra lens
|
|
1920
1942
|
// (ADR 0027 decision 9), read from the epic checkout.
|
|
1921
|
-
repoRules: loadRepoReviewRules(files, context.workDir),
|
|
1943
|
+
...(reviewAtStory ? {} : { repoRules: loadRepoReviewRules(files, context.workDir) }),
|
|
1922
1944
|
...(options.votes === undefined ? {} : { votes: options.votes }),
|
|
1923
1945
|
...(options.fixer === undefined ? {} : { fixer: options.fixer }),
|
|
1924
1946
|
...(options.stall === undefined ? {} : { stall: options.stall }),
|
|
1925
|
-
...(options.reviewers === undefined ? {} : { reviewers: options.reviewers }),
|
|
1947
|
+
...(options.reviewers === undefined || reviewAtStory ? {} : { reviewers: options.reviewers }),
|
|
1926
1948
|
...(options.maxRounds === undefined ? {} : { maxRounds: options.maxRounds }),
|
|
1927
1949
|
...(deferring ? { settle: "blocking" as const } : {})
|
|
1928
1950
|
})
|
|
1929
1951
|
if (mergeFailure !== undefined) {
|
|
1952
|
+
// The tasks run again: what the story review remembered no longer holds.
|
|
1953
|
+
yield* files.remove(reviewStatePath(options.stateDir, story.id))
|
|
1930
1954
|
// The branch already caught up with the epic above: the gates in the
|
|
1931
1955
|
// worktree now see the combination that was red after the merge.
|
|
1932
1956
|
yield* appendRevision(
|
|
@@ -1984,11 +2008,17 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
1984
2008
|
verdict
|
|
1985
2009
|
)
|
|
1986
2010
|
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
2011
|
+
/**
|
|
2012
|
+
* One judge round over the story's branch: the verdict, published on the
|
|
2013
|
+
* lane, logged in the findings and kept beside a fingerprint of what was
|
|
2014
|
+
* judged. `empty` says the branch had no changes, so its owned code was
|
|
2015
|
+
* judged instead.
|
|
2016
|
+
*/
|
|
2017
|
+
const judgeOnce = (
|
|
2018
|
+
judge: NonNullable<StoriesOptions["judge"]>,
|
|
2019
|
+
round: number
|
|
2020
|
+
): Effect.Effect<{ readonly verdict: ReviewResult; readonly empty: boolean }, FlowError> =>
|
|
2021
|
+
Effect.gen(function* () {
|
|
1992
2022
|
const diff = yield* git.diffVsBase(epicBranch)
|
|
1993
2023
|
// No changes is not a failure by itself: the story's code may already
|
|
1994
2024
|
// be on the epic (an earlier story or run put it there). Judge that
|
|
@@ -2100,6 +2130,183 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
2100
2130
|
dimensions: verdict instanceof StoryVerdict ? verdict.dimensions : []
|
|
2101
2131
|
})
|
|
2102
2132
|
)
|
|
2133
|
+
return { verdict, empty }
|
|
2134
|
+
})
|
|
2135
|
+
|
|
2136
|
+
/**
|
|
2137
|
+
* The story review (ADR 0044): every lens and the judge on the story's
|
|
2138
|
+
* diff, one fix turn per round, re-checks after the first. Returns the
|
|
2139
|
+
* judge's note; fails the story when the judge is still not cleared.
|
|
2140
|
+
*/
|
|
2141
|
+
const reviewStory = (): Effect.Effect<string | undefined, FlowError> =>
|
|
2142
|
+
Effect.gen(function* () {
|
|
2143
|
+
const statePath = reviewStatePath(options.stateDir, story.id)
|
|
2144
|
+
const repoRules = yield* loadRepoReviewRules(files, context.workDir)
|
|
2145
|
+
const lenses = [
|
|
2146
|
+
...(options.reviewers ?? minimalReviewers),
|
|
2147
|
+
...(repoRules === undefined ? [] : [repoRules])
|
|
2148
|
+
]
|
|
2149
|
+
const judge = options.judge
|
|
2150
|
+
const latest = yield* Ref.make<ReviewResult | undefined>(undefined)
|
|
2151
|
+
const lastStanding = yield* Ref.make<ReviewResult | undefined>(undefined)
|
|
2152
|
+
// The fix turn is a fresh chat: it gets the story and where each finding points.
|
|
2153
|
+
const storyFixPrompt = (prompt: string, standing: ReviewResult | undefined): string => {
|
|
2154
|
+
const located = (standing?.issues ?? []).flatMap((issue) =>
|
|
2155
|
+
issue.file === undefined
|
|
2156
|
+
? []
|
|
2157
|
+
: [
|
|
2158
|
+
`- ${issue.title} (${issue.lens ?? "gates"}): ${issue.file}${
|
|
2159
|
+
issue.line === undefined ? "" : `:${issue.line}`
|
|
2160
|
+
}`
|
|
2161
|
+
]
|
|
2162
|
+
)
|
|
2163
|
+
return [
|
|
2164
|
+
`Story ${story.id}: ${story.title}`,
|
|
2165
|
+
story.description,
|
|
2166
|
+
...(story.acceptance.length === 0
|
|
2167
|
+
? []
|
|
2168
|
+
: ["Done when:", ...story.acceptance.map((line) => `- ${line}`)]),
|
|
2169
|
+
"",
|
|
2170
|
+
prompt,
|
|
2171
|
+
...(located.length === 0 ? [] : ["", "Where the findings point:", ...located])
|
|
2172
|
+
].join("\n")
|
|
2173
|
+
}
|
|
2174
|
+
const judgeRuns = yield* Ref.make(0)
|
|
2175
|
+
const lastRound = yield* Ref.make(0)
|
|
2176
|
+
// A judge that scores `provides` at 0 is owed a piece no fix turn writes.
|
|
2177
|
+
const missingPiece = (verdict: ReviewResult | undefined): verdict is StoryVerdict =>
|
|
2178
|
+
verdict instanceof StoryVerdict &&
|
|
2179
|
+
verdict.dimensions.some((d) => d.id === "provides" && d.score <= 0)
|
|
2180
|
+
const result = yield* reviewAndFixLoop({
|
|
2181
|
+
reviewers: lenses,
|
|
2182
|
+
reviewerService: flowReviewer(storyContext),
|
|
2183
|
+
coder: yield* makeChat(storyContext.coder, { system, events, agent: "coder" }),
|
|
2184
|
+
taskTitle: `story ${story.id}: ${story.title}`,
|
|
2185
|
+
currentDiff: git.diffVsBase(epicBranch),
|
|
2186
|
+
events: storyContext.events,
|
|
2187
|
+
lint: gates,
|
|
2188
|
+
triage,
|
|
2189
|
+
maxRounds: reviewRounds,
|
|
2190
|
+
...(deferring ? { settle: "blocking" as const } : {}),
|
|
2191
|
+
...(options.fix === undefined ? {} : { fix: options.fix }),
|
|
2192
|
+
cache: { files, dir: reviewCacheDir(story) },
|
|
2193
|
+
fixWith: (prompt) =>
|
|
2194
|
+
Effect.gen(function* () {
|
|
2195
|
+
const verdict = yield* Ref.get(latest)
|
|
2196
|
+
if (missingPiece(verdict)) {
|
|
2197
|
+
// Used once: a later round's fix answers that round, not this verdict.
|
|
2198
|
+
yield* Ref.set(latest, undefined)
|
|
2199
|
+
yield* addRevision(verdict, false)
|
|
2200
|
+
yield* guarded(implementTasks, implementTasks)
|
|
2201
|
+
yield* catchUp(story, git)
|
|
2202
|
+
yield* repairPerimeter
|
|
2203
|
+
return ""
|
|
2204
|
+
}
|
|
2205
|
+
// A BLOCKED_ON from the fix turn stops the story like a task's would.
|
|
2206
|
+
yield* guarded(
|
|
2207
|
+
coderTurn(storyFixPrompt(prompt, yield* Ref.get(lastStanding))),
|
|
2208
|
+
Effect.void
|
|
2209
|
+
)
|
|
2210
|
+
yield* git.commitAll(`${story.id}: story review fixes`)
|
|
2211
|
+
return ""
|
|
2212
|
+
}),
|
|
2213
|
+
onRound: (round, standing, settled) =>
|
|
2214
|
+
Effect.andThen(
|
|
2215
|
+
Effect.andThen(Ref.set(lastRound, round), Ref.set(lastStanding, standing)),
|
|
2216
|
+
appendFindings(
|
|
2217
|
+
story,
|
|
2218
|
+
`story review round ${round} — ${
|
|
2219
|
+
standing.isClean ? "clean" : `${standing.issues.length} issue(s)`
|
|
2220
|
+
}${settled ? "" : ", fixing"}`,
|
|
2221
|
+
standing
|
|
2222
|
+
)
|
|
2223
|
+
),
|
|
2224
|
+
recheck: {
|
|
2225
|
+
extra:
|
|
2226
|
+
judge === undefined
|
|
2227
|
+
? []
|
|
2228
|
+
: [
|
|
2229
|
+
{
|
|
2230
|
+
name: "judge",
|
|
2231
|
+
review: () =>
|
|
2232
|
+
Effect.gen(function* () {
|
|
2233
|
+
const round = yield* Ref.updateAndGet(judgeRuns, (n) => n + 1)
|
|
2234
|
+
const { verdict } = yield* judgeOnce(judge, round)
|
|
2235
|
+
yield* Ref.set(latest, verdict)
|
|
2236
|
+
return verdict
|
|
2237
|
+
})
|
|
2238
|
+
}
|
|
2239
|
+
],
|
|
2240
|
+
memory: {
|
|
2241
|
+
load: Effect.map(files.read(statePath), (text) => {
|
|
2242
|
+
if (text === undefined) return undefined
|
|
2243
|
+
try {
|
|
2244
|
+
return Schema.decodeUnknownSync(ReviewMemory)(JSON.parse(text))
|
|
2245
|
+
} catch {
|
|
2246
|
+
// An unreadable state starts the review over.
|
|
2247
|
+
return undefined
|
|
2248
|
+
}
|
|
2249
|
+
}),
|
|
2250
|
+
save: (memory) =>
|
|
2251
|
+
files.writeAtomic(
|
|
2252
|
+
statePath,
|
|
2253
|
+
JSON.stringify(Schema.encodeSync(ReviewMemory)(memory), null, 2)
|
|
2254
|
+
)
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
})
|
|
2258
|
+
const rounds = yield* Ref.get(lastRound)
|
|
2259
|
+
const left = result.issues
|
|
2260
|
+
// Lenses and the judge always name themselves: a finding with no
|
|
2261
|
+
// lens is a gate's (target gates, oracle, perimeter, frozen tests).
|
|
2262
|
+
const gateLeft = left.filter((issue) => issue.lens === undefined)
|
|
2263
|
+
if (gateLeft.length > 0) {
|
|
2264
|
+
return yield* failed(
|
|
2265
|
+
story,
|
|
2266
|
+
`its gates are still red after ${rounds} story review round${rounds === 1 ? "" : "s"}:\n${issueLines(
|
|
2267
|
+
ReviewResult.make({ issues: gateLeft })
|
|
2268
|
+
)}`
|
|
2269
|
+
)
|
|
2270
|
+
}
|
|
2271
|
+
const judgeLeft = left.filter(
|
|
2272
|
+
(issue) => issue.lens === "judge" && (!deferring || isBlocking(issue))
|
|
2273
|
+
)
|
|
2274
|
+
if (judgeLeft.length > 0) {
|
|
2275
|
+
return yield* failed(
|
|
2276
|
+
story,
|
|
2277
|
+
`judge not cleared after ${rounds} story review round${rounds === 1 ? "" : "s"}:\n${issueLines(
|
|
2278
|
+
ReviewResult.make({ issues: judgeLeft })
|
|
2279
|
+
)}`
|
|
2280
|
+
)
|
|
2281
|
+
}
|
|
2282
|
+
const waiting = deferring ? left : []
|
|
2283
|
+
for (const issue of waiting) {
|
|
2284
|
+
deferred.push(deferredLine("story review", issue))
|
|
2285
|
+
}
|
|
2286
|
+
const carried = deferring ? [] : left.filter((issue) => issue.lens !== "judge")
|
|
2287
|
+
if (carried.length > 0) {
|
|
2288
|
+
yield* appendFindings(
|
|
2289
|
+
story,
|
|
2290
|
+
"carried: review rounds ran out",
|
|
2291
|
+
ReviewResult.make({ issues: carried })
|
|
2292
|
+
)
|
|
2293
|
+
}
|
|
2294
|
+
return judge === undefined
|
|
2295
|
+
? undefined
|
|
2296
|
+
: `judge cleared (story review round ${rounds}${
|
|
2297
|
+
waiting.length === 0 ? "" : `, ${waiting.length} finding(s) deferred`
|
|
2298
|
+
})`
|
|
2299
|
+
})
|
|
2300
|
+
|
|
2301
|
+
let judgeNote: string | undefined
|
|
2302
|
+
let inPlace = false
|
|
2303
|
+
const storyDiff = reviewAtStory ? yield* git.diffVsBase(epicBranch) : ""
|
|
2304
|
+
if (reviewAtStory && storyDiff.trim().length > 0) {
|
|
2305
|
+
judgeNote = yield* stage(laneOf(story), `story ${story.id}: review`, reviewStory())
|
|
2306
|
+
} else if (options.judge !== undefined) {
|
|
2307
|
+
const judge = options.judge
|
|
2308
|
+
for (let round = 1; round <= judgeRounds; round += 1) {
|
|
2309
|
+
const { verdict, empty } = yield* judgeOnce(judge, round)
|
|
2103
2310
|
// Deferring, a verdict with nothing blocking clears: what it found
|
|
2104
2311
|
// waits for the follow-up round instead of another revision.
|
|
2105
2312
|
if (verdict.isClean || (deferring && !verdict.issues.some(isBlocking))) {
|
|
@@ -2216,6 +2423,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
2216
2423
|
...(last === undefined ? {} : { executor: last })
|
|
2217
2424
|
})
|
|
2218
2425
|
)
|
|
2426
|
+
yield* files.remove(reviewStatePath(options.stateDir, story.id))
|
|
2219
2427
|
const inherited = inheritedByStory.get(story.id)
|
|
2220
2428
|
return StoryOutcome.make({
|
|
2221
2429
|
id: story.id,
|
|
@@ -2252,11 +2460,19 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
2252
2460
|
const attempt = (story: Story): Effect.Effect<Completion> =>
|
|
2253
2461
|
annotated(
|
|
2254
2462
|
story,
|
|
2255
|
-
stage(
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2463
|
+
stage(
|
|
2464
|
+
laneOf(story),
|
|
2465
|
+
`story ${story.id}`,
|
|
2466
|
+
// A story that ends failed starts its review over next time (ADR 0044).
|
|
2467
|
+
Effect.tapError(runStory(story), () =>
|
|
2468
|
+
Effect.ignore(files.remove(reviewStatePath(options.stateDir, story.id)))
|
|
2469
|
+
),
|
|
2470
|
+
{
|
|
2471
|
+
kind: "AGENT",
|
|
2472
|
+
root: true,
|
|
2473
|
+
attributes: storySpanAttributes(story)
|
|
2474
|
+
}
|
|
2475
|
+
)
|
|
2260
2476
|
).pipe(
|
|
2261
2477
|
Effect.map((outcome): Completion => ({ story, outcome })),
|
|
2262
2478
|
Effect.catch((error) =>
|