@pmelab/gtd 27.1.0 → 27.2.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 (35) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +16 -9
  3. package/dist/chunks/registry-CP3YuV9j.mjs +101 -0
  4. package/dist/chunks/{reserved-BL9krPao.mjs → reserved-B2QaImnN.mjs} +41 -73
  5. package/dist/chunks/tail-BnhIxCYv.mjs +81 -0
  6. package/dist/gtd.bundle.mjs +5 -2
  7. package/dist/testkit.mjs +5 -2
  8. package/dist/workflows/cleanup.mjs +37 -0
  9. package/dist/workflows/feature.mjs +2 -1
  10. package/dist/workflows/fix.mjs +2 -1
  11. package/dist/workflows/review.mjs +2 -1
  12. package/dist/workflows/rollout.mjs +33 -0
  13. package/dist/workflows.mjs +220 -21
  14. package/package.json +11 -1
  15. package/src/flags/index.test.ts +28 -0
  16. package/src/flags/index.ts +9 -4
  17. package/src/testkit/steps.ts +26 -0
  18. package/src/testkit/testkit.feature +8 -1
  19. package/src/workflows/cleanup/cleanup.feature +323 -0
  20. package/src/workflows/cleanup/cleanup.test.ts +272 -0
  21. package/src/workflows/cleanup/cleanup.ts +52 -0
  22. package/src/workflows/cleanup/index.ts +6 -0
  23. package/src/workflows/index.ts +3 -1
  24. package/src/workflows/rollout/index.ts +6 -0
  25. package/src/workflows/rollout/rollout.feature +214 -0
  26. package/src/workflows/rollout/rollout.test.ts +167 -0
  27. package/src/workflows/rollout/rollout.ts +44 -0
  28. package/src/workflows/shared/health.test.ts +39 -0
  29. package/src/workflows/shared/health.ts +30 -7
  30. package/src/workflows/shared/options.test.ts +22 -0
  31. package/src/workflows/shared/options.ts +19 -0
  32. package/src/workflows/shared/registry.test.ts +305 -0
  33. package/src/workflows/shared/registry.ts +158 -0
  34. package/src/workflows/shared/tail.test.ts +36 -2
  35. package/src/workflows/shared/tail.ts +30 -18
