@llm4ts/shell 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/Cli.d.ts +1 -1
  2. package/dist/Cli.d.ts.map +1 -1
  3. package/dist/Cli.js +32 -0
  4. package/dist/Cli.js.map +1 -1
  5. package/dist/Refine.d.ts +44 -0
  6. package/dist/Refine.d.ts.map +1 -0
  7. package/dist/Refine.js +362 -0
  8. package/dist/Refine.js.map +1 -0
  9. package/flows/epic-stories.js +1 -1
  10. package/flows/fixtures/epic-stories/conto-bonifico.md +3 -3
  11. package/flows/lib/modernize-extract.js +217 -0
  12. package/flows/modernize-extract.js +13 -173
  13. package/flows/modernize-implement.js +28 -2
  14. package/flows/modernize-pack-check.js +7 -1
  15. package/flows/modernize-refine.js +389 -0
  16. package/flows/modernize-seed.js +50 -2
  17. package/flows/modernize-verify.js +12 -5
  18. package/kits/j2ee-nextjs/README.md +5 -4
  19. package/kits/j2ee-nextjs/fixtures/demo-bank/RUNBOOK.md +43 -0
  20. package/kits/j2ee-nextjs/fixtures/demo-bank/legacy-j2ee/PAGES.md +26 -0
  21. package/kits/j2ee-nextjs/flows/convert-all.js +56 -20
  22. package/kits/j2ee-nextjs/flows/convert-feature.js +48 -0
  23. package/kits/j2ee-nextjs/flows/lib/convert.js +292 -40
  24. package/kits/j2ee-nextjs/packs/j2ee-nextjs-spa/pack.md +16 -0
  25. package/kits/j2ee-nextjs/packs/j2ee-nextjs-spa/prompts/consolidate.md +10 -0
  26. package/kits/j2ee-nextjs/packs/j2ee-nextjs-spa/prompts/plan.md +24 -16
  27. package/kits/j2ee-nextjs/packs/j2ee-nextjs-spa/prompts/refine-propose.md +16 -0
  28. package/kits/mainframe-java/packs/cobol-springboot/pack.md +5 -0
  29. package/kits/mainframe-java/packs/cobol-springboot/prompts/consolidate.md +8 -0
  30. package/kits/mainframe-java/packs/cobol-springboot/prompts/refine-propose.md +10 -0
  31. package/package.json +5 -4
  32. package/src/Cli.ts +57 -0
  33. package/src/Refine.ts +504 -0
@@ -0,0 +1,389 @@
1
+ // Legacy modernization phase 1.5 (optional): refine the extracted spec pack — prune, deepen, consolidate — before it is approved and seeded (ADR 0015).
2
+ //
3
+ // Runs rooted at the LEGACY repository (`--repo <legacy>`), after
4
+ // modernize-extract wrote its pack and before a human flips the README's
5
+ // approval. The FILE is the state, never the conversation: everything this
6
+ // flow does is driven by two overlays under docs/modernization/ that a
7
+ // human edits (or the `llm4ts refine` shell verb writes for them):
8
+ //
9
+ // decisions.md — what the pack should become: `drop`, `provided`, `defer`,
10
+ // `wrap` per program or scenario; `?` marks asking the
11
+ // model to propose; `## Deepen` marks sending the analyst
12
+ // back to the source with a focus; `## Open points` the
13
+ // questions a proposal could not settle.
14
+ // domains.md — the domain features: pages grouped by the pack's
15
+ // `## Consolidate` edge rules, named by the model, every
16
+ // surviving scenario assigned exactly once.
17
+ //
18
+ // One run, in order: validate the overlays → execute pending deepen marks
19
+ // (re-extract one program with its focus, judge, one fix turn, own commit)
20
+ // → propose dispositions for the `?` marks (an agent session on the
21
+ // read-only target at LLM4TS_TARGET_REPO; without it `provided` is never
22
+ // proposed) → consolidate when domains.md is absent or stale (LLM4TS_REGROUP=1
23
+ // forces it) → regenerate plan.md per domain feature → rewrite rules.txt with
24
+ // its `# waived` section → reset the README approval → commit. It halts with
25
+ // a typed OpenPointsPending when either overlay has unanswered questions;
26
+ // answer them in the file and rerun. Nothing marked and a fresh map is a
27
+ // no-op. Budgets: LLM4TS_ANALYST_TURNS, LLM4TS_MAX_CLOSURE_FILES,
28
+ // LLM4TS_CONTEXT_BUDGET. Pack: LLM4TS_PACK as for modernize-extract.
29
+ import { existsSync } from "node:fs";
30
+ import { join } from "node:path";
31
+ import * as Effect from "effect/Effect";
32
+ import { ProgramArtifacts, ProgramUnit, extractProgramsResumably, programArtifactPaths } from "@llm4ts/flow/Artifacts";
33
+ import { budget, capped } from "@llm4ts/flow/Context";
34
+ import { Decisions, DecisionsInvalid, DecisionsProposal, OpenPointsPending, applyProposal, decisionsProposalJsonSchema, parseDecisions, proposePrompt, renderDecisions, scenarioTitles, validateDecisions, waivedUnits } from "@llm4ts/flow/Decisions";
35
+ import { DomainProposal, Domains, checkExactlyOnce, clusterPrograms, consolidatePrompt, domainProposalJsonSchema, domainsFromProposal, domainsInputsHash, parseDomains, renderDomains } from "@llm4ts/flow/Domains";
36
+ import { structuredAndPublish } from "@llm4ts/flow/Flow";
37
+ import { FlowEvents } from "@llm4ts/flow/FlowEvents";
38
+ import { Task } from "@llm4ts/flow/Plan";
39
+ import { coverageUnits, matchingFiles } from "@llm4ts/flow/SpecChecks";
40
+ import { SurveyGraph, closureFor, surveyGraph } from "@llm4ts/flow/Survey";
41
+ import { withDraftApproval } from "@llm4ts/flow/Approval";
42
+ import { legacySourceWorkspaceLimits, workspaceLimitsFromEnv } from "@llm4ts/flow/Workspace";
43
+ import { FlowAborted, Info, Plan, asReadOnly, coderFromEnv, defaultPlanInstructions, makeNodeWorkspace, nodePlainFileStore, openPack, planFrom, resolveFlowInput, runFlowMain, runNode, stage, withTurnLimit } from "@llm4ts/runner";
44
+ import { ModDir, analystSystem, analystTurns, fixTurn, makeProgramJudge, maxClosureFiles, programArtifactsJsonSchema, programAsk, programFixAsk, programName, readmeFor } from "./lib/modernize-extract.js";
45
+ const today = () => new Date().toISOString().slice(0, 10);
46
+ const program = Effect.gen(function* () {
47
+ const input = yield* resolveFlowInput("Refine the extracted spec pack: prune, deepen, and consolidate it before approval");
48
+ const coder = withTurnLimit(coderFromEnv(process.env), analystTurns());
49
+ const files = nodePlainFileStore;
50
+ const modDirAbs = join(input.workDir, ModDir);
51
+ const targetRepo = process.env.LLM4TS_TARGET_REPO?.trim();
52
+ const regroup = process.env.LLM4TS_REGROUP?.trim() === "1";
53
+ yield* runNode({
54
+ workDir: input.workDir,
55
+ workspace: input.workspace,
56
+ userPrompt: input.prompt,
57
+ coder,
58
+ reasoning: asReadOnly(coder),
59
+ environment: process.env
60
+ }, (context) => Effect.gen(function* () {
61
+ const repo = yield* makeNodeWorkspace(input.workDir, workspaceLimitsFromEnv(process.env, legacySourceWorkspaceLimits));
62
+ const opened = yield* stage(context.events, "pack", openPack({
63
+ environment: process.env,
64
+ launchDir: input.workspace,
65
+ flowDir: import.meta.dirname
66
+ }));
67
+ const pack = opened.pack;
68
+ const say = (message) => context.events.publish(Info.make({ message }));
69
+ const notes = [];
70
+ // ---- The pack on disk: its programs, sources, and scenario titles ----
71
+ const specPaths = yield* repo
72
+ .discover(`${ModDir}/specs/*.md`)
73
+ .pipe(Effect.orElseSucceed(() => []));
74
+ const names = [...specPaths]
75
+ .map((path) => path.split("/").at(-1) ?? path)
76
+ .filter((file) => file.endsWith(".md") && file !== "README.md")
77
+ .map((file) => file.slice(0, -".md".length))
78
+ .sort();
79
+ if (names.length === 0) {
80
+ return yield* FlowAborted.make({
81
+ message: `no spec pack under ${ModDir}/specs — run modernize-extract first`
82
+ });
83
+ }
84
+ const sources = yield* matchingFiles(repo, pack.programs ?? pack.sources ?? ".*", pack.exclude);
85
+ const sourceOf = new Map(sources.map((path) => [programName(path), path]));
86
+ const units = names.map((name) => ProgramUnit.make({ name, sourcePath: sourceOf.get(name) ?? "" }));
87
+ const readPack = Effect.gen(function* () {
88
+ const specs = {};
89
+ const features = {};
90
+ const scenarios = new Map();
91
+ for (const name of names) {
92
+ specs[name] = (yield* files.read(join(modDirAbs, "specs", `${name}.md`))) ?? "";
93
+ const feature = (yield* files.read(join(modDirAbs, "features", `${name.toLowerCase()}.feature`))) ??
94
+ "";
95
+ features[name] = feature;
96
+ scenarios.set(name, new Set(scenarioTitles(feature)));
97
+ }
98
+ const known = { programs: new Set(names), scenarios };
99
+ return { specs, features, scenarios, known };
100
+ });
101
+ let packState = yield* readPack;
102
+ // ---- decisions.md ------------------------------------------------------
103
+ const decisionsPath = join(modDirAbs, "decisions.md");
104
+ const decisionsText = yield* files.read(decisionsPath);
105
+ let decisions = decisionsText === undefined
106
+ ? Decisions.empty()
107
+ : yield* parseDecisions(decisionsText, `${ModDir}/decisions.md`);
108
+ const violations = validateDecisions(decisions, packState.known);
109
+ if (violations.length > 0) {
110
+ return yield* DecisionsInvalid.make({ path: `${ModDir}/decisions.md`, violations });
111
+ }
112
+ const writeDecisions = Effect.gen(function* () {
113
+ yield* files.writeAtomic(decisionsPath, renderDecisions(decisions));
114
+ });
115
+ const graph = yield* stage(context.events, "graph", pack.survey.length === 0
116
+ ? Effect.succeed(SurveyGraph.make({ nodes: [], edges: [] }))
117
+ : surveyGraph(repo, pack.sources ?? ".*", pack.coverage, pack.survey, {
118
+ ...(pack.exclude === undefined ? {} : { exclude: pack.exclude })
119
+ }));
120
+ const system = analystSystem(pack);
121
+ const limit = budget();
122
+ const judgeProgram = makeProgramJudge({
123
+ context,
124
+ files,
125
+ pack,
126
+ modDirAbs,
127
+ workDir: input.workDir,
128
+ limit
129
+ });
130
+ // ---- Deepen: re-extract one program with its focus ----------------------
131
+ const pending = decisions.pendingDeepen;
132
+ if (pending.length > 0) {
133
+ yield* stage(context.events, "deepen", Effect.gen(function* () {
134
+ for (const mark of pending) {
135
+ const unit = units.find((candidate) => candidate.name === mark.program);
136
+ if (unit === undefined || unit.sourcePath.length === 0) {
137
+ return yield* FlowAborted.make({
138
+ message: `deepen: no legacy source found for program '${mark.program}'`
139
+ });
140
+ }
141
+ const [specPath, featurePath, tracePath, mappingPath] = programArtifactPaths(unit, modDirAbs);
142
+ const previous = ProgramArtifacts.make({
143
+ spec: (yield* files.read(specPath)) ?? "",
144
+ feature: (yield* files.read(featurePath)) ?? "",
145
+ traceability: (yield* files.read(tracePath)) ?? "",
146
+ mapping: (yield* files.read(mappingPath)) ?? ""
147
+ });
148
+ yield* say(`deepening ${unit.name} — ${mark.focus}`);
149
+ // Removing the spec is what makes the resumable seam re-extract it.
150
+ yield* files.remove(specPath);
151
+ yield* extractProgramsResumably(files, [unit], (target) => structuredAndPublish(context.coder, context.events, `${system}\n\n${programAsk(pack, target.sourcePath, closureFor(graph, target.name, maxClosureFiles()), { previous, focus: mark.focus })}`, ProgramArtifacts, programArtifactsJsonSchema, "coder"), modDirAbs, {
152
+ onCreated: (created) => context.git
153
+ .commitPaths(`modernize(${pack.name}): deepen ${created.name}`, programArtifactPaths(created, ModDir))
154
+ .pipe(Effect.asVoid)
155
+ });
156
+ const focusRubric = `deepen-focus (0..2): The revision addresses this focus explicitly and grounds it in ` +
157
+ `the source: ${mark.focus}. Score 2 only if the focus is fully answered.`;
158
+ let verdict = yield* judgeProgram(unit, focusRubric);
159
+ if (verdict.issues.length > 0) {
160
+ yield* say(`fixing ${unit.name} — ${verdict.issues.length} finding(s)`);
161
+ yield* fixTurn(context, system, programFixAsk(unit.name, unit.sourcePath, verdict.issues), `modernize(${pack.name}): deepen fixes ${unit.name}`);
162
+ verdict = yield* judgeProgram(unit, focusRubric);
163
+ if (verdict.issues.length > 0) {
164
+ yield* say(`${unit.name} still has ${verdict.issues.length} finding(s) after its fix turn — recorded as open points`);
165
+ }
166
+ }
167
+ const hash = (yield* context.git.checkpoint).slice(0, 7);
168
+ decisions = Decisions.make({
169
+ ...decisions,
170
+ deepen: decisions.deepen.map((entry) => entry === mark ? { ...entry, done: hash } : entry),
171
+ openPoints: [
172
+ ...decisions.openPoints,
173
+ ...verdict.issues.map((issue, index) => ({
174
+ number: decisions.openPoints.length + index + 1,
175
+ question: `After deepening ${unit.name}: ${issue.title} — ${issue.description}`
176
+ }))
177
+ ]
178
+ });
179
+ yield* writeDecisions;
180
+ notes.push(`deepened ${unit.name} (${hash}): ${mark.focus}`);
181
+ }
182
+ // Titles may have moved: a reference that dangles is a question, not a crash.
183
+ packState = yield* readPack;
184
+ const dangling = validateDecisions(decisions, packState.known);
185
+ if (dangling.length > 0) {
186
+ decisions = Decisions.make({
187
+ ...decisions,
188
+ openPoints: [
189
+ ...decisions.openPoints,
190
+ ...dangling.map((violation, index) => ({
191
+ number: decisions.openPoints.length + index + 1,
192
+ question: `After deepening: ${violation} — remap the entry or delete it`
193
+ }))
194
+ ]
195
+ });
196
+ yield* writeDecisions;
197
+ }
198
+ }));
199
+ }
200
+ // ---- Propose: resolve the `?` marks ---------------------------------------
201
+ if (decisions.marks.length > 0) {
202
+ yield* stage(context.events, "propose", Effect.gen(function* () {
203
+ const marked = [...new Set(decisions.marks.map((mark) => mark.program))];
204
+ const specs = marked.map((name) => ({
205
+ name,
206
+ spec: packState.specs[name] ?? "",
207
+ feature: packState.features[name] ?? ""
208
+ }));
209
+ const targetMounted = targetRepo !== undefined && targetRepo.length > 0;
210
+ if (!targetMounted) {
211
+ yield* say("no LLM4TS_TARGET_REPO — the proposal cannot claim anything is provided by the target");
212
+ }
213
+ const packParagraph = pack.prompt("refine-propose");
214
+ const promptText = proposePrompt(decisions, specs, {
215
+ targetMounted,
216
+ ...(packParagraph === undefined ? {} : { packParagraph })
217
+ });
218
+ const ask = yield* capped("propose", promptText, limit).pipe(Effect.provideService(FlowEvents, context.events));
219
+ const propose = (seat) => structuredAndPublish(seat.reasoning, context.events, ask, DecisionsProposal, decisionsProposalJsonSchema, "coder");
220
+ // The reasoning seat is the coder with read-only tools; rebound
221
+ // into the target (ADR 0013's seat rebind) it proposes `provided`
222
+ // from files it actually opened. Without a target the
223
+ // legacy-rooted seat proposes drops only.
224
+ const proposal = targetMounted && context.contextFor !== undefined
225
+ ? yield* Effect.scoped(Effect.flatMap(context.contextFor(targetRepo), (rebound) => propose(rebound)))
226
+ : yield* propose(context);
227
+ decisions = applyProposal(decisions, proposal, {
228
+ pointerExists: (pointer) => targetMounted && existsSync(join(targetRepo, pointer)),
229
+ known: packState.known,
230
+ decidedBy: "proposal",
231
+ decidedAt: today()
232
+ });
233
+ yield* writeDecisions;
234
+ notes.push(`proposed dispositions for ${marked.length} marked program(s): ` +
235
+ `${decisions.programs.length} program and ${decisions.scenarios.length} scenario decision(s) on file`);
236
+ }));
237
+ }
238
+ // ---- Consolidate: the domain map ----------------------------------------------
239
+ let domains;
240
+ const domainsPath = join(modDirAbs, "domains.md");
241
+ const existingText = yield* files.read(domainsPath);
242
+ const existing = existingText === undefined
243
+ ? undefined
244
+ : yield* parseDomains(existingText, `${ModDir}/domains.md`);
245
+ if (decisions.unansweredOpenPoints.length === 0) {
246
+ const survivingPrograms = names.filter((name) => decisions.programDecision(name) === undefined);
247
+ const surviving = new Map(survivingPrograms.map((name) => {
248
+ const disposed = decisions.disposedScenarios(name);
249
+ return [
250
+ name,
251
+ new Set([...(packState.scenarios.get(name) ?? [])].filter((t) => !disposed.has(t)))
252
+ ];
253
+ }));
254
+ const hash = domainsInputsHash(packState.specs, renderDecisions(decisions));
255
+ if (existing !== undefined && existing.inputsHash === hash && !regroup) {
256
+ domains = existing;
257
+ }
258
+ else {
259
+ domains = yield* stage(context.events, existing === undefined ? "consolidate" : "consolidate (regroup)", Effect.gen(function* () {
260
+ const clusters = clusterPrograms(graph, survivingPrograms, pack.consolidate ?? { cluster: [], context: [] });
261
+ if (pack.consolidate === undefined) {
262
+ yield* say("pack has no '## Consolidate' section — every program seeds its own domain feature");
263
+ }
264
+ const base = consolidatePrompt(clusters, surviving, pack.prompt("consolidate"));
265
+ const attempt = (extra) => Effect.gen(function* () {
266
+ const promptText = extra.length === 0
267
+ ? base
268
+ : `${base}\n\nYour previous answer broke these rules — fix them:\n${extra.map((v) => `- ${v}`).join("\n")}`;
269
+ const ask = yield* capped("consolidate", promptText, limit).pipe(Effect.provideService(FlowEvents, context.events));
270
+ const proposal = yield* structuredAndPublish(context.reasoning, context.events, ask, DomainProposal, domainProposalJsonSchema);
271
+ const map = domainsFromProposal(proposal, clusters, hash);
272
+ return { map, violations: checkExactlyOnce(map, surviving) };
273
+ });
274
+ let result = yield* attempt([]);
275
+ if (result.violations.length > 0) {
276
+ yield* say(`consolidation broke the exactly-once rule ${result.violations.length} time(s) — one retry`);
277
+ result = yield* attempt(result.violations);
278
+ }
279
+ const map = result.violations.length === 0
280
+ ? result.map
281
+ : Domains.make({
282
+ ...result.map,
283
+ openPoints: [
284
+ ...result.map.openPoints,
285
+ ...result.violations.map((violation, index) => ({
286
+ number: result.map.openPoints.length + index + 1,
287
+ question: `The map breaks the exactly-once rule: ${violation} — fix the map by hand`
288
+ }))
289
+ ]
290
+ });
291
+ yield* files.writeAtomic(domainsPath, renderDomains(map));
292
+ notes.push(`${existing === undefined ? "grouped" : "regrouped"} ${survivingPrograms.length} program(s) into ${map.features.length} domain feature(s)`);
293
+ return map;
294
+ }));
295
+ }
296
+ }
297
+ // ---- Plan per domain feature ------------------------------------------------------
298
+ const openPoints = [
299
+ ...decisions.unansweredOpenPoints.map((point) => `decisions.md ${point.number}. ${point.question}`),
300
+ ...(domains?.unansweredOpenPoints ?? []).map((point) => `domains.md ${point.number}. ${point.question}`)
301
+ ];
302
+ if (domains !== undefined && openPoints.length === 0 && (notes.length > 0 || regroup)) {
303
+ const map = domains;
304
+ yield* stage(context.events, "plan", Effect.gen(function* () {
305
+ const tasks = [];
306
+ const outOfScope = [
307
+ ...decisions.programs.map((e) => `${e.program}: ${e.disposition}`),
308
+ ...decisions.scenarios.map((e) => `${e.program} / ${e.scenario}: ${e.disposition}`)
309
+ ];
310
+ for (const feature of map.features) {
311
+ const text = [
312
+ `# Domain feature: ${feature.name} (${feature.id})`,
313
+ `Programs: ${feature.programs.join(", ")}`,
314
+ `Included fragments (context): ${feature.context.join(", ") || "none"}`,
315
+ `Scenarios in scope: ${feature.scenarios.map((s) => `${s.program} / ${s.title}`).join("; ")}`,
316
+ ...(outOfScope.length === 0
317
+ ? []
318
+ : [`Out of scope by decision (do not plan): ${outOfScope.join("; ")}`]),
319
+ ...feature.programs.map((name) => `\n===== ${name} =====\n${packState.specs[name] ?? ""}`),
320
+ ...feature.context.map((name) => `\n===== ${name} (context) =====\n${packState.specs[name] ?? ""}`)
321
+ ].join("\n");
322
+ const plannerSpecs = yield* capped(`plan[${feature.id}]`, text, limit).pipe(Effect.provideService(FlowEvents, context.events));
323
+ const plan = yield* planFrom(context.reasoning, plannerSpecs, `${defaultPlanInstructions}\n\n${pack.prompt("plan") ?? ""}`);
324
+ for (const task of plan.tasks) {
325
+ tasks.push(Task.make({
326
+ title: `[${feature.id}] ${task.title}`,
327
+ description: task.description,
328
+ completed: false
329
+ }));
330
+ }
331
+ }
332
+ yield* files.writeAtomic(join(modDirAbs, "plan.md"), Plan.make({ epicId: `${pack.name}-features`, tasks }).render);
333
+ notes.push(`planned ${tasks.length} task(s) across ${map.features.length} feature(s)`);
334
+ }));
335
+ }
336
+ if (notes.length === 0) {
337
+ yield* say(openPoints.length === 0
338
+ ? "nothing to refine — no marks, no deepen, and the domain map is fresh"
339
+ : "nothing ran — open points are still unanswered");
340
+ if (openPoints.length > 0) {
341
+ return yield* OpenPointsPending.make({ path: ModDir, points: openPoints });
342
+ }
343
+ return;
344
+ }
345
+ // ---- rules.txt with its waived section, README reset, commit ---------------------
346
+ yield* stage(context.events, "rules", Effect.gen(function* () {
347
+ const unitsByRule = yield* coverageUnits(repo, pack.coverage);
348
+ const allUnits = [...new Set(Object.values(unitsByRule).flat())].sort();
349
+ const fragments = new Map();
350
+ for (const name of names) {
351
+ const fragment = yield* files.read(join(modDirAbs, "traceability", `${name}.md`));
352
+ if (fragment !== undefined) {
353
+ fragments.set(name, fragment);
354
+ }
355
+ }
356
+ const waived = waivedUnits(decisions, { fragments, scenarios: packState.scenarios });
357
+ const lines = [
358
+ ...allUnits,
359
+ ...(waived.length === 0
360
+ ? []
361
+ : ["# waived", ...waived.map((entry) => `${entry.unit} — waived by ${entry.by}`)])
362
+ ];
363
+ if (allUnits.length > 0) {
364
+ yield* files.writeAtomic(join(modDirAbs, "rules.txt"), lines.join("\n") + "\n");
365
+ }
366
+ if (waived.length > 0) {
367
+ notes.push(`${waived.length} coverage unit(s) waived by decision`);
368
+ }
369
+ }));
370
+ // The README carries every refinement since the gate passed, not
371
+ // only this run's: an approver reads one list.
372
+ const readme = (yield* files.read(join(modDirAbs, "README.md"))) ?? "";
373
+ const verdict = /Gate verdict: (.+?)\.\n/.exec(readme)?.[1] ?? "PASSED — pending human approval";
374
+ const priorNotes = (/Refined after the gate passed:\n((?:- .*\n)+)/.exec(readme)?.[1] ?? "")
375
+ .split("\n")
376
+ .filter((line) => line.startsWith("- "))
377
+ .map((line) => line.slice(2));
378
+ yield* files.writeAtomic(join(modDirAbs, "README.md"), withDraftApproval(readmeFor(pack, verdict, [...priorNotes, ...notes])));
379
+ yield* stage(context.events, "commit", context.git
380
+ .commitAll(`modernize(${pack.name}): refine — ${notes[notes.length - 1] ?? "overlays"}`)
381
+ .pipe(Effect.asVoid));
382
+ if (openPoints.length > 0) {
383
+ return yield* OpenPointsPending.make({ path: ModDir, points: openPoints });
384
+ }
385
+ yield* say(`refined — review ${ModDir}/README.md, decisions.md, and domains.md, flip '- [x] Approved' ` +
386
+ "in each, then run the seed phase");
387
+ }));
388
+ });
389
+ runFlowMain(program);
@@ -27,6 +27,7 @@ import { packageVersion } from "@llm4ts/flow/Package";
27
27
  import { Provenance, makeProvenanceStore } from "@llm4ts/flow/Provenance";
