@pmelab/gtd 58.2.0 → 59.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 (133) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +115 -390
  3. package/dist/chunks/{Schema-B3o6eRNU.mjs → testkit-CMBAAyRK.mjs} +13409 -3351
  4. package/dist/gtd.bundle.mjs +76 -59
  5. package/dist/testkit/gherkin.mjs +1 -1
  6. package/dist/testkit.mjs +1 -1
  7. package/package.json +3 -42
  8. package/skills/authoring/SKILL.md +31 -25
  9. package/src/cli/Cli.test.ts +15 -19
  10. package/src/cli/Cli.ts +2 -4
  11. package/src/cli/Program.test.ts +60 -29
  12. package/src/cli/Program.ts +5 -7
  13. package/src/edge/Edge.ts +3 -3
  14. package/src/flags/index.test.ts +0 -13
  15. package/src/flags/index.ts +1 -3
  16. package/src/install/Install.test.ts +14 -11
  17. package/src/install/Install.ts +38 -15
  18. package/src/install/index.ts +1 -2
  19. package/src/replay/Replay.test.ts +1 -1
  20. package/src/replay/index.ts +1 -1
  21. package/src/step/planStart.ts +6 -1
  22. package/src/twins/FlowScripts.ts +1 -1
  23. package/src/workflow/index.ts +1 -0
  24. package/src/workflow/load.test.ts +138 -113
  25. package/src/workflow/load.ts +1 -1
  26. package/src/workflow/vars.test.ts +30 -3
  27. package/src/workflow/vars.ts +34 -2
  28. package/src/workflow-kit/index.ts +1 -1
  29. package/dist/chunks/reserved-lMYCaapY.mjs +0 -1990
  30. package/dist/chunks/review-Bfaror38.mjs +0 -427
  31. package/dist/chunks/tail-Bd7L940p.mjs +0 -355
  32. package/dist/chunks/testkit-JDtpNKyi.mjs +0 -10410
  33. package/dist/workflows/cleanup.mjs +0 -36
  34. package/dist/workflows/feature.mjs +0 -1018
  35. package/dist/workflows/fix.mjs +0 -41
  36. package/dist/workflows/review.mjs +0 -45
  37. package/dist/workflows/rollout.mjs +0 -48
  38. package/dist/workflows.mjs +0 -40651
  39. package/src/install/ConfigScaffold.ts +0 -22
  40. package/src/workflows/cleanup/cleanup-path.feature +0 -94
  41. package/src/workflows/cleanup/cleanup.feature +0 -315
  42. package/src/workflows/cleanup/cleanup.test.ts +0 -276
  43. package/src/workflows/cleanup/cleanup.ts +0 -52
  44. package/src/workflows/cleanup/index.ts +0 -6
  45. package/src/workflows/feature/acceptance-criteria-tier.feature +0 -25
  46. package/src/workflows/feature/acceptance-criteria.feature +0 -389
  47. package/src/workflows/feature/criteria-coverage.feature +0 -102
  48. package/src/workflows/feature/criteria.test.ts +0 -147
  49. package/src/workflows/feature/criteria.ts +0 -267
  50. package/src/workflows/feature/fast-path.feature +0 -93
  51. package/src/workflows/feature/feature.test.ts +0 -338
  52. package/src/workflows/feature/feature.ts +0 -156
  53. package/src/workflows/feature/green.ts +0 -19
  54. package/src/workflows/feature/index.ts +0 -18
  55. package/src/workflows/feature/lens-models.feature +0 -69
  56. package/src/workflows/feature/lens-review.feature +0 -566
  57. package/src/workflows/feature/naive-acceptance.feature +0 -147
  58. package/src/workflows/feature/naive-test.feature +0 -263
  59. package/src/workflows/feature/one-step-land.feature +0 -93
  60. package/src/workflows/feature/planning.ts +0 -41
  61. package/src/workflows/feature/refactor-absorbs.feature +0 -131
  62. package/src/workflows/feature/refactor-first.feature +0 -158
  63. package/src/workflows/feature/refactor-lens-models.feature +0 -79
  64. package/src/workflows/feature/refactor-rebuild.feature +0 -273
  65. package/src/workflows/feature/refactor-review.feature +0 -1528
  66. package/src/workflows/feature/refactor.test.ts +0 -1220
  67. package/src/workflows/feature/refactor.ts +0 -368
  68. package/src/workflows/feature/release-flag.feature +0 -167
  69. package/src/workflows/feature/release-flag.test.ts +0 -49
  70. package/src/workflows/feature/release-flag.ts +0 -46
  71. package/src/workflows/feature/review-gate.feature +0 -241
  72. package/src/workflows/feature/reviewer-walkthrough.feature +0 -142
  73. package/src/workflows/feature/risk-tier.feature +0 -351
  74. package/src/workflows/feature/seam-rejection.feature +0 -146
  75. package/src/workflows/feature/seam.feature +0 -297
  76. package/src/workflows/feature/seam.test.ts +0 -527
  77. package/src/workflows/feature/seam.ts +0 -426
  78. package/src/workflows/feature/standards.feature +0 -354
  79. package/src/workflows/feature/steps.test.ts +0 -65
  80. package/src/workflows/feature/steps.ts +0 -45
  81. package/src/workflows/feature/tier-pin.feature +0 -66
  82. package/src/workflows/feature/tier.test.ts +0 -166
  83. package/src/workflows/feature/tier.ts +0 -56
  84. package/src/workflows/fix/fix-lens-origin.feature +0 -114
  85. package/src/workflows/fix/fix-quality.feature +0 -212
  86. package/src/workflows/fix/fix.feature +0 -79
  87. package/src/workflows/fix/fix.test.ts +0 -74
  88. package/src/workflows/fix/fix.ts +0 -52
  89. package/src/workflows/fix/index.ts +0 -6
  90. package/src/workflows/index.ts +0 -47
  91. package/src/workflows/review/index.ts +0 -6
  92. package/src/workflows/review/review-door.feature +0 -199
  93. package/src/workflows/review/review.test.ts +0 -27
  94. package/src/workflows/review/review.ts +0 -60
  95. package/src/workflows/rollout/index.ts +0 -6
  96. package/src/workflows/rollout/rollout.feature +0 -510
  97. package/src/workflows/rollout/rollout.test.ts +0 -263
  98. package/src/workflows/rollout/rollout.ts +0 -82
  99. package/src/workflows/shared/__fixtures__/feature-collecting-prompt.txt +0 -98
  100. package/src/workflows/shared/access.test.ts +0 -62
  101. package/src/workflows/shared/access.ts +0 -24
  102. package/src/workflows/shared/config.fixture.ts +0 -12
  103. package/src/workflows/shared/fences.ts +0 -24
  104. package/src/workflows/shared/health.test.ts +0 -148
  105. package/src/workflows/shared/health.ts +0 -191
  106. package/src/workflows/shared/learn.test.ts +0 -140
  107. package/src/workflows/shared/learn.ts +0 -201
  108. package/src/workflows/shared/lenses.test.ts +0 -39
  109. package/src/workflows/shared/lenses.ts +0 -294
  110. package/src/workflows/shared/options.test.ts +0 -157
  111. package/src/workflows/shared/options.ts +0 -92
  112. package/src/workflows/shared/progress.feature +0 -124
  113. package/src/workflows/shared/prose.ts +0 -263
  114. package/src/workflows/shared/recheck.feature +0 -60
  115. package/src/workflows/shared/registry.test.ts +0 -345
  116. package/src/workflows/shared/registry.ts +0 -183
  117. package/src/workflows/shared/reserved.ts +0 -11
  118. package/src/workflows/shared/review.test.ts +0 -1062
  119. package/src/workflows/shared/review.ts +0 -446
  120. package/src/workflows/shared/skills.test.ts +0 -121
  121. package/src/workflows/shared/skills.ts +0 -49
  122. package/src/workflows/shared/standards.test.ts +0 -90
  123. package/src/workflows/shared/standards.ts +0 -156
  124. package/src/workflows/shared/steps.test.ts +0 -219
  125. package/src/workflows/shared/steps.ts +0 -201
  126. package/src/workflows/shared/tail.test.ts +0 -454
  127. package/src/workflows/shared/tail.ts +0 -264
  128. package/src/workflows/shared/text.fixture.ts +0 -128
  129. package/src/workflows/shared/text.test.ts +0 -1430
  130. package/src/workflows/shared/text.ts +0 -1700
  131. package/src/workflows/shared/threads.test.ts +0 -129
  132. package/src/workflows/shared/vars.ts +0 -47
  133. package/src/workflows/shared/workflows.steps.ts +0 -54
