@llm4ts/runner 2.45.0 → 2.47.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.
Files changed (49) hide show
  1. package/dist/Connectors.d.ts +1 -1
  2. package/dist/Connectors.d.ts.map +1 -1
  3. package/dist/Connectors.js +7 -2
  4. package/dist/Connectors.js.map +1 -1
  5. package/dist/Costs.d.ts +8 -4
  6. package/dist/Costs.d.ts.map +1 -1
  7. package/dist/Costs.js +32 -13
  8. package/dist/Costs.js.map +1 -1
  9. package/dist/Doctor.d.ts +24 -2
  10. package/dist/Doctor.d.ts.map +1 -1
  11. package/dist/Doctor.js +32 -12
  12. package/dist/Doctor.js.map +1 -1
  13. package/dist/FlowRunner.d.ts +38 -2
  14. package/dist/FlowRunner.d.ts.map +1 -1
  15. package/dist/FlowRunner.js +128 -13
  16. package/dist/FlowRunner.js.map +1 -1
  17. package/dist/NodeGeminiCliExecutor.js +3 -3
  18. package/dist/NodeGeminiCliExecutor.js.map +1 -1
  19. package/dist/RunReaders.d.ts +69 -0
  20. package/dist/RunReaders.d.ts.map +1 -0
  21. package/dist/RunReaders.js +283 -0
  22. package/dist/RunReaders.js.map +1 -0
  23. package/dist/RunSummary.d.ts +3 -0
  24. package/dist/RunSummary.d.ts.map +1 -0
  25. package/dist/RunSummary.js +49 -0
  26. package/dist/RunSummary.js.map +1 -0
  27. package/dist/StoryReaders.d.ts +16 -0
  28. package/dist/StoryReaders.d.ts.map +1 -0
  29. package/dist/StoryReaders.js +107 -0
  30. package/dist/StoryReaders.js.map +1 -0
  31. package/dist/Terminal.d.ts +2 -0
  32. package/dist/Terminal.d.ts.map +1 -1
  33. package/dist/Terminal.js +8 -1
  34. package/dist/Terminal.js.map +1 -1
  35. package/dist/Watch.d.ts +2 -0
  36. package/dist/Watch.d.ts.map +1 -1
  37. package/dist/Watch.js +2 -1
  38. package/dist/Watch.js.map +1 -1
  39. package/package.json +6 -3
  40. package/src/Connectors.ts +8 -2
  41. package/src/Costs.ts +44 -12
  42. package/src/Doctor.ts +62 -14
  43. package/src/FlowRunner.ts +177 -16
  44. package/src/NodeGeminiCliExecutor.ts +3 -3
  45. package/src/RunReaders.ts +419 -0
  46. package/src/RunSummary.ts +62 -0
  47. package/src/StoryReaders.ts +157 -0
  48. package/src/Terminal.ts +10 -1
  49. package/src/Watch.ts +2 -1
