@llm4ts/flow 2.38.0 → 2.40.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.
package/src/Stories.ts CHANGED
@@ -27,7 +27,7 @@ import {
27
27
  ensureBaseline as ensureStoredBaseline,
28
28
  failingLinesOf,
29
29
  storyGateLogDir,
30
- triageGates,
30
+ mergeGateLogDir,
31
31
  writeBaseline,
32
32
  type GateLogDir
33
33
  } from "./Gates.ts"
@@ -74,7 +74,9 @@ import {
74
74
  ReviewIssue,
75
75
  ReviewResult,
76
76
  applyTriage,
77
+ isBlocking,
77
78
  loadRepoReviewRules,
79
+ nonBlockingIssues,
78
80
  type FixPromptOptions,
79
81
  type GateTriageOptions
80
82
  } from "./Review.ts"
@@ -701,6 +703,13 @@ export interface StoriesOptions {
701
703
  * story's plan (coder, review, gates, commit). Default 2: one revision.
702
704
  */
703
705
  readonly judgeRounds?: number
706
+ /**
707
+ * When the epic gates are red after a story merges (the merge is undone),
708
+ * the story goes back to its worktree this many times: it catches up with
709
+ * the epic, gets a revision task with the failures, and merges again.
710
+ * Default 1; 0 fails the story at once, as before.
711
+ */
712
+ readonly mergeRevisions?: number
704
713
  /**
705
714
  * How much of the code a story starts from goes into its coder's system
706
715
  * prompt, in characters: the shared read-only files it uses, then its own.
@@ -722,6 +731,14 @@ export interface StoriesOptions {
722
731
  readonly failFast?: boolean
723
732
  readonly reviewers?: ReadonlyArray<Reviewer>
724
733
  readonly maxRounds?: number
734
+ /**
735
+ * Defer what does not block (ADR 0031): a task's review settles once its
736
+ * findings are all non-blocking, the judge clears a story whose findings
737
+ * are all non-blocking, and what is left is written to
738
+ * `stories/<id>.deferred.md` (`deferredPath`) when the story finishes,
739
+ * for a follow-up round. Default false: every finding is fixed or fails.
740
+ */
741
+ readonly deferNonBlocking?: boolean
725
742
  /**
726
743
  * Second opinion on a BLOCKED_ON claim the plan cannot settle (a path no
727
744
  * story owns, or a parallel story's). `real: false` sends the coder back
@@ -762,6 +779,24 @@ const combined = (first: ReviewResult, second: ReviewResult): ReviewResult =>
762
779
  summary: [first.summary, second.summary].filter((part) => part.length > 0).join("; ")
763
780
  })
764
781
 
782
+ /**
783
+ * Where a finished story's deferred findings are kept (ADR 0031): one
784
+ * Markdown list per story under the executor's state directory, absent when
785
+ * nothing was deferred.
786
+ */
787
+ export const deferredPath = (stateDir: string, storyId: string): string =>
788
+ join(stateDir, `stories/${storyId}.deferred.md`)
789
+
790
+ /** A deferred finding as one Markdown bullet, prefixed with where it was found. */
791
+ export const deferredLine = (source: string, issue: ReviewIssue): string => {
792
+ const where =
793
+ issue.file === undefined
794
+ ? ""
795
+ : ` (${issue.file}${issue.line === undefined ? "" : `:${issue.line}`})`
796
+ const detail = issue.description.trim().length === 0 ? "" : `: ${issue.description.trim()}`
797
+ return `- [${issue.severity}] ${source} — ${issue.title}${where}${detail}`
798
+ }
799
+
765
800
  const issueLines = (result: ReviewResult): string =>
766
801
  result.issues.map((issue) => `- ${issue.title}: ${issue.description}`).join("\n")
767
802
 
@@ -843,6 +878,8 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
843
878
  const epicBranch = options.epicBranch ?? `epic/${plan.epicId}`
844
879
  const concurrency = Math.max(1, options.concurrency ?? 3)
845
880
  const judgeRounds = Math.max(1, options.judgeRounds ?? 2)
881
+ const mergeRevisions = Math.max(0, options.mergeRevisions ?? 1)
882
+ const deferring = options.deferNonBlocking === true
846
883
  const statePath = (story: Story): string => join(options.stateDir, `stories/${story.id}.json`)
847
884
  const planPath = (story: Story): string => join(options.stateDir, `stories/${story.id}.plan.md`)
848
885
  /** The judge's last verdict beside a fingerprint of what it judged. */
@@ -981,8 +1018,15 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
981
1018
 
982
1019
  const mergeLock = yield* Semaphore.make(1)
983
1020
 
984
- /** Merge the story branch into the epic branch and re-gate the epic head, one story at a time. */
985
- const integrate = (story: Story, branch: string): Effect.Effect<void, FlowError> =>
1021
+ /**
1022
+ * Merge the story branch into the epic branch and re-gate the epic head,
1023
+ * one story at a time. Red gates undo the merge and are returned (the
1024
+ * failures the merge added); `undefined` means merged.
1025
+ */
1026
+ const integrate = (
1027
+ story: Story,
1028
+ branch: string
1029
+ ): Effect.Effect<ReviewResult | undefined, FlowError> =>
986
1030
  Effect.flatMap(Clock.currentTimeMillis, (requested) =>
987
1031
  Effect.andThen(
988
1032
  laneOf(story).publish(Began.make({ kind: "wait", label: "merge lock" })),
@@ -1003,22 +1047,36 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1003
1047
  Timed.make({ kind: "merge", label: "merge", ms, ...(failed ? { failed } : {}) }),
1004
1048
  () => Began.make({ kind: "merge", label: "merge" })
1005
1049
  )
1006
- const gate = yield* options.gates(context.workDir, lane)
1050
+ // The whole output is kept: the reason below names only the
1051
+ // failing lines, which is not enough to tell why.
1052
+ const gate = yield* options.gates(context.workDir, lane, {
1053
+ files,
1054
+ dir: mergeGateLogDir(options.stateDir, story.id)
1055
+ })
1007
1056
  // Charged only with what the merge added: the head before it had
1008
- // a baseline (run start or the previous merge).
1057
+ // a baseline (run start or the previous merge). A new line green
1058
+ // on one rerun of the test gate is flaky, as in the worktree.
1009
1059
  const before = yield* ensureBaseline(
1010
1060
  checkpoint,
1011
1061
  Effect.succeed(ReviewResult.make({ issues: [] })),
1012
1062
  context.workDir
1013
1063
  )
1014
- const verdict = triageGates(gate, before, rootsOf(context.workDir))
1015
- if (!verdict.blocking.isClean) {
1064
+ const blocking = yield* applyTriage(
1065
+ gate,
1066
+ {
1067
+ baseline: Effect.succeed(before),
1068
+ roots: rootsOf(context.workDir),
1069
+ ...(options.testGate === undefined
1070
+ ? {}
1071
+ : { rerunTest: options.testGate(context.workDir, lane) })
1072
+ },
1073
+ lane,
1074
+ yield* Ref.make<ReadonlySet<string>>(new Set())
1075
+ )
1076
+ if (!blocking.isClean) {
1016
1077
  // Never leave a red epic head for the next story to inherit.
1017
1078
  yield* context.git.rollback(checkpoint)
1018
- return yield* failed(
1019
- story,
1020
- `epic gates failed after merging; merge undone:\n${issueLines(verdict.blocking)}`
1021
- )
1079
+ return blocking
1022
1080
  }
1023
1081
  if (gateCommands !== undefined) {
1024
1082
  const mergedHead = yield* context.git.checkpoint
@@ -1032,6 +1090,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1032
1090
  yield* laneOf(story).publish(
1033
1091
  Info.make({ message: `story ${story.id}: merged into ${epicBranch}` })
1034
1092
  )
1093
+ return undefined
1035
1094
  })
1036
1095
  )
1037
1096
  )
