@pmelab/gtd 17.0.0 → 17.3.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 +9 -0
- package/README.md +59 -23
- package/bin/gtd +3 -0
- package/claude/hooks/drive.ts +169 -0
- package/claude/hooks/entry.ts +34 -0
- package/claude/hooks/handoff.ts +174 -0
- package/claude/hooks/judge.ts +99 -0
- package/claude/hooks/models.ts +19 -0
- package/claude/hooks/register.tsx +828 -0
- package/claude/hooks/ship.ts +450 -0
- package/claude/hooks/wording.ts +85 -0
- package/claude/types/index.d.ts +33 -0
- package/dist/gtd.bundle.mjs +130 -25
- package/hooks/hooks.json +1 -0
- package/package.json +9 -2
- package/skills/authoring/SKILL.md +248 -0
- package/src/workflows/prose.ts +12 -4
- package/src/workflows/review.test.ts +87 -1
- package/src/workflows/review.ts +19 -4
- package/src/workflows/skills.test.ts +2 -1
- package/src/workflows/skills.ts +1 -0
- package/src/workflows/steps.test.ts +53 -0
- package/src/workflows/steps.ts +34 -8
- package/src/workflows/text.test.ts +62 -0
- package/src/workflows/text.ts +75 -12
- package/src/workflows/vars.ts +2 -1
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
// Squash the finished gtd process into one commit, push, and open or refresh
|
|
2
|
+
// the branch's pull request. A port of the `ship` fish function: same steps,
|
|
3
|
+
// same prompts, with the text turns answered by a subagent of the session.
|
|
4
|
+
|
|
5
|
+
export type Ran = { code: number; out: string; err: string }
|
|
6
|
+
|
|
7
|
+
export type ShipIo = {
|
|
8
|
+
// argv runs in the repository root; stdin is the child's whole input
|
|
9
|
+
run(argv: string[], stdin?: string): Promise<Ran>
|
|
10
|
+
complete(prompt: string): Promise<string | undefined>
|
|
11
|
+
log(line: string): void
|
|
12
|
+
today(): string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type Shipped = { ok: boolean; text: string }
|
|
16
|
+
|
|
17
|
+
const fail = (text: string): Shipped => ({ ok: false, text })
|
|
18
|
+
const short = (sha: string) => sha.slice(0, 8)
|
|
19
|
+
|
|
20
|
+
// A cold model told to "print it and nothing else" fences it anyway: drop an
|
|
21
|
+
// opening fence and the closer that ends the reply, and blank edges around both.
|
|
22
|
+
export function cleanReply(text: string) {
|
|
23
|
+
const lines = text.split("\n")
|
|
24
|
+
const trim = () => {
|
|
25
|
+
while (lines.length && !lines[0]!.trim()) lines.shift()
|
|
26
|
+
while (lines.length && !lines.at(-1)!.trim()) lines.pop()
|
|
27
|
+
}
|
|
28
|
+
trim()
|
|
29
|
+
if (lines.length && /^\s*```/.test(lines[0]!)) {
|
|
30
|
+
lines.shift()
|
|
31
|
+
if (lines.length && /^\s*```\s*$/.test(lines.at(-1)!)) lines.pop()
|
|
32
|
+
}
|
|
33
|
+
trim()
|
|
34
|
+
return lines.join("\n")
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// gtd stamps each turn commit `Gtd-Cost: <n> [model]`; the squash drops those
|
|
38
|
+
// commits, so their sum is re-emitted per model, highest first, in the format
|
|
39
|
+
// gtd's own parser reads.
|
|
40
|
+
export function costTrailers(log: string) {
|
|
41
|
+
const sum = new Map<string, number>()
|
|
42
|
+
for (const line of log.split("\n")) {
|
|
43
|
+
const m = /^Gtd-Cost:[ \t]*([0-9][^ \t]*)[ \t]*(.*)$/.exec(line)
|
|
44
|
+
if (!m) continue
|
|
45
|
+
const model = m[2]!.trim() || "unspecified"
|
|
46
|
+
sum.set(model, (sum.get(model) ?? 0) + Number(m[1]))
|
|
47
|
+
}
|
|
48
|
+
return [...sum]
|
|
49
|
+
.sort(([a, x], [b, y]) => y - x || a.localeCompare(b))
|
|
50
|
+
.map(([model, n]) => `Gtd-Cost: ${Number(n.toPrecision(10))} ${model}`)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// `gtd summary` is the only place gtd names the process's start parent.
|
|
54
|
+
// `gtd base` is the review anchor and would squash only part of the process.
|
|
55
|
+
const summaryRange = (summary: string) =>
|
|
56
|
+
/git log ([0-9a-f]{7,40})\.\.([0-9a-f]{7,40})/.exec(summary)?.slice(1, 3)
|
|
57
|
+
|
|
58
|
+
const COMMIT_FORMAT = `
|
|
59
|
+
--- MESSAGE FORMAT (overrides any formatting the instructions above imply) ---
|
|
60
|
+
|
|
61
|
+
Write it as a git commit message, not a prose document. Be brief — a
|
|
62
|
+
reader scanning 'git log' has seconds, and the diff is right there.
|
|
63
|
+
|
|
64
|
+
- First line: a Conventional Commit subject — 'type(scope): summary', or
|
|
65
|
+
'type(scope)!: summary' for a breaking change. Types: feat, fix, refactor,
|
|
66
|
+
perf, docs, test, build, ci, chore. Lowercase summary, imperative mood, no
|
|
67
|
+
trailing period, 72 characters or fewer. The scope is optional — include one
|
|
68
|
+
only when a single part of the codebase clearly owns the change.
|
|
69
|
+
- Then a blank line.
|
|
70
|
+
- Then the body, if the subject does not already say everything — and often
|
|
71
|
+
it does, so drop the body rather than padding it. Ten lines is a lot.
|
|
72
|
+
|
|
73
|
+
For the body, invoke the show-me skill with the Skill tool if it is available,
|
|
74
|
+
then pick the smallest thing that makes the change clear — a call tree, a
|
|
75
|
+
shallow file tree, a diff sketch, or four lines of pseudocode — indented as
|
|
76
|
+
plain text, with a line of prose before it for the why. Skip the skill's mermaid and
|
|
77
|
+
HTML-artifact options and never open a file: a commit message is plain text
|
|
78
|
+
in a terminal. Plain prose is the right answer too when there is no shape to
|
|
79
|
+
draw; then keep it to a paragraph, wrapped at 72 columns.
|
|
80
|
+
|
|
81
|
+
No markdown headings, no code fences, no bullet list of changed files, no
|
|
82
|
+
sign-off, and no footer trailers except one: when the instructions above
|
|
83
|
+
say the change is breaking, end with a literal 'BREAKING CHANGE: <what
|
|
84
|
+
breaks>' footer after a blank line. The release tooling reads only that
|
|
85
|
+
footer, so a breaking change without it is never released as one.
|
|
86
|
+
|
|
87
|
+
NOTHING REVIEWS THIS BEFORE IT IS COMMITTED — no editor opens on it. What you
|
|
88
|
+
print is the commit message verbatim.
|
|
89
|
+
|
|
90
|
+
Print the message and nothing else — no code fences, no preamble.`
|
|
91
|
+
|
|
92
|
+
const DESCRIPTION = `The description is a STANDING statement, not a changelog. It covers exactly three
|
|
93
|
+
things, and a reviewer reads it once:
|
|
94
|
+
|
|
95
|
+
- MOTIVATION — the problem, and why it was worth solving.
|
|
96
|
+
- SOLUTION — the shape of the approach at a VERY high level. A few sentences.
|
|
97
|
+
Not a walkthrough: the diff is one click away.
|
|
98
|
+
- DECISIONS — the choices a reviewer would otherwise stop and question, and what
|
|
99
|
+
each one traded away.
|
|
100
|
+
|
|
101
|
+
Nothing else. No file-by-file list, no per-commit narration, no implementation
|
|
102
|
+
detail the diff already shows, no test-plan boilerplate, no sign-off, no footer
|
|
103
|
+
trailers. Short headings and lists are fine. Under 250 words.`
|
|
104
|
+
|
|
105
|
+
export async function ship(io: ShipIo, isDry: boolean): Promise<Shipped> {
|
|
106
|
+
const git = async (...args: string[]) => (await io.run(["git", ...args])).out.trim()
|
|
107
|
+
const ok = async (...args: string[]) => (await io.run(["git", ...args])).code === 0
|
|
108
|
+
|
|
109
|
+
const branch = await git("rev-parse", "--abbrev-ref", "HEAD")
|
|
110
|
+
if (!branch || branch === "HEAD") return fail("Detached HEAD: there is no branch to ship.")
|
|
111
|
+
const head = await git("symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD")
|
|
112
|
+
const baseBranch = head.replace(/^origin\//, "") || "main"
|
|
113
|
+
if (branch === baseBranch) return fail(`You are on the default branch (${baseBranch}).`)
|
|
114
|
+
let baseRef: string | undefined
|
|
115
|
+
for (const candidate of [baseBranch, `origin/${baseBranch}`]) {
|
|
116
|
+
if (await ok("rev-parse", "--verify", "--quiet", candidate)) {
|
|
117
|
+
baseRef = candidate
|
|
118
|
+
break
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (!baseRef) return fail(`There is no ref '${baseBranch}' (tried origin/${baseBranch} too).`)
|
|
122
|
+
// `reset --soft` would fold pending changes into the squash without saying so.
|
|
123
|
+
if (await git("status", "--porcelain"))
|
|
124
|
+
return fail("The working tree is dirty. Commit or stash first.")
|
|
125
|
+
|
|
126
|
+
const squashed = await squash(io, git, ok, branch, isDry)
|
|
127
|
+
if (!squashed.ok) return squashed
|
|
128
|
+
if (isDry) return preview(io, git, branch, baseRef, squashed.text)
|
|
129
|
+
return pullRequest(io, git, ok, branch, baseBranch, baseRef)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
type Git = (...args: string[]) => Promise<string>
|
|
133
|
+
type Ok = (...args: string[]) => Promise<boolean>
|
|
134
|
+
|
|
135
|
+
// The process's own commits, base exclusive, and only while it ends at HEAD;
|
|
136
|
+
// a string is why ship refuses to squash.
|
|
137
|
+
async function processRange(git: Git, summary: string) {
|
|
138
|
+
const range = summaryRange(summary)
|
|
139
|
+
if (!range) return "The gtd summary names no commit range, so ship refuses to guess a base."
|
|
140
|
+
const base = await git("rev-parse", "--verify", `${range[0]}^{commit}`)
|
|
141
|
+
const tip = await git("rev-parse", "--verify", `${range[1]}^{commit}`)
|
|
142
|
+
if (!base || !tip) return "The gtd summary's range does not resolve in this repository."
|
|
143
|
+
if (tip !== (await git("rev-parse", "HEAD"))) {
|
|
144
|
+
return `The process tip (${short(tip)}) is not HEAD: something landed since.`
|
|
145
|
+
}
|
|
146
|
+
const count = Number(await git("rev-list", "--count", `${base}..HEAD`))
|
|
147
|
+
if (!count) return `Nothing to squash: ${short(base)}..HEAD is empty.`
|
|
148
|
+
return { base, tip, count }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function squash(
|
|
152
|
+
io: ShipIo,
|
|
153
|
+
git: Git,
|
|
154
|
+
ok: Ok,
|
|
155
|
+
branch: string,
|
|
156
|
+
isDry: boolean,
|
|
157
|
+
): Promise<Shipped> {
|
|
158
|
+
const summary = await io.run(["gtd", "summary"])
|
|
159
|
+
if (summary.code !== 0) {
|
|
160
|
+
io.log(`No gtd process at HEAD, so nothing is squashed; ${branch} is described as it stands.`)
|
|
161
|
+
return { ok: true, text: "" }
|
|
162
|
+
}
|
|
163
|
+
// `gtd summary` also describes a process still underway; shipping that
|
|
164
|
+
// would publish half of it.
|
|
165
|
+
const state = (await io.run(["gtd", "next", "--json=state"])).out.trim()
|
|
166
|
+
if (state !== "idle") {
|
|
167
|
+
return fail(`The gtd process is still underway (at ${state}). Finish it, then ship.`)
|
|
168
|
+
}
|
|
169
|
+
const range = await processRange(git, summary.out)
|
|
170
|
+
if (typeof range === "string") return fail(range)
|
|
171
|
+
const { base, tip, count } = range
|
|
172
|
+
|
|
173
|
+
// gtd churns its own steering files every process, so `.gtd/` alone is no change.
|
|
174
|
+
if (await ok("diff", "--quiet", base, "HEAD", "--", ":(exclude).gtd/")) {
|
|
175
|
+
if (isDry)
|
|
176
|
+
return { ok: true, text: `No file changes outside .gtd/: would drop ${count} commit(s).` }
|
|
177
|
+
if (!(await ok("reset", "--hard", base))) return fail("git reset failed; nothing changed.")
|
|
178
|
+
io.log(
|
|
179
|
+
`No file changes outside .gtd/: dropped ${count} commit(s). Undo with git reset --hard ORIG_HEAD.`,
|
|
180
|
+
)
|
|
181
|
+
return { ok: true, text: "" }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
io.log(`Squashing ${count} commit(s) since ${short(base)}…`)
|
|
185
|
+
const trailers = costTrailers(await git("log", "--format=%B%n", `${base}..${tip}`))
|
|
186
|
+
const reply = await io.complete(summary.out + "\n" + COMMIT_FORMAT)
|
|
187
|
+
const message = reply && cleanReply(reply)
|
|
188
|
+
if (!message) return fail("The commit-message turn produced nothing.")
|
|
189
|
+
const full = trailers.length ? `${message}\n\n${trailers.join("\n")}` : message
|
|
190
|
+
if (isDry) return { ok: true, text: full }
|
|
191
|
+
|
|
192
|
+
if (!(await ok("reset", "--soft", base))) return fail("git reset failed; nothing changed.")
|
|
193
|
+
const commit = await io.run(["git", "commit", "--cleanup=strip", "-F", "-"], full + "\n")
|
|
194
|
+
if (commit.code !== 0) {
|
|
195
|
+
return fail(
|
|
196
|
+
`git commit failed: the process is staged. git reset --soft ORIG_HEAD puts its commits back.\n${commit.err}`,
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
io.log(
|
|
200
|
+
`Squashed: ${await git("log", "-1", "--format=%h %s")}. Undo with git reset --soft ORIG_HEAD.`,
|
|
201
|
+
)
|
|
202
|
+
return { ok: true, text: "" }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function preview(io: ShipIo, git: Git, branch: string, baseRef: string, message: string) {
|
|
206
|
+
const mergeBase = await git("merge-base", baseRef, "HEAD")
|
|
207
|
+
const existing = await io.run(["gh", "pr", "view", "--json", "url"])
|
|
208
|
+
const pr =
|
|
209
|
+
existing.code === 0 && existing.out.trim()
|
|
210
|
+
? `would refresh ${JSON.parse(existing.out).url}`
|
|
211
|
+
: `would open a pull request for ${branch} into ${baseRef} (${await git("rev-list", "--count", `${mergeBase}..HEAD`)} commit(s) before the squash)`
|
|
212
|
+
return {
|
|
213
|
+
ok: true,
|
|
214
|
+
text: [message && `Commit message:\n\n${message}`, `Pull request: ${pr}.`]
|
|
215
|
+
.filter(Boolean)
|
|
216
|
+
.join("\n\n"),
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function pullRequest(
|
|
221
|
+
io: ShipIo,
|
|
222
|
+
git: Git,
|
|
223
|
+
ok: Ok,
|
|
224
|
+
branch: string,
|
|
225
|
+
baseBranch: string,
|
|
226
|
+
baseRef: string,
|
|
227
|
+
) {
|
|
228
|
+
const mergeBase = await git("merge-base", baseRef, "HEAD")
|
|
229
|
+
if (!mergeBase) return fail(`${baseRef} and HEAD share no ancestor.`)
|
|
230
|
+
if (mergeBase === (await git("rev-parse", "HEAD")))
|
|
231
|
+
return fail(`HEAD is an ancestor of ${baseRef}: nothing to describe.`)
|
|
232
|
+
|
|
233
|
+
const pushed = await pushBranch(io, branch)
|
|
234
|
+
if (pushed) return fail(pushed)
|
|
235
|
+
|
|
236
|
+
const view = await io.run(["gh", "pr", "view", "--json", "number,title,body,url,state"])
|
|
237
|
+
if (view.code !== 0) return createPr(io, git, branch, baseBranch, baseRef, mergeBase)
|
|
238
|
+
const existing = JSON.parse(view.out) as {
|
|
239
|
+
number: number
|
|
240
|
+
body?: string
|
|
241
|
+
url: string
|
|
242
|
+
state: string
|
|
243
|
+
}
|
|
244
|
+
if (existing.state !== "OPEN")
|
|
245
|
+
return fail(`The pull request for ${branch} is ${existing.state}, not open.`)
|
|
246
|
+
// A thrown draft only ever described a hand-off: ship writes it properly.
|
|
247
|
+
if (existing.body?.includes(THROWN)) {
|
|
248
|
+
return createPr(io, git, branch, baseBranch, baseRef, mergeBase, existing)
|
|
249
|
+
}
|
|
250
|
+
return updatePr(io, git, ok, branch, mergeBase, existing)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// A pull request describes what the remote branch holds. A squash rewrote the
|
|
254
|
+
// branch, so an existing origin/<branch> needs --force-with-lease. Returns why
|
|
255
|
+
// the push failed, or nothing.
|
|
256
|
+
export async function pushBranch(io: ShipIo, branch: string) {
|
|
257
|
+
const ok = async (...args: string[]) => (await io.run(["git", ...args])).code === 0
|
|
258
|
+
if (!(await ok("rev-parse", "--verify", "--quiet", `origin/${branch}`))) {
|
|
259
|
+
io.log(`Pushing ${branch} to origin…`)
|
|
260
|
+
if (!(await ok("push", "--set-upstream", "origin", branch))) return "git push failed."
|
|
261
|
+
} else if (!(await ok("merge-base", "--is-ancestor", "HEAD", `origin/${branch}`))) {
|
|
262
|
+
io.log(`Pushing ${branch} to origin…`)
|
|
263
|
+
if (!(await ok("push", "--force-with-lease", "origin", branch))) {
|
|
264
|
+
return `git push failed: origin/${branch} moved. Fetch and reconcile first.`
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return undefined
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// The shell commands ship's writer may run: it reads commit messages anyone
|
|
271
|
+
// on the branch wrote, so read-only git and no shell syntax to chain more on.
|
|
272
|
+
export const READ_ONLY_GIT =
|
|
273
|
+
/^git (log|show|diff|status|rev-parse|rev-list|merge-base|branch)\b[^;&|<>`$()\n]*$/
|
|
274
|
+
|
|
275
|
+
const TITLE = /^(feat|fix|refactor|perf|docs|test|build|ci|chore)(\([\w./-]+\))?!?: \S.{0,70}$/
|
|
276
|
+
|
|
277
|
+
// Marks a draft pull request `/gtd throw` opened for a hand-off.
|
|
278
|
+
export const THROWN = "<!-- gtd:thrown -->"
|
|
279
|
+
|
|
280
|
+
async function createPr(
|
|
281
|
+
io: ShipIo,
|
|
282
|
+
git: Git,
|
|
283
|
+
branch: string,
|
|
284
|
+
baseBranch: string,
|
|
285
|
+
baseRef: string,
|
|
286
|
+
mergeBase: string,
|
|
287
|
+
thrown?: { number: number; url: string },
|
|
288
|
+
) {
|
|
289
|
+
const count = await git("rev-list", "--count", `${mergeBase}..HEAD`)
|
|
290
|
+
io.log(
|
|
291
|
+
thrown
|
|
292
|
+
? `#${thrown.number} was thrown as a draft: describing ${count} commit(s) properly…`
|
|
293
|
+
: `No pull request for ${branch} yet: describing ${count} commit(s)…`,
|
|
294
|
+
)
|
|
295
|
+
const prompt = `Write the title and description for a pull request, from the commits below.
|
|
296
|
+
|
|
297
|
+
Format, exactly:
|
|
298
|
+
|
|
299
|
+
- First line: the title — a Conventional Commit subject, 'type(scope): summary'
|
|
300
|
+
('type(scope)!: summary' for a breaking change). Types: feat, fix, refactor,
|
|
301
|
+
perf, docs, test, build, ci, chore. Lowercase summary, imperative mood, no
|
|
302
|
+
trailing period, 72 characters or fewer.
|
|
303
|
+
- Then a blank line.
|
|
304
|
+
- Then the description.
|
|
305
|
+
|
|
306
|
+
${DESCRIPTION}
|
|
307
|
+
|
|
308
|
+
At most ONE visual, and only if the solution has a shape prose cannot carry —
|
|
309
|
+
a call tree, a shallow file tree, a box-and-arrow sketch. None is the common
|
|
310
|
+
case. Put it in a fenced block (\`\`\`text, or \`\`\`mermaid, which GitHub renders);
|
|
311
|
+
an indented block alone does not survive as a block.
|
|
312
|
+
|
|
313
|
+
NOTHING REVIEWS THIS BEFORE IT IS PUBLISHED — no editor opens on it. What you
|
|
314
|
+
print is the pull request verbatim.
|
|
315
|
+
|
|
316
|
+
Print the title and description and nothing else — no preamble, and no code
|
|
317
|
+
fence wrapped around the whole reply.
|
|
318
|
+
|
|
319
|
+
--- COMMITS ON ${branch} (against ${baseRef}), oldest first ---
|
|
320
|
+
${await git("log", "--no-merges", "--reverse", "--format=%H%n%B%n---", `${mergeBase}..HEAD`)}
|
|
321
|
+
|
|
322
|
+
--- DIFF STAT ---
|
|
323
|
+
${await git("diff", "--stat", `${mergeBase}..HEAD`)}`
|
|
324
|
+
const reply = await io.complete(prompt)
|
|
325
|
+
const out = reply && cleanReply(reply)
|
|
326
|
+
if (!out) return fail("The pull-request turn produced nothing.")
|
|
327
|
+
const [title = "", ...rest] = out.split("\n")
|
|
328
|
+
// The writer read commit messages anyone on the branch wrote: publish only
|
|
329
|
+
// a reply shaped like the title it was asked for.
|
|
330
|
+
if (!TITLE.test(title))
|
|
331
|
+
return fail(`The pull-request turn returned no usable title: ${title.slice(0, 120)}`)
|
|
332
|
+
const body = rest.join("\n").replace(/^\s*\n/, "")
|
|
333
|
+
const head = await git("rev-parse", "HEAD")
|
|
334
|
+
if (thrown) {
|
|
335
|
+
const n = String(thrown.number)
|
|
336
|
+
const edited = await io.run(
|
|
337
|
+
["gh", "pr", "edit", n, "--title", title, "--body-file", "-"],
|
|
338
|
+
body + "\n",
|
|
339
|
+
)
|
|
340
|
+
if (edited.code !== 0)
|
|
341
|
+
return fail(`gh pr edit failed; the pull request is unchanged.\n${edited.err}`)
|
|
342
|
+
await io.run(["gh", "pr", "ready", n])
|
|
343
|
+
await io.run(["git", "config", "--local", `branch.${branch}.prSyncHead`, head])
|
|
344
|
+
return { ok: true, text: `Rewrote ${thrown.url} and marked it ready for review.` }
|
|
345
|
+
}
|
|
346
|
+
const created = await io.run(
|
|
347
|
+
[
|
|
348
|
+
"gh",
|
|
349
|
+
"pr",
|
|
350
|
+
"create",
|
|
351
|
+
"--head",
|
|
352
|
+
branch,
|
|
353
|
+
"--base",
|
|
354
|
+
baseBranch,
|
|
355
|
+
"--title",
|
|
356
|
+
title,
|
|
357
|
+
"--body-file",
|
|
358
|
+
"-",
|
|
359
|
+
],
|
|
360
|
+
body + "\n",
|
|
361
|
+
)
|
|
362
|
+
if (created.code !== 0) return fail(`gh pr create failed; nothing was opened.\n${created.err}`)
|
|
363
|
+
await io.run([
|
|
364
|
+
"git",
|
|
365
|
+
"config",
|
|
366
|
+
"--local",
|
|
367
|
+
`branch.${branch}.prSyncHead`,
|
|
368
|
+
await git("rev-parse", "HEAD"),
|
|
369
|
+
])
|
|
370
|
+
return { ok: true, text: `Opened ${created.out.trim()}` }
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async function updatePr(
|
|
374
|
+
io: ShipIo,
|
|
375
|
+
git: Git,
|
|
376
|
+
ok: Ok,
|
|
377
|
+
branch: string,
|
|
378
|
+
mergeBase: string,
|
|
379
|
+
existing: { number: number; body?: string; url: string },
|
|
380
|
+
) {
|
|
381
|
+
// Only what landed since the last run. A stored head a squash left behind
|
|
382
|
+
// falls back to the whole branch, the only honest range left.
|
|
383
|
+
const synced = await git("config", "--local", "--get", `branch.${branch}.prSyncHead`)
|
|
384
|
+
const isSynced =
|
|
385
|
+
synced &&
|
|
386
|
+
(await ok("rev-parse", "--verify", "--quiet", `${synced}^{commit}`)) &&
|
|
387
|
+
(await ok("merge-base", "--is-ancestor", synced, "HEAD"))
|
|
388
|
+
const since = isSynced ? synced : mergeBase
|
|
389
|
+
const count = Number(await git("rev-list", "--count", `${since}..HEAD`))
|
|
390
|
+
if (!count) return fail(`No commits since the last ship: ${existing.url}`)
|
|
391
|
+
io.log(`#${existing.number}: weighing ${count} commit(s) against the standing description…`)
|
|
392
|
+
|
|
393
|
+
const prompt = `An open pull request already states the motivation, the high-level solution and
|
|
394
|
+
the decisions behind this branch. Its description is below, followed by the
|
|
395
|
+
commits added since it was last considered.
|
|
396
|
+
|
|
397
|
+
Decide ONE thing: do those commits change the MOTIVATION, change the HIGH-LEVEL
|
|
398
|
+
SOLUTION, overturn a DECISION already stated, or add a new decision a reviewer
|
|
399
|
+
would stop and question?
|
|
400
|
+
|
|
401
|
+
Ordinary implementation work does not. Nor do fixes, refactors, tests, renames,
|
|
402
|
+
polish, or filling in something the description already promised. The honest
|
|
403
|
+
answer is almost always NO — that is the point of a description written at this
|
|
404
|
+
level, and appending to it for routine work makes it worse.
|
|
405
|
+
|
|
406
|
+
If NO: print exactly
|
|
407
|
+
|
|
408
|
+
NO-UPDATE
|
|
409
|
+
|
|
410
|
+
and nothing else.
|
|
411
|
+
|
|
412
|
+
If YES: print ONLY the new entry — two or three sentences, or a few bullets,
|
|
413
|
+
naming what moved at that level and why. Do not restate the existing
|
|
414
|
+
description, do not summarize the commits, do not write a heading (one is added
|
|
415
|
+
for you), no preamble, no code fence around the whole reply.
|
|
416
|
+
|
|
417
|
+
--- CURRENT DESCRIPTION OF PULL REQUEST #${existing.number} ---
|
|
418
|
+
${existing.body ?? ""}
|
|
419
|
+
|
|
420
|
+
--- COMMITS ADDED SINCE IT WAS LAST CONSIDERED, oldest first ---
|
|
421
|
+
${await git("log", "--no-merges", "--reverse", "--format=%H%n%B%n---", `${since}..HEAD`)}
|
|
422
|
+
|
|
423
|
+
--- DIFF STAT (whole branch) ---
|
|
424
|
+
${await git("diff", "--stat", `${mergeBase}..HEAD`)}`
|
|
425
|
+
const reply = await io.complete(prompt)
|
|
426
|
+
const out = reply && cleanReply(reply)
|
|
427
|
+
if (!out) return fail("The pull-request turn produced nothing.")
|
|
428
|
+
|
|
429
|
+
// The sync head advances either way: NO-UPDATE is a real answer.
|
|
430
|
+
const sha = await git("rev-parse", "HEAD")
|
|
431
|
+
if (/^\s*NO-UPDATE\s*$/.test(out)) {
|
|
432
|
+
await io.run(["git", "config", "--local", `branch.${branch}.prSyncHead`, sha])
|
|
433
|
+
return {
|
|
434
|
+
ok: true,
|
|
435
|
+
text: `Nothing at that level changed; the description of ${existing.url} is left alone.`,
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
// Appended, dated and anchored to its commit, so the standing text keeps saying what it said.
|
|
439
|
+
const repo = (await io.run(["gh", "repo", "view", "--json", "url", "-q", ".url"])).out.trim()
|
|
440
|
+
const anchor = repo ? `[\`${sha.slice(0, 7)}\`](${repo}/commit/${sha})` : `\`${sha.slice(0, 7)}\``
|
|
441
|
+
const body = `${existing.body ?? ""}\n\n## Update ${io.today()} — ${anchor}\n\n${out}\n`
|
|
442
|
+
const edited = await io.run(
|
|
443
|
+
["gh", "pr", "edit", String(existing.number), "--body-file", "-"],
|
|
444
|
+
body,
|
|
445
|
+
)
|
|
446
|
+
if (edited.code !== 0)
|
|
447
|
+
return fail(`gh pr edit failed; the pull request is unchanged.\n${edited.err}`)
|
|
448
|
+
await io.run(["git", "config", "--local", `branch.${branch}.prSyncHead`, sha])
|
|
449
|
+
return { ok: true, text: `Appended an update to ${existing.url}:\n\n${out}` }
|
|
450
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Everything the mod says to a person. Plain words: no gtd state ids, no tool
|
|
2
|
+
// names, and options that say what choosing them does.
|
|
3
|
+
|
|
4
|
+
import type { Run, Stop } from "../types"
|
|
5
|
+
import type { Beat } from "./drive"
|
|
6
|
+
|
|
7
|
+
export const CONTINUE = "I'm done, continue"
|
|
8
|
+
export const SAFE = "Continue with the safe choice"
|
|
9
|
+
export const HANDOFF = "Hand off to someone else"
|
|
10
|
+
export const LATER = "Not now"
|
|
11
|
+
export const SHIP = "Yes, open the pull request"
|
|
12
|
+
export const ANYONE = "Anyone on the team"
|
|
13
|
+
export const CANCEL = "Cancel"
|
|
14
|
+
export const CLOSE = "Close"
|
|
15
|
+
|
|
16
|
+
export const gateOptions = (stop: Stop) => [stop.isJudge ? SAFE : CONTINUE, HANDOFF, LATER]
|
|
17
|
+
|
|
18
|
+
const step = (s: { label?: string; state?: string }) => s.label ?? s.state ?? "the next step"
|
|
19
|
+
|
|
20
|
+
export function question(r: Run) {
|
|
21
|
+
const stop = r.stop as Stop
|
|
22
|
+
if (stop.isJudge) {
|
|
23
|
+
return `gtd can't decide this step on its own: ${step(stop)}. Continue with the safe choice, or hand it to someone who can decide?`
|
|
24
|
+
}
|
|
25
|
+
const how = r.url
|
|
26
|
+
? `Open it in your browser: ${r.url}`
|
|
27
|
+
: `Make your changes in ${stop.file ?? "the files it names"}.`
|
|
28
|
+
const again = r.isRepeat ? "You haven't changed anything yet, so gtd is still waiting. " : ""
|
|
29
|
+
return `${again}gtd needs your input: ${step(stop)}. ${how} Ready to continue?`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const STALE =
|
|
33
|
+
"This question is out of date: gtd has already moved on. Choose Close to dismiss it."
|
|
34
|
+
|
|
35
|
+
export const RELOAD_CUT_SCRIPT =
|
|
36
|
+
"a reload cut a script off mid-run — check the working tree, then Continue to run it again"
|
|
37
|
+
|
|
38
|
+
export const handoffQuestion =
|
|
39
|
+
"Who should take over from here? Type their GitHub username, or let anyone on the team pick it up."
|
|
40
|
+
|
|
41
|
+
export const shipQuestion = (branch: string) =>
|
|
42
|
+
`gtd has finished the work on ${branch}. Combine it into a single commit and open (or update) its pull request for review?`
|
|
43
|
+
|
|
44
|
+
export function headline(s: Stop) {
|
|
45
|
+
const where = step(s)
|
|
46
|
+
switch (s.kind) {
|
|
47
|
+
case "gate":
|
|
48
|
+
return `● needs your input — ${where}`
|
|
49
|
+
case "done":
|
|
50
|
+
return `✔ done — ${s.label ?? "nothing left to do"}`
|
|
51
|
+
case "stopped":
|
|
52
|
+
return `■ ${s.text}`
|
|
53
|
+
case "stalled":
|
|
54
|
+
return `✘ stuck — ${where}`
|
|
55
|
+
default:
|
|
56
|
+
return `✘ something went wrong — ${where}`
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const TONE: Record<Stop["kind"], string> = {
|
|
61
|
+
error: "red",
|
|
62
|
+
stalled: "red",
|
|
63
|
+
done: "green",
|
|
64
|
+
gate: "yellow",
|
|
65
|
+
stopped: "yellow",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The band while a step runs, in the shape of Claude's own spinner line,
|
|
69
|
+
// `✳ Thinking… (12s · …)`, which herdr reads as working. The "… (<n>[smh] ·"
|
|
70
|
+
// part is what its rule needs: the band row ends in the engine's own toggle.
|
|
71
|
+
export function runningLine(label: string, beat: number, elapsedMs: number) {
|
|
72
|
+
const s = Math.max(0, Math.floor(elapsedMs / 1000))
|
|
73
|
+
const took = s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}m` : `${Math.floor(s / 3600)}h`
|
|
74
|
+
return `✳ gtd ▸ ${label}… (${took} · step ${beat})`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// One line per beat, for the status line and the transcript.
|
|
78
|
+
export const beatLine = (beat: number, b: Beat) => `▸ ${step(b)} · step ${beat}`
|
|
79
|
+
|
|
80
|
+
// What a push to a phone says when gtd stops: the step, and where to act on it.
|
|
81
|
+
export function pushText(stop: Stop, repo: string, url?: string) {
|
|
82
|
+
const at = `${repo}: ${headline(stop).replace(/^\S+ /, "")}`
|
|
83
|
+
if (stop.kind !== "gate") return at
|
|
84
|
+
return url ? `${at}. Open: ${url}` : at
|
|
85
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export type Stop = {
|
|
2
|
+
kind: "gate" | "stalled" | "done" | "error" | "stopped"
|
|
3
|
+
text: string
|
|
4
|
+
state?: string
|
|
5
|
+
label?: string
|
|
6
|
+
isJudge?: boolean
|
|
7
|
+
// the steering file the rest asks a person to edit
|
|
8
|
+
file?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type Run = {
|
|
12
|
+
isRunning: boolean
|
|
13
|
+
beat: number
|
|
14
|
+
state?: string
|
|
15
|
+
label?: string
|
|
16
|
+
stop?: Stop
|
|
17
|
+
// `gtd ui`'s address while it serves the current rest
|
|
18
|
+
url?: string
|
|
19
|
+
// why `gtd ui` is not serving it (refused, crashed)
|
|
20
|
+
uiNote?: string
|
|
21
|
+
// the same gate came back with nothing landed since
|
|
22
|
+
isRepeat?: boolean
|
|
23
|
+
// the agent turn in flight, so a reload can land it instead of repeating it
|
|
24
|
+
inflight?: { agentId: string; memory: string }
|
|
25
|
+
// a script (beat or landing) is running, so a reload must not run it again
|
|
26
|
+
isScripting?: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
declare module "claude-code" {
|
|
30
|
+
interface PluginState {
|
|
31
|
+
gtd: { run: Run; scopes: Record<string, string>; agents: string[] }
|
|
32
|
+
}
|
|
33
|
+
}
|