@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.
Files changed (31) 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 +217 -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-refine.js +389 -0
  14. package/flows/modernize-seed.js +50 -2
  15. package/flows/modernize-verify.js +12 -5
  16. package/kits/j2ee-nextjs/README.md +5 -4
  17. package/kits/j2ee-nextjs/fixtures/demo-bank/RUNBOOK.md +43 -0
  18. package/kits/j2ee-nextjs/fixtures/demo-bank/legacy-j2ee/PAGES.md +26 -0
  19. package/kits/j2ee-nextjs/flows/convert-all.js +56 -20
  20. package/kits/j2ee-nextjs/flows/convert-feature.js +48 -0
  21. package/kits/j2ee-nextjs/flows/lib/convert.js +292 -40
  22. package/kits/j2ee-nextjs/packs/j2ee-nextjs-spa/pack.md +16 -0
  23. package/kits/j2ee-nextjs/packs/j2ee-nextjs-spa/prompts/consolidate.md +10 -0
  24. package/kits/j2ee-nextjs/packs/j2ee-nextjs-spa/prompts/plan.md +24 -16
  25. package/kits/j2ee-nextjs/packs/j2ee-nextjs-spa/prompts/refine-propose.md +16 -0
  26. package/kits/mainframe-java/packs/cobol-springboot/pack.md +5 -0
  27. package/kits/mainframe-java/packs/cobol-springboot/prompts/consolidate.md +8 -0
  28. package/kits/mainframe-java/packs/cobol-springboot/prompts/refine-propose.md +10 -0
  29. package/package.json +5 -4
  30. package/src/Cli.ts +57 -0
  31. package/src/Refine.ts +504 -0
