@llm4ts/shell 2.15.0 → 2.17.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/dist/Cli.d.ts +1 -1
- package/dist/Cli.d.ts.map +1 -1
- package/flows/epic-design.js +93 -0
- package/flows/fixtures/epic-design/legacy/docs/modernization/README.md +6 -0
- package/flows/fixtures/epic-design/legacy/docs/modernization/features/conto_movimenti.feature +16 -0
- package/flows/fixtures/epic-design/legacy/docs/modernization/features/conto_saldo.feature +10 -0
- package/flows/fixtures/epic-design/legacy/docs/modernization/specs/CONTO_MOVIMENTI.md +8 -0
- package/flows/fixtures/epic-design/legacy/docs/modernization/specs/CONTO_SALDO.md +8 -0
- package/flows/fixtures/epic-design/target/CONTRIBUTING.md +5 -0
- package/flows/fixtures/epic-design/target/src/kit/session/SessionGuard.tsx +2 -0
- package/flows/lib/epic-design.js +341 -0
- package/flows/lib/epic-stories.js +92 -4
- package/kits/soap-ace/README.md +50 -9
- package/kits/soap-ace/fixtures/demo-bank-soap/RUNBOOK.md +16 -0
- package/kits/soap-ace/flows/lib/soap/Call.js +10 -6
- package/kits/soap-ace/flows/lib/soap/Explore.js +476 -0
- package/kits/soap-ace/flows/soap-explore.js +190 -0
- package/package.json +4 -4
|
@@ -10,7 +10,9 @@ import { Dimension, Sample } from "@llm4ts/core/eval/Eval";
|
|
|
10
10
|
import { judge } from "@llm4ts/core/eval/Judge";
|
|
11
11
|
import { TokenUsage } from "@llm4ts/core/Models";
|
|
12
12
|
import { cap } from "@llm4ts/flow/Context";
|
|
13
|
+
import { parseEpicBrief, renderEpicBrief, unanswered } from "@llm4ts/flow/EpicBrief";
|
|
13
14
|
import { structuredAndPublish } from "@llm4ts/flow/Flow";
|
|
15
|
+
import { EpicBriefInvalid, EpicBriefNotApproved } from "@llm4ts/flow/FlowError";
|
|
14
16
|
import { Info } from "@llm4ts/flow/FlowEvents";
|
|
15
17
|
import { EpicLanded, EpicLandedVersion, landedPath } from "@llm4ts/flow/Landing";
|
|
16
18
|
import { loadVersioned } from "@llm4ts/flow/Persistence";
|
|
@@ -167,6 +169,53 @@ export const renderEpicList = (epics) => epics.length === 0
|
|
|
167
169
|
` "${epic.epic.length > 110 ? `${epic.epic.slice(0, 109)}…` : epic.epic}"`
|
|
168
170
|
])
|
|
169
171
|
].join("\n");
|
|
172
|
+
/** The names of the epic folders of a repository. */
|
|
173
|
+
export const epicDirs = (workDir) => Effect.tryPromise(() => readdir(epicsDir(workDir), { withFileTypes: true })).pipe(Effect.map((entries) => entries
|
|
174
|
+
.filter((entry) => entry.isDirectory())
|
|
175
|
+
.map((entry) => entry.name)
|
|
176
|
+
.sort()), Effect.catch(() => Effect.succeed([])));
|
|
177
|
+
/** The briefs among `dirs`; a folder without one, or with one that does not parse, is skipped. */
|
|
178
|
+
export const listBriefs = (files, workDir, dirs) => Effect.gen(function* () {
|
|
179
|
+
const briefs = [];
|
|
180
|
+
for (const dir of dirs) {
|
|
181
|
+
const text = yield* files.read(join(epicsDir(workDir), dir, "brief.md"));
|
|
182
|
+
if (text === undefined)
|
|
183
|
+
continue;
|
|
184
|
+
const brief = yield* parseEpicBrief(text).pipe(Effect.catch(() => Effect.succeed(undefined)));
|
|
185
|
+
if (brief !== undefined)
|
|
186
|
+
briefs.push({ dir, request: brief.request, status: brief.status });
|
|
187
|
+
}
|
|
188
|
+
return briefs;
|
|
189
|
+
});
|
|
190
|
+
/**
|
|
191
|
+
* What the story planner reads in place of the one-line epic: the approved
|
|
192
|
+
* brief in the epic's folder. Undefined when the plan is already written (the
|
|
193
|
+
* brief has done its job) or there is no brief. A draft, or an approved brief
|
|
194
|
+
* with an unanswered open point, stops the run: planning from the bare
|
|
195
|
+
* sentence beside an unfinished brief would silently ignore it.
|
|
196
|
+
*/
|
|
197
|
+
export const plannerInput = (files, stateDir, planPath) => Effect.gen(function* () {
|
|
198
|
+
if ((yield* files.read(planPath)) !== undefined)
|
|
199
|
+
return undefined;
|
|
200
|
+
const path = join(stateDir, "brief.md");
|
|
201
|
+
const text = yield* files.read(path);
|
|
202
|
+
if (text === undefined)
|
|
203
|
+
return undefined;
|
|
204
|
+
const brief = yield* parseEpicBrief(text, path);
|
|
205
|
+
if (brief.status !== "approved") {
|
|
206
|
+
return yield* EpicBriefNotApproved.make({ path });
|
|
207
|
+
}
|
|
208
|
+
const pending = unanswered(brief);
|
|
209
|
+
if (pending.length > 0) {
|
|
210
|
+
return yield* EpicBriefInvalid.make({
|
|
211
|
+
path,
|
|
212
|
+
violations: [
|
|
213
|
+
`the brief is approved with unanswered open points: ${pending.map((point) => point.number).join(", ")}`
|
|
214
|
+
]
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
return renderEpicBrief(brief);
|
|
218
|
+
});
|
|
170
219
|
/**
|
|
171
220
|
* The epic text given wins (a new epic, or the existing one with that exact
|
|
172
221
|
* text); `--epic` picks an existing one; with neither, the one epic not yet
|
|
@@ -176,6 +225,10 @@ export const renderEpicList = (epics) => epics.length === 0
|
|
|
176
225
|
export const chooseEpic = (args) => {
|
|
177
226
|
if (args.epic !== undefined) {
|
|
178
227
|
const found = args.epics.find((epic) => epic.dir === args.epic || epic.epicId === args.epic);
|
|
228
|
+
const brief = (args.briefs ?? []).find((candidate) => candidate.dir === args.epic);
|
|
229
|
+
if (found === undefined && brief !== undefined) {
|
|
230
|
+
return Effect.succeed({ _tag: "Brief", dir: brief.dir, request: brief.request });
|
|
231
|
+
}
|
|
179
232
|
return found === undefined
|
|
180
233
|
? Effect.fail(ScriptUsage.make({
|
|
181
234
|
message: `no epic '${args.epic}' in this repository\n${renderEpicList(args.epics)}`
|
|
@@ -186,6 +239,23 @@ export const chooseEpic = (args) => {
|
|
|
186
239
|
return Effect.succeed({ _tag: "Text", prompt: args.text });
|
|
187
240
|
}
|
|
188
241
|
const open = args.epics.filter((epic) => epic.landed === undefined);
|
|
242
|
+
// A folder `epic-design` wrote a brief into and nobody planned yet is an open
|
|
243
|
+
// epic too: alone it is the one to run, beside others it is never guessed.
|
|
244
|
+
const planned = new Set(args.epics.map((epic) => epic.dir));
|
|
245
|
+
const briefOnly = (args.briefs ?? []).filter((brief) => !planned.has(brief.dir));
|
|
246
|
+
const [onlyBrief] = briefOnly;
|
|
247
|
+
if (briefOnly.length === 1 && open.length === 0 && onlyBrief !== undefined) {
|
|
248
|
+
return Effect.succeed({ _tag: "Brief", dir: onlyBrief.dir, request: onlyBrief.request });
|
|
249
|
+
}
|
|
250
|
+
if (briefOnly.length > 0) {
|
|
251
|
+
return Effect.fail(ScriptUsage.make({
|
|
252
|
+
message: [
|
|
253
|
+
"several epics are open here; pick one with --epic <id>, or give an epic's text",
|
|
254
|
+
...(open.length === 0 ? [] : [renderEpicList(open)]),
|
|
255
|
+
...briefOnly.map((brief) => `${brief.dir} brief ${brief.status} ${brief.request}`)
|
|
256
|
+
].join("\n")
|
|
257
|
+
}));
|
|
258
|
+
}
|
|
189
259
|
if (open.length === 1 && open[0] !== undefined) {
|
|
190
260
|
return Effect.succeed({ _tag: "Existing", epic: open[0] });
|
|
191
261
|
}
|
|
@@ -470,7 +540,9 @@ export const storyPlanInstructions = (epicId, guidance) => [
|
|
|
470
540
|
"Target repository guidance (house rules and layout — the vocabulary to use):",
|
|
471
541
|
guidance
|
|
472
542
|
].join("\n");
|
|
473
|
-
export const generateStoryPlan = (reasoning, events, epic, epicId, guidance
|
|
543
|
+
export const generateStoryPlan = (reasoning, events, epic, epicId, guidance,
|
|
544
|
+
/** An approved epic brief (`epic-design`): what the planner reads in place of the sentence. */
|
|
545
|
+
brief) => structuredAndPublish(reasoning, events, `${storyPlanInstructions(epicId, guidance)}\n\nEpic:\n${brief ?? epic}`, StoryPlan, storyPlanJsonSchema).pipe(
|
|
474
546
|
// The id and the epic text are ours, whatever the model echoed back.
|
|
475
547
|
Effect.map((plan) => StoryPlan.make({ ...plan, epicId, epic })));
|
|
476
548
|
// ---- Usage ---------------------------------------------------------------------
|
|
@@ -719,11 +791,16 @@ export const runEpicStories = (options) => Effect.gen(function* () {
|
|
|
719
791
|
text: given.prompt,
|
|
720
792
|
epic: flags.epic,
|
|
721
793
|
epics,
|
|
794
|
+
briefs: yield* listBriefs(files, given.workDir, yield* epicDirs(given.workDir)),
|
|
722
795
|
defaultEpic
|
|
723
796
|
});
|
|
724
797
|
const input = {
|
|
725
798
|
...given,
|
|
726
|
-
prompt: choice._tag === "Existing"
|
|
799
|
+
prompt: choice._tag === "Existing"
|
|
800
|
+
? choice.epic.epic
|
|
801
|
+
: choice._tag === "Brief"
|
|
802
|
+
? choice.request
|
|
803
|
+
: choice.prompt
|
|
727
804
|
};
|
|
728
805
|
const coderFlags = flagsFromEnvironment(process.env.LLM4TS_CODER_FLAGS);
|
|
729
806
|
const reasoning = withExtraFlags(withOptionalModel(yield* reasonerFromEnvironment(process.env), process.env.LLM4TS_REASONING_MODEL), flagsFromEnvironment(process.env.LLM4TS_REASONING_FLAGS));
|
|
@@ -731,7 +808,11 @@ export const runEpicStories = (options) => Effect.gen(function* () {
|
|
|
731
808
|
const localServer = localCoderServer(process.env.LLM4TS_CODER_MODEL, coderFlags, process.env);
|
|
732
809
|
// A new epic's id (and state folder) is derived from its text; an existing
|
|
733
810
|
// one keeps its folder, whatever text is on the command line.
|
|
734
|
-
const epicId = choice._tag === "Existing"
|
|
811
|
+
const epicId = choice._tag === "Existing"
|
|
812
|
+
? choice.epic.dir
|
|
813
|
+
: choice._tag === "Brief"
|
|
814
|
+
? choice.dir
|
|
815
|
+
: epicIdFor(input.prompt);
|
|
735
816
|
const stateDir = join(epicsDir(input.workDir), epicId);
|
|
736
817
|
const planPath = join(stateDir, "plan.md");
|
|
737
818
|
const estimateOptions = estimatedUsageOptionsFromEnv(process.env);
|
|
@@ -750,9 +831,16 @@ export const runEpicStories = (options) => Effect.gen(function* () {
|
|
|
750
831
|
const events = context.events;
|
|
751
832
|
const reasoningMeter = yield* makeEstimatedUsageMeter(context.reasoning, estimateOptions);
|
|
752
833
|
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);
|
|
834
|
+
// An approved epic brief in the epic's folder is what the planner reads.
|
|
835
|
+
const brief = yield* plannerInput(files, stateDir, planPath);
|
|
836
|
+
if (brief !== undefined) {
|
|
837
|
+
yield* events.publish(Info.make({
|
|
838
|
+
message: `planning from the approved brief at ${join(stateDir, "brief.md")}`
|
|
839
|
+
}));
|
|
840
|
+
}
|
|
753
841
|
const store = makeStoryPlanStore(files);
|
|
754
842
|
const plan = yield* stage(events, "story plan", store
|
|
755
|
-
.recoverOrCreate(planPath, generateStoryPlan(reasoningMeter.service, events, input.prompt, epicId, guidance))
|
|
843
|
+
.recoverOrCreate(planPath, generateStoryPlan(reasoningMeter.service, events, input.prompt, epicId, guidance, brief))
|
|
756
844
|
.pipe(Effect.flatMap(validateStoryPlan)));
|
|
757
845
|
yield* events.publish(Info.make({
|
|
758
846
|
message: `story plan: ${plan.stories.length} stories at ${planPath} (edit and rerun to re-plan)`
|
package/kits/soap-ace/README.md
CHANGED
|
@@ -14,15 +14,56 @@ source) and shares nothing with this kit.
|
|
|
14
14
|
|
|
15
15
|
## Status
|
|
16
16
|
|
|
17
|
-
| Piece
|
|
18
|
-
|
|
|
19
|
-
| Strict XML reader
|
|
20
|
-
|
|
|
21
|
-
|
|
|
22
|
-
|
|
|
23
|
-
|
|
|
24
|
-
|
|
|
25
|
-
| `
|
|
17
|
+
| Piece | State |
|
|
18
|
+
| ------------------------------------------------------------------------ | ----- |
|
|
19
|
+
| Strict XML reader, WSDL 1.1/2.0 + XSD catalog | done |
|
|
20
|
+
| `soap-discover` flow and operation classification | done |
|
|
21
|
+
| Masking, auth profile, transport, sample authoring | done |
|
|
22
|
+
| Analysis, REST design, OpenAPI projection | done |
|
|
23
|
+
| `ace12-rest` pack, scaffold, `soap-epic` | done |
|
|
24
|
+
| `soap-explore`: the discovery half in one command | done |
|
|
25
|
+
| `demo-bank-soap` fixture ([runbook](fixtures/demo-bank-soap/RUNBOOK.md)) | done |
|
|
26
|
+
|
|
27
|
+
## Quick start: soap-explore
|
|
28
|
+
|
|
29
|
+
One command from a WSDL to a design draft, the way SoapUI turns a WSDL into
|
|
30
|
+
a project. It stops only where a person decides, and a rerun continues where
|
|
31
|
+
it stopped:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
llm4ts run soap-explore --repo ~/work/conti-analysis ./wsdl/ContiService.wsdl # or an https URL
|
|
35
|
+
# write .llm4ts/soap/<service>/auth.json: at least {"environment": "test"}
|
|
36
|
+
# review operations.md, set Status: confirmed
|
|
37
|
+
llm4ts run soap-explore --repo ~/work/conti-analysis # continue
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
| Step | What happens |
|
|
41
|
+
| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
42
|
+
| discover | `catalog.json`/`.md`, `operations.md` with proposed classes (as `soap-discover`) |
|
|
43
|
+
| requests | `samples/<op>/happy-path.request.yaml` for **every** operation, example values from the XSD |
|
|
44
|
+
| probe | calls the **read** operations once, producers first: the IBAN `cercaConti` returns is what `dettaglioConto` is called with. Live values stay in memory; the request file on disk carries the masked one. Mutating: never called |
|
|
45
|
+
| evidence | `analysis/<op>.md`, `design/mapping.json`/`.md` |
|
|
46
|
+
| design | the reasoning seat drafts `design/api-design.md`, `check`, one `revise` round when errors remain; an existing design is only checked; `--no-design` skips |
|
|
47
|
+
| report | `explore.md`: per operation the probe decision and result, the chained values, the design state, and the next command for every gap |
|
|
48
|
+
|
|
49
|
+
**Who may be called.** Without `auth.json` nothing is called (explore never
|
|
50
|
+
writes it: the environment label is yours to declare). With `operations.md`
|
|
51
|
+
confirmed, its `read` operations are called. Before confirmation, an
|
|
52
|
+
operation is called only in dev or test, and only when the file still lists
|
|
53
|
+
it as `read`, the name heuristic says `read`, and the judgment seat
|
|
54
|
+
(`LLM4TS_JUDGMENT_PROVIDER`) answers `read` with an `act` decision; without
|
|
55
|
+
a judgment seat, confirm first. uat always needs confirmation.
|
|
56
|
+
|
|
57
|
+
**Files it owns.** A request explore writes starts with a `# soap-explore:
|
|
58
|
+
generated` line and is regenerated when probed again. Delete that line to
|
|
59
|
+
own the file: explore then sends it as written and never touches it.
|
|
60
|
+
`--refresh` calls the probed operations again; everything else is kept.
|
|
61
|
+
|
|
62
|
+
Approvals stay human: `Status: confirmed` in `operations.md`,
|
|
63
|
+
`Status: approved` in `api-design.md`. Then `soap-design "openapi"` and
|
|
64
|
+
`soap-epic` as below. The individual flows remain for everything past the
|
|
65
|
+
first pass: more scenarios per operation (`soap-sample "propose <op>"`),
|
|
66
|
+
mutating calls, SoapUI import, design revisions.
|
|
26
67
|
|
|
27
68
|
## soap-discover
|
|
28
69
|
|
|
@@ -16,6 +16,22 @@ cd ~/work/conti-analysis
|
|
|
16
16
|
The analysis repository holds `.llm4ts/soap/<service>/`: catalog, samples,
|
|
17
17
|
analysis, design. The ACE repository receives the contracts and the code.
|
|
18
18
|
|
|
19
|
+
## Fast path: soap-explore
|
|
20
|
+
|
|
21
|
+
Steps 1 to 4 in one command, rerun after each decision:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
llm4ts run soap-explore --repo . ./wsdl/ContiService.wsdl # discover, requests, report
|
|
25
|
+
# write auth.json (step 2), review and confirm operations.md
|
|
26
|
+
llm4ts run soap-explore --repo . # probe reads, analyse, draft the design
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Read `.llm4ts/soap/<service>/explore.md` after each run: it lists what was
|
|
30
|
+
called, what was not and why, and the next command. On the fixture:
|
|
31
|
+
`LLM4TS_SOAP_STUB=<kit>/fixtures/demo-bank-soap/responses`. The steps below
|
|
32
|
+
are the same work one command at a time, and what to use past the first
|
|
33
|
+
pass.
|
|
34
|
+
|
|
19
35
|
## 1. Discover
|
|
20
36
|
|
|
21
37
|
```bash
|
|
@@ -69,7 +69,7 @@ export const callOperation = (options) => Effect.gen(function* () {
|
|
|
69
69
|
detail: `no auth profile; write ${`.llm4ts/soap/${service}/auth.json`} with at least {"environment": "dev"}`
|
|
70
70
|
});
|
|
71
71
|
}
|
|
72
|
-
const request = yield* readRequestFile(workspace, catalog, service, operation.name, name);
|
|
72
|
+
const request = options.request ?? (yield* readRequestFile(workspace, catalog, service, operation.name, name));
|
|
73
73
|
const issues = [
|
|
74
74
|
...request.readIssues,
|
|
75
75
|
...validateInstance(catalog, input, request.body)
|
|
@@ -130,6 +130,7 @@ export const callOperation = (options) => Effect.gen(function* () {
|
|
|
130
130
|
// which quote values) is read from the masked, security-stripped tree:
|
|
131
131
|
// nothing unmasked reaches the exchange.
|
|
132
132
|
let fault;
|
|
133
|
+
let payload;
|
|
133
134
|
if (parsedResponse._tag === "None") {
|
|
134
135
|
responseIssues.push({
|
|
135
136
|
path: "(response)",
|
|
@@ -148,11 +149,14 @@ export const callOperation = (options) => Effect.gen(function* () {
|
|
|
148
149
|
}
|
|
149
150
|
else if (operation.output !== undefined) {
|
|
150
151
|
const output = elementByName(catalog, operation.output);
|
|
151
|
-
const
|
|
152
|
-
if (output !== undefined &&
|
|
153
|
-
responseIssues.push(...instanceFromXml(catalog, output,
|
|
152
|
+
const masked = read.value.payload;
|
|
153
|
+
if (output !== undefined && masked !== undefined) {
|
|
154
|
+
responseIssues.push(...instanceFromXml(catalog, output, masked).issues);
|
|
155
|
+
const live = yield* readEnvelope(stripSecurity(parsedResponse.value)).pipe(Effect.option);
|
|
156
|
+
if (live._tag === "Some" && live.value.payload !== undefined)
|
|
157
|
+
payload = { live: live.value.payload, masked };
|
|
154
158
|
}
|
|
155
|
-
else if (
|
|
159
|
+
else if (masked === undefined) {
|
|
156
160
|
responseIssues.push({ path: "(response)", detail: "empty Body" });
|
|
157
161
|
}
|
|
158
162
|
}
|
|
@@ -182,5 +186,5 @@ export const callOperation = (options) => Effect.gen(function* () {
|
|
|
182
186
|
masking: mergeReports([maskedRequest.report, maskedResponse.report])
|
|
183
187
|
});
|
|
184
188
|
const path = yield* writeExchange(workspace, service, exchange);
|
|
185
|
-
return { exchange, path, permission };
|
|
189
|
+
return { exchange, path, permission, ...(payload === undefined ? {} : { payload }) };
|
|
186
190
|
});
|