@pmelab/gtd 59.0.1 → 60.0.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 (83) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +37 -33
  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-07Z_ENch.mjs → testkit-BvecVjQ0.mjs} +570 -390
  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 +751 -645
  10. package/dist/steering.mjs +2 -2
  11. package/dist/testkit/gherkin.mjs +245 -36
  12. package/dist/testkit.mjs +3 -2
  13. package/dist/web/main.js +234 -232
  14. package/dist/wire.mjs +2 -2
  15. package/dist/workflow-kit.mjs +3 -3
  16. package/package.json +1 -1
  17. package/skills/authoring/SKILL.md +66 -60
  18. package/src/cli/Cli.test.ts +183 -182
  19. package/src/cli/Cli.ts +164 -186
  20. package/src/cli/History.ts +9 -4
  21. package/src/cli/Judges.ts +1 -1
  22. package/src/cli/Output.test.ts +35 -0
  23. package/src/cli/Output.ts +31 -1
  24. package/src/cli/Program.test.ts +65 -79
  25. package/src/cli/Program.ts +157 -118
  26. package/src/edge/Edge.test.ts +64 -56
  27. package/src/edge/Edge.ts +3 -3
  28. package/src/flows/factory.test.ts +14 -0
  29. package/src/flows/factory.ts +22 -5
  30. package/src/flows/helpers.test.ts +35 -0
  31. package/src/flows/helpers.ts +19 -10
  32. package/src/flows/index.test.ts +66 -0
  33. package/src/flows/index.ts +109 -5
  34. package/src/flows/internal.ts +7 -0
  35. package/src/flows/runtime.test.ts +192 -1
  36. package/src/flows/runtime.ts +188 -54
  37. package/src/gherkin/git-control.feature +6 -6
  38. package/src/gherkin/index.test.ts +127 -0
  39. package/src/gherkin/index.ts +370 -51
  40. package/src/gherkin/messages.test.ts +38 -4
  41. package/src/gherkin/messages.ts +21 -4
  42. package/src/gherkin/more-steps.feature +170 -0
  43. package/src/gherkin/scenario-end.feature +101 -0
  44. package/src/gherkin/testkit.feature +83 -7
  45. package/src/install/Install.test.ts +7 -7
  46. package/src/install/Install.ts +8 -8
  47. package/src/replay/Replay.test.ts +13 -2
  48. package/src/replay/Replay.ts +19 -30
  49. package/src/replay/judgeDiffShape.test.ts +1 -1
  50. package/src/steering/Footnotes.test.ts +1 -1
  51. package/src/steering/SteeringFormats.test.ts +1 -1
  52. package/src/steering/freeform.test.ts +1 -1
  53. package/src/steering/index.test.ts +27 -1
  54. package/src/steering/index.ts +6 -104
  55. package/src/steering/internal.ts +11 -0
  56. package/src/steering/qa.test.ts +7 -2
  57. package/src/steering/reads.test.ts +42 -0
  58. package/src/steering/reads.ts +43 -0
  59. package/src/steering/registry.ts +93 -0
  60. package/src/steering/review.test.ts +2 -2
  61. package/src/step/planStart.test.ts +18 -16
  62. package/src/step/planStart.ts +9 -9
  63. package/src/testkit/TestRepo.test.ts +245 -7
  64. package/src/testkit/TestRepo.ts +271 -40
  65. package/src/testkit/imports.test.ts +58 -0
  66. package/src/testkit/imports.ts +102 -0
  67. package/src/testkit/index.test.ts +28 -0
  68. package/src/testkit/index.ts +14 -2
  69. package/src/twins/EmittedScriptRecognizer.ts +2 -6
  70. package/src/twins/EmittedScripts.test.ts +4 -2
  71. package/src/twins/InMemRepo.ts +1 -0
  72. package/src/wire/Script.ts +11 -0
  73. package/src/wire/WireSchema.test.ts +3 -0
  74. package/src/wire/golden/1/script.json +4 -0
  75. package/src/wire/index.ts +1 -0
  76. package/src/workflow/SteeringFormats.test.ts +1 -1
  77. package/src/workflow/compile.ts +2 -2
  78. package/src/workflow/discovery.test.ts +3 -3
  79. package/src/workflow/load.test.ts +256 -116
  80. package/src/workflow/load.ts +169 -146
  81. package/src/workflow-kit/index.test.ts +25 -0
  82. package/src/workflow-kit/index.ts +37 -4
  83. package/src/workflow-kit/review.ts +2 -2
