@llm4ts/flow 2.9.7 → 2.10.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 +35 -0
  24. package/dist/Landing.d.ts.map +1 -0
  25. package/dist/Landing.js +122 -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 +182 -0
package/src/Landing.ts ADDED
@@ -0,0 +1,182 @@
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 type { LlmServiceShape } from "@llm4ts/core/LlmService"
9
+ import { makeChat } from "./Chat.ts"
10
+ import type { FlowContextShape } from "./FlowContext.ts"
11
+ import { EpicCheckoutDirty, EpicIncomplete, LandingFailed, type FlowError } from "./FlowError.ts"
12
+ import { Info } from "./FlowEvents.ts"
13
+ import { statusPaths } from "./GitTool.ts"
14
+ import { loadVersioned, type PlainFileStoreShape } from "./Persistence.ts"
15
+ import type { ReviewResult } from "./Review.ts"
16
+ import { StoryState, StoryStateVersion } from "./Stories.ts"
17
+ import type { StoryPlan } from "./StoryPlan.ts"
18
+
19
+ const join = (root: string, path: string): string =>
20
+ `${root.replace(/[\\/]+$/, "")}/${path.replace(/^[\\/]+/, "")}`
21
+
22
+ export interface LandOptions {
23
+ readonly plan: StoryPlan
24
+ readonly files: PlainFileStoreShape
25
+ /** The executor's state directory, where each story's state says whether it merged. */
26
+ readonly stateDir: string
27
+ /** Default `epic/<epicId>`. */
28
+ readonly epicBranch?: string
29
+ /** Default `main`. */
30
+ readonly target?: string
31
+ /** The target's gates, run in the epic checkout. */
32
+ readonly gates: (workDir: string) => Effect.Effect<ReviewResult, FlowError>
33
+ /** Who resolves conflicts and red gates. Default: the context's coder. */
34
+ readonly coder?: LlmServiceShape
35
+ /** House rules for the coder. */
36
+ readonly system?: string
37
+ /** Fix rounds before giving up. Default 3. */
38
+ readonly maxRounds?: number
39
+ }
40
+
41
+ export interface LandReport {
42
+ readonly epicBranch: string
43
+ readonly target: string
44
+ /** The target's paths that conflicted with the epic, if any. */
45
+ readonly conflicts: ReadonlyArray<string>
46
+ /** Coder rounds it took (0: clean and green on the first try). */
47
+ readonly rounds: number
48
+ }
49
+
50
+ const markerPattern = /^(<{7} |={7}$|>{7} )/m
51
+
52
+ const issueLines = (result: ReviewResult): string =>
53
+ result.issues
54
+ .map(
55
+ (issue) =>
56
+ `- ${issue.title}${issue.description.length === 0 ? "" : `: ${issue.description.slice(-1500)}`}`
57
+ )
58
+ .join("\n")
59
+
60
+ export const landEpic = Effect.fn("@llm4ts/flow/Landing.land")(function* (
61
+ context: FlowContextShape,
62
+ options: LandOptions
63
+ ): Effect.fn.Return<LandReport, FlowError> {
64
+ const { plan, files } = options
65
+ const git = context.git
66
+ const events = context.events
67
+ const epicBranch = options.epicBranch ?? `epic/${plan.epicId}`
68
+ const target = options.target ?? "main"
69
+ const maxRounds = Math.max(1, options.maxRounds ?? 3)
70
+ const say = (message: string) => events.publish(Info.make({ message: `land: ${message}` }))
71
+ const failed = (reason: string) => LandingFailed.make({ epicBranch, target, reason })
72
+
73
+ // 1. Only a finished epic lands.
74
+ const unmerged: Array<string> = []
75
+ for (const story of plan.stories) {
76
+ const state = yield* loadVersioned(
77
+ files,
78
+ join(options.stateDir, `stories/${story.id}.json`),
79
+ StoryStateVersion,
80
+ StoryState
81
+ )
82
+ if (state?.status !== "merged") {
83
+ unmerged.push(story.id)
84
+ }
85
+ }
86
+ if (unmerged.length > 0) {
87
+ return yield* EpicIncomplete.make({ epicId: plan.epicId, stories: unmerged })
88
+ }
89
+ if (!(yield* git.branchExists(target))) {
90
+ return yield* failed(`there is no branch '${target}'`)
91
+ }
92
+ const dirty = statusPaths(yield* git.status)
93
+ if (dirty.length > 0) {
94
+ return yield* EpicCheckoutDirty.make({ checkout: context.workDir, paths: dirty })
95
+ }
96
+
97
+ // 2. Bring the target's history into the epic branch, on the epic side.
98
+ yield* git.checkout(epicBranch)
99
+ const before = yield* git.checkpoint
100
+ const behind = !(yield* git.isAncestor(target, epicBranch))
101
+ const conflicts = behind ? yield* git.mergeNoCommit(target) : []
102
+ if (behind) {
103
+ yield* say(
104
+ conflicts.length === 0
105
+ ? `merged ${target} into ${epicBranch} cleanly; checking the gates`
106
+ : `merging ${target} into ${epicBranch} conflicted in ${conflicts.length} file(s): ${conflicts.join(", ")}`
107
+ )
108
+ } else {
109
+ yield* say(`${epicBranch} already contains ${target}; checking the gates`)
110
+ }
111
+
112
+ // 3. Conflicts and red gates go to the coder, a bounded number of times.
113
+ const coder = options.coder ?? context.coder
114
+ const chat = yield* makeChat(coder, {
115
+ ...(options.system === undefined ? {} : { system: options.system }),
116
+ events,
117
+ agent: "coder"
118
+ })
119
+ const stories = plan.stories.map((story) => `- ${story.id}: ${story.provides.join("; ")}`)
120
+ let rounds = 0
121
+ while (true) {
122
+ const withMarkers: Array<string> = []
123
+ for (const path of conflicts) {
124
+ const text = yield* files.read(join(context.workDir, path))
125
+ if (text !== undefined && markerPattern.test(text)) {
126
+ withMarkers.push(path)
127
+ }
128
+ }
129
+ const gate = withMarkers.length > 0 ? undefined : yield* options.gates(context.workDir)
130
+ if (withMarkers.length === 0 && gate !== undefined && gate.isClean) {
131
+ break
132
+ }
133
+ if (rounds >= maxRounds) {
134
+ yield* git.rollback(before)
135
+ return yield* failed(
136
+ withMarkers.length > 0
137
+ ? `conflict markers still in ${withMarkers.join(", ")} after ${rounds} round(s)`
138
+ : `the gates are still red after ${rounds} round(s):\n${gate === undefined ? "" : issueLines(gate)}`
139
+ )
140
+ }
141
+ rounds += 1
142
+ yield* say(
143
+ withMarkers.length > 0
144
+ ? `round ${rounds}: asking the coder to resolve ${withMarkers.length} conflicted file(s)`
145
+ : `round ${rounds}: asking the coder to fix ${gate?.issues.length ?? 0} gate failure(s)`
146
+ )
147
+ yield* chat.ask(
148
+ withMarkers.length > 0
149
+ ? [
150
+ `The finished epic "${plan.epic}" (branch ${epicBranch}) is landing on ${target}.`,
151
+ `Merging ${target} into ${epicBranch} left conflicts in these files:`,
152
+ ...withMarkers.map((path) => `- ${path}`),
153
+ "",
154
+ `${target} holds newer mainline work; ${epicBranch} holds the epic's stories, which provide:`,
155
+ ...stories,
156
+ "",
157
+ "Resolve every conflict so both sides' intent survives, remove every conflict marker,",
158
+ "and keep the repository's gates (typecheck, lint, tests, build) green. Do not commit,",
159
+ "do not switch branches, and do not run git merge or git rebase: the flow owns git."
160
+ ].join("\n")
161
+ : [
162
+ `After merging ${target} into ${epicBranch}, the repository's gates fail:`,
163
+ gate === undefined ? "" : issueLines(gate),
164
+ "",
165
+ "Fix them without weakening any test. Do not commit or switch branches."
166
+ ].join("\n")
167
+ )
168
+ }
169
+
170
+ // 4. Green with the target's history: record it, then land on the target.
171
+ if (behind) {
172
+ yield* git.commitAll(`${plan.epicId}: merge ${target} into ${epicBranch} before landing`)
173
+ } else if (rounds > 0) {
174
+ yield* git.commitAll(`${plan.epicId}: fix the gates before landing`)
175
+ }
176
+ yield* git.checkout(target)
177
+ yield* git.merge(epicBranch, `${plan.epicId}: land ${epicBranch}`)
178
+ yield* say(
179
+ `landed ${epicBranch} on ${target}${rounds === 0 ? "" : ` after ${rounds} fix round(s)`}`
180
+ )
181
+ return { epicBranch, target, conflicts, rounds }
182
+ })