@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/src/Spans.ts ADDED
@@ -0,0 +1,150 @@
1
+ // The vocabulary every llm4ts span speaks (ADR 0026): OpenTelemetry GenAI
2
+ // attributes first, OpenInference's span kind and token keys beside them so
3
+ // Phoenix of any version renders the LLM views, and llm4ts's own keys for
4
+ // what no convention names (role, story, epic, estimated usage). Pure: the
5
+ // exporter is the runner's, the seams only call `withKindSpan`.
6
+ import * as Context from "effect/Context"
7
+ import * as Effect from "effect/Effect"
8
+ import * as Option from "effect/Option"
9
+ import type * as Tracer from "effect/Tracer"
10
+ import type { TokenUsage } from "@llm4ts/core/Models"
11
+ import { isEstimatedModel } from "./EstimatedUsage.ts"
12
+
13
+ export type SpanKind = "CHAIN" | "AGENT" | "LLM" | "TOOL" | "EVALUATOR"
14
+
15
+ export const kindAttribute = "openinference.span.kind"
16
+
17
+ export const attr = {
18
+ role: "llm4ts.role",
19
+ executor: "llm4ts.executor",
20
+ story: "llm4ts.story",
21
+ epic: "llm4ts.epic",
22
+ flow: "llm4ts.flow",
23
+ run: "llm4ts.run",
24
+ session: "session.id",
25
+ toolCategory: "llm4ts.tool.category",
26
+ estimated: "llm4ts.usage.estimated",
27
+ costUsd: "llm4ts.cost.usd",
28
+ costSource: "llm4ts.cost.source",
29
+ gateCommand: "llm4ts.gate.command",
30
+ gateExit: "llm4ts.gate.exit_code",
31
+ model: "gen_ai.request.model",
32
+ provider: "gen_ai.provider.name",
33
+ operation: "gen_ai.operation.name",
34
+ inputTokens: "gen_ai.usage.input_tokens",
35
+ outputTokens: "gen_ai.usage.output_tokens",
36
+ oiModel: "llm.model_name",
37
+ oiPrompt: "llm.token_count.prompt",
38
+ oiCompletion: "llm.token_count.completion",
39
+ oiTotal: "llm.token_count.total",
40
+ input: "input.value",
41
+ output: "output.value",
42
+ inputMessages: "gen_ai.input.messages",
43
+ outputMessages: "gen_ai.output.messages"
44
+ } as const
45
+
46
+ /** How much content leaves the machine: nothing, prompts and tools, or the system prompt too. */
47
+ export type OtelContent = "off" | "on" | "full"
48
+
49
+ /** LLM4TS_OTEL_CONTENT: `on` or `full`; anything else is off. */
50
+ export const otelContent = (
51
+ environment: Readonly<Record<string, string | undefined>>
52
+ ): OtelContent => {
53
+ const value = environment.LLM4TS_OTEL_CONTENT?.trim().toLowerCase()
54
+ return value === "on" ? "on" : value === "full" ? "full" : "off"
55
+ }
56
+
57
+ export interface SpanOptions {
58
+ readonly kind: SpanKind
59
+ /** Start a new trace here (a story), linked to the span it was started from. */
60
+ readonly root?: boolean
61
+ readonly attributes?: Readonly<Record<string, unknown>>
62
+ }
63
+
64
+ /**
65
+ * The nearest span `withKindSpan` opened. Effect names a span for every
66
+ * `Effect.fn`, so the fiber's current span is usually an internal one; kind
67
+ * spans chain to each other instead, and the runner leaves the internal ones
68
+ * unsampled, so an exported trace shows the flow, not the call stack.
69
+ */
70
+ const CurrentKindSpan = Context.Reference<Option.Option<Tracer.Span>>(
71
+ "@llm4ts/flow/Spans/CurrentKindSpan",
72
+ { defaultValue: () => Option.none() }
73
+ )
74
+
75
+ /** The kind span the current code runs under, when there is one. */
76
+ export const currentKindSpan: Effect.Effect<Option.Option<Tracer.Span>> = CurrentKindSpan
77
+
78
+ /**
79
+ * A span with the OpenInference kind set, parented to the nearest kind span,
80
+ * always sampled, ended with the effect's exit. With no kind span above it
81
+ * (the run; a story asked to root) it starts a trace of its own, linked to
82
+ * the kind span it was started from when there is one. The wrapped effect
83
+ * keeps the caller's scope: only the span's lifetime is managed here.
84
+ */
85
+ export const withKindSpan = <A, E, R>(
86
+ name: string,
87
+ options: SpanOptions,
88
+ effect: Effect.Effect<A, E, R>
89
+ ): Effect.Effect<A, E, R> =>
90
+ Effect.flatMap(CurrentKindSpan, (parent) =>
91
+ Effect.useSpan(
92
+ name,
93
+ {
94
+ kind: options.kind === "LLM" ? "client" : "internal",
95
+ sampled: true,
96
+ ...(options.root === true || Option.isNone(parent)
97
+ ? {
98
+ root: true,
99
+ ...(Option.isSome(parent) ? { links: [{ span: parent.value, attributes: {} }] } : {})
100
+ }
101
+ : { parent: parent.value }),
102
+ attributes: { [kindAttribute]: options.kind, ...(options.attributes ?? {}) }
103
+ },
104
+ (span) =>
105
+ Effect.withParentSpan(
106
+ Effect.provideService(effect, CurrentKindSpan, Option.some(span)),
107
+ span
108
+ )
109
+ )
110
+ )
111
+
112
+ /** The model name a backend would price: the estimate label stripped. */
113
+ export const modelNameOf = (model: string | undefined): string | undefined =>
114
+ model === undefined
115
+ ? undefined
116
+ : isEstimatedModel(model)
117
+ ? model.replace(/^estimated:/u, "")
118
+ : model
119
+
120
+ /**
121
+ * Token and cost attributes for one model call; `{}` when nothing is known.
122
+ * A cost travels only when the backend reported it on measured usage: the
123
+ * backends price estimates themselves from the model name.
124
+ */
125
+ export const usageAttributes = (
126
+ model: string | undefined,
127
+ usage: TokenUsage | undefined,
128
+ estimated: boolean
129
+ ): Record<string, unknown> => {
130
+ const name = modelNameOf(model)
131
+ if (name === undefined && usage === undefined) {
132
+ return {}
133
+ }
134
+ return {
135
+ ...(name === undefined ? {} : { [attr.model]: name, [attr.oiModel]: name }),
136
+ ...(usage === undefined
137
+ ? {}
138
+ : {
139
+ [attr.inputTokens]: usage.prompt,
140
+ [attr.outputTokens]: usage.completion,
141
+ [attr.oiPrompt]: usage.prompt,
142
+ [attr.oiCompletion]: usage.completion,
143
+ [attr.oiTotal]: usage.total,
144
+ ...(usage.costUsd === undefined || estimated
145
+ ? {}
146
+ : { [attr.costUsd]: usage.costUsd, [attr.costSource]: "reported" })
147
+ }),
148
+ [attr.estimated]: estimated
149
+ }
150
+ }
package/src/Stories.ts CHANGED
@@ -74,6 +74,7 @@ import {
74
74
  type StoryRelation
75
75
  } from "./StoryPlan.ts"
