@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/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
- reviewCache: { files, dir: reviewCacheDir(story) },
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
- let judgeNote: string | undefined
1990
- let inPlace = false
1991
- if (options.judge !== undefined) {
1992
- const judge = options.judge
1993
- for (let round = 1; round <= judgeRounds; round += 1) {
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(laneOf(story), `story ${story.id}`, runStory(story), {
2258
- kind: "AGENT",
2259
- root: true,
2260
- attributes: storySpanAttributes(story)
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) =>