@llm4ts/flow 2.28.1 → 2.30.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 (57) hide show
  1. package/dist/CarriedNotes.d.ts +25 -0
  2. package/dist/CarriedNotes.d.ts.map +1 -0
  3. package/dist/CarriedNotes.js +76 -0
  4. package/dist/CarriedNotes.js.map +1 -0
  5. package/dist/Flow.d.ts +8 -0
  6. package/dist/Flow.d.ts.map +1 -1
  7. package/dist/Flow.js +13 -1
  8. package/dist/Flow.js.map +1 -1
  9. package/dist/Orientation.d.ts +25 -0
  10. package/dist/Orientation.d.ts.map +1 -0
  11. package/dist/Orientation.js +150 -0
  12. package/dist/Orientation.js.map +1 -0
  13. package/dist/PlanExecution.d.ts +7 -1
  14. package/dist/PlanExecution.d.ts.map +1 -1
  15. package/dist/PlanExecution.js +8 -2
  16. package/dist/PlanExecution.js.map +1 -1
  17. package/dist/RefineRound.d.ts.map +1 -1
  18. package/dist/RefineRound.js +13 -1
  19. package/dist/RefineRound.js.map +1 -1
  20. package/dist/Retro.d.ts.map +1 -1
  21. package/dist/Retro.js +16 -3
  22. package/dist/Retro.js.map +1 -1
  23. package/dist/Review.d.ts.map +1 -1
  24. package/dist/Review.js +5 -3
  25. package/dist/Review.js.map +1 -1
  26. package/dist/Spans.d.ts +63 -0
  27. package/dist/Spans.d.ts.map +1 -0
  28. package/dist/Spans.js +104 -0
  29. package/dist/Spans.js.map +1 -0
  30. package/dist/Stories.d.ts +5 -4
  31. package/dist/Stories.d.ts.map +1 -1
  32. package/dist/Stories.js +107 -34
  33. package/dist/Stories.js.map +1 -1
  34. package/dist/StoryPlan.d.ts +33 -1
  35. package/dist/StoryPlan.d.ts.map +1 -1
  36. package/dist/StoryPlan.js +62 -4
  37. package/dist/StoryPlan.js.map +1 -1
  38. package/dist/Timing.d.ts +21 -1
  39. package/dist/Timing.d.ts.map +1 -1
  40. package/dist/Timing.js +218 -18
  41. package/dist/Timing.js.map +1 -1
  42. package/dist/Transcript.d.ts.map +1 -1
  43. package/dist/Transcript.js +3 -2
  44. package/dist/Transcript.js.map +1 -1
  45. package/package.json +5 -2
  46. package/src/CarriedNotes.ts +103 -0
  47. package/src/Flow.ts +31 -1
  48. package/src/Orientation.ts +192 -0
  49. package/src/PlanExecution.ts +21 -10
  50. package/src/RefineRound.ts +13 -1
  51. package/src/Retro.ts +26 -3
  52. package/src/Review.ts +18 -12
  53. package/src/Spans.ts +150 -0
  54. package/src/Stories.ts +140 -45
  55. package/src/StoryPlan.ts +78 -2
  56. package/src/Timing.ts +343 -16
  57. package/src/Transcript.ts +3 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llm4ts/flow",
3
- "version": "2.28.1",
3
+ "version": "2.30.0",
4
4
  "description": "Effect-native LLM workflow, persistence, review, and repository automation",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -24,6 +24,7 @@
24
24
  "./Blackboard": "./dist/Blackboard.js",
25
25
  "./BoardSync": "./dist/BoardSync.js",
26
26
  "./CapabilityGuard": "./dist/CapabilityGuard.js",
27
+ "./CarriedNotes": "./dist/CarriedNotes.js",
27
28
  "./Chat": "./dist/Chat.js",
28
29
  "./Classified": "./dist/Classified.js",
29
30
  "./Context": "./dist/Context.js",
@@ -57,6 +58,7 @@
57
58
  "./JudgmentLog": "./dist/JudgmentLog.js",
58
59
  "./Perimeter": "./dist/Perimeter.js",
59
60
  "./NodePreflight": "./dist/NodePreflight.js",
61
+ "./Orientation": "./dist/Orientation.js",
60
62
  "./Persistence": "./dist/Persistence.js",
