@llm4ts/flow 2.28.1 → 2.29.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.
@@ -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
+ }
@@ -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/Stories.ts CHANGED
@@ -314,10 +314,11 @@ const defaultContextChars = 40_000
314
314
  const fileChars = 8_000
315
315
 
316
316
  /**
317
- * The code a story starts from, for its coder's system prompt: the shared
318
- * read-only files it builds on, then the files it owns, each capped, until
319
- * `budget` runs out; the rest by path only. Reading them up front saves the
320
- * `ls`/`find`/`cat` round trips the coder would otherwise spend on them.
317
+ * The code a story starts from, for its coder's system prompt: the planner's
318
+ * read-first anchors, then the shared read-only files it builds on, then the
319
+ * files it owns, each capped, until `budget` runs out; the rest by path only.
320
+ * Reading them up front saves the `ls`/`find`/`cat` round trips the coder
321
+ * would otherwise spend on them.
321
322
  */
322
323
  export const startingCodeOf = (
323
324
  story: Story,
@@ -330,42 +331,66 @@ export const startingCodeOf = (
330
331
  if (budget <= 0) {
331
332
  return undefined
332
333
  }
334
+ const anchors = yield* git.listFiles(story.readFirst)
333
335
  const shared = yield* git.listFiles(story.sharedReadOnly)
334
336
  const owned = yield* git.listFiles(story.owned)
335
- if (shared.length + owned.length === 0) {
337
+ if (anchors.length + shared.length + owned.length === 0) {
336
338
  return undefined
337
339
  }
338
340
  let left = budget
339
341
  const leftOut: Array<string> = []
340
- const section = (title: string, paths: ReadonlyArray<string>) =>
342
+ const shown = new Set<string>()
343
+ // `limit`: how far this section may draw the budget down. The anchors
344
+ // stop at half, so a broad anchor (a whole feature folder) cannot starve
345
+ // the story's own files; the later sections may use everything left.
346
+ const section = (title: string, paths: ReadonlyArray<string>, limit = 0) =>
341
347
  Effect.gen(function* () {
342
348
  const parts: Array<string> = []
343
349
  for (const path of paths) {
350
+ if (shown.has(path)) {
351
+ continue
352
+ }
344
353
  const text = yield* Effect.orElseSucceed(
345
354
  files.read(join(worktree, path)),
346
355
  () => undefined
347
356
  )
348
- if (text === undefined || left <= 0) {
357
+ if (text === undefined || left <= limit) {
349
358
  leftOut.push(path)
350
359
  continue
351
360
  }
352
- const shown = cap(text, Math.min(fileChars, left)).text
353
- left -= shown.length
354
- parts.push(`### ${path}\n\`\`\`\n${shown}\n\`\`\``)
361
+ shown.add(path)
362
+ const piece = cap(text, Math.min(fileChars, left - limit)).text
363
+ left -= piece.length
364
+ parts.push(`### ${path}\n\`\`\`\n${piece}\n\`\`\``)
355
365
  }
356
366
  return parts.length === 0 ? [] : [title, ...parts]
357
367
  })
358
368
  return [
359
369
  "## The code you start from",
360
370
  "Read this before exploring: it is the current content of the files below.",
371
+ ...(yield* section(
372
+ "Read first — what the planner says to imitate or build on:",
373
+ anchors,
374
+ Math.floor(budget / 2)
375
+ )),
361
376
  ...(yield* section("Shared, read-only — use these as they are:", shared)),
362
377
  ...(yield* section("Yours — the story's owned files so far:", owned)),
363
378
  ...(leftOut.length === 0 ? [] : [`Not shown (over the budget): ${leftOut.join(", ")}`])
364
379
  ].join("\n\n")
365
380
  })
366
381
 
382
+ const numbered = (items: ReadonlyArray<string>): ReadonlyArray<string> =>
383
+ items.map((item, index) => `${index + 1}. ${item}`)
384
+
367
385
  export const storyPrompt = (story: Story): string =>
368
- [`Story: ${story.title}`, "", story.description.trim()].join("\n")
386
+ [
387
+ `Story: ${story.title}`,
388
+ "",
389
+ story.description.trim(),
390
+ ...(story.acceptance.length === 0
391
+ ? []
392
+ : ["", "Done when (each must hold, observably):", ...numbered(story.acceptance)])
393
+ ].join("\n")
369
394
 
370
395
  export const storyTaskPlanInstructions = (story: Story): string =>
371
396
  [
@@ -373,6 +398,14 @@ export const storyTaskPlanInstructions = (story: Story): string =>
373
398
  "into an ordered list of small, independently verifiable tasks, each described by its",
374
399
  "observable outcome. Every task must stay inside the story's owned paths:",
375
400
  bullets(story.owned),
401
+ ...(story.acceptance.length === 0
402
+ ? []
403
+ : [
404
+ "The story is done when every one of these holds:",
405
+ ...numbered(story.acceptance),
406
+ "End every task's description with `Satisfies: <n>` naming the criteria it serves, and",
407
+ "make sure every criterion is served by at least one task."
408
+ ]),
376
409
  "Never plan a task that creates or changes anything outside them — registering or wiring",
377
410
  "the story in elsewhere (the app's composition point, a shared kit file) is another story's job.",
378
411
  `Use exactly this epicId: "${story.id}".`,
@@ -681,6 +714,8 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
681
714
  /** What reviewers and the judge found, round by round, for people to read. */
682
715
  const findingsPath = (story: Story): string =>
683
716
  join(options.stateDir, `stories/${story.id}.findings.md`)
717
+ /** What the coder's tasks learned, carried into the next task of the same story. */
718
+ const notesPath = (story: Story): string => join(options.stateDir, `stories/${story.id}.notes.md`)
684
719
  const findingLines = (result: ReviewResult): ReadonlyArray<string> =>
685
720
  result.issues.map((issue) => {
686
721
  const where =
@@ -824,7 +859,9 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
824
859
  }
825
860
  // The task checkpoint belongs to the old branch: left in place, the
826
861
  // fresh branch would inherit "every task complete" and skip the coder.
862
+ // The notes its tasks carried describe the old definition: gone too.
827
863
  yield* files.remove(planPath(story))
864
+ yield* files.remove(notesPath(story))
828
865
  stored = undefined
829
866
  }
830
867
  if (stored !== undefined && stored.status === "merged") {
@@ -1127,6 +1164,10 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1127
1164
  ),
1128
1165
  system: taskSystem,
1129
1166
  chatPerTask: true,
1167
+ carry: {
1168
+ read: files.read(notesPath(story)),
1169
+ write: (notes) => files.writeAtomic(notesPath(story), notes)
1170
+ },
1130
1171
  checkoutBranch: false,
1131
1172
  lint: gates,
1132
1173
  // A story's final state is judged and gated downstream (judge round,
package/src/StoryPlan.ts CHANGED
@@ -30,6 +30,20 @@ export class Story extends Schema.Class<Story>("Story")({
30
30
  provides: Schema.Array(Schema.String).pipe(
31
31
  Schema.withConstructorDefault(Effect.succeed([])),
32
32
  Schema.withDecodingDefaultKey(Effect.succeed([]))
33
+ ),
34
+ /**
35
+ * Paths the planner says to read before touching anything: the exemplar
36
+ * feature to imitate, the contract this story extends, the kit component to
37
+ * reuse. Pruned to paths that exist; their contents open the starting code.
38
+ */
39
+ readFirst: Schema.Array(Schema.String).pipe(
40
+ Schema.withConstructorDefault(Effect.succeed([])),
41
+ Schema.withDecodingDefaultKey(Effect.succeed([]))
42
+ ),
43
+ /** Observable outcomes that make the story done; the coder plans against them, the judge scores against them. */
44
+ acceptance: Schema.Array(Schema.String).pipe(
45
+ Schema.withConstructorDefault(Effect.succeed([])),
46
+ Schema.withDecodingDefaultKey(Effect.succeed([]))
33
47
  )
34
48
  }) {}
35
49
 
@@ -120,6 +134,51 @@ export const storyPlanViolations = (plan: StoryPlan): ReadonlyArray<string> => {
120
134
  return violations
121
135
  }
122
136
 
137
+ /**
138
+ * An anchor the repository can answer: relative, inside the checkout, and
139
+ * narrower than the whole tree. `../x`, `/abs/x`, `.`, `` and `a/../b` are
140
+ * not — `git ls-files` is fatal on the first two, and the last ones would
141
+ * name every file.
142
+ */
143
+ export const isSafeAnchor = (anchor: string): boolean => {
144
+ const path = normalizePath(anchor)
145
+ return (
146
+ path.length > 0 &&
147
+ !path.startsWith("/") &&
148
+ !/^[A-Za-z]:/u.test(path) &&
149
+ path !== "." &&
150
+ !path.split("/").includes("..")
151
+ )
152
+ }
153
+
154
+ /**
155
+ * The plan with every `readFirst` anchor that no tracked file lies under
156
+ * removed — unsafe anchors (`isSafeAnchor`) first, without a lookup — and
157
+ * the list of what was dropped. A planner names paths from a layout digest
158
+ * and sometimes guesses; a guess must not reach a coder as something to read.
159
+ */
160
+ export const pruneReadFirst = (
161
+ plan: StoryPlan,
162
+ known: ReadonlySet<string>
163
+ ): {
164
+ readonly plan: StoryPlan
165
+ readonly dropped: ReadonlyArray<{ readonly story: string; readonly path: string }>
166
+ } => {
167
+ const files = [...known].map(normalizePath)
168
+ const dropped: Array<{ readonly story: string; readonly path: string }> = []
169
+ const stories = plan.stories.map((story) => {
170
+ const kept = story.readFirst.filter((anchor) => {
171
+ const exists = isSafeAnchor(anchor) && files.some((file) => pathWithin(file, anchor))
172
+ if (!exists) {
173
+ dropped.push({ story: story.id, path: anchor })
174
+ }
175
+ return exists
176
+ })
177
+ return kept.length === story.readFirst.length ? story : new Story({ ...story, readFirst: kept })
178
+ })
179
+ return { plan: dropped.length === 0 ? plan : new StoryPlan({ ...plan, stories }), dropped }
180
+ }
181
+
123
182
  const cycles = (plan: StoryPlan): ReadonlyArray<ReadonlyArray<string>> => {
124
183
  const found: Array<ReadonlyArray<string>> = []
125
184
  const state = new Map<string, "visiting" | "done">()
@@ -289,7 +348,11 @@ export const pathsNamedIn = (plan: StoryPlan, text: string): ReadonlyArray<strin
289
348
  return [...new Set(found)]
290
349
  }
291
350
 
292
- /** Stable over the story entry's content — a changed entry means a fresh branch. */
351
+ /**
352
+ * Stable over the story entry's content — a changed entry means a fresh
353
+ * branch. The 2.29 fields enter only when set, so a plan written earlier
354
+ * keeps every hash and an upgrade restarts no story.
355
+ */
293
356
  export const storyHash = (story: Story): string =>
294
357
  stableHash(
295
358
  JSON.stringify({
@@ -299,7 +362,11 @@ export const storyHash = (story: Story): string =>
299
362
  dependsOn: [...story.dependsOn],
300
363
  owned: [...story.owned].map(normalizePath),
301
364
  sharedReadOnly: [...story.sharedReadOnly].map(normalizePath),
302
- provides: [...story.provides]
365
+ provides: [...story.provides],
366
+ ...(story.readFirst.length === 0
367
+ ? {}
368
+ : { readFirst: [...story.readFirst].map(normalizePath) }),
369
+ ...(story.acceptance.length === 0 ? {} : { acceptance: [...story.acceptance] })
303
370
  })
304
371
  )
305
372
 
@@ -367,9 +434,18 @@ export const renderStoryPlan = Effect.fn("@llm4ts/flow/StoryPlan.render")(functi
367
434
  "",
368
435
  story.description.trim(),
369
436
  "",
437
+ ...(story.acceptance.length === 0
438
+ ? []
439
+ : [
440
+ "Done when:",
441
+ "",
442
+ ...story.acceptance.map((criterion, index) => `${index + 1}. ${criterion}`),
443
+ ""
444
+ ]),
370
445
  `- depends on: ${list(story.dependsOn)}`,
371
446
  `- owned: ${list(story.owned)}`,
372
447
  `- shared read-only: ${list(story.sharedReadOnly)}`,
448
+ `- read first: ${list(story.readFirst)}`,
373
449
  `- provides: ${list(story.provides)}`,
374
450
  ""
375
451
  )
package/src/Transcript.ts CHANGED
@@ -10,8 +10,9 @@ import { redactText } from "@llm4ts/core/observability/Redaction"
10
10
  import { roleOr } from "./Timing.ts"
11
11
 
12
12
  /**
13
- * What the model was told and what it answered, per seat call — an opt-in
14
- * record (`llm4ts run --transcript`) that `llm4ts watch` tails. It lives in
13
+ * What the model was told and what it answered, per seat call — a record
14
+ * (`llm4ts run --transcript`; on by default in epic-stories, where
15
+ * `LLM4TS_TRANSCRIPT=off` turns it off) that `llm4ts watch` tails. It lives in
15
16
  * its own files, never in the trace, so the trace and `llm4ts profile` stay
16
17
  * content-free. Secrets are redacted and every field is capped.
17
18
  */