@llm4ts/flow 2.28.0 → 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.
Files changed (53) 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/FlowEvents.d.ts +5 -0
  10. package/dist/FlowEvents.d.ts.map +1 -1
  11. package/dist/FlowEvents.js +9 -2
  12. package/dist/FlowEvents.js.map +1 -1
  13. package/dist/Orientation.d.ts +25 -0
  14. package/dist/Orientation.d.ts.map +1 -0
  15. package/dist/Orientation.js +150 -0
  16. package/dist/Orientation.js.map +1 -0
  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/Roster.d.ts +20 -0
  24. package/dist/Roster.d.ts.map +1 -1
  25. package/dist/Roster.js +34 -1
  26. package/dist/Roster.js.map +1 -1
  27. package/dist/RosterSeats.d.ts +5 -1
  28. package/dist/RosterSeats.d.ts.map +1 -1
  29. package/dist/RosterSeats.js +10 -15
  30. package/dist/RosterSeats.js.map +1 -1
  31. package/dist/Stories.d.ts +5 -4
  32. package/dist/Stories.d.ts.map +1 -1
  33. package/dist/Stories.js +46 -11
  34. package/dist/Stories.js.map +1 -1
  35. package/dist/StoryPlan.d.ts +33 -1
  36. package/dist/StoryPlan.d.ts.map +1 -1
  37. package/dist/StoryPlan.js +62 -4
  38. package/dist/StoryPlan.js.map +1 -1
  39. package/dist/Transcript.d.ts.map +1 -1
  40. package/dist/Transcript.js +3 -2
  41. package/dist/Transcript.js.map +1 -1
  42. package/package.json +4 -2
  43. package/src/CarriedNotes.ts +103 -0
  44. package/src/Flow.ts +31 -1
  45. package/src/FlowEvents.ts +11 -2
  46. package/src/Orientation.ts +192 -0
  47. package/src/RefineRound.ts +13 -1
  48. package/src/Retro.ts +26 -3
  49. package/src/Roster.ts +79 -1
  50. package/src/RosterSeats.ts +14 -18
  51. package/src/Stories.ts +52 -11
  52. package/src/StoryPlan.ts +78 -2
  53. package/src/Transcript.ts +3 -2
@@ -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/Roster.ts CHANGED
@@ -391,6 +391,8 @@ export interface Lease {
391
391
  readonly role: Role
392
392
  /** Frees the slot; idempotent, and also run when the lease's scope closes. */
393
393
  readonly release: Effect.Effect<void>
394
+ /** Took no slot: the context's own coder executor serves the call (see `LeaseOptions.borrow`). */
395
+ readonly borrowed: boolean
394
396
  }
395
397
 
396
398
  export interface LeaseOptions {
@@ -400,6 +402,18 @@ export interface LeaseOptions {
400
402
  readonly prefer?: string
401
403
  /** Who is asking, for the events (a story's worktree). */
402
404
  readonly label?: string
405
+ /**
406
+ * The executor holding this context's coder, used without a slot (its
407
+ * coder is idle while the context makes this call) when independence
408
+ * cannot be had: no executor outside `avoid` can ever take the role, or
409
+ * every one in the round that could has all its slots held by coders. A
410
+ * coder's slot frees only when its story ends, and that story may itself
411
+ * be waiting on a call like this one — waiting for it would saturate the
412
+ * roster. An executor that is out of the round is still borrowed in the
413
+ * first case (the call fails on it, as it would on anyone), never in the
414
+ * second (a story in the round will free a slot).
415
+ */
416
+ readonly borrow?: ExecutorSpec
403
417
  }
404
418
 
405
419
  export interface ExecutorStatus {
@@ -423,6 +437,12 @@ export interface RosterShape {
423
437
  ) => Effect.Effect<Lease | undefined, never, Scope.Scope>
424
438
  /** Whether an executor outside `avoid` has the role and is not out for the run. */
425
439
  readonly canEverServe: (role: Role, avoid?: ReadonlyArray<string>) => Effect.Effect<boolean>
440
+ /**
441
+ * Whether every executor in the round that takes `role`, outside `avoid`,
442
+ * has all its slots held by coders — and there is at least one. A lease
443
+ * for `role` would then wait for a story to end, not for a call.
444
+ */
445
+ readonly coderHeld: (role: Role, avoid?: ReadonlyArray<string>) => Effect.Effect<boolean>
426
446
  /** Free slots for `role` now, across executors in the round. */
427
447
  readonly available: (role: Role) => Effect.Effect<number>
428
448
  /** Configured slots for `role`. */
@@ -567,7 +587,29 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
567
587
  )
568
588
  )
