@pmelab/gtd 27.0.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 (39) 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/flows.mjs +74 -1
  7. package/dist/gtd.bundle.mjs +79 -3
  8. package/dist/testkit.mjs +5 -2
  9. package/dist/workflows/cleanup.mjs +37 -0
  10. package/dist/workflows/feature.mjs +2 -1
  11. package/dist/workflows/fix.mjs +2 -1
  12. package/dist/workflows/review.mjs +2 -1
  13. package/dist/workflows/rollout.mjs +33 -0
  14. package/dist/workflows.mjs +225 -21
  15. package/package.json +11 -1
  16. package/src/flags/index.test.ts +28 -0
  17. package/src/flags/index.ts +9 -4
  18. package/src/flows/helpers.ts +111 -0
  19. package/src/flows/runtime.ts +5 -2
  20. package/src/replay/judgeDiffShape.test.ts +208 -0
  21. package/src/testkit/steps.ts +26 -0
  22. package/src/testkit/testkit.feature +8 -1
  23. package/src/workflows/cleanup/cleanup.feature +323 -0
  24. package/src/workflows/cleanup/cleanup.test.ts +272 -0
  25. package/src/workflows/cleanup/cleanup.ts +52 -0
  26. package/src/workflows/cleanup/index.ts +6 -0
  27. package/src/workflows/index.ts +3 -1
  28. package/src/workflows/rollout/index.ts +6 -0
  29. package/src/workflows/rollout/rollout.feature +214 -0
  30. package/src/workflows/rollout/rollout.test.ts +167 -0
  31. package/src/workflows/rollout/rollout.ts +44 -0
  32. package/src/workflows/shared/health.test.ts +39 -0
  33. package/src/workflows/shared/health.ts +30 -7
  34. package/src/workflows/shared/options.test.ts +22 -0
  35. package/src/workflows/shared/options.ts +19 -0
  36. package/src/workflows/shared/registry.test.ts +305 -0
  37. package/src/workflows/shared/registry.ts +158 -0
  38. package/src/workflows/shared/tail.test.ts +36 -2
  39. package/src/workflows/shared/tail.ts +30 -18
