@pmelab/gtd 64.3.0 → 65.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +34 -25
  3. package/claude/hooks/assist.ts +9 -35
  4. package/claude/hooks/doors.ts +4 -4
  5. package/claude/hooks/git.ts +56 -0
  6. package/claude/hooks/handoff.ts +11 -11
  7. package/claude/hooks/jobs.ts +8 -8
  8. package/claude/hooks/pr.ts +8 -8
  9. package/claude/hooks/register.tsx +16 -78
  10. package/claude/hooks/tools.ts +7 -20
  11. package/claude/hooks/wording.ts +3 -3
  12. package/dist/chunks/{flows-dnIE_qbe.mjs → flows-DGs0W3HI.mjs} +19 -2
  13. package/dist/chunks/{testkit-BUX-47TD.mjs → testkit-CgrLmpdJ.mjs} +740 -79
  14. package/dist/chunks/{wire-CaPJ9Tme.mjs → wire-BNS8ciNu.mjs} +5 -3
  15. package/dist/flows.mjs +1 -1
  16. package/dist/gtd.bundle.mjs +553 -81
  17. package/dist/gtd.core.mjs +132 -129
  18. package/dist/testkit/gherkin.mjs +1 -1
  19. package/dist/testkit.mjs +2 -2
  20. package/dist/wire.mjs +1 -1
  21. package/dist/workflow-kit.mjs +1 -1
  22. package/package.json +1 -1
  23. package/src/cli/Cli.ts +4 -4
  24. package/src/cli/History.test.ts +10 -8
  25. package/src/cli/Metrics.test.ts +2 -1
  26. package/src/cli/Program.test.ts +4 -2
  27. package/src/cli/Program.ts +87 -5
  28. package/src/edge/Edge.test.ts +37 -16
  29. package/src/edge/Edge.ts +296 -31
  30. package/src/emit/GitScript.ts +116 -15
  31. package/src/emit/index.ts +7 -0
  32. package/src/flows/config.ts +7 -1
  33. package/src/flows/index.test.ts +1 -0
  34. package/src/flows/index.ts +2 -0
  35. package/src/flows/runtime.ts +32 -1
  36. package/src/platform/RetainedHistory.ts +3 -0
  37. package/src/platform/index.ts +1 -0
  38. package/src/replay/Replay.test.ts +9 -0
  39. package/src/replay/Replay.ts +59 -15
  40. package/src/replay/Trailers.ts +14 -0
  41. package/src/replay/index.ts +1 -1
  42. package/src/step/LandStep.ts +12 -0
  43. package/src/step/RepoSnapshot.ts +11 -0
  44. package/src/step/index.ts +1 -0
  45. package/src/step/planStart.test.ts +4 -3
  46. package/src/step/planStart.ts +7 -0
  47. package/src/step/planStep.ts +17 -12
  48. package/src/step/startBranch.ts +54 -0
  49. package/src/twins/EmittedScriptRecognizer.ts +117 -0
  50. package/src/twins/EmittedScripts.test.ts +147 -1
  51. package/src/twins/InMemRepo.ts +116 -0
  52. package/src/wire/Demand.ts +22 -10
  53. package/src/wire/constants.ts +3 -0
  54. package/src/wire/types.ts +4 -0
  55. package/src/workflow/Workflow.ts +1 -0
  56. package/src/workflow/compile.ts +2 -0
  57. package/src/workflow/load.ts +2 -0
  58. package/claude/hooks/ship.ts +0 -730
