@llm4ts/shell 2.25.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";
@@ -864,7 +865,10 @@ export const storyJudgeQuery = (story, plan, subject = "diff") => {
864
865
  "the epic branch, shown as additions: judge whether the story is already in place —",
865
866
  "everything it provides exists and is tested — not the diff's size or novelty."
866
867
  ]
867
- : [])
868
+ : []),
869
+ "",
870
+ "The response holds the complete subject. Judge what it shows; do not explore the",
871
+ "repository, read other files, or run anything."
868
872
  ].join("\n");
869
873
  };
870
874
  /** The story-level judge over the branch diff, bounded by the character budget. */
@@ -1047,7 +1051,7 @@ const withOptionalModel = (config, model) => {
1047
1051
  const withExtraFlags = (config, flags) => Object.keys(flags).length === 0
1048
1052
  ? config
1049
1053
  : CliConnectorConfig.make({ ...config, flags: { ...config.flags, ...flags } });
1050
- 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 " +
1051
1055
  "transfers (Bonifico) with beneficiary, review, SCA confirmation, and history.";
1052
1056
  export const runEpicStories = (options) => Effect.gen(function* () {
1053
1057
  const flags = yield* parseEpicArgs(process.argv.slice(2));
@@ -1164,14 +1168,31 @@ export const runEpicStories = (options) => Effect.gen(function* () {
1164
1168
  return;
1165
1169
  }
1166
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());
1167
1184
  // The unit the executor works on: the epic's plan, or one refine round.
1168
1185
  // Same branch, seats, gates and judge; its own plan and state folder.
1169
- 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
+ };
1170
1191
  if (action._tag === "RunRound") {
1171
1192
  const open = rounds.find((round) => round.round === action.round);
1172
1193
  if (open?.plan !== undefined) {
1173
1194
  unit = {
1174
- plan: open.plan,
1195
+ plan: applied?.plan ?? open.plan,
1175
1196
  stateDir: open.stateDir,
1176
1197
  label: `Epic: ${plan.epicId} · round ${open.round}`
1177
1198
  };
@@ -1351,5 +1372,25 @@ export const runEpicStories = (options) => Effect.gen(function* () {
1351
1372
  yield* events.publish(Info.make({
1352
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)`
1353
1374
  }));
1375
+ const hint = retroHint(report, input.workDir, epicId);
1376
+ if (hint !== undefined) {
1377
+ yield* events.publish(Info.make({ message: hint }));
1378
+ }
1354
1379
  }));
1355
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.25.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.25.0",
56
- "@llm4ts/flow": "2.25.0",
57
- "@llm4ts/runner": "2.25.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"