@llm4ts/flow 0.15.1 → 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.
Files changed (50) hide show
  1. package/dist/BenchReport.d.ts +2 -2
  2. package/dist/BenchReport.d.ts.map +1 -1
  3. package/dist/CostLedger.d.ts +2 -2
  4. package/dist/CostLedger.d.ts.map +1 -1
  5. package/dist/Equiv.d.ts +3 -3
  6. package/dist/Equiv.d.ts.map +1 -1
  7. package/dist/Flow.d.ts +1 -1
  8. package/dist/Flow.d.ts.map +1 -1
  9. package/dist/FlowContext.d.ts +10 -0
  10. package/dist/FlowContext.d.ts.map +1 -1
  11. package/dist/FlowContext.js.map +1 -1
  12. package/dist/FlowError.d.ts +42 -1
  13. package/dist/FlowError.d.ts.map +1 -1
  14. package/dist/FlowError.js +60 -1
  15. package/dist/FlowError.js.map +1 -1
  16. package/dist/GitTool.d.ts +15 -1
  17. package/dist/GitTool.d.ts.map +1 -1
  18. package/dist/GitTool.js +30 -2
  19. package/dist/GitTool.js.map +1 -1
  20. package/dist/Perimeter.d.ts +13 -0
  21. package/dist/Perimeter.d.ts.map +1 -0
  22. package/dist/Perimeter.js +34 -0
  23. package/dist/Perimeter.js.map +1 -0
  24. package/dist/Persistence.d.ts +2 -2
  25. package/dist/Persistence.d.ts.map +1 -1
  26. package/dist/PlanExecution.d.ts +1 -1
  27. package/dist/PlanExecution.d.ts.map +1 -1
  28. package/dist/ProgramJudge.d.ts +1 -1
  29. package/dist/ProgramJudge.d.ts.map +1 -1
  30. package/dist/Replay.d.ts +2 -2
  31. package/dist/Replay.d.ts.map +1 -1
  32. package/dist/Review.d.ts +2 -2
  33. package/dist/Review.d.ts.map +1 -1
  34. package/dist/ReviewCache.d.ts +1 -1
  35. package/dist/ReviewCache.d.ts.map +1 -1
  36. package/dist/Stories.d.ts +109 -0
  37. package/dist/Stories.d.ts.map +1 -0
  38. package/dist/Stories.js +432 -0
  39. package/dist/Stories.js.map +1 -0
  40. package/dist/StoryPlan.d.ts +81 -0
  41. package/dist/StoryPlan.d.ts.map +1 -0
  42. package/dist/StoryPlan.js +242 -0
  43. package/dist/StoryPlan.js.map +1 -0
  44. package/package.json +5 -2
  45. package/src/FlowContext.ts +10 -0
  46. package/src/FlowError.ts +74 -1
  47. package/src/GitTool.ts +74 -4
  48. package/src/Perimeter.ts +49 -0
  49. package/src/Stories.ts +670 -0
  50. package/src/StoryPlan.ts +353 -0
