@pmelab/gtd 39.0.0 → 41.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 (37) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +57 -55
  3. package/dist/chunks/{reserved-B6tTYjeb.mjs → reserved-Ch4LLrEj.mjs} +15 -9
  4. package/dist/chunks/{tail-_CZTP7Hu.mjs → tail-lY1UmJrU.mjs} +1 -1
  5. package/dist/workflow-kit.mjs +29 -11
  6. package/dist/workflows/cleanup.mjs +2 -2
  7. package/dist/workflows/feature.mjs +98 -37
  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 +140 -55
  12. package/package.json +1 -1
  13. package/skills/authoring/SKILL.md +3 -2
  14. package/src/workflow-kit/health.ts +42 -13
  15. package/src/workflows/feature/coverage.feature +5 -5
  16. package/src/workflows/feature/criteria.test.ts +20 -1
  17. package/src/workflows/feature/criteria.ts +24 -0
  18. package/src/workflows/feature/fast-path.feature +4 -4
  19. package/src/workflows/feature/feature.ts +82 -22
  20. package/src/workflows/feature/flag-mode.feature +2 -2
  21. package/src/workflows/feature/one-step-land.feature +4 -4
  22. package/src/workflows/feature/planning.ts +3 -2
  23. package/src/workflows/feature/quality-unresolved.feature +6 -6
  24. package/src/workflows/feature/refactor-absorbs.feature +3 -3
  25. package/src/workflows/feature/refactor-first.feature +5 -5
  26. package/src/workflows/feature/refactor-rebuild.feature +2 -2
  27. package/src/workflows/feature/refactor-review.feature +8 -8
  28. package/src/workflows/feature/review-gate.feature +271 -0
  29. package/src/workflows/feature/seam-rejection.feature +4 -4
  30. package/src/workflows/feature/seam.feature +2 -2
  31. package/src/workflows/feature/seam.ts +17 -7
  32. package/src/workflows/feature/slicing.feature +63 -4
  33. package/src/workflows/shared/health.test.ts +30 -0
  34. package/src/workflows/shared/health.ts +12 -9
  35. package/src/workflows/shared/progress.feature +124 -0
  36. package/src/workflows/shared/text.ts +7 -5
  37. package/src/workflows/review/review-door.feature +0 -121