@@ -15,7 +15,6 @@ import {
15
15
  resolveVars,
16
16
  seededValidateCommand,
17
17
  } from "./index.js"
18
- import { CONFIG_SCAFFOLD } from "../install/index.js"
19
18
 
20
19
  // Every real port `ConfigService.Live` needs, scoped to `dir` — `home` is a
21
20
  // fresh directory above the project, so the walk stops there as it would in a
@@ -71,12 +70,109 @@ const loadAndResolve = (dir: string = projectDir, vars: Env = {}) =>
71
70
  let homeDir: string
72
71
  let projectDir: string
73
72
 
74
- // Every test starts where `gtd install` leaves a repository.
73
+ // A config whose default workflow rests at `idle`, declaring a process
74
+ // setting, an environment setting and the scopes its turns run under.
75
+ const BASE_CONFIG = `import { human } from "@pmelab/gtd/flows"
76
+
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) => ({
85
+ build: [],
86
+ "build.fix.quality": [],
87
+ "build.review": [],
88
+ ...Object.fromEntries(
89
+ (vars.qualityReviews ?? "")
90
+ .split(",")
91
+ .map((lens) => lens.trim())
92
+ .filter((lens) => lens !== "")
93
+ .map((lens) => [\`build.quality.\${lens}\`, []]),
94
+ ),
95
+ })
96
+ `
97
+
98
+ // Workflow factories a config imports from a sibling module: string options
99
+ // with defaults, an unknown or non-string option being an issue.
100
+ const FACTORIES = `import { defineWorkflow, human } from "@pmelab/gtd/flows"
101
+
102
+ const settings = (known) => ({
103
+ "~standard": {
104
+ version: 1,
105
+ vendor: "test",
106
+ validate: (value) => {
107
+ const issues = Object.entries(value).flatMap(([key, given]) =>
108
+ !Object.hasOwn(known, key)
109
+ ? [{ path: [key], message: \`is not an option — options: \${Object.keys(known).join(", ")}\` }]
110
+ : typeof given === "string"
111
+ ? []
112
+ : [{ path: [key], message: "must be a string" }],
113
+ )
114
+ return issues.length > 0 ? { issues } : { value: { ...known, ...value } }
115
+ },
116
+ },
117
+ })
118
+
119
+ export const feature = defineWorkflow(
120
+ settings({ qualityReviews: "", reviewNoteActionable: "0.7", testCommand: "npm test" }),
121
+ ({ testCommand, ...defaults }) => ({
122
+ flow: async () => {
123
+ await human("idle")
124
+ },
125
+ defaults,
126
+ envDefaults: { testCommand },
127
+ }),
128
+ )
129
+
130
+ export const review = defineWorkflow(settings({ reviewNoteActionable: "0.7" }), (defaults) => ({
131
+ flow: async () => {
132
+ await human("review-gate")
133
+ },
134
+ defaults: { ...defaults, reviewBase: "" },
135
+ doors: {
136
+ review: {
137
+ args: [{ name: "base", optional: true }],
138
+ vars: ({ base }) => (base === undefined ? {} : { reviewBase: base }),
139
+ },
140
+ },
141
+ base: (_workflow, vars) => vars.reviewBase || "",
142
+ }))
143
+
144
+ export const fix = defineWorkflow(settings({ judgeBudgetBytes: "65536" }), (defaults) => ({
145
+ flow: async () => {
146
+ await human("fix-precheck")
147
+ },
148
+ defaults,
149
+ doors: { fix: {} },
150
+ }))
151
+
152
+ export const rollout = defineWorkflow(settings({ flagRegistry: "flags.ts" }), (defaults) => ({
153
+ flow: async () => {
154
+ await human("flipping")
155
+ },
156
+ defaults: { ...defaults, flag: "" },
157
+ doors: { rollout: { args: [{ name: "flag" }], vars: ({ flag }) => ({ flag: flag ?? "" }) } },
158
+ }))
159
+
160
+ export const unwind = async () => {
161
+ await human("unwinding")
162
+ }
163
+ `
164
+
165
+ /** Writes `config` as gtd.config.ts beside {@link FACTORIES} as `workflows.ts`. */
166
+ const withFactories = (config: string): void => {
167
+ writeFileSync(join(projectDir, "workflows.ts"), FACTORIES)
168
+ writeFileSync(join(projectDir, "gtd.config.ts"), config)
169
+ }
170
+
75
171
  beforeEach(() => {
76
172
  homeDir = mkdtempSync(join(tmpdir(), "gtd-config-"))
77
173
  projectDir = join(homeDir, "project")
78
174
  mkdirSync(projectDir)
79
- writeFileSync(join(projectDir, "gtd.config.ts"), CONFIG_SCAFFOLD)
175
+ writeFileSync(join(projectDir, "gtd.config.ts"), BASE_CONFIG)
80
176
  })
