@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.
@@ -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 = { plan, stateDir, label: `Epic: ${plan.epicId}` };
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.26.0",
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.26.0",
56
- "@llm4ts/flow": "2.26.0",
57
- "@llm4ts/runner": "2.26.0"
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"