@@ -0,0 +1,271 @@
1
+ @inmem
2
+ Feature: a feature process ends at the human review
3
+
4
+ After the last slice's quality lap the process rests at the review gate,
5
+ `.gtd/REVIEW.md`. A sign-off ends the process. A note asking for a change in
6
+ behaviour starts a feedback lap — design, then implement with no new naive
7
+ pass or seam judge, the coverage judge over every criterion, the quality lap
8
+ — and the review again. Its design keeps a built criterion's ID and numbers
9
+ new behaviour above every ID handed out.
10
+
11
+ Background:
12
+ Given the shipped workflows, as gtd install scaffolds them
13
+ And an environment variable "GTD_QUALITYREVIEWS" set to ""
14
+ And an environment variable "GTD_FLAGMODE" set to "false"
15
+ And a file "src/calc.ts" with:
16
+ """
17
+ export const add = (a: number, b: number) => a + b
18
+ """
19
+ And the working tree is committed as "feat: add calculator"
20
+ And a file "TODO.md" with:
21
+ """
22
+ # Add subtract
23
+
24
+ subtract(a, b) returns the difference.
25
+ """
26
+ And step "unwind" changes nothing
27
+ And step "start-gate.check" changes nothing
28
+ And step "design.triage" writes ".gtd/REQUIREMENTS.md" with:
29
+ """
30
+ # Requirements
31
+
32
+ ## Concerns
33
+
34
+ ### Subtract
35
+
36
+ - `subtract(a, b)` returns `a - b`.
37
+
38
+ ## Acceptance Criteria
39
+
40
+ - AC-1: subtract(a, b) returns a - b.
41
+ """
42
+ And step "design.gate.answer" changes nothing
43
+ And step "seam.naive" writes "src/calc.test.ts" with:
44
+ """
45
+ it("[AC-1] subtracts", () => {})
46
+ """
47
+ And step "seam.naive" writes "src/calc.ts" with:
48
+ """
49
+ export const add = (a: number, b: number) => a + b
50
+ export const subtract = (a: number, b: number) => a - b
51
+ """
52
+ And step "seam.check" changes nothing
53
+ And step "implement.applying" runs its script
54
+ And step "implement.health.check" changes nothing
55
+ And step "implement.closing" runs its script
56
+ And judge step "implement.coverage" answers:
57
+ """
58
+ [{"id":"AC-1","answer":"yes","p":0.9}]
59
+ """
60
+ And step "build.review.reviewing" writes ".gtd/REVIEW.md" with:
61
+ """
62
+ # Review: abc1234
63
+
64
+ <!-- base: 0000000 -->
65
+
66
+ ## calc
67
+
68
+ - [ ] ./src/calc.ts#2-2
69
+ new subtract function
70
+ """
71
+ And step "build.review.closing" runs its script
72
+
73
+ Scenario: a feature process ends at the review gate
74
+ When the process is driven
75
+ Then the process rests at "build.review.await-review"
76
+ And ".gtd/REQUIREMENTS.md" does not exist
77
+ And the resting step's content contains the hash of the commit "feat: add calculator"
78
+ And the commit subjects end with:
79
+ """
80
+ gtd(judge): implement.coverage → implement.closing
81
+ gtd(check): implement.closing → build.review.reviewing
82
+ gtd(agent): build.review.reviewing → build.review.await-review
83
+ """
84
+
85
+ Scenario: a sign-off ends the process at idle
86
+ When the process is driven
87
+ Then the process rests at "build.review.await-review"
88
+ Given a file ".gtd/REVIEW.md" with:
89
+ """
90
+ # Review: abc1234
91
+
92
+ <!-- base: 0000000 -->
93
+
94
+ ## calc
95
+
96
+ - [x] ./src/calc.ts#2-2
97
+ new subtract function
98
+ """
99
+ When gtd lands
100
+ And the process is driven
101
+ Then the process rests at "idle"
102
+ And ".gtd/REVIEW.md" does not exist
103
+ And the commit subjects end with:
104
+ """
105
+ gtd(agent): build.review.reviewing → build.review.await-review
106
+ gtd(human): build.review.await-review → build.review.closing
107
+ gtd(check): build.review.closing → idle
108
+ """
109
+
110
+ Rule: a note asking for new behaviour runs the feedback lap
111
+
112
+ Background:
113
+ Given step "build.review.collecting" writes ".gtd/REQUIREMENTS.md" with:
114
+ """
115
+ # Requirements
116
+
117
+ ## Concerns
118
+
119
+ ### Multiply
120
+
121
+ - Export `multiply()`.
122
+ """
123
+ And step "build.review.collecting" writes ".gtd/REVIEW.md" with:
124
+ """
125
+ # Review: abc1234
126
+
127
+ <!-- base: 0000000 -->
128
+
129
+ ## calc
130
+
131
+ - [ ] ./src/calc.ts#2-2
132
+ new subtract function[^why]
133
+
134
+ [^why]:
135
+ - H: also export multiply
136
+ - A: noted in the requirements.
137
+ """
138
+ And step "re-unwind" runs its script
139
+ When the process is driven
140
+ Then the process rests at "build.review.await-review"
141
+ Given a file ".gtd/REVIEW.md" with:
142
+ """
143
+ # Review: abc1234
144
+
145
+ <!-- base: 0000000 -->
146
+
147
+ ## calc
148
+
149
+ - [ ] ./src/calc.ts#2-2
150
+ new subtract function[^why]
151
+
152
+ [^why]:
153
+ - H: also export multiply
154
+ """
155
+ When gtd lands
156
+ And the process is driven
157
+ Then the process rests at "build.review.await-review"
158
+ And ".gtd/REQUIREMENTS.md" contains "multiply"
159
+ Given a file ".gtd/REVIEW.md" with:
160
+ """
161
+ # Review: abc1234
162
+
163
+ <!-- base: 0000000 -->
164
+
165
+ ## calc
166
+
167
+ - [ ] ./src/calc.ts#2-2
168
+ new subtract function
169
+ """
170
+
171
+ Scenario: the lap keeps AC-1, adds AC-2, and covers both before the next review
172
+ Given step "design.triage" from now on changes nothing
173
+ And step "design.triage" writes ".gtd/REQUIREMENTS.md" with:
174
+ """
175
+ # Requirements
176
+
177
+ ## Concerns
178
+
179
+ ### Multiply
180
+
181
+ - `multiply(a, b)` returns `a * b`.
182
+
183
+ ## Acceptance Criteria
184
+
185
+ - AC-1: subtract(a, b) returns a - b.
186
+ - AC-2: multiply(a, b) returns a * b.
187
+ """
188
+ And step "implement.building" writes "src/calc.ts" with:
189
+ """
190
+ export const add = (a: number, b: number) => a + b
191
+ export const subtract = (a: number, b: number) => a - b
192
+ export const multiply = (a: number, b: number) => a * b
193
+ """
194
+ And step "implement.building" writes "src/multiply.test.ts" with:
195
+ """
196
+ it("[AC-2] multiplies", () => {})
197
+ """
198
+ And judge step "implement.coverage" answers:
199
+ """
200
+ [{"id":"AC-1","answer":"yes","p":0.9},{"id":"AC-2","answer":"yes","p":0.9}]
201
+ """
202
+ When gtd lands
203
+ And the process is driven
204
+ Then the process rests at "build.review.await-review"
205
+ And ".gtd/REQUIREMENTS.md" does not exist
206
+ And the commit "gtd(judge): implement.coverage → implement.closing" has a body containing "Gtd-Mark: covers=AC-1"
207
+ And the commit subjects end with:
208
+ """
209
+ gtd(human): build.review.await-review → build.review.closing
210
+ gtd(check): build.review.closing → re-unwind
211
+ gtd(check): re-unwind → design.triage
212
+ gtd(agent): design.triage → design.gate.answer
213
+ gtd(human): design.gate.answer → implement.building
214
+ gtd(agent): implement.building → implement.health.check
215
+ gtd(check): implement.health.check → implement.coverage
216
+ gtd(judge): implement.coverage → implement.closing
217
+ gtd(check): implement.closing → build.review.reviewing
218
+ gtd(agent): build.review.reviewing → build.review.await-review
219
+ """
220
+
221
+ Scenario: a design numbering new behaviour at or below an earlier ID is refused
222
+ Given step "design.triage" from now on changes nothing
223
+ And step "design.triage" writes ".gtd/REQUIREMENTS.md" with:
224
+ """
225
+ # Requirements
226
+
227
+ ## Concerns
228
+
229
+ ### Multiply
230
+
231
+ - `multiply(a, b)` returns `a * b`.
232
+
233
+ ## Acceptance Criteria
234
+
235
+ - AC-0: multiply(a, b) returns a * b.
236
+ - AC-1: subtract(a, b) returns a - b.
237
+ """
238
+ When gtd lands
239
+ Then driving the process is refused with "criteria-ids"
240
+
241
+ Scenario: the lap holds a new criterion to coverage
242
+ Given step "design.triage" from now on changes nothing
243
+ And step "design.triage" writes ".gtd/REQUIREMENTS.md" with:
244
+ """
245
+ # Requirements
246
+
247
+ ## Concerns
248
+
249
+ ### Multiply
250
+
251
+ - `multiply(a, b)` returns `a * b`.
252
+
253
+ ## Acceptance Criteria
254
+
255
+ - AC-1: subtract(a, b) returns a - b.
256
+ - AC-2: multiply(a, b) returns a * b.
257
+ """
258
+ And step "implement.building" writes "src/calc.ts" with:
259
+ """
260
+ export const add = (a: number, b: number) => a + b
261
+ export const subtract = (a: number, b: number) => a - b
262
+ export const multiply = (a: number, b: number) => a * b
263
+ """
264
+ And judge step "implement.coverage" answers:
265
+ """
266
+ [{"id":"AC-1","answer":"yes","p":0.9},{"id":"AC-2","answer":"no","p":0.9}]
267
+ """
268
+ When gtd lands
269
+ And the process is driven
270
+ Then the process rests at "implement.covering"
271
+ And the resting step's content contains "AC-2"
@@ -1,6 +1,6 @@
1
1
  # One SDLC path per file, so a changed path reds exactly one fixture — do not merge.
