@pmelab/gtd 38.2.0 → 40.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 (46) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +66 -58
  3. package/dist/chunks/{reserved-C3PgP7XB.mjs → reserved-Qt5FM6Ia.mjs} +29 -10
  4. package/dist/chunks/{tail-Bkxki2nK.mjs → tail-tAHx_AHe.mjs} +1 -1
  5. package/dist/gtd.bundle.mjs +14 -5
  6. package/dist/testkit.mjs +14 -5
  7. package/dist/workflow-kit.mjs +12 -1
  8. package/dist/workflows/cleanup.mjs +2 -2
  9. package/dist/workflows/feature.mjs +98 -37
  10. package/dist/workflows/fix.mjs +2 -2
  11. package/dist/workflows/review.mjs +2 -2
  12. package/dist/workflows/rollout.mjs +1 -1
  13. package/dist/workflows.mjs +136 -45
  14. package/package.json +1 -1
  15. package/skills/authoring/SKILL.md +3 -2
  16. package/src/cli/Cli.ts +4 -3
  17. package/src/cli/Metrics.test.ts +31 -0
  18. package/src/cli/Metrics.ts +8 -0
  19. package/src/workflow-kit/health.ts +15 -1
  20. package/src/workflows/cleanup/cleanup.test.ts +2 -0
  21. package/src/workflows/feature/coverage.feature +5 -5
  22. package/src/workflows/feature/criteria.test.ts +20 -1
  23. package/src/workflows/feature/criteria.ts +24 -0
  24. package/src/workflows/feature/fast-path.feature +4 -4
  25. package/src/workflows/feature/feature.ts +82 -22
  26. package/src/workflows/feature/flag-mode.feature +2 -2
  27. package/src/workflows/feature/one-step-land.feature +4 -4
  28. package/src/workflows/feature/planning.ts +3 -2
  29. package/src/workflows/feature/quality-unresolved.feature +6 -6
  30. package/src/workflows/feature/refactor-absorbs.feature +3 -3
  31. package/src/workflows/feature/refactor-first.feature +5 -5
  32. package/src/workflows/feature/refactor-rebuild.feature +2 -2
  33. package/src/workflows/feature/refactor-review.feature +8 -8
  34. package/src/workflows/feature/refactor.test.ts +4 -0
  35. package/src/workflows/feature/review-gate.feature +271 -0
  36. package/src/workflows/feature/seam-rejection.feature +4 -4
  37. package/src/workflows/feature/seam.feature +2 -2
  38. package/src/workflows/feature/seam.ts +17 -7
  39. package/src/workflows/feature/slicing.feature +63 -4
  40. package/src/workflows/rollout/rollout.feature +16 -0
  41. package/src/workflows/rollout/rollout.test.ts +2 -0
  42. package/src/workflows/shared/health.test.ts +2 -0
  43. package/src/workflows/shared/health.ts +34 -5
  44. package/src/workflows/shared/recheck.feature +60 -0
  45. package/src/workflows/shared/text.ts +7 -5
  46. package/src/workflows/review/review-door.feature +0 -121
@@ -45,6 +45,8 @@ export interface Metrics {
45
45
  }
46
46
  }
47
47
  readonly findings: { readonly total: number; readonly byLens: Readonly<Record<string, number>> }
48
+ /** Keyed by the re-check's full step name, from its subject: the mark's value is scope-relative. */
49
+ readonly flaky: { readonly total: number; readonly byStep: Readonly<Record<string, number>> }
48
50
  }
49
51
 
50
52
  type Phase = keyof Metrics["cost"]["byPhase"]
@@ -87,6 +89,7 @@ interface Tally {
87
89
  readonly byPhase: Record<Phase, number>
88
90
  readonly doubleWork: { naive: number; rebuild: number }
89
91
  readonly byLens: Record<string, number>
92
+ readonly flaky: Record<string, number>
90
93
  }
91
94
 
92
95
  const emptyTally = (): Tally => ({
@@ -96,6 +99,7 @@ const emptyTally = (): Tally => ({
96
99
  byPhase: { naive: 0, refactor: 0, implement: 0, other: 0 },
97
100
  doubleWork: { naive: 0, rebuild: 0 },
98
101
  byLens: record(),
102
+ flaky: record(),
99
103
  })
100
104
 
101
105
  const sum = (record: Readonly<Record<string, number>>): number =>
@@ -116,6 +120,7 @@ const finish = (t: Tally): Metrics => ({
116
120
  },
117
121
  },
118
122
  findings: { total: sum(t.byLens), byLens: t.byLens },
123
+ flaky: { total: sum(t.flaky), byStep: t.flaky },
119
124
  })
120
125
 
121
126
  /** The open slice: its naive cost so far, and whether its implement turns rebuild it. */
