@pmelab/gtd 46.0.0 → 48.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 (34) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +21 -19
  3. package/dist/chunks/{reserved-BENPy6IJ.mjs → reserved-DF1HD6jL.mjs} +41 -22
  4. package/dist/chunks/{review-DcbgUwr7.mjs → review-Bv40kZu9.mjs} +5 -8
  5. package/dist/chunks/{tail-CDNMZ-2E.mjs → tail-D89fU_wf.mjs} +68 -39
  6. package/dist/gtd.bundle.mjs +203 -867
  7. package/dist/testkit.mjs +89 -53
  8. package/dist/workflows/cleanup.mjs +2 -2
  9. package/dist/workflows/feature.mjs +26 -22
  10. package/dist/workflows/fix.mjs +2 -2
  11. package/dist/workflows/review.mjs +3 -3
  12. package/dist/workflows/rollout.mjs +1 -1
  13. package/dist/workflows.mjs +257 -205
  14. package/package.json +1 -1
  15. package/src/cli/Cli.test.ts +0 -2
  16. package/src/cli/Cli.ts +0 -15
  17. package/src/cli/Program.test.ts +0 -1
  18. package/src/cli/Program.ts +0 -8
  19. package/src/cli/ToolCommands.ts +1 -2
  20. package/src/install/Install.ts +89 -35
  21. package/src/testkit/Layers.ts +0 -1
  22. package/src/wire/ContextLimit.ts +1 -1
  23. package/src/workflows/cleanup/cleanup.test.ts +11 -9
  24. package/src/workflows/feature/feature.ts +38 -28
  25. package/src/workflows/feature/lens-review.feature +3 -1
  26. package/src/workflows/feature/slice-findings.feature +198 -0
  27. package/src/workflows/fix/fix-lens-origin.feature +101 -0
  28. package/src/workflows/fix/fix-quality.feature +131 -0
  29. package/src/workflows/index.ts +0 -1
  30. package/src/workflows/shared/lenses.ts +30 -5
  31. package/src/workflows/shared/review.ts +3 -8
  32. package/src/workflows/shared/tail.ts +74 -57
  33. package/src/workflows/shared/text.ts +85 -29
  34. package/src/workflows/feature/quality-unresolved.feature +0 -121
