@llm4ts/flow 0.1.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 (198) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +41 -0
  3. package/dist/AzureDevOpsTool.d.ts +60 -0
  4. package/dist/AzureDevOpsTool.d.ts.map +1 -0
  5. package/dist/AzureDevOpsTool.js +163 -0
  6. package/dist/AzureDevOpsTool.js.map +1 -0
  7. package/dist/Bench.d.ts +133 -0
  8. package/dist/Bench.d.ts.map +1 -0
  9. package/dist/Bench.js +272 -0
  10. package/dist/Bench.js.map +1 -0
  11. package/dist/BenchReport.d.ts +9 -0
  12. package/dist/BenchReport.d.ts.map +1 -0
  13. package/dist/BenchReport.js +360 -0
  14. package/dist/BenchReport.js.map +1 -0
  15. package/dist/CapabilityGuard.d.ts +6 -0
  16. package/dist/CapabilityGuard.d.ts.map +1 -0
  17. package/dist/CapabilityGuard.js +25 -0
  18. package/dist/CapabilityGuard.js.map +1 -0
  19. package/dist/Chat.d.ts +15 -0
  20. package/dist/Chat.d.ts.map +1 -0
  21. package/dist/Chat.js +36 -0
  22. package/dist/Chat.js.map +1 -0
  23. package/dist/Classified.d.ts +14 -0
  24. package/dist/Classified.d.ts.map +1 -0
  25. package/dist/Classified.js +48 -0
  26. package/dist/Classified.js.map +1 -0
  27. package/dist/CostLedger.d.ts +55 -0
  28. package/dist/CostLedger.d.ts.map +1 -0
  29. package/dist/CostLedger.js +126 -0
  30. package/dist/CostLedger.js.map +1 -0
  31. package/dist/CostTracker.d.ts +13 -0
  32. package/dist/CostTracker.d.ts.map +1 -0
  33. package/dist/CostTracker.js +116 -0
  34. package/dist/CostTracker.js.map +1 -0
  35. package/dist/Equiv.d.ts +163 -0
  36. package/dist/Equiv.d.ts.map +1 -0
  37. package/dist/Equiv.js +256 -0
  38. package/dist/Equiv.js.map +1 -0
  39. package/dist/EquivReport.d.ts +16 -0
  40. package/dist/EquivReport.d.ts.map +1 -0
  41. package/dist/EquivReport.js +77 -0
  42. package/dist/EquivReport.js.map +1 -0
  43. package/dist/Flow.d.ts +25 -0
  44. package/dist/Flow.d.ts.map +1 -0
  45. package/dist/Flow.js +39 -0
  46. package/dist/Flow.js.map +1 -0
  47. package/dist/FlowContext.d.ts +23 -0
  48. package/dist/FlowContext.d.ts.map +1 -0
  49. package/dist/FlowContext.js +4 -0
  50. package/dist/FlowContext.js.map +1 -0
  51. package/dist/FlowError.d.ts +80 -0
  52. package/dist/FlowError.d.ts.map +1 -0
  53. package/dist/FlowError.js +92 -0
  54. package/dist/FlowError.js.map +1 -0
  55. package/dist/FlowEvents.d.ts +101 -0
  56. package/dist/FlowEvents.d.ts.map +1 -0
  57. package/dist/FlowEvents.js +103 -0
  58. package/dist/FlowEvents.js.map +1 -0
  59. package/dist/FlowRecorder.d.ts +26 -0
  60. package/dist/FlowRecorder.d.ts.map +1 -0
  61. package/dist/FlowRecorder.js +74 -0
  62. package/dist/FlowRecorder.js.map +1 -0
  63. package/dist/GitHubTool.d.ts +53 -0
  64. package/dist/GitHubTool.d.ts.map +1 -0
  65. package/dist/GitHubTool.js +191 -0
  66. package/dist/GitHubTool.js.map +1 -0
  67. package/dist/GitTool.d.ts +46 -0
  68. package/dist/GitTool.d.ts.map +1 -0
  69. package/dist/GitTool.js +97 -0
  70. package/dist/GitTool.js.map +1 -0
  71. package/dist/Interaction.d.ts +21 -0
  72. package/dist/Interaction.d.ts.map +1 -0
  73. package/dist/Interaction.js +9 -0
  74. package/dist/Interaction.js.map +1 -0
  75. package/dist/McpServer.d.ts +18 -0
  76. package/dist/McpServer.d.ts.map +1 -0
  77. package/dist/McpServer.js +122 -0
  78. package/dist/McpServer.js.map +1 -0
  79. package/dist/Mermaid.d.ts +7 -0
  80. package/dist/Mermaid.d.ts.map +1 -0
  81. package/dist/Mermaid.js +98 -0
  82. package/dist/Mermaid.js.map +1 -0
  83. package/dist/Pack.d.ts +40 -0
  84. package/dist/Pack.d.ts.map +1 -0
  85. package/dist/Pack.js +141 -0
  86. package/dist/Pack.js.map +1 -0
  87. package/dist/Package.d.ts +2 -0
  88. package/dist/Package.d.ts.map +1 -0
  89. package/dist/Package.js +2 -0
  90. package/dist/Package.js.map +1 -0
  91. package/dist/Persistence.d.ts +32 -0
  92. package/dist/Persistence.d.ts.map +1 -0
  93. package/dist/Persistence.js +85 -0
  94. package/dist/Persistence.js.map +1 -0
  95. package/dist/Plan.d.ts +26 -0
  96. package/dist/Plan.d.ts.map +1 -0
  97. package/dist/Plan.js +101 -0
  98. package/dist/Plan.js.map +1 -0
  99. package/dist/PlanExecution.d.ts +7 -0
  100. package/dist/PlanExecution.d.ts.map +1 -0
  101. package/dist/PlanExecution.js +19 -0
  102. package/dist/PlanExecution.js.map +1 -0
  103. package/dist/Planner.d.ts +32 -0
  104. package/dist/Planner.d.ts.map +1 -0
  105. package/dist/Planner.js +97 -0
  106. package/dist/Planner.js.map +1 -0
  107. package/dist/PrSummary.d.ts +15 -0
  108. package/dist/PrSummary.d.ts.map +1 -0
  109. package/dist/PrSummary.js +29 -0
  110. package/dist/PrSummary.js.map +1 -0
  111. package/dist/PriceList.d.ts +11 -0
  112. package/dist/PriceList.d.ts.map +1 -0
  113. package/dist/PriceList.js +79 -0
  114. package/dist/PriceList.js.map +1 -0
  115. package/dist/Provenance.d.ts +27 -0
  116. package/dist/Provenance.d.ts.map +1 -0
  117. package/dist/Provenance.js +38 -0
  118. package/dist/Provenance.js.map +1 -0
  119. package/dist/Replay.d.ts +29 -0
  120. package/dist/Replay.d.ts.map +1 -0
  121. package/dist/Replay.js +181 -0
  122. package/dist/Replay.js.map +1 -0
  123. package/dist/Review.d.ts +71 -0
  124. package/dist/Review.d.ts.map +1 -0
  125. package/dist/Review.js +204 -0
  126. package/dist/Review.js.map +1 -0
  127. package/dist/ReviewCache.d.ts +6 -0
  128. package/dist/ReviewCache.d.ts.map +1 -0
  129. package/dist/ReviewCache.js +28 -0
  130. package/dist/ReviewCache.js.map +1 -0
  131. package/dist/Reviewer.d.ts +12 -0
  132. package/dist/Reviewer.d.ts.map +1 -0
  133. package/dist/Reviewer.js +30 -0
  134. package/dist/Reviewer.js.map +1 -0
  135. package/dist/SpecChecks.d.ts +17 -0
  136. package/dist/SpecChecks.d.ts.map +1 -0
  137. package/dist/SpecChecks.js +107 -0
  138. package/dist/SpecChecks.js.map +1 -0
  139. package/dist/Survey.d.ts +32 -0
  140. package/dist/Survey.d.ts.map +1 -0
  141. package/dist/Survey.js +119 -0
  142. package/dist/Survey.js.map +1 -0
  143. package/dist/TransientRetry.d.ts +16 -0
  144. package/dist/TransientRetry.d.ts.map +1 -0
  145. package/dist/TransientRetry.js +102 -0
  146. package/dist/TransientRetry.js.map +1 -0
  147. package/dist/Workspace.d.ts +39 -0
  148. package/dist/Workspace.d.ts.map +1 -0
  149. package/dist/Workspace.js +192 -0
  150. package/dist/Workspace.js.map +1 -0
  151. package/dist/WorkspaceLayout.d.ts +3 -0
  152. package/dist/WorkspaceLayout.d.ts.map +1 -0
  153. package/dist/WorkspaceLayout.js +19 -0
  154. package/dist/WorkspaceLayout.js.map +1 -0
  155. package/dist/WorkspaceTools.d.ts +4 -0
  156. package/dist/WorkspaceTools.d.ts.map +1 -0
  157. package/dist/WorkspaceTools.js +130 -0
  158. package/dist/WorkspaceTools.js.map +1 -0
  159. package/package.json +91 -0
  160. package/src/AzureDevOpsTool.ts +291 -0
  161. package/src/Bench.ts +340 -0
  162. package/src/BenchReport.ts +447 -0
  163. package/src/CapabilityGuard.ts +43 -0
  164. package/src/Chat.ts +60 -0
  165. package/src/Classified.ts +67 -0
  166. package/src/CostLedger.ts +171 -0
  167. package/src/CostTracker.ts +170 -0
  168. package/src/Equiv.ts +378 -0
  169. package/src/EquivReport.ts +92 -0
  170. package/src/Flow.ts +72 -0
  171. package/src/FlowContext.ts +23 -0
  172. package/src/FlowError.ts +112 -0
  173. package/src/FlowEvents.ts +147 -0
  174. package/src/FlowRecorder.ts +116 -0
  175. package/src/GitHubTool.ts +315 -0
  176. package/src/GitTool.ts +214 -0
  177. package/src/Interaction.ts +23 -0
  178. package/src/McpServer.ts +168 -0
  179. package/src/Mermaid.ts +112 -0
  180. package/src/Pack.ts +213 -0
  181. package/src/Package.ts +1 -0
  182. package/src/Persistence.ts +198 -0
  183. package/src/Plan.ts +119 -0
  184. package/src/PlanExecution.ts +46 -0
  185. package/src/Planner.ts +134 -0
  186. package/src/PrSummary.ts +37 -0
  187. package/src/PriceList.ts +91 -0
  188. package/src/Provenance.ts +85 -0
  189. package/src/Replay.ts +265 -0
  190. package/src/Review.ts +337 -0
  191. package/src/ReviewCache.ts +45 -0
  192. package/src/Reviewer.ts +31 -0
  193. package/src/SpecChecks.ts +143 -0
  194. package/src/Survey.ts +144 -0
  195. package/src/TransientRetry.ts +238 -0
  196. package/src/Workspace.ts +255 -0
  197. package/src/WorkspaceLayout.ts +22 -0
  198. package/src/WorkspaceTools.ts +188 -0
