@pmelab/gtd 15.8.0 → 15.10.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "15.8.0",
3
+ "version": "15.10.0",
4
4
  "private": false,
5
5
  "description": "Git-aware CLI that emits the next prompt for an autonomous coding agent based on the current repository state",
6
6
  "bin": {
@@ -65,10 +65,11 @@ judgement call: the grouping is already decided.`
65
65
 
66
66
  export const reviewerPersona = `You are the independent reviewing mind in gtd's build pipeline —
67
67
  deliberately separate from whoever wrote the code, with no attachment
68
- to it. Two turns: write a structured review document grouping a diff
69
- into chunks; later, classify a round of the human's feedback as
70
- actionable or just approving, never fixing anything yourself. You judge
71
- and classify; you never build.`
68
+ to it. Write a structured review document grouping a diff into
69
+ chunks; classify a round of the human's feedback as actionable or
70
+ just approving; answer the human's questions inline in the review;
71
+ and, when asked, fix the small nits the human flagged in one batch.
72
+ Beyond that you never fix or build anything yourself.`
72
73
 
73
74
  export const specReviewerPersona = `You are the adversarial spec-conformance checker in gtd's build
74
75
  pipeline, checking one freshly-built package against its spec. Verify
@@ -0,0 +1,247 @@
1
+ import { afterEach, describe, expect, it } from "vitest"
2
+ import { installContext, type Change, type JudgeAnswer, type StepRequest } from "../flows/index.js"
3
+ import { review, type ReviewOutcome } from "./review.js"
4
+ import { fixtureContext } from "./text.fixture.js"
5
+
6
+ afterEach(() => installContext(undefined))
7
+
8
+ const REVIEW = ".gtd/REVIEW.md"
9
+ const REQUIREMENTS = ".gtd/REQUIREMENTS.md"
10
+
11
+ class Stop extends Error {}
12
+
13
+ const doc = (notes: readonly string[]): string =>
14
+ [
15
+ "# Review: abc1234",
16
+ "",
17
+ "<!-- base: 0000000000000000000000000000000000000000 -->",
18
+ "",
19
+ "## calc",
20
+ ...notes.flatMap((note, i) => [`- [ ] ./src/calc.ts#${i + 1}-${i + 1}`, note]),
21
+ "",
22
+ ].join("\n")
23
+
24
+ const BEFORE = doc(["add", "sub", "mul"])
25
+
26
+ interface Drive {
27
+ readonly notes: readonly string[]
28
+ readonly answers?: Readonly<Record<string, JudgeAnswer>>
29
+ readonly truncated?: readonly string[]
30
+ readonly vars?: Readonly<Record<string, string>>
31
+ /** The REVIEW.md the human leaves; defaults to `notes` laid over the baseline. */
32
+ readonly after?: string
33
+ }
34
+
35
+ interface Run {
36
+ readonly log: string[]
37
+ readonly prompts: Map<string, string>
38
+ readonly result: ReviewOutcome | "stopped"
39
+ readonly judged: readonly string[]
40
+ }
41
+
42
+ /** Run `review` against a scripted context; a second pass at the gate or a fresh review stops the run. */
43
+ const drive = async (d: Drive): Promise<Run> => {
44
+ const log: string[] = []
45
+ const prompts = new Map<string, string>()
46
+ const judged: string[] = []
47
+ const files = new Map<string, string>([[REVIEW, d.after ?? doc(d.notes)]])
48
+ let steps = 0
49
+ let nitsFixed = false
50
+ let awaits = 0
51
+ let reviews = 0
52
+ const effects: Record<string, () => void> = {
53
+ "review.reviewing": () => {
54
+ if (++reviews > 1) throw new Stop()
55
+ },
56
+ "review.await-review": () => {
57
+ if (++awaits > 1) throw new Stop()
58
+ },
59
+ "review.closing": () => void files.delete(REVIEW),
60
+ "review.collecting": () => void files.set(REQUIREMENTS, "## Concern"),
61
+ "review.fix-nits": () => {
62
+ nitsFixed = true
63
+ },
64
+ }
65
+ const context = fixtureContext(
66
+ { vars: d.vars ?? {} },
67
+ {
68
+ step: (request: StepRequest) => {
69
+ steps++
70
+ if (request.kind === "restart") return Promise.resolve()
71
+ log.push(request.name)
72
+ if (request.kind === "agent") prompts.set(request.name, request.prompt)
73
+ effects[request.name]?.()
74
+ if (request.kind !== "judge") return Promise.resolve()
75
+ judged.push(...request.questions.map((q) => q.id))
76
+ return Promise.resolve({ answers: d.answers ?? {}, truncated: d.truncated ?? [] })
77
+ },
78
+ refuse: (message: string): never => {
79
+ throw new Error(message)
80
+ },
81
+ pushScope: () => undefined,
82
+ popScope: () => undefined,
83
+ read: (path) => files.get(path),
84
+ changesSince: (): readonly Change[] => [
85
+ { path: REVIEW, status: "modified", before: BEFORE, after: files.get(REVIEW) },
86
+ ...(nitsFixed
87
+ ? [{ path: "src/calc.ts", status: "modified" as const, before: "a", after: "b" }]
88
+ : []),
89
+ ],
90
+ head: () => `c${steps}`,
91
+ start: () => "c0",
92
+ },
93
+ )
94
+ installContext(context)
95
+ let result: ReviewOutcome | "stopped"
96
+ try {
97
+ result = await review("base")
98
+ } catch (error) {
99
+ if (!(error instanceof Stop)) throw error
100
+ result = "stopped"
101
+ }
102
+ return { log, prompts, result, judged }
103
+ }
104
+
105
+ const verdict = (answer: string, p = 0.9): JudgeAnswer => ({ answer, p })
106
+
107
+ describe("review verdict routing", () => {
108
+ const notes = ["add — typo", "sub", "mul"]
109
+
110
+ it("judges one note per added note, as one judge rest", async () => {
111
+ const run = await drive({ notes: ["add — a", "sub — b", "mul"] })
112
+ expect(run.judged).toEqual(["note-1", "note-2"])
113
+ expect(run.log.filter((n) => n === "review.triage")).toHaveLength(1)
114
+ })
115
+
116
+ it("an unanswered note counts as edit", async () => {
117
+ const run = await drive({ notes: ["add — typo", "sub", "mul"], answers: {} })
118
+ expect(run.log).toEqual([
119
+ "review.reviewing",
120
+ "review.await-review",
121
+ "review.triage",
122
+ "review.closing",
123
+ "review.collecting",
124
+ ])
125
+ expect(run.result).toMatchObject({ verdict: "feedback" })
126
+ })
127
+
128
+ it("a cut note counts as edit despite a confident praise", async () => {
129
+ const run = await drive({
130
+ notes,
131
+ answers: { "note-1": verdict("praise", 0.99) },
132
+ truncated: ["note-1"],
133
+ })
134
+ expect(run.log).toContain("review.collecting")
135
+ })
136
+
137
+ it("a verdict below the reviewNoteActionable floor counts as edit", async () => {
138
+ const run = await drive({ notes, answers: { "note-1": verdict("praise", 0.5) } })
139
+ expect(run.log).toContain("review.collecting")
140
+ })
141
+
142
+ it("a blank floor makes every note an edit", async () => {
143
+ const run = await drive({
144
+ notes,
145
+ answers: { "note-1": verdict("praise", 1) },
146
+ vars: { reviewNoteActionable: "" },
147
+ })
148
+ expect(run.log).toContain("review.collecting")
149
+ })
150
+
151
+ it("only praise closes and signs off with no agent turn", async () => {
152
+ const run = await drive({ notes, answers: { "note-1": verdict("praise") } })
153
+ expect(run.log).toEqual([
154
+ "review.reviewing",
155
+ "review.await-review",
156
+ "review.triage",
157
+ "review.closing",
158
+ ])
159
+ expect(run.result).toEqual({ verdict: "signoff" })
160
+ })
161
+
162
+ it("questions only are answered, then the gate again, never closed", async () => {
163
+ const run = await drive({
164
+ notes: ["add — why?", "sub — and here?", "mul"],
165
+ answers: { "note-1": verdict("question"), "note-2": verdict("question") },
166
+ })
167
+ expect(run.log).toEqual([
168
+ "review.reviewing",
169
+ "review.await-review",
170
+ "review.triage",
171
+ "review.answer-review-questions",
172
+ "review.await-review",
173
+ ])
174
+ expect(run.prompts.get("review.answer-review-questions")).toContain("note-2")
175
+ expect(run.result).toBe("stopped")
176
+ })
177
+
178
+ it("nits without edits are fixed, kept green, closed, then re-reviewed with the answers carried", async () => {
179
+ const run = await drive({
180
+ notes: ["add — why?", "sub — typo", "mul"],
181
+ answers: { "note-1": verdict("question"), "note-2": verdict("nit") },
182
+ })
183
+ expect(run.log).toEqual([
184
+ "review.reviewing",
185
+ "review.await-review",
186
+ "review.triage",
187
+ "review.answer-review-questions",
188
+ "review.fix-nits",
189
+ "health.check",
190
+ "review.closing",
191
+ "review.reviewing",
192
+ ])
193
+ expect(run.prompts.get("review.fix-nits")).not.toContain("why?")
194
+ expect(run.prompts.get("review.reviewing")).toContain("Carry-over: commit `c4`")
195
+ expect(run.result).toBe("stopped")
196
+ })
197
+
198
+ it("nits alone carry nothing over", async () => {
199
+ const run = await drive({
200
+ notes: ["add — typo", "sub", "mul"],
201
+ answers: { "note-1": verdict("nit") },
202
+ })
203
+ expect(run.prompts.get("review.reviewing")).not.toContain("Carry-over")
204
+ })
205
+
206
+ it("edits run after questions and nits, and the capture names only the edits and the answering commit", async () => {
207
+ const run = await drive({
208
+ notes: ["add — rename it", "sub — why?", "mul — typo"],
209
+ answers: { "note-2": verdict("question"), "note-3": verdict("nit") },
210
+ })
211
+ expect(run.log).toEqual([
212
+ "review.reviewing",
213
+ "review.await-review",
214
+ "review.triage",
215
+ "review.answer-review-questions",
216
+ "review.fix-nits",
217
+ "health.check",
218
+ "review.closing",
219
+ "review.collecting",
220
+ ])
221
+ const capture = run.prompts.get("review.collecting") ?? ""
222
+ expect(capture).toContain("note-1")
223
+ expect(capture).toContain("rename it")
224
+ expect(capture).not.toContain("note-2 —")
225
+ expect(capture).not.toContain("note-3 —")
226
+ expect(capture).toContain("commit c4 answered")
227
+ })
228
+
229
+ it("a noted round with no extractable note collects as one edit, without judging", async () => {
230
+ const run = await drive({ notes: [], after: BEFORE + "\nSome stray prose.\n" })
231
+ expect(run.log).toEqual([
232
+ "review.reviewing",
233
+ "review.await-review",
234
+ "review.closing",
235
+ "review.collecting",
236
+ ])
237
+ expect(run.prompts.get("review.collecting")).toContain("The human's notes are in")
238
+ })
239
+
240
+ it("never counts a nit fix as a hand-edit", async () => {
241
+ const run = await drive({
242
+ notes: ["add — rename it", "sub — typo", "mul"],
243
+ answers: { "note-2": verdict("nit") },
244
+ })
245
+ expect(run.result).toMatchObject({ verdict: "feedback", edited: [] })
246
+ })
247
+ })
@@ -13,17 +13,18 @@ import {
13
13
  requireThreadsClosed,
14
14
  run,
15
15
  scope,
16
- sectionBodies,
17
16
  vars,
18
17
  type Change,
19
18
  type JudgeQuestion,
20
19
  } from "../flows/index.js"
