@pmelab/gtd 59.0.1 → 60.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 (108) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +48 -32
  3. package/claude/hooks/doors.ts +8 -4
  4. package/dist/chunks/flows-DENt0uWV.mjs +707 -0
  5. package/dist/chunks/{steering-CCNvngSH.mjs → steering-DF_BMadH.mjs} +292 -289
  6. package/dist/chunks/testkit-BhTey1Do.mjs +30517 -0
  7. package/dist/chunks/{wire-BTUhRGXj.mjs → wire-DZ8R1RU9.mjs} +8 -1
  8. package/dist/flows.mjs +2 -676
  9. package/dist/gtd.bundle.mjs +11285 -19864
  10. package/dist/gtd.core.mjs +593 -0
  11. package/dist/steering.mjs +2 -2
  12. package/dist/testkit/gherkin.mjs +246 -37
  13. package/dist/testkit.mjs +3 -2
  14. package/dist/web/main.js +234 -232
  15. package/dist/wire.mjs +2 -2
  16. package/dist/workflow-kit.mjs +3 -3
  17. package/package.json +7 -7
  18. package/skills/authoring/SKILL.md +66 -60
  19. package/src/cli/Cli.test.ts +185 -184
  20. package/src/cli/Cli.ts +219 -224
  21. package/src/cli/History.ts +9 -4
  22. package/src/cli/Judges.ts +1 -1
  23. package/src/cli/Output.test.ts +35 -0
  24. package/src/cli/Output.ts +34 -3
  25. package/src/cli/Program.test.ts +78 -84
  26. package/src/cli/Program.ts +192 -128
  27. package/src/cli/ToolCommands.ts +2 -2
  28. package/src/edge/Edge.test.ts +72 -62
  29. package/src/edge/Edge.ts +6 -6
  30. package/src/flows/factory.test.ts +14 -0
  31. package/src/flows/factory.ts +22 -5
  32. package/src/flows/helpers.test.ts +35 -0
  33. package/src/flows/helpers.ts +19 -10
  34. package/src/flows/index.test.ts +66 -0
  35. package/src/flows/index.ts +109 -5
  36. package/src/flows/internal.ts +7 -0
  37. package/src/flows/runtime.test.ts +192 -1
  38. package/src/flows/runtime.ts +189 -55
  39. package/src/gherkin/git-control.feature +6 -6
  40. package/src/gherkin/index.test.ts +127 -0
  41. package/src/gherkin/index.ts +370 -51
  42. package/src/gherkin/messages.test.ts +38 -4
  43. package/src/gherkin/messages.ts +21 -4
  44. package/src/gherkin/more-steps.feature +170 -0
  45. package/src/gherkin/scenario-end.feature +101 -0
  46. package/src/gherkin/testkit.feature +83 -7
  47. package/src/install/Install.test.ts +7 -7
  48. package/src/install/Install.ts +8 -8
  49. package/src/install/Version.ts +9 -3
  50. package/src/install/index.ts +1 -1
  51. package/src/platform/CommandRunner.ts +13 -11
  52. package/src/platform/Commentary.ts +2 -2
  53. package/src/platform/Git.test.ts +4 -5
  54. package/src/platform/Git.ts +42 -38
  55. package/src/platform/Host.ts +1 -1
  56. package/src/platform/NodeServices.ts +11 -0
  57. package/src/platform/Workspace.test.ts +4 -5
  58. package/src/platform/Workspace.ts +3 -3
  59. package/src/platform/WorktreeState.test.ts +2 -3
  60. package/src/platform/WorktreeState.ts +1 -1
  61. package/src/platform/index.ts +2 -1
  62. package/src/replay/Replay.test.ts +13 -2
  63. package/src/replay/Replay.ts +19 -30
  64. package/src/replay/judgeDiffShape.test.ts +1 -1
  65. package/src/steering/Footnotes.test.ts +1 -1
  66. package/src/steering/SteeringFormats.test.ts +1 -1
  67. package/src/steering/freeform.test.ts +1 -1
  68. package/src/steering/index.test.ts +27 -1
  69. package/src/steering/index.ts +6 -104
  70. package/src/steering/internal.ts +11 -0
  71. package/src/steering/qa.test.ts +7 -2
  72. package/src/steering/reads.test.ts +42 -0
  73. package/src/steering/reads.ts +43 -0
  74. package/src/steering/registry.ts +93 -0
  75. package/src/steering/review.test.ts +2 -2
  76. package/src/step/planStart.test.ts +18 -16
  77. package/src/step/planStart.ts +12 -12
  78. package/src/testkit/TestRepo.test.ts +245 -7
  79. package/src/testkit/TestRepo.ts +271 -40
  80. package/src/testkit/imports.test.ts +58 -0
  81. package/src/testkit/imports.ts +102 -0
  82. package/src/testkit/index.test.ts +28 -0
  83. package/src/testkit/index.ts +14 -2
  84. package/src/twins/EmittedScriptRecognizer.ts +2 -6
  85. package/src/twins/EmittedScripts.test.ts +5 -3
  86. package/src/twins/GitTiers.ts +6 -7
  87. package/src/twins/InMemRepo.ts +1 -0
  88. package/src/twins/Layers.test.ts +1 -1
  89. package/src/twins/Layers.ts +10 -3
  90. package/src/twins/cliIo.ts +9 -4
  91. package/src/wire/Script.ts +11 -0
  92. package/src/wire/WireSchema.test.ts +3 -0
  93. package/src/wire/golden/1/script.json +4 -0
  94. package/src/wire/index.ts +1 -0
  95. package/src/workflow/SteeringFormats.test.ts +1 -1
  96. package/src/workflow/compile.ts +2 -2
  97. package/src/workflow/discovery.test.ts +27 -10
  98. package/src/workflow/discovery.ts +5 -6
  99. package/src/workflow/index.ts +1 -0
  100. package/src/workflow/load.test.ts +316 -119
  101. package/src/workflow/load.ts +478 -305
  102. package/src/workflow/pin.test.ts +159 -0
  103. package/src/workflow/pin.ts +72 -0
  104. package/src/workflow/vars.ts +3 -3
  105. package/src/workflow-kit/index.test.ts +25 -0
  106. package/src/workflow-kit/index.ts +37 -4
  107. package/src/workflow-kit/review.ts +2 -2
  108. package/dist/chunks/testkit-07Z_ENch.mjs +0 -32175
@@ -1,18 +1,24 @@
1
- import { existsSync, readFileSync } from "node:fs"
2
- import { createRequire } from "node:module"
3
- import { dirname, join } from "node:path"
1
+ import { existsSync, readFileSync, realpathSync } from "node:fs"
2
+ import { createRequire, isBuiltin } from "node:module"
3
+ import { dirname, isAbsolute, join, relative, resolve as resolvePath, sep } from "node:path"
4
4
  import { fileURLToPath, pathToFileURL } from "node:url"
5
+ import { stripVTControlCharacters } from "node:util"
6
+ import { runInThisContext } from "node:vm"
7
+ import type { Plugin as RolldownPlugin } from "rolldown"
5
8
  import { Context, Effect, Layer } from "effect"
6
9
  import { GtdError, Narrator } from "../platform/index.js"
7
- import { replay, type TreeView } from "../replay/index.js"
10
+ import { accessShapeFault, replay, type TreeView } from "../replay/index.js"
11
+ // The published facade: what a config's `@pmelab/gtd/flows` import is served.
8
12
  import * as flows from "../flows/index.js"
