@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.
- 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 +226 -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-pack-upgrade.js +246 -0
- 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/Cli.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { homedir } from "node:os"
|
|
2
|
+
import { join, resolve } from "node:path"
|
|
2
3
|
import { fileURLToPath } from "node:url"
|
|
3
4
|
import * as Console from "effect/Console"
|
|
4
5
|
import * as Effect from "effect/Effect"
|
|
@@ -18,6 +19,7 @@ import {
|
|
|
18
19
|
} from "./FlowCatalog.ts"
|
|
19
20
|
import { launchFlow } from "./FlowLaunch.ts"
|
|
20
21
|
import { mainMenu } from "./Menu.ts"
|
|
22
|
+
import { ModDir, refineSession } from "./Refine.ts"
|
|
21
23
|
import { packageVersion } from "./Package.ts"
|
|
22
24
|
|
|
23
25
|
export class ShellUsageError extends Schema.TaggedError<ShellUsageError>()("ShellUsage", {
|
|
@@ -295,6 +297,60 @@ const askCommand = Command.make(
|
|
|
295
297
|
})
|
|
296
298
|
).pipe(Command.withDescription("Stream a one-shot prompt to the selected coding agent"))
|
|
297
299
|
|
|
300
|
+
const refineCommand = Command.make(
|
|
301
|
+
"refine",
|
|
302
|
+
{
|
|
303
|
+
repo: Flag.String("repo").pipe(
|
|
304
|
+
Flag.optional,
|
|
305
|
+
Flag.withDescription(
|
|
306
|
+
"The LEGACY repository holding the extracted spec pack (defaults to the current directory)"
|
|
307
|
+
)
|
|
308
|
+
),
|
|
309
|
+
pack: Flag.String("pack").pipe(
|
|
310
|
+
Flag.optional,
|
|
311
|
+
Flag.withDescription("Pack forwarded as LLM4TS_PACK, as for `llm4ts run`")
|
|
312
|
+
),
|
|
313
|
+
target: Flag.String("target").pipe(
|
|
314
|
+
Flag.optional,
|
|
315
|
+
Flag.withDescription(
|
|
316
|
+
"The target repository, mounted read-only so the proposal can claim what it already provides (LLM4TS_TARGET_REPO)"
|
|
317
|
+
)
|
|
318
|
+
)
|
|
319
|
+
},
|
|
320
|
+
(config) =>
|
|
321
|
+
Effect.gen(function* () {
|
|
322
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
323
|
+
return yield* new ShellUsageError({
|
|
324
|
+
message:
|
|
325
|
+
"refine is interactive; off a terminal edit docs/modernization/decisions.md and run `llm4ts run modernize-refine`"
|
|
326
|
+
})
|
|
327
|
+
}
|
|
328
|
+
const repo = resolve(config.repo._tag === "Some" ? config.repo.value : process.cwd())
|
|
329
|
+
const flowPath = yield* resolveFlow("modernize-refine", shellTierPaths())
|
|
330
|
+
const environment: Record<string, string | undefined> = { ...process.env }
|
|
331
|
+
if (config.pack._tag === "Some") {
|
|
332
|
+
environment.LLM4TS_PACK = config.pack.value
|
|
333
|
+
}
|
|
334
|
+
if (config.target._tag === "Some") {
|
|
335
|
+
environment.LLM4TS_TARGET_REPO = resolve(config.target.value)
|
|
336
|
+
}
|
|
337
|
+
yield* refineSession({
|
|
338
|
+
modDir: join(repo, ModDir),
|
|
339
|
+
environment,
|
|
340
|
+
launch: (extra) =>
|
|
341
|
+
launchFlow({
|
|
342
|
+
flowPath,
|
|
343
|
+
taskArgs: ["--repo", repo],
|
|
344
|
+
environment: { ...environment, ...extra }
|
|
345
|
+
}).pipe(Effect.catch((error) => Console.error(error.message).pipe(Effect.as(1))))
|
|
346
|
+
})
|
|
347
|
+
})
|
|
348
|
+
).pipe(
|
|
349
|
+
Command.withDescription(
|
|
350
|
+
"Interactively mark, deepen, and consolidate an extracted spec pack, then run modernize-refine on it (ADR 0015)"
|
|
351
|
+
)
|
|
352
|
+
)
|
|
353
|
+
|
|
298
354
|
const doctorCommand = Command.make("doctor", {}, () =>
|
|
299
355
|
Effect.gen(function* () {
|
|
300
356
|
const report = yield* makeDoctorProgram()
|
|
@@ -321,6 +377,7 @@ export const shellCommand = Command.make("llm4ts", {}, () =>
|
|
|
321
377
|
kitsCommand,
|
|
322
378
|
viewCommand,
|
|
323
379
|
askCommand,
|
|
380
|
+
refineCommand,
|
|
324
381
|
doctorCommand
|
|
325
382
|
])
|
|
326
383
|
)
|
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
|
+
})
|