@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 +1 -1
- package/dist/Cli.d.ts.map +1 -1
- package/flows/epic-design.js +33 -7
- package/flows/lib/epic-design.js +126 -14
- package/flows/lib/epic-stories.js +349 -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/epic-design.js
CHANGED
|
@@ -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
|
|
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
|
|
40
|
-
if (
|
|
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
|
-
|
|
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)) {
|
package/flows/lib/epic-design.js
CHANGED
|
@@ -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
|
-
...
|
|
212
|
-
|
|
213
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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:
|
|
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.
|
|
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/flow": "2.
|
|
57
|
-
"@llm4ts/runner": "2.
|
|
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"
|