@llm4ts/shell 2.18.0 → 2.19.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/dist/Cli.d.ts +1 -1
- package/dist/Cli.d.ts.map +1 -1
- package/flows/lib/epic-design.js +3 -2
- package/flows/lib/epic-stories.js +325 -20
- package/package.json +4 -4
package/dist/Cli.d.ts
CHANGED
|
@@ -41,7 +41,7 @@ export interface CostsFlags {
|
|
|
41
41
|
}
|
|
42
42
|
/** Turns the `costs` flags into program options; every rejection is a usage error. */
|
|
43
43
|
export declare const costsOptionsFrom: (flags: CostsFlags, cwd?: string) => Effect.Effect<CostsOptions, ShellUsageError>;
|
|
44
|
-
export declare const shellCommand: Command.Command<"llm4ts", {} | {}, {}, import("./FlowLaunch.ts").FlowLaunchError | import("@llm4ts/flow/FlowError.ts").DecisionsInvalid | ShellUsageError | ShellActionError | import("@llm4ts/flow/FlowError.ts").PersistenceError | import("@llm4ts/flow/FlowError.ts").PlanParseError | import("@llm4ts/flow/FlowError.ts").UnsupportedSchemaVersion | import("@llm4ts/flow/FlowError.ts").WorkspacePathError | import("@llm4ts/flow/FlowError.ts").WorkspaceLimitError | import("@llm4ts/flow/FlowError.ts").WorkspaceIoError | import("@llm4ts/flow/FlowError.ts").FlowAborted | import("@llm4ts/flow/FlowError.ts").ProcessError | import("@llm4ts/flow/FlowError.ts").FlowLlmError | import("@llm4ts/flow/FlowError.ts").FlowCapabilityDenied | import("@llm4ts/flow/FlowError.ts").ColumnNotFound | import("@llm4ts/flow/FlowError.ts").BudgetExceeded | import("@llm4ts/flow/FlowError.ts").StoryPlanInvalid | import("@llm4ts/flow/FlowError.ts").PerimeterViolation | import("@llm4ts/flow/FlowError.ts").MissingDependency | import("@llm4ts/flow/FlowError.ts").MergeConflict | import("@llm4ts/flow/FlowError.ts").EpicCheckoutDirty | import("@llm4ts/flow/FlowError.ts").RosterExhausted | import("@llm4ts/flow/FlowError.ts").RosterInvalid | import("@llm4ts/flow/FlowError.ts").EpicIncomplete | import("@llm4ts/flow/FlowError.ts").LandingFailed | import("@llm4ts/flow/FlowError.ts").StoryFailed | import("@llm4ts/flow/FlowError.ts").OpenPointsPending | import("@llm4ts/flow/FlowError.ts").ContractConflict | import("@llm4ts/flow/FlowError.ts").ExtractPackMissing | import("@llm4ts/flow/FlowError.ts").EpicBriefInvalid | import("@llm4ts/flow/FlowError.ts").EpicBriefNotApproved, import("effect/unstable/cli/Prompt").Environment>;
|
|
44
|
+
export declare const shellCommand: Command.Command<"llm4ts", {} | {}, {}, import("./FlowLaunch.ts").FlowLaunchError | import("@llm4ts/flow/FlowError.ts").DecisionsInvalid | ShellUsageError | ShellActionError | import("@llm4ts/flow/FlowError.ts").PersistenceError | import("@llm4ts/flow/FlowError.ts").PlanParseError | import("@llm4ts/flow/FlowError.ts").UnsupportedSchemaVersion | import("@llm4ts/flow/FlowError.ts").WorkspacePathError | import("@llm4ts/flow/FlowError.ts").WorkspaceLimitError | import("@llm4ts/flow/FlowError.ts").WorkspaceIoError | import("@llm4ts/flow/FlowError.ts").FlowAborted | import("@llm4ts/flow/FlowError.ts").ProcessError | import("@llm4ts/flow/FlowError.ts").FlowLlmError | import("@llm4ts/flow/FlowError.ts").FlowCapabilityDenied | import("@llm4ts/flow/FlowError.ts").ColumnNotFound | import("@llm4ts/flow/FlowError.ts").BudgetExceeded | import("@llm4ts/flow/FlowError.ts").StoryPlanInvalid | import("@llm4ts/flow/FlowError.ts").PerimeterViolation | import("@llm4ts/flow/FlowError.ts").MissingDependency | import("@llm4ts/flow/FlowError.ts").MergeConflict | import("@llm4ts/flow/FlowError.ts").EpicCheckoutDirty | import("@llm4ts/flow/FlowError.ts").RosterExhausted | import("@llm4ts/flow/FlowError.ts").RosterInvalid | import("@llm4ts/flow/FlowError.ts").EpicIncomplete | import("@llm4ts/flow/FlowError.ts").LandingFailed | import("@llm4ts/flow/FlowError.ts").StoryFailed | import("@llm4ts/flow/FlowError.ts").OpenPointsPending | import("@llm4ts/flow/FlowError.ts").ContractConflict | import("@llm4ts/flow/FlowError.ts").ExtractPackMissing | import("@llm4ts/flow/FlowError.ts").EpicBriefInvalid | import("@llm4ts/flow/FlowError.ts").EpicBriefNotApproved | import("@llm4ts/flow/FlowError.ts").RefineRefused, import("effect/unstable/cli/Prompt").Environment>;
|
|
45
45
|
export declare const runShellCommand: (argv: ReadonlyArray<string>) => Effect.Effect<void, unknown, Command.Environment>;
|
|
46
46
|
export {};
|
|
47
47
|
//# sourceMappingURL=Cli.d.ts.map
|
package/dist/Cli.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Cli.d.ts","sourceRoot":"","sources":["../src/Cli.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,MAAM,MAAM,eAAe,CAAA;AACvC,OAAO,KAAK,MAAM,MAAM,eAAe,CAAA;AACvC,OAAO,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAA;AAC9C,OAAO,KAAK,MAAM,MAAM,eAAe,CAAA;AAEvC,OAAO,KAAK,OAAO,MAAM,6BAA6B,CAAA;AAGtD,OAAO,EAIL,KAAK,YAAY,EAClB,MAAM,sBAAsB,CAAA;AAU7B,OAAO,EAA8C,KAAK,aAAa,EAAE,MAAM,qBAAqB,CAAA;AACpG,OAAO,EAGL,KAAK,cAAc,EACnB,KAAK,aAAa,EACnB,MAAM,kBAAkB,CAAA;;;;AAMzB,qBAAa,eAAgB,SAAQ,oBAEnC;CAAG;;;;AAEL,qBAAa,gBAAiB,SAAQ,qBAEpC;CAAG;AAEL,eAAO,MAAM,eAAe,QAAO,MAA6D,CAAA;AAEhG,eAAO,MAAM,cAAc,GAAI,UAAU;IACvC,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,WAAW,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC,CAAA;CACpE,KAAG,aAaH,CAAA;AAQD,eAAO,MAAM,aAAa,GACxB,MAAM,aAAa,CAAC,aAAa,CAAC,EAClC,UAAU;IAAE,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAA;CAAE,KACpC,MA+BF,CAAA;AAED,eAAO,MAAM,cAAc,GACzB,OAAO,aAAa,CAAC,cAAc,CAAC,EACpC,UAAU;IAAE,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAA;CAAE,KACpC,MAyBF,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,WAAW,iGAwBtB,CAAA;AA0NF,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC,MAAM,CAAC,CAAA;IACpC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAA;IACrC,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAA;IAClC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAA;CAC3C;AAED,sFAAsF;AACtF,eAAO,MAAM,gBAAgB,GAC3B,OAAO,UAAU,EACjB,MAAK,MAAsB,KAC1B,MAAM,CAAC,MAAM,CAAC,YAAY,EAAE,eAAe,CA2B1C,CAAA;AA4GJ,eAAO,MAAM,YAAY,
|
|
1
|
+
{"version":3,"file":"Cli.d.ts","sourceRoot":"","sources":["../src/Cli.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,MAAM,MAAM,eAAe,CAAA;AACvC,OAAO,KAAK,MAAM,MAAM,eAAe,CAAA;AACvC,OAAO,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAA;AAC9C,OAAO,KAAK,MAAM,MAAM,eAAe,CAAA;AAEvC,OAAO,KAAK,OAAO,MAAM,6BAA6B,CAAA;AAGtD,OAAO,EAIL,KAAK,YAAY,EAClB,MAAM,sBAAsB,CAAA;AAU7B,OAAO,EAA8C,KAAK,aAAa,EAAE,MAAM,qBAAqB,CAAA;AACpG,OAAO,EAGL,KAAK,cAAc,EACnB,KAAK,aAAa,EACnB,MAAM,kBAAkB,CAAA;;;;AAMzB,qBAAa,eAAgB,SAAQ,oBAEnC;CAAG;;;;AAEL,qBAAa,gBAAiB,SAAQ,qBAEpC;CAAG;AAEL,eAAO,MAAM,eAAe,QAAO,MAA6D,CAAA;AAEhG,eAAO,MAAM,cAAc,GAAI,UAAU;IACvC,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,WAAW,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC,CAAA;CACpE,KAAG,aAaH,CAAA;AAQD,eAAO,MAAM,aAAa,GACxB,MAAM,aAAa,CAAC,aAAa,CAAC,EAClC,UAAU;IAAE,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAA;CAAE,KACpC,MA+BF,CAAA;AAED,eAAO,MAAM,cAAc,GACzB,OAAO,aAAa,CAAC,cAAc,CAAC,EACpC,UAAU;IAAE,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAA;CAAE,KACpC,MAyBF,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,WAAW,iGAwBtB,CAAA;AA0NF,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC,MAAM,CAAC,CAAA;IACpC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAA;IACrC,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAA;IAClC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAA;CAC3C;AAED,sFAAsF;AACtF,eAAO,MAAM,gBAAgB,GAC3B,OAAO,UAAU,EACjB,MAAK,MAAsB,KAC1B,MAAM,CAAC,MAAM,CAAC,YAAY,EAAE,eAAe,CA2B1C,CAAA;AA4GJ,eAAO,MAAM,YAAY,0tDAwBxB,CAAA;AAED,eAAO,MAAM,eAAe,GAC1B,MAAM,aAAa,CAAC,MAAM,CAAC,KAC1B,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,OAAO,EAAE,OAAO,CAAC,WAAW,CACe,CAAA"}
|
package/flows/lib/epic-design.js
CHANGED
|
@@ -429,8 +429,9 @@ export const coveragePath = (targetDir) => join(epicsDir(targetDir), "coverage.m
|
|
|
429
429
|
export const epicProgressOf = (epics) => epics.map((epic) => ({
|
|
430
430
|
epicId: epic.dir,
|
|
431
431
|
planned: true,
|
|
432
|
-
stories
|
|
433
|
-
|
|
432
|
+
// A refine round's stories are the epic's work too (ADR 0021).
|
|
433
|
+
stories: epic.rounds.reduce((sum, round) => sum + (round.plan?.stories.length ?? 0), epic.stories),
|
|
434
|
+
merged: epic.rounds.reduce((sum, round) => sum + round.merged, epic.merged),
|
|
434
435
|
landed: epic.landed !== undefined
|
|
435
436
|
}));
|
|
436
437
|
/**
|
|
@@ -13,14 +13,16 @@ import { TokenUsage } from "@llm4ts/core/Models";
|
|
|
13
13
|
import { cap } from "@llm4ts/flow/Context";
|
|
14
14
|
import { parseEpicBrief, renderEpicBrief, unanswered } from "@llm4ts/flow/EpicBrief";
|
|
15
15
|
import { structuredAndPublish } from "@llm4ts/flow/Flow";
|
|
16
|
-
import { EpicBriefInvalid, EpicBriefNotApproved } from "@llm4ts/flow/FlowError";
|
|
16
|
+
import { EpicBriefInvalid, EpicBriefNotApproved, EpicCheckoutDirty, LandingFailed, RefineRefused } from "@llm4ts/flow/FlowError";
|
|
17
17
|
import { Info } from "@llm4ts/flow/FlowEvents";
|
|
18
|
+
import { statusPaths } from "@llm4ts/flow/GitTool";
|
|
18
19
|
import { EpicLanded, EpicLandedVersion, landedPath } from "@llm4ts/flow/Landing";
|
|
20
|
+
import { RefineProposal, assembleRound, countNotPlanned, earlierStories, refineProposalJsonSchema, renderNotPlanned, roundPrefix, roundRepair, runAction } from "@llm4ts/flow/RefineRound";
|
|
19
21
|
import { loadVersioned } from "@llm4ts/flow/Persistence";
|
|
20
22
|
import { BlockedVerdict, StoryState, StoryStateVersion } from "@llm4ts/flow/Stories";
|
|
21
23
|
import { stableHash } from "@llm4ts/flow/Plan";
|
|
22
24
|
import { ReviewIssue } from "@llm4ts/flow/Review";
|
|
23
|
-
import { StoryPlan, dependenciesOf, makeStoryPlanStore, pathsNamedIn } from "@llm4ts/flow/StoryPlan";
|
|
25
|
+
import { StoryPlan, dependenciesOf, makeStoryPlanStore, parseStoryPlan, pathsNamedIn } from "@llm4ts/flow/StoryPlan";
|
|
24
26
|
import * as Console from "effect/Console";
|
|
25
27
|
import { CliConnectorConfig } from "@llm4ts/core/ConnectorConfig";
|
|
26
28
|
import { budget } from "@llm4ts/flow/Context";
|
|
@@ -42,6 +44,9 @@ export const epicUsage = [
|
|
|
42
44
|
" --list list this repository's epics (id, stories merged, landed) and stop",
|
|
43
45
|
" --epic <id> work on an existing epic by id, no text needed. Without text or",
|
|
44
46
|
" --epic, the one epic not landed yet is chosen; several stop the run",
|
|
47
|
+
" --refine the text is feedback on the finished epic: plan a round of follow-up",
|
|
48
|
+
" stories from it and run them on the epic branch (before landing).",
|
|
49
|
+
" A plain rerun finishes an open round; --plan-only stops after planning",
|
|
45
50
|
" --concurrency <n> stories implemented at once (default 3)",
|
|
46
51
|
" --fail-fast stop the epic at the first failed story",
|
|
47
52
|
"Seats: LLM4TS_REASONER (claude|gemini|…, default claude) splits, reviews, judges;",
|
|
@@ -64,6 +69,7 @@ export const parseEpicArgs = (argv) => Effect.gen(function* () {
|
|
|
64
69
|
let land;
|
|
65
70
|
let keepWorktrees = false;
|
|
66
71
|
let list = false;
|
|
72
|
+
let refine = false;
|
|
67
73
|
let epic;
|
|
68
74
|
const rest = [];
|
|
69
75
|
for (let index = 0; index < argv.length; index += 1) {
|
|
@@ -80,6 +86,9 @@ export const parseEpicArgs = (argv) => Effect.gen(function* () {
|
|
|
80
86
|
else if (argument === "--list") {
|
|
81
87
|
list = true;
|
|
82
88
|
}
|
|
89
|
+
else if (argument === "--refine") {
|
|
90
|
+
refine = true;
|
|
91
|
+
}
|
|
83
92
|
else if (argument === "--epic" || argument.startsWith("--epic=")) {
|
|
84
93
|
const value = argument.includes("=") ? argument.slice("--epic=".length) : argv[index + 1];
|
|
85
94
|
if (!argument.includes("=")) {
|
|
@@ -117,8 +126,79 @@ export const parseEpicArgs = (argv) => Effect.gen(function* () {
|
|
|
117
126
|
rest.push(argument);
|
|
118
127
|
}
|
|
119
128
|
}
|
|
120
|
-
return { planOnly, failFast, concurrency, land, keepWorktrees, list, epic, rest };
|
|
129
|
+
return { planOnly, failFast, concurrency, land, keepWorktrees, list, epic, refine, rest };
|
|
130
|
+
});
|
|
131
|
+
export const roundDir = (stateDir, round) => join(stateDir, "rounds", String(round));
|
|
132
|
+
const mergedStories = (files, stateDir, plan) => Effect.gen(function* () {
|
|
133
|
+
let merged = 0;
|
|
134
|
+
for (const story of plan.stories) {
|
|
135
|
+
const state = yield* loadVersioned(files, join(stateDir, "stories", `${story.id}.json`), StoryStateVersion, StoryState).pipe(Effect.catch(() => Effect.succeed(undefined)));
|
|
136
|
+
if (state?.status === "merged") {
|
|
137
|
+
merged += 1;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return merged;
|
|
141
|
+
});
|
|
142
|
+
/**
|
|
143
|
+
* The epic's rounds, numbered from 1 without gaps: the first `rounds/<n>`
|
|
144
|
+
* without a plan ends the list. A plan that does not parse, or breaks the
|
|
145
|
+
* plan's rules, is a round that cannot be used, never one silently skipped.
|
|
146
|
+
*/
|
|
147
|
+
export const loadRounds = (files, stateDir) => Effect.gen(function* () {
|
|
148
|
+
const rounds = [];
|
|
149
|
+
for (let round = 1;; round += 1) {
|
|
150
|
+
const dir = roundDir(stateDir, round);
|
|
151
|
+
const path = join(dir, "plan.md");
|
|
152
|
+
const text = yield* files.read(path);
|
|
153
|
+
if (text === undefined) {
|
|
154
|
+
// A plan missing while the next round has one is a hole, not the end.
|
|
155
|
+
if ((yield* files.read(join(roundDir(stateDir, round + 1), "plan.md"))) === undefined) {
|
|
156
|
+
return rounds;
|
|
157
|
+
}
|
|
158
|
+
rounds.push({
|
|
159
|
+
round,
|
|
160
|
+
stateDir: dir,
|
|
161
|
+
plan: undefined,
|
|
162
|
+
merged: 0,
|
|
163
|
+
notPlanned: 0,
|
|
164
|
+
unreadable: `${path} is missing while round ${round + 1} exists`
|
|
165
|
+
});
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
const notPlanned = countNotPlanned((yield* files.read(join(dir, "not-planned.md"))) ?? "");
|
|
169
|
+
const parsed = yield* Effect.result(parseStoryPlan(text).pipe(Effect.flatMap(validateStoryPlan)));
|
|
170
|
+
if (Result.isSuccess(parsed)) {
|
|
171
|
+
rounds.push({
|
|
172
|
+
round,
|
|
173
|
+
stateDir: dir,
|
|
174
|
+
plan: parsed.success,
|
|
175
|
+
merged: yield* mergedStories(files, dir, parsed.success),
|
|
176
|
+
notPlanned,
|
|
177
|
+
unreadable: undefined
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
rounds.push({
|
|
182
|
+
round,
|
|
183
|
+
stateDir: dir,
|
|
184
|
+
plan: undefined,
|
|
185
|
+
merged: 0,
|
|
186
|
+
notPlanned,
|
|
187
|
+
unreadable: `${path}: ${parsed.failure.message}`
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
121
191
|
});
|
|
192
|
+
/** The rounds as the run decision reads them. */
|
|
193
|
+
export const roundProgress = (rounds) => rounds.map((round) => ({
|
|
194
|
+
round: round.round,
|
|
195
|
+
stories: round.plan?.stories.length ?? 0,
|
|
196
|
+
merged: round.merged,
|
|
197
|
+
...(round.unreadable === undefined ? {} : { unreadable: round.unreadable })
|
|
198
|
+
}));
|
|
199
|
+
const renderRound = (round) => round.plan === undefined
|
|
200
|
+
? `round ${round.round}: unreadable plan`
|
|
201
|
+
: `round ${round.round}: ${round.merged}/${round.plan.stories.length} merged${round.notPlanned === 0 ? "" : `, ${round.notPlanned} not planned`}`;
|
|
122
202
|
export const epicsDir = (workDir) => join(workDir, ".llm4ts", "epics");
|
|
123
203
|
/** Every epic with a plan in this repository, oldest folder name first. */
|
|
124
204
|
export const listEpics = (files, workDir) => Effect.gen(function* () {
|
|
@@ -137,13 +217,7 @@ export const listEpics = (files, workDir) => Effect.gen(function* () {
|
|
|
137
217
|
if (plan === undefined) {
|
|
138
218
|
continue;
|
|
139
219
|
}
|
|
140
|
-
|
|
141
|
-
for (const story of plan.stories) {
|
|
142
|
-
const state = yield* loadVersioned(files, join(stateDir, "stories", `${story.id}.json`), StoryStateVersion, StoryState).pipe(Effect.catch(() => Effect.succeed(undefined)));
|
|
143
|
-
if (state?.status === "merged") {
|
|
144
|
-
merged += 1;
|
|
145
|
-
}
|
|
146
|
-
}
|
|
220
|
+
const merged = yield* mergedStories(files, stateDir, plan);
|
|
147
221
|
const landed = yield* loadVersioned(files, landedPath(stateDir), EpicLandedVersion, EpicLanded).pipe(Effect.catch(() => Effect.succeed(undefined)));
|
|
148
222
|
summaries.push({
|
|
149
223
|
dir: entry.name,
|
|
@@ -152,7 +226,19 @@ export const listEpics = (files, workDir) => Effect.gen(function* () {
|
|
|
152
226
|
epic: plan.epic,
|
|
153
227
|
stories: plan.stories.length,
|
|
154
228
|
merged,
|
|
155
|
-
landed: landed?.target
|
|
229
|
+
landed: landed?.target,
|
|
230
|
+
// Rounds that cannot be read are reported, never dropped: an epic
|
|
231
|
+
// must not look free of rounds when it lands.
|
|
232
|
+
rounds: yield* loadRounds(files, stateDir).pipe(Effect.catch((error) => Effect.succeed([
|
|
233
|
+
{
|
|
234
|
+
round: 1,
|
|
235
|
+
stateDir: roundDir(stateDir, 1),
|
|
236
|
+
plan: undefined,
|
|
237
|
+
merged: 0,
|
|
238
|
+
notPlanned: 0,
|
|
239
|
+
unreadable: `${join(stateDir, "rounds")}: ${error.message}`
|
|
240
|
+
}
|
|
241
|
+
])))
|
|
156
242
|
});
|
|
157
243
|
}
|
|
158
244
|
return summaries;
|
|
@@ -166,7 +252,7 @@ export const renderEpicList = (epics) => epics.length === 0
|
|
|
166
252
|
? epic.merged === epic.stories
|
|
167
253
|
? "finished, not landed"
|
|
168
254
|
: "in progress"
|
|
169
|
-
: `landed on ${epic.landed}`} (--epic ${epic.epicId})`,
|
|
255
|
+
: `landed on ${epic.landed}`}${epic.rounds.map((round) => ` · ${renderRound(round)}`).join("")} (--epic ${epic.epicId})`,
|
|
170
256
|
` "${epic.epic.length > 110 ? `${epic.epic.slice(0, 109)}…` : epic.epic}"`
|
|
171
257
|
])
|
|
172
258
|
].join("\n");
|
|
@@ -569,6 +655,128 @@ export const generateStoryPlan = (reasoning, events, epic, epicId, guidance,
|
|
|
569
655
|
brief) => structuredAndPublish(reasoning, events, `${storyPlanInstructions(epicId, guidance)}\n\nEpic:\n${brief ?? epic}`, StoryPlan, storyPlanJsonSchema).pipe(
|
|
570
656
|
// The id and the epic text are ours, whatever the model echoed back.
|
|
571
657
|
Effect.map((plan) => StoryPlan.make({ ...plan, epicId, epic })));
|
|
658
|
+
/** The round planner's constraints: the story planner's, for feedback on finished work. */
|
|
659
|
+
export const refinePlanInstructions = (inputs) => [
|
|
660
|
+
"You are the orchestrator of a parallel implementation. The epic below is finished: every",
|
|
661
|
+
"story is merged into the epic branch, which is checked out in front of you. A person tried",
|
|
662
|
+
"it and gave the feedback at the end of this message. Turn the feedback into follow-up",
|
|
663
|
+
"stories that independent coding agents will implement AT THE SAME TIME, each in its own git",
|
|
664
|
+
"worktree, each confined to the paths it owns. Read the code before you answer.",
|
|
665
|
+
"",
|
|
666
|
+
"Rules (violations are rejected mechanically):",
|
|
667
|
+
"- Every story has a kebab-case id, a title, a description precise enough to implement alone,",
|
|
668
|
+
" `dependsOn`, `owned` (repo-relative paths it may create or change), `sharedReadOnly`",
|
|
669
|
+
" (prefixes it may read but never change), and `provides`.",
|
|
670
|
+
"- One story per feedback item, or per group of items that change the same files. The",
|
|
671
|
+
" description quotes the feedback items it answers, word for word.",
|
|
672
|
+
"- `owned` lists the files the story will change, existing files included: name them from the",
|
|
673
|
+
" code. The earlier stories below are merged, so their paths are free to claim.",
|
|
674
|
+
"- `owned` sets are pairwise DISJOINT within this round: items that need the same file go",
|
|
675
|
+
" into ONE story.",
|
|
676
|
+
"- `dependsOn` names stories of THIS round only, and only when one needs the other's result.",
|
|
677
|
+
"- `provides` says what the person who gave the feedback will see changed.",
|
|
678
|
+
"- Tests that cover the changed behaviour are updated in the same story, and the test files",
|
|
679
|
+
" are in its `owned`.",
|
|
680
|
+
"- Shared surfaces (the kit, the theme, house rules) are owned only by a story the feedback",
|
|
681
|
+
" asks to change them.",
|
|
682
|
+
"- An item that is unclear, contradicts another item, or cannot be tied to a file is NOT",
|
|
683
|
+
" guessed: it goes to `notPlanned` with the question whose answer would make it plannable.",
|
|
684
|
+
`- Ids are short; each is prefixed \`${roundPrefix(inputs.round)}\` for you (round ${inputs.round} of epic "${inputs.epicId}").`,
|
|
685
|
+
"",
|
|
686
|
+
"Respond only with JSON:",
|
|
687
|
+
'{"stories":[{"id":"...","title":"...","description":"...","dependsOn":[],"owned":[],',
|
|
688
|
+
'"sharedReadOnly":[],"provides":[]}],"notPlanned":[{"item":"...","reason":"..."}]}',
|
|
689
|
+
"",
|
|
690
|
+
"Earlier stories (merged: what was built, and where):",
|
|
691
|
+
...(inputs.earlier.length === 0
|
|
692
|
+
? ["(none)"]
|
|
693
|
+
: inputs.earlier.map((story) => `- ${story.id}: ${story.title} — owns: ${story.owned.join(", ")} — provides: ${story.provides.join("; ")}`)),
|
|
694
|
+
...(inputs.brief === undefined ? [] : ["", "The epic's approved brief:", inputs.brief]),
|
|
695
|
+
...(inputs.openItems === undefined
|
|
696
|
+
? []
|
|
697
|
+
: [
|
|
698
|
+
"",
|
|
699
|
+
"Left unplanned in the previous round (the new feedback may answer these):",
|
|
700
|
+
inputs.openItems
|
|
701
|
+
]),
|
|
702
|
+
"",
|
|
703
|
+
"Target repository guidance (house rules and layout — the vocabulary to use):",
|
|
704
|
+
inputs.guidance
|
|
705
|
+
].join("\n");
|
|
706
|
+
export const generateRefineProposal = (reasoning, events, inputs) => structuredAndPublish(reasoning, events, `${refinePlanInstructions(inputs)}\n\nFeedback:\n${inputs.feedback}`, RefineProposal, refineProposalJsonSchema);
|
|
707
|
+
const bulletLines = (markdown) => markdown
|
|
708
|
+
.split("\n")
|
|
709
|
+
.filter((line) => line.startsWith("- "))
|
|
710
|
+
.join("\n");
|
|
711
|
+
/**
|
|
712
|
+
* Plans a round from feedback and writes it: `feedback.md`, `plan.md`, and
|
|
713
|
+
* `not-planned.md` when something was left out. Nothing is written unless the
|
|
714
|
+
* plan is valid, and no round is created when no story could be planned.
|
|
715
|
+
*/
|
|
716
|
+
export const planRound = Effect.fn("flows/epic-stories.planRound")(function* (deps) {
|
|
717
|
+
const open = deps.round > 1
|
|
718
|
+
? bulletLines((yield* deps.files.read(join(roundDir(deps.stateDir, deps.round - 1), "not-planned.md"))) ?? "")
|
|
719
|
+
: "";
|
|
720
|
+
const earlier = earlierStories(deps.plans);
|
|
721
|
+
const proposal = yield* generateRefineProposal(deps.reasoning, deps.events, {
|
|
722
|
+
epicId: deps.epicId,
|
|
723
|
+
round: deps.round,
|
|
724
|
+
guidance: deps.guidance,
|
|
725
|
+
earlier,
|
|
726
|
+
feedback: deps.feedback,
|
|
727
|
+
...(deps.brief === undefined ? {} : { brief: deps.brief }),
|
|
728
|
+
...(open.length === 0 ? {} : { openItems: open })
|
|
729
|
+
});
|
|
730
|
+
if (proposal.stories.length === 0) {
|
|
731
|
+
return { plan: undefined, notPlanned: proposal.notPlanned };
|
|
732
|
+
}
|
|
733
|
+
const plan = yield* validateStoryPlan(assembleRound({
|
|
734
|
+
epicId: deps.epicId,
|
|
735
|
+
round: deps.round,
|
|
736
|
+
feedback: deps.feedback,
|
|
737
|
+
proposal,
|
|
738
|
+
earlier: earlier.map((story) => story.id)
|
|
739
|
+
}));
|
|
740
|
+
const dir = roundDir(deps.stateDir, deps.round);
|
|
741
|
+
yield* deps.files.writeAtomic(join(dir, "feedback.md"), `${deps.feedback}\n`);
|
|
742
|
+
// Always written: a reused folder must not keep an older round's list.
|
|
743
|
+
yield* deps.files.writeAtomic(join(dir, "not-planned.md"), renderNotPlanned(deps.round, proposal.notPlanned));
|
|
744
|
+
// The plan goes last: it is what makes the folder a round.
|
|
745
|
+
yield* makeStoryPlanStore(deps.files).save(join(dir, "plan.md"), plan);
|
|
746
|
+
return { plan, notPlanned: proposal.notPlanned };
|
|
747
|
+
});
|
|
748
|
+
/**
|
|
749
|
+
* The epic a `--refine` run works on. The text on the command line is
|
|
750
|
+
* feedback, so it never names or creates an epic: only one with a story plan
|
|
751
|
+
* can take a round.
|
|
752
|
+
*/
|
|
753
|
+
export const refineEpic = (choice) => choice._tag === "Existing"
|
|
754
|
+
? Effect.succeed(choice.epic)
|
|
755
|
+
: choice._tag === "Brief"
|
|
756
|
+
? Effect.fail(RefineRefused.make({
|
|
757
|
+
epicId: choice.dir,
|
|
758
|
+
reason: "it has no story plan yet: run the epic first"
|
|
759
|
+
}))
|
|
760
|
+
: Effect.fail(ScriptUsage.make({
|
|
761
|
+
message: `--refine needs a finished epic, and this repository has none to pick: run an epic first, or name one with --epic <id>\n${epicUsage}`
|
|
762
|
+
}));
|
|
763
|
+
/**
|
|
764
|
+
* The planned epic a choice lands on: the one picked, or the one whose folder
|
|
765
|
+
* the given text derives (an epic rerun by its text is still that epic).
|
|
766
|
+
*/
|
|
767
|
+
export const epicOnDisk = (choice, epics) => choice._tag === "Existing"
|
|
768
|
+
? choice.epic
|
|
769
|
+
: choice._tag === "Text"
|
|
770
|
+
? epics.find((epic) => epic.dir === epicIdFor(choice.prompt))
|
|
771
|
+
: undefined;
|
|
772
|
+
/** The rounds as the landing takes them; one whose plan cannot be read stops it. */
|
|
773
|
+
export const landRounds = (epicId, target, rounds) => Effect.forEach(rounds, (round) => round.plan === undefined
|
|
774
|
+
? Effect.fail(LandingFailed.make({
|
|
775
|
+
epicBranch: `epic/${epicId}`,
|
|
776
|
+
target,
|
|
777
|
+
reason: `round ${round.round}'s plan cannot be read (${round.unreadable ?? ""}); ${roundRepair(round.round)}`
|
|
778
|
+
}))
|
|
779
|
+
: Effect.succeed({ plan: round.plan, stateDir: round.stateDir }));
|
|
572
780
|
// ---- Usage ---------------------------------------------------------------------
|
|
573
781
|
/** Sums two meters into one per-story estimate. */
|
|
574
782
|
export const combineTotals = (first, second) => Effect.map(Effect.all([first, second]), ([left, right]) => {
|
|
@@ -811,13 +1019,45 @@ export const runEpicStories = (options) => Effect.gen(function* () {
|
|
|
811
1019
|
yield* Console.log(renderEpicList(epics));
|
|
812
1020
|
return;
|
|
813
1021
|
}
|
|
1022
|
+
// With `--refine` the text is feedback on a finished epic, never an epic's text.
|
|
1023
|
+
const feedback = flags.refine ? given.prompt.trim() : "";
|
|
814
1024
|
const choice = yield* chooseEpic({
|
|
815
|
-
text: given.prompt,
|
|
1025
|
+
text: flags.refine ? "" : given.prompt,
|
|
816
1026
|
epic: flags.epic,
|
|
817
1027
|
epics,
|
|
818
1028
|
briefs: yield* listBriefs(files, given.workDir, yield* epicDirs(given.workDir)),
|
|
819
1029
|
defaultEpic
|
|
820
1030
|
});
|
|
1031
|
+
if (flags.refine) {
|
|
1032
|
+
yield* refineEpic(choice);
|
|
1033
|
+
}
|
|
1034
|
+
// What this run does (ADR 0021): the plan's stories, the open refine round,
|
|
1035
|
+
// a new round, or the landing. Refusals come before any seat is resolved.
|
|
1036
|
+
const existing = epicOnDisk(choice, epics);
|
|
1037
|
+
const rounds = existing?.rounds ?? [];
|
|
1038
|
+
const action = existing !== undefined
|
|
1039
|
+
? runAction({
|
|
1040
|
+
planned: true,
|
|
1041
|
+
stories: existing.stories,
|
|
1042
|
+
merged: existing.merged,
|
|
1043
|
+
landed: existing.landed,
|
|
1044
|
+
rounds: roundProgress(rounds),
|
|
1045
|
+
refine: flags.refine,
|
|
1046
|
+
feedback,
|
|
1047
|
+
land: flags.land !== undefined
|
|
1048
|
+
})
|
|
1049
|
+
: flags.land !== undefined
|
|
1050
|
+
? { _tag: "Land" }
|
|
1051
|
+
: { _tag: "RunPlan" };
|
|
1052
|
+
if (action._tag === "Refused") {
|
|
1053
|
+
return yield* RefineRefused.make({
|
|
1054
|
+
epicId: existing?.epicId ?? "",
|
|
1055
|
+
reason: action.reason
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
if (action._tag === "Usage") {
|
|
1059
|
+
return yield* ScriptUsage.make({ message: `${action.message}\n${epicUsage}` });
|
|
1060
|
+
}
|
|
821
1061
|
const input = {
|
|
822
1062
|
...given,
|
|
823
1063
|
prompt: choice._tag === "Existing"
|
|
@@ -869,9 +1109,72 @@ export const runEpicStories = (options) => Effect.gen(function* () {
|
|
|
869
1109
|
yield* events.publish(Info.make({
|
|
870
1110
|
message: `story plan: ${plan.stories.length} stories at ${planPath} (edit and rerun to re-plan)`
|
|
871
1111
|
}));
|
|
872
|
-
if (flags.planOnly) {
|
|
1112
|
+
if (flags.planOnly && action._tag !== "PlanRound") {
|
|
873
1113
|
return;
|
|
874
1114
|
}
|
|
1115
|
+
const epicBranch = `epic/${plan.epicId}`;
|
|
1116
|
+
// The unit the executor works on: the epic's plan, or one refine round.
|
|
1117
|
+
// Same branch, seats, gates and judge; its own plan and state folder.
|
|
1118
|
+
let unit = { plan, stateDir, label: `Epic: ${plan.epicId}` };
|
|
1119
|
+
if (action._tag === "RunRound") {
|
|
1120
|
+
const open = rounds.find((round) => round.round === action.round);
|
|
1121
|
+
if (open?.plan !== undefined) {
|
|
1122
|
+
unit = {
|
|
1123
|
+
plan: open.plan,
|
|
1124
|
+
stateDir: open.stateDir,
|
|
1125
|
+
label: `Epic: ${plan.epicId} · round ${open.round}`
|
|
1126
|
+
};
|
|
1127
|
+
yield* events.publish(Info.make({
|
|
1128
|
+
message: `refine round ${open.round}: ${open.merged}/${open.plan.stories.length} stories merged; finishing it`
|
|
1129
|
+
}));
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
if (action._tag === "PlanRound") {
|
|
1133
|
+
// The planner reads the code as the person tried it: the epic
|
|
1134
|
+
// branch, with nothing uncommitted carried over onto it.
|
|
1135
|
+
const stray = statusPaths(yield* context.git.status);
|
|
1136
|
+
if (stray.length > 0) {
|
|
1137
|
+
return yield* EpicCheckoutDirty.make({ checkout: input.workDir, paths: stray });
|
|
1138
|
+
}
|
|
1139
|
+
yield* stage(events, "epic branch", context.git.checkoutOrCreate(epicBranch));
|
|
1140
|
+
const roundBrief = yield* files.read(join(stateDir, "brief.md"));
|
|
1141
|
+
const planned = yield* stage(events, `refine round ${action.round} plan`, planRound({
|
|
1142
|
+
files,
|
|
1143
|
+
reasoning: reasoningMeter.service,
|
|
1144
|
+
events,
|
|
1145
|
+
stateDir,
|
|
1146
|
+
epicId: plan.epicId,
|
|
1147
|
+
round: action.round,
|
|
1148
|
+
feedback,
|
|
1149
|
+
guidance,
|
|
1150
|
+
plans: [
|
|
1151
|
+
plan,
|
|
1152
|
+
...rounds.flatMap((round) => (round.plan === undefined ? [] : [round.plan]))
|
|
1153
|
+
],
|
|
1154
|
+
...(roundBrief === undefined ? {} : { brief: roundBrief })
|
|
1155
|
+
}));
|
|
1156
|
+
for (const left of planned.notPlanned) {
|
|
1157
|
+
yield* events.publish(Info.make({ message: `not planned: ${left.item} — ${left.reason}` }));
|
|
1158
|
+
}
|
|
1159
|
+
if (planned.plan === undefined) {
|
|
1160
|
+
yield* events.publish(Info.make({
|
|
1161
|
+
message: `refine round ${action.round}: no feedback item could be planned; answer the questions above and run --refine again`
|
|
1162
|
+
}));
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
const dir = roundDir(stateDir, action.round);
|
|
1166
|
+
yield* events.publish(Info.make({
|
|
1167
|
+
message: `refine round ${action.round}: ${planned.plan.stories.length} follow-up stories at ${join(dir, "plan.md")} (edit and rerun to re-plan)${planned.notPlanned.length === 0 ? "" : `; ${planned.notPlanned.length} item(s) not planned, listed in ${join(dir, "not-planned.md")}`}`
|
|
1168
|
+
}));
|
|
1169
|
+
if (flags.planOnly) {
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1172
|
+
unit = {
|
|
1173
|
+
plan: planned.plan,
|
|
1174
|
+
stateDir: dir,
|
|
1175
|
+
label: `Epic: ${plan.epicId} · round ${action.round}`
|
|
1176
|
+
};
|
|
1177
|
+
}
|
|
875
1178
|
// Setup and gates run where the application is, in every checkout.
|
|
876
1179
|
const appDir = yield* appDirFor(input.workDir, process.env);
|
|
877
1180
|
if (appDir !== ".") {
|
|
@@ -891,6 +1194,7 @@ export const runEpicStories = (options) => Effect.gen(function* () {
|
|
|
891
1194
|
files,
|
|
892
1195
|
stateDir,
|
|
893
1196
|
target: flags.land,
|
|
1197
|
+
rounds: yield* landRounds(plan.epicId, flags.land, rounds),
|
|
894
1198
|
keepWorktrees: flags.keepWorktrees,
|
|
895
1199
|
gates: inAppDir(appDir, gatesIn(nodeProcessExecutor, events, commands)),
|
|
896
1200
|
system: ["House rules of the target repository (CONTRIBUTING.md):", guidance].join("\n")
|
|
@@ -925,11 +1229,12 @@ export const runEpicStories = (options) => Effect.gen(function* () {
|
|
|
925
1229
|
const setupCommand = worktreeSetupCommand(process.env);
|
|
926
1230
|
const healthUrl = serverHealthUrl(localServer, process.env);
|
|
927
1231
|
const report = yield* implementStoriesFlow({ ...context, reasoning: reasoningMeter.service }, {
|
|
928
|
-
plan,
|
|
1232
|
+
plan: unit.plan,
|
|
929
1233
|
files,
|
|
930
|
-
stateDir,
|
|
1234
|
+
stateDir: unit.stateDir,
|
|
1235
|
+
epicBranch,
|
|
931
1236
|
worktreeRoot: worktreeRootFor(input.workDir, plan.epicId, process.env),
|
|
932
|
-
board: makeLocalBoardSync(files, stateDir,
|
|
1237
|
+
board: makeLocalBoardSync(files, unit.stateDir, unit.label),
|
|
933
1238
|
contextFor: (workDir, contextOptions) => Effect.gen(function* () {
|
|
934
1239
|
const rebound = yield* contextFor(workDir, contextOptions);
|
|
935
1240
|
const coderMeter = yield* makeEstimatedUsageMeter(rebound.coder, estimateOptions);
|
|
@@ -954,14 +1259,14 @@ export const runEpicStories = (options) => Effect.gen(function* () {
|
|
|
954
1259
|
// With a roster, the judge and the verifier are leased per story,
|
|
955
1260
|
// away from the executor coding it (ADR 0019).
|
|
956
1261
|
judge: options.storyJudge({
|
|
957
|
-
plan,
|
|
1262
|
+
plan: unit.plan,
|
|
958
1263
|
budget: contextBudget,
|
|
959
1264
|
reasoning: reasoningMeter.service,
|
|
960
1265
|
events,
|
|
961
1266
|
files,
|
|
962
1267
|
houseRules: guidance
|
|
963
1268
|
}),
|
|
964
|
-
verifyBlocked: (story, need, workDir, seats) => verifyBlockedOn(seats.context.roster?.forRole("verifier") ?? reasoningMeter.service, events, files, plan)(story, need, workDir),
|
|
1269
|
+
verifyBlocked: (story, need, workDir, seats) => verifyBlockedOn(seats.context.roster?.forRole("verifier") ?? reasoningMeter.service, events, files, unit.plan)(story, need, workDir),
|
|
965
1270
|
// A roster waits for its own executors (the story's next lease
|
|
966
1271
|
// does); without one, poll the single coder's engine.
|
|
967
1272
|
awaitRecovery: context.roster === undefined
|
|
@@ -978,7 +1283,7 @@ export const runEpicStories = (options) => Effect.gen(function* () {
|
|
|
978
1283
|
failFast: flags.failFast
|
|
979
1284
|
});
|
|
980
1285
|
yield* events.publish(Info.make({
|
|
981
|
-
message:
|
|
1286
|
+
message: `${unit.label.replace("Epic: ", "epic ")}: ${report.count("done")} done, ${report.count("failed")} failed, ${report.count("waiting")} waiting — report at ${join(unit.stateDir, "report.md")} (usage figures estimated)`
|
|
982
1287
|
}));
|
|
983
1288
|
}));
|
|
984
1289
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@llm4ts/shell",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.19.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/
|
|
57
|
-
"@llm4ts/
|
|
55
|
+
"@llm4ts/core": "2.19.0",
|
|
56
|
+
"@llm4ts/flow": "2.19.0",
|
|
57
|
+
"@llm4ts/runner": "2.19.0"
|
|
58
58
|
},
|
|
59
59
|
"peerDependencies": {
|
|
60
60
|
"effect": "4.0.0-rc.115"
|