@@ -0,0 +1,214 @@
1
+ Feature: rollout turns one release flag default-on and touches nothing else
2
+
3
+ `gtd --workflow rollout --var flag=<name>` has an agent set `defaultOn: true`
4
+ on the named registry entry; a check refuses any other change, and a red
5
+ suite rests at a human gate — never a fix turn.
6
+
7
+ Background:
8
+ Given a gtd config with:
9
+ """
10
+ export {
11
+ feature as default,
12
+ feature,
13
+ defaults,
14
+ envDefaults,
15
+ summary,
16
+ steering,
17
+ skills,
18
+ access,
19
+ } from "@pmelab/gtd/workflows/feature"
20
+ import { rollout } from "@pmelab/gtd/workflows"
21
+ export { rollout }
22
+ export const elsewhere = rollout({ flagRegistry: "config/flags.ts" })
23
+ """
24
+ And a file "src/calc.ts" with:
25
+ """
26
+ export const add = (a: number, b: number) => a + b
27
+ """
28
+ And a file "src/flags/index.ts" with:
29
+ """
30
+ export const FLAGS = [
31
+ { name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release" },
32
+ { name: "beta", owner: "b", expiry: "2027-02-01", kind: "release" },
33
+ { name: "ops1", owner: "c", expiry: "2027-03-01", kind: "ops" },
34
+ { name: "live", owner: "d", expiry: "2027-04-01", kind: "release", defaultOn: true },
35
+ ] as const
36
+ """
37
+ And the working tree is committed as "feat: registry"
38
+
39
+ Scenario: a flip of the named entry lands, the suite passes and the process ends
40
+ Given step "rollout.flipping" writes "src/flags/index.ts" with:
41
+ """
42
+ export const FLAGS = [
43
+ { name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release", defaultOn: true },
44
+ { name: "beta", owner: "b", expiry: "2027-02-01", kind: "release" },
45
+ { name: "ops1", owner: "c", expiry: "2027-03-01", kind: "ops" },
46
+ { name: "live", owner: "d", expiry: "2027-04-01", kind: "release", defaultOn: true },
47
+ ] as const
48
+ """
49
+ And step "rollout.suite.check" changes nothing
50
+ When gtd starts workflow "rollout" with "--var flag=alpha"
51
+ And the process is driven
52
+ Then the process rests at "idle"
53
+ And "src/flags/index.ts" contains "kind: \"release\", defaultOn: true },"
54
+ And the last commit subject is "gtd(check): rollout.suite.check → idle"
55
+
56
+ Scenario: a turn also changing a source file is refused naming that path
57
+ Given step "rollout.flipping" writes "src/flags/index.ts" with:
58
+ """
59
+ export const FLAGS = [
60
+ { name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release", defaultOn: true },
61
+ { name: "beta", owner: "b", expiry: "2027-02-01", kind: "release" },
62
+ { name: "ops1", owner: "c", expiry: "2027-03-01", kind: "ops" },
63
+ { name: "live", owner: "d", expiry: "2027-04-01", kind: "release", defaultOn: true },
64
+ ] as const
65
+ """
66
+ And step "rollout.flipping" writes "src/calc.ts" with:
67
+ """
68
+ export const add = (a: number, b: number) => b + a
69
+ """
70
+ When gtd starts workflow "rollout" with "--var flag=alpha"
71
+ Then driving the process is refused with "rollout-only: src/calc.ts changed"
72
+
73
+ Scenario: a turn editing a second registry entry is refused
74
+ Given step "rollout.flipping" writes "src/flags/index.ts" with:
75
+ """
76
+ export const FLAGS = [
77
+ { name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release", defaultOn: true },
78
+ { name: "beta", owner: "zed", expiry: "2027-02-01", kind: "release" },
79
+ { name: "ops1", owner: "c", expiry: "2027-03-01", kind: "ops" },
80
+ { name: "live", owner: "d", expiry: "2027-04-01", kind: "release", defaultOn: true },
81
+ ] as const
82
+ """
83
+ When gtd starts workflow "rollout" with "--var flag=alpha"
84
+ Then driving the process is refused with "other edits in src/flags/index.ts"
85
+
86
+ Scenario: a factory flagRegistry names the one file the guard allows
87
+ Given a file "config/flags.ts" with:
88
+ """
89
+ export const FLAGS = [
90
+ { name: "gamma", owner: "g", expiry: "2027-05-01", kind: "release" },
91
+ ] as const
92
+ """
93
+ And the working tree is committed as "feat: second registry"
94
+ And step "rollout.flipping" writes "config/flags.ts" with:
95
+ """
96
+ export const FLAGS = [
97
+ { name: "gamma", owner: "g", expiry: "2027-05-01", kind: "release", defaultOn: true },
98
+ ] as const
99
+ """
100
+ And step "rollout.suite.check" changes nothing
101
+ When gtd starts workflow "elsewhere" with "--var flag=gamma"
102
+ And the process is driven
103
+ Then the process rests at "idle"
104
+ And "config/flags.ts" contains "defaultOn: true"
105
+
106
+ Scenario: a missing flag var is refused before any turn
107
+ Then starting workflow "rollout" with "" is refused with "gtd: rollout needs --var flag=<name>"
108
+
109
+ Scenario: an unknown flag is refused before any turn
110
+ Then starting workflow "rollout" with "--var flag=nope" is refused with "no flag \"nope\" in src/flags/index.ts"
111
+
112
+ Scenario: an ops flag is refused before any turn
113
+ Then starting workflow "rollout" with "--var flag=ops1" is refused with "flag \"ops1\" is ops, not release"
114
+
115
+ Scenario: an already default-on flag is refused before any turn
116
+ Then starting workflow "rollout" with "--var flag=live" is refused with "flag \"live\" is already default-on"
117
+
118
+ Scenario: a red suite after the flip rests at the blocked gate with no fix turn
119
+ Given step "rollout.flipping" writes "src/flags/index.ts" with:
120
+ """
121
+ export const FLAGS = [
122
+ { name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release", defaultOn: true },
123
+ { name: "beta", owner: "b", expiry: "2027-02-01", kind: "release" },
124
+ { name: "ops1", owner: "c", expiry: "2027-03-01", kind: "ops" },
125
+ { name: "live", owner: "d", expiry: "2027-04-01", kind: "release", defaultOn: true },
126
+ ] as const
127
+ """
128
+ And step "rollout.suite.check" writes ".gtd/FEEDBACK.md" with:
129
+ """
130
+ 1 test failing
131
+ """
132
+ When gtd starts workflow "rollout" with "--var flag=alpha"
133
+ And the process is driven
134
+ Then the process rests at "rollout.suite.blocked"
135
+
136
+ @inmem
137
+ Scenario: a human fix at the blocked gate that also edits a source file is refused
138
+ Given step "rollout.flipping" writes "src/flags/index.ts" with:
139
+ """
140
+ export const FLAGS = [
141
+ { name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release", defaultOn: true },
142
+ { name: "beta", owner: "b", expiry: "2027-02-01", kind: "release" },
143
+ { name: "ops1", owner: "c", expiry: "2027-03-01", kind: "ops" },
144
+ { name: "live", owner: "d", expiry: "2027-04-01", kind: "release", defaultOn: true },
145
+ ] as const
146
+ """
147
+ And step "rollout.suite.check" writes ".gtd/FEEDBACK.md" with:
148
+ """
149
+ 1 test failing
150
+ """
151
+ When gtd starts workflow "rollout" with "--var flag=alpha"
152
+ And the process is driven
153
+ Then the process rests at "rollout.suite.blocked"
154
+ And step "rollout.suite.blocked" writes "src/calc.ts" with:
155
+ """
156
+ export const add = (a: number, b: number) => b + a
157
+ """
158
+ Then driving the process is refused with "rollout-only: src/calc.ts changed"
159
+
160
+ @inmem
161
+ Scenario: a human fix at the blocked gate that edits a second registry entry is refused
162
+ Given step "rollout.flipping" writes "src/flags/index.ts" with:
163
+ """
164
+ export const FLAGS = [
165
+ { name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release", defaultOn: true },
166
+ { name: "beta", owner: "b", expiry: "2027-02-01", kind: "release" },
167
+ { name: "ops1", owner: "c", expiry: "2027-03-01", kind: "ops" },
168
+ { name: "live", owner: "d", expiry: "2027-04-01", kind: "release", defaultOn: true },
169
+ ] as const
170
+ """
171
+ And step "rollout.suite.check" writes ".gtd/FEEDBACK.md" with:
172
+ """
173
+ 1 test failing
174
+ """
175
+ When gtd starts workflow "rollout" with "--var flag=alpha"
176
+ And the process is driven
177
+ Then the process rests at "rollout.suite.blocked"
178
+ And step "rollout.suite.blocked" writes "src/flags/index.ts" with:
179
+ """
180
+ export const FLAGS = [
181
+ { name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release", defaultOn: true },
182
+ { name: "beta", owner: "zed", expiry: "2027-02-01", kind: "release" },
183
+ { name: "ops1", owner: "c", expiry: "2027-03-01", kind: "ops" },
184
+ { name: "live", owner: "d", expiry: "2027-04-01", kind: "release", defaultOn: true },
185
+ ] as const
186
+ """
187
+ Then driving the process is refused with "other edits in src/flags/index.ts"
188
+
189
+ @inmem
190
+ Scenario: a human fix touching only the feedback file lands and the process ends with no review
191
+ Given step "rollout.flipping" writes "src/flags/index.ts" with:
192
+ """
193
+ export const FLAGS = [
194
+ { name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release", defaultOn: true },
195
+ { name: "beta", owner: "b", expiry: "2027-02-01", kind: "release" },
196
+ { name: "ops1", owner: "c", expiry: "2027-03-01", kind: "ops" },
197
+ { name: "live", owner: "d", expiry: "2027-04-01", kind: "release", defaultOn: true },
198
+ ] as const
199
+ """
200
+ And step "rollout.suite.check" writes ".gtd/FEEDBACK.md" with:
201
+ """
202
+ 1 test failing
203
+ """
204
+ When gtd starts workflow "rollout" with "--var flag=alpha"
205
+ And the process is driven
206
+ Then the process rests at "rollout.suite.blocked"
207
+ And step "rollout.suite.blocked" writes ".gtd/FEEDBACK.md" with:
208
+ """
209
+ fixed by hand
210
+ """
211
+ And step "rollout.suite.check" from now on changes nothing
212
+ When the process is driven
213
+ Then the process rests at "idle"
214
+ And the last commit subject is "gtd(check): rollout.suite.check → idle"
@@ -0,0 +1,167 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { CONFIG_SCAFFOLD } from "../../install/index.js"
3
+ import { testRepo } from "../../testkit/index.js"
4
+
5
+ const CONFIG = `${CONFIG_SCAFFOLD}export { rollout } from "@pmelab/gtd/workflows"\n`
6
+
7
+ const REGISTRY = `export const FLAGS = [
8
+ { name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release" },
9
+ { name: "beta", owner: "b", expiry: "2027-02-01", kind: "release" },
10
+ { name: "ops1", owner: "c", expiry: "2027-03-01", kind: "ops" },
11
+ { name: "live", owner: "d", expiry: "2027-04-01", kind: "release", defaultOn: true },
12
+ ] as const
13
+ `
14
+
15
+ const FLIPPED = REGISTRY.replace(
16
+ '{ name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release" }',
17
+ '{ name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release", defaultOn: true }',
18
+ )
19
+
20
+ const repoWith = () =>
21
+ testRepo({ workflow: CONFIG, files: { "src/flags/index.ts": REGISTRY, "src/a.ts": "a\n" } })
22
+
23
+ describe("the rollout workflow", () => {
24
+ it("lands a flip, checks the suite and ends with no review", async () => {
25
+ const repo = repoWith()
26
+ await repo.start("rollout", "--var", "flag=alpha")
27
+ const rest = await repo.drive({
28
+ "rollout.flipping": (beat, r) => {
29
+ expect(beat.content).toContain("alpha")
30
+ r.write("src/flags/index.ts", FLIPPED)
31
+ },
32
+ "rollout.suite.check": () => undefined,
33
+ })
34
+ expect(rest.state).toBe("idle")
35
+ expect(repo.subjects().at(-1)).toBe("gtd(check): rollout.suite.check → idle")
36
+ })
37
+
38
+ it("lands a flip when the tree was already dirty at the start", async () => {
39
+ const repo = repoWith()
40
+ repo.write("src/other.ts", "dirty\n")
41
+ await repo.start("rollout", "--var", "flag=alpha")
42
+ const rest = await repo.drive({
43
+ "rollout.flipping": (_, r) => r.write("src/flags/index.ts", FLIPPED),
44
+ "rollout.suite.check": () => undefined,
45
+ })
46
+ expect(rest.state).toBe("idle")
47
+ })
48
+
49
+ it("refuses a turn that also changes a source file, naming it", async () => {
50
+ const repo = repoWith()
51
+ await repo.start("rollout", "--var", "flag=alpha")
52
+ await expect(
53
+ repo.drive({
54
+ "rollout.flipping": (_, r) => {
55
+ r.write("src/flags/index.ts", FLIPPED)
56
+ r.write("src/a.ts", "changed\n")
57
+ },
58
+ }),
59
+ ).rejects.toThrow(/rollout-only: src\/a\.ts changed/)
60
+ })
61
+
62
+ it("refuses an edit to a second entry", async () => {
63
+ const repo = repoWith()
64
+ await repo.start("rollout", "--var", "flag=alpha")
65
+ await expect(
66
+ repo.drive({
67
+ "rollout.flipping": (_, r) =>
68
+ r.write("src/flags/index.ts", FLIPPED.replace('owner: "b"', 'owner: "z"')),
69
+ }),
70
+ ).rejects.toThrow("rollout-only: other edits in src/flags/index.ts")
71
+ })
72
+
73
+ it("refuses a turn that writes defaultOn: false, naming the fault", async () => {
74
+ const repo = repoWith()
75
+ await repo.start("rollout", "--var", "flag=alpha")
76
+ await expect(
77
+ repo.drive({
78
+ "rollout.flipping": (_, r) =>
79
+ r.write("src/flags/index.ts", FLIPPED.replace("defaultOn: true }", "defaultOn: false }")),
80
+ }),
81
+ ).rejects.toThrow('"alpha" is not set to defaultOn: true')
82
+ })
83
+
84
+ it("refuses a turn that also writes under .gtd/", async () => {
85
+ const repo = repoWith()
86
+ await repo.start("rollout", "--var", "flag=alpha")
87
+ await expect(
88
+ repo.drive({
89
+ "rollout.flipping": (_, r) => {
90
+ r.write("src/flags/index.ts", FLIPPED)
91
+ r.write(".gtd/NOTES.md", "planted\n")
92
+ },
93
+ }),
94
+ ).rejects.toThrow(/rollout-only: \.gtd\/NOTES\.md changed/)
95
+ })
96
+
97
+ it.each([
98
+ [[], "gtd: rollout needs --var flag=<name>"],
99
+ [["--var", "flag=nope"], 'gtd: rollout: no flag "nope" in src/flags/index.ts'],
100
+ [["--var", "flag=ops1"], 'gtd: rollout: flag "ops1" is ops, not release'],
101
+ [["--var", "flag=live"], 'gtd: rollout: flag "live" is already default-on'],
102
+ ])("refuses %j at start", async (args, message) => {
103
+ await expect(repoWith().start("rollout", ...args)).rejects.toThrow(message)
104
+ })
105
+
106
+ it("rests at rollout.suite.blocked on a red suite, with no fix turn", async () => {
107
+ const repo = repoWith()
108
+ await repo.start("rollout", "--var", "flag=alpha")
109
+ const rest = await repo.drive({
110
+ "rollout.flipping": (_, r) => r.write("src/flags/index.ts", FLIPPED),
111
+ "rollout.suite.check": (_, r) => r.write(".gtd/FEEDBACK.md", "1 failing\n"),
112
+ })
113
+ expect(rest).toMatchObject({ state: "rollout.suite.blocked", actor: "human" })
114
+ })
115
+
116
+ describe("the human's fix at rollout.suite.blocked", () => {
117
+ const blocked = async () => {
118
+ const repo = repoWith()
119
+ await repo.start("rollout", "--var", "flag=alpha")
120
+ await repo.drive({
121
+ "rollout.flipping": (_, r) => r.write("src/flags/index.ts", FLIPPED),
122
+ "rollout.suite.check": (_, r) => r.write(".gtd/FEEDBACK.md", "1 failing\n"),
123
+ })
124
+ return repo
125
+ }
126
+
127
+ it("refuses a fix that edits a source file, naming it", async () => {
128
+ const repo = await blocked()
129
+ await expect(
130
+ repo.drive({ "rollout.suite.blocked": (_, r) => r.write("src/a.ts", "fixed\n") }),
131
+ ).rejects.toThrow(/rollout-only: src\/a\.ts changed/)
132
+ })
133
+
134
+ it("refuses a fix that edits a second registry entry", async () => {
135
+ const repo = await blocked()
136
+ await expect(
137
+ repo.drive({
138
+ "rollout.suite.blocked": (_, r) =>
139
+ r.write("src/flags/index.ts", FLIPPED.replace('owner: "b"', 'owner: "z"')),
140
+ }),
141
+ ).rejects.toThrow("rollout-only: other edits in src/flags/index.ts")
142
+ })
143
+
144
+ it("refuses a fix that rewrites the flipped entry back to defaultOn: false", async () => {
145
+ const repo = await blocked()
146
+ await expect(
147
+ repo.drive({ "rollout.suite.blocked": (_, r) => r.write("src/flags/index.ts", REGISTRY) }),
148
+ ).rejects.toThrow('"alpha" is not set to defaultOn: true')
149
+ })
150
+
151
+ it("lands a fix that re-wraps the flag's entry and ends with no review", async () => {
152
+ const repo = await blocked()
153
+ const rest = await repo.drive({
154
+ "rollout.suite.blocked": (_, r) =>
155
+ r.write(
156
+ "src/flags/index.ts",
157
+ FLIPPED.replace(
158
+ '{ name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release", defaultOn: true }',
159
+ '{\n name: "alpha",\n owner: "a",\n expiry: "2027-01-01",\n kind: "release",\n defaultOn: true,\n }',
160
+ ),
161
+ ),
162
+ "rollout.suite.check": () => undefined,
163
+ })
164
+ expect(rest.state).toBe("idle")
165
+ })
166
+ })
167
+ })
@@ -0,0 +1,44 @@
1
+ import { agent, changesSince, head, read, refuse, scope, vars } from "../../flows/index.js"
2
+ import { gate } from "../shared/health.js"
3
+ import { flagShipped } from "../shared/options.js"
4
+ import { flipProblems, requireFlag } from "../shared/registry.js"
5
+ import { coder, FEEDBACK } from "../shared/steps.js"
6
+
7
+ const flippingPrompt = (name: string, registry: string): string =>
8
+ `Roll out the release flag "${name}": in ${registry}, set \`defaultOn: true\` on its entry. Change nothing else — no other entry, no other field, no other file, no comments.`
9
+
10
+ const suiteBlockedMessage = (): string =>
11
+ "The suite is red after the rollout. Fix it by hand — a rollout may only touch the flag's registry entry — then land."
12
+
13
+ // No review gate — a one-field change needs no review lap.
14
+ export const rolloutFlow = async (): Promise<void> => {
15
+ const registry = vars.flagRegistry ?? ""
16
+ const name = vars.flag ?? ""
17
+ // Before the turn, never after: replay re-runs every pre-turn check.
18
+ requireFlag("rollout", registry, name, false)
19
+ // Strays count from the same point `before` is read at, not `start()`: the opening commit sweeps in whatever the human left uncommitted.
20
+ const base = head()
21
+ const before = read(registry) ?? ""
22
+ const guard = (): void => {
23
+ const strays = changesSince(base).paths.filter((path) => path !== registry && path !== FEEDBACK)
24
+ if (strays.length > 0) {
25
+ refuse(
26
+ `gtd land: rollout-only: ${strays.join(", ")} changed — rollout may only set defaultOn: true on "${name}" in ${registry}`,
27
+ )
28
+ }
29
+ const problems = flipProblems(before, read(registry) ?? "", name)
30
+ if (problems.length > 0) {
31
+ refuse(`gtd land: rollout-only: other edits in ${registry}: ${problems.join("; ")}`)
32
+ }
33
+ }
34
+ await scope("rollout", async () => {
35
+ await agent("flipping", flippingPrompt(name, registry), {
36
+ label: `Rolling out ${name}`,
37
+ model: coder(),
38
+ })
39
+ guard()
40
+ await gate("suite", suiteBlockedMessage(), guard)
41
+ })
42
+ }
43
+
44
+ export const rollout = flagShipped(rolloutFlow)
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from "vitest"
2
2
  import { toRequest } from "../../judges/index.js"
