@llm4ts/flow 2.50.0 → 2.52.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"
@@ -116,8 +119,18 @@ const join = (root: string, path: string): string =>
116
119
 
117
120
  /** A flow context rooted in a story's worktree, plus that story's own usage totals. */
118
121
  /** A story judge's review with the rubric's scores; a plain `ReviewResult` has none. */
122
+ /** A dimension below the bar the story was not held to, with the judge's reasoning (ADR 0045). */
123
+ export const ExcusedDimension = Schema.Struct({
124
+ id: Schema.String,
125
+ score: Schema.Number,
126
+ max: Schema.Number,
127
+ reasoning: Schema.String
128
+ })
129
+ export type ExcusedDimension = typeof ExcusedDimension.Type
130
+
119
131
  export class StoryVerdict extends ReviewResult.extend<StoryVerdict>("StoryVerdict")({
120
- dimensions: Schema.Array(JudgedDimension)
132
+ dimensions: Schema.Array(JudgedDimension),
133
+ excused: Schema.optionalKey(Schema.Array(ExcusedDimension))
121
134
  }) {}
122
135
 
123
136
  /** What a judge answers: a scored verdict, or a plain review result. */
@@ -752,6 +765,14 @@ export interface StoriesOptions {
752
765
  * story's plan (coder, review, gates, commit). Default 2: one revision.
753
766
  */
754
767
  readonly judgeRounds?: number
768
+ /**
769
+ * Where the review lenses run (ADR 0044): each task (default), or once per
770
+ * story, beside the judge, with incremental re-checks and one fix turn per
771
+ * round.
772
+ */
773
+ readonly reviewAt?: "task" | "story"
774
+ /** Story mode: rounds of the merged story review. Default 3. Task mode ignores it. */
775
+ readonly reviewRounds?: number
755
776
  /**
756
777
  * When the epic gates are red after a story merges (the merge is undone),
757
778
  * the story goes back to its worktree this many times: it catches up with
@@ -809,6 +830,12 @@ export interface StoriesOptions {
809
830
  * for a follow-up round. Default false: every finding is fixed or fails.
810
831
  */
811
832
  readonly deferNonBlocking?: boolean
833
+ /**
834
+ * Names the bar the judge holds stories to when it is not the default
835
+ * (`defer-tests`, ADR 0045). It enters the judge's cache fingerprint, so a
836
+ * verdict judged under one bar is never reused under another.
837
+ */
838
+ readonly judgeBar?: string
812
839
  /**
813
840
  * Second opinion on a BLOCKED_ON claim the plan cannot settle (a path no
814
841
  * story owns, or a parallel story's). `real: false` sends the coder back
@@ -857,6 +884,31 @@ const combined = (first: ReviewResult, second: ReviewResult): ReviewResult =>
857
884
  export const deferredPath = (stateDir: string, storyId: string): string =>
858
885
  join(stateDir, `stories/${storyId}.deferred.md`)
859
886
 
887
+ /** Where a story review keeps its reviewers' memory between rounds and runs (ADR 0044). */
888
+ export const reviewStatePath = (stateDir: string, storyId: string): string =>
889
+ join(stateDir, `stories/${storyId}.review-state.json`)
890
+
891
+ /** Where a story's last judge verdict is kept, beside a fingerprint of what it judged. */
892
+ export const judgeVerdictPath = (stateDir: string, storyId: string): string =>
893
+ join(stateDir, `stories/${storyId}.judge.json`)
894
+
895
+ /** A story's last judge verdict, when it was a scored one (ADR 0045: what the debt reads). */
896
+ export const lastStoryVerdict = (
897
+ files: PlainFileStoreShape,
898
+ stateDir: string,
899
+ storyId: string
900
+ ): Effect.Effect<StoryVerdict | undefined> =>
901
+ files.read(judgeVerdictPath(stateDir, storyId)).pipe(
902
+ Effect.flatMap((text) =>
903
+ text === undefined
904
+ ? Effect.succeed(undefined)
905
+ : Schema.decodeUnknownEffect(
906
+ Schema.fromJsonString(Schema.Struct({ result: StoryVerdict }))
907
+ )(text).pipe(Effect.map((entry) => entry.result))
908
+ ),
909
+ Effect.catch(() => Effect.succeed(undefined))
910
+ )
911
+
860
912
  /** A deferred finding as one Markdown bullet, prefixed with where it was found. */
861
913
  export const deferredLine = (source: string, issue: ReviewIssue): string => {
862
914
  const where =
@@ -954,11 +1006,12 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
954
1006
  // Parallel tasks are opt-in (ADR 0034 amendment): one coder per story unless asked.
955
1007
  const taskConcurrency = Math.max(1, options.taskConcurrency ?? 1)
956
1008
  const deferring = options.deferNonBlocking === true
1009
+ const reviewAtStory = options.reviewAt === "story"
1010
+ const reviewRounds = Math.max(1, options.reviewRounds ?? 3)
957
1011
  const statePath = (story: Story): string => join(options.stateDir, `stories/${story.id}.json`)
958
1012
  const planPath = (story: Story): string => join(options.stateDir, `stories/${story.id}.plan.md`)
959
1013
  /** The judge's last verdict beside a fingerprint of what it judged. */
960
- const judgePath = (story: Story): string =>
961
- join(options.stateDir, `stories/${story.id}.judge.json`)
1014
+ const judgePath = (story: Story): string => judgeVerdictPath(options.stateDir, story.id)
962
1015
  /** One file per review lens, beside a fingerprint of the task and diff. */
963
1016
  const reviewCacheDir = (story: Story): string =>
964
1017
  join(options.stateDir, `stories/${story.id}.review`)
@@ -1900,7 +1953,10 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1900
1953
  // A lens's answer is kept beside the task and diff it answered, so a
1901
1954
  // rerun over the same change asks nothing; every round is written to
1902
1955
  // the story's findings log for people to read.
1903
- reviewCache: { files, dir: reviewCacheDir(story) },
1956
+ // Reviewing per story (ADR 0044), a task runs on its gates alone.
1957
+ ...(reviewAtStory
1958
+ ? { reviewers: [] }
1959
+ : { reviewCache: { files, dir: reviewCacheDir(story) } }),
1904
1960
  onReview: (task, round, result, settled) =>
1905
1961
  Effect.gen(function* () {
1906
1962
  // A settled round's leftovers are deferred, not dropped: when the
@@ -1920,15 +1976,17 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1920
1976
  onTaskReply: (task, _reply, trailer, startedAt) => checkEvidence(task, trailer, startedAt),
1921
1977
  // The repository's own review rules ride along as one extra lens
1922
1978
  // (ADR 0027 decision 9), read from the epic checkout.
1923
- repoRules: loadRepoReviewRules(files, context.workDir),
1979
+ ...(reviewAtStory ? {} : { repoRules: loadRepoReviewRules(files, context.workDir) }),
1924
1980
  ...(options.votes === undefined ? {} : { votes: options.votes }),
1925
1981
  ...(options.fixer === undefined ? {} : { fixer: options.fixer }),
1926
1982
  ...(options.stall === undefined ? {} : { stall: options.stall }),
1927
- ...(options.reviewers === undefined ? {} : { reviewers: options.reviewers }),
1983
+ ...(options.reviewers === undefined || reviewAtStory ? {} : { reviewers: options.reviewers }),
1928
1984
  ...(options.maxRounds === undefined ? {} : { maxRounds: options.maxRounds }),
1929
1985
  ...(deferring ? { settle: "blocking" as const } : {})
1930
1986
  })
1931
1987
  if (mergeFailure !== undefined) {
1988
+ // The tasks run again: what the story review remembered no longer holds.
1989
+ yield* files.remove(reviewStatePath(options.stateDir, story.id))
1932
1990
  // The branch already caught up with the epic above: the gates in the
1933
1991
  // worktree now see the combination that was red after the merge.
1934
1992
  yield* appendRevision(
@@ -1986,11 +2044,17 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1986
2044
  verdict
1987
2045
  )
1988
2046
 
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) {
2047
+ /**
2048
+ * One judge round over the story's branch: the verdict, published on the
2049
+ * lane, logged in the findings and kept beside a fingerprint of what was
2050
+ * judged. `empty` says the branch had no changes, so its owned code was
2051
+ * judged instead.
2052
+ */
2053
+ const judgeOnce = (
2054
+ judge: NonNullable<StoriesOptions["judge"]>,
2055
+ round: number
2056
+ ): Effect.Effect<{ readonly verdict: ReviewResult; readonly empty: boolean }, FlowError> =>
2057
+ Effect.gen(function* () {
1994
2058
  const diff = yield* git.diffVsBase(epicBranch)
1995
2059
  // No changes is not a failure by itself: the story's code may already
1996
2060
  // be on the epic (an earlier story or run put it there). Judge that
@@ -2045,7 +2109,9 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
2045
2109
  story.id,
2046
2110
  story.title,
2047
2111
  story.description,
2048
- story.provides.join("\n")
2112
+ story.provides.join("\n"),
2113
+ // Absent for the default bar: verdicts stored before 2.52 stay valid.
2114
+ ...(options.judgeBar === undefined ? [] : [options.judgeBar])
2049
2115
  ]),
2050
2116
  withTimedRole(
2051
2117
  "judge",
@@ -2102,6 +2168,183 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
2102
2168
  dimensions: verdict instanceof StoryVerdict ? verdict.dimensions : []
2103
2169
  })
2104
2170
  )
2171
+ return { verdict, empty }
2172
+ })
2173
+
2174
+ /**
2175
+ * The story review (ADR 0044): every lens and the judge on the story's
2176
+ * diff, one fix turn per round, re-checks after the first. Returns the
2177
+ * judge's note; fails the story when the judge is still not cleared.
2178
+ */
2179
+ const reviewStory = (): Effect.Effect<string | undefined, FlowError> =>
2180
+ Effect.gen(function* () {
2181
+ const statePath = reviewStatePath(options.stateDir, story.id)
2182
+ const repoRules = yield* loadRepoReviewRules(files, context.workDir)
2183
+ const lenses = [
2184
+ ...(options.reviewers ?? minimalReviewers),
2185
+ ...(repoRules === undefined ? [] : [repoRules])
2186
+ ]
2187
+ const judge = options.judge
2188
+ const latest = yield* Ref.make<ReviewResult | undefined>(undefined)
2189
+ const lastStanding = yield* Ref.make<ReviewResult | undefined>(undefined)
2190
+ // The fix turn is a fresh chat: it gets the story and where each finding points.
2191
+ const storyFixPrompt = (prompt: string, standing: ReviewResult | undefined): string => {
2192
+ const located = (standing?.issues ?? []).flatMap((issue) =>
2193
+ issue.file === undefined
2194
+ ? []
2195
+ : [
2196
+ `- ${issue.title} (${issue.lens ?? "gates"}): ${issue.file}${
2197
+ issue.line === undefined ? "" : `:${issue.line}`
2198
+ }`
2199
+ ]
2200
+ )
2201
+ return [
2202
+ `Story ${story.id}: ${story.title}`,
2203
+ story.description,
2204
+ ...(story.acceptance.length === 0
2205
+ ? []
2206
+ : ["Done when:", ...story.acceptance.map((line) => `- ${line}`)]),
2207
+ "",
2208
+ prompt,
2209
+ ...(located.length === 0 ? [] : ["", "Where the findings point:", ...located])
2210
+ ].join("\n")
2211
+ }
2212
+ const judgeRuns = yield* Ref.make(0)
2213
+ const lastRound = yield* Ref.make(0)
2214
+ // A judge that scores `provides` at 0 is owed a piece no fix turn writes.
2215
+ const missingPiece = (verdict: ReviewResult | undefined): verdict is StoryVerdict =>
2216
+ verdict instanceof StoryVerdict &&
2217
+ verdict.dimensions.some((d) => d.id === "provides" && d.score <= 0)
2218
+ const result = yield* reviewAndFixLoop({
2219
+ reviewers: lenses,
2220
+ reviewerService: flowReviewer(storyContext),
2221
+ coder: yield* makeChat(storyContext.coder, { system, events, agent: "coder" }),
2222
+ taskTitle: `story ${story.id}: ${story.title}`,
2223
+ currentDiff: git.diffVsBase(epicBranch),
2224
+ events: storyContext.events,
2225
+ lint: gates,
2226
+ triage,
2227
+ maxRounds: reviewRounds,
2228
+ ...(deferring ? { settle: "blocking" as const } : {}),
2229
+ ...(options.fix === undefined ? {} : { fix: options.fix }),
2230
+ cache: { files, dir: reviewCacheDir(story) },
2231
+ fixWith: (prompt) =>
2232
+ Effect.gen(function* () {
2233
+ const verdict = yield* Ref.get(latest)
2234
+ if (missingPiece(verdict)) {
2235
+ // Used once: a later round's fix answers that round, not this verdict.
2236
+ yield* Ref.set(latest, undefined)
2237
+ yield* addRevision(verdict, false)
2238
+ yield* guarded(implementTasks, implementTasks)
2239
+ yield* catchUp(story, git)
2240
+ yield* repairPerimeter
2241
+ return ""
2242
+ }
2243
+ // A BLOCKED_ON from the fix turn stops the story like a task's would.
2244
+ yield* guarded(
2245
+ coderTurn(storyFixPrompt(prompt, yield* Ref.get(lastStanding))),
2246
+ Effect.void
2247
+ )
2248
+ yield* git.commitAll(`${story.id}: story review fixes`)
2249
+ return ""
2250
+ }),
2251
+ onRound: (round, standing, settled) =>
2252
+ Effect.andThen(
2253
+ Effect.andThen(Ref.set(lastRound, round), Ref.set(lastStanding, standing)),
2254
+ appendFindings(
2255
+ story,
2256
+ `story review round ${round} — ${
2257
+ standing.isClean ? "clean" : `${standing.issues.length} issue(s)`
2258
+ }${settled ? "" : ", fixing"}`,
2259
+ standing
2260
+ )
2261
+ ),
2262
+ recheck: {
2263
+ extra:
2264
+ judge === undefined
2265
+ ? []
2266
+ : [
2267
+ {
2268
+ name: "judge",
2269
+ review: () =>
2270
+ Effect.gen(function* () {
2271
+ const round = yield* Ref.updateAndGet(judgeRuns, (n) => n + 1)
2272
+ const { verdict } = yield* judgeOnce(judge, round)
2273
+ yield* Ref.set(latest, verdict)
2274
+ return verdict
2275
+ })
2276
+ }
2277
+ ],
2278
+ memory: {
2279
+ load: Effect.map(files.read(statePath), (text) => {
2280
+ if (text === undefined) return undefined
2281
+ try {
2282
+ return Schema.decodeUnknownSync(ReviewMemory)(JSON.parse(text))
2283
+ } catch {
2284
+ // An unreadable state starts the review over.
2285
+ return undefined
2286
+ }
2287
+ }),
2288
+ save: (memory) =>
2289
+ files.writeAtomic(
2290
+ statePath,
2291
+ JSON.stringify(Schema.encodeSync(ReviewMemory)(memory), null, 2)
2292
+ )
2293
+ }
2294
+ }
2295
+ })
2296
+ const rounds = yield* Ref.get(lastRound)
2297
+ const left = result.issues
2298
+ // Lenses and the judge always name themselves: a finding with no
2299
+ // lens is a gate's (target gates, oracle, perimeter, frozen tests).
2300
+ const gateLeft = left.filter((issue) => issue.lens === undefined)
2301
+ if (gateLeft.length > 0) {
2302
+ return yield* failed(
2303
+ story,
2304
+ `its gates are still red after ${rounds} story review round${rounds === 1 ? "" : "s"}:\n${issueLines(
2305
+ ReviewResult.make({ issues: gateLeft })
2306
+ )}`
2307
+ )
2308
+ }
2309
+ const judgeLeft = left.filter(
2310
+ (issue) => issue.lens === "judge" && (!deferring || isBlocking(issue))
2311
+ )
2312
+ if (judgeLeft.length > 0) {
2313
+ return yield* failed(
2314
+ story,
2315
+ `judge not cleared after ${rounds} story review round${rounds === 1 ? "" : "s"}:\n${issueLines(
2316
+ ReviewResult.make({ issues: judgeLeft })
2317
+ )}`
2318
+ )
2319
+ }
2320
+ const waiting = deferring ? left : []
2321
+ for (const issue of waiting) {
2322
+ deferred.push(deferredLine("story review", issue))
2323
+ }
2324
+ const carried = deferring ? [] : left.filter((issue) => issue.lens !== "judge")
2325
+ if (carried.length > 0) {
2326
+ yield* appendFindings(
2327
+ story,
2328
+ "carried: review rounds ran out",
2329
+ ReviewResult.make({ issues: carried })
2330
+ )
2331
+ }
2332
+ return judge === undefined
2333
+ ? undefined
2334
+ : `judge cleared (story review round ${rounds}${
2335
+ waiting.length === 0 ? "" : `, ${waiting.length} finding(s) deferred`
2336
+ })`
2337
+ })
2338
+
2339
+ let judgeNote: string | undefined
2340
+ let inPlace = false
2341
+ const storyDiff = reviewAtStory ? yield* git.diffVsBase(epicBranch) : ""
2342
+ if (reviewAtStory && storyDiff.trim().length > 0) {
2343
+ judgeNote = yield* stage(laneOf(story), `story ${story.id}: review`, reviewStory())
2344
+ } else if (options.judge !== undefined) {
2345
+ const judge = options.judge
2346
+ for (let round = 1; round <= judgeRounds; round += 1) {
2347
+ const { verdict, empty } = yield* judgeOnce(judge, round)
2105
2348
  // Deferring, a verdict with nothing blocking clears: what it found
2106
2349
  // waits for the follow-up round instead of another revision.
2107
2350
  if (verdict.isClean || (deferring && !verdict.issues.some(isBlocking))) {
@@ -2218,6 +2461,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
2218
2461
  ...(last === undefined ? {} : { executor: last })
2219
2462
  })
2220
2463
  )
