@pmelab/gtd 24.1.0 → 24.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.
Files changed (46) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +37 -10
  3. package/claude/hooks/access.ts +25 -0
  4. package/claude/hooks/assist.ts +135 -0
  5. package/claude/hooks/drive.ts +15 -7
  6. package/claude/hooks/judge.ts +27 -0
  7. package/claude/hooks/register.tsx +294 -135
  8. package/claude/hooks/tools.ts +121 -0
  9. package/claude/hooks/wording.ts +2 -35
  10. package/claude/types/index.d.ts +9 -0
  11. package/dist/chunks/{Schema-Dh-mkwkq.mjs → Schema-B5yK7QrT.mjs} +47 -1
  12. package/dist/chunks/{reserved-ejn-LVYO.mjs → reserved-DJwU5C4z.mjs} +1 -1
  13. package/dist/gtd.bundle.mjs +337 -55
  14. package/dist/testkit.mjs +477 -65
  15. package/dist/workflows/feature.mjs +1 -1
  16. package/dist/workflows/fix.mjs +1 -1
  17. package/dist/workflows/review.mjs +1 -1
  18. package/package.json +1 -1
  19. package/src/cli/Cli.test.ts +57 -0
  20. package/src/cli/Cli.ts +89 -9
  21. package/src/cli/History.test.ts +225 -0
  22. package/src/cli/History.ts +143 -0
  23. package/src/cli/Output.ts +68 -0
  24. package/src/cli/Program.test.ts +7 -1
  25. package/src/cli/Program.ts +23 -64
  26. package/src/edge/Edge.test.ts +256 -1
  27. package/src/edge/Edge.ts +217 -39
  28. package/src/edge/index.ts +1 -0
  29. package/src/emit/GitScript.test.ts +27 -0
  30. package/src/emit/GitScript.ts +18 -2
  31. package/src/emit/index.ts +1 -0
  32. package/src/platform/Git.ts +48 -10
  33. package/src/platform/RetainedHistory.test.ts +14 -0
  34. package/src/platform/RetainedHistory.ts +5 -0
  35. package/src/platform/index.ts +7 -1
  36. package/src/replay/Trailers.test.ts +32 -0
  37. package/src/replay/Trailers.ts +17 -0
  38. package/src/step/LandStep.ts +3 -1
  39. package/src/step/RepoSnapshot.ts +7 -1
  40. package/src/step/planStart.test.ts +6 -3
  41. package/src/step/planStep.test.ts +29 -1
  42. package/src/step/planStep.ts +8 -0
  43. package/src/testkit/EmittedScriptRecognizer.ts +1 -1
  44. package/src/testkit/FakeGitOperations.ts +6 -2
  45. package/src/testkit/GitTiers.ts +133 -2
  46. package/src/testkit/InMemRepo.ts +26 -6
@@ -2,39 +2,28 @@ import { atom, read, update } from "claude-code"
2
2
  import type { EngineInterface, Register } from "claude-code"
3
3
 
4
4
  import type { Run, Stop } from "../types"
5
- import { accessDenial } from "./access"
5
+ import { accessDenial, mainDenial } from "./access"
6
6
  import type { AccessDef } from "./access"
7
+ import { wakePrompt, wakes } from "./assist"
7
8
  import { afterReload, drive, isTrue } from "./drive"
8
- import type { Io, Turn, TurnEnd } from "./drive"
9
+ import type { Io, Opening, Turn, TurnEnd } from "./drive"
9
10
  import { doors, enter } from "./doors"
10
11
  import { subagentModel } from "./models"
11
12
  import { personaSpec, resumable } from "./persona"
12
- import { JUDGE_SYSTEM, judgePrompt, judgeRunEnv, toVerdicts } from "./judge"
13
+ import { JUDGE_SYSTEM, judgePrompt, judgeQuestions, judgeRunEnv, toVerdicts } from "./judge"
13
14
  import type { Judgment } from "./judge"
14
15
  import { catchFrom, throwTo } from "./handoff"
15
- import { documents } from "./schema"
16
+ import { documents, readDocument } from "./schema"
16
17
  import { READ_ONLY_GIT, ship } from "./ship"
17
18
  import type { ShipIo } from "./ship"
