@pmelab/gtd 27.9.0 → 28.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 (43) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +53 -27
  3. package/claude/hooks/doors.ts +12 -0
  4. package/claude/hooks/register.tsx +2 -4
  5. package/claude/hooks/ship.ts +115 -20
  6. package/dist/chunks/{reserved-UdSuHDUJ.mjs → reserved-DeBS4Ymh.mjs} +178 -32
  7. package/dist/chunks/{tail-fj60rQ3y.mjs → tail-B2SER2Ih.mjs} +1 -1
  8. package/dist/gtd.bundle.mjs +209 -72
  9. package/dist/testkit.mjs +209 -72
  10. package/dist/workflows/cleanup.mjs +3 -4
  11. package/dist/workflows/feature.mjs +2 -2
  12. package/dist/workflows/fix.mjs +2 -2
  13. package/dist/workflows/review.mjs +2 -2
  14. package/dist/workflows/rollout.mjs +19 -4
  15. package/dist/workflows.mjs +196 -132
  16. package/package.json +1 -1
  17. package/skills/authoring/SKILL.md +4 -3
  18. package/src/cli/Cli.test.ts +39 -0
  19. package/src/cli/Cli.ts +41 -9
  20. package/src/cli/Program.test.ts +31 -0
  21. package/src/cli/Program.ts +14 -7
  22. package/src/edge/Edge.test.ts +259 -3
  23. package/src/edge/Edge.ts +175 -58
  24. package/src/flows/runtime.ts +20 -1
  25. package/src/install/ConfigScaffold.ts +4 -1
  26. package/src/install/Install.test.ts +10 -0
  27. package/src/install/Install.ts +5 -2
  28. package/src/workflow/load.test.ts +82 -5
  29. package/src/workflow/load.ts +40 -16
  30. package/src/workflows/cleanup/cleanup.feature +21 -0
  31. package/src/workflows/cleanup/cleanup.ts +1 -1
  32. package/src/workflows/index.ts +2 -1
  33. package/src/workflows/rollout/rollout.feature +282 -2
  34. package/src/workflows/rollout/rollout.test.ts +96 -2
  35. package/src/workflows/rollout/rollout.ts +41 -3
  36. package/src/workflows/shared/options.test.ts +25 -2
  37. package/src/workflows/shared/options.ts +7 -4
  38. package/src/workflows/shared/registry.test.ts +41 -1
  39. package/src/workflows/shared/registry.ts +26 -1
  40. package/src/workflows/shared/text.test.ts +377 -2
  41. package/src/workflows/shared/text.ts +65 -7
  42. package/src/workflows/shared/vars.ts +2 -0
  43. package/dist/chunks/registry-CP3YuV9j.mjs +0 -101
@@ -490,17 +490,36 @@ export const openQuestions = (text: string): readonly OpenQuestion[] => ctx().op
490
490
  /** A workflow's one flow. */
491
491
  export type Flow = () => Promise<void>
492
492
 
493
+ /** The slice `gtd summary --slice` writes the closing message for. */
494
+ export interface SummarySlice {
495
+ readonly name: string
496
+ /** Exclusive, as in git's `base..tip`. */
497
+ readonly base: string
498
+ readonly tip: string
499
+ /** 1-based stack position: `index === count` is the last slice. */
500
+ readonly index: number
501
+ readonly count: number
502
+ }
503
+
493
504
  export interface SummaryContext {
505
+ /** Whole process, even under a slice. */
494
506
  readonly entryCommit: string
507
+ /** The slice's `base` under `--slice`. */
495
508
  readonly processBase: string
509
+ /** The slice's `tip` under `--slice`. */
496
510
  readonly processTip: string
511
+ /** The slice's own under `--slice`. */
497
512
  readonly humanCommits: readonly { readonly hash: string; readonly state: string }[]
513
+ /** The slice's own under `--slice`. */
498
514
  readonly processCost: number
515
+ /** The slice's own under `--slice`. */
499
516
  readonly processCostByModel: readonly { readonly model: string; readonly cost: number }[]
500
- /** Every `Gtd-Mark` of the process's commits, oldest first. */
517
+ /** Every `Gtd-Mark` of the process's commits, oldest first — whole process, even under a slice. */
501
518
  readonly marks: readonly Mark[]
502
519
  readonly vars: Readonly<Record<string, string>>
503
520
  readonly env: Readonly<Record<string, string>>
521
+ /** Present only under `gtd summary --slice`; the key is absent, not undefined, otherwise. */
522
+ readonly slice?: SummarySlice
504
523
  }