2
2
  @inmem
3
- Feature: rejected naive passes walk one fixed path through the steer gate to idle
3
+ Feature: rejected naive passes walk one fixed path through the steer gate to the review gate
4
4
 
5
5
  With GTD_FLAG_RISK_TIER off, three rejected naive
6
6
  passes rest at the steer gate, where a human edit restarts the seam and the
@@ -40,7 +40,7 @@ Feature: rejected naive passes walk one fixed path through the steer gate to idl
40
40
  """
41
41
  And step "design.gate.answer" changes nothing
42
42
 
43
- Scenario: three rejects rest at the steer gate, a steered rerun lands and runs to idle
43
+ Scenario: three rejects rest at the steer gate, a steered rerun lands and runs to the review gate
44
44
  Given step "seam.check" changes nothing
45
45
  And step "seam.naive" writes "src/acceptance.test.ts" with:
46
46
  """
@@ -100,7 +100,7 @@ Feature: rejected naive passes walk one fixed path through the steer gate to idl
100
100
  And step "implement.applying" runs its script
101
101
  When gtd lands
102
102
  And the process is driven
103
- Then the process rests at "idle"
103
+ Then the process rests at "build.review.reviewing"
104
104
  And the commit subjects end with:
105
105
  """
106
106
  gtd(human): idle → unwind
@@ -124,7 +124,7 @@ Feature: rejected naive passes walk one fixed path through the steer gate to idl
124
124
  gtd(check): implement.applying → implement.health.check
125
125
  gtd(check): implement.health.check → implement.coverage
126
126
  gtd(judge): implement.coverage → implement.closing
127
- gtd(check): implement.closing → idle
127
+ gtd(check): implement.closing → build.review.reviewing
128
128
  """
