@pmelab/gtd 24.0.0 → 24.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 (196) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +31 -5
  3. package/claude/hooks/access.ts +1 -1
  4. package/dist/chunks/Schema-Dh-mkwkq.mjs +25353 -0
  5. package/dist/chunks/reserved-ejn-LVYO.mjs +1388 -0
  6. package/dist/chunks/rolldown-runtime-8H4AJuhK.mjs +14 -0
  7. package/dist/chunks/steering-D-PvRC5W.mjs +16296 -0
  8. package/dist/chunks/wire-DA7q2ikx.mjs +473 -0
  9. package/dist/flows.mjs +525 -0
  10. package/dist/gtd.bundle.mjs +43339 -55575
  11. package/dist/steering.mjs +2 -0
  12. package/dist/testkit.mjs +13006 -0
  13. package/dist/wire.mjs +2 -0
  14. package/dist/workflow-kit.mjs +352 -0
  15. package/dist/workflows/feature.mjs +315 -0
  16. package/dist/workflows/fix.mjs +40 -0
  17. package/dist/workflows/review.mjs +25 -0
  18. package/dist/workflows.mjs +8 -6
  19. package/package.json +69 -8
  20. package/src/cli/Cli.test.ts +1329 -0
  21. package/src/cli/Cli.ts +1348 -0
  22. package/src/cli/Program.test.ts +2262 -0
  23. package/src/cli/Program.ts +1331 -0
  24. package/src/cli/ToolCommands.ts +43 -0
  25. package/src/cli/index.ts +14 -0
  26. package/src/edge/Edge.test.ts +1016 -0
  27. package/src/edge/Edge.ts +1225 -0
  28. package/src/edge/index.ts +16 -0
  29. package/src/emit/Emit.test.ts +425 -0
  30. package/src/emit/Emit.ts +139 -0
  31. package/src/emit/GitScript.test.ts +260 -0
  32. package/src/emit/GitScript.ts +131 -0
  33. package/src/emit/ModeContradiction.test.ts +120 -0
  34. package/src/emit/ModeContradiction.ts +66 -0
  35. package/src/emit/OutcomeScript.test.ts +238 -0
  36. package/src/emit/OutcomeScript.ts +73 -0
  37. package/src/emit/SteeringMode.test.ts +274 -0
  38. package/src/emit/SteeringMode.ts +256 -0
  39. package/src/emit/index.ts +40 -0
  40. package/src/install/ConfigScaffold.ts +19 -0
  41. package/src/install/Install.test.ts +426 -0
  42. package/src/install/Install.ts +634 -0
  43. package/src/install/Version.ts +27 -0
  44. package/src/install/index.ts +3 -0
  45. package/src/platform/CommandRunner.ts +74 -0
  46. package/src/platform/Commentary.test.ts +126 -0
  47. package/src/platform/Commentary.ts +92 -0
  48. package/src/platform/Git.test.ts +50 -0
  49. package/src/platform/Git.ts +552 -0
  50. package/src/platform/Host.ts +42 -0
  51. package/src/platform/RetainedHistory.test.ts +65 -0
  52. package/src/platform/RetainedHistory.ts +34 -0
  53. package/src/platform/Workspace.test.ts +669 -0
  54. package/src/platform/Workspace.ts +631 -0
  55. package/src/platform/WorktreeState.test.ts +162 -0
  56. package/src/platform/WorktreeState.ts +50 -0
  57. package/src/platform/index.ts +22 -0
  58. package/src/replay/Access.test.ts +53 -0
  59. package/src/replay/Access.ts +46 -0
  60. package/src/replay/Format.test.ts +20 -0
  61. package/src/replay/Format.ts +14 -0
  62. package/src/replay/Replay.test.ts +887 -0
  63. package/src/replay/Replay.ts +669 -0
  64. package/src/replay/Trailers.test.ts +117 -0
  65. package/src/replay/Trailers.ts +213 -0
  66. package/src/replay/Tree.test.ts +25 -0
  67. package/src/replay/Tree.ts +45 -0
  68. package/src/replay/index.ts +11 -0
  69. package/src/steering/Blocks.fixture.ts +6 -0
  70. package/src/steering/Blocks.test.ts +167 -0
  71. package/src/steering/Blocks.ts +321 -0
  72. package/src/steering/CodeThreads.test.ts +127 -0
  73. package/src/steering/CodeThreads.ts +197 -0
  74. package/src/steering/Edits.test.ts +19 -0
  75. package/src/steering/Edits.ts +20 -0
  76. package/src/steering/Eol.test.ts +16 -0
  77. package/src/steering/Eol.ts +2 -0
  78. package/src/steering/FORMATS.md +58 -0
  79. package/src/steering/Footnotes.test.ts +977 -0
  80. package/src/steering/Footnotes.ts +661 -0
  81. package/src/steering/Inline.fixture.ts +7 -0
  82. package/src/steering/Inline.test.ts +227 -0
  83. package/src/steering/Inline.ts +195 -0
  84. package/src/steering/MarkdownTree.test.ts +247 -0
  85. package/src/steering/MarkdownTree.ts +260 -0
  86. package/src/steering/Outline.ts +19 -0
  87. package/src/steering/SteeringFormat.test.ts +91 -0
  88. package/src/steering/SteeringFormat.ts +267 -0
  89. package/src/steering/SteeringFormats.test.ts +163 -0
  90. package/src/steering/Threads.ts +163 -0
  91. package/src/steering/contract.test.ts +264 -0
  92. package/src/steering/fixtures/code-threads/cache.stripped.ts.txt +6 -0
  93. package/src/steering/fixtures/code-threads/cache.ts.md +15 -0
  94. package/src/steering/fixtures/free-form/notes.md +12 -0
  95. package/src/steering/fixtures/free-form/notes.read.json +42 -0
  96. package/src/steering/fixtures/qa/answered.md +23 -0
  97. package/src/steering/fixtures/qa/answered.read.json +119 -0
  98. package/src/steering/fixtures/qa/requirements.md +160 -0
  99. package/src/steering/fixtures/qa/requirements.read.json +475 -0
  100. package/src/steering/fixtures/review/review.md +76 -0
  101. package/src/steering/fixtures/review/review.read.json +237 -0
  102. package/src/steering/fixtures/review/ticked.md +23 -0
  103. package/src/steering/fixtures/review/ticked.read.json +99 -0
  104. package/src/steering/fixtures/review-notes/baseline.md +14 -0
  105. package/src/steering/fixtures/review-notes/noted.md +19 -0
  106. package/src/steering/freeform.test.ts +311 -0
  107. package/src/steering/freeform.ts +148 -0
  108. package/src/steering/index.test.ts +62 -0
  109. package/src/steering/index.ts +140 -0
  110. package/src/steering/qa.test.ts +2160 -0
  111. package/src/steering/qa.ts +1104 -0
  112. package/src/steering/review.test.ts +1829 -0
  113. package/src/steering/review.ts +1180 -0
  114. package/src/step/LandStep.ts +31 -0
  115. package/src/step/RepoSnapshot.ts +29 -0
  116. package/src/step/accessRefusal.test.ts +58 -0
  117. package/src/step/accessRefusal.ts +26 -0
  118. package/src/step/index.ts +5 -0
  119. package/src/step/planStart.test.ts +202 -0
  120. package/src/step/planStart.ts +167 -0
  121. package/src/step/planStep.test.ts +104 -0
  122. package/src/step/planStep.ts +89 -0
  123. package/src/step/snapshot.fixture.ts +28 -0
  124. package/src/testkit/EmittedScriptRecognizer.test.ts +639 -0
  125. package/src/testkit/EmittedScriptRecognizer.ts +642 -0
  126. package/src/testkit/FakeGitOperations.ts +95 -0
  127. package/src/testkit/GitTiers.test.ts +11 -0
  128. package/src/testkit/GitTiers.ts +1052 -0
  129. package/src/testkit/InMemRepo.ts +467 -0
  130. package/src/testkit/Layers.test.ts +58 -0
  131. package/src/testkit/Layers.ts +241 -0
  132. package/src/testkit/StrictGitOperations.ts +31 -0
  133. package/src/testkit/TestRepo.test.ts +119 -0
  134. package/src/testkit/TestRepo.ts +274 -0
  135. package/src/testkit/cliIo.ts +36 -0
  136. package/src/testkit/index.ts +9 -0
  137. package/src/testkit/steps.ts +177 -0
  138. package/src/testkit/testkit.feature +65 -0
  139. package/src/wire/BeatDocument.test.ts +419 -0
  140. package/src/wire/BeatDocument.ts +114 -0
  141. package/src/wire/BeatPlain.test.ts +113 -0
  142. package/src/wire/BeatPlain.ts +127 -0
  143. package/src/wire/BeatStatus.test.ts +59 -0
  144. package/src/wire/BeatStatus.ts +81 -0
  145. package/src/wire/Demand.test.ts +142 -0
  146. package/src/wire/Demand.ts +108 -0
  147. package/src/wire/ExitCodes.test.ts +21 -0
  148. package/src/wire/ExitCodes.ts +16 -0
  149. package/src/wire/Glob.ts +29 -0
  150. package/src/wire/Judge.ts +22 -0
  151. package/src/wire/Land.test.ts +120 -0
  152. package/src/wire/Land.ts +75 -0
  153. package/src/wire/Select.test.ts +148 -0
  154. package/src/wire/Select.ts +78 -0
  155. package/src/wire/Sessions.test.ts +59 -0
  156. package/src/wire/Sessions.ts +39 -0
  157. package/src/wire/WireSchema.test.ts +162 -0
  158. package/src/wire/constants.ts +22 -0
  159. package/src/wire/golden/1/beat-capture.json +34 -0
  160. package/src/wire/golden/1/beat-message.json +34 -0
  161. package/src/wire/golden/1/beat-prompt.json +44 -0
  162. package/src/wire/golden/1/beat-script.json +34 -0
  163. package/src/wire/golden/1/beat-stalled.json +34 -0
  164. package/src/wire/golden/1/judge.json +14 -0
  165. package/src/wire/golden/1/land-noop.json +10 -0
  166. package/src/wire/golden/1/land.json +10 -0
  167. package/src/wire/index.ts +12 -0
  168. package/src/wire/types.ts +46 -0
  169. package/src/workflow/Diagnostic.test.ts +93 -0
  170. package/src/workflow/Diagnostic.ts +54 -0
  171. package/src/workflow/SteeringFormats.test.ts +27 -0
  172. package/src/workflow/SteeringFormats.ts +6 -0
  173. package/src/workflow/Workflow.ts +77 -0
  174. package/src/workflow/compile.test.ts +53 -0
  175. package/src/workflow/compile.ts +56 -0
  176. package/src/workflow/discovery.ts +122 -0
  177. package/src/workflow/fingerprint.test.ts +52 -0
  178. package/src/workflow/fingerprint.ts +53 -0
  179. package/src/workflow/index.ts +33 -0
  180. package/src/workflow/load.test.ts +1017 -0
  181. package/src/workflow/load.ts +836 -0
  182. package/src/workflow/skills.test.ts +109 -0
  183. package/src/workflow/skills.ts +90 -0
  184. package/src/workflow/vars.ts +33 -0
  185. package/src/workflows/feature/feature.test.ts +27 -33
  186. package/src/workflows/feature/index.ts +10 -0
  187. package/src/workflows/fix/fix.feature +80 -0
  188. package/src/workflows/fix/fix.test.ts +58 -78
  189. package/src/workflows/fix/index.ts +6 -0
  190. package/src/workflows/index.ts +2 -10
  191. package/src/workflows/review/index.ts +6 -0
  192. package/src/workflows/review/review.test.ts +85 -29
  193. package/src/workflows/review/review.ts +3 -1
  194. package/src/workflows/shared/config.fixture.ts +12 -0
  195. package/src/workflows/shared/reserved.ts +11 -0
  196. package/src/workflows/shared/workflows.steps.ts +7 -0