569
589
  yield* Effect.addFinalizer(() => free)
570
- return { executor: spec, role, release: free }
590
+ return { executor: spec, role, release: free, borrowed: false }
591
+ })
592
+
593
+ /** A lease over the context's own coder executor: no slot taken, none freed. */
594
+ const borrowedLease = (
595
+ spec: ExecutorSpec,
596
+ role: Role,
597
+ label: string | undefined,
598
+ because: "nobody" | "held"
599
+ ): Effect.Effect<Lease, never, Scope.Scope> =>
600
+ Effect.gen(function* () {
601
+ const labelled = label === undefined ? {} : { label }
602
+ yield* events.publish(
603
+ ExecutorLeased.make({ executor: spec.id, role, ...labelled, borrowed: true, because })
604
+ )
605
+ const done = yield* Ref.make(false)
606
+ const free = Effect.flatMap(Ref.getAndSet(done, true), (was) =>
607
+ was
608
+ ? Effect.void
609
+ : events.publish(ExecutorReleased.make({ executor: spec.id, role, ...labelled }))
610
+ )
611
+ yield* Effect.addFinalizer(() => free)
612
+ return { executor: spec, role, release: free, borrowed: true }
571
613
  })
572
614
 
573
615
  /** One attempt: expire, pick, take the slot — all under the lock. */
@@ -616,6 +658,19 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
616
658
  )
617
659
  )
618
660
 
661
+ const coderHeld = (role: Role, avoid: ReadonlyArray<string> = []): Effect.Effect<boolean> =>
662
+ lock.withPermit(
663
+ Effect.sync(() => {
664
+ const inRound = eligible(role, avoid)
665
+ return (
666
+ inRound.length > 0 &&
667
+ inRound.every(
668
+ (spec) => (usage.get(spec.id) ?? { busy: 0, busyCoders: 0 }).busyCoders >= slotsOf(spec)
669
+ )
670
+ )
671
+ })
672
+ )
673
+
619
674
  /** Probes every `health` exclusion whose next probe is due; a live one comes back. */