129
129
  And the commit "gtd(human): idle → unwind" has a body containing "Gtd-Var: flag_risk_tier=0"
130
130
  And the commit "gtd(agent): seam.naive → seam.check" has a body containing "Gtd-Kept:"
@@ -152,7 +152,7 @@ Feature: the naive pass and seam judge decide how a feature is built
152
152
  [{"id":"AC-1","answer":"yes","p":0.9}]
153
153
  """
154
154
  When the process is driven
155
- Then the process rests at "idle"
155
+ Then the process rests at "build.review.reviewing"
156
156
  And "src/calc.ts" contains "subtract"
157
157
  And the commit subjects end with:
158
158
  """
@@ -160,7 +160,7 @@ Feature: the naive pass and seam judge decide how a feature is built
160
160
  gtd(check): implement.applying → implement.health.check
161
161
  gtd(check): implement.health.check → implement.coverage
162
162
  gtd(judge): implement.coverage → implement.closing
163
- gtd(check): implement.closing → idle
163
+ gtd(check): implement.closing → build.review.reviewing
164
164
  """
165
165
  And ".gtd/REQUIREMENTS.md" does not exist
166
166
 
@@ -283,7 +283,7 @@ export interface ImplementOptions {
283
283
  /** Where the slice began: the coverage judge reads only the tests changed since. */
284
284
  readonly base?: string | undefined
285
285
  /** Not the last slice: `.gtd/REQUIREMENTS.md` and `.gtd/SLICES.md` stay for the next one. */
286
- readonly more?: boolean | undefined
286
+ readonly more?: boolean | (() => boolean) | undefined
287
287
  }
288
288
 
289
289
  /** Resolves `false` when the tip no longer applies (the failure is in `.gtd/FEEDBACK.md`) or has nothing outside `.gtd/` to apply. */
@@ -351,14 +351,19 @@ export function implement(
351
351
  tier?: RiskTier,
352
352
  amend?: boolean,
353
353
  ): Promise<void>
354
- export function implement(
354
+ export async function implement(
355
355
  first?: string | ImplementOptions,
356
356
  tier?: RiskTier,
357
357
  amend?: boolean,
358
358
  ): Promise<void> {
359
- const options: ImplementOptions =
360
- typeof first === "object" && first !== null ? first : { reference: first, tier, amend }
361
- return scope("implement", async () => {
359
+ await implementing(
360
+ typeof first === "object" && first !== null ? first : { reference: first, tier, amend },
361
+ )
362
+ }
363
+
364
+ /** `implement`, resolving with the criterion IDs the requirements listed when it closed. */
365
+ export const implementing = (options: ImplementOptions): Promise<readonly string[]> =>
366
+ scope("implement", async () => {
362
367
  const since = head()
363
368
  const acceptance = options.rebuild?.acceptance
364
369
  const built = options.naive === undefined || !(await applied(options.naive))
@@ -372,9 +377,14 @@ export function implement(
372
377
  built && acceptance !== undefined ? () => requireAcceptanceKept(acceptance) : undefined,
373
378
  )
374
379
  await requireCoverage(options.base, options.slice)
375
- await closing(options.more === true)
380
+ const ids = criterionIds(read(REQUIREMENTS) ?? "")
381
+ await closing(moreToBuild(options.more))
382
+ return ids
376
383
  })
377
- }
384
+
385
+ // Read at closing: a narrowing at `implement.oversized` may have emptied every later slice.
386
+ const moreToBuild = (more: ImplementOptions["more"]): boolean =>
387
+ typeof more === "function" ? more() : more === true
378
388
 
379
389
  /** The last slice's closing, for a last slice a steer left with no criterion to build. */
380
390
  export const closeOut = (): Promise<void> => scope("implement", () => closing(false))
@@ -51,7 +51,7 @@ Feature: a change with more criteria than the cap is built in coarse slices
51
51
  [{"id":"AC-1","answer":"yes","p":0.9},{"id":"AC-2","answer":"yes","p":0.9}]
52
52
  """
