@llm4ts/shell 2.7.0 → 2.8.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.
@@ -9,31 +9,40 @@
9
9
  // their models (pi: "provider/model"). The story plan is persisted under
10
10
  // .llm4ts/epics/<epic-id>/plan.md BEFORE any coder runs, and an existing
11
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).
12
+ // path. Stories run in worktrees BESIDE the repository (<repo>.worktrees/
13
+ // <epic-id>/<story-id>, LLM4TS_WORKTREE_ROOT to move them) under
14
+ // --concurrency (default 3); a failed story puts its dependents on hold
15
+ // (--fail-fast stops instead). The epic branch is left in place; the board
16
+ // and the report under .llm4ts/epics/<epic-id>/ carry ESTIMATED usage
17
+ // figures (ADR 0013).
16
18
  import { join } from "node:path";
17
19
  import * as Effect from "effect/Effect";
20
+ import { CliConnectorConfig } from "@llm4ts/core/ConnectorConfig";
18
21
  import { budget, cap } from "@llm4ts/flow/Context";
19
22
  import { makeLocalBoardSync } from "@llm4ts/flow/BoardSync";
20
23
  import { estimatedUsageOptionsFromEnv, makeEstimatedUsageMeter } from "@llm4ts/flow/EstimatedUsage";
21
24
  import { FlowAborted, Info, asReadOnly, nodePlainFileStore, nodeProcessExecutor, resolveFlowInput, runFlowMain, runNode, stage, withModel } from "@llm4ts/runner";
22
25
  import { implementStoriesFlow } from "@llm4ts/flow/Stories";
23
26
  import { makeStoryPlanStore, validateStoryPlan } from "@llm4ts/flow/StoryPlan";
24
- import { combineTotals, epicIdFor, gateCommands, gatesIn, generateStoryPlan, judgeStory, parseEpicArgs, reasonerFromEnvironment, setupIn, storyCoderFromEnvironment, worktreeSetupCommand } from "./lib/epic-stories.js";
27
+ import { awaitServer, combineTotals, epicIdFor, flagsFromEnvironment, gateCommands, gatesIn, generateStoryPlan, httpProbe, judgeStory, localCoderServer, parseEpicArgs, reasonerFromEnvironment, serverHealthUrl, setupIn, storyCoderFromEnvironment, verifyBlockedOn, worktreeRootFor, worktreeSetupCommand } from "./lib/epic-stories.js";
25
28
  /** `LLM4TS_CODER_MODEL` / `LLM4TS_REASONING_MODEL`: pi takes `provider/model` (e.g. `openai-codex/gpt-5.5`). */
26
29
  const withOptionalModel = (config, model) => {
27
30
  const trimmed = model?.trim();
28
31
  return trimmed === undefined || trimmed.length === 0 ? config : withModel(config, trimmed);
29
32
  };
33
+ /** Extra CLI flags on top of a preset's own (`LLM4TS_CODER_FLAGS`). */
34
+ const withExtraFlags = (config, flags) => Object.keys(flags).length === 0
35
+ ? config
36
+ : CliConnectorConfig.make({ ...config, flags: { ...config.flags, ...flags } });
30
37
  const defaultEpic = "Add the retail customer's current account (Conto) with balance and movements, and wire " +
31
38
  "transfers (Bonifico) with beneficiary, review, SCA confirmation, and history.";
