@llm4ts/shell 2.26.0 → 2.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/flows/epic-retro.js +66 -0
- package/flows/lib/epic-retro.js +150 -0
- package/flows/lib/epic-stories.js +41 -3
- package/package.json +4 -4
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Read what a failed epic-stories run left behind and propose the fixes the next run applies once approved.
|
|
2
|
+
//
|
|
3
|
+
// llm4ts run epic-retro --repo ~/demo/portal # the one open epic, its latest run
|
|
4
|
+
// llm4ts run epic-retro --repo ~/demo/portal -- --epic <id> # one epic
|
|
5
|
+
// llm4ts run epic-retro --repo ~/demo/portal -- --epic <id> --run run-1759… # one run of it
|
|
6
|
+
//
|
|
7
|
+
// The evidence is the run's trace, its transcripts when it was started with
|
|
8
|
+
// --transcript, the board, the report, and each story's plan, findings and
|
|
9
|
+
// judge verdict under .llm4ts/epics/<epic-id>/. Code digests them (the digest
|
|
10
|
+
// is written first, at retro/<runId>.digest.md); one read-only seat
|
|
11
|
+
// (LLM4TS_REASONER, the judge seat) turns the digest into a proposal: per
|
|
12
|
+
// story a diagnosis and exactly one fix — tasks appended to its plan, an
|
|
13
|
+
// edit to its entry (which restarts it), a refine round, or nothing — plus
|
|
14
|
+
// run advice and, where the library misbehaved, advice for llm4ts
|
|
15
|
+
// (retro/<runId>-library.md). Code validates the proposal before writing
|
|
16
|
+
// retro/<runId>.md, which ends in `- [ ] Approved`. Tick it and rerun
|
|
17
|
+
// epic-stories: the approved fixes are applied before any story runs, and
|
|
18
|
+
// the report is marked Applied. Nothing is applied unapproved; nothing in
|
|
19
|
+
// the target's git is touched.
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import * as Console from "effect/Console";
|
|
22
|
+
import * as Effect from "effect/Effect";
|
|
23
|
+
import { ScriptUsage, asReadOnly, nodePlainFileStore, resolveFlowInput, runFlowMain, runNode } from "@llm4ts/runner";
|
|
24
|
+
import { loadTranscript, nodeTranscriptFiles } from "@llm4ts/runner/Transcripts";
|
|
25
|
+
import { parseEpicRetroArgs, resolveRetroTarget, runRetro } from "./lib/epic-retro.js";
|
|
26
|
+
import { chooseEpic, defaultEpic, epicsDir, listEpics, reasonerFromEnvironment } from "./lib/epic-stories.js";
|
|
27
|
+
const program = Effect.gen(function* () {
|
|
28
|
+
const flags = yield* parseEpicRetroArgs(process.argv.slice(2));
|
|
29
|
+
const given = yield* resolveFlowInput("", flags.rest);
|
|
30
|
+
const files = nodePlainFileStore;
|
|
31
|
+
const epics = yield* listEpics(files, given.workDir);
|
|
32
|
+
const choice = yield* chooseEpic({ text: "", epic: flags.epic, epics, defaultEpic });
|
|
33
|
+
if (choice._tag !== "Existing") {
|
|
34
|
+
return yield* ScriptUsage.make({
|
|
35
|
+
message: `no planned epic to look at in ${given.workDir}; run epic-stories first`
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
const epicDir = choice.epic.dir;
|
|
39
|
+
const stateDir = join(epicsDir(given.workDir), epicDir);
|
|
40
|
+
const target = yield* resolveRetroTarget(files, stateDir, flags.run);
|
|
41
|
+
const transcriptDir = join(given.workDir, ".llm4ts", "transcripts", target.run.runId);
|
|
42
|
+
const reasoning = asReadOnly(yield* reasonerFromEnvironment(process.env));
|
|
43
|
+
const outcome = yield* runNode({
|
|
44
|
+
workDir: given.workDir,
|
|
45
|
+
workspace: given.workspace,
|
|
46
|
+
userPrompt: `retro of run ${target.run.runId} of epic ${target.plan.epicId}`,
|
|
47
|
+
// No coder turn is ever taken: the read-only reasoning seat fills both.
|
|
48
|
+
coder: reasoning,
|
|
49
|
+
reasoning,
|
|
50
|
+
environment: process.env
|
|
51
|
+
}, (context) => runRetro({
|
|
52
|
+
files,
|
|
53
|
+
events: context.events,
|
|
54
|
+
seat: context.judge ?? context.reasoning,
|
|
55
|
+
workDir: given.workDir,
|
|
56
|
+
epicDir,
|
|
57
|
+
target,
|
|
58
|
+
transcript: (lane) => loadTranscript(nodeTranscriptFiles, transcriptDir, { lane }),
|
|
59
|
+
environment: process.env,
|
|
60
|
+
now: Date.now()
|
|
61
|
+
}));
|
|
62
|
+
if (outcome._tag === "NothingToDo") {
|
|
63
|
+
yield* Console.log(outcome.message);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
runFlowMain(program);
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* epic-retro: read what a failed epic-stories run left behind, explain each
|
|
3
|
+
* failed story, and propose the fix the next run applies once approved.
|
|
4
|
+
* The policy (digest, proposal schema, validation, report, apply) lives in
|
|
5
|
+
* `@llm4ts/flow/Retro`; this module finds the run and its files and drives
|
|
6
|
+
* one read-only seat.
|
|
7
|
+
*/
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import * as Effect from "effect/Effect";
|
|
10
|
+
import { readEpicRuns } from "@llm4ts/flow/EpicRuns";
|
|
11
|
+
import { Info } from "@llm4ts/flow/FlowEvents";
|
|
12
|
+
import { readTrace } from "@llm4ts/flow/Replay";
|
|
13
|
+
import { RetroProposal, defaultRetroChars, loadEpicFiles, loadStoryFiles, renderRetroDigest, retroCandidates, retroPaths, retroPrompt, retroProposalJsonSchema, validateRetroProposal, writeRetro } from "@llm4ts/flow/Retro";
|
|
14
|
+
import { makeStoryPlanStore } from "@llm4ts/flow/StoryPlan";
|
|
15
|
+
import { structuredAndPublish } from "@llm4ts/flow/Usage";
|
|
16
|
+
import { ScriptUsage } from "@llm4ts/runner/FlowArgs";
|
|
17
|
+
import { roundDir } from "./epic-stories.js";
|
|
18
|
+
export const epicRetroUsage = [
|
|
19
|
+
"usage: llm4ts run epic-retro --repo <target> [-- --epic <id>] [-- --run <runId>]",
|
|
20
|
+
" --epic <id> the epic (folder name or plan id); default: the one open epic",
|
|
21
|
+
" --run <id> the run to look at; default: the epic's latest run",
|
|
22
|
+
"Reads the run's trace, transcripts (when kept), board, report, story plans, findings and",
|
|
23
|
+
"verdicts; writes .llm4ts/epics/<epic>/retro/<runId>.md with proposed fixes behind an",
|
|
24
|
+
"approval line, and <runId>-library.md with advice for llm4ts when there is any."
|
|
25
|
+
].join("\n");
|
|
26
|
+
export const parseEpicRetroArgs = (argv) => Effect.gen(function* () {
|
|
27
|
+
let epic;
|
|
28
|
+
let run;
|
|
29
|
+
const rest = [];
|
|
30
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
31
|
+
const argument = argv[index] ?? "";
|
|
32
|
+
const valued = (name) => {
|
|
33
|
+
const value = argument.includes("=") ? argument.slice(name.length + 1) : argv[index + 1];
|
|
34
|
+
if (!argument.includes("=")) {
|
|
35
|
+
index += 1;
|
|
36
|
+
}
|
|
37
|
+
return value === undefined || value.trim().length === 0 || value.startsWith("--")
|
|
38
|
+
? ScriptUsage.make({ message: `${name} needs a value\n${epicRetroUsage}` })
|
|
39
|
+
: Effect.succeed(value.trim());
|
|
40
|
+
};
|
|
41
|
+
if (argument === "--epic" || argument.startsWith("--epic=")) {
|
|
42
|
+
epic = yield* valued("--epic");
|
|
43
|
+
}
|
|
44
|
+
else if (argument === "--run" || argument.startsWith("--run=")) {
|
|
45
|
+
run = yield* valued("--run");
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
rest.push(argument);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { epic, run, rest };
|
|
52
|
+
});
|
|
53
|
+
export const resolveRetroTarget = Effect.fn("epic-retro.resolveTarget")(function* (files, epicStateDir, runId) {
|
|
54
|
+
const runs = yield* readEpicRuns(files, epicStateDir);
|
|
55
|
+
const run = runId === undefined ? runs.at(-1) : runs.find((candidate) => candidate.runId === runId);
|
|
56
|
+
if (run === undefined) {
|
|
57
|
+
return yield* ScriptUsage.make({
|
|
58
|
+
message: runId === undefined
|
|
59
|
+
? `no run recorded in ${join(epicStateDir, "runs.jsonl")}; run epic-stories first`
|
|
60
|
+
: `no run '${runId}' in ${join(epicStateDir, "runs.jsonl")}; recorded: ${runs.map((r) => r.runId).join(", ") || "none"}`
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
const stateDir = run.round !== undefined && (run.action === "RunRound" || run.action === "PlanRound")
|
|
64
|
+
? roundDir(epicStateDir, run.round)
|
|
65
|
+
: epicStateDir;
|
|
66
|
+
const plan = yield* makeStoryPlanStore(files).load(join(stateDir, "plan.md"));
|
|
67
|
+
if (plan === undefined) {
|
|
68
|
+
return yield* ScriptUsage.make({ message: `no plan at ${join(stateDir, "plan.md")}` });
|
|
69
|
+
}
|
|
70
|
+
return { run, stateDir, plan };
|
|
71
|
+
});
|
|
72
|
+
const retroChars = (environment) => {
|
|
73
|
+
const value = Number.parseInt(environment.LLM4TS_RETRO_CHARS ?? "", 10);
|
|
74
|
+
return Number.isInteger(value) && value > 0 ? value : defaultRetroChars;
|
|
75
|
+
};
|
|
76
|
+
/** The whole retro: evidence, one structured call, validation, the files. */
|
|
77
|
+
export const runRetro = Effect.fn("epic-retro.run")(function* (deps) {
|
|
78
|
+
const { files, target } = deps;
|
|
79
|
+
const { stateDir, plan, run } = target;
|
|
80
|
+
const trace = yield* readTrace(files, run.tracePath);
|
|
81
|
+
const epic = yield* loadEpicFiles(files, stateDir);
|
|
82
|
+
const stories = yield* Effect.forEach(plan.stories, (story) => loadStoryFiles(files, stateDir, story));
|
|
83
|
+
const withoutTranscripts = {
|
|
84
|
+
runId: run.runId,
|
|
85
|
+
plan,
|
|
86
|
+
trace,
|
|
87
|
+
transcripts: undefined,
|
|
88
|
+
report: epic.report,
|
|
89
|
+
board: epic.board,
|
|
90
|
+
stories,
|
|
91
|
+
budget: retroChars(deps.environment)
|
|
92
|
+
};
|
|
93
|
+
const candidates = retroCandidates(withoutTranscripts);
|
|
94
|
+
if (candidates.length === 0) {
|
|
95
|
+
return {
|
|
96
|
+
_tag: "NothingToDo",
|
|
97
|
+
message: `run ${run.runId} of epic ${plan.epicId} left no failed, waiting or unfinished story; nothing to look at`
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
// Transcripts are read per candidate lane; the first answer says whether
|
|
101
|
+
// the run kept any at all.
|
|
102
|
+
const transcripts = new Map();
|
|
103
|
+
let kept = false;
|
|
104
|
+
for (const candidate of candidates) {
|
|
105
|
+
const entries = yield* deps.transcript(candidate.story.id);
|
|
106
|
+
if (entries !== undefined) {
|
|
107
|
+
kept = true;
|
|
108
|
+
transcripts.set(candidate.story.id, entries);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const inputs = { ...withoutTranscripts, transcripts: kept ? transcripts : undefined };
|
|
112
|
+
const digest = renderRetroDigest(inputs);
|
|
113
|
+
const paths = retroPaths(stateDir, run.runId);
|
|
114
|
+
// The digest is on disk before the seat is asked: a failed call still leaves it to read.
|
|
115
|
+
yield* files.writeAtomic(paths.digest, digest);
|
|
116
|
+
yield* deps.events.publish(Info.make({
|
|
117
|
+
message: `retro: ${candidates.length} stor${candidates.length === 1 ? "y" : "ies"} to look at in run ${run.runId}${kept ? "" : " (no transcripts; rerun with --transcript for more)"} — digest at ${paths.digest}`
|
|
118
|
+
}));
|
|
119
|
+
const withPlans = new Set(stories.filter((entry) => entry.plan !== undefined).map((entry) => entry.story.id));
|
|
120
|
+
const proposal = yield* structuredAndPublish(deps.seat, deps.events, retroPrompt(digest, [...withPlans]), RetroProposal, retroProposalJsonSchema, "retro");
|
|
121
|
+
const validated = validateRetroProposal(proposal, plan, withPlans);
|
|
122
|
+
const written = yield* writeRetro(files, stateDir, {
|
|
123
|
+
runId: run.runId,
|
|
124
|
+
plan,
|
|
125
|
+
validated,
|
|
126
|
+
workDir: deps.workDir,
|
|
127
|
+
epicDir: deps.epicDir,
|
|
128
|
+
digest,
|
|
129
|
+
at: deps.now
|
|
130
|
+
});
|
|
131
|
+
const applies = validated.proposal.stories.filter((s) => s.kind === "tasks" || s.kind === "story");
|
|
132
|
+
const summary = [
|
|
133
|
+
`retro of run ${run.runId}: ${validated.proposal.summary.trim()}`,
|
|
134
|
+
...validated.proposal.stories.map((entry) => ` ${entry.id}: ${entry.kind === "tasks" ? `${entry.tasks?.length ?? 0} task(s)` : entry.kind === "story" ? "edit the story (restart)" : entry.kind === "refine" ? "needs a refine round" : "no change"}`),
|
|
135
|
+
...(validated.dropped.length === 0
|
|
136
|
+
? []
|
|
137
|
+
: [` dropped ${validated.dropped.length} item(s) the plan cannot take; see the report`]),
|
|
138
|
+
`report: ${written.report}`,
|
|
139
|
+
...(validated.proposal.libraryAdvice.length === 0
|
|
140
|
+
? []
|
|
141
|
+
: [`advice for llm4ts: ${written.library}`]),
|
|
142
|
+
applies.length === 0
|
|
143
|
+
? "nothing to apply; the report is for reading"
|
|
144
|
+
: `tick '- [x] Approved' in the report, then rerun: llm4ts run epic-stories --repo ${deps.workDir} -- --epic ${deps.epicDir}`
|
|
145
|
+
];
|
|
146
|
+
for (const line of summary) {
|
|
147
|
+
yield* deps.events.publish(Info.make({ message: line }));
|
|
148
|
+
}
|
|
149
|
+
return { _tag: "Written", paths: written, summary };
|
|
150
|
+
});
|
|
@@ -16,6 +16,7 @@ import { structuredAndPublish } from "@llm4ts/flow/Flow";
|
|
|
16
16
|
import { EpicBriefInvalid, EpicBriefNotApproved, EpicCheckoutDirty, LandingFailed, RefineRefused } from "@llm4ts/flow/FlowError";
|
|
17
17
|
import { Info } from "@llm4ts/flow/FlowEvents";
|
|
18
18
|
import { nodePreflight } from "@llm4ts/flow/NodePreflight";
|
|
19
|
+
import { applyApprovedRetros } from "@llm4ts/flow/Retro";
|
|
19
20
|
import { statusPaths } from "@llm4ts/flow/GitTool";
|
|
20
21
|
import { EpicLanded, EpicLandedVersion, landedPath } from "@llm4ts/flow/Landing";
|
|
21
22
|
import { RefineProposal, assembleRound, countNotPlanned, earlierStories, refineProposalJsonSchema, renderNotPlanned, roundPrefix, roundRepair, runAction } from "@llm4ts/flow/RefineRound";
|
|
@@ -1050,7 +1051,7 @@ const withOptionalModel = (config, model) => {
|
|
|
1050
1051
|
const withExtraFlags = (config, flags) => Object.keys(flags).length === 0
|
|
1051
1052
|
? config
|
|
1052
1053
|
: CliConnectorConfig.make({ ...config, flags: { ...config.flags, ...flags } });
|
|
1053
|
-
const defaultEpic = "Add the retail customer's current account (Conto) with balance and movements, and wire " +
|
|
1054
|
+
export const defaultEpic = "Add the retail customer's current account (Conto) with balance and movements, and wire " +
|
|
1054
1055
|
"transfers (Bonifico) with beneficiary, review, SCA confirmation, and history.";
|
|
1055
1056
|
export const runEpicStories = (options) => Effect.gen(function* () {
|
|
1056
1057
|
const flags = yield* parseEpicArgs(process.argv.slice(2));
|
|
@@ -1167,14 +1168,31 @@ export const runEpicStories = (options) => Effect.gen(function* () {
|
|
|
1167
1168
|
return;
|
|
1168
1169
|
}
|
|
1169
1170
|
const epicBranch = `epic/${plan.epicId}`;
|
|
1171
|
+
// An approved retro of an earlier run (epic-retro) is applied before
|
|
1172
|
+
// any story runs: tasks onto story plans, story edits onto the plan.
|
|
1173
|
+
const retroState = action._tag === "RunRound"
|
|
1174
|
+
? (rounds.find((round) => round.round === action.round)?.stateDir ?? stateDir)
|
|
1175
|
+
: stateDir;
|
|
1176
|
+
const retroPlan = action._tag === "RunRound"
|
|
1177
|
+
? rounds.find((round) => round.round === action.round)?.plan
|
|
1178
|
+
: action._tag === "RunPlan"
|
|
1179
|
+
? plan
|
|
1180
|
+
: undefined;
|
|
1181
|
+
const applied = retroPlan === undefined
|
|
1182
|
+
? undefined
|
|
1183
|
+
: yield* applyApprovedRetros(files, retroState, retroPlan, events, Date.now());
|
|
1170
1184
|
// The unit the executor works on: the epic's plan, or one refine round.
|
|
1171
1185
|
// Same branch, seats, gates and judge; its own plan and state folder.
|
|
1172
|
-
let unit = {
|
|
1186
|
+
let unit = {
|
|
1187
|
+
plan: action._tag === "RunPlan" && applied !== undefined ? applied.plan : plan,
|
|
1188
|
+
stateDir,
|
|
1189
|
+
label: `Epic: ${plan.epicId}`
|
|
1190
|
+
};
|
|
1173
1191
|
if (action._tag === "RunRound") {
|
|
1174
1192
|
const open = rounds.find((round) => round.round === action.round);
|
|
1175
1193
|
if (open?.plan !== undefined) {
|
|
1176
1194
|
unit = {
|
|
1177
|
-
plan: open.plan,
|
|
1195
|
+
plan: applied?.plan ?? open.plan,
|
|
1178
1196
|
stateDir: open.stateDir,
|
|
1179
1197
|
label: `Epic: ${plan.epicId} · round ${open.round}`
|
|
1180
1198
|
};
|
|
@@ -1354,5 +1372,25 @@ export const runEpicStories = (options) => Effect.gen(function* () {
|
|
|
1354
1372
|
yield* events.publish(Info.make({
|
|
1355
1373
|
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)`
|
|
1356
1374
|
}));
|
|
1375
|
+
const hint = retroHint(report, input.workDir, epicId);
|
|
1376
|
+
if (hint !== undefined) {
|
|
1377
|
+
yield* events.publish(Info.make({ message: hint }));
|
|
1378
|
+
}
|
|
1357
1379
|
}));
|
|
1358
1380
|
});
|
|
1381
|
+
/**
|
|
1382
|
+
* The one command away from a red run: what to type to have the run's trace
|
|
1383
|
+
* and transcripts read and fixes proposed. `undefined` when every story is done.
|
|
1384
|
+
*/
|
|
1385
|
+
export const retroHint = (report, workDir, epicDir) => {
|
|
1386
|
+
const failed = report.count("failed");
|
|
1387
|
+
const waiting = report.count("waiting");
|
|
1388
|
+
if (failed === 0 && waiting === 0) {
|
|
1389
|
+
return undefined;
|
|
1390
|
+
}
|
|
1391
|
+
const what = [
|
|
1392
|
+
...(failed === 0 ? [] : [`${failed} failed`]),
|
|
1393
|
+
...(waiting === 0 ? [] : [`${waiting} waiting`])
|
|
1394
|
+
].join(", ");
|
|
1395
|
+
return `${what}: llm4ts run epic-retro --repo ${workDir} -- --epic ${epicDir} reads this run's trace and transcripts and proposes fixes for the next run`;
|
|
1396
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@llm4ts/shell",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.27.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",
|
|
54
54
|
"@effect/platform-node-shared": "4.0.0",
|
|
55
|
-
"@llm4ts/core": "2.
|
|
56
|
-
"@llm4ts/flow": "2.
|
|
57
|
-
"@llm4ts/runner": "2.
|
|
55
|
+
"@llm4ts/core": "2.27.0",
|
|
56
|
+
"@llm4ts/flow": "2.27.0",
|
|
57
|
+
"@llm4ts/runner": "2.27.0"
|
|
58
58
|
},
|
|
59
59
|
"peerDependencies": {
|
|
60
60
|
"effect": "4.0.0"
|