505
524
 
506
525
  /** `gtd summary`'s prompt — a workflow module's optional `summary` export. */
@@ -3,7 +3,10 @@
3
3
  export const CONFIG_SCAFFOLD = `// The workflows gtd runs here: gtd's shipped feature, review and fix.
4
4
  // Every exported function is startable with \`gtd --workflow <name>\`; the
5
5
  // default export is what a change in the tree starts. Each workflow brings
6
- // its own doors: \`gtd door review [base]\` and \`gtd door fix\`.
6
+ // its own doors: \`gtd door review [base]\` and \`gtd door fix\`. Export \`rollout\`
7
+ // from \`@pmelab/gtd/workflows/rollout\` and \`cleanup\` from
8
+ // \`@pmelab/gtd/workflows/cleanup\` to add \`gtd door rollout <flag>\` and
9
+ // \`gtd door cleanup <flag>\`.
7
10
  export {
8
11
  feature as default,
9
12
  feature,
@@ -10,6 +10,7 @@ import { resolve } from "node:path"
10
10
  import { createRequire } from "node:module"
11
11
  import { describe, expect, it } from "vitest"
12
12
  import { EDIT_COMMAND, DOOR_COMMAND, MINIMAL_DRIVER, renderBriefing } from "./Install.js"
13
+ import { CONFIG_SCAFFOLD } from "./index.js"
13
14
 
14
15
  const SUITE_PATHS = ["gtd-build", "gtd-edit", "gtd-door"]
15
16
  const SUPERSEDED_PATHS = ["gtd-fix", "gtd-review"]
@@ -424,3 +425,12 @@ describe("DOOR_COMMAND", () => {
424
425
  expect(DOOR_COMMAND).toMatch(/usage: gtd-door <name> \[args\.\.\.\]" >&2\n\s*exit 2/)
425
426
  })
426
427
  })
428
+
429
+ describe("bundled door lists", () => {
430
+ it("the scaffold's exports stay feature, review, fix, never rollout or cleanup", () => {
431
+ const code = CONFIG_SCAFFOLD.split("\n")
432
+ .filter((l) => !l.startsWith("//"))
433
+ .join("\n")
434
+ expect(code).not.toMatch(/rollout|cleanup/)
435
+ })
436
+ })
@@ -515,8 +515,11 @@ hands off to \`gtd-build\`, which halts at the blocked gate and prints it,
515
515
  rather than starting the review; \`gtd-door fix\` on a GREEN suite is a no-op
516
516
  straight back to \`idle\` — the exec'd \`gtd-build\` exits immediately on it.
517
517
  \`gtd doors\` lists the doors a repository offers (those of the workflows its
518
- config exports — the bundled \`fix\` and \`review [base]\` — plus any it
519
- declares). Refuses with
518
+ config exports — the bundled \`fix\` and \`review [base]\`, plus the opt-in
519
+ \`rollout <flag>\` (turn a release flag default-on) and \`cleanup <flag>\`
520
+ (remove it), which a config adds by exporting \`rollout\` from
521
+ \`@pmelab/gtd/workflows/rollout\` and \`cleanup\` from
522
+ \`@pmelab/gtd/workflows/cleanup\` — plus any it declares). Refuses with
520
523
  \`usage: gtd-door <name> [args...]\` on stderr and exit \`2\` when the name is
521
524
  missing — nothing was even attempted. Default path: \`~/.local/bin/gtd-door\`.
522
525
 
@@ -651,15 +651,92 @@ describe("ConfigService", () => {
651
651
  expect((await getConfig()).workflowNamed("review")?.def.base?.("review", {})).toBe("develop")
652
652
  })
653
653
 
654
- it("a door two workflows build is a config error until the doors export settles it", async () => {
655
- const both = `export { feature as default, fix, fix as fix2 } from "@pmelab/gtd/workflows"`
654
+ 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
656
  writeFileSync(join(projectDir, "gtd.config.ts"), `${both}\n`)
657
- await expect(getConfig()).rejects.toThrow(/door "fix" is built by workflows "fix" and "fix2"/)
657
+ expect((await getConfig()).doors.get("fix")).toBeUndefined()
658
658
  writeFileSync(
659
659
  join(projectDir, "gtd.config.ts"),
660
- `${both}\nexport const doors = { fix: { workflow: "fix2" } }\n`,
660
+ `${both}\nexport const doors = { fix: { workflow: "fix3" } }\n`,
661
661
  )
662
- expect((await getConfig()).doors.get("fix")?.workflow).toBe("fix2")
662
+ expect((await getConfig()).doors.get("fix")?.workflow).toBe("fix3")
663
+ })
664
+
665
+ 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`,
669
+ )
670
+ const cfg = await getConfig()
671
+ const clash = cfg.warnings.filter((w) => w.path[0] === "doors")
672
+ expect(clash).toEqual([
673
+ {
674
+ severity: "warning",
675
+ path: ["doors", "fix"],
676
+ origin: join(projectDir, "gtd.config.ts"),
677
+ message: `door "fix" is built by workflows "fix2", "fix3" and "fix4" and none is exported under that name — declare it in the "doors" export`,
678
+ },
679
+ ])
680
+ })
681
+
682
+ 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"`
684
+ 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`,
687
+ `${both}\nexport const doors = { fix: { workflow: "fix3" } }\n`,
688
+ ]) {
689
+ writeFileSync(join(projectDir, "gtd.config.ts"), body)
690
+ expect((await getConfig()).warnings.filter((w) => w.path[0] === "doors")).toEqual([])
691
+ }
692
+ })
693
+
694
+ 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
+ )
699
+ expect((await getConfig()).doors.get("fix")?.workflow).toBe("fix")
700
+ })
701
+
702
+ it("rollout and a second instance of it load, door rollout running workflow rollout whichever export comes first", async () => {
703
+ 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 }`,
706
+ ]) {
707
+ writeFileSync(
708
+ join(projectDir, "gtd.config.ts"),
709
+ `export { feature as default } from "@pmelab/gtd/workflows"\n${body}\n`,
710
+ )
711
+ expect((await getConfig()).doors.get("rollout")?.workflow).toBe("rollout")
712
+ }
713
+ })
714
+
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" }])
663
740
  })