@@ -186,6 +191,7 @@ export const processMetrics = (commits: readonly MetricCommit[]): Metrics => {
186
191
  add(t.continuations, at.replace(CONTINUE, "$1"), 1)
187
192
  const lens = LENS.exec(at)?.[1]
188
193
  if (lens !== undefined && commit.findings > 0) add(t.byLens, lens, commit.findings)
194
+ if (message.marks.some((m) => m.name === "flaky")) add(t.flaky, at, 1)
189
195
  tallyDecision(t, open, at, subject.to)
190
196
  }
191
197
  return finish(t)
@@ -202,6 +208,7 @@ export const totalMetrics = (all: readonly Metrics[]): Metrics & { readonly proc
202
208
  t.doubleWork.naive += m.cost.doubleWork.naive
203
209
  t.doubleWork.rebuild += m.cost.doubleWork.rebuild
204
210
  for (const [k, v] of Object.entries(m.findings.byLens)) add(t.byLens, k, v)
211
+ for (const [k, v] of Object.entries(m.flaky.byStep)) add(t.flaky, k, v)
205
212
  }
206
213
  return { processes: all.length, ...finish(t) }
207
214
  }
@@ -257,6 +264,7 @@ const line = (label: string, m: Metrics): string => {
257
264
  `${continued} continuations${continued > 0 ? ` (${listed(m.continuations)})` : ""}`,
258
265
  `cost ${cost.total} (${listed(cost.byPhase)}; by model ${listed(cost.byModel) || "-"}), double work ${cost.doubleWork.total}`,
259
266
  `${m.findings.total} findings${m.findings.total > 0 ? ` (${listed(m.findings.byLens)})` : ""}`,
267
+ `${m.flaky.total} flaky${m.flaky.total > 0 ? ` (${listed(m.flaky.byStep)})` : ""}`,
260
268
  ].join("; ")
261
269
  }
262
270
 
@@ -44,16 +44,24 @@ export interface Health {
44
44
  /** Shared with other callers in the same run of red checks. */
45
45
  readonly escalations: EscalationCount
46
46
  readonly escalation: EscalationSteps
47
+ /** Runs the suite once more before a person is asked; `true` ends the loop as if `check` had passed. */
48
+ readonly recheck?: () => Promise<boolean>
47
49
  }
48
50
 
49
51
  // check() stamps the report with the commit it ran at, so equal failures differ there.
50
52
  const STAMP = /\n<!-- gtd check [0-9a-f]+ -->\n?$/
51
53
  const stripStamp = (text: string): string => text.replace(STAMP, "\n")
52
54
 
55
+ /** `true` when the re-check passed, else the red re-check's report — the one the escalation describes. */
56
+ const recheckReport = async (health: Health): Promise<true | string | undefined> => {
57
+ if (health.recheck === undefined) return undefined
58
+ return (await health.recheck()) || stripStamp(read(health.report) ?? "")
59
+ }
60
+
53
61
  /**
54
62
  * Loop until the suite is green: check, and on red run `fix` — escalating
55
63
  * first once `fixCap` fixes are spent, or `sameFailure` calls the failure
56
- * identical to the previous round's.
64
+ * identical to the previous round's, unless `recheck` passes.
57
65
  */
