@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.
@@ -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 LLM4TS_JUDGE_SOURCES_LIMIT (chars).
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 judgeSourcesLimit = () => {
52
- const raw = Number.parseInt(process.env.LLM4TS_JUDGE_SOURCES_LIMIT ?? "", 10);
53
- return Number.isFinite(raw) && raw > 0 ? raw : 400_000;
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
- const programAsk = (pack, relativePath) => [
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
- "Read the source file and anything it references (copybooks, includes, called programs)",
101
- `for context, but spec ONLY ${relativePath} and do not modify legacy sources.`,
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 = judgeSourcesLimit();
304
- /**
305
- * An empty structured response that survives the in-run retries is
306
- * usually DETERMINISTIC (context overflow), so repeating the same
307
- * prompt cannot succeed retry at half, then quarter context instead.
308
- */
309
- const judgeWithShrink = (spec, feature, source) => {
310
- const attempt = (cap, rest) => packJudge
311
- .evaluate(Sample.make({
312
- response: capText(`${spec}\n\n${feature}`, cap),
313
- context: capText(source, cap),
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 plan = yield* planFrom(context.reasoning, capText(specTexts.join("\n\n"), limit), `${defaultPlanInstructions}\n\n${pack.prompt("plan") ?? ""}`);
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 branch-level judge: bounded rounds of feedback, each re-gated and
191
- // committed, failing the flow if the bar is never cleared.
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 diff = yield* context.git.diffVsBase(base);
199
- const scored = yield* complianceJudge
200
- .evaluate(Sample.make({
201
- response: diff,
202
- context: contractText,
203
- query: input.prompt
204
- }))
205
- .pipe(Effect.mapError(FlowLlmError.from));
206
- const below = scored.scores.filter((score) => {
207
- const max = complianceDimensions.find((d) => d.name === score.name)?.maxScore ?? 2;
208
- return score.score < max;
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
- if (below.length === 0) {
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
- below.map((d) => `- ${d.name} ${d.score}: ${d.reasoning}`).join("\n")
295
+ merged.issues.map((i) => `- ${i.title}: ${i.description}`).join("\n")
217
296
  });
218
297
  }
219
- yield* coderChat.ask([
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
- ...below.map((d) => `- ${d.name} (${d.score}): ${d.reasoning}`)
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, Sample } from "@llm4ts/core/eval/Eval";
20
+ import { Dimension } from "@llm4ts/core/eval/Eval";
21
21
  import { judge } from "@llm4ts/core/eval/Judge";
22
- import { FlowAborted, FlowLlmError } from "@llm4ts/flow/FlowError";
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
- const distillPrompt = (pack, findings, scored, diff) => [
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 scores for a modernization increment.",
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 scores:",
116
- scored.scores.map((score) => `- ${score.name}: ${score.score} — ${score.reasoning}`).join("\n"),
117
- "",
118
- "Diff under review:",
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
- const prompt = `${lens.systemPrompt}\n\n${reviewPrompt(`modernization increment vs committed specs\n\n${specText}`, diff)}`;
171
- results.push(yield* structuredAndPublish(reviewService, context.events, prompt, ReviewResult, reviewJsonSchema, "reviewer"));
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
- const scored = yield* stage(context.events, "judge", judge(context.reasoning, complianceDimensions)
176
- .evaluate(Sample.make({ response: diff, context: specText, query: input.prompt }))
177
- .pipe(Effect.mapError(FlowLlmError.from)));
178
- const outcome = yield* stage(context.events, "distill", structuredAndPublish(context.reasoning, context.events, distillPrompt(pack, findings, scored, diff), ReviewOutcome, reviewOutcomeJsonSchema));
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
- const generated = yield* structuredAndPublish(context.reasoning, context.events, generatePrompt(pack, name, spec, feature, universe), GeneratedVectors, generatedVectorsJsonSchema);
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
- const specTexts = [
374
- (yield* files.read(join(input.workDir, pack.specsDir, "traceability.md"))) ?? ""
375
- ];
376
- for (const name of programs) {
377
- specTexts.push((yield* files.read(join(input.workDir, pack.specsDir, `${name}.md`))) ?? "");
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 = yield* structuredAndPublish(context.reasoning, context.events, triagePrompt(pack, failing, specTexts.join("\n\n")), VerifyOutcome, verifyOutcomeJsonSchema);
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
- yield* provenance.extend(manifest, (current) => report === undefined
419
- ? current
420
- : Provenance.make({ ...current, equivalenceReport: report }));
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.0",
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/flow": "0.10.0",
53
- "@llm4ts/core": "0.10.0",
54
- "@llm4ts/runner": "0.10.0",
55
- "@llm4ts/modernize": "0.10.0"
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"