package/src/Pack.ts ADDED
@@ -0,0 +1,213 @@
1
+ import * as Effect from "effect/Effect"
2
+ import * as Schema from "effect/Schema"
3
+ import { Dimension } from "@llm4ts/core/eval/Eval"
4
+ import { PlanParseError } from "./FlowError.ts"
5
+ import { parseReviewer, type Reviewer } from "./Reviewer.ts"
6
+ import { CoverageRule } from "./SpecChecks.ts"
7
+ import type { WorkspaceError, WorkspaceShape } from "./Workspace.ts"
8
+
9
+ export { Reviewer, parseReviewer } from "./Reviewer.ts"
10
+
11
+ export const ComparisonOrdering = Schema.Literals(["Ordered", "Unordered", "PerKey"])
12
+ export type ComparisonOrdering = typeof ComparisonOrdering.Type
13
+
14
+ export class ComparisonPolicy extends Schema.Class<ComparisonPolicy>("ComparisonPolicy")({
15
+ ordering: ComparisonOrdering,
16
+ ignore: Schema.ReadonlySet(Schema.String)
17
+ }) {}
18
+
19
+ export interface Pack {
20
+ readonly name: string
21
+ readonly source: string
22
+ readonly scaffold: string | undefined
23
+ readonly sources: string | undefined
24
+ readonly programs: string | undefined
25
+ readonly specsDir: string
26
+ readonly featuresDir: string
27
+ readonly gates: Readonly<Record<string, ReadonlyArray<string>>>
28
+ readonly replay: ReadonlyArray<string> | undefined
29
+ readonly equivalence: ComparisonPolicy
30
+ readonly judgeDimensions: ReadonlyArray<Dimension>
31
+ readonly coverage: ReadonlyArray<CoverageRule>
32
+ readonly survey: ReadonlyArray<CoverageRule>
33
+ readonly prompts: Readonly<Record<string, string>>
34
+ readonly lenses: ReadonlyArray<Reviewer>
35
+ readonly lessons: string | undefined
36
+ readonly dir: string
37
+ readonly gate: (name: string) => ReadonlyArray<string> | undefined
38
+ readonly prompt: (name: string) => string | undefined
39
+ }
40
+
41
+ interface ParsedManifest {
42
+ readonly name: string
43
+ readonly fields: Readonly<Record<string, string>>
44
+ readonly sections: ReadonlyArray<string>
45
+ }
46
+
47
+ const parseManifest = (markdown: string): Effect.Effect<ParsedManifest, PlanParseError> => {
48
+ const chunks = markdown
49
+ .trim()
50
+ .split(/^(?=## )/m)
51
+ .map((chunk) => chunk.trim())
52
+ .filter((chunk) => chunk.length > 0)
53
+ const head = chunks[0]
54
+ const prefix = "# Pack: "
55
+ if (head === undefined || !head.startsWith(prefix)) {
56
+ return Effect.fail(
57
+ PlanParseError.make({
58
+ message: `expected a '${prefix}<name>' header`
59
+ })
60
+ )
61
+ }
62
+ const lines = head.split(/\r?\n/)
63
+ const fields = Object.fromEntries(
64
+ lines.slice(1).flatMap((line) => {
65
+ const index = line.indexOf(":")
66
+ return index < 0 || line.trim().startsWith("#") || line.trim().startsWith("-")
67
+ ? []
68
+ : [[line.slice(0, index).trim(), line.slice(index + 1).trim()]]
69
+ })
70
+ )
71
+ return fields.source === undefined
72
+ ? Effect.fail(
73
+ PlanParseError.make({
74
+ message: "pack manifest is missing a 'source:' field"
75
+ })
76
+ )
77
+ : Effect.succeed({
78
+ name: lines[0]?.slice(prefix.length).trim() ?? "",
79
+ fields,
80
+ sections: chunks.slice(1)
81
+ })
82
+ }
83
+
84
+ const section = (sections: ReadonlyArray<string>, title: string): string | undefined =>
85
+ sections
86
+ .find((candidate) => candidate.split(/\r?\n/)[0]?.trim() === `## ${title}`)
87
+ ?.split(/\r?\n/)
88
+ .slice(1)
89
+ .join("\n")
90
+ .trim()
91
+
92
+ const namedItems = (body: string | undefined): Readonly<Record<string, string>> =>
93
+ Object.fromEntries(
94
+ (body ?? "").split(/\r?\n/).flatMap((line) => {
95
+ const match = /^- ([^:]+): (.+)$/.exec(line)
96
+ return match?.[1] === undefined || match[2] === undefined
97
+ ? []
98
+ : [[match[1].trim(), match[2].trim()]]
99
+ })
100
+ )
101
+
102
+ const rules = (sections: ReadonlyArray<string>, prefix: string): ReadonlyArray<CoverageRule> =>
103
+ sections.flatMap((candidate) => {
104
+ const lines = candidate.split(/\r?\n/)
105
+ const heading = lines[0]?.trim() ?? ""
106
+ if (!heading.startsWith(prefix)) {
107
+ return []
108
+ }
109
+ const fields = Object.fromEntries(
110
+ lines.slice(1).flatMap((line) => {
111
+ const index = line.indexOf(":")
112
+ return index < 0 ? [] : [[line.slice(0, index).trim(), line.slice(index + 1).trim()]]
113
+ })
114
+ )
115
+ return fields.files === undefined || fields.unit === undefined
116
+ ? []
117
+ : [
118
+ CoverageRule.make({
119
+ name: heading.slice(prefix.length).trim(),
120
+ files: fields.files,
121
+ unit: fields.unit
122
+ })
123
+ ]
124
+ })
125
+
126
+ const dimensions = (body: string | undefined): ReadonlyArray<Dimension> =>
127
+ (body ?? "").split(/\r?\n/).flatMap((line) => {
128
+ const match = /^- ([^(]+)\(0\.\.(\d+)\): (.+)$/.exec(line)
129
+ const name = match?.[1]?.trim()
130
+ const maxScore = Number.parseInt(match?.[2] ?? "", 10)
131
+ const rubric = match?.[3]?.trim()
132
+ return name === undefined || rubric === undefined || !Number.isInteger(maxScore)
133
+ ? []
134
+ : [Dimension.make({ name, rubric, maxScore })]
135
+ })
136
+
137
+ const markdownSidecars = Effect.fn("@llm4ts/flow/Pack.markdownSidecars")(function* (
138
+ workspace: WorkspaceShape,
139
+ directory: string
140
+ ): Effect.fn.Return<Readonly<Record<string, string>>, WorkspaceError> {
141
+ const paths = yield* workspace.discover(`${directory}/*.md`)
142
+ const entries: Array<readonly [string, string]> = []
143
+ for (const path of paths) {
144
+ const file = path.split("/").at(-1) ?? path
145
+ entries.push([file.replace(/\.md$/, ""), (yield* workspace.read(path)).trim()])
146
+ }
147
+ return Object.fromEntries(entries)
148
+ })
149
+
150
+ export const loadPack = Effect.fn("@llm4ts/flow/Pack.load")(function* (
151
+ workspace: WorkspaceShape,
152
+ directory: string
153
+ ): Effect.fn.Return<Pack, WorkspaceError | PlanParseError> {
154
+ const manifest = yield* parseManifest(yield* workspace.read(`${directory}/pack.md`))
155
+ const prompts = yield* markdownSidecars(workspace, `${directory}/prompts`)
156
+ const lensFiles = yield* markdownSidecars(workspace, `${directory}/reviewers`)
157
+ const lessons = yield* workspace.read(`${directory}/lessons.md`).pipe(
158
+ Effect.map((text) => text.trim()),
159
+ Effect.catch(() => Effect.succeed(undefined))
160
+ )
161
+ const gateValues = namedItems(section(manifest.sections, "Gates"))
162
+ const equivalenceValues = namedItems(section(manifest.sections, "Equivalence"))
163
+ const ordering: ComparisonOrdering =
164
+ equivalenceValues.ordering?.toLowerCase() === "unordered"
165
+ ? "Unordered"
166
+ : equivalenceValues.ordering?.toLowerCase() === "per-key"
167
+ ? "PerKey"
168
+ : "Ordered"
169
+ const gates = Object.fromEntries(
170
+ Object.entries(gateValues).map(([name, command]) => [name, command.split(/\s+/)])
171
+ )
172
+ const lenses = Object.entries(lensFiles)
173
+ .sort(([left], [right]) => left.localeCompare(right))
174
+ .map(([name, text]) => parseReviewer(name, text))
175
+ const fields = manifest.fields
176
+ const pack: Pack = {
177
+ name: manifest.name,
178
+ source: fields.source ?? "",
179
+ scaffold: fields.scaffold,
180
+ sources: fields.sources,
181
+ programs: fields.programs,
182
+ specsDir: fields["specs-dir"] ?? "docs/specs",
183
+ featuresDir: fields["features-dir"] ?? "features",
184
+ gates,
185
+ replay: fields.replay?.split(/\s+/),
186
+ equivalence: ComparisonPolicy.make({
187
+ ordering,
188
+ ignore: new Set(
189
+ (equivalenceValues.ignore ?? "")
190
+ .split(",")
191
+ .map((item) => item.trim())
192
+ .filter((item) => item.length > 0)
193
+ )
194
+ }),
195
+ judgeDimensions: dimensions(section(manifest.sections, "Judge")),
196
+ coverage: rules(manifest.sections, "## Coverage: "),
197
+ survey: rules(manifest.sections, "## Survey: "),
198
+ prompts,
199
+ lenses,
200
+ lessons: lessons === undefined || lessons.length === 0 ? undefined : lessons,
201
+ dir: directory,
202
+ gate: (name) => gates[name],
203
+ prompt: (name) => prompts[name]
204
+ }
205
+ return pack
206
+ })
207
+
208
+ export const appendPackLesson = (
209
+ workspace: WorkspaceShape,
210
+ directory: string,
211
+ lesson: string
212
+ ): Effect.Effect<void, WorkspaceError> =>
213
+ workspace.append(`${directory}/lessons.md`, `- ${lesson.trim()}\n`)
package/src/Package.ts ADDED
@@ -0,0 +1 @@
1
+ export const packageName = "@llm4ts/flow"
@@ -0,0 +1,198 @@
1
+ import * as Context from "effect/Context"
2
+ import * as Effect from "effect/Effect"
3
+ import * as Ref from "effect/Ref"
4
+ import * as Schema from "effect/Schema"
5
+ import { PersistenceError, PlanParseError, UnsupportedSchemaVersion } from "./FlowError.ts"
6
+ import type { FlowError } from "./FlowError.ts"
7
+ import { parsePlan } from "./Plan.ts"
8
+ import type { Plan } from "./Plan.ts"
9
+
10
+ export interface PlainFileStoreShape {
11
+ readonly read: (path: string) => Effect.Effect<string | undefined, PersistenceError>
12
+ readonly writeAtomic: (path: string, contents: string) => Effect.Effect<void, PersistenceError>
13
+ readonly append: (path: string, contents: string) => Effect.Effect<void, PersistenceError>
14
+ readonly remove: (path: string) => Effect.Effect<void, PersistenceError>
15
+ readonly hashSha256: (path: string) => Effect.Effect<string, PersistenceError>
16
+ }
17
+
18
+ export class PlainFileStore extends Context.Service<PlainFileStore, PlainFileStoreShape>()(
19
+ "@llm4ts/flow/PlainFileStore"
20
+ ) {}
21
+
22
+ const hexDigest = (buffer: ArrayBuffer): string =>
23
+ Array.from(new Uint8Array(buffer))
24
+ .map((byte) => byte.toString(16).padStart(2, "0"))
25
+ .join("")
26
+
27
+ const sha256Hex = (path: string, contents: string): Effect.Effect<string, PersistenceError> =>
28
+ Effect.tryPromise({
29
+ try: async () =>
30
+ hexDigest(
31
+ await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(contents))
32
+ ),
33
+ catch: (error) =>
34
+ PersistenceError.make({
35
+ message: `failed to hash ${path}: ${error instanceof Error ? error.message : String(error)}`,
36
+ cause: error
37
+ })
38
+ })
39
+
40
+ export interface MemoryPlainFileStore {
41
+ readonly store: PlainFileStoreShape
42
+ readonly files: Effect.Effect<Readonly<Record<string, string>>>
43
+ readonly replace: (files: Readonly<Record<string, string>>) => Effect.Effect<void>
44
+ }
45
+
46
+ export const makeMemoryPlainFileStore = (
47
+ initial: Readonly<Record<string, string>> = {}
48
+ ): Effect.Effect<MemoryPlainFileStore> =>
49
+ Ref.make(initial).pipe(
50
+ Effect.map((state) => {
51
+ const store: PlainFileStoreShape = {
52
+ read: (path) => Ref.get(state).pipe(Effect.map((files) => files[path])),
53
+ writeAtomic: (path, contents) =>
54
+ Ref.update(state, (files) => ({ ...files, [path]: contents })),
55
+ append: (path, contents) =>
56
+ Ref.update(state, (files) => ({
57
+ ...files,
58
+ [path]: `${files[path] ?? ""}${contents}`
59
+ })),
60
+ remove: (path) =>
61
+ Ref.update(state, (files) =>
62
+ Object.fromEntries(Object.entries(files).filter(([candidate]) => candidate !== path))
63
+ ),
64
+ hashSha256: (path) =>
65
+ Ref.get(state).pipe(
66
+ Effect.flatMap((files) => {
67
+ const contents = files[path]
68
+ return contents === undefined
69
+ ? Effect.fail(
70
+ PersistenceError.make({
71
+ message: `failed to hash ${path}: file does not exist`
72
+ })
73
+ )
74
+ : sha256Hex(path, contents)
75
+ })
76
+ )
77
+ }
78
+ return {
79
+ store,
80
+ files: Ref.get(state),
81
+ replace: (files: Readonly<Record<string, string>>) => Ref.set(state, files)
82
+ }
83
+ })
84
+ )
85
+
86
+ export interface PlanStoreShape {
87
+ readonly save: (path: string, plan: Plan) => Effect.Effect<void, PersistenceError>
88
+ readonly load: (
89
+ path: string
90
+ ) => Effect.Effect<Plan | undefined, PersistenceError | PlanParseError>
91
+ readonly remove: (path: string) => Effect.Effect<void, PersistenceError>
92
+ readonly recoverOrCreate: <E, R>(
93
+ path: string,
94
+ create: Effect.Effect<Plan, E, R>
95
+ ) => Effect.Effect<Plan, E | PersistenceError | PlanParseError, R>
96
+ }
97
+
98
+ export const makePlanStore = (files: PlainFileStoreShape): PlanStoreShape => {
99
+ const save = (path: string, plan: Plan): Effect.Effect<void, PersistenceError> =>
100
+ files.writeAtomic(path, plan.render)
101
+
102
+ const load = (path: string): Effect.Effect<Plan | undefined, PersistenceError | PlanParseError> =>
103
+ Effect.flatMap(files.read(path), (contents) =>
104
+ contents === undefined
105
+ ? Effect.succeed(undefined)
106
+ : parsePlan(contents).pipe(Effect.map((plan) => plan))
107
+ )
108
+
109
+ const recoverOrCreate = <E, R>(
110
+ path: string,
111
+ create: Effect.Effect<Plan, E, R>
112
+ ): Effect.Effect<Plan, E | PersistenceError | PlanParseError, R> =>
113
+ Effect.flatMap(load(path), (stored) =>
114
+ stored === undefined ? Effect.tap(create, (plan) => save(path, plan)) : Effect.succeed(stored)
115
+ )
116
+
117
+ return {
118
+ save,
119
+ load,
120
+ remove: files.remove,
121
+ recoverOrCreate
122
+ }
123
+ }
124
+
125
+ interface VersionedEnvelope {
126
+ readonly schemaVersion: number
127
+ readonly value: unknown
128
+ }
129
+
130
+ const VersionedEnvelope = Schema.Struct({
131
+ schemaVersion: Schema.Int,
132
+ value: Schema.Unknown
133
+ })
134
+
135
+ export const saveVersioned = Effect.fn("@llm4ts/flow/Persistence.saveVersioned")(function* <
136
+ A,
137
+ E,
138
+ RD,
139
+ RE
140
+ >(
141
+ files: PlainFileStoreShape,
142
+ path: string,
143
+ schemaVersion: number,
144
+ schema: Schema.ConstraintCodec<A, E, RD, RE>,
145
+ value: A
146
+ ): Effect.fn.Return<void, FlowError, RE> {
147
+ const encoded = yield* Schema.encodeEffect(schema)(value).pipe(
148
+ Effect.mapError((error) =>
149
+ PlanParseError.make({
150
+ message: `failed to encode versioned document ${path}: ${String(error)}`
151
+ })
152
+ )
153
+ )
154
+ yield* files.writeAtomic(
155
+ path,
156
+ `${JSON.stringify({ schemaVersion, value: encoded }, undefined, 2)}\n`
157
+ )
158
+ })
159
+
160
+ export const loadVersioned = Effect.fn("@llm4ts/flow/Persistence.loadVersioned")(function* <
161
+ A,
162
+ E,
163
+ RD,
164
+ RE
165
+ >(
166
+ files: PlainFileStoreShape,
167
+ path: string,
168
+ expectedVersion: number,
169
+ schema: Schema.ConstraintCodec<A, E, RD, RE>
170
+ ): Effect.fn.Return<A | undefined, FlowError, RD> {
171
+ const contents = yield* files.read(path)
172
+ if (contents === undefined) {
173
+ return undefined
174
+ }
175
+ const envelope: VersionedEnvelope = yield* Schema.decodeUnknownEffect(
176
+ Schema.fromJsonString(VersionedEnvelope)
177
+ )(contents).pipe(
178
+ Effect.mapError((error) =>
179
+ PlanParseError.make({
180
+ message: `malformed versioned document ${path}: ${String(error)}`
181
+ })
182
+ )
183
+ )
184
+ if (envelope.schemaVersion !== expectedVersion) {
185
+ return yield* UnsupportedSchemaVersion.make({
186
+ path,
187
+ expected: expectedVersion,
188
+ actual: envelope.schemaVersion
189
+ })
190
+ }
191
+ return yield* Schema.decodeUnknownEffect(schema)(envelope.value).pipe(
192
+ Effect.mapError((error) =>
193
+ PlanParseError.make({
194
+ message: `invalid versioned document ${path}: ${String(error)}`
195
+ })
196
+ )
197
+ )
198
+ })
package/src/Plan.ts ADDED
@@ -0,0 +1,119 @@
1
+ import * as Effect from "effect/Effect"
2
+ import * as Schema from "effect/Schema"
3
+ import { PlanParseError } from "./FlowError.ts"
4
+
5
+ export class Task extends Schema.Class<Task>("Task")({
6
+ title: Schema.String,
7
+ description: Schema.String,
8
+ completed: Schema.Boolean.pipe(Schema.withConstructorDefault(Effect.succeed(false)))
9
+ }) {}
10
+
11
+ export class Plan extends Schema.Class<Plan>("Plan")({
12
+ epicId: Schema.String,
13
+ tasks: Schema.Array(Task),
14
+ brief: Schema.optionalKey(Schema.String)
15
+ }) {
16
+ get nextIncomplete(): Task | undefined {
17
+ return this.tasks.find((task) => !task.completed)
18
+ }
19
+
20
+ complete(title: string): Plan {
21
+ return new Plan({
22
+ ...this,
23
+ tasks: this.tasks.map((task) =>
24
+ task.title === title ? new Task({ ...task, completed: true }) : task
25
+ )
26
+ })
27
+ }
28
+
29
+ taskPrompt(task: Task): string {
30
+ const brief = this.brief?.trim()
31
+ return brief === undefined || brief.length === 0
32
+ ? task.description
33
+ : `${brief}\n\n---\n\n${task.description}`
34
+ }
35
+
36
+ get render(): string {
37
+ const blocks = this.tasks.map((task) => {
38
+ const box = task.completed ? "[x]" : "[ ]"
39
+ return `## ${box} ${task.title}\n${task.description}`.trimEnd()
40
+ })
41
+ const core = [`# Plan: ${this.epicId}`, ...blocks].join("\n\n")
42
+ const brief = this.brief?.trim()
43
+ return brief === undefined || brief.length === 0 ? core : `${core}\n\n# Brief\n\n${brief}`
44
+ }
45
+ }
46
+
47
+ export const stableHash = (input: string): string => {
48
+ let hash = 0x811c9dc5
49
+ for (let index = 0; index < input.length; index += 1) {
50
+ hash ^= input.charCodeAt(index)
51
+ hash = Math.imul(hash, 0x01000193)
52
+ }
53
+ return (hash >>> 0).toString(16)
54
+ }
55
+
56
+ export const defaultPlanPath = (prompt: string, directory = ".llm4ts"): string =>
57
+ `${directory}/plan-${stableHash(prompt)}.md`
58
+
59
+ const parseTask = (chunk: string): Effect.Effect<Task, PlanParseError> => {
60
+ const lines = chunk.split("\n")
61
+ const head = lines[0] ?? ""
62
+ const match = /^## \[([ xX])\] (.+)$/.exec(head)
63
+ const marker = match?.[1]
64
+ const title = match?.[2]?.trim()
65
+ return marker === undefined || title === undefined
66
+ ? Effect.fail(
67
+ PlanParseError.make({
68
+ message: head.length === 0 ? "empty task chunk" : `malformed task header: ${head}`
69
+ })
70
+ )
71
+ : Effect.succeed(
72
+ Task.make({
73
+ title,
74
+ description: lines.slice(1).join("\n").trim(),
75
+ completed: marker.toLowerCase() === "x"
76
+ })
77
+ )
78
+ }
79
+
80
+ const splitBrief = (body: string): readonly [tasks: string, brief: string | undefined] => {
81
+ const lines = body.split("\n")
82
+ const index = lines.findIndex((line) => line.trim() === "# Brief")
83
+ if (index < 0) {
84
+ return [body.trim(), undefined]
85
+ }
86
+ const brief = lines
87
+ .slice(index + 1)
88
+ .join("\n")
89
+ .trim()
90
+ return [lines.slice(0, index).join("\n").trim(), brief.length === 0 ? undefined : brief]
91
+ }
92
+
93
+ export const parsePlan = Effect.fn("@llm4ts/flow/Plan.parse")(function* (
94
+ markdown: string
95
+ ): Effect.fn.Return<Plan, PlanParseError> {
96
+ const stripped = markdown.trim()
97
+ const lines = stripped.split("\n")
98
+ const header = lines[0] ?? ""
99
+ const prefix = "# Plan: "
100
+ if (!header.startsWith(prefix)) {
101
+ return yield* PlanParseError.make({
102
+ message: `expected a '${prefix}<epicId>' header`
103
+ })
104
+ }
105
+ const [taskBody, brief] = splitBrief(lines.slice(1).join("\n").trim())
106
+ const chunks =
107
+ taskBody.length === 0
108
+ ? []
109
+ : taskBody
110
+ .split(/^(?=## )/m)
111
+ .map((chunk) => chunk.trim())
112
+ .filter((chunk) => chunk.length > 0)
113
+ const tasks = yield* Effect.forEach(chunks, parseTask)
114
+ return Plan.make({
115
+ epicId: header.slice(prefix.length).trim(),
116
+ tasks,
117
+ ...(brief === undefined ? {} : { brief })
118
+ })
119
+ })
@@ -0,0 +1,46 @@
1
+ import * as Effect from "effect/Effect"
2
+ import { StageCompleted, StageFailed, StageStarted, type FlowEventsShape } from "./FlowEvents.ts"
3
+ import type { FlowError } from "./FlowError.ts"
4
+ import type { Plan, Task } from "./Plan.ts"
5
+ import type { PlanStoreShape } from "./Persistence.ts"
6
+
7
+ const errorMessage = (error: unknown): string =>
8
+ error instanceof Error && error.message.length > 0 ? error.message : String(error)
9
+
10
+ export const stage = <A, E, R>(
11
+ events: FlowEventsShape,
12
+ name: string,
13
+ effect: Effect.Effect<A, E, R>
14
+ ): Effect.Effect<A, E, R> =>
15
+ events.publish(StageStarted.make({ stage: name })).pipe(
16
+ Effect.andThen(effect),
17
+ Effect.tap(() => events.publish(StageCompleted.make({ stage: name }))),
18
+ Effect.tapError((error) =>
19
+ events.publish(
20
+ StageFailed.make({
21
+ stage: name,
22
+ message: errorMessage(error)
23
+ })
24
+ )
25
+ )
26
+ )
27
+
28
+ export const implementTaskLoop = Effect.fn("@llm4ts/flow/PlanExecution.implementTaskLoop")(
29
+ function* <E, R>(
30
+ store: PlanStoreShape,
31
+ events: FlowEventsShape,
32
+ planPath: string,
33
+ plan: Plan,
34
+ perTask: (task: Task) => Effect.Effect<void, E, R>
35
+ ): Effect.fn.Return<Plan, E | FlowError, R> {
36
+ let current = plan
37
+ for (const task of plan.tasks) {
38
+ if (!task.completed) {
39
+ yield* stage(events, task.title, perTask(task))
40
+ current = current.complete(task.title)
41
+ yield* store.save(planPath, current)
42
+ }
43
+ }
44
+ return current
45
+ }
46
+ )