@llm4ts/shell 2.17.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"}
@@ -4,6 +4,7 @@
4
4
  // llm4ts run epic-design --repo ~/work/portal "Current account: balance, movements, statements"
5
5
  // llm4ts run epic-design --repo ~/work/portal -- --epic <id> # revise, or validate once approved
6
6
  // llm4ts run epic-design --repo ~/work/portal -- --list # the repository's briefs
7
+ // llm4ts run epic-design --repo ~/work/portal -- --coverage # the ledger across every brief
7
8
  //
8
9
  // Runs rooted at the TARGET repository. The legacy repository must hold
9
10
  // modernize-extract's pack (docs/modernization/); modernize-refine's
@@ -18,34 +19,57 @@
18
19
  // from the approved brief. Seat: LLM4TS_REASONER (read-only, default
19
20
  // claude). LLM4TS_PACK describes the target stack when the target is new.
20
21
  // LLM4TS_CONTEXT_BUDGET bounds the legacy evidence in the prompt.
22
+ //
23
+ // Briefs of one repository are read together. What the approved briefs of
24
+ // other epics decided is inherited: a scenario they dropped or found in the
25
+ // target is not restated, one they own is not proposed again, one they
26
+ // deferred is offered; deciding otherwise is a `[check]` open point naming
27
+ // the other epic. --coverage writes .llm4ts/epics/coverage.md, the ledger of
28
+ // every scenario of the pack against every brief, without calling a model.
21
29
  import { access, readdir } from "node:fs/promises";
22
- import { join } from "node:path";
30
+ import { join, resolve } from "node:path";
23
31
  import * as Console from "effect/Console";
24
32
  import * as Effect from "effect/Effect";
25
33
  import { budget, cap } from "@llm4ts/flow/Context";
26
34
  import { Info, ScriptUsage, asReadOnly, nodePlainFileStore, openPack, resolveFlowInput, runFlowMain, runNode, stage } from "@llm4ts/runner";
27
- import { designEpic, parseEpicDesignArgs, renderBriefList, renderOutcome, resolveDesignTarget } from "./lib/epic-design.js";
28
- import { epicDirs, listBriefs, reasonerFromEnvironment } from "./lib/epic-stories.js";
35
+ import { coverageReport, designEpic, parseEpicDesignArgs, renderBriefList, renderOutcome, resolveDesignTarget } from "./lib/epic-design.js";
36
+ import { epicDirs, listBriefs, listEpics, loadBriefs, reasonerFromEnvironment } from "./lib/epic-stories.js";
29
37
  import { ModDir } from "./lib/modernize-extract.js";