21
- import { stripCodeThreads } from "../steering/index.js"
20
+ import { reviewNotes, stripCodeThreads, type ReviewNote } from "../steering/index.js"
22
21
  import { escalation, FIX_CAP, healthy, type EscalationCount } from "./health.js"
23
22
  import {
23
+ answerReviewQuestions,
24
24
  awaitReview,
25
25
  collecting,
26
26
  fix,
27
+ fixNits,
27
28
  fixQuality,
28
29
  QUALITY,
29
30
  REQUIREMENTS,
@@ -70,36 +71,43 @@ export type ReviewOutcome =
70
71
  readonly edited: readonly Change[]
71
72
  }
72
73
 
73
- /** One noul per `## ` chunk of `.gtd/REVIEW.md`: is its note actionable? */
74
- const triageQuestions = (chunks: readonly string[]): JudgeQuestion[] =>
75
- chunks.map((title, i) => ({
76
- id: `chunk-${i + 1}`,
77
- primitive: "noul",
78
- instructions: `Is the note under review chunk "${title}" (in .gtd/REVIEW.md) actionable — anything beyond an approving remark with no code edit?`,
79
- criteria:
80
- "A concrete request, a question, a code comment, or a hand-edit under this chunk answers yes. No note, or a purely approving remark, answers no.",
81
- }))
82
-
83
- /** A note-only round: judge whether any chunk of `review` asks for something. */
84
- const actionable = async (review: string): Promise<boolean> => {
85
- const found = sectionBodies(review)
86
- const chunks = found.map((section) => section.title)
87
- const evidence = Object.fromEntries(found.map(({ body }, i) => [`chunk-${i + 1}`, body]))
74
+ type Verdict = "edit" | "question" | "nit" | "praise"
75
+
76
+ const VERDICTS: readonly Verdict[] = ["edit", "question", "nit", "praise"]
77
+
78
+ /** One four-way choice per note the human added to `.gtd/REVIEW.md`. */
79
+ const verdictQuestion = (note: ReviewNote): JudgeQuestion => ({
80
+ id: note.id,
81
+ primitive: "choice",
82
+ instructions: `Classify the human's note on review item "${note.anchor}" (in .gtd/REVIEW.md). Its evidence holds the anchor, the reviewer's text and the human's text.`,
83
+ criteria:
84
+ "edit: a request to change behaviour, design or scope — anything needing a plan, or any note you are unsure about. question: asks something and wants an answer, with no change requested. nit: a small, local, unambiguous fix (naming, typo, formatting, comment wording) needing no re-plan. praise: an approving remark with nothing to do.",
85
+ })
86
+
87
+ /**
88
+ * One judge rest, one verdict per note. Dismissing a note is the risky
89
+ * direction, so only a confident non-`edit` verdict on uncut evidence counts;
90
+ * a cut, unanswered or below-floor note is an `edit`, and a blank floor makes
91
+ * every note one.
92
+ */
93
+ const triage = async (notes: readonly ReviewNote[]): Promise<ReadonlyMap<string, Verdict>> => {
88
94
  const { answers, truncated } = await judge("review.triage", {
89
- questions: triageQuestions(chunks),
90
- evidence,
95
+ questions: notes.map(verdictQuestion),
96
+ evidence: Object.fromEntries(
97
+ notes.map((n) => [
98
+ n.id,
99
+ `Anchor: ${n.anchor}\nReviewer's text: ${n.before}\nHuman's text: ${n.text}`,
100
+ ]),
101
+ ),
91
102
  message: t.buildReviewTriageMessage(),
92
- label: "Judging feedback actionability",
103
+ label: "Judging each note",
93
104
  })
94
- // Dismissing a note is the risky direction, so only a confident "no" on
95
- // uncut evidence dismisses a chunk; a blank floor dismisses nothing.
96
105
  const minP = numeric(vars.reviewNoteActionable, Infinity)
97
- return (
98
- chunks.length === 0 ||
99
- chunks.some((_, i) => {
100
- const id = `chunk-${i + 1}`
101
- return truncated.includes(id) || !answered(answers[id], "no", minP)
102
- })
106
+ return new Map(
107
+ notes.map((n): [string, Verdict] => {
108
+ const verdict = VERDICTS.find((v) => v !== "edit" && answered(answers[n.id], v, minP))
109
+ return [n.id, truncated.includes(n.id) || verdict === undefined ? "edit" : verdict]
110
+ }),
103
111
  )
104
112
  }
105
113
 
@@ -140,8 +148,8 @@ const notedSince = (commit: string): boolean =>
140
148
  * commit of the last `review.collecting` turn, `"missing"` when the round
141
149
  * left no review, or `undefined` when no such turn ran.
142
150
  */
143
- const converse = async (base: string): Promise<string | undefined> => {
144
- let collectedAt: string | undefined
151
+ const converse = async (base: string, collectedBefore?: string): Promise<string | undefined> => {
152
+ let collectedAt = collectedBefore
145
153
  for (;;) {
146
154
  await awaitReview(base)
147
155
  if (changes(REVIEW).some((c) => c.status === "deleted")) {
@@ -180,41 +188,111 @@ const settledAfter = (collectedAt: string | undefined): { folded: boolean; settl
180
188
  !notedSince(collectedAt),
181
189
  })
182
190
 
191
+ /** What `finish` hands back to `review`: an outcome, or another lap at the gate. */
192
+ type Finish =
193
+ | ReviewOutcome
194
+ | { readonly next: "await"; readonly reviewed: string }
195
+ | { readonly next: "rereview"; readonly carry: string | undefined }
196
+
197
+ /** What a round's routing needs from `finish`: its closing step and the ways it ends. */
198
+ interface Round {
199
+ readonly round: string
200
+ readonly escalations: EscalationCount
201
+ readonly close: () => Promise<void>
202
+ readonly outcome: (verdict: "signoff" | "feedback") => ReviewOutcome
203
+ readonly unfolded: () => Promise<Finish>
204
+ }
205
+
206
+ /** Judge every note, then run each verdict's route: answers, then nit fixes, then the edits' lap. */
207
+ const routeNotes = async (notes: readonly ReviewNote[], r: Round): Promise<Finish> => {
208
+ const verdicts = await triage(notes)
209
+ const of = (v: Verdict): ReviewNote[] => notes.filter((n) => verdicts.get(n.id) === v)
210
+ const [edits, questions, nits] = [of("edit"), of("question"), of("nit")]
211
+ if (edits.length + questions.length + nits.length === 0) return r.unfolded()
212
+
213
+ let answeredAt: string | undefined
214
+ if (questions.length > 0) {
215
+ await answerReviewQuestions(questions)
216
+ answeredAt = head()
217
+ }
218
+ if (nits.length > 0) {
219
+ await fixNits(nits)
220
+ await healthy(fix, { escalations: r.escalations })
221
+ }
222
+ if (edits.length > 0) {
223
+ await r.close()
224
+ return r.outcome(await collect(t.reviewEditNotesCapture(r.round, edits, answeredAt)))
225
+ }
226
+ if (nits.length > 0) {
227
+ await r.close()
228
+ return { next: "rereview", carry: answeredAt }
229
+ }
230
+ return { next: "await", reviewed: answeredAt! }
231
+ }
232
+
183
233
  const finish = async (
184
234
  reviewed: string,
185
235
  collectedAt: string | undefined,
186
- ): Promise<ReviewOutcome> => {
236
+ escalations: EscalationCount,
237
+ ): Promise<Finish> => {
238
+ // Everything the human did is read before any agent turn runs, so a nit fix
239
+ // is never counted as a hand-edit.
187
240
  const round = head()
188
241
  const since = changesSince(reviewed)
189
242
  const edited = since.filter(isCodeEdit)
243
+ const baselineText = since.get(REVIEW)?.before ?? ""
190
244
  const record = read(REVIEW) ?? ""
191
245
  const { folded, settled } = afterCollect(collectedAt)
192
246
  // The review gate clears every tick before its commit, so a changed
193
247
  // REVIEW.md is a note, never a tick.
194
248
  const noted = since.some((c) => c.path === REVIEW)
249
+ const close = (): Promise<void> =>
250
+ run("review.closing", removeScript([REVIEW]), { label: "Closing the review", base: round })
195
251
  const outcome = (verdict: "signoff" | "feedback"): ReviewOutcome =>
196
252
  verdict === "signoff" ? { verdict } : { verdict, base: round, restoreFrom: reviewed, edited }
197
- await run("review.closing", removeScript([REVIEW]), {
198
- label: "Closing the review",
199
- base: round,
200
- })
201
- if (settled) return outcome(folded ? "feedback" : "signoff")
202
- if (edited.length > 0) return outcome(await collect(t.reviewEditsCapture(round)))
203
- if (!noted || !(await actionable(record))) return outcome(folded ? "feedback" : "signoff")
204
- return outcome(await collect(t.reviewNotesCapture(round)))
253
+ const unfolded = async (): Promise<Finish> => {
254
+ await close()
255
+ return outcome(folded ? "feedback" : "signoff")
256
+ }
257
+
258
+ const collectWith = async (capture: string): Promise<Finish> => {
259
+ await close()
260
+ return outcome(await collect(capture))
261
+ }
262
+ if (settled || (edited.length === 0 && !noted)) return unfolded()
263
+ if (edited.length > 0) return collectWith(t.reviewEditsCapture(round))
264
+ const notes = reviewNotes(baselineText, record)
265
+ if (notes.length === 0) return collectWith(t.reviewNotesCapture(round))
266
+ return routeNotes(notes, { round, escalations, close, outcome, unfolded })
205
267
  }
206
268
 
207
269
  /**
208
270
  * A reviewer writes `.gtd/REVIEW.md` over everything since `base`, a human
209
- * reviews and signs off or comments, and a comment is classified into
210
- * requirements for another lap.
271
+ * reviews and signs off or comments, and each note is judged: edits go to
272
+ * another planning lap, questions are answered in place, nits fixed in one
273
+ * batch, praise dropped.
211
274
  */
212
- export const review = async (base: string): Promise<ReviewOutcome> => {
275
+ export const review = async (
276
+ base: string,
277
+ escalations: EscalationCount = { rounds: 0 },
278
+ ): Promise<ReviewOutcome> => {
279
+ let carry: string | undefined
213
280
  for (;;) {
214
- await reviewing(base)
215
- const reviewed = head()
216
- const collectedAt = await converse(base)
217
- if (collectedAt !== "missing") return finish(reviewed, collectedAt)
281
+ await reviewing(base, carry)
282
+ carry = undefined
283
+ let reviewed = head()
284
+ let collectedAt: string | undefined
285
+ for (;;) {
286
+ collectedAt = await converse(base, collectedAt)
287
+ if (collectedAt === "missing") break
288
+ const result = await finish(reviewed, collectedAt, escalations)
289
+ if (!("next" in result)) return result
290
+ if (result.next === "rereview") {
291
+ carry = result.carry
292
+ break
293
+ }
294
+ reviewed = result.reviewed
295
+ }
218
296
  }
219
297
  }
220
298
 
@@ -233,7 +311,7 @@ export const buildTail = (fixFirst: boolean, base: string): Promise<ReviewOutcom
233
311
  }
234
312
  const lap = lapped ? "clean" : await qualityLap()
235
313
  lapped = true
236
- if (lap === "clean") return review(base)
314
+ if (lap === "clean") return review(base, escalations)
237
315
  if (await fixQualityFindings(escalations)) await healthy(fix, { escalations })
238
316
  else redFirst = true
239
317
  }
@@ -104,4 +104,43 @@ describe("the bundled workflow's steps declare skills", () => {
104
104
  expect(request.options.skills).toEqual([])
105
105
  expect(request.prompt).not.toContain("Load whatever's listed here")
106
106
  })
107
+
108
+ it("answerReviewQuestions answers inline in .gtd/REVIEW.md as the reviewer, with reviewSkills", async () => {
109
+ const request = await capture(() =>
110
+ steps.answerReviewQuestions([{ id: "note-1", anchor: "calc ./a.ts#1-1", text: "why?" }]),
111
+ )
112
+ if (request.kind !== "agent") throw new Error("unreachable")
113
+ expect(request.name).toBe("review.answer-review-questions")
114
+ expect(request.options).toMatchObject({
115
+ file: ".gtd/REVIEW.md",
116
+ mode: "review",
117
+ skills: ["code-review-and-quality"],
118
+ })
119
+ expect(request.prompt).toContain("note-1 — calc ./a.ts#1-1")
120
+ expect(request.prompt).toContain("`A: ` line")
121
+ expect(request.prompt).toContain("gtd check review .gtd/REVIEW.md")
122
+ })
123
+
124
+ it("fixNits fixes every nit in one turn and leaves .gtd/REVIEW.md alone", async () => {
125
+ const request = await capture(() =>
126
+ steps.fixNits([
127
+ { id: "note-1", anchor: "calc ./a.ts#1-1", text: "typo" },
128
+ { id: "note-2", anchor: "calc ./a.ts#2-2", text: "semicolon" },
129
+ ]),
130
+ )
131
+ if (request.kind !== "agent") throw new Error("unreachable")
132
+ expect(request.name).toBe("review.fix-nits")
133
+ expect(request.options.skills).toEqual(["incremental-implementation", "code-simplification"])
134
+ expect(request.prompt).toContain("typo")
135
+ expect(request.prompt).toContain("semicolon")
136
+ expect(request.prompt).toContain("Leave `.gtd/REVIEW.md` untouched")
137
+ })
138
+
139
+ it("reviewing names the carry-over commit only when given one", async () => {
140
+ const bare = await capture(() => steps.reviewing("base"))
141
+ const carried = await capture(() => steps.reviewing("base", "abc1234"))
142
+ if (bare.kind !== "agent" || carried.kind !== "agent") throw new Error("unreachable")
143
+ expect(bare.prompt).not.toContain("Carry-over")
144
+ expect(carried.prompt).toContain("Carry-over: commit `abc1234`")
145
+ })
107
146
  })
@@ -158,15 +158,47 @@ export const fixQuality = (): Promise<void> =>
158
158
  allowEmpty: true,
159
159
  })