package/src/Refine.ts ADDED
@@ -0,0 +1,504 @@
1
+ import { userInfo } from "node:os"
2
+ import * as Console from "effect/Console"
3
+ import * as Effect from "effect/Effect"
4
+ import { FileSystem } from "effect/FileSystem"
5
+ import * as Prompt from "effect/unstable/cli/Prompt"
6
+ import {
7
+ Decisions,
8
+ DeepenMark,
9
+ type Disposition,
10
+ ProgramDecision,
11
+ ProposalMark,
12
+ ScenarioDecision,
13
+ parseDecisions,
14
+ renderDecisions,
15
+ scenarioTitles
16
+ } from "@llm4ts/flow/Decisions"
17
+ import { Domains, parseDomains, renderDomains } from "@llm4ts/flow/Domains"
18
+ import { ApprovedMarker, DraftApprovalMarker } from "@llm4ts/flow/Approval"
19
+
20
+ /**
21
+ * The interactive front of `modernize-refine` (ADR 0015). The FILE is the
22
+ * state: every beat here reads `docs/modernization/decisions.md` and
23
+ * `domains.md`, edits them through the flow package's parse/render, and
24
+ * writes them back — then the engine flow runs as a child process exactly
25
+ * as `llm4ts run modernize-refine` would. Nothing done here is unreachable
26
+ * by editing the files by hand.
27
+ */
28
+
29
+ export const ModDir = "docs/modernization"
30
+
31
+ export interface PackInventory {
32
+ readonly programs: ReadonlyArray<string>
33
+ readonly scenarios: ReadonlyMap<string, ReadonlyArray<string>>
34
+ }
35
+
36
+ /** The extracted programs and their scenario titles, from the pack on disk. */
37
+ export const scanPack = Effect.fn("@llm4ts/shell/Refine.scanPack")(function* (modDir: string) {
38
+ const fs = yield* FileSystem
39
+ const specsDir = `${modDir}/specs`
40
+ const names = (yield* fs.readDirectory(specsDir).pipe(Effect.orElseSucceed(() => [])))
41
+ .filter((file) => file.endsWith(".md") && file !== "README.md")
42
+ .map((file) => file.slice(0, -".md".length))
43
+ .sort()
44
+ const scenarios = new Map<string, ReadonlyArray<string>>()
45
+ for (const name of names) {
46
+ const feature = yield* fs
47
+ .readFileString(`${modDir}/features/${name.toLowerCase()}.feature`)
48
+ .pipe(Effect.orElseSucceed(() => ""))
49
+ scenarios.set(name, scenarioTitles(feature))
50
+ }
51
+ return { programs: names, scenarios } satisfies PackInventory
52
+ })
53
+
54
+ export const loadDecisions = Effect.fn("@llm4ts/shell/Refine.loadDecisions")(function* (
55
+ modDir: string
56
+ ) {
57
+ const fs = yield* FileSystem
58
+ const text = yield* fs
59
+ .readFileString(`${modDir}/decisions.md`)
60
+ .pipe(Effect.orElseSucceed(() => undefined))
61
+ return text === undefined ? Decisions.empty() : yield* parseDecisions(text, "decisions.md")
62
+ })
63
+
64
+ export const loadDomains = Effect.fn("@llm4ts/shell/Refine.loadDomains")(function* (
65
+ modDir: string
66
+ ) {
67
+ const fs = yield* FileSystem
68
+ const text = yield* fs
69
+ .readFileString(`${modDir}/domains.md`)
70
+ .pipe(Effect.orElseSucceed(() => undefined))
71
+ return text === undefined ? undefined : yield* parseDomains(text, "domains.md")
72
+ })
73
+
74
+ export const saveDecisions = (modDir: string, decisions: Decisions) =>
75
+ Effect.flatMap(FileSystem, (fs) =>
76
+ fs.writeFileString(`${modDir}/decisions.md`, renderDecisions(decisions))
77
+ )
78
+
79
+ export const saveDomains = (modDir: string, domains: Domains) =>
80
+ Effect.flatMap(FileSystem, (fs) =>
81
+ fs.writeFileString(`${modDir}/domains.md`, renderDomains(domains))
82
+ )
83
+
84
+ /** `- [ ] Approved` → `- [x] Approved` in a markdown document; unchanged when already approved. */
85
+ export const approveMarkdown = (markdown: string): string =>
86
+ markdown.includes(ApprovedMarker)
87
+ ? markdown
88
+ : markdown.replace(DraftApprovalMarker, ApprovedMarker)
89
+
90
+ /** The decision signature: LLM4TS_APPROVER, else the OS user. */
91
+ export const signer = (environment: Readonly<Record<string, string | undefined>>): string => {
92
+ const approver = environment.LLM4TS_APPROVER?.trim()
93
+ if (approver !== undefined && approver.length > 0) {
94
+ return approver
95
+ }
96
+ try {
97
+ return userInfo().username
98
+ } catch {
99
+ return "operator"
100
+ }
101
+ }
102
+
103
+ const today = (): string => new Date().toISOString().slice(0, 10)
104
+
105
+ export interface RefineSessionOptions {
106
+ /** Absolute `docs/modernization` of the legacy repository. */
107
+ readonly modDir: string
108
+ /** Runs the engine flow; resolves to its exit code. */
109
+ readonly launch: (extraEnvironment: Readonly<Record<string, string>>) => Effect.Effect<number>
110
+ readonly environment: Readonly<Record<string, string | undefined>>
111
+ }
112
+
113
+ type Action =
114
+ | "programs"
115
+ | "scenarios"
116
+ | "deepen"
117
+ | "run"
118
+ | "answer"
119
+ | "regroup"
120
+ | "approve"
121
+ | "exit"
122
+
123
+ const dispositionChoices: ReadonlyArray<Prompt.SelectChoice<Disposition | "?">> = [
124
+ { title: "drop", value: "drop", description: "deprecated — will not exist in the target" },
125
+ {
126
+ title: "provided",
127
+ value: "provided",
128
+ description: "the target already has it or solves it differently"
129
+ },
130
+ { title: "defer", value: "defer", description: "still to migrate, not in this delivery" },
131
+ { title: "wrap", value: "wrap", description: "programs only: stays legacy behind an API" },
132
+ { title: "? ask the model", value: "?", description: "let the proposal decide, with your note" }
133
+ ]
134
+
135
+ const nonEmpty = (value: string): Effect.Effect<string, string> =>
136
+ value.trim().length === 0 ? Effect.fail("required") : Effect.succeed(value.trim())
137
+
138
+ interface Decided {
139
+ readonly disposition: Disposition
140
+ readonly reason: string
141
+ readonly pointer?: string
142
+ readonly milestone?: string
143
+ readonly decidedBy: string
144
+ readonly decidedAt: string
145
+ }
146
+
147
+ /** Asks for a disposition and what it needs; `undefined` means a `?` mark with the note. */
148
+ const askDisposition = Effect.fn("@llm4ts/shell/Refine.askDisposition")(function* (
149
+ key: string,
150
+ programLevel: boolean,
151
+ who: string
152
+ ) {
153
+ const disposition = yield* Prompt.run(
154
+ Prompt.Select<Disposition | "?">({
155
+ message: `${key} — disposition`,
156
+ choices: programLevel
157
+ ? dispositionChoices
158
+ : dispositionChoices.filter((choice) => choice.value !== "wrap")
159
+ })
160
+ )
161
+ if (disposition === "?") {
162
+ const note = yield* Prompt.run(
163
+ Prompt.String({ message: `${key} — note for the model (what makes you unsure)` })
164
+ )
165
+ return { mark: note.trim() }
166
+ }
167
+ const pointer =
168
+ disposition === "provided"
169
+ ? yield* Prompt.run(
170
+ Prompt.String({
171
+ message: `${key} — target path or capability that provides it`,
172
+ validate: nonEmpty
173
+ })
174
+ )
175
+ : undefined
176
+ const reason = yield* Prompt.run(
177
+ Prompt.String({
178
+ message: `${key} — why${disposition === "provided" ? " (note)" : ""}`,
179
+ ...(disposition === "provided" ? {} : { validate: nonEmpty })
180
+ })
181
+ )
182
+ const milestone =
183
+ disposition === "defer"
184
+ ? yield* Prompt.run(Prompt.String({ message: `${key} — milestone or wave (empty for none)` }))
185
+ : undefined
186
+ const decided: Decided = {
187
+ disposition,
188
+ reason: reason.trim(),
189
+ ...(pointer === undefined ? {} : { pointer: pointer.trim() }),
190
+ ...(milestone === undefined || milestone.trim().length === 0
191
+ ? {}
192
+ : { milestone: milestone.trim() }),
193
+ decidedBy: who,
194
+ decidedAt: today()
195
+ }
196
+ return { decided }
197
+ })
198
+
199
+ const markPrograms = Effect.fn("@llm4ts/shell/Refine.markPrograms")(function* (
200
+ options: RefineSessionOptions,
201
+ inventory: PackInventory,
202
+ decisions: Decisions
203
+ ) {
204
+ const open = inventory.programs.filter(
205
+ (name) =>
206
+ decisions.programDecision(name) === undefined &&
207
+ !decisions.marks.some((mark) => mark.program === name && mark.scenario === undefined)
208
+ )
209
+ if (open.length === 0) {
210
+ yield* Console.log("every program already has a decision or a mark")
211
+ return decisions
212
+ }
213
+ const chosen = yield* Prompt.run(
214
+ Prompt.MultiSelect<string>({
215
+ message: "Programs to mark (space selects, enter confirms)",
216
+ choices: open.map((name) => ({ title: name, value: name }))
217
+ })
218
+ )
219
+ const who = signer(options.environment)
220
+ let next = decisions
221
+ for (const name of chosen) {
222
+ const answer = yield* askDisposition(name, true, who)
223
+ next = Decisions.make({
224
+ ...next,
225
+ ...("decided" in answer
226
+ ? {
227
+ programs: [...next.programs, ProgramDecision.make({ program: name, ...answer.decided })]
228
+ }
229
+ : { marks: [...next.marks, ProposalMark.make({ program: name, note: answer.mark })] })
230
+ })
231
+ }
232
+ return next
233
+ })
234
+
235
+ const markScenarios = Effect.fn("@llm4ts/shell/Refine.markScenarios")(function* (
236
+ options: RefineSessionOptions,
237
+ inventory: PackInventory,
238
+ decisions: Decisions
239
+ ) {
240
+ const programs = inventory.programs.filter(
241
+ (name) => decisions.programDecision(name) === undefined
242
+ )
243
+ if (programs.length === 0) {
244
+ yield* Console.log("no program left whose scenarios can be marked")
245
+ return decisions
246
+ }
247
+ const program = yield* Prompt.run(
248
+ Prompt.Select<string>({
249
+ message: "Program",
250
+ choices: programs.map((name) => ({
251
+ title: name,
252
+ value: name,
253
+ description: `${inventory.scenarios.get(name)?.length ?? 0} scenario(s)`
254
+ }))
255
+ })
256
+ )
257
+ const disposed = decisions.disposedScenarios(program)
258
+ const open = (inventory.scenarios.get(program) ?? []).filter(
259
+ (title) =>
260
+ !disposed.has(title) &&
261
+ !decisions.marks.some((mark) => mark.program === program && mark.scenario === title)
262
+ )
263
+ if (open.length === 0) {
264
+ yield* Console.log(`every scenario of ${program} already has a decision or a mark`)
265
+ return decisions
266
+ }
267
+ const chosen = yield* Prompt.run(
268
+ Prompt.MultiSelect<string>({
269
+ message: `${program} — scenarios to mark`,
270
+ choices: open.map((title) => ({ title, value: title }))
271
+ })
272
+ )
273
+ const who = signer(options.environment)
274
+ let next = decisions
275
+ for (const title of chosen) {
276
+ const answer = yield* askDisposition(`${program} / ${title}`, false, who)
277
+ next = Decisions.make({
278
+ ...next,
279
+ ...("decided" in answer
280
+ ? {
281
+ scenarios: [
282
+ ...next.scenarios,
283
+ ScenarioDecision.make({ program, scenario: title, ...answer.decided })
284
+ ]
285
+ }
286
+ : {
287
+ marks: [
288
+ ...next.marks,
289
+ ProposalMark.make({ program, scenario: title, note: answer.mark })
290
+ ]
291
+ })
292
+ })
293
+ }
294
+ return next
295
+ })
296
+
297
+ const markDeepen = Effect.fn("@llm4ts/shell/Refine.markDeepen")(function* (
298
+ inventory: PackInventory,
299
+ decisions: Decisions
300
+ ) {
301
+ const program = yield* Prompt.run(
302
+ Prompt.Select<string>({
303
+ message: "Program to deepen",
304
+ choices: inventory.programs.map((name) => ({ title: name, value: name }))
305
+ })
306
+ )
307
+ const focus = yield* Prompt.run(
308
+ Prompt.String({
309
+ message: `${program} — what must the analyst look for (mandatory focus)`,
310
+ validate: nonEmpty
311
+ })
312
+ )
313
+ return Decisions.make({
314
+ ...decisions,
315
+ deepen: [...decisions.deepen, DeepenMark.make({ program, focus: focus.trim() })]
316
+ })
317
+ })
318
+
319
+ /** Walks every unanswered open point of both overlays; an empty answer leaves it open. */
320
+ const answerPoints = Effect.fn("@llm4ts/shell/Refine.answerPoints")(function* (
321
+ options: RefineSessionOptions
322
+ ) {
323
+ const decisions = yield* loadDecisions(options.modDir)
324
+ const domains = yield* loadDomains(options.modDir)
325
+ const pendingDecisions = decisions.unansweredOpenPoints
326
+ const pendingDomains = domains?.unansweredOpenPoints ?? []
327
+ if (pendingDecisions.length + pendingDomains.length === 0) {
328
+ yield* Console.log("no open points")
329
+ return false
330
+ }
331
+ let answered = 0
332
+ let nextDecisions = decisions
333
+ for (const point of pendingDecisions) {
334
+ const answer = yield* Prompt.run(
335
+ Prompt.String({ message: `decisions.md ${point.number}. ${point.question}` })
336
+ )
337
+ if (answer.trim().length > 0) {
338
+ answered += 1
339
+ nextDecisions = Decisions.make({
340
+ ...nextDecisions,
341
+ openPoints: nextDecisions.openPoints.map((candidate) =>
342
+ candidate.number === point.number ? { ...candidate, answer: answer.trim() } : candidate
343
+ )
344
+ })
345
+ }
346
+ }
347
+ if (nextDecisions !== decisions) {
348
+ yield* saveDecisions(options.modDir, nextDecisions)
349
+ }
350
+ if (domains !== undefined) {
351
+ let nextDomains = domains
352
+ for (const point of pendingDomains) {
353
+ const answer = yield* Prompt.run(
354
+ Prompt.String({ message: `domains.md ${point.number}. ${point.question}` })
355
+ )
356
+ if (answer.trim().length > 0) {
357
+ answered += 1
358
+ nextDomains = Domains.make({
359
+ ...nextDomains,
360
+ openPoints: nextDomains.openPoints.map((candidate) =>
361
+ candidate.number === point.number ? { ...candidate, answer: answer.trim() } : candidate
362
+ )
363
+ })
364
+ }
365
+ }
366
+ if (nextDomains !== domains) {
367
+ yield* saveDomains(options.modDir, nextDomains)
368
+ }
369
+ }
370
+ yield* Console.log(`${answered} answer(s) recorded — run modernize-refine to apply them`)
371
+ return answered > 0
372
+ })
373
+
374
+ const approveOverlays = Effect.fn("@llm4ts/shell/Refine.approveOverlays")(function* (
375
+ options: RefineSessionOptions
376
+ ) {
377
+ const fs = yield* FileSystem
378
+ const decisions = yield* loadDecisions(options.modDir)
379
+ const domains = yield* loadDomains(options.modDir)
380
+ const pending =
381
+ decisions.unansweredOpenPoints.length + (domains?.unansweredOpenPoints.length ?? 0)
382
+ if (pending > 0) {
383
+ yield* Console.log(`${pending} open point(s) are still unanswered — answer them first`)
384
+ return
385
+ }
386
+ const ok = yield* Prompt.run(
387
+ Prompt.Confirm({
388
+ message: "Approve the README, decisions.md, and domains.md as they are on disk?",
389
+ initial: false
390
+ })
391
+ )
392
+ if (!ok) {
393
+ return
394
+ }
395
+ for (const file of ["README.md", "decisions.md", "domains.md"]) {
396
+ const path = `${options.modDir}/${file}`
397
+ const text = yield* fs.readFileString(path).pipe(Effect.orElseSucceed(() => undefined))
398
+ if (text !== undefined) {
399
+ yield* fs.writeFileString(path, approveMarkdown(text))
400
+ }
401
+ }
402
+ yield* Console.log("approved — commit the pack and run the seed phase")
403
+ })
404
+
405
+ /**
406
+ * The interactive loop. Each beat writes the files and returns to the menu;
407
+ * Ctrl-C inside a prompt returns to the menu rather than crashing.
408
+ */
409
+ export const refineSession = Effect.fn("@llm4ts/shell/Refine.session")(function* (
410
+ options: RefineSessionOptions
411
+ ) {
412
+ const fs = yield* FileSystem
413
+ const inventory = yield* scanPack(options.modDir)
414
+ if (inventory.programs.length === 0) {
415
+ yield* Console.error(`no spec pack under ${options.modDir}/specs — run modernize-extract first`)
416
+ return
417
+ }
418
+ while (true) {
419
+ const decisions = yield* loadDecisions(options.modDir)
420
+ const domains = yield* loadDomains(options.modDir)
421
+ const pending =
422
+ decisions.unansweredOpenPoints.length + (domains?.unansweredOpenPoints.length ?? 0)
423
+ const summary =
424
+ `${inventory.programs.length} program(s) · ${decisions.programs.length} program and ` +
425
+ `${decisions.scenarios.length} scenario decision(s) · ${decisions.marks.length} mark(s) · ` +
426
+ `${decisions.pendingDeepen.length} deepen pending · ` +
427
+ `${domains === undefined ? "no domain map" : `${domains.features.length} domain feature(s)`} · ` +
428
+ `${pending} open point(s)`
429
+ const action = yield* Prompt.run(
430
+ Prompt.Select<Action>({
431
+ message: `refine — ${summary}`,
432
+ choices: [
433
+ { title: "Mark programs", value: "programs" },
434
+ { title: "Mark scenarios", value: "scenarios" },
435
+ { title: "Deepen a program", value: "deepen" },
436
+ { title: "Run modernize-refine", value: "run" },
437
+ { title: "Answer open points", value: "answer" },
438
+ { title: "Regroup (discard domains.md and rebuild it)", value: "regroup" },
439
+ { title: "Approve the overlays", value: "approve" },
440
+ { title: "Exit", value: "exit" }
441
+ ]
442
+ })
443
+ ).pipe(Effect.catchTag("QuitError", () => Effect.succeed<Action>("exit")))
444
+ const beat = Effect.gen(function* () {
445
+ switch (action) {
446
+ case "programs": {
447
+ yield* saveDecisions(options.modDir, yield* markPrograms(options, inventory, decisions))
448
+ return
449
+ }
450
+ case "scenarios": {
451
+ yield* saveDecisions(options.modDir, yield* markScenarios(options, inventory, decisions))
452
+ return
453
+ }
454
+ case "deepen": {
455
+ yield* saveDecisions(options.modDir, yield* markDeepen(inventory, decisions))
456
+ return
457
+ }
458
+ case "run":
459
+ case "regroup": {
460
+ if (action === "regroup") {
461
+ const ok = yield* Prompt.run(
462
+ Prompt.Confirm({
463
+ message: "Discard domains.md and regroup from the current specs and decisions?",
464
+ initial: false
465
+ })
466
+ )
467
+ if (!ok) {
468
+ return
469
+ }
470
+ }
471
+ if (decisions.isEmpty && !(yield* fs.exists(`${options.modDir}/decisions.md`))) {
472
+ // An empty overlay still lands with its guide, so the next hand-edit has the vocabulary.
473
+ yield* saveDecisions(options.modDir, decisions)
474
+ }
475
+ const code = yield* options.launch(action === "regroup" ? { LLM4TS_REGROUP: "1" } : {})
476
+ if (code !== 0) {
477
+ yield* Console.log(
478
+ `modernize-refine exited with code ${code} — answer any open points it listed, or fix the files`
479
+ )
480
+ }
481
+ return
482
+ }
483
+ case "answer": {
484
+ yield* answerPoints(options)
485
+ return
486
+ }
487
+ case "approve": {
488
+ yield* approveOverlays(options)
489
+ return
490
+ }
491
+ case "exit": {
492
+ return
493
+ }
494
+ }
495
+ })
496
+ if (action === "exit") {
497
+ return
498
+ }
499
+ yield* beat.pipe(
500
+ Effect.catchTag("QuitError", () => Effect.void),
501
+ Effect.catch((error) => Console.error(error instanceof Error ? error.message : String(error)))
502
+ )
503
+ }
504
+ })