@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.
- package/flows/epic-stories.js +46 -9
- package/flows/lib/epic-stories.js +168 -8
- package/package.json +4 -4
package/flows/epic-stories.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
-
: {
|
|
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 {
|
|
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 {
|
|
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
|
|
458
|
-
|
|
459
|
-
|
|
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.
|
|
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.
|
|
56
|
-
"@llm4ts/flow": "2.
|
|
57
|
-
"@llm4ts/runner": "2.
|
|
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"
|