@pmelab/gtd 28.2.0 → 29.1.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 (53) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +28 -14
  3. package/claude/hooks/handoff.ts +2 -3
  4. package/claude/hooks/ship.ts +762 -62
  5. package/dist/chunks/{reserved-DeBS4Ymh.mjs → reserved-DoHUII2D.mjs} +206 -121
  6. package/dist/chunks/{tail-B2SER2Ih.mjs → tail-C6YBG5zh.mjs} +19 -12
  7. package/dist/testkit.mjs +2 -1
  8. package/dist/workflow-kit.mjs +51 -21
  9. package/dist/workflows/cleanup.mjs +2 -2
  10. package/dist/workflows/feature.mjs +247 -96
  11. package/dist/workflows/fix.mjs +2 -2
  12. package/dist/workflows/review.mjs +2 -2
  13. package/dist/workflows/rollout.mjs +1 -1
  14. package/dist/workflows.mjs +528 -262
  15. package/package.json +1 -1
  16. package/src/testkit/TestRepo.ts +3 -0
  17. package/src/testkit/index.ts +1 -1
  18. package/src/testkit/steps.ts +44 -0
  19. package/src/workflow-kit/review.test.ts +366 -8
  20. package/src/workflow-kit/review.ts +109 -26
  21. package/src/workflows/feature/feature.test.ts +313 -1
  22. package/src/workflows/feature/feature.ts +48 -43
  23. package/src/workflows/feature/one-step-land.feature +4 -0
  24. package/src/workflows/feature/refactor-first.feature +54 -24
  25. package/src/workflows/feature/refactor-review.feature +1706 -0
  26. package/src/workflows/feature/refactor.test.ts +592 -2
  27. package/src/workflows/feature/refactor.ts +160 -42
  28. package/src/workflows/feature/review.test.ts +322 -5
  29. package/src/workflows/feature/review.ts +77 -10
  30. package/src/workflows/feature/seam-rejection.feature +4 -0
  31. package/src/workflows/feature/seam.feature +3 -4
  32. package/src/workflows/feature/seam.test.ts +196 -1
  33. package/src/workflows/feature/seam.ts +23 -9
  34. package/src/workflows/feature/steps.test.ts +54 -2
  35. package/src/workflows/feature/steps.ts +45 -9
  36. package/src/workflows/feature/tier.test.ts +4 -0
  37. package/src/workflows/feature/tier.ts +2 -20
  38. package/src/workflows/shared/__fixtures__/feature-collecting-prompt.txt +97 -0
  39. package/src/workflows/shared/access.test.ts +31 -1
  40. package/src/workflows/shared/access.ts +7 -1
  41. package/src/workflows/shared/fences.ts +19 -0
  42. package/src/workflows/shared/health.ts +32 -12
  43. package/src/workflows/shared/options.test.ts +18 -0
  44. package/src/workflows/shared/options.ts +1 -0
  45. package/src/workflows/shared/prose.ts +4 -2
  46. package/src/workflows/shared/skills.test.ts +20 -1
  47. package/src/workflows/shared/skills.ts +43 -32
  48. package/src/workflows/shared/steps.ts +33 -19
  49. package/src/workflows/shared/tail.test.ts +84 -1
  50. package/src/workflows/shared/tail.ts +30 -12
  51. package/src/workflows/shared/text.test.ts +266 -3
  52. package/src/workflows/shared/text.ts +183 -69
  53. package/src/workflows/shared/vars.ts +12 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "28.2.0",
3
+ "version": "29.1.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": {
@@ -72,6 +72,8 @@ export interface TestRepo {
72
72
  subjects(): readonly string[]
73
73
  /** Every commit's full message, subject and trailers, oldest first. */
74
74
  messages(): readonly string[]
75
+ /** Every commit's full hash, oldest first. */
76
+ hashes(): readonly string[]
75
77
  }
76
78
 
77
79
  const REGISTRY = Symbol.for("@pmelab/gtd/testkit:modules")
@@ -286,6 +288,7 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
286
288
  },
