@llm4ts/flow 2.18.0 → 2.20.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/EpicRuns.d.ts +26 -0
- package/dist/EpicRuns.d.ts.map +1 -0
- package/dist/EpicRuns.js +28 -0
- package/dist/EpicRuns.js.map +1 -0
- 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 +5 -0
- package/dist/FlowContext.d.ts.map +1 -1
- package/dist/FlowContext.js.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/FlowEvents.d.ts +75 -1
- package/dist/FlowEvents.d.ts.map +1 -1
- package/dist/FlowEvents.js +92 -1
- package/dist/FlowEvents.js.map +1 -1
- package/dist/FlowRecorder.d.ts +5 -0
- package/dist/FlowRecorder.d.ts.map +1 -1
- package/dist/FlowRecorder.js +3 -0
- package/dist/FlowRecorder.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/Roster.js +14 -12
- package/dist/Roster.js.map +1 -1
- package/dist/RosterSeats.d.ts +1 -1
- package/dist/RosterSeats.d.ts.map +1 -1
- package/dist/RosterSeats.js +21 -5
- package/dist/RosterSeats.js.map +1 -1
- package/dist/Stories.d.ts +13 -1
- package/dist/Stories.d.ts.map +1 -1
- package/dist/Stories.js +15 -1
- package/dist/Stories.js.map +1 -1
- package/package.json +4 -2
- package/src/EpicRuns.ts +45 -0
- package/src/FlowContext.ts +2 -0
- package/src/FlowError.ts +12 -1
- package/src/FlowEvents.ts +107 -1
- package/src/FlowRecorder.ts +7 -0
- package/src/Landing.ts +24 -6
- package/src/RefineRound.ts +227 -0
- package/src/Roster.ts +32 -16
- package/src/RosterSeats.ts +24 -13
- package/src/Stories.ts +15 -1
package/src/FlowEvents.ts
CHANGED
|
@@ -181,6 +181,106 @@ export class BlackboardRun extends Schema.TaggedClass<BlackboardRun>()("Blackboa
|
|
|
181
181
|
result: RunResult
|
|
182
182
|
}) {}
|
|
183
183
|
|
|
184
|
+
/** One rubric dimension of a story verdict: its score out of `max`. */
|
|
185
|
+
export const JudgedDimension = Schema.Struct({
|
|
186
|
+
id: Schema.String,
|
|
187
|
+
score: Schema.Number,
|
|
188
|
+
max: Schema.Number
|
|
189
|
+
})
|
|
190
|
+
export type JudgedDimension = typeof JudgedDimension.Type
|
|
191
|
+
|
|
192
|
+
/** The story judge ruled on a story's branch (ADR 0022): one event per judge round. */
|
|
193
|
+
export class StoryJudged extends Schema.TaggedClass<StoryJudged>()("StoryJudged", {
|
|
194
|
+
lane: Schema.String,
|
|
195
|
+
round: Schema.Int,
|
|
196
|
+
cleared: Schema.Boolean,
|
|
197
|
+
/** Issues the coder gets back when it is not cleared. */
|
|
198
|
+
issues: Schema.Int,
|
|
199
|
+
/** The rubric's scores, when the judge reports them. */
|
|
200
|
+
dimensions: Schema.Array(JudgedDimension)
|
|
201
|
+
}) {}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The executor roster's events (ADR 0019, 0022). `label` names who holds the
|
|
205
|
+
* lease — a story's id, or "the run" — so a view can put an executor on its lane.
|
|
206
|
+
*/
|
|
207
|
+
export class ExecutorLeased extends Schema.TaggedClass<ExecutorLeased>()("ExecutorLeased", {
|
|
208
|
+
executor: Schema.String,
|
|
209
|
+
role: Schema.String,
|
|
210
|
+
label: Schema.optionalKey(Schema.String),
|
|
211
|
+
/** Took the role on its own context's coder slot: nobody independent could. */
|
|
212
|
+
borrowed: Schema.optionalKey(Schema.Boolean)
|
|
213
|
+
}) {}
|
|
214
|
+
|
|
215
|
+
export class ExecutorReleased extends Schema.TaggedClass<ExecutorReleased>()("ExecutorReleased", {
|
|
216
|
+
executor: Schema.String,
|
|
217
|
+
role: Schema.String,
|
|
218
|
+
label: Schema.optionalKey(Schema.String)
|
|
219
|
+
}) {}
|
|
220
|
+
|
|
221
|
+
export class ExecutorExcluded extends Schema.TaggedClass<ExecutorExcluded>()("ExecutorExcluded", {
|
|
222
|
+
executor: Schema.String,
|
|
223
|
+
/** How long and why, as the roster describes it ("for this run: not logged in"). */
|
|
224
|
+
reason: Schema.String
|
|
225
|
+
}) {}
|
|
226
|
+
|
|
227
|
+
export class ExecutorResumed extends Schema.TaggedClass<ExecutorResumed>()("ExecutorResumed", {
|
|
228
|
+
executor: Schema.String,
|
|
229
|
+
/** `expired`: its exclusion ran out; `health`: its engine answers again; `resumed`: by hand. */
|
|
230
|
+
why: Schema.Literals(["expired", "health", "resumed"])
|
|
231
|
+
}) {}
|
|
232
|
+
|
|
233
|
+
export class ExecutorHandedOver extends Schema.TaggedClass<ExecutorHandedOver>()(
|
|
234
|
+
"ExecutorHandedOver",
|
|
235
|
+
{
|
|
236
|
+
from: Schema.String,
|
|
237
|
+
role: Schema.String,
|
|
238
|
+
label: Schema.optionalKey(Schema.String),
|
|
239
|
+
reason: Schema.String,
|
|
240
|
+
/** `call`: one call moves to another executor; `coder`: a context's coder changes hands. */
|
|
241
|
+
scope: Schema.Literals(["call", "coder"])
|
|
242
|
+
}
|
|
243
|
+
) {}
|
|
244
|
+
|
|
245
|
+
export type RosterEvent =
|
|
246
|
+
| ExecutorLeased
|
|
247
|
+
| ExecutorReleased
|
|
248
|
+
| ExecutorExcluded
|
|
249
|
+
| ExecutorResumed
|
|
250
|
+
| ExecutorHandedOver
|
|
251
|
+
|
|
252
|
+
const resumedWords: Readonly<Record<ExecutorResumed["why"], string>> = {
|
|
253
|
+
expired: "back in the round",
|
|
254
|
+
health: "answers its health check again; back in the round",
|
|
255
|
+
resumed: "resumed"
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The line the classic terminal shows for a roster event — the same words
|
|
260
|
+
* the roster used to publish as `Info` — or `undefined` for one it does not
|
|
261
|
+
* show (a release) and for any other event.
|
|
262
|
+
*/
|
|
263
|
+
export const rosterEventMessage = (event: FlowEvent): string | undefined => {
|
|
264
|
+
const forLabel = (label: string | undefined): string =>
|
|
265
|
+
label === undefined ? "" : ` for ${label}`
|
|
266
|
+
switch (event._tag) {
|
|
267
|
+
case "ExecutorLeased":
|
|
268
|
+
return event.borrowed === true
|
|
269
|
+
? `roster: ${event.executor} takes ${event.role}${forLabel(event.label)} on its own coder's slot — not independent (no other executor can take ${event.role})`
|
|
270
|
+
: `roster: ${event.executor} takes ${event.role}${forLabel(event.label)}`
|
|
271
|
+
case "ExecutorExcluded":
|
|
272
|
+
return `roster: ${event.executor} out of the round ${event.reason}`
|
|
273
|
+
case "ExecutorResumed":
|
|
274
|
+
return `roster: ${event.executor} ${resumedWords[event.why]}`
|
|
275
|
+
case "ExecutorHandedOver":
|
|
276
|
+
return event.scope === "coder"
|
|
277
|
+
? `roster: ${event.label ?? "the run"}: handing the coder over from ${event.from} (${event.reason})`
|
|
278
|
+
: `roster: ${event.role}${forLabel(event.label)} moves off ${event.from} (${event.reason})`
|
|
279
|
+
default:
|
|
280
|
+
return undefined
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
184
284
|
export const FlowEvent = Schema.Union([
|
|
185
285
|
JudgmentObserved,
|
|
186
286
|
BlackboardRun,
|
|
@@ -197,7 +297,13 @@ export const FlowEvent = Schema.Union([
|
|
|
197
297
|
CapabilityUsedEvent,
|
|
198
298
|
CapabilityDeniedEvent,
|
|
199
299
|
CapabilityUnenforceable,
|
|
200
|
-
Declassified
|
|
300
|
+
Declassified,
|
|
301
|
+
ExecutorLeased,
|
|
302
|
+
ExecutorReleased,
|
|
303
|
+
ExecutorExcluded,
|
|
304
|
+
ExecutorResumed,
|
|
305
|
+
ExecutorHandedOver,
|
|
306
|
+
StoryJudged
|
|
201
307
|
])
|
|
202
308
|
export type FlowEvent = typeof FlowEvent.Type
|
|
203
309
|
|
package/src/FlowRecorder.ts
CHANGED
|
@@ -20,11 +20,17 @@ export class TraceLine extends Schema.Class<TraceLine>("TraceLine")({
|
|
|
20
20
|
fields: Schema.Record(Schema.String, Schema.Json)
|
|
21
21
|
}) {}
|
|
22
22
|
|
|
23
|
+
/** How a run ended, as its trace's last line says (ADR 0022). */
|
|
24
|
+
export const RunOutcome = Schema.Literals(["completed", "failed", "interrupted"])
|
|
25
|
+
export type RunOutcome = typeof RunOutcome.Type
|
|
26
|
+
|
|
23
27
|
export interface FlowRecorderShape extends StreamRecorder {
|
|
24
28
|
readonly runId: string
|
|
25
29
|
readonly tracePath: string
|
|
26
30
|
readonly record: (event: FlowEvent) => Effect.Effect<void>
|
|
27
31
|
readonly consume: (hub: FlowEventHub) => Effect.Effect<void, never, Scope.Scope>
|
|
32
|
+
/** Appends the `RunEnded` line: a reader knows the run is over, not stalled. */
|
|
33
|
+
readonly end: (outcome: RunOutcome) => Effect.Effect<void>
|
|
28
34
|
/** Waits for this recorder to catch up; `false` means it gave up first. */
|
|
29
35
|
readonly awaitDrained: (hub: FlowEventHub, timeout?: Duration.Input) => Effect.Effect<boolean>
|
|
30
36
|
}
|
|
@@ -104,6 +110,7 @@ export const makeFlowRecorder = Effect.fn("@llm4ts/flow/FlowRecorder.make")(func
|
|
|
104
110
|
Effect.asVoid
|
|
105
111
|
)
|
|
106
112
|
}),
|
|
113
|
+
end: (outcome) => append("RunEnded", { outcome }),
|
|
107
114
|
awaitDrained: (hub, timeout) => awaitConsumed(hub, consumed, timeout)
|
|
108
115
|
}
|
|
109
116
|
return recorder
|
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
|
+
)
|
package/src/Roster.ts
CHANGED
|
@@ -14,7 +14,14 @@ import type * as Scope from "effect/Scope"
|
|
|
14
14
|
import * as Semaphore from "effect/Semaphore"
|
|
15
15
|
import type { LlmError } from "@llm4ts/core/Errors"
|
|
16
16
|
import { RosterExhausted, type FlowError } from "./FlowError.ts"
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
ExecutorExcluded,
|
|
19
|
+
ExecutorLeased,
|
|
20
|
+
ExecutorReleased,
|
|
21
|
+
ExecutorResumed,
|
|
22
|
+
Info,
|
|
23
|
+
type FlowEventsShape
|
|
24
|
+
} from "./FlowEvents.ts"
|
|
18
25
|
import { loadVersioned, saveVersioned, type PlainFileStoreShape } from "./Persistence.ts"
|
|
19
26
|
import { isOutage } from "./TransientRetry.ts"
|
|
20
27
|
|
|
@@ -541,11 +548,22 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
|
|
|
541
548
|
)
|
|
542
549
|
.pipe(Effect.andThen(signal))
|
|
543
550
|
|
|
544
|
-
const leaseOf = (
|
|
551
|
+
const leaseOf = (
|
|
552
|
+
spec: ExecutorSpec,
|
|
553
|
+
role: Role,
|
|
554
|
+
label: string | undefined
|
|
555
|
+
): Effect.Effect<Lease, never, Scope.Scope> =>
|
|
545
556
|
Effect.gen(function* () {
|
|
557
|
+
const labelled = label === undefined ? {} : { label }
|
|
558
|
+
yield* events.publish(ExecutorLeased.make({ executor: spec.id, role, ...labelled }))
|
|
546
559
|
const done = yield* Ref.make(false)
|
|
547
560
|
const free = Effect.flatMap(Ref.getAndSet(done, true), (was) =>
|
|
548
|
-
was
|
|
561
|
+
was
|
|
562
|
+
? Effect.void
|
|
563
|
+
: Effect.andThen(
|
|
564
|
+
release(spec, role),
|
|
565
|
+
events.publish(ExecutorReleased.make({ executor: spec.id, role, ...labelled }))
|
|
566
|
+
)
|
|
549
567
|
)
|
|
550
568
|
yield* Effect.addFinalizer(() => free)
|
|
551
569
|
return { executor: spec, role, release: free }
|
|
@@ -577,7 +595,9 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
|
|
|
577
595
|
})
|
|
578
596
|
)
|
|
579
597
|
if (back.length > 0) {
|
|
580
|
-
yield*
|
|
598
|
+
yield* Effect.forEach(back, (id) =>
|
|
599
|
+
events.publish(ExecutorResumed.make({ executor: id, why: "expired" }))
|
|
600
|
+
)
|
|
581
601
|
yield* persist
|
|
582
602
|
}
|
|
583
603
|
return spec
|
|
@@ -638,7 +658,7 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
|
|
|
638
658
|
})
|
|
639
659
|
)
|
|
640
660
|
if (up) {
|
|
641
|
-
yield*
|
|
661
|
+
yield* events.publish(ExecutorResumed.make({ executor: exclusion.id, why: "health" }))
|
|
642
662
|
yield* persist
|
|
643
663
|
yield* signal
|
|
644
664
|
}
|
|
@@ -693,10 +713,7 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
|
|
|
693
713
|
while (true) {
|
|
694
714
|
const spec = yield* attempt(role, leaseOptions)
|
|
695
715
|
if (spec !== undefined) {
|
|
696
|
-
yield*
|
|
697
|
-
`${spec.id} takes ${role}${leaseOptions.label === undefined ? "" : ` for ${leaseOptions.label}`}`
|
|
698
|
-
)
|
|
699
|
-
return yield* leaseOf(spec, role)
|
|
716
|
+
return yield* leaseOf(spec, role, leaseOptions.label)
|
|
700
717
|
}
|
|
701
718
|
if (!(yield* canEverServe(role, avoid))) {
|
|
702
719
|
const reasons = yield* lock.withPermit(
|
|
@@ -723,10 +740,7 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
|
|
|
723
740
|
yield* probeDue
|
|
724
741
|
const recovered = yield* attempt(role, leaseOptions)
|
|
725
742
|
if (recovered !== undefined) {
|
|
726
|
-
yield*
|
|
727
|
-
`${recovered.id} takes ${role}${leaseOptions.label === undefined ? "" : ` for ${leaseOptions.label}`}`
|
|
728
|
-
)
|
|
729
|
-
return yield* leaseOf(recovered, role)
|
|
743
|
+
return yield* leaseOf(recovered, role, leaseOptions.label)
|
|
730
744
|
}
|
|
731
745
|
const probing = yield* lock.withPermit(
|
|
732
746
|
Effect.sync(() =>
|
|
@@ -744,7 +758,7 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
|
|
|
744
758
|
leaseOptions: LeaseOptions = {}
|
|
745
759
|
): Effect.Effect<Lease | undefined, never, Scope.Scope> =>
|
|
746
760
|
Effect.flatMap(attempt(role, leaseOptions), (spec) =>
|
|
747
|
-
spec === undefined ? Effect.succeed(undefined) : leaseOf(spec, role)
|
|
761
|
+
spec === undefined ? Effect.succeed(undefined) : leaseOf(spec, role, leaseOptions.label)
|
|
748
762
|
)
|
|
749
763
|
|
|
750
764
|
const exclude = (exclusion: Exclusion): Effect.Effect<void> =>
|
|
@@ -753,7 +767,9 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
|
|
|
753
767
|
return
|
|
754
768
|
}
|
|
755
769
|
yield* lock.withPermit(Effect.sync(() => exclusions.set(exclusion.id, exclusion)))
|
|
756
|
-
yield*
|
|
770
|
+
yield* events.publish(
|
|
771
|
+
ExecutorExcluded.make({ executor: exclusion.id, reason: describeExclusion(exclusion) })
|
|
772
|
+
)
|
|
757
773
|
yield* persist
|
|
758
774
|
yield* signal
|
|
759
775
|
})
|
|
@@ -790,7 +806,7 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
|
|
|
790
806
|
Effect.gen(function* () {
|
|
791
807
|
const removed = yield* lock.withPermit(Effect.sync(() => exclusions.delete(id)))
|
|
792
808
|
if (removed) {
|
|
793
|
-
yield*
|
|
809
|
+
yield* events.publish(ExecutorResumed.make({ executor: id, why: "resumed" }))
|
|
794
810
|
yield* persist
|
|
795
811
|
yield* signal
|
|
796
812
|
}
|
package/src/RosterSeats.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { ConfigError, type LlmError } from "@llm4ts/core/Errors"
|
|
|
12
12
|
import type { LlmServiceShape } from "@llm4ts/core/LlmService"
|
|
13
13
|
import { Message, type ConnectorCapabilities, type LlmChunk } from "@llm4ts/core/Models"
|
|
14
14
|
import { describeFlowError, type FlowError } from "./FlowError.ts"
|
|
15
|
-
import {
|
|
15
|
+
import { ExecutorHandedOver, ExecutorLeased, type FlowEventsShape } from "./FlowEvents.ts"
|
|
16
16
|
import {
|
|
17
17
|
describeExclusion,
|
|
18
18
|
hasRole,
|
|
@@ -51,9 +51,6 @@ export interface RosterView {
|
|
|
51
51
|
const asLlmError = (error: FlowError): LlmError =>
|
|
52
52
|
ConfigError.make({ message: describeFlowError(error) })
|
|
53
53
|
|
|
54
|
-
const say = (events: FlowEventsShape, message: string): Effect.Effect<void> =>
|
|
55
|
-
events.publish(Info.make({ message: `roster: ${message}` }))
|
|
56
|
-
|
|
57
54
|
/** Shared plumbing: every service method as "run this on some seat". */
|
|
58
55
|
interface SeatRunner {
|
|
59
56
|
readonly run: <A, R>(
|
|
@@ -133,9 +130,13 @@ export const rosterSeat = (
|
|
|
133
130
|
if (avoid.length > 0 && !(yield* roster.canEverServe(role, avoid))) {
|
|
134
131
|
const borrowed = options.borrow === undefined ? undefined : yield* options.borrow
|
|
135
132
|
if (borrowed !== undefined && hasRole(borrowed, role)) {
|
|
136
|
-
yield*
|
|
137
|
-
|
|
138
|
-
|
|
133
|
+
yield* options.events.publish(
|
|
134
|
+
ExecutorLeased.make({
|
|
135
|
+
executor: borrowed.id,
|
|
136
|
+
role,
|
|
137
|
+
...(options.label === undefined ? {} : { label: options.label }),
|
|
138
|
+
borrowed: true
|
|
139
|
+
})
|
|
139
140
|
)
|
|
140
141
|
return borrowed
|
|
141
142
|
}
|
|
@@ -167,9 +168,14 @@ export const rosterSeat = (
|
|
|
167
168
|
const avoid = options.avoid === undefined ? [] : yield* options.avoid
|
|
168
169
|
const other = yield* roster.canEverServe(role, [...avoid, executor.id])
|
|
169
170
|
if (other) {
|
|
170
|
-
yield*
|
|
171
|
-
|
|
172
|
-
|
|
171
|
+
yield* options.events.publish(
|
|
172
|
+
ExecutorHandedOver.make({
|
|
173
|
+
from: executor.id,
|
|
174
|
+
role,
|
|
175
|
+
...(options.label === undefined ? {} : { label: options.label }),
|
|
176
|
+
reason: describeExclusion(exclusion),
|
|
177
|
+
scope: "call"
|
|
178
|
+
})
|
|
173
179
|
)
|
|
174
180
|
}
|
|
175
181
|
return other
|
|
@@ -318,9 +324,14 @@ export const makeHeldCoder = Effect.fn("@llm4ts/flow/RosterSeats.heldCoder")(fun
|
|
|
318
324
|
yield* Ref.set(prefer, undefined)
|
|
319
325
|
yield* held.lease.release
|
|
320
326
|
yield* Ref.set(current, undefined)
|
|
321
|
-
yield*
|
|
322
|
-
|
|
323
|
-
|
|
327
|
+
yield* options.events.publish(
|
|
328
|
+
ExecutorHandedOver.make({
|
|
329
|
+
from: held.lease.executor.id,
|
|
330
|
+
role: "coder",
|
|
331
|
+
label: options.label ?? workDir,
|
|
332
|
+
reason: describeExclusion(exclusion),
|
|
333
|
+
scope: "coder"
|
|
334
|
+
})
|
|
324
335
|
)
|
|
325
336
|
return takeoverNote(held.lease.executor.id, exclusion.reason)
|
|
326
337
|
})
|
package/src/Stories.ts
CHANGED
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
StoryFailed,
|
|
29
29
|
type FlowError
|
|
30
30
|
} from "./FlowError.ts"
|
|
31
|
-
import { Info, withLane, type FlowEventsShape } from "./FlowEvents.ts"
|
|
31
|
+
import { Info, JudgedDimension, StoryJudged, withLane, type FlowEventsShape } from "./FlowEvents.ts"
|
|
32
32
|
import { statusPaths, type GitToolShape } from "./GitTool.ts"
|
|
33
33
|
import {
|
|
34
34
|
checkPerimeter,
|
|
@@ -70,6 +70,11 @@ const join = (root: string, path: string): string =>
|
|
|
70
70
|
// ---- Story seats ----------------------------------------------------------
|
|
71
71
|
|
|
72
72
|
/** A flow context rooted in a story's worktree, plus that story's own usage totals. */
|
|
73
|
+
/** A story judge's review with the rubric's scores; a plain `ReviewResult` has none. */
|
|
74
|
+
export class StoryVerdict extends ReviewResult.extend<StoryVerdict>("StoryVerdict")({
|
|
75
|
+
dimensions: Schema.Array(JudgedDimension)
|
|
76
|
+
}) {}
|
|
77
|
+
|
|
73
78
|
export interface StorySeats {
|
|
74
79
|
readonly context: FlowContextShape
|
|
75
80
|
/** Running usage of this story's seats — estimates where the backend reports none. */
|
|
@@ -986,6 +991,15 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
986
991
|
return yield* failed(story, "the story branch has no changes against the epic branch")
|
|
987
992
|
}
|
|
988
993
|
const verdict = yield* judge(story, diff, watchedSeats)
|
|
994
|
+
yield* laneOf(story).publish(
|
|
995
|
+
StoryJudged.make({
|
|
996
|
+
lane: story.id,
|
|
997
|
+
round,
|
|
998
|
+
cleared: verdict.isClean,
|
|
999
|
+
issues: verdict.issues.length,
|
|
1000
|
+
dimensions: verdict instanceof StoryVerdict ? verdict.dimensions : []
|
|
1001
|
+
})
|
|
1002
|
+
)
|
|
989
1003
|
if (verdict.isClean) {
|
|
990
1004
|
judgeNote = `judge cleared (round ${round})`
|
|
991
1005
|
break
|