@pmelab/gtd 32.1.0 → 34.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +66 -53
  3. package/dist/chunks/{reserved-2fV0mj14.mjs → reserved-BcJgt5_R.mjs} +79 -12
  4. package/dist/chunks/{tail-BynhCfuv.mjs → tail-CM_fa93v.mjs} +1 -1
  5. package/dist/testkit.mjs +2 -2
  6. package/dist/workflows/cleanup.mjs +2 -2
  7. package/dist/workflows/feature.mjs +174 -40
  8. package/dist/workflows/fix.mjs +2 -2
  9. package/dist/workflows/review.mjs +2 -2
  10. package/dist/workflows/rollout.mjs +1 -1
  11. package/dist/workflows.mjs +249 -48
  12. package/package.json +1 -1
  13. package/skills/authoring/SKILL.md +2 -2
  14. package/src/testkit/steps.ts +4 -0
  15. package/src/workflows/feature/acceptance-criteria.feature +8 -0
  16. package/src/workflows/feature/fast-path.feature +11 -35
  17. package/src/workflows/feature/feature.test.ts +9 -100
  18. package/src/workflows/feature/feature.ts +29 -16
  19. package/src/workflows/feature/flag-mode.feature +2 -91
  20. package/src/workflows/feature/naive-acceptance.feature +140 -0
  21. package/src/workflows/feature/one-step-land.feature +11 -34
  22. package/src/workflows/feature/quality-unresolved.feature +96 -0
  23. package/src/workflows/feature/refactor-absorbs.feature +17 -25
  24. package/src/workflows/feature/refactor-first.feature +11 -35
  25. package/src/workflows/feature/refactor-lens-models.feature +6 -1
  26. package/src/workflows/feature/refactor-review.feature +67 -360
  27. package/src/workflows/feature/refactor.ts +4 -3
  28. package/src/workflows/feature/risk-tier.feature +10 -0
  29. package/src/workflows/feature/seam-rejection.feature +20 -37
  30. package/src/workflows/feature/seam.feature +66 -113
  31. package/src/workflows/feature/seam.test.ts +173 -3
  32. package/src/workflows/feature/seam.ts +196 -31
  33. package/src/workflows/review/review-door.feature +108 -0
  34. package/src/workflows/shared/tail.ts +2 -2
  35. package/src/workflows/shared/text.test.ts +40 -0
  36. package/src/workflows/shared/text.ts +110 -16
@@ -41,24 +41,17 @@ Feature: rejected naive passes walk one fixed path through the steer gate to idl
41
41
  And step "design.gate.answer" changes nothing
42
42
 
43
43
  Scenario: three rejects rest at the steer gate, a steered rerun lands and runs to idle
44
- Given step "seam.naive" writes "src/calc.ts" with:
44
+ Given step "seam.check" changes nothing
45
+ And step "seam.naive" writes "src/acceptance.test.ts" with:
46
+ """
47
+ it("[AC-1] holds", () => {})
48
+ """
49
+ And step "seam.naive" writes "src/calc.ts" with:
45
50
  """
46
51
  export const add = (a: number, b: number) => b + a
47
52
  """
48
53
  And step "implement.health.check" changes nothing
49
54
  And step "implement.closing" runs its script
50
- And step "build.review.reviewing" writes ".gtd/REVIEW.md" with:
51
- """
52
- # Review: abc1234
53
-
54
- <!-- base: 0000000 -->
55
-
56
- ## calc
57
-
58
- - [ ] ./src/calc.ts#1-2
59
- subtract added
60
- """
61
- And step "build.review.closing" runs its script
62
55
  When the process is driven
63
56
  Then the process rests at "seam.judge"
64
57
 
@@ -87,6 +80,10 @@ Feature: rejected naive passes walk one fixed path through the steer gate to idl
87
80
  - AC-1: the change behaves as described.
88
81
  """
89
82
  And step "seam.naive" from now on changes nothing
83
+ And step "seam.naive" writes "src/acceptance.test.ts" with:
84
+ """
85
+ it("[AC-1] holds", () => {})
86
+ """
90
87
  And step "seam.naive" writes "src/calc.ts" with:
91
88
  """
92
89
  export const add = (a: number, b: number) => a + b
@@ -104,21 +101,6 @@ Feature: rejected naive passes walk one fixed path through the steer gate to idl
104
101
  """
105
102
  When gtd lands
106
103
  And the process is driven
107
- Then the process rests at "build.review.await-review"
108
-
109
- Given a file ".gtd/REVIEW.md" with:
110
- """
111
- # Review: abc1234
112
-
113
- <!-- base: 0000000 -->
114
-
115
- ## calc
116
-
117
- - [x] ./src/calc.ts#1-2
118
- subtract added
119
- """
120
- When gtd lands
121
- And the process is driven
122
104
  Then the process rests at "idle"
123
105
  And the commit subjects end with:
124
106
  """
@@ -127,24 +109,25 @@ Feature: rejected naive passes walk one fixed path through the steer gate to idl
127
109
  gtd(check): start-gate.check → design.triage
128
110
  gtd(agent): design.triage → design.gate.answer
129
111
  gtd(human): design.gate.answer → seam.naive
130
- gtd(agent): seam.naive → seam.judge
112
+ gtd(agent): seam.naive → seam.check
113
+ gtd(check): seam.check → seam.judge
131
114
  gtd(judge): seam.judge → seam.naive
132
- gtd(agent): seam.naive → seam.judge
115
+ gtd(agent): seam.naive → seam.check
116
+ gtd(check): seam.check → seam.judge
133
117
  gtd(judge): seam.judge → seam.naive
134
- gtd(agent): seam.naive → seam.judge
118
+ gtd(agent): seam.naive → seam.check
119
+ gtd(check): seam.check → seam.judge
135
120
  gtd(judge): seam.judge → seam.steer
136
121
  gtd(human): seam.steer → seam.naive
137
- gtd(agent): seam.naive → seam.judge
122
+ gtd(agent): seam.naive → seam.check
123
+ gtd(check): seam.check → seam.judge
138
124
  gtd(judge): seam.judge → implement.building
139
125
  gtd(agent): implement.building → implement.health.check
140
126
  gtd(check): implement.health.check → implement.closing
141
- gtd(check): implement.closing → build.review.reviewing
142
- gtd(agent): build.review.reviewing → build.review.await-review
143
- gtd(human): build.review.await-review → build.review.closing
144
- gtd(check): build.review.closing → idle
127
+ gtd(check): implement.closing → idle
145
128
  """
146
129
  And the commit "gtd(human): idle → unwind" has a body containing "Gtd-Var: flag_risk_tier=0"
147
- And the commit "gtd(agent): seam.naive → seam.judge" has a body containing "Gtd-Kept:"
130
+ And the commit "gtd(agent): seam.naive → seam.check" has a body containing "Gtd-Kept:"
148
131
  And the commit "gtd(judge): seam.judge → seam.naive" has a body containing "\"answer\":\"reject\""
149
132
  And the commit "gtd(judge): seam.judge → seam.steer" has a body containing "\"answer\":\"reject\""
150
133
  And the commit "gtd(judge): seam.judge → implement.building" has a body containing "\"answer\":\"land\""
@@ -51,7 +51,12 @@ Feature: the naive pass and seam judge decide how a feature is built
51
51
  And step "design.gate.answer" changes nothing
52
52
 
53
53
  Scenario: a land judgment goes straight to the implementation with no seam gate or refactor
54
- Given step "seam.naive" writes "src/calc.ts" with:
54
+ Given step "seam.check" changes nothing
55
+ And step "seam.naive" writes "src/acceptance.test.ts" with:
56
+ """
57
+ it("[AC-1] holds", () => {})
58
+ """
59
+ And step "seam.naive" writes "src/calc.ts" with:
55
60
  """
56
61
  export const add = (a: number, b: number) => a + b
57
62
  export const subtract = (a: number, b: number) => a - b
@@ -72,12 +77,18 @@ Feature: the naive pass and seam judge decide how a feature is built
72
77
  gtd(check): start-gate.check → design.triage
73
78
  gtd(agent): design.triage → design.gate.answer
74
79
  gtd(human): design.gate.answer → seam.naive
75
- gtd(agent): seam.naive → seam.judge
80
+ gtd(agent): seam.naive → seam.check
81
+ gtd(check): seam.check → seam.judge
76
82
  gtd(judge): seam.judge → implement.building
77
83
  """
78
84
 
79
85
  Scenario: config skills for the implement scope reach the implement step
80
- Given step "seam.naive" writes "src/calc.ts" with:
86
+ Given step "seam.check" changes nothing
87
+ And step "seam.naive" writes "src/acceptance.test.ts" with:
88
+ """
89
+ it("[AC-1] holds", () => {})
90
+ """
91
+ And step "seam.naive" writes "src/calc.ts" with:
81
92
  """
82
93
  export const add = (a: number, b: number) => a + b
83
94
  export const subtract = (a: number, b: number) => a - b
@@ -91,19 +102,33 @@ Feature: the naive pass and seam judge decide how a feature is built
91
102
  And the resting step's content contains "my-tdd, my-runbook"
92
103
 
93
104
  Scenario: the naive pass lands as a marker commit and its turn stays off the branch
94
- Given step "seam.naive" writes "src/calc.ts" with:
105
+ Given step "seam.check" changes nothing
106
+ And step "seam.naive" writes "src/acceptance.test.ts" with:
107
+ """
108
+ it("[AC-1] holds", () => {})
109
+ """
110
+ And step "seam.naive" writes "src/calc.ts" with:
95
111
  """
96
112
  export const add = (a: number, b: number) => a + b
97
113
  export const subtract = (a: number, b: number) => a - b
98
114
  """
99
115
  When the process is driven
100
116
  Then the process rests at "seam.judge"
101
- And the last commit subject is "gtd(agent): seam.naive → seam.judge"
102
- And the last commit body contains "Gtd-Kept:"
117
+ And the commit subjects end with:
118
+ """
119
+ gtd(agent): seam.naive → seam.check
120
+ gtd(check): seam.check → seam.judge
121
+ """
122
+ And the commit "gtd(agent): seam.naive → seam.check" has a body containing "Gtd-Kept:"
103
123
  And "src/calc.ts" does not contain "subtract"
104
124
 
105
125
  Scenario: the implementation is one build turn, a green check, then the requirements are closed out
106
- Given step "seam.naive" writes "src/calc.ts" with:
126
+ Given step "seam.check" changes nothing
127
+ And step "seam.naive" writes "src/acceptance.test.ts" with:
128
+ """
129
+ it("[AC-1] holds", () => {})
130
+ """
131
+ And step "seam.naive" writes "src/calc.ts" with:
107
132
  """
108
133
  export const add = (a: number, b: number) => a + b
109
134
  export const subtract = (a: number, b: number) => a - b
@@ -121,18 +146,23 @@ Feature: the naive pass and seam judge decide how a feature is built
121
146
  And step "implement.health.check" changes nothing
122
147
  And step "implement.closing" runs its script
123
148
  When the process is driven
124
- Then the process rests at "build.review.reviewing"
149
+ Then the process rests at "idle"
125
150
  And the commit subjects end with:
126
151
  """
127
152
  gtd(judge): seam.judge → implement.building
128
153
  gtd(agent): implement.building → implement.health.check
129
154
  gtd(check): implement.health.check → implement.closing
130
- gtd(check): implement.closing → build.review.reviewing
155
+ gtd(check): implement.closing → idle
131
156
  """
132
157
  And ".gtd/REQUIREMENTS.md" does not exist
133
158
 
134
159
  Scenario: a naive pass modifying a test file and adding a new one skips the judge
135
- Given step "seam.naive" writes "src/subtract.ts" with:
160
+ Given step "seam.check" changes nothing
161
+ And step "seam.naive" writes "src/acceptance.test.ts" with:
162
+ """
163
+ it("[AC-1] holds", () => {})
164
+ """
165
+ And step "seam.naive" writes "src/subtract.ts" with:
136
166
  """
137
167
  export const subtract = (a: number, b: number) => a - b
138
168
  """
@@ -146,11 +176,17 @@ Feature: the naive pass and seam judge decide how a feature is built
146
176
  And the commit subjects end with:
147
177
  """
148
178
  gtd(human): design.gate.answer → seam.naive
149
- gtd(agent): seam.naive → implement.building
179
+ gtd(agent): seam.naive → seam.check
180
+ gtd(check): seam.check → implement.building
150
181
  """
151
182
 
152
183
  Scenario: a naive pass adding a glob-named file does not hide a modified sibling
153
- Given step "seam.naive" writes "src/calc*.ts" with:
184
+ Given step "seam.check" changes nothing
185
+ And step "seam.naive" writes "src/acceptance.test.ts" with:
186
+ """
187
+ it("[AC-1] holds", () => {})
188
+ """
189
+ And step "seam.naive" writes "src/calc*.ts" with:
154
190
  """
155
191
  export const subtract = (a: number, b: number) => a - b
156
192
  """
@@ -169,7 +205,12 @@ Feature: the naive pass and seam judge decide how a feature is built
169
205
  And the resting step's content does not contain "src/calc*.ts"
170
206
 
171
207
  Scenario: an edit at the steer gate runs the naive pass again with a fresh retry budget
172
- Given step "seam.naive" writes "src/calc.ts" with:
208
+ Given step "seam.check" changes nothing
209
+ And step "seam.naive" writes "src/acceptance.test.ts" with:
210
+ """
211
+ it("[AC-1] holds", () => {})
212
+ """
213
+ And step "seam.naive" writes "src/calc.ts" with:
173
214
  """
174
215
  export const add = (a: number, b: number) => b + a
175
216
  """
@@ -201,16 +242,24 @@ Feature: the naive pass and seam judge decide how a feature is built
201
242
  And the commit subjects end with:
202
243
  """
203
244
  gtd(human): seam.steer → seam.naive
204
- gtd(agent): seam.naive → seam.judge
245
+ gtd(agent): seam.naive → seam.check
246
+ gtd(check): seam.check → seam.judge
205
247
  gtd(judge): seam.judge → seam.naive
206
- gtd(agent): seam.naive → seam.judge
248
+ gtd(agent): seam.naive → seam.check
249
+ gtd(check): seam.check → seam.judge
207
250
  gtd(judge): seam.judge → seam.naive
208
- gtd(agent): seam.naive → seam.judge
251
+ gtd(agent): seam.naive → seam.check
252
+ gtd(check): seam.check → seam.judge
209
253
  gtd(judge): seam.judge → seam.steer
210
254
  """
211
255
 
212
256
  Scenario: a clean landing at the steer gate overrules, with no seam gate and no refactor
213
- Given step "seam.naive" writes "src/calc.ts" with:
257
+ Given step "seam.check" changes nothing
258
+ And step "seam.naive" writes "src/acceptance.test.ts" with:
259
+ """
260
+ it("[AC-1] holds", () => {})
261
+ """
262
+ And step "seam.naive" writes "src/calc.ts" with:
214
263
  """
215
264
  export const add = (a: number, b: number) => b + a
216
265
  """
@@ -227,99 +276,3 @@ Feature: the naive pass and seam judge decide how a feature is built
227
276
  gtd(judge): seam.judge → seam.steer
228
277
  gtd(human): seam.steer → implement.building
229
278
  """
230
-
231
- Scenario: review feedback after land re-runs design and implement, never the seam phase
232
- Given step "seam.naive" writes "src/calc.ts" with:
233
- """
234
- export const add = (a: number, b: number) => b + a
235
- """
236
- And judge step "seam.judge" answers:
237
- """
238
- [{"id":"shape","answer":"land","p":0.9}]
239
- """
240
- And step "implement.building" writes "src/calc.ts" with:
241
- """
242
- export const add = (a: number, b: number) => a + b
243
- export const SUBTRACT = process.env.GTD_FLAG_SUBTRACT === "1"
244
- """
245
- And step "implement.health.check" changes nothing
246
- And step "implement.closing" runs its script
247
- And step "build.review.reviewing" writes ".gtd/REVIEW.md" with:
248
- """
249
- # Review: abc1234
250
-
251
- <!-- base: 0000000 -->
252
-
253
- ## calc
254
-
255
- - [ ] ./src/calc.ts#1-1
256
- new add function
257
- """
258
- When the process is driven
259
- Then the process rests at "build.review.await-review"
260
- Given a file ".gtd/REVIEW.md" with:
261
- """
262
- # Review: abc1234
263
-
264
- <!-- base: 0000000 -->
265
-
266
- ## calc
267
-
268
- - [ ] ./src/calc.ts#1-1
269
- new add function[^why]
270
-
271
- [^why]:
272
- - H: also export multiply
273
- """
274
- And step "build.review.collecting" writes ".gtd/REQUIREMENTS.md" with:
275
- """
276
- # Requirements
277
-
278
- ## Concerns
279
-
280
- ### Multiply
281
-
282
- - Export `multiply()`.
283
- """
284
- And step "build.review.collecting" writes ".gtd/REVIEW.md" with:
285
- """
286
- # Review: abc1234
287
-
288
- <!-- base: 0000000 -->
289
-
290
- ## calc
291
-
292
- - [ ] ./src/calc.ts#1-1
293
- new add function[^why]
294
-
295
- [^why]:
296
- - H: also export multiply
297
- - A: noted in the requirements.
298
- """
299
- When gtd lands
300
- And the process is driven
301
- Then the process rests at "build.review.await-review"
302
- Given a file ".gtd/REVIEW.md" with:
303
- """
304
- # Review: abc1234
305
-
306
- <!-- base: 0000000 -->
307
-
308
- ## calc
309
-
310
- - [ ] ./src/calc.ts#1-1
311
- new add function
312
- """
313
- And step "build.review.closing" runs its script
314
- And step "re-unwind" runs its script
315
- When gtd lands
316
- And the process is driven
317
- Then the process rests at "implement.building"
318
- And the commit subjects end with:
319
- """
320
- gtd(check): build.review.closing → re-unwind
321
- gtd(check): re-unwind → design.triage
322
- gtd(agent): design.triage → design.gate.answer
323
- gtd(human): design.gate.answer → implement.building
324
- gtd(agent): implement.building
325
- """
@@ -8,7 +8,14 @@ import {
8
8
  } from "../../flows/index.js"
9
9
  import { globMatches } from "../../wire/index.js"
10
10
  import { fixtureContext } from "../shared/text.fixture.js"
11
- import { implement, judgeSeam, naivePass, seamBuild, type SeamOutcome } from "./seam.js"
11
+ import {
12
+ implement,
13
+ judgeSeam,
14
+ naivePass,
15
+ acceptanceFault,
16
+ seamBuild,
17
+ type SeamOutcome,
18
+ } from "./seam.js"
12
19
  import { captureStep } from "../shared/text.fixture.js"
13
20
 
14
21
  afterEach(() => installContext(undefined))
@@ -98,6 +105,130 @@ describe("naivePass", () => {
98
105
  })
99
106
  })
100
107
 
108
+ describe("acceptanceFault", () => {
109
+ const requirements = "# R\n\n## Acceptance Criteria\n\n- AC-1: adds.\n- AC-2: subtracts.\n"
110
+ const file = (path: string, status: Change["status"], after: string): Change => ({
111
+ path,
112
+ status,
113
+ before: status === "added" ? undefined : "",
114
+ after,
115
+ })
116
+ const refusal = (...changes: Change[]): string | undefined => {
117
+ installContext({
118
+ ...fixtureContext({}),
119
+ matches: globMatches,
120
+ read: (path: string) => (path === ".gtd/REQUIREMENTS.md" ? requirements : undefined),
121
+ } as unknown as FlowContext)
122
+ return acceptanceFault(".gtd/REQUIREMENTS.md", tip(...changes))
123
+ }
124
+ const tagged = 'import { add } from "../src/calc.js"\nit("[AC-1] adds")\nit("[AC-2] subtracts")'
125
+
126
+ it("passes when every ID is named in a test the pass wrote", () => {
127
+ expect(
128
+ refusal(file("src/calc.ts", "modified", "x"), file("tests/calc.test.ts", "added", tagged)),
129
+ ).toBe(undefined)
130
+ })
131
+
132
+ it("names every ID no test of the pass mentions", () => {
133
+ const message = refusal(file("tests/calc.test.ts", "added", 'it("[AC-12] x")'))
134
+ expect(message).toMatch(/^acceptance-tests: /)
135
+ expect(message).toContain("AC-1, AC-2")
136
+ })
137
+
138
+ it("does not count an ID in a non-test file", () => {
139
+ expect(refusal(file("src/calc.ts", "modified", "AC-1 AC-2"))).toContain("AC-1, AC-2")
140
+ })
141
+
142
+ it("refuses a test importing a file the pass added, resolved from the test's directory", () => {
143
+ const message = refusal(
144
+ file("src/naive/helper.ts", "added", "export const h = 1"),
145
+ file("src/naive/helper.test.ts", "added", `import { h } from "./helper.js"\n${tagged}`),
146
+ )
147
+ expect(message).toMatch(/^acceptance-internals: /)
148
+ expect(message).toContain("src/naive/helper.test.ts → src/naive/helper.ts")
149
+ })
150
+
151
+ it("refuses an aliased or directory import of an added module", () => {
152
+ const added = file("src/naive/index.ts", "added", "")
153
+ expect(
154
+ refusal(added, file("tests/a.test.ts", "added", `import "@/naive"\n${tagged}`)),
155
+ ).toContain("acceptance-internals")
156
+ expect(
157
+ refusal(added, file("tests/a.test.ts", "added", `import "../src/naive/index.js"\n${tagged}`)),
158
+ ).toContain("acceptance-internals")
159
+ })
160
+
161
+ it("counts only the lines the pass added: an ID an earlier feature left does not", () => {
162
+ const old = 'it("[AC-1] old")\nit("[AC-2] old")'
163
+ const message = refusal({
164
+ path: "tests/calc.test.ts",
165
+ status: "modified",
166
+ before: old,
167
+ after: `${old}\nit("[AC-2] new")`,
168
+ })
169
+ expect(message).toContain("names AC-1 —")
170
+ })
171
+
172
+ it("refuses a bare . or .. import of an added index", () => {
173
+ expect(
174
+ refusal(
175
+ file("src/naive/index.ts", "added", ""),
176
+ file("src/naive/__tests__/a.test.ts", "added", `import { x } from ".."\n${tagged}`),
177
+ ),
178
+ ).toContain("acceptance-internals")
179
+ })
180
+
181
+ it("reads a URL route or path-like data as no import", () => {
182
+ expect(
183
+ refusal(
184
+ file("src/routes/users.ts", "added", ""),
185
+ file(
186
+ "tests/a.test.ts",
187
+ "added",
188
+ `request(app).get("/users")\nrun("src/routes/users.ts")\n${tagged}`,
189
+ ),
190
+ ),
191
+ ).toBe(undefined)
192
+ })
193
+
194
+ it("reads no import out of a .feature file", () => {
195
+ expect(
196
+ refusal(
197
+ file("src/subtract.ts", "added", ""),
198
+ file("features/calc.feature", "added", `Given a file "src/subtract.ts"\n${tagged}`),
199
+ ),
200
+ ).toBe(undefined)
201
+ })
202
+
203
+ it("skips commented-out imports and takes a package subpath as no repository path", () => {
204
+ expect(
205
+ refusal(
206
+ file("src/naive/helper.ts", "added", ""),
207
+ file("src/date-fns/format.ts", "added", ""),
208
+ file(
209
+ "src/naive/a.test.ts",
210
+ "added",
211
+ `// import { h } from "./helper.js"\nimport { format } from "date-fns/format"\n${tagged}`,
212
+ ),
213
+ ),
214
+ ).toBe(undefined)
215
+ })
216
+
217
+ it("lets a test reach an existing module, a same-named file elsewhere, or a URL", () => {
218
+ expect(
219
+ refusal(
220
+ file("src/naive/api.ts", "added", ""),
221
+ file("src/calc.ts", "modified", ""),
222
+ file(
223
+ "tests/a.test.ts",
224
+ "added",
225
+ `import "../src/calc.js"\nimport "./api.js"\nfetch("https://x/api")\n${tagged}`,
226
+ ),
227
+ ),
228
+ ).toBe(undefined)
229
+ })
230
+ })
231
+
101
232
  const REVIEW = ".gtd/REVIEW.md"
102
233
  const REFACTOR = ".gtd/REFACTOR.md"
103
234
  const reviewDoc = [
@@ -124,7 +255,10 @@ const driveSeam = async (
124
255
  answer: string,
125
256
  stray: Change = change("src/a.ts", "modified"),
126
257
  vars: Record<string, string> = {},
258
+ reds = 0,
127
259
  ): Promise<Seamed> => {
260
+ let red = false
261
+ let redsLeft = reds
128
262
  const names: string[] = []
129
263
  const slices: string[] = []
130
264
  const inside: Record<string, string[]> = {}
@@ -158,11 +292,17 @@ const driveSeam = async (
158
292
  read: (path: string) => files.get(path),
159
293
  head: () => current,
160
294
  kept: () => tip(stray),
161
- changes: () => [],
295
+ changes: () => (red ? [change(".gtd/FEEDBACK.md", "added")] : []),
162
296
  changesSince: () => [],
163
297
  step: (request: StepRequest) => {
164
298
  if (request.kind === "restart") return Promise.resolve()
165
299
  const full = [...stack, request.name].join(".")
300
+ red = full === "seam.check" && redsLeft-- > 0
301
+ if (full === "seam.check") {
302
+ if (red) files.set(".gtd/FEEDBACK.md", "FAIL")
303
+ else files.delete(".gtd/FEEDBACK.md")
304
+ }
305
+ if (full === "seam.clear") files.delete(".gtd/FEEDBACK.md")
166
306
  names.push(full)
167
307
  for (const name of open) inside[name]!.push(full)
168
308
  effects[request.name]?.()
@@ -185,7 +325,37 @@ describe("seamBuild", () => {
185
325
  const h = await driveSeam("land")
186
326
  expect(h.outcome).toEqual({ reference: "abc", base: "c-base" })
187
327
  expect(h.slices).toEqual([])
188
- expect(h.names).toEqual(["seam.naive", "seam.judge"])
328
+ expect(h.names).toEqual(["seam.naive", "seam.check", "seam.judge"])
329
+ })
330
+
331
+ it("a red check retries the pass, and the judge sees it only once green", async () => {
332
+ const h = await driveSeam("land", undefined, {}, 1)
333
+ expect(h.names).toEqual(["seam.naive", "seam.check", "seam.naive", "seam.check", "seam.judge"])
334
+ })
335
+
336
+ it("an overruled red pass gets a fresh budget for the judge's rejects", async () => {
337
+ const h = await driveSeam("reject", undefined, {}, 3)
338
+ const steers = h.names.flatMap((name, i) => (name === "seam.steer" ? [i] : []))
339
+ expect(steers).toHaveLength(2)
340
+ expect(h.names.slice(steers[0]! + 1, steers[1]).filter((n) => n === "seam.judge")).toHaveLength(
341
+ 3,
342
+ )
343
+ })
344
+
345
+ it("a third red rests at seam.steer, and a clean landing there takes the red pass to the judge", async () => {
346
+ const h = await driveSeam("land", undefined, {}, 3)
347
+ expect(h.names).toEqual([
348
+ "seam.naive",
349
+ "seam.check",
350
+ "seam.naive",
351
+ "seam.check",
352
+ "seam.naive",
353
+ "seam.check",
354
+ "seam.steer",
355
+ "seam.clear",
356
+ "seam.judge",
357
+ ])
358
+ expect(h.outcome).toEqual({ reference: "abc", base: "c-base" })
189
359
  })
190
360
 
191
361
  it("refactor-first runs the reviewed refactor in slice refactor and resolves HEAD after it", async () => {