@pmelab/gtd 55.0.0 → 56.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 (61) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +60 -61
  3. package/dist/chunks/{reserved-DHafcW7t.mjs → reserved-B8EC-WE8.mjs} +77 -203
  4. package/dist/chunks/{review-BJGKwlYv.mjs → review-HdrX0FJ7.mjs} +30 -125
  5. package/dist/chunks/{steering-CLhu8C7q.mjs → steering-CCNvngSH.mjs} +256 -65
  6. package/dist/chunks/{tail-B9Hw2AFB.mjs → tail-BSLah2mA.mjs} +144 -56
  7. package/dist/gtd.bundle.mjs +259 -22
  8. package/dist/steering.mjs +2 -2
  9. package/dist/testkit.mjs +6 -6
  10. package/dist/web/main.css +1 -1
  11. package/dist/web/main.js +551 -66
  12. package/dist/workflow-kit.mjs +1 -1
  13. package/dist/workflows/cleanup.mjs +2 -2
  14. package/dist/workflows/feature.mjs +14 -16
  15. package/dist/workflows/fix.mjs +3 -3
  16. package/dist/workflows/review.mjs +3 -3
  17. package/dist/workflows/rollout.mjs +1 -1
  18. package/dist/workflows.mjs +522 -453
  19. package/package.json +1 -1
  20. package/skills/authoring/SKILL.md +3 -3
  21. package/src/cli/Cli.ts +5 -5
  22. package/src/edge/Edge.test.ts +1 -1
  23. package/src/steering/FORMATS.md +5 -1
  24. package/src/steering/SteeringFormats.test.ts +2 -2
  25. package/src/steering/contract.test.ts +1 -0
  26. package/src/steering/findings.test.ts +47 -0
  27. package/src/steering/findings.ts +350 -0
  28. package/src/steering/fixtures/findings/gate.md +34 -0
  29. package/src/steering/fixtures/findings/gate.read.json +87 -0
  30. package/src/steering/index.test.ts +2 -2
  31. package/src/steering/index.ts +4 -3
  32. package/src/steering/review.test.ts +1 -75
  33. package/src/steering/review.ts +1 -74
  34. package/src/workflow/compile.test.ts +1 -0
  35. package/src/workflows/feature/criteria-coverage.feature +1 -6
  36. package/src/workflows/feature/feature.test.ts +1 -1
  37. package/src/workflows/feature/feature.ts +12 -20
  38. package/src/workflows/feature/lens-review.feature +323 -171
  39. package/src/workflows/feature/refactor-absorbs.feature +1 -7
  40. package/src/workflows/feature/refactor-first.feature +2 -10
  41. package/src/workflows/feature/refactor-rebuild.feature +4 -24
  42. package/src/workflows/feature/refactor-review.feature +33 -117
  43. package/src/workflows/feature/refactor.test.ts +0 -2
  44. package/src/workflows/feature/refactor.ts +0 -1
  45. package/src/workflows/feature/review-gate.feature +6 -22
  46. package/src/workflows/feature/reviewer-walkthrough.feature +16 -103
  47. package/src/workflows/feature/standards.feature +75 -34
  48. package/src/workflows/fix/fix-lens-origin.feature +20 -7
  49. package/src/workflows/fix/fix-quality.feature +102 -23
  50. package/src/workflows/review/review-door.feature +62 -59
  51. package/src/workflows/shared/learn.test.ts +0 -14
  52. package/src/workflows/shared/learn.ts +1 -6
  53. package/src/workflows/shared/lenses.ts +149 -43
  54. package/src/workflows/shared/review.test.ts +27 -26
  55. package/src/workflows/shared/review.ts +29 -196
  56. package/src/workflows/shared/tail.ts +18 -9
  57. package/src/workflows/shared/text.test.ts +103 -288
  58. package/src/workflows/shared/text.ts +131 -329
  59. package/src/workflows/feature/review-answers.feature +0 -324
  60. package/src/workflows/feature/severity-budget.feature +0 -116
  61. package/src/workflows/feature/severity-sorted-review.feature +0 -198