13
+ import { configure, isFactoryWorkflow, markedOptions, type OptionIssue } from "../flows/internal.js"
9
14
  import type { StateMode, WorkflowDefinition } from "./Workflow.js"
10
- import type { Door, Flow } from "../flows/index.js"
15
+ import type { Door, Flow, ScopeAccess } from "../flows/index.js"
11
16
  import { Host, Workspace, type WorkspaceOps } from "../platform/index.js"
12
17
  import { compileConfig, type CompiledConfig } from "./compile.js"
13
18
  import { SETTING_NAME_RULE, isSettingName } from "./vars.js"
14
19
  import { ConfigDiscovery, WORKFLOW_MODULE, type WorkflowModule } from "./discovery.js"
15
20
  import { configFingerprint, type ConfigFingerprint } from "./fingerprint.js"
21
+ import { PIN_KEY } from "../install/index.js"
16
22
  import {
17
23
  dedupeDiagnostics,
18
24
  formatDiagnostic,
@@ -33,10 +39,10 @@ export interface ConfigOperations {
33
39
  readonly workflow: WorkflowDefinition
34
40
  /** The default, resolved like a named one; its name is what status shows: the named export it also is, else `"default"`. */
35
41
  readonly defaultWorkflow: ResolvedWorkflow
36
- /** Every name `gtd --workflow` accepts, sorted — never `"default"`. */
42
+ /** Every workflow name `gtd start` accepts, sorted — never `"default"`. */
37
43
  readonly workflowNames: readonly string[]
38
44
  readonly workflowNamed: (name: string) => ResolvedWorkflow | undefined
39
- /** Every door `gtd door` accepts. A door's `workflow` resolves through `workflowNamed`. */
45
+ /** Every door `gtd start` accepts. A door's `workflow` resolves through `workflowNamed`. */
40
46
  readonly doors: ReadonlyMap<string, Door>
41
47
  /** `config.ui` (absent when unconfigured) — `gtd ui` and its CLI flags read it. */
42
48
  readonly ui?: flows.UiSettings
@@ -87,8 +93,8 @@ export const refuseLegacyRc: Effect.Effect<void, GtdError, Host | ConfigDiscover
87
93
  "gtd: config:",
88
94
  ...found.map((file) => ` - ${file}: no longer read — delete it`),
89
95
  " gtd 23 removed the .gtdrc files; move each key they set:",
90
- " vars → an option of the workflow factory in gtd.config.ts, or its `defaults` export",
91
- " env → an option of the workflow factory in gtd.config.ts, or its `envDefaults` export",
96
+ " vars → an option of the workflow factory in gtd.config.ts, or `workflow(flow, { defaults })`",
97
+ " env → an option of the workflow factory in gtd.config.ts, or `workflow(flow, { envDefaults })`",
92
98
  " judge → ~/.config/gtd/judge.json, or GTD_JUDGE_PROVIDER / GTD_JUDGE_MODEL",
93
99
  " modes, ui, skills, access → the `config` export of gtd.config.ts (`defineConfig`)",
94
100
  " $schema → nothing; drop it",
@@ -98,9 +104,25 @@ export const refuseLegacyRc: Effect.Effect<void, GtdError, Host | ConfigDiscover
98
104
  )
99
105
  })
100
106
 
107
+ const configModule: Effect.Effect<WorkflowModule, Error, Host | ConfigDiscovery> = Effect.gen(
108
+ function* () {
109
+ const host = yield* Host
110
+ const module = yield* (yield* ConfigDiscovery).workflowModule(host.root, host.home)
111
+ if (module !== undefined) return module
112
+ const missing: Diagnostic = {
113
+ severity: "error",
114
+ path: [],
115
+ message: `no workflows configured: no ${WORKFLOW_MODULE} here or above — gtd ships no workflow; \`gtd guide\` says where to get one`,
116
+ origin: host.root,
117
+ }
118
+ return yield* Effect.fail(configError([missing]))
119
+ },
120
+ )
121
+
101
122
  /**
102
- * Find and evaluate `gtd.config.ts`, then check what it exports. Every finding
103
- * is collected into ONE sorted report rather than failing on the first.
123
+ * Find and evaluate `gtd.config.ts` — or, inside a pin, the workflow bundled
124
+ * into it — then check what it exports. Every finding is collected into ONE
125
+ * sorted report rather than failing on the first.
104
126
  */
105
127
  export const load: Effect.Effect<
106
128
  ConfigOperations,
@@ -108,25 +130,30 @@ export const load: Effect.Effect<
108
130
  Narrator | Workspace | Host | ConfigDiscovery
