@pmelab/gtd 41.0.0 → 43.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 (73) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +107 -53
  3. package/claude/hooks/sketch.ts +1 -1
  4. package/dist/chunks/{reserved-Ch4LLrEj.mjs → reserved-COEMFEpy.mjs} +84 -51
  5. package/dist/chunks/{steering-DVILanVf.mjs → steering-D4LUQ-av.mjs} +95 -12
  6. package/dist/chunks/tail-C_xn8U7U.mjs +246 -0
  7. package/dist/flows.mjs +11 -1
  8. package/dist/gtd.bundle.mjs +498 -30
  9. package/dist/steering.mjs +2 -2
  10. package/dist/testkit.mjs +460 -28
  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 +66 -57
  15. package/dist/workflows/fix.mjs +3 -3
  16. package/dist/workflows/review.mjs +2 -2
  17. package/dist/workflows/rollout.mjs +1 -1
  18. package/dist/workflows.mjs +446 -141
  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/flows/scripts.ts +15 -0
  29. package/src/steering/index.ts +12 -1
  30. package/src/steering/review.test.ts +109 -20
  31. package/src/steering/review.ts +160 -9
  32. package/src/testkit/EmittedScriptRecognizer.ts +0 -14
  33. package/src/testkit/FlowScripts.test.ts +17 -0
  34. package/src/testkit/FlowScripts.ts +15 -0
  35. package/src/testkit/steps.ts +31 -0
  36. package/src/workflow-kit/review.test.ts +170 -26
  37. package/src/workflow-kit/review.ts +95 -19
  38. package/src/workflows/feature/coverage.feature +3 -3
  39. package/src/workflows/feature/fast-path.feature +2 -2
  40. package/src/workflows/feature/feature.test.ts +2 -37
  41. package/src/workflows/feature/feature.ts +12 -14
  42. package/src/workflows/feature/flag-mode.feature +2 -2
  43. package/src/workflows/feature/lens-review.feature +246 -0
  44. package/src/workflows/feature/one-step-land.feature +2 -2
  45. package/src/workflows/feature/quality-unresolved.feature +47 -32
  46. package/src/workflows/feature/refactor-absorbs.feature +3 -3
  47. package/src/workflows/feature/refactor-first.feature +5 -5
  48. package/src/workflows/feature/refactor-rebuild.feature +7 -7
  49. package/src/workflows/feature/refactor-review.feature +19 -100
  50. package/src/workflows/feature/refactor.test.ts +12 -27
  51. package/src/workflows/feature/refactor.ts +0 -1
  52. package/src/workflows/feature/review-answers.feature +179 -0
  53. package/src/workflows/feature/review-gate.feature +6 -6
  54. package/src/workflows/feature/review.test.ts +33 -77
  55. package/src/workflows/feature/review.ts +79 -47
  56. package/src/workflows/feature/seam-rejection.feature +2 -2
  57. package/src/workflows/feature/seam.feature +2 -2
  58. package/src/workflows/feature/seam.test.ts +1 -1
  59. package/src/workflows/feature/slicing.feature +6 -6
  60. package/src/workflows/feature/steps.test.ts +0 -12
  61. package/src/workflows/feature/steps.ts +3 -10
  62. package/src/workflows/index.ts +1 -0
  63. package/src/workflows/shared/__fixtures__/feature-collecting-prompt.txt +6 -5
  64. package/src/workflows/shared/access.test.ts +2 -11
  65. package/src/workflows/shared/access.ts +0 -2
  66. package/src/workflows/shared/lenses.test.ts +39 -0
  67. package/src/workflows/shared/lenses.ts +176 -0
  68. package/src/workflows/shared/options.ts +1 -0
  69. package/src/workflows/shared/tail.ts +19 -4
  70. package/src/workflows/shared/text.test.ts +106 -88
  71. package/src/workflows/shared/text.ts +164 -53
  72. package/src/workflows/shared/vars.ts +1 -0
  73. package/dist/chunks/tail-lY1UmJrU.mjs +0 -112
@@ -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
@@ -61,13 +61,13 @@ Feature: a feature is done only when every acceptance criterion has a passing te
61
61
  [{"id":"AC-1","answer":"yes","p":0.9},{"id":"AC-2","answer":"yes","p":0.9}]