81
177
 
82
178
  afterEach(() => {
@@ -97,11 +193,11 @@ const minimalWorkflow = (first: string) =>
97
193
  const withConfig = (config: string) =>
98
194
  writeFileSync(
99
195
  join(projectDir, "gtd.config.ts"),
100
- `${CONFIG_SCAFFOLD}\nexport const config = ${config}\n`,
196
+ `${BASE_CONFIG}\nexport const config = ${config}\n`,
101
197
  )
102
198
 
103
199
  describe("ConfigService", () => {
104
- it("the scaffolded config runs the shipped feature workflow by default", async () => {
200
+ it("a config runs its default workflow, its envDefaults as environment settings", async () => {
105
201
  const cfg = await getConfig()
106
202
 
107
203
  expect(cfg.workflow.initial).toBe("idle")
@@ -109,11 +205,11 @@ describe("ConfigService", () => {
109
205
  expect(cfg.defaultWorkflow.vars["testCommand"]).toBeUndefined()
110
206
  })
111
207
 
112
- it("with no gtd.config.ts anywhere: fails, saying to run gtd install", async () => {
208
+ it("with no gtd.config.ts anywhere: fails, pointing at gtd install", async () => {
113
209
  rmSync(join(projectDir, "gtd.config.ts"))
114
210
 
115
211
  await expect(getConfig()).rejects.toThrow(
116
- /no workflows configured: no gtd\.config\.ts here or above — run `gtd install`/,
212
+ /no workflows configured: no gtd\.config\.ts here or above — gtd ships no workflow; `gtd install` says where to get one/,
117
213
  )
118
214
  })
119
215
 
@@ -149,7 +245,7 @@ describe("ConfigService", () => {
149
245
  const others = Layer.succeed(ConfigDiscovery, {
150
246
  legacyRcFiles: () => Effect.succeed([{ path: "/shared/.gtdrc", owned: false }]),
151
247
  workflowModule: () =>
152
- Effect.succeed({ filepath: join(projectDir, "gtd.config.ts"), source: CONFIG_SCAFFOLD }),
248
+ Effect.succeed({ filepath: join(projectDir, "gtd.config.ts"), source: BASE_CONFIG }),
153
249
  })
154
250
  const cfg = await Effect.runPromise(
155
251
  Effect.flatMap(ConfigService, (c) => c.load).pipe(
@@ -172,7 +268,7 @@ describe("ConfigService", () => {
172
268
  it("refuses a user-level rc file in home, even when the walk never reaches home", async () => {
173
269
  writeFileSync(join(homeDir, ".gtdrc.json"), `{ "$schema": "x" }\n`)
174
270
  const outside = mkdtempSync(join(tmpdir(), "gtd-outside-"))
175
- writeFileSync(join(outside, "gtd.config.ts"), CONFIG_SCAFFOLD)
271
+ writeFileSync(join(outside, "gtd.config.ts"), BASE_CONFIG)
176
272
 
177
273
  try {
178
274
  await expect(getConfig()).rejects.toThrow(`${join(homeDir, ".gtdrc.json")}: no longer read`)
@@ -233,7 +329,7 @@ describe("ConfigService", () => {
233
329
  join(projectDir, "gtd.config.ts"),
234
330
  [
235
331
  `import { defineConfig } from "@pmelab/gtd/flows"`,
236
- CONFIG_SCAFFOLD,
332
+ BASE_CONFIG,
237
333
  `export const config = defineConfig({ ui: { port: 4173 } })`,
238
334
  ``,
239
335
  ].join("\n"),
@@ -247,7 +343,7 @@ describe("ConfigService", () => {
247
343
  join(projectDir, "gtd.config.ts"),
248
344
  [
249
345
  `import { defineConfig } from "@pmelab/gtd/flows"`,
250
- CONFIG_SCAFFOLD,
346
+ BASE_CONFIG,
251
347
  `export const config = defineConfig({ env: { testCommand: "x" } })`,
252
348
  ``,
253
349
  ].join("\n"),
@@ -318,7 +414,7 @@ describe("ConfigService", () => {
318
414
  expect(Object.keys(cfg.fingerprint).sort()).toEqual(["gtd.config.ts", "steps.ts"])
319
415
  })
320
416
 
321
- it("the fingerprint covers gtd.config.ts but no shipped module it imports", async () => {
417
+ it("the fingerprint covers gtd.config.ts but no gtd module it imports", async () => {
322
418
  const cfg = await getConfig()
323
419
 
324
420
  expect(Object.keys(cfg.fingerprint)).toEqual(["gtd.config.ts"])
@@ -465,7 +561,8 @@ describe("ConfigService", () => {
465
561
  const flow = (first: string) => `async () => {\n await human("${first}")\n}`
466
562
  const header = `import { human } from "@pmelab/gtd/flows"\n`
467
563
 
468
- it("the scaffolded config: feature, review and fix are startable, default is not", async () => {
564
+ it("re-exported factories are startable under their names, default is not", async () => {
565
+ withFactories(`export { feature as default, feature, review, fix } from "./workflows"\n`)
469
566
  const cfg = await getConfig()
470
567
  expect(cfg.workflowNames).toEqual(["feature", "fix", "review"])
471
568
  expect(cfg.defaultWorkflow.name).toBe("feature")
@@ -512,38 +609,12 @@ describe("ConfigService", () => {
512
609
  expect((await getConfig()).defaultWorkflow.name).toBe("hotfix")
513
610
  })
514
611
 
515
- it("a helper re-exported from the shipped module becomes a startable workflow", async () => {
516
- writeFileSync(
517
- join(projectDir, "gtd.config.ts"),
518
- [`export { feature as default, unwind } from "@pmelab/gtd/workflows"`, ``].join("\n"),
519
- )
612
+ it("a helper re-exported from a workflow module becomes a startable workflow", async () => {
613
+ withFactories(`export { feature as default, unwind } from "./workflows"\n`)
520
614
  expect((await getConfig()).workflowNames).toContain("unwind")
521
615
  })
522
616
 
523
- it("the old @pmelab/gtd/workflow subpath serves the same module", async () => {
524
- writeFileSync(
525
- join(projectDir, "gtd.config.ts"),
526
- [`export { feature as default, feature } from "@pmelab/gtd/workflow"`, ``].join("\n"),
527
- )
528
- expect((await getConfig()).defaultWorkflow.name).toBe("feature")
529
- })
530
-
531
- it("each shipped workflow has its own subpath", async () => {
532
- writeFileSync(
533
- join(projectDir, "gtd.config.ts"),
534
- [
535
- `export { feature as default, feature } from "@pmelab/gtd/workflows/feature"`,
536
- `export { review } from "@pmelab/gtd/workflows/review"`,
537
- `export { fix } from "@pmelab/gtd/workflows/fix"`,
538
- ``,
539
- ].join("\n"),
540
- )
541
- const config = await getConfig()
542
- expect(config.defaultWorkflow.name).toBe("feature")
543
- expect(config.workflowNames).toEqual(["feature", "fix", "review"])
544
- })
545
-
546
- it("a shipped subpath only a helper file imports resolves", async () => {
617
+ it("a gtd subpath only a helper file imports resolves", async () => {
547
618
  writeFileSync(
548
619
  join(projectDir, "kit.ts"),
549
620
  [
@@ -598,7 +669,8 @@ describe("ConfigService", () => {
598
669
  ].join("\n"),
599
670
  )
600
671
 
601
- it("the scaffolded config: the fix and review doors, review taking an optional base", async () => {
672
+ it("exported factories bring their doors, review taking an optional base", async () => {
673
+ withFactories(`export { feature as default, review, fix } from "./workflows"\n`)
602
674
  const { doors } = await getConfig()
603
675
  expect([...doors.keys()].sort()).toEqual(["fix", "review"])
604
676
  expect(doors.get("fix")?.workflow).toBe("fix")
@@ -606,44 +678,33 @@ describe("ConfigService", () => {
606
678
  })
607
679
 
608
680
  it("a config exporting only fix gets only fix's door", async () => {
609
- writeFileSync(
610
- join(projectDir, "gtd.config.ts"),
611
- [
612
- header,
613
- `export default ${flow("main")}`,
614
- `export { fix } from "@pmelab/gtd/workflows"`,
615
- ``,
616
- ].join("\n"),
681
+ withFactories(
682
+ [header, `export default ${flow("main")}`, `export { fix } from "./workflows"`, ``].join(
683
+ "\n",
684
+ ),
617
685
  )
618
686
  const { doors } = await getConfig()
619
687
  expect([...doors.keys()]).toEqual(["fix"])
620
688
  })
621
689
 
622
690
  it("a door follows the name its workflow is exported under", async () => {
623
- writeFileSync(
624
- join(projectDir, "gtd.config.ts"),
625
- `export { feature as default, review as audit } from "@pmelab/gtd/workflows"\n`,
626
- )
691
+ withFactories(`export { feature as default, review as audit } from "./workflows"\n`)
627
692
  const door = (await getConfig()).doors.get("review")
628
693
  expect(door?.workflow).toBe("audit")
629
694
  expect(door?.vars?.({ base: "main" })).toEqual({ reviewBase: "main" })
630
695
  })
631
696
 
632
697
  it("a review factory exported as audit reviews the default branch, or an explicit base", async () => {
633
- writeFileSync(
634
- join(projectDir, "gtd.config.ts"),
635
- `export { feature as default, review as audit } from "@pmelab/gtd/workflows"\n`,
636
- )
698
+ withFactories(`export { feature as default, review as audit } from "./workflows"\n`)
637
699
  const audit = (await getConfig()).workflowNamed("audit")
638
700
  expect(audit?.def.base?.("audit", {})).toBe("")
639
701
  expect(audit?.def.base?.("audit", { reviewBase: "v1" })).toBe("v1")
640
702
  })
641
703
 
642
704
  it("a config's own base export wins over the review factory's", async () => {
643
- writeFileSync(
644
- join(projectDir, "gtd.config.ts"),
705
+ withFactories(
645
706
  [
646
- `export { feature as default, review } from "@pmelab/gtd/workflows"`,
707
+ `export { feature as default, review } from "./workflows"`,
647
708
  `export const base = (w: string) => (w === "review" ? "develop" : undefined)`,
648
709
  ``,
649
710
  ].join("\n"),
@@ -652,8 +713,8 @@ describe("ConfigService", () => {
652
713
  })
653
714
 
654
715
  it("a door several workflows build gets no door when none is exported under its name, until the doors export declares it", async () => {
655
- const both = `export { feature as default, fix as fix2, fix as fix3 } from "@pmelab/gtd/workflows"`
656
- writeFileSync(join(projectDir, "gtd.config.ts"), `${both}\n`)
716
+ const both = `export { feature as default, fix as fix2, fix as fix3 } from "./workflows"`
717
+ withFactories(`${both}\n`)
657
718
  expect((await getConfig()).doors.get("fix")).toBeUndefined()
658
719
  writeFileSync(
659
720
  join(projectDir, "gtd.config.ts"),
@@ -663,9 +724,8 @@ describe("ConfigService", () => {
663
724
  })
664
725
 
665
726
  it("a door clash warns once at doors.<name>, builders sorted", async () => {
666
- writeFileSync(
667
- join(projectDir, "gtd.config.ts"),
668
- `export { feature as default, fix as fix3, fix as fix4, fix as fix2 } from "@pmelab/gtd/workflows"\n`,
727
+ withFactories(
728
+ `export { feature as default, fix as fix3, fix as fix4, fix as fix2 } from "./workflows"\n`,
669
729
  )
670
730
  const cfg = await getConfig()
671
731
  const clash = cfg.warnings.filter((w) => w.path[0] === "doors")
@@ -680,70 +740,38 @@ describe("ConfigService", () => {
680
740
  })
681
741
 
682
742
  it("no clash warning when an export is named after the door, one workflow builds it, or doors declares it", async () => {
683
- const both = `export { feature as default, fix as fix2, fix as fix3 } from "@pmelab/gtd/workflows"`
743
+ const both = `export { feature as default, fix as fix2, fix as fix3 } from "./workflows"`
684
744
  for (const body of [
685
- `export { feature as default, fix as fix2, fix } from "@pmelab/gtd/workflows"\n`,
686
- `export { feature as default, fix } from "@pmelab/gtd/workflows"\n`,
745
+ `export { feature as default, fix as fix2, fix } from "./workflows"\n`,
746
+ `export { feature as default, fix } from "./workflows"\n`,
687
747
  `${both}\nexport const doors = { fix: { workflow: "fix3" } }\n`,
688
748
  ]) {
689
- writeFileSync(join(projectDir, "gtd.config.ts"), body)
749
+ withFactories(body)
690
750
  expect((await getConfig()).warnings.filter((w) => w.path[0] === "doors")).toEqual([])
691
751
  }
692
752
  })
693
753
 
694
754
  it("door ownership does not depend on export order", async () => {
695
- writeFileSync(
696
- join(projectDir, "gtd.config.ts"),
697
- `export { feature as default, fix as fix2, fix } from "@pmelab/gtd/workflows"\n`,
698
- )
755
+ withFactories(`export { feature as default, fix as fix2, fix } from "./workflows"\n`)
699
756
  expect((await getConfig()).doors.get("fix")?.workflow).toBe("fix")
700
757
  })
701
758
 
702
759
  it("rollout and a second instance of it load, door rollout running workflow rollout whichever export comes first", async () => {
703
760
  for (const body of [
704
- `export { rollout } from "@pmelab/gtd/workflows"\nimport { rollout } from "@pmelab/gtd/workflows"\nexport const elsewhere = rollout({ flagRegistry: "x.ts" })`,
705
- `import { rollout } from "@pmelab/gtd/workflows"\nexport const elsewhere = rollout({ flagRegistry: "x.ts" })\nexport { rollout }`,
761
+ `export { rollout } from "./workflows"\nimport { rollout } from "./workflows"\nexport const elsewhere = rollout({ flagRegistry: "x.ts" })`,
762
+ `import { rollout } from "./workflows"\nexport const elsewhere = rollout({ flagRegistry: "x.ts" })\nexport { rollout }`,
706
763
  ]) {
707
- writeFileSync(
708
- join(projectDir, "gtd.config.ts"),
709
- `export { feature as default } from "@pmelab/gtd/workflows"\n${body}\n`,
710
- )
764
+ withFactories(`export { feature as default } from "./workflows"\n${body}\n`)
711
765
  expect((await getConfig()).doors.get("rollout")?.workflow).toBe("rollout")
712
766
  }
713
767
  })
714
768
 
715
- it("shipped rollout and cleanup list their doors beside fix and review", async () => {
716
- writeFileSync(
717
- join(projectDir, "gtd.config.ts"),
718
- `export { feature as default, fix, review, rollout, cleanup } from "@pmelab/gtd/workflows"\n`,
719
- )
720
- const { doors } = await getConfig()
721
- expect([...doors.keys()]).toEqual(["cleanup", "fix", "review", "rollout"])
722
- expect(doors.get("rollout")?.args).toEqual([{ name: "flag" }])
723
- expect(doors.get("cleanup")?.args).toEqual([{ name: "flag" }])
724
- })
725
-
726
- it("rollout and cleanup load from their own subpaths", async () => {
727
- writeFileSync(
728
- join(projectDir, "gtd.config.ts"),
729
- [
730
- `export { feature as default, fix, review } from "@pmelab/gtd/workflows"`,
731
- `export { rollout } from "@pmelab/gtd/workflows/rollout"`,
732
- `export { cleanup } from "@pmelab/gtd/workflows/cleanup"`,
733
- ``,
734
- ].join("\n"),
735
- )
736
- const { doors } = await getConfig()
737
- expect([...doors.keys()]).toEqual(["cleanup", "fix", "review", "rollout"])
738
- expect(doors.get("rollout")?.args).toEqual([{ name: "flag" }])
739
- expect(doors.get("cleanup")?.args).toEqual([{ name: "flag" }])
740
- })
741
-
742
769
  it("a config's doors merge derived doors (exported workflows) with declared doors (doors export)", async () => {
743
770
  withDoors(
744
771
  `{ hot: { workflow: "hotfix", args: [{ name: "ticket" }] } }`,
745
- 'export { fix } from "@pmelab/gtd/workflows"',
772
+ 'export { fix } from "./workflows"',
746
773
  )
774
+ writeFileSync(join(projectDir, "workflows.ts"), FACTORIES)
747
775
  const { doors } = await getConfig()
748
776
  expect([...doors.keys()].sort()).toEqual(["fix", "hot"])
749
777
  expect(doors.get("fix")).toBeDefined()
@@ -939,10 +967,7 @@ describe("ConfigService", () => {
939
967
 
940
968
  describe("ConfigService — workflow factory options", () => {
941
969
  const configWith = (body: string) =>
942
- writeFileSync(
943
- join(projectDir, "gtd.config.ts"),
944
- [`import { feature, fix, review } from "@pmelab/gtd/workflows"`, body, ``].join("\n"),
945
- )
970
+ withFactories([`import { feature, fix, review } from "./workflows"`, body, ``].join("\n"))
946
971
 
947
972
  it("an option given in gtd.config.ts becomes the workflow's setting of its kind", async () => {
948
973
  configWith(
@@ -115,7 +115,7 @@ export const load: Effect.Effect<
115
115
  const missing: Diagnostic = {
116
116
  severity: "error",
117
117
  path: [],
118
- message: `no workflows configured: no ${WORKFLOW_MODULE} here or above — run \`gtd install\` to scaffold one`,
118
+ message: `no workflows configured: no ${WORKFLOW_MODULE} here or above — gtd ships no workflow; \`gtd install\` says where to get one`,
119
119
  origin: host.root,
120
120
  }
121
121
  return yield* Effect.fail(configError([missing]))
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest"
2
- import { resolveStartVars, resolveVars } from "./vars.js"
2
+ import { ownFlagRefusal, resolveStartVars, resolveVars } from "./vars.js"
3
3
 
4
4
  describe("resolveVars — GTD_FLAG_* belongs to feature flags", () => {
5
5
  it.each(["flag_x", "FLAG_X", "Flag_x"])("%s keeps its default with GTD_FLAG_X set", (name) => {
@@ -30,9 +30,36 @@ describe("resolveStartVars", () => {
30
30
  })
31
31
  })
32
32
 
33
- it("GTD_FLAG_X never overrides, while GTD_<NAME> still does for other names", () => {
33
+ it("GTD_FLAG_X never overrides one of gtd's own flags, while GTD_<NAME> still does for other names", () => {
34
34
  expect(
35
- resolveStartVars({ flag_x: "0", a: "1" }, {}, {}, { GTD_FLAG_X: "1", GTD_A: "2" }),
35
+ resolveStartVars(
36
+ { flag_x: "0", a: "1" },
37
+ { flag_x: "0" },
38
+ {},
39
+ { GTD_FLAG_X: "1", GTD_A: "2" },
40
+ ),
36
41
  ).toEqual({ flag_x: "0", a: "2" })
37
42
  })
43
+
44
+ it("pins a workflow's own flag from GTD_FLAG_<NAME>, below --var", () => {
45
+ expect(resolveStartVars({ flag_own: "0" }, {}, {}, { GTD_FLAG_OWN: "true" })).toEqual({
46
+ flag_own: "1",
47
+ })
48
+ expect(
49
+ resolveStartVars({ flag_own: "0" }, {}, { flag_own: "0" }, { GTD_FLAG_OWN: "1" }),
50
+ ).toEqual({
51
+ flag_own: "0",
52
+ })
53
+ expect(resolveStartVars({ flag_own: "1" }, {}, {}, {})).toEqual({ flag_own: "1" })
54
+ })
55
+ })
56
+
57
+ describe("ownFlagRefusal", () => {
58
+ it("refuses a workflow's own flag set to anything but 1, 0, true or false", () => {
59
+ expect(ownFlagRefusal({ flag_own: "0" }, {}, { GTD_FLAG_OWN: "yes" })).toBe(
60
+ "GTD_FLAG_OWN must be 1, 0, true or false",
61
+ )
62
+ expect(ownFlagRefusal({ flag_own: "0" }, {}, { GTD_FLAG_OWN: "TRUE" })).toBeUndefined()
63
+ expect(ownFlagRefusal({ flag_x: "0" }, { flag_x: "1" }, { GTD_FLAG_X: "yes" })).toBeUndefined()
64
+ })
38
65
  })
@@ -29,10 +29,38 @@ export const resolveVars = (
29
29
  return merged
30
30
  }
31
31
 
32
+ const FLAG_VALUES: Readonly<Record<string, string>> = { "0": "0", false: "0", "1": "1", true: "1" }
33
+
34
+ const flagVariable = (setting: string): string =>
35
+ `GTD_FLAG_${setting.slice("flag_".length).toUpperCase()}`
36
+
37
+ /** The `flag_<name>` settings a workflow declares that gtd's own flags lack: the workflow's own release flags. */
38
+ const ownFlags = (
39
+ workflowVars: Readonly<Record<string, string>>,
40
+ flags: Readonly<Record<string, string>>,
41
+ ): string[] =>
42
+ Object.keys(workflowVars).filter(
43
+ (name) => name.startsWith("flag_") && !Object.hasOwn(flags, name),
44
+ )
45
+
46
+ /** Why a workflow's own release flag cannot start, if one is set to anything but 1, 0, true or false. */
47
+ export const ownFlagRefusal = (
48
+ workflowVars: Readonly<Record<string, string>>,
49
+ flags: Readonly<Record<string, string>>,
50
+ env: Readonly<Record<string, string | undefined>>,
51
+ ): string | undefined => {
52
+ const bad = ownFlags(workflowVars, flags).find((name) => {
53
+ const raw = env[flagVariable(name)] ?? ""
54
+ return raw !== "" && !Object.hasOwn(FLAG_VALUES, raw.toLowerCase())
55
+ })
56
+ return bad === undefined ? undefined : `${flagVariable(bad)} must be 1, 0, true or false`
57
+ }
58
+
32
59
  /**
33
60
  * `resolveVars` for a process being STARTED: `flags` land between the workflow
34
- * defaults and `--var`, for already-declared names only. A running process never
35
- * calls this — it replays its pinned record.
61
+ * defaults and `--var`, for already-declared names only, and so does
62
+ * `GTD_FLAG_<NAME>` for a `flag_<name>` the workflow declares itself. A running
63
+ * process never calls this — it replays its pinned record.
36
64
  */
37
65
  export const resolveStartVars = (
38
66
  workflowVars: Readonly<Record<string, string>>,
@@ -43,6 +71,10 @@ export const resolveStartVars = (
43
71
  const declared = Object.fromEntries(
44
72
  Object.entries(flags).filter(([name]) => Object.hasOwn(workflowVars, name)),
45
73
  )
74
+ for (const name of ownFlags(workflowVars, flags)) {
75
+ const value = FLAG_VALUES[(env[flagVariable(name)] ?? "").toLowerCase()]
76
+ if (value !== undefined) declared[name] = value
77
+ }
46
78
  return resolveVars({ ...workflowVars, ...declared }, entryVars, env)
47
79
  }
48
80
 
@@ -1,4 +1,4 @@
1
- // Building blocks the shipped workflows share. Policy-free: every skill list,
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
4
  export * from "./health.js"