@llm4ts/flow 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.
package/src/Retro.ts ADDED
@@ -0,0 +1,1061 @@
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 type { JsonSchema } from "@llm4ts/core/Models"
17
+ import { ApprovedMarker, DraftApprovalMarker } from "./Approval.ts"
18
+ import { Board, BoardVersion } from "./BoardSync.ts"
19
+ import { cap } from "./Context.ts"
20
+ import { PersistenceError, type FlowError, type StoryPlanInvalid } from "./FlowError.ts"
21
+ import { FlowEvent, Info, type FlowEventsShape } from "./FlowEvents.ts"
22
+ import type { TraceLine } from "./FlowRecorder.ts"
23
+ import { Plan, Task, parsePlan } from "./Plan.ts"
24
+ import { loadVersioned, makePlanStore, type PlainFileStoreShape } from "./Persistence.ts"
25
+ import { ReviewResult } from "./Review.ts"
26
+ import {
27
+ EpicReport,
28
+ EpicReportVersion,
29
+ StoryState,
30
+ StoryStateVersion,
31
+ StoryVerdict
32
+ } from "./Stories.ts"
33
+ import { Story, StoryPlan, makeStoryPlanStore, validateStoryPlan } from "./StoryPlan.ts"
34
+ import type { TranscriptEntry } from "./Transcript.ts"
35
+
36
+ // ---- Paths --------------------------------------------------------------------------
37
+
38
+ export interface RetroPaths {
39
+ readonly dir: string
40
+ readonly digest: string
41
+ readonly report: string
42
+ readonly proposal: string
43
+ readonly library: string
44
+ readonly index: string
45
+ }
46
+
47
+ /** Where a run's retro lives: `<state>/retro/<runId>.*`, indexed in `retro/retros.jsonl`. */
48
+ export const retroPaths = (stateDir: string, runId: string): RetroPaths => {
49
+ const dir = join(stateDir, "retro")
50
+ return {
51
+ dir,
52
+ digest: join(dir, `${runId}.digest.md`),
53
+ report: join(dir, `${runId}.md`),
54
+ proposal: join(dir, `${runId}.json`),
55
+ library: join(dir, `${runId}-library.md`),
56
+ index: join(dir, "retros.jsonl")
57
+ }
58
+ }
59
+
60
+ const RetroIndexEntry = Schema.Struct({ runId: Schema.String, at: Schema.Number })
61
+ const encodeIndexEntry = Schema.encodeSync(Schema.fromJsonString(RetroIndexEntry))
62
+ const decodeIndexEntry = Schema.decodeUnknownOption(Schema.fromJsonString(RetroIndexEntry))
63
+
64
+ export const appendRetroIndex = (
65
+ files: PlainFileStoreShape,
66
+ stateDir: string,
67
+ runId: string,
68
+ at: number
69
+ ): Effect.Effect<void, PersistenceError> =>
70
+ files.append(retroPaths(stateDir, runId).index, `${encodeIndexEntry({ runId, at })}\n`)
71
+
72
+ /** The retros written for this state folder, oldest first. */
73
+ export const readRetroIndex = (
74
+ files: PlainFileStoreShape,
75
+ stateDir: string
76
+ ): Effect.Effect<ReadonlyArray<{ readonly runId: string; readonly at: number }>> =>
77
+ files.read(retroPaths(stateDir, "").index).pipe(
78
+ Effect.catch(() => Effect.succeed(undefined)),
79
+ Effect.map((contents) =>
80
+ (contents ?? "")
81
+ .split("\n")
82
+ .filter((line) => line.trim().length > 0)
83
+ .flatMap((line) => Option.toArray(decodeIndexEntry(line)))
84
+ )
85
+ )
86
+
87
+ // ---- Evidence --------------------------------------------------------------------------
88
+
89
+ /** What the run left behind for one story, as files on disk. */
90
+ export interface StoryFiles {
91
+ readonly story: Story
92
+ readonly state: StoryState | undefined
93
+ readonly plan: Plan | undefined
94
+ readonly findings: string | undefined
95
+ readonly verdict: ReviewResult | undefined
96
+ }
97
+
98
+ export interface RetroInputs {
99
+ readonly runId: string
100
+ readonly plan: StoryPlan
101
+ readonly trace: ReadonlyArray<TraceLine>
102
+ /** Per story lane; `undefined` when the run kept no transcript at all. */
103
+ readonly transcripts: ReadonlyMap<string, ReadonlyArray<TranscriptEntry>> | undefined
104
+ readonly report: EpicReport | undefined
105
+ readonly board: Board | undefined
106
+ readonly stories: ReadonlyArray<StoryFiles>
107
+ /** Characters the whole digest may hold (`LLM4TS_RETRO_CHARS`, default 60 000). */
108
+ readonly budget?: number
109
+ }
110
+
111
+ export const defaultRetroChars = 60_000
112
+ const perStoryShare = 12_000
113
+ const findingsTail = 3_000
114
+ const transcriptHead = 600
115
+ const runLineCap = 40
116
+
117
+ const Verdict = Schema.Union([StoryVerdict, ReviewResult])
118
+ const VerdictEntry = Schema.fromJsonString(
119
+ Schema.Struct({ fingerprint: Schema.String, result: Verdict })
120
+ )
121
+
122
+ /** Reads the per-story files the executor keeps under `<state>/stories/`. */
123
+ export const loadStoryFiles = Effect.fn("@llm4ts/flow/Retro.loadStoryFiles")(function* (
124
+ files: PlainFileStoreShape,
125
+ stateDir: string,
126
+ story: Story
127
+ ): Effect.fn.Return<StoryFiles, FlowError> {
128
+ const base = join(stateDir, "stories", story.id)
129
+ const quiet = <A>(effect: Effect.Effect<A, unknown>): Effect.Effect<A | undefined> =>
130
+ effect.pipe(Effect.catch(() => Effect.succeed(undefined)))
131
+ const state = yield* quiet(loadVersioned(files, `${base}.json`, StoryStateVersion, StoryState))
132
+ const planText = yield* quiet(files.read(`${base}.plan.md`))
133
+ const plan = planText === undefined ? undefined : yield* quiet(parsePlan(planText))
134
+ const findings = yield* quiet(files.read(`${base}.findings.md`))
135
+ const judgeText = yield* quiet(files.read(`${base}.judge.json`))
136
+ const verdict =
137
+ judgeText === undefined
138
+ ? undefined
139
+ : Option.getOrUndefined(Schema.decodeUnknownOption(VerdictEntry)(judgeText))?.result
140
+ return { story, state, plan, findings, verdict }
141
+ })
142
+
143
+ /** The epic's report and board, when the run wrote them. */
144
+ export const loadEpicFiles = Effect.fn("@llm4ts/flow/Retro.loadEpicFiles")(function* (
145
+ files: PlainFileStoreShape,
146
+ stateDir: string
147
+ ): Effect.fn.Return<{
148
+ readonly report: EpicReport | undefined
149
+ readonly board: Board | undefined
150
+ }> {
151
+ const quiet = <A>(effect: Effect.Effect<A, unknown>): Effect.Effect<A | undefined> =>
152
+ effect.pipe(Effect.catch(() => Effect.succeed(undefined)))
153
+ const report = yield* quiet(
154
+ loadVersioned(files, join(stateDir, "report.json"), EpicReportVersion, EpicReport)
155
+ )
156
+ const board = yield* quiet(
157
+ loadVersioned(files, join(stateDir, "board.json"), BoardVersion, Board)
158
+ )
159
+ return { report, board }
160
+ })
161
+
162
+ const decodeEvent = Schema.decodeUnknownOption(Schema.fromJsonString(FlowEvent))
163
+
164
+ interface TraceView {
165
+ readonly outcome: string | undefined
166
+ readonly events: ReadonlyArray<FlowEvent>
167
+ }
168
+
169
+ const traceView = (lines: ReadonlyArray<TraceLine>): TraceView => {
170
+ let outcome: string | undefined
171
+ const events: Array<FlowEvent> = []
172
+ for (const line of lines) {
173
+ if (line.kind === "RunEnded") {
174
+ const value = line.fields.outcome
175
+ outcome = typeof value === "string" ? value : "completed"
176
+ continue
177
+ }
178
+ const raw = line.fields.event
179
+ if (typeof raw === "string") {
180
+ const decoded = decodeEvent(raw)
181
+ if (decoded._tag === "Some") {
182
+ events.push(decoded.value)
183
+ }
184
+ }
185
+ }
186
+ return { outcome, events }
187
+ }
188
+
189
+ const laneOf = (event: FlowEvent): string | undefined =>
190
+ "lane" in event && typeof event.lane === "string" ? event.lane : undefined
191
+
192
+ const retryLine = /^⟳ (.+?) — retry (\d+)\/(\d+): (.*)$/u
193
+
194
+ const retryKind = (what: string): string =>
195
+ what.startsWith("serving engine down")
196
+ ? "outage"
197
+ : what.startsWith("flaky")
198
+ ? "flaky stream"
199
+ : what.startsWith("transient")
200
+ ? "transient"
201
+ : what.startsWith("structured output")
202
+ ? "structured repair"
203
+ : what
204
+
205
+ const countBy = (items: ReadonlyArray<string>): string =>
206
+ [
207
+ ...items.reduce(
208
+ (map, item) => map.set(item, (map.get(item) ?? 0) + 1),
209
+ new Map<string, number>()
210
+ )
211
+ ]
212
+ .sort((left, right) => right[1] - left[1])
213
+ .map(([key, count]) => `${key} ×${count}`)
214
+ .join(", ")
215
+
216
+ const head = (text: string, limit = transcriptHead): string => {
217
+ const flat = text.replace(/\s+/g, " ").trim()
218
+ return flat.length <= limit ? flat : `${flat.slice(0, limit)}…`
219
+ }
220
+
221
+ /** The stories worth a retro: failed, waiting, or still active when the run ended. */
222
+ export const retroCandidates = (inputs: RetroInputs): ReadonlyArray<StoryFiles> => {
223
+ const fromReport = new Set(
224
+ (inputs.report?.stories ?? [])
225
+ .filter((outcome) => outcome.status !== "done")
226
+ .map((outcome) => outcome.id)
227
+ )
228
+ const fromBoard = new Set(
229
+ (inputs.board?.items ?? [])
230
+ .filter(
231
+ (item) => item.status === "active" || item.status === "failed" || item.status === "waiting"
232
+ )
233
+ .map((item) => item.id)
234
+ )
235
+ const noRecord = inputs.report === undefined && inputs.board === undefined
236
+ return inputs.stories.filter(
237
+ (entry) =>
238
+ entry.state?.status !== "merged" &&
239
+ (fromReport.has(entry.story.id) ||
240
+ fromBoard.has(entry.story.id) ||
241
+ (noRecord && entry.state !== undefined))
242
+ )
243
+ }
244
+
245
+ const storySection = (
246
+ inputs: RetroInputs,
247
+ trace: TraceView,
248
+ entry: StoryFiles,
249
+ share: number
250
+ ): string => {
251
+ const { story } = entry
252
+ const cuts: Array<string> = []
253
+ const lines: Array<string> = [`## Story ${story.id} — ${story.title}`]
254
+ const outcome = inputs.report?.stories.find((candidate) => candidate.id === story.id)
255
+ const item = inputs.board?.items.find((candidate) => candidate.id === story.id)
256
+ if (outcome !== undefined) {
257
+ lines.push(
258
+ `- outcome: ${outcome.status}${outcome.reason === undefined ? "" : ` — ${outcome.reason}`}`
259
+ )
260
+ }
261
+ if (item !== undefined && (outcome === undefined || item.detail !== outcome.reason)) {
262
+ lines.push(`- board: ${item.status}${item.detail === undefined ? "" : ` — ${item.detail}`}`)
263
+ }
264
+ if (entry.state !== undefined) {
265
+ lines.push(
266
+ `- state: ${entry.state.status} on ${entry.state.branch}${entry.state.executor === undefined ? "" : `, coder ${entry.state.executor}`}`
267
+ )
268
+ }
269
+ if (outcome?.judge !== undefined) {
270
+ lines.push(`- judge: ${outcome.judge}`)
271
+ }
272
+ lines.push(
273
+ `- owned: ${story.owned.join(", ")}; provides: ${story.provides.join(", ") || "(none)"}`
274
+ )
275
+
276
+ if (entry.plan === undefined) {
277
+ lines.push("", "### Task plan", "- none yet (the story never planned its tasks)")
278
+ } else {
279
+ lines.push(
280
+ "",
281
+ "### Task plan",
282
+ ...entry.plan.tasks.map((task) => `- [${task.completed ? "x" : " "}] ${task.title}`)
283
+ )
284
+ }
285
+
286
+ if (entry.findings !== undefined) {
287
+ const tail = entry.findings.trimEnd()
288
+ const shown = tail.length > findingsTail ? tail.slice(tail.length - findingsTail) : tail
289
+ if (shown.length < tail.length) {
290
+ cuts.push(`findings ${tail.length - shown.length} chars`)
291
+ }
292
+ lines.push("", "### Findings (tail)", shown)
293
+ }
294
+ if (entry.verdict !== undefined) {
295
+ const dims =
296
+ entry.verdict instanceof StoryVerdict
297
+ ? ` (${entry.verdict.dimensions.map((d) => `${d.id} ${d.score}/${d.max}`).join(", ")})`
298
+ : ""
299
+ lines.push(
300
+ "",
301
+ `### Last judge verdict${dims}`,
302
+ ...(entry.verdict.issues.length === 0
303
+ ? ["- cleared"]
304
+ : entry.verdict.issues.map(
305
+ (issue) => `- [${issue.severity}] ${issue.title}: ${issue.description}`
306
+ ))
307
+ )
308
+ }
309
+
310
+ const laneEvents = trace.events.filter((event) => laneOf(event) === story.id)
311
+ const traceLines: Array<string> = []
312
+ const retries: Array<string> = []
313
+ for (const event of laneEvents) {
314
+ switch (event._tag) {
315
+ case "StageFailed":
316
+ traceLines.push(`- stage failed (${event.stage}): ${event.message}`)
317
+ break
318
+ case "StoryJudged":
319
+ traceLines.push(
320
+ `- judge round ${event.round}: ${event.cleared ? "cleared" : `not cleared, ${event.issues} issue(s)`}${
321
+ event.dimensions.length === 0
322
+ ? ""
323
+ : ` (${event.dimensions.map((d) => `${d.id} ${d.score}/${d.max}`).join(", ")})`
324
+ }`
325
+ )
326
+ break
327
+ case "ReviewFindings":
328
+ traceLines.push(
329
+ `- review round ${event.round}${event.settled ? " (settled)" : ""}: ${event.issues.length} issue(s)${
330
+ event.issues.length === 0
331
+ ? ""
332
+ : ` — ${event.issues.map((issue) => issue.title).join("; ")}`
333
+ }`
334
+ )
335
+ break
336
+ case "Timed":
337
+ if (event.kind === "gate" && event.failed === true) {
338
+ traceLines.push(`- gate failed: ${event.label} (exit ${event.exitCode ?? "?"})`)
339
+ }
340
+ break
341
+ case "Info": {
342
+ const match = retryLine.exec(event.message)
343
+ if (match?.[1] !== undefined) {
344
+ retries.push(retryKind(match[1]))
345
+ }
346
+ break
347
+ }
348
+ default:
349
+ break
350
+ }
351
+ }
352
+ if (retries.length > 0) {
353
+ traceLines.push(`- retries: ${countBy(retries)}`)
354
+ }
355
+ if (traceLines.length > 0) {
356
+ lines.push("", "### Trace", ...traceLines)
357
+ }
358
+
359
+ if (inputs.transcripts !== undefined) {
360
+ const entries = inputs.transcripts.get(story.id) ?? []
361
+ if (entries.length === 0) {
362
+ lines.push("", "### Transcript", "- no entries for this story")
363
+ } else {
364
+ const calls = entries.filter((e) => e._tag === "Call")
365
+ const tools = entries.flatMap((e) => (e._tag === "Tool" ? [e.tool] : []))
366
+ const failures = entries.filter((e) => e._tag === "ToolResult" && e.failed === true)
367
+ const lastCall = calls.at(-1)
368
+ const lastReply = [...entries].reverse().find((e) => e._tag === "Reply")
369
+ const lastEnd = [...entries].reverse().find((e) => e._tag === "End")
370
+ const transcriptLines = [
371
+ `- calls: ${calls.length} (${countBy(calls.map((c) => (c._tag === "Call" ? c.role : "")))})`,
372
+ `- tool calls: ${tools.length}${tools.length === 0 ? "" : ` (${countBy(tools)})`}`,
373
+ `- failed tool results: ${failures.length}`
374
+ ]
375
+ const firstFailure = failures[0]
376
+ if (firstFailure !== undefined && firstFailure._tag === "ToolResult") {
377
+ transcriptLines.push(`- first failed tool result: ${head(firstFailure.output)}`)
378
+ }
379
+ if (lastCall !== undefined && lastCall._tag === "Call") {
380
+ transcriptLines.push(`- last call: ${lastCall.role} — ${head(lastCall.input)}`)
381
+ }
382
+ if (lastReply !== undefined && lastReply._tag === "Reply") {
383
+ transcriptLines.push(`- last reply: ${head(lastReply.text)}`)
384
+ }
385
+ if (lastEnd !== undefined && lastEnd._tag === "End") {
386
+ transcriptLines.push(
387
+ `- last call ended ${lastEnd.failed === true ? "failed" : "ok"} after ${Math.round(lastEnd.ms / 1000)}s`
388
+ )
389
+ }
390
+ lines.push("", "### Transcript", ...transcriptLines)
391
+ }
392
+ }
393
+
394
+ const text = lines.join("\n")
395
+ const capped = cap(text, share)
396
+ if (capped.truncated) {
397
+ cuts.push(`section ${text.length - capped.text.length} chars`)
398
+ }
399
+ return cuts.length === 0 ? capped.text : `${capped.text}\n\n(cut: ${cuts.join(", ")})`
400
+ }
401
+
402
+ const runLinePrefixes = [
403
+ "gates:",
404
+ "node:",
405
+ "app dir:",
406
+ "epic ",
407
+ "roster",
408
+ "seats",
409
+ "coder:",
410
+ "reasoning:",
411
+ "judge",
412
+ "executor",
413
+ "story plan:",
414
+ "refine round",
415
+ "retro:"
416
+ ]
417
+
418
+ /** The digest a retro seat is given: code-built, deterministic, capped. */
419
+ export const renderRetroDigest = (inputs: RetroInputs): string => {
420
+ const trace = traceView(inputs.trace)
421
+ const candidates = retroCandidates(inputs)
422
+ const budget = Math.max(4_000, inputs.budget ?? defaultRetroChars)
423
+ const runLines: Array<string> = []
424
+ const retries: Array<string> = []
425
+ for (const event of trace.events) {
426
+ if (laneOf(event) !== undefined) {
427
+ continue
428
+ }
429
+ switch (event._tag) {
430
+ case "Info": {
431
+ const match = retryLine.exec(event.message)
432
+ if (match?.[1] !== undefined) {
433
+ retries.push(retryKind(match[1]))
434
+ } else if (runLinePrefixes.some((prefix) => event.message.startsWith(prefix))) {
435
+ runLines.push(`- ${event.message}`)
436
+ }
437
+ break
438
+ }
439
+ case "Aborted":
440
+ runLines.push(`- aborted: ${event.message}`)
441
+ break
442
+ case "CapabilityDenied":
443
+ runLines.push(`- capability denied: ${event.capability} (${event.operation})`)
444
+ break
445
+ case "CapabilityUnenforceable":
446
+ runLines.push(`- capability unenforceable: ${event.detail}`)
447
+ break
448
+ case "ExecutorExcluded":
449
+ runLines.push(`- executor excluded: ${event.executor} — ${event.reason}`)
450
+ break
451
+ case "StageFailed":
452
+ runLines.push(`- stage failed (${event.stage}): ${event.message}`)
453
+ break
454
+ default:
455
+ break
456
+ }
457
+ }
458
+ const header = [
459
+ `# Retro digest — run ${inputs.runId}, epic ${inputs.plan.epicId}`,
460
+ "",
461
+ `- run outcome: ${trace.outcome ?? "unknown (the trace has no end line: the process died)"}`,
462
+ `- stories: ${inputs.plan.stories.length} planned, ${candidates.length} to look at (${candidates.map((c) => c.story.id).join(", ") || "none"})`,
463
+ `- transcripts: ${inputs.transcripts === undefined ? "none (the run was started without --transcript)" : "present"}`,
464
+ ...(retries.length === 0 ? [] : [`- retries across the run: ${countBy(retries)}`]),
465
+ ...(runLines.length === 0
466
+ ? []
467
+ : [
468
+ "",
469
+ "## Run",
470
+ ...runLines.slice(0, runLineCap),
471
+ ...(runLines.length > runLineCap
472
+ ? [`- … ${runLines.length - runLineCap} more lines`]
473
+ : [])
474
+ ])
475
+ ].join("\n")
476
+ const remaining = budget - header.length
477
+ const share = Math.min(
478
+ perStoryShare,
479
+ Math.max(1_500, Math.floor(remaining / Math.max(1, candidates.length)))
480
+ )
481
+ const sections = candidates.map((entry) => storySection(inputs, trace, entry, share))
482
+ return [header, ...sections].join("\n\n")
483
+ }
484
+
485
+ // ---- Proposal ------------------------------------------------------------------------------
486
+
487
+ export const RetroFixKind = Schema.Literals(["tasks", "story", "refine", "none"])
488
+ export type RetroFixKind = typeof RetroFixKind.Type
489
+
490
+ export const RetroTask = Schema.Struct({
491
+ title: Schema.String,
492
+ description: Schema.String
493
+ })
494
+ export type RetroTask = typeof RetroTask.Type
495
+
496
+ export const RetroStoryChanges = Schema.Struct({
497
+ description: Schema.optionalKey(Schema.String),
498
+ owned: Schema.optionalKey(Schema.Array(Schema.String)),
499
+ sharedReadOnly: Schema.optionalKey(Schema.Array(Schema.String)),
500
+ dependsOn: Schema.optionalKey(Schema.Array(Schema.String)),
501
+ provides: Schema.optionalKey(Schema.Array(Schema.String))
502
+ })
503
+ export type RetroStoryChanges = typeof RetroStoryChanges.Type
504
+
505
+ /** One story's diagnosis and exactly one fix, discriminated by `kind`. */
506
+ export const RetroStory = Schema.Struct({
507
+ id: Schema.String,
508
+ diagnosis: Schema.String,
509
+ kind: RetroFixKind,
510
+ /** `tasks`: appended to the story's plan. */
511
+ tasks: Schema.optionalKey(Schema.Array(RetroTask)),
512
+ /** `story`: the fields to change on the plan entry. */
513
+ changes: Schema.optionalKey(RetroStoryChanges),
514
+ /** `story`: why the entry itself is wrong. `none`: why nothing changes. */
515
+ why: Schema.optionalKey(Schema.String),
516
+ /** `refine`: the feedback a new round should take. */
517
+ feedback: Schema.optionalKey(Schema.String)
518
+ })
519
+ export type RetroStory = typeof RetroStory.Type
520
+
521
+ export class RetroProposal extends Schema.Class<RetroProposal>("RetroProposal")({
522
+ summary: Schema.String,
523
+ stories: Schema.Array(RetroStory),
524
+ runAdvice: Schema.Array(Schema.Struct({ finding: Schema.String, evidence: Schema.String })),
525
+ libraryAdvice: Schema.Array(
526
+ Schema.Struct({ title: Schema.String, evidence: Schema.String, suggestion: Schema.String })
527
+ )
528
+ }) {}
529
+
530
+ export const retroProposalJsonSchema: JsonSchema = {
531
+ type: "object",
532
+ properties: {
533
+ summary: { type: "string" },
534
+ stories: {
535
+ type: "array",
536
+ items: {
537
+ type: "object",
538
+ properties: {
539
+ id: { type: "string" },
540
+ diagnosis: { type: "string" },
541
+ kind: { type: "string", enum: ["tasks", "story", "refine", "none"] },
542
+ tasks: {
543
+ type: "array",
544
+ items: {
545
+ type: "object",
546
+ properties: { title: { type: "string" }, description: { type: "string" } },
547
+ required: ["title", "description"]
548
+ }
549
+ },
550
+ changes: {
551
+ type: "object",
552
+ properties: {
553
+ description: { type: "string" },
554
+ owned: { type: "array", items: { type: "string" } },
555
+ sharedReadOnly: { type: "array", items: { type: "string" } },
556
+ dependsOn: { type: "array", items: { type: "string" } },
557
+ provides: { type: "array", items: { type: "string" } }
558
+ }
559
+ },
560
+ why: { type: "string" },
561
+ feedback: { type: "string" }
562
+ },
563
+ required: ["id", "diagnosis", "kind"]
564
+ }
565
+ },
566
+ runAdvice: {
567
+ type: "array",
568
+ items: {
569
+ type: "object",
570
+ properties: { finding: { type: "string" }, evidence: { type: "string" } },
571
+ required: ["finding", "evidence"]
572
+ }
573
+ },
574
+ libraryAdvice: {
575
+ type: "array",
576
+ items: {
577
+ type: "object",
578
+ properties: {
579
+ title: { type: "string" },
580
+ evidence: { type: "string" },
581
+ suggestion: { type: "string" }
582
+ },
583
+ required: ["title", "evidence", "suggestion"]
584
+ }
585
+ }
586
+ },
587
+ required: ["summary", "stories", "runAdvice", "libraryAdvice"]
588
+ }
589
+
590
+ /** The seat's instructions: the digest is the whole subject. */
591
+ export const retroPrompt = (digest: string, storiesWithPlan: ReadonlyArray<string>): string =>
592
+ [
593
+ "You are the retrospective of a failed parallel implementation run. Below is a digest of",
594
+ "what the run left behind: per story, its outcome, task plan, reviewer findings, judge",
595
+ "verdict, trace events and transcript summary. The digest is the whole subject: do not",
596
+ "explore the repository, read other files, or run anything; answer from the digest.",
597
+ "",
598
+ "For every story in the digest, write a diagnosis that cites digest lines, then choose",
599
+ "exactly one fix by `kind`:",
600
+ "- `tasks`: new tasks to append to the story's own plan, each with a title and a description",
601
+ " that carries what the coder needs to know from the diagnosis. Only for stories that already",
602
+ ` have a task plan: ${storiesWithPlan.length === 0 ? "none here" : storiesWithPlan.join(", ")}.`,
603
+ "- `story`: the story's entry in the epic plan is wrong (description, owned paths, shared",
604
+ " read-only paths, dependencies, provides). Give only the fields that change, and `why`.",
605
+ " This restarts the story from a fresh worktree, discarding its work: choose it only when",
606
+ " the entry itself caused the failure.",
607
+ "- `refine`: the story needs a new planning round (a missing story, a split, a scope change",
608
+ " no edit covers). Give the `feedback` text a planner should take.",
609
+ "- `none`: nothing to change (an outage, a dependency that will unblock it). Give `why`.",
610
+ "",
611
+ "`runAdvice`: findings about the environment or configuration (a seat on an old CLI, Node",
612
+ "mismatch, gates missing, transcripts off), each with its evidence line.",
613
+ "`libraryAdvice`: only where the digest shows llm4ts itself — the flow, a seat's harness",
614
+ "mapping, a prompt — misbehaving rather than the epic: title, evidence, suggestion.",
615
+ "Be concrete and short. Return ONLY JSON matching the schema.",
616
+ "",
617
+ digest
618
+ ].join("\n")
619
+
620
+ // ---- Validation ----------------------------------------------------------------------------
621
+
622
+ export interface ValidatedRetro {
623
+ readonly proposal: RetroProposal
624
+ /** What was dropped and why, for the report. */
625
+ readonly dropped: ReadonlyArray<string>
626
+ }
627
+
628
+ const badPath = (path: string): boolean =>
629
+ path.trim().length === 0 ||
630
+ path.startsWith("/") ||
631
+ /^[A-Za-z]:[\\/]/.test(path) ||
632
+ path.split(/[\\/]/).includes("..")
633
+
634
+ const sameList = (left: ReadonlyArray<string>, right: ReadonlyArray<string>): boolean =>
635
+ left.length === right.length && left.every((value, index) => value === right[index])
636
+
637
+ /** Applies the story changes a proposal names; `undefined` when nothing would change. */
638
+ export const storyWithChanges = (story: Story, changes: RetroStoryChanges): Story | undefined => {
639
+ const next = Story.make({
640
+ ...story,
641
+ ...(changes.description === undefined ? {} : { description: changes.description }),
642
+ ...(changes.owned === undefined ? {} : { owned: changes.owned }),
643
+ ...(changes.sharedReadOnly === undefined ? {} : { sharedReadOnly: changes.sharedReadOnly }),
644
+ ...(changes.dependsOn === undefined ? {} : { dependsOn: changes.dependsOn }),
645
+ ...(changes.provides === undefined ? {} : { provides: changes.provides })
646
+ })
647
+ const unchanged =
648
+ next.description === story.description &&
649
+ sameList(next.owned, story.owned) &&
650
+ sameList(next.sharedReadOnly, story.sharedReadOnly) &&
651
+ sameList(next.dependsOn, story.dependsOn) &&
652
+ sameList(next.provides, story.provides)
653
+ return unchanged ? undefined : next
654
+ }
655
+
656
+ /** Code checks the seat's answer before anything is written; bad items are dropped, not fatal. */
657
+ export const validateRetroProposal = (
658
+ proposal: RetroProposal,
659
+ plan: StoryPlan,
660
+ storiesWithPlan: ReadonlySet<string>
661
+ ): ValidatedRetro => {
662
+ const dropped: Array<string> = []
663
+ const seen = new Set<string>()
664
+ const stories: Array<RetroStory> = []
665
+ for (const entry of proposal.stories) {
666
+ const story = plan.story(entry.id)
667
+ if (story === undefined) {
668
+ dropped.push(`${entry.id}: not a story of this plan`)
669
+ continue
670
+ }
671
+ if (seen.has(entry.id)) {
672
+ dropped.push(`${entry.id}: named twice; the first entry stands`)
673
+ continue
674
+ }
675
+ seen.add(entry.id)
676
+ switch (entry.kind) {
677
+ case "tasks": {
678
+ const tasks = (entry.tasks ?? []).filter(
679
+ (task) => task.title.trim().length > 0 && task.description.trim().length > 0
680
+ )
681
+ if (tasks.length === 0) {
682
+ dropped.push(`${entry.id}: a tasks fix with no task`)
683
+ continue
684
+ }
685
+ if (!storiesWithPlan.has(entry.id)) {
686
+ dropped.push(`${entry.id}: tasks need a task plan, and the story never planned one`)
687
+ continue
688
+ }
689
+ stories.push({ ...entry, tasks })
690
+ break
691
+ }
692
+ case "story": {
693
+ const changes = entry.changes
694
+ if (changes === undefined) {
695
+ dropped.push(`${entry.id}: a story fix with no changes`)
696
+ continue
697
+ }
698
+ const paths = [...(changes.owned ?? []), ...(changes.sharedReadOnly ?? [])]
699
+ const outside = paths.filter(badPath)
700
+ if (outside.length > 0) {
701
+ dropped.push(`${entry.id}: paths outside the repository (${outside.join(", ")})`)
702
+ continue
703
+ }
704
+ const unknown = (changes.dependsOn ?? []).filter(
705
+ (id) => id === entry.id || plan.story(id) === undefined
706
+ )
707
+ if (unknown.length > 0) {
708
+ dropped.push(`${entry.id}: dependencies that are not stories (${unknown.join(", ")})`)
709
+ continue
710
+ }
711
+ if (storyWithChanges(story, changes) === undefined) {
712
+ dropped.push(`${entry.id}: a story fix that changes nothing`)
713
+ continue
714
+ }
715
+ stories.push(entry)
716
+ break
717
+ }
718
+ case "refine":
719
+ if ((entry.feedback ?? "").trim().length === 0) {
720
+ dropped.push(`${entry.id}: a refine fix with no feedback`)
721
+ continue
722
+ }
723
+ stories.push(entry)
724
+ break
725
+ case "none":
726
+ stories.push(entry)
727
+ break
728
+ }
729
+ }
730
+ return { proposal: RetroProposal.make({ ...proposal, stories }), dropped }
731
+ }
732
+
733
+ // ---- Report ---------------------------------------------------------------------------------
734
+
735
+ export const AppliedMarkerPrefix = "- [x] Applied"
736
+
737
+ export interface RetroApproval {
738
+ readonly approved: boolean
739
+ readonly applied: boolean
740
+ }
741
+
742
+ export const retroApprovalOf = (markdown: string): RetroApproval => ({
743
+ approved: markdown.includes(ApprovedMarker),
744
+ applied: markdown.split("\n").some((line) => line.trim().startsWith(AppliedMarkerPrefix))
745
+ })
746
+
747
+ const fixLines = (entry: RetroStory, story: Story | undefined): ReadonlyArray<string> => {
748
+ switch (entry.kind) {
749
+ case "tasks":
750
+ return [
751
+ `**Proposed fix: ${entry.tasks?.length ?? 0} task(s) appended to the story's plan.**`,
752
+ ...(entry.tasks ?? []).map((task) => `- ${task.title}: ${task.description}`)
753
+ ]
754
+ case "story": {
755
+ const changes = entry.changes ?? {}
756
+ const field = (
757
+ name: keyof RetroStoryChanges,
758
+ before: ReadonlyArray<string> | string | undefined
759
+ ) => {
760
+ const after = changes[name]
761
+ if (after === undefined) {
762
+ return []
763
+ }
764
+ const render = (value: ReadonlyArray<string> | string | undefined): string =>
765
+ value === undefined
766
+ ? "(none)"
767
+ : typeof value === "string"
768
+ ? value
769
+ : value.join(", ") || "(none)"
770
+ return [`- ${name}: ${render(before)} → ${render(after)}`]
771
+ }
772
+ return [
773
+ `**Proposed fix: edit the story's entry (restarts it from a fresh worktree).** ${entry.why ?? ""}`.trimEnd(),
774
+ ...field("description", story?.description),
775
+ ...field("owned", story?.owned),
776
+ ...field("sharedReadOnly", story?.sharedReadOnly),
777
+ ...field("dependsOn", story?.dependsOn),
778
+ ...field("provides", story?.provides)
779
+ ]
780
+ }
781
+ case "refine":
782
+ return [
783
+ "**Proposed fix: a refine round, not applied here.** Feedback to give it:",
784
+ `> ${(entry.feedback ?? "").trim().replace(/\n/g, "\n> ")}`
785
+ ]
786
+ case "none":
787
+ return [`**No change proposed.** ${entry.why ?? ""}`.trimEnd()]
788
+ }
789
+ }
790
+
791
+ export interface RetroReportInputs {
792
+ readonly runId: string
793
+ readonly plan: StoryPlan
794
+ readonly validated: ValidatedRetro
795
+ readonly workDir: string
796
+ readonly epicDir: string
797
+ }
798
+
799
+ /** The report a person reads and ticks. */
800
+ export const renderRetroReport = (inputs: RetroReportInputs): string => {
801
+ const { proposal, dropped } = inputs.validated
802
+ const lines: Array<string> = [
803
+ `# Retro — run ${inputs.runId}, epic ${inputs.plan.epicId}`,
804
+ "",
805
+ proposal.summary.trim(),
806
+ ""
807
+ ]
808
+ if (proposal.stories.length > 0) {
809
+ lines.push("## Stories", "")
810
+ for (const entry of proposal.stories) {
811
+ const story = inputs.plan.story(entry.id)
812
+ lines.push(
813
+ `### ${entry.id}${story === undefined ? "" : ` — ${story.title}`}`,
814
+ "",
815
+ `**Diagnosis.** ${entry.diagnosis.trim()}`,
816
+ "",
817
+ ...fixLines(entry, story),
818
+ ""
819
+ )
820
+ }
821
+ }
822
+ if (proposal.runAdvice.length > 0) {
823
+ lines.push(
824
+ "## Run advice",
825
+ "",
826
+ ...proposal.runAdvice.map((item) => `- ${item.finding} — ${item.evidence}`),
827
+ ""
828
+ )
829
+ }
830
+ if (dropped.length > 0) {
831
+ lines.push("## Dropped by validation", "", ...dropped.map((item) => `- ${item}`), "")
832
+ }
833
+ const applies = proposal.stories.filter(
834
+ (entry) => entry.kind === "tasks" || entry.kind === "story"
835
+ )
836
+ const refines = proposal.stories.filter((entry) => entry.kind === "refine")
837
+ lines.push("## Approval", "")
838
+ if (applies.length === 0) {
839
+ lines.push("Nothing here is applied by the next run; the report is for reading.", "")
840
+ } else {
841
+ lines.push(
842
+ "Ticking the box lets the next `epic-stories` run of this epic apply:",
843
+ "",
844
+ ...applies.map((entry) =>
845
+ entry.kind === "tasks"
846
+ ? `- ${entry.id}: append ${entry.tasks?.length ?? 0} task(s) to its plan`
847
+ : `- ${entry.id}: edit its entry and restart it from a fresh worktree`
848
+ ),
849
+ ""
850
+ )
851
+ }
852
+ if (refines.length > 0) {
853
+ lines.push(
854
+ "A refine round is started by hand, with the feedback above:",
855
+ "",
856
+ ...refines.map(
857
+ () =>
858
+ ` llm4ts run epic-stories --repo ${inputs.workDir} -- --refine --epic ${inputs.epicDir} "<feedback>"`
859
+ ),
860
+ ""
861
+ )
862
+ }
863
+ lines.push(DraftApprovalMarker, "")
864
+ return lines.join("\n")
865
+ }
866
+
867
+ /** The note for llm4ts itself; `undefined` when the seat had nothing to say. */
868
+ export const renderLibraryAdvice = (
869
+ runId: string,
870
+ epicId: string,
871
+ proposal: RetroProposal
872
+ ): string | undefined =>
873
+ proposal.libraryAdvice.length === 0
874
+ ? undefined
875
+ : [
876
+ `# Advice for llm4ts — from run ${runId} of epic ${epicId}`,
877
+ "",
878
+ "What the retro saw the library, not the epic, do. Read before changing anything.",
879
+ "",
880
+ ...proposal.libraryAdvice.flatMap((item) => [
881
+ `## ${item.title}`,
882
+ "",
883
+ `Evidence: ${item.evidence}`,
884
+ "",
885
+ `Suggestion: ${item.suggestion}`,
886
+ ""
887
+ ])
888
+ ].join("\n")
889
+
890
+ const encodeProposal = Schema.encodeEffect(Schema.fromJsonString(RetroProposal))
891
+ const decodeProposal = Schema.decodeUnknownOption(Schema.fromJsonString(RetroProposal))
892
+
893
+ /** Writes the four files of a retro and indexes it. */
894
+ export const writeRetro = Effect.fn("@llm4ts/flow/Retro.write")(function* (
895
+ files: PlainFileStoreShape,
896
+ stateDir: string,
897
+ inputs: RetroReportInputs & { readonly digest: string; readonly at: number }
898
+ ): Effect.fn.Return<RetroPaths, FlowError> {
899
+ const paths = retroPaths(stateDir, inputs.runId)
900
+ yield* files.writeAtomic(paths.digest, inputs.digest)
901
+ const encoded = yield* encodeProposal(inputs.validated.proposal).pipe(
902
+ Effect.mapError((error) =>
903
+ PersistenceError.make({
904
+ message: `failed to encode the retro proposal: ${String(error)}`,
905
+ cause: error
906
+ })
907
+ )
908
+ )
909
+ yield* files.writeAtomic(paths.proposal, `${encoded}\n`)
910
+ yield* files.writeAtomic(paths.report, renderRetroReport(inputs))
911
+ const library = renderLibraryAdvice(inputs.runId, inputs.plan.epicId, inputs.validated.proposal)
912
+ if (library !== undefined) {
913
+ yield* files.writeAtomic(paths.library, library)
914
+ }
915
+ yield* appendRetroIndex(files, stateDir, inputs.runId, inputs.at)
916
+ return paths
917
+ })
918
+
919
+ // ---- Applying ---------------------------------------------------------------------------------
920
+
921
+ export interface AppliedRetros {
922
+ /** The plan after story edits, to run with. */
923
+ readonly plan: StoryPlan
924
+ /** One line per thing done or skipped, already published. */
925
+ readonly notes: ReadonlyArray<string>
926
+ }
927
+
928
+ const retroTaskTitle = (runId: string, title: string): string => `Retro ${runId}: ${title}`
929
+
930
+ /**
931
+ * Applies every approved, not yet applied retro of this state folder, oldest
932
+ * first: tasks go onto story plans, story edits onto the epic plan (saved,
933
+ * so the executor's hash check restarts them). Says what it did on the
934
+ * events. Nothing here fails the run: a retro that no longer fits is skipped.
935
+ */
936
+ export const applyApprovedRetros = Effect.fn("@llm4ts/flow/Retro.apply")(function* (
937
+ files: PlainFileStoreShape,
938
+ stateDir: string,
939
+ plan: StoryPlan,
940
+ events: FlowEventsShape,
941
+ now: number
942
+ ): Effect.fn.Return<AppliedRetros, FlowError> {
943
+ const notes: Array<string> = []
944
+ const say = (message: string): Effect.Effect<void> =>
945
+ Effect.sync(() => {
946
+ notes.push(message)
947
+ }).pipe(Effect.andThen(events.publish(Info.make({ message: `retro: ${message}` }))))
948
+ let current = plan
949
+ const planStore = makePlanStore(files)
950
+ for (const { runId } of yield* readRetroIndex(files, stateDir)) {
951
+ const paths = retroPaths(stateDir, runId)
952
+ const report = yield* files
953
+ .read(paths.report)
954
+ .pipe(Effect.catch(() => Effect.succeed(undefined)))
955
+ if (report === undefined) {
956
+ continue
957
+ }
958
+ const approval = retroApprovalOf(report)
959
+ if (!approval.approved || approval.applied) {
960
+ continue
961
+ }
962
+ const proposalText = yield* files
963
+ .read(paths.proposal)
964
+ .pipe(Effect.catch(() => Effect.succeed(undefined)))
965
+ const proposal =
966
+ proposalText === undefined ? undefined : Option.getOrUndefined(decodeProposal(proposalText))
967
+ if (proposal === undefined) {
968
+ yield* say(`${runId}: approved, but ${paths.proposal} is missing or unreadable; skipped`)
969
+ continue
970
+ }
971
+ let edited = current
972
+ const editedIds: Array<string> = []
973
+ for (const entry of proposal.stories) {
974
+ const story = edited.story(entry.id)
975
+ if (story === undefined) {
976
+ yield* say(`${runId}: ${entry.id} is no longer in the plan; skipped`)
977
+ continue
978
+ }
979
+ switch (entry.kind) {
980
+ case "tasks": {
981
+ const path = join(stateDir, "stories", `${entry.id}.plan.md`)
982
+ const existing = yield* planStore
983
+ .load(path)
984
+ .pipe(Effect.catch(() => Effect.succeed(undefined)))
985
+ if (existing === undefined) {
986
+ yield* say(`${runId}: ${entry.id} has no task plan to append to; skipped`)
987
+ continue
988
+ }
989
+ const tasks = entry.tasks ?? []
990
+ const titles = new Set(existing.tasks.map((task) => task.title))
991
+ const fresh = tasks.filter((task) => !titles.has(retroTaskTitle(runId, task.title)))
992
+ if (fresh.length === 0) {
993
+ continue
994
+ }
995
+ yield* planStore.save(
996
+ path,
997
+ Plan.make({
998
+ ...existing,
999
+ tasks: [
1000
+ ...existing.tasks,
1001
+ ...fresh.map((task) =>
1002
+ Task.make({
1003
+ title: retroTaskTitle(runId, task.title),
1004
+ description: task.description
1005
+ })
1006
+ )
1007
+ ]
1008
+ })
1009
+ )
1010
+ yield* say(`${runId}: ${entry.id} gets ${fresh.length} task(s) from the retro`)
1011
+ break
1012
+ }
1013
+ case "story": {
1014
+ const next =
1015
+ entry.changes === undefined ? undefined : storyWithChanges(story, entry.changes)
1016
+ if (next === undefined) {
1017
+ continue
1018
+ }
1019
+ edited = StoryPlan.make({
1020
+ ...edited,
1021
+ stories: edited.stories.map((candidate) =>
1022
+ candidate.id === entry.id ? next : candidate
1023
+ )
1024
+ })
1025
+ editedIds.push(entry.id)
1026
+ break
1027
+ }
1028
+ case "refine":
1029
+ yield* say(
1030
+ `${runId}: ${entry.id} needs a refine round (not applied): ${(entry.feedback ?? "").trim()}`
1031
+ )
1032
+ break
1033
+ case "none":
1034
+ break
1035
+ }
1036
+ }
1037
+ if (editedIds.length > 0) {
1038
+ const valid: { readonly plan: StoryPlan; readonly problem: string | undefined } =
1039
+ yield* validateStoryPlan(edited).pipe(
1040
+ Effect.map((plan) => ({ plan, problem: undefined })),
1041
+ Effect.catch((error: StoryPlanInvalid) =>
1042
+ Effect.succeed({ plan: current, problem: error.violations.join("; ") })
1043
+ )
1044
+ )
1045
+ if (valid.problem === undefined) {
1046
+ yield* makeStoryPlanStore(files).save(join(stateDir, "plan.md"), valid.plan)
1047
+ current = valid.plan
1048
+ for (const id of editedIds) {
1049
+ yield* say(`${runId}: story ${id} edited, restarting from a fresh worktree`)
1050
+ }
1051
+ } else {
1052
+ yield* say(`${runId}: story edits would leave the plan invalid (${valid.problem}); skipped`)
1053
+ }
1054
+ }
1055
+ yield* files.writeAtomic(
1056
+ paths.report,
1057
+ `${report.trimEnd()}\n${AppliedMarkerPrefix} ${new Date(now).toISOString()}\n`
1058
+ )
1059
+ }
1060
+ return { plan: current, notes }
1061
+ })