@@ -0,0 +1,101 @@
1
+ @inmem
2
+ Feature: each finding the reviewer is handed names the lens that wrote it
3
+
4
+ Lenses append to `.gtd/QUALITY.md` in turn, so a finding belongs to the lens
5
+ whose turn added it — even when two lenses give findings the same title,
6
+ which the review then tells apart by a numbered suffix.
7
+
8
+ Background:
9
+ Given the shipped workflows, as gtd install scaffolds them
10
+ And an environment variable "GTD_QUALITYREVIEWS" set to "correctness,tests"
11
+ And a file "src/calc.ts" with:
12
+ """
13
+ export const add = (a: number, b: number) => a + b
14
+ """
15
+ And the working tree is committed as "feat: add calculator"
16
+ And a file ".gtd/REVIEW.orig.md" with:
17
+ """
18
+ # Review: abc1234
19
+
20
+ <!-- base: 0000000 -->
21
+
22
+ ## calc
23
+
24
+ - [ ] ./src/calc.ts#1-1
25
+ new add function
26
+ """
27
+ And a file ".gtd/REVIEW.md" with:
28
+ """
29
+ # Review: abc1234
30
+
31
+ <!-- base: 0000000 -->
32
+
33
+ ## calc
34
+
35
+ - [ ] ./src/calc.ts#1-1
36
+ new add function — rename it to sum
37
+ """
38
+ And the working tree is committed as "chore: hand off a review"
39
+ And step "notes.fixing" writes "src/calc.ts" with:
40
+ """
41
+ export const sum = (a: number, b: number) => a + b
42
+ """
43
+ And step "notes.closing" runs its script
44
+ And step "build.health.check" changes nothing
45
+ And step "build.quality.correctness.reviewing" writes ".gtd/QUALITY.md" with:
46
+ """
47
+ # Quality
48
+
49
+ ## sum is untested
50
+
51
+ From correctness.
52
+ """
53
+ And step "build.quality.tests.reviewing" writes ".gtd/QUALITY.md" with:
54
+ """
55
+ # Quality
56
+
57
+ ## sum is untested
58
+
59
+ From correctness.
60
+
61
+ ## sum is untested
62
+
63
+ From tests.
64
+ """
65
+ And step "build.quality.sorting" runs its script
66
+ And step "build.quality.closing" runs its script
67
+ And step "build.review.reviewing" writes ".gtd/REVIEW.md" with:
68
+ """
69
+ # Review: abc1234
70
+
71
+ <!-- base: 0000000 -->
72
+
73
+ ## calc
74
+
75
+ - [ ] ./src/calc.ts#1-1
76
+ renamed to sum
77
+ """
78
+ And step "build.review.carrying" runs its script
79
+
80
+ Scenario: two lenses' findings with one title each name their own lens
81
+ Given judge step "build.quality.triage" answers:
82
+ """
83
+ [{"id":"Q1","answer":"design","p":0.9},{"id":"Q2","answer":"design","p":0.9}]
84
+ """
85
+ When gtd opens door "fix"
86
+ And the process is driven
87
+ Then the process rests at "build.review.await-review"
88
+ And ".gtd/REVIEW.md" contains:
89
+ """
90
+ ## sum is untested
91
+
92
+ _From lens correctness._
93
+
94
+ From correctness.
95
+
96
+ ## sum is untested (2)
97
+
98
+ _From lens tests._
99
+
100
+ From tests.
101
+ """
@@ -0,0 +1,131 @@
1
+ @inmem
2
+ Feature: fix's quality lap is triaged, and its leftovers go to the hand-off review
3
+
4
+ A judge sorts the lap's findings: mechanical ones are fixed for up to three
5
+ turns, noise is dropped, and design ones — with whatever the fix turns left —
6
+ are added to the reviewer's `.gtd/REVIEW.md` by gtd, never to a person's
7
+ gate before the review.
8
+
9
+ Background:
10
+ Given the shipped workflows, as gtd install scaffolds them
11
+ And an environment variable "GTD_QUALITYREVIEWS" set to "correctness"
12
+ And a file "src/calc.ts" with:
13
+ """
14
+ export const add = (a: number, b: number) => a + b
15
+ """
16
+ And the working tree is committed as "feat: add calculator"
17
+ And a file ".gtd/REVIEW.orig.md" with:
18
+ """
19
+ # Review: abc1234
20
+
21
+ <!-- base: 0000000 -->
22
+
23
+ ## calc
24
+
25
+ - [ ] ./src/calc.ts#1-1
26
+ new add function
27
+ """
28
+ And a file ".gtd/REVIEW.md" with:
29
+ """
30
+ # Review: abc1234
31
+
32
+ <!-- base: 0000000 -->
33
+
34
+ ## calc
35
+
36
+ - [ ] ./src/calc.ts#1-1
37
+ new add function — rename it to sum
38
+ """
39
+ And the working tree is committed as "chore: hand off a review"
40
+ And step "notes.fixing" writes "src/calc.ts" with:
41
+ """
42
+ export const sum = (a: number, b: number) => a + b
43
+ """
44
+ And step "notes.closing" runs its script
45
+ And step "build.health.check" changes nothing
46
+ And step "build.quality.correctness.reviewing" writes ".gtd/QUALITY.md" with:
47
+ """
48
+ # Quality
49
+
50
+ ## sum has no negative test
51
+
52
+ Add a test summing negative numbers.
53
+
54
+ ## sum should be named plus
55
+
56
+ The glossary says plus.
57
+ """
58
+ And step "build.quality.sorting" runs its script
59
+ And step "build.quality.closing" runs its script
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#1-1
69
+ renamed to sum
70
+ """
71
+ And step "build.review.carrying" runs its script
72
+
73
+ Scenario: a design finding and one the fix cap left reach the reviewer, never an escalation
74
+ Given judge step "build.quality.triage" answers:
75
+ """
76
+ [{"id":"Q1","answer":"mechanical","p":0.9},{"id":"Q2","answer":"design","p":0.9}]
77
+ """
78
+ And step "build.fix.quality.fixing" changes nothing
79
+ When gtd opens door "fix"
80
+ And the process is driven
81
+ Then the process rests at "build.review.await-review"
82
+ And ".gtd/QUALITY.md" does not exist
83
+ And no commit body contains "build.health.describe"
84
+ And ".gtd/REVIEW.md" contains:
85
+ """
86
+ ## sum has no negative test
87
+
88
+ _From lens correctness._
89
+
90
+ _The fixer left this unresolved._
91
+ """
92
+ And ".gtd/REVIEW.md" contains:
93
+ """
94
+ ## sum should be named plus
95
+
96
+ _From lens correctness._
97
+
98
+ The glossary says plus.
99
+ """
100
+ And the commit subjects end with:
101
+ """
102
+ gtd(agent): build.quality.correctness.reviewing → build.quality.triage
103
+ gtd(judge): build.quality.triage → build.quality.sorting
104
+ gtd(check): build.quality.sorting → build.fix.quality.fixing
105
+ gtd(agent): build.fix.quality.fixing
106
+ gtd(agent): build.fix.quality.fixing
107
+ gtd(agent): build.fix.quality.fixing → build.health.check
108
+ gtd(check): build.health.check → build.quality.closing
109
+ gtd(check): build.quality.closing → build.review.reviewing
110
+ gtd(agent): build.review.reviewing → build.review.carrying
111
+ gtd(check): build.review.carrying → build.review.await-review
112
+ """
113
+
114
+ Scenario: a fixed finding and a noise one are listed in the review
115
+ Given judge step "build.quality.triage" answers:
116
+ """
117
+ [{"id":"Q1","answer":"mechanical","p":0.9},{"id":"Q2","answer":"noise","p":0.9}]
118
+ """
119
+ And step "build.fix.quality.fixing" writes "src/calc.test.ts" with:
120
+ """
121
+ it("sums negative numbers", () => {})
122
+ """
123
+ And step "build.fix.quality.fixing" deletes ".gtd/QUALITY.md"
124
+ When gtd opens door "fix"
125
+ And the process is driven
126
+ Then the process rests at "build.review.await-review"
127
+ And no commit body contains "build.quality.closing"
128
+ And ".gtd/REVIEW.md" contains "## Fixed automatically"
129
+ And ".gtd/REVIEW.md" contains "- sum has no negative test ("
130
+ And ".gtd/REVIEW.md" contains the short hash of the commit "gtd(agent): build.fix.quality.fixing → build.health.check"
131
+ And ".gtd/REVIEW.md" contains "- sum should be named plus (p=0.9, lens correctness)"
@@ -11,7 +11,6 @@ export * from "./shared/steps.js"
11
11
  export * from "./shared/health.js"
12
12
  export {
13
13
  closeReview,
14
- fixQualityFindings,
15
14
  handOffReview,
16
15
  handOffTail,
17
16
  qualityLap,
@@ -15,6 +15,7 @@ import {
15
15
  import { FIX_CAP, guarded, healthy, type EscalationCount, type Guard } from "./health.js"
16
16
  import { fixCheck, fixQuality, QUALITY } from "./steps.js"
17
17
  import * as t from "./text.js"
18
+ import type { Origin } from "./text.js"
18
19
 
19
20
  /** One `## ` section of `.gtd/QUALITY.md`. */
20
21
  export interface Finding {
@@ -29,13 +30,30 @@ type FindingKind = "mechanical" | "noise" | "design"
29
30
  /** What the triaged quality lap did with its findings, for the review to list. */
30
31
  export interface LensReport {
31
32
  /** Design findings, then the mechanical ones the fix cap left. */
32
- readonly notes: readonly (Finding & { readonly unresolved?: true; readonly unclear?: true })[]
33
- readonly fixed: readonly (Finding & { readonly commit: string })[]
34
- readonly noise: readonly (Finding & { readonly p: number })[]
33
+ readonly notes: readonly (Finding &
34
+ Origin & {
35
+ readonly unresolved?: true
36
+ readonly unclear?: true
37
+ })[]
38
+ readonly fixed: readonly (Finding & Origin & { readonly commit: string })[]
39
+ readonly noise: readonly (Finding & Origin & { readonly p: number })[]
35
40
  }
36
41
 
37
42
  export const NO_FINDINGS: LensReport = { notes: [], fixed: [], noise: [] }
38
43
 
44
+ /** `reports` as one, each finding labelled with the slice it came from, in slice order. */
45
+ export const joinReports = (
46
+ reports: readonly { readonly slice: string; readonly report: LensReport }[],
47
+ ): LensReport => {
48
+ const tag = <F extends Origin>(slice: string, fs: readonly F[]): F[] =>
49
+ fs.map((f) => ({ ...f, slice }))
50
+ return {
51
+ notes: reports.flatMap((r) => tag(r.slice, r.report.notes)),
52
+ fixed: reports.flatMap((r) => tag(r.slice, r.report.fixed)),
53
+ noise: reports.flatMap((r) => tag(r.slice, r.report.noise)),
54
+ }
55
+ }
56
+
39
57
  const UNTITLED = "Quality findings"
40
58
 
41
59
  const below = (section: string): string => section.split("\n").slice(1).join("\n").trim()
@@ -129,6 +147,8 @@ export interface TriageOptions {
129
147
  readonly guard?: Guard | undefined
130
148
  readonly guardRests?: boolean | undefined
131
149
  readonly rules?: string | undefined
150
+ /** The lens that wrote a finding, when known; asked once per finding, in file order. */
151
+ readonly lensOf?: ((f: Finding) => string | undefined) | undefined
132
152
  }
133
153
 
134
154
  /**
@@ -139,7 +159,11 @@ export interface TriageOptions {
139
159
  */
140
160
  export const triageFindings = async (options: TriageOptions): Promise<LensReport> => {
141
161
  const { escalations, guard, guardRests, rules } = options
142
- const findings = findingsOf(read(QUALITY) ?? "")
162
+ const withLens = (f: Finding): Finding & Origin => {
163
+ const lens = options.lensOf?.(f)
164
+ return lens === undefined ? f : { ...f, lens }
165
+ }
166
+ const findings = findingsOf(read(QUALITY) ?? "").map(withLens)
143
167
  if (findings.length === 0) return NO_FINDINGS
144
168
  const { mechanical, noise, design } = await classify(findings)
145
169
  // Rewritten whenever it is not one chunk per mechanical finding: the fixer deletes chunks.
@@ -160,7 +184,8 @@ export const triageFindings = async (options: TriageOptions): Promise<LensReport
160
184
  const commit = head()
161
185
  fixed.push(...turn.fixed.map((f) => ({ ...f, commit })))
162
186
  unclear.push(...turn.unclear)
163
- open = turn.open
187
+ // A turn that renamed a chunk hands back fresh parses: keep the lens a same-titled finding had.
188
+ open = turn.open.map((f) => ({ ...mechanical.find((m) => m.title === f.title), ...f }))
164
189
  }
165
190
  if (mechanical.length > 0)
166
191
  await healthy(() => fixCheck(rules), { escalations, guard, guardRests, rules })
@@ -26,7 +26,6 @@ import {
26
26
  fixCheck,
27
27
  fixNits,
28
28
  fixRisks,
29
- QUALITY,
30
29
  REQUIREMENTS,
31
30
  REVIEW,
32
31
  reviewMissing,
@@ -173,7 +172,7 @@ export const isCode = (path: string): boolean => path !== ".gtd" && !path.starts
173
172
 
174
173
  /**
175
174
  * gtd writes `.gtd/REVIEW.md` from the quality lap's `report` — no reviewer
176
- * turn — and removes what the fixer left in `.gtd/QUALITY.md`. A re-review
175
+ * turn. A re-review
177
176
  * after fixed nits restores the doc as committed at `carry`, answers kept;
178
177
  * a fresh one leaves out the notes the human already answered.
179
178
  */
@@ -199,7 +198,6 @@ const composing = (
199
198
  undecided: parseStandards(read(STANDARDS) ?? "").undecided,
200
199
  },
201
200
  }),
202
- [QUALITY],
203
201
  )
204
202
  : showScript(carry, REVIEW, REVIEW),
205
203
  { label: "Composing the review", file: REVIEW, base },
@@ -305,7 +303,7 @@ export interface BuildTailOptions extends TailOptions {
305
303
  readonly report?: LensReport | undefined
306
304
  }
307
305
 
308
- /** The build tail: fix (when entered red), keep green unless `green`, the triaged quality lap, then human review since `base`. */
306
+ /** The build tail: fix (when entered red), keep green unless `green`, the quality lap, then human review since `base`. */
309
307
  export function buildTail(base: string, options?: BuildTailOptions): Promise<kit.ReviewOutcome>
310
308
  /** @deprecated Pass `buildTail(base, { fixFirst, green, ...options })`. */
311
309
  export function buildTail(
@@ -329,10 +327,7 @@ export function buildTail(
329
327
  )
330
328
  }
331
329
  const [base, form] = options
332
- return tail((escalations, report) => humanReview(base, escalations, { ...form, report }), {
333
- ...form,
334
- triage: true,
335
- })
330
+ return tail((escalations, report) => humanReview(base, escalations, { ...form, report }), form)
336
331
  }
337
332
 
338
333
  const isOptionalBoolean = (value: unknown): value is boolean | null | undefined =>
@@ -1,17 +1,18 @@
1
- import { read, refuse, removeScript, run, scope, showScript, vars } from "../../flows/index.js"
2
- import * as kit from "../../workflow-kit/index.js"
3
1
  import {
4
- escalation,
5
- FIX_CAP,
6
- guarded,
7
- healthy,
8
- type EscalationCount,
9
- type Guard,
10
- } from "./health.js"
2
+ read,
3
+ refuse,
4
+ removeScript,
5
+ run,
6
+ scope,
7
+ showScript,
8
+ vars,
9
+ writeScript,
10
+ } from "../../flows/index.js"
11
+ import * as kit from "../../workflow-kit/index.js"
12
+ import { guarded, healthy, type EscalationCount, type Guard } from "./health.js"
11
13
  import {
12
14
  awaitReview,
13
15
  fixCheck,
14
- fixQuality,
15
16
  fixRisks,
16
17
  QUALITY,
17
18
  REVIEW,
@@ -19,7 +20,7 @@ import {
19
20
  reviewMissing,
20
21
  reviewQuality,
21
22
  } from "./steps.js"
22
- import { NO_FINDINGS, triageFindings, type LensReport } from "./lenses.js"
23
+ import { findingsOf, NO_FINDINGS, triageFindings, type Finding, type LensReport } from "./lenses.js"
23
24
  import * as t from "./text.js"
24
25
 
25
26
  /** The reviewer's own `.gtd/REVIEW.md`, kept beside the human's notes so `fix` can tell them apart. */
@@ -34,29 +35,30 @@ export const qualityLap = (
34
35
  guard?: Guard,
35
36
  lenses?: readonly string[],
36
37
  base?: string,
38
+ ): Promise<"clean" | "findings"> => lensLap(guard, lenses, base, () => {})
39
+
40
+ // `seen` gets each lens and every finding in the file once its turn is done.
41
+ const lensLap = (
42
+ guard: Guard | undefined,
43
+ lenses: readonly string[] | undefined,
44
+ base: string | undefined,
45
+ seen: (lens: string, findings: readonly Finding[]) => void,
37
46
  ): Promise<"clean" | "findings"> =>
38
47
  kit.qualityLap(
39
48
  lenses ?? kit.lensEntriesOf(vars.qualityReviews ?? ""),
40
- guarded((entry: string) => {
49
+ guarded(async (entry: string) => {
41
50
  const { lens, model } = kit.lensOf(entry)
42
- return reviewQuality(lens, base, model)
51
+ await reviewQuality(lens, base, model)
52
+ seen(lens, findingsOf(read(QUALITY) ?? ""))
43
53
  }, guard),
44
54
  QUALITY,
45
55
  )
46
56
 
47
- /** Resolves `true` once `.gtd/QUALITY.md` is resolved, `false` when the fix cap escalated instead. */
48
- export const fixQualityFindings = (
49
- escalations: EscalationCount,
50
- guard?: Guard,
51
- guardRests = true,
52
- rules?: string,
53
- ): Promise<boolean> =>
54
- kit.fixFindings({
55
- fix: guarded(() => fixQuality(rules), guard),
56
- findings: QUALITY,
57
- cap: FIX_CAP,
58
- escalate: () => escalation(escalations, guard, guardRests, rules),
59
- })
57
+ // What the fixer left is in the lap's report by now; the next lap must not append to it.
58
+ const closeQuality = async (): Promise<void> => {
59
+ if (read(QUALITY) === undefined) return
60
+ await run("quality.closing", removeScript([QUALITY]), { label: "Clearing quality findings" })
61
+ }
60
62
 
61
63
  /** `.gtd/REVIEW.md` removed at the end of a round at commit `round`. */
62
64
  export const closeReview = (round: string): Promise<void> =>
@@ -89,17 +91,12 @@ export interface TailOptions {
89
91
  readonly lensBase?: string | undefined
90
92
  /** Appended to every fix and describe prompt of the tail. */
91
93
  readonly rules?: string | undefined
92
- /**
93
- * Judge the lap's findings: fix the mechanical ones, drop noise, and hand
94
- * `finish` the rest as notes for the human review; default `false`. `finish`
95
- * must remove `.gtd/QUALITY.md`, which still holds what the fixer left.
96
- */
97
- readonly triage?: boolean | undefined
98
94
  }
99
95
 
100
96
  /**
101
- * The build tail: fix (when entered red), keep green, the quality lap, then
102
- * `finish` — whatever the calling workflow runs last, a human review or nothing.
97
+ * The build tail: fix (when entered red), keep green, the quality lap — a
98
+ * judge sorts its findings, the mechanical ones are fixed, noise dropped —
99
+ * then `finish` with the rest as notes: a human review, or carrying them on.
103
100
  */
104
101
  export function tail<T>(
105
102
  finish: (escalations: EscalationCount, report: LensReport) => Promise<T>,
@@ -108,7 +105,7 @@ export function tail<T>(
108
105
  /** @deprecated Pass `tail(finish, { fixFirst, green, guard, lenses, guardRests, lensBase, rules })`. */
109
106
  export function tail<T>(
110
107
  fixFirst: boolean,
111
- finish: (escalations: EscalationCount) => Promise<T>,
108
+ finish: (escalations: EscalationCount, report: LensReport) => Promise<T>,
112
109
  green?: boolean,
113
110
  guard?: Guard,
114
111
  lenses?: readonly string[],
@@ -164,29 +161,34 @@ const runTail = <T>(finish: Finish<T>, options: TailOptions): Promise<T> =>
164
161
  const guardRests = options.guardRests === undefined ? true : options.guardRests
165
162
  const escalations: EscalationCount = { rounds: 0 }
166
163
  const fix = () => fixCheck(rules)
167
- const repair = async () => {
168
- await guarded(fix, guard)()
169
- await healthy(fix, { fixesSoFar: 1, escalations, guard, guardRests, rules })
170
- }
171
164
  // fixFirst's own fix + healthy below already is the full run.
172
165
  // Truthy, not `true`: an untyped config's flags read as they always did.
173
- if (options.fixFirst) await repair()
174
- else if (!options.green) await healthy(fix, { escalations, guard, guardRests, rules })
166
+ if (options.fixFirst) {
167
+ await guarded(fix, guard)()
168
+ await healthy(fix, { fixesSoFar: 1, escalations, guard, guardRests, rules })
169
+ } else if (!options.green) await healthy(fix, { escalations, guard, guardRests, rules })
175
170
  // The lap runs once a tail: after its findings are fixed, `finish` follows.
176
- const lap = await qualityLap(guard, lenses, lensBase)
177
- if (options.triage) {
178
- const report =
179
- lap === "findings"
180
- ? await triageFindings({ escalations, guard, guardRests, rules })
181
- : NO_FINDINGS
182
- return finish(escalations, report)
183
- }
184
- if (lap === "findings") {
185
- if (await fixQualityFindings(escalations, guard, guardRests, rules)) {
186
- await healthy(fix, { escalations, guard, guardRests, rules })
187
- } else await repair()
171
+ // Lenses append, so a title the file did not hold before a lens's turn is that lens's.
172
+ const byTitle: { title: string; lens: string }[] = []
173
+ const lap = await lensLap(guard, lenses, lensBase, (lens, findings) => {
174
+ const before = byTitle.map((f) => f.title)
175
+ for (const { title } of findings) {
176
+ const at = before.indexOf(title)
177
+ if (at >= 0) before.splice(at, 1)
178
+ else byTitle.push({ title, lens })
179
+ }
180
+ })
181
+ // Asked once per finding in file order, so a title two lenses wrote goes to each in turn.
182
+ const lensOf = (f: Finding): string | undefined => {
183
+ const at = byTitle.findIndex((e) => e.title === f.title)
184
+ return at < 0 ? undefined : byTitle.splice(at, 1)[0]!.lens
188
185
  }
189
- return finish(escalations, NO_FINDINGS)
186
+ const report =
187
+ lap === "findings"
188
+ ? await triageFindings({ escalations, guard, guardRests, rules, lensOf })
189
+ : NO_FINDINGS
190
+ await closeQuality()
191
+ return finish(escalations, report)
190
192
  })
191
193
 
192
194
  /**
@@ -198,10 +200,22 @@ export const handOffReview = (
198
200
  base: string,
199
201
  escalations: EscalationCount,
200
202
  guard?: Guard,
203
+ report: LensReport = NO_FINDINGS,
201
204
  ): Promise<void> =>
202
205
  kit.reviewToHandOff(base, {
203
206
  file: REVIEW,
204
- reviewing: guarded((b, carry) => reviewing(b, carry), guard),
207
+ reviewing: guarded(async (b, carry) => {
208
+ await reviewing(b, carry)
209
+ // gtd adds the lap's chunks itself: .gtd/QUALITY.md is gone, so a reviewer leaving one out would lose it.
210
+ const review = read(REVIEW)
211
+ if (review === undefined) return
212
+ const full = t.withLapFindings(review, report)
213
+ if (full !== review) {
214
+ await run("review.carrying", writeScript(REVIEW, full), {
215
+ label: "Adding the quality lap's findings",
216
+ })
217
+ }
218
+ }, guard),
205
219
  fixRisks: guarded(fixRisks, guard),
206
220
  keepGreen: () => healthy(fixCheck, { escalations, guard }),
207
221
  awaitReview: (b) => awaitReview(b, t.handOffAwaitReviewMessage(b)),
@@ -220,6 +234,9 @@ export const handOffReview = (
220
234
  "gtd land: review-doc: .gtd/REVIEW.md was deleted — restore it, or leave a note to hand to fix.",
221
235
  })
222
236
 
223
- /** The tail the fix workflow runs: ends at the hand-off review since `base`. */
237
+ /** The tail the fix workflow runs: ends at the hand-off review since `base`, which gets what the lap left. */
224
238
  export const handOffTail = (fixFirst: boolean, base: string, guard?: Guard): Promise<void> =>
225
- tail((escalations) => handOffReview(base, escalations, guard), { fixFirst, guard })
239
+ tail((escalations, report) => handOffReview(base, escalations, guard, report), {
240
+ fixFirst,
241
+ guard,
242
+ })