@llm4ts/shell 0.15.1 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/flows/convert-all.js +1 -1
- package/flows/convert-page.js +1 -1
- package/flows/epic-stories.js +109 -0
- package/flows/fixtures/epic-stories/conto-bonifico.md +96 -0
- package/flows/lib/convert.js +485 -0
- package/flows/lib/epic-stories.js +245 -0
- package/package.json +6 -5
package/flows/convert-all.js
CHANGED
|
@@ -30,7 +30,7 @@ import { resolveFlowInput } from "@llm4ts/runner/FlowArgs";
|
|
|
30
30
|
import { runFlowMain, runNode } from "@llm4ts/runner/FlowRunner";
|
|
31
31
|
import { nodePlainFileStore } from "@llm4ts/runner/NodePlainFileStore";
|
|
32
32
|
import { nodeProcessExecutor } from "@llm4ts/runner/NodeProcessExecutor";
|
|
33
|
-
import { conversionInventory, convertPage, migrationReport, setupConversion } from "./lib/convert.
|
|
33
|
+
import { conversionInventory, convertPage, migrationReport, setupConversion } from "./lib/convert.js";
|
|
34
34
|
const program = Effect.gen(function* () {
|
|
35
35
|
const input = yield* resolveFlowInput("Convert the legacy estate into the destination SPA");
|
|
36
36
|
const coder = coderFromEnv(process.env);
|
package/flows/convert-page.js
CHANGED
|
@@ -23,7 +23,7 @@ import { Info } from "@llm4ts/flow/FlowEvents";
|
|
|
23
23
|
import { asReadOnly, coderFromEnv } from "@llm4ts/runner/Connectors";
|
|
24
24
|
import { resolveFlowInput } from "@llm4ts/runner/FlowArgs";
|
|
25
25
|
import { runFlowMain, runNode } from "@llm4ts/runner/FlowRunner";
|
|
26
|
-
import { convertPage, setupConversion } from "./lib/convert.
|
|
26
|
+
import { convertPage, setupConversion } from "./lib/convert.js";
|
|
27
27
|
const program = Effect.gen(function* () {
|
|
28
28
|
const input = yield* resolveFlowInput("Convert one legacy page into the destination SPA");
|
|
29
29
|
const page = input.prompt.trim().split(/\s+/)[0] ?? "";
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Split an epic into stories with a declared dependency graph, approve the plan, then implement the stories with parallel coders in per-story worktrees merged into an epic branch.
|
|
2
|
+
//
|
|
3
|
+
// llm4ts run epic-stories --repo ~/demo/portal "Add the current account and wire transfers"
|
|
4
|
+
// llm4ts run epic-stories --repo ~/demo/portal --plan-only "…" # write the plan and stop
|
|
5
|
+
//
|
|
6
|
+
// The reasoning seat (LLM4TS_REASONER, default claude) splits the epic,
|
|
7
|
+
// reviews every task and judges every story; the coder seat (LLM4TS_CODER,
|
|
8
|
+
// default pi) implements. The story plan is persisted under
|
|
9
|
+
// .llm4ts/epics/<epic-id>/plan.md BEFORE any coder runs, and an existing
|
|
10
|
+
// file wins over regeneration — editing it is the approval and the re-plan
|
|
11
|
+
// path. Stories run in .llm4ts/worktrees/<story-id> under --concurrency
|
|
12
|
+
// (default 3); a failed story skips its dependents (--fail-fast stops
|
|
13
|
+
// instead). The epic branch is left in place; the board and the report
|
|
14
|
+
// under .llm4ts/epics/<epic-id>/ carry ESTIMATED usage figures (ADR 0013).
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import * as Effect from "effect/Effect";
|
|
17
|
+
import { budget, cap } from "@llm4ts/flow/Context";
|
|
18
|
+
import { makeLocalBoardSync } from "@llm4ts/flow/BoardSync";
|
|
19
|
+
import { estimatedUsageOptionsFromEnv, makeEstimatedUsageMeter } from "@llm4ts/flow/EstimatedUsage";
|
|
20
|
+
import { FlowAborted } from "@llm4ts/flow/FlowError";
|
|
21
|
+
import { Info } from "@llm4ts/flow/FlowEvents";
|
|
22
|
+
import { stage } from "@llm4ts/flow/PlanExecution";
|
|
23
|
+
import { implementStoriesFlow } from "@llm4ts/flow/Stories";
|
|
24
|
+
import { makeStoryPlanStore, validateStoryPlan } from "@llm4ts/flow/StoryPlan";
|
|
25
|
+
import { asReadOnly } from "@llm4ts/runner/Connectors";
|
|
26
|
+
import { resolveFlowInput } from "@llm4ts/runner/FlowArgs";
|
|
27
|
+
import { runFlowMain, runNode } from "@llm4ts/runner/FlowRunner";
|
|
28
|
+
import { nodePlainFileStore } from "@llm4ts/runner/NodePlainFileStore";
|
|
29
|
+
import { nodeProcessExecutor } from "@llm4ts/runner/NodeProcessExecutor";
|
|
30
|
+
import { combineTotals, epicIdFor, gateCommands, gatesIn, generateStoryPlan, judgeStory, parseEpicArgs, reasonerFromEnvironment, storyCoderFromEnvironment } from "./lib/epic-stories.js";
|
|
31
|
+
const defaultEpic = "Add the retail customer's current account (Conto) with balance and movements, and wire " +
|
|
32
|
+
"transfers (Bonifico) with beneficiary, review, SCA confirmation, and history.";
|
|
33
|
+
const program = Effect.gen(function* () {
|
|
34
|
+
const flags = yield* parseEpicArgs(process.argv.slice(2));
|
|
35
|
+
const input = yield* resolveFlowInput(defaultEpic, flags.rest);
|
|
36
|
+
const reasoning = yield* reasonerFromEnvironment(process.env);
|
|
37
|
+
const coder = yield* storyCoderFromEnvironment(process.env);
|
|
38
|
+
const files = nodePlainFileStore;
|
|
39
|
+
const epicId = epicIdFor(input.prompt);
|
|
40
|
+
const stateDir = join(input.workDir, ".llm4ts", "epics", epicId);
|
|
41
|
+
const planPath = join(stateDir, "plan.md");
|
|
42
|
+
const estimateOptions = estimatedUsageOptionsFromEnv(process.env);
|
|
43
|
+
const contextBudget = budget(process.env);
|
|
44
|
+
yield* runNode({
|
|
45
|
+
workDir: input.workDir,
|
|
46
|
+
workspace: input.workspace,
|
|
47
|
+
userPrompt: input.prompt,
|
|
48
|
+
coder,
|
|
49
|
+
reasoning,
|
|
50
|
+
reviewers: [asReadOnly(reasoning)],
|
|
51
|
+
environment: process.env
|
|
52
|
+
}, (context) => Effect.gen(function* () {
|
|
53
|
+
const events = context.events;
|
|
54
|
+
const reasoningMeter = yield* makeEstimatedUsageMeter(context.reasoning, estimateOptions);
|
|
55
|
+
const guidance = yield* Effect.map(files.read(join(input.workDir, "CONTRIBUTING.md")), (text) => cap(text ?? "(no CONTRIBUTING.md in the target repository)", 24_000).text);
|
|
56
|
+
const store = makeStoryPlanStore(files);
|
|
57
|
+
const plan = yield* stage(events, "story plan", store
|
|
58
|
+
.recoverOrCreate(planPath, generateStoryPlan(reasoningMeter.service, events, input.prompt, epicId, guidance))
|
|
59
|
+
.pipe(Effect.flatMap(validateStoryPlan)));
|
|
60
|
+
yield* events.publish(Info.make({
|
|
61
|
+
message: `story plan: ${plan.stories.length} stories at ${planPath} (edit and rerun to re-plan)`
|
|
62
|
+
}));
|
|
63
|
+
if (flags.planOnly) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const contextFor = context.contextFor;
|
|
67
|
+
if (contextFor === undefined) {
|
|
68
|
+
return yield* FlowAborted.make({
|
|
69
|
+
message: "this runner cannot rebind seats to a worktree (no contextFor)"
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
const gates = gatesIn(nodeProcessExecutor, events, gateCommands(process.env));
|
|
73
|
+
const report = yield* implementStoriesFlow({ ...context, reasoning: reasoningMeter.service }, {
|
|
74
|
+
plan,
|
|
75
|
+
files,
|
|
76
|
+
stateDir,
|
|
77
|
+
worktreeRoot: join(input.workDir, ".llm4ts", "worktrees"),
|
|
78
|
+
board: makeLocalBoardSync(files, stateDir, `Epic: ${plan.epicId}`),
|
|
79
|
+
contextFor: (workDir) => Effect.gen(function* () {
|
|
80
|
+
const rebound = yield* contextFor(workDir);
|
|
81
|
+
const coderMeter = yield* makeEstimatedUsageMeter(rebound.coder, estimateOptions);
|
|
82
|
+
const reviewMeter = yield* makeEstimatedUsageMeter(rebound.reasoning, estimateOptions);
|
|
83
|
+
const seats = {
|
|
84
|
+
context: {
|
|
85
|
+
...rebound,
|
|
86
|
+
coder: coderMeter.service,
|
|
87
|
+
reasoning: reviewMeter.service
|
|
88
|
+
},
|
|
89
|
+
totals: combineTotals(coderMeter.totals, reviewMeter.totals)
|
|
90
|
+
};
|
|
91
|
+
return seats;
|
|
92
|
+
}),
|
|
93
|
+
gates,
|
|
94
|
+
judge: (story, diff) => judgeStory(reasoningMeter.service, story, diff, contextBudget),
|
|
95
|
+
system: (story) => Effect.succeed([
|
|
96
|
+
"House rules of the target repository (CONTRIBUTING.md):",
|
|
97
|
+
guidance,
|
|
98
|
+
"",
|
|
99
|
+
`Imitate the exemplar feature before inventing anything. Story id: ${story.id}.`
|
|
100
|
+
].join("\n")),
|
|
101
|
+
...(flags.concurrency === undefined ? {} : { concurrency: flags.concurrency }),
|
|
102
|
+
failFast: flags.failFast
|
|
103
|
+
});
|
|
104
|
+
yield* events.publish(Info.make({
|
|
105
|
+
message: `epic ${report.epicId}: ${report.count("done")} done, ${report.count("failed")} failed, ${report.count("skipped")} skipped — report at ${join(stateDir, "report.md")} (usage figures estimated)`
|
|
106
|
+
}));
|
|
107
|
+
}));
|
|
108
|
+
});
|
|
109
|
+
runFlowMain(program);
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# Epic: conto-bonifico
|
|
2
|
+
|
|
3
|
+
Add the retail customer's current account (Conto) with balance and movements, and wire transfers (Bonifico) with beneficiary, review, SCA confirmation, and history.
|
|
4
|
+
|
|
5
|
+
## Waves
|
|
6
|
+
|
|
7
|
+
1. accounts-contract, payments-contract, iban-field
|
|
8
|
+
2. conto-overview, conto-movimenti, bonifico-form, bonifici-list
|
|
9
|
+
3. home
|
|
10
|
+
|
|
11
|
+
## Stories
|
|
12
|
+
|
|
13
|
+
The expected split of the demo epic against the internet-banking portal fixture (`examples/internet-banking/portal`): two contract stories and one shared-kit story first, four screens under the concurrency cap, then the fan-in that owns the composition point. The block below is the source of truth; the flow's live generator is prompted with the same epic sentence.
|
|
14
|
+
|
|
15
|
+
## Plan block
|
|
16
|
+
|
|
17
|
+
```json storyplan
|
|
18
|
+
{
|
|
19
|
+
"epicId": "conto-bonifico",
|
|
20
|
+
"epic": "Add the retail customer's current account (Conto) with balance and movements, and wire transfers (Bonifico) with beneficiary, review, SCA confirmation, and history.",
|
|
21
|
+
"stories": [
|
|
22
|
+
{
|
|
23
|
+
"id": "accounts-contract",
|
|
24
|
+
"title": "Accounts domain contract and fake routes",
|
|
25
|
+
"description": "Declare the Accounts HttpApi in src/contracts/accounts.ts: list the customer's current accounts (id, IBAN, label, balance in cents, available balance), one account's detail, and its movements as a cursor-paged list (date, description, amount in cents, running balance, category). Implement src/contracts/accounts.fake.ts with deterministic fixture data for customer C-000123 (two accounts, at least 30 movements on the first), a reset function, and the exported accountsDomain. Run pnpm openapi and commit contracts/openapi/accounts.json. Follow src/contracts/profile.ts and profile.fake.ts exactly.",
|
|
26
|
+
"dependsOn": [],
|
|
27
|
+
"owned": ["src/contracts/accounts.ts", "src/contracts/accounts.fake.ts", "contracts/openapi/accounts.json"],
|
|
28
|
+
"sharedReadOnly": ["src/kit", "src/contracts/profile.ts", "src/contracts/profile.fake.ts", "CONTRIBUTING.md"],
|
|
29
|
+
"provides": ["accountsDomain from src/contracts/accounts.fake.ts", "client.accounts.list()", "client.accounts.get({ params: { accountId } })", "client.accounts.movements({ params: { accountId }, urlParams: { cursor } })"]
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"id": "payments-contract",
|
|
33
|
+
"title": "Payments domain contract and stateful fake routes",
|
|
34
|
+
"description": "Declare the Payments HttpApi in src/contracts/payments.ts: list saved beneficiaries (name, IBAN), create a transfer (from account id, beneficiary name, IBAN, amount in cents, description, execution date) returning a pending transfer with an id, confirm a transfer with a six-digit SCA code (any code confirms except 000000, which is refused as UnprocessableEntity), list transfers newest first with their state (pending, confirmed, refused), and get one transfer. Implement src/contracts/payments.fake.ts with an in-memory store per page session so a created then confirmed transfer appears in the list, a reset function, and the exported paymentsDomain. Run pnpm openapi and commit contracts/openapi/payments.json. Follow src/contracts/profile.ts and profile.fake.ts exactly.",
|
|
35
|
+
"dependsOn": [],
|
|
36
|
+
"owned": ["src/contracts/payments.ts", "src/contracts/payments.fake.ts", "contracts/openapi/payments.json"],
|
|
37
|
+
"sharedReadOnly": ["src/kit", "src/contracts/profile.ts", "src/contracts/profile.fake.ts", "CONTRIBUTING.md"],
|
|
38
|
+
"provides": ["paymentsDomain from src/contracts/payments.fake.ts", "client.payments.beneficiaries()", "client.payments.create({ payload })", "client.payments.confirm({ params: { transferId }, payload: { code } })", "client.payments.list()", "client.payments.get({ params: { transferId } })"]
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"id": "iban-field",
|
|
42
|
+
"title": "IBAN input component in the kit",
|
|
43
|
+
"description": "Add src/kit/iban-field.tsx exporting IbanField (a labelled input that formats the IBAN in groups of four as the customer types, keeps the compact value, and reports validity) and isValidIban (the mod-97 check over the ISO 13616 alphabet). Add src/kit/iban-field.test.tsx covering formatting and valid and invalid IBANs, using the existing Field component and theme classes only.",
|
|
44
|
+
"dependsOn": [],
|
|
45
|
+
"owned": ["src/kit/iban-field.tsx", "src/kit/iban-field.test.tsx"],
|
|
46
|
+
"sharedReadOnly": ["src/kit/components.tsx", "src/kit/theme.css", "src/kit/i18n.tsx", "src/kit/format.ts", "CONTRIBUTING.md"],
|
|
47
|
+
"provides": ["IbanField and isValidIban from src/kit/iban-field.tsx"]
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"id": "conto-overview",
|
|
51
|
+
"title": "Conto: account overview screen",
|
|
52
|
+
"description": "Add the feature src/features/conto/overview/ with messages.ts (English and Italian), route.tsx exporting contoOverviewFeature (id conto), and ContoScreen.tsx: an account picker across the customer's accounts, the balance and available balance as Figures, the IBAN and account details as KeyValues, loaded through accountsDomain. Add ContoScreen.test.tsx in the house style: rows and figures render in both languages against the fake transport.",
|
|
53
|
+
"dependsOn": ["accounts-contract"],
|
|
54
|
+
"owned": ["src/features/conto/overview"],
|
|
55
|
+
"sharedReadOnly": ["src/kit", "src/contracts", "src/features/profilo", "CONTRIBUTING.md"],
|
|
56
|
+
"provides": ["contoOverviewFeature from src/features/conto/overview/route.tsx", "screen id conto"]
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"id": "conto-movimenti",
|
|
60
|
+
"title": "Conto: movements list with filter and CSV export",
|
|
61
|
+
"description": "Add the feature src/features/conto/movimenti/ with messages.ts (English and Italian), route.tsx exporting contoMovimentiFeature (id movimenti), and MovimentiScreen.tsx: the selected account's movements as a DataTable paged with usePages and LoadMore, a SelectFilter by category, and a CSV export through downloadCsv, loaded through accountsDomain. Add MovimentiScreen.test.tsx in the house style: rows render, the filter narrows them, the next page loads.",
|
|
62
|
+
"dependsOn": ["accounts-contract"],
|
|
63
|
+
"owned": ["src/features/conto/movimenti"],
|
|
64
|
+
"sharedReadOnly": ["src/kit", "src/contracts", "src/features/profilo", "CONTRIBUTING.md"],
|
|
65
|
+
"provides": ["contoMovimentiFeature from src/features/conto/movimenti/route.tsx", "screen id movimenti"]
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"id": "bonifico-form",
|
|
69
|
+
"title": "Bonifico: new transfer with review, SCA confirmation and outcome",
|
|
70
|
+
"description": "Add the feature src/features/bonifico/nuovo/ with messages.ts (English and Italian), route.tsx exporting bonificoNuovoFeature (id bonifico), and BonificoScreen.tsx: a form (from account, beneficiary picked from saved beneficiaries or typed with IbanField, amount with parseEuro, description, execution date) validated by a pure function, a review step, a six-digit SCA code step calling client.payments.confirm, and an outcome panel for confirmed and refused, all through paymentsDomain and useAction. Add BonificoScreen.test.tsx in the house style: validation blocks a bad submit, a good transfer reaches the fake and is confirmed, code 000000 shows the refusal.",
|
|
71
|
+
"dependsOn": ["payments-contract", "iban-field"],
|
|
72
|
+
"owned": ["src/features/bonifico/nuovo"],
|
|
73
|
+
"sharedReadOnly": ["src/kit", "src/contracts", "src/features/profilo", "CONTRIBUTING.md"],
|
|
74
|
+
"provides": ["bonificoNuovoFeature from src/features/bonifico/nuovo/route.tsx", "screen id bonifico"]
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
"id": "bonifici-list",
|
|
78
|
+
"title": "Bonifico: transfer history with state badges and detail",
|
|
79
|
+
"description": "Add the feature src/features/bonifico/elenco/ with messages.ts (English and Italian), route.tsx exporting bonificiElencoFeature (id bonifici), and BonificiScreen.tsx: the customer's transfers newest first as a DataTable with a StateBadge per state (pending, confirmed, refused mapped to tones) and a detail panel for the selected transfer, through paymentsDomain. Add BonificiScreen.test.tsx in the house style: rows render with translated states in both languages, selecting a row shows its detail.",
|
|
80
|
+
"dependsOn": ["payments-contract"],
|
|
81
|
+
"owned": ["src/features/bonifico/elenco"],
|
|
82
|
+
"sharedReadOnly": ["src/kit", "src/contracts", "src/features/profilo", "CONTRIBUTING.md"],
|
|
83
|
+
"provides": ["bonificiElencoFeature from src/features/bonifico/elenco/route.tsx", "screen id bonifici"]
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"id": "home",
|
|
87
|
+
"title": "Home dashboard and the composition point",
|
|
88
|
+
"description": "Replace the placeholder src/features/home/ with a dashboard: the first account's balance as a Figure, the last five movements, a quick action navigating to the transfer form with useNavigate, and the three most recent transfers, through accountsDomain and paymentsDomain. Rewrite src/App.tsx so the feature list is home, conto, movimenti, bonifico, bonifici, profilo, in that order. Add HomeScreen.test.tsx in the house style: the figures and lists render, the quick action navigates.",
|
|
89
|
+
"dependsOn": ["conto-overview", "conto-movimenti", "bonifico-form", "bonifici-list"],
|
|
90
|
+
"owned": ["src/features/home", "src/App.tsx"],
|
|
91
|
+
"sharedReadOnly": ["src/kit", "src/contracts", "src/features/conto", "src/features/bonifico", "src/features/profilo", "CONTRIBUTING.md"],
|
|
92
|
+
"provides": ["the composed application with every screen reachable from the shell"]
|
|
93
|
+
}
|
|
94
|
+
]
|
|
95
|
+
}
|
|
96
|
+
```
|
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
// Shared core of the J2EE→Next.js conversion flows (convert-page,
|
|
2
|
+
// convert-all). One page = one branch = one conversion report. The Page Spec
|
|
3
|
+
// extracted from the legacy repo is the contract; the legacy source is
|
|
4
|
+
// consultable evidence (NOT clean-room — ADR 0012); the destination repo's
|
|
5
|
+
// own pages are the style guide. All token/cost figures are ESTIMATES.
|
|
6
|
+
import { join, resolve } from "node:path";
|
|
7
|
+
import * as Effect from "effect/Effect";
|
|
8
|
+
import { Dimension } from "@llm4ts/core/eval/Eval";
|
|
9
|
+
import { judge } from "@llm4ts/core/eval/Judge";
|
|
10
|
+
import { TokenUsage } from "@llm4ts/core/Models";
|
|
11
|
+
import { makeChat } from "@llm4ts/flow/Chat";
|
|
12
|
+
import { budget, capped } from "@llm4ts/flow/Context";
|
|
13
|
+
import { implementPlanFlow } from "@llm4ts/flow/Flow";
|
|
14
|
+
import { FlowAborted } from "@llm4ts/flow/FlowError";
|
|
15
|
+
import { FlowEvents, Info } from "@llm4ts/flow/FlowEvents";
|
|
16
|
+
import { openApiFor, parsePageSpec, renderPageSpec } from "@llm4ts/flow/PageSpec";
|
|
17
|
+
import { loadPatternCards, matchingPatternCards } from "@llm4ts/flow/Patterns";
|
|
18
|
+
import { makePlanStore } from "@llm4ts/flow/Persistence";
|
|
19
|
+
import { Plan, Task } from "@llm4ts/flow/Plan";
|
|
20
|
+
import { stage } from "@llm4ts/flow/PlanExecution";
|
|
21
|
+
import { judgeAllPrograms } from "@llm4ts/flow/ProgramJudge";
|
|
22
|
+
import { lintCommand, mergeReviewResults, minimalReviewers } from "@llm4ts/flow/Review";
|
|
23
|
+
import { closureFor, surveyGraph } from "@llm4ts/flow/Survey";
|
|
24
|
+
import { estimatedUsageOptionsFromEnv, makeEstimatedUsageMeter } from "@llm4ts/flow/EstimatedUsage";
|
|
25
|
+
import { legacySourceWorkspaceLimits, workspaceLimitsFromEnv } from "@llm4ts/flow/Workspace";
|
|
26
|
+
import { nodePlainFileStore } from "@llm4ts/runner/NodePlainFileStore";
|
|
27
|
+
import { nodeProcessExecutor } from "@llm4ts/runner/NodeProcessExecutor";
|
|
28
|
+
import { makeNodeWorkspace } from "@llm4ts/runner/NodeWorkspace";
|
|
29
|
+
import { loadUniversalPatternCards, openPack } from "@llm4ts/runner/Packs";
|
|
30
|
+
import { reviewFingerprint } from "@llm4ts/runner/ReviewFingerprint";
|
|
31
|
+
export const positiveEnvInt = (environment, name, fallback) => {
|
|
32
|
+
const raw = Number.parseInt(environment[name] ?? "", 10);
|
|
33
|
+
return Number.isFinite(raw) && raw > 0 ? raw : fallback;
|
|
34
|
+
};
|
|
35
|
+
/** Sums the coder and reasoning meters into one per-run estimate. */
|
|
36
|
+
export const combineTotals = (first, second) => Effect.gen(function* () {
|
|
37
|
+
const left = yield* first;
|
|
38
|
+
const right = yield* second;
|
|
39
|
+
if (left === undefined) {
|
|
40
|
+
return right;
|
|
41
|
+
}
|
|
42
|
+
if (right === undefined) {
|
|
43
|
+
return left;
|
|
44
|
+
}
|
|
45
|
+
const cached = [left.cached, right.cached].flatMap((value) => value === undefined ? [] : [value]);
|
|
46
|
+
const cost = [left.costUsd, right.costUsd].flatMap((value) => value === undefined ? [] : [value]);
|
|
47
|
+
return TokenUsage.make({
|
|
48
|
+
prompt: left.prompt + right.prompt,
|
|
49
|
+
completion: left.completion + right.completion,
|
|
50
|
+
total: left.total + right.total,
|
|
51
|
+
...(cached.length === 0 ? {} : { cached: cached.reduce((sum, value) => sum + value, 0) }),
|
|
52
|
+
...(cost.length === 0 ? {} : { costUsd: cost.reduce((sum, value) => sum + value, 0) })
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
const conversionDimensions = [
|
|
56
|
+
Dimension.make({
|
|
57
|
+
name: "spec-compliance",
|
|
58
|
+
rubric: "Does the converted page satisfy its Page Spec — every form field present, every " +
|
|
59
|
+
"validation rule with its VERBATIM message in the spec's order, navigation and " +
|
|
60
|
+
"multi-step session state owned explicitly, and every apiCall represented as a port " +
|
|
61
|
+
"operation matching the OpenAPI contract — without weakening any test?"
|
|
62
|
+
}),
|
|
63
|
+
Dimension.make({
|
|
64
|
+
name: "acl-purity",
|
|
65
|
+
rubric: "Is the anti-corruption layer intact — no fetch outside service adapters, no legacy " +
|
|
66
|
+
"DTO names anywhere, no business logic implemented client-side, the page depending " +
|
|
67
|
+
"only on the port through the registry, the mock adapter faking transport rather " +
|
|
68
|
+
"than rules?"
|
|
69
|
+
}),
|
|
70
|
+
Dimension.make({
|
|
71
|
+
name: "house-fidelity",
|
|
72
|
+
rubric: "Does the page read as if the destination team wrote it — design-system components " +
|
|
73
|
+
"instead of hand-rolled UI, the house Form validation map, the house test style, no " +
|
|
74
|
+
"ad-hoc styling or auth handling?"
|
|
75
|
+
})
|
|
76
|
+
];
|
|
77
|
+
const conversionPlan = (page, spec, contractPath) => Plan.make({
|
|
78
|
+
epicId: `convert/${page}`,
|
|
79
|
+
brief: [
|
|
80
|
+
`You are converting the legacy page '${page}' into this Next.js SPA.`,
|
|
81
|
+
"",
|
|
82
|
+
renderPageSpec(spec).trimEnd(),
|
|
83
|
+
"",
|
|
84
|
+
`The OpenAPI anti-corruption contract is ALREADY WRITTEN at ${contractPath} —`,
|
|
85
|
+
"it is generated from the page spec and is the contract of record. Do not edit it."
|
|
86
|
+
].join("\n"),
|
|
87
|
+
tasks: [
|
|
88
|
+
Task.make({
|
|
89
|
+
title: `acl: ${page} service port and mock`,
|
|
90
|
+
description: [
|
|
91
|
+
`Implement the anti-corruption service layer for '${page}' from the contract at`,
|
|
92
|
+
`${contractPath}:`,
|
|
93
|
+
`- src/services/${page}/port.ts — a typed port interface with one method per`,
|
|
94
|
+
" OpenAPI operation, request/response types in the contract's DOMAIN names.",
|
|
95
|
+
`- src/services/${page}/mock.ts — a mock adapter returning contract-shaped,`,
|
|
96
|
+
" deterministic fixture data (transport fake, never business rules).",
|
|
97
|
+
"- Wire the port into src/services/registry.ts the same way the existing",
|
|
98
|
+
" services are wired.",
|
|
99
|
+
"Imitate the existing services (e.g. src/services/cards/) exactly. No page code",
|
|
100
|
+
"in this task."
|
|
101
|
+
].join("\n")
|
|
102
|
+
}),
|
|
103
|
+
Task.make({
|
|
104
|
+
title: `page: ${page} component`,
|
|
105
|
+
description: [
|
|
106
|
+
`Build the converted page under src/app/${page}/ using ONLY the destination`,
|
|
107
|
+
"design-system components and the port from the previous task:",
|
|
108
|
+
"- Respect the original form: same fields, same validation rules with their",
|
|
109
|
+
" VERBATIM messages in the spec's order, same navigation.",
|
|
110
|
+
"- Anything the legacy app kept in HttpSession or hidden fields becomes explicit",
|
|
111
|
+
" client state (use the Stepper pattern for multi-step flows).",
|
|
112
|
+
"- No fetch in components; the page obtains its port from the registry.",
|
|
113
|
+
"- Read CONTRIBUTING.md and the existing pages first and match their style."
|
|
114
|
+
].join("\n")
|
|
115
|
+
}),
|
|
116
|
+
Task.make({
|
|
117
|
+
title: `tests: ${page} component tests`,
|
|
118
|
+
description: [
|
|
119
|
+
`Write component tests at tests/${page}.page.test.tsx in the house test style`,
|
|
120
|
+
"(see the existing tests/ files): mock the registry port, render inside",
|
|
121
|
+
"AuthProvider, and assert EXACTLY three families of behaviour:",
|
|
122
|
+
"1. every spec'd form field renders,",
|
|
123
|
+
"2. every spec'd validation fires with its verbatim message,",
|
|
124
|
+
"3. the port is called with contract-shaped payloads on the happy path.",
|
|
125
|
+
"No snapshots, no styling assertions, nothing beyond those families."
|
|
126
|
+
].join("\n")
|
|
127
|
+
})
|
|
128
|
+
]
|
|
129
|
+
});
|
|
130
|
+
const gateFor = (deps, name) => {
|
|
131
|
+
const command = deps.pack.gate(name);
|
|
132
|
+
return command === undefined
|
|
133
|
+
? undefined
|
|
134
|
+
: lintCommand(nodeProcessExecutor, deps.context.events, command, deps.targetDir);
|
|
135
|
+
};
|
|
136
|
+
const allClean = (gates) => Effect.gen(function* () {
|
|
137
|
+
const results = [];
|
|
138
|
+
for (const gate of gates) {
|
|
139
|
+
if (gate !== undefined) {
|
|
140
|
+
const result = yield* gate;
|
|
141
|
+
results.push(result);
|
|
142
|
+
// Fail fast: a broken typecheck makes later gate output noise.
|
|
143
|
+
if (!result.isClean) {
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return mergeReviewResults(results);
|
|
149
|
+
});
|
|
150
|
+
const issueLines = (result) => result.issues.map((issue) => `- ${issue.title}: ${issue.description}`).join("\n");
|
|
151
|
+
/** The page's legacy source and its bounded include closure, capped to budget. */
|
|
152
|
+
const legacyEvidence = Effect.fn("convert.legacyEvidence")(function* (deps, page) {
|
|
153
|
+
const matchesPage = yield* deps.legacy
|
|
154
|
+
.discover(`**/${page}.jsp`)
|
|
155
|
+
.pipe(Effect.orElseSucceed(() => []));
|
|
156
|
+
const sourcePath = matchesPage[0];
|
|
157
|
+
const source = sourcePath === undefined
|
|
158
|
+
? ""
|
|
159
|
+
: yield* deps.legacy.read(sourcePath).pipe(Effect.orElseSucceed(() => ""));
|
|
160
|
+
const graph = yield* surveyGraph(deps.legacy, deps.pack.sources ?? ".*", deps.pack.coverage, deps.pack.survey);
|
|
161
|
+
const closure = closureFor(graph, page, positiveEnvInt(deps.environment, "LLM4TS_MAX_CLOSURE_FILES", 12));
|
|
162
|
+
const parts = [];
|
|
163
|
+
if (sourcePath !== undefined) {
|
|
164
|
+
parts.push(`===== ${sourcePath} =====\n${source}`);
|
|
165
|
+
}
|
|
166
|
+
for (const path of closure) {
|
|
167
|
+
const text = yield* deps.legacy.read(path).pipe(Effect.orElseSucceed(() => ""));
|
|
168
|
+
if (text.trim().length > 0) {
|
|
169
|
+
parts.push(`===== ${path} =====\n${text}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const evidence = yield* capped(`legacy[${page}]`, parts.join("\n\n"), Math.floor(budget(deps.environment) / 3)).pipe(Effect.provideService(FlowEvents, deps.context.events));
|
|
173
|
+
return { source, evidence };
|
|
174
|
+
});
|
|
175
|
+
const destinationGuidance = Effect.fn("convert.destinationGuidance")(function* (deps) {
|
|
176
|
+
const contributing = yield* deps.target
|
|
177
|
+
.read("CONTRIBUTING.md")
|
|
178
|
+
.pipe(Effect.orElseSucceed(() => ""));
|
|
179
|
+
const pages = yield* deps.target
|
|
180
|
+
.discover("src/app/**/page.tsx")
|
|
181
|
+
.pipe(Effect.orElseSucceed(() => []));
|
|
182
|
+
return [
|
|
183
|
+
"Destination house rules (CONTRIBUTING.md):",
|
|
184
|
+
contributing.trim(),
|
|
185
|
+
"",
|
|
186
|
+
"Existing pages to imitate:",
|
|
187
|
+
...pages.map((path) => `- ${path}`)
|
|
188
|
+
].join("\n");
|
|
189
|
+
});
|
|
190
|
+
export const convertPage = Effect.fn("convert.page")(function* (deps, page) {
|
|
191
|
+
const { context, environment, files, pack } = deps;
|
|
192
|
+
const specPath = join(deps.legacyDir, pack.specsDir, `${page}.md`);
|
|
193
|
+
const specMarkdown = yield* files.read(specPath);
|
|
194
|
+
if (specMarkdown === undefined) {
|
|
195
|
+
return yield* FlowAborted.make({
|
|
196
|
+
message: `no spec at ${specPath} — run modernize-extract on the legacy repo first`
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
// Hard schema validation: a spec without a decodable pagespec block is an
|
|
200
|
+
// incomplete extraction, not a page to guess at.
|
|
201
|
+
const spec = yield* parsePageSpec(specMarkdown);
|
|
202
|
+
const branch = `convert/${page}`;
|
|
203
|
+
yield* stage(context.events, "branch", context.git.checkoutOrCreate(branch));
|
|
204
|
+
// The contract is a deterministic projection of the reviewed spec — written
|
|
205
|
+
// by code before any model runs, committed with the first task.
|
|
206
|
+
const contractPath = `contracts/${page}.openapi.yaml`;
|
|
207
|
+
yield* stage(context.events, "contract", files.writeAtomic(join(deps.targetDir, contractPath), openApiFor(spec)));
|
|
208
|
+
const { source, evidence } = yield* legacyEvidence(deps, page);
|
|
209
|
+
const playbook = matchingPatternCards(source, deps.cards);
|
|
210
|
+
const guidance = yield* destinationGuidance(deps);
|
|
211
|
+
const system = [
|
|
212
|
+
pack.prompt("implement"),
|
|
213
|
+
pack.lessons === undefined
|
|
214
|
+
? undefined
|
|
215
|
+
: `Lessons from previous conversion runs — apply them:\n${pack.lessons}`,
|
|
216
|
+
playbook.length === 0
|
|
217
|
+
? undefined
|
|
218
|
+
: "Pattern cards matched by the legacy source — the translation playbook (advisory, " +
|
|
219
|
+
"the spec wins):\n\n" +
|
|
220
|
+
playbook.map((card) => `### ${card.id}\n${card.body}`).join("\n\n"),
|
|
221
|
+
guidance,
|
|
222
|
+
evidence.trim().length === 0
|
|
223
|
+
? undefined
|
|
224
|
+
: `Legacy source evidence (for disambiguation only — the Page Spec wins):\n\n${evidence}`
|
|
225
|
+
]
|
|
226
|
+
.filter((part) => part !== undefined)
|
|
227
|
+
.join("\n\n");
|
|
228
|
+
const perTaskGate = allClean([
|
|
229
|
+
gateFor(deps, "typecheck"),
|
|
230
|
+
gateFor(deps, "lint"),
|
|
231
|
+
gateFor(deps, "test")
|
|
232
|
+
]);
|
|
233
|
+
yield* implementPlanFlow(context, {
|
|
234
|
+
store: makePlanStore(files),
|
|
235
|
+
planPath: join(deps.targetDir, ".llm4ts", "convert", `${page}.plan.md`),
|
|
236
|
+
plan: Effect.succeed(conversionPlan(page, spec, contractPath)),
|
|
237
|
+
system,
|
|
238
|
+
chatPerTask: true,
|
|
239
|
+
checkoutBranch: false,
|
|
240
|
+
reviewers: [...minimalReviewers, ...pack.lenses],
|
|
241
|
+
lint: perTaskGate
|
|
242
|
+
});
|
|
243
|
+
const verifyGate = allClean([gateFor(deps, "test"), gateFor(deps, "build")]);
|
|
244
|
+
yield* stage(context.events, "verify", Effect.gen(function* () {
|
|
245
|
+
const result = yield* verifyGate;
|
|
246
|
+
if (!result.isClean) {
|
|
247
|
+
return yield* FlowAborted.make({
|
|
248
|
+
message: `verify gate failed for ${page}:\n${issueLines(result)}`
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}));
|
|
252
|
+
yield* stage(context.events, "judge", Effect.gen(function* () {
|
|
253
|
+
const complianceJudge = judge(context.reasoning, conversionDimensions);
|
|
254
|
+
const rounds = positiveEnvInt(environment, "LLM4TS_JUDGE_ROUNDS", 2);
|
|
255
|
+
for (let round = 1; round <= rounds; round += 1) {
|
|
256
|
+
const base = yield* context.git.defaultBase;
|
|
257
|
+
const verdict = yield* judgeAllPrograms({
|
|
258
|
+
pack,
|
|
259
|
+
judge: complianceJudge,
|
|
260
|
+
dimensions: conversionDimensions,
|
|
261
|
+
git: context.git,
|
|
262
|
+
files,
|
|
263
|
+
gateDir: join(deps.targetDir, ".llm4ts", "convert", "gate"),
|
|
264
|
+
base,
|
|
265
|
+
programs: [page],
|
|
266
|
+
specFor: () => Effect.succeed(specMarkdown),
|
|
267
|
+
query: context.userPrompt,
|
|
268
|
+
fingerprint: reviewFingerprint
|
|
269
|
+
});
|
|
270
|
+
if (verdict.isClean) {
|
|
271
|
+
return yield* context.events.publish(Info.make({ message: `judge: ${page} cleared the bar` }));
|
|
272
|
+
}
|
|
273
|
+
if (round >= rounds) {
|
|
274
|
+
return yield* FlowAborted.make({
|
|
275
|
+
message: `judge not cleared for ${page} after ${rounds} round(s):\n${issueLines(verdict)}`
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
const feedback = yield* makeChat(context.coder, {
|
|
279
|
+
system,
|
|
280
|
+
events: context.events,
|
|
281
|
+
agent: "coder"
|
|
282
|
+
});
|
|
283
|
+
yield* feedback.ask([
|
|
284
|
+
`The conversion of '${page}' scored below the bar. Close these gaps without`,
|
|
285
|
+
"weakening any test, then stop:",
|
|
286
|
+
issueLines(verdict)
|
|
287
|
+
].join("\n"));
|
|
288
|
+
const regated = yield* verifyGate;
|
|
289
|
+
if (!regated.isClean) {
|
|
290
|
+
return yield* FlowAborted.make({
|
|
291
|
+
message: `verify gate broke while addressing judge feedback on ${page}`
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
yield* context.git.commitAll(`convert/${page}: address judge feedback`);
|
|
295
|
+
}
|
|
296
|
+
}).pipe(Effect.provideService(FlowEvents, context.events)));
|
|
297
|
+
const totals = yield* deps.totals;
|
|
298
|
+
const reportPath = `docs/conversion/${page}.md`;
|
|
299
|
+
const base = yield* context.git.defaultBase;
|
|
300
|
+
const changed = yield* context.git.changedFilesVsBase(base);
|
|
301
|
+
const report = [
|
|
302
|
+
`# Conversion report: ${page}`,
|
|
303
|
+
"",
|
|
304
|
+
"> Token and cost figures below are ESTIMATES from character counts",
|
|
305
|
+
"> (see docs/adr/0012): the CLI seats report no usage. They are not",
|
|
306
|
+
"> measurements.",
|
|
307
|
+
"",
|
|
308
|
+
`- Legacy spec: ${specPath}`,
|
|
309
|
+
`- Branch: \`${branch}\` (awaiting human review — no auto-merge)`,
|
|
310
|
+
`- Contract: ${contractPath}`,
|
|
311
|
+
`- Gates: typecheck, lint, test, build — green at report time`,
|
|
312
|
+
"- Judge: cleared (spec-compliance, acl-purity, house-fidelity)",
|
|
313
|
+
...(totals === undefined
|
|
314
|
+
? ["- Estimated usage: none recorded"]
|
|
315
|
+
: [
|
|
316
|
+
`- Estimated tokens: ~${totals.total} (${totals.prompt} in / ${totals.completion} out)`,
|
|
317
|
+
...(totals.costUsd === undefined
|
|
318
|
+
? []
|
|
319
|
+
: [`- Estimated cost: ~$${totals.costUsd.toFixed(2)}`])
|
|
320
|
+
]),
|
|
321
|
+
"",
|
|
322
|
+
"## Files changed",
|
|
323
|
+
"",
|
|
324
|
+
...changed.map((file) => `- ${file}`),
|
|
325
|
+
...(spec.openQuestions.length === 0
|
|
326
|
+
? []
|
|
327
|
+
: ["", "## Open questions carried forward", "", ...spec.openQuestions.map((q) => `- ${q}`)])
|
|
328
|
+
].join("\n");
|
|
329
|
+
yield* files.writeAtomic(join(deps.targetDir, reportPath), report + "\n");
|
|
330
|
+
yield* context.git.commitAll(`convert/${page}: conversion report`);
|
|
331
|
+
return {
|
|
332
|
+
page,
|
|
333
|
+
branch,
|
|
334
|
+
reportPath,
|
|
335
|
+
...(totals === undefined ? {} : { estimatedTokens: totals.total }),
|
|
336
|
+
...(totals?.costUsd === undefined ? {} : { estimatedCostUsd: totals.costUsd })
|
|
337
|
+
};
|
|
338
|
+
});
|
|
339
|
+
/**
|
|
340
|
+
* The common wiring of both conversion flows: metered seats (estimates-only
|
|
341
|
+
* accounting), the two workspaces (legacy read-only limits, target default),
|
|
342
|
+
* the pack (default `packs/j2ee-nextjs-spa`), and the pattern-card deck.
|
|
343
|
+
*/
|
|
344
|
+
export const setupConversion = Effect.fn("convert.setup")(function* (context, input, environment, flowDir) {
|
|
345
|
+
const legacyRaw = environment.LLM4TS_LEGACY_REPO;
|
|
346
|
+
if (legacyRaw === undefined || legacyRaw.trim().length === 0) {
|
|
347
|
+
return yield* FlowAborted.make({
|
|
348
|
+
message: "set LLM4TS_LEGACY_REPO to the extracted legacy repository path"
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
const legacyDir = resolve(input.workspace, legacyRaw.trim());
|
|
352
|
+
const estimateOptions = estimatedUsageOptionsFromEnv(environment);
|
|
353
|
+
const coderMeter = yield* makeEstimatedUsageMeter(context.coder, estimateOptions);
|
|
354
|
+
const reasoningMeter = yield* makeEstimatedUsageMeter(context.reasoning, estimateOptions);
|
|
355
|
+
const metered = {
|
|
356
|
+
...context,
|
|
357
|
+
coder: coderMeter.service,
|
|
358
|
+
reasoning: reasoningMeter.service
|
|
359
|
+
};
|
|
360
|
+
const legacy = yield* makeNodeWorkspace(legacyDir, workspaceLimitsFromEnv(environment, legacySourceWorkspaceLimits));
|
|
361
|
+
const target = yield* makeNodeWorkspace(input.workDir);
|
|
362
|
+
const opened = yield* stage(context.events, "pack", openPack({
|
|
363
|
+
environment: {
|
|
364
|
+
...environment,
|
|
365
|
+
LLM4TS_PACK: environment.LLM4TS_PACK ?? "packs/j2ee-nextjs-spa"
|
|
366
|
+
},
|
|
367
|
+
launchDir: input.workspace,
|
|
368
|
+
flowDir
|
|
369
|
+
}));
|
|
370
|
+
const cards = [
|
|
371
|
+
...(yield* loadPatternCards(opened.workspace, `${opened.dir}/patterns`)),
|
|
372
|
+
...(yield* loadUniversalPatternCards([input.workspace, flowDir]))
|
|
373
|
+
];
|
|
374
|
+
return {
|
|
375
|
+
context: metered,
|
|
376
|
+
files: nodePlainFileStore,
|
|
377
|
+
pack: opened.pack,
|
|
378
|
+
cards,
|
|
379
|
+
legacy,
|
|
380
|
+
legacyDir,
|
|
381
|
+
target,
|
|
382
|
+
targetDir: input.workDir,
|
|
383
|
+
environment,
|
|
384
|
+
totals: combineTotals(coderMeter.totals, reasoningMeter.totals)
|
|
385
|
+
};
|
|
386
|
+
});
|
|
387
|
+
/** `## Wave: <name>` sections with their `- PROG` lines — the survey's plan. */
|
|
388
|
+
export const parseWavePlan = (planText) => {
|
|
389
|
+
const waves = [];
|
|
390
|
+
let collecting = false;
|
|
391
|
+
for (const line of planText.split(/\r?\n/)) {
|
|
392
|
+
const trimmed = line.trim();
|
|
393
|
+
const heading = /^## Wave: (.+)$/.exec(trimmed);
|
|
394
|
+
if (heading?.[1] !== undefined) {
|
|
395
|
+
waves.push({ wave: heading[1].trim(), pages: [] });
|
|
396
|
+
collecting = true;
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
if (trimmed.startsWith("## ") || trimmed.startsWith("# ")) {
|
|
400
|
+
// Any other section (Triage, notes) ends the current wave's list.
|
|
401
|
+
collecting = false;
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
const current = waves.at(-1);
|
|
405
|
+
if (collecting && current !== undefined && trimmed.startsWith("- ")) {
|
|
406
|
+
current.pages.push(trimmed.slice(2).trim());
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return waves;
|
|
410
|
+
};
|
|
411
|
+
/**
|
|
412
|
+
* The ordered page list: the approved wave plan when present, otherwise every
|
|
413
|
+
* extracted spec. Pages appear in conversion order.
|
|
414
|
+
*/
|
|
415
|
+
export const conversionInventory = Effect.fn("convert.inventory")(function* (files, legacy, legacyDir, pack) {
|
|
416
|
+
const planText = yield* files.read(join(legacyDir, "docs/modernization/wave-plan.md"));
|
|
417
|
+
if (planText !== undefined) {
|
|
418
|
+
const waves = parseWavePlan(planText);
|
|
419
|
+
if (waves.length > 0) {
|
|
420
|
+
return waves.flatMap((entry) => entry.pages.map((page) => ({ page, wave: entry.wave })));
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
const specs = yield* legacy
|
|
424
|
+
.discover(`${pack.specsDir}/*.md`)
|
|
425
|
+
.pipe(Effect.orElseSucceed(() => []));
|
|
426
|
+
return [...specs]
|
|
427
|
+
.map((path) => path.split("/").at(-1) ?? path)
|
|
428
|
+
.filter((name) => name.endsWith(".md") && name !== "README.md")
|
|
429
|
+
.map((name) => name.slice(0, -".md".length))
|
|
430
|
+
.sort()
|
|
431
|
+
.map((page) => ({ page }));
|
|
432
|
+
});
|
|
433
|
+
/**
|
|
434
|
+
* Whole-estate summary with a deliberately naive projection: average estimated
|
|
435
|
+
* cost per converted page times the remainder. Every figure is an estimate of
|
|
436
|
+
* an estimate and the report says so — no pretend precision, rounded hard.
|
|
437
|
+
*/
|
|
438
|
+
export const migrationReport = (rows, remaining) => {
|
|
439
|
+
const done = rows.filter((row) => row.outcome === "done");
|
|
440
|
+
const tokenRows = done.flatMap((row) => row.estimatedTokens === undefined ? [] : [row.estimatedTokens]);
|
|
441
|
+
const costRows = done.flatMap((row) => row.estimatedCostUsd === undefined ? [] : [row.estimatedCostUsd]);
|
|
442
|
+
const averageTokens = tokenRows.length === 0
|
|
443
|
+
? undefined
|
|
444
|
+
: Math.round(tokenRows.reduce((sum, value) => sum + value, 0) / tokenRows.length);
|
|
445
|
+
const averageCost = costRows.length === 0
|
|
446
|
+
? undefined
|
|
447
|
+
: costRows.reduce((sum, value) => sum + value, 0) / costRows.length;
|
|
448
|
+
const lines = [
|
|
449
|
+
"# Migration report",
|
|
450
|
+
"",
|
|
451
|
+
"> EVERY figure in this report is an ESTIMATE derived from character",
|
|
452
|
+
"> counts (docs/adr/0012) — the CLI seats report no token usage. The",
|
|
453
|
+
"> projection below is an estimate built on those estimates.",
|
|
454
|
+
"",
|
|
455
|
+
`- Pages converted: ${done.length}`,
|
|
456
|
+
`- Pages failed: ${rows.filter((row) => row.outcome === "failed").length}`,
|
|
457
|
+
`- Pages skipped: ${rows.filter((row) => row.outcome === "skipped").length}`,
|
|
458
|
+
`- Pages remaining: ${remaining.length}`,
|
|
459
|
+
"",
|
|
460
|
+
"## Per page (estimated)",
|
|
461
|
+
"",
|
|
462
|
+
"| Page | Outcome | ~Tokens | ~Cost | Note |",
|
|
463
|
+
"| --- | --- | --- | --- | --- |",
|
|
464
|
+
...rows.map((row) => [
|
|
465
|
+
`| ${row.page}`,
|
|
466
|
+
row.outcome,
|
|
467
|
+
row.estimatedTokens === undefined ? "—" : `~${row.estimatedTokens}`,
|
|
468
|
+
row.estimatedCostUsd === undefined ? "—" : `~$${row.estimatedCostUsd.toFixed(2)}`,
|
|
469
|
+
`${row.detail ?? ""} |`
|
|
470
|
+
].join(" | "))
|
|
471
|
+
];
|
|
472
|
+
if (remaining.length > 0 && (averageTokens !== undefined || averageCost !== undefined)) {
|
|
473
|
+
lines.push("", "## Projection for the remaining estate (estimated)", "");
|
|
474
|
+
if (averageTokens !== undefined) {
|
|
475
|
+
lines.push(`- ~${averageTokens} tokens/page × ${remaining.length} pages ≈ ` +
|
|
476
|
+
`~${averageTokens * remaining.length} tokens`);
|
|
477
|
+
}
|
|
478
|
+
if (averageCost !== undefined) {
|
|
479
|
+
lines.push(`- ~$${averageCost.toFixed(2)}/page × ${remaining.length} pages ≈ ` +
|
|
480
|
+
`~$${(averageCost * remaining.length).toFixed(0)}`);
|
|
481
|
+
}
|
|
482
|
+
lines.push("", `Remaining: ${remaining.join(", ")}`);
|
|
483
|
+
}
|
|
484
|
+
return lines.join("\n") + "\n";
|
|
485
|
+
};
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// Shared core of the epic-stories flow (ADR 0013): the operator flags, the
|
|
2
|
+
// story-plan generator prompt and schema, the story judge, the gate runner,
|
|
3
|
+
// and seat selection. The executor itself is `@llm4ts/flow/Stories`.
|
|
4
|
+
import * as Effect from "effect/Effect";
|
|
5
|
+
import { Dimension, Sample } from "@llm4ts/core/eval/Eval";
|
|
6
|
+
import { judge } from "@llm4ts/core/eval/Judge";
|
|
7
|
+
import { TokenUsage } from "@llm4ts/core/Models";
|
|
8
|
+
import { cap } from "@llm4ts/flow/Context";
|
|
9
|
+
import { structuredAndPublish } from "@llm4ts/flow/Flow";
|
|
10
|
+
import { FlowLlmError } from "@llm4ts/flow/FlowError";
|
|
11
|
+
import { stableHash } from "@llm4ts/flow/Plan";
|
|
12
|
+
import { lintCommand, mergeReviewResults, ReviewIssue, ReviewResult } from "@llm4ts/flow/Review";
|
|
13
|
+
import { StoryPlan } from "@llm4ts/flow/StoryPlan";
|
|
14
|
+
import { claude, coderFor, coderIds, pi } from "@llm4ts/runner/Connectors";
|
|
15
|
+
import { ScriptUsage } from "@llm4ts/runner/FlowArgs";
|
|
16
|
+
export const epicUsage = [
|
|
17
|
+
"epic-stories flags:",
|
|
18
|
+
" --plan-only write (or re-validate) the story plan and stop",
|
|
19
|
+
" --concurrency <n> stories implemented at once (default 3)",
|
|
20
|
+
" --fail-fast stop the epic at the first failed story",
|
|
21
|
+
"Seats: LLM4TS_REASONER (claude|gemini|…, default claude) splits, reviews, judges;",
|
|
22
|
+
" LLM4TS_CODER (default pi) implements. LLM4TS_GATES overrides the gate commands."
|
|
23
|
+
].join("\n");
|
|
24
|
+
/** The flow's own flags, taken out before the shared `--repo`/prompt parsing sees the rest. */
|
|
25
|
+
export const parseEpicArgs = (argv) => Effect.gen(function* () {
|
|
26
|
+
let planOnly = false;
|
|
27
|
+
let failFast = false;
|
|
28
|
+
let concurrency;
|
|
29
|
+
const rest = [];
|
|
30
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
31
|
+
const argument = argv[index] ?? "";
|
|
32
|
+
if (argument === "--plan-only") {
|
|
33
|
+
planOnly = true;
|
|
34
|
+
}
|
|
35
|
+
else if (argument === "--fail-fast") {
|
|
36
|
+
failFast = true;
|
|
37
|
+
}
|
|
38
|
+
else if (argument === "--concurrency" || argument.startsWith("--concurrency=")) {
|
|
39
|
+
const raw = argument.includes("=")
|
|
40
|
+
? argument.slice("--concurrency=".length)
|
|
41
|
+
: argv[index + 1];
|
|
42
|
+
if (!argument.includes("=")) {
|
|
43
|
+
index += 1;
|
|
44
|
+
}
|
|
45
|
+
const parsed = Number.parseInt(raw ?? "", 10);
|
|
46
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
47
|
+
return yield* ScriptUsage.make({
|
|
48
|
+
message: `--concurrency requires a positive integer\n${epicUsage}`
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
concurrency = parsed;
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
rest.push(argument);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return { planOnly, failFast, concurrency, rest };
|
|
58
|
+
});
|
|
59
|
+
// ---- Seats --------------------------------------------------------------------
|
|
60
|
+
/** The reasoning seat: `LLM4TS_REASONER`, any coder id, default claude. Unknown names fail typed. */
|
|
61
|
+
export const reasonerFromEnvironment = (environment) => {
|
|
62
|
+
const requested = (environment.LLM4TS_REASONER ?? "").trim();
|
|
63
|
+
if (requested.length === 0) {
|
|
64
|
+
return Effect.succeed(claude);
|
|
65
|
+
}
|
|
66
|
+
const preset = coderFor(requested);
|
|
67
|
+
return preset === undefined
|
|
68
|
+
? ScriptUsage.make({
|
|
69
|
+
message: `unknown LLM4TS_REASONER '${requested}'; expected ${coderIds.join("|")}`
|
|
70
|
+
})
|
|
71
|
+
: Effect.succeed(preset);
|
|
72
|
+
};
|
|
73
|
+
/** The coder seat: `LLM4TS_CODER`, default pi — the cheap local typist under a stronger orchestrator. */
|
|
74
|
+
export const storyCoderFromEnvironment = (environment) => {
|
|
75
|
+
const requested = (environment.LLM4TS_CODER ?? "").trim();
|
|
76
|
+
if (requested.length === 0) {
|
|
77
|
+
return Effect.succeed(pi);
|
|
78
|
+
}
|
|
79
|
+
const preset = coderFor(requested);
|
|
80
|
+
return preset === undefined
|
|
81
|
+
? ScriptUsage.make({
|
|
82
|
+
message: `unknown LLM4TS_CODER '${requested}'; expected ${coderIds.join("|")}`
|
|
83
|
+
})
|
|
84
|
+
: Effect.succeed(preset);
|
|
85
|
+
};
|
|
86
|
+
// ---- Story plan generation -----------------------------------------------------
|
|
87
|
+
/** A readable, stable epic id: the first words of the epic plus a content hash. */
|
|
88
|
+
export const epicIdFor = (epic) => {
|
|
89
|
+
const slug = epic
|
|
90
|
+
.toLowerCase()
|
|
91
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
92
|
+
.replace(/^-+|-+$/g, "")
|
|
93
|
+
.split("-")
|
|
94
|
+
.filter((part) => part.length > 0)
|
|
95
|
+
.slice(0, 4)
|
|
96
|
+
.join("-");
|
|
97
|
+
return `${slug.length === 0 ? "epic" : slug}-${stableHash(epic).slice(0, 6)}`;
|
|
98
|
+
};
|
|
99
|
+
export const storyPlanJsonSchema = {
|
|
100
|
+
type: "object",
|
|
101
|
+
properties: {
|
|
102
|
+
epicId: { type: "string" },
|
|
103
|
+
epic: { type: "string" },
|
|
104
|
+
stories: {
|
|
105
|
+
type: "array",
|
|
106
|
+
items: {
|
|
107
|
+
type: "object",
|
|
108
|
+
properties: {
|
|
109
|
+
id: { type: "string" },
|
|
110
|
+
title: { type: "string" },
|
|
111
|
+
description: { type: "string" },
|
|
112
|
+
dependsOn: { type: "array", items: { type: "string" } },
|
|
113
|
+
owned: { type: "array", items: { type: "string" } },
|
|
114
|
+
sharedReadOnly: { type: "array", items: { type: "string" } },
|
|
115
|
+
provides: { type: "array", items: { type: "string" } }
|
|
116
|
+
},
|
|
117
|
+
required: ["id", "title", "description", "dependsOn", "owned", "sharedReadOnly", "provides"]
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
required: ["epicId", "epic", "stories"]
|
|
122
|
+
};
|
|
123
|
+
/** The generator's hard constraints — the perimeter rules the executor will enforce. */
|
|
124
|
+
export const storyPlanInstructions = (epicId, guidance) => [
|
|
125
|
+
"You are the orchestrator of a parallel implementation. Split the epic below into stories",
|
|
126
|
+
"that independent coding agents will implement AT THE SAME TIME, each in its own git worktree,",
|
|
127
|
+
"each confined to the paths it owns. Dependencies are declared here and never discovered later.",
|
|
128
|
+
"",
|
|
129
|
+
"Rules (violations are rejected mechanically):",
|
|
130
|
+
"- Every story has a kebab-case id, a title, a description precise enough to implement alone,",
|
|
131
|
+
" `dependsOn` (ids it must wait for), `owned` (repo-relative path prefixes it may create or",
|
|
132
|
+
" change), `sharedReadOnly` (prefixes it may read but never change), and `provides` (routes,",
|
|
133
|
+
" exports, contracts other stories may rely on).",
|
|
134
|
+
"- `owned` sets are pairwise DISJOINT: no path prefix appears under two stories.",
|
|
135
|
+
"- Shared surfaces (the kit, the theme, house rules) are never edited by a feature story. A new",
|
|
136
|
+
" shared component is its own story, and every story using it depends on it.",
|
|
137
|
+
"- Every new service domain is its own contract story (contract + fake routes) that the pages",
|
|
138
|
+
" depend on.",
|
|
139
|
+
"- Exactly ONE story owns the composition point (`src/App.tsx`): the fan-in that depends on",
|
|
140
|
+
" every screen story and wires them in.",
|
|
141
|
+
"- A story fits one agent session: one screen, one contract, or one component.",
|
|
142
|
+
`- Use exactly this epicId: "${epicId}". Copy the epic text into "epic".`,
|
|
143
|
+
"",
|
|
144
|
+
"Respond only with JSON:",
|
|
145
|
+
'{"epicId":"...","epic":"...","stories":[{"id":"...","title":"...","description":"...",',
|
|
146
|
+
'"dependsOn":[],"owned":[],"sharedReadOnly":[],"provides":[]}]}',
|
|
147
|
+
"",
|
|
148
|
+
"Target repository guidance (house rules and layout — the vocabulary to use):",
|
|
149
|
+
guidance
|
|
150
|
+
].join("\n");
|
|
151
|
+
export const generateStoryPlan = (reasoning, events, epic, epicId, guidance) => structuredAndPublish(reasoning, events, `${storyPlanInstructions(epicId, guidance)}\n\nEpic:\n${epic}`, StoryPlan, storyPlanJsonSchema).pipe(
|
|
152
|
+
// The id and the epic text are ours, whatever the model echoed back.
|
|
153
|
+
Effect.map((plan) => StoryPlan.make({ ...plan, epicId, epic })));
|
|
154
|
+
// ---- Usage ---------------------------------------------------------------------
|
|
155
|
+
/** Sums two meters into one per-story estimate. */
|
|
156
|
+
export const combineTotals = (first, second) => Effect.map(Effect.all([first, second]), ([left, right]) => {
|
|
157
|
+
if (left === undefined)
|
|
158
|
+
return right;
|
|
159
|
+
if (right === undefined)
|
|
160
|
+
return left;
|
|
161
|
+
const cost = [left.costUsd, right.costUsd].flatMap((value) => value === undefined ? [] : [value]);
|
|
162
|
+
return TokenUsage.make({
|
|
163
|
+
prompt: left.prompt + right.prompt,
|
|
164
|
+
completion: left.completion + right.completion,
|
|
165
|
+
total: left.total + right.total,
|
|
166
|
+
...(cost.length === 0 ? {} : { costUsd: cost.reduce((sum, value) => sum + value, 0) })
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
// ---- Judge ---------------------------------------------------------------------
|
|
170
|
+
export const storyDimensions = [
|
|
171
|
+
Dimension.make({
|
|
172
|
+
name: "provides",
|
|
173
|
+
rubric: "Everything the story promised to provide (routes, exports, contracts) exists in the diff and is complete enough for a dependent story to use. 2 = all present and complete; 1 = present but partial; 0 = missing."
|
|
174
|
+
}),
|
|
175
|
+
Dimension.make({
|
|
176
|
+
name: "scope",
|
|
177
|
+
rubric: "The diff does only what the story describes, inside its owned paths, and does not reimplement what the shared kit already offers. 2 = focused; 1 = minor drift; 0 = unrelated or duplicated work."
|
|
178
|
+
}),
|
|
179
|
+
Dimension.make({
|
|
180
|
+
name: "house-style",
|
|
181
|
+
rubric: "The code follows the target's house rules: kit components and hooks only, per-feature messages in both languages, contract-first domains, tests beside the feature. 2 = follows them; 1 = mostly; 0 = ignores them."
|
|
182
|
+
}),
|
|
183
|
+
Dimension.make({
|
|
184
|
+
name: "tests",
|
|
185
|
+
rubric: "The story ships deterministic tests in the house style covering its screens or contract. 2 = yes; 1 = thin; 0 = none."
|
|
186
|
+
})
|
|
187
|
+
];
|
|
188
|
+
const subBar = (scored, story) => ReviewResult.make({
|
|
189
|
+
issues: scored.scores
|
|
190
|
+
.filter((score) => score.score <
|
|
191
|
+
(storyDimensions.find((dimension) => dimension.name === score.name)?.maxScore ?? 2))
|
|
192
|
+
.map((score) => ReviewIssue.make({
|
|
193
|
+
severity: "Critical",
|
|
194
|
+
title: `judge[${story.id}]: ${score.name} scored ${score.score}`,
|
|
195
|
+
description: score.reasoning
|
|
196
|
+
})),
|
|
197
|
+
summary: `judge:${story.id}`
|
|
198
|
+
});
|
|
199
|
+
/** The story-level judge over the branch diff, bounded by the character budget. */
|
|
200
|
+
export const judgeStory = (reasoning, story, diff, budget) => judge(reasoning, storyDimensions)
|
|
201
|
+
.evaluate(Sample.make({
|
|
202
|
+
query: [
|
|
203
|
+
`Story: ${story.title}`,
|
|
204
|
+
story.description,
|
|
205
|
+
"",
|
|
206
|
+
`Provides: ${story.provides.join(", ") || "(none)"}`,
|
|
207
|
+
`Owned paths: ${story.owned.join(", ")}`,
|
|
208
|
+
`Shared read-only: ${story.sharedReadOnly.join(", ") || "(none)"}`
|
|
209
|
+
].join("\n"),
|
|
210
|
+
response: cap(diff, budget).text
|
|
211
|
+
}))
|
|
212
|
+
.pipe(Effect.mapError(FlowLlmError.from), Effect.map((scored) => subBar(scored, story)));
|
|
213
|
+
// ---- Gates ---------------------------------------------------------------------
|
|
214
|
+
export const defaultGateCommands = [
|
|
215
|
+
["pnpm", "typecheck"],
|
|
216
|
+
["pnpm", "lint"],
|
|
217
|
+
["pnpm", "test"],
|
|
218
|
+
["pnpm", "build"]
|
|
219
|
+
];
|
|
220
|
+
/** `LLM4TS_GATES="pnpm typecheck;pnpm test"` overrides the four defaults. */
|
|
221
|
+
export const gateCommands = (environment) => {
|
|
222
|
+
const raw = environment.LLM4TS_GATES?.trim();
|
|
223
|
+
if (raw === undefined || raw.length === 0) {
|
|
224
|
+
return defaultGateCommands;
|
|
225
|
+
}
|
|
226
|
+
return raw
|
|
227
|
+
.split(";")
|
|
228
|
+
.map((command) => command
|
|
229
|
+
.trim()
|
|
230
|
+
.split(/\s+/)
|
|
231
|
+
.filter((part) => part.length > 0))
|
|
232
|
+
.filter((command) => command.length > 0);
|
|
233
|
+
};
|
|
234
|
+
/** Runs the gates in a directory, stopping at the first red one (later output would be noise). */
|
|
235
|
+
export const gatesIn = (process, events, commands) => (workDir) => Effect.gen(function* () {
|
|
236
|
+
const results = [];
|
|
237
|
+
for (const command of commands) {
|
|
238
|
+
const result = yield* lintCommand(process, events, command, workDir);
|
|
239
|
+
results.push(result);
|
|
240
|
+
if (!result.isClean) {
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return mergeReviewResults(results);
|
|
245
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@llm4ts/shell",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Interactive shell and CLI for llm4ts: flow discovery, run-a-flow, and view",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -49,10 +49,11 @@
|
|
|
49
49
|
],
|
|
50
50
|
"dependencies": {
|
|
51
51
|
"@effect/platform-node": "4.0.0-beta.102",
|
|
52
|
-
"@
|
|
53
|
-
"@llm4ts/
|
|
54
|
-
"@llm4ts/core": "0.
|
|
55
|
-
"@llm4ts/
|
|
52
|
+
"@effect/platform-node-shared": "4.0.0-beta.102",
|
|
53
|
+
"@llm4ts/flow": "0.16.0",
|
|
54
|
+
"@llm4ts/core": "0.16.0",
|
|
55
|
+
"@llm4ts/runner": "0.16.0",
|
|
56
|
+
"@llm4ts/modernize": "0.16.0"
|
|
56
57
|
},
|
|
57
58
|
"peerDependencies": {
|
|
58
59
|
"effect": "4.0.0-beta.102"
|