@llm4ts/shell 2.1.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/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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Convert the whole legacy estate: walk the
|
|
1
|
+
// Convert the whole legacy estate: walk the approved domain map (one branch per feature) or the survey inventory (one branch per page) in wave order, progress board, estimated-cost migration report.
|
|
2
2
|
//
|
|
3
3
|
// Runs rooted at the TARGET repository (`--repo <nextjs>`), with
|
|
4
4
|
// LLM4TS_LEGACY_REPO pointing at the extracted legacy repository:
|
|
@@ -24,7 +24,7 @@ import { AdoConfig, makeAzureDevOpsTool } from "@llm4ts/flow/AzureDevOpsTool";
|
|
|
24
24
|
import { BoardItem, composeBoardSync, makeAdoBoardSync, makeLocalBoardSync } from "@llm4ts/flow/BoardSync";
|
|
25
25
|
import { describeFlowError } from "@llm4ts/flow/FlowError";
|
|
26
26
|
import { Info, asReadOnly, coderFromEnv, nodePlainFileStore, nodeProcessExecutor, resolveFlowInput, runFlowMain, runNode, stage } from "@llm4ts/runner";
|
|
27
|
-
import { conversionInventory, convertPage, migrationReport, setupConversion } from "./lib/convert.js";
|
|
27
|
+
import { conversionInventory, convertFeature, convertPage, featureInventory, migrationReport, setupConversion } from "./lib/convert.js";
|
|
28
28
|
const program = Effect.gen(function* () {
|
|
29
29
|
const input = yield* resolveFlowInput("Convert the legacy estate into the destination SPA");
|
|
30
30
|
const coder = coderFromEnv(process.env);
|
|
@@ -57,39 +57,75 @@ const program = Effect.gen(function* () {
|
|
|
57
57
|
yield* context.events.publish(Info.make({ message: `ADO board mirror enabled: ${orgUrl}/${project}` }));
|
|
58
58
|
}
|
|
59
59
|
const board = composeBoardSync(boards);
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
// ADR 0012 addendum: an approved domain map makes the feature the
|
|
61
|
+
// unit of delivery; without one the walk is per page as before.
|
|
62
|
+
const features = yield* stage(context.events, "inventory", featureInventory(files, deps.legacy, deps.legacyDir, deps.pack));
|
|
63
|
+
let items;
|
|
64
|
+
if (features !== undefined) {
|
|
65
|
+
yield* context.events.publish(Info.make({
|
|
66
|
+
message: `approved domain map: converting ${features.length} feature(s), one branch each`
|
|
67
|
+
}));
|
|
68
|
+
items = features.map((entry) => ({
|
|
69
|
+
id: entry.feature.id,
|
|
70
|
+
title: entry.feature.name,
|
|
71
|
+
...(entry.wave === undefined ? {} : { wave: entry.wave }),
|
|
72
|
+
detail: `pages: ${entry.feature.programs.join(", ")}`,
|
|
73
|
+
...(entry.disposed ? { skip: "every page disposed by decision" } : {}),
|
|
74
|
+
convert: convertFeature(deps, entry.feature.id)
|
|
75
|
+
}));
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
const inventory = yield* conversionInventory(files, deps.legacy, deps.legacyDir, deps.pack);
|
|
79
|
+
items = yield* Effect.forEach(inventory, ({ page, wave, disposition }) => Effect.gen(function* () {
|
|
80
|
+
const specPath = join(deps.legacyDir, deps.pack.specsDir, `${page}.md`);
|
|
81
|
+
const missing = (yield* files.read(specPath)) === undefined;
|
|
82
|
+
return {
|
|
83
|
+
id: page,
|
|
84
|
+
title: page,
|
|
85
|
+
...(wave === undefined ? {} : { wave }),
|
|
86
|
+
// A page the decisions overlay disposed of as a whole (ADR 0015)
|
|
87
|
+
// is listed with its disposition, like a page triaged dead.
|
|
88
|
+
...(disposition !== undefined
|
|
89
|
+
? { skip: `${disposition} by decision` }
|
|
90
|
+
: missing
|
|
91
|
+
? { skip: "no extracted spec" }
|
|
92
|
+
: {}),
|
|
93
|
+
convert: convertPage(deps, page)
|
|
94
|
+
};
|
|
95
|
+
}));
|
|
96
|
+
}
|
|
97
|
+
if (items.length === 0) {
|
|
62
98
|
yield* context.events.publish(Info.make({ message: "inventory is empty — extract the legacy estate first" }));
|
|
63
99
|
return;
|
|
64
100
|
}
|
|
65
101
|
// The whole estate lands on the board as planned up front — the
|
|
66
102
|
// breadth view exists from minute one.
|
|
67
|
-
yield* stage(context.events, "board", board.plan(
|
|
68
|
-
id:
|
|
69
|
-
title:
|
|
103
|
+
yield* stage(context.events, "board", board.plan(items.map((item) => BoardItem.make({
|
|
104
|
+
id: item.id,
|
|
105
|
+
title: item.title,
|
|
70
106
|
status: "planned",
|
|
71
|
-
...(wave === undefined ? {} : { wave })
|
|
107
|
+
...(item.wave === undefined ? {} : { wave: item.wave }),
|
|
108
|
+
...(item.detail === undefined ? {} : { detail: item.detail })
|
|
72
109
|
}))));
|
|
73
110
|
const baseBranch = yield* context.git.currentBranch;
|
|
74
111
|
const failFast = process.env.LLM4TS_FAIL_FAST === "1";
|
|
75
|
-
for (const
|
|
112
|
+
for (const item of items) {
|
|
76
113
|
const snapshot = yield* board.snapshot;
|
|
77
|
-
const known = snapshot.items.find((
|
|
114
|
+
const known = snapshot.items.find((candidate) => candidate.id === item.id);
|
|
78
115
|
if (known !== undefined && known.status !== "planned" && known.status !== "failed") {
|
|
79
|
-
yield* context.events.publish(Info.make({ message: `resume: ${
|
|
116
|
+
yield* context.events.publish(Info.make({ message: `resume: ${item.id} is already ${known.status} — skipping` }));
|
|
80
117
|
continue;
|
|
81
118
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
yield* board.skip(page, "no extracted spec");
|
|
119
|
+
if (item.skip !== undefined) {
|
|
120
|
+
yield* board.skip(item.id, item.skip);
|
|
85
121
|
continue;
|
|
86
122
|
}
|
|
87
123
|
const checkpoint = yield* context.git.checkpoint;
|
|
88
|
-
yield* board.start(
|
|
89
|
-
const result = yield* Effect.result(
|
|
124
|
+
yield* board.start(item.id);
|
|
125
|
+
const result = yield* Effect.result(item.convert);
|
|
90
126
|
if (result._tag === "Success") {
|
|
91
127
|
const outcome = result.success;
|
|
92
|
-
yield* board.complete(
|
|
128
|
+
yield* board.complete(item.id, {
|
|
93
129
|
branch: outcome.branch,
|
|
94
130
|
reportPath: outcome.reportPath,
|
|
95
131
|
...(outcome.estimatedTokens === undefined
|
|
@@ -103,15 +139,15 @@ const program = Effect.gen(function* () {
|
|
|
103
139
|
}
|
|
104
140
|
else {
|
|
105
141
|
const reason = describeFlowError(result.failure);
|
|
106
|
-
// A stuck
|
|
142
|
+
// A stuck unit must not sink the walk: reset the working tree,
|
|
107
143
|
// mark the failure, keep going (LLM4TS_FAIL_FAST=1 to stop).
|
|
108
144
|
yield* context.git.rollback(checkpoint);
|
|
109
145
|
yield* context.git.checkout(baseBranch);
|
|
110
|
-
yield* board.fail(
|
|
146
|
+
yield* board.fail(item.id, reason);
|
|
111
147
|
if (failFast) {
|
|
112
148
|
return yield* Effect.fromResult(result);
|
|
113
149
|
}
|
|
114
|
-
yield* context.events.publish(Info.make({ message:
|
|
150
|
+
yield* context.events.publish(Info.make({ message: `${item.id} failed — continuing: ${reason}` }));
|
|
115
151
|
}
|
|
116
152
|
}
|
|
117
153
|
const finalBoard = yield* board.snapshot;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Convert ONE domain feature of the approved domains.md into the destination Next.js SPA: one branch, one merged contract, the port then each page in navigation order.
|
|
2
|
+
//
|
|
3
|
+
// Runs rooted at the TARGET repository (`--repo <nextjs>`), with
|
|
4
|
+
// LLM4TS_LEGACY_REPO pointing at the refined legacy repository whose
|
|
5
|
+
// docs/modernization/domains.md is approved. The task text is the feature id:
|
|
6
|
+
//
|
|
7
|
+
// LLM4TS_LEGACY_REPO=~/estates/demo-bank-legacy \
|
|
8
|
+
// llm4ts run convert-feature --repo ~/estates/demo-bank-nextjs beneficiary-maintenance
|
|
9
|
+
//
|
|
10
|
+
// ADR 0012 addendum: the unit of delivery is the domain feature — its
|
|
11
|
+
// surviving pages share `contracts/<feature>.openapi.yaml` (the deterministic
|
|
12
|
+
// union of their API sections; conflicts are open points in domains.md, never
|
|
13
|
+
// silent merges), one port under src/services/<feature>/, page components
|
|
14
|
+
// and tests per page. Gates and judge as convert-page, plus the feature
|
|
15
|
+
// judged against its contract of record. Branch `convert/<feature>`, no PR.
|
|
16
|
+
import * as Effect from "effect/Effect";
|
|
17
|
+
import { FlowAborted, Info, asReadOnly, coderFromEnv, resolveFlowInput, runFlowMain, runNode } from "@llm4ts/runner";
|
|
18
|
+
import { convertFeature, setupConversion } from "./lib/convert.js";
|
|
19
|
+
const program = Effect.gen(function* () {
|
|
20
|
+
const input = yield* resolveFlowInput("Convert one domain feature into the destination SPA");
|
|
21
|
+
const feature = input.prompt.trim().split(/\s+/)[0] ?? "";
|
|
22
|
+
const coder = coderFromEnv(process.env);
|
|
23
|
+
yield* runNode({
|
|
24
|
+
workDir: input.workDir,
|
|
25
|
+
workspace: input.workspace,
|
|
26
|
+
userPrompt: input.prompt,
|
|
27
|
+
coder,
|
|
28
|
+
reasoning: asReadOnly(coder),
|
|
29
|
+
reviewers: [asReadOnly(coder)],
|
|
30
|
+
environment: process.env
|
|
31
|
+
}, (context) => Effect.gen(function* () {
|
|
32
|
+
if (feature.length === 0) {
|
|
33
|
+
return yield* FlowAborted.make({
|
|
34
|
+
message: "pass the domain feature id to convert, e.g.: llm4ts run convert-feature beneficiary-maintenance"
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
const deps = yield* setupConversion(context, input, process.env, import.meta.dirname);
|
|
38
|
+
const outcome = yield* convertFeature(deps, feature);
|
|
39
|
+
yield* context.events.publish(Info.make({
|
|
40
|
+
message: `converted feature ${outcome.page} on branch ${outcome.branch} — ` +
|
|
41
|
+
`report at ${outcome.reportPath}` +
|
|
42
|
+
(outcome.estimatedTokens === undefined
|
|
43
|
+
? ""
|
|
44
|
+
: ` (~${outcome.estimatedTokens} tokens, ESTIMATED)`)
|
|
45
|
+
}));
|
|
46
|
+
}));
|
|
47
|
+
});
|
|
48
|
+
runFlowMain(program);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Shared core of the J2EE→Next.js conversion flows (convert-page,
|
|
2
|
-
// convert-all). One page
|
|
2
|
+
// convert-feature, convert-all). One page — or, once refine has produced an
|
|
3
|
+
// approved domain map, one domain feature — = one branch = one conversion report. The Page Spec
|
|
3
4
|
// extracted from the legacy repo is the contract; the legacy source is
|
|
4
5
|
// consultable evidence (NOT clean-room — ADR 0012); the destination repo's
|
|
5
6
|
// own pages are the style guide. All token/cost figures are ESTIMATES.
|
|
@@ -10,8 +11,10 @@ import { judge } from "@llm4ts/core/eval/Judge";
|
|
|
10
11
|
import { TokenUsage } from "@llm4ts/core/Models";
|
|
11
12
|
import { FlowAborted, Info, Plan, implementPlanFlow, lintCommand, loadKitPatternCards, makeChat, makeNodeWorkspace, makePlanStore, mergeReviewResults, minimalReviewers, nodePlainFileStore, nodeProcessExecutor, openPack, reviewFingerprint, stage } from "@llm4ts/runner";
|
|
12
13
|
import { budget, capped } from "@llm4ts/flow/Context";
|
|
14
|
+
import { Decisions, parseDecisions } from "@llm4ts/flow/Decisions";
|
|
15
|
+
import { navigationOrder, parseDomains } from "@llm4ts/flow/Domains";
|
|
13
16
|
import { FlowEvents } from "@llm4ts/flow/FlowEvents";
|
|
14
|
-
import { openApiFor, parsePageSpec, renderPageSpec } from "@llm4ts/flow/PageSpec";
|
|
17
|
+
import { openApiFor, openApiForFeature, parsePageSpec, renderPageSpec } from "@llm4ts/flow/PageSpec";
|
|
15
18
|
import { loadPatternCards, matchingPatternCards } from "@llm4ts/flow/Patterns";
|
|
16
19
|
import { Task } from "@llm4ts/flow/Plan";
|
|
17
20
|
import { judgeAllPrograms } from "@llm4ts/flow/ProgramJudge";
|
|
@@ -117,6 +120,68 @@ const conversionPlan = (page, spec, contractPath) => Plan.make({
|
|
|
117
120
|
})
|
|
118
121
|
]
|
|
119
122
|
});
|
|
123
|
+
/**
|
|
124
|
+
* The feature plan (ADR 0012 addendum): the shared port first, then one
|
|
125
|
+
* task per page in navigation order with its tests inside, so every task is
|
|
126
|
+
* a vertical slice behind the gate.
|
|
127
|
+
*/
|
|
128
|
+
export const featurePlan = (feature, order, specs, contractPath) => Plan.make({
|
|
129
|
+
epicId: `convert/${feature.id}`,
|
|
130
|
+
brief: [
|
|
131
|
+
`You are converting the legacy domain feature '${feature.name}' (${feature.id}) — pages`,
|
|
132
|
+
`${order.join(", ")} — into this Next.js SPA.`,
|
|
133
|
+
...(feature.context.length === 0
|
|
134
|
+
? []
|
|
135
|
+
: [
|
|
136
|
+
`The pages include the shared fragments ${feature.context.join(", ")}: they are context,`,
|
|
137
|
+
"already provided by the app layout — do not re-implement them here."
|
|
138
|
+
]),
|
|
139
|
+
"",
|
|
140
|
+
...order.flatMap((page) => {
|
|
141
|
+
const spec = specs.get(page);
|
|
142
|
+
return spec === undefined ? [] : [`===== ${page} =====`, renderPageSpec(spec).trimEnd(), ""];
|
|
143
|
+
}),
|
|
144
|
+
`The OpenAPI anti-corruption contract is ALREADY WRITTEN at ${contractPath} — it is the`,
|
|
145
|
+
"union of the pages' API sections and the contract of record. Do not edit it."
|
|
146
|
+
].join("\n"),
|
|
147
|
+
tasks: [
|
|
148
|
+
Task.make({
|
|
149
|
+
title: `acl: ${feature.id} service port and mock`,
|
|
150
|
+
description: [
|
|
151
|
+
`Implement the anti-corruption service layer for '${feature.name}' from the contract at`,
|
|
152
|
+
`${contractPath}:`,
|
|
153
|
+
`- src/services/${feature.id}/port.ts — a typed port interface with one method per`,
|
|
154
|
+
" OpenAPI operation, request/response types in the contract's DOMAIN names.",
|
|
155
|
+
`- src/services/${feature.id}/mock.ts — a mock adapter returning contract-shaped,`,
|
|
156
|
+
" deterministic fixture data (transport fake, never business rules).",
|
|
157
|
+
"- Wire the port into src/services/registry.ts the same way the existing",
|
|
158
|
+
" services are wired.",
|
|
159
|
+
"Imitate the existing services (e.g. src/services/cards/) exactly. No page code",
|
|
160
|
+
"in this task."
|
|
161
|
+
].join("\n")
|
|
162
|
+
}),
|
|
163
|
+
...order.map((page) => Task.make({
|
|
164
|
+
title: `page: ${page} component and tests`,
|
|
165
|
+
description: [
|
|
166
|
+
`Build the converted page under src/app/${page}/ using ONLY the destination`,
|
|
167
|
+
`design-system components and the '${feature.id}' port from the first task:`,
|
|
168
|
+
"- Respect the original form: same fields, same validation rules with their",
|
|
169
|
+
" VERBATIM messages in the spec's order, same navigation between the feature's pages.",
|
|
170
|
+
"- Anything the legacy app kept in HttpSession or hidden fields becomes explicit",
|
|
171
|
+
" client state (use the Stepper pattern for multi-step flows).",
|
|
172
|
+
"- No fetch in components; the page obtains its port from the registry.",
|
|
173
|
+
"- Read CONTRIBUTING.md and the existing pages first and match their style.",
|
|
174
|
+
`Then write its component tests at tests/${page}.page.test.tsx in the house test`,
|
|
175
|
+
"style (see the existing tests/ files): mock the registry port, render inside",
|
|
176
|
+
"AuthProvider, and assert EXACTLY three families of behaviour:",
|
|
177
|
+
"1. every spec'd form field renders,",
|
|
178
|
+
"2. every spec'd validation fires with its verbatim message,",
|
|
179
|
+
"3. the port is called with contract-shaped payloads on the happy path.",
|
|
180
|
+
"No snapshots, no styling assertions, nothing beyond those families."
|
|
181
|
+
].join("\n")
|
|
182
|
+
}))
|
|
183
|
+
]
|
|
184
|
+
});
|
|
120
185
|
const gateFor = (deps, name) => {
|
|
121
186
|
const command = deps.pack.gate(name);
|
|
122
187
|
return command === undefined
|
|
@@ -177,26 +242,36 @@ const destinationGuidance = Effect.fn("convert.destinationGuidance")(function* (
|
|
|
177
242
|
...pages.map((path) => `- ${path}`)
|
|
178
243
|
].join("\n");
|
|
179
244
|
});
|
|
180
|
-
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
245
|
+
const scopeFor = (decisions, pages) => {
|
|
246
|
+
const lines = pages.flatMap((page) => decisions.scenarios
|
|
247
|
+
.filter((entry) => entry.program === page)
|
|
248
|
+
.map((entry) => `- ${page} / ${entry.scenario}: ${entry.disposition} — ${entry.reason}`));
|
|
249
|
+
return lines.length === 0
|
|
250
|
+
? undefined
|
|
251
|
+
: "Out of scope by decision (do not implement, do not test, do not score their absence):\n" +
|
|
252
|
+
lines.join("\n");
|
|
253
|
+
};
|
|
254
|
+
const readSpec = Effect.fn("convert.readSpec")(function* (deps, page) {
|
|
255
|
+
const path = join(deps.legacyDir, deps.pack.specsDir, `${page}.md`);
|
|
256
|
+
const markdown = yield* deps.files.read(path);
|
|
257
|
+
if (markdown === undefined) {
|
|
185
258
|
return yield* FlowAborted.make({
|
|
186
|
-
message: `no spec at ${
|
|
259
|
+
message: `no spec at ${path} — run modernize-extract on the legacy repo first`
|
|
187
260
|
});
|
|
188
261
|
}
|
|
189
262
|
// Hard schema validation: a spec without a decodable pagespec block is an
|
|
190
263
|
// incomplete extraction, not a page to guess at.
|
|
191
|
-
const spec = yield* parsePageSpec(
|
|
192
|
-
|
|
264
|
+
const spec = yield* parsePageSpec(markdown);
|
|
265
|
+
return { path, markdown, spec };
|
|
266
|
+
});
|
|
267
|
+
const runConversion = Effect.fn("convert.run")(function* (deps, unit) {
|
|
268
|
+
const { context, environment, files, pack } = deps;
|
|
269
|
+
const branch = `convert/${unit.id}`;
|
|
193
270
|
yield* stage(context.events, "branch", context.git.checkoutOrCreate(branch));
|
|
194
|
-
// The contract is a deterministic projection of the reviewed spec — written
|
|
271
|
+
// The contract is a deterministic projection of the reviewed spec(s) — written
|
|
195
272
|
// by code before any model runs, committed with the first task.
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
const { source, evidence } = yield* legacyEvidence(deps, page);
|
|
199
|
-
const playbook = matchingPatternCards(source, deps.cards);
|
|
273
|
+
yield* stage(context.events, "contract", files.writeAtomic(join(deps.targetDir, unit.contractPath), unit.contract));
|
|
274
|
+
const playbook = matchingPatternCards(unit.source, deps.cards);
|
|
200
275
|
const guidance = yield* destinationGuidance(deps);
|
|
201
276
|
const system = [
|
|
202
277
|
pack.prompt("implement"),
|
|
@@ -209,9 +284,10 @@ export const convertPage = Effect.fn("convert.page")(function* (deps, page) {
|
|
|
209
284
|
"the spec wins):\n\n" +
|
|
210
285
|
playbook.map((card) => `### ${card.id}\n${card.body}`).join("\n\n"),
|
|
211
286
|
guidance,
|
|
212
|
-
|
|
287
|
+
unit.scope,
|
|
288
|
+
unit.evidence.trim().length === 0
|
|
213
289
|
? undefined
|
|
214
|
-
: `Legacy source evidence (for disambiguation only — the Page Spec wins):\n\n${evidence}`
|
|
290
|
+
: `Legacy source evidence (for disambiguation only — the Page Spec wins):\n\n${unit.evidence}`
|
|
215
291
|
]
|
|
216
292
|
.filter((part) => part !== undefined)
|
|
217
293
|
.join("\n\n");
|
|
@@ -222,8 +298,8 @@ export const convertPage = Effect.fn("convert.page")(function* (deps, page) {
|
|
|
222
298
|
]);
|
|
223
299
|
yield* implementPlanFlow(context, {
|
|
224
300
|
store: makePlanStore(files),
|
|
225
|
-
planPath: join(deps.targetDir, ".llm4ts", "convert", `${
|
|
226
|
-
plan: Effect.succeed(
|
|
301
|
+
planPath: join(deps.targetDir, ".llm4ts", "convert", `${unit.id}.plan.md`),
|
|
302
|
+
plan: Effect.succeed(unit.plan),
|
|
227
303
|
system,
|
|
228
304
|
chatPerTask: true,
|
|
229
305
|
checkoutBranch: false,
|
|
@@ -235,13 +311,14 @@ export const convertPage = Effect.fn("convert.page")(function* (deps, page) {
|
|
|
235
311
|
const result = yield* verifyGate;
|
|
236
312
|
if (!result.isClean) {
|
|
237
313
|
return yield* FlowAborted.make({
|
|
238
|
-
message: `verify gate failed for ${
|
|
314
|
+
message: `verify gate failed for ${unit.id}:\n${issueLines(result)}`
|
|
239
315
|
});
|
|
240
316
|
}
|
|
241
317
|
}));
|
|
242
318
|
yield* stage(context.events, "judge", Effect.gen(function* () {
|
|
243
319
|
const complianceJudge = judge(context.reasoning, conversionDimensions);
|
|
244
320
|
const rounds = positiveEnvInt(environment, "LLM4TS_JUDGE_ROUNDS", 2);
|
|
321
|
+
const specOf = new Map(unit.judged.map((entry) => [entry.name, entry.spec]));
|
|
245
322
|
for (let round = 1; round <= rounds; round += 1) {
|
|
246
323
|
const base = yield* context.git.defaultBase;
|
|
247
324
|
const verdict = yield* judgeAllPrograms({
|
|
@@ -252,17 +329,17 @@ export const convertPage = Effect.fn("convert.page")(function* (deps, page) {
|
|
|
252
329
|
files,
|
|
253
330
|
gateDir: join(deps.targetDir, ".llm4ts", "convert", "gate"),
|
|
254
331
|
base,
|
|
255
|
-
programs:
|
|
256
|
-
specFor: () => Effect.succeed(
|
|
332
|
+
programs: unit.judged.map((entry) => entry.name),
|
|
333
|
+
specFor: (program) => Effect.succeed(specOf.get(program) ?? ""),
|
|
257
334
|
query: context.userPrompt,
|
|
258
335
|
fingerprint: reviewFingerprint
|
|
259
336
|
});
|
|
260
337
|
if (verdict.isClean) {
|
|
261
|
-
return yield* context.events.publish(Info.make({ message: `judge: ${
|
|
338
|
+
return yield* context.events.publish(Info.make({ message: `judge: ${unit.id} cleared the bar` }));
|
|
262
339
|
}
|
|
263
340
|
if (round >= rounds) {
|
|
264
341
|
return yield* FlowAborted.make({
|
|
265
|
-
message: `judge not cleared for ${
|
|
342
|
+
message: `judge not cleared for ${unit.id} after ${rounds} round(s):\n${issueLines(verdict)}`
|
|
266
343
|
});
|
|
267
344
|
}
|
|
268
345
|
const feedback = yield* makeChat(context.coder, {
|
|
@@ -271,33 +348,33 @@ export const convertPage = Effect.fn("convert.page")(function* (deps, page) {
|
|
|
271
348
|
agent: "coder"
|
|
272
349
|
});
|
|
273
350
|
yield* feedback.ask([
|
|
274
|
-
`The conversion of '${
|
|
351
|
+
`The conversion of '${unit.id}' scored below the bar. Close these gaps without`,
|
|
275
352
|
"weakening any test, then stop:",
|
|
276
353
|
issueLines(verdict)
|
|
277
354
|
].join("\n"));
|
|
278
355
|
const regated = yield* verifyGate;
|
|
279
356
|
if (!regated.isClean) {
|
|
280
357
|
return yield* FlowAborted.make({
|
|
281
|
-
message: `verify gate broke while addressing judge feedback on ${
|
|
358
|
+
message: `verify gate broke while addressing judge feedback on ${unit.id}`
|
|
282
359
|
});
|
|
283
360
|
}
|
|
284
|
-
yield* context.git.commitAll(`convert/${
|
|
361
|
+
yield* context.git.commitAll(`convert/${unit.id}: address judge feedback`);
|
|
285
362
|
}
|
|
286
363
|
}).pipe(Effect.provideService(FlowEvents, context.events)));
|
|
287
364
|
const totals = yield* deps.totals;
|
|
288
|
-
const reportPath = `docs/conversion/${
|
|
365
|
+
const reportPath = `docs/conversion/${unit.id}.md`;
|
|
289
366
|
const base = yield* context.git.defaultBase;
|
|
290
367
|
const changed = yield* context.git.changedFilesVsBase(base);
|
|
291
368
|
const report = [
|
|
292
|
-
`# Conversion report: ${
|
|
369
|
+
`# Conversion report: ${unit.id}`,
|
|
293
370
|
"",
|
|
294
371
|
"> Token and cost figures below are ESTIMATES from character counts",
|
|
295
372
|
"> (see docs/adr/0012): the CLI seats report no usage. They are not",
|
|
296
373
|
"> measurements.",
|
|
297
374
|
"",
|
|
298
|
-
|
|
375
|
+
...unit.reportLines,
|
|
299
376
|
`- Branch: \`${branch}\` (awaiting human review — no auto-merge)`,
|
|
300
|
-
`- Contract: ${contractPath}`,
|
|
377
|
+
`- Contract: ${unit.contractPath}`,
|
|
301
378
|
`- Gates: typecheck, lint, test, build — green at report time`,
|
|
302
379
|
"- Judge: cleared (spec-compliance, acl-purity, house-fidelity)",
|
|
303
380
|
...(totals === undefined
|
|
@@ -312,20 +389,148 @@ export const convertPage = Effect.fn("convert.page")(function* (deps, page) {
|
|
|
312
389
|
"## Files changed",
|
|
313
390
|
"",
|
|
314
391
|
...changed.map((file) => `- ${file}`),
|
|
315
|
-
...(
|
|
392
|
+
...(unit.openQuestions.length === 0
|
|
316
393
|
? []
|
|
317
|
-
: [
|
|
394
|
+
: [
|
|
395
|
+
"",
|
|
396
|
+
"## Open questions carried forward",
|
|
397
|
+
"",
|
|
398
|
+
...unit.openQuestions.map((q) => unit.kind === "page" ? `- ${q.question}` : `- ${q.page}: ${q.question}`)
|
|
399
|
+
])
|
|
318
400
|
].join("\n");
|
|
319
401
|
yield* files.writeAtomic(join(deps.targetDir, reportPath), report + "\n");
|
|
320
|
-
yield* context.git.commitAll(`convert/${
|
|
402
|
+
yield* context.git.commitAll(`convert/${unit.id}: conversion report`);
|
|
321
403
|
return {
|
|
322
|
-
page,
|
|
404
|
+
page: unit.id,
|
|
323
405
|
branch,
|
|
324
406
|
reportPath,
|
|
325
407
|
...(totals === undefined ? {} : { estimatedTokens: totals.total }),
|
|
326
408
|
...(totals?.costUsd === undefined ? {} : { estimatedCostUsd: totals.costUsd })
|
|
327
409
|
};
|
|
328
410
|
});
|
|
411
|
+
export const convertPage = Effect.fn("convert.page")(function* (deps, page) {
|
|
412
|
+
const { path: specPath, markdown: specMarkdown, spec } = yield* readSpec(deps, page);
|
|
413
|
+
// The decisions overlay (ADR 0015): a page disposed as a whole is never
|
|
414
|
+
// converted; disposed scenarios are out of scope for the coder and the judge.
|
|
415
|
+
const decisions = yield* legacyDecisions(deps.files, deps.legacyDir);
|
|
416
|
+
const pageDecision = decisions.programDecision(page);
|
|
417
|
+
if (pageDecision !== undefined) {
|
|
418
|
+
return yield* FlowAborted.make({
|
|
419
|
+
message: `${page} is marked '${pageDecision.disposition}' in the legacy pack's decisions.md — ${pageDecision.reason}`
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
const scope = scopeFor(decisions, [page]);
|
|
423
|
+
const contractPath = `contracts/${page}.openapi.yaml`;
|
|
424
|
+
const { source, evidence } = yield* legacyEvidence(deps, page);
|
|
425
|
+
return yield* runConversion(deps, {
|
|
426
|
+
id: page,
|
|
427
|
+
kind: "page",
|
|
428
|
+
contractPath,
|
|
429
|
+
contract: openApiFor(spec),
|
|
430
|
+
plan: conversionPlan(page, spec, contractPath),
|
|
431
|
+
source,
|
|
432
|
+
evidence,
|
|
433
|
+
scope,
|
|
434
|
+
judged: [
|
|
435
|
+
{ name: page, spec: scope === undefined ? specMarkdown : `${specMarkdown}\n\n${scope}` }
|
|
436
|
+
],
|
|
437
|
+
openQuestions: spec.openQuestions.map((question) => ({ page, question })),
|
|
438
|
+
reportLines: [`- Legacy spec: ${specPath}`]
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
/** The legacy pack's domain map, or undefined when refine never consolidated it. */
|
|
442
|
+
export const legacyDomains = Effect.fn("convert.domains")(function* (files, legacyDir) {
|
|
443
|
+
const text = yield* files.read(join(legacyDir, "docs/modernization/domains.md"));
|
|
444
|
+
return text === undefined ? undefined : yield* parseDomains(text, "domains.md");
|
|
445
|
+
});
|
|
446
|
+
/**
|
|
447
|
+
* Convert ONE domain feature (ADR 0012 addendum): its surviving pages on one
|
|
448
|
+
* branch, one contract that is the union of their API sections, the port
|
|
449
|
+
* first and then each page with its tests in navigation order. The judge
|
|
450
|
+
* scores every page against its own spec plus the feature against its
|
|
451
|
+
* contract of record.
|
|
452
|
+
*/
|
|
453
|
+
export const convertFeature = Effect.fn("convert.feature")(function* (deps, featureId) {
|
|
454
|
+
const domains = yield* legacyDomains(deps.files, deps.legacyDir);
|
|
455
|
+
if (domains === undefined) {
|
|
456
|
+
return yield* FlowAborted.make({
|
|
457
|
+
message: "no docs/modernization/domains.md in the legacy pack — run modernize-refine first"
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
if (!domains.approved) {
|
|
461
|
+
return yield* FlowAborted.make({
|
|
462
|
+
message: "docs/modernization/domains.md is not approved — flip '- [x] Approved' first"
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
const feature = domains.features.find((candidate) => candidate.id === featureId);
|
|
466
|
+
if (feature === undefined) {
|
|
467
|
+
return yield* FlowAborted.make({
|
|
468
|
+
message: `no domain feature '${featureId}' in domains.md (known: ${domains.features.map((f) => f.id).join(", ")})`
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
const decisions = yield* legacyDecisions(deps.files, deps.legacyDir);
|
|
472
|
+
const pages = feature.programs.filter((page) => decisions.programDecision(page) === undefined);
|
|
473
|
+
if (pages.length === 0) {
|
|
474
|
+
return yield* FlowAborted.make({
|
|
475
|
+
message: `every page of '${featureId}' is disposed of in decisions.md — nothing to convert`
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
const specs = new Map();
|
|
479
|
+
for (const page of pages) {
|
|
480
|
+
specs.set(page, yield* readSpec(deps, page));
|
|
481
|
+
}
|
|
482
|
+
const graph = yield* surveyGraph(deps.legacy, deps.pack.sources ?? ".*", deps.pack.coverage, deps.pack.survey);
|
|
483
|
+
const order = navigationOrder({ ...feature, programs: pages }, graph, deps.pack.consolidate ?? { cluster: [], context: [] });
|
|
484
|
+
const contractPath = `contracts/${feature.id}.openapi.yaml`;
|
|
485
|
+
const contract = yield* openApiForFeature(feature, order.map((page) => specs.get(page)?.spec).filter((spec) => spec !== undefined));
|
|
486
|
+
const sources = [];
|
|
487
|
+
const evidences = [];
|
|
488
|
+
for (const page of order) {
|
|
489
|
+
const { source, evidence } = yield* legacyEvidence(deps, page);
|
|
490
|
+
sources.push(source);
|
|
491
|
+
evidences.push(evidence);
|
|
492
|
+
}
|
|
493
|
+
const evidence = yield* capped(`legacy[${feature.id}]`, evidences.join("\n\n"), Math.floor(budget(deps.environment) / 3)).pipe(Effect.provideService(FlowEvents, deps.context.events));
|
|
494
|
+
const scope = scopeFor(decisions, order);
|
|
495
|
+
const specMap = new Map(order.map((page) => [page, specs.get(page)?.spec]));
|
|
496
|
+
const plan = featurePlan(feature, order, new Map([...specMap.entries()].flatMap(([k, v]) => (v === undefined ? [] : [[k, v]]))), contractPath);
|
|
497
|
+
return yield* runConversion(deps, {
|
|
498
|
+
id: feature.id,
|
|
499
|
+
kind: "feature",
|
|
500
|
+
contractPath,
|
|
501
|
+
contract: contract.yaml,
|
|
502
|
+
plan,
|
|
503
|
+
source: sources.join("\n"),
|
|
504
|
+
evidence,
|
|
505
|
+
scope,
|
|
506
|
+
judged: [
|
|
507
|
+
...order.map((page) => {
|
|
508
|
+
const markdown = specs.get(page)?.markdown ?? "";
|
|
509
|
+
return { name: page, spec: scope === undefined ? markdown : `${markdown}\n\n${scope}` };
|
|
510
|
+
}),
|
|
511
|
+
// The feature itself is judged against its contract of record: the
|
|
512
|
+
// pack's feature-files scope is exactly the port and the contract.
|
|
513
|
+
{
|
|
514
|
+
name: feature.id,
|
|
515
|
+
spec: `# Contract of record for domain feature ${feature.name}\n\n` +
|
|
516
|
+
"The service port under src/services/<feature>/ must expose one operation per path " +
|
|
517
|
+
"and method below, with request and response types in the contract's domain names.\n\n" +
|
|
518
|
+
"```yaml\n" +
|
|
519
|
+
contract.yaml +
|
|
520
|
+
"```"
|
|
521
|
+
}
|
|
522
|
+
],
|
|
523
|
+
openQuestions: order.flatMap((page) => (specs.get(page)?.spec.openQuestions ?? []).map((question) => ({ page, question }))),
|
|
524
|
+
reportLines: [
|
|
525
|
+
`- Domain feature: ${feature.name} (${feature.id})`,
|
|
526
|
+
`- Pages, in navigation order: ${order.join(", ")}`,
|
|
527
|
+
...(feature.context.length === 0
|
|
528
|
+
? []
|
|
529
|
+
: [`- Context fragments: ${feature.context.join(", ")}`]),
|
|
530
|
+
...order.map((page) => `- Legacy spec: ${specs.get(page)?.path ?? page}`)
|
|
531
|
+
]
|
|
532
|
+
});
|
|
533
|
+
});
|
|
329
534
|
/**
|
|
330
535
|
* The common wiring of both conversion flows: metered seats (estimates-only
|
|
331
536
|
* accounting), the two workspaces (legacy read-only limits, target default),
|
|
@@ -392,22 +597,37 @@ export const parseWavePlan = (planText) => {
|
|
|
392
597
|
continue;
|
|
393
598
|
}
|
|
394
599
|
const current = waves.at(-1);
|
|
395
|
-
|
|
600
|
+
// The approval marker sits under the last wave; it is not a page.
|
|
601
|
+
if (collecting &&
|
|
602
|
+
current !== undefined &&
|
|
603
|
+
trimmed.startsWith("- ") &&
|
|
604
|
+
!/^- \[[ xX]\] /.test(trimmed)) {
|
|
396
605
|
current.pages.push(trimmed.slice(2).trim());
|
|
397
606
|
}
|
|
398
607
|
}
|
|
399
608
|
return waves;
|
|
400
609
|
};
|
|
610
|
+
/** The legacy pack's decisions overlay, empty when refine never ran. */
|
|
611
|
+
export const legacyDecisions = Effect.fn("convert.decisions")(function* (files, legacyDir) {
|
|
612
|
+
const text = yield* files.read(join(legacyDir, "docs/modernization/decisions.md"));
|
|
613
|
+
return text === undefined ? Decisions.empty() : yield* parseDecisions(text, "decisions.md");
|
|
614
|
+
});
|
|
401
615
|
/**
|
|
402
616
|
* The ordered page list: the approved wave plan when present, otherwise every
|
|
403
|
-
* extracted spec. Pages appear in conversion order
|
|
617
|
+
* extracted spec. Pages appear in conversion order; a page disposed as a whole
|
|
618
|
+
* by the decisions overlay carries its disposition and is skipped by the walk.
|
|
404
619
|
*/
|
|
405
620
|
export const conversionInventory = Effect.fn("convert.inventory")(function* (files, legacy, legacyDir, pack) {
|
|
621
|
+
const decisions = yield* legacyDecisions(files, legacyDir);
|
|
622
|
+
const withDisposition = (entry) => {
|
|
623
|
+
const decision = decisions.programDecision(entry.page);
|
|
624
|
+
return decision === undefined ? entry : { ...entry, disposition: decision.disposition };
|
|
625
|
+
};
|
|
406
626
|
const planText = yield* files.read(join(legacyDir, "docs/modernization/wave-plan.md"));
|
|
407
627
|
if (planText !== undefined) {
|
|
408
628
|
const waves = parseWavePlan(planText);
|
|
409
629
|
if (waves.length > 0) {
|
|
410
|
-
return waves.flatMap((entry) => entry.pages.map((page) => ({ page, wave: entry.wave })));
|
|
630
|
+
return waves.flatMap((entry) => entry.pages.map((page) => withDisposition({ page, wave: entry.wave })));
|
|
411
631
|
}
|
|
412
632
|
}
|
|
413
633
|
const specs = yield* legacy
|
|
@@ -415,10 +635,42 @@ export const conversionInventory = Effect.fn("convert.inventory")(function* (fil
|
|
|
415
635
|
.pipe(Effect.orElseSucceed(() => []));
|
|
416
636
|
return [...specs]
|
|
417
637
|
.map((path) => path.split("/").at(-1) ?? path)
|
|
418
|
-
.filter((name) => name.endsWith(".md") &&
|
|
638
|
+
.filter((name) => name.endsWith(".md") && !["README.md", "decisions.md", "domains.md"].includes(name))
|
|
419
639
|
.map((name) => name.slice(0, -".md".length))
|
|
420
640
|
.sort()
|
|
421
|
-
.map((page) => ({ page }));
|
|
641
|
+
.map((page) => withDisposition({ page }));
|
|
642
|
+
});
|
|
643
|
+
/**
|
|
644
|
+
* The feature walk (ADR 0012 addendum): the approved domain map's features
|
|
645
|
+
* ordered by the earliest wave of their pages, then by id; undefined when the
|
|
646
|
+
* legacy pack has no approved map, so the caller falls back to pages.
|
|
647
|
+
*/
|
|
648
|
+
export const featureInventory = Effect.fn("convert.featureInventory")(function* (files, legacy, legacyDir, pack) {
|
|
649
|
+
const domains = yield* legacyDomains(files, legacyDir);
|
|
650
|
+
if (domains === undefined || !domains.approved) {
|
|
651
|
+
return undefined;
|
|
652
|
+
}
|
|
653
|
+
const pages = yield* conversionInventory(files, legacy, legacyDir, pack);
|
|
654
|
+
const waveOf = new Map(pages.map((entry) => [entry.page, entry.wave]));
|
|
655
|
+
const waveIndex = [...new Set(pages.map((entry) => entry.wave))];
|
|
656
|
+
const decisions = yield* legacyDecisions(files, legacyDir);
|
|
657
|
+
return domains.features
|
|
658
|
+
.map((feature) => {
|
|
659
|
+
const waves = feature.programs
|
|
660
|
+
.map((page) => waveOf.get(page))
|
|
661
|
+
.filter((wave) => wave !== undefined)
|
|
662
|
+
.sort((left, right) => waveIndex.indexOf(left) - waveIndex.indexOf(right));
|
|
663
|
+
const wave = waves[0];
|
|
664
|
+
return {
|
|
665
|
+
feature,
|
|
666
|
+
...(wave === undefined ? {} : { wave }),
|
|
667
|
+
disposed: feature.programs.every((page) => decisions.programDecision(page) !== undefined)
|
|
668
|
+
};
|
|
669
|
+
})
|
|
670
|
+
.sort((left, right) => {
|
|
671
|
+
const byWave = waveIndex.indexOf(left.wave ?? "") - waveIndex.indexOf(right.wave ?? "");
|
|
672
|
+
return byWave !== 0 ? byWave : left.feature.id.localeCompare(right.feature.id);
|
|
673
|
+
});
|
|
422
674
|
});
|
|
423
675
|
/**
|
|
424
676
|
* Whole-estate summary with a deliberately naive projection: average estimated
|