@@ -4,10 +4,12 @@ import { dirname, join } from "node:path"
4
4
  import { fileURLToPath, pathToFileURL } from "node:url"
5
5
  import { Context, Effect, Layer } from "effect"
6
6
  import { GtdError, Narrator } from "../platform/index.js"
7
- import { replay, type TreeView } from "../replay/index.js"
7
+ import { accessShapeFault, replay, type TreeView } from "../replay/index.js"
8
+ // The published facade: what a config's `@pmelab/gtd/flows` import is served.
8
9
  import * as flows from "../flows/index.js"
10
+ import { configure, isFactoryWorkflow, markedOptions, type OptionIssue } from "../flows/internal.js"
9
11
  import type { StateMode, WorkflowDefinition } from "./Workflow.js"
10
- import type { Door, Flow } from "../flows/index.js"
12
+ import type { Door, Flow, ScopeAccess } from "../flows/index.js"
11
13
  import { Host, Workspace, type WorkspaceOps } from "../platform/index.js"
12
14
  import { compileConfig, type CompiledConfig } from "./compile.js"
13
15
  import { SETTING_NAME_RULE, isSettingName } from "./vars.js"
@@ -33,10 +35,10 @@ export interface ConfigOperations {
33
35
  readonly workflow: WorkflowDefinition
34
36
  /** The default, resolved like a named one; its name is what status shows: the named export it also is, else `"default"`. */
35
37
  readonly defaultWorkflow: ResolvedWorkflow
36
- /** Every name `gtd --workflow` accepts, sorted — never `"default"`. */
38
+ /** Every workflow name `gtd start` accepts, sorted — never `"default"`. */
37
39
  readonly workflowNames: readonly string[]
38
40
  readonly workflowNamed: (name: string) => ResolvedWorkflow | undefined
39
- /** Every door `gtd door` accepts. A door's `workflow` resolves through `workflowNamed`. */
41
+ /** Every door `gtd start` accepts. A door's `workflow` resolves through `workflowNamed`. */
40
42
  readonly doors: ReadonlyMap<string, Door>
41
43
  /** `config.ui` (absent when unconfigured) — `gtd ui` and its CLI flags read it. */
42
44
  readonly ui?: flows.UiSettings
@@ -87,8 +89,8 @@ export const refuseLegacyRc: Effect.Effect<void, GtdError, Host | ConfigDiscover
87
89
  "gtd: config:",
88
90
  ...found.map((file) => ` - ${file}: no longer read — delete it`),
89
91
  " 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",
92
+ " vars → an option of the workflow factory in gtd.config.ts, or `workflow(flow, { defaults })`",
93
+ " env → an option of the workflow factory in gtd.config.ts, or `workflow(flow, { envDefaults })`",
92
94
  " judge → ~/.config/gtd/judge.json, or GTD_JUDGE_PROVIDER / GTD_JUDGE_MODEL",
93
95
  " modes, ui, skills, access → the `config` export of gtd.config.ts (`defineConfig`)",
94
96
  " $schema → nothing; drop it",
@@ -115,7 +117,7 @@ export const load: Effect.Effect<
115
117
  const missing: Diagnostic = {
116
118
  severity: "error",
117
119
  path: [],
118
- message: `no workflows configured: no ${WORKFLOW_MODULE} here or above — gtd ships no workflow; \`gtd install\` says where to get one`,
120
+ message: `no workflows configured: no ${WORKFLOW_MODULE} here or above — gtd ships no workflow; \`gtd guide\` says where to get one`,
119
121
  origin: host.root,
120
122
  }
121
123
  return yield* Effect.fail(configError([missing]))
@@ -142,17 +144,15 @@ export const load: Effect.Effect<
142
144
  }
143
145
 
144
146
  const initial = yield* firstStep(loaded.default, headTree(yield* Workspace))