53
53
  When the process is driven
54
- Then the process rests at "idle"
54
+ Then the process rests at "build.review.reviewing"
55
55
  And the commit subjects end with:
56
56
  """
57
57
  gtd(human): design.gate.answer → seam.naive
@@ -60,7 +60,7 @@ Feature: a change with more criteria than the cap is built in coarse slices
60
60
  gtd(check): implement.applying → implement.health.check
61
61
  gtd(check): implement.health.check → implement.coverage
62
62
  gtd(judge): implement.coverage → implement.closing
63
- gtd(check): implement.closing → idle
63
+ gtd(check): implement.closing → build.review.reviewing
64
64
  """
65
65
  And the output lines of gtd "slices" start with:
66
66
  """
@@ -112,7 +112,7 @@ Feature: a change with more criteria than the cap is built in coarse slices
112
112
  [{"id":"AC-2","answer":"yes","p":0.9}]
113
113
  """
114
114
  When the process is driven
115
- Then the process rests at "idle"
115
+ Then the process rests at "build.review.reviewing"
116
116
  And the commit subjects end with:
117
117
  """
118
118
  gtd(agent): design.triage → design.gate.answer
@@ -129,7 +129,7 @@ Feature: a change with more criteria than the cap is built in coarse slices
129
129
  gtd(check): implement.applying → implement.health.check
130
130
  gtd(check): implement.health.check → implement.coverage
131
131
  gtd(judge): implement.coverage → implement.closing
132
- gtd(check): implement.closing → idle
132
+ gtd(check): implement.closing → build.review.reviewing
133
133
  """
134
134
  And the output lines of gtd "slices" start with:
135
135
  """
@@ -223,3 +223,62 @@ Feature: a change with more criteria than the cap is built in coarse slices
223
223
  - AC-2
224
224
  """