@@ -0,0 +1,836 @@
1
+ import { existsSync, readFileSync } from "node:fs"
2
+ import { createRequire } from "node:module"
3
+ import { dirname, join } from "node:path"
4
+ import { fileURLToPath, pathToFileURL } from "node:url"
5
+ import { Context, Effect, Layer } from "effect"
6
+ import { GtdError, Narrator } from "../platform/index.js"
7
+ import { replay, type TreeView } from "../replay/index.js"
8
+ import * as flows from "../flows/index.js"
9
+ import type { StateMode, WorkflowDefinition } from "./Workflow.js"
10
+ import type { Door, Flow } from "../flows/index.js"
11
+ import { Host, Workspace, type WorkspaceOps } from "../platform/index.js"
12
+ import { compileConfig, type CompiledConfig } from "./compile.js"
13
+ import { SETTING_NAME_RULE, isSettingName } from "./vars.js"
14
+ import { ConfigDiscovery, WORKFLOW_MODULE, type WorkflowModule } from "./discovery.js"
15
+ import {
16
+ dedupeDiagnostics,
17
+ formatDiagnostic,
18
+ sortDiagnostics,
19
+ type Diagnostic,
20
+ } from "./Diagnostic.js"
21
+
22
+ /** A startable workflow, resolved with its own file's setting defaults. */
23
+ export interface ResolvedWorkflow {
24
+ readonly name: string
25
+ readonly def: WorkflowDefinition
26
+ readonly vars: Record<string, string>
27
+ readonly env: Record<string, string>
28
+ }
29
+
30
+ export interface ConfigOperations {
31
+ /** The default workflow: what an ordinary start runs. */
32
+ readonly workflow: WorkflowDefinition
33
+ /** The default, resolved like a named one; its name is what status shows: the named export it also is, else `"default"`. */
34
+ readonly defaultWorkflow: ResolvedWorkflow
35
+ /** Every name `gtd --workflow` accepts, sorted — never `"default"`. */
36
+ readonly workflowNames: readonly string[]
37
+ readonly workflowNamed: (name: string) => ResolvedWorkflow | undefined
38
+ /** Every door `gtd door` accepts. A door's `workflow` resolves through `workflowNamed`. */
39
+ readonly doors: ReadonlyMap<string, Door>
40
+ /** `config.ui` (absent when unconfigured) — `gtd ui` and its CLI flags read it. */
41
+ readonly ui?: flows.UiSettings
42
+ /** Non-fatal findings, formatted through `formatDiagnostic` like an error. */
43
+ readonly warnings: readonly Diagnostic[]
44
+ /** Every real file `gtd.config.ts`'s evaluation touched (itself plus any relative import). A caller that must know when `workflow` might have changed (the LSP's steering-map memo) watches all of them; watching `gtd.config.ts`'s own path alone misses an edit to a module it imports. */
45
+ readonly workflowFiles: readonly string[]
46
+ }
47
+
48
+ // Everything lives in `message` (not `GtdError.detail`) — `renderFailure`
49
+ // prints `message` verbatim and would print a duplicated `detail` twice.
50
+ const configError = (diagnostics: readonly Diagnostic[]): GtdError =>
51
+ new GtdError(
52
+ `gtd config:\n${diagnostics
53
+ .filter((d) => d.severity === "error")
54
+ .map((d) => ` - ${formatDiagnostic(d)}`)
55
+ .join("\n")}`,
56
+ )
57
+
58
+ export const failOnErrors = (diagnostics: readonly Diagnostic[]): Effect.Effect<void, GtdError> =>
59
+ diagnostics.some((d) => d.severity === "error")
60
+ ? Effect.fail(configError(diagnostics))
61
+ : Effect.void
62
+
63
+ const MIGRATION_DOC =
64
+ "https://github.com/pmelab/gtd/blob/main/docs/configuration.md#migrating-from-gtdrc"
65
+
66
+ /**
67
+ * `.gtdrc` is gone, but a leftover one — a repository's or the user's own —
68
+ * must fail loudly rather than be silently ignored. One the user cannot delete
69
+ * (another account's, in a shared ancestor) only warns: failing would lock them
70
+ * out of gtd with no way to fix it.
71
+ */
72
+ export const refuseLegacyRc: Effect.Effect<void, GtdError, Host | ConfigDiscovery | Narrator> =
73
+ Effect.gen(function* () {
74
+ const host = yield* Host
75
+ const files = yield* Effect.orDie((yield* ConfigDiscovery).legacyRcFiles(host.root, host.home))
76
+ for (const file of files.filter((f) => !f.owned)) {
77
+ yield* (yield* Narrator).warn(
78
+ `gtd: warning: ${file.path} is a removed .gtdrc file owned by another user — ignored`,
79
+ )
80
+ }
81
+ const found = files.filter((f) => f.owned).map((f) => f.path)
82
+ if (found.length === 0) return
83
+ return yield* Effect.fail(
84
+ new GtdError(
85
+ [
86
+ "gtd config:",
87
+ ...found.map((file) => ` - ${file}: no longer read — delete it`),
88
+ " gtd 23 removed the .gtdrc files; move each key they set:",
89
+ " vars → an option of the workflow factory in gtd.config.ts, or its `defaults` export",
90
+ " env → an option of the workflow factory in gtd.config.ts, or its `envDefaults` export",
91
+ " judge → ~/.config/gtd/judge.json, or GTD_JUDGE_PROVIDER / GTD_JUDGE_MODEL",
92
+ " modes, ui, skills, access → the `config` export of gtd.config.ts (`defineConfig`)",
93
+ " $schema → nothing; drop it",
94
+ ` see ${MIGRATION_DOC}`,
95
+ ].join("\n"),
96
+ ),
97
+ )
98
+ })
99
+
100
+ /**
101
+ * Find and evaluate `gtd.config.ts`, then check what it exports. Every finding
102
+ * is collected into ONE sorted report rather than failing on the first.
103
+ */
104
+ export const load: Effect.Effect<
105
+ ConfigOperations,
106
+ Error,
107
+ Narrator | Workspace | Host | ConfigDiscovery
108
+ > = Effect.gen(function* () {
109
+ const host = yield* Host
110
+ const discovery = yield* ConfigDiscovery
111
+ yield* refuseLegacyRc
112
+ const module = yield* discovery.workflowModule(host.root, host.home)
113
+ if (module === undefined) {
114
+ const missing: Diagnostic = {
115
+ severity: "error",
116
+ path: [],
117
+ message: `no workflows configured: no ${WORKFLOW_MODULE} here or above — run \`gtd install\` to scaffold one`,
118
+ origin: host.root,
119
+ }
120
+ return yield* Effect.fail(configError([missing]))
121
+ }
122
+ yield* (yield* Narrator).narrate(`config: module ${module.filepath}`)
123
+ const shipped = yield* Effect.tryPromise({
124
+ try: shippedModules,
125
+ catch: (e) => new GtdError(`gtd: ${e instanceof Error ? e.message : String(e)}`),
126
+ })
127
+ const loaded = yield* Effect.try({
128
+ try: () => loadWorkflows(module, shipped),
129
+ catch: (e) =>
130
+ new GtdError(
131
+ `gtd config:\n - ${module.filepath}: ${e instanceof Error ? e.message : String(e)}`,
132
+ ),
133
+ })
134
+ const compiled = loaded.config
135
+ const diagnostics = dedupeDiagnostics(
136
+ sortDiagnostics([...compiled.diagnostics, ...loaded.diagnostics]),
137
+ )
138
+ yield* failOnErrors(diagnostics)
139
+ if (loaded.default === undefined) {
140
+ return yield* Effect.die("unreachable: a default fails to build only with an error reported")
141
+ }
142
+
143
+ const initial = yield* firstStep(loaded.default, headTree(yield* Workspace))
144
+ const steering = loaded.file.steering as Record<string, StateMode>
145
+ const define = (entry: LoadedWorkflow): WorkflowDefinition => ({
146
+ flow: entry.flow,
147
+ summary: entry.file.summary,
148
+ // A config's own `base` export wins when it answers; else the factory's.
149
+ base: (w, v) => entry.file.base?.(w, v) ?? entry.base?.(w, v),
150
+ steering,
151
+ modes: compiled.modes,
152
+ skills: entry.file.skills,
153
+ configuredSkills: compiled.skills,
154
+ access: entry.file.access,
155
+ configuredAccess: compiled.access,
156
+ skillsOrigin: entry.file.origin,
157
+ initial,
158
+ })
159
+ const names = [...loaded.catalogue.keys()].sort()
160
+ const resolve = (name: string, entry: LoadedWorkflow): ResolvedWorkflow => ({
161
+ name,
162
+ def: define(entry),
163
+ vars: { ...entry.defaults },
164
+ env: { ...entry.envDefaults },
165
+ })
166
+ const defaultWorkflow = resolve(loaded.default.name, loaded.default)
167
+ return {
168
+ workflow: defaultWorkflow.def,
169
+ defaultWorkflow,
170
+ workflowNames: names,
171
+ doors: loaded.doors,
172
+ workflowNamed: (name) => {
173
+ const entry = loaded.catalogue.get(name)
174
+ return entry === undefined ? undefined : resolve(name, entry)
175
+ },
176
+ ...(compiled.ui !== undefined ? { ui: compiled.ui } : {}),
177
+ warnings: diagnostics.filter((d) => d.severity === "warning"),
178
+ workflowFiles: loaded.file.dependencyFiles,
179
+ }
180
+ })
181
+
182
+ interface Settings {
183
+ readonly defaults: Readonly<Record<string, string>>
184
+ readonly envDefaults: Readonly<Record<string, string>>
185
+ }
186
+
187
+ interface LoadedFile
188
+ extends
189
+ Pick<WorkflowDefinition, "summary" | "base" | "steering" | "skills" | "access">,
190
+ Settings {
191
+ readonly origin: string
192
+ /**
193
+ * Every real file this module's evaluation touched — `origin` itself plus
194
+ * any relative import it (transitively) resolved on disk, in NO particular
195
+ * order. A caller that
196
+ * needs to know when a workflow's DEFINITION might have changed (the LSP's
197
+ * steering-map memo) must watch this whole set, not just `origin`: a
198
+ * `gtd.config.ts` re-exporting a sibling module is a documented pattern,
199
+ * and jiti resolves that import off disk same as any other require.
200
+ */
201
+ readonly dependencyFiles: readonly string[]
202
+ }
203
+
204
+ /** A startable workflow; its settings are its file's, under whatever its factory declared. */
205
+ interface LoadedWorkflow extends Settings {
206
+ readonly flow: Flow
207
+ readonly file: LoadedFile
208
+ readonly doors?: flows.WorkflowDoors | undefined
209
+ readonly base?: flows.WorkflowBase | undefined
210
+ }
211
+
212
+ interface Loaded {
213
+ readonly file: LoadedFile
214
+ /** Every startable workflow. */
215
+ readonly catalogue: ReadonlyMap<string, LoadedWorkflow>
216
+ readonly doors: ReadonlyMap<string, Door>
217
+ /** What an ordinary start runs; absent only alongside an error in `diagnostics`. */
218
+ readonly default: (LoadedWorkflow & { readonly name: string }) | undefined
219
+ readonly config: CompiledConfig
220
+ readonly diagnostics: readonly Diagnostic[]
221
+ }
222
+
223
+ type JitiInstance = {
224
+ evalModule: (
225
+ source: string,
226
+ options: { filename: string; async?: false; cache?: Record<string, unknown> },
227
+ ) => unknown
228
+ }
229
+ type JitiModule = {
230
+ createJiti: (id: string, options: Record<string, unknown>) => JitiInstance
231
+ }
232
+
233
+ type Transform = (options: { readonly source: string }) => { code: string; error?: unknown }
234
+
235
+ // Transpiling is most of a load's cost, and a load re-transpiles
236
+ // gtd.config.ts and every shipped module it imports. The same options (the
237
+ // source included) always transpile to the same code, so an in-process memo
238
+ // is safe; evaluation stays fresh on every load.
239
+ const TRANSFORM_MEMO_SIZE = 512
240
+ const memoized = (transform: Transform): Transform => {
241
+ const memo = new Map<string, ReturnType<Transform>>()
242
+ return (options) => {
243
+ const key = JSON.stringify(options)
244
+ const hit = memo.get(key)
245
+ if (hit !== undefined) return hit
246
+ const result = transform(options)
247
+ if (result.error !== undefined) return result
248
+ if (memo.size >= TRANSFORM_MEMO_SIZE) memo.delete(memo.keys().next().value!)
249
+ memo.set(key, result)
250
+ return result
251
+ }
252
+ }
253
+
254
+ let jitiModule: JitiModule | undefined
255
+ let transform: Transform | undefined
256
+ const jiti = (shipped: ShippedModules): JitiInstance => {
257
+ const require = createRequire(import.meta.url)
258
+ jitiModule ??= require("jiti") as JitiModule
259
+ // jiti's own Babel transform, which its package exports don't name.
260
+ transform ??= memoized(
261
+ require(join(dirname(require.resolve("jiti/package.json")), "dist", "babel.cjs")) as Transform,
262
+ )
263
+ return jitiModule.createJiti(import.meta.url, {
264
+ virtualModules: shipped.modules,
265
+ alias: shipped.missing,
266
+ // No transpile cache on disk, and a fresh module every load — nothing a
267
+ // later command could read back instead of the source.
268
+ fsCache: false,
269
+ moduleCache: false,
270
+ transform,
271
+ // The whole module, not just its default: the named exports are read too.
272
+ interopDefault: false,
273
+ })
274
+ }
275
+
276
+ const PACKAGE = "@pmelab/gtd"
277
+
278
+ interface PackageJson {
279
+ readonly name?: string
280
+ readonly exports: Readonly<Record<string, string | Readonly<Record<string, string>>>>
281
+ }
282
+
283
+ // Only gtd's own package.json: whatever other one sits nearest (a vendored
284
+ // bundle inside a project) must never get its exports executed.
285
+ const ownPackage = (): { readonly root: string; readonly pkg: PackageJson } => {
286
+ const start = dirname(fileURLToPath(import.meta.url))
287
+ for (let dir = start; ; dir = dirname(dir)) {
288
+ const file = join(dir, "package.json")
289
+ if (existsSync(file)) {
290
+ const pkg = JSON.parse(readFileSync(file, "utf8")) as PackageJson
291
+ if (pkg.name === PACKAGE) return { root: dir, pkg }
292
+ }
293
+ if (dirname(dir) === dir) throw new Error(`no ${PACKAGE} package.json above ${start}`)
294
+ }
295
+ }
296
+
297
+ interface ShippedModules {
298
+ readonly modules: Readonly<Record<string, unknown>>
299
+ /** Subpaths whose file failed to load, aliased to that file so only a config importing one fails. */
300
+ readonly missing: Readonly<Record<string, string>>
301
+ }
302
+
303
+ // Every subpath gtd's package exports resolves to the running gtd's own copy,
304
+ // so a config needs no install and never mixes versions. `flows` is the module
305
+ // already loaded here: a config runs against the very runtime replay installs
306
+ // its context for. Run from source (tests), a subpath's `source` is served
307
+ // instead of its build. Shipped code never changes in a process, so it is
308
+ // imported once, natively: where Node can `require` ESM, the first time a
309
+ // config — or a workflow as it runs — requires it, so a command loads only the
310
+ // subpaths its config uses; elsewhere, and from source, all of them up front.
311
+ let shippedMemo: Promise<ShippedModules> | undefined
312
+ const shippedModules = (): Promise<ShippedModules> =>
313
+ (shippedMemo ??= (async () => {
314
+ const fromSource = import.meta.url.endsWith(".ts")
315
+ const files = subpathFiles(fromSource ? "source" : "default")
316
+ const modules: Record<string, unknown> = { [`${PACKAGE}/flows`]: flows }
317
+ const missing: Record<string, string> = {}
318
+ if (process.features.require_module && !fromSource) {
319
+ return { modules: requiredOnUse(modules, files), missing }
320
+ }
321
+ for (const [id, path] of files) {
322
+ try {
323
+ modules[id] = await import(pathToFileURL(path).href)
324
+ } catch {
325
+ missing[id] = path
326
+ }
327
+ }
328
+ return { modules, missing }
329
+ })())
330
+
331
+ /** The file each subpath a config may import serves: all but `flows`, the engine's own, and `testkit`. */
332
+ const subpathFiles = (condition: string): ReadonlyMap<string, string> => {
333
+ const { root, pkg } = ownPackage()
334
+ const files = new Map<string, string>()
335
+ for (const [subpath, target] of Object.entries(pkg.exports)) {
336
+ const file = typeof target === "string" ? target : target[condition]
337
+ // testkit imports the consumer's test runner, and no config runs one.
338
+ if (subpath === "./flows" || subpath === "./testkit" || file === undefined) continue
339
+ files.set(`${PACKAGE}/${subpath.replace(/^\.\//, "")}`, join(root, file))
340
+ }
341
+ return files
342
+ }
343
+
344
+ const requiredOnUse = (
345
+ modules: Record<string, unknown>,
346
+ files: ReadonlyMap<string, string>,
347
+ ): Record<string, unknown> => {
348
+ const require = createRequire(import.meta.url)
349
+ return new Proxy(modules, {
350
+ has: (loaded, id) => id in loaded || (typeof id === "string" && files.has(id)),
351
+ get: (loaded, id) =>
352
+ typeof id !== "string" || id in loaded
353
+ ? loaded[id as string]
354
+ : (loaded[id] = require(files.get(id)!)),
355
+ })
356
+ }
357
+
358
+ const stringRecord = (
359
+ exports: Record<string, unknown>,
360
+ name: string,
361
+ ): Readonly<Record<string, string>> => {
362
+ const value = exports[name] ?? {}
363
+ if (
364
+ typeof value !== "object" ||
365
+ value === null ||
366
+ Object.values(value).some((entry) => typeof entry !== "string")
367
+ ) {
368
+ throw new Error(`the "${name}" export is not a record of strings`)
369
+ }
370
+ return value as Readonly<Record<string, string>>
371
+ }
372
+
373
+ const isStringArray = (v: unknown): v is readonly string[] =>
374
+ Array.isArray(v) && v.every((entry) => typeof entry === "string")
375
+
376
+ const checkSkillsRecord = (value: unknown): Readonly<Record<string, readonly string[]>> => {
377
+ if (
378
+ typeof value !== "object" ||
379
+ value === null ||
380
+ Object.values(value).some((entry) => !isStringArray(entry))
381
+ ) {
382
+ throw new Error(`the "skills" export is not a record of skill-name arrays`)
383
+ }
384
+ return value as Readonly<Record<string, readonly string[]>>
385
+ }
386
+
387
+ /** Read a module's optional `skills` export — a scope full name -> skill list record, or a function of the vars returning one. */
388
+ const skillsExport = (exports: Record<string, unknown>): WorkflowDefinition["skills"] => {
389
+ const value = exports["skills"] ?? {}
390
+ if (typeof value === "function") {
391
+ return (vars) => checkSkillsRecord((value as (v: typeof vars) => unknown)(vars))
392
+ }
393
+ const record = checkSkillsRecord(value)
394
+ return () => record
395
+ }
396
+
397
+ /** Read a module's optional `access` export — a scope full name -> `{ read?, write? }` record, or a function of the vars returning one. */
398
+ const accessExport = (exports: Record<string, unknown>): WorkflowDefinition["access"] => {
399
+ const value = exports["access"] ?? {}
400
+ if (typeof value === "function") {
401
+ return (vars) => (value as (v: typeof vars) => never)(vars)
402
+ }
403
+ return () => value as never
404
+ }
405
+
406
+ const optionalFunction = <T>(exports: Record<string, unknown>, name: string): T | undefined => {
407
+ const value = exports[name]
408
+ if (value === undefined) return undefined
409
+ if (typeof value !== "function") throw new Error(`the "${name}" export is not a function`)
410
+ return value as T
411
+ }
412
+
413
+ const RESERVED_EXPORTS: ReadonlySet<string> = new Set([
414
+ "default",
415
+ "summary",
416
+ "base",
417
+ "defaults",
418
+ "envDefaults",
419
+ "steering",
420
+ "skills",
421
+ "access",
422
+ "doors",
423
+ "config",
424
+ ])
425
+
426
+ /** The rules every declared setting meets, whether a file's export or a factory's build. */
427
+ const checkSettings = (
428
+ defaults: Readonly<Record<string, unknown>>,
429
+ envDefaults: Readonly<Record<string, unknown>>,
430
+ declares: (kind: "defaults" | "envDefaults") => string,
431
+ ): void => {
432
+ for (const [kind, record] of [
433
+ ["defaults", defaults],
434
+ ["envDefaults", envDefaults],
435
+ ] as const) {
436
+ for (const [name, setting] of Object.entries(record)) {
437
+ if (!isSettingName(name)) {
438
+ throw new Error(
439
+ `${declares(kind)} ${JSON.stringify(name)}, not a valid setting name — ${SETTING_NAME_RULE}`,
440
+ )
441
+ }
442
+ if (typeof setting !== "string") {
443
+ throw new Error(`${declares(kind)} ${JSON.stringify(name)}, whose value is not a string`)
444
+ }
445
+ }
446
+ }
447
+ const both = Object.keys(defaults).filter((name) => Object.hasOwn(envDefaults, name))
448
+ if (both.length > 0) {
449
+ throw new Error(
450
+ `${both.map((name) => `"${name}"`).join(", ")} declared in both "defaults" and "envDefaults" — a setting is a process setting or an environment setting, not both`,
451
+ )
452
+ }
453
+ }
454
+
455
+ /** A module's per-file exports: `defaults`, `envDefaults`, `steering`, `skills`, `access`, `summary`, `base`. */
456
+ const fileFrom = (
457
+ exports: Record<string, unknown>,
458
+ origin: string,
459
+ dependencyFiles: readonly string[],
460
+ ): LoadedFile => {
461
+ const defaults = stringRecord(exports, "defaults")
462
+ const envDefaults = stringRecord(exports, "envDefaults")
463
+ checkSettings(defaults, envDefaults, (kind) => `the "${kind}" export declares`)
464
+ return {
465
+ defaults,
466
+ envDefaults,
467
+ steering: stringRecord(exports, "steering"),
468
+ skills: skillsExport(exports),
469
+ access: accessExport(exports),
470
+ summary: optionalFunction(exports, "summary"),
471
+ base: optionalFunction(exports, "base"),
472
+ origin,
473
+ dependencyFiles,
474
+ }
475
+ }
476
+
477
+ const DOOR_NAME_RULE = /^[a-z][a-z0-9-]*$/
478
+
479
+ /** Why one door's `args` are malformed, or `undefined` when they are well-formed. */
480
+ const argsProblem = (args: unknown): string | undefined => {
481
+ if (args === undefined) return undefined
482
+ if (!Array.isArray(args)) return `has "args" that is not an array`
483
+ const seen = new Set<string>()
484
+ let optional = false
485
+ for (const arg of args as readonly { name?: unknown; optional?: unknown }[]) {
486
+ if (typeof arg?.name !== "string") return `has an arg without a "name"`
487
+ if (seen.has(arg.name)) return `declares duplicate arg "${arg.name}"`
488
+ seen.add(arg.name)
489
+ if (arg.optional === true) optional = true
490
+ else if (optional) return `declares required arg "${arg.name}" after an optional one`
491
+ }
492
+ return undefined
493
+ }
494
+
495
+ /** Why one door is malformed, or `undefined` when it is well-formed. */
496
+ const doorProblem = (name: string, door: unknown): string | undefined => {
497
+ if (!DOOR_NAME_RULE.test(name)) {
498
+ return `is not a valid door name — it must match ${DOOR_NAME_RULE}`
499
+ }
500
+ const { workflow, args, vars } = (door ?? {}) as Partial<Door>
501
+ if (typeof workflow !== "string") return `must be an object with a "workflow" name`
502
+ const varsOk = vars === undefined || typeof vars === "function"
503
+ return argsProblem(args) ?? (varsOk ? undefined : `has "vars" that is not a function`)
504
+ }
505
+
506
+ /** A module's `doors` export, shape-checked; the workflow names are checked later, against the catalogue. */
507
+ const doorsFrom = (exports: Record<string, unknown>): ReadonlyMap<string, Door> => {
508
+ const value = exports["doors"]
509
+ if (value === undefined) return new Map()
510
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
511
+ throw new Error(`the "doors" export is not a record of doors`)
512
+ }
513
+ for (const [name, door] of Object.entries(value)) {
514
+ const problem = doorProblem(name, door)
515
+ if (problem !== undefined) throw new Error(`door "${name}" ${problem}`)
516
+ }
517
+ return new Map(Object.entries(value) as [string, Door][])
518
+ }
519
+
520
+ /**
521
+ * Each exported workflow's own doors, onto the name it is exported under. The
522
+ * `doors` export adds to them and settles a name two workflows both declare.
523
+ */
524
+ const derivedDoors = (
525
+ catalogue: ReadonlyMap<string, LoadedWorkflow>,
526
+ declared: ReadonlyMap<string, Door>,
527
+ ): ReadonlyMap<string, Door> => {
528
+ const doors = new Map<string, Door>()
529
+ for (const [workflow, loaded] of catalogue) {
530
+ for (const [name, spec] of Object.entries(loaded.doors ?? {})) {
531
+ const door: Door = { ...spec, workflow }
532
+ const problem = doorProblem(name, door)
533
+ if (problem !== undefined)
534
+ throw new Error(`workflow "${workflow}" builds door "${name}", which ${problem}`)
535
+ const other = doors.get(name)
536
+ if (other !== undefined && !declared.has(name)) {
537
+ throw new Error(
538
+ `door "${name}" is built by workflows "${other.workflow}" and "${workflow}" — declare it in the "doors" export to choose one (a declared door replaces the derived one, so restate its args and vars)`,
539
+ )
540
+ }
541
+ doors.set(name, door)
542
+ }
543
+ }
544
+ return new Map([...doors, ...declared])
545
+ }
546
+
547
+ /** Sort the doors and check each names a startable workflow. */
548
+ const checkDoors = (
549
+ declared: ReadonlyMap<string, Door>,
550
+ catalogue: ReadonlyMap<string, LoadedWorkflow>,
551
+ ): ReadonlyMap<string, Door> => {
552
+ const doors = new Map([...declared].sort(([a], [b]) => a.localeCompare(b)))
553
+ for (const [name, door] of doors) {
554
+ if (!catalogue.has(door.workflow)) {
555
+ throw new Error(
556
+ `door "${name}" names workflow "${door.workflow}", which is not startable — known: ${[...catalogue.keys()].sort().join(", ")}`,
557
+ )
558
+ }
559
+ }
560
+ return doors
561
+ }
562
+
563
+ const namespaceOf = (exported: unknown): Record<string, unknown> =>
564
+ (typeof exported === "object" && exported !== null ? exported : {}) as Record<string, unknown>
565
+
566
+ /**
567
+ * Evaluate `gtd.config.ts`. Every exported function or factory workflow but
568
+ * the reserved names is a workflow named by its export. `cache` is jiti's OWN
569
+ * per-call module cache (unrelated to the instance's `moduleCache: false`) —
570
+ * passing an empty object in has the side effect of collecting every real file
571
+ * jiti resolved evaluating this module, module AND every relative import it
572
+ * pulled in, transitively.
573
+ */
574
+ const loadWorkflows = (module: WorkflowModule, shipped: ShippedModules): Loaded => {
575
+ const cache: Record<string, unknown> = {}
576
+ const exports = namespaceOf(
577
+ jiti(shipped).evalModule(module.source, { filename: module.filepath, cache }),
578
+ )
579
+ const file = fileFrom(exports, module.filepath, Object.keys(cache))
580
+ const exported = Object.entries(exports).filter(
581
+ ([name, value]) => !RESERVED_EXPORTS.has(name) && isWorkflow(value),
582
+ )
583
+ const value = defaultExport(exports)
584
+ // A default that is also a named export goes by that name.
585
+ const defaultName = exported.find(([, other]) => other === value)?.[0] ?? "default"
586
+
587
+ // Built once per exported value, so a default that is also a named export
588
+ // stays the same workflow.
589
+ const built = new Map<unknown, LoadedWorkflow | undefined>()
590
+ const rejections: Rejection[] = []
591
+ for (const [name, entry] of [...exported, [defaultName, value] as const]) {
592
+ if (!built.has(entry)) built.set(entry, buildWorkflow(name, entry, file, rejections))
593
+ }
594
+ const catalogue = new Map(
595
+ exported.flatMap(([name, entry]) => {
596
+ const workflow = built.get(entry)
597
+ return workflow === undefined ? [] : [[name, workflow] as const]
598
+ }),
599
+ )
600
+ const defaultWorkflow = built.get(value)
601
+ return {
602
+ file,
603
+ catalogue,
604
+ doors:
605
+ rejections.length > 0
606
+ ? new Map()
607
+ : checkDoors(derivedDoors(catalogue, doorsFrom(exports)), catalogue),
608
+ default: defaultWorkflow && { ...defaultWorkflow, name: defaultName },
609
+ config: compileConfig(exports["config"], module.filepath),
610
+ diagnostics: nameRejections(rejections),
611
+ }
612
+ }
613
+
614
+ const isWorkflow = (value: unknown): boolean =>
615
+ typeof value === "function" || flows.isFactoryWorkflow(value)
616
+
617
+ const defaultExport = (exports: Record<string, unknown>): unknown => {
618
+ const value = exports.default
619
+ if (value === undefined) {
620
+ throw new Error(
621
+ "no default export — export the workflow an ordinary start runs (`gtd install` scaffolds one)",
622
+ )
623
+ }
624
+ if (!isWorkflow(value)) throw new Error("the default export is not a function")
625
+ return value
626
+ }
627
+
628
+ /** One exported workflow: a plain flow as-is, a factory validated and built. */
629
+ const buildWorkflow = (
630
+ name: string,
631
+ value: unknown,
632
+ file: LoadedFile,
633
+ rejections: Rejection[],
634
+ ): LoadedWorkflow | undefined => {
635
+ const result = flows.configure(value)
636
+ if (result === undefined)
637
+ return { flow: value as Flow, file, defaults: file.defaults, envDefaults: file.envDefaults }
638
+ if (!result.ok) {
639
+ for (const issue of result.issues) rejections.push(optionDiagnostic(name, issue, file))
640
+ return undefined
641
+ }
642
+ return {
643
+ flow: result.build.flow,
644
+ file,
645
+ ...builtSettings(name, file, result.build),
646
+ doors: result.build.doors,
647
+ base: result.build.base,
648
+ }
649
+ }
650
+
651
+ /** A factory's settings over its file's, held to the rules a file's own exports are. */
652
+ const builtSettings = (name: string, file: LoadedFile, build: flows.WorkflowBuild): Settings => {
653
+ checkSettings(
654
+ build.defaults ?? {},
655
+ build.envDefaults ?? {},
656
+ (kind) => `workflow "${name}" builds ${kind}`,
657
+ )
658
+ const defaults = { ...file.defaults, ...build.defaults }
659
+ const envDefaults = { ...file.envDefaults, ...build.envDefaults }
660
+ checkSettings(defaults, envDefaults, (kind) => `workflow "${name}" builds ${kind}`)
661
+ return { defaults, envDefaults }
662
+ }
663
+
664
+ /** A rejected factory option, reported at `gtd.config.ts`. */
665
+ const optionDiagnostic = (
666
+ workflow: string,
667
+ issue: flows.OptionIssue,
668
+ file: LoadedFile,
669
+ ): Rejection => ({
670
+ workflow,
671
+ severity: "error",
672
+ path: issue.path,
673
+ message: `${issue.path.length > 0 ? `option "${issue.path.join(".")}"` : "options"}: ${issue.message}`,
674
+ origin: file.origin,
675
+ })
676
+
677
+ /** One rejected option, before the workflows rejecting it alike are named together. */
678
+ interface Rejection extends Diagnostic {
679
+ readonly workflow: string
680
+ }
681
+
682
+ const nameRejections = (rejections: readonly Rejection[]): Diagnostic[] => {
683
+ const grouped = new Map<string, { readonly first: Rejection; readonly workflows: string[] }>()
684
+ for (const rejection of rejections) {
685
+ const key = JSON.stringify([rejection.origin, rejection.path, rejection.message])
686
+ const group = grouped.get(key)
687
+ if (group === undefined) grouped.set(key, { first: rejection, workflows: [rejection.workflow] })
688
+ else group.workflows.push(rejection.workflow)
689
+ }
690
+ return [...grouped.values()].map(({ first: { workflow: _, ...diagnostic }, workflows }) => ({
691
+ ...diagnostic,
692
+ message: `${workflows.length > 1 ? "workflows" : "workflow"} ${workflows.map((w) => `"${w}"`).join(", ")} ${diagnostic.message}`,
693
+ }))
694
+ }
695
+
696
+ // The repository's HEAD, read lazily.
697
+ const headTree = (workspace: WorkspaceOps): TreeView => {
698
+ let entries: ReadonlyMap<string, string> | undefined
699
+ const list = () => (entries ??= workspace.treeSync("HEAD"))
700
+ return {
701
+ paths: () => [...list().keys()].sort(),
702
+ read: (path) => (list().has(path) ? workspace.readCommittedSync(path, "HEAD") : undefined),
703
+ id: (path) => list().get(path),
704
+ }
705
+ }
706
+
707
+ /** An empty tree that records whether the flow looked at it. */
708
+ const watchedEmptyTree = (): { readonly tree: TreeView; readonly read: () => boolean } => {
709
+ let read = false
710
+ return {
711
+ tree: {
712
+ paths: () => ((read = true), []),
713
+ read: () => ((read = true), undefined),
714
+ },
715
+ read: () => read,
716
+ }
717
+ }
718
+
719
+ /** An empty settings record that logs every read: a named `get`/`has`, or an enumeration. */
720
+ const watchedEmptySettings = (): {
721
+ readonly settings: Readonly<Record<string, string>>
722
+ readonly names: ReadonlySet<string>
723
+ readonly enumerated: () => boolean
724
+ } => {
725
+ const names = new Set<string>()
726
+ let enumerated = false
727
+ const settings = new Proxy<Record<string, string>>(
728
+ {},
729
+ {
730
+ get: (_target, key) => {
731
+ if (typeof key === "string") names.add(key)
732
+ return undefined
733
+ },
734
+ has: (_target, key) => {
735
+ if (typeof key === "string") names.add(key)
736
+ return false
737
+ },
738
+ ownKeys: () => {
739
+ enumerated = true
740
+ return []
741
+ },
742
+ },
743
+ )
744
+ return { settings, names, enumerated: () => enumerated }
745
+ }
746
+
747
+ const settingReads = (kind: string, watch: ReturnType<typeof watchedEmptySettings>): string[] => {
748
+ const reads: string[] = []
749
+ if (watch.names.size > 0) {
750
+ const sorted = [...watch.names].sort().map((name) => JSON.stringify(name))
751
+ reads.push(`the ${kind} setting${sorted.length > 1 ? "s" : ""} ${sorted.join(", ")}`)
752
+ }
753
+ if (watch.enumerated()) reads.push(`every ${kind} setting`)
754
+ return reads
755
+ }
756
+
757
+ const replayToFirstStep = (
758
+ loaded: NonNullable<Loaded["default"]>,
759
+ vars: Readonly<Record<string, string>>,
760
+ env: Readonly<Record<string, string>>,
761
+ tree: TreeView,
762
+ ): Effect.Effect<string, GtdError> =>
763
+ Effect.flatMap(
764
+ Effect.promise(() =>
765
+ replay({
766
+ flow: loaded.flow,
767
+ episode: { base: { hash: "", tree }, commits: [] },
768
+ vars,
769
+ env,
770
+ start: "",
771
+ budgetBytes: Number.MAX_SAFE_INTEGER,
772
+ }),
773
+ ),
774
+ (outcome) => {
775
+ if (outcome.kind === "rest") return Effect.succeed(outcome.rest.name)
776
+ const problem =
777
+ outcome.kind === "failed" || outcome.kind === "refused"
778
+ ? outcome.message.replace(/^gtd: /, "")
779
+ : "the default entry must begin at a step — that step is where a finished process waits"
780
+ return Effect.fail(new GtdError(`gtd config:\n - ${loaded.file.origin}: ${problem}`))
781
+ },
782
+ )
783
+
784
+ /**
785
+ * The default entry's first step — where a finished process waits — found the
786
+ * only way a flow can be read: by replaying it over an empty history. Episode
787
+ * boundaries are that step's commits, so it must depend on neither a setting
788
+ * (edited mid-process, it would move the boundary before pinned values are
789
+ * read) nor the tree: a flow that reads the repository before reaching it is
790
+ * replayed again over HEAD, and must reach the same step there.
791
+ */
792
+ const firstStep = (
793
+ loaded: NonNullable<Loaded["default"]>,
794
+ head: TreeView,
795
+ ): Effect.Effect<string, GtdError> =>
796
+ Effect.gen(function* () {
797
+ const empty = watchedEmptyTree()
798
+ const vars = watchedEmptySettings()
799
+ const env = watchedEmptySettings()
800
+ const outcome = yield* Effect.either(
801
+ replayToFirstStep(loaded, vars.settings, env.settings, empty.tree),
802
+ )
803
+ const reads = [...settingReads("process", vars), ...settingReads("environment", env)]
804
+ if (reads.length > 0) {
805
+ return yield* Effect.fail(
806
+ new GtdError(
807
+ `gtd config:\n - ${loaded.file.origin}: the flow's first step on an ordinary start reads ${reads.join(" and ")} — that step is where a finished process waits, so it must not depend on a setting`,
808
+ ),
809
+ )
810
+ }
811
+ if (outcome._tag === "Left") return yield* Effect.fail(outcome.left)
812
+ const name = outcome.right
813
+ if (!empty.read()) return name
814
+ const atHead = yield* replayToFirstStep(loaded, vars.settings, env.settings, head)
815
+ if (atHead === name) return name
816
+ return yield* Effect.fail(
817
+ new GtdError(
818
+ `gtd config:\n - ${loaded.file.origin}: the flow's first step on an ordinary start depends on the repository's files ("${name}" without them, "${atHead}" at HEAD) — that step is where a finished process waits, so it must not change as files do`,
819
+ ),
820
+ )
821
+ })
822
+
823
+ interface ConfigServiceOperations {
824
+ readonly load: Effect.Effect<
825
+ ConfigOperations,
826
+ Error,
827
+ Narrator | Workspace | Host | ConfigDiscovery
828
+ >
829
+ }
830
+
831
+ export class ConfigService extends Context.Tag("ConfigService")<
832
+ ConfigService,
833
+ ConfigServiceOperations
834
+ >() {
835
+ static Live = Layer.succeed(ConfigService, { load })
836
+ }