@llm4ts/shell 2.10.0 → 2.11.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 +28 -5
- package/flows/lib/epic-stories.js +116 -4
- 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, gateCommands, gatesIn, generateStoryPlan, httpProbe, judgeStory, localCoderServer, parseEpicArgs, reasonerFromEnvironment, serverHealthUrl, 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);
|
|
@@ -79,6 +101,7 @@ const program = Effect.gen(function* () {
|
|
|
79
101
|
files,
|
|
80
102
|
stateDir,
|
|
81
103
|
target: flags.land,
|
|
104
|
+
keepWorktrees: flags.keepWorktrees,
|
|
82
105
|
gates: gatesIn(nodeProcessExecutor, events, gateCommands(process.env)),
|
|
83
106
|
system: ["House rules of the target repository (CONTRIBUTING.md):", guidance].join("\n")
|
|
84
107
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
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 { readdir } from "node:fs/promises";
|
|
4
5
|
import { join } from "node:path";
|
|
5
6
|
import * as Duration from "effect/Duration";
|
|
6
7
|
import * as Effect from "effect/Effect";
|
|
@@ -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;",
|
|
@@ -37,6 +45,9 @@ export const parseEpicArgs = (argv) => Effect.gen(function* () {
|
|
|
37
45
|
let failFast = false;
|
|
38
46
|
let concurrency;
|
|
39
47
|
let land;
|
|
48
|
+
let keepWorktrees = false;
|
|
49
|
+
let list = false;
|
|
50
|
+
let epic;
|
|
40
51
|
const rest = [];
|
|
41
52
|
for (let index = 0; index < argv.length; index += 1) {
|
|
42
53
|
const argument = argv[index] ?? "";
|
|
@@ -46,6 +57,22 @@ export const parseEpicArgs = (argv) => Effect.gen(function* () {
|
|
|
46
57
|
else if (argument === "--fail-fast") {
|
|
47
58
|
failFast = true;
|
|
48
59
|
}
|
|
60
|
+
else if (argument === "--keep-worktrees") {
|
|
61
|
+
keepWorktrees = true;
|
|
62
|
+
}
|
|
63
|
+
else if (argument === "--list") {
|
|
64
|
+
list = true;
|
|
65
|
+
}
|
|
66
|
+
else if (argument === "--epic" || argument.startsWith("--epic=")) {
|
|
67
|
+
const value = argument.includes("=") ? argument.slice("--epic=".length) : argv[index + 1];
|
|
68
|
+
if (!argument.includes("=")) {
|
|
69
|
+
index += 1;
|
|
70
|
+
}
|
|
71
|
+
if (value === undefined || value.trim().length === 0 || value.startsWith("--")) {
|
|
72
|
+
return yield* ScriptUsage.make({ message: `--epic needs an epic id\n${epicUsage}` });
|
|
73
|
+
}
|
|
74
|
+
epic = value.trim();
|
|
75
|
+
}
|
|
49
76
|
else if (argument === "--land" || argument.startsWith("--land=")) {
|
|
50
77
|
// `--land=<branch>`, never a separate word: the epic text may follow.
|
|
51
78
|
const branch = argument.includes("=") ? argument.slice("--land=".length).trim() : "main";
|
|
@@ -73,8 +100,93 @@ export const parseEpicArgs = (argv) => Effect.gen(function* () {
|
|
|
73
100
|
rest.push(argument);
|
|
74
101
|
}
|
|
75
102
|
}
|
|
76
|
-
return { planOnly, failFast, concurrency, land, rest };
|
|
103
|
+
return { planOnly, failFast, concurrency, land, keepWorktrees, list, epic, rest };
|
|
77
104
|
});
|
|
105
|
+
export const epicsDir = (workDir) => join(workDir, ".llm4ts", "epics");
|
|
106
|
+
/** Every epic with a plan in this repository, oldest folder name first. */
|
|
107
|
+
export const listEpics = (files, workDir) => Effect.gen(function* () {
|
|
108
|
+
const root = epicsDir(workDir);
|
|
109
|
+
const entries = yield* Effect.tryPromise(() => readdir(root, { withFileTypes: true })).pipe(Effect.catch(() => Effect.succeed([])));
|
|
110
|
+
const store = makeStoryPlanStore(files);
|
|
111
|
+
const summaries = [];
|
|
112
|
+
for (const entry of [...entries].sort((left, right) => left.name.localeCompare(right.name))) {
|
|
113
|
+
if (!entry.isDirectory()) {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const stateDir = join(root, entry.name);
|
|
117
|
+
const plan = yield* store
|
|
118
|
+
.load(join(stateDir, "plan.md"))
|
|
119
|
+
.pipe(Effect.catch(() => Effect.succeed(undefined)));
|
|
120
|
+
if (plan === undefined) {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
let merged = 0;
|
|
124
|
+
for (const story of plan.stories) {
|
|
125
|
+
const state = yield* loadVersioned(files, join(stateDir, "stories", `${story.id}.json`), StoryStateVersion, StoryState).pipe(Effect.catch(() => Effect.succeed(undefined)));
|
|
126
|
+
if (state?.status === "merged") {
|
|
127
|
+
merged += 1;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const landed = yield* loadVersioned(files, landedPath(stateDir), EpicLandedVersion, EpicLanded).pipe(Effect.catch(() => Effect.succeed(undefined)));
|
|
131
|
+
summaries.push({
|
|
132
|
+
dir: entry.name,
|
|
133
|
+
stateDir,
|
|
134
|
+
epicId: plan.epicId,
|
|
135
|
+
epic: plan.epic,
|
|
136
|
+
stories: plan.stories.length,
|
|
137
|
+
merged,
|
|
138
|
+
landed: landed?.target
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return summaries;
|
|
142
|
+
});
|
|
143
|
+
export const renderEpicList = (epics) => epics.length === 0
|
|
144
|
+
? "no epics in this repository yet: give an epic's text to plan one"
|
|
145
|
+
: [
|
|
146
|
+
`epics (${epics.length}):`,
|
|
147
|
+
...epics.flatMap((epic) => [
|
|
148
|
+
`- ${epic.epicId} · ${epic.merged}/${epic.stories} stories merged · ${epic.landed === undefined
|
|
149
|
+
? epic.merged === epic.stories
|
|
150
|
+
? "finished, not landed"
|
|
151
|
+
: "in progress"
|
|
152
|
+
: `landed on ${epic.landed}`} (--epic ${epic.epicId})`,
|
|
153
|
+
` "${epic.epic.length > 110 ? `${epic.epic.slice(0, 109)}…` : epic.epic}"`
|
|
154
|
+
])
|
|
155
|
+
].join("\n");
|
|
156
|
+
/**
|
|
157
|
+
* The epic text given wins (a new epic, or the existing one with that exact
|
|
158
|
+
* text); `--epic` picks an existing one; with neither, the one epic not yet
|
|
159
|
+
* landed is chosen. Several candidates, or none left, stop the run with the
|
|
160
|
+
* list — never a silent guess. With no epic at all, the flow's default text.
|
|
161
|
+
*/
|
|
162
|
+
export const chooseEpic = (args) => {
|
|
163
|
+
if (args.epic !== undefined) {
|
|
164
|
+
const found = args.epics.find((epic) => epic.dir === args.epic || epic.epicId === args.epic);
|
|
165
|
+
return found === undefined
|
|
166
|
+
? Effect.fail(ScriptUsage.make({
|
|
167
|
+
message: `no epic '${args.epic}' in this repository\n${renderEpicList(args.epics)}`
|
|
168
|
+
}))
|
|
169
|
+
: Effect.succeed({ _tag: "Existing", epic: found });
|
|
170
|
+
}
|
|
171
|
+
if (args.text.trim().length > 0) {
|
|
172
|
+
return Effect.succeed({ _tag: "Text", prompt: args.text });
|
|
173
|
+
}
|
|
174
|
+
const open = args.epics.filter((epic) => epic.landed === undefined);
|
|
175
|
+
if (open.length === 1 && open[0] !== undefined) {
|
|
176
|
+
return Effect.succeed({ _tag: "Existing", epic: open[0] });
|
|
177
|
+
}
|
|
178
|
+
if (open.length > 1) {
|
|
179
|
+
return Effect.fail(ScriptUsage.make({
|
|
180
|
+
message: `several epics are open here; pick one with --epic <id>, or give an epic's text\n${renderEpicList(open)}`
|
|
181
|
+
}));
|
|
182
|
+
}
|
|
183
|
+
if (args.epics.length > 0) {
|
|
184
|
+
return Effect.fail(ScriptUsage.make({
|
|
185
|
+
message: `every epic here has landed; give the new epic's text to plan it\n${renderEpicList(args.epics)}`
|
|
186
|
+
}));
|
|
187
|
+
}
|
|
188
|
+
return Effect.succeed({ _tag: "Text", prompt: args.defaultEpic });
|
|
189
|
+
};
|
|
78
190
|
// ---- Seats --------------------------------------------------------------------
|
|
79
191
|
/** The reasoning seat: `LLM4TS_REASONER`, any coder id, default claude. Unknown names fail typed. */
|
|
80
192
|
export const reasonerFromEnvironment = (environment) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@llm4ts/shell",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.11.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.11.0",
|
|
56
|
+
"@llm4ts/flow": "2.11.0",
|
|
57
|
+
"@llm4ts/runner": "2.11.0"
|
|
58
58
|
},
|
|
59
59
|
"peerDependencies": {
|
|
60
60
|
"effect": "4.0.0-rc.115"
|