@pmelab/gtd 24.1.0 → 24.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gtd",
3
- "version": "24.1.0",
3
+ "version": "24.2.0",
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
@@ -329,14 +329,17 @@ Then, in any repository:
329
329
  ```
330
330
 
331
331
  That starts a process from your requirements and drives it until it needs you.
332
- `/gtd fix` and `/gtd review [base]` take the two doors described below. Every
333
- human rest opens Claude Code's own question dialog, with a link to `gtd ui` for
334
- the step; review there, then answer **I'm done, continue**. When the process
335
- finishes, **Yes, open the pull request** (or `/gtd ship`) squashes it into one
336
- commit and opens it. A process can change hands at any gate: **Hand off to
337
- someone else** (or `/gtd throw @dev`) opens a draft pull request assigned to
338
- them, and `/gtd catch <pr>` picks it up exactly where it waits, so whoever wrote
339
- the requirements can hand the architecture to someone else. See
332
+ `/gtd fix` and `/gtd review [base]` take the two doors described below. At every
333
+ human rest Claude itself takes over: it walks you through the open questions one
334
+ at a time, answers your own questions about the plan or the diff, records what
335
+ you decide in the steering file the way the step asks, and moves the process on
336
+ only when you say so. While a step runs you can keep asking; requests for
337
+ changes wait for the next rest. When the process finishes, Claude offers to
338
+ squash it into one commit and open its pull request (or run `/gtd ship`). A
339
+ process can change hands at any gate: ask Claude to hand it off (or run
340
+ `/gtd throw @dev`) and it opens a draft pull request assigned to them, and
341
+ `/gtd catch <pr>` picks it up exactly where it waits, so whoever wrote the
342
+ requirements can hand the architecture to someone else. See
340
343
  [Inside Claude Code](https://github.com/pmelab/gtd/blob/main/docs/driver.md#inside-claude-code-the-gtd-mod).
341
344
 
342
345
  ### Then let an agent build your own
@@ -63,3 +63,28 @@ function searchDenial(raw: unknown, globs: string[] | null, root: string) {
63
63
  })
64
64
  return inside ? undefined : deny("read", rel || ".", globs)
65
65
  }
66
+
67
+ // What the main session may write while gtd drives: nothing while a step runs
68
+ // (gtd commits the tree when it ends), only the steering file at a rest whose
69
+ // mode is `qa`, anything otherwise. A review rest takes code edits as feedback.
70
+ export type MainPhase = { isRunning: boolean; mode?: string; file?: string }
71
+
72
+ export const RUNNING_DENIAL =
73
+ "gtd is running a step and commits the working tree when it ends: don't edit files now. Answer read-only, or record the request with mcp__gtd__note for the next step that waits on the person. /gtd stop stops the run."
74
+
75
+ export function mainDenial(
76
+ tool: string,
77
+ input: Record<string, unknown>,
78
+ phase: MainPhase,
79
+ root: string,
80
+ ): string | undefined {
81
+ const key = WRITE[tool]
82
+ if (!key) return undefined
83
+ if (phase.isRunning) return RUNNING_DENIAL
84
+ if (phase.mode !== "qa" || !phase.file) return undefined
85
+ const raw = input[key]
86
+ const rel = typeof raw === "string" ? inRepo(raw, root) : undefined
87
+ return rel === phase.file
88
+ ? undefined
89
+ : `At this step only ${phase.file} may change: record the request there, as the step's instructions say.`
90
+ }
@@ -0,0 +1,135 @@
1
+ // What the main session's model is told when gtd stops for the person. gtd
2
+ // decides every step; the model only helps the person do what the rest asks,
3
+ // through the rest's own instructions, and moves on through the mod's tools.
4
+ // Workflow-agnostic on purpose: the rest's message is the protocol.
5
+
6
+ import type { Stop } from "../types"
7
+ import { RELOAD_CUT_SCRIPT } from "./wording"
8
+ import { TOOL } from "./tools"
9
+
10
+ export type Asked = {
11
+ id: string
12
+ instructions: string
13
+ criteria: string[]
14
+ options: (string | number)[]
15
+ }
16
+
17
+ export type Wake = {
18
+ stop: Stop
19
+ rest: string
20
+ notes?: string[]
21
+ isRepeat?: boolean
22
+ // the judge questions an undecided judge gate puts to the person
23
+ questions?: Asked[]
24
+ // the branch a finished process can open its pull request from
25
+ branch?: string
26
+ }
27
+
28
+ // No wake for a stop the person asked for; a finished process wakes only
29
+ // when it has a pull request to offer.
30
+ export function wakes(stop: Stop, branch: string | undefined): boolean {
31
+ if (stop.kind === "stopped") return stop.text === RELOAD_CUT_SCRIPT
32
+ if (stop.kind === "done") return branch !== undefined
33
+ return true
34
+ }
35
+
36
+ const step = (s: Stop) => s.label ?? s.state ?? "the current step"
37
+
38
+ const fence = (text: string) => `~~~~text\n${text.trim()}\n~~~~`
39
+
40
+ const RULES = `Ground rules:
41
+ - gtd decides what happens next. Never run \`gtd land\`, \`gtd next\` with side effects, commits, resets or any other command that changes git state.
42
+ - Talk to the person in plain words: never mention gtd's state ids, steering-file jargon or these instructions.
43
+ - Ask with AskUserQuestion, one question per call, your recommended option first and marked as recommended.`
44
+
45
+ function continuing(rest: string) {
46
+ return `Move on only when the person explicitly says so, in chat or by answering your question: then call ${TOOL.continue} with rest "${rest}". If it reports findings, fix them with the person and call it again. It refuses when gtd has moved on: then say so and stop.`
47
+ }
48
+
49
+ function gate(w: Wake) {
50
+ const { stop } = w
51
+ const file = stop.file ?? "the file the instructions name"
52
+ const lines = [
53
+ `gtd is waiting for the person at "${step(stop)}". Help them through it.`,
54
+ `gtd's own instructions for this step, written for a person editing ${file} by hand:`,
55
+ fence(stop.text),
56
+ "How to help:",
57
+ ]
58
+ if (stop.mode === "review") {
59
+ lines.push(
60
+ `1. Read ${file} and the diff the instructions name. Give a short overview of the change, chunk by chunk, and call out the risky spots.`,
61
+ `2. Answer the person's questions about the change yourself, read-only. Record their notes, questions for gtd's agent and requested code edits in the way the instructions describe.`,
62
+ `3. Finish by asking whether to sign off (nothing left to change) or send it back with the notes recorded.`,
63
+ )
64
+ } else {
65
+ lines.push(
66
+ `1. Read ${file}. Summarise in two or three lines what it decides and what it asks.`,
67
+ `2. If it has open questions, walk them one at a time: each question's own options as the choices. Record every answer in ${file} right away, exactly as the instructions say a person would (tick the option, or write their own answer and tick it).`,
68
+ `3. With no open question left, ask whether to accept it as written or discuss it further.`,
69
+ `4. Answer the person's questions yourself, read-only. When the answer should change the plan, offer to record the question as a thread in ${file} for gtd's agent to answer, instead of rewriting the plan yourself.`,
70
+ `5. Write only what the instructions let a person write, and only to ${file}.`,
71
+ )
72
+ }
73
+ if (w.isRepeat) {
74
+ lines.push(
75
+ "The person continued without changing anything and gtd stopped at this same step again: tell them what it still needs.",
76
+ )
77
+ }
78
+ lines.push(continuing(w.rest))
79
+ return lines
80
+ }
81
+
82
+ function judged(w: Wake) {
83
+ const lines = [
84
+ `gtd could not decide "${step(w.stop)}" on its own and needs the person's judgment.`,
85
+ "What gtd says about this step:",
86
+ fence(w.stop.text),
87
+ ]
88
+ for (const q of w.questions ?? []) {
89
+ lines.push(
90
+ `Question "${q.id}": ${q.instructions}\n${q.criteria.map((c) => ` - ${c}`).join("\n")}\n Answers: ${q.options.map((o) => JSON.stringify(o)).join(", ")}`,
91
+ )
92
+ }
93
+ lines.push(
94
+ "Read whatever the questions are about, then put each one to the person, one at a time, with its answers as the choices and your own reading first.",
95
+ `Once all are answered and the person agrees, call ${TOOL.continue} with rest "${w.rest}" and verdicts mapping each question id to the chosen answer, exactly as listed. If the person would rather not decide, call it without verdicts: gtd then takes the cautious route.`,
96
+ )
97
+ return lines
98
+ }
99
+
100
+ function trouble(w: Wake) {
101
+ const { stop } = w
102
+ const why = stop.kind === "stalled" ? "is stuck" : "stopped on a problem"
103
+ return [
104
+ `gtd ${why} at "${step(stop)}":`,
105
+ fence(stop.text),
106
+ "Find the cause (read the error, the log it names, the working tree) and explain it in plain words. Propose a fix and make it only with the person's consent.",
107
+ continuing(w.rest),
108
+ ]
109
+ }
110
+
111
+ function finished(w: Wake) {
112
+ return [
113
+ `gtd finished the process on branch ${w.branch}.`,
114
+ `Tell the person in two or three lines what was built (read the branch's commits), then ask whether to combine it into one commit and open its pull request. On yes, call ${TOOL.ship}.`,
115
+ ]
116
+ }
117
+
118
+ export function wakePrompt(w: Wake): string {
119
+ const { stop } = w
120
+ const body =
121
+ stop.kind === "done"
122
+ ? finished(w)
123
+ : stop.kind === "gate"
124
+ ? stop.isJudge
125
+ ? judged(w)
126
+ : gate(w)
127
+ : trouble(w)
128
+ const notes = w.notes?.length
129
+ ? [
130
+ `While gtd ran, the person asked for the following. Fold each in now in the way this step allows, or tell them why it does not fit here:\n${w.notes.map((n) => `- ${n}`).join("\n")}`,
131
+ ]
132
+ : []
133
+ 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
+ return [...body, ...notes, RULES, extras].join("\n\n")
135
+ }
@@ -11,6 +11,7 @@ export type Beat = {
11
11
  state?: string
12
12
  label?: string
13
13
  file?: string
14
+ mode?: string
14
15
  content?: string
15
16
  log?: string
16
17
  memory?: string
@@ -69,11 +70,15 @@ type Acted = { stop: Stop } | { stop?: undefined; verdict?: string }
69
70
  const where = (b: Beat) => ({ state: b.state, label: b.label })
70
71
  const failed = (b: Beat, text: string): Acted => ({ stop: { kind: "error", text, ...where(b) } })
71
72
 
73
+ // How the opening beat is taken. `landTurn` names a memory scope whose agent
74
+ // turn already ran (a reload cut the loop off while it did): the opening prompt
75
+ // beat lands it, not repeats it. `isAnswered` is a person's answer to an opening
76
+ // judge gate: their `verdict`, or none to land it untouched (the cautious default).
77
+ export type Opening = { landTurn?: string; isAnswered?: boolean; verdict?: string }
78
+
72
79
  // The reference sh driver from docs/driver.md, beat for beat. gtd decides;
73
80
  // this only executes what `gtd next` and `gtd land` print.
74
- // `landTurn` names a memory scope whose agent turn already ran (a reload cut
75
- // the loop off while it did): the opening prompt beat lands it, not repeats it.
76
- export async function drive(io: Io, landTurn?: string): Promise<Stop> {
81
+ export async function drive(io: Io, opening: Opening = {}): Promise<Stop> {
77
82
  // A judge gate answered on the previous beat that rests on the same state
78
83
  // again moved nothing: hand it to the human rather than pay for it twice.
79
84
  let judged: string | undefined
@@ -85,7 +90,7 @@ export async function drive(io: Io, landTurn?: string): Promise<Stop> {
85
90
  // decision, and an `acceptClean` first step must get to fire.
86
91
  if (beat > 1 && isTrue(b.idle)) return { kind: "done", text: await io.plain(), ...where(b) }
87
92
 
88
- const acted = await act(io, b, beat, judged, beat === 1 ? landTurn : undefined)
93
+ const acted = await act(io, b, beat, judged, beat === 1 ? opening : {})
89
94
  if (acted.stop) return acted.stop
90
95
  judged = acted.verdict ? b.state : undefined
91
96
  const landed = await land(io, b, acted.verdict, true)
@@ -98,20 +103,22 @@ async function act(
98
103
  b: Beat,
99
104
  beat: number,
100
105
  judged: string | undefined,
101
- landTurn: string | undefined,
106
+ opening: Opening,
102
107
  ): Promise<Acted> {
103
108
  switch (b.kind) {
104
109
  case "stalled":
105
110
  return { stop: { kind: "stalled", text: b.content ?? "", ...where(b) } }
106
111
  case "message":
107
- return message(io, b, beat, judged)
112
+ return opening.isAnswered && b.judge
113
+ ? { verdict: opening.verdict }
114
+ : message(io, b, beat, judged)
108
115
  case "capture":
109
116
  return {}
110
117
  case "script":
111
118
  await io.sh(b.content ?? "", b.log)
112
119
  return {}
113
120
  case "prompt":
114
- return agent(io, b, landTurn)
121
+ return agent(io, b, opening.landTurn)
115
122
  default:
116
123
  return failed(b, `unknown beat kind '${b.kind}'`)
117
124
  }
@@ -126,6 +133,7 @@ async function message(io: Io, b: Beat, beat: number, judged: string | undefined
126
133
  const stop: Stop = { kind: "gate", text: b.content ?? "", ...where(b) }
127
134
  if (b.judge) stop.isJudge = true
128
135
  if (b.file) stop.file = b.file
136
+ if (b.mode) stop.mode = b.mode
129
137
  return { stop }
130
138
  }
131
139
 
@@ -111,3 +111,30 @@ export function toVerdicts(j: Judgment, reply: unknown): Verdict[] | string {
111
111
  }
112
112
  return out
113
113
  }
114
+
115
+ // Each question as a person is asked it: its instructions, criteria and the
116
+ // answers it takes; a string is why it cannot be asked.
117
+ export function judgeQuestions(j: Judgment) {
118
+ return j.questions.map((q) => {
119
+ const levels = levelsOf(q)
120
+ if (typeof levels === "string") return levels
121
+ const meaning = levels.map(([label, text], i) =>
122
+ q.primitive === "score" ? `${i + 1}: ${text}` : `${label}: ${text}`,
123
+ )
124
+ return {
125
+ id: q.id,
126
+ instructions: q.instructions,
127
+ criteria: q.primitive === "noul" ? [q.criteria] : meaning,
128
+ options: optionsOf(q, levels),
129
+ }
130
+ })
131
+ }
132
+
133
+ // A person's `{ <id>: <answer> }` as gtd's verdicts, held with certainty.
134
+ export function humanVerdicts(j: Judgment, answers: unknown): Verdict[] | string {
135
+ if (typeof answers !== "object" || answers === null) return "verdicts must be an object"
136
+ const reply = Object.fromEntries(
137
+ Object.entries(answers).map(([id, answer]) => [id, { answer, p: 1 }]),
138
+ )
139
+ return toVerdicts(j, reply)
140
+ }
@@ -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>}
@@ -0,0 +1,121 @@
1
+ // The only way the main session's model moves gtd: tools the mod serves. Each
2
+ // checks the run before it acts, so a model cannot skip a rest or answer one
3
+ // gtd has already left.
4
+
5
+ import type { Run } from "../types"
6
+ import { humanVerdicts } from "./judge"
7
+ import type { Judgment } from "./judge"
8
+
9
+ export const TOOL = {
10
+ continue: "mcp__gtd__continue",
11
+ note: "mcp__gtd__note",
12
+ handoff: "mcp__gtd__handoff",
13
+ ship: "mcp__gtd__ship",
14
+ browser: "mcp__gtd__browser",
15
+ } as const
16
+
17
+ export const TOOLS = [
18
+ {
19
+ name: "continue",
20
+ description:
21
+ "Moves the gtd process on from the step it waits at. Call it ONLY after the person explicitly said to continue. `rest` is the rest id gtd gave you for this step. `verdicts` only at a step gtd's judge could not decide: each question id mapped to the person's chosen answer. Refused when gtd has moved on, while a step runs, or when the step's file does not validate; the result says why.",
22
+ inputSchema: {
23
+ type: "object",
24
+ properties: {
25
+ rest: { type: "string" },
26
+ verdicts: { type: "object", additionalProperties: { type: ["string", "number"] } },
27
+ },
28
+ required: ["rest"],
29
+ },
30
+ },
31
+ {
32
+ name: "note",
33
+ description:
34
+ "Records a request the person makes while gtd runs a step (a change, something to consider), for the next step that waits on them. Use it instead of editing files while gtd runs.",
35
+ inputSchema: {
36
+ type: "object",
37
+ properties: { text: { type: "string" } },
38
+ required: ["text"],
39
+ },
40
+ },
41
+ {
42
+ name: "handoff",
43
+ description:
44
+ "Hands the gtd process to someone else: pushes the branch and opens or refreshes a draft pull request assigned to them. Call it only when the person asks. `to`: their GitHub username; omit it to leave the process for anyone.",
45
+ inputSchema: { type: "object", properties: { to: { type: "string" } } },
46
+ },
47
+ {
48
+ name: "ship",
49
+ description:
50
+ "Combines the finished gtd process into one commit, pushes it and opens or updates its pull request. Call it only after the person agreed. `dryRun` previews without writing anything.",
51
+ inputSchema: { type: "object", properties: { dryRun: { type: "boolean" } } },
52
+ },
53
+ {
54
+ name: "browser",
55
+ description:
56
+ "Starts gtd's browser view of the step it waits at and returns its address. Call it only when the person wants it.",
57
+ inputSchema: { type: "object", properties: {} },
58
+ },
59
+ ]
60
+
61
+ // A rest is the state gtd waits at on a given HEAD: once either moves, an
62
+ // answer meant for it no longer applies. Survives a mod reload, unlike a counter.
63
+ export const restId = (head: string, state: string | undefined) =>
64
+ `${head.slice(0, 12)}:${state ?? "?"}`
65
+
66
+ export type Proceed = { refuse: string } | { refuse?: undefined; verdict?: string }
67
+
68
+ export type ContinueIo = {
69
+ // the rest gtd is at now; undefined when it cannot say
70
+ current(): Promise<string | undefined>
71
+ // the rest's own `gtd validate` script, run; ok when there is nothing to check
72
+ validate(): Promise<{ ok: boolean; out: string }>
73
+ judgment(): Promise<Judgment | undefined>
74
+ }
75
+
76
+ // What `continue` may do from here. `rest` undefined is the band's Continue,
77
+ // which always means the rest on screen.
78
+ export async function decideContinue(
79
+ run: Run,
80
+ rest: string | undefined,
81
+ verdicts: unknown,
82
+ io: ContinueIo,
83
+ ): Promise<Proceed> {
84
+ if (run.isRunning) return { refuse: "gtd is already running a step." }
85
+ const stop = run.stop
86
+ if (!stop) return { refuse: "Nothing is waiting: gtd has no rest open. /gtd drives it." }
87
+ if (stop.kind === "done") {
88
+ return { refuse: `The process is finished: ${TOOL.ship} opens its pull request.` }
89
+ }
90
+ if (rest !== undefined && rest !== run.rest) {
91
+ return {
92
+ refuse: "gtd has moved on from that step; it no longer waits there. Nothing was changed.",
93
+ }
94
+ }
95
+ // Another terminal or a branch switch can move gtd under a waiting answer.
96
+ const now = await io.current()
97
+ if (now !== undefined && now !== run.rest) {
98
+ return {
99
+ refuse:
100
+ "gtd is no longer at that step (it changed outside this session): /gtd shows where it is.",
101
+ }
102
+ }
103
+ if (stop.kind === "gate" && stop.isJudge) return judged(verdicts, io)
104
+ if (verdicts !== undefined) return { refuse: "This step takes no verdicts." }
105
+ if (stop.kind === "gate") {
106
+ const v = await io.validate()
107
+ if (!v.ok) return { refuse: `The step's file does not validate yet:\n${v.out}` }
108
+ }
109
+ return {}
110
+ }
111
+
112
+ // No verdicts lands the gate untouched: the cautious default.
113
+ async function judged(verdicts: unknown, io: ContinueIo): Promise<Proceed> {
114
+ if (verdicts === undefined) return {}
115
+ const j = await io.judgment()
116
+ if (!j) return { refuse: "gtd no longer has a judgment open here." }
117
+ const v = humanVerdicts(j, verdicts)
118
+ return typeof v === "string"
119
+ ? { refuse: `Those verdicts don't fit: ${v}` }
120
+ : { verdict: JSON.stringify(v) }
121
+ }
@@ -1,46 +1,13 @@
1
- // Everything the mod says to a person. Plain words: no gtd state ids, no tool
2
- // names, and options that say what choosing them does.
1
+ // Everything the mod itself shows a person. Plain words: no gtd state ids.
3
2
 
4
- import type { Run, Stop } from "../types"
3
+ import type { Stop } from "../types"
5
4
  import type { Beat } from "./drive"
6
5
 
7
- export const CONTINUE = "I'm done, continue"
8
- export const SAFE = "Continue with the safe choice"
9
- export const HANDOFF = "Hand off to someone else"
10
- export const LATER = "Not now"
11
- export const SHIP = "Yes, open the pull request"
12
- export const ANYONE = "Anyone on the team"
13
- export const CANCEL = "Cancel"
14
- export const CLOSE = "Close"
15
-
16
- export const gateOptions = (stop: Stop) => [stop.isJudge ? SAFE : CONTINUE, HANDOFF, LATER]
17
-
18
6
  const step = (s: { label?: string; state?: string }) => s.label ?? s.state ?? "the next step"
19
7
 
20
- export function question(r: Run) {
21
- const stop = r.stop as Stop
22
- if (stop.isJudge) {
23
- return `gtd can't decide this step on its own: ${step(stop)}. Continue with the safe choice, or hand it to someone who can decide?`
24
- }
25
- const how = r.url
26
- ? `Open it in your browser: ${r.url}`
27
- : `Make your changes in ${stop.file ?? "the files it names"}.`
28
- const again = r.isRepeat ? "You haven't changed anything yet, so gtd is still waiting. " : ""
29
- return `${again}gtd needs your input: ${step(stop)}. ${how} Ready to continue?`
30
- }
31
-
32
- export const STALE =
33
- "This question is out of date: gtd has already moved on. Choose Close to dismiss it."
34
-
35
8
  export const RELOAD_CUT_SCRIPT =
36
9
  "a reload cut a script off mid-run — check the working tree, then Continue to run it again"
37
10
 
38
- export const handoffQuestion =
39
- "Who should take over from here? Type their GitHub username, or let anyone on the team pick it up."
40
-
41
- export const shipQuestion = (branch: string) =>
42
- `gtd has finished the work on ${branch}. Combine it into a single commit and open (or update) its pull request for review?`
43
-
44
11
  export function headline(s: Stop) {
45
12
  const where = step(s)
46
13
  switch (s.kind) {
@@ -6,6 +6,8 @@ export type Stop = {
6
6
  isJudge?: boolean
7
7
  // the steering file the rest asks a person to edit
8
8
  file?: string
9
+ // the steering file's mode (`qa`, `review`), which bounds what may be written at the rest
10
+ mode?: string
9
11
  }
10
12
 
11
13
  export type Run = {
@@ -14,6 +16,13 @@ export type Run = {
14
16
  state?: string
15
17
  label?: string
16
18
  stop?: Stop
19
+ // the rest a person is helped at: HEAD and state, so an answer to an older one is refused
20
+ rest?: string
21
+ // a continue the model asked for, started once its turn ends (and only if
22
+ // gtd still rests at `rest`); kept here so a reload does not lose a verdict
23
+ pending?: { rest: string; isAnswered?: boolean; verdict?: string | undefined }
24
+ // requests a person made while a step ran, for the next rest's assistant
25
+ notes?: string[]
17
26
  // `gtd ui`'s address while it serves the current rest
18
27
  url?: string
19
28
  // why `gtd ui` is not serving it (refused, crashed)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "24.1.0",
3
+ "version": "24.2.0",
4
4
  "private": false,
5
5
  "description": "Git-aware CLI that emits the next prompt for an autonomous coding agent based on the current repository state",
6
6
  "bin": {