@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
@@ -0,0 +1,246 @@
1
+ // Continue a modernization an OLDER llm4ts extracted: check its spec pack against the current pack rules and spec schema, re-index it, and mark what must be re-extracted (no LLM).
2
+ //
3
+ // Runs rooted at the LEGACY repository (`--repo <legacy>`) holding a
4
+ // docs/modernization/ pack written by any earlier release (the README of a
5
+ // pack older than 2.2.0 carries no `Written by llm4ts` stamp). Deterministic:
6
+ //
7
+ // 1. Every program with a spec is checked for its four artifacts, the
8
+ // feature file's Gherkin shape, and — when the pack declares
9
+ // `spec-schema:` — a decodable pagespec block under the CURRENT schema
10
+ // (2.0.0 made `esbService` identifier-only, which fails older blocks).
11
+ // 2. traceability.md, mapping.md, and rules.txt are regenerated from the
12
+ // fragments under the current pack's coverage rules; units the current
13
+ // rules capture that no fragment covers are reported (the closing
14
+ // modernize-extract run, or a deepen, closes them).
15
+ // 3. The README is rewritten with the current version stamp and an
16
+ // upgrade note, its approval reset: a pack another release touched is
17
+ // re-approved by a human before seed.
18
+ // 4. With LLM4TS_MARK_DEEPEN=1 every incompatible program gets a `## Deepen`
19
+ // mark in decisions.md ("regenerate the artifacts under the current
20
+ // schema …"), so `modernize-refine` re-extracts exactly those with the
21
+ // current prompts and judge, one commit each. Without it the marks are
22
+ // printed for a human to paste.
23
+ //
24
+ // Exit 0 whether or not findings exist — the findings ARE the result; the
25
+ // commit records them. Pack: LLM4TS_PACK as for modernize-extract.
26
+ import { join } from "node:path";
27
+ import * as Effect from "effect/Effect";
28
+ import { ProgramUnit, programArtifactPaths } from "@llm4ts/flow/Artifacts";
29
+ import { withDraftApproval } from "@llm4ts/flow/Approval";
30
+ import { DeepenMark, Decisions, parseDecisions, renderDecisions, scenarioTitles, waivedUnits } from "@llm4ts/flow/Decisions";
31
+ import { packageVersion } from "@llm4ts/flow/Package";
32
+ import { coverageReport, coverageUnits, features, matchingFiles, specSchemaIssues } from "@llm4ts/flow/SpecChecks";
33
+ import { legacySourceWorkspaceLimits, workspaceLimitsFromEnv } from "@llm4ts/flow/Workspace";
34
+ import { FlowAborted, Info, makeNodeWorkspace, mock, nodePlainFileStore, openPack, resolveFlowInput, runFlowMain, runNode, stage } from "@llm4ts/runner";
35
+ import { ModDir, programName, readmeFor, readmeVersion } from "./lib/modernize-extract.js";
36
+ const program = Effect.gen(function* () {
37
+ const input = yield* resolveFlowInput("Check a spec pack an older llm4ts extracted against the current release");
38
+ const files = nodePlainFileStore;
39
+ const modDirAbs = join(input.workDir, ModDir);
40
+ const markDeepen = process.env.LLM4TS_MARK_DEEPEN?.trim() === "1";
41
+ yield* runNode({
42
+ workDir: input.workDir,
43
+ workspace: input.workspace,
44
+ userPrompt: input.prompt,
45
+ // No model call: the mock seat satisfies the one context shape the
46
+ // runner composes for every flow.
47
+ coder: mock,
48
+ environment: process.env
49
+ }, (context) => Effect.gen(function* () {
50
+ const say = (message) => context.events.publish(Info.make({ message }));
51
+ const repo = yield* makeNodeWorkspace(input.workDir, workspaceLimitsFromEnv(process.env, legacySourceWorkspaceLimits));
52
+ const opened = yield* stage(context.events, "pack", openPack({
53
+ environment: process.env,
54
+ launchDir: input.workspace,
55
+ flowDir: import.meta.dirname
56
+ }));
57
+ const pack = opened.pack;
58
+ const readme = yield* files.read(join(modDirAbs, "README.md"));
59
+ if (readme === undefined) {
60
+ return yield* FlowAborted.make({
61
+ message: `no spec pack under ${ModDir} — nothing to upgrade`
62
+ });
63
+ }
64
+ const previous = readmeVersion(readme) ?? "an llm4ts older than 2.2.0 (no version stamp)";
65
+ yield* say(`spec pack written by ${previous}; checking it as llm4ts ${packageVersion}`);
66
+ const specPaths = yield* repo
67
+ .discover(`${ModDir}/specs/*.md`)
68
+ .pipe(Effect.orElseSucceed(() => []));
69
+ const names = [...specPaths]
70
+ .map((path) => path.split("/").at(-1) ?? path)
71
+ .filter((file) => file.endsWith(".md") && file !== "README.md")
72
+ .map((file) => file.slice(0, -".md".length))
73
+ .sort();
74
+ if (names.length === 0) {
75
+ return yield* FlowAborted.make({ message: `no specs under ${ModDir}/specs` });
76
+ }
77
+ const sources = yield* matchingFiles(repo, pack.programs ?? pack.sources ?? ".*", pack.exclude);
78
+ const sourceOf = new Map(sources.map((path) => [programName(path), path]));
79
+ const units = names.map((name) => ProgramUnit.make({ name, sourcePath: sourceOf.get(name) ?? "" }));
80
+ // ---- 1. Per-program artifacts under the current rules ----------------------
81
+ const findings = [];
82
+ const fragments = new Map();
83
+ const scenarios = new Map();
84
+ yield* stage(context.events, "check", Effect.gen(function* () {
85
+ const specs = [];
86
+ for (const unit of units) {
87
+ const [specPath, featurePath, tracePath, mappingPath] = programArtifactPaths(unit, modDirAbs);
88
+ const spec = yield* files.read(specPath);
89
+ specs.push({ name: unit.name, markdown: spec });
90
+ if (unit.sourcePath.length === 0) {
91
+ findings.push({
92
+ program: unit.name,
93
+ problem: "no legacy source matches the current pack's programs regex",
94
+ deepen: false
95
+ });
96
+ }
97
+ const feature = yield* files.read(featurePath);
98
+ if (feature === undefined) {
99
+ findings.push({ program: unit.name, problem: "feature file missing", deepen: true });
100
+ }
101
+ else {
102
+ scenarios.set(unit.name, new Set(scenarioTitles(feature)));
103
+ }
104
+ const trace = yield* files.read(tracePath);
105
+ if (trace === undefined || trace.trim().length === 0) {
106
+ findings.push({
107
+ program: unit.name,
108
+ problem: "traceability fragment missing",
109
+ deepen: true
110
+ });
111
+ }
112
+ else {
113
+ fragments.set(unit.name, trace);
114
+ }
115
+ if ((yield* files.read(mappingPath)) === undefined) {
116
+ findings.push({
117
+ program: unit.name,
118
+ problem: "mapping fragment missing",
119
+ deepen: true
120
+ });
121
+ }
122
+ }
123
+ for (const issue of yield* specSchemaIssues(pack.specSchema, specs)) {
124
+ const name = /^judge\[([^\]]+)\]/.exec(issue.title)?.[1] ?? "?";
125
+ findings.push({
126
+ program: name,
127
+ problem: `pagespec block does not decode under the current schema: ${issue.description.split(". ")[0] ?? ""}`,
128
+ deepen: true
129
+ });
130
+ }
131
+ const wellFormed = yield* features(repo, join(ModDir, "features"));
132
+ for (const issue of wellFormed.issues) {
133
+ const stem = (issue.file?.split("/").at(-1) ?? "").replace(/\.feature$/, "");
134
+ const name = names.find((candidate) => candidate.toLowerCase() === stem) ?? stem;
135
+ findings.push({ program: name, problem: issue.description, deepen: true });
136
+ }
137
+ }));
138
+ // ---- 2. Indexes and rules.txt under the current pack --------------------------
139
+ let uncovered = [];
140
+ yield* stage(context.events, "reindex", Effect.gen(function* () {
141
+ for (const [fragmentDir, index] of [
142
+ ["traceability", "traceability.md"],
143
+ ["mapping", "mapping.md"]
144
+ ]) {
145
+ const parts = [];
146
+ for (const unit of units) {
147
+ const text = yield* files.read(join(modDirAbs, fragmentDir, `${unit.name}.md`));
148
+ if (text !== undefined && text.trim().length > 0) {
149
+ parts.push(`===== ${unit.name} =====\n${text.trimEnd()}`);
150
+ }
151
+ }
152
+ if (parts.length > 0) {
153
+ yield* files.writeAtomic(join(modDirAbs, index), parts.join("\n\n") + "\n");
154
+ }
155
+ }
156
+ const decisionsText = yield* files.read(join(modDirAbs, "decisions.md"));
157
+ const decisions = decisionsText === undefined
158
+ ? Decisions.empty()
159
+ : yield* parseDecisions(decisionsText, `${ModDir}/decisions.md`);
160
+ const unitsByRule = yield* coverageUnits(repo, pack.coverage);
161
+ const allUnits = [...new Set(Object.values(unitsByRule).flat())].sort();
162
+ const waived = waivedUnits(decisions, { fragments, scenarios });
163
+ if (allUnits.length > 0) {
164
+ yield* files.writeAtomic(join(modDirAbs, "rules.txt"), [
165
+ ...allUnits,
166
+ ...(waived.length === 0
167
+ ? []
168
+ : [
169
+ "# waived",
170
+ ...waived.map((entry) => `${entry.unit} — waived by ${entry.by}`)
171
+ ])
172
+ ].join("\n") + "\n");
173
+ }
174
+ const trace = (yield* files.read(join(modDirAbs, "traceability.md"))) ?? "";
175
+ const report = yield* coverageReport(repo, pack.coverage, trace, {
176
+ waived: new Set(waived.map((entry) => entry.unit))
177
+ });
178
+ uncovered = report.result.issues.map((issue) => issue.title);
179
+ }));
180
+ // ---- 3. README stamp, 4. deepen marks -------------------------------------------
181
+ const toDeepen = [...new Set(findings.filter((f) => f.deepen).map((f) => f.program))];
182
+ const markLines = toDeepen.map((name) => `- ${name}: regenerate the artifacts under the current llm4ts ${packageVersion} schema and prompts — ` +
183
+ findings
184
+ .filter((f) => f.program === name && f.deepen)
185
+ .map((f) => f.problem)
186
+ .join("; "));
187
+ if (markDeepen && toDeepen.length > 0) {
188
+ yield* stage(context.events, "mark", Effect.gen(function* () {
189
+ const text = yield* files.read(join(modDirAbs, "decisions.md"));
190
+ const decisions = text === undefined
191
+ ? Decisions.empty()
192
+ : yield* parseDecisions(text, `${ModDir}/decisions.md`);
193
+ const already = new Set(decisions.pendingDeepen.map((mark) => mark.program));
194
+ const added = toDeepen
195
+ .filter((name) => !already.has(name))
196
+ .map((name) => DeepenMark.make({
197
+ program: name,
198
+ focus: markLines
199
+ .find((line) => line.startsWith(`- ${name}: `))
200
+ ?.slice(name.length + 4) ?? ""
201
+ }));
202
+ yield* files.writeAtomic(join(modDirAbs, "decisions.md"), renderDecisions(Decisions.make({
203
+ ...decisions,
204
+ deepen: [...decisions.deepen, ...added],
205
+ approved: false
206
+ })));
207
+ yield* say(`${added.length} deepen mark(s) written to ${ModDir}/decisions.md`);
208
+ }));
209
+ }
210
+ const verdict = /Gate verdict: (.+?)\.\n/.exec(readme)?.[1] ?? "UNKNOWN — extracted by an older release";
211
+ const priorNotes = (/Refined after the gate passed:\n((?:- .*\n)+)/.exec(readme)?.[1] ?? "")
212
+ .split("\n")
213
+ .filter((line) => line.startsWith("- "))
214
+ .map((line) => line.slice(2));
215
+ const note = `upgraded from ${previous} to llm4ts ${packageVersion}: ${findings.length} finding(s), ` +
216
+ `${uncovered.length} uncovered unit(s) under the current rules` +
217
+ (markDeepen && toDeepen.length > 0
218
+ ? `, ${toDeepen.length} program(s) marked for deepen`
219
+ : "");
220
+ yield* files.writeAtomic(join(modDirAbs, "README.md"), withDraftApproval(readmeFor(pack, verdict, [...priorNotes, note])));
221
+ yield* stage(context.events, "commit", context.git
222
+ .commitAll(`modernize(${pack.name}): pack upgrade check as llm4ts ${packageVersion}`)
223
+ .pipe(Effect.asVoid));
224
+ // ---- Report -------------------------------------------------------------------
225
+ for (const finding of findings) {
226
+ yield* say(`finding: ${finding.program} — ${finding.problem}`);
227
+ }
228
+ for (const title of uncovered.slice(0, 20)) {
229
+ yield* say(`uncovered under the current rules: ${title}`);
230
+ }
231
+ if (uncovered.length > 20) {
232
+ yield* say(`… and ${uncovered.length - 20} more uncovered unit(s)`);
233
+ }
234
+ if (toDeepen.length > 0 && !markDeepen) {
235
+ yield* say(`${toDeepen.length} program(s) need re-extraction — rerun with LLM4TS_MARK_DEEPEN=1, or add under '## Deepen' in ${ModDir}/decisions.md:\n` +
236
+ markLines.join("\n"));
237
+ }
238
+ yield* say(findings.length === 0 && uncovered.length === 0
239
+ ? `pack is compatible with llm4ts ${packageVersion} — review ${ModDir}/README.md, flip '- [x] Approved', then continue with modernize-refine or modernize-seed`
240
+ : `upgrade check done — ${findings.length} finding(s); ` +
241
+ (markDeepen && toDeepen.length > 0
242
+ ? "run modernize-refine to re-extract the marked programs"
243
+ : "mark the programs to re-extract, then run modernize-refine"));
244
+ }));
245
+ });
246
+ runFlowMain(program);
@@ -0,0 +1,389 @@
1
+ // Legacy modernization phase 1.5 (optional): refine the extracted spec pack — prune, deepen, consolidate — before it is approved and seeded (ADR 0015).
2
+ //
3
+ // Runs rooted at the LEGACY repository (`--repo <legacy>`), after
4
+ // modernize-extract wrote its pack and before a human flips the README's
5
+ // approval. The FILE is the state, never the conversation: everything this
6
+ // flow does is driven by two overlays under docs/modernization/ that a
7
+ // human edits (or the `llm4ts refine` shell verb writes for them):
8
+ //
9
+ // decisions.md — what the pack should become: `drop`, `provided`, `defer`,
10
+ // `wrap` per program or scenario; `?` marks asking the
11
+ // model to propose; `## Deepen` marks sending the analyst
12
+ // back to the source with a focus; `## Open points` the
13
+ // questions a proposal could not settle.
14
+ // domains.md — the domain features: pages grouped by the pack's
15
+ // `## Consolidate` edge rules, named by the model, every
16
+ // surviving scenario assigned exactly once.
17
+ //
18
+ // One run, in order: validate the overlays → execute pending deepen marks
19
+ // (re-extract one program with its focus, judge, one fix turn, own commit)
20
+ // → propose dispositions for the `?` marks (an agent session on the
21
+ // read-only target at LLM4TS_TARGET_REPO; without it `provided` is never
22
+ // proposed) → consolidate when domains.md is absent or stale (LLM4TS_REGROUP=1
23
+ // forces it) → regenerate plan.md per domain feature → rewrite rules.txt with
24
+ // its `# waived` section → reset the README approval → commit. It halts with
25
+ // a typed OpenPointsPending when either overlay has unanswered questions;
26
+ // answer them in the file and rerun. Nothing marked and a fresh map is a
27
+ // no-op. Budgets: LLM4TS_ANALYST_TURNS, LLM4TS_MAX_CLOSURE_FILES,
28
+ // LLM4TS_CONTEXT_BUDGET. Pack: LLM4TS_PACK as for modernize-extract.
29
+ import { existsSync } from "node:fs";
30
+ import { join } from "node:path";
31
+ import * as Effect from "effect/Effect";
32
+ import { ProgramArtifacts, ProgramUnit, extractProgramsResumably, programArtifactPaths } from "@llm4ts/flow/Artifacts";
33
+ import { budget, capped } from "@llm4ts/flow/Context";
34
+ import { Decisions, DecisionsInvalid, DecisionsProposal, OpenPointsPending, applyProposal, decisionsProposalJsonSchema, parseDecisions, proposePrompt, renderDecisions, scenarioTitles, validateDecisions, waivedUnits } from "@llm4ts/flow/Decisions";
35
+ import { DomainProposal, Domains, checkExactlyOnce, clusterPrograms, consolidatePrompt, domainProposalJsonSchema, domainsFromProposal, domainsInputsHash, parseDomains, renderDomains } from "@llm4ts/flow/Domains";
36
+ import { structuredAndPublish } from "@llm4ts/flow/Flow";
37
+ import { FlowEvents } from "@llm4ts/flow/FlowEvents";
38
+ import { Task } from "@llm4ts/flow/Plan";
39
+ import { coverageUnits, matchingFiles } from "@llm4ts/flow/SpecChecks";
40
+ import { SurveyGraph, closureFor, surveyGraph } from "@llm4ts/flow/Survey";
41
+ import { withDraftApproval } from "@llm4ts/flow/Approval";
42
+ import { legacySourceWorkspaceLimits, workspaceLimitsFromEnv } from "@llm4ts/flow/Workspace";
43
+ import { FlowAborted, Info, Plan, asReadOnly, coderFromEnv, defaultPlanInstructions, makeNodeWorkspace, nodePlainFileStore, openPack, planFrom, resolveFlowInput, runFlowMain, runNode, stage, withTurnLimit } from "@llm4ts/runner";
44
+ import { ModDir, analystSystem, analystTurns, fixTurn, makeProgramJudge, maxClosureFiles, programArtifactsJsonSchema, programAsk, programFixAsk, programName, readmeFor } from "./lib/modernize-extract.js";
45
+ const today = () => new Date().toISOString().slice(0, 10);
46
+ const program = Effect.gen(function* () {
47
+ const input = yield* resolveFlowInput("Refine the extracted spec pack: prune, deepen, and consolidate it before approval");
48
+ const coder = withTurnLimit(coderFromEnv(process.env), analystTurns());
49
+ const files = nodePlainFileStore;
50
+ const modDirAbs = join(input.workDir, ModDir);
51
+ const targetRepo = process.env.LLM4TS_TARGET_REPO?.trim();
52
+ const regroup = process.env.LLM4TS_REGROUP?.trim() === "1";
53
+ yield* runNode({
54
+ workDir: input.workDir,
55
+ workspace: input.workspace,
56
+ userPrompt: input.prompt,
57
+ coder,
58
+ reasoning: asReadOnly(coder),
59
+ environment: process.env
60
+ }, (context) => Effect.gen(function* () {
61
+ const repo = yield* makeNodeWorkspace(input.workDir, workspaceLimitsFromEnv(process.env, legacySourceWorkspaceLimits));
62
+ const opened = yield* stage(context.events, "pack", openPack({
63
+ environment: process.env,
64
+ launchDir: input.workspace,
65
+ flowDir: import.meta.dirname
66
+ }));
67
+ const pack = opened.pack;
68
+ const say = (message) => context.events.publish(Info.make({ message }));
69
+ const notes = [];
70
+ // ---- The pack on disk: its programs, sources, and scenario titles ----
71
+ const specPaths = yield* repo
72
+ .discover(`${ModDir}/specs/*.md`)
73
+ .pipe(Effect.orElseSucceed(() => []));
74
+ const names = [...specPaths]
75
+ .map((path) => path.split("/").at(-1) ?? path)
76
+ .filter((file) => file.endsWith(".md") && file !== "README.md")
77
+ .map((file) => file.slice(0, -".md".length))
78
+ .sort();
79
+ if (names.length === 0) {
80
+ return yield* FlowAborted.make({
81
+ message: `no spec pack under ${ModDir}/specs — run modernize-extract first`
82
+ });
83
+ }
84
+ const sources = yield* matchingFiles(repo, pack.programs ?? pack.sources ?? ".*", pack.exclude);
85
+ const sourceOf = new Map(sources.map((path) => [programName(path), path]));
86
+ const units = names.map((name) => ProgramUnit.make({ name, sourcePath: sourceOf.get(name) ?? "" }));
87
+ const readPack = Effect.gen(function* () {
88
+ const specs = {};
89
+ const features = {};
90
+ const scenarios = new Map();
91
+ for (const name of names) {
92
+ specs[name] = (yield* files.read(join(modDirAbs, "specs", `${name}.md`))) ?? "";
93
+ const feature = (yield* files.read(join(modDirAbs, "features", `${name.toLowerCase()}.feature`))) ??
94
+ "";
95
+ features[name] = feature;
96
+ scenarios.set(name, new Set(scenarioTitles(feature)));
97
+ }
98
+ const known = { programs: new Set(names), scenarios };
99
+ return { specs, features, scenarios, known };
100
+ });
101
+ let packState = yield* readPack;
102
+ // ---- decisions.md ------------------------------------------------------
103
+ const decisionsPath = join(modDirAbs, "decisions.md");
104
+ const decisionsText = yield* files.read(decisionsPath);
105
+ let decisions = decisionsText === undefined
106
+ ? Decisions.empty()
107
+ : yield* parseDecisions(decisionsText, `${ModDir}/decisions.md`);
108
+ const violations = validateDecisions(decisions, packState.known);
109
+ if (violations.length > 0) {
110
+ return yield* DecisionsInvalid.make({ path: `${ModDir}/decisions.md`, violations });
111
+ }
112
+ const writeDecisions = Effect.gen(function* () {
113
+ yield* files.writeAtomic(decisionsPath, renderDecisions(decisions));
114
+ });
115
+ const graph = yield* stage(context.events, "graph", pack.survey.length === 0
116
+ ? Effect.succeed(SurveyGraph.make({ nodes: [], edges: [] }))
117
+ : surveyGraph(repo, pack.sources ?? ".*", pack.coverage, pack.survey, {
118
+ ...(pack.exclude === undefined ? {} : { exclude: pack.exclude })
119
+ }));
120
+ const system = analystSystem(pack);
121
+ const limit = budget();
122
+ const judgeProgram = makeProgramJudge({
123
+ context,
124
+ files,
125
+ pack,
126
+ modDirAbs,
127
+ workDir: input.workDir,
128
+ limit
129
+ });
130
+ // ---- Deepen: re-extract one program with its focus ----------------------
131
+ const pending = decisions.pendingDeepen;
132
+ if (pending.length > 0) {
133
+ yield* stage(context.events, "deepen", Effect.gen(function* () {
134
+ for (const mark of pending) {
135
+ const unit = units.find((candidate) => candidate.name === mark.program);
136
+ if (unit === undefined || unit.sourcePath.length === 0) {
137
+ return yield* FlowAborted.make({
138
+ message: `deepen: no legacy source found for program '${mark.program}'`
139
+ });
140
+ }
141
+ const [specPath, featurePath, tracePath, mappingPath] = programArtifactPaths(unit, modDirAbs);
142
+ const previous = ProgramArtifacts.make({
143
+ spec: (yield* files.read(specPath)) ?? "",
144
+ feature: (yield* files.read(featurePath)) ?? "",
145
+ traceability: (yield* files.read(tracePath)) ?? "",
146
+ mapping: (yield* files.read(mappingPath)) ?? ""
147
+ });
148
+ yield* say(`deepening ${unit.name} — ${mark.focus}`);
149
+ // Removing the spec is what makes the resumable seam re-extract it.
150
+ yield* files.remove(specPath);
151
+ yield* extractProgramsResumably(files, [unit], (target) => structuredAndPublish(context.coder, context.events, `${system}\n\n${programAsk(pack, target.sourcePath, closureFor(graph, target.name, maxClosureFiles()), { previous, focus: mark.focus })}`, ProgramArtifacts, programArtifactsJsonSchema, "coder"), modDirAbs, {
152
+ onCreated: (created) => context.git
153
+ .commitPaths(`modernize(${pack.name}): deepen ${created.name}`, programArtifactPaths(created, ModDir))
154
+ .pipe(Effect.asVoid)
155
+ });
156
+ const focusRubric = `deepen-focus (0..2): The revision addresses this focus explicitly and grounds it in ` +
157
+ `the source: ${mark.focus}. Score 2 only if the focus is fully answered.`;
158
+ let verdict = yield* judgeProgram(unit, focusRubric);
159
+ if (verdict.issues.length > 0) {
160
+ yield* say(`fixing ${unit.name} — ${verdict.issues.length} finding(s)`);
161
+ yield* fixTurn(context, system, programFixAsk(unit.name, unit.sourcePath, verdict.issues), `modernize(${pack.name}): deepen fixes ${unit.name}`);
162
+ verdict = yield* judgeProgram(unit, focusRubric);
163
+ if (verdict.issues.length > 0) {
164
+ yield* say(`${unit.name} still has ${verdict.issues.length} finding(s) after its fix turn — recorded as open points`);
165
+ }
166
+ }
167
+ const hash = (yield* context.git.checkpoint).slice(0, 7);
168
+ decisions = Decisions.make({
169
+ ...decisions,
170
+ deepen: decisions.deepen.map((entry) => entry === mark ? { ...entry, done: hash } : entry),
171
+ openPoints: [
172
+ ...decisions.openPoints,
173
+ ...verdict.issues.map((issue, index) => ({
174
+ number: decisions.openPoints.length + index + 1,
175
+ question: `After deepening ${unit.name}: ${issue.title} — ${issue.description}`
176
+ }))
177
+ ]
178
+ });
179
+ yield* writeDecisions;
180
+ notes.push(`deepened ${unit.name} (${hash}): ${mark.focus}`);
181
+ }
182
+ // Titles may have moved: a reference that dangles is a question, not a crash.
183
+ packState = yield* readPack;
184
+ const dangling = validateDecisions(decisions, packState.known);
185
+ if (dangling.length > 0) {
186
+ decisions = Decisions.make({
187
+ ...decisions,
188
+ openPoints: [
189
+ ...decisions.openPoints,
190
+ ...dangling.map((violation, index) => ({
191
+ number: decisions.openPoints.length + index + 1,
192
+ question: `After deepening: ${violation} — remap the entry or delete it`
193
+ }))
194
+ ]
195
+ });
196
+ yield* writeDecisions;
197
+ }
198
+ }));
199
+ }
200
+ // ---- Propose: resolve the `?` marks ---------------------------------------
201
+ if (decisions.marks.length > 0) {
202
+ yield* stage(context.events, "propose", Effect.gen(function* () {
203
+ const marked = [...new Set(decisions.marks.map((mark) => mark.program))];
204
+ const specs = marked.map((name) => ({
205
+ name,
206
+ spec: packState.specs[name] ?? "",
207
+ feature: packState.features[name] ?? ""
208
+ }));
209
+ const targetMounted = targetRepo !== undefined && targetRepo.length > 0;
210
+ if (!targetMounted) {
211
+ yield* say("no LLM4TS_TARGET_REPO — the proposal cannot claim anything is provided by the target");
212
+ }
213
+ const packParagraph = pack.prompt("refine-propose");
214
+ const promptText = proposePrompt(decisions, specs, {
215
+ targetMounted,
216
+ ...(packParagraph === undefined ? {} : { packParagraph })
217
+ });
218
+ const ask = yield* capped("propose", promptText, limit).pipe(Effect.provideService(FlowEvents, context.events));
219
+ const propose = (seat) => structuredAndPublish(seat.reasoning, context.events, ask, DecisionsProposal, decisionsProposalJsonSchema, "coder");
220
+ // The reasoning seat is the coder with read-only tools; rebound
221
+ // into the target (ADR 0013's seat rebind) it proposes `provided`
222
+ // from files it actually opened. Without a target the
223
+ // legacy-rooted seat proposes drops only.
224
+ const proposal = targetMounted && context.contextFor !== undefined
225
+ ? yield* Effect.scoped(Effect.flatMap(context.contextFor(targetRepo), (rebound) => propose(rebound)))
226
+ : yield* propose(context);
227
+ decisions = applyProposal(decisions, proposal, {
228
+ pointerExists: (pointer) => targetMounted && existsSync(join(targetRepo, pointer)),
229
+ known: packState.known,
230
+ decidedBy: "proposal",
231
+ decidedAt: today()
232
+ });
233
+ yield* writeDecisions;
234
+ notes.push(`proposed dispositions for ${marked.length} marked program(s): ` +
235
+ `${decisions.programs.length} program and ${decisions.scenarios.length} scenario decision(s) on file`);
236
+ }));
237
+ }
238
+ // ---- Consolidate: the domain map ----------------------------------------------
239
+ let domains;
240
+ const domainsPath = join(modDirAbs, "domains.md");
241
+ const existingText = yield* files.read(domainsPath);
242
+ const existing = existingText === undefined
243
+ ? undefined
244
+ : yield* parseDomains(existingText, `${ModDir}/domains.md`);
245
+ if (decisions.unansweredOpenPoints.length === 0) {
246
+ const survivingPrograms = names.filter((name) => decisions.programDecision(name) === undefined);
247
+ const surviving = new Map(survivingPrograms.map((name) => {
248
+ const disposed = decisions.disposedScenarios(name);
249
+ return [
250
+ name,
251
+ new Set([...(packState.scenarios.get(name) ?? [])].filter((t) => !disposed.has(t)))
252
+ ];
253
+ }));
254
+ const hash = domainsInputsHash(packState.specs, renderDecisions(decisions));
255
+ if (existing !== undefined && existing.inputsHash === hash && !regroup) {
256
+ domains = existing;
257
+ }
258
+ else {
259
+ domains = yield* stage(context.events, existing === undefined ? "consolidate" : "consolidate (regroup)", Effect.gen(function* () {
260
+ const clusters = clusterPrograms(graph, survivingPrograms, pack.consolidate ?? { cluster: [], context: [] });
261
+ if (pack.consolidate === undefined) {
262
+ yield* say("pack has no '## Consolidate' section — every program seeds its own domain feature");
263
+ }
264
+ const base = consolidatePrompt(clusters, surviving, pack.prompt("consolidate"));
265
+ const attempt = (extra) => Effect.gen(function* () {
266
+ const promptText = extra.length === 0
267
+ ? base
268
+ : `${base}\n\nYour previous answer broke these rules — fix them:\n${extra.map((v) => `- ${v}`).join("\n")}`;
269
+ const ask = yield* capped("consolidate", promptText, limit).pipe(Effect.provideService(FlowEvents, context.events));
270
+ const proposal = yield* structuredAndPublish(context.reasoning, context.events, ask, DomainProposal, domainProposalJsonSchema);
271
+ const map = domainsFromProposal(proposal, clusters, hash);
272
+ return { map, violations: checkExactlyOnce(map, surviving) };
273
+ });
274
+ let result = yield* attempt([]);
275
+ if (result.violations.length > 0) {
276
+ yield* say(`consolidation broke the exactly-once rule ${result.violations.length} time(s) — one retry`);
277
+ result = yield* attempt(result.violations);
278
+ }
279
+ const map = result.violations.length === 0
280
+ ? result.map
281
+ : Domains.make({
282
+ ...result.map,
283
+ openPoints: [
284
+ ...result.map.openPoints,
285
+ ...result.violations.map((violation, index) => ({
286
+ number: result.map.openPoints.length + index + 1,
287
+ question: `The map breaks the exactly-once rule: ${violation} — fix the map by hand`
288
+ }))
289
+ ]
290
+ });
291
+ yield* files.writeAtomic(domainsPath, renderDomains(map));
292
+ notes.push(`${existing === undefined ? "grouped" : "regrouped"} ${survivingPrograms.length} program(s) into ${map.features.length} domain feature(s)`);
293
+ return map;
294
+ }));
295
+ }
296
+ }
297
+ // ---- Plan per domain feature ------------------------------------------------------
298
+ const openPoints = [
299
+ ...decisions.unansweredOpenPoints.map((point) => `decisions.md ${point.number}. ${point.question}`),
300
+ ...(domains?.unansweredOpenPoints ?? []).map((point) => `domains.md ${point.number}. ${point.question}`)
301
+ ];
302
+ if (domains !== undefined && openPoints.length === 0 && (notes.length > 0 || regroup)) {
303
+ const map = domains;
304
+ yield* stage(context.events, "plan", Effect.gen(function* () {
305
+ const tasks = [];
306
+ const outOfScope = [
307
+ ...decisions.programs.map((e) => `${e.program}: ${e.disposition}`),
308
+ ...decisions.scenarios.map((e) => `${e.program} / ${e.scenario}: ${e.disposition}`)
309
+ ];
310
+ for (const feature of map.features) {
311
+ const text = [
312
+ `# Domain feature: ${feature.name} (${feature.id})`,
313
+ `Programs: ${feature.programs.join(", ")}`,
314
+ `Included fragments (context): ${feature.context.join(", ") || "none"}`,
315
+ `Scenarios in scope: ${feature.scenarios.map((s) => `${s.program} / ${s.title}`).join("; ")}`,
316
+ ...(outOfScope.length === 0
317
+ ? []
318
+ : [`Out of scope by decision (do not plan): ${outOfScope.join("; ")}`]),
319
+ ...feature.programs.map((name) => `\n===== ${name} =====\n${packState.specs[name] ?? ""}`),
320
+ ...feature.context.map((name) => `\n===== ${name} (context) =====\n${packState.specs[name] ?? ""}`)
321
+ ].join("\n");
322
+ const plannerSpecs = yield* capped(`plan[${feature.id}]`, text, limit).pipe(Effect.provideService(FlowEvents, context.events));
323
+ const plan = yield* planFrom(context.reasoning, plannerSpecs, `${defaultPlanInstructions}\n\n${pack.prompt("plan") ?? ""}`);
324
+ for (const task of plan.tasks) {
325
+ tasks.push(Task.make({
326
+ title: `[${feature.id}] ${task.title}`,
327
+ description: task.description,
328
+ completed: false
329
+ }));
330
+ }
331
+ }
332
+ yield* files.writeAtomic(join(modDirAbs, "plan.md"), Plan.make({ epicId: `${pack.name}-features`, tasks }).render);
333
+ notes.push(`planned ${tasks.length} task(s) across ${map.features.length} feature(s)`);
334
+ }));
335
+ }
336
+ if (notes.length === 0) {
337
+ yield* say(openPoints.length === 0
338
+ ? "nothing to refine — no marks, no deepen, and the domain map is fresh"
339
+ : "nothing ran — open points are still unanswered");
340
+ if (openPoints.length > 0) {
341
+ return yield* OpenPointsPending.make({ path: ModDir, points: openPoints });
342
+ }
343
+ return;
344
+ }
345
+ // ---- rules.txt with its waived section, README reset, commit ---------------------
346
+ yield* stage(context.events, "rules", Effect.gen(function* () {
347
+ const unitsByRule = yield* coverageUnits(repo, pack.coverage);
348
+ const allUnits = [...new Set(Object.values(unitsByRule).flat())].sort();
349
+ const fragments = new Map();
350
+ for (const name of names) {
351
+ const fragment = yield* files.read(join(modDirAbs, "traceability", `${name}.md`));
352
+ if (fragment !== undefined) {
353
+ fragments.set(name, fragment);
354
+ }
355
+ }
356
+ const waived = waivedUnits(decisions, { fragments, scenarios: packState.scenarios });
357
+ const lines = [
358
+ ...allUnits,
359
+ ...(waived.length === 0
360
+ ? []
361
+ : ["# waived", ...waived.map((entry) => `${entry.unit} — waived by ${entry.by}`)])
362
+ ];
363
+ if (allUnits.length > 0) {
364
+ yield* files.writeAtomic(join(modDirAbs, "rules.txt"), lines.join("\n") + "\n");
365
+ }
366
+ if (waived.length > 0) {
367
+ notes.push(`${waived.length} coverage unit(s) waived by decision`);
368
+ }
369
+ }));
370
+ // The README carries every refinement since the gate passed, not
371
+ // only this run's: an approver reads one list.
372
+ const readme = (yield* files.read(join(modDirAbs, "README.md"))) ?? "";
373
+ const verdict = /Gate verdict: (.+?)\.\n/.exec(readme)?.[1] ?? "PASSED — pending human approval";
374
+ const priorNotes = (/Refined after the gate passed:\n((?:- .*\n)+)/.exec(readme)?.[1] ?? "")
375
+ .split("\n")
376
+ .filter((line) => line.startsWith("- "))
377
+ .map((line) => line.slice(2));
378
+ yield* files.writeAtomic(join(modDirAbs, "README.md"), withDraftApproval(readmeFor(pack, verdict, [...priorNotes, ...notes])));
379
+ yield* stage(context.events, "commit", context.git
380
+ .commitAll(`modernize(${pack.name}): refine — ${notes[notes.length - 1] ?? "overlays"}`)
381
+ .pipe(Effect.asVoid));
382
+ if (openPoints.length > 0) {
383
+ return yield* OpenPointsPending.make({ path: ModDir, points: openPoints });
384
+ }
385
+ yield* say(`refined — review ${ModDir}/README.md, decisions.md, and domains.md, flip '- [x] Approved' ` +
386
+ "in each, then run the seed phase");
387
+ }));
388
+ });
389
+ runFlowMain(program);