@llm4ts/shell 0.2.2 → 0.3.1
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/flows/modernize-bench.ts +362 -0
- package/flows/modernize-extract.ts +124 -13
- package/flows/modernize-implement.ts +333 -0
- package/flows/modernize-review.ts +343 -0
- package/flows/modernize-seed.ts +259 -0
- package/flows/modernize-survey.ts +0 -0
- package/flows/modernize-verify.ts +611 -0
- package/package.json +4 -4
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
// Legacy modernization benchmark: measure an extraction run and report the cost of a wave.
|
|
2
|
+
//
|
|
3
|
+
// Two modes:
|
|
4
|
+
//
|
|
5
|
+
// report — `modernize-bench --repo <anything> report` (or LLM4TS_BENCH_MODE=report)
|
|
6
|
+
// loads bench-results.jsonl next to the launch directory and prints
|
|
7
|
+
// the comparison report. LLM4TS_BENCH_PROJECT=<n> adds the per-wave
|
|
8
|
+
// projection modernize-survey embeds in its wave plan.
|
|
9
|
+
//
|
|
10
|
+
// measure — the default. Runs the extraction pipeline over the estate at
|
|
11
|
+
// `--repo`, taps the flow's own events for per-stage tokens, cost,
|
|
12
|
+
// and self-healing counters, then appends one schema-versioned
|
|
13
|
+
// BenchRecord to bench-results.jsonl. Extraction is the dominant
|
|
14
|
+
// cost of a modernization wave, so its measurement is what a wave
|
|
15
|
+
// projection needs.
|
|
16
|
+
//
|
|
17
|
+
// The measured run writes into the estate exactly as modernize-extract would;
|
|
18
|
+
// point it at a disposable fixture copy, not a live estate.
|
|
19
|
+
//
|
|
20
|
+
// Run: modernize-bench --repo ~/estates/fixture-copy
|
|
21
|
+
import { arch, cpus, hostname, totalmem, type as osType } from "node:os"
|
|
22
|
+
import { join } from "node:path"
|
|
23
|
+
import * as Effect from "effect/Effect"
|
|
24
|
+
import { Sample } from "@llm4ts/core/eval/Eval"
|
|
25
|
+
import type { TokenUsage } from "@llm4ts/core/Models"
|
|
26
|
+
import { judge } from "@llm4ts/core/eval/Judge"
|
|
27
|
+
import type { LlmServiceShape } from "@llm4ts/core/LlmService"
|
|
28
|
+
import {
|
|
29
|
+
BenchJudge,
|
|
30
|
+
BenchMachine,
|
|
31
|
+
BenchQuality,
|
|
32
|
+
BenchRecord,
|
|
33
|
+
BenchScore,
|
|
34
|
+
CurrentBenchSchema,
|
|
35
|
+
makeBenchPhase,
|
|
36
|
+
makeBenchTap,
|
|
37
|
+
type BenchPhase
|
|
38
|
+
} from "@llm4ts/flow/Bench"
|
|
39
|
+
import { loadBenchRecords, appendBenchRecord, renderBenchReport } from "@llm4ts/flow/BenchReport"
|
|
40
|
+
import { FlowAborted, FlowLlmError } from "@llm4ts/flow/FlowError"
|
|
41
|
+
import { Info, TokensUsed } from "@llm4ts/flow/FlowEvents"
|
|
42
|
+
import { packageVersion } from "@llm4ts/flow/Package"
|
|
43
|
+
import { loadPack } from "@llm4ts/flow/Pack"
|
|
44
|
+
import { stage } from "@llm4ts/flow/PlanExecution"
|
|
45
|
+
import { mergeReviewResults } from "@llm4ts/flow/Review"
|
|
46
|
+
import { coverage, coverageUnits, features, matchingFiles } from "@llm4ts/flow/SpecChecks"
|
|
47
|
+
import {
|
|
48
|
+
ProgramArtifacts,
|
|
49
|
+
ProgramUnit,
|
|
50
|
+
extractProgramsResumably
|
|
51
|
+
} from "@llm4ts/modernize/Artifacts"
|
|
52
|
+
import { asReadOnly, coderFromEnv } from "@llm4ts/runner/Connectors"
|
|
53
|
+
import { resolveFlowInput } from "@llm4ts/runner/FlowArgs"
|
|
54
|
+
import { runFlowMain, runNode } from "@llm4ts/runner/FlowRunner"
|
|
55
|
+
import { nodePlainFileStore } from "@llm4ts/runner/NodePlainFileStore"
|
|
56
|
+
import { makeNodeWorkspace } from "@llm4ts/runner/NodeWorkspace"
|
|
57
|
+
import { reviewFingerprint } from "@llm4ts/runner/ReviewFingerprint"
|
|
58
|
+
|
|
59
|
+
const ModDir = "docs/modernization"
|
|
60
|
+
const BenchFile = "bench-results.jsonl"
|
|
61
|
+
|
|
62
|
+
const programName = (relativePath: string): string => {
|
|
63
|
+
const base = relativePath.slice(relativePath.lastIndexOf("/") + 1)
|
|
64
|
+
const dot = base.lastIndexOf(".")
|
|
65
|
+
return dot > 0 ? base.slice(0, dot) : base
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const machine = (): BenchMachine =>
|
|
69
|
+
BenchMachine.make({
|
|
70
|
+
hostname: hostname(),
|
|
71
|
+
os: osType(),
|
|
72
|
+
arch: arch(),
|
|
73
|
+
cores: cpus().length,
|
|
74
|
+
memoryGb: Math.round((totalmem() / 1024 ** 3) * 10) / 10,
|
|
75
|
+
runtime: `node ${process.versions.node}`
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
const program = Effect.gen(function* () {
|
|
79
|
+
const input = yield* resolveFlowInput("Benchmark a modernization extraction run")
|
|
80
|
+
const packDir = process.env.LLM4TS_PACK ?? "packs/cobol-springboot"
|
|
81
|
+
const mode =
|
|
82
|
+
process.env.LLM4TS_BENCH_MODE?.trim() ??
|
|
83
|
+
(input.prompt.trim().toLowerCase() === "report" ? "report" : "measure")
|
|
84
|
+
const files = nodePlainFileStore
|
|
85
|
+
const benchPath = join(input.workspace, BenchFile)
|
|
86
|
+
const coder = coderFromEnv(process.env)
|
|
87
|
+
const startedAt = new Date()
|
|
88
|
+
|
|
89
|
+
if (mode === "report") {
|
|
90
|
+
const records = yield* loadBenchRecords(files, benchPath)
|
|
91
|
+
if (records.length === 0) {
|
|
92
|
+
return yield* FlowAborted.make({ message: `no benchmark records at ${benchPath}` })
|
|
93
|
+
}
|
|
94
|
+
const projected = Number.parseInt(process.env.LLM4TS_BENCH_PROJECT ?? "", 10)
|
|
95
|
+
process.stdout.write(
|
|
96
|
+
`${renderBenchReport(records, Number.isFinite(projected) && projected > 0 ? projected : undefined)}\n`
|
|
97
|
+
)
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
yield* runNode(
|
|
102
|
+
{
|
|
103
|
+
workDir: input.workDir,
|
|
104
|
+
workspace: input.workspace,
|
|
105
|
+
userPrompt: input.prompt,
|
|
106
|
+
coder,
|
|
107
|
+
reasoning: asReadOnly(coder),
|
|
108
|
+
environment: process.env
|
|
109
|
+
},
|
|
110
|
+
(context) =>
|
|
111
|
+
Effect.scoped(
|
|
112
|
+
Effect.gen(function* () {
|
|
113
|
+
const launchWorkspace = yield* makeNodeWorkspace(input.workspace)
|
|
114
|
+
const estate = yield* makeNodeWorkspace(input.workDir)
|
|
115
|
+
const pack = yield* stage(context.events, "pack", loadPack(launchWorkspace, packDir))
|
|
116
|
+
const modDirAbs = join(input.workDir, ModDir)
|
|
117
|
+
// The tap observes the same events the terminal renders, so the
|
|
118
|
+
// measurement never needs its own instrumentation inside the flow.
|
|
119
|
+
const tap = yield* makeBenchTap(context.events)
|
|
120
|
+
const phases: Array<BenchPhase> = []
|
|
121
|
+
|
|
122
|
+
const measured = <A, E>(name: string, body: Effect.Effect<A, E>) =>
|
|
123
|
+
Effect.gen(function* () {
|
|
124
|
+
yield* tap.reset
|
|
125
|
+
const began = Date.now()
|
|
126
|
+
const value = yield* stage(context.events, name, body)
|
|
127
|
+
const observation = yield* tap.get
|
|
128
|
+
phases.push(makeBenchPhase(observation, name, Date.now() - began))
|
|
129
|
+
return value
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
const programs = yield* measured(
|
|
133
|
+
"inventory",
|
|
134
|
+
matchingFiles(estate, pack.programs ?? pack.sources ?? ".*")
|
|
135
|
+
)
|
|
136
|
+
if (programs.length === 0) {
|
|
137
|
+
return yield* FlowAborted.make({
|
|
138
|
+
message: `no source units matched the pack's programs/sources regex under ${input.workDir}`
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
const units = programs.map((relativePath) =>
|
|
142
|
+
ProgramUnit.make({ name: programName(relativePath), sourcePath: relativePath })
|
|
143
|
+
)
|
|
144
|
+
const system = [
|
|
145
|
+
pack.prompt("analysis"),
|
|
146
|
+
pack.lessons === undefined
|
|
147
|
+
? undefined
|
|
148
|
+
: `Lessons from previous modernization runs — apply them:\n${pack.lessons}`
|
|
149
|
+
]
|
|
150
|
+
.filter((part) => part !== undefined)
|
|
151
|
+
.join("\n\n")
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Structured calls report usage only to their caller; the Chat seam
|
|
155
|
+
* is what normally publishes `TokensUsed` (ADR 0005). A benchmark
|
|
156
|
+
* that skipped this would measure wall-clock and nothing else, so
|
|
157
|
+
* every measured call republishes its own usage for the tap.
|
|
158
|
+
*/
|
|
159
|
+
const publishUsage = (agent: string, usage: TokenUsage, model: string | undefined) =>
|
|
160
|
+
context.events.publish(
|
|
161
|
+
TokensUsed.make({
|
|
162
|
+
agent,
|
|
163
|
+
usage,
|
|
164
|
+
...(model === undefined ? {} : { model })
|
|
165
|
+
})
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
yield* measured(
|
|
169
|
+
"extract",
|
|
170
|
+
extractProgramsResumably(
|
|
171
|
+
files,
|
|
172
|
+
units,
|
|
173
|
+
(unit) =>
|
|
174
|
+
context.coder
|
|
175
|
+
.executeStructuredWithUsage(
|
|
176
|
+
[
|
|
177
|
+
system,
|
|
178
|
+
"",
|
|
179
|
+
`Extract the behavioural spec for ONE source unit: ${unit.sourcePath}`,
|
|
180
|
+
'Respond only with JSON: {"spec":"…","feature":"…","traceability":"…","mapping":"…"}.',
|
|
181
|
+
pack.prompt("spec") ?? "",
|
|
182
|
+
pack.prompt("bdd") ?? ""
|
|
183
|
+
].join("\n"),
|
|
184
|
+
ProgramArtifacts,
|
|
185
|
+
{ type: "object" }
|
|
186
|
+
)
|
|
187
|
+
.pipe(
|
|
188
|
+
Effect.mapError(FlowLlmError.from),
|
|
189
|
+
Effect.tap(([, usage, model]) =>
|
|
190
|
+
usage === undefined ? Effect.void : publishUsage("coder", usage, model)
|
|
191
|
+
),
|
|
192
|
+
Effect.map(([artifacts]) => artifacts)
|
|
193
|
+
),
|
|
194
|
+
modDirAbs
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
// The deterministic half of the extraction gate, measured on its own:
|
|
199
|
+
// it is the part a slower model cannot make cheaper.
|
|
200
|
+
const deterministic = yield* measured(
|
|
201
|
+
"gate",
|
|
202
|
+
Effect.gen(function* () {
|
|
203
|
+
const fragments: Array<string> = []
|
|
204
|
+
for (const unit of units) {
|
|
205
|
+
const text = yield* files.read(join(modDirAbs, "traceability", `${unit.name}.md`))
|
|
206
|
+
if (text !== undefined) {
|
|
207
|
+
fragments.push(text)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const trace = fragments.join("\n\n")
|
|
211
|
+
const covered = yield* coverage(estate, pack.coverage, trace)
|
|
212
|
+
const wellFormed = yield* features(estate, `${ModDir}/features`)
|
|
213
|
+
return mergeReviewResults([covered, wellFormed])
|
|
214
|
+
})
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The evaluator has no usage-reporting variant, so the seat it runs
|
|
219
|
+
* on is wrapped instead: every structured call it makes reports its
|
|
220
|
+
* usage to the tap before returning the value.
|
|
221
|
+
*/
|
|
222
|
+
const metered: LlmServiceShape = {
|
|
223
|
+
...context.reasoning,
|
|
224
|
+
executeStructured: (prompt, schema, jsonSchema) =>
|
|
225
|
+
context.reasoning.executeStructuredWithUsage(prompt, schema, jsonSchema).pipe(
|
|
226
|
+
Effect.tap(([, usage, model]) =>
|
|
227
|
+
usage === undefined ? Effect.void : publishUsage("reasoning", usage, model)
|
|
228
|
+
),
|
|
229
|
+
Effect.map(([value]) => value)
|
|
230
|
+
)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const judged = yield* measured(
|
|
234
|
+
"judge",
|
|
235
|
+
Effect.gen(function* () {
|
|
236
|
+
const packJudge = judge(metered, pack.judgeDimensions)
|
|
237
|
+
const scores: Array<BenchScore> = []
|
|
238
|
+
let findings = 0
|
|
239
|
+
for (const unit of units) {
|
|
240
|
+
const spec = (yield* files.read(join(modDirAbs, "specs", `${unit.name}.md`))) ?? ""
|
|
241
|
+
const source = (yield* files.read(join(input.workDir, unit.sourcePath))) ?? ""
|
|
242
|
+
const scored = yield* packJudge
|
|
243
|
+
.evaluate(Sample.make({ response: spec, context: source, query: input.prompt }))
|
|
244
|
+
.pipe(Effect.mapError(FlowLlmError.from))
|
|
245
|
+
for (const score of scored.scores) {
|
|
246
|
+
const max = pack.judgeDimensions.find((d) => d.name === score.name)?.maxScore ?? 2
|
|
247
|
+
scores.push(
|
|
248
|
+
BenchScore.make({
|
|
249
|
+
name: `${unit.name}:${score.name}`,
|
|
250
|
+
score: score.score,
|
|
251
|
+
max,
|
|
252
|
+
reasoning: score.reasoning
|
|
253
|
+
})
|
|
254
|
+
)
|
|
255
|
+
if (score.score < max) {
|
|
256
|
+
findings += 1
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return { scores, findings }
|
|
261
|
+
})
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
const quality = yield* Effect.gen(function* () {
|
|
265
|
+
const specFiles = yield* matchingFiles(estate, `^${ModDir}/specs/.*\\.md$`).pipe(
|
|
266
|
+
Effect.orElseSucceed(() => [])
|
|
267
|
+
)
|
|
268
|
+
const featureFiles = yield* matchingFiles(
|
|
269
|
+
estate,
|
|
270
|
+
`^${ModDir}/features/.*\\.feature$`
|
|
271
|
+
).pipe(Effect.orElseSucceed(() => []))
|
|
272
|
+
let scenarios = 0
|
|
273
|
+
for (const path of featureFiles) {
|
|
274
|
+
const text = yield* estate.read(path).pipe(Effect.orElseSucceed(() => ""))
|
|
275
|
+
scenarios += text
|
|
276
|
+
.split(/\r?\n/)
|
|
277
|
+
.filter((line) => line.trim().startsWith("Scenario")).length
|
|
278
|
+
}
|
|
279
|
+
let sourceLines = 0
|
|
280
|
+
for (const path of programs) {
|
|
281
|
+
const text = yield* estate.read(path).pipe(Effect.orElseSucceed(() => ""))
|
|
282
|
+
sourceLines += text.split(/\r?\n/).length
|
|
283
|
+
}
|
|
284
|
+
const units_ = yield* coverageUnits(estate, pack.coverage)
|
|
285
|
+
const malformed = (yield* features(estate, `${ModDir}/features`)).issues.length
|
|
286
|
+
return BenchQuality.make({
|
|
287
|
+
featureFiles: featureFiles.length,
|
|
288
|
+
scenarios,
|
|
289
|
+
malformedFeatures: malformed,
|
|
290
|
+
specFiles: specFiles.length,
|
|
291
|
+
programs: programs.length,
|
|
292
|
+
sourceFiles: programs.length,
|
|
293
|
+
sourceLines,
|
|
294
|
+
testFiles: featureFiles.length,
|
|
295
|
+
testLines: scenarios,
|
|
296
|
+
deterministicFindings: deterministic.issues.length,
|
|
297
|
+
judgeFindings: judged.findings,
|
|
298
|
+
gateCleared: deterministic.issues.length === 0 && judged.findings === 0,
|
|
299
|
+
gateOpenIssues: deterministic.issues.length + judged.findings,
|
|
300
|
+
judgeScores: judged.scores,
|
|
301
|
+
// The rule universe is the estate's own coverage units.
|
|
302
|
+
coveragePct:
|
|
303
|
+
Object.values(units_).flat().length === 0
|
|
304
|
+
? 0
|
|
305
|
+
: Math.round(
|
|
306
|
+
(Object.values(units_)
|
|
307
|
+
.flat()
|
|
308
|
+
.filter((unit) => unit.length > 0).length /
|
|
309
|
+
Object.values(units_).flat().length) *
|
|
310
|
+
100
|
|
311
|
+
)
|
|
312
|
+
})
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
// The fingerprint groups records of the SAME estate so the report can
|
|
316
|
+
// compare providers rather than mixing unrelated fixtures.
|
|
317
|
+
const fingerprint = reviewFingerprint(
|
|
318
|
+
pack.name,
|
|
319
|
+
...[...programs].sort(),
|
|
320
|
+
String(quality.sourceLines)
|
|
321
|
+
).slice(0, 16)
|
|
322
|
+
|
|
323
|
+
const finishedAt = new Date()
|
|
324
|
+
const record = BenchRecord.make({
|
|
325
|
+
schemaVersion: CurrentBenchSchema,
|
|
326
|
+
runId: `${startedAt.toISOString()}-${process.pid}`,
|
|
327
|
+
startedAt: startedAt.toISOString(),
|
|
328
|
+
finishedAt: finishedAt.toISOString(),
|
|
329
|
+
provider: coder.connectorId.value,
|
|
330
|
+
modelRequested: coder.model ?? "(harness default)",
|
|
331
|
+
modelsServed: [],
|
|
332
|
+
judge: BenchJudge.make({
|
|
333
|
+
provider: coder.connectorId.value,
|
|
334
|
+
model: coder.model ?? "(harness default)",
|
|
335
|
+
fixed: false
|
|
336
|
+
}),
|
|
337
|
+
llm4tsVersion: packageVersion,
|
|
338
|
+
pack: pack.name,
|
|
339
|
+
fixtureFingerprint: fingerprint,
|
|
340
|
+
machine: machine(),
|
|
341
|
+
outcome: quality.gateCleared === true ? "completed" : "gate-open",
|
|
342
|
+
totalMs: finishedAt.getTime() - startedAt.getTime(),
|
|
343
|
+
phases,
|
|
344
|
+
quality
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
yield* appendBenchRecord(files, benchPath, record)
|
|
348
|
+
yield* context.events.publish(
|
|
349
|
+
Info.make({
|
|
350
|
+
message:
|
|
351
|
+
`benchmark appended to ${benchPath} — ${record.totalTokens} token(s) over ` +
|
|
352
|
+
`${phases.length} phase(s); report with LLM4TS_BENCH_MODE=report`
|
|
353
|
+
})
|
|
354
|
+
)
|
|
355
|
+
const all = yield* loadBenchRecords(files, benchPath)
|
|
356
|
+
process.stdout.write(`${renderBenchReport(all)}\n`)
|
|
357
|
+
})
|
|
358
|
+
)
|
|
359
|
+
)
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
runFlowMain(program)
|
|
@@ -23,16 +23,18 @@
|
|
|
23
23
|
// plan. Judge context is bounded by LLM4TS_JUDGE_SOURCES_LIMIT (chars).
|
|
24
24
|
import { join } from "node:path"
|
|
25
25
|
import * as Effect from "effect/Effect"
|
|
26
|
-
import { Sample } from "@llm4ts/core/eval/Eval"
|
|
26
|
+
import { Sample, type EvalResult } from "@llm4ts/core/eval/Eval"
|
|
27
27
|
import { judge } from "@llm4ts/core/eval/Judge"
|
|
28
28
|
import type { JsonSchema } from "@llm4ts/core/Models"
|
|
29
29
|
import { FlowAborted, FlowLlmError } from "@llm4ts/flow/FlowError"
|
|
30
30
|
import { Info } from "@llm4ts/flow/FlowEvents"
|
|
31
31
|
import { makeChat } from "@llm4ts/flow/Chat"
|
|
32
32
|
import { loadPack, type Pack } from "@llm4ts/flow/Pack"
|
|
33
|
+
import { loadPatternCards, matchingPatternCards } from "@llm4ts/flow/Patterns"
|
|
33
34
|
import { stage } from "@llm4ts/flow/PlanExecution"
|
|
34
35
|
import { defaultPlanInstructions, planFrom } from "@llm4ts/flow/Planner"
|
|
35
36
|
import { ReviewIssue, ReviewResult, mergeReviewResults } from "@llm4ts/flow/Review"
|
|
37
|
+
import { cachedReview } from "@llm4ts/flow/ReviewCache"
|
|
36
38
|
import { coverage, coverageUnits, features, matchingFiles } from "@llm4ts/flow/SpecChecks"
|
|
37
39
|
import { withDraftApproval, requireApproval } from "@llm4ts/modernize/Approval"
|
|
38
40
|
import {
|
|
@@ -43,6 +45,7 @@ import {
|
|
|
43
45
|
import { asReadOnly, coderFromEnv } from "@llm4ts/runner/Connectors"
|
|
44
46
|
import { resolveFlowInput } from "@llm4ts/runner/FlowArgs"
|
|
45
47
|
import { runFlowMain, runNode } from "@llm4ts/runner/FlowRunner"
|
|
48
|
+
import { reviewFingerprint } from "@llm4ts/runner/ReviewFingerprint"
|
|
46
49
|
import { nodePlainFileStore } from "@llm4ts/runner/NodePlainFileStore"
|
|
47
50
|
import { makeNodeWorkspace } from "@llm4ts/runner/NodeWorkspace"
|
|
48
51
|
|
|
@@ -267,6 +270,13 @@ const program = Effect.gen(function* () {
|
|
|
267
270
|
ProgramUnit.make({ name: programName(rel), sourcePath: rel })
|
|
268
271
|
)
|
|
269
272
|
|
|
273
|
+
// Pattern cards are selected deterministically from the SOURCE, never by
|
|
274
|
+
// the model: implementation later injects exactly the cards cited here.
|
|
275
|
+
const cards = [
|
|
276
|
+
...(yield* loadPatternCards(launchWorkspace, `${packDir}/patterns`)),
|
|
277
|
+
...(yield* loadPatternCards(launchWorkspace, "patterns"))
|
|
278
|
+
]
|
|
279
|
+
|
|
270
280
|
// One structured analyst call per program, resumable per program: a rerun
|
|
271
281
|
// skips every program whose spec exists, and each program gets its own commit.
|
|
272
282
|
yield* stage(
|
|
@@ -290,11 +300,51 @@ const program = Effect.gen(function* () {
|
|
|
290
300
|
ProgramArtifacts,
|
|
291
301
|
programArtifactsJsonSchema
|
|
292
302
|
)
|
|
293
|
-
.pipe(
|
|
303
|
+
.pipe(
|
|
304
|
+
Effect.mapError(FlowLlmError.from),
|
|
305
|
+
// A turn-limit trip is the wedged-agent tail, not a
|
|
306
|
+
// failure: keep whatever the analyst already produced.
|
|
307
|
+
Effect.catchIf(
|
|
308
|
+
(error) => error.cause?._tag === "TurnLimitError",
|
|
309
|
+
(error) =>
|
|
310
|
+
Effect.gen(function* () {
|
|
311
|
+
const existing = yield* files.read(
|
|
312
|
+
join(modDirAbs, "specs", `${target.name}.md`)
|
|
313
|
+
)
|
|
314
|
+
if (existing === undefined) {
|
|
315
|
+
return yield* Effect.fail(error)
|
|
316
|
+
}
|
|
317
|
+
yield* context.events.publish(
|
|
318
|
+
Info.make({
|
|
319
|
+
message: `turn limit hit on ${target.sourcePath} after its spec was written — keeping the work`
|
|
320
|
+
})
|
|
321
|
+
)
|
|
322
|
+
return ProgramArtifacts.make({
|
|
323
|
+
spec: existing,
|
|
324
|
+
feature: "",
|
|
325
|
+
traceability: "",
|
|
326
|
+
mapping: ""
|
|
327
|
+
})
|
|
328
|
+
})
|
|
329
|
+
)
|
|
330
|
+
)
|
|
294
331
|
}),
|
|
295
332
|
modDirAbs
|
|
296
333
|
)
|
|
297
334
|
if (summary.created.length > 0) {
|
|
335
|
+
// Tag the traceability fragment with the cards this program's
|
|
336
|
+
// source matches — regex-decided, so implementation's playbook
|
|
337
|
+
// is reproducible.
|
|
338
|
+
const source = yield* files.read(join(input.workDir, unit.sourcePath))
|
|
339
|
+
const matched = source === undefined ? [] : matchingPatternCards(source, cards)
|
|
340
|
+
if (matched.length > 0) {
|
|
341
|
+
const fragmentPath = join(modDirAbs, "traceability", `${unit.name}.md`)
|
|
342
|
+
const fragment = (yield* files.read(fragmentPath)) ?? ""
|
|
343
|
+
yield* files.writeAtomic(
|
|
344
|
+
fragmentPath,
|
|
345
|
+
`${fragment.trimEnd()}\n\nPatterns: ${matched.map((card) => card.id).join(", ")}\n`
|
|
346
|
+
)
|
|
347
|
+
}
|
|
298
348
|
yield* context.git
|
|
299
349
|
.commitAll(`modernize(${pack.name}): spec ${unit.name}`)
|
|
300
350
|
.pipe(Effect.asVoid)
|
|
@@ -355,6 +405,50 @@ const program = Effect.gen(function* () {
|
|
|
355
405
|
const packJudge = judge(context.reasoning, pack.judgeDimensions)
|
|
356
406
|
const limit = judgeSourcesLimit()
|
|
357
407
|
|
|
408
|
+
/**
|
|
409
|
+
* An empty structured response that survives the in-run retries is
|
|
410
|
+
* usually DETERMINISTIC (context overflow), so repeating the same
|
|
411
|
+
* prompt cannot succeed — retry at half, then quarter context instead.
|
|
412
|
+
*/
|
|
413
|
+
const judgeWithShrink = (spec: string, feature: string, source: string) => {
|
|
414
|
+
const attempt = (
|
|
415
|
+
cap: number,
|
|
416
|
+
rest: ReadonlyArray<number>
|
|
417
|
+
): Effect.Effect<EvalResult, FlowLlmError> =>
|
|
418
|
+
packJudge
|
|
419
|
+
.evaluate(
|
|
420
|
+
Sample.make({
|
|
421
|
+
response: capText(`${spec}\n\n${feature}`, cap),
|
|
422
|
+
context: capText(source, cap),
|
|
423
|
+
query: input.prompt
|
|
424
|
+
})
|
|
425
|
+
)
|
|
426
|
+
.pipe(
|
|
427
|
+
Effect.mapError(FlowLlmError.from),
|
|
428
|
+
Effect.catchIf(
|
|
429
|
+
(error) => rest.length > 0 && error.message.includes("empty response"),
|
|
430
|
+
(error) => {
|
|
431
|
+
const [next, ...remaining] = rest
|
|
432
|
+
return context.events
|
|
433
|
+
.publish(
|
|
434
|
+
Info.make({
|
|
435
|
+
message: `judge returned empty at cap ${cap} chars — shrinking to ${next ?? cap}: ${error.message}`
|
|
436
|
+
})
|
|
437
|
+
)
|
|
438
|
+
.pipe(Effect.andThen(attempt(next ?? cap, remaining)))
|
|
439
|
+
}
|
|
440
|
+
)
|
|
441
|
+
)
|
|
442
|
+
return attempt(limit, [Math.floor(limit / 2), Math.floor(limit / 4)])
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Judging is resumable per program: the verdict persists under
|
|
447
|
+
* `gate/<NAME>.json`, fingerprinted over the source, spec, feature, and
|
|
448
|
+
* rubric it judged. Unchanged content reuses the stored verdict with NO
|
|
449
|
+
* model call, so a crash or quota death re-judges only what changed.
|
|
450
|
+
* Delete `gate/` to force a full re-judge.
|
|
451
|
+
*/
|
|
358
452
|
const judgeProgram = (unit: ProgramUnit) =>
|
|
359
453
|
Effect.gen(function* () {
|
|
360
454
|
const spec = (yield* files.read(join(modDirAbs, "specs", `${unit.name}.md`))) ?? ""
|
|
@@ -363,17 +457,20 @@ const program = Effect.gen(function* () {
|
|
|
363
457
|
join(modDirAbs, "features", `${unit.name.toLowerCase()}.feature`)
|
|
364
458
|
)) ?? ""
|
|
365
459
|
const source = (yield* files.read(join(input.workDir, unit.sourcePath))) ?? ""
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
460
|
+
const rubric = pack.judgeDimensions
|
|
461
|
+
.map(
|
|
462
|
+
(dimension) => `${dimension.name} (0..${dimension.maxScore}): ${dimension.rubric}`
|
|
463
|
+
)
|
|
464
|
+
.join("\n")
|
|
465
|
+
return yield* cachedReview(
|
|
466
|
+
files,
|
|
467
|
+
join(modDirAbs, "gate", `${unit.name}.json`),
|
|
468
|
+
reviewFingerprint(source, spec, feature, rubric),
|
|
469
|
+
context.events.publish(Info.make({ message: `judging ${unit.name}` })).pipe(
|
|
470
|
+
Effect.andThen(judgeWithShrink(spec, feature, source)),
|
|
471
|
+
Effect.map((scored) => judgeIssues(pack, scored, unit.name))
|
|
374
472
|
)
|
|
375
|
-
|
|
376
|
-
return judgeIssues(pack, scored, unit.name)
|
|
473
|
+
)
|
|
377
474
|
})
|
|
378
475
|
|
|
379
476
|
const gateEvaluate = Effect.gen(function* () {
|
|
@@ -432,7 +529,21 @@ const program = Effect.gen(function* () {
|
|
|
432
529
|
const turn = (ask: string, commitMessage: string) =>
|
|
433
530
|
Effect.gen(function* () {
|
|
434
531
|
const chat = yield* makeChat(context.coder, { system })
|
|
435
|
-
yield* chat.ask(ask)
|
|
532
|
+
yield* chat.ask(ask).pipe(
|
|
533
|
+
Effect.asVoid,
|
|
534
|
+
// A wedged agent that trips its turn limit mid-fix still wrote
|
|
535
|
+
// something; re-evaluate what landed instead of failing.
|
|
536
|
+
Effect.catchIf(
|
|
537
|
+
(error) => error._tag === "Llm" && error.cause?._tag === "TurnLimitError",
|
|
538
|
+
() =>
|
|
539
|
+
context.events.publish(
|
|
540
|
+
Info.make({
|
|
541
|
+
message:
|
|
542
|
+
"turn limit hit during a fix turn — re-evaluating what was written"
|
|
543
|
+
})
|
|
544
|
+
)
|
|
545
|
+
)
|
|
546
|
+
)
|
|
436
547
|
yield* context.git.commitAll(commitMessage).pipe(Effect.asVoid)
|
|
437
548
|
})
|
|
438
549
|
for (const [name, issues] of [...scoped.entries()].sort()) {
|