@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
@@ -4,8 +4,7 @@ import { join } from "node:path"
4
4
  import { tmpdir } from "node:os"
5
5
  import { afterEach, beforeEach, describe, expect, it } from "vitest"
6
6
  import { Cause, Effect, Exit, Layer } from "effect"
7
- import { NodeContext } from "@effect/platform-node"
8
- import { Narrator, GitService, Host, Workspace } from "../platform/index.js"
7
+ import { Narrator, GitService, Host, Workspace, NodeServices } from "../platform/index.js"
9
8
  import {
10
9
  ConfigDiscovery,
11
10
  ConfigService,
@@ -23,7 +22,7 @@ type Env = Readonly<Record<string, string>>
23
22
 
24
23
  const baseLayer = (dir: string, env: Env = {}) => {
25
24
  const hostLayer = Host.layer({ root: dir, home: homeDir, env })
26
- const gitLayer = GitService.Live.pipe(Layer.provide(Layer.merge(hostLayer, NodeContext.layer)))
25
+ const gitLayer = GitService.Live.pipe(Layer.provide(Layer.merge(hostLayer, NodeServices.layer)))
27
26
  const workspaceLayer = Workspace.Live.pipe(Layer.provide(Layer.merge(hostLayer, gitLayer)))
28
27
  return Layer.mergeAll(hostLayer, gitLayer, workspaceLayer)
29
28
  }
@@ -72,16 +71,11 @@ let projectDir: string
72
71
 
73
72
  // A config whose default workflow rests at `idle`, declaring a process
74
73
  // setting, an environment setting and the scopes its turns run under.
75
- const BASE_CONFIG = `import { human } from "@pmelab/gtd/flows"
74
+ const BASE_CONFIG = `import { human, workflow } from "@pmelab/gtd/flows"
76
75
 
77
- export default async () => {
78
- await human("idle", { message: "hi" })
79
- }
80
-
81
- export const defaults = { qualityReviews: "" }
82
- export const envDefaults = { testCommand: "npm test" }
83
-
84
- export const skills = (vars) => ({
76
+ const defaults = { qualityReviews: "" }
77
+ const envDefaults = { testCommand: "npm test" }
78
+ const skills = (vars) => ({
85
79
  build: [],
86
80
  "build.fix.quality": [],
87
81
  "build.review": [],
@@ -93,6 +87,11 @@ export const skills = (vars) => ({
93
87
  .map((lens) => [\`build.quality.\${lens}\`, []]),
94
88
  ),
95
89
  })
90
+
91
+ export default workflow(async () => {
92
+ await human("idle", { message: "hi" })
93
+ }, { defaults, envDefaults, skills })
94
+
96
95
  `
97
96
 
98
97
  // Workflow factories a config imports from a sibling module: string options
@@ -190,6 +189,17 @@ const minimalWorkflow = (first: string) =>
190
189
  ``,
191
190
  ].join("\n")
192
191
 
192
+ // The same one-step workflow, carrying `options` (source text) as its build options.
193
+ const optionedWorkflow = (first: string, options: string) =>
194
+ [
195
+ `import { human, workflow } from "@pmelab/gtd/flows"`,
196
+ ``,
197
+ `export default workflow(async () => {`,
198
+ ` await human("${first}")`,
199
+ `}, ${options})`,
200
+ ``,
201
+ ].join("\n")
202
+
193
203
  const withConfig = (config: string) =>
194
204
  writeFileSync(
195
205
  join(projectDir, "gtd.config.ts"),
@@ -205,11 +215,11 @@ describe("ConfigService", () => {
205
215
  expect(cfg.defaultWorkflow.vars["testCommand"]).toBeUndefined()
206
216
  })
207
217
 
208
- it("with no gtd.config.ts anywhere: fails, pointing at gtd install", async () => {
218
+ it("with no gtd.config.ts anywhere: fails, pointing at gtd guide", async () => {
209
219
  rmSync(join(projectDir, "gtd.config.ts"))
210
220
 
211
221
  await expect(getConfig()).rejects.toThrow(
212
- /no workflows configured: no gtd\.config\.ts here or above — gtd ships no workflow; `gtd install` says where to get one/,
222
+ /no workflows configured: no gtd\.config\.ts here or above — gtd ships no workflow; `gtd guide` says where to get one/,
213
223
  )
214
224
  })
215
225
 
@@ -307,7 +317,7 @@ describe("ConfigService", () => {
307
317
  it("a workflow declaring a name in both defaults and envDefaults fails to load", async () => {
308
318
  writeFileSync(
309
319
  join(projectDir, "gtd.config.ts"),
310
- `${minimalWorkflow("first")}export const defaults = { x: "1" }\nexport const envDefaults = { x: "2" }\n`,
320
+ optionedWorkflow("first", `{ defaults: { x: "1" }, envDefaults: { x: "2" } }`),
311
321
  )
312
322
 
313
323
  await expect(getConfig()).rejects.toThrow(`"x" declared in both "defaults" and "envDefaults"`)
@@ -375,16 +385,16 @@ describe("ConfigService", () => {
375
385
  expect(cfg.workflow.initial).toBe("custom-idle")
376
386
  })
377
387
 
378
- it("reads `defaults`, `summary`, `base` and `steering` off the module and ignores its other exports", async () => {
388
+ it("reads `defaults`, `summary`, `base` and `steering` off workflow()'s options and ignores other exports", async () => {
379
389
  writeFileSync(
380
390
  join(projectDir, "gtd.config.ts"),
381
391
  [
382
- minimalWorkflow("first"),
383
- `export const defaults = { greeting: "hi" }`,
384
- `export const summary = () => "sum"`,
385
- `export const base = () => undefined`,
386
- `export const steering = { ".gtd/PLAN.md": "qa" }`,
392
+ optionedWorkflow(
393
+ "first",
394
+ `{ defaults: { greeting: "hi" }, summary: () => "sum", base: () => undefined, steering: { ".gtd/PLAN.md": "qa" } }`,
395
+ ),
387
396
  `export const helper = 42`,
397
+ `export const plain = async () => {}`,
388
398
  ``,
389
399
  ].join("\n"),
390
400
  )
@@ -396,8 +406,23 @@ describe("ConfigService", () => {
396
406
  expect(typeof cfg.workflow.summary).toBe("function")
397
407
  expect(typeof cfg.workflow.base).toBe("function")
398
408
  expect(cfg.workflow.steering).toEqual({ ".gtd/PLAN.md": "qa" })
409
+ expect(cfg.workflowNames).toEqual([])
399
410
  })
400
411
 
412
+ it.each(["defaults", "envDefaults", "steering", "skills", "access", "doors", "summary", "base"])(
413
+ "refuses a file-level %s export, naming its replacement",
414
+ async (name) => {
415
+ writeFileSync(
416
+ join(projectDir, "gtd.config.ts"),
417
+ `${minimalWorkflow("first")}export const ${name} = {}\n`,
418
+ )
419
+
420
+ await expect(getConfig()).rejects.toThrow(
421
+ `gtd.config.ts: ${name}: the "${name}" export is no longer read — pass it to the workflow it configures: \`workflow(flow, { ${name} })\`, or the build a workflow factory returns`,
422
+ )
423
+ },
424
+ )
425
+
401
426
  it("the fingerprint covers gtd.config.ts AND a sibling module it imports from", async () => {
402
427
  writeFileSync(
403
428
  join(projectDir, "steps.ts"),
@@ -405,7 +430,9 @@ describe("ConfigService", () => {
405
430
  )
406
431
  writeFileSync(
407
432
  join(projectDir, "gtd.config.ts"),
408
- [minimalWorkflow("first"), `export { steering } from "./steps.js"`, ``].join("\n"),
433
+ [`import { steering } from "./steps.js"`, optionedWorkflow("first", "{ steering }")].join(
434
+ "\n",
435
+ ),
409
436
  )
410
437
 
411
438
  const cfg = await getConfig()
@@ -414,6 +441,64 @@ describe("ConfigService", () => {
414
441
  expect(Object.keys(cfg.fingerprint).sort()).toEqual(["gtd.config.ts", "steps.ts"])
415
442
  })
416
443
 
444
+ it("an ES module package gets the shipped gtd, a CommonJS one stays Node's, each module keeps its location", async () => {
445
+ const vendor = join(projectDir, "vendor", "wf")
446
+ const pkg = (name: string, manifest: object, file: string, source: string) => {
447
+ const dir = join(vendor, "node_modules", name)
448
+ mkdirSync(dir, { recursive: true })
449
+ writeFileSync(join(dir, "package.json"), JSON.stringify({ name, ...manifest }))
450
+ writeFileSync(join(dir, file), source)
451
+ }
452
+ // No @pmelab/gtd installed: only the shipped copy can serve this import.
453
+ pkg(
454
+ "dep",
455
+ { type: "module", exports: { import: "./index.js" } },
456
+ "index.js",
457
+ `import { human } from "@pmelab/gtd/flows"\nexport const file = typeof human\n`,
458
+ )
459
+ pkg("ts-dep", { main: "index.ts" }, "index.ts", `export const suffix: string = "ts"\n`)
460
+ pkg("cjs-dep", {}, "index.js", `module.exports = require("node:path").basename(__dirname)\n`)
461
+ writeFileSync(
462
+ join(vendor, "index.ts"),
463
+ [
464
+ `import { basename, dirname } from "node:path"`,
465
+ `import { fileURLToPath } from "node:url"`,
466
+ `import { file as base } from "dep"`,
467
+ `import { suffix } from "ts-dep"`,
468
+ `import cjs from "cjs-dep"`,
469
+ `const where = basename(dirname(fileURLToPath(import.meta.url)))`,
470
+ `export const file = [base, suffix, cjs, where].join("-")`,
471
+ ``,
472
+ ].join("\n"),
473
+ )
474
+ writeFileSync(
475
+ join(projectDir, "gtd.config.ts"),
476
+ [
477
+ `import { file } from "./vendor/wf/index.js"`,
478
+ optionedWorkflow("first", "{ steering: { [`.gtd/${file}.md`]: 'qa' } }"),
479
+ ].join("\n"),
480
+ )
481
+
482
+ const cfg = await getConfig()
483
+
484
+ expect(cfg.workflow.steering[".gtd/function-ts-cjs-dep-wf.md"]).toBe("qa")
485
+ expect(Object.keys(cfg.fingerprint).sort()).toEqual(["gtd.config.ts", "vendor/wf/index.ts"])
486
+ })
487
+
488
+ it("a config may require and require.resolve a local module", async () => {
489
+ writeFileSync(join(projectDir, "steps.ts"), `export const file = "required"\n`)
490
+ writeFileSync(
491
+ join(projectDir, "gtd.config.ts"),
492
+ [
493
+ `const { file } = require("./steps.js")`,
494
+ `const found = require("node:path").basename(require.resolve("./steps.ts"))`,
495
+ optionedWorkflow("first", "{ steering: { [`.gtd/${file}-${found}.md`]: 'qa' } }"),
496
+ ].join("\n"),
497
+ )
498
+
499
+ expect((await getConfig()).workflow.steering[".gtd/required-steps.ts.md"]).toBe("qa")
500
+ })
501
+
417
502
  it("the fingerprint covers gtd.config.ts but no gtd module it imports", async () => {
418
503
  const cfg = await getConfig()
419
504
 
@@ -421,21 +506,24 @@ describe("ConfigService", () => {
421
506
  })
422
507
 
423
508
  it.each([
424
- [`export const defaults = { n: 1 }`, /"defaults" export is not a record of strings/],
425
- [`export const summary = "text"`, /"summary" export is not a function/],
509
+ [`{ defaults: { n: 1 } }`, `workflow "default"'s "defaults" is not a record of strings`],
510
+ [`{ summary: "text" }`, `workflow "default"'s "summary" is not a function`],
511
+ [`{ base: "main" }`, `workflow "default"'s "base" is not a function`],
426
512
  [
427
- `export const steering = { ".gtd/PLAN.md": 1 }`,
428
- /"steering" export is not a record of strings/,
513
+ `{ steering: { ".gtd/PLAN.md": 1 } }`,
514
+ `workflow "default"'s "steering" is not a record of strings`,
429
515
  ],
430
- ])("rejects a malformed named export: %s", async (line, message) => {
431
- writeFileSync(join(projectDir, "gtd.config.ts"), `${minimalWorkflow("first")}${line}\n`)
516
+ [`{ doors: [] }`, `workflow "default"'s "doors" is not a record of doors`],
517
+ [`{ defualts: {} }`, `workflow "default" has unknown option(s) "defualts" — known: defaults,`],
518
+ ])("rejects malformed workflow() options: %s", async (options, message) => {
519
+ writeFileSync(join(projectDir, "gtd.config.ts"), optionedWorkflow("first", options))
432
520
 
433
521
  await expect(getConfig()).rejects.toThrow(message)
434
522
  })
435
523
 
436
524
  const firstStepWorkflow = (body: string) =>
437
525
  [
438
- `import { human, vars, env } from "@pmelab/gtd/flows"`,
526
+ `import { human, settings as vars, envSettings as env } from "@pmelab/gtd/flows"`,
439
527
  `export default async () => {`,
440
528
  ` ${body}`,
441
529
  `}`,
@@ -479,23 +567,19 @@ describe("ConfigService", () => {
479
567
  it("refuses a workflow default whose name is not a valid setting name", async () => {
480
568
  writeFileSync(
481
569
  join(projectDir, "gtd.config.ts"),
482
- [
483
- minimalWorkflow("first"),
484
- `export const defaults = { "a=b": "1" }`,
485
- `export const envDefaults = { "c d": "1" }`,
486
- ].join("\n"),
570
+ optionedWorkflow("first", `{ defaults: { "a=b": "1" }, envDefaults: { "c d": "1" } }`),
487
571
  )
488
572
  await expect(getConfig()).rejects.toThrow(
489
- 'the "defaults" export declares "a=b", not a valid setting name — a setting name is a letter or "_", then letters, digits or "_"',
573
+ 'workflow "default" declares defaults "a=b", not a valid setting name — a setting name is a letter or "_", then letters, digits or "_"',
490
574
  )
491
575
  })
492
576
 
493
577
  it("refuses an envDefaults name that is not a valid setting name", async () => {
494
578
  writeFileSync(
495
579
  join(projectDir, "gtd.config.ts"),
496
- [minimalWorkflow("first"), `export const envDefaults = { "c d": "1" }`].join("\n"),
580
+ optionedWorkflow("first", `{ envDefaults: { "c d": "1" } }`),
497
581
  )
498
- await expect(getConfig()).rejects.toThrow('the "envDefaults" export declares "c d"')
582
+ await expect(getConfig()).rejects.toThrow('workflow "default" declares envDefaults "c d"')
499
583
  })
500
584
 
501
585
  it("refuses a flow whose first step depends on the repository's files", async () => {
@@ -559,7 +643,7 @@ describe("ConfigService", () => {
559
643
 
560
644
  describe("named workflows", () => {
561
645
  const flow = (first: string) => `async () => {\n await human("${first}")\n}`
562
- const header = `import { human } from "@pmelab/gtd/flows"\n`
646
+ const header = `import { human, workflow } from "@pmelab/gtd/flows"\n`
563
647
 
564
648
  it("re-exported factories are startable under their names, default is not", async () => {
565
649
  withFactories(`export { feature as default, feature, review, fix } from "./workflows"\n`)
@@ -570,48 +654,93 @@ describe("ConfigService", () => {
570
654
  expect(cfg.workflowNamed("fix")?.def.initial).toBe("idle")
571
655
  })
572
656
 
573
- it("every exported function but the reserved names is a workflow, named by its export", async () => {
657
+ it("only workflow() exports and factories are workflows, named by their export; other functions are helpers", async () => {
574
658
  writeFileSync(
575
659
  join(projectDir, "gtd.config.ts"),
576
660
  [
577
661
  header,
578
662
  `export default ${flow("main")}`,
579
- `export const hotfix = ${flow("hot")}`,
580
- `export const defaults = { greeting: "hi" }`,
581
- `export const summary = () => "sum"`,
582
- `export const base = () => undefined`,
583
- `export const steering = {}`,
584
- `export const skills = {}`,
663
+ `export const hotfix = workflow(${flow("hot")}, { defaults: { greeting: "hi" } })`,
664
+ `export const helper = ${flow("help")}`,
585
665
  `export const notAFlow = 42`,
586
666
  ``,
587
667
  ].join("\n"),
588
668
  )
589
669
  const cfg = await getConfig()
590
670
  expect(cfg.workflowNames).toEqual(["hotfix"])
671
+ expect(cfg.warnings).toEqual([])
591
672
  expect(cfg.defaultWorkflow.name).toBe("default")
673
+ expect(cfg.defaultWorkflow.vars).toEqual({})
592
674
  expect(cfg.workflow.initial).toBe("main")
593
675
  expect(cfg.workflowNamed("hotfix")?.vars).toEqual({ greeting: "hi" })
594
676
  })
595
677
 
678
+ it("an exported workflow re-wrapped with options of its own is a second workflow with only those", async () => {
679
+ writeFileSync(
680
+ join(projectDir, "gtd.config.ts"),
681
+ [
682
+ header,
683
+ `export const library = workflow(${flow("main")}, { defaults: { greeting: "hi" } })`,
684
+ `export const mine = workflow(library, { defaults: { tone: "dry" } })`,
685
+ `export default mine`,
686
+ ``,
687
+ ].join("\n"),
688
+ )
689
+ const cfg = await getConfig()
690
+ expect(cfg.workflowNames).toEqual(["library", "mine"])
691
+ expect(cfg.workflowNamed("library")?.vars).toEqual({ greeting: "hi" })
692
+ expect(cfg.workflowNamed("mine")?.vars).toEqual({ tone: "dry" })
693
+ expect(cfg.workflowNamed("mine")?.def.initial).toBe("main")
694
+ })
695
+
696
+ it("a plain default export is a workflow without options", async () => {
697
+ writeFileSync(
698
+ join(projectDir, "gtd.config.ts"),
699
+ [header, `export default ${flow("main")}`, ``].join("\n"),
700
+ )
701
+ const cfg = await getConfig()
702
+ expect(cfg.workflow.initial).toBe("main")
703
+ expect(cfg.defaultWorkflow.vars).toEqual({})
704
+ expect(cfg.workflow.summary).toBeUndefined()
705
+ })
706
+
596
707
  it("a config with no default export fails, saying what to export", async () => {
597
708
  writeFileSync(
598
709
  join(projectDir, "gtd.config.ts"),
599
710
  [header, `export const hotfix = ${flow("hot")}`, ``].join("\n"),
600
711
  )
601
- await expect(getConfig()).rejects.toThrow(/no default export.*`gtd install`/)
712
+ await expect(getConfig()).rejects.toThrow(
713
+ /no default export — export the workflow an ordinary start runs; `gtd guide` says where to get one, or write your own/,
714
+ )
602
715
  })
603
716
 
604
717
  it("a default that is also a named export goes by that name", async () => {
605
718
  writeFileSync(
606
719
  join(projectDir, "gtd.config.ts"),
607
- [header, `export const hotfix = ${flow("hot")}`, `export default hotfix`, ``].join("\n"),
720
+ [
721
+ header,
722
+ `export const hotfix = workflow(${flow("hot")})`,
723
+ `export default hotfix`,
724
+ ``,
725
+ ].join("\n"),
608
726
  )
609
727
  expect((await getConfig()).defaultWorkflow.name).toBe("hotfix")
610
728
  })
611
729
 
612
- it("a helper re-exported from a workflow module becomes a startable workflow", async () => {
730
+ it("a helper re-exported from a workflow module stays a helper; a marked one is a workflow", async () => {
613
731
  withFactories(`export { feature as default, unwind } from "./workflows"\n`)
614
- expect((await getConfig()).workflowNames).toContain("unwind")
732
+ expect((await getConfig()).workflowNames).not.toContain("unwind")
733
+ writeFileSync(
734
+ join(projectDir, "gtd.config.ts"),
735
+ [
736
+ `import { workflow } from "@pmelab/gtd/flows"`,
737
+ `import { unwind } from "./workflows"`,
738
+ `export { feature as default } from "./workflows"`,
739
+ `export const unwinding = workflow(unwind)`,
740
+ ``,
741
+ ].join("\n"),
742
+ )
743
+ expect((await getConfig()).workflowNames).toContain("unwinding")
615
744
  })
616
745
 
617
746
  it("a gtd subpath only a helper file imports resolves", async () => {
@@ -628,21 +757,19 @@ describe("ConfigService", () => {
628
757
  [
629
758
  header,
630
759
  `import { kit } from "./kit"`,
631
- `export default ${flow("main")}`,
632
- 'export const steering = { [`.gtd/${kit}.md`]: "qa" }',
760
+ `export default workflow(${flow("main")}, { steering: { [\`.gtd/\${kit}.md\`]: "qa" } })`,
633
761
  ``,
634
762
  ].join("\n"),
635
763
  )
636
764
  expect((await getConfig()).workflow.steering[".gtd/function.md"]).toBe("qa")
637
765
  })
638
766
 
639
- it("steering is the config's own export", async () => {
767
+ it("steering is the workflow's own option", async () => {
640
768
  writeFileSync(
641
769
  join(projectDir, "gtd.config.ts"),
642
770
  [
643
771
  header,
644
- `export default ${flow("main")}`,
645
- `export const steering = { ".gtd/REVIEW.md": "qa", ".gtd/X.md": "qa" }`,
772
+ `export default workflow(${flow("main")}, { steering: { ".gtd/REVIEW.md": "qa", ".gtd/X.md": "qa" } })`,
646
773
  ``,
647
774
  ].join("\n"),
648
775
  )
@@ -655,16 +782,16 @@ describe("ConfigService", () => {
655
782
 
656
783
  describe("doors", () => {
657
784
  const flow = (first: string) => `async () => {\n await human("${first}")\n}`
658
- const header = `import { human } from "@pmelab/gtd/flows"\n`
785
+ const header = `import { human, workflow } from "@pmelab/gtd/flows"\n`
786
+ /** `hotfix` builds `doors`, a record of doors without their `workflow`. */
659
787
  const withDoors = (doors: string, extra = ""): void =>
660
788
  writeFileSync(
661
789
  join(projectDir, "gtd.config.ts"),
662
790
  [
663
791
  header,
664
792
  `export default ${flow("main")}`,
665
- `export const hotfix = ${flow("hot")}`,
793
+ `export const hotfix = workflow(${flow("hot")}, { doors: ${doors} })`,
666
794
  extra,
667
- `export const doors = ${doors}`,
668
795
  ``,
669
796
  ].join("\n"),
670
797
  )
@@ -701,26 +828,9 @@ describe("ConfigService", () => {
701
828
  expect(audit?.def.base?.("audit", { reviewBase: "v1" })).toBe("v1")
702
829
  })
703
830
 
704
- it("a config's own base export wins over the review factory's", async () => {
705
- withFactories(
706
- [
707
- `export { feature as default, review } from "./workflows"`,
708
- `export const base = (w: string) => (w === "review" ? "develop" : undefined)`,
709
- ``,
710
- ].join("\n"),
711
- )
712
- expect((await getConfig()).workflowNamed("review")?.def.base?.("review", {})).toBe("develop")
713
- })
714
-
715
- it("a door several workflows build gets no door when none is exported under its name, until the doors export declares it", async () => {
716
- const both = `export { feature as default, fix as fix2, fix as fix3 } from "./workflows"`
717
- withFactories(`${both}\n`)
831
+ it("a door several workflows build gets no door when none is exported under its name", async () => {
832
+ withFactories(`export { feature as default, fix as fix2, fix as fix3 } from "./workflows"\n`)
718
833
  expect((await getConfig()).doors.get("fix")).toBeUndefined()
719
- writeFileSync(
720
- join(projectDir, "gtd.config.ts"),
721
- `${both}\nexport const doors = { fix: { workflow: "fix3" } }\n`,
722
- )
723
- expect((await getConfig()).doors.get("fix")?.workflow).toBe("fix3")
724
834
  })
725
835
 
726
836
  it("a door clash warns once at doors.<name>, builders sorted", async () => {
@@ -734,17 +844,15 @@ describe("ConfigService", () => {
734
844
  severity: "warning",
735
845
  path: ["doors", "fix"],
736
846
  origin: join(projectDir, "gtd.config.ts"),
737
- message: `door "fix" is built by workflows "fix2", "fix3" and "fix4" and none is exported under that name — declare it in the "doors" export`,
847
+ message: `door "fix" is built by workflows "fix2", "fix3" and "fix4" and none is exported under that name — rename the export that should own it to "fix"`,
738
848
  },
739
849
  ])
740
850
  })
741
851
 
742
- it("no clash warning when an export is named after the door, one workflow builds it, or doors declares it", async () => {
743
- const both = `export { feature as default, fix as fix2, fix as fix3 } from "./workflows"`
852
+ it("no clash warning when an export is named after the door, or one workflow builds it", async () => {
744
853
  for (const body of [
745
854
  `export { feature as default, fix as fix2, fix } from "./workflows"\n`,
746
855
  `export { feature as default, fix } from "./workflows"\n`,
747
- `${both}\nexport const doors = { fix: { workflow: "fix3" } }\n`,
748
856
  ]) {
749
857
  withFactories(body)
750
858
  expect((await getConfig()).warnings.filter((w) => w.path[0] === "doors")).toEqual([])
@@ -766,11 +874,8 @@ describe("ConfigService", () => {
766
874
  }
767
875
  })
768
876
 
769
- it("a config's doors merge derived doors (exported workflows) with declared doors (doors export)", async () => {
770
- withDoors(
771
- `{ hot: { workflow: "hotfix", args: [{ name: "ticket" }] } }`,
772
- 'export { fix } from "./workflows"',
773
- )
877
+ it("a config's doors are those of every workflow it exports, factory or workflow()", async () => {
878
+ withDoors(`{ hot: { args: [{ name: "ticket" }] } }`, 'export { fix } from "./workflows"')
774
879
  writeFileSync(join(projectDir, "workflows.ts"), FACTORIES)
775
880
  const { doors } = await getConfig()
776
881
  expect([...doors.keys()].sort()).toEqual(["fix", "hot"])
@@ -779,39 +884,54 @@ describe("ConfigService", () => {
779
884
  expect(doors.get("hot")?.args).toEqual([{ name: "ticket" }])
780
885
  })
781
886
 
782
- it("the reserved `doors` export is never a workflow", async () => {
783
- withDoors(`{}`)
784
- expect((await getConfig()).workflowNames).toEqual(["hotfix"])
887
+ it("a door named like a workflow it does not start warns that it hides that workflow", async () => {
888
+ const config = (fixDoors: string): void =>
889
+ writeFileSync(
890
+ join(projectDir, "gtd.config.ts"),
891
+ [
892
+ `import { human, workflow } from "@pmelab/gtd/flows"`,
893
+ `export default workflow(${flow("main")})`,
894
+ `export const fix = workflow(${flow("fix")}${fixDoors})`,
895
+ `export const hotfix = workflow(${flow("hot")}, { doors: { fix: {} } })`,
896
+ ``,
897
+ ].join("\n"),
898
+ )
899
+ config("")
900
+ const cfg = await getConfig()
901
+ expect(cfg.doors.get("fix")?.workflow).toBe("hotfix")
902
+ expect(cfg.warnings.filter((w) => w.path[0] === "doors")).toEqual([
903
+ {
904
+ severity: "warning",
905
+ path: ["doors", "fix"],
906
+ origin: join(projectDir, "gtd.config.ts"),
907
+ message:
908
+ 'door "fix" starts workflow "hotfix" and hides the workflow of the same name from `gtd start fix` — rename one of them',
909
+ },
910
+ ])
911
+ config(", { doors: { fix: {} } }")
912
+ expect((await getConfig()).warnings.filter((w) => w.path[0] === "doors")).toEqual([])
785
913
  })
786
914
 
787
915
  it("rejects a door name that is not lowercase-kebab", async () => {
788
- withDoors(`{ Bad_Name: { workflow: "fix" } }`)
916
+ withDoors(`{ Bad_Name: {} }`)
789
917
  await expect(getConfig()).rejects.toThrow(/door "Bad_Name".*\^\[a-z\]\[a-z0-9-\]\*\$/)
790
918
  })
791
919
 
792
- it("rejects a door whose workflow is not startable", async () => {
793
- withDoors(`{ go: { workflow: "nope" } }`)
794
- await expect(getConfig()).rejects.toThrow(/door "go".*workflow "nope"/)
795
- })
796
-
797
920
  it("rejects a required arg after an optional one", async () => {
798
- withDoors(`{ go: { workflow: "fix", args: [{ name: "a", optional: true }, { name: "b" }] } }`)
921
+ withDoors(`{ go: { args: [{ name: "a", optional: true }, { name: "b" }] } }`)
799
922
  await expect(getConfig()).rejects.toThrow(/door "go".*"b".*after an optional/)
800
923
  })
801
924
 
802
925
  it("rejects duplicate arg names", async () => {
803
- withDoors(`{ go: { workflow: "fix", args: [{ name: "a" }, { name: "a" }] } }`)
926
+ withDoors(`{ go: { args: [{ name: "a" }, { name: "a" }] } }`)
804
927
  await expect(getConfig()).rejects.toThrow(/door "go".*duplicate arg "a"/)
805
928
  })
806
929
 
807
- it("rejects a `doors` export that is not a record", async () => {
930
+ it("rejects doors that are not a record", async () => {
808
931
  withDoors(`[1]`)
809
- await expect(getConfig()).rejects.toThrow(/the "doors" export is not a record/)
810
- })
811
-
812
- it("rejects a door that is not an object with a workflow name", async () => {
813
- withDoors(`{ go: 3 }`)
814
- await expect(getConfig()).rejects.toThrow(/door "go".*workflow/)
932
+ await expect(getConfig()).rejects.toThrow(
933
+ `workflow "hotfix"'s "doors" is not a record of doors`,
934
+ )
815
935
  })
816
936
  })
817
937
 
@@ -907,16 +1027,15 @@ describe("ConfigService", () => {
907
1027
  )
908
1028
  })
909
1029
 
910
- it("loads a skills export given as a record or as a function of the vars", async () => {
911
- const flow = minimalWorkflow("first")
1030
+ it("loads a skills option given as a record or as a function of the vars", async () => {
912
1031
  writeFileSync(
913
1032
  join(projectDir, "gtd.config.ts"),
914
- `${flow}export const skills = { a: ["x"] }\n`,
1033
+ optionedWorkflow("first", `{ skills: { a: ["x"] } }`),
915
1034
  )
916
1035
  expect(Object.keys((await getConfig()).workflow.skills({}))).toEqual(["a"])
917
1036
  writeFileSync(
918
1037
  join(projectDir, "gtd.config.ts"),
919
- `${flow}export const skills = (vars) => ({ [vars.k ?? "none"]: ["x"] })\n`,
1038
+ optionedWorkflow("first", `{ skills: (vars) => ({ [vars.k ?? "none"]: ["x"] }) }`),
920
1039
  )
921
1040
  expect(Object.keys((await getConfig()).workflow.skills({ k: "b" }))).toEqual(["b"])
922
1041
  })
@@ -945,10 +1064,10 @@ describe("ConfigService", () => {
945
1064
  expect(cfg.ui).toEqual({ format: "oxfmt --write $GTD_FILE" })
946
1065
  })
947
1066
 
948
- it("never interpolates the workflow's own `defaults` export", async () => {
1067
+ it("never interpolates the workflow's own `defaults`", async () => {
949
1068
  writeFileSync(
950
1069
  join(projectDir, "gtd.config.ts"),
951
- [minimalWorkflow("first"), `export const defaults = { greeting: "$LITERAL" }`, ``].join("\n"),
1070
+ optionedWorkflow("first", `{ defaults: { greeting: "$LITERAL" } }`),
952
1071
  )
953
1072
 
954
1073
  const cfg = await getConfig(undefined, { LITERAL: "should-never-appear" })
@@ -1015,13 +1134,8 @@ describe("ConfigService — workflow factory options", () => {
1015
1134
  expect(error).toMatch(/gtd\.config\.ts: testComand: workflow "default" option "testComand"/)
1016
1135
  })
1017
1136
 
1018
- it("a door naming a rejected factory reports the option error alone", async () => {
1019
- configWith(
1020
- [
1021
- `export default feature({ testComand: "x" } as never)`,
1022
- `export const doors = { go: { workflow: "default" } }`,
1023
- ].join("\n"),
1024
- )
1137
+ it("a rejected factory reports the option error, not the doors it would build", async () => {
1138
+ configWith(`export default feature({ testComand: "x" } as never)`)
1025
1139
 
1026
1140
  const error = await getConfig().catch((e: Error) => e.message)
1027
1141
 
@@ -1049,7 +1163,7 @@ describe("ConfigService — a factory's built settings", () => {
1049
1163
  await expect(getConfig()).rejects.toThrow(/workflow "audit" builds door "Bad_Name", which/)
1050
1164
  })
1051
1165
 
1052
- it("are held to the setting-name rule, like a file's defaults", async () => {
1166
+ it("are held to the setting-name rule, like workflow()'s", async () => {
1053
1167
  writeFileSync(
1054
1168
  join(projectDir, "gtd.config.ts"),
1055
1169
  [
@@ -1064,7 +1178,7 @@ describe("ConfigService — a factory's built settings", () => {
1064
1178
  )
1065
1179
 
1066
1180
  await expect(getConfig()).rejects.toThrow(
1067
- /workflow "default" builds defaults "x=y", not a valid setting name/,
1181
+ /workflow "default" declares defaults "x=y", not a valid setting name/,
1068
1182
  )
1069
1183
  })
1070
1184
 
@@ -1100,6 +1214,89 @@ describe("ConfigService — a factory's built settings", () => {
1100
1214
  ].join("\n"),
1101
1215
  )
1102
1216
 
1103
- await expect(getConfig()).rejects.toThrow(/builds defaults "x", whose value is not a string/)
1217
+ await expect(getConfig()).rejects.toThrow(
1218
+ `workflow "default"'s "defaults" is not a record of strings`,
1219
+ )
1220
+ })
1221
+ })
1222
+
1223
+ describe("ConfigService — what a factory's build carries", () => {
1224
+ const factoryConfig = (build: string): void =>
1225
+ writeFileSync(
1226
+ join(projectDir, "gtd.config.ts"),
1227
+ [
1228
+ `import { defineWorkflow, human } from "@pmelab/gtd/flows"`,
1229
+ `const any = { "~standard": { version: 1, vendor: "t", validate: (value) => ({ value }) } }`,
1230
+ `export default defineWorkflow(any, () => ({`,
1231
+ ` flow: async () => { await human("idle") },`,
1232
+ build,
1233
+ `}))`,
1234
+ ``,
1235
+ ].join("\n"),
1236
+ )
1237
+
1238
+ it("skills, access, steering and summary reach the workflow", async () => {
1239
+ factoryConfig(
1240
+ [
1241
+ ` skills: (vars) => ({ work: [vars.lens ?? "none"] }),`,
1242
+ ` access: { work: { write: ["out/**"] } },`,
1243
+ ` steering: { ".gtd/PLAN.md": "qa" },`,
1244
+ ` summary: () => "built",`,
1245
+ ].join("\n"),
1246
+ )
1247
+ const { workflow } = await getConfig()
1248
+ expect(workflow.skills({ lens: "x" })).toEqual({ work: ["x"] })
1249
+ expect(workflow.access({})).toEqual({ work: { write: ["out/**"] } })
1250
+ expect(workflow.steering).toEqual({ ".gtd/PLAN.md": "qa" })
1251
+ expect(workflow.summary?.({} as never)).toBe("built")
1252
+ })
1253
+
1254
+ it("checks them as it checks workflow()'s options", async () => {
1255
+ factoryConfig(` skills: { work: "one" },`)
1256
+ await expect(getConfig()).rejects.toThrow(
1257
+ `workflow "default"'s "skills" is not a record of skill-name arrays`,
1258
+ )
1259
+ factoryConfig(` summary: "text",`)
1260
+ await expect(getConfig()).rejects.toThrow(`workflow "default"'s "summary" is not a function`)
1261
+ factoryConfig(` access: { work: { read: "src/**" } },`)
1262
+ await expect(getConfig()).rejects.toThrow(
1263
+ `workflow "default"'s "access": scope "work": access.read must be an array of glob strings`,
1264
+ )
1265
+ })
1266
+ })
1267
+
1268
+ describe("ConfigService — workflow()'s access option", () => {
1269
+ const withAccess = (access: string): void =>
1270
+ writeFileSync(
1271
+ join(projectDir, "gtd.config.ts"),
1272
+ optionedWorkflow("first", `{ access: ${access} }`),
1273
+ )
1274
+
1275
+ it("accepts a record of { read?, write? } glob arrays, and a function", async () => {
1276
+ withAccess(`{ a: { read: ["x/**"], write: [] }, b: {}, c: { read: undefined } }`)
1277
+ expect((await getConfig()).workflow.access({})).toEqual({
1278
+ a: { read: ["x/**"], write: [] },
1279
+ b: {},
1280
+ c: { read: undefined },
1281
+ })
1282
+ withAccess(`(vars) => ({ [vars.k ?? "none"]: { write: "unchecked until resolved" } })`)
1283
+ expect(Object.keys((await getConfig()).workflow.access({ k: "b" }))).toEqual(["b"])
1284
+ })
1285
+
1286
+ it.each([
1287
+ [
1288
+ `{ a: { wirte: [] } }`,
1289
+ `workflow "default"'s "access": scope "a": unknown access key "wirte"`,
1290
+ ],
1291
+ [
1292
+ `{ a: { read: [1] } }`,
1293
+ `workflow "default"'s "access": scope "a": access.read must be an array`,
1294
+ ],
1295
+ [`{ a: ["x/**"] }`, `workflow "default"'s "access": scope "a": access must be an object`],
1296
+ [`["x/**"]`, `workflow "default"'s "access" is not a record of scope access`],
1297
+ [`"x/**"`, `workflow "default"'s "access" is not a record of scope access`],
1298
+ ])("refuses %s at load", async (access, message) => {
1299
+ withAccess(access)
1300
+ await expect(getConfig()).rejects.toThrow(message)
1104
1301
  })
1105
1302
  })