3
+ import { testRepo } from "../../testkit/index.js"
3
4
  import { retryQuestion } from "./health.js"
4
5
 
5
6
  describe("retryQuestion", () => {
@@ -11,3 +12,41 @@ describe("retryQuestion", () => {
11
12
  ).toBeGreaterThanOrEqual(2)
12
13
  })
13
14
  })
15
+
16
+ describe("gate guard", () => {
17
+ const config = (guard: string) =>
18
+ [
19
+ `import { gate } from "@pmelab/gtd/workflows"`,
20
+ `import { agent, refuse } from "@pmelab/gtd/flows"`,
21
+ `export const held = async () => { await agent("first", "go"); ${guard}; await gate("suite", "fix it", guard) }`,
22
+ `export default held`,
23
+ ].join("\n")
24
+
25
+ const red = {
26
+ first: (_: unknown, r: { write(p: string, c: string): void }) => r.write("f", "1\n"),
27
+ "suite.check": (_: unknown, r: { write(p: string, c: string): void }) =>
28
+ r.write(".gtd/FEEDBACK.md", "red\n"),
29
+ }
30
+
31
+ it("runs the guard after a blocked landing and lets its refusal stop it", async () => {
32
+ const repo = testRepo({
33
+ workflow: config('const guard = () => { refuse("guarded: nope") }'),
34
+ env: { GTD_TEST_COMMAND: "true" },
35
+ })
36
+ await repo.start("held")
37
+ await repo.drive(red)
38
+ await expect(repo.drive({ "suite.blocked": (_, r) => r.write("x", "1\n") })).rejects.toThrow(
39
+ "guarded: nope",
40
+ )
41
+ })
42
+
43
+ it("never runs the guard after the check step", async () => {
44
+ const repo = testRepo({
45
+ workflow: config('const guard = () => { refuse("guarded: nope") }'),
46
+ env: { GTD_TEST_COMMAND: "true" },
47
+ })
48
+ await repo.start("held")
49
+ const rest = await repo.drive(red)
50
+ expect(rest.state).toBe("suite.blocked")
51
+ })
52
+ })
@@ -28,13 +28,30 @@ const escalationSteps: kit.EscalationSteps = {
28
28
  exhausted: escalationExhausted,
29
29
  }
