@pmelab/gtd 42.0.0 → 44.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 (62) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +96 -48
  3. package/claude/hooks/sketch.ts +1 -1
  4. package/dist/chunks/{reserved-D7MNY9QJ.mjs → reserved-DJk_6-ju.mjs} +75 -46
  5. package/dist/chunks/review-X7Jfz0dY.mjs +249 -0
  6. package/dist/chunks/{steering-oCDdQlpP.mjs → steering-D4LUQ-av.mjs} +95 -12
  7. package/dist/chunks/{tail-D2o2h5cc.mjs → tail-5w5mxGTq.mjs} +3 -3
  8. package/dist/gtd.bundle.mjs +488 -30
  9. package/dist/steering.mjs +2 -2
  10. package/dist/testkit.mjs +447 -27
  11. package/dist/web/main.js +42 -13
  12. package/dist/workflow-kit.mjs +61 -25
  13. package/dist/workflows/cleanup.mjs +2 -2
  14. package/dist/workflows/feature.mjs +7 -290
  15. package/dist/workflows/fix.mjs +3 -3
  16. package/dist/workflows/review.mjs +27 -7
  17. package/dist/workflows/rollout.mjs +1 -1
  18. package/dist/workflows.mjs +287 -161
  19. package/package.json +1 -1
  20. package/src/cli/Cli.test.ts +33 -1
  21. package/src/cli/Cli.ts +70 -6
  22. package/src/cli/History.ts +15 -0
  23. package/src/cli/Judges.test.ts +373 -0
  24. package/src/cli/Judges.ts +624 -0
  25. package/src/cli/Metrics.ts +3 -16
  26. package/src/cli/Program.test.ts +3 -1
  27. package/src/cli/Program.ts +9 -0
  28. package/src/steering/FORMATS.md +4 -4
  29. package/src/steering/index.ts +12 -1
  30. package/src/steering/review.test.ts +107 -0
  31. package/src/steering/review.ts +159 -9
  32. package/src/testkit/EmittedScriptRecognizer.ts +0 -14
  33. package/src/testkit/steps.ts +4 -0
  34. package/src/workflow-kit/review.test.ts +170 -26
  35. package/src/workflow-kit/review.ts +95 -19
  36. package/src/workflows/feature/coverage.ts +1 -1
  37. package/src/workflows/feature/feature.test.ts +8 -0
  38. package/src/workflows/feature/feature.ts +2 -2
  39. package/src/workflows/feature/index.ts +1 -1
  40. package/src/workflows/feature/lens-review.feature +16 -4
  41. package/src/workflows/feature/refactor.ts +1 -1
  42. package/src/workflows/feature/review-answers.feature +179 -0
  43. package/src/workflows/feature/seam.ts +1 -1
  44. package/src/workflows/feature/steps.test.ts +2 -76
  45. package/src/workflows/feature/steps.ts +5 -48
  46. package/src/workflows/fix/fix.feature +20 -21
  47. package/src/workflows/index.ts +1 -1
  48. package/src/workflows/review/review-door.feature +149 -0
  49. package/src/workflows/review/review.test.ts +1 -73
  50. package/src/workflows/review/review.ts +34 -6
  51. package/src/workflows/shared/__fixtures__/feature-collecting-prompt.txt +6 -5
  52. package/src/workflows/shared/access.test.ts +2 -11
  53. package/src/workflows/shared/access.ts +0 -2
  54. package/src/workflows/{feature → shared}/review.test.ts +9 -61
  55. package/src/workflows/{feature → shared}/review.ts +40 -48
  56. package/src/workflows/shared/skills.test.ts +2 -0
  57. package/src/workflows/shared/skills.ts +2 -0
  58. package/src/workflows/shared/steps.test.ts +64 -2
  59. package/src/workflows/shared/steps.ts +40 -1
  60. package/src/workflows/shared/tail.ts +2 -2
  61. package/src/workflows/shared/text.test.ts +40 -89
  62. package/src/workflows/shared/text.ts +63 -54
@@ -11,6 +11,7 @@ const state = vi.hoisted(() => ({
11
11
  | undefined,
12
12
  agentThread: false,
13
13
  humanThread: false,
14
+ marks: [] as string[],
14
15
  }))
15
16
 
