@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.
Files changed (32) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +31 -26
  3. package/dist/chunks/{reserved-CYZ1bSBy.mjs → reserved-CdOzKFVX.mjs} +59 -2
  4. package/dist/chunks/{review-BewyJflS.mjs → review-BChXEl2Y.mjs} +84 -20
  5. package/dist/chunks/{steering-BXwjmocB.mjs → steering-DtUVUz9l.mjs} +44 -14
  6. package/dist/chunks/{tail-C1Oi2YEy.mjs → tail-DRM2eC1z.mjs} +1 -1
  7. package/dist/gtd.bundle.mjs +22 -5
  8. package/dist/steering.mjs +2 -2
  9. package/dist/testkit.mjs +1 -1
  10. package/dist/web/main.js +27 -3
  11. package/dist/workflow-kit.mjs +1 -1
  12. package/dist/workflows/cleanup.mjs +2 -2
  13. package/dist/workflows/feature.mjs +3 -3
  14. package/dist/workflows/fix.mjs +3 -3
  15. package/dist/workflows/review.mjs +3 -3
  16. package/dist/workflows/rollout.mjs +1 -1
  17. package/dist/workflows.mjs +181 -30
  18. package/package.json +1 -1
  19. package/src/steering/index.ts +1 -0
  20. package/src/steering/review.test.ts +60 -2
  21. package/src/steering/review.ts +66 -9
  22. package/src/workflows/feature/lens-review.feature +16 -4
  23. package/src/workflows/feature/review-answers.feature +8 -1
  24. package/src/workflows/feature/review-gate.feature +20 -3
  25. package/src/workflows/feature/reviewer-walkthrough.feature +14 -3
  26. package/src/workflows/feature/severity-budget.feature +111 -0
  27. package/src/workflows/feature/severity-sorted-review.feature +197 -0
  28. package/src/workflows/feature/slice-findings.feature +14 -2
  29. package/src/workflows/feature/standards.feature +4 -0
  30. package/src/workflows/shared/review.ts +124 -18
  31. package/src/workflows/shared/text.test.ts +90 -0
  32. 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.carrying
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.carrying
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 = (note: ReviewNote, judgment: Judgment, minP: number): Severity => {
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 { tier: "critical", reason: `critical: ${critical.join(", ")}`, rank: rank() }
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
- const severityQuestions = (note: ReviewNote): JudgeQuestion[] => [
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: `${note.id}.security`,
116
+ id: `${id}.security`,
106
117
  primitive: "noul",
107
- instructions: `Does the change the human asks for in note "${note.id}" (on "${note.anchor}") touch security — authentication, authorisation, secrets, untrusted input or data exposure?`,
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: `${note.id}.reversibility`,
122
+ id: `${id}.reversibility`,
112
123
  primitive: "choice",
113
- instructions: `Once the change in note "${note.id}" ships, can it be undone without breaking anyone who already depends on it?`,
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: `${note.id}.blastRadius`,
129
+ id: `${id}.blastRadius`,
119
130
  primitive: "choice",
120
- instructions: `How far does the change in note "${note.id}" reach?`,
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: `${note.id}.newConcept`,
136
+ id: `${id}.newConcept`,
126
137
  primitive: "noul",
127
- instructions: `Does the change in note "${note.id}" 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.`,
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) => [verdictQuestion(n), ...severityQuestions(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 review = read(REVIEW)
369
+ const written = read(REVIEW)
267
370
  // A review the reviewer did not write is the round's own refusal or rest.
268
- if (review === undefined) return
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
- review,
375
+ written,
271
376
  notes,
272
377
  changesSince(base).some((c) => isCode(c.path)),
273
378
  )
274
- const full = t.withWalkthrough(review, report)
275
- if (full !== review) {
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: "Adding the quality lap's findings",
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, so no carrying step runs.
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