@llm4ts/flow 2.9.7 → 2.11.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 (48) hide show
  1. package/dist/Approval.d.ts +1 -1
  2. package/dist/Approval.d.ts.map +1 -1
  3. package/dist/Artifacts.d.ts +2 -2
  4. package/dist/Artifacts.d.ts.map +1 -1
  5. package/dist/BenchReport.d.ts +2 -2
  6. package/dist/BenchReport.d.ts.map +1 -1
  7. package/dist/CostLedger.d.ts +2 -2
  8. package/dist/CostLedger.d.ts.map +1 -1
  9. package/dist/Equiv.d.ts +3 -3
  10. package/dist/Equiv.d.ts.map +1 -1
  11. package/dist/Flow.d.ts +1 -1
  12. package/dist/Flow.d.ts.map +1 -1
  13. package/dist/FlowError.d.ts +18 -1
  14. package/dist/FlowError.d.ts.map +1 -1
  15. package/dist/FlowError.js +21 -0
  16. package/dist/FlowError.js.map +1 -1
  17. package/dist/GitTool.d.ts +8 -0
  18. package/dist/GitTool.d.ts.map +1 -1
  19. package/dist/GitTool.js +17 -0
  20. package/dist/GitTool.js.map +1 -1
  21. package/dist/Judgment.d.ts +2 -2
  22. package/dist/Judgment.d.ts.map +1 -1
  23. package/dist/Landing.d.ts +52 -0
  24. package/dist/Landing.d.ts.map +1 -0
  25. package/dist/Landing.js +167 -0
  26. package/dist/Landing.js.map +1 -0
  27. package/dist/Persistence.d.ts +2 -2
  28. package/dist/Persistence.d.ts.map +1 -1
  29. package/dist/PlanExecution.d.ts +1 -1
  30. package/dist/PlanExecution.d.ts.map +1 -1
  31. package/dist/ProgramJudge.d.ts +1 -1
  32. package/dist/ProgramJudge.d.ts.map +1 -1
  33. package/dist/Replay.d.ts +2 -2
  34. package/dist/Replay.d.ts.map +1 -1
  35. package/dist/Review.d.ts +3 -3
  36. package/dist/Review.d.ts.map +1 -1
  37. package/dist/ReviewCache.d.ts +1 -1
  38. package/dist/ReviewCache.d.ts.map +1 -1
  39. package/dist/Roster.d.ts +1 -1
  40. package/dist/Roster.d.ts.map +1 -1
  41. package/dist/RosterSeats.d.ts +1 -1
  42. package/dist/RosterSeats.d.ts.map +1 -1
  43. package/dist/Stories.d.ts +1 -1
  44. package/dist/Stories.d.ts.map +1 -1
  45. package/package.json +3 -2
  46. package/src/FlowError.ts +23 -0
  47. package/src/GitTool.ts +29 -0
  48. package/src/Landing.ts +250 -0