664
741
 
665
742
  it("a config's doors merge derived doors (exported workflows) with declared doors (doors export)", async () => {
@@ -548,31 +548,43 @@ const doorsFrom = (exports: Record<string, unknown>): ReadonlyMap<string, Door>
548
548
 
549
549
  /**
550
550
  * Each exported workflow's own doors, onto the name it is exported under. The
551
- * `doors` export adds to them and settles a name two workflows both declare.
551
+ * `doors` export adds to them and declares a name several workflows clash on.
552
552
  */
553
553
  const derivedDoors = (
554
554
  catalogue: ReadonlyMap<string, LoadedWorkflow>,
555
555
  declared: ReadonlyMap<string, Door>,
556
- ): ReadonlyMap<string, Door> => {
557
- const doors = new Map<string, Door>()
556
+ ): {
557
+ readonly doors: ReadonlyMap<string, Door>
558
+ readonly clashes: ReadonlyMap<string, readonly string[]>
559
+ } => {
560
+ const builders = new Map<string, Door[]>()
558
561
  for (const [workflow, loaded] of catalogue) {
559
562
  for (const [name, spec] of Object.entries(loaded.doors ?? {})) {
560
563
  const door: Door = { ...spec, workflow }
561
564
  const problem = doorProblem(name, door)
562
565
  if (problem !== undefined)
563
566
  throw new Error(`workflow "${workflow}" builds door "${name}", which ${problem}`)
564
- const other = doors.get(name)
565
- if (other !== undefined && !declared.has(name)) {
566
- throw new Error(
567
- `door "${name}" is built by workflows "${other.workflow}" and "${workflow}" — declare it in the "doors" export to choose one (a declared door replaces the derived one, so restate its args and vars)`,
568
- )
569
- }
570
- doors.set(name, door)
567
+ builders.set(name, [...(builders.get(name) ?? []), door])
571
568
  }
572
569
  }
573
- return new Map([...doors, ...declared])
570
+ const doors = new Map<string, Door>()
571
+ const clashes = new Map<string, readonly string[]>()
572
+ for (const [name, built] of builders) {
573
+ const owner = ownerOf(name, built)
574
+ if (owner !== undefined) doors.set(name, owner)
575
+ else if (!declared.has(name))
576
+ clashes.set(
577
+ name,
578
+ built.map((door) => door.workflow),
579
+ )
580
+ }
581
+ return { doors: new Map([...doors, ...declared]), clashes }
574
582
  }
575
583
 
584
+ /** The export named after the door owns it, else the sole builder; a clash of several builds no door. */
585
+ const ownerOf = (name: string, built: readonly Door[]): Door | undefined =>
586
+ built.find((door) => door.workflow === name) ?? (built.length === 1 ? built[0] : undefined)
587
+
576
588
  /** Sort the doors and check each names a startable workflow. */
577
589
  const checkDoors = (
578
590
  declared: ReadonlyMap<string, Door>,
@@ -628,16 +640,28 @@ const loadWorkflows = (module: WorkflowModule, shipped: ShippedModules): Loaded
628
640
  }),
629
641
  )
