@llm4ts/shell 2.1.0 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) 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/lib/modernize-extract.js +226 -0
  10. package/flows/modernize-extract.js +13 -173
  11. package/flows/modernize-implement.js +28 -2
  12. package/flows/modernize-pack-check.js +7 -1
  13. package/flows/modernize-pack-upgrade.js +246 -0
  14. package/flows/modernize-refine.js +389 -0
  15. package/flows/modernize-seed.js +50 -2
  16. package/flows/modernize-verify.js +12 -5
  17. package/kits/j2ee-nextjs/README.md +5 -4
  18. package/kits/j2ee-nextjs/fixtures/demo-bank/RUNBOOK.md +43 -0
  19. package/kits/j2ee-nextjs/fixtures/demo-bank/legacy-j2ee/PAGES.md +26 -0
  20. package/kits/j2ee-nextjs/flows/convert-all.js +56 -20
  21. package/kits/j2ee-nextjs/flows/convert-feature.js +48 -0
  22. package/kits/j2ee-nextjs/flows/lib/convert.js +292 -40
  23. package/kits/j2ee-nextjs/packs/j2ee-nextjs-spa/pack.md +16 -0
  24. package/kits/j2ee-nextjs/packs/j2ee-nextjs-spa/prompts/consolidate.md +10 -0
  25. package/kits/j2ee-nextjs/packs/j2ee-nextjs-spa/prompts/plan.md +24 -16
  26. package/kits/j2ee-nextjs/packs/j2ee-nextjs-spa/prompts/refine-propose.md +16 -0
  27. package/kits/mainframe-java/packs/cobol-springboot/pack.md +5 -0
  28. package/kits/mainframe-java/packs/cobol-springboot/prompts/consolidate.md +8 -0
  29. package/kits/mainframe-java/packs/cobol-springboot/prompts/refine-propose.md +10 -0
  30. package/package.json +5 -4
  31. package/src/Cli.ts +57 -0
  32. package/src/Refine.ts +504 -0
@@ -1,5 +1,6 @@
1
1
  // Shared core of the J2EE→Next.js conversion flows (convert-page,
2
- // convert-all). One page = one branch = one conversion report. The Page Spec
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
- export const convertPage = Effect.fn("convert.page")(function* (deps, page) {
181
- const { context, environment, files, pack } = deps;
182
- const specPath = join(deps.legacyDir, pack.specsDir, `${page}.md`);
183
- const specMarkdown = yield* files.read(specPath);
184
- if (specMarkdown === undefined) {
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 ${specPath} — run modernize-extract on the legacy repo first`
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(specMarkdown);
192
- const branch = `convert/${page}`;
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
- const contractPath = `contracts/${page}.openapi.yaml`;
197
- yield* stage(context.events, "contract", files.writeAtomic(join(deps.targetDir, contractPath), openApiFor(spec)));
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
- evidence.trim().length === 0
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", `${page}.plan.md`),
226
- plan: Effect.succeed(conversionPlan(page, spec, contractPath)),
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 ${page}:\n${issueLines(result)}`
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: [page],
256
- specFor: () => Effect.succeed(specMarkdown),
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: ${page} cleared the bar` }));
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 ${page} after ${rounds} round(s):\n${issueLines(verdict)}`
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 '${page}' scored below the bar. Close these gaps without`,
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 ${page}`
358
+ message: `verify gate broke while addressing judge feedback on ${unit.id}`
282
359
  });
283
360
  }
