@llm4ts/flow 2.26.0 → 2.28.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.d.ts +185 -0
- package/dist/Retro.d.ts.map +1 -0
- package/dist/Retro.js +768 -0
- package/dist/Retro.js.map +1 -0
- package/package.json +3 -2
- package/src/Retro.ts +1061 -0
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
|
+
})
|