package/src/Landing.ts ADDED
@@ -0,0 +1,250 @@
1
+ // Landing a finished epic (`epic-stories --land`): every story merged into the
2
+ // epic branch, the target (main by default) merged INTO the epic branch first
3
+ // so conflicts are resolved on the epic side, a coder resolving them in a
4
+ // bounded loop with the target's gates as the bar, and only then a merge
5
+ // commit on the target. Nothing touches the target until the epic branch is
6
+ // green with the target's history in it.
7
+ import * as Effect from "effect/Effect"
8
+ import * as Schema from "effect/Schema"
9
+ import type { LlmServiceShape } from "@llm4ts/core/LlmService"
10
+ import { makeChat } from "./Chat.ts"
11
+ import type { FlowContextShape } from "./FlowContext.ts"
12
+ import { EpicCheckoutDirty, EpicIncomplete, LandingFailed, type FlowError } from "./FlowError.ts"
13
+ import { Info } from "./FlowEvents.ts"
14
+ import { statusPaths } from "./GitTool.ts"
15
+ import { loadVersioned, saveVersioned, type PlainFileStoreShape } from "./Persistence.ts"
16
+ import type { ReviewResult } from "./Review.ts"
17
+ import { StoryState, StoryStateVersion } from "./Stories.ts"
18
+ import type { StoryPlan } from "./StoryPlan.ts"
19
+
20
+ const join = (root: string, path: string): string =>
21
+ `${root.replace(/[\\/]+$/, "")}/${path.replace(/^[\\/]+/, "")}`
22
+
23
+ export const EpicLandedVersion = 1
24
+
25
+ /** Written to the epic's state directory once it lands: listings show it, reruns see it. */
26
+ export class EpicLanded extends Schema.Class<EpicLanded>("EpicLanded")({
27
+ target: Schema.String,
28
+ epicBranch: Schema.String
29
+ }) {}
30
+
31
+ export const landedPath = (stateDir: string): string => join(stateDir, "landed.json")
32
+
33
+ export interface LandOptions {
34
+ readonly plan: StoryPlan
35
+ readonly files: PlainFileStoreShape
36
+ /** The executor's state directory, where each story's state says whether it merged. */
37
+ readonly stateDir: string
38
+ /** Default `epic/<epicId>`. */
39
+ readonly epicBranch?: string
40
+ /** Default `main`. */
41
+ readonly target?: string
42
+ /** The target's gates, run in the epic checkout. */
43
+ readonly gates: (workDir: string) => Effect.Effect<ReviewResult, FlowError>
44
+ /** Who resolves conflicts and red gates. Default: the context's coder. */
45
+ readonly coder?: LlmServiceShape
46
+ /** House rules for the coder. */
47
+ readonly system?: string
48
+ /** Fix rounds before giving up. Default 3. */
49
+ readonly maxRounds?: number
50
+ /** Keep the story worktrees and branches after landing. Default false: they go. */
51
+ readonly keepWorktrees?: boolean
52
+ }
53
+
54
+ export interface LandReport {
55
+ readonly epicBranch: string
56
+ readonly target: string
57
+ /** The target's paths that conflicted with the epic, if any. */
58
+ readonly conflicts: ReadonlyArray<string>
59
+ /** Coder rounds it took (0: clean and green on the first try). */
60
+ readonly rounds: number
61
+ /** Story worktrees removed after landing. */
62
+ readonly removedWorktrees: number
63
+ /** Story branches deleted after landing (each fully merged into the target). */
64
+ readonly deletedBranches: number
65
+ }
66
+
67
+ const markerPattern = /^(<{7} |={7}$|>{7} )/m
68
+
69
+ const issueLines = (result: ReviewResult): string =>
70
+ result.issues
71
+ .map(
72
+ (issue) =>
73
+ `- ${issue.title}${issue.description.length === 0 ? "" : `: ${issue.description.slice(-1500)}`}`
74
+ )
75
+ .join("\n")
76
+
77
+ export const landEpic = Effect.fn("@llm4ts/flow/Landing.land")(function* (
78
+ context: FlowContextShape,
79
+ options: LandOptions
80
+ ): Effect.fn.Return<LandReport, FlowError> {
81
+ const { plan, files } = options
82
+ const git = context.git
83
+ const events = context.events
84
+ const epicBranch = options.epicBranch ?? `epic/${plan.epicId}`
85
+ const target = options.target ?? "main"
86
+ const maxRounds = Math.max(1, options.maxRounds ?? 3)
87
+ const say = (message: string) => events.publish(Info.make({ message: `land: ${message}` }))
88
+ const failed = (reason: string) => LandingFailed.make({ epicBranch, target, reason })
89
+
90
+ // 1. Only a finished epic lands.
91
+ const unmerged: Array<string> = []
92
+ for (const story of plan.stories) {
93
+ const state = yield* loadVersioned(
94
+ files,
95
+ join(options.stateDir, `stories/${story.id}.json`),
96
+ StoryStateVersion,
97
+ StoryState
98
+ )
99
+ if (state?.status !== "merged") {
100
+ unmerged.push(story.id)
101
+ }
102
+ }
103
+ if (unmerged.length > 0) {
104
+ return yield* EpicIncomplete.make({ epicId: plan.epicId, stories: unmerged })
105
+ }
106
+ if (!(yield* git.branchExists(target))) {
107
+ return yield* failed(`there is no branch '${target}'`)
108
+ }
109
+ const dirty = statusPaths(yield* git.status)
110
+ if (dirty.length > 0) {
111
+ return yield* EpicCheckoutDirty.make({ checkout: context.workDir, paths: dirty })
112
+ }
113
+
114
+ // 2. Bring the target's history into the epic branch, on the epic side.
115
+ yield* git.checkout(epicBranch)
116
+ const before = yield* git.checkpoint
117
+ const behind = !(yield* git.isAncestor(target, epicBranch))
118
+ const conflicts = behind ? yield* git.mergeNoCommit(target) : []
119
+ if (behind) {
120
+ yield* say(
121
+ conflicts.length === 0
122
+ ? `merged ${target} into ${epicBranch} cleanly; checking the gates`
123
+ : `merging ${target} into ${epicBranch} conflicted in ${conflicts.length} file(s): ${conflicts.join(", ")}`
124
+ )
125
+ } else {
126
+ yield* say(`${epicBranch} already contains ${target}; checking the gates`)
127
+ }
128
+
129
+ // 3. Conflicts and red gates go to the coder, a bounded number of times.
130
+ const coder = options.coder ?? context.coder
131
+ const chat = yield* makeChat(coder, {
132
+ ...(options.system === undefined ? {} : { system: options.system }),
133
+ events,
134
+ agent: "coder"
135
+ })
136
+ const stories = plan.stories.map((story) => `- ${story.id}: ${story.provides.join("; ")}`)
137
+ let rounds = 0
138
+ while (true) {
139
+ const withMarkers: Array<string> = []
140
+ for (const path of conflicts) {
141
+ const text = yield* files.read(join(context.workDir, path))
142
+ if (text !== undefined && markerPattern.test(text)) {
143
+ withMarkers.push(path)
144
+ }
145
+ }
146
+ const gate = withMarkers.length > 0 ? undefined : yield* options.gates(context.workDir)
147
+ if (withMarkers.length === 0 && gate !== undefined && gate.isClean) {
148
+ break
149
+ }
150
+ if (rounds >= maxRounds) {
151
+ yield* git.rollback(before)
152
+ return yield* failed(
153
+ withMarkers.length > 0
154
+ ? `conflict markers still in ${withMarkers.join(", ")} after ${rounds} round(s)`
155
+ : `the gates are still red after ${rounds} round(s):\n${gate === undefined ? "" : issueLines(gate)}`
156
+ )
157
+ }
158
+ rounds += 1
159
+ yield* say(
160
+ withMarkers.length > 0
161
+ ? `round ${rounds}: asking the coder to resolve ${withMarkers.length} conflicted file(s)`
162
+ : `round ${rounds}: asking the coder to fix ${gate?.issues.length ?? 0} gate failure(s)`
163
+ )
164
+ yield* chat.ask(
165
+ withMarkers.length > 0
166
+ ? [
167
+ `The finished epic "${plan.epic}" (branch ${epicBranch}) is landing on ${target}.`,
168
+ `Merging ${target} into ${epicBranch} left conflicts in these files:`,
169
+ ...withMarkers.map((path) => `- ${path}`),
170
+ "",
171
+ `${target} holds newer mainline work; ${epicBranch} holds the epic's stories, which provide:`,
172
+ ...stories,
173
+ "",
174
+ "Resolve every conflict so both sides' intent survives, remove every conflict marker,",
175
+ "and keep the repository's gates (typecheck, lint, tests, build) green. Do not commit,",
176
+ "do not switch branches, and do not run git merge or git rebase: the flow owns git."
177
+ ].join("\n")
178
+ : [
179
+ `After merging ${target} into ${epicBranch}, the repository's gates fail:`,
180
+ gate === undefined ? "" : issueLines(gate),
181
+ "",
182
+ "Fix them without weakening any test. Do not commit or switch branches."
183
+ ].join("\n")
184
+ )
185
+ }
186
+
187
+ // 4. Green with the target's history: record it, then land on the target.
188
+ if (behind) {
189
+ yield* git.commitAll(`${plan.epicId}: merge ${target} into ${epicBranch} before landing`)
190
+ } else if (rounds > 0) {
191
+ yield* git.commitAll(`${plan.epicId}: fix the gates before landing`)
192
+ }
193
+ yield* git.checkout(target)
194
+ yield* git.merge(epicBranch, `${plan.epicId}: land ${epicBranch}`)
195
+ yield* saveVersioned(
196
+ files,
197
+ landedPath(options.stateDir),
198
+ EpicLandedVersion,
199
+ EpicLanded,
200
+ EpicLanded.make({ target, epicBranch })
201
+ )
202
+ yield* say(
203
+ `landed ${epicBranch} on ${target}${rounds === 0 ? "" : ` after ${rounds} fix round(s)`}`
204
+ )
205
+
206
+ // 5. The stories' worktrees and branches have done their job: their work
207
+ // is in the target's history. The epic's record (plan, board, report,
208
+ // story states, traces) stays. A worktree with uncommitted work, or a
209
+ // branch not fully merged, is kept and named.
210
+ let removedWorktrees = 0
211
+ let deletedBranches = 0
212
+ if (options.keepWorktrees !== true) {
213
+ const kept: Array<string> = []
214
+ for (const story of plan.stories) {
215
+ const state = yield* loadVersioned(
216
+ files,
217
+ join(options.stateDir, `stories/${story.id}.json`),
218
+ StoryStateVersion,
219
+ StoryState
220
+ )
221
+ if (state === undefined) {
222
+ continue
223
+ }
224
+ if ((yield* files.read(join(state.worktree, ".git"))) !== undefined) {
225
+ const removed = yield* git.removeWorktree(state.worktree).pipe(
226
+ Effect.as(true),
227
+ Effect.catch(() => Effect.succeed(false))
228
+ )
229
+ if (removed) {
230
+ removedWorktrees += 1
231
+ } else {
232
+ kept.push(`${state.worktree} (uncommitted work)`)
233
+ }
234
+ }
235
+ if (yield* git.branchExists(state.branch)) {
236
+ if (yield* git.isAncestor(state.branch, target)) {
237
+ yield* git.deleteBranch(state.branch)
238
+ deletedBranches += 1
239
+ } else {
240
+ kept.push(`${state.branch} (not merged into ${target})`)
241
+ }
242
+ }
243
+ }
244
+ yield* say(
245
+ `removed ${removedWorktrees} story worktree(s) and deleted ${deletedBranches} merged story branch(es)` +
246
+ (kept.length === 0 ? "" : `; kept: ${kept.join(", ")}`)
247
+ )
248
+ }
249
+ return { epicBranch, target, conflicts, rounds, removedWorktrees, deletedBranches }
250
+ })