145
- const steering = loaded.file.steering as Record<string, StateMode>
146
147
  const define = (entry: LoadedWorkflow): WorkflowDefinition => ({
147
148
  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,
149
+ summary: entry.summary,
150
+ base: (w, v) => entry.base?.(w, v),
151
+ steering: entry.steering as Record<string, StateMode>,
152
152
  modes: compiled.modes,
153
- skills: entry.file.skills,
153
+ skills: entry.skills,
154
154
  configuredSkills: compiled.skills,
155
- access: entry.file.access,
155
+ access: entry.access,
156
156
  configuredAccess: compiled.access,
157
157
  skillsOrigin: entry.file.origin,
158
158
  initial,
@@ -197,10 +197,7 @@ interface Settings {
197
197
  readonly envDefaults: Readonly<Record<string, string>>
198
198
  }
199
199
 
200
- interface LoadedFile
201
- extends
202
- Pick<WorkflowDefinition, "summary" | "base" | "steering" | "skills" | "access">,
203
- Settings {
200
+ interface LoadedFile {
204
201
  readonly origin: string
205
202
  /**
206
203
  * Every real file this module's evaluation touched — `origin` itself plus
@@ -214,8 +211,9 @@ interface LoadedFile
214
211
  readonly dependencyFiles: readonly string[]
215
212
  }
216
213
 
217
- /** A startable workflow; its settings are its file's, under whatever its factory declared. */
218
- interface LoadedWorkflow extends Settings {
214
+ /** A startable workflow, built from a factory or a `workflow()` call's options. */
215
+ interface LoadedWorkflow
216
+ extends Settings, Pick<WorkflowDefinition, "summary" | "steering" | "skills" | "access"> {
219
217
  readonly flow: Flow
220
218
  readonly file: LoadedFile
221
219
  readonly doors?: flows.WorkflowDoors | undefined
@@ -384,75 +382,105 @@ const requiredOnUse = (
384
382
  })
385
383
  }
386
384
 
387
- const stringRecord = (
388
- exports: Record<string, unknown>,
389
- name: string,
390
- ): Readonly<Record<string, string>> => {
391
- const value = exports[name] ?? {}
385
+ /** `what` names the value in an error: `workflow "x"'s "steering"`. */
386
+ const stringRecord = (value: unknown, what: string): Readonly<Record<string, string>> => {
387
+ const record = value ?? {}
392
388
  if (
393
- typeof value !== "object" ||
394
- value === null ||
395
- Object.values(value).some((entry) => typeof entry !== "string")
389
+ typeof record !== "object" ||
390
+ record === null ||
391
+ Object.values(record).some((entry) => typeof entry !== "string")
396
392
  ) {
397
- throw new Error(`the "${name}" export is not a record of strings`)
393
+ throw new Error(`${what} is not a record of strings`)
398
394
  }
399
- return value as Readonly<Record<string, string>>
395
+ return record as Readonly<Record<string, string>>
400
396
  }
401
397
 
402
398
  const isStringArray = (v: unknown): v is readonly string[] =>
403
399
  Array.isArray(v) && v.every((entry) => typeof entry === "string")
404
400
 
405
- const checkSkillsRecord = (value: unknown): Readonly<Record<string, readonly string[]>> => {
401
+ const checkSkillsRecord = (
402
+ value: unknown,
403
+ what: string,
404
+ ): Readonly<Record<string, readonly string[]>> => {
406
405
  if (
407
406
  typeof value !== "object" ||
408
407
  value === null ||
409
408
  Object.values(value).some((entry) => !isStringArray(entry))
410
409
  ) {
411
- throw new Error(`the "skills" export is not a record of skill-name arrays`)
410
+ throw new Error(`${what} is not a record of skill-name arrays`)
412
411
  }
413
412
  return value as Readonly<Record<string, readonly string[]>>
414
413
  }
415
414
 
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"] ?? {}
415
+ /** A workflow's `skills` option — a scope full name -> skill list record, or a function of the vars returning one. */
416
+ const skillsFrom = (value: unknown, what: string): WorkflowDefinition["skills"] => {
419
417
  if (typeof value === "function") {
420
- return (vars) => checkSkillsRecord((value as (v: typeof vars) => unknown)(vars))
418
+ return (vars) => checkSkillsRecord((value as (v: typeof vars) => unknown)(vars), what)
421
419
  }
422
- const record = checkSkillsRecord(value)
420
+ const record = checkSkillsRecord(value ?? {}, what)
423
421
  return () => record
424
422
  }
425
423
 
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"] ?? {}
424
+ const checkAccessRecord = (value: unknown, what: string): Readonly<Record<string, ScopeAccess>> => {
425
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
426
+ throw new Error(
427
+ `${what} is not a record of scope access (scope -> { read?, write? }) or a function returning one`,
428
+ )
429
+ }
430
+ for (const [scope, access] of Object.entries(value)) {
431
+ const fault = accessShapeFault(access)
432
+ if (fault !== undefined) throw new Error(`${what}: scope "${scope}": ${fault}`)
433
+ }
434
+ return value as Readonly<Record<string, ScopeAccess>>
435
+ }
436
+
437
+ /**
438
+ * A workflow's `access` option — a scope full name -> `{ read?, write? }`
439
+ * record, checked now, or a function of the vars returning one, checked when
440
+ * the process's settings are known.
441
+ */
442
+ const accessFrom = (value: unknown, what: string): WorkflowDefinition["access"] => {
429
443
  if (typeof value === "function") {
430
444
  return (vars) => (value as (v: typeof vars) => never)(vars)
431
445
  }
432
- return () => value as never
446
+ const record = checkAccessRecord(value ?? {}, what)
447
+ return () => record
433
448
  }
434
449
 
435
- const optionalFunction = <T>(exports: Record<string, unknown>, name: string): T | undefined => {
436
- const value = exports[name]
450
+ const optionalFunction = <T>(value: unknown, what: string): T | undefined => {
437
451
  if (value === undefined) return undefined
438
- if (typeof value !== "function") throw new Error(`the "${name}" export is not a function`)
452
+ if (typeof value !== "function") throw new Error(`${what} is not a function`)
439
453
  return value as T
440
454
  }
441
455
 
442
- const RESERVED_EXPORTS: ReadonlySet<string> = new Set([
443
- "default",
444
- "summary",
445
- "base",
456
+ // What each file-level export of earlier versions became: an option of the
457
+ // workflow it configured. Exporting one is a load error, never silently ignored.
458
+ const RETIRED_EXPORTS: ReadonlySet<string> = new Set([
446
459
  "defaults",
447
460
  "envDefaults",
448
461
  "steering",
449
462
  "skills",
450
463
  "access",
451
464
  "doors",
452
- "config",
465
+ "summary",
466
+ "base",
453
467
  ])
454
468
 
455
- /** The rules every declared setting meets, whether a file's export or a factory's build. */
469
+ /** Names that are never a workflow. */
470
+ const RESERVED_EXPORTS: ReadonlySet<string> = new Set(["default", "config", ...RETIRED_EXPORTS])
471
+
472
+ const retiredExports = (exports: Record<string, unknown>, origin: string): Diagnostic[] =>
473
+ Object.keys(exports)
474
+ .filter((name) => RETIRED_EXPORTS.has(name))
475
+ .sort()
476
+ .map((name) => ({
477
+ severity: "error",
478
+ path: [name],
479
+ origin,
480
+ 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`,
481
+ }))
482
+
483
+ /** The rules every declared setting meets. */
456
484
  const checkSettings = (
457
485
  defaults: Readonly<Record<string, unknown>>,
458
486
  envDefaults: Readonly<Record<string, unknown>>,
@@ -481,28 +509,6 @@ const checkSettings = (
481
509
  }
482
510
  }
483
511
 
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
512
  const DOOR_NAME_RULE = /^[a-z][a-z0-9-]*$/
507
513
 
508
514
  /** Why one door's `args` are malformed, or `undefined` when they are well-formed. */
@@ -532,27 +538,12 @@ const doorProblem = (name: string, door: unknown): string | undefined => {
532
538
  return argsProblem(args) ?? (varsOk ? undefined : `has "vars" that is not a function`)
533
539
  }
534
540
 
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
541
  /**
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.
542
+ * Each exported workflow's own doors, onto the name it is exported under. A
543
+ * door several workflows build and none is exported under is a clash: no door.
552
544
  */
553
545
  const derivedDoors = (
554
546
  catalogue: ReadonlyMap<string, LoadedWorkflow>,
555
- declared: ReadonlyMap<string, Door>,
556
547
  ): {
557
548
  readonly doors: ReadonlyMap<string, Door>
558
549
  readonly clashes: ReadonlyMap<string, readonly string[]>
@@ -572,45 +563,29 @@ const derivedDoors = (
572
563
  for (const [name, built] of builders) {
573
564
  const owner = ownerOf(name, built)
574
565
  if (owner !== undefined) doors.set(name, owner)
575
- else if (!declared.has(name))
566
+ else
576
567
  clashes.set(
577
568
  name,
578
569
  built.map((door) => door.workflow),
579
570
  )
580
571
  }
581
- return { doors: new Map([...doors, ...declared]), clashes }
572
+ return { doors, clashes }
582
573
  }
583
574
 
584
575
  /** The export named after the door owns it, else the sole builder; a clash of several builds no door. */
585
576
  const ownerOf = (name: string, built: readonly Door[]): Door | undefined =>
586
577
  built.find((door) => door.workflow === name) ?? (built.length === 1 ? built[0] : undefined)
587
578
 
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
579
  const namespaceOf = (exported: unknown): Record<string, unknown> =>
605
580
  (typeof exported === "object" && exported !== null ? exported : {}) as Record<string, unknown>
606
581
 
607
582
  /**
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.
583
+ * Evaluate `gtd.config.ts`. Every export but the reserved names that went
584
+ * through `workflow()` or is a factory workflow is a workflow named by its
585
+ * export. `cache` is jiti's OWN per-call module cache (unrelated to the
586
+ * instance's `moduleCache: false`) — passing an empty object in has the side
587
+ * effect of collecting every real file jiti resolved evaluating this module,
588
+ * module AND every relative import it pulled in, transitively.
614
589
  */
615
590
  const loadWorkflows = (module: WorkflowModule, shipped: ShippedModules): Loaded => {
616
591
  const { exports: evaluated, files } =
@@ -618,7 +593,7 @@ const loadWorkflows = (module: WorkflowModule, shipped: ShippedModules): Loaded
618
593
  ? evaluateFromDisk(module, shipped)
619
594
  : evaluateRead(module, module.read, shipped)
620
595
  const exports = namespaceOf(evaluated)
621
- const file = fileFrom(exports, module.filepath, files)
596
+ const file: LoadedFile = { origin: module.filepath, dependencyFiles: files }
622
597
  const exported = Object.entries(exports).filter(
623
598
  ([name, value]) => !RESERVED_EXPORTS.has(name) && isWorkflow(value),
624
599
  )
@@ -643,20 +618,23 @@ const loadWorkflows = (module: WorkflowModule, shipped: ShippedModules): Loaded
643
618
  const derived =
644
619
  rejections.length > 0
645
620
  ? { doors: new Map<string, Door>(), clashes: new Map<string, readonly string[]>() }
646
- : derivedDoors(catalogue, doorsFrom(exports))
621
+ : derivedDoors(catalogue)
622
+ const doors = new Map([...derived.doors].sort(([a], [b]) => a.localeCompare(b)))
647
623
  return {
648
624
  file,
649
625
  catalogue,
650
- doors: checkDoors(derived.doors, catalogue),
626
+ doors,
651
627
  default: defaultWorkflow && { ...defaultWorkflow, name: defaultName },
652
628
  config: compileConfig(exports["config"], module.filepath),
653
629
  diagnostics: [
630
+ ...retiredExports(exports, file.origin),
654
631
  ...nameRejections(rejections),
632
+ ...shadowingDoors(doors, catalogue, file.origin),
655
633
  ...[...derived.clashes].map(([name, builders]): Diagnostic => {
656
634
  const names = [...builders].sort().map((b) => JSON.stringify(b))
657
635
  return {
658
636
  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`,
637
+ 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
638
  path: ["doors", name],
661
639
  origin: file.origin,
662
640
  }
@@ -665,6 +643,21 @@ const loadWorkflows = (module: WorkflowModule, shipped: ShippedModules): Loaded
665
643
  }
666
644
  }
667
645
 
646
+ /** `gtd start <name>` takes the door, so a door named like a workflow it does not start hides that workflow. */
647
+ const shadowingDoors = (
648
+ doors: ReadonlyMap<string, Door>,
649
+ catalogue: ReadonlyMap<string, unknown>,
650
+ origin: string,
651
+ ): Diagnostic[] =>
652
+ [...doors]
653
+ .filter(([name, door]) => catalogue.has(name) && door.workflow !== name)
654
+ .map(([name, door]) => ({
655
+ severity: "warning",
656
+ 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`,
657
+ path: ["doors", name],
658
+ origin,
659
+ }))
660
+
668
661
  const evaluateFromDisk = (
669
662
  module: WorkflowModule,
670
663
  shipped: ShippedModules,
@@ -720,61 +713,91 @@ const evaluateRead = (
720
713
  }
721
714
 
722
715
  const isWorkflow = (value: unknown): boolean =>
723
- typeof value === "function" || flows.isFactoryWorkflow(value)
716
+ markedOptions(value) !== undefined || isFactoryWorkflow(value)
724
717
 
725
718
  const defaultExport = (exports: Record<string, unknown>): unknown => {
726
719
  const value = exports.default
727
720
  if (value === undefined) {
728
721
  throw new Error(
729
- "no default export — export the workflow an ordinary start runs (`gtd install` scaffolds one)",
722
+ "no default export — export the workflow an ordinary start runs; `gtd guide` says where to get one, or write your own",
730
723
  )
731
724
  }
732
- if (!isWorkflow(value)) throw new Error("the default export is not a function")
725
+ if (typeof value !== "function" && !isFactoryWorkflow(value)) {
726
+ throw new Error("the default export is not a function")
727
+ }
733
728
  return value
734
729
  }
735
730
 
736
- /** One exported workflow: a plain flow as-is, a factory validated and built. */
731
+ /** One exported workflow: a factory validated and built, a `workflow()` call's options checked alike. */
737
732
  const buildWorkflow = (
738
733
  name: string,
739
734
  value: unknown,
740
735
  file: LoadedFile,
741
736
  rejections: Rejection[],
742
737
  ): 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 }
738
+ const result = configure(value)
739
+ if (result === undefined) {
740
+ // A plain function only gets here as the default export: a flow without options.
741
+ return checkedBuild(name, file, { ...markedOptions(value), flow: value as Flow })
742
+ }
746
743
  if (!result.ok) {
747
744
  for (const issue of result.issues) rejections.push(optionDiagnostic(name, issue, file))
748
745
  return undefined
749
746
  }
747
+ return checkedBuild(name, file, result.build)
748
+ }
749
+
750
+ const BUILD_KEYS: ReadonlySet<string> = new Set([
751
+ "flow",
752
+ "defaults",
753
+ "envDefaults",
754
+ "doors",
755
+ "base",
756
+ "skills",
757
+ "access",
758
+ "steering",
759
+ "summary",
760
+ ])
761
+
762
+ /** A build — a factory's, or a `workflow()` call's options with its flow — held to one set of rules. */
763
+ const checkedBuild = (
764
+ name: string,
765
+ file: LoadedFile,
766
+ build: flows.WorkflowBuild,
767
+ ): LoadedWorkflow => {
768
+ const what = (key: string): string => `workflow "${name}"'s "${key}"`
769
+ const unknown = Object.keys(build).filter((key) => !BUILD_KEYS.has(key))
770
+ if (unknown.length > 0) {
771
+ throw new Error(
772
+ `workflow "${name}" has unknown option(s) ${unknown.map((key) => JSON.stringify(key)).join(", ")} — known: ${[...BUILD_KEYS].filter((key) => key !== "flow").join(", ")}`,
773
+ )
774
+ }
775
+ if (typeof build.flow !== "function") throw new Error(`${what("flow")} is not a function`)
776
+ const defaults = stringRecord(build.defaults, what("defaults"))
777
+ const envDefaults = stringRecord(build.envDefaults, what("envDefaults"))
778
+ checkSettings(defaults, envDefaults, (kind) => `workflow "${name}" declares ${kind}`)
779
+ if (
780
+ build.doors !== undefined &&
781
+ (typeof build.doors !== "object" || build.doors === null || Array.isArray(build.doors))
782
+ ) {
783
+ throw new Error(`${what("doors")} is not a record of doors`)
784
+ }
750
785
  return {
751
- flow: result.build.flow,
786
+ flow: build.flow,
752
787
  file,
753
- ...builtSettings(name, file, result.build),
754
- doors: result.build.doors,
755
- base: result.build.base,
788
+ defaults,
789
+ envDefaults,
790
+ summary: optionalFunction<flows.Summary>(build.summary, what("summary")),
791
+ steering: stringRecord(build.steering, what("steering")),
792
+ skills: skillsFrom(build.skills, what("skills")),
793
+ access: accessFrom(build.access, what("access")),
794
+ doors: build.doors,
795
+ base: optionalFunction<flows.WorkflowBase>(build.base, what("base")),
756
796
  }
757
797
  }
758
798
 
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
799
  /** A rejected factory option, reported at `gtd.config.ts`. */
773
- const optionDiagnostic = (
774
- workflow: string,
775
- issue: flows.OptionIssue,
776
- file: LoadedFile,
777
- ): Rejection => ({
800
+ const optionDiagnostic = (workflow: string, issue: OptionIssue, file: LoadedFile): Rejection => ({
778
801
  workflow,
779
802
  severity: "error",
780
803
  path: issue.path,
@@ -0,0 +1,25 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import * as kit from "./index.js"
3
+
4
+ describe("@pmelab/gtd/workflow-kit", () => {
5
+ it("publishes its helpers by name", () => {
6
+ expect(Object.keys(kit).sort()).toEqual(
7
+ [
8
+ "assertionLines",
9
+ "escalation",
10
+ "fixFindings",
11
+ "gate",
12
+ "healthy",
13
+ "lensEntriesOf",
14
+ "lensOf",
15
+ "lensScopes",
16
+ "lensesOf",
17
+ "qualityLap",
18
+ "removedAssertions",
19
+ "review",
20
+ "reviewToHandOff",
21
+ "untilSettled",
22
+ ].sort(),
23
+ )
24
+ })
25
+ })
@@ -1,7 +1,40 @@
1
1
  // Building blocks workflows share. Policy-free: every skill list,
2
2
  // lens, prompt, step, path and threshold arrives as a parameter, so a new
3
3
  // workflow composes these without inheriting the shipped one's choices.
4
- export * from "./health.js"
5
- export * from "./quality.js"
6
- export * from "./scenarios.js"
7
- export * from "./review.js"
4
+ export {
5
+ escalation,
6
+ gate,
7
+ healthy,
8
+ untilSettled,
9
+ type EscalationCount,
10
+ type EscalationSteps,
11
+ type Health,
12
+ type RetryVerdict,
13
+ } from "./health.js"
14
+ export {
15
+ fixFindings,
16
+ lensEntriesOf,
17
+ lensOf,
18
+ lensesOf,
19
+ lensScopes,
20
+ qualityLap,
21
+ type FindingsFix,
22
+ type LensEntry,
23
+ } from "./quality.js"
24
+ export {
25
+ assertionLines,
26
+ removedAssertions,
27
+ type Assertion,
28
+ type RemovedAssertions,
29
+ } from "./scenarios.js"
30
+ export {
31
+ review,
32
+ reviewToHandOff,
33
+ type CollectRound,
34
+ type HandOffPolicy,
35
+ type Requested,
36
+ type ReviewOutcome,
37
+ type ReviewPolicy,
38
+ type ReviewRisk,
39
+ type Verdict,
40
+ } from "./review.js"
@@ -195,13 +195,13 @@ const converse = async (
195
195
  }
196
196
 
197
197
  /** What `finish` hands back to `review`: an outcome, or another lap at the gate. */
198
- type Finish =
198
+ export type Finish =
199
199
  | ReviewOutcome
200
200
  | { readonly next: "await"; readonly reviewed: string }
201
201
  | { readonly next: "rereview"; readonly carry: string | undefined }
202
202
 
203
203
  /** What a round's routing needs from `finish`: its closing step and the ways it ends. */
204
- interface Round {
204
+ export interface Round {
205
205
  readonly round: string
206
206
  readonly close: () => Promise<void>
207
207
  readonly outcome: (verdict: "signoff" | "feedback") => ReviewOutcome