@llm4ts/flow 2.41.0 → 2.42.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/dist/Flow.d.ts +26 -1
- package/dist/Flow.d.ts.map +1 -1
- package/dist/Flow.js +155 -133
- package/dist/Flow.js.map +1 -1
- package/dist/FlowContext.d.ts +5 -0
- package/dist/FlowContext.d.ts.map +1 -1
- package/dist/FlowContext.js.map +1 -1
- package/dist/FlowEvents.d.ts +4 -0
- package/dist/FlowEvents.d.ts.map +1 -1
- package/dist/FlowEvents.js +9 -1
- package/dist/FlowEvents.js.map +1 -1
- package/dist/Perimeter.d.ts +7 -0
- package/dist/Perimeter.d.ts.map +1 -1
- package/dist/Perimeter.js +31 -0
- package/dist/Perimeter.js.map +1 -1
- package/dist/Plan.d.ts +24 -0
- package/dist/Plan.d.ts.map +1 -1
- package/dist/Plan.js +52 -0
- package/dist/Plan.js.map +1 -1
- package/dist/PlanExecution.d.ts +37 -2
- package/dist/PlanExecution.d.ts.map +1 -1
- package/dist/PlanExecution.js +106 -1
- package/dist/PlanExecution.js.map +1 -1
- package/dist/RosterSeats.d.ts +9 -2
- package/dist/RosterSeats.d.ts.map +1 -1
- package/dist/RosterSeats.js +11 -5
- package/dist/RosterSeats.js.map +1 -1
- package/dist/Stories.d.ts +7 -0
- package/dist/Stories.d.ts.map +1 -1
- package/dist/Stories.js +153 -11
- package/dist/Stories.js.map +1 -1
- package/package.json +2 -2
- package/src/Flow.ts +108 -34
- package/src/FlowContext.ts +5 -0
- package/src/FlowEvents.ts +9 -1
- package/src/Perimeter.ts +36 -0
- package/src/Plan.ts +71 -0
- package/src/PlanExecution.ts +188 -2
- package/src/RosterSeats.ts +28 -7
- package/src/Stories.ts +226 -15
package/src/Stories.ts
CHANGED
|
@@ -20,7 +20,7 @@ import type { LlmChunk, TokenUsage } from "@llm4ts/core/Models"
|
|
|
20
20
|
import type { LlmError } from "@llm4ts/core/Errors"
|
|
21
21
|
import { BoardItem, eventedBoard, type BoardSyncShape } from "./BoardSync.ts"
|
|
22
22
|
import { makeChat } from "./Chat.ts"
|
|
23
|
-
import { implementPlanFlow } from "./Flow.ts"
|
|
23
|
+
import { implementPlanFlow, type TaskOverrides } from "./Flow.ts"
|
|
24
24
|
import {
|
|
25
25
|
GateBaseline,
|
|
26
26
|
baselineKey,
|
|
@@ -58,6 +58,7 @@ import {
|
|
|
58
58
|
enforcePerimeter,
|
|
59
59
|
isWithinPerimeter,
|
|
60
60
|
perimeterGate,
|
|
61
|
+
taskOwnsGate,
|
|
61
62
|
strayTasks,
|
|
62
63
|
type PerimeterCheck
|
|
63
64
|
} from "./Perimeter.ts"
|
|
@@ -68,7 +69,7 @@ import {
|
|
|
68
69
|
type PlainFileStoreShape
|
|
69
70
|
} from "./Persistence.ts"
|
|
70
71
|
import { Plan, Task } from "./Plan.ts"
|
|
71
|
-
import { stage } from "./PlanExecution.ts"
|
|
72
|
+
import { stage, type AsideOutcome, type AsideRequest } from "./PlanExecution.ts"
|
|
72
73
|
import { planFrom } from "./Planner.ts"
|
|
73
74
|
import {
|
|
74
75
|
ReviewIssue,
|
|
@@ -459,6 +460,10 @@ export const storyTaskPlanInstructions = (story: Story): string =>
|
|
|
459
460
|
"End every task's description with `Satisfies: <n>` naming the criteria it serves, and",
|
|
460
461
|
"make sure every criterion is served by at least one task."
|
|
461
462
|
]),
|
|
463
|
+
"End every task's description with a `Depends on: <n, …>` line naming the earlier tasks it",
|
|
464
|
+
"needs done first (`Depends on: none` when it needs none; without the line it waits for the",
|
|
465
|
+
"task before it), and an `Owns: <paths>` line naming the files it changes. Tasks that need",
|
|
466
|
+
"nothing from each other and own different files may run at the same time.",
|
|
462
467
|
"Never plan a task that creates or changes anything outside them — registering or wiring",
|
|
463
468
|
"the story in elsewhere (the app's composition point, a shared kit file) is another story's job.",
|
|
464
469
|
`Use exactly this epicId: "${story.id}".`,
|
|
@@ -727,6 +732,13 @@ export interface StoriesOptions {
|
|
|
727
732
|
) => Effect.Effect<Plan, FlowError>
|
|
728
733
|
/** Stories implemented at once. Default 3. */
|
|
729
734
|
readonly concurrency?: number
|
|
735
|
+
/**
|
|
736
|
+
* Coders one story may hold at once, its own included (ADR 0034): a
|
|
737
|
+
* ready task whose plan says it may runs beside the held coder, in its
|
|
738
|
+
* own worktree, on a coder that is free now. Default 2; 1 runs every task
|
|
739
|
+
* in order on the story's coder.
|
|
740
|
+
*/
|
|
741
|
+
readonly taskConcurrency?: number
|
|
730
742
|
/** Stop the epic at the first failed story instead of skipping its dependents. */
|
|
731
743
|
readonly failFast?: boolean
|
|
732
744
|
readonly reviewers?: ReadonlyArray<Reviewer>
|
|
@@ -881,6 +893,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
881
893
|
const concurrency = Math.max(1, options.concurrency ?? 3)
|
|
882
894
|
const judgeRounds = Math.max(1, options.judgeRounds ?? 2)
|
|
883
895
|
const mergeRevisions = Math.max(0, options.mergeRevisions ?? 1)
|
|
896
|
+
const taskConcurrency = Math.max(1, options.taskConcurrency ?? 2)
|
|
884
897
|
const deferring = options.deferNonBlocking === true
|
|
885
898
|
const statePath = (story: Story): string => join(options.stateDir, `stories/${story.id}.json`)
|
|
886
899
|
const planPath = (story: Story): string => join(options.stateDir, `stories/${story.id}.plan.md`)
|
|
@@ -1362,12 +1375,15 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
1362
1375
|
)
|
|
1363
1376
|
// The oracle guard over the story's whole change against the epic:
|
|
1364
1377
|
// committed on the branch and uncommitted in the worktree (ADR 0027).
|
|
1365
|
-
const oracleGate = (
|
|
1378
|
+
const oracleGate = (
|
|
1379
|
+
target: ReviewResult,
|
|
1380
|
+
where: GitToolShape = git
|
|
1381
|
+
): Effect.Effect<ReviewResult, FlowError> =>
|
|
1366
1382
|
story.testsChange
|
|
1367
1383
|
? Effect.succeed(ReviewResult.make({ issues: [] }))
|
|
1368
1384
|
: Effect.gen(function* () {
|
|
1369
|
-
const committed = yield*
|
|
1370
|
-
const uncommitted = yield*
|
|
1385
|
+
const committed = yield* where.diffVsBase(epicBranch)
|
|
1386
|
+
const uncommitted = yield* where.diffAll
|
|
1371
1387
|
const base =
|
|
1372
1388
|
gateCommands === undefined ? undefined : (yield* storyBaseline)?.passedCount
|
|
1373
1389
|
const issues = checkOracle(
|
|
@@ -1378,17 +1394,41 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
1378
1394
|
)
|
|
1379
1395
|
return ReviewResult.make({ issues, summary: issues.length === 0 ? "" : "oracle guard" })
|
|
1380
1396
|
})
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1397
|
+
/**
|
|
1398
|
+
* The gates in a worktree of the story: the target's, the story's
|
|
1399
|
+
* perimeter, the oracle guard and — for a task run aside — the paths the
|
|
1400
|
+
* task said it owns, against the story branch it started from.
|
|
1401
|
+
*/
|
|
1402
|
+
const gatesAt = (
|
|
1403
|
+
workDir: string,
|
|
1404
|
+
where: GitToolShape,
|
|
1405
|
+
logDir: string,
|
|
1406
|
+
task?: { readonly owns: ReadonlyArray<string>; readonly from: string }
|
|
1407
|
+
): Effect.Effect<ReviewResult, FlowError> =>
|
|
1408
|
+
Effect.gen(function* () {
|
|
1409
|
+
const target = yield* options.gates(workDir, laneOf(story), { files, dir: logDir })
|
|
1410
|
+
const changed = yield* perimeterNow(story, where)
|
|
1411
|
+
const owned =
|
|
1412
|
+
task === undefined || task.owns.length === 0
|
|
1413
|
+
? ReviewResult.make({ issues: [] })
|
|
1414
|
+
: taskOwnsGate(
|
|
1415
|
+
[
|
|
1416
|
+
...new Set([
|
|
1417
|
+
...(yield* where.changedFilesVsBase(task.from)),
|
|
1418
|
+
...(yield* where.uncommittedFiles)
|
|
1419
|
+
])
|
|
1420
|
+
],
|
|
1421
|
+
task.owns
|
|
1422
|
+
)
|
|
1423
|
+
return combined(
|
|
1424
|
+
combined(
|
|
1425
|
+
combined(target, perimeterGate([...changed.sharedReadOnly, ...changed.outside], story)),
|
|
1426
|
+
yield* oracleGate(target, where)
|
|
1427
|
+
),
|
|
1428
|
+
owned
|
|
1429
|
+
)
|
|
1385
1430
|
})
|
|
1386
|
-
|
|
1387
|
-
return combined(
|
|
1388
|
-
combined(target, perimeterGate([...changed.sharedReadOnly, ...changed.outside], story)),
|
|
1389
|
-
yield* oracleGate(target)
|
|
1390
|
-
)
|
|
1391
|
-
})
|
|
1431
|
+
const gates = gatesAt(state.worktree, git, storyGateLogDir(options.stateDir, story.id))
|
|
1392
1432
|
const triage: GateTriageOptions = {
|
|
1393
1433
|
baseline: storyBaseline,
|
|
1394
1434
|
roots: rootsOf(state.worktree),
|
|
@@ -1571,6 +1611,147 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
1571
1611
|
const taskSystem = [system, startingCode]
|
|
1572
1612
|
.filter((part): part is string => part !== undefined)
|
|
1573
1613
|
.join("\n\n")
|
|
1614
|
+
/** Tasks merged in from beside the held coder this run: the story's gates rerun after them. */
|
|
1615
|
+
let mergedAside = 0
|
|
1616
|
+
/**
|
|
1617
|
+
* A ready task beside the held coder (ADR 0034): a coder free now, a
|
|
1618
|
+
* worktree on its own branch from the story branch, the task's whole
|
|
1619
|
+
* body there — coder, review, gates with its `Owns:` — then a merge
|
|
1620
|
+
* into the story branch while no task runs at home. Anything short of
|
|
1621
|
+
* a clean merge hands the task back to the held coder; a conflict
|
|
1622
|
+
* hands it back to run after the others.
|
|
1623
|
+
*/
|
|
1624
|
+
const runAside = (
|
|
1625
|
+
request: AsideRequest,
|
|
1626
|
+
run: (at: FlowContextShape, overrides: TaskOverrides) => Effect.Effect<void, FlowError>
|
|
1627
|
+
): Effect.Effect<AsideOutcome, FlowError> => {
|
|
1628
|
+
const { node } = request
|
|
1629
|
+
const where = join(options.worktreeRoot, `${story.id}.task-${node.index}`)
|
|
1630
|
+
const branch = `${state.branch}--task-${node.index}`
|
|
1631
|
+
const lane = laneOf(story)
|
|
1632
|
+
const said = (message: string) =>
|
|
1633
|
+
lane.publish(Info.make({ message: `story ${story.id}: task ${node.index} ${message}` }))
|
|
1634
|
+
const cleanUp = Effect.gen(function* () {
|
|
1635
|
+
yield* context.git.removeWorktree(where, true).pipe(Effect.ignore)
|
|
1636
|
+
if (yield* context.git.branchExists(branch).pipe(Effect.orElseSucceed(() => false))) {
|
|
1637
|
+
yield* context.git.deleteBranch(branch).pipe(Effect.ignore)
|
|
1638
|
+
}
|
|
1639
|
+
})
|
|
1640
|
+
const attempt = Effect.gen(function* () {
|
|
1641
|
+
// No disk work while no coder is free: the held coder takes the task.
|
|
1642
|
+
if (roster !== undefined && (yield* roster.available("coder")) === 0) {
|
|
1643
|
+
return "declined" as const
|
|
1644
|
+
}
|
|
1645
|
+
// A previous run's leftovers go first.
|
|
1646
|
+
yield* cleanUp
|
|
1647
|
+
yield* context.git.addWorktreeNewBranch(where, branch, state.branch)
|
|
1648
|
+
// The coder is held for the work only: its slot is free again before
|
|
1649
|
+
// the merge waits for the story's coder to finish its own task.
|
|
1650
|
+
const blockedOn = yield* Effect.scoped(
|
|
1651
|
+
Effect.gen(function* () {
|
|
1652
|
+
const asideSeats = yield* options.contextFor(where, {
|
|
1653
|
+
label: `${story.id}#t${node.index}`,
|
|
1654
|
+
ifFree: true
|
|
1655
|
+
})
|
|
1656
|
+
const asideRoster = asideSeats.context.roster
|
|
1657
|
+
yield* request.announce({
|
|
1658
|
+
...(asideRoster === undefined
|
|
1659
|
+
? {}
|
|
1660
|
+
: {
|
|
1661
|
+
...(yield* Effect.map(asideRoster.executor, (executor) =>
|
|
1662
|
+
executor === undefined ? {} : { executor }
|
|
1663
|
+
)),
|
|
1664
|
+
...(asideRoster.clone === undefined
|
|
1665
|
+
? {}
|
|
1666
|
+
: yield* Effect.map(asideRoster.clone, (clone) =>
|
|
1667
|
+
clone === undefined ? {} : { clone }
|
|
1668
|
+
))
|
|
1669
|
+
})
|
|
1670
|
+
})
|
|
1671
|
+
if (options.setup !== undefined) {
|
|
1672
|
+
yield* options.setup(where, lane)
|
|
1673
|
+
}
|
|
1674
|
+
const asideBlocked = yield* Ref.make<string | undefined>(undefined)
|
|
1675
|
+
const asideContext: FlowContextShape = {
|
|
1676
|
+
...asideSeats.context,
|
|
1677
|
+
coder: watchForBlockedOn(asideSeats.context.coder, asideBlocked)
|
|
1678
|
+
}
|
|
1679
|
+
const asideGit = asideContext.git
|
|
1680
|
+
const asideSystem = [
|
|
1681
|
+
withContract(
|
|
1682
|
+
[
|
|
1683
|
+
perimeterRules(story, { plan, worktree: where, epicCheckout: context.workDir }),
|
|
1684
|
+
gateRules,
|
|
1685
|
+
extra,
|
|
1686
|
+
node.owns.length === 0
|
|
1687
|
+
? undefined
|
|
1688
|
+
: `This task owns ${node.owns.join(", ")}: another task of this story may be changing other files right now, so change nothing else.`
|
|
1689
|
+
]
|
|
1690
|
+
.filter((part): part is string => part !== undefined && part.trim().length > 0)
|
|
1691
|
+
.join("\n\n"),
|
|
1692
|
+
contractProfile
|
|
1693
|
+
),
|
|
1694
|
+
startingCode
|
|
1695
|
+
]
|
|
1696
|
+
.filter((part): part is string => part !== undefined)
|
|
1697
|
+
.join("\n\n")
|
|
1698
|
+
yield* run(asideContext, {
|
|
1699
|
+
lint: gatesAt(
|
|
1700
|
+
where,
|
|
1701
|
+
asideGit,
|
|
1702
|
+
join(storyGateLogDir(options.stateDir, story.id), `task-${node.index}`),
|
|
1703
|
+
{ owns: node.owns, from: state.branch }
|
|
1704
|
+
),
|
|
1705
|
+
triage: {
|
|
1706
|
+
baseline: storyBaseline,
|
|
1707
|
+
roots: rootsOf(where),
|
|
1708
|
+
...(options.testGate === undefined
|
|
1709
|
+
? {}
|
|
1710
|
+
: { rerunTest: options.testGate(where, lane) })
|
|
1711
|
+
},
|
|
1712
|
+
// Its tool calls are on its own lane's transcript, not the story's.
|
|
1713
|
+
onTaskReply: undefined,
|
|
1714
|
+
system: asideSystem
|
|
1715
|
+
})
|
|
1716
|
+
return yield* Ref.get(asideBlocked)
|
|
1717
|
+
})
|
|
1718
|
+
)
|
|
1719
|
+
if (blockedOn !== undefined) {
|
|
1720
|
+
yield* said("stopped on BLOCKED_ON aside; the story's coder takes it")
|
|
1721
|
+
return "declined" as const
|
|
1722
|
+
}
|
|
1723
|
+
return yield* request.exclusive(
|
|
1724
|
+
Effect.gen(function* () {
|
|
1725
|
+
yield* git.merge(branch, `${story.id}: merge task ${node.index} (${node.task.title})`)
|
|
1726
|
+
mergedAside += 1
|
|
1727
|
+
yield* said(`merged from beside the story's coder`)
|
|
1728
|
+
return "merged" as const
|
|
1729
|
+
})
|
|
1730
|
+
)
|
|
1731
|
+
})
|
|
1732
|
+
return Effect.scoped(
|
|
1733
|
+
Effect.gen(function* () {
|
|
1734
|
+
yield* Effect.addFinalizer(() => cleanUp)
|
|
1735
|
+
return yield* attempt
|
|
1736
|
+
})
|
|
1737
|
+
).pipe(
|
|
1738
|
+
Effect.catch((error) =>
|
|
1739
|
+
error._tag === "MergeConflict"
|
|
1740
|
+
? Effect.as(
|
|
1741
|
+
said("conflicted merging back; the story's coder runs it last"),
|
|
1742
|
+
"conflict" as const
|
|
1743
|
+
)
|
|
1744
|
+
: error._tag === "RosterExhausted"
|
|
1745
|
+
? Effect.succeed("declined" as const)
|
|
1746
|
+
: Effect.as(
|
|
1747
|
+
said(
|
|
1748
|
+
`could not finish aside (${error.message.split("\n")[0] ?? ""}); the story's coder takes it`
|
|
1749
|
+
),
|
|
1750
|
+
"declined" as const
|
|
1751
|
+
)
|
|
1752
|
+
)
|
|
1753
|
+
)
|
|
1754
|
+
}
|
|
1574
1755
|
const implementTasks = implementPlanFlow(storyContext, {
|
|
1575
1756
|
store: makePlanStore(files),
|
|
1576
1757
|
planPath: planPath(story),
|
|
@@ -1588,6 +1769,9 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
1588
1769
|
),
|
|
1589
1770
|
system: taskSystem,
|
|
1590
1771
|
chatPerTask: true,
|
|
1772
|
+
...(taskConcurrency > 1
|
|
1773
|
+
? { parallel: { concurrency: taskConcurrency, aside: runAside } }
|
|
1774
|
+
: {}),
|
|
1591
1775
|
carry: {
|
|
1592
1776
|
read: files.read(notesPath(story)),
|
|
1593
1777
|
write: (notes) => files.writeAtomic(notesPath(story), notes)
|
|
@@ -1646,6 +1830,33 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
1646
1830
|
)
|
|
1647
1831
|
}
|
|
1648
1832
|
yield* guarded(implementTasks, implementTasks)
|
|
1833
|
+
// Tasks merged in from beside the held coder were each green alone: the
|
|
1834
|
+
// story's gates now run on them together, and red goes back to the
|
|
1835
|
+
// story's coder as a revision, as a red epic merge does (ADR 0034).
|
|
1836
|
+
for (let revision = 0; mergedAside > 0; revision += 1) {
|
|
1837
|
+
mergedAside = 0
|
|
1838
|
+
const reported = yield* Ref.make<ReadonlySet<string>>(new Set())
|
|
1839
|
+
const together = yield* stage(
|
|
1840
|
+
laneOf(story),
|
|
1841
|
+
`story ${story.id}: gates after parallel tasks`,
|
|
1842
|
+
Effect.flatMap(gates, (result) => applyTriage(result, triage, laneOf(story), reported))
|
|
1843
|
+
)
|
|
1844
|
+
if (together.isClean) {
|
|
1845
|
+
break
|
|
1846
|
+
}
|
|
1847
|
+
if (revision >= mergeRevisions) {
|
|
1848
|
+
return yield* failed(
|
|
1849
|
+
story,
|
|
1850
|
+
`its gates are red with its parallel tasks merged together:\n${issueLines(together)}`
|
|
1851
|
+
)
|
|
1852
|
+
}
|
|
1853
|
+
yield* appendRevision(
|
|
1854
|
+
"make the story gates green after merging its parallel tasks",
|
|
1855
|
+
`Tasks of the story "${story.title}" ran at the same time and were merged together; the gates are red on the result.`,
|
|
1856
|
+
together
|
|
1857
|
+
)
|
|
1858
|
+
yield* guarded(implementTasks, implementTasks)
|
|
1859
|
+
}
|
|
1649
1860
|
|
|
1650
1861
|
// Other stories may have merged while this one ran: judge the branch
|
|
1651
1862
|
// against the epic as it is now.
|