@llm4ts/flow 0.15.0 → 0.16.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/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/FlowContext.d.ts +10 -0
- package/dist/FlowContext.d.ts.map +1 -1
- package/dist/FlowContext.js.map +1 -1
- package/dist/FlowError.d.ts +42 -1
- package/dist/FlowError.d.ts.map +1 -1
- package/dist/FlowError.js +60 -1
- package/dist/FlowError.js.map +1 -1
- package/dist/GitTool.d.ts +15 -1
- package/dist/GitTool.d.ts.map +1 -1
- package/dist/GitTool.js +30 -2
- package/dist/GitTool.js.map +1 -1
- package/dist/Perimeter.d.ts +13 -0
- package/dist/Perimeter.d.ts.map +1 -0
- package/dist/Perimeter.js +34 -0
- package/dist/Perimeter.js.map +1 -0
- 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/Replay.d.ts +2 -2
- package/dist/Replay.d.ts.map +1 -1
- package/dist/Review.d.ts +2 -2
- 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/Stories.d.ts +109 -0
- package/dist/Stories.d.ts.map +1 -0
- package/dist/Stories.js +432 -0
- package/dist/Stories.js.map +1 -0
- package/dist/StoryPlan.d.ts +81 -0
- package/dist/StoryPlan.d.ts.map +1 -0
- package/dist/StoryPlan.js +242 -0
- package/dist/StoryPlan.js.map +1 -0
- package/dist/TransientRetry.d.ts +18 -0
- package/dist/TransientRetry.d.ts.map +1 -1
- package/dist/TransientRetry.js +35 -3
- package/dist/TransientRetry.js.map +1 -1
- package/package.json +5 -2
- package/src/FlowContext.ts +10 -0
- package/src/FlowError.ts +74 -1
- package/src/GitTool.ts +74 -4
- package/src/Perimeter.ts +49 -0
- package/src/Stories.ts +670 -0
- package/src/StoryPlan.ts +353 -0
- package/src/TransientRetry.ts +76 -7
package/src/FlowError.ts
CHANGED
|
@@ -124,6 +124,74 @@ export class BudgetExceeded extends Schema.TaggedErrorClass<BudgetExceeded>()("B
|
|
|
124
124
|
}
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/** A story plan that failed deterministic validation — every violation, not the first (ADR 0013). */
|
|
128
|
+
export class StoryPlanInvalid extends Schema.TaggedErrorClass<StoryPlanInvalid>()(
|
|
129
|
+
"StoryPlanInvalid",
|
|
130
|
+
{
|
|
131
|
+
violations: Schema.Array(Schema.String)
|
|
132
|
+
}
|
|
133
|
+
) {
|
|
134
|
+
get message(): string {
|
|
135
|
+
return `story plan invalid:\n${this.violations.map((violation) => `- ${violation}`).join("\n")}`
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** A story branch changed paths outside the story's declared `owned` set. */
|
|
140
|
+
export class PerimeterViolation extends Schema.TaggedErrorClass<PerimeterViolation>()(
|
|
141
|
+
"PerimeterViolation",
|
|
142
|
+
{
|
|
143
|
+
story: Schema.String,
|
|
144
|
+
outside: Schema.Array(Schema.String),
|
|
145
|
+
sharedReadOnly: Schema.Array(Schema.String)
|
|
146
|
+
}
|
|
147
|
+
) {
|
|
148
|
+
get message(): string {
|
|
149
|
+
const lines = [`story '${this.story}' changed paths outside its perimeter:`]
|
|
150
|
+
for (const path of this.sharedReadOnly) {
|
|
151
|
+
lines.push(`- ${path} (shared read-only: revert it, or request it as a dedicated story)`)
|
|
152
|
+
}
|
|
153
|
+
for (const path of this.outside) {
|
|
154
|
+
lines.push(`- ${path} (not in the story's owned paths)`)
|
|
155
|
+
}
|
|
156
|
+
return lines.join("\n")
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The coder ended a story with `BLOCKED_ON:` — unplanned work belongs to another story. */
|
|
161
|
+
export class MissingDependency extends Schema.TaggedErrorClass<MissingDependency>()(
|
|
162
|
+
"MissingDependency",
|
|
163
|
+
{
|
|
164
|
+
story: Schema.String,
|
|
165
|
+
need: Schema.String
|
|
166
|
+
}
|
|
167
|
+
) {
|
|
168
|
+
get message(): string {
|
|
169
|
+
return `story '${this.story}' is blocked on unplanned work: ${this.need}`
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** A story branch did not merge cleanly into the epic branch; the merge was aborted. */
|
|
174
|
+
export class MergeConflict extends Schema.TaggedErrorClass<MergeConflict>()("MergeConflict", {
|
|
175
|
+
branch: Schema.String,
|
|
176
|
+
into: Schema.String,
|
|
177
|
+
paths: Schema.Array(Schema.String)
|
|
178
|
+
}) {
|
|
179
|
+
get message(): string {
|
|
180
|
+
const where = this.paths.length === 0 ? "" : `: ${this.paths.join(", ")}`
|
|
181
|
+
return `merging '${this.branch}' into '${this.into}' conflicted${where}`
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** One story failed; carries the story id so a fail-fast run names its cause. */
|
|
186
|
+
export class StoryFailed extends Schema.TaggedErrorClass<StoryFailed>()("StoryFailed", {
|
|
187
|
+
story: Schema.String,
|
|
188
|
+
reason: Schema.String
|
|
189
|
+
}) {
|
|
190
|
+
get message(): string {
|
|
191
|
+
return `story '${this.story}' failed: ${this.reason}`
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
127
195
|
export const FlowError = Schema.Union([
|
|
128
196
|
PersistenceError,
|
|
129
197
|
PlanParseError,
|
|
@@ -136,6 +204,11 @@ export const FlowError = Schema.Union([
|
|
|
136
204
|
FlowLlmError,
|
|
137
205
|
FlowCapabilityDenied,
|
|
138
206
|
ColumnNotFound,
|
|
139
|
-
BudgetExceeded
|
|
207
|
+
BudgetExceeded,
|
|
208
|
+
StoryPlanInvalid,
|
|
209
|
+
PerimeterViolation,
|
|
210
|
+
MissingDependency,
|
|
211
|
+
MergeConflict,
|
|
212
|
+
StoryFailed
|
|
140
213
|
])
|
|
141
214
|
export type FlowError = typeof FlowError.Type
|
package/src/GitTool.ts
CHANGED
|
@@ -2,7 +2,7 @@ import * as Effect from "effect/Effect"
|
|
|
2
2
|
import * as Schema from "effect/Schema"
|
|
3
3
|
import { Capabilities, type Capability } from "@llm4ts/core/Capability"
|
|
4
4
|
import type { ProcessExecutorShape, ProcessResult } from "@llm4ts/core/ProcessExecutor"
|
|
5
|
-
import { ProcessError, type FlowError } from "./FlowError.ts"
|
|
5
|
+
import { MergeConflict, ProcessError, type FlowError } from "./FlowError.ts"
|
|
6
6
|
import type { FlowEventsShape } from "./FlowEvents.ts"
|
|
7
7
|
import { guarded } from "./CapabilityGuard.ts"
|
|
8
8
|
|
|
@@ -56,8 +56,26 @@ export interface GitToolShape {
|
|
|
56
56
|
readonly push: (remote: string, branch: string) => Effect.Effect<void, FlowError>
|
|
57
57
|
readonly checkpoint: Effect.Effect<string, FlowError>
|
|
58
58
|
readonly rollback: (checkpoint: string) => Effect.Effect<void, FlowError>
|
|
59
|
+
/** Checks an EXISTING branch out into a new worktree at `path`. */
|
|
59
60
|
readonly addWorktree: (path: string, branch: string) => Effect.Effect<void, FlowError>
|
|
60
|
-
|
|
61
|
+
/** Creates `branch` at `startPoint` and checks it out into a new worktree at `path`. */
|
|
62
|
+
readonly addWorktreeNewBranch: (
|
|
63
|
+
path: string,
|
|
64
|
+
branch: string,
|
|
65
|
+
startPoint: string
|
|
66
|
+
) => Effect.Effect<void, FlowError>
|
|
67
|
+
/** `force` also removes a worktree holding untracked or modified files. */
|
|
68
|
+
readonly removeWorktree: (path: string, force?: boolean) => Effect.Effect<void, FlowError>
|
|
69
|
+
readonly branchExists: (name: string) => Effect.Effect<boolean, FlowError>
|
|
70
|
+
readonly deleteBranch: (name: string) => Effect.Effect<void, FlowError>
|
|
71
|
+
/** Whether `commit` is reachable from `of` — a merged story branch is an ancestor of the epic head. */
|
|
72
|
+
readonly isAncestor: (commit: string, of: string) => Effect.Effect<boolean, FlowError>
|
|
73
|
+
/**
|
|
74
|
+
* Merges `branch` into the checked-out branch with a merge commit. A
|
|
75
|
+
* conflict fails typed with the conflicting paths and leaves the tree as it
|
|
76
|
+
* was (the merge is aborted), so the next merge can proceed.
|
|
77
|
+
*/
|
|
78
|
+
readonly merge: (branch: string, message: string) => Effect.Effect<void, FlowError>
|
|
61
79
|
}
|
|
62
80
|
|
|
63
81
|
const nonInteractiveEnvironment = Object.freeze({
|
|
@@ -287,7 +305,59 @@ export const makeGitTool = (
|
|
|
287
305
|
write("git rollback", runOrFail(["reset", "--hard", checkpoint]).pipe(Effect.asVoid)),
|
|
288
306
|
addWorktree: (path, branch) =>
|
|
289
307
|
write("git worktree add", runOrFail(["worktree", "add", path, branch]).pipe(Effect.asVoid)),
|
|
290
|
-
|
|
291
|
-
write(
|
|
308
|
+
addWorktreeNewBranch: (path, branch, startPoint) =>
|
|
309
|
+
write(
|
|
310
|
+
"git worktree add -b",
|
|
311
|
+
runOrFail(["worktree", "add", "-b", branch, path, startPoint]).pipe(Effect.asVoid)
|
|
312
|
+
),
|
|
313
|
+
removeWorktree: (path, force = false) =>
|
|
314
|
+
write(
|
|
315
|
+
"git worktree remove",
|
|
316
|
+
runOrFail(["worktree", "remove", ...(force ? ["--force"] : []), path]).pipe(Effect.asVoid)
|
|
317
|
+
),
|
|
318
|
+
branchExists: (name) =>
|
|
319
|
+
read(
|
|
320
|
+
"git branchExists",
|
|
321
|
+
Effect.map(verifiedRef(`refs/heads/${name}`), (found) => found !== undefined)
|
|
322
|
+
),
|
|
323
|
+
deleteBranch: (name) =>
|
|
324
|
+
write("git branch -D", runOrFail(["branch", "-D", name]).pipe(Effect.asVoid)),
|
|
325
|
+
isAncestor: (commit, of) =>
|
|
326
|
+
read(
|
|
327
|
+
"git merge-base --is-ancestor",
|
|
328
|
+
Effect.flatMap(run(["merge-base", "--is-ancestor", commit, of]), (result) =>
|
|
329
|
+
result.exitCode === 0
|
|
330
|
+
? Effect.succeed(true)
|
|
331
|
+
: result.exitCode === 1
|
|
332
|
+
? Effect.succeed(false)
|
|
333
|
+
: Effect.fail(
|
|
334
|
+
ProcessError.make({
|
|
335
|
+
message: `git merge-base --is-ancestor ${commit} ${of}`,
|
|
336
|
+
detail: problem(result)
|
|
337
|
+
})
|
|
338
|
+
)
|
|
339
|
+
)
|
|
340
|
+
),
|
|
341
|
+
merge: (branch, message) =>
|
|
342
|
+
write(
|
|
343
|
+
"git merge",
|
|
344
|
+
Effect.gen(function* () {
|
|
345
|
+
const result = yield* run(["merge", "--no-ff", "-m", message, branch])
|
|
346
|
+
if (result.exitCode === 0) {
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
const into = yield* runOrFail(["rev-parse", "--abbrev-ref", "HEAD"])
|
|
350
|
+
const unmerged = yield* run(["diff", "--name-only", "--diff-filter=U"])
|
|
351
|
+
const paths = text(unmerged.stdout)
|
|
352
|
+
.split(/\r?\n/)
|
|
353
|
+
.map((line) => line.trim())
|
|
354
|
+
.filter((line) => line.length > 0)
|
|
355
|
+
// Abort regardless of the outcome so the epic tree is clean for the
|
|
356
|
+
// next story; a merge that failed before starting has nothing to
|
|
357
|
+
// abort and git says so, which is not a second failure.
|
|
358
|
+
yield* run(["merge", "--abort"])
|
|
359
|
+
return yield* MergeConflict.make({ branch, into, paths })
|
|
360
|
+
})
|
|
361
|
+
)
|
|
292
362
|
}
|
|
293
363
|
}
|
package/src/Perimeter.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// The story perimeter check (ADR 0013): a story branch may only change what
|
|
2
|
+
// the story owns. Enforced after the fact on the branch's changed files —
|
|
3
|
+
// the prompt states the rule, this is what makes it true.
|
|
4
|
+
import * as Effect from "effect/Effect"
|
|
5
|
+
import { PerimeterViolation } from "./FlowError.ts"
|
|
6
|
+
import { pathWithin, type Story } from "./StoryPlan.ts"
|
|
7
|
+
|
|
8
|
+
export interface PerimeterCheck {
|
|
9
|
+
/** Changed paths under a `sharedReadOnly` prefix — the worse class, reported first. */
|
|
10
|
+
readonly sharedReadOnly: ReadonlyArray<string>
|
|
11
|
+
/** Changed paths under neither `owned` nor `sharedReadOnly`. */
|
|
12
|
+
readonly outside: ReadonlyArray<string>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const checkPerimeter = (
|
|
16
|
+
changedPaths: ReadonlyArray<string>,
|
|
17
|
+
story: Story
|
|
18
|
+
): PerimeterCheck => {
|
|
19
|
+
const sharedReadOnly: Array<string> = []
|
|
20
|
+
const outside: Array<string> = []
|
|
21
|
+
for (const path of changedPaths) {
|
|
22
|
+
if (story.owned.some((prefix) => pathWithin(path, prefix))) {
|
|
23
|
+
continue
|
|
24
|
+
}
|
|
25
|
+
if (story.sharedReadOnly.some((prefix) => pathWithin(path, prefix))) {
|
|
26
|
+
sharedReadOnly.push(path)
|
|
27
|
+
} else {
|
|
28
|
+
outside.push(path)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return { sharedReadOnly, outside }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const isWithinPerimeter = (check: PerimeterCheck): boolean =>
|
|
35
|
+
check.sharedReadOnly.length === 0 && check.outside.length === 0
|
|
36
|
+
|
|
37
|
+
export const enforcePerimeter = Effect.fn("@llm4ts/flow/Perimeter.enforce")(function* (
|
|
38
|
+
changedPaths: ReadonlyArray<string>,
|
|
39
|
+
story: Story
|
|
40
|
+
): Effect.fn.Return<void, PerimeterViolation> {
|
|
41
|
+
const check = checkPerimeter(changedPaths, story)
|
|
42
|
+
if (!isWithinPerimeter(check)) {
|
|
43
|
+
return yield* PerimeterViolation.make({
|
|
44
|
+
story: story.id,
|
|
45
|
+
outside: check.outside,
|
|
46
|
+
sharedReadOnly: check.sharedReadOnly
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
})
|