@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
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
|
+
})
|