32
39
  const program = Effect.gen(function* () {
33
40
  const flags = yield* parseEpicArgs(process.argv.slice(2));
34
41
  const input = yield* resolveFlowInput(defaultEpic, flags.rest);
35
- const reasoning = withOptionalModel(yield* reasonerFromEnvironment(process.env), process.env.LLM4TS_REASONING_MODEL);
36
- const coder = withOptionalModel(yield* storyCoderFromEnvironment(process.env), process.env.LLM4TS_CODER_MODEL);
42
+ const coderFlags = flagsFromEnvironment(process.env.LLM4TS_CODER_FLAGS);
43
+ const reasoning = withExtraFlags(withOptionalModel(yield* reasonerFromEnvironment(process.env), process.env.LLM4TS_REASONING_MODEL), flagsFromEnvironment(process.env.LLM4TS_REASONING_FLAGS));
44
+ const coder = withExtraFlags(withOptionalModel(yield* storyCoderFromEnvironment(process.env), process.env.LLM4TS_CODER_MODEL), coderFlags);
45
+ const localServer = localCoderServer(process.env.LLM4TS_CODER_MODEL, coderFlags, process.env);
37
46
  const files = nodePlainFileStore;
38
47
  const epicId = epicIdFor(input.prompt);
39
48
  const stateDir = join(input.workDir, ".llm4ts", "epics", epicId);
@@ -62,6 +71,15 @@ const program = Effect.gen(function* () {
62
71
  if (flags.planOnly) {
63
72
  return;
64
73
  }
74
+ const concurrency = flags.concurrency ?? 3;
75
+ if (localServer !== undefined && concurrency > 1) {
76
+ yield* events.publish(Info.make({
77
+ message: `⚠ the coder is served by a local single-model server (${localServer}): it generates one ` +
78
+ `reply at a time, so ${concurrency} parallel coders queue behind each other and a queued ` +
79
+ 'request is cut off after a few minutes ("terminated"). Prefer --concurrency 1, or give ' +
80
+ "the server parallel slots."
81
+ }));
82
+ }
65
83
  const contextFor = context.contextFor;
66
84
  if (contextFor === undefined) {
67
85
  return yield* FlowAborted.make({
@@ -70,11 +88,12 @@ const program = Effect.gen(function* () {
70
88
  }
71
89
  const gates = gatesIn(nodeProcessExecutor, events, gateCommands(process.env));
72
90
  const setupCommand = worktreeSetupCommand(process.env);
91
+ const healthUrl = serverHealthUrl(localServer, process.env);
73
92
  const report = yield* implementStoriesFlow({ ...context, reasoning: reasoningMeter.service }, {
74
93
  plan,
75
94
  files,
76
95
  stateDir,
77
- worktreeRoot: join(input.workDir, ".llm4ts", "worktrees"),
96
+ worktreeRoot: worktreeRootFor(input.workDir, plan.epicId, process.env),
78
97
  board: makeLocalBoardSync(files, stateDir, `Epic: ${plan.epicId}`),
79
98
  contextFor: (workDir) => Effect.gen(function* () {
80
99
  const rebound = yield* contextFor(workDir);
@@ -95,6 +114,8 @@ const program = Effect.gen(function* () {
95
114
  : { setup: setupIn(nodeProcessExecutor, events, setupCommand) }),
96
115
  gates,
97
116
  judge: (story, diff) => judgeStory(reasoningMeter.service, story, diff, contextBudget),
117
+ verifyBlocked: verifyBlockedOn(reasoningMeter.service, events, files, plan),
118
+ awaitRecovery: awaitServer(healthUrl === undefined ? undefined : httpProbe(healthUrl), events),
98
119
  system: (story) => Effect.succeed([
99
120
  "House rules of the target repository (CONTRIBUTING.md):",
100
121
  guidance,
@@ -1,15 +1,19 @@
1
1
  // Shared core of the epic-stories flow (ADR 0013): the operator flags, the
2
2
  // story-plan generator prompt and schema, the story judge, the gate runner,
3
3
  // and seat selection. The executor itself is `@llm4ts/flow/Stories`.
4
+ import { join } from "node:path";
5
+ import * as Duration from "effect/Duration";
4
6
  import * as Effect from "effect/Effect";
5
7
  import { Dimension, Sample } from "@llm4ts/core/eval/Eval";
6
8
  import { judge } from "@llm4ts/core/eval/Judge";
7
9
  import { TokenUsage } from "@llm4ts/core/Models";
8
10
  import { cap } from "@llm4ts/flow/Context";
9
11
  import { structuredAndPublish } from "@llm4ts/flow/Flow";
12
+ import { Info } from "@llm4ts/flow/FlowEvents";
13
+ import { BlockedVerdict } from "@llm4ts/flow/Stories";
10
14
  import { stableHash } from "@llm4ts/flow/Plan";
11
15
  import { ReviewIssue } from "@llm4ts/flow/Review";
12
- import { StoryPlan } from "@llm4ts/flow/StoryPlan";
16
+ import { StoryPlan, pathsNamedIn } from "@llm4ts/flow/StoryPlan";
13
17
  import { claude, coderIds, pi } from "@llm4ts/runner/Connectors";
14
18
  import { FlowAborted, FlowLlmError, ReviewResult, ScriptUsage, coderFor, lintCommand, mergeReviewResults } from "@llm4ts/runner";
15
19
  export const epicUsage = [
@@ -19,8 +23,11 @@ export const epicUsage = [
19
23
  " --fail-fast stop the epic at the first failed story",
20
24
  "Seats: LLM4TS_REASONER (claude|gemini|…, default claude) splits, reviews, judges;",
21
25
  " LLM4TS_CODER (default pi) implements; LLM4TS_REASONING_MODEL / LLM4TS_CODER_MODEL",
22
- " pick their models (pi: provider/model). LLM4TS_GATES overrides the gate commands;",
23
- " LLM4TS_WORKTREE_SETUP (default: pnpm install --offline) prepares each worktree."
26
+ " pick their models (pi: provider/model); LLM4TS_CODER_FLAGS / LLM4TS_REASONING_FLAGS",
27
+ " add CLI flags (key=value;key=value). LLM4TS_GATES overrides the gate commands;",
28
+ " LLM4TS_WORKTREE_SETUP (default: pnpm install --offline) prepares each worktree;",
29
+ " LLM4TS_WORKTREE_ROOT (default: <repo>.worktrees beside the repository) holds them;",
30
+ " LLM4TS_CODER_HEALTH_URL is polled after the coder's serving engine goes down."
24
31
  ].join("\n");
25
32
  /** The flow's own flags, taken out before the shared `--repo`/prompt parsing sees the rest. */
26
33
  export const parseEpicArgs = (argv) => Effect.gen(function* () {
@@ -84,6 +91,180 @@ export const storyCoderFromEnvironment = (environment) => {
84
91
  })
85
92
  : Effect.succeed(preset);
86
93
  };
94
+ // ---- Seat flags and local servers --------------------------------------------------
95
+ /**
96
+ * `LLM4TS_CODER_FLAGS` / `LLM4TS_REASONING_FLAGS`: extra CLI flags for a seat as
97
+ * `key=value;key=value` (a bare `key` is a boolean flag). The first `=` splits, so
98
+ * a value may itself carry `=`: `config=model_provider=lmstudio` is codex's
99
+ * `--config model_provider=lmstudio`.
100
+ */
101
+ export const flagsFromEnvironment = (raw) => {
102
+ const flags = {};
103
+ for (const part of (raw ?? "").split(";")) {
104
+ const entry = part.trim();
105
+ if (entry.length === 0) {
106
+ continue;
107
+ }
108
+ const at = entry.indexOf("=");
109
+ if (at < 0) {
110
+ flags[entry] = "";
111
+ }
112
+ else {
113
+ flags[entry.slice(0, at).trim()] = entry.slice(at + 1).trim();
114
+ }
115
+ }
116
+ return flags;
117
+ };
118
+ /**
119
+ * The local single-model server a coder seat points at, if any: pi's
120
+ * `lmstudio/…` and `ollama/…` model specs, codex's `--config model_provider=lmstudio`,
121
+ * a claude seat with `ANTHROPIC_BASE_URL` on a loopback host. Such a server serves
122
+ * one generation at a time, so parallel coders queue and a queued request is cut
123
+ * off after a few minutes — the flow warns when concurrency is above one.
124
+ */
125
+ export const localCoderServer = (model, flags, environment) => {
126
+ const spec = (model ?? "").trim().toLowerCase();
127
+ for (const provider of ["lmstudio", "lm-studio", "ollama", "mlx"]) {
128
+ if (spec.startsWith(`${provider}/`)) {
129
+ return provider;
130
+ }
131
+ }
132
+ const configured = Object.entries(flags).find(([key, value]) => key === "config" && /^model_provider=(lmstudio|ollama)/.test(value));
133
+ if (configured !== undefined) {
134
+ return configured[1].slice("model_provider=".length);
135
+ }
136
+ const base = environment.ANTHROPIC_BASE_URL ?? environment.OPENAI_BASE_URL ?? "";
137
+ return /^(https?:\/\/)?(127\.0\.0\.1|localhost|0\.0\.0\.0|\[::1\])(:|\/|$)/.test(base)
138
+ ? "local"
139
+ : undefined;
140
+ };
141
+ // ---- Worktrees and the serving engine -------------------------------------------
142
+ /**
143
+ * Where an epic's story worktrees live: `LLM4TS_WORKTREE_ROOT/<epicId>`, by
144
+ * default `<repo>.worktrees/<epicId>` BESIDE the repository. Nested inside
145
+ * it, a coder's parent directory is the epic checkout, and coders cd there.
146
+ */
147
+ export const worktreeRootFor = (workDir, epicId, environment) => {
148
+ const configured = environment.LLM4TS_WORKTREE_ROOT?.trim();
149
+ const root = configured === undefined || configured.length === 0
150
+ ? `${workDir.replace(/[\\/]+$/, "")}.worktrees`
151
+ : configured;
152
+ return join(root, epicId);
153
+ };
154
+ /**
155
+ * The URL that answers when a local serving engine is up:
156
+ * `LLM4TS_CODER_HEALTH_URL`, else the server's model list at its default
157
+ * address. Undefined for a hosted provider — there is nothing to poll.
158
+ */
159
+ export const serverHealthUrl = (server, environment) => {
160
+ const configured = environment.LLM4TS_CODER_HEALTH_URL?.trim();
161
+ if (configured !== undefined && configured.length > 0) {
162
+ return configured;
163
+ }
164
+ switch (server) {
165
+ case "lmstudio":
166
+ case "lm-studio":
167
+ return "http://127.0.0.1:1234/v1/models";
168
+ case "ollama":
169
+ return "http://127.0.0.1:11434/api/tags";
170
+ case "local": {
171
+ const base = (environment.ANTHROPIC_BASE_URL ?? environment.OPENAI_BASE_URL ?? "")
172
+ .trim()
173
+ .replace(/\/+$/, "")
174
+ .replace(/\/v1$/, "");
175
+ return base.length === 0 ? undefined : `${base}/v1/models`;
176
+ }
177
+ default:
178
+ return undefined;
179
+ }
180
+ };
181
+ /** Whether `url` answers 2xx within five seconds. */
182
+ export const httpProbe = (url) => Effect.tryPromise(() => fetch(url, { signal: AbortSignal.timeout(5_000) })).pipe(Effect.map((response) => response.ok), Effect.catch(() => Effect.succeed(false)));
183
+ export const defaultRecoveryTiming = {
184
+ interval: "15 seconds",
185
+ attempts: 60,
186
+ grace: "30 seconds"
187
+ };
188
+ /**
189
+ * The executor's `awaitRecovery`: polls `probe` until the engine answers
190
+ * (then waits the grace period), or fails after `attempts`. Without a probe
191
+ * (a hosted provider) it waits one interval-times-eight and lets the retry
192
+ * find out.
193
+ */
194
+ export const awaitServer = (probe, events, timing = defaultRecoveryTiming) => (reason) => Effect.gen(function* () {
195
+ yield* events.publish(Info.make({ message: `⏸ waiting for the coder's serving engine to recover: ${reason}` }));
196
+ if (probe === undefined) {
197
+ yield* Effect.sleep(Duration.times(Duration.fromInputUnsafe(timing.interval), 8));
198
+ return;
199
+ }
200
+ for (let attempt = 0; attempt < timing.attempts; attempt += 1) {
201
+ if (yield* probe) {
202
+ yield* Effect.sleep(timing.grace);
203
+ yield* events.publish(Info.make({ message: "▶ the coder's serving engine answers again" }));
204
+ return;
205
+ }
206
+ yield* Effect.sleep(timing.interval);
207
+ }
208
+ return yield* FlowAborted.make({
209
+ message: `the coder's serving engine did not answer after ${timing.attempts} probes`
210
+ });
211
+ });
212
+ // ---- BLOCKED_ON second opinion ----------------------------------------------------
213
+ export const blockedVerdictJsonSchema = {
214
+ type: "object",
215
+ properties: {
216
+ real: { type: "boolean" },
217
+ reason: { type: "string" }
218
+ },
219
+ required: ["real", "reason"]
220
+ };
221
+ const excerptLimit = 6_000;
222
+ /**
223
+ * The executor's `verifyBlocked`: the reasoning seat reads the claim, the
224
+ * plan, and the named files as they are in the story's worktree, and decides
225
+ * whether the plan really has a gap. Only claims the plan alone cannot
226
+ * settle reach it.
227
+ */
228
+ export const verifyBlockedOn = (reasoning, events, files, plan) => (story, need, workDir) => Effect.gen(function* () {
229
+ const excerpts = [];
230
+ for (const path of pathsNamedIn(plan, need).slice(0, 4)) {
231
+ const text = yield* files
232
+ .read(join(workDir, path))
233
+ .pipe(Effect.catch(() => Effect.succeed("(a directory, or unreadable)")));
234
+ excerpts.push(`### ${path}`, text === undefined ? "(does not exist in the working tree)" : cap(text, excerptLimit).text);
235
+ }
236
+ const others = plan.stories
237
+ .filter((other) => other.id !== story.id)
238
+ .map((other) => `- ${other.id} (depends on: ${other.dependsOn.join(", ") || "nothing"}) owns ${other.owned.join(", ")}; provides ${other.provides.join("; ")}`);
239
+ const prompt = [
240
+ "A coding agent implementing ONE story of a parallel epic stopped and claimed it is blocked",
241
+ "on work another story owns. Decide whether the claim is REAL.",
242
+ "",
243
+ "It is real only when the needed work is absent from the agent's working tree, is not",
244
+ "within its own owned paths, and no story it depends on provides it: the plan has a gap.",
245
+ "It is NOT real when the thing exists (perhaps in another file or under another name), when",
246
+ "the agent can build it inside its owned paths, when a later story owns it, or when the",
247
+ "problem is tooling or a type error the agent can fix itself.",
248
+ "",
249
+ `Story ${story.id}: ${story.title}`,
250
+ story.description,
251
+ `Depends on: ${story.dependsOn.join(", ") || "nothing"}`,
252
+ `Owns: ${story.owned.join(", ")}`,
253
+ `Reads: ${story.sharedReadOnly.join(", ") || "nothing"}`,
254
+ "",
255
+ "The other stories:",
256
+ ...others,
257
+ "",
258
+ `The claim: BLOCKED_ON: ${need}`,
259
+ "",
260
+ "The paths the claim names, as they are in the agent's working tree:",
261
+ ...(excerpts.length === 0 ? ["(the claim names no repository path)"] : excerpts),
262
+ "",
263
+ 'Respond only with JSON: {"real": true|false, "reason": "..."}. The reason is shown to the',
264
+ "agent when the claim is rejected: say concretely where to look or what to do."
265
+ ].join("\n");
266
+ return yield* structuredAndPublish(reasoning, events, prompt, BlockedVerdict, blockedVerdictJsonSchema);
267
+ });
87
268
  // ---- Story plan generation -----------------------------------------------------
88
269
  /** A readable, stable epic id: the first words of the epic plus a content hash. */
89
270
  export const epicIdFor = (epic) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llm4ts/shell",
3
- "version": "2.7.0",
3
+ "version": "2.8.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",
@@ -52,9 +52,9 @@
52
52
  "dependencies": {
53
53
  "@effect/platform-node": "4.0.0-rc.115",
54
54
  "@effect/platform-node-shared": "4.0.0-rc.115",
55
- "@llm4ts/core": "2.7.0",
56
- "@llm4ts/flow": "2.7.0",
57
- "@llm4ts/runner": "2.7.0"
55
+ "@llm4ts/core": "2.8.0",
56
+ "@llm4ts/flow": "2.8.0",
57
+ "@llm4ts/runner": "2.8.0"
58
58
  },
59
59
  "peerDependencies": {
60
60
  "effect": "4.0.0-rc.115"