@pmelab/gtd 45.1.0 → 45.2.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gtd",
3
- "version": "45.1.0",
3
+ "version": "45.2.0",
4
4
  "description": "Drive the gtd loop inside Claude Code: agent turns run as subagents, gtd decides every beat",
5
5
  "author": {
6
6
  "name": "Philipp Melab"
package/README.md CHANGED
@@ -371,11 +371,15 @@ the top branch with `git push origin --delete`, since
371
371
  ask Claude to hand it off (or run `/gtd throw @dev`) and it opens a draft pull
372
372
  request assigned to them, and `/gtd catch <pr>` picks it up exactly where it
373
373
  waits, so whoever wrote the requirements can hand the build to someone else.
374
- Ship, hand-off and throw answer at once and run in the background, with progress
375
- on the status line; they report by waking Claude, a toast, a transcript line and
376
- a push notification, and a failed push or `gh` call reports as a failure. While
377
- a ship or hand-off runs or is queued, `/gtd catch`, the doors,
378
- `/gtd <requirements>` and bare `/gtd` wait for its report. See
374
+ `/gtd pr` brings a pull request's unresolved review threads into the review:
375
+ each becomes a note in `.gtd/REVIEW.md` ticked "fix as suggested" (untick it for
376
+ "won't fix" plus a reason, or your own answer), the review door fixes them in
377
+ place, and each thread then gets a reply naming the fixing commit (or your
378
+ reason) and is resolved. Ship, hand-off and throw answer at once and run in the
379
+ background, with progress on the status line; they report by waking Claude, a
380
+ toast, a transcript line and a push notification, and a failed push or `gh` call
381
+ reports as a failure. While a ship or hand-off runs or is queued, `/gtd catch`,
382
+ the doors, `/gtd <requirements>` and bare `/gtd` wait for its report. See
379
383
  [Inside Claude Code](https://github.com/pmelab/gtd/blob/main/docs/driver.md#inside-claude-code-the-gtd-mod).
380
384
 
381
385
  ### Then let an agent build your own
@@ -0,0 +1,342 @@
1
+ // `/gtd pr`: a pull request's unresolved review threads become notes in the
2
+ // review gate's file, and once the review door has acted on them each thread
3
+ // gets a reply and is resolved. gtd itself never talks to GitHub: the mod
4
+ // reads and writes the threads, and gtd only sees the notes.
5
+
6
+ import type { PrFeedback, PrThread } from "../types"
7
+ import { pushBranch } from "./ship"
8
+ import type { ShipIo, Shipped } from "./ship"
9
+
10
+ export const REVIEW_FILE = ".gtd/REVIEW.md"
11
+
12
+ export type Pr = {
13
+ host: string
14
+ owner: string
15
+ name: string
16
+ number: number
17
+ url: string
18
+ branch: string
19
+ }
20
+ export type Thread = PrThread
21
+ export type Choice = "fix" | "wontfix" | "answer"
22
+ export type Answer = { thread: string; title: string; choice: Choice; text: string }
23
+
24
+ const PR_URL = /^https?:\/\/([^/]+)\/([^/]+)\/([^/]+)\/pull\/(\d+)/
25
+ // The thread's node id, tagged on the note's heading.
26
+ const TAG = /\(thread ([A-Za-z0-9_=-]+)\)\s*$/
27
+ const LEAD = "Tick exactly one:"
28
+ const PLACEHOLDER = "_your answer_"
29
+
30
+ const fail = (text: string): Shipped => ({ ok: false, text })
31
+
32
+ export async function currentPr(io: ShipIo): Promise<Pr | string> {
33
+ const branch = (await io.run(["git", "rev-parse", "--abbrev-ref", "HEAD"])).out.trim()
34
+ const v = await io.run(["gh", "pr", "view", "--json", "number,url"])
35
+ if (v.code !== 0) return `gh pr view failed: ${v.err.trim()}`
36
+ try {
37
+ const { url } = JSON.parse(v.out) as { url: string }
38
+ const m = PR_URL.exec(url)
39
+ if (!m) return `gh pr view returned an unexpected url: ${url}`
40
+ return { host: m[1]!, owner: m[2]!, name: m[3]!, number: Number(m[4]), url, branch }
41
+ } catch {
42
+ return "gh pr view returned something unreadable."
43
+ }
44
+ }
45
+
46
+ const THREADS = `query($owner: String!, $name: String!, $number: Int!) {
47
+ repository(owner: $owner, name: $name) {
48
+ pullRequest(number: $number) {
49
+ reviewThreads(first: 100) {
50
+ nodes {
51
+ id
52
+ isResolved
53
+ path
54
+ line
55
+ originalLine
56
+ comments(first: 50) { nodes { author { login } body url } }
57
+ }
58
+ }
59
+ }
60
+ }
61
+ }`
62
+
63
+ type ThreadNode = {
64
+ id: string
65
+ isResolved: boolean
66
+ path: string | null
67
+ line: number | null
68
+ originalLine: number | null
69
+ comments: { nodes: { author: { login: string } | null; body: string; url: string }[] }
70
+ }
71
+
72
+ export async function unresolvedThreads(io: ShipIo, pr: Pr): Promise<Thread[] | string> {
73
+ const r = await io.run([
74
+ "gh",
75
+ "api",
76
+ "graphql",
77
+ "--hostname",
78
+ pr.host,
79
+ "-f",
80
+ `query=${THREADS}`,
81
+ "-f",
82
+ `owner=${pr.owner}`,
83
+ "-f",
84
+ `name=${pr.name}`,
85
+ "-F",
86
+ `number=${pr.number}`,
87
+ ])
88
+ if (r.code !== 0) return `Reading the review threads failed: ${r.err.trim()}`
89
+ let nodes: ThreadNode[]
90
+ try {
91
+ nodes = JSON.parse(r.out).data.repository.pullRequest.reviewThreads.nodes as ThreadNode[]
92
+ } catch {
93
+ return "gh api returned something unreadable for the review threads."
94
+ }
95
+ return nodes
96
+ .filter((t) => !t.isResolved)
97
+ .map((t) => ({
98
+ id: t.id,
99
+ ...(t.path ? { path: t.path } : {}),
100
+ ...((t.line ?? t.originalLine) ? { line: (t.line ?? t.originalLine)! } : {}),
101
+ comments: t.comments.nodes.map((c) => ({
102
+ author: c.author?.login ?? "ghost",
103
+ body: c.body,
104
+ url: c.url,
105
+ })),
106
+ }))
107
+ }
108
+
109
+ // Code spans pass as they are; elsewhere only an entity hides a footnote
110
+ // marker or a comment from gtd's raw-text checks.
111
+ const escaped = (line: string) =>
112
+ line
113
+ .replace(/^((?:\s*>)*\s*(?:[-*+]|\d+[.)])\s+)\[([ xX])\]/, "$1\\[$2\\]")
114
+ .split(/(`+[^`]*`+)/)
115
+ .map((part, i) =>
116
+ i % 2 === 1 ? part : part.replace(/\[\^/g, "&#91;^").replace(/<!--/g, "&lt;!--"),
117
+ )
118
+ .join("")
119
+
120
+ // The fence open after `line`, given the one open before it. A closing fence
121
+ // carries no info string.
122
+ const fenceAfter = (open: string | undefined, line: string) => {
123
+ const m = /^(?:\s*>)*\s*(`{3,}|~{3,})(.*)$/.exec(line)
124
+ if (!m) return open
125
+ const f = m[1]!
126
+ if (open === undefined) return f
127
+ return f[0] === open[0] && f.length >= open.length && !m[2]!.trim() ? undefined : open
128
+ }
129
+
130
+ // A comment is quoted, and anything that would parse as part of the review's
131
+ // own structure — a task box, a footnote — is escaped. Code in a fence parses
132
+ // as code, so a suggestion is kept verbatim.
133
+ const quoted = (body: string) => {
134
+ let fence: string | undefined
135
+ return body
136
+ .trim()
137
+ .split(/\r?\n/)
138
+ .map((l) => {
139
+ const wasCode = fence !== undefined
140
+ fence = fenceAfter(fence, l)
141
+ return wasCode || fence !== undefined ? l : escaped(l)
142
+ })
143
+ .map((l) => (l ? `> ${l}` : ">"))
144
+ .join("\n")
145
+ }
146
+
147
+ const heading = (t: Thread) =>
148
+ `## ${t.path ? `${t.path}${t.line ? `:${t.line}` : ""}` : "Pull request review"} (thread ${t.id})`
149
+
150
+ export function threadNote(t: Thread) {
151
+ const said = t.comments.map((c) => `**@${c.author}** ([comment](${c.url})):\n\n${quoted(c.body)}`)
152
+ return [
153
+ heading(t),
154
+ ...said,
155
+ "From the pull request. To decline it, tick won't fix and write your reason after it on that line.",
156
+ LEAD,
157
+ "- [x] fix as suggested\n- [ ] won't fix\n- [ ] _your answer_",
158
+ ].join("\n\n")
159
+ }
160
+
161
+ // The review with every thread it does not already hold appended, pre-ticked.
162
+ export function withThreads(review: string, threads: readonly Thread[]) {
163
+ const held = new Set(chunks(review).flatMap((c) => (c.thread ? [c.thread] : [])))
164
+ const added = threads.filter((t) => !held.has(t.id)).map(threadNote)
165
+ return added.length === 0 ? review : `${review.trimEnd()}\n\n${added.join("\n\n")}\n`
166
+ }
167
+
168
+ type Chunk = { title: string; thread?: string; lines: string[] }
169
+
170
+ // Every imported line is quoted, so a `## ` line is always a chunk's heading.
171
+ function chunks(review: string): Chunk[] {
172
+ return review
173
+ .split(/^## /m)
174
+ .slice(1)
175
+ .map((part) => {
176
+ const [first = "", ...lines] = part.split(/\r?\n/)
177
+ const title = first.trim()
178
+ const thread = TAG.exec(title)?.[1]
179
+ return { title, ...(thread ? { thread } : {}), lines }
180
+ })
181
+ }
182
+
183
+ // An unindented line right below an item continues it, as markdown reads it.
184
+ const continues = (line: string, afterBlank: boolean) => !afterBlank || /^\s+\S/.test(line)
185
+
186
+ // The three answer boxes after the lead line, a wrapped item's continuation joined on.
187
+ function boxes(lines: readonly string[]) {
188
+ const at = lines.findIndex((l) => l.trim() === LEAD)
189
+ if (at === -1) return undefined
190
+ const items: { checked: boolean; text: string }[] = []
191
+ let blank = false
192
+ for (const line of lines.slice(at + 1)) {
193
+ const m = /^[-*+] \[([ xX])\]\s*(.*)$/.exec(line)
194
+ const isText = line.trim() !== ""
195
+ if (m) items.push({ checked: m[1] !== " ", text: m[2]!.trim() })
196
+ else if (items.length > 0 && isText && continues(line, blank))
197
+ items.at(-1)!.text = `${items.at(-1)!.text} ${line.trim()}`.trim()
198
+ else if (items.length > 0 && isText) break
199
+ blank = !isText
200
+ }
201
+ return items
202
+ }
203
+
204
+ const reasonOf = (text: string) => text.replace(/^won['’]t fix\b[\s:,;.—–-]*/i, "").trim()
205
+
206
+ export function answers(review: string): Answer[] {
207
+ return chunks(review).flatMap((c) => {
208
+ const b = c.thread ? boxes(c.lines) : undefined
209
+ if (!c.thread || b?.length !== 3) return []
210
+ const ticked = b.flatMap((o, i) => (o.checked ? [i] : []))
211
+ if (ticked.length !== 1) return []
212
+ const i = ticked[0]!
213
+ const choice = (["fix", "wontfix", "answer"] as const)[i]!
214
+ const own = b[2]!.text.toLowerCase() === PLACEHOLDER ? "" : b[2]!.text
215
+ const text = choice === "wontfix" ? reasonOf(b[1]!.text) : choice === "answer" ? own : ""
216
+ return [{ thread: c.thread, title: c.title, choice, text }]
217
+ })
218
+ }
219
+
220
+ // Thread notes declined without the reason their reply needs.
221
+ export function missingReasons(review: string) {
222
+ return answers(review)
223
+ .filter((a) => a.choice === "wontfix" && a.text === "")
224
+ .map((a) => `${REVIEW_FILE}: "${a.title}": write the reason after "won't fix" on that line.`)
225
+ }
226
+
227
+ // The first commit since `since` whose review answers any of `ids`: the gate
228
+ // that decided them. Undefined while none has landed.
229
+ export async function decided(io: ShipIo, since: string, ids: readonly string[]) {
230
+ // A reset or rebase since the import leaves nothing to find the gate in.
231
+ if ((await io.run(["git", "merge-base", "--is-ancestor", since, "HEAD"])).code !== 0)
232
+ return "gone"
233
+ const log = await io.run([
234
+ "git",
235
+ "log",
236
+ "--reverse",
237
+ "--first-parent",
238
+ "--format=%H",
239
+ `${since}..HEAD`,
240
+ ])
241
+ if (log.code !== 0) return "gone"
242
+ for (const commit of log.out.split("\n").filter(Boolean)) {
243
+ const shown = await io.run(["git", "show", `${commit}:${REVIEW_FILE}`])
244
+ if (shown.code !== 0) continue
245
+ const got = answers(shown.out).filter((a) => ids.includes(a.thread))
246
+ if (got.length > 0) return { gate: commit, answers: got }
247
+ }
248
+ return undefined
249
+ }
250
+
251
+ export type PrRest = { isReviewGate: boolean; isDone: boolean; branch: string }
252
+
253
+ // What a rest does with `/gtd pr`'s threads. Threads of another branch's pull
254
+ // request never reach this one's review; a process that ends before any review
255
+ // gate drops them.
256
+ export function prAction(pr: PrFeedback | undefined, at: PrRest) {
257
+ if (!pr) return "none"
258
+ if (at.branch !== pr.branch) return "forget"
259
+ if (pr.importedAt === undefined) return at.isReviewGate ? "import" : at.isDone ? "forget" : "none"
260
+ return at.isReviewGate || at.isDone ? "reply" : "none"
261
+ }
262
+
263
+ // The fixes the gate asked for are the first turn after it, so its first code
264
+ // commit is the fix; later ones belong to other notes or rounds.
265
+ export function replyText(a: Answer, fix: string | undefined) {
266
+ if (a.choice === "wontfix") return a.text ? `Won't fix: ${a.text}` : "Won't fix."
267
+ if (a.choice === "answer") return a.text
268
+ return fix ? `Fixed in ${fix}.` : "Fixed as suggested; the fix changed no code."
269
+ }
270
+
271
+ const REPLY = `mutation($thread: ID!, $body: String!) {
272
+ addPullRequestReviewThreadReply(input: { pullRequestReviewThreadId: $thread, body: $body }) { comment { id } }
273
+ }`
274
+ const RESOLVE = `mutation($thread: ID!) {
275
+ resolveReviewThread(input: { threadId: $thread }) { thread { isResolved } }
276
+ }`
277
+
278
+ // Pushes the branch so the commits a reply names exist on GitHub, then replies
279
+ // to each answered thread and resolves it. The code commits after `gate` are
280
+ // the fixes; a thread that fails is named and left open.
281
+ export async function reply(
282
+ io: ShipIo,
283
+ host: string,
284
+ gate: string,
285
+ decidedAnswers: readonly Answer[],
286
+ ): Promise<Shipped & { isPushed: boolean }> {
287
+ const branch = (await io.run(["git", "rev-parse", "--abbrev-ref", "HEAD"])).out.trim()
288
+ const pushed = await pushBranch(io, branch)
289
+ if (pushed) return { ...fail(pushed), isPushed: false }
290
+ const log = await io.run([
291
+ "git",
292
+ "log",
293
+ "--reverse",
294
+ "--first-parent",
295
+ "--format=%H",
296
+ `${gate}..HEAD`,
297
+ "--",
298
+ ".",
299
+ ":(exclude).gtd",
300
+ ])
301
+ const fix = log.out.split("\n").find(Boolean)
302
+ const failed: string[] = []
303
+ for (const a of decidedAnswers) {
304
+ const sent = await io.run([
305
+ "gh",
306
+ "api",
307
+ "graphql",
308
+ "--hostname",
309
+ host,
310
+ "-f",
311
+ `query=${REPLY}`,
312
+ "-f",
313
+ `thread=${a.thread}`,
314
+ "-f",
315
+ `body=${replyText(a, fix)}`,
316
+ ])
317
+ const resolved =
318
+ sent.code === 0 &&
319
+ (
320
+ await io.run([
321
+ "gh",
322
+ "api",
323
+ "graphql",
324
+ "--hostname",
325
+ host,
326
+ "-f",
327
+ `query=${RESOLVE}`,
328
+ "-f",
329
+ `thread=${a.thread}`,
330
+ ])
331
+ ).code === 0
332
+ if (!resolved) failed.push(a.title)
333
+ }
334
+ const done = decidedAnswers.length - failed.length
335
+ const text = `Replied to and resolved ${done} review thread(s).`
336
+ return failed.length === 0
337
+ ? { ok: true, text, isPushed: true }
338
+ : {
339
+ ...fail(`${text}\nThese failed and stay open:\n${failed.map((t) => `- ${t}`).join("\n")}`),
340
+ isPushed: true,
341
+ }
342
+ }
@@ -10,6 +10,15 @@ import type { Io, Opening, Turn, TurnEnd } from "./drive"
10
10
  import { doorFor, doors, enter } from "./doors"
11
11
  import { subagentModel } from "./models"
12
12
  import { pastedCommand } from "./paste"
13
+ import {
14
+ currentPr,
15
+ decided,
16
+ missingReasons,
17
+ reply,
18
+ REVIEW_FILE,
19
+ unresolvedThreads,
20
+ withThreads,
21
+ } from "./pr"
13
22
  import { personaSpec, resumable } from "./persona"
14
23
  import { JUDGE_SYSTEM, judgePrompt, judgeQuestions, judgeRunEnv, toVerdicts } from "./judge"
15
24
  import type { Judgment } from "./judge"
@@ -277,8 +286,8 @@ async function start($: $, opening: Opening = {}) {
277
286
  moveOn($)
278
287
  stopRequested = false
279
288
  await findRoot($)
280
- // Requests not yet handed to a rest outlive a restart.
281
- await update($, run, (r) => ({ isRunning: true, beat: 0, notes: r.notes }))
289
+ // Requests not yet handed to a rest, and review threads not yet replied to, outlive a restart.
290
+ await update($, run, (r) => ({ isRunning: true, beat: 0, notes: r.notes, pr: r.pr }))
282
291
  void drive(io($), opening)
283
292
  .catch((err: unknown): Stop => ({ kind: "error", text: String(err) }))
284
293
  .then(async (stop) => {
@@ -290,6 +299,8 @@ async function start($: $, opening: Opening = {}) {
290
299
  const rest = restId(at.head, stop.state)
291
300
  await update($, run, (r) => ({ ...r, isRunning: false, stop, rest, isRepeat }))
292
301
  $.ui.status(undefined)
302
+ // Before the wake, so nothing the model starts there races the push.
303
+ await prRest($, stop)
293
304
  $.ui.toast(headline(stop))
294
305
  await push($, stop)
295
306
  await wake($)
@@ -502,6 +513,125 @@ async function enterNow($: $, door: string, args: string[]) {
502
513
  return entered.text
503
514
  }
504
515
 
516
+ const isReviewGate = (stop: Stop | undefined) =>
517
+ stop?.kind === "gate" &&
518
+ !stop.isJudge &&
519
+ stop.file === REVIEW_FILE &&
520
+ /(^|\.)review\.await-review$/.test(stop.state ?? "")
521
+
522
+ // The pull request's unresolved threads go into the review gate's file: now,
523
+ // when gtd waits there, or through the review door at its first gate.
524
+ async function prNow($: $) {
525
+ const r = await read($, run)
526
+ if (r.isRunning) return "Stop the loop first: /gtd stop."
527
+ if (r.pending) return "A continue is already waiting for this turn to end."
528
+ if (r.pr?.importedAt !== undefined) return retryReplies($, r.stop)
529
+ if (r.pr && !isReviewGate(r.stop)) {
530
+ return "Review threads from an earlier /gtd pr wait for the review's first review gate."
531
+ }
532
+ const io = shipIo($)
533
+ const pr = await currentPr(io)
534
+ if (typeof pr === "string") return pr
535
+ const threads = await unresolvedThreads(io, pr)
536
+ if (typeof threads === "string") return threads
537
+ if (threads.length === 0) return `${pr.url} has no unresolved review threads.`
538
+ await update($, run, (x) => ({ ...x, pr: { threads, host: pr.host, branch: pr.branch } }))
539
+ if (isReviewGate(r.stop)) {
540
+ await importThreads($)
541
+ return `Added ${threads.length} review thread(s) of ${pr.url} to ${REVIEW_FILE}, ticked fix as suggested.`
542
+ }
543
+ const entered = await enter(io, "review", [])
544
+ if (!entered.ok) {
545
+ await update($, run, (x) => ({ ...x, pr: undefined }))
546
+ return `${entered.text}\nRun /gtd pr at a review gate, or once nothing is underway.`
547
+ }
548
+ await start($)
549
+ return `Started a review of ${pr.url}; its ${threads.length} review thread(s) join the review.`
550
+ }
551
+
552
+ // Replies whose push failed are retried here, at a rest that sends them.
553
+ async function retryReplies($: $, stop: Stop | undefined) {
554
+ // prRest, not a bare reply: threads of another branch must be dropped, never answered.
555
+ if (stop) {
556
+ await prRest($, stop)
557
+ if ((await read($, run)).pr === undefined) return "Done with the earlier review threads."
558
+ }
559
+ return "Review threads from an earlier /gtd pr still wait for their replies, sent at the next review gate or when the process ends; /gtd pr retries them there."
560
+ }
561
+
562
+ async function importThreads($: $) {
563
+ const pr = (await read($, run)).pr
564
+ if (!pr || pr.importedAt !== undefined) return
565
+ const path = `${root}/${REVIEW_FILE}`
566
+ const review = (await $.fs.exists(path)) ? await $.fs.read(path) : ""
567
+ await $.fs.write(path, withThreads(review, pr.threads))
568
+ const importedAt = await head($)
569
+ await update($, run, (x) => ({ ...x, pr: { ...pr, importedAt } }))
570
+ }
571
+
572
+ const forgetPr = ($: $) => update($, run, (x) => ({ ...x, pr: undefined }))
573
+
574
+ // A failing import or reply is reported, never allowed to swallow the rest.
575
+ async function guardedPr($: $, work: () => Promise<unknown>) {
576
+ try {
577
+ await work()
578
+ } catch (err) {
579
+ $.ui.log(`gtd pr: ${String(err)}`)
580
+ $.ui.toast("gtd pr: handling the review threads failed")
581
+ }
582
+ }
583
+
584
+ async function prRest($: $, stop: Stop) {
585
+ const pr = (await read($, run)).pr
586
+ if (!pr) return
587
+ const branch = (
588
+ await $.process.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], { cwd: root })
589
+ ).stdout.trim()
590
+ const action = prAction(pr, {
591
+ isReviewGate: isReviewGate(stop),
592
+ isDone: stop.kind === "done",
593
+ branch,
594
+ })
595
+ if (action === "forget") await forgetPr($)
596
+ if (action === "import") await guardedPr($, () => importThreads($))
597
+ if (action === "reply") await guardedPr($, () => replyThreads($, stop))
598
+ }
599
+
600
+ // Once a gate answered the imported threads and the process rests at a review
601
+ // gate again or is done, each thread is replied to and resolved. Only a failed
602
+ // push keeps them for the next rest.
603
+ async function replyThreads($: $, stop: Stop) {
604
+ const pr = (await read($, run)).pr
605
+ if (!pr?.importedAt) return
606
+ const io = shipIo($)
607
+ const found = await decided(
608
+ io,
609
+ pr.importedAt,
610
+ pr.threads.map((t) => t.id),
611
+ )
612
+ if (found === "gone") {
613
+ $.ui.log("gtd pr: the history moved since the review threads were added; they get no replies.")
614
+ await forgetPr($)
615
+ return
616
+ }
617
+ if (!found) {
618
+ if (stop.kind === "done") await forgetPr($)
619
+ return
620
+ }
621
+ // Nothing has acted on the answers yet, unless the process already ended.
622
+ if (stop.kind !== "done" && found.gate === (await head($))) return
623
+ $.ui.status("replying to the review threads…")
624
+ let replied
625
+ try {
626
+ replied = await reply(io, pr.host, found.gate, found.answers)
627
+ } finally {
628
+ $.ui.status(undefined)
629
+ }
630
+ for (const line of replied.text.split("\n")) $.ui.log(line)
631
+ $.ui.toast(replied.text.split("\n")[0]!)
632
+ if (replied.isPushed) await forgetPr($)
633
+ }
634
+
505
635
  // Picks the process up where it was thrown: a rest that waits on a person is
506
636
  // shown to the catcher first, never landed unseen.
507
637
  async function catchNow($: $, ref: string) {
@@ -743,8 +873,15 @@ async function proceed(
743
873
  return undefined
744
874
  }
745
875
 
746
- // The rest's own `gtd validate` script, run.
876
+ // The rest's own `gtd validate` script, run; a declined review thread needs
877
+ // the reason its reply carries.
747
878
  async function validateRest($: $) {
879
+ const r = await read($, run)
880
+ if (r.pr?.importedAt !== undefined && isReviewGate(r.stop)) {
881
+ const path = `${root}/${REVIEW_FILE}`
882
+ const missing = (await $.fs.exists(path)) ? missingReasons(await $.fs.read(path)) : []
883
+ if (missing.length > 0) return { ok: false, out: missing.join("\n") }
884
+ }
748
885
  const script = await gtd($, ["validate"])
749
886
  if (script.trim().startsWith("nothing to validate")) return { ok: true, out: "" }
750
887
  const v = await $.process.run(["sh", "-c", script], { cwd: root, timeoutMs: TEN_MINUTES })
@@ -872,10 +1009,16 @@ async function command($: $, arg: string): Promise<string | undefined> {
872
1009
  case "":
873
1010
  return resume($)
874
1011
  default:
875
- return doorFor(await doors(shipIo($)), verb, rest) ? enterNow($, verb, rest) : begin($, arg)
1012
+ return routed($, verb, rest, arg)
876
1013
  }
877
1014
  }
878
1015
 
1016
+ // A door wins over the bare `pr` verb; anything else is requirements.
1017
+ async function routed($: $, verb: string, rest: string[], arg: string) {
1018
+ if (doorFor(await doors(shipIo($)), verb, rest)) return enterNow($, verb, rest)
1019
+ return verb === "pr" && rest.length === 0 ? prNow($) : begin($, arg)
1020
+ }
1021
+
879
1022
  async function resume($: $) {
880
1023
  const b = await docs($).next()
881
1024
  if (isTrue(b.idle)) {
@@ -1020,7 +1163,7 @@ export const register: Register = (on) => {
1020
1163
  name: "gtd",
1021
1164
  description: "Drive gtd until it rests on you; pass requirements to start a new process",
1022
1165
  argumentHint:
1023
- "[requirements | <door> [args] | stop | status | ui | ship [-n] | throw [@user] | catch <pr|branch>]",
1166
+ "[requirements | <door> [args] | stop | status | ui | ship [-n] | pr | throw [@user] | catch <pr|branch>]",
1024
1167
  immediate: true,
1025
1168
  })
1026
1169
  return next(e)
@@ -10,6 +10,20 @@ export type Stop = {
10
10
  mode?: string
11
11
  }
12
12
 
13
+ export type PrThread = {
14
+ id: string
15
+ path?: string
16
+ line?: number
17
+ comments: { author: string; body: string; url: string }[]
18
+ }
19
+
20
+ export type PrFeedback = {
21
+ threads: PrThread[]
22
+ host: string
23
+ branch: string
24
+ importedAt?: string
25
+ }
26
+
13
27
  export type Run = {
14
28
  isRunning: boolean
15
29
  beat: number
@@ -33,6 +47,10 @@ export type Run = {
33
47
  inflight?: { agentId: string; memory: string }
34
48
  // a script (beat or landing) is running, so a reload must not run it again
35
49
  isScripting?: boolean
50
+ // `/gtd pr`'s review threads, for the pull request of `branch`;
51
+ // `importedAt` is HEAD when they were written into the review gate's file,
52
+ // and the replies wait on a gate after it
53
+ pr?: PrFeedback
36
54
  }
37
55
 
38
56
  declare module "claude-code" {
@@ -1,5 +1,5 @@
1
1
  import { accessFor, agent, answered, changesSince, check, codeThreads, defineWorkflow, displayPath, env, head, human, judge, mark, numeric, quote, read, refuse, scope, skillsFor, start, vars } from "../flows.mjs";
2
- import { d as ANSWER_FIX, f as ANSWER_LEAD, p as ANSWER_WONT_FIX, v as FREE_TEXT_PLACEHOLDER } from "./steering-D4LUQ-av.mjs";
2
+ import { d as ANSWER_FIX, f as ANSWER_LEAD, p as ANSWER_WONT_FIX, v as FREE_TEXT_PLACEHOLDER } from "./steering-D7hWeMgE.mjs";
3
3
  import { escalation as escalation$1, gate as gate$1, healthy as healthy$1, lensScopes, lensesOf, removedAssertions } from "../workflow-kit.mjs";
4
4
  import { c as String$, g as standardSchemaV1, h as optionalWith, l as Struct, n as Boolean$, s as Number$ } from "./Schema-C6fLlQzx.mjs";
5
5
  //#region src/workflows/shared/registry.ts
@@ -1,7 +1,7 @@
1
1
  import { answered, changesSince, head, judge, numeric, read, removeScript, requireRevert, restoreScript, run, showScript, vars, writeScript } from "../flows.mjs";
2
2
  import { removedAssertions, review } from "../workflow-kit.mjs";
3
- import { At as conflictBody, B as composedReview, C as awaitReview, D as fixNits, E as fixCheck, Ft as parseStandards, I as buildReviewTriageMessage, It as renderStandards, J as finisherSystem, Lt as standardKey, Mt as conflictTitle, Nt as isConflictTitle, P as agentWithSkills, Pt as oneLine, S as answerReviewQuestions, T as collecting, b as REVIEW, f as guarded, ft as reviewEditNotesCapture, g as QUALITY, gt as reviewNotesCapture, j as reviewMissing, jt as conflictFor, k as fixRisks, kt as STANDARDS, mt as reviewFeedbackFixPrompt, p as healthy, pt as reviewEditsCapture, w as coder, y as REQUIREMENTS } from "./reserved-ulcSkND6.mjs";
4
- import { i as isOptionalObject, n as closeReview, o as tail, s as NO_FINDINGS } from "./tail-BW030xz_.mjs";
3
+ import { At as conflictBody, B as composedReview, C as awaitReview, D as fixNits, E as fixCheck, Ft as parseStandards, I as buildReviewTriageMessage, It as renderStandards, J as finisherSystem, Lt as standardKey, Mt as conflictTitle, Nt as isConflictTitle, P as agentWithSkills, Pt as oneLine, S as answerReviewQuestions, T as collecting, b as REVIEW, f as guarded, ft as reviewEditNotesCapture, g as QUALITY, gt as reviewNotesCapture, j as reviewMissing, jt as conflictFor, k as fixRisks, kt as STANDARDS, mt as reviewFeedbackFixPrompt, p as healthy, pt as reviewEditsCapture, w as coder, y as REQUIREMENTS } from "./reserved-FWrjy7zo.mjs";
4
+ import { i as isOptionalObject, n as closeReview, o as tail, s as NO_FINDINGS } from "./tail-B-Es6Hwj.mjs";
5
5
  //#region src/workflows/shared/learn.ts
6
6
  const SUMMARY_MAX = 200;
7
7
  const BANNER = /^_(?:The fixer left this unresolved\.|A fix turn removed this while renaming another finding — check whether it was fixed\.|No detail given\.)_\s*/;
@@ -15901,7 +15901,7 @@ const reviewDescriptor = {
15901
15901
  openThreads: openThreadsOf
15902
15902
  };
15903
15903
  const pointerLabel = (f) => `${f.path}${f.line === void 0 ? "" : `#${f.line}${f.rangeEnd === void 0 ? "" : `-${f.rangeEnd}`}`}`;
15904
- const chunkNote = (chunk, prior) => chunk.description === (prior?.description ?? "") ? [] : [{
15904
+ const chunkNote = (chunk, prior) => chunk.description === (prior?.description ?? "") || prior === void 0 && chunk.answers !== void 0 ? [] : [{
15905
15905
  line: chunk.headingLine,
15906
15906
  note: {
15907
15907
  kind: "chunk",
@@ -1,6 +1,6 @@
1
1
  import { answered, head, judge, mark, numeric, read, refuse, removeScript, run, scope, sectionBodies, showScript, vars, writeScript } from "../flows.mjs";
2
2
  import { fixFindings, lensEntriesOf, lensOf, qualityLap as qualityLap$1, reviewToHandOff } from "../workflow-kit.mjs";
3
- import { C as awaitReview, E as fixCheck, M as reviewQuality, N as reviewing, O as fixQuality, X as handOffAwaitReviewMessage, at as qualityTriageMessage, b as REVIEW, f as guarded, g as QUALITY, it as qualityFindingsFile, j as reviewMissing, k as fixRisks, p as healthy, u as escalation } from "./reserved-ulcSkND6.mjs";
3
+ import { C as awaitReview, E as fixCheck, M as reviewQuality, N as reviewing, O as fixQuality, X as handOffAwaitReviewMessage, at as qualityTriageMessage, b as REVIEW, f as guarded, g as QUALITY, it as qualityFindingsFile, j as reviewMissing, k as fixRisks, p as healthy, u as escalation } from "./reserved-FWrjy7zo.mjs";
4
4
  //#region src/workflows/shared/lenses.ts
5
5
  const NO_FINDINGS = {
6
6
  notes: [],
package/dist/steering.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { C as parseFootnotes, D as headingSectionBodies, E as getParseCount, O as headingSections, S as openThreadFindings, T as parseThreadsWithFindings, _ as unansweredNotes, a as unansweredQuestions, b as FOOTNOTE_ACTION_TITLE, c as stripCodeThreads, d as ANSWER_FIX, f as ANSWER_LEAD, g as reviewRisks, h as reviewNotes, i as steeringFormatOrFreeForm, k as formatFinding, l as freeFormFormat, m as reviewDecisions, n as openThreads, o as viewOf, p as ANSWER_WONT_FIX, r as steeringFormatFor, s as parseCodeThreads, t as BUILT_IN_MODE_NAMES, u as applySteeringEdits, v as FREE_TEXT_PLACEHOLDER, w as parseThreads, x as THREAD_REPLY_ACTION_TITLE, y as isAnswered } from "./chunks/steering-D4LUQ-av.mjs";
1
+ import { C as parseFootnotes, D as headingSectionBodies, E as getParseCount, O as headingSections, S as openThreadFindings, T as parseThreadsWithFindings, _ as unansweredNotes, a as unansweredQuestions, b as FOOTNOTE_ACTION_TITLE, c as stripCodeThreads, d as ANSWER_FIX, f as ANSWER_LEAD, g as reviewRisks, h as reviewNotes, i as steeringFormatOrFreeForm, k as formatFinding, l as freeFormFormat, m as reviewDecisions, n as openThreads, o as viewOf, p as ANSWER_WONT_FIX, r as steeringFormatFor, s as parseCodeThreads, t as BUILT_IN_MODE_NAMES, u as applySteeringEdits, v as FREE_TEXT_PLACEHOLDER, w as parseThreads, x as THREAD_REPLY_ACTION_TITLE, y as isAnswered } from "./chunks/steering-D7hWeMgE.mjs";
2
2
  export { ANSWER_FIX, ANSWER_LEAD, ANSWER_WONT_FIX, BUILT_IN_MODE_NAMES, FOOTNOTE_ACTION_TITLE, FREE_TEXT_PLACEHOLDER, THREAD_REPLY_ACTION_TITLE, applySteeringEdits, formatFinding, freeFormFormat, getParseCount, headingSectionBodies, headingSections, isAnswered, openThreadFindings, openThreads, parseCodeThreads, parseFootnotes, parseThreads, parseThreadsWithFindings, reviewDecisions, reviewNotes, reviewRisks, steeringFormatFor, steeringFormatOrFreeForm, stripCodeThreads, unansweredNotes, unansweredQuestions, viewOf };
package/dist/testkit.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { answered, applyScript, checkScript, configProblems, configure, installContext, isFactoryWorkflow, moveScript, numeric, protocolMismatch, removeScript, restoreScript, revertScript, showScript, t as flows_exports, writeScript } from "./flows.mjs";
2
2
  import { A as beatKindOf, B as ACCESS_REFUSAL, C as renderLandPlain, D as renderBeatJson, E as beatDocument, O as statusOf, S as renderLandJson, T as renderBeatPlain, U as renderFormat, _ as selectPath, a as contextLimitFault, g as resolveSession, h as globMatches, j as demandOf, l as parseContextWindow, m as slicesDocument, o as contextShapeFault, p as renderSlicesJson, v as judgeJson, x as noopText, y as landFields } from "./chunks/wire-BJuZWR3C.mjs";
3
- import { D as headingSectionBodies, O as headingSections, T as parseThreadsWithFindings, a as unansweredQuestions, k as formatFinding, n as openThreads, r as steeringFormatFor, s as parseCodeThreads, t as BUILT_IN_MODE_NAMES } from "./chunks/steering-D4LUQ-av.mjs";
3
+ import { D as headingSectionBodies, O as headingSections, T as parseThreadsWithFindings, a as unansweredQuestions, k as formatFinding, n as openThreads, r as steeringFormatFor, s as parseCodeThreads, t as BUILT_IN_MODE_NAMES } from "./chunks/steering-D7hWeMgE.mjs";
4
4
  import { $ as logWarning, $i as BaseProto, $n as map$5, $r as make$19, $t as provide$1, A as catchIf, Ai as add$1, An as scopeMake, Ar as flatMap$3, At as suspend$5, B as flatMap$4, Bi as getOrElse, Bn as get$3, Br as sync$1, Bt as unsafeMakeLatch, C as all$1, Ci as toArray, Cn as isInterrupted, Cr as exitFail, Ct as sandbox, D as async, Di as GenericTag, Dn as squash, Dr as exitZipRight, Dt as scopedWith$1, E as asVoid$1, Ei as unsafeHead, En as reduceWithContext, Er as exitVoid, Et as scoped$4, F as ensuring$2, Fi as fromIterable, Fn as join$3, Fr as map$3, Ft as tryMap, G as forkIn, Gi as match$4, Gn as TypeId$10, Gr as whenEffect, Gt as zipLeft, H as fnUntraced, Hi as isNone, Hn as modify$1, Hr as uninterruptible, Ht as when, I as exit, Ii as splitAt, In as sequential, Ir as onInterrupt, It as tryPromise, J as ignore, Ji as isLeft, Jn as all, Jr as DeferredTypeId$1, Jt as spaced$1, K as forkScoped, Ki as none, Kn as Class, Kr as withFiberRuntime, Kt as zipRight$3, L as fail$5, Li as filter$1, Ln as bind, Lr as scopeClose, Lt as try_, M as die$2, Mi as getOption, Mn as _await$2, Mr as interruptAsFiber, Mt as tap, N as dieMessage, Ni as omit, Nn as inheritAll$1, Nr as interruptFiber, Nt as tapErrorCause, O as catchAll$1, Oi as Reference, On as ensuring$3, Or as fiberId, Ot as serviceOption, P as either, Pi as empty$5, Pn as interruptAllAs$1, Pr as interruptible$1, Pt as timeout, Q as isEffect, Qi as pipeArguments, Qn as isFailure, Qr as ids, Qt as mergeAll$2, R as failCause$4, Ri as flatMap$5, Rn as bindTo, Rr as succeed$4, Rt as uninterruptible$1, S as addFinalizer$1, Si as takeRight, Sn as isDieType, Sr as exitAs, St as runtime, T as as, Ti as unsafeGet, Tn as map$6, Tr as exitSucceed, Tt as scopeWith, U as forEach$2, Ui as isSome, Un as set$1, Ur as uninterruptibleMask$1, Ut as withFiberRuntime$1, V as flatten$4, Vi as getOrUndefined, Vn as make$16, Vr as tap$1, Vt as unsafeMakeSemaphore$1, W as forkDaemon, Wi as map$8, Wn as update$1, Wr as void_$2, Wt as zip$1, X as interruptible, Xi as match$2, Xn as fail$4, Xr as combine, Xt as tagged, Y as interrupt$4, Yi as left, Yn as die, Yr as empty$6, Yt as TaggedError$1, Z as intoDeferred, Zi as right, Zn as failCause$3, Zr as combineAll, Zt as fromEffect$5, _ as decodeSync, _i as of, _n as fail$3, _r as deferredIsDone, _t as raceWith, a as Literal, aa as constFalse, ai as size$2, an as updateContext$1, ar as zipRight$2, at as matchCause, b as acquireRelease, bi as reverse, bn as interrupt$3, br as deferredUnsafeDone, bt as runPromise, c as String$, ca as constVoid, ci as append$1, cn as Scope, cr as asyncInterrupt, ct as negate, d as Union, da as pipe, di as empty$4, dn as close, dr as deferredAwait, dt as orElse, ea as NodeInspectSymbol, ei as none$1, en as provideMerge$1, er as mapBoth, et as makeSemaphore, f as between, fi as filter, fn as extend, fr as deferredComplete, ft as orElseSucceed, gi as map$7, gn as die$1, gr as deferredInterruptWith, gt as race, hi as isEmpty$2, hn as NoSuchElementException, hr as deferredInterrupt, ht as provideService, i as Defect, ia as hasProperty, ii as has, in as unsafeRunPromiseExit, ir as zip$2, it as match$5, j as catchTag, ji as get$5, jn as scopeTag, jr as interrupt$2, jt as sync$2, k as catchAllCause, ki as Tag, kn as forEachConcurrentDiscard, kr as fiberRefLocally, kt as succeed$6, l as Struct, la as dual, li as appendAll, ln as addFinalizer, lr as catchAll, lt as onError, m as optional$1, mi as isChunk, mn as make$18, mr as deferredFailCause, mt as provide$2, n as Boolean$, na as toJSON, ni as make$17, nn as succeed$3, nr as succeed$5, nt as mapBoth$1, o as Never, oa as constTrue, oi as decode, on as forkIn$1, or as as$1, ot as matchCauseEffect, p as decodeUnknown, pi as fromIterable$1, pn as fork, pr as deferredFail, pt as promise, q as gen, qi as some, qn as spanTag, qr as zipRight$1, qt as zipWith, r as Cause, ra as equals, ri as set$2, rn as unsafeFork, rr as void_$3, rt as mapError, s as Number$, sa as constUndefined, si as isFinite, sn as unsafeMakeSemaphore, sr as asVoid, st as matchEffect, t as Array$, ta as format, ti as get$4, tn as scoped$3, tr as match$3, tt as map$4, u as TaggedError, ua as identity, ui as drop, un as addFinalizerExit, ur as currentContext$1, ut as orDie, v as encodeSync, vi as prepend, vn as failureOrCause, vr as deferredMake, vt as repeat, w as annotateLogs, wi as unsafeFromArray, wn as isInterruptedOnly, wr as exitFailCause, wt as scope, x as acquireUseRelease$2, xi as take$2, xn as interruptors, xr as deferredUnsafeMake, xt as runPromiseExit$1, y as _void, yi as reduce, yn as flipCauseOption, yr as deferredSucceed, yt as retry, z as fiberIdWith, zi as fromNullable, zn as sleep, zr as suspend$4, zt as uninterruptibleMask } from "./chunks/Schema-C6fLlQzx.mjs";
5
5
  import { createRequire } from "node:module";
6
6
  import * as Crypto from "node:crypto";
@@ -1,5 +1,5 @@
1
1
  import { changes, changesSince, hasThreadFor, head, mark, read, refuse, requireReplies, requireThreadsClosed, scope } from "./flows.mjs";
2
- import { _ as unansweredNotes, c as stripCodeThreads, g as reviewRisks, h as reviewNotes, m as reviewDecisions, w as parseThreads } from "./chunks/steering-D4LUQ-av.mjs";
2
+ import { _ as unansweredNotes, c as stripCodeThreads, g as reviewRisks, h as reviewNotes, m as reviewDecisions, w as parseThreads } from "./chunks/steering-D7hWeMgE.mjs";
3
3
  //#region src/workflow-kit/health.ts
4
4
  /** Hand a red suite that repeated attempts could not fix to a person, until `limit` rounds are spent. */
5
5
  const escalation = async (count, steps) => {
@@ -1,6 +1,6 @@
1
1
  import { agent, changes, glob, read, refuse, scope, start, vars } from "../flows.mjs";
2
- import { Et as envDefaults, Ht as requireFlag, Tt as defaults, Vt as registryEntry, i as skills, n as summary, o as flagShipped, r as access, t as steering, w as coder, zt as flagReferences } from "../chunks/reserved-ulcSkND6.mjs";
3
- import { a as refusePendingHandOff, r as handOffTail } from "../chunks/tail-BW030xz_.mjs";
2
+ import { Et as envDefaults, Ht as requireFlag, Tt as defaults, Vt as registryEntry, i as skills, n as summary, o as flagShipped, r as access, t as steering, w as coder, zt as flagReferences } from "../chunks/reserved-FWrjy7zo.mjs";
3
+ import { a as refusePendingHandOff, r as handOffTail } from "../chunks/tail-B-Es6Hwj.mjs";
4
4
  //#region src/workflows/cleanup/cleanup.ts
5
5
  const removingPrompt = (name, registry) => `Clean up the release flag "${name}", which is default-on: delete its entry in ${registry}, and remove every \`GTD_FLAG_${name.toUpperCase()}\` check from the shipped sources. Keep the flag-on path; delete code only the off path reached, including tests that pin the off path.`;
6
6
  const cleanupFlow = async () => {
@@ -1,8 +1,8 @@
1
1
  import { agent, answered, applyScript, changes, changesSince, check, codeChanges, env, hasThreadFor, head, human, judge, judgeDiffShape, kept, mark, matches, quote, read, refuse, removeScript, requireAnswers, requireProgress, requireReplies, requireThreadsClosed, revertScript, run, scope, slice, start, vars, wrote } from "../flows.mjs";
2
2
  import { lensEntriesOf } from "../workflow-kit.mjs";
3
- import { $ as implementFixPrompt, A as planner, Ct as unwindFailedMessage, D as fixNits, Dt as testFileGlobs, E as fixCheck, Et as envDefaults, F as buildReviewCollectingPrompt, G as designGateAnswerMessage, H as coverageQuestion, K as designSystem, L as builderSystem, P as agentWithSkills, Q as implementBuildingPrompt, R as clearCriteriaMessage, S as answerReviewQuestions, St as uncoveredMessage, T as collecting, Tt as defaults, U as coveringPrompt, V as coverageMessage, W as criterionLine, Z as idleMessage, _ as REFACTOR, _t as reviewerSystem, a as flagModeOn, bt as slicingPrompt, ct as refactorDroppedRefusal, d as gate, dt as refactorRules, et as naiveApplyFailure, g as QUALITY, h as HANDOFF, i as skills, lt as refactorFixPrompt, m as FEEDBACK, n as summary, nt as normaliseCriteriaScript, ot as qualityUnresolvedMessage, p as healthy, q as designTriagePrompt, r as access, rt as oversizedMessage, s as shipped, st as refactorBuildingPrompt, t as steering, tt as naivePassPrompt, ut as refactorNotePrompt, v as REFACTOR_NOTE, vt as seamByHandMessage, w as coder, wt as unwindFailure, x as SLICES, xt as startGateBlockedMessage, y as REQUIREMENTS, yt as seamSteerMessage, z as clearCriteriaQuestion } from "../chunks/reserved-ulcSkND6.mjs";
4
- import { a as refusePendingHandOff, o as tail } from "../chunks/tail-BW030xz_.mjs";
5
- import { a as reUnwind, i as isCode$1, r as humanReview, t as buildTail } from "../chunks/review-Da2ndnB1.mjs";
3
+ import { $ as implementFixPrompt, A as planner, Ct as unwindFailedMessage, D as fixNits, Dt as testFileGlobs, E as fixCheck, Et as envDefaults, F as buildReviewCollectingPrompt, G as designGateAnswerMessage, H as coverageQuestion, K as designSystem, L as builderSystem, P as agentWithSkills, Q as implementBuildingPrompt, R as clearCriteriaMessage, S as answerReviewQuestions, St as uncoveredMessage, T as collecting, Tt as defaults, U as coveringPrompt, V as coverageMessage, W as criterionLine, Z as idleMessage, _ as REFACTOR, _t as reviewerSystem, a as flagModeOn, bt as slicingPrompt, ct as refactorDroppedRefusal, d as gate, dt as refactorRules, et as naiveApplyFailure, g as QUALITY, h as HANDOFF, i as skills, lt as refactorFixPrompt, m as FEEDBACK, n as summary, nt as normaliseCriteriaScript, ot as qualityUnresolvedMessage, p as healthy, q as designTriagePrompt, r as access, rt as oversizedMessage, s as shipped, st as refactorBuildingPrompt, t as steering, tt as naivePassPrompt, ut as refactorNotePrompt, v as REFACTOR_NOTE, vt as seamByHandMessage, w as coder, wt as unwindFailure, x as SLICES, xt as startGateBlockedMessage, y as REQUIREMENTS, yt as seamSteerMessage, z as clearCriteriaQuestion } from "../chunks/reserved-FWrjy7zo.mjs";
4
+ import { a as refusePendingHandOff, o as tail } from "../chunks/tail-B-Es6Hwj.mjs";
5
+ import { a as reUnwind, i as isCode$1, r as humanReview, t as buildTail } from "../chunks/review-Dhl4vvDe.mjs";
6
6
  import { posix } from "node:path";
7
7
  //#region src/workflows/shared/fences.ts
8
8
  const markFenced = (text) => {
@@ -1,7 +1,7 @@
1
1
  import { read, refuse, removeScript, run, scope, start } from "../flows.mjs";
2
- import { g as reviewRisks, h as reviewNotes } from "../chunks/steering-D4LUQ-av.mjs";
3
- import { Et as envDefaults, J as finisherSystem, P as agentWithSkills, Tt as defaults, Y as fixReviewNotesPrompt, b as REVIEW, i as skills, l as baseline, n as summary, r as access, s as shipped, t as steering, w as coder } from "../chunks/reserved-ulcSkND6.mjs";
4
- import { r as handOffTail, t as REVIEW_ORIG } from "../chunks/tail-BW030xz_.mjs";
2
+ import { g as reviewRisks, h as reviewNotes } from "../chunks/steering-D7hWeMgE.mjs";
3
+ import { Et as envDefaults, J as finisherSystem, P as agentWithSkills, Tt as defaults, Y as fixReviewNotesPrompt, b as REVIEW, i as skills, l as baseline, n as summary, r as access, s as shipped, t as steering, w as coder } from "../chunks/reserved-FWrjy7zo.mjs";
4
+ import { r as handOffTail, t as REVIEW_ORIG } from "../chunks/tail-B-Es6Hwj.mjs";
5
5
  //#region src/workflows/fix/fix.ts
6
6
  /**
7
7
  * Fix every note a human left in a handed-off `.gtd/REVIEW.md` and every
@@ -1,7 +1,7 @@
1
1
  import { read, refuse, start } from "../flows.mjs";
2
- import { Et as envDefaults, Tt as defaults, d as gate, ht as reviewGateBlockedMessage, i as skills, n as summary, r as access, s as shipped, t as steering, y as REQUIREMENTS } from "../chunks/reserved-ulcSkND6.mjs";
3
- import { a as refusePendingHandOff } from "../chunks/tail-BW030xz_.mjs";
4
- import { a as reUnwind, n as fixFeedback, t as buildTail } from "../chunks/review-Da2ndnB1.mjs";
2
+ import { Et as envDefaults, Tt as defaults, d as gate, ht as reviewGateBlockedMessage, i as skills, n as summary, r as access, s as shipped, t as steering, y as REQUIREMENTS } from "../chunks/reserved-FWrjy7zo.mjs";
3
+ import { a as refusePendingHandOff } from "../chunks/tail-B-Es6Hwj.mjs";
4
+ import { a as reUnwind, n as fixFeedback, t as buildTail } from "../chunks/review-Dhl4vvDe.mjs";
5
5
  //#region src/workflows/review/review.ts
6
6
  /**
7
7
  * The feature's review of everything since `reviewBase` — a green gate, the
@@ -1,5 +1,5 @@
1
1
  import { agent, changesSince, glob, head, mark, read, refuse, removeScript, run, scope, vars } from "../flows.mjs";
2
- import { Bt as flipProblems, Et as envDefaults, Ht as requireFlag, Rt as flagMentions, Tt as defaults, d as gate, i as skills, m as FEEDBACK, n as summary, o as flagShipped, r as access, t as steering, w as coder } from "../chunks/reserved-ulcSkND6.mjs";
2
+ import { Bt as flipProblems, Et as envDefaults, Ht as requireFlag, Rt as flagMentions, Tt as defaults, d as gate, i as skills, m as FEEDBACK, n as summary, o as flagShipped, r as access, t as steering, w as coder } from "../chunks/reserved-FWrjy7zo.mjs";
3
3
  //#region src/workflows/rollout/rollout.ts
4
4
  const flippingPrompt = (name, registry) => `Roll out the release flag "${name}": in ${registry}, set \`defaultOn: true\` on its entry. Change nothing else — no other entry, no other field, no other file, no comments.`;
5
5
  const suiteBlockedMessage = () => "The suite is red after the rollout. Fix it by hand — a rollout may only touch the flag's registry entry — then land.";
@@ -1,7 +1,7 @@
1
1
  import { changes, changesSince, glob, read, refuse, removeScript, run, scope, sectionBodies, start, vars } from "../flows.mjs";
2
- import { E as fixCheck, Et as envDefaults, J as finisherSystem, Ot as testsDefaults, P as agentWithSkills, Tt as defaults, c as testsShipped, d as gate, i as skills, n as summary, p as healthy, r as access, t as steering, w as coder, y as REQUIREMENTS } from "../chunks/reserved-ulcSkND6.mjs";
3
- import { a as refusePendingHandOff, o as tail } from "../chunks/tail-BW030xz_.mjs";
4
- import { a as reUnwind, i as isCode, n as fixFeedback, r as humanReview } from "../chunks/review-Da2ndnB1.mjs";
2
+ import { E as fixCheck, Et as envDefaults, J as finisherSystem, Ot as testsDefaults, P as agentWithSkills, Tt as defaults, c as testsShipped, d as gate, i as skills, n as summary, p as healthy, r as access, t as steering, w as coder, y as REQUIREMENTS } from "../chunks/reserved-FWrjy7zo.mjs";
3
+ import { a as refusePendingHandOff, o as tail } from "../chunks/tail-B-Es6Hwj.mjs";
4
+ import { a as reUnwind, i as isCode, n as fixFeedback, r as humanReview } from "../chunks/review-Dhl4vvDe.mjs";
5
5
  //#region src/workflows/tests/tests.ts
6
6
  const TESTS = ".gtd/TESTS.md";
7
7
  const RETIRE = ".gtd/RETIRE.md";
@@ -16469,7 +16469,7 @@ const reviewDescriptor = {
16469
16469
  openThreads: openThreadsOf
16470
16470
  };
16471
16471
  const pointerLabel = (f) => `${f.path}${f.line === void 0 ? "" : `#${f.line}${f.rangeEnd === void 0 ? "" : `-${f.rangeEnd}`}`}`;
16472
- const chunkNote = (chunk, prior) => chunk.description === (prior?.description ?? "") ? [] : [{
16472
+ const chunkNote = (chunk, prior) => chunk.description === (prior?.description ?? "") || prior === void 0 && chunk.answers !== void 0 ? [] : [{
16473
16473
  line: chunk.headingLine,
16474
16474
  note: {
16475
16475
  kind: "chunk",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "45.1.0",
3
+ "version": "45.2.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": {
@@ -1875,6 +1875,12 @@ describe("answer boxes", async () => {
1875
1875
  expect(reviewNotes(open, doc(note("Rounding", [" ", "x", ""])))).toEqual([])
1876
1876
  })
1877
1877
 
1878
+ it("a note added with its own boxes is a decision, never also a note", () => {
1879
+ const added = doc(note("Rounding", [" ", " ", ""]), note("PR thread", ["x", " ", ""]))
1880
+ expect(reviewNotes(open, added)).toEqual([])
1881
+ expect(reviewDecisions(open, added)).toMatchObject([{ anchor: "PR thread", choice: "fix" }])
1882
+ })
1883
+
1878
1884
  it("an unticked note, or one with two ticks, is unanswered", () => {
1879
1885
  expect(unansweredNotes(open)).toEqual([
1880
1886
  { title: "Rounding", line: 5, reason: "tick exactly one box" },
@@ -1177,8 +1177,11 @@ const pointerLabel = (f: ReviewFile): string =>
1177
1177
 
1178
1178
  type Found = { line: number; note: Omit<ReviewNote, "id" | "forced"> }
1179
1179
 
1180
+ // A chunk added with its own answer boxes (a driver's imported note) is
1181
+ // answered by its tick, never judged as prose as well.
1180
1182
  const chunkNote = (chunk: Changeset, prior: Changeset | undefined): Found[] =>
1181
- chunk.description === (prior?.description ?? "")
1183
+ chunk.description === (prior?.description ?? "") ||
1184
+ (prior === undefined && chunk.answers !== undefined)
1182
1185
  ? []
1183
1186
  : [
1184
1187
  {
@@ -140,6 +140,11 @@ Given(
140
140
  },
141
141
  )
142
142
 
143
+ Given("{string} gets appended:", (world: TestkitWorld, path: string, text: string) => {
144
+ const content = world.repo().read(path) ?? ""
145
+ world.repo().write(path, `${content.trimEnd()}\n\n${text}\n`)
146
+ })
147
+
143
148
  Given("the working tree is committed as {string}", (world: TestkitWorld, message: string) => {
144
149
  world.repo().commit(message)
145
150
  })
@@ -156,3 +156,41 @@ Feature: the review door fixes its notes in the same process
156
156
  """
157
157
  And the working tree is committed as "chore: leftover requirements"
158
158
  Then opening door "review" is refused with ".gtd/REQUIREMENTS.md is left over"
159
+
160
+ Scenario: a note added with its own boxes is fixed as ticked, never judged
161
+ Given judge step "build.review.standards.judge" answers:
162
+ """
163
+ [{"id":"standard-1","answer":"no","p":0.9}]
164
+ """
165
+ And step "build.review.fix.nits.fixing" writes "src/calc.ts" with:
166
+ """
167
+ export const add = (a: number, b: number): number => a + b
168
+ """
169
+ When gtd opens door "review" with "HEAD~1"
170
+ And the process is driven
171
+ And the text "- [ ] won't fix" in ".gtd/REVIEW.md" is replaced with "- [x] won't fix"
172
+ And ".gtd/REVIEW.md" gets appended:
173
+ """
174
+ ## src/calc.ts:1 (thread PRRT_1)
175
+
176
+ > Annotate the return type.
177
+
178
+ Tick exactly one:
179
+
180
+ - [x] fix as suggested
181
+ - [ ] won't fix
182
+ - [ ] _your answer_
183
+ """
184
+ And gtd lands
185
+ And the process is driven
186
+ Then the process rests at "build.review.await-review"
187
+ And the commit subjects end with:
188
+ """
189
+ gtd(human): build.review.await-review → build.review.standards.judge
190
+ gtd(judge): build.review.standards.judge → build.review.fix.nits.fixing
191
+ gtd(agent): build.review.fix.nits.fixing → build.health.check
192
+ gtd(check): build.health.check → build.review.closing
193
+ gtd(check): build.review.closing → build.review.composing
194
+ gtd(check): build.review.composing → build.review.await-review
195
+ """
196
+ And "src/calc.ts" contains "): number =>"