30
30
 
31
+ /** Runs after an agent or human turn; refuses it by throwing (`refuse`). */
32
+ export type Guard = () => void
33
+
34
+ export const guarded =
35
+ <A extends unknown[]>(turn: (...a: A) => Promise<void>, guard?: Guard) =>
36
+ async (...a: A): Promise<void> => {
37
+ await turn(...a)
38
+ guard?.()
39
+ }
40
+
41
+ const guardedSteps = (guard?: Guard): kit.EscalationSteps => ({
42
+ limit: escalationSteps.limit,
43
+ describe: guarded(escalationSteps.describe, guard),
44
+ stop: guarded(escalationSteps.stop, guard),
45
+ exhausted: guarded(escalationSteps.exhausted, guard),
46
+ })
47
+
31
48
  /**
32
49
  * Hand a red suite that repeated attempts could not fix to a person: an
33
50
  * agent writes `.gtd/ESCALATION.md`, a human narrows it. From the third round
34
51
  * on, the human is told the budget is spent instead.
35
52
  */
36
- export const escalation = (count: kit.EscalationCount): Promise<void> =>
37
- kit.escalation(count, escalationSteps)
53
+ export const escalation = (count: kit.EscalationCount, guard?: Guard): Promise<void> =>
54
+ kit.escalation(count, guardedSteps(guard))
38
55
 