16
17
  vi.mock("../flows/index.js", () => ({
@@ -24,6 +25,7 @@ vi.mock("../flows/index.js", () => ({
24
25
  },
25
26
  hasThreadFor: (who: string) => (who === "agent" ? state.agentThread : state.humanThread),
26
27
  head: () => state.head,
28
+ mark: (name: string, value: string) => void state.marks.push(`${name}=${value}`),
27
29
  read: (path: string) => (path === "f.md" ? state.feedback : state.files[path]),
28
30
  refuse: (m: string) => {
29
31
  throw new Error(m)
@@ -41,20 +43,29 @@ import {
41
43
  type ReviewPolicy,
42
44
  type Verdict,
43
45
  } from "./review.js"
44
- import type { ReviewNote } from "../steering/index.js"
46
+ import type { ReviewDecision, ReviewNote } from "../steering/index.js"
45
47
 
46
48
  const note = (id: string) => ({ id }) as unknown as ReviewNote
47
49
 
48
50
  const setup = (verdicts: Record<string, Verdict>, silent = false, dropped = false) => {
49
51
  const calls: string[] = []
50
- const deferred: string[][] = []
52
+ const judged: ReviewNote[][] = []
53
+ const fixed: ReviewNote[][] = []
54
+ const decided: ReviewDecision[][] = []
51
55
  const collected: ReviewNote[][] = []
52
56
  const p = {
53
57
  file: "r.md",
54
58
  feedback: "f.md",
55
- triage: async () => new Map(Object.entries(verdicts)),
59
+ triage: async (ns: readonly ReviewNote[]) => {
60
+ judged.push([...ns])
61
+ return new Map(Object.entries(verdicts))
62
+ },
56
63
  answerQuestions: async () => void calls.push("answer"),
57
- fixNits: async () => void calls.push("nits"),
64
+ fixNits: async (ns: readonly ReviewNote[]) => {
65
+ calls.push("nits")
66
+ fixed.push([...ns])
67
+ },
68
+ decided: (ds: readonly ReviewDecision[]) => void decided.push([...ds]),
58
69
  keepGreen: async () => void calls.push("green"),
59
70
  collecting: async () => {
60
71
  calls.push("collect")
@@ -72,10 +83,6 @@ const setup = (verdicts: Record<string, Verdict>, silent = false, dropped = fals
72
83
  `dropped: ${r.notes.map((n) => n.id).join(",")} / ${r.paths.join(",")}`,
73
84
  }
74
85
  : {},
75
- defer: async (ns: readonly ReviewNote[]) => {
76
- calls.push("defer")
77
- deferred.push(ns.map((n) => n.id))
78
- },
79
86
  } as unknown as ReviewPolicy
80
87
  const r = {
81
88
  round: "r1",
@@ -83,7 +90,7 @@ const setup = (verdicts: Record<string, Verdict>, silent = false, dropped = fals
83
90
  outcome: (verdict: "signoff" | "feedback") => ({ verdict }) as never,
84
91
  unfolded: async () => ({ verdict: "signoff" }) as never,
85
92
  }
86
- return { p, r, calls, deferred, collected }
93
+ return { p, r, calls, judged, fixed, decided, collected }
87
94
  }
88
95
 
89
96
  beforeEach(() => {
@@ -95,29 +102,47 @@ beforeEach(() => {
95
102
  state.head = "h1"
96
103
  state.agentThread = false
97
104
  state.humanThread = false
105
+ state.marks = []
98
106
  })
99
107
 
100
- describe("routeNotes defer", () => {
101
- it("defer-only round awaits again, no collecting turn", async () => {
102
- const t = setup({ a: "defer", b: "defer" })
103
- const res = await routeNotes(t.p, [note("a"), note("b")], t.r)
104
- expect(res).toEqual({ next: "await", reviewed: "r1" })
105
- expect(t.deferred).toEqual([["a", "b"]])
106
- expect(t.calls).toEqual(["defer"])
108
+ describe("routeNotes decisions", () => {
109
+ const decision = (
110
+ anchor: string,
111
+ choice: ReviewDecision["choice"],
112
+ text = "",
113
+ ): ReviewDecision => ({ anchor, body: `${anchor} body`, choice, text })
114
+
115
+ it("a fix joins the nit batch unjudged", async () => {
116
+ const t = setup({})
117
+ const res = await routeNotes(t.p, [], t.r, [decision("Rounding", "fix")])
118
+ expect(res).toEqual({ next: "rereview", carry: undefined })
119
+ expect(t.judged).toEqual([])
120
+ expect(t.fixed[0]).toMatchObject([{ anchor: "Rounding", text: "Rounding body" }])
121
+ expect(t.calls).toEqual(["nits", "green", "close"])
107
122
  })
108
123
 
109
- it("defer + edit collects only the edits and ends feedback", async () => {
110
- const t = setup({ a: "defer", b: "edit" })
111
- const res = await routeNotes(t.p, [note("a"), note("b")], t.r)
124
+ it("an own answer is judged like a note", async () => {
125
+ const t = setup({ "answer-1": "edit" })
126
+ const res = await routeNotes(t.p, [], t.r, [decision("Rounding", "answer", "round up")])
112
127
  expect(res).toEqual({ verdict: "feedback" })
113
- expect(t.calls).toEqual(["defer", "close", "collect"])
114
- expect(t.collected[0]!.map((n) => n.id)).toEqual(["b"])
128
+ expect(t.judged[0]).toMatchObject([{ id: "answer-1", anchor: "Rounding", text: "round up" }])
129
+ expect(t.collected[0]!.map((n) => n.id)).toEqual(["answer-1"])
115
130
  })
116
131
 
117
- it("runs after nit fixes and before the collecting turn", async () => {
118
- const t = setup({ a: "nit", b: "defer", c: "edit" })
119
- await routeNotes(t.p, [note("a"), note("b"), note("c")], t.r)
120
- expect(t.calls).toEqual(["nits", "green", "defer", "close", "collect"])
132
+ it("a won't-fix is dropped, marked, and signs off a round with nothing else", async () => {
133
+ const t = setup({})
134
+ const res = await routeNotes(t.p, [], t.r, [decision("Rounding", "wontfix")])
135
+ expect(res).toEqual({ verdict: "signoff" })
136
+ expect(t.calls).toEqual([])
137
+ expect(t.judged).toEqual([])
138
+ expect(state.marks).toEqual(["wontfix=Rounding"])
139
+ })
140
+
141
+ it("tells the policy every decision before routing", async () => {
142
+ const t = setup({})
143
+ const ds = [decision("A", "fix"), decision("B", "wontfix")]
144
+ await routeNotes(t.p, [], t.r, ds)
145
+ expect(t.decided).toEqual([ds])
121
146
  })
122
147
  })
123
148
 
@@ -221,7 +246,6 @@ const driveRound = async (
221
246
  },
222
247
  answerQuestions: async () => {},
223
248
  fixNits: async () => {},
224
- defer: async () => {},
225
249
  close: async () => {},
226
250
  captures: {
227
251
  edits: (_round: string, from?: string) => (
@@ -440,3 +464,123 @@ describe("reviewToHandOff", () => {
440
464
  expect(calls).toEqual(["handOff"])
441
465
  })
442
466
  })
467
+
468
+ describe("a note's answer boxes", () => {
469
+ const boxes = (fix: string, wont: string): string =>
470
+ doc(
471
+ POINTER,
472
+ "",
473
+ "## Rounding",
474
+ "",
475
+ "Should subtract round?",
476
+ "",
477
+ "Tick exactly one:",
478
+ "",
479
+ `- [${fix}] fix as suggested`,
480
+ `- [${wont}] won't fix`,
481
+ "- [ ] _your answer_",
482
+ )
483
+ const open = boxes(" ", " ")
484
+
485
+ it("a sign-off with a note unanswered is refused", async () => {
486
+ const r = await driveRound(open, open)
487
+ expect(r.result).toBeInstanceOf(Error)
488
+ expect((r.result as Error).message).toContain("answer-completeness: 1 note(s) in r.md")
489
+ })
490
+
491
+ it("two ticks on one note are refused", async () => {
492
+ const r = await driveRound(open, boxes("x", "x"))
493
+ expect((r.result as Error).message).toContain("answer-completeness")
494
+ })
495
+
496
+ it("a won't-fix answer signs off with no judge", async () => {
497
+ const r = await driveRound(open, boxes(" ", "x"))
498
+ expect(r.result).toEqual({ verdict: "signoff" })
499
+ expect(r.triaged).toEqual([])
500
+ expect(state.marks).toEqual(["wontfix=Rounding"])
501
+ })
502
+
503
+ it("a thread round may leave notes unanswered until the agent replied", async () => {
504
+ const base = doc(
505
+ `${POINTER}[^t]`,
506
+ "",
507
+ thread("H: why?"),
508
+ "",
509
+ "## Rounding",
510
+ "",
511
+ "Round?",
512
+ "",
513
+ "Tick exactly one:",
514
+ "",
515
+ "- [ ] fix as suggested",
516
+ "- [ ] won't fix",
517
+ "- [ ] _your answer_",
518
+ )
519
+ const replied = base.replace(" - H: why?", " - H: why?\n - A: because")
520
+ const r = await driveRound(base, replied, { inGate: replied })
521
+ expect(r.rounds).toEqual(["reply"])
522
+ expect((r.result as Error).message).toContain("answer-completeness")
523
+ })
524
+
525
+ const threaded = (wont: string, withThread: boolean): string =>
526
+ doc(
527
+ withThread ? `${POINTER}[^t]` : POINTER,
528
+ ...(withThread ? ["", thread("H: why?", "A: because")] : []),
529
+ "",
530
+ "## Rounding",
531
+ "",
532
+ "Round?",
533
+ "",
534
+ "Tick exactly one:",
535
+ "",
536
+ "- [ ] fix as suggested",
537
+ `- [${wont}] won't fix`,
538
+ "- [ ] _your answer_",
539
+ )
540
+
541
+ it("a reply that folds the last thread away rests at the gate again for the answers", async () => {
542
+ const base = threaded(" ", true).replace("\n - A: because", "")
543
+ const r = await driveRound(base, threaded("x", false), { inGate: threaded(" ", false) })
544
+ expect(r.rounds).toEqual(["reply"])
545
+ expect(r.result).toEqual({ verdict: "signoff" })
546
+ expect(state.marks).toEqual(["wontfix=Rounding"])
547
+ })
548
+
549
+ it("an answer ticked beside a thread still routes once the thread settles", async () => {
550
+ const base = threaded(" ", true).replace("\n - A: because", "")
551
+ const ticked = threaded("x", true)
552
+ const r = await driveRound(base, ticked, { inGate: ticked, settled: true, noDropped: true })
553
+ expect(r.result).toEqual({ verdict: "signoff" })
554
+ expect(state.marks).toEqual(["wontfix=Rounding"])
555
+ })
556
+
557
+ it("a hand-edit round still records its answers", async () => {
558
+ const r = await driveRound(open, boxes(" ", "x"), { handEdit: true })
559
+ expect(r.rounds).toEqual(["edits"])
560
+ expect(state.marks).toEqual(["wontfix=Rounding"])
561
+ })
562
+ })
563
+
564
+ describe("deleted answer boxes", () => {
565
+ it("are refused even when a reply turn landed them", async () => {
566
+ const withBoxes = doc(
567
+ `${POINTER}[^t]`,
568
+ "",
569
+ thread("H: why?"),
570
+ "",
571
+ "## Rounding",
572
+ "",
573
+ "Round?",
574
+ "",
575
+ "Tick exactly one:",
576
+ "",
577
+ "- [ ] fix as suggested",
578
+ "- [ ] won't fix",
579
+ "- [ ] _your answer_",
580
+ )
581
+ const gone = doc(POINTER, "", "## Rounding", "", "Round?")
582
+ state.since = [{ path: "r.md", status: "modified", before: withBoxes, after: gone }]
583
+ const r = await driveRound(withBoxes, gone, { inGate: gone })
584
+ expect((r.result as Error).message).toContain("its answer boxes were deleted")
585
+ })
586
+ })
@@ -3,6 +3,7 @@ import {
3
3
  changesSince,
4
4
  hasThreadFor,
5
5
  head,
6
+ mark,
6
7
  read,
7
8
  refuse,
8
9
  requireReplies,
@@ -11,9 +12,12 @@ import {
11
12
  } from "../flows/index.js"
12
13
  import {
13
14
  parseThreads,
15
+ reviewDecisions,
14
16
  reviewNotes,
15
17
  reviewRisks,
16
18
  stripCodeThreads,
19
+ unansweredNotes,
20
+ type ReviewDecision,
17
21
  type ReviewNote,
18
22
  } from "../steering/index.js"
19
23
 
@@ -39,7 +43,7 @@ export interface Requested {
39
43
  readonly paths: readonly string[]
40
44
  }
41
45
 
42
- export type Verdict = "edit" | "question" | "nit" | "praise" | "defer"
46
+ export type Verdict = "edit" | "question" | "nit" | "praise"
43
47
 
44
48
  export type ReviewRisk = ReturnType<typeof reviewRisks>[number]
45
49
 
@@ -64,8 +68,8 @@ export interface ReviewPolicy {
64
68
  readonly triage: (notes: readonly ReviewNote[]) => Promise<ReadonlyMap<string, Verdict>>
65
69
  readonly answerQuestions: (notes: readonly ReviewNote[]) => Promise<void>
66
70
  readonly fixNits: (notes: readonly ReviewNote[]) => Promise<void>
67
- /** Leave `defer` notes out of the round. */
68
- readonly defer: (notes: readonly ReviewNote[]) => Promise<void>
71
+ /** Told every note the human answered this round, before any of them is routed. */
72
+ readonly decided?: (decisions: readonly ReviewDecision[]) => void
69
73
  /** Remove `file` at the end of a round at commit `round`. */
70
74
  readonly close: (round: string) => Promise<void>
71
75
  /** What a collecting turn reads. */
@@ -112,6 +116,18 @@ const collect = async (
112
116
  return refuse(p.refusals.unmatched)
113
117
  }
114
118
 
119
+ /** Refuse while a note's answer boxes hold anything but exactly one tick, or a note's boxes were deleted. */
120
+ const requireDecisions = (file: string, reviewed: string): void => {
121
+ const open = unansweredNotes(read(file) ?? "", changesSince(reviewed).get(file)?.before)
122
+ if (open.length === 0) return
123
+ const list = open
124
+ .map((n) => ` - ${file}${n.line > 0 ? `:${n.line}` : ""}: ${n.title} — ${n.reason}`)
125
+ .join("\n")
126
+ refuse(
127
+ `gtd land: answer-completeness: ${open.length} note(s) in ${file} not answered — tick exactly one box per note: fix as suggested, won't fix, or your own answer written over _your answer_:\n${list}`,
128
+ )
129
+ }
130
+
115
131
  const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
116
132
 
117
133
  /**
@@ -144,6 +160,7 @@ const notesOf = (text: string): string => {
144
160
  const converse = async (
145
161
  p: ReviewPolicy,
146
162
  base: string,
163
+ reviewed: string,
147
164
  collectedBefore?: string,
148
165
  ): Promise<string | undefined> => {
149
166
  let collectedAt = collectedBefore
@@ -155,11 +172,19 @@ const converse = async (
155
172
  return "missing"
156
173
  }
157
174
  requireThreadsClosed(p.file)
158
- if (!hasThreadFor("agent", p.file)) return collectedAt
175
+ if (!hasThreadFor("agent", p.file)) {
176
+ requireDecisions(p.file, reviewed)
177
+ return collectedAt
178
+ }
159
179
  await p.collecting(p.captures.edits(head()), "reply")
160
180
  collectedAt = head()
161
181
  requireReplies(p.file)
162
- if (!hasThreadFor("human", p.file)) return collectedAt
182
+ // A reply that folds the last thread away still leaves the notes to answer at the gate.
183
+ if (
184
+ !hasThreadFor("human", p.file) &&
185
+ unansweredNotes(read(p.file) ?? "", changesSince(reviewed).get(p.file)?.before).length === 0
186
+ )
187
+ return collectedAt
163
188
  }
164
189
  }
165
190
 
@@ -177,16 +202,51 @@ interface Round {
177
202
  readonly unfolded: () => Promise<Finish>
178
203
  }
179
204
 
180
- /** Judge every note, then run each verdict's route: answers, then nit fixes, then deferrals, then the edits' lap. */
205
+ const decisionNote = (d: ReviewDecision, id: string, text: string): ReviewNote => ({
206
+ id,
207
+ kind: "chunk",
208
+ anchor: d.anchor,
209
+ before: d.body,
210
+ text,
211
+ forced: /^!(\s|$)/.test(text),
212
+ })
213
+
214
+ /** One `Gtd-Mark` value per line: a control character would make mark() throw on every replay. */
215
+ const oneLine = (text: string): string =>
216
+ // eslint-disable-next-line no-control-regex -- the control range IS the point
217
+ text.replace(/[\s\x00-\x1f\x7f-\x9f\u2028\u2029]+/g, " ").trim() || "untitled"
218
+
219
+ /** Tell the policy, and mark each won't-fix on the gate's commit. */
220
+ const record = (p: ReviewPolicy, decisions: readonly ReviewDecision[]): void => {
221
+ p.decided?.(decisions)
222
+ for (const d of decisions) if (d.choice === "wontfix") mark("wontfix", oneLine(d.anchor))
223
+ }
224
+
225
+ /**
226
+ * Judge every note, then run each verdict's route: answers, then nit fixes,
227
+ * then the edits' lap. Of the human's `decisions`, a fix joins the nit batch
228
+ * unjudged, an own answer is judged like a note, and a won't-fix is dropped
229
+ * with a `Gtd-Mark` on the gate's commit.
230
+ */
181
231
  export const routeNotes = async (
182
232
  p: ReviewPolicy,
183
233
  notes: readonly ReviewNote[],
184
234
  r: Round,
235
+ decisions: readonly ReviewDecision[] = [],
185
236
  ): Promise<Finish> => {
186
- const verdicts = await p.triage(notes)
187
- const of = (v: Verdict): ReviewNote[] => notes.filter((n) => (verdicts.get(n.id) ?? "edit") === v)
188
- const [edits, questions, nits, deferred] = [of("edit"), of("question"), of("nit"), of("defer")]
189
- if (edits.length + questions.length + nits.length + deferred.length === 0) return r.unfolded()
237
+ record(p, decisions)
238
+ const of = (c: ReviewDecision["choice"]) => decisions.filter((d) => d.choice === c)
239
+ const fixes = of("fix").map((d, i) => decisionNote(d, `fix-${i + 1}`, d.body))
240
+ const judged = [
241
+ ...notes,
242
+ ...of("answer").map((d, i) => decisionNote(d, `answer-${i + 1}`, d.text)),
243
+ ]
244
+ const verdicts = judged.length === 0 ? new Map<string, Verdict>() : await p.triage(judged)
245
+ const ofVerdict = (v: Verdict): ReviewNote[] =>
246
+ judged.filter((n) => (verdicts.get(n.id) ?? "edit") === v)
247
+ const [edits, questions] = [ofVerdict("edit"), ofVerdict("question")]
248
+ const nits = [...ofVerdict("nit"), ...fixes]
249
+ if (edits.length + questions.length + nits.length === 0) return r.unfolded()
190
250
 
191
251
  let answeredAt: string | undefined
192
252
  if (questions.length > 0) {
@@ -197,7 +257,6 @@ export const routeNotes = async (
197
257
  await p.fixNits(nits)
198
258
  await p.keepGreen()
199
259
  }
200
- if (deferred.length > 0) await p.defer(deferred)
201
260
  if (edits.length > 0) {
202
261
  await r.close()
203
262
  return r.outcome(
@@ -219,10 +278,13 @@ const isCodeEdit = (p: ReviewPolicy, c: Change): boolean =>
219
278
  p.isCode(c.path) &&
220
279
  stripCodeThreads(c.path, c.before ?? "") !== stripCodeThreads(c.path, c.after ?? "")
221
280
 
222
- /** Whether anything but thread edits and ticks landed in the review file since `commit`. */
281
+ /** Whether a note or an answer, not just thread edits and pointer ticks, landed in the review file since `commit`. */
223
282
  const notedSince = (p: ReviewPolicy, commit: string): boolean =>
224
283
  changesSince(commit).some(
225
- (c) => c.path === p.file && notesOf(c.before ?? "") !== notesOf(read(p.file) ?? ""),
284
+ (c) =>
285
+ c.path === p.file &&
286
+ (notesOf(c.before ?? "") !== notesOf(read(p.file) ?? "") ||
287
+ reviewDecisions(c.before ?? "", read(p.file) ?? "").length > 0),
226
288
  )
227
289
 
228
290
  /**
@@ -251,13 +313,14 @@ const settledAfter = (
251
313
 
252
314
  const finishNotes = async (
253
315
  p: ReviewPolicy,
254
- before: string,
316
+ before: string | undefined,
317
+ decisions: readonly ReviewDecision[],
255
318
  reopened: boolean,
256
319
  ctx: Parameters<typeof routeNotes>[2],
257
320
  ): Promise<Finish> => {
258
321
  // An unchanged review file has no baseline entry, which would read as all-new.
259
- const notes = reviewNotes(before, read(p.file) ?? "")
260
- if (notes.length > 0) return routeNotes(p, notes, ctx)
322
+ const notes = reviewNotes(before ?? "", read(p.file) ?? "")
323
+ if (notes.length + decisions.length > 0) return routeNotes(p, notes, ctx, decisions)
261
324
  if (reopened) return ctx.unfolded()
262
325
  await ctx.close()
263
326
  return ctx.outcome(await collect(p, p.captures.notes(ctx.round), "notes"))
@@ -271,6 +334,15 @@ const toOutcome = (
271
334
  const isReopened = (p: ReviewPolicy, r: { settled: boolean; folded: boolean }): boolean =>
272
335
  r.settled && !r.folded && p.refusals.dropped !== undefined
273
336
 
337
+ /** A carried answer is unchanged, never routed twice; one ticked beside a thread still routes once the thread has settled. */
338
+ const decisionsSince = (
339
+ p: ReviewPolicy,
340
+ since: ReturnType<typeof changesSince>,
341
+ ): readonly ReviewDecision[] => {
342
+ const after = read(p.file) ?? ""
343
+ return reviewDecisions(since.get(p.file)?.before ?? after, after)
344
+ }
345
+
274
346
  const finish = async (
275
347
  p: ReviewPolicy,
276
348
  reviewed: string,
@@ -296,8 +368,12 @@ const finish = async (
296
368
  // Thread-only edits settled the round, but a change note or hand-edit since
297
369
  // `reviewed` must still reach a collecting turn when a dropped edit is refused.
298
370
  const reopened = isReopened(p, { settled, folded })
299
- if ((settled && !reopened) || (edited.length === 0 && !noted)) return unfolded()
371
+ const decisions = decisionsSince(p, since)
372
+ if ((settled && !reopened && decisions.length === 0) || (edited.length === 0 && !noted))
373
+ return unfolded()
300
374
  if (edited.length > 0) {
375
+ // A hand-edit plans a lap; its collecting turn reads the answers in the review.
376
+ record(p, decisions)
301
377
  await close()
302
378
  return outcome(
303
379
  await collect(p, p.captures.edits(round, reopened ? reviewed : undefined), "edits", {
@@ -306,7 +382,7 @@ const finish = async (
306
382
  }),
307
383
  )
308
384
  }
309
- return finishNotes(p, since.get(p.file)?.before ?? "", reopened, {
385
+ return finishNotes(p, since.get(p.file)?.before, decisions, reopened, {
310
386
  round,
311
387
  close,
312
388
  outcome,
@@ -342,7 +418,7 @@ export const review = async (base: string, p: ReviewPolicy): Promise<ReviewOutco
342
418
  let reviewed = head()
343
419
  let collectedAt: string | undefined
344
420
  for (;;) {
345
- collectedAt = await converse(p, base, collectedAt)
421
+ collectedAt = await converse(p, base, reviewed, collectedAt)
346
422
  if (collectedAt === "missing") break
347
423
  const result = await finish(p, reviewed, collectedAt)
348
424
  if (!("next" in result)) return result
@@ -19,7 +19,7 @@ import {
19
19
  type CriteriaSlice,
20
20
  } from "./criteria.js"
21
21
  import { isTestFile } from "./refactor.js"
22
- import { isCode } from "./review.js"
22
+ import { isCode } from "../shared/review.js"
23
23
 
24
24
  /** Below this the judge's `yes` is not trusted: a wrongly covered ID ends the feature without its test. */
25
25
  const COVERED_MIN_P = 0.7
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"
2
2
  import { runsItsScript, testRepo } from "../../testkit/index.js"
3
3
  import { CONFIG_SCAFFOLD } from "../../install/index.js"
4
4
  import { reviewDoc } from "../shared/config.fixture.js"
5
+ import type * as featureEntry from "./index.js"
5
6
 
6
7
  describe("an ordinary feature start", () => {
7
8
  it("unwinds a captured TODO, checks the baseline and starts design from it", async () => {
@@ -363,3 +364,10 @@ describe("flagMode", () => {
363
364
  expect(repo.subjects()).toEqual(["chore: initial commit"])
364
365
  })
365
366
  })
367
+
368
+ // pins the public re-exports from the feature entry point
369
+ export type _PublicBuildTailOptions = [
370
+ featureEntry.BuildTailOptions,
371
+ featureEntry.FeedbackTarget,
372
+ featureEntry.ReviewRange,
373
+ ]
@@ -26,7 +26,7 @@ import {
26
26
  type StatedCriteria,
27
27
  } from "./criteria.js"
28
28
  import { design } from "./planning.js"
29
- import { buildTail, isCode, reUnwind, review, type ReviewOutcome } from "./review.js"
29
+ import { buildTail, humanReview, isCode, reUnwind, type ReviewOutcome } from "../shared/review.js"
30
30
  import { implement, implementing, seamBuild } from "./seam.js"
31
31
  import { sliceCap, sliceCriteria, slicing } from "./slicing.js"
32
32
  import { recordRisk, riskTierOn, type RiskTier } from "./tier.js"
@@ -153,7 +153,7 @@ const settleQuality = async (): Promise<void> => {
153
153
 
154
154
  /** The triaged quality lap, then the human review since `base`. */
155
155
  const reviewedTail = (base: string, lensBase: string): Promise<ReviewOutcome> =>
156
- tail((escalations, report) => review(base, escalations, { report }), {
156
+ tail((escalations, report) => humanReview(base, escalations, { report }), {
157
157
  green: true,
158
158
  lensBase,
159
159
  triage: true,
@@ -11,7 +11,7 @@ export {
11
11
  type BuildTailOptions,
12
12
  type FeedbackTarget,
13
13
  type ReviewOutcome,
14
- } from "./review.js"
14
+ } from "../shared/review.js"
15
15
  export type { ReviewRange } from "../shared/text.js"
16
16
  export * from "./feature.js"
17
17
  export * from "../shared/reserved.js"
@@ -3,9 +3,10 @@ Feature: the quality lenses are the only agentic review
3
3
 
4
4
  A judge classifies each `## ` finding the lenses wrote to `.gtd/QUALITY.md`:
5
5
  a mechanical one is fixed by the fixer, noise is dropped with its `p` kept in
6
- a trailer, and a design one becomes a note in `.gtd/REVIEW.md`. gtd writes
7
- that file itself — no reviewer turn runs — and lists, without tick boxes,
8
- what was fixed automatically and what was dropped as noise.
6
+ a trailer, and a design one becomes a note in `.gtd/REVIEW.md`, with answer
7
+ boxes. gtd writes that file itself — no reviewer turn runs — and lists,
8
+ without tick boxes, what was fixed automatically and what was dropped as
9
+ noise.
9
10
 
10
11
  Background:
11
12
  Given the shipped workflows, as gtd install scaffolds them
@@ -97,6 +98,12 @@ Feature: the quality lenses are the only agentic review
97
98
 
98
99
  Should subtract round? The criterion does not say.
99
100
 
101
+ Tick exactly one:
102
+
103
+ - [ ] fix as suggested
104
+ - [ ] won't fix
105
+ - [ ] _your answer_
106
+
100
107
  ## Fixed automatically
101
108
 
102
109
  - subtract has no negative test (
@@ -108,7 +115,12 @@ Feature: the quality lenses are the only agentic review
108
115
 
109
116
  - add could be a method (p=0.85)
110
117
  """
111
- And ".gtd/REVIEW.md" does not contain "[ ]"
118
+ And ".gtd/REVIEW.md" contains:
119
+ """
120
+ ## Fixed automatically
121
+
122
+ - subtract has no negative test (
123
+ """
112
124
  And the commit subjects end with:
113
125
  """
114
126
  gtd(check): implement.closing → build.quality.correctness.reviewing
@@ -20,7 +20,7 @@ import { healthy, type Guard } from "../shared/health.js"
20
20
  import { coder, FEEDBACK, HANDOFF, REFACTOR } from "../shared/steps.js"
21
21
  import * as t from "../shared/text.js"
22
22
  import { defaults, testFileGlobs } from "../shared/vars.js"
23
- import { buildTail, reUnwind, type FeedbackTarget } from "./review.js"
23
+ import { buildTail, reUnwind, type FeedbackTarget } from "../shared/review.js"
24
24
  import { refactorCollecting } from "./steps.js"
25
25
 
26
26
  export const REFACTOR_PACKAGE = REFACTOR