@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +9 -5
- package/claude/hooks/pr.ts +342 -0
- package/claude/hooks/register.tsx +148 -5
- package/claude/types/index.d.ts +18 -0
- package/dist/chunks/{reserved-ulcSkND6.mjs → reserved-FWrjy7zo.mjs} +1 -1
- package/dist/chunks/{review-Da2ndnB1.mjs → review-Dhl4vvDe.mjs} +2 -2
- package/dist/chunks/{steering-D4LUQ-av.mjs → steering-D7hWeMgE.mjs} +1 -1
- package/dist/chunks/{tail-BW030xz_.mjs → tail-B-Es6Hwj.mjs} +1 -1
- package/dist/steering.mjs +1 -1
- package/dist/testkit.mjs +1 -1
- package/dist/workflow-kit.mjs +1 -1
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +3 -3
- package/dist/workflows/fix.mjs +3 -3
- package/dist/workflows/review.mjs +3 -3
- package/dist/workflows/rollout.mjs +1 -1
- package/dist/workflows/tests.mjs +3 -3
- package/dist/workflows.mjs +1 -1
- package/package.json +1 -1
- package/src/steering/review.test.ts +6 -0
- package/src/steering/review.ts +4 -1
- package/src/testkit/steps.ts +5 -0
- package/src/workflows/review/review-door.feature +38 -0
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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, "[^").replace(/<!--/g, "<!--"),
|
|
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
|
|
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)
|
package/claude/types/index.d.ts
CHANGED
|
@@ -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-
|
|
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-
|
|
4
|
-
import { i as isOptionalObject, n as closeReview, o as tail, s as NO_FINDINGS } from "./tail-
|
|
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-
|
|
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-
|
|
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-
|
|
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";
|
package/dist/workflow-kit.mjs
CHANGED
|
@@ -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-
|
|
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-
|
|
3
|
-
import { a as refusePendingHandOff, r as handOffTail } from "../chunks/tail-
|
|
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-
|
|
4
|
-
import { a as refusePendingHandOff, o as tail } from "../chunks/tail-
|
|
5
|
-
import { a as reUnwind, i as isCode$1, r as humanReview, t as buildTail } from "../chunks/review-
|
|
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) => {
|
package/dist/workflows/fix.mjs
CHANGED
|
@@ -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-
|
|
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-
|
|
4
|
-
import { r as handOffTail, t as REVIEW_ORIG } from "../chunks/tail-
|
|
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-
|
|
3
|
-
import { a as refusePendingHandOff } from "../chunks/tail-
|
|
4
|
-
import { a as reUnwind, n as fixFeedback, t as buildTail } from "../chunks/review-
|
|
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-
|
|
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.";
|
package/dist/workflows/tests.mjs
CHANGED
|
@@ -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-
|
|
3
|
-
import { a as refusePendingHandOff, o as tail } from "../chunks/tail-
|
|
4
|
-
import { a as reUnwind, i as isCode, n as fixFeedback, r as humanReview } from "../chunks/review-
|
|
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";
|
package/dist/workflows.mjs
CHANGED
|
@@ -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
|
@@ -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" },
|
package/src/steering/review.ts
CHANGED
|
@@ -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
|
{
|
package/src/testkit/steps.ts
CHANGED
|
@@ -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 =>"
|