@llm4ts/shell 0.15.1 → 0.16.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.
@@ -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.ts";
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);
@@ -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.ts";
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,119 @@
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; LLM4TS_CODER_MODEL / LLM4TS_REASONING_MODEL pick
9
+ // their models (pi: "provider/model"). The story plan is persisted under
10
+ // .llm4ts/epics/<epic-id>/plan.md BEFORE any coder runs, and an existing
11
+ // file wins over regeneration — editing it is the approval and the re-plan
12
+ // path. Stories run in .llm4ts/worktrees/<story-id> under --concurrency
13
+ // (default 3); a failed story skips its dependents (--fail-fast stops
14
+ // instead). The epic branch is left in place; the board and the report
15
+ // under .llm4ts/epics/<epic-id>/ carry ESTIMATED usage figures (ADR 0013).
16
+ import { join } from "node:path";
17
+ import * as Effect from "effect/Effect";
18
+ import { budget, cap } from "@llm4ts/flow/Context";
19
+ import { makeLocalBoardSync } from "@llm4ts/flow/BoardSync";
20
+ import { estimatedUsageOptionsFromEnv, makeEstimatedUsageMeter } from "@llm4ts/flow/EstimatedUsage";
21
+ import { FlowAborted } from "@llm4ts/flow/FlowError";
22
+ import { Info } from "@llm4ts/flow/FlowEvents";
23
+ import { stage } from "@llm4ts/flow/PlanExecution";
24
+ import { implementStoriesFlow } from "@llm4ts/flow/Stories";
25
+ import { makeStoryPlanStore, validateStoryPlan } from "@llm4ts/flow/StoryPlan";
26
+ import { asReadOnly, withModel } from "@llm4ts/runner/Connectors";
27
+ import { resolveFlowInput } from "@llm4ts/runner/FlowArgs";
28
+ import { runFlowMain, runNode } from "@llm4ts/runner/FlowRunner";
29
+ import { nodePlainFileStore } from "@llm4ts/runner/NodePlainFileStore";
30
+ import { nodeProcessExecutor } from "@llm4ts/runner/NodeProcessExecutor";
31
+ import { combineTotals, epicIdFor, gateCommands, gatesIn, generateStoryPlan, judgeStory, parseEpicArgs, reasonerFromEnvironment, setupIn, storyCoderFromEnvironment, worktreeSetupCommand } from "./lib/epic-stories.js";
32
+ /** `LLM4TS_CODER_MODEL` / `LLM4TS_REASONING_MODEL`: pi takes `provider/model` (e.g. `openai-codex/gpt-5.5`). */
33
+ const withOptionalModel = (config, model) => {
34
+ const trimmed = model?.trim();
35
+ return trimmed === undefined || trimmed.length === 0 ? config : withModel(config, trimmed);
36
+ };
37
+ const defaultEpic = "Add the retail customer's current account (Conto) with balance and movements, and wire " +
38
+ "transfers (Bonifico) with beneficiary, review, SCA confirmation, and history.";
39
+ const program = Effect.gen(function* () {
40
+ const flags = yield* parseEpicArgs(process.argv.slice(2));
41
+ const input = yield* resolveFlowInput(defaultEpic, flags.rest);
42
+ const reasoning = withOptionalModel(yield* reasonerFromEnvironment(process.env), process.env.LLM4TS_REASONING_MODEL);
43
+ const coder = withOptionalModel(yield* storyCoderFromEnvironment(process.env), process.env.LLM4TS_CODER_MODEL);
44
+ const files = nodePlainFileStore;
45
+ const epicId = epicIdFor(input.prompt);
46
+ const stateDir = join(input.workDir, ".llm4ts", "epics", epicId);
47
+ const planPath = join(stateDir, "plan.md");
48
+ const estimateOptions = estimatedUsageOptionsFromEnv(process.env);
49
+ const contextBudget = budget(process.env);
50
+ yield* runNode({
51
+ workDir: input.workDir,
52
+ workspace: input.workspace,
53
+ userPrompt: input.prompt,
54
+ coder,
55
+ reasoning,
56
+ reviewers: [asReadOnly(reasoning)],
57
+ environment: process.env
58
+ }, (context) => Effect.gen(function* () {
59
+ const events = context.events;
60
+ const reasoningMeter = yield* makeEstimatedUsageMeter(context.reasoning, estimateOptions);
61
+ 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);
62
+ const store = makeStoryPlanStore(files);
63
+ const plan = yield* stage(events, "story plan", store
64
+ .recoverOrCreate(planPath, generateStoryPlan(reasoningMeter.service, events, input.prompt, epicId, guidance))
65
+ .pipe(Effect.flatMap(validateStoryPlan)));
66
+ yield* events.publish(Info.make({
67
+ message: `story plan: ${plan.stories.length} stories at ${planPath} (edit and rerun to re-plan)`
68
+ }));
69
+ if (flags.planOnly) {
70
+ return;
71
+ }
72
+ const contextFor = context.contextFor;
73
+ if (contextFor === undefined) {
74
+ return yield* FlowAborted.make({
75
+ message: "this runner cannot rebind seats to a worktree (no contextFor)"
76
+ });
77
+ }
78
+ const gates = gatesIn(nodeProcessExecutor, events, gateCommands(process.env));
79
+ const setupCommand = worktreeSetupCommand(process.env);
80
+ const report = yield* implementStoriesFlow({ ...context, reasoning: reasoningMeter.service }, {
81
+ plan,
82
+ files,
83
+ stateDir,
84
+ worktreeRoot: join(input.workDir, ".llm4ts", "worktrees"),
85
+ board: makeLocalBoardSync(files, stateDir, `Epic: ${plan.epicId}`),
86
+ contextFor: (workDir) => Effect.gen(function* () {
87
+ const rebound = yield* contextFor(workDir);
88
+ const coderMeter = yield* makeEstimatedUsageMeter(rebound.coder, estimateOptions);
89
+ const reviewMeter = yield* makeEstimatedUsageMeter(rebound.reasoning, estimateOptions);
90
+ const seats = {
91
+ context: {
92
+ ...rebound,
93
+ coder: coderMeter.service,
94
+ reasoning: reviewMeter.service
95
+ },
96
+ totals: combineTotals(coderMeter.totals, reviewMeter.totals)
97
+ };
98
+ return seats;
99
+ }),
100
+ ...(setupCommand === undefined
101
+ ? {}
102
+ : { setup: setupIn(nodeProcessExecutor, events, setupCommand) }),
103
+ gates,
104
+ judge: (story, diff) => judgeStory(reasoningMeter.service, story, diff, contextBudget),
105
+ system: (story) => Effect.succeed([
106
+ "House rules of the target repository (CONTRIBUTING.md):",
107
+ guidance,
108
+ "",
109
+ `Imitate the exemplar feature before inventing anything. Story id: ${story.id}.`
110
+ ].join("\n")),
111
+ ...(flags.concurrency === undefined ? {} : { concurrency: flags.concurrency }),
112
+ failFast: flags.failFast
113
+ });
114
+ yield* events.publish(Info.make({
115
+ 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)`
116
+ }));
117
+ }));
118
+ });
119
+ runFlowMain(program);
@@ -0,0 +1,207 @@
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. Add src/contracts/accounts.test.ts covering the fake routes through the typed client: list, detail, and a page of movements.",
26
+ "dependsOn": [],
27
+ "owned": [
28
+ "src/contracts/accounts.ts",
29
+ "src/contracts/accounts.fake.ts",
30
+ "contracts/openapi/accounts.json",
31
+ "src/contracts/accounts.test.ts"
32
+ ],
33
+ "sharedReadOnly": [
34
+ "src/kit",
35
+ "src/contracts/profile.ts",
36
+ "src/contracts/profile.fake.ts",
37
+ "CONTRIBUTING.md"
38
+ ],
39
+ "provides": [
40
+ "accountsDomain from src/contracts/accounts.fake.ts",
41
+ "client.accounts.list()",
42
+ "client.accounts.get({ params: { accountId } })",
43
+ "client.accounts.movements({ params: { accountId }, urlParams: { cursor } })",
44
+ "src/contracts/accounts.test.ts"
45
+ ]
46
+ },
47
+ {
48
+ "id": "payments-contract",
49
+ "title": "Payments domain contract and stateful fake routes",
50
+ "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. Add src/contracts/payments.test.ts covering the fake routes through the typed client: create then confirm then list, and the refused code 000000.",
51
+ "dependsOn": [],
52
+ "owned": [
53
+ "src/contracts/payments.ts",
54
+ "src/contracts/payments.fake.ts",
55
+ "contracts/openapi/payments.json",
56
+ "src/contracts/payments.test.ts"
57
+ ],
58
+ "sharedReadOnly": [
59
+ "src/kit",
60
+ "src/contracts/profile.ts",
61
+ "src/contracts/profile.fake.ts",
62
+ "CONTRIBUTING.md"
63
+ ],
64
+ "provides": [
65
+ "paymentsDomain from src/contracts/payments.fake.ts",
66
+ "client.payments.beneficiaries()",
67
+ "client.payments.create({ payload })",
68
+ "client.payments.confirm({ params: { transferId }, payload: { code } })",
69
+ "client.payments.list()",
70
+ "client.payments.get({ params: { transferId } })",
71
+ "src/contracts/payments.test.ts"
72
+ ]
73
+ },
74
+ {
75
+ "id": "iban-field",
76
+ "title": "IBAN input component in the kit",
77
+ "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.",
78
+ "dependsOn": [],
79
+ "owned": [
80
+ "src/kit/iban-field.tsx",
81
+ "src/kit/iban-field.test.tsx"
82
+ ],
83
+ "sharedReadOnly": [
84
+ "src/kit/components.tsx",
85
+ "src/kit/theme.css",
86
+ "src/kit/i18n.tsx",
87
+ "src/kit/format.ts",
88
+ "CONTRIBUTING.md"
89
+ ],
90
+ "provides": [
91
+ "IbanField and isValidIban from src/kit/iban-field.tsx"
92
+ ]
93
+ },
94
+ {
95
+ "id": "conto-overview",
96
+ "title": "Conto: account overview screen",
97
+ "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.",
98
+ "dependsOn": [
99
+ "accounts-contract"
100
+ ],
101
+ "owned": [
102
+ "src/features/conto/overview"
103
+ ],
104
+ "sharedReadOnly": [
105
+ "src/kit",
106
+ "src/contracts",
107
+ "src/features/profilo",
108
+ "CONTRIBUTING.md"
109
+ ],
110
+ "provides": [
111
+ "contoOverviewFeature from src/features/conto/overview/route.tsx",
112
+ "screen id conto"
113
+ ]
114
+ },
115
+ {
116
+ "id": "conto-movimenti",
117
+ "title": "Conto: movements list with filter and CSV export",
118
+ "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.",
119
+ "dependsOn": [
120
+ "accounts-contract"
121
+ ],
122
+ "owned": [
123
+ "src/features/conto/movimenti"
124
+ ],
125
+ "sharedReadOnly": [
126
+ "src/kit",
127
+ "src/contracts",
128
+ "src/features/profilo",
129
+ "CONTRIBUTING.md"
130
+ ],
131
+ "provides": [
132
+ "contoMovimentiFeature from src/features/conto/movimenti/route.tsx",
133
+ "screen id movimenti"
134
+ ]
135
+ },
136
+ {
137
+ "id": "bonifico-form",
138
+ "title": "Bonifico: new transfer with review, SCA confirmation and outcome",
139
+ "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.",
140
+ "dependsOn": [
141
+ "payments-contract",
142
+ "iban-field"
143
+ ],
144
+ "owned": [
145
+ "src/features/bonifico/nuovo"
146
+ ],
147
+ "sharedReadOnly": [
148
+ "src/kit",
149
+ "src/contracts",
150
+ "src/features/profilo",
151
+ "CONTRIBUTING.md"
152
+ ],
153
+ "provides": [
154
+ "bonificoNuovoFeature from src/features/bonifico/nuovo/route.tsx",
155
+ "screen id bonifico"
156
+ ]
157
+ },
158
+ {
159
+ "id": "bonifici-list",
160
+ "title": "Bonifico: transfer history with state badges and detail",
161
+ "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.",
162
+ "dependsOn": [
163
+ "payments-contract"
164
+ ],
165
+ "owned": [
166
+ "src/features/bonifico/elenco"
167
+ ],
168
+ "sharedReadOnly": [
169
+ "src/kit",
170
+ "src/contracts",
171
+ "src/features/profilo",
172
+ "CONTRIBUTING.md"
173
+ ],
174
+ "provides": [
175
+ "bonificiElencoFeature from src/features/bonifico/elenco/route.tsx",
176
+ "screen id bonifici"
177
+ ]
178
+ },
179
+ {
180
+ "id": "home",
181
+ "title": "Home dashboard and the composition point",
182
+ "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.",
183
+ "dependsOn": [
184
+ "conto-overview",
185
+ "conto-movimenti",
186
+ "bonifico-form",
187
+ "bonifici-list"
188
+ ],
189
+ "owned": [
190
+ "src/features/home",
191
+ "src/App.tsx"
192
+ ],
193
+ "sharedReadOnly": [
194
+ "src/kit",
195
+ "src/contracts",
196
+ "src/features/conto",
197
+ "src/features/bonifico",
198
+ "src/features/profilo",
199
+ "CONTRIBUTING.md"
200
+ ],
201
+ "provides": [
202
+ "the composed application with every screen reachable from the shell"
203
+ ]
204
+ }
205
+ ]
206
+ }
207
+ ```
@@ -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,275 @@
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 { FlowAborted, 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_REASONING_MODEL / LLM4TS_CODER_MODEL",
23
+ " pick their models (pi: provider/model). LLM4TS_GATES overrides the gate commands;",
24
+ " LLM4TS_WORKTREE_SETUP (default: pnpm install --offline) prepares each worktree."
25
+ ].join("\n");
26
+ /** The flow's own flags, taken out before the shared `--repo`/prompt parsing sees the rest. */
27
+ export const parseEpicArgs = (argv) => Effect.gen(function* () {
28
+ let planOnly = false;
29
+ let failFast = false;
30
+ let concurrency;
31
+ const rest = [];
32
+ for (let index = 0; index < argv.length; index += 1) {
33
+ const argument = argv[index] ?? "";
34
+ if (argument === "--plan-only") {
35
+ planOnly = true;
36
+ }
37
+ else if (argument === "--fail-fast") {
38
+ failFast = true;
39
+ }
40
+ else if (argument === "--concurrency" || argument.startsWith("--concurrency=")) {
41
+ const raw = argument.includes("=")
42
+ ? argument.slice("--concurrency=".length)
43
+ : argv[index + 1];
44
+ if (!argument.includes("=")) {
45
+ index += 1;
46
+ }
47
+ const parsed = Number.parseInt(raw ?? "", 10);
48
+ if (!Number.isInteger(parsed) || parsed < 1) {
49
+ return yield* ScriptUsage.make({
50
+ message: `--concurrency requires a positive integer\n${epicUsage}`
51
+ });
52
+ }
53
+ concurrency = parsed;
54
+ }
55
+ else {
56
+ rest.push(argument);
57
+ }
58
+ }
59
+ return { planOnly, failFast, concurrency, rest };
60
+ });
61
+ // ---- Seats --------------------------------------------------------------------
62
+ /** The reasoning seat: `LLM4TS_REASONER`, any coder id, default claude. Unknown names fail typed. */
63
+ export const reasonerFromEnvironment = (environment) => {
64
+ const requested = (environment.LLM4TS_REASONER ?? "").trim();
65
+ if (requested.length === 0) {
66
+ return Effect.succeed(claude);
67
+ }
68
+ const preset = coderFor(requested);
69
+ return preset === undefined
70
+ ? ScriptUsage.make({
71
+ message: `unknown LLM4TS_REASONER '${requested}'; expected ${coderIds.join("|")}`
72
+ })
73
+ : Effect.succeed(preset);
74
+ };
75
+ /** The coder seat: `LLM4TS_CODER`, default pi — the cheap local typist under a stronger orchestrator. */
76
+ export const storyCoderFromEnvironment = (environment) => {
77
+ const requested = (environment.LLM4TS_CODER ?? "").trim();
78
+ if (requested.length === 0) {
79
+ return Effect.succeed(pi);
80
+ }
81
+ const preset = coderFor(requested);
82
+ return preset === undefined
83
+ ? ScriptUsage.make({
84
+ message: `unknown LLM4TS_CODER '${requested}'; expected ${coderIds.join("|")}`
85
+ })
86
+ : Effect.succeed(preset);
87
+ };
88
+ // ---- Story plan generation -----------------------------------------------------
89
+ /** A readable, stable epic id: the first words of the epic plus a content hash. */
90
+ export const epicIdFor = (epic) => {
91
+ const slug = epic
92
+ .toLowerCase()
93
+ .replace(/[^a-z0-9]+/g, "-")
94
+ .replace(/^-+|-+$/g, "")
95
+ .split("-")
96
+ .filter((part) => part.length > 0)
97
+ .slice(0, 4)
98
+ .join("-");
99
+ return `${slug.length === 0 ? "epic" : slug}-${stableHash(epic).slice(0, 6)}`;
100
+ };
101
+ export const storyPlanJsonSchema = {
102
+ type: "object",
103
+ properties: {
104
+ epicId: { type: "string" },
105
+ epic: { type: "string" },
106
+ stories: {
107
+ type: "array",
108
+ items: {
109
+ type: "object",
110
+ properties: {
111
+ id: { type: "string" },
112
+ title: { type: "string" },
113
+ description: { type: "string" },
114
+ dependsOn: { type: "array", items: { type: "string" } },
115
+ owned: { type: "array", items: { type: "string" } },
116
+ sharedReadOnly: { type: "array", items: { type: "string" } },
117
+ provides: { type: "array", items: { type: "string" } }
118
+ },
119
+ required: ["id", "title", "description", "dependsOn", "owned", "sharedReadOnly", "provides"]
120
+ }
121
+ }
122
+ },
123
+ required: ["epicId", "epic", "stories"]
124
+ };
125
+ /** The generator's hard constraints — the perimeter rules the executor will enforce. */
126
+ export const storyPlanInstructions = (epicId, guidance) => [
127
+ "You are the orchestrator of a parallel implementation. Split the epic below into stories",
128
+ "that independent coding agents will implement AT THE SAME TIME, each in its own git worktree,",
129
+ "each confined to the paths it owns. Dependencies are declared here and never discovered later.",
130
+ "",
131
+ "Rules (violations are rejected mechanically):",
132
+ "- Every story has a kebab-case id, a title, a description precise enough to implement alone,",
133
+ " `dependsOn` (ids it must wait for), `owned` (repo-relative path prefixes it may create or",
134
+ " change), `sharedReadOnly` (prefixes it may read but never change), and `provides` (routes,",
135
+ " exports, contracts other stories may rely on).",
136
+ "- `owned` sets are pairwise DISJOINT: no path prefix appears under two stories.",
137
+ "- Shared surfaces (the kit, the theme, house rules) are never edited by a feature story. A new",
138
+ " shared component is its own story, and every story using it depends on it.",
139
+ "- Every new service domain is its own contract story (contract + fake routes) that the pages",
140
+ " depend on.",
141
+ "- Exactly ONE story owns the composition point (`src/App.tsx`): the fan-in that depends on",
142
+ " every screen story and wires them in.",
143
+ "- A story fits one agent session: one screen, one contract, or one component.",
144
+ "- Every story OWNS the test files it must write (the judge asks for tests): list them",
145
+ " in `owned` explicitly — a story cannot add a test outside its owned paths.",
146
+ `- Use exactly this epicId: "${epicId}". Copy the epic text into "epic".`,
147
+ "",
148
+ "Respond only with JSON:",
149
+ '{"epicId":"...","epic":"...","stories":[{"id":"...","title":"...","description":"...",',
150
+ '"dependsOn":[],"owned":[],"sharedReadOnly":[],"provides":[]}]}',
151
+ "",
152
+ "Target repository guidance (house rules and layout — the vocabulary to use):",
153
+ guidance
154
+ ].join("\n");
155
+ export const generateStoryPlan = (reasoning, events, epic, epicId, guidance) => structuredAndPublish(reasoning, events, `${storyPlanInstructions(epicId, guidance)}\n\nEpic:\n${epic}`, StoryPlan, storyPlanJsonSchema).pipe(
156
+ // The id and the epic text are ours, whatever the model echoed back.
157
+ Effect.map((plan) => StoryPlan.make({ ...plan, epicId, epic })));
158
+ // ---- Usage ---------------------------------------------------------------------
159
+ /** Sums two meters into one per-story estimate. */
160
+ export const combineTotals = (first, second) => Effect.map(Effect.all([first, second]), ([left, right]) => {
161
+ if (left === undefined)
162
+ return right;
163
+ if (right === undefined)
164
+ return left;
165
+ const cost = [left.costUsd, right.costUsd].flatMap((value) => value === undefined ? [] : [value]);
166
+ return TokenUsage.make({
167
+ prompt: left.prompt + right.prompt,
168
+ completion: left.completion + right.completion,
169
+ total: left.total + right.total,
170
+ ...(cost.length === 0 ? {} : { costUsd: cost.reduce((sum, value) => sum + value, 0) })
171
+ });
172
+ });
173
+ // ---- Judge ---------------------------------------------------------------------
174
+ export const storyDimensions = [
175
+ Dimension.make({
176
+ name: "provides",
177
+ 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."
178
+ }),
179
+ Dimension.make({
180
+ name: "scope",
181
+ 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."
182
+ }),
183
+ Dimension.make({
184
+ name: "house-style",
185
+ 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."
186
+ }),
187
+ Dimension.make({
188
+ name: "tests",
189
+ rubric: "The story ships deterministic tests in the house style covering its screens or contract. 2 = yes; 1 = thin; 0 = none."
190
+ })
191
+ ];
192
+ const subBar = (scored, story) => ReviewResult.make({
193
+ issues: scored.scores
194
+ .filter((score) => score.score <
195
+ (storyDimensions.find((dimension) => dimension.name === score.name)?.maxScore ?? 2))
196
+ .map((score) => ReviewIssue.make({
197
+ severity: "Critical",
198
+ title: `judge[${story.id}]: ${score.name} scored ${score.score}`,
199
+ description: score.reasoning
200
+ })),
201
+ summary: `judge:${story.id}`
202
+ });
203
+ /** The story-level judge over the branch diff, bounded by the character budget. */
204
+ export const judgeStory = (reasoning, story, diff, budget) => judge(reasoning, storyDimensions)
205
+ .evaluate(Sample.make({
206
+ query: [
207
+ `Story: ${story.title}`,
208
+ story.description,
209
+ "",
210
+ `Provides: ${story.provides.join(", ") || "(none)"}`,
211
+ `Owned paths: ${story.owned.join(", ")}`,
212
+ `Shared read-only: ${story.sharedReadOnly.join(", ") || "(none)"}`
213
+ ].join("\n"),
214
+ response: cap(diff, budget).text
215
+ }))
216
+ .pipe(Effect.mapError(FlowLlmError.from), Effect.map((scored) => subBar(scored, story)));
217
+ // ---- Worktree setup --------------------------------------------------------------
218
+ export const defaultWorktreeSetup = ["pnpm", "install", "--offline"];
219
+ /**
220
+ * `LLM4TS_WORKTREE_SETUP="pnpm install --offline"` (the default) prepares
221
+ * each story worktree; an empty value disables the step. Offline by default:
222
+ * the runbook warms the pnpm store once, and a workshop stage has no network.
223
+ */
224
+ export const worktreeSetupCommand = (environment) => {
225
+ const raw = environment.LLM4TS_WORKTREE_SETUP;
226
+ if (raw === undefined) {
227
+ return defaultWorktreeSetup;
228
+ }
229
+ const parts = raw
230
+ .trim()
231
+ .split(/\s+/)
232
+ .filter((part) => part.length > 0);
233
+ return parts.length === 0 ? undefined : parts;
234
+ };
235
+ /** Runs the setup command in a worktree; a non-zero exit fails the story with the output. */
236
+ export const setupIn = (process, events, command) => (workDir) => Effect.flatMap(lintCommand(process, events, command, workDir), (result) => result.isClean
237
+ ? Effect.void
238
+ : FlowAborted.make({
239
+ message: `worktree setup failed (${command.join(" ")}):\n${result.issues
240
+ .map((issue) => issue.description)
241
+ .join("\n")}`
242
+ }));
243
+ // ---- Gates ---------------------------------------------------------------------
244
+ export const defaultGateCommands = [
245
+ ["pnpm", "typecheck"],
246
+ ["pnpm", "lint"],
247
+ ["pnpm", "test"],
248
+ ["pnpm", "build"]
249
+ ];
250
+ /** `LLM4TS_GATES="pnpm typecheck;pnpm test"` overrides the four defaults. */
251
+ export const gateCommands = (environment) => {
252
+ const raw = environment.LLM4TS_GATES?.trim();
253
+ if (raw === undefined || raw.length === 0) {
254
+ return defaultGateCommands;
255
+ }
256
+ return raw
257
+ .split(";")
258
+ .map((command) => command
259
+ .trim()
260
+ .split(/\s+/)
261
+ .filter((part) => part.length > 0))
262
+ .filter((command) => command.length > 0);
263
+ };
264
+ /** Runs the gates in a directory, stopping at the first red one (later output would be noise). */
265
+ export const gatesIn = (process, events, commands) => (workDir) => Effect.gen(function* () {
266
+ const results = [];
267
+ for (const command of commands) {
268
+ const result = yield* lintCommand(process, events, command, workDir);
269
+ results.push(result);
270
+ if (!result.isClean) {
271
+ break;
272
+ }
273
+ }
274
+ return mergeReviewResults(results);
275
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llm4ts/shell",
3
- "version": "0.15.1",
3
+ "version": "0.16.1",
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
- "@llm4ts/modernize": "0.15.1",
53
- "@llm4ts/runner": "0.15.1",
54
- "@llm4ts/core": "0.15.1",
55
- "@llm4ts/flow": "0.15.1"
52
+ "@effect/platform-node-shared": "4.0.0-beta.102",
53
+ "@llm4ts/core": "0.16.1",
54
+ "@llm4ts/modernize": "0.16.1",
55
+ "@llm4ts/flow": "0.16.1",
56
+ "@llm4ts/runner": "0.16.1"
56
57
  },
57
58
  "peerDependencies": {
58
59
  "effect": "4.0.0-beta.102"