630
642
  const defaultWorkflow = built.get(value)
643
+ const derived =
644
+ rejections.length > 0
645
+ ? { doors: new Map<string, Door>(), clashes: new Map<string, readonly string[]>() }
646
+ : derivedDoors(catalogue, doorsFrom(exports))
631
647
  return {
632
648
  file,
633
649
  catalogue,
634
- doors:
635
- rejections.length > 0
636
- ? new Map()
637
- : checkDoors(derivedDoors(catalogue, doorsFrom(exports)), catalogue),
650
+ doors: checkDoors(derived.doors, catalogue),
638
651
  default: defaultWorkflow && { ...defaultWorkflow, name: defaultName },
639
652
  config: compileConfig(exports["config"], module.filepath),
640
- diagnostics: nameRejections(rejections),
653
+ diagnostics: [
654
+ ...nameRejections(rejections),
655
+ ...[...derived.clashes].map(([name, builders]): Diagnostic => {
656
+ const names = [...builders].sort().map((b) => JSON.stringify(b))
657
+ return {
658
+ 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`,
660
+ path: ["doors", name],
661
+ origin: file.origin,
662
+ }
663
+ }),
664
+ ],
641
665
  }
642
666
  }
643
667
 
@@ -75,6 +75,27 @@ Feature: cleanup removes a default-on release flag and keeps its flag-on path
75
75
  And the process is driven
76
76
  Then the process rests at "build.review.reviewing"
77
77
 
78
+ @inmem
79
+ Scenario: gtd door cleanup starts the cleanup of its flag argument and reaches the review
80
+ Given step "cleanup.removing" writes "src/flags/index.ts" with:
81
+ """
82
+ export const FLAGS = [
83
+ { name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release" },
84
+ ] as const
85
+ """
86
+ And step "cleanup.removing" writes "src/calc.ts" with:
87
+ """
88
+ export const on = true
89
+ """
90
+ And step "build.health.check" changes nothing
91
+ When gtd opens door "cleanup" with "live"
92
+ And the process is driven
93
+ Then the process rests at "build.review.reviewing"
94
+
95
+ @inmem
96
+ Scenario: gtd door cleanup with no argument is refused naming the flag
97
+ Then opening door "cleanup" is refused with "usage: gtd door cleanup <flag>"
98
+
78
99
  @inmem
79
100
  Scenario: a fix after a red suite that restores the registry entry is refused
80
101
  Given step "cleanup.removing" writes "src/flags/index.ts" with:
@@ -49,4 +49,4 @@ export const cleanupFlow = async (): Promise<void> => {
49
49
  await handOffTail(false, start(), guard)
50
50
  }
51
51
 
52
- export const cleanup = flagShipped(cleanupFlow)
52
+ export const cleanup = flagShipped(cleanupFlow, "cleanup")
@@ -35,6 +35,7 @@ export default feature
35
35
 
36
36
  /**
37
37
  * @deprecated Empty: each shipped workflow builds its own doors, so a config
38
- * gets `fix` and `review [base]` by exporting `fix` and `review`.
38
+ * gets `fix` and `review [base]` by exporting `fix` and `review`, and
39
+ * `rollout <flag>` and `cleanup <flag>` by exporting `rollout` and `cleanup`.
39
40
  */
40
41
  export const doors: Doors = {}
@@ -47,11 +47,16 @@ Feature: rollout turns one release flag default-on and touches nothing else
47
47
  ] as const
48
48
  """
49
49
  And step "rollout.suite.check" changes nothing
50
+ And step "rollout.mentions" runs its script
50
51
  When gtd starts workflow "rollout" with "--var flag=alpha"
51
52
  And the process is driven
52
53
  Then the process rests at "idle"
53
54
  And "src/flags/index.ts" contains "kind: \"release\", defaultOn: true },"
54
- And the last commit subject is "gtd(check): rollout.suite.check → idle"
55
+ And the commit subjects end with:
56
+ """
57
+ gtd(check): rollout.suite.check → rollout.mentions
58
+ gtd(check): rollout.mentions → idle
59
+ """
55
60
 
56
61
  Scenario: a turn also changing a source file is refused naming that path
57
62
  Given step "rollout.flipping" writes "src/flags/index.ts" with:
@@ -98,11 +103,34 @@ Feature: rollout turns one release flag default-on and touches nothing else
98
103
  ] as const
99
104
  """
100
105
  And step "rollout.suite.check" changes nothing
106
+ And step "rollout.mentions" runs its script
101
107
  When gtd starts workflow "elsewhere" with "--var flag=gamma"
102
108
  And the process is driven
103
109
  Then the process rests at "idle"
104
110
  And "config/flags.ts" contains "defaultOn: true"
105
111
 
112
+ @inmem
113
+ Scenario: gtd door rollout starts the rollout of its flag argument
114
+ Given step "rollout.flipping" writes "src/flags/index.ts" with:
115
+ """
116
+ export const FLAGS = [
117
+ { name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release", defaultOn: true },
118
+ { name: "beta", owner: "b", expiry: "2027-02-01", kind: "release" },
119
+ { name: "ops1", owner: "c", expiry: "2027-03-01", kind: "ops" },
120
+ { name: "live", owner: "d", expiry: "2027-04-01", kind: "release", defaultOn: true },
121
+ ] as const
122
+ """
123
+ And step "rollout.suite.check" changes nothing
124
+ And step "rollout.mentions" runs its script
125
+ When gtd opens door "rollout" with "alpha"
126
+ And the process is driven
127
+ Then the process rests at "idle"
128
+ And "src/flags/index.ts" contains "kind: \"release\", defaultOn: true },"
129
+
130
+ @inmem
131
+ Scenario: gtd door rollout with no argument is refused naming the flag
132
+ Then opening door "rollout" is refused with "usage: gtd door rollout <flag>"
133
+
106
134
  Scenario: a missing flag var is refused before any turn
107
135
  Then starting workflow "rollout" with "" is refused with "gtd: rollout needs --var flag=<name>"
108
136
 
@@ -209,6 +237,258 @@ Feature: rollout turns one release flag default-on and touches nothing else
209
237
  fixed by hand
210
238
  """
211
239
  And step "rollout.suite.check" from now on changes nothing
240
+ And step "rollout.mentions" runs its script
212
241
  When the process is driven
213
242
  Then the process rests at "idle"
214
- And the last commit subject is "gtd(check): rollout.suite.check → idle"
243
+ And the commit subjects end with:
244
+ """
245
+ gtd(check): rollout.suite.check → rollout.mentions
246
+ gtd(check): rollout.mentions → idle
247
+ """
248
+
249
+ @inmem
250
+ Scenario: a stray file written at the mentions step is refused naming that path
251
+ Given step "rollout.flipping" writes "src/flags/index.ts" with:
252
+ """
253
+ export const FLAGS = [
254
+ { name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release", defaultOn: true },
255
+ { name: "beta", owner: "b", expiry: "2027-02-01", kind: "release" },
256
+ { name: "ops1", owner: "c", expiry: "2027-03-01", kind: "ops" },
257
+ { name: "live", owner: "d", expiry: "2027-04-01", kind: "release", defaultOn: true },
258
+ ] as const
259
+ """
260
+ And step "rollout.suite.check" changes nothing
261
+ And step "rollout.mentions" runs its script
262
+ And step "rollout.mentions" writes "src/stray.ts" with:
263
+ """
264
+ export const x = 1
265
+ """
266
+ When gtd starts workflow "rollout" with "--var flag=alpha"
267
+ Then driving the process is refused with "rollout-only: src/stray.ts changed"
268
+
269
+ @inmem
270
+ Scenario: a second registry entry edited at the mentions step is refused
271
+ Given step "rollout.flipping" writes "src/flags/index.ts" with:
272
+ """
273
+ export const FLAGS = [
274
+ { name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release", defaultOn: true },
275
+ { name: "beta", owner: "b", expiry: "2027-02-01", kind: "release" },
276
+ { name: "ops1", owner: "c", expiry: "2027-03-01", kind: "ops" },
277
+ { name: "live", owner: "d", expiry: "2027-04-01", kind: "release", defaultOn: true },
278
+ ] as const
279
+ """
280
+ And step "rollout.suite.check" changes nothing
281
+ And step "rollout.mentions" runs its script
282
+ And step "rollout.mentions" writes "src/flags/index.ts" with:
283
+ """
284
+ export const FLAGS = [
285
+ { name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release", defaultOn: true },
286
+ { name: "beta", owner: "z", expiry: "2027-02-01", kind: "release" },
287
+ { name: "ops1", owner: "c", expiry: "2027-03-01", kind: "ops" },
288
+ { name: "live", owner: "d", expiry: "2027-04-01", kind: "release", defaultOn: true },
289
+ ] as const
290
+ """
291
+ When gtd starts workflow "rollout" with "--var flag=alpha"
292
+ Then driving the process is refused with "rollout-only: other edits in src/flags/index.ts"
293
+
294
+ @inmem
295
+ Scenario: every line naming the flag is listed, no file is edited, and gtd summary repeats the list
296
+ Given a file "README.md" with:
297
+ """
298
+ # calc
299
+
300
+ The alpha formatter is on under GTD_FLAG_ALPHA=1.
301
+ """
302
+ And a file "src/calc.ts" with:
303
+ """
304
+ export const help = "pass --flag_alpha to enable the alpha formatter"
305
+ """
306
+ And the working tree is committed as "docs: mention the flag"
307
+ And step "rollout.flipping" writes "src/flags/index.ts" with:
308
+ """
309
+ export const FLAGS = [
310
+ { name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release", defaultOn: true },
311
+ { name: "beta", owner: "b", expiry: "2027-02-01", kind: "release" },
312
+ { name: "ops1", owner: "c", expiry: "2027-03-01", kind: "ops" },
313
+ { name: "live", owner: "d", expiry: "2027-04-01", kind: "release", defaultOn: true },
314
+ ] as const
315
+ """
316
+ And step "rollout.suite.check" changes nothing
317
+ And step "rollout.mentions" runs its script
318
+ When gtd starts workflow "rollout" with "--var flag=alpha"
319
+ And the process is driven
320
+ Then the process rests at "idle"
321
+ And ".gtd/FEEDBACK.md" does not exist
322
+ And "README.md" contains "The alpha formatter is on under GTD_FLAG_ALPHA=1."
323
+ And "src/calc.ts" contains "pass --flag_alpha to enable the alpha formatter"
324
+ And the output of gtd "summary" contains "`README.md:3`"
325
+ And the output of gtd "summary" contains "src/calc.ts:1"
326
+
327
+ @inmem
328
+ Scenario: gtd summary's grep hint excludes a factory flagRegistry
329
+ Given a file "config/flags.ts" with:
330
+ """
331
+ export const FLAGS = [
332
+ { name: "alpha", owner: "g", expiry: "2027-05-01", kind: "release" },
333
+ ] as const
334
+ """
335
+ And a file "NOTES.md" with:
336
+ """
337
+ alpha 1
338
+ alpha 2
339
+ alpha 3
340
+ alpha 4
341
+ alpha 5
342
+ alpha 6
343
+ alpha 7
344
+ alpha 8
345
+ alpha 9
346
+ alpha 10
347
+ alpha 11
348
+ alpha 12
349
+ alpha 13
350
+ alpha 14
351
+ alpha 15
352
+ alpha 16
353
+ alpha 17
354
+ alpha 18
355
+ alpha 19
356
+ alpha 20
357
+ alpha 21
358
+ alpha 22
359
+ alpha 23
360
+ alpha 24
361
+ alpha 25
362
+ alpha 26
363
+ alpha 27
364
+ alpha 28
365
+ alpha 29
366
+ alpha 30
367
+ alpha 31
368
+ alpha 32
369
+ alpha 33
370
+ alpha 34
371
+ alpha 35
372
+ alpha 36
373
+ alpha 37
374
+ alpha 38
375
+ alpha 39
376
+ alpha 40
377
+ alpha 41
378
+ alpha 42
379
+ alpha 43
380
+ alpha 44
381
+ alpha 45
382
+ alpha 46
383
+ alpha 47
384
+ alpha 48
385
+ alpha 49
386
+ alpha 50
387
+ alpha 51
388
+ alpha 52
389
+ alpha 53
390
+ alpha 54
391
+ alpha 55
392
+ """
393
+ And the working tree is committed as "docs: mention the flag"
394
+ And step "rollout.flipping" writes "config/flags.ts" with:
395
+ """
396
+ export const FLAGS = [
397
+ { name: "alpha", owner: "g", expiry: "2027-05-01", kind: "release", defaultOn: true },
398
+ ] as const
399
+ """
400
+ And step "rollout.suite.check" changes nothing
401
+ And step "rollout.mentions" runs its script
402
+ When gtd starts workflow "elsewhere" with "--var flag=alpha"
403
+ And the process is driven
404
+ Then the process rests at "idle"
405
+ And the output of gtd "summary" contains "':(top,exclude,literal)config/flags.ts'"
406
+
407
+ @inmem
408
+ Scenario: a flag named nowhere outside the registry says so in gtd summary
409
+ Given step "rollout.flipping" writes "src/flags/index.ts" with:
410
+ """
411
+ export const FLAGS = [
412
+ { name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release", defaultOn: true },
413
+ { name: "beta", owner: "b", expiry: "2027-02-01", kind: "release" },
414
+ { name: "ops1", owner: "c", expiry: "2027-03-01", kind: "ops" },
415
+ { name: "live", owner: "d", expiry: "2027-04-01", kind: "release", defaultOn: true },
416
+ ] as const
417
+ """
418
+ And step "rollout.suite.check" changes nothing
419
+ And step "rollout.mentions" runs its script
420
+ When gtd starts workflow "rollout" with "--var flag=alpha"
421
+ And the process is driven
422
+ Then the process rests at "idle"
423
+ And ".gtd/FEEDBACK.md" does not exist
424
+ And the output of gtd "summary" contains "no line outside the registry names it"
425
+
426
+ @inmem
427
+ Scenario: the closing commit marks the total and each listed line
428
+ Given a file "README.md" with:
429
+ """
430
+ # calc
431
+
432
+ The alpha formatter is on under GTD_FLAG_ALPHA=1.
433
+ """
434
+ And a file "src/calc.ts" with:
435
+ """
436
+ export const help = "pass --flag_alpha to enable the alpha formatter"
437
+ """
438
+ And the working tree is committed as "docs: mention the flag"
439
+ And step "rollout.flipping" writes "src/flags/index.ts" with:
440
+ """
441
+ export const FLAGS = [
442
+ { name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release", defaultOn: true },
443
+ { name: "beta", owner: "b", expiry: "2027-02-01", kind: "release" },
444
+ { name: "ops1", owner: "c", expiry: "2027-03-01", kind: "ops" },
445
+ { name: "live", owner: "d", expiry: "2027-04-01", kind: "release", defaultOn: true },
446
+ ] as const
447
+ """
448
+ And step "rollout.suite.check" changes nothing
449
+ And step "rollout.mentions" runs its script
450
+ When gtd starts workflow "rollout" with "--var flag=alpha"
451
+ And the process is driven
452
+ Then the last commit subject is "gtd(check): rollout.mentions → idle"
453
+ And the last commit body contains "Gtd-Mark: flag-mentions=2"
454
+ And the last commit body contains "Gtd-Mark: flag-mention=README.md:3"
455
+ And the last commit body contains "Gtd-Mark: flag-mention=src/calc.ts:1"
456
+
457
+ @inmem
458
+ Scenario: a finished rollout leaves no state file for the next feature start
459
+ Given a file "README.md" with:
460
+ """
461
+ # calc
462
+
463
+ The alpha formatter is on under GTD_FLAG_ALPHA=1.
464
+ """
465
+ And a file "src/calc.ts" with:
466
+ """
467
+ export const help = "pass --flag_alpha to enable the alpha formatter"
468
+ """
469
+ And the working tree is committed as "docs: mention the flag"
470
+ And step "rollout.flipping" writes "src/flags/index.ts" with:
471
+ """
472
+ export const FLAGS = [
473
+ { name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release", defaultOn: true },
474
+ { name: "beta", owner: "b", expiry: "2027-02-01", kind: "release" },
475
+ { name: "ops1", owner: "c", expiry: "2027-03-01", kind: "ops" },
476
+ { name: "live", owner: "d", expiry: "2027-04-01", kind: "release", defaultOn: true },
477
+ ] as const
478
+ """
479
+ And step "rollout.suite.check" changes nothing
480
+ And step "rollout.mentions" runs its script
481
+ And step "unwind" changes nothing
482
+ And step "start-gate.check" changes nothing
483
+ When gtd starts workflow "rollout" with "--var flag=alpha"
484
+ And the process is driven
485
+ Then the process rests at "idle"
486
+ Given a file "TODO.md" with:
487
+ """
488
+ # Add subtract
489
+
490
+ subtract(a, b) returns the difference.
491
+ """
492
+ When the process is driven
493
+ Then the process rests at "design.triage"
494
+ And ".gtd/FEEDBACK.md" does not exist