58
66
  export const healthy = async (fix: () => Promise<void>, health: Health): Promise<void> => {
59
67
  let fixes = health.fixesSoFar ?? 0
@@ -67,6 +75,12 @@ export const healthy = async (fix: () => Promise<void>, health: Health): Promise
67
75
  const stuck = previous !== undefined && (await health.sameFailure(previous, current))
68
76
  previous = current
69
77
  if (stuck || fixes >= health.fixCap) {
78
+ const rechecked = await recheckReport(health)
79
+ if (rechecked === true) {
80
+ health.escalations.rounds = 0
81
+ return
82
+ }
83
+ previous = rechecked ?? previous
70
84
  await escalation(health.escalations, health.escalation)
71
85
  fixes = 0
72
86
  }
@@ -160,6 +160,7 @@ describe("the cleanup guard over every later agent turn", () => {
160
160
  "build.health.check": (_, r) => {
161
161
  if (!stopped) r.write(FEEDBACK, `failing ${runs++}\n`)
162
162
  },
163
+ "build.health.recheck": (_, r) => r.write(FEEDBACK, `failing ${runs++}\n`),
163
164
  "build.health.describe": () => undefined,
164
165
  "build.health.judge": () => '[{"id":"verdict","answer":"progress","p":0.9}]',
165
166
  "build.fix": (_, r) => r.write("src/other.ts", `export const n = ${runs}\n`),
@@ -176,6 +177,7 @@ describe("the cleanup guard over every later agent turn", () => {
176
177
  await expect(
177
178
  driven(guarded("", NO_ACCESS), {
178
179
  "build.health.check": (_, r) => r.write(FEEDBACK, `failing ${runs++}\n`),
180
+ "build.health.recheck": (_, r) => r.write(FEEDBACK, `failing ${runs++}\n`),
179
181
  "build.health.judge": () => '[{"id":"verdict","answer":"progress","p":0.9}]',
180
182
  "build.fix": (_, r) => r.write("src/other.ts", `export const n = ${runs}\n`),
181
183
  "build.health.describe": (_, r) => r.write("src/b.ts", NAMED),
@@ -55,19 +55,19 @@ Feature: a feature is done only when every acceptance criterion has a passing te
55
55
  And the output of gtd "judge" contains "add is unchanged."
56
56
  And the output of gtd "judge" contains "[AC-1] subtracts"
57
57
 
58
- Scenario: every ID covered ends the feature and marks the IDs it covers
58
+ Scenario: every ID covered moves on to the review and marks the IDs it covers
59
59
  Given judge step "implement.coverage" answers:
60
60
  """
61
61
  [{"id":"AC-1","answer":"yes","p":0.9},{"id":"AC-2","answer":"yes","p":0.9}]
62
62
  """
63
63
  When the process is driven
64
- Then the process rests at "idle"
64
+ Then the process rests at "build.review.reviewing"
65
65
  And the commit subjects end with:
66
66
  """
67
67
  gtd(check): implement.applying → implement.health.check
68
68
  gtd(check): implement.health.check → implement.coverage
69
69
  gtd(judge): implement.coverage → implement.closing
70
- gtd(check): implement.closing → idle
70
+ gtd(check): implement.closing → build.review.reviewing
71
71
  """
72
72
  And the commit "gtd(judge): implement.coverage → implement.closing" has a body containing "Gtd-Mark: covers=AC-1"
73
73
  And the commit "gtd(judge): implement.coverage → implement.closing" has a body containing "Gtd-Mark: covers=AC-2"
@@ -132,7 +132,7 @@ Feature: a feature is done only when every acceptance criterion has a passing te
132
132
  """
133
133
  Then driving the process is refused with "criteria-frozen"
134
134
 
135
- Scenario: overruling at the gate ends the feature, recording the uncovered IDs
135
+ Scenario: overruling at the gate moves on to the review, recording the uncovered IDs
136
136
  Given judge step "implement.coverage" answers:
137
137
  """
138
138
  [{"id":"AC-1","answer":"yes","p":0.9},{"id":"AC-2","answer":"no","p":0.9}]
@@ -144,7 +144,7 @@ Feature: a feature is done only when every acceptance criterion has a passing te
144
144
  """
145
145
  And step "implement.uncovered" changes nothing
146
146
  When the process is driven
147
- Then the process rests at "idle"
147
+ Then the process rests at "build.review.reviewing"
148
148
  And the commit "gtd(human): implement.uncovered → implement.closing" has a body containing "Gtd-Mark: covers=AC-1"
149
149
  And the commit "gtd(human): implement.uncovered → implement.closing" has a body containing "Gtd-Mark: uncovered=AC-2"
150
150
 
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from "vitest"
2
2
  import type { Change } from "../../flows/index.js"
3
- import { criteriaOf, criterionIds, statedCriteria } from "./criteria.js"
3
+ import { criteriaOf, criterionIds, statedCriteria, unstableIds } from "./criteria.js"
4
4
 
5
5
  const added = (path: string, after: string): Change => ({
6
6
  path,
@@ -120,3 +120,22 @@ describe("criterionIds", () => {
120
120
  expect(criterionIds(requirements, { except: ["AC-1", "AC-3"] })).toEqual(["AC-2", "AC-4"])
121
121
  })
122
122
  })
123
+
124
+ describe("unstableIds", () => {
125
+ const ids = { closed: ["AC-1", "AC-3"], used: ["AC-1", "AC-2", "AC-3"] }
126
+
127
+ it("accepts built IDs kept or dropped and new ones numbered above all handed out", () => {
128
+ expect(unstableIds(["AC-3", "AC-4", "AC-7"], ids)).toBeUndefined()
129
+ expect(unstableIds([], ids)).toBeUndefined()
130
+ })
131
+
132
+ it("names a new ID that reuses a number handed out before, even a narrowed-away one", () => {
133
+ expect(unstableIds(["AC-1", "AC-2", "AC-3"], ids)).toMatch(
134
+ /^numbers AC-2 at or below AC-3, .* from AC-4$/,
135
+ )
136
+ })
137
+
138
+ it("names every reused ID", () => {
139
+ expect(unstableIds(["AC-0", "AC-2"], ids)).toMatch(/^numbers AC-0, AC-2 at or below AC-3/)
140
+ })
141
+ })
@@ -151,6 +151,30 @@ export const requireCriteria = (file: string): void => {
151
151
  )
152
152
  }
153
153
 
154
+ /** The IDs a review's feedback lap must respect. */
155
+ export interface StableIds {
156
+ /** Listed when the build closed: each may stay, reworded or not, or go. */
157
+ readonly closed: readonly string[]
158
+ /** Every ID handed out so far: any other is numbered above all of them. */
159
+ readonly used: readonly string[]
160
+ }
161
+
162
+ const idNumber = (id: string): number => Number(id.slice("AC-".length))
163
+
164
+ /** Why `listed` renumbers or reuses an earlier ID; `undefined` when it does neither. */
165
+ export const unstableIds = (listed: readonly string[], ids: StableIds): string | undefined => {
166
+ const top = Math.max(0, ...ids.used.map(idNumber))
167
+ const reused = listed.filter((id) => !ids.closed.includes(id) && idNumber(id) <= top)
168
+ if (reused.length === 0) return undefined
169
+ return `numbers ${reused.join(", ")} at or below AC-${top}, an ID already handed out — keep a built criterion's ID (reword it in place when the review changes it), and number new ones from AC-${top + 1}`
170
+ }
171
+
172
+ /** Refuse criteria in `file` that renumber or reuse an earlier ID. */
173
+ export const requireStableIds = (file: string, ids: StableIds): void => {
174
+ const why = unstableIds(criterionIds(read(file) ?? ""), ids)
175
+ if (why !== undefined) refuse(`gtd land: criteria-ids: ${file} ${why}`)
176
+ }
177
+
154
178
  /** Refuse a turn that changed the acceptance criteria in `file`: after the design gate only another design lap may. */
155
179
  export const requireCriteriaFrozen = (file: string): void => {
156
180
  const change = changes().get(file)
@@ -3,7 +3,7 @@ Feature: a trivial change takes the same few beats at either risk tier
3
3
 
4
4
  With GTD_FLAG_RISK_TIER on, a one-line copy fix goes
5
5
  from idle through the naive pass, the seam judge's land, the applied pass and
6
- the quality lap back to idle. Low tier walks exactly the steps high does; only
6
+ the quality lap to the review gate. Low tier walks exactly the steps high does; only
7
7
  the summary differs.
8
8
 
9
9
  Background:
@@ -32,7 +32,7 @@ Feature: a trivial change takes the same few beats at either risk tier
32
32
  """
33
33
  And step "build.quality.correctness.reviewing" changes nothing
34
34
 
35
- Scenario Outline: a one-line fix runs idle to idle with the same beats at either tier
35
+ Scenario Outline: a one-line fix runs from idle to the review gate with the same beats at either tier
36
36
  Given step "design.triage" writes ".gtd/REQUIREMENTS.md" with:
37
37
  """
38
38
  # Requirements
@@ -64,7 +64,7 @@ Feature: a trivial change takes the same few beats at either risk tier
64
64
  """
65
65
  And step "implement.applying" runs its script
66
66
  When the process is driven
67
- Then the process rests at "idle"
67
+ Then the process rests at "build.review.reviewing"
68
68
  And "src/greeting.ts" contains "Hello, world"
69
69
  And the commit subjects end with:
70
70
  """
@@ -80,7 +80,7 @@ Feature: a trivial change takes the same few beats at either risk tier
80
80
  gtd(check): implement.health.check → implement.coverage
81
81
  gtd(judge): implement.coverage → implement.closing
82
82
  gtd(check): implement.closing → build.quality.correctness.reviewing
83
- gtd(agent): build.quality.correctness.reviewing → idle
83
+ gtd(agent): build.quality.correctness.reviewing → build.review.reviewing
84
84
  """
85
85
  And the output of gtd "summary" <summary> "needs a human reviewer"
86
86
 
@@ -9,13 +9,16 @@ import {
9
9
  slice,
10
10
  start,
11
11
  } from "../../flows/index.js"
12
- import { gate, healthy } from "../shared/health.js"
12
+ import { gate, healthy, type EscalationCount } from "../shared/health.js"
13
13
  import { flagModeOn, shipped } from "../shared/options.js"
14
14
  import { FEEDBACK, fixCheck, QUALITY, REQUIREMENTS } from "../shared/steps.js"
15
15
  import { refusePendingHandOff, tail } from "../shared/tail.js"
16
16
  import * as t from "../shared/text.js"
17
17
  import {
18
+ criterionIds,
18
19
  requireCriteriaFrozen,
20
+ requireStableIds,
21
+ type StableIds,
19
22
  skipDesign,
20
23
  sliceIds,
21
24
  statedCriteria,
@@ -23,8 +26,8 @@ import {
23
26
  type StatedCriteria,
24
27
  } from "./criteria.js"
25
28
  import { design } from "./planning.js"
26
- import { buildTail, isCode, reUnwind, type ReviewOutcome } from "./review.js"
27
- import { closeOut, implement, seamBuild } from "./seam.js"
29
+ import { buildTail, isCode, reUnwind, review, type ReviewOutcome } from "./review.js"
30
+ import { implement, implementing, seamBuild } from "./seam.js"
28
31
  import { sliceCap, sliceCriteria, slicing } from "./slicing.js"
29
32
  import { recordRisk, riskTierOn, type RiskTier } from "./tier.js"
30
33
 
@@ -33,7 +36,9 @@ import { recordRisk, riskTierOn, type RiskTier } from "./tier.js"
33
36
  // green-baseline gate → design → per slice of the criteria, a naive pass,
34
37
  // judged with `flagMode` on: it lands as the implementation, or (refactoring
35
38
  // first when the judge asks) is built fresh from → the health loop → the
36
- // quality lap, and the episode ends back at `idle`. Human review is the `review` door's.
39
+ // quality lap → after the last slice, human review of the whole change, which
40
+ // signs off (the episode ends back at `idle`) or sends a design → implement
41
+ // lap.
37
42
 
38
43
  /** Revert the sketch that started the process out of the working tree; its intent survives in history. */
39
44
  export const unwind = (): Promise<void> => {
@@ -52,40 +57,54 @@ export { reUnwind }
52
57
  export const buildFeature = async (firstBase: string): Promise<void> => {
53
58
  const tier = riskTierOn() ? recordRisk() : undefined
54
59
  const slices = await slicing()
60
+ // Every ID handed out, gathered before a later narrowing can drop one: none is ever reused.
61
+ const handedOut = new Set(listedIds())
55
62
  // A single slice keeps the unnumbered names and builds against every criterion.
56
63
  const one = slices.length === 1
64
+ // A steer may delete every criterion of a later slice: there is nothing to build there.
65
+ const built = (i: number): boolean => sliceIds(sliceCriteria(slices, i))?.length !== 0
57
66
  for (const i of slices.keys()) {
58
- const more = i < slices.length - 1
59
- const criteria = sliceCriteria(slices, i)
60
- // A steer may have deleted every criterion of a later slice: nothing to build.
61
- if (sliceIds(criteria)?.length === 0) {
62
- if (!more) await closeOut()
63
- continue
64
- }
67
+ if (!built(i)) continue
65
68
  const n = one ? "" : `-${i + 1}`
66
69
  await buildSlice(i === 0 ? firstBase : head(), {
67
70
  tier,
68
- criteria,
71
+ criteria: sliceCriteria(slices, i),
69
72
  refactor: `refactor${n}`,
70
73
  feature: `feature${n}`,
71
- more,
74
+ // A steer or a narrowing may empty every later slice.
75
+ more: () => slices.some((_, j) => j > i && built(j)),
76
+ // Every slice but the one is built unreviewed, so the review covers them all.
77
+ reviewBase: one ? undefined : firstBase,
78
+ handedOut,
72
79
  })
73
80
  }
74
81
  }
75
82
 
83
+ const listedIds = (): readonly string[] => criterionIds(read(REQUIREMENTS) ?? "")
84
+
85
+ const gather = (handedOut: Set<string>): readonly string[] => {
86
+ for (const id of listedIds()) handedOut.add(id)
87
+ return [...handedOut]
88
+ }
89
+
76
90
  interface SliceBuild {
77
91
  readonly tier: RiskTier | undefined
78
92
  readonly criteria: CriteriaSlice | undefined
79
93
  readonly refactor: string
80
94
  readonly feature: string
81
- readonly more: boolean
95
+ readonly more: () => boolean
96
+ /** Absent: the slice's own base. */
97
+ readonly reviewBase?: string | undefined
98
+ /** Every criterion ID handed out so far; the slice adds those a steer adds. */
99
+ readonly handedOut: Set<string>
82
100
  }
83
101
 
84
- /** A naive pass (judged only with `flagMode` on), then implement and the quality lap. */
102
+ /** A naive pass (judged only with `flagMode` on), then implement and the quality lap; after the last slice, the review. */
85
103
  const buildSlice = async (sliceBase: string, build: SliceBuild): Promise<void> => {
86
104
  const seam = await seamBuild(sliceBase, { criteria: build.criteria, refactor: build.refactor })
105
+ const used = gather(build.handedOut)
87
106
  await slice(build.feature, async () => {
88
- await implement({
107
+ const closed = await implementing({
89
108
  reference: seam.reference,
90
109
  tier: build.tier,
91
110
  afterRefactor: seam.refactored,
@@ -95,6 +114,8 @@ const buildSlice = async (sliceBase: string, build: SliceBuild): Promise<void> =
95
114
  base: build.criteria === undefined ? undefined : sliceBase,
96
115
  more: build.more,
97
116
  })
117
+ // Closing kept the criteria exactly when there is more to build, so this reads as it did.
118
+ const more = build.more()
98
119
  // A feature slice with no code beyond the carried-over tests: the refactor carried the
99
120
  // feature, so the lap covers the whole slice.
100
121
  const carried = seam.rebuild?.acceptance?.tests ?? []
@@ -103,14 +124,19 @@ const buildSlice = async (sliceBase: string, build: SliceBuild): Promise<void> =
103
124
  !changesSince(seam.base).some((c) => isCode(c.path) && !carried.includes(c.path))
104
125
  ? sliceBase
105
126
  : seam.base
106
- // The criteria stay in the tree for the next slice; no quality fix may move them.
107
- const guard = build.more ? () => requireCriteriaFrozen(REQUIREMENTS) : undefined
108
- await tail(settleQuality, { green: true, lensBase: base, guard, guardRests: false })
127
+ if (more) {
128
+ // The criteria stay in the tree for the next slice; no quality fix may move them.
129
+ const guard = () => requireCriteriaFrozen(REQUIREMENTS)
130
+ await tail(settleQuality, { green: true, lensBase: base, guard, guardRests: false })
131
+ return
132
+ }
133
+ const reviewBase = build.reviewBase ?? base
134
+ await featureFeedback(await reviewedTail(reviewBase, base), base, { closed, used })
109
135
  })
110
136
  }
111
137
 
112
- // The lap is feature's last look: findings the fix cap left must not end the process, nor reach
113
- // the next one's lap, which appends to the file. Empty counts as resolved, as the lap reads it.
138
+ // The lap's findings the fix cap left must not reach the review, nor the next process's lap,
139
+ // which appends to the file. Empty counts as resolved, as the lap reads it.
114
140
  const settleQuality = async (): Promise<void> => {
115
141
  let rested = false
116
142
  while ((read(QUALITY) ?? "").length > 0) {
@@ -121,10 +147,44 @@ const settleQuality = async (): Promise<void> => {
121
147
  file: QUALITY,
122
148
  })
123
149
  }
124
- // The person may have fixed code by hand there; nothing else checks it before idle.
150
+ // The person may have fixed code by hand there; nothing else checks it before the review.
125
151
  if (rested) await healthy(() => fixCheck())
126
152
  }
127
153
 
154
+ /** The quality lap, then the human review since `base`. */
155
+ const reviewedTail = (base: string, lensBase: string, lap?: "feedback"): Promise<ReviewOutcome> =>
156
+ tail(
157
+ async (escalations: EscalationCount) => {
158
+ await settleQuality()
159
+ return review(base, escalations, { lap })
160
+ },
161
+ { green: true, lensBase },
162
+ )
163
+
164
+ // seam.naive / seam.judge never run on a feedback lap: its design → implement is built unjudged
165
+ // and unrefactored, held to every criterion's coverage since the process start.
166
+ const featureFeedback = async (
167
+ first: ReviewOutcome,
168
+ lensBase: string,
169
+ ids: StableIds,
170
+ ): Promise<void> => {
171
+ let outcome = first
172
+ let closed = ids.closed
173
+ const handedOut = new Set([...ids.used, ...ids.closed])
174
+ while (outcome.verdict === "feedback") {
175
+ await reUnwind(outcome)
176
+ const lap = { closed, used: [...handedOut] }
177
+ // Gathered every round, so an ID one round adds and the next drops is never reused.
178
+ await design(outcome.base, () => {
179
+ requireStableIds(REQUIREMENTS, lap)
180
+ gather(handedOut)
181
+ })
182
+ const tier = riskTierOn() ? recordRisk() : undefined
183
+ closed = await implementing({ tier, amend: true })
184
+ outcome = await reviewedTail(outcome.base, lensBase, "feedback")
185
+ }
186
+ }
187
+
128
188
  /** Design — skipped when the sketch's own criteria are judged clear — then `buildFeature`. */
129
189
  export const planAndBuild = async (firstBase: string, stated?: StatedCriteria): Promise<void> => {
130
190
  // GTD_FLAG_RISK_TIER: the tier is proposed at triage and set at the design gate, so no skip.
@@ -67,7 +67,7 @@ Feature: flag mode off lands the naive pass with no release flag, seam judge or
67
67
  """
68
68
  And step "build.quality.correctness.reviewing" changes nothing
69
69
  When the process is driven
70
- Then the process rests at "idle"
70
+ Then the process rests at "build.review.reviewing"
71
71
  And "src/calc.ts" contains "subtract"
72
72
  And "src/calc.test.ts" contains "[AC-1] subtracts"
73
73
  And the commit subjects end with:
@@ -85,5 +85,5 @@ Feature: flag mode off lands the naive pass with no release flag, seam judge or
85
85
  gtd(check): implement.health.check → implement.coverage
86
86
  gtd(judge): implement.coverage → implement.closing
87
87
  gtd(check): implement.closing → build.quality.correctness.reviewing
88
- gtd(agent): build.quality.correctness.reviewing → idle
88
+ gtd(agent): build.quality.correctness.reviewing → build.review.reviewing
89
89
  """
@@ -5,7 +5,7 @@ Feature: a naive pass the judge rules land is itself the implementation
5
5
  With GTD_FLAG_RISK_TIER off, a naive pass that
6
6
  edits an existing source file is judged land: the process skips the approval
7
7
  gate and any refactor, applies the kept pass as it is — no build turn — and
8
- runs the health loop and quality back to idle, with no review. The flag is pinned here so the per-flag test runs never
8
+ runs the health loop and quality lap to the feature review. The flag is pinned here so the per-flag test runs never
9
9
  move it off this path.
10
10
 
11
11
  Background:
@@ -62,9 +62,9 @@ Feature: a naive pass the judge rules land is itself the implementation
62
62
  """
63
63
  And step "build.quality.correctness.reviewing" changes nothing
64
64
 
65
- Scenario: a land judgment applies the naive pass and runs idle to idle with no rebuild
65
+ Scenario: a land judgment applies the naive pass and runs from idle to the review gate with no rebuild
66
66
  When the process is driven
67
- Then the process rests at "idle"
67
+ Then the process rests at "build.review.reviewing"
68
68
  And "src/greeting.ts" contains "Hello, world"
69
69
  And "src/acceptance.test.ts" contains "[AC-1] holds"
70
70
  And the commit subjects end with:
@@ -81,7 +81,7 @@ Feature: a naive pass the judge rules land is itself the implementation
81
81
  gtd(check): implement.health.check → implement.coverage
82
82
  gtd(judge): implement.coverage → implement.closing
83
83
  gtd(check): implement.closing → build.quality.correctness.reviewing
84
- gtd(agent): build.quality.correctness.reviewing → idle
84
+ gtd(agent): build.quality.correctness.reviewing → build.review.reviewing
85
85
  """
86
86
  And the commit "gtd(human): idle → unwind" has a body containing "Gtd-Var: flag_risk_tier=0"
87
87
  And the commit "gtd(agent): seam.naive → seam.check" has a body containing "Gtd-Kept:"
@@ -25,13 +25,14 @@ const noteGate = async (file: string, answer: () => Promise<void>): Promise<bool
25
25
  return true
26
26
  }
27
27
 
28
- /** Triage the sketch since `base` into requirements, until no product question is open. */
29
- export const design = (base: string): Promise<void> =>
28
+ /** Triage the sketch since `base` into requirements, until no product question is open; `guard` holds every round's criteria. */
29
+ export const design = (base: string, guard?: () => void): Promise<void> =>
30
30
  scope("design", async () => {
31
31
  do {
32
32
  await triage(base)
33
33
  requireProgress(REQUIREMENTS)
34
34
  requireReplies(REQUIREMENTS)
35
35
  requireCriteria(REQUIREMENTS)
36
+ guard?.()
36
37
  } while (await noteGate(REQUIREMENTS, answerProductQuestions))
37
38
  })
@@ -1,9 +1,9 @@
1
1
  @inmem
2
- Feature: quality findings left unresolved hold a feature process short of idle
2
+ Feature: quality findings left unresolved hold a feature process short of its review
3
3
 
4
- The quality lap is the last look `feature` takes. When the fix cap leaves
5
- `.gtd/QUALITY.md` behind, the process rests for a person instead of ending
6
- with findings nobody acted on.
4
+ When the quality lap's fix cap leaves `.gtd/QUALITY.md` behind, the process
5
+ rests for a person instead of handing findings nobody acted on to the
6
+ human review.
7
7
 
8
8
  Background:
9
9
  Given the shipped workflows, as gtd install scaffolds them
@@ -97,10 +97,10 @@ Feature: quality findings left unresolved hold a feature process short of idle
97
97
  And step "build.health.check" changes nothing
98
98
  When gtd lands
99
99
  And the process is driven
100
- Then the process rests at "idle"
100
+ Then the process rests at "build.review.reviewing"
101
101
  And the commit subjects end with:
102
102
  """
103
103
  gtd(check): build.health.check → build.quality-unresolved
104
104
  gtd(human): build.quality-unresolved → build.health.check
105
- gtd(check): build.health.check → idle
105
+ gtd(check): build.health.check → build.review.reviewing
106
106
  """
@@ -42,7 +42,7 @@ Feature: a refactor that carried the whole feature still gets a quality lap over
42
42
  """
43
43
  And step "design.gate.answer" changes nothing
44
44
 
45
- Scenario: a tests-only implement turn after a refactor that did it all reaches the quality lap and idle
45
+ Scenario: a tests-only implement turn after a refactor that did it all reaches the quality lap and the review
46
46
  Given step "seam.check" changes nothing
47
47
  And step "seam.naive" writes "src/acceptance.test.ts" with:
48
48
  """
@@ -113,7 +113,7 @@ Feature: a refactor that carried the whole feature still gets a quality lap over
113
113
 
114
114
  Given step "build.quality.correctness.reviewing" changes nothing
115
115
  When the process is driven
116
- Then the process rests at "idle"
116
+ Then the process rests at "build.review.reviewing"
117
117
  And "src/calc.ts" contains "subtract"
118
118
  And the commit subjects end with:
119
119
  """
@@ -123,7 +123,7 @@ Feature: a refactor that carried the whole feature still gets a quality lap over
123
123
  gtd(check): implement.health.check → implement.coverage
124
124
  gtd(judge): implement.coverage → implement.closing
125
125
  gtd(check): implement.closing → build.quality.correctness.reviewing
126
- gtd(agent): build.quality.correctness.reviewing → idle
126
+ gtd(agent): build.quality.correctness.reviewing → build.review.reviewing
127
127
  """
128
128
  And the output lines of gtd "slices" start with:
129
129
  """
@@ -1,10 +1,10 @@
1
1
  # One SDLC path per file, so a changed path reds exactly one fixture — do not merge.
2
2
  @inmem
3
- Feature: a refactor-first judgment walks one fixed path to idle
3
+ Feature: a refactor-first judgment walks one fixed path to the review gate
4
4
 
5
5
  With GTD_FLAG_RISK_TIER off, a naive pass touching
6
6
  an existing non-allowlisted file is ruled refactor-first, is refactored and
7
- that refactor reviewed, and only then implemented, with no feature review. The flags are
7
+ that refactor reviewed, and only then implemented and brought to the feature review. The flags are
8
8
  pinned so the per-flag test runs never move it off its path.
9
9
 
10
10
  Background:
@@ -41,7 +41,7 @@ Feature: a refactor-first judgment walks one fixed path to idle
41
41
  """
42
42
  And step "design.gate.answer" changes nothing
43
43
 
44
- Scenario: a refactor-first judgment runs refactor, its review, implement and review to idle
44
+ Scenario: a refactor-first judgment runs refactor, its review, implement and the quality lap to the feature review
45
45
  Given step "seam.check" changes nothing
46
46
  And step "seam.naive" writes "src/acceptance.test.ts" with:
47
47
  """
@@ -121,7 +121,7 @@ Feature: a refactor-first judgment walks one fixed path to idle
121
121
  [{"id":"AC-1","answer":"yes","p":0.9}]
122
122
  """
123
123
  When the process is driven
124
- Then the process rests at "idle"
124
+ Then the process rests at "build.review.reviewing"
125
125
  And "src/calc.ts" contains "op(a, b, 1)"
126
126
  And "src/calc.ts" contains "subtract"
127
127
  And the commit subjects end with:
@@ -145,7 +145,7 @@ Feature: a refactor-first judgment walks one fixed path to idle
145
145
  gtd(agent): implement.building → implement.health.check
146
146
  gtd(check): implement.health.check → implement.coverage
147
147
  gtd(judge): implement.coverage → implement.closing
148
- gtd(check): implement.closing → idle
148
+ gtd(check): implement.closing → build.review.reviewing
149
149
  """
150
150
  And the commit "gtd(human): idle → unwind" has a body containing "Gtd-Var: flag_risk_tier=0"
151
151
  And the commit "gtd(agent): seam.naive → seam.check" has a body containing "Gtd-Kept:"
@@ -242,7 +242,7 @@ Feature: refactor-first writes a refactor note, refactors on its own, then rebui
242
242
  [{"id":"AC-1","answer":"yes","p":0.9}]
243
243
  """
244
244
  When the process is driven
245
- Then the process rests at "idle"
245
+ Then the process rests at "build.review.reviewing"
246
246
  And "src/calc.ts" contains "op(a, b, -1)"
247
247
  And "src/acceptance.test.ts" contains "[AC-1]"
248
248
  And ".gtd/REFACTOR-NOTE.md" does not exist
@@ -260,7 +260,7 @@ Feature: refactor-first writes a refactor note, refactors on its own, then rebui
260
260
  gtd(agent): implement.building → implement.health.check
261
261
  gtd(check): implement.health.check → implement.coverage
262
262
  gtd(judge): implement.coverage → implement.closing
263
- gtd(check): implement.closing → idle
263
+ gtd(check): implement.closing → build.review.reviewing
264
264
  """
265
265
  And the output lines of gtd "slices" start with:
266
266
  """