@llm4ts/flow 2.7.2 → 2.9.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 (75) 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/Chat.d.ts.map +1 -1
  8. package/dist/Chat.js +35 -4
  9. package/dist/Chat.js.map +1 -1
  10. package/dist/CostLedger.d.ts +2 -2
  11. package/dist/CostLedger.d.ts.map +1 -1
  12. package/dist/Equiv.d.ts +3 -3
  13. package/dist/Equiv.d.ts.map +1 -1
  14. package/dist/Flow.d.ts +1 -1
  15. package/dist/Flow.d.ts.map +1 -1
  16. package/dist/FlowContext.d.ts +15 -1
  17. package/dist/FlowContext.d.ts.map +1 -1
  18. package/dist/FlowContext.js.map +1 -1
  19. package/dist/FlowError.d.ts +40 -1
  20. package/dist/FlowError.d.ts.map +1 -1
  21. package/dist/FlowError.js +58 -3
  22. package/dist/FlowError.js.map +1 -1
  23. package/dist/GitTool.d.ts +26 -2
  24. package/dist/GitTool.d.ts.map +1 -1
  25. package/dist/GitTool.js +54 -3
  26. package/dist/GitTool.js.map +1 -1
  27. package/dist/Judgment.d.ts +2 -2
  28. package/dist/Judgment.d.ts.map +1 -1
  29. package/dist/Perimeter.d.ts +22 -1
  30. package/dist/Perimeter.d.ts.map +1 -1
  31. package/dist/Perimeter.js +57 -1
  32. package/dist/Perimeter.js.map +1 -1
  33. package/dist/Persistence.d.ts +2 -2
  34. package/dist/Persistence.d.ts.map +1 -1
  35. package/dist/PlanExecution.d.ts +1 -1
  36. package/dist/PlanExecution.d.ts.map +1 -1
  37. package/dist/ProgramJudge.d.ts +1 -1
  38. package/dist/ProgramJudge.d.ts.map +1 -1
  39. package/dist/Replay.d.ts +2 -2
  40. package/dist/Replay.d.ts.map +1 -1
  41. package/dist/Review.d.ts +3 -3
  42. package/dist/Review.d.ts.map +1 -1
  43. package/dist/ReviewCache.d.ts +1 -1
  44. package/dist/ReviewCache.d.ts.map +1 -1
  45. package/dist/Roster.d.ts +163 -0
  46. package/dist/Roster.d.ts.map +1 -0
  47. package/dist/Roster.js +560 -0
  48. package/dist/Roster.js.map +1 -0
  49. package/dist/RosterSeats.d.ts +71 -0
  50. package/dist/RosterSeats.d.ts.map +1 -0
  51. package/dist/RosterSeats.js +144 -0
  52. package/dist/RosterSeats.js.map +1 -0
  53. package/dist/Stories.d.ts +54 -8
  54. package/dist/Stories.d.ts.map +1 -1
  55. package/dist/Stories.js +413 -51
  56. package/dist/Stories.js.map +1 -1
  57. package/dist/StoryPlan.d.ts +15 -0
  58. package/dist/StoryPlan.d.ts.map +1 -1
  59. package/dist/StoryPlan.js +58 -0
  60. package/dist/StoryPlan.js.map +1 -1
  61. package/dist/TransientRetry.d.ts +9 -0
  62. package/dist/TransientRetry.d.ts.map +1 -1
  63. package/dist/TransientRetry.js +43 -8
  64. package/dist/TransientRetry.js.map +1 -1
  65. package/package.json +4 -2
  66. package/src/Chat.ts +45 -6
  67. package/src/FlowContext.ts +19 -1
  68. package/src/FlowError.ts +65 -3
  69. package/src/GitTool.ts +99 -5
  70. package/src/Perimeter.ts +85 -1
  71. package/src/Roster.ts +824 -0
  72. package/src/RosterSeats.ts +306 -0
  73. package/src/Stories.ts +588 -88
  74. package/src/StoryPlan.ts +73 -0
  75. package/src/TransientRetry.ts +97 -8
@@ -8,6 +8,15 @@ import type { FlowError } from "./FlowError.ts"
8
8
  import type { FlowEventsShape } from "./FlowEvents.ts"
