@llm4ts/shell 0.10.0 → 0.10.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-extract.js +54 -44
- package/flows/modernize-implement.js +110 -22
- package/flows/modernize-review.js +84 -17
- package/flows/modernize-survey.js +0 -0
- package/flows/modernize-verify.js +47 -12
- package/package.json +5 -5
|
@@ -21,14 +21,17 @@
|
|
|
21
21
|
// Pack: LLM4TS_PACK=<dir> (default packs/cobol-springboot, resolved against the
|
|
22
22
|
// launch dir, then against the flow's own directory — the built-in packs).
|
|
23
23
|
// LLM4TS_WAVE=<name> scopes the run to one wave of the approved plan. Judge
|
|
24
|
-
// context is bounded by
|
|
24
|
+
// context is bounded by LLM4TS_CONTEXT_BUDGET (chars;
|
|
25
|
+
// LLM4TS_JUDGE_SOURCES_LIMIT is the deprecated alias). The analyst is bounded
|
|
26
|
+
// by LLM4TS_ANALYST_TURNS and LLM4TS_MAX_CLOSURE_FILES.
|
|
25
27
|
import { join } from "node:path";
|
|
26
28
|
import * as Effect from "effect/Effect";
|
|
27
29
|
import { Sample } from "@llm4ts/core/eval/Eval";
|
|
28
30
|
import { judge } from "@llm4ts/core/eval/Judge";
|
|
31
|
+
import { budget, capped, withShrink } from "@llm4ts/flow/Context";
|
|
29
32
|
import { FlowAborted, FlowLlmError } from "@llm4ts/flow/FlowError";
|
|
30
33
|
import { structuredAndPublish } from "@llm4ts/flow/Flow";
|
|
31
|
-
import { Info } from "@llm4ts/flow/FlowEvents";
|
|
34
|
+
import { FlowEvents, Info } from "@llm4ts/flow/FlowEvents";
|
|
32
35
|
import { makeChat } from "@llm4ts/flow/Chat";
|
|
33
36
|
import { loadPatternCards, matchingPatternCards } from "@llm4ts/flow/Patterns";
|
|
34
37
|
import { legacySourceWorkspaceLimits, workspaceLimitsFromEnv } from "@llm4ts/flow/Workspace";
|
|
@@ -37,9 +40,10 @@ import { defaultPlanInstructions, planFrom } from "@llm4ts/flow/Planner";
|
|
|
37
40
|
import { ReviewIssue, ReviewResult, mergeReviewResults } from "@llm4ts/flow/Review";
|
|
38
41
|
import { cachedReview } from "@llm4ts/flow/ReviewCache";
|
|
39
42
|
import { coverage, coverageUnits, features, matchingFiles } from "@llm4ts/flow/SpecChecks";
|
|
43
|
+
import { SurveyGraph, closureFor, surveyGraph } from "@llm4ts/flow/Survey";
|
|
40
44
|
import { withDraftApproval, requireApproval } from "@llm4ts/modernize/Approval";
|
|
41
45
|
import { ProgramArtifacts, ProgramUnit, extractProgramsResumably } from "@llm4ts/modernize/Artifacts";
|
|
42
|
-
import { asReadOnly, coderFromEnv } from "@llm4ts/runner/Connectors";
|
|
46
|
+
import { asReadOnly, coderFromEnv, withTurnLimit } from "@llm4ts/runner/Connectors";
|
|
43
47
|
import { resolveFlowInput } from "@llm4ts/runner/FlowArgs";
|
|
44
48
|
import { runFlowMain, runNode } from "@llm4ts/runner/FlowRunner";
|
|
45
49
|
import { reviewFingerprint } from "@llm4ts/runner/ReviewFingerprint";
|
|
@@ -48,18 +52,17 @@ import { makeNodeWorkspace } from "@llm4ts/runner/NodeWorkspace";
|
|
|
48
52
|
import { loadUniversalPatternCards, openPack } from "@llm4ts/runner/Packs";
|
|
49
53
|
const ModDir = "docs/modernization";
|
|
50
54
|
const MaxRounds = 3;
|
|
51
|
-
const
|
|
52
|
-
const raw = Number.parseInt(process.env
|
|
53
|
-
return Number.isFinite(raw) && raw > 0 ? raw :
|
|
54
|
-
};
|
|
55
|
-
/** Past the limit keep head + tail so entry points and trailing rules stay visible. */
|
|
56
|
-
const capText = (text, limit) => {
|
|
57
|
-
if (text.length <= limit) {
|
|
58
|
-
return text;
|
|
59
|
-
}
|
|
60
|
-
const head = Math.floor((limit * 3) / 4);
|
|
61
|
-
return `${text.slice(0, head)}\n\n… [truncated] …\n\n${text.slice(text.length - (limit - head))}`;
|
|
55
|
+
const positiveEnvInt = (name, fallback) => {
|
|
56
|
+
const raw = Number.parseInt(process.env[name] ?? "", 10);
|
|
57
|
+
return Number.isFinite(raw) && raw > 0 ? raw : fallback;
|
|
62
58
|
};
|
|
59
|
+
// Per-program turn budget — bounds a wedged agent, generous for real work.
|
|
60
|
+
const analystTurns = () => positiveEnvInt("LLM4TS_ANALYST_TURNS", 48);
|
|
61
|
+
/**
|
|
62
|
+
* Max files named in one program's include closure. A program pulling more
|
|
63
|
+
* than this gets a bounded, visible subset rather than an unbounded read.
|
|
64
|
+
*/
|
|
65
|
+
const maxClosureFiles = () => positiveEnvInt("LLM4TS_MAX_CLOSURE_FILES", 40);
|
|
63
66
|
/** `cobol/ACCTXFR.cbl` → `ACCTXFR`: the program name keying every per-program artifact. */
|
|
64
67
|
const programName = (relativePath) => {
|
|
65
68
|
const base = relativePath.slice(relativePath.lastIndexOf("/") + 1);
|
|
@@ -94,11 +97,20 @@ const programArtifactsJsonSchema = {
|
|
|
94
97
|
},
|
|
95
98
|
required: ["spec", "feature", "traceability", "mapping"]
|
|
96
99
|
};
|
|
97
|
-
|
|
100
|
+
// The analyst gets a deterministically resolved include closure, not an open
|
|
101
|
+
// "read anything it references" instruction: told to chase references itself,
|
|
102
|
+
// the coding agent pulls files into its own context inside a single turn —
|
|
103
|
+
// which is how extract blew a 1M-token window while its own cap sat untouched.
|
|
104
|
+
const programAsk = (pack, relativePath, closure) => [
|
|
98
105
|
`Extract the behavioural spec for ONE source unit of this repository: ${relativePath}`,
|
|
99
106
|
"",
|
|
100
|
-
|
|
101
|
-
|
|
107
|
+
...(closure.length === 0
|
|
108
|
+
? [`Read ${relativePath}. It has no resolved dependencies.`]
|
|
109
|
+
: [
|
|
110
|
+
`Read ${relativePath} and EXACTLY these resolved dependencies — do not go looking for others:`,
|
|
111
|
+
...closure.map((file) => `- ${file}`)
|
|
112
|
+
]),
|
|
113
|
+
`Spec ONLY ${relativePath} and do not modify legacy sources.`,
|
|
102
114
|
"",
|
|
103
115
|
'Respond only with JSON: {"spec":"…","feature":"…","traceability":"…","mapping":"…"} where:',
|
|
104
116
|
"",
|
|
@@ -164,7 +176,7 @@ const readmeFor = (pack, verdict) => [
|
|
|
164
176
|
].join("\n");
|
|
165
177
|
const program = Effect.gen(function* () {
|
|
166
178
|
const input = yield* resolveFlowInput("Extract the complete behavioural spec pack for this legacy estate");
|
|
167
|
-
const coder = coderFromEnv(process.env);
|
|
179
|
+
const coder = withTurnLimit(coderFromEnv(process.env), analystTurns());
|
|
168
180
|
const files = nodePlainFileStore;
|
|
169
181
|
const modDirAbs = join(input.workDir, ModDir);
|
|
170
182
|
yield* runNode({
|
|
@@ -219,6 +231,15 @@ const program = Effect.gen(function* () {
|
|
|
219
231
|
...(yield* loadPatternCards(opened.workspace, `${opened.dir}/patterns`)),
|
|
220
232
|
...(yield* loadUniversalPatternCards([input.workspace, import.meta.dirname]))
|
|
221
233
|
];
|
|
234
|
+
// The dependency graph the analysts' include closures are resolved
|
|
235
|
+
// from — walked over the pack's `## Survey:` edge regexes.
|
|
236
|
+
const graph = yield* stage(context.events, "graph", pack.survey.length === 0
|
|
237
|
+
? context.events
|
|
238
|
+
.publish(Info.make({
|
|
239
|
+
message: "pack has no '## Survey:' edge regexes — the analyst gets no resolved closure"
|
|
240
|
+
}))
|
|
241
|
+
.pipe(Effect.as(SurveyGraph.make({ nodes: [], edges: [] })))
|
|
242
|
+
: surveyGraph(repo, pack.sources ?? ".*", pack.coverage, pack.survey));
|
|
222
243
|
// One structured analyst call per program, resumable per program: a rerun
|
|
223
244
|
// skips every program whose spec exists, and each program gets its own commit.
|
|
224
245
|
yield* stage(context.events, "extract", Effect.gen(function* () {
|
|
@@ -227,7 +248,7 @@ const program = Effect.gen(function* () {
|
|
|
227
248
|
yield* context.events.publish(Info.make({
|
|
228
249
|
message: `extracting ${target.sourcePath} (${index + 1}/${units.length})`
|
|
229
250
|
}));
|
|
230
|
-
return yield* structuredAndPublish(context.coder, context.events, `${system}\n\n${programAsk(pack, target.sourcePath)}`, ProgramArtifacts, programArtifactsJsonSchema, "coder").pipe(
|
|
251
|
+
return yield* structuredAndPublish(context.coder, context.events, `${system}\n\n${programAsk(pack, target.sourcePath, closureFor(graph, target.name, maxClosureFiles()))}`, ProgramArtifacts, programArtifactsJsonSchema, "coder").pipe(
|
|
231
252
|
// A turn-limit trip is the wedged-agent tail, not a
|
|
232
253
|
// failure: keep whatever the analyst already produced.
|
|
233
254
|
Effect.catchIf((error) => error.cause?._tag === "TurnLimitError", (error) => Effect.gen(function* () {
|
|
@@ -300,29 +321,17 @@ const program = Effect.gen(function* () {
|
|
|
300
321
|
.commitAll(`modernize(${pack.name}): spec pack draft (ungated)`)
|
|
301
322
|
.pipe(Effect.asVoid))));
|
|
302
323
|
const packJudge = judge(context.reasoning, pack.judgeDimensions);
|
|
303
|
-
const limit =
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
.evaluate(Sample.make({
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
query: input.prompt
|
|
315
|
-
}))
|
|
316
|
-
.pipe(Effect.mapError(FlowLlmError.from), Effect.catchIf((error) => rest.length > 0 && error.message.includes("empty response"), (error) => {
|
|
317
|
-
const [next, ...remaining] = rest;
|
|
318
|
-
return context.events
|
|
319
|
-
.publish(Info.make({
|
|
320
|
-
message: `judge returned empty at cap ${cap} chars — shrinking to ${next ?? cap}: ${error.message}`
|
|
321
|
-
}))
|
|
322
|
-
.pipe(Effect.andThen(attempt(next ?? cap, remaining)));
|
|
323
|
-
}));
|
|
324
|
-
return attempt(limit, [Math.floor(limit / 2), Math.floor(limit / 4)]);
|
|
325
|
-
};
|
|
324
|
+
const limit = budget();
|
|
325
|
+
// The shared Context ladder: an oversized prompt retries at half, then
|
|
326
|
+
// quarter budget (repeating it identically cannot succeed), and every
|
|
327
|
+
// cap or shrink is recorded and published.
|
|
328
|
+
const judgeWithShrink = (name, spec, feature, source) => withShrink(`judge[${name}]`, (cap) => Effect.gen(function* () {
|
|
329
|
+
const response = yield* capped(`spec[${name}]`, `${spec}\n\n${feature}`, cap);
|
|
330
|
+
const source_ = yield* capped(`source[${name}]`, source, cap);
|
|
331
|
+
return yield* packJudge
|
|
332
|
+
.evaluate(Sample.make({ response, context: source_, query: input.prompt }))
|
|
333
|
+
.pipe(Effect.mapError(FlowLlmError.from));
|
|
334
|
+
}), { start: limit }).pipe(Effect.provideService(FlowEvents, context.events));
|
|
326
335
|
/**
|
|
327
336
|
* Judging is resumable per program: the verdict persists under
|
|
328
337
|
* `gate/<NAME>.json`, fingerprinted over the source, spec, feature, and
|
|
@@ -337,7 +346,7 @@ const program = Effect.gen(function* () {
|
|
|
337
346
|
const rubric = pack.judgeDimensions
|
|
338
347
|
.map((dimension) => `${dimension.name} (0..${dimension.maxScore}): ${dimension.rubric}`)
|
|
339
348
|
.join("\n");
|
|
340
|
-
return yield* cachedReview(files, join(modDirAbs, "gate", `${unit.name}.json`), reviewFingerprint(source, spec, feature, rubric), context.events.publish(Info.make({ message: `judging ${unit.name}` })).pipe(Effect.andThen(judgeWithShrink(spec, feature, source)), Effect.map((scored) => judgeIssues(pack, scored, unit.name))));
|
|
349
|
+
return yield* cachedReview(files, join(modDirAbs, "gate", `${unit.name}.json`), reviewFingerprint(source, spec, feature, rubric), context.events.publish(Info.make({ message: `judging ${unit.name}` })).pipe(Effect.andThen(judgeWithShrink(unit.name, spec, feature, source)), Effect.map((scored) => judgeIssues(pack, scored, unit.name))));
|
|
341
350
|
});
|
|
342
351
|
const gateEvaluate = Effect.gen(function* () {
|
|
343
352
|
yield* rebuildIndexes;
|
|
@@ -427,7 +436,8 @@ const program = Effect.gen(function* () {
|
|
|
427
436
|
specTexts.push(spec);
|
|
428
437
|
}
|
|
429
438
|
}
|
|
430
|
-
const
|
|
439
|
+
const plannerSpecs = yield* capped("plan specs", specTexts.join("\n\n"), limit).pipe(Effect.provideService(FlowEvents, context.events));
|
|
440
|
+
const plan = yield* planFrom(context.reasoning, plannerSpecs, `${defaultPlanInstructions}\n\n${pack.prompt("plan") ?? ""}`);
|
|
431
441
|
yield* files.writeAtomic(join(modDirAbs, "plan.md"), plan.render);
|
|
432
442
|
}));
|
|
433
443
|
}
|
|
@@ -23,14 +23,18 @@ import * as Effect from "effect/Effect";
|
|
|
23
23
|
import { Dimension, Sample } from "@llm4ts/core/eval/Eval";
|
|
24
24
|
import { judge } from "@llm4ts/core/eval/Judge";
|
|
25
25
|
import { makeChat } from "@llm4ts/flow/Chat";
|
|
26
|
+
import { capped, renderTruncation, truncations, withShrink } from "@llm4ts/flow/Context";
|
|
26
27
|
import { FlowAborted, FlowLlmError } from "@llm4ts/flow/FlowError";
|
|
27
|
-
import { Info } from "@llm4ts/flow/FlowEvents";
|
|
28
|
+
import { FlowEvents, Info } from "@llm4ts/flow/FlowEvents";
|
|
29
|
+
import { judgeAllPrograms } from "@llm4ts/flow/ProgramJudge";
|
|
30
|
+
import { Provenance, makeProvenanceStore } from "@llm4ts/flow/Provenance";
|
|
28
31
|
import { loadPatternCards, taggedPatternIds } from "@llm4ts/flow/Patterns";
|
|
29
32
|
import { makePlanStore } from "@llm4ts/flow/Persistence";
|
|
30
33
|
import { implementTaskLoop, stage } from "@llm4ts/flow/PlanExecution";
|
|
31
|
-
import { lintCommand, minimalReviewers, reviewAndFixLoop } from "@llm4ts/flow/Review";
|
|
34
|
+
import { ReviewIssue, ReviewResult, lintCommand, mergeReviewResults, minimalReviewers, reviewAndFixLoop } from "@llm4ts/flow/Review";
|
|
32
35
|
import { checkWall, wallBreachMessage } from "@llm4ts/flow/Wall";
|
|
33
36
|
import { asReadOnly, coderFromEnv } from "@llm4ts/runner/Connectors";
|
|
37
|
+
import { reviewFingerprint } from "@llm4ts/runner/ReviewFingerprint";
|
|
34
38
|
import { resolveFlowInput } from "@llm4ts/runner/FlowArgs";
|
|
35
39
|
import { runFlowMain, runNode } from "@llm4ts/runner/FlowRunner";
|
|
36
40
|
import { nodePlainFileStore } from "@llm4ts/runner/NodePlainFileStore";
|
|
@@ -66,6 +70,62 @@ const gatherSpecs = Effect.fn("modernize-implement.gatherSpecs")(function* (targ
|
|
|
66
70
|
return parts.join("\n\n");
|
|
67
71
|
});
|
|
68
72
|
const issueText = (issues) => issues.map((issue) => `${issue.title}\n${issue.description}`.trim()).join("\n\n");
|
|
73
|
+
/** The spec'd programs: top-level `<NAME>.md` under the specs dir, indexes aside. */
|
|
74
|
+
const specPrograms = Effect.fn("modernize-implement.specPrograms")(function* (target, specsDir) {
|
|
75
|
+
const paths = yield* target.discover(`${specsDir}/*.md`).pipe(Effect.orElseSucceed(() => []));
|
|
76
|
+
return [...paths]
|
|
77
|
+
.map((path) => path.split("/").at(-1) ?? path)
|
|
78
|
+
.filter((name) => name.endsWith(".md"))
|
|
79
|
+
.map((name) => name.slice(0, -".md".length))
|
|
80
|
+
.filter((name) => !["traceability", "mapping", "README"].includes(name))
|
|
81
|
+
.sort();
|
|
82
|
+
});
|
|
83
|
+
/**
|
|
84
|
+
* One bounded estate-wide pass: the traceability index plus the changed-file
|
|
85
|
+
* NAMES (never contents). Per-program judging cannot see cross-program
|
|
86
|
+
* problems — a rule that moved between programs, a scenario orphaned when two
|
|
87
|
+
* programs were merged — because each of its calls only ever sees one
|
|
88
|
+
* program's slice of the diff. This pass is the compensating check. Both
|
|
89
|
+
* parts are capped, not just the index: on a full-estate branch the
|
|
90
|
+
* changed-file name list alone can run to five figures of lines.
|
|
91
|
+
*/
|
|
92
|
+
const traceabilityPass = (complianceJudge, dimensions, trace, changedFiles, userPrompt) => withShrink("judge[traceability]", (cap) => Effect.gen(function* () {
|
|
93
|
+
const cappedTrace = yield* capped("traceability", trace, Math.floor(cap / 2));
|
|
94
|
+
const cappedNames = yield* capped("changed files", `Files changed on this branch:\n${changedFiles.join("\n")}`, Math.floor(cap / 2));
|
|
95
|
+
return yield* complianceJudge
|
|
96
|
+
.evaluate(Sample.make({ response: cappedNames, context: cappedTrace, query: userPrompt }))
|
|
97
|
+
.pipe(Effect.mapError(FlowLlmError.from));
|
|
98
|
+
})).pipe(Effect.map((scored) => traceabilityIssues(scored, dimensions)));
|
|
99
|
+
const traceabilityIssues = (scored, dimensions) => {
|
|
100
|
+
const subBar = scored.scores.filter((score) => score.score < (dimensions.find((dimension) => dimension.name === score.name)?.maxScore ?? 2));
|
|
101
|
+
return ReviewResult.make({
|
|
102
|
+
issues: subBar.map((score) => ReviewIssue.make({
|
|
103
|
+
severity: "Critical",
|
|
104
|
+
title: `judge[traceability]: ${score.name} scored ${score.score}`,
|
|
105
|
+
description: score.reasoning
|
|
106
|
+
})),
|
|
107
|
+
summary: "judge:traceability"
|
|
108
|
+
});
|
|
109
|
+
};
|
|
110
|
+
/**
|
|
111
|
+
* Append this run's recorded context truncations to the manifest, so a
|
|
112
|
+
* verdict rendered on a partially-read spec pack says so in the evidence
|
|
113
|
+
* chain rather than only in the console log. A repo seeded before provenance
|
|
114
|
+
* existed simply has no manifest — skip rather than fail.
|
|
115
|
+
*/
|
|
116
|
+
const recordTruncations = (manifestPath, events) => Effect.gen(function* () {
|
|
117
|
+
const recorded = yield* truncations;
|
|
118
|
+
if (recorded.length === 0) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const store = makeProvenanceStore(nodePlainFileStore);
|
|
122
|
+
yield* store
|
|
123
|
+
.extend(manifestPath, (current) => Provenance.make({
|
|
124
|
+
...current,
|
|
125
|
+
contextTruncations: [...current.contextTruncations, ...recorded.map(renderTruncation)]
|
|
126
|
+
}))
|
|
127
|
+
.pipe(Effect.asVoid, Effect.catch(() => events.publish(Info.make({ message: "no provenance.json — seeded by an older run; skipping" }))));
|
|
128
|
+
});
|
|
69
129
|
const program = Effect.gen(function* () {
|
|
70
130
|
const input = yield* resolveFlowInput("Implement the seeded modernization plan");
|
|
71
131
|
const coder = coderFromEnv(process.env);
|
|
@@ -141,10 +201,14 @@ const program = Effect.gen(function* () {
|
|
|
141
201
|
if (playbook.length > 0) {
|
|
142
202
|
yield* context.events.publish(Info.make({ message: `${playbook.length} pattern card(s) cited by the specs` }));
|
|
143
203
|
}
|
|
144
|
-
const coderChat = yield* makeChat(context.coder, { system });
|
|
145
204
|
const firstTitle = plan.tasks[0]?.title;
|
|
205
|
+
// One chat per task: `Chat` replays its full message list on each ask,
|
|
206
|
+
// so a shared chat would carry every earlier task's transcript into
|
|
207
|
+
// task N's prompt. The repo, not the transcript, carries state between
|
|
208
|
+
// tasks.
|
|
146
209
|
yield* implementTaskLoop(store, context.events, planPath, plan, (task) => Effect.gen(function* () {
|
|
147
210
|
const testsTask = task.title === firstTitle;
|
|
211
|
+
const coderChat = yield* makeChat(context.coder, { system });
|
|
148
212
|
yield* coderChat.ask(plan.taskPrompt(task));
|
|
149
213
|
yield* reviewAndFixLoop({
|
|
150
214
|
reviewers: [...minimalReviewers, ...pack.lenses],
|
|
@@ -187,39 +251,58 @@ const program = Effect.gen(function* () {
|
|
|
187
251
|
}
|
|
188
252
|
}));
|
|
189
253
|
}
|
|
190
|
-
// The
|
|
191
|
-
//
|
|
254
|
+
// The spec-compliance judge, decomposed: one ProgramJudge pass per
|
|
255
|
+
// program's own slice of the diff (cached, resumable), plus one
|
|
256
|
+
// bounded traceability pass over the whole estate — never the whole
|
|
257
|
+
// spec pack times the whole branch diff in a single call. Bounded
|
|
258
|
+
// rounds of feedback, each re-gated and committed, failing the flow
|
|
259
|
+
// if the bar is never cleared.
|
|
192
260
|
yield* stage(context.events, "judge", Effect.gen(function* () {
|
|
193
|
-
const contractText = yield* gatherSpecs(target, pack.specsDir);
|
|
194
261
|
const complianceJudge = judge(context.reasoning, complianceDimensions);
|
|
262
|
+
const specsDirAbs = join(input.workDir, pack.specsDir);
|
|
263
|
+
const gateDir = join(input.workDir, ModDir, "gate");
|
|
195
264
|
const rounds = judgeRounds();
|
|
196
265
|
for (let round = 1; round <= rounds; round += 1) {
|
|
197
266
|
const base = yield* context.git.defaultBase;
|
|
198
|
-
const
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
267
|
+
const programs = yield* specPrograms(target, pack.specsDir);
|
|
268
|
+
const perProgram = yield* judgeAllPrograms({
|
|
269
|
+
pack,
|
|
270
|
+
judge: complianceJudge,
|
|
271
|
+
dimensions: complianceDimensions,
|
|
272
|
+
git: context.git,
|
|
273
|
+
files,
|
|
274
|
+
gateDir,
|
|
275
|
+
base,
|
|
276
|
+
programs,
|
|
277
|
+
specFor: (program) => files
|
|
278
|
+
.read(join(specsDirAbs, `${program}.md`))
|
|
279
|
+
.pipe(Effect.map((text) => text ?? "")),
|
|
280
|
+
query: input.prompt,
|
|
281
|
+
fingerprint: reviewFingerprint
|
|
209
282
|
});
|
|
210
|
-
|
|
283
|
+
const trace = yield* files
|
|
284
|
+
.read(join(specsDirAbs, "traceability.md"))
|
|
285
|
+
.pipe(Effect.map((text) => text ?? ""));
|
|
286
|
+
const changed = yield* context.git.changedFilesVsBase(base);
|
|
287
|
+
const traced = yield* traceabilityPass(complianceJudge, complianceDimensions, trace, changed, input.prompt);
|
|
288
|
+
const merged = mergeReviewResults([perProgram, traced]);
|
|
289
|
+
if (merged.isClean) {
|
|
211
290
|
return yield* context.events.publish(Info.make({ message: "spec-compliance judge: branch cleared the bar" }));
|
|
212
291
|
}
|
|
213
292
|
if (round >= rounds) {
|
|
214
293
|
return yield* FlowAborted.make({
|
|
215
294
|
message: `spec-compliance judge not cleared after ${rounds} round(s):\n` +
|
|
216
|
-
|
|
295
|
+
merged.issues.map((i) => `- ${i.title}: ${i.description}`).join("\n")
|
|
217
296
|
});
|
|
218
297
|
}
|
|
219
|
-
|
|
298
|
+
// Feedback goes to a FRESH chat: the judge findings carry their
|
|
299
|
+
// own context, and replaying the whole implementation transcript
|
|
300
|
+
// is exactly the accumulation this flow no longer does.
|
|
301
|
+
const feedbackChat = yield* makeChat(context.coder, { system });
|
|
302
|
+
yield* feedbackChat.ask([
|
|
220
303
|
"The final spec-compliance review scored the branch below the bar. Close these gaps",
|
|
221
304
|
"without weakening any test, then stop:",
|
|
222
|
-
...
|
|
305
|
+
...merged.issues.map((i) => `- ${i.title}: ${i.description}`)
|
|
223
306
|
].join("\n"));
|
|
224
307
|
if (verifyGate !== undefined) {
|
|
225
308
|
const regated = yield* verifyGate;
|
|
@@ -233,7 +316,12 @@ const program = Effect.gen(function* () {
|
|
|
233
316
|
.commitAll(`${plan.epicId}: address spec-compliance feedback`)
|
|
234
317
|
.pipe(Effect.asVoid);
|
|
235
318
|
}
|
|
236
|
-
}));
|
|
319
|
+
}).pipe(Effect.provideService(FlowEvents, context.events)));
|
|
320
|
+
// Truncations recorded while judging land in the evidence chain, and
|
|
321
|
+
// the per-program verdict cache is committed with them: it is part of
|
|
322
|
+
// the evidence, and a rerun on a fresh clone resumes from it.
|
|
323
|
+
yield* recordTruncations(join(input.workDir, ModDir, "provenance.json"), context.events);
|
|
324
|
+
yield* context.git.commitAll(`${plan.epicId}: judge verdicts`).pipe(Effect.asVoid);
|
|
237
325
|
// Publishing is best-effort: a repository with no remote or forge is a
|
|
238
326
|
// normal local run, not a failure.
|
|
239
327
|
yield* stage(context.events, "publish", Effect.gen(function* () {
|
|
@@ -17,11 +17,14 @@
|
|
|
17
17
|
import { join } from "node:path";
|
|
18
18
|
import * as Effect from "effect/Effect";
|
|
19
19
|
import * as Schema from "effect/Schema";
|
|
20
|
-
import { Dimension
|
|
20
|
+
import { Dimension } from "@llm4ts/core/eval/Eval";
|
|
21
21
|
import { judge } from "@llm4ts/core/eval/Judge";
|
|
22
|
-
import {
|
|
22
|
+
import { capped, renderTruncation, truncations, withShrink } from "@llm4ts/flow/Context";
|
|
23
|
+
import { FlowAborted } from "@llm4ts/flow/FlowError";
|
|
23
24
|
import { structuredAndPublish } from "@llm4ts/flow/Flow";
|
|
24
|
-
import { Info } from "@llm4ts/flow/FlowEvents";
|
|
25
|
+
import { FlowEvents, Info } from "@llm4ts/flow/FlowEvents";
|
|
26
|
+
import { judgeAllPrograms } from "@llm4ts/flow/ProgramJudge";
|
|
27
|
+
import { Provenance, makeProvenanceStore } from "@llm4ts/flow/Provenance";
|
|
25
28
|
import { appendPackLesson } from "@llm4ts/flow/Pack";
|
|
26
29
|
import { makePlanStore } from "@llm4ts/flow/Persistence";
|
|
27
30
|
import { Plan, Task } from "@llm4ts/flow/Plan";
|
|
@@ -34,6 +37,7 @@ import { runFlowMain, runNode } from "@llm4ts/runner/FlowRunner";
|
|
|
34
37
|
import { nodePlainFileStore } from "@llm4ts/runner/NodePlainFileStore";
|
|
35
38
|
import { makeNodeWorkspace } from "@llm4ts/runner/NodeWorkspace";
|
|
36
39
|
import { openPack } from "@llm4ts/runner/Packs";
|
|
40
|
+
import { reviewFingerprint } from "@llm4ts/runner/ReviewFingerprint";
|
|
37
41
|
const ModDir = "docs/modernization";
|
|
38
42
|
class FixSpec extends Schema.Class("FixSpec")({
|
|
39
43
|
title: Schema.String,
|
|
@@ -94,10 +98,17 @@ const gatherDir = Effect.fn("modernize-review.gatherDir")(function* (workspace,
|
|
|
94
98
|
}
|
|
95
99
|
return parts.join("\n\n");
|
|
96
100
|
});
|
|
97
|
-
|
|
101
|
+
/**
|
|
102
|
+
* The distill prompt carries findings and judge verdicts, NOT the diff: every
|
|
103
|
+
* finding already names what it is about (reviewer issues carry file/line,
|
|
104
|
+
* judge issues name their program), so anything the distiller needs arrived
|
|
105
|
+
* scoped upstream — reintroducing the whole diff here is exactly the
|
|
106
|
+
* unbounded prompt this flow no longer sends.
|
|
107
|
+
*/
|
|
108
|
+
const distillPrompt = (pack, findings, judged) => [
|
|
98
109
|
pack.prompt("review") ?? "",
|
|
99
110
|
"",
|
|
100
|
-
"Below are the raw reviewer findings and judge
|
|
111
|
+
"Below are the raw reviewer findings and judge verdicts for a modernization increment.",
|
|
101
112
|
"Distill them:",
|
|
102
113
|
'- "fixes": findings where the implementation VIOLATES the committed specs. Each gets a',
|
|
103
114
|
" short spec document (Markdown: what is wrong, the spec rule it violates, the expected",
|
|
@@ -112,12 +123,35 @@ const distillPrompt = (pack, findings, scored, diff) => [
|
|
|
112
123
|
.map((issue) => `- [${issue.severity}] ${issue.title}: ${issue.description}`)
|
|
113
124
|
.join("\n"),
|
|
114
125
|
"",
|
|
115
|
-
"Judge
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
diff
|
|
126
|
+
"Judge findings (per-program spec-compliance):",
|
|
127
|
+
judged.isClean
|
|
128
|
+
? "- all programs cleared the bar"
|
|
129
|
+
: judged.issues.map((issue) => `- ${issue.title}: ${issue.description}`).join("\n")
|
|
120
130
|
].join("\n");
|
|
131
|
+
/** The spec'd programs: top-level `<NAME>.md` under the specs dir, indexes aside. */
|
|
132
|
+
const specPrograms = Effect.fn("modernize-review.specPrograms")(function* (target, specsDir) {
|
|
133
|
+
const paths = yield* target.discover(`${specsDir}/*.md`).pipe(Effect.orElseSucceed(() => []));
|
|
134
|
+
return [...paths]
|
|
135
|
+
.map((path) => path.split("/").at(-1) ?? path)
|
|
136
|
+
.filter((name) => name.endsWith(".md"))
|
|
137
|
+
.map((name) => name.slice(0, -".md".length))
|
|
138
|
+
.filter((name) => !["traceability", "mapping", "README"].includes(name))
|
|
139
|
+
.sort();
|
|
140
|
+
});
|
|
141
|
+
/** Append this run's recorded truncations to provenance.json, when present. */
|
|
142
|
+
const recordTruncations = (manifestPath, events) => Effect.gen(function* () {
|
|
143
|
+
const recorded = yield* truncations;
|
|
144
|
+
if (recorded.length === 0) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const store = makeProvenanceStore(nodePlainFileStore);
|
|
148
|
+
yield* store
|
|
149
|
+
.extend(manifestPath, (current) => Provenance.make({
|
|
150
|
+
...current,
|
|
151
|
+
contextTruncations: [...current.contextTruncations, ...recorded.map(renderTruncation)]
|
|
152
|
+
}))
|
|
153
|
+
.pipe(Effect.asVoid, Effect.catch(() => events.publish(Info.make({ message: "no provenance.json — seeded by an older run; skipping" }))));
|
|
154
|
+
});
|
|
121
155
|
const program = Effect.gen(function* () {
|
|
122
156
|
const input = yield* resolveFlowInput("Review the modernization increment against its spec pack");
|
|
123
157
|
const coder = coderFromEnv(process.env);
|
|
@@ -167,15 +201,45 @@ const program = Effect.gen(function* () {
|
|
|
167
201
|
const roster = [...allReviewers, ...pack.lenses].filter((lens) => lens.matches(changedFiles));
|
|
168
202
|
const results = [];
|
|
169
203
|
for (const lens of roster) {
|
|
170
|
-
|
|
171
|
-
|
|
204
|
+
// Scope the diff to what this lens actually cares about; a lens
|
|
205
|
+
// matching everything still sees the whole diff, just capped
|
|
206
|
+
// like everything else below.
|
|
207
|
+
const scoped = changedFiles.filter((file) => lens.matches([file]));
|
|
208
|
+
const lensDiff = yield* context.git.diffVsBaseScoped(base, scoped);
|
|
209
|
+
results.push(yield* withShrink(`review[${lens.name}]`, (cap) => Effect.gen(function* () {
|
|
210
|
+
const cappedSpecs = yield* capped(`specs[${lens.name}]`, specText, cap);
|
|
211
|
+
const cappedDiff = yield* capped(`diff[${lens.name}]`, lensDiff, cap);
|
|
212
|
+
const prompt = `${lens.systemPrompt}\n\n${reviewPrompt(`modernization increment vs committed specs\n\n${cappedSpecs}`, cappedDiff)}`;
|
|
213
|
+
return yield* structuredAndPublish(reviewService, context.events, prompt, ReviewResult, reviewJsonSchema, "reviewer");
|
|
214
|
+
})));
|
|
172
215
|
}
|
|
173
216
|
return mergeReviewResults(results);
|
|
174
|
-
}));
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
const
|
|
217
|
+
}).pipe(Effect.provideService(FlowEvents, context.events)));
|
|
218
|
+
// Per-program spec-compliance judging: each judge call sees one
|
|
219
|
+
// program's spec and one program's slice of the diff, cached under
|
|
220
|
+
// the gate dir so an unchanged program re-judges nothing.
|
|
221
|
+
const judged = yield* stage(context.events, "judge", Effect.gen(function* () {
|
|
222
|
+
const programs = yield* specPrograms(target, pack.specsDir);
|
|
223
|
+
return yield* judgeAllPrograms({
|
|
224
|
+
pack,
|
|
225
|
+
judge: judge(context.reasoning, complianceDimensions),
|
|
226
|
+
dimensions: complianceDimensions,
|
|
227
|
+
git: context.git,
|
|
228
|
+
files,
|
|
229
|
+
gateDir: join(input.workDir, ModDir, "gate"),
|
|
230
|
+
base,
|
|
231
|
+
programs,
|
|
232
|
+
specFor: (program) => files
|
|
233
|
+
.read(join(input.workDir, pack.specsDir, `${program}.md`))
|
|
234
|
+
.pipe(Effect.map((text) => text ?? "")),
|
|
235
|
+
query: input.prompt,
|
|
236
|
+
fingerprint: reviewFingerprint
|
|
237
|
+
});
|
|
238
|
+
}).pipe(Effect.provideService(FlowEvents, context.events)));
|
|
239
|
+
const outcome = yield* stage(context.events, "distill", withShrink("distill", (cap) => Effect.gen(function* () {
|
|
240
|
+
const prompt = yield* capped("distill prompt", distillPrompt(pack, findings, judged), cap);
|
|
241
|
+
return yield* structuredAndPublish(context.reasoning, context.events, prompt, ReviewOutcome, reviewOutcomeJsonSchema);
|
|
242
|
+
})).pipe(Effect.provideService(FlowEvents, context.events)));
|
|
179
243
|
yield* stage(context.events, "fix specs", Effect.gen(function* () {
|
|
180
244
|
const documents = [
|
|
181
245
|
...outcome.fixes.map((fix) => ["fix", fix]),
|
|
@@ -221,6 +285,9 @@ const program = Effect.gen(function* () {
|
|
|
221
285
|
"review and commit the pack change"
|
|
222
286
|
}));
|
|
223
287
|
}));
|
|
288
|
+
// Truncations recorded while reviewing/judging land in the evidence
|
|
289
|
+
// chain — before the commit stage, so the manifest change is included.
|
|
290
|
+
yield* recordTruncations(join(input.workDir, ModDir, "provenance.json"), context.events);
|
|
224
291
|
yield* stage(context.events, "commit", context.git
|
|
225
292
|
.commitAll(`modernize(${pack.name}): review — ${outcome.fixes.length} fix(es), ` +
|
|
226
293
|
`${outcome.improvements.length} improvement(s)`)
|
|
Binary file
|
|
@@ -21,10 +21,11 @@ import { join } from "node:path";
|
|
|
21
21
|
import * as Effect from "effect/Effect";
|
|
22
22
|
import * as Schema from "effect/Schema";
|
|
23
23
|
import { CurrentEquivSchema, EquivVector, Observations, canonicaliseFieldMap, readEquivVectors, replayEquivVector, diffObservations, writeEquivVectors } from "@llm4ts/flow/Equiv";
|
|
24
|
+
import { capped, renderTruncation, truncations, withShrink } from "@llm4ts/flow/Context";
|
|
24
25
|
import { renderEquivReport, VectorVerdict } from "@llm4ts/flow/EquivReport";
|
|
25
26
|
import { FlowAborted, PlanParseError } from "@llm4ts/flow/FlowError";
|
|
26
27
|
import { structuredAndPublish } from "@llm4ts/flow/Flow";
|
|
27
|
-
import { Info } from "@llm4ts/flow/FlowEvents";
|
|
28
|
+
import { FlowEvents, Info } from "@llm4ts/flow/FlowEvents";
|
|
28
29
|
import { makePlanStore } from "@llm4ts/flow/Persistence";
|
|
29
30
|
import { stage } from "@llm4ts/flow/PlanExecution";
|
|
30
31
|
import { Plan, Task } from "@llm4ts/flow/Plan";
|
|
@@ -192,6 +193,12 @@ const generatePrompt = (pack, program, spec, feature, rules) => [
|
|
|
192
193
|
"Scenarios:",
|
|
193
194
|
feature
|
|
194
195
|
].join("\n");
|
|
196
|
+
/**
|
|
197
|
+
* One triage call per program (not one for the whole estate): mismatches
|
|
198
|
+
* already carry their program, so each prompt needs only that program's spec —
|
|
199
|
+
* never the whole estate's specs concatenated, which is what blows a real
|
|
200
|
+
* estate's context budget.
|
|
201
|
+
*/
|
|
195
202
|
const triagePrompt = (pack, failing, specText) => {
|
|
196
203
|
const details = failing
|
|
197
204
|
.map((verdict) => {
|
|
@@ -295,7 +302,13 @@ const program = Effect.gen(function* () {
|
|
|
295
302
|
const feature = featurePath === undefined
|
|
296
303
|
? ""
|
|
297
304
|
: yield* target.read(featurePath).pipe(Effect.orElseSucceed(() => ""));
|
|
298
|
-
|
|
305
|
+
// `universe` is every rule in the estate's rules.txt, sent
|
|
306
|
+
// once per program, so this is the largest prompt the phase
|
|
307
|
+
// builds — budget it like the rest.
|
|
308
|
+
const generated = yield* withShrink(`vectors[${name}]`, (cap) => Effect.gen(function* () {
|
|
309
|
+
const prompt = yield* capped(`vectors[${name}]`, generatePrompt(pack, name, spec, feature, universe), cap);
|
|
310
|
+
return yield* structuredAndPublish(context.reasoning, context.events, prompt, GeneratedVectors, generatedVectorsJsonSchema);
|
|
311
|
+
})).pipe(Effect.provideService(FlowEvents, context.events));
|
|
299
312
|
if (generated.vectors.length === 0) {
|
|
300
313
|
return yield* FlowAborted.make({
|
|
301
314
|
message: `generator produced no vectors for ${name}`
|
|
@@ -370,13 +383,26 @@ const program = Effect.gen(function* () {
|
|
|
370
383
|
yield* stage(context.events, "report", files.writeAtomic(join(input.workDir, ModDir, "equivalence.md"), renderEquivReport(verdicts, allRules)));
|
|
371
384
|
if (failing.length > 0) {
|
|
372
385
|
yield* stage(context.events, "triage", Effect.gen(function* () {
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
386
|
+
// One triage call PER PROGRAM, each carrying only that program's
|
|
387
|
+
// spec — not the whole estate's specs in one prompt.
|
|
388
|
+
const byProgram = [
|
|
389
|
+
...new Set(failing.map((verdict) => verdict.vector.program))
|
|
390
|
+
].sort();
|
|
391
|
+
const fixes = [];
|
|
392
|
+
for (const name of byProgram) {
|
|
393
|
+
const programFailing = failing.filter((verdict) => verdict.vector.program === name);
|
|
394
|
+
const spec = (yield* files.read(join(input.workDir, pack.specsDir, `${name}.md`))) ?? "";
|
|
395
|
+
// Cap the WHOLE rendered prompt, not just the spec: the
|
|
396
|
+
// mismatch detail block is unbounded too, and shrinking one
|
|
397
|
+
// ingredient while another grows leaves the ladder failing
|
|
398
|
+
// all three rungs and then advising a knob that cannot help.
|
|
399
|
+
const outcome = yield* withShrink(`triage[${name}]`, (cap) => Effect.gen(function* () {
|
|
400
|
+
const prompt = yield* capped(`triage[${name}]`, triagePrompt(pack, programFailing, spec), cap);
|
|
401
|
+
return yield* structuredAndPublish(context.reasoning, context.events, prompt, VerifyOutcome, verifyOutcomeJsonSchema);
|
|
402
|
+
})).pipe(Effect.provideService(FlowEvents, context.events));
|
|
403
|
+
fixes.push(...outcome.fixes);
|
|
378
404
|
}
|
|
379
|
-
const outcome =
|
|
405
|
+
const outcome = VerifyOutcome.make({ fixes });
|
|
380
406
|
for (const fix of outcome.fixes) {
|
|
381
407
|
yield* files.writeAtomic(join(input.workDir, pack.specsDir, "fixes", `fix-${slug(fix.title)}.md`), `# ${fix.title}\n\n${fix.spec}\n`);
|
|
382
408
|
}
|
|
@@ -413,11 +439,20 @@ const program = Effect.gen(function* () {
|
|
|
413
439
|
const provenance = makeProvenanceStore(files);
|
|
414
440
|
const hashes = yield* provenance.hashFiles(input.workDir, [`${ModDir}/equivalence.md`]);
|
|
415
441
|
const report = Object.values(hashes)[0];
|
|
442
|
+
const recorded = yield* truncations;
|
|
416
443
|
// Spreading into a plain object would not satisfy the schema's
|
|
417
|
-
// encoder — the manifest must stay a Provenance instance.
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
444
|
+
// encoder — the manifest must stay a Provenance instance. The
|
|
445
|
+
// truncations recorded while generating and triaging land here so
|
|
446
|
+
// a verdict rendered on a partially-read spec pack says so in the
|
|
447
|
+
// evidence chain.
|
|
448
|
+
yield* provenance.extend(manifest, (current) => Provenance.make({
|
|
449
|
+
...current,
|
|
450
|
+
...(report === undefined ? {} : { equivalenceReport: report }),
|
|
451
|
+
contextTruncations: [
|
|
452
|
+
...current.contextTruncations,
|
|
453
|
+
...recorded.map(renderTruncation)
|
|
454
|
+
]
|
|
455
|
+
}));
|
|
421
456
|
}));
|
|
422
457
|
const generated = verdicts.filter((verdict) => verdict.vector.tier === "generated").length;
|
|
423
458
|
const captured = verdicts.filter((verdict) => verdict.vector.tier === "captured").length;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@llm4ts/shell",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.1",
|
|
4
4
|
"description": "Interactive shell and CLI for llm4ts: flow discovery, run-a-flow, and view",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -49,10 +49,10 @@
|
|
|
49
49
|
],
|
|
50
50
|
"dependencies": {
|
|
51
51
|
"@effect/platform-node": "4.0.0-beta.102",
|
|
52
|
-
"@llm4ts/
|
|
53
|
-
"@llm4ts/
|
|
54
|
-
"@llm4ts/
|
|
55
|
-
"@llm4ts/
|
|
52
|
+
"@llm4ts/core": "0.10.1",
|
|
53
|
+
"@llm4ts/flow": "0.10.1",
|
|
54
|
+
"@llm4ts/modernize": "0.10.1",
|
|
55
|
+
"@llm4ts/runner": "0.10.1"
|
|
56
56
|
},
|
|
57
57
|
"peerDependencies": {
|
|
58
58
|
"effect": "4.0.0-beta.102"
|