@pmelab/gtd 27.6.0 → 27.7.1

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 (42) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +25 -14
  3. package/claude/hooks/assist.ts +35 -2
  4. package/claude/hooks/delegate.ts +22 -0
  5. package/claude/hooks/handoff.ts +13 -4
  6. package/claude/hooks/jobs.ts +67 -0
  7. package/claude/hooks/paste.ts +17 -0
  8. package/claude/hooks/register.tsx +126 -65
  9. package/claude/hooks/ship.ts +7 -2
  10. package/claude/hooks/tools.ts +32 -0
  11. package/claude/hooks/wording.ts +11 -0
  12. package/dist/chunks/{reserved-Df8GeSHw.mjs → reserved-UdSuHDUJ.mjs} +2 -2
  13. package/dist/chunks/{tail-DmIfEAI4.mjs → tail-fj60rQ3y.mjs} +1 -1
  14. package/dist/gtd.bundle.mjs +3 -3
  15. package/dist/testkit.mjs +30 -18
  16. package/dist/workflows/cleanup.mjs +2 -2
  17. package/dist/workflows/feature.mjs +3 -3
  18. package/dist/workflows/fix.mjs +2 -2
  19. package/dist/workflows/review.mjs +2 -2
  20. package/dist/workflows/rollout.mjs +1 -1
  21. package/dist/workflows.mjs +3 -3
  22. package/package.json +1 -1
  23. package/src/cli/Cli.ts +2 -2
  24. package/src/testkit/EmittedScriptRecognizer.test.ts +41 -0
  25. package/src/testkit/EmittedScriptRecognizer.ts +16 -7
  26. package/src/testkit/TestRepo.test.ts +79 -1
  27. package/src/testkit/TestRepo.ts +17 -6
  28. package/src/testkit/steps.ts +44 -1
  29. package/src/testkit/testkit.feature +35 -0
  30. package/src/workflows/cleanup/cleanup-path.feature +94 -0
  31. package/src/workflows/cleanup/cleanup.feature +0 -29
  32. package/src/workflows/feature/fast-path.feature +114 -0
  33. package/src/workflows/feature/one-step-land.feature +108 -0
  34. package/src/workflows/feature/refactor-first.feature +137 -0
  35. package/src/workflows/feature/review.test.ts +9 -3
  36. package/src/workflows/feature/review.ts +3 -3
  37. package/src/workflows/feature/risk-tier.feature +0 -63
  38. package/src/workflows/feature/seam-rejection.feature +146 -0
  39. package/src/workflows/feature/seam.feature +0 -67
  40. package/src/workflows/shared/steps.ts +1 -2
  41. package/src/workflows/shared/tail.test.ts +18 -9
  42. package/src/workflows/shared/workflows.steps.ts +5 -4
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gtd",
3
- "version": "27.6.0",
3
+ "version": "27.7.1",
4
4
  "description": "Drive the gtd loop inside Claude Code: agent turns run as subagents, gtd decides every beat",