@@ -47,7 +47,7 @@ interface Drive {
47
47
  readonly vars?: Readonly<Record<string, string>>
48
48
  /** The REVIEW.md the human leaves; defaults to `notes` laid over the baseline. */
49
49
  readonly after?: string
50
- /** The REVIEW.md each `review.composing` turn writes, in order; also raises the review-turn stop to one past the last. */
50
+ /** The REVIEW.md each `review.reviewing` turn writes, in order; also raises the review-turn stop to one past the last. */
51
51
  readonly reviewDocs?: readonly (string | undefined)[]
52
52
  /** Let the run pass the second gate, stopping at the first range read after it. */
53
53
  readonly passSecondGate?: boolean
@@ -94,7 +94,7 @@ const drive = async (d: Drive): Promise<Run> => {
94
94
  let awaits = 0
95
95
  let reviews = 0
96
96
  const effects: Record<string, () => void> = {
97
- "review.composing": () => {
97
+ "review.reviewing": () => {
98
98
  const docs = d.reviewDocs
99
99
  if (++reviews > (docs?.length ?? 0) + (docs === undefined ? 1 : 0)) throw new Stop()
100
100
  const written = docs?.[reviews - 1]
@@ -184,10 +184,10 @@ describe("the risk-fix pass", () => {
184
184
  it("a marked risk is fixed, kept green, re-reviewed, then rests at the gate", async () => {
185
185
  const run = await drive({ notes: [], reviewDocs: [risky, clean] })
186
186
  expect(run.log.slice(0, 5)).toEqual([
187
- "review.composing",
187
+ "review.reviewing",
188
188
  "review.fix.risks.fixing",
189
189
  "health.check",
190
- "review.composing",
190
+ "review.reviewing",
191
191
  "review.await-review",
192
192
  ])
193
193
  expect(run.prompts.get("review.fix.risks.fixing")).toContain("risk-1")
@@ -199,10 +199,10 @@ describe("the risk-fix pass", () => {
199
199
  const run = await drive({ notes: [], reviewDocs: [risky, risky] })
200
200
  expect(run.log.filter((n) => n === "review.fix.risks.fixing")).toHaveLength(1)
201
201
  expect(run.log.slice(0, 5)).toEqual([
202
- "review.composing",
202
+ "review.reviewing",
203
203
  "review.fix.risks.fixing",
204
204
  "health.check",
205
- "review.composing",
205
+ "review.reviewing",
206
206
  "review.await-review",
207
207
  ])
208
208
  })
@@ -232,7 +232,7 @@ describe("the risk-fix pass", () => {
232
232
  it("no marker means no fix-risks step", async () => {
233
233
  const run = await drive({ notes: [], reviewDocs: [clean] })
234
234
  expect(run.log).not.toContain("review.fix.risks.fixing")
235
- expect(run.log[0]).toBe("review.composing")
235
+ expect(run.log[0]).toBe("review.reviewing")
236
236
  expect(run.log[1]).toBe("review.await-review")
237
237
  })
238
238
 
@@ -243,16 +243,16 @@ describe("the risk-fix pass", () => {
243
243
  reviewDocs: [undefined, risky, clean],
244
244
  })
245
245
  expect(run.log).toEqual([
246
- "review.composing",
246
+ "review.reviewing",
247
247
  "review.await-review",
248
248
  "review.triage",
249
249
  "review.fix.nits.fixing",
250
250
  "health.check",
251
251
  "review.closing",
252
- "review.composing",
252
+ "review.reviewing",
253
253
  "review.fix.risks.fixing",
254
254
  "health.check",
255
- "review.composing",
255
+ "review.reviewing",
256
256
  "review.await-review",
257
257
  ])
258
258
  })
@@ -264,8 +264,9 @@ describe("the guard", () => {
264
264
 
265
265
  it("runs right after the risk-fix turn", async () => {
266
266
  const run = await drive({ notes: [], reviewDocs: [risky, clean], guarded: true })
267
- expect(run.log.slice(0, 4)).toEqual([
268
- "review.composing",
267
+ expect(run.log.slice(0, 5)).toEqual([
268
+ "review.reviewing",
269
+ "guard",
269
270
  "review.fix.risks.fixing",
270
271
  "guard",
271
272
  "health.check",
@@ -278,7 +279,7 @@ describe("the guard", () => {
278
279
  answers: { "note-1": verdict("nit") },
279
280
  guarded: true,
280
281
  })
281
- expect(run.log.slice(2, 6)).toEqual([
282
+ expect(run.log.slice(3, 7)).toEqual([
282
283
  "review.triage",
283
284
  "review.fix.nits.fixing",
284
285
  "guard",
@@ -443,7 +444,7 @@ describe("review verdict routing", () => {
443
444
  it("an unanswered note counts as edit", async () => {
444
445
  const run = await drive({ notes: ["add — typo", "sub", "mul"], answers: {} })
445
446
  expect(run.log).toEqual([
446
- "review.composing",
447
+ "review.reviewing",
447
448
  "review.await-review",
448
449
  "review.triage",
449
450
  "review.closing",
@@ -478,7 +479,7 @@ describe("review verdict routing", () => {
478
479
  it("only praise closes and signs off with no agent turn", async () => {
479
480
  const run = await drive({ notes, answers: { "note-1": verdict("praise") } })
480
481
  expect(run.log).toEqual([
481
- "review.composing",
482
+ "review.reviewing",
482
483
  "review.await-review",
483
484
  "review.triage",
484
485
  "review.closing",
@@ -492,7 +493,7 @@ describe("review verdict routing", () => {
492
493
  answers: { "note-1": verdict("question"), "note-2": verdict("question") },
493
494
  })
494
495
  expect(run.log).toEqual([
495
- "review.composing",
496
+ "review.reviewing",
496
497
  "review.await-review",
497
498
  "review.triage",
498
499
  "review.answer-review-questions",
@@ -508,17 +509,17 @@ describe("review verdict routing", () => {
508
509
  answers: { "note-1": verdict("question"), "note-2": verdict("nit") },
509
510
  })
510
511
  expect(run.log).toEqual([
511
- "review.composing",
512
+ "review.reviewing",
512
513
  "review.await-review",
513
514
  "review.triage",
514
515
  "review.answer-review-questions",
515
516
  "review.fix.nits.fixing",
516
517
  "health.check",
517
518
  "review.closing",
518
- "review.composing",
519
+ "review.reviewing",
519
520
  ])
520
521
  expect(run.prompts.get("review.fix.nits.fixing")).not.toContain("why?")
521
- expect(run.prompts.get("review.composing")).toContain("git show 'c4:.gtd/REVIEW.md'")
522
+ expect(run.prompts.get("review.reviewing")).toContain("Run: git show c4")
522
523
  expect(run.result).toBe("stopped")
523
524
  })
524
525
 
@@ -527,8 +528,8 @@ describe("review verdict routing", () => {
527
528
  notes: ["add — typo", "sub", "mul"],
528
529
  answers: { "note-1": verdict("nit") },
529
530
  })
530
- expect(run.prompts.get("review.composing")).not.toContain("git show")
531
- expect(run.prompts.get("review.composing")).toContain("## Fixed from your notes")
531
+ expect(run.prompts.get("review.reviewing")).not.toContain("git show")
532
+ expect(run.prompts.get("review.reviewing")).toContain("worked on the human's nits")
532
533
  })
533
534
 
534
535
  it("edits run after questions and nits, and the capture names only the edits and the answering commit", async () => {
@@ -537,7 +538,7 @@ describe("review verdict routing", () => {
537
538
  answers: { "note-2": verdict("question"), "note-3": verdict("nit") },
538
539
  })
539
540
  expect(run.log).toEqual([
540
- "review.composing",
541
+ "review.reviewing",
541
542
  "review.await-review",
542
543
  "review.triage",
543
544
  "review.answer-review-questions",
@@ -557,7 +558,7 @@ describe("review verdict routing", () => {
557
558
  it("a noted round with no extractable note collects as one edit, without judging", async () => {
558
559
  const run = await drive({ notes: [], after: BEFORE + "\nSome stray prose.\n" })
559
560
  expect(run.log).toEqual([
560
- "review.composing",
561
+ "review.reviewing",
561
562
  "review.await-review",
562
563
  "review.closing",
563
564
  "review.collecting",
@@ -682,7 +683,7 @@ describe("severity routing", () => {
682
683
  answers: { "note-1": verdict("edit"), ...sev("note-1", could) },
683
684
  })
684
685
  expect(run.log).toEqual([
685
- "review.composing",
686
+ "review.reviewing",
686
687
  "review.await-review",
687
688
  "review.triage",
688
689
  "review.closing",
@@ -762,7 +763,7 @@ describe("BuildTailOptions.lensBase", () => {
762
763
  if (request.kind === "restart") return Promise.resolve()
763
764
  if (request.kind === "agent" && request.name.startsWith("quality."))
764
765
  prompts.push(request.prompt)
765
- if (request.name === "review.composing") throw new Stop()
766
+ if (request.name === "review.reviewing") throw new Stop()
766
767
  return Promise.resolve()
767
768
  },
768
769
  pushScope: () => undefined,
@@ -1000,7 +1001,7 @@ describe("buildTail without rules", () => {
1000
1001
  if (request.kind === "agent") prompts.set(request.name, request.prompt)
1001
1002
  if (request.name === "health.check") checks++
1002
1003
  if (request.name === "fix.quality.fixing") files.delete(".gtd/QUALITY.md")
1003
- if (request.name === "review.composing" || request.name === "health.stop")
1004
+ if (request.name === "review.reviewing" || request.name === "health.stop")
1004
1005
  throw new Stop()
1005
1006
  return Promise.resolve(
1006
1007
  request.kind === "judge"
@@ -9,7 +9,6 @@ import {
9
9
  requireRevert,
10
10
  restoreScript,
11
11
  run,
12
- showScript,
13
12
  vars,
14
13
  writeScript,
15
14
  type JudgeQuestion,
@@ -21,7 +20,6 @@ import {
21
20
  reviewChunks,
22
21
  reviewRisks,
23
22
  reviewSections,
24
- sectionedReview,
25
23
  type ReviewNote,
26
24
  } from "../../steering/index.js"
27
25
  import * as kit from "../../workflow-kit/index.js"
@@ -195,214 +193,59 @@ const triage =
195
193
  )
196
194
  }
197
195
 
198
- /** Review order: security, one-way doors, the judge unsure, then should, then could. */
199
- const chunkKey = (s: Severity): number =>
200
- s.tier === "could" ? 4 : s.tier === "should" ? 3 : s.security === true ? 0 : s.rank === 0 ? 1 : 2
201
-
202
- type Pointer = ReturnType<typeof reviewChunks>[number]["files"][number]
203
-
204
- /** A chunk's text, then the code each of its pointers covers: a bare path the whole file. */
205
- const chunkEvidence = (text: string, files: readonly Pointer[]): string =>
206
- [
207
- text,
208
- ...files.map(({ path, line, rangeEnd }) => {
209
- const where = `${path}${line === undefined ? "" : `#${line}${rangeEnd === undefined ? "" : `-${rangeEnd}`}`}`
210
- const lines = read(path.replace(/^\.\//, ""))?.split("\n")
211
- if (lines === undefined) return `${where}: no longer exists`
212
- const code = line === undefined ? lines : lines.slice(line - 1, rangeEnd ?? line)
213
- return `${where}:\n${code.join("\n")}`
214
- }),
215
- ].join("\n\n")
216
-
217
- const bytes = (text: string): number => new TextEncoder().encode(text).length
218
-
219
- /** Fills each batch while the budget's even share still holds its widest evidence, so nothing is cut that fits alone. */
220
- const batched = <T extends { readonly evidence: string }>(
221
- items: readonly T[],
222
- budget: number,
223
- ): T[][] => {
224
- const batches: T[][] = []
225
- let batch: T[] = []
226
- let widest = 0
227
- for (const item of items) {
228
- const size = bytes(item.evidence)
229
- if (batch.length > 0 && Math.floor(budget / (batch.length + 1)) < Math.max(widest, size)) {
230
- batches.push(batch)
231
- batch = []
232
- widest = 0
233
- }
234
- batch.push(item)
235
- widest = Math.max(widest, size)
236
- }
237
- return batch.length === 0 ? batches : [...batches, batch]
238
- }
239
-
240
- /**
241
- * Rates every chunk the reviewer wrote and sorts the \`findings\` among them
242
- * first in \`full\`, each led by its tier and reason; a walkthrough chunk
243
- * keeps its place and carries no tier. It is still rated, so a review with no
244
- * findings still reaches \`review.ranking\` and older histories replay.
245
- */
246
- const ranked = async (
247
- review: string,
248
- full: string,
249
- findings: ReadonlySet<string>,
250
- ): Promise<string> => {
251
- const texts = t.chunkTexts(full)
252
- const chunks = reviewChunks(review).map((c, i) => ({
253
- id: `C${i + 1}`,
254
- title: c.title,
255
- evidence: chunkEvidence(texts.get(c.title) ?? "", c.files),
256
- }))
257
- if (chunks.length === 0) return full
258
- const minP = numeric(vars.reviewSeverityMinP, Infinity)
259
- const batches = batched(chunks, numeric(vars.judgeBudgetBytes, 32768))
260
- const severities = new Map<string, Severity>()
261
- for (const [i, batch] of batches.entries()) {
262
- const judgment = await judge("review.ranking", {
263
- questions: batch.flatMap((c) =>
264
- severityQuestions(
265
- c.id,
266
- `the change review chunk "${c.id}" ("${c.title}") walks through`,
267
- `the change in review chunk "${c.id}"`,
268
- ),
269
- ),
270
- evidence: Object.fromEntries(batch.map((c) => [c.id, c.evidence])),
271
- message: t.reviewRankingMessage(),
272
- label:
273
- batches.length === 1
274
- ? "Ranking the review"
275
- : `Ranking the review (${i + 1}/${batches.length})`,
276
- })
277
- for (const c of batch) severities.set(c.id, severityOf(c, judgment, minP))
278
- }
279
- return t.rankedReview(
280
- full,
281
- chunks
282
- .map((c, i) => ({ c, i, s: severities.get(c.id)! }))
283
- .filter(({ c }) => findings.has(c.title))
284
- .sort((x, y) => chunkKey(x.s) - chunkKey(y.s) || x.i - y.i)
285
- .map(({ c, s }) => ({ title: c.title, label: s.reason })),
286
- )
287
- }
288
-
289
196
  export const isCode = (path: string): boolean => path !== ".gtd" && !path.startsWith(".gtd/")
290
197
 
291
- const composedOf = (
292
- base: string,
293
- report: LensReport,
294
- nitFix: string | undefined,
295
- answered: ReadonlySet<string>,
296
- ): t.ComposedReview => ({
297
- ...report,
298
- nitFix,
299
- answered,
300
- standards: {
301
- added: standardsAddedSince(base),
302
- undecided: parseStandards(read(STANDARDS) ?? "").undecided,
303
- },
304
- })
305
-
306
- /**
307
- * gtd writes `.gtd/REVIEW.md` from the quality lap's `report` — no reviewer
308
- * turn. A re-review
309
- * after fixed nits restores the doc as committed at `carry`, answers kept;
310
- * a fresh one leaves out the notes the human already answered.
311
- */
312
- const composing = (
313
- base: string,
314
- report: LensReport,
315
- carry?: string,
316
- nitFix?: string,
317
- answered: ReadonlySet<string> = new Set(),
318
- ): Promise<void> =>
319
- run(
320
- "review.composing",
321
- carry === undefined
322
- ? writeScript(
323
- REVIEW,
324
- t.composedReview(base, {
325
- ...composedOf(base, report, nitFix, answered),
326
- weakened: kit.removedAssertions(changesSince(base)),
327
- }),
328
- )
329
- : showScript(carry, REVIEW, REVIEW),
330
- { label: "Composing the review", file: REVIEW, base },
331
- )
332
-
333
- /**
334
- * Refuses a review with a chunk pointing at no code, or a lap note without a
335
- * chunk of its own. A range with no code change has nothing to point at, so
336
- * there only a note's chunk needs a pointer.
337
- */
338
- const requirePointers = (
339
- review: string,
340
- notes: readonly { title: string }[],
341
- codeChanged: boolean,
342
- ): void => {
198
+ /** Refuses a review with a chunk pointing at no code; a range with no code change has nothing to point at. */
199
+ const requirePointers = (review: string, codeChanged: boolean): void => {
343
200
  const chunks = reviewChunks(review)
344
- const titles = new Set(chunks.map((c) => c.title))
345
- const noted = new Set(notes.map((n) => n.title))
346
201
  const problems = [
347
202
  ...chunks
348
- .filter((c) => c.pointers === 0 && (codeChanged || noted.has(c.title)))
203
+ .filter((c) => c.pointers === 0 && codeChanged)
349
204
  .map((c) => ` - ${REVIEW}:${c.line + 1}: ${c.title} — points at no code`),
350
205
  ...chunks
351
206
  .filter((c) => isConflictTitle(c.title))
352
207
  .map((c) => ` - ${REVIEW}:${c.line + 1}: ${c.title} — gtd writes conflicts; leave it out`),
353
- ...notes.filter((n) => !titles.has(n.title)).map((n) => ` - ${n.title} — no chunk of its own`),
354
208
  // Notes and answers are matched to their chunk by title.
355
209
  ...chunks
356
210
  .filter((c, i) => chunks.findIndex((d) => d.title === c.title) !== i)
357
211
  .map((c) => ` - ${REVIEW}:${c.line + 1}: ${c.title} — a second chunk with this title`),
358
212
  ...reviewSections(review).map(
359
213
  (s) =>
360
- ` - ${REVIEW}:${s.line + 1}: ${REVIEW_SECTIONS[s.section]} — gtd sorts the chunks into sections; write \`## \` chunks only`,
214
+ ` - ${REVIEW}:${s.line + 1}: ${REVIEW_SECTIONS[s.section]} — write \`## \` chunks only`,
361
215
  ),
362
- // A risk is fixed before the human sees it; a note is the human's to decide.
363
- ...reviewRisks(review)
364
- .filter((r) => [...noted].some((title) => r.anchor.startsWith(`${title} ./`)))
365
- .map((r) => ` - ${r.anchor} — no \`Risk:\` in a quality finding's chunk`),
366
216
  ]
367
217
  if (problems.length === 0) return
368
218
  refuse(
369
- `gtd land: review-pointers: ${problems.length} problem(s) in ${REVIEW} — every chunk needs a pointer to its code, and every quality finding its own chunk, headed exactly as the prompt gave it:\n${problems.join("\n")}`,
219
+ `gtd land: review-pointers: ${problems.length} problem(s) in ${REVIEW} — every chunk needs a pointer to its code:\n${problems.join("\n")}`,
370
220
  )
371
221
  }
372
222
 
373
223
  /**
374
224
  * A reviewer walks the human through everything since `base`, every chunk
375
- * pointing at its code and each of the lap's notes a chunk of its own; gtd
376
- * then adds each note's text and answer boxes, and the summary chunks.
225
+ * pointing at its code; gtd then appends the summary chunks.
377
226
  */
378
227
  const walkthrough = async (
379
228
  base: string,
380
- report: t.ComposedReview,
229
+ summary: t.ReviewSummary,
381
230
  lap: t.ReviewRange,
382
231
  carry: string | undefined,
232
+ nitFix: string | undefined,
383
233
  afterRiskFix: boolean,
384
234
  ): Promise<void> => {
385
- const notes = t.titledNotes(report)
386
- await reviewing(base, carry, lap, t.walkthroughBlock(notes, report.nitFix))
235
+ await reviewing(base, carry, lap, t.nitFixBlock(nitFix))
387
236
  const written = read(REVIEW)
388
237
  // A review the reviewer did not write is the round's own refusal or rest.
389
238
  if (written === undefined) return
390
- const review = t.unranked(written)
391
- // Against the file as written, so a refusal's line numbers match it.
392
239
  requirePointers(
393
240
  written,
394
- notes,
395
241
  changesSince(base).some((c) => isCode(c.path)),
396
242
  )
397
- // Its risks are fixed and the review written again, so the re-reviewer edits this draft, never gtd's layout.
243
+ // Its risks are fixed and the review written again, so the re-reviewer edits this draft, never gtd's additions.
398
244
  if (!afterRiskFix && reviewRisks(written).length > 0) return
399
- const full = sectionedReview(
400
- await ranked(review, t.withWalkthrough(review, report), new Set(notes.map((n) => n.title))),
401
- new Set(reviewChunks(review).map((c) => c.title)),
402
- )
245
+ const full = t.withSummary(written, summary)
403
246
  if (full !== written) {
404
247
  await run("review.carrying", writeScript(REVIEW, full), {
405
- label: "Ordering the review and adding the quality lap's findings",
248
+ label: "Adding the quality lap's summary",
406
249
  file: REVIEW,
407
250
  base,
408
251
  })
@@ -410,12 +253,10 @@ const walkthrough = async (
410
253
  }
411
254
 
412
255
  /**
413
- * The quality lap's notes reach a human over everything since `base`, who
414
- * answers each one — fix as suggested, won't fix, or their own answer — and
415
- * may comment. A fix joins the nit batch, a won't-fix is dropped, and an own
416
- * answer or comment is judged: edits end the review as `feedback` for the
417
- * caller to build, questions are answered in place, nits fixed in one batch,
418
- * praise dropped.
256
+ * A reviewer's walkthrough of everything since `base` reaches a human, who
257
+ * may comment: each note is judged — edits end the review as `feedback` for
258
+ * the caller to build, questions are answered in place, nits fixed in one
259
+ * batch, praise dropped.
419
260
  */
420
261
  export const humanReview = (
421
262
  base: string,
@@ -432,8 +273,6 @@ export const humanReview = (
432
273
  const severities = new Map<string, Severity>()
433
274
  // Set by a nit fix, so the fresh review that follows names its commit.
434
275
  let nitFix: string | undefined
435
- // Every note the human answered in this review, left out of a fresh one.
436
- const answered = new Set<string>()
437
276
  // Set by a risk fix, so the review written next reaches the human whatever it marks.
438
277
  let riskFixed = false
439
278
  return kit.review(base, {
@@ -444,20 +283,17 @@ export const humanReview = (
444
283
  const fixedAt = nitFix
445
284
  const afterRiskFix = riskFixed
446
285
  riskFixed = false
447
- const report = options.report ?? NO_FINDINGS
448
- return options.walkthrough === true
449
- ? guarded(
450
- () =>
451
- walkthrough(
452
- b,
453
- composedOf(b, report, fixedAt, answered),
454
- options.lap ?? "first",
455
- carry,
456
- afterRiskFix,
457
- ),
458
- guard,
459
- )()
460
- : composing(b, report, carry, fixedAt, answered)
286
+ const summary: t.ReviewSummary = {
287
+ report: options.report ?? NO_FINDINGS,
288
+ standards: {
289
+ added: standardsAddedSince(b),
290
+ undecided: parseStandards(read(STANDARDS) ?? "").undecided,
291
+ },
292
+ }
293
+ return guarded(
294
+ () => walkthrough(b, summary, options.lap ?? "first", carry, fixedAt, afterRiskFix),
295
+ guard,
296
+ )()
461
297
  },
462
298
  fixRisks: guarded((notes) => {
463
299
  riskFixed = true
@@ -472,7 +308,6 @@ export const humanReview = (
472
308
  reviewMissing,
473
309
  collecting: feedback.collecting,
474
310
  triage: triage(severities),
475
- decided: (decisions) => decisions.forEach((d) => answered.add(d.anchor)),
476
311
  settles: settlesStandard,
477
312
  learn: learnStandards,
478
313
  pending: standardsPending,
@@ -526,11 +361,9 @@ export interface BuildTailOptions extends TailOptions {
526
361
  readonly feedback?: FeedbackTarget | undefined
527
362
  /** Write access for the nit and risk fix turns; absent: the bundled `access` export decides. */
528
363
  readonly fixAccess?: ScopeAccess | undefined
529
- /** Which round a `walkthrough` reviewer writes; absent: `first`. */
364
+ /** Which round the reviewer writes; absent: `first`. */
530
365
  readonly lap?: t.ReviewRange | undefined
531
- /** A reviewer turn writes the review as a walkthrough of the code, the lap's notes among its chunks; absent: gtd composes it from the notes alone. */
532
- readonly walkthrough?: boolean | undefined
533
- /** What the triaged quality lap left for the review; absent: nothing. */
366
+ /** What the quality lap and its finding gate did, for the review's summary; absent: nothing. */
534
367
  readonly report?: LensReport | undefined
535
368
  }
536
369
 
@@ -8,6 +8,7 @@ import {
8
8
  vars,
9
9
  writeScript,
10
10
  } from "../../flows/index.js"
11
+ import { reviewRisks } from "../../steering/index.js"
11
12
  import * as kit from "../../workflow-kit/index.js"
12
13
  import { guarded, healthy, type EscalationCount, type Guard } from "./health.js"
13
14
  import {
@@ -108,8 +109,8 @@ export interface TailOptions {
108
109
 
109
110
  /**
110
111
  * The build tail: fix (when entered red), keep green, the quality lap — a
111
- * judge sorts its findings, the mechanical ones are fixed, noise dropped —
112
- * then `finish` with the rest as notes: a human review, or carrying them on.
112
+ * judge sorts its findings and the mechanical ones are fixed — then the
113
+ * finding gate over the rest, then `finish` with what became of them all.
113
114
  */
114
115
  export function tail<T>(
115
116
  finish: (escalations: EscalationCount, report: LensReport) => Promise<T>,
@@ -214,22 +215,29 @@ export const handOffReview = (
214
215
  escalations: EscalationCount,
215
216
  guard?: Guard,
216
217
  report: LensReport = NO_FINDINGS,
217
- ): Promise<void> =>
218
- kit.reviewToHandOff(base, {
218
+ ): Promise<void> => {
219
+ // Set by a risk fix, so the re-review written next is summarised whatever it marks.
220
+ let riskFixed = false
221
+ return kit.reviewToHandOff(base, {
219
222
  file: REVIEW,
220
223
  reviewing: guarded(async (b, carry) => {
224
+ const afterRiskFix = riskFixed
225
+ riskFixed = false
221
226
  await reviewing(b, carry)
222
- // gtd adds the lap's chunks itself: .gtd/QUALITY.md is gone, so a reviewer leaving one out would lose it.
223
227
  const review = read(REVIEW)
224
- if (review === undefined) return
225
- const full = t.withLapFindings(review, report)
228
+ // A risk-marked draft is fixed and written again first: the re-reviewer edits it, never gtd's summary.
229
+ if (review === undefined || (!afterRiskFix && reviewRisks(review).length > 0)) return
230
+ const full = t.withSummary(review, { report })
226
231
  if (full !== review) {
227
232
  await run("review.carrying", writeScript(REVIEW, full), {
228
- label: "Adding the quality lap's findings",
233
+ label: "Adding the quality lap's summary",
229
234
  })
230
235
  }
231
236
  }, guard),
232
- fixRisks: guarded(fixRisks, guard),
237
+ fixRisks: guarded((risks) => {
238
+ riskFixed = true
239
+ return fixRisks(risks)
240
+ }, guard),
233
241
  keepGreen: () => healthy(fixCheck, { escalations, guard }),
234
242
  awaitReview: (b) => awaitReview(b, t.handOffAwaitReviewMessage(b)),
235
243
  reviewMissing,
@@ -246,6 +254,7 @@ export const handOffReview = (
246
254
  deleted:
247
255
  "gtd land: review-doc: .gtd/REVIEW.md was deleted — restore it, or leave a note to hand to fix.",
248
256
  })
257
+ }
249
258
 
250
259
  /** The tail the fix workflow runs: ends at the hand-off review since `base`, which gets what the lap left. */
251
260
  export const handOffTail = (fixFirst: boolean, base: string, guard?: Guard): Promise<void> =>