@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/Review.ts CHANGED
@@ -34,6 +34,16 @@ import {
34
34
  parseUnifiedDiff,
35
35
  passedCountIn
36
36
  } from "./OracleGuard.ts"
37
+ import { ReviewMemory } from "./Recheck.ts"
38
+ import {
39
+ type Candidate,
40
+ diffChunks,
41
+ planRound,
42
+ recheckLead,
43
+ remember,
44
+ standing,
45
+ touchedFiles
46
+ } from "./Recheck.ts"
37
47
  import { cachedReview, fingerprintOf } from "./ReviewCache.ts"
38
48
  import { withCallPurpose } from "./Timing.ts"
39
49
  import {
@@ -61,46 +71,8 @@ import {
61
71
  type JudgmentPolicy
62
72
  } from "./Judgment.ts"
63
73
 
64
- export const Severity = Schema.Literals(["Critical", "Warning", "Info"])
65
- export type Severity = typeof Severity.Type
66
-
67
- export class ReviewIssue extends Schema.Class<ReviewIssue>("ReviewIssue")({
68
- severity: Severity,
69
- title: Schema.String,
70
- description: Schema.String.pipe(
71
- Schema.withConstructorDefault(Effect.succeed("")),
72
- Schema.withDecodingDefaultKey(Effect.succeed(""))
73
- ),
74
- file: Schema.optionalKey(Schema.String),
75
- line: Schema.optionalKey(Schema.Int),
76
- suggestion: Schema.optionalKey(Schema.String),
77
- confidence: Schema.Number.pipe(
78
- Schema.withConstructorDefault(Effect.succeed(1)),
79
- Schema.withDecodingDefaultKey(Effect.succeed(1))
80
- ),
81
- /** A gate failure's triage against the base (ADR 0027); absent for review findings. */
82
- origin: Schema.optionalKey(Schema.Literals(["new", "base", "flaky"])),
83
- /** How a gate ended: a red exit, killed at the timeout, or a signal exit. */
84
- gateClass: Schema.optionalKey(Schema.Literals(["red", "hang", "crash"])),
85
- /** Why a test gate's output could not stand for green: no tests ran, or test files failed to load. */
86
- gateReason: Schema.optionalKey(Schema.Literals(["zero-tests", "load-failure"])),
87
- /** Where the gate's full output was written, when the caller asked for a log. */
88
- logPath: Schema.optionalKey(Schema.String)
89
- }) {}
90
-
91
- export class ReviewResult extends Schema.Class<ReviewResult>("ReviewResult")({
92
- issues: Schema.Array(ReviewIssue),
93
- summary: Schema.String.pipe(
94
- Schema.withConstructorDefault(Effect.succeed("")),
95
- Schema.withDecodingDefaultKey(Effect.succeed(""))
96
- ),
97
- /** A test gate's passed-test count, when its output had a summary the oracle guard reads (ADR 0027). */
98
- passed: Schema.optionalKey(Schema.Int)
99
- }) {
100
- get isClean(): boolean {
101
- return this.issues.length === 0
102
- }
103
- }
74
+ export { ReviewIssue, ReviewResult, Severity, reviewPrompt } from "./ReviewModel.ts"
75
+ import { ReviewIssue, ReviewResult, reviewPrompt } from "./ReviewModel.ts"
104
76
 
105
77
  export const reviewJsonSchema: JsonSchema = {
106
78
  type: "object",
@@ -300,19 +272,6 @@ const fileSlug = (name: string): string =>
300
272
  .replace(/[^a-z0-9]+/g, "-")
301
273
  .replace(/^-+|-+$/g, "") || "lens"
302
274
 
303
- export const reviewPrompt = (task: string, diff: string): string =>
304
- [
305
- `Review the change below for task "${task}". Report problems as JSON:`,
306
- '{"issues":[{"severity":"Critical|Warning|Info","title":"...",',
307
- '"description":"..."}],"summary":"..."}.',
308
- 'An empty "issues" array means the change is acceptable. Respond with JSON only.',
309
- "The diff below is the whole subject: judge what it shows. Do not explore the",
310
- "repository, read other files, or run anything; answer from the diff.",
311
- "",
312
- "Diff:",
313
- diff
314
- ].join("\n")
315
-
316
275
  export interface FixPromptOptions {
317
276
  /** Characters of a gate's output kept in the prompt, from the end. Default 4000. */
318
277
  readonly tailChars?: number
@@ -460,6 +419,17 @@ export const applyTriage = Effect.fn("@llm4ts/flow/Review.applyTriage")(function
460
419
  return triaged.blocking
461
420
  })
462
421
 
422
+ /** Names `lens` as the source of every finding in `result` that names none yet (ADR 0044). */
423
+ export const tagLens = (lens: string, result: ReviewResult): ReviewResult =>
424
+ result.issues.every((issue) => issue.lens !== undefined)
425
+ ? result
426
+ : ReviewResult.make({
427
+ ...result,
428
+ issues: result.issues.map((issue) =>
429
+ issue.lens === undefined ? ReviewIssue.make({ ...issue, lens }) : issue
430
+ )
431
+ })
432
+
463
433
  export const mergeReviewResults = (results: ReadonlyArray<ReviewResult>): ReviewResult => {
464
434
  const counts = results.flatMap((result) => (result.passed === undefined ? [] : [result.passed]))
465
435
  return ReviewResult.make({
@@ -755,6 +725,32 @@ export interface ReviewAndFixOptions {
755
725
  * non-blocking. A red gate is always blocking.
756
726
  */
757
727
  readonly settle?: SettleRule
728
+ /**
729
+ * Incremental re-review (ADR 0044): each reviewer's findings are kept
730
+ * across rounds, a later round asks only the reviewers with findings (a
731
+ * re-check of them) or with files they have not seen, and the result is
732
+ * every finding still standing. Absent, every lens reviews every round.
733
+ */
734
+ readonly recheck?: RecheckOptions
735
+ }
736
+
737
+ /** A reviewer beside the lenses, such as the story judge (ADR 0044). */
738
+ export interface ExtraReview {
739
+ readonly name: string
740
+ /** `prior`: its own standing findings when this run is a re-check; undefined for a full review. */
741
+ readonly review: (
742
+ diff: string,
743
+ prior: ReadonlyArray<ReviewIssue> | undefined
744
+ ) => Effect.Effect<ReviewResult, FlowError>
745
+ }
746
+
747
+ export interface RecheckOptions {
748
+ readonly extra?: ReadonlyArray<ExtraReview>
749
+ /** Where the reviewers' memory is kept, so a restart resumes at the next round. */
750
+ readonly memory?: {
751
+ readonly load: Effect.Effect<ReviewMemory | undefined, FlowError>
752
+ readonly save: (memory: ReviewMemory) => Effect.Effect<void, FlowError>
753
+ }
758
754
  }
759
755
 
760
756
  /** When a review round stops sending findings back to the coder (ADR 0031). */
@@ -865,9 +861,13 @@ export const reviewWith = (
865
861
  events: FlowEventsShape,
866
862
  lens: Reviewer,
867
863
  taskTitle: string,
868
- diff: string
864
+ diff: string,
865
+ /** A re-check (ADR 0044): the files the fix touched and the lens's own earlier findings. */
866
+ recheck?: { readonly touched: ReadonlyArray<string>; readonly prior: ReadonlyArray<ReviewIssue> }
869
867
  ): Effect.Effect<ReviewResult, FlowLlmError> => {
870
- const prompt = `${lensPrompt(lens)}\n\n${reviewPrompt(taskTitle, diff)}`
868
+ const lead =
869
+ recheck === undefined ? "" : `${recheckLead(taskTitle, recheck.touched, recheck.prior)}\n\n`
870
+ const prompt = `${lensPrompt(lens)}\n\n${lead}${reviewPrompt(taskTitle, diff)}`
871
871
  // Usage is published per attempt — a schema retry costs real tokens too.
872
872
  const attempt = (text: string): Effect.Effect<ReviewResult, LlmError> =>
873
873
  service.executeStructuredWithUsage(text, ReviewResult, reviewJsonSchema).pipe(
@@ -915,6 +915,104 @@ export const reviewAndFixLoop = Effect.fn("@llm4ts/flow/Review.reviewAndFixLoop"
915
915
  // A red gate is never settled early, whatever its findings' severities.
916
916
  const gateRed = yield* Ref.make(false)
917
917
 
918
+ const recheck = options.recheck
919
+ const loaded = recheck?.memory === undefined ? undefined : yield* recheck.memory.load
920
+ const memory = yield* Ref.make<ReviewMemory | undefined>(loaded)
921
+
922
+ /** One round of incremental re-review (ADR 0044): returns what still stands. */
923
+ const recheckRound = (
924
+ settings: RecheckOptions,
925
+ round: number,
926
+ diff: string,
927
+ files: ReadonlyArray<string>
928
+ ): Effect.Effect<ReviewResult, FlowError> =>
929
+ Effect.gen(function* () {
930
+ const before = yield* Ref.get(memory)
931
+ const chunks = diffChunks(diff)
932
+ const shown = files.length > 0 ? files : Object.keys(chunks)
933
+ const touched = before === undefined ? [] : touchedFiles(before.chunks, chunks)
934
+ const extras = settings.extra ?? []
935
+ const candidates: ReadonlyArray<Candidate> = [
936
+ ...options.reviewers.map((lens) => ({
937
+ name: lens.name,
938
+ matches: (changed: ReadonlyArray<string>) => lens.matches(changed)
939
+ })),
940
+ ...extras.map((extra) => ({ name: extra.name, matches: () => true, onlyWhenDirty: true }))
941
+ ]
942
+ const plan = planRound(candidates, before, shown, touched)
943
+ const priorOf = (name: string): ReadonlyArray<ReviewIssue> =>
944
+ before?.reviewers.find((entry) => entry.name === name)?.findings ?? []
945
+ const asked = [...plan.full, ...plan.recheck]
946
+ if (asked.length > 0) {
947
+ yield* options.events.publish(
948
+ Info.make({
949
+ message: `review round ${round} of "${options.taskTitle}": ${asked
950
+ .map((name) => (plan.recheck.includes(name) ? `${name} (re-check)` : name))
951
+ .join(", ")}`
952
+ })
953
+ )
954
+ }
955
+ const cache = options.cache
956
+ const ask = (
957
+ name: string
958
+ ): Effect.Effect<{ readonly name: string; readonly result: ReviewResult }, FlowError> => {
959
+ const again = plan.recheck.includes(name)
960
+ const prior = priorOf(name)
961
+ const lens = options.reviewers.find((candidate) => candidate.name === name)
962
+ const extra = extras.find((candidate) => candidate.name === name)
963
+ const lensAnswer = (lens: Reviewer): Effect.Effect<ReviewResult, FlowError> => {
964
+ const call = withCallPurpose(
965
+ `${name} lens${again ? " · re-check" : ""}`,
966
+ reviewWith(
967
+ options.reviewerService,
968
+ options.events,
969
+ lens,
970
+ options.taskTitle,
971
+ diff,
972
+ again ? { touched, prior } : undefined
973
+ )
974
+ )
975
+ const answered =
976
+ cache === undefined
977
+ ? call
978
+ : cachedReview(
979
+ cache.files,
980
+ join(cache.dir, `${fileSlug(name)}.json`),
981
+ fingerprintOf([
982
+ name,
983
+ lensPrompt(lens),
984
+ "0",
985
+ options.taskTitle,
986
+ diff,
987
+ ...(again ? ["recheck", ...prior.map((issue) => issue.title)] : [])
988
+ ]),
989
+ call
990
+ )
991
+ return Effect.flatMap(answered, (result) =>
992
+ demoteUnplaced(lens, result, files, options.events)
993
+ )
994
+ }
995
+ const answer: Effect.Effect<ReviewResult, FlowError> =
996
+ lens !== undefined
997
+ ? lensAnswer(lens)
998
+ : extra !== undefined
999
+ ? extra.review(diff, again ? prior : undefined)
1000
+ : Effect.succeed(ReviewResult.make({ issues: [] }))
1001
+ return Effect.map(answer, (result) => ({ name, result: tagLens(name, result) }))
1002
+ }
1003
+ const parallelism = options.parallelism ?? 0
1004
+ const ran =
1005
+ parallelism > 0
1006
+ ? yield* Effect.forEach(asked, ask, { concurrency: parallelism })
1007
+ : yield* Effect.forEach(asked, ask, { concurrency: "unbounded" })
1008
+ const next = remember(before, round, chunks, ran, shown)
1009
+ yield* Ref.set(memory, next)
1010
+ if (settings.memory !== undefined) {
1011
+ yield* settings.memory.save(next)
1012
+ }
1013
+ return standing(next)
1014
+ })
1015
+
918
1016
  const reviewOnce = (
919
1017
  round: number,
920
1018
  previous: ReviewResult | undefined
@@ -933,6 +1031,9 @@ export const reviewAndFixLoop = Effect.fn("@llm4ts/flow/Review.reviewAndFixLoop"
933
1031
  }
934
1032
  const diff = yield* options.currentDiff
935
1033
  const files = yield* changedFiles
1034
+ if (recheck !== undefined) {
1035
+ return yield* recheckRound(recheck, round, diff, files)
1036
+ }
936
1037
  const selected = yield* selector.select(options.reviewers, files, round, previous)
937
1038
  const screened =
938
1039
  options.prescreen === undefined
@@ -974,7 +1075,7 @@ export const reviewAndFixLoop = Effect.fn("@llm4ts/flow/Review.reviewAndFixLoop"
974
1075
  Effect.flatMap(review(lens), (result) =>
975
1076
  Effect.map(demoteUnplaced(lens, result, files, options.events), (placed) => ({
976
1077
  lens,
977
- result: placed
1078
+ result: tagLens(lens.name, placed)
978
1079
  }))
979
1080
  )
980
1081
  const parallelism = options.parallelism ?? 0
@@ -1056,10 +1157,12 @@ export const reviewAndFixLoop = Effect.fn("@llm4ts/flow/Review.reviewAndFixLoop"
1056
1157
  result.isClean ||
1057
1158
  round >= maxRounds ||
1058
1159
  (settle === "blocking" && !(yield* Ref.get(gateRed)) && !result.issues.some(isBlocking))
1160
+ const reviewed = !(yield* Ref.get(gateRed)) && diffNow.length > 0
1059
1161
  yield* options.events.publish(
1060
1162
  ReviewFindings.make({
1061
1163
  round,
1062
1164
  settled,
1165
+ ...(reviewed ? { diffLines: diffNow.split("\n").length } : {}),
1063
1166
  issues: result.issues.map((issue) =>
1064
1167
  ReviewFinding.make({
1065
1168
  severity: issue.severity,
@@ -1076,10 +1179,27 @@ export const reviewAndFixLoop = Effect.fn("@llm4ts/flow/Review.reviewAndFixLoop"
1076
1179
  if (settled) {
1077
1180
  return result
1078
1181
  }
1182
+ yield* applyFix(result)
1183
+ return yield* loop(round + 1, result)
1184
+ })
1185
+
1186
+ /** Sends the findings to the fixer; in recheck mode, records that the fix landed (ADR 0044). */
1187
+ const applyFix = (result: ReviewResult): Effect.Effect<void, FlowError> =>
1188
+ Effect.gen(function* () {
1079
1189
  const fix = fixPrompt(result, options.fix)
1080
1190
  yield* options.fixWith === undefined ? options.coder.ask(fix) : options.fixWith(fix)
1081
- return yield* loop(round + 1, result)
1191
+ const remembered = yield* Ref.get(memory)
1192
+ if (recheck?.memory !== undefined && remembered !== undefined) {
1193
+ const fixed = ReviewMemory.make({ ...remembered, fixed: true })
1194
+ yield* Ref.set(memory, fixed)
1195
+ yield* recheck.memory.save(fixed)
1196
+ }
1082
1197
  })
1083
1198
 
1084
- return yield* loop(1, undefined)
1199
+ // A restart resumes after the last round its reviewers answered (ADR 0044),
1200
+ // first applying that round's fix when it never landed.
1201
+ if (loaded !== undefined && loaded.fixed !== true && !standing(loaded).isClean) {
1202
+ yield* applyFix(standing(loaded))
1203
+ }
1204
+ return yield* loop(loaded === undefined ? 1 : Math.min(loaded.round + 1, maxRounds), undefined)
1085
1205
  })
@@ -3,7 +3,7 @@ import * as Effect from "effect/Effect"
3
3
  import * as Schema from "effect/Schema"
4
4
  import { PersistenceError, type FlowError } from "./FlowError.ts"
5
5
  import type { PlainFileStoreShape } from "./Persistence.ts"
6
- import { ReviewResult } from "./Review.ts"
6
+ import { ReviewResult } from "./ReviewModel.ts"
7
7
 
8
8
  /** A stable key over parts, length-prefixed so a split never collides with another. */
9
9
  export const fingerprintOf = (parts: ReadonlyArray<string>): string => {
@@ -0,0 +1,63 @@
1
+ /**
2
+ * What a review answers: findings and their result. Kept apart from
3
+ * `Review.ts` so modules that `Review.ts` imports can build schemas over them
4
+ * at load time without an import cycle (ADR 0044).
5
+ */
6
+ import * as Effect from "effect/Effect"
7
+ import * as Schema from "effect/Schema"
8
+
9
+ export const Severity = Schema.Literals(["Critical", "Warning", "Info"])
10
+ export type Severity = typeof Severity.Type
11
+
12
+ export class ReviewIssue extends Schema.Class<ReviewIssue>("ReviewIssue")({
13
+ severity: Severity,
14
+ title: Schema.String,
15
+ description: Schema.String.pipe(
16
+ Schema.withConstructorDefault(Effect.succeed("")),
17
+ Schema.withDecodingDefaultKey(Effect.succeed(""))
18
+ ),
19
+ file: Schema.optionalKey(Schema.String),
20
+ line: Schema.optionalKey(Schema.Int),
21
+ suggestion: Schema.optionalKey(Schema.String),
22
+ confidence: Schema.Number.pipe(
23
+ Schema.withConstructorDefault(Effect.succeed(1)),
24
+ Schema.withDecodingDefaultKey(Effect.succeed(1))
25
+ ),
26
+ /** A gate failure's triage against the base (ADR 0027); absent for review findings. */
27
+ origin: Schema.optionalKey(Schema.Literals(["new", "base", "flaky"])),
28
+ /** How a gate ended: a red exit, killed at the timeout, or a signal exit. */
29
+ gateClass: Schema.optionalKey(Schema.Literals(["red", "hang", "crash"])),
30
+ /** Why a test gate's output could not stand for green: no tests ran, or test files failed to load. */
31
+ gateReason: Schema.optionalKey(Schema.Literals(["zero-tests", "load-failure"])),
32
+ /** Where the gate's full output was written, when the caller asked for a log. */
33
+ logPath: Schema.optionalKey(Schema.String),
34
+ /** The reviewer that raised it (ADR 0044): a lens name, or "judge"; absent for gate findings. */
35
+ lens: Schema.optionalKey(Schema.String)
36
+ }) {}
37
+
38
+ export class ReviewResult extends Schema.Class<ReviewResult>("ReviewResult")({
39
+ issues: Schema.Array(ReviewIssue),
40
+ summary: Schema.String.pipe(
41
+ Schema.withConstructorDefault(Effect.succeed("")),
42
+ Schema.withDecodingDefaultKey(Effect.succeed(""))
43
+ ),
44
+ /** A test gate's passed-test count, when its output had a summary the oracle guard reads (ADR 0027). */
45
+ passed: Schema.optionalKey(Schema.Int)
46
+ }) {
47
+ get isClean(): boolean {
48
+ return this.issues.length === 0
49
+ }
50
+ }
51
+
52
+ export const reviewPrompt = (task: string, diff: string): string =>
53
+ [
54
+ `Review the change below for task "${task}". Report problems as JSON:`,
55
+ '{"issues":[{"severity":"Critical|Warning|Info","title":"...",',
56
+ '"description":"..."}],"summary":"..."}.',
57
+ 'An empty "issues" array means the change is acceptable. Respond with JSON only.',
58
+ "The diff below is the whole subject: judge what it shows. Do not explore the",
59
+ "repository, read other files, or run anything; answer from the diff.",
60
+ "",
61
+ "Diff:",
62
+ diff
63
+ ].join("\n")