61
63
  "./Plan": "./dist/Plan.js",
62
64
  "./PlanExecution": "./dist/PlanExecution.js",
@@ -72,6 +74,7 @@
72
74
  "./ReviewCache": "./dist/ReviewCache.js",
73
75
  "./Reviewer": "./dist/Reviewer.js",
74
76
  "./SpecChecks": "./dist/SpecChecks.js",
77
+ "./Spans": "./dist/Spans.js",
75
78
  "./Roster": "./dist/Roster.js",
76
79
  "./RosterSeats": "./dist/RosterSeats.js",
77
80
  "./Stories": "./dist/Stories.js",
@@ -110,7 +113,7 @@
110
113
  "typescript"
111
114
  ],
112
115
  "dependencies": {
113
- "@llm4ts/core": "2.28.1"
116
+ "@llm4ts/core": "2.30.0"
114
117
  },
115
118
  "peerDependencies": {
116
119
  "effect": "4.0.0"
@@ -0,0 +1,103 @@
1
+ // What a story's earlier tasks learned, carried into the next task's prompt.
2
+ // With one chat per task (ADR 0003) every task starts cold; without this a
3
+ // six-task story explores the repository six times. The coder ends each task
4
+ // with a short Findings section; code saves it beside the story and prepends
5
+ // the accumulated notes to the next task. Pure helpers here, the seam in Flow.
6
+ import type * as Effect from "effect/Effect"
7
+ import type { FlowError } from "./FlowError.ts"
8
+
9
+ export const notesHeading = "## Findings"
10
+
11
+ /** Appended to every task prompt that carries notes. */
12
+ export const findingsRequest = [
13
+ "",
14
+ `When the task is done, end your reply with a section headed \`${notesHeading}\` of at most`,
15
+ "ten lines: files you read or changed that matter for the rest of this story, conventions",
16
+ "you learned (where tests live, how a feature is registered, which kit component to use),",
17
+ "and commands that worked. Nothing follows it — unless you must stop with BLOCKED_ON:,",
18
+ "which stays the last line."
19
+ ].join("\n")
20
+
21
+ const sectionChars = 1_500
22
+ const headingPattern = /^#{1,3}[ \t]*findings[ \t]*:?[ \t]*$/gimu
23
+ const blockedLine = /^BLOCKED_ON:.*$/u
24
+
25
+ /**
26
+ * The text after the LAST Findings heading, without a trailing BLOCKED_ON
27
+ * line (that stays the reply's last line for `blockedOnIn`), capped;
28
+ * undefined when there is no heading or nothing under it.
29
+ */
30
+ export const findingsIn = (reply: string): string | undefined => {
31
+ let last: RegExpExecArray | undefined
32
+ headingPattern.lastIndex = 0
33
+ for (let match = headingPattern.exec(reply); match !== null; match = headingPattern.exec(reply)) {
34
+ last = match
35
+ }
36
+ if (last === undefined) {
37
+ return undefined
38
+ }
39
+ const lines = reply
40
+ .slice(last.index + last[0].length)
41
+ .split(/\r?\n/u)
42
+ .map((line) => line.trimEnd())
43
+ while (lines.length > 0 && (lines.at(-1) ?? "").trim().length === 0) {
44
+ lines.pop()
45
+ }
46
+ if (
47
+ lines.length > 0 &&
48
+ blockedLine.test((lines.at(-1) ?? "").trim().replace(/^[`*_\s]+|[`*_\s]+$/gu, ""))
49
+ ) {
50
+ lines.pop()
51
+ }
52
+ const text = lines.join("\n").trim()
53
+ return text.length === 0 ? undefined : text.slice(0, sectionChars)
54
+ }
55
+
56
+ export const notesLimit = 6_000
57
+
58
+ /**
59
+ * `notes` plus a titled section for `found`; when the result is over `limit`
60
+ * the oldest sections go first, the newest always stays.
61
+ */
62
+ export const appendNote = (
63
+ notes: string | undefined,
64
+ title: string,
65
+ found: string,
66
+ limit = notesLimit
67
+ ): string => {
68
+ // A finding's own headings are flattened: `### ` is this file's section
69
+ // marker, and eviction must never split a section in two.
70
+ const flat = found
71
+ .trim()
72
+ .split(/\r?\n/u)
73
+ .map((line) => line.replace(/^#{1,6}[ \t]*/u, ""))
74
+ .join("\n")
75
+ const sections = [
76
+ ...(notes === undefined || notes.trim().length === 0 ? [] : notes.split(/\n\n(?=### )/u)),
77
+ `### ${title}\n${flat}`
78
+ ]
79
+ while (sections.length > 1 && sections.join("\n\n").length > limit) {
80
+ sections.shift()
81
+ }
82
+ return sections.join("\n\n")
83
+ }
84
+
85
+ /** The prompt with the notes in front, or the prompt itself when there are none. */
86
+ export const withNotes = (prompt: string, notes: string | undefined): string =>
87
+ notes === undefined || notes.trim().length === 0
88
+ ? prompt
89
+ : [
90
+ "What earlier tasks of this story learned — trust it before exploring:",
91
+ "",
92
+ notes.trim(),
93
+ "",
94
+ "---",
95
+ "",
96
+ prompt
97
+ ].join("\n")
98
+
99
+ /** Where a story keeps its carried notes: read before a task, written after it. */
100
+ export interface CarriedNotes {
101
+ readonly read: Effect.Effect<string | undefined, FlowError>
102
+ readonly write: (notes: string) => Effect.Effect<void, FlowError>
103
+ }
package/src/Flow.ts CHANGED
@@ -2,6 +2,13 @@ import * as Effect from "effect/Effect"
2
2
  import type { LlmServiceShape } from "@llm4ts/core/LlmService"
3
3
  import { collect } from "@llm4ts/core/Streaming"
4
4
  import { withToolActivity } from "./Activity.ts"
5
+ import {
6
+ appendNote,
7
+ findingsIn,
8
+ findingsRequest,
9
+ withNotes,
10
+ type CarriedNotes
11
+ } from "./CarriedNotes.ts"
5
12
  import { makeChat, type Chat } from "./Chat.ts"
6
13
  import type { FlowContextShape } from "./FlowContext.ts"
7
14
  import { FlowAborted, FlowLlmError, type FlowError } from "./FlowError.ts"
@@ -97,6 +104,13 @@ export interface ImplementPlanOptions {
97
104
  * or failure. The legacy "judgment" string remains an alias for { mode: "act" }.
98
105
  */
99
106
  readonly satisfiedProbe?: "literal" | "judgment" | { readonly mode?: JudgmentMode }
107
+ /**
108
+ * Notes carried from one task to the next (meant for `chatPerTask`, where
109
+ * every task starts cold): the accumulated notes open each task's prompt,
110
+ * the task is asked to end with a Findings section, and what it reports is
111
+ * appended. Omit to carry nothing.
112
+ */
113
+ readonly carry?: CarriedNotes
100
114
  }
101
115
 
102
116
  /** The judgment form of the empty-diff probe: one Truth question over the coder's reply. */
@@ -193,7 +207,22 @@ export const implementPlanFlow = Effect.fn("@llm4ts/flow/Flow.implementPlan")(fu
193
207
  // the top of this function: a task prompt only needs that task's own
194
208
  // details. `planSoFar`, threaded through by implementTaskLoop, is the
195
209
  // single source of truth for completion progress instead.
196
- yield* coder.ask(plan.taskPrompt(task))
210
+ const notes = options.carry === undefined ? undefined : yield* options.carry.read
211
+ /** What the task's reply learned goes to the carried notes, whichever turn did the work. */
212
+ const keepFindings = (reply: string): Effect.Effect<void, FlowError> =>
213
+ Effect.gen(function* () {
214
+ const found = options.carry === undefined ? undefined : findingsIn(reply)
215
+ if (options.carry !== undefined && found !== undefined) {
216
+ yield* options.carry.write(appendNote(notes, task.title, found))
217
+ }
218
+ })
219
+ yield* keepFindings(
220
+ yield* coder.ask(
221
+ options.carry === undefined
222
+ ? plan.taskPrompt(task)
223
+ : `${withNotes(plan.taskPrompt(task), notes)}\n${findingsRequest}`
224
+ )
225
+ )
197
226
  const produced = yield* context.git.diffAll
198
227
  if (produced.trim().length === 0) {
199
228
  // An empty diff is ambiguous: the task may be genuinely satisfied
@@ -208,6 +237,7 @@ export const implementPlanFlow = Effect.fn("@llm4ts/flow/Flow.implementPlan")(fu
208
237
  "Otherwise, implement the task now."
209
238
  ].join("\n")
210
239
  )
240
+ yield* keepFindings(confirmation)
211
241
  const afterConfirmation = yield* context.git.diffAll
212
242
  if (afterConfirmation.trim().length === 0) {
213
243
  const judged =
@@ -0,0 +1,192 @@
1
+ // The repository as a planner and a coder should see it before they look:
2
+ // a deterministic digest of the tracked files — folders with their counts,
3
+ // small folders in full, the package scripts, where the tests live. No model
4
+ // call, computed once per epic, so every story stops rediscovering the
5
+ // layout with `ls`, `find` and `grep` round trips.
6
+ import { cap } from "./Context.ts"
7
+
8
+ export interface OrientationInput {
9
+ /** Tracked files, repo-relative, as `git ls-files` prints them. */
10
+ readonly files: ReadonlyArray<string>
11
+ /** The application's `package.json` text, when there is one. */
12
+ readonly packageJson?: string
13
+ /** Where the application lives: `.` for the repository root, else a subfolder. */
14
+ readonly appDir?: string
15
+ /** Characters the digest may take; `<= 0` leaves it out. */
16
+ readonly budget: number
17
+ }
18
+
19
+ export const defaultOrientationChars = 8_000
20
+
21
+ /** Folders never worth a coder's attention, wherever they sit: dependencies and state. */
22
+ const noiseAnywhere = new Set(["node_modules", ".git", ".llm4ts"])
23
+ /** Build output is noise at the top level only: `src/features/build/` is code. */
24
+ const noiseAtRoot = new Set(["dist", "build", "out", "coverage"])
25
+ const noiseFiles = new Set(["pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lockb"])
26
+
27
+ /** Folders deeper than this are summarised by their count. */
28
+ const maxDepth = 3
29
+ /** A folder with this many files or fewer is listed in full. */
30
+ const smallFolder = 12
31
+ const testFile = /\.(test|spec)\.[cm]?[jt]sx?$/u
32
+
33
+ interface Folder {
34
+ readonly path: string
35
+ files: number
36
+ readonly children: Map<string, Folder>
37
+ readonly own: Array<string>
38
+ }
39
+
40
+ const folderOf = (path: string): Folder => ({ path, files: 0, children: new Map(), own: [] })
41
+
42
+ const isNoise = (file: string): boolean => {
43
+ const parts = file.split("/")
44
+ return (
45
+ parts.some((part) => noiseAnywhere.has(part)) ||
46
+ (parts.length > 1 && noiseAtRoot.has(parts[0] ?? "")) ||
47
+ noiseFiles.has(parts.at(-1) ?? "")
48
+ )
49
+ }
50
+
51
+ const treeOf = (files: ReadonlyArray<string>): Folder => {
52
+ const root = folderOf("")
53
+ for (const file of files) {
54
+ const parts = file.split("/").filter((part) => part.length > 0)
55
+ let folder = root
56
+ folder.files += 1
57
+ for (const part of parts.slice(0, -1)) {
58
+ let child = folder.children.get(part)
59
+ if (child === undefined) {
60
+ child = folderOf(folder.path.length === 0 ? part : `${folder.path}/${part}`)
61
+ folder.children.set(part, child)
62
+ }
63
+ child.files += 1
64
+ folder = child
65
+ }
66
+ folder.own.push(file)
67
+ }
68
+ return root
69
+ }
70
+
71
+ const sortedChildren = (folder: Folder): ReadonlyArray<Folder> =>
72
+ [...folder.children.values()].sort((left, right) => left.path.localeCompare(right.path))
73
+
74
+ const folderLine = (folder: Folder, depth: number): string =>
75
+ `${" ".repeat(depth)}${folder.path}/ (${folder.files} ${folder.files === 1 ? "file" : "files"})`
76
+
77
+ /** A small folder in full: its own files, then every subfolder the same way. */
78
+ const expanded = (folder: Folder, depth: number): ReadonlyArray<string> => [
79
+ ...[...folder.own].sort().map((file) => `${" ".repeat(depth)}${file}`),
80
+ ...sortedChildren(folder).flatMap((child) => [
81
+ folderLine(child, depth),
82
+ ...expanded(child, depth + 1)
83
+ ])
84
+ ]
85
+
86
+ /**
87
+ * The layout: every folder with its count; a folder of `smallFolder` files or
88
+ * fewer listed in full, a larger one descended into up to `maxDepth`.
89
+ */
90
+ const layoutLines = (folder: Folder, depth: number): ReadonlyArray<string> =>
91
+ sortedChildren(folder).flatMap((child) => {
92
+ if (child.files <= smallFolder) {
93
+ return [folderLine(child, depth), ...expanded(child, depth + 1)]
94
+ }
95
+ const own =
96
+ child.own.length <= smallFolder
97
+ ? [...child.own].sort().map((file) => `${" ".repeat(depth + 1)}${file}`)
98
+ : []
99
+ return [
100
+ folderLine(child, depth),
101
+ ...own,
102
+ ...(depth + 1 < maxDepth ? layoutLines(child, depth + 1) : [])
103
+ ]
104
+ })
105
+
106
+ const scriptsLine = (packageJson: string | undefined): string | undefined => {
107
+ if (packageJson === undefined) {
108
+ return undefined
109
+ }
110
+ let parsed: unknown
111
+ try {
112
+ parsed = JSON.parse(packageJson)
113
+ } catch {
114
+ return undefined
115
+ }
116
+ if (typeof parsed !== "object" || parsed === null || !("scripts" in parsed)) {
117
+ return undefined
118
+ }
119
+ const scripts = parsed.scripts
120
+ if (typeof scripts !== "object" || scripts === null) {
121
+ return undefined
122
+ }
123
+ const entries = Object.entries(scripts).flatMap(([name, command]) =>
124
+ typeof command === "string" ? [`${name}: ${cap(command, 80).text}`] : []
125
+ )
126
+ return entries.length === 0 ? undefined : `Scripts (package.json): ${entries.join("; ")}`
127
+ }
128
+
129
+ const testsLine = (files: ReadonlyArray<string>): string | undefined => {
130
+ const counts = new Map<string, number>()
131
+ for (const file of files) {
132
+ if (testFile.test(file) || file.includes("/__tests__/")) {
133
+ const folder = file.split("/").slice(0, -1).join("/") || "."
134
+ counts.set(folder, (counts.get(folder) ?? 0) + 1)
135
+ }
136
+ }
137
+ if (counts.size === 0) {
138
+ return undefined
139
+ }
140
+ const shown = [...counts.entries()]
141
+ .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
142
+ .slice(0, 8)
143
+ .map(([folder, count]) => `${folder} (${count})`)
144
+ const more = counts.size - shown.length
145
+ return `Tests live beside the code in: ${shown.join(", ")}${more > 0 ? ` and ${more} more folder(s)` : ""}`
146
+ }
147
+
148
+ /**
149
+ * The digest, or `undefined` when there is nothing to say (no tracked files)
150
+ * or no budget. Never longer than `budget`: the head (the layout) and the
151
+ * tail (scripts, tests) survive a cut, the middle of a long listing does not.
152
+ */
153
+ export const orientationOf = (input: OrientationInput): string | undefined => {
154
+ if (input.budget <= 0) {
155
+ return undefined
156
+ }
157
+ const files = input.files.filter((file) => !isNoise(file))
158
+ if (files.length === 0) {
159
+ return undefined
160
+ }
161
+ const root = treeOf(files)
162
+ const appDir = input.appDir ?? "."
163
+ const text = [
164
+ "## Repository orientation",
165
+ "Tracked files by folder (counts; small folders listed in full) — go straight to the right",
166
+ "place instead of listing directories or searching for where things live.",
167
+ ...(appDir === "."
168
+ ? []
169
+ : [`The application lives in ${appDir}/ — its package.json, sources and tests.`]),
170
+ "",
171
+ ...[...root.own].sort(),
172
+ ...layoutLines(root, 0),
173
+ ...[scriptsLine(input.packageJson), testsLine(files)].flatMap((line) =>
174
+ line === undefined ? [] : ["", line]
175
+ )
176
+ ].join("\n")
177
+ return cap(text, input.budget).text
178
+ }
179
+
180
+ /**
181
+ * LLM4TS_ORIENTATION_CHARS: how much of the repository digest the planner and
182
+ * every coder see (0 leaves it out); the default otherwise.
183
+ */
184
+ export const orientationChars = (
185
+ environment: Readonly<Record<string, string | undefined>>
186
+ ): { readonly orientationChars?: number } => {
187
+ const raw = environment.LLM4TS_ORIENTATION_CHARS?.trim()
188
+ const value = Number(raw ?? "")
189
+ return raw === undefined || raw.length === 0 || !Number.isInteger(value) || value < 0
190
+ ? {}
191
+ : { orientationChars: value }
192
+ }
@@ -1,25 +1,36 @@
1
1
  import * as Effect from "effect/Effect"
2
2
  import { StageCompleted, StageFailed, StageStarted, type FlowEventsShape } from "./FlowEvents.ts"
3
+ import { withKindSpan, type SpanOptions } from "./Spans.ts"
3
4
  import { describeFlowError, type FlowError } from "./FlowError.ts"
4
5
  import type { Plan, Task } from "./Plan.ts"
5
6
  import type { PlanStoreShape } from "./Persistence.ts"
6
7
 
7
8
  const errorMessage = describeFlowError
8
9
 
10
+ /**
11
+ * A named step of a flow: published as StageStarted/Completed/Failed for the
12
+ * terminal, the trace and the profile, and a span of `span.kind` (CHAIN by
13
+ * default) for OpenTelemetry (ADR 0026).
14
+ */
9
15
  export const stage = <A, E, R>(
10
16
  events: FlowEventsShape,
11
17
  name: string,
12
- effect: Effect.Effect<A, E, R>
18
+ effect: Effect.Effect<A, E, R>,
19
+ span: SpanOptions = { kind: "CHAIN" }
13
20
  ): Effect.Effect<A, E, R> =>
14
- events.publish(StageStarted.make({ stage: name })).pipe(
15
- Effect.andThen(effect),
16
- Effect.tap(() => events.publish(StageCompleted.make({ stage: name }))),
17
- Effect.tapError((error) =>
18
- events.publish(
19
- StageFailed.make({
20
- stage: name,
21
- message: errorMessage(error)
22
- })
21
+ withKindSpan(
22
+ name,
23
+ span,
24
+ events.publish(StageStarted.make({ stage: name })).pipe(
25
+ Effect.andThen(effect),
26
+ Effect.tap(() => events.publish(StageCompleted.make({ stage: name }))),
27
+ Effect.tapError((error) =>
28
+ events.publish(
29
+ StageFailed.make({
30
+ stage: name,
31
+ message: errorMessage(error)
32
+ })
33
+ )
23
34
  )
24
35
  )
25
36
  )
@@ -38,9 +38,21 @@ export const refineProposalJsonSchema: JsonSchema = {
38
38
  dependsOn: strings,
39
39
  owned: strings,
40
40
  sharedReadOnly: strings,
41
+ readFirst: strings,
42
+ acceptance: strings,
41
43
  provides: strings
42
44
  },
43
- required: ["id", "title", "description", "dependsOn", "owned", "sharedReadOnly", "provides"]
45
+ required: [
46
+ "id",
47
+ "title",
48
+ "description",
49
+ "dependsOn",
50
+ "owned",
51
+ "sharedReadOnly",
52
+ "readFirst",
53
+ "acceptance",
54
+ "provides"
55
+ ]
44
56
  }
45
57
  },
46
58
  notPlanned: {
package/src/Retro.ts CHANGED
@@ -213,6 +213,16 @@ const countBy = (items: ReadonlyArray<string>): string =>
213
213
  .map(([key, count]) => `${key} ×${count}`)
214
214
  .join(", ")
215
215
 
216
+ /**
217
+ * A transcript `--land` compacted: every call's input is empty and no reply
218
+ * is left (ADR 0025). Its shape is still worth reading; its words are gone.
219
+ */
220
+ const isCompacted = (entries: ReadonlyArray<TranscriptEntry>): boolean =>
221
+ entries.length > 0 &&
222
+ entries.every((entry) =>
223
+ entry._tag === "Call" ? entry.input.length === 0 : entry._tag !== "Reply"
224
+ )
225
+
216
226
  const head = (text: string, limit = transcriptHead): string => {
217
227
  const flat = text.replace(/\s+/g, " ").trim()
218
228
  return flat.length <= limit ? flat : `${flat.slice(0, limit)}…`
@@ -373,11 +383,17 @@ const storySection = (
373
383
  `- failed tool results: ${failures.length}`
374
384
  ]
375
385
  const firstFailure = failures[0]
376
- if (firstFailure !== undefined && firstFailure._tag === "ToolResult") {
386
+ if (
387
+ firstFailure !== undefined &&
388
+ firstFailure._tag === "ToolResult" &&
389
+ firstFailure.output.length > 0
390
+ ) {
377
391
  transcriptLines.push(`- first failed tool result: ${head(firstFailure.output)}`)
378
392
  }
379
393
  if (lastCall !== undefined && lastCall._tag === "Call") {
380
- transcriptLines.push(`- last call: ${lastCall.role} — ${head(lastCall.input)}`)
394
+ transcriptLines.push(
395
+ `- last call: ${lastCall.role} — ${isCompacted(entries) ? "(compacted)" : head(lastCall.input)}`
396
+ )
381
397
  }
382
398
  if (lastReply !== undefined && lastReply._tag === "Reply") {
383
399
  transcriptLines.push(`- last reply: ${head(lastReply.text)}`)
@@ -460,7 +476,14 @@ export const renderRetroDigest = (inputs: RetroInputs): string => {
460
476
  "",
461
477
  `- run outcome: ${trace.outcome ?? "unknown (the trace has no end line: the process died)"}`,
462
478
  `- 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"}`,
479
+ `- transcripts: ${
480
+ inputs.transcripts === undefined
481
+ ? "none (the run was started without --transcript)"
482
+ : [...inputs.transcripts.values()].some((entries) => entries.length > 0) &&
483
+ [...inputs.transcripts.values()].every(isCompacted)
484
+ ? "present, compacted (calls, tools and timings; content removed on land)"
485
+ : "present"
486
+ }`,
464
487
  ...(retries.length === 0 ? [] : [`- retries across the run: ${countBy(retries)}`]),
465
488
  ...(runLines.length === 0
466
489
  ? []
package/src/Review.ts CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  Timed,
22
22
  type FlowEventsShape
23
23
  } from "./FlowEvents.ts"
24
+ import { attr, withKindSpan } from "./Spans.ts"
24
25
  import { Reviewer } from "./Reviewer.ts"
25
26
  import { publishUsage } from "./Usage.ts"
26
27
  import type { JudgmentShape } from "@llm4ts/core/judgment/Judgment"
@@ -275,20 +276,25 @@ export const lintCommand = Effect.fn("@llm4ts/flow/Review.lintCommand")(function
275
276
  return ReviewResult.make({ issues: [], summary: "" })
276
277
  }
277
278
  const started = yield* Clock.currentTimeMillis
278
- const result = yield* guarded(
279
- Capabilities.Exec(executable),
280
- `lint: ${command.join(" ")}`,
281
- events,
282
- process.run(command, workDir, {}).pipe(
283
- Effect.mapError((cause) =>
284
- ProcessError.make({
285
- message: command.join(" "),
286
- detail: cause.message
287
- })
279
+ // A TOOL span (ADR 0026) and a Timed event: the command as configured and
280
+ // its exit code, never its output.
281
+ const result = yield* withKindSpan(
282
+ `gate ${command.join(" ")}`,
283
+ { kind: "TOOL", attributes: { [attr.gateCommand]: command.join(" ") } },
284
+ guarded(
285
+ Capabilities.Exec(executable),
286
+ `lint: ${command.join(" ")}`,
287
+ events,
288
+ process.run(command, workDir, {}).pipe(
289
+ Effect.mapError((cause) =>
290
+ ProcessError.make({
291
+ message: command.join(" "),
292
+ detail: cause.message
293
+ })
294
+ )
288
295
  )
289
- )
296
+ ).pipe(Effect.tap((ran) => Effect.annotateCurrentSpan(attr.gateExit, ran.exitCode)))
290
297
  )
291
- // The command as configured and its exit code: never its output.
292
298
  yield* events.publish(
293
299
  Timed.make({
294
300
  kind: "gate",