@pmelab/gtd 49.0.0 → 50.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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +31 -26
- package/dist/chunks/{reserved-CYZ1bSBy.mjs → reserved-CdOzKFVX.mjs} +59 -2
- package/dist/chunks/{review-BewyJflS.mjs → review-BChXEl2Y.mjs} +84 -20
- package/dist/chunks/{steering-BXwjmocB.mjs → steering-DtUVUz9l.mjs} +44 -14
- package/dist/chunks/{tail-C1Oi2YEy.mjs → tail-DRM2eC1z.mjs} +1 -1
- package/dist/gtd.bundle.mjs +22 -5
- package/dist/steering.mjs +2 -2
- package/dist/testkit.mjs +1 -1
- package/dist/web/main.js +27 -3
- package/dist/workflow-kit.mjs +1 -1
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +3 -3
- package/dist/workflows/fix.mjs +3 -3
- package/dist/workflows/review.mjs +3 -3
- package/dist/workflows/rollout.mjs +1 -1
- package/dist/workflows.mjs +181 -30
- package/package.json +1 -1
- package/src/steering/index.ts +1 -0
- package/src/steering/review.test.ts +60 -2
- package/src/steering/review.ts +66 -9
- package/src/workflows/feature/lens-review.feature +16 -4
- package/src/workflows/feature/review-answers.feature +8 -1
- package/src/workflows/feature/review-gate.feature +20 -3
- package/src/workflows/feature/reviewer-walkthrough.feature +14 -3
- package/src/workflows/feature/severity-budget.feature +111 -0
- package/src/workflows/feature/severity-sorted-review.feature +197 -0
- package/src/workflows/feature/slice-findings.feature +14 -2
- package/src/workflows/feature/standards.feature +4 -0
- package/src/workflows/shared/review.ts +124 -18
- package/src/workflows/shared/text.test.ts +90 -0
- package/src/workflows/shared/text.ts +77 -1
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
@inmem
|
|
2
|
+
Feature: the final review lists what matters most first
|
|
3
|
+
|
|
4
|
+
Before the review rests, the judge rates every chunk the reviewer wrote — a
|
|
5
|
+
walkthrough chunk or a carried quality finding — on the same four questions
|
|
6
|
+
as a human's note (`build.review.ranking`): critical (security, or a one-way
|
|
7
|
+
door), should (a contract, or a new concept), could. The review lists them
|
|
8
|
+
in that order, security before one-way doors, each led by its tier and
|
|
9
|
+
reason. A chunk the judge cannot rate is critical, so it is shown first.
|
|
10
|
+
|
|
11
|
+
Background:
|
|
12
|
+
Given the shipped workflows, as gtd install scaffolds them
|
|
13
|
+
And an environment variable "GTD_QUALITYREVIEWS" set to "correctness"
|
|
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
|
+
## Acceptance Criteria
|
|
33
|
+
|
|
34
|
+
- AC-1: subtract(a, b) returns a - b.
|
|
35
|
+
"""
|
|
36
|
+
And step "design.gate.answer" changes nothing
|
|
37
|
+
And step "seam.naive" writes "src/calc.test.ts" with:
|
|
38
|
+
"""
|
|
39
|
+
it("[AC-1] subtracts", () => {})
|
|
40
|
+
"""
|
|
41
|
+
And step "seam.naive" writes "src/calc.ts" with:
|
|
42
|
+
"""
|
|
43
|
+
export const add = (a: number, b: number) => a + b
|
|
44
|
+
export const subtract = (a: number, b: number) => a - b
|
|
45
|
+
"""
|
|
46
|
+
And step "seam.check" changes nothing
|
|
47
|
+
And step "implement.applying" runs its script
|
|
48
|
+
And step "implement.health.check" changes nothing
|
|
49
|
+
And step "implement.closing" runs its script
|
|
50
|
+
And judge step "implement.coverage" answers:
|
|
51
|
+
"""
|
|
52
|
+
[{"id":"AC-1","answer":"yes","p":0.9}]
|
|
53
|
+
"""
|
|
54
|
+
And step "build.quality.correctness.reviewing" writes ".gtd/QUALITY.md" with:
|
|
55
|
+
"""
|
|
56
|
+
# Quality
|
|
57
|
+
|
|
58
|
+
## AC-1 ignores floating point
|
|
59
|
+
|
|
60
|
+
Should subtract round? The criterion does not say.
|
|
61
|
+
"""
|
|
62
|
+
And judge step "build.quality.triage" answers:
|
|
63
|
+
"""
|
|
64
|
+
[{"id":"Q1","answer":"design","p":0.9}]
|
|
65
|
+
"""
|
|
66
|
+
And step "build.quality.sorting" runs its script
|
|
67
|
+
And step "build.quality.closing" runs its script
|
|
68
|
+
And step "build.review.carrying" runs its script
|
|
69
|
+
|
|
70
|
+
Scenario: security and one-way doors come first, a carried finding sorted among the rest
|
|
71
|
+
Given step "build.review.reviewing" writes ".gtd/REVIEW.md" with:
|
|
72
|
+
"""
|
|
73
|
+
# Review: abc1234
|
|
74
|
+
|
|
75
|
+
<!-- base: 0000000 -->
|
|
76
|
+
|
|
77
|
+
## Subtract
|
|
78
|
+
|
|
79
|
+
The new export.
|
|
80
|
+
|
|
81
|
+
- [ ] ./src/calc.ts#2-2 — subtract(a, b)
|
|
82
|
+
|
|
83
|
+
## Session token
|
|
84
|
+
|
|
85
|
+
- [ ] ./src/calc.ts#1-1 — reads the caller's token
|
|
86
|
+
|
|
87
|
+
## AC-1 ignores floating point
|
|
88
|
+
|
|
89
|
+
- [ ] ./src/calc.ts#2-2 — returns the raw difference
|
|
90
|
+
|
|
91
|
+
## Saved format
|
|
92
|
+
|
|
93
|
+
- [ ] ./src/calc.ts#1-2 — what callers persist
|
|
94
|
+
"""
|
|
95
|
+
And judge step "build.review.ranking" answers:
|
|
96
|
+
"""
|
|
97
|
+
[{"id":"C1.security","answer":"no","p":0.9},{"id":"C1.reversibility","answer":"two-way","p":0.9},{"id":"C1.blastRadius","answer":"local","p":0.9},{"id":"C1.newConcept","answer":"no","p":0.9},{"id":"C2.security","answer":"yes","p":0.9},{"id":"C2.reversibility","answer":"two-way","p":0.9},{"id":"C2.blastRadius","answer":"module","p":0.9},{"id":"C2.newConcept","answer":"no","p":0.9},{"id":"C3.security","answer":"no","p":0.9},{"id":"C3.reversibility","answer":"two-way","p":0.9},{"id":"C3.blastRadius","answer":"contract","p":0.9},{"id":"C3.newConcept","answer":"no","p":0.9},{"id":"C4.security","answer":"no","p":0.9},{"id":"C4.reversibility","answer":"one-way","p":0.9},{"id":"C4.blastRadius","answer":"contract","p":0.9},{"id":"C4.newConcept","answer":"no","p":0.9}]
|
|
98
|
+
"""
|
|
99
|
+
When the process is driven
|
|
100
|
+
Then the process rests at "build.review.await-review"
|
|
101
|
+
And ".gtd/REVIEW.md" contains:
|
|
102
|
+
"""
|
|
103
|
+
## Session token
|
|
104
|
+
|
|
105
|
+
_critical: security_
|
|
106
|
+
|
|
107
|
+
- [ ] ./src/calc.ts#1-1 — reads the caller's token
|
|
108
|
+
|
|
109
|
+
## Saved format
|
|
110
|
+
|
|
111
|
+
_critical: one-way door_
|
|
112
|
+
|
|
113
|
+
- [ ] ./src/calc.ts#1-2 — what callers persist
|
|
114
|
+
|
|
115
|
+
## AC-1 ignores floating point
|
|
116
|
+
|
|
117
|
+
_should: contract_
|
|
118
|
+
|
|
119
|
+
Should subtract round? The criterion does not say.
|
|
120
|
+
"""
|
|
121
|
+
And ".gtd/REVIEW.md" contains:
|
|
122
|
+
"""
|
|
123
|
+
- [ ] _your answer_
|
|
124
|
+
|
|
125
|
+
## Subtract
|
|
126
|
+
|
|
127
|
+
_could_
|
|
128
|
+
|
|
129
|
+
The new export.
|
|
130
|
+
"""
|
|
131
|
+
And the commit "gtd(judge): build.review.ranking → build.review.carrying" has a body containing "Gtd-Judge:"
|
|
132
|
+
And the commit subjects end with:
|
|
133
|
+
"""
|
|
134
|
+
gtd(agent): build.review.reviewing → build.review.ranking
|
|
135
|
+
gtd(judge): build.review.ranking → build.review.carrying
|
|
136
|
+
gtd(check): build.review.carrying → build.review.await-review
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
Scenario: a chunk the judge is unsure about is listed as critical
|
|
140
|
+
Given step "build.review.reviewing" writes ".gtd/REVIEW.md" with:
|
|
141
|
+
"""
|
|
142
|
+
# Review: abc1234
|
|
143
|
+
|
|
144
|
+
<!-- base: 0000000 -->
|
|
145
|
+
|
|
146
|
+
## Subtract
|
|
147
|
+
|
|
148
|
+
The new export.
|
|
149
|
+
|
|
150
|
+
- [ ] ./src/calc.ts#2-2 — subtract(a, b)
|
|
151
|
+
|
|
152
|
+
## Session token
|
|
153
|
+
|
|
154
|
+
- [ ] ./src/calc.ts#1-1 — reads the caller's token
|
|
155
|
+
|
|
156
|
+
## AC-1 ignores floating point
|
|
157
|
+
|
|
158
|
+
- [ ] ./src/calc.ts#2-2 — returns the raw difference
|
|
159
|
+
|
|
160
|
+
## Saved format
|
|
161
|
+
|
|
162
|
+
- [ ] ./src/calc.ts#1-2 — what callers persist
|
|
163
|
+
"""
|
|
164
|
+
And judge step "build.review.ranking" answers:
|
|
165
|
+
"""
|
|
166
|
+
[{"id":"C1.security","answer":"no","p":0.9},{"id":"C1.reversibility","answer":"two-way","p":0.9},{"id":"C1.blastRadius","answer":"local","p":0.9},{"id":"C1.newConcept","answer":"no","p":0.9},{"id":"C2.security","answer":"no","p":0.4},{"id":"C2.reversibility","answer":"two-way","p":0.9},{"id":"C2.blastRadius","answer":"local","p":0.9},{"id":"C2.newConcept","answer":"no","p":0.9},{"id":"C3.security","answer":"no","p":0.9},{"id":"C3.reversibility","answer":"two-way","p":0.9},{"id":"C3.blastRadius","answer":"contract","p":0.9},{"id":"C3.newConcept","answer":"no","p":0.9},{"id":"C4.security","answer":"no","p":0.9},{"id":"C4.reversibility","answer":"two-way","p":0.9},{"id":"C4.blastRadius","answer":"local","p":0.9},{"id":"C4.newConcept","answer":"yes","p":0.9}]
|
|
167
|
+
"""
|
|
168
|
+
When the process is driven
|
|
169
|
+
Then the process rests at "build.review.await-review"
|
|
170
|
+
And ".gtd/REVIEW.md" contains:
|
|
171
|
+
"""
|
|
172
|
+
# Review: abc1234
|
|
173
|
+
|
|
174
|
+
<!-- base: 0000000 -->
|
|
175
|
+
|
|
176
|
+
## Session token
|
|
177
|
+
|
|
178
|
+
_critical: judge unsure_
|
|
179
|
+
"""
|
|
180
|
+
And ".gtd/REVIEW.md" contains:
|
|
181
|
+
"""
|
|
182
|
+
## AC-1 ignores floating point
|
|
183
|
+
|
|
184
|
+
_should: contract_
|
|
185
|
+
"""
|
|
186
|
+
And ".gtd/REVIEW.md" contains:
|
|
187
|
+
"""
|
|
188
|
+
## Saved format
|
|
189
|
+
|
|
190
|
+
_should: new concept_
|
|
191
|
+
"""
|
|
192
|
+
And ".gtd/REVIEW.md" contains:
|
|
193
|
+
"""
|
|
194
|
+
## Subtract
|
|
195
|
+
|
|
196
|
+
_could_
|
|
197
|
+
"""
|
|
@@ -71,6 +71,10 @@ Feature: every slice's quality lap is triaged, and its leftovers wait for the re
|
|
|
71
71
|
And step "build.quality.closing" runs its script
|
|
72
72
|
And step "build.health.check" changes nothing
|
|
73
73
|
And step "build.review.carrying" runs its script
|
|
74
|
+
And judge step "build.review.ranking" answers:
|
|
75
|
+
"""
|
|
76
|
+
[]
|
|
77
|
+
"""
|
|
74
78
|
|
|
75
79
|
Scenario: a finding questioning a criterion reaches the final review, never the fixer
|
|
76
80
|
Given step "build.quality.correctness.reviewing" writes ".gtd/QUALITY.md" with:
|
|
@@ -112,6 +116,8 @@ Feature: every slice's quality lap is triaged, and its leftovers wait for the re
|
|
|
112
116
|
"""
|
|
113
117
|
## AC-1 ignores floating point
|
|
114
118
|
|
|
119
|
+
_critical: judge unsure_
|
|
120
|
+
|
|
115
121
|
_From slice 1, lens correctness._
|
|
116
122
|
|
|
117
123
|
Should subtract round? The criterion does not say.
|
|
@@ -120,6 +126,8 @@ Feature: every slice's quality lap is triaged, and its leftovers wait for the re
|
|
|
120
126
|
"""
|
|
121
127
|
## AC-1 ignores floating point (2)
|
|
122
128
|
|
|
129
|
+
_critical: judge unsure_
|
|
130
|
+
|
|
123
131
|
_From slice 2, lens correctness._
|
|
124
132
|
"""
|
|
125
133
|
And the commit subjects end with:
|
|
@@ -137,7 +145,8 @@ Feature: every slice's quality lap is triaged, and its leftovers wait for the re
|
|
|
137
145
|
gtd(agent): build.quality.correctness.reviewing → build.quality.triage
|
|
138
146
|
gtd(judge): build.quality.triage → build.quality.sorting
|
|
139
147
|
gtd(check): build.quality.sorting → build.review.reviewing
|
|
140
|
-
gtd(agent): build.review.reviewing → build.review.
|
|
148
|
+
gtd(agent): build.review.reviewing → build.review.ranking
|
|
149
|
+
gtd(judge): build.review.ranking → build.review.carrying
|
|
141
150
|
gtd(check): build.review.carrying → build.review.await-review
|
|
142
151
|
"""
|
|
143
152
|
|
|
@@ -181,6 +190,8 @@ Feature: every slice's quality lap is triaged, and its leftovers wait for the re
|
|
|
181
190
|
"""
|
|
182
191
|
## subtract has no negative test
|
|
183
192
|
|
|
193
|
+
_critical: judge unsure_
|
|
194
|
+
|
|
184
195
|
_From slice 1, lens correctness._
|
|
185
196
|
|
|
186
197
|
_The fixer left this unresolved._
|
|
@@ -208,7 +219,8 @@ Feature: every slice's quality lap is triaged, and its leftovers wait for the re
|
|
|
208
219
|
gtd(agent): build.fix.quality.fixing → build.health.check
|
|
209
220
|
gtd(check): build.health.check → build.quality.closing
|
|
210
221
|
gtd(check): build.quality.closing → build.review.reviewing
|
|
211
|
-
gtd(agent): build.review.reviewing → build.review.
|
|
222
|
+
gtd(agent): build.review.reviewing → build.review.ranking
|
|
223
|
+
gtd(judge): build.review.ranking → build.review.carrying
|
|
212
224
|
gtd(check): build.review.carrying → build.review.await-review
|
|
213
225
|
"""
|
|
214
226
|
|
|
@@ -92,6 +92,10 @@ Feature: the review learns standards from the human's answers
|
|
|
92
92
|
- [ ] ./src/calc.ts#2-2 — a plain function
|
|
93
93
|
"""
|
|
94
94
|
And step "build.review.carrying" runs its script
|
|
95
|
+
And judge step "build.review.ranking" answers:
|
|
96
|
+
"""
|
|
97
|
+
[]
|
|
98
|
+
"""
|
|
95
99
|
And judge step "build.quality.triage" answers:
|
|
96
100
|
"""
|
|
97
101
|
[{"id":"Q1","answer":"design","p":0.9}]
|
|
@@ -70,10 +70,15 @@ interface Severity {
|
|
|
70
70
|
readonly reason: string
|
|
71
71
|
/** Capture order: 0 one-way door, 1 new concept, 2 everything else. */
|
|
72
72
|
readonly rank: 0 | 1 | 2
|
|
73
|
+
readonly security?: true
|
|
73
74
|
}
|
|
74
75
|
|
|
75
76
|
/** Anything unanswered, unrecognised, cut or below `minP` is `critical`, never `could`. */
|
|
76
|
-
export const severityOf = (
|
|
77
|
+
export const severityOf = (
|
|
78
|
+
note: { readonly id: string; readonly forced?: boolean | undefined },
|
|
79
|
+
judgment: Judgment,
|
|
80
|
+
minP: number,
|
|
81
|
+
): Severity => {
|
|
77
82
|
const ans = (q: string) => judgment.answers[`${note.id}.${q}`]
|
|
78
83
|
const a = (q: string): string | undefined => ans(q)?.answer
|
|
79
84
|
const rank = (): 0 | 1 | 2 =>
|
|
@@ -90,7 +95,12 @@ export const severityOf = (note: ReviewNote, judgment: Judgment, minP: number):
|
|
|
90
95
|
...(a("reversibility") === "one-way" ? ["one-way door"] : []),
|
|
91
96
|
]
|
|
92
97
|
if (critical.length > 0)
|
|
93
|
-
return {
|
|
98
|
+
return {
|
|
99
|
+
tier: "critical",
|
|
100
|
+
reason: `critical: ${critical.join(", ")}`,
|
|
101
|
+
rank: rank(),
|
|
102
|
+
...(a("security") === "yes" ? { security: true as const } : {}),
|
|
103
|
+
}
|
|
94
104
|
const should = [
|
|
95
105
|
...(a("blastRadius") === "contract" ? ["contract"] : []),
|
|
96
106
|
...(a("newConcept") === "yes" ? ["new concept"] : []),
|
|
@@ -100,31 +110,32 @@ export const severityOf = (note: ReviewNote, judgment: Judgment, minP: number):
|
|
|
100
110
|
return { tier: "could", reason: "could", rank: 2 }
|
|
101
111
|
}
|
|
102
112
|
|
|
103
|
-
|
|
113
|
+
/** `asks` names the change in full, `change` refers back to it. */
|
|
114
|
+
const severityQuestions = (id: string, asks: string, change: string): JudgeQuestion[] => [
|
|
104
115
|
{
|
|
105
|
-
id: `${
|
|
116
|
+
id: `${id}.security`,
|
|
106
117
|
primitive: "noul",
|
|
107
|
-
instructions: `Does
|
|
118
|
+
instructions: `Does ${asks} touch security — authentication, authorisation, secrets, untrusted input or data exposure?`,
|
|
108
119
|
criteria: "yes: it changes or could weaken a security property. no: it does not.",
|
|
109
120
|
},
|
|
110
121
|
{
|
|
111
|
-
id: `${
|
|
122
|
+
id: `${id}.reversibility`,
|
|
112
123
|
primitive: "choice",
|
|
113
|
-
instructions: `Once
|
|
124
|
+
instructions: `Once ${change} ships, can it be undone without breaking anyone who already depends on it?`,
|
|
114
125
|
criteria:
|
|
115
126
|
"one-way: a published API, file format, persisted data, protocol or other contract that cannot be taken back. two-way: can be reverted freely.",
|
|
116
127
|
},
|
|
117
128
|
{
|
|
118
|
-
id: `${
|
|
129
|
+
id: `${id}.blastRadius`,
|
|
119
130
|
primitive: "choice",
|
|
120
|
-
instructions: `How far does
|
|
131
|
+
instructions: `How far does ${change} reach?`,
|
|
121
132
|
criteria:
|
|
122
133
|
"local: one function or file. module: a whole module or several files. contract: a public interface other code or users depend on.",
|
|
123
134
|
},
|
|
124
135
|
{
|
|
125
|
-
id: `${
|
|
136
|
+
id: `${id}.newConcept`,
|
|
126
137
|
primitive: "noul",
|
|
127
|
-
instructions: `Does
|
|
138
|
+
instructions: `Does ${change} introduce a concept new to this codebase — a new abstraction, entity or term? Judge from the evidence, never a glossary; reusing or extending an existing concept is not new.`,
|
|
128
139
|
criteria: "yes: a genuinely new concept. no: reuses or extends an existing one.",
|
|
129
140
|
},
|
|
130
141
|
]
|
|
@@ -144,7 +155,14 @@ const triage =
|
|
|
144
155
|
asked.length === 0
|
|
145
156
|
? { answers: {}, truncated: [] }
|
|
146
157
|
: await judge("review.triage", {
|
|
147
|
-
questions: asked.flatMap((n) => [
|
|
158
|
+
questions: asked.flatMap((n) => [
|
|
159
|
+
verdictQuestion(n),
|
|
160
|
+
...severityQuestions(
|
|
161
|
+
n.id,
|
|
162
|
+
`the change the human asks for in note "${n.id}" (on "${n.anchor}")`,
|
|
163
|
+
`the change in note "${n.id}"`,
|
|
164
|
+
),
|
|
165
|
+
]),
|
|
148
166
|
evidence: Object.fromEntries(
|
|
149
167
|
asked.map((n) => [
|
|
150
168
|
n.id,
|
|
@@ -170,6 +188,91 @@ const triage =
|
|
|
170
188
|
)
|
|
171
189
|
}
|
|
172
190
|
|
|
191
|
+
/** Review order: security, one-way doors, the judge unsure, then should, then could. */
|
|
192
|
+
const chunkKey = (s: Severity): number =>
|
|
193
|
+
s.tier === "could" ? 4 : s.tier === "should" ? 3 : s.security === true ? 0 : s.rank === 0 ? 1 : 2
|
|
194
|
+
|
|
195
|
+
type Pointer = ReturnType<typeof reviewChunks>[number]["files"][number]
|
|
196
|
+
|
|
197
|
+
/** A chunk's text, then the code each of its pointers covers: a bare path the whole file. */
|
|
198
|
+
const chunkEvidence = (text: string, files: readonly Pointer[]): string =>
|
|
199
|
+
[
|
|
200
|
+
text,
|
|
201
|
+
...files.map(({ path, line, rangeEnd }) => {
|
|
202
|
+
const where = `${path}${line === undefined ? "" : `#${line}${rangeEnd === undefined ? "" : `-${rangeEnd}`}`}`
|
|
203
|
+
const lines = read(path.replace(/^\.\//, ""))?.split("\n")
|
|
204
|
+
if (lines === undefined) return `${where}: no longer exists`
|
|
205
|
+
const code = line === undefined ? lines : lines.slice(line - 1, rangeEnd ?? line)
|
|
206
|
+
return `${where}:\n${code.join("\n")}`
|
|
207
|
+
}),
|
|
208
|
+
].join("\n\n")
|
|
209
|
+
|
|
210
|
+
const bytes = (text: string): number => new TextEncoder().encode(text).length
|
|
211
|
+
|
|
212
|
+
/** Fills each batch while the budget's even share still holds its widest evidence, so nothing is cut that fits alone. */
|
|
213
|
+
const batched = <T extends { readonly evidence: string }>(
|
|
214
|
+
items: readonly T[],
|
|
215
|
+
budget: number,
|
|
216
|
+
): T[][] => {
|
|
217
|
+
const batches: T[][] = []
|
|
218
|
+
let batch: T[] = []
|
|
219
|
+
let widest = 0
|
|
220
|
+
for (const item of items) {
|
|
221
|
+
const size = bytes(item.evidence)
|
|
222
|
+
if (batch.length > 0 && Math.floor(budget / (batch.length + 1)) < Math.max(widest, size)) {
|
|
223
|
+
batches.push(batch)
|
|
224
|
+
batch = []
|
|
225
|
+
widest = 0
|
|
226
|
+
}
|
|
227
|
+
batch.push(item)
|
|
228
|
+
widest = Math.max(widest, size)
|
|
229
|
+
}
|
|
230
|
+
return batch.length === 0 ? batches : [...batches, batch]
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Rates every chunk the reviewer wrote — a walkthrough chunk or a carried
|
|
235
|
+
* finding — and sorts \`full\` by it, each chunk led by its tier and reason.
|
|
236
|
+
* gtd's summary chunks are not rated and stay last.
|
|
237
|
+
*/
|
|
238
|
+
const ranked = async (review: string, full: string): Promise<string> => {
|
|
239
|
+
const texts = t.chunkTexts(full)
|
|
240
|
+
const chunks = reviewChunks(review).map((c, i) => ({
|
|
241
|
+
id: `C${i + 1}`,
|
|
242
|
+
title: c.title,
|
|
243
|
+
evidence: chunkEvidence(texts.get(c.title) ?? "", c.files),
|
|
244
|
+
}))
|
|
245
|
+
if (chunks.length === 0) return full
|
|
246
|
+
const minP = numeric(vars.reviewSeverityMinP, Infinity)
|
|
247
|
+
const batches = batched(chunks, numeric(vars.judgeBudgetBytes, 32768))
|
|
248
|
+
const severities = new Map<string, Severity>()
|
|
249
|
+
for (const [i, batch] of batches.entries()) {
|
|
250
|
+
const judgment = await judge("review.ranking", {
|
|
251
|
+
questions: batch.flatMap((c) =>
|
|
252
|
+
severityQuestions(
|
|
253
|
+
c.id,
|
|
254
|
+
`the change review chunk "${c.id}" ("${c.title}") walks through`,
|
|
255
|
+
`the change in review chunk "${c.id}"`,
|
|
256
|
+
),
|
|
257
|
+
),
|
|
258
|
+
evidence: Object.fromEntries(batch.map((c) => [c.id, c.evidence])),
|
|
259
|
+
message: t.reviewRankingMessage(),
|
|
260
|
+
label:
|
|
261
|
+
batches.length === 1
|
|
262
|
+
? "Ranking the review"
|
|
263
|
+
: `Ranking the review (${i + 1}/${batches.length})`,
|
|
264
|
+
})
|
|
265
|
+
for (const c of batch) severities.set(c.id, severityOf(c, judgment, minP))
|
|
266
|
+
}
|
|
267
|
+
return t.rankedReview(
|
|
268
|
+
full,
|
|
269
|
+
chunks
|
|
270
|
+
.map((c, i) => ({ c, i, s: severities.get(c.id)! }))
|
|
271
|
+
.sort((x, y) => chunkKey(x.s) - chunkKey(y.s) || x.i - y.i)
|
|
272
|
+
.map(({ c, s }) => ({ title: c.title, label: s.reason })),
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
|
|
173
276
|
export const isCode = (path: string): boolean => path !== ".gtd" && !path.startsWith(".gtd/")
|
|
174
277
|
|
|
175
278
|
const composedOf = (
|
|
@@ -263,18 +366,21 @@ const walkthrough = async (
|
|
|
263
366
|
): Promise<void> => {
|
|
264
367
|
const notes = t.titledNotes(report)
|
|
265
368
|
await reviewing(base, carry, lap, t.walkthroughBlock(notes, report.nitFix))
|
|
266
|
-
const
|
|
369
|
+
const written = read(REVIEW)
|
|
267
370
|
// A review the reviewer did not write is the round's own refusal or rest.
|
|
268
|
-
if (
|
|
371
|
+
if (written === undefined) return
|
|
372
|
+
const review = t.unranked(written)
|
|
373
|
+
// Against the file as written, so a refusal's line numbers match it.
|
|
269
374
|
requirePointers(
|
|
270
|
-
|
|
375
|
+
written,
|
|
271
376
|
notes,
|
|
272
377
|
changesSince(base).some((c) => isCode(c.path)),
|
|
273
378
|
)
|
|
274
|
-
|
|
275
|
-
|
|
379
|
+
// Ranked even when it marks risks: the review written after their fix reaches the human with its own.
|
|
380
|
+
const full = await ranked(review, t.withWalkthrough(review, report))
|
|
381
|
+
if (full !== written) {
|
|
276
382
|
await run("review.carrying", writeScript(REVIEW, full), {
|
|
277
|
-
label: "
|
|
383
|
+
label: "Ordering the review and adding the quality lap's findings",
|
|
278
384
|
file: REVIEW,
|
|
279
385
|
base,
|
|
280
386
|
})
|
|
@@ -33,6 +33,8 @@ import {
|
|
|
33
33
|
titledNotes,
|
|
34
34
|
walkthroughBlock,
|
|
35
35
|
withWalkthrough,
|
|
36
|
+
rankedReview,
|
|
37
|
+
unranked,
|
|
36
38
|
} from "./text.js"
|
|
37
39
|
import { reviewDecisions, steeringFormatFor, unansweredNotes } from "../../steering/index.js"
|
|
38
40
|
import { captureStep, renderText } from "./text.fixture.js"
|
|
@@ -1517,3 +1519,91 @@ describe("walkthrough", () => {
|
|
|
1517
1519
|
)
|
|
1518
1520
|
})
|
|
1519
1521
|
})
|
|
1522
|
+
|
|
1523
|
+
describe("rankedReview", () => {
|
|
1524
|
+
const review = [
|
|
1525
|
+
"# Review: abc1234",
|
|
1526
|
+
"",
|
|
1527
|
+
"<!-- base: abc1234 -->",
|
|
1528
|
+
"",
|
|
1529
|
+
"## Rename",
|
|
1530
|
+
"",
|
|
1531
|
+
"- [ ] ./a.ts#1-1",
|
|
1532
|
+
"",
|
|
1533
|
+
"## Token",
|
|
1534
|
+
"",
|
|
1535
|
+
"_could_",
|
|
1536
|
+
"",
|
|
1537
|
+
"Reads the token.",
|
|
1538
|
+
"",
|
|
1539
|
+
"- [ ] ./a.ts#2-2",
|
|
1540
|
+
"",
|
|
1541
|
+
"## Fixed automatically",
|
|
1542
|
+
"",
|
|
1543
|
+
"- x (abc1234)",
|
|
1544
|
+
"",
|
|
1545
|
+
].join("\n")
|
|
1546
|
+
|
|
1547
|
+
it("puts the ranked chunks first, each led by its tier, and leaves the rest in place", () => {
|
|
1548
|
+
expect(
|
|
1549
|
+
rankedReview(unranked(review), [
|
|
1550
|
+
{ title: "Token", label: "critical: security" },
|
|
1551
|
+
{ title: "Rename", label: "could" },
|
|
1552
|
+
]),
|
|
1553
|
+
).toBe(
|
|
1554
|
+
[
|
|
1555
|
+
"# Review: abc1234",
|
|
1556
|
+
"",
|
|
1557
|
+
"<!-- base: abc1234 -->",
|
|
1558
|
+
"",
|
|
1559
|
+
"## Token",
|
|
1560
|
+
"",
|
|
1561
|
+
"_critical: security_",
|
|
1562
|
+
"",
|
|
1563
|
+
"Reads the token.",
|
|
1564
|
+
"",
|
|
1565
|
+
"- [ ] ./a.ts#2-2",
|
|
1566
|
+
"",
|
|
1567
|
+
"## Rename",
|
|
1568
|
+
"",
|
|
1569
|
+
"_could_",
|
|
1570
|
+
"",
|
|
1571
|
+
"- [ ] ./a.ts#1-1",
|
|
1572
|
+
"",
|
|
1573
|
+
"## Fixed automatically",
|
|
1574
|
+
"",
|
|
1575
|
+
"- x (abc1234)",
|
|
1576
|
+
"",
|
|
1577
|
+
].join("\n"),
|
|
1578
|
+
)
|
|
1579
|
+
})
|
|
1580
|
+
|
|
1581
|
+
it("keeps a setext heading whole", () => {
|
|
1582
|
+
expect(
|
|
1583
|
+
rankedReview("# Review: a\n\nToken\n-----\n\n- [ ] ./a.ts#1\n", [
|
|
1584
|
+
{ title: "Token", label: "could" },
|
|
1585
|
+
]),
|
|
1586
|
+
).toBe("# Review: a\n\nToken\n-----\n\n_could_\n\n- [ ] ./a.ts#1\n")
|
|
1587
|
+
})
|
|
1588
|
+
|
|
1589
|
+
it("keeps a chunk's leading indented code block indented", () => {
|
|
1590
|
+
const code = "# Review: abc1234\n\n## Code\n\n const x = 1\n\n- [ ] ./a.ts#1-1\n"
|
|
1591
|
+
expect(rankedReview(code, [{ title: "Code", label: "could" }])).toContain(
|
|
1592
|
+
"_could_\n\n const x = 1",
|
|
1593
|
+
)
|
|
1594
|
+
})
|
|
1595
|
+
|
|
1596
|
+
it("drops a stale tier with no blank line under the heading, never a longer paragraph", () => {
|
|
1597
|
+
expect(unranked("## A\n_could_\n\nbody\n")).toBe("## A\nbody\n")
|
|
1598
|
+
expect(unranked("## A\n\n_could_\n- [ ] ./a.ts#1\n")).toBe("## A\n\n- [ ] ./a.ts#1\n")
|
|
1599
|
+
const code = "## A\n\n _could_\n"
|
|
1600
|
+
expect(unranked(code)).toBe(code)
|
|
1601
|
+
const prose = "## A\n\n_should: x_\nruns on\n"
|
|
1602
|
+
expect(unranked(prose)).toBe(prose)
|
|
1603
|
+
})
|
|
1604
|
+
|
|
1605
|
+
it("drops a stale tier, so a re-review is ranked afresh", () => {
|
|
1606
|
+
expect(unranked(review)).not.toContain("_could_")
|
|
1607
|
+
expect(unranked(unranked(review))).toBe(unranked(review))
|
|
1608
|
+
})
|
|
1609
|
+
})
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
ANSWER_WONT_FIX,
|
|
20
20
|
FREE_TEXT_PLACEHOLDER,
|
|
21
21
|
reviewChunks,
|
|
22
|
+
severityLine,
|
|
22
23
|
} from "../../steering/index.js"
|
|
23
24
|
import { escapeRegExp } from "./registry.js"
|
|
24
25
|
import {
|
|
@@ -301,12 +302,78 @@ export const withWalkthrough = (review: string, report: ComposedReview): string
|
|
|
301
302
|
`## ${unique("Dropped as noise")}\n\n${report.noise.map((f) => noiseLine(f, carriedOnly(f))).join("\n")}`,
|
|
302
303
|
]),
|
|
303
304
|
]
|
|
304
|
-
// Nothing to add leaves the review byte for byte
|
|
305
|
+
// Nothing to add leaves the review byte for byte.
|
|
305
306
|
if (notes.size === listed && summary.length === 0) return review
|
|
306
307
|
const body = lines.join("\n").trimEnd()
|
|
307
308
|
return summary.length === 0 ? `${body}\n` : `${body}\n\n${summary.join("\n\n")}\n`
|
|
308
309
|
}
|
|
309
310
|
|
|
311
|
+
export interface RankedChunk {
|
|
312
|
+
readonly title: string
|
|
313
|
+
readonly label: string
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const chunkBlocks = (
|
|
317
|
+
review: string,
|
|
318
|
+
): { head: string[]; blocks: { title: string; lines: string[] }[] } => {
|
|
319
|
+
const lines = review.split("\n")
|
|
320
|
+
const chunks = reviewChunks(review)
|
|
321
|
+
return {
|
|
322
|
+
head: lines.slice(0, chunks[0]?.line ?? lines.length),
|
|
323
|
+
blocks: chunks.map((c, i) => ({
|
|
324
|
+
title: c.title,
|
|
325
|
+
lines: lines.slice(c.line, chunks[i + 1]?.line ?? lines.length),
|
|
326
|
+
})),
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const isBlank = (line: string | undefined): boolean => line !== undefined && line.trim() === ""
|
|
331
|
+
|
|
332
|
+
/** \`review\` with every chunk's severity line removed, so a re-review is ranked afresh. */
|
|
333
|
+
export const unranked = (review: string): string => {
|
|
334
|
+
const drop = new Set(reviewChunks(review).flatMap((c) => c.severityLine ?? []))
|
|
335
|
+
if (drop.size === 0) return review
|
|
336
|
+
const lines = review.split("\n")
|
|
337
|
+
return lines.filter((_, i) => !drop.has(i) && !(drop.has(i - 1) && isBlank(lines[i]))).join("\n")
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export const chunkTexts = (review: string): ReadonlyMap<string, string> =>
|
|
341
|
+
new Map(chunkBlocks(review).blocks.map((b) => [b.title, b.lines.join("\n").trim()]))
|
|
342
|
+
|
|
343
|
+
// Blank lines only: a chunk opening with an indented code block keeps its indent.
|
|
344
|
+
const trimBlankLines = (lines: readonly string[]): string[] => {
|
|
345
|
+
let start = 0
|
|
346
|
+
let end = lines.length
|
|
347
|
+
while (start < end && isBlank(lines[start])) start++
|
|
348
|
+
while (end > start && isBlank(lines[end - 1])) end--
|
|
349
|
+
return lines.slice(start, end)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* \`review\` with the \`ranked\` chunks first, in their order, each led by its
|
|
354
|
+
* severity line; every other chunk follows in place.
|
|
355
|
+
*/
|
|
356
|
+
export const rankedReview = (review: string, ranked: readonly RankedChunk[]): string => {
|
|
357
|
+
const { head, blocks } = chunkBlocks(review)
|
|
358
|
+
const place = new Map(ranked.map((r, k) => [r.title, k]))
|
|
359
|
+
const labels = new Map(ranked.map((r) => [r.title, r.label]))
|
|
360
|
+
const order = (i: number): number => place.get(blocks[i]!.title) ?? ranked.length
|
|
361
|
+
const chunk = (i: number): string => {
|
|
362
|
+
const { title, lines } = blocks[i]!
|
|
363
|
+
const label = labels.get(title)
|
|
364
|
+
// A setext heading is its title line and its underline.
|
|
365
|
+
const heading = /^ {0,3}-+\s*$/.test(lines[1] ?? "") ? 2 : 1
|
|
366
|
+
const body = trimBlankLines(lines.slice(heading)).join("\n")
|
|
367
|
+
return [
|
|
368
|
+
lines.slice(0, heading).join("\n"),
|
|
369
|
+
...(label === undefined ? [] : [severityLine(label)]),
|
|
370
|
+
...(body === "" ? [] : [body]),
|
|
371
|
+
].join("\n\n")
|
|
372
|
+
}
|
|
373
|
+
const sorted = blocks.map((_, i) => i).sort((x, y) => order(x) - order(y) || x - y)
|
|
374
|
+
return `${[head.join("\n").trim(), ...sorted.map(chunk)].filter((p) => p !== "").join("\n\n")}\n`
|
|
375
|
+
}
|
|
376
|
+
|
|
310
377
|
/**
|
|
311
378
|
* \`.gtd/REVIEW.md\` as gtd writes it: one chunk per note, each ending in its
|
|
312
379
|
* answer boxes, then the fixed and noise lists. Chunk titles are made unique —
|
|
@@ -1074,6 +1141,15 @@ const wrapItem = (item: string): string[] => {
|
|
|
1074
1141
|
return [...out, line]
|
|
1075
1142
|
}
|
|
1076
1143
|
|
|
1144
|
+
export const reviewRankingMessage = (): string =>
|
|
1145
|
+
`Ranking each chunk of the review before the human reads it. Run
|
|
1146
|
+
\`gtd judge answer\` and pipe, per chunk \`C<n>\`: \`C<n>.security\`
|
|
1147
|
+
(yes/no), \`C<n>.reversibility\` (one-way/two-way),
|
|
1148
|
+
\`C<n>.blastRadius\` (local/module/contract) and \`C<n>.newConcept\`
|
|
1149
|
+
(yes/no) — or land without a verdict, which lists every chunk as
|
|
1150
|
+
critical.
|
|
1151
|
+
`
|
|
1152
|
+
|
|
1077
1153
|
export const buildReviewTriageMessage = (): string =>
|
|
1078
1154
|
`Judging each note the human added to \`.gtd/REVIEW.md\` (a line note, a
|
|
1079
1155
|
footnote, or prose under a chunk) with one verdict: \`edit\` (a change
|