@pmelab/gtd 27.1.0 → 27.3.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 (45) 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-BPdVZkgv.mjs} +66 -73
  5. package/dist/chunks/tail-iLAMoigl.mjs +81 -0
  6. package/dist/gtd.bundle.mjs +5 -2
  7. package/dist/testkit.mjs +17 -2
  8. package/dist/workflows/cleanup.mjs +37 -0
  9. package/dist/workflows/feature.mjs +119 -5
  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 +362 -25
  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/EmittedScriptRecognizer.ts +21 -0
  18. package/src/testkit/EmittedScripts.test.ts +9 -0
  19. package/src/testkit/steps.ts +26 -0
  20. package/src/testkit/testkit.feature +8 -1
  21. package/src/workflows/cleanup/cleanup.feature +323 -0
  22. package/src/workflows/cleanup/cleanup.test.ts +272 -0
  23. package/src/workflows/cleanup/cleanup.ts +52 -0
  24. package/src/workflows/cleanup/index.ts +6 -0
  25. package/src/workflows/feature/index.ts +1 -0
  26. package/src/workflows/feature/refactor.test.ts +466 -0
  27. package/src/workflows/feature/refactor.ts +178 -0
  28. package/src/workflows/index.ts +4 -1
  29. package/src/workflows/rollout/index.ts +6 -0
  30. package/src/workflows/rollout/rollout.feature +214 -0
  31. package/src/workflows/rollout/rollout.test.ts +167 -0
  32. package/src/workflows/rollout/rollout.ts +44 -0
  33. package/src/workflows/shared/access.ts +2 -1
  34. package/src/workflows/shared/health.test.ts +103 -0
  35. package/src/workflows/shared/health.ts +32 -7
  36. package/src/workflows/shared/options.test.ts +30 -0
  37. package/src/workflows/shared/options.ts +20 -0
  38. package/src/workflows/shared/registry.test.ts +305 -0
  39. package/src/workflows/shared/registry.ts +158 -0
  40. package/src/workflows/shared/skills.test.ts +6 -0
  41. package/src/workflows/shared/skills.ts +5 -1
  42. package/src/workflows/shared/tail.test.ts +36 -2
  43. package/src/workflows/shared/tail.ts +30 -18
  44. package/src/workflows/shared/text.ts +35 -0
  45. package/src/workflows/shared/vars.ts +1 -0