284
- yield* context.git.commitAll(`convert/${page}: address judge feedback`);
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/${page}.md`;
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: ${page}`,
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
- `- Legacy spec: ${specPath}`,
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
- ...(spec.openQuestions.length === 0
392
+ ...(unit.openQuestions.length === 0
316
393
  ? []
317
- : ["", "## Open questions carried forward", "", ...spec.openQuestions.map((q) => `- ${q}`)])
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/${page}: conversion report`);
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
- if (collecting && current !== undefined && trimmed.startsWith("- ")) {
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") && name !== "README.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
@@ -8,6 +8,7 @@ programs: .*\.jsp
8
8
  specs-dir: docs/modernization/specs
9
9
  features-dir: docs/modernization/features
10
10
  program-files: (?:src/app/<NAME>(?:/.*)?|src/services/<NAME>(?:/.*)?|contracts/<NAME>\.openapi\.yaml|tests/<NAME>\..*)
11
+ feature-files: (?:src/services/<NAME>(?:/.*)?|contracts/<NAME>\.openapi\.yaml)
11
12
 
12
13
  ## Gates
13
14
 
@@ -47,3 +48,18 @@ unit: <jsp:include page="([^"]+)"
47
48
 
48
49
  files: .*web\.xml
49
50
  unit: <servlet-class>[a-z.]*\.([A-Za-z0-9]+)</servlet-class>
51
+
52
+ ## Survey: jsp-form-action
53
+
54
+ files: .*\.jsp
55
+ unit: action="([^"]+)"
56
+
57
+ ## Survey: jsp-ajax-target
58
+
59
+ files: .*\.jsp
60
+ unit: url:\s*['"]([^'"?]+)
61
+
62
+ ## Consolidate
63
+
64
+ - cluster: jsp-form-action, jsp-ajax-target
65
+ - context: jsp-include
@@ -0,0 +1,10 @@
1
+ In a JSP portal a domain feature is the set of pages one servlet serves and
2
+ one ESB service pair backs: a list page with its detail or edit form, the
3
+ steps of one wizard (a session draft carried from step to step and a confirm
4
+ screen), a read-only screen with its ajax refresh. The shell — header, nav,
5
+ footer and any fragment every page includes — is one feature of its own,
6
+ planned first because every other feature renders inside it. Filler pages
7
+ with no form and no API call (help, profile, messages, settings) may be
8
+ folded into one "Portal shell and static pages" feature; say so in the
9
+ evidence. Never join two features that talk to different ESB services unless
10
+ one page posts to the other's servlet.
@@ -1,17 +1,25 @@
1
- Derive the conversion task list for ONE page from its Page Spec. The
2
- destination is an existing Next.js SPA with a design system, an AuthProvider,
3
- and a port/adapter service convention — imitate it, never fight it.
1
+ Derive the conversion task list for ONE domain feature from the Page Specs of
2
+ its programs. The destination is an existing Next.js SPA with a design system,
3
+ an AuthProvider, and a port/adapter service convention — imitate it, never
4
+ fight it. The feature's included fragments (header, nav, footer) are context:
5
+ use their specs for layout and navigation, do not re-implement them here
6
+ unless this feature IS the shell.
4
7
 
5
- - Task 1: the anti-corruption service layer — the typed port interface under
6
- src/services/<page>/port.ts matching the OpenAPI contract at
7
- contracts/<page>.openapi.yaml (domain names only), a mock adapter under
8
- src/services/<page>/mock.ts returning contract-shaped fixture data, and the
9
- registry wiring. No page code yet.
10
- - Task 2: the page component(s) under src/app/<page>/ using ONLY the
11
- destination design-system components and the port forms, validation with
12
- VERBATIM messages, navigation, and explicit state for anything the legacy
13
- app carried in the session or hidden fields.
14
- - Task 3: component tests under tests/<page>.page.test.tsx in the house test
15
- style spec'd fields render, spec'd validations fire with their exact
16
- messages, the port is called with contract-shaped payloads. Nothing else.
17
- - Each task names the spec rules and scenarios it covers.
8
+ - Task 1: the feature's anti-corruption service layer — the typed port
9
+ interface under src/services/<feature>/port.ts matching the OpenAPI contract
10
+ at contracts/<feature>.openapi.yaml (domain names only, one operation per
11
+ API call across all the feature's pages), a mock adapter under
12
+ src/services/<feature>/mock.ts returning contract-shaped fixture data, and
13
+ the registry wiring. No page code yet.
14
+ - Then ONE task per page of the feature, in navigation order (the page with no
15
+ inbound link inside the feature first; list before edit; step 1 before
16
+ step 2 before confirm): the page component(s) under src/app/<page>/ using
17
+ ONLY the destination design-system components and the feature port
18
+ forms, validation with VERBATIM messages, navigation, explicit state for
19
+ anything the legacy carried in the session or hidden fields — together with
20
+ its component tests under tests/<page>.page.test.tsx in the house test style:
21
+ spec'd fields render, spec'd validations fire with their exact messages, the
22
+ port is called with contract-shaped payloads. Nothing else.
23
+ - Each task names the programs, spec rules, and scenarios it covers. Scenarios
24
+ the decisions overlay marks drop, provided, or defer are out of scope: do
25
+ not plan them, and use the target capability a `provided` entry points at.
@@ -0,0 +1,16 @@
1
+ The target is an existing Next.js SPA with a design system, an AuthProvider, a
2
+ router, and a port/adapter service convention. Things such a target usually
3
+ PROVIDES, so a legacy page or scenario about them is a `provided` candidate
4
+ once you have found the file that proves it:
5
+
6
+ - login, logout, session timeout, and "remember me" — the AuthProvider and its
7
+ login route own these; a legacy login JSP is provided, not converted;
8
+ - navigation shell, header, footer, and the nav link set — the app layout;
9
+ - "back" links, breadcrumbs, and page titles — the router and layout;
10
+ - session-carried drafts between wizard steps — client state, not a session;
11
+ - client-side validation libraries — the house Form validation map.
12
+
13
+ Things commonly DEPRECATED in a JSP estate, `drop` candidates when the spec
14
+ itself shows the evidence (dead route, expired campaign, developer harness,
15
+ print-only view, applet or Flash embed, frameset): name the evidence, never
16
+ guess from the page name alone. Never propose `defer`.
@@ -49,3 +49,8 @@ unit: ^ {6}[ ]*COPY +([A-Z0-9]+)
49
49
 
50
50
  files: .*\.(jcl|JCL)
51
51
  unit: EXEC +PGM=([A-Z0-9]+)
52
+
53
+ ## Consolidate
54
+
55
+ - cluster: calls, exec-pgm
56
+ - context: copies
@@ -0,0 +1,8 @@
1
+ In a COBOL estate a domain feature is a job's worth of business behaviour: the
2
+ program a JCL step executes together with every program it CALLs and the
3
+ copybooks they share, or a set of programs that post to the same ledger
4
+ tables. Copybooks are context, never features of their own. Programs that
5
+ only compute (fee, interest, limit) and are CALLed from several jobs belong to
6
+ the feature that owns their business rule, not to every caller; say which in
7
+ the evidence. Never join two batch jobs that touch different ledgers unless
8
+ one executes the other.
@@ -0,0 +1,10 @@
1
+ The target is a Spring Boot service with JPA entities, a validation chain, and
2
+ JUnit 5 acceptance tests over the seeded feature files. Things such a target
3
+ usually PROVIDES, so a legacy program or scenario about them is a `provided`
4
+ candidate once you have found the file that proves it: audit rows written by
5
+ an aspect or entity listener, request logging, the transaction boundary a
6
+ COBOL program opened and closed by hand, date and time stamping, and sequence
7
+ allocation. Things commonly DEPRECATED in a COBOL estate, `drop` candidates
8
+ when the source shows the evidence (a job step nothing schedules, a program no
9
+ JCL executes and nothing CALLs, a report format the business retired): name
10
+ the evidence, never guess from the program name alone. Never propose `defer`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llm4ts/shell",
3
- "version": "2.1.0",
3
+ "version": "2.2.1",
4
4
  "description": "Interactive shell and CLI for llm4ts: flow discovery, run-a-flow, and view",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -23,6 +23,7 @@
23
23
  "./FlowCatalog": "./dist/FlowCatalog.js",
24
24
  "./FlowLaunch": "./dist/FlowLaunch.js",
25
25
  "./Menu": "./dist/Menu.js",
26
+ "./Refine": "./dist/Refine.js",
26
27
  "./Package": "./dist/Package.js",
27
28
  "./package.json": "./package.json"
28
29
  },
@@ -51,9 +52,9 @@
51
52
  "dependencies": {
52
53
  "@effect/platform-node": "4.0.0-rc.115",
53
54
  "@effect/platform-node-shared": "4.0.0-rc.115",
54
- "@llm4ts/flow": "2.1.0",
55
- "@llm4ts/runner": "2.1.0",
56
- "@llm4ts/core": "2.1.0"
55
+ "@llm4ts/flow": "2.2.1",
56
+ "@llm4ts/runner": "2.2.1",
57
+ "@llm4ts/core": "2.2.1"
57
58
  },
58
59
  "peerDependencies": {
59
60
  "effect": "4.0.0-rc.115"