@llm4ts/flow 0.15.1 → 0.16.1

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 (54) hide show
  1. package/dist/BenchReport.d.ts +2 -2
  2. package/dist/BenchReport.d.ts.map +1 -1
  3. package/dist/BoardSync.d.ts.map +1 -1
  4. package/dist/BoardSync.js +9 -4
  5. package/dist/BoardSync.js.map +1 -1
  6. package/dist/CostLedger.d.ts +2 -2
  7. package/dist/CostLedger.d.ts.map +1 -1
  8. package/dist/Equiv.d.ts +3 -3
  9. package/dist/Equiv.d.ts.map +1 -1
  10. package/dist/Flow.d.ts +1 -1
  11. package/dist/Flow.d.ts.map +1 -1
  12. package/dist/FlowContext.d.ts +10 -0
  13. package/dist/FlowContext.d.ts.map +1 -1
  14. package/dist/FlowContext.js.map +1 -1
  15. package/dist/FlowError.d.ts +42 -1
  16. package/dist/FlowError.d.ts.map +1 -1
  17. package/dist/FlowError.js +60 -1
  18. package/dist/FlowError.js.map +1 -1
  19. package/dist/GitTool.d.ts +15 -1
  20. package/dist/GitTool.d.ts.map +1 -1
  21. package/dist/GitTool.js +30 -2
  22. package/dist/GitTool.js.map +1 -1
  23. package/dist/Perimeter.d.ts +13 -0
  24. package/dist/Perimeter.d.ts.map +1 -0
  25. package/dist/Perimeter.js +34 -0
  26. package/dist/Perimeter.js.map +1 -0
  27. package/dist/Persistence.d.ts +2 -2
  28. package/dist/Persistence.d.ts.map +1 -1
  29. package/dist/PlanExecution.d.ts +1 -1
  30. package/dist/PlanExecution.d.ts.map +1 -1
  31. package/dist/ProgramJudge.d.ts +1 -1
  32. package/dist/ProgramJudge.d.ts.map +1 -1
  33. package/dist/Replay.d.ts +2 -2
  34. package/dist/Replay.d.ts.map +1 -1
  35. package/dist/Review.d.ts +2 -2
  36. package/dist/Review.d.ts.map +1 -1
  37. package/dist/ReviewCache.d.ts +1 -1
  38. package/dist/ReviewCache.d.ts.map +1 -1
  39. package/dist/Stories.d.ts +121 -0
  40. package/dist/Stories.d.ts.map +1 -0
  41. package/dist/Stories.js +458 -0
  42. package/dist/Stories.js.map +1 -0
  43. package/dist/StoryPlan.d.ts +81 -0
  44. package/dist/StoryPlan.d.ts.map +1 -0
  45. package/dist/StoryPlan.js +242 -0
  46. package/dist/StoryPlan.js.map +1 -0
  47. package/package.json +5 -2
  48. package/src/BoardSync.ts +29 -20
  49. package/src/FlowContext.ts +10 -0
  50. package/src/FlowError.ts +74 -1
  51. package/src/GitTool.ts +74 -4
  52. package/src/Perimeter.ts +49 -0
  53. package/src/Stories.ts +702 -0
  54. package/src/StoryPlan.ts +353 -0