19
+ import { decideContinue, restId, TOOLS } from "./tools"
18
20
  import {
19
- ANYONE,
20
21
  beatLine,
21
- CANCEL,
22
- CLOSE,
23
- CONTINUE,
24
- gateOptions,
25
- HANDOFF,
26
- handoffQuestion,
27
22
  headline,
28
- LATER,
29
23
  pushText,
30
- runningLine,
31
- question,
32
24
  restartLine,
33
25
  RELOAD_CUT_SCRIPT,
34
- SAFE,
35
- SHIP,
36
- shipQuestion,
37
- STALE,
26
+ runningLine,
38
27
  TONE,
39
28
  } from "./wording"
40
29
 
@@ -53,11 +42,9 @@ const TEN_MINUTES = 600_000
53
42
  // gtd refuses outside the repository root; every process runs there.
54
43
  let root = "."
55
44
  let gateUi: { stop(): void } | undefined
56
- // bumped per rest, so an answer or hand-back for an older rest is ignored
45
+ // bumped per rest, so a browser view of an older rest is ignored
57
46
  let gateToken = 0
58
47
  let lastGate: { state?: string; head: string } | undefined
59
- // the question dialog this mod has open, to mark it stale once gtd moves on
60
- let openAsk: { token: number; text: string } | undefined
61
48
 
62
49
  let stopRequested = false
63
50
  // when the current step began, for the band's elapsed time
@@ -66,6 +53,8 @@ let stepStartedAt = Date.now()
66
53
  // together cannot both pass the isRunning check and start two drivers.
67
54
  let isDriving = false
68
55
  let isShipping = false
56
+ // bumped by every stop, so a queued continue that was mid-check sees it
57
+ let stopCount = 0
69
58
  // subagents answering into a file: what each may touch (see tool.call)
70
59
  const delegates = new Map<string, { file: string; mayRun: RegExp | undefined }>()
71
60
  const ours = new Set<string>()
@@ -229,15 +218,16 @@ async function head($: $) {
229
218
  return (await $.process.run(["git", "rev-parse", "HEAD"], { cwd: root })).stdout.trim()
230
219
  }
231
220
 
232
- async function start($: $, landTurn?: string) {
221
+ async function start($: $, opening: Opening = {}) {
233
222
  if (isDriving) return
234
223
  isDriving = true
235
224
  closeUi()
236
225
  moveOn($)
237
226
  stopRequested = false
238
227
  await findRoot($)
239
- await update($, run, () => ({ isRunning: true, beat: 0 }))
240
- void drive(io($), landTurn)
228
+ // Requests not yet handed to a rest outlive a restart.
229
+ await update($, run, (r) => ({ isRunning: true, beat: 0, notes: r.notes }))
230
+ void drive(io($), opening)
241
231
  .catch((err: unknown): Stop => ({ kind: "error", text: String(err) }))
242
232
  .then(async (stop) => {
243
233
  isDriving = false
@@ -245,14 +235,57 @@ async function start($: $, landTurn?: string) {
245
235
  const isRepeat =
246
236
  stop.kind === "gate" && at.state === lastGate?.state && at.head === lastGate?.head
247
237
  lastGate = at
248
- await update($, run, (r) => ({ ...r, isRunning: false, stop, isRepeat }))
238
+ const rest = restId(at.head, stop.state)
239
+ await update($, run, (r) => ({ ...r, isRunning: false, stop, rest, isRepeat }))
249
240
  $.ui.status(undefined)
250
241
  $.ui.toast(headline(stop))
251
- if (stop.kind === "gate" || stop.kind === "done") await openGate($)
252
- else await push($, stop)
242
+ await push($, stop)
243
+ await wake($)
253
244
  })
254
245
  }
255
246
 
247
+ // A rest that needs the person hands the session's model a prompt to help them
248
+ // through it; the model moves gtd on only through the mod's tools.
249
+ async function wake($: $) {
250
+ const r = await read($, run)
251
+ const stop = r.stop
252
+ if (!stop || !r.rest) return
253
+ const branch = stop.kind === "done" ? await shippable($) : undefined
254
+ if (!wakes(stop, branch)) return
255
+ const questions = stop.isJudge ? await openQuestions($) : undefined
256
+ const text = wakePrompt({
257
+ stop,
258
+ rest: r.rest,
259
+ notes: r.notes,
260
+ isRepeat: r.isRepeat,
261
+ questions,
262
+ branch,
263
+ })
264
+ await update($, run, (x) => ({ ...x, notes: undefined }))
265
+ // Not from inside the hook that started the run: a prompt submitted there
266
+ // would wait on that hook's own turn.
267
+ $.clock.after(0, () => void $.prompt.submit({ text }))
268
+ }
269
+
270
+ async function openQuestions($: $) {
271
+ try {
272
+ const j = readDocument<Judgment>("judge --json", await gtd($, ["judge", "--json"]))
273
+ return judgeQuestions(j).filter((q) => typeof q !== "string")
274
+ } catch {
275
+ return undefined
276
+ }
277
+ }
278
+
279
+ // The branch a finished process can open its pull request from, if any.
280
+ async function shippable($: $) {
281
+ const branch = (
282
+ await $.process.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], { cwd: root })
283
+ ).stdout.trim()
284
+ if (["main", "master", "HEAD"].includes(branch)) return undefined
285
+ const summary = await $.process.run(["gtd", "summary"], { cwd: root })
286
+ return summary.exitCode === 0 ? branch : undefined
287
+ }
288
+
256
289
  // A new process starts from the requirements written to the steering file an