76
76
  import { isOutageMessage } from "./TransientRetry.ts"
77
+ import { attr, withKindSpan } from "./Spans.ts"
77
78
 
78
79
  const join = (root: string, path: string): string =>
79
80
  `${root.replace(/[\\/]+$/, "")}/${path.replace(/^[\\/]+/, "")}`
@@ -314,10 +315,11 @@ const defaultContextChars = 40_000
314
315
  const fileChars = 8_000
315
316
 
316
317
  /**
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.
318
+ * The code a story starts from, for its coder's system prompt: the planner's
319
+ * read-first anchors, then the shared read-only files it builds on, then the
320
+ * files it owns, each capped, until `budget` runs out; the rest by path only.
321
+ * Reading them up front saves the `ls`/`find`/`cat` round trips the coder
322
+ * would otherwise spend on them.
321
323
  */
322
324
  export const startingCodeOf = (
323
325
  story: Story,
@@ -330,42 +332,66 @@ export const startingCodeOf = (
330
332
  if (budget <= 0) {
331
333
  return undefined
332
334
  }
335
+ const anchors = yield* git.listFiles(story.readFirst)
333
336
  const shared = yield* git.listFiles(story.sharedReadOnly)
334
337
  const owned = yield* git.listFiles(story.owned)
335
- if (shared.length + owned.length === 0) {
338
+ if (anchors.length + shared.length + owned.length === 0) {
336
339
  return undefined
337
340
  }
338
341
  let left = budget
339
342
  const leftOut: Array<string> = []
340
- const section = (title: string, paths: ReadonlyArray<string>) =>
343
+ const shown = new Set<string>()
344
+ // `limit`: how far this section may draw the budget down. The anchors
345
+ // stop at half, so a broad anchor (a whole feature folder) cannot starve
346
+ // the story's own files; the later sections may use everything left.
347
+ const section = (title: string, paths: ReadonlyArray<string>, limit = 0) =>
341
348
  Effect.gen(function* () {
342
349
  const parts: Array<string> = []
343
350
  for (const path of paths) {
351
+ if (shown.has(path)) {
352
+ continue
353
+ }
344
354
  const text = yield* Effect.orElseSucceed(
345
355
  files.read(join(worktree, path)),
346
356
  () => undefined
347
357
  )
348
- if (text === undefined || left <= 0) {
358
+ if (text === undefined || left <= limit) {
349
359
  leftOut.push(path)
350
360
  continue
351
361
  }
352
- const shown = cap(text, Math.min(fileChars, left)).text
353
- left -= shown.length
354
- parts.push(`### ${path}\n\`\`\`\n${shown}\n\`\`\``)
362
+ shown.add(path)
363
+ const piece = cap(text, Math.min(fileChars, left - limit)).text
364
+ left -= piece.length
365
+ parts.push(`### ${path}\n\`\`\`\n${piece}\n\`\`\``)
355
366
  }
356
367
  return parts.length === 0 ? [] : [title, ...parts]
357
368
  })
358
369
  return [
359
370
  "## The code you start from",
360
371
  "Read this before exploring: it is the current content of the files below.",
372
+ ...(yield* section(
373
+ "Read first — what the planner says to imitate or build on:",
374
+ anchors,
375
+ Math.floor(budget / 2)
376
+ )),
361
377
  ...(yield* section("Shared, read-only — use these as they are:", shared)),
362
378
  ...(yield* section("Yours — the story's owned files so far:", owned)),
363
379
  ...(leftOut.length === 0 ? [] : [`Not shown (over the budget): ${leftOut.join(", ")}`])
364
380
  ].join("\n\n")
365
381
  })
366
382
 
383
+ const numbered = (items: ReadonlyArray<string>): ReadonlyArray<string> =>
384
+ items.map((item, index) => `${index + 1}. ${item}`)
385
+
367
386
  export const storyPrompt = (story: Story): string =>
368
- [`Story: ${story.title}`, "", story.description.trim()].join("\n")
387
+ [
388
+ `Story: ${story.title}`,
389
+ "",
390
+ story.description.trim(),
391
+ ...(story.acceptance.length === 0
392
+ ? []
393
+ : ["", "Done when (each must hold, observably):", ...numbered(story.acceptance)])
394
+ ].join("\n")
369
395
 
370
396
  export const storyTaskPlanInstructions = (story: Story): string =>
371
397
  [
@@ -373,6 +399,14 @@ export const storyTaskPlanInstructions = (story: Story): string =>
373
399
  "into an ordered list of small, independently verifiable tasks, each described by its",
374
400
  "observable outcome. Every task must stay inside the story's owned paths:",
375
401
  bullets(story.owned),
402
+ ...(story.acceptance.length === 0
403
+ ? []
404
+ : [
405
+ "The story is done when every one of these holds:",
406
+ ...numbered(story.acceptance),
407
+ "End every task's description with `Satisfies: <n>` naming the criteria it serves, and",
408
+ "make sure every criterion is served by at least one task."
409
+ ]),
376
410
  "Never plan a task that creates or changes anything outside them — registering or wiring",
377
411
  "the story in elsewhere (the app's composition point, a shared kit file) is another story's job.",
378
412
  `Use exactly this epicId: "${story.id}".`,
@@ -681,6 +715,8 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
681
715
  /** What reviewers and the judge found, round by round, for people to read. */
682
716
  const findingsPath = (story: Story): string =>
683
717
  join(options.stateDir, `stories/${story.id}.findings.md`)
718
+ /** What the coder's tasks learned, carried into the next task of the same story. */
719
+ const notesPath = (story: Story): string => join(options.stateDir, `stories/${story.id}.notes.md`)
684
720
  const findingLines = (result: ReviewResult): ReadonlyArray<string> =>
685
721
  result.issues.map((issue) => {
686
722
  const where =
@@ -824,7 +860,9 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
824
860
  }
825
861
  // The task checkpoint belongs to the old branch: left in place, the
826
862
  // fresh branch would inherit "every task complete" and skip the coder.
863
+ // The notes its tasks carried describe the old definition: gone too.
827
864
  yield* files.remove(planPath(story))
865
+ yield* files.remove(notesPath(story))
828
866
  stored = undefined
829
867
  }
830
868
  if (stored !== undefined && stored.status === "merged") {
@@ -1127,6 +1165,10 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1127
1165
  ),
1128
1166
  system: taskSystem,
1129
1167
  chatPerTask: true,
1168
+ carry: {
1169
+ read: files.read(notesPath(story)),
1170
+ write: (notes) => files.writeAtomic(notesPath(story), notes)
1171
+ },
1130
1172
  checkoutBranch: false,
1131
1173
  lint: gates,
1132
1174
  // A story's final state is judged and gated downstream (judge round,
@@ -1202,40 +1244,68 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1202
1244
  // rerun that finds the same diff gets the same answer without a
1203
1245
  // model call, and the last verdict is on disk to read. Only the
1204
1246
  // first round reuses it; a later round follows a revision and asks.
1205
- const judged =
1206
- empty && subject.trim().length === 0
1207
- ? // Nothing to judge is decided here, not asked of a model.
1208
- {
1209
- value: ReviewResult.make({
1210
- issues: [
1211
- ReviewIssue.make({
1212
- severity: "Critical",
1213
- title: `nothing exists yet under ${story.owned.join(", ")}`,
1214
- description: "The story's owned paths are empty on the epic branch."
1247
+ // The round is an EVALUATOR span (ADR 0026): the verdict's scores and
1248
+ // findings land on it, so a trace shows why a story was sent back.
1249
+ const judged = yield* withKindSpan(
1250
+ `story ${story.id}: judge ${round}`,
1251
+ { kind: "EVALUATOR" },
1252
+ Effect.gen(function* () {
1253
+ const outcome =
1254
+ empty && subject.trim().length === 0
1255
+ ? // Nothing to judge is decided here, not asked of a model.
1256
+ {
1257
+ value: ReviewResult.make({
1258
+ issues: [
1259
+ ReviewIssue.make({
1260
+ severity: "Critical",
1261
+ title: `nothing exists yet under ${story.owned.join(", ")}`,
1262
+ description: "The story's owned paths are empty on the epic branch."
1263
+ })
1264
+ ],
1265
+ summary: `judge:${story.id}`
1266
+ }),
1267
+ reused: false
1268
+ }
1269
+ : yield* cachedValue(
1270
+ files,
1271
+ judgePath(story),
1272
+ Verdict,
1273
+ fingerprintOf([
1274
+ empty ? "code" : "diff",
1275
+ subject,
1276
+ story.id,
1277
+ story.title,
1278
+ story.description,
1279
+ story.provides.join("\n")
1280
+ ]),
1281
+ withTimedRole(
1282
+ "judge",
1283
+ judge(story, subject, watchedSeats, empty ? "code" : "diff")
1284
+ ),
1285
+ { reuse: round === 1 }
1286
+ )
1287
+ const scored = outcome.value instanceof StoryVerdict ? outcome.value.dimensions : []
1288
+ yield* Effect.annotateCurrentSpan({
1289
+ "llm4ts.judge.cleared": outcome.value.isClean,
1290
+ "llm4ts.judge.reused": outcome.reused,
1291
+ ...Object.fromEntries(scored.map((d) => [`llm4ts.judge.${d.id}`, d.score]))
1292
+ })
1293
+ const now = yield* Clock.currentTimeNanos
1294
+ yield* Effect.option(Effect.currentSpan).pipe(
1295
+ Effect.map((span) => {
1296
+ if (span._tag === "Some") {
1297
+ for (const issue of outcome.value.issues) {
1298
+ span.value.event("judge finding", now, {
1299
+ title: issue.title,
1300
+ severity: issue.severity
1215
1301
  })
1216
- ],
1217
- summary: `judge:${story.id}`
1218
- }),
1219
- reused: false
1220
- }
1221
- : yield* cachedValue(
1222
- files,
1223
- judgePath(story),
1224
- Verdict,
1225
- fingerprintOf([
1226
- empty ? "code" : "diff",
1227
- subject,
1228
- story.id,
1229
- story.title,
1230
- story.description,
1231
- story.provides.join("\n")
1232
- ]),
1233
- withTimedRole(
1234
- "judge",
1235
- judge(story, subject, watchedSeats, empty ? "code" : "diff")
1236
- ),
1237
- { reuse: round === 1 }
1238
- )
1302
+ }
1303
+ }
1304
+ })
1305
+ )
1306
+ return outcome
1307
+ })
1308
+ )
1239
1309
  const verdict = judged.value
1240
1310
  if (judged.reused) {
1241
1311
  yield* laneOf(story).publish(
@@ -1349,8 +1419,33 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1349
1419
  })
1350
1420
 
1351
1421
  /** A story's run as a completion — failures become outcomes, never fiber deaths. */
1422
+ // One trace per story (ADR 0026): the story span roots it, linked to the
1423
+ // run's span, and every span under it — tasks, model calls, tools, gates —
1424
+ // carries the story, the epic and the run's session.
1425
+ const storySpanAttributes = (story: Story): Readonly<Record<string, unknown>> => ({
1426
+ [attr.story]: story.id,
1427
+ [attr.epic]: plan.epicId,
1428
+ ...(context.trace === undefined
1429
+ ? {}
1430
+ : { [attr.session]: context.trace.runId, [attr.run]: context.trace.runId })
1431
+ })
1432
+ const annotated = <A, E, R>(
1433
+ story: Story,
1434
+ effect: Effect.Effect<A, E, R>
1435
+ ): Effect.Effect<A, E, R> =>
1436
+ Object.entries(storySpanAttributes(story)).reduce(
1437
+ (acc, [key, value]) => Effect.annotateSpans(key, value)(acc),
1438
+ effect
1439
+ )
1352
1440
  const attempt = (story: Story): Effect.Effect<Completion> =>
1353
- stage(laneOf(story), `story ${story.id}`, runStory(story)).pipe(
1441
+ annotated(
1442
+ story,
1443
+ stage(laneOf(story), `story ${story.id}`, runStory(story), {
1444
+ kind: "AGENT",
1445
+ root: true,
1446
+ attributes: storySpanAttributes(story)
1447
+ })
1448
+ ).pipe(
1354
1449
  Effect.map((outcome): Completion => ({ story, outcome })),
1355
1450
  Effect.catch((error) =>
1356
1451
  Effect.gen(function* () {
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
  )