287
289
  subjects: () => repo.commitHistory().map(({ message }) => message.split("\n")[0] ?? ""),
288
290
  messages: () => repo.commitHistory().map(({ message }) => message),
291
+ hashes: () => repo.commitHistory().map(({ hash }) => hash),
289
292
  }
290
293
  return self
291
294
  }
@@ -1,4 +1,4 @@
1
- export { runsItsScript, testRepo, type TestRepo } from "./TestRepo.js"
1
+ export { runsItsScript, testRepo, type Actor, type TestRepo } from "./TestRepo.js"
2
2
  export { InMemRepo } from "./InMemRepo.js"
3
3
  export { makeInMemoryWorkspaceOps, testLayers, type ScriptedCommand } from "./Layers.js"
4
4
  export { makeCapturingCliIo } from "./cliIo.js"
@@ -121,6 +121,11 @@ Given("step {string} from now on changes nothing", (world: TestkitWorld, step: s
121
121
  world.actions.set(step, [() => undefined])
122
122
  })
123
123
 
124
+ // Lets a later visit rest at a step an earlier one had scripted.
125
+ Given("step {string} is no longer scripted", (world: TestkitWorld, step: string) => {
126
+ world.actions.delete(step)
127
+ })
128
+
124
129
  Given(
125
130
  "step {string} writes {string} with:",
126
131
  (world: TestkitWorld, step: string, path: string, content: string) => {
@@ -141,6 +146,17 @@ Given(
141
146
  },
142
147
  )
143
148
 
149
+ // A closing step deletes the file before its collecting turn runs, so this edit
150
+ // leaves a closed turn changing nothing while still answering an in-gate one.
151
+ Given(
152
+ "step {string} rewrites {string} with:",
153
+ (world: TestkitWorld, step: string, path: string, content: string) => {
154
+ world.act(step, (_, repo) => {
155
+ if (repo.read(path) !== undefined) repo.write(path, withNewline(content))
156
+ })
157
+ },
158
+ )
159
+
144
160
  Given("step {string} runs its script", (world: TestkitWorld, step: string) => {
145
161
  world.act(step, runsItsScript)
146
162
  })
@@ -202,6 +218,13 @@ Then(
202
218
  },
203
219
  )
204
220
 
221
+ Then(
222
+ "driving the process is refused without {string}",
223
+ async (world: TestkitWorld, message: string) => {
224
+ await assert.rejects(world.drive(), (error: Error) => !error.message.includes(message))
225
+ },
226
+ )
227
+
205
228
  Then("the process rests at {string}", async (world: TestkitWorld, step: string) => {
206
229
  assert.strictEqual((await world.repo().next()).state, step)
207
230
  })
@@ -211,6 +234,17 @@ Then("the resting step's content contains {string}", async (world: TestkitWorld,
211
234
  assert.ok(content.includes(text), content)
212
235
  })
213
236
 