39
56
  /** "Is this red round the same failure as the last one?" — asked of two reports, or of nothing when one is empty. */
40
57
  export const retryQuestion = (comparable: boolean): JudgeQuestion =>
@@ -79,6 +96,8 @@ export interface HealthOptions {
79
96
  readonly escalations?: kit.EscalationCount
80
97
  /** Paths removed on a green run. */
81
98
  readonly sweepOnGreen?: readonly string[]
99
+ /** Runs after every fix and escalation turn. */
100
+ readonly guard?: Guard | undefined
82
101
  }
83
102
 
84
103
  const runSuite = (options: HealthOptions): Promise<boolean> =>
@@ -95,20 +114,24 @@ const runSuite = (options: HealthOptions): Promise<boolean> =>
95
114
  * identical to the previous round's.
96
115
  */
97
116
  export const healthy = (fix: () => Promise<void>, options: HealthOptions = {}): Promise<void> =>
98
- kit.healthy(fix, {
117
+ kit.healthy(guarded(fix, options.guard), {
99
118
  check: () => runSuite(options),
100
119
  report: FEEDBACK,
101
120
  fixCap: FIX_CAP,
102
121
  fixesSoFar: options.fixesSoFar ?? 0,
103
122
  sameFailure,
104
123
  escalations: options.escalations ?? { rounds: 0 },
105
- escalation: escalationSteps,
124
+ escalation: guardedSteps(options.guard),
106
125
  })
107
126
 
108
- /** Hold the process at `<name>.blocked` until the suite is green. */
109
- export const gate = (name: string, message: string): Promise<void> =>
127
+ /**
128
+ * Hold the process at `<name>.blocked` until the suite is green. `guard` runs
129
+ * after each `blocked` landing only — never after a check, whose refusal would
130
+ * re-run the same script forever.
131
+ */
132
+ export const gate = (name: string, message: string, guard?: Guard): Promise<void> =>
110
133
  kit.gate(
111
134
  name,
112
135
  () => baseline("check"),
113
- () => human("blocked", { message, label: "Baseline is red", file: FEEDBACK }),
136
+ guarded(() => human("blocked", { message, label: "Baseline is red", file: FEEDBACK }), guard),
114
137
  )
@@ -1,8 +1,10 @@
1
1
  import { describe, expect, it } from "vitest"
2
2
  import { configure } from "../../flows/index.js"
3
3
  import * as workflows from "../index.js"
4
+ import * as options from "./options.js"
4
5
 
5
6
  const { defaults, envDefaults, feature, fix, review, ShippedOptions } = workflows
7
+ const { flagShipped } = options
6
8
 
7
9
  describe("the shipped workflow factories", () => {
8
10
  it("take exactly the settings the shipped workflows declare as options", () => {
@@ -57,4 +59,24 @@ describe("the shipped workflow factories", () => {
57
59
  const result = configure(fix({ judgeBudgetBytes: 65536 }))
58
60
  expect(result).toMatchObject({ ok: false, issues: [{ path: ["judgeBudgetBytes"] }] })
59
61
  })
62
+
63
+ describe("flagShipped", () => {
64
+ const factory = flagShipped(async () => {})
65
+
66
+ it("declare flag and flagRegistry as process settings, so --var flag= is accepted", () => {
67
+ expect(configure(factory)).toMatchObject({
68
+ ok: true,
69
+ build: {
70
+ defaults: { ...defaults, flag: "", flagRegistry: "src/flags/index.ts" },
71
+ envDefaults,
72
+ },
73
+ })
74
+ })
75
+
76
+ it("fail the load on an unknown option", () => {
77
+ // @ts-expect-error — `flag` is a --var, not an option
78
+ const result = configure(factory({ flag: "x" }))
79
+ expect(result).toMatchObject({ ok: false, issues: [{ path: ["flag"] }] })
80
+ })
81
+ })
60
82
  })
@@ -48,3 +48,22 @@ export const shipped = (
48
48
  base,
49
49
  }),
50
50
  )
51
+
52
+ export const FlagOptions = Schema.Struct({ ...ShippedOptions.fields, flagRegistry: setting })
53
+
54
+ export type FlagOptions = Schema.Schema.Encoded<typeof FlagOptions>
55
+
56
+ export const flagShipped = (flow: Flow): WorkflowFactory<FlagOptions> =>
57
+ defineWorkflow(
58
+ Schema.standardSchemaV1(FlagOptions, { onExcessProperty: "error", errors: "all" }),
59
+ (options) => {
60
+ const flagDefaults = { ...defaults, flag: "", flagRegistry: "src/flags/index.ts" }
61
+ return {
62
+ flow,
63
+ defaults: over(flagDefaults, options),
64
+ envDefaults: over(envDefaults, options),
65
+ doors: {},
66
+ base: undefined,
67
+ }
68
+ },
69
+ )