109
131
  > = Effect.gen(function* () {
110
132
  const host = yield* Host
111
- const discovery = yield* ConfigDiscovery
112
133
  yield* refuseLegacyRc
113
- const module = yield* discovery.workflowModule(host.root, host.home)
114
- if (module === undefined) {
115
- const missing: Diagnostic = {
116
- severity: "error",
117
- path: [],
118
- message: `no workflows configured: no ${WORKFLOW_MODULE} here or above — gtd ships no workflow; \`gtd install\` says where to get one`,
119
- origin: host.root,
120
- }
121
- return yield* Effect.fail(configError([missing]))
122
- }
123
- yield* (yield* Narrator).narrate(`config: module ${module.filepath}`)
124
- const shipped = yield* Effect.tryPromise({
125
- try: shippedModules,
126
- catch: (e) => new GtdError(`gtd: ${e instanceof Error ? e.message : String(e)}`),
127
- })
128
- const loaded = yield* Effect.try({
129
- try: () => loadWorkflows(module, shipped),
134
+ const pinned = pinnedWorkflow()
135
+ const module: WorkflowModule =
136
+ pinned === undefined
137
+ ? yield* configModule
138
+ : { filepath: resolvePath(host.root, pinned.origin), source: "" }
139
+ yield* (yield* Narrator).narrate(
140
+ `config: ${pinned === undefined ? "module" : "pinned module"} ${module.filepath}`,
141
+ )
142
+ const shipped =
143
+ pinned === undefined
144
+ ? yield* Effect.tryPromise({
145
+ try: shippedModules,
146
+ catch: (e) => new GtdError(`gtd: ${e instanceof Error ? e.message : String(e)}`),
147
+ })
148
+ : undefined
149
+ const loaded = yield* Effect.tryPromise({
150
+ try: async () =>
151
+ loadWorkflows(
152
+ module.filepath,
153
+ shipped === undefined
154
+ ? runPinned(pinned!, module.filepath, host.root)
155
+ : await evaluate(module, shipped),
156
+ ),
130
157
  catch: (e) =>
131
158
  new GtdError(
132
159
  `gtd: config:\n - ${module.filepath}: ${e instanceof Error ? e.message : String(e)}`,
@@ -142,17 +169,15 @@ export const load: Effect.Effect<
142
169
  }
143
170
 
144
171
  const initial = yield* firstStep(loaded.default, headTree(yield* Workspace))
145
- const steering = loaded.file.steering as Record<string, StateMode>
146
172
  const define = (entry: LoadedWorkflow): WorkflowDefinition => ({
147
173
  flow: entry.flow,
148
- summary: entry.file.summary,
149
- // A config's own `base` export wins when it answers; else the factory's.
150
- base: (w, v) => entry.file.base?.(w, v) ?? entry.base?.(w, v),
151
- steering,
174
+ summary: entry.summary,
175
+ base: (w, v) => entry.base?.(w, v),
176
+ steering: entry.steering as Record<string, StateMode>,
152
177
  modes: compiled.modes,
153
- skills: entry.file.skills,
178
+ skills: entry.skills,
154
179
  configuredSkills: compiled.skills,
155
- access: entry.file.access,
180
+ access: entry.access,
156
181
  configuredAccess: compiled.access,
157
182
  skillsOrigin: entry.file.origin,
158
183
  initial,
@@ -176,11 +201,9 @@ export const load: Effect.Effect<
176
201
  },
177
202
  ...(compiled.ui !== undefined ? { ui: compiled.ui } : {}),
178
203
  warnings: diagnostics.filter((d) => d.severity === "warning"),
179
- fingerprint: configFingerprint(
180
- loaded.file.dependencyFiles,
181
- host.root,
182
- module.read ?? readFromDisk,
183
- ),
204
+ fingerprint:
205
+ pinned?.fingerprint ??
206
+ configFingerprint(loaded.file.dependencyFiles, host.root, module.read ?? readFromDisk),
184
207
  }
185
208
  })
186
209
 
@@ -197,25 +220,21 @@ interface Settings {
197
220
  readonly envDefaults: Readonly<Record<string, string>>
198
221
  }
199
222
 
200
- interface LoadedFile
201
- extends
202
- Pick<WorkflowDefinition, "summary" | "base" | "steering" | "skills" | "access">,
203
- Settings {
223
+ interface LoadedFile {
204
224
  readonly origin: string
205
225
  /**
206
- * Every real file this module's evaluation touched — `origin` itself plus
207
- * any relative import it (transitively) resolved on disk, in NO particular
208
- * order. A caller that
209
- * needs to know when a workflow's DEFINITION might have changed (the LSP's
210
- * steering-map memo) must watch this whole set, not just `origin`: a
211
- * `gtd.config.ts` re-exporting a sibling module is a documented pattern,
212
- * and jiti resolves that import off disk same as any other require.
226
+ * Every file bundled evaluating this module — `origin` itself plus every
227
+ * local module it (transitively) imports, in NO particular order. A caller
228
+ * that needs to know when a workflow's DEFINITION might have changed (the
229
+ * LSP's steering-map memo) must watch this whole set, not just `origin`: a
230
+ * `gtd.config.ts` re-exporting a sibling module is a documented pattern.
213
231
  */
214
232
  readonly dependencyFiles: readonly string[]
215
233
  }
216
234
 
217
- /** A startable workflow; its settings are its file's, under whatever its factory declared. */
218
- interface LoadedWorkflow extends Settings {
235
+ /** A startable workflow, built from a factory or a `workflow()` call's options. */
236
+ interface LoadedWorkflow
237
+ extends Settings, Pick<WorkflowDefinition, "summary" | "steering" | "skills" | "access"> {
219
238
  readonly flow: Flow
220
239
  readonly file: LoadedFile
221
240
  readonly doors?: flows.WorkflowDoors | undefined
@@ -233,76 +252,8 @@ interface Loaded {
233
252
  readonly diagnostics: readonly Diagnostic[]
234
253
  }
235
254
 
236
- type JitiInstance = {
237
- evalModule: (
238
- source: string,
239
- options: { filename: string; async?: false; cache?: Record<string, unknown> },
240
- ) => unknown
241
- }
242
- type JitiModule = {
243
- createJiti: (id: string, options: Record<string, unknown>) => JitiInstance
244
- }
245
-
246
- type Transform = (options: { readonly source: string }) => { code: string; error?: unknown }
247
-
248
- // Transpiling is most of a load's cost, and a load re-transpiles
249
- // gtd.config.ts and every shipped module it imports. The same options (the
250
- // source included) always transpile to the same code, so an in-process memo
251
- // is safe; evaluation stays fresh on every load.
252
- const TRANSFORM_MEMO_SIZE = 512
253
- const memoized = (transform: Transform): Transform => {
254
- const memo = new Map<string, ReturnType<Transform>>()
255
- return (options) => {
256
- const key = JSON.stringify(options)
257
- const hit = memo.get(key)
258
- if (hit !== undefined) return hit
259
- const result = transform(options)
260
- if (result.error !== undefined) return result
261
- if (memo.size >= TRANSFORM_MEMO_SIZE) memo.delete(memo.keys().next().value!)
262
- memo.set(key, result)
263
- return result
264
- }
265
- }
266
-
267
- let jitiModule: JitiModule | undefined
268
- let transform: Transform | undefined
269
- /** Relative specifiers a module imports, served from somewhere other than the disk. */
270
- interface LocalModules {
271
- /** The path a specifier names, or nothing when it names no module. */
272
- readonly resolve: (specifier: string) => string | undefined
273
- readonly load: (path: string) => unknown
274
- }
275
-
276
- const jiti = (shipped: ShippedModules, local?: LocalModules): JitiInstance => {
277
- const require = createRequire(import.meta.url)
278
- jitiModule ??= require("jiti") as JitiModule
279
- // jiti's own Babel transform, which its package exports don't name.
280
- transform ??= memoized(
281
- require(join(dirname(require.resolve("jiti/package.json")), "dist", "babel.cjs")) as Transform,
282
- )
283
- return jitiModule.createJiti(import.meta.url, {
284
- // Consulted when a module is required, so a local module is evaluated
285
- // only once an import actually runs.
286
- virtualModules: new Proxy(shipped.modules, {
287
- has: (modules, id) =>
288
- id in modules || (typeof id === "string" && local?.resolve(id) !== undefined),
289
- get: (modules, id) => {
290
- const path = typeof id === "string" ? local?.resolve(id) : undefined
291
- return path === undefined ? modules[id as string] : local!.load(path)
292
- },
293
- }),
294
- alias: shipped.missing,
295
- // No transpile cache on disk, and a fresh module every load — nothing a
296
- // later command could read back instead of the source.
297
- fsCache: false,
298
- moduleCache: false,
299
- transform,
300
- // The whole module, not just its default: the named exports are read too.
301
- interopDefault: false,
302
- })
303
- }
304
-
305
255
  const PACKAGE = "@pmelab/gtd"
256
+ const FLOWS = `${PACKAGE}/flows`
306
257
 
307
258
  interface PackageJson {
308
259
  readonly name?: string
@@ -311,7 +262,7 @@ interface PackageJson {
311
262
 
312
263
  // Only gtd's own package.json: whatever other one sits nearest (a vendored
313
264
  // bundle inside a project) must never get its exports executed.
314
- const ownPackage = (): { readonly root: string; readonly pkg: PackageJson } => {
265
+ export const ownPackage = (): { readonly root: string; readonly pkg: PackageJson } => {
315
266
  const start = dirname(fileURLToPath(import.meta.url))
316
267
  for (let dir = start; ; dir = dirname(dir)) {
317
268
  const file = join(dir, "package.json")
@@ -342,7 +293,7 @@ const shippedModules = (): Promise<ShippedModules> =>
342
293
  (shippedMemo ??= (async () => {
343
294
  const fromSource = import.meta.url.endsWith(".ts")
344
295
  const files = subpathFiles(fromSource ? "source" : "default")
345
- const modules: Record<string, unknown> = { [`${PACKAGE}/flows`]: flows }
296
+ const modules: Record<string, unknown> = { [FLOWS]: flows }
346
297
  const missing: Record<string, string> = {}
347
298
  if (process.features.require_module && !fromSource) {
348
299
  return { modules: requiredOnUse(modules, files), missing }
@@ -384,75 +335,105 @@ const requiredOnUse = (
384
335
  })
385
336
  }
386
337
 
387
- const stringRecord = (
388
- exports: Record<string, unknown>,
389
- name: string,
390
- ): Readonly<Record<string, string>> => {
391
- const value = exports[name] ?? {}
338
+ /** `what` names the value in an error: `workflow "x"'s "steering"`. */
339
+ const stringRecord = (value: unknown, what: string): Readonly<Record<string, string>> => {
340
+ const record = value ?? {}
392
341
  if (
393
- typeof value !== "object" ||
394
- value === null ||
395
- Object.values(value).some((entry) => typeof entry !== "string")
342
+ typeof record !== "object" ||
343
+ record === null ||
344
+ Object.values(record).some((entry) => typeof entry !== "string")
396
345
  ) {
397
- throw new Error(`the "${name}" export is not a record of strings`)
346
+ throw new Error(`${what} is not a record of strings`)
398
347
  }
399
- return value as Readonly<Record<string, string>>
348
+ return record as Readonly<Record<string, string>>
400
349
  }
401
350
 
402
351
  const isStringArray = (v: unknown): v is readonly string[] =>
403
352
  Array.isArray(v) && v.every((entry) => typeof entry === "string")
404
353
 
405
- const checkSkillsRecord = (value: unknown): Readonly<Record<string, readonly string[]>> => {
354
+ const checkSkillsRecord = (
355
+ value: unknown,
356
+ what: string,
357
+ ): Readonly<Record<string, readonly string[]>> => {
406
358
  if (
407
359
  typeof value !== "object" ||
408
360
  value === null ||
409
361
  Object.values(value).some((entry) => !isStringArray(entry))
410
362
  ) {
411
- throw new Error(`the "skills" export is not a record of skill-name arrays`)
363
+ throw new Error(`${what} is not a record of skill-name arrays`)
412
364
  }
413
365
  return value as Readonly<Record<string, readonly string[]>>
414
366
  }
415
367
 
416
- /** Read a module's optional `skills` export — a scope full name -> skill list record, or a function of the vars returning one. */
417
- const skillsExport = (exports: Record<string, unknown>): WorkflowDefinition["skills"] => {
418
- const value = exports["skills"] ?? {}
368
+ /** A workflow's `skills` option — a scope full name -> skill list record, or a function of the vars returning one. */
369
+ const skillsFrom = (value: unknown, what: string): WorkflowDefinition["skills"] => {
419
370
  if (typeof value === "function") {
420
- return (vars) => checkSkillsRecord((value as (v: typeof vars) => unknown)(vars))
371
+ return (vars) => checkSkillsRecord((value as (v: typeof vars) => unknown)(vars), what)
421
372
  }
422
- const record = checkSkillsRecord(value)
373
+ const record = checkSkillsRecord(value ?? {}, what)
423
374
  return () => record
424
375
  }
425
376
 
426
- /** Read a module's optional `access` export — a scope full name -> `{ read?, write? }` record, or a function of the vars returning one. */
427
- const accessExport = (exports: Record<string, unknown>): WorkflowDefinition["access"] => {
428
- const value = exports["access"] ?? {}
377
+ const checkAccessRecord = (value: unknown, what: string): Readonly<Record<string, ScopeAccess>> => {
378
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
379
+ throw new Error(
380
+ `${what} is not a record of scope access (scope -> { read?, write? }) or a function returning one`,
381
+ )
382
+ }
383
+ for (const [scope, access] of Object.entries(value)) {
384
+ const fault = accessShapeFault(access)
385
+ if (fault !== undefined) throw new Error(`${what}: scope "${scope}": ${fault}`)
386
+ }
387
+ return value as Readonly<Record<string, ScopeAccess>>
388
+ }
389
+
390
+ /**
391
+ * A workflow's `access` option — a scope full name -> `{ read?, write? }`
392
+ * record, checked now, or a function of the vars returning one, checked when
393
+ * the process's settings are known.
394
+ */
395
+ const accessFrom = (value: unknown, what: string): WorkflowDefinition["access"] => {
429
396
  if (typeof value === "function") {
430
397
  return (vars) => (value as (v: typeof vars) => never)(vars)
431
398
  }
432
- return () => value as never
399
+ const record = checkAccessRecord(value ?? {}, what)
400
+ return () => record
433
401
  }
434
402
 
435
- const optionalFunction = <T>(exports: Record<string, unknown>, name: string): T | undefined => {
436
- const value = exports[name]
403
+ const optionalFunction = <T>(value: unknown, what: string): T | undefined => {
437
404
  if (value === undefined) return undefined
438
- if (typeof value !== "function") throw new Error(`the "${name}" export is not a function`)
405
+ if (typeof value !== "function") throw new Error(`${what} is not a function`)
439
406
  return value as T
440
407
  }
441
408
 
442
- const RESERVED_EXPORTS: ReadonlySet<string> = new Set([
443
- "default",
444
- "summary",
445
- "base",
409
+ // What each file-level export of earlier versions became: an option of the
410
+ // workflow it configured. Exporting one is a load error, never silently ignored.
411
+ const RETIRED_EXPORTS: ReadonlySet<string> = new Set([
446
412
  "defaults",
447
413
  "envDefaults",
448
414
  "steering",
449
415
  "skills",
450
416
  "access",
451
417
  "doors",
452
- "config",
418
+ "summary",
419
+ "base",
453
420
  ])
454
421
 
455
- /** The rules every declared setting meets, whether a file's export or a factory's build. */
422
+ /** Names that are never a workflow. */
423
+ const RESERVED_EXPORTS: ReadonlySet<string> = new Set(["default", "config", ...RETIRED_EXPORTS])
424
+
425
+ const retiredExports = (exports: Record<string, unknown>, origin: string): Diagnostic[] =>
426
+ Object.keys(exports)
427
+ .filter((name) => RETIRED_EXPORTS.has(name))
428
+ .sort()
429
+ .map((name) => ({
430
+ severity: "error",
431
+ path: [name],
432
+ origin,
433
+ message: `the "${name}" export is no longer read — pass it to the workflow it configures: \`workflow(flow, { ${name} })\`, or the build a workflow factory returns`,
434
+ }))
435
+
436
+ /** The rules every declared setting meets. */
456
437
  const checkSettings = (
457
438
  defaults: Readonly<Record<string, unknown>>,
458
439
  envDefaults: Readonly<Record<string, unknown>>,
@@ -481,28 +462,6 @@ const checkSettings = (
481
462
  }
482
463
  }
483
464
 
484
- /** A module's per-file exports: `defaults`, `envDefaults`, `steering`, `skills`, `access`, `summary`, `base`. */
485
- const fileFrom = (
486
- exports: Record<string, unknown>,
487
- origin: string,
488
- dependencyFiles: readonly string[],
489
- ): LoadedFile => {
490
- const defaults = stringRecord(exports, "defaults")
491
- const envDefaults = stringRecord(exports, "envDefaults")
492
- checkSettings(defaults, envDefaults, (kind) => `the "${kind}" export declares`)
493
- return {
494
- defaults,
495
- envDefaults,
496
- steering: stringRecord(exports, "steering"),
497
- skills: skillsExport(exports),
498
- access: accessExport(exports),
499
- summary: optionalFunction(exports, "summary"),
500
- base: optionalFunction(exports, "base"),
501
- origin,
502
- dependencyFiles,
503
- }
504
- }
505
-
506
465
  const DOOR_NAME_RULE = /^[a-z][a-z0-9-]*$/
507
466
 
508
467
  /** Why one door's `args` are malformed, or `undefined` when they are well-formed. */
@@ -532,27 +491,12 @@ const doorProblem = (name: string, door: unknown): string | undefined => {
532
491
  return argsProblem(args) ?? (varsOk ? undefined : `has "vars" that is not a function`)
533
492
  }
534
493
 
535
- /** A module's `doors` export, shape-checked; the workflow names are checked later, against the catalogue. */
536
- const doorsFrom = (exports: Record<string, unknown>): ReadonlyMap<string, Door> => {
537
- const value = exports["doors"]
538
- if (value === undefined) return new Map()
539
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
540
- throw new Error(`the "doors" export is not a record of doors`)
541
- }
542
- for (const [name, door] of Object.entries(value)) {
543
- const problem = doorProblem(name, door)
544
- if (problem !== undefined) throw new Error(`door "${name}" ${problem}`)
545
- }
546
- return new Map(Object.entries(value) as [string, Door][])
547
- }
548
-
549
494
  /**
550
- * Each exported workflow's own doors, onto the name it is exported under. The
551
- * `doors` export adds to them and declares a name several workflows clash on.
495
+ * Each exported workflow's own doors, onto the name it is exported under. A
496
+ * door several workflows build and none is exported under is a clash: no door.
552
497
  */
553
498
  const derivedDoors = (
554
499
  catalogue: ReadonlyMap<string, LoadedWorkflow>,
555
- declared: ReadonlyMap<string, Door>,
556
500
  ): {
557
501
  readonly doors: ReadonlyMap<string, Door>
558
502
  readonly clashes: ReadonlyMap<string, readonly string[]>
@@ -572,53 +516,30 @@ const derivedDoors = (
572
516
  for (const [name, built] of builders) {
573
517
  const owner = ownerOf(name, built)
574
518
  if (owner !== undefined) doors.set(name, owner)
575
- else if (!declared.has(name))
519
+ else
576
520
  clashes.set(
577
521
  name,
578
522
  built.map((door) => door.workflow),
579
523
  )
580
524
  }
581
- return { doors: new Map([...doors, ...declared]), clashes }
525
+ return { doors, clashes }
582
526
  }
583
527
 
584
528
  /** The export named after the door owns it, else the sole builder; a clash of several builds no door. */
585
529
  const ownerOf = (name: string, built: readonly Door[]): Door | undefined =>
586
530
  built.find((door) => door.workflow === name) ?? (built.length === 1 ? built[0] : undefined)
587
531
 
588
- /** Sort the doors and check each names a startable workflow. */
589
- const checkDoors = (
590
- declared: ReadonlyMap<string, Door>,
591
- catalogue: ReadonlyMap<string, LoadedWorkflow>,
592
- ): ReadonlyMap<string, Door> => {
593
- const doors = new Map([...declared].sort(([a], [b]) => a.localeCompare(b)))
594
- for (const [name, door] of doors) {
595
- if (!catalogue.has(door.workflow)) {
596
- throw new Error(
597
- `door "${name}" names workflow "${door.workflow}", which is not startable — known: ${[...catalogue.keys()].sort().join(", ")}`,
598
- )
599
- }
600
- }
601
- return doors
602
- }
603
-
604
532
  const namespaceOf = (exported: unknown): Record<string, unknown> =>
605
533
  (typeof exported === "object" && exported !== null ? exported : {}) as Record<string, unknown>
606
534
 
607
535
  /**
608
- * Evaluate `gtd.config.ts`. Every exported function or factory workflow but
609
- * the reserved names is a workflow named by its export. `cache` is jiti's OWN
610
- * per-call module cache (unrelated to the instance's `moduleCache: false`) —
611
- * passing an empty object in has the side effect of collecting every real file
612
- * jiti resolved evaluating this module, module AND every relative import it
613
- * pulled in, transitively.
536
+ * Evaluate `gtd.config.ts`. Every export but the reserved names that went
537
+ * through `workflow()` or is a factory workflow is a workflow named by its
538
+ * export.
614
539
  */
615
- const loadWorkflows = (module: WorkflowModule, shipped: ShippedModules): Loaded => {
616
- const { exports: evaluated, files } =
617
- module.read === undefined
618
- ? evaluateFromDisk(module, shipped)
619
- : evaluateRead(module, module.read, shipped)
620
- const exports = namespaceOf(evaluated)
621
- const file = fileFrom(exports, module.filepath, files)
540
+ const loadWorkflows = (origin: string, evaluated: Evaluated): Loaded => {
541
+ const exports = namespaceOf(evaluated.exports)
542
+ const file: LoadedFile = { origin, dependencyFiles: evaluated.files }
622
543
  const exported = Object.entries(exports).filter(
623
544
  ([name, value]) => !RESERVED_EXPORTS.has(name) && isWorkflow(value),
624
545
  )
@@ -643,20 +564,23 @@ const loadWorkflows = (module: WorkflowModule, shipped: ShippedModules): Loaded
643
564
  const derived =
644
565
  rejections.length > 0
645
566
  ? { doors: new Map<string, Door>(), clashes: new Map<string, readonly string[]>() }
646
- : derivedDoors(catalogue, doorsFrom(exports))
567
+ : derivedDoors(catalogue)
568
+ const doors = new Map([...derived.doors].sort(([a], [b]) => a.localeCompare(b)))
647
569
  return {
648
570
  file,
649
571
  catalogue,
650
- doors: checkDoors(derived.doors, catalogue),
572
+ doors,
651
573
  default: defaultWorkflow && { ...defaultWorkflow, name: defaultName },
652
- config: compileConfig(exports["config"], module.filepath),
574
+ config: compileConfig(exports["config"], origin),
653
575
  diagnostics: [
576
+ ...retiredExports(exports, file.origin),
654
577
  ...nameRejections(rejections),
578
+ ...shadowingDoors(doors, catalogue, file.origin),
655
579
  ...[...derived.clashes].map(([name, builders]): Diagnostic => {
656
580
  const names = [...builders].sort().map((b) => JSON.stringify(b))
657
581
  return {
658
582
  severity: "warning",
659
- message: `door "${name}" is built by workflows ${names.slice(0, -1).join(", ")} and ${names.at(-1)} and none is exported under that name — declare it in the "doors" export`,
583
+ message: `door "${name}" is built by workflows ${names.slice(0, -1).join(", ")} and ${names.at(-1)} and none is exported under that name — rename the export that should own it to "${name}"`,
660
584
  path: ["doors", name],
661
585
  origin: file.origin,
662
586
  }
@@ -665,17 +589,23 @@ const loadWorkflows = (module: WorkflowModule, shipped: ShippedModules): Loaded
665
589
  }
666
590
  }
667
591
 
668
- const evaluateFromDisk = (
669
- module: WorkflowModule,
670
- shipped: ShippedModules,
671
- ): { readonly exports: unknown; readonly files: readonly string[] } => {
672
- const cache: Record<string, unknown> = {}
673
- const exports = jiti(shipped).evalModule(module.source, { filename: module.filepath, cache })
674
- return { exports, files: Object.keys(cache) }
675
- }
592
+ /** `gtd start <name>` takes the door, so a door named like a workflow it does not start hides that workflow. */
593
+ const shadowingDoors = (
594
+ doors: ReadonlyMap<string, Door>,
595
+ catalogue: ReadonlyMap<string, unknown>,
596
+ origin: string,
597
+ ): Diagnostic[] =>
598
+ [...doors]
599
+ .filter(([name, door]) => catalogue.has(name) && door.workflow !== name)
600
+ .map(([name, door]) => ({
601
+ severity: "warning",
602
+ message: `door "${name}" starts workflow ${JSON.stringify(door.workflow)} and hides the workflow of the same name from \`gtd start ${name}\` — rename one of them`,
603
+ path: ["doors", name],
604
+ origin,
605
+ }))
676
606
 
677
- // How jiti finds a TypeScript module behind a specifier: as written, its
678
- // `.js` spelling as `.ts`, with `.ts` appended, or as a directory index.
607
+ // How a TypeScript module is found behind a relative specifier: as written,
608
+ // its `.js` spelling as `.ts`, with `.ts` appended, or as a directory index.
679
609
  const moduleCandidates = (path: string): readonly string[] => [
680
610
  path,
681
611
  path.replace(/\.js$/, ".ts"),
@@ -684,97 +614,341 @@ const moduleCandidates = (path: string): readonly string[] => [
684
614
  join(path, "index.ts"),
685
615
  ]
686
616
 
617
+ interface Evaluated {
618
+ readonly exports: unknown
619
+ readonly files: readonly string[]
620
+ }
621
+
622
+ /** A bundle's one chunk: CommonJS, so it runs as a plain function. */
623
+ type Chunk = (
624
+ exports: unknown,
625
+ require: (id: string) => unknown,
626
+ module: { exports: unknown },
627
+ __filename: string,
628
+ __dirname: string,
629
+ ) => void
630
+
631
+ const execute = (chunk: Chunk, filepath: string, require: (id: string) => unknown): unknown => {
632
+ const cjs = { exports: {} as unknown }
633
+ chunk(cjs.exports, require, cjs, filepath, dirname(filepath))
634
+ return cjs.exports
635
+ }
636
+
687
637
  /**
688
- * Evaluates the config with every local module it imports taken from `read`
689
- * instead of the disk. jiti matches a virtual module on the specifier as
690
- * written, so each module gets its own instance resolving against its own
691
- * directory. A specifier `read` cannot serve falls through to the disk, and
692
- * fails the way a missing file does.
638
+ * Bundles the config and every local module it imports in memory, then
639
+ * evaluates the bundle. With `read`, a relative import `read` can serve is
640
+ * taken from it instead of the disk; one it cannot falls through to the disk,
641
+ * and fails the way a missing file does. An ES module or TypeScript package is
642
+ * bundled too; a CommonJS one is required from where its importer resolved
643
+ * it, as Node would. `@pmelab/gtd/*` is the running gtd's own copy, and
644
+ * `files` leaves out every package file.
693
645
  */
694
- const evaluateRead = (
646
+ const evaluate = async (module: WorkflowModule, shipped: ShippedModules): Promise<Evaluated> => {
647
+ const { code, files } = await bundleConfig(module, { kind: "evaluate", shipped })
648
+ const fromConfig = createRequire(module.filepath)
649
+ const require = Object.assign(
650
+ (id: string): unknown =>
651
+ id in shipped.modules
652
+ ? shipped.modules[id]
653
+ : fromConfig(id in shipped.missing ? shipped.missing[id]! : id),
654
+ { resolve: fromConfig.resolve },
655
+ )
656
+ const chunk = runInThisContext(
657
+ `(function (exports, require, module, __filename, __dirname) {${code}\n})`,
658
+ { filename: module.filepath },
659
+ ) as Chunk
660
+ return { exports: execute(chunk, module.filepath, require), files }
661
+ }
662
+
663
+ /**
664
+ * What a pin registers: its workflow bundled, its origin relative to the
665
+ * repository root, and the fingerprint of the files it was bundled from — the
666
+ * content it runs, whatever the disk holds now.
667
+ */
668
+ interface PinnedWorkflow {
669
+ readonly version: string
670
+ readonly origin: string
671
+ readonly fingerprint: ConfigFingerprint
672
+ readonly run: Chunk
673
+ }
674
+
675
+ const pinnedWorkflow = (): PinnedWorkflow | undefined =>
676
+ (globalThis as { [PIN_KEY]?: PinnedWorkflow })[PIN_KEY]
677
+
678
+ const runPinned = (pinned: PinnedWorkflow, filepath: string, root: string): Evaluated => ({
679
+ // Everything but the engine's own flows and Node's builtins is in the bundle.
680
+ exports: execute(pinned.run, filepath, (id) =>
681
+ id === FLOWS ? flows : createRequire(import.meta.url)(id),
682
+ ),
683
+ files: Object.keys(pinned.fingerprint).map((file) => resolvePath(root, file)),
684
+ })
685
+
686
+ /**
687
+ * The statement that registers `module`'s workflow for a pin: the config and
688
+ * everything it imports — npm packages and gtd's own subpaths included — in
689
+ * one function, so the engine runs it without a config, a package or rolldown.
690
+ */
691
+ export const pinnedWorkflowSource = async (
695
692
  module: WorkflowModule,
696
- read: (path: string) => string | undefined,
697
- shipped: ShippedModules,
698
- ): { readonly exports: unknown; readonly files: readonly string[] } => {
699
- const files = [module.filepath]
700
- const evaluated = new Map<string, unknown>()
701
- const evaluate = (filepath: string, source: string): unknown =>
702
- jiti(shipped, {
703
- resolve: (specifier) =>
704
- /^\.\.?\//.test(specifier)
705
- ? moduleCandidates(join(dirname(filepath), specifier)).find(
706
- (candidate) => read(candidate) !== undefined,
707
- )
708
- : undefined,
709
- load: (path) => {
710
- if (!evaluated.has(path)) {
711
- // A placeholder first, so an import cycle ends here.
712
- evaluated.set(path, {})
713
- files.push(path)
714
- evaluated.set(path, evaluate(path, read(path)!))
715
- }
716
- return evaluated.get(path)
717
- },
718
- }).evalModule(source, { filename: filepath })
719
- return { exports: evaluate(module.filepath, module.source), files }
693
+ root: string,
694
+ version: string,
695
+ ): Promise<string> => {
696
+ const gtd = realpathSync(ownPackage().root) + sep
697
+ const { code, files } = await bundleConfig(module, {
698
+ kind: "pin",
699
+ shipped: subpathFiles(import.meta.url.endsWith(".ts") ? "source" : "default"),
700
+ })
701
+ // gtd's own subpaths are bundled from its package, which only a config
702
+ // living in gtd's own repository may count among its files.
703
+ const own = (file: string) => file.startsWith(gtd) && !module.filepath.startsWith(gtd)
704
+ const registered = {
705
+ version,
706
+ origin: relative(root, module.filepath),
707
+ fingerprint: configFingerprint(
708
+ files.filter((file) => !own(file)),
709
+ root,
710
+ module.read ?? readFromDisk,
711
+ ),
712
+ }
713
+ return `globalThis[Symbol.for(${JSON.stringify(PIN_KEY.description)})] = { ...${JSON.stringify(registered)}, run: function (exports, require, module, __filename, __dirname) {\n${code}\n} };\n`
714
+ }
715
+
716
+ type BundleMode =
717
+ | { readonly kind: "evaluate"; readonly shipped: ShippedModules }
718
+ // Every import goes into the bundle but flows, the engine's own.
719
+ | { readonly kind: "pin"; readonly shipped: ReadonlyMap<string, string> }
720
+
721
+ const shippedImport = (
722
+ source: string,
723
+ mode: BundleMode,
724
+ ): string | { readonly id: string; readonly external: true } | undefined => {
725
+ if (mode.kind === "pin")
726
+ return source === FLOWS ? { id: source, external: true } : mode.shipped.get(source)
727
+ return source in mode.shipped.modules || source in mode.shipped.missing
728
+ ? { id: source, external: true }
729
+ : undefined
730
+ }
731
+
732
+ const bundleConfig = async (
733
+ module: WorkflowModule,
734
+ mode: BundleMode,
735
+ ): Promise<{ readonly code: string; readonly files: readonly string[] }> => {
736
+ const { read } = module
737
+ const served = new Set<string>()
738
+ const fromRead = (path: string): string | null => {
739
+ const hit = moduleCandidates(path).find((candidate) => read?.(candidate) !== undefined)
740
+ if (hit === undefined) return null
741
+ served.add(hit)
742
+ return hit
743
+ }
744
+ const plugin: RolldownPlugin = {
745
+ name: "gtd-config",
746
+ async resolveId(source, importer) {
747
+ if (importer === undefined) return source === module.filepath ? source : null
748
+ const shipped = shippedImport(source, mode)
749
+ if (shipped !== undefined) return shipped
750
+ if (/^\.\.?(\/|$)/.test(source)) return fromRead(join(dirname(importer), source))
751
+ if (!isPackage(source)) return null
752
+ const resolved = await this.resolve(source, importer, { skipSelf: true })
753
+ return mode.kind === "pin" ? resolved : packageImport(source, resolved)
754
+ },
755
+ load(id) {
756
+ if (mode.kind === "pin" && isNativeAddon(id)) throw new Error(nativeAddon(id))
757
+ if (id === module.filepath) return module.source
758
+ return served.has(id) ? read!(id) : null
759
+ },
760
+ // One bundle has one file; each module keeps its own location.
761
+ transform(code, id) {
762
+ return code.replace(/\bimport\.meta\.(url|filename|dirname)\b/g, (_, key: string) =>
763
+ JSON.stringify(
764
+ key === "url" ? pathToFileURL(id).href : key === "filename" ? id : dirname(id),
765
+ ),
766
+ )
767
+ },
768
+ }
769
+ const bundle = await (rolldownModule ??= requireRolldown()).rolldown({
770
+ input: module.filepath,
771
+ platform: "node",
772
+ plugins: [plugin],
773
+ external: (id) => isBuiltin(id),
774
+ resolve: { extensionAlias: { ".js": [".js", ".ts"], ".mjs": [".mjs", ".mts"] } },
775
+ // Every statement runs, as it would unbundled.
776
+ treeshake: false,
777
+ tsconfig: false,
778
+ logLevel: "silent",
779
+ })
780
+ try {
781
+ const { output } = await bundle
782
+ // One chunk, and no `import()` left: the bundle runs as a plain function.
783
+ .generate({
784
+ format: "cjs",
785
+ exports: "named",
786
+ codeSplitting: false,
787
+ dynamicImportInCjs: false,
788
+ })
789
+ .catch((e) => {
790
+ throw bundleError(e)
791
+ })
792
+ const chunk = output[0]
793
+ return {
794
+ code: chunk.code,
795
+ files: chunk.moduleIds.filter((id) => !id.startsWith("\0") && !inPackage(id)),
796
+ }
797
+ } finally {
798
+ await bundle.close()
799
+ }
800
+ }
801
+
802
+ // A pin is plain JavaScript: a compiled addon stays a file on one machine.
803
+ // A package node-gyp builds loads its addon by a path no bundler sees.
804
+ const isNativeAddon = (id: string): boolean => {
805
+ if (id.endsWith(".node")) return true
806
+ const at = id.lastIndexOf(`node_modules${sep}`)
807
+ if (at < 0) return false
808
+ const rest = id.slice(at + `node_modules${sep}`.length).split(sep)
809
+ const root =
810
+ id.slice(0, at) + ["node_modules", ...rest.slice(0, rest[0]!.startsWith("@") ? 2 : 1)].join(sep)
811
+ return existsSync(join(root, "binding.gyp"))
812
+ }
813
+
814
+ const nativeAddon = (path: string): string => {
815
+ const at = path.lastIndexOf(`node_modules${sep}`)
816
+ const rest = at < 0 ? [] : path.slice(at + `node_modules${sep}`.length).split(sep)
817
+ const name = rest[0]?.startsWith("@") ? rest.slice(0, 2).join("/") : rest[0]
818
+ return `${name === undefined ? path : `package "${name}"`} loads a native addon (${path}), which a pin cannot carry`
720
819
  }
721
820
 
821
+ // Bundled, an ES module gets the shipped `@pmelab/gtd/*` like the config
822
+ // does. CommonJS stays Node's: it may read `__dirname`, which a bundle loses.
823
+ const packageImport = (
824
+ source: string,
825
+ resolved: { readonly id: string } | null,
826
+ ): string | { readonly id: string; readonly external: true } =>
827
+ resolved !== null && isEsModule(resolved.id)
828
+ ? resolved.id
829
+ : { id: resolved?.id ?? source, external: true }
830
+
831
+ const isEsModule = (path: string): boolean => {
832
+ if (/\.(mjs|[cm]?tsx?)$/.test(path)) return true
833
+ if (!/\.jsx?$/.test(path)) return false
834
+ for (let dir = dirname(path); dirname(dir) !== dir; dir = dirname(dir)) {
835
+ const file = join(dir, "package.json")
836
+ if (existsSync(file)) {
837
+ return (JSON.parse(readFileSync(file, "utf8")) as { type?: string }).type === "module"
838
+ }
839
+ }
840
+ return false
841
+ }
842
+
843
+ const inPackage = (path: string): boolean => /[\\/]node_modules[\\/]/.test(path)
844
+
845
+ const isPackage = (specifier: string): boolean => !isAbsolute(specifier) && !isBuiltin(specifier)
846
+
847
+ // rolldown reports each error with a colored code frame; a diagnostic takes
848
+ // its headline and location.
849
+ const bundleError = (e: unknown): unknown => {
850
+ const errors = (e as { errors?: readonly { message: string }[] } | null)?.errors
851
+ if (errors === undefined) return e
852
+ const lines = errors.map(({ message }) => {
853
+ const plain = stripVTControlCharacters(message)
854
+ const at = /\[ (.+:\d+:\d+) \]/.exec(plain)?.[1]
855
+ const headline = plain.split("\n")[0]!
856
+ return at === undefined ? headline : `${headline} (${at})`
857
+ })
858
+ return new Error(lines.join("\n"))
859
+ }
860
+
861
+ type Rolldown = typeof import("rolldown")
862
+ let rolldownModule: Rolldown | undefined
863
+ // Required at run time, never bundled: rolldown ships a native binding.
864
+ const requireRolldown = (): Rolldown => createRequire(import.meta.url)("rolldown") as Rolldown
865
+
722
866
  const isWorkflow = (value: unknown): boolean =>
723
- typeof value === "function" || flows.isFactoryWorkflow(value)
867
+ markedOptions(value) !== undefined || isFactoryWorkflow(value)
724
868
 
725
869
  const defaultExport = (exports: Record<string, unknown>): unknown => {
726
870
  const value = exports.default
727
871
  if (value === undefined) {
728
872
  throw new Error(
729
- "no default export — export the workflow an ordinary start runs (`gtd install` scaffolds one)",
873
+ "no default export — export the workflow an ordinary start runs; `gtd guide` says where to get one, or write your own",
730
874
  )
731
875
  }
732
- if (!isWorkflow(value)) throw new Error("the default export is not a function")
876
+ if (typeof value !== "function" && !isFactoryWorkflow(value)) {
877
+ throw new Error("the default export is not a function")
878
+ }
733
879
  return value
734
880
  }
735
881
 
736
- /** One exported workflow: a plain flow as-is, a factory validated and built. */
882
+ /** One exported workflow: a factory validated and built, a `workflow()` call's options checked alike. */
737
883
  const buildWorkflow = (
738
884
  name: string,
739
885
  value: unknown,
740
886
  file: LoadedFile,
741
887
  rejections: Rejection[],
742
888
  ): LoadedWorkflow | undefined => {
743
- const result = flows.configure(value)
744
- if (result === undefined)
745
- return { flow: value as Flow, file, defaults: file.defaults, envDefaults: file.envDefaults }
889
+ const result = configure(value)
890
+ if (result === undefined) {
891
+ // A plain function only gets here as the default export: a flow without options.
892
+ return checkedBuild(name, file, { ...markedOptions(value), flow: value as Flow })
893
+ }
746
894
  if (!result.ok) {
747
895
  for (const issue of result.issues) rejections.push(optionDiagnostic(name, issue, file))
748
896
  return undefined
749
897
  }
898
+ return checkedBuild(name, file, result.build)
899
+ }
900
+
901
+ const BUILD_KEYS: ReadonlySet<string> = new Set([
902
+ "flow",
903
+ "defaults",
904
+ "envDefaults",
905
+ "doors",
906
+ "base",
907
+ "skills",
908
+ "access",
909
+ "steering",
910
+ "summary",
911
+ ])
912
+
913
+ /** A build — a factory's, or a `workflow()` call's options with its flow — held to one set of rules. */
914
+ const checkedBuild = (
915
+ name: string,
916
+ file: LoadedFile,
917
+ build: flows.WorkflowBuild,
918
+ ): LoadedWorkflow => {
919
+ const what = (key: string): string => `workflow "${name}"'s "${key}"`
920
+ const unknown = Object.keys(build).filter((key) => !BUILD_KEYS.has(key))
921
+ if (unknown.length > 0) {
922
+ throw new Error(
923
+ `workflow "${name}" has unknown option(s) ${unknown.map((key) => JSON.stringify(key)).join(", ")} — known: ${[...BUILD_KEYS].filter((key) => key !== "flow").join(", ")}`,
924
+ )
925
+ }
926
+ if (typeof build.flow !== "function") throw new Error(`${what("flow")} is not a function`)
927
+ const defaults = stringRecord(build.defaults, what("defaults"))
928
+ const envDefaults = stringRecord(build.envDefaults, what("envDefaults"))
929
+ checkSettings(defaults, envDefaults, (kind) => `workflow "${name}" declares ${kind}`)
930
+ if (
931
+ build.doors !== undefined &&
932
+ (typeof build.doors !== "object" || build.doors === null || Array.isArray(build.doors))
933
+ ) {
934
+ throw new Error(`${what("doors")} is not a record of doors`)
935
+ }
750
936
  return {
751
- flow: result.build.flow,
937
+ flow: build.flow,
752
938
  file,
753
- ...builtSettings(name, file, result.build),
754
- doors: result.build.doors,
755
- base: result.build.base,
939
+ defaults,
940
+ envDefaults,
941
+ summary: optionalFunction<flows.Summary>(build.summary, what("summary")),
942
+ steering: stringRecord(build.steering, what("steering")),
943
+ skills: skillsFrom(build.skills, what("skills")),
944
+ access: accessFrom(build.access, what("access")),
945
+ doors: build.doors,
946
+ base: optionalFunction<flows.WorkflowBase>(build.base, what("base")),
756
947
  }
757
948
  }
758
949
 
759
- /** A factory's settings over its file's, held to the rules a file's own exports are. */
760
- const builtSettings = (name: string, file: LoadedFile, build: flows.WorkflowBuild): Settings => {
761
- checkSettings(
762
- build.defaults ?? {},
763
- build.envDefaults ?? {},
764
- (kind) => `workflow "${name}" builds ${kind}`,
765
- )
766
- const defaults = { ...file.defaults, ...build.defaults }
767
- const envDefaults = { ...file.envDefaults, ...build.envDefaults }
768
- checkSettings(defaults, envDefaults, (kind) => `workflow "${name}" builds ${kind}`)
769
- return { defaults, envDefaults }
770
- }
771
-
772
950
  /** A rejected factory option, reported at `gtd.config.ts`. */
773
- const optionDiagnostic = (
774
- workflow: string,
775
- issue: flows.OptionIssue,
776
- file: LoadedFile,
777
- ): Rejection => ({
951
+ const optionDiagnostic = (workflow: string, issue: OptionIssue, file: LoadedFile): Rejection => ({
778
952
  workflow,
779
953
  severity: "error",
780
954
  path: issue.path,
@@ -905,7 +1079,7 @@ const firstStep = (
905
1079
  const empty = watchedEmptyTree()
906
1080
  const vars = watchedEmptySettings()
907
1081
  const env = watchedEmptySettings()
908
- const outcome = yield* Effect.either(
1082
+ const outcome = yield* Effect.result(
909
1083
  replayToFirstStep(loaded, vars.settings, env.settings, empty.tree),
910
1084
  )
911
1085
  const reads = [...settingReads("process", vars), ...settingReads("environment", env)]
@@ -916,8 +1090,8 @@ const firstStep = (
916
1090
  ),
917
1091
  )
918
1092
  }
919
- if (outcome._tag === "Left") return yield* Effect.fail(outcome.left)
920
- const name = outcome.right
1093
+ if (outcome._tag === "Failure") return yield* Effect.fail(outcome.failure)
1094
+ const name = outcome.success
921
1095
  if (!empty.read()) return name
922
1096
  const atHead = yield* replayToFirstStep(loaded, vars.settings, env.settings, head)
923
1097
  if (atHead === name) return name
@@ -936,9 +1110,8 @@ interface ConfigServiceOperations {
936
1110
  >
937
1111
  }
938
1112
 
939
- export class ConfigService extends Context.Tag("ConfigService")<
940
- ConfigService,
941
- ConfigServiceOperations
942
- >() {
1113
+ export class ConfigService extends Context.Service<ConfigService, ConfigServiceOperations>()(
1114
+ "ConfigService",
1115
+ ) {
943
1116
  static Live = Layer.succeed(ConfigService, { load })
944
1117
  }