@@ -1,730 +0,0 @@
1
- export type Ran = { code: number; out: string; err: string }
2
-
3
- export type ShipIo = {
4
- // argv runs in the repository root; stdin is the child's whole input
5
- run(argv: string[], stdin?: string): Promise<Ran>
6
- complete(prompt: string): Promise<string | undefined>
7
- log(line: string): void
8
- today(): string
9
- }
10
-
11
- export type Shipped = { ok: boolean; text: string }
12
-
13
- const fail = (text: string): Shipped => ({ ok: false, text })
14
- const short = (sha: string) => sha.slice(0, 8)
15
-
16
- // A cold model told to "print it and nothing else" fences it anyway: drop an
17
- // opening fence and the closer that ends the reply, and blank edges around both.
18
- export function cleanReply(text: string) {
19
- const lines = text.split("\n")
20
- const trim = () => {
21
- while (lines.length && !lines[0]!.trim()) lines.shift()
22
- while (lines.length && !lines.at(-1)!.trim()) lines.pop()
23
- }
24
- trim()
25
- if (lines.length && /^\s*```/.test(lines[0]!)) {
26
- lines.shift()
27
- if (lines.length && /^\s*```\s*$/.test(lines.at(-1)!)) lines.pop()
28
- }
29
- trim()
30
- return lines.join("\n")
31
- }
32
-
33
- // gtd stamps each turn commit `Gtd-Cost: <n> [model]`; the squash drops those
34
- // commits, so their sum is re-emitted per model, highest first, in the format
35
- // gtd's own parser reads.
36
- export function costTrailers(log: string) {
37
- const sum = new Map<string, number>()
38
- for (const line of log.split("\n")) {
39
- const m = /^Gtd-Cost:[ \t]*([0-9][^ \t]*)[ \t]*(.*)$/.exec(line)
40
- if (!m) continue
41
- const model = m[2]!.trim() || "unspecified"
42
- sum.set(model, (sum.get(model) ?? 0) + Number(m[1]))
43
- }
44
- return [...sum]
45
- .sort(([a, x], [b, y]) => y - x || a.localeCompare(b))
46
- .map(([model, n]) => `Gtd-Cost: ${Number(n.toPrecision(10))} ${model}`)
47
- }
48
-
49
- // The process's range, from the plain `gtd summary`: it names it as `git log <base>..<tip>`.
50
- const summaryRange = (summary: string) =>
51
- /git log ([0-9a-f]{7,64})\.\.([0-9a-f]{7,64})/.exec(summary)?.slice(1, 3)
52
-
53
- const TYPE_RULE = `- Pick the type from what the change does, never from the branch's name:
54
- adding or changing a public surface — anything a user, a config or a
55
- driver reaches: the published package's exports, a command, flag, config
56
- key, environment variable, workflow step or machine-read output — is feat,
57
- fix or perf, the types that cut a release. Docs or tests alone change no
58
- surface; refactor only when no user can tell the difference.`
59
-
60
- const COMMIT_FORMAT = `
61
- --- MESSAGE FORMAT (overrides any formatting the instructions above imply) ---
62
-
63
- Write it as a git commit message, not a prose document. Be brief — a
64
- reader scanning 'git log' has seconds, and the diff is right there.
65
-
66
- - First line: a Conventional Commit subject — 'type(scope): summary', or
67
- 'type(scope)!: summary' for a breaking change. Types: feat, fix, refactor,
68
- perf, docs, test, build, ci, chore. Lowercase summary, imperative mood, no
69
- trailing period, 72 characters or fewer. The scope is optional — include one
70
- only when a single part of the codebase clearly owns the change.
71
- ${TYPE_RULE}
72
- - Then a blank line.
73
- - Then the body, if the subject does not already say everything — and often
74
- it does, so drop the body rather than padding it. Ten lines is a lot.
75
-
76
- For the body, invoke the show-me skill with the Skill tool if it is available,
77
- then pick the smallest thing that makes the change clear — a call tree, a
78
- shallow file tree, a diff sketch, or four lines of pseudocode — indented as
79
- plain text, with a line of prose before it for the why. Skip the skill's mermaid and
80
- HTML-artifact options and never open a file: a commit message is plain text
81
- in a terminal. Plain prose is the right answer too when there is no shape to
82
- draw; then keep it to a paragraph, wrapped at 72 columns.
83
-
84
- No markdown headings, no code fences, no bullet list of changed files, no
85
- sign-off, and no footer trailers except two: when the instructions above
86
- say the change is breaking, end with a literal 'BREAKING CHANGE: <what
87
- breaks>' footer after a blank line. The release tooling reads only that
88
- footer, so a breaking change without it is never released as one. And when
89
- the instructions above ask for a 'Closes #N' footer, end with it too — the
90
- pull request is linked to its issue only through that line.
91
-
92
- NOTHING REVIEWS THIS BEFORE IT IS COMMITTED — no editor opens on it. What you
93
- print is the commit message verbatim.
94
-
95
- Print the message and nothing else — no code fences, no preamble.`
96
-
97
- const DESCRIPTION = `The description is a STANDING statement, not a changelog. It covers exactly three
98
- things, and a reviewer reads it once:
99
-
100
- - MOTIVATION — the problem, and why it was worth solving.
101
- - SOLUTION — the shape of the approach at a VERY high level. A few sentences.
102
- Not a walkthrough: the diff is one click away.
103
- - DECISIONS — the choices a reviewer would otherwise stop and question, and what
104
- each one traded away.
105
-
106
- Nothing else. No file-by-file list, no per-commit narration, no implementation
107
- detail the diff already shows, no test-plan boilerplate, no sign-off, no footer
108
- trailers. Short headings and lists are fine. Under 250 words.`
109
-
110
- // A rejection becomes `killed`, so the caller can tell a timeout from a refused
111
- // push. Relies on the host's process runner rejecting at its timeout; a spawn
112
- // failure lands here too. Only pushes: any other crashed read must abort the
113
- // ship, not pass as an answer.
114
- const attempt = async (io: ShipIo, argv: string[]): Promise<Ran & { killed: boolean }> => {
115
- try {
116
- return { ...(await io.run(argv)), killed: false }
117
- } catch (err) {
118
- return { code: 1, out: "", err: err instanceof Error ? err.message : String(err), killed: true }
119
- }
120
- }
121
-
122
- // The local default branch may lag origin's, so a ship would read a stale
123
- // merge base from it and describe other people's changes. Origin's copy
124
- // wins when it descends from the local one, or when the branch sits on it and
125
- // not on the local one; otherwise the local branch stays first.
126
- async function resolveRef(git: Git, ok: Ok, baseBranch: string): Promise<string | undefined> {
127
- const remote = `origin/${baseBranch}`
128
- const local = await ok("rev-parse", "--verify", "--quiet", baseBranch)
129
- const origin = await ok("rev-parse", "--verify", "--quiet", remote)
130
- if (!local || !origin) return local ? baseBranch : origin ? remote : undefined
131
- if (await ok("merge-base", "--is-ancestor", baseBranch, remote)) return remote
132
- const m = await git("merge-base", remote, "HEAD")
133
- if (m && !(await ok("merge-base", "--is-ancestor", m, baseBranch))) return remote
134
- return baseBranch
135
- }
136
-
137
- export async function defaultBranch(git: (...args: string[]) => Promise<string>) {
138
- const head = await git("symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD")
139
- return head.replace(/^origin\//, "") || "main"
140
- }
141
-
142
- async function preflight(git: Git, ok: Ok) {
143
- const branch = await git("rev-parse", "--abbrev-ref", "HEAD")
144
- if (!branch || branch === "HEAD") return { refusal: "Detached HEAD: there is no branch to ship." }
145
- const baseBranch = await defaultBranch(git)
146
- if (branch === baseBranch) return { refusal: `You are on the default branch (${baseBranch}).` }
147
- const baseRef = await resolveRef(git, ok, baseBranch)
148
- if (!baseRef)
149
- return { refusal: `There is no ref '${baseBranch}' (tried origin/${baseBranch} too).` }
150
- // `reset --soft` would fold pending changes into the squash without saying so.
151
- if (await git("status", "--porcelain"))
152
- return { refusal: "The working tree is dirty. Commit or stash first." }
153
- return { branch, baseBranch, baseRef }
154
- }
155
-
156
- export async function ship(io: ShipIo, isDry: boolean): Promise<Shipped> {
157
- const git = async (...args: string[]) => (await io.run(["git", ...args])).out.trim()
158
- const ok = async (...args: string[]) => (await io.run(["git", ...args])).code === 0
159
-
160
- const pre = await preflight(git, ok)
161
- if ("refusal" in pre) return fail(pre.refusal)
162
- const { branch, baseBranch, baseRef } = pre
163
-
164
- const squashed = await squash(io, git, ok, branch, isDry)
165
- if (!squashed.ok) return squashed
166
- if (isDry) return preview(io, git, branch, baseRef, squashed.text, squashed.history)
167
- return pullRequest(io, git, ok, branch, baseBranch, baseRef)
168
- }
169
-
170
- type Git = (...args: string[]) => Promise<string>
171
- type Ok = (...args: string[]) => Promise<boolean>
172
-
173
- // A failure text while the process is not a finished, shippable one.
174
- async function unfinished(io: ShipIo) {
175
- // `gtd summary` also describes a process still underway; shipping that
176
- // would publish half of it.
177
- const initial = (await io.run(["gtd", "next", "--json=initial"])).out.trim()
178
- if (initial === "true") return undefined
179
- const state = (await io.run(["gtd", "next", "--json=state"])).out.trim()
180
- return `The gtd process is still underway (at ${state}). Finish it, then ship.`
181
- }
182
-
183
- // The process's own commits, base exclusive, and only while it ends at HEAD;
184
- // a string is why ship refuses to squash.
185
- async function processRange(git: Git, summary: string) {
186
- const range = summaryRange(summary)
187
- if (!range) return "The gtd summary names no commit range, so ship refuses to guess a base."
188
- const base = await git("rev-parse", "--verify", `${range[0]}^{commit}`)
189
- const tip = await git("rev-parse", "--verify", `${range[1]}^{commit}`)
190
- if (!base || !tip) return "The gtd summary's range does not resolve in this repository."
191
- if (tip !== (await git("rev-parse", "HEAD")))
192
- return `The process tip (${short(tip)}) is not HEAD: something landed since.`
193
- const count = Number(await git("rev-list", "--count", `${base}..HEAD`))
194
- if (!count) return `Nothing to squash: ${short(base)}..HEAD is empty.`
195
- return { base, tip, count }
196
- }
197
-
198
- async function squash(
199
- io: ShipIo,
200
- git: Git,
201
- ok: Ok,
202
- branch: string,
203
- isDry: boolean,
204
- ): Promise<Shipped & { history?: string | undefined }> {
205
- const summary = await io.run(["gtd", "summary"])
206
- if (summary.code !== 0) {
207
- io.log(`No gtd process at HEAD, so nothing is squashed; ${branch} is described as it stands.`)
208
- return { ok: true, text: "" }
209
- }
210
- const underway = await unfinished(io)
211
- if (underway) return fail(underway)
212
- const range = await processRange(git, summary.out)
213
- if (typeof range === "string") return fail(`${range} Nothing changed.`)
214
- const { base, tip, count } = range
215
-
216
- // gtd churns its own steering files every process, so `.gtd/` alone is no change.
217
- if (await ok("diff", "--quiet", base, tip, "--", ":(exclude).gtd/"))
218
- return dropProcess(io, ok, base, count, isDry)
219
-
220
- io.log(`Squashing ${count} commit(s) since ${short(base)}…`)
221
- const trailers = costTrailers(await git("log", "--format=%B%n", `${base}..${tip}`))
222
- const reply = await io.complete(summary.out + "\n" + COMMIT_FORMAT)
223
- const message = reply && cleanReply(reply)
224
- if (!message) return fail("The commit-message turn produced nothing.")
225
- const history = await historyRef(io, tip)
226
- const all = history ? [...trailers, `Gtd-History: ${history}`] : trailers
227
- const full = all.length ? `${message}\n\n${all.join("\n")}` : message
228
- if (isDry) return { ok: true, text: full, history }
229
-
230
- const pushed = await pushHistory(io, history)
231
- if (pushed) return fail(pushed)
232
- return commitSquash(io, git, ok, base, full)
233
- }
234
-
235
- type View = {
236
- number: number
237
- title?: string
238
- body?: string
239
- url: string
240
- state: string
241
- }
242
-
243
- // gh's stderr for a branch with no PR (cli/cli pr view); tests script it by this.
244
- export const NO_PR_FOUND = "no pull requests found"
245
-
246
- // `gh pr view <branch>`: only NO_PR_FOUND reads as none; any
247
- // other failure (auth, network) must refuse, or ship would open a duplicate PR.
248
- // A closed PR whose branch origin lost is an abandoned earlier ship and reads as
249
- // none; `abandoned` is recorded before the push so a retry, with origin holding
250
- // the branch again, still reads it as none. `gone`: a tracking ref is stale.
251
- async function readPr(
252
- io: ShipIo,
253
- branch: string,
254
- ): Promise<{ view?: View; gone?: boolean; abandoned?: number } | string> {
255
- const v = await io.run(["gh", "pr", "view", branch, "--json", "number,title,body,url,state"])
256
- if (v.code !== 0) {
257
- if (v.err.includes(NO_PR_FOUND)) return {}
258
- return `gh pr view ${branch} failed: ${v.err.trim()}`
259
- }
260
- let view: View
261
- try {
262
- view = JSON.parse(v.out) as View
263
- } catch {
264
- return `gh pr view ${branch} returned something unreadable.`
265
- }
266
- if (view.state !== "CLOSED") return { view }
267
- const key = await io.run(["git", "config", "--local", "--get", ABANDONED_KEY(branch)])
268
- const recorded = key.out.trim() === String(view.number)
269
- const ls = await io.run(["git", "ls-remote", "origin", `refs/heads/${branch}`])
270
- const gone = ls.code === 0 && !ls.out.trim()
271
- if (recorded) return { gone }
272
- return gone ? { gone, abandoned: view.number } : { view }
273
- }
274
-
275
- const ABANDONED_KEY = (branch: string) => `branch.${branch}.gtdAbandonedPr`
276
-
277
- // Once the new PR opens, gh pr view finds it: the record has served its purpose.
278
- async function settled<T extends Shipped>(io: ShipIo, branch: string, done: T) {
279
- if (done.ok) await io.run(["git", "config", "--local", "--unset", ABANDONED_KEY(branch)])
280
- return done
281
- }
282
-
283
- async function abandon(io: ShipIo, branch: string, pr: number | undefined) {
284
- if (pr === undefined) return undefined
285
- const set = await io.run(["git", "config", "--local", ABANDONED_KEY(branch), String(pr)])
286
- return set.code === 0 ? undefined : `git config failed.\n${set.err}`.trimEnd()
287
- }
288
-
289
- async function dropProcess(
290
- io: ShipIo,
291
- ok: Ok,
292
- base: string,
293
- count: number,
294
- isDry: boolean,
295
- ): Promise<Shipped> {
296
- if (isDry)
297
- return { ok: true, text: `No file changes outside .gtd/: would drop ${count} commit(s).` }
298
- if (!(await ok("reset", "--hard", base))) return fail("git reset failed; nothing changed.")
299
- io.log(
300
- `No file changes outside .gtd/: dropped ${count} commit(s). Undo with git reset --hard ORIG_HEAD.`,
301
- )
302
- return { ok: true, text: "" }
303
- }
304
-
305
- // Before the squash: a failure leaves the branch as it was, so a retry finds the process.
306
- async function pushHistory(io: ShipIo, history: string | undefined) {
307
- if (!history) return undefined
308
- // Forced so a retry after the tip changed lands. The ref is keyed by the entry
309
- // commit, which every clone of the process shares: a second clone shipping the
310
- // same entry replaces origin's copy.
311
- io.log(`Pushing ${history} to origin: the process's step commits become fetchable there.`)
312
- const pushed = await attempt(io, ["git", "push", "origin", `+${history}:${history}`])
313
- if (pushed.killed)
314
- return `git push of ${history} did not finish; nothing was squashed. Retry.\n${pushed.err}`.trimEnd()
315
- if (pushed.code !== 0)
316
- return `git push of ${history} failed; nothing was squashed or pushed.\n${pushed.err}`.trimEnd()
317
- return undefined
318
- }
319
-
320
- async function commitSquash(
321
- io: ShipIo,
322
- git: Git,
323
- ok: Ok,
324
- base: string,
325
- full: string,
326
- ): Promise<Shipped> {
327
- if (!(await ok("reset", "--soft", base))) return fail("git reset failed; nothing changed.")
328
- const commit = await io.run(["git", "commit", "--cleanup=strip", "-F", "-"], full + "\n")
329
- if (commit.code !== 0) {
330
- return fail(
331
- `git commit failed: the process is staged. git reset --soft ORIG_HEAD puts its commits back.\n${commit.err}`,
332
- )
333
- }
334
- io.log(
335
- `Squashed: ${await git("log", "-1", "--format=%h %s")}. Undo with git reset --soft ORIG_HEAD.`,
336
- )
337
- return { ok: true, text: "" }
338
- }
339
-
340
- async function historyRef(io: ShipIo, tip: string) {
341
- const list = await io.run(["gtd", "history", "list", "--json"])
342
- let entries: { ref?: string; entry?: string; tip?: string }[] = []
343
- try {
344
- entries = JSON.parse(list.out)
345
- } catch {
346
- // unreadable list reads as no entry
347
- }
348
- // The ref becomes a refspec: push it only when it is exactly the full-sha
349
- // name of its entry (40 hex, or 64 under SHA-256).
350
- const found = Array.isArray(entries)
351
- ? entries.find(
352
- (e) =>
353
- e.tip === tip &&
354
- /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(e.entry ?? "") &&
355
- e.ref === `refs/gtd/history/${e.entry}`,
356
- )
357
- : undefined
358
- if (!found) {
359
- io.log("No gtd history ref keeps this process: shipping without a Gtd-History trailer.")
360
- return undefined
361
- }
362
- return found.ref
363
- }
364
-
365
- async function preview(
366
- io: ShipIo,
367
- git: Git,
368
- branch: string,
369
- baseRef: string,
370
- message: string,
371
- history?: string,
372
- ): Promise<Shipped> {
373
- const mergeBase = await git("merge-base", baseRef, "HEAD")
374
- const read = await readPr(io, branch)
375
- if (typeof read === "string") return fail(`${read} Nothing changed.`)
376
- const existing = read.view
377
- const pr = existing
378
- ? `would refresh ${existing.url}`
379
- : `would open a pull request for ${branch} into ${baseRef} (${await git("rev-list", "--count", `${mergeBase}..HEAD`)} commit(s) before the squash)`
380
- return {
381
- ok: true,
382
- text: [
383
- message && `Commit message:\n\n${message}`,
384
- history && `History: would push ${history} to origin.`,
385
- `Pull request: ${pr}.`,
386
- ]
387
- .filter(Boolean)
388
- .join("\n\n"),
389
- }
390
- }
391
-
392
- async function pullRequest(
393
- io: ShipIo,
394
- git: Git,
395
- ok: Ok,
396
- branch: string,
397
- baseBranch: string,
398
- baseRef: string,
399
- ) {
400
- const mergeBase = await git("merge-base", baseRef, "HEAD")
401
- if (!mergeBase) return fail(`${baseRef} and HEAD share no ancestor.`)
402
- if (mergeBase === (await git("rev-parse", "HEAD")))
403
- return fail(`HEAD is an ancestor of ${baseRef}: nothing to describe.`)
404
-
405
- const read = await readPr(io, branch)
406
- if (typeof read === "string")
407
- return fail(
408
- `${read} ${branch} was not pushed; the squash and history ref stay, /gtd ship retries.`,
409
- )
410
- const { view: existing, gone } = read
411
- if (existing && existing.state !== "OPEN")
412
- return fail(`The pull request for ${branch} is ${existing.state}, not open.`)
413
- const marked = await abandon(io, branch, read.abandoned)
414
- if (marked) return fail(`${marked} ${branch} was not pushed; /gtd ship retries.`)
415
- const pushed = await pushBranch(io, branch, gone)
416
- if (pushed) return fail(pushed)
417
- const ctx: Ctx = { from: mergeBase, to: "HEAD" }
418
- if (!existing)
419
- return settled(io, branch, await createPr(io, git, branch, baseBranch, baseRef, ctx))
420
- // A thrown draft only ever described a hand-off: ship writes it properly.
421
- if (existing.body?.includes(THROWN))
422
- return createPr(io, git, branch, baseBranch, baseRef, ctx, existing)
423
- return updatePr(io, git, ok, branch, ctx, existing)
424
- }
425
-
426
- // A pull request describes what the remote branch holds. A squash rewrote the
427
- // branch, so an existing origin/<branch> needs --force-with-lease. `fresh`:
428
- // origin lacks the branch, so any tracking ref is stale. Returns why the push
429
- // failed, or nothing.
430
- export async function pushBranch(io: ShipIo, branch: string, fresh = false) {
431
- const ok = async (...args: string[]) => (await io.run(["git", ...args])).code === 0
432
- const push = async (args: string[], failure: string) => {
433
- const r = await attempt(io, ["git", "push", ...args])
434
- if (r.killed)
435
- return `git push of ${branch} did not finish; nothing was lost. Retry without fetching.\n${r.err}`.trimEnd()
436
- if (r.code !== 0) return `${failure}\n${r.err}`.trimEnd()
437
- return undefined
438
- }
439
- if (fresh || !(await ok("rev-parse", "--verify", "--quiet", `origin/${branch}`))) {
440
- io.log(`Pushing ${branch} to origin…`)
441
- return push(["--set-upstream", "origin", branch], "git push failed.")
442
- }
443
- if (!(await ok("merge-base", "--is-ancestor", "HEAD", `origin/${branch}`))) {
444
- io.log(`Pushing ${branch} to origin…`)
445
- return push(
446
- ["--force-with-lease", "origin", branch],
447
- `git push failed: origin/${branch} moved. Fetch and reconcile first.`,
448
- )
449
- }
450
- return undefined
451
- }
452
-
453
- // The shell commands ship's writer may run: it reads commit messages anyone
454
- // on the branch wrote, so read-only git and no shell syntax to chain more on.
455
- // `--output` writes a file and `branch` with arguments moves refs, so neither is allowed.
456
- // For diff, quotes and backslashes are refused outright: the `..` path check cannot see
457
- // through shell quoting. The other subcommands' flag check still sees through them.
458
- // git diff falls back to --no-index by itself when a path lies outside the work
459
- // tree, so diff refuses absolute, `~` and `..` paths.
460
- export const READ_ONLY_GIT =
461
- /^git (?!diff\b.*["'\\])(?!diff\b[^;&|<>`$()\n{}*?[\]]*(?:\s["'\\]*(?:[/~]|\.\.(?![^\s/"'\\]))|\/\.\.(?![^\s/"'\\])))(?:(?:log|show|diff|status|rev-parse|rev-list|merge-base)\b(?![^;&|<>`$()\n{}*?[\]]*\s["'\\]*-["'\\]*(?:o|-["'\\]*(?:o["'\\]*u|n["'\\]*o["'\\]*-["'\\]*i|e["'\\]*x["'\\]*t["'\\]*-|t["'\\]*e["'\\]*x["'\\]*t["'\\]*c)))[^;&|<>`$()\n{}*?[\]]*|branch(?:\s+(?:--list|-a|-r|--show-current))*(?:\s+--contains\s+[\w./^~-]+)?)$/
462
-
463
- const TITLE = /^(feat|fix|refactor|perf|docs|test|build|ci|chore)(\([\w./-]+\))?!?: \S.{0,70}$/
464
-
465
- // Marks a draft pull request `/gtd throw` opened for a hand-off.
466
- export const THROWN = "<!-- gtd:thrown -->"
467
-
468
- type Ctx = { from: string; to: string }
469
-
470
- // [ \t], never \s: a reference on the next line is not what the keyword closes.
471
- const KEYWORD = String.raw`(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?):?[ \t]+`
472
- const URL = String.raw`https:\/\/github\.com\/([\w.-]+\/[\w.-]+)\/issues\/(\d+)`
473
- const REF = String.raw`(?:(?:[\w.-]+\/[\w.-]+)?#\d+|${URL})`
474
- const NEXT = String.raw`(?:[ \t]*,[ \t]*|[ \t]+and[ \t]+)(?:${KEYWORD})?`
475
- // A whole line of closing references, bulleted or not: `Closes #1.`,
476
- // `- Fixes #1, #2`, `Resolves o/r#3 and fixes <issue URL>`.
477
- const CLOSING = new RegExp(
478
- String.raw`^[ \t]*(?:[-*][ \t]+)?${KEYWORD}${REF}(?:${NEXT}${REF})*[ \t]*\.?[ \t]*$`,
479
- "gim",
480
- )
481
- const REFS = new RegExp(REF, "g")
482
-
483
- const closed = (text: string) => {
484
- const refs = new Map<string, string>()
485
- for (const [line] of text.matchAll(CLOSING))
486
- for (const [whole, repo, n] of line.matchAll(REFS)) {
487
- const ref = repo ? `${repo}#${n}` : whole
488
- if (!refs.has(ref.toLowerCase())) refs.set(ref.toLowerCase(), ref)
489
- }
490
- return refs
491
- }
492
-
493
- // The description prompt forbids footers, so the issues the commits close are
494
- // carried over here: a PR body without the keyword never links its issue.
495
- export function withCloses(body: string, log: string) {
496
- const has = closed(body)
497
- const add = [...closed(log)].filter(([key]) => !has.has(key)).map(([, ref]) => ref)
498
- if (!add.length) return body
499
- return [body.trimEnd(), add.map((ref) => `Closes ${ref}`).join("\n")].filter(Boolean).join("\n\n")
500
- }
501
-
502
- async function createPr(
503
- io: ShipIo,
504
- git: Git,
505
- branch: string,
506
- baseBranch: string,
507
- baseRef: string,
508
- { from: mergeBase, to }: Ctx,
509
- thrown?: { number: number; url: string },
510
- ): Promise<Shipped & { number?: number }> {
511
- const count = await git("rev-list", "--count", `${mergeBase}..${to}`)
512
- io.log(
513
- thrown
514
- ? `#${thrown.number} was thrown as a draft: describing ${count} commit(s) properly…`
515
- : `No pull request for ${branch} yet: describing ${count} commit(s)…`,
516
- )
517
- const log = await git(
518
- "log",
519
- "--no-merges",
520
- "--reverse",
521
- "--format=%H%n%B%n---",
522
- `${mergeBase}..${to}`,
523
- )
524
- const prompt = `Write the title and description for a pull request, from the commits below.
525
-
526
- Format, exactly:
527
-
528
- - First line: the title — a Conventional Commit subject, 'type(scope): summary'
529
- ('type(scope)!: summary' for a breaking change). Types: feat, fix, refactor,
530
- perf, docs, test, build, ci, chore. Lowercase summary, imperative mood, no
531
- trailing period, 72 characters or fewer.
532
- ${TYPE_RULE} A squash merges every commit under this title, so take the
533
- type of the commit that releases the most, and its '!' when any commit
534
- carries a BREAKING CHANGE footer.
535
- - Then a blank line.
536
- - Then the description.
537
-
538
- ${DESCRIPTION}
539
-
540
- At most ONE visual, and only if the solution has a shape prose cannot carry —
541
- a call tree, a shallow file tree, a box-and-arrow sketch. None is the common
542
- case. Put it in a fenced block (\`\`\`text, or \`\`\`mermaid, which GitHub renders);
543
- an indented block alone does not survive as a block.
544
-
545
- NOTHING REVIEWS THIS BEFORE IT IS PUBLISHED — no editor opens on it. What you
546
- print is the pull request verbatim.
547
-
548
- Print the title and description and nothing else — no preamble, and no code
549
- fence wrapped around the whole reply.
550
-
551
- --- COMMITS ON ${branch} (against ${baseRef}), oldest first ---
552
- ${log}
553
-
554
- --- DIFF STAT ---
555
- ${await git("diff", "--stat", `${mergeBase}..${to}`)}`
556
- const reply = await io.complete(prompt)
557
- const out = reply && cleanReply(reply)
558
- if (!out) return fail("The pull-request turn produced nothing.")
559
- const [title = "", ...rest] = out.split("\n")
560
- // The writer read commit messages anyone on the branch wrote: publish only
561
- // a reply shaped like the title it was asked for.
562
- if (!TITLE.test(title))
563
- return fail(`The pull-request turn returned no usable title: ${title.slice(0, 120)}`)
564
- const text = rest.join("\n").replace(/^\s*\n/, "")
565
- const body = withCloses(text, log)
566
- const head = await git("rev-parse", to)
567
- if (thrown) {
568
- const n = String(thrown.number)
569
- // Ready first: a failure here leaves the THROWN marker, so a retry comes back.
570
- const ready = await io.run(["gh", "pr", "ready", n])
571
- if (ready.code !== 0)
572
- return fail(`gh pr ready failed; ${thrown.url} is unchanged.\n${ready.err}`)
573
- const edited = await io.run(
574
- ["gh", "pr", "edit", n, "--title", title, "--body-file", "-"],
575
- body + "\n",
576
- )
577
- if (edited.code !== 0)
578
- return fail(
579
- `gh pr edit failed; ${thrown.url} is marked ready but keeps its thrown title and description.\n${edited.err}`,
580
- )
581
- await io.run(["git", "config", "--local", `branch.${branch}.prSyncHead`, head])
582
- return {
583
- ok: true,
584
- text: `Rewrote ${thrown.url} and marked it ready for review.`,
585
- number: thrown.number,
586
- }
587
- }
588
- const created = await io.run(
589
- [
590
- "gh",
591
- "pr",
592
- "create",
593
- "--head",
594
- branch,
595
- "--base",
596
- baseBranch,
597
- "--title",
598
- title,
599
- "--body-file",
600
- "-",
601
- ],
602
- body + "\n",
603
- )
604
- if (created.code !== 0) return fail(`gh pr create failed; nothing was opened.\n${created.err}`)
605
- await io.run(["git", "config", "--local", `branch.${branch}.prSyncHead`, head])
606
- const number = /\/pull\/(\d+)/.exec(created.out)?.[1]
607
- return {
608
- ok: true,
609
- text: `Opened ${created.out.trim()}`,
610
- number: number ? Number(number) : undefined,
611
- }
612
- }
613
-
614
- async function sinceHead(git: Git, ok: Ok, branch: string, mergeBase: string, to: string) {
615
- const synced = await git("config", "--local", "--get", `branch.${branch}.prSyncHead`)
616
- const isSynced =
617
- synced &&
618
- (await ok("rev-parse", "--verify", "--quiet", `${synced}^{commit}`)) &&
619
- (await ok("merge-base", "--is-ancestor", synced, to))
620
- return isSynced ? synced : mergeBase
621
- }
622
-
623
- const closingLog = (git: Git, from: string, to: string) =>
624
- git("log", "--no-merges", "--format=%B%n---", `${from}..${to}`)
625
-
626
- type Existing = { number: number; body?: string; url: string }
627
-
628
- // The issues the branch closes, re-applied on every refresh but only written when changed.
629
- async function refresher(io: ShipIo, git: Git, { from: mergeBase, to }: Ctx, existing: Existing) {
630
- // The whole branch, not just what is new: a PR shipped before its commits
631
- // closed anything, or whose edit failed after the sync head moved, still gains them.
632
- const log = await closingLog(git, mergeBase, to)
633
- const closing = (raw: string) => withCloses(raw, log)
634
- const edit = async (body: string) =>
635
- io.run(["gh", "pr", "edit", String(existing.number), "--body-file", "-"], body)
636
- const was = existing.body ?? ""
637
- const refreshed = async (text: string) => {
638
- const fixed = closing(was)
639
- if (fixed === was) return { ok: true, text, number: existing.number }
640
- const edited = await edit(fixed)
641
- if (edited.code !== 0)
642
- return {
643
- ...fail(
644
- `gh pr edit failed; the description of ${existing.url} is unchanged.\n${edited.err}`,
645
- ),
646
- number: existing.number,
647
- }
648
- return { ok: true, text, number: existing.number }
649
- }
650
- return { closing, edit, refreshed, stale: closing(was) !== was }
651
- }
652
-
653
- async function updatePr(
654
- io: ShipIo,
655
- git: Git,
656
- ok: Ok,
657
- branch: string,
658
- ctx: Ctx,
659
- existing: Existing,
660
- ): Promise<Shipped & { number?: number }> {
661
- const { from: mergeBase, to } = ctx
662
- const { closing, edit, refreshed, stale } = await refresher(io, git, ctx, existing)
663
- // Only what landed since the last run. A stored head a squash left behind
664
- // falls back to the whole branch, the only honest range left.
665
- const since = await sinceHead(git, ok, branch, mergeBase, to)
666
- // ^mergeBase keeps a synced head from before a main merge from re-weighing
667
- // the commits that merge brought in.
668
- const count = Number(await git("rev-list", "--count", `${since}..${to}`, `^${mergeBase}`))
669
- if (!count && stale) return refreshed(`Nothing new since the last ship: ${existing.url}`)
670
- if (!count) return fail(`No commits since the last ship: ${existing.url}`)
671
- io.log(`#${existing.number}: weighing ${count} commit(s) against the standing description…`)
672
-
673
- const prompt = `An open pull request already states the motivation, the high-level solution and
674
- the decisions behind this branch. Its description is below, followed by the
675
- commits added since it was last considered.
676
-
677
- Decide ONE thing: do those commits change the MOTIVATION, change the HIGH-LEVEL
678
- SOLUTION, overturn a DECISION already stated, or add a new decision a reviewer
679
- would stop and question?
680
-
681
- Ordinary implementation work does not. Nor do fixes, refactors, tests, renames,
682
- polish, or filling in something the description already promised. The honest
683
- answer is almost always NO — that is the point of a description written at this
684
- level, and appending to it for routine work makes it worse.
685
-
686
- If NO: print exactly
687
-
688
- NO-UPDATE
689
-
690
- and nothing else.
691
-
692
- If YES: print ONLY the new entry — two or three sentences, or a few bullets,
693
- naming what moved at that level and why. Do not restate the existing
694
- description, do not summarize the commits, do not write a heading (one is added
695
- for you), no preamble, no code fence around the whole reply.
696
-
697
- --- CURRENT DESCRIPTION OF PULL REQUEST #${existing.number} ---
698
- ${existing.body ?? ""}
699
-
700
- --- COMMITS ADDED SINCE IT WAS LAST CONSIDERED, oldest first ---
701
- ${await git("log", "--no-merges", "--reverse", "--format=%H%n%B%n---", `${since}..${to}`, `^${mergeBase}`)}
702
-
703
- --- DIFF STAT (whole branch) ---
704
- ${await git("diff", "--stat", `${mergeBase}..${to}`)}`
705
- const reply = await io.complete(prompt)
706
- const out = reply && cleanReply(reply)
707
- if (!out) return fail("The pull-request turn produced nothing.")
708
-
709
- // The sync head advances either way: NO-UPDATE is a real answer.
710
- const sha = await git("rev-parse", to)
711
- if (/^\s*NO-UPDATE\s*$/.test(out)) {
712
- await io.run(["git", "config", "--local", `branch.${branch}.prSyncHead`, sha])
713
- return refreshed(
714
- `Nothing at that level changed; the description of ${existing.url} is left alone.`,
715
- )
716
- }
717
- // Appended, dated and anchored to its commit, so the standing text keeps saying what it said.
718
- const repo = (await io.run(["gh", "repo", "view", "--json", "url", "-q", ".url"])).out.trim()
719
- const anchor = repo ? `[\`${sha.slice(0, 7)}\`](${repo}/commit/${sha})` : `\`${sha.slice(0, 7)}\``
720
- const body = closing(`${existing.body ?? ""}\n\n## Update ${io.today()} — ${anchor}\n\n${out}\n`)
721
- const edited = await edit(body)
722
- if (edited.code !== 0)
723
- return fail(`gh pr edit failed; the pull request is unchanged.\n${edited.err}`)
724
- await io.run(["git", "config", "--local", `branch.${branch}.prSyncHead`, sha])
725
- return {
726
- ok: true,
727
- text: `Appended an update to ${existing.url}:\n\n${out}`,
728
- number: existing.number,
729
- }
730
- }