@@ -0,0 +1,242 @@
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 } from "./FlowError.js";
7
+ import { stableHash } from "./Plan.js";
8
+ export const StoryPlanVersion = 1;
9
+ export class Story extends Schema.Class("Story")({
10
+ /** Kebab-case, unique within the plan; names the branch and the worktree. */
11
+ id: Schema.String,
12
+ title: Schema.String,
13
+ description: Schema.String,
14
+ /** Story ids this one waits for; they are merged before this one starts. */
15
+ dependsOn: Schema.Array(Schema.String).pipe(Schema.withConstructorDefault(Effect.succeed([])), Schema.withDecodingDefaultKey(Effect.succeed([]))),
16
+ /** Repo-relative path prefixes (files or directories) the story may create or change. */
17
+ owned: Schema.Array(Schema.String),
18
+ /** Path prefixes fed as context and forbidden to change. */
19
+ sharedReadOnly: Schema.Array(Schema.String).pipe(Schema.withConstructorDefault(Effect.succeed([])), Schema.withDecodingDefaultKey(Effect.succeed([]))),
20
+ /** What dependents may rely on: routes, exports, contracts. */
21
+ provides: Schema.Array(Schema.String).pipe(Schema.withConstructorDefault(Effect.succeed([])), Schema.withDecodingDefaultKey(Effect.succeed([])))
22
+ }) {
23
+ }
24
+ export class StoryPlan extends Schema.Class("StoryPlan")({
25
+ epicId: Schema.String,
26
+ /** The epic as the operator phrased it. */
27
+ epic: Schema.String,
28
+ stories: Schema.Array(Story)
29
+ }) {
30
+ story(id) {
31
+ return this.stories.find((story) => story.id === id);
32
+ }
33
+ }
34
+ /** A path prefix in canonical form: no `./`, no trailing slash, forward slashes. */
35
+ export const normalizePath = (path) => path
36
+ .trim()
37
+ .replace(/\\/g, "/")
38
+ .replace(/^(\.\/)+/, "")
39
+ .replace(/\/+$/, "");
40
+ /** Whether `path` is `prefix` itself or lies under it. */
41
+ export const pathWithin = (path, prefix) => {
42
+ const target = normalizePath(path);
43
+ const root = normalizePath(prefix);
44
+ return root.length === 0 || target === root || target.startsWith(`${root}/`);
45
+ };
46
+ const overlapping = (left, right) => pathWithin(left, right) || pathWithin(right, left);
47
+ /**
48
+ * Every violation of the plan's invariants, in one pass — an operator fixing
49
+ * a hand-edited plan wants the whole list, not a rerun per finding.
50
+ */
51
+ export const storyPlanViolations = (plan) => {
52
+ const violations = [];
53
+ const ids = new Set();
54
+ for (const story of plan.stories) {
55
+ if (ids.has(story.id)) {
56
+ violations.push(`duplicate story id '${story.id}'`);
57
+ }
58
+ ids.add(story.id);
59
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(story.id)) {
60
+ violations.push(`story id '${story.id}' is not kebab-case`);
61
+ }
62
+ if (story.owned.length === 0) {
63
+ violations.push(`story '${story.id}' owns no paths`);
64
+ }
65
+ for (const dependency of story.dependsOn) {
66
+ if (dependency === story.id) {
67
+ violations.push(`story '${story.id}' depends on itself`);
68
+ }
69
+ else if (!plan.stories.some((candidate) => candidate.id === dependency)) {
70
+ violations.push(`story '${story.id}' depends on unknown story '${dependency}'`);
71
+ }
72
+ }
73
+ for (const owned of story.owned) {
74
+ for (const shared of story.sharedReadOnly) {
75
+ if (overlapping(owned, shared)) {
76
+ violations.push(`story '${story.id}' both owns '${owned}' and declares '${shared}' shared read-only`);
77
+ }
78
+ }
79
+ }
80
+ }
81
+ for (let index = 0; index < plan.stories.length; index += 1) {
82
+ const left = plan.stories[index];
83
+ if (left === undefined) {
84
+ continue;
85
+ }
86
+ for (const right of plan.stories.slice(index + 1)) {
87
+ for (const leftPath of left.owned) {
88
+ for (const rightPath of right.owned) {
89
+ if (overlapping(leftPath, rightPath)) {
90
+ violations.push(`stories '${left.id}' and '${right.id}' both own '${normalizePath(leftPath)}' / '${normalizePath(rightPath)}'`);
91
+ }
92
+ }
93
+ }
94
+ }
95
+ }
96
+ for (const cycle of cycles(plan)) {
97
+ violations.push(`dependency cycle: ${cycle.join(" -> ")}`);
98
+ }
99
+ return violations;
100
+ };
101
+ const cycles = (plan) => {
102
+ const found = [];
103
+ const state = new Map();
104
+ const visit = (id, trail) => {
105
+ const mark = state.get(id);
106
+ if (mark === "done") {
107
+ return;
108
+ }
109
+ if (mark === "visiting") {
110
+ const start = trail.indexOf(id);
111
+ found.push([...trail.slice(start), id]);
112
+ return;
113
+ }
114
+ state.set(id, "visiting");
115
+ for (const dependency of plan.story(id)?.dependsOn ?? []) {
116
+ // A self-edge is already reported as "depends on itself".
117
+ if (dependency !== id && plan.story(dependency) !== undefined) {
118
+ visit(dependency, [...trail, id]);
119
+ }
120
+ }
121
+ state.set(id, "done");
122
+ };
123
+ for (const story of plan.stories) {
124
+ visit(story.id, []);
125
+ }
126
+ return found;
127
+ };
128
+ export const validateStoryPlan = Effect.fn("@llm4ts/flow/StoryPlan.validate")(function* (plan) {
129
+ const violations = storyPlanViolations(plan);
130
+ return violations.length === 0 ? plan : yield* StoryPlanInvalid.make({ violations });
131
+ });
132
+ /**
133
+ * Stories grouped by the earliest wave they can run in (all dependencies in
134
+ * earlier waves). Assumes a valid plan; a cycle leaves its members out.
135
+ */
136
+ export const topologicalWaves = (plan) => {
137
+ const waves = [];
138
+ const placed = new Set();
139
+ let remaining = plan.stories.map((story) => story.id);
140
+ while (remaining.length > 0) {
141
+ const wave = remaining.filter((id) => (plan.story(id)?.dependsOn ?? []).every((dependency) => placed.has(dependency)));
142
+ if (wave.length === 0) {
143
+ break;
144
+ }
145
+ waves.push(wave);
146
+ for (const id of wave) {
147
+ placed.add(id);
148
+ }
149
+ remaining = remaining.filter((id) => !placed.has(id));
150
+ }
151
+ return waves;
152
+ };
153
+ /** Stories that may start now: not yet touched, every dependency done. Plan order. */
154
+ export const readyStories = (plan, progress) => plan.stories.filter((story) => !progress.done.has(story.id) &&
155
+ !progress.failed.has(story.id) &&
156
+ !progress.skipped.has(story.id) &&
157
+ !progress.running.has(story.id) &&
158
+ story.dependsOn.every((dependency) => progress.done.has(dependency)));
159
+ /** Every story that transitively depends on `id`, in plan order. */
160
+ export const dependentsOf = (plan, id) => {
161
+ const blocked = new Set([id]);
162
+ let grew = true;
163
+ while (grew) {
164
+ grew = false;
165
+ for (const story of plan.stories) {
166
+ if (!blocked.has(story.id) && story.dependsOn.some((dependency) => blocked.has(dependency))) {
167
+ blocked.add(story.id);
168
+ grew = true;
169
+ }
170
+ }
171
+ }
172
+ return plan.stories
173
+ .map((story) => story.id)
174
+ .filter((candidate) => candidate !== id && blocked.has(candidate));
175
+ };
176
+ /** Stable over the story entry's content — a changed entry means a fresh branch. */
177
+ export const storyHash = (story) => stableHash(JSON.stringify({
178
+ id: story.id,
179
+ title: story.title,
180
+ description: story.description,
181
+ dependsOn: [...story.dependsOn],
182
+ owned: [...story.owned].map(normalizePath),
183
+ sharedReadOnly: [...story.sharedReadOnly].map(normalizePath),
184
+ provides: [...story.provides]
185
+ }));
186
+ export const storyPlanFenceInfo = "json storyplan";
187
+ const fencePattern = /```json[ \t]+storyplan[ \t]*\r?\n([\s\S]*?)\r?\n[ \t]*```/;
188
+ /** The raw JSON of the first ```json storyplan fenced block, if any. */
189
+ export const storyPlanBlock = (markdown) => fencePattern.exec(markdown)?.[1];
190
+ /**
191
+ * Decodes the story plan embedded in its markdown file. Decoding only —
192
+ * `validateStoryPlan` checks the graph invariants separately, so a parse
193
+ * failure and an invalid plan are told apart.
194
+ */
195
+ export const parseStoryPlan = Effect.fn("@llm4ts/flow/StoryPlan.parse")(function* (markdown) {
196
+ const block = storyPlanBlock(markdown);
197
+ if (block === undefined) {
198
+ return yield* PlanParseError.make({
199
+ message: "no ```json storyplan fenced block in the story plan markdown"
200
+ });
201
+ }
202
+ return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(StoryPlan))(block).pipe(Effect.mapError((error) => PlanParseError.make({ message: `invalid story plan block: ${String(error)}` })));
203
+ });
204
+ const list = (items) => items.length === 0 ? "none" : items.join(", ");
205
+ /**
206
+ * The operator-facing markdown: a readable summary per story plus the
207
+ * fenced block that is the source of truth. Editing the block and rerunning
208
+ * is the approval and the re-plan path.
209
+ */
210
+ export const renderStoryPlan = Effect.fn("@llm4ts/flow/StoryPlan.render")(function* (plan) {
211
+ const encoded = yield* Schema.encodeEffect(StoryPlan)(plan).pipe(Effect.mapError((error) => PlanParseError.make({ message: `story plan not encodable: ${String(error)}` })));
212
+ const waves = topologicalWaves(plan);
213
+ const lines = [
214
+ `# Epic: ${plan.epicId}`,
215
+ "",
216
+ plan.epic.trim(),
217
+ "",
218
+ "## Waves",
219
+ "",
220
+ ...waves.map((wave, index) => `${index + 1}. ${wave.join(", ")}`),
221
+ "",
222
+ "## Stories",
223
+ ""
224
+ ];
225
+ for (const story of plan.stories) {
226
+ lines.push(`### ${story.id} — ${story.title}`, "", story.description.trim(), "", `- depends on: ${list(story.dependsOn)}`, `- owned: ${list(story.owned)}`, `- shared read-only: ${list(story.sharedReadOnly)}`, `- provides: ${list(story.provides)}`, "");
227
+ }
228
+ lines.push("## Plan block", "", "Edit this block to change the plan; the prose above is regenerated from it.", "", "```" + storyPlanFenceInfo, JSON.stringify(encoded, null, 2), "```", "");
229
+ return lines.join("\n");
230
+ });
231
+ export const makeStoryPlanStore = (files) => {
232
+ const save = (path, plan) => Effect.flatMap(renderStoryPlan(plan), (markdown) => files.writeAtomic(path, markdown));
233
+ const load = (path) => Effect.flatMap(files.read(path), (contents) => contents === undefined ? Effect.succeed(undefined) : parseStoryPlan(contents));
234
+ return {
235
+ save,
236
+ load,
237
+ recoverOrCreate: (path, create) => Effect.flatMap(load(path), (stored) => stored === undefined
238
+ ? Effect.tap(create, (plan) => save(path, plan))
239
+ : Effect.succeed(stored))
240
+ };
241
+ };
242
+ //# sourceMappingURL=StoryPlan.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"StoryPlan.js","sourceRoot":"","sources":["../src/StoryPlan.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llm4ts/flow",
3
- "version": "0.15.1",
3
+ "version": "0.16.1",
4
4
  "description": "Effect-native LLM workflow, persistence, review, and repository automation",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -43,6 +43,7 @@
