@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
|
@@ -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);
|