@@ -0,0 +1,323 @@
1
+ Feature: cleanup removes a default-on release flag and keeps its flag-on path
2
+
3
+ `gtd --workflow cleanup --var flag=<name>` has an agent delete the registry
4
+ entry and the off path; checks refuse a surviving entry or env-var reference,
5
+ then the shared health loop and review run; the same checks re-run after every later agent turn. A review note ends the process.
6
+
7
+ Background:
8
+ Given the shipped workflows, as gtd install scaffolds them
9
+ And a gtd config with:
10
+ """
11
+ export {
12
+ feature as default,
13
+ feature,
14
+ defaults,
15
+ envDefaults,
16
+ summary,
17
+ steering,
18
+ skills,
19
+ access,
20
+ } from "@pmelab/gtd/workflows/feature"
21
+ import { cleanup } from "@pmelab/gtd/workflows"
22
+ export { cleanup }
23
+ """
24
+ And an environment variable "GTD_QUALITYREVIEWS" set to ""
25
+ And a file "src/flags/index.ts" with:
26
+ """
27
+ export const FLAGS = [
28
+ { name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release" },
29
+ { name: "ops1", owner: "c", expiry: "2027-03-01", kind: "ops" },
30
+ { name: "live", owner: "d", expiry: "2027-04-01", kind: "release", defaultOn: true },
31
+ ] as const
32
+ """
33
+ And a file "src/calc.ts" with:
34
+ """
35
+ export const on = process.env.GTD_FLAG_LIVE !== "0"
36
+ """
37
+ And the working tree is committed as "feat: registry"
38
+
39
+ Scenario: removing the entry and the off path lands, then reaches the review gate
40
+ Given step "cleanup.removing" writes "src/flags/index.ts" with:
41
+ """
42
+ export const FLAGS = [
43
+ { name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release" },
44
+ { name: "ops1", owner: "c", expiry: "2027-03-01", kind: "ops" },
45
+ ] as const
46
+ """
47
+ And step "cleanup.removing" writes "src/calc.ts" with:
48
+ """
49
+ export const on = true
50
+ """
51
+ And step "build.health.check" changes nothing
52
+ And step "build.review.reviewing" writes ".gtd/REVIEW.md" with:
53
+ """
54
+ # Review: abc1234
55
+
56
+ <!-- base: 0000000 -->
57
+
58
+ ## calc
59
+
60
+ - [ ] ./src/calc.ts#1-1
61
+ flag removed
62
+ """
63
+ When gtd starts workflow "cleanup" with "--var flag=live"
64
+ And the process is driven
65
+ Then the process rests at "build.review.await-review"
66
+ And "src/flags/index.ts" does not contain "live"
67
+
68
+ Scenario: a turn leaving the registry entry is refused
69
+ Given step "cleanup.removing" writes "src/calc.ts" with:
70
+ """
71
+ export const on = true
72
+ """
73
+ When gtd starts workflow "cleanup" with "--var flag=live"
74
+ Then driving the process is refused with "src/flags/index.ts still declares flag \"live\""
75
+
76
+ Scenario: a turn leaving the env var in a shipped source is refused naming path:line
77
+ Given step "cleanup.removing" writes "src/flags/index.ts" with:
78
+ """
79
+ export const FLAGS = [
80
+ { name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release" },
81
+ ] as const
82
+ """
83
+ When gtd starts workflow "cleanup" with "--var flag=live"
84
+ Then driving the process is refused with "src/calc.ts:1 still names GTD_FLAG_LIVE"
85
+
86
+ Scenario: the same env var in a test file is allowed
87
+ Given a file "src/calc.test.ts" with:
88
+ """
89
+ process.env.GTD_FLAG_LIVE = "0"
90
+ """
91
+ And the working tree is committed as "test: pin kill switch"
92
+ And step "cleanup.removing" writes "src/flags/index.ts" with:
93
+ """
94
+ export const FLAGS = [
95
+ { name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release" },
96
+ ] as const
97
+ """
98
+ And step "cleanup.removing" writes "src/calc.ts" with:
99
+ """
100
+ export const on = true
101
+ """
102
+ And step "build.health.check" changes nothing
103
+ When gtd starts workflow "cleanup" with "--var flag=live"
104
+ And the process is driven
105
+ Then the process rests at "build.review.reviewing"
106
+
107
+ @inmem
108
+ Scenario: a fix after a red suite that restores the registry entry is refused
109
+ Given step "cleanup.removing" writes "src/flags/index.ts" with:
110
+ """
111
+ export const FLAGS = [
112
+ { name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release" },
113
+ ] as const
114
+ """
115
+ And step "cleanup.removing" writes "src/calc.ts" with:
116
+ """
117
+ export const on = true
118
+ """
119
+ When gtd starts workflow "cleanup" with "--var flag=live"
120
+ And the process is driven
121
+ Then the process rests at "build.health.check"
122
+
123
+ Given a file ".gtd/FEEDBACK.md" with:
124
+ """
125
+ 1 failing
126
+ """
127
+ When gtd lands
128
+ Then the process rests at "build.fix"
129
+
130
+ Given step "build.fix" writes "src/flags/index.ts" with:
131
+ """
132
+ export const FLAGS = [
133
+ { name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release" },
134
+ { name: "live", owner: "d", expiry: "2027-04-01", kind: "release", defaultOn: true },
135
+ ] as const
136
+ """
137
+ Then driving the process is refused with "still declares flag \"live\""
138
+
139
+ @inmem
140
+ Scenario: a fix after a red suite that re-adds the env var to a shipped source is refused
141
+ Given step "cleanup.removing" writes "src/flags/index.ts" with:
142
+ """
143
+ export const FLAGS = [
144
+ { name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release" },
145
+ ] as const
146
+ """
147
+ And step "cleanup.removing" writes "src/calc.ts" with:
148
+ """
149
+ export const on = true
150
+ """
151
+ When gtd starts workflow "cleanup" with "--var flag=live"
152
+ And the process is driven
153
+ Then the process rests at "build.health.check"
154
+
155
+ Given a file ".gtd/FEEDBACK.md" with:
156
+ """
157
+ 1 failing
158
+ """
159
+ When gtd lands
160
+ Then the process rests at "build.fix"
161
+
162
+ Given step "build.fix" writes "src/calc.ts" with:
163
+ """
164
+ export const on = process.env.GTD_FLAG_LIVE !== "0"
165
+ """
166
+ Then driving the process is refused with "src/calc.ts:1 still names GTD_FLAG_LIVE"
167
+
168
+ @inmem
169
+ Scenario: a fix after a red suite that edits another file only reaches the review
170
+ Given step "cleanup.removing" writes "src/flags/index.ts" with:
171
+ """
172
+ export const FLAGS = [
173
+ { name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release" },
174
+ ] as const
175
+ """
176
+ And step "cleanup.removing" writes "src/calc.ts" with:
177
+ """
178
+ export const on = true
179
+ """
180
+ When gtd starts workflow "cleanup" with "--var flag=live"
181
+ And the process is driven
182
+ Then the process rests at "build.health.check"
183
+
184
+ Given a file ".gtd/FEEDBACK.md" with:
185
+ """
186
+ 1 failing
187
+ """
188
+ When gtd lands
189
+ Then the process rests at "build.fix"
190
+
191
+ Given step "build.fix" writes "src/other.ts" with:
192
+ """
193
+ export const other = 1
194
+ """
195
+ And step "build.health.check" changes nothing
196
+ When the process is driven
197
+ Then the process rests at "build.review.reviewing"
198
+
199
+ @inmem
200
+ Scenario: a risk-fix turn after the review that restores the registry entry is refused
201
+ Given step "cleanup.removing" writes "src/flags/index.ts" with:
202
+ """
203
+ export const FLAGS = [
204
+ { name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release" },
205
+ ] as const
206
+ """
207
+ And step "cleanup.removing" writes "src/calc.ts" with:
208
+ """
209
+ export const on = true
210
+ """
211
+ And step "build.health.check" changes nothing
212
+ And step "build.review.reviewing" writes ".gtd/REVIEW.md" with:
213
+ """
214
+ # Review: abc1234
215
+
216
+ <!-- base: 0000000 -->
217
+
218
+ ## calc
219
+
220
+ - [ ] ./src/calc.ts#1-1
221
+ Risk: leaks
222
+ """
223
+ And step "build.review.fix.risks.fixing" writes "src/flags/index.ts" with:
224
+ """
225
+ export const FLAGS = [
226
+ { name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release" },
227
+ { name: "live", owner: "d", expiry: "2027-04-01", kind: "release", defaultOn: true },
228
+ ] as const
229
+ """
230
+ When gtd starts workflow "cleanup" with "--var flag=live"
231
+ Then driving the process is refused with "still declares flag \"live\""
232
+
233
+ @inmem
234
+ Scenario: the human's review sign-off may restore the registry entry
235
+ Given step "cleanup.removing" writes "src/flags/index.ts" with:
236
+ """
237
+ export const FLAGS = [
238
+ { name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release" },
239
+ ] as const
240
+ """
241
+ And step "cleanup.removing" writes "src/calc.ts" with:
242
+ """
243
+ export const on = true
244
+ """
245
+ And step "build.health.check" changes nothing
246
+ And step "build.review.reviewing" writes ".gtd/REVIEW.md" with:
247
+ """
248
+ # Review: abc1234
249
+
250
+ <!-- base: 0000000 -->
251
+
252
+ ## calc
253
+
254
+ - [ ] ./src/calc.ts#1-1
255
+ fine
256
+ """
257
+ When gtd starts workflow "cleanup" with "--var flag=live"
258
+ And the process is driven
259
+ Then the process rests at "build.review.await-review"
260
+
261
+ Given a file "src/flags/index.ts" with:
262
+ """
263
+ export const FLAGS = [
264
+ { name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release" },
265
+ { name: "live", owner: "d", expiry: "2027-04-01", kind: "release", defaultOn: true },
266
+ ] as const
267
+ """
268
+ When gtd lands
269
+ Then the process rests at "build.health.check"
270
+
271
+ Scenario: a review note ends the process leaving the review for fix
272
+ Given step "cleanup.removing" writes "src/flags/index.ts" with:
273
+ """
274
+ export const FLAGS = [
275
+ { name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release" },
276
+ ] as const
277
+ """
278
+ And step "cleanup.removing" writes "src/calc.ts" with:
279
+ """
280
+ export const on = true
281
+ """
282
+ And step "build.health.check" changes nothing
283
+ And step "build.review.reviewing" writes ".gtd/REVIEW.md" with:
284
+ """
285
+ # Review: abc1234
286
+
287
+ <!-- base: 0000000 -->
288
+
289
+ ## calc
290
+
291
+ - [ ] ./src/calc.ts#1-1
292
+ flag removed
293
+ """
294
+ When gtd starts workflow "cleanup" with "--var flag=live"
295
+ And the process is driven
296
+ Then the process rests at "build.review.await-review"
297
+
298
+ Given a file ".gtd/REVIEW.md" with:
299
+ """
300
+ # Review: abc1234
301
+
302
+ <!-- base: 0000000 -->
303
+
304
+ ## calc
305
+
306
+ - [ ] ./src/calc.ts#1-1
307
+ flag removed — also drop the constant
308
+ """
309
+ And step "build.review.handing-off" runs its script
310
+ When gtd lands
311
+ And the process is driven
312
+ Then the process rests at "idle"
313
+ And ".gtd/REVIEW.md" contains "also drop the constant"
314
+ And ".gtd/REVIEW.orig.md" contains "flag removed"
315
+
316
+ Scenario: a default-off flag is refused at start
317
+ Then starting workflow "cleanup" with "--var flag=alpha" is refused with "flag \"alpha\" is not default-on — roll it out first"
318
+
319
+ Scenario: an unknown flag is refused at start
320
+ Then starting workflow "cleanup" with "--var flag=nope" is refused with "no flag \"nope\" in src/flags/index.ts"
321
+
322
+ Scenario: a non-release flag is refused at start
323
+ Then starting workflow "cleanup" with "--var flag=ops1" is refused with "flag \"ops1\" is ops, not release"
@@ -0,0 +1,272 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { CONFIG_SCAFFOLD } from "../../install/index.js"
3
+ import { testRepo, type TestRepo } from "../../testkit/index.js"
4
+ import { reviewDoc } from "../shared/config.fixture.js"
5
+
6
+ type Actors = NonNullable<Parameters<TestRepo["drive"]>[0]>
7
+
8
+ const CONFIG = `${CONFIG_SCAFFOLD}export { cleanup } from "@pmelab/gtd/workflows"\n`
9
+
10
+ const REGISTRY = `export const FLAGS = [
11
+ { name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release" },
12
+ { name: "ops1", owner: "c", expiry: "2027-03-01", kind: "ops" },
13
+ { name: "live", owner: "d", expiry: "2027-04-01", kind: "release", defaultOn: true },
14
+ ] as const
15
+ `
16
+ const WITHOUT_LIVE = REGISTRY.replace(/ \{ name: "live".*\n/, "")
17
+ const SOURCE = "export const on = process.env.GTD_FLAG_LIVE !== '0'\n"
18
+
19
+ const repoWith = () =>
20
+ testRepo({
21
+ workflow: CONFIG,
22
+ env: { GTD_QUALITYREVIEWS: "" },
23
+ files: { "src/flags/index.ts": REGISTRY, "src/a.ts": SOURCE },
24
+ })
25
+
26
+ describe("the cleanup workflow", () => {
27
+ it("lands a removal and reaches the review", async () => {
28
+ const repo = repoWith()
29
+ await repo.start("cleanup", "--var", "flag=live")
30
+ const rest = await repo.drive({
31
+ "cleanup.removing": (beat, r) => {
32
+ expect(beat.content).toContain("live")
33
+ r.write("src/flags/index.ts", WITHOUT_LIVE)
34
+ r.write("src/a.ts", "export const on = true\n")
35
+ },
36
+ "build.health.check": () => undefined,
37
+ })
38
+ expect(rest).toMatchObject({ state: "build.review.reviewing", actor: "agent" })
39
+ })
40
+
41
+ it("refuses to start over a pending hand-off", async () => {
42
+ const repo = testRepo({
43
+ workflow: CONFIG,
44
+ files: {
45
+ "src/flags/index.ts": REGISTRY,
46
+ ".gtd/REVIEW.md": "# Review: abc1234\n",
47
+ ".gtd/REVIEW.orig.md": "# Review: abc1234\n",
48
+ },
49
+ })
50
+ await expect(repo.start("cleanup", "--var", "flag=live")).rejects.toThrow(
51
+ ".gtd/REVIEW.orig.md is a pending hand-off",
52
+ )
53
+ })
54
+
55
+ it("refuses a turn that leaves the registry entry", async () => {
56
+ const repo = repoWith()
57
+ await repo.start("cleanup", "--var", "flag=live")
58
+ await expect(
59
+ repo.drive({ "cleanup.removing": (_, r) => r.write("src/a.ts", "export const on = true\n") }),
60
+ ).rejects.toThrow('gtd land: cleanup: src/flags/index.ts still declares flag "live"')
61
+ })
62
+
63
+ it("refuses a turn leaving the env var in a shipped source, naming path:line", async () => {
64
+ const repo = repoWith()
65
+ await repo.start("cleanup", "--var", "flag=live")
66
+ await expect(
67
+ repo.drive({ "cleanup.removing": (_, r) => r.write("src/flags/index.ts", WITHOUT_LIVE) }),
68
+ ).rejects.toThrow("gtd land: cleanup: src/a.ts:1 still names GTD_FLAG_LIVE")
69
+ })
70
+
71
+ it.each([
72
+ [[], "gtd: cleanup needs --var flag=<name>"],
73
+ [["--var", "flag=nope"], 'gtd: cleanup: no flag "nope" in src/flags/index.ts'],
74
+ [["--var", "flag=ops1"], 'gtd: cleanup: flag "ops1" is ops, not release'],
75
+ [["--var", "flag=alpha"], 'gtd: cleanup: flag "alpha" is not default-on — roll it out first'],
76
+ ])("refuses %j at start", async (args, message) => {
77
+ await expect(repoWith().start("cleanup", ...args)).rejects.toThrow(message)
78
+ })
79
+ })
80
+
81
+ describe("the cleanup guard over every later agent turn", () => {
82
+ const FEEDBACK = ".gtd/FEEDBACK.md"
83
+ const CLEAN = "export const on = true\n"
84
+ const NAMED = "export const on = process.env.GTD_FLAG_LIVE\n"
85
+ const REMOVED: Actors = {
86
+ "cleanup.removing": (_, r) => {
87
+ r.write("src/flags/index.ts", WITHOUT_LIVE)
88
+ r.write("src/a.ts", CLEAN)
89
+ },
90
+ "build.health.check": () => undefined,
91
+ }
92
+
93
+ // Without the shipped access rules, which would refuse a source write from a read-only turn first: the guard is the second line.
94
+ const NO_ACCESS = CONFIG.replace(" access,\n", "")
95
+
96
+ const guarded = (lenses = "", workflow = CONFIG) =>
97
+ testRepo({
98
+ workflow,
99
+ env: { GTD_QUALITYREVIEWS: lenses },
100
+ files: {
101
+ "src/flags/index.ts": REGISTRY,
102
+ "src/a.ts": SOURCE,
103
+ "src/calc.ts": "export const add = 1\n",
104
+ },
105
+ })
106
+
107
+ const redOnce = (): Actors => {
108
+ let runs = 0
109
+ return {
110
+ "build.health.check": (_, r) => {
111
+ if (runs++ === 0) r.write(FEEDBACK, "1 failing\n")
112
+ },
113
+ }
114
+ }
115
+
116
+ const driven = async (repo: ReturnType<typeof guarded>, actors: Actors) => {
117
+ await repo.start("cleanup", "--var", "flag=live")
118
+ return repo.drive({ ...REMOVED, ...actors })
119
+ }
120
+
121
+ it("refuses a build.fix that restores the registry entry", async () => {
122
+ await expect(
123
+ driven(guarded(), {
124
+ ...redOnce(),
125
+ "build.fix": (_, r) => r.write("src/flags/index.ts", REGISTRY),
126
+ }),
127
+ ).rejects.toThrow('src/flags/index.ts still declares flag "live"')
128
+ })
129
+
130
+ it("refuses a build.fix that adds the env var to a source, naming path:line", async () => {
131
+ await expect(
132
+ driven(guarded(), { ...redOnce(), "build.fix": (_, r) => r.write("src/b.ts", NAMED) }),
133
+ ).rejects.toThrow("gtd land: cleanup: src/b.ts:1 still names GTD_FLAG_LIVE")
134
+ })
135
+
136
+ it("lets a build.fix add the env var to a test file", async () => {
137
+ const rest = await driven(guarded(), {
138
+ ...redOnce(),
139
+ "build.fix": (_, r) => r.write("src/b.test.ts", NAMED),
140
+ })
141
+ expect(rest.state).toBe("build.review.reviewing")
142
+ })
143
+
144
+ it("lets a build.fix touching only another file reach the review", async () => {
145
+ const rest = await driven(guarded(), {
146
+ ...redOnce(),
147
+ "build.fix": (_, r) => r.write("src/other.ts", CLEAN),
148
+ })
149
+ expect(rest).toMatchObject({ state: "build.review.reviewing", actor: "agent" })
150
+ })
151
+
152
+ it("refuses the human's build.health.stop landing that restores the registry entry", async () => {
153
+ let stopped = false
154
+ let runs = 0
155
+ const repo = guarded()
156
+ await repo.start("cleanup", "--var", "flag=live")
157
+ await expect(
158
+ repo.drive({
159
+ ...REMOVED,
160
+ "build.health.check": (_, r) => {
161
+ if (!stopped) r.write(FEEDBACK, `failing ${runs++}\n`)
162
+ },
163
+ "build.health.describe": () => undefined,
164
+ "build.health.judge": () => '[{"id":"verdict","answer":"progress","p":0.9}]',
165
+ "build.fix": (_, r) => r.write("src/other.ts", `export const n = ${runs}\n`),
166
+ "build.health.stop": (_, r) => {
167
+ stopped = true
168
+ r.write("src/flags/index.ts", REGISTRY)
169
+ },
170
+ }),
171
+ ).rejects.toThrow('still declares flag "live"')
172
+ })
173
+
174
+ it("refuses an escalation describe turn that re-adds the env var", async () => {
175
+ let runs = 0
176
+ await expect(
177
+ driven(guarded("", NO_ACCESS), {
178
+ "build.health.check": (_, r) => r.write(FEEDBACK, `failing ${runs++}\n`),
179
+ "build.health.judge": () => '[{"id":"verdict","answer":"progress","p":0.9}]',
180
+ "build.fix": (_, r) => r.write("src/other.ts", `export const n = ${runs}\n`),
181
+ "build.health.describe": (_, r) => r.write("src/b.ts", NAMED),
182
+ }),
183
+ ).rejects.toThrow("src/b.ts:1 still names GTD_FLAG_LIVE")
184
+ })
185
+
186
+ it("refuses the first fix after escalating quality findings that re-adds the env var", async () => {
187
+ await expect(
188
+ driven(guarded("correctness", NO_ACCESS), {
189
+ "build.quality.correctness.reviewing": (_, r) =>
190
+ r.write(".gtd/QUALITY.md", "- a finding\n"),
191
+ "build.fix.quality.fixing": () => undefined,
192
+ "build.health.describe": () => undefined,
193
+ "build.health.stop": () => undefined,
194
+ "build.fix": (_, r) => r.write("src/b.ts", NAMED),
195
+ }),
196
+ ).rejects.toThrow("src/b.ts:1 still names GTD_FLAG_LIVE")
197
+ })
198
+
199
+ it("refuses a quality lens turn that re-adds the env var", async () => {
200
+ await expect(
201
+ driven(guarded("correctness", NO_ACCESS), {
202
+ "build.quality.correctness.reviewing": (_, r) => r.write("src/b.ts", NAMED),
203
+ }),
204
+ ).rejects.toThrow("src/b.ts:1 still names GTD_FLAG_LIVE")
205
+ })
206
+
207
+ it("refuses a quality fix turn that restores the registry entry", async () => {
208
+ await expect(
209
+ driven(guarded("correctness"), {
210
+ "build.quality.correctness.reviewing": (_, r) =>
211
+ r.write(".gtd/QUALITY.md", "- a finding\n"),
212
+ "build.fix.quality.fixing": (_, r) => r.write("src/flags/index.ts", REGISTRY),
213
+ }),
214
+ ).rejects.toThrow('still declares flag "live"')
215
+ })
216
+
217
+ it("refuses a risk fix turn that restores the registry entry", async () => {
218
+ await expect(
219
+ driven(guarded(), {
220
+ "build.review.reviewing": (_, r) => r.write(".gtd/REVIEW.md", reviewDoc("Risk: leaks")),
221
+ "build.review.fix.risks.fixing": (_, r) => r.write("src/flags/index.ts", REGISTRY),
222
+ }),
223
+ ).rejects.toThrow('still declares flag "live"')
224
+ })
225
+
226
+ it("refuses a review turn that re-adds the env var", async () => {
227
+ await expect(
228
+ driven(guarded("", NO_ACCESS), {
229
+ "build.review.reviewing": (_, r) => {
230
+ r.write(".gtd/REVIEW.md", reviewDoc("fine"))
231
+ r.write("src/b.ts", NAMED)
232
+ },
233
+ }),
234
+ ).rejects.toThrow("src/b.ts:1 still names GTD_FLAG_LIVE")
235
+ })
236
+
237
+ it("leaves the human's review sign-off unguarded", async () => {
238
+ const repo = guarded()
239
+ await repo.start("cleanup", "--var", "flag=live")
240
+ const rest = await repo.drive({
241
+ ...REMOVED,
242
+ "build.review.reviewing": (_, r) => r.write(".gtd/REVIEW.md", reviewDoc("fine")),
243
+ })
244
+ expect(rest.state).toBe("build.review.await-review")
245
+ repo.write("src/flags/index.ts", REGISTRY)
246
+ await repo.land()
247
+ const done = await repo.drive({ "build.review.closing": () => undefined })
248
+ expect(done.state).not.toBe("build.review.await-review")
249
+ })
250
+
251
+ it("does not blame a later fix turn for the entry the human restored at sign-off", async () => {
252
+ const repo = guarded()
253
+ await repo.start("cleanup", "--var", "flag=live")
254
+ let runs = 0
255
+ const check: Actors = {
256
+ "build.health.check": (_, r) => {
257
+ if (runs++ === 1) r.write(FEEDBACK, "1 failing\n")
258
+ },
259
+ }
260
+ const rest = await repo.drive({
261
+ ...REMOVED,
262
+ ...check,
263
+ "build.review.reviewing": (_, r) => r.write(".gtd/REVIEW.md", reviewDoc("fine")),
264
+ })
265
+ expect(rest.state).toBe("build.review.await-review")
266
+ repo.write("src/flags/index.ts", REGISTRY)
267
+ repo.write("src/hand.ts", CLEAN)
268
+ await repo.land()
269
+ await repo.drive({ ...check, "build.fix": (_, r) => r.write("src/other.ts", CLEAN) })
270
+ expect(repo.subjects()).toContain("gtd(agent): build.fix → build.health.check")
271
+ })
272
+ })
@@ -0,0 +1,52 @@
1
+ import { agent, changes, glob, read, refuse, scope, start, vars } from "../../flows/index.js"
2
+ import { flagShipped } from "../shared/options.js"
3
+ import { flagReferences, registryEntry, requireFlag } from "../shared/registry.js"
4
+ import { coder } from "../shared/steps.js"
5
+ import { handOffTail, refusePendingHandOff } from "../shared/tail.js"
6
+
7
+ const removingPrompt = (name: string, registry: string): string =>
8
+ `Clean up the release flag "${name}", which is default-on: delete its entry in ${registry}, and remove every \`GTD_FLAG_${name.toUpperCase()}\` check from the shipped sources. Keep the flag-on path; delete code only the off path reached, including tests that pin the off path.`
9
+
10
+ export const cleanupFlow = async (): Promise<void> => {
11
+ const registry = vars.flagRegistry ?? ""
12
+ const name = vars.flag ?? ""
13
+ // Before the turn, never after: replay re-runs every pre-turn check.
14
+ refusePendingHandOff()
15
+ requireFlag("cleanup", registry, name, true)
16
+ // After `removing` both checks are per-turn: only a turn that touched the registry or a source is refused, so the human's unguarded sign-off edits are never blamed on a later agent turn.
17
+ let scanned = false
18
+ const guard = (): void => {
19
+ const turn = scanned ? changes() : undefined
20
+ if (
21
+ (turn === undefined || turn.some((c) => c.path === registry)) &&
22
+ registryEntry(read(registry) ?? "", name) !== undefined
23
+ ) {
24
+ refuse(`gtd land: cleanup: ${registry} still declares flag "${name}"`)
25
+ }
26
+ const paths =
27
+ turn === undefined
28
+ ? [...glob("src/**/*.ts"), ...glob("src/**/*.tsx")]
29
+ : turn
30
+ .filter((c) => c.status !== "deleted")
31
+ .map((c) => c.path)
32
+ .filter((path) => path.startsWith("src/") && /\.tsx?$/.test(path))
33
+ const hits = flagReferences(
34
+ paths.map((path) => ({ path, text: read(path) ?? "" })),
35
+ name,
36
+ )
37
+ if (hits.length > 0) {
38
+ refuse(`gtd land: cleanup: ${hits.join(", ")} still names GTD_FLAG_${name.toUpperCase()}`)
39
+ }
40
+ }
41
+ await scope("cleanup", async () => {
42
+ await agent("removing", removingPrompt(name, registry), {
43
+ label: `Removing ${name}`,
44
+ model: coder(),
45
+ })
46
+ guard()
47
+ scanned = true
48
+ })
49
+ await handOffTail(false, start(), guard)
50
+ }
51
+
52
+ export const cleanup = flagShipped(cleanupFlow)
@@ -0,0 +1,6 @@
1
+ import { cleanup } from "./cleanup.js"
2
+
3
+ export { cleanup, cleanupFlow } from "./cleanup.js"
4
+ export * from "../shared/reserved.js"
5
+
6
+ export default cleanup
@@ -5,7 +5,7 @@ import { feature } from "./feature/feature.js"
5
5
 
6
6
  export { threads, type ThreadInfo } from "../flows/index.js"
7
7
  export * from "./shared/reserved.js"
8
- export { ShippedOptions } from "./shared/options.js"
8
+ export { FlagOptions, ShippedOptions } from "./shared/options.js"
9
9
  export { agentWithSkills } from "./shared/text.js"
10
10
  export * from "./shared/steps.js"
11
11
  export * from "./shared/health.js"
@@ -26,6 +26,8 @@ export { buildTail, type ReviewOutcome } from "./feature/review.js"
26
26
  export * from "./feature/feature.js"
27
27
  export { base, review, reviewFlow } from "./review/review.js"
28
28
  export { fix, fixFlow, fixReviewNotes } from "./fix/fix.js"
29
+ export { rollout, rolloutFlow } from "./rollout/rollout.js"
30
+ export { cleanup, cleanupFlow } from "./cleanup/cleanup.js"
29
31
 
30
32
  export default feature
31
33
 
@@ -0,0 +1,6 @@
1
+ import { rollout } from "./rollout.js"
2
+
3
+ export { rollout, rolloutFlow } from "./rollout.js"
4
+ export * from "../shared/reserved.js"
5
+
6
+ export default rollout