225
225
  Then driving the process is refused with "wrote outside its write access"
226
+
227
+ Scenario: a steer that empties the last slice still ends at the review of the whole change
228
+ Given step "design.triage" writes ".gtd/REQUIREMENTS.md" with:
229
+ """
230
+ # Requirements
231
+
232
+ ## Acceptance Criteria
233
+
234
+ - AC-1: subtract(a, b) returns a - b.
235
+ - AC-2: multiply(a, b) returns a * b.
236
+ - AC-3: divide(a, b) returns a / b.
237
+ """
238
+ And step "design.slicing" writes ".gtd/SLICES.md" with:
239
+ """
240
+ # Slices
241
+
242
+ ## Subtract and divide
243
+
244
+ - AC-1
245
+ - AC-3
246
+
247
+ ## Multiply
248
+
249
+ - AC-2
250
+ """
251
+ And step "seam.naive" writes "src/sub.test.ts" with:
252
+ """
253
+ it("[AC-1] subtracts", () => {})
254
+ """
255
+ And judge step "implement.coverage" answers:
256
+ """
257
+ [{"id":"AC-1","answer":"yes","p":0.9}]
258
+ """
259
+ When the process is driven
260
+ Then the process rests at "seam.steer"
261
+ Given a file ".gtd/REQUIREMENTS.md" with:
262
+ """
263
+ # Requirements
264
+
265
+ ## Acceptance Criteria
266
+
267
+ - AC-1: subtract(a, b) returns a - b.
268
+ """
269
+ When gtd lands
270
+ And the process is driven
271
+ Then the process rests at "build.review.reviewing"
272
+ And the commit subjects end with:
273
+ """
274
+ gtd(human): seam.steer → seam.naive
275
+ gtd(agent): seam.naive → seam.check
276
+ gtd(check): seam.check → implement.applying
277
+ gtd(check): implement.applying → implement.health.check
278
+ gtd(check): implement.health.check → implement.coverage
279
+ gtd(judge): implement.coverage → implement.closing
280
+ gtd(check): implement.closing → build.review.reviewing
281
+ """
282
+ And ".gtd/REQUIREMENTS.md" does not exist
283
+ And ".gtd/SLICES.md" does not exist
284
+ And the output of gtd "slices" does not contain "feature-2"
@@ -116,3 +116,33 @@ describe("healthy describeGuard", () => {
116
116
  ).rejects.toThrow("guarded: nope")
117
117
  })
118
118
  })
119
+
120
+ describe("healthy at the fix cap", () => {
121
+ // Blank reports are not comparable; at an 8-byte budget the cut trims both.
122
+ it.each([
123
+ ["could not compare", "", (n: number) => "\n".repeat(n)],
124
+ ["saw only cut", `, judgeBudgetBytes: "8"`, (n: number) => `a long failing report ${n}\n`],
125
+ ])("grants no extra turn on progress the judge %s", async (_, extra, report) => {
126
+ let runs = 0
127
+ const repo = testRepo({
128
+ workflow: [
129
+ `import { healthy } from "@pmelab/gtd/workflows"`,
130
+ `import { agent } from "@pmelab/gtd/flows"`,
131
+ `export const defaults = { judgeIdenticalMinP: "0.7"${extra} }`,
132
+ `export const held = async () => { await agent("first", "go"); await healthy(() => agent("fix", "go")) }`,
133
+ `export default held`,
134
+ ].join("\n"),
135
+ env: { GTD_TEST_COMMAND: "true" },
136
+ })
137
+ await repo.start("held")
138
+ const rest = await repo.drive({
139
+ first: (_, r) => r.write("start", "1\n"),
140
+ "health.check": (_, r) => r.write(".gtd/FEEDBACK.md", report(++runs)),
141
+ "health.judge": () => '[{"id":"verdict","answer":"progress","p":0.9}]',
142
+ fix: (_, r) => r.write("f", `${runs}\n`),
143
+ "health.recheck": (_, r) => r.write(".gtd/FEEDBACK.md", "red again\n"),
144
+ })
145
+ expect(rest.state).toBe("health.describe")
146
+ expect(repo.subjects().filter((s) => s.startsWith("gtd(agent): fix")).length).toBe(3)
147
+ })
148
+ })
@@ -105,7 +105,10 @@ export const retryQuestion = (comparable: boolean): JudgeQuestion =>
105
105
  "new-failure: a materially different failure than before. progress: still red, but closer to green — the default if genuinely unsure.",