257
290
  // idle rest names; the opening beat captures them like a hand-edit.
258
291
  async function begin($: $, requirements: string) {
@@ -318,38 +351,22 @@ function serveUi($: $, token: number) {
318
351
  return isReady
319
352
  }
320
353
 
321
- // The engine's own question dialog, not a pane: it is the state herdr, the
322
- // desktop app and Remote Control recognize as "waiting on you".
323
- async function openGate($: $) {
354
+ // `gtd ui` on request only: the session's model is the main way through a rest.
355
+ async function browse($: $) {
356
+ const r = await read($, run)
357
+ if (r.isRunning || r.stop?.kind !== "gate") return "gtd is not waiting at a step right now."
358
+ if (r.stop.isJudge) return "This step has no browser view."
359
+ if (r.url) return `Open in your browser: ${r.url}`
324
360
  closeUi()
325
361
  const token = ++gateToken
326
- await update($, run, (r) => ({ ...r, url: undefined, uiNote: undefined }))
327
- const stop = (await read($, run)).stop
328
- if (stop?.kind === "done") return offerShip($, token)
329
- // A judge gate has no screen in gtd ui: it refuses to start there.
330
- if (!stop?.isJudge) await Promise.race([serveUi($, token), $.clock.sleep(8000)])
331
- const r = await read($, run)
332
- if (token !== gateToken || !r.stop) return
333
- await push($, r.stop, r.url)
334
- // Also a transcript line: Remote Control and the phone show no dialog.
335
- if (r.url) $.ui.log(`Open in your browser: ${r.url}`)
336
- const answer = await ask($, token, question(r), gateOptions(r.stop))
337
- if (token !== gateToken) return
338
- if (answer === CONTINUE || answer === SAFE) await proceed($)
339
- if (answer === HANDOFF) await askThrow($)
340
- }
341
-
342
- // The engine's own dialog; undefined when dismissed. Remembered, so it can be
343
- // marked out of date once gtd moves on without it (a mod cannot close it).
344
- async function ask($: $, token: number, text: string, options: string[]) {
345
- openAsk = { token, text }
346
- try {
347
- return await $.ui.ask(text, { header: "gtd", options })
348
- } catch {
349
- return undefined
350
- } finally {
351
- if (openAsk?.text === text) openAsk = undefined
362
+ await update($, run, (x) => ({ ...x, url: undefined, uiNote: undefined }))
363
+ await Promise.race([serveUi($, token), $.clock.sleep(8000)])
364
+ const now = await read($, run)
365
+ if (now.url) {
366
+ if (now.stop) await push($, now.stop, now.url)
367
+ return `Open in your browser: ${now.url}`
352
368
  }
369
+ return `The browser view did not start${now.uiNote ? `: ${now.uiNote}` : "."}`
353
370
  }
354
371
 
355
372
  // A gate is news even when nobody watches the terminal: a push reaches the
@@ -367,13 +384,8 @@ async function push($: $, stop: Stop, url?: string) {
367
384
  }
368
385
  }
369
386
 
370
- async function askThrow($: $) {
371
- const answer = await ask($, gateToken, handoffQuestion, [ANYONE, CANCEL])
372
- if (answer && answer !== CANCEL) await throwNow($, answer === ANYONE ? undefined : answer.trim())
373
- }
374
-
375
387
  async function throwNow($: $, target: string | undefined) {
376
- if ((await read($, run)).isRunning) return $.ui.log("Stop the loop first: /gtd stop.")
388
+ if ((await read($, run)).isRunning) return "Stop the loop first: /gtd stop."
377
389
  closeUi()
378
390
  moveOn($)
379
391
  const b = await docs($).next()
@@ -396,10 +408,8 @@ async function throwNow($: $, target: string | undefined) {
396
408
  uiNote: undefined,
397
409
  stop: { kind: "stopped" as const, text: thrown.text },
398
410
  }))
399
- } else if ((await read($, run)).stop?.kind === "gate") {
400
- // Nothing left: the person is still at the gate they tried to throw.
401
- void openGate($)
402
411
  }