30
38
  const program = Effect.gen(function* () {
31
39
  const flags = yield* parseEpicDesignArgs(process.argv.slice(2));
32
40
  const given = yield* resolveFlowInput("", flags.rest);
33
41
  const files = nodePlainFileStore;
34
- const briefs = yield* listBriefs(files, given.workDir, yield* epicDirs(given.workDir));
42
+ const dirs = yield* epicDirs(given.workDir);
43
+ const briefs = yield* listBriefs(files, given.workDir, dirs);
35
44
  if (flags.list) {
36
45
  yield* Console.log(renderBriefList(briefs));
37
46
  return;
38
47
  }
39
- const legacyRepo = process.env.LLM4TS_LEGACY_REPO?.trim();
40
- if (legacyRepo === undefined || legacyRepo.length === 0) {
48
+ const legacyGiven = process.env.LLM4TS_LEGACY_REPO?.trim();
49
+ if (legacyGiven === undefined || legacyGiven.length === 0) {
41
50
  return yield* ScriptUsage.make({
42
51
  message: "LLM4TS_LEGACY_REPO is not set: point it at the legacy repository holding docs/modernization/"
43
52
  });
44
53
  }
45
- const target = yield* resolveDesignTarget({ text: given.prompt, epic: flags.epic, briefs });
54
+ // One spelling of the path, so every brief of this repository names the same legacy.
55
+ const legacyRepo = resolve(given.workspace, legacyGiven);
46
56
  const specNames = yield* Effect.tryPromise(() => readdir(join(legacyRepo, ModDir, "specs"))).pipe(Effect.map((names) => names
47
57
  .filter((name) => name.endsWith(".md") && name !== "README.md")
48
58
  .map((name) => name.slice(0, -".md".length))), Effect.catch(() => Effect.succeed([])));
59
+ if (flags.coverage) {
60
+ const report = yield* coverageReport({
61
+ files,
62
+ targetDir: given.workDir,
63
+ legacyRepo,
64
+ specNames,
65
+ dirs,
66
+ epics: yield* listEpics(files, given.workDir)
67
+ });
68
+ yield* Console.log(`${report.headline}\n${report.path}`);
69
+ return;
70
+ }
71
+ const target = yield* resolveDesignTarget({ text: given.prompt, epic: flags.epic, briefs });
72
+ const loaded = yield* loadBriefs(files, given.workDir, dirs);
49
73
  const reasoning = asReadOnly(yield* reasonerFromEnvironment(process.env));
50
74
  yield* runNode({
51
75
  workDir: given.workDir,
@@ -83,6 +107,8 @@ const program = Effect.gen(function* () {
83
107
  budget: budget(process.env),
84
108
  guidance,
85
109
  packNote,
110
+ others: loaded.briefs,
111
+ unreadable: loaded.unreadable,
86
112
  pathExists: (absolute) => Effect.tryPromise(() => access(absolute)).pipe(Effect.as(true), Effect.catch(() => Effect.succeed(false)))
87
113
  }));
88
114
  for (const line of renderOutcome(outcome, target.epicId)) {
@@ -5,12 +5,13 @@
5
5
  import { join } from "node:path";
6
6
  import * as Effect from "effect/Effect";
7
7
  import { cap } from "@llm4ts/flow/Context";
8
+ import { buildLedger, inheritedFrom, ledgerHeadline, renderLedger } from "@llm4ts/flow/CoverageLedger";
8
9
  import { parseDecisions, scenarioTitles } from "@llm4ts/flow/Decisions";
9
- import { assembleBrief, checkEpicBrief, diffBriefs, EpicBriefProposal, epicBriefProposalJsonSchema, loopAction, normalizeBrief, parseEpicBrief, ProgramSelection, programSelectionJsonSchema, providedPointers, renderBriefProblem, renderEpicBrief, unanswered } from "@llm4ts/flow/EpicBrief";
10
+ import { assembleBrief, checkEpicBrief, diffBriefs, dispositionWords, EpicBriefProposal, epicBriefProposalJsonSchema, loopAction, normalizeBrief, parseEpicBrief, ProgramSelection, programSelectionJsonSchema, providedPointers, renderBriefProblem, renderEpicBrief, unanswered } from "@llm4ts/flow/EpicBrief";
10
11
  import { structuredAndPublish } from "@llm4ts/flow/Flow";
11
12
  import { EpicBriefInvalid, ExtractPackMissing, OpenPointsPending } from "@llm4ts/flow/FlowError";
12
13
  import { ScriptUsage } from "@llm4ts/runner";
13
- import { epicIdFor, epicsDir } from "./epic-stories.js";
14
+ import { epicIdFor, epicsDir, loadBriefs } from "./epic-stories.js";
14
15
  import { ModDir } from "./modernize-extract.js";
15
16
  // ---- Arguments ----------------------------------------------------------------
16
17
  export const epicDesignUsage = [
@@ -18,10 +19,12 @@ export const epicDesignUsage = [
18
19
  " LLM4TS_LEGACY_REPO=<path> the legacy repository holding docs/modernization/ (required)",
19
20
  " LLM4TS_PACK=<name|dir> the pack describing the target stack (a new or empty target)",
20
21
  " --epic <id> revise or validate an existing brief, no text needed",
21
- " --list this repository's briefs and their status"
22
+ " --list this repository's briefs and their status",
23
+ " --coverage write the coverage ledger across every brief (no model call)"
22
24
  ].join("\n");
23
25
  export const parseEpicDesignArgs = (argv) => Effect.gen(function* () {
24
26
  let list = false;
27
+ let coverage = false;
25
28
  let epic;
26
29
  const rest = [];
27
30
  for (let index = 0; index < argv.length; index += 1) {
@@ -29,6 +32,9 @@ export const parseEpicDesignArgs = (argv) => Effect.gen(function* () {
29
32
  if (argument === "--list") {
30
33
  list = true;
31
34
  }
35
+ else if (argument === "--coverage") {
36
+ coverage = true;
37
+ }
32
38
  else if (argument === "--epic" || argument.startsWith("--epic=")) {
33
39
  const value = argument.includes("=") ? argument.slice("--epic=".length) : argv[index + 1];
34
40
  if (!argument.includes("="))
@@ -50,7 +56,7 @@ export const parseEpicDesignArgs = (argv) => Effect.gen(function* () {
50
56
  rest.push(argument);
51
57
  }
52
58
  }
53
- return { list, epic, rest };
59
+ return { list, coverage, epic, rest };
54
60
  });
55
61
  // ---- Which brief ---------------------------------------------------------------
56
62
  export const renderBriefList = (briefs) => briefs.length === 0
@@ -156,6 +162,16 @@ export const selectPrompt = (request, index) => [
156
162
  ].join("\n");
157
163
  export const proposePrompt = (options) => {
158
164
  const perProgram = Math.max(2_000, Math.floor(options.budget / Math.max(1, options.programs.length)));
165
+ const selected = new Set(options.programs.map((program) => program.name));
166
+ const inherited = (options.inherited ?? []).filter((decision) => selected.has(decision.program));
167
+ const decided = inherited.filter((decision) => decision.disposition !== "deferred");
168
+ const available = inherited.filter((decision) => decision.disposition === "deferred");
169
+ const settled = new Set(decided.map((decision) => `${decision.program}\u0000${decision.scenario}`));
170
+ const open = options.pack.index.programs
171
+ .filter((program) => selected.has(program.name))
172
+ .flatMap((program) => program.scenarios
173
+ .filter((scenario) => !settled.has(`${program.name}\u0000${scenario}`))
174
+ .map((scenario) => `- ${program.name} › ${scenario}`));
159
175
  const evidence = options.programs.flatMap((program) => [
160
176
  `===== ${program.name} — spec =====`,
161
177
  cap(options.pack.specs[program.name] ?? "", perProgram).text,
@@ -206,11 +222,27 @@ export const proposePrompt = (options) => {
206
222
  options.guidance,
207
223
  ...(options.packNote === undefined ? [] : ["", "The target stack's pack:", options.packNote]),
208
224
  "",
225
+ ...(decided.length === 0
226
+ ? []
227
+ : [
228
+ "Already decided by other epics (do not restate; they are inherited):",
229
+ ...decided.map((decision) => `- ${decision.program} › ${decision.scenario} — ${decision.disposition === "dropped"
230
+ ? `dropped by epic ${decision.epic}`
231
+ : `${dispositionWords[decision.disposition]} in epic ${decision.epic}`}${decision.note.length === 0 ? "" : ` (${decision.note})`}`),
232
+ ""
233
+ ]),
234
+ ...(available.length === 0
235
+ ? []
236
+ : [
237
+ "Deferred by other epics, available to this one (claim them if they belong here):",
238
+ ...available.map((decision) => `- ${decision.program} › ${decision.scenario} — deferred by epic ${decision.epic}${decision.note.length === 0 ? "" : `: ${decision.note}`}`),
239
+ ""
240
+ ]),
209
241
  "Every scenario to give a disposition to (the complete list; the evidence below may be",
210
242
  "abridged, these titles are not):",
211
- ...options.pack.index.programs
212
- .filter((program) => options.programs.some((selected) => selected.name === program.name))
213
- .flatMap((program) => program.scenarios.map((scenario) => `- ${program.name} › ${scenario}`)),
243
+ ...(open.length === 0
244
+ ? ["(none: every scenario of these programs is already decided by other epics)"]
245
+ : open),
214
246
  "",
215
247
  "Legacy evidence:",
216
248
  ...evidence
@@ -233,14 +265,53 @@ const verifiedPointers = (deps, brief) => Effect.gen(function* () {
233
265
  }
234
266
  return found;
235
267
  });
236
- const check = (deps, pack, brief) => Effect.map(verifiedPointers(deps, brief), (pointers) => checkEpicBrief(brief, { pack: pack.index, pointers }));
268
+ const check = (deps, pack, brief, others) => Effect.map(verifiedPointers(deps, brief), (pointers) => checkEpicBrief(brief, { pack: pack.index, pointers, others, epicId: deps.epicId }));
269
+ /** The ledger of the other epics' briefs, against this pack. */
270
+ const ledgerOfOthers = (deps, pack) => buildLedger({
271
+ pack: pack.index,
272
+ briefs: (deps.others ?? []).filter((other) => other.epicId !== deps.epicId),
273
+ epics: [],
274
+ legacy: deps.legacyRepo
275
+ });
276
+ /** Other briefs that fed nothing into this run, said in words. */
277
+ const unusedBriefs = (deps, others) => [
278
+ ...(deps.unreadable ?? [])
279
+ .filter((brief) => brief.dir !== deps.epicId)
280
+ .map((brief) => `brief '${brief.dir}' could not be read (${brief.reason}): nothing is inherited from it`),
281
+ ...others.skipped.map((epic) => `brief '${epic}' was designed against another legacy repository: nothing is inherited from it`)
282
+ ];
283
+ /** Per epic, how many scenarios of the considered programs a brief inherits. */
284
+ const inheritedSummary = (inherited, programs) => {
285
+ const considered = new Set(programs.map((program) => program.name));
286
+ const perEpic = new Map();
287
+ for (const decision of inherited) {
288
+ if (decision.disposition === "deferred" || !considered.has(decision.program))
289
+ continue;
290
+ const seen = perEpic.get(decision.epic) ?? new Set();
291
+ seen.add(`${decision.program}\u0000${decision.scenario}`);
292
+ perEpic.set(decision.epic, seen);
293
+ }
294
+ return [...perEpic].map(([epic, scenarios]) => ({ epic, scenarios: scenarios.size }));
295
+ };
237
296
  export const designEpic = Effect.fn("epic-design.design")(function* (deps) {
238
297
  const path = briefPath(deps.targetDir, deps.epicId);
239
298
  const pack = yield* readPackIndex(deps);
240
299
  const onDisk = yield* deps.files.read(path);
241
300
  const current = onDisk === undefined ? undefined : yield* parseEpicBrief(onDisk, path);
242
301
  const action = loopAction(current);
243
- const outcome = (changes, problems, openPoints) => ({ action, path, targetDir: deps.targetDir, changes, problems, openPoints });
302
+ const others = ledgerOfOthers(deps, pack);
303
+ const inherited = inheritedFrom(others);
304
+ const warnings = unusedBriefs(deps, others);
305
+ const outcome = (changes, problems, openPoints, considered = []) => ({
306
+ action,
307
+ path,
308
+ targetDir: deps.targetDir,
309
+ changes,
310
+ problems,
311
+ openPoints,
312
+ inherited: inheritedSummary(inherited, considered),
313
+ warnings
314
+ });
244
315
  if (current !== undefined && action === "halt") {
245
316
  return yield* OpenPointsPending.make({
246
317
  path,
@@ -251,7 +322,7 @@ export const designEpic = Effect.fn("epic-design.design")(function* (deps) {
251
322
  return outcome([], [], 0);
252
323
  }
253
324
  if (current !== undefined && action === "validate") {
254
- const problems = yield* check(deps, pack, current);
325
+ const problems = yield* check(deps, pack, current, inherited);
255
326
  if (problems.length > 0) {
256
327
  return yield* EpicBriefInvalid.make({ path, violations: problems.map(renderBriefProblem) });
257
328
  }
@@ -270,6 +341,7 @@ export const designEpic = Effect.fn("epic-design.design")(function* (deps) {
270
341
  budget: deps.budget,
271
342
  guidance: deps.guidance,
272
343
  packNote: deps.packNote,
344
+ inherited,
273
345
  ...(current === undefined ? {} : { current }),
274
346
  ...(problems === undefined ? {} : { problems })
275
347
  }), EpicBriefProposal, epicBriefProposalJsonSchema);
@@ -290,11 +362,11 @@ export const designEpic = Effect.fn("epic-design.design")(function* (deps) {
290
362
  ? proposed
291
363
  : EpicBriefProposal.make({ ...proposed, openPoints: [noPrograms, ...proposed.openPoints] });
292
364
  let proposal = withNotice(yield* propose());
293
- let problems = yield* check(deps, pack, assemble(proposal));
365
+ let problems = yield* check(deps, pack, assemble(proposal), inherited);
294
366
  if (problems.length > 0) {
295
367
  // One bounded fix round; what still fails is shown to the human, never hidden.
296
368
  proposal = withNotice(yield* propose(problems));
297
- problems = yield* check(deps, pack, assemble(proposal));
369
+ problems = yield* check(deps, pack, assemble(proposal), inherited);
298
370
  }
299
371
  const next = assemble(proposal, problems);
300
372
  // What is written must read back as the same brief: a file that parses to
@@ -309,10 +381,10 @@ export const designEpic = Effect.fn("epic-design.design")(function* (deps) {
309
381
  });
310
382
  }
311
383
  yield* deps.files.writeAtomic(path, written);
312
- return outcome(diffBriefs(current, next), problems, next.openPoints.length);
384
+ return outcome(diffBriefs(current, next), problems, next.openPoints.length, next.programs);
313
385
  });
314
386
  /** What the flow prints after a run. */
315
- export const renderOutcome = (outcome, epicId) => {
387
+ const outcomeLines = (outcome, epicId) => {
316
388
  const approve = `set \`Status: approved\` in ${outcome.path} once it is right`;
317
389
  const plan = `llm4ts run epic-stories --repo ${outcome.targetDir} -- --epic ${epicId}`;
318
390
  switch (outcome.action) {
@@ -330,6 +402,13 @@ export const renderOutcome = (outcome, epicId) => {
330
402
  return [
331
403
  `${outcome.action === "propose" ? "proposed" : "revised"} ${outcome.path}:`,
332
404
  ...outcome.changes.map((change) => ` ${change}`),
405
+ ...(outcome.inherited.length === 0
406
+ ? []
407
+ : [
408
+ ` inherited from ${outcome.inherited
409
+ .map((entry) => `${entry.epic} (${entry.scenarios} scenario${entry.scenarios === 1 ? "" : "s"})`)
410
+ .join(", ")}: not restated here; see \`epic-design --coverage\``
411
+ ]),
333
412
  ...(outcome.problems.length === 0
334
413
  ? []
335
414
  : [` ${outcome.problems.length} check(s) still failing, raised as [check] open points`]),
@@ -339,3 +418,36 @@ export const renderOutcome = (outcome, epicId) => {
339
418
  ];
340
419
  }
341
420
  };
421
+ /** What the flow prints after a run; briefs it could not use are always said. */
422
+ export const renderOutcome = (outcome, epicId) => [
423
+ ...outcomeLines(outcome, epicId),
424
+ ...outcome.warnings.map((warning) => `warning: ${warning}`)
425
+ ];
426
+ // ---- The coverage ledger ---------------------------------------------------------
427
+ export const coveragePath = (targetDir) => join(epicsDir(targetDir), "coverage.md");
428
+ /** What epic-stories recorded for each planned epic, as the ledger reads it. */
429
+ export const epicProgressOf = (epics) => epics.map((epic) => ({
430
+ epicId: epic.dir,
431
+ planned: true,
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),
435
+ landed: epic.landed !== undefined
436
+ }));
437
+ /**
438
+ * The ledger across every brief of the target, written as coverage.md. A
439
+ * read of the repository: no model is called and no brief is edited.
440
+ */
441
+ export const coverageReport = Effect.fn("epic-design.coverage")(function* (options) {
442
+ const pack = yield* readPackIndex(options);
443
+ const loaded = yield* loadBriefs(options.files, options.targetDir, options.dirs);
444
+ const ledger = buildLedger({
445
+ pack: pack.index,
446
+ briefs: loaded.briefs,
447
+ epics: epicProgressOf(options.epics),
448
+ legacy: options.legacyRepo
449
+ });
450
+ const path = coveragePath(options.targetDir);
451
+ yield* options.files.writeAtomic(path, renderLedger(ledger, { legacy: options.legacyRepo, unreadable: loaded.unreadable }));
452
+ return { path, headline: ledgerHeadline(ledger), ledger, unreadable: loaded.unreadable };
453
+ });
@@ -5,6 +5,7 @@ import { readdir } from "node:fs/promises";
5
5
  import { isAbsolute, join, normalize } from "node:path";
6
6
  import * as Duration from "effect/Duration";
7
7
  import * as Effect from "effect/Effect";
8
+ import * as Result from "effect/Result";
8
9
  import * as Schema from "effect/Schema";
9
10
  import { Dimension, Sample } from "@llm4ts/core/eval/Eval";
10
11
  import { judge } from "@llm4ts/core/eval/Judge";
@@ -12,14 +13,16 @@ import { TokenUsage } from "@llm4ts/core/Models";
12
13
  import { cap } from "@llm4ts/flow/Context";
13
14
  import { parseEpicBrief, renderEpicBrief, unanswered } from "@llm4ts/flow/EpicBrief";
14
15
  import { structuredAndPublish } from "@llm4ts/flow/Flow";
15
- import { EpicBriefInvalid, EpicBriefNotApproved } from "@llm4ts/flow/FlowError";
16
+ import { EpicBriefInvalid, EpicBriefNotApproved, EpicCheckoutDirty, LandingFailed, RefineRefused } from "@llm4ts/flow/FlowError";
16
17
  import { Info } from "@llm4ts/flow/FlowEvents";
18
+ import { statusPaths } from "@llm4ts/flow/GitTool";
17
19
  import { EpicLanded, EpicLandedVersion, landedPath } from "@llm4ts/flow/Landing";
20
+ import { RefineProposal, assembleRound, countNotPlanned, earlierStories, refineProposalJsonSchema, renderNotPlanned, roundPrefix, roundRepair, runAction } from "@llm4ts/flow/RefineRound";
18
21
  import { loadVersioned } from "@llm4ts/flow/Persistence";
19
22
  import { BlockedVerdict, StoryState, StoryStateVersion } from "@llm4ts/flow/Stories";
20
23
  import { stableHash } from "@llm4ts/flow/Plan";
21
24
  import { ReviewIssue } from "@llm4ts/flow/Review";
22
- import { StoryPlan, dependenciesOf, makeStoryPlanStore, pathsNamedIn } from "@llm4ts/flow/StoryPlan";
25
+ import { StoryPlan, dependenciesOf, makeStoryPlanStore, parseStoryPlan, pathsNamedIn } from "@llm4ts/flow/StoryPlan";
23
26
  import * as Console from "effect/Console";
24
27
  import { CliConnectorConfig } from "@llm4ts/core/ConnectorConfig";
25
28
  import { budget } from "@llm4ts/flow/Context";
@@ -41,6 +44,9 @@ export const epicUsage = [
41
44
  " --list list this repository's epics (id, stories merged, landed) and stop",
42
45
  " --epic <id> work on an existing epic by id, no text needed. Without text or",
43
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",
44
50
  " --concurrency <n> stories implemented at once (default 3)",
45
51
  " --fail-fast stop the epic at the first failed story",
46
52
  "Seats: LLM4TS_REASONER (claude|gemini|…, default claude) splits, reviews, judges;",
@@ -63,6 +69,7 @@ export const parseEpicArgs = (argv) => Effect.gen(function* () {
63
69
  let land;
64
70
  let keepWorktrees = false;
65
71
  let list = false;
72
+ let refine = false;
66
73
  let epic;
67
74
  const rest = [];
68
75
  for (let index = 0; index < argv.length; index += 1) {
@@ -79,6 +86,9 @@ export const parseEpicArgs = (argv) => Effect.gen(function* () {
79
86
  else if (argument === "--list") {
80
87
  list = true;
81
88
  }
89
+ else if (argument === "--refine") {
90
+ refine = true;
91
+ }
82
92
  else if (argument === "--epic" || argument.startsWith("--epic=")) {
83
93
  const value = argument.includes("=") ? argument.slice("--epic=".length) : argv[index + 1];
84
94
  if (!argument.includes("=")) {
@@ -116,8 +126,79 @@ export const parseEpicArgs = (argv) => Effect.gen(function* () {
116
126
  rest.push(argument);
117
127
  }
118
128
  }
119
- return { planOnly, failFast, concurrency, land, keepWorktrees, list, epic, rest };
129
+ return { planOnly, failFast, concurrency, land, keepWorktrees, list, epic, refine, rest };
120
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
+ }
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`}`;
121
202
  export const epicsDir = (workDir) => join(workDir, ".llm4ts", "epics");
122
203
  /** Every epic with a plan in this repository, oldest folder name first. */
123
204
  export const listEpics = (files, workDir) => Effect.gen(function* () {
@@ -136,13 +217,7 @@ export const listEpics = (files, workDir) => Effect.gen(function* () {
136
217
  if (plan === undefined) {
137
218
  continue;
138
219
  }
139
- let merged = 0;
140
- for (const story of plan.stories) {
141
- const state = yield* loadVersioned(files, join(stateDir, "stories", `${story.id}.json`), StoryStateVersion, StoryState).pipe(Effect.catch(() => Effect.succeed(undefined)));
142
- if (state?.status === "merged") {
143
- merged += 1;
144
- }
145
- }
220
+ const merged = yield* mergedStories(files, stateDir, plan);
146
221
  const landed = yield* loadVersioned(files, landedPath(stateDir), EpicLandedVersion, EpicLanded).pipe(Effect.catch(() => Effect.succeed(undefined)));
147
222
  summaries.push({
148
223
  dir: entry.name,
@@ -151,7 +226,19 @@ export const listEpics = (files, workDir) => Effect.gen(function* () {
151
226
  epic: plan.epic,
152
227
  stories: plan.stories.length,
153
228
  merged,
154
- 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
+ ])))
155
242
  });
156
243
  }
157
244
  return summaries;
@@ -165,7 +252,7 @@ export const renderEpicList = (epics) => epics.length === 0
165
252
  ? epic.merged === epic.stories
166
253
  ? "finished, not landed"
167
254
  : "in progress"
168
- : `landed on ${epic.landed}`} (--epic ${epic.epicId})`,
255
+ : `landed on ${epic.landed}`}${epic.rounds.map((round) => ` · ${renderRound(round)}`).join("")} (--epic ${epic.epicId})`,
169
256
  ` "${epic.epic.length > 110 ? `${epic.epic.slice(0, 109)}…` : epic.epic}"`
170
257
  ])
171
258
  ].join("\n");
@@ -187,6 +274,29 @@ export const listBriefs = (files, workDir, dirs) => Effect.gen(function* () {
187
274
  }
188
275
  return briefs;
189
276
  });
277
+ /**
278
+ * Every brief of the repository, parsed, for the coverage ledger. A folder
279
+ * without a brief is skipped; a brief that does not parse is reported with
280
+ * the first thing wrong in it, never dropped silently.
281
+ */
282
+ export const loadBriefs = (files, workDir, dirs) => Effect.gen(function* () {
283
+ const briefs = [];
284
+ const unreadable = [];
285
+ for (const dir of dirs) {
286
+ const path = join(epicsDir(workDir), dir, "brief.md");
287
+ const text = yield* files.read(path);
288
+ if (text === undefined)
289
+ continue;
290
+ const parsed = yield* Effect.result(parseEpicBrief(text, path));
291
+ if (Result.isSuccess(parsed)) {
292
+ briefs.push({ epicId: dir, brief: parsed.success });
293
+ }
294
+ else {
295
+ unreadable.push({ dir, reason: parsed.failure.violations[0] ?? "does not parse" });
296
+ }
297
+ }
298
+ return { briefs, unreadable };
299
+ });
190
300
  /**
191
301
  * What the story planner reads in place of the one-line epic: the approved
192
302
  * brief in the epic's folder. Undefined when the plan is already written (the
@@ -545,6 +655,128 @@ export const generateStoryPlan = (reasoning, events, epic, epicId, guidance,
545
655
  brief) => structuredAndPublish(reasoning, events, `${storyPlanInstructions(epicId, guidance)}\n\nEpic:\n${brief ?? epic}`, StoryPlan, storyPlanJsonSchema).pipe(
546
656
  // The id and the epic text are ours, whatever the model echoed back.
547
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 }));
548
780
  // ---- Usage ---------------------------------------------------------------------
549
781
  /** Sums two meters into one per-story estimate. */
550
782
  export const combineTotals = (first, second) => Effect.map(Effect.all([first, second]), ([left, right]) => {
@@ -787,13 +1019,45 @@ export const runEpicStories = (options) => Effect.gen(function* () {
787
1019
  yield* Console.log(renderEpicList(epics));
788
1020
  return;
789
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() : "";
790
1024
  const choice = yield* chooseEpic({
791
- text: given.prompt,
1025
+ text: flags.refine ? "" : given.prompt,
792
1026
  epic: flags.epic,
793
1027
  epics,
794
1028
  briefs: yield* listBriefs(files, given.workDir, yield* epicDirs(given.workDir)),
795
1029
  defaultEpic
796
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
+ }
797
1061
  const input = {
798
1062
  ...given,
799
1063
  prompt: choice._tag === "Existing"
@@ -845,9 +1109,72 @@ export const runEpicStories = (options) => Effect.gen(function* () {
845
1109
  yield* events.publish(Info.make({
846
1110
  message: `story plan: ${plan.stories.length} stories at ${planPath} (edit and rerun to re-plan)`
847
1111
  }));
848
- if (flags.planOnly) {
1112
+ if (flags.planOnly && action._tag !== "PlanRound") {
849
1113
  return;
850
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
+ }
851
1178
  // Setup and gates run where the application is, in every checkout.
852
1179
  const appDir = yield* appDirFor(input.workDir, process.env);
853
1180
  if (appDir !== ".") {
@@ -867,6 +1194,7 @@ export const runEpicStories = (options) => Effect.gen(function* () {
867
1194
  files,
868
1195
  stateDir,
869
1196
  target: flags.land,
1197
+ rounds: yield* landRounds(plan.epicId, flags.land, rounds),
870
1198
  keepWorktrees: flags.keepWorktrees,
871
1199
  gates: inAppDir(appDir, gatesIn(nodeProcessExecutor, events, commands)),
872
1200
  system: ["House rules of the target repository (CONTRIBUTING.md):", guidance].join("\n")
@@ -901,11 +1229,12 @@ export const runEpicStories = (options) => Effect.gen(function* () {
901
1229
  const setupCommand = worktreeSetupCommand(process.env);
902
1230
  const healthUrl = serverHealthUrl(localServer, process.env);
903
1231
  const report = yield* implementStoriesFlow({ ...context, reasoning: reasoningMeter.service }, {
904
- plan,
1232
+ plan: unit.plan,
905
1233
  files,
906
- stateDir,
1234
+ stateDir: unit.stateDir,
1235
+ epicBranch,
907
1236
  worktreeRoot: worktreeRootFor(input.workDir, plan.epicId, process.env),
908
- board: makeLocalBoardSync(files, stateDir, `Epic: ${plan.epicId}`),
1237
+ board: makeLocalBoardSync(files, unit.stateDir, unit.label),
909
1238
  contextFor: (workDir, contextOptions) => Effect.gen(function* () {
910
1239
  const rebound = yield* contextFor(workDir, contextOptions);
911
1240
  const coderMeter = yield* makeEstimatedUsageMeter(rebound.coder, estimateOptions);
@@ -930,14 +1259,14 @@ export const runEpicStories = (options) => Effect.gen(function* () {
930
1259
  // With a roster, the judge and the verifier are leased per story,
931
1260
  // away from the executor coding it (ADR 0019).
932
1261
  judge: options.storyJudge({
933
- plan,
1262
+ plan: unit.plan,
934
1263
  budget: contextBudget,
935
1264
  reasoning: reasoningMeter.service,
936
1265
  events,
937
1266
  files,
938
1267
  houseRules: guidance
939
1268
  }),
940
- 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),
941
1270
  // A roster waits for its own executors (the story's next lease
942
1271
  // does); without one, poll the single coder's engine.
943
1272
  awaitRecovery: context.roster === undefined
@@ -954,7 +1283,7 @@ export const runEpicStories = (options) => Effect.gen(function* () {
954
1283
  failFast: flags.failFast
955
1284
  });
956
1285
  yield* events.publish(Info.make({
957
- 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)`
958
1287
  }));
959
1288
  }));
960
1289
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llm4ts/shell",
3
- "version": "2.17.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.17.0",
56
- "@llm4ts/flow": "2.17.0",
57
- "@llm4ts/runner": "2.17.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"