@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.
@@ -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) => structuredAndPublish(reasoning, events, `${storyPlanInstructions(epicId, guidance)}\n\nEpic:\n${epic}`, StoryPlan, storyPlanJsonSchema).pipe(
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" ? choice.epic.epic : choice.prompt
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" ? choice.epic.dir : epicIdFor(input.prompt);
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)`
@@ -14,15 +14,56 @@ source) and shares nothing with this kit.
14
14
 
15
15
  ## Status
16
16
 
17
- | Piece | State |
18
- | -------------------------------------------------- | -------- |
19
- | Strict XML reader (`flows/lib/soap/Xml.ts`) | done |
20
- | WSDL 1.1/2.0 + XSD catalog (`Wsdl.ts`, `Xsd.ts`) | done |
21
- | `demo-bank-soap` fixture | WSDL/XSD |
22
- | `soap-discover` flow and operation classification | pending |
23
- | Masking, auth profile, transport, sample authoring | pending |
24
- | Analysis, REST design, OpenAPI projection | pending |
25
- | `ace12-rest` pack, scaffold, `soap-epic` | pending |
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 payload = read.value.payload;
152
- if (output !== undefined && payload !== undefined) {
153
- responseIssues.push(...instanceFromXml(catalog, output, payload).issues);
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 (payload === undefined) {
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
  });