@llm4ts/flow 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/dist/Retro.js ADDED
@@ -0,0 +1,768 @@
1
+ /**
2
+ * Retro: a run's failures become proposed fixes behind an approval.
3
+ *
4
+ * After an epic-stories run with failed stories, the evidence the run left
5
+ * behind (trace, transcripts when kept, board, report, story plans, findings
6
+ * and verdicts) is digested by code into one document, a read-only seat
7
+ * turns it into a typed proposal, and the proposal is written beside an
8
+ * approval line. The next epic-stories run applies what a person ticked:
9
+ * tasks appended to a story's plan, or a story entry edited in the epic
10
+ * plan (which restarts that story). Nothing is applied unreviewed.
11
+ */
12
+ import { join } from "node:path";
13
+ import * as Effect from "effect/Effect";
14
+ import * as Option from "effect/Option";
15
+ import * as Schema from "effect/Schema";
16
+ import { ApprovedMarker, DraftApprovalMarker } from "./Approval.js";
17
+ import { Board, BoardVersion } from "./BoardSync.js";
18
+ import { cap } from "./Context.js";
19
+ import { PersistenceError } from "./FlowError.js";
20
+ import { FlowEvent, Info } from "./FlowEvents.js";
21
+ import { Plan, Task, parsePlan } from "./Plan.js";
22
+ import { loadVersioned, makePlanStore } from "./Persistence.js";
23
+ import { ReviewResult } from "./Review.js";
24
+ import { EpicReport, EpicReportVersion, StoryState, StoryStateVersion, StoryVerdict } from "./Stories.js";
25
+ import { Story, StoryPlan, makeStoryPlanStore, validateStoryPlan } from "./StoryPlan.js";
26
+ /** Where a run's retro lives: `<state>/retro/<runId>.*`, indexed in `retro/retros.jsonl`. */
27
+ export const retroPaths = (stateDir, runId) => {
28
+ const dir = join(stateDir, "retro");
29
+ return {
30
+ dir,
31
+ digest: join(dir, `${runId}.digest.md`),
32
+ report: join(dir, `${runId}.md`),
33
+ proposal: join(dir, `${runId}.json`),
34
+ library: join(dir, `${runId}-library.md`),
35
+ index: join(dir, "retros.jsonl")
36
+ };
37
+ };
38
+ const RetroIndexEntry = Schema.Struct({ runId: Schema.String, at: Schema.Number });
39
+ const encodeIndexEntry = Schema.encodeSync(Schema.fromJsonString(RetroIndexEntry));
40
+ const decodeIndexEntry = Schema.decodeUnknownOption(Schema.fromJsonString(RetroIndexEntry));
41
+ export const appendRetroIndex = (files, stateDir, runId, at) => files.append(retroPaths(stateDir, runId).index, `${encodeIndexEntry({ runId, at })}\n`);
42
+ /** The retros written for this state folder, oldest first. */
43
+ export const readRetroIndex = (files, stateDir) => files.read(retroPaths(stateDir, "").index).pipe(Effect.catch(() => Effect.succeed(undefined)), Effect.map((contents) => (contents ?? "")
44
+ .split("\n")
45
+ .filter((line) => line.trim().length > 0)
46
+ .flatMap((line) => Option.toArray(decodeIndexEntry(line)))));
47
+ export const defaultRetroChars = 60_000;
48
+ const perStoryShare = 12_000;
49
+ const findingsTail = 3_000;
50
+ const transcriptHead = 600;
51
+ const runLineCap = 40;
52
+ const Verdict = Schema.Union([StoryVerdict, ReviewResult]);
53
+ const VerdictEntry = Schema.fromJsonString(Schema.Struct({ fingerprint: Schema.String, result: Verdict }));
54
+ /** Reads the per-story files the executor keeps under `<state>/stories/`. */
55
+ export const loadStoryFiles = Effect.fn("@llm4ts/flow/Retro.loadStoryFiles")(function* (files, stateDir, story) {
56
+ const base = join(stateDir, "stories", story.id);
57
+ const quiet = (effect) => effect.pipe(Effect.catch(() => Effect.succeed(undefined)));
58
+ const state = yield* quiet(loadVersioned(files, `${base}.json`, StoryStateVersion, StoryState));
59
+ const planText = yield* quiet(files.read(`${base}.plan.md`));
60
+ const plan = planText === undefined ? undefined : yield* quiet(parsePlan(planText));
61
+ const findings = yield* quiet(files.read(`${base}.findings.md`));
62
+ const judgeText = yield* quiet(files.read(`${base}.judge.json`));
63
+ const verdict = judgeText === undefined
64
+ ? undefined
65
+ : Option.getOrUndefined(Schema.decodeUnknownOption(VerdictEntry)(judgeText))?.result;
66
+ return { story, state, plan, findings, verdict };
67
+ });
68
+ /** The epic's report and board, when the run wrote them. */
69
+ export const loadEpicFiles = Effect.fn("@llm4ts/flow/Retro.loadEpicFiles")(function* (files, stateDir) {
70
+ const quiet = (effect) => effect.pipe(Effect.catch(() => Effect.succeed(undefined)));
71
+ const report = yield* quiet(loadVersioned(files, join(stateDir, "report.json"), EpicReportVersion, EpicReport));
72
+ const board = yield* quiet(loadVersioned(files, join(stateDir, "board.json"), BoardVersion, Board));
73
+ return { report, board };
74
+ });
75
+ const decodeEvent = Schema.decodeUnknownOption(Schema.fromJsonString(FlowEvent));
76
+ const traceView = (lines) => {
77
+ let outcome;
78
+ const events = [];
79
+ for (const line of lines) {
80
+ if (line.kind === "RunEnded") {
81
+ const value = line.fields.outcome;
82
+ outcome = typeof value === "string" ? value : "completed";
83
+ continue;
84
+ }
85
+ const raw = line.fields.event;
86
+ if (typeof raw === "string") {
87
+ const decoded = decodeEvent(raw);
88
+ if (decoded._tag === "Some") {
89
+ events.push(decoded.value);
90
+ }
91
+ }
92
+ }
93
+ return { outcome, events };
94
+ };
95
+ const laneOf = (event) => "lane" in event && typeof event.lane === "string" ? event.lane : undefined;
96
+ const retryLine = /^⟳ (.+?) — retry (\d+)\/(\d+): (.*)$/u;
97
+ const retryKind = (what) => what.startsWith("serving engine down")
98
+ ? "outage"
99
+ : what.startsWith("flaky")
100
+ ? "flaky stream"
101
+ : what.startsWith("transient")
102
+ ? "transient"
103
+ : what.startsWith("structured output")
104
+ ? "structured repair"
105
+ : what;
106
+ const countBy = (items) => [
107
+ ...items.reduce((map, item) => map.set(item, (map.get(item) ?? 0) + 1), new Map())
108
+ ]
109
+ .sort((left, right) => right[1] - left[1])
110
+ .map(([key, count]) => `${key} ×${count}`)
111
+ .join(", ");
112
+ const head = (text, limit = transcriptHead) => {
113
+ const flat = text.replace(/\s+/g, " ").trim();
114
+ return flat.length <= limit ? flat : `${flat.slice(0, limit)}…`;
115
+ };
116
+ /** The stories worth a retro: failed, waiting, or still active when the run ended. */
117
+ export const retroCandidates = (inputs) => {
118
+ const fromReport = new Set((inputs.report?.stories ?? [])
119
+ .filter((outcome) => outcome.status !== "done")
120
+ .map((outcome) => outcome.id));
121
+ const fromBoard = new Set((inputs.board?.items ?? [])
122
+ .filter((item) => item.status === "active" || item.status === "failed" || item.status === "waiting")
123
+ .map((item) => item.id));
124
+ const noRecord = inputs.report === undefined && inputs.board === undefined;
125
+ return inputs.stories.filter((entry) => entry.state?.status !== "merged" &&
126
+ (fromReport.has(entry.story.id) ||
127
+ fromBoard.has(entry.story.id) ||
128
+ (noRecord && entry.state !== undefined)));
129
+ };
130
+ const storySection = (inputs, trace, entry, share) => {
131
+ const { story } = entry;
132
+ const cuts = [];
133
+ const lines = [`## Story ${story.id} — ${story.title}`];
134
+ const outcome = inputs.report?.stories.find((candidate) => candidate.id === story.id);
135
+ const item = inputs.board?.items.find((candidate) => candidate.id === story.id);
136
+ if (outcome !== undefined) {
137
+ lines.push(`- outcome: ${outcome.status}${outcome.reason === undefined ? "" : ` — ${outcome.reason}`}`);
138
+ }
139
+ if (item !== undefined && (outcome === undefined || item.detail !== outcome.reason)) {
140
+ lines.push(`- board: ${item.status}${item.detail === undefined ? "" : ` — ${item.detail}`}`);
141
+ }
142
+ if (entry.state !== undefined) {
143
+ lines.push(`- state: ${entry.state.status} on ${entry.state.branch}${entry.state.executor === undefined ? "" : `, coder ${entry.state.executor}`}`);
144
+ }
145
+ if (outcome?.judge !== undefined) {
146
+ lines.push(`- judge: ${outcome.judge}`);
147
+ }
148
+ lines.push(`- owned: ${story.owned.join(", ")}; provides: ${story.provides.join(", ") || "(none)"}`);
149
+ if (entry.plan === undefined) {
150
+ lines.push("", "### Task plan", "- none yet (the story never planned its tasks)");
151
+ }
152
+ else {
153
+ lines.push("", "### Task plan", ...entry.plan.tasks.map((task) => `- [${task.completed ? "x" : " "}] ${task.title}`));
154
+ }
155
+ if (entry.findings !== undefined) {
156
+ const tail = entry.findings.trimEnd();
157
+ const shown = tail.length > findingsTail ? tail.slice(tail.length - findingsTail) : tail;
158
+ if (shown.length < tail.length) {
159
+ cuts.push(`findings ${tail.length - shown.length} chars`);
160
+ }
161
+ lines.push("", "### Findings (tail)", shown);
162
+ }
163
+ if (entry.verdict !== undefined) {
164
+ const dims = entry.verdict instanceof StoryVerdict
165
+ ? ` (${entry.verdict.dimensions.map((d) => `${d.id} ${d.score}/${d.max}`).join(", ")})`
166
+ : "";
167
+ lines.push("", `### Last judge verdict${dims}`, ...(entry.verdict.issues.length === 0
168
+ ? ["- cleared"]
169
+ : entry.verdict.issues.map((issue) => `- [${issue.severity}] ${issue.title}: ${issue.description}`)));
170
+ }
171
+ const laneEvents = trace.events.filter((event) => laneOf(event) === story.id);
172
+ const traceLines = [];
173
+ const retries = [];
174
+ for (const event of laneEvents) {
175
+ switch (event._tag) {
176
+ case "StageFailed":
177
+ traceLines.push(`- stage failed (${event.stage}): ${event.message}`);
178
+ break;
179
+ case "StoryJudged":
180
+ traceLines.push(`- judge round ${event.round}: ${event.cleared ? "cleared" : `not cleared, ${event.issues} issue(s)`}${event.dimensions.length === 0
181
+ ? ""
182
+ : ` (${event.dimensions.map((d) => `${d.id} ${d.score}/${d.max}`).join(", ")})`}`);
183
+ break;
184
+ case "ReviewFindings":
185
+ traceLines.push(`- review round ${event.round}${event.settled ? " (settled)" : ""}: ${event.issues.length} issue(s)${event.issues.length === 0
186
+ ? ""
187
+ : ` — ${event.issues.map((issue) => issue.title).join("; ")}`}`);
188
+ break;
189
+ case "Timed":
190
+ if (event.kind === "gate" && event.failed === true) {
191
+ traceLines.push(`- gate failed: ${event.label} (exit ${event.exitCode ?? "?"})`);
192
+ }
193
+ break;
194
+ case "Info": {
195
+ const match = retryLine.exec(event.message);
196
+ if (match?.[1] !== undefined) {
197
+ retries.push(retryKind(match[1]));
198
+ }
199
+ break;
200
+ }
201
+ default:
202
+ break;
203
+ }
204
+ }
205
+ if (retries.length > 0) {
206
+ traceLines.push(`- retries: ${countBy(retries)}`);
207
+ }
208
+ if (traceLines.length > 0) {
209
+ lines.push("", "### Trace", ...traceLines);
210
+ }
211
+ if (inputs.transcripts !== undefined) {
212
+ const entries = inputs.transcripts.get(story.id) ?? [];
213
+ if (entries.length === 0) {
214
+ lines.push("", "### Transcript", "- no entries for this story");
215
+ }
216
+ else {
217
+ const calls = entries.filter((e) => e._tag === "Call");
218
+ const tools = entries.flatMap((e) => (e._tag === "Tool" ? [e.tool] : []));
219
+ const failures = entries.filter((e) => e._tag === "ToolResult" && e.failed === true);
220
+ const lastCall = calls.at(-1);
221
+ const lastReply = [...entries].reverse().find((e) => e._tag === "Reply");
222
+ const lastEnd = [...entries].reverse().find((e) => e._tag === "End");
223
+ const transcriptLines = [
224
+ `- calls: ${calls.length} (${countBy(calls.map((c) => (c._tag === "Call" ? c.role : "")))})`,
225
+ `- tool calls: ${tools.length}${tools.length === 0 ? "" : ` (${countBy(tools)})`}`,
226
+ `- failed tool results: ${failures.length}`
227
+ ];
228
+ const firstFailure = failures[0];
229
+ if (firstFailure !== undefined && firstFailure._tag === "ToolResult") {
230
+ transcriptLines.push(`- first failed tool result: ${head(firstFailure.output)}`);
231
+ }
232
+ if (lastCall !== undefined && lastCall._tag === "Call") {
233
+ transcriptLines.push(`- last call: ${lastCall.role} — ${head(lastCall.input)}`);
234
+ }
235
+ if (lastReply !== undefined && lastReply._tag === "Reply") {
236
+ transcriptLines.push(`- last reply: ${head(lastReply.text)}`);
237
+ }
238
+ if (lastEnd !== undefined && lastEnd._tag === "End") {
239
+ transcriptLines.push(`- last call ended ${lastEnd.failed === true ? "failed" : "ok"} after ${Math.round(lastEnd.ms / 1000)}s`);
240
+ }
241
+ lines.push("", "### Transcript", ...transcriptLines);
242
+ }
243
+ }
244
+ const text = lines.join("\n");
245
+ const capped = cap(text, share);
246
+ if (capped.truncated) {
247
+ cuts.push(`section ${text.length - capped.text.length} chars`);
248
+ }
249
+ return cuts.length === 0 ? capped.text : `${capped.text}\n\n(cut: ${cuts.join(", ")})`;
250
+ };
251
+ const runLinePrefixes = [
252
+ "gates:",
253
+ "node:",
254
+ "app dir:",
255
+ "epic ",
256
+ "roster",
257
+ "seats",
258
+ "coder:",
259
+ "reasoning:",
260
+ "judge",
261
+ "executor",
262
+ "story plan:",
263
+ "refine round",
264
+ "retro:"
265
+ ];
266
+ /** The digest a retro seat is given: code-built, deterministic, capped. */
267
+ export const renderRetroDigest = (inputs) => {
268
+ const trace = traceView(inputs.trace);
269
+ const candidates = retroCandidates(inputs);
270
+ const budget = Math.max(4_000, inputs.budget ?? defaultRetroChars);
271
+ const runLines = [];
272
+ const retries = [];
273
+ for (const event of trace.events) {
274
+ if (laneOf(event) !== undefined) {
275
+ continue;
276
+ }
277
+ switch (event._tag) {
278
+ case "Info": {
279
+ const match = retryLine.exec(event.message);
280
+ if (match?.[1] !== undefined) {
281
+ retries.push(retryKind(match[1]));
282
+ }
283
+ else if (runLinePrefixes.some((prefix) => event.message.startsWith(prefix))) {
284
+ runLines.push(`- ${event.message}`);
285
+ }
286
+ break;
287
+ }
288
+ case "Aborted":
289
+ runLines.push(`- aborted: ${event.message}`);
290
+ break;
291
+ case "CapabilityDenied":
292
+ runLines.push(`- capability denied: ${event.capability} (${event.operation})`);
293
+ break;
294
+ case "CapabilityUnenforceable":
295
+ runLines.push(`- capability unenforceable: ${event.detail}`);
296
+ break;
297
+ case "ExecutorExcluded":
298
+ runLines.push(`- executor excluded: ${event.executor} — ${event.reason}`);
299
+ break;
300
+ case "StageFailed":
301
+ runLines.push(`- stage failed (${event.stage}): ${event.message}`);
302
+ break;
303
+ default:
304
+ break;
305
+ }
306
+ }
307
+ const header = [
308
+ `# Retro digest — run ${inputs.runId}, epic ${inputs.plan.epicId}`,
309
+ "",
310
+ `- run outcome: ${trace.outcome ?? "unknown (the trace has no end line: the process died)"}`,
311
+ `- stories: ${inputs.plan.stories.length} planned, ${candidates.length} to look at (${candidates.map((c) => c.story.id).join(", ") || "none"})`,
312
+ `- transcripts: ${inputs.transcripts === undefined ? "none (the run was started without --transcript)" : "present"}`,
313
+ ...(retries.length === 0 ? [] : [`- retries across the run: ${countBy(retries)}`]),
314
+ ...(runLines.length === 0
315
+ ? []
316
+ : [
317
+ "",
318
+ "## Run",
319
+ ...runLines.slice(0, runLineCap),
320
+ ...(runLines.length > runLineCap
321
+ ? [`- … ${runLines.length - runLineCap} more lines`]
322
+ : [])
323
+ ])
324
+ ].join("\n");
325
+ const remaining = budget - header.length;
326
+ const share = Math.min(perStoryShare, Math.max(1_500, Math.floor(remaining / Math.max(1, candidates.length))));
327
+ const sections = candidates.map((entry) => storySection(inputs, trace, entry, share));
328
+ return [header, ...sections].join("\n\n");
329
+ };
330
+ // ---- Proposal ------------------------------------------------------------------------------
331
+ export const RetroFixKind = Schema.Literals(["tasks", "story", "refine", "none"]);
332
+ export const RetroTask = Schema.Struct({
333
+ title: Schema.String,
334
+ description: Schema.String
335
+ });
336
+ export const RetroStoryChanges = Schema.Struct({
337
+ description: Schema.optionalKey(Schema.String),
338
+ owned: Schema.optionalKey(Schema.Array(Schema.String)),
339
+ sharedReadOnly: Schema.optionalKey(Schema.Array(Schema.String)),
340
+ dependsOn: Schema.optionalKey(Schema.Array(Schema.String)),
341
+ provides: Schema.optionalKey(Schema.Array(Schema.String))
342
+ });
343
+ /** One story's diagnosis and exactly one fix, discriminated by `kind`. */
344
+ export const RetroStory = Schema.Struct({
345
+ id: Schema.String,
346
+ diagnosis: Schema.String,
347
+ kind: RetroFixKind,
348
+ /** `tasks`: appended to the story's plan. */
349
+ tasks: Schema.optionalKey(Schema.Array(RetroTask)),
350
+ /** `story`: the fields to change on the plan entry. */
351
+ changes: Schema.optionalKey(RetroStoryChanges),
352
+ /** `story`: why the entry itself is wrong. `none`: why nothing changes. */
353
+ why: Schema.optionalKey(Schema.String),
354
+ /** `refine`: the feedback a new round should take. */
355
+ feedback: Schema.optionalKey(Schema.String)
356
+ });
357
+ export class RetroProposal extends Schema.Class("RetroProposal")({
358
+ summary: Schema.String,
359
+ stories: Schema.Array(RetroStory),
360
+ runAdvice: Schema.Array(Schema.Struct({ finding: Schema.String, evidence: Schema.String })),
361
+ libraryAdvice: Schema.Array(Schema.Struct({ title: Schema.String, evidence: Schema.String, suggestion: Schema.String }))
362
+ }) {
363
+ }
364
+ export const retroProposalJsonSchema = {
365
+ type: "object",
366
+ properties: {
367
+ summary: { type: "string" },
368
+ stories: {
369
+ type: "array",
370
+ items: {
371
+ type: "object",
372
+ properties: {
373
+ id: { type: "string" },
374
+ diagnosis: { type: "string" },
375
+ kind: { type: "string", enum: ["tasks", "story", "refine", "none"] },
376
+ tasks: {
377
+ type: "array",
378
+ items: {
379
+ type: "object",
380
+ properties: { title: { type: "string" }, description: { type: "string" } },
381
+ required: ["title", "description"]
382
+ }
383
+ },
384
+ changes: {
385
+ type: "object",
386
+ properties: {
387
+ description: { type: "string" },
388
+ owned: { type: "array", items: { type: "string" } },
389
+ sharedReadOnly: { type: "array", items: { type: "string" } },
390
+ dependsOn: { type: "array", items: { type: "string" } },
391
+ provides: { type: "array", items: { type: "string" } }
392
+ }
393
+ },
394
+ why: { type: "string" },
395
+ feedback: { type: "string" }
396
+ },
397
+ required: ["id", "diagnosis", "kind"]
398
+ }
399
+ },
400
+ runAdvice: {
401
+ type: "array",
402
+ items: {
403
+ type: "object",
404
+ properties: { finding: { type: "string" }, evidence: { type: "string" } },
405
+ required: ["finding", "evidence"]
406
+ }
407
+ },
408
+ libraryAdvice: {
409
+ type: "array",
410
+ items: {
411
+ type: "object",
412
+ properties: {
413
+ title: { type: "string" },
414
+ evidence: { type: "string" },
415
+ suggestion: { type: "string" }
416
+ },
417
+ required: ["title", "evidence", "suggestion"]
418
+ }
419
+ }
420
+ },
421
+ required: ["summary", "stories", "runAdvice", "libraryAdvice"]
422
+ };
423
+ /** The seat's instructions: the digest is the whole subject. */
424
+ export const retroPrompt = (digest, storiesWithPlan) => [
425
+ "You are the retrospective of a failed parallel implementation run. Below is a digest of",
426
+ "what the run left behind: per story, its outcome, task plan, reviewer findings, judge",
427
+ "verdict, trace events and transcript summary. The digest is the whole subject: do not",
428
+ "explore the repository, read other files, or run anything; answer from the digest.",
429
+ "",
430
+ "For every story in the digest, write a diagnosis that cites digest lines, then choose",
431
+ "exactly one fix by `kind`:",
432
+ "- `tasks`: new tasks to append to the story's own plan, each with a title and a description",
433
+ " that carries what the coder needs to know from the diagnosis. Only for stories that already",
434
+ ` have a task plan: ${storiesWithPlan.length === 0 ? "none here" : storiesWithPlan.join(", ")}.`,
435
+ "- `story`: the story's entry in the epic plan is wrong (description, owned paths, shared",
436
+ " read-only paths, dependencies, provides). Give only the fields that change, and `why`.",
437
+ " This restarts the story from a fresh worktree, discarding its work: choose it only when",
438
+ " the entry itself caused the failure.",
439
+ "- `refine`: the story needs a new planning round (a missing story, a split, a scope change",
440
+ " no edit covers). Give the `feedback` text a planner should take.",
441
+ "- `none`: nothing to change (an outage, a dependency that will unblock it). Give `why`.",
442
+ "",
443
+ "`runAdvice`: findings about the environment or configuration (a seat on an old CLI, Node",
444
+ "mismatch, gates missing, transcripts off), each with its evidence line.",
445
+ "`libraryAdvice`: only where the digest shows llm4ts itself — the flow, a seat's harness",
446
+ "mapping, a prompt — misbehaving rather than the epic: title, evidence, suggestion.",
447
+ "Be concrete and short. Return ONLY JSON matching the schema.",
448
+ "",
449
+ digest
450
+ ].join("\n");
451
+ const badPath = (path) => path.trim().length === 0 ||
452
+ path.startsWith("/") ||
453
+ /^[A-Za-z]:[\\/]/.test(path) ||
454
+ path.split(/[\\/]/).includes("..");
455
+ const sameList = (left, right) => left.length === right.length && left.every((value, index) => value === right[index]);
456
+ /** Applies the story changes a proposal names; `undefined` when nothing would change. */
457
+ export const storyWithChanges = (story, changes) => {
458
+ const next = Story.make({
459
+ ...story,
460
+ ...(changes.description === undefined ? {} : { description: changes.description }),
461
+ ...(changes.owned === undefined ? {} : { owned: changes.owned }),
462
+ ...(changes.sharedReadOnly === undefined ? {} : { sharedReadOnly: changes.sharedReadOnly }),
463
+ ...(changes.dependsOn === undefined ? {} : { dependsOn: changes.dependsOn }),
464
+ ...(changes.provides === undefined ? {} : { provides: changes.provides })
465
+ });
466
+ const unchanged = next.description === story.description &&
467
+ sameList(next.owned, story.owned) &&
468
+ sameList(next.sharedReadOnly, story.sharedReadOnly) &&
469
+ sameList(next.dependsOn, story.dependsOn) &&
470
+ sameList(next.provides, story.provides);
471
+ return unchanged ? undefined : next;
472
+ };
473
+ /** Code checks the seat's answer before anything is written; bad items are dropped, not fatal. */
474
+ export const validateRetroProposal = (proposal, plan, storiesWithPlan) => {
475
+ const dropped = [];
476
+ const seen = new Set();
477
+ const stories = [];
478
+ for (const entry of proposal.stories) {
479
+ const story = plan.story(entry.id);
480
+ if (story === undefined) {
481
+ dropped.push(`${entry.id}: not a story of this plan`);
482
+ continue;
483
+ }
484
+ if (seen.has(entry.id)) {
485
+ dropped.push(`${entry.id}: named twice; the first entry stands`);
486
+ continue;
487
+ }
488
+ seen.add(entry.id);
489
+ switch (entry.kind) {
490
+ case "tasks": {
491
+ const tasks = (entry.tasks ?? []).filter((task) => task.title.trim().length > 0 && task.description.trim().length > 0);
492
+ if (tasks.length === 0) {
493
+ dropped.push(`${entry.id}: a tasks fix with no task`);
494
+ continue;
495
+ }
496
+ if (!storiesWithPlan.has(entry.id)) {
497
+ dropped.push(`${entry.id}: tasks need a task plan, and the story never planned one`);
498
+ continue;
499
+ }
500
+ stories.push({ ...entry, tasks });
501
+ break;
502
+ }
503
+ case "story": {
504
+ const changes = entry.changes;
505
+ if (changes === undefined) {
506
+ dropped.push(`${entry.id}: a story fix with no changes`);
507
+ continue;
508
+ }
509
+ const paths = [...(changes.owned ?? []), ...(changes.sharedReadOnly ?? [])];
510
+ const outside = paths.filter(badPath);
511
+ if (outside.length > 0) {
512
+ dropped.push(`${entry.id}: paths outside the repository (${outside.join(", ")})`);
513
+ continue;
514
+ }
515
+ const unknown = (changes.dependsOn ?? []).filter((id) => id === entry.id || plan.story(id) === undefined);
516
+ if (unknown.length > 0) {
517
+ dropped.push(`${entry.id}: dependencies that are not stories (${unknown.join(", ")})`);
518
+ continue;
519
+ }
520
+ if (storyWithChanges(story, changes) === undefined) {
521
+ dropped.push(`${entry.id}: a story fix that changes nothing`);
522
+ continue;
523
+ }
524
+ stories.push(entry);
525
+ break;
526
+ }
527
+ case "refine":
528
+ if ((entry.feedback ?? "").trim().length === 0) {
529
+ dropped.push(`${entry.id}: a refine fix with no feedback`);
530
+ continue;
531
+ }
532
+ stories.push(entry);
533
+ break;
534
+ case "none":
535
+ stories.push(entry);
536
+ break;
537
+ }
538
+ }
539
+ return { proposal: RetroProposal.make({ ...proposal, stories }), dropped };
540
+ };
541
+ // ---- Report ---------------------------------------------------------------------------------
542
+ export const AppliedMarkerPrefix = "- [x] Applied";
543
+ export const retroApprovalOf = (markdown) => ({
544
+ approved: markdown.includes(ApprovedMarker),
545
+ applied: markdown.split("\n").some((line) => line.trim().startsWith(AppliedMarkerPrefix))
546
+ });
547
+ const fixLines = (entry, story) => {
548
+ switch (entry.kind) {
549
+ case "tasks":
550
+ return [
551
+ `**Proposed fix: ${entry.tasks?.length ?? 0} task(s) appended to the story's plan.**`,
552
+ ...(entry.tasks ?? []).map((task) => `- ${task.title}: ${task.description}`)
553
+ ];
554
+ case "story": {
555
+ const changes = entry.changes ?? {};
556
+ const field = (name, before) => {
557
+ const after = changes[name];
558
+ if (after === undefined) {
559
+ return [];
560
+ }
561
+ const render = (value) => value === undefined
562
+ ? "(none)"
563
+ : typeof value === "string"
564
+ ? value
565
+ : value.join(", ") || "(none)";
566
+ return [`- ${name}: ${render(before)} → ${render(after)}`];
567
+ };
568
+ return [
569
+ `**Proposed fix: edit the story's entry (restarts it from a fresh worktree).** ${entry.why ?? ""}`.trimEnd(),
570
+ ...field("description", story?.description),
571
+ ...field("owned", story?.owned),
572
+ ...field("sharedReadOnly", story?.sharedReadOnly),
573
+ ...field("dependsOn", story?.dependsOn),
574
+ ...field("provides", story?.provides)
575
+ ];
576
+ }
577
+ case "refine":
578
+ return [
579
+ "**Proposed fix: a refine round, not applied here.** Feedback to give it:",
580
+ `> ${(entry.feedback ?? "").trim().replace(/\n/g, "\n> ")}`
581
+ ];
582
+ case "none":
583
+ return [`**No change proposed.** ${entry.why ?? ""}`.trimEnd()];
584
+ }
585
+ };
586
+ /** The report a person reads and ticks. */
587
+ export const renderRetroReport = (inputs) => {
588
+ const { proposal, dropped } = inputs.validated;
589
+ const lines = [
590
+ `# Retro — run ${inputs.runId}, epic ${inputs.plan.epicId}`,
591
+ "",
592
+ proposal.summary.trim(),
593
+ ""
594
+ ];
595
+ if (proposal.stories.length > 0) {
596
+ lines.push("## Stories", "");
597
+ for (const entry of proposal.stories) {
598
+ const story = inputs.plan.story(entry.id);
599
+ lines.push(`### ${entry.id}${story === undefined ? "" : ` — ${story.title}`}`, "", `**Diagnosis.** ${entry.diagnosis.trim()}`, "", ...fixLines(entry, story), "");
600
+ }
601
+ }
602
+ if (proposal.runAdvice.length > 0) {
603
+ lines.push("## Run advice", "", ...proposal.runAdvice.map((item) => `- ${item.finding} — ${item.evidence}`), "");
604
+ }
605
+ if (dropped.length > 0) {
606
+ lines.push("## Dropped by validation", "", ...dropped.map((item) => `- ${item}`), "");
607
+ }
608
+ const applies = proposal.stories.filter((entry) => entry.kind === "tasks" || entry.kind === "story");
609
+ const refines = proposal.stories.filter((entry) => entry.kind === "refine");
610
+ lines.push("## Approval", "");
611
+ if (applies.length === 0) {
612
+ lines.push("Nothing here is applied by the next run; the report is for reading.", "");
613
+ }
614
+ else {
615
+ lines.push("Ticking the box lets the next `epic-stories` run of this epic apply:", "", ...applies.map((entry) => entry.kind === "tasks"
616
+ ? `- ${entry.id}: append ${entry.tasks?.length ?? 0} task(s) to its plan`
617
+ : `- ${entry.id}: edit its entry and restart it from a fresh worktree`), "");
618
+ }
619
+ if (refines.length > 0) {
620
+ lines.push("A refine round is started by hand, with the feedback above:", "", ...refines.map(() => ` llm4ts run epic-stories --repo ${inputs.workDir} -- --refine --epic ${inputs.epicDir} "<feedback>"`), "");
621
+ }
622
+ lines.push(DraftApprovalMarker, "");
623
+ return lines.join("\n");
624
+ };
625
+ /** The note for llm4ts itself; `undefined` when the seat had nothing to say. */
626
+ export const renderLibraryAdvice = (runId, epicId, proposal) => proposal.libraryAdvice.length === 0
627
+ ? undefined
628
+ : [
629
+ `# Advice for llm4ts — from run ${runId} of epic ${epicId}`,
630
+ "",
631
+ "What the retro saw the library, not the epic, do. Read before changing anything.",
632
+ "",
633
+ ...proposal.libraryAdvice.flatMap((item) => [
634
+ `## ${item.title}`,
635
+ "",
636
+ `Evidence: ${item.evidence}`,
637
+ "",
638
+ `Suggestion: ${item.suggestion}`,
639
+ ""
640
+ ])
641
+ ].join("\n");
642
+ const encodeProposal = Schema.encodeEffect(Schema.fromJsonString(RetroProposal));
643
+ const decodeProposal = Schema.decodeUnknownOption(Schema.fromJsonString(RetroProposal));
644
+ /** Writes the four files of a retro and indexes it. */
645
+ export const writeRetro = Effect.fn("@llm4ts/flow/Retro.write")(function* (files, stateDir, inputs) {
646
+ const paths = retroPaths(stateDir, inputs.runId);
647
+ yield* files.writeAtomic(paths.digest, inputs.digest);
648
+ const encoded = yield* encodeProposal(inputs.validated.proposal).pipe(Effect.mapError((error) => PersistenceError.make({
649
+ message: `failed to encode the retro proposal: ${String(error)}`,
650
+ cause: error
651
+ })));
652
+ yield* files.writeAtomic(paths.proposal, `${encoded}\n`);
653
+ yield* files.writeAtomic(paths.report, renderRetroReport(inputs));
654
+ const library = renderLibraryAdvice(inputs.runId, inputs.plan.epicId, inputs.validated.proposal);
655
+ if (library !== undefined) {
656
+ yield* files.writeAtomic(paths.library, library);
657
+ }
658
+ yield* appendRetroIndex(files, stateDir, inputs.runId, inputs.at);
659
+ return paths;
660
+ });
661
+ const retroTaskTitle = (runId, title) => `Retro ${runId}: ${title}`;
662
+ /**
663
+ * Applies every approved, not yet applied retro of this state folder, oldest
664
+ * first: tasks go onto story plans, story edits onto the epic plan (saved,
665
+ * so the executor's hash check restarts them). Says what it did on the
666
+ * events. Nothing here fails the run: a retro that no longer fits is skipped.
667
+ */
668
+ export const applyApprovedRetros = Effect.fn("@llm4ts/flow/Retro.apply")(function* (files, stateDir, plan, events, now) {
669
+ const notes = [];
670
+ const say = (message) => Effect.sync(() => {
671
+ notes.push(message);
672
+ }).pipe(Effect.andThen(events.publish(Info.make({ message: `retro: ${message}` }))));
673
+ let current = plan;
674
+ const planStore = makePlanStore(files);
675
+ for (const { runId } of yield* readRetroIndex(files, stateDir)) {
676
+ const paths = retroPaths(stateDir, runId);
677
+ const report = yield* files
678
+ .read(paths.report)
679
+ .pipe(Effect.catch(() => Effect.succeed(undefined)));
680
+ if (report === undefined) {
681
+ continue;
682
+ }
683
+ const approval = retroApprovalOf(report);
684
+ if (!approval.approved || approval.applied) {
685
+ continue;
686
+ }
687
+ const proposalText = yield* files
688
+ .read(paths.proposal)
689
+ .pipe(Effect.catch(() => Effect.succeed(undefined)));
690
+ const proposal = proposalText === undefined ? undefined : Option.getOrUndefined(decodeProposal(proposalText));
691
+ if (proposal === undefined) {
692
+ yield* say(`${runId}: approved, but ${paths.proposal} is missing or unreadable; skipped`);
693
+ continue;
694
+ }
695
+ let edited = current;
696
+ const editedIds = [];
697
+ for (const entry of proposal.stories) {
698
+ const story = edited.story(entry.id);
699
+ if (story === undefined) {
700
+ yield* say(`${runId}: ${entry.id} is no longer in the plan; skipped`);
701
+ continue;
702
+ }
703
+ switch (entry.kind) {
704
+ case "tasks": {
705
+ const path = join(stateDir, "stories", `${entry.id}.plan.md`);
706
+ const existing = yield* planStore
707
+ .load(path)
708
+ .pipe(Effect.catch(() => Effect.succeed(undefined)));
709
+ if (existing === undefined) {
710
+ yield* say(`${runId}: ${entry.id} has no task plan to append to; skipped`);
711
+ continue;
712
+ }
713
+ const tasks = entry.tasks ?? [];
714
+ const titles = new Set(existing.tasks.map((task) => task.title));
715
+ const fresh = tasks.filter((task) => !titles.has(retroTaskTitle(runId, task.title)));
716
+ if (fresh.length === 0) {
717
+ continue;
718
+ }
719
+ yield* planStore.save(path, Plan.make({
720
+ ...existing,
721
+ tasks: [
722
+ ...existing.tasks,
723
+ ...fresh.map((task) => Task.make({
724
+ title: retroTaskTitle(runId, task.title),
725
+ description: task.description
726
+ }))
727
+ ]
728
+ }));
729
+ yield* say(`${runId}: ${entry.id} gets ${fresh.length} task(s) from the retro`);
730
+ break;
731
+ }
732
+ case "story": {
733
+ const next = entry.changes === undefined ? undefined : storyWithChanges(story, entry.changes);
734
+ if (next === undefined) {
735
+ continue;
736
+ }
737
+ edited = StoryPlan.make({
738
+ ...edited,
739
+ stories: edited.stories.map((candidate) => candidate.id === entry.id ? next : candidate)
740
+ });
741
+ editedIds.push(entry.id);
742
+ break;
743
+ }
744
+ case "refine":
745
+ yield* say(`${runId}: ${entry.id} needs a refine round (not applied): ${(entry.feedback ?? "").trim()}`);
746
+ break;
747
+ case "none":
748
+ break;
749
+ }
750
+ }
751
+ if (editedIds.length > 0) {
752
+ const valid = yield* validateStoryPlan(edited).pipe(Effect.map((plan) => ({ plan, problem: undefined })), Effect.catch((error) => Effect.succeed({ plan: current, problem: error.violations.join("; ") })));
753
+ if (valid.problem === undefined) {
754
+ yield* makeStoryPlanStore(files).save(join(stateDir, "plan.md"), valid.plan);
755
+ current = valid.plan;
756
+ for (const id of editedIds) {
757
+ yield* say(`${runId}: story ${id} edited, restarting from a fresh worktree`);
758
+ }
759
+ }
760
+ else {
761
+ yield* say(`${runId}: story edits would leave the plan invalid (${valid.problem}); skipped`);
762
+ }
763
+ }
764
+ yield* files.writeAtomic(paths.report, `${report.trimEnd()}\n${AppliedMarkerPrefix} ${new Date(now).toISOString()}\n`);
765
+ }
766
+ return { plan: current, notes };
767
+ });
768
+ //# sourceMappingURL=Retro.js.map