@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 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,sqDAwBxB,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"}
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"}
@@ -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: epic.stories,
433
- merged: epic.merged,
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
- let merged = 0;
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, `Epic: ${plan.epicId}`),
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: `epic ${report.epicId}: ${report.count("done")} done, ${report.count("failed")} failed, ${report.count("waiting")} waiting — report at ${join(stateDir, "report.md")} (usage figures estimated)`
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.18.0",
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.18.0",
56
- "@llm4ts/runner": "2.18.0",
57
- "@llm4ts/flow": "2.18.0"
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"