9
9
  import type { GitToolShape } from "./GitTool.ts"
10
10
  import type { GitHubToolShape } from "./GitHubTool.ts"
11
+ import type { RosterView } from "./RosterSeats.ts"
12
+
13
+ /** How a rebound context wants its coder (ADR 0019); ignored without a roster. */
14
+ export interface ContextOptions {
15
+ /** The executor this context held before (a resumed story), taken first while free. */
16
+ readonly prefer?: string
17
+ /** Who the context is for, in the roster's events ("story conto-overview"). */
18
+ readonly label?: string
19
+ }
11
20
 
12
21
  export interface FlowContextShape {
13
22
  readonly reasoning: LlmServiceShape
@@ -32,7 +41,16 @@ export interface FlowContextShape {
32
41
  * events and cost tracking shared. Absent when the runner cannot rebind
33
42
  * (an embedded context built by hand).
34
43
  */
35
- readonly contextFor?: (workDir: string) => Effect.Effect<FlowContextShape, FlowError, Scope.Scope>
44
+ readonly contextFor?: (
45
+ workDir: string,
46
+ options?: ContextOptions
47
+ ) => Effect.Effect<FlowContextShape, FlowError, Scope.Scope>
48
+ /**
49
+ * The executor roster behind the seats (ADR 0019), when the run has one:
50
+ * a seat per role, free and configured slots, and the executor holding
51
+ * this context's coder. Absent on a run with one executor per seat.
52
+ */
53
+ readonly roster?: RosterView
36
54
  }
37
55
 
38
56
  export class FlowContext extends Context.Service<FlowContext, FlowContextShape>()(
package/src/FlowError.ts CHANGED
@@ -168,11 +168,70 @@ 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
- const where = this.paths.length === 0 ? "" : `: ${this.paths.join(", ")}`
175
- return `merging '${this.branch}' into '${this.into}' conflicted${where}`
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")
205
+ }
206
+ }
207
+
208
+ /** A roster file that failed to load or validate — every violation, not the first (ADR 0019). */
209
+ export class RosterInvalid extends Schema.TaggedError<RosterInvalid>()("RosterInvalid", {
210
+ path: Schema.optionalKey(Schema.String),
211
+ violations: Schema.Array(Schema.String)
212
+ }) {
213
+ get message(): string {
214
+ const where = this.path === undefined ? "" : ` (${this.path})`
215
+ return `roster invalid${where}:\n${this.violations.map((violation) => `- ${violation}`).join("\n")}`
216
+ }
217
+ }
218
+
219
+ /** The start of every `RosterExhausted` message, so it is recognisable once wrapped. */
220
+ export const rosterExhaustedPrefix = "roster exhausted: no executor can ever take the role"
221
+
222
+ /**
223
+ * No executor of the roster can ever take `role` in this run: none has the
224
+ * role, or every one that has it is excluded for the run (ADR 0019). A
225
+ * roster waits for an executor that will come back; this is the case where
226
+ * none will.
227
+ */
228
+ export class RosterExhausted extends Schema.TaggedError<RosterExhausted>()("RosterExhausted", {
229
+ role: Schema.String,
230
+ reasons: Schema.Array(Schema.String)
231
+ }) {
232
+ get message(): string {
233
+ const why = this.reasons.length === 0 ? "" : `: ${this.reasons.join("; ")}`
234
+ return `${rosterExhaustedPrefix} '${this.role}'${why}`
176
235
  }
177
236
  }
178
237
 
@@ -240,6 +299,9 @@ export const FlowError = Schema.Union([
240
299
  PerimeterViolation,
241
300
  MissingDependency,
242
301
  MergeConflict,
302
+ EpicCheckoutDirty,
303
+ RosterExhausted,
304
+ RosterInvalid,
243
305
  StoryFailed,
244
306
  DecisionsInvalid,
245
307
  OpenPointsPending,
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: (branch: string, message: string) => Effect.Effect<void, FlowError>
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(["merge", "--no-ff", "-m", message, branch])
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({ branch, into, paths })
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/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 { pathWithin, type Story } from "./StoryPlan.ts"
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
+ })