@@ -1144,7 +1203,9 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1144
1203
  /** Everything that happens inside a story's worktree: plan, implement, judge, perimeter. */
1145
1204
  const implementStory = Effect.fn("@llm4ts/flow/Stories.implementStory")(function* (
1146
1205
  story: Story,
1147
- state: StoryState
1206
+ state: StoryState,
1207
+ /** The epic gates' failures after the story's last merge: run as a revision task. */
1208
+ mergeFailure?: ReviewResult
1148
1209
  ): Effect.fn.Return<
1149
1210
  {
1150
1211
  readonly judge: string | undefined
@@ -1196,6 +1257,8 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1196
1257
  )
1197
1258
  /** What the coder claimed to have run and did not, task by task, for the judge. */
1198
1259
  const evidenceNotes: Array<string> = []
1260
+ /** Findings left for a follow-up round (ADR 0031), written when the story finishes. */
1261
+ const deferred: Array<string> = []
1199
1262
  const checkEvidence = (
1200
1263
  task: Task,
1201
1264
  trailer: Trailer,
@@ -1466,6 +1529,34 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1466
1529
  yield* git.commitAll(`${story.id}: put paths outside the perimeter back`)
1467
1530
  })
1468
1531
 
1532
+ /** A revision task on the story's own plan, run by the task loop like any other. */
1533
+ const appendRevision = (subject: string, lead: string, verdict: ReviewResult) =>
1534
+ Effect.gen(function* () {
1535
+ const store = makePlanStore(files)
1536
+ const current = yield* store.load(planPath(story))
1537
+ if (current === undefined) {
1538
+ return
1539
+ }
1540
+ const number = current.tasks.filter((task) => revisionTitle.test(task.title)).length + 1
1541
+ yield* store.save(
1542
+ planPath(story),
1543
+ Plan.make({
1544
+ ...current,
1545
+ tasks: [
1546
+ ...current.tasks,
1547
+ Task.make({
1548
+ title: `Revision ${number}: ${subject}`,
1549
+ description: [
1550
+ lead,
1551
+ "Close these gaps without weakening any test and without leaving your owned paths:",
1552
+ issueLines(verdict)
1553
+ ].join("\n")
1554
+ })
1555
+ ]
1556
+ })
1557
+ )
1558
+ })
1559
+
1469
1560
  const planTasks = options.planTasks ?? defaultPlanTasks