43
43
  "./Package": "./dist/Package.js",
44
44
  "./PageSpec": "./dist/PageSpec.js",
45
45
  "./Patterns": "./dist/Patterns.js",
46
+ "./Perimeter": "./dist/Perimeter.js",
46
47
  "./Persistence": "./dist/Persistence.js",
47
48
  "./Plan": "./dist/Plan.js",
48
49
  "./PlanExecution": "./dist/PlanExecution.js",
@@ -56,6 +57,8 @@
56
57
  "./ReviewCache": "./dist/ReviewCache.js",
57
58
  "./Reviewer": "./dist/Reviewer.js",
58
59
  "./SpecChecks": "./dist/SpecChecks.js",
60
+ "./Stories": "./dist/Stories.js",
61
+ "./StoryPlan": "./dist/StoryPlan.js",
59
62
  "./Survey": "./dist/Survey.js",
60
63
  "./TransientRetry": "./dist/TransientRetry.js",
61
64
  "./Usage": "./dist/Usage.js",
@@ -86,7 +89,7 @@
86
89
  "typescript"
87
90
  ],
88
91
  "dependencies": {
89
- "@llm4ts/core": "0.15.1"
92
+ "@llm4ts/core": "0.16.1"
90
93
  },
91
94
  "peerDependencies": {
92
95
  "effect": "4.0.0-beta.102"
package/src/BoardSync.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import * as Effect from "effect/Effect"
2
2
  import * as Ref from "effect/Ref"
3
3
  import * as Schema from "effect/Schema"
4
+ import * as Semaphore from "effect/Semaphore"
4
5
  import { quoteWiql, type AzureDevOpsToolShape } from "./AzureDevOpsTool.ts"
5
6
  import { FlowAborted, type FlowError } from "./FlowError.ts"
6
7
  import { loadVersioned, saveVersioned, type PlainFileStoreShape } from "./Persistence.ts"
@@ -138,6 +139,10 @@ export const makeLocalBoardSync = (
138
139
  ): BoardSyncShape => {
139
140
  const jsonPath = join(directory, "board.json")
140
141
  const markdownPath = join(directory, "board.md")
142
+ // Every mutation is load → change → save; stories running in parallel
143
+ // (ADR 0013) mutate the board at the same time, and without the permit
144
+ // the last save wins and the others' transitions vanish.
145
+ const lock = Semaphore.makeUnsafe(1)
141
146
 
142
147
  const load: Effect.Effect<Board, FlowError> = loadVersioned(
143
148
  files,
@@ -155,29 +160,33 @@ export const makeLocalBoardSync = (
155
160
  id: string,
156
161
  transform: (item: BoardItem) => BoardItem
157
162
  ): Effect.Effect<void, FlowError> =>
158
- Effect.gen(function* () {
159
- const board = yield* load
160
- if (!board.items.some((item) => item.id === id)) {
161
- return yield* FlowAborted.make({ message: `board has no item '${id}' plan it first` })
162
- }
163
- yield* save(
164
- Board.make({
165
- ...board,
166
- items: board.items.map((item) => (item.id === id ? transform(item) : item))
167
- })
168
- )
169
- })
163
+ lock.withPermit(
164
+ Effect.gen(function* () {
165
+ const board = yield* load
166
+ if (!board.items.some((item) => item.id === id)) {
167
+ return yield* FlowAborted.make({ message: `board has no item '${id}' — plan it first` })
168
+ }
169
+ yield* save(
170
+ Board.make({
171
+ ...board,
172
+ items: board.items.map((item) => (item.id === id ? transform(item) : item))
173
+ })
174
+ )
175
+ })
176
+ )
170
177
 
171
178
  return {
172
179
  plan: (items) =>
173
- Effect.gen(function* () {
174
- const board = yield* load
175
- const known = new Map(board.items.map((item) => [item.id, item] as const))
176
- // Known ids keep their lived state re-planning must never demote a
177
- // converted page back to "planned".
178
- const merged = [...board.items, ...items.filter((item) => !known.has(item.id))]
179
- yield* save(Board.make({ title: board.title, items: merged }))
180
- }),
180
+ lock.withPermit(
181
+ Effect.gen(function* () {
182
+ const board = yield* load
183
+ const known = new Map(board.items.map((item) => [item.id, item] as const))
184
+ // Known ids keep their lived state — re-planning must never demote a
185
+ // converted page back to "planned".
186
+ const merged = [...board.items, ...items.filter((item) => !known.has(item.id))]
187
+ yield* save(Board.make({ title: board.title, items: merged }))
188
+ })
189
+ ),
181
190
  start: (id) => update(id, (item) => BoardItem.make({ ...item, status: "active" })),
182
191
  complete: (id, result) => update(id, (item) => applyResult(item, "done", result)),
183
192
  fail: (id, reason) =>
@@ -1,6 +1,9 @@
1
1
  import * as Context from "effect/Context"
2
+ import type * as Effect from "effect/Effect"
3
+ import type * as Scope from "effect/Scope"
2
4
  import type { ConnectorCapabilities } from "@llm4ts/core/Models"
3
5
  import type { LlmServiceShape } from "@llm4ts/core/LlmService"
6
+ import type { FlowError } from "./FlowError.ts"
4
7
  import type { FlowEventsShape } from "./FlowEvents.ts"
5
8
  import type { GitToolShape } from "./GitTool.ts"
6
9
  import type { GitHubToolShape } from "./GitHubTool.ts"
@@ -16,6 +19,13 @@ export interface FlowContextShape {
16
19
  readonly userPrompt: string
17
20
  readonly workDir: string
18
21
  readonly workspace: string
22
+ /**
23
+ * The same seats rebound to another directory (a story worktree, ADR
24
+ * 0013): every CLI seat launched there, git rooted there, the run's
25
+ * events and cost tracking shared. Absent when the runner cannot rebind
26
+ * (an embedded context built by hand).
27
+ */
28
+ readonly contextFor?: (workDir: string) => Effect.Effect<FlowContextShape, FlowError, Scope.Scope>
19
29
  }
20
30
 
21
31
  export class FlowContext extends Context.Service<FlowContext, FlowContextShape>()(
package/src/FlowError.ts CHANGED
@@ -124,6 +124,74 @@ export class BudgetExceeded extends Schema.TaggedErrorClass<BudgetExceeded>()("B
124
124
  }
125
125
  }
126
126
 
127
+ /** A story plan that failed deterministic validation — every violation, not the first (ADR 0013). */
128
+ export class StoryPlanInvalid extends Schema.TaggedErrorClass<StoryPlanInvalid>()(
129
+ "StoryPlanInvalid",
130
+ {
131
+ violations: Schema.Array(Schema.String)
132
+ }
133
+ ) {
134
+ get message(): string {
135
+ return `story plan invalid:\n${this.violations.map((violation) => `- ${violation}`).join("\n")}`
136
+ }
137
+ }
138
+
139
+ /** A story branch changed paths outside the story's declared `owned` set. */
140
+ export class PerimeterViolation extends Schema.TaggedErrorClass<PerimeterViolation>()(
141
+ "PerimeterViolation",
142
+ {
143
+ story: Schema.String,
144
+ outside: Schema.Array(Schema.String),
145
+ sharedReadOnly: Schema.Array(Schema.String)
146
+ }
147
+ ) {
148
+ get message(): string {
149
+ const lines = [`story '${this.story}' changed paths outside its perimeter:`]
150
+ for (const path of this.sharedReadOnly) {
151
+ lines.push(`- ${path} (shared read-only: revert it, or request it as a dedicated story)`)
152
+ }
153
+ for (const path of this.outside) {
154
+ lines.push(`- ${path} (not in the story's owned paths)`)
155
+ }
156
+ return lines.join("\n")
157
+ }
158
+ }
159
+
160
+ /** The coder ended a story with `BLOCKED_ON:` — unplanned work belongs to another story. */
161
+ export class MissingDependency extends Schema.TaggedErrorClass<MissingDependency>()(
162
+ "MissingDependency",
163
+ {
164
+ story: Schema.String,
165
+ need: Schema.String
166
+ }
167
+ ) {
168
+ get message(): string {
169
+ return `story '${this.story}' is blocked on unplanned work: ${this.need}`
170
+ }
171
+ }
172
+
173
+ /** A story branch did not merge cleanly into the epic branch; the merge was aborted. */
174
+ export class MergeConflict extends Schema.TaggedErrorClass<MergeConflict>()("MergeConflict", {
175
+ branch: Schema.String,
176
+ into: Schema.String,
177
+ paths: Schema.Array(Schema.String)
178
+ }) {
179
+ get message(): string {
180
+ const where = this.paths.length === 0 ? "" : `: ${this.paths.join(", ")}`
181
+ return `merging '${this.branch}' into '${this.into}' conflicted${where}`
182
+ }
183
+ }
184
+
185
+ /** One story failed; carries the story id so a fail-fast run names its cause. */
186
+ export class StoryFailed extends Schema.TaggedErrorClass<StoryFailed>()("StoryFailed", {
187
+ story: Schema.String,
188
+ reason: Schema.String
189
+ }) {
190
+ get message(): string {
191
+ return `story '${this.story}' failed: ${this.reason}`
192
+ }
193
+ }
194
+
127
195
  export const FlowError = Schema.Union([
128
196
  PersistenceError,
129
197
  PlanParseError,
@@ -136,6 +204,11 @@ export const FlowError = Schema.Union([
136
204
  FlowLlmError,
137
205
  FlowCapabilityDenied,
138
206
  ColumnNotFound,
139
- BudgetExceeded
207
+ BudgetExceeded,
208
+ StoryPlanInvalid,
209
+ PerimeterViolation,
210
+ MissingDependency,
211
+ MergeConflict,
212
+ StoryFailed
140
213
  ])
141
214
  export type FlowError = typeof FlowError.Type
package/src/GitTool.ts CHANGED
@@ -2,7 +2,7 @@ import * as Effect from "effect/Effect"
2
2
  import * as Schema from "effect/Schema"
3
3
  import { Capabilities, type Capability } from "@llm4ts/core/Capability"
4
4
  import type { ProcessExecutorShape, ProcessResult } from "@llm4ts/core/ProcessExecutor"
5
- import { ProcessError, type FlowError } from "./FlowError.ts"
5
+ import { MergeConflict, ProcessError, type FlowError } from "./FlowError.ts"
6
6
  import type { FlowEventsShape } from "./FlowEvents.ts"
7
7
  import { guarded } from "./CapabilityGuard.ts"
8
8
 
@@ -56,8 +56,26 @@ export interface GitToolShape {
56
56
  readonly push: (remote: string, branch: string) => Effect.Effect<void, FlowError>
57
57
  readonly checkpoint: Effect.Effect<string, FlowError>
58
58
  readonly rollback: (checkpoint: string) => Effect.Effect<void, FlowError>
59
+ /** Checks an EXISTING branch out into a new worktree at `path`. */
59
60
  readonly addWorktree: (path: string, branch: string) => Effect.Effect<void, FlowError>
60
- readonly removeWorktree: (path: string) => Effect.Effect<void, FlowError>
61
+ /** Creates `branch` at `startPoint` and checks it out into a new worktree at `path`. */
62
+ readonly addWorktreeNewBranch: (
63
+ path: string,
64
+ branch: string,
65
+ startPoint: string
66
+ ) => Effect.Effect<void, FlowError>
67
+ /** `force` also removes a worktree holding untracked or modified files. */
68
+ readonly removeWorktree: (path: string, force?: boolean) => Effect.Effect<void, FlowError>
69
+ readonly branchExists: (name: string) => Effect.Effect<boolean, FlowError>
70
+ readonly deleteBranch: (name: string) => Effect.Effect<void, FlowError>
71
+ /** Whether `commit` is reachable from `of` — a merged story branch is an ancestor of the epic head. */
72
+ readonly isAncestor: (commit: string, of: string) => Effect.Effect<boolean, FlowError>
73
+ /**
74
+ * Merges `branch` into the checked-out branch with a merge commit. A
75
+ * conflict fails typed with the conflicting paths and leaves the tree as it
76
+ * was (the merge is aborted), so the next merge can proceed.
77
+ */
78
+ readonly merge: (branch: string, message: string) => Effect.Effect<void, FlowError>
61
79
  }
62
80
 
63
81
  const nonInteractiveEnvironment = Object.freeze({
@@ -287,7 +305,59 @@ export const makeGitTool = (
287
305
  write("git rollback", runOrFail(["reset", "--hard", checkpoint]).pipe(Effect.asVoid)),
288
306
  addWorktree: (path, branch) =>
289
307
  write("git worktree add", runOrFail(["worktree", "add", path, branch]).pipe(Effect.asVoid)),
290
- removeWorktree: (path) =>
291
- write("git worktree remove", runOrFail(["worktree", "remove", path]).pipe(Effect.asVoid))
308
+ addWorktreeNewBranch: (path, branch, startPoint) =>
309
+ write(
310
+ "git worktree add -b",
311
+ runOrFail(["worktree", "add", "-b", branch, path, startPoint]).pipe(Effect.asVoid)
312
+ ),
313
+ removeWorktree: (path, force = false) =>
314
+ write(
315
+ "git worktree remove",
316
+ runOrFail(["worktree", "remove", ...(force ? ["--force"] : []), path]).pipe(Effect.asVoid)
317
+ ),
318
+ branchExists: (name) =>
319
+ read(
320
+ "git branchExists",
321
+ Effect.map(verifiedRef(`refs/heads/${name}`), (found) => found !== undefined)
322
+ ),
323
+ deleteBranch: (name) =>
324
+ write("git branch -D", runOrFail(["branch", "-D", name]).pipe(Effect.asVoid)),
325
+ isAncestor: (commit, of) =>
326
+ read(
327
+ "git merge-base --is-ancestor",
328
+ Effect.flatMap(run(["merge-base", "--is-ancestor", commit, of]), (result) =>
329
+ result.exitCode === 0
330
+ ? Effect.succeed(true)
331
+ : result.exitCode === 1
332
+ ? Effect.succeed(false)
333
+ : Effect.fail(
334
+ ProcessError.make({
335
+ message: `git merge-base --is-ancestor ${commit} ${of}`,
336
+ detail: problem(result)
337
+ })
338
+ )
339
+ )
340
+ ),
341
+ merge: (branch, message) =>
342
+ write(
343
+ "git merge",
344
+ Effect.gen(function* () {
345
+ const result = yield* run(["merge", "--no-ff", "-m", message, branch])
346
+ if (result.exitCode === 0) {
347
+ return
348
+ }
349
+ const into = yield* runOrFail(["rev-parse", "--abbrev-ref", "HEAD"])
350
+ const unmerged = yield* run(["diff", "--name-only", "--diff-filter=U"])
351
+ const paths = text(unmerged.stdout)
352
+ .split(/\r?\n/)
353
+ .map((line) => line.trim())
354
+ .filter((line) => line.length > 0)
355
+ // Abort regardless of the outcome so the epic tree is clean for the
356
+ // next story; a merge that failed before starting has nothing to
357
+ // abort and git says so, which is not a second failure.
358
+ yield* run(["merge", "--abort"])
359
+ return yield* MergeConflict.make({ branch, into, paths })
360
+ })
361
+ )
292
362
  }
293
363
  }
@@ -0,0 +1,49 @@
1
+ // The story perimeter check (ADR 0013): a story branch may only change what
2
+ // the story owns. Enforced after the fact on the branch's changed files —
3
+ // the prompt states the rule, this is what makes it true.
4
+ import * as Effect from "effect/Effect"
5
+ import { PerimeterViolation } from "./FlowError.ts"
6
+ import { pathWithin, type Story } from "./StoryPlan.ts"
7
+
8
+ export interface PerimeterCheck {
9
+ /** Changed paths under a `sharedReadOnly` prefix — the worse class, reported first. */
10
+ readonly sharedReadOnly: ReadonlyArray<string>
11
+ /** Changed paths under neither `owned` nor `sharedReadOnly`. */
12
+ readonly outside: ReadonlyArray<string>
13
+ }
14
+
15
+ export const checkPerimeter = (
16
+ changedPaths: ReadonlyArray<string>,
17
+ story: Story
18
+ ): PerimeterCheck => {
19
+ const sharedReadOnly: Array<string> = []
20
+ const outside: Array<string> = []
21
+ for (const path of changedPaths) {
22
+ if (story.owned.some((prefix) => pathWithin(path, prefix))) {
23
+ continue
24
+ }
25
+ if (story.sharedReadOnly.some((prefix) => pathWithin(path, prefix))) {
26
+ sharedReadOnly.push(path)
27
+ } else {
28
+ outside.push(path)
29
+ }
30
+ }
31
+ return { sharedReadOnly, outside }
32
+ }
33
+
34
+ export const isWithinPerimeter = (check: PerimeterCheck): boolean =>
35
+ check.sharedReadOnly.length === 0 && check.outside.length === 0
36
+
37
+ export const enforcePerimeter = Effect.fn("@llm4ts/flow/Perimeter.enforce")(function* (
38
+ changedPaths: ReadonlyArray<string>,
39
+ story: Story
40
+ ): Effect.fn.Return<void, PerimeterViolation> {
41
+ const check = checkPerimeter(changedPaths, story)
42
+ if (!isWithinPerimeter(check)) {
43
+ return yield* PerimeterViolation.make({
44
+ story: story.id,
45
+ outside: check.outside,
46
+ sharedReadOnly: check.sharedReadOnly
47
+ })
48
+ }
49
+ })