5
5
  "author": {
6
6
  "name": "Philipp Melab"
package/README.md CHANGED
@@ -324,18 +324,26 @@ Then, in any repository:
324
324
  /gtd Add a --json flag to the export command
325
325
  ```
326
326
 
327
- That starts a process from your requirements and drives it until it needs you.
328
- `/gtd fix` and `/gtd review [base]` take the two doors described below. At every
329
- human rest Claude itself takes over: it walks you through the open questions one
330
- at a time, answers your own questions about the plan or the diff, records what
331
- you decide in the steering file the way the step asks, and moves the process on
332
- only when you say so. While a step runs you can keep asking; requests for
333
- changes wait for the next rest. When the process finishes, Claude offers to
334
- squash it into one commit and open its pull request (or run `/gtd ship`). A
335
- process can change hands at any gate: ask Claude to hand it off (or run
336
- `/gtd throw @dev`) and it opens a draft pull request assigned to them, and
337
- `/gtd catch <pr>` picks it up exactly where it waits, so whoever wrote the
338
- requirements can hand the architecture to someone else. See
327
+ That starts a process from your requirements and drives it until it needs you. A
328
+ long, multi-line requirements paste still starts a process; if Claude Code never
329
+ expanded the paste, gtd says it could not read it, and you write the
330
+ requirements to `.gtd/TODO.md` and run `/gtd`. `/gtd fix` and
331
+ `/gtd review [base]` take the two doors described below. At every human rest
332
+ Claude itself takes over: it walks you through the open questions one at a time,
333
+ answers your own questions about the plan or the diff, records what you decide
334
+ in the steering file the way the step asks, and moves the process on only when
335
+ you say so; a review you leave untouched signs off. While a step runs you can
336
+ keep asking; requests for changes wait for the next rest. When the process
337
+ finishes, Claude offers to squash it into one commit and open its pull request
338
+ (or run `/gtd ship`). A process can change hands at any gate: ask Claude to hand
339
+ it off (or run `/gtd throw @dev`) and it opens a draft pull request assigned to
340
+ them, and `/gtd catch <pr>` picks it up exactly where it waits, so whoever wrote
341
+ the requirements can hand the architecture to someone else. Ship, hand-off and
342
+ throw answer at once and run in the background, with progress on the status
343
+ line; they report by waking Claude, a toast, a transcript line and a push
344
+ notification, and a failed push or `gh` call reports as a failure. While a ship
345
+ or hand-off runs or is queued, `/gtd catch`, the doors, `/gtd <requirements>`
346
+ and bare `/gtd` wait for its report. See
339
347
  [Inside Claude Code](https://github.com/pmelab/gtd/blob/main/docs/driver.md#inside-claude-code-the-gtd-mod).
340
348
 
341
349
  ### Then let an agent build your own
@@ -416,7 +424,7 @@ noted in steps 3 and 4 below.
416
424
  `gtd judge run --provider jev` asks TypeSafe's Jev instead, with the key in
417
425
  `TYPESAFE_API_KEY` and `JEV_BASE_URL` optionally overriding the endpoint;
418
426
  `gtd judge run --provider llm` asks the `claude` CLI on `PATH` (Claude Code
419
- users only; model `haiku`, `--model <name>` overrides) and reuses its login.
427
+ users only; model `sonnet`; `--model <name>` overrides) and reuses its login.
420
428
  With no `--provider`, `gtd judge run` picks jev when `TYPESAFE_API_KEY` is
421
429
  set and non-empty and no `--model` is given, else llm — never falling back
422
430
  across them; the `GTD_JUDGE_PROVIDER` / `GTD_JUDGE_MODEL` environment
@@ -431,7 +439,8 @@ noted in steps 3 and 4 below.
431
439
 
432
440
  gtd itself never calls a judge: `gtd loop`, the Claude Code mod and the
433
441
  reference driver answer these through `gtd judge run` (auto selection: jev
434
- when `TYPESAFE_API_KEY` is set, else `llm` via `claude`, default model haiku,
442
+ when `TYPESAFE_API_KEY` is set, else `llm` via `claude`, default model
443
+ sonnet, while the Claude Code mod's own judge subagent stays on `haiku` —
435
444
  `--model <name>` overrides); if that fails it shows you the message and
436
445
  stops. **The `llm` provider's `p` is self-reported by the model, not a
437
446
  measured probability, so a confidently wrong verdict can clear the 0.7 floor
@@ -576,6 +585,8 @@ Two more bundled workflows retire a release flag:
576
585
  `gtd --workflow cleanup --var flag=<name>` removes it afterwards. See
577
586
  [Feature flags](docs/configuration.md#rolling-out-and-cleaning-up-a-release-flag).
578
587
 
588
+ The delivery model these workflows implement: [Delivery model](SDLC.md).
589
+
579
590
  ## Checking a range for stray files
580
591
 
581
592
  ```sh
@@ -5,7 +5,9 @@
5
5
 
6
6
  import type { Stop } from "../types"
7
7
  import { RELOAD_CUT_SCRIPT } from "./wording"
8
- import { TOOL } from "./tools"
8
+ import type { Shipped } from "./ship"
9
+ import { JOB_NAME, TOOL } from "./tools"
10
+ import type { JobName } from "./tools"
9
11
 
10
12
  export type Asked = {
11
13
  id: string
@@ -35,7 +37,13 @@ export function wakes(stop: Stop, branch: string | undefined): boolean {
35
37
 
36
38
  const step = (s: Stop) => s.label ?? s.state ?? "the current step"
37
39
 
38
- const fence = (text: string) => `~~~~text\n${text.trim()}\n~~~~`
40
+ // The text can come from commit messages anyone on the branch wrote: the fence
41
+ // is longer than any run of ~ inside it, so the text cannot close it early.
42
+ const fence = (text: string) => {
43
+ const body = text.trim()
44
+ const bar = "~".repeat(Math.max(4, ...[...body.matchAll(/~+/g)].map((m) => m[0].length + 1)))
45
+ return `${bar}text\n${body}\n${bar}`
46
+ }
39
47
 
40
48
  const RULES = `Ground rules:
41
49
  - gtd decides what happens next. Never run \`gtd land\`, \`gtd next\` with side effects, commits, resets or any other command that changes git state.
@@ -133,3 +141,28 @@ export function wakePrompt(w: Wake): string {
133
141
  const extras = `Only when the person asks: ${TOOL.handoff} hands the process to someone else, ${TOOL.browser} opens gtd's browser view of this step.`
134
142
  return [...body, ...notes, RULES, extras].join("\n\n")
135
143
  }
144
+
145
+ // The wake after a background ship or hand-off ended: the model only relays it.
146
+ export function jobPrompt(job: JobName, outcome: Shipped): string {
147
+ const name = JOB_NAME[job]
148
+ if (outcome.ok) {
149
+ return [
150
+ `The ${name} the person asked for finished. gtd's report:`,
151
+ fence(outcome.text),
152
+ "Relay the result to the person in a line or two, with any link in it. Do nothing else.",
153
+ ].join("\n\n")
154
+ }
155
+ const lines = [
156
+ `The ${name} the person asked for failed. gtd reports the failed step:`,
157
+ fence(outcome.text),
158
+ ]
159
+ if (job === "ship") {
160
+ lines.push(
161
+ `If the commits were already squashed, they stay squashed: running /gtd ship again pushes the already-squashed branch.`,
162
+ )
163
+ }
164
+ lines.push(
165
+ "Tell the person what failed in plain words and what to try. Do not retry it yourself.",
166
+ )
167
+ return lines.join("\n\n")
168
+ }
@@ -0,0 +1,22 @@
1
+ export type AnswerIo = {
2
+ ended: Promise<{ ok: boolean }>
3
+ deadline: Promise<unknown>
4
+ read(): Promise<string>
5
+ remove(): Promise<unknown>
6
+ }
7
+
8
+ export async function collectAnswer(io: AnswerIo): Promise<string | undefined> {
9
+ const ended = await Promise.race([io.ended, io.deadline.then(() => undefined)])
10
+ if (!ended) {
11
+ // The agent may still write the file: remove it only once its turn ends.
12
+ void io.ended.then(io.remove).catch(() => undefined)
13
+ return undefined
14
+ }
15
+ try {
16
+ return ended.ok ? await io.read() : undefined
17
+ } catch {
18
+ return undefined
19
+ } finally {
20
+ await io.remove()
21
+ }
22
+ }
@@ -43,11 +43,17 @@ export async function throwTo(
43
43
  if (assigned) return fail(assigned)
44
44
  }
45
45
  const to = handle ? `to @${handle}` : "to anyone"
46
- await io.run(
46
+ const commented = await io.run(
47
47
  ["gh", "pr", "comment", String(pr.number), "--body-file", "-"],
48
48
  `Thrown ${to} at **${at}**. Catch it with \`/gtd catch ${on.branch}\`.\n`,
49
49
  )
50
- return { ok: true, text: `Thrown ${to}: ${pr.url}` }
50
+ // The hand-off already happened: failing here would keep the run open locally
51
+ // while the pull request is someone else's.
52
+ const warning =
53
+ commented.code === 0
54
+ ? ""
55
+ : `\nWarning: gh pr comment failed; no catch hint was posted.\n${commented.err}`
56
+ return { ok: true, text: `Thrown ${to}: ${pr.url}${warning}` }
51
57
  }
52
58
 
53
59
  // The catcher checks out what origin holds, so a throw describing local HEAD
@@ -141,8 +147,11 @@ async function draft(
141
147
  // A pull request someone opened for review keeps its own description and
142
148
  // stays ready: only ship marks a draft ready again, and only a thrown one.
143
149
  if (pr.body?.includes(THROWN)) {
144
- await io.run(["gh", "pr", "edit", String(pr.number), "--body-file", "-"], body)
145
- await io.run(["gh", "pr", "ready", String(pr.number), "--undo"])
150
+ const edited = await io.run(["gh", "pr", "edit", String(pr.number), "--body-file", "-"], body)
151
+ if (edited.code !== 0) return `gh pr edit failed; the pull request is unchanged.\n${edited.err}`
152
+ const undone = await io.run(["gh", "pr", "ready", String(pr.number), "--undo"])
153
+ if (undone.code !== 0)
154
+ return `gh pr ready --undo failed; ${pr.url} is not a draft again.\n${undone.err}`
146
155
  }
147
156
  return pr
148
157
  }
@@ -0,0 +1,67 @@
1
+ // The one background slot ship and hand-off share: both push the branch, so
2
+ // two at once would race. No global deadline on purpose: a deadline that freed
3
+ // the slot while the work still ran would let a second push overwrite the first.
4
+
5
+ import type { Shipped } from "./ship"
6
+ import type { JobName } from "./tools"
7
+ import { JOB_NAME } from "./tools"
8
+
9
+ export type Jobs = {
10
+ // Claims the slot now; `work` starts at once or, `isInTurn`, at the next
11
+ // `flush`. `done` runs when it ends, either way, and owns the job's effect
12
+ // and report. Answers at once: the started text, or a refusal when a job is
13
+ // running or queued.
14
+ start(
15
+ job: JobName,
16
+ work: () => Promise<Shipped>,
17
+ isInTurn: boolean,
18
+ done: (outcome: Shipped) => Promise<void>,
19
+ ): string
20
+ flush(): void
21
+ busy(): JobName | undefined
22
+ }
23
+
24
+ export function jobs(): Jobs {
25
+ let current:
26
+ | {
27
+ job: JobName
28
+ work: () => Promise<Shipped>
29
+ done: (outcome: Shipped) => Promise<void>
30
+ isRunning: boolean
31
+ }
32
+ | undefined
33
+
34
+ const launch = () => {
35
+ const slot = current
36
+ if (!slot || slot.isRunning) return
37
+ slot.isRunning = true
38
+ void (async () => {
39
+ let outcome: Shipped
40
+ try {
41
+ outcome = await slot.work()
42
+ } catch (err) {
43
+ outcome = { ok: false, text: err instanceof Error ? err.message : String(err) }
44
+ } finally {
45
+ current = undefined
46
+ }
47
+ try {
48
+ await slot.done(outcome)
49
+ } catch {
50
+ // a failed report must not become an unhandled rejection
51
+ }
52
+ })()
53
+ }
54
+
55
+ return {
56
+ start(job, work, isInTurn, done) {
57
+ if (current) {
58
+ return `A ${JOB_NAME[current.job]} is already being prepared; wait for its report.`
59
+ }
60
+ current = { job, work, done, isRunning: false }
61
+ if (!isInTurn) launch()
62
+ return `The ${JOB_NAME[job]} has started in the background; its outcome is reported here when it ends.`
63
+ },
64
+ flush: launch,
65
+ busy: () => current?.job,
66
+ }
67
+ }
@@ -0,0 +1,17 @@
1
+ export type Pasted = { arg: string } | { lost: string }
2
+
3
+ const PLACEHOLDER = /\[Pasted text #\d+[^\]]*\]/
4
+
5
+ // The mod cannot recover an unexpanded paste: Claude Code's paste cache is keyed
6
+ // by a hash it does not expose.
7
+ export function pastedCommand(text: string): Pasted | undefined {
8
+ const m = /^\/gtd\s+([\s\S]*)$/.exec(text)
9
+ const arg = m?.[1]?.trim()
10
+ if (!arg) return undefined
11
+ if (PLACEHOLDER.test(arg)) {
12
+ return {
13
+ lost: "The pasted requirements did not arrive whole. Write them to .gtd/TODO.md and run /gtd.",
14
+ }
15
+ }
16
+ return { arg }
17
+ }
@@ -4,22 +4,28 @@ import type { EngineInterface, Register } from "claude-code"
4
4
  import type { Run, Stop } from "../types"
5
5
  import { accessDenial, mainDenial } from "./access"
6
6
  import type { AccessDef } from "./access"
7
- import { wakePrompt, wakes } from "./assist"
7
+ import { jobPrompt, wakePrompt, wakes } from "./assist"
8
8
  import { afterReload, drive, isTrue } from "./drive"
9
9
  import type { Io, Opening, Turn, TurnEnd } from "./drive"
10
10
  import { doors, enter } from "./doors"
11
11
  import { subagentModel } from "./models"
12
+ import { pastedCommand } from "./paste"
12
13
  import { personaSpec, resumable } from "./persona"
13
14
  import { JUDGE_SYSTEM, judgePrompt, judgeQuestions, judgeRunEnv, toVerdicts } from "./judge"
14
15
  import type { Judgment } from "./judge"
15
16
  import { catchFrom, throwTo } from "./handoff"
17
+ import { jobs } from "./jobs"
16
18
  import { documents, readDocument } from "./schema"
17
19
  import { READ_ONLY_GIT, ship } from "./ship"
18
- import type { ShipIo } from "./ship"
19
- import { decideContinue, restId, TOOLS } from "./tools"
20
+ import type { Shipped, ShipIo } from "./ship"
21
+ import { collectAnswer } from "./delegate"
22
+ import { decideCommand, decideContinue, decideJob, restId, TOOLS } from "./tools"
23
+ import type { JobName } from "./tools"
20
24
  import {
21
25
  beatLine,
22
26
  headline,
27
+ jobHeadline,
28
+ jobPushText,
23
29
  pushText,
24
30
  restartLine,
25
31
  RELOAD_CUT_SCRIPT,
@@ -52,7 +58,6 @@ let stepStartedAt = Date.now()
52
58
  // Set before the first await of `start`, so two Continue presses arriving
53
59
  // together cannot both pass the isRunning check and start two drivers.
54
60
  let isDriving = false
55
- let isShipping = false
56
61
  // bumped by every stop, so a queued continue that was mid-check sees it
57
62
  let stopCount = 0
58
63
  // subagents answering into a file: what each may touch (see tool.call)
@@ -219,7 +224,7 @@ async function head($: $) {
219
224
  }
220
225
 
221
226
  async function start($: $, opening: Opening = {}) {
222
- if (isDriving) return
227
+ if (isDriving || background.busy()) return
223
228
  isDriving = true
224
229
  closeUi()
225
230
  moveOn($)
@@ -371,45 +376,75 @@ async function browse($: $) {
371
376
 
372
377
  // A gate is news even when nobody watches the terminal: a push reaches the
373
378
  // phone over Remote Control, and the engine drops it while the person is here.
379
+ const repoName = () => root.split("/").pop() ?? "gtd"
380
+
374
381
  async function push($: $, stop: Stop, url?: string) {
375
- const repo = root.split("/").pop() ?? "gtd"
382
+ await notify($, pushText(stop, repoName(), url))
383
+ }
384
+
385
+ async function notify($: $, message: string) {
376
386
  try {
377
- await $.tool.call({
378
- tool: "PushNotification",
379
- message: pushText(stop, repo, url),
380
- status: "proactive",
381
- })
387
+ await $.tool.call({ tool: "PushNotification", message, status: "proactive" })
382
388
  } catch {
383
389
  // no push channel: the dialog and the band still say it
384
390
  }
385
391
  }
386
392
 
387
- async function throwNow($: $, target: string | undefined) {
388
- if ((await read($, run)).isRunning) return "Stop the loop first: /gtd stop."
389
- closeUi()
390
- moveOn($)
391
- const b = await docs($).next()
392
- $.ui.status("handing off…")
393
- const rest = {
394
- state: b.state,
395
- label: b.label,
396
- content: b.content,
397
- isIdle: isTrue(b.idle),
398
- }
399
- const thrown = await throwTo(shipIo($), rest, target, new Date().toISOString()).catch(
400
- (err: unknown) => ({ ok: false, text: String(err) }),
393
+ const background = jobs()
394
+
395
+ // A job asked from the model's tool call waits for that turn to end; one asked
396
+ // from the command starts right after, off the hook, so the command answers in
397
+ // budget. Either way the slot is claimed now, with the job's own effect.
398
+ function startJob(
399
+ $: $,
400
+ job: JobName,
401
+ isInTurn: boolean,
402
+ work: () => Promise<Shipped>,
403
+ then?: (outcome: Shipped) => Promise<void>,
404
+ ) {
405
+ const answer = background.start(job, work, true, async (outcome) => {
406
+ if (then) await then(outcome)
407
+ $.ui.status(undefined)
408
+ for (const line of outcome.text.split("\n")) $.ui.log(line)
409
+ $.ui.toast(jobHeadline(job, outcome))
410
+ await notify($, jobPushText(job, outcome, repoName()))
411
+ const text = jobPrompt(job, outcome)
412
+ $.clock.after(0, () => void $.prompt.submit({ text }))
413
+ })
414
+ if (!isInTurn) $.clock.after(0, () => background.flush())
415
+ return answer
416
+ }
417
+
418
+ async function throwNow($: $, target: string | undefined, isInTurn: boolean) {
419
+ const refused = decideJob(await read($, run), "handoff")
420
+ if (refused) return refused
421
+ return startJob(
422
+ $,
423
+ "handoff",
424
+ isInTurn,
425
+ async () => {
426
+ closeUi()
427
+ moveOn($)
428
+ const b = await docs($).next()
429
+ $.ui.status("handing off…")
430
+ const rest = {
431
+ state: b.state,
432
+ label: b.label,
433
+ content: b.content,
434
+ isIdle: isTrue(b.idle),
435
+ }
436
+ return throwTo(shipIo($), rest, target, new Date().toISOString())
437
+ },
438
+ async (thrown) => {
439
+ if (!thrown.ok) return
440
+ await update($, run, (r) => ({
441
+ ...r,
442
+ url: undefined,
443
+ uiNote: undefined,
444
+ stop: { kind: "stopped" as const, text: thrown.text },
445
+ }))
446
+ },
401
447
  )
402
- $.ui.status(undefined)
403
- for (const line of thrown.text.split("\n")) $.ui.log(line)
404
- if (thrown.ok) {
405
- await update($, run, (r) => ({
406
- ...r,
407
- url: undefined,
408
- uiNote: undefined,
409
- stop: { kind: "stopped" as const, text: thrown.text },
410
- }))
411
- }
412
- return thrown.text
413
448
  }
414
449
 
415
450
  async function enterNow($: $, door: string, args: string[]) {
@@ -499,15 +534,14 @@ async function delegate($: $, who: Delegate, prompt: string, model: string, deli
499
534
  if (!id) return undefined
500
535
  ours.add(id)
501
536
  delegates.set(id, { file, mayRun: who.mayRun })
502
- const ended = await turnEnd(id)
503
- delegates.delete(id)
504
- try {
505
- return ended.ok ? await $.fs.read(file) : undefined
506
- } catch {
507
- return undefined
508
- } finally {
509
- await $.process.run(["rm", "-f", file])
510
- }
537
+ // A timed-out agent may still be running: it keeps its tool limits until its
538
+ // turn ends, where turn.complete drops them.
539
+ return collectAnswer({
540
+ ended: turnEnd(id),
541
+ deadline: $.clock.sleep(TEN_MINUTES),
542
+ read: () => $.fs.read(file),
543
+ remove: () => $.process.run(["rm", "-f", file]),
544
+ })
511
545
  }
512
546
 
513
547
  async function writer($: $, prompt: string) {
@@ -544,25 +578,28 @@ function shipIo($: $): ShipIo {
544
578
  complete: (prompt) => writer($, prompt),
545
579
  log: (line) => {
546
580
  for (const l of line.split("\n")) $.ui.log(l)
581
+ if (background.busy()) $.ui.status(line.split("\n")[0]!)
547
582
  },
548
583
  today: () => new Date().toISOString().slice(0, 10),
549
584
  }
550
585
  }
551
586
 
552
- async function shipNow($: $, isDry: boolean) {
553
- if (isShipping) return "A pull request is already being prepared."
554
- isShipping = true
555
- await dismiss($)
556
- $.ui.status(isDry ? "previewing the pull request…" : "opening the pull request…")
557
- const shipped = await ship(shipIo($), isDry).catch((err: unknown) => ({
558
- ok: false,
559
- text: String(err),
560
- }))
561
- isShipping = false
562
- $.ui.status(undefined)
563
- for (const line of shipped.text.split("\n")) $.ui.log(line)
564
- $.ui.toast(shipped.ok ? "pull request ready" : "opening the pull request failed")
565
- return shipped.text
587
+ async function shipNow($: $, isDry: boolean, isInTurn: boolean) {
588
+ const refused = decideJob(await read($, run), "ship")
589
+ if (refused) return refused
590
+ return startJob(
591
+ $,
592
+ "ship",
593
+ isInTurn,
594
+ async () => {
595
+ $.ui.status(isDry ? "previewing the pull request…" : "opening the pull request…")
596
+ return ship(shipIo($), isDry)
597
+ },
598
+ async (shipped) => {
599
+ // a failed ship leaves the run as it was; a dry run changes nothing
600
+ if (shipped.ok && !isDry) await dismiss($)
601
+ },
602
+ )
566
603
  }
567
604
 
568
605
  // Turns a missing CLI or a wrong directory into an instruction, not a stack trace.
@@ -641,6 +678,7 @@ async function proceed(
641
678
  const go = await decideContinue(r, rest, verdicts, {
642
679
  current: () => currentRest($, r.stop?.state),
643
680
  validate: () => validateRest($),
681
+ busy: background.busy(),
644
682
  judgment: async () => {
645
683
  const doc = await gtd($, ["judge", "--json"]).catch(() => undefined)
646
684
  return doc === undefined ? undefined : readDocument<Judgment>("judge --json", doc)
@@ -765,6 +803,8 @@ async function stopNow($: $) {
765
803
  // `/gtd [verb] …`; anything that is not a verb is a new process's requirements.
766
804
  async function command($: $, arg: string): Promise<string | undefined> {
767
805
  if (arg === "stop") return stopNow($)
806
+ const busy = decideCommand(arg, background.busy())
807
+ if (busy) return busy
768
808
  const problem = await preflight($)
769
809
  if (problem) return problem
770
810
  const [verb = "", ...rest] = arg.split(/\s+/)
@@ -772,7 +812,7 @@ async function command($: $, arg: string): Promise<string | undefined> {
772
812
  case "status":
773
813
  return gtd($, ["next"])
774
814
  case "throw":
775
- return throwNow($, rest[0])
815
+ return throwNow($, rest[0], false)
776
816
  case "ui":
777
817
  return browse($)
778
818
  case "catch":
@@ -780,7 +820,7 @@ async function command($: $, arg: string): Promise<string | undefined> {
780
820
  ? catchNow($, rest[0])
781
821
  : "Name the pull request or branch: /gtd catch <number|branch>."
782
822
  case "ship":
783
- return shipNow($, rest[0] === "-n" || rest[0] === "--dry-run")
823
+ return shipNow($, rest[0] === "-n" || rest[0] === "--dry-run", false)
784
824
  case "":
785
825
  return resume($)
786
826
  default:
@@ -912,10 +952,14 @@ export const register: Register = (on) => {
912
952
  }).catch(($, e, next) => ({ text: next.error.message }))
913
953
 
914
954
  on("turn.complete", ($, e, next) => {
915
- if (!e.agentId) void launchPending($)
955
+ if (!e.agentId) {
956
+ background.flush()
957
+ void launchPending($)
958
+ }
916
959
  if (e.agentId && ours.has(e.agentId)) {
917
960
  const end: TurnEnd =
918
961
  e.reason === "answer" ? { ok: true } : { ok: false, why: `agent turn ended: ${e.reason}` }
962
+ delegates.delete(e.agentId)
919
963
  const resolve = waiting.get(e.agentId)
920
964
  waiting.delete(e.agentId)
921
965
  if (resolve) resolve(end)
@@ -943,7 +987,24 @@ export const register: Register = (on) => {
943
987
  // A subagent's hand-back arrives as a prompt to the main session, whose model
944
988
  // would then act on it. gtd reads the turn's result from git instead.
945
989
  on("prompt.submit", async ($, e, next) => {
946
- if (!e.origin || e.origin.kind === "composer" || e.origin.kind === "bridge") return next(e)
990
+ if (!e.origin || e.origin.kind === "composer" || e.origin.kind === "bridge") {
991
+ const pasted = pastedCommand(e.text)
992
+ if (!pasted) return next(e)
993
+ // Off the hook: preflight may install gtd's runtime and outlast its budget.
994
+ const say = (text: string) => {
995
+ for (const line of text.split("\n")) $.ui.log(line)
996
+ $.ui.toast(text.split("\n")[0]!)
997
+ }
998
+ if ("lost" in pasted) say(pasted.lost)
999
+ else {
1000
+ $.clock.after(0, () => {
1001
+ void command($, pasted.arg)
1002
+ .then((text) => text && say(text))
1003
+ .catch((err) => say(err instanceof Error ? err.message : String(err)))
1004
+ })
1005
+ }
1006
+ return { drop: "gtd took the command" }
1007
+ }
947
1008
  if ([...ours].some((id) => e.text.includes(`from="${id}"`))) {
948
1009
  return { drop: DROPPED }
949
1010
  }
@@ -957,10 +1018,10 @@ export const register: Register = (on) => {
957
1018
  answer(await note($, String(e.text ?? "")), true),
958
1019
  )
959
1020
  on("tool.call", { tool: "mcp__gtd__handoff" }, async ($, e) =>
960
- answer(await throwNow($, typeof e.to === "string" && e.to ? e.to : undefined), true),
1021
+ answer(await throwNow($, typeof e.to === "string" && e.to ? e.to : undefined, true), true),
961
1022
  )
962
1023
  on("tool.call", { tool: "mcp__gtd__ship" }, async ($, e) =>
963
- answer(await shipNow($, e.dryRun === true), true),
1024
+ answer(await shipNow($, e.dryRun === true, true), true),
964
1025
  )
965
1026
  on("tool.call", { tool: "mcp__gtd__browser" }, async ($) => answer(await browse($), true))
966
1027
 
@@ -334,13 +334,18 @@ ${await git("diff", "--stat", `${mergeBase}..HEAD`)}`
334
334
  const head = await git("rev-parse", "HEAD")
335
335
  if (thrown) {
336
336
  const n = String(thrown.number)
337
+ // Ready first: a failure here leaves the THROWN marker, so a retry comes back.
338
+ const ready = await io.run(["gh", "pr", "ready", n])
339
+ if (ready.code !== 0)
340
+ return fail(`gh pr ready failed; ${thrown.url} is unchanged.\n${ready.err}`)
337
341
  const edited = await io.run(
338
342
  ["gh", "pr", "edit", n, "--title", title, "--body-file", "-"],
339
343
  body + "\n",
340
344
  )
341
345
  if (edited.code !== 0)
342
- return fail(`gh pr edit failed; the pull request is unchanged.\n${edited.err}`)
343
- await io.run(["gh", "pr", "ready", n])
346
+ return fail(
347
+ `gh pr edit failed; ${thrown.url} is marked ready but keeps its thrown title and description.\n${edited.err}`,
348
+ )
344
349
  await io.run(["git", "config", "--local", `branch.${branch}.prSyncHead`, head])
345
350
  return { ok: true, text: `Rewrote ${thrown.url} and marked it ready for review.` }
346
351
  }
@@ -71,6 +71,37 @@ export type ContinueIo = {
71
71
  // the rest's own `gtd validate` script, run; ok when there is nothing to check
72
72
  validate(): Promise<{ ok: boolean; out: string }>
73
73
  judgment(): Promise<Judgment | undefined>
74
+ // a ship or hand-off running or queued: it writes the tree the driver would
75
+ busy?: JobName
76
+ }
77
+
78
+ export type JobName = "ship" | "handoff"
79
+
80
+ export const JOB_NAME: Record<JobName, string> = { ship: "ship", handoff: "hand-off" }
81
+
82
+ const busyText = (job: JobName) => `A ${JOB_NAME[job]} is still running: wait for its report.`
83
+
84
+ // Only these leave the tree alone (ship and throw refuse for themselves); any
85
+ // other verb, unknown ones included, may switch branch or write .gtd/. `stop`
86
+ // is not here: `command` takes the bare word itself, and `stop <words>` is a
87
+ // requirement. `ui` is not: its page writes steering files.
88
+ const WHILE_BUSY = new Set(["status", "throw", "ship"])
89
+
90
+ export function decideCommand(arg: string, busy: JobName | undefined): string | undefined {
91
+ if (!busy) return undefined
92
+ const verb = arg.trim().split(/\s+/)[0] ?? ""
93
+ return WHILE_BUSY.has(verb) ? undefined : busyText(busy)
94
+ }
95
+
96
+ // Pure: a refused job leaves the run, and so `continue`, as it was.
97
+ export function decideJob(run: Run, job: JobName): string | undefined {
98
+ if (run.isRunning) return "gtd is running a step: wait for it to rest."
99
+ if (run.pending) return "A continue is already waiting for this turn to end."
100
+ const kind = run.stop?.kind
101
+ if (job === "ship" && (kind === "gate" || kind === "stalled" || kind === "error")) {
102
+ return "gtd still waits on the person at this step: answer it with continue first."
103
+ }
104
+ return undefined
74
105
  }
75
106
 
76
107
  // What `continue` may do from here. `rest` undefined is the band's Continue,
@@ -82,6 +113,7 @@ export async function decideContinue(
82
113
  io: ContinueIo,
83
114
  ): Promise<Proceed> {
84
115
  if (run.isRunning) return { refuse: "gtd is already running a step." }
116
+ if (io.busy) return { refuse: busyText(io.busy) }
85
117
  const stop = run.stop
86
118
  if (!stop) return { refuse: "Nothing is waiting: gtd has no rest open. /gtd drives it." }
87
119
  if (stop.kind === "done") {