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