@pmelab/gtd 17.0.0 → 17.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }