@llm4ts/shell 2.10.0 → 2.12.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.
@@ -3,6 +3,8 @@
3
3
  // llm4ts run epic-stories --repo ~/demo/portal "Add the current account and wire transfers"
4
4
  // llm4ts run epic-stories --repo ~/demo/portal -- --plan-only "…" # write the plan and stop
5
5
  // llm4ts run epic-stories --repo ~/demo/portal -- --land # land the finished epic on main
6
+ // llm4ts run epic-stories --repo ~/demo/portal -- --list # the repository's epics
7
+ // llm4ts run epic-stories --repo ~/demo/portal -- --epic <id> # resume one, no text needed
6
8
  //
7
9
  // The reasoning seat (LLM4TS_REASONER, default claude) splits the epic,
8
10
  // reviews every task and judges every story; the coder seat (LLM4TS_CODER,
@@ -26,7 +28,8 @@ import { FlowAborted, Info, asReadOnly, nodePlainFileStore, nodeProcessExecutor,
26
28
  import { landEpic } from "@llm4ts/flow/Landing";
27
29
  import { implementStoriesFlow } from "@llm4ts/flow/Stories";
28
30
  import { makeStoryPlanStore, validateStoryPlan } from "@llm4ts/flow/StoryPlan";
29
- import { awaitServer, combineTotals, epicIdFor, flagsFromEnvironment, gateCommands, gatesIn, generateStoryPlan, httpProbe, judgeStory, localCoderServer, parseEpicArgs, reasonerFromEnvironment, serverHealthUrl, setupIn, storyCoderFromEnvironment, verifyBlockedOn, worktreeRootFor, worktreeSetupCommand } from "./lib/epic-stories.js";
31
+ import * as Console from "effect/Console";
32
+ import { awaitServer, chooseEpic, epicsDir, listEpics, renderEpicList, combineTotals, epicIdFor, flagsFromEnvironment, appDirFor, gateCommands, gatesIn, inAppDir, generateStoryPlan, httpProbe, judgeStory, localCoderServer, parseEpicArgs, reasonerFromEnvironment, serverHealthUrl, setupAgentEnabled, setupIn, storyCoderFromEnvironment, verifyBlockedOn, worktreeRootFor, worktreeSetupCommand } from "./lib/epic-stories.js";
30
33
  /** `LLM4TS_CODER_MODEL` / `LLM4TS_REASONING_MODEL`: pi takes `provider/model` (e.g. `openai-codex/gpt-5.5`). */
31
34
  const withOptionalModel = (config, model) => {
32
35
  const trimmed = model?.trim();
@@ -40,14 +43,33 @@ const defaultEpic = "Add the retail customer's current account (Conto) with bala
40
43
  "transfers (Bonifico) with beneficiary, review, SCA confirmation, and history.";
41
44
  const program = Effect.gen(function* () {
42
45
  const flags = yield* parseEpicArgs(process.argv.slice(2));
43
- const input = yield* resolveFlowInput(defaultEpic, flags.rest);
46
+ // The epic is chosen before any seat is resolved: the text given, `--epic`,
47
+ // or the one epic not landed yet (`chooseEpic`); `--list` only lists.
48
+ const given = yield* resolveFlowInput("", flags.rest);
49
+ const files = nodePlainFileStore;
50
+ const epics = yield* listEpics(files, given.workDir);
51
+ if (flags.list) {
52
+ yield* Console.log(renderEpicList(epics));
53
+ return;
54
+ }
55
+ const choice = yield* chooseEpic({
56
+ text: given.prompt,
57
+ epic: flags.epic,
58
+ epics,
59
+ defaultEpic
60
+ });
61
+ const input = {
62
+ ...given,
63
+ prompt: choice._tag === "Existing" ? choice.epic.epic : choice.prompt
64
+ };
44
65
  const coderFlags = flagsFromEnvironment(process.env.LLM4TS_CODER_FLAGS);
45
66
  const reasoning = withExtraFlags(withOptionalModel(yield* reasonerFromEnvironment(process.env), process.env.LLM4TS_REASONING_MODEL), flagsFromEnvironment(process.env.LLM4TS_REASONING_FLAGS));
46
67
  const coder = withExtraFlags(withOptionalModel(yield* storyCoderFromEnvironment(process.env), process.env.LLM4TS_CODER_MODEL), coderFlags);
47
68
  const localServer = localCoderServer(process.env.LLM4TS_CODER_MODEL, coderFlags, process.env);
48
- const files = nodePlainFileStore;
49
- const epicId = epicIdFor(input.prompt);
50
- const stateDir = join(input.workDir, ".llm4ts", "epics", epicId);
69
+ // A new epic's id (and state folder) is derived from its text; an existing
70
+ // one keeps its folder, whatever text is on the command line.
71
+ const epicId = choice._tag === "Existing" ? choice.epic.dir : epicIdFor(input.prompt);
72
+ const stateDir = join(epicsDir(input.workDir), epicId);
51
73
  const planPath = join(stateDir, "plan.md");
52
74
  const estimateOptions = estimatedUsageOptionsFromEnv(process.env);
53
75
  const contextBudget = budget(process.env);
@@ -73,13 +95,19 @@ const program = Effect.gen(function* () {
73
95
  if (flags.planOnly) {
74
96
  return;
75
97
  }
98
+ // Setup and gates run where the application is, in every checkout.
99
+ const appDir = yield* appDirFor(input.workDir, process.env);
100
+ if (appDir !== ".") {
101
+ yield* events.publish(Info.make({ message: `app dir: ${appDir} (setup and gates run there; LLM4TS_APP_DIR)` }));
102
+ }
76
103
  if (flags.land !== undefined) {
77
104
  const landed = yield* landEpic(context, {
78
105
  plan,
79
106
  files,
80
107
  stateDir,
81
108
  target: flags.land,
82
- gates: gatesIn(nodeProcessExecutor, events, gateCommands(process.env)),
109
+ keepWorktrees: flags.keepWorktrees,
110
+ gates: inAppDir(appDir, gatesIn(nodeProcessExecutor, events, gateCommands(process.env))),
83
111
  system: ["House rules of the target repository (CONTRIBUTING.md):", guidance].join("\n")
84
112
  });
85
113
  yield* events.publish(Info.make({
@@ -103,7 +131,12 @@ const program = Effect.gen(function* () {
103
131
  message: "this runner cannot rebind seats to a worktree (no contextFor)"
104
132
  });
105
133
  }
106
- const gates = gatesIn(nodeProcessExecutor, events, gateCommands(process.env));
134
+ const appDirNote = appDir === "."
135
+ ? []
136
+ : [
137
+ `The application lives in ${appDir}/ — its package.json, sources and tests; the gates run there.`
138
+ ];
139
+ const gates = inAppDir(appDir, gatesIn(nodeProcessExecutor, events, gateCommands(process.env)));
107
140
  const setupCommand = worktreeSetupCommand(process.env);
108
141
  const healthUrl = serverHealthUrl(localServer, process.env);
109
142
  const report = yield* implementStoriesFlow({ ...context, reasoning: reasoningMeter.service }, {
@@ -128,7 +161,10 @@ const program = Effect.gen(function* () {
128
161
  }),
129
162
  ...(setupCommand === undefined
130
163
  ? {}
131
- : { setup: setupIn(nodeProcessExecutor, events, setupCommand) }),
164
+ : {
165
+ setup: inAppDir(appDir, setupIn(nodeProcessExecutor, events, setupCommand)),
166
+ setupAgent: setupAgentEnabled(process.env)
167
+ }),
132
168
  gates,
133
169
  // With a roster, the judge and the verifier are leased per story,
134
170
  // away from the executor coding it (ADR 0019).
@@ -143,7 +179,8 @@ const program = Effect.gen(function* () {
143
179
  "House rules of the target repository (CONTRIBUTING.md):",
144
180
  guidance,
145
181
  "",
146
- `Imitate the exemplar feature before inventing anything. Story id: ${story.id}.`
182
+ `Imitate the exemplar feature before inventing anything. Story id: ${story.id}.`,
183
+ ...appDirNote
147
184
  ].join("\n")),
148
185
  concurrency,
149
186
  failFast: flags.failFast
@@ -1,7 +1,8 @@
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";
4
+ import { readdir } from "node:fs/promises";
5
+ import { isAbsolute, join, normalize } from "node:path";
5
6
  import * as Duration from "effect/Duration";
6
7
  import * as Effect from "effect/Effect";
7
8
  import { Dimension, Sample } from "@llm4ts/core/eval/Eval";
@@ -10,17 +11,24 @@ import { TokenUsage } from "@llm4ts/core/Models";
10
11
  import { cap } from "@llm4ts/flow/Context";
11
12
  import { structuredAndPublish } from "@llm4ts/flow/Flow";
12
13
  import { Info } from "@llm4ts/flow/FlowEvents";
13
- import { BlockedVerdict } from "@llm4ts/flow/Stories";
14
+ import { EpicLanded, EpicLandedVersion, landedPath } from "@llm4ts/flow/Landing";
15
+ import { loadVersioned } from "@llm4ts/flow/Persistence";
16
+ import { BlockedVerdict, StoryState, StoryStateVersion } from "@llm4ts/flow/Stories";
14
17
  import { stableHash } from "@llm4ts/flow/Plan";
15
18
  import { ReviewIssue } from "@llm4ts/flow/Review";
16
- import { StoryPlan, dependenciesOf, pathsNamedIn } from "@llm4ts/flow/StoryPlan";
19
+ import { StoryPlan, dependenciesOf, makeStoryPlanStore, pathsNamedIn } from "@llm4ts/flow/StoryPlan";
17
20
  import { claude, coderIds, pi } from "@llm4ts/runner/Connectors";
18
21
  import { FlowAborted, FlowLlmError, ReviewResult, ScriptUsage, coderFor, lintCommand, mergeReviewResults } from "@llm4ts/runner";
19
22
  export const epicUsage = [
20
23
  "epic-stories flags:",
21
24
  " --plan-only write (or re-validate) the story plan and stop",
22
25
  " --land[=<branch>] land the finished epic on <branch> (default main): merge the branch in,",
23
- " let the coder fix conflicts and red gates (3 rounds), then merge",
26
+ " let the coder fix conflicts and red gates (3 rounds), then merge;",
27
+ " afterwards the story worktrees and merged story branches go",
28
+ " --keep-worktrees with --land: keep the story worktrees and branches",
29
+ " --list list this repository's epics (id, stories merged, landed) and stop",
30
+ " --epic <id> work on an existing epic by id, no text needed. Without text or",
31
+ " --epic, the one epic not landed yet is chosen; several stop the run",
24
32
  " --concurrency <n> stories implemented at once (default 3)",
25
33
  " --fail-fast stop the epic at the first failed story",
26
34
  "Seats: LLM4TS_REASONER (claude|gemini|…, default claude) splits, reviews, judges;",
@@ -29,6 +37,9 @@ export const epicUsage = [
29
37
  " add CLI flags (key=value;key=value). LLM4TS_GATES overrides the gate commands;",
30
38
  " LLM4TS_WORKTREE_SETUP (default: pnpm install --offline) prepares each worktree;",
31
39
  " LLM4TS_WORKTREE_ROOT (default: <repo>.worktrees beside the repository) holds them;",
40
+ " LLM4TS_APP_DIR (e.g. frontend) runs setup and gates in that subfolder; unset, a",
41
+ " repository without a root package.json uses its one subfolder that has one;",
42
+ " LLM4TS_SETUP_AGENT=1 gives the coder one turn to fix a failed setup, then reruns it;",
32
43
  " LLM4TS_CODER_HEALTH_URL is polled after the coder's serving engine goes down."
33
44
  ].join("\n");
34
45
  /** The flow's own flags, taken out before the shared `--repo`/prompt parsing sees the rest. */
@@ -37,6 +48,9 @@ export const parseEpicArgs = (argv) => Effect.gen(function* () {
37
48
  let failFast = false;
38
49
  let concurrency;
39
50
  let land;
51
+ let keepWorktrees = false;
52
+ let list = false;
53
+ let epic;
40
54
  const rest = [];
41
55
  for (let index = 0; index < argv.length; index += 1) {
42
56
  const argument = argv[index] ?? "";
@@ -46,6 +60,22 @@ export const parseEpicArgs = (argv) => Effect.gen(function* () {
46
60
  else if (argument === "--fail-fast") {
47
61
  failFast = true;
48
62
  }
63
+ else if (argument === "--keep-worktrees") {
64
+ keepWorktrees = true;
65
+ }
66
+ else if (argument === "--list") {
67
+ list = true;
68
+ }
69
+ else if (argument === "--epic" || argument.startsWith("--epic=")) {
70
+ const value = argument.includes("=") ? argument.slice("--epic=".length) : argv[index + 1];
71
+ if (!argument.includes("=")) {
72
+ index += 1;
73
+ }
74
+ if (value === undefined || value.trim().length === 0 || value.startsWith("--")) {
75
+ return yield* ScriptUsage.make({ message: `--epic needs an epic id\n${epicUsage}` });
76
+ }
77
+ epic = value.trim();
78
+ }
49
79
  else if (argument === "--land" || argument.startsWith("--land=")) {
50
80
  // `--land=<branch>`, never a separate word: the epic text may follow.
51
81
  const branch = argument.includes("=") ? argument.slice("--land=".length).trim() : "main";
@@ -73,8 +103,93 @@ export const parseEpicArgs = (argv) => Effect.gen(function* () {
73
103
  rest.push(argument);
74
104
  }
75
105
  }
76
- return { planOnly, failFast, concurrency, land, rest };
106
+ return { planOnly, failFast, concurrency, land, keepWorktrees, list, epic, rest };
77
107
  });
108
+ export const epicsDir = (workDir) => join(workDir, ".llm4ts", "epics");
109
+ /** Every epic with a plan in this repository, oldest folder name first. */
110
+ export const listEpics = (files, workDir) => Effect.gen(function* () {
111
+ const root = epicsDir(workDir);
112
+ const entries = yield* Effect.tryPromise(() => readdir(root, { withFileTypes: true })).pipe(Effect.catch(() => Effect.succeed([])));
113
+ const store = makeStoryPlanStore(files);
114
+ const summaries = [];
115
+ for (const entry of [...entries].sort((left, right) => left.name.localeCompare(right.name))) {
116
+ if (!entry.isDirectory()) {
117
+ continue;
118
+ }
119
+ const stateDir = join(root, entry.name);
120
+ const plan = yield* store
121
+ .load(join(stateDir, "plan.md"))
122
+ .pipe(Effect.catch(() => Effect.succeed(undefined)));
123
+ if (plan === undefined) {
124
+ continue;
125
+ }
126
+ let merged = 0;
127
+ for (const story of plan.stories) {
128
+ const state = yield* loadVersioned(files, join(stateDir, "stories", `${story.id}.json`), StoryStateVersion, StoryState).pipe(Effect.catch(() => Effect.succeed(undefined)));
129
+ if (state?.status === "merged") {
130
+ merged += 1;
131
+ }
132
+ }
133
+ const landed = yield* loadVersioned(files, landedPath(stateDir), EpicLandedVersion, EpicLanded).pipe(Effect.catch(() => Effect.succeed(undefined)));
134
+ summaries.push({
135
+ dir: entry.name,
136
+ stateDir,
137
+ epicId: plan.epicId,
138
+ epic: plan.epic,
139
+ stories: plan.stories.length,
140
+ merged,
141
+ landed: landed?.target
142
+ });
143
+ }
144
+ return summaries;
145
+ });
146
+ export const renderEpicList = (epics) => epics.length === 0
147
+ ? "no epics in this repository yet: give an epic's text to plan one"
148
+ : [
149
+ `epics (${epics.length}):`,
150
+ ...epics.flatMap((epic) => [
151
+ `- ${epic.epicId} · ${epic.merged}/${epic.stories} stories merged · ${epic.landed === undefined
152
+ ? epic.merged === epic.stories
153
+ ? "finished, not landed"
154
+ : "in progress"
155
+ : `landed on ${epic.landed}`} (--epic ${epic.epicId})`,
156
+ ` "${epic.epic.length > 110 ? `${epic.epic.slice(0, 109)}…` : epic.epic}"`
157
+ ])
158
+ ].join("\n");
159
+ /**
160
+ * The epic text given wins (a new epic, or the existing one with that exact
161
+ * text); `--epic` picks an existing one; with neither, the one epic not yet
162
+ * landed is chosen. Several candidates, or none left, stop the run with the
163
+ * list — never a silent guess. With no epic at all, the flow's default text.
164
+ */
165
+ export const chooseEpic = (args) => {
166
+ if (args.epic !== undefined) {
167
+ const found = args.epics.find((epic) => epic.dir === args.epic || epic.epicId === args.epic);
168
+ return found === undefined
169
+ ? Effect.fail(ScriptUsage.make({
170
+ message: `no epic '${args.epic}' in this repository\n${renderEpicList(args.epics)}`
171
+ }))
172
+ : Effect.succeed({ _tag: "Existing", epic: found });
173
+ }
174
+ if (args.text.trim().length > 0) {
175
+ return Effect.succeed({ _tag: "Text", prompt: args.text });
176
+ }
177
+ const open = args.epics.filter((epic) => epic.landed === undefined);
178
+ if (open.length === 1 && open[0] !== undefined) {
179
+ return Effect.succeed({ _tag: "Existing", epic: open[0] });
180
+ }
181
+ if (open.length > 1) {
182
+ return Effect.fail(ScriptUsage.make({
183
+ message: `several epics are open here; pick one with --epic <id>, or give an epic's text\n${renderEpicList(open)}`
184
+ }));
185
+ }
186
+ if (args.epics.length > 0) {
187
+ return Effect.fail(ScriptUsage.make({
188
+ message: `every epic here has landed; give the new epic's text to plan it\n${renderEpicList(args.epics)}`
189
+ }));
190
+ }
191
+ return Effect.succeed({ _tag: "Text", prompt: args.defaultEpic });
192
+ };
78
193
  // ---- Seats --------------------------------------------------------------------
79
194
  /** The reasoning seat: `LLM4TS_REASONER`, any coder id, default claude. Unknown names fail typed. */
80
195
  export const reasonerFromEnvironment = (environment) => {
@@ -432,6 +547,45 @@ export const judgeStory = (reasoning, story, diff, budget, plan) => judge(reason
432
547
  response: cap(diff, budget).text
433
548
  }))
434
549
  .pipe(Effect.mapError(FlowLlmError.from), Effect.map((scored) => subBar(scored, story)));
550
+ // ---- App directory ----------------------------------------------------------------
551
+ /** Folders never taken for the application: dependencies, build output, llm4ts state. */
552
+ const ignoredAppDirs = new Set(["node_modules", "dist", "build", "out", "coverage"]);
553
+ const hasPackageJson = (dir) => Effect.tryPromise(() => readdir(dir)).pipe(Effect.map((names) => names.includes("package.json")), Effect.catch(() => Effect.succeed(false)));
554
+ /**
555
+ * Where setup and gates run, relative to a checkout: `LLM4TS_APP_DIR` when set
556
+ * (`.` for the root); otherwise the root when it has a `package.json`, else
557
+ * its ONE first-level subfolder that has one (a Next.js app in `frontend/`).
558
+ * Several candidates, or none, keep the root — the setup error then names it.
559
+ * The value is relative so it applies to every worktree and the epic checkout.
560
+ */
561
+ export const appDirFor = (workDir, environment) => Effect.gen(function* () {
562
+ const configured = environment.LLM4TS_APP_DIR?.trim();
563
+ if (configured !== undefined && configured.length > 0) {
564
+ const normalized = normalize(configured);
565
+ const relative = normalized.replace(/[\\/]+$/, "");
566
+ if (isAbsolute(normalized) || relative === ".." || /^\.\.[\\/]/.test(relative)) {
567
+ return yield* ScriptUsage.make({
568
+ message: `LLM4TS_APP_DIR must be a folder inside the repository, got '${configured}'`
569
+ });
570
+ }
571
+ return relative.length === 0 ? "." : relative;
572
+ }
573
+ if (yield* hasPackageJson(workDir)) {
574
+ return ".";
575
+ }
576
+ const entries = yield* Effect.tryPromise(() => readdir(workDir, { withFileTypes: true })).pipe(Effect.catch(() => Effect.succeed([])));
577
+ const candidates = [];
578
+ for (const entry of entries) {
579
+ if (entry.isDirectory() && !entry.name.startsWith(".") && !ignoredAppDirs.has(entry.name)) {
580
+ if (yield* hasPackageJson(join(workDir, entry.name))) {
581
+ candidates.push(entry.name);
582
+ }
583
+ }
584
+ }
585
+ return candidates.length === 1 ? (candidates[0] ?? ".") : ".";
586
+ });
587
+ /** Runs a per-checkout step in the app directory of whichever checkout it is given. */
588
+ export const inAppDir = (appDir, step) => (workDir) => step(appDir === "." ? workDir : join(workDir, appDir));
435
589
  // ---- Worktree setup --------------------------------------------------------------
436
590
  export const defaultWorktreeSetup = ["pnpm", "install", "--offline"];
437
591
  /**
@@ -450,13 +604,19 @@ export const worktreeSetupCommand = (environment) => {
450
604
  .filter((part) => part.length > 0);
451
605
  return parts.length === 0 ? undefined : parts;
452
606
  };
607
+ /**
608
+ * `LLM4TS_SETUP_AGENT=1` (or `true`/`on`): a failed setup gets one coder turn
609
+ * to make the worktree ready, then runs again as the check. Off by default.
610
+ */
611
+ export const setupAgentEnabled = (environment) => /^(1|true|on|yes)$/i.test(environment.LLM4TS_SETUP_AGENT?.trim() ?? "");
612
+ const output = (result) => result.issues.map((issue) => issue.description).join("\n");
453
613
  /** Runs the setup command in a worktree; a non-zero exit fails the story with the output. */
454
614
  export const setupIn = (process, events, command) => (workDir) => Effect.flatMap(lintCommand(process, events, command, workDir), (result) => result.isClean
455
615
  ? Effect.void
456
616
  : FlowAborted.make({
457
- message: `worktree setup failed (${command.join(" ")}):\n${result.issues
458
- .map((issue) => issue.description)
459
- .join("\n")}`
617
+ message: `worktree setup failed (${command.join(" ")}):\n${output(result)}${/ERR_PNPM_NO_PKG_MANIFEST|ENOENT.*package\.json/.test(output(result))
618
+ ? "\n(the application is not at this folder: set LLM4TS_APP_DIR to its subfolder)"
619
+ : ""}`
460
620
  }));
461
621
  // ---- Gates ---------------------------------------------------------------------
462
622
  export const defaultGateCommands = [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llm4ts/shell",
3
- "version": "2.10.0",
3
+ "version": "2.12.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.10.0",
56
- "@llm4ts/flow": "2.10.0",
57
- "@llm4ts/runner": "2.10.0"
55
+ "@llm4ts/core": "2.12.0",
56
+ "@llm4ts/flow": "2.12.0",
57
+ "@llm4ts/runner": "2.12.0"
58
58
  },
59
59
  "peerDependencies": {
60
60
  "effect": "4.0.0-rc.115"