412
+ return thrown.text
403
413
  }
404
414
 
405
415
  async function enterNow($: $, door: string, args: string[]) {
@@ -420,26 +430,17 @@ async function catchNow($: $, ref: string) {
420
430
  if (b.kind === "message") {
421
431
  const stop: Stop = { kind: "gate", text: b.content ?? "", state: b.state, label: b.label }
422
432
  if (b.judge) stop.isJudge = true
423
- await update($, run, (r) => ({ ...r, stop, isRepeat: false }))
424
- void openGate($)
433
+ if (b.file) stop.file = b.file
434
+ if (b.mode) stop.mode = b.mode
435
+ const rest = restId(await head($), b.state)
436
+ await update($, run, (r) => ({ ...r, stop, rest, isRepeat: false }))
437
+ void wake($)
425
438
  } else {
426
439
  await start($)
427
440
  }
428
441
  return caught.text
429
442
  }
430
443
 
431
- // A finished process on a feature branch is one answer away from its pull request.
432
- async function offerShip($: $, token: number) {
433
- const branch = (
434
- await $.process.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], { cwd: root })
435
- ).stdout.trim()
436
- const summary = await $.process.run(["gtd", "summary"], { cwd: root })
437
- if (summary.exitCode !== 0 || ["main", "master", "HEAD"].includes(branch)) return
438
- await push($, { kind: "done", text: "", label: `ready to open a pull request for ${branch}` })
439
- const answer = await ask($, token, shipQuestion(branch), [SHIP, LATER])
440
- if (answer === SHIP && token === gateToken) await shipNow($, false)
441
- }
442
-
443
444
  // Ship's text turns need git to read the process, so they run as a subagent.
444
445
  // Its answer is a file, not its hand-back: a hand-back never reaches a mod.
445
446
  // `mayRun`: the only shell commands the subagent may run; none when absent.
@@ -549,7 +550,7 @@ function shipIo($: $): ShipIo {
549
550
  }
550
551
 
