@llm4ts/flow 2.25.0 → 2.27.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/Flow.d.ts +5 -1
- package/dist/Flow.d.ts.map +1 -1
- package/dist/Flow.js +9 -1
- package/dist/Flow.js.map +1 -1
- package/dist/Retro.d.ts +185 -0
- package/dist/Retro.d.ts.map +1 -0
- package/dist/Retro.js +768 -0
- package/dist/Retro.js.map +1 -0
- package/dist/Review.d.ts +13 -0
- package/dist/Review.d.ts.map +1 -1
- package/dist/Review.js +17 -1
- package/dist/Review.js.map +1 -1
- package/dist/ReviewCache.d.ts +19 -2
- package/dist/ReviewCache.d.ts.map +1 -1
- package/dist/ReviewCache.js +27 -16
- package/dist/ReviewCache.js.map +1 -1
- package/dist/Stories.d.ts.map +1 -1
- package/dist/Stories.js +62 -12
- package/dist/Stories.js.map +1 -1
- package/package.json +3 -2
- package/src/Flow.ts +25 -2
- package/src/Retro.ts +1061 -0
- package/src/Review.ts +43 -4
- package/src/ReviewCache.ts +62 -23
- package/src/Stories.ts +100 -14
package/src/Review.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { join } from "node:path"
|
|
1
2
|
import * as Clock from "effect/Clock"
|
|
2
3
|
import * as Effect from "effect/Effect"
|
|
3
4
|
import * as Schema from "effect/Schema"
|
|
@@ -9,6 +10,8 @@ import { Capabilities } from "@llm4ts/core/Capability"
|
|
|
9
10
|
import { guarded } from "./CapabilityGuard.ts"
|
|
10
11
|
import type { Chat } from "./Chat.ts"
|
|
11
12
|
import { FlowLlmError, ProcessError, describeFlowError, type FlowError } from "./FlowError.ts"
|
|
13
|
+
import type { PlainFileStoreShape } from "./Persistence.ts"
|
|
14
|
+
import { cachedReview, fingerprintOf } from "./ReviewCache.ts"
|
|
12
15
|
import {
|
|
13
16
|
Info,
|
|
14
17
|
JudgmentObserved,
|
|
@@ -217,12 +220,21 @@ export const llmDriven = (picker: LlmServiceShape): ReviewerSelector => ({
|
|
|
217
220
|
}
|
|
218
221
|
})
|
|
219
222
|
|
|
223
|
+
/** A lens name as a file name. */
|
|
224
|
+
const fileSlug = (name: string): string =>
|
|
225
|
+
name
|
|
226
|
+
.toLowerCase()
|
|
227
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
228
|
+
.replace(/^-+|-+$/g, "") || "lens"
|
|
229
|
+
|
|
220
230
|
export const reviewPrompt = (task: string, diff: string): string =>
|
|
221
231
|
[
|
|
222
232
|
`Review the change below for task "${task}". Report problems as JSON:`,
|
|
223
233
|
'{"issues":[{"severity":"Critical|Warning|Info","title":"...",',
|
|
224
234
|
'"description":"..."}],"summary":"..."}.',
|
|
225
235
|
'An empty "issues" array means the change is acceptable. Respond with JSON only.',
|
|
236
|
+
"The diff below is the whole subject: judge what it shows. Do not explore the",
|
|
237
|
+
"repository, read other files, or run anything; answer from the diff.",
|
|
226
238
|
"",
|
|
227
239
|
"Diff:",
|
|
228
240
|
diff
|
|
@@ -320,6 +332,23 @@ export interface ReviewAndFixOptions {
|
|
|
320
332
|
* act mode skips lenses. Omit to disable the judgment entirely.
|
|
321
333
|
*/
|
|
322
334
|
readonly prescreen?: ReviewPrescreen
|
|
335
|
+
/**
|
|
336
|
+
* Where each lens's answer is kept beside a fingerprint of the lens, the
|
|
337
|
+
* task and the diff: a rerun over an unchanged diff reuses it instead of
|
|
338
|
+
* asking again. One file per lens under `dir`.
|
|
339
|
+
*/
|
|
340
|
+
readonly cache?: ReviewCacheLocation
|
|
341
|
+
/** Told after every round's findings are published, settled or not. */
|
|
342
|
+
readonly onRound?: (
|
|
343
|
+
round: number,
|
|
344
|
+
result: ReviewResult,
|
|
345
|
+
settled: boolean
|
|
346
|
+
) => Effect.Effect<void, FlowError>
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export interface ReviewCacheLocation {
|
|
350
|
+
readonly files: PlainFileStoreShape
|
|
351
|
+
readonly dir: string
|
|
323
352
|
}
|
|
324
353
|
|
|
325
354
|
export interface ReviewPrescreen {
|
|
@@ -476,10 +505,17 @@ export const reviewAndFixLoop = Effect.fn("@llm4ts/flow/Review.reviewAndFixLoop"
|
|
|
476
505
|
? { reviewers: selected, observations: [] }
|
|
477
506
|
: yield* prescreenReviewers(options.prescreen, options.events, diff, selected)
|
|
478
507
|
const chosen = screened.reviewers
|
|
479
|
-
const
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
508
|
+
const cache = options.cache
|
|
509
|
+
const review = (lens: Reviewer): Effect.Effect<ReviewResult, FlowError> =>
|
|
510
|
+
cache === undefined
|
|
511
|
+
? reviewWith(options.reviewerService, options.events, lens, options.taskTitle, diff)
|
|
512
|
+
: cachedReview(
|
|
513
|
+
cache.files,
|
|
514
|
+
join(cache.dir, `${fileSlug(lens.name)}.json`),
|
|
515
|
+
fingerprintOf([lens.name, lens.systemPrompt, options.taskTitle, diff]),
|
|
516
|
+
reviewWith(options.reviewerService, options.events, lens, options.taskTitle, diff)
|
|
517
|
+
)
|
|
518
|
+
const run = (lens: Reviewer) => Effect.map(review(lens), (result) => ({ lens, result }))
|
|
483
519
|
const parallelism = options.parallelism ?? 0
|
|
484
520
|
const results =
|
|
485
521
|
parallelism > 0
|
|
@@ -537,6 +573,9 @@ export const reviewAndFixLoop = Effect.fn("@llm4ts/flow/Review.reviewAndFixLoop"
|
|
|
537
573
|
)
|
|
538
574
|
})
|
|
539
575
|
)
|
|
576
|
+
if (options.onRound !== undefined) {
|
|
577
|
+
yield* options.onRound(round, result, settled)
|
|
578
|
+
}
|
|
540
579
|
if (settled) {
|
|
541
580
|
return result
|
|
542
581
|
}
|
package/src/ReviewCache.ts
CHANGED
|
@@ -1,45 +1,84 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
1
2
|
import * as Effect from "effect/Effect"
|
|
2
3
|
import * as Schema from "effect/Schema"
|
|
3
4
|
import { PersistenceError, type FlowError } from "./FlowError.ts"
|
|
4
5
|
import type { PlainFileStoreShape } from "./Persistence.ts"
|
|
5
6
|
import { ReviewResult } from "./Review.ts"
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
/** A stable key over parts, length-prefixed so a split never collides with another. */
|
|
9
|
+
export const fingerprintOf = (parts: ReadonlyArray<string>): string => {
|
|
10
|
+
const hash = createHash("sha256")
|
|
11
|
+
for (const part of parts) {
|
|
12
|
+
hash.update(`${part.length}:`)
|
|
13
|
+
hash.update(part)
|
|
14
|
+
}
|
|
15
|
+
return hash.digest("hex")
|
|
16
|
+
}
|
|
11
17
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
18
|
+
export interface CachedValue<A> {
|
|
19
|
+
readonly value: A
|
|
20
|
+
/** True when the answer came from disk, for an unchanged fingerprint. */
|
|
21
|
+
readonly reused: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CachedValueOptions {
|
|
25
|
+
/** False evaluates regardless and only stores the answer (default true). */
|
|
26
|
+
readonly reuse?: boolean
|
|
27
|
+
}
|
|
17
28
|
|
|
18
|
-
|
|
29
|
+
/**
|
|
30
|
+
* An answer persisted beside its fingerprint: a rerun re-evaluates only
|
|
31
|
+
* when the fingerprint changes, and says when it did not have to.
|
|
32
|
+
*/
|
|
33
|
+
export const cachedValue = Effect.fn("@llm4ts/flow/ReviewCache.cachedValue")(function* <
|
|
34
|
+
A,
|
|
35
|
+
E,
|
|
36
|
+
RD,
|
|
37
|
+
RE,
|
|
38
|
+
R
|
|
39
|
+
>(
|
|
19
40
|
files: PlainFileStoreShape,
|
|
20
41
|
path: string,
|
|
42
|
+
schema: Schema.Codec<A, E, RD, RE>,
|
|
21
43
|
fingerprint: string,
|
|
22
|
-
evaluate: Effect.Effect<
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const
|
|
44
|
+
evaluate: Effect.Effect<A, FlowError, R>,
|
|
45
|
+
options: CachedValueOptions = {}
|
|
46
|
+
): Effect.fn.Return<CachedValue<A>, FlowError, R | RD | RE> {
|
|
47
|
+
const Entry = Schema.fromJsonString(Schema.Struct({ fingerprint: Schema.String, result: schema }))
|
|
48
|
+
const contents =
|
|
49
|
+
options.reuse === false
|
|
50
|
+
? undefined
|
|
51
|
+
: yield* files.read(path).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
|
52
|
+
const entry =
|
|
53
|
+
contents === undefined
|
|
54
|
+
? undefined
|
|
55
|
+
: yield* Schema.decodeUnknownEffect(Entry)(contents).pipe(
|
|
56
|
+
Effect.option,
|
|
57
|
+
Effect.map((option) => (option._tag === "Some" ? option.value : undefined))
|
|
58
|
+
)
|
|
26
59
|
if (entry?.fingerprint === fingerprint) {
|
|
27
|
-
return entry.result
|
|
60
|
+
return { value: entry.result, reused: true }
|
|
28
61
|
}
|
|
29
62
|
const result = yield* evaluate
|
|
30
|
-
const encoded = yield* Schema.encodeEffect(
|
|
31
|
-
ReviewCacheEntry.make({
|
|
32
|
-
fingerprint,
|
|
33
|
-
result
|
|
34
|
-
})
|
|
35
|
-
).pipe(
|
|
63
|
+
const encoded = yield* Schema.encodeEffect(Entry)({ fingerprint, result }).pipe(
|
|
36
64
|
Effect.mapError((error) =>
|
|
37
65
|
PersistenceError.make({
|
|
38
|
-
message: `failed to encode
|
|
66
|
+
message: `failed to encode cache entry: ${String(error)}`,
|
|
39
67
|
cause: error
|
|
40
68
|
})
|
|
41
69
|
)
|
|
42
70
|
)
|
|
43
71
|
yield* files.writeAtomic(path, encoded)
|
|
44
|
-
return result
|
|
72
|
+
return { value: result, reused: false }
|
|
45
73
|
})
|
|
74
|
+
|
|
75
|
+
export const cachedReview = <R>(
|
|
76
|
+
files: PlainFileStoreShape,
|
|
77
|
+
path: string,
|
|
78
|
+
fingerprint: string,
|
|
79
|
+
evaluate: Effect.Effect<ReviewResult, FlowError, R>
|
|
80
|
+
): Effect.Effect<ReviewResult, FlowError, R> =>
|
|
81
|
+
Effect.map(
|
|
82
|
+
cachedValue(files, path, ReviewResult, fingerprint, evaluate),
|
|
83
|
+
(cached) => cached.value
|
|
84
|
+
)
|
package/src/Stories.ts
CHANGED
|
@@ -58,6 +58,7 @@ import { Plan, Task } from "./Plan.ts"
|
|
|
58
58
|
import { stage } from "./PlanExecution.ts"
|
|
59
59
|
import { planFrom } from "./Planner.ts"
|
|
60
60
|
import { ReviewIssue, ReviewResult } from "./Review.ts"
|
|
61
|
+
import { cachedValue, fingerprintOf } from "./ReviewCache.ts"
|
|
61
62
|
import type { Reviewer } from "./Reviewer.ts"
|
|
62
63
|
import {
|
|
63
64
|
dependentsOf,
|
|
@@ -85,6 +86,9 @@ export class StoryVerdict extends ReviewResult.extend<StoryVerdict>("StoryVerdic
|
|
|
85
86
|
dimensions: Schema.Array(JudgedDimension)
|
|
86
87
|
}) {}
|
|
87
88
|
|
|
89
|
+
/** What a judge answers: a scored verdict, or a plain review result. */
|
|
90
|
+
const Verdict = Schema.Union([StoryVerdict, ReviewResult])
|
|
91
|
+
|
|
88
92
|
export interface StorySeats {
|
|
89
93
|
readonly context: FlowContextShape
|
|
90
94
|
/** Running usage of this story's seats — estimates where the backend reports none. */
|
|
@@ -668,6 +672,37 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
668
672
|
const judgeRounds = Math.max(1, options.judgeRounds ?? 2)
|
|
669
673
|
const statePath = (story: Story): string => join(options.stateDir, `stories/${story.id}.json`)
|
|
670
674
|
const planPath = (story: Story): string => join(options.stateDir, `stories/${story.id}.plan.md`)
|
|
675
|
+
/** The judge's last verdict beside a fingerprint of what it judged. */
|
|
676
|
+
const judgePath = (story: Story): string =>
|
|
677
|
+
join(options.stateDir, `stories/${story.id}.judge.json`)
|
|
678
|
+
/** One file per review lens, beside a fingerprint of the task and diff. */
|
|
679
|
+
const reviewCacheDir = (story: Story): string =>
|
|
680
|
+
join(options.stateDir, `stories/${story.id}.review`)
|
|
681
|
+
/** What reviewers and the judge found, round by round, for people to read. */
|
|
682
|
+
const findingsPath = (story: Story): string =>
|
|
683
|
+
join(options.stateDir, `stories/${story.id}.findings.md`)
|
|
684
|
+
const findingLines = (result: ReviewResult): ReadonlyArray<string> =>
|
|
685
|
+
result.issues.map((issue) => {
|
|
686
|
+
const where =
|
|
687
|
+
issue.file === undefined
|
|
688
|
+
? ""
|
|
689
|
+
: ` (${issue.file}${issue.line === undefined ? "" : `:${issue.line}`})`
|
|
690
|
+
const detail = issue.description.trim().length === 0 ? "" : `: ${issue.description.trim()}`
|
|
691
|
+
return `- [${issue.severity}] ${issue.title}${where}${detail}`
|
|
692
|
+
})
|
|
693
|
+
const appendFindings = (
|
|
694
|
+
story: Story,
|
|
695
|
+
heading: string,
|
|
696
|
+
result: ReviewResult
|
|
697
|
+
): Effect.Effect<void, FlowError> =>
|
|
698
|
+
files.append(
|
|
699
|
+
findingsPath(story),
|
|
700
|
+
[
|
|
701
|
+
`## ${heading}`,
|
|
702
|
+
...(result.issues.length === 0 ? ["- no issues"] : findingLines(result)),
|
|
703
|
+
""
|
|
704
|
+
].join("\n") + "\n"
|
|
705
|
+
)
|
|
671
706
|
/** Git's empty tree: a diff against it is a path's whole current content. */
|
|
672
707
|
const emptyTree = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
|
|
673
708
|
const revisionTitle = /^Revision \d+: /u
|
|
@@ -1099,6 +1134,18 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
1099
1134
|
// satisfied — without saying the exact sentinel — must not sink the
|
|
1100
1135
|
// story: the option exists for pipelines shaped like this one.
|
|
1101
1136
|
noopTaskPolicy: "complete",
|
|
1137
|
+
// A lens's answer is kept beside the task and diff it answered, so a
|
|
1138
|
+
// rerun over the same change asks nothing; every round is written to
|
|
1139
|
+
// the story's findings log for people to read.
|
|
1140
|
+
reviewCache: { files, dir: reviewCacheDir(story) },
|
|
1141
|
+
onReview: (task, round, result, settled) =>
|
|
1142
|
+
appendFindings(
|
|
1143
|
+
story,
|
|
1144
|
+
`review "${task.title}" round ${round} — ${
|
|
1145
|
+
result.isClean ? "clean" : `${result.issues.length} issue(s)`
|
|
1146
|
+
}${settled ? "" : ", fixing"}`,
|
|
1147
|
+
result
|
|
1148
|
+
),
|
|
1102
1149
|
...(options.reviewers === undefined ? {} : { reviewers: options.reviewers }),
|
|
1103
1150
|
...(options.maxRounds === undefined ? {} : { maxRounds: options.maxRounds })
|
|
1104
1151
|
})
|
|
@@ -1151,23 +1198,62 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
1151
1198
|
// code instead: the owned paths against the empty tree.
|
|
1152
1199
|
const empty = diff.trim().length === 0
|
|
1153
1200
|
const subject = empty ? yield* git.diffVsBaseScoped(emptyTree, story.owned, false) : diff
|
|
1154
|
-
|
|
1201
|
+
// The verdict is kept beside a fingerprint of what was judged: a
|
|
1202
|
+
// rerun that finds the same diff gets the same answer without a
|
|
1203
|
+
// model call, and the last verdict is on disk to read. Only the
|
|
1204
|
+
// first round reuses it; a later round follows a revision and asks.
|
|
1205
|
+
const judged =
|
|
1155
1206
|
empty && subject.trim().length === 0
|
|
1156
1207
|
? // Nothing to judge is decided here, not asked of a model.
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1208
|
+
{
|
|
1209
|
+
value: ReviewResult.make({
|
|
1210
|
+
issues: [
|
|
1211
|
+
ReviewIssue.make({
|
|
1212
|
+
severity: "Critical",
|
|
1213
|
+
title: `nothing exists yet under ${story.owned.join(", ")}`,
|
|
1214
|
+
description: "The story's owned paths are empty on the epic branch."
|
|
1215
|
+
})
|
|
1216
|
+
],
|
|
1217
|
+
summary: `judge:${story.id}`
|
|
1218
|
+
}),
|
|
1219
|
+
reused: false
|
|
1220
|
+
}
|
|
1221
|
+
: yield* cachedValue(
|
|
1222
|
+
files,
|
|
1223
|
+
judgePath(story),
|
|
1224
|
+
Verdict,
|
|
1225
|
+
fingerprintOf([
|
|
1226
|
+
empty ? "code" : "diff",
|
|
1227
|
+
subject,
|
|
1228
|
+
story.id,
|
|
1229
|
+
story.title,
|
|
1230
|
+
story.description,
|
|
1231
|
+
story.provides.join("\n")
|
|
1232
|
+
]),
|
|
1233
|
+
withTimedRole(
|
|
1234
|
+
"judge",
|
|
1235
|
+
judge(story, subject, watchedSeats, empty ? "code" : "diff")
|
|
1236
|
+
),
|
|
1237
|
+
{ reuse: round === 1 }
|
|
1170
1238
|
)
|
|
1239
|
+
const verdict = judged.value
|
|
1240
|
+
if (judged.reused) {
|
|
1241
|
+
yield* laneOf(story).publish(
|
|
1242
|
+
Info.make({
|
|
1243
|
+
lane: story.id,
|
|
1244
|
+
message: `judge round ${round}: reused the verdict for an unchanged ${empty ? "code" : "diff"}`
|
|
1245
|
+
})
|
|
1246
|
+
)
|
|
1247
|
+
}
|
|
1248
|
+
yield* appendFindings(
|
|
1249
|
+
story,
|
|
1250
|
+
`judge round ${round} — ${verdict.isClean ? "cleared" : "not cleared"}${
|
|
1251
|
+
verdict instanceof StoryVerdict
|
|
1252
|
+
? ` (${verdict.dimensions.map((d) => `${d.id} ${d.score}/${d.max}`).join(", ")})`
|
|
1253
|
+
: ""
|
|
1254
|
+
}${judged.reused ? ", reused" : ""}`,
|
|
1255
|
+
verdict
|
|
1256
|
+
)
|
|
1171
1257
|
yield* laneOf(story).publish(
|
|
1172
1258
|
StoryJudged.make({
|
|
1173
1259
|
lane: story.id,
|