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