1470
1561
  // Taken after catch-up and setup: the code as the first task finds it.
1471
1562
  const startingCode = yield* startingCodeOf(
@@ -1513,13 +1604,21 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1513
1604
  // the story's findings log for people to read.
1514
1605
  reviewCache: { files, dir: reviewCacheDir(story) },
1515
1606
  onReview: (task, round, result, settled) =>
1516
- appendFindings(
1517
- story,
1518
- `review "${task.title}" round ${round} — ${
1519
- result.isClean ? "clean" : `${result.issues.length} issue(s)`
1520
- }${settled ? "" : ", fixing"}`,
1521
- result
1522
- ),
1607
+ Effect.gen(function* () {
1608
+ // A settled round's leftovers are deferred, not dropped: when the
1609
+ // rounds ran out, blocking ones too, since the task commits anyway.
1610
+ const left = settled && deferring ? result.issues : []
1611
+ for (const issue of left) {
1612
+ deferred.push(deferredLine(`review "${task.title}"`, issue))
1613
+ }
1614
+ yield* appendFindings(
1615
+ story,
1616
+ `review "${task.title}" round ${round} — ${
1617
+ result.isClean ? "clean" : `${result.issues.length} issue(s)`
1618
+ }${settled ? (left.length === 0 ? "" : ", deferred") : ", fixing"}`,
1619
+ result
1620
+ )
1621
+ }),
1523
1622
  onTaskReply: (task, _reply, trailer, startedAt) => checkEvidence(task, trailer, startedAt),
1524
1623
  // The repository's own review rules ride along as one extra lens
1525
1624
  // (ADR 0027 decision 9), read from the epic checkout.
@@ -1528,8 +1627,22 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1528
1627
  ...(options.fixer === undefined ? {} : { fixer: options.fixer }),
1529
1628
  ...(options.stall === undefined ? {} : { stall: options.stall }),
1530
1629
  ...(options.reviewers === undefined ? {} : { reviewers: options.reviewers }),
1531
- ...(options.maxRounds === undefined ? {} : { maxRounds: options.maxRounds })
1630
+ ...(options.maxRounds === undefined ? {} : { maxRounds: options.maxRounds }),
1631
+ ...(deferring ? { settle: "blocking" as const } : {})
1532
1632
  })