237
+ Then(
238
+ "the resting step's content contains the hash of the commit {string}",
239
+ async (world: TestkitWorld, subject: string) => {
240
+ const repo = world.repo()
241
+ const hash = repo.hashes()[repo.subjects().lastIndexOf(subject)]
242
+ assert.ok(hash !== undefined, `no commit "${subject}"`)
243
+ const { content } = await repo.next()
244
+ assert.ok(content.includes(hash), content)
245
+ },
246
+ )
247
+
214
248
  Then(
215
249
  "the resting step's content does not contain {string}",
216
250
  async (world: TestkitWorld, text: string) => {
@@ -250,6 +284,16 @@ Then(
250
284
  },
251
285
  )
252
286
 
287
+ Then(
288
+ "the output lines of gtd {string} start with:",
289
+ async (world: TestkitWorld, list: string, prefixes: string) => {
290
+ const out = (await outputOf(world, list)).split("\n").filter((line) => line !== "")
291
+ const expected = prefixes.split("\n").filter((line) => line.trim() !== "")
292
+ assert.strictEqual(out.length, expected.length, out.join("\n"))
293
+ out.forEach((line, i) => assert.ok(line.startsWith(expected[i]!), out.join("\n")))
294
+ },
295
+ )
296
+
253
297
  Then("{string} contains {string}", (world: TestkitWorld, path: string, text: string) => {
254
298
  assert.ok(world.repo().read(path)?.includes(text), `${path}: ${world.repo().read(path)}`)
255
299
  })
@@ -1,13 +1,30 @@
1
1
  import { beforeEach, describe, expect, it, vi } from "vitest"
2
2
 
3
- const state = vi.hoisted(() => ({ feedback: undefined as string | undefined, head: "h1" }))
3
+ const state = vi.hoisted(() => ({
4
+ feedback: undefined as string | undefined,
5
+ head: "h1",
6
+ pending: [] as unknown[],
7
+ files: {} as Record<string, string | undefined>,
8
+ since: [] as { path: string; status: string; before?: string; after?: string }[],
9
+ sinceAfter: undefined as
10
+ | { path: string; status: string; before?: string; after?: string }[]
11
+ | undefined,
12
+ agentThread: false,
13
+ humanThread: false,
14
+ }))
4
15
 
5
16
  vi.mock("../flows/index.js", () => ({
6
- changes: () => [],
7
- changesSince: () => [],
8
- hasThreadFor: () => false,
17
+ changes: () => state.pending,
18
+ changesSince: (commit: string) => {
19
+ const list = commit === "h2" && state.sinceAfter ? state.sinceAfter : state.since
20
+ return Object.assign([...list], {
21
+ paths: list.map((c) => c.path),
22
+ get: (path: string) => list.find((c) => c.path === path),
23
+ })
24
+ },
25
+ hasThreadFor: (who: string) => (who === "agent" ? state.agentThread : state.humanThread),
9
26
  head: () => state.head,
10
- read: () => state.feedback,
27
+ read: (path: string) => (path === "f.md" ? state.feedback : state.files[path]),
11
28
  refuse: (m: string) => {
12
29
  throw new Error(m)
13
30
  },
@@ -15,12 +32,20 @@ vi.mock("../flows/index.js", () => ({
15
32
  requireThreadsClosed: () => {},
16
33
  }))
17
34
 
18
- import { routeNotes, type ReviewPolicy, type Verdict } from "./review.js"
35
+ import {
36
+ review,
37
+ reviewToHandOff,
38
+ routeNotes,
39
+ type HandOffPolicy,
40
+ type Requested,
41
+ type ReviewPolicy,
42
+ type Verdict,
43
+ } from "./review.js"
19
44
  import type { ReviewNote } from "../steering/index.js"
20
45
 
21
46
  const note = (id: string) => ({ id }) as unknown as ReviewNote
22
47
 
23
- const setup = (verdicts: Record<string, Verdict>) => {
48
+ const setup = (verdicts: Record<string, Verdict>, silent = false, dropped = false) => {
24
49
  const calls: string[] = []
25
50
  const deferred: string[][] = []
26
51
  const collected: ReviewNote[][] = []
@@ -33,7 +58,7 @@ const setup = (verdicts: Record<string, Verdict>) => {
33
58
  keepGreen: async () => void calls.push("green"),
34
59
  collecting: async () => {
35
60
  calls.push("collect")
36
- state.feedback = "x"
61
+ if (!silent) state.feedback = "x"
37
62
  },
38
63
  captures: {
39
64
  editNotes: (_r: string, edits: readonly ReviewNote[]) => {
@@ -41,6 +66,12 @@ const setup = (verdicts: Record<string, Verdict>) => {
41
66
  return "cap"
42
67
  },
43
68
  },
69
+ refusals: dropped
70
+ ? {
71
+ dropped: (r: Requested) =>
72
+ `dropped: ${r.notes.map((n) => n.id).join(",")} / ${r.paths.join(",")}`,
73
+ }
74
+ : {},
44
75
  defer: async (ns: readonly ReviewNote[]) => {
45
76
  calls.push("defer")
46
77
  deferred.push(ns.map((n) => n.id))
@@ -57,6 +88,13 @@ const setup = (verdicts: Record<string, Verdict>) => {
57
88
 
58
89
  beforeEach(() => {
59
90
  state.feedback = undefined
91
+ state.pending = []
92
+ state.files = {}
93
+ state.since = []
94
+ state.sinceAfter = undefined
95
+ state.head = "h1"
96
+ state.agentThread = false
97
+ state.humanThread = false
60
98
  })
61
99
 
62
100
  describe("routeNotes defer", () => {
@@ -82,3 +120,323 @@ describe("routeNotes defer", () => {
82
120
  expect(t.calls).toEqual(["nits", "green", "defer", "close", "collect"])
83
121
  })
84
122
  })
123
+
124
+ describe("routeNotes dropped", () => {
125
+ it("an edit note whose collecting turn writes nothing is refused naming the note", async () => {
126
+ const t = setup({ a: "edit" }, true, true)
127
+ await expect(routeNotes(t.p, [note("a")], t.r)).rejects.toThrow("dropped: a / ")
128
+ })
129
+
130
+ it("signs off when no dropped refusal is set", async () => {
131
+ const t = setup({ a: "edit" }, true)
132
+ expect(await routeNotes(t.p, [note("a")], t.r)).toEqual({ verdict: "signoff" })
133
+ })
134
+
135
+ it("a collecting turn that changed something else is unmatched, not dropped", async () => {
136
+ const t = setup({ a: "edit" }, true, true)
137
+ Object.assign(t.p.refusals, { unmatched: "unmatched" })
138
+ state.pending = [{ path: "x" }]
139
+ await expect(routeNotes(t.p, [note("a")], t.r)).rejects.toThrow("unmatched")
140
+ })
141
+
142
+ it("a praise-only round never collects, so signs off", async () => {
143
+ const t = setup({ a: "praise" }, true, true)
144
+ expect(await routeNotes(t.p, [note("a")], t.r)).toEqual({ verdict: "signoff" })
145
+ })
146
+ })
147
+
148
+ const doc = (...body: string[]): string =>
149
+ [
150
+ "# Review: abc1234",
151
+ "",
152
+ "<!-- base: 0000000000000000000000000000000000000000 -->",
153
+ "",
154
+ "## calc",
155
+ "",
156
+ ...body,
157
+ "",
158
+ ].join("\n")
159
+
160
+ const POINTER = "- [ ] ./a.ts#1-1 does x"
161
+ const thread = (...entries: string[]): string =>
162
+ ["[^t]:", ...entries.map((e) => ` - ${e}`)].join("\n")
163
+ const oneShot = (id: string, text: string): string => `[^${id}]:\n ${text}`
164
+
165
+ /** Drives `review` over one round: `before` is the file as reviewed, `after` what the human left. */
166
+ const driveRound = async (
167
+ before: string,
168
+ after: string,
169
+ opts: {
170
+ verdicts?: Record<string, Verdict>
171
+ inGate?: string
172
+ handEdit?: boolean
173
+ silent?: boolean
174
+ /** Nothing but thread edits lands after the in-gate collecting turn. */
175
+ settled?: boolean
176
+ noDropped?: boolean
177
+ } = {},
178
+ ) => {
179
+ const triaged: string[][] = []
180
+ const captured: string[] = []
181
+ const editRanges: (string | undefined)[] = []
182
+ const dropped: Requested[] = []
183
+ const rounds: string[] = []
184
+ let awaits = 0
185
+ const p = {
186
+ file: "r.md",
187
+ feedback: "f.md",
188
+ isCode: (path: string) => path.endsWith(".ts"),
189
+ reviewing: async () => void (state.files["r.md"] = before),
190
+ fixRisks: async () => {},
191
+ keepGreen: async () => {},
192
+ awaitReview: async () => {
193
+ awaits++
194
+ if (awaits > 2 || (awaits > 1 && opts.inGate === undefined)) throw new Error("stop")
195
+ if (opts.inGate !== undefined && awaits === 1) {
196
+ state.agentThread = true
197
+ return
198
+ }
199
+ state.humanThread = false
200
+ state.files["r.md"] = after
201
+ state.since = [{ path: "r.md", status: "modified", before, after }]
202
+ if (opts.handEdit)
203
+ state.since.push({ path: "a.ts", status: "modified", before: "a", after: "b" })
204
+ },
205
+ reviewMissing: async () => {},
206
+ collecting: async (_capture: string, round: string) => {
207
+ rounds.push(round)
208
+ if (opts.inGate !== undefined && state.agentThread) {
209
+ state.agentThread = false
210
+ state.humanThread = true
211
+ state.files["r.md"] = opts.inGate
212
+ state.head = "h2"
213
+ if (opts.settled) state.sinceAfter = []
214
+ return
215
+ }
216
+ if (!opts.silent) state.feedback = "x"
217
+ },
218
+ triage: async (ns: readonly ReviewNote[]) => {
219
+ triaged.push(ns.map((n) => n.id))
220
+ return new Map(Object.entries(opts.verdicts ?? {}))
221
+ },
222
+ answerQuestions: async () => {},
223
+ fixNits: async () => {},
224
+ defer: async () => {},
225
+ close: async () => {},
226
+ captures: {
227
+ edits: (_round: string, from?: string) => (
228
+ captured.push("edits"),
229
+ editRanges.push(from),
230
+ "edits"
231
+ ),
232
+ notes: () => (captured.push("notes"), "notes"),
233
+ editNotes: () => (captured.push("editNotes"), "editNotes"),
234
+ },
235
+ refusals: {
236
+ deleted: "deleted",
237
+ unmatched: "unmatched",
238
+ ...(opts.noDropped
239
+ ? {}
240
+ : {
241
+ dropped: (r: Requested) => {
242
+ dropped.push(r)
243
+ return "dropped"
244
+ },
245
+ }),
246
+ },
247
+ } as unknown as ReviewPolicy
248
+ const result = await review("base", p).catch((e: Error) => e)
249
+ return { result, triaged, dropped, captured, rounds, editRanges }
250
+ }
251
+
252
+ describe("a one-shot footnote is a note, a thread footnote is not", () => {
253
+ const base = doc(`${POINTER}[^t]`, "", thread("H: why?"))
254
+ const replied = doc(`${POINTER}[^t]`, "", thread("H: why?", "A: because"))
255
+
256
+ it("a one-shot footnote added after an in-gate collecting turn routes through triage", async () => {
257
+ const withFootnote = doc(
258
+ `${POINTER}[^t][^n1]`,
259
+ "",
260
+ thread("H: why?", "A: because"),
261
+ "",
262
+ oneShot("n1", "please rename this"),
263
+ )
264
+ const r = await driveRound(base, withFootnote, {
265
+ inGate: replied,
266
+ verdicts: { "note-1": "praise" },
267
+ })
268
+ expect(r.triaged).toHaveLength(1)
269
+ expect(r.triaged[0]).toHaveLength(1)
270
+ })
271
+
272
+ it("only a thread reply after an in-gate collecting turn still settles", async () => {
273
+ const r = await driveRound(base, replied, { inGate: replied })
274
+ expect(r.triaged).toEqual([])
275
+ expect(r.result).toEqual({ verdict: "signoff" })
276
+ })
277
+ })
278
+
279
+ describe("the dropped refusal lists edit notes only", () => {
280
+ const before = doc(POINTER, "- [ ] ./a.ts#2-2 other")
281
+ const after = doc(`${POINTER} nice`, "- [ ] ./a.ts#2-2 other rename it")
282
+
283
+ it("a hand-edit round names the edited paths and no notes", async () => {
284
+ const r = await driveRound(before, after, { handEdit: true, silent: true })
285
+ expect(r.result).toBeInstanceOf(Error)
286
+ expect(r.dropped).toEqual([{ notes: [], paths: ["a.ts"] }])
287
+ })
288
+
289
+ it('a round with unparsed notes collects as "notes", a triaged one as "triaged"', async () => {
290
+ const unparsed = await driveRound(before, `${before}\n\nstray prose`, { silent: true })
291
+ expect(unparsed.rounds).toEqual(["notes"])
292
+ const praise = doc(`${POINTER}[^n1]`, "- [ ] ./a.ts#2-2 other", "", oneShot("n1", "rename it"))
293
+ const triaged = await driveRound(before, praise, {
294
+ verdicts: { "note-1": "edit" },
295
+ silent: true,
296
+ })
297
+ expect(triaged.rounds).toEqual(["triaged"])
298
+ })
299
+
300
+ it("a notes-only round names the notes judged edit, footnotes included", async () => {
301
+ const praise = doc(
302
+ `${POINTER}[^n1]`,
303
+ "- [ ] ./a.ts#2-2 other[^n2]",
304
+ "",
305
+ oneShot("n1", "lovely"),
306
+ "",
307
+ oneShot("n2", "rename it"),
308
+ )
309
+ const r = await driveRound(before, praise, {
310
+ verdicts: { "note-1": "praise", "note-2": "edit" },
311
+ silent: true,
312
+ })
313
+ expect(r.result).toBeInstanceOf(Error)
314
+ expect(r.triaged[0]).toHaveLength(2)
315
+ expect(r.dropped).toHaveLength(1)
316
+ expect(r.dropped[0]!.paths).toEqual([])
317
+ expect(r.dropped[0]!.notes).toHaveLength(1)
318
+ expect(r.dropped[0]!.notes[0]!.anchor).toContain("[^n2]")
319
+ })
320
+ })
321
+
322
+ describe("a settled round with an unfolded change", () => {
323
+ const base = doc(`${POINTER}[^t]`, "", thread("H: why?"))
324
+ const replied = doc(`${POINTER}[^t]`, "", thread("H: why?", "A: because"))
325
+ const withNote = (text: string) =>
326
+ doc(`${POINTER} ${text}[^t]`, "", thread("H: why?", "A: because", "H: ok"))
327
+
328
+ it("an edit note since reviewed with an empty collecting turn refuses naming the note", async () => {
329
+ const r = await driveRound(base, withNote("rename it"), {
330
+ inGate: replied,
331
+ settled: true,
332
+ silent: true,
333
+ verdicts: { "note-1": "edit" },
334
+ })
335
+ expect(r.result).toBeInstanceOf(Error)
336
+ expect(r.triaged).toHaveLength(1)
337
+ expect(r.captured).toEqual(["edits", "editNotes"])
338
+ expect(r.rounds).toEqual(["reply", "triaged"])
339
+ expect(r.dropped).toHaveLength(1)
340
+ expect(r.dropped[0]!.notes).toHaveLength(1)
341
+ expect(r.dropped[0]!.paths).toEqual([])
342
+ })
343
+
344
+ it("a code hand-edit since reviewed names the path", async () => {
345
+ const r = await driveRound(base, replied, {
346
+ inGate: replied,
347
+ settled: true,
348
+ silent: true,
349
+ handEdit: true,
350
+ })
351
+ expect(r.result).toBeInstanceOf(Error)
352
+ expect(r.triaged).toEqual([])
353
+ expect(r.captured).toEqual(["edits", "edits"])
354
+ expect(r.rounds).toEqual(["reply", "edits"])
355
+ expect(r.dropped).toEqual([{ notes: [], paths: ["a.ts"] }])
356
+ // the in-gate reply capture has no range; the settled round's starts at `reviewed`
357
+ expect(r.editRanges).toEqual([undefined, "h1"])
358
+ })
359
+
360
+ it("a nit-only note is fixed without a collecting turn or a refusal", async () => {
361
+ const r = await driveRound(base, withNote("typo"), {
362
+ inGate: replied,
363
+ settled: true,
364
+ silent: true,
365
+ verdicts: { "note-1": "nit" },
366
+ })
367
+ expect(r.rounds).toEqual(["reply"])
368
+ expect(r.dropped).toEqual([])
369
+ })
370
+
371
+ it("a closed collecting turn that writes the feedback file turns the round into feedback", async () => {
372
+ const r = await driveRound(base, withNote("rename it"), {
373
+ inGate: replied,
374
+ settled: true,
375
+ verdicts: { "note-1": "edit" },
376
+ })
377
+ expect(r.result).toMatchObject({ verdict: "feedback" })
378
+ expect(r.dropped).toEqual([])
379
+ })
380
+
381
+ it("praise-only notes sign off", async () => {
382
+ const r = await driveRound(base, withNote("lovely"), {
383
+ inGate: replied,
384
+ settled: true,
385
+ verdicts: { "note-1": "praise" },
386
+ })
387
+ expect(r.triaged).toHaveLength(1)
388
+ expect(r.result).toEqual({ verdict: "signoff" })
389
+ })
390
+
391
+ it("only thread replies sign off without triage", async () => {
392
+ const r = await driveRound(
393
+ base,
394
+ doc(`${POINTER}[^t]`, "", thread("H: why?", "A: because", "H: ok")),
395
+ {
396
+ inGate: replied,
397
+ settled: true,
398
+ },
399
+ )
400
+ expect(r.triaged).toEqual([])
401
+ expect(r.result).toEqual({ verdict: "signoff" })
402
+ })
403
+
404
+ it("without a dropped refusal an edit note signs off unjudged", async () => {
405
+ const r = await driveRound(base, withNote("rename it"), {
406
+ inGate: replied,
407
+ settled: true,
408
+ noDropped: true,
409
+ verdicts: { "note-1": "edit" },
410
+ })
411
+ expect(r.triaged).toEqual([])
412
+ expect(r.result).toEqual({ verdict: "signoff" })
413
+ })
414
+ })
415
+
416
+ describe("reviewToHandOff", () => {
417
+ it("hands off a footnote-only edit instead of closing as a sign-off", async () => {
418
+ const before = doc(POINTER)
419
+ const after = doc(`${POINTER}[^n1]`, "", oneShot("n1", "rename it"))
420
+ const calls: string[] = []
421
+ let awaits = 0
422
+ const p = {
423
+ file: "r.md",
424
+ reviewing: async () => void (state.files["r.md"] = before),
425
+ fixRisks: async () => {},
426
+ keepGreen: async () => {},
427
+ awaitReview: async () => {
428
+ if (++awaits > 1) throw new Error("stop")
429
+ state.files["r.md"] = after
430
+ state.since = [{ path: "r.md", status: "modified", before, after }]
431
+ },
432
+ reviewMissing: async () => {},
433
+ close: async () => void calls.push("close"),
434
+ handOff: async () => void calls.push("handOff"),
435
+ deleted: "deleted",
436
+ threadsPending: "pending",
437
+ unparsed: "unparsed",
438
+ } as unknown as HandOffPolicy
439
+ await reviewToHandOff("base", p)
440
+ expect(calls).toEqual(["handOff"])
441
+ })
442
+ })