620
675
  const probeDue: Effect.Effect<void> = Effect.gen(function* () {
621
676
  const probe = options.probe
@@ -710,6 +765,17 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
710
765
  ): Effect.Effect<Lease, RosterExhausted, Scope.Scope> =>
711
766
  Effect.gen(function* () {
712
767
  const avoid = leaseOptions.avoid ?? []
768
+ const borrow =
769
+ leaseOptions.borrow !== undefined && hasRole(leaseOptions.borrow, role)
770
+ ? leaseOptions.borrow
771
+ : undefined
772
+ /** Borrowing when the round is held by coders needs an executor in the round. */
773
+ const borrowInRound = (): Effect.Effect<ExecutorSpec | undefined> =>
774
+ lock.withPermit(
775
+ Effect.sync(() =>
776
+ borrow !== undefined && !exclusions.has(borrow.id) ? borrow : undefined
777
+ )
778
+ )
713
779
  let announced = false
714
780
  const asked = yield* Clock.currentTimeMillis
715
781
  // A lease that had to wait says how long, for the story it is for.
@@ -732,6 +798,10 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
732
798
  return yield* leaseOf(spec, role, leaseOptions.label)
733
799
  }
734
800
  if (!(yield* canEverServe(role, avoid))) {
801
+ if (borrow !== undefined) {
802
+ yield* waited
803
+ return yield* borrowedLease(borrow, role, leaseOptions.label, "nobody")
804
+ }
735
805
  const reasons = yield* lock.withPermit(
736
806
  Effect.sync(() =>
737
807
  executors
@@ -747,6 +817,13 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
747
817
  reasons: reasons.length === 0 ? ["no executor in the roster takes this role"] : reasons
748
818
  })
749
819
  }
820
+ if (yield* coderHeld(role, avoid)) {
821
+ const own = yield* borrowInRound()
822
+ if (own !== undefined) {
823
+ yield* waited
824
+ return yield* borrowedLease(own, role, leaseOptions.label, "held")
825
+ }
826
+ }
750
827
  if (!announced) {
751
828
  announced = true
752
829
  yield* say(
@@ -876,6 +953,7 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
876
953
  lease,
877
954
  tryLease,
878
955
  canEverServe,
956
+ coderHeld,
879
957
  available,
880
958
  slots,
881
959
  report,
@@ -12,10 +12,9 @@ import { ConfigError, type LlmError } from "@llm4ts/core/Errors"
12
12
  import type { LlmServiceShape } from "@llm4ts/core/LlmService"
13
13
  import { Message, type ConnectorCapabilities, type LlmChunk } from "@llm4ts/core/Models"
14
14
  import { describeFlowError, type FlowError } from "./FlowError.ts"
15
- import { ExecutorHandedOver, ExecutorLeased, type FlowEventsShape } from "./FlowEvents.ts"
15
+ import { ExecutorHandedOver, type FlowEventsShape } from "./FlowEvents.ts"
16
16
  import {
17
17
  describeExclusion,
18
- hasRole,
19
18
  type ExecutorSpec,
20
19
  type Lease,
21
20
  type Role,
@@ -102,7 +101,11 @@ export interface RosterSeatOptions {
102
101
  readonly events: FlowEventsShape
103
102
  /** Executors this seat must not use: the context's coder, for independence. */
104
103
  readonly avoid?: Effect.Effect<ReadonlyArray<string>>
105
- /** Used without a slot when nobody outside `avoid` can ever serve the role. */
104
+ /**
105
+ * The context's coder executor, used without a slot when independence
106
+ * cannot be had: nobody outside `avoid` can ever serve the role, or every
107
+ * executor that could is held by a coder (`LeaseOptions.borrow`).
108
+ */
106
109
  readonly borrow?: Effect.Effect<ExecutorSpec | undefined>
107
110
  /** Who is asking, for the events. */
108
111
  readonly label?: string
@@ -127,22 +130,15 @@ export const rosterSeat = (
127
130
  ): Effect.Effect<ExecutorSpec, LlmError, Scope.Scope> =>
128
131
  Effect.gen(function* () {
129
132
  const avoid = [...(options.avoid === undefined ? [] : yield* options.avoid), ...alsoAvoid]
130
- if (avoid.length > 0 && !(yield* roster.canEverServe(role, avoid))) {
131
- const borrowed = options.borrow === undefined ? undefined : yield* options.borrow
132
- if (borrowed !== undefined && hasRole(borrowed, role)) {
133
- yield* options.events.publish(
134
- ExecutorLeased.make({
135
- executor: borrowed.id,
136
- role,
137
- ...(options.label === undefined ? {} : { label: options.label }),
138
- borrowed: true
139
- })
140
- )
141
- return borrowed
142
- }
143
- }
133
+ // The roster decides who serves: an independent executor, or, when
134
+ // none can ever serve or every one is held by a coder, the context's own.
135
+ const borrow = options.borrow === undefined ? undefined : yield* options.borrow
144
136
  const lease = yield* roster
145
- .lease(role, { avoid, ...(options.label === undefined ? {} : { label: options.label }) })
137
+ .lease(role, {
138
+ avoid,
139
+ ...(options.label === undefined ? {} : { label: options.label }),
140
+ ...(borrow === undefined ? {} : { borrow })
141
+ })
146
142
  .pipe(Effect.mapError(asLlmError))
147
143
  return lease.executor
148
144
  })
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
  */