1633
+ if (mergeFailure !== undefined) {
1634
+ // The branch already caught up with the epic above: the gates in the
1635
+ // worktree now see the combination that was red after the merge.
1636
+ yield* appendRevision(
1637
+ "make the epic gates green after merging",
1638
+ [
1639
+ `The story "${story.title}" was merged into ${epicBranch} and the epic's gates went red, so the merge was undone.`,
1640
+ `Your branch now includes everything merged into ${epicBranch} since you started: the failures`,
1641
+ "come from your change combined with that work. Fix them on your side."
1642
+ ].join("\n"),
1643
+ mergeFailure
1644
+ )
1645
+ }
1533
1646
  yield* guarded(implementTasks, implementTasks)
1534
1647
 
1535
1648
  // Other stories may have merged while this one ran: judge the branch
@@ -1540,33 +1653,13 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1540
1653
  // A judge finding becomes a revision task on the story's own plan, run by
1541
1654
  // the task loop like any other: coder, review, gates, commit.
1542
1655
  const addRevision = (verdict: ReviewResult, empty: boolean): Effect.Effect<void, FlowError> =>
1543
- Effect.gen(function* () {
1544
- const store = makePlanStore(files)
1545
- const current = yield* store.load(planPath(story))
1546
- if (current === undefined) {
1547
- return
1548
- }
1549
- const number = current.tasks.filter((task) => revisionTitle.test(task.title)).length + 1
1550
- yield* store.save(
1551
- planPath(story),
1552
- Plan.make({
1553
- ...current,
1554
- tasks: [
1555
- ...current.tasks,
1556
- Task.make({
1557
- title: `Revision ${number}: close the judge's findings`,
1558
- description: [
1559
- empty
1560
- ? `The story "${story.title}" has no changes yet, and what it must provide is not all in place on the epic branch.`
1561
- : `The story "${story.title}" was judged short of done.`,
1562
- "Close these gaps without weakening any test and without leaving your owned paths:",
1563
- issueLines(verdict)
1564
- ].join("\n")
1565
- })
1566
- ]
1567
- })
1568
- )
1569
- })
1656
+ appendRevision(
1657
+ "close the judge's findings",
1658
+ empty
1659
+ ? `The story "${story.title}" has no changes yet, and what it must provide is not all in place on the epic branch.`
1660
+ : `The story "${story.title}" was judged short of done.`,
1661
+ verdict
1662
+ )
1570
1663
 
1571
1664
  let judgeNote: string | undefined
1572
1665
  let inPlace = false
@@ -1684,11 +1777,17 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1684
1777
  dimensions: verdict instanceof StoryVerdict ? verdict.dimensions : []
1685
1778
  })
1686
1779
  )