160
160
 
161
- /** Write `.gtd/REVIEW.md` over everything since `base`. */
162
- export const reviewing = (base: string): Promise<void> =>
163
- t.agentWithSkills("review.reviewing", vars.reviewSkills, t.buildReviewReviewingPrompt(base), {
164
- label: "Reviewing",
161
+ /** Write `.gtd/REVIEW.md` over everything since `base`, carrying the answers of commit `carry` when given. */
162
+ export const reviewing = (base: string, carry?: string): Promise<void> =>
163
+ t.agentWithSkills(
164
+ "review.reviewing",
165
+ vars.reviewSkills,
166
+ t.buildReviewReviewingPrompt(base, carry),
167
+ {
168
+ label: "Reviewing",
169
+ file: REVIEW,
170
+ mode: "review",
171
+ model: planner(),
172
+ system: t.reviewerSystem(),
173
+ base,
174
+ },
175
+ )
176
+
177
+ /** Answer every `question` note inline in `.gtd/REVIEW.md`. */
178
+ export const answerReviewQuestions = (notes: readonly t.NoteInput[]): Promise<void> =>
179
+ t.agentWithSkills(
180
+ "review.answer-review-questions",
181
+ vars.reviewSkills,
182
+ t.buildReviewAnswerQuestionsPrompt(notes),
183
+ {
184
+ label: "Answering your questions",
185
+ file: REVIEW,
186
+ mode: "review",
187
+ model: planner(),
188
+ system: t.reviewerSystem(),
189
+ },
190
+ )
191
+
192
+ /**
193
+ * Fix every `nit` note in one turn. Its name puts it in the `build.review`
194
+ * conversation, which has one identity — so it runs as the reviewer, not the coder.
195
+ */
196
+ export const fixNits = (notes: readonly t.NoteInput[]): Promise<void> =>
197
+ t.agentWithSkills("review.fix-nits", vars.reviewFixSkills, t.buildReviewFixNitsPrompt(notes), {
198
+ label: "Fixing your nits",
165
199
  file: REVIEW,
166
- mode: "review",
167
200
  model: planner(),
168
201
  system: t.reviewerSystem(),
169
- base,
170
202
  })
171
203
 
172
204
  export const awaitReview = (base: string): Promise<void> =>