@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/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. Default 2; 1 runs every task
796
- * in order on the story's coder.
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
- const taskConcurrency = Math.max(1, options.taskConcurrency ?? 2)
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
- 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) } }),
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
- let judgeNote: string | undefined
1988
- let inPlace = false
1989
- if (options.judge !== undefined) {
1990
- const judge = options.judge
1991
- 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* () {
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(laneOf(story), `story ${story.id}`, runStory(story), {
2256
- kind: "AGENT",
2257
- root: true,
2258
- attributes: storySpanAttributes(story)
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) =>