2464
+ yield* files.remove(reviewStatePath(options.stateDir, story.id))
2221
2465
  const inherited = inheritedByStory.get(story.id)
2222
2466
  return StoryOutcome.make({
2223
2467
  id: story.id,
@@ -2254,11 +2498,19 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
2254
2498
  const attempt = (story: Story): Effect.Effect<Completion> =>
2255
2499
  annotated(
2256
2500
  story,
2257
- stage(laneOf(story), `story ${story.id}`, runStory(story), {
2258
- kind: "AGENT",
2259
- root: true,
2260
- attributes: storySpanAttributes(story)
2261
- })
2501
+ stage(
2502
+ laneOf(story),
2503
+ `story ${story.id}`,
2504
+ // A story that ends failed starts its review over next time (ADR 0044).
2505
+ Effect.tapError(runStory(story), () =>
2506
+ Effect.ignore(files.remove(reviewStatePath(options.stateDir, story.id)))
2507
+ ),
2508
+ {
2509
+ kind: "AGENT",
2510
+ root: true,
2511
+ attributes: storySpanAttributes(story)
2512
+ }
2513
+ )
2262
2514
  ).pipe(
2263
2515
  Effect.map((outcome): Completion => ({ story, outcome })),
2264
2516
  Effect.catch((error) =>