551
552
  async function shipNow($: $, isDry: boolean) {
552
- if (isShipping) return $.ui.log("A pull request is already being prepared.")
553
+ if (isShipping) return "A pull request is already being prepared."
553
554
  isShipping = true
554
555
  await dismiss($)
555
556
  $.ui.status(isDry ? "previewing the pull request…" : "opening the pull request…")
@@ -561,6 +562,7 @@ async function shipNow($: $, isDry: boolean) {
561
562
  $.ui.status(undefined)
562
563
  for (const line of shipped.text.split("\n")) $.ui.log(line)
563
564
  $.ui.toast(shipped.ok ? "pull request ready" : "opening the pull request failed")
565
+ return shipped.text
564
566
  }
565
567
 
566
568
  // Turns a missing CLI or a wrong directory into an instruction, not a stack trace.
@@ -624,17 +626,125 @@ function moveOn($: $) {
624
626
  $.ui.invalidate("ui.render")
625
627
  }
626
628
 
627
- async function proceed($: $) {
629
+ // The one way on from a rest, for the model's tool and the band's Continue
630
+ // alike; a string is why it did not move.
631
+ // `isInTurn`: asked from the model's own turn. Its subagents would then count
632
+ // as that turn's background work and keep it open, so the loop starts once the
633
+ // turn has ended; until then the continue waits in the run state.
634
+ async function proceed(
635
+ $: $,
636
+ rest?: string,
637
+ verdicts?: unknown,
638
+ isInTurn = false,
639
+ ): Promise<string | undefined> {
640
+ const r = await read($, run)
641
+ const go = await decideContinue(r, rest, verdicts, {
642
+ current: () => currentRest($, r.stop?.state),
643
+ validate: () => validateRest($),
644
+ judgment: async () => {
645
+ const doc = await gtd($, ["judge", "--json"]).catch(() => undefined)
646
+ return doc === undefined ? undefined : readDocument<Judgment>("judge --json", doc)
647
+ },
648
+ })
649
+ if (go.refuse !== undefined) return go.refuse
650
+ const opening = r.stop?.isJudge ? { isAnswered: true, verdict: go.verdict } : {}
651
+ if (isInTurn) {
652
+ await update($, run, (x) => ({ ...x, pending: { rest: r.rest ?? "", ...opening } }))
653
+ return undefined
654
+ }
655
+ await leaveRest($)
656
+ await start($, opening)
657
+ return undefined
658
+ }
659
+
660
+ // The rest's own `gtd validate` script, run.
661
+ async function validateRest($: $) {
662
+ const script = await gtd($, ["validate"])
663
+ if (script.trim().startsWith("nothing to validate")) return { ok: true, out: "" }
664
+ const v = await $.process.run(["sh", "-c", script], { cwd: root, timeoutMs: TEN_MINUTES })
665
+ return { ok: v.exitCode === 0, out: `${v.stdout}${v.stderr}`.trim() }
666
+ }
667
+
668
+ async function tellModel($: $, text: string, stops: number) {
669
+ if (stops === stopCount) await $.prompt.submit({ text })
670
+ }
671
+
672
+ async function leaveRest($: $) {
628
673
  moveOn($)
629
674
  closeUi()
630
- await update($, run, (r) => ({ ...r, stop: undefined, url: undefined, uiNote: undefined }))
631
- await start($)
675
+ await update($, run, (x) => ({
676
+ ...x,
677
+ stop: undefined,
678
+ rest: undefined,
679
+ pending: undefined,
680
+ url: undefined,
681
+ uiNote: undefined,
682
+ }))
683
+ }
684
+
685
+ // The rest gtd is at now: HEAD and, when the stop named one, its state.
686
+ // Undefined when gtd cannot say, which checks nothing.
687
+ async function currentRest($: $, state: string | undefined) {
688
+ try {
689
+ const now = state === undefined ? undefined : (await docs($).next()).state
690
+ return restId(await head($), now)
691
+ } catch {
692
+ return undefined
693
+ }
694
+ }
695
+
696
+ // A continue the model asked for, once its turn is over: only if gtd still
697
+ // waits where the person answered, its file still validates (it can change
698
+ // outside this session, or across a reload), and no stop came in meanwhile.
699
+ async function launchPending($: $) {
700
+ const stops = stopCount
701
+ const r = await read($, run)
702
+ const pending = r.pending
703
+ if (!pending) return
704
+ await update($, run, (x) => ({ ...x, pending: undefined }))
705
+ const now = await currentRest($, r.stop?.state)
706
+ if (now !== undefined && now !== pending.rest) {
707
+ $.ui.toast("gtd moved on before it could continue: nothing was landed")
708
+ return
709
+ }
710
+ if (r.stop?.kind === "gate" && !r.stop.isJudge) {
711
+ const v = await validateRest($)
712
+ if (!v.ok) {
713
+ const text = `gtd did not continue: the step's file no longer validates.\n${v.out}\nFix it with the person, then call continue again with rest "${pending.rest}".`
714
+ if (stops === stopCount) $.clock.after(0, () => void tellModel($, text, stops))
715
+ return
716
+ }
717
+ }
718
+ // Nothing awaited between this check and `start`'s own reset of the stop
719
+ // flag, so a stop cannot slip in between; `start` also clears the rest.
720
+ if (stops !== stopCount) return
721
+ await start($, pending.isAnswered ? { isAnswered: true, verdict: pending.verdict } : {})
722
+ }
723
+
724
+ async function note($: $, text: string) {
725
+ const r = await read($, run)
726
+ if (!r.isRunning && !r.stop) return "No gtd process is underway here."
727
+ await update($, run, (x) => ({ ...x, notes: [...(x.notes ?? []), text] }))
728
+ return "Noted for the next step that waits on the person."
729
+ }
730
+
731
+ async function bandContinue($: $) {
732
+ const refused = await proceed($)
733
+ if (!refused) return
734
+ $.ui.toast(refused.split("\n")[0]!)
735
+ for (const line of refused.split("\n")) $.ui.log(line)
632
736
  }
633
737
 
634
738
  async function dismiss($: $) {
635
739
  moveOn($)
636
740
  closeUi()
637
- await update($, run, (r) => ({ ...r, stop: undefined, url: undefined, uiNote: undefined }))
741
+ await update($, run, (r) => ({
742
+ ...r,
743
+ stop: undefined,
744
+ rest: undefined,
745
+ url: undefined,
746
+ uiNote: undefined,
747
+ }))
638
748
  }
639
749
 
640
750
  const DROPPED = "gtd subagent notice"
@@ -643,12 +753,18 @@ async function refreshBand($: $) {
643
753
  if ((await read($, run)).isRunning) $.ui.invalidate("ui.render")
644
754
  }
645
755
 
756
+ // A continue still waiting for the model's turn to end is dropped too.
757
+ async function stopNow($: $) {
758
+ stopRequested = true
759
+ stopCount++
760
+ if (!(await read($, run)).pending) return "Stopping after the current beat."
761
+ await update($, run, (x) => ({ ...x, pending: undefined }))
762
+ return "Cancelled the pending continue; gtd stays where it is."
763
+ }
764
+
646
765
  // `/gtd [verb] …`; anything that is not a verb is a new process's requirements.
647
766
  async function command($: $, arg: string): Promise<string | undefined> {
648
- if (arg === "stop") {
649
- stopRequested = true
650
- return "Stopping after the current beat."
651
- }
767
+ if (arg === "stop") return stopNow($)
652
768
  const problem = await preflight($)
653
769
  if (problem) return problem
654
770
  const [verb = "", ...rest] = arg.split(/\s+/)
@@ -656,15 +772,15 @@ async function command($: $, arg: string): Promise<string | undefined> {
656
772
  case "status":
657
773
  return gtd($, ["next"])
658
774
  case "throw":
659
- void throwNow($, rest[0])
660
- return undefined
775
+ return throwNow($, rest[0])
776
+ case "ui":
777
+ return browse($)
661
778
  case "catch":
662
779
  return rest[0]
663
780
  ? catchNow($, rest[0])
664
781
  : "Name the pull request or branch: /gtd catch <number|branch>."
665
782
  case "ship":
666
- void shipNow($, rest[0] === "-n" || rest[0] === "--dry-run")
667
- return undefined
783
+ return shipNow($, rest[0] === "-n" || rest[0] === "--dry-run")
668
784
  case "":
669
785
  return resume($)
670
786
  default:
@@ -703,12 +819,70 @@ async function resumeAfterReload($: $, was: Run) {
703
819
  state: was.state,
704
820
  label: was.label,
705
821
  }
706
- await update($, run, (r) => ({ ...r, stop }))
822
+ const rest = restId(await head($), was.state)
823
+ await update($, run, (r) => ({ ...r, stop, rest }))
707
824
  $.ui.status(undefined)
708
825
  $.ui.toast(headline(stop))
826
+ await wake($)
709
827
  return
710
828
  }
711
- await start($, next === "land" ? inflight?.memory : undefined)
829
+ await start($, next === "land" ? { landTurn: inflight?.memory } : {})
830
+ }
831
+
832
+ // Whether an agent is one of gtd's workers or spawned (at any depth) by one;
833
+ // any other agent works for the session's model and shares its limits.
834
+ const lineage = new Map<string, boolean>()
835
+ async function isOurs($: $, id: string) {
836
+ if (ours.has(id)) return true
837
+ const known = lineage.get(id)
838
+ if (known !== undefined) return known
839
+ const byId = new Map((await $.agent.list()).map((a) => [a.id, a]))
840
+ let parent = byId.get(id)?.parentId
841
+ for (let hops = 0; parent && hops < 16; hops++) {
842
+ if (ours.has(parent)) break
843
+ parent = byId.get(parent)?.parentId
844
+ }
845
+ const isWorker = parent !== undefined && ours.has(parent)
846
+ lineage.set(id, isWorker)
847
+ return isWorker
848
+ }
849
+
850
+ // What a subagent this mod spawned may not do: write outside its step's
851
+ // access, or, answering into a file, anything but that.
852
+ function subagentDenial(agentId: string, e: Record<string, unknown>) {
853
+ if (!ours.has(agentId)) return undefined
854
+ const access = accesses.get(agentId)
855
+ const denial = access && accessDenial(String(e.tool), e, access, root)
856
+ if (denial) return denial
857
+ const delegated = delegates.get(agentId)
858
+ if (!delegated) return undefined
859
+ if (e.tool === "Write" && e.file_path !== delegated.file) {
860
+ return `Write only your answer, to ${delegated.file}.`
861
+ }
862
+ if (e.tool === "Bash" && !delegated.mayRun?.test(String(e.command).trim())) {
863
+ return "Only read-only git commands, without shell syntax, are allowed here."
864
+ }
865
+ return undefined
866
+ }
867
+
868
+ // What the main session may write now (see mainDenial).
869
+ async function phase($: $) {
870
+ const r = await read($, run)
871
+ const stop = r.stop
872
+ const atQa = stop?.kind === "gate" && !stop.isJudge
873
+ // A continue waiting for the turn to end is as good as running: the file
874
+ // it validated must not change before it lands.
875
+ const isRunning = r.isRunning || r.pending !== undefined
876
+ return { isRunning, mode: atQa ? stop.mode : undefined, file: stop?.file }
877
+ }
878
+
879
+ // A tool's answer. For `continue`, any text is a refusal the model reads as an
880
+ // error; the other tools report what happened either way.
881
+ function answer(text: string | undefined, isReport = false) {
882
+ if (text === undefined) {
883
+ return { result: "gtd continues as soon as this turn ends: end your turn now." }
884
+ }
885
+ return isReport ? { result: text } : { deny: text }
712
886
  }
713
887
 
714
888
  export const register: Register = (on) => {
@@ -716,16 +890,17 @@ export const register: Register = (on) => {
716
890
  // The band's elapsed time ticks while a step runs.
717
891
  $.clock.every(10_000, () => void refreshBand($))
718
892
  for (const id of await read($, agents)) ours.add(id)
719
- // A reload drops the loop mid-beat and closes an open dialog; gtd keeps
720
- // its state in git, so pick both up again.
893
+ // A reload drops the loop mid-beat; gtd keeps its state in git, so pick it
894
+ // up again. A rest needs nothing: the model already holds its prompt.
721
895
  const was = await read($, run)
722
896
  if (was.isRunning) void resumeAfterReload($, was)
723
- else if (was.stop?.kind === "gate") void findRoot($).then(() => openGate($))
897
+ else void findRoot($).then(() => launchPending($))
898
+ for (const tool of TOOLS) await $.tool.register(tool)
724
899
  await $.command.register({
725
900
  name: "gtd",
726
901
  description: "Drive gtd until it rests on you; pass requirements to start a new process",
727
902
  argumentHint:
728
- "[requirements | <door> [args] | stop | status | ship [-n] | throw [@user] | catch <pr|branch>]",
903
+ "[requirements | <door> [args] | stop | status | ui | ship [-n] | throw [@user] | catch <pr|branch>]",
729
904
  immediate: true,
730
905
  })
731
906
  return next(e)
@@ -737,6 +912,7 @@ export const register: Register = (on) => {
737
912
  }).catch(($, e, next) => ({ text: next.error.message }))
738
913
 
739
914
  on("turn.complete", ($, e, next) => {
915
+ if (!e.agentId) void launchPending($)
740
916
  if (e.agentId && ours.has(e.agentId)) {
741
917
  const end: TurnEnd =
742
918
  e.reason === "answer" ? { ok: true } : { ok: false, why: `agent turn ended: ${e.reason}` }
@@ -750,20 +926,13 @@ export const register: Register = (on) => {
750
926
 
751
927
  // The loop lands a turn when it ends; work left running in the background
752
928
  // would still be writing the tree gtd is committing.
753
- on("tool.call", ($, e, next) => {
754
- if (!e.agentId || !ours.has(e.agentId)) return next(e)
755
- const access = accesses.get(e.agentId)
756
- const denial = access && accessDenial(String(e.tool), e, access, root)
929
+ on("tool.call", async ($, e, next) => {
930
+ const isWorker = e.agentId !== undefined && (await isOurs($, e.agentId))
931
+ const denial = isWorker
932
+ ? subagentDenial(e.agentId!, e)
933
+ : mainDenial(String(e.tool), e, await phase($), root)
757
934
  if (denial) return { deny: denial }
758
- const delegated = delegates.get(e.agentId)
759
- if (delegated) {
760
- if (e.tool === "Write" && e.file_path !== delegated.file) {
761
- return { deny: `Write only your answer, to ${delegated.file}.` }
762
- }
763
- if (e.tool === "Bash" && !delegated.mayRun?.test(String(e.command).trim())) {
764
- return { deny: "Only read-only git commands, without shell syntax, are allowed here." }
765
- }
766
- }
935
+ if (!e.agentId || !ours.has(e.agentId)) return next(e)
767
936
  if (e.tool === "Bash") {
768
937
  return next({ ...e, run_in_background: false, timeout: e.timeout ?? TEN_MINUTES })
769
938
  }
@@ -781,29 +950,19 @@ export const register: Register = (on) => {
781
950
  return next(e)
782
951
  })
783
952
 
784
- // A dialog gtd has moved past still waits for an answer; say it is stale.
785
- on("ui.render", { component: "AskUserQuestion" }, ($, e, next) => {
786
- const [first] = e.props.questions as { question?: string }[]
787
- if (!openAsk || openAsk.token === gateToken || first?.question !== openAsk.text) return next(e)
788
- return next({
789
- ...e,
790
- props: {
791
- ...e.props,
792
- questions: [
793
- {
794
- ...first,
795
- question: STALE,
796
- header: "gtd",
797
- multiSelect: false,
798
- options: [
799
- { label: CLOSE, description: "Nothing happens." },
800
- { label: LATER, description: "Nothing happens." },
801
- ],
802
- },
803
- ],
804
- },
805
- })
806
- })
953
+ on("tool.call", { tool: "mcp__gtd__continue" }, async ($, e) =>
954
+ answer(await proceed($, String(e.rest ?? ""), e.verdicts, true)),
955
+ )
956
+ on("tool.call", { tool: "mcp__gtd__note" }, async ($, e) =>
957
+ answer(await note($, String(e.text ?? "")), true),
958
+ )
959
+ on("tool.call", { tool: "mcp__gtd__handoff" }, async ($, e) =>
960
+ answer(await throwNow($, typeof e.to === "string" && e.to ? e.to : undefined), true),
961
+ )
962
+ on("tool.call", { tool: "mcp__gtd__ship" }, async ($, e) =>
963
+ answer(await shipNow($, e.dryRun === true), true),
964
+ )
965
+ on("tool.call", { tool: "mcp__gtd__browser" }, async ($) => answer(await browse($), true))
807
966
 
808
967
  on("agent.offer", ($, e, next) => (e.agent.startsWith("gtd:") ? { isOffered: false } : next(e)))
809
968
 
@@ -832,7 +991,7 @@ export const register: Register = (on) => {
832
991
  <Text color={TONE[stop.kind]} bold>
833
992
  gtd {headline(stop)}
834
993
  </Text>
835
- <Button key="go" label="Continue" variant="primary" onPress={() => proceed($)} />
994
+ <Button key="go" label="Continue" variant="primary" onPress={() => bandContinue($)} />
836
995
  <Button key="dismiss" label="Dismiss" role="dismiss" onPress={() => dismiss($)} />
837
996
  </Box>
838
997
  {ui && <Text dimColor>{ui}</Text>}