106
106
  }
107
107
 
108
- const sameFailure = async (previous: string, current: string): Promise<boolean> => {
108
+ const compare = async (
109
+ previous: string,
110
+ current: string,
111
+ ): Promise<kit.RetryVerdict | undefined> => {
109
112
  const comparable = current.trim() !== "" && previous.trim() !== ""
110
113
  const { answers, truncated } = await judge("health.judge", {
111
114
  questions: [retryQuestion(comparable)],
@@ -113,12 +116,11 @@ const sameFailure = async (previous: string, current: string): Promise<boolean>
113
116
  message: t.healthJudgeMessage(),
114
117
  label: "Judging the retry",
115
118
  })
116
- // Evidence the budget cut can make two different reports look alike: never sameness.
117
- return (
118
- comparable &&
119
- truncated.length === 0 &&
120
- answered(answers.verdict, "identical", numeric(vars.judgeIdenticalMinP, Infinity))
121
- )
119
+ // Evidence the budget cut can make two different reports look alike: neither sameness nor progress.
120
+ if (!comparable || truncated.length > 0) return undefined
121
+ const minP = numeric(vars.judgeIdenticalMinP, Infinity)
122
+ if (answered(answers.verdict, "identical", minP)) return "identical"
123
+ return answered(answers.verdict, "progress", minP) ? "progress" : undefined
122
124
  }
123
125
 
124
126
  export interface HealthOptions {
@@ -153,7 +155,8 @@ const runSuite = (options: HealthOptions): Promise<boolean> =>
153
155
  /**
154
156
  * Loop until the suite is green: check, and on red run `fix` — escalating
155
157
  * first once `FIX_CAP` fixes are spent, or a judge calls the failure
156
- * identical to the previous round's. An escalation re-runs the suite once
158
+ * identical to the previous round's. A judge calling it progress at the cap
159
+ * buys one extra fix per run of fixes. An escalation re-runs the suite once
157
160
  * first; a green re-run ends the loop.
158
161
  */
159
162
  export const healthy = (fix: () => Promise<void>, options: HealthOptions = {}): Promise<void> =>
@@ -162,7 +165,7 @@ export const healthy = (fix: () => Promise<void>, options: HealthOptions = {}):
162
165
  report: FEEDBACK,
163
166
  fixCap: FIX_CAP,
164
167
  fixesSoFar: options.fixesSoFar ?? 0,
165
- sameFailure,
168
+ compare,
166
169
  escalations: options.escalations ?? { rounds: 0 },
167
170
  recheck: () =>
168
171
  recheck("health.recheck", "health.check", { sweepOnGreen: healthSweep(options) }),
@@ -0,0 +1,124 @@
1
+ @inmem
2
+ Feature: a fix loop still making progress at the cap gets one extra turn
3
+
4
+ `healthy` escalates once three fix turns are spent. When `health.judge`
5
+ calls that round progress — still red, but closer to green — with at least
6
+ `judgeIdenticalMinP` confidence, one more fix runs first, once per run of
7
+ fixes. The round after it escalates without asking the judge. Any other
8
+ verdict escalates at the cap as before.
9
+
10
+ Background:
11
+ Given a gtd config with:
12
+ """
13
+ import { healthy } from "@pmelab/gtd/workflows"
14
+ import { agent } from "@pmelab/gtd/flows"
15
+
16
+ export const defaults = { judgeIdenticalMinP: "0.7" }
17
+ export const held = async () => {
18
+ await agent("first", "go")
19
+ await healthy(() => agent("fix", "go"))
20
+ }
21
+ export default held
22
+ """
23
+ And step "first" writes "src/a.ts" with:
24
+ """
25
+ export const a = 1
26
+ """
27
+ And step "health.check" writes ".gtd/FEEDBACK.md" with:
28
+ """
29
+ tests failing
30
+ """
31
+ And step "fix" deletes ".gtd/FEEDBACK.md"
32
+ And step "health.recheck" writes ".gtd/FEEDBACK.md" with:
33
+ """
34
+ tests still failing
35
+ """
36
+
37
+ Scenario: progress at the cap buys exactly one extra fix turn
38
+ Given judge step "health.judge" answers:
39
+ """
40
+ [{"id":"verdict","answer":"progress","p":0.9}]
41
+ """
42
+ When gtd starts workflow "held"
43
+ And the process is driven
44
+ Then the process rests at "health.describe"
45
+ And the commit subjects end with:
46
+ """
47
+ gtd(agent): first → health.check
48
+ gtd(check): health.check → fix
49
+ gtd(agent): fix → health.check
50
+ gtd(check): health.check → health.judge
51
+ gtd(judge): health.judge → fix
52
+ gtd(agent): fix → health.check
53
+ gtd(check): health.check → health.judge
54
+ gtd(judge): health.judge → fix
55
+ gtd(agent): fix → health.check
56
+ gtd(check): health.check → health.judge
57
+ gtd(judge): health.judge → fix
58
+ gtd(agent): fix → health.check
59
+ gtd(check): health.check → health.recheck
60
+ gtd(check): health.recheck → health.describe
61
+ """
62
+
63
+ Scenario Outline: <verdict> at <p> escalates at the cap without an extra turn
64
+ Given judge step "health.judge" answers:
65
+ """
66
+ [{"id":"verdict","answer":"<verdict>","p":<p>}]
67
+ """
68
+ When gtd starts workflow "held"
69
+ And the process is driven
70
+ Then the process rests at "health.describe"
71
+ And the commit subjects end with:
72
+ """
73
+ gtd(agent): first → health.check
74
+ gtd(check): health.check → fix
75
+ gtd(agent): fix → health.check
76
+ gtd(check): health.check → health.judge
77
+ gtd(judge): health.judge → fix
78
+ gtd(agent): fix → health.check
79
+ gtd(check): health.check → health.judge
80
+ gtd(judge): health.judge → fix
81
+ gtd(agent): fix → health.check
82
+ gtd(check): health.check → health.judge
83
+ gtd(judge): health.judge → health.recheck
84
+ gtd(check): health.recheck → health.describe
85
+ """
86
+
87
+ Examples:
88
+ | verdict | p |
89
+ | new-failure | 0.9 |
90
+ | progress | 0.5 |
91
+
92
+ Scenario: a person's escalation stop starts a new run of fixes with its own extra turn
93
+ Given judge step "health.judge" answers:
94
+ """
95
+ [{"id":"verdict","answer":"progress","p":0.9}]
96
+ """
97
+ And step "health.describe" writes ".gtd/ESCALATION.md" with:
98
+ """
99
+ try something else
100
+ """
101
+ And step "health.stop" writes "NOTES.md" with:
102
+ """
103
+ narrowed
104
+ """
105
+ When gtd starts workflow "held"
106
+ And the process is driven
107
+ Then the process rests at "health.exhausted"
108
+ And the commit subjects end with:
109
+ """
110
+ gtd(agent): health.describe → health.stop
111
+ gtd(human): health.stop → fix
112
+ gtd(agent): fix → health.check
113
+ gtd(check): health.check → health.judge
114
+ gtd(judge): health.judge → fix
115
+ gtd(agent): fix → health.check
116
+ gtd(check): health.check → health.judge
117
+ gtd(judge): health.judge → fix
118
+ gtd(agent): fix → health.check
119
+ gtd(check): health.check → health.judge
120
+ gtd(judge): health.judge → fix
121
+ gtd(agent): fix → health.check
122
+ gtd(check): health.check → health.recheck
123
+ gtd(check): health.recheck → health.exhausted
124
+ """