@@ -0,0 +1,178 @@
1
+ import {
2
+ changes,
3
+ changesSince,
4
+ head,
5
+ quote,
6
+ read,
7
+ refuse,
8
+ removeScript,
9
+ run,
10
+ scope,
11
+ vars,
12
+ type DiffShapeResult,
13
+ } from "../../flows/index.js"
14
+ import { healthy } from "../shared/health.js"
15
+ import { coder, FEEDBACK } from "../shared/steps.js"
16
+ import * as t from "../shared/text.js"
17
+ import { defaults } from "../shared/vars.js"
18
+
19
+ export const REFACTOR_PACKAGE = ".gtd/REFACTOR.md"
20
+
21
+ export interface RefactorOptions {
22
+ /** Blank counts as absent: `kept()` reports `""` while the keeping turn is pending. */
23
+ readonly reference?: string | undefined
24
+ }
25
+
26
+ const isCode = (path: string): boolean => path !== ".gtd" && !path.startsWith(".gtd/")
27
+
28
+ export const requireWithin = (allowed: readonly string[]): void => {
29
+ const outside = changes()
30
+ .map((c) => c.path)
31
+ .filter((path) => isCode(path) && !allowed.includes(path))
32
+ if (outside.length === 0) return
33
+ refuse(
34
+ `gtd land: refactor-scope: the refactor may change only its stray paths — revert these:\n${outside.map((path) => ` - ${path}`).join("\n")}`,
35
+ )
36
+ }
37
+
38
+ const flagExpression = (pattern: string): RegExp => {
39
+ if (pattern.trim() === "") throw new Error("gtd: refactorFlagPattern must not be blank")
40
+ let expression: RegExp
41
+ try {
42
+ expression = new RegExp(pattern, "g")
43
+ } catch (error) {
44
+ throw new Error(`gtd: refactorFlagPattern is not a valid regular expression: ${String(error)}`)
45
+ }
46
+ // An empty match would count the name "" at every character.
47
+ if (expression.test(""))
48
+ throw new Error("gtd: refactorFlagPattern must not match the empty string")
49
+ return expression
50
+ }
51
+
52
+ // A zero-length match is never counted: a lookahead-only pattern would count every read
53
+ // under "" and let a flag swap pass. Checked on the strays up front and again at landing.
54
+ const countFlags = (text: string, expression: RegExp, into: Map<string, number>): boolean => {
55
+ let zeroLength = false
56
+ for (const [name] of text.matchAll(expression)) {
57
+ if (name === "") zeroLength = true
58
+ else into.set(name, (into.get(name) ?? 0) + 1)
59
+ }
60
+ return zeroLength
61
+ }
62
+
63
+ const listPaths = (paths: readonly string[]): string =>
64
+ paths.map((path) => ` - ${path}`).join("\n")
65
+
66
+ // Counts per name, so a read moved between files passes.
67
+ export const requireNoNewFlags = (pattern: string, since: string): void => {
68
+ const expression = flagExpression(pattern)
69
+ const before = new Map<string, number>()
70
+ const after = new Map<string, number>()
71
+ const unreadable: string[] = []
72
+ const zeroLength: string[] = []
73
+ for (const { path, status, before: was, after: now } of changesSince(since)) {
74
+ if (!isCode(path)) continue
75
+ // A side absent by status is empty; one absent otherwise could not be read.
76
+ const oldSide = status === "added" ? "" : was
77
+ const newSide = status === "deleted" ? "" : now
78
+ if (oldSide === undefined || newSide === undefined) {
79
+ unreadable.push(path)
80
+ continue
81
+ }
82
+ countFlags(oldSide, expression, before)
83
+ if (countFlags(newSide, expression, after)) zeroLength.push(path)
84
+ }
85
+ if (unreadable.length > 0)
86
+ refuse(`gtd land: refactor-flags: cannot compare flag reads in:\n${listPaths(unreadable)}`)
87
+ if (zeroLength.length > 0)
88
+ refuse(
89
+ `gtd land: refactor-flags: refactorFlagPattern (${pattern}) produced a zero-length match in:\n${listPaths(zeroLength)}`,
90
+ )
91
+ const raised = [...after].filter(([name, count]) => count > (before.get(name) ?? 0))
92
+ if (raised.length === 0) return
93
+ refuse(
94
+ `gtd land: refactor-flags: the refactor may not introduce or add reads of a flag (${pattern}):\n${raised.map(([name, count]) => ` - ${name}: ${before.get(name) ?? 0} → ${count}`).join("\n")}`,
95
+ )
96
+ }
97
+
98
+ const packageLines = (
99
+ strays: readonly string[],
100
+ pattern: string,
101
+ reference: string | undefined,
102
+ ): string[] => [
103
+ "# Behaviour-neutral refactor",
104
+ "",
105
+ "## Scope (read-only)",
106
+ "",
107
+ ...strays.map((path) => `- \`${path}\``),
108
+ "",
109
+ "This list is fixed when the package is derived: editing it here widens or narrows nothing.",
110
+ "",
111
+ "## Rules",
112
+ "",
113
+ "- The suite's outcome must not change",
114
+ "- Change only the paths above; a test file only when it is one of them",
115
+ `- Introduce no flag read and add no second read of an existing one (\`${pattern}\`)`,
116
+ "",
117
+ "## Reference",
118
+ "",
119
+ reference === undefined
120
+ ? "None."
121
+ : `Reading material only, never applied: \`git show ${reference} -- ${strays.map(quote).join(" ")}\``,
122
+ "",
123
+ ]
124
+
125
+ const deriveScript = (lines: readonly string[]): string =>
126
+ `mkdir -p .gtd
127
+ printf '%s\\n' ${lines.map(quote).join(" ")} > ${quote(REFACTOR_PACKAGE)}
128
+ `
129
+
130
+ export const refactorFirst = async (
131
+ shape: DiffShapeResult,
132
+ options: RefactorOptions = {},
133
+ ): Promise<boolean> => {
134
+ if (shape.choice !== "refactor-first" || shape.unreadable.length > 0) return false
135
+ const pattern = vars.refactorFlagPattern ?? defaults.refactorFlagPattern!
136
+ const expression = flagExpression(pattern)
137
+ const { strays } = shape
138
+ const zeroLength = strays.filter((path) => countFlags(read(path) ?? "", expression, new Map()))
139
+ if (zeroLength.length > 0)
140
+ throw new Error(
141
+ `gtd: refactorFlagPattern (${pattern}) produces a zero-length match in:\n${listPaths(zeroLength)}`,
142
+ )
143
+ const reference = options.reference?.trim() ? options.reference : undefined
144
+ return scope({ name: "refactor", access: { write: [...strays, ".gtd/**"] } }, async () => {
145
+ await run("deriving", deriveScript(packageLines(strays, pattern, reference)), {
146
+ label: "Deriving the refactor package",
147
+ file: REFACTOR_PACKAGE,
148
+ })
149
+ const from = head()
150
+ // The in-memory `strays`, never the package file: a hand edit of its list changes nothing.
151
+ // Scope looks at the turn's own changes: a person's edit at an escalation rest must not
152
+ // wedge every later fix turn. Flags look at the whole range.
153
+ const guard = (): void => {
154
+ requireWithin(strays)
155
+ requireNoNewFlags(pattern, from)
156
+ }
157
+ await t.agentWithSkills(
158
+ "building",
159
+ t.refactorBuildingPrompt(REFACTOR_PACKAGE, strays, pattern, reference),
160
+ { label: "Refactoring", model: coder(), system: t.builderSystem() },
161
+ )
162
+ guard()
163
+ await healthy(
164
+ async () => {
165
+ await t.agentWithSkills("fix.suite.fixing", t.refactorFixPrompt(strays, pattern), {
166
+ label: "Fixing the check",
167
+ file: FEEDBACK,
168
+ model: coder(),
169
+ system: t.builderSystem(),
170
+ })
171
+ guard()
172
+ },
173
+ { describeGuard: guard },
174
+ )
175
+ await run("closing", removeScript([REFACTOR_PACKAGE]), { label: "Closing out the refactor" })
176
+ return true
177
+ })
178
+ }
@@ -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"
@@ -22,10 +22,13 @@ export { lensesOf } from "../workflow-kit/index.js"
22
22
  export * from "./feature/steps.js"