@@ -0,0 +1,353 @@
1
+ // The epic-level plan of the parallel story executor (ADR 0013): stories
2
+ // with a declared dependency graph and declared file ownership. Dependencies
3
+ // are declared here, up front, never discovered by a running coder.
4
+ import * as Effect from "effect/Effect"
5
+ import * as Schema from "effect/Schema"
6
+ import { PlanParseError, StoryPlanInvalid, type PersistenceError } from "./FlowError.ts"
7
+ import type { PlainFileStoreShape } from "./Persistence.ts"
8
+ import { stableHash } from "./Plan.ts"
9
+
10
+ export const StoryPlanVersion = 1
11
+
12
+ export class Story extends Schema.Class<Story>("Story")({
13
+ /** Kebab-case, unique within the plan; names the branch and the worktree. */
14
+ id: Schema.String,
15
+ title: Schema.String,
16
+ description: Schema.String,
17
+ /** Story ids this one waits for; they are merged before this one starts. */
18
+ dependsOn: Schema.Array(Schema.String).pipe(
19
+ Schema.withConstructorDefault(Effect.succeed([])),
20
+ Schema.withDecodingDefaultKey(Effect.succeed([]))
21
+ ),
22
+ /** Repo-relative path prefixes (files or directories) the story may create or change. */
23
+ owned: Schema.Array(Schema.String),
24
+ /** Path prefixes fed as context and forbidden to change. */
25
+ sharedReadOnly: Schema.Array(Schema.String).pipe(
26
+ Schema.withConstructorDefault(Effect.succeed([])),
27
+ Schema.withDecodingDefaultKey(Effect.succeed([]))
28
+ ),
29
+ /** What dependents may rely on: routes, exports, contracts. */
30
+ provides: Schema.Array(Schema.String).pipe(
31
+ Schema.withConstructorDefault(Effect.succeed([])),
32
+ Schema.withDecodingDefaultKey(Effect.succeed([]))
33
+ )
34
+ }) {}
35
+
36
+ export class StoryPlan extends Schema.Class<StoryPlan>("StoryPlan")({
37
+ epicId: Schema.String,
38
+ /** The epic as the operator phrased it. */
39
+ epic: Schema.String,
40
+ stories: Schema.Array(Story)
41
+ }) {
42
+ story(id: string): Story | undefined {
43
+ return this.stories.find((story) => story.id === id)
44
+ }
45
+ }
46
+
47
+ /** A path prefix in canonical form: no `./`, no trailing slash, forward slashes. */
48
+ export const normalizePath = (path: string): string =>
49
+ path
50
+ .trim()
51
+ .replace(/\\/g, "/")
52
+ .replace(/^(\.\/)+/, "")
53
+ .replace(/\/+$/, "")
54
+
55
+ /** Whether `path` is `prefix` itself or lies under it. */
56
+ export const pathWithin = (path: string, prefix: string): boolean => {
57
+ const target = normalizePath(path)
58
+ const root = normalizePath(prefix)
59
+ return root.length === 0 || target === root || target.startsWith(`${root}/`)
60
+ }
61
+
62
+ const overlapping = (left: string, right: string): boolean =>
63
+ pathWithin(left, right) || pathWithin(right, left)
64
+
65
+ /**
66
+ * Every violation of the plan's invariants, in one pass — an operator fixing
67
+ * a hand-edited plan wants the whole list, not a rerun per finding.
68
+ */
69
+ export const storyPlanViolations = (plan: StoryPlan): ReadonlyArray<string> => {
70
+ const violations: Array<string> = []
71
+ const ids = new Set<string>()
72
+ for (const story of plan.stories) {
73
+ if (ids.has(story.id)) {
74
+ violations.push(`duplicate story id '${story.id}'`)
75
+ }
76
+ ids.add(story.id)
77
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(story.id)) {
78
+ violations.push(`story id '${story.id}' is not kebab-case`)
79
+ }
80
+ if (story.owned.length === 0) {
81
+ violations.push(`story '${story.id}' owns no paths`)
82
+ }
83
+ for (const dependency of story.dependsOn) {
84
+ if (dependency === story.id) {
85
+ violations.push(`story '${story.id}' depends on itself`)
86
+ } else if (!plan.stories.some((candidate) => candidate.id === dependency)) {
87
+ violations.push(`story '${story.id}' depends on unknown story '${dependency}'`)
88
+ }
89
+ }
90
+ for (const owned of story.owned) {
91
+ for (const shared of story.sharedReadOnly) {
92
+ if (overlapping(owned, shared)) {
93
+ violations.push(
94
+ `story '${story.id}' both owns '${owned}' and declares '${shared}' shared read-only`
95
+ )
96
+ }
97
+ }
98
+ }
99
+ }
100
+ for (let index = 0; index < plan.stories.length; index += 1) {
101
+ const left = plan.stories[index]
102
+ if (left === undefined) {
103
+ continue
104
+ }
105
+ for (const right of plan.stories.slice(index + 1)) {
106
+ for (const leftPath of left.owned) {
107
+ for (const rightPath of right.owned) {
108
+ if (overlapping(leftPath, rightPath)) {
109
+ violations.push(
110
+ `stories '${left.id}' and '${right.id}' both own '${normalizePath(leftPath)}' / '${normalizePath(rightPath)}'`
111
+ )
112
+ }
113
+ }
114
+ }
115
+ }
116
+ }
117
+ for (const cycle of cycles(plan)) {
118
+ violations.push(`dependency cycle: ${cycle.join(" -> ")}`)
119
+ }
120
+ return violations
121
+ }
122
+
123
+ const cycles = (plan: StoryPlan): ReadonlyArray<ReadonlyArray<string>> => {
124
+ const found: Array<ReadonlyArray<string>> = []
125
+ const state = new Map<string, "visiting" | "done">()
126
+ const visit = (id: string, trail: ReadonlyArray<string>): void => {
127
+ const mark = state.get(id)
128
+ if (mark === "done") {
129
+ return
130
+ }
131
+ if (mark === "visiting") {
132
+ const start = trail.indexOf(id)
133
+ found.push([...trail.slice(start), id])
134
+ return
135
+ }
136
+ state.set(id, "visiting")
137
+ for (const dependency of plan.story(id)?.dependsOn ?? []) {
138
+ // A self-edge is already reported as "depends on itself".
139
+ if (dependency !== id && plan.story(dependency) !== undefined) {
140
+ visit(dependency, [...trail, id])
141
+ }
142
+ }
143
+ state.set(id, "done")
144
+ }
145
+ for (const story of plan.stories) {
146
+ visit(story.id, [])
147
+ }
148
+ return found
149
+ }
150
+
151
+ export const validateStoryPlan = Effect.fn("@llm4ts/flow/StoryPlan.validate")(function* (
152
+ plan: StoryPlan
153
+ ): Effect.fn.Return<StoryPlan, StoryPlanInvalid> {
154
+ const violations = storyPlanViolations(plan)
155
+ return violations.length === 0 ? plan : yield* StoryPlanInvalid.make({ violations })
156
+ })
157
+
158
+ /**
159
+ * Stories grouped by the earliest wave they can run in (all dependencies in
160
+ * earlier waves). Assumes a valid plan; a cycle leaves its members out.
161
+ */
162
+ export const topologicalWaves = (plan: StoryPlan): ReadonlyArray<ReadonlyArray<string>> => {
163
+ const waves: Array<ReadonlyArray<string>> = []
164
+ const placed = new Set<string>()
165
+ let remaining = plan.stories.map((story) => story.id)
166
+ while (remaining.length > 0) {
167
+ const wave = remaining.filter((id) =>
168
+ (plan.story(id)?.dependsOn ?? []).every((dependency) => placed.has(dependency))
169
+ )
170
+ if (wave.length === 0) {
171
+ break
172
+ }
173
+ waves.push(wave)
174
+ for (const id of wave) {
175
+ placed.add(id)
176
+ }
177
+ remaining = remaining.filter((id) => !placed.has(id))
178
+ }
179
+ return waves
180
+ }
181
+
182
+ export interface StoryProgress {
183
+ readonly done: ReadonlySet<string>
184
+ readonly failed: ReadonlySet<string>
185
+ readonly skipped: ReadonlySet<string>
186
+ readonly running: ReadonlySet<string>
187
+ }
188
+
189
+ /** Stories that may start now: not yet touched, every dependency done. Plan order. */
190
+ export const readyStories = (plan: StoryPlan, progress: StoryProgress): ReadonlyArray<Story> =>
191
+ plan.stories.filter(
192
+ (story) =>
193
+ !progress.done.has(story.id) &&
194
+ !progress.failed.has(story.id) &&
195
+ !progress.skipped.has(story.id) &&
196
+ !progress.running.has(story.id) &&
197
+ story.dependsOn.every((dependency) => progress.done.has(dependency))
198
+ )
199
+
200
+ /** Every story that transitively depends on `id`, in plan order. */
201
+ export const dependentsOf = (plan: StoryPlan, id: string): ReadonlyArray<string> => {
202
+ const blocked = new Set<string>([id])
203
+ let grew = true
204
+ while (grew) {
205
+ grew = false
206
+ for (const story of plan.stories) {
207
+ if (!blocked.has(story.id) && story.dependsOn.some((dependency) => blocked.has(dependency))) {
208
+ blocked.add(story.id)
209
+ grew = true
210
+ }
211
+ }
212
+ }
213
+ return plan.stories
214
+ .map((story) => story.id)
215
+ .filter((candidate) => candidate !== id && blocked.has(candidate))
216
+ }
217
+
218
+ /** Stable over the story entry's content — a changed entry means a fresh branch. */
219
+ export const storyHash = (story: Story): string =>
220
+ stableHash(
221
+ JSON.stringify({
222
+ id: story.id,
223
+ title: story.title,
224
+ description: story.description,
225
+ dependsOn: [...story.dependsOn],
226
+ owned: [...story.owned].map(normalizePath),
227
+ sharedReadOnly: [...story.sharedReadOnly].map(normalizePath),
228
+ provides: [...story.provides]
229
+ })
230
+ )
231
+
232
+ export const storyPlanFenceInfo = "json storyplan"
233
+
234
+ const fencePattern = /```json[ \t]+storyplan[ \t]*\r?\n([\s\S]*?)\r?\n[ \t]*```/
235
+
236
+ /** The raw JSON of the first ```json storyplan fenced block, if any. */
237
+ export const storyPlanBlock = (markdown: string): string | undefined =>
238
+ fencePattern.exec(markdown)?.[1]
239
+
240
+ /**
241
+ * Decodes the story plan embedded in its markdown file. Decoding only —
242
+ * `validateStoryPlan` checks the graph invariants separately, so a parse
243
+ * failure and an invalid plan are told apart.
244
+ */
245
+ export const parseStoryPlan = Effect.fn("@llm4ts/flow/StoryPlan.parse")(function* (
246
+ markdown: string
247
+ ): Effect.fn.Return<StoryPlan, PlanParseError> {
248
+ const block = storyPlanBlock(markdown)
249
+ if (block === undefined) {
250
+ return yield* PlanParseError.make({
251
+ message: "no ```json storyplan fenced block in the story plan markdown"
252
+ })
253
+ }
254
+ return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(StoryPlan))(block).pipe(
255
+ Effect.mapError((error) =>
256
+ PlanParseError.make({ message: `invalid story plan block: ${String(error)}` })
257
+ )
258
+ )
259
+ })
260
+
261
+ const list = (items: ReadonlyArray<string>): string =>
262
+ items.length === 0 ? "none" : items.join(", ")
263
+
264
+ /**
265
+ * The operator-facing markdown: a readable summary per story plus the
266
+ * fenced block that is the source of truth. Editing the block and rerunning
267
+ * is the approval and the re-plan path.
268
+ */
269
+ export const renderStoryPlan = Effect.fn("@llm4ts/flow/StoryPlan.render")(function* (
270
+ plan: StoryPlan
271
+ ): Effect.fn.Return<string, PlanParseError> {
272
+ const encoded = yield* Schema.encodeEffect(StoryPlan)(plan).pipe(
273
+ Effect.mapError((error) =>
274
+ PlanParseError.make({ message: `story plan not encodable: ${String(error)}` })
275
+ )
276
+ )
277
+ const waves = topologicalWaves(plan)
278
+ const lines: Array<string> = [
279
+ `# Epic: ${plan.epicId}`,
280
+ "",
281
+ plan.epic.trim(),
282
+ "",
283
+ "## Waves",
284
+ "",
285
+ ...waves.map((wave, index) => `${index + 1}. ${wave.join(", ")}`),
286
+ "",
287
+ "## Stories",
288
+ ""
289
+ ]
290
+ for (const story of plan.stories) {
291
+ lines.push(
292
+ `### ${story.id} — ${story.title}`,
293
+ "",
294
+ story.description.trim(),
295
+ "",
296
+ `- depends on: ${list(story.dependsOn)}`,
297
+ `- owned: ${list(story.owned)}`,
298
+ `- shared read-only: ${list(story.sharedReadOnly)}`,
299
+ `- provides: ${list(story.provides)}`,
300
+ ""
301
+ )
302
+ }
303
+ lines.push(
304
+ "## Plan block",
305
+ "",
306
+ "Edit this block to change the plan; the prose above is regenerated from it.",
307
+ "",
308
+ "```" + storyPlanFenceInfo,
309
+ JSON.stringify(encoded, null, 2),
310
+ "```",
311
+ ""
312
+ )
313
+ return lines.join("\n")
314
+ })
315
+
316
+ export interface StoryPlanStoreShape {
317
+ readonly save: (
318
+ path: string,
319
+ plan: StoryPlan
320
+ ) => Effect.Effect<void, PersistenceError | PlanParseError>
321
+ readonly load: (
322
+ path: string
323
+ ) => Effect.Effect<StoryPlan | undefined, PersistenceError | PlanParseError>
324
+ /** An existing file wins over `create`: the operator's edits are the plan. */
325
+ readonly recoverOrCreate: <E, R>(
326
+ path: string,
327
+ create: Effect.Effect<StoryPlan, E, R>
328
+ ) => Effect.Effect<StoryPlan, E | PersistenceError | PlanParseError, R>
329
+ }
330
+
331
+ export const makeStoryPlanStore = (files: PlainFileStoreShape): StoryPlanStoreShape => {
332
+ const save = (
333
+ path: string,
334
+ plan: StoryPlan
335
+ ): Effect.Effect<void, PersistenceError | PlanParseError> =>
336
+ Effect.flatMap(renderStoryPlan(plan), (markdown) => files.writeAtomic(path, markdown))
337
+ const load = (
338
+ path: string
339
+ ): Effect.Effect<StoryPlan | undefined, PersistenceError | PlanParseError> =>
340
+ Effect.flatMap(files.read(path), (contents) =>
341
+ contents === undefined ? Effect.succeed(undefined) : parseStoryPlan(contents)
342
+ )
343
+ return {
344
+ save,
345
+ load,
346
+ recoverOrCreate: (path, create) =>
347
+ Effect.flatMap(load(path), (stored) =>
348
+ stored === undefined
349
+ ? Effect.tap(create, (plan) => save(path, plan))
350
+ : Effect.succeed(stored)
351
+ )
352
+ }
353
+ }