62
62
  """
63
63
  When the process is driven
64
- Then the process rests at "build.review.reviewing"
64
+ Then the process rests at "build.review.composing"
65
65
  And the commit subjects end with:
66
66
  """
67
67
  gtd(check): implement.applying → implement.health.check
68
68
  gtd(check): implement.health.check → implement.coverage
69
69
  gtd(judge): implement.coverage → implement.closing
70
- gtd(check): implement.closing → build.review.reviewing
70
+ gtd(check): implement.closing → build.review.composing
71
71
  """
72
72
  And the commit "gtd(judge): implement.coverage → implement.closing" has a body containing "Gtd-Mark: covers=AC-1"
73
73
  And the commit "gtd(judge): implement.coverage → implement.closing" has a body containing "Gtd-Mark: covers=AC-2"
@@ -144,7 +144,7 @@ Feature: a feature is done only when every acceptance criterion has a passing te
144
144
  """
145
145
  And step "implement.uncovered" changes nothing
146
146
  When the process is driven
147
- Then the process rests at "build.review.reviewing"
147
+ Then the process rests at "build.review.composing"
148
148
  And the commit "gtd(human): implement.uncovered → implement.closing" has a body containing "Gtd-Mark: covers=AC-1"
149
149
  And the commit "gtd(human): implement.uncovered → implement.closing" has a body containing "Gtd-Mark: uncovered=AC-2"
150
150
 
@@ -64,7 +64,7 @@ Feature: a trivial change takes the same few beats at either risk tier
64
64
  """
65
65
  And step "implement.applying" runs its script
66
66
  When the process is driven
67
- Then the process rests at "build.review.reviewing"
67
+ Then the process rests at "build.review.composing"
68
68
  And "src/greeting.ts" contains "Hello, world"
69
69
  And the commit subjects end with:
70
70
  """
@@ -80,7 +80,7 @@ Feature: a trivial change takes the same few beats at either risk tier
80
80
  gtd(check): implement.health.check → implement.coverage
81
81
  gtd(judge): implement.coverage → implement.closing
82
82
  gtd(check): implement.closing → build.quality.correctness.reviewing