23
23
  export * from "./feature/planning.js"
24
24
  export * from "./feature/packages.js"
25
+ export * from "./feature/refactor.js"
25
26
  export { buildTail, type ReviewOutcome } from "./feature/review.js"
26
27
  export * from "./feature/feature.js"
27
28
  export { base, review, reviewFlow } from "./review/review.js"
28
29
  export { fix, fixFlow, fixReviewNotes } from "./fix/fix.js"
30
+ export { rollout, rolloutFlow } from "./rollout/rollout.js"
31
+ export { cleanup, cleanupFlow } from "./cleanup/cleanup.js"
29
32
 
30
33
  export default feature
31
34
 
@@ -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
@@ -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)
@@ -3,7 +3,8 @@ import { lensesOf, lensScopes } from "../../workflow-kit/index.js"
3
3
 
4
4
  // The bundled workflow's default file access, keyed by scope full name — the
5
5
  // name a `config.access` entry addresses. Only planning and review scopes
6
- // are restricted, and only on writes; no scope restricts reads. A step's own
6
+ // are restricted, and only on writes; no scope restricts reads. `refactor`'s
7
+ // write restriction lives in its own `scope()` option, not here. A step's own
7
8
  // steering file is folded in per step, so it is not listed here. Build and fix
8
9
  // scopes carry no entry: no project layout is assumed.
9
10
  export const access = (