1687
- if (verdict.isClean) {
1780
+ // Deferring, a verdict with nothing blocking clears: what it found
1781
+ // waits for the follow-up round instead of another revision.
1782
+ if (verdict.isClean || (deferring && !verdict.issues.some(isBlocking))) {
1783
+ const waiting = nonBlockingIssues(verdict)
1784
+ for (const issue of waiting) {
1785
+ deferred.push(deferredLine(`judge round ${round}`, issue))
1786
+ }
1688
1787
  inPlace = empty
1689
- judgeNote = empty
1690
- ? `verified already in place (round ${round})`
1691
- : `judge cleared (round ${round})`
1788
+ judgeNote = `${empty ? "verified already in place" : "judge cleared"} (round ${round}${
1789
+ waiting.length === 0 ? "" : `, ${waiting.length} finding(s) deferred`
1790
+ })`
1692
1791
  break
1693
1792
  }
1694
1793
  if (round >= judgeRounds) {
@@ -1709,6 +1808,19 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1709
1808
 
1710
1809
  const changed = yield* git.changedFilesVsBase(epicBranch)
1711
1810
  yield* enforcePerimeter(changed, story)
1811
+ if (deferring) {
1812
+ // A merge revision adds to what the story's first run deferred.
1813
+ if (mergeFailure !== undefined) {
1814
+ const earlier = yield* files.read(deferredPath(options.stateDir, story.id))
1815
+ deferred.unshift(...(earlier ?? "").split("\n").filter((line) => line.startsWith("- [")))
1816
+ }
1817
+ yield* deferred.length === 0
1818
+ ? files.remove(deferredPath(options.stateDir, story.id))
1819
+ : files.writeAtomic(
1820
+ deferredPath(options.stateDir, story.id),
1821
+ [`# Deferred from ${story.id}: ${story.title}`, "", ...new Set(deferred), ""].join("\n")
1822
+ )
1823
+ }
1712
1824
  const totals = seats.totals === undefined ? undefined : yield* seats.totals
1713
1825
  const history = roster === undefined ? [] : yield* roster.history
1714
1826
  return {
@@ -1736,14 +1848,38 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1736
1848
  judge: "merged on a previous run"
1737
1849
  })
1738
1850
  }
1739
- const result = yield* Effect.scoped(implementStory(story, state))
1740
- if (result.inPlace) {
1741
- // Verified already on the epic: there is nothing to merge or re-gate.
1851
+ let result = yield* Effect.scoped(implementStory(story, state))
1852
+ for (let revision = 0; ; revision += 1) {
1853
+ if (result.inPlace) {
1854
+ // Verified already on the epic: there is nothing to merge or re-gate.
1855
+ yield* laneOf(story).publish(
1856
+ Info.make({ message: `story ${story.id}: verified already in place; nothing to merge` })
1857
+ )
1858
+ break
1859
+ }
1860
+ const red = yield* integrate(story, state.branch)
1861
+ if (red === undefined) {
1862
+ break
1863
+ }
1864
+ const logs = red.issues.flatMap((issue) =>
1865
+ issue.logPath === undefined ? [] : [issue.logPath]
1866
+ )
1867
+ const why = `${issueLines(red)}${logs.length === 0 ? "" : `\nfull output: ${logs.join(", ")}`}`
1868
+ if (revision >= mergeRevisions) {
1869
+ return yield* failed(
1870
+ story,
1871
+ `epic gates failed after merging${
1872
+ revision === 0 ? "" : ` (after ${revision} merge revision${revision === 1 ? "" : "s"})`
1873
+ }; merge undone:\n${why}`
1874
+ )
1875
+ }
1876
+ yield* appendFindings(story, `epic gates after merging — red, merge undone; revising`, red)
1742
1877
  yield* laneOf(story).publish(
1743
- Info.make({ message: `story ${story.id}: verified already in place; nothing to merge` })
1878
+ Info.make({
1879
+ message: `story ${story.id}: epic gates red after merging; merge undone, sending it back to catch up and fix (merge revision ${revision + 1} of ${mergeRevisions})`
1880
+ })
1744
1881
  )
1745
- } else {
1746
- yield* integrate(story, state.branch)
1882
+ result = yield* Effect.scoped(implementStory(story, state, red))
1747
1883
  }
1748
1884
  const last = result.executor?.split(" → ").at(-1)
1749
1885
  yield* saveVersioned(