@llm4ts/flow 2.39.0 → 2.40.1
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/dist/GateTriage.d.ts +1 -1
- package/dist/GateTriage.d.ts.map +1 -1
- package/dist/GateTriage.js +4 -2
- package/dist/GateTriage.js.map +1 -1
- package/dist/Gates.d.ts +2 -0
- package/dist/Gates.d.ts.map +1 -1
- package/dist/Gates.js +11 -7
- package/dist/Gates.js.map +1 -1
- package/dist/Retro.d.ts.map +1 -1
- package/dist/Retro.js +4 -1
- package/dist/Retro.js.map +1 -1
- package/dist/Review.d.ts.map +1 -1
- package/dist/Review.js +3 -3
- package/dist/Review.js.map +1 -1
- package/dist/Stories.d.ts +7 -0
- package/dist/Stories.d.ts.map +1 -1
- package/dist/Stories.js +90 -40
- package/dist/Stories.js.map +1 -1
- package/package.json +2 -2
- package/src/GateTriage.ts +6 -2
- package/src/Gates.ts +13 -7
- package/src/Retro.ts +6 -1
- package/src/Review.ts +3 -3
- package/src/Stories.ts +128 -46
package/src/Stories.ts
CHANGED
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
ensureBaseline as ensureStoredBaseline,
|
|
28
28
|
failingLinesOf,
|
|
29
29
|
storyGateLogDir,
|
|
30
|
-
|
|
30
|
+
mergeGateLogDir,
|
|
31
31
|
writeBaseline,
|
|
32
32
|
type GateLogDir
|
|
33
33
|
} from "./Gates.ts"
|
|
@@ -703,6 +703,13 @@ export interface StoriesOptions {
|
|
|
703
703
|
* story's plan (coder, review, gates, commit). Default 2: one revision.
|
|
704
704
|
*/
|
|
705
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
|
|
706
713
|
/**
|
|
707
714
|
* How much of the code a story starts from goes into its coder's system
|
|
708
715
|
* prompt, in characters: the shared read-only files it uses, then its own.
|
|
@@ -871,6 +878,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
871
878
|
const epicBranch = options.epicBranch ?? `epic/${plan.epicId}`
|
|
872
879
|
const concurrency = Math.max(1, options.concurrency ?? 3)
|
|
873
880
|
const judgeRounds = Math.max(1, options.judgeRounds ?? 2)
|
|
881
|
+
const mergeRevisions = Math.max(0, options.mergeRevisions ?? 1)
|
|
874
882
|
const deferring = options.deferNonBlocking === true
|
|
875
883
|
const statePath = (story: Story): string => join(options.stateDir, `stories/${story.id}.json`)
|
|
876
884
|
const planPath = (story: Story): string => join(options.stateDir, `stories/${story.id}.plan.md`)
|
|
@@ -1010,8 +1018,15 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
1010
1018
|
|
|
1011
1019
|
const mergeLock = yield* Semaphore.make(1)
|
|
1012
1020
|
|
|
1013
|
-
/**
|
|
1014
|
-
|
|
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> =>
|
|
1015
1030
|
Effect.flatMap(Clock.currentTimeMillis, (requested) =>
|
|
1016
1031
|
Effect.andThen(
|
|
1017
1032
|
laneOf(story).publish(Began.make({ kind: "wait", label: "merge lock" })),
|
|
@@ -1032,22 +1047,36 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
1032
1047
|
Timed.make({ kind: "merge", label: "merge", ms, ...(failed ? { failed } : {}) }),
|
|
1033
1048
|
() => Began.make({ kind: "merge", label: "merge" })
|
|
1034
1049
|
)
|
|
1035
|
-
|
|
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
|
+
})
|
|
1036
1056
|
// Charged only with what the merge added: the head before it had
|
|
1037
|
-
// 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.
|
|
1038
1059
|
const before = yield* ensureBaseline(
|
|
1039
1060
|
checkpoint,
|
|
1040
1061
|
Effect.succeed(ReviewResult.make({ issues: [] })),
|
|
1041
1062
|
context.workDir
|
|
1042
1063
|
)
|
|
1043
|
-
const
|
|
1044
|
-
|
|
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) {
|
|
1045
1077
|
// Never leave a red epic head for the next story to inherit.
|
|
1046
1078
|
yield* context.git.rollback(checkpoint)
|
|
1047
|
-
return
|
|
1048
|
-
story,
|
|
1049
|
-
`epic gates failed after merging; merge undone:\n${issueLines(verdict.blocking)}`
|
|
1050
|
-
)
|
|
1079
|
+
return blocking
|
|
1051
1080
|
}
|
|
1052
1081
|
if (gateCommands !== undefined) {
|
|
1053
1082
|
const mergedHead = yield* context.git.checkpoint
|
|
@@ -1061,6 +1090,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
1061
1090
|
yield* laneOf(story).publish(
|
|
1062
1091
|
Info.make({ message: `story ${story.id}: merged into ${epicBranch}` })
|
|
1063
1092
|
)
|
|
1093
|
+
return undefined
|
|
1064
1094
|
})
|
|
1065
1095
|
)
|
|
1066
1096
|
)
|
|
@@ -1173,7 +1203,9 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
1173
1203
|
/** Everything that happens inside a story's worktree: plan, implement, judge, perimeter. */
|
|
1174
1204
|
const implementStory = Effect.fn("@llm4ts/flow/Stories.implementStory")(function* (
|
|
1175
1205
|
story: Story,
|
|
1176
|
-
state: StoryState
|
|
1206
|
+
state: StoryState,
|
|
1207
|
+
/** The epic gates' failures after the story's last merge: run as a revision task. */
|
|
1208
|
+
mergeFailure?: ReviewResult
|
|
1177
1209
|
): Effect.fn.Return<
|
|
1178
1210
|
{
|
|
1179
1211
|
readonly judge: string | undefined
|
|
@@ -1497,6 +1529,34 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
1497
1529
|
yield* git.commitAll(`${story.id}: put paths outside the perimeter back`)
|
|
1498
1530
|
})
|
|
1499
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
|
+
|
|
1500
1560
|
const planTasks = options.planTasks ?? defaultPlanTasks
|
|
1501
1561
|
// Taken after catch-up and setup: the code as the first task finds it.
|
|
1502
1562
|
const startingCode = yield* startingCodeOf(
|
|
@@ -1570,6 +1630,19 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
1570
1630
|
...(options.maxRounds === undefined ? {} : { maxRounds: options.maxRounds }),
|
|
1571
1631
|
...(deferring ? { settle: "blocking" as const } : {})
|
|
1572
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
|
+
}
|
|
1573
1646
|
yield* guarded(implementTasks, implementTasks)
|
|
1574
1647
|
|
|
1575
1648
|
// Other stories may have merged while this one ran: judge the branch
|
|
@@ -1580,33 +1653,13 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
1580
1653
|
// A judge finding becomes a revision task on the story's own plan, run by
|
|
1581
1654
|
// the task loop like any other: coder, review, gates, commit.
|
|
1582
1655
|
const addRevision = (verdict: ReviewResult, empty: boolean): Effect.Effect<void, FlowError> =>
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
yield* store.save(
|
|
1591
|
-
planPath(story),
|
|
1592
|
-
Plan.make({
|
|
1593
|
-
...current,
|
|
1594
|
-
tasks: [
|
|
1595
|
-
...current.tasks,
|
|
1596
|
-
Task.make({
|
|
1597
|
-
title: `Revision ${number}: close the judge's findings`,
|
|
1598
|
-
description: [
|
|
1599
|
-
empty
|
|
1600
|
-
? `The story "${story.title}" has no changes yet, and what it must provide is not all in place on the epic branch.`
|
|
1601
|
-
: `The story "${story.title}" was judged short of done.`,
|
|
1602
|
-
"Close these gaps without weakening any test and without leaving your owned paths:",
|
|
1603
|
-
issueLines(verdict)
|
|
1604
|
-
].join("\n")
|
|
1605
|
-
})
|
|
1606
|
-
]
|
|
1607
|
-
})
|
|
1608
|
-
)
|
|
1609
|
-
})
|
|
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
|
+
)
|
|
1610
1663
|
|
|
1611
1664
|
let judgeNote: string | undefined
|
|
1612
1665
|
let inPlace = false
|
|
@@ -1756,11 +1809,16 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
1756
1809
|
const changed = yield* git.changedFilesVsBase(epicBranch)
|
|
1757
1810
|
yield* enforcePerimeter(changed, story)
|
|
1758
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
|
+
}
|
|
1759
1817
|
yield* deferred.length === 0
|
|
1760
1818
|
? files.remove(deferredPath(options.stateDir, story.id))
|
|
1761
1819
|
: files.writeAtomic(
|
|
1762
1820
|
deferredPath(options.stateDir, story.id),
|
|
1763
|
-
[`# Deferred from ${story.id}: ${story.title}`, "", ...deferred, ""].join("\n")
|
|
1821
|
+
[`# Deferred from ${story.id}: ${story.title}`, "", ...new Set(deferred), ""].join("\n")
|
|
1764
1822
|
)
|
|
1765
1823
|
}
|
|
1766
1824
|
const totals = seats.totals === undefined ? undefined : yield* seats.totals
|
|
@@ -1790,14 +1848,38 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
1790
1848
|
judge: "merged on a previous run"
|
|
1791
1849
|
})
|
|
1792
1850
|
}
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
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)
|
|
1796
1877
|
yield* laneOf(story).publish(
|
|
1797
|
-
Info.make({
|
|
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
|
+
})
|
|
1798
1881
|
)
|
|
1799
|
-
|
|
1800
|
-
yield* integrate(story, state.branch)
|
|
1882
|
+
result = yield* Effect.scoped(implementStory(story, state, red))
|
|
1801
1883
|
}
|
|
1802
1884
|
const last = result.executor?.split(" → ").at(-1)
|
|
1803
1885
|
yield* saveVersioned(
|