@pmelab/gtd 45.1.0 → 46.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +18 -29
- 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-BENPy6IJ.mjs} +4 -29
- package/dist/chunks/{review-Da2ndnB1.mjs → review-DcbgUwr7.mjs} +6 -17
- package/dist/chunks/{steering-D4LUQ-av.mjs → steering-D7hWeMgE.mjs} +1 -1
- package/dist/chunks/{tail-BW030xz_.mjs → tail-CDNMZ-2E.mjs} +1 -1
- package/dist/gtd.bundle.mjs +5 -7
- package/dist/steering.mjs +1 -1
- package/dist/testkit.mjs +6 -8
- 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 +14 -4
- package/dist/workflows/rollout.mjs +1 -1
- package/dist/workflows.mjs +18 -229
- package/package.json +1 -6
- package/src/install/ConfigScaffold.ts +1 -2
- package/src/install/Install.ts +4 -5
- 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/index.ts +1 -2
- package/src/workflows/review/review-door.feature +38 -0
- package/src/workflows/review/review.ts +15 -3
- package/src/workflows/shared/lenses.ts +1 -5
- package/src/workflows/shared/options.ts +1 -21
- package/src/workflows/shared/review.ts +2 -19
- package/src/workflows/shared/skills.test.ts +0 -2
- package/src/workflows/shared/skills.ts +0 -3
- package/src/workflows/shared/text.ts +2 -5
- package/src/workflows/shared/vars.ts +0 -8
- package/dist/workflows/tests.mjs +0 -193
- package/src/workflows/tests/index.ts +0 -6
- package/src/workflows/tests/tests-options.feature +0 -52
- package/src/workflows/tests/tests.feature +0 -266
- package/src/workflows/tests/tests.test.ts +0 -51
- package/src/workflows/tests/tests.ts +0 -270
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
|
|
@@ -737,21 +741,6 @@ hand or leave it for cleanup.
|
|
|
737
741
|
|
|
738
742
|
The delivery model these workflows implement: [Delivery model](SDLC.md).
|
|
739
743
|
|
|
740
|
-
### Reviving skipped tests
|
|
741
|
-
|
|
742
|
-
The opt-in `tests` workflow revisits tests nobody runs. Export `tests` from
|
|
743
|
-
`@pmelab/gtd/workflows/tests` and run `gtd door tests` (or
|
|
744
|
-
`gtd --workflow tests`). On a green baseline an agent sweeps the whole
|
|
745
|
-
repository — every skipped test plus the `test-audit` skill's findings — into
|
|
746
|
-
`.gtd/TESTS.md`, one section per test file. Each file is then rewritten in its
|
|
747
|
-
own turn until nothing in it is skipped and the suite is green. A test the agent
|
|
748
|
-
calls obsolete stays skipped and is proposed for deletion: the review in
|
|
749
|
-
`.gtd/REVIEW.md` gives each proposal `delete it`, `keep it skipped` or
|
|
750
|
-
`_your answer_`, and refuses sign-off until each holds one tick. An agent turn
|
|
751
|
-
that deletes a test file is refused. Skip detection is a factory option with
|
|
752
|
-
JavaScript/TypeScript and cucumber defaults; see
|
|
753
|
-
[Reviving skipped tests](docs/configuration.md#reviving-skipped-tests).
|
|
754
|
-
|
|
755
744
|
## Checking a range for stray files
|
|
756
745
|
|
|
757
746
|
```sh
|
|
@@ -890,15 +879,15 @@ approvals as `approvals` (`[]` when none), and `gtd history show` prints each as
|
|
|
890
879
|
gtd is one package, `@pmelab/gtd`, and every entrypoint shares its version and
|
|
891
880
|
[SemVer promise](https://github.com/pmelab/gtd/blob/main/COMPATIBILITY.md).
|
|
892
881
|
|
|
893
|
-
| Import
|
|
894
|
-
|
|
|
895
|
-
| `@pmelab/gtd/flows`
|
|
896
|
-
| `@pmelab/gtd/wire`
|
|
897
|
-
| `@pmelab/gtd/steering`
|
|
898
|
-
| `@pmelab/gtd/workflow-kit`
|
|
899
|
-
| `@pmelab/gtd/workflows`
|
|
900
|
-
| `@pmelab/gtd/workflows/feature`, `@pmelab/gtd/workflows/review`, `@pmelab/gtd/workflows/fix`, `@pmelab/gtd/workflows/rollout`, `@pmelab/gtd/workflows/cleanup
|
|
901
|
-
| `@pmelab/gtd/testkit`
|
|
882
|
+
| Import | What it is |
|
|
883
|
+
| -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
884
|
+
| `@pmelab/gtd/flows` | The flow API a workflow is written in: the steps (`agent`, `human`, `run`, …), `scope` and `slice` (named spans of a process), workflow factories, `defineConfig` and `FLOWS_PROTOCOL`. |
|
|
885
|
+
| `@pmelab/gtd/wire` | The driver protocol: `WIRE_SCHEMA`, the exit codes and the types of every `--json` document. |
|
|
886
|
+
| `@pmelab/gtd/steering` | The steering-file formats (`qa`, `review`) and the review-note readers. |
|
|
887
|
+
| `@pmelab/gtd/workflow-kit` | Policy-free building blocks to compose a workflow from: health checks, quality laps, scenario freezing, review tails. |
|
|
888
|
+
| `@pmelab/gtd/workflows` | Every shipped workflow and all its parts in one module; also resolvable as `@pmelab/gtd/workflow`. |
|
|
889
|
+
| `@pmelab/gtd/workflows/feature`, `@pmelab/gtd/workflows/review`, `@pmelab/gtd/workflows/fix`, `@pmelab/gtd/workflows/rollout`, `@pmelab/gtd/workflows/cleanup` | One shipped workflow each: its factory as the default export, its parts, and the reserved exports (`defaults`, `envDefaults`, `summary`, `steering`, `skills`, `access`). `gtd install` imports these. |
|
|
890
|
+
| `@pmelab/gtd/testkit` | Runs any workflow against the real engine, in an in-memory or a real repository. Needs `vitest` and `quickpickle`, optional peer dependencies. |
|
|
902
891
|
|
|
903
892
|
Types come from the TypeScript sources the package ships. Configs never need an
|
|
904
893
|
install for any of these but `./testkit`: gtd resolves a `gtd.config.ts`'s
|
|
@@ -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" {
|