83
- gtd(agent): build.quality.correctness.reviewing → build.review.reviewing
83
+ gtd(agent): build.quality.correctness.reviewing → build.review.composing
84
84
  """
85
85
  And the output of gtd "summary" <summary> "needs a human reviewer"
86
86
 
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest"
2
- import { runsItsScript, testRepo, type Actor } from "../../testkit/index.js"
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
5
 
@@ -75,10 +75,7 @@ describe("afterTail feedback lap", () => {
75
75
  `}`,
76
76
  ].join("\n")
77
77
 
78
- const prompts: string[] = []
79
-
80
78
  const feedbackLap = async () => {
81
- prompts.length = 0
82
79
  const repo = testRepo({
83
80
  workflow: TAIL,
84
81
  env: {
@@ -93,10 +90,7 @@ describe("afterTail feedback lap", () => {
93
90
  await repo.drive({
94
91
  "review-gate.check": () => undefined,
95
92
  "build.health.check": () => undefined,
96
- "build.review.reviewing": (beat, r) => {
97
- prompts.push(beat.content)
98
- r.write(".gtd/REVIEW.md", review())
99
- },
93
+ "build.review.composing": (_, r) => r.write(".gtd/REVIEW.md", review()),
100
94
  })
101
95
  repo.write(".gtd/REVIEW.md", review("also export multiply"))
102
96
  await repo.land()
@@ -137,35 +131,6 @@ describe("afterTail feedback lap", () => {
137
131
  expect(rest.content).not.toContain("write the change fresh")
138
132
  expect(repo.subjects().some((subject) => subject.includes("seam."))).toBe(false)
139
133
  })
140
-
141
- it("the first review says everything since base, the one after feedback only what is new", async () => {
142
- const repo = await feedbackLap()
143
- expect(prompts[0]).toContain("it covers everything since the base")
144
- expect(prompts[0]).not.toContain("only what is new since the previous review")
145
- const writes: Record<string, () => [string, string]> = {
146
- "design.triage": () => [
147
- ".gtd/REQUIREMENTS.md",
148
- "# Requirements\n\n- multiply, designed\n\n## Acceptance Criteria\n\n- AC-1: done\n",
149
- ],
150
- "implement.building": () => ["src/calc.ts", `${CALC}// again\n`],
151
- }
152
- const actors = new Proxy({} as Record<string, Actor>, {
153
- get: (_, state: string): Actor | undefined =>
154
- state === "build.review.await-review" || state === "idle"
155
- ? undefined
156
- : (beat, r) => {
157
- if (state === "implement.coverage") return '[{"id":"AC-1","answer":"yes","p":0.9}]'
158
- if (state === "build.review.reviewing") {
159
- prompts.push(beat.content)
160
- r.write(".gtd/REVIEW.md", review())
161
- } else if (writes[state]) r.write(...writes[state]())
162
- else if (state.endsWith("closing") || state === "re-unwind") r.runScript(beat.content)
163
- },
164
- })
165
- await repo.drive(actors, { maxBeats: 80 })
166
- expect(prompts[1]).toContain("it covers only what is new since the previous review")
167
- expect(prompts[1]).not.toContain("everything since the base")
168
- })
169
134
  })
170
135
 
171
136
  describe("an in-flight 28.0.0 naive-pass process", () => {
@@ -9,7 +9,7 @@ import {
9
9
  slice,
10
10
  start,
11
11
  } from "../../flows/index.js"
12
- import { gate, healthy, type EscalationCount } from "../shared/health.js"
12
+ import { gate, healthy } from "../shared/health.js"
13
13
  import { flagModeOn, shipped } from "../shared/options.js"
14
14
  import { FEEDBACK, fixCheck, QUALITY, REQUIREMENTS } from "../shared/steps.js"
15
15
  import { refusePendingHandOff, tail } from "../shared/tail.js"
@@ -135,8 +135,8 @@ const buildSlice = async (sliceBase: string, build: SliceBuild): Promise<void> =
135
135
  })
136
136
  }
137
137
 
138
- // The lap's findings the fix cap left must not reach the review, nor the next process's lap,
139
- // which appends to the file. Empty counts as resolved, as the lap reads it.
138
+ // Between slices nothing reviews the lap's leftovers, and the next lap appends to the file.
139
+ // Empty counts as resolved, as the lap reads it.
140
140
  const settleQuality = async (): Promise<void> => {
141
141
  let rested = false
142
142
  while ((read(QUALITY) ?? "").length > 0) {
@@ -151,15 +151,13 @@ const settleQuality = async (): Promise<void> => {
151
151
  if (rested) await healthy(() => fixCheck())
152
152
  }
153
153
 
154
- /** The quality lap, then the human review since `base`. */
155
- const reviewedTail = (base: string, lensBase: string, lap?: "feedback"): Promise<ReviewOutcome> =>
156
- tail(
157
- async (escalations: EscalationCount) => {
158
- await settleQuality()
159
- return review(base, escalations, { lap })
160
- },
161
- { green: true, lensBase },
162
- )
154
+ /** The triaged quality lap, then the human review since `base`. */
155
+ const reviewedTail = (base: string, lensBase: string): Promise<ReviewOutcome> =>
156
+ tail((escalations, report) => review(base, escalations, { report }), {
157
+ green: true,
158
+ lensBase,
159
+ triage: true,
160
+ })
163
161
 
164
162
  // seam.naive / seam.judge never run on a feedback lap: its design → implement is built unjudged
165
163
  // and unrefactored, held to every criterion's coverage since the process start.
@@ -181,7 +179,7 @@ const featureFeedback = async (
181
179
  })
182
180
  const tier = riskTierOn() ? recordRisk() : undefined
183
181
  closed = await implementing({ tier, amend: true })
184
- outcome = await reviewedTail(outcome.base, lensBase, "feedback")
182
+ outcome = await reviewedTail(outcome.base, lensBase)
185
183
  }
186
184
  }
187
185
 
@@ -201,7 +199,7 @@ export const afterTail = async (first: ReviewOutcome): Promise<void> => {
201
199
  await design(outcome.base)
202
200
  const tier = riskTierOn() ? recordRisk() : undefined
203
201
  await implement({ tier, amend: true })
204
- outcome = await buildTail(outcome.base, { green: true, lap: "feedback" })
202
+ outcome = await buildTail(outcome.base, { green: true })
205
203
  }
206
204
  }
207
205