@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,828 @@
1
+ import { atom, read, update } from "claude-code"
2
+ import type { EngineInterface, Register } from "claude-code"
3
+
4
+ import type { Run, Stop } from "../types"
5
+ import { afterReload, drive, isTrue } from "./drive"
6
+ import type { Beat, Io, Landing, TurnEnd } from "./drive"
7
+ import { enter } from "./entry"
8
+ import { subagentModel } from "./models"
9
+ import { JUDGE_SYSTEM, judgePrompt, toVerdicts } from "./judge"
10
+ import type { Judgment } from "./judge"
11
+ import { catchFrom, throwTo } from "./handoff"
12
+ import { READ_ONLY_GIT, ship } from "./ship"
13
+ import type { ShipIo } from "./ship"
14
+ import {
15
+ ANYONE,
16
+ beatLine,
17
+ CANCEL,
18
+ CLOSE,
19
+ CONTINUE,
20
+ gateOptions,
21
+ HANDOFF,
22
+ handoffQuestion,
23
+ headline,
24
+ LATER,
25
+ pushText,
26
+ runningLine,
27
+ question,
28
+ RELOAD_CUT_SCRIPT,
29
+ SAFE,
30
+ SHIP,
31
+ shipQuestion,
32
+ STALE,
33
+ TONE,
34
+ } from "./wording"
35
+
36
+ type $ = EngineInterface
37
+
38
+ const run = atom({ plugin: "gtd", key: "run" } as const, { isRunning: false, beat: 0 } as Run)
39
+ // memory scope (`<scope>#<hash7>`) -> the subagent holding that conversation
40
+ const scopes = atom({ plugin: "gtd", key: "scopes" } as const, {} as Record<string, string>)
41
+ const agents = atom({ plugin: "gtd", key: "agents" } as const, [] as string[])
42
+
43
+ const TEN_MINUTES = 600_000
44
+
45
+ // gtd refuses outside the repository root; every process runs there.
46
+ let root = "."
47
+ let gateUi: { stop(): void } | undefined
48
+ // bumped per rest, so an answer or hand-back for an older rest is ignored
49
+ let gateToken = 0
50
+ let lastGate: { state?: string; head: string } | undefined
51
+ // the question dialog this mod has open, to mark it stale once gtd moves on
52
+ let openAsk: { token: number; text: string } | undefined
53
+
54
+ let stopRequested = false
55
+ // when the current step began, for the band's elapsed time
56
+ let stepStartedAt = Date.now()
57
+ // Set before the first await of `start`, so two Continue presses arriving
58
+ // together cannot both pass the isRunning check and start two drivers.
59
+ let isDriving = false
60
+ let isShipping = false
61
+ // subagents answering into a file: what each may touch (see tool.call)
62
+ const delegates = new Map<string, { file: string; mayRun: RegExp | undefined }>()
63
+ const ours = new Set<string>()
64
+ const waiting = new Map<string, (end: TurnEnd) => void>()
65
+ const ended = new Map<string, TurnEnd>()
66
+ const personas = new Set<string>()
67
+
68
+ const turnEnd = (id: string) =>
69
+ new Promise<TurnEnd>((resolve) => {
70
+ const done = ended.get(id)
71
+ if (done) {
72
+ ended.delete(id)
73
+ resolve(done)
74
+ } else waiting.set(id, resolve)
75
+ })
76
+
77
+ async function gtd($: $, args: string[], stdin?: string) {
78
+ const r = await $.process.run(["gtd", ...args], { cwd: root, stdin, timeoutMs: TEN_MINUTES })
79
+ if (r.exitCode !== 0) {
80
+ throw new Error(`gtd ${args.join(" ")} exited ${r.exitCode}: ${r.stderr.trim()}`)
81
+ }
82
+ return r.stdout
83
+ }
84
+
85
+ // One agent type per system prompt. A registered type's prompt replaces the
86
+ // session's system prompt, as `--system-prompt` does for the sh driver.
87
+ async function persona($: $, system: string) {
88
+ const bytes = new TextEncoder().encode(system)
89
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))
90
+ const name =
91
+ "p-" + Array.from(digest.slice(0, 6), (b) => b.toString(16).padStart(2, "0")).join("")
92
+ if (!personas.has(name)) {
93
+ await $.agent.register({
94
+ name,
95
+ description: "gtd workflow persona, spawned by the gtd driver only",
96
+ prompt: system,
97
+ permissionMode: "bypassPermissions",
98
+ })
99
+ personas.add(name)
100
+ }
101
+ return `gtd:${name}`
102
+ }
103
+
104
+ async function send($: $, agentId: string, text: string) {
105
+ // A turn that ended while nothing waited on it must not end this one.
106
+ ended.delete(agentId)
107
+ const end = turnEnd(agentId)
108
+ const sent = await $.session.send({ to: { agentId }, text })
109
+ if (sent.isDelivered) return end
110
+ waiting.delete(agentId)
111
+ return undefined
112
+ }
113
+
114
+ async function settle($: $, end: TurnEnd) {
115
+ await update($, run, (r) => ({ ...r, inflight: undefined }))
116
+ return end
117
+ }
118
+
119
+ function io($: $): Io {
120
+ return {
121
+ next: async () => JSON.parse(await gtd($, ["next", "--json"])) as Beat,
122
+ plain: () => gtd($, ["next"]),
123
+ land: async (verdict) =>
124
+ JSON.parse(
125
+ verdict === undefined
126
+ ? await gtd($, ["land", "--json"])
127
+ : await gtd($, ["judge", "answer", "--json"], verdict),
128
+ ) as Landing,
129
+ sh: async (script, log) => {
130
+ // Streamed, not `$.process.run`: a check beat can outlast run's 10-minute cap.
131
+ const argv = log
132
+ ? ["sh", "-c", 'sh -c "$1" >>"$2" 2>&1', "gtd", script, log]
133
+ : ["sh", "-c", script]
134
+ await update($, run, (r) => ({ ...r, isScripting: true }))
135
+ const child = $.process.spawn({ argv, cwd: root })
136
+ for await (const _ of child);
137
+ const code = (await child.result).code ?? 1
138
+ await update($, run, (r) => ({ ...r, isScripting: false }))
139
+ return code
140
+ },
141
+ check: async (script) => {
142
+ // Streamed for the same reason: a validator may outlast run's cap.
143
+ const child = $.process.spawn({ argv: ["sh", "-c", script], cwd: root })
144
+ let out = ""
145
+ for await (const chunk of child) out += chunk.text
146
+ return { ok: (await child.result).code === 0, out: out.trim() }
147
+ },
148
+ judge: async () => {
149
+ const key = (await $.env.get("TYPESAFE_API_KEY")) ?? (await $.env.get("TYPESAFE_AI_KEY"))
150
+ if (!key) return judgeInSession($)
151
+ try {
152
+ const doc = await gtd($, ["judge", "--json"])
153
+ const r = await $.process.run(["gtd", "judge", "run", "--provider", "jev"], {
154
+ cwd: root,
155
+ stdin: doc,
156
+ env: { TYPESAFE_API_KEY: key },
157
+ timeoutMs: TEN_MINUTES,
158
+ })
159
+ return r.exitCode === 0 && r.stdout.trim() ? r.stdout : undefined
160
+ } catch {
161
+ return undefined
162
+ }
163
+ },
164
+ turn: async (t) => {
165
+ const known = (await read($, scopes))[t.memory]
166
+ if (t.resume && known) {
167
+ const end = send($, known, t.prompt)
168
+ await update($, run, (r) => ({ ...r, inflight: { agentId: known, memory: t.memory } }))
169
+ const ended = await end
170
+ if (ended) return settle($, ended)
171
+ }
172
+ // No live conversation for this scope (fresh scope, or this session never
173
+ // held it): start one, as the sh driver falls back to `--session-id`.
174
+ const spawned = await $.agent.spawn({
175
+ subagentType: t.system ? await persona($, t.system) : "general-purpose",
176
+ prompt: t.prompt,
177
+ description: t.label || "gtd",
178
+ model: subagentModel(t.model, {
179
+ smart: await $.env.get("GTD_PLANNERMODEL"),
180
+ base: await $.env.get("GTD_CODERMODEL"),
181
+ }),
182
+ cwd: root,
183
+ })
184
+ const id = spawned.agentId
185
+ if (!id) return { ok: false, why: spawned.deny ?? "subagent spawn refused" }
186
+ ours.add(id)
187
+ await update($, agents, (list) => [...list, id].slice(-200))
188
+ await update($, scopes, (s) => ({ ...s, [t.memory]: id }))
189
+ await update($, run, (r) => ({ ...r, inflight: { agentId: id, memory: t.memory } }))
190
+ return settle($, await turnEnd(id))
191
+ },
192
+ resume: async (memory, text) => {
193
+ const id = (await read($, scopes))[memory]
194
+ const end = id ? await send($, id, text) : undefined
195
+ return end ?? { ok: false, why: `no live subagent for ${memory}` }
196
+ },
197
+ progress: (beat, b) => {
198
+ // The band is not drawn everywhere (Remote Control, the mobile app):
199
+ // the status line and one transcript line per step say it too.
200
+ stepStartedAt = Date.now()
201
+ $.ui.status(beatLine(beat, b))
202
+ $.ui.log(beatLine(beat, b))
203
+ void update($, run, (r) => ({ ...r, beat, state: b.state, label: b.label }))
204
+ },
205
+ stopped: () => stopRequested,
206
+ }
207
+ }
208
+
209
+ async function findRoot($: $) {
210
+ const r = await $.process.run(["git", "rev-parse", "--show-toplevel"])
211
+ root = r.exitCode === 0 ? r.stdout.trim() : "."
212
+ }
213
+
214
+ async function head($: $) {
215
+ return (await $.process.run(["git", "rev-parse", "HEAD"], { cwd: root })).stdout.trim()
216
+ }
217
+
218
+ async function start($: $, landTurn?: string) {
219
+ if (isDriving) return
220
+ isDriving = true
221
+ closeUi()
222
+ moveOn($)
223
+ stopRequested = false
224
+ await findRoot($)
225
+ await update($, run, () => ({ isRunning: true, beat: 0 }))
226
+ void drive(io($), landTurn)
227
+ .catch((err: unknown): Stop => ({ kind: "error", text: String(err) }))
228
+ .then(async (stop) => {
229
+ isDriving = false
230
+ const at = { state: stop.state, head: await head($) }
231
+ const isRepeat =
232
+ stop.kind === "gate" && at.state === lastGate?.state && at.head === lastGate?.head
233
+ lastGate = at
234
+ await update($, run, (r) => ({ ...r, isRunning: false, stop, isRepeat }))
235
+ $.ui.status(undefined)
236
+ $.ui.toast(headline(stop))
237
+ if (stop.kind === "gate" || stop.kind === "done") await openGate($)
238
+ else await push($, stop)
239
+ })
240
+ }
241
+
242
+ // A new process starts from the requirements written to the steering file an
243
+ // idle rest names; the opening beat captures them like a hand-edit.
244
+ async function begin($: $, requirements: string) {
245
+ await findRoot($)
246
+ const b = JSON.parse(await gtd($, ["next", "--json"])) as Beat
247
+ if (b.state === "idle" && !isTrue(b.idle)) {
248
+ const paths = (b.changes ?? []).map((c) => c.path).join(", ")
249
+ return `The working tree has uncommitted changes (${paths}); gtd would start from those. Commit, stash or revert them, or run /gtd to start from them.`
250
+ }
251
+ if (b.state !== "idle") {
252
+ return `A gtd process is already underway at ${b.state}. Run /gtd to continue it.`
253
+ }
254
+ const file = b.file ?? ".gtd/TODO.md"
255
+ const path = `${root}/${file}`
256
+ await $.process.run(["mkdir", "-p", path.slice(0, path.lastIndexOf("/"))])
257
+ await $.fs.write(path, requirements + "\n")
258
+ await start($)
259
+ return `Started a new gtd process from ${file}.`
260
+ }
261
+
262
+ function closeUi() {
263
+ gateUi?.stop()
264
+ gateUi = undefined
265
+ }
266
+
267
+ // `gtd ui` serves exactly one rest. Its exit never continues the process, not
268
+ // even a hand-back from the web page: only the mod's own question or the band's
269
+ // Continue does, so a person always decides in Claude Code itself.
270
+ function serveUi($: $, token: number) {
271
+ let isKilled = false
272
+ let ready = () => {}
273
+ const isReady = new Promise<void>((resolve) => (ready = resolve))
274
+ const child = $.process.spawn({ argv: ["gtd", "ui"], cwd: root })
275
+ gateUi = {
276
+ stop: () => {
277
+ isKilled = true
278
+ void child.return({ code: null, signal: "SIGTERM" })
279
+ },
280
+ }
281
+ void (async () => {
282
+ let err = ""
283
+ try {
284
+ for await (const chunk of child) {
285
+ const url = chunk.stream === "stdout" ? /https?:\/\/\S+/.exec(chunk.text)?.[0] : undefined
286
+ if (url) {
287
+ await update($, run, (r) => ({ ...r, url }))
288
+ ready()
289
+ }
290
+ if (chunk.stream === "stderr") err += chunk.text
291
+ }
292
+ const { code } = await child.result
293
+ if (isKilled || token !== gateToken) return
294
+ const uiNote =
295
+ code === 0
296
+ ? "the browser view closed"
297
+ : err.trim().replace(/^gtd ui: /, "") || `exited ${code}`
298
+ await update($, run, (r) => ({ ...r, url: undefined, uiNote }))
299
+ } catch (x) {
300
+ if (!isKilled) await update($, run, (r) => ({ ...r, url: undefined, uiNote: String(x) }))
301
+ }
302
+ ready()
303
+ })()
304
+ return isReady
305
+ }
306
+
307
+ // The engine's own question dialog, not a pane: it is the state herdr, the
308
+ // desktop app and Remote Control recognize as "waiting on you".
309
+ async function openGate($: $) {
310
+ closeUi()
311
+ const token = ++gateToken
312
+ await update($, run, (r) => ({ ...r, url: undefined, uiNote: undefined }))
313
+ const stop = (await read($, run)).stop
314
+ if (stop?.kind === "done") return offerShip($, token)
315
+ // A judge gate has no screen in gtd ui: it refuses to start there.
316
+ if (!stop?.isJudge) await Promise.race([serveUi($, token), $.clock.sleep(8000)])
317
+ const r = await read($, run)
318
+ if (token !== gateToken || !r.stop) return
319
+ await push($, r.stop, r.url)
320
+ // Also a transcript line: Remote Control and the phone show no dialog.
321
+ if (r.url) $.ui.log(`Open in your browser: ${r.url}`)
322
+ const answer = await ask($, token, question(r), gateOptions(r.stop))
323
+ if (token !== gateToken) return
324
+ if (answer === CONTINUE || answer === SAFE) await proceed($)
325
+ if (answer === HANDOFF) await askThrow($)
326
+ }
327
+
328
+ // The engine's own dialog; undefined when dismissed. Remembered, so it can be
329
+ // marked out of date once gtd moves on without it (a mod cannot close it).
330
+ async function ask($: $, token: number, text: string, options: string[]) {
331
+ openAsk = { token, text }
332
+ try {
333
+ return await $.ui.ask(text, { header: "gtd", options })
334
+ } catch {
335
+ return undefined
336
+ } finally {
337
+ if (openAsk?.text === text) openAsk = undefined
338
+ }
339
+ }
340
+
341
+ // A gate is news even when nobody watches the terminal: a push reaches the
342
+ // phone over Remote Control, and the engine drops it while the person is here.
343
+ async function push($: $, stop: Stop, url?: string) {
344
+ const repo = root.split("/").pop() ?? "gtd"
345
+ try {
346
+ await $.tool.call({
347
+ tool: "PushNotification",
348
+ message: pushText(stop, repo, url),
349
+ status: "proactive",
350
+ })
351
+ } catch {
352
+ // no push channel: the dialog and the band still say it
353
+ }
354
+ }
355
+
356
+ async function askThrow($: $) {
357
+ const answer = await ask($, gateToken, handoffQuestion, [ANYONE, CANCEL])
358
+ if (answer && answer !== CANCEL) await throwNow($, answer === ANYONE ? undefined : answer.trim())
359
+ }
360
+
361
+ async function throwNow($: $, target: string | undefined) {
362
+ if ((await read($, run)).isRunning) return $.ui.log("Stop the loop first: /gtd stop.")
363
+ closeUi()
364
+ moveOn($)
365
+ const b = JSON.parse(await gtd($, ["next", "--json"])) as Beat
366
+ $.ui.status("handing off…")
367
+ const rest = {
368
+ state: b.state,
369
+ label: b.label,
370
+ content: b.content,
371
+ isIdle: b.state === "idle" && isTrue(b.idle),
372
+ }
373
+ const thrown = await throwTo(shipIo($), rest, target, new Date().toISOString()).catch(
374
+ (err: unknown) => ({ ok: false, text: String(err) }),
375
+ )
376
+ $.ui.status(undefined)
377
+ for (const line of thrown.text.split("\n")) $.ui.log(line)
378
+ if (thrown.ok) {
379
+ await update($, run, (r) => ({
380
+ ...r,
381
+ url: undefined,
382
+ uiNote: undefined,
383
+ stop: { kind: "stopped" as const, text: thrown.text },
384
+ }))
385
+ } else if ((await read($, run)).stop?.kind === "gate") {
386
+ // Nothing left: the person is still at the gate they tried to throw.
387
+ void openGate($)
388
+ }
389
+ }
390
+
391
+ async function enterNow($: $, door: "fix" | "review", base: string | undefined) {
392
+ if ((await read($, run)).isRunning) return "Stop the loop first: /gtd stop."
393
+ const entered = await enter(shipIo($), door, base)
394
+ if (entered.ok) await start($)
395
+ return entered.text
396
+ }
397
+
398
+ // Picks the process up where it was thrown: a rest that waits on a person is
399
+ // shown to the catcher first, never landed unseen.
400
+ async function catchNow($: $, ref: string) {
401
+ if ((await read($, run)).isRunning) return "Stop the loop first: /gtd stop."
402
+ const caught = await catchFrom(shipIo($), ref)
403
+ if (!caught.ok) return caught.text
404
+ await findRoot($)
405
+ const b = JSON.parse(await gtd($, ["next", "--json"])) as Beat
406
+ if (b.kind === "message") {
407
+ const stop: Stop = { kind: "gate", text: b.content ?? "", state: b.state, label: b.label }
408
+ if (b.judge) stop.isJudge = true
409
+ await update($, run, (r) => ({ ...r, stop, isRepeat: false }))
410
+ void openGate($)
411
+ } else {
412
+ await start($)
413
+ }
414
+ return caught.text
415
+ }
416
+
417
+ // A finished process on a feature branch is one answer away from its pull request.
418
+ async function offerShip($: $, token: number) {
419
+ const branch = (
420
+ await $.process.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], { cwd: root })
421
+ ).stdout.trim()
422
+ const summary = await $.process.run(["gtd", "summary"], { cwd: root })
423
+ if (summary.exitCode !== 0 || ["main", "master", "HEAD"].includes(branch)) return
424
+ await push($, { kind: "done", text: "", label: `ready to open a pull request for ${branch}` })
425
+ const answer = await ask($, token, shipQuestion(branch), [SHIP, LATER])
426
+ if (answer === SHIP && token === gateToken) await shipNow($, false)
427
+ }
428
+
429
+ // Ship's text turns need git to read the process, so they run as a subagent.
430
+ // Its answer is a file, not its hand-back: a hand-back never reaches a mod.
431
+ // `mayRun`: the only shell commands the subagent may run; none when absent.
432
+ type Delegate = {
433
+ name: string
434
+ description: string
435
+ system: string
436
+ tools: string[]
437
+ mayRun?: RegExp
438
+ }
439
+
440
+ const SHIPPER: Delegate = {
441
+ name: "shipper",
442
+ description:
443
+ "Writes commit messages and pull requests for gtd's ship, spawned by the gtd mod only",
444
+ system:
445
+ "You write commit messages and pull-request descriptions for gtd's ship command. Read the repository with git and the file tools as the instructions ask. Never modify the repository: no commits, no checkouts, no pushes.",
446
+ tools: ["Bash", "Read", "Grep", "Glob", "Write", "Skill"],
447
+ // It reads commit messages anyone on the branch wrote: read-only git, and
448
+ // no shell syntax to chain anything else onto it.
449
+ mayRun: READ_ONLY_GIT,
450
+ }
451
+
452
+ // gtd's llm judge persona; writing its answer file is its one tool.
453
+ const JUDGE: Delegate = {
454
+ name: "judge",
455
+ description: "Answers gtd judge gates, spawned by the gtd mod only",
456
+ system: `${JUDGE_SYSTEM}\nThe one tool you use is Write, to deliver that answer to the file the task names.`,
457
+ tools: ["Write"],
458
+ }
459
+
460
+ // A one-off subagent whose answer is a file, not its hand-back: a hand-back
461
+ // never reaches a mod. The file lives in the git dir, which in a linked
462
+ // worktree is not `.git`.
463
+ async function delegate($: $, who: Delegate, prompt: string, model: string, delivery: string) {
464
+ const gitDir = await $.process.run(["git", "rev-parse", "--absolute-git-dir"], { cwd: root })
465
+ const file = `${gitDir.stdout.trim()}/gtd-${who.name}-${crypto.randomUUID()}`
466
+ if (!personas.has(who.name)) {
467
+ await $.agent.register({
468
+ name: who.name,
469
+ description: who.description,
470
+ prompt: who.system,
471
+ tools: who.tools,
472
+ permissionMode: "bypassPermissions",
473
+ })
474
+ personas.add(who.name)
475
+ }
476
+ const spawned = await $.agent.spawn({
477
+ subagentType: `gtd:${who.name}`,
478
+ prompt: `${prompt}\n\n--- DELIVERY ---\n${delivery} Write it to ${file} with the Write tool. Only that file is read. Then stop.`,
479
+ description: `gtd ${who.name}`,
480
+ model,
481
+ cwd: root,
482
+ })
483
+ const id = spawned.agentId
484
+ if (!id) return undefined
485
+ ours.add(id)
486
+ delegates.set(id, { file, mayRun: who.mayRun })
487
+ const ended = await turnEnd(id)
488
+ delegates.delete(id)
489
+ try {
490
+ return ended.ok ? await $.fs.read(file) : undefined
491
+ } catch {
492
+ return undefined
493
+ } finally {
494
+ await $.process.run(["rm", "-f", file])
495
+ }
496
+ }
497
+
498
+ async function writer($: $, prompt: string) {
499
+ const model = (await $.env.get("GTD_PLANNERMODEL")) ?? "opus"
500
+ return delegate($, SHIPPER, prompt, model, "Your final answer, exactly as you would print it.")
501
+ }
502
+
503
+ // Without a TypeSafe key a judge gate is put to a small model, as gtd's own
504
+ // llm provider does, but as a subagent of this session. Any doubt leaves the
505
+ // gate to the person, which routes the workflow its cautious way.
506
+ async function judgeInSession($: $) {
507
+ try {
508
+ const j = JSON.parse(await gtd($, ["judge", "--json"])) as Judgment
509
+ const model = (await $.env.get("GTD_JUDGE_MODEL")) ?? "haiku"
510
+ const reply = await delegate(
511
+ $,
512
+ JUDGE,
513
+ judgePrompt(j),
514
+ model,
515
+ 'A JSON object mapping every question id to {"answer": <your answer>, "p": <your confidence>}, and nothing else.',
516
+ )
517
+ const verdicts = reply ? toVerdicts(j, JSON.parse(reply)) : "no answer"
518
+ return typeof verdicts === "string" ? undefined : JSON.stringify(verdicts)
519
+ } catch {
520
+ return undefined
521
+ }
522
+ }
523
+
524
+ function shipIo($: $): ShipIo {
525
+ return {
526
+ run: async (argv, stdin) => {
527
+ const r = await $.process.run(argv, { cwd: root, stdin, timeoutMs: TEN_MINUTES })
528
+ return { code: r.exitCode, out: r.stdout, err: r.stderr }
529
+ },
530
+ complete: (prompt) => writer($, prompt),
531
+ log: (line) => {
532
+ for (const l of line.split("\n")) $.ui.log(l)
533
+ },
534
+ today: () => new Date().toISOString().slice(0, 10),
535
+ }
536
+ }
537
+
538
+ async function shipNow($: $, isDry: boolean) {
539
+ if (isShipping) return $.ui.log("A pull request is already being prepared.")
540
+ isShipping = true
541
+ await dismiss($)
542
+ $.ui.status(isDry ? "previewing the pull request…" : "opening the pull request…")
543
+ const shipped = await ship(shipIo($), isDry).catch((err: unknown) => ({
544
+ ok: false,
545
+ text: String(err),
546
+ }))
547
+ isShipping = false
548
+ $.ui.status(undefined)
549
+ for (const line of shipped.text.split("\n")) $.ui.log(line)
550
+ $.ui.toast(shipped.ok ? "pull request ready" : "opening the pull request failed")
551
+ }
552
+
553
+ // Turns a missing CLI or a wrong directory into an instruction, not a stack trace.
554
+ // The plugin is gtd's own npm package, so it runs the gtd it ships and the mod
555
+ // and the CLI never disagree. A checkout without a build falls back to PATH.
556
+ // The plugin install fetches no dependencies, so they arrive here, once per
557
+ // version, beside the bundle that resolves them.
558
+ async function adoptOwnGtd($: $) {
559
+ const dir = $.plugin.root
560
+ if (!(await $.fs.exists(`${dir}/dist/gtd.bundle.mjs`))) return undefined
561
+ if (!(await $.fs.exists(`${dir}/node_modules/effect`))) {
562
+ $.ui.status("installing gtd's runtime, once per version…")
563
+ try {
564
+ const npm = await $.process.run(
565
+ [
566
+ "npm",
567
+ "install",
568
+ "--omit=dev",
569
+ "--omit=peer",
570
+ "--ignore-scripts",
571
+ "--no-audit",
572
+ "--no-fund",
573
+ "--no-package-lock",
574
+ ],
575
+ { cwd: dir, timeoutMs: TEN_MINUTES },
576
+ )
577
+ if (npm.exitCode !== 0) return `Installing gtd's runtime failed:\n${npm.stderr.trim()}`
578
+ } catch {
579
+ return "gtd needs node and npm on your PATH."
580
+ } finally {
581
+ $.ui.status(undefined)
582
+ }
583
+ }
584
+ const path = (await $.env.get("PATH")) ?? ""
585
+ if (!path.startsWith(`${dir}/bin:`)) await $.env.set("PATH", `${dir}/bin:${path}`)
586
+ return undefined
587
+ }
588
+
589
+ async function preflight($: $) {
590
+ const setup = await adoptOwnGtd($)
591
+ if (setup) return setup
592
+ try {
593
+ const v = (await $.process.run(["gtd", "--version"])).stdout.trim()
594
+ const [major = 0, minor = 0] = v.split(".").map(Number)
595
+ if (major < 15 || (major === 15 && minor < 6)) {
596
+ return `gtd ${v} is too old for this mod, which needs 15.6 or later. Update it: npm install -g @pmelab/gtd`
597
+ }
598
+ } catch {
599
+ return "The gtd CLI is not on your PATH. Install it: npm install -g @pmelab/gtd"
600
+ }
601
+ await findRoot($)
602
+ if (root === ".")
603
+ return "gtd runs inside a git repository. Start Claude Code in one, then run /gtd again."
604
+ return undefined
605
+ }
606
+
607
+ // A new rest, or none: answers to the old one no longer count, and its dialog
608
+ // is redrawn as out of date.
609
+ function moveOn($: $) {
610
+ gateToken++
611
+ $.ui.invalidate("ui.render")
612
+ }
613
+
614
+ async function proceed($: $) {
615
+ moveOn($)
616
+ closeUi()
617
+ await update($, run, (r) => ({ ...r, stop: undefined, url: undefined, uiNote: undefined }))
618
+ await start($)
619
+ }
620
+
621
+ async function dismiss($: $) {
622
+ moveOn($)
623
+ closeUi()
624
+ await update($, run, (r) => ({ ...r, stop: undefined, url: undefined, uiNote: undefined }))
625
+ }
626
+
627
+ const DROPPED = "gtd subagent notice"
628
+
629
+ async function refreshBand($: $) {
630
+ if ((await read($, run)).isRunning) $.ui.invalidate("ui.render")
631
+ }
632
+
633
+ // `/gtd [verb] …`; anything that is not a verb is a new process's requirements.
634
+ async function command($: $, arg: string): Promise<string | undefined> {
635
+ if (arg === "stop") {
636
+ stopRequested = true
637
+ return "Stopping after the current beat."
638
+ }
639
+ const problem = await preflight($)
640
+ if (problem) return problem
641
+ const [verb = "", ...rest] = arg.split(/\s+/)
642
+ switch (verb) {
643
+ case "status":
644
+ return gtd($, ["next"])
645
+ case "fix":
646
+ case "review":
647
+ return enterNow($, verb, rest[0])
648
+ case "throw":
649
+ void throwNow($, rest[0])
650
+ return undefined
651
+ case "catch":
652
+ return rest[0]
653
+ ? catchNow($, rest[0])
654
+ : "Name the pull request or branch: /gtd catch <number|branch>."
655
+ case "ship":
656
+ void shipNow($, rest[0] === "-n" || rest[0] === "--dry-run")
657
+ return undefined
658
+ case "":
659
+ return resume($)
660
+ default:
661
+ return begin($, arg)
662
+ }
663
+ }
664
+
665
+ async function resume($: $) {
666
+ const b = JSON.parse(await gtd($, ["next", "--json"])) as Beat
667
+ if (b.state === "idle" && isTrue(b.idle)) {
668
+ return "Nothing is in progress. Start a process with /gtd <requirements>, or sketch the change in .gtd/TODO.md and run /gtd."
669
+ }
670
+ await start($)
671
+ return undefined
672
+ }
673
+
674
+ // A turn a reload cut off keeps running as a subagent; wait for it, then land
675
+ // it rather than start it again.
676
+ async function resumeAfterReload($: $, was: Run) {
677
+ await findRoot($)
678
+ const { inflight } = was
679
+ const agent = inflight && (await $.agent.list()).find((a) => a.id === inflight.agentId)
680
+ const next = afterReload({ isScripting: was.isScripting, agentStatus: agent?.status })
681
+ if (next === "wait") {
682
+ $.ui.status("picking up where gtd left off…")
683
+ $.clock.after(5000, () => void resumeAfterReload($, was))
684
+ return
685
+ }
686
+ await update($, run, (r) => ({ ...r, isRunning: false, inflight: undefined, isScripting: false }))
687
+ if (next === "halt") {
688
+ const stop: Stop = {
689
+ kind: "stopped",
690
+ text: RELOAD_CUT_SCRIPT,
691
+ state: was.state,
692
+ label: was.label,
693
+ }
694
+ await update($, run, (r) => ({ ...r, stop }))
695
+ $.ui.status(undefined)
696
+ $.ui.toast(headline(stop))
697
+ return
698
+ }
699
+ await start($, next === "land" ? inflight?.memory : undefined)
700
+ }
701
+
702
+ export const register: Register = (on) => {
703
+ on("session.start", async ($, e, next) => {
704
+ // The band's elapsed time ticks while a step runs.
705
+ $.clock.every(10_000, () => void refreshBand($))
706
+ for (const id of await read($, agents)) ours.add(id)
707
+ // A reload drops the loop mid-beat and closes an open dialog; gtd keeps
708
+ // its state in git, so pick both up again.
709
+ const was = await read($, run)
710
+ if (was.isRunning) void resumeAfterReload($, was)
711
+ else if (was.stop?.kind === "gate") void findRoot($).then(() => openGate($))
712
+ await $.command.register({
713
+ name: "gtd",
714
+ description: "Drive gtd until it rests on you; pass requirements to start a new process",
715
+ argumentHint:
716
+ "[requirements | fix | review [base] | stop | status | ship [-n] | throw [@user] | catch <pr|branch>]",
717
+ immediate: true,
718
+ })
719
+ return next(e)
720
+ })
721
+
722
+ on("command.run", { command: "gtd" }, async ($, e) => {
723
+ const text = await command($, e.args.trim())
724
+ return text ? { text } : {}
725
+ }).catch(($, e, next) => ({ text: next.error.message }))
726
+
727
+ on("turn.complete", ($, e, next) => {
728
+ if (e.agentId && ours.has(e.agentId)) {
729
+ const end: TurnEnd =
730
+ e.reason === "answer" ? { ok: true } : { ok: false, why: `agent turn ended: ${e.reason}` }
731
+ const resolve = waiting.get(e.agentId)
732
+ waiting.delete(e.agentId)
733
+ if (resolve) resolve(end)
734
+ else ended.set(e.agentId, end)
735
+ }
736
+ return next(e)
737
+ })
738
+
739
+ // The loop lands a turn when it ends; work left running in the background
740
+ // would still be writing the tree gtd is committing.
741
+ on("tool.call", ($, e, next) => {
742
+ if (!e.agentId || !ours.has(e.agentId)) return next(e)
743
+ const delegated = delegates.get(e.agentId)
744
+ if (delegated) {
745
+ if (e.tool === "Write" && e.file_path !== delegated.file) {
746
+ return { deny: `Write only your answer, to ${delegated.file}.` }
747
+ }
748
+ if (e.tool === "Bash" && !delegated.mayRun?.test(String(e.command).trim())) {
749
+ return { deny: "Only read-only git commands, without shell syntax, are allowed here." }
750
+ }
751
+ }
752
+ if (e.tool === "Bash") {
753
+ return next({ ...e, run_in_background: false, timeout: e.timeout ?? TEN_MINUTES })
754
+ }
755
+ if (e.tool === "Agent") return next({ ...e, run_in_background: false })
756
+ return next(e)
757
+ })
758
+
759
+ // A subagent's hand-back arrives as a prompt to the main session, whose model
760
+ // would then act on it. gtd reads the turn's result from git instead.
761
+ on("prompt.submit", async ($, e, next) => {
762
+ if (!e.origin || e.origin.kind === "composer" || e.origin.kind === "bridge") return next(e)
763
+ if ([...ours].some((id) => e.text.includes(`from="${id}"`))) {
764
+ return { drop: DROPPED }
765
+ }
766
+ return next(e)
767
+ })
768
+
769
+ // A dialog gtd has moved past still waits for an answer; say it is stale.
770
+ on("ui.render", { component: "AskUserQuestion" }, ($, e, next) => {
771
+ const [first] = e.props.questions as { question?: string }[]
772
+ if (!openAsk || openAsk.token === gateToken || first?.question !== openAsk.text) return next(e)
773
+ return next({
774
+ ...e,
775
+ props: {
776
+ ...e.props,
777
+ questions: [
778
+ {
779
+ ...first,
780
+ question: STALE,
781
+ header: "gtd",
782
+ multiSelect: false,
783
+ options: [
784
+ { label: CLOSE, description: "Nothing happens." },
785
+ { label: LATER, description: "Nothing happens." },
786
+ ],
787
+ },
788
+ ],
789
+ },
790
+ })
791
+ })
792
+
793
+ on("agent.offer", ($, e, next) => (e.agent.startsWith("gtd:") ? { isOffered: false } : next(e)))
794
+
795
+ on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
796
+ const r = await read($, run)
797
+ if (!r.isRunning && !r.stop) return next(e)
798
+ const { Box, Button, Text } = $.ui.resolve(e)
799
+ if (r.isRunning) {
800
+ // Shaped like Claude's own spinner line, the one herdr reads as working
801
+ // (its `live_turn_working` rule): an idle prompt would say idle.
802
+ return (
803
+ <Box gap={1}>
804
+ <Text color="cyan">
805
+ {runningLine(r.label ?? r.state ?? "starting", r.beat, Date.now() - stepStartedAt)}
806
+ </Text>
807
+ <Button key="stop" label="Stop" onPress={() => void (stopRequested = true)} />
808
+ </Box>
809
+ )
810
+ }
811
+ const stop = r.stop as Stop
812
+ const ui = r.url ? `Open in your browser: ${r.url}` : r.uiNote && `No browser view: ${r.uiNote}`
813
+ const detail = TONE[stop.kind] === "red" ? stop.text.split("\n").slice(0, 8).join("\n") : ""
814
+ return (
815
+ <Box flexDirection="column">
816
+ <Box gap={1}>
817
+ <Text color={TONE[stop.kind]} bold>
818
+ gtd {headline(stop)}
819
+ </Text>
820
+ <Button key="go" label="Continue" variant="primary" onPress={() => proceed($)} />
821
+ <Button key="dismiss" label="Dismiss" role="dismiss" onPress={() => dismiss($)} />
822
+ </Box>
823
+ {ui && <Text dimColor>{ui}</Text>}
824
+ {detail && <Text dimColor>{detail}</Text>}
825
+ </Box>
826
+ )
827
+ })
828
+ }