@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.
- package/dist/Cli.d.ts +1 -1
- package/dist/Cli.d.ts.map +1 -1
- package/dist/Cli.js +32 -0
- package/dist/Cli.js.map +1 -1
- package/dist/Refine.d.ts +44 -0
- package/dist/Refine.d.ts.map +1 -0
- package/dist/Refine.js +362 -0
- package/dist/Refine.js.map +1 -0
- package/flows/epic-stories.js +1 -1
- package/flows/fixtures/epic-stories/conto-bonifico.md +3 -3
- package/flows/lib/modernize-extract.js +217 -0
- package/flows/modernize-extract.js +13 -173
- package/flows/modernize-implement.js +28 -2
- package/flows/modernize-pack-check.js +7 -1
- package/flows/modernize-refine.js +389 -0
- package/flows/modernize-seed.js +50 -2
- package/flows/modernize-verify.js +12 -5
- package/kits/j2ee-nextjs/README.md +5 -4
- package/kits/j2ee-nextjs/fixtures/demo-bank/RUNBOOK.md +43 -0
- package/kits/j2ee-nextjs/fixtures/demo-bank/legacy-j2ee/PAGES.md +26 -0
- package/kits/j2ee-nextjs/flows/convert-all.js +56 -20
- package/kits/j2ee-nextjs/flows/convert-feature.js +48 -0
- package/kits/j2ee-nextjs/flows/lib/convert.js +292 -40
- package/kits/j2ee-nextjs/packs/j2ee-nextjs-spa/pack.md +16 -0
- package/kits/j2ee-nextjs/packs/j2ee-nextjs-spa/prompts/consolidate.md +10 -0
- package/kits/j2ee-nextjs/packs/j2ee-nextjs-spa/prompts/plan.md +24 -16
- package/kits/j2ee-nextjs/packs/j2ee-nextjs-spa/prompts/refine-propose.md +16 -0
- package/kits/mainframe-java/packs/cobol-springboot/pack.md +5 -0
- package/kits/mainframe-java/packs/cobol-springboot/prompts/consolidate.md +8 -0
- package/kits/mainframe-java/packs/cobol-springboot/prompts/refine-propose.md +10 -0
- package/package.json +5 -4
- package/src/Cli.ts +57 -0
- 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);
|
package/flows/modernize-seed.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
|
19
|
-
|
|
|
20
|
-
| `convert-page`
|
|
21
|
-
| `convert-
|
|
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.
|