@@ -0,0 +1,419 @@
1
+ import { join } from "node:path"
2
+ import * as Effect from "effect/Effect"
3
+ import * as Option from "effect/Option"
4
+ import * as Schema from "effect/Schema"
5
+ import { Board, BoardVersion } from "@llm4ts/flow/BoardSync"
6
+ import { loadCostLedger } from "@llm4ts/flow/CostLedger"
7
+ import {
8
+ buildCostReport,
9
+ reconcileWithLedger,
10
+ usageSamplesFromTrace
11
+ } from "@llm4ts/flow/CostReport"
12
+ import { describeFlowError, type FlowError } from "@llm4ts/flow/FlowError"
13
+ import { FlowEvent } from "@llm4ts/flow/FlowEvents"
14
+ import { RunOutcome, type TraceLine } from "@llm4ts/flow/FlowRecorder"
15
+ import {
16
+ CostsOut,
17
+ Diagnosis,
18
+ RunStatus,
19
+ type LaneStatusOut,
20
+ type MeaningfulEvent,
21
+ type OperatorRunState
22
+ } from "@llm4ts/flow/OperatorSchemas"
23
+ import { loadVersioned, type PlainFileStoreShape } from "@llm4ts/flow/Persistence"
24
+ import { parsePlan } from "@llm4ts/flow/Plan"
25
+ import { readTrace } from "@llm4ts/flow/Replay"
26
+ import { emptyTree, reduceTree, treeInputsOfTrace, type TreeState } from "./AgentTree.ts"
27
+ import { summarizeTree } from "./RunSummary.ts"
28
+ import { loadTranscript, tailLinesOf, type TranscriptFiles } from "./Transcripts.ts"
29
+
30
+ /**
31
+ * Read-only answers about a run, from the files it writes: how it stands,
32
+ * what happened since a sequence number, why it failed, what it cost. Every
33
+ * reader takes the file store as a parameter, so a test feeds it a memory
34
+ * store and the MCP operator feeds it the real one.
35
+ */
36
+
37
+ export class TraceUnreadable extends Schema.TaggedError<TraceUnreadable>()("TraceUnreadable", {
38
+ path: Schema.String,
39
+ message: Schema.String
40
+ }) {}
41
+
42
+ export interface RunSources {
43
+ readonly files: PlainFileStoreShape
44
+ /**
45
+ * Relative paths of every regular file under a directory, recursively (e.g.
46
+ * "task-2/1-pnpm-test.log"), never directory names; none when it does not exist.
47
+ */
48
+ readonly listFiles: (directory: string) => ReadonlyArray<string>
49
+ }
50
+
51
+ export interface TraceFold {
52
+ readonly lines: ReadonlyArray<TraceLine>
53
+ readonly state: TreeState
54
+ readonly seq: number
55
+ }
56
+
57
+ const decodeEvent = Schema.decodeUnknownOption(Schema.fromJsonString(FlowEvent))
58
+ const isOutcome = Schema.is(RunOutcome)
59
+
60
+ const eventOf = (line: TraceLine): FlowEvent | undefined => {
61
+ const raw = line.fields.event
62
+ return typeof raw === "string" ? Option.getOrUndefined(decodeEvent(raw)) : undefined
63
+ }
64
+
65
+ const unreadable =
66
+ (path: string) =>
67
+ (error: FlowError): TraceUnreadable =>
68
+ TraceUnreadable.make({ path, message: describeFlowError(error) })
69
+
70
+ const readLines = (
71
+ files: PlainFileStoreShape,
72
+ path: string
73
+ ): Effect.Effect<ReadonlyArray<TraceLine>, TraceUnreadable> =>
74
+ readTrace(files, path).pipe(Effect.mapError(unreadable(path)))
75
+
76
+ const lastSeq = (lines: ReadonlyArray<TraceLine>): number => lines[lines.length - 1]?.seq ?? 0
77
+
78
+ /** The trace folded into the watch TUI's tree, seeded from the epic's board when there is one. */
79
+ export const foldTrace = (
80
+ files: PlainFileStoreShape,
81
+ tracePath: string,
82
+ boardDir?: string
83
+ ): Effect.Effect<TraceFold, TraceUnreadable> =>
84
+ Effect.gen(function* () {
85
+ const lines = yield* readLines(files, tracePath)
86
+ const board =
87
+ boardDir === undefined
88
+ ? undefined
89
+ : yield* loadVersioned(files, join(boardDir, "board.json"), BoardVersion, Board).pipe(
90
+ Effect.catch(() => Effect.succeed(undefined))
91
+ )
92
+ const start = emptyTree(
93
+ board === undefined
94
+ ? {}
95
+ : {
96
+ title: board.title.replace(/^Epic:\s*/u, "epic "),
97
+ stories: board.items.map((item) => ({ id: item.id, status: item.status }))
98
+ }
99
+ )
100
+ const state = treeInputsOfTrace(lines).reduce(reduceTree, start)
101
+ return { lines, state, seq: lastSeq(lines) }
102
+ })
103
+
104
+ export const runStatusOf = (
105
+ fold: TraceFold,
106
+ run: {
107
+ readonly runId: string
108
+ readonly flow: string
109
+ readonly state: OperatorRunState
110
+ },
111
+ now: number
112
+ ): RunStatus => {
113
+ const { state } = fold
114
+ const lanes = state.lanes.map((lane): LaneStatusOut => {
115
+ const stage = lane.stages[lane.stages.length - 1]
116
+ return {
117
+ id: lane.id,
118
+ status: lane.status,
119
+ ...(stage === undefined ? {} : { stage }),
120
+ ...(lane.executor === undefined ? {} : { executor: lane.executor }),
121
+ ...(lane.activity === undefined ? {} : { activity: lane.activity.text }),
122
+ lastEventAgeMs: Math.max(0, Math.round(now - lane.lastEventAt)),
123
+ costUsd: lane.costUsd
124
+ }
125
+ })
126
+ const unenforceable = fold.lines.flatMap((line) => {
127
+ const event = eventOf(line)
128
+ return event?._tag === "CapabilityUnenforceable" ? [event.detail] : []
129
+ })
130
+ const end = state.ended === undefined ? now : (state.now ?? now)
131
+ return RunStatus.make({
132
+ runId: run.runId,
133
+ state: run.state,
134
+ ...(state.ended !== undefined && isOutcome(state.ended) ? { ended: state.ended } : {}),
135
+ elapsedMs: state.startedAt === undefined ? 0 : Math.max(0, Math.round(end - state.startedAt)),
136
+ lanes,
137
+ stories: state.stories.map((story) => ({ id: story.id, status: story.status })),
138
+ tokens: Math.round(state.tokens),
139
+ costUsd: state.costUsd,
140
+ warnings: [
141
+ ...unenforceable,
142
+ ...state.exclusions.map((entry) => `${entry.executor} excluded: ${entry.reason}`)
143
+ ],
144
+ seq: fold.seq,
145
+ summary: summarizeTree(state, run.flow)
146
+ })
147
+ }
148
+
149
+ export const meaningfulKinds: ReadonlySet<string> = new Set([
150
+ "StoryStatusChanged",
151
+ "StageFailed",
152
+ "Aborted",
153
+ "CapabilityUnenforceable",
154
+ "ExecutorExcluded",
155
+ "ExecutorHandedOver",
156
+ "StoryJudged",
157
+ "Began",
158
+ "RunEnded",
159
+ "CriterionChecked",
160
+ "Parked"
161
+ ])
162
+
163
+ const textOf = (
164
+ event: FlowEvent
165
+ ): { readonly text: string; readonly lane?: string } | undefined => {
166
+ switch (event._tag) {
167
+ case "StoryStatusChanged":
168
+ return { text: `story ${event.id} ${event.status}`, lane: event.id }
169
+ case "CriterionChecked":
170
+ return { text: `criterion ${event.tag} ${event.status}`, lane: event.story }
171
+ case "Parked":
172
+ return { text: `parked ${event.item}: ${event.reason}`, lane: event.story }
173
+ case "StageFailed":
174
+ return {
175
+ text: `${event.stage} failed: ${event.message}`,
176
+ ...(event.lane === undefined ? {} : { lane: event.lane })
177
+ }
178
+ case "Aborted":
179
+ return { text: `aborted: ${event.message}` }
180
+ case "CapabilityUnenforceable":
181
+ return { text: `capability unenforceable: ${event.detail}` }
182
+ case "ExecutorExcluded":
183
+ return { text: `${event.executor} excluded: ${event.reason}` }
184
+ case "ExecutorHandedOver":
185
+ return {
186
+ text: `${event.from} handed over ${event.role} (${event.scope}): ${event.reason}`,
187
+ ...(event.label === undefined ? {} : { lane: event.label })
188
+ }
189
+ case "StoryJudged":
190
+ return {
191
+ text: `${event.lane} judged round ${event.round}: ${event.cleared ? "cleared" : `${event.issues} issues`}`,
192
+ lane: event.lane
193
+ }
194
+ case "Began":
195
+ return {
196
+ text: `began ${event.kind} ${event.label}`,
197
+ ...(event.lane === undefined ? {} : { lane: event.lane })
198
+ }
199
+ default:
200
+ return undefined
201
+ }
202
+ }
203
+
204
+ export const meaningfulEventsAfter = (
205
+ lines: ReadonlyArray<TraceLine>,
206
+ sinceSeq: number
207
+ ): ReadonlyArray<MeaningfulEvent> =>
208
+ lines.flatMap((line): ReadonlyArray<MeaningfulEvent> => {
209
+ if (line.seq <= sinceSeq || !meaningfulKinds.has(line.kind)) {
210
+ return []
211
+ }
212
+ if (line.kind === "RunEnded") {
213
+ const outcome = line.fields.outcome
214
+ return [
215
+ {
216
+ seq: line.seq,
217
+ at: line.timestamp,
218
+ kind: line.kind,
219
+ text: `run ${typeof outcome === "string" ? outcome : "completed"}`
220
+ }
221
+ ]
222
+ }
223
+ const event = eventOf(line)
224
+ const rendered = event === undefined ? undefined : textOf(event)
225
+ return rendered === undefined
226
+ ? []
227
+ : [
228
+ {
229
+ seq: line.seq,
230
+ at: line.timestamp,
231
+ kind: line.kind,
232
+ ...(rendered.lane === undefined ? {} : { lane: rendered.lane }),
233
+ text: rendered.text
234
+ }
235
+ ]
236
+ })
237
+
238
+ export const eventsAfter = (
239
+ lines: ReadonlyArray<TraceLine>,
240
+ sinceSeq: number,
241
+ kinds: ReadonlyArray<string> | undefined,
242
+ limit: number
243
+ ): ReadonlyArray<TraceLine> =>
244
+ lines
245
+ .filter(
246
+ (line) =>
247
+ line.seq > sinceSeq && (kinds === undefined || kinds.some((kind) => kind === line.kind))
248
+ )
249
+ .slice(0, Math.max(0, limit))
250
+
251
+ const digestTail = 40
252
+ const tailRows = 30
253
+ const tailWidth = 120
254
+
255
+ const quiet = <A, E>(effect: Effect.Effect<A, E>): Effect.Effect<A | undefined> =>
256
+ effect.pipe(Effect.catch(() => Effect.succeed(undefined)))
257
+
258
+ const capText = (text: string, remaining: number): string => text.slice(0, Math.max(0, remaining))
259
+
260
+ /** The last rows of each log in a directory, within what is left of a character budget. */
261
+ export const tailGateLogs = (
262
+ sources: RunSources,
263
+ directory: string | undefined,
264
+ budget: number
265
+ ): Effect.Effect<{
266
+ readonly tails: ReadonlyArray<{ readonly path: string; readonly tail: string }>
267
+ readonly remaining: number
268
+ }> =>
269
+ Effect.gen(function* () {
270
+ const tails: Array<{ path: string; tail: string }> = []
271
+ let remaining = budget
272
+ if (directory !== undefined) {
273
+ for (const name of sources.listFiles(directory)) {
274
+ const path = join(directory, name)
275
+ const text = yield* quiet(sources.files.read(path))
276
+ if (text === undefined || remaining <= 0) continue
277
+ const tail = capText(text.trimEnd().split(/\r?\n/u).slice(-tailRows).join("\n"), remaining)
278
+ remaining -= tail.length
279
+ tails.push({ path, tail })
280
+ }
281
+ }
282
+ return { tails, remaining }
283
+ })
284
+
285
+ /** The last rows of one lane's transcript, capped to what is left of a character budget. */
286
+ export const tailTranscript = (
287
+ sources: RunSources,
288
+ directory: string | undefined,
289
+ lane: string,
290
+ budget: number
291
+ ): Effect.Effect<ReadonlyArray<string>> =>
292
+ Effect.gen(function* () {
293
+ const out: Array<string> = []
294
+ if (directory === undefined) return out
295
+ let remaining = budget
296
+ const transcriptFiles: TranscriptFiles = {
297
+ read: (path) => quiet(sources.files.read(path)),
298
+ list: sources.listFiles
299
+ }
300
+ const entries = yield* loadTranscript(transcriptFiles, directory, { lane })
301
+ const rows = tailLinesOf(entries, { lane }, undefined, tailWidth, tailRows) ?? []
302
+ for (const row of rows) {
303
+ if (remaining <= 0) break
304
+ const kept = capText(row, remaining)
305
+ remaining -= kept.length
306
+ out.push(kept)
307
+ }
308
+ return out
309
+ })
310
+
311
+ export const runDiagnosisOf = (
312
+ sources: RunSources,
313
+ args: {
314
+ readonly runId: string
315
+ readonly tracePath: string
316
+ readonly planPath: string | undefined
317
+ readonly transcriptsDir: string | undefined
318
+ readonly gateLogDir: string | undefined
319
+ readonly budget: number
320
+ }
321
+ ): Effect.Effect<Diagnosis, TraceUnreadable> =>
322
+ Effect.gen(function* () {
323
+ const lines = yield* readLines(sources.files, args.tracePath)
324
+ const events = lines.flatMap((line) => {
325
+ const event = eventOf(line)
326
+ return event === undefined ? [] : [event]
327
+ })
328
+ const failed = events.filter(
329
+ (event) => event._tag === "StageFailed" || event._tag === "Aborted"
330
+ )
331
+ const last = failed[failed.length - 1]
332
+ const failure =
333
+ last === undefined
334
+ ? undefined
335
+ : last._tag === "StageFailed"
336
+ ? {
337
+ stage: last.stage,
338
+ message: last.message,
339
+ ...(last.lane === undefined ? {} : { lane: last.lane })
340
+ }
341
+ : last._tag === "Aborted"
342
+ ? { message: last.message }
343
+ : undefined
344
+
345
+ const markdown =
346
+ args.planPath === undefined ? undefined : yield* quiet(sources.files.read(args.planPath))
347
+ const plan = markdown === undefined ? undefined : yield* quiet(parsePlan(markdown))
348
+ const taskLines = (plan?.tasks ?? []).map(
349
+ (task) => `- [${task.completed ? "x" : " "}] ${task.title}`
350
+ )
351
+ const recent = events
352
+ .flatMap((event) =>
353
+ event._tag === "Info"
354
+ ? [event.message]
355
+ : event._tag === "StageFailed"
356
+ ? [`${event.stage} failed: ${event.message}`]
357
+ : []
358
+ )
359
+ .slice(-digestTail)
360
+ const digest = capText([...taskLines, ...recent].join("\n"), args.budget)
361
+ const remaining = args.budget - digest.length
362
+
363
+ const gateTails = yield* tailGateLogs(sources, args.gateLogDir, remaining)
364
+ const transcript = yield* tailTranscript(
365
+ sources,
366
+ args.transcriptsDir,
367
+ "run",
368
+ gateTails.remaining
369
+ )
370
+
371
+ return Diagnosis.make({
372
+ runId: args.runId,
373
+ digest,
374
+ ...(failure === undefined ? {} : { failure }),
375
+ gateLogTails: gateTails.tails,
376
+ transcriptTail: transcript
377
+ })
378
+ })
379
+
380
+ const ledgerTolerance = 0.005
381
+
382
+ const costOf = (report: {
383
+ readonly measured: { readonly costUsd?: number }
384
+ readonly estimated: { readonly costUsd?: number }
385
+ }): number => (report.measured.costUsd ?? 0) + (report.estimated.costUsd ?? 0)
386
+
387
+ /**
388
+ * One run's costs: its trace folded and topped up from its `costs.jsonl`
389
+ * line (`CostReport.reconcileWithLedger`), so `total` is what the run itself
390
+ * printed even when the trace lost lines; `fromLedger` is how much of it the
391
+ * ledger supplied. `differs` is left for a trace that says more than the
392
+ * ledger, which no line can fill.
393
+ */
394
+ export const costsOfRun = (
395
+ files: PlainFileStoreShape,
396
+ tracePath: string,
397
+ ledgerPath: string,
398
+ runId: string
399
+ ): Effect.Effect<CostsOut, TraceUnreadable> =>
400
+ Effect.gen(function* () {
401
+ const lines = yield* readLines(files, tracePath)
402
+ const traced = usageSamplesFromTrace(lines)
403
+ const records = yield* loadCostLedger(files, ledgerPath).pipe(
404
+ Effect.mapError(unreadable(ledgerPath))
405
+ )
406
+ const record = records.find((candidate) => candidate.runId === runId)
407
+ const reconciled = record === undefined ? traced : reconcileWithLedger(traced, [record]).samples
408
+ const report = buildCostReport(reconciled)
409
+ const total = costOf(report)
410
+ const fromLedger = total - costOf(buildCostReport(traced))
411
+ const ledger = record?.totalCostUsd
412
+ return CostsOut.make({
413
+ report,
414
+ total,
415
+ ...(ledger === undefined ? {} : { ledger }),
416
+ ...(fromLedger > ledgerTolerance ? { fromLedger } : {}),
417
+ differs: ledger !== undefined && Math.abs(total - ledger) > ledgerTolerance
418
+ })
419
+ })
@@ -0,0 +1,62 @@
1
+ import type { TreeLane, TreeState } from "./AgentTree.ts"
2
+
3
+ /**
4
+ * One sentence on how a run is going, for a chat agent that asked. Pure over
5
+ * the watch TUI's folded state, so a live run and a replay read the same.
6
+ */
7
+
8
+ const listed = 3
9
+
10
+ const elapsedOf = (state: TreeState): string => {
11
+ const minutes =
12
+ state.startedAt === undefined || state.now === undefined
13
+ ? 0
14
+ : Math.max(0, Math.floor((state.now - state.startedAt) / 60_000))
15
+ return minutes < 60 ? `${minutes} min` : `${Math.floor(minutes / 60)} h ${minutes % 60} min`
16
+ }
17
+
18
+ /** At most three entries; the rest collapse into `+<n>`. */
19
+ const capped = (items: ReadonlyArray<string>, separator: string): string =>
20
+ items.length > listed
21
+ ? [...items.slice(0, listed), `+${items.length - listed}`].join(separator)
22
+ : items.join(separator)
23
+
24
+ const doing = (lane: TreeLane | undefined): string =>
25
+ lane?.stages[lane.stages.length - 1] ?? lane?.activity?.text ?? "starting"
26
+
27
+ const lastLogOf = (state: TreeState, id: string): string => {
28
+ for (let index = state.log.length - 1; index >= 0; index--) {
29
+ const entry = state.log[index]
30
+ if (entry !== undefined && entry.who === id) return entry.what
31
+ }
32
+ return "failed"
33
+ }
34
+
35
+ const parenthesized = (items: ReadonlyArray<string>, separator: string): string =>
36
+ items.length === 0 ? "" : ` (${capped(items, separator)})`
37
+
38
+ export const summarizeTree = (state: TreeState, flow: string | undefined): string => {
39
+ const ended = state.ended === undefined ? "" : `${state.ended}, `
40
+ const head = `${ended}${flow ?? state.title}, ${elapsedOf(state)}`
41
+ const cost = `$${state.costUsd.toFixed(2)}`
42
+ const board = state.stories.length > 0
43
+ if (!board && state.lanes.length === 0) return `${head}: no stories yet, ${cost}`
44
+ const ids = (status: "done" | "active" | "failed"): ReadonlyArray<string> =>
45
+ board
46
+ ? state.stories.filter((story) => story.status === status).map((story) => story.id)
47
+ : state.lanes
48
+ .filter((lane) => lane.status === (status === "active" ? "running" : status))
49
+ .map((lane) => lane.id)
50
+ const active = ids("active")
51
+ const failed = ids("failed")
52
+ const activeItems = active.map((id) => {
53
+ const lane = state.lanes.find((open) => open.id === id)
54
+ return `${id}, ${lane?.executor ?? "?"}, ${doing(lane)}`
55
+ })
56
+ const failedItems = failed.map((id) => `${id}: ${lastLogOf(state, id)}`)
57
+ return (
58
+ `${head}: ${ids("done").length} stories done, ` +
59
+ `${active.length} active${parenthesized(activeItems, "; ")}, ` +
60
+ `${failed.length} failed${parenthesized(failedItems, "; ")}, ${cost}`
61
+ )
62
+ }
@@ -0,0 +1,157 @@
1
+ import { join } from "node:path"
2
+ import * as Effect from "effect/Effect"
3
+ import * as Option from "effect/Option"
4
+ import * as Schema from "effect/Schema"
5
+ import { describeFlowError, type FlowError } from "@llm4ts/flow/FlowError"
6
+ import { Diagnosis, StoryDetail, UnknownStory } from "@llm4ts/flow/OperatorSchemas"
7
+ import {
8
+ loadEpicFiles,
9
+ loadStoryFiles,
10
+ renderStoryDigest,
11
+ type RetroInputs
12
+ } from "@llm4ts/flow/Retro"
13
+ import { readTrace } from "@llm4ts/flow/Replay"
14
+ import { makeStoryPlanStore, type Story, type StoryPlan } from "@llm4ts/flow/StoryPlan"
15
+ import { TraceUnreadable, tailGateLogs, tailTranscript, type RunSources } from "./RunReaders.ts"
16
+ import { loadTranscript, type TranscriptFiles } from "./Transcripts.ts"
17
+
18
+ /**
19
+ * Read-only answers about one story of an epic run, from the files the story
20
+ * executor keeps under `<stateDir>/stories/<id>.*` and `<stateDir>/board.json`.
21
+ */
22
+
23
+ const safeId = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u
24
+
25
+ /** A story id is a file-name stem: no separators, no dots-only, nothing that climbs. */
26
+ export const isSafeStoryId = (id: string): boolean => safeId.test(id)
27
+
28
+ const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Json))
29
+ const encodeJson = (value: unknown): string | undefined => JSON.stringify(value)
30
+
31
+ const planPathOf = (stateDir: string): string => join(stateDir, "plan.md")
32
+
33
+ const unknownStory = (storyId: string): UnknownStory =>
34
+ UnknownStory.make({ message: `unknown story '${storyId}'` })
35
+
36
+ /** The plan's story, or `UnknownStory` for an unsafe id, a missing plan or an id the plan lacks. */
37
+ const storyOf = (
38
+ sources: RunSources,
39
+ stateDir: string,
40
+ storyId: string
41
+ ): Effect.Effect<{ readonly plan: StoryPlan; readonly story: Story }, FlowError | UnknownStory> =>
42
+ Effect.gen(function* () {
43
+ if (!isSafeStoryId(storyId)) {
44
+ return yield* unknownStory(storyId)
45
+ }
46
+ const plan = yield* makeStoryPlanStore(sources.files).load(planPathOf(stateDir))
47
+ const story = plan?.story(storyId)
48
+ if (plan === undefined || story === undefined) {
49
+ return yield* unknownStory(storyId)
50
+ }
51
+ return { plan, story }
52
+ })
53
+
54
+ export const storyDetailOf = (
55
+ sources: RunSources,
56
+ stateDir: string,
57
+ storyId: string
58
+ ): Effect.Effect<StoryDetail, FlowError | UnknownStory> =>
59
+ Effect.gen(function* () {
60
+ const { story } = yield* storyOf(sources, stateDir, storyId)
61
+ const stored = yield* loadStoryFiles(sources.files, stateDir, story)
62
+ const epic = yield* loadEpicFiles(sources.files, stateDir)
63
+ const item = epic.board?.items.find((candidate) => candidate.id === storyId)
64
+ const verdictText = stored.verdict === undefined ? undefined : encodeJson(stored.verdict)
65
+ const verdict =
66
+ verdictText === undefined ? undefined : Option.getOrUndefined(decodeJson(verdictText))
67
+ const gateDir = join(stateDir, "stories", storyId, "gates")
68
+ return StoryDetail.make({
69
+ id: story.id,
70
+ title: story.title,
71
+ ...(stored.state === undefined
72
+ ? {}
73
+ : {
74
+ state: {
75
+ status: stored.state.status,
76
+ branch: stored.state.branch,
77
+ worktree: stored.state.worktree,
78
+ ...(stored.state.executor === undefined ? {} : { executor: stored.state.executor })
79
+ }
80
+ }),
81
+ ...(item === undefined
82
+ ? {}
83
+ : {
84
+ board: {
85
+ status: item.status,
86
+ ...(item.detail === undefined ? {} : { detail: item.detail })
87
+ }
88
+ }),
89
+ tasks: (stored.plan?.tasks ?? []).map((task) => ({
90
+ title: task.title,
91
+ completed: task.completed
92
+ })),
93
+ ...(stored.findings === undefined ? {} : { findings: stored.findings }),
94
+ ...(verdict === undefined ? {} : { verdict }),
95
+ gateLogs: sources.listFiles(gateDir).map((name) => join(gateDir, name))
96
+ })
97
+ })
98
+
99
+ export const storyDiagnosisOf = (
100
+ sources: RunSources,
101
+ args: {
102
+ readonly stateDir: string
103
+ readonly storyId: string
104
+ readonly runId: string
105
+ readonly tracePath: string
106
+ readonly transcriptsDir: string | undefined
107
+ readonly budget: number
108
+ }
109
+ ): Effect.Effect<Diagnosis, FlowError | UnknownStory | TraceUnreadable> =>
110
+ Effect.gen(function* () {
111
+ const { plan, story } = yield* storyOf(sources, args.stateDir, args.storyId)
112
+ const trace = yield* readTrace(sources.files, args.tracePath).pipe(
113
+ Effect.mapError((error) =>
114
+ TraceUnreadable.make({ path: args.tracePath, message: describeFlowError(error) })
115
+ )
116
+ )
117
+ const stored = yield* loadStoryFiles(sources.files, args.stateDir, story)
118
+ const epic = yield* loadEpicFiles(sources.files, args.stateDir)
119
+ const transcriptFiles: TranscriptFiles = {
120
+ read: (path) => Effect.catch(sources.files.read(path), () => Effect.succeed(undefined)),
121
+ list: sources.listFiles
122
+ }
123
+ const entries =
124
+ args.transcriptsDir === undefined
125
+ ? undefined
126
+ : yield* loadTranscript(transcriptFiles, args.transcriptsDir, { lane: story.id })
127
+ const inputs: RetroInputs = {
128
+ runId: args.runId,
129
+ plan,
130
+ trace,
131
+ transcripts: entries === undefined ? undefined : new Map([[story.id, entries]]),
132
+ report: epic.report,
133
+ board: epic.board,
134
+ stories: [stored]
135
+ }
136
+ const digest = renderStoryDigest(inputs, story.id, args.budget) ?? ""
137
+ const item = epic.board?.items.find((candidate) => candidate.id === story.id)
138
+ const gates = yield* tailGateLogs(
139
+ sources,
140
+ join(args.stateDir, "stories", story.id, "gates"),
141
+ args.budget - digest.length
142
+ )
143
+ const transcriptTail = yield* tailTranscript(
144
+ sources,
145
+ args.transcriptsDir,
146
+ story.id,
147
+ gates.remaining
148
+ )
149
+ return Diagnosis.make({
150
+ runId: args.runId,
151
+ story: story.id,
152
+ digest,
153
+ ...(item?.detail === undefined ? {} : { failure: { message: item.detail, lane: story.id } }),
154
+ gateLogTails: gates.tails,
155
+ transcriptTail
156
+ })
157
+ })
package/src/Terminal.ts CHANGED
@@ -273,6 +273,12 @@ export const terminalLine = (
273
273
  return palette.fail(`aborted: ${safe(event.message)}`)
274
274
  case "Info":
275
275
  return palette.info(safe(event.message))
276
+ case "CriterionChecked":
277
+ return palette.info(`criterion ${safe(event.tag)}: ${event.status}`)
278
+ case "Parked":
279
+ return palette.info(
280
+ `parked${event.mvp ? " (MVP)" : ""}: ${safe(event.story)} — ${safe(event.item)} (${safe(event.reason)})`
281
+ )
276
282
  case "ToolUse":
277
283
  return palette.toolCall(safe(event.tool), safe(event.args))
278
284
  case "AssistantMessage":
@@ -460,6 +466,9 @@ export const makeLiveTerminalSurface = Effect.fn("@llm4ts/runner/Terminal.makeLi
460
466
  const hideCursor = "\u001b[?25l"
461
467
  const showCursor = "\u001b[?25h"
462
468
 
469
+ /** Re-raise only when no other listener remains to handle the signal. */
470
+ export const shouldReraise = (otherListeners: number): boolean => otherListeners === 0
471
+
463
472
  /**
464
473
  * While the live status block is on screen, keystrokes must not be echoed:
465
474
  * an arrow key (or a trackpad scroll the terminal turns into one) echoes as
@@ -490,7 +499,7 @@ const quietTerminalInput = (
490
499
  // then let the signal do what it would have done.
491
500
  const onSignal = (signal: NodeJS.Signals): void => {
492
501
  restore()
493
- process.kill(process.pid, signal)
502
+ if (shouldReraise(process.listenerCount(signal))) process.kill(process.pid, signal)
494
503
  }
495
504
  yield* Effect.sync(() => {
496
505
  stty("-echo")
package/src/Watch.ts CHANGED
@@ -362,7 +362,8 @@ export const makeWatchProgram = Effect.fn("@llm4ts/runner/Watch.make")(function*
362
362
 
363
363
  // ── node wiring ─────────────────────────────────────────────────────────────
364
364
 
365
- const nodeListEpics = (directory: string): ReadonlyArray<string> =>
365
+ /** Folder names under an `epics/` directory; none when it is absent. */
366
+ export const nodeListEpics = (directory: string): ReadonlyArray<string> =>
366
367
  existsSync(directory)
367
368
  ? readdirSync(directory).filter((name) => statSync(join(directory, name)).isDirectory())
368
369
  : []