@llm4ts/flow 2.7.0 → 2.8.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/Chat.d.ts.map +1 -1
- package/dist/Chat.js +35 -4
- package/dist/Chat.js.map +1 -1
- package/dist/CostLedger.d.ts +2 -2
- package/dist/CostLedger.d.ts.map +1 -1
- package/dist/CostTracker.d.ts +4 -2
- package/dist/CostTracker.d.ts.map +1 -1
- package/dist/CostTracker.js +2 -5
- package/dist/CostTracker.js.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 +17 -1
- package/dist/FlowError.d.ts.map +1 -1
- package/dist/FlowError.js +29 -3
- package/dist/FlowError.js.map +1 -1
- package/dist/FlowEvents.d.ts +16 -0
- package/dist/FlowEvents.d.ts.map +1 -1
- package/dist/FlowEvents.js +25 -1
- package/dist/FlowEvents.js.map +1 -1
- package/dist/FlowRecorder.d.ts +4 -2
- package/dist/FlowRecorder.d.ts.map +1 -1
- package/dist/FlowRecorder.js +2 -5
- package/dist/FlowRecorder.js.map +1 -1
- package/dist/GitTool.d.ts +26 -2
- package/dist/GitTool.d.ts.map +1 -1
- package/dist/GitTool.js +54 -3
- package/dist/GitTool.js.map +1 -1
- package/dist/Judgment.d.ts +2 -2
- package/dist/Judgment.d.ts.map +1 -1
- package/dist/JudgmentLog.d.ts +3 -1
- package/dist/JudgmentLog.d.ts.map +1 -1
- package/dist/JudgmentLog.js +2 -6
- package/dist/JudgmentLog.js.map +1 -1
- package/dist/Perimeter.d.ts +22 -1
- package/dist/Perimeter.d.ts.map +1 -1
- package/dist/Perimeter.js +57 -1
- package/dist/Perimeter.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/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/Stories.d.ts +47 -5
- package/dist/Stories.d.ts.map +1 -1
- package/dist/Stories.js +347 -45
- package/dist/Stories.js.map +1 -1
- package/dist/StoryPlan.d.ts +15 -0
- package/dist/StoryPlan.d.ts.map +1 -1
- package/dist/StoryPlan.js +58 -0
- package/dist/StoryPlan.js.map +1 -1
- package/dist/TransientRetry.d.ts +9 -0
- package/dist/TransientRetry.d.ts.map +1 -1
- package/dist/TransientRetry.js +43 -8
- package/dist/TransientRetry.js.map +1 -1
- package/package.json +2 -2
- package/src/Chat.ts +45 -6
- package/src/CostTracker.ts +5 -14
- package/src/FlowError.ts +33 -3
- package/src/FlowEvents.ts +42 -2
- package/src/FlowRecorder.ts +5 -14
- package/src/GitTool.ts +99 -5
- package/src/JudgmentLog.ts +6 -14
- package/src/Perimeter.ts +85 -1
- package/src/Stories.ts +495 -78
- package/src/StoryPlan.ts +73 -0
- package/src/TransientRetry.ts +97 -8
package/src/FlowError.ts
CHANGED
|
@@ -168,11 +168,40 @@ export class MissingDependency extends Schema.TaggedError<MissingDependency>()(
|
|
|
168
168
|
export class MergeConflict extends Schema.TaggedError<MergeConflict>()("MergeConflict", {
|
|
169
169
|
branch: Schema.String,
|
|
170
170
|
into: Schema.String,
|
|
171
|
-
paths: Schema.Array(Schema.String)
|
|
171
|
+
paths: Schema.Array(Schema.String),
|
|
172
|
+
/** What git said when no path is in conflict — a merge refused before it started. */
|
|
173
|
+
detail: Schema.optionalKey(Schema.String)
|
|
172
174
|
}) {
|
|
173
175
|
get message(): string {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
+
if (this.paths.length > 0) {
|
|
177
|
+
return `merging '${this.branch}' into '${this.into}' conflicted: ${this.paths.join(", ")}`
|
|
178
|
+
}
|
|
179
|
+
const why = this.detail === undefined || this.detail.length === 0 ? "" : `: ${this.detail}`
|
|
180
|
+
return `merging '${this.branch}' into '${this.into}' failed${why}`
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The epic checkout holds changes no story may make there — a coder worked
|
|
186
|
+
* outside its worktree. Every later merge and epic gate would inherit them,
|
|
187
|
+
* so the executor stops launching stories until an operator cleans it.
|
|
188
|
+
*/
|
|
189
|
+
export class EpicCheckoutDirty extends Schema.TaggedError<EpicCheckoutDirty>()(
|
|
190
|
+
"EpicCheckoutDirty",
|
|
191
|
+
{
|
|
192
|
+
checkout: Schema.String,
|
|
193
|
+
paths: Schema.Array(Schema.String),
|
|
194
|
+
/** The story that had just finished when the change was found. */
|
|
195
|
+
story: Schema.optionalKey(Schema.String)
|
|
196
|
+
}
|
|
197
|
+
) {
|
|
198
|
+
get message(): string {
|
|
199
|
+
const suspect = this.story === undefined ? "" : ` (found after story '${this.story}' finished)`
|
|
200
|
+
return [
|
|
201
|
+
`the epic checkout ${this.checkout} has uncommitted changes${suspect} — a coder worked outside its worktree:`,
|
|
202
|
+
...this.paths.map((path) => `- ${path}`),
|
|
203
|
+
"Move each change into the owning story's worktree or discard it, then rerun."
|
|
204
|
+
].join("\n")
|
|
176
205
|
}
|
|
177
206
|
}
|
|
178
207
|
|
|
@@ -240,6 +269,7 @@ export const FlowError = Schema.Union([
|
|
|
240
269
|
PerimeterViolation,
|
|
241
270
|
MissingDependency,
|
|
242
271
|
MergeConflict,
|
|
272
|
+
EpicCheckoutDirty,
|
|
243
273
|
StoryFailed,
|
|
244
274
|
DecisionsInvalid,
|
|
245
275
|
OpenPointsPending,
|
package/src/FlowEvents.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import * as Context from "effect/Context"
|
|
2
|
+
import type * as Duration from "effect/Duration"
|
|
2
3
|
import * as Effect from "effect/Effect"
|
|
4
|
+
import * as Option from "effect/Option"
|
|
3
5
|
import * as Layer from "effect/Layer"
|
|
4
6
|
import * as PubSub from "effect/PubSub"
|
|
5
7
|
import * as Ref from "effect/Ref"
|
|
@@ -177,9 +179,16 @@ export const makeFlowEventHub = Effect.fn("@llm4ts/flow/FlowEvents.makeHub")(fun
|
|
|
177
179
|
const events = yield* PubSub.bounded<FlowEvent>(capacity)
|
|
178
180
|
const published = yield* Ref.make(0)
|
|
179
181
|
return {
|
|
182
|
+
// Counted AFTER delivery, and only when the PubSub accepted the event.
|
|
183
|
+
// Tallying first meant a publish interrupted while backpressured (a
|
|
184
|
+
// bounded PubSub suspends when a subscriber is behind) left `published`
|
|
185
|
+
// permanently above anything a consumer could ever reach, and every
|
|
186
|
+
// drain waiting on it never finished.
|
|
180
187
|
publish: (event) =>
|
|
181
|
-
|
|
182
|
-
Effect.
|
|
188
|
+
PubSub.publish(events, event).pipe(
|
|
189
|
+
Effect.flatMap((accepted) =>
|
|
190
|
+
accepted ? Ref.update(published, (count) => count + 1) : Effect.void
|
|
191
|
+
),
|
|
183
192
|
Effect.asVoid
|
|
184
193
|
),
|
|
185
194
|
subscribe: PubSub.subscribe(events),
|
|
@@ -187,3 +196,34 @@ export const makeFlowEventHub = Effect.fn("@llm4ts/flow/FlowEvents.makeHub")(fun
|
|
|
187
196
|
publishedCount: Ref.get(published)
|
|
188
197
|
}
|
|
189
198
|
})
|
|
199
|
+
|
|
200
|
+
/** How long a consumer waits to catch up before the run moves on regardless. */
|
|
201
|
+
export const defaultDrainTimeout = "3 seconds"
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The shared drain: wait until `consumed` has caught up with everything the
|
|
205
|
+
* hub published, and report whether it did.
|
|
206
|
+
*
|
|
207
|
+
* Bounded on purpose, and the single copy of this loop. A consumer can fall
|
|
208
|
+
* permanently short of the count — a subscription torn down early, an
|
|
209
|
+
* event the PubSub refused — and an unbounded wait then pegs a core forever.
|
|
210
|
+
* In the runner that swallowed the end of a run: every stage printed and
|
|
211
|
+
* ticked green, and no cost summary ever following, because the summary is
|
|
212
|
+
* written only once every consumer reports drained.
|
|
213
|
+
*/
|
|
214
|
+
export const awaitConsumed = (
|
|
215
|
+
hub: FlowEventHub,
|
|
216
|
+
consumed: Ref.Ref<number>,
|
|
217
|
+
timeout: Duration.Input = defaultDrainTimeout
|
|
218
|
+
): Effect.Effect<boolean> =>
|
|
219
|
+
Effect.gen(function* () {
|
|
220
|
+
const target = yield* hub.publishedCount
|
|
221
|
+
const drain: Effect.Effect<void> = Effect.suspend(() =>
|
|
222
|
+
Ref.get(consumed).pipe(
|
|
223
|
+
Effect.flatMap((count) =>
|
|
224
|
+
count >= target ? Effect.void : Effect.yieldNow.pipe(Effect.andThen(drain))
|
|
225
|
+
)
|
|
226
|
+
)
|
|
227
|
+
)
|
|
228
|
+
return Option.isSome(yield* Effect.timeoutOption(drain, timeout))
|
|
229
|
+
})
|
package/src/FlowRecorder.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as Clock from "effect/Clock"
|
|
2
|
+
import type * as Duration from "effect/Duration"
|
|
2
3
|
import * as Effect from "effect/Effect"
|
|
3
4
|
import * as Ref from "effect/Ref"
|
|
4
5
|
import * as Schema from "effect/Schema"
|
|
@@ -7,7 +8,7 @@ import type * as Scope from "effect/Scope"
|
|
|
7
8
|
import * as Stream from "effect/Stream"
|
|
8
9
|
import type { StreamRecorder } from "@llm4ts/core/observability/StreamRecorder"
|
|
9
10
|
import type { JsonValue } from "@llm4ts/core/providers/CliSupport"
|
|
10
|
-
import type
|
|
11
|
+
import { awaitConsumed, type FlowEvent, type FlowEventHub } from "./FlowEvents.ts"
|
|
11
12
|
import type { PlainFileStoreShape } from "./Persistence.ts"
|
|
12
13
|
|
|
13
14
|
export class TraceLine extends Schema.Class<TraceLine>("TraceLine")({
|
|
@@ -24,7 +25,8 @@ export interface FlowRecorderShape extends StreamRecorder {
|
|
|
24
25
|
readonly tracePath: string
|
|
25
26
|
readonly record: (event: FlowEvent) => Effect.Effect<void>
|
|
26
27
|
readonly consume: (hub: FlowEventHub) => Effect.Effect<void, never, Scope.Scope>
|
|
27
|
-
|
|
28
|
+
/** Waits for this recorder to catch up; `false` means it gave up first. */
|
|
29
|
+
readonly awaitDrained: (hub: FlowEventHub, timeout?: Duration.Input) => Effect.Effect<boolean>
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
const encode = (line: TraceLine): string =>
|
|
@@ -99,18 +101,7 @@ export const makeFlowRecorder = Effect.fn("@llm4ts/flow/FlowRecorder.make")(func
|
|
|
99
101
|
Effect.asVoid
|
|
100
102
|
)
|
|
101
103
|
}),
|
|
102
|
-
awaitDrained: (hub) =>
|
|
103
|
-
Effect.gen(function* () {
|
|
104
|
-
const target = yield* hub.publishedCount
|
|
105
|
-
const drain: Effect.Effect<void> = Effect.suspend(() =>
|
|
106
|
-
Ref.get(consumed).pipe(
|
|
107
|
-
Effect.flatMap((count) =>
|
|
108
|
-
count >= target ? Effect.void : Effect.yieldNow.pipe(Effect.andThen(drain))
|
|
109
|
-
)
|
|
110
|
-
)
|
|
111
|
-
)
|
|
112
|
-
yield* drain
|
|
113
|
-
})
|
|
104
|
+
awaitDrained: (hub, timeout) => awaitConsumed(hub, consumed, timeout)
|
|
114
105
|
}
|
|
115
106
|
return recorder
|
|
116
107
|
})
|
package/src/GitTool.ts
CHANGED
|
@@ -24,6 +24,11 @@ export interface GitToolShape {
|
|
|
24
24
|
readonly initBare: Effect.Effect<void, FlowError>
|
|
25
25
|
readonly config: (key: string, value: string) => Effect.Effect<void, FlowError>
|
|
26
26
|
readonly status: Effect.Effect<string, FlowError>
|
|
27
|
+
/**
|
|
28
|
+
* Every path with uncommitted changes, untracked files listed one by one
|
|
29
|
+
* (not collapsed into their directory), minus the runner's `.llm4ts/`.
|
|
30
|
+
*/
|
|
31
|
+
readonly uncommittedFiles: Effect.Effect<ReadonlyArray<string>, FlowError>
|
|
27
32
|
readonly currentBranch: Effect.Effect<string, FlowError>
|
|
28
33
|
readonly diff: Effect.Effect<string, FlowError>
|
|
29
34
|
readonly diffAll: Effect.Effect<string, FlowError>
|
|
@@ -66,6 +71,16 @@ export interface GitToolShape {
|
|
|
66
71
|
) => Effect.Effect<void, FlowError>
|
|
67
72
|
/** `force` also removes a worktree holding untracked or modified files. */
|
|
68
73
|
readonly removeWorktree: (path: string, force?: boolean) => Effect.Effect<void, FlowError>
|
|
74
|
+
/** Moves an existing worktree to `to`, keeping its branch and uncommitted work. */
|
|
75
|
+
readonly moveWorktree: (from: string, to: string) => Effect.Effect<void, FlowError>
|
|
76
|
+
/**
|
|
77
|
+
* Puts `paths` back as they are on `source`: a path `source` has is checked
|
|
78
|
+
* out from it, a path it lacks is deleted (tracked or not). Uncommitted.
|
|
79
|
+
*/
|
|
80
|
+
readonly restorePaths: (
|
|
81
|
+
source: string,
|
|
82
|
+
paths: ReadonlyArray<string>
|
|
83
|
+
) => Effect.Effect<void, FlowError>
|
|
69
84
|
readonly branchExists: (name: string) => Effect.Effect<boolean, FlowError>
|
|
70
85
|
readonly deleteBranch: (name: string) => Effect.Effect<void, FlowError>
|
|
71
86
|
/** Whether `commit` is reachable from `of` — a merged story branch is an ancestor of the epic head. */
|
|
@@ -73,11 +88,41 @@ export interface GitToolShape {
|
|
|
73
88
|
/**
|
|
74
89
|
* Merges `branch` into the checked-out branch with a merge commit. A
|
|
75
90
|
* 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.
|
|
91
|
+
* was (the merge is aborted), so the next merge can proceed. A merge git
|
|
92
|
+
* refuses before it starts (a dirty tree) fails typed with git's words.
|
|
93
|
+
* `preferIncoming` resolves conflicting hunks to `branch`'s side
|
|
94
|
+
* (`-X theirs`): the catch-up of a story branch with its epic, where the
|
|
95
|
+
* epic side is authoritative for every path the story does not own.
|
|
77
96
|
*/
|
|
78
|
-
readonly merge: (
|
|
97
|
+
readonly merge: (
|
|
98
|
+
branch: string,
|
|
99
|
+
message: string,
|
|
100
|
+
options?: MergeOptions
|
|
101
|
+
) => Effect.Effect<void, FlowError>
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface MergeOptions {
|
|
105
|
+
readonly preferIncoming?: boolean
|
|
79
106
|
}
|
|
80
107
|
|
|
108
|
+
/**
|
|
109
|
+
* The paths `git status --short` reports, minus the runner's own state under
|
|
110
|
+
* `.llm4ts/`. A rename reports its destination.
|
|
111
|
+
*/
|
|
112
|
+
export const statusPaths = (status: string): ReadonlyArray<string> =>
|
|
113
|
+
status
|
|
114
|
+
.split(/\r?\n/)
|
|
115
|
+
.filter((line) => line.trim().length > 0)
|
|
116
|
+
.map((line) => {
|
|
117
|
+
// `status` trims its output, so the first line may have lost the
|
|
118
|
+
// leading blank of its two-letter code: match the code, not a column.
|
|
119
|
+
const path = (/^[ MADRCTU?!]{1,2}\s+(.+)$/.exec(line)?.[1] ?? "").trim()
|
|
120
|
+
const arrow = path.indexOf(" -> ")
|
|
121
|
+
const target = arrow < 0 ? path : path.slice(arrow + 4)
|
|
122
|
+
return target.replace(/^"|"$/g, "")
|
|
123
|
+
})
|
|
124
|
+
.filter((path) => path.length > 0 && !path.startsWith(".llm4ts/") && path !== ".llm4ts")
|
|
125
|
+
|
|
81
126
|
const nonInteractiveEnvironment = Object.freeze({
|
|
82
127
|
GIT_TERMINAL_PROMPT: "0",
|
|
83
128
|
GIT_SSH_COMMAND: "ssh -o BatchMode=yes"
|
|
@@ -265,6 +310,10 @@ export const makeGitTool = (
|
|
|
265
310
|
config: (key, value) =>
|
|
266
311
|
write("git config", runOrFail(["config", key, value]).pipe(Effect.asVoid)),
|
|
267
312
|
status: read("git status", runOrFail(["status", "--short"])),
|
|
313
|
+
uncommittedFiles: read(
|
|
314
|
+
"git uncommittedFiles",
|
|
315
|
+
runOrFail(["status", "--porcelain", "--untracked-files=all"]).pipe(Effect.map(statusPaths))
|
|
316
|
+
),
|
|
268
317
|
currentBranch: read("git currentBranch", runOrFail(["rev-parse", "--abbrev-ref", "HEAD"])),
|
|
269
318
|
diff: read("git diff", runOrFail(["diff"])),
|
|
270
319
|
diffAll: read(
|
|
@@ -328,6 +377,39 @@ export const makeGitTool = (
|
|
|
328
377
|
"git worktree add -b",
|
|
329
378
|
runOrFail(["worktree", "add", "-b", branch, path, startPoint]).pipe(Effect.asVoid)
|
|
330
379
|
),
|
|
380
|
+
moveWorktree: (from, to) =>
|
|
381
|
+
write(
|
|
382
|
+
"git worktree move",
|
|
383
|
+
Effect.gen(function* () {
|
|
384
|
+
// git creates a new worktree's parents but not a moved one's.
|
|
385
|
+
const parent = to.replace(/[\\/]+$/, "").replace(/[\\/][^\\/]*$/, "")
|
|
386
|
+
if (parent.length > 0 && parent !== to) {
|
|
387
|
+
yield* process
|
|
388
|
+
.run(["mkdir", "-p", parent], workDir, nonInteractiveEnvironment)
|
|
389
|
+
.pipe(
|
|
390
|
+
Effect.mapError((error) =>
|
|
391
|
+
ProcessError.make({ message: `mkdir -p ${parent}`, detail: error.message })
|
|
392
|
+
)
|
|
393
|
+
)
|
|
394
|
+
}
|
|
395
|
+
yield* runOrFail(["worktree", "move", from, to])
|
|
396
|
+
})
|
|
397
|
+
),
|
|
398
|
+
restorePaths: (source, paths) =>
|
|
399
|
+
write(
|
|
400
|
+
"git restorePaths",
|
|
401
|
+
Effect.gen(function* () {
|
|
402
|
+
for (const path of paths) {
|
|
403
|
+
const known = yield* run(["cat-file", "-e", `${source}:${path}`])
|
|
404
|
+
if (known.exitCode === 0) {
|
|
405
|
+
yield* runOrFail(["checkout", source, "--", path])
|
|
406
|
+
} else {
|
|
407
|
+
yield* runOrFail(["rm", "-r", "-f", "-q", "--ignore-unmatch", "--", path])
|
|
408
|
+
yield* runOrFail(["clean", "-f", "-d", "-q", "--", path])
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
})
|
|
412
|
+
),
|
|
331
413
|
removeWorktree: (path, force = false) =>
|
|
332
414
|
write(
|
|
333
415
|
"git worktree remove",
|
|
@@ -356,11 +438,18 @@ export const makeGitTool = (
|
|
|
356
438
|
)
|
|
357
439
|
)
|
|
358
440
|
),
|
|
359
|
-
merge: (branch, message) =>
|
|
441
|
+
merge: (branch, message, options = {}) =>
|
|
360
442
|
write(
|
|
361
443
|
"git merge",
|
|
362
444
|
Effect.gen(function* () {
|
|
363
|
-
const result = yield* run([
|
|
445
|
+
const result = yield* run([
|
|
446
|
+
"merge",
|
|
447
|
+
"--no-ff",
|
|
448
|
+
...(options.preferIncoming === true ? ["-X", "theirs"] : []),
|
|
449
|
+
"-m",
|
|
450
|
+
message,
|
|
451
|
+
branch
|
|
452
|
+
])
|
|
364
453
|
if (result.exitCode === 0) {
|
|
365
454
|
return
|
|
366
455
|
}
|
|
@@ -374,7 +463,12 @@ export const makeGitTool = (
|
|
|
374
463
|
// next story; a merge that failed before starting has nothing to
|
|
375
464
|
// abort and git says so, which is not a second failure.
|
|
376
465
|
yield* run(["merge", "--abort"])
|
|
377
|
-
return yield* MergeConflict.make({
|
|
466
|
+
return yield* MergeConflict.make({
|
|
467
|
+
branch,
|
|
468
|
+
into,
|
|
469
|
+
paths,
|
|
470
|
+
...(paths.length === 0 ? { detail: problem(result) } : {})
|
|
471
|
+
})
|
|
378
472
|
})
|
|
379
473
|
)
|
|
380
474
|
}
|
package/src/JudgmentLog.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import * as Clock from "effect/Clock"
|
|
2
|
+
import type * as Duration from "effect/Duration"
|
|
2
3
|
import * as Effect from "effect/Effect"
|
|
3
4
|
import * as Ref from "effect/Ref"
|
|
4
5
|
import * as Schema from "effect/Schema"
|
|
5
6
|
import * as Semaphore from "effect/Semaphore"
|
|
6
7
|
import type * as Scope from "effect/Scope"
|
|
7
8
|
import * as Stream from "effect/Stream"
|
|
8
|
-
import { JudgmentObserved, type FlowEvent, type FlowEventHub } from "./FlowEvents.ts"
|
|
9
|
+
import { awaitConsumed, JudgmentObserved, type FlowEvent, type FlowEventHub } from "./FlowEvents.ts"
|
|
9
10
|
import type { PlainFileStoreShape } from "./Persistence.ts"
|
|
10
11
|
|
|
11
12
|
export class JudgmentObservation extends Schema.Class<JudgmentObservation>("JudgmentObservation")({
|
|
@@ -29,7 +30,8 @@ export const judgmentLogPath = (root: string, consumer: JudgmentObservation["con
|
|
|
29
30
|
export interface JudgmentLogShape {
|
|
30
31
|
readonly record: (event: FlowEvent) => Effect.Effect<void>
|
|
31
32
|
readonly consume: (hub: FlowEventHub) => Effect.Effect<void, never, Scope.Scope>
|
|
32
|
-
|
|
33
|
+
/** Waits for this log to catch up; `false` means it gave up first. */
|
|
34
|
+
readonly awaitDrained: (hub: FlowEventHub, timeout?: Duration.Input) => Effect.Effect<boolean>
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
const codec = Schema.fromJsonString(JudgmentObservation)
|
|
@@ -63,18 +65,8 @@ export const makeJudgmentLog = Effect.fn("@llm4ts/flow/JudgmentLog.make")(functi
|
|
|
63
65
|
}).pipe(Effect.catch(() => Ref.set(degraded, true)))
|
|
64
66
|
)
|
|
65
67
|
|
|
66
|
-
const awaitDrained = (hub: FlowEventHub): Effect.Effect<
|
|
67
|
-
|
|
68
|
-
const target = yield* hub.publishedCount
|
|
69
|
-
const drain: Effect.Effect<void> = Effect.suspend(() =>
|
|
70
|
-
Ref.get(consumed).pipe(
|
|
71
|
-
Effect.flatMap((count) =>
|
|
72
|
-
count >= target ? Effect.void : Effect.yieldNow.pipe(Effect.andThen(drain))
|
|
73
|
-
)
|
|
74
|
-
)
|
|
75
|
-
)
|
|
76
|
-
yield* drain
|
|
77
|
-
})
|
|
68
|
+
const awaitDrained = (hub: FlowEventHub, timeout?: Duration.Input): Effect.Effect<boolean> =>
|
|
69
|
+
awaitConsumed(hub, consumed, timeout)
|
|
78
70
|
|
|
79
71
|
return {
|
|
80
72
|
record,
|
package/src/Perimeter.ts
CHANGED
|
@@ -3,7 +3,16 @@
|
|
|
3
3
|
// the prompt states the rule, this is what makes it true.
|
|
4
4
|
import * as Effect from "effect/Effect"
|
|
5
5
|
import { PerimeterViolation } from "./FlowError.ts"
|
|
6
|
-
import {
|
|
6
|
+
import type { Task } from "./Plan.ts"
|
|
7
|
+
import { ReviewIssue, ReviewResult } from "./Review.ts"
|
|
8
|
+
import {
|
|
9
|
+
normalizePath,
|
|
10
|
+
ownerOf,
|
|
11
|
+
pathWithin,
|
|
12
|
+
pathsNamedIn,
|
|
13
|
+
type Story,
|
|
14
|
+
type StoryPlan
|
|
15
|
+
} from "./StoryPlan.ts"
|
|
7
16
|
|
|
8
17
|
export interface PerimeterCheck {
|
|
9
18
|
/** Changed paths under a `sharedReadOnly` prefix — the worse class, reported first. */
|
|
@@ -47,3 +56,78 @@ export const enforcePerimeter = Effect.fn("@llm4ts/flow/Perimeter.enforce")(func
|
|
|
47
56
|
})
|
|
48
57
|
}
|
|
49
58
|
})
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The perimeter as a gate result: a violation is one Critical issue whose
|
|
62
|
+
* text names every stray path, so the task review loop hands it back to the
|
|
63
|
+
* coder to revert before anything is committed.
|
|
64
|
+
*/
|
|
65
|
+
export const perimeterGate = (changedPaths: ReadonlyArray<string>, story: Story): ReviewResult => {
|
|
66
|
+
const check = checkPerimeter(changedPaths, story)
|
|
67
|
+
if (isWithinPerimeter(check)) {
|
|
68
|
+
return ReviewResult.make({ issues: [], summary: "perimeter: clean" })
|
|
69
|
+
}
|
|
70
|
+
const violation = PerimeterViolation.make({
|
|
71
|
+
story: story.id,
|
|
72
|
+
outside: check.outside,
|
|
73
|
+
sharedReadOnly: check.sharedReadOnly
|
|
74
|
+
})
|
|
75
|
+
return ReviewResult.make({
|
|
76
|
+
issues: [
|
|
77
|
+
ReviewIssue.make({
|
|
78
|
+
severity: "Critical",
|
|
79
|
+
title: "perimeter",
|
|
80
|
+
description: [
|
|
81
|
+
violation.message,
|
|
82
|
+
"Revert these paths (restore a changed file from the epic branch, delete a file you",
|
|
83
|
+
"created there) and keep the work inside your owned paths."
|
|
84
|
+
].join("\n")
|
|
85
|
+
})
|
|
86
|
+
],
|
|
87
|
+
summary: "perimeter: violated"
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A planned task that names paths outside its story's perimeter. */
|
|
92
|
+
export interface StrayTask {
|
|
93
|
+
readonly task: Task
|
|
94
|
+
readonly paths: ReadonlyArray<string>
|
|
95
|
+
/** Some of `paths` are owned by another story — the task is that story's work. */
|
|
96
|
+
readonly foreign: boolean
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const fileLike = /\.[A-Za-z0-9]{1,5}$/
|
|
100
|
+
|
|
101
|
+
const allowed = (story: Story, path: string): boolean =>
|
|
102
|
+
story.owned.some((prefix) => pathWithin(path, prefix)) ||
|
|
103
|
+
story.sharedReadOnly.some((prefix) => pathWithin(path, prefix)) ||
|
|
104
|
+
// A bare directory above an owned path ("inside src/features/conto") is a
|
|
105
|
+
// location, not a target.
|
|
106
|
+
(!fileLike.test(normalizePath(path)) && story.owned.some((prefix) => pathWithin(prefix, path)))
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The incomplete tasks of a story's plan that name paths the story may not
|
|
110
|
+
* touch. Checked before any coder turn is spent on them: a task such as
|
|
111
|
+
* "register the screen in src/App.tsx" is another story's job.
|
|
112
|
+
*/
|
|
113
|
+
export const strayTasks = (
|
|
114
|
+
plan: StoryPlan,
|
|
115
|
+
story: Story,
|
|
116
|
+
tasks: ReadonlyArray<Task>
|
|
117
|
+
): ReadonlyArray<StrayTask> =>
|
|
118
|
+
tasks.flatMap((task) => {
|
|
119
|
+
if (task.completed) {
|
|
120
|
+
return []
|
|
121
|
+
}
|
|
122
|
+
const paths = pathsNamedIn(plan, `${task.title}\n${task.description}`).filter(
|
|
123
|
+
(path) => !allowed(story, path)
|
|
124
|
+
)
|
|
125
|
+
if (paths.length === 0) {
|
|
126
|
+
return []
|
|
127
|
+
}
|
|
128
|
+
const foreign = paths.some((path) => {
|
|
129
|
+
const owner = ownerOf(plan, path)
|
|
130
|
+
return owner !== undefined && owner.id !== story.id
|
|
131
|
+
})
|
|
132
|
+
return [{ task, paths, foreign }]
|
|
133
|
+
})
|