@llm4ts/flow 2.50.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/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 +10 -0
- package/dist/Stories.d.ts.map +1 -1
- package/dist/Stories.js +237 -88
- package/dist/Stories.js.map +1 -1
- package/package.json +4 -2
- package/src/FlowEvents.ts +3 -1
- 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 +228 -14
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
|
|
@@ -857,6 +868,10 @@ const combined = (first: ReviewResult, second: ReviewResult): ReviewResult =>
|
|
|
857
868
|
export const deferredPath = (stateDir: string, storyId: string): string =>
|
|
858
869
|
join(stateDir, `stories/${storyId}.deferred.md`)
|
|
859
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
|
+
|
|
860
875
|
/** A deferred finding as one Markdown bullet, prefixed with where it was found. */
|
|
861
876
|
export const deferredLine = (source: string, issue: ReviewIssue): string => {
|
|
862
877
|
const where =
|
|
@@ -954,6 +969,8 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
954
969
|
// Parallel tasks are opt-in (ADR 0034 amendment): one coder per story unless asked.
|
|
955
970
|
const taskConcurrency = Math.max(1, options.taskConcurrency ?? 1)
|
|
956
971
|
const deferring = options.deferNonBlocking === true
|
|
972
|
+
const reviewAtStory = options.reviewAt === "story"
|
|
973
|
+
const reviewRounds = Math.max(1, options.reviewRounds ?? 3)
|
|
957
974
|
const statePath = (story: Story): string => join(options.stateDir, `stories/${story.id}.json`)
|
|
958
975
|
const planPath = (story: Story): string => join(options.stateDir, `stories/${story.id}.plan.md`)
|
|
959
976
|
/** The judge's last verdict beside a fingerprint of what it judged. */
|
|
@@ -1900,7 +1917,10 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
1900
1917
|
// A lens's answer is kept beside the task and diff it answered, so a
|
|
1901
1918
|
// rerun over the same change asks nothing; every round is written to
|
|
1902
1919
|
// the story's findings log for people to read.
|
|
1903
|
-
|
|
1920
|
+
// Reviewing per story (ADR 0044), a task runs on its gates alone.
|
|
1921
|
+
...(reviewAtStory
|
|
1922
|
+
? { reviewers: [] }
|
|
1923
|
+
: { reviewCache: { files, dir: reviewCacheDir(story) } }),
|
|
1904
1924
|
onReview: (task, round, result, settled) =>
|
|
1905
1925
|
Effect.gen(function* () {
|
|
1906
1926
|
// A settled round's leftovers are deferred, not dropped: when the
|
|
@@ -1920,15 +1940,17 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
1920
1940
|
onTaskReply: (task, _reply, trailer, startedAt) => checkEvidence(task, trailer, startedAt),
|
|
1921
1941
|
// The repository's own review rules ride along as one extra lens
|
|
1922
1942
|
// (ADR 0027 decision 9), read from the epic checkout.
|
|
1923
|
-
repoRules: loadRepoReviewRules(files, context.workDir),
|
|
1943
|
+
...(reviewAtStory ? {} : { repoRules: loadRepoReviewRules(files, context.workDir) }),
|
|
1924
1944
|
...(options.votes === undefined ? {} : { votes: options.votes }),
|
|
1925
1945
|
...(options.fixer === undefined ? {} : { fixer: options.fixer }),
|
|
1926
1946
|
...(options.stall === undefined ? {} : { stall: options.stall }),
|
|
1927
|
-
...(options.reviewers === undefined ? {} : { reviewers: options.reviewers }),
|
|
1947
|
+
...(options.reviewers === undefined || reviewAtStory ? {} : { reviewers: options.reviewers }),
|
|
1928
1948
|
...(options.maxRounds === undefined ? {} : { maxRounds: options.maxRounds }),
|
|
1929
1949
|
...(deferring ? { settle: "blocking" as const } : {})
|
|
1930
1950
|
})
|
|
1931
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))
|
|
1932
1954
|
// The branch already caught up with the epic above: the gates in the
|
|
1933
1955
|
// worktree now see the combination that was red after the merge.
|
|
1934
1956
|
yield* appendRevision(
|
|
@@ -1986,11 +2008,17 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
1986
2008
|
verdict
|
|
1987
2009
|
)
|
|
1988
2010
|
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
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* () {
|
|
1994
2022
|
const diff = yield* git.diffVsBase(epicBranch)
|
|
1995
2023
|
// No changes is not a failure by itself: the story's code may already
|
|
1996
2024
|
// be on the epic (an earlier story or run put it there). Judge that
|
|
@@ -2102,6 +2130,183 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
2102
2130
|
dimensions: verdict instanceof StoryVerdict ? verdict.dimensions : []
|
|
2103
2131
|
})
|
|
2104
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)
|
|
2105
2310
|
// Deferring, a verdict with nothing blocking clears: what it found
|
|
2106
2311
|
// waits for the follow-up round instead of another revision.
|
|
2107
2312
|
if (verdict.isClean || (deferring && !verdict.issues.some(isBlocking))) {
|
|
@@ -2218,6 +2423,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
2218
2423
|
...(last === undefined ? {} : { executor: last })
|
|
2219
2424
|
})
|
|
2220
2425
|
)
|
|
2426
|
+
yield* files.remove(reviewStatePath(options.stateDir, story.id))
|
|
2221
2427
|
const inherited = inheritedByStory.get(story.id)
|
|
2222
2428
|
return StoryOutcome.make({
|
|
2223
2429
|
id: story.id,
|
|
@@ -2254,11 +2460,19 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
2254
2460
|
const attempt = (story: Story): Effect.Effect<Completion> =>
|
|
2255
2461
|
annotated(
|
|
2256
2462
|
story,
|
|
2257
|
-
stage(
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
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
|
+
)
|
|
2262
2476
|
).pipe(
|
|
2263
2477
|
Effect.map((outcome): Completion => ({ story, outcome })),
|
|
2264
2478
|
Effect.catch((error) =>
|