@llm4ts/flow 2.18.0 → 2.19.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/Approval.d.ts +1 -1
- package/dist/Approval.d.ts.map +1 -1
- package/dist/Artifacts.d.ts +2 -2
- package/dist/Artifacts.d.ts.map +1 -1
- package/dist/BenchReport.d.ts +2 -2
- package/dist/BenchReport.d.ts.map +1 -1
- package/dist/CostLedger.d.ts +2 -2
- package/dist/CostLedger.d.ts.map +1 -1
- package/dist/Equiv.d.ts +3 -3
- package/dist/Equiv.d.ts.map +1 -1
- package/dist/Flow.d.ts +1 -1
- package/dist/Flow.d.ts.map +1 -1
- package/dist/FlowError.d.ts +9 -1
- package/dist/FlowError.d.ts.map +1 -1
- package/dist/FlowError.js +11 -1
- package/dist/FlowError.js.map +1 -1
- package/dist/Judgment.d.ts +2 -2
- package/dist/Judgment.d.ts.map +1 -1
- package/dist/Landing.d.ts +9 -1
- package/dist/Landing.d.ts.map +1 -1
- package/dist/Landing.js +16 -5
- package/dist/Landing.js.map +1 -1
- package/dist/Persistence.d.ts +2 -2
- package/dist/Persistence.d.ts.map +1 -1
- package/dist/PlanExecution.d.ts +1 -1
- package/dist/PlanExecution.d.ts.map +1 -1
- package/dist/ProgramJudge.d.ts +1 -1
- package/dist/ProgramJudge.d.ts.map +1 -1
- package/dist/RefineRound.d.ts +94 -0
- package/dist/RefineRound.d.ts.map +1 -0
- package/dist/RefineRound.js +157 -0
- package/dist/RefineRound.js.map +1 -0
- package/dist/Replay.d.ts +2 -2
- package/dist/Replay.d.ts.map +1 -1
- package/dist/Review.d.ts +3 -3
- package/dist/Review.d.ts.map +1 -1
- package/dist/ReviewCache.d.ts +1 -1
- package/dist/ReviewCache.d.ts.map +1 -1
- package/dist/Roster.d.ts +1 -1
- package/dist/Roster.d.ts.map +1 -1
- package/dist/RosterSeats.d.ts +1 -1
- package/dist/RosterSeats.d.ts.map +1 -1
- package/dist/Stories.d.ts +1 -1
- package/dist/Stories.d.ts.map +1 -1
- package/package.json +3 -2
- package/src/FlowError.ts +12 -1
- package/src/Landing.ts +24 -6
- package/src/RefineRound.ts +227 -0
package/src/Landing.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { statusPaths } from "./GitTool.ts"
|
|
|
15
15
|
import { loadVersioned, saveVersioned, type PlainFileStoreShape } from "./Persistence.ts"
|
|
16
16
|
import type { ReviewResult } from "./Review.ts"
|
|
17
17
|
import { StoryState, StoryStateVersion } from "./Stories.ts"
|
|
18
|
-
import type { StoryPlan } from "./StoryPlan.ts"
|
|
18
|
+
import type { Story, StoryPlan } from "./StoryPlan.ts"
|
|
19
19
|
|
|
20
20
|
const join = (root: string, path: string): string =>
|
|
21
21
|
`${root.replace(/[\\/]+$/, "")}/${path.replace(/^[\\/]+/, "")}`
|
|
@@ -49,6 +49,11 @@ export interface LandOptions {
|
|
|
49
49
|
readonly maxRounds?: number
|
|
50
50
|
/** Keep the story worktrees and branches after landing. Default false: they go. */
|
|
51
51
|
readonly keepWorktrees?: boolean
|
|
52
|
+
/**
|
|
53
|
+
* The epic's refine rounds (ADR 0021), each with its own plan and state
|
|
54
|
+
* folder: they must be merged too, and their worktrees go with the plan's.
|
|
55
|
+
*/
|
|
56
|
+
readonly rounds?: ReadonlyArray<{ readonly plan: StoryPlan; readonly stateDir: string }>
|
|
52
57
|
}
|
|
53
58
|
|
|
54
59
|
export interface LandReport {
|
|
@@ -88,11 +93,15 @@ export const landEpic = Effect.fn("@llm4ts/flow/Landing.land")(function* (
|
|
|
88
93
|
const failed = (reason: string) => LandingFailed.make({ epicBranch, target, reason })
|
|
89
94
|
|
|
90
95
|
// 1. Only a finished epic lands.
|
|
96
|
+
// The plan's stories, then each round's, every one against its own state folder.
|
|
97
|
+
const tracked = [{ plan, stateDir: options.stateDir }, ...(options.rounds ?? [])].flatMap(
|
|
98
|
+
(part) => part.plan.stories.map((story) => ({ id: story.id, stateDir: part.stateDir }))
|
|
99
|
+
)
|
|
91
100
|
const unmerged: Array<string> = []
|
|
92
|
-
for (const story of
|
|
101
|
+
for (const story of tracked) {
|
|
93
102
|
const state = yield* loadVersioned(
|
|
94
103
|
files,
|
|
95
|
-
join(
|
|
104
|
+
join(story.stateDir, `stories/${story.id}.json`),
|
|
96
105
|
StoryStateVersion,
|
|
97
106
|
StoryState
|
|
98
107
|
)
|
|
@@ -133,7 +142,16 @@ export const landEpic = Effect.fn("@llm4ts/flow/Landing.land")(function* (
|
|
|
133
142
|
events,
|
|
134
143
|
agent: "coder"
|
|
135
144
|
})
|
|
136
|
-
const
|
|
145
|
+
const provided = (story: Story): string => `- ${story.id}: ${story.provides.join("; ")}`
|
|
146
|
+
// A round changed what the plan's stories built (it may have removed some of
|
|
147
|
+
// it): the coder must not resolve a conflict back to the older intent.
|
|
148
|
+
const stories = [
|
|
149
|
+
...plan.stories.map(provided),
|
|
150
|
+
...(options.rounds ?? []).flatMap((round, index) => [
|
|
151
|
+
`and later changed on feedback (refine round ${index + 1}, which wins over the above):`,
|
|
152
|
+
...round.plan.stories.map(provided)
|
|
153
|
+
])
|
|
154
|
+
]
|
|
137
155
|
let rounds = 0
|
|
138
156
|
while (true) {
|
|
139
157
|
const withMarkers: Array<string> = []
|
|
@@ -211,10 +229,10 @@ export const landEpic = Effect.fn("@llm4ts/flow/Landing.land")(function* (
|
|
|
211
229
|
let deletedBranches = 0
|
|
212
230
|
if (options.keepWorktrees !== true) {
|
|
213
231
|
const kept: Array<string> = []
|
|
214
|
-
for (const story of
|
|
232
|
+
for (const story of tracked) {
|
|
215
233
|
const state = yield* loadVersioned(
|
|
216
234
|
files,
|
|
217
|
-
join(
|
|
235
|
+
join(story.stateDir, `stories/${story.id}.json`),
|
|
218
236
|
StoryStateVersion,
|
|
219
237
|
StoryState
|
|
220
238
|
)
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// Refine rounds (ADR 0021): follow-up stories on a finished epic, planned
|
|
2
|
+
// from feedback. A round is its own story plan on the epic's branch, so the
|
|
3
|
+
// executor and the ownership rule are the ones a plan has. Pure: reading the
|
|
4
|
+
// round folders and asking the planner belong to the flow.
|
|
5
|
+
import * as Effect from "effect/Effect"
|
|
6
|
+
import * as Schema from "effect/Schema"
|
|
7
|
+
import type { JsonSchema } from "@llm4ts/core/Models"
|
|
8
|
+
import { Story, StoryPlan } from "./StoryPlan.ts"
|
|
9
|
+
|
|
10
|
+
/** A feedback item the planner left out, with the reason or the question to answer. */
|
|
11
|
+
export class NotPlanned extends Schema.Class<NotPlanned>("NotPlanned")({
|
|
12
|
+
item: Schema.String,
|
|
13
|
+
reason: Schema.String
|
|
14
|
+
}) {}
|
|
15
|
+
|
|
16
|
+
/** What the planner returns for a round: the stories it can place, and the rest. */
|
|
17
|
+
export class RefineProposal extends Schema.Class<RefineProposal>("RefineProposal")({
|
|
18
|
+
stories: Schema.Array(Story),
|
|
19
|
+
notPlanned: Schema.Array(NotPlanned).pipe(
|
|
20
|
+
Schema.withConstructorDefault(Effect.succeed([])),
|
|
21
|
+
Schema.withDecodingDefaultKey(Effect.succeed([]))
|
|
22
|
+
)
|
|
23
|
+
}) {}
|
|
24
|
+
|
|
25
|
+
const strings: JsonSchema = { type: "array", items: { type: "string" } }
|
|
26
|
+
|
|
27
|
+
export const refineProposalJsonSchema: JsonSchema = {
|
|
28
|
+
type: "object",
|
|
29
|
+
properties: {
|
|
30
|
+
stories: {
|
|
31
|
+
type: "array",
|
|
32
|
+
items: {
|
|
33
|
+
type: "object",
|
|
34
|
+
properties: {
|
|
35
|
+
id: { type: "string" },
|
|
36
|
+
title: { type: "string" },
|
|
37
|
+
description: { type: "string" },
|
|
38
|
+
dependsOn: strings,
|
|
39
|
+
owned: strings,
|
|
40
|
+
sharedReadOnly: strings,
|
|
41
|
+
provides: strings
|
|
42
|
+
},
|
|
43
|
+
required: ["id", "title", "description", "dependsOn", "owned", "sharedReadOnly", "provides"]
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
notPlanned: {
|
|
47
|
+
type: "array",
|
|
48
|
+
items: {
|
|
49
|
+
type: "object",
|
|
50
|
+
properties: { item: { type: "string" }, reason: { type: "string" } },
|
|
51
|
+
required: ["item", "reason"]
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
required: ["stories", "notPlanned"]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Round stories are `r<n>-…`: unique across the epic, whatever the planner called them. */
|
|
59
|
+
export const roundPrefix = (round: number): string => `r${round}-`
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The round's story plan from a proposal. Ids get the round's prefix (once),
|
|
63
|
+
* dependencies follow, and a dependency on a story merged before the round
|
|
64
|
+
* (`earlier`) is dropped: a round only starts once those are in the branch.
|
|
65
|
+
*/
|
|
66
|
+
export const assembleRound = (inputs: {
|
|
67
|
+
readonly epicId: string
|
|
68
|
+
readonly round: number
|
|
69
|
+
readonly feedback: string
|
|
70
|
+
readonly proposal: RefineProposal
|
|
71
|
+
/** Ids of every story of the epic's plan and of the earlier rounds. */
|
|
72
|
+
readonly earlier: ReadonlyArray<string>
|
|
73
|
+
}): StoryPlan => {
|
|
74
|
+
const prefix = roundPrefix(inputs.round)
|
|
75
|
+
const prefixed = (id: string): string => (id.startsWith(prefix) ? id : `${prefix}${id}`)
|
|
76
|
+
const own = new Set(inputs.proposal.stories.map((story) => story.id))
|
|
77
|
+
const earlier = new Set(inputs.earlier)
|
|
78
|
+
return StoryPlan.make({
|
|
79
|
+
epicId: inputs.epicId,
|
|
80
|
+
epic: inputs.feedback,
|
|
81
|
+
stories: inputs.proposal.stories.map((story) =>
|
|
82
|
+
Story.make({
|
|
83
|
+
...story,
|
|
84
|
+
id: prefixed(story.id),
|
|
85
|
+
dependsOn: story.dependsOn
|
|
86
|
+
.filter((dependency) => own.has(dependency) || !earlier.has(dependency))
|
|
87
|
+
.map(prefixed)
|
|
88
|
+
})
|
|
89
|
+
)
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** What to do about a round whose plan cannot be read: a plan alone is never deleted. */
|
|
94
|
+
export const roundRepair = (round: number): string =>
|
|
95
|
+
`fix the plan, or delete the folder rounds/${round} and every later round`
|
|
96
|
+
|
|
97
|
+
/** A round as the run decision sees it. */
|
|
98
|
+
export interface RoundProgress {
|
|
99
|
+
readonly round: number
|
|
100
|
+
readonly stories: number
|
|
101
|
+
readonly merged: number
|
|
102
|
+
/** Why its plan cannot be read, naming the file. */
|
|
103
|
+
readonly unreadable?: string
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export type RunAction =
|
|
107
|
+
| { readonly _tag: "RunPlan" }
|
|
108
|
+
| { readonly _tag: "RunRound"; readonly round: number }
|
|
109
|
+
| { readonly _tag: "PlanRound"; readonly round: number }
|
|
110
|
+
| { readonly _tag: "Land" }
|
|
111
|
+
| { readonly _tag: "Refused"; readonly reason: string }
|
|
112
|
+
| { readonly _tag: "Usage"; readonly message: string }
|
|
113
|
+
|
|
114
|
+
export interface RunInputs {
|
|
115
|
+
/** The epic has a story plan. */
|
|
116
|
+
readonly planned: boolean
|
|
117
|
+
readonly stories: number
|
|
118
|
+
readonly merged: number
|
|
119
|
+
/** The branch the epic landed on, once it has. */
|
|
120
|
+
readonly landed: string | undefined
|
|
121
|
+
readonly rounds: ReadonlyArray<RoundProgress>
|
|
122
|
+
/** `--refine` was given. */
|
|
123
|
+
readonly refine: boolean
|
|
124
|
+
/** The text given with `--refine`. */
|
|
125
|
+
readonly feedback: string
|
|
126
|
+
/** `--land` was given. */
|
|
127
|
+
readonly land: boolean
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* What a run of the epic does: its plan's stories, the open round, a new
|
|
132
|
+
* round, or the landing. A round starts only when everything before it is
|
|
133
|
+
* merged, and an open round is finished before another is planned.
|
|
134
|
+
*/
|
|
135
|
+
export const runAction = (inputs: RunInputs): RunAction => {
|
|
136
|
+
if (inputs.land) {
|
|
137
|
+
// Landing with feedback in hand would drop the feedback for good.
|
|
138
|
+
return inputs.refine && inputs.feedback.trim().length > 0
|
|
139
|
+
? {
|
|
140
|
+
_tag: "Usage",
|
|
141
|
+
message:
|
|
142
|
+
"--refine with feedback and --land do not go together: run the round first, then land"
|
|
143
|
+
}
|
|
144
|
+
: { _tag: "Land" }
|
|
145
|
+
}
|
|
146
|
+
const planOpen = inputs.merged < inputs.stories
|
|
147
|
+
const unreadable = inputs.rounds.find((round) => round.unreadable !== undefined)
|
|
148
|
+
const open = inputs.rounds.find((round) => round.merged < round.stories)
|
|
149
|
+
const broken = (round: RoundProgress): RunAction => ({
|
|
150
|
+
_tag: "Refused",
|
|
151
|
+
reason: `round ${round.round}'s plan cannot be read (${round.unreadable ?? ""}); ${roundRepair(round.round)}`
|
|
152
|
+
})
|
|
153
|
+
if (!inputs.refine) {
|
|
154
|
+
if (planOpen) {
|
|
155
|
+
return { _tag: "RunPlan" }
|
|
156
|
+
}
|
|
157
|
+
if (unreadable !== undefined) {
|
|
158
|
+
return broken(unreadable)
|
|
159
|
+
}
|
|
160
|
+
return open === undefined ? { _tag: "RunPlan" } : { _tag: "RunRound", round: open.round }
|
|
161
|
+
}
|
|
162
|
+
if (!inputs.planned) {
|
|
163
|
+
return { _tag: "Refused", reason: "it has no story plan yet: run the epic first" }
|
|
164
|
+
}
|
|
165
|
+
if (inputs.landed !== undefined) {
|
|
166
|
+
return {
|
|
167
|
+
_tag: "Refused",
|
|
168
|
+
reason: `it has landed on ${inputs.landed}; give the changes as a new epic`
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (unreadable !== undefined) {
|
|
172
|
+
return broken(unreadable)
|
|
173
|
+
}
|
|
174
|
+
if (planOpen) {
|
|
175
|
+
return {
|
|
176
|
+
_tag: "Refused",
|
|
177
|
+
reason: `${inputs.merged} of ${inputs.stories} stories merged: rerun the epic until every story merges`
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const feedback = inputs.feedback.trim()
|
|
181
|
+
if (open !== undefined) {
|
|
182
|
+
return feedback.length === 0
|
|
183
|
+
? { _tag: "RunRound", round: open.round }
|
|
184
|
+
: {
|
|
185
|
+
_tag: "Refused",
|
|
186
|
+
reason: `round ${open.round} is open (${open.merged} of ${open.stories} stories merged): rerun without --refine text to finish it, or edit its plan`
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (feedback.length === 0) {
|
|
190
|
+
return { _tag: "Usage", message: "--refine needs the feedback to plan a round from" }
|
|
191
|
+
}
|
|
192
|
+
return { _tag: "PlanRound", round: inputs.rounds.length + 1 }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const oneLine = (text: string): string => text.replace(/\s+/g, " ").trim()
|
|
196
|
+
|
|
197
|
+
/** `not-planned.md`: one line per item left out, for the person and for the next round's planner. */
|
|
198
|
+
export const renderNotPlanned = (round: number, items: ReadonlyArray<NotPlanned>): string =>
|
|
199
|
+
[
|
|
200
|
+
`# Not planned in round ${round}`,
|
|
201
|
+
"",
|
|
202
|
+
...(items.length === 0
|
|
203
|
+
? ["Every feedback item was planned."]
|
|
204
|
+
: items.map((entry) => `- **${oneLine(entry.item)}** — ${oneLine(entry.reason)}`)),
|
|
205
|
+
""
|
|
206
|
+
].join("\n")
|
|
207
|
+
|
|
208
|
+
export const countNotPlanned = (markdown: string): number =>
|
|
209
|
+
markdown.split("\n").filter((line) => line.startsWith("- **")).length
|
|
210
|
+
|
|
211
|
+
/** An earlier story as a round's planner is shown it: what was built, and where. */
|
|
212
|
+
export interface EarlierStory {
|
|
213
|
+
readonly id: string
|
|
214
|
+
readonly title: string
|
|
215
|
+
readonly owned: ReadonlyArray<string>
|
|
216
|
+
readonly provides: ReadonlyArray<string>
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export const earlierStories = (plans: ReadonlyArray<StoryPlan>): ReadonlyArray<EarlierStory> =>
|
|
220
|
+
plans.flatMap((plan) =>
|
|
221
|
+
plan.stories.map((story) => ({
|
|
222
|
+
id: story.id,
|
|
223
|
+
title: story.title,
|
|
224
|
+
owned: story.owned,
|
|
225
|
+
provides: story.provides
|
|
226
|
+
}))
|
|
227
|
+
)
|