28
28
  import { matchingFiles } from "@llm4ts/flow/SpecChecks";
29
29
  import { requireApproval } from "@llm4ts/flow/Approval";
30
+ import { Decisions, filterFeature, parseDecisions } from "@llm4ts/flow/Decisions";
30
31
  const ModDir = "docs/modernization";
31
32
  const skipped = new Set([".git", "target", "node_modules", "dist"]);
32
33
  const Seats = Schema.Record(Schema.String, Schema.String);
@@ -84,7 +85,20 @@ const program = Effect.gen(function* () {
84
85
  }));
85
86
  const pack = opened.pack;
86
87
  const specPackRoot = join(legacyRepo, ModDir);
87
- yield* stage(context.events, "approval", requireApproval(files, join(specPackRoot, "README.md")));
88
+ // The README always; each refinement overlay when it exists (ADR 0015):
89
+ // a pack refined after its gate passed is seeded only as approved.
90
+ yield* stage(context.events, "approval", Effect.gen(function* () {
91
+ yield* requireApproval(files, join(specPackRoot, "README.md"));
92
+ for (const overlay of ["decisions.md", "domains.md"]) {
93
+ if ((yield* files.read(join(specPackRoot, overlay))) !== undefined) {
94
+ yield* requireApproval(files, join(specPackRoot, overlay));
95
+ }
96
+ }
97
+ }));
98
+ const decisionsText = yield* files.read(join(specPackRoot, "decisions.md"));
99
+ const decisions = decisionsText === undefined
100
+ ? Decisions.empty()
101
+ : yield* parseDecisions(decisionsText, `${ModDir}/decisions.md`);
88
102
  yield* stage(context.events, "scaffold", Effect.gen(function* () {
89
103
  const existing = (yield* target.discover().pipe(Effect.orElseSucceed(() => []))).filter((path) => !path.startsWith(".git/"));
90
104
  if (existing.length > 0) {
@@ -116,8 +130,42 @@ const program = Effect.gen(function* () {
116
130
  "check LLM4TS_LEGACY_REPO and that extraction wrote its spec pack"
117
131
  });
118
132
  }
133
+ // The projection (ADR 0015): a feature file reaches the target
134
+ // with only its surviving scenarios, so the coder never sees a
135
+ // scenario it must not encode; a program disposed as a whole
136
+ // ships no feature file at all. Specs are copied unchanged, with
137
+ // the overlays beside them for the judges.
119
138
  const features = yield* copyTree(legacy, target, `${ModDir}/features`, pack.featuresDir);
120
- for (const index of ["traceability.md", "mapping.md", "rules.txt"]) {
139
+ let projected = 0;
140
+ if (!decisions.isEmpty) {
141
+ const featurePaths = yield* target
142
+ .discover(`${pack.featuresDir}/**`)
143
+ .pipe(Effect.orElseSucceed(() => []));
144
+ for (const path of featurePaths) {
145
+ const stem = (path.split("/").at(-1) ?? "").replace(/\.feature$/, "").toLowerCase();
146
+ const program = [...decisions.programs, ...decisions.scenarios]
147
+ .map((entry) => entry.program)
148
+ .find((name) => name.toLowerCase() === stem);
149
+ if (program === undefined) {
150
+ continue;
151
+ }
152
+ projected += 1;
153
+ if (decisions.programDecision(program) !== undefined) {
154
+ yield* target.write(path, "");
155
+ continue;
156
+ }
157
+ const text = yield* target.read(path);
158
+ yield* target.write(path, filterFeature(text, decisions.disposedScenarios(program)));
159
+ }
160
+ yield* context.events.publish(Info.make({ message: `projected decisions onto ${projected} feature file(s)` }));
161
+ }
162
+ for (const index of [
163
+ "traceability.md",
164
+ "mapping.md",
165
+ "rules.txt",
166
+ "decisions.md",
167
+ "domains.md"
168
+ ]) {
121
169
  yield* copyFile(legacy, target, `${ModDir}/${index}`, join(pack.specsDir, index));
122
170
  }
123
171
  return specs + features;
@@ -229,7 +229,7 @@ const triagePrompt = (pack, failing, specText) => {
229
229
  /** The spec'd programs: top-level `<NAME>.md` files under the specs dir, indexes aside. */
230
230
  const specPrograms = Effect.fn("modernize-verify.specPrograms")(function* (target, specsDir) {
231
231
  const paths = yield* matchingFiles(target, `^${specsDir}/[^/]+\\.md$`).pipe(Effect.orElseSucceed(() => []));
232
- const excluded = new Set(["traceability", "mapping", "README"]);
232
+ const excluded = new Set(["traceability", "mapping", "README", "decisions", "domains"]);
233
233
  return paths
234
234
  .map((path) => (path.split("/").at(-1) ?? path).replace(/\.md$/, ""))
235
235
  .filter((name) => !excluded.has(name))
@@ -268,10 +268,17 @@ const program = Effect.gen(function* () {
268
268
  yield* context.events.publish(Info.make({ message: "clean-room wall: no legacy source in the target workspace" }));
269
269
  }));
270
270
  const rulesText = (yield* files.read(join(input.workDir, pack.specsDir, "rules.txt"))) ?? "";
271
- const universe = rulesText
272
- .split(/\r?\n/)
273
- .map((line) => line.trim())
274
- .filter((line) => line.length > 0);
271
+ // Everything above `# waived` is the universe; the units below it were
272
+ // waived by a decision (ADR 0015) and are neither expected nor flagged.
273
+ const rulesLines = rulesText.split(/\r?\n/).map((line) => line.trim());
274
+ const waivedAt = rulesLines.indexOf("# waived");
275
+ const universe = (waivedAt < 0 ? rulesLines : rulesLines.slice(0, waivedAt)).filter((line) => line.length > 0);
276
+ const waived = waivedAt < 0 ? [] : rulesLines.slice(waivedAt + 1).filter((l) => l.length > 0);
277
+ if (waived.length > 0) {
278
+ yield* context.events.publish(Info.make({
279
+ message: `${waived.length} rule(s) waived by decision are left out of the universe`
280
+ }));
281
+ }
275
282
  if (universe.length === 0) {
276
283
  yield* context.events.publish(Info.make({
277
284
  message: "no rules.txt in the seeded spec pack — the report will use the vectors' own " +
@@ -15,10 +15,11 @@ target) the [workshop runbook](fixtures/demo-bank/RUNBOOK.md) rehearses on.
15
15
  | [`jsp-nextjs`](packs/jsp-nextjs/pack.md) | JSP/Java → Next.js SPA | `nextjs-spa` | no |
16
16
  | [`jsp-bff-nextjs`](packs/jsp-bff-nextjs/pack.md) | JSP/Java → Spring BFF + Next.js | `spring-bff` | no |
17
17
 
18
- | Flow | What it does |
19
- | -------------- | ---------------------------------------------------------------------- |
20
- | `convert-page` | Convert ONE extracted page into the Next.js target on its own branch |
21
- | `convert-all` | Walk the survey inventory in wave order, one branch per page, a report |
18
+ | Flow | What it does |
19
+ | ----------------- | ---------------------------------------------------------------------------------------- |
20
+ | `convert-page` | Convert ONE extracted page into the Next.js target on its own branch |
21
+ | `convert-feature` | Convert ONE domain feature of the approved `domains.md`: one branch, one merged contract |
22
+ | `convert-all` | Walk the approved domain map (features) or the survey inventory (pages), one branch each |
22
23
 
23
24
  ```sh
24
25
  llm4ts run modernize-pack-check --pack j2ee-nextjs-spa --repo /path/to/legacy-estate
@@ -119,6 +119,49 @@ estate once every wave is in.
119
119
  LLM4TS_WAVE=wave-2 llm4ts run modernize-extract --repo ~/demo/legacy-j2ee
120
120
  ```
121
121
 
122
+ ### Refine — the pack becomes what the client wants built (optional beat)
123
+
124
+ Extraction says what the legacy does. Refinement (ADR 0015) says what the
125
+ target should become, in two files the audience can read on screen:
126
+
127
+ ```bash
128
+ llm4ts refine --repo ~/demo/legacy-j2ee --target ~/demo/nextjs
129
+ ```
130
+
131
+ Three marks, then run:
132
+
133
+ 1. **Mark programs** — `promoQ3`, `oldTransfer`, `testHarness`: `drop`
134
+ ("dead, nothing links here"); `login`: `?` with the note "the target has
135
+ an AuthProvider". The proposal reads `~/demo/nextjs` read-only and comes
136
+ back with `provided — src/auth/AuthProvider.tsx`, or an open point if it
137
+ could not find the proof. Say that `defer` is never proposed by the model.
138
+ 2. **Deepen a program** — `accountOverview` with the focus "the date-range
139
+ filter on movements is missing; check AccountOverviewServlet". Watch the
140
+ analyst revise the spec (own commit, `deepen accountOverview`), the judge
141
+ score the focus, and the mark stamp `[done <commit>]`.
142
+ 3. **Run modernize-refine** — the map lands at
143
+ `docs/modernization/domains.md`: `beneficiaryList` + `beneficiaryEdit`
144
+ become "Beneficiary maintenance" (they share the `/beneficiary` form
145
+ target), the three transfer steps "Wire transfer", header/nav/footer the
146
+ shell; the filler pages are folded only if the model proposed it with
147
+ evidence. `plan.md` is regenerated per feature and `rules.txt` gains a
148
+ `# waived` section listing the dropped pages' units. Answer any open
149
+ point in the menu, rerun, then **Approve the overlays** — and point out
150
+ that the README went back to `- [ ] Approved` the moment refine wrote
151
+ anything.
152
+
153
+ Off stage the same beat is: edit `decisions.md` by hand (its header explains
154
+ the vocabulary) and `LLM4TS_TARGET_REPO=~/demo/nextjs llm4ts run
155
+ modernize-refine --repo ~/demo/legacy-j2ee`. Skip the beat entirely and the
156
+ pipeline behaves exactly as before.
157
+
158
+ Once `domains.md` is approved, Act 2 may convert **one feature instead of
159
+ two pages**: `llm4ts run convert-feature --repo ~/demo/nextjs
160
+ beneficiary-maintenance` lands `beneficiaryList` and `beneficiaryEdit` on one
161
+ `convert/beneficiary-maintenance` branch behind one merged contract, and
162
+ `convert-all` walks the features rather than the pages. Rehearse the timing
163
+ before choosing it on stage: it is the two pages' cost in one run.
164
+
122
165
  Extraction runs three pages at once (`LLM4TS_EXTRACT_CONCURRENCY=3` from Act
123
166
  0): the pages of a wave are independent, each lands in its own commit holding
124
167
  only its four files, and the log interleaves — say so before it starts, then
@@ -65,3 +65,29 @@ anti-corruption renaming in a Page Spec has something to bite on.
65
65
 
66
66
  All data is fictional: EUR accounts, fake IBAN-like numbers in the form
67
67
  `IT00 DEMO 0000 ...`, customer "MARIO BIANCHI" / customer id `CUST0042`.
68
+
69
+ ## Refinement answer key (ADR 0015)
70
+
71
+ What `modernize-refine` should produce on this estate once the three dead
72
+ pages are marked `drop` and `login` resolves to `provided` by the target's
73
+ AuthProvider:
74
+
75
+ | Domain feature (deterministic cluster) | Programs | Why they cluster |
76
+ | ------------------------------------------------------------ | --------------------------------------------------------- | --------------------------------------------- |
77
+ | Portal shell | `header.jsp`, `nav.jsp`, `footer.jsp` | fragments: targets of `jsp-include` edges |
78
+ | Account overview | `accountOverview.jsp` | its ajax target names no other page |
79
+ | Beneficiary maintenance | `beneficiaryList.jsp`, `beneficiaryEdit.jsp` | both post to `/beneficiary` (`jsp-form-action`) |
80
+ | Wire transfer | `transferStep1.jsp`, `transferStep2.jsp`, `transferConfirm.jsp` | all post to `/transfer` |
81
+ | one singleton each (fold into the shell only by proposal) | `dashboard.jsp`, `settings.jsp`, `profile.jsp`, `messages.jsp`, `help.jsp` | no shared form or ajax target |
82
+
83
+ Waived coverage units after the drops: every `url-pattern`, form action, and
84
+ ajax url captured only from `oldTransfer.jsp`, `promoQ3.jsp`, and
85
+ `testHarness.jsp`; `login.jsp`'s form action once it is `provided`.
86
+
87
+ Feature contracts `convert-feature` writes once the map above is approved:
88
+ `contracts/beneficiary-maintenance.openapi.yaml` unions `GET /beneficiary`
89
+ (list, both pages), `GET /beneficiary?action=edit` and `POST /beneficiary`
90
+ (edit) over the shared `Beneficiary` DTO; `contracts/wire-transfer.openapi.yaml`
91
+ unions the three `POST /transfer` steps — same path, one operation per
92
+ `step`, so the pages must agree on the `TransferDraft` DTO or the map gets a
93
+ conflict open point.